mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-17 13:06:21 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6deb8650cc |
@@ -223,29 +223,6 @@ OPENAI_API_KEY=sk-xxxxxxxxx
|
||||
# The LobeChat agents market index url
|
||||
# AGENTS_INDEX_URL=https://chat-agents.lobehub.com
|
||||
|
||||
# #######################################
|
||||
# ######### Cloud Sandbox Service #######
|
||||
# #######################################
|
||||
|
||||
# Sandbox provider for built-in code execution, shell, file operations, and export.
|
||||
# Supported values: market, onlyboxes
|
||||
# SANDBOX_PROVIDER=market
|
||||
|
||||
# Required when SANDBOX_PROVIDER=onlyboxes. Base URL of the Onlyboxes console API, without /api/v1.
|
||||
# ONLYBOXES_BASE_URL=https://onlyboxes.example.com
|
||||
|
||||
# Required when SANDBOX_PROVIDER=onlyboxes. Must match Onlyboxes CONSOLE_JIT_SIGNING_KEY.
|
||||
# ONLYBOXES_JIT_SIGNING_KEY=onlyboxes-jit-signing-secret
|
||||
|
||||
# Optional JIT token issuer. Defaults to APP_URL.
|
||||
# ONLYBOXES_JIT_ISSUER=https://lobehub.example.com
|
||||
|
||||
# Optional JIT token TTL in seconds.
|
||||
# ONLYBOXES_JIT_TTL_SEC=1800
|
||||
|
||||
# Optional terminal session lease in seconds for the Onlyboxes provider.
|
||||
# ONLYBOXES_LEASE_TTL_SEC=900
|
||||
|
||||
# #######################################
|
||||
# ########### Plugin Service ############
|
||||
# #######################################
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
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
|
||||
@@ -125,9 +125,8 @@ bun run type-check
|
||||
### i18n
|
||||
|
||||
- Add keys to a namespace file under `src/locales/default/` (e.g. `agent.ts`, `auth.ts`)
|
||||
- Ship en-US and zh-CN by hand in the same PR: write the English source in `src/locales/default/*.ts` and mirror it to `locales/en-US/`; hand-translate `locales/zh-CN/`. Leave all other locales to CI.
|
||||
- Don't run `pnpm i18n` manually by default — a daily CI workflow (`auto-i18n.yml`) runs it and opens an automated translation PR for any missing keys.
|
||||
- Run `pnpm i18n` manually only when your branch needs the translated locales immediately, instead of waiting for the daily job (slow; requires `OPENAI_API_KEY`). Note it only fills keys missing from other locales — value-only edits never need it.
|
||||
- For dev preview: translate `locales/zh-CN/` and `locales/en-US/`
|
||||
- `pnpm i18n` is slow; run it manually when locale keys need updating (e.g. before opening a PR).
|
||||
|
||||
### Code Style
|
||||
|
||||
|
||||
@@ -210,14 +210,6 @@ ENV NEXT_PUBLIC_S3_DOMAIN="" \
|
||||
S3_ENABLE_PATH_STYLE="" \
|
||||
S3_SET_ACL=""
|
||||
|
||||
# Cloud Sandbox
|
||||
ENV SANDBOX_PROVIDER="" \
|
||||
ONLYBOXES_BASE_URL="" \
|
||||
ONLYBOXES_JIT_ISSUER="" \
|
||||
ONLYBOXES_JIT_SIGNING_KEY="" \
|
||||
ONLYBOXES_JIT_TTL_SEC="" \
|
||||
ONLYBOXES_LEASE_TTL_SEC=""
|
||||
|
||||
# Model Variables
|
||||
ENV \
|
||||
# AI21
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
#!/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
|
||||
@@ -4,9 +4,6 @@ packages:
|
||||
- '../../packages/device-identity'
|
||||
- '../../packages/heterogeneous-agents'
|
||||
- '../../packages/local-file-shell'
|
||||
- '../../packages/tool-runtime'
|
||||
- '../../packages/prompts'
|
||||
- '../../packages/const'
|
||||
- '../../packages/types'
|
||||
- '../../packages/model-bank'
|
||||
- '../../packages/business/const'
|
||||
|
||||
@@ -347,33 +347,22 @@ export function registerAgentCommand(program: Command) {
|
||||
const { serverUrl, headers, token, tokenType } = await getAgentStreamAuthInfo();
|
||||
const agentGatewayUrl = options.sse ? undefined : resolveAgentGatewayUrl();
|
||||
|
||||
try {
|
||||
if (agentGatewayUrl) {
|
||||
await streamAgentEventsViaWebSocket({
|
||||
gatewayUrl: agentGatewayUrl,
|
||||
json: options.json,
|
||||
operationId,
|
||||
serverUrl,
|
||||
token,
|
||||
tokenType,
|
||||
verbose: options.verbose,
|
||||
});
|
||||
} else {
|
||||
const streamUrl = `${serverUrl}/api/agent/stream?operationId=${encodeURIComponent(operationId)}`;
|
||||
await streamAgentEvents(streamUrl, headers, {
|
||||
json: options.json,
|
||||
verbose: options.verbose,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// The live stream (gateway WS / SSE) dropped before the run finished —
|
||||
// the run is still executing server-side. Instead of failing, fall back
|
||||
// to polling the run status until it reaches a terminal state.
|
||||
if (options.json) throw error;
|
||||
log.warn(
|
||||
`Live stream unavailable (${(error as Error).message}). Polling run status every 10s…`,
|
||||
);
|
||||
await pollAgentRunStatus(client, operationId);
|
||||
if (agentGatewayUrl) {
|
||||
await streamAgentEventsViaWebSocket({
|
||||
gatewayUrl: agentGatewayUrl,
|
||||
json: options.json,
|
||||
operationId,
|
||||
serverUrl,
|
||||
token,
|
||||
tokenType,
|
||||
verbose: options.verbose,
|
||||
});
|
||||
} else {
|
||||
const streamUrl = `${serverUrl}/api/agent/stream?operationId=${encodeURIComponent(operationId)}`;
|
||||
await streamAgentEvents(streamUrl, headers, {
|
||||
json: options.json,
|
||||
verbose: options.verbose,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -637,56 +626,3 @@ function colorStatus(status: string): string {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const TERMINAL_RUN_STATUSES = new Set([
|
||||
'completed',
|
||||
'done',
|
||||
'success',
|
||||
'failed',
|
||||
'error',
|
||||
'cancelled',
|
||||
'canceled',
|
||||
'aborted',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Fallback when the live stream (gateway WebSocket / SSE) drops before the run
|
||||
* finishes: the run is still executing server-side, so poll its status every 10s
|
||||
* until it reaches a terminal state (or is no longer tracked, which also means it
|
||||
* has finished). Avoids hard-exiting on a transient gateway disconnect.
|
||||
*/
|
||||
async function pollAgentRunStatus(
|
||||
client: Awaited<ReturnType<typeof getTrpcClient>>,
|
||||
operationId: string,
|
||||
): Promise<void> {
|
||||
const POLL_MS = 10_000;
|
||||
let lastStatus = '';
|
||||
for (let i = 0; ; i++) {
|
||||
if (i > 0) await new Promise((resolve) => setTimeout(resolve, POLL_MS));
|
||||
|
||||
let r: any;
|
||||
try {
|
||||
r = await client.aiAgent.getOperationStatus.query({ operationId } as any);
|
||||
} catch (error) {
|
||||
log.error(`Status poll failed: ${(error as Error).message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!r) {
|
||||
log.info('Run is no longer tracked — finished (or expired).');
|
||||
return;
|
||||
}
|
||||
|
||||
const status = r.status || r.state || 'unknown';
|
||||
if (status !== lastStatus) {
|
||||
lastStatus = status;
|
||||
const steps = r.stepCount !== undefined ? ` · ${r.stepCount} step(s)` : '';
|
||||
log.info(`Run status: ${colorStatus(status)}${steps}`);
|
||||
}
|
||||
|
||||
if (TERMINAL_RUN_STATUSES.has(status)) {
|
||||
if (r.error) log.error(`Run error: ${r.error}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
import { Command } from 'commander';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { registerVerifyCommand } from './verify';
|
||||
|
||||
const { mockTrpcClient } = vi.hoisted(() => ({
|
||||
mockTrpcClient: {
|
||||
verify: {
|
||||
createRubric: { mutate: vi.fn() },
|
||||
getRubric: { query: vi.fn() },
|
||||
updateRubric: { mutate: vi.fn() },
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const { getTrpcClient: mockGetTrpcClient } = vi.hoisted(() => ({
|
||||
getTrpcClient: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../api/client', () => ({ getTrpcClient: mockGetTrpcClient }));
|
||||
vi.mock('../utils/logger', () => ({
|
||||
log: { debug: vi.fn(), error: vi.fn(), info: vi.fn(), warn: vi.fn() },
|
||||
setVerbose: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('verify rubric config commands', () => {
|
||||
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
mockGetTrpcClient.mockResolvedValue(mockTrpcClient);
|
||||
mockTrpcClient.verify.createRubric.mutate.mockReset().mockResolvedValue({ id: 'rub-1' });
|
||||
mockTrpcClient.verify.updateRubric.mutate.mockReset().mockResolvedValue(undefined);
|
||||
mockTrpcClient.verify.getRubric.query.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => consoleSpy.mockRestore());
|
||||
|
||||
const run = async (args: string[]) => {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerVerifyCommand(program);
|
||||
await program.parseAsync(['node', 'lh', 'verify', ...args]);
|
||||
};
|
||||
|
||||
it('passes maxRepairRounds config when creating a rubric', async () => {
|
||||
await run(['rubric', 'create', '-t', 'Standard', '--max-repair-rounds', '3']);
|
||||
|
||||
expect(mockTrpcClient.verify.createRubric.mutate).toHaveBeenCalledWith({
|
||||
config: { maxRepairRounds: 3 },
|
||||
description: undefined,
|
||||
title: 'Standard',
|
||||
});
|
||||
});
|
||||
|
||||
it('omits config when no max-repair-rounds flag is given', async () => {
|
||||
await run(['rubric', 'create', '-t', 'Standard']);
|
||||
|
||||
expect(mockTrpcClient.verify.createRubric.mutate).toHaveBeenCalledWith({
|
||||
config: undefined,
|
||||
description: undefined,
|
||||
title: 'Standard',
|
||||
});
|
||||
});
|
||||
|
||||
it('updates only the config when max-repair-rounds is passed', async () => {
|
||||
await run(['rubric', 'update', 'rub-1', '--max-repair-rounds', '0']);
|
||||
|
||||
expect(mockTrpcClient.verify.updateRubric.mutate).toHaveBeenCalledWith({
|
||||
id: 'rub-1',
|
||||
value: { config: { maxRepairRounds: 0 } },
|
||||
});
|
||||
});
|
||||
|
||||
it('views a rubric and prints its repair-round config', async () => {
|
||||
mockTrpcClient.verify.getRubric.query.mockResolvedValue({
|
||||
config: { maxRepairRounds: 4 },
|
||||
description: 'desc',
|
||||
id: 'rub-1',
|
||||
title: 'Standard',
|
||||
});
|
||||
|
||||
await run(['rubric', 'view', 'rub-1']);
|
||||
|
||||
expect(mockTrpcClient.verify.getRubric.query).toHaveBeenCalledWith({ id: 'rub-1' });
|
||||
const printed = consoleSpy.mock.calls.map((c) => String(c[0])).join('\n');
|
||||
expect(printed).toContain('Standard');
|
||||
expect(printed).toContain('4');
|
||||
});
|
||||
});
|
||||
@@ -1,455 +0,0 @@
|
||||
import type { Command } from 'commander';
|
||||
import pc from 'picocolors';
|
||||
|
||||
import { getTrpcClient } from '../api/client';
|
||||
import { confirm, outputJson, printTable, timeAgo, truncate } from '../utils/format';
|
||||
import { log } from '../utils/logger';
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────
|
||||
|
||||
type VerifierType = 'agent' | 'llm' | 'program';
|
||||
type OnFail = 'auto_repair' | 'manual';
|
||||
type Decision = 'accepted' | 'overridden' | 'rejected';
|
||||
|
||||
const VERIFIER_TYPES: VerifierType[] = ['program', 'agent', 'llm'];
|
||||
const ON_FAIL: OnFail[] = ['manual', 'auto_repair'];
|
||||
const DECISIONS: Decision[] = ['accepted', 'rejected', 'overridden'];
|
||||
|
||||
function parseConfig(raw?: string): Record<string, unknown> | undefined {
|
||||
if (!raw) return undefined;
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
log.error('--config must be valid JSON');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
function assertEnum<T extends string>(value: T | undefined, allowed: T[], flag: string): void {
|
||||
if (value !== undefined && !allowed.includes(value)) {
|
||||
log.error(`${flag} must be one of: ${allowed.join(', ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Command Registration ───────────────────────────────────
|
||||
|
||||
export function registerVerifyCommand(program: Command) {
|
||||
const verify = program
|
||||
.command('verify')
|
||||
.description('Manage the Agent Run delivery checker (criteria, rubrics, plans, results)');
|
||||
|
||||
// ════════════ criteria ════════════
|
||||
const criterion = verify.command('criterion').description('Reusable pass/fail standards');
|
||||
|
||||
criterion
|
||||
.command('list')
|
||||
.description('List criteria')
|
||||
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
|
||||
.action(async (options: { json?: boolean | string }) => {
|
||||
const client = await getTrpcClient();
|
||||
const items = await client.verify.listCriteria.query();
|
||||
|
||||
if (options.json !== undefined) {
|
||||
outputJson(items, typeof options.json === 'string' ? options.json : undefined);
|
||||
return;
|
||||
}
|
||||
if (items.length === 0) return void console.log('No criteria found.');
|
||||
printTable(
|
||||
items.map((c) => [
|
||||
c.id,
|
||||
truncate(c.title, 60),
|
||||
c.verifierType,
|
||||
c.required ? 'gate' : 'soft',
|
||||
c.onFail,
|
||||
c.updatedAt ? timeAgo(c.updatedAt) : '',
|
||||
]),
|
||||
['ID', 'TITLE', 'TYPE', 'BLOCK', 'ON-FAIL', 'UPDATED'],
|
||||
);
|
||||
});
|
||||
|
||||
criterion
|
||||
.command('create')
|
||||
.description('Create a criterion')
|
||||
.requiredOption('-t, --title <title>', 'Criterion title')
|
||||
.requiredOption('--type <type>', `Verifier type (${VERIFIER_TYPES.join('|')})`)
|
||||
.option('--on-fail <strategy>', `Action on failure (${ON_FAIL.join('|')})`)
|
||||
.option('--soft', 'Non-blocking (required=false); defaults to blocking')
|
||||
.option('--config <json>', 'Verifier config as JSON')
|
||||
.option('--doc <id>', 'Linked guidance document id')
|
||||
.action(
|
||||
async (options: {
|
||||
config?: string;
|
||||
doc?: string;
|
||||
onFail?: OnFail;
|
||||
soft?: boolean;
|
||||
title: string;
|
||||
type: VerifierType;
|
||||
}) => {
|
||||
assertEnum(options.type, VERIFIER_TYPES, '--type');
|
||||
assertEnum(options.onFail, ON_FAIL, '--on-fail');
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.verify.createCriterion.mutate({
|
||||
documentId: options.doc,
|
||||
onFail: options.onFail,
|
||||
required: options.soft ? false : undefined,
|
||||
title: options.title,
|
||||
verifierConfig: parseConfig(options.config),
|
||||
verifierType: options.type,
|
||||
});
|
||||
console.log(`${pc.green('✓')} Created criterion ${pc.bold((result as any).id)}`);
|
||||
},
|
||||
);
|
||||
|
||||
criterion
|
||||
.command('delete <id>')
|
||||
.description('Delete a criterion')
|
||||
.option('--yes', 'Skip confirmation')
|
||||
.action(async (id: string, options: { yes?: boolean }) => {
|
||||
if (!options.yes && !(await confirm(`Delete criterion ${id}?`)))
|
||||
return void console.log('Cancelled.');
|
||||
const client = await getTrpcClient();
|
||||
await client.verify.deleteCriterion.mutate({ id });
|
||||
console.log(`${pc.green('✓')} Deleted criterion ${pc.bold(id)}`);
|
||||
});
|
||||
|
||||
// ════════════ rubrics ════════════
|
||||
const rubric = verify.command('rubric').description('Named groups of criteria');
|
||||
|
||||
rubric
|
||||
.command('list')
|
||||
.description('List rubrics')
|
||||
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
|
||||
.action(async (options: { json?: boolean | string }) => {
|
||||
const client = await getTrpcClient();
|
||||
const items = await client.verify.listRubrics.query();
|
||||
if (options.json !== undefined) {
|
||||
outputJson(items, typeof options.json === 'string' ? options.json : undefined);
|
||||
return;
|
||||
}
|
||||
if (items.length === 0) return void console.log('No rubrics found.');
|
||||
printTable(
|
||||
items.map((r) => [
|
||||
r.id,
|
||||
truncate(r.title, 60),
|
||||
truncate(r.description || '', 60),
|
||||
r.updatedAt ? timeAgo(r.updatedAt) : '',
|
||||
]),
|
||||
['ID', 'TITLE', 'DESCRIPTION', 'UPDATED'],
|
||||
);
|
||||
});
|
||||
|
||||
rubric
|
||||
.command('create')
|
||||
.description('Create a rubric')
|
||||
.requiredOption('-t, --title <title>', 'Rubric title')
|
||||
.option('-d, --description <text>', 'Rubric description')
|
||||
.option('--max-repair-rounds <n>', 'Cap on automatic repair rounds (0-5)')
|
||||
.action(async (options: { description?: string; maxRepairRounds?: string; title: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.verify.createRubric.mutate({
|
||||
config:
|
||||
options.maxRepairRounds !== undefined
|
||||
? { maxRepairRounds: Number(options.maxRepairRounds) }
|
||||
: undefined,
|
||||
description: options.description,
|
||||
title: options.title,
|
||||
});
|
||||
console.log(`${pc.green('✓')} Created rubric ${pc.bold((result as any).id)}`);
|
||||
});
|
||||
|
||||
rubric
|
||||
.command('view <id>')
|
||||
.description('Show a rubric and its run-policy config')
|
||||
.option('--json [fields]', 'Output JSON')
|
||||
.action(async (id: string, options: { json?: boolean | string }) => {
|
||||
const client = await getTrpcClient();
|
||||
const item = await client.verify.getRubric.query({ id });
|
||||
if (!item) return void log.error('Rubric not found.');
|
||||
if (options.json !== undefined) {
|
||||
outputJson(item, typeof options.json === 'string' ? options.json : undefined);
|
||||
return;
|
||||
}
|
||||
console.log(`${pc.bold('ID')} ${item.id}`);
|
||||
console.log(`${pc.bold('Title')} ${item.title}`);
|
||||
if (item.description) console.log(`${pc.bold('Description')} ${item.description}`);
|
||||
const maxRepairRounds = (item.config as { maxRepairRounds?: number } | null)?.maxRepairRounds;
|
||||
console.log(`${pc.bold('Repair rounds')} ${maxRepairRounds ?? pc.dim('default')}`);
|
||||
});
|
||||
|
||||
rubric
|
||||
.command('update <id>')
|
||||
.description('Update a rubric (title / description / run-policy config)')
|
||||
.option('-t, --title <title>', 'New title')
|
||||
.option('-d, --description <text>', 'New description')
|
||||
.option('--max-repair-rounds <n>', 'Cap on automatic repair rounds (0-5)')
|
||||
.action(
|
||||
async (
|
||||
id: string,
|
||||
options: { description?: string; maxRepairRounds?: string; title?: string },
|
||||
) => {
|
||||
const client = await getTrpcClient();
|
||||
const value: {
|
||||
config?: { maxRepairRounds?: number };
|
||||
description?: string;
|
||||
title?: string;
|
||||
} = {};
|
||||
if (options.title !== undefined) value.title = options.title;
|
||||
if (options.description !== undefined) value.description = options.description;
|
||||
if (options.maxRepairRounds !== undefined)
|
||||
value.config = { maxRepairRounds: Number(options.maxRepairRounds) };
|
||||
await client.verify.updateRubric.mutate({ id, value });
|
||||
console.log(`${pc.green('✓')} Updated rubric ${pc.bold(id)}`);
|
||||
},
|
||||
);
|
||||
|
||||
rubric
|
||||
.command('delete <id>')
|
||||
.description('Delete a rubric')
|
||||
.option('--yes', 'Skip confirmation')
|
||||
.action(async (id: string, options: { yes?: boolean }) => {
|
||||
if (!options.yes && !(await confirm(`Delete rubric ${id}?`)))
|
||||
return void console.log('Cancelled.');
|
||||
const client = await getTrpcClient();
|
||||
await client.verify.deleteRubric.mutate({ id });
|
||||
console.log(`${pc.green('✓')} Deleted rubric ${pc.bold(id)}`);
|
||||
});
|
||||
|
||||
rubric
|
||||
.command('criteria <rubricId>')
|
||||
.description('List criteria in a rubric')
|
||||
.option('--json [fields]', 'Output JSON')
|
||||
.action(async (rubricId: string, options: { json?: boolean | string }) => {
|
||||
const client = await getTrpcClient();
|
||||
const items = await client.verify.getRubricCriteria.query({ rubricId });
|
||||
if (options.json !== undefined) {
|
||||
outputJson(items, typeof options.json === 'string' ? options.json : undefined);
|
||||
return;
|
||||
}
|
||||
if (items.length === 0) return void console.log('No criteria in this rubric.');
|
||||
printTable(
|
||||
items.map((c: any) => [
|
||||
c.id,
|
||||
truncate(c.title, 60),
|
||||
c.verifierType,
|
||||
c.required ? 'gate' : 'soft',
|
||||
]),
|
||||
['ID', 'TITLE', 'TYPE', 'BLOCK'],
|
||||
);
|
||||
});
|
||||
|
||||
rubric
|
||||
.command('set-criteria <rubricId> <criterionIds...>')
|
||||
.description('Set the criteria a rubric aggregates (order preserved)')
|
||||
.action(async (rubricId: string, criterionIds: string[]) => {
|
||||
const client = await getTrpcClient();
|
||||
await client.verify.setRubricCriteria.mutate({
|
||||
criteria: criterionIds.map((criterionId, i) => ({ criterionId, sortOrder: i })),
|
||||
rubricId,
|
||||
});
|
||||
console.log(
|
||||
`${pc.green('✓')} Rubric ${pc.bold(rubricId)} now has ${criterionIds.length} criterion(s)`,
|
||||
);
|
||||
});
|
||||
|
||||
// ════════════ per-run plan ════════════
|
||||
const plan = verify.command('plan').description('Per-run check plan lifecycle');
|
||||
|
||||
plan
|
||||
.command('generate <operationId>')
|
||||
.description('Generate a draft check plan for a run')
|
||||
.requiredOption('--goal <goal>', "The run's task/instruction the plan must satisfy")
|
||||
.option('--rubric <id>', 'Mounted rubric id')
|
||||
.option('--criteria <ids>', 'Ad-hoc criterion ids (comma-separated)')
|
||||
.option('--ai', 'Let the LLM propose additional criteria')
|
||||
.option('--max-ai <n>', 'Max AI-proposed criteria')
|
||||
.option('--model <model>', 'Model (required with --ai)')
|
||||
.option('--provider <provider>', 'Provider (required with --ai)')
|
||||
.option('--context <text>', 'Extra context for the AI prompt')
|
||||
.option('--json [fields]', 'Output JSON')
|
||||
.action(
|
||||
async (
|
||||
operationId: string,
|
||||
options: {
|
||||
ai?: boolean;
|
||||
context?: string;
|
||||
criteria?: string;
|
||||
goal: string;
|
||||
json?: boolean | string;
|
||||
maxAi?: string;
|
||||
model?: string;
|
||||
provider?: string;
|
||||
rubric?: string;
|
||||
},
|
||||
) => {
|
||||
if (options.ai && (!options.model || !options.provider)) {
|
||||
log.error('--ai requires --model and --provider');
|
||||
process.exit(1);
|
||||
}
|
||||
const client = await getTrpcClient();
|
||||
const items = await client.verify.generateDraftPlan.mutate({
|
||||
context: options.context,
|
||||
enableAiGeneration: options.ai,
|
||||
goal: options.goal,
|
||||
maxAiCriteria: options.maxAi ? Number.parseInt(options.maxAi, 10) : undefined,
|
||||
modelConfig:
|
||||
options.model && options.provider
|
||||
? { model: options.model, provider: options.provider }
|
||||
: undefined,
|
||||
operationId,
|
||||
verifyCriteriaIds: options.criteria
|
||||
?.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
verifyRubricId: options.rubric ?? null,
|
||||
});
|
||||
if (options.json !== undefined) {
|
||||
outputJson(items, typeof options.json === 'string' ? options.json : undefined);
|
||||
return;
|
||||
}
|
||||
console.log(`${pc.green('✓')} Draft plan: ${pc.bold(String(items.length))} item(s)`);
|
||||
printTable(
|
||||
items.map((i: any) => [
|
||||
String(i.index),
|
||||
truncate(i.title, 60),
|
||||
i.verifierType,
|
||||
i.required ? 'gate' : 'soft',
|
||||
]),
|
||||
['#', 'TITLE', 'TYPE', 'BLOCK'],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
plan
|
||||
.command('state <operationId>')
|
||||
.description('Show the verify state (status + frozen plan) of a run')
|
||||
.option('--json [fields]', 'Output JSON')
|
||||
.action(async (operationId: string, options: { json?: boolean | string }) => {
|
||||
const client = await getTrpcClient();
|
||||
const state = await client.verify.getVerifyState.query({ operationId });
|
||||
if (options.json !== undefined) {
|
||||
outputJson(state, typeof options.json === 'string' ? options.json : undefined);
|
||||
return;
|
||||
}
|
||||
if (!state) return void console.log('No verify state for this run.');
|
||||
console.log(`${pc.bold('status')}: ${state.verifyStatus ?? pc.dim('(none)')}`);
|
||||
console.log(
|
||||
`${pc.bold('confirmed')}: ${state.verifyPlanConfirmedAt ? timeAgo(state.verifyPlanConfirmedAt) : pc.dim('no')}`,
|
||||
);
|
||||
const items = (state.verifyPlan ?? []) as any[];
|
||||
console.log(`${pc.bold('plan')}: ${items.length} item(s)`);
|
||||
if (items.length > 0)
|
||||
printTable(
|
||||
items.map((i) => [
|
||||
String(i.index),
|
||||
truncate(i.title, 60),
|
||||
i.verifierType,
|
||||
i.required ? 'gate' : 'soft',
|
||||
]),
|
||||
['#', 'TITLE', 'TYPE', 'BLOCK'],
|
||||
);
|
||||
});
|
||||
|
||||
plan
|
||||
.command('confirm <operationId>')
|
||||
.description('Freeze (confirm) the draft plan')
|
||||
.action(async (operationId: string) => {
|
||||
const client = await getTrpcClient();
|
||||
await client.verify.confirmPlan.mutate({ operationId });
|
||||
console.log(`${pc.green('✓')} Confirmed plan for run ${pc.bold(operationId)}`);
|
||||
});
|
||||
|
||||
plan
|
||||
.command('skip <operationId>')
|
||||
.description('Skip verification for a run')
|
||||
.action(async (operationId: string) => {
|
||||
const client = await getTrpcClient();
|
||||
await client.verify.skipPlan.mutate({ operationId });
|
||||
console.log(`${pc.green('✓')} Skipped verification for run ${pc.bold(operationId)}`);
|
||||
});
|
||||
|
||||
// ════════════ run / results ════════════
|
||||
verify
|
||||
.command('run <operationId>')
|
||||
.description('Execute the confirmed plan against a deliverable (LLM judge)')
|
||||
.requiredOption('--goal <goal>', "The run's task")
|
||||
.requiredOption('--deliverable <text>', 'The output to judge')
|
||||
.requiredOption('--model <model>', 'Judge model')
|
||||
.requiredOption('--provider <provider>', 'Judge provider')
|
||||
.option('--no-batch', 'Judge each item separately instead of one batched call')
|
||||
.option('--json [fields]', 'Output JSON')
|
||||
.action(
|
||||
async (
|
||||
operationId: string,
|
||||
options: {
|
||||
batch?: boolean;
|
||||
deliverable: string;
|
||||
goal: string;
|
||||
json?: boolean | string;
|
||||
model: string;
|
||||
provider: string;
|
||||
},
|
||||
) => {
|
||||
const client = await getTrpcClient();
|
||||
const results = await client.verify.executeVerify.mutate({
|
||||
batchLlm: options.batch,
|
||||
deliverable: options.deliverable,
|
||||
goal: options.goal,
|
||||
modelConfig: { model: options.model, provider: options.provider },
|
||||
operationId,
|
||||
});
|
||||
if (options.json !== undefined) {
|
||||
outputJson(results, typeof options.json === 'string' ? options.json : undefined);
|
||||
return;
|
||||
}
|
||||
printResults(results);
|
||||
},
|
||||
);
|
||||
|
||||
verify
|
||||
.command('results <operationId>')
|
||||
.description('List check results for a run')
|
||||
.option('--json [fields]', 'Output JSON')
|
||||
.action(async (operationId: string, options: { json?: boolean | string }) => {
|
||||
const client = await getTrpcClient();
|
||||
const results = await client.verify.listResults.query({ operationId });
|
||||
if (options.json !== undefined) {
|
||||
outputJson(results, typeof options.json === 'string' ? options.json : undefined);
|
||||
return;
|
||||
}
|
||||
if (results.length === 0) return void console.log('No results yet.');
|
||||
printResults(results);
|
||||
});
|
||||
|
||||
// ════════════ feedback ════════════
|
||||
verify
|
||||
.command('decision <resultId> <decision>')
|
||||
.description(`Record human feedback on a result (${DECISIONS.join('|')})`)
|
||||
.action(async (resultId: string, decision: Decision) => {
|
||||
assertEnum(decision, DECISIONS, 'decision');
|
||||
const client = await getTrpcClient();
|
||||
await client.verify.submitDecision.mutate({ decision, resultId });
|
||||
console.log(`${pc.green('✓')} Recorded ${pc.bold(decision)} on result ${pc.bold(resultId)}`);
|
||||
});
|
||||
}
|
||||
|
||||
function printResults(results: any[]): void {
|
||||
printTable(
|
||||
results.map((r) => [
|
||||
truncate(r.checkItemTitle || r.checkItemId, 50),
|
||||
statusColor(r.status),
|
||||
r.verdict ?? '',
|
||||
r.confidence != null ? String(r.confidence) : '',
|
||||
r.required ? 'gate' : 'soft',
|
||||
truncate(r.suggestion || '', 40),
|
||||
]),
|
||||
['CHECK', 'STATUS', 'VERDICT', 'CONF', 'BLOCK', 'SUGGESTION'],
|
||||
);
|
||||
}
|
||||
|
||||
function statusColor(status: string): string {
|
||||
if (status === 'passed') return pc.green(status);
|
||||
if (status === 'failed') return pc.red(status);
|
||||
if (status === 'running') return pc.yellow(status);
|
||||
return pc.dim(status);
|
||||
}
|
||||
@@ -34,7 +34,6 @@ import { registerTaskCommand } from './commands/task';
|
||||
import { registerThreadCommand } from './commands/thread';
|
||||
import { registerTopicCommand } from './commands/topic';
|
||||
import { registerUserCommand } from './commands/user';
|
||||
import { registerVerifyCommand } from './commands/verify';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { version } = require('../package.json');
|
||||
@@ -76,7 +75,6 @@ export function createProgram() {
|
||||
registerProviderCommand(program);
|
||||
registerPluginCommand(program);
|
||||
registerUserCommand(program);
|
||||
registerVerifyCommand(program);
|
||||
registerConfigCommand(program);
|
||||
registerEvalCommand(program);
|
||||
registerMigrateCommand(program);
|
||||
|
||||
@@ -296,11 +296,7 @@ export async function streamAgentEventsViaWebSocket(
|
||||
console.log(JSON.stringify(jsonEvents, null, 2));
|
||||
}
|
||||
isSettled = true;
|
||||
// Surface the close code + reason — `String(event)` is just "[object CloseEvent]".
|
||||
const reason = event.reason ? `: ${event.reason}` : '';
|
||||
reject(
|
||||
new Error(`Agent gateway WebSocket closed before completion (code ${event.code}${reason})`),
|
||||
);
|
||||
reject(new Error(`Agent gateway WebSocket closed before completion: ${String(event)}`));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -223,22 +223,5 @@ export default defineConfig({
|
||||
dedupe: ['react', 'react-dom'],
|
||||
tsconfigPaths: true,
|
||||
},
|
||||
// In dev the BrowserWindow loads `app://renderer/` and the Electron main process
|
||||
// proxies non-backend requests to this Vite dev server via `net.fetch`. The HMR
|
||||
// WebSocket still connects directly (browser → ws://localhost:<port>) — so the
|
||||
// port MUST be deterministic. `strictPort` fails fast on conflict instead of
|
||||
// silently sliding, and `clientPort` baked into the HMR injection has to match.
|
||||
server: {
|
||||
hmr: {
|
||||
clientPort: 5173,
|
||||
host: '127.0.0.1',
|
||||
protocol: 'ws',
|
||||
},
|
||||
// Force IPv4 so main-process `fetch` skips happy-eyeballs dual-stack
|
||||
// attempts that surface as ETIMEDOUT under cold-start request bursts.
|
||||
host: '127.0.0.1',
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -68,16 +68,9 @@
|
||||
if (resolvedTheme === 'dark' || resolvedTheme === 'light') {
|
||||
document.documentElement.setAttribute('data-theme', resolvedTheme);
|
||||
}
|
||||
// Renderer-side reloads (Cmd+R / webContents.reload) don't go through
|
||||
// the main process's `?lng=` injection, so prefer the i18next cache —
|
||||
// the actual user setting persisted by the language switcher — before
|
||||
// falling back to the URL param or navigator detection.
|
||||
// Check URL query parameter for locale (set by Electron main process from stored settings)
|
||||
var urlParams = new URLSearchParams(window.location.search);
|
||||
var locale;
|
||||
try {
|
||||
locale = localStorage.getItem('i18nextLng');
|
||||
} catch (_) {}
|
||||
if (!locale) locale = urlParams.get('lng') || navigator.language || 'en-US';
|
||||
var locale = urlParams.get('lng') || navigator.language || 'en-US';
|
||||
document.documentElement.lang = locale;
|
||||
var rtl = ['ar', 'arc', 'dv', 'fa', 'ha', 'he', 'khw', 'ks', 'ku', 'ps', 'ur', 'yi'];
|
||||
document.documentElement.dir =
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
"cross-env": "^10.1.0",
|
||||
"diff": "^8.0.4",
|
||||
"electron": "41.3.0",
|
||||
"electron-builder": "26.14.0",
|
||||
"electron-builder": "^26.8.1",
|
||||
"electron-devtools-installer": "4.0.0",
|
||||
"electron-is": "^3.0.0",
|
||||
"electron-store": "^8.2.0",
|
||||
@@ -125,7 +125,6 @@
|
||||
"node-mac-permissions"
|
||||
],
|
||||
"overrides": {
|
||||
"node-gyp": "^12.4.0",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"vitest": "3.2.4"
|
||||
|
||||
@@ -1,16 +1,4 @@
|
||||
export const ELECTRON_BE_PROTOCOL_SCHEME = 'lobe-backend';
|
||||
|
||||
export const LOCAL_FILE_PROTOCOL_SCHEME = 'localfile';
|
||||
export const LOCAL_FILE_PROTOCOL_HOST = 'file';
|
||||
|
||||
/**
|
||||
* Renderer pathnames that must be proxied to the remote LobeHub backend
|
||||
* instead of being served as static assets. Covers tRPC, webapi, NextAuth,
|
||||
* and the marketplace REST + OIDC token/userinfo/handoff endpoints.
|
||||
*
|
||||
* `/lobehub-oidc/*` is intentionally NOT here — those URLs are handed to
|
||||
* `shell.openExternal` as fully-qualified web URLs and never reach renderer
|
||||
* `fetch`.
|
||||
*/
|
||||
export const BACKEND_PATH_PREFIXES = ['/trpc', '/webapi', '/api/auth', '/market'];
|
||||
|
||||
export const isBackendPath = (pathname: string) =>
|
||||
BACKEND_PATH_PREFIXES.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`));
|
||||
|
||||
@@ -13,10 +13,8 @@ import type {
|
||||
GetCommandOutputParams,
|
||||
GlobFilesParams,
|
||||
GrepContentParams,
|
||||
InitWorkspaceParams,
|
||||
KillCommandParams,
|
||||
ListLocalFileParams,
|
||||
ListProjectSkillsParams,
|
||||
LocalReadFileParams,
|
||||
LocalReadFilesParams,
|
||||
LocalSearchFilesParams,
|
||||
@@ -30,14 +28,12 @@ import { type ILocalSystemService, LocalSystemExecutionRuntime } from '@lobechat
|
||||
import GatewayConnectionService from '@/services/gatewayConnectionSrv';
|
||||
import ImessageBridgeService from '@/services/imessageBridgeSrv';
|
||||
|
||||
import GitCtr from './GitCtr';
|
||||
import HeterogeneousAgentCtr from './HeterogeneousAgentCtr';
|
||||
import { ControllerModule, IpcMethod } from './index';
|
||||
import LocalFileCtr from './LocalFileCtr';
|
||||
import McpCtr from './McpCtr';
|
||||
import RemoteServerConfigCtr from './RemoteServerConfigCtr';
|
||||
import ShellCommandCtr from './ShellCommandCtr';
|
||||
import WorkspaceCtr from './WorkspaceCtr';
|
||||
|
||||
/**
|
||||
* Inject the lh-notify protocol into the first turn of a new hetero-agent session.
|
||||
@@ -166,14 +162,6 @@ export default class GatewayConnectionCtr extends ControllerModule {
|
||||
return this.app.getController(LocalFileCtr);
|
||||
}
|
||||
|
||||
private get workspaceCtr() {
|
||||
return this.app.getController(WorkspaceCtr);
|
||||
}
|
||||
|
||||
private get gitCtr() {
|
||||
return this.app.getController(GitCtr);
|
||||
}
|
||||
|
||||
private get shellCommandCtr() {
|
||||
return this.app.getController(ShellCommandCtr);
|
||||
}
|
||||
@@ -215,10 +203,6 @@ export default class GatewayConnectionCtr extends ControllerModule {
|
||||
// Wire up agent run handler
|
||||
srv.setAgentRunHandler((request) => this.executeAgentRun(request));
|
||||
|
||||
// Wire up generic device RPC handler (server-internal method forwarding,
|
||||
// e.g. workspace-init scans — never surfaced to the agent)
|
||||
srv.setRpcHandler((method, params) => this.executeDeviceRpc(method, params));
|
||||
|
||||
// Wire up device registrar (persists this device to the server registry)
|
||||
srv.setDeviceRegistrar((info) => this.registerDevice(info));
|
||||
|
||||
@@ -324,7 +308,7 @@ export default class GatewayConnectionCtr extends ControllerModule {
|
||||
* renderer uses, so remote tool calls produce identical
|
||||
* `{ content, state, success }` envelopes — `content` is the LLM-facing
|
||||
* prompt text, `state` is the structured payload, both flow downstream
|
||||
* intact (the gateway / DeviceGateway / RuntimeExecutors paths preserve them
|
||||
* intact (the gateway / DeviceProxy / RuntimeExecutors paths preserve them
|
||||
* and write `state` to the tool message's `pluginState`).
|
||||
*/
|
||||
private getLocalSystemRuntime(): LocalSystemExecutionRuntime {
|
||||
@@ -351,89 +335,6 @@ export default class GatewayConnectionCtr extends ControllerModule {
|
||||
return this.localSystemRuntime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a generic server-internal device RPC (not an agent tool call) by
|
||||
* method name. Currently only `initWorkspace` (scan the bound project root for
|
||||
* skills + AGENTS.md); add new server-only device methods here.
|
||||
*/
|
||||
private async executeDeviceRpc(method: string, params: unknown): Promise<unknown> {
|
||||
switch (method) {
|
||||
case 'initWorkspace': {
|
||||
return this.workspaceCtr.initWorkspace(params as InitWorkspaceParams);
|
||||
}
|
||||
|
||||
case 'getGitBranch': {
|
||||
return this.gitCtr.getGitBranch((params as { path: string }).path);
|
||||
}
|
||||
|
||||
case 'getLinkedPullRequest': {
|
||||
return this.gitCtr.getLinkedPullRequest(params as { branch: string; path: string });
|
||||
}
|
||||
|
||||
case 'getGitWorkingTreeStatus': {
|
||||
return this.gitCtr.getGitWorkingTreeStatus((params as { path: string }).path);
|
||||
}
|
||||
|
||||
case 'getGitAheadBehind': {
|
||||
return this.gitCtr.getGitAheadBehind((params as { path: string }).path);
|
||||
}
|
||||
|
||||
case 'listGitBranches': {
|
||||
return this.gitCtr.listGitBranches((params as { path: string }).path);
|
||||
}
|
||||
|
||||
case 'checkoutGitBranch': {
|
||||
return this.gitCtr.checkoutGitBranch(
|
||||
params as { branch: string; create?: boolean; path: string },
|
||||
);
|
||||
}
|
||||
|
||||
case 'pullGitBranch': {
|
||||
return this.gitCtr.pullGitBranch(params as { path: string });
|
||||
}
|
||||
|
||||
case 'pushGitBranch': {
|
||||
return this.gitCtr.pushGitBranch(params as { path: string });
|
||||
}
|
||||
|
||||
case 'getGitWorkingTreePatches': {
|
||||
return this.gitCtr.getGitWorkingTreePatches((params as { path: string }).path);
|
||||
}
|
||||
|
||||
case 'getGitWorkingTreeFiles': {
|
||||
return this.gitCtr.getGitWorkingTreeFiles((params as { path: string }).path);
|
||||
}
|
||||
|
||||
case 'getProjectFileIndex': {
|
||||
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 });
|
||||
}
|
||||
|
||||
case 'listGitRemoteBranches': {
|
||||
return this.gitCtr.listGitRemoteBranches((params as { path: string }).path);
|
||||
}
|
||||
|
||||
case 'revertGitFile': {
|
||||
return this.gitCtr.revertGitFile(params as { filePath: string; path: string });
|
||||
}
|
||||
|
||||
case 'statPath': {
|
||||
return this.workspaceCtr.statPath(params as { path: string });
|
||||
}
|
||||
|
||||
default: {
|
||||
throw new Error(`Unknown device RPC method: ${method}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async executeToolCall(
|
||||
apiName: string,
|
||||
args: unknown,
|
||||
|
||||
@@ -22,16 +22,8 @@ import type {
|
||||
GitWorkingTreeStatus,
|
||||
SubmoduleWorkingTreePatches,
|
||||
} from '@lobechat/electron-client-ipc';
|
||||
import {
|
||||
type DeviceGitInfo,
|
||||
getGitAheadBehind as computeGitAheadBehind,
|
||||
getGitBranch as computeGitBranch,
|
||||
getGitWorkingTreeStatus as computeGitWorkingTreeStatus,
|
||||
getLinkedPullRequest as computeLinkedPullRequest,
|
||||
gitInfo as computeGitInfo,
|
||||
} from '@lobechat/local-file-shell';
|
||||
|
||||
import { detectRepoType } from '@/utils/git';
|
||||
import { detectRepoType, resolveGitDir } from '@/utils/git';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { ControllerModule, IpcMethod } from './index';
|
||||
@@ -458,17 +450,23 @@ export default class GitController extends ControllerModule {
|
||||
*/
|
||||
@IpcMethod()
|
||||
async getGitBranch(dirPath: string): Promise<GitBranchInfo> {
|
||||
return computeGitBranch(dirPath);
|
||||
}
|
||||
try {
|
||||
const gitDir = await resolveGitDir(dirPath);
|
||||
if (!gitDir) return {};
|
||||
|
||||
/**
|
||||
* Aggregate git status (branch + linked PR + working tree + ahead/behind) for a
|
||||
* directory. The single entry point shared by the local desktop display, the
|
||||
* device `gitInfo` RPC, and the CLI — implemented in `@lobechat/local-file-shell`.
|
||||
*/
|
||||
@IpcMethod()
|
||||
async gitInfo(params: { isGithub?: boolean; scope: string }): Promise<DeviceGitInfo> {
|
||||
return computeGitInfo(params);
|
||||
const head = (await readFile(path.join(gitDir, 'HEAD'), 'utf8')).trim();
|
||||
const refMatch = /^ref:\s*refs\/heads\/(.+)$/.exec(head);
|
||||
if (refMatch) {
|
||||
return { branch: refMatch[1] };
|
||||
}
|
||||
// Detached HEAD — HEAD file contains the full sha
|
||||
if (/^[\da-f]{40}$/i.test(head)) {
|
||||
return { branch: head.slice(0, 7), detached: true };
|
||||
}
|
||||
return {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -481,7 +479,58 @@ export default class GitController extends ControllerModule {
|
||||
branch: string;
|
||||
path: string;
|
||||
}): Promise<GitLinkedPullRequestResult> {
|
||||
return computeLinkedPullRequest(payload);
|
||||
const { path: dirPath, branch } = payload;
|
||||
if (!branch) {
|
||||
return { pullRequest: null, status: 'ok' };
|
||||
}
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
'gh',
|
||||
[
|
||||
'pr',
|
||||
'list',
|
||||
'--head',
|
||||
branch,
|
||||
'--state',
|
||||
'open',
|
||||
'--limit',
|
||||
'5',
|
||||
'--json',
|
||||
'number,url,title,state',
|
||||
],
|
||||
{ cwd: dirPath, timeout: 8000 },
|
||||
);
|
||||
const parsed = JSON.parse(stdout.trim() || '[]') as Array<{
|
||||
number: number;
|
||||
state: string;
|
||||
title: string;
|
||||
url: string;
|
||||
}>;
|
||||
if (parsed.length === 0) {
|
||||
return { pullRequest: null, status: 'ok' };
|
||||
}
|
||||
const [primary, ...rest] = parsed;
|
||||
return {
|
||||
extraCount: rest.length,
|
||||
pullRequest: primary,
|
||||
status: 'ok',
|
||||
};
|
||||
} catch (error: any) {
|
||||
const code = error?.code;
|
||||
const stderr: string = error?.stderr ?? '';
|
||||
// `gh` binary not on PATH
|
||||
if (code === 'ENOENT') {
|
||||
return { pullRequest: null, status: 'gh-missing' };
|
||||
}
|
||||
// gh reports auth issues via stderr; treat as a soft-fail
|
||||
if (/auth\s+login|not\s+logged\s+in|authentication/i.test(stderr)) {
|
||||
return { pullRequest: null, status: 'gh-missing' };
|
||||
}
|
||||
logger.debug('[getLinkedPullRequest] failed', { branch, code, stderr });
|
||||
return { pullRequest: null, status: 'error' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -586,7 +635,42 @@ export default class GitController extends ControllerModule {
|
||||
*/
|
||||
@IpcMethod()
|
||||
async getGitWorkingTreeStatus(dirPath: string): Promise<GitWorkingTreeStatus> {
|
||||
return computeGitWorkingTreeStatus(dirPath);
|
||||
const execFileAsync = promisify(execFile);
|
||||
try {
|
||||
const { stdout } = await execFileAsync('git', ['status', '--porcelain', '-z'], {
|
||||
cwd: dirPath,
|
||||
timeout: 5000,
|
||||
});
|
||||
const tokens = stdout.split('\0');
|
||||
let added = 0;
|
||||
let modified = 0;
|
||||
let deleted = 0;
|
||||
let i = 0;
|
||||
while (i < tokens.length) {
|
||||
const entry = tokens[i];
|
||||
i++;
|
||||
if (entry.length < 2) continue;
|
||||
const x = entry[0];
|
||||
const y = entry[1];
|
||||
// R/C entries carry an extra source-path token we must consume.
|
||||
if (x === 'R' || x === 'C') i++;
|
||||
if (x === '?' && y === '?') {
|
||||
added++;
|
||||
} else if (x === '!' && y === '!') {
|
||||
// ignored — skip
|
||||
} else if (x === 'D' || y === 'D') {
|
||||
deleted++;
|
||||
} else if (x === 'A' || y === 'A') {
|
||||
added++;
|
||||
} else {
|
||||
modified++;
|
||||
}
|
||||
}
|
||||
const total = added + modified + deleted;
|
||||
return { added, clean: total === 0, deleted, modified, total };
|
||||
} catch {
|
||||
return { added: 0, clean: true, deleted: 0, modified: 0, total: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -605,7 +689,7 @@ export default class GitController extends ControllerModule {
|
||||
const modified: string[] = [];
|
||||
const deleted: string[] = [];
|
||||
try {
|
||||
const { stdout } = await execFileAsync('git', ['status', '--porcelain', '-u', '-z'], {
|
||||
const { stdout } = await execFileAsync('git', ['status', '--porcelain', '-z'], {
|
||||
cwd: dirPath,
|
||||
timeout: 5000,
|
||||
});
|
||||
@@ -746,7 +830,7 @@ export default class GitController extends ControllerModule {
|
||||
const entries: Entry[] = [];
|
||||
const submoduleDirtyEntries: Entry[] = [];
|
||||
try {
|
||||
const { stdout } = await execFileAsync('git', ['status', '--porcelain', '-u', '-z'], {
|
||||
const { stdout } = await execFileAsync('git', ['status', '--porcelain', '-z'], {
|
||||
cwd: dirPath,
|
||||
timeout: 5000,
|
||||
});
|
||||
@@ -1049,7 +1133,66 @@ export default class GitController extends ControllerModule {
|
||||
*/
|
||||
@IpcMethod()
|
||||
async getGitAheadBehind(dirPath: string): Promise<GitAheadBehind> {
|
||||
return computeGitAheadBehind(dirPath);
|
||||
const execFileAsync = promisify(execFile);
|
||||
try {
|
||||
await execFileAsync('git', ['fetch', '--no-tags', '--quiet', 'origin'], {
|
||||
cwd: dirPath,
|
||||
timeout: 10_000,
|
||||
});
|
||||
} catch {
|
||||
// swallow — fall through to compute against cached refs
|
||||
}
|
||||
try {
|
||||
const { stdout: upstreamOut } = await execFileAsync(
|
||||
'git',
|
||||
['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'],
|
||||
{ cwd: dirPath, timeout: 5000 },
|
||||
);
|
||||
const upstream = upstreamOut.trim();
|
||||
if (!upstream) return { ahead: 0, behind: 0, hasUpstream: false };
|
||||
|
||||
const { stdout } = await execFileAsync(
|
||||
'git',
|
||||
['rev-list', '--left-right', '--count', `${upstream}...HEAD`],
|
||||
{ cwd: dirPath, timeout: 5000 },
|
||||
);
|
||||
const [behindStr, aheadStr] = stdout.trim().split(/\s+/);
|
||||
const behind = Number.parseInt(behindStr ?? '0', 10) || 0;
|
||||
const ahead = Number.parseInt(aheadStr ?? '0', 10) || 0;
|
||||
|
||||
// `git push -u origin HEAD` always targets origin/<current-branch-name>,
|
||||
// which may differ from upstream (the branched-off-canary case).
|
||||
let pushTarget: string | undefined;
|
||||
let pushTargetExists = false;
|
||||
try {
|
||||
const { stdout: branchOut } = await execFileAsync(
|
||||
'git',
|
||||
['symbolic-ref', '--short', 'HEAD'],
|
||||
{ cwd: dirPath, timeout: 5000 },
|
||||
);
|
||||
const branch = branchOut.trim();
|
||||
if (branch) {
|
||||
pushTarget = `origin/${branch}`;
|
||||
try {
|
||||
await execFileAsync(
|
||||
'git',
|
||||
['rev-parse', '--verify', '--quiet', `refs/remotes/${pushTarget}`],
|
||||
{ cwd: dirPath, timeout: 5000 },
|
||||
);
|
||||
pushTargetExists = true;
|
||||
} catch {
|
||||
pushTargetExists = false;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// detached HEAD — leave pushTarget undefined
|
||||
}
|
||||
|
||||
return { ahead, behind, hasUpstream: true, pushTarget, pushTargetExists, upstream };
|
||||
} catch {
|
||||
// No upstream configured, detached HEAD, or git error — all treated as "no upstream"
|
||||
return { ahead: 0, behind: 0, hasUpstream: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { constants } from 'node:fs';
|
||||
import { access, mkdir, readFile, realpath, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { access, mkdir, readdir, readFile, realpath, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
type GrepContentParams,
|
||||
type GrepContentResult,
|
||||
type ListLocalFileParams,
|
||||
type ListProjectSkillsParams,
|
||||
type ListProjectSkillsResult,
|
||||
type LocalFilePreviewUrlParams,
|
||||
type LocalFilePreviewUrlResult,
|
||||
type LocalMoveFilesResultItem,
|
||||
@@ -121,6 +123,62 @@ const collectProjectDirectories = (files: string[], root: string): ProjectFileIn
|
||||
return [...directories].map((directory) => createProjectFileEntry(root, directory, true));
|
||||
};
|
||||
|
||||
const SKILL_FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/;
|
||||
|
||||
// Cap recursion to guard against pathological directory trees.
|
||||
const MAX_SKILL_FILE_COUNT = 1000;
|
||||
|
||||
const listSkillFilesRecursive = async (dir: string): Promise<string[]> => {
|
||||
const results: string[] = [];
|
||||
const stack: string[] = [dir];
|
||||
|
||||
while (stack.length > 0 && results.length < MAX_SKILL_FILE_COUNT) {
|
||||
const current = stack.pop()!;
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(current, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith('.')) continue;
|
||||
const full = path.join(current, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
stack.push(full);
|
||||
} else if (entry.isFile()) {
|
||||
results.push(toPosixRelativePath(path.relative(dir, full)));
|
||||
if (results.length >= MAX_SKILL_FILE_COUNT) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return results.sort();
|
||||
};
|
||||
|
||||
// Parse a minimal YAML frontmatter block for SKILL.md files.
|
||||
// Only handles `key: value` lines; multi-line block scalars fall back to the first line.
|
||||
const parseSkillFrontmatter = (raw: string): Record<string, string> => {
|
||||
const match = raw.match(SKILL_FRONTMATTER_RE);
|
||||
if (!match) return {};
|
||||
|
||||
const fields: Record<string, string> = {};
|
||||
for (const line of match[1].split(/\r?\n/)) {
|
||||
const colonIdx = line.indexOf(':');
|
||||
if (colonIdx === -1) continue;
|
||||
const key = line.slice(0, colonIdx).trim();
|
||||
if (!key || key.startsWith('#')) continue;
|
||||
let value = line.slice(colonIdx + 1).trim();
|
||||
if (value.startsWith('|') || value.startsWith('>')) continue;
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
fields[key] = value;
|
||||
}
|
||||
return fields;
|
||||
};
|
||||
|
||||
const createDetectedProjectFileEntry = async (
|
||||
root: string,
|
||||
absolutePath: string,
|
||||
@@ -603,6 +661,61 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan agent skill directories under the project root and return parsed
|
||||
* frontmatter for each SKILL.md. Used by the hetero agent's working sidebar
|
||||
* to surface skills available in the current project.
|
||||
*/
|
||||
@IpcMethod()
|
||||
async listProjectSkills(params: ListProjectSkillsParams): Promise<ListProjectSkillsResult> {
|
||||
const root = params.scope;
|
||||
const sources = ['.agents/skills', '.claude/skills'] as const;
|
||||
|
||||
for (const source of sources) {
|
||||
const dir = path.join(root, source);
|
||||
try {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
const skills = (
|
||||
await Promise.all(
|
||||
entries
|
||||
.filter((entry) => entry.isDirectory() || entry.isSymbolicLink())
|
||||
.map(async (entry) => {
|
||||
const skillDir = path.join(dir, entry.name);
|
||||
const skillFile = path.join(skillDir, 'SKILL.md');
|
||||
try {
|
||||
const raw = await readFile(skillFile, 'utf8');
|
||||
const fields = parseSkillFrontmatter(raw);
|
||||
const files = await listSkillFilesRecursive(skillDir);
|
||||
return {
|
||||
description: fields.description || undefined,
|
||||
fileCount: files.length,
|
||||
files,
|
||||
name: fields.name || entry.name,
|
||||
path: skillFile,
|
||||
skillDir,
|
||||
source,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
)
|
||||
)
|
||||
.filter((skill): skill is NonNullable<typeof skill> => skill !== null)
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
if (skills.length > 0) {
|
||||
await this.approveProjectRootForPreview(root);
|
||||
return { root, skills, source };
|
||||
}
|
||||
} catch {
|
||||
// Directory does not exist or is not readable; try the next candidate.
|
||||
}
|
||||
}
|
||||
|
||||
return { root, skills: [], source: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle IPC event for local file search
|
||||
*/
|
||||
|
||||
@@ -1,251 +0,0 @@
|
||||
import { readdir, readFile, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
type InitWorkspaceParams,
|
||||
type InitWorkspaceResult,
|
||||
type ListProjectSkillsParams,
|
||||
type ListProjectSkillsResult,
|
||||
type ProjectSkillItem,
|
||||
} from '@lobechat/electron-client-ipc';
|
||||
|
||||
import { detectRepoType } from '@/utils/git';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { ControllerModule, IpcMethod } from './index';
|
||||
|
||||
const logger = createLogger('controllers:WorkspaceCtr');
|
||||
|
||||
const SKILL_FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/;
|
||||
|
||||
// Cap recursion to guard against pathological directory trees.
|
||||
const MAX_SKILL_FILE_COUNT = 1000;
|
||||
|
||||
const toPosixRelativePath = (filePath: string) => filePath.split(path.sep).join('/');
|
||||
|
||||
const listSkillFilesRecursive = async (dir: string): Promise<string[]> => {
|
||||
const results: string[] = [];
|
||||
const stack: string[] = [dir];
|
||||
|
||||
while (stack.length > 0 && results.length < MAX_SKILL_FILE_COUNT) {
|
||||
const current = stack.pop()!;
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(current, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith('.')) continue;
|
||||
const full = path.join(current, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
stack.push(full);
|
||||
} else if (entry.isFile()) {
|
||||
results.push(toPosixRelativePath(path.relative(dir, full)));
|
||||
if (results.length >= MAX_SKILL_FILE_COUNT) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return results.sort();
|
||||
};
|
||||
|
||||
// Parse a minimal YAML frontmatter block for SKILL.md files.
|
||||
// Only handles `key: value` lines; multi-line block scalars fall back to the first line.
|
||||
const parseSkillFrontmatter = (raw: string): Record<string, string> => {
|
||||
const match = raw.match(SKILL_FRONTMATTER_RE);
|
||||
if (!match) return {};
|
||||
|
||||
const fields: Record<string, string> = {};
|
||||
for (const line of match[1].split(/\r?\n/)) {
|
||||
const colonIdx = line.indexOf(':');
|
||||
if (colonIdx === -1) continue;
|
||||
const key = line.slice(0, colonIdx).trim();
|
||||
if (!key || key.startsWith('#')) continue;
|
||||
let value = line.slice(colonIdx + 1).trim();
|
||||
if (value.startsWith('|') || value.startsWith('>')) continue;
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
fields[key] = value;
|
||||
}
|
||||
return fields;
|
||||
};
|
||||
|
||||
/**
|
||||
* WorkspaceCtr
|
||||
*
|
||||
* Owns "project workspace" scanning: discovering agent skills (`.agents/skills`
|
||||
* / `.claude/skills`) and project-root instructions (`AGENTS.md` / `CLAUDE.md`)
|
||||
* under a bound project directory. Split out of LocalFileCtr so the
|
||||
* workspace/agent-config concern is distinct from generic local file ops.
|
||||
*/
|
||||
export default class WorkspaceCtr extends ControllerModule {
|
||||
static override readonly groupName = 'workspace';
|
||||
|
||||
/**
|
||||
* Scan one skill source directory (e.g. `.agents/skills`) under `root` and
|
||||
* return parsed frontmatter for each `SKILL.md`. Returns `[]` when the source
|
||||
* directory is absent or unreadable. Unsorted — callers sort/merge.
|
||||
*/
|
||||
private async scanSkillsInSource(
|
||||
root: string,
|
||||
source: ProjectSkillItem['source'],
|
||||
): Promise<ProjectSkillItem[]> {
|
||||
const dir = path.join(root, source);
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
// Directory does not exist or is not readable.
|
||||
return [];
|
||||
}
|
||||
|
||||
const skills = await Promise.all(
|
||||
entries
|
||||
.filter((entry) => entry.isDirectory() || entry.isSymbolicLink())
|
||||
.map(async (entry) => {
|
||||
const skillDir = path.join(dir, entry.name);
|
||||
const skillFile = path.join(skillDir, 'SKILL.md');
|
||||
try {
|
||||
const raw = await readFile(skillFile, 'utf8');
|
||||
const fields = parseSkillFrontmatter(raw);
|
||||
const files = await listSkillFilesRecursive(skillDir);
|
||||
return {
|
||||
description: fields.description || undefined,
|
||||
fileCount: files.length,
|
||||
files,
|
||||
name: fields.name || entry.name,
|
||||
path: skillFile,
|
||||
skillDir,
|
||||
source,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return skills.filter((skill): skill is ProjectSkillItem => skill !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan agent skill directories under the project root and return parsed
|
||||
* frontmatter for each SKILL.md. Used by the hetero agent's working sidebar
|
||||
* to surface skills available in the current project. Returns the first
|
||||
* source directory that yields any skills (`.agents/skills` wins).
|
||||
*/
|
||||
@IpcMethod()
|
||||
async listProjectSkills(params: ListProjectSkillsParams): Promise<ListProjectSkillsResult> {
|
||||
const root = params.scope;
|
||||
const sources = ['.agents/skills', '.claude/skills'] as const;
|
||||
|
||||
for (const source of sources) {
|
||||
const skills = (await this.scanSkillsInSource(root, source)).sort((a, b) =>
|
||||
a.name.localeCompare(b.name),
|
||||
);
|
||||
|
||||
if (skills.length > 0) {
|
||||
await this.approveProjectRootForPreview(root);
|
||||
return { root, skills, source };
|
||||
}
|
||||
}
|
||||
|
||||
return { root, skills: [], source: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* One-call "workspace init" scan of a bound project directory: merge the
|
||||
* project skills from BOTH `.agents/skills` and `.claude/skills` (deduped by
|
||||
* name, `.agents/skills` winning) and read the project-root agent
|
||||
* instructions file (`AGENTS.md`, else `CLAUDE.md`). Driven server-side at run
|
||||
* start via the generic device RPC (not an LLM-visible tool) and cached onto
|
||||
* `devices.workingDirs[].workspace`.
|
||||
*
|
||||
* Approves the root for the `lobe-file://` preview protocol (same as
|
||||
* `listProjectSkills`) so the user can later click through to the scanned
|
||||
* skills / instructions in the UI.
|
||||
*/
|
||||
@IpcMethod()
|
||||
async initWorkspace(params: InitWorkspaceParams): Promise<InitWorkspaceResult> {
|
||||
const root = params.scope;
|
||||
const sources = ['.agents/skills', '.claude/skills'] as const;
|
||||
|
||||
const seen = new Set<string>();
|
||||
const skills: ProjectSkillItem[] = [];
|
||||
for (const source of sources) {
|
||||
for (const skill of await this.scanSkillsInSource(root, source)) {
|
||||
if (seen.has(skill.name)) continue;
|
||||
seen.add(skill.name);
|
||||
skills.push(skill);
|
||||
}
|
||||
}
|
||||
skills.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
const instructions = await this.readWorkspaceInstructions(root);
|
||||
|
||||
// Approve regardless of what was found — the run is now bound to this root,
|
||||
// so any later click-through to it should resolve through the preview
|
||||
// protocol even if the project carries neither skills nor instructions.
|
||||
await this.approveProjectRootForPreview(root);
|
||||
|
||||
return { instructions, root, skills };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a path exists on this device and is a directory, plus its git
|
||||
* repo type (`git` / `github` / none). Used to validate a manually-entered
|
||||
* working directory from a web / remote client (which can't browse this
|
||||
* device's filesystem) before binding it, and to render the right dir icon.
|
||||
*/
|
||||
@IpcMethod()
|
||||
async statPath(params: {
|
||||
path: string;
|
||||
}): Promise<{ exists: boolean; isDirectory: boolean; repoType?: 'git' | 'github' }> {
|
||||
try {
|
||||
const stats = await stat(params.path);
|
||||
if (!stats.isDirectory()) return { exists: true, isDirectory: false };
|
||||
const repoType = await detectRepoType(params.path);
|
||||
return { exists: true, isDirectory: true, repoType };
|
||||
} catch {
|
||||
return { exists: false, isDirectory: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the project-root agent instructions files. Collects every present
|
||||
* candidate (`AGENTS.md`, then `CLAUDE.md`) rather than first-match, since both
|
||||
* can coexist. Each body is capped so a pathologically large file can't bloat
|
||||
* the cached `workingDirs` payload or the injected system role.
|
||||
*/
|
||||
private async readWorkspaceInstructions(
|
||||
root: string,
|
||||
): Promise<InitWorkspaceResult['instructions']> {
|
||||
const MAX_INSTRUCTIONS_BYTES = 64 * 1024;
|
||||
const candidates = ['AGENTS.md', 'CLAUDE.md'] as const;
|
||||
|
||||
const instructions: InitWorkspaceResult['instructions'] = [];
|
||||
for (const source of candidates) {
|
||||
try {
|
||||
const raw = await readFile(path.join(root, source), 'utf8');
|
||||
const content =
|
||||
raw.length > MAX_INSTRUCTIONS_BYTES ? raw.slice(0, MAX_INSTRUCTIONS_BYTES) : raw;
|
||||
instructions.push({ content, source });
|
||||
} catch {
|
||||
// File absent or unreadable; skip it.
|
||||
}
|
||||
}
|
||||
|
||||
return instructions;
|
||||
}
|
||||
|
||||
private async approveProjectRootForPreview(root: string) {
|
||||
try {
|
||||
await this.app.localFileProtocolManager.approveIndexedProjectRoot(root);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to approve project preview root ${root}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -440,14 +440,8 @@ describe('HeterogeneousAgentCtr', () => {
|
||||
expect(command).toBe('codex');
|
||||
expect(cliArgs).not.toContain(prompt);
|
||||
expect(cliArgs).toEqual(
|
||||
expect.arrayContaining([
|
||||
'exec',
|
||||
'--json',
|
||||
'--skip-git-repo-check',
|
||||
'--dangerously-bypass-approvals-and-sandbox',
|
||||
]),
|
||||
expect.arrayContaining(['exec', '--json', '--skip-git-repo-check', '--full-auto']),
|
||||
);
|
||||
expect(cliArgs).not.toContain('--full-auto');
|
||||
expect(cliArgs).not.toContain('-');
|
||||
expect(writes).toEqual([prompt]);
|
||||
});
|
||||
|
||||
@@ -26,7 +26,6 @@ vi.mock('@/utils/logger', () => ({
|
||||
|
||||
// Mock child_process for the shared package
|
||||
vi.mock('node:child_process', () => ({
|
||||
execFile: vi.fn(),
|
||||
spawn: vi.fn(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { type App } from '@/core/App';
|
||||
|
||||
import WorkspaceCtr from '../WorkspaceCtr';
|
||||
|
||||
const { ipcMainHandleMock } = vi.hoisted(() => ({
|
||||
ipcMainHandleMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
handle: ipcMainHandleMock,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
readFile: vi.fn(),
|
||||
readdir: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockLocalFileProtocolManager = {
|
||||
approveIndexedProjectRoot: vi.fn(),
|
||||
};
|
||||
|
||||
const mockApp = {
|
||||
localFileProtocolManager: mockLocalFileProtocolManager,
|
||||
} as unknown as App;
|
||||
|
||||
describe('WorkspaceCtr', () => {
|
||||
let workspaceCtr: WorkspaceCtr;
|
||||
let mockFsPromises: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
mockFsPromises = await import('node:fs/promises');
|
||||
workspaceCtr = new WorkspaceCtr(mockApp);
|
||||
});
|
||||
|
||||
const dirent = (name: string, kind: 'dir' | 'file') => ({
|
||||
isDirectory: () => kind === 'dir',
|
||||
isFile: () => kind === 'file',
|
||||
isSymbolicLink: () => false,
|
||||
name,
|
||||
});
|
||||
|
||||
const frontmatter = (name: string, description: string) =>
|
||||
`---\nname: ${name}\ndescription: ${description}\n---\nbody`;
|
||||
|
||||
describe('initWorkspace', () => {
|
||||
it('merges skills from both sources and reads instruction files', async () => {
|
||||
vi.mocked(mockFsPromises.readdir).mockImplementation(async (dir: string) => {
|
||||
if (dir === '/proj/.agents/skills') return [dirent('spa-routes', 'dir')];
|
||||
if (dir === '/proj/.agents/skills/spa-routes') return [dirent('SKILL.md', 'file')];
|
||||
if (dir === '/proj/.claude/skills') return [dirent('reviewer', 'dir')];
|
||||
if (dir === '/proj/.claude/skills/reviewer') return [dirent('SKILL.md', 'file')];
|
||||
throw new Error('ENOENT');
|
||||
});
|
||||
vi.mocked(mockFsPromises.readFile).mockImplementation(async (file: string) => {
|
||||
if (file === '/proj/.agents/skills/spa-routes/SKILL.md')
|
||||
return frontmatter('spa-routes', 'SPA routing');
|
||||
if (file === '/proj/.claude/skills/reviewer/SKILL.md')
|
||||
return frontmatter('reviewer', 'Code review');
|
||||
if (file === '/proj/AGENTS.md') return '# Agents';
|
||||
if (file === '/proj/CLAUDE.md') return '# Claude';
|
||||
throw new Error('ENOENT');
|
||||
});
|
||||
|
||||
const result = await workspaceCtr.initWorkspace({ scope: '/proj' });
|
||||
|
||||
expect(result.skills.map((s) => s.name)).toEqual(['reviewer', 'spa-routes']);
|
||||
expect(result.instructions).toEqual([
|
||||
{ content: '# Agents', source: 'AGENTS.md' },
|
||||
{ content: '# Claude', source: 'CLAUDE.md' },
|
||||
]);
|
||||
// Approves the scanned root for the lobe-file:// preview protocol.
|
||||
expect(mockLocalFileProtocolManager.approveIndexedProjectRoot).toHaveBeenCalledWith('/proj');
|
||||
});
|
||||
|
||||
it('dedupes skills by name with .agents/skills winning', async () => {
|
||||
vi.mocked(mockFsPromises.readdir).mockImplementation(async (dir: string) => {
|
||||
if (dir === '/proj/.agents/skills') return [dirent('shared', 'dir')];
|
||||
if (dir === '/proj/.claude/skills') return [dirent('shared', 'dir')];
|
||||
if (dir.endsWith('/shared')) return [dirent('SKILL.md', 'file')];
|
||||
throw new Error('ENOENT');
|
||||
});
|
||||
vi.mocked(mockFsPromises.readFile).mockImplementation(async (file: string) => {
|
||||
if (file === '/proj/.agents/skills/shared/SKILL.md')
|
||||
return frontmatter('shared', 'from agents');
|
||||
if (file === '/proj/.claude/skills/shared/SKILL.md')
|
||||
return frontmatter('shared', 'from claude');
|
||||
throw new Error('ENOENT');
|
||||
});
|
||||
|
||||
const result = await workspaceCtr.initWorkspace({ scope: '/proj' });
|
||||
|
||||
expect(result.skills).toHaveLength(1);
|
||||
expect(result.skills[0]).toMatchObject({
|
||||
description: 'from agents',
|
||||
path: '/proj/.agents/skills/shared/SKILL.md',
|
||||
});
|
||||
});
|
||||
|
||||
it('caps instruction file content', async () => {
|
||||
vi.mocked(mockFsPromises.readdir).mockRejectedValue(new Error('ENOENT'));
|
||||
const huge = 'x'.repeat(100 * 1024);
|
||||
vi.mocked(mockFsPromises.readFile).mockImplementation(async (file: string) => {
|
||||
if (file === '/proj/AGENTS.md') return huge;
|
||||
throw new Error('ENOENT');
|
||||
});
|
||||
|
||||
const result = await workspaceCtr.initWorkspace({ scope: '/proj' });
|
||||
|
||||
expect(result.skills).toEqual([]);
|
||||
expect(result.instructions).toHaveLength(1);
|
||||
expect(result.instructions[0].content.length).toBe(64 * 1024);
|
||||
});
|
||||
|
||||
it('returns empty skills and instructions when nothing is present', async () => {
|
||||
vi.mocked(mockFsPromises.readdir).mockRejectedValue(new Error('ENOENT'));
|
||||
vi.mocked(mockFsPromises.readFile).mockRejectedValue(new Error('ENOENT'));
|
||||
|
||||
const result = await workspaceCtr.initWorkspace({ scope: '/proj' });
|
||||
|
||||
expect(result).toEqual({ instructions: [], root: '/proj', skills: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('listProjectSkills', () => {
|
||||
it('returns the first source with skills (.agents/skills wins) and ignores .claude', async () => {
|
||||
vi.mocked(mockFsPromises.readdir).mockImplementation(async (dir: string) => {
|
||||
if (dir === '/proj/.agents/skills') return [dirent('alpha', 'dir')];
|
||||
if (dir === '/proj/.agents/skills/alpha') return [dirent('SKILL.md', 'file')];
|
||||
throw new Error('ENOENT');
|
||||
});
|
||||
vi.mocked(mockFsPromises.readFile).mockResolvedValue(frontmatter('alpha', 'A'));
|
||||
|
||||
const result = await workspaceCtr.listProjectSkills({ scope: '/proj' });
|
||||
|
||||
expect(result.source).toBe('.agents/skills');
|
||||
expect(result.skills.map((s) => s.name)).toEqual(['alpha']);
|
||||
});
|
||||
|
||||
it('returns empty + null source when no skills exist', async () => {
|
||||
vi.mocked(mockFsPromises.readdir).mockRejectedValue(new Error('ENOENT'));
|
||||
|
||||
const result = await workspaceCtr.listProjectSkills({ scope: '/proj' });
|
||||
|
||||
expect(result).toEqual({ root: '/proj', skills: [], source: null });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -24,7 +24,6 @@ import SystemController from './SystemCtr';
|
||||
import ToolDetectorCtr from './ToolDetectorCtr';
|
||||
import TrayMenuCtr from './TrayMenuCtr';
|
||||
import UpdaterCtr from './UpdaterCtr';
|
||||
import WorkspaceCtr from './WorkspaceCtr';
|
||||
|
||||
export const controllerIpcConstructors = [
|
||||
HeterogeneousAgentCtr,
|
||||
@@ -51,7 +50,6 @@ export const controllerIpcConstructors = [
|
||||
ToolDetectorCtr,
|
||||
TrayMenuCtr,
|
||||
UpdaterCtr,
|
||||
WorkspaceCtr,
|
||||
] as const satisfies readonly IpcServiceConstructor[];
|
||||
|
||||
type DesktopControllerIpcConstructors = typeof controllerIpcConstructors;
|
||||
|
||||
@@ -10,6 +10,7 @@ import * as electronIs from 'electron-is';
|
||||
import { name } from '@/../../package.json';
|
||||
import { binDir, buildDir } from '@/const/dir';
|
||||
import { isDev } from '@/const/env';
|
||||
import { ELECTRON_BE_PROTOCOL_SCHEME } from '@/const/protocol';
|
||||
import type { IControlModule } from '@/controllers';
|
||||
import AuthCtr from '@/controllers/AuthCtr';
|
||||
import { generateCliWrapper, getCliWrapperDir } from '@/modules/cliEmbedding';
|
||||
@@ -28,7 +29,6 @@ import type { IServiceModule } from '@/services';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { BrowserManager } from './browser/BrowserManager';
|
||||
import { backendProxyProtocolManager } from './infrastructure/BackendProxyProtocolManager';
|
||||
import { I18nManager } from './infrastructure/I18nManager';
|
||||
import { IoCContainer } from './infrastructure/IoCContainer';
|
||||
import { LocalFileProtocolManager } from './infrastructure/LocalFileProtocolManager';
|
||||
@@ -104,17 +104,21 @@ export class App {
|
||||
this.storeManager = new StoreManager(this);
|
||||
|
||||
this.rendererUrlManager = new RendererUrlManager();
|
||||
// Wire the backend reverse-proxy as an `app://` interceptor: keeps
|
||||
// RendererUrlManager ignorant of "what counts as a backend path" while
|
||||
// letting BackendProxyProtocolManager own that knowledge.
|
||||
this.rendererUrlManager.addRequestInterceptor(
|
||||
backendProxyProtocolManager.createAppRequestInterceptor(),
|
||||
);
|
||||
this.localFileProtocolManager = new LocalFileProtocolManager();
|
||||
void this.localFileProtocolManager.approveWorkspaceRoots(
|
||||
this.storeManager.get('localFileWorkspaceRoots', []),
|
||||
);
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{
|
||||
privileges: {
|
||||
allowServiceWorkers: true,
|
||||
corsEnabled: true,
|
||||
secure: true,
|
||||
standard: true,
|
||||
supportFetchAPI: true,
|
||||
},
|
||||
scheme: ELECTRON_BE_PROTOCOL_SCHEME,
|
||||
},
|
||||
this.rendererUrlManager.protocolScheme,
|
||||
this.localFileProtocolManager.protocolScheme,
|
||||
]);
|
||||
@@ -427,6 +431,7 @@ export class App {
|
||||
if (!isDev) return;
|
||||
|
||||
logger.debug('Setting up dev branding');
|
||||
app.setName('lobehub-desktop-dev');
|
||||
if (electronIs.macOS()) {
|
||||
app.dock!.setIcon(path.join(buildDir, 'icon-dev.png'));
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { app, BrowserWindow, ipcMain, screen, session as electronSession, shell
|
||||
|
||||
import { preloadDir, resourcesDir } from '@/const/dir';
|
||||
import { isMac } from '@/const/env';
|
||||
import { ELECTRON_BE_PROTOCOL_SCHEME } from '@/const/protocol';
|
||||
import RemoteServerConfigCtr from '@/controllers/RemoteServerConfigCtr';
|
||||
import { backendProxyProtocolManager } from '@/core/infrastructure/BackendProxyProtocolManager';
|
||||
import { appendVercelCookie, setResponseHeader } from '@/utils/http-headers';
|
||||
@@ -560,10 +561,7 @@ export default class Browser {
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind this window's session to the backend proxy. The `app://` request
|
||||
* interceptor (wired in `App.ts`) consumes this context to route
|
||||
* `/trpc`, `/webapi`, `/api/auth`, and `/market` requests to the remote
|
||||
* LobeHub server.
|
||||
* Rewrite tRPC requests to remote server and inject OIDC token
|
||||
*/
|
||||
private setupRemoteServerRequestHook(browserWindow: BrowserWindow): void {
|
||||
const session = browserWindow.webContents.session;
|
||||
@@ -579,6 +577,7 @@ export default class Browser {
|
||||
const remoteServerUrl = await remoteServerConfigCtr.getRemoteServerUrl(config);
|
||||
return remoteServerUrl || null;
|
||||
},
|
||||
scheme: ELECTRON_BE_PROTOCOL_SCHEME,
|
||||
source: this.identifier,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,36 +1,44 @@
|
||||
import { AUTH_REQUIRED_HEADER } from '@lobechat/desktop-bridge';
|
||||
import { BrowserWindow, type Session, session as electronSession } from 'electron';
|
||||
import { BrowserWindow, type Session } from 'electron';
|
||||
|
||||
import { isDev } from '@/const/env';
|
||||
import { isBackendPath } from '@/const/protocol';
|
||||
import { appendVercelCookie } from '@/utils/http-headers';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
import { netFetch } from '@/utils/net-fetch';
|
||||
|
||||
import type { RendererRequestInterceptor } from './RendererProtocolManager';
|
||||
|
||||
interface BackendProxyContext {
|
||||
interface BackendProxyProtocolManagerOptions {
|
||||
getAccessToken: () => Promise<string | undefined | null>;
|
||||
rewriteUrl: (rawUrl: string) => Promise<string | null>;
|
||||
scheme: string;
|
||||
/**
|
||||
* Used for log prefixes. e.g. window identifier
|
||||
*/
|
||||
source?: string;
|
||||
}
|
||||
|
||||
interface BackendProxyRemoteBaseOptions {
|
||||
interface BackendProxyProtocolManagerRemoteBaseOptions {
|
||||
getAccessToken: () => Promise<string | undefined | null>;
|
||||
getRemoteBaseUrl: () => Promise<string | undefined | null>;
|
||||
scheme: string;
|
||||
/**
|
||||
* Used for log prefixes. e.g. window identifier
|
||||
*/
|
||||
source?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds per-session proxy context for routing renderer-originated backend
|
||||
* requests (`/trpc`, `/webapi`, `/api/auth`, `/market`) to the remote LobeHub
|
||||
* server. The context is consumed by `createAppRequestInterceptor`, which the
|
||||
* `app://` protocol manager invokes before its static / Vite fallback.
|
||||
* Manage `lobe-backend://` (or any custom scheme) transparent proxy handler registration.
|
||||
* Keeps a WeakSet per session to avoid duplicate handler registration.
|
||||
*/
|
||||
export class BackendProxyProtocolManager {
|
||||
private readonly contexts = new WeakMap<Session, BackendProxyContext>();
|
||||
private readonly handledSessions = new WeakSet<Session>();
|
||||
private readonly logger = createLogger('core:BackendProxyProtocolManager');
|
||||
|
||||
/**
|
||||
* Debounce timer for authorization required notifications.
|
||||
* Prevents multiple rapid 401 responses from triggering duplicate notifications.
|
||||
*/
|
||||
|
||||
private authRequiredDebounceTimer: NodeJS.Timeout | null = null;
|
||||
private static readonly AUTH_REQUIRED_DEBOUNCE_MS = 1000;
|
||||
|
||||
@@ -53,12 +61,10 @@ export class BackendProxyProtocolManager {
|
||||
}, BackendProxyProtocolManager.AUTH_REQUIRED_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind a session's proxy context using a remote-base-URL provider. Backend
|
||||
* paths get rewritten onto the remote base; same-origin requests pass through
|
||||
* (returns null so the `app://` handler falls back to its static / Vite path).
|
||||
*/
|
||||
registerWithRemoteBaseUrl(session: Session, options: BackendProxyRemoteBaseOptions) {
|
||||
registerWithRemoteBaseUrl(
|
||||
session: Session,
|
||||
options: BackendProxyProtocolManagerRemoteBaseOptions,
|
||||
) {
|
||||
let lastRemoteBaseUrl: string | undefined;
|
||||
|
||||
const rewriteUrl = async (rawUrl: string) => {
|
||||
@@ -93,117 +99,90 @@ export class BackendProxyProtocolManager {
|
||||
this.register(session, {
|
||||
getAccessToken: options.getAccessToken,
|
||||
rewriteUrl,
|
||||
scheme: options.scheme,
|
||||
source: options.source,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind a session's proxy context. Subsequent backend-path requests on this
|
||||
* session will be rewritten via `rewriteUrl` and have `Oidc-Auth` injected.
|
||||
*/
|
||||
register(session: Session, context: BackendProxyContext) {
|
||||
if (!session) return;
|
||||
this.contexts.set(session, context);
|
||||
}
|
||||
register(session: Session, options: BackendProxyProtocolManagerOptions) {
|
||||
if (!session || this.handledSessions.has(session)) return;
|
||||
|
||||
/**
|
||||
* Build an `app://` request interceptor that diverts backend-prefixed paths
|
||||
* (trpc / webapi / api/auth / market) through `proxy()` against the default
|
||||
* session. Plug into `RendererProtocolManager.addRequestInterceptor` so the
|
||||
* protocol manager doesn't need to know what "backend" means.
|
||||
*
|
||||
* Returns `null` for non-backend paths (lets the fallback run). Returns a
|
||||
* 502 if the backend context isn't wired up yet — for backend prefixes we
|
||||
* must never fall through to the SPA HTML / Vite path.
|
||||
*/
|
||||
createAppRequestInterceptor(): RendererRequestInterceptor {
|
||||
return async (request) => {
|
||||
const url = new URL(request.url);
|
||||
if (!isBackendPath(url.pathname)) return null;
|
||||
const logPrefix = options.source ? `[${options.source}] BackendProxy` : '[BackendProxy]';
|
||||
|
||||
const session = electronSession.defaultSession;
|
||||
if (!session) return new Response('Backend Proxy Unavailable', { status: 502 });
|
||||
session.protocol.handle(options.scheme, async (request: Request): Promise<Response | null> => {
|
||||
try {
|
||||
const rewrittenUrl = await options.rewriteUrl(request.url);
|
||||
if (!rewrittenUrl) return null;
|
||||
|
||||
const proxied = await this.proxy(request, session);
|
||||
return proxied ?? new Response('Backend Proxy Unavailable', { status: 502 });
|
||||
};
|
||||
}
|
||||
const headers = new Headers(request.headers);
|
||||
const token = await options.getAccessToken();
|
||||
if (token) {
|
||||
headers.set('Oidc-Auth', token);
|
||||
}
|
||||
appendVercelCookie(headers);
|
||||
|
||||
/**
|
||||
* Proxy a renderer-originated request through the remote LobeHub backend.
|
||||
* Returns `null` if the session has no proxy context registered yet (caller
|
||||
* decides how to fall back). Throws on upstream fetch failure to mirror the
|
||||
* original `protocol.handle` semantics.
|
||||
*/
|
||||
async proxy(request: Request, session: Session): Promise<Response | null> {
|
||||
const context = this.contexts.get(session);
|
||||
if (!context) return null;
|
||||
const requestInit: RequestInit & { duplex?: 'half' } = {
|
||||
headers,
|
||||
method: request.method,
|
||||
};
|
||||
|
||||
const logPrefix = context.source ? `[${context.source}] BackendProxy` : '[BackendProxy]';
|
||||
// Only forward body for non-GET/HEAD requests
|
||||
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
||||
const body = request.body ?? undefined;
|
||||
if (body) {
|
||||
requestInit.body = body;
|
||||
// Node.js (undici) requires `duplex` when sending a streaming body
|
||||
requestInit.duplex = 'half';
|
||||
}
|
||||
}
|
||||
|
||||
const rewrittenUrl = await context.rewriteUrl(request.url);
|
||||
if (!rewrittenUrl) return null;
|
||||
let upstreamResponse: Response;
|
||||
try {
|
||||
upstreamResponse = await netFetch(rewrittenUrl, requestInit);
|
||||
} catch (error) {
|
||||
this.logger.error(`${logPrefix} upstream fetch failed: ${rewrittenUrl}`, error);
|
||||
|
||||
const headers = new Headers(request.headers);
|
||||
const token = await context.getAccessToken();
|
||||
if (token) {
|
||||
headers.set('Oidc-Auth', token);
|
||||
}
|
||||
appendVercelCookie(headers);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const requestInit: RequestInit & { duplex?: 'half' } = {
|
||||
headers,
|
||||
method: request.method,
|
||||
};
|
||||
const responseHeaders = new Headers(upstreamResponse.headers);
|
||||
const allowOrigin = request.headers.get('Origin') || undefined;
|
||||
|
||||
// Only forward body for non-GET/HEAD requests
|
||||
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
||||
const body = request.body ?? undefined;
|
||||
if (body) {
|
||||
requestInit.body = body;
|
||||
// Node.js (undici) requires `duplex` when sending a streaming body
|
||||
requestInit.duplex = 'half';
|
||||
if (allowOrigin) {
|
||||
responseHeaders.set('Access-Control-Allow-Origin', allowOrigin);
|
||||
responseHeaders.set('Access-Control-Allow-Credentials', 'true');
|
||||
}
|
||||
|
||||
if (isDev) {
|
||||
responseHeaders.set('x-dev-oidc-auth', token);
|
||||
}
|
||||
|
||||
responseHeaders.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
||||
responseHeaders.set('Access-Control-Allow-Headers', '*');
|
||||
responseHeaders.set('X-Src-Url', rewrittenUrl);
|
||||
|
||||
// Re-auth prompt: rely on X-Auth-Required (set by tRPC responseMeta for UNAUTHORIZED).
|
||||
// Batched tRPC responses can use HTTP 207 when calls mix success (200) and UNAUTHORIZED (401);
|
||||
// checking only status === 401 misses that case and the login modal never opens.
|
||||
// Other failures keep 401 without this header (e.g., invalid API keys) and must not notify here.
|
||||
const authRequired = upstreamResponse.headers.get(AUTH_REQUIRED_HEADER) === 'true';
|
||||
if (authRequired) {
|
||||
this.notifyAuthorizationRequired();
|
||||
}
|
||||
|
||||
return new Response(upstreamResponse.body, {
|
||||
headers: responseHeaders,
|
||||
status: upstreamResponse.status,
|
||||
statusText: upstreamResponse.statusText,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(`${logPrefix} protocol.handle error:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
let upstreamResponse: Response;
|
||||
try {
|
||||
upstreamResponse = await netFetch(rewrittenUrl, requestInit);
|
||||
} catch (error) {
|
||||
this.logger.error(`${logPrefix} upstream fetch failed: ${rewrittenUrl}`, error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const responseHeaders = new Headers(upstreamResponse.headers);
|
||||
const allowOrigin = request.headers.get('Origin') || undefined;
|
||||
|
||||
if (allowOrigin) {
|
||||
responseHeaders.set('Access-Control-Allow-Origin', allowOrigin);
|
||||
responseHeaders.set('Access-Control-Allow-Credentials', 'true');
|
||||
}
|
||||
|
||||
if (isDev) {
|
||||
responseHeaders.set('x-dev-oidc-auth', token);
|
||||
}
|
||||
|
||||
responseHeaders.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
||||
responseHeaders.set('Access-Control-Allow-Headers', '*');
|
||||
responseHeaders.set('X-Src-Url', rewrittenUrl);
|
||||
|
||||
// Re-auth prompt: rely on X-Auth-Required (set by tRPC responseMeta for UNAUTHORIZED).
|
||||
// Batched tRPC responses can use HTTP 207 when calls mix success (200) and UNAUTHORIZED (401);
|
||||
// checking only status === 401 misses that case and the login modal never opens.
|
||||
// Other failures keep 401 without this header (e.g., invalid API keys) and must not notify here.
|
||||
const authRequired = upstreamResponse.headers.get(AUTH_REQUIRED_HEADER) === 'true';
|
||||
if (authRequired) {
|
||||
this.notifyAuthorizationRequired();
|
||||
}
|
||||
|
||||
return new Response(upstreamResponse.body, {
|
||||
headers: responseHeaders,
|
||||
status: upstreamResponse.status,
|
||||
statusText: upstreamResponse.statusText,
|
||||
});
|
||||
|
||||
this.logger.debug(`${logPrefix} protocol handler registered for ${options.scheme}`);
|
||||
this.handledSessions.add(session);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,61 +10,41 @@ import { getExportMimeType } from '../../utils/mime';
|
||||
|
||||
type ResolveRendererFilePath = (url: URL) => Promise<string | null>;
|
||||
|
||||
/**
|
||||
* Request interceptor: inspects an `app://` request and either produces a Response
|
||||
* (short-circuits the pipeline) or returns `null` to let the next interceptor — and
|
||||
* ultimately the fallback strategy — try.
|
||||
*/
|
||||
export type RendererRequestInterceptor = (request: Request) => Promise<Response | null>;
|
||||
|
||||
/**
|
||||
* Fallback strategy invoked when no interceptor handled the request. Static
|
||||
* (production) and Vite-proxy (development) implementations live below; the
|
||||
* protocol manager is agnostic to which one is plugged in.
|
||||
*/
|
||||
export interface RendererFallbackStrategy {
|
||||
handle: (request: Request, url: URL) => Promise<Response>;
|
||||
}
|
||||
|
||||
const RENDERER_PROTOCOL_PRIVILEGES = {
|
||||
allowServiceWorkers: true,
|
||||
corsEnabled: true,
|
||||
secure: true,
|
||||
standard: true,
|
||||
stream: true,
|
||||
supportFetchAPI: true,
|
||||
} as const;
|
||||
|
||||
interface RendererProtocolManagerOptions {
|
||||
fallback: RendererFallbackStrategy;
|
||||
host?: string;
|
||||
rendererDir: string;
|
||||
resolveRendererFilePath: ResolveRendererFilePath;
|
||||
scheme?: string;
|
||||
}
|
||||
|
||||
const RENDERER_DIR = 'renderer';
|
||||
|
||||
export class RendererProtocolManager {
|
||||
private readonly scheme: string;
|
||||
private readonly host: string;
|
||||
private readonly fallback: RendererFallbackStrategy;
|
||||
private readonly interceptors: RendererRequestInterceptor[] = [];
|
||||
private readonly rendererDir: string;
|
||||
private readonly resolveRendererFilePath: ResolveRendererFilePath;
|
||||
private handlerRegistered = false;
|
||||
|
||||
constructor(options: RendererProtocolManagerOptions) {
|
||||
this.scheme = options.scheme ?? 'app';
|
||||
this.host = options.host ?? RENDERER_DIR;
|
||||
this.fallback = options.fallback;
|
||||
const { rendererDir, resolveRendererFilePath } = options;
|
||||
|
||||
this.scheme = 'app';
|
||||
this.host = RENDERER_DIR;
|
||||
this.rendererDir = rendererDir;
|
||||
this.resolveRendererFilePath = resolveRendererFilePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a request interceptor that runs before the fallback strategy.
|
||||
* Interceptors are invoked in registration order; the first one to return a
|
||||
* non-null Response short-circuits the pipeline.
|
||||
* Get the full renderer URL with scheme and host
|
||||
*/
|
||||
addRequestInterceptor(interceptor: RendererRequestInterceptor) {
|
||||
this.interceptors.push(interceptor);
|
||||
}
|
||||
|
||||
getRendererUrl(): string {
|
||||
return `${this.scheme}://${this.host}`;
|
||||
}
|
||||
@@ -75,30 +55,169 @@ export class RendererProtocolManager {
|
||||
scheme: this.scheme,
|
||||
};
|
||||
}
|
||||
|
||||
registerHandler() {
|
||||
if (this.handlerRegistered) return;
|
||||
|
||||
if (!pathExistsSync(this.rendererDir)) {
|
||||
createLogger('core:RendererProtocolManager').warn(
|
||||
`Renderer directory not found, skip static handler: ${this.rendererDir}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const logger = createLogger('core:RendererProtocolManager');
|
||||
logger.debug(`Registering ${this.scheme}:// handler for host ${this.host}`);
|
||||
logger.debug(
|
||||
`Registering renderer ${this.scheme}:// handler for production export at host ${this.host}`,
|
||||
);
|
||||
|
||||
const register = () => {
|
||||
if (this.handlerRegistered) return;
|
||||
|
||||
protocol.handle(this.scheme, async (request) => {
|
||||
const url = new URL(request.url);
|
||||
const hostname = url.hostname;
|
||||
const pathname = url.pathname;
|
||||
const isAssetRequest = this.isAssetRequest(pathname);
|
||||
const isExplicit404HtmlRequest = pathname.endsWith('/404.html');
|
||||
|
||||
if (url.hostname !== this.host) {
|
||||
if (hostname !== this.host) {
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
// Pipeline: first interceptor to return a Response wins; null = pass through.
|
||||
for (const interceptor of this.interceptors) {
|
||||
const response = await interceptor(request);
|
||||
if (response) return response;
|
||||
const buildFileResponse = async (targetPath: string) => {
|
||||
const fileStat = await stat(targetPath);
|
||||
const totalSize = fileStat.size;
|
||||
|
||||
const buffer = await readFile(targetPath);
|
||||
const headers = new Headers();
|
||||
const mimeType = getExportMimeType(targetPath);
|
||||
|
||||
if (mimeType) headers.set('Content-Type', mimeType);
|
||||
|
||||
// Chromium media pipeline relies on byte ranges for video/audio.
|
||||
headers.set('Accept-Ranges', 'bytes');
|
||||
|
||||
const method = request.method?.toUpperCase?.() || 'GET';
|
||||
const rangeHeader = request.headers.get('range') || request.headers.get('Range');
|
||||
|
||||
// HEAD (no range): return only headers
|
||||
if (method === 'HEAD' && !rangeHeader) {
|
||||
headers.set('Content-Length', String(totalSize));
|
||||
return new Response(null, { headers, status: 200 });
|
||||
}
|
||||
|
||||
// No Range: return entire file
|
||||
if (!rangeHeader) {
|
||||
headers.set('Content-Length', String(buffer.byteLength));
|
||||
return new Response(buffer, { headers, status: 200 });
|
||||
}
|
||||
|
||||
// Range: bytes=start-end | bytes=-suffixLength
|
||||
const match = /^bytes=(\d*)-(\d*)$/i.exec(rangeHeader.trim());
|
||||
if (!match) {
|
||||
headers.set('Content-Range', `bytes */${totalSize}`);
|
||||
return new Response(null, {
|
||||
headers,
|
||||
status: 416,
|
||||
statusText: 'Range Not Satisfiable',
|
||||
});
|
||||
}
|
||||
|
||||
const [, startRaw, endRaw] = match;
|
||||
let start = startRaw ? Number(startRaw) : NaN;
|
||||
let end = endRaw ? Number(endRaw) : NaN;
|
||||
|
||||
// Suffix range: bytes=-N (last N bytes)
|
||||
if (!startRaw && endRaw) {
|
||||
const suffixLength = Number(endRaw);
|
||||
if (!Number.isFinite(suffixLength) || suffixLength <= 0) {
|
||||
headers.set('Content-Range', `bytes */${totalSize}`);
|
||||
return new Response(null, {
|
||||
headers,
|
||||
status: 416,
|
||||
statusText: 'Range Not Satisfiable',
|
||||
});
|
||||
}
|
||||
start = Math.max(totalSize - suffixLength, 0);
|
||||
end = totalSize - 1;
|
||||
} else {
|
||||
if (!Number.isFinite(start)) start = 0;
|
||||
if (!Number.isFinite(end)) end = totalSize - 1;
|
||||
}
|
||||
|
||||
if (start < 0 || end < 0 || start > end || start >= totalSize) {
|
||||
headers.set('Content-Range', `bytes */${totalSize}`);
|
||||
return new Response(null, {
|
||||
headers,
|
||||
status: 416,
|
||||
statusText: 'Range Not Satisfiable',
|
||||
});
|
||||
}
|
||||
|
||||
end = Math.min(end, totalSize - 1);
|
||||
const sliced = buffer.subarray(start, end + 1);
|
||||
|
||||
headers.set('Content-Range', `bytes ${start}-${end}/${totalSize}`);
|
||||
headers.set('Content-Length', String(sliced.byteLength));
|
||||
|
||||
if (method === 'HEAD') {
|
||||
return new Response(null, { headers, status: 206, statusText: 'Partial Content' });
|
||||
}
|
||||
|
||||
return new Response(sliced, { headers, status: 206, statusText: 'Partial Content' });
|
||||
};
|
||||
|
||||
const resolveEntryFilePath = () =>
|
||||
this.resolveRendererFilePath(new URL(`${this.scheme}://${this.host}/`));
|
||||
|
||||
let filePath = await this.resolveRendererFilePath(url);
|
||||
|
||||
// If the resolved file is the export 404 page, treat it as missing so we can
|
||||
// fall back to the entry HTML for SPA routing (unless explicitly requested).
|
||||
if (filePath && this.is404Html(filePath) && !isExplicit404HtmlRequest) {
|
||||
filePath = null;
|
||||
}
|
||||
|
||||
return this.fallback.handle(request, url);
|
||||
if (!filePath) {
|
||||
if (isAssetRequest) {
|
||||
return new Response('File Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
// Fallback to entry HTML for unknown routes (SPA-like behavior)
|
||||
filePath = await resolveEntryFilePath();
|
||||
if (!filePath || this.is404Html(filePath)) {
|
||||
return new Response('Render file Not Found', { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return await buildFileResponse(filePath);
|
||||
} catch (error) {
|
||||
const code = (error as any).code;
|
||||
|
||||
if (code === 'ENOENT') {
|
||||
logger.warn(`Export asset missing on disk ${filePath}, falling back`, error);
|
||||
|
||||
if (isAssetRequest) {
|
||||
return new Response('File Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
const fallbackPath = await resolveEntryFilePath();
|
||||
if (!fallbackPath || this.is404Html(fallbackPath)) {
|
||||
return new Response('Render file Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
try {
|
||||
return await buildFileResponse(fallbackPath);
|
||||
} catch (fallbackError) {
|
||||
logger.error(`Failed to serve fallback entry ${fallbackPath}:`, fallbackError);
|
||||
return new Response('Internal Server Error', { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
logger.error(`Failed to serve export asset ${filePath}:`, error);
|
||||
return new Response('Internal Server Error', { status: 500 });
|
||||
}
|
||||
});
|
||||
|
||||
this.handlerRegistered = true;
|
||||
@@ -108,165 +227,10 @@ export class RendererProtocolManager {
|
||||
register();
|
||||
} else {
|
||||
// protocol.handle needs the default session, which is only available after ready
|
||||
|
||||
app.whenReady().then(register);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Production fallback: serve the renderer's static export from disk. Resolves
|
||||
* the file via `resolveRendererFilePath`, falls back to the SPA entry HTML for
|
||||
* unknown routes, and supports HTTP `Range` requests for media playback.
|
||||
*/
|
||||
export class StaticRendererFallback implements RendererFallbackStrategy {
|
||||
private readonly rendererDir: string;
|
||||
private readonly resolveRendererFilePath: ResolveRendererFilePath;
|
||||
private readonly logger = createLogger('core:StaticRendererFallback');
|
||||
|
||||
constructor(rendererDir: string, resolveRendererFilePath: ResolveRendererFilePath) {
|
||||
this.rendererDir = rendererDir;
|
||||
this.resolveRendererFilePath = resolveRendererFilePath;
|
||||
|
||||
if (!pathExistsSync(this.rendererDir)) {
|
||||
this.logger.warn(`Renderer directory not found: ${this.rendererDir}`);
|
||||
}
|
||||
}
|
||||
|
||||
async handle(request: Request, url: URL): Promise<Response> {
|
||||
const pathname = url.pathname;
|
||||
const isAssetRequest = this.isAssetRequest(pathname);
|
||||
const isExplicit404HtmlRequest = pathname.endsWith('/404.html');
|
||||
|
||||
let filePath = await this.resolveRendererFilePath(url);
|
||||
|
||||
if (filePath && this.is404Html(filePath) && !isExplicit404HtmlRequest) {
|
||||
filePath = null;
|
||||
}
|
||||
|
||||
if (!filePath) {
|
||||
if (isAssetRequest) {
|
||||
return new Response('File Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
filePath = await this.resolveEntryFilePath(url);
|
||||
if (!filePath || this.is404Html(filePath)) {
|
||||
return new Response('Render file Not Found', { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.buildFileResponse(request, filePath);
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
|
||||
if (code === 'ENOENT') {
|
||||
this.logger.warn(`Export asset missing on disk ${filePath}, falling back`, error);
|
||||
|
||||
if (isAssetRequest) {
|
||||
return new Response('File Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
const fallbackPath = await this.resolveEntryFilePath(url);
|
||||
if (!fallbackPath || this.is404Html(fallbackPath)) {
|
||||
return new Response('Render file Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.buildFileResponse(request, fallbackPath);
|
||||
} catch (fallbackError) {
|
||||
this.logger.error(`Failed to serve fallback entry ${fallbackPath}:`, fallbackError);
|
||||
return new Response('Internal Server Error', { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.error(`Failed to serve export asset ${filePath}:`, error);
|
||||
return new Response('Internal Server Error', { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
private resolveEntryFilePath(url: URL) {
|
||||
return this.resolveRendererFilePath(new URL(`${url.protocol}//${url.host}/`));
|
||||
}
|
||||
|
||||
private async buildFileResponse(request: Request, targetPath: string): Promise<Response> {
|
||||
const fileStat = await stat(targetPath);
|
||||
const totalSize = fileStat.size;
|
||||
|
||||
const buffer = await readFile(targetPath);
|
||||
const headers = new Headers();
|
||||
const mimeType = getExportMimeType(targetPath);
|
||||
|
||||
if (mimeType) headers.set('Content-Type', mimeType);
|
||||
|
||||
// Chromium media pipeline relies on byte ranges for video/audio.
|
||||
headers.set('Accept-Ranges', 'bytes');
|
||||
|
||||
const method = request.method?.toUpperCase?.() || 'GET';
|
||||
const rangeHeader = request.headers.get('range') || request.headers.get('Range');
|
||||
|
||||
if (method === 'HEAD' && !rangeHeader) {
|
||||
headers.set('Content-Length', String(totalSize));
|
||||
return new Response(null, { headers, status: 200 });
|
||||
}
|
||||
|
||||
if (!rangeHeader) {
|
||||
headers.set('Content-Length', String(buffer.byteLength));
|
||||
return new Response(buffer, { headers, status: 200 });
|
||||
}
|
||||
|
||||
const match = /^bytes=(\d*)-(\d*)$/i.exec(rangeHeader.trim());
|
||||
if (!match) {
|
||||
headers.set('Content-Range', `bytes */${totalSize}`);
|
||||
return new Response(null, {
|
||||
headers,
|
||||
status: 416,
|
||||
statusText: 'Range Not Satisfiable',
|
||||
});
|
||||
}
|
||||
|
||||
const [, startRaw, endRaw] = match;
|
||||
let start = startRaw ? Number(startRaw) : Number.NaN;
|
||||
let end = endRaw ? Number(endRaw) : Number.NaN;
|
||||
|
||||
// Suffix range: bytes=-N (last N bytes)
|
||||
if (!startRaw && endRaw) {
|
||||
const suffixLength = Number(endRaw);
|
||||
if (!Number.isFinite(suffixLength) || suffixLength <= 0) {
|
||||
headers.set('Content-Range', `bytes */${totalSize}`);
|
||||
return new Response(null, {
|
||||
headers,
|
||||
status: 416,
|
||||
statusText: 'Range Not Satisfiable',
|
||||
});
|
||||
}
|
||||
start = Math.max(totalSize - suffixLength, 0);
|
||||
end = totalSize - 1;
|
||||
} else {
|
||||
if (!Number.isFinite(start)) start = 0;
|
||||
if (!Number.isFinite(end)) end = totalSize - 1;
|
||||
}
|
||||
|
||||
if (start < 0 || end < 0 || start > end || start >= totalSize) {
|
||||
headers.set('Content-Range', `bytes */${totalSize}`);
|
||||
return new Response(null, {
|
||||
headers,
|
||||
status: 416,
|
||||
statusText: 'Range Not Satisfiable',
|
||||
});
|
||||
}
|
||||
|
||||
end = Math.min(end, totalSize - 1);
|
||||
const sliced = buffer.subarray(start, end + 1);
|
||||
|
||||
headers.set('Content-Range', `bytes ${start}-${end}/${totalSize}`);
|
||||
headers.set('Content-Length', String(sliced.byteLength));
|
||||
|
||||
if (method === 'HEAD') {
|
||||
return new Response(null, { headers, status: 206, statusText: 'Partial Content' });
|
||||
}
|
||||
|
||||
return new Response(sliced, { headers, status: 206, statusText: 'Partial Content' });
|
||||
}
|
||||
|
||||
private isAssetRequest(pathname: string) {
|
||||
const normalizedPathname = pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
|
||||
@@ -285,82 +249,3 @@ export class StaticRendererFallback implements RendererFallbackStrategy {
|
||||
return path.basename(filePath) === '404.html';
|
||||
}
|
||||
}
|
||||
|
||||
class Semaphore {
|
||||
private active = 0;
|
||||
private readonly waiters: Array<() => void> = [];
|
||||
|
||||
constructor(private readonly max: number) {}
|
||||
|
||||
async acquire(): Promise<() => void> {
|
||||
if (this.active >= this.max) {
|
||||
await new Promise<void>((resolve) => this.waiters.push(resolve));
|
||||
}
|
||||
this.active += 1;
|
||||
|
||||
let released = false;
|
||||
return () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
this.active -= 1;
|
||||
this.waiters.shift()?.();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const VITE_FETCH_CONCURRENCY = 64;
|
||||
|
||||
export class ViteRendererFallback implements RendererFallbackStrategy {
|
||||
private readonly viteOrigin: string;
|
||||
private readonly logger = createLogger('core:ViteRendererFallback');
|
||||
private readonly gate = new Semaphore(VITE_FETCH_CONCURRENCY);
|
||||
|
||||
constructor(viteOrigin: string) {
|
||||
this.viteOrigin = viteOrigin.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
async handle(request: Request, url: URL): Promise<Response> {
|
||||
const target = `${this.viteOrigin}${url.pathname}${url.search}`;
|
||||
|
||||
// Strip Host so fetch derives it from the target URL (otherwise Vite
|
||||
// sees `Host: renderer` and middleware that keys off Host can misbehave).
|
||||
const headers = new Headers(request.headers);
|
||||
headers.delete('host');
|
||||
|
||||
const init: RequestInit & { duplex?: 'half' } = {
|
||||
headers,
|
||||
method: request.method,
|
||||
};
|
||||
|
||||
if (request.method !== 'GET' && request.method !== 'HEAD' && request.body) {
|
||||
init.body = request.body;
|
||||
init.duplex = 'half';
|
||||
}
|
||||
|
||||
const release = await this.gate.acquire();
|
||||
try {
|
||||
const response = await fetch(target, init);
|
||||
return this.releaseOnBodyDone(response, release);
|
||||
} catch (error) {
|
||||
release();
|
||||
this.logger.error(`Vite dev server fetch failed: ${target}`, error);
|
||||
return new Response('Vite Dev Server Unavailable', { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
private releaseOnBodyDone(response: Response, release: () => void): Response {
|
||||
if (!response.body) {
|
||||
release();
|
||||
return response;
|
||||
}
|
||||
|
||||
const passthrough = new TransformStream();
|
||||
void response.body.pipeTo(passthrough.writable).then(release, release);
|
||||
|
||||
return new Response(passthrough.readable, {
|
||||
headers: response.headers,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,12 +7,7 @@ import { isDev } from '@/const/env';
|
||||
import { getDesktopEnv } from '@/env';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import {
|
||||
RendererProtocolManager,
|
||||
type RendererRequestInterceptor,
|
||||
StaticRendererFallback,
|
||||
ViteRendererFallback,
|
||||
} from './RendererProtocolManager';
|
||||
import { RendererProtocolManager } from './RendererProtocolManager';
|
||||
|
||||
const logger = createLogger('core:RendererUrlManager');
|
||||
|
||||
@@ -25,11 +20,12 @@ const POPUP_ENTRY_HTML = path.join(rendererDir, 'apps', 'desktop', 'popup.html')
|
||||
export class RendererUrlManager {
|
||||
private readonly rendererProtocolManager: RendererProtocolManager;
|
||||
private readonly rendererStaticOverride = getDesktopEnv().DESKTOP_RENDERER_STATIC;
|
||||
private readonly rendererLoadedUrl: string;
|
||||
private rendererLoadedUrl: string;
|
||||
|
||||
constructor() {
|
||||
this.rendererProtocolManager = new RendererProtocolManager({
|
||||
fallback: this.pickFallback(),
|
||||
rendererDir,
|
||||
resolveRendererFilePath: this.resolveRendererFilePath,
|
||||
});
|
||||
|
||||
this.rendererLoadedUrl = this.rendererProtocolManager.getRendererUrl();
|
||||
@@ -39,22 +35,31 @@ export class RendererUrlManager {
|
||||
return this.rendererProtocolManager.protocolScheme;
|
||||
}
|
||||
|
||||
addRequestInterceptor(interceptor: RendererRequestInterceptor) {
|
||||
this.rendererProtocolManager.addRequestInterceptor(interceptor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the `app://` protocol handler. Idempotent — safe to call after
|
||||
* interceptors are wired.
|
||||
* Configure renderer loading strategy for dev/prod
|
||||
*/
|
||||
configureRendererLoader() {
|
||||
this.rendererProtocolManager.registerHandler();
|
||||
const electronRendererUrl = process.env['ELECTRON_RENDERER_URL'];
|
||||
|
||||
if (isDev && !this.rendererStaticOverride && electronRendererUrl) {
|
||||
this.rendererLoadedUrl = electronRendererUrl;
|
||||
this.setupDevRenderer();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDev && !this.rendererStaticOverride && !electronRendererUrl) {
|
||||
logger.warn('Dev mode: ELECTRON_RENDERER_URL not set, falling back to protocol handler');
|
||||
}
|
||||
|
||||
if (isDev && this.rendererStaticOverride) {
|
||||
logger.warn('Dev mode: DESKTOP_RENDERER_STATIC enabled, using static renderer handler');
|
||||
}
|
||||
|
||||
this.setupProdRenderer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a renderer URL. Always uses `app://renderer` so dev and prod share
|
||||
* the same origin (cookies, storage, service-workers). Dev requests are
|
||||
* proxied to the Vite dev server inside the `app://` handler.
|
||||
* Build renderer URL for dev/prod.
|
||||
*/
|
||||
buildRendererUrl(path: string): string {
|
||||
const cleanPath = path.startsWith('/') ? path : `/${path}`;
|
||||
@@ -64,10 +69,7 @@ export class RendererUrlManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a renderer file path against the static export. Used by the
|
||||
* production fallback; left on the manager so the desktop-specific entry
|
||||
* HTML mappings stay in one place.
|
||||
*
|
||||
* Resolve renderer file path in production.
|
||||
* Static assets map directly; /overlay routes fall back to overlay.html;
|
||||
* popup routes go to popup.html; all other routes fall back to index.html (SPA).
|
||||
*/
|
||||
@@ -94,26 +96,20 @@ export class RendererUrlManager {
|
||||
return SPA_ENTRY_HTML;
|
||||
};
|
||||
|
||||
private pickFallback() {
|
||||
const electronRendererUrl = process.env['ELECTRON_RENDERER_URL'];
|
||||
/**
|
||||
* Development: use electron-vite renderer dev server
|
||||
*/
|
||||
private setupDevRenderer() {
|
||||
logger.info(
|
||||
`Development mode: renderer served from electron-vite dev server at ${this.rendererLoadedUrl}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (isDev && !this.rendererStaticOverride && electronRendererUrl) {
|
||||
logger.info(
|
||||
`Development mode: app:// requests proxied to Vite dev server at ${electronRendererUrl}`,
|
||||
);
|
||||
return new ViteRendererFallback(electronRendererUrl);
|
||||
}
|
||||
|
||||
if (isDev && !this.rendererStaticOverride && !electronRendererUrl) {
|
||||
logger.warn(
|
||||
'Dev mode: ELECTRON_RENDERER_URL not set, falling back to static renderer handler',
|
||||
);
|
||||
}
|
||||
|
||||
if (isDev && this.rendererStaticOverride) {
|
||||
logger.warn('Dev mode: DESKTOP_RENDERER_STATIC enabled, using static renderer handler');
|
||||
}
|
||||
|
||||
return new StaticRendererFallback(rendererDir, this.resolveRendererFilePath);
|
||||
/**
|
||||
* Production: serve static renderer assets via protocol handler
|
||||
*/
|
||||
private setupProdRenderer() {
|
||||
this.rendererLoadedUrl = this.rendererProtocolManager.getRendererUrl();
|
||||
this.rendererProtocolManager.registerHandler();
|
||||
}
|
||||
}
|
||||
|
||||
+79
-134
@@ -1,5 +1,5 @@
|
||||
import { AUTH_REQUIRED_HEADER } from '@lobechat/desktop-bridge';
|
||||
import { BrowserWindow, session as electronSession } from 'electron';
|
||||
import { BrowserWindow } from 'electron';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { BackendProxyProtocolManager } from '../BackendProxyProtocolManager';
|
||||
@@ -10,6 +10,19 @@ interface RequestInitWithDuplex extends RequestInit {
|
||||
|
||||
type FetchMock = (input: RequestInfo | URL, init?: RequestInitWithDuplex) => Promise<Response>;
|
||||
|
||||
const { mockProtocol, protocolHandlerRef } = vi.hoisted(() => {
|
||||
const protocolHandlerRef = { current: null as any };
|
||||
|
||||
return {
|
||||
mockProtocol: {
|
||||
handle: vi.fn((_scheme: string, handler: any) => {
|
||||
protocolHandlerRef.current = handler;
|
||||
}),
|
||||
},
|
||||
protocolHandlerRef,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('electron-is', () => ({
|
||||
dev: vi.fn(() => false),
|
||||
macOS: vi.fn(() => false),
|
||||
@@ -35,23 +48,21 @@ vi.mock('electron', () => ({
|
||||
global.fetch(input as any, init as any),
|
||||
),
|
||||
},
|
||||
session: {
|
||||
defaultSession: {},
|
||||
},
|
||||
}));
|
||||
|
||||
describe('BackendProxyProtocolManager', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
protocolHandlerRef.current = null;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('rewrites url to remote base and injects Oidc-Auth via proxy()', async () => {
|
||||
it('should rewrite url to remote base and inject Oidc-Auth token', async () => {
|
||||
const manager = new BackendProxyProtocolManager();
|
||||
const session = {} as any;
|
||||
const session = { protocol: mockProtocol } as any;
|
||||
|
||||
const fetchMock = vi.fn<FetchMock>(async () => {
|
||||
return new Response('ok', {
|
||||
@@ -65,19 +76,19 @@ describe('BackendProxyProtocolManager', () => {
|
||||
manager.registerWithRemoteBaseUrl(session, {
|
||||
getAccessToken: async () => 'token-123',
|
||||
getRemoteBaseUrl: async () => 'https://remote.example.com',
|
||||
scheme: 'lobe-backend',
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
const response = await manager.proxy(
|
||||
{
|
||||
headers: new Headers({ 'Origin': 'app://renderer', 'X-Test': '1' }),
|
||||
method: 'GET',
|
||||
url: 'app://renderer/trpc/hello?batch=1',
|
||||
} as any,
|
||||
session,
|
||||
);
|
||||
const handler = protocolHandlerRef.current;
|
||||
expect(mockProtocol.handle).toHaveBeenCalledWith('lobe-backend', expect.any(Function));
|
||||
|
||||
const response = await handler({
|
||||
headers: new Headers({ 'Origin': 'app://desktop', 'X-Test': '1' }),
|
||||
method: 'GET',
|
||||
url: 'lobe-backend://app/trpc/hello?batch=1',
|
||||
} as any);
|
||||
|
||||
expect(response).not.toBeNull();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [calledUrl, init] = fetchMock.mock.calls[0]!;
|
||||
expect(calledUrl).toBe('https://remote.example.com/trpc/hello?batch=1');
|
||||
@@ -89,18 +100,16 @@ describe('BackendProxyProtocolManager', () => {
|
||||
expect(headers.get('Oidc-Auth')).toBe('token-123');
|
||||
expect(headers.get('X-Test')).toBe('1');
|
||||
|
||||
expect(response!.status).toBe(200);
|
||||
expect(response!.headers.get('X-Src-Url')).toBe(
|
||||
'https://remote.example.com/trpc/hello?batch=1',
|
||||
);
|
||||
expect(response!.headers.get('Access-Control-Allow-Origin')).toBe('app://renderer');
|
||||
expect(response!.headers.get('Access-Control-Allow-Credentials')).toBe('true');
|
||||
expect(await response!.text()).toBe('ok');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('X-Src-Url')).toBe('https://remote.example.com/trpc/hello?batch=1');
|
||||
expect(response.headers.get('Access-Control-Allow-Origin')).toBe('app://desktop');
|
||||
expect(response.headers.get('Access-Control-Allow-Credentials')).toBe('true');
|
||||
expect(await response.text()).toBe('ok');
|
||||
});
|
||||
|
||||
it('forwards body and sets duplex for non-GET requests', async () => {
|
||||
it('should forward body and set duplex for non-GET requests', async () => {
|
||||
const manager = new BackendProxyProtocolManager();
|
||||
const session = {} as any;
|
||||
const session = { protocol: mockProtocol } as any;
|
||||
|
||||
const fetchMock = vi.fn<FetchMock>(async () => new Response('ok', { status: 200 }));
|
||||
vi.stubGlobal('fetch', fetchMock as any);
|
||||
@@ -108,18 +117,18 @@ describe('BackendProxyProtocolManager', () => {
|
||||
manager.registerWithRemoteBaseUrl(session, {
|
||||
getAccessToken: async () => null,
|
||||
getRemoteBaseUrl: async () => 'https://remote.example.com',
|
||||
scheme: 'lobe-backend',
|
||||
});
|
||||
|
||||
await manager.proxy(
|
||||
{
|
||||
headers: new Headers(),
|
||||
method: 'POST',
|
||||
// body doesn't have to be a real stream for this unit test; manager only checks truthiness
|
||||
body: 'payload' as any,
|
||||
url: 'app://renderer/api/upload',
|
||||
} as any,
|
||||
session,
|
||||
);
|
||||
const handler = protocolHandlerRef.current;
|
||||
|
||||
await handler({
|
||||
headers: new Headers(),
|
||||
method: 'POST',
|
||||
// body doesn't have to be a real stream for this unit test; manager only checks truthiness
|
||||
body: 'payload' as any,
|
||||
url: 'lobe-backend://app/api/upload',
|
||||
} as any);
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0]!;
|
||||
expect(init).toBeDefined();
|
||||
@@ -130,9 +139,9 @@ describe('BackendProxyProtocolManager', () => {
|
||||
expect(init.duplex).toBe('half');
|
||||
});
|
||||
|
||||
it('returns null when remote base url is missing', async () => {
|
||||
it('should return null when remote base url is missing', async () => {
|
||||
const manager = new BackendProxyProtocolManager();
|
||||
const session = {} as any;
|
||||
const session = { protocol: mockProtocol } as any;
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock as any);
|
||||
@@ -140,20 +149,19 @@ describe('BackendProxyProtocolManager', () => {
|
||||
manager.registerWithRemoteBaseUrl(session, {
|
||||
getAccessToken: async () => 'token',
|
||||
getRemoteBaseUrl: async () => null,
|
||||
scheme: 'lobe-backend',
|
||||
});
|
||||
|
||||
const res = await manager.proxy(
|
||||
{ method: 'GET', headers: new Headers(), url: 'app://renderer/trpc' } as any,
|
||||
session,
|
||||
);
|
||||
const handler = protocolHandlerRef.current;
|
||||
const res = await handler({ method: 'GET', url: 'lobe-backend://app/trpc' } as any);
|
||||
|
||||
expect(res).toBeNull();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns null when request url is already the remote origin', async () => {
|
||||
it('should return null when request url is already the remote origin', async () => {
|
||||
const manager = new BackendProxyProtocolManager();
|
||||
const session = {} as any;
|
||||
const session = { protocol: mockProtocol } as any;
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock as any);
|
||||
@@ -161,24 +169,22 @@ describe('BackendProxyProtocolManager', () => {
|
||||
manager.registerWithRemoteBaseUrl(session, {
|
||||
getAccessToken: async () => null,
|
||||
getRemoteBaseUrl: async () => 'https://remote.example.com',
|
||||
scheme: 'lobe-backend',
|
||||
});
|
||||
|
||||
const res = await manager.proxy(
|
||||
{
|
||||
method: 'GET',
|
||||
headers: new Headers(),
|
||||
url: 'https://remote.example.com/trpc/hello?x=1',
|
||||
} as any,
|
||||
session,
|
||||
);
|
||||
const handler = protocolHandlerRef.current;
|
||||
const res = await handler({
|
||||
method: 'GET',
|
||||
url: 'https://remote.example.com/trpc/hello?x=1',
|
||||
} as any);
|
||||
|
||||
expect(res).toBeNull();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns null when rewrite fails (invalid remote base url)', async () => {
|
||||
it('should return null when rewrite fails (invalid remote base url)', async () => {
|
||||
const manager = new BackendProxyProtocolManager();
|
||||
const session = {} as any;
|
||||
const session = { protocol: mockProtocol } as any;
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock as any);
|
||||
@@ -186,20 +192,19 @@ describe('BackendProxyProtocolManager', () => {
|
||||
manager.registerWithRemoteBaseUrl(session, {
|
||||
getAccessToken: async () => null,
|
||||
getRemoteBaseUrl: async () => 'not-a-url',
|
||||
scheme: 'lobe-backend',
|
||||
});
|
||||
|
||||
const res = await manager.proxy(
|
||||
{ method: 'GET', headers: new Headers(), url: 'app://renderer/trpc' } as any,
|
||||
session,
|
||||
);
|
||||
const handler = protocolHandlerRef.current;
|
||||
const res = await handler({ method: 'GET', url: 'lobe-backend://app/trpc' } as any);
|
||||
|
||||
expect(res).toBeNull();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws when upstream fetch throws', async () => {
|
||||
it('should throw when upstream fetch throws', async () => {
|
||||
const manager = new BackendProxyProtocolManager();
|
||||
const session = {} as any;
|
||||
const session = { protocol: mockProtocol } as any;
|
||||
|
||||
const fetchMock = vi.fn(async () => {
|
||||
throw new Error('network down');
|
||||
@@ -209,21 +214,20 @@ describe('BackendProxyProtocolManager', () => {
|
||||
manager.registerWithRemoteBaseUrl(session, {
|
||||
getAccessToken: async () => null,
|
||||
getRemoteBaseUrl: async () => 'https://remote.example.com',
|
||||
scheme: 'lobe-backend',
|
||||
});
|
||||
|
||||
const handler = protocolHandlerRef.current;
|
||||
await expect(
|
||||
manager.proxy(
|
||||
{
|
||||
headers: new Headers(),
|
||||
method: 'GET',
|
||||
url: 'app://renderer/trpc/hello',
|
||||
} as any,
|
||||
session,
|
||||
),
|
||||
handler({
|
||||
headers: new Headers(),
|
||||
method: 'GET',
|
||||
url: 'lobe-backend://app/trpc/hello',
|
||||
} as any),
|
||||
).rejects.toThrow('network down');
|
||||
});
|
||||
|
||||
it('broadcasts authorizationRequired when X-Auth-Required is set on HTTP 207 (batched tRPC)', async () => {
|
||||
it('should broadcast authorizationRequired when X-Auth-Required is set on HTTP 207 (batched tRPC)', async () => {
|
||||
vi.useFakeTimers();
|
||||
const send = vi.fn();
|
||||
vi.mocked(BrowserWindow.getAllWindows).mockReturnValue([
|
||||
@@ -231,7 +235,7 @@ describe('BackendProxyProtocolManager', () => {
|
||||
] as any);
|
||||
|
||||
const manager = new BackendProxyProtocolManager();
|
||||
const session = {} as any;
|
||||
const session = { protocol: mockProtocol } as any;
|
||||
|
||||
const headers = new Headers({
|
||||
[AUTH_REQUIRED_HEADER]: 'true',
|
||||
@@ -245,77 +249,18 @@ describe('BackendProxyProtocolManager', () => {
|
||||
manager.registerWithRemoteBaseUrl(session, {
|
||||
getAccessToken: async () => null,
|
||||
getRemoteBaseUrl: async () => 'https://remote.example.com',
|
||||
scheme: 'lobe-backend',
|
||||
});
|
||||
|
||||
await manager.proxy(
|
||||
{
|
||||
headers: new Headers(),
|
||||
method: 'GET',
|
||||
url: 'app://renderer/trpc/lambda/batch?batch=1',
|
||||
} as any,
|
||||
session,
|
||||
);
|
||||
const handler = protocolHandlerRef.current;
|
||||
await handler({
|
||||
headers: new Headers(),
|
||||
method: 'GET',
|
||||
url: 'lobe-backend://app/trpc/lambda/batch?batch=1',
|
||||
} as any);
|
||||
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(send).toHaveBeenCalledWith('authorizationRequired');
|
||||
});
|
||||
|
||||
describe('createAppRequestInterceptor', () => {
|
||||
it('returns null for non-backend paths', async () => {
|
||||
const manager = new BackendProxyProtocolManager();
|
||||
const interceptor = manager.createAppRequestInterceptor();
|
||||
|
||||
const res = await interceptor({
|
||||
headers: new Headers(),
|
||||
method: 'GET',
|
||||
url: 'app://renderer/settings',
|
||||
} as any);
|
||||
|
||||
expect(res).toBeNull();
|
||||
});
|
||||
|
||||
it('returns 502 for backend paths when default session has no context', async () => {
|
||||
// electronSession.defaultSession is the empty {} mock; no register() was called.
|
||||
void electronSession.defaultSession;
|
||||
|
||||
const manager = new BackendProxyProtocolManager();
|
||||
const interceptor = manager.createAppRequestInterceptor();
|
||||
|
||||
const res = await interceptor({
|
||||
headers: new Headers(),
|
||||
method: 'GET',
|
||||
url: 'app://renderer/trpc/hello',
|
||||
} as any);
|
||||
|
||||
expect(res).not.toBeNull();
|
||||
expect(res!.status).toBe(502);
|
||||
});
|
||||
|
||||
it('proxies backend paths through the registered default-session context', async () => {
|
||||
const fetchMock = vi.fn<FetchMock>(async () => new Response('proxied', { status: 200 }));
|
||||
vi.stubGlobal('fetch', fetchMock as any);
|
||||
|
||||
const manager = new BackendProxyProtocolManager();
|
||||
manager.registerWithRemoteBaseUrl(electronSession.defaultSession as any, {
|
||||
getAccessToken: async () => null,
|
||||
getRemoteBaseUrl: async () => 'https://remote.example.com',
|
||||
});
|
||||
|
||||
const interceptor = manager.createAppRequestInterceptor();
|
||||
const res = await interceptor({
|
||||
headers: new Headers(),
|
||||
method: 'GET',
|
||||
url: 'app://renderer/trpc/hello?batch=1',
|
||||
} as any);
|
||||
|
||||
expect(res).not.toBeNull();
|
||||
expect(res!.status).toBe(200);
|
||||
expect(await res!.text()).toBe('proxied');
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'https://remote.example.com/trpc/hello?batch=1',
|
||||
expect.objectContaining({ method: 'GET' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+50
-177
@@ -1,41 +1,27 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
RendererProtocolManager,
|
||||
StaticRendererFallback,
|
||||
ViteRendererFallback,
|
||||
} from '../RendererProtocolManager';
|
||||
import { RendererProtocolManager } from '../RendererProtocolManager';
|
||||
|
||||
const {
|
||||
mockApp,
|
||||
mockFetch,
|
||||
mockPathExistsSync,
|
||||
mockProtocol,
|
||||
mockReadFile,
|
||||
mockStat,
|
||||
protocolHandlerRef,
|
||||
} = vi.hoisted(() => {
|
||||
const protocolHandlerRef = { current: null as any };
|
||||
const { mockApp, mockPathExistsSync, mockProtocol, mockReadFile, mockStat, protocolHandlerRef } =
|
||||
vi.hoisted(() => {
|
||||
const protocolHandlerRef = { current: null as any };
|
||||
|
||||
return {
|
||||
mockApp: {
|
||||
isReady: vi.fn().mockReturnValue(true),
|
||||
whenReady: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
mockFetch: vi.fn(),
|
||||
mockPathExistsSync: vi.fn().mockReturnValue(true),
|
||||
mockProtocol: {
|
||||
handle: vi.fn((_scheme: string, handler: any) => {
|
||||
protocolHandlerRef.current = handler;
|
||||
}),
|
||||
},
|
||||
mockReadFile: vi.fn(),
|
||||
mockStat: vi.fn(),
|
||||
protocolHandlerRef,
|
||||
};
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
return {
|
||||
mockApp: {
|
||||
isReady: vi.fn().mockReturnValue(true),
|
||||
whenReady: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
mockPathExistsSync: vi.fn().mockReturnValue(true),
|
||||
mockProtocol: {
|
||||
handle: vi.fn((_scheme: string, handler: any) => {
|
||||
protocolHandlerRef.current = handler;
|
||||
}),
|
||||
},
|
||||
mockReadFile: vi.fn(),
|
||||
mockStat: vi.fn(),
|
||||
protocolHandlerRef,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: mockApp,
|
||||
@@ -60,7 +46,7 @@ vi.mock('@/utils/logger', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('RendererProtocolManager + StaticRendererFallback', () => {
|
||||
describe('RendererProtocolManager', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
protocolHandlerRef.current = null;
|
||||
@@ -73,14 +59,7 @@ describe('RendererProtocolManager + StaticRendererFallback', () => {
|
||||
protocolHandlerRef.current = null;
|
||||
});
|
||||
|
||||
const buildStaticManager = (resolve: (url: URL) => Promise<string | null>) => {
|
||||
const fallback = new StaticRendererFallback('/export', resolve);
|
||||
const manager = new RendererProtocolManager({ fallback });
|
||||
manager.registerHandler();
|
||||
return manager;
|
||||
};
|
||||
|
||||
it('falls back to entry HTML when resolve returns 404.html for non-asset routes', async () => {
|
||||
it('should fall back to entry HTML when resolve returns 404.html for non-asset routes', async () => {
|
||||
const resolveRendererFilePath = vi.fn(async (url: URL) => {
|
||||
if (url.pathname === '/missing') return '/export/404.html';
|
||||
if (url.pathname === '/') return '/export/index.html';
|
||||
@@ -88,7 +67,12 @@ describe('RendererProtocolManager + StaticRendererFallback', () => {
|
||||
});
|
||||
mockReadFile.mockImplementation(async (path: string) => Buffer.from(`content:${path}`));
|
||||
|
||||
buildStaticManager(resolveRendererFilePath);
|
||||
const manager = new RendererProtocolManager({
|
||||
rendererDir: '/export',
|
||||
resolveRendererFilePath,
|
||||
});
|
||||
|
||||
manager.registerHandler();
|
||||
expect(mockProtocol.handle).toHaveBeenCalled();
|
||||
const handler = protocolHandlerRef.current;
|
||||
|
||||
@@ -108,7 +92,7 @@ describe('RendererProtocolManager + StaticRendererFallback', () => {
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it('serves 404.html when explicitly requested', async () => {
|
||||
it('should serve 404.html when explicitly requested', async () => {
|
||||
const resolveRendererFilePath = vi.fn(async (url: URL) => {
|
||||
if (url.pathname === '/404.html') return '/export/404.html';
|
||||
if (url.pathname === '/') return '/export/index.html';
|
||||
@@ -116,7 +100,12 @@ describe('RendererProtocolManager + StaticRendererFallback', () => {
|
||||
});
|
||||
mockReadFile.mockImplementation(async (path: string) => Buffer.from(`content:${path}`));
|
||||
|
||||
buildStaticManager(resolveRendererFilePath);
|
||||
const manager = new RendererProtocolManager({
|
||||
rendererDir: '/export',
|
||||
resolveRendererFilePath,
|
||||
});
|
||||
|
||||
manager.registerHandler();
|
||||
const handler = protocolHandlerRef.current;
|
||||
|
||||
const response = await handler({
|
||||
@@ -130,30 +119,36 @@ describe('RendererProtocolManager + StaticRendererFallback', () => {
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it('returns 404 for missing asset requests without fallback', async () => {
|
||||
it('should return 404 for missing asset requests without fallback', async () => {
|
||||
const resolveRendererFilePath = vi.fn(async (_url: URL) => null);
|
||||
|
||||
buildStaticManager(resolveRendererFilePath);
|
||||
const manager = new RendererProtocolManager({
|
||||
rendererDir: '/export',
|
||||
resolveRendererFilePath,
|
||||
});
|
||||
|
||||
manager.registerHandler();
|
||||
const handler = protocolHandlerRef.current;
|
||||
|
||||
const response = await handler({
|
||||
headers: new Headers(),
|
||||
method: 'GET',
|
||||
url: 'app://renderer/logo.png',
|
||||
} as any);
|
||||
const response = await handler({ url: 'app://renderer/logo.png' } as any);
|
||||
|
||||
expect(resolveRendererFilePath).toHaveBeenCalledTimes(1);
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it('supports Range requests for media assets', async () => {
|
||||
it('should support Range requests for media assets', async () => {
|
||||
const resolveRendererFilePath = vi.fn(async (_url: URL) => '/export/intro-video.mp4');
|
||||
const payload = Buffer.from('0123456789');
|
||||
|
||||
mockStat.mockImplementation(async () => ({ size: payload.length }));
|
||||
mockReadFile.mockImplementation(async () => payload);
|
||||
|
||||
buildStaticManager(resolveRendererFilePath);
|
||||
const manager = new RendererProtocolManager({
|
||||
rendererDir: '/export',
|
||||
resolveRendererFilePath,
|
||||
});
|
||||
|
||||
manager.registerHandler();
|
||||
const handler = protocolHandlerRef.current;
|
||||
|
||||
const response = await handler({
|
||||
@@ -171,126 +166,4 @@ describe('RendererProtocolManager + StaticRendererFallback', () => {
|
||||
const buf = Buffer.from(await response.arrayBuffer());
|
||||
expect(buf.toString()).toBe('01');
|
||||
});
|
||||
|
||||
it('runs interceptors before the fallback and short-circuits on first non-null Response', async () => {
|
||||
const resolveRendererFilePath = vi.fn(async () => '/export/index.html');
|
||||
mockReadFile.mockImplementation(async () => Buffer.from('static'));
|
||||
|
||||
const fallback = new StaticRendererFallback('/export', resolveRendererFilePath);
|
||||
const manager = new RendererProtocolManager({ fallback });
|
||||
|
||||
manager.addRequestInterceptor(async () => null);
|
||||
manager.addRequestInterceptor(async (request) =>
|
||||
new URL(request.url).pathname === '/trpc/hello'
|
||||
? new Response('intercepted', { status: 200 })
|
||||
: null,
|
||||
);
|
||||
|
||||
manager.registerHandler();
|
||||
const handler = protocolHandlerRef.current;
|
||||
|
||||
const intercepted = await handler({
|
||||
headers: new Headers(),
|
||||
method: 'GET',
|
||||
url: 'app://renderer/trpc/hello',
|
||||
} as any);
|
||||
expect(intercepted.status).toBe(200);
|
||||
expect(await intercepted.text()).toBe('intercepted');
|
||||
expect(resolveRendererFilePath).not.toHaveBeenCalled();
|
||||
|
||||
const fallthrough = await handler({
|
||||
headers: new Headers(),
|
||||
method: 'GET',
|
||||
url: 'app://renderer/anything',
|
||||
} as any);
|
||||
expect(fallthrough.status).toBe(200);
|
||||
expect(await fallthrough.text()).toBe('static');
|
||||
});
|
||||
|
||||
it('returns 404 for cross-host requests', async () => {
|
||||
const resolveRendererFilePath = vi.fn(async () => '/export/index.html');
|
||||
buildStaticManager(resolveRendererFilePath);
|
||||
const handler = protocolHandlerRef.current;
|
||||
|
||||
const response = await handler({
|
||||
headers: new Headers(),
|
||||
method: 'GET',
|
||||
url: 'app://elsewhere/index.html',
|
||||
} as any);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(resolveRendererFilePath).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ViteRendererFallback', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
protocolHandlerRef.current = null;
|
||||
mockApp.isReady.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it('forwards GET requests to the Vite origin preserving pathname + search', async () => {
|
||||
mockFetch.mockResolvedValue(new Response('vite-served', { status: 200 }));
|
||||
|
||||
const fallback = new ViteRendererFallback('http://localhost:5173');
|
||||
const manager = new RendererProtocolManager({ fallback });
|
||||
manager.registerHandler();
|
||||
const handler = protocolHandlerRef.current;
|
||||
|
||||
const response = await handler({
|
||||
headers: new Headers({ Accept: 'text/html' }),
|
||||
method: 'GET',
|
||||
url: 'app://renderer/src/main.tsx?t=12345',
|
||||
} as any);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
const [target, init] = mockFetch.mock.calls[0]!;
|
||||
expect(target).toBe('http://localhost:5173/src/main.tsx?t=12345');
|
||||
expect((init as RequestInit).method).toBe('GET');
|
||||
const headers = (init as RequestInit).headers as Headers;
|
||||
expect(headers.get('Accept')).toBe('text/html');
|
||||
expect(headers.get('Host')).toBeNull();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).toBe('vite-served');
|
||||
});
|
||||
|
||||
it('forwards body and sets duplex for non-GET requests', async () => {
|
||||
mockFetch.mockResolvedValue(new Response('ok', { status: 200 }));
|
||||
|
||||
const fallback = new ViteRendererFallback('http://localhost:5173/');
|
||||
const manager = new RendererProtocolManager({ fallback });
|
||||
manager.registerHandler();
|
||||
const handler = protocolHandlerRef.current;
|
||||
|
||||
await handler({
|
||||
headers: new Headers(),
|
||||
method: 'POST',
|
||||
body: 'payload' as any,
|
||||
url: 'app://renderer/__hmr',
|
||||
} as any);
|
||||
|
||||
const [target, init] = mockFetch.mock.calls[0]!;
|
||||
expect(target).toBe('http://localhost:5173/__hmr');
|
||||
expect((init as RequestInit & { duplex?: string }).duplex).toBe('half');
|
||||
expect((init as any).body).toBe('payload');
|
||||
});
|
||||
|
||||
it('returns 502 when fetch throws', async () => {
|
||||
mockFetch.mockRejectedValue(new Error('ECONNREFUSED'));
|
||||
|
||||
const fallback = new ViteRendererFallback('http://localhost:5173');
|
||||
const manager = new RendererProtocolManager({ fallback });
|
||||
manager.registerHandler();
|
||||
const handler = protocolHandlerRef.current;
|
||||
|
||||
const response = await handler({
|
||||
headers: new Headers(),
|
||||
method: 'GET',
|
||||
url: 'app://renderer/@vite/client',
|
||||
} as any);
|
||||
|
||||
expect(response.status).toBe(502);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mockPathExistsSync = vi.fn();
|
||||
const mockProtocolHandle = vi.fn();
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
isReady: vi.fn(() => true),
|
||||
whenReady: vi.fn(() => Promise.resolve()),
|
||||
},
|
||||
net: {
|
||||
fetch: vi.fn(),
|
||||
},
|
||||
protocol: {
|
||||
handle: mockProtocolHandle,
|
||||
handle: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -49,7 +45,6 @@ describe('RendererUrlManager', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockPathExistsSync.mockReset();
|
||||
mockProtocolHandle.mockReset();
|
||||
mockIsDev = false;
|
||||
delete process.env['ELECTRON_RENDERER_URL'];
|
||||
});
|
||||
@@ -84,39 +79,8 @@ describe('RendererUrlManager', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildRendererUrl', () => {
|
||||
it('always returns app://renderer regardless of dev/prod', async () => {
|
||||
const { RendererUrlManager } = await import('../RendererUrlManager');
|
||||
const prodManager = new RendererUrlManager();
|
||||
expect(prodManager.buildRendererUrl('/')).toBe('app://renderer/');
|
||||
expect(prodManager.buildRendererUrl('/settings')).toBe('app://renderer/settings');
|
||||
|
||||
mockIsDev = true;
|
||||
process.env['ELECTRON_RENDERER_URL'] = 'http://localhost:5173';
|
||||
const devManager = new RendererUrlManager();
|
||||
expect(devManager.buildRendererUrl('/')).toBe('app://renderer/');
|
||||
expect(devManager.buildRendererUrl('/settings')).toBe('app://renderer/settings');
|
||||
});
|
||||
|
||||
it('prefixes a slash when the input lacks one', async () => {
|
||||
const { RendererUrlManager } = await import('../RendererUrlManager');
|
||||
const manager = new RendererUrlManager();
|
||||
expect(manager.buildRendererUrl('settings')).toBe('app://renderer/settings');
|
||||
});
|
||||
});
|
||||
|
||||
describe('configureRendererLoader', () => {
|
||||
it('registers the app:// protocol handler in prod', async () => {
|
||||
mockIsDev = false;
|
||||
const { RendererUrlManager } = await import('../RendererUrlManager');
|
||||
const manager = new RendererUrlManager();
|
||||
manager.configureRendererLoader();
|
||||
|
||||
expect(mockProtocolHandle).toHaveBeenCalledTimes(1);
|
||||
expect(mockProtocolHandle.mock.calls[0][0]).toBe('app');
|
||||
});
|
||||
|
||||
it('registers the app:// protocol handler in dev (Vite fallback)', async () => {
|
||||
describe('configureRendererLoader (dev mode)', () => {
|
||||
it('should use ELECTRON_RENDERER_URL when available in dev mode', async () => {
|
||||
mockIsDev = true;
|
||||
process.env['ELECTRON_RENDERER_URL'] = 'http://localhost:5173';
|
||||
|
||||
@@ -124,20 +88,34 @@ describe('RendererUrlManager', () => {
|
||||
const manager = new RendererUrlManager();
|
||||
manager.configureRendererLoader();
|
||||
|
||||
expect(mockProtocolHandle).toHaveBeenCalledTimes(1);
|
||||
expect(mockProtocolHandle.mock.calls[0][0]).toBe('app');
|
||||
expect(manager.buildRendererUrl('/')).toBe('http://localhost:5173/');
|
||||
expect(manager.buildRendererUrl('/settings')).toBe('http://localhost:5173/settings');
|
||||
});
|
||||
|
||||
it('still registers in dev when ELECTRON_RENDERER_URL is missing (static fallback)', async () => {
|
||||
it('should normalize trailing slashes from ELECTRON_RENDERER_URL', async () => {
|
||||
mockIsDev = true;
|
||||
process.env['ELECTRON_RENDERER_URL'] = 'http://localhost:5173/';
|
||||
|
||||
const { RendererUrlManager } = await import('../RendererUrlManager');
|
||||
const manager = new RendererUrlManager();
|
||||
manager.configureRendererLoader();
|
||||
|
||||
expect(mockProtocolHandle).toHaveBeenCalledTimes(1);
|
||||
expect(manager.buildRendererUrl('/')).toBe('http://localhost:5173/');
|
||||
expect(manager.buildRendererUrl('/overlay')).toBe('http://localhost:5173/overlay');
|
||||
});
|
||||
|
||||
it('uses static fallback when DESKTOP_RENDERER_STATIC overrides ELECTRON_RENDERER_URL', async () => {
|
||||
it('should fall back to protocol handler when ELECTRON_RENDERER_URL is not set', async () => {
|
||||
mockIsDev = true;
|
||||
|
||||
const { RendererUrlManager } = await import('../RendererUrlManager');
|
||||
const manager = new RendererUrlManager();
|
||||
mockPathExistsSync.mockReturnValue(true);
|
||||
manager.configureRendererLoader();
|
||||
|
||||
expect(manager.buildRendererUrl('/')).toBe('app://renderer/');
|
||||
});
|
||||
|
||||
it('should use protocol handler when DESKTOP_RENDERER_STATIC is enabled regardless of ELECTRON_RENDERER_URL', async () => {
|
||||
mockIsDev = true;
|
||||
process.env['ELECTRON_RENDERER_URL'] = 'http://localhost:5173';
|
||||
|
||||
@@ -146,10 +124,10 @@ describe('RendererUrlManager', () => {
|
||||
|
||||
const { RendererUrlManager } = await import('../RendererUrlManager');
|
||||
const manager = new RendererUrlManager();
|
||||
mockPathExistsSync.mockReturnValue(true);
|
||||
manager.configureRendererLoader();
|
||||
|
||||
expect(manager.buildRendererUrl('/')).toBe('app://renderer/');
|
||||
expect(mockProtocolHandle).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -70,11 +70,6 @@ export const getDesktopEnv = memoize(() =>
|
||||
// escape hatch: allow testing static renderer in dev via env
|
||||
DESKTOP_RENDERER_STATIC: envBoolean(false),
|
||||
|
||||
// device gateway url override (dev: point at a local `wrangler dev` instance,
|
||||
// e.g. http://localhost:8787). Falls back to the stored value, then the
|
||||
// production gateway.
|
||||
DEVICE_GATEWAY_URL: z.string().url().optional(),
|
||||
|
||||
// Force use dev-app-update.yml even in packaged app (for testing updates)
|
||||
FORCE_DEV_UPDATE_CONFIG: envBoolean(false),
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import './pre-app-init';
|
||||
|
||||
import fixPath from 'fix-path';
|
||||
|
||||
import { App } from './core/App';
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import {
|
||||
CODEX_DEFAULT_EXECUTION_ARGS,
|
||||
CODEX_EXECUTION_MODE_FLAGS,
|
||||
CODEX_REQUIRED_ARGS,
|
||||
} from '@lobechat/heterogeneous-agents/spawn';
|
||||
|
||||
import type { HeterogeneousAgentBuildPlanParams, HeterogeneousAgentDriver } from '../types';
|
||||
|
||||
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;
|
||||
|
||||
const hasAnyFlag = (args: string[], flags: readonly string[]) =>
|
||||
args.some((arg) => flags.includes(arg as (typeof flags)[number]));
|
||||
|
||||
@@ -16,11 +18,9 @@ const buildCodexOptionArgs = async ({
|
||||
}: Pick<HeterogeneousAgentBuildPlanParams, 'args' | 'helpers' | 'imageList'>) => {
|
||||
const imagePaths = await helpers.resolveCliImagePaths(imageList);
|
||||
const imageArgs = imagePaths.flatMap((filePath) => ['--image', filePath]);
|
||||
const executionModeArgs = hasAnyFlag(args, CODEX_EXECUTION_MODE_FLAGS)
|
||||
? []
|
||||
: [...CODEX_DEFAULT_EXECUTION_ARGS];
|
||||
const autoExecutionArgs = hasAnyFlag(args, CODEX_AUTO_EXECUTION_FLAGS) ? [] : ['--full-auto'];
|
||||
|
||||
return [...CODEX_REQUIRED_ARGS, ...executionModeArgs, ...args, ...imageArgs];
|
||||
return [...CODEX_REQUIRED_ARGS, ...autoExecutionArgs, ...args, ...imageArgs];
|
||||
};
|
||||
|
||||
export const codexDriver: HeterogeneousAgentDriver = {
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import { app } from 'electron';
|
||||
import * as electronIs from 'electron-is';
|
||||
|
||||
// Must run BEFORE any module captures `app.getPath('userData')` (e.g. `@/const/dir`
|
||||
// reads it at top level). Once a path is read, `setName` / `setPath` no-op for it.
|
||||
//
|
||||
// Dev now uses the same `app://renderer/` origin as prod, so localStorage / cookies /
|
||||
// IndexedDB would collide if both shared the packaged-app's userData dir. Pin dev to
|
||||
// a sibling directory so prod sessions stay clean.
|
||||
if (electronIs.dev()) {
|
||||
app.setName('lobehub-desktop-dev');
|
||||
app.setPath('userData', path.join(app.getPath('appData'), 'lobehub-desktop-dev'));
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import type {
|
||||
AgentRunRequestMessage,
|
||||
GatewayMcpStdioParams,
|
||||
MessageApiRequestMessage,
|
||||
RpcRequestMessage,
|
||||
SystemInfoRequestMessage,
|
||||
ToolCallRequestMessage,
|
||||
ToolCallResponseMessage,
|
||||
@@ -17,7 +16,6 @@ import type { GatewayConnectionStatus } from '@lobechat/electron-client-ipc';
|
||||
import { app, powerSaveBlocker } from 'electron';
|
||||
|
||||
import { isDev } from '@/const/env';
|
||||
import { getDesktopEnv } from '@/env';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { ServiceModule } from './index';
|
||||
@@ -85,15 +83,6 @@ interface AgentRunHandler {
|
||||
(request: AgentRunRequestMessage): Promise<{ reason?: string; status: 'accepted' | 'rejected' }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for generic server-internal device RPCs (e.g. workspace-init scans).
|
||||
* Dispatches by `method` name and returns the JSON-serializable result. Distinct
|
||||
* from {@link ToolCallHandler} — RPCs are never exposed to the agent.
|
||||
*/
|
||||
interface RpcHandler {
|
||||
(method: string, params: unknown): Promise<unknown>;
|
||||
}
|
||||
|
||||
interface DeviceRegistrar {
|
||||
(info: {
|
||||
deviceId: string;
|
||||
@@ -123,7 +112,6 @@ export default class GatewayConnectionService extends ServiceModule {
|
||||
private mcpCallHandler: McpCallHandler | null = null;
|
||||
private messageApiHandler: MessageApiHandler | null = null;
|
||||
private agentRunHandler: AgentRunHandler | null = null;
|
||||
private rpcHandler: RpcHandler | null = null;
|
||||
private deviceRegistrar: DeviceRegistrar | null = null;
|
||||
|
||||
// ─── Configuration ───
|
||||
@@ -161,15 +149,6 @@ export default class GatewayConnectionService extends ServiceModule {
|
||||
this.messageApiHandler = handler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the generic device-RPC handler (routes server-internal method calls such
|
||||
* as workspace-init to the relevant controller). Distinct from the tool-call
|
||||
* handler — these are never surfaced to the agent.
|
||||
*/
|
||||
setRpcHandler(handler: RpcHandler) {
|
||||
this.rpcHandler = handler;
|
||||
}
|
||||
|
||||
setAgentRunHandler(handler: AgentRunHandler) {
|
||||
this.agentRunHandler = handler;
|
||||
}
|
||||
@@ -358,10 +337,6 @@ export default class GatewayConnectionService extends ServiceModule {
|
||||
this.handleSystemInfoRequest(client, request);
|
||||
});
|
||||
|
||||
client.on('rpc_request', (request) => {
|
||||
this.handleRpcRequest(client, request);
|
||||
});
|
||||
|
||||
client.on('agent_run_request', (request) => {
|
||||
this.handleAgentRunRequest(client, request);
|
||||
});
|
||||
@@ -427,32 +402,6 @@ export default class GatewayConnectionService extends ServiceModule {
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Generic Device RPC ───
|
||||
|
||||
private async handleRpcRequest(client: GatewayClient, request: RpcRequestMessage) {
|
||||
const { method, params, requestId } = request;
|
||||
logger.info(`Received rpc_request: method=${method}, requestId=${requestId}`);
|
||||
|
||||
if (!this.rpcHandler) {
|
||||
client.sendRpcResponse({
|
||||
requestId,
|
||||
result: { error: 'No RPC handler registered', success: false },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await this.rpcHandler(method, params);
|
||||
client.sendRpcResponse({ requestId, result: { data, success: true } });
|
||||
} catch (error) {
|
||||
logger.error(`rpc_request method=${method} failed:`, serializeWireError(error));
|
||||
client.sendRpcResponse({
|
||||
requestId,
|
||||
result: { error: serializeWireError(error), success: false },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Agent Run ───
|
||||
|
||||
private handleAgentRunRequest = async (
|
||||
@@ -516,7 +465,7 @@ export default class GatewayConnectionService extends ServiceModule {
|
||||
// Forward the typed envelope unchanged. Critically, do NOT stringify the
|
||||
// whole result into `content` — that would bury the structured payload
|
||||
// inside a JSON blob and lose `state`. The wire protocol carries each
|
||||
// field separately so downstream (`DeviceGateway` → `RuntimeExecutors`)
|
||||
// field separately so downstream (`DeviceProxy` → `RuntimeExecutors`)
|
||||
// can persist `state` to `pluginState`. Optional fields are only set
|
||||
// when present so payloads stay minimal.
|
||||
const wireResult: ToolCallResponseMessage['result'] = {
|
||||
@@ -629,13 +578,7 @@ export default class GatewayConnectionService extends ServiceModule {
|
||||
// ─── Gateway URL ───
|
||||
|
||||
private getGatewayUrl(): string {
|
||||
// Env override wins (dev: point at a local `wrangler dev` gateway), then the
|
||||
// user-configured store value, then the production default.
|
||||
return (
|
||||
getDesktopEnv().DEVICE_GATEWAY_URL ||
|
||||
this.app.storeManager.get('gatewayUrl') ||
|
||||
DEFAULT_GATEWAY_URL
|
||||
);
|
||||
return this.app.storeManager.get('gatewayUrl') || DEFAULT_GATEWAY_URL;
|
||||
}
|
||||
|
||||
// ─── Token Helpers ───
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { readdir, readFile } from 'node:fs/promises';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
import { readdir } from 'fs-extra';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { detectRepoType } from '../git';
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
readFile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('fs-extra', () => ({
|
||||
readdir: vi.fn(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,6 +1,71 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import { readdir } from 'fs-extra';
|
||||
|
||||
/**
|
||||
* Git repo-type / gitdir helpers. The implementations now live in
|
||||
* `@lobechat/local-file-shell` so desktop, the device RPC, and the CLI share one
|
||||
* copy; re-exported here to keep existing `@/utils/git` import sites stable.
|
||||
* Resolve the actual `.git` directory for a working tree.
|
||||
* Supports both standard layouts and worktree pointer files (`.git` as a regular file
|
||||
* containing `gitdir: <path>`).
|
||||
*/
|
||||
export { detectRepoType, resolveCommonGitDir, resolveGitDir } from '@lobechat/local-file-shell';
|
||||
export const resolveGitDir = async (dirPath: string): Promise<string | undefined> => {
|
||||
const gitPath = path.join(dirPath, '.git');
|
||||
try {
|
||||
const content = await readFile(gitPath, 'utf8');
|
||||
const worktreeMatch = /^gitdir:\s*(\S.*)$/m.exec(content.trim());
|
||||
if (worktreeMatch) {
|
||||
const resolved = worktreeMatch[1].trim();
|
||||
return path.isAbsolute(resolved) ? resolved : path.resolve(dirPath, resolved);
|
||||
}
|
||||
} catch {
|
||||
// `.git` is a directory (EISDIR) or missing — fall through
|
||||
}
|
||||
try {
|
||||
const stat = await readdir(gitPath);
|
||||
if (stat.length > 0) return gitPath;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the common git dir — where shared state like `config` and
|
||||
* `packed-refs` lives. For linked worktrees, `resolveGitDir` returns
|
||||
* `.git/worktrees/<name>/` which has its own `HEAD` but no `config`;
|
||||
* the `commondir` pointer inside it resolves to the main repo's gitdir.
|
||||
*/
|
||||
export const resolveCommonGitDir = async (dirPath: string): Promise<string | undefined> => {
|
||||
const gitDir = await resolveGitDir(dirPath);
|
||||
if (!gitDir) return undefined;
|
||||
try {
|
||||
const commondir = (await readFile(path.join(gitDir, 'commondir'), 'utf8')).trim();
|
||||
if (!commondir) return gitDir;
|
||||
return path.isAbsolute(commondir) ? commondir : path.resolve(gitDir, commondir);
|
||||
} catch {
|
||||
return gitDir;
|
||||
}
|
||||
};
|
||||
|
||||
// Match `github.com` only in a remote-URL host position: preceded by `@`, `/`,
|
||||
// or line start (covers `git@github.com:`, `https://github.com/`,
|
||||
// `ssh://git@github.com/`, etc.) and followed by `:` or `/`. Avoids matching
|
||||
// look-alikes like `evilgithub.com` or `github.com.attacker.com`.
|
||||
const GITHUB_REMOTE_HOST_RE = /(?:^|[@/])github\.com[:/]/m;
|
||||
|
||||
/**
|
||||
* Classify a working tree as `git` (plain) / `github` (origin points at github.com) /
|
||||
* `undefined` (not a git repo). Reads the shared gitdir's `config` so submodules and
|
||||
* linked worktrees resolve the same as the main repo.
|
||||
*/
|
||||
export const detectRepoType = async (dirPath: string): Promise<'git' | 'github' | undefined> => {
|
||||
const commonDir = await resolveCommonGitDir(dirPath);
|
||||
if (!commonDir) return undefined;
|
||||
try {
|
||||
const config = await readFile(path.join(commonDir, 'config'), 'utf8');
|
||||
if (GITHUB_REMOTE_HOST_RE.test(config)) return 'github';
|
||||
return 'git';
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -473,6 +473,5 @@
|
||||
"https://github.com/user-attachments/assets/fcdfb9c5-819a-488f-b28d-0857fe861219": "/blog/assets8477415ecec1f37e38ab38ff1217d0a7.webp",
|
||||
"https://github.com/user-attachments/assets/fd60ab55-ead2-4930-ad00-fdf77662f5a0": "/blog/assets276a4e8748e9bd300b30dcd9d0e24980.webp",
|
||||
"https://file.rene.wang/task.png": "/blog/assets4aa1732a45832afc780600e6e329860c.webp",
|
||||
"https://file.rene.wang/Platform Agent.png": "/blog/assets10cadd434aeb36bd1beb3c7b3d371fbd.webp",
|
||||
"https://file.rene.wang/clipboard-1780888016983-b47fcdab831b1.png": "/blog/assets65dddd1748c3de8646c8ad56abf53390.webp"
|
||||
"https://file.rene.wang/Platform Agent.png": "/blog/assets10cadd434aeb36bd1beb3c7b3d371fbd.webp"
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
---
|
||||
title: Connectors & Connect Agents
|
||||
description: >-
|
||||
Govern every tool with the new Connectors system, run Connect Agents on your own device, and track spending right in the activity heatmap.
|
||||
|
||||
tags:
|
||||
- Connectors
|
||||
- Connect Agent
|
||||
- Insights
|
||||
- Models
|
||||
---
|
||||
|
||||
# Connectors & Connect Agents
|
||||
|
||||
## Connectors: one place to govern every tool
|
||||
|
||||
Connectors bring all of an agent's tools — MCP servers, Skill Market skills, built-in tools, and third-party integrations — under a single permission layer. For each tool you decide whether it runs freely, pauses for your approval, or stays off, and read-only actions (like fetching or listing) are detected automatically so they aren't blocked by mistake. It's the clearest way yet to see what your agents can reach, and to keep write actions on a short leash.
|
||||
|
||||
## Connect Agents, running on your own machine
|
||||
|
||||
What you used to create as a "Platform Agent" is now a **Connect Agent** — a name that better reflects what it is: a third-party agent running on your own device, not on LobeHub. The execution-device switcher now appears for every agent, so you can point any conversation at a specific machine. Agents can call stdio MCP tools directly through your device and their results render inline in chat, and server-run agents now scan the project folder you bind them to — automatically picking up `.agents/skills`, `.claude/skills`, and your `AGENTS.md` / `CLAUDE.md`.
|
||||
|
||||
## See where your tokens go
|
||||
|
||||
The activity heatmap added a token-usage mode, so you can switch from "how often did I chat" to "how much did each day cost" without leaving the page. The topic sidebar can now group conversations by status, and one click collapses or expands every group at once.
|
||||
|
||||
## New model and chat-input polish
|
||||
|
||||
- **New model**: MiniMax M3, including its video runtime
|
||||
- **Configurable model routing and starters**, for finer control over which model handles what
|
||||
- The chat input's **`+` menu** was reworked with toggle switches and grouped submenus, and app-fixed tools now live in a dedicated **Pinned** section
|
||||
- Command output now **renders ANSI colors**, so `RunCommand` logs read just like your terminal
|
||||
- Inside a task, the comment box is now a full chat input that **kicks off a new run**
|
||||
|
||||
## Improvements and fixes
|
||||
|
||||
- Page-agent edits now run server-side, so they no longer break when you switch tabs, navigate away, or hit a network blip.
|
||||
- Cleaner auto-generated topic titles, with better results on DeepSeek, and stray Markdown tokens stripped from fallback titles.
|
||||
- The agent document editor renders system docs, defaults new files to `.md`, and preserves IME composition for CJK input.
|
||||
- Delete confirmations were restructured for clearer titles and wording across the app.
|
||||
- Desktop: macOS auto-update signing works again, the updater can quit cleanly, CLI tools resolve from your shell `PATH`, and a startup renderer crash is fixed.
|
||||
- Streaming no longer duplicates after a stale reconnect, and home-screen starters load more reliably.
|
||||
- The GitHub bot renders its `runCommand` result card, and agent documents load with noticeably less latency.
|
||||
@@ -1,41 +0,0 @@
|
||||
---
|
||||
title: 连接器与接入助理
|
||||
description: 用全新的「连接器」管控每个工具,让接入助理跑在你自己的设备上,并在活跃度热力图里直接追踪用量花费。
|
||||
tags:
|
||||
- 连接器
|
||||
- 接入助理
|
||||
- 用量洞察
|
||||
- 模型
|
||||
---
|
||||
|
||||
# 连接器与接入助理
|
||||
|
||||
## 连接器:统一管控每一个工具
|
||||
|
||||
连接器把助理的所有工具——MCP 服务器、技能市场的技能、内置工具,以及第三方集成——都纳入同一套权限体系。你可以为每个工具单独决定:直接放行、先暂停等你批准,还是干脆关闭;只读类操作(例如获取、列举)会被自动识别,不会被误拦。这是迄今最清晰的方式,让你看清助理能触达哪些能力,也把写入类操作牢牢攥在手里。
|
||||
|
||||
## 接入助理,跑在你自己的设备上
|
||||
|
||||
过去你创建的「平台 Agent」,现在叫 **接入助理**——这个名字更贴切:它是运行在你自己设备上的第三方助理,而非 LobeHub 托管的助理。执行设备切换器现在对所有助理可见,你可以把任意会话指向某台指定机器。助理能直接通过你的设备调用 stdio MCP 工具,结果会内嵌在聊天里呈现;在服务端运行的助理还会扫描你为它绑定的项目目录,自动读取 `.agents/skills`、`.claude/skills` 以及 `AGENTS.md` / `CLAUDE.md`。
|
||||
|
||||
## 看清 Token 花在哪
|
||||
|
||||
活跃度热力图新增了 Token 用量模式,无需离开页面,就能从「每天聊了多少次」切到「每天花了多少」。话题侧边栏现在支持按状态分组,一次点击即可折叠或展开全部分组。
|
||||
|
||||
## 新模型与输入框打磨
|
||||
|
||||
- **新模型**:MiniMax M3,含视频运行时
|
||||
- **可配置的模型路由与开场白**,更精细地决定由哪个模型处理什么
|
||||
- 聊天输入框的 **`+` 菜单** 重做,改用开关切换并分组归类;应用固定的工具现在收进独立的 **「固定」区**
|
||||
- 命令输出会**渲染 ANSI 颜色**,`RunCommand` 的日志读起来和终端里一样
|
||||
- 在任务里,评论框现在是完整的聊天输入框,可**直接发起一次新的运行**
|
||||
|
||||
## 体验优化与修复
|
||||
|
||||
- Page Agent 的编辑改到服务端执行,切换标签页、离开页面或网络抖动时不再中断。
|
||||
- 自动生成的话题标题更干净,在 DeepSeek 上效果更好,兜底标题里残留的 Markdown 符号也会被清除。
|
||||
- 助理文档编辑器可渲染系统文档,新建文件默认 `.md`,并保留中日韩输入法(IME)的组合输入。
|
||||
- 各处删除确认弹窗重新梳理了标题与文案,更清晰。
|
||||
- 桌面端:修复 macOS 自动更新签名、更新时能正常退出、CLI 工具可从 shell `PATH` 解析,以及启动时的渲染进程崩溃。
|
||||
- 修复重连后偶发的重复流式输出,首页开场白加载更稳定。
|
||||
- GitHub Bot 能正确渲染 `runCommand` 结果卡片,助理文档的加载延迟明显降低。
|
||||
+42
-172
@@ -2,385 +2,255 @@
|
||||
"$schema": "https://github.com/lobehub/lobe-chat/blob/main/docs/changelog/schema.json",
|
||||
"cloud": [],
|
||||
"community": [
|
||||
{
|
||||
"image": "/blog/assets65dddd1748c3de8646c8ad56abf53390.webp",
|
||||
"id": "2026-06-08-connectors",
|
||||
"date": "2026-06-08",
|
||||
"versionRange": [
|
||||
"2.2.2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets10cadd434aeb36bd1beb3c7b3d371fbd.webp",
|
||||
"id": "2026-05-31-drag-and-drop-skills",
|
||||
"date": "2026-05-31",
|
||||
"versionRange": [
|
||||
"2.2.0",
|
||||
"2.2.1"
|
||||
]
|
||||
"versionRange": ["2.2.0", "2.2.1"]
|
||||
},
|
||||
{
|
||||
"image": "https://hub-apac-1.lobeobjects.space/billboard/covers/1778838542538-MDEMAEav.png",
|
||||
"id": "2026-05-19-chief-agent-operator",
|
||||
"date": "2026-05-19",
|
||||
"versionRange": [
|
||||
"2.1.58",
|
||||
"2.2.0"
|
||||
]
|
||||
"versionRange": ["2.1.58", "2.2.0"]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets4aa1732a45832afc780600e6e329860c.webp",
|
||||
"id": "2026-05-11-agent-tasks-ga",
|
||||
"date": "2026-05-11",
|
||||
"versionRange": [
|
||||
"2.1.57"
|
||||
]
|
||||
"versionRange": ["2.1.57"]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assetsb2bf4ddf0a45ff887a993c18cb7ab983.webp",
|
||||
"id": "2026-05-04-task-scheduler",
|
||||
"date": "2026-05-04",
|
||||
"versionRange": [
|
||||
"2.1.54",
|
||||
"2.1.56"
|
||||
]
|
||||
"versionRange": ["2.1.54", "2.1.56"]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assetsfa267a02f20bc5ba6f1273bcf27b7c9f.webp",
|
||||
"id": "2026-04-27-heterogeneous-agent",
|
||||
"date": "2026-04-27",
|
||||
"versionRange": [
|
||||
"2.1.53"
|
||||
]
|
||||
"versionRange": ["2.1.53"]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assetsdfda32866c4bc59af0526e52f31d1da2.webp",
|
||||
"id": "2026-04-20-daily-brief",
|
||||
"date": "2026-04-20",
|
||||
"versionRange": [
|
||||
"2.1.50",
|
||||
"2.1.52"
|
||||
]
|
||||
"versionRange": ["2.1.50", "2.1.52"]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets300abe7e259d293da6c5ed4f642a1be6.webp",
|
||||
"id": "2026-04-13-gateway-sidebar",
|
||||
"date": "2026-04-13",
|
||||
"versionRange": [
|
||||
"2.1.48",
|
||||
"2.1.49"
|
||||
]
|
||||
"versionRange": ["2.1.48", "2.1.49"]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets7ea204859aeb5aa9be5810a20ba1669a.webp",
|
||||
"id": "2026-04-06-auto-completion",
|
||||
"date": "2026-04-06",
|
||||
"versionRange": [
|
||||
"2.1.47"
|
||||
]
|
||||
"versionRange": ["2.1.47"]
|
||||
},
|
||||
{
|
||||
"id": "2026-03-30-agent-tasks",
|
||||
"date": "2026-03-30",
|
||||
"versionRange": [
|
||||
"2.1.45",
|
||||
"2.1.46"
|
||||
]
|
||||
"versionRange": ["2.1.45", "2.1.46"]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets53e6ec9cf72554dbc1f8224fc0550a03.webp",
|
||||
"id": "2026-03-23-media-memory",
|
||||
"date": "2026-03-23",
|
||||
"versionRange": [
|
||||
"2.1.44"
|
||||
]
|
||||
"versionRange": ["2.1.44"]
|
||||
},
|
||||
{
|
||||
"image": "https://hub-apac-1.lobeobjects.space/blog/assets/4a68a7644501cb513d08670b102a446e.webp",
|
||||
"id": "2026-03-16-search",
|
||||
"date": "2026-03-16",
|
||||
"versionRange": [
|
||||
"2.1.38",
|
||||
"2.1.43"
|
||||
]
|
||||
"versionRange": ["2.1.38", "2.1.43"]
|
||||
},
|
||||
{
|
||||
"id": "2026-02-08-runtime-auth",
|
||||
"date": "2026-02-08",
|
||||
"versionRange": [
|
||||
"2.1.6",
|
||||
"2.1.26"
|
||||
]
|
||||
"versionRange": ["2.1.6", "2.1.26"]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assetsa8e504275f2cd891fabecca985998de0.webp",
|
||||
"id": "2026-01-27-v2",
|
||||
"date": "2026-01-27",
|
||||
"versionRange": [
|
||||
"2.0.1",
|
||||
"2.1.5"
|
||||
]
|
||||
"versionRange": ["2.0.1", "2.1.5"]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets7f3b38c1d76cceb91edb29d6b1eb60db.webp",
|
||||
"id": "2025-12-20-mcp",
|
||||
"date": "2025-12-20",
|
||||
"versionRange": [
|
||||
"1.142.8",
|
||||
"1.143.0"
|
||||
]
|
||||
"versionRange": ["1.142.8", "1.143.0"]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets3a7f0b29839603336e39e923b423409b.webp",
|
||||
"id": "2025-11-08-comfy-ui",
|
||||
"date": "2025-11-08",
|
||||
"versionRange": [
|
||||
"1.133.5",
|
||||
"1.142.8"
|
||||
]
|
||||
"versionRange": ["1.133.5", "1.142.8"]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets35e6aa692b0c16009c61964279514166.webp",
|
||||
"id": "2025-10-08-python",
|
||||
"date": "2025-10-08",
|
||||
"versionRange": [
|
||||
"1.120.7",
|
||||
"1.133.5"
|
||||
]
|
||||
"versionRange": ["1.120.7", "1.133.5"]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assetsce5d6dc93676f974be2e162e8ace03f0.webp",
|
||||
"id": "2025-09-08-gemini",
|
||||
"date": "2025-09-08",
|
||||
"versionRange": [
|
||||
"1.109.1",
|
||||
"1.120.7"
|
||||
]
|
||||
"versionRange": ["1.109.1", "1.120.7"]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assetsdf48eed9de76b7e37c269b294285f09d.webp",
|
||||
"id": "2025-08-08-image-generation",
|
||||
"date": "2025-08-08",
|
||||
"versionRange": [
|
||||
"1.97.10",
|
||||
"1.109.1"
|
||||
]
|
||||
"versionRange": ["1.97.10", "1.109.1"]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets902eb746fe2042fc2ea831c71002be72.webp",
|
||||
"id": "2025-07-08-mcp-market",
|
||||
"date": "2025-07-08",
|
||||
"versionRange": [
|
||||
"1.93.3",
|
||||
"1.97.10"
|
||||
]
|
||||
"versionRange": ["1.93.3", "1.97.10"]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets5cc27b8cae995074da20d4ffe06a1460.webp",
|
||||
"id": "2025-06-08-claude-4",
|
||||
"date": "2025-06-08",
|
||||
"versionRange": [
|
||||
"1.84.27",
|
||||
"1.93.3"
|
||||
]
|
||||
"versionRange": ["1.84.27", "1.93.3"]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets2a36d86a4eed6e7938dd6e9c684701ed.webp",
|
||||
"id": "2025-05-08-desktop-app",
|
||||
"date": "2025-05-08",
|
||||
"versionRange": [
|
||||
"1.77.17",
|
||||
"1.84.27"
|
||||
]
|
||||
"versionRange": ["1.77.17", "1.84.27"]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assetsc0efdb82443556ae3acefe00099b3f23.webp",
|
||||
"id": "2025-04-06-exports",
|
||||
"date": "2025-04-06",
|
||||
"versionRange": [
|
||||
"1.67.2",
|
||||
"1.77.17"
|
||||
]
|
||||
"versionRange": ["1.67.2", "1.77.17"]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assetse743f0a47127390dde766a0a790476db.webp",
|
||||
"id": "2025-03-02-new-models",
|
||||
"date": "2025-03-02",
|
||||
"versionRange": [
|
||||
"1.49.13",
|
||||
"1.67.2"
|
||||
]
|
||||
"versionRange": ["1.49.13", "1.67.2"]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets18168d5fe64ea34905a7e52fd82d0e9d.webp",
|
||||
"id": "2025-02-02-deepseek-r1",
|
||||
"date": "2025-02-02",
|
||||
"versionRange": [
|
||||
"1.47.8",
|
||||
"1.49.12"
|
||||
]
|
||||
"versionRange": ["1.47.8", "1.49.12"]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assetsf9ed064fe764cbeff2f46910e7099a91.webp",
|
||||
"id": "2025-01-22-new-ai-provider",
|
||||
"date": "2025-01-22",
|
||||
"versionRange": [
|
||||
"1.43.1",
|
||||
"1.47.7"
|
||||
]
|
||||
"versionRange": ["1.43.1", "1.47.7"]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets2d409f43b58953ad5396c6beab8a0719.webp",
|
||||
"id": "2025-01-03-user-profile",
|
||||
"date": "2025-01-03",
|
||||
"versionRange": [
|
||||
"1.34.1",
|
||||
"1.43.0"
|
||||
]
|
||||
"versionRange": ["1.34.1", "1.43.0"]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets/d9cbfcbef130183bc490d515d8a38aa4.webp",
|
||||
"id": "2024-11-27-forkable-chat",
|
||||
"date": "2024-11-27",
|
||||
"versionRange": [
|
||||
"1.33.1",
|
||||
"1.34.0"
|
||||
]
|
||||
"versionRange": ["1.33.1", "1.34.0"]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets/2d678631c55369ba7d753c3ffcb73782.webp",
|
||||
"id": "2024-11-25-november-providers",
|
||||
"date": "2024-11-25",
|
||||
"versionRange": [
|
||||
"1.30.1",
|
||||
"1.33.0"
|
||||
]
|
||||
"versionRange": ["1.30.1", "1.33.0"]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets/f10a4b98782e36797c38071eed785c6f.webp",
|
||||
"id": "2024-11-06-share-text-json",
|
||||
"date": "2024-11-06",
|
||||
"versionRange": [
|
||||
"1.26.1",
|
||||
"1.28.0"
|
||||
]
|
||||
"versionRange": ["1.26.1", "1.28.0"]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets/944c671604833cd2457445b211ebba33.webp",
|
||||
"id": "2024-10-27-pin-assistant",
|
||||
"date": "2024-10-27",
|
||||
"versionRange": [
|
||||
"1.19.1",
|
||||
"1.26.0"
|
||||
]
|
||||
"versionRange": ["1.19.1", "1.26.0"]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets/f6d047a345e47a52592cff916c9a64ce.webp",
|
||||
"id": "2024-09-20-artifacts",
|
||||
"date": "2024-09-20",
|
||||
"versionRange": [
|
||||
"1.17.1",
|
||||
"1.19.0"
|
||||
]
|
||||
"versionRange": ["1.17.1", "1.19.0"]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets/d7e57f8e69f97b76b3c2414f3441b6e4.webp",
|
||||
"id": "2024-09-13-openai-o1-models",
|
||||
"date": "2024-09-13",
|
||||
"versionRange": [
|
||||
"1.12.1",
|
||||
"1.17.0"
|
||||
]
|
||||
"versionRange": ["1.12.1", "1.17.0"]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets/d6129350de510a62fe87b2d2f0fb9477.webp",
|
||||
"id": "2024-08-21-file-upload-and-knowledge-base",
|
||||
"date": "2024-08-21",
|
||||
"versionRange": [
|
||||
"1.8.1",
|
||||
"1.12.0"
|
||||
]
|
||||
"versionRange": ["1.8.1", "1.12.0"]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets/37d85fdfccff9ed56e9c6827faee01c7.webp",
|
||||
"id": "2024-08-02-lobe-chat-database-docker",
|
||||
"date": "2024-08-02",
|
||||
"versionRange": [
|
||||
"1.6.1",
|
||||
"1.8.0"
|
||||
]
|
||||
"versionRange": ["1.6.1", "1.8.0"]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets/39d7890f8cbe21e77db8d3c94f7f22e4.webp",
|
||||
"id": "2024-07-19-gpt-4o-mini",
|
||||
"date": "2024-07-19",
|
||||
"versionRange": [
|
||||
"1.0.1",
|
||||
"1.6.0"
|
||||
]
|
||||
"versionRange": ["1.0.1", "1.6.0"]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets/eb477e62217f4d1b644eff975c7ac168.webp",
|
||||
"id": "2024-06-19-lobe-chat-v1",
|
||||
"date": "2024-06-19",
|
||||
"versionRange": [
|
||||
"0.147.0",
|
||||
"1.0.0"
|
||||
]
|
||||
"versionRange": ["0.147.0", "1.0.0"]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets/8a8d361b4c0cce6da350cc0de65c0ad6.webp",
|
||||
"id": "2024-02-14-ollama",
|
||||
"date": "2024-02-14",
|
||||
"versionRange": [
|
||||
"0.125.1",
|
||||
"0.127.0"
|
||||
]
|
||||
"versionRange": ["0.125.1", "0.127.0"]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets/9498087e85f27e692716a63cb3b58d79.webp",
|
||||
"id": "2024-02-08-sso-oauth",
|
||||
"date": "2024-02-08",
|
||||
"versionRange": [
|
||||
"0.118.1",
|
||||
"0.125.0"
|
||||
]
|
||||
"versionRange": ["0.118.1", "0.125.0"]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets/603fefbb944bc6761ebdab5956fc0084.webp",
|
||||
"id": "2023-12-22-dalle-3",
|
||||
"date": "2023-12-22",
|
||||
"versionRange": [
|
||||
"0.102.1",
|
||||
"0.118.0"
|
||||
]
|
||||
"versionRange": ["0.102.1", "0.118.0"]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets/8d4c2cc0ce8654fa8ac06cc036a7f941.webp",
|
||||
"id": "2023-11-19-tts-stt",
|
||||
"date": "2023-11-19",
|
||||
"versionRange": [
|
||||
"0.101.1",
|
||||
"0.102.0"
|
||||
]
|
||||
"versionRange": ["0.101.1", "0.102.0"]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets/d47654360d626f80144cdedb979a3526.webp",
|
||||
"id": "2023-11-14-gpt4-vision",
|
||||
"date": "2023-11-14",
|
||||
"versionRange": [
|
||||
"0.90.0",
|
||||
"0.101.0"
|
||||
]
|
||||
"versionRange": ["0.90.0", "0.101.0"]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets/50b38eac1769ae6f13aef72f3d725eec.webp",
|
||||
"id": "2023-09-09-plugin-system",
|
||||
"date": "2023-09-09",
|
||||
"versionRange": [
|
||||
"0.67.0",
|
||||
"0.72.0"
|
||||
]
|
||||
"versionRange": ["0.67.0", "0.72.0"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -36,8 +36,6 @@ table agents {
|
||||
title [name: 'agents_title_idx']
|
||||
description [name: 'agents_description_idx']
|
||||
session_group_id [name: 'agents_session_group_id_idx']
|
||||
workspace_id [name: 'agents_workspace_id_idx']
|
||||
(workspace_id, slug) [name: 'agents_slug_workspace_id_unique', unique]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +54,6 @@ table agents_files {
|
||||
agent_id [name: 'agents_files_agent_id_idx']
|
||||
file_id [name: 'agents_files_file_id_idx']
|
||||
user_id [name: 'agents_files_user_id_idx']
|
||||
workspace_id [name: 'agents_files_workspace_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,7 +72,6 @@ table agents_knowledge_bases {
|
||||
agent_id [name: 'agents_knowledge_bases_agent_id_idx']
|
||||
knowledge_base_id [name: 'agents_knowledge_bases_knowledge_base_id_idx']
|
||||
user_id [name: 'agents_knowledge_bases_user_id_idx']
|
||||
workspace_id [name: 'agents_knowledge_bases_workspace_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,7 +94,6 @@ table agent_bot_providers {
|
||||
platform [name: 'agent_bot_providers_platform_idx']
|
||||
agent_id [name: 'agent_bot_providers_agent_id_idx']
|
||||
user_id [name: 'agent_bot_providers_user_id_idx']
|
||||
workspace_id [name: 'agent_bot_providers_workspace_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,7 +126,6 @@ table agent_cron_jobs {
|
||||
enabled [name: 'agent_cron_jobs_enabled_idx']
|
||||
remaining_executions [name: 'agent_cron_jobs_remaining_executions_idx']
|
||||
last_executed_at [name: 'agent_cron_jobs_last_executed_at_idx']
|
||||
workspace_id [name: 'agent_cron_jobs_workspace_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,7 +152,6 @@ table agent_documents {
|
||||
updated_at "timestamp with time zone" [not null, default: `now()`]
|
||||
|
||||
indexes {
|
||||
workspace_id [name: 'agent_documents_workspace_id_idx']
|
||||
user_id [name: 'agent_documents_user_id_idx']
|
||||
agent_id [name: 'agent_documents_agent_id_idx']
|
||||
access_self [name: 'agent_documents_access_self_idx']
|
||||
@@ -196,8 +189,6 @@ table agent_eval_benchmarks {
|
||||
(identifier, user_id) [name: 'agent_eval_benchmarks_identifier_user_id_unique', unique]
|
||||
is_system [name: 'agent_eval_benchmarks_is_system_idx']
|
||||
user_id [name: 'agent_eval_benchmarks_user_id_idx']
|
||||
workspace_id [name: 'agent_eval_benchmarks_workspace_id_idx']
|
||||
(workspace_id, identifier) [name: 'agent_eval_benchmarks_identifier_workspace_id_unique', unique]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,8 +213,6 @@ table agent_eval_datasets {
|
||||
benchmark_id [name: 'agent_eval_datasets_benchmark_id_idx']
|
||||
source_experiment_id [name: 'agent_eval_datasets_source_experiment_id_idx']
|
||||
user_id [name: 'agent_eval_datasets_user_id_idx']
|
||||
workspace_id [name: 'agent_eval_datasets_workspace_id_idx']
|
||||
(workspace_id, identifier) [name: 'agent_eval_datasets_identifier_workspace_id_unique', unique]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,7 +227,6 @@ table agent_eval_experiment_benchmarks {
|
||||
(experiment_id, benchmark_id) [pk]
|
||||
benchmark_id [name: 'agent_eval_experiment_benchmarks_benchmark_id_idx']
|
||||
user_id [name: 'agent_eval_experiment_benchmarks_user_id_idx']
|
||||
workspace_id [name: 'agent_eval_experiment_benchmarks_workspace_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,7 +243,6 @@ table agent_eval_experiments {
|
||||
|
||||
indexes {
|
||||
user_id [name: 'agent_eval_experiments_user_id_idx']
|
||||
workspace_id [name: 'agent_eval_experiments_workspace_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,7 +263,6 @@ table agent_eval_run_topics {
|
||||
user_id [name: 'agent_eval_run_topics_user_id_idx']
|
||||
run_id [name: 'agent_eval_run_topics_run_id_idx']
|
||||
test_case_id [name: 'agent_eval_run_topics_test_case_id_idx']
|
||||
workspace_id [name: 'agent_eval_run_topics_workspace_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,7 +291,6 @@ table agent_eval_runs {
|
||||
user_id [name: 'agent_eval_runs_user_id_idx']
|
||||
status [name: 'agent_eval_runs_status_idx']
|
||||
target_agent_id [name: 'agent_eval_runs_target_agent_id_idx']
|
||||
workspace_id [name: 'agent_eval_runs_workspace_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -327,7 +312,6 @@ table agent_eval_test_cases {
|
||||
user_id [name: 'agent_eval_test_cases_user_id_idx']
|
||||
dataset_id [name: 'agent_eval_test_cases_dataset_id_idx']
|
||||
sort_order [name: 'agent_eval_test_cases_sort_order_idx']
|
||||
workspace_id [name: 'agent_eval_test_cases_workspace_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,9 +327,6 @@ table agent_operations {
|
||||
parent_operation_id text
|
||||
status text [not null]
|
||||
completion_reason text
|
||||
verify_status text
|
||||
verify_plan jsonb
|
||||
verify_plan_confirmed_at "timestamp with time zone"
|
||||
started_at "timestamp with time zone"
|
||||
completed_at "timestamp with time zone"
|
||||
step_count integer
|
||||
@@ -388,7 +369,6 @@ table agent_operations {
|
||||
status [name: 'agent_operations_status_idx']
|
||||
(user_id, created_at) [name: 'agent_operations_user_id_created_at_idx']
|
||||
metadata [name: 'agent_operations_metadata_idx']
|
||||
workspace_id [name: 'agent_operations_workspace_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -431,14 +411,11 @@ table agent_skills {
|
||||
user_id [name: 'agent_skills_user_id_idx']
|
||||
source [name: 'agent_skills_source_idx']
|
||||
zip_file_hash [name: 'agent_skills_zip_hash_idx']
|
||||
workspace_id [name: 'agent_skills_workspace_id_idx']
|
||||
(workspace_id, name) [name: 'agent_skills_name_workspace_id_unique', unique]
|
||||
}
|
||||
}
|
||||
|
||||
table ai_models {
|
||||
id varchar(150) [not null]
|
||||
"_id" uuid [default: `gen_random_uuid()`]
|
||||
display_name varchar(200)
|
||||
description text
|
||||
organization varchar(100)
|
||||
@@ -463,14 +440,12 @@ table ai_models {
|
||||
indexes {
|
||||
(id, provider_id, user_id) [pk]
|
||||
user_id [name: 'ai_models_user_id_idx']
|
||||
workspace_id [name: 'ai_models_workspace_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
table ai_providers {
|
||||
id varchar(64) [not null]
|
||||
name text
|
||||
"_id" uuid [default: `gen_random_uuid()`]
|
||||
user_id text [not null]
|
||||
sort integer
|
||||
enabled boolean
|
||||
@@ -490,7 +465,6 @@ table ai_providers {
|
||||
indexes {
|
||||
(id, user_id) [pk]
|
||||
user_id [name: 'ai_providers_user_id_idx']
|
||||
workspace_id [name: 'ai_providers_workspace_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -510,7 +484,6 @@ table api_keys {
|
||||
|
||||
indexes {
|
||||
user_id [name: 'api_keys_user_id_idx']
|
||||
workspace_id [name: 'api_keys_workspace_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -535,7 +508,6 @@ table async_tasks {
|
||||
(type, status) [name: 'async_tasks_type_status_idx']
|
||||
inference_id [name: 'async_tasks_inference_id_idx']
|
||||
metadata [name: 'async_tasks_metadata_idx']
|
||||
workspace_id [name: 'async_tasks_workspace_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -642,7 +614,6 @@ table chat_groups {
|
||||
(client_id, user_id) [name: 'chat_groups_client_id_user_id_unique', unique]
|
||||
user_id [name: 'chat_groups_user_id_idx']
|
||||
group_id [name: 'chat_groups_group_id_idx']
|
||||
workspace_id [name: 'chat_groups_workspace_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -661,7 +632,6 @@ table chat_groups_agents {
|
||||
indexes {
|
||||
(chat_group_id, agent_id) [pk]
|
||||
user_id [name: 'chat_groups_agents_user_id_idx']
|
||||
workspace_id [name: 'chat_groups_agents_workspace_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -690,7 +660,6 @@ table user_connector_tools {
|
||||
(user_connector_id, tool_name) [name: 'user_connector_tools_connector_tool_unique', unique]
|
||||
user_id [name: 'user_connector_tools_user_id_idx']
|
||||
user_connector_id [name: 'user_connector_tools_connector_id_idx']
|
||||
workspace_id [name: 'user_connector_tools_workspace_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -718,26 +687,6 @@ table user_connectors {
|
||||
(user_id, identifier) [name: 'user_connectors_user_identifier_unique', unique]
|
||||
user_id [name: 'user_connectors_user_id_idx']
|
||||
token_expires_at [name: 'user_connectors_token_expires_at_idx']
|
||||
workspace_id [name: 'user_connectors_workspace_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
table user_installed_plugins {
|
||||
user_id text [not null]
|
||||
workspace_id text
|
||||
identifier text [not null]
|
||||
type text [not null]
|
||||
manifest jsonb
|
||||
settings jsonb
|
||||
custom_params jsonb
|
||||
source varchar(255)
|
||||
accessed_at "timestamp with time zone" [not null, default: `now()`]
|
||||
created_at "timestamp with time zone" [not null, default: `now()`]
|
||||
updated_at "timestamp with time zone" [not null, default: `now()`]
|
||||
|
||||
indexes {
|
||||
(user_id, identifier) [pk]
|
||||
workspace_id [name: 'user_installed_plugins_workspace_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -762,7 +711,6 @@ table devices {
|
||||
indexes {
|
||||
(user_id, device_id) [name: 'devices_user_id_device_id_unique', unique]
|
||||
user_id [name: 'devices_user_id_idx']
|
||||
workspace_id [name: 'devices_workspace_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -779,7 +727,6 @@ table document_histories {
|
||||
document_id [name: 'document_histories_document_id_idx']
|
||||
user_id [name: 'document_histories_user_id_idx']
|
||||
saved_at [name: 'document_histories_saved_at_idx']
|
||||
workspace_id [name: 'document_histories_workspace_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -798,7 +745,6 @@ table document_shares {
|
||||
indexes {
|
||||
document_id [name: 'document_shares_document_id_unique', unique]
|
||||
user_id [name: 'document_shares_user_id_idx']
|
||||
workspace_id [name: 'document_shares_workspace_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -837,8 +783,6 @@ table documents {
|
||||
knowledge_base_id [name: 'documents_knowledge_base_id_idx']
|
||||
(client_id, user_id) [name: 'documents_client_id_user_id_unique', unique]
|
||||
(slug, user_id) [name: 'documents_slug_user_id_unique', unique]
|
||||
workspace_id [name: 'documents_workspace_id_idx']
|
||||
(workspace_id, slug) [name: 'documents_slug_workspace_id_unique', unique]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -868,7 +812,6 @@ table files {
|
||||
chunk_task_id [name: 'files_chunk_task_id_idx']
|
||||
embedding_task_id [name: 'files_embedding_task_id_idx']
|
||||
(client_id, user_id) [name: 'files_client_id_user_id_unique', unique]
|
||||
workspace_id [name: 'files_workspace_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -899,7 +842,6 @@ table knowledge_base_files {
|
||||
knowledge_base_id [name: 'knowledge_base_files_kb_id_idx']
|
||||
user_id [name: 'knowledge_base_files_user_id_idx']
|
||||
file_id [name: 'knowledge_base_files_file_id_idx']
|
||||
workspace_id [name: 'knowledge_base_files_workspace_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -921,7 +863,6 @@ table knowledge_bases {
|
||||
indexes {
|
||||
(client_id, user_id) [name: 'knowledge_bases_client_id_user_id_unique', unique]
|
||||
user_id [name: 'knowledge_bases_user_id_idx']
|
||||
workspace_id [name: 'knowledge_bases_workspace_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -944,7 +885,6 @@ table generation_batches {
|
||||
indexes {
|
||||
user_id [name: 'generation_batches_user_id_idx']
|
||||
generation_topic_id [name: 'generation_batches_topic_id_idx']
|
||||
workspace_id [name: 'generation_batches_workspace_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -961,7 +901,6 @@ table generation_topics {
|
||||
|
||||
indexes {
|
||||
user_id [name: 'generation_topics_user_id_idx']
|
||||
workspace_id [name: 'generation_topics_workspace_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -982,7 +921,6 @@ table generations {
|
||||
user_id [name: 'generations_user_id_idx']
|
||||
generation_batch_id [name: 'generations_batch_id_idx']
|
||||
file_id [name: 'generations_file_id_idx']
|
||||
workspace_id [name: 'generations_workspace_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1034,7 +972,6 @@ table llm_generation_tracing {
|
||||
validation_failed [name: 'llm_generation_tracing_validation_failed_idx']
|
||||
feedback_signal [name: 'llm_generation_tracing_feedback_signal_idx']
|
||||
created_at [name: 'llm_generation_tracing_created_at_idx']
|
||||
workspace_id [name: 'llm_generation_tracing_workspace_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1048,7 +985,6 @@ table message_chunks {
|
||||
(chunk_id, message_id) [pk]
|
||||
user_id [name: 'message_chunks_user_id_idx']
|
||||
message_id [name: 'message_chunks_message_id_idx']
|
||||
workspace_id [name: 'message_chunks_workspace_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1077,7 +1013,6 @@ table message_groups {
|
||||
type [name: 'message_groups_type_idx']
|
||||
parent_group_id [name: 'message_groups_parent_group_id_idx']
|
||||
parent_message_id [name: 'message_groups_parent_message_id_idx']
|
||||
workspace_id [name: 'message_groups_workspace_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1099,7 +1034,6 @@ table message_plugins {
|
||||
(client_id, user_id) [name: 'message_plugins_client_id_user_id_unique', unique]
|
||||
user_id [name: 'message_plugins_user_id_idx']
|
||||
tool_call_id [name: 'message_plugins_tool_call_id_idx']
|
||||
workspace_id [name: 'message_plugins_workspace_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1118,7 +1052,6 @@ table message_queries {
|
||||
user_id [name: 'message_queries_user_id_idx']
|
||||
message_id [name: 'message_queries_message_id_idx']
|
||||
embeddings_id [name: 'message_queries_embeddings_id_idx']
|
||||
workspace_id [name: 'message_queries_workspace_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1135,7 +1068,6 @@ table message_query_chunks {
|
||||
user_id [name: 'message_query_chunks_user_id_idx']
|
||||
id [name: 'message_query_chunks_message_id_idx']
|
||||
query_id [name: 'message_query_chunks_query_id_idx']
|
||||
workspace_id [name: 'message_query_chunks_workspace_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1151,7 +1083,6 @@ table message_tts {
|
||||
indexes {
|
||||
(client_id, user_id) [name: 'message_tts_client_id_user_id_unique', unique]
|
||||
user_id [name: 'message_tts_user_id_idx']
|
||||
workspace_id [name: 'message_tts_workspace_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1167,7 +1098,6 @@ table message_translates {
|
||||
indexes {
|
||||
(client_id, user_id) [name: 'message_translates_client_id_user_id_unique', unique]
|
||||
user_id [name: 'message_translates_user_id_idx']
|
||||
workspace_id [name: 'message_translates_workspace_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1218,7 +1148,6 @@ table messages {
|
||||
message_group_id [name: 'messages_message_group_id_idx']
|
||||
() [name: 'messages_usage_cost_idx']
|
||||
() [name: 'messages_usage_total_tokens_idx']
|
||||
workspace_id [name: 'messages_workspace_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1232,7 +1161,6 @@ table messages_files {
|
||||
(file_id, message_id) [pk]
|
||||
user_id [name: 'messages_files_user_id_idx']
|
||||
message_id [name: 'messages_files_message_id_idx']
|
||||
workspace_id [name: 'messages_files_workspace_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1253,7 +1181,6 @@ table messenger_account_links {
|
||||
(platform, tenant_id, platform_user_id) [name: 'messenger_account_links_platform_tenant_user_unique', unique]
|
||||
(user_id, platform, tenant_id) [name: 'messenger_account_links_user_platform_tenant_unique', unique]
|
||||
active_agent_id [name: 'messenger_account_links_active_agent_idx']
|
||||
workspace_id [name: 'messenger_account_links_workspace_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1561,7 +1488,6 @@ table chunks {
|
||||
indexes {
|
||||
(client_id, user_id) [name: 'chunks_client_id_user_id_unique', unique]
|
||||
user_id [name: 'chunks_user_id_idx']
|
||||
workspace_id [name: 'chunks_workspace_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1578,7 +1504,6 @@ table document_chunks {
|
||||
document_id [name: 'document_chunks_document_id_idx']
|
||||
chunk_id [name: 'document_chunks_chunk_id_idx']
|
||||
user_id [name: 'document_chunks_user_id_idx']
|
||||
workspace_id [name: 'document_chunks_workspace_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1595,7 +1520,6 @@ table embeddings {
|
||||
(client_id, user_id) [name: 'embeddings_client_id_user_id_unique', unique]
|
||||
chunk_id [name: 'embeddings_chunk_id_idx']
|
||||
user_id [name: 'embeddings_user_id_idx']
|
||||
workspace_id [name: 'embeddings_workspace_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1620,7 +1544,6 @@ table unstructured_chunks {
|
||||
user_id [name: 'unstructured_chunks_user_id_idx']
|
||||
composite_id [name: 'unstructured_chunks_composite_id_idx']
|
||||
file_id [name: 'unstructured_chunks_file_id_idx']
|
||||
workspace_id [name: 'unstructured_chunks_workspace_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1639,7 +1562,6 @@ table rag_eval_dataset_records {
|
||||
|
||||
indexes {
|
||||
user_id [name: 'rag_eval_dataset_records_user_id_idx']
|
||||
workspace_id [name: 'rag_eval_dataset_records_workspace_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1656,7 +1578,6 @@ table rag_eval_datasets {
|
||||
|
||||
indexes {
|
||||
user_id [name: 'rag_eval_datasets_user_id_idx']
|
||||
workspace_id [name: 'rag_eval_datasets_workspace_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1679,7 +1600,6 @@ table rag_eval_evaluations {
|
||||
|
||||
indexes {
|
||||
user_id [name: 'rag_eval_evaluations_user_id_idx']
|
||||
workspace_id [name: 'rag_eval_evaluations_workspace_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1705,7 +1625,6 @@ table rag_eval_evaluation_records {
|
||||
|
||||
indexes {
|
||||
user_id [name: 'rag_eval_evaluation_records_user_id_idx']
|
||||
workspace_id [name: 'rag_eval_evaluation_records_workspace_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1735,7 +1654,7 @@ table rbac_role_permissions {
|
||||
|
||||
table rbac_roles {
|
||||
id text [pk, not null]
|
||||
name text [not null]
|
||||
name text [not null, unique]
|
||||
display_name text [not null]
|
||||
description text
|
||||
is_system boolean [not null, default: false]
|
||||
@@ -1745,15 +1664,9 @@ table rbac_roles {
|
||||
accessed_at "timestamp with time zone" [not null, default: `now()`]
|
||||
created_at "timestamp with time zone" [not null, default: `now()`]
|
||||
updated_at "timestamp with time zone" [not null, default: `now()`]
|
||||
|
||||
indexes {
|
||||
workspace_id [name: 'rbac_roles_workspace_id_idx']
|
||||
(name, workspace_id) [name: 'rbac_roles_name_workspace_unique', unique]
|
||||
}
|
||||
}
|
||||
|
||||
table rbac_user_roles {
|
||||
id uuid [pk, not null, default: `gen_random_uuid()`]
|
||||
user_id text [not null]
|
||||
role_id text [not null]
|
||||
workspace_id text
|
||||
@@ -1761,10 +1674,9 @@ table rbac_user_roles {
|
||||
expires_at "timestamp with time zone"
|
||||
|
||||
indexes {
|
||||
(user_id, role_id, workspace_id) [name: 'rbac_user_roles_user_role_scope_unique', unique]
|
||||
(user_id, role_id) [pk]
|
||||
user_id [name: 'rbac_user_roles_user_id_idx']
|
||||
role_id [name: 'rbac_user_roles_role_id_idx']
|
||||
workspace_id [name: 'rbac_user_roles_workspace_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1779,7 +1691,6 @@ table agents_to_sessions {
|
||||
session_id [name: 'agents_to_sessions_session_id_idx']
|
||||
agent_id [name: 'agents_to_sessions_agent_id_idx']
|
||||
user_id [name: 'agents_to_sessions_user_id_idx']
|
||||
workspace_id [name: 'agents_to_sessions_workspace_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1795,7 +1706,6 @@ table file_chunks {
|
||||
user_id [name: 'file_chunks_user_id_idx']
|
||||
file_id [name: 'file_chunks_file_id_idx']
|
||||
chunk_id [name: 'file_chunks_chunk_id_idx']
|
||||
workspace_id [name: 'file_chunks_workspace_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1810,7 +1720,6 @@ table files_to_sessions {
|
||||
user_id [name: 'files_to_sessions_user_id_idx']
|
||||
file_id [name: 'files_to_sessions_file_id_idx']
|
||||
session_id [name: 'files_to_sessions_session_id_idx']
|
||||
workspace_id [name: 'files_to_sessions_workspace_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1828,7 +1737,6 @@ table session_groups {
|
||||
indexes {
|
||||
(client_id, user_id) [name: 'session_groups_client_id_user_id_unique', unique]
|
||||
user_id [name: 'session_groups_user_id_idx']
|
||||
workspace_id [name: 'session_groups_workspace_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1856,8 +1764,6 @@ table sessions {
|
||||
(id, user_id) [name: 'sessions_id_user_id_idx']
|
||||
(user_id, updated_at) [name: 'sessions_user_id_updated_at_idx']
|
||||
group_id [name: 'sessions_group_id_idx']
|
||||
workspace_id [name: 'sessions_workspace_id_idx']
|
||||
(workspace_id, slug) [name: 'sessions_slug_workspace_id_unique', unique]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1909,7 +1815,6 @@ table briefs {
|
||||
priority [name: 'briefs_priority_idx']
|
||||
(user_id, resolved_at) [name: 'briefs_unresolved_idx']
|
||||
trigger [name: 'briefs_trigger_idx']
|
||||
workspace_id [name: 'briefs_workspace_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1935,7 +1840,6 @@ table task_comments {
|
||||
author_agent_id [name: 'task_comments_agent_id_idx']
|
||||
brief_id [name: 'task_comments_brief_id_idx']
|
||||
topic_id [name: 'task_comments_topic_id_idx']
|
||||
workspace_id [name: 'task_comments_workspace_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1954,7 +1858,6 @@ table task_dependencies {
|
||||
task_id [name: 'task_deps_task_id_idx']
|
||||
depends_on_id [name: 'task_deps_depends_on_id_idx']
|
||||
user_id [name: 'task_deps_user_id_idx']
|
||||
workspace_id [name: 'task_dependencies_workspace_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1972,7 +1875,6 @@ table task_documents {
|
||||
task_id [name: 'task_docs_task_id_idx']
|
||||
document_id [name: 'task_docs_document_id_idx']
|
||||
user_id [name: 'task_docs_user_id_idx']
|
||||
workspace_id [name: 'task_documents_workspace_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2001,7 +1903,6 @@ table task_topics {
|
||||
topic_id [name: 'task_topics_topic_id_idx']
|
||||
user_id [name: 'task_topics_user_id_idx']
|
||||
(task_id, status) [name: 'task_topics_status_idx']
|
||||
workspace_id [name: 'task_topics_workspace_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2052,8 +1953,6 @@ table tasks {
|
||||
priority [name: 'tasks_priority_idx']
|
||||
automation_mode [name: 'tasks_automation_mode_idx']
|
||||
(status, last_heartbeat_at) [name: 'tasks_heartbeat_idx']
|
||||
workspace_id [name: 'tasks_workspace_id_idx']
|
||||
(workspace_id, identifier) [name: 'tasks_identifier_workspace_id_unique', unique]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2086,7 +1985,6 @@ table threads {
|
||||
agent_id [name: 'threads_agent_id_idx']
|
||||
group_id [name: 'threads_group_id_idx']
|
||||
parent_thread_id [name: 'threads_parent_thread_id_idx']
|
||||
workspace_id [name: 'threads_workspace_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2102,7 +2000,6 @@ table topic_documents {
|
||||
user_id [name: 'topic_documents_user_id_idx']
|
||||
topic_id [name: 'topic_documents_topic_id_idx']
|
||||
document_id [name: 'topic_documents_document_id_idx']
|
||||
workspace_id [name: 'topic_documents_workspace_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2120,7 +2017,6 @@ table topic_shares {
|
||||
indexes {
|
||||
topic_id [name: 'topic_shares_topic_id_unique', unique]
|
||||
user_id [name: 'topic_shares_user_id_idx']
|
||||
workspace_id [name: 'topic_shares_workspace_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2170,7 +2066,24 @@ table topics {
|
||||
(user_id, completed_at) [name: 'topics_user_id_completed_at_idx']
|
||||
sender_id [name: 'topics_sender_id_idx']
|
||||
() [name: 'topics_extract_status_gin_idx']
|
||||
workspace_id [name: 'topics_workspace_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
table user_installed_plugins {
|
||||
user_id text [not null]
|
||||
workspace_id text
|
||||
identifier text [not null]
|
||||
type text [not null]
|
||||
manifest jsonb
|
||||
settings jsonb
|
||||
custom_params jsonb
|
||||
source varchar(255)
|
||||
accessed_at "timestamp with time zone" [not null, default: `now()`]
|
||||
created_at "timestamp with time zone" [not null, default: `now()`]
|
||||
updated_at "timestamp with time zone" [not null, default: `now()`]
|
||||
|
||||
indexes {
|
||||
(user_id, identifier) [pk]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2446,102 +2359,6 @@ table user_memory_persona_documents {
|
||||
}
|
||||
}
|
||||
|
||||
table verify_check_results {
|
||||
id uuid [pk, not null, default: `gen_random_uuid()`]
|
||||
operation_id text [not null]
|
||||
user_id text [not null]
|
||||
workspace_id text
|
||||
check_item_id text [not null]
|
||||
check_item_title text
|
||||
required boolean [not null, default: true]
|
||||
check_item_index integer
|
||||
verifier_type text [not null]
|
||||
verifier_config_hash text
|
||||
verifier_operation_id text
|
||||
verifier_tracing_id uuid
|
||||
status text [not null, default: 'pending']
|
||||
verdict text
|
||||
confidence "numeric(3, 2)"
|
||||
toulmin jsonb
|
||||
suggestion text
|
||||
user_decision text
|
||||
is_false_positive boolean
|
||||
is_false_negative boolean
|
||||
repair_operation_id text
|
||||
started_at "timestamp with time zone"
|
||||
completed_at "timestamp with time zone"
|
||||
created_at "timestamp with time zone" [not null, default: `now()`]
|
||||
|
||||
indexes {
|
||||
operation_id [name: 'verify_check_results_operation_id_idx']
|
||||
user_id [name: 'verify_check_results_user_id_idx']
|
||||
(operation_id, check_item_id) [name: 'verify_check_results_operation_id_check_item_id_unique', unique]
|
||||
verifier_type [name: 'verify_check_results_verifier_type_idx']
|
||||
verifier_operation_id [name: 'verify_check_results_verifier_operation_id_idx']
|
||||
verifier_tracing_id [name: 'verify_check_results_verifier_tracing_id_idx']
|
||||
status [name: 'verify_check_results_status_idx']
|
||||
verdict [name: 'verify_check_results_verdict_idx']
|
||||
repair_operation_id [name: 'verify_check_results_repair_operation_id_idx']
|
||||
workspace_id [name: 'verify_check_results_workspace_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
table verify_criteria {
|
||||
id uuid [pk, not null, default: `gen_random_uuid()`]
|
||||
user_id text [not null]
|
||||
title text [not null]
|
||||
description text
|
||||
required boolean [not null, default: true]
|
||||
verifier_type text [not null]
|
||||
verifier_config jsonb [default: `{}`]
|
||||
on_fail text [not null, default: 'manual']
|
||||
document_id varchar(255)
|
||||
workspace_id text
|
||||
accessed_at "timestamp with time zone" [not null, default: `now()`]
|
||||
created_at "timestamp with time zone" [not null, default: `now()`]
|
||||
updated_at "timestamp with time zone" [not null, default: `now()`]
|
||||
|
||||
indexes {
|
||||
user_id [name: 'verify_criteria_user_id_idx']
|
||||
verifier_type [name: 'verify_criteria_verifier_type_idx']
|
||||
document_id [name: 'verify_criteria_document_id_idx']
|
||||
workspace_id [name: 'verify_criteria_workspace_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
table verify_rubric_criteria {
|
||||
rubric_id uuid [not null]
|
||||
criterion_id uuid [not null]
|
||||
user_id text [not null]
|
||||
workspace_id text
|
||||
sort_order integer
|
||||
created_at "timestamp with time zone" [not null, default: `now()`]
|
||||
|
||||
indexes {
|
||||
(rubric_id, criterion_id) [pk]
|
||||
criterion_id [name: 'verify_rubric_criteria_criterion_id_idx']
|
||||
user_id [name: 'verify_rubric_criteria_user_id_idx']
|
||||
workspace_id [name: 'verify_rubric_criteria_workspace_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
table verify_rubrics {
|
||||
id uuid [pk, not null, default: `gen_random_uuid()`]
|
||||
user_id text [not null]
|
||||
title text [not null]
|
||||
description text
|
||||
config jsonb [default: `{}`]
|
||||
workspace_id text
|
||||
accessed_at "timestamp with time zone" [not null, default: `now()`]
|
||||
created_at "timestamp with time zone" [not null, default: `now()`]
|
||||
updated_at "timestamp with time zone" [not null, default: `now()`]
|
||||
|
||||
indexes {
|
||||
user_id [name: 'verify_rubrics_user_id_idx']
|
||||
workspace_id [name: 'verify_rubrics_workspace_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
table workspace_audit_logs {
|
||||
id text [pk, not null]
|
||||
workspace_id text [not null]
|
||||
|
||||
@@ -19,13 +19,11 @@ LobeHub provides some additional configuration options when deployed, which can
|
||||
|
||||
<Card href={'environment-variables/model-provider'} title={'Model Service Providers'} />
|
||||
|
||||
<Card href={'environment-variables/auth'} title={'Authentication'} />
|
||||
<Cards href={'environment-variables/auth'} title={'Authentication'} />
|
||||
|
||||
<Card href={'environment-variables/s3'} title={'S3 Storage Service'} />
|
||||
<Cards href={'environment-variables/s3'} title={'S3 Storage Service'} />
|
||||
|
||||
<Card href={'environment-variables/cloud-sandbox'} title={'Cloud Sandbox'} />
|
||||
|
||||
<Card href={'environment-variables/analytics'} title={'Data Analytics'} />
|
||||
<Cards href={'environment-variables/analytics'} title={'Data Analytics'} />
|
||||
</Cards>
|
||||
|
||||
## Building a Custom Image with Overridden `NEXT_PUBLIC` Variables
|
||||
|
||||
@@ -13,15 +13,13 @@ tags:
|
||||
LobeHub 在部署时提供了一些额外的配置项,你可以使用环境变量进行自定义设置。
|
||||
|
||||
<Cards>
|
||||
<Card href={'environment-variables/basic'} title={'基础环境变量'} />
|
||||
<Cards href={'environment-variables/basic'} title={'基础环境变量'} />
|
||||
|
||||
<Card href={'environment-variables/model-provider'} title={'模型服务商'} />
|
||||
<Cards href={'environment-variables/model-provider'} title={'模型服务商'} />
|
||||
|
||||
<Card href={'environment-variables/auth'} title={'身份验证'} />
|
||||
<Cards href={'environment-variables/auth'} title={'身份验证'} />
|
||||
|
||||
<Card href={'environment-variables/s3'} title={'S3 存储服务'} />
|
||||
<Cards href={'environment-variables/s3'} title={'S3 存储服务'} />
|
||||
|
||||
<Card href={'environment-variables/cloud-sandbox'} title={'云端沙箱'} />
|
||||
|
||||
<Card href={'environment-variables/analytics'} title={'数据统计'} />
|
||||
<Cards href={'environment-variables/analytics'} title={'数据统计'} />
|
||||
</Cards>
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
---
|
||||
title: Configuring Cloud Sandbox
|
||||
description: Configure the built-in Cloud Sandbox provider, including Market and self-hosted Onlyboxes.
|
||||
tags:
|
||||
- Cloud Sandbox
|
||||
- Onlyboxes
|
||||
- Self-hosting
|
||||
---
|
||||
|
||||
# Configuring Cloud Sandbox
|
||||
|
||||
Cloud Sandbox powers the built-in code execution, shell command, file operation, and file export tools. By default, LobeHub uses the Market sandbox. Self-hosted deployments can switch the same tool surface to an Onlyboxes-compatible sandbox provider.
|
||||
|
||||
## Core Environment Variables
|
||||
|
||||
### `SANDBOX_PROVIDER`
|
||||
|
||||
- Type: Optional
|
||||
- Description: Selects the server-side sandbox provider.
|
||||
- Default: `market`
|
||||
- Example: `onlyboxes`
|
||||
|
||||
Supported values:
|
||||
|
||||
- `market`: Use the existing Market sandbox.
|
||||
- `onlyboxes`: Use an Onlyboxes-compatible self-hosted sandbox console.
|
||||
|
||||
### `MARKET_BASE_URL`
|
||||
|
||||
- Type: Optional
|
||||
- Description: Base URL of the Market service. Leave it unset when using the official Market; set it only when connecting to a self-hosted or dedicated Market service.
|
||||
- Default: `https://market.lobehub.com`
|
||||
- Example: `https://market.example.com`
|
||||
|
||||
### `MARKET_TRUSTED_CLIENT_ID`
|
||||
|
||||
- Type: Optional
|
||||
- Description: Market Trusted Client ID used by the LobeHub server to call Market capabilities on behalf of the current user. It must be registered by the target Market service.
|
||||
- Default: -
|
||||
- Example: `lobechat-com`
|
||||
|
||||
### `MARKET_TRUSTED_CLIENT_SECRET`
|
||||
|
||||
- Type: Optional
|
||||
- Description: Shared secret for the Market Trusted Client. It must match the target Market service configuration.
|
||||
- Default: -
|
||||
- Example: `your-market-trusted-client-secret`
|
||||
|
||||
### `ONLYBOXES_BASE_URL`
|
||||
|
||||
- Type: Required when `SANDBOX_PROVIDER=onlyboxes`
|
||||
- Description: Base URL of the Onlyboxes console API. Do not include `/api/v1`.
|
||||
- Default: -
|
||||
- Example: `https://onlyboxes.example.com`
|
||||
|
||||
### `ONLYBOXES_JIT_SIGNING_KEY`
|
||||
|
||||
- Type: Required when `SANDBOX_PROVIDER=onlyboxes`
|
||||
- Description: HMAC signing key used to mint Onlyboxes MCP JIT bearer tokens. It must match the Onlyboxes console `CONSOLE_JIT_SIGNING_KEY`.
|
||||
- Default: -
|
||||
- Example: `onlyboxes-jit-signing-secret`
|
||||
|
||||
### `ONLYBOXES_JIT_ISSUER`
|
||||
|
||||
- Type: Optional
|
||||
- Description: Issuer used in Onlyboxes JIT token claims.
|
||||
- Default: `APP_URL`
|
||||
- Example: `https://lobehub.example.com`
|
||||
|
||||
### `ONLYBOXES_JIT_TTL_SEC`
|
||||
|
||||
- Type: Optional
|
||||
- Description: Lifetime of each Onlyboxes JIT token minted by LobeHub.
|
||||
- Default: `1800`
|
||||
- Example: `900`
|
||||
|
||||
### `ONLYBOXES_LEASE_TTL_SEC`
|
||||
|
||||
- Type: Optional
|
||||
- Description: Lease duration for persistent terminal sessions created by the Cloud Sandbox provider.
|
||||
- Default: `900`
|
||||
- Example: `3600`
|
||||
|
||||
## Market Configuration
|
||||
|
||||
By default, LobeHub uses the official Market sandbox and does not require extra sandbox configuration:
|
||||
|
||||
```bash
|
||||
# SANDBOX_PROVIDER=market
|
||||
```
|
||||
|
||||
To explicitly use Market, or to connect to a self-hosted or dedicated Market service, configure:
|
||||
|
||||
```bash
|
||||
SANDBOX_PROVIDER=market
|
||||
MARKET_BASE_URL=https://market.example.com
|
||||
```
|
||||
|
||||
If that Market service requires the LobeHub server to call sandbox, credential, or skill capabilities on behalf of the current user, also configure Trusted Client credentials:
|
||||
|
||||
```bash
|
||||
MARKET_TRUSTED_CLIENT_ID=lobechat-com
|
||||
MARKET_TRUSTED_CLIENT_SECRET=your-market-trusted-client-secret
|
||||
```
|
||||
|
||||
`MARKET_TRUSTED_CLIENT_ID` must be registered in the Market service's trusted client allowlist, and `MARKET_TRUSTED_CLIENT_SECRET` must match the shared secret configured on the Market service. Without Trusted Client credentials, Market capabilities that require authentication continue to use the existing user authorization flow.
|
||||
|
||||
## Onlyboxes Runtime Requirements
|
||||
|
||||
The configured Onlyboxes worker should expose `terminalExec` and `terminalResource`. LobeHub uses `terminalExec` as the compatibility layer for shell commands, code execution, and file operations, and uses `terminalResource` for file export through a pre-signed upload URL.
|
||||
|
||||
For feature parity with the Market sandbox, the terminal runtime image should include:
|
||||
|
||||
- `python3`, used by file operation wrappers and Python execution
|
||||
- `node`, used by JavaScript execution
|
||||
- `npx` with access to `tsx`, used by TypeScript execution
|
||||
- Standard shell utilities such as `base64`, `find`, and `grep`
|
||||
|
||||
Minimum configuration for using Onlyboxes:
|
||||
|
||||
```bash
|
||||
SANDBOX_PROVIDER=onlyboxes
|
||||
ONLYBOXES_BASE_URL=https://onlyboxes.example.com
|
||||
ONLYBOXES_JIT_SIGNING_KEY=onlyboxes-jit-signing-secret
|
||||
```
|
||||
|
||||
Set the same secret on the Onlyboxes console:
|
||||
|
||||
```bash
|
||||
CONSOLE_JIT_SIGNING_KEY=onlyboxes-jit-signing-secret
|
||||
```
|
||||
|
||||
<Callout type={'info'}>
|
||||
File export still writes the exported artifact to the configured LobeHub S3 storage. Configure the
|
||||
S3 environment variables when users need to download files generated inside the sandbox.
|
||||
</Callout>
|
||||
@@ -1,136 +0,0 @@
|
||||
---
|
||||
title: 配置云端沙箱
|
||||
description: 配置内置云端沙箱能力,包括 Market 和自托管 Onlyboxes。
|
||||
tags:
|
||||
- 云端沙箱
|
||||
- Onlyboxes
|
||||
- 自托管
|
||||
---
|
||||
|
||||
# 配置云端沙箱
|
||||
|
||||
云端沙箱用于内置的代码执行、Shell 命令、文件操作和文件导出工具。默认情况下,LobeHub 使用 Market 沙箱;自托管部署可以把同一套工具能力切换到兼容 Onlyboxes 的沙箱提供方。
|
||||
|
||||
## 核心环境变量
|
||||
|
||||
### `SANDBOX_PROVIDER`
|
||||
|
||||
- 类型:可选
|
||||
- 描述:选择服务端使用的沙箱提供方。
|
||||
- 默认值:`market`
|
||||
- 示例:`onlyboxes`
|
||||
|
||||
支持的取值:
|
||||
|
||||
- `market`:使用现有 Market 沙箱。
|
||||
- `onlyboxes`:使用兼容 Onlyboxes 的自托管沙箱 Console。
|
||||
|
||||
### `MARKET_BASE_URL`
|
||||
|
||||
- 类型:可选
|
||||
- 描述:Market 服务的基础 URL。使用官方 Market 时无需配置;仅当你需要连接自托管或专用 Market 服务时设置。
|
||||
- 默认值:`https://market.lobehub.com`
|
||||
- 示例:`https://market.example.com`
|
||||
|
||||
### `MARKET_TRUSTED_CLIENT_ID`
|
||||
|
||||
- 类型:可选
|
||||
- 描述:Market Trusted Client 的客户端 ID,用于让 LobeHub 服务端代表当前用户调用 Market 能力。需要由对应 Market 服务登记。
|
||||
- 默认值:-
|
||||
- 示例:`lobechat-com`
|
||||
|
||||
### `MARKET_TRUSTED_CLIENT_SECRET`
|
||||
|
||||
- 类型:可选
|
||||
- 描述:Market Trusted Client 的共享密钥,必须与对应 Market 服务端配置一致。
|
||||
- 默认值:-
|
||||
- 示例:`your-market-trusted-client-secret`
|
||||
|
||||
### `ONLYBOXES_BASE_URL`
|
||||
|
||||
- 类型:当 `SANDBOX_PROVIDER=onlyboxes` 时必填
|
||||
- 描述:Onlyboxes Console API 的基础 URL,不需要包含 `/api/v1`。
|
||||
- 默认值:-
|
||||
- 示例:`https://onlyboxes.example.com`
|
||||
|
||||
### `ONLYBOXES_JIT_SIGNING_KEY`
|
||||
|
||||
- 类型:当 `SANDBOX_PROVIDER=onlyboxes` 时必填
|
||||
- 描述:用于签发 Onlyboxes MCP JIT Bearer Token 的 HMAC 密钥,必须与 Onlyboxes Console 的 `CONSOLE_JIT_SIGNING_KEY` 一致。
|
||||
- 默认值:-
|
||||
- 示例:`onlyboxes-jit-signing-secret`
|
||||
|
||||
### `ONLYBOXES_JIT_ISSUER`
|
||||
|
||||
- 类型:可选
|
||||
- 描述:Onlyboxes JIT Token claims 中使用的签发方。
|
||||
- 默认值:`APP_URL`
|
||||
- 示例:`https://lobehub.example.com`
|
||||
|
||||
### `ONLYBOXES_JIT_TTL_SEC`
|
||||
|
||||
- 类型:可选
|
||||
- 描述:LobeHub 签发的每个 Onlyboxes JIT Token 的有效期。
|
||||
- 默认值:`1800`
|
||||
- 示例:`900`
|
||||
|
||||
### `ONLYBOXES_LEASE_TTL_SEC`
|
||||
|
||||
- 类型:可选
|
||||
- 描述:云端沙箱提供方创建持久终端会话时使用的租约时长。
|
||||
- 默认值:`900`
|
||||
- 示例:`3600`
|
||||
|
||||
## Market 配置
|
||||
|
||||
默认情况下,LobeHub 使用官方 Market 沙箱,不需要额外配置:
|
||||
|
||||
```bash
|
||||
# SANDBOX_PROVIDER=market
|
||||
```
|
||||
|
||||
如果你需要显式使用 Market,或者连接自托管 / 专用 Market 服务,可以配置:
|
||||
|
||||
```bash
|
||||
SANDBOX_PROVIDER=market
|
||||
MARKET_BASE_URL=https://market.example.com
|
||||
```
|
||||
|
||||
如果该 Market 服务要求 LobeHub 服务端代表当前用户调用沙箱、凭据或技能能力,还需要配置 Trusted Client:
|
||||
|
||||
```bash
|
||||
MARKET_TRUSTED_CLIENT_ID=lobechat-com
|
||||
MARKET_TRUSTED_CLIENT_SECRET=your-market-trusted-client-secret
|
||||
```
|
||||
|
||||
`MARKET_TRUSTED_CLIENT_ID` 需要在 Market 服务端的可信客户端白名单中,`MARKET_TRUSTED_CLIENT_SECRET` 需要与 Market 服务端共享密钥一致。未配置 Trusted Client 时,Market 侧需要认证的能力会继续使用现有的用户授权流程。
|
||||
|
||||
## Onlyboxes 运行时要求
|
||||
|
||||
配置的 Onlyboxes worker 需要暴露 `terminalExec` 和 `terminalResource`。LobeHub 使用 `terminalExec` 作为 Shell 命令、代码执行和文件操作的兼容层,并使用 `terminalResource` 通过预签名上传 URL 导出文件。
|
||||
|
||||
为了和 Market 沙箱保持能力对等,终端运行镜像应包含:
|
||||
|
||||
- `python3`,用于文件操作封装和 Python 执行
|
||||
- `node`,用于 JavaScript 执行
|
||||
- `npx` 以及可用的 `tsx`,用于 TypeScript 执行
|
||||
- `base64`、`find`、`grep` 等常见 Shell 工具
|
||||
|
||||
使用 Onlyboxes 时的最小配置:
|
||||
|
||||
```bash
|
||||
SANDBOX_PROVIDER=onlyboxes
|
||||
ONLYBOXES_BASE_URL=https://onlyboxes.example.com
|
||||
ONLYBOXES_JIT_SIGNING_KEY=onlyboxes-jit-signing-secret
|
||||
```
|
||||
|
||||
Onlyboxes Console 侧需要配置同一个密钥:
|
||||
|
||||
```bash
|
||||
CONSOLE_JIT_SIGNING_KEY=onlyboxes-jit-signing-secret
|
||||
```
|
||||
|
||||
<Callout type={'info'}>
|
||||
文件导出仍会把沙箱内产物写入 LobeHub 配置的 S3 存储。如果用户需要下载沙箱生成的文件,请同时配置 S3
|
||||
相关环境变量。
|
||||
</Callout>
|
||||
@@ -351,6 +351,7 @@
|
||||
"newCodexAgent": "أضف Codex",
|
||||
"newGroupChat": "إنشاء مجموعة",
|
||||
"newPage": "إنشاء صفحة",
|
||||
"newPlatformAgent": "إضافة وكيل منصة",
|
||||
"noAgentsYet": "لا يوجد أعضاء في هذه المجموعة بعد. انقر على زر + لدعوة وكلاء.",
|
||||
"noAvailableAgents": "لا يوجد أعضاء متاحون للدعوة",
|
||||
"noMatchingAgents": "لم يتم العثور على أعضاء مطابقين",
|
||||
@@ -376,6 +377,8 @@
|
||||
"platformAgent.create.checkFailed": "فشل التحقق",
|
||||
"platformAgent.create.checking": "جارٍ التحقق من التوفر...",
|
||||
"platformAgent.create.comingSoon": "قريبًا",
|
||||
"platformAgent.create.create": "إنشاء وكيل",
|
||||
"platformAgent.create.creating": "جارٍ الإنشاء...",
|
||||
"platformAgent.create.desc.amp": "الاتصال بـ Amp الذي يعمل على أحد أجهزتك",
|
||||
"platformAgent.create.desc.hermes": "الاتصال بـ Hermes الذي يعمل على أحد أجهزتك",
|
||||
"platformAgent.create.desc.openclaw": "الاتصال بـ OpenClaw الذي يعمل على أحد أجهزتك",
|
||||
@@ -392,8 +395,10 @@
|
||||
"platformAgent.create.notInstalled": "{{name}} غير مثبت على هذا الجهاز",
|
||||
"platformAgent.create.refresh": "تحديث",
|
||||
"platformAgent.create.selectDevice": "اختر جهازًا",
|
||||
"platformAgent.create.step1": "اختر المنصة",
|
||||
"platformAgent.create.step2": "اختر الجهاز",
|
||||
"platformAgent.create.step3": "تكوين الوكيل",
|
||||
"platformAgent.create.title": "إضافة وكيل منصة",
|
||||
"platformAgent.create.upgradeCmd": "npm install -g @lobehub/cli",
|
||||
"platformAgent.create.versionTooLow": "إصدار lh منخفض جدًا",
|
||||
"platformAgent.create.versionTooLowHint": "قم بتحديث lh إلى أحدث إصدار:",
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
"features.agentSelfIteration.title": "التكرار الذاتي للوكيل",
|
||||
"features.assistantMessageGroup.desc": "تجميع رسائل الوكيل ونتائج استدعاء الأدوات معًا للعرض",
|
||||
"features.assistantMessageGroup.title": "تجميع رسائل الوكيل",
|
||||
"features.executionDeviceSwitcher.desc": "إظهار مفتاح تبديل جهاز التنفيذ في شريط أدوات الوكيل المتنوع بحيث يمكنك توجيه العمليات إلى هذا الجهاز أو إلى سحابة تجريبية أو إلى جهاز بعيد مرتبط.",
|
||||
"features.executionDeviceSwitcher.title": "مفتاح تبديل جهاز التنفيذ",
|
||||
"features.gatewayMode.desc": "تنفيذ مهام الوكيل على الخادم عبر بوابة WebSocket بدلًا من التشغيل محليًا، مما يتيح تنفيذًا أسرع ويقلل من استهلاك موارد العميل.",
|
||||
"features.gatewayMode.title": "تنفيذ الوكيل من جانب الخادم (البوابة)",
|
||||
"features.groupChat.desc": "تمكين تنسيق الدردشة الجماعية متعددة الوكلاء.",
|
||||
@@ -13,5 +15,7 @@
|
||||
"features.imessage.title": "قناة iMessage",
|
||||
"features.inputMarkdown.desc": "عرض Markdown في منطقة الإدخال في الوقت الفعلي (نص عريض، كتل الشيفرة، جداول، إلخ).",
|
||||
"features.inputMarkdown.title": "عرض Markdown في الإدخال",
|
||||
"features.platformAgent.desc": "عرض خيار \"إضافة وكيل منصة\" في قائمة الإنشاء. يعمل وكلاء المنصة (مثل OpenClaw، Hermes) على جهاز متصل ويتواصلون عبر lh connect.",
|
||||
"features.platformAgent.title": "إنشاء وكيل منصة",
|
||||
"title": "المختبرات"
|
||||
}
|
||||
|
||||
@@ -491,8 +491,10 @@
|
||||
"platformAgentConfig.device.none": "لا شيء",
|
||||
"platformAgentConfig.device.offline": "غير متصل",
|
||||
"platformAgentConfig.device.online": "متصل",
|
||||
"platformAgentConfig.platform.label": "المنصة",
|
||||
"platformAgentConfig.redetect": "إعادة الكشف",
|
||||
"platformAgentConfig.selectDevice": "اختر جهازًا",
|
||||
"platformAgentConfig.title": "إعدادات المنصة",
|
||||
"plugin.addMCPPlugin": "إضافة MCP",
|
||||
"plugin.addTooltip": "مهارات مخصصة",
|
||||
"plugin.clearDeprecated": "إزالة المهارات الموقوفة",
|
||||
|
||||
@@ -351,6 +351,7 @@
|
||||
"newCodexAgent": "Добави Codex",
|
||||
"newGroupChat": "Създай група",
|
||||
"newPage": "Създай страница",
|
||||
"newPlatformAgent": "Добавяне на платформен агент",
|
||||
"noAgentsYet": "Тази група все още няма членове. Натиснете бутона +, за да поканите агенти.",
|
||||
"noAvailableAgents": "Няма налични членове за покана",
|
||||
"noMatchingAgents": "Няма съвпадащи членове",
|
||||
@@ -376,6 +377,8 @@
|
||||
"platformAgent.create.checkFailed": "Проверката не успя",
|
||||
"platformAgent.create.checking": "Проверка на наличността...",
|
||||
"platformAgent.create.comingSoon": "Очаквайте скоро",
|
||||
"platformAgent.create.create": "Създаване на агент",
|
||||
"platformAgent.create.creating": "Създаване...",
|
||||
"platformAgent.create.desc.amp": "Свържете се с Amp, работещ на едно от вашите устройства",
|
||||
"platformAgent.create.desc.hermes": "Свържете се с Hermes, работещ на едно от вашите устройства",
|
||||
"platformAgent.create.desc.openclaw": "Свържете се с OpenClaw, работещ на едно от вашите устройства",
|
||||
@@ -392,8 +395,10 @@
|
||||
"platformAgent.create.notInstalled": "{{name}} не е инсталиран на това устройство",
|
||||
"platformAgent.create.refresh": "Обнови",
|
||||
"platformAgent.create.selectDevice": "Изберете устройство",
|
||||
"platformAgent.create.step1": "Изберете платформа",
|
||||
"platformAgent.create.step2": "Изберете устройство",
|
||||
"platformAgent.create.step3": "Конфигурирайте агент",
|
||||
"platformAgent.create.title": "Добавяне на платформен агент",
|
||||
"platformAgent.create.upgradeCmd": "npm install -g @lobehub/cli",
|
||||
"platformAgent.create.versionTooLow": "Версията на lh е твърде ниска",
|
||||
"platformAgent.create.versionTooLowHint": "Актуализирайте lh до последната версия:",
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
"features.agentSelfIteration.title": "Само-итерация на агента",
|
||||
"features.assistantMessageGroup.desc": "Групиране на съобщенията от агента и резултатите от извикванията на инструменти заедно за показване",
|
||||
"features.assistantMessageGroup.title": "Групиране на съобщения от агент",
|
||||
"features.executionDeviceSwitcher.desc": "Показване на превключвателя за изпълнителни устройства в хетерогенната лента с инструменти на агента, за да можете да насочвате изпълненията към това устройство, облачен пясъчник или свързано отдалечено устройство.",
|
||||
"features.executionDeviceSwitcher.title": "Превключвател за изпълнителни устройства",
|
||||
"features.gatewayMode.desc": "Изпълнявайте задачите на агента на сървъра чрез Gateway WebSocket вместо локално. Осигурява по-бързо изпълнение и намалява използването на ресурси от клиента.",
|
||||
"features.gatewayMode.title": "Изпълнение на агента от страна на сървъра (Gateway)",
|
||||
"features.groupChat.desc": "Активиране на координация в групов чат с множество агенти.",
|
||||
@@ -13,5 +15,7 @@
|
||||
"features.imessage.title": "Канал iMessage",
|
||||
"features.inputMarkdown.desc": "Реално време визуализация на Markdown в полето за въвеждане (удебелен текст, кодови блокове, таблици и др.).",
|
||||
"features.inputMarkdown.title": "Визуализация на Markdown при въвеждане",
|
||||
"features.platformAgent.desc": "Показване на записа \"Добавяне на платформен агент\" в менюто за създаване. Платформените агенти (например OpenClaw, Hermes) работят на свързано устройство и комуникират обратно чрез lh connect.",
|
||||
"features.platformAgent.title": "Създаване на платформен агент",
|
||||
"title": "Лаборатория"
|
||||
}
|
||||
|
||||
@@ -491,8 +491,10 @@
|
||||
"platformAgentConfig.device.none": "Няма",
|
||||
"platformAgentConfig.device.offline": "Офлайн",
|
||||
"platformAgentConfig.device.online": "Онлайн",
|
||||
"platformAgentConfig.platform.label": "Платформа",
|
||||
"platformAgentConfig.redetect": "Повторно откриване",
|
||||
"platformAgentConfig.selectDevice": "Изберете устройство",
|
||||
"platformAgentConfig.title": "Конфигурация на платформата",
|
||||
"plugin.addMCPPlugin": "Добавяне на MCP",
|
||||
"plugin.addTooltip": "Персонализирани умения",
|
||||
"plugin.clearDeprecated": "Премахване на остарели умения",
|
||||
|
||||
@@ -351,6 +351,7 @@
|
||||
"newCodexAgent": "Codex hinzufügen",
|
||||
"newGroupChat": "Gruppe erstellen",
|
||||
"newPage": "Seite erstellen",
|
||||
"newPlatformAgent": "Plattform-Agent hinzufügen",
|
||||
"noAgentsYet": "Diese Gruppe hat noch keine Mitglieder. Klicke auf +, um Agenten einzuladen.",
|
||||
"noAvailableAgents": "Keine Mitglieder zum Einladen verfügbar",
|
||||
"noMatchingAgents": "Keine passenden Mitglieder gefunden",
|
||||
@@ -376,6 +377,8 @@
|
||||
"platformAgent.create.checkFailed": "Überprüfung fehlgeschlagen",
|
||||
"platformAgent.create.checking": "Verfügbarkeit wird überprüft...",
|
||||
"platformAgent.create.comingSoon": "Demnächst verfügbar",
|
||||
"platformAgent.create.create": "Agent erstellen",
|
||||
"platformAgent.create.creating": "Wird erstellt...",
|
||||
"platformAgent.create.desc.amp": "Mit Amp verbinden, das auf einem Ihrer Geräte läuft",
|
||||
"platformAgent.create.desc.hermes": "Mit Hermes verbinden, das auf einem Ihrer Geräte läuft",
|
||||
"platformAgent.create.desc.openclaw": "Mit OpenClaw verbinden, das auf einem Ihrer Geräte läuft",
|
||||
@@ -392,8 +395,10 @@
|
||||
"platformAgent.create.notInstalled": "{{name}} ist auf diesem Gerät nicht installiert",
|
||||
"platformAgent.create.refresh": "Aktualisieren",
|
||||
"platformAgent.create.selectDevice": "Gerät auswählen",
|
||||
"platformAgent.create.step1": "Plattform auswählen",
|
||||
"platformAgent.create.step2": "Gerät auswählen",
|
||||
"platformAgent.create.step3": "Agent konfigurieren",
|
||||
"platformAgent.create.title": "Plattform-Agent hinzufügen",
|
||||
"platformAgent.create.upgradeCmd": "npm install -g @lobehub/cli",
|
||||
"platformAgent.create.versionTooLow": "lh-Version ist zu niedrig",
|
||||
"platformAgent.create.versionTooLowHint": "Aktualisieren Sie lh auf die neueste Version:",
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
"features.agentSelfIteration.title": "Agenten-Selbstiteration",
|
||||
"features.assistantMessageGroup.desc": "Agenten-Nachrichten und deren Tool-Ergebnisse gemeinsam anzeigen",
|
||||
"features.assistantMessageGroup.title": "Agenten-Nachrichten gruppieren",
|
||||
"features.executionDeviceSwitcher.desc": "Zeigen Sie den Ausführungsgeräte-Umschalter in der heterogenen Agenten-Toolbar an, damit Sie Läufe auf dieses Gerät, eine Cloud-Sandbox oder ein verbundenes Remote-Gerät leiten können.",
|
||||
"features.executionDeviceSwitcher.title": "Ausführungsgeräte-Umschalter",
|
||||
"features.gatewayMode.desc": "Führt Agentenaufgaben über die Gateway-WebSocket-Verbindung auf dem Server aus, statt sie lokal auszuführen. Ermöglicht eine schnellere Ausführung und verringert die Client-Ressourcennutzung.",
|
||||
"features.gatewayMode.title": "Serverseitige Agentenausführung (Gateway)",
|
||||
"features.groupChat.desc": "Koordination von Gruppenchats mit mehreren Agenten aktivieren.",
|
||||
@@ -13,5 +15,7 @@
|
||||
"features.imessage.title": "iMessage-Kanal",
|
||||
"features.inputMarkdown.desc": "Markdown in Echtzeit im Eingabebereich rendern (fetter Text, Codeblöcke, Tabellen usw.).",
|
||||
"features.inputMarkdown.title": "Markdown-Darstellung im Eingabefeld",
|
||||
"features.platformAgent.desc": "Zeigen Sie den Eintrag \"Plattform-Agent hinzufügen\" im Erstellungsmenü an. Plattform-Agenten (z. B. OpenClaw, Hermes) laufen auf einem verbundenen Gerät und kommunizieren über lh connect zurück.",
|
||||
"features.platformAgent.title": "Erstellung von Plattform-Agenten",
|
||||
"title": "Labs"
|
||||
}
|
||||
|
||||
@@ -491,8 +491,10 @@
|
||||
"platformAgentConfig.device.none": "Keines",
|
||||
"platformAgentConfig.device.offline": "Offline",
|
||||
"platformAgentConfig.device.online": "Online",
|
||||
"platformAgentConfig.platform.label": "Plattform",
|
||||
"platformAgentConfig.redetect": "Erneut erkennen",
|
||||
"platformAgentConfig.selectDevice": "Gerät auswählen",
|
||||
"platformAgentConfig.title": "Plattformkonfiguration",
|
||||
"plugin.addMCPPlugin": "MCP hinzufügen",
|
||||
"plugin.addTooltip": "Benutzerdefinierte Fähigkeiten",
|
||||
"plugin.clearDeprecated": "Veraltete Fähigkeiten entfernen",
|
||||
|
||||
@@ -105,7 +105,6 @@
|
||||
"betterAuth.signin.socialOnlyHint": "This email was registered via a third-party social account. Sign in with that provider, or",
|
||||
"betterAuth.signin.ssoOnlyNoProviders": "Email registration is disabled and no SSO providers are configured. Please contact your administrator.",
|
||||
"betterAuth.signin.submit": "Sign In",
|
||||
"betterAuth.signup.cardTitle": "Create a {{appName}} Account",
|
||||
"betterAuth.signup.confirmPasswordPlaceholder": "Confirm your password",
|
||||
"betterAuth.signup.emailPlaceholder": "Enter your email address",
|
||||
"betterAuth.signup.error": "Sign up failed, please try again",
|
||||
|
||||
+9
-21
@@ -209,18 +209,14 @@
|
||||
"heteroAgent.cloudRepo.notSet": "No repo selected",
|
||||
"heteroAgent.cloudRepo.sectionTitle": "Repositories",
|
||||
"heteroAgent.executionTarget.downloadDesktop": "Get Desktop App",
|
||||
"heteroAgent.executionTarget.downloadDesktopDesc": "Run agents with access to your computer",
|
||||
"heteroAgent.executionTarget.downloadDesktopTitle": "Get the desktop app",
|
||||
"heteroAgent.executionTarget.infoTooltip": "Pick a device and the agent uses it as its runtime environment — reading and writing files and operating the computer. Cloud sandbox is provided by LobeHub Marketplace.",
|
||||
"heteroAgent.executionTarget.infoTooltip": "Pick a remote device to drive that machine from the web. \"This device\" runs the agent locally and is only available inside the desktop app.",
|
||||
"heteroAgent.executionTarget.loading": "Loading devices…",
|
||||
"heteroAgent.executionTarget.local": "This device",
|
||||
"heteroAgent.executionTarget.localDesc": "Run as a local process on this desktop app",
|
||||
"heteroAgent.executionTarget.noDevices": "No remote devices yet. Run `lh connect` on another machine to add one.",
|
||||
"heteroAgent.executionTarget.none": "No device",
|
||||
"heteroAgent.executionTarget.noneDesc": "No device enabled",
|
||||
"heteroAgent.executionTarget.noDevices": "No remote devices yet. Install the desktop app or run `lh connect` on another machine.",
|
||||
"heteroAgent.executionTarget.offline": "Offline",
|
||||
"heteroAgent.executionTarget.online": "Online",
|
||||
"heteroAgent.executionTarget.sandbox": "Cloud Sandbox",
|
||||
"heteroAgent.executionTarget.sandbox": "Cloud sandbox",
|
||||
"heteroAgent.executionTarget.sandboxDesc": "Run in an ephemeral cloud sandbox",
|
||||
"heteroAgent.executionTarget.title": "Execution Device",
|
||||
"heteroAgent.executionTarget.unknownDevice": "Unknown device",
|
||||
@@ -356,7 +352,7 @@
|
||||
"newCodexAgent": "Add Codex",
|
||||
"newGroupChat": "Create Group",
|
||||
"newPage": "Create Page",
|
||||
"newPlatformAgent": "Connect Agent",
|
||||
"newPlatformAgent": "Add Platform Agent",
|
||||
"noAgentsYet": "This group has no members yet. Click the + button to invite agents.",
|
||||
"noAvailableAgents": "No members available to invite",
|
||||
"noMatchingAgents": "No matching members found",
|
||||
@@ -382,8 +378,8 @@
|
||||
"platformAgent.create.checkFailed": "Check failed",
|
||||
"platformAgent.create.checking": "Checking availability...",
|
||||
"platformAgent.create.comingSoon": "Coming Soon",
|
||||
"platformAgent.create.create": "Connect",
|
||||
"platformAgent.create.creating": "Connecting...",
|
||||
"platformAgent.create.create": "Create Agent",
|
||||
"platformAgent.create.creating": "Creating...",
|
||||
"platformAgent.create.desc.amp": "Connect to Amp running on one of your devices",
|
||||
"platformAgent.create.desc.hermes": "Connect to Hermes running on one of your devices",
|
||||
"platformAgent.create.desc.openclaw": "Connect to OpenClaw running on one of your devices",
|
||||
@@ -400,10 +396,10 @@
|
||||
"platformAgent.create.notInstalled": "{{name}} not installed on this device",
|
||||
"platformAgent.create.refresh": "Refresh",
|
||||
"platformAgent.create.selectDevice": "Select a device",
|
||||
"platformAgent.create.step1": "Select Agent",
|
||||
"platformAgent.create.step1": "Select Platform",
|
||||
"platformAgent.create.step2": "Select Device",
|
||||
"platformAgent.create.step3": "Configure Agent",
|
||||
"platformAgent.create.title": "Connect Agent",
|
||||
"platformAgent.create.title": "Add Platform Agent",
|
||||
"platformAgent.create.upgradeCmd": "npm install -g @lobehub/cli",
|
||||
"platformAgent.create.versionTooLow": "lh version is too low",
|
||||
"platformAgent.create.versionTooLowHint": "Update lh to the latest version:",
|
||||
@@ -604,6 +600,7 @@
|
||||
"taskDetail.comment.edit": "Edit",
|
||||
"taskDetail.comment.save": "Save",
|
||||
"taskDetail.commentPlaceholder": "Leave feedback to guide the agent — your comments shape the next run...",
|
||||
"taskDetail.commentSubmitAndRun": "Send & run now",
|
||||
"taskDetail.deleteConfirm.content": "This action cannot be undone.",
|
||||
"taskDetail.deleteConfirm.ok": "Delete",
|
||||
"taskDetail.deleteConfirm.title": "Delete this task?",
|
||||
@@ -630,8 +627,6 @@
|
||||
"taskDetail.priority.urgent": "Urgent",
|
||||
"taskDetail.properties": "Properties",
|
||||
"taskDetail.reassignDisabled": "Cannot reassign agent while task is running",
|
||||
"taskDetail.replyInThread": "Reply in this thread",
|
||||
"taskDetail.replyPlaceholder": "Reply in this thread...",
|
||||
"taskDetail.rerunTask": "Re-run task",
|
||||
"taskDetail.runAll": "Run all",
|
||||
"taskDetail.runAll.cancel": "Cancel",
|
||||
@@ -652,7 +647,6 @@
|
||||
"taskDetail.runNow": "Run now",
|
||||
"taskDetail.runTask": "Run",
|
||||
"taskDetail.saveModelConfig": "Save",
|
||||
"taskDetail.sendFollowUp": "Send follow up message",
|
||||
"taskDetail.status.backlog": "Backlog",
|
||||
"taskDetail.status.canceled": "Canceled",
|
||||
"taskDetail.status.completed": "Completed",
|
||||
@@ -775,12 +769,6 @@
|
||||
"thread.closeSubagentThread": "Hide Detail",
|
||||
"thread.divider": "Subtopic",
|
||||
"thread.openSubagentThread": "View Detail",
|
||||
"thread.subagentMetrics.modelLabel": "Model",
|
||||
"thread.subagentMetrics.tokens": "{{count}} tokens",
|
||||
"thread.subagentMetrics.toolCalls_one": "{{count}} tool call",
|
||||
"thread.subagentMetrics.toolCalls_other": "{{count}} tool calls",
|
||||
"thread.subagentMetrics.toolsShort_one": "{{count}} tool",
|
||||
"thread.subagentMetrics.toolsShort_other": "{{count}} tools",
|
||||
"thread.subagentReadOnlyHint": "SubAgent conversations are read-only — execution is driven by the parent agent.",
|
||||
"thread.threadMessageCount": "{{messageCount}} messages",
|
||||
"thread.title": "Subtopic",
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
{
|
||||
"workingDirectory.addFolder": "Add folder…",
|
||||
"workingDirectory.addFolderDesc": "Enter an absolute path on the target device, e.g. /Users/name/projects",
|
||||
"workingDirectory.addFolderTitle": "Add working directory",
|
||||
"workingDirectory.agentDescription": "Default working directory for all conversations with this Agent",
|
||||
"workingDirectory.agentLevel": "Agent Working Directory",
|
||||
"workingDirectory.branchSearchPlaceholder": "Search branches",
|
||||
"workingDirectory.branchesEmpty": "No local branches",
|
||||
"workingDirectory.branchesHeading": "Branches",
|
||||
"workingDirectory.branchesLoading": "Loading branches…",
|
||||
"workingDirectory.branchesNoMatch": "No matching branches",
|
||||
"workingDirectory.cancel": "Cancel",
|
||||
"workingDirectory.checkoutAction": "Checkout",
|
||||
"workingDirectory.checkoutFailed": "Checkout failed",
|
||||
"workingDirectory.chooseDifferentFolder": "Choose a folder...",
|
||||
"workingDirectory.clear": "Clear",
|
||||
"workingDirectory.createBranchAction": "Checkout new branch…",
|
||||
"workingDirectory.createBranchTitle": "Create new branch",
|
||||
"workingDirectory.current": "Current working directory",
|
||||
"workingDirectory.detachedHead": "Detached HEAD at {{sha}}",
|
||||
"workingDirectory.diffStatTooltip": "Added {{added}} · Modified {{modified}} · Deleted {{deleted}}",
|
||||
"workingDirectory.filesAdded": "Added",
|
||||
"workingDirectory.filesDeleted": "Deleted",
|
||||
"workingDirectory.filesEmpty": "No uncommitted changes",
|
||||
"workingDirectory.filesLoading": "Loading changes…",
|
||||
"workingDirectory.filesModified": "Modified",
|
||||
"workingDirectory.ghMissing": "Install and log in to the GitHub CLI (`gh`) to see linked pull requests",
|
||||
"workingDirectory.newBranchPlaceholder": "feature/new-branch-name",
|
||||
"workingDirectory.noRecent": "No recent directories",
|
||||
"workingDirectory.notSet": "Click to set working directory",
|
||||
"workingDirectory.pathNotDirectory": "This path is not a directory",
|
||||
"workingDirectory.pathNotExist": "This path doesn't exist on the device",
|
||||
"workingDirectory.placeholder": "Enter directory path, e.g. /Users/name/projects",
|
||||
"workingDirectory.prTooltipWithExtra": "{{title}} (+{{count}} more open PR on this branch)",
|
||||
"workingDirectory.pullAction": "Click to pull {{count}} commit(s) from {{upstream}}",
|
||||
"workingDirectory.pullFailed": "Pull failed",
|
||||
"workingDirectory.pullInProgress": "Pulling…",
|
||||
"workingDirectory.pullNoop": "Already up to date",
|
||||
"workingDirectory.pullSuccess": "Pulled successfully",
|
||||
"workingDirectory.pushAction": "Click to push {{count}} commit(s) to {{target}}",
|
||||
"workingDirectory.pushActionNew": "Click to create branch {{target}}",
|
||||
"workingDirectory.pushFailed": "Push failed",
|
||||
"workingDirectory.pushInProgress": "Pushing…",
|
||||
"workingDirectory.pushNoop": "Everything up-to-date",
|
||||
"workingDirectory.pushSuccess": "Pushed successfully",
|
||||
"workingDirectory.recent": "Recent",
|
||||
"workingDirectory.refreshGitStatus": "Refresh branch & PR status",
|
||||
"workingDirectory.removeRecent": "Remove from recent",
|
||||
"workingDirectory.selectFolder": "Select folder",
|
||||
"workingDirectory.title": "Working Directory",
|
||||
"workingDirectory.topicDescription": "Override Agent default for this conversation only",
|
||||
"workingDirectory.topicLevel": "Conversation override",
|
||||
"workingDirectory.topicOverride": "Override for this conversation",
|
||||
"workingDirectory.uncommittedChanges_one": "Uncommitted changes: {{count}} file",
|
||||
"workingDirectory.uncommittedChanges_other": "Uncommitted changes: {{count}} files"
|
||||
}
|
||||
@@ -5,6 +5,8 @@
|
||||
"features.agentSelfIteration.title": "Agent Self-iteration",
|
||||
"features.assistantMessageGroup.desc": "Group agent messages and their tool call results together for display",
|
||||
"features.assistantMessageGroup.title": "Agent Message Grouping",
|
||||
"features.executionDeviceSwitcher.desc": "Surface the execution-device switcher in the heterogeneous agent toolbar so you can route runs to this device, a cloud sandbox, or a bound remote device.",
|
||||
"features.executionDeviceSwitcher.title": "Execution Device Switcher",
|
||||
"features.gatewayMode.desc": "Execute agent tasks on the server via Gateway WebSocket instead of running locally. Enables faster execution and reduces client resource usage.",
|
||||
"features.gatewayMode.title": "Server-Side Agent Execution (Gateway)",
|
||||
"features.groupChat.desc": "Enable multi-agent group chat coordination.",
|
||||
@@ -13,7 +15,7 @@
|
||||
"features.imessage.title": "iMessage Channel",
|
||||
"features.inputMarkdown.desc": "Render Markdown in the input area in real time (bold text, code blocks, tables, etc.).",
|
||||
"features.inputMarkdown.title": "Input Markdown Rendering",
|
||||
"features.platformAgent.desc": "Show the \"Connect Agent\" entry in the create menu. Connected agents (e.g. OpenClaw, Hermes) run on your own devices and communicate back via lh connect.",
|
||||
"features.platformAgent.title": "Connect Agent",
|
||||
"features.platformAgent.desc": "Show the \"Add Platform Agent\" entry in the create menu. Platform agents (e.g. OpenClaw, Hermes) run on a connected device and communicate back via lh connect.",
|
||||
"features.platformAgent.title": "Platform Agent Creation",
|
||||
"title": "Labs"
|
||||
}
|
||||
|
||||
@@ -5,12 +5,6 @@
|
||||
"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",
|
||||
|
||||
+48
-25
@@ -130,31 +130,6 @@
|
||||
"builtins.lobe-cloud-sandbox.apiName.writeLocalFile": "Write file",
|
||||
"builtins.lobe-cloud-sandbox.inspector.noResults": "No results",
|
||||
"builtins.lobe-cloud-sandbox.title": "Cloud Sandbox",
|
||||
"builtins.lobe-delivery-checker.apiName.generateVerifyPlan": "Create automated checks",
|
||||
"builtins.lobe-delivery-checker.verifyPlan.optional": "Optional",
|
||||
"builtins.lobe-delivery-checker.verifyPlan.portal.fields.description": "Summary",
|
||||
"builtins.lobe-delivery-checker.verifyPlan.portal.fields.instruction": "Judging rubric",
|
||||
"builtins.lobe-delivery-checker.verifyPlan.portal.fields.title": "Check title",
|
||||
"builtins.lobe-delivery-checker.verifyPlan.portal.onFail.auto_repair": "Auto repair",
|
||||
"builtins.lobe-delivery-checker.verifyPlan.portal.onFail.auto_repairDesc": "On failure, automatically start a repair round and re-run the check.",
|
||||
"builtins.lobe-delivery-checker.verifyPlan.portal.onFail.manual": "Handle manually",
|
||||
"builtins.lobe-delivery-checker.verifyPlan.portal.onFail.manualDesc": "On failure, stop and leave the next step to you.",
|
||||
"builtins.lobe-delivery-checker.verifyPlan.portal.onFail.title": "On failure",
|
||||
"builtins.lobe-delivery-checker.verifyPlan.portal.required.desc": "When on, a failure on this check blocks the run from being delivered.",
|
||||
"builtins.lobe-delivery-checker.verifyPlan.portal.required.title": "Required",
|
||||
"builtins.lobe-delivery-checker.verifyPlan.portal.rubric.maxRepairRounds.desc": "How many times a failing run is automatically re-run with the failure feedback before it stops. Set to 0 to disable auto-repair.",
|
||||
"builtins.lobe-delivery-checker.verifyPlan.portal.rubric.maxRepairRounds.title": "Max repair rounds",
|
||||
"builtins.lobe-delivery-checker.verifyPlan.portal.rubric.name": "Standard name",
|
||||
"builtins.lobe-delivery-checker.verifyPlan.portal.rubric.title": "Standard settings",
|
||||
"builtins.lobe-delivery-checker.verifyPlan.portal.title": "Check configuration",
|
||||
"builtins.lobe-delivery-checker.verifyPlan.portal.verifier.agent.desc": "A dedicated agent reads the trace, files, diff and PR before judging.",
|
||||
"builtins.lobe-delivery-checker.verifyPlan.portal.verifier.agent.title": "Agent check",
|
||||
"builtins.lobe-delivery-checker.verifyPlan.portal.verifier.llm.desc": "Judge against the text result and the run context.",
|
||||
"builtins.lobe-delivery-checker.verifyPlan.portal.verifier.llm.title": "LLM judgment",
|
||||
"builtins.lobe-delivery-checker.verifyPlan.portal.verifier.program.desc": "Validate via commands, APIs or status results. Good for tests, type-check, PR existence.",
|
||||
"builtins.lobe-delivery-checker.verifyPlan.portal.verifier.program.title": "Program check",
|
||||
"builtins.lobe-delivery-checker.verifyPlan.portal.verifier.title": "Verification method",
|
||||
"builtins.lobe-delivery-checker.verifyPlan.required": "Required",
|
||||
"builtins.lobe-group-agent-builder.apiName.batchCreateAgents": "Batch create agents",
|
||||
"builtins.lobe-group-agent-builder.apiName.createAgent": "Create agent",
|
||||
"builtins.lobe-group-agent-builder.apiName.createGroup": "Create group",
|
||||
@@ -552,6 +527,54 @@
|
||||
"list.item.local.title": "Custom",
|
||||
"loading.content": "Calling Skill…",
|
||||
"loading.plugin": "Skill running…",
|
||||
"localSystem.workingDirectory.agentDescription": "Default working directory for all conversations with this Agent",
|
||||
"localSystem.workingDirectory.agentLevel": "Agent Working Directory",
|
||||
"localSystem.workingDirectory.branchSearchPlaceholder": "Search branches",
|
||||
"localSystem.workingDirectory.branchesEmpty": "No local branches",
|
||||
"localSystem.workingDirectory.branchesHeading": "Branches",
|
||||
"localSystem.workingDirectory.branchesLoading": "Loading branches…",
|
||||
"localSystem.workingDirectory.branchesNoMatch": "No matching branches",
|
||||
"localSystem.workingDirectory.cancel": "Cancel",
|
||||
"localSystem.workingDirectory.checkoutAction": "Checkout",
|
||||
"localSystem.workingDirectory.checkoutFailed": "Checkout failed",
|
||||
"localSystem.workingDirectory.chooseDifferentFolder": "Choose a folder...",
|
||||
"localSystem.workingDirectory.clear": "Clear",
|
||||
"localSystem.workingDirectory.createBranchAction": "Checkout new branch…",
|
||||
"localSystem.workingDirectory.current": "Current working directory",
|
||||
"localSystem.workingDirectory.detachedHead": "Detached HEAD at {{sha}}",
|
||||
"localSystem.workingDirectory.diffStatTooltip": "Added {{added}} · Modified {{modified}} · Deleted {{deleted}}",
|
||||
"localSystem.workingDirectory.filesAdded": "Added",
|
||||
"localSystem.workingDirectory.filesDeleted": "Deleted",
|
||||
"localSystem.workingDirectory.filesEmpty": "No uncommitted changes",
|
||||
"localSystem.workingDirectory.filesLoading": "Loading changes…",
|
||||
"localSystem.workingDirectory.filesModified": "Modified",
|
||||
"localSystem.workingDirectory.ghMissing": "Install and log in to the GitHub CLI (`gh`) to see linked pull requests",
|
||||
"localSystem.workingDirectory.newBranchPlaceholder": "feature/new-branch-name",
|
||||
"localSystem.workingDirectory.noRecent": "No recent directories",
|
||||
"localSystem.workingDirectory.notSet": "Click to set working directory",
|
||||
"localSystem.workingDirectory.placeholder": "Enter directory path, e.g. /Users/name/projects",
|
||||
"localSystem.workingDirectory.prTooltipWithExtra": "{{title}} (+{{count}} more open PR on this branch)",
|
||||
"localSystem.workingDirectory.pullAction": "Click to pull {{count}} commit(s) from {{upstream}}",
|
||||
"localSystem.workingDirectory.pullFailed": "Pull failed",
|
||||
"localSystem.workingDirectory.pullInProgress": "Pulling…",
|
||||
"localSystem.workingDirectory.pullNoop": "Already up to date",
|
||||
"localSystem.workingDirectory.pullSuccess": "Pulled successfully",
|
||||
"localSystem.workingDirectory.pushAction": "Click to push {{count}} commit(s) to {{target}}",
|
||||
"localSystem.workingDirectory.pushActionNew": "Click to create branch {{target}}",
|
||||
"localSystem.workingDirectory.pushFailed": "Push failed",
|
||||
"localSystem.workingDirectory.pushInProgress": "Pushing…",
|
||||
"localSystem.workingDirectory.pushNoop": "Everything up-to-date",
|
||||
"localSystem.workingDirectory.pushSuccess": "Pushed successfully",
|
||||
"localSystem.workingDirectory.recent": "Recent",
|
||||
"localSystem.workingDirectory.refreshGitStatus": "Refresh branch & PR status",
|
||||
"localSystem.workingDirectory.removeRecent": "Remove from recent",
|
||||
"localSystem.workingDirectory.selectFolder": "Select folder",
|
||||
"localSystem.workingDirectory.title": "Working Directory",
|
||||
"localSystem.workingDirectory.topicDescription": "Override Agent default for this conversation only",
|
||||
"localSystem.workingDirectory.topicLevel": "Conversation override",
|
||||
"localSystem.workingDirectory.topicOverride": "Override for this conversation",
|
||||
"localSystem.workingDirectory.uncommittedChanges_one": "Uncommitted changes: {{count}} file",
|
||||
"localSystem.workingDirectory.uncommittedChanges_other": "Uncommitted changes: {{count}} files",
|
||||
"mcpEmpty.deployment": "No deployment options",
|
||||
"mcpEmpty.prompts": "No prompts",
|
||||
"mcpEmpty.resources": "No resources",
|
||||
|
||||
@@ -519,10 +519,10 @@
|
||||
"platformAgentConfig.device.none": "None",
|
||||
"platformAgentConfig.device.offline": "Offline",
|
||||
"platformAgentConfig.device.online": "Online",
|
||||
"platformAgentConfig.platform.label": "Connected to",
|
||||
"platformAgentConfig.platform.label": "Platform",
|
||||
"platformAgentConfig.redetect": "Re-detect",
|
||||
"platformAgentConfig.selectDevice": "Select a device",
|
||||
"platformAgentConfig.title": "Connection",
|
||||
"platformAgentConfig.title": "Platform Configuration",
|
||||
"plugin.addMCPPlugin": "Add MCP",
|
||||
"plugin.addTooltip": "Custom Skills",
|
||||
"plugin.clearDeprecated": "Remove Deprecated Skills",
|
||||
@@ -1014,11 +1014,9 @@
|
||||
"tab.usage": "Usage",
|
||||
"tools.activation.auto": "Auto",
|
||||
"tools.activation.auto.desc": "Smart",
|
||||
"tools.activation.fixed.hint": "Always on — managed by the app and can’t be turned off",
|
||||
"tools.activation.pinned": "Pinned",
|
||||
"tools.activation.pinned.desc": "Always On",
|
||||
"tools.add": "Add Skill",
|
||||
"tools.addSkillOrConnector": "Add Skills / Connector",
|
||||
"tools.builtins.configure": "Configure",
|
||||
"tools.builtins.find-skills.description": "Helps users discover and install agent skills when they ask \"how do I do X\", \"find a skill for X\", or want to extend capabilities",
|
||||
"tools.builtins.find-skills.title": "Find Skills",
|
||||
@@ -1035,8 +1033,6 @@
|
||||
"tools.builtins.lobe-agent-documents.title": "Documents",
|
||||
"tools.builtins.lobe-agent-management.description": "Create, manage, and orchestrate AI agents",
|
||||
"tools.builtins.lobe-agent-management.title": "Agent Management",
|
||||
"tools.builtins.lobe-agent.description": "Built-in Lobe Agent capabilities: plan and todo management, sub-agent dispatch, and visual media analysis",
|
||||
"tools.builtins.lobe-agent.title": "Lobe Agent",
|
||||
"tools.builtins.lobe-artifacts.description": "Generate and preview interactive UI components and visualizations",
|
||||
"tools.builtins.lobe-artifacts.readme": "Generate and live-preview interactive UI components, data visualizations, charts, SVG graphics, and web applications. Create rich visual content that users can interact with directly.",
|
||||
"tools.builtins.lobe-artifacts.title": "Artifacts",
|
||||
|
||||
@@ -40,7 +40,6 @@
|
||||
"agentMarketplace.render.alreadyInLibraryTag": "Already in library",
|
||||
"agentMarketplace.render.alreadyInLibrary_one": "{{count}} already in library",
|
||||
"agentMarketplace.render.alreadyInLibrary_other": "{{count}} already in library",
|
||||
"claudeCode.askUserQuestion.customOption.placeholder": "Or write your own…",
|
||||
"claudeCode.askUserQuestion.escape.back": "Back to options",
|
||||
"claudeCode.askUserQuestion.escape.enter": "Or type directly",
|
||||
"claudeCode.askUserQuestion.escape.placeholder": "Type your answer here…",
|
||||
|
||||
@@ -135,8 +135,6 @@
|
||||
"renameModal.title": "Rename Topic",
|
||||
"searchPlaceholder": "Search Topics...",
|
||||
"searchResultEmpty": "No search results found.",
|
||||
"sidebar.collapseAll": "Collapse all groups",
|
||||
"sidebar.expandAll": "Expand all groups",
|
||||
"sidebar.title": "Topics",
|
||||
"taskManager.agent": "Task Agent",
|
||||
"taskManager.welcome": "Ask me about your tasks",
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
{
|
||||
"badge.failed": "Check failed",
|
||||
"badge.passed": "Check passed",
|
||||
"badge.pending": "Awaiting check",
|
||||
"badge.repairing": "Repair triggered",
|
||||
"behavior.auto_improve": "Auto-fill",
|
||||
"behavior.auto_improveDesc": "Filled in automatically; does not block delivery",
|
||||
"behavior.gate": "Delivery gate",
|
||||
"behavior.gateDesc": "Blocks delivery on failure and triggers a repair round",
|
||||
"detail.checkedAt": "Checked at",
|
||||
"detail.confidence": "Confidence",
|
||||
"detail.counterEvidence": "Counter-evidence",
|
||||
"detail.duration": "Duration",
|
||||
"detail.evidence": "Evidence",
|
||||
"detail.instruction": "Judging rule",
|
||||
"detail.limitation": "Limitation",
|
||||
"detail.method": "Method",
|
||||
"detail.methodAgent": "Agent",
|
||||
"detail.methodLlm": "LLM",
|
||||
"detail.methodProgram": "Program",
|
||||
"detail.model": "Model",
|
||||
"detail.openTrace": "View agent trace",
|
||||
"detail.pending": "This check has not run yet.",
|
||||
"detail.reasoning": "Reasoning",
|
||||
"detail.suggestion": "Suggested fix",
|
||||
"detail.summary": "Summary",
|
||||
"detail.tokens": "Tokens",
|
||||
"dock.confirm": "Confirm & run",
|
||||
"dock.edit": "Adjust checks",
|
||||
"dock.forceDeliver": "Ignore & deliver",
|
||||
"dock.repairHint": "The next round is fixing the failed checks. A new result is produced and the checker re-runs when it finishes.",
|
||||
"dock.saveAndRepair": "Save input & repair now",
|
||||
"dock.skip": "Skip checks",
|
||||
"dock.title": "Delivery Checker",
|
||||
"editor.add": "+ Add check",
|
||||
"editor.cancel": "Cancel",
|
||||
"editor.placeholder": "Check title",
|
||||
"editor.save": "Save",
|
||||
"input.hint": "This goes to the next repair round as checker input — it will not appear as a chat message.",
|
||||
"input.label": "Extra input for the next repair round",
|
||||
"input.placeholder": "e.g. run type-check first; if it still fails, just add a risk note.",
|
||||
"result.failed.sub": "This result is held back. The delivery checker found verification insufficient and triggered a repair.",
|
||||
"result.failed.title": "Draft result",
|
||||
"result.foot": "A snapshot of this run’s result — not an assistant or user message.",
|
||||
"result.kicker": "Verification · Round {{round}}",
|
||||
"result.passed.sub": "The delivery checker passed {{passed}}/{{total}}. This result is ready to deliver.",
|
||||
"result.passed.title": "Result",
|
||||
"result.pending.sub": "The result is generated but not yet delivered — waiting for the delivery checker.",
|
||||
"result.pending.title": "Draft result",
|
||||
"result.repairing.sub": "Checks did not pass. A repair round has started.",
|
||||
"result.repairing.title": "Draft result",
|
||||
"result.title": "Verification #{{round}}",
|
||||
"status.checking": "Delivery Checker: checking {{passed}}/{{total}}",
|
||||
"status.draft": "Delivery Checker: awaiting confirmation · {{total}} checks",
|
||||
"status.failed": "Delivery Checker: failed · repair triggered",
|
||||
"status.idle": "Delivery Checker: not generated",
|
||||
"status.passed": "Delivery Checker: passed {{passed}}/{{total}}",
|
||||
"status.repairing": "Delivery Checker: repairing",
|
||||
"status.verifying": "Delivery Checker: waiting for run to finish"
|
||||
}
|
||||
@@ -351,6 +351,7 @@
|
||||
"newCodexAgent": "Agregar Codex",
|
||||
"newGroupChat": "Crear grupo",
|
||||
"newPage": "Crear página",
|
||||
"newPlatformAgent": "Agregar Agente de Plataforma",
|
||||
"noAgentsYet": "Este grupo aún no tiene miembros. Haz clic en el botón + para invitar agentes.",
|
||||
"noAvailableAgents": "No hay miembros disponibles para invitar",
|
||||
"noMatchingAgents": "No se encontraron miembros coincidentes",
|
||||
@@ -376,6 +377,8 @@
|
||||
"platformAgent.create.checkFailed": "Verificación fallida",
|
||||
"platformAgent.create.checking": "Verificando disponibilidad...",
|
||||
"platformAgent.create.comingSoon": "Próximamente",
|
||||
"platformAgent.create.create": "Crear Agente",
|
||||
"platformAgent.create.creating": "Creando...",
|
||||
"platformAgent.create.desc.amp": "Conectar a Amp ejecutándose en uno de tus dispositivos",
|
||||
"platformAgent.create.desc.hermes": "Conectar a Hermes ejecutándose en uno de tus dispositivos",
|
||||
"platformAgent.create.desc.openclaw": "Conectar a OpenClaw ejecutándose en uno de tus dispositivos",
|
||||
@@ -392,8 +395,10 @@
|
||||
"platformAgent.create.notInstalled": "{{name}} no está instalado en este dispositivo",
|
||||
"platformAgent.create.refresh": "Actualizar",
|
||||
"platformAgent.create.selectDevice": "Seleccionar un dispositivo",
|
||||
"platformAgent.create.step1": "Seleccionar Plataforma",
|
||||
"platformAgent.create.step2": "Seleccionar Dispositivo",
|
||||
"platformAgent.create.step3": "Configurar Agente",
|
||||
"platformAgent.create.title": "Agregar Agente de Plataforma",
|
||||
"platformAgent.create.upgradeCmd": "npm install -g @lobehub/cli",
|
||||
"platformAgent.create.versionTooLow": "La versión de lh es demasiado baja",
|
||||
"platformAgent.create.versionTooLowHint": "Actualiza lh a la última versión:",
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
"features.agentSelfIteration.title": "Autoiteración del agente",
|
||||
"features.assistantMessageGroup.desc": "Agrupa los mensajes del agente y los resultados de sus herramientas para mostrarlos juntos",
|
||||
"features.assistantMessageGroup.title": "Agrupación de Mensajes del Agente",
|
||||
"features.executionDeviceSwitcher.desc": "Mostrar el conmutador de dispositivo de ejecución en la barra de herramientas del agente heterogéneo para que puedas dirigir las ejecuciones a este dispositivo, un sandbox en la nube o un dispositivo remoto vinculado.",
|
||||
"features.executionDeviceSwitcher.title": "Conmutador de Dispositivo de Ejecución",
|
||||
"features.gatewayMode.desc": "Ejecuta las tareas del agente en el servidor a través del WebSocket de Gateway en lugar de hacerlo localmente. Permite una ejecución más rápida y reduce el uso de recursos del cliente.",
|
||||
"features.gatewayMode.title": "Ejecución del agente del lado del servidor (Gateway)",
|
||||
"features.groupChat.desc": "Activa la coordinación de chat grupal con múltiples agentes.",
|
||||
@@ -13,5 +15,7 @@
|
||||
"features.imessage.title": "Canal de iMessage",
|
||||
"features.inputMarkdown.desc": "Renderiza Markdown en el área de entrada en tiempo real (texto en negrita, bloques de código, tablas, etc.).",
|
||||
"features.inputMarkdown.title": "Renderizado de Markdown en la Entrada",
|
||||
"features.platformAgent.desc": "Mostrar la entrada \"Agregar Agente de Plataforma\" en el menú de creación. Los agentes de plataforma (por ejemplo, OpenClaw, Hermes) se ejecutan en un dispositivo conectado y se comunican de vuelta a través de lh connect.",
|
||||
"features.platformAgent.title": "Creación de Agente de Plataforma",
|
||||
"title": "Laboratorios"
|
||||
}
|
||||
|
||||
@@ -491,8 +491,10 @@
|
||||
"platformAgentConfig.device.none": "Ninguno",
|
||||
"platformAgentConfig.device.offline": "Desconectado",
|
||||
"platformAgentConfig.device.online": "Conectado",
|
||||
"platformAgentConfig.platform.label": "Plataforma",
|
||||
"platformAgentConfig.redetect": "Re-detectar",
|
||||
"platformAgentConfig.selectDevice": "Seleccionar un dispositivo",
|
||||
"platformAgentConfig.title": "Configuración de la plataforma",
|
||||
"plugin.addMCPPlugin": "Agregar MCP",
|
||||
"plugin.addTooltip": "Habilidades Personalizadas",
|
||||
"plugin.clearDeprecated": "Eliminar Habilidades Retiradas",
|
||||
|
||||
@@ -351,6 +351,7 @@
|
||||
"newCodexAgent": "افزودن Codex",
|
||||
"newGroupChat": "ایجاد گروه",
|
||||
"newPage": "ایجاد صفحه",
|
||||
"newPlatformAgent": "افزودن عامل پلتفرم",
|
||||
"noAgentsYet": "این گروه هنوز عضوی ندارد. برای دعوت نمایندهها روی دکمه + کلیک کنید.",
|
||||
"noAvailableAgents": "هیچ عضوی برای دعوت در دسترس نیست",
|
||||
"noMatchingAgents": "هیچ عضوی مطابق یافت نشد",
|
||||
@@ -376,6 +377,8 @@
|
||||
"platformAgent.create.checkFailed": "بررسی ناموفق بود",
|
||||
"platformAgent.create.checking": "در حال بررسی دسترسی...",
|
||||
"platformAgent.create.comingSoon": "به زودی",
|
||||
"platformAgent.create.create": "ایجاد عامل",
|
||||
"platformAgent.create.creating": "در حال ایجاد...",
|
||||
"platformAgent.create.desc.amp": "اتصال به Amp که روی یکی از دستگاههای شما اجرا میشود",
|
||||
"platformAgent.create.desc.hermes": "اتصال به Hermes که روی یکی از دستگاههای شما اجرا میشود",
|
||||
"platformAgent.create.desc.openclaw": "اتصال به OpenClaw که روی یکی از دستگاههای شما اجرا میشود",
|
||||
@@ -392,8 +395,10 @@
|
||||
"platformAgent.create.notInstalled": "{{name}} روی این دستگاه نصب نشده است",
|
||||
"platformAgent.create.refresh": "تازهسازی",
|
||||
"platformAgent.create.selectDevice": "یک دستگاه را انتخاب کنید",
|
||||
"platformAgent.create.step1": "انتخاب پلتفرم",
|
||||
"platformAgent.create.step2": "انتخاب دستگاه",
|
||||
"platformAgent.create.step3": "پیکربندی عامل",
|
||||
"platformAgent.create.title": "افزودن عامل پلتفرم",
|
||||
"platformAgent.create.upgradeCmd": "npm install -g @lobehub/cli",
|
||||
"platformAgent.create.versionTooLow": "نسخه lh خیلی پایین است",
|
||||
"platformAgent.create.versionTooLowHint": "lh را به آخرین نسخه بهروزرسانی کنید:",
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
"features.agentSelfIteration.title": "خودتکراری عامل",
|
||||
"features.assistantMessageGroup.desc": "نمایش گروهی پیامهای عامل و نتایج ابزارهای فراخوانیشده بهصورت یکجا",
|
||||
"features.assistantMessageGroup.title": "گروهبندی پیامهای عامل",
|
||||
"features.executionDeviceSwitcher.desc": "نمایش تغییردهنده دستگاه اجرا در نوار ابزار نماینده ناهمگن تا بتوانید اجراها را به این دستگاه، یک محیط ابری یا یک دستگاه راه دور متصل هدایت کنید.",
|
||||
"features.executionDeviceSwitcher.title": "تغییردهنده دستگاه اجرا",
|
||||
"features.gatewayMode.desc": "اجرای وظایف ایجنت روی سرور از طریق وبسوکت Gateway بهجای اجرای محلی. این کار سرعت اجرا را افزایش داده و مصرف منابع در دستگاه کاربر را کاهش میدهد.",
|
||||
"features.gatewayMode.title": "اجرای ایجنت در سمت سرور (Gateway)",
|
||||
"features.groupChat.desc": "فعالسازی هماهنگی گفتوگوی گروهی چندعاملی.",
|
||||
@@ -13,5 +15,7 @@
|
||||
"features.imessage.title": "کانال iMessage",
|
||||
"features.inputMarkdown.desc": "نمایش زنده Markdown در ناحیه ورودی (متن پررنگ، بلوکهای کد، جدولها و غیره).",
|
||||
"features.inputMarkdown.title": "نمایش Markdown در ورودی",
|
||||
"features.platformAgent.desc": "نمایش گزینه \"افزودن نماینده پلتفرم\" در منوی ایجاد. نمایندگان پلتفرم (مانند OpenClaw، Hermes) روی یک دستگاه متصل اجرا میشوند و از طریق lh connect ارتباط برقرار میکنند.",
|
||||
"features.platformAgent.title": "ایجاد نماینده پلتفرم",
|
||||
"title": "آزمایشگاهها"
|
||||
}
|
||||
|
||||
@@ -491,8 +491,10 @@
|
||||
"platformAgentConfig.device.none": "هیچکدام",
|
||||
"platformAgentConfig.device.offline": "آفلاین",
|
||||
"platformAgentConfig.device.online": "آنلاین",
|
||||
"platformAgentConfig.platform.label": "پلتفرم",
|
||||
"platformAgentConfig.redetect": "شناسایی مجدد",
|
||||
"platformAgentConfig.selectDevice": "انتخاب دستگاه",
|
||||
"platformAgentConfig.title": "پیکربندی پلتفرم",
|
||||
"plugin.addMCPPlugin": "افزودن MCP",
|
||||
"plugin.addTooltip": "مهارتهای سفارشی",
|
||||
"plugin.clearDeprecated": "حذف مهارتهای منسوخ",
|
||||
|
||||
@@ -351,6 +351,7 @@
|
||||
"newCodexAgent": "Ajouter Codex",
|
||||
"newGroupChat": "Créer un groupe",
|
||||
"newPage": "Créer une page",
|
||||
"newPlatformAgent": "Ajouter un agent de plateforme",
|
||||
"noAgentsYet": "Ce groupe n'a pas encore de membres. Cliquez sur le bouton + pour inviter des agents.",
|
||||
"noAvailableAgents": "Aucun membre disponible à inviter",
|
||||
"noMatchingAgents": "Aucun membre correspondant trouvé",
|
||||
@@ -376,6 +377,8 @@
|
||||
"platformAgent.create.checkFailed": "Vérification échouée",
|
||||
"platformAgent.create.checking": "Vérification de la disponibilité...",
|
||||
"platformAgent.create.comingSoon": "Bientôt disponible",
|
||||
"platformAgent.create.create": "Créer un agent",
|
||||
"platformAgent.create.creating": "Création...",
|
||||
"platformAgent.create.desc.amp": "Connectez-vous à Amp exécuté sur l'un de vos appareils",
|
||||
"platformAgent.create.desc.hermes": "Connectez-vous à Hermes exécuté sur l'un de vos appareils",
|
||||
"platformAgent.create.desc.openclaw": "Connectez-vous à OpenClaw exécuté sur l'un de vos appareils",
|
||||
@@ -392,8 +395,10 @@
|
||||
"platformAgent.create.notInstalled": "{{name}} n'est pas installé sur cet appareil",
|
||||
"platformAgent.create.refresh": "Actualiser",
|
||||
"platformAgent.create.selectDevice": "Sélectionnez un appareil",
|
||||
"platformAgent.create.step1": "Sélectionnez une plateforme",
|
||||
"platformAgent.create.step2": "Sélectionnez un appareil",
|
||||
"platformAgent.create.step3": "Configurer l'agent",
|
||||
"platformAgent.create.title": "Ajouter un agent de plateforme",
|
||||
"platformAgent.create.upgradeCmd": "npm install -g @lobehub/cli",
|
||||
"platformAgent.create.versionTooLow": "La version de lh est trop ancienne",
|
||||
"platformAgent.create.versionTooLowHint": "Mettez à jour lh vers la dernière version :",
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
"features.agentSelfIteration.title": "Auto‑itération de l’agent",
|
||||
"features.assistantMessageGroup.desc": "Regroupez les messages de l'agent et les résultats de leurs appels d'outils pour les afficher ensemble",
|
||||
"features.assistantMessageGroup.title": "Regroupement des messages de l'agent",
|
||||
"features.executionDeviceSwitcher.desc": "Afficher le commutateur de périphérique d'exécution dans la barre d'outils hétérogène de l'agent afin de pouvoir rediriger les exécutions vers cet appareil, un bac à sable cloud ou un appareil distant lié.",
|
||||
"features.executionDeviceSwitcher.title": "Commutateur de Périphérique d'Exécution",
|
||||
"features.gatewayMode.desc": "Exécute les tâches de l’agent sur le serveur via un WebSocket Gateway au lieu de les exécuter localement. Permet une exécution plus rapide et réduit l’utilisation des ressources du client.",
|
||||
"features.gatewayMode.title": "Exécution de l’agent côté serveur (Gateway)",
|
||||
"features.groupChat.desc": "Activez la coordination de discussions de groupe multi-agents.",
|
||||
@@ -13,5 +15,7 @@
|
||||
"features.imessage.title": "Canal iMessage",
|
||||
"features.inputMarkdown.desc": "Affichez le Markdown dans la zone de saisie en temps réel (texte en gras, blocs de code, tableaux, etc.).",
|
||||
"features.inputMarkdown.title": "Rendu Markdown dans la saisie",
|
||||
"features.platformAgent.desc": "Afficher l'entrée \"Ajouter un Agent de Plateforme\" dans le menu de création. Les agents de plateforme (par exemple, OpenClaw, Hermes) fonctionnent sur un appareil connecté et communiquent via lh connect.",
|
||||
"features.platformAgent.title": "Création d'Agent de Plateforme",
|
||||
"title": "Laboratoires"
|
||||
}
|
||||
|
||||
@@ -491,8 +491,10 @@
|
||||
"platformAgentConfig.device.none": "Aucun",
|
||||
"platformAgentConfig.device.offline": "Hors ligne",
|
||||
"platformAgentConfig.device.online": "En ligne",
|
||||
"platformAgentConfig.platform.label": "Plateforme",
|
||||
"platformAgentConfig.redetect": "Re-détecter",
|
||||
"platformAgentConfig.selectDevice": "Sélectionner un appareil",
|
||||
"platformAgentConfig.title": "Configuration de la plateforme",
|
||||
"plugin.addMCPPlugin": "Ajouter MCP",
|
||||
"plugin.addTooltip": "Compétences personnalisées",
|
||||
"plugin.clearDeprecated": "Supprimer les compétences obsolètes",
|
||||
|
||||
@@ -351,6 +351,7 @@
|
||||
"newCodexAgent": "Aggiungi Codex",
|
||||
"newGroupChat": "Crea gruppo",
|
||||
"newPage": "Crea pagina",
|
||||
"newPlatformAgent": "Aggiungi agente della piattaforma",
|
||||
"noAgentsYet": "Questo gruppo non ha ancora membri. Clicca sul pulsante + per invitare agenti.",
|
||||
"noAvailableAgents": "Nessun membro disponibile da invitare",
|
||||
"noMatchingAgents": "Nessun membro corrispondente trovato",
|
||||
@@ -376,6 +377,8 @@
|
||||
"platformAgent.create.checkFailed": "Verifica fallita",
|
||||
"platformAgent.create.checking": "Verifica disponibilità...",
|
||||
"platformAgent.create.comingSoon": "Prossimamente",
|
||||
"platformAgent.create.create": "Crea agente",
|
||||
"platformAgent.create.creating": "Creazione in corso...",
|
||||
"platformAgent.create.desc.amp": "Connettiti ad Amp in esecuzione su uno dei tuoi dispositivi",
|
||||
"platformAgent.create.desc.hermes": "Connettiti a Hermes in esecuzione su uno dei tuoi dispositivi",
|
||||
"platformAgent.create.desc.openclaw": "Connettiti a OpenClaw in esecuzione su uno dei tuoi dispositivi",
|
||||
@@ -392,8 +395,10 @@
|
||||
"platformAgent.create.notInstalled": "{{name}} non installato su questo dispositivo",
|
||||
"platformAgent.create.refresh": "Aggiorna",
|
||||
"platformAgent.create.selectDevice": "Seleziona un dispositivo",
|
||||
"platformAgent.create.step1": "Seleziona piattaforma",
|
||||
"platformAgent.create.step2": "Seleziona dispositivo",
|
||||
"platformAgent.create.step3": "Configura agente",
|
||||
"platformAgent.create.title": "Aggiungi agente della piattaforma",
|
||||
"platformAgent.create.upgradeCmd": "npm install -g @lobehub/cli",
|
||||
"platformAgent.create.versionTooLow": "La versione di lh è troppo bassa",
|
||||
"platformAgent.create.versionTooLowHint": "Aggiorna lh all'ultima versione:",
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
"features.agentSelfIteration.title": "Auto-iterazione dell'agente",
|
||||
"features.assistantMessageGroup.desc": "Raggruppa i messaggi dell'agente e i risultati delle chiamate agli strumenti per una visualizzazione unificata",
|
||||
"features.assistantMessageGroup.title": "Raggruppamento Messaggi Agente",
|
||||
"features.executionDeviceSwitcher.desc": "Visualizza il selettore del dispositivo di esecuzione nella barra degli strumenti eterogenea dell'agente, così puoi instradare le esecuzioni su questo dispositivo, un sandbox cloud o un dispositivo remoto associato.",
|
||||
"features.executionDeviceSwitcher.title": "Selettore del Dispositivo di Esecuzione",
|
||||
"features.gatewayMode.desc": "Esegui le attività dell’agente sul server tramite Gateway WebSocket invece di eseguirle in locale. Consente un’esecuzione più rapida e riduce l’utilizzo delle risorse del client.",
|
||||
"features.gatewayMode.title": "Esecuzione dell’Agente Lato Server (Gateway)",
|
||||
"features.groupChat.desc": "Abilita il coordinamento della chat di gruppo con più agenti.",
|
||||
@@ -13,5 +15,7 @@
|
||||
"features.imessage.title": "Canale iMessage",
|
||||
"features.inputMarkdown.desc": "Visualizza in tempo reale il Markdown nell'area di input (testo in grassetto, blocchi di codice, tabelle, ecc.).",
|
||||
"features.inputMarkdown.title": "Rendering Markdown in Input",
|
||||
"features.platformAgent.desc": "Mostra la voce \"Aggiungi Agente Piattaforma\" nel menu di creazione. Gli agenti di piattaforma (ad esempio OpenClaw, Hermes) funzionano su un dispositivo connesso e comunicano tramite lh connect.",
|
||||
"features.platformAgent.title": "Creazione Agente Piattaforma",
|
||||
"title": "Laboratori"
|
||||
}
|
||||
|
||||
@@ -491,8 +491,10 @@
|
||||
"platformAgentConfig.device.none": "Nessuno",
|
||||
"platformAgentConfig.device.offline": "Offline",
|
||||
"platformAgentConfig.device.online": "Online",
|
||||
"platformAgentConfig.platform.label": "Piattaforma",
|
||||
"platformAgentConfig.redetect": "Rileva di nuovo",
|
||||
"platformAgentConfig.selectDevice": "Seleziona un dispositivo",
|
||||
"platformAgentConfig.title": "Configurazione della piattaforma",
|
||||
"plugin.addMCPPlugin": "Aggiungi MCP",
|
||||
"plugin.addTooltip": "Competenze Personalizzate",
|
||||
"plugin.clearDeprecated": "Rimuovi Competenze Deprecate",
|
||||
|
||||
@@ -351,6 +351,7 @@
|
||||
"newCodexAgent": "Codex を追加",
|
||||
"newGroupChat": "グループを作成",
|
||||
"newPage": "ドキュメントを作成",
|
||||
"newPlatformAgent": "プラットフォームエージェントを追加",
|
||||
"noAgentsYet": "このグループにはまだメンバーがいません。「+」をクリックしてアシスタントを招待してください",
|
||||
"noAvailableAgents": "招待可能なメンバーがいません",
|
||||
"noMatchingAgents": "一致するメンバーが見つかりませんでした",
|
||||
@@ -376,6 +377,8 @@
|
||||
"platformAgent.create.checkFailed": "チェック失敗",
|
||||
"platformAgent.create.checking": "利用可能性を確認中...",
|
||||
"platformAgent.create.comingSoon": "近日公開",
|
||||
"platformAgent.create.create": "エージェントを作成",
|
||||
"platformAgent.create.creating": "作成中...",
|
||||
"platformAgent.create.desc.amp": "デバイスの1つで実行中のAmpに接続",
|
||||
"platformAgent.create.desc.hermes": "デバイスの1つで実行中のHermesに接続",
|
||||
"platformAgent.create.desc.openclaw": "デバイスの1つで実行中のOpenClawに接続",
|
||||
@@ -392,8 +395,10 @@
|
||||
"platformAgent.create.notInstalled": "{{name}} がこのデバイスにインストールされていません",
|
||||
"platformAgent.create.refresh": "更新",
|
||||
"platformAgent.create.selectDevice": "デバイスを選択",
|
||||
"platformAgent.create.step1": "プラットフォームを選択",
|
||||
"platformAgent.create.step2": "デバイスを選択",
|
||||
"platformAgent.create.step3": "エージェントを構成",
|
||||
"platformAgent.create.title": "プラットフォームエージェントを追加",
|
||||
"platformAgent.create.upgradeCmd": "npm install -g @lobehub/cli",
|
||||
"platformAgent.create.versionTooLow": "lh のバージョンが低すぎます",
|
||||
"platformAgent.create.versionTooLowHint": "lh を最新バージョンに更新してください:",
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
"features.agentSelfIteration.title": "エージェントの自己反復",
|
||||
"features.assistantMessageGroup.desc": "アシスタントのメッセージとそのツール呼び出し結果をグループ化して表示します",
|
||||
"features.assistantMessageGroup.title": "アシスタントメッセージのグループ化表示",
|
||||
"features.executionDeviceSwitcher.desc": "異種エージェントツールバーに実行デバイススイッチャーを表示し、このデバイス、クラウドサンドボックス、またはバインドされたリモートデバイスに実行をルーティングできます。",
|
||||
"features.executionDeviceSwitcher.title": "実行デバイススイッチャー",
|
||||
"features.gatewayMode.desc": "エージェントのタスクをローカルではなく Gateway WebSocket を介してサーバー上で実行します。これにより、より高速な処理が可能になり、クライアント側のリソース消費を削減できます。",
|
||||
"features.gatewayMode.title": "サーバーサイドエージェント実行(Gateway)",
|
||||
"features.groupChat.desc": "複数のAIアシスタントによるグループチャット機能を有効にします。",
|
||||
@@ -13,5 +15,7 @@
|
||||
"features.imessage.title": "iMessageチャンネル",
|
||||
"features.inputMarkdown.desc": "入力エリアでMarkdown(太字、コードブロック、表など)をリアルタイムでレンダリングします。",
|
||||
"features.inputMarkdown.title": "入力欄のMarkdownレンダリング",
|
||||
"features.platformAgent.desc": "作成メニューに「プラットフォームエージェントを追加」エントリを表示します。プラットフォームエージェント(例: OpenClaw、Hermes)は接続されたデバイス上で動作し、lh connectを介して通信します。",
|
||||
"features.platformAgent.title": "プラットフォームエージェント作成",
|
||||
"title": "ラボ"
|
||||
}
|
||||
|
||||
@@ -491,8 +491,10 @@
|
||||
"platformAgentConfig.device.none": "なし",
|
||||
"platformAgentConfig.device.offline": "オフライン",
|
||||
"platformAgentConfig.device.online": "オンライン",
|
||||
"platformAgentConfig.platform.label": "プラットフォーム",
|
||||
"platformAgentConfig.redetect": "再検出",
|
||||
"platformAgentConfig.selectDevice": "デバイスを選択",
|
||||
"platformAgentConfig.title": "プラットフォーム設定",
|
||||
"plugin.addMCPPlugin": "MCPスキルを追加",
|
||||
"plugin.addTooltip": "カスタムスキル",
|
||||
"plugin.clearDeprecated": "無効なスキルをクリア",
|
||||
|
||||
@@ -351,6 +351,7 @@
|
||||
"newCodexAgent": "Codex 추가",
|
||||
"newGroupChat": "그룹 만들기",
|
||||
"newPage": "문서 만들기",
|
||||
"newPlatformAgent": "플랫폼 에이전트 추가",
|
||||
"noAgentsYet": "이 그룹에는 아직 구성원이 없습니다. +를 클릭하여 도우미를 초대하세요",
|
||||
"noAvailableAgents": "초대할 수 있는 구성원이 없습니다",
|
||||
"noMatchingAgents": "일치하는 구성원을 찾을 수 없습니다",
|
||||
@@ -376,6 +377,8 @@
|
||||
"platformAgent.create.checkFailed": "확인 실패",
|
||||
"platformAgent.create.checking": "사용 가능 여부 확인 중...",
|
||||
"platformAgent.create.comingSoon": "곧 출시",
|
||||
"platformAgent.create.create": "에이전트 생성",
|
||||
"platformAgent.create.creating": "생성 중...",
|
||||
"platformAgent.create.desc.amp": "기기 중 하나에서 실행 중인 Amp에 연결",
|
||||
"platformAgent.create.desc.hermes": "기기 중 하나에서 실행 중인 Hermes에 연결",
|
||||
"platformAgent.create.desc.openclaw": "기기 중 하나에서 실행 중인 OpenClaw에 연결",
|
||||
@@ -392,8 +395,10 @@
|
||||
"platformAgent.create.notInstalled": "{{name}}이(가) 이 장치에 설치되지 않았습니다",
|
||||
"platformAgent.create.refresh": "새로 고침",
|
||||
"platformAgent.create.selectDevice": "장치 선택",
|
||||
"platformAgent.create.step1": "플랫폼 선택",
|
||||
"platformAgent.create.step2": "장치 선택",
|
||||
"platformAgent.create.step3": "에이전트 구성",
|
||||
"platformAgent.create.title": "플랫폼 에이전트 추가",
|
||||
"platformAgent.create.upgradeCmd": "npm install -g @lobehub/cli",
|
||||
"platformAgent.create.versionTooLow": "lh 버전이 너무 낮습니다",
|
||||
"platformAgent.create.versionTooLowHint": "lh를 최신 버전으로 업데이트하세요:",
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
"features.agentSelfIteration.title": "에이전트 자기 반복",
|
||||
"features.assistantMessageGroup.desc": "도우미 메시지와 해당 도구 호출 결과를 그룹으로 묶어 표시합니다",
|
||||
"features.assistantMessageGroup.title": "도우미 메시지 그룹화",
|
||||
"features.executionDeviceSwitcher.desc": "이종 에이전트 툴바에서 실행 장치 전환기를 표시하여 실행을 이 장치, 클라우드 샌드박스 또는 연결된 원격 장치로 라우팅할 수 있습니다.",
|
||||
"features.executionDeviceSwitcher.title": "실행 장치 전환기",
|
||||
"features.gatewayMode.desc": "로컬에서 실행하는 대신 Gateway WebSocket을 통해 서버에서 에이전트 작업을 실행합니다. 더 빠른 처리 속도를 제공하고 클라이언트 리소스 사용을 줄여줍니다.",
|
||||
"features.gatewayMode.title": "서버 사이드 에이전트 실행(Gateway)",
|
||||
"features.groupChat.desc": "다중 도우미 그룹 채팅 조정 기능을 활성화합니다.",
|
||||
@@ -13,5 +15,7 @@
|
||||
"features.imessage.title": "iMessage 채널",
|
||||
"features.inputMarkdown.desc": "입력 영역에서 실시간으로 Markdown을 렌더링합니다 (굵은 글씨, 코드 블록, 표 등).",
|
||||
"features.inputMarkdown.title": "입력창 Markdown 렌더링",
|
||||
"features.platformAgent.desc": "생성 메뉴에서 \"플랫폼 에이전트 추가\" 항목을 표시합니다. 플랫폼 에이전트(예: OpenClaw, Hermes)는 연결된 장치에서 실행되며 lh connect를 통해 다시 통신합니다.",
|
||||
"features.platformAgent.title": "플랫폼 에이전트 생성",
|
||||
"title": "실험실"
|
||||
}
|
||||
|
||||
@@ -491,8 +491,10 @@
|
||||
"platformAgentConfig.device.none": "없음",
|
||||
"platformAgentConfig.device.offline": "오프라인",
|
||||
"platformAgentConfig.device.online": "온라인",
|
||||
"platformAgentConfig.platform.label": "플랫폼",
|
||||
"platformAgentConfig.redetect": "다시 감지",
|
||||
"platformAgentConfig.selectDevice": "장치 선택",
|
||||
"platformAgentConfig.title": "플랫폼 구성",
|
||||
"plugin.addMCPPlugin": "MCP 기능 추가",
|
||||
"plugin.addTooltip": "사용자 정의 기능",
|
||||
"plugin.clearDeprecated": "유효하지 않은 기능 제거",
|
||||
|
||||
@@ -351,6 +351,7 @@
|
||||
"newCodexAgent": "Codex toevoegen",
|
||||
"newGroupChat": "Groepsgesprek aanmaken",
|
||||
"newPage": "Pagina aanmaken",
|
||||
"newPlatformAgent": "Platformagent toevoegen",
|
||||
"noAgentsYet": "Deze groep heeft nog geen leden. Klik op de + knop om agenten uit te nodigen.",
|
||||
"noAvailableAgents": "Geen leden beschikbaar om uit te nodigen",
|
||||
"noMatchingAgents": "Geen overeenkomende leden gevonden",
|
||||
@@ -376,6 +377,8 @@
|
||||
"platformAgent.create.checkFailed": "Controle mislukt",
|
||||
"platformAgent.create.checking": "Beschikbaarheid controleren...",
|
||||
"platformAgent.create.comingSoon": "Binnenkort beschikbaar",
|
||||
"platformAgent.create.create": "Agent aanmaken",
|
||||
"platformAgent.create.creating": "Bezig met aanmaken...",
|
||||
"platformAgent.create.desc.amp": "Verbind met Amp die op een van je apparaten draait",
|
||||
"platformAgent.create.desc.hermes": "Verbind met Hermes die op een van je apparaten draait",
|
||||
"platformAgent.create.desc.openclaw": "Verbind met OpenClaw die op een van je apparaten draait",
|
||||
@@ -392,8 +395,10 @@
|
||||
"platformAgent.create.notInstalled": "{{name}} is niet geïnstalleerd op dit apparaat",
|
||||
"platformAgent.create.refresh": "Vernieuwen",
|
||||
"platformAgent.create.selectDevice": "Selecteer een apparaat",
|
||||
"platformAgent.create.step1": "Selecteer platform",
|
||||
"platformAgent.create.step2": "Selecteer apparaat",
|
||||
"platformAgent.create.step3": "Configureer agent",
|
||||
"platformAgent.create.title": "Platformagent toevoegen",
|
||||
"platformAgent.create.upgradeCmd": "npm install -g @lobehub/cli",
|
||||
"platformAgent.create.versionTooLow": "lh-versie is te laag",
|
||||
"platformAgent.create.versionTooLowHint": "Update lh naar de nieuwste versie:",
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
"features.agentSelfIteration.title": "Agent-zelfiteratie",
|
||||
"features.assistantMessageGroup.desc": "Groepeer berichten van de agent en de resultaten van hun tool-aanroepen samen voor weergave",
|
||||
"features.assistantMessageGroup.title": "Agentberichtengroepering",
|
||||
"features.executionDeviceSwitcher.desc": "Toon de uitvoerapparaat-schakelaar in de heterogene agentwerkbalk, zodat je runs kunt routeren naar dit apparaat, een cloud-sandbox of een gekoppeld extern apparaat.",
|
||||
"features.executionDeviceSwitcher.title": "Uitvoerapparaat-schakelaar",
|
||||
"features.gatewayMode.desc": "Voer agenttaken op de server uit via de Gateway‑WebSocket in plaats van ze lokaal uit te voeren. Zorgt voor snellere uitvoering en vermindert het gebruik van clientbronnen.",
|
||||
"features.gatewayMode.title": "Server-side uitvoering van agenten (Gateway)",
|
||||
"features.groupChat.desc": "Schakel coördinatie van groepschats met meerdere agenten in.",
|
||||
@@ -13,5 +15,7 @@
|
||||
"features.imessage.title": "iMessage-kanaal",
|
||||
"features.inputMarkdown.desc": "Toon Markdown in het invoerveld in realtime (vette tekst, codeblokken, tabellen, enz.).",
|
||||
"features.inputMarkdown.title": "Markdown-weergave bij Invoer",
|
||||
"features.platformAgent.desc": "Toon de optie \"Platformagent toevoegen\" in het aanmaakmenu. Platformagents (bijv. OpenClaw, Hermes) draaien op een verbonden apparaat en communiceren terug via lh connect.",
|
||||
"features.platformAgent.title": "Platformagent Aanmaken",
|
||||
"title": "Labs"
|
||||
}
|
||||
|
||||
@@ -491,8 +491,10 @@
|
||||
"platformAgentConfig.device.none": "Geen",
|
||||
"platformAgentConfig.device.offline": "Offline",
|
||||
"platformAgentConfig.device.online": "Online",
|
||||
"platformAgentConfig.platform.label": "Platform",
|
||||
"platformAgentConfig.redetect": "Opnieuw detecteren",
|
||||
"platformAgentConfig.selectDevice": "Selecteer een apparaat",
|
||||
"platformAgentConfig.title": "Platformconfiguratie",
|
||||
"plugin.addMCPPlugin": "MCP toevoegen",
|
||||
"plugin.addTooltip": "Aangepaste vaardigheden",
|
||||
"plugin.clearDeprecated": "Verouderde vaardigheden verwijderen",
|
||||
|
||||
@@ -351,6 +351,7 @@
|
||||
"newCodexAgent": "Dodaj Codex",
|
||||
"newGroupChat": "Utwórz grupę",
|
||||
"newPage": "Utwórz stronę",
|
||||
"newPlatformAgent": "Dodaj agenta platformy",
|
||||
"noAgentsYet": "Ta grupa nie ma jeszcze członków. Kliknij przycisk +, aby zaprosić agentów.",
|
||||
"noAvailableAgents": "Brak dostępnych członków do zaproszenia",
|
||||
"noMatchingAgents": "Nie znaleziono pasujących członków",
|
||||
@@ -376,6 +377,8 @@
|
||||
"platformAgent.create.checkFailed": "Sprawdzenie nie powiodło się",
|
||||
"platformAgent.create.checking": "Sprawdzanie dostępności...",
|
||||
"platformAgent.create.comingSoon": "Już wkrótce",
|
||||
"platformAgent.create.create": "Utwórz agenta",
|
||||
"platformAgent.create.creating": "Tworzenie...",
|
||||
"platformAgent.create.desc.amp": "Połącz z Amp uruchomionym na jednym z Twoich urządzeń",
|
||||
"platformAgent.create.desc.hermes": "Połącz z Hermes uruchomionym na jednym z Twoich urządzeń",
|
||||
"platformAgent.create.desc.openclaw": "Połącz z OpenClaw uruchomionym na jednym z Twoich urządzeń",
|
||||
@@ -392,8 +395,10 @@
|
||||
"platformAgent.create.notInstalled": "{{name}} nie jest zainstalowany na tym urządzeniu",
|
||||
"platformAgent.create.refresh": "Odśwież",
|
||||
"platformAgent.create.selectDevice": "Wybierz urządzenie",
|
||||
"platformAgent.create.step1": "Wybierz platformę",
|
||||
"platformAgent.create.step2": "Wybierz urządzenie",
|
||||
"platformAgent.create.step3": "Skonfiguruj agenta",
|
||||
"platformAgent.create.title": "Dodaj agenta platformy",
|
||||
"platformAgent.create.upgradeCmd": "npm install -g @lobehub/cli",
|
||||
"platformAgent.create.versionTooLow": "Wersja lh jest zbyt niska",
|
||||
"platformAgent.create.versionTooLowHint": "Zaktualizuj lh do najnowszej wersji:",
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
"features.agentSelfIteration.title": "Samoiteracja agenta",
|
||||
"features.assistantMessageGroup.desc": "Grupuj wiadomości agenta i wyniki wywołań narzędzi razem do wyświetlenia",
|
||||
"features.assistantMessageGroup.title": "Grupowanie Wiadomości Agenta",
|
||||
"features.executionDeviceSwitcher.desc": "Wyświetl przełącznik urządzeń wykonawczych na pasku narzędzi agenta heterogenicznego, aby móc kierować uruchomienia na to urządzenie, chmurę sandbox lub powiązane urządzenie zdalne.",
|
||||
"features.executionDeviceSwitcher.title": "Przełącznik Urządzeń Wykonawczych",
|
||||
"features.gatewayMode.desc": "Wykonuj zadania agenta na serwerze przez Gateway WebSocket zamiast lokalnie. Umożliwia to szybsze wykonywanie i zmniejsza wykorzystanie zasobów po stronie klienta.",
|
||||
"features.gatewayMode.title": "Wykonywanie agenta po stronie serwera (Gateway)",
|
||||
"features.groupChat.desc": "Włącz koordynację czatu grupowego z wieloma agentami.",
|
||||
@@ -13,5 +15,7 @@
|
||||
"features.imessage.title": "Kanał iMessage",
|
||||
"features.inputMarkdown.desc": "Renderuj Markdown w polu wprowadzania w czasie rzeczywistym (pogrubiony tekst, bloki kodu, tabele itp.).",
|
||||
"features.inputMarkdown.title": "Renderowanie Markdown w Polu Wprowadzania",
|
||||
"features.platformAgent.desc": "Pokaż opcję \"Dodaj Agenta Platformy\" w menu tworzenia. Agenci platformy (np. OpenClaw, Hermes) działają na podłączonym urządzeniu i komunikują się z powrotem za pośrednictwem lh connect.",
|
||||
"features.platformAgent.title": "Tworzenie Agenta Platformy",
|
||||
"title": "Laboratorium"
|
||||
}
|
||||
|
||||
@@ -491,8 +491,10 @@
|
||||
"platformAgentConfig.device.none": "Brak",
|
||||
"platformAgentConfig.device.offline": "Offline",
|
||||
"platformAgentConfig.device.online": "Online",
|
||||
"platformAgentConfig.platform.label": "Platforma",
|
||||
"platformAgentConfig.redetect": "Ponowne wykrycie",
|
||||
"platformAgentConfig.selectDevice": "Wybierz urządzenie",
|
||||
"platformAgentConfig.title": "Konfiguracja platformy",
|
||||
"plugin.addMCPPlugin": "Dodaj MCP",
|
||||
"plugin.addTooltip": "Własne Umiejętności",
|
||||
"plugin.clearDeprecated": "Usuń przestarzałe umiejętności",
|
||||
|
||||
@@ -351,6 +351,7 @@
|
||||
"newCodexAgent": "Adicionar Codex",
|
||||
"newGroupChat": "Criar Grupo",
|
||||
"newPage": "Criar Página",
|
||||
"newPlatformAgent": "Adicionar Agente de Plataforma",
|
||||
"noAgentsYet": "Este grupo ainda não possui membros. Clique no botão + para convidar agentes.",
|
||||
"noAvailableAgents": "Nenhum membro disponível para convite",
|
||||
"noMatchingAgents": "Nenhum membro correspondente encontrado",
|
||||
@@ -376,6 +377,8 @@
|
||||
"platformAgent.create.checkFailed": "Verificação falhou",
|
||||
"platformAgent.create.checking": "Verificando disponibilidade...",
|
||||
"platformAgent.create.comingSoon": "Em breve",
|
||||
"platformAgent.create.create": "Criar Agente",
|
||||
"platformAgent.create.creating": "Criando...",
|
||||
"platformAgent.create.desc.amp": "Conectar ao Amp em execução em um dos seus dispositivos",
|
||||
"platformAgent.create.desc.hermes": "Conectar ao Hermes em execução em um dos seus dispositivos",
|
||||
"platformAgent.create.desc.openclaw": "Conectar ao OpenClaw em execução em um dos seus dispositivos",
|
||||
@@ -392,8 +395,10 @@
|
||||
"platformAgent.create.notInstalled": "{{name}} não está instalado neste dispositivo",
|
||||
"platformAgent.create.refresh": "Atualizar",
|
||||
"platformAgent.create.selectDevice": "Selecione um dispositivo",
|
||||
"platformAgent.create.step1": "Selecionar Plataforma",
|
||||
"platformAgent.create.step2": "Selecionar Dispositivo",
|
||||
"platformAgent.create.step3": "Configurar Agente",
|
||||
"platformAgent.create.title": "Adicionar Agente de Plataforma",
|
||||
"platformAgent.create.upgradeCmd": "npm install -g @lobehub/cli",
|
||||
"platformAgent.create.versionTooLow": "A versão do lh é muito baixa",
|
||||
"platformAgent.create.versionTooLowHint": "Atualize o lh para a versão mais recente:",
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
"features.agentSelfIteration.title": "Autoiteração do Agente",
|
||||
"features.assistantMessageGroup.desc": "Agrupe mensagens do agente e os resultados das chamadas de ferramentas para exibição conjunta",
|
||||
"features.assistantMessageGroup.title": "Agrupamento de Mensagens do Agente",
|
||||
"features.executionDeviceSwitcher.desc": "Exibir o alternador de dispositivo de execução na barra de ferramentas de agentes heterogêneos para que você possa direcionar execuções para este dispositivo, um sandbox na nuvem ou um dispositivo remoto vinculado.",
|
||||
"features.executionDeviceSwitcher.title": "Alternador de Dispositivo de Execução",
|
||||
"features.gatewayMode.desc": "Execute tarefas de agente no servidor via Gateway WebSocket em vez de executá-las localmente. Permite uma execução mais rápida e reduz o uso de recursos do cliente.",
|
||||
"features.gatewayMode.title": "Execução de Agente no Servidor (Gateway)",
|
||||
"features.groupChat.desc": "Ative a coordenação de bate-papo em grupo com múltiplos agentes.",
|
||||
@@ -13,5 +15,7 @@
|
||||
"features.imessage.title": "Canal iMessage",
|
||||
"features.inputMarkdown.desc": "Renderize Markdown na área de entrada em tempo real (texto em negrito, blocos de código, tabelas etc.).",
|
||||
"features.inputMarkdown.title": "Renderização de Markdown na Entrada",
|
||||
"features.platformAgent.desc": "Exibir a entrada \"Adicionar Agente de Plataforma\" no menu de criação. Agentes de plataforma (por exemplo, OpenClaw, Hermes) operam em um dispositivo conectado e se comunicam de volta via lh connect.",
|
||||
"features.platformAgent.title": "Criação de Agente de Plataforma",
|
||||
"title": "Laboratórios"
|
||||
}
|
||||
|
||||
@@ -491,8 +491,10 @@
|
||||
"platformAgentConfig.device.none": "Nenhum",
|
||||
"platformAgentConfig.device.offline": "Offline",
|
||||
"platformAgentConfig.device.online": "Online",
|
||||
"platformAgentConfig.platform.label": "Plataforma",
|
||||
"platformAgentConfig.redetect": "Redetectar",
|
||||
"platformAgentConfig.selectDevice": "Selecione um dispositivo",
|
||||
"platformAgentConfig.title": "Configuração da Plataforma",
|
||||
"plugin.addMCPPlugin": "Adicionar MCP",
|
||||
"plugin.addTooltip": "Habilidades Personalizadas",
|
||||
"plugin.clearDeprecated": "Remover Habilidades Descontinuadas",
|
||||
|
||||
@@ -351,6 +351,7 @@
|
||||
"newCodexAgent": "Добавить Codex",
|
||||
"newGroupChat": "Создать группу",
|
||||
"newPage": "Создать страницу",
|
||||
"newPlatformAgent": "Добавить платформенного агента",
|
||||
"noAgentsYet": "В этой группе пока нет участников. Нажмите +, чтобы пригласить агентов.",
|
||||
"noAvailableAgents": "Нет доступных участников для приглашения",
|
||||
"noMatchingAgents": "Совпадений не найдено",
|
||||
@@ -376,6 +377,8 @@
|
||||
"platformAgent.create.checkFailed": "Проверка не удалась",
|
||||
"platformAgent.create.checking": "Проверка доступности...",
|
||||
"platformAgent.create.comingSoon": "Скоро",
|
||||
"platformAgent.create.create": "Создать агента",
|
||||
"platformAgent.create.creating": "Создание...",
|
||||
"platformAgent.create.desc.amp": "Подключение к Amp, работающему на одном из ваших устройств",
|
||||
"platformAgent.create.desc.hermes": "Подключение к Hermes, работающему на одном из ваших устройств",
|
||||
"platformAgent.create.desc.openclaw": "Подключение к OpenClaw, работающему на одном из ваших устройств",
|
||||
@@ -392,8 +395,10 @@
|
||||
"platformAgent.create.notInstalled": "{{name}} не установлен на этом устройстве",
|
||||
"platformAgent.create.refresh": "Обновить",
|
||||
"platformAgent.create.selectDevice": "Выберите устройство",
|
||||
"platformAgent.create.step1": "Выберите платформу",
|
||||
"platformAgent.create.step2": "Выберите устройство",
|
||||
"platformAgent.create.step3": "Настройте агента",
|
||||
"platformAgent.create.title": "Добавить платформенного агента",
|
||||
"platformAgent.create.upgradeCmd": "npm install -g @lobehub/cli",
|
||||
"platformAgent.create.versionTooLow": "Версия lh слишком низкая",
|
||||
"platformAgent.create.versionTooLowHint": "Обновите lh до последней версии:",
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
"features.agentSelfIteration.title": "Самоитерация агента",
|
||||
"features.assistantMessageGroup.desc": "Группируйте сообщения агента и результаты вызова инструментов для совместного отображения",
|
||||
"features.assistantMessageGroup.title": "Группировка сообщений агента",
|
||||
"features.executionDeviceSwitcher.desc": "Отображать переключатель устройства выполнения в панели инструментов гетерогенного агента, чтобы вы могли направлять выполнение на это устройство, облачную песочницу или подключенное удаленное устройство.",
|
||||
"features.executionDeviceSwitcher.title": "Переключатель устройства выполнения",
|
||||
"features.gatewayMode.desc": "Выполняйте задачи агента на сервере через WebSocket Gateway вместо локального запуска. Обеспечивает более быструю работу и снижает нагрузку на ресурсы клиента.",
|
||||
"features.gatewayMode.title": "Серверное выполнение агента (Gateway)",
|
||||
"features.groupChat.desc": "Включите координацию группового чата с несколькими агентами.",
|
||||
@@ -13,5 +15,7 @@
|
||||
"features.imessage.title": "Канал iMessage",
|
||||
"features.inputMarkdown.desc": "Отображение Markdown в поле ввода в реальном времени (жирный текст, блоки кода, таблицы и т. д.).",
|
||||
"features.inputMarkdown.title": "Отображение Markdown при вводе",
|
||||
"features.platformAgent.desc": "Показывать пункт \"Добавить платформенного агента\" в меню создания. Платформенные агенты (например, OpenClaw, Hermes) работают на подключенном устройстве и взаимодействуют через lh connect.",
|
||||
"features.platformAgent.title": "Создание платформенного агента",
|
||||
"title": "Лаборатория"
|
||||
}
|
||||
|
||||
@@ -491,8 +491,10 @@
|
||||
"platformAgentConfig.device.none": "Нет",
|
||||
"platformAgentConfig.device.offline": "Офлайн",
|
||||
"platformAgentConfig.device.online": "Онлайн",
|
||||
"platformAgentConfig.platform.label": "Платформа",
|
||||
"platformAgentConfig.redetect": "Повторное обнаружение",
|
||||
"platformAgentConfig.selectDevice": "Выберите устройство",
|
||||
"platformAgentConfig.title": "Конфигурация платформы",
|
||||
"plugin.addMCPPlugin": "Добавить MCP",
|
||||
"plugin.addTooltip": "Пользовательские навыки",
|
||||
"plugin.clearDeprecated": "Удалить устаревшие навыки",
|
||||
|
||||
@@ -351,6 +351,7 @@
|
||||
"newCodexAgent": "Codex ekle",
|
||||
"newGroupChat": "Grup Oluştur",
|
||||
"newPage": "Sayfa Oluştur",
|
||||
"newPlatformAgent": "Platform Ajanı Ekle",
|
||||
"noAgentsYet": "Bu grupta henüz üye yok. Ajan davet etmek için + butonuna tıklayın.",
|
||||
"noAvailableAgents": "Davet edilecek uygun üye yok",
|
||||
"noMatchingAgents": "Eşleşen üye bulunamadı",
|
||||
@@ -376,6 +377,8 @@
|
||||
"platformAgent.create.checkFailed": "Kontrol başarısız oldu",
|
||||
"platformAgent.create.checking": "Mevcutluk kontrol ediliyor...",
|
||||
"platformAgent.create.comingSoon": "Yakında",
|
||||
"platformAgent.create.create": "Ajan Oluştur",
|
||||
"platformAgent.create.creating": "Oluşturuluyor...",
|
||||
"platformAgent.create.desc.amp": "Cihazlarınızdan birinde çalışan Amp'e bağlanın",
|
||||
"platformAgent.create.desc.hermes": "Cihazlarınızdan birinde çalışan Hermes'e bağlanın",
|
||||
"platformAgent.create.desc.openclaw": "Cihazlarınızdan birinde çalışan OpenClaw'a bağlanın",
|
||||
@@ -392,8 +395,10 @@
|
||||
"platformAgent.create.notInstalled": "{{name}} bu cihazda yüklü değil",
|
||||
"platformAgent.create.refresh": "Yenile",
|
||||
"platformAgent.create.selectDevice": "Bir cihaz seçin",
|
||||
"platformAgent.create.step1": "Platform Seç",
|
||||
"platformAgent.create.step2": "Cihaz Seç",
|
||||
"platformAgent.create.step3": "Ajanı Yapılandır",
|
||||
"platformAgent.create.title": "Platform Ajanı Ekle",
|
||||
"platformAgent.create.upgradeCmd": "npm install -g @lobehub/cli",
|
||||
"platformAgent.create.versionTooLow": "lh sürümü çok düşük",
|
||||
"platformAgent.create.versionTooLowHint": "lh'yi en son sürüme güncelleyin:",
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
"features.agentSelfIteration.title": "Aracının Kendi Kendine Yinelemesi",
|
||||
"features.assistantMessageGroup.desc": "Temsilci mesajlarını ve bunlara ait araç çağrısı sonuçlarını birlikte gruplayarak görüntüleyin",
|
||||
"features.assistantMessageGroup.title": "Temsilci Mesaj Gruplama",
|
||||
"features.executionDeviceSwitcher.desc": "Heterojen ajan araç çubuğunda yürütme cihazı değiştiricisini göstererek çalıştırmaları bu cihaza, bir bulut sanal alanına veya bağlı bir uzak cihaza yönlendirebilirsiniz.",
|
||||
"features.executionDeviceSwitcher.title": "Yürütme Cihazı Değiştirici",
|
||||
"features.gatewayMode.desc": "Aracı görevlerini yerel olarak çalıştırmak yerine Gateway WebSocket üzerinden sunucuda yürütün. Daha hızlı yürütme sağlar ve istemci kaynak kullanımını azaltır.",
|
||||
"features.gatewayMode.title": "Sunucu Taraflı Aracı Yürütme (Gateway)",
|
||||
"features.groupChat.desc": "Çoklu temsilci grup sohbeti koordinasyonunu etkinleştirin.",
|
||||
@@ -13,5 +15,7 @@
|
||||
"features.imessage.title": "iMessage Kanalı",
|
||||
"features.inputMarkdown.desc": "Girdi alanında Markdown biçimlendirmesini (kalın metin, kod blokları, tablolar vb.) gerçek zamanlı olarak görüntüleyin.",
|
||||
"features.inputMarkdown.title": "Girdi Markdown Görüntüleme",
|
||||
"features.platformAgent.desc": "Oluşturma menüsünde \"Platform Ajanı Ekle\" girişini göster. Platform ajanları (ör. OpenClaw, Hermes) bağlı bir cihazda çalışır ve lh connect aracılığıyla geri iletişim kurar.",
|
||||
"features.platformAgent.title": "Platform Ajanı Oluşturma",
|
||||
"title": "Deneysel Özellikler"
|
||||
}
|
||||
|
||||
@@ -491,8 +491,10 @@
|
||||
"platformAgentConfig.device.none": "Yok",
|
||||
"platformAgentConfig.device.offline": "Çevrimdışı",
|
||||
"platformAgentConfig.device.online": "Çevrimiçi",
|
||||
"platformAgentConfig.platform.label": "Platform",
|
||||
"platformAgentConfig.redetect": "Yeniden Tespit Et",
|
||||
"platformAgentConfig.selectDevice": "Bir cihaz seçin",
|
||||
"platformAgentConfig.title": "Platform Yapılandırması",
|
||||
"plugin.addMCPPlugin": "MCP Ekle",
|
||||
"plugin.addTooltip": "Özel Yetenekler",
|
||||
"plugin.clearDeprecated": "Kullanımdan Kaldırılan Yetenekleri Kaldır",
|
||||
|
||||
@@ -351,6 +351,7 @@
|
||||
"newCodexAgent": "Thêm Codex",
|
||||
"newGroupChat": "Tạo nhóm",
|
||||
"newPage": "Tạo trang",
|
||||
"newPlatformAgent": "Thêm Tác nhân Nền tảng",
|
||||
"noAgentsYet": "Nhóm này chưa có thành viên. Nhấn nút + để mời tác nhân.",
|
||||
"noAvailableAgents": "Không có thành viên nào để mời",
|
||||
"noMatchingAgents": "Không tìm thấy thành viên phù hợp",
|
||||
@@ -376,6 +377,8 @@
|
||||
"platformAgent.create.checkFailed": "Kiểm tra thất bại",
|
||||
"platformAgent.create.checking": "Đang kiểm tra tính khả dụng...",
|
||||
"platformAgent.create.comingSoon": "Sắp ra mắt",
|
||||
"platformAgent.create.create": "Tạo Tác nhân",
|
||||
"platformAgent.create.creating": "Đang tạo...",
|
||||
"platformAgent.create.desc.amp": "Kết nối với Amp đang chạy trên một trong các thiết bị của bạn",
|
||||
"platformAgent.create.desc.hermes": "Kết nối với Hermes đang chạy trên một trong các thiết bị của bạn",
|
||||
"platformAgent.create.desc.openclaw": "Kết nối với OpenClaw đang chạy trên một trong các thiết bị của bạn",
|
||||
@@ -392,8 +395,10 @@
|
||||
"platformAgent.create.notInstalled": "{{name}} chưa được cài đặt trên thiết bị này",
|
||||
"platformAgent.create.refresh": "Làm mới",
|
||||
"platformAgent.create.selectDevice": "Chọn một thiết bị",
|
||||
"platformAgent.create.step1": "Chọn Nền tảng",
|
||||
"platformAgent.create.step2": "Chọn Thiết bị",
|
||||
"platformAgent.create.step3": "Cấu hình Tác nhân",
|
||||
"platformAgent.create.title": "Thêm Tác nhân Nền tảng",
|
||||
"platformAgent.create.upgradeCmd": "npm install -g @lobehub/cli",
|
||||
"platformAgent.create.versionTooLow": "Phiên bản lh quá thấp",
|
||||
"platformAgent.create.versionTooLowHint": "Cập nhật lh lên phiên bản mới nhất:",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user