mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-21 06:29:59 +00:00
Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 17fd96ca5a | |||
| 58a44e5ed3 | |||
| e5adb393b3 | |||
| 434c16c49d | |||
| 52d2306793 | |||
| 330694fe49 | |||
| 0230cd0e7f | |||
| e1ff69bfbe | |||
| 3432707dc9 | |||
| 2cf7b0a824 | |||
| d389e4fe9d | |||
| 02d1bb307c | |||
| b7579279ab | |||
| 3464bba25e | |||
| 6c0649e8a0 | |||
| 1ed0ca305b | |||
| aff3760ec4 | |||
| d378f525a5 | |||
| fd39bbdc1b | |||
| 45d75dbaad | |||
| 3ed3ac02e5 | |||
| de51d13d58 | |||
| 88a7e82ab8 | |||
| 2731937feb | |||
| 25d582827c | |||
| bd034c3aef | |||
| 80a06e047c | |||
| bb3e689053 | |||
| 54ea8f62a2 | |||
| d37ebb2922 | |||
| 2487407192 | |||
| 6de1e14a4d |
+1
-1
@@ -34,7 +34,7 @@ module.exports = defineConfig({
|
||||
markdown: {
|
||||
reference:
|
||||
'You need to maintain the component format of the mdx file; the output text does not need to be wrapped in any code block syntax on the outermost layer.\n' +
|
||||
fs.readFileSync(path.join(__dirname, 'docs/glossary.md'), 'utf8'),
|
||||
fs.readFileSync(path.join(__dirname, 'docs/glossary.mdx'), 'utf8'),
|
||||
entry: ['./README.md', './docs/**/*.md', './docs/**/*.mdx'],
|
||||
entryLocale: 'en-US',
|
||||
outputLocales: ['zh-CN'],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
.\" Code generated by `npm run man:generate`; DO NOT EDIT.
|
||||
.\" Manual command details come from the Commander command tree.
|
||||
.TH LH 1 "" "@lobehub/cli 0.0.32" "User Commands"
|
||||
.TH LH 1 "" "@lobehub/cli 0.0.34" "User Commands"
|
||||
.SH NAME
|
||||
lh \- LobeHub CLI \- manage and connect to LobeHub services
|
||||
.SH SYNOPSIS
|
||||
@@ -41,6 +41,9 @@ Show a manual page for the CLI or a subcommand
|
||||
.B connect
|
||||
Connect to the device gateway and listen for tool calls
|
||||
.TP
|
||||
.B disconnect
|
||||
Disconnect from the device gateway (alias for `connect stop`)
|
||||
.TP
|
||||
.B device
|
||||
Manage connected devices
|
||||
.TP
|
||||
@@ -127,6 +130,9 @@ Manage evaluation workflows
|
||||
.TP
|
||||
.B migrate
|
||||
Migrate data from external tools (OpenClaw, ChatGPT, Claude, etc.)
|
||||
.TP
|
||||
.B update
|
||||
Update the LobeHub CLI to the latest published version
|
||||
.SH OPTIONS
|
||||
.TP
|
||||
.B \-V, \-\-version
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@lobehub/cli",
|
||||
"version": "0.0.32",
|
||||
"version": "0.0.34",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"lh": "./dist/index.js",
|
||||
@@ -37,6 +37,7 @@
|
||||
"@lobechat/tool-runtime": "workspace:*",
|
||||
"@trpc/client": "^11.8.1",
|
||||
"@types/node": "^24.13.2",
|
||||
"@types/semver": "^7.7.1",
|
||||
"@types/ws": "^8.18.1",
|
||||
"commander": "^13.1.0",
|
||||
"dayjs": "^1.11.19",
|
||||
@@ -45,6 +46,7 @@
|
||||
"fast-glob": "^3.3.3",
|
||||
"ignore": "^7.0.5",
|
||||
"picocolors": "^1.1.1",
|
||||
"semver": "^7.7.3",
|
||||
"superjson": "^2.2.6",
|
||||
"tsdown": "^0.21.4",
|
||||
"typescript": "^6.0.3",
|
||||
|
||||
+39
-13
@@ -12,7 +12,8 @@ import { log } from '../utils/logger';
|
||||
export type TrpcClient = ReturnType<typeof createTRPCClient<LambdaRouter>>;
|
||||
export type ToolsTrpcClient = ReturnType<typeof createTRPCClient<ToolsRouter>>;
|
||||
|
||||
let _client: TrpcClient | undefined;
|
||||
const PERSONAL_KEY = '__personal__';
|
||||
const _clients = new Map<string, TrpcClient>();
|
||||
let _toolsClient: ToolsTrpcClient | undefined;
|
||||
|
||||
async function getAuthAndServer() {
|
||||
@@ -53,21 +54,40 @@ async function getAuthAndServer() {
|
||||
};
|
||||
}
|
||||
|
||||
export async function getTrpcClient(): Promise<TrpcClient> {
|
||||
if (_client) return _client;
|
||||
/**
|
||||
* Resolve the workspace scope for outbound tRPC calls.
|
||||
*
|
||||
* Precedence: explicit caller arg → `LOBEHUB_WORKSPACE_ID` env (inherited
|
||||
* from a workspace-dispatched parent process, e.g. openclaw spawned by the
|
||||
* device's `runHeteroTask`) → personal mode. Without this, agentNotify
|
||||
* callbacks on workspace topics would resolve through personal-mode
|
||||
* TopicModel and 404.
|
||||
*/
|
||||
function resolveWorkspaceId(explicit?: string): string | undefined {
|
||||
if (explicit) return explicit;
|
||||
const fromEnv = process.env.LOBEHUB_WORKSPACE_ID;
|
||||
return fromEnv && fromEnv.length > 0 ? fromEnv : undefined;
|
||||
}
|
||||
|
||||
export async function getTrpcClient(workspaceId?: string): Promise<TrpcClient> {
|
||||
const wsId = resolveWorkspaceId(workspaceId);
|
||||
const cacheKey = wsId ?? PERSONAL_KEY;
|
||||
const cached = _clients.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const { headers, serverUrl } = await getAuthAndServer();
|
||||
_client = createTRPCClient<LambdaRouter>({
|
||||
const client = createTRPCClient<LambdaRouter>({
|
||||
links: [
|
||||
httpLink({
|
||||
headers,
|
||||
headers: wsId ? { ...headers, 'X-Workspace-Id': wsId } : headers,
|
||||
transformer: superjson,
|
||||
url: `${serverUrl}/trpc/lambda`,
|
||||
}),
|
||||
],
|
||||
});
|
||||
_clients.set(cacheKey, client);
|
||||
|
||||
return _client;
|
||||
return client;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,13 +97,19 @@ export async function getTrpcClient(): Promise<TrpcClient> {
|
||||
* via env/stored creds and `process.exit(1)` when none exist, which would
|
||||
* abort an otherwise-valid explicit-token session.
|
||||
*/
|
||||
export function createLambdaClient(auth: {
|
||||
serverUrl: string;
|
||||
token: string;
|
||||
tokenType: 'apiKey' | 'jwt' | 'serviceToken';
|
||||
}): TrpcClient {
|
||||
const headers =
|
||||
auth.tokenType === 'apiKey' ? { 'X-API-Key': auth.token } : { 'Oidc-Auth': auth.token };
|
||||
export function createLambdaClient(
|
||||
auth: {
|
||||
serverUrl: string;
|
||||
token: string;
|
||||
tokenType: 'apiKey' | 'jwt' | 'serviceToken';
|
||||
},
|
||||
/** When set, scopes the request to a workspace (e.g. workspace-device enrollment). */
|
||||
workspaceId?: string,
|
||||
): TrpcClient {
|
||||
const headers: Record<string, string> = {
|
||||
...(auth.tokenType === 'apiKey' ? { 'X-API-Key': auth.token } : { 'Oidc-Auth': auth.token }),
|
||||
...(workspaceId ? { 'X-Workspace-Id': workspaceId } : {}),
|
||||
};
|
||||
|
||||
return createTRPCClient<LambdaRouter>({
|
||||
links: [httpLink({ headers, transformer: superjson, url: `${auth.serverUrl}/trpc/lambda` })],
|
||||
|
||||
@@ -18,7 +18,6 @@ import type {
|
||||
import { GatewayClient } from '@lobechat/device-gateway-client';
|
||||
import type { Command } from 'commander';
|
||||
|
||||
import { getValidToken } from '../auth/refresh';
|
||||
import { resolveToken } from '../auth/resolveToken';
|
||||
import { CLI_API_KEY_ENV } from '../constants/auth';
|
||||
import { OFFICIAL_GATEWAY_URL } from '../constants/urls';
|
||||
@@ -34,7 +33,13 @@ import {
|
||||
writeStatus,
|
||||
} from '../daemon/manager';
|
||||
import { spawnHeteroAgentRun } from '../device/agentRun';
|
||||
import { registerDevice, resolveDeviceIdentity } from '../device/register';
|
||||
import {
|
||||
mintWorkspaceConnectToken,
|
||||
registerDevice,
|
||||
registerWorkspaceDevice,
|
||||
resolveDeviceIdentity,
|
||||
resolveWorkspaceDeviceIdentity,
|
||||
} from '../device/register';
|
||||
import { loadOrCreateConnectionId, loadSettings, normalizeUrl, saveSettings } from '../settings';
|
||||
import { executeToolCall } from '../tools';
|
||||
import { cleanupAllProcesses } from '../tools/shell';
|
||||
@@ -47,6 +52,8 @@ interface ConnectOptions {
|
||||
gateway?: string;
|
||||
token?: string;
|
||||
verbose?: boolean;
|
||||
/** Enroll this machine as a device of the given workspace (admin only). */
|
||||
workspace?: string;
|
||||
}
|
||||
|
||||
export function registerConnectCommand(program: Command) {
|
||||
@@ -56,6 +63,7 @@ export function registerConnectCommand(program: Command) {
|
||||
.option('--token <jwt>', 'JWT access token')
|
||||
.option('--gateway <url>', 'Device gateway URL')
|
||||
.option('--device-id <id>', 'Device ID (auto-generated if not provided)')
|
||||
.option('--workspace <id>', 'Enroll as a device of this workspace (admin only)')
|
||||
.option('-v, --verbose', 'Enable verbose logging')
|
||||
.option('-d, --daemon', 'Run as a background daemon process')
|
||||
.option('--daemon-child', 'Internal: runs as the daemon child process')
|
||||
@@ -185,6 +193,7 @@ function buildDaemonArgs(options: ConnectOptions): string[] {
|
||||
if (options.token) args.push('--token', options.token);
|
||||
if (options.gateway) args.push('--gateway', options.gateway);
|
||||
if (options.deviceId) args.push('--device-id', options.deviceId);
|
||||
if (options.workspace) args.push('--workspace', options.workspace);
|
||||
if (options.verbose) args.push('--verbose');
|
||||
|
||||
return args;
|
||||
@@ -209,10 +218,43 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
|
||||
|
||||
const resolvedGatewayUrl = gatewayUrl || OFFICIAL_GATEWAY_URL;
|
||||
|
||||
// Workspace enrollment: the device joins a workspace pool (reachable by all
|
||||
// members) instead of the personal pool. It authenticates with a minted
|
||||
// workspace-device token (carrying the `workspace_id` claim) and uses a
|
||||
// workspace-derived deviceId. `auth` stays the admin's identity — used only to
|
||||
// (re-)mint the connect token and register the row.
|
||||
const workspaceId = options.workspace;
|
||||
|
||||
// Resolve a stable device identity. An explicit `--device-id` wins (lets a
|
||||
// user pin a VM to a fixed identity); otherwise derive from the machine id so
|
||||
// the same machine + user maps to one device across reconnects.
|
||||
const identity = resolveDeviceIdentity(auth.userId, options.deviceId);
|
||||
// the same machine maps to one device across reconnects.
|
||||
const identity = workspaceId
|
||||
? resolveWorkspaceDeviceIdentity(workspaceId, options.deviceId)
|
||||
: resolveDeviceIdentity(auth.userId, options.deviceId);
|
||||
|
||||
// The token the gateway socket authenticates with. Re-minted on refresh for
|
||||
// workspace devices (see `refreshConnectToken`).
|
||||
let connectToken = auth.token;
|
||||
let connectTokenType: 'apiKey' | 'jwt' | 'serviceToken' = auth.tokenType;
|
||||
if (workspaceId) {
|
||||
const minted = await mintWorkspaceConnectToken(auth, workspaceId);
|
||||
connectToken = minted.token;
|
||||
connectTokenType = 'jwt';
|
||||
}
|
||||
|
||||
// Re-resolve the admin auth and, for workspace mode, re-mint the connect token.
|
||||
const refreshConnectToken = async (): Promise<string | undefined> => {
|
||||
const refreshed = await resolveToken({});
|
||||
if (!refreshed) return undefined;
|
||||
auth = refreshed;
|
||||
if (workspaceId) {
|
||||
const minted = await mintWorkspaceConnectToken(auth, workspaceId);
|
||||
connectToken = minted.token;
|
||||
return connectToken;
|
||||
}
|
||||
connectToken = refreshed.token;
|
||||
return connectToken;
|
||||
};
|
||||
|
||||
// Freeform channel label (`cli` by default); `LOBEHUB_CLI_CHANNEL` lets a
|
||||
// dev build tag itself `cli-dev` so the gateway can prioritise / display it.
|
||||
@@ -225,9 +267,10 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
|
||||
gatewayUrl: resolvedGatewayUrl,
|
||||
logger: isDaemonChild ? createDaemonLogger() : log,
|
||||
serverUrl: auth.serverUrl,
|
||||
token: auth.token,
|
||||
tokenType: auth.tokenType,
|
||||
userId: auth.userId,
|
||||
token: connectToken,
|
||||
tokenType: connectTokenType,
|
||||
userId: workspaceId ? undefined : auth.userId,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
const info = (msg: string) => {
|
||||
@@ -376,15 +419,21 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
|
||||
updateStatus('reconnecting');
|
||||
});
|
||||
|
||||
// Proactive token refresh — schedule before JWT expires
|
||||
const startProactiveRefresh = () =>
|
||||
// Proactive token refresh — schedule before the connect token expires. For a
|
||||
// workspace device `refreshConnectToken` re-mints the workspace token; for a
|
||||
// personal device it refreshes the user token. Scheduling watches the actual
|
||||
// connect token, so the workspace token's shorter life is respected.
|
||||
const startProactiveRefresh = (): (() => void) | null =>
|
||||
scheduleProactiveRefresh(
|
||||
auth,
|
||||
(refreshed) => {
|
||||
client.updateToken(refreshed.token);
|
||||
auth = refreshed;
|
||||
// Schedule next refresh based on the new token
|
||||
cancelRefreshTimer = startProactiveRefresh();
|
||||
connectToken,
|
||||
connectTokenType,
|
||||
async () => {
|
||||
const newToken = await refreshConnectToken();
|
||||
if (newToken) {
|
||||
client.updateToken(newToken);
|
||||
cancelRefreshTimer = startProactiveRefresh();
|
||||
}
|
||||
return newToken;
|
||||
},
|
||||
info,
|
||||
error,
|
||||
@@ -395,15 +444,15 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
|
||||
// (e.g., auto-reconnect may send an expired JWT before proactive refresh fires)
|
||||
let authFailedRefreshAttempted = false;
|
||||
client.on('auth_failed', async (reason) => {
|
||||
if (auth.tokenType === 'jwt' && !authFailedRefreshAttempted) {
|
||||
if (connectTokenType === 'jwt' && !authFailedRefreshAttempted) {
|
||||
authFailedRefreshAttempted = true;
|
||||
info(`Authentication failed (${reason}). Attempting token refresh...`);
|
||||
try {
|
||||
const refreshed = await resolveToken({});
|
||||
if (refreshed && refreshed.token !== auth.token) {
|
||||
const prev = connectToken;
|
||||
const newToken = await refreshConnectToken();
|
||||
if (newToken && newToken !== prev) {
|
||||
info('Token refreshed successfully. Reconnecting...');
|
||||
client.updateToken(refreshed.token);
|
||||
auth = refreshed;
|
||||
client.updateToken(newToken);
|
||||
authFailedRefreshAttempted = false;
|
||||
cancelRefreshTimer = startProactiveRefresh();
|
||||
await client.reconnect();
|
||||
@@ -424,7 +473,7 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
|
||||
|
||||
// Handle auth expired — refresh token and reconnect automatically
|
||||
client.on('auth_expired', async () => {
|
||||
if (auth.tokenType === 'apiKey') {
|
||||
if (connectTokenType === 'apiKey') {
|
||||
// API keys don't expire; ignore stale auth_expired signals
|
||||
return;
|
||||
}
|
||||
@@ -432,11 +481,10 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
|
||||
info('Authentication expired. Attempting to refresh token...');
|
||||
|
||||
try {
|
||||
const refreshed = await resolveToken({});
|
||||
if (refreshed) {
|
||||
const newToken = await refreshConnectToken();
|
||||
if (newToken) {
|
||||
info('Token refreshed successfully. Reconnecting...');
|
||||
client.updateToken(refreshed.token);
|
||||
auth = refreshed;
|
||||
client.updateToken(newToken);
|
||||
cancelRefreshTimer = startProactiveRefresh();
|
||||
await client.reconnect();
|
||||
return;
|
||||
@@ -486,7 +534,8 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
|
||||
try {
|
||||
// Reuse the already-resolved auth (respects `--token` mode) so we don't
|
||||
// re-discover creds and exit when none are found.
|
||||
await registerDevice(auth, identity);
|
||||
if (workspaceId) await registerWorkspaceDevice(auth, identity, workspaceId);
|
||||
else await registerDevice(auth, identity);
|
||||
} catch (err) {
|
||||
error(`Device registration failed (non-fatal): ${(err as Error).message}`);
|
||||
}
|
||||
@@ -534,47 +583,49 @@ function parseJwtExp(token: string): number | undefined {
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a proactive token refresh before the JWT expires.
|
||||
* Returns a cleanup function that cancels the scheduled timer.
|
||||
* Schedule a proactive token refresh before the (connect) token expires.
|
||||
* `refresh` performs the actual refresh — re-minting a workspace token or
|
||||
* refreshing the user token — and returns the new token. Returns a cleanup
|
||||
* function that cancels the scheduled timer.
|
||||
*/
|
||||
function scheduleProactiveRefresh(
|
||||
auth: { token: string; tokenType: string },
|
||||
onRefreshed: (newAuth: Awaited<ReturnType<typeof resolveToken>>) => void,
|
||||
token: string,
|
||||
tokenType: string,
|
||||
refresh: () => Promise<string | undefined>,
|
||||
info: (msg: string) => void,
|
||||
error: (msg: string) => void,
|
||||
): (() => void) | null {
|
||||
if (auth.tokenType !== 'jwt') return null;
|
||||
if (tokenType !== 'jwt') return null;
|
||||
|
||||
const exp = parseJwtExp(auth.token);
|
||||
const exp = parseJwtExp(token);
|
||||
if (!exp) return null;
|
||||
|
||||
const refreshAt = (exp - PROACTIVE_REFRESH_BUFFER) * 1000;
|
||||
const delay = refreshAt - Date.now();
|
||||
|
||||
if (delay < 0) {
|
||||
// Already past the refresh window — refresh immediately on next tick
|
||||
const lifetimeMs = exp * 1000 - Date.now();
|
||||
if (lifetimeMs <= 0) {
|
||||
// Token already expired — refresh once on next tick.
|
||||
void doRefresh();
|
||||
return null;
|
||||
}
|
||||
|
||||
// Refresh ahead of expiry, but never let the buffer meet or exceed the token's
|
||||
// remaining lifetime: a buffer >= lifetime collapses the refresh window to <=0
|
||||
// and busy-loops re-minting (e.g. a 1h token with a 1h buffer). Cap the buffer
|
||||
// at half the remaining lifetime so a short-lived token refreshes about once per
|
||||
// half-life instead of spinning.
|
||||
const bufferMs = Math.min(PROACTIVE_REFRESH_BUFFER * 1000, lifetimeMs / 2);
|
||||
const delay = lifetimeMs - bufferMs;
|
||||
|
||||
const timer = setTimeout(() => void doRefresh(), delay);
|
||||
return () => clearTimeout(timer);
|
||||
|
||||
async function doRefresh() {
|
||||
try {
|
||||
// Use the same buffer so getValidToken actually triggers a refresh
|
||||
const result = await getValidToken(PROACTIVE_REFRESH_BUFFER);
|
||||
if (!result) {
|
||||
const newToken = await refresh();
|
||||
if (!newToken) {
|
||||
error('Proactive token refresh failed — no valid credentials.');
|
||||
return;
|
||||
}
|
||||
|
||||
const refreshed = await resolveToken({});
|
||||
// Only notify if the token actually changed to avoid reschedule loops
|
||||
if (refreshed.token !== auth.token) {
|
||||
info('Proactively refreshed token.');
|
||||
onRefreshed(refreshed);
|
||||
}
|
||||
if (newToken !== token) info('Proactively refreshed token.');
|
||||
} catch {
|
||||
error('Proactive token refresh failed.');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildInstallCommand, isNewerVersion } from './update';
|
||||
|
||||
describe('isNewerVersion', () => {
|
||||
it('compares core versions', () => {
|
||||
expect(isNewerVersion('1.2.3', '1.2.2')).toBe(true);
|
||||
expect(isNewerVersion('1.2.2', '1.2.3')).toBe(false);
|
||||
expect(isNewerVersion('1.2.3', '1.2.3')).toBe(false);
|
||||
expect(isNewerVersion('2.0.0', '1.9.9')).toBe(true);
|
||||
});
|
||||
|
||||
it('tolerates a leading v and missing segments', () => {
|
||||
expect(isNewerVersion('v1.2.0', '1.2.0')).toBe(false);
|
||||
expect(isNewerVersion('1.2', '1.2.0')).toBe(false);
|
||||
expect(isNewerVersion('1.3', '1.2.9')).toBe(true);
|
||||
});
|
||||
|
||||
it('ranks a stable release above a prerelease of the same core', () => {
|
||||
expect(isNewerVersion('1.2.3', '1.2.3-beta.1')).toBe(true);
|
||||
expect(isNewerVersion('1.2.3-beta.1', '1.2.3')).toBe(false);
|
||||
expect(isNewerVersion('1.2.3-beta.2', '1.2.3-beta.1')).toBe(true);
|
||||
expect(isNewerVersion('1.2.3-beta.1', '1.2.3-beta.1')).toBe(false);
|
||||
});
|
||||
|
||||
it('orders numeric prerelease identifiers numerically, not lexicographically', () => {
|
||||
// The bug a raw string compare gets wrong: beta.10 must outrank beta.9.
|
||||
expect(isNewerVersion('1.0.0-beta.10', '1.0.0-beta.9')).toBe(true);
|
||||
expect(isNewerVersion('1.0.0-beta.9', '1.0.0-beta.10')).toBe(false);
|
||||
expect(isNewerVersion('1.0.0-beta.2', '1.0.0-beta.10')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for an unparseable latest version', () => {
|
||||
expect(isNewerVersion('not-a-version', '1.0.0')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildInstallCommand', () => {
|
||||
it('builds the global install command per package manager', () => {
|
||||
expect(buildInstallCommand('npm', '@lobehub/cli@1.0.0')).toEqual({
|
||||
args: ['install', '-g', '@lobehub/cli@1.0.0'],
|
||||
command: 'npm',
|
||||
});
|
||||
expect(buildInstallCommand('pnpm', '@lobehub/cli@1.0.0')).toEqual({
|
||||
args: ['add', '-g', '@lobehub/cli@1.0.0'],
|
||||
command: 'pnpm',
|
||||
});
|
||||
expect(buildInstallCommand('bun', '@lobehub/cli@1.0.0')).toEqual({
|
||||
args: ['add', '-g', '@lobehub/cli@1.0.0'],
|
||||
command: 'bun',
|
||||
});
|
||||
expect(buildInstallCommand('yarn', '@lobehub/cli@1.0.0')).toEqual({
|
||||
args: ['global', 'add', '@lobehub/cli@1.0.0'],
|
||||
command: 'yarn',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { realpathSync } from 'node:fs';
|
||||
|
||||
import type { Command } from 'commander';
|
||||
import pc from 'picocolors';
|
||||
import semver from 'semver';
|
||||
|
||||
// Pull package metadata from the shared `src/pkg.ts` module (resolved at the
|
||||
// bundled entry's depth) rather than a local `require('../../package.json')`,
|
||||
// which would point outside the package once bundled into dist/index.js.
|
||||
import { cliPackageName, cliVersion } from '../pkg';
|
||||
import { log } from '../utils/logger';
|
||||
|
||||
export type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun';
|
||||
|
||||
const PACKAGE_MANAGERS: PackageManager[] = ['npm', 'pnpm', 'yarn', 'bun'];
|
||||
|
||||
interface UpdateOptions {
|
||||
check?: boolean;
|
||||
packageManager?: PackageManager;
|
||||
tag?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect which package manager installed the CLI so we run the matching global
|
||||
* upgrade command. We first trust an explicit `npm_config_user_agent` (set when
|
||||
* invoked through a package-manager script) and otherwise infer from the path of
|
||||
* the running binary. Falls back to npm.
|
||||
*/
|
||||
export function detectPackageManager(): PackageManager {
|
||||
const ua = process.env.npm_config_user_agent;
|
||||
if (ua) {
|
||||
if (ua.startsWith('pnpm')) return 'pnpm';
|
||||
if (ua.startsWith('yarn')) return 'yarn';
|
||||
if (ua.startsWith('bun')) return 'bun';
|
||||
if (ua.startsWith('npm')) return 'npm';
|
||||
}
|
||||
|
||||
try {
|
||||
const binPath = realpathSync(process.argv[1] ?? '').replaceAll('\\', '/');
|
||||
if (binPath.includes('/pnpm/')) return 'pnpm';
|
||||
if (binPath.includes('/.bun/') || binPath.includes('/bun/')) return 'bun';
|
||||
if (binPath.includes('/yarn/') || binPath.includes('/.yarn/')) return 'yarn';
|
||||
} catch {
|
||||
// ignore – fall back to npm
|
||||
}
|
||||
|
||||
return 'npm';
|
||||
}
|
||||
|
||||
/** Build the global-install command for the detected package manager. */
|
||||
export function buildInstallCommand(
|
||||
pm: PackageManager,
|
||||
spec: string,
|
||||
): { args: string[]; command: string } {
|
||||
switch (pm) {
|
||||
case 'pnpm': {
|
||||
return { args: ['add', '-g', spec], command: 'pnpm' };
|
||||
}
|
||||
case 'yarn': {
|
||||
return { args: ['global', 'add', spec], command: 'yarn' };
|
||||
}
|
||||
case 'bun': {
|
||||
return { args: ['add', '-g', spec], command: 'bun' };
|
||||
}
|
||||
default: {
|
||||
return { args: ['install', '-g', spec], command: 'npm' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `latest` is a newer version than `current`. Delegates to `semver` so
|
||||
* prerelease identifiers order correctly (e.g. `1.0.0-beta.10` > `1.0.0-beta.9`,
|
||||
* which a lexicographic compare gets wrong). Tolerates a leading `v` and missing
|
||||
* segments via coercion; an unparseable `latest` is treated as "not newer".
|
||||
*/
|
||||
export function isNewerVersion(latest: string, current: string): boolean {
|
||||
const latestParsed = semver.coerce(latest, { includePrerelease: true }) ?? semver.parse(latest);
|
||||
const currentParsed =
|
||||
semver.coerce(current, { includePrerelease: true }) ?? semver.parse(current);
|
||||
if (!latestParsed || !currentParsed) return false;
|
||||
return semver.gt(latestParsed, currentParsed);
|
||||
}
|
||||
|
||||
async function fetchLatestVersion(name: string, tag: string): Promise<string> {
|
||||
const url = `https://registry.npmjs.org/${name}/${encodeURIComponent(tag)}`;
|
||||
const res = await fetch(url, { headers: { accept: 'application/json' } });
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`npm registry returned status ${res.status} for tag "${tag}"`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as { version?: string };
|
||||
if (!data.version) {
|
||||
throw new Error('npm registry response is missing the "version" field');
|
||||
}
|
||||
|
||||
return data.version;
|
||||
}
|
||||
|
||||
function runInstall(command: string, args: string[]): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
shell: process.platform === 'win32',
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
child.on('error', reject);
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`${command} exited with code ${code ?? 'null'}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function registerUpdateCommand(program: Command) {
|
||||
program
|
||||
.command('update')
|
||||
.description('Update the LobeHub CLI to the latest published version')
|
||||
.option('--check', 'Only check for a newer version without installing')
|
||||
.option('--tag <tag>', 'npm dist-tag to update to', 'latest')
|
||||
.option(
|
||||
'--package-manager <pm>',
|
||||
`Force a package manager (${PACKAGE_MANAGERS.join(', ')}) instead of auto-detecting`,
|
||||
)
|
||||
.action(async (options: UpdateOptions) => {
|
||||
if (options.packageManager && !PACKAGE_MANAGERS.includes(options.packageManager)) {
|
||||
log.error(
|
||||
`Unsupported package manager "${options.packageManager}". Use one of: ${PACKAGE_MANAGERS.join(', ')}.`,
|
||||
);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const current = cliVersion;
|
||||
const tag = options.tag || 'latest';
|
||||
|
||||
log.info(`Current version: ${pc.bold(current)}`);
|
||||
|
||||
let latest: string;
|
||||
try {
|
||||
latest = await fetchLatestVersion(cliPackageName, tag);
|
||||
} catch (error) {
|
||||
log.error(`Unable to check for updates: ${(error as Error).message}`);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
log.info(`Latest version: ${pc.bold(latest)} ${pc.dim(`(${tag})`)}`);
|
||||
|
||||
if (!isNewerVersion(latest, current)) {
|
||||
log.info(pc.green('Already on the latest version.'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.check) {
|
||||
log.info(
|
||||
`Update available: ${current} → ${pc.green(latest)}. Run ${pc.cyan('lh update')} to upgrade.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const pm = options.packageManager || detectPackageManager();
|
||||
const spec = `${cliPackageName}@${latest}`;
|
||||
const { args, command } = buildInstallCommand(pm, spec);
|
||||
|
||||
log.info(`Upgrading via ${pc.bold(pm)}: ${pc.dim([command, ...args].join(' '))}`);
|
||||
|
||||
try {
|
||||
await runInstall(command, args);
|
||||
log.info(pc.green(`Successfully updated to ${latest}. Restart any running sessions.`));
|
||||
} catch (error) {
|
||||
log.error(`Update failed: ${(error as Error).message}`);
|
||||
log.error(`You can upgrade manually: ${[command, ...args].join(' ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -88,3 +88,45 @@ describe('verify rubric config commands', () => {
|
||||
expect(printed).toContain('4');
|
||||
});
|
||||
});
|
||||
|
||||
describe('verify evidence upload command', () => {
|
||||
let exitSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockGetTrpcClient.mockReset();
|
||||
exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
|
||||
throw new Error(`process.exit ${code}`);
|
||||
}) as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
exitSpy.mockRestore();
|
||||
});
|
||||
|
||||
const run = async (args: string[]) => {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerVerifyCommand(program);
|
||||
await program.parseAsync(['node', 'lh', 'verify', ...args]);
|
||||
};
|
||||
|
||||
it('rejects evidence with both file and inline content', async () => {
|
||||
await expect(
|
||||
run([
|
||||
'evidence',
|
||||
'upload',
|
||||
'--check',
|
||||
'result-1',
|
||||
'--type',
|
||||
'text',
|
||||
'--file',
|
||||
'artifact.txt',
|
||||
'--content',
|
||||
'inline payload',
|
||||
]),
|
||||
).rejects.toThrow('process.exit 1');
|
||||
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
expect(mockGetTrpcClient).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
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';
|
||||
import { uploadLocalFile } from '../utils/uploadLocalFile';
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────
|
||||
|
||||
@@ -32,6 +36,36 @@ function assertEnum<T extends string>(value: T | undefined, allowed: T[], flag:
|
||||
}
|
||||
}
|
||||
|
||||
type Verdict = 'failed' | 'passed' | 'uncertain';
|
||||
type EvidenceType = 'dom_snapshot' | 'gif' | 'screenshot' | 'text' | 'transcript' | 'video';
|
||||
|
||||
/** Map a free-form case/summary result token onto the verify verdict vocabulary. */
|
||||
function toVerdict(raw: unknown): Verdict {
|
||||
const s = String(raw ?? '').toLowerCase();
|
||||
if (['pass', 'passed', 'ok', 'success'].includes(s)) return 'passed';
|
||||
if (['fail', 'failed', 'error'].includes(s)) return 'failed';
|
||||
return 'uncertain'; // partial / blocked / skipped / pending / unknown
|
||||
}
|
||||
|
||||
/** Pick an evidence medium from a file extension. */
|
||||
function evidenceTypeForFile(file: string): EvidenceType {
|
||||
const ext = path.extname(file).toLowerCase().slice(1);
|
||||
if (ext === 'gif') return 'gif';
|
||||
if (['png', 'jpg', 'jpeg', 'webp', 'svg', 'bmp'].includes(ext)) return 'screenshot';
|
||||
if (['mp4', 'webm', 'mov', 'm4v'].includes(ext)) return 'video';
|
||||
if (['html', 'htm'].includes(ext)) return 'dom_snapshot';
|
||||
return 'text';
|
||||
}
|
||||
|
||||
/** Normalize a case's `evidence` field (string | string[] | {path}[]) to path strings. */
|
||||
function evidencePaths(evidence: unknown): string[] {
|
||||
if (!evidence) return [];
|
||||
const arr = Array.isArray(evidence) ? evidence : [evidence];
|
||||
return arr
|
||||
.map((e) => (typeof e === 'string' ? e : (e?.path ?? e?.file)))
|
||||
.filter((p): p is string => typeof p === 'string' && p.length > 0);
|
||||
}
|
||||
|
||||
// ── Command Registration ───────────────────────────────────
|
||||
|
||||
export function registerVerifyCommand(program: Command) {
|
||||
@@ -368,9 +402,9 @@ export function registerVerifyCommand(program: Command) {
|
||||
console.log(`${pc.green('✓')} Skipped verification for run ${pc.bold(operationId)}`);
|
||||
});
|
||||
|
||||
// ════════════ run / results ════════════
|
||||
// ════════════ execute (agent path) ════════════
|
||||
verify
|
||||
.command('run <operationId>')
|
||||
.command('execute <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')
|
||||
@@ -406,13 +440,147 @@ export function registerVerifyCommand(program: Command) {
|
||||
},
|
||||
);
|
||||
|
||||
verify
|
||||
.command('results <operationId>')
|
||||
.description('List check results for a run')
|
||||
// ════════════ run (verification session entity) ════════════
|
||||
const run = verify.command('run').description('Verification sessions (verify_runs)');
|
||||
|
||||
run
|
||||
.command('create')
|
||||
.description('Create a standalone verification session')
|
||||
.option('--source <source>', 'agent | agent-testing', 'agent-testing')
|
||||
.option('--operation <id>', 'Link to an existing Agent Run')
|
||||
.option('--title <title>', 'Session title')
|
||||
.option('--goal <goal>', 'Goal/task being verified')
|
||||
.option('--json [fields]', 'Output JSON')
|
||||
.action(async (operationId: string, options: { json?: boolean | string }) => {
|
||||
.action(
|
||||
async (options: {
|
||||
goal?: string;
|
||||
json?: boolean | string;
|
||||
operation?: string;
|
||||
source?: string;
|
||||
title?: string;
|
||||
}) => {
|
||||
const client = await getTrpcClient();
|
||||
const created = await client.verify.createRun.mutate({
|
||||
goal: options.goal,
|
||||
operationId: options.operation,
|
||||
source: options.source as any,
|
||||
title: options.title,
|
||||
});
|
||||
if (options.json !== undefined) {
|
||||
outputJson(created, typeof options.json === 'string' ? options.json : undefined);
|
||||
return;
|
||||
}
|
||||
console.log(`${pc.green('✓')} Created run ${pc.bold(created.id)}`);
|
||||
},
|
||||
);
|
||||
|
||||
run
|
||||
.command('list')
|
||||
.description('List recent verification sessions')
|
||||
.option('--json [fields]', 'Output JSON')
|
||||
.action(async (options: { json?: boolean | string }) => {
|
||||
const client = await getTrpcClient();
|
||||
const results = await client.verify.listResults.query({ operationId });
|
||||
const runs = await client.verify.listRuns.query();
|
||||
if (options.json !== undefined) {
|
||||
outputJson(runs, typeof options.json === 'string' ? options.json : undefined);
|
||||
return;
|
||||
}
|
||||
if (runs.length === 0) return void console.log('No runs found.');
|
||||
printTable(
|
||||
runs.map((r: any) => [
|
||||
r.id,
|
||||
truncate(r.title || '', 40),
|
||||
r.source,
|
||||
r.status ?? '',
|
||||
r.operationId ? 'agent' : 'standalone',
|
||||
r.createdAt ? timeAgo(r.createdAt) : '',
|
||||
]),
|
||||
['ID', 'TITLE', 'SOURCE', 'STATUS', 'KIND', 'CREATED'],
|
||||
);
|
||||
});
|
||||
|
||||
run
|
||||
.command('get <runId>')
|
||||
.description('Show a verification session')
|
||||
.option('--json [fields]', 'Output JSON')
|
||||
.action(async (runId: string, options: { json?: boolean | string }) => {
|
||||
const client = await getTrpcClient();
|
||||
const item = await client.verify.getRun.query({ verifyRunId: runId });
|
||||
if (options.json !== undefined) {
|
||||
outputJson(item, typeof options.json === 'string' ? options.json : undefined);
|
||||
return;
|
||||
}
|
||||
if (!item) return void console.log('Run not found.');
|
||||
console.log(JSON.stringify(item, null, 2));
|
||||
});
|
||||
|
||||
// ════════════ result (check result entity) ════════════
|
||||
const result = verify.command('result').description('Check results (verify_check_results)');
|
||||
|
||||
result
|
||||
.command('ingest')
|
||||
.description('Upsert one check result by (run, checkItemId) from a supplied verdict')
|
||||
.requiredOption('--run <verifyRunId>', 'Target session id')
|
||||
.requiredOption('--check <checkItemId>', 'Stable check item id within the session')
|
||||
.requiredOption('--verdict <verdict>', 'passed|failed|uncertain')
|
||||
.option('--title <title>', 'Check title')
|
||||
.option('--index <n>', 'Display index')
|
||||
.option('--confidence <n>', '0-1 confidence')
|
||||
.option('--status <status>', 'pending|running|passed|failed|skipped (derived from verdict)')
|
||||
.option('--evidence <text>', 'Key observation (stored as Toulmin evidence)')
|
||||
.option('--suggestion <text>', 'Remediation hint')
|
||||
.option('--soft', 'Non-blocking (required=false); defaults to blocking')
|
||||
.option('--json [fields]', 'Output JSON')
|
||||
.action(
|
||||
async (options: {
|
||||
check: string;
|
||||
confidence?: string;
|
||||
evidence?: string;
|
||||
index?: string;
|
||||
json?: boolean | string;
|
||||
run: string;
|
||||
soft?: boolean;
|
||||
status?: string;
|
||||
suggestion?: string;
|
||||
title?: string;
|
||||
verdict: string;
|
||||
}) => {
|
||||
const client = await getTrpcClient();
|
||||
const created = await client.verify.ingestResult.mutate({
|
||||
checkItemId: options.check,
|
||||
checkItemIndex: options.index ? Number.parseInt(options.index, 10) : undefined,
|
||||
checkItemTitle: options.title,
|
||||
confidence: options.confidence ? Number.parseFloat(options.confidence) : undefined,
|
||||
required: options.soft ? false : undefined,
|
||||
status: options.status as any,
|
||||
suggestion: options.suggestion,
|
||||
toulmin: options.evidence ? { evidence: options.evidence } : undefined,
|
||||
verdict: options.verdict as any,
|
||||
verifyRunId: options.run,
|
||||
});
|
||||
if (options.json !== undefined) {
|
||||
outputJson(created, typeof options.json === 'string' ? options.json : undefined);
|
||||
return;
|
||||
}
|
||||
console.log(`${pc.green('✓')} Result ${pc.bold(created.id)} (${created.verdict})`);
|
||||
},
|
||||
);
|
||||
|
||||
result
|
||||
.command('list')
|
||||
.description('List check results — by session (--run) or by Agent Run (--operation)')
|
||||
.option('--run <verifyRunId>', 'List by verification session')
|
||||
.option('--operation <operationId>', 'List by Agent Run')
|
||||
.option('--json [fields]', 'Output JSON')
|
||||
.action(async (options: { json?: boolean | string; operation?: string; run?: string }) => {
|
||||
if (!options.run && !options.operation) {
|
||||
log.error('Provide either --run or --operation');
|
||||
process.exit(1);
|
||||
}
|
||||
const client = await getTrpcClient();
|
||||
const results = options.run
|
||||
? await client.verify.listResultsByRun.query({ verifyRunId: options.run })
|
||||
: await client.verify.listResults.query({ operationId: options.operation! });
|
||||
if (options.json !== undefined) {
|
||||
outputJson(results, typeof options.json === 'string' ? options.json : undefined);
|
||||
return;
|
||||
@@ -421,6 +589,143 @@ export function registerVerifyCommand(program: Command) {
|
||||
printResults(results);
|
||||
});
|
||||
|
||||
// ════════════ evidence (artifact entity) ════════════
|
||||
const evidence = verify.command('evidence').description('Evidence artifacts (verify_evidence)');
|
||||
|
||||
evidence
|
||||
.command('upload')
|
||||
.description('Attach an evidence artifact (file or inline text) to a check result')
|
||||
.requiredOption('--check <checkResultId>', 'Target check result id')
|
||||
.requiredOption('--type <type>', 'screenshot|gif|video|text|dom_snapshot|transcript')
|
||||
.option('--file <path>', 'Local file to upload as the artifact')
|
||||
.option('--content <text>', 'Inline text payload (instead of a file)')
|
||||
.option('--by <capturedBy>', 'agent-browser|cdp|cli|program|llm_judge', 'cli')
|
||||
.option('--desc <text>', 'Human-readable caption')
|
||||
.option('--json [fields]', 'Output JSON')
|
||||
.action(
|
||||
async (options: {
|
||||
by?: string;
|
||||
check: string;
|
||||
content?: string;
|
||||
desc?: string;
|
||||
file?: string;
|
||||
json?: boolean | string;
|
||||
type: string;
|
||||
}) => {
|
||||
if (Boolean(options.file) === Boolean(options.content)) {
|
||||
log.error('Provide exactly one of --file or --content');
|
||||
process.exit(1);
|
||||
}
|
||||
const client = await getTrpcClient();
|
||||
let fileId: string | undefined;
|
||||
if (options.file) {
|
||||
const uploaded = await uploadLocalFile(client, options.file);
|
||||
fileId = uploaded.id;
|
||||
}
|
||||
const ev = await client.verify.uploadEvidence.mutate({
|
||||
capturedBy: options.by as any,
|
||||
checkResultId: options.check,
|
||||
content: options.content,
|
||||
description: options.desc,
|
||||
fileId,
|
||||
type: options.type as any,
|
||||
});
|
||||
if (options.json !== undefined) {
|
||||
outputJson(ev, typeof options.json === 'string' ? options.json : undefined);
|
||||
return;
|
||||
}
|
||||
console.log(
|
||||
`${pc.green('✓')} Evidence ${pc.bold(ev.id)}${fileId ? ` (file ${fileId})` : ''}`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
evidence
|
||||
.command('list <checkResultId>')
|
||||
.description('List evidence for a check result')
|
||||
.option('--json [fields]', 'Output JSON')
|
||||
.action(async (checkResultId: string, options: { json?: boolean | string }) => {
|
||||
const client = await getTrpcClient();
|
||||
const rows = await client.verify.listEvidence.query({ checkResultId });
|
||||
if (options.json !== undefined) {
|
||||
outputJson(rows, typeof options.json === 'string' ? options.json : undefined);
|
||||
return;
|
||||
}
|
||||
if (rows.length === 0) return void console.log('No evidence.');
|
||||
printTable(
|
||||
rows.map((e: any) => [
|
||||
e.id,
|
||||
e.type,
|
||||
e.capturedBy ?? '',
|
||||
e.fileId ? 'file' : 'inline',
|
||||
truncate(e.description || '', 40),
|
||||
]),
|
||||
['ID', 'TYPE', 'BY', 'PAYLOAD', 'DESC'],
|
||||
);
|
||||
});
|
||||
|
||||
// ════════════ report (narrative entity) ════════════
|
||||
const report = verify.command('report').description('Verification reports (verify_reports)');
|
||||
|
||||
report
|
||||
.command('upsert')
|
||||
.description('Write (overwrite) the report for a session')
|
||||
.requiredOption('--run <verifyRunId>', 'Target session id')
|
||||
.option('--verdict <verdict>', 'passed|failed|uncertain')
|
||||
.option('--summary <text>', 'Short summary')
|
||||
.option('--content <markdown>', 'Full markdown body')
|
||||
.option('--total <n>', 'Total checks')
|
||||
.option('--passed <n>', 'Passed checks')
|
||||
.option('--failed <n>', 'Failed checks')
|
||||
.option('--uncertain <n>', 'Uncertain checks')
|
||||
.option('--json [fields]', 'Output JSON')
|
||||
.action(
|
||||
async (options: {
|
||||
content?: string;
|
||||
failed?: string;
|
||||
json?: boolean | string;
|
||||
passed?: string;
|
||||
run: string;
|
||||
summary?: string;
|
||||
total?: string;
|
||||
uncertain?: string;
|
||||
verdict?: string;
|
||||
}) => {
|
||||
const num = (s?: string) => (s === undefined ? undefined : Number.parseInt(s, 10));
|
||||
const client = await getTrpcClient();
|
||||
const created = await client.verify.upsertReport.mutate({
|
||||
content: options.content,
|
||||
failedChecks: num(options.failed),
|
||||
passedChecks: num(options.passed),
|
||||
summary: options.summary,
|
||||
totalChecks: num(options.total),
|
||||
uncertainChecks: num(options.uncertain),
|
||||
verdict: options.verdict as any,
|
||||
verifyRunId: options.run,
|
||||
});
|
||||
if (options.json !== undefined) {
|
||||
outputJson(created, typeof options.json === 'string' ? options.json : undefined);
|
||||
return;
|
||||
}
|
||||
console.log(`${pc.green('✓')} Report ${pc.bold(created.id)} (${created.verdict ?? '—'})`);
|
||||
},
|
||||
);
|
||||
|
||||
report
|
||||
.command('get <runId>')
|
||||
.description('Show the report for a session')
|
||||
.option('--json [fields]', 'Output JSON')
|
||||
.action(async (runId: string, options: { json?: boolean | string }) => {
|
||||
const client = await getTrpcClient();
|
||||
const item = await client.verify.getReport.query({ verifyRunId: runId });
|
||||
if (options.json !== undefined) {
|
||||
outputJson(item, typeof options.json === 'string' ? options.json : undefined);
|
||||
return;
|
||||
}
|
||||
if (!item) return void console.log('No report.');
|
||||
console.log(JSON.stringify(item, null, 2));
|
||||
});
|
||||
|
||||
// ════════════ feedback ════════════
|
||||
verify
|
||||
.command('decision <resultId> <decision>')
|
||||
@@ -431,6 +736,128 @@ export function registerVerifyCommand(program: Command) {
|
||||
await client.verify.submitDecision.mutate({ decision, resultId });
|
||||
console.log(`${pc.green('✓')} Recorded ${pc.bold(decision)} on result ${pc.bold(resultId)}`);
|
||||
});
|
||||
|
||||
// ════════════ ingest (aggregate convenience over the atomic commands) ════════════
|
||||
verify
|
||||
.command('ingest-report <reportDir>')
|
||||
.description(
|
||||
'Ingest a local agent-testing report (result.json + report.md + assets) as a verify session',
|
||||
)
|
||||
.option('--source <source>', 'agent | agent-testing', 'agent-testing')
|
||||
.option('--operation <id>', 'Link the session to an existing Agent Run')
|
||||
.option('--title <title>', 'Override the session title')
|
||||
.option('--goal <goal>', 'The goal/task being verified')
|
||||
.option('--open', 'Print the in-app URL to open the report')
|
||||
.option('--json [fields]', 'Output JSON')
|
||||
.action(
|
||||
async (
|
||||
reportDir: string,
|
||||
options: {
|
||||
goal?: string;
|
||||
json?: boolean | string;
|
||||
open?: boolean;
|
||||
operation?: string;
|
||||
source?: string;
|
||||
title?: string;
|
||||
},
|
||||
) => {
|
||||
const dir = path.resolve(reportDir);
|
||||
const resultPath = path.join(dir, 'result.json');
|
||||
if (!existsSync(resultPath)) {
|
||||
log.error(`result.json not found in ${dir}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let result: any;
|
||||
try {
|
||||
result = JSON.parse(readFileSync(resultPath, 'utf8'));
|
||||
} catch {
|
||||
log.error('result.json is not valid JSON');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const cases: any[] = Array.isArray(result.cases) ? result.cases : [];
|
||||
const summary = result.summary ?? {};
|
||||
const reportMdPath = path.join(dir, 'report.md');
|
||||
const content = existsSync(reportMdPath) ? readFileSync(reportMdPath, 'utf8') : undefined;
|
||||
|
||||
const client = await getTrpcClient();
|
||||
|
||||
// 1. Create the verification session.
|
||||
const run = await client.verify.createRun.mutate({
|
||||
goal: options.goal,
|
||||
operationId: options.operation,
|
||||
source: options.source as any,
|
||||
title: options.title ?? result.title,
|
||||
});
|
||||
|
||||
// 2. Ingest each case as a check result + its evidence.
|
||||
let uploaded = 0;
|
||||
for (const [index, c] of cases.entries()) {
|
||||
const checkItemId = String(c.id ?? c.checkItemId ?? `case-${index + 1}`);
|
||||
const verdict = toVerdict(c.result ?? c.status ?? c.verdict);
|
||||
const observation = c.keyObservation ?? c.observation ?? c.note;
|
||||
const checkResult = await client.verify.ingestResult.mutate({
|
||||
checkItemId,
|
||||
checkItemIndex: index,
|
||||
checkItemTitle: c.name ?? c.case ?? c.title ?? checkItemId,
|
||||
required: c.required ?? true,
|
||||
// The case's key observation is recorded as Toulmin evidence; a real
|
||||
// remediation hint (if the report provides one) goes to `suggestion`.
|
||||
suggestion: typeof c.suggestion === 'string' ? c.suggestion : undefined,
|
||||
toulmin: typeof observation === 'string' ? { evidence: observation } : undefined,
|
||||
verdict,
|
||||
verifierType: 'agent',
|
||||
verifyRunId: run.id,
|
||||
});
|
||||
|
||||
for (const rel of evidencePaths(c.evidence)) {
|
||||
const abs = path.isAbsolute(rel) ? rel : path.join(dir, rel);
|
||||
if (!existsSync(abs)) {
|
||||
log.warn(`evidence not found, skipping: ${rel}`);
|
||||
continue;
|
||||
}
|
||||
const file = await uploadLocalFile(client, abs);
|
||||
await client.verify.uploadEvidence.mutate({
|
||||
capturedBy: 'cli',
|
||||
checkResultId: checkResult.id,
|
||||
description: c.name ?? path.basename(abs),
|
||||
fileId: file.id,
|
||||
type: evidenceTypeForFile(abs),
|
||||
});
|
||||
uploaded += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Write the report (full markdown + stats snapshot).
|
||||
await client.verify.upsertReport.mutate({
|
||||
content,
|
||||
failedChecks: summary.failed,
|
||||
passedChecks: summary.passed,
|
||||
summary: typeof summary.note === 'string' ? summary.note : undefined,
|
||||
totalChecks: summary.total ?? cases.length,
|
||||
uncertainChecks: (summary.blocked ?? 0) + (summary.uncertain ?? 0) || undefined,
|
||||
verdict: summary.verdict ? toVerdict(summary.verdict) : undefined,
|
||||
verifyRunId: run.id,
|
||||
});
|
||||
|
||||
if (options.json !== undefined) {
|
||||
outputJson(
|
||||
{ cases: cases.length, evidence: uploaded, verifyRunId: run.id },
|
||||
typeof options.json === 'string' ? options.json : undefined,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`${pc.green('✓')} Ingested ${pc.bold(String(cases.length))} case(s), ${pc.bold(String(uploaded))} evidence file(s)`,
|
||||
);
|
||||
console.log(`${pc.bold('verifyRunId')}: ${run.id}`);
|
||||
if (options.open) {
|
||||
console.log(`${pc.bold('open')}: /verify/${run.id}`);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function printResults(results: any[]): void {
|
||||
|
||||
@@ -10,6 +10,11 @@ export interface TaskEntry {
|
||||
startedAt: string;
|
||||
taskId: string;
|
||||
topicId: string;
|
||||
/**
|
||||
* Workspace that owns the dispatched topic. Persisted so the cancel-time
|
||||
* notify still scopes to the right workspace after the daemon restarts.
|
||||
*/
|
||||
workspaceId?: string;
|
||||
}
|
||||
|
||||
function getRegistryPath(): string {
|
||||
|
||||
@@ -38,3 +38,45 @@ export async function registerDevice(
|
||||
platform: process.platform,
|
||||
});
|
||||
}
|
||||
|
||||
type Auth = { serverUrl: string; token: string; tokenType: 'apiKey' | 'jwt' | 'serviceToken' };
|
||||
|
||||
/**
|
||||
* Identity for a WORKSPACE device: derived from the workspaceId (namespaced) so
|
||||
* the same physical machine enrolled into a workspace is a distinct device from
|
||||
* its personal identity, and stable across reconnects.
|
||||
*/
|
||||
export function resolveWorkspaceDeviceIdentity(
|
||||
workspaceId: string,
|
||||
explicitDeviceId?: string,
|
||||
): DeviceIdentity {
|
||||
if (explicitDeviceId) return { deviceId: explicitDeviceId, identitySource: 'fallback' };
|
||||
return deriveDeviceId(`workspace:${workspaceId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint a workspace-device connect token (owner-only on the server). The returned
|
||||
* token carries the `workspace_id` claim the gateway routes by.
|
||||
*/
|
||||
export async function mintWorkspaceConnectToken(
|
||||
auth: Auth,
|
||||
workspaceId: string,
|
||||
): Promise<{ token: string; workspaceId: string }> {
|
||||
const trpc = createLambdaClient(auth, workspaceId);
|
||||
return trpc.device.mintWorkspaceConnectToken.mutate();
|
||||
}
|
||||
|
||||
/** Register this machine as a device of the given workspace (owner-only). */
|
||||
export async function registerWorkspaceDevice(
|
||||
auth: Auth,
|
||||
identity: DeviceIdentity,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
const trpc = createLambdaClient(auth, workspaceId);
|
||||
await trpc.device.registerWorkspaceDevice.mutate({
|
||||
deviceId: identity.deviceId,
|
||||
hostname: os.hostname(),
|
||||
identitySource: identity.identitySource,
|
||||
platform: process.platform,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
/**
|
||||
* Single source of truth for this package's own metadata.
|
||||
*
|
||||
* Must live directly under `src/` (depth 1), the same depth as the bundled
|
||||
* entry `dist/index.js`, so `../package.json` resolves to `@lobehub/cli`'s own
|
||||
* package.json both when running from source (`bun src/index.ts`) and from the
|
||||
* tsdown bundle (`dist/index.js`). A module one directory deeper would resolve
|
||||
* the path outside the package once everything is bundled into a single file.
|
||||
*/
|
||||
const require = createRequire(import.meta.url);
|
||||
const pkg = require('../package.json') as { name: string; version: string };
|
||||
|
||||
export const cliPackageName = pkg.name;
|
||||
export const cliVersion = pkg.version;
|
||||
@@ -1,5 +1,3 @@
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
import { Command } from 'commander';
|
||||
|
||||
import { registerAgentCommand } from './commands/agent';
|
||||
@@ -33,11 +31,10 @@ import { registerStatusCommand } from './commands/status';
|
||||
import { registerTaskCommand } from './commands/task';
|
||||
import { registerThreadCommand } from './commands/thread';
|
||||
import { registerTopicCommand } from './commands/topic';
|
||||
import { registerUpdateCommand } from './commands/update';
|
||||
import { registerUserCommand } from './commands/user';
|
||||
import { registerVerifyCommand } from './commands/verify';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { version } = require('../package.json');
|
||||
import { cliVersion } from './pkg';
|
||||
|
||||
export function createProgram() {
|
||||
const program = new Command();
|
||||
@@ -45,7 +42,7 @@ export function createProgram() {
|
||||
program
|
||||
.name('lh')
|
||||
.description('LobeHub CLI - manage and connect to LobeHub services')
|
||||
.version(version);
|
||||
.version(cliVersion);
|
||||
|
||||
registerLoginCommand(program);
|
||||
registerLogoutCommand(program);
|
||||
@@ -80,8 +77,9 @@ export function createProgram() {
|
||||
registerConfigCommand(program);
|
||||
registerEvalCommand(program);
|
||||
registerMigrateCommand(program);
|
||||
registerUpdateCommand(program);
|
||||
|
||||
return program;
|
||||
}
|
||||
|
||||
export { version as cliVersion };
|
||||
export { cliPackageName, cliVersion } from './pkg';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { getTrpcClient } from '../../api/client';
|
||||
import { removeTask, saveTask } from '../../daemon/taskRegistry';
|
||||
import { runHeteroTask } from '../heteroTask';
|
||||
|
||||
@@ -34,6 +35,8 @@ vi.mock('../../api/client', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
const getTrpcClientMock = vi.mocked(getTrpcClient);
|
||||
|
||||
vi.mock('../../utils/logger', () => ({
|
||||
log: { error: vi.fn(), info: vi.fn(), warn: vi.fn() },
|
||||
}));
|
||||
@@ -248,4 +251,56 @@ describe('runHeteroTask (openclaw)', () => {
|
||||
expect(removeTask).toHaveBeenCalledWith('task-1');
|
||||
killSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('threads workspaceId into the saved task entry and the spawned child env', async () => {
|
||||
const child = makeMockChild(6666);
|
||||
spawnMock.mockReturnValue(child);
|
||||
|
||||
await runHeteroTask({
|
||||
agentId: 'agent-ws',
|
||||
agentType: 'openclaw',
|
||||
operationId: 'op-ws',
|
||||
prompt: 'workspace dispatch',
|
||||
taskId: 'task-ws',
|
||||
topicId: 'topic-ws',
|
||||
workspaceId: 'ws-42',
|
||||
});
|
||||
|
||||
expect(saveTask).toHaveBeenCalledWith(expect.objectContaining({ workspaceId: 'ws-42' }));
|
||||
|
||||
const [, , spawnOpts] = spawnMock.mock.calls[0] as [
|
||||
string,
|
||||
string[],
|
||||
{ env: NodeJS.ProcessEnv },
|
||||
];
|
||||
expect(spawnOpts.env.LOBEHUB_WORKSPACE_ID).toBe('ws-42');
|
||||
});
|
||||
|
||||
it('passes workspaceId to getTrpcClient when the close handler auto-notifies', async () => {
|
||||
const child = makeMockChild(7777);
|
||||
spawnMock.mockReturnValue(child);
|
||||
|
||||
await runHeteroTask({
|
||||
agentId: 'agent-ws',
|
||||
agentType: 'openclaw',
|
||||
operationId: 'op-ws-2',
|
||||
prompt: 'ws prompt',
|
||||
taskId: 'task-ws-2',
|
||||
topicId: 'topic-ws-2',
|
||||
workspaceId: 'ws-99',
|
||||
});
|
||||
|
||||
getTrpcClientMock.mockClear();
|
||||
// Abnormal exit triggers sendAutoNotify + sendDoneSignal — both must scope
|
||||
// to the dispatching workspace or agentNotify resolves the topic in
|
||||
// personal mode and 404s.
|
||||
child._emit('close', 1, null);
|
||||
// Await microtask drain so the close-handler promise chain settles.
|
||||
await new Promise((r) => setImmediate(r));
|
||||
|
||||
expect(getTrpcClientMock.mock.calls.length).toBeGreaterThan(0);
|
||||
for (const call of getTrpcClientMock.mock.calls) {
|
||||
expect(call[0]).toBe('ws-99');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -57,6 +57,13 @@ export interface RunHeteroTaskParams {
|
||||
prompt: string;
|
||||
taskId: string;
|
||||
topicId: string;
|
||||
/**
|
||||
* Workspace id seeded by the server when the dispatched topic lives in a
|
||||
* workspace. Threaded into auto-notify calls (as `X-Workspace-Id`) and into
|
||||
* the spawned child's `LOBEHUB_WORKSPACE_ID` env so its own `lh notify`
|
||||
* shells inherit the same scope.
|
||||
*/
|
||||
workspaceId?: string;
|
||||
}
|
||||
|
||||
export interface CancelHeteroTaskParams {
|
||||
@@ -69,9 +76,10 @@ async function sendAutoNotify(
|
||||
taskId: string,
|
||||
text: string,
|
||||
agentId?: string,
|
||||
workspaceId?: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const client = await getTrpcClient();
|
||||
const client = await getTrpcClient(workspaceId);
|
||||
await client.agentNotify.notify.mutate({
|
||||
agentId,
|
||||
content: text,
|
||||
@@ -90,9 +98,13 @@ async function sendAutoNotify(
|
||||
* `sendAutoNotify` which writes an error message AND triggers completion via
|
||||
* the `done` flag.
|
||||
*/
|
||||
async function sendDoneSignal(topicId: string, agentId?: string): Promise<void> {
|
||||
async function sendDoneSignal(
|
||||
topicId: string,
|
||||
agentId?: string,
|
||||
workspaceId?: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const client = await getTrpcClient();
|
||||
const client = await getTrpcClient(workspaceId);
|
||||
await client.agentNotify.notify.mutate({
|
||||
agentId,
|
||||
content: '',
|
||||
@@ -138,9 +150,15 @@ function buildNotifyProtocol(lhPath: string, topicId: string): string {
|
||||
}
|
||||
|
||||
export async function runHeteroTask(params: RunHeteroTaskParams): Promise<string> {
|
||||
const { agentId, agentType, cwd, operationId, prompt, taskId, topicId } = params;
|
||||
const { agentId, agentType, cwd, operationId, prompt, taskId, topicId, workspaceId } = params;
|
||||
const workDir = cwd || process.cwd();
|
||||
const lhPath = resolveLhPath();
|
||||
// Propagate workspace scope into the spawned child so its own `lh notify`
|
||||
// invocations (and any grandchildren it shells out) inherit the same scope
|
||||
// via getTrpcClient → resolveWorkspaceId.
|
||||
const childEnv: NodeJS.ProcessEnv = workspaceId
|
||||
? { ...process.env, LOBEHUB_WORKSPACE_ID: workspaceId }
|
||||
: { ...process.env };
|
||||
|
||||
if (agentType === 'openclaw') {
|
||||
// openclaw agent --local is one-shot: each invocation processes one message and exits.
|
||||
@@ -182,7 +200,7 @@ export async function runHeteroTask(params: RunHeteroTaskParams): Promise<string
|
||||
{
|
||||
cwd: workDir,
|
||||
detached: true,
|
||||
env: { ...process.env },
|
||||
env: childEnv,
|
||||
stdio: 'ignore',
|
||||
},
|
||||
);
|
||||
@@ -201,6 +219,7 @@ export async function runHeteroTask(params: RunHeteroTaskParams): Promise<string
|
||||
startedAt: new Date().toISOString(),
|
||||
taskId,
|
||||
topicId,
|
||||
workspaceId,
|
||||
});
|
||||
log.info(`OpenClaw task started: taskId=${taskId} pid=${pid} agent=${openclawAgent}`);
|
||||
|
||||
@@ -216,12 +235,12 @@ export async function runHeteroTask(params: RunHeteroTaskParams): Promise<string
|
||||
: `Task failed (exit code: ${code})`;
|
||||
// Send error message first, THEN signal done (sequential).
|
||||
// Fire-and-forget both, but ensure done is always sent even if notify fails.
|
||||
void sendAutoNotify(topicId, taskId, text, agentId).finally(() =>
|
||||
sendDoneSignal(topicId, agentId),
|
||||
void sendAutoNotify(topicId, taskId, text, agentId, workspaceId).finally(() =>
|
||||
sendDoneSignal(topicId, agentId, workspaceId),
|
||||
);
|
||||
} else {
|
||||
// Clean exit — openclaw already sent its final message; just signal done.
|
||||
void sendDoneSignal(topicId, agentId);
|
||||
void sendDoneSignal(topicId, agentId, workspaceId);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -253,7 +272,7 @@ export async function runHeteroTask(params: RunHeteroTaskParams): Promise<string
|
||||
const child = spawn('hermes', hermesArgs, {
|
||||
cwd: workDir,
|
||||
detached: true,
|
||||
env: { ...process.env },
|
||||
env: childEnv,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
});
|
||||
|
||||
@@ -269,6 +288,7 @@ export async function runHeteroTask(params: RunHeteroTaskParams): Promise<string
|
||||
startedAt: new Date().toISOString(),
|
||||
taskId,
|
||||
topicId,
|
||||
workspaceId,
|
||||
});
|
||||
log.info(`Hermes task started: taskId=${taskId} pid=${pid}`);
|
||||
|
||||
@@ -284,8 +304,8 @@ export async function runHeteroTask(params: RunHeteroTaskParams): Promise<string
|
||||
const text = signal
|
||||
? `Task cancelled (signal: ${signal})`
|
||||
: `Task failed (exit code: ${code})`;
|
||||
void sendAutoNotify(topicId, taskId, text, agentId).finally(() =>
|
||||
sendDoneSignal(topicId, agentId),
|
||||
void sendAutoNotify(topicId, taskId, text, agentId, workspaceId).finally(() =>
|
||||
sendDoneSignal(topicId, agentId, workspaceId),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -298,11 +318,11 @@ export async function runHeteroTask(params: RunHeteroTaskParams): Promise<string
|
||||
if (sessionId) saveHermesSessionId(topicId, sessionId);
|
||||
|
||||
if (response) {
|
||||
void sendAutoNotify(topicId, taskId, response, agentId).finally(() =>
|
||||
sendDoneSignal(topicId, agentId),
|
||||
void sendAutoNotify(topicId, taskId, response, agentId, workspaceId).finally(() =>
|
||||
sendDoneSignal(topicId, agentId, workspaceId),
|
||||
);
|
||||
} else {
|
||||
void sendDoneSignal(topicId, agentId);
|
||||
void sendDoneSignal(topicId, agentId, workspaceId);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -334,6 +354,7 @@ export async function cancelHeteroTask(params: CancelHeteroTaskParams): Promise<
|
||||
taskId,
|
||||
'Task already completed or cancelled',
|
||||
entry.agentId,
|
||||
entry.workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -77,6 +77,12 @@ interface PlatformTaskEntry {
|
||||
operationId: string;
|
||||
pid: number;
|
||||
topicId: string;
|
||||
/**
|
||||
* Workspace that owns the dispatched topic — used at exit time so the
|
||||
* cleanup notify still scopes to the workspace agentNotify resolves the
|
||||
* topic in (the server seeds this via the `runHeteroTask` args).
|
||||
*/
|
||||
workspaceId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -524,6 +530,7 @@ export default class GatewayConnectionCtr extends ControllerModule {
|
||||
prompt: string;
|
||||
taskId: string;
|
||||
topicId: string;
|
||||
workspaceId?: string;
|
||||
},
|
||||
);
|
||||
return { content: json, state: safeJsonParse(json), success: true };
|
||||
@@ -765,8 +772,9 @@ export default class GatewayConnectionCtr extends ControllerModule {
|
||||
prompt: string;
|
||||
taskId: string;
|
||||
topicId: string;
|
||||
workspaceId?: string;
|
||||
}): Promise<string> {
|
||||
const { agentId, agentType, cwd, operationId, prompt, taskId, topicId } = args;
|
||||
const { agentId, agentType, cwd, operationId, prompt, taskId, topicId, workspaceId } = args;
|
||||
const workDir = cwd || process.cwd();
|
||||
|
||||
const [serverUrl, accessToken] = await Promise.all([
|
||||
@@ -774,11 +782,15 @@ export default class GatewayConnectionCtr extends ControllerModule {
|
||||
this.remoteServerConfigCtr.getAccessToken(),
|
||||
]);
|
||||
|
||||
// Inject auth into child env so `lh notify` can authenticate without CLI config.
|
||||
// Inject auth + workspace scope into child env so `lh notify` can
|
||||
// authenticate AND target the same workspace as the dispatched topic
|
||||
// (without LOBEHUB_WORKSPACE_ID, the CLI's notify falls back to personal
|
||||
// mode and the workspace topic 404s).
|
||||
const childEnv: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
...(accessToken && { LOBEHUB_JWT: accessToken }),
|
||||
...(serverUrl && { LOBEHUB_SERVER: serverUrl }),
|
||||
...(workspaceId && { LOBEHUB_WORKSPACE_ID: workspaceId }),
|
||||
};
|
||||
|
||||
if (agentType === 'openclaw') {
|
||||
@@ -823,7 +835,14 @@ export default class GatewayConnectionCtr extends ControllerModule {
|
||||
if (pid === undefined) throw new Error('Failed to get PID for openclaw process');
|
||||
child.unref();
|
||||
|
||||
this.platformTasks.set(taskId, { agentId, agentType, operationId, pid, topicId });
|
||||
this.platformTasks.set(taskId, {
|
||||
agentId,
|
||||
agentType,
|
||||
operationId,
|
||||
pid,
|
||||
topicId,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
child.on('close', (code, signal) => {
|
||||
this.platformTasks.delete(taskId);
|
||||
@@ -831,11 +850,31 @@ export default class GatewayConnectionCtr extends ControllerModule {
|
||||
const text = signal
|
||||
? `Task cancelled (signal: ${signal})`
|
||||
: `Task failed (exit code: ${code})`;
|
||||
void this.sendNotify({ agentId, content: text, role: 'assistant', topicId }).finally(() =>
|
||||
this.sendNotify({ agentId, content: '', done: true, role: 'assistant', topicId }),
|
||||
void this.sendNotify({
|
||||
agentId,
|
||||
content: text,
|
||||
role: 'assistant',
|
||||
topicId,
|
||||
workspaceId,
|
||||
}).finally(() =>
|
||||
this.sendNotify({
|
||||
agentId,
|
||||
content: '',
|
||||
done: true,
|
||||
role: 'assistant',
|
||||
topicId,
|
||||
workspaceId,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
void this.sendNotify({ agentId, content: '', done: true, role: 'assistant', topicId });
|
||||
void this.sendNotify({
|
||||
agentId,
|
||||
content: '',
|
||||
done: true,
|
||||
role: 'assistant',
|
||||
topicId,
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -874,7 +913,14 @@ export default class GatewayConnectionCtr extends ControllerModule {
|
||||
if (pid === undefined) throw new Error('Failed to get PID for hermes process');
|
||||
child.unref();
|
||||
|
||||
this.platformTasks.set(taskId, { agentId, agentType, operationId, pid, topicId });
|
||||
this.platformTasks.set(taskId, {
|
||||
agentId,
|
||||
agentType,
|
||||
operationId,
|
||||
pid,
|
||||
topicId,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
child.stdout.on('data', (chunk: Buffer) => {
|
||||
@@ -888,8 +934,21 @@ export default class GatewayConnectionCtr extends ControllerModule {
|
||||
const text = signal
|
||||
? `Task cancelled (signal: ${signal})`
|
||||
: `Task failed (exit code: ${code})`;
|
||||
void this.sendNotify({ agentId, content: text, role: 'assistant', topicId }).finally(() =>
|
||||
this.sendNotify({ agentId, content: '', done: true, role: 'assistant', topicId }),
|
||||
void this.sendNotify({
|
||||
agentId,
|
||||
content: text,
|
||||
role: 'assistant',
|
||||
topicId,
|
||||
workspaceId,
|
||||
}).finally(() =>
|
||||
this.sendNotify({
|
||||
agentId,
|
||||
content: '',
|
||||
done: true,
|
||||
role: 'assistant',
|
||||
topicId,
|
||||
workspaceId,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -902,11 +961,31 @@ export default class GatewayConnectionCtr extends ControllerModule {
|
||||
if (sessionId) this.hermesSessionMap.set(topicId, sessionId);
|
||||
|
||||
if (response) {
|
||||
void this.sendNotify({ agentId, content: response, role: 'assistant', topicId }).finally(
|
||||
() => this.sendNotify({ agentId, content: '', done: true, role: 'assistant', topicId }),
|
||||
void this.sendNotify({
|
||||
agentId,
|
||||
content: response,
|
||||
role: 'assistant',
|
||||
topicId,
|
||||
workspaceId,
|
||||
}).finally(() =>
|
||||
this.sendNotify({
|
||||
agentId,
|
||||
content: '',
|
||||
done: true,
|
||||
role: 'assistant',
|
||||
topicId,
|
||||
workspaceId,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
void this.sendNotify({ agentId, content: '', done: true, role: 'assistant', topicId });
|
||||
void this.sendNotify({
|
||||
agentId,
|
||||
content: '',
|
||||
done: true,
|
||||
role: 'assistant',
|
||||
topicId,
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -934,6 +1013,7 @@ export default class GatewayConnectionCtr extends ControllerModule {
|
||||
content: 'Task already completed or cancelled',
|
||||
role: 'assistant',
|
||||
topicId: entry.topicId,
|
||||
workspaceId: entry.workspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -951,6 +1031,12 @@ export default class GatewayConnectionCtr extends ControllerModule {
|
||||
done?: boolean;
|
||||
role: string;
|
||||
topicId: string;
|
||||
/**
|
||||
* Workspace scope for the notify. When set, attaches `X-Workspace-Id` so
|
||||
* agentNotify resolves the workspace-owned topic instead of falling back
|
||||
* to personal mode (which would 404 the lookup).
|
||||
*/
|
||||
workspaceId?: string;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const [serverUrl, token] = await Promise.all([
|
||||
@@ -959,12 +1045,16 @@ export default class GatewayConnectionCtr extends ControllerModule {
|
||||
]);
|
||||
if (!serverUrl || !token) return;
|
||||
|
||||
const { workspaceId, ...body } = params;
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'Oidc-Auth': token,
|
||||
};
|
||||
if (workspaceId) headers['X-Workspace-Id'] = workspaceId;
|
||||
|
||||
await fetch(`${serverUrl}/trpc/lambda/agentNotify.notify`, {
|
||||
body: JSON.stringify({ json: params }),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Oidc-Auth': token,
|
||||
},
|
||||
body: JSON.stringify({ json: body }),
|
||||
headers,
|
||||
method: 'POST',
|
||||
});
|
||||
} catch {
|
||||
|
||||
@@ -3,6 +3,11 @@ import './pre-app-init';
|
||||
import fixPath from 'fix-path';
|
||||
|
||||
import { App } from './core/App';
|
||||
import { installProcessErrorHandlers } from './process-error-handlers';
|
||||
|
||||
// Guard the main process against transient network blips (Wi-Fi/VPN switch,
|
||||
// system sleep) emitted by Electron's net stack as uncaught exceptions.
|
||||
installProcessErrorHandlers();
|
||||
|
||||
const app = new App();
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
const logger = createLogger('main:process-error-handlers');
|
||||
|
||||
/**
|
||||
* Transient Chromium network errors emitted by Electron's `net` stack
|
||||
* (`SimpleURLLoaderWrapper`). These happen during normal operation — switching
|
||||
* Wi-Fi / VPN, the machine sleeping, the network interface dropping — and are
|
||||
* NOT application bugs. Electron emits them as an `error` event on the internal
|
||||
* loader; when nothing is listening they bubble up as an `uncaughtException`
|
||||
* and pop the "A JavaScript error occurred in the main process" dialog, even
|
||||
* though the request layer already handles the failure via promise rejection.
|
||||
*
|
||||
* We swallow these specific cases so transient connectivity blips never crash
|
||||
* the main process. Everything else is re-thrown to preserve normal crash
|
||||
* visibility.
|
||||
*
|
||||
* @see https://github.com/electron/electron/issues/24948
|
||||
*/
|
||||
const TRANSIENT_NET_ERROR_CODES = new Set([
|
||||
'ERR_NETWORK_CHANGED',
|
||||
'ERR_NETWORK_IO_SUSPENDED',
|
||||
'ERR_INTERNET_DISCONNECTED',
|
||||
'ERR_NETWORK_ACCESS_DENIED',
|
||||
'ERR_CONNECTION_RESET',
|
||||
'ERR_CONNECTION_ABORTED',
|
||||
'ERR_CONNECTION_CLOSED',
|
||||
'ERR_NAME_NOT_RESOLVED',
|
||||
'ERR_TIMED_OUT',
|
||||
]);
|
||||
|
||||
const isTransientNetError = (error: unknown): boolean => {
|
||||
if (!error) return false;
|
||||
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
|
||||
// Electron net errors are formatted as `net::ERR_XXX`.
|
||||
const match = message.match(/net::(ERR_[A-Z_]+)/);
|
||||
if (match && TRANSIENT_NET_ERROR_CODES.has(match[1])) return true;
|
||||
|
||||
// Belt-and-suspenders: these only ever originate from the net loader.
|
||||
const stack = error instanceof Error ? (error.stack ?? '') : '';
|
||||
return /net::ERR_/.test(message) && stack.includes('SimpleURLLoaderWrapper');
|
||||
};
|
||||
|
||||
/**
|
||||
* Install global guards for the Electron main process. Must be called as early
|
||||
* as possible (before the rest of the app boots) so it catches errors from any
|
||||
* module's top-level / async work.
|
||||
*/
|
||||
export const installProcessErrorHandlers = () => {
|
||||
process.on('uncaughtException', (error) => {
|
||||
if (isTransientNetError(error)) {
|
||||
logger.warn('Ignoring transient network error in main process:', error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
// Re-throw so genuine bugs still surface as a crash instead of being
|
||||
// silently swallowed by this handler.
|
||||
logger.error('Uncaught exception in main process:', error);
|
||||
throw error;
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
if (isTransientNetError(reason)) {
|
||||
logger.warn(
|
||||
'Ignoring transient network rejection in main process:',
|
||||
reason instanceof Error ? reason.message : String(reason),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Installing this listener overrides Node's default
|
||||
// `--unhandled-rejections=throw`, so we must re-throw to preserve the fatal
|
||||
// behavior. Throwing here surfaces as an uncaughtException (handled above,
|
||||
// which also re-throws non-transient errors), instead of leaving the app
|
||||
// partially booted on a genuine failure (e.g. an unawaited app.bootstrap()).
|
||||
logger.error('Unhandled rejection in main process:', reason);
|
||||
throw reason;
|
||||
});
|
||||
|
||||
logger.info('Process error handlers installed');
|
||||
};
|
||||
@@ -9,7 +9,6 @@
|
||||
* - Gets model capabilities from provided function
|
||||
* - No dependency on frontend stores (useToolStore, useAgentStore, etc.)
|
||||
*/
|
||||
import { AgentDocumentsManifest } from '@lobechat/builtin-tool-agent-documents';
|
||||
import { CloudSandboxManifest } from '@lobechat/builtin-tool-cloud-sandbox';
|
||||
import { KnowledgeBaseManifest } from '@lobechat/builtin-tool-knowledge-base';
|
||||
import { LocalSystemManifest } from '@lobechat/builtin-tool-local-system';
|
||||
@@ -135,7 +134,6 @@ export const createServerAgentToolsEngine = (
|
||||
disableLocalSystem = false,
|
||||
executionPlan,
|
||||
globalMemoryEnabled = false,
|
||||
hasAgentDocuments = false,
|
||||
hasEnabledKnowledgeBases = false,
|
||||
isBotConversation = false,
|
||||
model,
|
||||
@@ -247,7 +245,6 @@ export const createServerAgentToolsEngine = (
|
||||
hasDeviceProxy &&
|
||||
!deviceContext?.autoActivated &&
|
||||
!deviceContext?.boundDeviceId,
|
||||
[AgentDocumentsManifest.identifier]: hasAgentDocuments,
|
||||
[WebBrowsingManifest.identifier]: isSearchEnabled,
|
||||
};
|
||||
|
||||
|
||||
@@ -101,8 +101,6 @@ export interface ServerCreateAgentToolsEngineParams {
|
||||
executionPlan?: ExecutionPlan;
|
||||
/** Whether the user's global memory setting is enabled */
|
||||
globalMemoryEnabled?: boolean;
|
||||
/** Whether agent has agent documents */
|
||||
hasAgentDocuments?: boolean;
|
||||
/** Whether agent has enabled knowledge bases */
|
||||
hasEnabledKnowledgeBases?: boolean;
|
||||
/** Whether the request originates from a bot conversation (auto-enables message tool) */
|
||||
|
||||
@@ -348,6 +348,72 @@ describe('Task Router Integration', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('verify config', () => {
|
||||
it('should set and retrieve verify config (round-trip)', async () => {
|
||||
const task = await caller.create({ instruction: 'Test' });
|
||||
|
||||
await caller.updateVerifyConfig({
|
||||
id: task.data.id,
|
||||
verify: {
|
||||
enabled: true,
|
||||
maxIterations: 3,
|
||||
verifierAgentId: 'agt_codex',
|
||||
verifyCriteriaIds: ['c1', 'c2'],
|
||||
verifyRubricId: 'rub_1',
|
||||
},
|
||||
});
|
||||
|
||||
const verify = await caller.getVerifyConfig({ id: task.data.id });
|
||||
expect(verify.data).toEqual({
|
||||
enabled: true,
|
||||
maxIterations: 3,
|
||||
verifierAgentId: 'agt_codex',
|
||||
verifyCriteriaIds: ['c1', 'c2'],
|
||||
verifyRubricId: 'rub_1',
|
||||
});
|
||||
|
||||
// task.detail must surface the saved verify config (not leave it undefined).
|
||||
const detail = await caller.detail({ id: task.data.id });
|
||||
expect(detail.data!.verify).toEqual({
|
||||
enabled: true,
|
||||
maxIterations: 3,
|
||||
verifierAgentId: 'agt_codex',
|
||||
verifyCriteriaIds: ['c1', 'c2'],
|
||||
verifyRubricId: 'rub_1',
|
||||
});
|
||||
});
|
||||
|
||||
it('should clear a saved field when passed null', async () => {
|
||||
const task = await caller.create({ instruction: 'Test' });
|
||||
|
||||
await caller.updateVerifyConfig({
|
||||
id: task.data.id,
|
||||
verify: { enabled: true, verifierAgentId: 'agt_codex', verifyRubricId: 'rub_1' },
|
||||
});
|
||||
|
||||
// Switch the verifier back to default + drop the rubric.
|
||||
await caller.updateVerifyConfig({
|
||||
id: task.data.id,
|
||||
verify: { verifierAgentId: null, verifyRubricId: null },
|
||||
});
|
||||
|
||||
const verify = await caller.getVerifyConfig({ id: task.data.id });
|
||||
expect(verify.data).toEqual({ enabled: true });
|
||||
});
|
||||
|
||||
it('getVerifyConfig falls back to the legacy review key', async () => {
|
||||
const task = await caller.create({ instruction: 'Test' });
|
||||
|
||||
await caller.updateReview({
|
||||
id: task.data.id,
|
||||
review: { autoRetry: true, enabled: true, maxIterations: 4, rubrics: [] },
|
||||
});
|
||||
|
||||
const verify = await caller.getVerifyConfig({ id: task.data.id });
|
||||
expect(verify.data).toEqual({ enabled: true, maxIterations: 4 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('run idempotency', () => {
|
||||
it('should reject run when a topic is already running', async () => {
|
||||
const task = await caller.create({
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { verifyRouter } from '@/server/routers/lambda/verify';
|
||||
import { FileService } from '@/server/services/file';
|
||||
|
||||
const modelMocks = vi.hoisted(() => ({
|
||||
findRunById: vi.fn(),
|
||||
findResultById: vi.fn(),
|
||||
getFullFileUrl: vi.fn(),
|
||||
getServerDB: vi.fn(async () => ({})),
|
||||
upsertByCheckItem: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/database/core/db-adaptor', () => ({
|
||||
getServerDB: modelMocks.getServerDB,
|
||||
}));
|
||||
|
||||
vi.mock('@/database/models/verifyCheckResult', () => ({
|
||||
VerifyCheckResultModel: vi.fn(() => ({
|
||||
findById: modelMocks.findResultById,
|
||||
upsertByCheckItem: modelMocks.upsertByCheckItem,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@/database/models/verifyRun', () => ({
|
||||
VerifyRunModel: vi.fn(() => ({
|
||||
findById: modelMocks.findRunById,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@/server/services/verify', () => ({
|
||||
VerifyExecutorService: class VerifyExecutorService {},
|
||||
VerifyFeedbackService: class VerifyFeedbackService {},
|
||||
VerifyPlanGeneratorService: class VerifyPlanGeneratorService {},
|
||||
}));
|
||||
|
||||
vi.mock('@/server/services/file', () => ({
|
||||
FileService: vi.fn(() => ({
|
||||
getFullFileUrl: modelMocks.getFullFileUrl,
|
||||
})),
|
||||
}));
|
||||
|
||||
const createCaller = () => verifyRouter.createCaller({ userId: 'verify-router-test-user' } as any);
|
||||
const createPublicCaller = () => verifyRouter.createCaller({} as any);
|
||||
|
||||
const selectRows = <T>(rows: T[]) => ({
|
||||
from: vi.fn(() => ({
|
||||
where: vi.fn(() => ({
|
||||
orderBy: vi.fn(async () => rows),
|
||||
})),
|
||||
})),
|
||||
});
|
||||
|
||||
describe('verifyRouter', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
modelMocks.getServerDB.mockResolvedValue({});
|
||||
vi.mocked(FileService).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
getFullFileUrl: modelMocks.getFullFileUrl,
|
||||
}) as any,
|
||||
);
|
||||
});
|
||||
|
||||
describe('ingestResult', () => {
|
||||
it("rejects a run outside the caller's scope before upserting the result", async () => {
|
||||
modelMocks.findRunById.mockResolvedValueOnce(undefined);
|
||||
|
||||
await expect(
|
||||
createCaller().ingestResult({
|
||||
checkItemId: 'shared-check',
|
||||
checkItemTitle: 'attacker update',
|
||||
status: 'passed',
|
||||
verdict: 'passed',
|
||||
verifyRunId: 'other-user-run',
|
||||
}),
|
||||
).rejects.toThrow('Verification run not found');
|
||||
|
||||
expect(modelMocks.findRunById).toHaveBeenCalledWith('other-user-run');
|
||||
expect(modelMocks.upsertByCheckItem).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('uploadEvidence', () => {
|
||||
it('rejects evidence with both inline content and fileId', async () => {
|
||||
await expect(
|
||||
createCaller().uploadEvidence({
|
||||
checkResultId: 'result-1',
|
||||
content: 'inline payload',
|
||||
fileId: 'files-1',
|
||||
type: 'text',
|
||||
}),
|
||||
).rejects.toThrow('Provide exactly one of `content` or `fileId`.');
|
||||
});
|
||||
|
||||
it('rejects evidence without inline content or fileId', async () => {
|
||||
await expect(
|
||||
createCaller().uploadEvidence({
|
||||
checkResultId: 'result-1',
|
||||
type: 'text',
|
||||
}),
|
||||
).rejects.toThrow('Provide exactly one of `content` or `fileId`.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getReportBundle', () => {
|
||||
it('reads a standalone report without a logged-in user', async () => {
|
||||
const run = {
|
||||
goal: 'Ship a working page',
|
||||
id: 'run-1',
|
||||
title: 'Run report',
|
||||
userId: 'owner-user',
|
||||
workspaceId: null,
|
||||
};
|
||||
const report = {
|
||||
id: 'report-1',
|
||||
totalChecks: 1,
|
||||
verdict: 'passed',
|
||||
verifyRunId: 'run-1',
|
||||
};
|
||||
const result = {
|
||||
checkItemId: 'check-1',
|
||||
checkItemIndex: 0,
|
||||
checkItemTitle: 'Page renders',
|
||||
id: 'result-1',
|
||||
required: true,
|
||||
status: 'passed',
|
||||
verdict: 'passed',
|
||||
verifyRunId: 'run-1',
|
||||
};
|
||||
const evidence = {
|
||||
checkResultId: 'result-1',
|
||||
content: null,
|
||||
description: 'Homepage screenshot',
|
||||
fileId: 'file-1',
|
||||
id: 'evidence-1',
|
||||
type: 'screenshot',
|
||||
};
|
||||
const serverDB = {
|
||||
query: {
|
||||
files: {
|
||||
findFirst: vi.fn(async () => ({ id: 'file-1', url: 'verify/evidence.png' })),
|
||||
},
|
||||
verifyReports: {
|
||||
findFirst: vi.fn(async () => report),
|
||||
},
|
||||
verifyRuns: {
|
||||
findFirst: vi.fn(async () => run),
|
||||
},
|
||||
},
|
||||
select: vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(selectRows([result]))
|
||||
.mockReturnValueOnce(selectRows([evidence])),
|
||||
};
|
||||
modelMocks.getServerDB.mockResolvedValue(serverDB);
|
||||
modelMocks.getFullFileUrl.mockResolvedValue('https://cdn.example.com/verify/evidence.png');
|
||||
|
||||
const bundle = await createPublicCaller().getReportBundle({ verifyRunId: 'run-1' });
|
||||
|
||||
expect(bundle).toMatchObject({
|
||||
report,
|
||||
results: [
|
||||
{
|
||||
checkItemId: 'check-1',
|
||||
evidence: [
|
||||
{
|
||||
fileId: 'file-1',
|
||||
fileUrl: 'https://cdn.example.com/verify/evidence.png',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
run,
|
||||
});
|
||||
expect(modelMocks.findRunById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps returning the bundle when file URL resolution is unavailable', async () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
vi.mocked(FileService).mockImplementation(() => {
|
||||
throw new Error('S3 env missing');
|
||||
});
|
||||
|
||||
const run = {
|
||||
goal: 'Ship a working page',
|
||||
id: 'run-1',
|
||||
title: 'Run report',
|
||||
userId: 'owner-user',
|
||||
workspaceId: null,
|
||||
};
|
||||
const result = {
|
||||
checkItemId: 'check-1',
|
||||
checkItemIndex: 0,
|
||||
checkItemTitle: 'Page renders',
|
||||
id: 'result-1',
|
||||
required: true,
|
||||
status: 'passed',
|
||||
verdict: 'passed',
|
||||
verifyRunId: 'run-1',
|
||||
};
|
||||
const evidence = {
|
||||
checkResultId: 'result-1',
|
||||
content: null,
|
||||
description: 'Homepage screenshot',
|
||||
fileId: 'file-1',
|
||||
id: 'evidence-1',
|
||||
type: 'screenshot',
|
||||
};
|
||||
const serverDB = {
|
||||
query: {
|
||||
files: {
|
||||
findFirst: vi.fn(async () => ({ id: 'file-1', url: 'verify/evidence.png' })),
|
||||
},
|
||||
verifyReports: {
|
||||
findFirst: vi.fn(async () => null),
|
||||
},
|
||||
verifyRuns: {
|
||||
findFirst: vi.fn(async () => run),
|
||||
},
|
||||
},
|
||||
select: vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(selectRows([result]))
|
||||
.mockReturnValueOnce(selectRows([evidence])),
|
||||
};
|
||||
modelMocks.getServerDB.mockResolvedValue(serverDB);
|
||||
|
||||
const bundle = await createPublicCaller().getReportBundle({ verifyRunId: 'run-1' });
|
||||
|
||||
expect(bundle).toMatchObject({
|
||||
results: [
|
||||
{
|
||||
evidence: [
|
||||
{
|
||||
fileId: 'file-1',
|
||||
fileUrl: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
run,
|
||||
});
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'[verify:getReportBundle:resolveFileUrl]',
|
||||
expect.any(Error),
|
||||
);
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,16 @@
|
||||
import { REMOTE_HETEROGENEOUS_AGENT_CONFIGS } from '@lobechat/heterogeneous-agents';
|
||||
import type { DeviceChannel, DeviceListItem, WorkingDirEntry } from '@lobechat/types';
|
||||
import type { DeviceChannel, DeviceListItem, DeviceScope, WorkingDirEntry } from '@lobechat/types';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
wsCompatProcedure,
|
||||
wsOwnerProcedure,
|
||||
} from '@/business/server/trpc-middlewares/workspaceAuth';
|
||||
import { DeviceModel } from '@/database/models/device';
|
||||
import { authedProcedure, router } from '@/libs/trpc/lambda';
|
||||
import { router } from '@/libs/trpc/lambda';
|
||||
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
|
||||
import { deviceGateway } from '@/server/services/deviceGateway';
|
||||
import { signWorkspaceDeviceToken } from '@/libs/trpc/utils/internalJwt';
|
||||
import { type DeviceAttachment, deviceGateway } from '@/server/services/deviceGateway';
|
||||
|
||||
import { preserveWorkspaceCache } from './deviceWorkingDirs';
|
||||
import { assertWorkspaceRootApproved } from './deviceWorkspaceGuard';
|
||||
@@ -22,11 +27,19 @@ const remotePlatformEnum = z.enum(
|
||||
const CAPABILITY_TIMEOUT_MS = 5_000;
|
||||
const PROFILE_TIMEOUT_MS = 5_000;
|
||||
|
||||
const deviceProcedure = authedProcedure.use(serverDatabase).use(async (opts) => {
|
||||
// Workspace-aware (compat): with an `X-Workspace-Id` header the device list also
|
||||
// surfaces the workspace's shared devices; without it, the personal path is
|
||||
// unchanged (`ctx.workspaceId === undefined`).
|
||||
const deviceProcedure = wsCompatProcedure.use(serverDatabase).use(async (opts) => {
|
||||
const { ctx } = opts;
|
||||
const wsId = ctx.workspaceId ?? undefined;
|
||||
|
||||
return opts.next({
|
||||
ctx: { deviceModel: new DeviceModel(ctx.serverDB, ctx.userId), userId: ctx.userId },
|
||||
ctx: {
|
||||
deviceModel: new DeviceModel(ctx.serverDB, ctx.userId, wsId),
|
||||
userId: ctx.userId,
|
||||
workspaceId: wsId,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -62,7 +75,7 @@ export const deviceRouter = router({
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const result = await deviceGateway.executeToolCall(
|
||||
{ deviceId: input.deviceId, userId: ctx.userId },
|
||||
{ deviceId: input.deviceId, userId: ctx.userId, workspaceId: ctx.workspaceId },
|
||||
{
|
||||
apiName: 'checkPlatformCapability',
|
||||
arguments: JSON.stringify({ platform: input.platform }),
|
||||
@@ -99,6 +112,7 @@ export const deviceRouter = router({
|
||||
deviceId: input.deviceId,
|
||||
path: input.path,
|
||||
userId: ctx.userId,
|
||||
workspaceId: ctx.workspaceId,
|
||||
});
|
||||
return result ?? null;
|
||||
}),
|
||||
@@ -111,6 +125,7 @@ export const deviceRouter = router({
|
||||
deviceId: input.deviceId,
|
||||
path: input.path,
|
||||
userId: ctx.userId,
|
||||
workspaceId: ctx.workspaceId,
|
||||
});
|
||||
return result ?? null;
|
||||
}),
|
||||
@@ -122,6 +137,7 @@ export const deviceRouter = router({
|
||||
deviceId: input.deviceId,
|
||||
path: input.path,
|
||||
userId: ctx.userId,
|
||||
workspaceId: ctx.workspaceId,
|
||||
});
|
||||
return result ?? null;
|
||||
}),
|
||||
@@ -133,6 +149,7 @@ export const deviceRouter = router({
|
||||
deviceId: input.deviceId,
|
||||
path: input.path,
|
||||
userId: ctx.userId,
|
||||
workspaceId: ctx.workspaceId,
|
||||
});
|
||||
return result ?? null;
|
||||
}),
|
||||
@@ -149,6 +166,7 @@ export const deviceRouter = router({
|
||||
deviceId: input.deviceId,
|
||||
path: input.path,
|
||||
userId: ctx.userId,
|
||||
workspaceId: ctx.workspaceId,
|
||||
});
|
||||
return result ?? [];
|
||||
}),
|
||||
@@ -170,6 +188,7 @@ export const deviceRouter = router({
|
||||
deviceId: input.deviceId,
|
||||
path: input.path,
|
||||
userId: ctx.userId,
|
||||
workspaceId: ctx.workspaceId,
|
||||
});
|
||||
return result ?? [];
|
||||
}),
|
||||
@@ -194,6 +213,7 @@ export const deviceRouter = router({
|
||||
deviceId: input.deviceId,
|
||||
path: input.path,
|
||||
userId: ctx.userId,
|
||||
workspaceId: ctx.workspaceId,
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -217,6 +237,7 @@ export const deviceRouter = router({
|
||||
path: input.path,
|
||||
to: input.to,
|
||||
userId: ctx.userId,
|
||||
workspaceId: ctx.workspaceId,
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -238,6 +259,7 @@ export const deviceRouter = router({
|
||||
deviceId: input.deviceId,
|
||||
path: input.path,
|
||||
userId: ctx.userId,
|
||||
workspaceId: ctx.workspaceId,
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -252,6 +274,7 @@ export const deviceRouter = router({
|
||||
deviceId: input.deviceId,
|
||||
path: input.path,
|
||||
userId: ctx.userId,
|
||||
workspaceId: ctx.workspaceId,
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -266,6 +289,7 @@ export const deviceRouter = router({
|
||||
deviceId: input.deviceId,
|
||||
path: input.path,
|
||||
userId: ctx.userId,
|
||||
workspaceId: ctx.workspaceId,
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -281,6 +305,7 @@ export const deviceRouter = router({
|
||||
deviceId: input.deviceId,
|
||||
path: input.path,
|
||||
userId: ctx.userId,
|
||||
workspaceId: ctx.workspaceId,
|
||||
});
|
||||
return result ?? null;
|
||||
}),
|
||||
@@ -297,6 +322,7 @@ export const deviceRouter = router({
|
||||
deviceId: input.deviceId,
|
||||
path: input.path,
|
||||
userId: ctx.userId,
|
||||
workspaceId: ctx.workspaceId,
|
||||
});
|
||||
return result ?? null;
|
||||
}),
|
||||
@@ -312,6 +338,7 @@ export const deviceRouter = router({
|
||||
deviceId: input.deviceId,
|
||||
path: input.path,
|
||||
userId: ctx.userId,
|
||||
workspaceId: ctx.workspaceId,
|
||||
});
|
||||
return result ?? [];
|
||||
}),
|
||||
@@ -328,6 +355,7 @@ export const deviceRouter = router({
|
||||
deviceId: input.deviceId,
|
||||
path: input.path,
|
||||
userId: ctx.userId,
|
||||
workspaceId: ctx.workspaceId,
|
||||
});
|
||||
return result ?? null;
|
||||
}),
|
||||
@@ -344,6 +372,7 @@ export const deviceRouter = router({
|
||||
deviceId: input.deviceId,
|
||||
scope: input.scope,
|
||||
userId: ctx.userId,
|
||||
workspaceId: ctx.workspaceId,
|
||||
});
|
||||
return result ?? null;
|
||||
}),
|
||||
@@ -365,6 +394,7 @@ export const deviceRouter = router({
|
||||
deviceId: input.deviceId,
|
||||
path: input.path,
|
||||
userId: ctx.userId,
|
||||
workspaceId: ctx.workspaceId,
|
||||
workingDirectory: input.workingDirectory,
|
||||
});
|
||||
}),
|
||||
@@ -381,6 +411,7 @@ export const deviceRouter = router({
|
||||
deviceId: input.deviceId,
|
||||
scope: input.scope,
|
||||
userId: ctx.userId,
|
||||
workspaceId: ctx.workspaceId,
|
||||
});
|
||||
return result ?? null;
|
||||
}),
|
||||
@@ -397,6 +428,7 @@ export const deviceRouter = router({
|
||||
filePath: input.filePath,
|
||||
path: input.path,
|
||||
userId: ctx.userId,
|
||||
workspaceId: ctx.workspaceId,
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -415,6 +447,7 @@ export const deviceRouter = router({
|
||||
deviceId: input.deviceId,
|
||||
items: input.items,
|
||||
userId: ctx.userId,
|
||||
workspaceId: ctx.workspaceId,
|
||||
workingDirectory: input.workingDirectory,
|
||||
});
|
||||
}),
|
||||
@@ -436,6 +469,7 @@ export const deviceRouter = router({
|
||||
newName: input.newName,
|
||||
path: input.path,
|
||||
userId: ctx.userId,
|
||||
workspaceId: ctx.workspaceId,
|
||||
workingDirectory: input.workingDirectory,
|
||||
});
|
||||
}),
|
||||
@@ -457,6 +491,7 @@ export const deviceRouter = router({
|
||||
deviceId: input.deviceId,
|
||||
path: input.path,
|
||||
userId: ctx.userId,
|
||||
workspaceId: ctx.workspaceId,
|
||||
workingDirectory: input.workingDirectory,
|
||||
});
|
||||
}),
|
||||
@@ -474,6 +509,7 @@ export const deviceRouter = router({
|
||||
deviceId: input.deviceId,
|
||||
path: input.path,
|
||||
userId: ctx.userId,
|
||||
workspaceId: ctx.workspaceId,
|
||||
});
|
||||
return result ?? null;
|
||||
}),
|
||||
@@ -492,7 +528,7 @@ export const deviceRouter = router({
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const result = await deviceGateway.executeToolCall(
|
||||
{ deviceId: input.deviceId, userId: ctx.userId },
|
||||
{ deviceId: input.deviceId, userId: ctx.userId, workspaceId: ctx.workspaceId },
|
||||
{
|
||||
apiName: 'getAgentProfile',
|
||||
arguments: JSON.stringify({ platform: input.platform }),
|
||||
@@ -517,7 +553,7 @@ export const deviceRouter = router({
|
||||
getDeviceSystemInfo: deviceProcedure
|
||||
.input(z.object({ deviceId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return deviceGateway.queryDeviceSystemInfo(ctx.userId, input.deviceId);
|
||||
return deviceGateway.queryDeviceSystemInfo(ctx.userId, input.deviceId, ctx.workspaceId);
|
||||
}),
|
||||
|
||||
/**
|
||||
@@ -533,76 +569,171 @@ export const deviceRouter = router({
|
||||
* a currently-reachable device during rollout.
|
||||
*/
|
||||
listDevices: deviceProcedure.query(async ({ ctx }): Promise<DeviceListItem[]> => {
|
||||
const [registered, onlineList] = await Promise.all([
|
||||
ctx.deviceModel.query(),
|
||||
const wsId = ctx.workspaceId;
|
||||
|
||||
// Personal devices resolve under the user principal; workspace devices under
|
||||
// the `workspace:<id>` principal (a separate gateway pool). Fetch both.
|
||||
const [personalRows, workspaceRows, personalOnline, workspaceOnline] = await Promise.all([
|
||||
ctx.deviceModel.queryPersonal(),
|
||||
wsId ? ctx.deviceModel.queryWorkspaceDevices() : Promise.resolve([]),
|
||||
deviceGateway.queryDeviceList(ctx.userId),
|
||||
wsId ? deviceGateway.queryDeviceList(ctx.userId, wsId) : Promise.resolve([]),
|
||||
]);
|
||||
|
||||
// The gateway already groups by device, exposing live sessions as nested
|
||||
// `channels`. Flatten them into the UI-facing channel shape; fall back to a
|
||||
// single synthetic channel for a legacy gateway that omits the field.
|
||||
const channelsByDevice = new Map<string, DeviceChannel[]>();
|
||||
for (const conn of onlineList) {
|
||||
const channels: DeviceChannel[] =
|
||||
conn.channels && conn.channels.length > 0
|
||||
? conn.channels.map((c) => ({
|
||||
channel: c.channel ?? null,
|
||||
connectedAt: c.connectedAt,
|
||||
// `channels`. Flatten one connection into the UI-facing channel shape; fall
|
||||
// back to a single synthetic channel for a legacy gateway that omits the field.
|
||||
const toChannels = (conn: DeviceAttachment): DeviceChannel[] =>
|
||||
conn.channels && conn.channels.length > 0
|
||||
? conn.channels.map((c) => ({
|
||||
channel: c.channel ?? null,
|
||||
connectedAt: c.connectedAt,
|
||||
hostname: conn.hostname ?? null,
|
||||
platform: conn.platform ?? null,
|
||||
}))
|
||||
: [
|
||||
{
|
||||
channel: null,
|
||||
connectedAt: conn.lastSeen,
|
||||
hostname: conn.hostname ?? null,
|
||||
platform: conn.platform ?? null,
|
||||
}))
|
||||
: [
|
||||
{
|
||||
channel: null,
|
||||
connectedAt: conn.lastSeen,
|
||||
hostname: conn.hostname ?? null,
|
||||
platform: conn.platform ?? null,
|
||||
},
|
||||
];
|
||||
channelsByDevice.set(conn.deviceId, channels);
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
const seen = new Set<string>();
|
||||
// Merge a DB-registered set with its live gateway pool into the UI shape.
|
||||
// `scope` tags the group; deviceIds never collide across pools (a personal id
|
||||
// is derived from userId, a workspace id from workspaceId).
|
||||
const buildItems = (
|
||||
rows: Awaited<ReturnType<typeof ctx.deviceModel.queryPersonal>>,
|
||||
onlineList: DeviceAttachment[],
|
||||
scope: DeviceScope,
|
||||
): DeviceListItem[] => {
|
||||
const channelsByDevice = new Map<string, DeviceChannel[]>();
|
||||
for (const conn of onlineList) channelsByDevice.set(conn.deviceId, toChannels(conn));
|
||||
|
||||
const fromDb = registered.map((d) => {
|
||||
seen.add(d.deviceId);
|
||||
const channels = channelsByDevice.get(d.deviceId) ?? [];
|
||||
const live = channels[0];
|
||||
return {
|
||||
channels,
|
||||
defaultCwd: d.defaultCwd,
|
||||
deviceId: d.deviceId,
|
||||
friendlyName: d.friendlyName,
|
||||
hostname: d.hostname ?? live?.hostname ?? null,
|
||||
identitySource: d.identitySource,
|
||||
lastSeen: d.lastSeenAt.toISOString(),
|
||||
online: channels.length > 0,
|
||||
platform: d.platform ?? live?.platform ?? null,
|
||||
registered: true,
|
||||
workingDirs: d.workingDirs ?? [],
|
||||
};
|
||||
});
|
||||
const seen = new Set<string>();
|
||||
const fromDb = rows.map((d): DeviceListItem => {
|
||||
seen.add(d.deviceId);
|
||||
const channels = channelsByDevice.get(d.deviceId) ?? [];
|
||||
const live = channels[0];
|
||||
return {
|
||||
channels,
|
||||
defaultCwd: d.defaultCwd,
|
||||
deviceId: d.deviceId,
|
||||
friendlyName: d.friendlyName,
|
||||
hostname: d.hostname ?? live?.hostname ?? null,
|
||||
identitySource: d.identitySource,
|
||||
lastSeen: d.lastSeenAt.toISOString(),
|
||||
online: channels.length > 0,
|
||||
platform: d.platform ?? live?.platform ?? null,
|
||||
registered: true,
|
||||
scope,
|
||||
workingDirs: d.workingDirs ?? [],
|
||||
};
|
||||
});
|
||||
|
||||
// Online but not yet persisted — transient until the client auto-registers.
|
||||
const ghosts = [...channelsByDevice.entries()]
|
||||
.filter(([deviceId]) => !seen.has(deviceId))
|
||||
.map(([deviceId, channels]) => ({
|
||||
channels,
|
||||
defaultCwd: null,
|
||||
deviceId,
|
||||
friendlyName: null,
|
||||
hostname: channels[0]?.hostname ?? null,
|
||||
identitySource: null,
|
||||
lastSeen: channels[0]?.connectedAt ?? new Date().toISOString(),
|
||||
online: true,
|
||||
platform: channels[0]?.platform ?? null,
|
||||
registered: false,
|
||||
workingDirs: [] as WorkingDirEntry[],
|
||||
}));
|
||||
// Online but not yet persisted — transient until the client auto-registers.
|
||||
const ghosts = [...channelsByDevice.entries()]
|
||||
.filter(([deviceId]) => !seen.has(deviceId))
|
||||
.map(
|
||||
([deviceId, channels]): DeviceListItem => ({
|
||||
channels,
|
||||
defaultCwd: null,
|
||||
deviceId,
|
||||
friendlyName: null,
|
||||
hostname: channels[0]?.hostname ?? null,
|
||||
identitySource: null,
|
||||
lastSeen: channels[0]?.connectedAt ?? new Date().toISOString(),
|
||||
online: true,
|
||||
platform: channels[0]?.platform ?? null,
|
||||
registered: false,
|
||||
scope,
|
||||
workingDirs: [] as WorkingDirEntry[],
|
||||
}),
|
||||
);
|
||||
|
||||
return [...fromDb, ...ghosts];
|
||||
return [...fromDb, ...ghosts];
|
||||
};
|
||||
|
||||
return [
|
||||
...buildItems(personalRows, personalOnline, 'personal'),
|
||||
...buildItems(workspaceRows, workspaceOnline, 'workspace'),
|
||||
];
|
||||
}),
|
||||
|
||||
/**
|
||||
* Mint a short-lived connect token for enrolling a WORKSPACE-owned device.
|
||||
* Owner-only (`wsOwnerProcedure`) — the server verifies the caller is an admin
|
||||
* of the workspace, then signs a token carrying the `workspace_id` claim that
|
||||
* the device gateway trusts to route the device to the `workspace:<id>`
|
||||
* principal. The CLI (`lh connect --workspace`) / settings page use this.
|
||||
*/
|
||||
mintWorkspaceConnectToken: wsOwnerProcedure.mutation(async ({ ctx }) => {
|
||||
const token = await signWorkspaceDeviceToken(ctx.workspaceId);
|
||||
return { token, workspaceId: ctx.workspaceId };
|
||||
}),
|
||||
|
||||
/**
|
||||
* Enroll the calling machine as a device of the current workspace. Owner-only;
|
||||
* stamps `workspace_id` so the row belongs to the workspace. Used by
|
||||
* `lh connect --workspace` after minting the connect token.
|
||||
*/
|
||||
registerWorkspaceDevice: wsOwnerProcedure
|
||||
.use(serverDatabase)
|
||||
.input(
|
||||
z.object({
|
||||
deviceId: z.string().min(1).max(64),
|
||||
hostname: z.string().nullable().optional(),
|
||||
identitySource: z.enum(['machine-id', 'fallback']),
|
||||
platform: z.string().max(20).nullable().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const model = new DeviceModel(ctx.serverDB, ctx.userId, ctx.workspaceId);
|
||||
return model.registerWorkspaceDevice({ ...input, workspaceId: ctx.workspaceId });
|
||||
}),
|
||||
|
||||
/**
|
||||
* Rename / set working dirs of a WORKSPACE device — scoped by `workspace_id`,
|
||||
* owner-gated, so any workspace owner can manage it (not just the enroller).
|
||||
* Mirrors {@link deviceRouter.updateDevice} but for the workspace pool.
|
||||
*/
|
||||
updateWorkspaceDevice: wsOwnerProcedure
|
||||
.use(serverDatabase)
|
||||
.input(
|
||||
z.object({
|
||||
defaultCwd: z.string().nullable().optional(),
|
||||
deviceId: z.string(),
|
||||
friendlyName: z.string().max(100).nullable().optional(),
|
||||
workingDirs: z
|
||||
.array(z.object({ path: z.string(), repoType: z.enum(['git', 'github']).optional() }))
|
||||
.max(20)
|
||||
.optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const model = new DeviceModel(ctx.serverDB, ctx.userId, ctx.workspaceId);
|
||||
const { deviceId, workingDirs, ...value } = input;
|
||||
const nextWorkingDirs = workingDirs
|
||||
? preserveWorkspaceCache(
|
||||
workingDirs,
|
||||
(await model.findWorkspaceDeviceById(deviceId))?.workingDirs ?? [],
|
||||
)
|
||||
: undefined;
|
||||
await model.updateWorkspaceDevice(deviceId, { ...value, workingDirs: nextWorkingDirs });
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
/** Remove a WORKSPACE device — scoped by `workspace_id`, owner-gated. */
|
||||
removeWorkspaceDevice: wsOwnerProcedure
|
||||
.use(serverDatabase)
|
||||
.input(z.object({ deviceId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const model = new DeviceModel(ctx.serverDB, ctx.userId, ctx.workspaceId);
|
||||
await model.deleteWorkspaceDevice(input.deviceId);
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
/**
|
||||
* Auto-register the calling device (desktop after OIDC login / CLI on first
|
||||
* `lh connect`). Upserts on (userId, deviceId); user-owned fields are
|
||||
@@ -629,7 +760,7 @@ export const deviceRouter = router({
|
||||
}),
|
||||
|
||||
status: deviceProcedure.query(async ({ ctx }) => {
|
||||
return deviceGateway.queryDeviceStatus(ctx.userId);
|
||||
return deviceGateway.queryDeviceStatus(ctx.userId, ctx.workspaceId);
|
||||
}),
|
||||
|
||||
/** User-editable fields only — never the machine-reported identity columns. */
|
||||
|
||||
@@ -900,6 +900,62 @@ export const taskRouter = router({
|
||||
}
|
||||
}),
|
||||
|
||||
getVerifyConfig: taskProcedure.input(idInput).query(async ({ input, ctx }) => {
|
||||
try {
|
||||
const model = ctx.taskModel;
|
||||
const task = await resolveOrThrow(model, input.id);
|
||||
return { data: model.getVerifyConfig(task) || null, success: true };
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) throw error;
|
||||
console.error('[task:getVerifyConfig]', error);
|
||||
throw new TRPCError({
|
||||
cause: error,
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: 'Failed to get verify config',
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
updateVerifyConfig: taskProcedureWrite
|
||||
.input(
|
||||
idInput.merge(
|
||||
z.object({
|
||||
// `.nullish()` lets callers clear a saved field: `null` removes it
|
||||
// (JSON can't send `undefined`), omission leaves it untouched. See
|
||||
// TaskModel.updateVerifyConfig.
|
||||
verify: z.object({
|
||||
enabled: z.boolean().nullish(),
|
||||
maxIterations: z.number().min(1).max(10).nullish(),
|
||||
verifierAgentId: z.string().nullish(),
|
||||
verifyCriteriaIds: z.array(z.string()).nullish(),
|
||||
verifyRubricId: z.string().nullish(),
|
||||
}),
|
||||
}),
|
||||
),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { id, verify } = input;
|
||||
try {
|
||||
const model = ctx.taskModel;
|
||||
const resolved = await resolveOrThrow(model, id);
|
||||
const task = await model.updateVerifyConfig(resolved.id, verify);
|
||||
if (!task) throw new TRPCError({ code: 'NOT_FOUND', message: 'Task not found' });
|
||||
return {
|
||||
data: model.getVerifyConfig(task),
|
||||
message: 'Verify config updated',
|
||||
success: true,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) throw error;
|
||||
console.error('[task:updateVerifyConfig]', error);
|
||||
throw new TRPCError({
|
||||
cause: error,
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: 'Failed to update verify config',
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
runReview: taskProcedureWrite
|
||||
.input(
|
||||
idInput.merge(
|
||||
|
||||
@@ -1,13 +1,26 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { asc, eq } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { wsCompatProcedure } from '@/business/server/trpc-middlewares/workspaceAuth';
|
||||
import { AgentOperationModel } from '@/database/models/agentOperation';
|
||||
import { FileModel } from '@/database/models/file';
|
||||
import { LlmGenerationTracingModel } from '@/database/models/llmGenerationTracing';
|
||||
import { VerifyCheckResultModel } from '@/database/models/verifyCheckResult';
|
||||
import { VerifyCriterionModel } from '@/database/models/verifyCriterion';
|
||||
import { VerifyEvidenceModel } from '@/database/models/verifyEvidence';
|
||||
import { VerifyReportModel } from '@/database/models/verifyReport';
|
||||
import { VerifyRubricModel } from '@/database/models/verifyRubric';
|
||||
import { router } from '@/libs/trpc/lambda';
|
||||
import { VerifyRunModel } from '@/database/models/verifyRun';
|
||||
import {
|
||||
verifyCheckResults,
|
||||
verifyEvidence,
|
||||
verifyReports,
|
||||
verifyRuns,
|
||||
} from '@/database/schemas/verify';
|
||||
import { publicProcedure, router } from '@/libs/trpc/lambda';
|
||||
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
|
||||
import { FileService } from '@/server/services/file';
|
||||
import {
|
||||
VerifyExecutorService,
|
||||
VerifyFeedbackService,
|
||||
@@ -18,6 +31,28 @@ const verifierTypeSchema = z.enum(['program', 'agent', 'llm']);
|
||||
const onFailSchema = z.enum(['manual', 'auto_repair']);
|
||||
const decisionSchema = z.enum(['accepted', 'rejected', 'overridden']);
|
||||
const modelConfigSchema = z.object({ model: z.string(), provider: z.string() });
|
||||
const verdictSchema = z.enum(['passed', 'failed', 'uncertain']);
|
||||
const checkStatusSchema = z.enum(['pending', 'running', 'passed', 'failed', 'skipped']);
|
||||
const runSourceSchema = z.enum(['agent', 'agent-testing']);
|
||||
const evidenceTypeSchema = z.enum([
|
||||
'screenshot',
|
||||
'gif',
|
||||
'video',
|
||||
'text',
|
||||
'dom_snapshot',
|
||||
'transcript',
|
||||
]);
|
||||
const evidenceCapturedBySchema = z.enum(['agent-browser', 'cdp', 'cli', 'program', 'llm_judge']);
|
||||
const toulminSchema = z.object({
|
||||
counterEvidence: z.string().optional(),
|
||||
evidence: z.string().optional(),
|
||||
limitation: z.string().optional(),
|
||||
reasoning: z.string().optional(),
|
||||
});
|
||||
|
||||
/** Derive the lifecycle status from a verdict when the caller doesn't pin one. */
|
||||
const statusForVerdict = (verdict: 'passed' | 'failed' | 'uncertain') =>
|
||||
verdict === 'passed' ? ('passed' as const) : ('failed' as const);
|
||||
|
||||
/** Run-policy knobs persisted on a rubric (see VerifyRubricConfig). */
|
||||
const rubricConfigSchema = z.object({
|
||||
@@ -36,23 +71,67 @@ const checkItemSchema = z.object({
|
||||
verifierType: verifierTypeSchema,
|
||||
});
|
||||
|
||||
const verifyRunIdInputSchema = z.object({ verifyRunId: z.string() });
|
||||
|
||||
const uploadEvidenceInputSchema = z
|
||||
.object({
|
||||
capturedBy: evidenceCapturedBySchema.optional(),
|
||||
// Exactly one of `content` (inline text) or `fileId` (already-uploaded artifact).
|
||||
checkResultId: z.string(),
|
||||
content: z.string().min(1).optional(),
|
||||
description: z.string().optional(),
|
||||
fileId: z.string().min(1).optional(),
|
||||
type: evidenceTypeSchema,
|
||||
})
|
||||
.refine((data) => Boolean(data.content) !== Boolean(data.fileId), {
|
||||
message: 'Provide exactly one of `content` or `fileId`.',
|
||||
});
|
||||
|
||||
const verifyProcedure = wsCompatProcedure.use(serverDatabase).use(async (opts) => {
|
||||
const { ctx } = opts;
|
||||
const workspaceId = ctx.workspaceId ?? undefined;
|
||||
return opts.next({
|
||||
ctx: {
|
||||
criterionModel: new VerifyCriterionModel(ctx.serverDB, ctx.userId, workspaceId),
|
||||
evidenceModel: new VerifyEvidenceModel(ctx.serverDB, ctx.userId, workspaceId),
|
||||
executorService: new VerifyExecutorService(ctx.serverDB, ctx.userId, workspaceId),
|
||||
tracingModel: new LlmGenerationTracingModel(ctx.serverDB, ctx.userId, workspaceId),
|
||||
feedbackService: new VerifyFeedbackService(ctx.serverDB, ctx.userId, workspaceId),
|
||||
operationModel: new AgentOperationModel(ctx.serverDB, ctx.userId, workspaceId),
|
||||
planGenerator: new VerifyPlanGeneratorService(ctx.serverDB, ctx.userId, workspaceId),
|
||||
reportModel: new VerifyReportModel(ctx.serverDB, ctx.userId, workspaceId),
|
||||
resultModel: new VerifyCheckResultModel(ctx.serverDB, ctx.userId, workspaceId),
|
||||
rubricModel: new VerifyRubricModel(ctx.serverDB, ctx.userId, workspaceId),
|
||||
runModel: new VerifyRunModel(ctx.serverDB, ctx.userId, workspaceId),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const publicVerifyReportProcedure = publicProcedure.use(serverDatabase);
|
||||
|
||||
const resolveVerifyRun = async (ctx: { runModel: VerifyRunModel }, verifyRunId: string) => {
|
||||
const run = await ctx.runModel.findById(verifyRunId);
|
||||
|
||||
if (!run) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Verification run not found' });
|
||||
}
|
||||
|
||||
return run;
|
||||
};
|
||||
|
||||
const resolveCheckResult = async (
|
||||
ctx: { resultModel: VerifyCheckResultModel },
|
||||
checkResultId: string,
|
||||
) => {
|
||||
const result = await ctx.resultModel.findById(checkResultId);
|
||||
|
||||
if (!result) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Verification check result not found' });
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const verifyRouter = router({
|
||||
// ---- criteria (reusable atomic standards) ----
|
||||
createCriterion: verifyProcedure
|
||||
@@ -143,7 +222,10 @@ export const verifyRouter = router({
|
||||
// ---- per-run plan ----
|
||||
confirmPlan: verifyProcedure
|
||||
.input(z.object({ operationId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => ctx.operationModel.confirmVerifyPlan(input.operationId)),
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const run = await ctx.runModel.ensureForOperation(input.operationId);
|
||||
return ctx.runModel.confirmPlan(run.id);
|
||||
}),
|
||||
|
||||
generateDraftPlan: verifyProcedure
|
||||
.input(
|
||||
@@ -188,19 +270,21 @@ export const verifyRouter = router({
|
||||
|
||||
getVerifyState: verifyProcedure
|
||||
.input(z.object({ operationId: z.string() }))
|
||||
.query(async ({ ctx, input }) => ctx.operationModel.getVerifyState(input.operationId)),
|
||||
.query(async ({ ctx, input }) => ctx.runModel.getStateByOperation(input.operationId)),
|
||||
|
||||
skipPlan: verifyProcedure
|
||||
.input(z.object({ operationId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
ctx.operationModel.updateVerifyStatus(input.operationId, null),
|
||||
),
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const run = await ctx.runModel.findByOperation(input.operationId);
|
||||
if (run) await ctx.runModel.updateStatus(run.id, null);
|
||||
}),
|
||||
|
||||
updateDraftItems: verifyProcedure
|
||||
.input(z.object({ items: z.array(checkItemSchema), operationId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
ctx.operationModel.replaceVerifyPlanItems(input.operationId, input.items),
|
||||
),
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const run = await ctx.runModel.ensureForOperation(input.operationId);
|
||||
return ctx.runModel.replacePlanItems(run.id, input.items);
|
||||
}),
|
||||
|
||||
// ---- results / execution ----
|
||||
executeVerify: verifyProcedure
|
||||
@@ -215,12 +299,16 @@ export const verifyRouter = router({
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ctx.executorService.execute(input);
|
||||
return ctx.resultModel.listByOperation(input.operationId);
|
||||
const run = await ctx.runModel.findByOperation(input.operationId);
|
||||
return run ? ctx.resultModel.listByRun(run.id) : [];
|
||||
}),
|
||||
|
||||
listResults: verifyProcedure
|
||||
.input(z.object({ operationId: z.string() }))
|
||||
.query(async ({ ctx, input }) => ctx.resultModel.listByOperation(input.operationId)),
|
||||
.query(async ({ ctx, input }) => {
|
||||
const run = await ctx.runModel.findByOperation(input.operationId);
|
||||
return run ? ctx.resultModel.listByRun(run.id) : [];
|
||||
}),
|
||||
|
||||
// ---- feedback (data flywheel) ----
|
||||
submitDecision: verifyProcedure
|
||||
@@ -228,4 +316,202 @@ export const verifyRouter = router({
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
ctx.feedbackService.submitDecision(input.resultId, input.decision),
|
||||
),
|
||||
|
||||
// ---- ingest (standalone sessions: results / evidence / report, e.g. agent-testing) ----
|
||||
// A verification session that isn't a live Agent Run (no executor): an external
|
||||
// harness creates the run, ingests each check's verdict + evidence, and writes a
|
||||
// report — all keyed by verifyRunId.
|
||||
createRun: verifyProcedure
|
||||
.input(
|
||||
z.object({
|
||||
goal: z.string().optional(),
|
||||
operationId: z.string().optional(),
|
||||
source: runSourceSchema.optional(),
|
||||
title: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
ctx.runModel.create({
|
||||
goal: input.goal,
|
||||
operationId: input.operationId,
|
||||
source: input.source ?? 'agent-testing',
|
||||
title: input.title,
|
||||
}),
|
||||
),
|
||||
|
||||
getRun: verifyProcedure
|
||||
.input(verifyRunIdInputSchema)
|
||||
.query(async ({ ctx, input }) => ctx.runModel.findById(input.verifyRunId)),
|
||||
|
||||
listRuns: verifyProcedure.query(async ({ ctx }) => ctx.runModel.query()),
|
||||
|
||||
listResultsByRun: verifyProcedure.input(verifyRunIdInputSchema).query(async ({ ctx, input }) => {
|
||||
const run = await resolveVerifyRun(ctx, input.verifyRunId);
|
||||
return ctx.resultModel.listByRun(run.id);
|
||||
}),
|
||||
|
||||
ingestResult: verifyProcedure
|
||||
.input(
|
||||
z.object({
|
||||
checkItemId: z.string(),
|
||||
checkItemIndex: z.number().optional(),
|
||||
checkItemTitle: z.string().optional(),
|
||||
confidence: z.number().min(0).max(1).optional(),
|
||||
required: z.boolean().optional(),
|
||||
status: checkStatusSchema.optional(),
|
||||
suggestion: z.string().optional(),
|
||||
toulmin: toulminSchema.optional(),
|
||||
verdict: verdictSchema,
|
||||
verifierType: verifierTypeSchema.optional(),
|
||||
verifyRunId: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const run = await resolveVerifyRun(ctx, input.verifyRunId);
|
||||
|
||||
return ctx.resultModel.upsertByCheckItem({
|
||||
checkItemId: input.checkItemId,
|
||||
checkItemIndex: input.checkItemIndex,
|
||||
checkItemTitle: input.checkItemTitle,
|
||||
completedAt: new Date(),
|
||||
confidence: input.confidence,
|
||||
required: input.required ?? true,
|
||||
status: input.status ?? statusForVerdict(input.verdict),
|
||||
suggestion: input.suggestion,
|
||||
toulmin: input.toulmin,
|
||||
verdict: input.verdict,
|
||||
verifierType: input.verifierType ?? 'agent',
|
||||
verifyRunId: run.id,
|
||||
});
|
||||
}),
|
||||
|
||||
uploadEvidence: verifyProcedure
|
||||
.input(uploadEvidenceInputSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const result = await resolveCheckResult(ctx, input.checkResultId);
|
||||
|
||||
return ctx.evidenceModel.create({
|
||||
capturedAt: new Date(),
|
||||
capturedBy: input.capturedBy ?? null,
|
||||
checkResultId: result.id,
|
||||
content: input.content ?? null,
|
||||
description: input.description ?? null,
|
||||
fileId: input.fileId ?? null,
|
||||
type: input.type,
|
||||
});
|
||||
}),
|
||||
|
||||
listEvidence: verifyProcedure
|
||||
.input(z.object({ checkResultId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const result = await resolveCheckResult(ctx, input.checkResultId);
|
||||
return ctx.evidenceModel.listByCheckResult(result.id);
|
||||
}),
|
||||
|
||||
upsertReport: verifyProcedure
|
||||
.input(
|
||||
z.object({
|
||||
content: z.string().optional(),
|
||||
failedChecks: z.number().optional(),
|
||||
generatedBy: z.string().optional(),
|
||||
overallConfidence: z.number().min(0).max(1).optional(),
|
||||
passedChecks: z.number().optional(),
|
||||
summary: z.string().optional(),
|
||||
totalChecks: z.number().optional(),
|
||||
uncertainChecks: z.number().optional(),
|
||||
verdict: verdictSchema.optional(),
|
||||
verifyRunId: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const run = await resolveVerifyRun(ctx, input.verifyRunId);
|
||||
|
||||
return ctx.reportModel.upsertByRun({
|
||||
content: input.content ?? null,
|
||||
failedChecks: input.failedChecks ?? null,
|
||||
generatedBy: input.generatedBy ?? 'agent-testing',
|
||||
overallConfidence: input.overallConfidence ?? null,
|
||||
passedChecks: input.passedChecks ?? null,
|
||||
summary: input.summary ?? null,
|
||||
totalChecks: input.totalChecks ?? null,
|
||||
uncertainChecks: input.uncertainChecks ?? null,
|
||||
verdict: input.verdict ?? null,
|
||||
verifyRunId: run.id,
|
||||
});
|
||||
}),
|
||||
|
||||
getReport: verifyProcedure.input(verifyRunIdInputSchema).query(async ({ ctx, input }) => {
|
||||
const run = await resolveVerifyRun(ctx, input.verifyRunId);
|
||||
return ctx.reportModel.findByRun(run.id);
|
||||
}),
|
||||
|
||||
/**
|
||||
* One-shot payload for the standalone report viewer: the session, its report,
|
||||
* and every check result with its evidence — addressed purely by verifyRunId
|
||||
* (no operation / chat context required).
|
||||
*/
|
||||
getReportBundle: publicVerifyReportProcedure
|
||||
.input(verifyRunIdInputSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const run = await ctx.serverDB.query.verifyRuns.findFirst({
|
||||
where: eq(verifyRuns.id, input.verifyRunId),
|
||||
});
|
||||
if (!run) return null;
|
||||
const [report, results] = await Promise.all([
|
||||
ctx.serverDB.query.verifyReports.findFirst({
|
||||
where: eq(verifyReports.verifyRunId, input.verifyRunId),
|
||||
}),
|
||||
ctx.serverDB
|
||||
.select()
|
||||
.from(verifyCheckResults)
|
||||
.where(eq(verifyCheckResults.verifyRunId, input.verifyRunId))
|
||||
.orderBy(asc(verifyCheckResults.checkItemIndex)),
|
||||
]);
|
||||
|
||||
// Resolve a displayable URL for each file-backed evidence artifact.
|
||||
let fileService: FileService | null | undefined;
|
||||
const getFileService = () => {
|
||||
if (fileService !== undefined) return fileService;
|
||||
|
||||
try {
|
||||
fileService = new FileService(ctx.serverDB, run.userId, run.workspaceId ?? undefined);
|
||||
} catch (error) {
|
||||
console.error('[verify:getReportBundle:resolveFileUrl]', error);
|
||||
fileService = null;
|
||||
}
|
||||
|
||||
return fileService;
|
||||
};
|
||||
const resolveFileUrl = async (fileId: string | null) => {
|
||||
if (!fileId) return null;
|
||||
|
||||
try {
|
||||
const file = await FileModel.getFileById(ctx.serverDB, fileId);
|
||||
if (!file?.url) return null;
|
||||
|
||||
const service = getFileService();
|
||||
return service ? await service.getFullFileUrl(file.url) : null;
|
||||
} catch (error) {
|
||||
console.error('[verify:getReportBundle:resolveFileUrl]', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const resultsWithEvidence = await Promise.all(
|
||||
results.map(async (r) => {
|
||||
const evidence = await ctx.serverDB
|
||||
.select()
|
||||
.from(verifyEvidence)
|
||||
.where(eq(verifyEvidence.checkResultId, r.id))
|
||||
.orderBy(asc(verifyEvidence.createdAt));
|
||||
return {
|
||||
...r,
|
||||
evidence: await Promise.all(
|
||||
evidence.map(async (e) => ({ ...e, fileUrl: await resolveFileUrl(e.fileId) })),
|
||||
),
|
||||
};
|
||||
}),
|
||||
);
|
||||
return { report: report ?? null, results: resultsWithEvidence, run };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type RecordOperationStartParams,
|
||||
} from '@/database/models/agentOperation';
|
||||
import { MessageModel } from '@/database/models/message';
|
||||
import { VerifyRunModel } from '@/database/models/verifyRun';
|
||||
import { type LobeChatDatabase } from '@/database/type';
|
||||
import { formatErrorForState } from '@/server/modules/AgentRuntime/formatErrorForState';
|
||||
import { buildFinalSnapshotKey } from '@/server/modules/AgentTracing';
|
||||
@@ -278,11 +279,10 @@ export class CompletionLifecycle {
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const operationModel = new AgentOperationModel(this.serverDB, userId);
|
||||
const state = await operationModel.getVerifyState(operationId);
|
||||
if (!state?.verifyPlan?.length) return;
|
||||
const run = await new VerifyRunModel(this.serverDB, userId).findByOperation(operationId);
|
||||
if (!run?.plan?.length) return;
|
||||
|
||||
const op = await operationModel.findById(operationId);
|
||||
const op = await new AgentOperationModel(this.serverDB, userId).findById(operationId);
|
||||
if (!op?.topicId) return;
|
||||
|
||||
const messageModel = new MessageModel(this.serverDB, userId);
|
||||
@@ -422,14 +422,24 @@ export class CompletionLifecycle {
|
||||
? Date.now() - new Date(state.createdAt).getTime()
|
||||
: undefined;
|
||||
|
||||
// On the error path, normalize the runtime error once so the lifecycle
|
||||
// event carries the stable taxonomy fields (errorType + attribution). Bot
|
||||
// reply renderers switch on these to surface a perceivable cause (network /
|
||||
// quota / provider outage …) instead of an opaque Operation ID. Mirrors the
|
||||
// same normalization dispatchHooks runs before writing the error onto the
|
||||
// assistant message row.
|
||||
const formattedError = state?.error ? formatErrorForState(state.error) : undefined;
|
||||
|
||||
return {
|
||||
event: {
|
||||
agentId: metadata?.agentId || '',
|
||||
attachments: attachments.length > 0 ? attachments : undefined,
|
||||
cost: state?.cost?.total,
|
||||
duration,
|
||||
errorAttribution: formattedError?.attribution,
|
||||
errorDetail: state?.error,
|
||||
errorMessage: this.extractErrorMessage(state?.error) || String(state?.error || ''),
|
||||
errorType: formattedError?.type === undefined ? undefined : String(formattedError.type),
|
||||
finalState: state,
|
||||
lastAssistantContent,
|
||||
llmCalls: state?.usage?.llm?.apiCalls,
|
||||
|
||||
@@ -196,6 +196,30 @@ describe('CompletionLifecycle.buildLifecycleEvent', () => {
|
||||
expect(event.attachments).toBeUndefined();
|
||||
expect(event.agentId).toBe('a');
|
||||
});
|
||||
|
||||
it('populates errorType + attribution from the normalized error on the error path', () => {
|
||||
// Regression: the event previously carried only errorDetail/errorMessage, so
|
||||
// bot reply renderers never saw the stable code/attribution and always fell
|
||||
// back to the opaque Operation ID. buildLifecycleEvent must normalize the
|
||||
// runtime error via formatErrorForState and surface these taxonomy fields.
|
||||
const state = {
|
||||
error: { error: { message: 'fetch failed' }, errorType: 'ProviderNetworkError' },
|
||||
metadata: { agentId: 'agent-1', userId: 'user-1' },
|
||||
};
|
||||
|
||||
const { event } = callBuild(state, 'error');
|
||||
|
||||
expect(event.errorType).toBe('ProviderNetworkError');
|
||||
expect(event.errorAttribution).toBe('system');
|
||||
expect(event.errorMessage).toBe('fetch failed');
|
||||
});
|
||||
|
||||
it('leaves errorType + attribution undefined when there is no error', () => {
|
||||
const { event } = callBuild({ messages: [], metadata: {} }, 'done');
|
||||
|
||||
expect(event.errorType).toBeUndefined();
|
||||
expect(event.errorAttribution).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('CompletionLifecycle.dispatchHooks — error persistence', () => {
|
||||
|
||||
@@ -371,6 +371,24 @@ export class AiAgentService {
|
||||
return task?.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* If `deviceId` is a device enrolled into the caller's current workspace,
|
||||
* return that workspaceId so device-gateway calls route to the
|
||||
* `workspace:<id>` principal. Returns undefined for a personal device (or no
|
||||
* workspace context), keeping the personal path byte-identical.
|
||||
*/
|
||||
private async resolveDeviceWorkspaceId(
|
||||
deviceId: string | undefined,
|
||||
): Promise<string | undefined> {
|
||||
if (!deviceId || !this.workspaceId) return undefined;
|
||||
const row = await new DeviceModel(
|
||||
this.db,
|
||||
this.userId,
|
||||
this.workspaceId,
|
||||
).findWorkspaceDeviceById(deviceId);
|
||||
return row ? this.workspaceId : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the "workspace init" scan (project skills + AGENTS.md) for a run
|
||||
* bound to a device's project directory. Reads the cache on
|
||||
@@ -392,10 +410,24 @@ export class AiAgentService {
|
||||
if (!activeDeviceId) return { workspace: empty };
|
||||
|
||||
try {
|
||||
const deviceModel = new DeviceModel(this.db, this.userId);
|
||||
const device = await deviceModel.findByDeviceId(activeDeviceId);
|
||||
// The active device may be personal (userId-scoped) or workspace-owned
|
||||
// (workspace-scoped) — look up both pools so the bound cwd, project
|
||||
// skills, and AGENTS/CLAUDE instructions still resolve for a workspace
|
||||
// device. Mirrors the dispatch-side lookup (see `deviceModelForCwd`).
|
||||
const deviceModel = new DeviceModel(this.db, this.userId, this.workspaceId);
|
||||
const personalDevice = await deviceModel.findByDeviceId(activeDeviceId);
|
||||
const workspaceDevice = personalDevice
|
||||
? undefined
|
||||
: await deviceModel.findWorkspaceDeviceById(activeDeviceId);
|
||||
const device = personalDevice ?? workspaceDevice;
|
||||
if (!device) return { workspace: empty };
|
||||
|
||||
// For a workspace-owned device, route the gateway RPC to the
|
||||
// `workspace:<id>` principal and persist the scan via the workspace
|
||||
// update path — otherwise the scan goes through the personal pool
|
||||
// (empty result) and the writeback misses the row.
|
||||
const deviceWorkspaceId = workspaceDevice ? this.workspaceId : undefined;
|
||||
|
||||
// The bound project root we scan — resolved via the shared precedence
|
||||
// helper so it cannot drift from hetero dispatch / topic backfill. Read
|
||||
// from the persisted `device.defaultCwd` (not a live device query, which
|
||||
@@ -423,6 +455,7 @@ export class AiAgentService {
|
||||
deviceId: activeDeviceId,
|
||||
scope: boundCwd,
|
||||
userId: this.userId,
|
||||
workspaceId: deviceWorkspaceId,
|
||||
});
|
||||
if (!scanned) {
|
||||
// Scan failed (offline mid-run / parse error). Fall back to a stale
|
||||
@@ -435,9 +468,15 @@ export class AiAgentService {
|
||||
}
|
||||
|
||||
// Persist the fresh scan back onto `workingDirs` (update in place or prepend
|
||||
// a new MRU entry), keeping the JSONB payload bounded.
|
||||
// a new MRU entry), keeping the JSONB payload bounded. Workspace devices
|
||||
// are owned by the workspace, not a userId — use the workspace-scoped
|
||||
// update path so the writeback actually lands.
|
||||
const updated = upsertWorkspaceScan(workingDirs, boundCwd, scanned, Date.now());
|
||||
await deviceModel.update(activeDeviceId, { workingDirs: updated });
|
||||
if (deviceWorkspaceId) {
|
||||
await deviceModel.updateWorkspaceDevice(activeDeviceId, { workingDirs: updated });
|
||||
} else {
|
||||
await deviceModel.update(activeDeviceId, { workingDirs: updated });
|
||||
}
|
||||
log('execAgent: scanned and cached workspace init for %s', boundCwd);
|
||||
|
||||
return { boundCwd, workspace: scanned };
|
||||
@@ -1401,8 +1440,9 @@ export class AiAgentService {
|
||||
|
||||
// lh connect only handles tool_call_request (not agent_run_request),
|
||||
// so we use executeToolCall with the runHeteroTask tool instead of dispatchAgentRun.
|
||||
const remoteDeviceWorkspaceId = await this.resolveDeviceWorkspaceId(remoteDeviceId);
|
||||
const result = await deviceGateway.executeToolCall(
|
||||
{ deviceId: remoteDeviceId, userId: this.userId },
|
||||
{ deviceId: remoteDeviceId, userId: this.userId, workspaceId: remoteDeviceWorkspaceId },
|
||||
{
|
||||
apiName: 'runHeteroTask',
|
||||
arguments: JSON.stringify({
|
||||
@@ -1413,6 +1453,11 @@ export class AiAgentService {
|
||||
prompt,
|
||||
taskId: operationId,
|
||||
topicId,
|
||||
// Scope notify callbacks to the same workspace as the dispatched
|
||||
// topic so agentNotify can resolve the workspace-owned topic.
|
||||
// Without this the device's notify call falls back to personal
|
||||
// mode and TopicModel.findById returns NOT_FOUND.
|
||||
workspaceId: remoteDeviceWorkspaceId,
|
||||
}),
|
||||
identifier: 'runHeteroTask',
|
||||
},
|
||||
@@ -1510,9 +1555,13 @@ export class AiAgentService {
|
||||
// wins, else the device's user-configured defaultCwd. The device row
|
||||
// lives in the DB (the gateway only knows live connections), so read
|
||||
// it directly rather than via deviceGateway.
|
||||
const boundDevice = await new DeviceModel(this.db, this.userId).findByDeviceId(
|
||||
dispatchDeviceId,
|
||||
);
|
||||
// The bound device may be personal (userId-scoped) or a workspace
|
||||
// device (workspace-scoped) — look up both so its defaultCwd resolves.
|
||||
const deviceModelForCwd = new DeviceModel(this.db, this.userId, this.workspaceId);
|
||||
const boundDevice =
|
||||
(await deviceModelForCwd.findByDeviceId(dispatchDeviceId)) ??
|
||||
(await deviceModelForCwd.findWorkspaceDeviceById(dispatchDeviceId));
|
||||
const dispatchWorkspaceId = await this.resolveDeviceWorkspaceId(dispatchDeviceId);
|
||||
// Resolve via the shared precedence helper so dispatch, workspace-init,
|
||||
// and the new-topic backfill below all agree on the cwd.
|
||||
const deviceCwd = resolveDeviceWorkingDirectory({
|
||||
@@ -1548,6 +1597,9 @@ export class AiAgentService {
|
||||
cwd: deviceCwd,
|
||||
deviceId: dispatchDeviceId,
|
||||
systemContext: deviceSystemContext,
|
||||
// Route to the workspace pool when this is a workspace device; the
|
||||
// operation JWT stays member-scoped (the run belongs to the member).
|
||||
workspaceId: dispatchWorkspaceId,
|
||||
});
|
||||
if (!result.success) {
|
||||
log('execAgent: hetero device dispatch failed: %s', result.error);
|
||||
@@ -1871,7 +1923,16 @@ export class AiAgentService {
|
||||
const boundDeviceId = topicBoundDeviceId || agentBoundDeviceId;
|
||||
if (gatewayConfigured) {
|
||||
try {
|
||||
onlineDevices = await deviceGateway.queryDeviceList(this.userId);
|
||||
// Personal pool (user principal) ∪ the current workspace's shared pool
|
||||
// (workspace principal). Workspace devices are absent for non-workspace
|
||||
// runs, so this is identical to the personal-only fetch there.
|
||||
const [personalOnline, workspaceOnline] = await Promise.all([
|
||||
deviceGateway.queryDeviceList(this.userId),
|
||||
this.workspaceId
|
||||
? deviceGateway.queryDeviceList(this.userId, this.workspaceId)
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
onlineDevices = [...personalOnline, ...workspaceOnline];
|
||||
log('execAgent: found %d online device(s)', onlineDevices.length);
|
||||
} catch (error) {
|
||||
log('execAgent: failed to query device list: %O', error);
|
||||
@@ -1986,7 +2047,6 @@ export class AiAgentService {
|
||||
disableLocalSystem,
|
||||
executionPlan,
|
||||
globalMemoryEnabled,
|
||||
hasAgentDocuments,
|
||||
hasEnabledKnowledgeBases,
|
||||
isBotConversation,
|
||||
model,
|
||||
@@ -3812,9 +3872,14 @@ export class AiAgentService {
|
||||
runningOp.deviceId,
|
||||
taskId,
|
||||
);
|
||||
const cancelWorkspaceId = await this.resolveDeviceWorkspaceId(runningOp.deviceId);
|
||||
await deviceGateway
|
||||
.executeToolCall(
|
||||
{ deviceId: runningOp.deviceId, userId: this.userId },
|
||||
{
|
||||
deviceId: runningOp.deviceId,
|
||||
userId: this.userId,
|
||||
workspaceId: cancelWorkspaceId,
|
||||
},
|
||||
{
|
||||
apiName: 'cancelHeteroTask',
|
||||
arguments: JSON.stringify({ signal: 'SIGINT', taskId }),
|
||||
|
||||
@@ -1229,6 +1229,7 @@ export class AgentBridgeService {
|
||||
errorMsg,
|
||||
event.operationId,
|
||||
replyLocale,
|
||||
event.errorAttribution,
|
||||
);
|
||||
// Wrap in `{ markdown }` so the Chat SDK adapter sets the
|
||||
// platform's markdown parse_mode (e.g. Telegram `Markdown`,
|
||||
|
||||
@@ -55,6 +55,13 @@ export interface BotCallbackBody {
|
||||
cost?: number;
|
||||
duration?: number;
|
||||
elapsedMs?: number;
|
||||
/**
|
||||
* Error ownership from the model-runtime error taxonomy (`user` | `provider`
|
||||
* | `harness` | `system`). Drives the user-facing error message tier when the
|
||||
* exact `errorType` has no precise copy. Forwarded verbatim from the agent
|
||||
* lifecycle event.
|
||||
*/
|
||||
errorAttribution?: string;
|
||||
errorMessage?: string;
|
||||
errorType?: string;
|
||||
executionTimeMs?: number;
|
||||
@@ -379,8 +386,15 @@ export class BotCallbackService {
|
||||
charLimit?: number,
|
||||
canEdit = true,
|
||||
): Promise<void> {
|
||||
const { reason, lastAssistantContent, errorMessage, errorType, operationId, attachments } =
|
||||
body;
|
||||
const {
|
||||
reason,
|
||||
lastAssistantContent,
|
||||
errorAttribution,
|
||||
errorMessage,
|
||||
errorType,
|
||||
operationId,
|
||||
attachments,
|
||||
} = body;
|
||||
|
||||
if (reason === 'error') {
|
||||
log(
|
||||
@@ -389,7 +403,13 @@ export class BotCallbackService {
|
||||
errorType,
|
||||
errorMessage,
|
||||
);
|
||||
const errorBody = renderAgentError(errorType, errorMessage, operationId, replyLocale);
|
||||
const errorBody = renderAgentError(
|
||||
errorType,
|
||||
errorMessage,
|
||||
operationId,
|
||||
replyLocale,
|
||||
errorAttribution,
|
||||
);
|
||||
const errorText = client.formatMarkdown?.(errorBody) ?? errorBody;
|
||||
await this.deliverFirstChunk(messenger, progressMessageId, errorText, canEdit);
|
||||
return;
|
||||
|
||||
@@ -417,7 +417,70 @@ describe('replyTemplate', () => {
|
||||
expect(zh).toContain('命令会话已断开');
|
||||
});
|
||||
|
||||
it('falls back to the generic op-id template for unknown error codes', () => {
|
||||
it('keeps command-disconnect copy after the error is refined to a StateStore code', () => {
|
||||
// formatErrorForState pattern-refines "Command aborted due to connection
|
||||
// close" to StateStorePersistError (write) / StateStoreReadError (read).
|
||||
// The specific disconnect guidance must still win over the harness/system
|
||||
// tiers now that errorType is always populated.
|
||||
const persist = renderAgentError(
|
||||
'StateStorePersistError',
|
||||
'Command aborted due to connection close',
|
||||
'op-1',
|
||||
'en-US',
|
||||
'harness',
|
||||
);
|
||||
expect(persist).toContain('Command session disconnected');
|
||||
|
||||
const read = renderAgentError(
|
||||
'StateStoreReadError',
|
||||
'Command aborted due to connection close',
|
||||
'op-1',
|
||||
'en-US',
|
||||
'system',
|
||||
);
|
||||
expect(read).toContain('Command session disconnected');
|
||||
});
|
||||
|
||||
it('uses provider-neutral copy for system infra errors (no model-provider blame)', () => {
|
||||
// StateStoreReadError is system-attributed but the LLM provider is not
|
||||
// involved — the fallback must not suggest switching models / blame the
|
||||
// provider the way the network copy does.
|
||||
const en = renderAgentError(
|
||||
'StateStoreReadError',
|
||||
'Agent state not found for operation',
|
||||
'op-1',
|
||||
'en-US',
|
||||
'system',
|
||||
);
|
||||
expect(en).toContain('temporary system error');
|
||||
expect(en).not.toContain('model provider');
|
||||
expect(en).not.toMatch(/switch to a different model/i);
|
||||
expect(en).toContain('op-1');
|
||||
|
||||
const zh = renderAgentError(
|
||||
'StateStoreReadError',
|
||||
'Agent state not found for operation',
|
||||
'op-1',
|
||||
'zh-CN',
|
||||
'system',
|
||||
);
|
||||
expect(zh).toContain('临时系统错误');
|
||||
});
|
||||
|
||||
it('still gives ProviderNetworkError the provider-specific network copy', () => {
|
||||
// Regression guard: the provider-neutral system fallback must not swallow
|
||||
// the one system-attributed code that IS about the model provider.
|
||||
const out = renderAgentError(
|
||||
'ProviderNetworkError',
|
||||
'fetch failed',
|
||||
'op-1',
|
||||
'en-US',
|
||||
'system',
|
||||
);
|
||||
expect(out).toContain('Network error talking to the model provider');
|
||||
});
|
||||
|
||||
it('falls back to the generic op-id template for unknown error codes without attribution', () => {
|
||||
expect(renderAgentError('SomeNewErrorCode', undefined, 'op-1')).toBe(
|
||||
'**Agent Execution Failed**\nOperation ID: `op-1`',
|
||||
);
|
||||
@@ -426,6 +489,63 @@ describe('replyTemplate', () => {
|
||||
it('falls back to the generic header when neither errorType nor operationId is known', () => {
|
||||
expect(renderAgentError(undefined, undefined, undefined)).toBe('**Agent Execution Failed**');
|
||||
});
|
||||
|
||||
it('surfaces a network message for ProviderNetworkError instead of a bare op id', () => {
|
||||
const en = renderAgentError('ProviderNetworkError', 'fetch failed', 'op-net');
|
||||
expect(en).toContain('Network error talking to the model provider');
|
||||
expect(en).toContain('op-net');
|
||||
expect(en).not.toContain('**Agent Execution Failed**\nOperation ID');
|
||||
|
||||
const zh = renderAgentError('ProviderNetworkError', 'fetch failed', 'op-net', 'zh-CN');
|
||||
expect(zh).toContain('网络连接异常');
|
||||
});
|
||||
|
||||
it('maps provider-capacity codes to the temporarily-unavailable copy', () => {
|
||||
const unavailable = renderAgentError('ProviderServiceUnavailable', undefined, 'op-1');
|
||||
const noChannel = renderAgentError('NoAvailableChannel', undefined, 'op-1');
|
||||
expect(unavailable).toContain('temporarily unavailable');
|
||||
expect(unavailable).toBe(noChannel);
|
||||
|
||||
expect(renderAgentError('RateLimitExceeded', undefined, 'op-1')).toContain(
|
||||
'Too many requests',
|
||||
);
|
||||
expect(renderAgentError('ModelEmptyCompletion', undefined, 'op-1')).toContain(
|
||||
'empty response',
|
||||
);
|
||||
});
|
||||
|
||||
it('gives OperationInactivityTimeout retry-oriented copy', () => {
|
||||
expect(renderAgentError('OperationInactivityTimeout', undefined, 'op-1')).toContain(
|
||||
'timed out',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back by attribution when the exact code is unknown', () => {
|
||||
// system → provider-neutral infra copy (must not blame the model provider)
|
||||
expect(renderAgentError('SomeNewInfraCode', undefined, 'op-1', 'en-US', 'system')).toContain(
|
||||
'temporary system error',
|
||||
);
|
||||
// provider → temporarily-unavailable copy
|
||||
expect(renderAgentError('SomeNewCode', undefined, 'op-1', 'en-US', 'provider')).toContain(
|
||||
'temporarily unavailable',
|
||||
);
|
||||
// harness → internal-error copy, op id kept for support
|
||||
const harness = renderAgentError('SomeNewCode', undefined, 'op-h', 'en-US', 'harness');
|
||||
expect(harness).toContain('Something went wrong on our side');
|
||||
expect(harness).toContain('op-h');
|
||||
// user → generic check-your-settings copy
|
||||
expect(renderAgentError('SomeNewCode', undefined, 'op-1', 'en-US', 'user')).toContain(
|
||||
"couldn't be completed",
|
||||
);
|
||||
});
|
||||
|
||||
it('prefers the precise code copy over the attribution fallback', () => {
|
||||
// ProviderNetworkError has precise copy even though attribution=system
|
||||
// would also resolve; the precise tier must win.
|
||||
const out = renderAgentError('InvalidProviderAPIKey', undefined, 'op-1', 'en-US', 'system');
|
||||
expect(out).toContain('Invalid or missing API key');
|
||||
expect(out).not.toContain('Network error');
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== renderStopped ====================
|
||||
|
||||
@@ -263,9 +263,10 @@ describe('WechatGatewayClient', () => {
|
||||
expect.anything(), // WechatApiClient instance
|
||||
raw,
|
||||
);
|
||||
expect(result).toEqual([
|
||||
{ buffer, mimeType: 'image/jpeg', name: 'image.jpg', size: undefined },
|
||||
]);
|
||||
expect(result).toEqual({
|
||||
files: [{ buffer, mimeType: 'image/jpeg', name: 'image.jpg', size: undefined }],
|
||||
warnings: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns undefined when downloadMediaFromRawMessage resolves to an empty array', async () => {
|
||||
@@ -276,6 +277,34 @@ describe('WechatGatewayClient', () => {
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('warns when a FILE item has no downloadable media (e.g. oversized) and was dropped', async () => {
|
||||
// WeChat relays oversized files as metadata only — no CDN media handle —
|
||||
// so downloadMediaFromRawMessage returns nothing for them. We must surface
|
||||
// a warning instead of silently passing only the `[file: name]` text.
|
||||
mockDownloadMediaFromRawMessage.mockResolvedValue([]);
|
||||
const client = createClient();
|
||||
const result = (await client.extractFiles!(
|
||||
makeMessage({
|
||||
item_list: [
|
||||
{
|
||||
file_item: {
|
||||
file_name: 'October 11, 2023 Alta Town Council Meeting Audio.mp3',
|
||||
len: '132800970',
|
||||
},
|
||||
type: 4, // MessageItemType.FILE
|
||||
},
|
||||
],
|
||||
}),
|
||||
)) as { files?: unknown[]; warnings?: string[] } | undefined;
|
||||
expect(result?.files).toBeUndefined();
|
||||
expect(result?.warnings).toHaveLength(1);
|
||||
expect(result?.warnings?.[0]).toContain(
|
||||
'October 11, 2023 Alta Town Council Meeting Audio.mp3',
|
||||
);
|
||||
expect(result?.warnings?.[0]).toContain('126.6 MB');
|
||||
expect(result?.warnings?.[0]).toContain('could not be retrieved');
|
||||
});
|
||||
|
||||
it('maps file attachments preserving name + size', async () => {
|
||||
const buffer = Buffer.from('pdf-bytes');
|
||||
mockDownloadMediaFromRawMessage.mockResolvedValue([
|
||||
@@ -303,9 +332,10 @@ describe('WechatGatewayClient', () => {
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual([
|
||||
{ buffer, mimeType: 'application/pdf', name: 'report.pdf', size: 4096 },
|
||||
]);
|
||||
expect(result).toEqual({
|
||||
files: [{ buffer, mimeType: 'application/pdf', name: 'report.pdf', size: 4096 }],
|
||||
warnings: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('maps multiple attachments in a single message', async () => {
|
||||
@@ -324,10 +354,13 @@ describe('WechatGatewayClient', () => {
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual([
|
||||
{ buffer: imageBuf, mimeType: 'image/jpeg', name: 'image.jpg', size: undefined },
|
||||
{ buffer: voiceBuf, mimeType: 'audio/silk', name: undefined, size: undefined },
|
||||
]);
|
||||
expect(result).toEqual({
|
||||
files: [
|
||||
{ buffer: imageBuf, mimeType: 'image/jpeg', name: 'image.jpg', size: undefined },
|
||||
{ buffer: voiceBuf, mimeType: 'audio/silk', name: undefined, size: undefined },
|
||||
],
|
||||
warnings: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('propagates errors from downloadMediaFromRawMessage as undefined gracefully', async () => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { WechatRawMessage } from '@lobechat/chat-adapter-wechat';
|
||||
import {
|
||||
createWechatAdapter,
|
||||
downloadMediaFromRawMessage,
|
||||
MessageItemType,
|
||||
MessageState,
|
||||
MessageType,
|
||||
WechatApiClient,
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
type BotPlatformRuntimeContext,
|
||||
type BotProviderConfig,
|
||||
ClientFactory,
|
||||
type ExtractFilesResult,
|
||||
type MessengerContent,
|
||||
messengerContentText,
|
||||
type PlatformClient,
|
||||
@@ -347,30 +349,63 @@ class WechatGatewayClient implements PlatformClient {
|
||||
* `parseRawEvent` runs at adapter parse time — including the cascading
|
||||
* image fallback (CDN main → thumb → direct URL).
|
||||
*/
|
||||
async extractFiles(message: Message): Promise<AttachmentSource[] | undefined> {
|
||||
async extractFiles(message: Message): Promise<ExtractFilesResult | undefined> {
|
||||
const raw = (message as any).raw as WechatRawMessage | undefined;
|
||||
if (!raw?.item_list?.length) return undefined;
|
||||
|
||||
log('extractFiles: msgId=%s, items=%d', (message as any).id, raw.item_list.length);
|
||||
|
||||
const attachments = await downloadMediaFromRawMessage(this.api, raw);
|
||||
if (attachments.length === 0) {
|
||||
|
||||
// Detect FILE items that arrived as metadata only. WeChat does not relay a
|
||||
// downloadable CDN media descriptor for oversized files, so
|
||||
// downloadMediaFromRawMessage silently drops them. Without a warning the
|
||||
// agent only sees the bare `[file: name]` text placeholder (from the
|
||||
// adapter's extractText) and hallucinates that it received the file — e.g.
|
||||
// claiming it can't "hear" an audio it never actually got. Surface a
|
||||
// warning so the model can tell the user the file couldn't be retrieved.
|
||||
const downloadedNames = new Set(
|
||||
attachments.map((att: any) => att.name).filter(Boolean) as string[],
|
||||
);
|
||||
const warnings: string[] = [];
|
||||
for (const item of raw.item_list) {
|
||||
if (item.type !== MessageItemType.FILE || !item.file_item) continue;
|
||||
const fileName = item.file_item.file_name;
|
||||
if (fileName && downloadedNames.has(fileName)) continue;
|
||||
const sizeBytes = Number(item.file_item.len);
|
||||
const sizeHint =
|
||||
Number.isFinite(sizeBytes) && sizeBytes > 0
|
||||
? ` (${(sizeBytes / (1024 * 1024)).toFixed(1)} MB)`
|
||||
: '';
|
||||
warnings.push(
|
||||
`File "${fileName || 'unknown'}"${sizeHint} could not be retrieved from WeChat ` +
|
||||
`(it may be too large) and was not processed.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (attachments.length === 0 && warnings.length === 0) {
|
||||
log('extractFiles: no media items resolved for msgId=%s', (message as any).id);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
log(
|
||||
'extractFiles: resolved %d media item(s) for msgId=%s',
|
||||
'extractFiles: resolved %d media item(s), %d warning(s) for msgId=%s',
|
||||
attachments.length,
|
||||
warnings.length,
|
||||
(message as any).id,
|
||||
);
|
||||
|
||||
return attachments.map((att: any) => ({
|
||||
const files: AttachmentSource[] = attachments.map((att: any) => ({
|
||||
buffer: att.buffer,
|
||||
mimeType: att.mimeType,
|
||||
name: att.name,
|
||||
size: att.size,
|
||||
}));
|
||||
|
||||
return {
|
||||
files: files.length > 0 ? files : undefined,
|
||||
warnings: warnings.length > 0 ? warnings : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
getMessenger(platformThreadId: string): PlatformMessenger {
|
||||
|
||||
@@ -237,11 +237,20 @@ type SystemStrings = {
|
||||
errorExceededContextWindow: string;
|
||||
errorInvalidProviderAPIKey: string;
|
||||
errorCommandConnectionClosed: string;
|
||||
errorContentModeration: string;
|
||||
errorEmptyCompletion: string;
|
||||
errorHarnessInternal: string;
|
||||
errorLocationNotSupported: string;
|
||||
errorModelNotFound: string;
|
||||
errorNoAvailableProvider: string;
|
||||
errorPermissionDenied: string;
|
||||
errorProviderUnavailable: string;
|
||||
errorQuotaLimitReached: string;
|
||||
errorRateLimited: string;
|
||||
errorSystemInfra: string;
|
||||
errorTimeout: string;
|
||||
errorTransientNetwork: string;
|
||||
errorUserGeneric: string;
|
||||
errorWithDetails: (details: string, operationId?: string) => string;
|
||||
errorWithId: (operationId: string) => string;
|
||||
groupRejectedAllowlist: string;
|
||||
@@ -293,6 +302,12 @@ const SYSTEM_STRINGS: Partial<Record<BotReplyLocale, SystemStrings>> = {
|
||||
"**Context window exceeded.**\nThe conversation is too long for this model. Send `/new` to start a fresh topic, or switch to a model with a larger context window in the agent's settings.",
|
||||
errorCommandConnectionClosed:
|
||||
'**Command session disconnected.**\nThe agent lost its command connection before finishing. Please retry. If this keeps happening, check the sandbox or device connection and review the server logs for the operation.',
|
||||
errorContentModeration:
|
||||
"**Blocked by the content-safety filter.**\nThe model provider's safety filter rejected the request or response. Please rephrase and try again.",
|
||||
errorEmptyCompletion:
|
||||
"**The model returned an empty response.**\nThe model finished without producing any output. Please try again, or switch to a different model in the agent's settings.",
|
||||
errorHarnessInternal:
|
||||
'**Something went wrong on our side.**\nThe agent run hit an internal error, which has been logged. Please try again — if it keeps happening, share the Operation ID below with support.',
|
||||
errorInvalidProviderAPIKey:
|
||||
"**Invalid or missing API key.**\nThe configured model provider rejected its API key. Please verify the key in the agent's provider settings (it may be expired, revoked, or mistyped) and try again.",
|
||||
errorLocationNotSupported:
|
||||
@@ -303,8 +318,20 @@ const SYSTEM_STRINGS: Partial<Record<BotReplyLocale, SystemStrings>> = {
|
||||
"**No model provider configured.**\nThis bot's agent has no available model provider — please add an API key and enable a provider in the agent's settings, then try again.",
|
||||
errorPermissionDenied:
|
||||
"**Permission denied by the model provider.**\nThe API key doesn't have access to the requested model or operation. Please check the key's permissions, or switch to a model your account is authorized to use.",
|
||||
errorProviderUnavailable:
|
||||
"**Model provider temporarily unavailable.**\nThe model provider is overloaded or unavailable right now. Please wait a moment and try again, or switch to a different model in the agent's settings.",
|
||||
errorQuotaLimitReached:
|
||||
"**Provider quota exhausted.**\nThe configured model provider is out of quota or rate-limited. Please wait a moment and try again, top up the account, or switch to a different provider in the agent's settings.",
|
||||
errorRateLimited:
|
||||
"**Too many requests.**\nThe model provider is rate-limiting requests right now. Please wait a moment before trying again, or switch to a different model in the agent's settings.",
|
||||
errorSystemInfra:
|
||||
'**A temporary system error occurred.**\nThe request hit a transient infrastructure issue on our side and could not be completed. Please try again in a moment.',
|
||||
errorTimeout:
|
||||
'**The agent run timed out.**\nThe operation ran too long without progress and was stopped. Please try again; if the task is large, try breaking it into smaller steps.',
|
||||
errorTransientNetwork:
|
||||
"**Network error talking to the model provider.**\nThe connection to the model provider timed out or dropped. This is usually temporary — please try again in a moment. If it keeps happening, try a different model in the agent's settings.",
|
||||
errorUserGeneric:
|
||||
"**The agent run couldn't be completed.**\nPlease check your input or the agent's settings and try again.",
|
||||
errorWithDetails: (details, operationId) =>
|
||||
operationId
|
||||
? `**Agent Execution Failed**\nOperation ID: \`${operationId}\`\nDetails:\n\`\`\`\n${details}\n\`\`\``
|
||||
@@ -350,6 +377,12 @@ const SYSTEM_STRINGS: Partial<Record<BotReplyLocale, SystemStrings>> = {
|
||||
'**上下文已超出模型上限**\n当前对话长度超过了该模型的上下文窗口。可以发送 `/new` 开启新话题,或在 Agent 设置中切换到上下文更大的模型后重试。',
|
||||
errorCommandConnectionClosed:
|
||||
'**命令会话已断开**\nAgent 在完成前丢失了命令连接。请重试;如果该问题持续出现,请检查 sandbox 或设备连接,并结合 Operation ID 查看服务端日志。',
|
||||
errorContentModeration:
|
||||
'**被内容安全策略拦截**\n模型 Provider 的安全策略拒绝了本次请求或回复。请调整内容后重试。',
|
||||
errorEmptyCompletion:
|
||||
'**模型未返回任何内容**\n模型执行结束但没有产生输出。请重试,或在 Agent 设置中切换到其他模型。',
|
||||
errorHarnessInternal:
|
||||
'**我们这边出了点问题**\nAgent 执行遇到内部错误,已记录。请重试;如果持续出现,请把下方 Operation ID 提供给支持人员。',
|
||||
errorInvalidProviderAPIKey:
|
||||
'**API Key 无效或缺失**\n所配置的模型 Provider 拒绝了 API Key,可能已过期、被吊销或填写错误。请到 Agent 的 Provider 设置中检查并更新 API Key 后重试。',
|
||||
errorLocationNotSupported:
|
||||
@@ -360,8 +393,19 @@ const SYSTEM_STRINGS: Partial<Record<BotReplyLocale, SystemStrings>> = {
|
||||
'**未配置可用的模型 Provider**\n该机器人的 Agent 当前没有可用的模型 Provider,请在 Agent 设置中添加 API Key 并启用一个 Provider 后重试。',
|
||||
errorPermissionDenied:
|
||||
'**模型 Provider 拒绝访问**\nAPI Key 没有访问该模型或操作的权限。请检查 Key 的权限范围,或在 Agent 设置中切换到当前账户已授权的模型。',
|
||||
errorProviderUnavailable:
|
||||
'**模型 Provider 暂时不可用**\n模型 Provider 当前过载或不可用。请稍后重试,或在 Agent 设置中切换到其他模型。',
|
||||
errorQuotaLimitReached:
|
||||
'**Provider 配额已用尽**\n所配置的模型 Provider 已达到配额上限或被限流。请稍后重试、为账户充值,或在 Agent 设置中切换到其他 Provider。',
|
||||
errorRateLimited:
|
||||
'**请求过于频繁**\n模型 Provider 正在限流。请稍后再试,或在 Agent 设置中切换到其他模型。',
|
||||
errorSystemInfra:
|
||||
'**发生了临时系统错误**\n由于我们这边的临时基础设施问题,本次请求未能完成。请稍后重试。',
|
||||
errorTimeout:
|
||||
'**Agent 执行超时**\n操作长时间无进展,已被中止。请重试;如果任务较大,可尝试拆分成更小的步骤。',
|
||||
errorTransientNetwork:
|
||||
'**与模型 Provider 的网络连接异常**\n连接模型 Provider 时超时或中断。通常是临时问题,请稍后重试;如果反复出现,可在 Agent 设置中换一个模型。',
|
||||
errorUserGeneric: '**Agent 执行未能完成**\n请检查你的输入或 Agent 设置后重试。',
|
||||
errorWithDetails: (details, operationId) =>
|
||||
operationId
|
||||
? `**Agent 执行失败**\nOperation ID: \`${operationId}\`\n详细信息:\n\`\`\`\n${details}\n\`\`\``
|
||||
@@ -389,14 +433,19 @@ export function renderError(operationId?: string, lng?: BotReplyLocale): string
|
||||
|
||||
/**
|
||||
* Map known `AgentRuntimeError` codes to the `SystemStrings` field that
|
||||
* carries the friendly, actionable copy for that failure mode. Codes not in
|
||||
* this map fall back to the generic `Operation ID` template — opaque enough
|
||||
* not to leak internal error strings, but still traceable in logs.
|
||||
* carries the friendly, actionable copy for that failure mode. This is the
|
||||
* precise tier: when we recognize the exact code we show copy tailored to it.
|
||||
*
|
||||
* Codes not in this map fall back to {@link FALLBACK_ERROR_BY_ATTRIBUTION}
|
||||
* (a per-`attribution` tier), and only then to the generic `Operation ID`
|
||||
* template.
|
||||
*
|
||||
* When adding a new code: extend `SystemStrings`, drop the copy into both the
|
||||
* `en-US` and `zh-CN` dictionaries, then add the mapping here.
|
||||
*/
|
||||
const FRIENDLY_ERROR_BY_TYPE: Record<string, keyof SystemStrings> = {
|
||||
// ── user-fixable config / input (attribution: user) ──
|
||||
ContentModeration: 'errorContentModeration',
|
||||
ExceededContextWindow: 'errorExceededContextWindow',
|
||||
InsufficientQuota: 'errorQuotaLimitReached',
|
||||
InvalidProviderAPIKey: 'errorInvalidProviderAPIKey',
|
||||
@@ -405,47 +454,103 @@ const FRIENDLY_ERROR_BY_TYPE: Record<string, keyof SystemStrings> = {
|
||||
NoAvailableProvider: 'errorNoAvailableProvider',
|
||||
PermissionDenied: 'errorPermissionDenied',
|
||||
QuotaLimitReached: 'errorQuotaLimitReached',
|
||||
// ── transient provider / capacity (attribution: provider) ──
|
||||
ModelEmptyCompletion: 'errorEmptyCompletion',
|
||||
NoAvailableChannel: 'errorProviderUnavailable',
|
||||
ProviderServiceUnavailable: 'errorProviderUnavailable',
|
||||
RateLimitExceeded: 'errorRateLimited',
|
||||
// ── network / infra (attribution: system) ──
|
||||
// ProviderNetworkError is the one system-attributed code that *is* about the
|
||||
// model provider, so it keeps the provider-specific "switch model" copy. Other
|
||||
// system codes (state-store reads) hit the provider-neutral `system` fallback.
|
||||
ProviderNetworkError: 'errorTransientNetwork',
|
||||
// ── harness watchdog: harness-owned but retry-friendly, so it gets its
|
||||
// own retry-oriented copy rather than the generic internal-error tier ──
|
||||
OperationInactivityTimeout: 'errorTimeout',
|
||||
};
|
||||
|
||||
/**
|
||||
* When a specific error code has no precise copy above, fall back to a message
|
||||
* keyed on the error's `attribution` (from the model-runtime error taxonomy) so
|
||||
* the user still learns *who owns the failure* and whether to retry — instead of
|
||||
* a bare Operation ID. Unknown / absent attribution falls through to the legacy
|
||||
* template.
|
||||
*/
|
||||
const FALLBACK_ERROR_BY_ATTRIBUTION: Record<string, keyof SystemStrings> = {
|
||||
harness: 'errorHarnessInternal',
|
||||
provider: 'errorProviderUnavailable',
|
||||
// Provider-neutral: `system` covers infra failures (state-store reads, etc.)
|
||||
// where the LLM provider/model is not involved, so the copy must NOT blame the
|
||||
// provider or suggest switching models. ProviderNetworkError, the one
|
||||
// provider-related system code, is mapped precisely above.
|
||||
system: 'errorSystemInfra',
|
||||
user: 'errorUserGeneric',
|
||||
};
|
||||
|
||||
/**
|
||||
* Append the Operation ID as a traceable footer so operators can still grep
|
||||
* logs for the failure even when the user-facing copy is a friendly, actionable
|
||||
* message rather than the raw "Operation ID" line.
|
||||
*/
|
||||
const appendOperationId = (value: string, operationId: string | undefined): string =>
|
||||
operationId ? `${value}\nOperation ID: \`${operationId}\`` : value;
|
||||
|
||||
// `command aborted due to connection close` reaches us under a few stable
|
||||
// codes: the raw `500` fallback, or — once `formatErrorForState` pattern-refines
|
||||
// the Upstash/ioredis disconnect — `StateStorePersistError` (write path) /
|
||||
// `StateStoreReadError` (blocking-read path). The message stays the precise
|
||||
// signal; the type gate just has to let these through so the specific
|
||||
// "command session disconnected" guidance still wins over the generic tiers.
|
||||
const COMMAND_CONNECTION_CLOSED_TYPES = new Set([
|
||||
'500',
|
||||
'StateStorePersistError',
|
||||
'StateStoreReadError',
|
||||
]);
|
||||
|
||||
const isCommandConnectionClosedError = (
|
||||
errorType: string | undefined,
|
||||
errorMessage: string | undefined,
|
||||
) => {
|
||||
if (errorType && errorType !== '500') return false;
|
||||
if (errorType && !COMMAND_CONNECTION_CLOSED_TYPES.has(errorType)) return false;
|
||||
if (!errorMessage) return false;
|
||||
|
||||
return /command aborted due to connection close/i.test(errorMessage);
|
||||
};
|
||||
|
||||
/**
|
||||
* Render an agent-execution failure for the user. Switches on the stable
|
||||
* `errorType` code (from `AgentRuntimeError.chat`) to surface a friendly,
|
||||
* actionable message for known failure modes.
|
||||
* Render an agent-execution failure for the user, in three tiers:
|
||||
*
|
||||
* For unknown error codes — or when `errorType` is missing — falls back to
|
||||
* the legacy `Operation ID` template.
|
||||
* 1. **Precise** — switch on the stable `errorType` code (from
|
||||
* `AgentRuntimeError.chat`) for copy tailored to that exact failure mode.
|
||||
* 2. **Attribution** — when the code is unknown, fall back to a message keyed
|
||||
* on `attribution` (network / provider / harness / user) so the user still
|
||||
* learns who owns the failure and whether to retry.
|
||||
* 3. **Legacy** — when neither is known, the opaque `Operation ID` template.
|
||||
*
|
||||
* The Operation ID is appended as a footer to every tier (not the whole
|
||||
* message) so it stays traceable in logs without being the only thing the
|
||||
* user sees.
|
||||
*/
|
||||
export function renderAgentError(
|
||||
errorType: string | undefined,
|
||||
errorMessage: string | undefined,
|
||||
operationId: string | undefined,
|
||||
lng?: BotReplyLocale,
|
||||
attribution?: string,
|
||||
): string {
|
||||
const strings = getSystemStrings(lng);
|
||||
|
||||
if (isCommandConnectionClosedError(errorType, errorMessage)) {
|
||||
const value = strings.errorCommandConnectionClosed;
|
||||
return operationId ? `${value}\nOperation ID: \`${operationId}\`` : value;
|
||||
return appendOperationId(strings.errorCommandConnectionClosed, operationId);
|
||||
}
|
||||
|
||||
const stringKey = errorType ? FRIENDLY_ERROR_BY_TYPE[errorType] : undefined;
|
||||
const stringKey =
|
||||
(errorType ? FRIENDLY_ERROR_BY_TYPE[errorType] : undefined) ??
|
||||
(attribution ? FALLBACK_ERROR_BY_ATTRIBUTION[attribution] : undefined);
|
||||
if (stringKey) {
|
||||
const value = strings[stringKey];
|
||||
if (typeof value === 'string') {
|
||||
// Append the operationId as a traceable footer so operators can still
|
||||
// grep logs for the failure even when the user-facing copy is a
|
||||
// friendly, actionable message rather than the raw "Operation ID" line.
|
||||
return operationId ? `${value}\nOperation ID: \`${operationId}\`` : value;
|
||||
return appendOperationId(value, operationId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ describe('DeviceGateway', () => {
|
||||
const result = await proxy.queryDeviceStatus('user-1');
|
||||
|
||||
expect(result).toEqual(expected);
|
||||
expect(mockClient.queryDeviceStatus).toHaveBeenCalledWith('user-1');
|
||||
expect(mockClient.queryDeviceStatus).toHaveBeenCalledWith('user-1', undefined);
|
||||
});
|
||||
|
||||
it('should return offline status on error', async () => {
|
||||
@@ -136,7 +136,7 @@ describe('DeviceGateway', () => {
|
||||
platform: 'win32',
|
||||
},
|
||||
]);
|
||||
expect(mockClient.queryDeviceList).toHaveBeenCalledWith('user-1');
|
||||
expect(mockClient.queryDeviceList).toHaveBeenCalledWith('user-1', undefined);
|
||||
});
|
||||
|
||||
it('tolerates a legacy gateway response without channels', async () => {
|
||||
@@ -191,7 +191,7 @@ describe('DeviceGateway', () => {
|
||||
const result = await proxy.queryDeviceSystemInfo('user-1', 'dev-1');
|
||||
|
||||
expect(result).toEqual(systemInfo);
|
||||
expect(mockClient.getDeviceSystemInfo).toHaveBeenCalledWith('user-1', 'dev-1');
|
||||
expect(mockClient.getDeviceSystemInfo).toHaveBeenCalledWith('user-1', 'dev-1', undefined);
|
||||
});
|
||||
|
||||
it('should return undefined when result is not successful', async () => {
|
||||
|
||||
@@ -87,23 +87,25 @@ export class DeviceGateway {
|
||||
return !!gatewayEnv.DEVICE_GATEWAY_URL;
|
||||
}
|
||||
|
||||
async queryDeviceStatus(userId: string): Promise<DeviceStatusResult> {
|
||||
async queryDeviceStatus(userId: string, workspaceId?: string): Promise<DeviceStatusResult> {
|
||||
const client = this.getClient();
|
||||
if (!client) return { deviceCount: 0, online: false };
|
||||
|
||||
try {
|
||||
return await client.queryDeviceStatus(userId);
|
||||
return await client.queryDeviceStatus(userId, workspaceId);
|
||||
} catch {
|
||||
return { deviceCount: 0, online: false };
|
||||
}
|
||||
}
|
||||
|
||||
async queryDeviceList(userId: string): Promise<DeviceAttachment[]> {
|
||||
// Pass a `workspaceId` to address a workspace-owned device pool (the gateway
|
||||
// routes to the `workspace:<id>` principal); omit it for the personal pool.
|
||||
async queryDeviceList(userId: string, workspaceId?: string): Promise<DeviceAttachment[]> {
|
||||
const client = this.getClient();
|
||||
if (!client) return [];
|
||||
|
||||
try {
|
||||
const devices = await client.queryDeviceList(userId);
|
||||
const devices = await client.queryDeviceList(userId, workspaceId);
|
||||
// The gateway already dedupes to one entry per physical device, with its
|
||||
// live connections nested as `channels`. Map to the runtime shape; every
|
||||
// returned device has at least one channel, so it's online.
|
||||
@@ -129,12 +131,13 @@ export class DeviceGateway {
|
||||
async queryDeviceSystemInfo(
|
||||
userId: string,
|
||||
deviceId: string,
|
||||
workspaceId?: string,
|
||||
): Promise<DeviceSystemInfo | undefined> {
|
||||
const client = this.getClient();
|
||||
if (!client) return undefined;
|
||||
|
||||
try {
|
||||
const result = await client.getDeviceSystemInfo(userId, deviceId);
|
||||
const result = await client.getDeviceSystemInfo(userId, deviceId, workspaceId);
|
||||
return result.success ? result.systemInfo : undefined;
|
||||
} catch {
|
||||
log('queryDeviceSystemInfo: failed for userId=%s, deviceId=%s', userId, deviceId);
|
||||
@@ -157,8 +160,9 @@ export class DeviceGateway {
|
||||
scope: string;
|
||||
timeout?: number;
|
||||
userId: string;
|
||||
workspaceId?: string;
|
||||
}): Promise<WorkspaceInitResult | undefined> {
|
||||
const { userId, deviceId, scope, timeout = 30_000 } = params;
|
||||
const { userId, deviceId, scope, timeout = 30_000, workspaceId } = params;
|
||||
const client = this.getClient();
|
||||
if (!client) return undefined;
|
||||
|
||||
@@ -169,7 +173,10 @@ export class DeviceGateway {
|
||||
const result = await client.invokeRpc<{
|
||||
instructions?: WorkspaceInitResult['instructions'];
|
||||
skills?: (ProjectSkillMeta & Record<string, unknown>)[];
|
||||
}>({ deviceId, timeout, userId }, { method: 'initWorkspace', params: { scope } });
|
||||
}>(
|
||||
{ deviceId, timeout, userId, workspaceId },
|
||||
{ method: 'initWorkspace', params: { scope } },
|
||||
);
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
log('initWorkspace: failed for deviceId=%s — %s', deviceId, result.error);
|
||||
@@ -198,16 +205,16 @@ export class DeviceGateway {
|
||||
*/
|
||||
private async invokeGitRead<T>(
|
||||
method: string,
|
||||
params: { deviceId: string; timeout?: number; userId: string },
|
||||
params: { deviceId: string; timeout?: number; userId: string; workspaceId?: string },
|
||||
rpcParams: Record<string, unknown>,
|
||||
): Promise<T | undefined> {
|
||||
const { userId, deviceId, timeout = 15_000 } = params;
|
||||
const { userId, deviceId, timeout = 15_000, workspaceId } = params;
|
||||
const client = this.getClient();
|
||||
if (!client) return undefined;
|
||||
|
||||
try {
|
||||
const result = await client.invokeRpc<T>(
|
||||
{ deviceId, timeout, userId },
|
||||
{ deviceId, timeout, userId, workspaceId },
|
||||
{ method, params: rpcParams },
|
||||
);
|
||||
|
||||
@@ -224,12 +231,18 @@ export class DeviceGateway {
|
||||
}
|
||||
|
||||
/** Branch name + detached flag for a directory on a remote device. */
|
||||
gitBranch(params: { deviceId: string; path: string; userId: string }) {
|
||||
gitBranch(params: { deviceId: string; path: string; userId: string; workspaceId?: string }) {
|
||||
return this.invokeGitRead<DeviceGitBranchInfo>('getGitBranch', params, { path: params.path });
|
||||
}
|
||||
|
||||
/** The GitHub PR linked to a branch in a directory on a remote device. */
|
||||
gitLinkedPullRequest(params: { branch: string; deviceId: string; path: string; userId: string }) {
|
||||
gitLinkedPullRequest(params: {
|
||||
branch: string;
|
||||
deviceId: string;
|
||||
path: string;
|
||||
userId: string;
|
||||
workspaceId?: string;
|
||||
}) {
|
||||
return this.invokeGitRead<DeviceGitLinkedPullRequestResult>('getLinkedPullRequest', params, {
|
||||
branch: params.branch,
|
||||
path: params.path,
|
||||
@@ -237,21 +250,31 @@ export class DeviceGateway {
|
||||
}
|
||||
|
||||
/** Working-tree dirty-file counts for a directory on a remote device. */
|
||||
gitWorkingTreeStatus(params: { deviceId: string; path: string; userId: string }) {
|
||||
gitWorkingTreeStatus(params: {
|
||||
deviceId: string;
|
||||
path: string;
|
||||
userId: string;
|
||||
workspaceId?: string;
|
||||
}) {
|
||||
return this.invokeGitRead<DeviceGitWorkingTreeStatus>('getGitWorkingTreeStatus', params, {
|
||||
path: params.path,
|
||||
});
|
||||
}
|
||||
|
||||
/** Ahead/behind commit counts for a directory on a remote device. */
|
||||
gitAheadBehind(params: { deviceId: string; path: string; userId: string }) {
|
||||
gitAheadBehind(params: { deviceId: string; path: string; userId: string; workspaceId?: string }) {
|
||||
return this.invokeGitRead<DeviceGitAheadBehind>('getGitAheadBehind', params, {
|
||||
path: params.path,
|
||||
});
|
||||
}
|
||||
|
||||
/** Git worktrees attached to the same repository as a directory on a remote device. */
|
||||
listGitWorktrees(params: { deviceId: string; path: string; userId: string }) {
|
||||
listGitWorktrees(params: {
|
||||
deviceId: string;
|
||||
path: string;
|
||||
userId: string;
|
||||
workspaceId?: string;
|
||||
}) {
|
||||
return this.invokeGitRead<DeviceGitWorktreeListItem[]>('listGitWorktrees', params, {
|
||||
path: params.path,
|
||||
});
|
||||
@@ -267,14 +290,15 @@ export class DeviceGateway {
|
||||
path: string;
|
||||
timeout?: number;
|
||||
userId: string;
|
||||
workspaceId?: string;
|
||||
}): Promise<DeviceGitBranchListItem[] | undefined> {
|
||||
const { userId, deviceId, path, timeout = 15_000 } = params;
|
||||
const { userId, deviceId, path, timeout = 15_000, workspaceId } = params;
|
||||
const client = this.getClient();
|
||||
if (!client) return undefined;
|
||||
|
||||
try {
|
||||
const result = await client.invokeRpc<DeviceGitBranchListItem[]>(
|
||||
{ deviceId, timeout, userId },
|
||||
{ deviceId, timeout, userId, workspaceId },
|
||||
{ method: 'listGitBranches', params: { path } },
|
||||
);
|
||||
|
||||
@@ -301,14 +325,15 @@ export class DeviceGateway {
|
||||
path: string;
|
||||
timeout?: number;
|
||||
userId: string;
|
||||
workspaceId?: string;
|
||||
}): Promise<DeviceGitCheckoutResult> {
|
||||
const { userId, deviceId, branch, create, path, timeout = 30_000 } = params;
|
||||
const { userId, deviceId, branch, create, path, timeout = 30_000, workspaceId } = params;
|
||||
const client = this.getClient();
|
||||
if (!client) return { error: 'Device gateway not configured', success: false };
|
||||
|
||||
try {
|
||||
const result = await client.invokeRpc<DeviceGitCheckoutResult>(
|
||||
{ deviceId, timeout, userId },
|
||||
{ deviceId, timeout, userId, workspaceId },
|
||||
{ method: 'checkoutGitBranch', params: { branch, create, path } },
|
||||
);
|
||||
|
||||
@@ -335,14 +360,15 @@ export class DeviceGateway {
|
||||
timeout?: number;
|
||||
to: string;
|
||||
userId: string;
|
||||
workspaceId?: string;
|
||||
}): Promise<DeviceGitRenameBranchResult> {
|
||||
const { userId, deviceId, from, to, path, timeout = 30_000 } = params;
|
||||
const { userId, deviceId, from, to, path, timeout = 30_000, workspaceId } = params;
|
||||
const client = this.getClient();
|
||||
if (!client) return { error: 'Device gateway not configured', success: false };
|
||||
|
||||
try {
|
||||
const result = await client.invokeRpc<DeviceGitRenameBranchResult>(
|
||||
{ deviceId, timeout, userId },
|
||||
{ deviceId, timeout, userId, workspaceId },
|
||||
{ method: 'renameGitBranch', params: { from, path, to } },
|
||||
);
|
||||
|
||||
@@ -368,14 +394,15 @@ export class DeviceGateway {
|
||||
path: string;
|
||||
timeout?: number;
|
||||
userId: string;
|
||||
workspaceId?: string;
|
||||
}): Promise<DeviceGitDeleteBranchResult> {
|
||||
const { userId, deviceId, branch, path, timeout = 30_000 } = params;
|
||||
const { userId, deviceId, branch, path, timeout = 30_000, workspaceId } = params;
|
||||
const client = this.getClient();
|
||||
if (!client) return { error: 'Device gateway not configured', success: false };
|
||||
|
||||
try {
|
||||
const result = await client.invokeRpc<DeviceGitDeleteBranchResult>(
|
||||
{ deviceId, timeout, userId },
|
||||
{ deviceId, timeout, userId, workspaceId },
|
||||
{ method: 'deleteGitBranch', params: { branch, path } },
|
||||
);
|
||||
|
||||
@@ -400,14 +427,15 @@ export class DeviceGateway {
|
||||
path: string;
|
||||
timeout?: number;
|
||||
userId: string;
|
||||
workspaceId?: string;
|
||||
}): Promise<DeviceGitSyncResult> {
|
||||
const { userId, deviceId, path, timeout = 65_000 } = params;
|
||||
const { userId, deviceId, path, timeout = 65_000, workspaceId } = params;
|
||||
const client = this.getClient();
|
||||
if (!client) return { error: 'Device gateway not configured', success: false };
|
||||
|
||||
try {
|
||||
const result = await client.invokeRpc<DeviceGitSyncResult>(
|
||||
{ deviceId, timeout, userId },
|
||||
{ deviceId, timeout, userId, workspaceId },
|
||||
{ method: 'pullGitBranch', params: { path } },
|
||||
);
|
||||
|
||||
@@ -432,14 +460,15 @@ export class DeviceGateway {
|
||||
path: string;
|
||||
timeout?: number;
|
||||
userId: string;
|
||||
workspaceId?: string;
|
||||
}): Promise<DeviceGitSyncResult> {
|
||||
const { userId, deviceId, path, timeout = 65_000 } = params;
|
||||
const { userId, deviceId, path, timeout = 65_000, workspaceId } = params;
|
||||
const client = this.getClient();
|
||||
if (!client) return { error: 'Device gateway not configured', success: false };
|
||||
|
||||
try {
|
||||
const result = await client.invokeRpc<DeviceGitSyncResult>(
|
||||
{ deviceId, timeout, userId },
|
||||
{ deviceId, timeout, userId, workspaceId },
|
||||
{ method: 'pushGitBranch', params: { path } },
|
||||
);
|
||||
|
||||
@@ -465,14 +494,15 @@ export class DeviceGateway {
|
||||
path: string;
|
||||
timeout?: number;
|
||||
userId: string;
|
||||
workspaceId?: string;
|
||||
}): Promise<DeviceGitWorkingTreePatches | undefined> {
|
||||
const { userId, deviceId, path, timeout = 30_000 } = params;
|
||||
const { userId, deviceId, path, timeout = 30_000, workspaceId } = params;
|
||||
const client = this.getClient();
|
||||
if (!client) return undefined;
|
||||
|
||||
try {
|
||||
const result = await client.invokeRpc<DeviceGitWorkingTreePatches>(
|
||||
{ deviceId, timeout, userId },
|
||||
{ deviceId, timeout, userId, workspaceId },
|
||||
{ method: 'getGitWorkingTreePatches', params: { path } },
|
||||
);
|
||||
|
||||
@@ -498,14 +528,15 @@ export class DeviceGateway {
|
||||
path: string;
|
||||
timeout?: number;
|
||||
userId: string;
|
||||
workspaceId?: string;
|
||||
}): Promise<DeviceGitBranchDiffPatches | undefined> {
|
||||
const { userId, deviceId, baseRef, path, timeout = 30_000 } = params;
|
||||
const { userId, deviceId, baseRef, path, timeout = 30_000, workspaceId } = params;
|
||||
const client = this.getClient();
|
||||
if (!client) return undefined;
|
||||
|
||||
try {
|
||||
const result = await client.invokeRpc<DeviceGitBranchDiffPatches>(
|
||||
{ deviceId, timeout, userId },
|
||||
{ deviceId, timeout, userId, workspaceId },
|
||||
{ method: 'getGitBranchDiff', params: { baseRef, path } },
|
||||
);
|
||||
|
||||
@@ -531,14 +562,15 @@ export class DeviceGateway {
|
||||
path: string;
|
||||
timeout?: number;
|
||||
userId: string;
|
||||
workspaceId?: string;
|
||||
}): Promise<DeviceGitWorkingTreeFiles | undefined> {
|
||||
const { userId, deviceId, path, timeout = 15_000 } = params;
|
||||
const { userId, deviceId, path, timeout = 15_000, workspaceId } = params;
|
||||
const client = this.getClient();
|
||||
if (!client) return undefined;
|
||||
|
||||
try {
|
||||
const result = await client.invokeRpc<DeviceGitWorkingTreeFiles>(
|
||||
{ deviceId, timeout, userId },
|
||||
{ deviceId, timeout, userId, workspaceId },
|
||||
{ method: 'getGitWorkingTreeFiles', params: { path } },
|
||||
);
|
||||
|
||||
@@ -563,14 +595,15 @@ export class DeviceGateway {
|
||||
scope: string;
|
||||
timeout?: number;
|
||||
userId: string;
|
||||
workspaceId?: string;
|
||||
}): Promise<DeviceProjectFileIndexResult | undefined> {
|
||||
const { userId, deviceId, scope, timeout = 30_000 } = params;
|
||||
const { userId, deviceId, scope, timeout = 30_000, workspaceId } = params;
|
||||
const client = this.getClient();
|
||||
if (!client) return undefined;
|
||||
|
||||
try {
|
||||
const result = await client.invokeRpc<DeviceProjectFileIndexResult>(
|
||||
{ deviceId, timeout, userId },
|
||||
{ deviceId, timeout, userId, workspaceId },
|
||||
{ method: 'getProjectFileIndex', params: { scope } },
|
||||
);
|
||||
|
||||
@@ -598,14 +631,23 @@ export class DeviceGateway {
|
||||
timeout?: number;
|
||||
userId: string;
|
||||
workingDirectory: string;
|
||||
workspaceId?: string;
|
||||
}): Promise<DeviceLocalFilePreviewResult> {
|
||||
const { accept, userId, deviceId, path, workingDirectory, timeout = 30_000 } = params;
|
||||
const {
|
||||
accept,
|
||||
userId,
|
||||
deviceId,
|
||||
path,
|
||||
workingDirectory,
|
||||
timeout = 30_000,
|
||||
workspaceId,
|
||||
} = params;
|
||||
const client = this.getClient();
|
||||
if (!client) return { error: 'Device gateway not configured', success: false };
|
||||
|
||||
try {
|
||||
const result = await client.invokeRpc<DeviceLocalFilePreviewResult>(
|
||||
{ deviceId, timeout, userId },
|
||||
{ deviceId, timeout, userId, workspaceId },
|
||||
{
|
||||
method: 'getLocalFilePreview',
|
||||
params: { accept, path, workingDirectory },
|
||||
@@ -636,14 +678,15 @@ export class DeviceGateway {
|
||||
scope: string;
|
||||
timeout?: number;
|
||||
userId: string;
|
||||
workspaceId?: string;
|
||||
}): Promise<DeviceListProjectSkillsResult | undefined> {
|
||||
const { userId, deviceId, scope, timeout = 30_000 } = params;
|
||||
const { userId, deviceId, scope, timeout = 30_000, workspaceId } = params;
|
||||
const client = this.getClient();
|
||||
if (!client) return undefined;
|
||||
|
||||
try {
|
||||
const result = await client.invokeRpc<DeviceListProjectSkillsResult>(
|
||||
{ deviceId, timeout, userId },
|
||||
{ deviceId, timeout, userId, workspaceId },
|
||||
{ method: 'listProjectSkills', params: { scope } },
|
||||
);
|
||||
|
||||
@@ -669,14 +712,15 @@ export class DeviceGateway {
|
||||
path: string;
|
||||
timeout?: number;
|
||||
userId: string;
|
||||
workspaceId?: string;
|
||||
}): Promise<DeviceGitRemoteBranchListItem[] | undefined> {
|
||||
const { userId, deviceId, path, timeout = 15_000 } = params;
|
||||
const { userId, deviceId, path, timeout = 15_000, workspaceId } = params;
|
||||
const client = this.getClient();
|
||||
if (!client) return undefined;
|
||||
|
||||
try {
|
||||
const result = await client.invokeRpc<DeviceGitRemoteBranchListItem[]>(
|
||||
{ deviceId, timeout, userId },
|
||||
{ deviceId, timeout, userId, workspaceId },
|
||||
{ method: 'listGitRemoteBranches', params: { path } },
|
||||
);
|
||||
|
||||
@@ -702,14 +746,15 @@ export class DeviceGateway {
|
||||
path: string;
|
||||
timeout?: number;
|
||||
userId: string;
|
||||
workspaceId?: string;
|
||||
}): Promise<DeviceGitFileRevertResult> {
|
||||
const { userId, deviceId, filePath, path, timeout = 15_000 } = params;
|
||||
const { userId, deviceId, filePath, path, timeout = 15_000, workspaceId } = params;
|
||||
const client = this.getClient();
|
||||
if (!client) return { error: 'Device gateway not configured', success: false };
|
||||
|
||||
try {
|
||||
const result = await client.invokeRpc<DeviceGitFileRevertResult>(
|
||||
{ deviceId, timeout, userId },
|
||||
{ deviceId, timeout, userId, workspaceId },
|
||||
{ method: 'revertGitFile', params: { filePath, path } },
|
||||
);
|
||||
|
||||
@@ -738,8 +783,9 @@ export class DeviceGateway {
|
||||
timeout?: number;
|
||||
userId: string;
|
||||
workingDirectory: string;
|
||||
workspaceId?: string;
|
||||
}): Promise<DeviceMoveProjectFileResultItem[]> {
|
||||
const { userId, deviceId, items, workingDirectory, timeout = 30_000 } = params;
|
||||
const { userId, deviceId, items, workingDirectory, timeout = 30_000, workspaceId } = params;
|
||||
const client = this.getClient();
|
||||
if (!client) throw new Error('Device gateway not configured');
|
||||
|
||||
@@ -749,7 +795,7 @@ export class DeviceGateway {
|
||||
);
|
||||
|
||||
const result = await client.invokeRpc<DeviceMoveProjectFileResultItem[]>(
|
||||
{ deviceId, timeout, userId },
|
||||
{ deviceId, timeout, userId, workspaceId },
|
||||
{ method: 'moveLocalFiles', params: { items } },
|
||||
);
|
||||
|
||||
@@ -773,8 +819,17 @@ export class DeviceGateway {
|
||||
timeout?: number;
|
||||
userId: string;
|
||||
workingDirectory: string;
|
||||
workspaceId?: string;
|
||||
}): Promise<DeviceRenameProjectFileResult> {
|
||||
const { userId, deviceId, path, newName, workingDirectory, timeout = 30_000 } = params;
|
||||
const {
|
||||
userId,
|
||||
deviceId,
|
||||
path,
|
||||
newName,
|
||||
workingDirectory,
|
||||
timeout = 30_000,
|
||||
workspaceId,
|
||||
} = params;
|
||||
const client = this.getClient();
|
||||
if (!client) throw new Error('Device gateway not configured');
|
||||
|
||||
@@ -783,7 +838,7 @@ export class DeviceGateway {
|
||||
assertPathsWithinWorkspace(workingDirectory, [path]);
|
||||
|
||||
const result = await client.invokeRpc<DeviceRenameProjectFileResult>(
|
||||
{ deviceId, timeout, userId },
|
||||
{ deviceId, timeout, userId, workspaceId },
|
||||
{ method: 'renameLocalFile', params: { newName, path } },
|
||||
);
|
||||
|
||||
@@ -807,15 +862,24 @@ export class DeviceGateway {
|
||||
timeout?: number;
|
||||
userId: string;
|
||||
workingDirectory: string;
|
||||
workspaceId?: string;
|
||||
}): Promise<DeviceWriteProjectFileResult> {
|
||||
const { userId, deviceId, path, content, workingDirectory, timeout = 30_000 } = params;
|
||||
const {
|
||||
userId,
|
||||
deviceId,
|
||||
path,
|
||||
content,
|
||||
workingDirectory,
|
||||
timeout = 30_000,
|
||||
workspaceId,
|
||||
} = params;
|
||||
const client = this.getClient();
|
||||
if (!client) throw new Error('Device gateway not configured');
|
||||
|
||||
assertPathsWithinWorkspace(workingDirectory, [path]);
|
||||
|
||||
const result = await client.invokeRpc<DeviceWriteProjectFileResult>(
|
||||
{ deviceId, timeout, userId },
|
||||
{ deviceId, timeout, userId, workspaceId },
|
||||
{ method: 'writeLocalFile', params: { content, path } },
|
||||
);
|
||||
|
||||
@@ -839,8 +903,9 @@ export class DeviceGateway {
|
||||
path: string;
|
||||
timeout?: number;
|
||||
userId: string;
|
||||
workspaceId?: string;
|
||||
}): Promise<{ exists: boolean; isDirectory: boolean; repoType?: 'git' | 'github' } | undefined> {
|
||||
const { userId, deviceId, path, timeout = 8000 } = params;
|
||||
const { userId, deviceId, path, timeout = 8000, workspaceId } = params;
|
||||
const client = this.getClient();
|
||||
if (!client) return undefined;
|
||||
|
||||
@@ -849,7 +914,7 @@ export class DeviceGateway {
|
||||
exists: boolean;
|
||||
isDirectory: boolean;
|
||||
repoType?: 'git' | 'github';
|
||||
}>({ deviceId, timeout, userId }, { method: 'statPath', params: { path } });
|
||||
}>({ deviceId, timeout, userId, workspaceId }, { method: 'statPath', params: { path } });
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
log('statPath: failed for deviceId=%s — %s', deviceId, result.error);
|
||||
@@ -876,6 +941,7 @@ export class DeviceGateway {
|
||||
systemContext?: string;
|
||||
topicId: string;
|
||||
userId: string;
|
||||
workspaceId?: string;
|
||||
}): Promise<{ error?: string; success: boolean }> {
|
||||
const client = this.getClient();
|
||||
if (!client) return { error: 'GATEWAY_NOT_CONFIGURED', success: false };
|
||||
@@ -890,7 +956,7 @@ export class DeviceGateway {
|
||||
}
|
||||
|
||||
async executeToolCall(
|
||||
params: { deviceId: string; operationId?: string; userId: string },
|
||||
params: { deviceId: string; operationId?: string; userId: string; workspaceId?: string },
|
||||
toolCall: { apiName: string; arguments: string; identifier: string },
|
||||
timeout = 30_000,
|
||||
): Promise<DeviceToolCallResult> {
|
||||
@@ -919,6 +985,7 @@ export class DeviceGateway {
|
||||
operationId: params.operationId,
|
||||
timeout,
|
||||
userId: params.userId,
|
||||
workspaceId: params.workspaceId,
|
||||
},
|
||||
toolCall,
|
||||
);
|
||||
@@ -942,6 +1009,7 @@ export class DeviceGateway {
|
||||
identifier: string;
|
||||
params: GatewayMcpStdioParams;
|
||||
userId: string;
|
||||
workspaceId?: string;
|
||||
},
|
||||
timeout = 30_000,
|
||||
): Promise<DeviceToolCallResult> {
|
||||
@@ -972,7 +1040,7 @@ export class DeviceGateway {
|
||||
}
|
||||
|
||||
async executeMessageApi(
|
||||
params: { deviceId: string; userId: string },
|
||||
params: { deviceId: string; userId: string; workspaceId?: string },
|
||||
api: { apiName: string; payload: Record<string, unknown>; platform: string },
|
||||
timeout = 30_000,
|
||||
): Promise<DeviceMessageApiResult> {
|
||||
@@ -995,7 +1063,12 @@ export class DeviceGateway {
|
||||
|
||||
try {
|
||||
return await client.executeMessageApi(
|
||||
{ deviceId: params.deviceId, timeout, userId: params.userId },
|
||||
{
|
||||
deviceId: params.deviceId,
|
||||
timeout,
|
||||
userId: params.userId,
|
||||
workspaceId: params.workspaceId,
|
||||
},
|
||||
api,
|
||||
);
|
||||
} catch (error) {
|
||||
|
||||
@@ -63,6 +63,7 @@ describe('TaskService', () => {
|
||||
getDependencies: vi.fn(),
|
||||
getDependenciesByTaskIds: vi.fn(),
|
||||
getReviewConfig: vi.fn(),
|
||||
getVerifyConfig: vi.fn(),
|
||||
getTaskFileIds: vi.fn().mockResolvedValue([]),
|
||||
getTreeAgentIdsForTaskIds: vi.fn().mockResolvedValue({}),
|
||||
getTreePinnedDocuments: vi.fn(),
|
||||
@@ -131,7 +132,7 @@ describe('TaskService', () => {
|
||||
mockTaskModel.getTreePinnedDocuments.mockResolvedValue({ nodeMap: {}, tree: [] });
|
||||
mockTaskModel.findByIds.mockResolvedValue([]);
|
||||
mockTaskModel.getCheckpointConfig.mockReturnValue({});
|
||||
mockTaskModel.getReviewConfig.mockReturnValue(undefined);
|
||||
mockTaskModel.getVerifyConfig.mockReturnValue(undefined);
|
||||
|
||||
const service = new TaskService(db, userId);
|
||||
const result = await service.getTaskDetail('TASK-1');
|
||||
@@ -189,7 +190,7 @@ describe('TaskService', () => {
|
||||
mockTaskModel.findByIds.mockResolvedValue([]);
|
||||
mockTaskModel.findById.mockResolvedValue(parentTask);
|
||||
mockTaskModel.getCheckpointConfig.mockReturnValue({});
|
||||
mockTaskModel.getReviewConfig.mockReturnValue(undefined);
|
||||
mockTaskModel.getVerifyConfig.mockReturnValue(undefined);
|
||||
|
||||
const service = new TaskService(db, userId);
|
||||
const result = await service.getTaskDetail('TASK-2');
|
||||
@@ -232,7 +233,7 @@ describe('TaskService', () => {
|
||||
mockTaskModel.findByIds.mockResolvedValue([]);
|
||||
mockTaskModel.findById.mockResolvedValue(null);
|
||||
mockTaskModel.getCheckpointConfig.mockReturnValue({});
|
||||
mockTaskModel.getReviewConfig.mockReturnValue(undefined);
|
||||
mockTaskModel.getVerifyConfig.mockReturnValue(undefined);
|
||||
|
||||
const service = new TaskService(db, userId);
|
||||
const result = await service.getTaskDetail('TASK-2');
|
||||
@@ -292,7 +293,7 @@ describe('TaskService', () => {
|
||||
mockTaskModel.getDependenciesByTaskIds.mockResolvedValue(subtaskDeps);
|
||||
mockTaskModel.findByIds.mockResolvedValue([]);
|
||||
mockTaskModel.getCheckpointConfig.mockReturnValue({});
|
||||
mockTaskModel.getReviewConfig.mockReturnValue(undefined);
|
||||
mockTaskModel.getVerifyConfig.mockReturnValue(undefined);
|
||||
|
||||
const service = new TaskService(db, userId);
|
||||
const result = await service.getTaskDetail('TASK-1');
|
||||
@@ -374,7 +375,7 @@ describe('TaskService', () => {
|
||||
mockTaskModel.getDependenciesByTaskIds.mockResolvedValue([]);
|
||||
mockTaskModel.findByIds.mockResolvedValue([]);
|
||||
mockTaskModel.getCheckpointConfig.mockReturnValue({});
|
||||
mockTaskModel.getReviewConfig.mockReturnValue(undefined);
|
||||
mockTaskModel.getVerifyConfig.mockReturnValue(undefined);
|
||||
|
||||
const service = new TaskService(db, userId);
|
||||
const result = await service.getTaskDetail('TASK-1');
|
||||
@@ -433,7 +434,7 @@ describe('TaskService', () => {
|
||||
mockTaskModel.getTreePinnedDocuments.mockResolvedValue({ nodeMap: {}, tree: [] });
|
||||
mockTaskModel.findByIds.mockResolvedValue(depTasks);
|
||||
mockTaskModel.getCheckpointConfig.mockReturnValue({});
|
||||
mockTaskModel.getReviewConfig.mockReturnValue(undefined);
|
||||
mockTaskModel.getVerifyConfig.mockReturnValue(undefined);
|
||||
|
||||
const service = new TaskService(db, userId);
|
||||
const result = await service.getTaskDetail('TASK-3');
|
||||
@@ -474,7 +475,7 @@ describe('TaskService', () => {
|
||||
mockTaskModel.getTreePinnedDocuments.mockResolvedValue({ nodeMap: {}, tree: [] });
|
||||
mockTaskModel.findByIds.mockResolvedValue([]);
|
||||
mockTaskModel.getCheckpointConfig.mockReturnValue({});
|
||||
mockTaskModel.getReviewConfig.mockReturnValue(undefined);
|
||||
mockTaskModel.getVerifyConfig.mockReturnValue(undefined);
|
||||
|
||||
const service = new TaskService(db, userId);
|
||||
const result = await service.getTaskDetail('TASK-3');
|
||||
@@ -544,7 +545,7 @@ describe('TaskService', () => {
|
||||
mockTaskModel.getTreePinnedDocuments.mockResolvedValue({ nodeMap: {}, tree: [] });
|
||||
mockTaskModel.findByIds.mockResolvedValue([]);
|
||||
mockTaskModel.getCheckpointConfig.mockReturnValue({});
|
||||
mockTaskModel.getReviewConfig.mockReturnValue(undefined);
|
||||
mockTaskModel.getVerifyConfig.mockReturnValue(undefined);
|
||||
|
||||
const service = new TaskService(db, userId);
|
||||
const result = await service.getTaskDetail('TASK-1');
|
||||
@@ -604,7 +605,7 @@ describe('TaskService', () => {
|
||||
mockTaskModel.getTreePinnedDocuments.mockResolvedValue({ nodeMap: {}, tree: [] });
|
||||
mockTaskModel.findByIds.mockResolvedValue([]);
|
||||
mockTaskModel.getCheckpointConfig.mockReturnValue({});
|
||||
mockTaskModel.getReviewConfig.mockReturnValue(undefined);
|
||||
mockTaskModel.getVerifyConfig.mockReturnValue(undefined);
|
||||
|
||||
// Mock model methods to return agent and user data
|
||||
mockAgentModel.getAgentAvatarsByIds.mockResolvedValue([
|
||||
@@ -670,7 +671,7 @@ describe('TaskService', () => {
|
||||
mockTaskModel.getTreePinnedDocuments.mockResolvedValue({ nodeMap: {}, tree: [] });
|
||||
mockTaskModel.findByIds.mockResolvedValue([]);
|
||||
mockTaskModel.getCheckpointConfig.mockReturnValue({});
|
||||
mockTaskModel.getReviewConfig.mockReturnValue(undefined);
|
||||
mockTaskModel.getVerifyConfig.mockReturnValue(undefined);
|
||||
|
||||
const service = new TaskService(db, userId);
|
||||
const result = await service.getTaskDetail('TASK-1');
|
||||
@@ -726,7 +727,7 @@ describe('TaskService', () => {
|
||||
mockTaskModel.getTreePinnedDocuments.mockResolvedValue({ nodeMap: {}, tree: [] });
|
||||
mockTaskModel.findByIds.mockResolvedValue([]);
|
||||
mockTaskModel.getCheckpointConfig.mockReturnValue({});
|
||||
mockTaskModel.getReviewConfig.mockReturnValue(undefined);
|
||||
mockTaskModel.getVerifyConfig.mockReturnValue(undefined);
|
||||
|
||||
const service = new TaskService(db, userId);
|
||||
const result = await service.getTaskDetail('TASK-1');
|
||||
@@ -767,7 +768,7 @@ describe('TaskService', () => {
|
||||
mockTaskModel.getTreePinnedDocuments.mockResolvedValue({ nodeMap: {}, tree: [] });
|
||||
mockTaskModel.findByIds.mockResolvedValue([]);
|
||||
mockTaskModel.getCheckpointConfig.mockReturnValue({});
|
||||
mockTaskModel.getReviewConfig.mockReturnValue(undefined);
|
||||
mockTaskModel.getVerifyConfig.mockReturnValue(undefined);
|
||||
|
||||
const service = new TaskService(db, userId);
|
||||
const result = await service.getTaskDetail('TASK-1');
|
||||
@@ -831,7 +832,7 @@ describe('TaskService', () => {
|
||||
mockTaskModel.getTreePinnedDocuments.mockResolvedValue(workspace);
|
||||
mockTaskModel.findByIds.mockResolvedValue([]);
|
||||
mockTaskModel.getCheckpointConfig.mockReturnValue({});
|
||||
mockTaskModel.getReviewConfig.mockReturnValue(undefined);
|
||||
mockTaskModel.getVerifyConfig.mockReturnValue(undefined);
|
||||
|
||||
const service = new TaskService(db, userId);
|
||||
const result = await service.getTaskDetail('TASK-1');
|
||||
@@ -878,7 +879,7 @@ describe('TaskService', () => {
|
||||
mockTaskModel.getTreePinnedDocuments.mockResolvedValue({ nodeMap: {}, tree: [] });
|
||||
mockTaskModel.findByIds.mockResolvedValue([]);
|
||||
mockTaskModel.getCheckpointConfig.mockReturnValue({});
|
||||
mockTaskModel.getReviewConfig.mockReturnValue(undefined);
|
||||
mockTaskModel.getVerifyConfig.mockReturnValue(undefined);
|
||||
|
||||
const service = new TaskService(db, userId);
|
||||
const result = await service.getTaskDetail('TASK-1');
|
||||
@@ -919,7 +920,7 @@ describe('TaskService', () => {
|
||||
mockTaskModel.getTreePinnedDocuments.mockResolvedValue({ nodeMap: {}, tree: [] });
|
||||
mockTaskModel.findByIds.mockResolvedValue([]);
|
||||
mockTaskModel.getCheckpointConfig.mockReturnValue({});
|
||||
mockTaskModel.getReviewConfig.mockReturnValue(undefined);
|
||||
mockTaskModel.getVerifyConfig.mockReturnValue(undefined);
|
||||
|
||||
const service = new TaskService(db, userId);
|
||||
const result = await service.getTaskDetail('TASK-1');
|
||||
@@ -957,7 +958,7 @@ describe('TaskService', () => {
|
||||
mockTaskModel.getTreePinnedDocuments.mockRejectedValue(new Error('DB error'));
|
||||
mockTaskModel.findByIds.mockResolvedValue([]);
|
||||
mockTaskModel.getCheckpointConfig.mockReturnValue({});
|
||||
mockTaskModel.getReviewConfig.mockReturnValue(undefined);
|
||||
mockTaskModel.getVerifyConfig.mockReturnValue(undefined);
|
||||
|
||||
const service = new TaskService(db, userId);
|
||||
const result = await service.getTaskDetail('TASK-1');
|
||||
@@ -1020,7 +1021,7 @@ describe('TaskService', () => {
|
||||
mockTaskModel.getTreePinnedDocuments.mockResolvedValue({ nodeMap: {}, tree: [] });
|
||||
mockTaskModel.findByIds.mockResolvedValue([]);
|
||||
mockTaskModel.getCheckpointConfig.mockReturnValue({});
|
||||
mockTaskModel.getReviewConfig.mockReturnValue(undefined);
|
||||
mockTaskModel.getVerifyConfig.mockReturnValue(undefined);
|
||||
mockAgentModel.getAgentAvatarsByIds.mockResolvedValue([
|
||||
{ avatar: 'avatar.png', backgroundColor: '#fff', id: 'agent-1', title: 'Agent One' },
|
||||
]);
|
||||
@@ -1091,7 +1092,7 @@ describe('TaskService', () => {
|
||||
mockTaskModel.getTreePinnedDocuments.mockResolvedValue({ nodeMap: {}, tree: [] });
|
||||
mockTaskModel.findByIds.mockResolvedValue([]);
|
||||
mockTaskModel.getCheckpointConfig.mockReturnValue({});
|
||||
mockTaskModel.getReviewConfig.mockReturnValue(undefined);
|
||||
mockTaskModel.getVerifyConfig.mockReturnValue(undefined);
|
||||
|
||||
const service = new TaskService(db, userId);
|
||||
const result = await service.getTaskDetail('TASK-1');
|
||||
@@ -1147,7 +1148,7 @@ describe('TaskService', () => {
|
||||
mockTaskModel.getTreePinnedDocuments.mockResolvedValue({ nodeMap: {}, tree: [] });
|
||||
mockTaskModel.findByIds.mockResolvedValue([]);
|
||||
mockTaskModel.getCheckpointConfig.mockReturnValue({});
|
||||
mockTaskModel.getReviewConfig.mockReturnValue(undefined);
|
||||
mockTaskModel.getVerifyConfig.mockReturnValue(undefined);
|
||||
// Force the brief enrichment path to reject without breaking the
|
||||
// sibling resolveAuthors call (which shares the agent model mock).
|
||||
const enrichSpy = vi
|
||||
@@ -1213,7 +1214,7 @@ describe('TaskService', () => {
|
||||
mockTaskModel.getTreePinnedDocuments.mockResolvedValue({ nodeMap: {}, tree: [] });
|
||||
mockTaskModel.findByIds.mockResolvedValue([]);
|
||||
mockTaskModel.getCheckpointConfig.mockReturnValue({});
|
||||
mockTaskModel.getReviewConfig.mockReturnValue(undefined);
|
||||
mockTaskModel.getVerifyConfig.mockReturnValue(undefined);
|
||||
|
||||
const service = new TaskService(db, userId);
|
||||
const result = await service.getTaskDetail('TASK-1');
|
||||
@@ -1273,7 +1274,7 @@ describe('TaskService', () => {
|
||||
mockTaskModel.getTreePinnedDocuments.mockResolvedValue({ nodeMap: {}, tree: [] });
|
||||
mockTaskModel.findByIds.mockResolvedValue([]);
|
||||
mockTaskModel.getCheckpointConfig.mockReturnValue({});
|
||||
mockTaskModel.getReviewConfig.mockReturnValue(undefined);
|
||||
mockTaskModel.getVerifyConfig.mockReturnValue(undefined);
|
||||
|
||||
const service = new TaskService(db, userId);
|
||||
const result = await service.getTaskDetail('TASK-1');
|
||||
|
||||
@@ -691,7 +691,6 @@ export class TaskService {
|
||||
name: task.name,
|
||||
parent,
|
||||
priority: task.priority,
|
||||
review: this.taskModel.getReviewConfig(task),
|
||||
schedule:
|
||||
task.schedulePattern || task.scheduleTimezone || scheduleConfig.maxExecutions != null
|
||||
? {
|
||||
@@ -702,6 +701,7 @@ export class TaskService {
|
||||
: undefined,
|
||||
status: task.status,
|
||||
userId: task.assigneeUserId,
|
||||
verify: this.taskModel.getVerifyConfig(task),
|
||||
subtasks,
|
||||
activities: activities.length > 0 ? activities : undefined,
|
||||
topicCount: topics.length > 0 ? topics.length : undefined,
|
||||
|
||||
+27
-2
@@ -83,7 +83,7 @@ describe('localSystemRuntime', () => {
|
||||
const result = await proxy[apiName](args);
|
||||
|
||||
expect(mockExecuteToolCall).toHaveBeenCalledWith(
|
||||
{ deviceId: 'device-1', operationId: 'op-1', userId: 'user-1' },
|
||||
{ deviceId: 'device-1', operationId: 'op-1', userId: 'user-1', workspaceId: undefined },
|
||||
{
|
||||
apiName,
|
||||
arguments: JSON.stringify(args),
|
||||
@@ -110,13 +110,38 @@ describe('localSystemRuntime', () => {
|
||||
await proxy[apiName](complexArgs);
|
||||
|
||||
expect(mockExecuteToolCall).toHaveBeenCalledWith(
|
||||
{ deviceId: 'device-2', userId: 'user-2' },
|
||||
{ deviceId: 'device-2', userId: 'user-2', workspaceId: undefined },
|
||||
expect.objectContaining({
|
||||
arguments: JSON.stringify(complexArgs),
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('should forward workspaceId so workspace-owned devices route to the correct gateway pool', async () => {
|
||||
const context: ToolExecutionContext = {
|
||||
activeDeviceId: 'device-ws',
|
||||
toolManifestMap: {},
|
||||
userId: 'user-1',
|
||||
workspaceId: 'ws-42',
|
||||
};
|
||||
|
||||
mockExecuteToolCall.mockResolvedValue({ content: '', success: true });
|
||||
|
||||
const proxy = localSystemRuntime.factory(context);
|
||||
const apiName = LocalSystemManifest.api[0].name;
|
||||
|
||||
await proxy[apiName]({ path: '/tmp' });
|
||||
|
||||
expect(mockExecuteToolCall).toHaveBeenCalledWith(
|
||||
{ deviceId: 'device-ws', userId: 'user-1', workspaceId: 'ws-42' },
|
||||
expect.objectContaining({
|
||||
apiName,
|
||||
identifier: LocalSystemIdentifier,
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('working directory injection', () => {
|
||||
|
||||
+50
-3
@@ -2,7 +2,7 @@ import {
|
||||
RemoteDeviceExecutionRuntime,
|
||||
RemoteDeviceIdentifier,
|
||||
} from '@lobechat/builtin-tool-remote-device';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { type ToolExecutionContext } from '../../types';
|
||||
|
||||
@@ -17,6 +17,10 @@ vi.mock('@/server/services/deviceGateway', () => ({
|
||||
// Import after mock setup
|
||||
const { remoteDeviceRuntime } = await import('../remoteDevice');
|
||||
|
||||
beforeEach(() => {
|
||||
mockQueryDeviceList.mockReset();
|
||||
});
|
||||
|
||||
describe('remoteDeviceRuntime', () => {
|
||||
it('should have the correct identifier', () => {
|
||||
expect(remoteDeviceRuntime.identifier).toBe(RemoteDeviceIdentifier);
|
||||
@@ -44,7 +48,7 @@ describe('remoteDeviceRuntime', () => {
|
||||
expect(runtime).toBeInstanceOf(RemoteDeviceExecutionRuntime);
|
||||
});
|
||||
|
||||
it('should pass queryDeviceList that calls deviceGateway with the userId', async () => {
|
||||
it('should query only the personal pool when no workspaceId is in context', async () => {
|
||||
const context: ToolExecutionContext = {
|
||||
toolManifestMap: {},
|
||||
userId: 'user-1',
|
||||
@@ -63,11 +67,54 @@ describe('remoteDeviceRuntime', () => {
|
||||
|
||||
const runtime = remoteDeviceRuntime.factory(context) as RemoteDeviceExecutionRuntime;
|
||||
|
||||
// Call listOnlineDevices which internally calls queryDeviceList
|
||||
const result = await runtime.listOnlineDevices();
|
||||
|
||||
expect(mockQueryDeviceList).toHaveBeenCalledTimes(1);
|
||||
expect(mockQueryDeviceList).toHaveBeenCalledWith('user-1');
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should merge personal + workspace pools when workspaceId is in context', async () => {
|
||||
const context: ToolExecutionContext = {
|
||||
toolManifestMap: {},
|
||||
userId: 'user-1',
|
||||
workspaceId: 'ws-1',
|
||||
};
|
||||
|
||||
const personalDevice = {
|
||||
deviceId: 'd-personal',
|
||||
hostname: 'laptop',
|
||||
lastSeen: '2024-01-01',
|
||||
online: true,
|
||||
platform: 'darwin',
|
||||
};
|
||||
const workspaceDevice = {
|
||||
deviceId: 'd-workspace',
|
||||
hostname: 'shared-mac',
|
||||
lastSeen: '2024-01-01',
|
||||
online: true,
|
||||
platform: 'darwin',
|
||||
};
|
||||
|
||||
mockQueryDeviceList.mockImplementation((_userId: string, wsId?: string) =>
|
||||
Promise.resolve(wsId ? [workspaceDevice] : [personalDevice]),
|
||||
);
|
||||
|
||||
const runtime = remoteDeviceRuntime.factory(context) as RemoteDeviceExecutionRuntime;
|
||||
|
||||
const result = await runtime.listOnlineDevices();
|
||||
|
||||
expect(mockQueryDeviceList).toHaveBeenCalledTimes(2);
|
||||
expect(mockQueryDeviceList).toHaveBeenCalledWith('user-1');
|
||||
expect(mockQueryDeviceList).toHaveBeenCalledWith('user-1', 'ws-1');
|
||||
expect(result.success).toBe(true);
|
||||
const parsed = JSON.parse(result.content);
|
||||
expect(parsed).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ deviceId: 'd-personal' }),
|
||||
expect.objectContaining({ deviceId: 'd-workspace' }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -65,6 +65,10 @@ export const localSystemRuntime: ServerRuntimeRegistration = {
|
||||
deviceId: context.activeDeviceId!,
|
||||
operationId: context.operationId,
|
||||
userId: context.userId!,
|
||||
// Workspace devices live under the `workspace:<id>` principal in
|
||||
// the gateway, so the relay needs the workspaceId to address the
|
||||
// right DO pool. Personal device runs leave it undefined.
|
||||
workspaceId: context.workspaceId,
|
||||
},
|
||||
{
|
||||
apiName: api.name,
|
||||
|
||||
@@ -14,9 +14,21 @@ export const remoteDeviceRuntime: ServerRuntimeRegistration = {
|
||||
}
|
||||
|
||||
const userId = context.userId;
|
||||
const workspaceId = context.workspaceId;
|
||||
|
||||
return new RemoteDeviceExecutionRuntime({
|
||||
queryDeviceList: () => deviceGateway.queryDeviceList(userId),
|
||||
// Personal pool (user principal) ∪ the current workspace's shared pool
|
||||
// (workspace principal). Mirrors execAgent's onlineDevices fetch so the
|
||||
// tool refresh stays consistent with the systemRole snapshot — otherwise
|
||||
// a workspace-bound chat would see its workspace device in the system
|
||||
// prompt but lose it the moment the model calls listOnlineDevices.
|
||||
queryDeviceList: async () => {
|
||||
const [personal, workspace] = await Promise.all([
|
||||
deviceGateway.queryDeviceList(userId),
|
||||
workspaceId ? deviceGateway.queryDeviceList(userId, workspaceId) : Promise.resolve([]),
|
||||
]);
|
||||
return [...personal, ...workspace];
|
||||
},
|
||||
});
|
||||
},
|
||||
identifier: RemoteDeviceIdentifier,
|
||||
|
||||
@@ -150,9 +150,12 @@ export const createTaskRuntime = (deps: TaskRuntimeDeps) => {
|
||||
},
|
||||
|
||||
createTask: async (args: CreateTaskArgs) => {
|
||||
const result = await createTaskImpl(args);
|
||||
const { identifier: _identifier, ...rest } = result;
|
||||
return rest;
|
||||
const { identifier, ...rest } = await createTaskImpl(args);
|
||||
// Surface the created task identifier as plugin state (mirrors the client
|
||||
// executor's `{ identifier, success }`) so the inline render can link to
|
||||
// the task detail. Without this the tool message persists no state and the
|
||||
// card has nothing to open.
|
||||
return identifier ? { ...rest, state: { identifier, success: rest.success } } : rest;
|
||||
},
|
||||
|
||||
createTasks: async (args: { tasks: CreateTaskArgs[] }) => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import debug from 'debug';
|
||||
|
||||
import { AgentOperationModel } from '@/database/models/agentOperation';
|
||||
import { VerifyCheckResultModel } from '@/database/models/verifyCheckResult';
|
||||
import { VerifyRunModel } from '@/database/models/verifyRun';
|
||||
import type { LobeChatDatabase } from '@/database/type';
|
||||
import { maybeAutoRepair, VerifyStatusService } from '@/server/services/verify';
|
||||
|
||||
@@ -55,9 +56,17 @@ class VerifyResultExecutionRuntime {
|
||||
);
|
||||
const targetOperationId = op?.parentOperationId ?? this.operationId;
|
||||
|
||||
// The result row is keyed by the parent run's verification session.
|
||||
const run = await new VerifyRunModel(this.db, this.userId, this.workspaceId).findByOperation(
|
||||
targetOperationId,
|
||||
);
|
||||
if (!run) {
|
||||
return { content: 'No verification session for this run.', error: 'NO_RUN', success: false };
|
||||
}
|
||||
|
||||
const status = params.verdict === 'passed' ? 'passed' : 'failed';
|
||||
await new VerifyCheckResultModel(this.db, this.userId, this.workspaceId).updateByCheckItem(
|
||||
targetOperationId,
|
||||
run.id,
|
||||
params.checkItemId,
|
||||
{
|
||||
completedAt: new Date(),
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { BUILTIN_AGENT_SLUGS } from '@lobechat/builtin-agents';
|
||||
import { VerifyToolIdentifier } from '@lobechat/builtin-tool-verify';
|
||||
import type { VerifyCheckItem } from '@lobechat/types';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createVerifierAgentRunner } from '../agentVerifier';
|
||||
|
||||
// AgentModel/ThreadModel expose their methods as arrow-function class fields
|
||||
// (instance props, not on the prototype), so they can't be spied via the
|
||||
// prototype — mock the modules instead. Hoisted so the factories can close over them.
|
||||
const { existsByIdMock, getBuiltinAgentMock, threadCreateMock, execAgentMock } = vi.hoisted(() => ({
|
||||
execAgentMock: vi.fn(async (_params: any) => ({ operationId: 'verifier-op-1' })),
|
||||
existsByIdMock: vi.fn(),
|
||||
getBuiltinAgentMock: vi.fn(),
|
||||
threadCreateMock: vi.fn(async () => ({ id: 'thread-1' })),
|
||||
}));
|
||||
|
||||
/** The single execAgent param object, asserted to exist. */
|
||||
const execParams = (): any => {
|
||||
const call = execAgentMock.mock.calls[0];
|
||||
expect(call).toBeDefined();
|
||||
return call![0];
|
||||
};
|
||||
|
||||
vi.mock('@/database/models/agent', () => ({
|
||||
AgentModel: vi.fn().mockImplementation(() => ({
|
||||
existsById: existsByIdMock,
|
||||
getBuiltinAgent: getBuiltinAgentMock,
|
||||
})),
|
||||
}));
|
||||
vi.mock('@/database/models/thread', () => ({
|
||||
ThreadModel: vi.fn().mockImplementation(() => ({ create: threadCreateMock })),
|
||||
}));
|
||||
// The runner dynamically imports AiAgentService to break a static cycle.
|
||||
vi.mock('@/server/services/aiAgent', () => ({
|
||||
AiAgentService: vi.fn().mockImplementation(() => ({ execAgent: execAgentMock })),
|
||||
}));
|
||||
|
||||
const checkItem: VerifyCheckItem = {
|
||||
id: 'check-1',
|
||||
index: 0,
|
||||
onFail: 'manual',
|
||||
required: true,
|
||||
title: 'Toolbar renders',
|
||||
verifierConfig: {},
|
||||
verifierType: 'agent',
|
||||
};
|
||||
|
||||
const runnerArgs = { checkItem, goal: 'ship the toolbar', operationId: 'parent-op-1' };
|
||||
const db = {} as any;
|
||||
|
||||
const baseParams = {
|
||||
db,
|
||||
deliverable: 'the toolbar',
|
||||
model: 'gpt-parent',
|
||||
provider: 'openai',
|
||||
topicId: 'topic-1',
|
||||
userId: 'u',
|
||||
};
|
||||
|
||||
describe('createVerifierAgentRunner', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
threadCreateMock.mockResolvedValue({ id: 'thread-1' });
|
||||
execAgentMock.mockResolvedValue({ operationId: 'verifier-op-1' });
|
||||
});
|
||||
|
||||
it('returns undefined without a topicId (no thread to host the verifier)', () => {
|
||||
const runner = createVerifierAgentRunner({ db, deliverable: 'x', topicId: null, userId: 'u' });
|
||||
expect(runner).toBeUndefined();
|
||||
});
|
||||
|
||||
it('runs a pinned agent by agentId, keeping its own model/provider', async () => {
|
||||
existsByIdMock.mockResolvedValue(true);
|
||||
|
||||
const runner = createVerifierAgentRunner({ ...baseParams, verifierAgentId: 'agent-codex' })!;
|
||||
const result = await runner(runnerArgs);
|
||||
|
||||
expect(result).toEqual({ verifierOperationId: 'verifier-op-1' });
|
||||
expect(existsByIdMock).toHaveBeenCalledWith('agent-codex');
|
||||
expect(getBuiltinAgentMock).not.toHaveBeenCalled();
|
||||
|
||||
const params = execParams();
|
||||
expect(params.agentId).toBe('agent-codex');
|
||||
// A pinned agent keeps its own agency — never overridden by the parent run.
|
||||
expect(params.slug).toBeUndefined();
|
||||
expect(params.model).toBeUndefined();
|
||||
expect(params.provider).toBeUndefined();
|
||||
// A pinned agent lacks the writeback tool, so it must be injected.
|
||||
expect(params.additionalPluginIds).toEqual([VerifyToolIdentifier]);
|
||||
});
|
||||
|
||||
it('falls back to the builtin verify agent (by slug) inheriting parent model/provider', async () => {
|
||||
existsByIdMock.mockResolvedValue(false); // pinned id no longer exists
|
||||
getBuiltinAgentMock.mockResolvedValue({ id: 'builtin-verify' });
|
||||
|
||||
const runner = createVerifierAgentRunner({ ...baseParams, verifierAgentId: 'agent-deleted' })!;
|
||||
await runner(runnerArgs);
|
||||
|
||||
expect(getBuiltinAgentMock).toHaveBeenCalledWith(BUILTIN_AGENT_SLUGS.verifyAgent);
|
||||
const params = execParams();
|
||||
expect(params.slug).toBe(BUILTIN_AGENT_SLUGS.verifyAgent);
|
||||
expect(params.agentId).toBeUndefined();
|
||||
expect(params.model).toBe('gpt-parent');
|
||||
expect(params.provider).toBe('openai');
|
||||
// The builtin verify agent already declares the tool — not re-injected.
|
||||
expect(params.additionalPluginIds).toBeUndefined();
|
||||
});
|
||||
|
||||
it('uses the builtin agent when no verifierAgentId is pinned', async () => {
|
||||
getBuiltinAgentMock.mockResolvedValue({ id: 'builtin-verify' });
|
||||
|
||||
const runner = createVerifierAgentRunner({ ...baseParams })!;
|
||||
await runner(runnerArgs);
|
||||
|
||||
// No pinned id → never probes existsById, goes straight to the builtin.
|
||||
expect(existsByIdMock).not.toHaveBeenCalled();
|
||||
expect(execParams().slug).toBe(BUILTIN_AGENT_SLUGS.verifyAgent);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BUILTIN_AGENT_SLUGS } from '@lobechat/builtin-agents';
|
||||
import { VerifyToolIdentifier } from '@lobechat/builtin-tool-verify';
|
||||
import type { VerifyCheckItem } from '@lobechat/types';
|
||||
import { ThreadType } from '@lobechat/types';
|
||||
import debug from 'debug';
|
||||
@@ -37,24 +38,35 @@ export const buildVerifierPrompt = (params: {
|
||||
};
|
||||
|
||||
/**
|
||||
* Build a {@link VerifierAgentRunner} that runs each `agent`-type check as the
|
||||
* dedicated builtin **verify agent**: it materializes the verify agent, opens an
|
||||
* isolated thread, and `execAgent`s (headless) with the check context (incl.
|
||||
* `checkItemId`) injected into the prompt. The verify agent investigates and
|
||||
* writes its verdict back via the `submitVerifyResult` tool during its run — no
|
||||
* document creation, no output parsing, no external completion hook.
|
||||
* Build a {@link VerifierAgentRunner} that runs each `agent`-type check as a
|
||||
* **verify agent**: it opens an isolated thread and `execAgent`s (headless) with
|
||||
* the check context (incl. `checkItemId`) injected into the prompt. The verify
|
||||
* agent investigates and writes its verdict back via the `submitVerifyResult`
|
||||
* tool during its run — no document creation, no output parsing, no external
|
||||
* completion hook.
|
||||
*
|
||||
* Which agent runs is selectable: when the task pins a `verifierAgentId`
|
||||
* (`TaskVerifyConfig.verifierAgentId`) that agent runs under its OWN agency
|
||||
* config (executionTarget / device / provider) — so picking a heterogeneous
|
||||
* agent (e.g. Codex) naturally gives the verifier device + browser access. When
|
||||
* unset (or the pinned agent no longer exists) it falls back to the builtin
|
||||
* verify agent, which inherits the parent run's model/provider (its own default
|
||||
* may not point at a configured provider).
|
||||
*/
|
||||
export const createVerifierAgentRunner = (params: {
|
||||
db: LobeChatDatabase;
|
||||
deliverable: string;
|
||||
/** Inherit the parent run's model so the verifier uses a configured provider. */
|
||||
/** Inherit the parent run's model so the builtin fallback uses a configured provider. */
|
||||
model?: string | null;
|
||||
provider?: string | null;
|
||||
topicId?: string | null;
|
||||
userId: string;
|
||||
/** Task-pinned verify agent. Falls back to the builtin verify agent when unset/missing. */
|
||||
verifierAgentId?: string | null;
|
||||
workspaceId?: string;
|
||||
}): VerifierAgentRunner | undefined => {
|
||||
const { db, deliverable, model, provider, topicId, userId, workspaceId } = params;
|
||||
const { db, deliverable, model, provider, topicId, userId, verifierAgentId, workspaceId } =
|
||||
params;
|
||||
if (!topicId) return undefined;
|
||||
|
||||
return async ({ checkItem, goal, operationId }) => {
|
||||
@@ -64,17 +76,42 @@ export const createVerifierAgentRunner = (params: {
|
||||
?.content ?? undefined)
|
||||
: undefined;
|
||||
|
||||
// Materialize the builtin verify agent (idempotent) to get an id for the thread.
|
||||
const verifyAgent = await new AgentModel(db, userId, workspaceId).getBuiltinAgent(
|
||||
BUILTIN_AGENT_SLUGS.verifyAgent,
|
||||
);
|
||||
if (!verifyAgent) {
|
||||
log('verify agent unavailable, cannot run agent verifier for check %s', checkItem.id);
|
||||
return null;
|
||||
const agentModel = new AgentModel(db, userId, workspaceId);
|
||||
|
||||
// Resolve which agent verifies. A pinned agent runs as itself (`agentId`) so
|
||||
// its own agency config drives execution target/provider — we don't override
|
||||
// its model/provider. The builtin fallback runs by `slug` and inherits the
|
||||
// parent run's model/provider.
|
||||
let threadAgentId: string;
|
||||
let agentRef: { agentId: string } | { slug: string };
|
||||
let inheritModel = false;
|
||||
// A pinned agent (selected for its runtime/device) carries only its own
|
||||
// configured plugins, so it lacks the verify writeback tool — inject it, else
|
||||
// the verdict never lands and the check result is stuck `running`. The builtin
|
||||
// verify agent already declares this tool in its plugins, so it isn't re-added.
|
||||
let extraPluginIds: string[] = [];
|
||||
|
||||
if (verifierAgentId && (await agentModel.existsById(verifierAgentId))) {
|
||||
threadAgentId = verifierAgentId;
|
||||
agentRef = { agentId: verifierAgentId };
|
||||
extraPluginIds = [VerifyToolIdentifier];
|
||||
} else {
|
||||
if (verifierAgentId) {
|
||||
log('pinned verify agent %s not found, falling back to builtin', verifierAgentId);
|
||||
}
|
||||
// Materialize the builtin verify agent (idempotent) to get an id for the thread.
|
||||
const builtin = await agentModel.getBuiltinAgent(BUILTIN_AGENT_SLUGS.verifyAgent);
|
||||
if (!builtin) {
|
||||
log('verify agent unavailable, cannot run agent verifier for check %s', checkItem.id);
|
||||
return null;
|
||||
}
|
||||
threadAgentId = builtin.id;
|
||||
agentRef = { slug: BUILTIN_AGENT_SLUGS.verifyAgent };
|
||||
inheritModel = true;
|
||||
}
|
||||
|
||||
const thread = await new ThreadModel(db, userId, workspaceId).create({
|
||||
agentId: verifyAgent.id,
|
||||
agentId: threadAgentId,
|
||||
title: `Verify: ${checkItem.title}`,
|
||||
topicId,
|
||||
type: ThreadType.Isolation,
|
||||
@@ -88,15 +125,17 @@ export const createVerifierAgentRunner = (params: {
|
||||
// → verify lifecycle → this runner → aiAgent.
|
||||
const { AiAgentService } = await import('@/server/services/aiAgent');
|
||||
const result = await new AiAgentService(db, userId, { workspaceId }).execAgent({
|
||||
// Inject the verify writeback tool for pinned agents (no-op list otherwise).
|
||||
...(extraPluginIds.length ? { additionalPluginIds: extraPluginIds } : {}),
|
||||
appContext: { threadId: thread.id, topicId },
|
||||
autoStart: true,
|
||||
// Inherit the parent run's model/provider so the verifier uses a provider
|
||||
// that's actually configured (the builtin agent's default may not be).
|
||||
...(model ? { model } : {}),
|
||||
// Only the builtin fallback inherits the parent run's model/provider; a
|
||||
// pinned agent keeps its own (critical for heterogeneous runtimes).
|
||||
...(inheritModel && model ? { model } : {}),
|
||||
parentOperationId: operationId,
|
||||
prompt: buildVerifierPrompt({ checkItem, deliverable, goal, instruction }),
|
||||
...(provider ? { provider } : {}),
|
||||
slug: BUILTIN_AGENT_SLUGS.verifyAgent,
|
||||
...(inheritModel && provider ? { provider } : {}),
|
||||
...agentRef,
|
||||
userInterventionConfig: { approvalMode: 'headless' },
|
||||
});
|
||||
|
||||
|
||||
@@ -10,9 +10,9 @@ import type {
|
||||
} from '@lobechat/types';
|
||||
import debug from 'debug';
|
||||
|
||||
import { AgentOperationModel } from '@/database/models/agentOperation';
|
||||
import { DocumentModel } from '@/database/models/document';
|
||||
import { VerifyCheckResultModel } from '@/database/models/verifyCheckResult';
|
||||
import { VerifyRunModel } from '@/database/models/verifyRun';
|
||||
import type { NewVerifyCheckResult } from '@/database/schemas/verify';
|
||||
import type { LobeChatDatabase } from '@/database/type';
|
||||
import { AiGenerationService } from '@/server/services/aiGeneration';
|
||||
@@ -75,7 +75,7 @@ const toToulmin = (v: SingleVerdict): ToulminVerdict => ({
|
||||
export class VerifyExecutorService {
|
||||
private readonly db: LobeChatDatabase;
|
||||
private readonly userId: string;
|
||||
private readonly operationModel: AgentOperationModel;
|
||||
private readonly runModel: VerifyRunModel;
|
||||
private readonly resultModel: VerifyCheckResultModel;
|
||||
private readonly statusService: VerifyStatusService;
|
||||
private readonly documentModel: DocumentModel;
|
||||
@@ -83,7 +83,7 @@ export class VerifyExecutorService {
|
||||
constructor(db: LobeChatDatabase, userId: string, workspaceId?: string) {
|
||||
this.db = db;
|
||||
this.userId = userId;
|
||||
this.operationModel = new AgentOperationModel(db, userId, workspaceId);
|
||||
this.runModel = new VerifyRunModel(db, userId, workspaceId);
|
||||
this.resultModel = new VerifyCheckResultModel(db, userId, workspaceId);
|
||||
this.statusService = new VerifyStatusService(db, userId, workspaceId);
|
||||
this.documentModel = new DocumentModel(db, userId, workspaceId);
|
||||
@@ -105,20 +105,22 @@ export class VerifyExecutorService {
|
||||
* spawner (results land asynchronously). Recomputes the rollup at the end.
|
||||
*/
|
||||
async execute(params: ExecuteVerifyParams): Promise<void> {
|
||||
const state = await this.operationModel.getVerifyState(params.operationId);
|
||||
if (!state?.verifyPlan?.length) {
|
||||
// Resolve (or lazily create) the verification session bound to this Agent Run.
|
||||
const run = await this.runModel.ensureForOperation(params.operationId);
|
||||
if (!run.plan?.length) {
|
||||
log('execute: no plan for op %s, skipping', params.operationId);
|
||||
return;
|
||||
}
|
||||
if (!state.verifyPlanConfirmedAt) {
|
||||
if (!run.planConfirmedAt) {
|
||||
log('execute: plan for op %s not confirmed, skipping', params.operationId);
|
||||
return;
|
||||
}
|
||||
|
||||
const items = state.verifyPlan as VerifyCheckItem[];
|
||||
const verifyRunId = run.id;
|
||||
const items = run.plan as VerifyCheckItem[];
|
||||
|
||||
// Idempotently create the pending result rows (skip ones already present).
|
||||
const existing = await this.resultModel.listByOperation(params.operationId);
|
||||
const existing = await this.resultModel.listByRun(verifyRunId);
|
||||
const existingIds = new Set(existing.map((r) => r.checkItemId));
|
||||
const toCreate: Omit<NewVerifyCheckResult, 'userId'>[] = items
|
||||
.filter((i) => !existingIds.has(i.id))
|
||||
@@ -126,11 +128,13 @@ export class VerifyExecutorService {
|
||||
checkItemId: item.id,
|
||||
checkItemIndex: item.index,
|
||||
checkItemTitle: item.title,
|
||||
// Denormalized direct link to the Agent Run (canonical link is verifyRunId).
|
||||
operationId: params.operationId,
|
||||
required: item.required,
|
||||
status: 'pending' as const,
|
||||
verifierConfigHash: hashConfig(item.verifierConfig),
|
||||
verifierType: item.verifierType,
|
||||
verifyRunId,
|
||||
}));
|
||||
if (toCreate.length > 0) await this.resultModel.createMany(toCreate);
|
||||
|
||||
@@ -143,18 +147,18 @@ export class VerifyExecutorService {
|
||||
// The three verifier kinds are independent — run them concurrently. LLM items
|
||||
// are judged in one batched call; each agent item spawns its own sub-agent.
|
||||
await Promise.all([
|
||||
this.runProgramItems(params.operationId, programItems),
|
||||
this.runLlmItems(params, llmItems),
|
||||
...agentItems.map((item) => this.runAgentItem(params, item)),
|
||||
this.runProgramItems(verifyRunId, programItems),
|
||||
this.runLlmItems(params, verifyRunId, llmItems),
|
||||
...agentItems.map((item) => this.runAgentItem(params, verifyRunId, item)),
|
||||
]);
|
||||
|
||||
await this.statusService.recompute(params.operationId);
|
||||
}
|
||||
|
||||
/** Program verifiers are a v1 placeholder (no shell environment) — mark skipped. */
|
||||
private async runProgramItems(operationId: string, items: VerifyCheckItem[]): Promise<void> {
|
||||
private async runProgramItems(verifyRunId: string, items: VerifyCheckItem[]): Promise<void> {
|
||||
for (const item of items) {
|
||||
await this.resultModel.updateByCheckItem(operationId, item.id, {
|
||||
await this.resultModel.updateByCheckItem(verifyRunId, item.id, {
|
||||
completedAt: new Date(),
|
||||
status: 'skipped',
|
||||
toulmin: { limitation: 'Program verifier is not executed in v1.' },
|
||||
@@ -163,13 +167,17 @@ export class VerifyExecutorService {
|
||||
}
|
||||
|
||||
/** Judge all LLM items via the Toulmin judge (one batched call by default). */
|
||||
private async runLlmItems(params: ExecuteVerifyParams, items: VerifyCheckItem[]): Promise<void> {
|
||||
private async runLlmItems(
|
||||
params: ExecuteVerifyParams,
|
||||
verifyRunId: string,
|
||||
items: VerifyCheckItem[],
|
||||
): Promise<void> {
|
||||
if (items.length === 0) return;
|
||||
try {
|
||||
if (params.batchLlm ?? true) {
|
||||
await this.judgeBatch(params, items);
|
||||
await this.judgeBatch(params, verifyRunId, items);
|
||||
} else {
|
||||
for (const item of items) await this.judgeSingle(params, item);
|
||||
for (const item of items) await this.judgeSingle(params, verifyRunId, item);
|
||||
}
|
||||
} catch (error) {
|
||||
log('llm judge failed for op %s: %O', params.operationId, error);
|
||||
@@ -178,9 +186,13 @@ export class VerifyExecutorService {
|
||||
}
|
||||
|
||||
/** Run one agent check as a verifier sub-agent (verdict lands async via its hook) or skip. */
|
||||
private async runAgentItem(params: ExecuteVerifyParams, item: VerifyCheckItem): Promise<void> {
|
||||
private async runAgentItem(
|
||||
params: ExecuteVerifyParams,
|
||||
verifyRunId: string,
|
||||
item: VerifyCheckItem,
|
||||
): Promise<void> {
|
||||
if (!params.runVerifierAgent) {
|
||||
await this.resultModel.updateByCheckItem(params.operationId, item.id, {
|
||||
await this.resultModel.updateByCheckItem(verifyRunId, item.id, {
|
||||
completedAt: new Date(),
|
||||
status: 'skipped',
|
||||
toulmin: { limitation: 'Agent verifier requires runtime context; not run here.' },
|
||||
@@ -193,14 +205,14 @@ export class VerifyExecutorService {
|
||||
goal: params.goal,
|
||||
operationId: params.operationId,
|
||||
});
|
||||
await this.resultModel.updateByCheckItem(params.operationId, item.id, {
|
||||
await this.resultModel.updateByCheckItem(verifyRunId, item.id, {
|
||||
startedAt: new Date(),
|
||||
status: 'running',
|
||||
verifierOperationId: spawned?.verifierOperationId ?? null,
|
||||
});
|
||||
} catch (error) {
|
||||
log('agent verifier spawn failed for item %s: %O', item.id, error);
|
||||
await this.resultModel.updateByCheckItem(params.operationId, item.id, {
|
||||
await this.resultModel.updateByCheckItem(verifyRunId, item.id, {
|
||||
completedAt: new Date(),
|
||||
status: 'failed',
|
||||
toulmin: { limitation: 'Agent verifier failed to start.' },
|
||||
@@ -209,7 +221,11 @@ export class VerifyExecutorService {
|
||||
}
|
||||
}
|
||||
|
||||
private async judgeBatch(params: ExecuteVerifyParams, items: VerifyCheckItem[]): Promise<void> {
|
||||
private async judgeBatch(
|
||||
params: ExecuteVerifyParams,
|
||||
verifyRunId: string,
|
||||
items: VerifyCheckItem[],
|
||||
): Promise<void> {
|
||||
// Batch: N verdicts share ONE tracing row (N:1).
|
||||
const tracingId = randomUUID();
|
||||
const promptItems = await Promise.all(
|
||||
@@ -248,7 +264,7 @@ export class VerifyExecutorService {
|
||||
// Backfill the tracing FK only after the (async, best-effort) tracing
|
||||
// row is persisted — verdicts are written with a null link below.
|
||||
onPersisted: this.backfillTracing(
|
||||
params.operationId,
|
||||
verifyRunId,
|
||||
items.map((i) => i.id),
|
||||
),
|
||||
},
|
||||
@@ -266,13 +282,17 @@ export class VerifyExecutorService {
|
||||
if (!validIds.has(v.checkItemId)) continue;
|
||||
await this.writeVerdict({
|
||||
checkItemId: v.checkItemId,
|
||||
operationId: params.operationId,
|
||||
verdict: v,
|
||||
verifyRunId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async judgeSingle(params: ExecuteVerifyParams, item: VerifyCheckItem): Promise<void> {
|
||||
private async judgeSingle(
|
||||
params: ExecuteVerifyParams,
|
||||
verifyRunId: string,
|
||||
item: VerifyCheckItem,
|
||||
): Promise<void> {
|
||||
// Per-criterion: each result gets its own tracing row (1:1).
|
||||
const tracingId = randomUUID();
|
||||
const { system, user } = buildJudgePrompt({
|
||||
@@ -301,7 +321,7 @@ export class VerifyExecutorService {
|
||||
schemaName: SINGLE_VERDICT_JSON_SCHEMA.name,
|
||||
tracingId,
|
||||
} satisfies TracingOptions),
|
||||
onPersisted: this.backfillTracing(params.operationId, [item.id]),
|
||||
onPersisted: this.backfillTracing(verifyRunId, [item.id]),
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -313,21 +333,21 @@ export class VerifyExecutorService {
|
||||
}
|
||||
await this.writeVerdict({
|
||||
checkItemId: item.id,
|
||||
operationId: params.operationId,
|
||||
verdict: parsed.data,
|
||||
verifyRunId,
|
||||
});
|
||||
}
|
||||
|
||||
private async writeVerdict(params: {
|
||||
checkItemId: string;
|
||||
operationId: string;
|
||||
verdict: SingleVerdict;
|
||||
verifyRunId: string;
|
||||
}): Promise<void> {
|
||||
const { operationId, checkItemId, verdict } = params;
|
||||
const { verifyRunId, checkItemId, verdict } = params;
|
||||
// `verifier_tracing_id` is intentionally left null here — the tracing row is
|
||||
// written asynchronously (best-effort, after the response), so linking it now
|
||||
// would violate the FK. It is backfilled by `backfillTracing` once the row exists.
|
||||
await this.resultModel.updateByCheckItem(operationId, checkItemId, {
|
||||
await this.resultModel.updateByCheckItem(verifyRunId, checkItemId, {
|
||||
completedAt: new Date(),
|
||||
confidence: verdict.confidence,
|
||||
status: verdictToStatus(verdict.verdict),
|
||||
@@ -344,13 +364,13 @@ export class VerifyExecutorService {
|
||||
* FK link. Receives the persisted tracing id (or null if tracing was disabled
|
||||
* or the record failed), so a missing tracing row simply leaves the link null.
|
||||
*/
|
||||
private backfillTracing(operationId: string, checkItemIds: string[]) {
|
||||
private backfillTracing(verifyRunId: string, checkItemIds: string[]) {
|
||||
return async (tracingId: string | null): Promise<void> => {
|
||||
if (!tracingId) return;
|
||||
try {
|
||||
await this.resultModel.backfillTracingId(operationId, checkItemIds, tracingId);
|
||||
await this.resultModel.backfillTracingId(verifyRunId, checkItemIds, tracingId);
|
||||
} catch (error) {
|
||||
log('tracing-id backfill failed for op %s (non-fatal): %O', operationId, error);
|
||||
log('tracing-id backfill failed for run %s (non-fatal): %O', verifyRunId, error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import debug from 'debug';
|
||||
|
||||
import { AgentOperationModel } from '@/database/models/agentOperation';
|
||||
import { TaskModel } from '@/database/models/task';
|
||||
import { VerifyRunModel } from '@/database/models/verifyRun';
|
||||
import type { LobeChatDatabase } from '@/database/type';
|
||||
|
||||
import { createVerifierAgentRunner } from './agentVerifier';
|
||||
@@ -36,27 +38,38 @@ export const runVerifyOnCompletion = async (
|
||||
workspaceId?: string,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const operationModel = new AgentOperationModel(db, userId, workspaceId);
|
||||
const state = await operationModel.getVerifyState(params.operationId);
|
||||
const run = await new VerifyRunModel(db, userId, workspaceId).findByOperation(
|
||||
params.operationId,
|
||||
);
|
||||
|
||||
// Opt-in gate: only runs with a confirmed plan that hasn't been verified yet.
|
||||
if (!state?.verifyPlan?.length || !state.verifyPlanConfirmedAt) return;
|
||||
if (state.verifyStatus !== 'planned') return;
|
||||
if (!run?.plan?.length || !run.planConfirmedAt) return;
|
||||
if (run.status !== 'planned') return;
|
||||
|
||||
const op = await operationModel.findById(params.operationId);
|
||||
const op = await new AgentOperationModel(db, userId, workspaceId).findById(params.operationId);
|
||||
if (!op?.model || !op?.provider) {
|
||||
log('op %s missing model/provider, cannot run verify', params.operationId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Task-bound runs may pin which agent verifies (TaskVerifyConfig.verifierAgentId,
|
||||
// with subtask inheritance). Non-task runs leave it undefined → builtin fallback.
|
||||
let verifierAgentId: string | undefined;
|
||||
if (op.taskId) {
|
||||
const verifyConfig = await new TaskModel(db, userId, workspaceId).resolveVerifyConfig(
|
||||
op.taskId,
|
||||
);
|
||||
verifierAgentId = verifyConfig?.verifierAgentId ?? undefined;
|
||||
}
|
||||
|
||||
const executor = new VerifyExecutorService(db, userId, workspaceId);
|
||||
await executor.execute({
|
||||
deliverable: params.deliverable,
|
||||
goal: params.goal,
|
||||
modelConfig: { model: op.model, provider: op.provider },
|
||||
operationId: params.operationId,
|
||||
// `agent`-type checks run as the dedicated builtin verify agent, which
|
||||
// writes its verdict back via the submitVerifyResult tool during its run.
|
||||
// `agent`-type checks run as the task-pinned verify agent (or the builtin
|
||||
// one), which writes its verdict back via the submitVerifyResult tool.
|
||||
runVerifierAgent: createVerifierAgentRunner({
|
||||
db,
|
||||
deliverable: params.deliverable,
|
||||
@@ -64,6 +77,7 @@ export const runVerifyOnCompletion = async (
|
||||
provider: op.provider,
|
||||
topicId: op.topicId,
|
||||
userId,
|
||||
verifierAgentId,
|
||||
workspaceId,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -5,10 +5,10 @@ import type { TracingOptions } from '@lobechat/llm-generation-tracing';
|
||||
import type { VerifyCheckItem } from '@lobechat/types';
|
||||
import debug from 'debug';
|
||||
|
||||
import { AgentOperationModel } from '@/database/models/agentOperation';
|
||||
import { DocumentModel } from '@/database/models/document';
|
||||
import { VerifyCriterionModel } from '@/database/models/verifyCriterion';
|
||||
import { VerifyRubricModel } from '@/database/models/verifyRubric';
|
||||
import { VerifyRunModel } from '@/database/models/verifyRun';
|
||||
import type { VerifyCriterionItem } from '@/database/schemas/verify';
|
||||
import type { LobeChatDatabase } from '@/database/type';
|
||||
import { AiGenerationService } from '@/server/services/aiGeneration';
|
||||
@@ -72,7 +72,7 @@ export class VerifyPlanGeneratorService {
|
||||
private readonly userId: string;
|
||||
private readonly criterionModel: VerifyCriterionModel;
|
||||
private readonly rubricModel: VerifyRubricModel;
|
||||
private readonly operationModel: AgentOperationModel;
|
||||
private readonly runModel: VerifyRunModel;
|
||||
private readonly documentModel: DocumentModel;
|
||||
|
||||
constructor(db: LobeChatDatabase, userId: string, workspaceId?: string) {
|
||||
@@ -80,7 +80,7 @@ export class VerifyPlanGeneratorService {
|
||||
this.userId = userId;
|
||||
this.criterionModel = new VerifyCriterionModel(db, userId, workspaceId);
|
||||
this.rubricModel = new VerifyRubricModel(db, userId, workspaceId);
|
||||
this.operationModel = new AgentOperationModel(db, userId, workspaceId);
|
||||
this.runModel = new VerifyRunModel(db, userId, workspaceId);
|
||||
this.documentModel = new DocumentModel(db, userId, workspaceId);
|
||||
}
|
||||
|
||||
@@ -155,9 +155,10 @@ export class VerifyPlanGeneratorService {
|
||||
// 3. Aggregate the criteria under the rubric (criteria reusable across rubrics).
|
||||
await this.rubricModel.setCriteria(rubric.id, links);
|
||||
|
||||
// 4. Snapshot onto the operation + confirm so it runs when the op completes.
|
||||
await this.operationModel.setVerifyPlan(params.operationId, items);
|
||||
await this.operationModel.confirmVerifyPlan(params.operationId);
|
||||
// 4. Snapshot onto the run + confirm so it runs when the op completes.
|
||||
const run = await this.runModel.ensureForOperation(params.operationId, { title: params.title });
|
||||
await this.runModel.setPlan(run.id, items);
|
||||
await this.runModel.confirmPlan(run.id);
|
||||
|
||||
log(
|
||||
'created rubric %s with %d criteria for op %s',
|
||||
@@ -215,7 +216,8 @@ export class VerifyPlanGeneratorService {
|
||||
}
|
||||
}
|
||||
|
||||
await this.operationModel.setVerifyPlan(params.operationId, items);
|
||||
const run = await this.runModel.ensureForOperation(params.operationId, { goal: params.goal });
|
||||
await this.runModel.setPlan(run.id, items);
|
||||
log('generated draft plan for op %s with %d items', params.operationId, items.length);
|
||||
|
||||
return items;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { AgentOperationModel } from '@/database/models/agentOperation';
|
||||
import { MessageModel } from '@/database/models/message';
|
||||
import { VerifyCheckResultModel } from '@/database/models/verifyCheckResult';
|
||||
import { VerifyRubricModel } from '@/database/models/verifyRubric';
|
||||
import { VerifyRunModel } from '@/database/models/verifyRun';
|
||||
import type { VerifyCheckResultItem } from '@/database/schemas/verify';
|
||||
import type { LobeChatDatabase } from '@/database/type';
|
||||
|
||||
@@ -114,13 +115,15 @@ export const createRepairRunner = (params: {
|
||||
});
|
||||
const repairOperationId = result.operationId;
|
||||
|
||||
// Re-snapshot the same plan onto the repair op + confirm, so the repair run
|
||||
// re-verifies (round N+1) against its corrected deliverable on completion.
|
||||
const state = await operationModel.getVerifyState(operationId);
|
||||
const plan = (state?.verifyPlan ?? []) as VerifyCheckItem[];
|
||||
// Re-snapshot the same plan onto the repair op's session + confirm, so the
|
||||
// repair run re-verifies (round N+1) against its corrected deliverable.
|
||||
const runModel = new VerifyRunModel(db, userId, workspaceId);
|
||||
const sourceRun = await runModel.findByOperation(operationId);
|
||||
const plan = (sourceRun?.plan ?? []) as VerifyCheckItem[];
|
||||
if (plan.length > 0) {
|
||||
await operationModel.setVerifyPlan(repairOperationId, plan);
|
||||
await operationModel.confirmVerifyPlan(repairOperationId);
|
||||
const repairRun = await runModel.ensureForOperation(repairOperationId);
|
||||
await runModel.setPlan(repairRun.id, plan);
|
||||
await runModel.confirmPlan(repairRun.id);
|
||||
}
|
||||
|
||||
log('repair op %s → %s (round %d)', operationId, repairOperationId, round + 1);
|
||||
@@ -143,13 +146,11 @@ export const maybeAutoRepair = async (
|
||||
workspaceId?: string,
|
||||
): Promise<void> => {
|
||||
const operationModel = new AgentOperationModel(db, userId, workspaceId);
|
||||
const state = await operationModel.getVerifyState(operationId);
|
||||
const plan = (state?.verifyPlan ?? []) as VerifyCheckItem[];
|
||||
if (plan.length === 0) return;
|
||||
const run = await new VerifyRunModel(db, userId, workspaceId).findByOperation(operationId);
|
||||
const plan = (run?.plan ?? []) as VerifyCheckItem[];
|
||||
if (!run || plan.length === 0) return;
|
||||
|
||||
const results = await new VerifyCheckResultModel(db, userId, workspaceId).listByOperation(
|
||||
operationId,
|
||||
);
|
||||
const results = await new VerifyCheckResultModel(db, userId, workspaceId).listByRun(run.id);
|
||||
const byItem = new Map(results.map((r) => [r.checkItemId, r]));
|
||||
|
||||
// Wait until every required check has a terminal result (don't repair early).
|
||||
@@ -193,22 +194,23 @@ const buildInstruction = (
|
||||
|
||||
export class VerifyRepairService {
|
||||
private readonly messageModel: MessageModel;
|
||||
private readonly operationModel: AgentOperationModel;
|
||||
private readonly runModel: VerifyRunModel;
|
||||
private readonly resultModel: VerifyCheckResultModel;
|
||||
private readonly statusService: VerifyStatusService;
|
||||
|
||||
constructor(db: LobeChatDatabase, userId: string, workspaceId?: string) {
|
||||
this.messageModel = new MessageModel(db, userId, workspaceId);
|
||||
this.operationModel = new AgentOperationModel(db, userId, workspaceId);
|
||||
this.runModel = new VerifyRunModel(db, userId, workspaceId);
|
||||
this.resultModel = new VerifyCheckResultModel(db, userId, workspaceId);
|
||||
this.statusService = new VerifyStatusService(db, userId, workspaceId);
|
||||
}
|
||||
|
||||
/** Collect the auto-repairable failures for a run. */
|
||||
async collectRepairable(operationId: string) {
|
||||
const state = await this.operationModel.getVerifyState(operationId);
|
||||
const plan = (state?.verifyPlan ?? []) as VerifyCheckItem[];
|
||||
const results = await this.resultModel.listByOperation(operationId);
|
||||
const run = await this.runModel.findByOperation(operationId);
|
||||
if (!run) return [];
|
||||
const plan = (run.plan ?? []) as VerifyCheckItem[];
|
||||
const results = await this.resultModel.listByRun(run.id);
|
||||
const byItem = new Map(results.map((r) => [r.checkItemId, r]));
|
||||
|
||||
return plan
|
||||
@@ -254,10 +256,13 @@ export class VerifyRepairService {
|
||||
if (!spawned) return null;
|
||||
|
||||
// Link the repair operation onto each failed result and flip the rollup.
|
||||
for (const { item } of failures) {
|
||||
await this.resultModel.updateByCheckItem(operationId, item.id, {
|
||||
repairOperationId: spawned.repairOperationId,
|
||||
});
|
||||
const run = await this.runModel.findByOperation(operationId);
|
||||
if (run) {
|
||||
for (const { item } of failures) {
|
||||
await this.resultModel.updateByCheckItem(run.id, item.id, {
|
||||
repairOperationId: spawned.repairOperationId,
|
||||
});
|
||||
}
|
||||
}
|
||||
await this.statusService.markRepairing(operationId);
|
||||
log('triggered auto-repair op %s → %s', operationId, spawned.repairOperationId);
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
import type { VerifyCheckItem } from '@lobechat/types';
|
||||
import type { VerifyCheckItem, VerifyRunStatus } from '@lobechat/types';
|
||||
import debug from 'debug';
|
||||
|
||||
import type { VerifyStatus } from '@/database/models/agentOperation';
|
||||
import { AgentOperationModel } from '@/database/models/agentOperation';
|
||||
import { VerifyCheckResultModel } from '@/database/models/verifyCheckResult';
|
||||
import { VerifyRunModel } from '@/database/models/verifyRun';
|
||||
import type { LobeChatDatabase } from '@/database/type';
|
||||
|
||||
const log = debug('lobe-server:verify-status');
|
||||
|
||||
/**
|
||||
* Service-layer chokepoint for the denormalized `agent_operations.verify_status`
|
||||
* rollup. MUST be the only writer of that column (besides explicit repair /
|
||||
* deliver transitions) so the badge never drifts from the underlying results.
|
||||
* Service-layer chokepoint for the denormalized `verify_runs.status` rollup. MUST
|
||||
* be the only writer of that column (besides explicit repair / deliver
|
||||
* transitions) so the badge never drifts from the underlying results. Addresses
|
||||
* sessions by their bound Agent Run (`operationId`) for the agent pipeline.
|
||||
*/
|
||||
export class VerifyStatusService {
|
||||
private readonly operationModel: AgentOperationModel;
|
||||
private readonly runModel: VerifyRunModel;
|
||||
private readonly resultModel: VerifyCheckResultModel;
|
||||
|
||||
constructor(db: LobeChatDatabase, userId: string, workspaceId?: string) {
|
||||
this.operationModel = new AgentOperationModel(db, userId, workspaceId);
|
||||
this.runModel = new VerifyRunModel(db, userId, workspaceId);
|
||||
this.resultModel = new VerifyCheckResultModel(db, userId, workspaceId);
|
||||
}
|
||||
|
||||
@@ -30,18 +30,18 @@ export class VerifyStatusService {
|
||||
* - otherwise → `passed`
|
||||
* `skipped` results (e.g. v1 program placeholders) are pass-through.
|
||||
*/
|
||||
async recompute(operationId: string): Promise<VerifyStatus | null> {
|
||||
const state = await this.operationModel.getVerifyState(operationId);
|
||||
if (!state) return null;
|
||||
async recompute(operationId: string): Promise<VerifyRunStatus | null> {
|
||||
const run = await this.runModel.findByOperation(operationId);
|
||||
if (!run) return null;
|
||||
|
||||
const plan = (state.verifyPlan ?? []) as VerifyCheckItem[];
|
||||
const plan = (run.plan ?? []) as VerifyCheckItem[];
|
||||
if (plan.length === 0) {
|
||||
// No plan → nothing to verify. Leave as-is (unverified / skipped).
|
||||
return state.verifyStatus ?? null;
|
||||
return (run.status ?? null) as VerifyRunStatus | null;
|
||||
}
|
||||
if (!state.verifyPlanConfirmedAt) return 'planned';
|
||||
if (!run.planConfirmedAt) return 'planned';
|
||||
|
||||
const results = await this.resultModel.listByOperation(operationId);
|
||||
const results = await this.resultModel.listByRun(run.id);
|
||||
const byItem = new Map(results.map((r) => [r.checkItemId, r]));
|
||||
|
||||
const requiredItems = plan.filter((i) => i.required);
|
||||
@@ -58,11 +58,11 @@ export class VerifyStatusService {
|
||||
if (result.status === 'failed' || result.verdict === 'failed') anyFailed = true;
|
||||
}
|
||||
|
||||
const status: VerifyStatus = anyPending ? 'verifying' : anyFailed ? 'failed' : 'passed';
|
||||
const status: VerifyRunStatus = anyPending ? 'verifying' : anyFailed ? 'failed' : 'passed';
|
||||
|
||||
if (status !== state.verifyStatus) {
|
||||
await this.operationModel.updateVerifyStatus(operationId, status);
|
||||
log('rollup op %s → %s', operationId, status);
|
||||
if (status !== run.status) {
|
||||
await this.runModel.updateStatus(run.id, status);
|
||||
log('rollup op %s (run %s) → %s', operationId, run.id, status);
|
||||
}
|
||||
|
||||
return status;
|
||||
@@ -70,14 +70,24 @@ export class VerifyStatusService {
|
||||
|
||||
/** Explicit transitions that aren't derivable from results alone. */
|
||||
async markVerifying(operationId: string) {
|
||||
await this.operationModel.updateVerifyStatus(operationId, 'verifying');
|
||||
await this.setStatus(operationId, 'verifying');
|
||||
}
|
||||
|
||||
async markRepairing(operationId: string) {
|
||||
await this.operationModel.updateVerifyStatus(operationId, 'repairing');
|
||||
await this.setStatus(operationId, 'repairing');
|
||||
}
|
||||
|
||||
async markDelivered(operationId: string) {
|
||||
await this.operationModel.updateVerifyStatus(operationId, 'delivered');
|
||||
await this.setStatus(operationId, 'delivered');
|
||||
}
|
||||
|
||||
/** Resolve the session for an Agent Run and write its rollup status. */
|
||||
private async setStatus(operationId: string, status: VerifyRunStatus): Promise<void> {
|
||||
const run = await this.runModel.findByOperation(operationId);
|
||||
if (!run) {
|
||||
log('setStatus: no verify run for op %s, skipping %s', operationId, status);
|
||||
return;
|
||||
}
|
||||
await this.runModel.updateStatus(run.id, status);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ services:
|
||||
- '5432:5432'
|
||||
volumes:
|
||||
- './data:/var/lib/postgresql/data'
|
||||
command: ['postgres', '-c', 'shared_preload_libraries=pg_search']
|
||||
environment:
|
||||
- 'POSTGRES_DB=${LOBE_DB_NAME}'
|
||||
- 'POSTGRES_PASSWORD=${POSTGRES_PASSWORD}'
|
||||
|
||||
@@ -21,6 +21,7 @@ services:
|
||||
- '5432:5432'
|
||||
volumes:
|
||||
- './data:/var/lib/postgresql/data'
|
||||
command: ['postgres', '-c', 'shared_preload_libraries=pg_search']
|
||||
environment:
|
||||
- 'POSTGRES_DB=${LOBE_DB_NAME}'
|
||||
- 'POSTGRES_PASSWORD=${POSTGRES_PASSWORD}'
|
||||
|
||||
@@ -26,6 +26,7 @@ services:
|
||||
environment:
|
||||
- 'POSTGRES_DB=${LOBE_DB_NAME}'
|
||||
- 'POSTGRES_PASSWORD=${POSTGRES_PASSWORD}'
|
||||
command: ['postgres', '-c', 'shared_preload_libraries=pg_search']
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U postgres']
|
||||
interval: 5s
|
||||
|
||||
@@ -461,8 +461,7 @@ table ai_models {
|
||||
updated_at "timestamp with time zone" [not null, default: `now()`]
|
||||
|
||||
indexes {
|
||||
(id, provider_id, user_id) [name: 'ai_models_id_provider_id_user_id_unique', unique]
|
||||
(id, provider_id, user_id, workspace_id) [name: 'ai_models_id_provider_id_user_id_workspace_id_unique', unique]
|
||||
(id, provider_id, user_id) [pk]
|
||||
user_id [name: 'ai_models_user_id_idx']
|
||||
workspace_id [name: 'ai_models_workspace_id_idx']
|
||||
}
|
||||
@@ -489,8 +488,7 @@ table ai_providers {
|
||||
updated_at "timestamp with time zone" [not null, default: `now()`]
|
||||
|
||||
indexes {
|
||||
(id, user_id) [name: 'ai_providers_id_user_id_unique', unique]
|
||||
(id, user_id, workspace_id) [name: 'ai_providers_id_user_id_workspace_id_unique', unique]
|
||||
(id, user_id) [pk]
|
||||
user_id [name: 'ai_providers_user_id_idx']
|
||||
workspace_id [name: 'ai_providers_workspace_id_idx']
|
||||
}
|
||||
@@ -2451,7 +2449,8 @@ table user_memory_persona_documents {
|
||||
|
||||
table verify_check_results {
|
||||
id uuid [pk, not null, default: `gen_random_uuid()`]
|
||||
operation_id text [not null]
|
||||
verify_run_id uuid
|
||||
operation_id text
|
||||
user_id text [not null]
|
||||
workspace_id text
|
||||
check_item_id text [not null]
|
||||
@@ -2476,9 +2475,10 @@ table verify_check_results {
|
||||
created_at "timestamp with time zone" [not null, default: `now()`]
|
||||
|
||||
indexes {
|
||||
verify_run_id [name: 'verify_check_results_verify_run_id_idx']
|
||||
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]
|
||||
(verify_run_id, check_item_id) [name: 'verify_check_results_verify_run_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']
|
||||
@@ -2512,6 +2512,54 @@ table verify_criteria {
|
||||
}
|
||||
}
|
||||
|
||||
table verify_evidence {
|
||||
id uuid [pk, not null, default: `gen_random_uuid()`]
|
||||
description text
|
||||
check_result_id uuid [not null]
|
||||
type text [not null]
|
||||
content text
|
||||
file_id text
|
||||
captured_by text
|
||||
captured_at "timestamp with time zone"
|
||||
user_id text [not null]
|
||||
workspace_id text
|
||||
created_at "timestamp with time zone" [not null, default: `now()`]
|
||||
|
||||
indexes {
|
||||
check_result_id [name: 'verify_evidence_check_result_id_idx']
|
||||
file_id [name: 'verify_evidence_file_id_idx']
|
||||
user_id [name: 'verify_evidence_user_id_idx']
|
||||
workspace_id [name: 'verify_evidence_workspace_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
table verify_reports {
|
||||
id uuid [pk, not null, default: `gen_random_uuid()`]
|
||||
verify_run_id uuid
|
||||
operation_id text
|
||||
user_id text [not null]
|
||||
workspace_id text
|
||||
verdict text
|
||||
overall_confidence "numeric(3, 2)"
|
||||
total_checks integer
|
||||
passed_checks integer
|
||||
failed_checks integer
|
||||
uncertain_checks integer
|
||||
summary text
|
||||
content text
|
||||
reviewed_by_user boolean [default: false]
|
||||
generated_by text [default: 'system']
|
||||
generated_at "timestamp with time zone" [not null, default: `now()`]
|
||||
created_at "timestamp with time zone" [not null, default: `now()`]
|
||||
|
||||
indexes {
|
||||
verify_run_id [name: 'verify_reports_verify_run_id_unique', unique]
|
||||
operation_id [name: 'verify_reports_operation_id_idx']
|
||||
user_id [name: 'verify_reports_user_id_idx']
|
||||
workspace_id [name: 'verify_reports_workspace_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
table verify_rubric_criteria {
|
||||
rubric_id uuid [not null]
|
||||
criterion_id uuid [not null]
|
||||
@@ -2545,6 +2593,29 @@ table verify_rubrics {
|
||||
}
|
||||
}
|
||||
|
||||
table verify_runs {
|
||||
id uuid [pk, not null, default: `gen_random_uuid()`]
|
||||
user_id text [not null]
|
||||
workspace_id text
|
||||
operation_id text
|
||||
source text [not null, default: 'agent']
|
||||
title text
|
||||
goal text
|
||||
plan jsonb
|
||||
plan_confirmed_at "timestamp with time zone"
|
||||
status 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_runs_user_id_idx']
|
||||
workspace_id [name: 'verify_runs_workspace_id_idx']
|
||||
operation_id [name: 'verify_runs_operation_id_unique', unique]
|
||||
source [name: 'verify_runs_source_idx']
|
||||
}
|
||||
}
|
||||
|
||||
table workspace_audit_logs {
|
||||
id text [pk, not null]
|
||||
workspace_id text [not null]
|
||||
|
||||
+28
-2
@@ -41,6 +41,8 @@
|
||||
"artifact.thinking": "يفكر",
|
||||
"artifact.thought": "عملية التفكير",
|
||||
"artifact.unknownTitle": "عمل بدون عنوان",
|
||||
"audioPlayer.pause": "إيقاف تشغيل الصوت",
|
||||
"audioPlayer.play": "تشغيل الصوت",
|
||||
"availableAgents": "الوكلاء المتاحون",
|
||||
"backToBottom": "الانتقال إلى الأحدث",
|
||||
"beforeUnload.confirmLeave": "لا يزال هناك طلب قيد التشغيل. هل تريد المغادرة؟",
|
||||
@@ -120,6 +122,18 @@
|
||||
"createModal.groupPlaceholder": "صف ما ينبغي أن يقوم به هذا الفريق...",
|
||||
"createModal.groupTitle": "ما الذي ينبغي أن يقوم به فريقك؟",
|
||||
"createModal.placeholder": "صف ما ينبغي أن يقوم به وكيلك...",
|
||||
"createModal.skillSuggestion.actions.createAnyway": "إنشاء الوكيل على أي حال",
|
||||
"createModal.skillSuggestion.actions.createAnywayHint": "المهارة غير مناسبة؟",
|
||||
"createModal.skillSuggestion.actions.install": "تثبيت المهارة",
|
||||
"createModal.skillSuggestion.actions.installing": "جارٍ التثبيت…",
|
||||
"createModal.skillSuggestion.actions.openSkills": "عرض في المهارات",
|
||||
"createModal.skillSuggestion.actions.tryInLobeAI": "استخدام في {{name}}",
|
||||
"createModal.skillSuggestion.description": "يبدو أن هذا سير عمل قابل لإعادة الاستخدام. قم بتثبيت المهارة مرة واحدة، ثم استخدمها عبر الوكلاء.",
|
||||
"createModal.skillSuggestion.installError": "لم يتم تثبيت المهارة. حاول مرة أخرى، أو قم بإنشاء وكيل على أي حال.",
|
||||
"createModal.skillSuggestion.installed.description": "يمكنك استخدام هذه المهارة في {{name}}، أو تمكينها لأي وكيل.",
|
||||
"createModal.skillSuggestion.installed.ready": "جاهز في {{name}}",
|
||||
"createModal.skillSuggestion.installed.title": "تم تثبيت المهارة",
|
||||
"createModal.skillSuggestion.title": "قد تكون المهارة مناسبة أكثر",
|
||||
"createModal.title": "ما الذي ينبغي أن يقوم به وكيلك؟",
|
||||
"createTask.assignee": "المكلّف",
|
||||
"createTask.collapse": "إخفاء الإدخال",
|
||||
@@ -166,6 +180,8 @@
|
||||
"extendParams.title": "ميزات توسيع النموذج",
|
||||
"extendParams.urlContext.desc": "عند التفعيل، سيتم تحليل الروابط تلقائيًا لاستخراج محتوى صفحة الويب الفعلي",
|
||||
"extendParams.urlContext.title": "استخراج محتوى رابط الويب",
|
||||
"floatingChatPanel.collapse": "إخفاء الدردشة",
|
||||
"floatingChatPanel.expand": "توسيع الدردشة",
|
||||
"followUpPlaceholder": "متابعة. @ لإسناد مهام لوكلاء آخرين.",
|
||||
"followUpPlaceholderHeterogeneous": "تابع.",
|
||||
"gatewayMode.beta": "تجريبي",
|
||||
@@ -219,9 +235,13 @@
|
||||
"heteroAgent.cloudRepo.noRepos": "لم يتم تكوين أي مستودعات. أضفها في إعدادات الوكيل.",
|
||||
"heteroAgent.cloudRepo.notSet": "لم يتم تحديد أي مستودع",
|
||||
"heteroAgent.cloudRepo.sectionTitle": "المستودعات",
|
||||
"heteroAgent.executionTarget.auto": "تلقائي",
|
||||
"heteroAgent.executionTarget.autoDesc": "استخدام جهاز متصل تلقائيًا، واختيار واحد عند توفر عدة أجهزة",
|
||||
"heteroAgent.executionTarget.downloadDesktop": "احصل على تطبيق سطح المكتب",
|
||||
"heteroAgent.executionTarget.downloadDesktopDesc": "قم بتشغيل الوكلاء مع الوصول إلى جهاز الكمبيوتر الخاص بك",
|
||||
"heteroAgent.executionTarget.downloadDesktopTitle": "احصل على تطبيق سطح المكتب",
|
||||
"heteroAgent.executionTarget.gateway": "البوابة",
|
||||
"heteroAgent.executionTarget.gatewayDesc": "التشغيل عبر بوابة الجهاز بحيث يمكن للعملاء الآخرين متابعة التقدم",
|
||||
"heteroAgent.executionTarget.infoTooltip": "اختر جهازًا بعيدًا لتشغيل هذا الجهاز من الويب. \"هذا الجهاز\" يشغل الوكيل محليًا وهو متاح فقط داخل تطبيق سطح المكتب.",
|
||||
"heteroAgent.executionTarget.loading": "جارٍ تحميل الأجهزة...",
|
||||
"heteroAgent.executionTarget.local": "هذا الجهاز",
|
||||
@@ -231,10 +251,12 @@
|
||||
"heteroAgent.executionTarget.noneDesc": "لم يتم تمكين أي جهاز",
|
||||
"heteroAgent.executionTarget.offline": "غير متصل",
|
||||
"heteroAgent.executionTarget.online": "متصل",
|
||||
"heteroAgent.executionTarget.personalGroup": "شخصي",
|
||||
"heteroAgent.executionTarget.sandbox": "بيئة سحابية مؤقتة",
|
||||
"heteroAgent.executionTarget.sandboxDesc": "تشغيل في بيئة سحابية مؤقتة",
|
||||
"heteroAgent.executionTarget.title": "جهاز التنفيذ",
|
||||
"heteroAgent.executionTarget.unknownDevice": "جهاز غير معروف",
|
||||
"heteroAgent.executionTarget.workspaceGroup": "مساحة العمل",
|
||||
"heteroAgent.fullAccess.label": "وصول كامل",
|
||||
"heteroAgent.fullAccess.tooltip": "يعمل Claude Code محليًا مع صلاحية قراءة/كتابة كاملة في دليل العمل. تبديل أوضاع الصلاحيات غير متاح بعد.",
|
||||
"heteroAgent.resumeReset.cwdChanged": "تم تغيير دليل العمل. لا يمكن استئناف جلسة Claude Code السابقة إلا من دليلها الأصلي، لذا بدأت محادثة جديدة.",
|
||||
@@ -631,6 +653,8 @@
|
||||
"taskDetail.artifacts": "المنتجات",
|
||||
"taskDetail.blockedBy": "محجوب بواسطة {{id}}",
|
||||
"taskDetail.cancelSchedule": "إلغاء الجدولة",
|
||||
"taskDetail.closeDetail": "إغلاق التفاصيل",
|
||||
"taskDetail.collapseReply": "إخفاء الرد",
|
||||
"taskDetail.comment.cancel": "إلغاء",
|
||||
"taskDetail.comment.delete": "حذف",
|
||||
"taskDetail.comment.deleteConfirm.content": "سيتم حذف هذا التعليق بشكل دائم.",
|
||||
@@ -657,6 +681,7 @@
|
||||
"taskDetail.notFound.backToTasks": "العودة إلى جميع المهام",
|
||||
"taskDetail.notFound.desc": "قد تكون هذه المهمة قد حُذفت، أو ليس لديك إذن لعرضها.",
|
||||
"taskDetail.notFound.title": "المهمة غير موجودة",
|
||||
"taskDetail.openDetail": "فتح التفاصيل",
|
||||
"taskDetail.pauseTask": "إيقاف المهمة مؤقتًا",
|
||||
"taskDetail.priority.high": "عالية",
|
||||
"taskDetail.priority.low": "منخفضة",
|
||||
@@ -925,9 +950,9 @@
|
||||
"workflow.collapse": "طي",
|
||||
"workflow.expandFull": "توسيع كامل",
|
||||
"workflow.failedSuffix": "(فشل)",
|
||||
"workflow.summaryAcrossTools": "عبر {{count}} أدوات",
|
||||
"workflow.summaryCallsLead": "{{count}} مكالمات: {{tools}}",
|
||||
"workflow.summaryFailed": "{{count}} فشلت",
|
||||
"workflow.summaryMoreTools": "{{count}} أنواع أدوات",
|
||||
"workflow.summaryTotalCalls": "{{count}} مكالمات إجمالية",
|
||||
"workflow.thoughtForDuration": "تفكير لمدة {{duration}}",
|
||||
"workflow.toolDisplayName.activateDevice": "تم تفعيل الجهاز",
|
||||
"workflow.toolDisplayName.activateSkill": "تم تفعيل مهارة",
|
||||
@@ -1043,6 +1068,7 @@
|
||||
"workingPanel.resources.deleteTitle": "Delete document?",
|
||||
"workingPanel.resources.deleteTitleMulti": "حذف {{count}} عنصرًا؟",
|
||||
"workingPanel.resources.empty": "لا توجد مستندات بعد. ستظهر المستندات المرتبطة بهذا الوكيل هنا.",
|
||||
"workingPanel.resources.emptyDocuments": "لا توجد مستندات حتى الآن. قم بإنشاء واحد باستخدام + أعلاه.",
|
||||
"workingPanel.resources.error": "Failed to load resources",
|
||||
"workingPanel.resources.filter.documents": "مستندات",
|
||||
"workingPanel.resources.filter.skills": "المهارات",
|
||||
|
||||
+18
-2
@@ -444,6 +444,23 @@
|
||||
"tab.setting": "الإعدادات",
|
||||
"tab.tasks": "المهام",
|
||||
"tab.video": "الفيديو",
|
||||
"taskTemplate.action.connect.button": "اتصل بـ {{provider}}",
|
||||
"taskTemplate.action.connect.error": "فشل الاتصال، يرجى المحاولة مرة أخرى.",
|
||||
"taskTemplate.action.connect.popupBlocked": "تم حظر نافذة الاتصال المنبثقة. اسمح بالنوافذ المنبثقة في متصفحك للمتابعة.",
|
||||
"taskTemplate.action.connect.short": "اتصل",
|
||||
"taskTemplate.action.connecting": "في انتظار التفويض...",
|
||||
"taskTemplate.action.create.error": "فشل إنشاء المهمة. يرجى المحاولة مرة أخرى.",
|
||||
"taskTemplate.action.create.success": "تمت إضافة المهمة المجدولة. يمكنك العثور عليها في Lobe AI.",
|
||||
"taskTemplate.action.createButton": "إضافة مهمة",
|
||||
"taskTemplate.action.creating": "جاري الإنشاء...",
|
||||
"taskTemplate.action.dismiss.error": "فشل الإلغاء. يرجى المحاولة مرة أخرى.",
|
||||
"taskTemplate.action.dismiss.tooltip": "غير مهتم",
|
||||
"taskTemplate.action.refresh.button": "تحديث",
|
||||
"taskTemplate.card.templateTag": "قالب",
|
||||
"taskTemplate.schedule.daily": "كل يوم في {{time}}",
|
||||
"taskTemplate.schedule.editableAfterCreateTooltip": "يمكنك تعديل الجدول الزمني بعد إنشاء المهمة.",
|
||||
"taskTemplate.schedule.weekly": "كل {{weekday}} في {{time}}",
|
||||
"taskTemplate.section.title": "جرب هذه المهام المجدولة",
|
||||
"telemetry.allow": "السماح",
|
||||
"telemetry.deny": "رفض",
|
||||
"telemetry.desc": "نود جمع معلومات الاستخدام بشكل مجهول لمساعدتنا في تحسين {{appName}} وتقديم تجربة أفضل لك. يمكنك تعطيل هذا في أي وقت من خلال الإعدادات - حول.",
|
||||
@@ -474,15 +491,14 @@
|
||||
"userPanel.email": "دعم البريد الإلكتروني",
|
||||
"userPanel.feedback": "اتصل بنا",
|
||||
"userPanel.help": "مركز المساعدة",
|
||||
"userPanel.inviteFriend": "ادعُ صديقًا",
|
||||
"userPanel.moveGuide": "تم نقل زر الإعدادات إلى هنا",
|
||||
"userPanel.plans": "خطط الاشتراك",
|
||||
"userPanel.profile": "الحساب",
|
||||
"userPanel.setting": "الإعدادات",
|
||||
"userPanel.upgradePlan": "ترقية الخطة",
|
||||
"userPanel.usages": "إحصائيات الاستخدام",
|
||||
"userPanel.workspaceCredits": "أرصدة مساحة العمل",
|
||||
"userPanel.workspaceSetting": "إعدادات مساحة العمل",
|
||||
"userPanel.workspaceUsages": "استخدامات مساحة العمل",
|
||||
"version": "الإصدار",
|
||||
"zoom": "تكبير"
|
||||
}
|
||||
|
||||
@@ -101,6 +101,7 @@
|
||||
"LocalFile.action.open": "فتح",
|
||||
"LocalFile.action.showInFolder": "عرض في المجلد",
|
||||
"MaxTokenSlider.unlimited": "غير محدود",
|
||||
"ModelSelect.featureTag.audio": "يدعم هذا النموذج التعرف على إدخال الصوت.",
|
||||
"ModelSelect.featureTag.custom": "نموذج مخصص، يدعم افتراضيًا استدعاء الوظائف والتعرف البصري. يرجى التحقق من توفر هذه القدرات حسب الحالة الفعلية.",
|
||||
"ModelSelect.featureTag.file": "يدعم هذا النموذج تحميل الملفات للقراءة والتعرف.",
|
||||
"ModelSelect.featureTag.functionCall": "يدعم هذا النموذج استدعاء الوظائف.",
|
||||
@@ -114,6 +115,7 @@
|
||||
"ModelSwitchPanel.byModel": "حسب النموذج",
|
||||
"ModelSwitchPanel.byProvider": "حسب المزوّد",
|
||||
"ModelSwitchPanel.detail.abilities": "القدرات",
|
||||
"ModelSwitchPanel.detail.abilities.audio": "الصوت",
|
||||
"ModelSwitchPanel.detail.abilities.files": "الملفات",
|
||||
"ModelSwitchPanel.detail.abilities.functionCall": "استدعاء الأداة",
|
||||
"ModelSwitchPanel.detail.abilities.imageOutput": "إخراج الصورة",
|
||||
|
||||
@@ -38,9 +38,6 @@
|
||||
"brief.title": "الموجز اليومي",
|
||||
"brief.viewAllTasks": "عرض جميع المهام",
|
||||
"brief.viewRun": "عرض التشغيل",
|
||||
"freeCreditBadge.cta": "ابدأ النسخة التجريبية المجانية",
|
||||
"freeCreditBadge.dismiss": "تجاهل",
|
||||
"freeCreditBadge.label": "أرصدة مجانية حصرية لـ {{model}}",
|
||||
"project.create": "مشروع جديد",
|
||||
"project.deleteConfirm": "سيتم حذف هذا المشروع ولن يمكن استعادته. أكد للمتابعة.",
|
||||
"recommendations.heteroAgent.cta": "أضف الوكيل",
|
||||
|
||||
@@ -5,10 +5,12 @@
|
||||
"authorize.footer.agreement": "بالمتابعة، فإنك تؤكد أنك قرأت ووافقت على <terms>الشروط والأحكام</terms> و<privacy>سياسة الخصوصية</privacy>.",
|
||||
"authorize.footer.privacy": "سياسة الخصوصية",
|
||||
"authorize.footer.terms": "شروط الخدمة",
|
||||
"authorize.scenes.connector.confirm": "متابعة إلى السوق",
|
||||
"authorize.scenes.connector.description": "يُستخدم السوق فقط لبدء تفويض هذه الخدمة. يبقى حساب {{appName}} الخاص بك منفصلًا.",
|
||||
"authorize.scenes.connector.subtitle": "سجّل الدخول إلى السوق للاتصال وتفويض هذه الخدمة المجتمعية.",
|
||||
"authorize.scenes.connector.title": "اتصال الخدمة المجتمعية",
|
||||
"authorize.scenes.mcp.subtitle": "قم بإنشاء ملف تعريف مجتمعي لتثبيت وتشغيل هذه المهارة من المجتمع.",
|
||||
"authorize.scenes.mcp.title": "تثبيت مهارة المجتمع",
|
||||
"authorize.scenes.publish.subtitle": "قم بإنشاء ملف تعريف مجتمعي لنشر وإدارة قائمتك داخل المجتمع.",
|
||||
"authorize.scenes.publish.title": "النشر في المجتمع",
|
||||
"authorize.scenes.sandbox.subtitle": "قم بإنشاء ملف تعريف مجتمعي لتشغيل هذه الأداة في صندوق المجتمع التجريبي.",
|
||||
"authorize.scenes.sandbox.title": "جرب صندوق المجتمع التجريبي",
|
||||
"authorize.subtitle": "أنشئ ملفًا شخصيًا في المجتمع لتتمكن من إرسال وإدارة القوائم داخل المجتمع.",
|
||||
@@ -50,8 +52,6 @@
|
||||
"messages.handoffTimeout": "انتهت مهلة التفويض. أكمل العملية في المتصفح ثم أعد المحاولة.",
|
||||
"messages.loading": "جارٍ بدء عملية التفويض...",
|
||||
"messages.success.cloudMcpInstall": "تم التفويض بنجاح! يمكنك الآن تثبيت مهارة Cloud MCP.",
|
||||
"messages.success.submit": "تم التفويض بنجاح! يمكنك الآن نشر وكيلك.",
|
||||
"messages.success.upload": "تم التفويض بنجاح! يمكنك الآن نشر إصدار جديد.",
|
||||
"profileSetup.cancel": "إلغاء",
|
||||
"profileSetup.confirmChangeUserId.cancel": "إلغاء",
|
||||
"profileSetup.confirmChangeUserId.confirm": "تغيير معرف المستخدم",
|
||||
|
||||
@@ -222,6 +222,7 @@
|
||||
"providerModels.item.modelConfig.extendParams.options.effort.hint": "لـ Claude Opus 4.6؛ يتحكم في مستوى الجهد (منخفض/متوسط/عالٍ/أقصى).",
|
||||
"providerModels.item.modelConfig.extendParams.options.enableAdaptiveThinking.hint": "لـ Claude Opus 4.6؛ يفعّل أو يعطّل التفكير التكيفي.",
|
||||
"providerModels.item.modelConfig.extendParams.options.enableReasoning.hint": "لنماذج Claude وDeepSeek وغيرها من نماذج الاستدلال؛ يفعّل التفكير العميق.",
|
||||
"providerModels.item.modelConfig.extendParams.options.glm5_2ReasoningEffort.hint": "بالنسبة لـ GLM-5.2؛ يتحكم في جهد التفكير بمستويات عالية وأقصى.",
|
||||
"providerModels.item.modelConfig.extendParams.options.gpt5ReasoningEffort.hint": "لسلسلة GPT-5؛ يتحكم في شدة الاستدلال.",
|
||||
"providerModels.item.modelConfig.extendParams.options.gpt5_1ReasoningEffort.hint": "لسلسلة GPT-5.1؛ يتحكم في شدة الاستدلال.",
|
||||
"providerModels.item.modelConfig.extendParams.options.gpt5_2ProReasoningEffort.hint": "لسلسلة GPT-5.2 Pro؛ يتحكم في شدة الاستدلال.",
|
||||
@@ -256,6 +257,7 @@
|
||||
"providerModels.item.modelConfig.files.title": "دعم رفع الملفات",
|
||||
"providerModels.item.modelConfig.functionCall.extra": "سيمكن هذا الإعداد النموذج من استخدام الأدوات، ولكن قدرة النموذج الفعلية على استخدامها تعتمد عليه بالكامل؛ يرجى الاختبار بنفسك.",
|
||||
"providerModels.item.modelConfig.functionCall.title": "دعم استخدام الأدوات",
|
||||
"providerModels.item.modelConfig.id.duplicate": "يوجد بالفعل نموذج بهذا المعرف. استخدم معرف نموذج مختلف.",
|
||||
"providerModels.item.modelConfig.id.extra": "لا يمكن تعديله بعد الإنشاء وسيُستخدم كمعرف النموذج عند استدعاء الذكاء الاصطناعي",
|
||||
"providerModels.item.modelConfig.id.placeholder": "يرجى إدخال معرف النموذج، مثل gpt-4o أو claude-3.5-sonnet",
|
||||
"providerModels.item.modelConfig.id.title": "معرف النموذج",
|
||||
@@ -270,11 +272,11 @@
|
||||
"providerModels.item.modelConfig.tokens.title": "أقصى نافذة سياق",
|
||||
"providerModels.item.modelConfig.tokens.unlimited": "غير محدود",
|
||||
"providerModels.item.modelConfig.type.extra": "أنواع النماذج المختلفة لها استخدامات وقدرات مختلفة",
|
||||
"providerModels.item.modelConfig.type.options.asr": "تحويل الكلام إلى نص",
|
||||
"providerModels.item.modelConfig.type.options.chat": "دردشة",
|
||||
"providerModels.item.modelConfig.type.options.embedding": "تضمين",
|
||||
"providerModels.item.modelConfig.type.options.image": "توليد الصور",
|
||||
"providerModels.item.modelConfig.type.options.realtime": "دردشة فورية",
|
||||
"providerModels.item.modelConfig.type.options.stt": "تحويل الكلام إلى نص",
|
||||
"providerModels.item.modelConfig.type.options.text2music": "نص إلى موسيقى",
|
||||
"providerModels.item.modelConfig.type.options.tts": "تحويل النص إلى كلام",
|
||||
"providerModels.item.modelConfig.type.options.video": "توليد الفيديو",
|
||||
@@ -323,10 +325,10 @@
|
||||
"providerModels.list.total": "{{count}} نموذج متاح",
|
||||
"providerModels.searchNotFound": "لم يتم العثور على نتائج",
|
||||
"providerModels.tabs.all": "الكل",
|
||||
"providerModels.tabs.asr": "تحويل الكلام إلى نص",
|
||||
"providerModels.tabs.chat": "دردشة",
|
||||
"providerModels.tabs.embedding": "تضمين",
|
||||
"providerModels.tabs.image": "صورة",
|
||||
"providerModels.tabs.stt": "تحويل الكلام إلى نص",
|
||||
"providerModels.tabs.tts": "تحويل النص إلى كلام",
|
||||
"providerModels.tabs.video": "فيديو",
|
||||
"sortModal.success": "تم تحديث الترتيب بنجاح",
|
||||
|
||||
+9
-69
@@ -440,60 +440,7 @@
|
||||
"llm.proxyUrl.title": "عنوان وكيل API",
|
||||
"llm.waitingForMore": "سيتم <1>إضافة المزيد من النماذج</1> قريبًا، ترقبوا",
|
||||
"llm.waitingForMoreLinkAriaLabel": "فتح نموذج طلب المزود",
|
||||
"marketPublish.forkConfirm.by": "بواسطة {{author}}",
|
||||
"marketPublish.forkConfirm.confirm": "تأكيد النشر",
|
||||
"marketPublish.forkConfirm.confirmGroup": "تأكيد النشر",
|
||||
"marketPublish.forkConfirm.description": "أنت على وشك نشر نسخة مشتقة تستند إلى وكيل موجود من المجتمع. سيتم إنشاء وكيلك الجديد كإدخال منفصل في السوق.",
|
||||
"marketPublish.forkConfirm.descriptionGroup": "أنت على وشك نشر نسخة مشتقة تستند إلى مجموعة موجودة من المجتمع. سيتم إنشاء مجموعتك الجديدة كإدخال منفصل في السوق.",
|
||||
"marketPublish.forkConfirm.title": "نشر وكيل مشتق",
|
||||
"marketPublish.forkConfirm.titleGroup": "نشر مجموعة مشتقة",
|
||||
"marketPublish.modal.changelog.extra": "وصف التغييرات والتحسينات الرئيسية في هذا الإصدار",
|
||||
"marketPublish.modal.changelog.label": "سجل التغييرات",
|
||||
"marketPublish.modal.changelog.maxLengthError": "يجب ألا يتجاوز سجل التغييرات 500 حرف",
|
||||
"marketPublish.modal.changelog.placeholder": "أدخل سجل التغييرات",
|
||||
"marketPublish.modal.changelog.required": "يرجى إدخال سجل التغييرات",
|
||||
"marketPublish.modal.comparison.local": "الإصدار المحلي الحالي",
|
||||
"marketPublish.modal.comparison.remote": "الإصدار المنشور حاليًا",
|
||||
"marketPublish.modal.identifier.extra": "هذا هو المعرف الفريد للوكيل. استخدم أحرفًا صغيرة وأرقامًا وشرطات.",
|
||||
"marketPublish.modal.identifier.label": "معرف الوكيل",
|
||||
"marketPublish.modal.identifier.lengthError": "يجب أن يتراوح طول المعرف بين 3 و50 حرفًا",
|
||||
"marketPublish.modal.identifier.patternError": "يمكن أن يحتوي المعرف فقط على أحرف صغيرة وأرقام وشرطات",
|
||||
"marketPublish.modal.identifier.placeholder": "أدخل معرفًا فريدًا للوكيل، مثل web-development",
|
||||
"marketPublish.modal.identifier.required": "يرجى إدخال معرف الوكيل",
|
||||
"marketPublish.modal.loading.fetchingRemote": "جارٍ تحميل البيانات البعيدة...",
|
||||
"marketPublish.modal.loading.submit": "جارٍ إرسال الوكيل...",
|
||||
"marketPublish.modal.loading.submitGroup": "جارٍ إرسال المجموعة...",
|
||||
"marketPublish.modal.loading.upload": "جارٍ نشر الإصدار الجديد...",
|
||||
"marketPublish.modal.loading.uploadGroup": "جارٍ نشر إصدار جديد من المجموعة...",
|
||||
"marketPublish.modal.messages.createVersionFailed": "فشل في إنشاء الإصدار: {{message}}",
|
||||
"marketPublish.modal.messages.fetchRemoteFailed": "فشل في جلب بيانات الوكيل البعيدة",
|
||||
"marketPublish.modal.messages.missingIdentifier": "لا يحتوي هذا الوكيل على معرف المجتمع بعد.",
|
||||
"marketPublish.modal.messages.noGroup": "لم يتم تحديد أي مجموعة",
|
||||
"marketPublish.modal.messages.notAuthenticated": "يرجى تسجيل الدخول إلى حسابك في المجتمع أولاً.",
|
||||
"marketPublish.modal.messages.publishFailed": "فشل النشر: {{message}}",
|
||||
"marketPublish.modal.submitButton": "نشر",
|
||||
"marketPublish.modal.title.submit": "مشاركة في مجتمع الوكلاء",
|
||||
"marketPublish.modal.title.upload": "نشر إصدار جديد",
|
||||
"marketPublish.resultModal.message": "تم إرسال وكيلك للمراجعة. بمجرد الموافقة عليه، سيتم نشره تلقائيًا.",
|
||||
"marketPublish.resultModal.messageGroup": "تم إرسال مجموعتك للمراجعة. بمجرد الموافقة عليها، سيتم نشرها تلقائيًا.",
|
||||
"marketPublish.resultModal.title": "تم الإرسال بنجاح",
|
||||
"marketPublish.resultModal.view": "عرض في المجتمع",
|
||||
"marketPublish.status.underReview": "قيد المراجعة",
|
||||
"marketPublish.submit.button": "مشاركة في المجتمع",
|
||||
"marketPublish.submit.tooltip": "شارك هذا الوكيل في المجتمع",
|
||||
"marketPublish.submitGroup.tooltip": "شارك هذه المجموعة مع المجتمع",
|
||||
"marketPublish.upload.button": "نشر إصدار جديد",
|
||||
"marketPublish.upload.tooltip": "نشر إصدار جديد في مجتمع الوكلاء",
|
||||
"marketPublish.uploadGroup.tooltip": "نشر إصدار جديد في مجتمع المجموعات",
|
||||
"marketPublish.validation.communitySetupRequired.action": "إعداد الآن",
|
||||
"marketPublish.validation.communitySetupRequired.desc": "لم تقم هذه مساحة العمل بإعداد ملف تعريف المجتمع الخاص بها بعد. قم بإعداده قبل النشر في المجتمع.",
|
||||
"marketPublish.validation.communitySetupRequired.memberHint": "لم تقم هذه مساحة العمل بإعداد ملف تعريف المجتمع الخاص بها بعد. اطلب من مالك مساحة العمل إعدادها قبل النشر في المجتمع.",
|
||||
"marketPublish.validation.communitySetupRequired.title": "قم بإعداد ملف تعريف المجتمع أولاً",
|
||||
"marketPublish.validation.confirmPublish": "النشر في السوق؟",
|
||||
"marketPublish.validation.confirmPublishDesc": "بمجرد النشر، سيكون هذا المحتوى مرئيًا للجمهور في السوق ومتاحًا لأي شخص لاكتشافه واستخدامه.",
|
||||
"marketPublish.validation.emptyName": "لا يمكن النشر: الاسم مطلوب",
|
||||
"marketPublish.validation.emptySystemRole": "لا يمكن النشر: دور النظام مطلوب",
|
||||
"marketPublish.validation.underReview": "إصدارك الجديد قيد المراجعة حاليًا. يرجى انتظار الموافقة قبل نشر إصدار جديد.",
|
||||
"memory.effort.desc": "تحكم في مدى شدة استرجاع وتحديث الذاكرة من قبل الذكاء الاصطناعي.",
|
||||
"memory.effort.high": "عالي — استرجاع وتحديث استباقي",
|
||||
"memory.effort.level.high": "عالي",
|
||||
@@ -515,14 +462,6 @@
|
||||
"myAgents.actions.deprecateLoading": "جارٍ إيقاف الوكيل...",
|
||||
"myAgents.actions.deprecateSuccess": "تم إيقاف الوكيل",
|
||||
"myAgents.actions.edit": "تعديل الوكيل",
|
||||
"myAgents.actions.publish": "نشر الوكيل",
|
||||
"myAgents.actions.publishError": "فشل في نشر الوكيل",
|
||||
"myAgents.actions.publishLoading": "جارٍ نشر الوكيل...",
|
||||
"myAgents.actions.publishSuccess": "تم نشر الوكيل",
|
||||
"myAgents.actions.unpublish": "إلغاء نشر الوكيل",
|
||||
"myAgents.actions.unpublishError": "فشل في إلغاء نشر الوكيل",
|
||||
"myAgents.actions.unpublishLoading": "جارٍ إلغاء نشر الوكيل...",
|
||||
"myAgents.actions.unpublishSuccess": "تم إلغاء نشر الوكيل",
|
||||
"myAgents.actions.viewDetail": "عرض التفاصيل",
|
||||
"myAgents.detail.category": "الفئة",
|
||||
"myAgents.detail.description": "الوصف",
|
||||
@@ -587,7 +526,6 @@
|
||||
"plugin.settings.title": "إعدادات مهارة {{id}}",
|
||||
"plugin.settings.tooltip": "إعدادات المهارة",
|
||||
"plugin.store": "متجر المهارات",
|
||||
"publishToCommunity": "النشر في المجتمع",
|
||||
"serviceModel.contextLimit.placeholder": "حد السياق",
|
||||
"serviceModel.memoryModels.title": "نماذج الذاكرة",
|
||||
"serviceModel.modelAssignments.title": "تعيينات النموذج",
|
||||
@@ -955,13 +893,6 @@
|
||||
"storageOverage.usage.estimatedCharge": "الرسوم المقدرة للدورة",
|
||||
"storageOverage.usage.incurredCharge": "تم تكبدها في هذه الدورة",
|
||||
"storageOverage.usage.overage": "التجاوز",
|
||||
"submitAgentModal.button": "إرسال الوكيل",
|
||||
"submitAgentModal.identifier": "معرّف الوكيل",
|
||||
"submitAgentModal.metaMiss": "يرجى إكمال معلومات الوكيل قبل الإرسال. يجب أن تتضمن الاسم والوصف والوسوم.",
|
||||
"submitAgentModal.placeholder": "أدخل معرّفًا فريدًا للوكيل، مثل: web-development",
|
||||
"submitAgentModal.success": "تم إرسال الوكيل بنجاح",
|
||||
"submitAgentModal.tooltips": "مشاركة في مجتمع الوكلاء",
|
||||
"submitGroupModal.tooltips": "المشاركة في مجتمع المجموعات",
|
||||
"sync.device.deviceName.hint": "أضف اسمًا لسهولة التعرف",
|
||||
"sync.device.deviceName.placeholder": "أدخل اسم الجهاز",
|
||||
"sync.device.deviceName.title": "اسم الجهاز",
|
||||
@@ -1086,6 +1017,7 @@
|
||||
"tools.activation.auto": "تلقائي",
|
||||
"tools.activation.auto.desc": "ذكي",
|
||||
"tools.activation.fixed.hint": "دائمًا قيد التشغيل — يتم إدارته بواسطة التطبيق ولا يمكن إيقاف تشغيله",
|
||||
"tools.activation.pin": "الرمز السري",
|
||||
"tools.activation.pinned": "مثبت",
|
||||
"tools.activation.pinned.desc": "دائمًا قيد التشغيل",
|
||||
"tools.add": "إضافة مهارة",
|
||||
@@ -2047,6 +1979,14 @@
|
||||
"workspace.wizard.step3.title": "مرحبًا بك في {{name}}!",
|
||||
"workspace.wizard.title": "إنشاء مساحة العمل",
|
||||
"workspaceSetting.breadcrumb.settings": "الإعدادات",
|
||||
"workspaceSetting.devices.desc": "الأجهزة المشتركة المسجلة في مساحة العمل هذه. يمكن للأعضاء تشغيل الوكلاء عليها.",
|
||||
"workspaceSetting.devices.empty": "لا توجد أجهزة مرتبطة بمساحة العمل حتى الآن.",
|
||||
"workspaceSetting.devices.enrollDesc": "قم بتشغيل هذا على الجهاز الذي تريد مشاركته (لصاحب مساحة العمل فقط):",
|
||||
"workspaceSetting.devices.enrollTitle": "إضافة جهاز",
|
||||
"workspaceSetting.devices.heroDesc": "قم بتسجيل جهاز مشترك — مثل خادم بناء أو جهاز Mac لفريق العمل — وسيتمكن كل عضو من تشغيل الوكلاء عليه: قراءة/كتابة الملفات، تشغيل الأوامر، واستخدام أدوات النظام.",
|
||||
"workspaceSetting.devices.heroTitle": "قم بتوصيل أول جهاز لمساحة العمل",
|
||||
"workspaceSetting.devices.offline": "غير متصل",
|
||||
"workspaceSetting.devices.online": "متصل",
|
||||
"workspaceSetting.group.admin": "الإدارة",
|
||||
"workspaceSetting.group.agent": "الوكيل",
|
||||
"workspaceSetting.group.general": "عام",
|
||||
|
||||
@@ -147,10 +147,6 @@
|
||||
"limitation.chat.topupSuccess.title": "تم الشحن بنجاح",
|
||||
"limitation.expired.desc": "انتهت صلاحية أرصدة الحوسبة الخاصة بك في خطة {{plan}} بتاريخ {{expiredAt}}. قم بالترقية الآن للحصول على أرصدة جديدة.",
|
||||
"limitation.expired.title": "انتهت أرصدة الحوسبة",
|
||||
"limitation.fableCampaign.desc": "Claude Fable 5 هو نموذج عالي التكلفة. تم استخدام أرصدة التجربة الخاصة بالحملة. قم بترقية خطتك للاستمرار في استخدام Fable.",
|
||||
"limitation.fableCampaign.title": "تم استخدام أرصدة تجربة Fable",
|
||||
"limitation.fableCampaign.upgrade": "ترقية الخطة",
|
||||
"limitation.fableCampaign.upgradeToPlan": "الترقية إلى {{plan}}",
|
||||
"limitation.hobby.action": "تم التكوين، تابع المحادثة",
|
||||
"limitation.hobby.configAPI": "تكوين API",
|
||||
"limitation.hobby.desc": "تم استهلاك أرصدة الحوسبة المجانية الخاصة بك. يرجى تكوين واجهة برمجة التطبيقات المخصصة للنموذج للمتابعة.",
|
||||
@@ -342,7 +338,14 @@
|
||||
"plans.workspace.noSharedCredits": "لا توجد أرصدة مشتركة",
|
||||
"plans.workspace.sharedCredits": "~{{count}} أرصدة / شهريًا",
|
||||
"plans.workspace.solo": "فردي (عضو واحد)",
|
||||
"promoBanner.fableYearly": "المشتركين السنويين يحصلون على خصم {{percent}}% على الاستخدام لفترة محدودة",
|
||||
"plansModal.creditLimit.desc": "قم بترقية خطتك لفتح المزيد من الاعتمادات الشهرية ومواصلة العمل دون انقطاع.",
|
||||
"plansModal.creditLimit.title": "لقد نفدت الاعتمادات",
|
||||
"plansModal.default.desc": "افتح المزيد من السعة والميزات المتقدمة.",
|
||||
"plansModal.default.title": "قم بترقية خطتك",
|
||||
"plansModal.fileStorageLimit.desc": "مساحة تخزين الملفات ممتلئة. قم بالترقية لمواصلة تحميل وإدارة الملفات.",
|
||||
"plansModal.fileStorageLimit.title": "تم الوصول إلى حد التخزين",
|
||||
"plansModal.modelAccess.desc": "هذا النموذج متاح في الخطط المدفوعة. قم بالترقية لاستخدام مجموعة النماذج الكاملة.",
|
||||
"plansModal.modelAccess.title": "افتح جميع النماذج",
|
||||
"qa.desc": "إذا لم تجد إجابتك، تحقق من <1>وثائق المنتج</1> لمزيد من الأسئلة الشائعة، أو تواصل معنا.",
|
||||
"qa.detail": "عرض التفاصيل",
|
||||
"qa.list.credit.a": "أرصدة الحوسبة هي وحدة قياس يستخدمها {{cloud}} لقياس استخدام نماذج الذكاء الاصطناعي. تستهلك النماذج المختلفة كميات مختلفة من الأرصدة.",
|
||||
@@ -398,6 +401,8 @@
|
||||
"referral.errors.invalidFormat": "تنسيق رمز الإحالة غير صالح، يرجى إدخال 2-8 أحرف أو أرقام أو شرطات سفلية",
|
||||
"referral.errors.selfReferral": "لا يمكنك استخدام رمز الدعوة الخاص بك",
|
||||
"referral.errors.updateFailed": "فشل التحديث، يرجى المحاولة لاحقًا",
|
||||
"referral.hero.description": "شارك رابط الإحالة الخاص بك أدناه. بعد أن يقوم صديقك بأول عملية دفع، ستحصلان كلاكما على {{reward}} مليون اعتماد.",
|
||||
"referral.hero.title": "ادعُ أصدقاءك، واحصل كلاكما على <0>{{reward}} مليون اعتماد</0>",
|
||||
"referral.inviteCode.description": "شارك رمز الإحالة الحصري الخاص بك لدعوة الأصدقاء للتسجيل",
|
||||
"referral.inviteCode.title": "رمز الإحالة الخاص بي",
|
||||
"referral.inviteLink.description": "انسخ الرابط وشاركه مع الأصدقاء. يحصل كلاكما على أرصدة بعد أن يقوم صديقك بالدفع",
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"actions.unmarkCompleted": "وضع علامة كنشط",
|
||||
"defaultTitle": "موضوع افتراضي",
|
||||
"displayItems": "عرض العناصر",
|
||||
"draft": "[مسودة]",
|
||||
"duplicateLoading": "جارٍ نسخ الموضوع...",
|
||||
"duplicateSuccess": "تم نسخ الموضوع بنجاح",
|
||||
"failedStatusTip": "تعرضت هذه العملية لخطأ — افتحها لإلقاء نظرة.",
|
||||
|
||||
+28
-2
@@ -41,6 +41,8 @@
|
||||
"artifact.thinking": "Мислене",
|
||||
"artifact.thought": "Мисловен процес",
|
||||
"artifact.unknownTitle": "Без заглавие",
|
||||
"audioPlayer.pause": "Пауза на аудио",
|
||||
"audioPlayer.play": "Пусни аудио",
|
||||
"availableAgents": "Налични Агенти",
|
||||
"backToBottom": "Към най-новото",
|
||||
"beforeUnload.confirmLeave": "Заявка все още се изпълнява. Искате ли да напуснете въпреки това?",
|
||||
@@ -120,6 +122,18 @@
|
||||
"createModal.groupPlaceholder": "Опишете какво трябва да прави тази група...",
|
||||
"createModal.groupTitle": "Какво трябва да прави вашата група?",
|
||||
"createModal.placeholder": "Опишете какво трябва да прави вашият агент...",
|
||||
"createModal.skillSuggestion.actions.createAnyway": "Създай агент въпреки това",
|
||||
"createModal.skillSuggestion.actions.createAnywayHint": "Умението не е подходящо?",
|
||||
"createModal.skillSuggestion.actions.install": "Инсталирай умение",
|
||||
"createModal.skillSuggestion.actions.installing": "Инсталиране…",
|
||||
"createModal.skillSuggestion.actions.openSkills": "Преглед в Умения",
|
||||
"createModal.skillSuggestion.actions.tryInLobeAI": "Използвай в {{name}}",
|
||||
"createModal.skillSuggestion.description": "Това изглежда като повторяем работен процес. Инсталирайте умението веднъж, след това го използвайте в различни агенти.",
|
||||
"createModal.skillSuggestion.installError": "Умението не беше инсталирано. Опитайте отново или създайте агент въпреки това.",
|
||||
"createModal.skillSuggestion.installed.description": "Можете да използвате това умение в {{name}}, или да го активирате за всеки агент.",
|
||||
"createModal.skillSuggestion.installed.ready": "Готово в {{name}}",
|
||||
"createModal.skillSuggestion.installed.title": "Умението е инсталирано",
|
||||
"createModal.skillSuggestion.title": "Умението може да е по-подходящо",
|
||||
"createModal.title": "Какво трябва да прави вашият агент?",
|
||||
"createTask.assignee": "Изпълнител",
|
||||
"createTask.collapse": "Скрий полето",
|
||||
@@ -166,6 +180,8 @@
|
||||
"extendParams.title": "Разширени функции на модела",
|
||||
"extendParams.urlContext.desc": "Когато е активирано, уеб връзките ще се анализират автоматично, за да се извлече съдържанието на страницата",
|
||||
"extendParams.urlContext.title": "Извличане на съдържание от уеб връзки",
|
||||
"floatingChatPanel.collapse": "Свий чата",
|
||||
"floatingChatPanel.expand": "Разшири чата",
|
||||
"followUpPlaceholder": "Последващо действие. Използвайте @, за да възлагате задачи на други агенти.",
|
||||
"followUpPlaceholderHeterogeneous": "Последващ въпрос.",
|
||||
"gatewayMode.beta": "Бета",
|
||||
@@ -219,9 +235,13 @@
|
||||
"heteroAgent.cloudRepo.noRepos": "Няма конфигурирани хранилища. Добавете ги в настройките на агента.",
|
||||
"heteroAgent.cloudRepo.notSet": "Няма избрано хранилище",
|
||||
"heteroAgent.cloudRepo.sectionTitle": "Хранилища",
|
||||
"heteroAgent.executionTarget.auto": "Автоматично",
|
||||
"heteroAgent.executionTarget.autoDesc": "Използвайте онлайн устройство автоматично, избирайки едно, когато има няколко налични",
|
||||
"heteroAgent.executionTarget.downloadDesktop": "Изтеглете десктоп приложението",
|
||||
"heteroAgent.executionTarget.downloadDesktopDesc": "Стартирайте агенти с достъп до вашия компютър",
|
||||
"heteroAgent.executionTarget.downloadDesktopTitle": "Изтеглете десктоп приложението",
|
||||
"heteroAgent.executionTarget.gateway": "Шлюз",
|
||||
"heteroAgent.executionTarget.gatewayDesc": "Изпълнявайте през шлюза на устройството, за да могат други клиенти да следят напредъка",
|
||||
"heteroAgent.executionTarget.infoTooltip": "Изберете отдалечено устройство, за да управлявате тази машина от уеба. \"Това устройство\" изпълнява агента локално и е достъпно само в десктоп приложението.",
|
||||
"heteroAgent.executionTarget.loading": "Зареждане на устройства...",
|
||||
"heteroAgent.executionTarget.local": "Това устройство",
|
||||
@@ -231,10 +251,12 @@
|
||||
"heteroAgent.executionTarget.noneDesc": "Няма активирано устройство",
|
||||
"heteroAgent.executionTarget.offline": "Офлайн",
|
||||
"heteroAgent.executionTarget.online": "Онлайн",
|
||||
"heteroAgent.executionTarget.personalGroup": "Лично",
|
||||
"heteroAgent.executionTarget.sandbox": "Облачен пясъчник",
|
||||
"heteroAgent.executionTarget.sandboxDesc": "Изпълнява се в временен облачен пясъчник",
|
||||
"heteroAgent.executionTarget.title": "Устройство за изпълнение",
|
||||
"heteroAgent.executionTarget.unknownDevice": "Неизвестно устройство",
|
||||
"heteroAgent.executionTarget.workspaceGroup": "Работно пространство",
|
||||
"heteroAgent.fullAccess.label": "Пълен достъп",
|
||||
"heteroAgent.fullAccess.tooltip": "Claude Code работи локално с пълен достъп за четене/запис в работната директория. Превключването на режимите на достъп все още не е налично.",
|
||||
"heteroAgent.resumeReset.cwdChanged": "Работната директория е променена. Предишната сесия на Claude Code може да бъде продължена само от оригиналната ѝ директория, затова е започнат нов разговор.",
|
||||
@@ -631,6 +653,8 @@
|
||||
"taskDetail.artifacts": "Артефакти",
|
||||
"taskDetail.blockedBy": "Блокирано от {{id}}",
|
||||
"taskDetail.cancelSchedule": "Отмени графика",
|
||||
"taskDetail.closeDetail": "Затвори детайла",
|
||||
"taskDetail.collapseReply": "Свий",
|
||||
"taskDetail.comment.cancel": "Отказ",
|
||||
"taskDetail.comment.delete": "Изтриване",
|
||||
"taskDetail.comment.deleteConfirm.content": "Този коментар ще бъде изтрит завинаги.",
|
||||
@@ -657,6 +681,7 @@
|
||||
"taskDetail.notFound.backToTasks": "Обратно към всички задачи",
|
||||
"taskDetail.notFound.desc": "Тази задача може да е изтрита или нямате разрешение да я видите.",
|
||||
"taskDetail.notFound.title": "Задачата не е намерена",
|
||||
"taskDetail.openDetail": "Отвори детайла",
|
||||
"taskDetail.pauseTask": "Пауза на задачата",
|
||||
"taskDetail.priority.high": "Висок",
|
||||
"taskDetail.priority.low": "Нисък",
|
||||
@@ -925,9 +950,9 @@
|
||||
"workflow.collapse": "Свий",
|
||||
"workflow.expandFull": "Разгъни напълно",
|
||||
"workflow.failedSuffix": "(неуспешно)",
|
||||
"workflow.summaryAcrossTools": "през {{count}} инструмента",
|
||||
"workflow.summaryCallsLead": "{{count}} обаждания: {{tools}}",
|
||||
"workflow.summaryFailed": "{{count}} неуспешни",
|
||||
"workflow.summaryMoreTools": "{{count}} вида инструменти",
|
||||
"workflow.summaryTotalCalls": "{{count}} извиквания общо",
|
||||
"workflow.thoughtForDuration": "Мисли в продължение на {{duration}}",
|
||||
"workflow.toolDisplayName.activateDevice": "Активирано устройство",
|
||||
"workflow.toolDisplayName.activateSkill": "Активира умение",
|
||||
@@ -1043,6 +1068,7 @@
|
||||
"workingPanel.resources.deleteTitle": "Delete document?",
|
||||
"workingPanel.resources.deleteTitleMulti": "Изтриване на {{count}} елемента?",
|
||||
"workingPanel.resources.empty": "Все още няма документи. Документите, свързани с този агент, ще се показват тук.",
|
||||
"workingPanel.resources.emptyDocuments": "Все още няма документи. Създайте един с + горе.",
|
||||
"workingPanel.resources.error": "Failed to load resources",
|
||||
"workingPanel.resources.filter.documents": "Документи",
|
||||
"workingPanel.resources.filter.skills": "Умения",
|
||||
|
||||
@@ -444,6 +444,23 @@
|
||||
"tab.setting": "Настройки",
|
||||
"tab.tasks": "Задачи",
|
||||
"tab.video": "Видео",
|
||||
"taskTemplate.action.connect.button": "Свържете {{provider}}",
|
||||
"taskTemplate.action.connect.error": "Свързването не бе успешно, моля опитайте отново.",
|
||||
"taskTemplate.action.connect.popupBlocked": "Изскачащият прозорец за свързване е блокиран. Разрешете изскачащи прозорци в браузъра си, за да продължите.",
|
||||
"taskTemplate.action.connect.short": "Свържете",
|
||||
"taskTemplate.action.connecting": "Изчакване за оторизация…",
|
||||
"taskTemplate.action.create.error": "Неуспешно създаване на задача. Моля, опитайте отново.",
|
||||
"taskTemplate.action.create.success": "Добавена е планирана задача. Намерете я в Lobe AI.",
|
||||
"taskTemplate.action.createButton": "Добавете задача",
|
||||
"taskTemplate.action.creating": "Създаване...",
|
||||
"taskTemplate.action.dismiss.error": "Неуспешно отхвърляне. Моля, опитайте отново.",
|
||||
"taskTemplate.action.dismiss.tooltip": "Не се интересувам",
|
||||
"taskTemplate.action.refresh.button": "Обновете",
|
||||
"taskTemplate.card.templateTag": "Шаблон",
|
||||
"taskTemplate.schedule.daily": "Всеки ден в {{time}}",
|
||||
"taskTemplate.schedule.editableAfterCreateTooltip": "Можете да коригирате графика след създаването на задачата.",
|
||||
"taskTemplate.schedule.weekly": "Всеки {{weekday}} в {{time}}",
|
||||
"taskTemplate.section.title": "Опитайте тези планирани задачи",
|
||||
"telemetry.allow": "Разреши",
|
||||
"telemetry.deny": "Откажи",
|
||||
"telemetry.desc": "Бихме искали анонимно да събираме информация за използването, за да подобрим {{appName}} и да ви предоставим по-добро потребителско изживяване. Можете да го изключите по всяко време в Настройки - Относно.",
|
||||
@@ -474,15 +491,14 @@
|
||||
"userPanel.email": "Имейл поддръжка",
|
||||
"userPanel.feedback": "Свържете се с нас",
|
||||
"userPanel.help": "Център за помощ",
|
||||
"userPanel.inviteFriend": "Поканете приятел",
|
||||
"userPanel.moveGuide": "Бутонът за настройки е преместен тук",
|
||||
"userPanel.plans": "Абонаментни планове",
|
||||
"userPanel.profile": "Акаунт",
|
||||
"userPanel.setting": "Настройки",
|
||||
"userPanel.upgradePlan": "Надграждане на плана",
|
||||
"userPanel.usages": "Статистика на използване",
|
||||
"userPanel.workspaceCredits": "Кредити за работно пространство",
|
||||
"userPanel.workspaceSetting": "Настройки на работното пространство",
|
||||
"userPanel.workspaceUsages": "Използване на работното пространство",
|
||||
"version": "Версия",
|
||||
"zoom": "Увеличение"
|
||||
}
|
||||
|
||||
@@ -101,6 +101,7 @@
|
||||
"LocalFile.action.open": "Отвори",
|
||||
"LocalFile.action.showInFolder": "Покажи в папката",
|
||||
"MaxTokenSlider.unlimited": "Неограничено",
|
||||
"ModelSelect.featureTag.audio": "Този модел поддържа разпознаване на аудио вход.",
|
||||
"ModelSelect.featureTag.custom": "Персонализиран модел, по подразбиране поддържа извикване на функции и визуално разпознаване. Моля, проверете наличността на тези възможности според конкретната ситуация.",
|
||||
"ModelSelect.featureTag.file": "Този модел поддържа качване на файлове за четене и разпознаване.",
|
||||
"ModelSelect.featureTag.functionCall": "Този модел поддържа извикване на функции.",
|
||||
@@ -114,6 +115,7 @@
|
||||
"ModelSwitchPanel.byModel": "По модел",
|
||||
"ModelSwitchPanel.byProvider": "По доставчик",
|
||||
"ModelSwitchPanel.detail.abilities": "Възможности",
|
||||
"ModelSwitchPanel.detail.abilities.audio": "Аудио",
|
||||
"ModelSwitchPanel.detail.abilities.files": "Файлове",
|
||||
"ModelSwitchPanel.detail.abilities.functionCall": "Извикване на инструмент",
|
||||
"ModelSwitchPanel.detail.abilities.imageOutput": "Изход на изображение",
|
||||
|
||||
@@ -38,9 +38,6 @@
|
||||
"brief.title": "Дневен обзор",
|
||||
"brief.viewAllTasks": "Преглед на всички задачи",
|
||||
"brief.viewRun": "Преглед на изпълнението",
|
||||
"freeCreditBadge.cta": "Започнете безплатен пробен период",
|
||||
"freeCreditBadge.dismiss": "Отхвърли",
|
||||
"freeCreditBadge.label": "Ексклузивни безплатни кредити за {{model}}",
|
||||
"project.create": "Нов проект",
|
||||
"project.deleteConfirm": "Този проект ще бъде изтрит и не може да бъде възстановен. Потвърдете, за да продължите.",
|
||||
"recommendations.heteroAgent.cta": "Добавяне на агент",
|
||||
|
||||
@@ -5,10 +5,12 @@
|
||||
"authorize.footer.agreement": "Продължавайки, потвърждаваш, че си прочел и се съгласяваш с <terms>Общите условия</terms> и <privacy>Политиката за поверителност</privacy>.",
|
||||
"authorize.footer.privacy": "Политика за поверителност",
|
||||
"authorize.footer.terms": "Общи условия",
|
||||
"authorize.scenes.connector.confirm": "Продължете към Market",
|
||||
"authorize.scenes.connector.description": "Market се използва само за стартиране на тази услуга за упълномощаване. Вашият акаунт в {{appName}} остава отделен.",
|
||||
"authorize.scenes.connector.subtitle": "Влезте в Market, за да свържете и упълномощите тази обществена услуга.",
|
||||
"authorize.scenes.connector.title": "Свържете обществена услуга",
|
||||
"authorize.scenes.mcp.subtitle": "Създайте профил в общността, за да инсталирате и използвате това умение от общността.",
|
||||
"authorize.scenes.mcp.title": "Инсталиране на умение от общността",
|
||||
"authorize.scenes.publish.subtitle": "Създайте профил в общността, за да публикувате и управлявате вашия списък в общността.",
|
||||
"authorize.scenes.publish.title": "Публикуване в общността",
|
||||
"authorize.scenes.sandbox.subtitle": "Създайте профил в общността, за да използвате този инструмент в пясъчника на общността.",
|
||||
"authorize.scenes.sandbox.title": "Опитайте пясъчника на общността",
|
||||
"authorize.subtitle": "Създай профил в общността, за да публикуваш и управляваш обяви в нея.",
|
||||
@@ -50,8 +52,6 @@
|
||||
"messages.handoffTimeout": "Времето за удостоверяване изтече. Завършете го в браузъра си и опитайте отново.",
|
||||
"messages.loading": "Стартиране на процеса на удостоверяване...",
|
||||
"messages.success.cloudMcpInstall": "Удостоверяването е успешно! Вече можете да инсталирате умението Cloud MCP.",
|
||||
"messages.success.submit": "Удостоверяването е успешно! Вече можете да публикувате своя агент.",
|
||||
"messages.success.upload": "Удостоверяването е успешно! Вече можете да публикувате нова версия.",
|
||||
"profileSetup.cancel": "Отказ",
|
||||
"profileSetup.confirmChangeUserId.cancel": "Отказ",
|
||||
"profileSetup.confirmChangeUserId.confirm": "Промени потребителското име",
|
||||
|
||||
@@ -222,6 +222,7 @@
|
||||
"providerModels.item.modelConfig.extendParams.options.effort.hint": "За Claude Opus 4.6; контролира нивото на усилие (ниско/средно/високо/максимално).",
|
||||
"providerModels.item.modelConfig.extendParams.options.enableAdaptiveThinking.hint": "За Claude Opus 4.6; включва или изключва адаптивното мислене.",
|
||||
"providerModels.item.modelConfig.extendParams.options.enableReasoning.hint": "За Claude, DeepSeek и други модели с логическо мислене; отключва по-задълбочено разсъждение.",
|
||||
"providerModels.item.modelConfig.extendParams.options.glm5_2ReasoningEffort.hint": "За GLM-5.2; контролира усилието за разсъждение с високи и максимални нива.",
|
||||
"providerModels.item.modelConfig.extendParams.options.gpt5ReasoningEffort.hint": "За серията GPT-5; контролира интензивността на разсъждението.",
|
||||
"providerModels.item.modelConfig.extendParams.options.gpt5_1ReasoningEffort.hint": "За серията GPT-5.1; контролира интензивността на разсъждението.",
|
||||
"providerModels.item.modelConfig.extendParams.options.gpt5_2ProReasoningEffort.hint": "За серията GPT-5.2 Pro; контролира интензивността на разсъждението.",
|
||||
@@ -256,6 +257,7 @@
|
||||
"providerModels.item.modelConfig.files.title": "Поддръжка на качване на файлове",
|
||||
"providerModels.item.modelConfig.functionCall.extra": "Тази настройка активира възможността на модела да използва инструменти. Дали ще ги използва ефективно зависи от самия модел. Моля, тествайте.",
|
||||
"providerModels.item.modelConfig.functionCall.title": "Поддръжка на използване на инструменти",
|
||||
"providerModels.item.modelConfig.id.duplicate": "Модел с този ID вече съществува. Използвайте различен ID за модела.",
|
||||
"providerModels.item.modelConfig.id.extra": "Не може да се променя след създаване и ще се използва като ID на модела при извикване",
|
||||
"providerModels.item.modelConfig.id.placeholder": "Въведете ID на модела, напр. gpt-4o или claude-3.5-sonnet",
|
||||
"providerModels.item.modelConfig.id.title": "ID на модела",
|
||||
@@ -270,11 +272,11 @@
|
||||
"providerModels.item.modelConfig.tokens.title": "Максимален контекстов прозорец",
|
||||
"providerModels.item.modelConfig.tokens.unlimited": "Неограничен",
|
||||
"providerModels.item.modelConfig.type.extra": "Различните типове модели имат различни приложения и възможности",
|
||||
"providerModels.item.modelConfig.type.options.asr": "Реч към текст",
|
||||
"providerModels.item.modelConfig.type.options.chat": "Чат",
|
||||
"providerModels.item.modelConfig.type.options.embedding": "Вграждане",
|
||||
"providerModels.item.modelConfig.type.options.image": "Генериране на изображения",
|
||||
"providerModels.item.modelConfig.type.options.realtime": "Чат в реално време",
|
||||
"providerModels.item.modelConfig.type.options.stt": "Реч към текст",
|
||||
"providerModels.item.modelConfig.type.options.text2music": "Текст към музика",
|
||||
"providerModels.item.modelConfig.type.options.tts": "Текст към реч",
|
||||
"providerModels.item.modelConfig.type.options.video": "Генериране на видео",
|
||||
@@ -323,10 +325,10 @@
|
||||
"providerModels.list.total": "Налични {{count}} модела",
|
||||
"providerModels.searchNotFound": "Няма намерени резултати от търсенето",
|
||||
"providerModels.tabs.all": "Всички",
|
||||
"providerModels.tabs.asr": "ASR",
|
||||
"providerModels.tabs.chat": "Чат",
|
||||
"providerModels.tabs.embedding": "Вграждане",
|
||||
"providerModels.tabs.image": "Изображение",
|
||||
"providerModels.tabs.stt": "ASR",
|
||||
"providerModels.tabs.tts": "TTS",
|
||||
"providerModels.tabs.video": "Видео",
|
||||
"sortModal.success": "Сортирането е успешно обновено",
|
||||
|
||||
@@ -440,60 +440,7 @@
|
||||
"llm.proxyUrl.title": "API прокси URL",
|
||||
"llm.waitingForMore": "Очакват се <1>още модели</1>, следете за новини",
|
||||
"llm.waitingForMoreLinkAriaLabel": "Отвори формуляр за заявка към доставчик",
|
||||
"marketPublish.forkConfirm.by": "от {{author}}",
|
||||
"marketPublish.forkConfirm.confirm": "Потвърди публикуване",
|
||||
"marketPublish.forkConfirm.confirmGroup": "Потвърди публикуване",
|
||||
"marketPublish.forkConfirm.description": "Вие сте на път да публикувате производна версия, базирана на съществуващ агент от общността. Вашият нов агент ще бъде създаден като отделен запис в пазара.",
|
||||
"marketPublish.forkConfirm.descriptionGroup": "Вие сте на път да публикувате производна версия, базирана на съществуваща група от общността. Вашата нова група ще бъде създадена като отделен запис в пазара.",
|
||||
"marketPublish.forkConfirm.title": "Публикуване на производен агент",
|
||||
"marketPublish.forkConfirm.titleGroup": "Публикуване на производна група",
|
||||
"marketPublish.modal.changelog.extra": "Опишете основните промени и подобрения в тази версия",
|
||||
"marketPublish.modal.changelog.label": "Дневник на промените",
|
||||
"marketPublish.modal.changelog.maxLengthError": "Дневникът на промените не трябва да надвишава 500 знака",
|
||||
"marketPublish.modal.changelog.placeholder": "Въведете дневника на промените",
|
||||
"marketPublish.modal.changelog.required": "Моля, въведете дневника на промените",
|
||||
"marketPublish.modal.comparison.local": "Текуща локална версия",
|
||||
"marketPublish.modal.comparison.remote": "Публикувана версия",
|
||||
"marketPublish.modal.identifier.extra": "Това е уникалният идентификатор на агента. Използвайте малки букви, цифри и тирета.",
|
||||
"marketPublish.modal.identifier.label": "Идентификатор на агента",
|
||||
"marketPublish.modal.identifier.lengthError": "Идентификаторът трябва да е между 3 и 50 знака",
|
||||
"marketPublish.modal.identifier.patternError": "Идентификаторът може да съдържа само малки букви, цифри и тирета",
|
||||
"marketPublish.modal.identifier.placeholder": "Въведете уникален идентификатор за агента, напр. web-development",
|
||||
"marketPublish.modal.identifier.required": "Моля, въведете идентификатор на агента",
|
||||
"marketPublish.modal.loading.fetchingRemote": "Зареждане на отдалечени данни...",
|
||||
"marketPublish.modal.loading.submit": "Изпращане на агента...",
|
||||
"marketPublish.modal.loading.submitGroup": "Изпращане на групата...",
|
||||
"marketPublish.modal.loading.upload": "Публикуване на нова версия...",
|
||||
"marketPublish.modal.loading.uploadGroup": "Публикуване на нова версия на групата...",
|
||||
"marketPublish.modal.messages.createVersionFailed": "Неуспешно създаване на версия: {{message}}",
|
||||
"marketPublish.modal.messages.fetchRemoteFailed": "Неуспешно извличане на данни за отдалечения агент",
|
||||
"marketPublish.modal.messages.missingIdentifier": "Този агент все още няма идентификатор в Общността.",
|
||||
"marketPublish.modal.messages.noGroup": "Няма избрана група",
|
||||
"marketPublish.modal.messages.notAuthenticated": "Първо влезте в профила си в Общността.",
|
||||
"marketPublish.modal.messages.publishFailed": "Публикуването не бе успешно: {{message}}",
|
||||
"marketPublish.modal.submitButton": "Публикувай",
|
||||
"marketPublish.modal.title.submit": "Сподели в Общността на агентите",
|
||||
"marketPublish.modal.title.upload": "Публикувай нова версия",
|
||||
"marketPublish.resultModal.message": "Вашият агент е изпратен за преглед. След одобрение ще бъде автоматично публикуван.",
|
||||
"marketPublish.resultModal.messageGroup": "Вашата група е изпратена за преглед. След одобрение ще бъде автоматично публикувана.",
|
||||
"marketPublish.resultModal.title": "Успешно изпращане",
|
||||
"marketPublish.resultModal.view": "Виж в Общността",
|
||||
"marketPublish.status.underReview": "В процес на преглед",
|
||||
"marketPublish.submit.button": "Сподели в Общността",
|
||||
"marketPublish.submit.tooltip": "Споделете този агент в Общността",
|
||||
"marketPublish.submitGroup.tooltip": "Споделете тази група с общността",
|
||||
"marketPublish.upload.button": "Публикувай нова версия",
|
||||
"marketPublish.upload.tooltip": "Публикувайте нова версия в Общността на агентите",
|
||||
"marketPublish.uploadGroup.tooltip": "Публикувайте нова версия в общността на групите",
|
||||
"marketPublish.validation.communitySetupRequired.action": "Настройте сега",
|
||||
"marketPublish.validation.communitySetupRequired.desc": "Това работно пространство все още не е настроило своя профил в Общността. Настройте го преди публикуване в Общността.",
|
||||
"marketPublish.validation.communitySetupRequired.memberHint": "Това работно пространство все още не е настроило своя профил в Общността. Помолете собственик на работното пространство да го настрои преди публикуване в Общността.",
|
||||
"marketPublish.validation.communitySetupRequired.title": "Първо настройте профила в Общността",
|
||||
"marketPublish.validation.confirmPublish": "Публикуване в Маркета?",
|
||||
"marketPublish.validation.confirmPublishDesc": "След публикуване, това съдържание ще бъде публично видимо в маркета и достъпно за всеки да го открие и използва.",
|
||||
"marketPublish.validation.emptyName": "Не може да се публикува: Името е задължително",
|
||||
"marketPublish.validation.emptySystemRole": "Не може да се публикува: Системната роля е задължителна",
|
||||
"marketPublish.validation.underReview": "Вашата нова версия в момента се преглежда. Моля, изчакайте одобрение преди да публикувате нова версия.",
|
||||
"memory.effort.desc": "Контролирайте колко агресивно AI извлича и актуализира паметта.",
|
||||
"memory.effort.high": "Високо — Проактивно извличане и актуализации",
|
||||
"memory.effort.level.high": "Високо",
|
||||
@@ -515,14 +462,6 @@
|
||||
"myAgents.actions.deprecateLoading": "Оттегляне на агента...",
|
||||
"myAgents.actions.deprecateSuccess": "Агентът е оттеглен",
|
||||
"myAgents.actions.edit": "Редактирай агент",
|
||||
"myAgents.actions.publish": "Публикувай агент",
|
||||
"myAgents.actions.publishError": "Неуспешно публикуване на агента",
|
||||
"myAgents.actions.publishLoading": "Публикуване на агента...",
|
||||
"myAgents.actions.publishSuccess": "Агентът е публикуван",
|
||||
"myAgents.actions.unpublish": "Скрий агента",
|
||||
"myAgents.actions.unpublishError": "Неуспешно скриване на агента",
|
||||
"myAgents.actions.unpublishLoading": "Скриване на агента...",
|
||||
"myAgents.actions.unpublishSuccess": "Агентът е скрит",
|
||||
"myAgents.actions.viewDetail": "Виж подробности",
|
||||
"myAgents.detail.category": "Категория",
|
||||
"myAgents.detail.description": "Описание",
|
||||
@@ -587,7 +526,6 @@
|
||||
"plugin.settings.title": "Конфигурация на умение {{id}}",
|
||||
"plugin.settings.tooltip": "Конфигурация на умение",
|
||||
"plugin.store": "Магазин за умения",
|
||||
"publishToCommunity": "Публикувай в общността",
|
||||
"serviceModel.contextLimit.placeholder": "Ограничение на контекста",
|
||||
"serviceModel.memoryModels.title": "Модели на паметта",
|
||||
"serviceModel.modelAssignments.title": "Назначения на модели",
|
||||
@@ -955,13 +893,6 @@
|
||||
"storageOverage.usage.estimatedCharge": "Очаквана такса за цикъл",
|
||||
"storageOverage.usage.incurredCharge": "Начислено за този цикъл",
|
||||
"storageOverage.usage.overage": "Превишение",
|
||||
"submitAgentModal.button": "Изпрати агент",
|
||||
"submitAgentModal.identifier": "Идентификатор на агент",
|
||||
"submitAgentModal.metaMiss": "Моля, попълнете информацията за агента преди изпращане. Тя трябва да включва име, описание и тагове",
|
||||
"submitAgentModal.placeholder": "Въведете уникален идентификатор за агента, напр. web-development",
|
||||
"submitAgentModal.success": "Агентът е изпратен успешно",
|
||||
"submitAgentModal.tooltips": "Сподели в общността на агентите",
|
||||
"submitGroupModal.tooltips": "Сподели в общността на групите",
|
||||
"sync.device.deviceName.hint": "Добавете име за по-лесно разпознаване",
|
||||
"sync.device.deviceName.placeholder": "Въведете име на устройство",
|
||||
"sync.device.deviceName.title": "Име на устройство",
|
||||
@@ -1086,6 +1017,7 @@
|
||||
"tools.activation.auto": "Автоматично",
|
||||
"tools.activation.auto.desc": "Интелигентно",
|
||||
"tools.activation.fixed.hint": "Винаги включено — управлява се от приложението и не може да бъде изключено",
|
||||
"tools.activation.pin": "Пин",
|
||||
"tools.activation.pinned": "Закрепено",
|
||||
"tools.activation.pinned.desc": "Винаги включено",
|
||||
"tools.add": "Добави умение",
|
||||
@@ -2047,6 +1979,14 @@
|
||||
"workspace.wizard.step3.title": "Добре дошли в {{name}}!",
|
||||
"workspace.wizard.title": "Създайте работно пространство",
|
||||
"workspaceSetting.breadcrumb.settings": "Настройки",
|
||||
"workspaceSetting.devices.desc": "Споделени машини, записани в това работно пространство. Членовете могат да изпълняват агенти на тях.",
|
||||
"workspaceSetting.devices.empty": "Все още няма устройства в работното пространство.",
|
||||
"workspaceSetting.devices.enrollDesc": "Изпълнете това на машината, която искате да споделите (само за собственика на работното пространство):",
|
||||
"workspaceSetting.devices.enrollTitle": "Добавете устройство",
|
||||
"workspaceSetting.devices.heroDesc": "Запишете споделена машина — сървър за изграждане или екипен Mac — и всеки член може да изпълнява агенти на нея: четене/запис на файлове, изпълнение на команди и използване на системни инструменти.",
|
||||
"workspaceSetting.devices.heroTitle": "Свържете първото устройство към работното пространство",
|
||||
"workspaceSetting.devices.offline": "Офлайн",
|
||||
"workspaceSetting.devices.online": "Онлайн",
|
||||
"workspaceSetting.group.admin": "Администратор",
|
||||
"workspaceSetting.group.agent": "Агент",
|
||||
"workspaceSetting.group.general": "Общи",
|
||||
|
||||
@@ -147,10 +147,6 @@
|
||||
"limitation.chat.topupSuccess.title": "Успешно зареждане",
|
||||
"limitation.expired.desc": "Вашите {{plan}} изчислителни кредити изтекоха на {{expiredAt}}. Надградете плана си сега, за да получите нови кредити.",
|
||||
"limitation.expired.title": "Изчислителните кредити са изтекли",
|
||||
"limitation.fableCampaign.desc": "Claude Fable 5 е модел с висока цена. Кредитите за пробната кампания са изчерпани. Надградете плана си, за да продължите да използвате Fable.",
|
||||
"limitation.fableCampaign.title": "Изчерпани кредити за пробната версия на Fable",
|
||||
"limitation.fableCampaign.upgrade": "Надградете плана",
|
||||
"limitation.fableCampaign.upgradeToPlan": "Надградете до {{plan}}",
|
||||
"limitation.hobby.action": "Конфигурирано, продължи разговора",
|
||||
"limitation.hobby.configAPI": "Конфигурирай API",
|
||||
"limitation.hobby.desc": "Вашите безплатни изчислителни кредити са изчерпани. Моля, конфигурирайте персонализиран API на модел, за да продължите.",
|
||||
@@ -342,7 +338,14 @@
|
||||
"plans.workspace.noSharedCredits": "Без споделени кредити",
|
||||
"plans.workspace.sharedCredits": "~{{count}} кредити / месец",
|
||||
"plans.workspace.solo": "Соло (1 член)",
|
||||
"promoBanner.fableYearly": "Годишните абонати получават {{percent}}% отстъпка за ограничено време",
|
||||
"plansModal.creditLimit.desc": "Надградете плана си, за да отключите повече месечни кредити и да продължите работа без прекъсване.",
|
||||
"plansModal.creditLimit.title": "Нямате кредити",
|
||||
"plansModal.default.desc": "Отключете повече капацитет и разширени функции.",
|
||||
"plansModal.default.title": "Надградете плана си",
|
||||
"plansModal.fileStorageLimit.desc": "Вашето файлово хранилище е пълно. Надградете, за да продължите да качвате и управлявате файлове.",
|
||||
"plansModal.fileStorageLimit.title": "Достигнат лимит за хранилище",
|
||||
"plansModal.modelAccess.desc": "Този модел е достъпен в платените планове. Надградете, за да използвате пълния набор от модели.",
|
||||
"plansModal.modelAccess.title": "Отключете всички модели",
|
||||
"qa.desc": "Ако въпросът ви не е отговорен, проверете <1>Документацията на продукта</1> за още често задавани въпроси или се свържете с нас.",
|
||||
"qa.detail": "Виж подробности",
|
||||
"qa.list.credit.a": "Изчислителните кредити са мярка, използвана от {{cloud}} за измерване на използването на AI модели при извикване на модели. Различните AI модели консумират различно количество изчислителни кредити.",
|
||||
@@ -398,6 +401,8 @@
|
||||
"referral.errors.invalidFormat": "Невалиден формат на кода, въведете 2–8 букви, цифри или долни черти",
|
||||
"referral.errors.selfReferral": "Не можете да използвате собствения си код за покана",
|
||||
"referral.errors.updateFailed": "Неуспешна актуализация, моля опитайте отново по-късно",
|
||||
"referral.hero.description": "Споделете вашия реферален линк по-долу. След като вашият приятел направи първото си плащане, и двамата ще получите {{reward}}M кредита.",
|
||||
"referral.hero.title": "Поканете приятели, и двамата печелите <0>{{reward}}M кредита</0>",
|
||||
"referral.inviteCode.description": "Споделете своя уникален код за покана, за да поканите приятели да се регистрират",
|
||||
"referral.inviteCode.title": "Моят код за покана",
|
||||
"referral.inviteLink.description": "Копирайте линка и го споделете с приятели. И двамата получавате кредити, след като вашият приятел направи плащане.",
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"actions.unmarkCompleted": "Отбележи като активна",
|
||||
"defaultTitle": "Тема по подразбиране",
|
||||
"displayItems": "Показване на елементи",
|
||||
"draft": "[Чернова]",
|
||||
"duplicateLoading": "Копиране на тема...",
|
||||
"duplicateSuccess": "Темата беше успешно копирана",
|
||||
"failedStatusTip": "Този процес срещна грешка — отворете го, за да разгледате.",
|
||||
|
||||
+28
-2
@@ -41,6 +41,8 @@
|
||||
"artifact.thinking": "Denkt nach",
|
||||
"artifact.thought": "Denkprozess",
|
||||
"artifact.unknownTitle": "Unbenannte Arbeit",
|
||||
"audioPlayer.pause": "Audio pausieren",
|
||||
"audioPlayer.play": "Audio abspielen",
|
||||
"availableAgents": "Verfügbare Agenten",
|
||||
"backToBottom": "Zum neuesten Beitrag springen",
|
||||
"beforeUnload.confirmLeave": "Eine Anfrage läuft noch. Trotzdem verlassen?",
|
||||
@@ -120,6 +122,18 @@
|
||||
"createModal.groupPlaceholder": "Beschreiben Sie, was diese Gruppe tun soll...",
|
||||
"createModal.groupTitle": "Was soll Ihre Gruppe tun?",
|
||||
"createModal.placeholder": "Beschreiben Sie, was Ihr Agent tun soll...",
|
||||
"createModal.skillSuggestion.actions.createAnyway": "Agent trotzdem erstellen",
|
||||
"createModal.skillSuggestion.actions.createAnywayHint": "Skill passt nicht?",
|
||||
"createModal.skillSuggestion.actions.install": "Skill installieren",
|
||||
"createModal.skillSuggestion.actions.installing": "Wird installiert…",
|
||||
"createModal.skillSuggestion.actions.openSkills": "In Skills anzeigen",
|
||||
"createModal.skillSuggestion.actions.tryInLobeAI": "In {{name}} verwenden",
|
||||
"createModal.skillSuggestion.description": "Das scheint ein wiederverwendbarer Workflow zu sein. Installieren Sie den Skill einmal und nutzen Sie ihn dann in verschiedenen Agents.",
|
||||
"createModal.skillSuggestion.installError": "Skill wurde nicht installiert. Versuchen Sie es erneut oder erstellen Sie trotzdem einen Agent.",
|
||||
"createModal.skillSuggestion.installed.description": "Sie können diesen Skill in {{name}} verwenden oder ihn für jeden Agent aktivieren.",
|
||||
"createModal.skillSuggestion.installed.ready": "Bereit in {{name}}",
|
||||
"createModal.skillSuggestion.installed.title": "Skill installiert",
|
||||
"createModal.skillSuggestion.title": "Ein Skill könnte besser passen",
|
||||
"createModal.title": "Was soll Ihr Agent tun?",
|
||||
"createTask.assignee": "Zuständige Person",
|
||||
"createTask.collapse": "Eingabe ausblenden",
|
||||
@@ -166,6 +180,8 @@
|
||||
"extendParams.title": "Modellerweiterungsfunktionen",
|
||||
"extendParams.urlContext.desc": "Wenn aktiviert, werden Weblinks automatisch analysiert, um den tatsächlichen Seiteninhalt abzurufen",
|
||||
"extendParams.urlContext.title": "Webseiteninhalte extrahieren",
|
||||
"floatingChatPanel.collapse": "Chat minimieren",
|
||||
"floatingChatPanel.expand": "Chat maximieren",
|
||||
"followUpPlaceholder": "Folgen Sie nach. @, um Aufgaben anderen Agenten zuzuweisen.",
|
||||
"followUpPlaceholderHeterogeneous": "Weiter ausführen.",
|
||||
"gatewayMode.beta": "Beta",
|
||||
@@ -219,9 +235,13 @@
|
||||
"heteroAgent.cloudRepo.noRepos": "Keine Repositories konfiguriert. Fügen Sie diese in den Agenteneinstellungen hinzu.",
|
||||
"heteroAgent.cloudRepo.notSet": "Kein Repository ausgewählt",
|
||||
"heteroAgent.cloudRepo.sectionTitle": "Repositories",
|
||||
"heteroAgent.executionTarget.auto": "Automatisch",
|
||||
"heteroAgent.executionTarget.autoDesc": "Automatisch ein Online-Gerät verwenden, wobei eines ausgewählt wird, wenn mehrere verfügbar sind",
|
||||
"heteroAgent.executionTarget.downloadDesktop": "Desktop-App herunterladen",
|
||||
"heteroAgent.executionTarget.downloadDesktopDesc": "Führen Sie Agenten mit Zugriff auf Ihren Computer aus",
|
||||
"heteroAgent.executionTarget.downloadDesktopTitle": "Desktop-App herunterladen",
|
||||
"heteroAgent.executionTarget.gateway": "Gateway",
|
||||
"heteroAgent.executionTarget.gatewayDesc": "Über das Geräte-Gateway ausführen, damit andere Clients den Fortschritt verfolgen können",
|
||||
"heteroAgent.executionTarget.infoTooltip": "Wählen Sie ein Remote-Gerät aus, um diese Maschine über das Web zu steuern. \"Dieses Gerät\" führt den Agenten lokal aus und ist nur in der Desktop-App verfügbar.",
|
||||
"heteroAgent.executionTarget.loading": "Geräte werden geladen…",
|
||||
"heteroAgent.executionTarget.local": "Dieses Gerät",
|
||||
@@ -231,10 +251,12 @@
|
||||
"heteroAgent.executionTarget.noneDesc": "Kein Gerät aktiviert",
|
||||
"heteroAgent.executionTarget.offline": "Offline",
|
||||
"heteroAgent.executionTarget.online": "Online",
|
||||
"heteroAgent.executionTarget.personalGroup": "Persönlich",
|
||||
"heteroAgent.executionTarget.sandbox": "Cloud-Sandbox",
|
||||
"heteroAgent.executionTarget.sandboxDesc": "In einer temporären Cloud-Sandbox ausführen",
|
||||
"heteroAgent.executionTarget.title": "Ausführungsgerät",
|
||||
"heteroAgent.executionTarget.unknownDevice": "Unbekanntes Gerät",
|
||||
"heteroAgent.executionTarget.workspaceGroup": "Arbeitsbereich",
|
||||
"heteroAgent.fullAccess.label": "Vollzugriff",
|
||||
"heteroAgent.fullAccess.tooltip": "Claude Code läuft lokal mit vollständigem Lese-/Schreibzugriff auf das Arbeitsverzeichnis. Das Umschalten von Berechtigungsmodi ist derzeit nicht verfügbar.",
|
||||
"heteroAgent.resumeReset.cwdChanged": "Arbeitsverzeichnis geändert. Die vorherige Claude-Code-Sitzung kann nur aus dem ursprünglichen Verzeichnis fortgesetzt werden, daher wurde eine neue Unterhaltung gestartet.",
|
||||
@@ -631,6 +653,8 @@
|
||||
"taskDetail.artifacts": "Artefakte",
|
||||
"taskDetail.blockedBy": "Blockiert durch {{id}}",
|
||||
"taskDetail.cancelSchedule": "Planung abbrechen",
|
||||
"taskDetail.closeDetail": "Details schließen",
|
||||
"taskDetail.collapseReply": "Einklappen",
|
||||
"taskDetail.comment.cancel": "Abbrechen",
|
||||
"taskDetail.comment.delete": "Löschen",
|
||||
"taskDetail.comment.deleteConfirm.content": "Dieser Kommentar wird dauerhaft entfernt.",
|
||||
@@ -657,6 +681,7 @@
|
||||
"taskDetail.notFound.backToTasks": "Zurück zu allen Aufgaben",
|
||||
"taskDetail.notFound.desc": "Diese Aufgabe wurde möglicherweise gelöscht oder Sie haben keine Berechtigung, sie anzusehen.",
|
||||
"taskDetail.notFound.title": "Aufgabe nicht gefunden",
|
||||
"taskDetail.openDetail": "Details öffnen",
|
||||
"taskDetail.pauseTask": "Aufgabe pausieren",
|
||||
"taskDetail.priority.high": "Hoch",
|
||||
"taskDetail.priority.low": "Niedrig",
|
||||
@@ -925,9 +950,9 @@
|
||||
"workflow.collapse": "Einklappen",
|
||||
"workflow.expandFull": "Vollständig ausklappen",
|
||||
"workflow.failedSuffix": "(fehlgeschlagen)",
|
||||
"workflow.summaryAcrossTools": "über {{count}} Tools",
|
||||
"workflow.summaryCallsLead": "{{count}} Anrufe: {{tools}}",
|
||||
"workflow.summaryFailed": "{{count}} fehlgeschlagen",
|
||||
"workflow.summaryMoreTools": "{{count}} Werkzeugarten",
|
||||
"workflow.summaryTotalCalls": "{{count}} Aufrufe insgesamt",
|
||||
"workflow.thoughtForDuration": "Nachgedacht für {{duration}}",
|
||||
"workflow.toolDisplayName.activateDevice": "Gerät aktiviert",
|
||||
"workflow.toolDisplayName.activateSkill": "Eine Fähigkeit wurde aktiviert",
|
||||
@@ -1043,6 +1068,7 @@
|
||||
"workingPanel.resources.deleteTitle": "Delete document?",
|
||||
"workingPanel.resources.deleteTitleMulti": "{{count}} Elemente löschen?",
|
||||
"workingPanel.resources.empty": "Noch keine Dokumente. Dokumente, die diesem Agenten zugeordnet sind, werden hier angezeigt.",
|
||||
"workingPanel.resources.emptyDocuments": "Noch keine Dokumente. Erstellen Sie eines mit dem + oben.",
|
||||
"workingPanel.resources.error": "Failed to load resources",
|
||||
"workingPanel.resources.filter.documents": "Dokumente",
|
||||
"workingPanel.resources.filter.skills": "Fähigkeiten",
|
||||
|
||||
@@ -444,6 +444,23 @@
|
||||
"tab.setting": "Einstellungen",
|
||||
"tab.tasks": "Aufgaben",
|
||||
"tab.video": "Video",
|
||||
"taskTemplate.action.connect.button": "Verbinden mit {{provider}}",
|
||||
"taskTemplate.action.connect.error": "Verbindung fehlgeschlagen, bitte versuchen Sie es erneut.",
|
||||
"taskTemplate.action.connect.popupBlocked": "Verbindungspopup blockiert. Erlauben Sie Popups in Ihrem Browser, um fortzufahren.",
|
||||
"taskTemplate.action.connect.short": "Verbinden",
|
||||
"taskTemplate.action.connecting": "Warten auf Autorisierung…",
|
||||
"taskTemplate.action.create.error": "Aufgabe konnte nicht erstellt werden. Bitte versuchen Sie es erneut.",
|
||||
"taskTemplate.action.create.success": "Geplante Aufgabe hinzugefügt. Finden Sie sie in Lobe AI.",
|
||||
"taskTemplate.action.createButton": "Aufgabe hinzufügen",
|
||||
"taskTemplate.action.creating": "Wird erstellt...",
|
||||
"taskTemplate.action.dismiss.error": "Abweisung fehlgeschlagen. Bitte versuchen Sie es erneut.",
|
||||
"taskTemplate.action.dismiss.tooltip": "Nicht interessiert",
|
||||
"taskTemplate.action.refresh.button": "Aktualisieren",
|
||||
"taskTemplate.card.templateTag": "Vorlage",
|
||||
"taskTemplate.schedule.daily": "Jeden Tag um {{time}}",
|
||||
"taskTemplate.schedule.editableAfterCreateTooltip": "Sie können den Zeitplan nach der Erstellung der Aufgabe anpassen.",
|
||||
"taskTemplate.schedule.weekly": "Jeden {{weekday}} um {{time}}",
|
||||
"taskTemplate.section.title": "Probieren Sie diese geplanten Aufgaben aus",
|
||||
"telemetry.allow": "Zulassen",
|
||||
"telemetry.deny": "Ablehnen",
|
||||
"telemetry.desc": "Wir möchten anonym Nutzungsdaten erfassen, um {{appName}} zu verbessern und Ihnen ein besseres Produkterlebnis zu bieten. Sie können dies jederzeit unter Einstellungen – Über deaktivieren.",
|
||||
@@ -474,15 +491,14 @@
|
||||
"userPanel.email": "E-Mail-Support",
|
||||
"userPanel.feedback": "Kontaktieren Sie uns",
|
||||
"userPanel.help": "Hilfezentrum",
|
||||
"userPanel.inviteFriend": "Einen Freund einladen",
|
||||
"userPanel.moveGuide": "Die Schaltfläche für Einstellungen wurde hierher verschoben",
|
||||
"userPanel.plans": "Abonnementpläne",
|
||||
"userPanel.profile": "Konto",
|
||||
"userPanel.setting": "Einstellungen",
|
||||
"userPanel.upgradePlan": "Plan upgraden",
|
||||
"userPanel.usages": "Nutzungsstatistiken",
|
||||
"userPanel.workspaceCredits": "Arbeitsbereich-Guthaben",
|
||||
"userPanel.workspaceSetting": "Arbeitsbereich-Einstellungen",
|
||||
"userPanel.workspaceUsages": "Arbeitsbereich-Nutzung",
|
||||
"version": "Version",
|
||||
"zoom": "Zoom"
|
||||
}
|
||||
|
||||
@@ -101,6 +101,7 @@
|
||||
"LocalFile.action.open": "Öffnen",
|
||||
"LocalFile.action.showInFolder": "Im Ordner anzeigen",
|
||||
"MaxTokenSlider.unlimited": "Unbegrenzt",
|
||||
"ModelSelect.featureTag.audio": "Dieses Modell unterstützt die Erkennung von Audioeingaben.",
|
||||
"ModelSelect.featureTag.custom": "Benutzerdefiniertes Modell, unterstützt standardmäßig Funktionsaufrufe und visuelle Erkennung. Bitte prüfen Sie die tatsächliche Verfügbarkeit dieser Funktionen.",
|
||||
"ModelSelect.featureTag.file": "Dieses Modell unterstützt das Hochladen von Dateien zur Analyse und Erkennung.",
|
||||
"ModelSelect.featureTag.functionCall": "Dieses Modell unterstützt Funktionsaufrufe.",
|
||||
@@ -114,6 +115,7 @@
|
||||
"ModelSwitchPanel.byModel": "Nach Modell",
|
||||
"ModelSwitchPanel.byProvider": "Nach Anbieter",
|
||||
"ModelSwitchPanel.detail.abilities": "Fähigkeiten",
|
||||
"ModelSwitchPanel.detail.abilities.audio": "Audio",
|
||||
"ModelSwitchPanel.detail.abilities.files": "Dateien",
|
||||
"ModelSwitchPanel.detail.abilities.functionCall": "Werkzeugaufruf",
|
||||
"ModelSwitchPanel.detail.abilities.imageOutput": "Bildausgabe",
|
||||
|
||||
@@ -38,9 +38,6 @@
|
||||
"brief.title": "Tagesübersicht",
|
||||
"brief.viewAllTasks": "Alle Aufgaben anzeigen",
|
||||
"brief.viewRun": "Lauf anzeigen",
|
||||
"freeCreditBadge.cta": "Kostenlose Testversion starten",
|
||||
"freeCreditBadge.dismiss": "Schließen",
|
||||
"freeCreditBadge.label": "Exklusive Gratisguthaben für {{model}}",
|
||||
"project.create": "Neues Projekt",
|
||||
"project.deleteConfirm": "Dieses Projekt wird gelöscht und kann nicht wiederhergestellt werden. Bestätigen Sie, um fortzufahren.",
|
||||
"recommendations.heteroAgent.cta": "Agent hinzufügen",
|
||||
|
||||
@@ -5,10 +5,12 @@
|
||||
"authorize.footer.agreement": "Indem du fortfährst, bestätigst du, dass du die <terms>Nutzungsbedingungen</terms> und die <privacy>Datenschutzerklärung</privacy> gelesen hast und ihnen zustimmst.",
|
||||
"authorize.footer.privacy": "Datenschutzerklärung",
|
||||
"authorize.footer.terms": "Nutzungsbedingungen",
|
||||
"authorize.scenes.connector.confirm": "Weiter zum Markt",
|
||||
"authorize.scenes.connector.description": "Der Markt wird nur verwendet, um diese Dienstautorisierung zu starten. Ihr {{appName}}-Konto bleibt getrennt.",
|
||||
"authorize.scenes.connector.subtitle": "Melden Sie sich beim Markt an, um diesen Gemeinschaftsdienst zu verbinden und zu autorisieren.",
|
||||
"authorize.scenes.connector.title": "Gemeinschaftsdienst verbinden",
|
||||
"authorize.scenes.mcp.subtitle": "Erstellen Sie ein Community-Profil, um diese Fähigkeit aus der Community zu installieren und auszuführen.",
|
||||
"authorize.scenes.mcp.title": "Community-Fähigkeit installieren",
|
||||
"authorize.scenes.publish.subtitle": "Erstellen Sie ein Community-Profil, um Ihr Angebot in der Community zu veröffentlichen und zu verwalten.",
|
||||
"authorize.scenes.publish.title": "In der Community veröffentlichen",
|
||||
"authorize.scenes.sandbox.subtitle": "Erstellen Sie ein Community-Profil, um dieses Tool im Community-Sandbox-Modus auszuführen.",
|
||||
"authorize.scenes.sandbox.title": "Community-Sandbox ausprobieren",
|
||||
"authorize.subtitle": "Erstelle ein Community-Profil, um Einträge innerhalb der Community zu verwalten und einzureichen.",
|
||||
@@ -50,8 +52,6 @@
|
||||
"messages.handoffTimeout": "Autorisierung abgelaufen. Schließe sie im Browser ab und versuche es erneut.",
|
||||
"messages.loading": "Autorisierungsvorgang wird gestartet...",
|
||||
"messages.success.cloudMcpInstall": "Autorisierung erfolgreich! Du kannst jetzt die Cloud MCP-Funktion installieren.",
|
||||
"messages.success.submit": "Autorisierung erfolgreich! Du kannst jetzt deinen Agenten veröffentlichen.",
|
||||
"messages.success.upload": "Autorisierung erfolgreich! Du kannst jetzt eine neue Version veröffentlichen.",
|
||||
"profileSetup.cancel": "Abbrechen",
|
||||
"profileSetup.confirmChangeUserId.cancel": "Abbrechen",
|
||||
"profileSetup.confirmChangeUserId.confirm": "Benutzer-ID ändern",
|
||||
|
||||
@@ -222,6 +222,7 @@
|
||||
"providerModels.item.modelConfig.extendParams.options.effort.hint": "Für Claude Opus 4.6; steuert das Anstrengungsniveau (niedrig/mittel/hoch/maximal).",
|
||||
"providerModels.item.modelConfig.extendParams.options.enableAdaptiveThinking.hint": "Für Claude Opus 4.6; schaltet adaptives Denken ein oder aus.",
|
||||
"providerModels.item.modelConfig.extendParams.options.enableReasoning.hint": "Für Claude-, DeepSeek- und andere Modelle mit logischem Denken; ermöglicht tiefere Überlegungen.",
|
||||
"providerModels.item.modelConfig.extendParams.options.glm5_2ReasoningEffort.hint": "Für GLM-5.2; steuert den Aufwand für logisches Denken mit den Stufen Hoch und Maximal.",
|
||||
"providerModels.item.modelConfig.extendParams.options.gpt5ReasoningEffort.hint": "Für die GPT-5-Serie; steuert die Intensität des logischen Denkens.",
|
||||
"providerModels.item.modelConfig.extendParams.options.gpt5_1ReasoningEffort.hint": "Für die GPT-5.1-Serie; steuert die Intensität des logischen Denkens.",
|
||||
"providerModels.item.modelConfig.extendParams.options.gpt5_2ProReasoningEffort.hint": "Für die GPT-5.2 Pro-Serie; steuert die Intensität des logischen Denkens.",
|
||||
@@ -256,6 +257,7 @@
|
||||
"providerModels.item.modelConfig.files.title": "Datei-Upload-Unterstützung",
|
||||
"providerModels.item.modelConfig.functionCall.extra": "Diese Konfiguration aktiviert nur die Fähigkeit des Modells, Werkzeuge zu verwenden. Ob das Modell diese tatsächlich nutzen kann, hängt vom Modell selbst ab. Bitte testen Sie die Nutzbarkeit selbst.",
|
||||
"providerModels.item.modelConfig.functionCall.title": "Werkzeugnutzung unterstützen",
|
||||
"providerModels.item.modelConfig.id.duplicate": "Ein Modell mit dieser ID existiert bereits. Verwenden Sie eine andere Modell-ID.",
|
||||
"providerModels.item.modelConfig.id.extra": "Kann nach Erstellung nicht mehr geändert werden und wird als Modell-ID bei KI-Aufrufen verwendet",
|
||||
"providerModels.item.modelConfig.id.placeholder": "Bitte geben Sie die Modell-ID ein, z. B. gpt-4o oder claude-3.5-sonnet",
|
||||
"providerModels.item.modelConfig.id.title": "Modell-ID",
|
||||
@@ -270,11 +272,11 @@
|
||||
"providerModels.item.modelConfig.tokens.title": "Maximales Kontextfenster",
|
||||
"providerModels.item.modelConfig.tokens.unlimited": "Unbegrenzt",
|
||||
"providerModels.item.modelConfig.type.extra": "Verschiedene Modelltypen haben unterschiedliche Anwendungsfälle und Fähigkeiten",
|
||||
"providerModels.item.modelConfig.type.options.asr": "Sprache-zu-Text",
|
||||
"providerModels.item.modelConfig.type.options.chat": "Chat",
|
||||
"providerModels.item.modelConfig.type.options.embedding": "Embedding",
|
||||
"providerModels.item.modelConfig.type.options.image": "Bildgenerierung",
|
||||
"providerModels.item.modelConfig.type.options.realtime": "Echtzeit-Chat",
|
||||
"providerModels.item.modelConfig.type.options.stt": "Sprache-zu-Text",
|
||||
"providerModels.item.modelConfig.type.options.text2music": "Text-zu-Musik",
|
||||
"providerModels.item.modelConfig.type.options.tts": "Text-zu-Sprache",
|
||||
"providerModels.item.modelConfig.type.options.video": "Videoerstellung",
|
||||
@@ -323,10 +325,10 @@
|
||||
"providerModels.list.total": "{{count}} Modelle verfügbar",
|
||||
"providerModels.searchNotFound": "Keine Suchergebnisse gefunden",
|
||||
"providerModels.tabs.all": "Alle",
|
||||
"providerModels.tabs.asr": "ASR",
|
||||
"providerModels.tabs.chat": "Chat",
|
||||
"providerModels.tabs.embedding": "Embedding",
|
||||
"providerModels.tabs.image": "Bild",
|
||||
"providerModels.tabs.stt": "ASR",
|
||||
"providerModels.tabs.tts": "TTS",
|
||||
"providerModels.tabs.video": "Video",
|
||||
"sortModal.success": "Sortierung erfolgreich aktualisiert",
|
||||
|
||||
@@ -440,60 +440,7 @@
|
||||
"llm.proxyUrl.title": "API-Proxy-URL",
|
||||
"llm.waitingForMore": "Weitere Modelle sind <1>in Planung</1>, bleib dran",
|
||||
"llm.waitingForMoreLinkAriaLabel": "Anbieter-Anfrageformular öffnen",
|
||||
"marketPublish.forkConfirm.by": "von {{author}}",
|
||||
"marketPublish.forkConfirm.confirm": "Veröffentlichung bestätigen",
|
||||
"marketPublish.forkConfirm.confirmGroup": "Veröffentlichung bestätigen",
|
||||
"marketPublish.forkConfirm.description": "Sie sind dabei, eine abgeleitete Version basierend auf einem bestehenden Agenten aus der Community zu veröffentlichen. Ihr neuer Agent wird als separater Eintrag im Marktplatz erstellt.",
|
||||
"marketPublish.forkConfirm.descriptionGroup": "Sie sind dabei, eine abgeleitete Version basierend auf einer bestehenden Gruppe aus der Community zu veröffentlichen. Ihre neue Gruppe wird als separater Eintrag im Marktplatz erstellt.",
|
||||
"marketPublish.forkConfirm.title": "Abgeleiteten Agenten veröffentlichen",
|
||||
"marketPublish.forkConfirm.titleGroup": "Abgeleitete Gruppe veröffentlichen",
|
||||
"marketPublish.modal.changelog.extra": "Beschreiben Sie die wichtigsten Änderungen und Verbesserungen in dieser Version",
|
||||
"marketPublish.modal.changelog.label": "Änderungsprotokoll",
|
||||
"marketPublish.modal.changelog.maxLengthError": "Das Änderungsprotokoll darf 500 Zeichen nicht überschreiten",
|
||||
"marketPublish.modal.changelog.placeholder": "Änderungsprotokoll eingeben",
|
||||
"marketPublish.modal.changelog.required": "Bitte geben Sie das Änderungsprotokoll ein",
|
||||
"marketPublish.modal.comparison.local": "Aktuelle lokale Version",
|
||||
"marketPublish.modal.comparison.remote": "Derzeit veröffentlichte Version",
|
||||
"marketPublish.modal.identifier.extra": "Dies ist die eindeutige Kennung des Agenten. Verwenden Sie Kleinbuchstaben, Zahlen und Bindestriche.",
|
||||
"marketPublish.modal.identifier.label": "Agentenkennung",
|
||||
"marketPublish.modal.identifier.lengthError": "Die Kennung muss zwischen 3 und 50 Zeichen lang sein",
|
||||
"marketPublish.modal.identifier.patternError": "Die Kennung darf nur Kleinbuchstaben, Zahlen und Bindestriche enthalten",
|
||||
"marketPublish.modal.identifier.placeholder": "Eindeutige Kennung für den Agenten eingeben, z. B. web-entwicklung",
|
||||
"marketPublish.modal.identifier.required": "Bitte geben Sie die Agentenkennung ein",
|
||||
"marketPublish.modal.loading.fetchingRemote": "Remote-Daten werden geladen...",
|
||||
"marketPublish.modal.loading.submit": "Agent wird übermittelt...",
|
||||
"marketPublish.modal.loading.submitGroup": "Gruppe wird übermittelt...",
|
||||
"marketPublish.modal.loading.upload": "Neue Version wird veröffentlicht...",
|
||||
"marketPublish.modal.loading.uploadGroup": "Neue Gruppenversion wird veröffentlicht...",
|
||||
"marketPublish.modal.messages.createVersionFailed": "Version konnte nicht erstellt werden: {{message}}",
|
||||
"marketPublish.modal.messages.fetchRemoteFailed": "Remote-Agentendaten konnten nicht abgerufen werden",
|
||||
"marketPublish.modal.messages.missingIdentifier": "Dieser Agent hat noch keine Community-Kennung.",
|
||||
"marketPublish.modal.messages.noGroup": "Keine Gruppe ausgewählt",
|
||||
"marketPublish.modal.messages.notAuthenticated": "Melden Sie sich zuerst bei Ihrem Community-Konto an.",
|
||||
"marketPublish.modal.messages.publishFailed": "Veröffentlichung fehlgeschlagen: {{message}}",
|
||||
"marketPublish.modal.submitButton": "Veröffentlichen",
|
||||
"marketPublish.modal.title.submit": "In der Agenten-Community teilen",
|
||||
"marketPublish.modal.title.upload": "Neue Version veröffentlichen",
|
||||
"marketPublish.resultModal.message": "Ihr Agent wurde zur Überprüfung eingereicht. Nach Freigabe wird er automatisch veröffentlicht.",
|
||||
"marketPublish.resultModal.messageGroup": "Ihre Gruppe wurde zur Überprüfung eingereicht. Nach der Freigabe wird sie automatisch veröffentlicht.",
|
||||
"marketPublish.resultModal.title": "Erfolgreich eingereicht",
|
||||
"marketPublish.resultModal.view": "In der Community anzeigen",
|
||||
"marketPublish.status.underReview": "In Prüfung",
|
||||
"marketPublish.submit.button": "In der Community teilen",
|
||||
"marketPublish.submit.tooltip": "Diesen Agenten in der Community teilen",
|
||||
"marketPublish.submitGroup.tooltip": "Diese Gruppe mit der Community teilen",
|
||||
"marketPublish.upload.button": "Neue Version veröffentlichen",
|
||||
"marketPublish.upload.tooltip": "Eine neue Version in der Agenten-Community veröffentlichen",
|
||||
"marketPublish.uploadGroup.tooltip": "Neue Version in der Gruppen-Community veröffentlichen",
|
||||
"marketPublish.validation.communitySetupRequired.action": "Jetzt einrichten",
|
||||
"marketPublish.validation.communitySetupRequired.desc": "Dieser Arbeitsbereich hat sein Community-Profil noch nicht eingerichtet. Richten Sie es ein, bevor Sie es in der Community veröffentlichen.",
|
||||
"marketPublish.validation.communitySetupRequired.memberHint": "Dieser Arbeitsbereich hat sein Community-Profil noch nicht eingerichtet. Bitten Sie einen Arbeitsbereichsbesitzer, es einzurichten, bevor Sie es in der Community veröffentlichen.",
|
||||
"marketPublish.validation.communitySetupRequired.title": "Community-Profil zuerst einrichten",
|
||||
"marketPublish.validation.confirmPublish": "Im Markt veröffentlichen?",
|
||||
"marketPublish.validation.confirmPublishDesc": "Nach der Veröffentlichung ist dieser Inhalt öffentlich im Markt sichtbar und für jeden zugänglich.",
|
||||
"marketPublish.validation.emptyName": "Veröffentlichung nicht möglich: Name ist erforderlich",
|
||||
"marketPublish.validation.emptySystemRole": "Veröffentlichung nicht möglich: Systemrolle ist erforderlich",
|
||||
"marketPublish.validation.underReview": "Ihre neue Version wird derzeit geprüft. Bitte warten Sie auf die Genehmigung, bevor Sie eine neue Version veröffentlichen.",
|
||||
"memory.effort.desc": "Steuern Sie, wie aggressiv die KI Speicher abruft und aktualisiert.",
|
||||
"memory.effort.high": "Hoch — Proaktives Abrufen und Aktualisieren",
|
||||
"memory.effort.level.high": "Hoch",
|
||||
@@ -515,14 +462,6 @@
|
||||
"myAgents.actions.deprecateLoading": "Agent wird veraltet...",
|
||||
"myAgents.actions.deprecateSuccess": "Agent veraltet",
|
||||
"myAgents.actions.edit": "Agent bearbeiten",
|
||||
"myAgents.actions.publish": "Agent veröffentlichen",
|
||||
"myAgents.actions.publishError": "Agent konnte nicht veröffentlicht werden",
|
||||
"myAgents.actions.publishLoading": "Agent wird veröffentlicht...",
|
||||
"myAgents.actions.publishSuccess": "Agent veröffentlicht",
|
||||
"myAgents.actions.unpublish": "Agent zurückziehen",
|
||||
"myAgents.actions.unpublishError": "Agent konnte nicht zurückgezogen werden",
|
||||
"myAgents.actions.unpublishLoading": "Agent wird zurückgezogen...",
|
||||
"myAgents.actions.unpublishSuccess": "Agent zurückgezogen",
|
||||
"myAgents.actions.viewDetail": "Details anzeigen",
|
||||
"myAgents.detail.category": "Kategorie",
|
||||
"myAgents.detail.description": "Beschreibung",
|
||||
@@ -587,7 +526,6 @@
|
||||
"plugin.settings.title": "{{id}} Fähigkeitenkonfiguration",
|
||||
"plugin.settings.tooltip": "Fähigkeitenkonfiguration",
|
||||
"plugin.store": "Fähigkeiten-Store",
|
||||
"publishToCommunity": "In der Community veröffentlichen",
|
||||
"serviceModel.contextLimit.placeholder": "Kontextlimit",
|
||||
"serviceModel.memoryModels.title": "Speichermodelle",
|
||||
"serviceModel.modelAssignments.title": "Modellzuweisungen",
|
||||
@@ -955,13 +893,6 @@
|
||||
"storageOverage.usage.estimatedCharge": "Geschätzte Zyklusgebühr",
|
||||
"storageOverage.usage.incurredCharge": "In diesem Zyklus angefallen",
|
||||
"storageOverage.usage.overage": "Überlastung",
|
||||
"submitAgentModal.button": "Agent einreichen",
|
||||
"submitAgentModal.identifier": "Agentenkennung",
|
||||
"submitAgentModal.metaMiss": "Bitte vervollständigen Sie die Agenteninformationen vor dem Einreichen. Name, Beschreibung und Tags sind erforderlich.",
|
||||
"submitAgentModal.placeholder": "Geben Sie eine eindeutige Kennung für den Agenten ein, z. B. web-entwicklung",
|
||||
"submitAgentModal.success": "Agent erfolgreich eingereicht",
|
||||
"submitAgentModal.tooltips": "In der Agenten-Community teilen",
|
||||
"submitGroupModal.tooltips": "In der Gruppen-Community teilen",
|
||||
"sync.device.deviceName.hint": "Fügen Sie einen Namen zur leichteren Identifizierung hinzu",
|
||||
"sync.device.deviceName.placeholder": "Gerätenamen eingeben",
|
||||
"sync.device.deviceName.title": "Gerätename",
|
||||
@@ -1086,6 +1017,7 @@
|
||||
"tools.activation.auto": "Automatisch",
|
||||
"tools.activation.auto.desc": "Intelligent",
|
||||
"tools.activation.fixed.hint": "Immer aktiv — wird von der App verwaltet und kann nicht deaktiviert werden",
|
||||
"tools.activation.pin": "Pin",
|
||||
"tools.activation.pinned": "Angeheftet",
|
||||
"tools.activation.pinned.desc": "Immer an",
|
||||
"tools.add": "Fähigkeit hinzufügen",
|
||||
@@ -2047,6 +1979,14 @@
|
||||
"workspace.wizard.step3.title": "Willkommen bei {{name}}!",
|
||||
"workspace.wizard.title": "Arbeitsbereich erstellen",
|
||||
"workspaceSetting.breadcrumb.settings": "Einstellungen",
|
||||
"workspaceSetting.devices.desc": "Gemeinsam genutzte Geräte, die in diesem Arbeitsbereich registriert sind. Mitglieder können darauf Agenten ausführen.",
|
||||
"workspaceSetting.devices.empty": "Noch keine Geräte im Arbeitsbereich.",
|
||||
"workspaceSetting.devices.enrollDesc": "Führen Sie dies auf dem Gerät aus, das Sie teilen möchten (nur für Arbeitsbereichsinhaber):",
|
||||
"workspaceSetting.devices.enrollTitle": "Ein Gerät hinzufügen",
|
||||
"workspaceSetting.devices.heroDesc": "Registrieren Sie ein gemeinsam genutztes Gerät – einen Build-Server oder einen Team-Mac – und jedes Mitglied kann darauf Agenten ausführen: Dateien lesen/schreiben, Befehle ausführen und Systemtools aufrufen.",
|
||||
"workspaceSetting.devices.heroTitle": "Verbinden Sie Ihr erstes Arbeitsbereichsgerät",
|
||||
"workspaceSetting.devices.offline": "Offline",
|
||||
"workspaceSetting.devices.online": "Online",
|
||||
"workspaceSetting.group.admin": "Admin",
|
||||
"workspaceSetting.group.agent": "Agent",
|
||||
"workspaceSetting.group.general": "Allgemein",
|
||||
|
||||
@@ -147,10 +147,6 @@
|
||||
"limitation.chat.topupSuccess.title": "Aufladung erfolgreich",
|
||||
"limitation.expired.desc": "Deine {{plan}}-Rechenguthaben sind am {{expiredAt}} abgelaufen. Upgrade jetzt, um neue Guthaben zu erhalten.",
|
||||
"limitation.expired.title": "Rechenguthaben abgelaufen",
|
||||
"limitation.fableCampaign.desc": "Claude Fable 5 ist ein hochpreisiges Modell. Die Kampagnen-Testguthaben sind aufgebraucht. Aktualisieren Sie Ihren Plan, um Fable weiterhin zu nutzen.",
|
||||
"limitation.fableCampaign.title": "Fable-Testguthaben aufgebraucht",
|
||||
"limitation.fableCampaign.upgrade": "Plan aktualisieren",
|
||||
"limitation.fableCampaign.upgradeToPlan": "Upgrade auf {{plan}}",
|
||||
"limitation.hobby.action": "Konfiguriert, weiter chatten",
|
||||
"limitation.hobby.configAPI": "API konfigurieren",
|
||||
"limitation.hobby.desc": "Deine kostenlosen Rechenguthaben sind aufgebraucht. Bitte konfiguriere eine benutzerdefinierte Modell-API, um fortzufahren.",
|
||||
@@ -342,7 +338,14 @@
|
||||
"plans.workspace.noSharedCredits": "Keine geteilten Credits",
|
||||
"plans.workspace.sharedCredits": "~{{count}} Credits / Monat",
|
||||
"plans.workspace.solo": "Solo (1 Mitglied)",
|
||||
"promoBanner.fableYearly": "Jahresabonnenten erhalten {{percent}}% Rabatt für eine begrenzte Zeit",
|
||||
"plansModal.creditLimit.desc": "Aktualisieren Sie Ihren Plan, um mehr monatliche Credits freizuschalten und ohne Unterbrechung weiterzuarbeiten.",
|
||||
"plansModal.creditLimit.title": "Ihnen sind die Credits ausgegangen",
|
||||
"plansModal.default.desc": "Schalten Sie mehr Kapazität und erweiterte Funktionen frei.",
|
||||
"plansModal.default.title": "Aktualisieren Sie Ihren Plan",
|
||||
"plansModal.fileStorageLimit.desc": "Ihr Dateispeicher ist voll. Aktualisieren Sie, um weiterhin Dateien hochzuladen und zu verwalten.",
|
||||
"plansModal.fileStorageLimit.title": "Speicherlimit erreicht",
|
||||
"plansModal.modelAccess.desc": "Dieses Modell ist in kostenpflichtigen Plänen verfügbar. Aktualisieren Sie, um die vollständige Modellauswahl zu nutzen.",
|
||||
"plansModal.modelAccess.title": "Alle Modelle freischalten",
|
||||
"qa.desc": "Wenn Ihre Frage nicht beantwortet wurde, besuchen Sie die <1>Produktdokumentation</1> für weitere FAQs oder kontaktieren Sie uns.",
|
||||
"qa.detail": "Details anzeigen",
|
||||
"qa.list.credit.a": "Rechen-Credits sind eine Metrik von {{cloud}}, um die Nutzung von KI-Modellen zu messen. Verschiedene Modelle verbrauchen unterschiedlich viele Credits.",
|
||||
@@ -398,6 +401,8 @@
|
||||
"referral.errors.invalidFormat": "Ungültiges Format, bitte 2–8 Buchstaben, Zahlen oder Unterstriche eingeben",
|
||||
"referral.errors.selfReferral": "Du kannst deinen eigenen Einladungscode nicht verwenden",
|
||||
"referral.errors.updateFailed": "Aktualisierung fehlgeschlagen, bitte später erneut versuchen",
|
||||
"referral.hero.description": "Teilen Sie Ihren Empfehlungslink unten. Nachdem Ihr Freund seine erste Zahlung geleistet hat, erhalten Sie beide jeweils {{reward}}M Credits.",
|
||||
"referral.hero.title": "Laden Sie Freunde ein, Sie verdienen beide <0>{{reward}}M Credits</0>",
|
||||
"referral.inviteCode.description": "Teilen Sie Ihren exklusiven Empfehlungscode, um Freunde einzuladen",
|
||||
"referral.inviteCode.title": "Mein Empfehlungscode",
|
||||
"referral.inviteLink.description": "Kopieren Sie den Link und teilen Sie ihn mit Freunden. Beide erhalten Credits, nachdem Ihr Freund eine Zahlung getätigt hat.",
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"actions.unmarkCompleted": "Als aktiv markieren",
|
||||
"defaultTitle": "Standardthema",
|
||||
"displayItems": "Elemente anzeigen",
|
||||
"draft": "[Entwurf]",
|
||||
"duplicateLoading": "Thema wird kopiert...",
|
||||
"duplicateSuccess": "Thema erfolgreich kopiert",
|
||||
"failedStatusTip": "Dieser Durchlauf hat einen Fehler — öffnen Sie ihn, um nachzusehen.",
|
||||
|
||||
@@ -246,15 +246,18 @@
|
||||
"heteroAgent.executionTarget.loading": "Loading devices…",
|
||||
"heteroAgent.executionTarget.local": "This device",
|
||||
"heteroAgent.executionTarget.localDesc": "Run as a local process on this desktop app",
|
||||
"heteroAgent.executionTarget.manage": "Manage",
|
||||
"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.offline": "Offline",
|
||||
"heteroAgent.executionTarget.online": "Online",
|
||||
"heteroAgent.executionTarget.personalGroup": "Personal",
|
||||
"heteroAgent.executionTarget.sandbox": "Cloud Sandbox",
|
||||
"heteroAgent.executionTarget.sandboxDesc": "Run in an ephemeral cloud sandbox",
|
||||
"heteroAgent.executionTarget.title": "Execution Device",
|
||||
"heteroAgent.executionTarget.unknownDevice": "Unknown device",
|
||||
"heteroAgent.executionTarget.workspaceGroup": "Workspace",
|
||||
"heteroAgent.fullAccess.label": "Full access",
|
||||
"heteroAgent.fullAccess.tooltip": "Claude Code runs locally with full read/write access to the working directory. Switching permission modes is not available yet.",
|
||||
"heteroAgent.resumeReset.cwdChanged": "Working directory changed. Previous Claude Code session can only be resumed from its original directory, so a new conversation has started.",
|
||||
@@ -651,6 +654,8 @@
|
||||
"taskDetail.artifacts": "Artifacts",
|
||||
"taskDetail.blockedBy": "Blocked by {{id}}",
|
||||
"taskDetail.cancelSchedule": "Cancel schedule",
|
||||
"taskDetail.closeDetail": "Close detail",
|
||||
"taskDetail.collapseReply": "Collapse",
|
||||
"taskDetail.comment.cancel": "Cancel",
|
||||
"taskDetail.comment.delete": "Delete",
|
||||
"taskDetail.comment.deleteConfirm.content": "This comment will be permanently removed.",
|
||||
@@ -677,6 +682,7 @@
|
||||
"taskDetail.notFound.backToTasks": "Back to all tasks",
|
||||
"taskDetail.notFound.desc": "This task may have been deleted, or you don't have permission to view it.",
|
||||
"taskDetail.notFound.title": "Task not found",
|
||||
"taskDetail.openDetail": "Open detail",
|
||||
"taskDetail.pauseTask": "Pause task",
|
||||
"taskDetail.priority.high": "High",
|
||||
"taskDetail.priority.low": "Low",
|
||||
|
||||
@@ -491,12 +491,14 @@
|
||||
"userPanel.email": "Email Support",
|
||||
"userPanel.feedback": "Contact Us",
|
||||
"userPanel.help": "Help Center",
|
||||
"userPanel.inviteFriend": "Invite a friend",
|
||||
"userPanel.moveGuide": "The settings button has been moved here",
|
||||
"userPanel.plans": "Subscription Plans",
|
||||
"userPanel.profile": "Account",
|
||||
"userPanel.setting": "Settings",
|
||||
"userPanel.upgradePlan": "Upgrade Plan",
|
||||
"userPanel.usages": "Usage",
|
||||
"userPanel.workspaceSetting": "Workspace Settings",
|
||||
"version": "Version",
|
||||
"zoom": "Zoom"
|
||||
}
|
||||
|
||||
@@ -38,9 +38,6 @@
|
||||
"brief.title": "Brief",
|
||||
"brief.viewAllTasks": "View all tasks",
|
||||
"brief.viewRun": "View run",
|
||||
"freeCreditBadge.cta": "Start free trial",
|
||||
"freeCreditBadge.dismiss": "Dismiss",
|
||||
"freeCreditBadge.label": "Exclusive free credits for {{model}}",
|
||||
"project.create": "New project",
|
||||
"project.deleteConfirm": "This project will be deleted and can't be recovered. Confirm to continue.",
|
||||
"recommendations.heteroAgent.cta": "Add Agent",
|
||||
|
||||
@@ -1979,6 +1979,14 @@
|
||||
"workspace.wizard.step3.title": "Welcome to {{name}}!",
|
||||
"workspace.wizard.title": "Create Workspace",
|
||||
"workspaceSetting.breadcrumb.settings": "Settings",
|
||||
"workspaceSetting.devices.desc": "Shared machines enrolled into this workspace. Members can run agents on them.",
|
||||
"workspaceSetting.devices.empty": "No workspace devices yet.",
|
||||
"workspaceSetting.devices.enrollDesc": "Run this on the machine you want to share (workspace owner only):",
|
||||
"workspaceSetting.devices.enrollTitle": "Add a device",
|
||||
"workspaceSetting.devices.heroDesc": "Enroll a shared machine — a build server or a team Mac — and every member can run agents on it: read/write files, run commands, and call system tools.",
|
||||
"workspaceSetting.devices.heroTitle": "Connect your first workspace device",
|
||||
"workspaceSetting.devices.offline": "Offline",
|
||||
"workspaceSetting.devices.online": "Online",
|
||||
"workspaceSetting.group.admin": "Admin",
|
||||
"workspaceSetting.group.agent": "Agent",
|
||||
"workspaceSetting.group.general": "General",
|
||||
|
||||
@@ -147,10 +147,6 @@
|
||||
"limitation.chat.topupSuccess.title": "Top-up Successful",
|
||||
"limitation.expired.desc": "Your {{plan}} credits expired on {{expiredAt}}. Upgrade your plan now to get credits.",
|
||||
"limitation.expired.title": "Credits Expired",
|
||||
"limitation.fableCampaign.desc": "Claude Fable 5 is a high-cost model. The campaign trial credits have been used up. Upgrade your plan to keep using Fable.",
|
||||
"limitation.fableCampaign.title": "Fable Trial Credits Used Up",
|
||||
"limitation.fableCampaign.upgrade": "Upgrade Plan",
|
||||
"limitation.fableCampaign.upgradeToPlan": "Upgrade to {{plan}}",
|
||||
"limitation.hobby.action": "Configured, continue chatting",
|
||||
"limitation.hobby.configAPI": "Configure API",
|
||||
"limitation.hobby.desc": "Your free credits have been exhausted. Please configure a custom model API to continue.",
|
||||
@@ -350,7 +346,6 @@
|
||||
"plansModal.fileStorageLimit.title": "Storage limit reached",
|
||||
"plansModal.modelAccess.desc": "This model is available on paid plans. Upgrade to use the full model lineup.",
|
||||
"plansModal.modelAccess.title": "Unlock all models",
|
||||
"promoBanner.fableYearly": "Annual subscribers get {{percent}}% usage off for a limited time",
|
||||
"qa.desc": "If your question is not answered, check <1>Product Documentation</1> for more FAQs, or contact us.",
|
||||
"qa.detail": "View Details",
|
||||
"qa.list.credit.a": "Credits are how {{cloud}} measures AI model usage. Different AI models consume different amounts of credits.",
|
||||
@@ -406,8 +401,10 @@
|
||||
"referral.errors.invalidFormat": "Invalid referral code format, please enter 2-8 letters, numbers or underscores",
|
||||
"referral.errors.selfReferral": "You cannot use your own invite code",
|
||||
"referral.errors.updateFailed": "Update failed, please try again later",
|
||||
"referral.hero.description": "Share your referral link below. After your friend makes their first payment, you each earn {{reward}}M credits.",
|
||||
"referral.hero.title": "Invite friends, you both earn <0>{{reward}}M credits</0>",
|
||||
"referral.inviteCode.description": "Share your exclusive referral code to invite friends to register",
|
||||
"referral.inviteCode.title": "My Referral Code",
|
||||
"referral.inviteCode.title": "My Exclusive Referral Code",
|
||||
"referral.inviteLink.description": "Copy the link and share with friends. Both of you earn credits after your friend makes a payment",
|
||||
"referral.inviteLink.title": "Referral Link",
|
||||
"referral.rules.antiAbuse": "If fraudulent activity is detected (e.g., mass registration of disposable email accounts), the associated accounts will be permanently banned",
|
||||
|
||||
+28
-2
@@ -41,6 +41,8 @@
|
||||
"artifact.thinking": "Pensando",
|
||||
"artifact.thought": "Proceso de pensamiento",
|
||||
"artifact.unknownTitle": "Trabajo sin título",
|
||||
"audioPlayer.pause": "Pausar audio",
|
||||
"audioPlayer.play": "Reproducir audio",
|
||||
"availableAgents": "Agentes disponibles",
|
||||
"backToBottom": "Ir al último mensaje",
|
||||
"beforeUnload.confirmLeave": "Una solicitud aún está en curso. ¿Salir de todos modos?",
|
||||
@@ -120,6 +122,18 @@
|
||||
"createModal.groupPlaceholder": "Describe lo que debería hacer este grupo...",
|
||||
"createModal.groupTitle": "¿Qué debería hacer tu grupo?",
|
||||
"createModal.placeholder": "Describe lo que debería hacer tu agente...",
|
||||
"createModal.skillSuggestion.actions.createAnyway": "Crear agente de todos modos",
|
||||
"createModal.skillSuggestion.actions.createAnywayHint": "¿Habilidad no adecuada?",
|
||||
"createModal.skillSuggestion.actions.install": "Instalar habilidad",
|
||||
"createModal.skillSuggestion.actions.installing": "Instalando…",
|
||||
"createModal.skillSuggestion.actions.openSkills": "Ver en Habilidades",
|
||||
"createModal.skillSuggestion.actions.tryInLobeAI": "Usar en {{name}}",
|
||||
"createModal.skillSuggestion.description": "Esto parece un flujo de trabajo reutilizable. Instala la habilidad una vez y luego úsala en todos los agentes.",
|
||||
"createModal.skillSuggestion.installError": "La habilidad no se instaló. Inténtalo de nuevo o crea un agente de todos modos.",
|
||||
"createModal.skillSuggestion.installed.description": "Puedes usar esta habilidad en {{name}} o habilitarla para cualquier agente.",
|
||||
"createModal.skillSuggestion.installed.ready": "Listo en {{name}}",
|
||||
"createModal.skillSuggestion.installed.title": "Habilidad instalada",
|
||||
"createModal.skillSuggestion.title": "Una habilidad podría encajar mejor",
|
||||
"createModal.title": "¿Qué debería hacer tu agente?",
|
||||
"createTask.assignee": "Asignado a",
|
||||
"createTask.collapse": "Ocultar entrada",
|
||||
@@ -166,6 +180,8 @@
|
||||
"extendParams.title": "Funciones de Extensión del Modelo",
|
||||
"extendParams.urlContext.desc": "Cuando está habilitado, los enlaces web se analizarán automáticamente para recuperar el contenido real de la página",
|
||||
"extendParams.urlContext.title": "Extraer contenido de enlaces web",
|
||||
"floatingChatPanel.collapse": "Colapsar chat",
|
||||
"floatingChatPanel.expand": "Expandir chat",
|
||||
"followUpPlaceholder": "Seguimiento. Usa @ para asignar tareas a otros agentes.",
|
||||
"followUpPlaceholderHeterogeneous": "Continuar.",
|
||||
"gatewayMode.beta": "Beta",
|
||||
@@ -219,9 +235,13 @@
|
||||
"heteroAgent.cloudRepo.noRepos": "No hay repositorios configurados. Agrégalos en la configuración del agente.",
|
||||
"heteroAgent.cloudRepo.notSet": "Ningún repositorio seleccionado",
|
||||
"heteroAgent.cloudRepo.sectionTitle": "Repositorios",
|
||||
"heteroAgent.executionTarget.auto": "Automático",
|
||||
"heteroAgent.executionTarget.autoDesc": "Usar un dispositivo en línea automáticamente, eligiendo uno cuando haya varios disponibles",
|
||||
"heteroAgent.executionTarget.downloadDesktop": "Obtener la aplicación de escritorio",
|
||||
"heteroAgent.executionTarget.downloadDesktopDesc": "Ejecuta agentes con acceso a tu computadora",
|
||||
"heteroAgent.executionTarget.downloadDesktopTitle": "Obtener la aplicación de escritorio",
|
||||
"heteroAgent.executionTarget.gateway": "Puerta de enlace",
|
||||
"heteroAgent.executionTarget.gatewayDesc": "Ejecutar a través de la puerta de enlace del dispositivo para que otros clientes puedan seguir el progreso",
|
||||
"heteroAgent.executionTarget.infoTooltip": "Elige un dispositivo remoto para controlar esa máquina desde la web. \"Este dispositivo\" ejecuta el agente localmente y solo está disponible dentro de la aplicación de escritorio.",
|
||||
"heteroAgent.executionTarget.loading": "Cargando dispositivos…",
|
||||
"heteroAgent.executionTarget.local": "Este dispositivo",
|
||||
@@ -231,10 +251,12 @@
|
||||
"heteroAgent.executionTarget.noneDesc": "No hay dispositivos habilitados",
|
||||
"heteroAgent.executionTarget.offline": "Desconectado",
|
||||
"heteroAgent.executionTarget.online": "Conectado",
|
||||
"heteroAgent.executionTarget.personalGroup": "Personal",
|
||||
"heteroAgent.executionTarget.sandbox": "Sandbox en la nube",
|
||||
"heteroAgent.executionTarget.sandboxDesc": "Ejecutar en un sandbox efímero en la nube",
|
||||
"heteroAgent.executionTarget.title": "Dispositivo de Ejecución",
|
||||
"heteroAgent.executionTarget.unknownDevice": "Dispositivo desconocido",
|
||||
"heteroAgent.executionTarget.workspaceGroup": "Espacio de trabajo",
|
||||
"heteroAgent.fullAccess.label": "Acceso completo",
|
||||
"heteroAgent.fullAccess.tooltip": "Claude Code se ejecuta localmente con acceso completo de lectura y escritura al directorio de trabajo. Cambiar los modos de permiso aún no está disponible.",
|
||||
"heteroAgent.resumeReset.cwdChanged": "El directorio de trabajo ha cambiado. La sesión anterior de Claude Code solo puede reanudarse desde su directorio original, por lo que se ha iniciado una nueva conversación.",
|
||||
@@ -631,6 +653,8 @@
|
||||
"taskDetail.artifacts": "Artefactos",
|
||||
"taskDetail.blockedBy": "Bloqueado por {{id}}",
|
||||
"taskDetail.cancelSchedule": "Cancelar programación",
|
||||
"taskDetail.closeDetail": "Cerrar detalle",
|
||||
"taskDetail.collapseReply": "Colapsar",
|
||||
"taskDetail.comment.cancel": "Cancelar",
|
||||
"taskDetail.comment.delete": "Eliminar",
|
||||
"taskDetail.comment.deleteConfirm.content": "Este comentario se eliminará permanentemente.",
|
||||
@@ -657,6 +681,7 @@
|
||||
"taskDetail.notFound.backToTasks": "Volver a todas las tareas",
|
||||
"taskDetail.notFound.desc": "Es posible que esta tarea haya sido eliminada o que no tengas permiso para verla.",
|
||||
"taskDetail.notFound.title": "Tarea no encontrada",
|
||||
"taskDetail.openDetail": "Abrir detalle",
|
||||
"taskDetail.pauseTask": "Pausar tarea",
|
||||
"taskDetail.priority.high": "Alta",
|
||||
"taskDetail.priority.low": "Baja",
|
||||
@@ -925,9 +950,9 @@
|
||||
"workflow.collapse": "Contraer",
|
||||
"workflow.expandFull": "Expandir completamente",
|
||||
"workflow.failedSuffix": "(fallido)",
|
||||
"workflow.summaryAcrossTools": "a través de {{count}} herramientas",
|
||||
"workflow.summaryCallsLead": "{{count}} llamadas: {{tools}}",
|
||||
"workflow.summaryFailed": "{{count}} fallos",
|
||||
"workflow.summaryMoreTools": "{{count}} tipos de herramientas",
|
||||
"workflow.summaryTotalCalls": "{{count}} llamadas en total",
|
||||
"workflow.thoughtForDuration": "Reflexionó durante {{duration}}",
|
||||
"workflow.toolDisplayName.activateDevice": "Dispositivo activado",
|
||||
"workflow.toolDisplayName.activateSkill": "Activó una habilidad",
|
||||
@@ -1043,6 +1068,7 @@
|
||||
"workingPanel.resources.deleteTitle": "Delete document?",
|
||||
"workingPanel.resources.deleteTitleMulti": "¿Eliminar {{count}} elementos?",
|
||||
"workingPanel.resources.empty": "Aún no hay documentos. Los documentos asociados con este agente aparecerán aquí.",
|
||||
"workingPanel.resources.emptyDocuments": "Aún no hay documentos. Crea uno con el + arriba.",
|
||||
"workingPanel.resources.error": "Failed to load resources",
|
||||
"workingPanel.resources.filter.documents": "Documentos",
|
||||
"workingPanel.resources.filter.skills": "Habilidades",
|
||||
|
||||
@@ -444,6 +444,23 @@
|
||||
"tab.setting": "Configuración",
|
||||
"tab.tasks": "Tareas",
|
||||
"tab.video": "Vídeo",
|
||||
"taskTemplate.action.connect.button": "Conectar {{provider}}",
|
||||
"taskTemplate.action.connect.error": "Conexión fallida, por favor inténtalo de nuevo.",
|
||||
"taskTemplate.action.connect.popupBlocked": "Ventana emergente de conexión bloqueada. Permite ventanas emergentes en tu navegador para continuar.",
|
||||
"taskTemplate.action.connect.short": "Conectar",
|
||||
"taskTemplate.action.connecting": "Esperando autorización…",
|
||||
"taskTemplate.action.create.error": "No se pudo crear la tarea. Por favor, inténtalo de nuevo.",
|
||||
"taskTemplate.action.create.success": "Tarea programada añadida. Encuéntrala en Lobe AI.",
|
||||
"taskTemplate.action.createButton": "Añadir tarea",
|
||||
"taskTemplate.action.creating": "Creando...",
|
||||
"taskTemplate.action.dismiss.error": "No se pudo descartar. Por favor, inténtalo de nuevo.",
|
||||
"taskTemplate.action.dismiss.tooltip": "No me interesa",
|
||||
"taskTemplate.action.refresh.button": "Actualizar",
|
||||
"taskTemplate.card.templateTag": "Plantilla",
|
||||
"taskTemplate.schedule.daily": "Todos los días a las {{time}}",
|
||||
"taskTemplate.schedule.editableAfterCreateTooltip": "Puedes ajustar el horario después de crear la tarea.",
|
||||
"taskTemplate.schedule.weekly": "Todos los {{weekday}} a las {{time}}",
|
||||
"taskTemplate.section.title": "Prueba estas tareas programadas",
|
||||
"telemetry.allow": "Permitir",
|
||||
"telemetry.deny": "Denegar",
|
||||
"telemetry.desc": "Nos gustaría recopilar información de uso de forma anónima para ayudarnos a mejorar {{appName}} y ofrecerte una mejor experiencia. Puedes desactivar esta opción en cualquier momento en Configuración - Acerca de.",
|
||||
@@ -474,15 +491,14 @@
|
||||
"userPanel.email": "Soporte por correo",
|
||||
"userPanel.feedback": "Contáctanos",
|
||||
"userPanel.help": "Centro de ayuda",
|
||||
"userPanel.inviteFriend": "Invitar a un amigo",
|
||||
"userPanel.moveGuide": "El botón de configuración se ha movido aquí",
|
||||
"userPanel.plans": "Planes de suscripción",
|
||||
"userPanel.profile": "Cuenta",
|
||||
"userPanel.setting": "Configuración",
|
||||
"userPanel.upgradePlan": "Actualizar Plan",
|
||||
"userPanel.usages": "Estadísticas de uso",
|
||||
"userPanel.workspaceCredits": "Créditos del Espacio de Trabajo",
|
||||
"userPanel.workspaceSetting": "Configuración del Espacio de Trabajo",
|
||||
"userPanel.workspaceUsages": "Uso del Espacio de Trabajo",
|
||||
"version": "Versión",
|
||||
"zoom": "Zoom"
|
||||
}
|
||||
|
||||
@@ -101,6 +101,7 @@
|
||||
"LocalFile.action.open": "Abrir",
|
||||
"LocalFile.action.showInFolder": "Mostrar en carpeta",
|
||||
"MaxTokenSlider.unlimited": "Ilimitado",
|
||||
"ModelSelect.featureTag.audio": "Este modelo admite el reconocimiento de entrada de audio.",
|
||||
"ModelSelect.featureTag.custom": "Modelo personalizado, por defecto, admite llamadas a funciones y reconocimiento visual. Verifica la disponibilidad de estas capacidades según el caso.",
|
||||
"ModelSelect.featureTag.file": "Este modelo admite la carga de archivos para lectura y reconocimiento.",
|
||||
"ModelSelect.featureTag.functionCall": "Este modelo admite llamadas a funciones.",
|
||||
@@ -114,6 +115,7 @@
|
||||
"ModelSwitchPanel.byModel": "Por modelo",
|
||||
"ModelSwitchPanel.byProvider": "Por proveedor",
|
||||
"ModelSwitchPanel.detail.abilities": "Capacidades",
|
||||
"ModelSwitchPanel.detail.abilities.audio": "Audio",
|
||||
"ModelSwitchPanel.detail.abilities.files": "Archivos",
|
||||
"ModelSwitchPanel.detail.abilities.functionCall": "Llamada a herramienta",
|
||||
"ModelSwitchPanel.detail.abilities.imageOutput": "Salida de imagen",
|
||||
|
||||
@@ -38,9 +38,6 @@
|
||||
"brief.title": "Informe diario",
|
||||
"brief.viewAllTasks": "Ver todas las tareas",
|
||||
"brief.viewRun": "Ver ejecución",
|
||||
"freeCreditBadge.cta": "Comienza la prueba gratuita",
|
||||
"freeCreditBadge.dismiss": "Descartar",
|
||||
"freeCreditBadge.label": "Créditos gratis exclusivos para {{model}}",
|
||||
"project.create": "Nuevo proyecto",
|
||||
"project.deleteConfirm": "Este proyecto se eliminará y no se podrá recuperar. Confirma para continuar.",
|
||||
"recommendations.heteroAgent.cta": "Agregar agente",
|
||||
|
||||
@@ -5,10 +5,12 @@
|
||||
"authorize.footer.agreement": "Al continuar, confirmas que has leído y aceptas los <terms>Términos y Condiciones</terms> y la <privacy>Política de Privacidad</privacy>.",
|
||||
"authorize.footer.privacy": "Política de Privacidad",
|
||||
"authorize.footer.terms": "Términos del Servicio",
|
||||
"authorize.scenes.connector.confirm": "Continuar al Mercado",
|
||||
"authorize.scenes.connector.description": "El Mercado solo se utiliza para iniciar esta autorización de servicio. Tu cuenta de {{appName}} permanece separada.",
|
||||
"authorize.scenes.connector.subtitle": "Inicia sesión en el Mercado para conectar y autorizar este servicio comunitario.",
|
||||
"authorize.scenes.connector.title": "Conectar Servicio Comunitario",
|
||||
"authorize.scenes.mcp.subtitle": "Crea un perfil comunitario para instalar y ejecutar esta habilidad desde la comunidad.",
|
||||
"authorize.scenes.mcp.title": "Instalar Habilidad Comunitaria",
|
||||
"authorize.scenes.publish.subtitle": "Crea un perfil comunitario para publicar y gestionar tu listado dentro de la comunidad.",
|
||||
"authorize.scenes.publish.title": "Publicar en la Comunidad",
|
||||
"authorize.scenes.sandbox.subtitle": "Crea un perfil comunitario para ejecutar esta herramienta en el sandbox de la comunidad.",
|
||||
"authorize.scenes.sandbox.title": "Probar el Sandbox de la Comunidad",
|
||||
"authorize.subtitle": "Crea un perfil de comunidad para enviar y gestionar publicaciones dentro de la comunidad.",
|
||||
@@ -50,8 +52,6 @@
|
||||
"messages.handoffTimeout": "La autorización ha expirado. Complétala en tu navegador y vuelve a intentarlo.",
|
||||
"messages.loading": "Iniciando proceso de autorización...",
|
||||
"messages.success.cloudMcpInstall": "¡Autorización exitosa! Ahora puedes instalar la habilidad Cloud MCP.",
|
||||
"messages.success.submit": "¡Autorización exitosa! Ahora puedes publicar tu agente.",
|
||||
"messages.success.upload": "¡Autorización exitosa! Ahora puedes publicar una nueva versión.",
|
||||
"profileSetup.cancel": "Cancelar",
|
||||
"profileSetup.confirmChangeUserId.cancel": "Cancelar",
|
||||
"profileSetup.confirmChangeUserId.confirm": "Cambiar ID de usuario",
|
||||
|
||||
@@ -222,6 +222,7 @@
|
||||
"providerModels.item.modelConfig.extendParams.options.effort.hint": "Para Claude Opus 4.6; controla el nivel de esfuerzo (bajo/medio/alto/máximo).",
|
||||
"providerModels.item.modelConfig.extendParams.options.enableAdaptiveThinking.hint": "Para Claude Opus 4.6; activa o desactiva el pensamiento adaptativo.",
|
||||
"providerModels.item.modelConfig.extendParams.options.enableReasoning.hint": "Para Claude, DeepSeek y otros modelos de razonamiento; permite un pensamiento más profundo.",
|
||||
"providerModels.item.modelConfig.extendParams.options.glm5_2ReasoningEffort.hint": "Para GLM-5.2; controla el esfuerzo de razonamiento con niveles Alto y Máximo.",
|
||||
"providerModels.item.modelConfig.extendParams.options.gpt5ReasoningEffort.hint": "Para la serie GPT-5; controla la intensidad del razonamiento.",
|
||||
"providerModels.item.modelConfig.extendParams.options.gpt5_1ReasoningEffort.hint": "Para la serie GPT-5.1; controla la intensidad del razonamiento.",
|
||||
"providerModels.item.modelConfig.extendParams.options.gpt5_2ProReasoningEffort.hint": "Para la serie GPT-5.2 Pro; controla la intensidad del razonamiento.",
|
||||
@@ -256,6 +257,7 @@
|
||||
"providerModels.item.modelConfig.files.title": "Soporte de carga de archivos",
|
||||
"providerModels.item.modelConfig.functionCall.extra": "Esta configuración solo habilita la capacidad del modelo para usar herramientas, permitiendo agregar habilidades tipo herramienta. Sin embargo, si el modelo puede usarlas depende completamente de él; por favor, prueba su funcionalidad.",
|
||||
"providerModels.item.modelConfig.functionCall.title": "Soporte para uso de herramientas",
|
||||
"providerModels.item.modelConfig.id.duplicate": "Ya existe un modelo con este ID. Utiliza un ID de modelo diferente.",
|
||||
"providerModels.item.modelConfig.id.extra": "No se puede modificar después de la creación y se usará como ID del modelo al llamar a la IA",
|
||||
"providerModels.item.modelConfig.id.placeholder": "Introduce el ID del modelo, por ejemplo, gpt-4o o claude-3.5-sonnet",
|
||||
"providerModels.item.modelConfig.id.title": "ID del modelo",
|
||||
@@ -270,11 +272,11 @@
|
||||
"providerModels.item.modelConfig.tokens.title": "Ventana de contexto máxima",
|
||||
"providerModels.item.modelConfig.tokens.unlimited": "Ilimitado",
|
||||
"providerModels.item.modelConfig.type.extra": "Los diferentes tipos de modelos tienen distintos casos de uso y capacidades",
|
||||
"providerModels.item.modelConfig.type.options.asr": "Texto a voz",
|
||||
"providerModels.item.modelConfig.type.options.chat": "Chat",
|
||||
"providerModels.item.modelConfig.type.options.embedding": "Embedding",
|
||||
"providerModels.item.modelConfig.type.options.image": "Generación de imágenes",
|
||||
"providerModels.item.modelConfig.type.options.realtime": "Chat en tiempo real",
|
||||
"providerModels.item.modelConfig.type.options.stt": "Voz a texto",
|
||||
"providerModels.item.modelConfig.type.options.text2music": "Texto a música",
|
||||
"providerModels.item.modelConfig.type.options.tts": "Texto a voz",
|
||||
"providerModels.item.modelConfig.type.options.video": "Generación de video",
|
||||
@@ -323,10 +325,10 @@
|
||||
"providerModels.list.total": "{{count}} modelos disponibles",
|
||||
"providerModels.searchNotFound": "No se encontraron resultados de búsqueda",
|
||||
"providerModels.tabs.all": "Todos",
|
||||
"providerModels.tabs.asr": "ASR",
|
||||
"providerModels.tabs.chat": "Chat",
|
||||
"providerModels.tabs.embedding": "Embedding",
|
||||
"providerModels.tabs.image": "Imagen",
|
||||
"providerModels.tabs.stt": "ASR",
|
||||
"providerModels.tabs.tts": "TTS",
|
||||
"providerModels.tabs.video": "Vídeo",
|
||||
"sortModal.success": "Orden actualizado con éxito",
|
||||
|
||||
@@ -440,60 +440,7 @@
|
||||
"llm.proxyUrl.title": "URL del Proxy de API",
|
||||
"llm.waitingForMore": "Se <1>planea añadir más modelos</1>, mantente atento",
|
||||
"llm.waitingForMoreLinkAriaLabel": "Abrir formulario de solicitud de proveedor",
|
||||
"marketPublish.forkConfirm.by": "por {{author}}",
|
||||
"marketPublish.forkConfirm.confirm": "Confirmar publicación",
|
||||
"marketPublish.forkConfirm.confirmGroup": "Confirmar publicación",
|
||||
"marketPublish.forkConfirm.description": "Estás a punto de publicar una versión derivada basada en un agente existente de la comunidad. Tu nuevo agente se creará como una entrada separada en el mercado.",
|
||||
"marketPublish.forkConfirm.descriptionGroup": "Estás a punto de publicar una versión derivada basada en un grupo existente de la comunidad. Tu nuevo grupo se creará como una entrada separada en el mercado.",
|
||||
"marketPublish.forkConfirm.title": "Publicar agente derivado",
|
||||
"marketPublish.forkConfirm.titleGroup": "Publicar grupo derivado",
|
||||
"marketPublish.modal.changelog.extra": "Describe los cambios clave y mejoras en esta versión",
|
||||
"marketPublish.modal.changelog.label": "Registro de cambios",
|
||||
"marketPublish.modal.changelog.maxLengthError": "El registro de cambios no debe exceder los 500 caracteres",
|
||||
"marketPublish.modal.changelog.placeholder": "Introduce el registro de cambios",
|
||||
"marketPublish.modal.changelog.required": "Por favor, introduce el registro de cambios",
|
||||
"marketPublish.modal.comparison.local": "Versión local actual",
|
||||
"marketPublish.modal.comparison.remote": "Versión publicada actualmente",
|
||||
"marketPublish.modal.identifier.extra": "Este es el identificador único del Agente. Usa letras minúsculas, números y guiones.",
|
||||
"marketPublish.modal.identifier.label": "Identificador del Agente",
|
||||
"marketPublish.modal.identifier.lengthError": "El identificador debe tener entre 3 y 50 caracteres",
|
||||
"marketPublish.modal.identifier.patternError": "El identificador solo puede contener letras minúsculas, números y guiones",
|
||||
"marketPublish.modal.identifier.placeholder": "Introduce un identificador único para el agente, por ejemplo, desarrollo-web",
|
||||
"marketPublish.modal.identifier.required": "Por favor, introduce el identificador del agente",
|
||||
"marketPublish.modal.loading.fetchingRemote": "Cargando datos remotos...",
|
||||
"marketPublish.modal.loading.submit": "Enviando Agente...",
|
||||
"marketPublish.modal.loading.submitGroup": "Enviando grupo...",
|
||||
"marketPublish.modal.loading.upload": "Publicando nueva versión...",
|
||||
"marketPublish.modal.loading.uploadGroup": "Publicando nueva versión del grupo...",
|
||||
"marketPublish.modal.messages.createVersionFailed": "Error al crear la versión: {{message}}",
|
||||
"marketPublish.modal.messages.fetchRemoteFailed": "Error al obtener los datos del agente remoto",
|
||||
"marketPublish.modal.messages.missingIdentifier": "Este Agente aún no tiene un identificador de la Comunidad.",
|
||||
"marketPublish.modal.messages.noGroup": "Ningún grupo seleccionado",
|
||||
"marketPublish.modal.messages.notAuthenticated": "Inicia sesión en tu cuenta de la Comunidad primero.",
|
||||
"marketPublish.modal.messages.publishFailed": "Error al publicar: {{message}}",
|
||||
"marketPublish.modal.submitButton": "Publicar",
|
||||
"marketPublish.modal.title.submit": "Compartir con la Comunidad de Agentes",
|
||||
"marketPublish.modal.title.upload": "Publicar Nueva Versión",
|
||||
"marketPublish.resultModal.message": "Tu Agente ha sido enviado para revisión. Una vez aprobado, se publicará automáticamente.",
|
||||
"marketPublish.resultModal.messageGroup": "Tu grupo ha sido enviado para revisión. Una vez aprobado, se publicará automáticamente.",
|
||||
"marketPublish.resultModal.title": "Envío Exitoso",
|
||||
"marketPublish.resultModal.view": "Ver en la Comunidad",
|
||||
"marketPublish.status.underReview": "En revisión",
|
||||
"marketPublish.submit.button": "Compartir con la Comunidad",
|
||||
"marketPublish.submit.tooltip": "Comparte este Agente con la Comunidad",
|
||||
"marketPublish.submitGroup.tooltip": "Comparte este grupo con la comunidad",
|
||||
"marketPublish.upload.button": "Publicar Nueva Versión",
|
||||
"marketPublish.upload.tooltip": "Publica una nueva versión en la Comunidad de Agentes",
|
||||
"marketPublish.uploadGroup.tooltip": "Publica una nueva versión en la comunidad de grupos",
|
||||
"marketPublish.validation.communitySetupRequired.action": "Configurar ahora",
|
||||
"marketPublish.validation.communitySetupRequired.desc": "Este espacio de trabajo aún no ha configurado su perfil de Comunidad. Configúralo antes de publicar en la Comunidad.",
|
||||
"marketPublish.validation.communitySetupRequired.memberHint": "Este espacio de trabajo aún no ha configurado su perfil de Comunidad. Pide a un propietario del espacio de trabajo que lo configure antes de publicar en la Comunidad.",
|
||||
"marketPublish.validation.communitySetupRequired.title": "Configura primero el perfil de Comunidad",
|
||||
"marketPublish.validation.confirmPublish": "¿Publicar en el Mercado?",
|
||||
"marketPublish.validation.confirmPublishDesc": "Una vez publicado, este contenido será visible públicamente en el mercado y estará disponible para que cualquiera lo descubra y utilice.",
|
||||
"marketPublish.validation.emptyName": "No se puede publicar: El nombre es obligatorio",
|
||||
"marketPublish.validation.emptySystemRole": "No se puede publicar: El rol del sistema es obligatorio",
|
||||
"marketPublish.validation.underReview": "Tu nueva versión está actualmente en revisión. Por favor, espera la aprobación antes de publicar una nueva versión.",
|
||||
"memory.effort.desc": "Controla cuán agresivamente la IA recupera y actualiza la memoria.",
|
||||
"memory.effort.high": "Alto — Recuperación y actualizaciones proactivas",
|
||||
"memory.effort.level.high": "Alto",
|
||||
@@ -515,14 +462,6 @@
|
||||
"myAgents.actions.deprecateLoading": "Retirando agente...",
|
||||
"myAgents.actions.deprecateSuccess": "Agente retirado",
|
||||
"myAgents.actions.edit": "Editar Agente",
|
||||
"myAgents.actions.publish": "Publicar Agente",
|
||||
"myAgents.actions.publishError": "Error al publicar el agente",
|
||||
"myAgents.actions.publishLoading": "Publicando agente...",
|
||||
"myAgents.actions.publishSuccess": "Agente publicado",
|
||||
"myAgents.actions.unpublish": "Despublicar Agente",
|
||||
"myAgents.actions.unpublishError": "Error al despublicar el agente",
|
||||
"myAgents.actions.unpublishLoading": "Despublicando agente...",
|
||||
"myAgents.actions.unpublishSuccess": "Agente despublicado",
|
||||
"myAgents.actions.viewDetail": "Ver Detalles",
|
||||
"myAgents.detail.category": "Categoría",
|
||||
"myAgents.detail.description": "Descripción",
|
||||
@@ -587,7 +526,6 @@
|
||||
"plugin.settings.title": "Configuración de Habilidad {{id}}",
|
||||
"plugin.settings.tooltip": "Configuración de Habilidad",
|
||||
"plugin.store": "Tienda de Habilidades",
|
||||
"publishToCommunity": "Publicar en la comunidad",
|
||||
"serviceModel.contextLimit.placeholder": "Límite de contexto",
|
||||
"serviceModel.memoryModels.title": "Modelos de memoria",
|
||||
"serviceModel.modelAssignments.title": "Asignaciones de modelo",
|
||||
@@ -955,13 +893,6 @@
|
||||
"storageOverage.usage.estimatedCharge": "Cargo estimado del ciclo",
|
||||
"storageOverage.usage.incurredCharge": "Incurrido en este ciclo",
|
||||
"storageOverage.usage.overage": "Exceso",
|
||||
"submitAgentModal.button": "Enviar Agente",
|
||||
"submitAgentModal.identifier": "Identificador del Agente",
|
||||
"submitAgentModal.metaMiss": "Por favor, completa la información del agente antes de enviarlo. Debe incluir nombre, descripción y etiquetas",
|
||||
"submitAgentModal.placeholder": "Introduce un identificador único para el agente, por ejemplo: desarrollo-web",
|
||||
"submitAgentModal.success": "Agente enviado con éxito",
|
||||
"submitAgentModal.tooltips": "Compartir con la Comunidad de Agentes",
|
||||
"submitGroupModal.tooltips": "Compartir con la comunidad de grupos",
|
||||
"sync.device.deviceName.hint": "Agrega un nombre para facilitar la identificación",
|
||||
"sync.device.deviceName.placeholder": "Introduce el nombre del dispositivo",
|
||||
"sync.device.deviceName.title": "Nombre del Dispositivo",
|
||||
@@ -1086,6 +1017,7 @@
|
||||
"tools.activation.auto": "Automático",
|
||||
"tools.activation.auto.desc": "Inteligente",
|
||||
"tools.activation.fixed.hint": "Siempre activado — gestionado por la aplicación y no se puede desactivar",
|
||||
"tools.activation.pin": "Pin",
|
||||
"tools.activation.pinned": "Fijado",
|
||||
"tools.activation.pinned.desc": "Siempre Activado",
|
||||
"tools.add": "Agregar Habilidad",
|
||||
@@ -2047,6 +1979,14 @@
|
||||
"workspace.wizard.step3.title": "¡Bienvenido a {{name}}!",
|
||||
"workspace.wizard.title": "Crear espacio de trabajo",
|
||||
"workspaceSetting.breadcrumb.settings": "Configuración",
|
||||
"workspaceSetting.devices.desc": "Máquinas compartidas inscritas en este espacio de trabajo. Los miembros pueden ejecutar agentes en ellas.",
|
||||
"workspaceSetting.devices.empty": "Aún no hay dispositivos en el espacio de trabajo.",
|
||||
"workspaceSetting.devices.enrollDesc": "Ejecuta esto en la máquina que deseas compartir (solo el propietario del espacio de trabajo):",
|
||||
"workspaceSetting.devices.enrollTitle": "Agregar un dispositivo",
|
||||
"workspaceSetting.devices.heroDesc": "Inscribe una máquina compartida — un servidor de compilación o un Mac de equipo — y cada miembro podrá ejecutar agentes en ella: leer/escribir archivos, ejecutar comandos y utilizar herramientas del sistema.",
|
||||
"workspaceSetting.devices.heroTitle": "Conecta tu primer dispositivo del espacio de trabajo",
|
||||
"workspaceSetting.devices.offline": "Desconectado",
|
||||
"workspaceSetting.devices.online": "Conectado",
|
||||
"workspaceSetting.group.admin": "Administrador",
|
||||
"workspaceSetting.group.agent": "Agente",
|
||||
"workspaceSetting.group.general": "General",
|
||||
|
||||
@@ -147,10 +147,6 @@
|
||||
"limitation.chat.topupSuccess.title": "Recarga Exitosa",
|
||||
"limitation.expired.desc": "Tus créditos de cómputo del plan {{plan}} expiraron el {{expiredAt}}. Mejora tu plan ahora para obtener más créditos.",
|
||||
"limitation.expired.title": "Créditos de Cómputo Expirados",
|
||||
"limitation.fableCampaign.desc": "Claude Fable 5 es un modelo de alto costo. Los créditos de prueba de la campaña se han agotado. Mejora tu plan para seguir usando Fable.",
|
||||
"limitation.fableCampaign.title": "Créditos de Prueba de Fable Agotados",
|
||||
"limitation.fableCampaign.upgrade": "Mejorar Plan",
|
||||
"limitation.fableCampaign.upgradeToPlan": "Mejorar a {{plan}}",
|
||||
"limitation.hobby.action": "Configurado, continuar chateando",
|
||||
"limitation.hobby.configAPI": "Configurar API",
|
||||
"limitation.hobby.desc": "Tus créditos gratuitos de cómputo se han agotado. Por favor configura una API de modelo personalizada para continuar.",
|
||||
@@ -342,7 +338,14 @@
|
||||
"plans.workspace.noSharedCredits": "Sin créditos compartidos",
|
||||
"plans.workspace.sharedCredits": "~{{count}} Créditos / mes",
|
||||
"plans.workspace.solo": "Solo (1 miembro)",
|
||||
"promoBanner.fableYearly": "Los suscriptores anuales obtienen un {{percent}}% de descuento en el uso por tiempo limitado",
|
||||
"plansModal.creditLimit.desc": "Mejora tu plan para desbloquear más créditos mensuales y seguir trabajando sin interrupciones.",
|
||||
"plansModal.creditLimit.title": "Te has quedado sin créditos",
|
||||
"plansModal.default.desc": "Desbloquea más capacidad y funciones avanzadas.",
|
||||
"plansModal.default.title": "Mejora tu plan",
|
||||
"plansModal.fileStorageLimit.desc": "Tu almacenamiento de archivos está lleno. Mejora tu plan para seguir subiendo y gestionando archivos.",
|
||||
"plansModal.fileStorageLimit.title": "Límite de almacenamiento alcanzado",
|
||||
"plansModal.modelAccess.desc": "Este modelo está disponible en planes de pago. Mejora tu plan para acceder a toda la gama de modelos.",
|
||||
"plansModal.modelAccess.title": "Desbloquea todos los modelos",
|
||||
"qa.desc": "Si tu pregunta no está respondida, revisa la <1>Documentación del Producto</1> para más preguntas frecuentes, o contáctanos.",
|
||||
"qa.detail": "Ver Detalles",
|
||||
"qa.list.credit.a": "Los créditos de cómputo son una métrica utilizada por {{cloud}} para medir el uso de modelos de IA al llamarlos. Diferentes modelos consumen diferentes cantidades de créditos.",
|
||||
@@ -398,6 +401,8 @@
|
||||
"referral.errors.invalidFormat": "Formato inválido, introduce 2-8 letras, números o guiones bajos",
|
||||
"referral.errors.selfReferral": "No puedes usar tu propio código de invitación",
|
||||
"referral.errors.updateFailed": "Error al actualizar, por favor intenta más tarde",
|
||||
"referral.hero.description": "Comparte tu enlace de referencia a continuación. Después de que tu amigo realice su primer pago, ambos ganarán {{reward}}M de créditos.",
|
||||
"referral.hero.title": "Invita a tus amigos, ambos ganan <0>{{reward}}M de créditos</0>",
|
||||
"referral.inviteCode.description": "Comparte tu código exclusivo para invitar amigos a registrarse",
|
||||
"referral.inviteCode.title": "Mi Código de Referido",
|
||||
"referral.inviteLink.description": "Copia el enlace y compártelo con amigos. Ambos ganan créditos después de que tu amigo realice un pago",
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"actions.unmarkCompleted": "Marcar como activa",
|
||||
"defaultTitle": "Tema predeterminado",
|
||||
"displayItems": "Mostrar elementos",
|
||||
"draft": "[Borrador]",
|
||||
"duplicateLoading": "Copiando tema...",
|
||||
"duplicateSuccess": "Tema copiado con éxito",
|
||||
"failedStatusTip": "Esta ejecución tuvo un error — ábrela para echar un vistazo.",
|
||||
|
||||
+28
-2
@@ -41,6 +41,8 @@
|
||||
"artifact.thinking": "در حال تفکر",
|
||||
"artifact.thought": "فرآیند تفکر",
|
||||
"artifact.unknownTitle": "کار بدون عنوان",
|
||||
"audioPlayer.pause": "توقف صدا",
|
||||
"audioPlayer.play": "پخش صدا",
|
||||
"availableAgents": "عوامل در دسترس",
|
||||
"backToBottom": "پرش به آخرین پیام",
|
||||
"beforeUnload.confirmLeave": "درخواستی هنوز در حال اجراست. آیا میخواهید خارج شوید؟",
|
||||
@@ -120,6 +122,18 @@
|
||||
"createModal.groupPlaceholder": "توضیح دهید این گروه باید چه کاری انجام دهد...",
|
||||
"createModal.groupTitle": "گروه شما باید چه کاری انجام دهد؟",
|
||||
"createModal.placeholder": "توضیح دهید عامل شما باید چه کاری انجام دهد...",
|
||||
"createModal.skillSuggestion.actions.createAnyway": "ایجاد عامل به هر حال",
|
||||
"createModal.skillSuggestion.actions.createAnywayHint": "مهارت مناسب نیست؟",
|
||||
"createModal.skillSuggestion.actions.install": "نصب مهارت",
|
||||
"createModal.skillSuggestion.actions.installing": "در حال نصب...",
|
||||
"createModal.skillSuggestion.actions.openSkills": "مشاهده در مهارتها",
|
||||
"createModal.skillSuggestion.actions.tryInLobeAI": "استفاده در {{name}}",
|
||||
"createModal.skillSuggestion.description": "این به نظر یک جریان کاری قابل استفاده مجدد است. مهارت را یک بار نصب کنید، سپس آن را در عوامل مختلف استفاده کنید.",
|
||||
"createModal.skillSuggestion.installError": "مهارت نصب نشد. دوباره تلاش کنید یا به هر حال یک عامل ایجاد کنید.",
|
||||
"createModal.skillSuggestion.installed.description": "شما میتوانید این مهارت را در {{name}} استفاده کنید یا آن را برای هر عاملی فعال کنید.",
|
||||
"createModal.skillSuggestion.installed.ready": "آماده در {{name}}",
|
||||
"createModal.skillSuggestion.installed.title": "مهارت نصب شد",
|
||||
"createModal.skillSuggestion.title": "ممکن است یک مهارت بهتر باشد",
|
||||
"createModal.title": "عامل شما باید چه کاری انجام دهد؟",
|
||||
"createTask.assignee": "مسئول",
|
||||
"createTask.collapse": "پنهان کردن ورودی",
|
||||
@@ -166,6 +180,8 @@
|
||||
"extendParams.title": "ویژگیهای توسعه مدل",
|
||||
"extendParams.urlContext.desc": "در صورت فعال بودن، پیوندهای وب بهطور خودکار تجزیه شده و محتوای صفحه بازیابی میشود",
|
||||
"extendParams.urlContext.title": "استخراج محتوای پیوند وب",
|
||||
"floatingChatPanel.collapse": "بستن چت",
|
||||
"floatingChatPanel.expand": "باز کردن چت",
|
||||
"followUpPlaceholder": "پیگیری. برای واگذاری وظیفه به عاملهای دیگر از @ استفاده کنید.",
|
||||
"followUpPlaceholderHeterogeneous": "پیگیری.",
|
||||
"gatewayMode.beta": "بتا",
|
||||
@@ -219,9 +235,13 @@
|
||||
"heteroAgent.cloudRepo.noRepos": "هیچ مخزنی تنظیم نشده است. آنها را در تنظیمات عامل اضافه کنید.",
|
||||
"heteroAgent.cloudRepo.notSet": "هیچ مخزنی انتخاب نشده است",
|
||||
"heteroAgent.cloudRepo.sectionTitle": "مخازن",
|
||||
"heteroAgent.executionTarget.auto": "خودکار",
|
||||
"heteroAgent.executionTarget.autoDesc": "به طور خودکار از یک دستگاه آنلاین استفاده کنید و یکی را در صورت موجود بودن چندین دستگاه انتخاب کنید",
|
||||
"heteroAgent.executionTarget.downloadDesktop": "دریافت اپلیکیشن دسکتاپ",
|
||||
"heteroAgent.executionTarget.downloadDesktopDesc": "اجرای عوامل با دسترسی به کامپیوتر شما",
|
||||
"heteroAgent.executionTarget.downloadDesktopTitle": "دریافت اپلیکیشن دسکتاپ",
|
||||
"heteroAgent.executionTarget.gateway": "دروازه",
|
||||
"heteroAgent.executionTarget.gatewayDesc": "از طریق دروازه دستگاه اجرا کنید تا سایر مشتریان بتوانند پیشرفت را دنبال کنند",
|
||||
"heteroAgent.executionTarget.infoTooltip": "یک دستگاه از راه دور را انتخاب کنید تا آن ماشین را از طریق وب کنترل کنید. \"این دستگاه\" عامل را به صورت محلی اجرا میکند و فقط در داخل برنامه دسکتاپ در دسترس است.",
|
||||
"heteroAgent.executionTarget.loading": "در حال بارگذاری دستگاهها...",
|
||||
"heteroAgent.executionTarget.local": "این دستگاه",
|
||||
@@ -231,10 +251,12 @@
|
||||
"heteroAgent.executionTarget.noneDesc": "هیچ دستگاهی فعال نشده است",
|
||||
"heteroAgent.executionTarget.offline": "آفلاین",
|
||||
"heteroAgent.executionTarget.online": "آنلاین",
|
||||
"heteroAgent.executionTarget.personalGroup": "شخصی",
|
||||
"heteroAgent.executionTarget.sandbox": "محیط آزمایشی ابری",
|
||||
"heteroAgent.executionTarget.sandboxDesc": "در یک محیط آزمایشی ابری موقت اجرا شود",
|
||||
"heteroAgent.executionTarget.title": "دستگاه اجرا",
|
||||
"heteroAgent.executionTarget.unknownDevice": "دستگاه ناشناخته",
|
||||
"heteroAgent.executionTarget.workspaceGroup": "محیط کاری",
|
||||
"heteroAgent.fullAccess.label": "دسترسی کامل",
|
||||
"heteroAgent.fullAccess.tooltip": "Claude Code بهصورت محلی با دسترسی کامل خواندن/نوشتن در پوشه کاری اجرا میشود. تغییر حالتهای دسترسی فعلاً امکانپذیر نیست.",
|
||||
"heteroAgent.resumeReset.cwdChanged": "پوشه کاری تغییر کرده است. نشست قبلی Claude Code فقط از پوشه اصلی خود قابل ادامه است، بنابراین یک مکالمه جدید آغاز شد.",
|
||||
@@ -631,6 +653,8 @@
|
||||
"taskDetail.artifacts": "آیتمها",
|
||||
"taskDetail.blockedBy": "مسدود شده توسط {{id}}",
|
||||
"taskDetail.cancelSchedule": "لغو زمانبندی",
|
||||
"taskDetail.closeDetail": "بستن جزئیات",
|
||||
"taskDetail.collapseReply": "بستن پاسخ",
|
||||
"taskDetail.comment.cancel": "انصراف",
|
||||
"taskDetail.comment.delete": "حذف",
|
||||
"taskDetail.comment.deleteConfirm.content": "این نظر بهطور دائمی حذف خواهد شد.",
|
||||
@@ -657,6 +681,7 @@
|
||||
"taskDetail.notFound.backToTasks": "بازگشت به همه وظایف",
|
||||
"taskDetail.notFound.desc": "این وظیفه ممکن است حذف شده باشد، یا شما اجازه مشاهده آن را ندارید.",
|
||||
"taskDetail.notFound.title": "وظیفه پیدا نشد",
|
||||
"taskDetail.openDetail": "باز کردن جزئیات",
|
||||
"taskDetail.pauseTask": "توقف وظیفه",
|
||||
"taskDetail.priority.high": "زیاد",
|
||||
"taskDetail.priority.low": "کم",
|
||||
@@ -925,9 +950,9 @@
|
||||
"workflow.collapse": "جمع کردن",
|
||||
"workflow.expandFull": "نمایش کامل",
|
||||
"workflow.failedSuffix": "(ناموفق)",
|
||||
"workflow.summaryAcrossTools": "در میان {{count}} ابزار",
|
||||
"workflow.summaryCallsLead": "{{count}} تماسها: {{tools}}",
|
||||
"workflow.summaryFailed": "{{count}} مورد ناموفق",
|
||||
"workflow.summaryMoreTools": "{{count}} نوع ابزار",
|
||||
"workflow.summaryTotalCalls": "{{count}} فراخوانی در مجموع",
|
||||
"workflow.thoughtForDuration": "تفکر به مدت {{duration}}",
|
||||
"workflow.toolDisplayName.activateDevice": "دستگاه فعالشده",
|
||||
"workflow.toolDisplayName.activateSkill": "یک مهارت فعال شد",
|
||||
@@ -1043,6 +1068,7 @@
|
||||
"workingPanel.resources.deleteTitle": "Delete document?",
|
||||
"workingPanel.resources.deleteTitleMulti": "حذف {{count}} مورد؟",
|
||||
"workingPanel.resources.empty": "هنوز سندی وجود ندارد. اسناد مرتبط با این عامل در اینجا نمایش داده میشوند.",
|
||||
"workingPanel.resources.emptyDocuments": "هنوز هیچ سندی وجود ندارد. یکی را با + بالا ایجاد کنید.",
|
||||
"workingPanel.resources.error": "Failed to load resources",
|
||||
"workingPanel.resources.filter.documents": "اسناد",
|
||||
"workingPanel.resources.filter.skills": "مهارتها",
|
||||
|
||||
@@ -444,6 +444,23 @@
|
||||
"tab.setting": "تنظیمات",
|
||||
"tab.tasks": "کارها",
|
||||
"tab.video": "ویدیو",
|
||||
"taskTemplate.action.connect.button": "اتصال به {{provider}}",
|
||||
"taskTemplate.action.connect.error": "اتصال ناموفق بود، لطفاً دوباره تلاش کنید.",
|
||||
"taskTemplate.action.connect.popupBlocked": "پنجره اتصال مسدود شده است. برای ادامه، پاپآپها را در مرورگر خود فعال کنید.",
|
||||
"taskTemplate.action.connect.short": "اتصال",
|
||||
"taskTemplate.action.connecting": "در انتظار تأیید...",
|
||||
"taskTemplate.action.create.error": "ایجاد وظیفه ناموفق بود. لطفاً دوباره تلاش کنید.",
|
||||
"taskTemplate.action.create.success": "وظیفه زمانبندیشده اضافه شد. آن را در Lobe AI پیدا کنید.",
|
||||
"taskTemplate.action.createButton": "اضافه کردن وظیفه",
|
||||
"taskTemplate.action.creating": "در حال ایجاد...",
|
||||
"taskTemplate.action.dismiss.error": "رد کردن ناموفق بود. لطفاً دوباره تلاش کنید.",
|
||||
"taskTemplate.action.dismiss.tooltip": "علاقهای ندارم",
|
||||
"taskTemplate.action.refresh.button": "تازهسازی",
|
||||
"taskTemplate.card.templateTag": "الگو",
|
||||
"taskTemplate.schedule.daily": "هر روز در ساعت {{time}}",
|
||||
"taskTemplate.schedule.editableAfterCreateTooltip": "میتوانید زمانبندی را پس از ایجاد وظیفه تنظیم کنید.",
|
||||
"taskTemplate.schedule.weekly": "هر {{weekday}} در ساعت {{time}}",
|
||||
"taskTemplate.section.title": "این وظایف زمانبندیشده را امتحان کنید",
|
||||
"telemetry.allow": "اجازه بده",
|
||||
"telemetry.deny": "رد کن",
|
||||
"telemetry.desc": "ما مایلیم اطلاعات استفاده را بهصورت ناشناس جمعآوری کنیم تا {{appName}} را بهبود دهیم و تجربه بهتری ارائه دهیم. میتوانید این گزینه را در تنظیمات - درباره غیرفعال کنید.",
|
||||
@@ -474,15 +491,14 @@
|
||||
"userPanel.email": "پشتیبانی ایمیلی",
|
||||
"userPanel.feedback": "تماس با ما",
|
||||
"userPanel.help": "مرکز راهنما",
|
||||
"userPanel.inviteFriend": "دعوت از یک دوست",
|
||||
"userPanel.moveGuide": "دکمه تنظیمات به اینجا منتقل شده است",
|
||||
"userPanel.plans": "طرحهای اشتراک",
|
||||
"userPanel.profile": "حساب کاربری",
|
||||
"userPanel.setting": "تنظیمات",
|
||||
"userPanel.upgradePlan": "ارتقاء طرح",
|
||||
"userPanel.usages": "آمار استفاده",
|
||||
"userPanel.workspaceCredits": "اعتبارات فضای کاری",
|
||||
"userPanel.workspaceSetting": "تنظیمات فضای کاری",
|
||||
"userPanel.workspaceUsages": "استفاده از فضای کاری",
|
||||
"version": "نسخه",
|
||||
"zoom": "بزرگنمایی"
|
||||
}
|
||||
|
||||
@@ -101,6 +101,7 @@
|
||||
"LocalFile.action.open": "باز کردن",
|
||||
"LocalFile.action.showInFolder": "نمایش در پوشه",
|
||||
"MaxTokenSlider.unlimited": "نامحدود",
|
||||
"ModelSelect.featureTag.audio": "این مدل از شناسایی ورودی صوتی پشتیبانی میکند.",
|
||||
"ModelSelect.featureTag.custom": "مدل سفارشی که بهطور پیشفرض از تماسهای تابع و تشخیص بصری پشتیبانی میکند. لطفاً بر اساس شرایط واقعی، قابلیتهای فوق را بررسی کنید.",
|
||||
"ModelSelect.featureTag.file": "این مدل از بارگذاری فایل برای خواندن و تشخیص پشتیبانی میکند.",
|
||||
"ModelSelect.featureTag.functionCall": "این مدل از تماسهای تابع پشتیبانی میکند.",
|
||||
@@ -114,6 +115,7 @@
|
||||
"ModelSwitchPanel.byModel": "بر اساس مدل",
|
||||
"ModelSwitchPanel.byProvider": "بر اساس ارائهدهنده",
|
||||
"ModelSwitchPanel.detail.abilities": "قابلیتها",
|
||||
"ModelSwitchPanel.detail.abilities.audio": "صوت",
|
||||
"ModelSwitchPanel.detail.abilities.files": "فایلها",
|
||||
"ModelSwitchPanel.detail.abilities.functionCall": "فراخوانی ابزار",
|
||||
"ModelSwitchPanel.detail.abilities.imageOutput": "خروجی تصویر",
|
||||
|
||||
@@ -38,9 +38,6 @@
|
||||
"brief.title": "گزارش روزانه",
|
||||
"brief.viewAllTasks": "مشاهدهٔ همهٔ وظایف",
|
||||
"brief.viewRun": "مشاهده اجرا",
|
||||
"freeCreditBadge.cta": "شروع آزمایش رایگان",
|
||||
"freeCreditBadge.dismiss": "رد کردن",
|
||||
"freeCreditBadge.label": "اعتبار رایگان انحصاری برای {{model}}",
|
||||
"project.create": "پروژه جدید",
|
||||
"project.deleteConfirm": "این پروژه حذف خواهد شد و امکان بازیابی آن وجود ندارد. برای ادامه تأیید کنید.",
|
||||
"recommendations.heteroAgent.cta": "افزودن عامل",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user