mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-15 20:16:02 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d2f60c078c | |||
| 96d19fe403 |
@@ -51,7 +51,7 @@ export interface GlobalServerConfig {
|
||||
|
||||
### 3. Assemble Server Config (if new domain)
|
||||
|
||||
In `apps/server/src/globalConfig/index.ts`:
|
||||
In `src/server/globalConfig/index.ts`:
|
||||
|
||||
```typescript
|
||||
import { <domain>Env } from '@/envs/<domain>';
|
||||
@@ -97,7 +97,7 @@ AI_IMAGE_DEFAULT_IMAGE_NUM: z.coerce.number().min(1).max(20).optional(),
|
||||
// packages/types/src/serverConfig.ts
|
||||
image?: PartialDeep<UserImageConfig>;
|
||||
|
||||
// apps/server/src/globalConfig/index.ts
|
||||
// src/server/globalConfig/index.ts
|
||||
image: cleanObject({ defaultImageNum: imageEnv.AI_IMAGE_DEFAULT_IMAGE_NUM }),
|
||||
|
||||
// src/store/user/slices/common/action.ts
|
||||
|
||||
@@ -50,14 +50,14 @@ execAgent({ hooks })
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Role |
|
||||
| --------------------------------------------------------------- | ------------------------------------------------------ |
|
||||
| `packages/agent-runtime/src/types/hooks.ts` | Type definitions (AgentHookType, all event interfaces) |
|
||||
| `apps/server/src/services/agentRuntime/hooks/types.ts` | Server-side types (AgentHook, re-exports) |
|
||||
| `apps/server/src/services/agentRuntime/hooks/HookDispatcher.ts` | Registration, dispatch, dispatchBeforeToolCall |
|
||||
| `apps/server/src/modules/AgentRuntime/RuntimeExecutors.ts` | Tool/Compact/HumanIntervention hook dispatch |
|
||||
| `apps/server/src/services/agentRuntime/AgentRuntimeService.ts` | Step hooks + HumanIntervention resume/reject |
|
||||
| `apps/server/src/services/aiAgent/index.ts` | CallAgent hook dispatch |
|
||||
| File | Role |
|
||||
| ---------------------------------------------------------- | ------------------------------------------------------ |
|
||||
| `packages/agent-runtime/src/types/hooks.ts` | Type definitions (AgentHookType, all event interfaces) |
|
||||
| `src/server/services/agentRuntime/hooks/types.ts` | Server-side types (AgentHook, re-exports) |
|
||||
| `src/server/services/agentRuntime/hooks/HookDispatcher.ts` | Registration, dispatch, dispatchBeforeToolCall |
|
||||
| `src/server/modules/AgentRuntime/RuntimeExecutors.ts` | Tool/Compact/HumanIntervention hook dispatch |
|
||||
| `src/server/services/agentRuntime/AgentRuntimeService.ts` | Step hooks + HumanIntervention resume/reject |
|
||||
| `src/server/services/aiAgent/index.ts` | CallAgent hook dispatch |
|
||||
|
||||
## Registration Flow
|
||||
|
||||
|
||||
@@ -26,9 +26,9 @@ Agent Signal has one consistent shape:
|
||||
|
||||
Read:
|
||||
|
||||
- `apps/server/src/services/agentSignal/index.ts`
|
||||
- `apps/server/src/workflows/agentSignal/index.ts`
|
||||
- `apps/server/src/workflows/agentSignal/run.ts`
|
||||
- `src/server/services/agentSignal/index.ts`
|
||||
- `src/server/workflows/agentSignal/index.ts`
|
||||
- `src/server/workflows/agentSignal/run.ts`
|
||||
|
||||
## Core Model
|
||||
|
||||
@@ -48,11 +48,11 @@ Keep the boundaries strict:
|
||||
## Implementation Workflow
|
||||
|
||||
1. Decide whether the use case is synchronous or quiet background work.
|
||||
2. Define or reuse a source type in `apps/server/src/services/agentSignal/sourceTypes.ts`.
|
||||
3. Define or reuse signal and action types in `apps/server/src/services/agentSignal/policies/types.ts`.
|
||||
2. Define or reuse a source type in `src/server/services/agentSignal/sourceTypes.ts`.
|
||||
3. Define or reuse signal and action types in `src/server/services/agentSignal/policies/types.ts`.
|
||||
4. Implement handlers with `defineSourceHandler`, `defineSignalHandler`, or `defineActionHandler`.
|
||||
5. Bundle handlers with `defineAgentSignalHandlers(...)`.
|
||||
6. Register the policy in `apps/server/src/services/agentSignal/policies/index.ts` and pass it into the runtime factory if needed.
|
||||
6. Register the policy in `src/server/services/agentSignal/policies/index.ts` and pass it into the runtime factory if needed.
|
||||
7. Add or update ingress code that emits or enqueues the source event.
|
||||
8. Add observability and tests before considering the flow complete.
|
||||
|
||||
@@ -63,19 +63,19 @@ Keep the boundaries strict:
|
||||
`packages/agent-signal/src/base/builders.ts`
|
||||
`packages/agent-signal/src/base/types.ts`
|
||||
- Server-owned runtime and middleware:
|
||||
`apps/server/src/services/agentSignal/runtime/AgentSignalRuntime.ts`
|
||||
`apps/server/src/services/agentSignal/runtime/AgentSignalScheduler.ts`
|
||||
`apps/server/src/services/agentSignal/runtime/middleware.ts`
|
||||
`apps/server/src/services/agentSignal/runtime/context.ts`
|
||||
`src/server/services/agentSignal/runtime/AgentSignalRuntime.ts`
|
||||
`src/server/services/agentSignal/runtime/AgentSignalScheduler.ts`
|
||||
`src/server/services/agentSignal/runtime/middleware.ts`
|
||||
`src/server/services/agentSignal/runtime/context.ts`
|
||||
- Existing policy example:
|
||||
`apps/server/src/services/agentSignal/policies/analyzeIntent/index.ts`
|
||||
`apps/server/src/services/agentSignal/policies/analyzeIntent/feedbackSatisfaction.ts`
|
||||
`apps/server/src/services/agentSignal/policies/analyzeIntent/feedbackDomain.ts`
|
||||
`apps/server/src/services/agentSignal/policies/analyzeIntent/feedbackAction.ts`
|
||||
`apps/server/src/services/agentSignal/policies/analyzeIntent/actions/userMemory.ts`
|
||||
`src/server/services/agentSignal/policies/analyzeIntent/index.ts`
|
||||
`src/server/services/agentSignal/policies/analyzeIntent/feedbackSatisfaction.ts`
|
||||
`src/server/services/agentSignal/policies/analyzeIntent/feedbackDomain.ts`
|
||||
`src/server/services/agentSignal/policies/analyzeIntent/feedbackAction.ts`
|
||||
`src/server/services/agentSignal/policies/analyzeIntent/actions/userMemory.ts`
|
||||
- Observability:
|
||||
`apps/server/src/services/agentSignal/observability/projector.ts`
|
||||
`apps/server/src/services/agentSignal/observability/traceEvents.ts`
|
||||
`src/server/services/agentSignal/observability/projector.ts`
|
||||
`src/server/services/agentSignal/observability/traceEvents.ts`
|
||||
`packages/observability-otel/src/modules/agent-signal/index.ts`
|
||||
|
||||
## Implementation Rules
|
||||
@@ -86,7 +86,7 @@ Keep the boundaries strict:
|
||||
- Use stable ids and idempotency keys when the same source can arrive more than once.
|
||||
- Preserve scope discipline. The runtime uses `scopeKey` to serialize related background work.
|
||||
- Prefer the dedicated shared package types and builders from `@lobechat/agent-signal` for normalized nodes and result contracts.
|
||||
- Add focused tests near the touched runtime, policy, or store module. Existing tests under `apps/server/src/services/agentSignal/**/__tests__` are the reference pattern.
|
||||
- Add focused tests near the touched runtime, policy, or store module. Existing tests under `src/server/services/agentSignal/**/__tests__` are the reference pattern.
|
||||
|
||||
## References
|
||||
|
||||
|
||||
@@ -32,9 +32,9 @@ source node
|
||||
|
||||
Read:
|
||||
|
||||
- `apps/server/src/services/agentSignal/index.ts`
|
||||
- `apps/server/src/services/agentSignal/sources/index.ts`
|
||||
- `apps/server/src/services/agentSignal/runtime/AgentSignalScheduler.ts`
|
||||
- `src/server/services/agentSignal/index.ts`
|
||||
- `src/server/services/agentSignal/sources/index.ts`
|
||||
- `src/server/services/agentSignal/runtime/AgentSignalScheduler.ts`
|
||||
|
||||
## Package Boundaries
|
||||
|
||||
@@ -56,7 +56,7 @@ Read:
|
||||
- `packages/agent-signal/src/types/events.ts`
|
||||
- `packages/agent-signal/src/types/builtin.ts`
|
||||
|
||||
### `apps/server/src/services/agentSignal`
|
||||
### `src/server/services/agentSignal`
|
||||
|
||||
Treat this as the server-owned implementation layer.
|
||||
|
||||
@@ -89,11 +89,11 @@ Examples:
|
||||
|
||||
Define source payloads in:
|
||||
|
||||
- `apps/server/src/services/agentSignal/sourceTypes.ts`
|
||||
- `src/server/services/agentSignal/sourceTypes.ts`
|
||||
|
||||
Build normalized sources in:
|
||||
|
||||
- `apps/server/src/services/agentSignal/sources/buildSource.ts`
|
||||
- `src/server/services/agentSignal/sources/buildSource.ts`
|
||||
- `packages/agent-signal/src/base/builders.ts`
|
||||
|
||||
### Signal
|
||||
@@ -109,7 +109,7 @@ Examples from `analyzeIntent`:
|
||||
|
||||
Define server-owned signal types in:
|
||||
|
||||
- `apps/server/src/services/agentSignal/policies/types.ts`
|
||||
- `src/server/services/agentSignal/policies/types.ts`
|
||||
|
||||
### Action
|
||||
|
||||
@@ -157,9 +157,9 @@ When a user asks for "the procedure", document the flow above and point to the e
|
||||
|
||||
Read:
|
||||
|
||||
- `apps/server/src/services/agentSignal/sources/index.ts`
|
||||
- `apps/server/src/services/agentSignal/runtime/context.ts`
|
||||
- `apps/server/src/services/agentSignal/constants.ts`
|
||||
- `src/server/services/agentSignal/sources/index.ts`
|
||||
- `src/server/services/agentSignal/runtime/context.ts`
|
||||
- `src/server/services/agentSignal/constants.ts`
|
||||
|
||||
Use `enqueueAgentSignalSourceEvent(...)` when the work should stay quiet and out-of-band. That path:
|
||||
|
||||
@@ -172,8 +172,8 @@ This is the preferred path when the UI request should finish immediately and the
|
||||
|
||||
Read:
|
||||
|
||||
- `apps/server/src/workflows/agentSignal/index.ts`
|
||||
- `apps/server/src/workflows/agentSignal/run.ts`
|
||||
- `src/server/workflows/agentSignal/index.ts`
|
||||
- `src/server/workflows/agentSignal/run.ts`
|
||||
|
||||
## Existing Example: `analyzeIntent`
|
||||
|
||||
@@ -192,8 +192,8 @@ agent.user.message
|
||||
|
||||
Read:
|
||||
|
||||
- `apps/server/src/services/agentSignal/policies/analyzeIntent/index.ts`
|
||||
- `apps/server/src/services/agentSignal/policies/analyzeIntent/feedbackSatisfaction.ts`
|
||||
- `apps/server/src/services/agentSignal/policies/analyzeIntent/feedbackDomain.ts`
|
||||
- `apps/server/src/services/agentSignal/policies/analyzeIntent/feedbackAction.ts`
|
||||
- `apps/server/src/services/agentSignal/policies/analyzeIntent/actions/userMemory.ts`
|
||||
- `src/server/services/agentSignal/policies/analyzeIntent/index.ts`
|
||||
- `src/server/services/agentSignal/policies/analyzeIntent/feedbackSatisfaction.ts`
|
||||
- `src/server/services/agentSignal/policies/analyzeIntent/feedbackDomain.ts`
|
||||
- `src/server/services/agentSignal/policies/analyzeIntent/feedbackAction.ts`
|
||||
- `src/server/services/agentSignal/policies/analyzeIntent/actions/userMemory.ts`
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Fluent Registration API
|
||||
|
||||
Use the middleware helpers in `apps/server/src/services/agentSignal/runtime/middleware.ts`.
|
||||
Use the middleware helpers in `src/server/services/agentSignal/runtime/middleware.ts`.
|
||||
|
||||
They provide:
|
||||
|
||||
@@ -32,7 +32,7 @@ The context gives you:
|
||||
|
||||
Read:
|
||||
|
||||
- `apps/server/src/services/agentSignal/runtime/context.ts`
|
||||
- `src/server/services/agentSignal/runtime/context.ts`
|
||||
|
||||
## Return Contracts
|
||||
|
||||
@@ -48,7 +48,7 @@ Return one of these shapes:
|
||||
Read:
|
||||
|
||||
- `packages/agent-signal/src/base/types.ts`
|
||||
- `apps/server/src/services/agentSignal/runtime/AgentSignalScheduler.ts`
|
||||
- `src/server/services/agentSignal/runtime/AgentSignalScheduler.ts`
|
||||
|
||||
## Policy Composition Pattern
|
||||
|
||||
@@ -72,8 +72,8 @@ That bundle is later passed into the runtime via:
|
||||
|
||||
Read:
|
||||
|
||||
- `apps/server/src/services/agentSignal/policies/index.ts`
|
||||
- `apps/server/src/services/agentSignal/policies/analyzeIntent/index.ts`
|
||||
- `src/server/services/agentSignal/policies/index.ts`
|
||||
- `src/server/services/agentSignal/policies/analyzeIntent/index.ts`
|
||||
|
||||
## Source Handler Pattern
|
||||
|
||||
@@ -81,7 +81,7 @@ Use a source handler when you are interpreting a producer event into semantic si
|
||||
|
||||
Reference:
|
||||
|
||||
- `apps/server/src/services/agentSignal/policies/analyzeIntent/feedbackSatisfaction.ts`
|
||||
- `src/server/services/agentSignal/policies/analyzeIntent/feedbackSatisfaction.ts`
|
||||
|
||||
Pattern:
|
||||
|
||||
@@ -114,8 +114,8 @@ Use a signal handler when one semantic state should branch into more semantic st
|
||||
|
||||
References:
|
||||
|
||||
- `apps/server/src/services/agentSignal/policies/analyzeIntent/feedbackDomain.ts`
|
||||
- `apps/server/src/services/agentSignal/policies/analyzeIntent/feedbackAction.ts`
|
||||
- `src/server/services/agentSignal/policies/analyzeIntent/feedbackDomain.ts`
|
||||
- `src/server/services/agentSignal/policies/analyzeIntent/feedbackAction.ts`
|
||||
|
||||
Pattern:
|
||||
|
||||
@@ -148,7 +148,7 @@ Use an action handler when the runtime should do actual work.
|
||||
|
||||
Reference:
|
||||
|
||||
- `apps/server/src/services/agentSignal/policies/analyzeIntent/actions/userMemory.ts`
|
||||
- `src/server/services/agentSignal/policies/analyzeIntent/actions/userMemory.ts`
|
||||
|
||||
Pattern:
|
||||
|
||||
@@ -186,9 +186,9 @@ Keep these rules:
|
||||
Use this split:
|
||||
|
||||
- external event payloads:
|
||||
`apps/server/src/services/agentSignal/sourceTypes.ts`
|
||||
`src/server/services/agentSignal/sourceTypes.ts`
|
||||
- policy-owned signal and action payloads:
|
||||
`apps/server/src/services/agentSignal/policies/types.ts`
|
||||
`src/server/services/agentSignal/policies/types.ts`
|
||||
- normalized shared node contracts:
|
||||
`packages/agent-signal/src/base/types.ts`
|
||||
|
||||
@@ -216,10 +216,10 @@ Prefer focused tests near the touched code.
|
||||
|
||||
Useful references:
|
||||
|
||||
- `apps/server/src/services/agentSignal/runtime/__tests__/AgentSignalRuntime.test.ts`
|
||||
- `apps/server/src/services/agentSignal/__tests__/index.integration.test.ts`
|
||||
- `apps/server/src/services/agentSignal/policies/analyzeIntent/__tests__/*`
|
||||
- `apps/server/src/services/agentSignal/policies/analyzeIntent/actions/__tests__/*`
|
||||
- `src/server/services/agentSignal/runtime/__tests__/AgentSignalRuntime.test.ts`
|
||||
- `src/server/services/agentSignal/__tests__/index.integration.test.ts`
|
||||
- `src/server/services/agentSignal/policies/analyzeIntent/__tests__/*`
|
||||
- `src/server/services/agentSignal/policies/analyzeIntent/actions/__tests__/*`
|
||||
|
||||
Test at the smallest level that proves the behavior:
|
||||
|
||||
|
||||
@@ -24,9 +24,9 @@ After runtime execution, the service projects one compact observability model fr
|
||||
|
||||
Read:
|
||||
|
||||
- `apps/server/src/services/agentSignal/observability/projector.ts`
|
||||
- `apps/server/src/services/agentSignal/observability/traceEvents.ts`
|
||||
- `apps/server/src/services/agentSignal/observability/store.ts`
|
||||
- `src/server/services/agentSignal/observability/projector.ts`
|
||||
- `src/server/services/agentSignal/observability/traceEvents.ts`
|
||||
- `src/server/services/agentSignal/observability/store.ts`
|
||||
|
||||
Projection outputs:
|
||||
|
||||
@@ -58,7 +58,7 @@ Workflow-triggered runs do not naturally pass through the normal foreground runt
|
||||
|
||||
Read:
|
||||
|
||||
- `apps/server/src/workflows/agentSignal/run.ts`
|
||||
- `src/server/workflows/agentSignal/run.ts`
|
||||
|
||||
Use that path when:
|
||||
|
||||
@@ -77,8 +77,8 @@ Check:
|
||||
|
||||
Read:
|
||||
|
||||
- `apps/server/src/services/agentSignal/index.ts`
|
||||
- `apps/server/src/services/agentSignal/sources/index.ts`
|
||||
- `src/server/services/agentSignal/index.ts`
|
||||
- `src/server/services/agentSignal/sources/index.ts`
|
||||
|
||||
### The signal exists but no action runs
|
||||
|
||||
@@ -98,8 +98,8 @@ Check:
|
||||
|
||||
Reference:
|
||||
|
||||
- `apps/server/src/services/agentSignal/policies/actionIdempotency.ts`
|
||||
- `apps/server/src/services/agentSignal/policies/analyzeIntent/actions/userMemory.ts`
|
||||
- `src/server/services/agentSignal/policies/actionIdempotency.ts`
|
||||
- `src/server/services/agentSignal/policies/analyzeIntent/actions/userMemory.ts`
|
||||
|
||||
### Background runs are hard to discover
|
||||
|
||||
|
||||
@@ -216,6 +216,6 @@ When using `--messages`, the output shows three sections (if context engine data
|
||||
|
||||
## Integration Points
|
||||
|
||||
- **Recording**: `apps/server/src/services/agentRuntime/AgentRuntimeService.ts` — in the `executeStep()` method, after building `stepPresentationData`, writes partial snapshot in dev mode
|
||||
- **Context engine capture**: `apps/server/src/modules/AgentRuntime/RuntimeExecutors.ts` — in `call_llm` executor, after `serverMessagesEngine()` returns, calls `ctx.tracingContextEngine(input, output)`. `AgentRuntimeService.executeStep` buffers it per step and passes it to `traceRecorder.appendStep` as the typed `contextEngine` field (kept off the `events` array to stay out of Redis state).
|
||||
- **Recording**: `src/server/services/agentRuntime/AgentRuntimeService.ts` — in the `executeStep()` method, after building `stepPresentationData`, writes partial snapshot in dev mode
|
||||
- **Context engine capture**: `src/server/modules/AgentRuntime/RuntimeExecutors.ts` — in `call_llm` executor, after `serverMessagesEngine()` returns, calls `ctx.tracingContextEngine(input, output)`. `AgentRuntimeService.executeStep` buffers it per step and passes it to `traceRecorder.appendStep` as the typed `contextEngine` field (kept off the `events` array to stay out of Redis state).
|
||||
- **Store**: `FileSnapshotStore` reads/writes to `.agent-tracing/` relative to `process.cwd()`
|
||||
|
||||
@@ -271,7 +271,7 @@ Lists in the same file you may need to touch:
|
||||
|
||||
- `defaultToolIds` — added to the agent's tool list by default
|
||||
- `alwaysOnToolIds` — forced on regardless of user selection (use sparingly)
|
||||
- `runtimeManagedToolIds` — enable state controlled by runtime, not user UI; **must mirror the rules map** in `apps/server/src/modules/Mecha/AgentToolsEngine/index.ts` and `src/helpers/toolEngineering/index.ts`
|
||||
- `runtimeManagedToolIds` — enable state controlled by runtime, not user UI; **must mirror the rules map** in `src/server/modules/Mecha/AgentToolsEngine/index.ts` and `src/helpers/toolEngineering/index.ts`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ lsof -ti:3011 | xargs kill
|
||||
pnpm run dev:next
|
||||
```
|
||||
|
||||
**Important:** Server-side code changes in the submodule (`lobehub/apps/server/src/`, `lobehub/src/server/`, `lobehub/packages/`) require a server restart. Next.js hot-reload may not pick up changes in submodule packages.
|
||||
**Important:** Server-side code changes in the submodule (`lobehub/src/server/`, `lobehub/packages/`) require a server restart. Next.js hot-reload may not pick up changes in submodule packages.
|
||||
|
||||
### Step 2: Check CLI Authentication
|
||||
|
||||
@@ -150,15 +150,14 @@ $CLI provider test <provider-id>
|
||||
|
||||
### When Server Restart is Needed
|
||||
|
||||
| Change Location | Restart? |
|
||||
| ------------------------------------------------------- | -------- |
|
||||
| `lobehub/apps/server/src/` (routers, services, modules) | Yes |
|
||||
| `lobehub/src/server/` (agent-hono, workflows-hono) | Yes |
|
||||
| `lobehub/packages/database/` (models) | Yes |
|
||||
| `lobehub/packages/types/` | Yes |
|
||||
| `lobehub/packages/prompts/` | Yes |
|
||||
| `lobehub/apps/cli/` (CLI code) | No |
|
||||
| `src/` (cloud overrides) | Yes |
|
||||
| Change Location | Restart? |
|
||||
| ----------------------------------------- | -------- |
|
||||
| `lobehub/src/server/` (routers, services) | Yes |
|
||||
| `lobehub/packages/database/` (models) | Yes |
|
||||
| `lobehub/packages/types/` | Yes |
|
||||
| `lobehub/packages/prompts/` | Yes |
|
||||
| `lobehub/apps/cli/` (CLI code) | No |
|
||||
| `src/` (cloud overrides) | Yes |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@ Generate video from text prompt. This is an async operation.
|
||||
**Source**: `apps/cli/src/commands/generate/video.ts`
|
||||
|
||||
```bash
|
||||
lh gen video "A cat playing piano" -m < model > -p < provider > [options]
|
||||
lh gen video "A cat playing piano" -m <model> -p <provider> [options]
|
||||
```
|
||||
|
||||
| Option | Description | Required |
|
||||
@@ -259,13 +259,13 @@ Image and video generation use an async task pattern:
|
||||
UUID from the `async_tasks` table, not `gen_xxx`
|
||||
- Returns `{ status, error, generation }` (generation includes asset URLs on success)
|
||||
- Before querying, calls `checkTimeoutTasks` which marks tasks as `error` if they have been
|
||||
`pending` or `processing` for more than \~5 minutes (`ASYNC_TASK_TIMEOUT = 298s`)
|
||||
`pending` or `processing` for more than ~5 minutes (`ASYNC_TASK_TIMEOUT = 298s`)
|
||||
|
||||
**Server routes**:
|
||||
|
||||
- `apps/server/src/routers/lambda/image/index.ts` — image creation (uses `authedProcedure` + `serverDatabase`)
|
||||
- `apps/server/src/routers/lambda/video/index.ts` — video creation (uses `authedProcedure` + `serverDatabase`)
|
||||
- `apps/server/src/routers/lambda/generation.ts` — status checking
|
||||
- `src/server/routers/lambda/image/index.ts` — image creation (uses `authedProcedure` + `serverDatabase`)
|
||||
- `src/server/routers/lambda/video/index.ts` — video creation (uses `authedProcedure` + `serverDatabase`)
|
||||
- `src/server/routers/lambda/generation.ts` — status checking
|
||||
- `packages/database/src/models/asyncTask.ts` — `AsyncTaskModel` including `checkTimeoutTasks`
|
||||
|
||||
**Note**: Image/video routes do NOT use the `keyVaults` middleware — they read API keys from the database via `initModelRuntimeFromDB` or `createAsyncCaller`.
|
||||
|
||||
@@ -6,66 +6,6 @@ user-invocable: false
|
||||
|
||||
# Database Migrations Guide
|
||||
|
||||
## Development-stage schema changes
|
||||
|
||||
Schema changes churn during feature development. When the schema changes before the migration has shipped, do not hand-edit the existing migration SQL to chase the new schema shape. Delete the draft migration artifacts added by this branch (SQL file, matching snapshot, and matching journal entry), then run the generator again and re-apply the normal migration review steps below.
|
||||
|
||||
For example, if this branch's draft migration is `0110_add_verify_tables_and_ai_infra_id`:
|
||||
|
||||
```bash
|
||||
# 1. Delete the draft SQL and its snapshot
|
||||
rm packages/database/migrations/0110_add_verify_tables_and_ai_infra_id.sql
|
||||
rm packages/database/migrations/meta/0110_snapshot.json
|
||||
|
||||
# 2. Remove the matching 0110 entry from the journal's "entries" array
|
||||
# packages/database/migrations/meta/_journal.json
|
||||
|
||||
# 3. Regenerate from the current schema
|
||||
bun run db:generate
|
||||
```
|
||||
|
||||
This keeps the generated SQL, snapshot, and journal aligned with the actual schema. Manual SQL edits are reserved for review-time hardening such as idempotent clauses, custom extension SQL, and meaningful filename/tag updates.
|
||||
|
||||
Before release, if a feature branch accumulated multiple development-only migrations, consolidate them into one migration when possible. Production does not need to replay every intermediate draft shape, and fewer migrations reduce deploy-time risk.
|
||||
|
||||
For example, if this branch added `0110`, `0111`, and `0112`, delete all three drafts and regenerate a single migration:
|
||||
|
||||
```bash
|
||||
# 1. Delete every draft SQL and snapshot this branch added
|
||||
rm packages/database/migrations/011{0,1,2}_*.sql
|
||||
rm packages/database/migrations/meta/011{0,1,2}_snapshot.json
|
||||
|
||||
# 2. Remove the 0110/0111/0112 entries from the journal's "entries" array
|
||||
# packages/database/migrations/meta/_journal.json
|
||||
|
||||
# 3. Regenerate one migration covering the full schema delta
|
||||
bun run db:generate
|
||||
```
|
||||
|
||||
Do not make a migration compatible with earlier development-only versions of the same branch. While the migration has not shipped, there is no production history to preserve. Fix local/dev databases directly with whatever SQL is simplest (drop the draft table, rename a column, delete draft rows), then regenerate the branch migration from the current schema.
|
||||
|
||||
For example, if an earlier draft on this branch created `signup_attempt_id` and you have since renamed it to `user_signup_log_id`, do not add a compatibility `ALTER ... RENAME` to the migration. Just fix the dev DB directly (see the `access-pg` skill for the `bun -e` + `pg` pattern), then regenerate:
|
||||
|
||||
```bash
|
||||
# Fix the dev DB to match the new schema (simplest SQL wins)
|
||||
set -a && source .env && set +a && bun -e '
|
||||
import pg from "pg";
|
||||
const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
|
||||
await client.connect();
|
||||
await client.query("ALTER TABLE user_signup_logs DROP COLUMN signup_attempt_id");
|
||||
await client.end();
|
||||
'
|
||||
|
||||
# Regenerate so the migration reflects only the final shape
|
||||
bun run db:generate
|
||||
```
|
||||
|
||||
After a migration has reached production or the target default branch, treat it as immutable: add a follow-up migration instead of rewriting it.
|
||||
|
||||
## Rebase conflicts
|
||||
|
||||
When a rebase conflicts in migration files, keep the upstream/default-branch migrations and remove all migrations introduced by the current feature branch. Complete the rebase, then regenerate this branch's migration from the rebased schema. This avoids merging two independent snapshots or hand-splicing journal entries.
|
||||
|
||||
## Step 1: Generate Migrations
|
||||
|
||||
```bash
|
||||
|
||||
@@ -57,7 +57,7 @@ process.env.DEBUG = 'lobe-*';
|
||||
## Example
|
||||
|
||||
```typescript
|
||||
// apps/server/src/routers/edge/market/index.ts
|
||||
// src/server/routers/edge/market/index.ts
|
||||
import debug from 'debug';
|
||||
|
||||
const log = debug('lobe-edge-router:market');
|
||||
|
||||
+60
-152
@@ -6,14 +6,6 @@ user-invocable: false
|
||||
|
||||
# Drizzle ORM Schema Style Guide
|
||||
|
||||
> **Adding a Model or Repository?** Ship a sibling test in the same PR — every new
|
||||
> file under `packages/database/src/models/**` or `src/repositories/**` needs a
|
||||
> matching `__tests__/<name>.test.ts`. See the **testing** skill
|
||||
> (`.agents/skills/testing/references/db-model-test.md`) for the `getTestDB()`
|
||||
> integration pattern, user-isolation tests, the BM25 `describe.skipIf(!isServerDB)`
|
||||
> guard, and schema gotchas. CI's coverage patch gate won't reliably catch a brand-new
|
||||
> untested file, so this is on you.
|
||||
|
||||
## Configuration
|
||||
|
||||
- Config: `drizzle.config.ts`
|
||||
@@ -33,42 +25,16 @@ Location: `packages/database/src/schemas/_helpers.ts`
|
||||
|
||||
- **Tables**: Plural snake_case (`users`, `session_groups`)
|
||||
- **Columns**: snake_case (`user_id`, `created_at`)
|
||||
- **New tables**: Check nearby existing tables before naming a new one. Preserve
|
||||
the established noun family and suffix. For example, if the user-scoped table
|
||||
is `user_xxx_logs`, the workspace-scoped counterpart should be
|
||||
`workspace_xxx_logs`, not `workspace_xxx_records` or another new synonym.
|
||||
|
||||
```typescript
|
||||
// ✅ Good: follows the existing user/workspace table family.
|
||||
export const userSignupLogs = pgTable('user_signup_logs', { ... });
|
||||
export const workspaceSignupLogs = pgTable('workspace_signup_logs', { ... });
|
||||
|
||||
// ❌ Bad: introduces a new suffix for the same concept.
|
||||
export const workspaceSignupRecords = pgTable('workspace_signup_records', { ... });
|
||||
```
|
||||
|
||||
## Column Definitions
|
||||
|
||||
### Primary Keys
|
||||
|
||||
Do not use auto-incrementing primary keys (`serial`, `bigserial`, generated
|
||||
identity columns). They create sequence-state problems during cross-database
|
||||
migrations, restores, and data copy jobs. Prefer text IDs from application
|
||||
generators (`idGenerator`, `createNanoId`) or `uuid` for internal tables.
|
||||
|
||||
Keep `$defaultFn(...)` when a table normally owns ID generation. Callers can
|
||||
still pass an explicit `id`; the default only runs when the insert omits it. Do
|
||||
not remove the default just because one flow needs to supply a request-scoped ID.
|
||||
|
||||
```typescript
|
||||
// ✅ Good: app-generated text ID; explicit inserts can still override it.
|
||||
id: text('id')
|
||||
.primaryKey()
|
||||
.$defaultFn(() => idGenerator('agents'))
|
||||
.notNull(),
|
||||
|
||||
// ❌ Bad: sequence state is fragile across DB migrations and restores.
|
||||
id: serial('id').primaryKey(),
|
||||
```
|
||||
|
||||
ID prefixes make entity types distinguishable. For internal tables, use `uuid`.
|
||||
@@ -87,80 +53,6 @@ userId: text('user_id')
|
||||
...timestamps, // Spread from _helpers.ts
|
||||
```
|
||||
|
||||
### Optional and Undefined Values
|
||||
|
||||
Do not introduce artificial sentinel strings for missing values, such as
|
||||
`unknown`, unless the domain already has that explicit state and existing code
|
||||
uses it consistently. Prefer nullable columns, optional TypeScript fields, or a
|
||||
separate concrete status enum when the value is genuinely absent.
|
||||
|
||||
```typescript
|
||||
// ✅ Good: absent until the final stage writes a real decision.
|
||||
export type UserSignupLogFinalDecision = 'allow' | 'block' | 'error';
|
||||
|
||||
finalDecision: varchar('final_decision', { length: 32 }).$type<UserSignupLogFinalDecision>(),
|
||||
|
||||
// ❌ Bad: invents a new state that callers now need to handle everywhere.
|
||||
export type UserSignupLogFinalDecision = 'allow' | 'block' | 'error' | 'unknown';
|
||||
|
||||
finalDecision: varchar('final_decision', { length: 32 })
|
||||
.$type<UserSignupLogFinalDecision>()
|
||||
.notNull()
|
||||
.default('unknown');
|
||||
```
|
||||
|
||||
### Field Descriptions
|
||||
|
||||
For columns whose meaning is not obvious from the name alone, add JSDoc on the
|
||||
schema field. Include a concrete example when it clarifies the stored value or
|
||||
the lifecycle moment that writes it. This is especially important for external
|
||||
IDs, lifecycle statuses, denormalized snapshots, JSONB signals, and fields whose
|
||||
name could mean either a request ID or a persisted row ID.
|
||||
|
||||
```typescript
|
||||
// ✅ Good: explain the table's business object first, then only document
|
||||
// non-obvious lifecycle or risk-control fields.
|
||||
/**
|
||||
* User signup logs - one row per signup flow, collecting stage-level
|
||||
* risk-control decisions before and after the auth provider creates a user.
|
||||
*/
|
||||
export const userSignupLogs = pgTable('user_signup_logs', {
|
||||
/** Final signup outcome reason, for example user_created, llm_block, or guard_error */
|
||||
finalReason: text('final_reason'),
|
||||
|
||||
/** Aggregated risk level derived from stage decisions, for example block -> high */
|
||||
riskLevel: varchar('risk_level', { length: 16 }).$type<UserSignupLogRiskLevel>(),
|
||||
|
||||
/** Ordered stage-level decisions and metadata grouped by signup review stage */
|
||||
stageResults: jsonb('stage_results').$type<UserSignupLogStageResults>(),
|
||||
});
|
||||
|
||||
// ❌ Bad: comments restate obvious column names without adding domain meaning.
|
||||
/** User email */
|
||||
email: text('email'),
|
||||
```
|
||||
|
||||
### JSONB Types
|
||||
|
||||
Avoid `Record<string, unknown>` or similarly loose JSONB types for schema
|
||||
columns. Define a concrete interface that describes the expected JSON shape, even
|
||||
when most properties are optional. This keeps callers, migrations, and review
|
||||
queries aligned on the same data contract.
|
||||
|
||||
```typescript
|
||||
interface UserSignupLogMetadata {
|
||||
payloadPath?: string;
|
||||
requestPath?: string;
|
||||
}
|
||||
|
||||
metadata: jsonb('metadata').$type<UserSignupLogMetadata>(),
|
||||
```
|
||||
|
||||
```typescript
|
||||
// ❌ Bad: hides the contract and makes downstream access untyped.
|
||||
metadata: jsonb('metadata').$type<Record<string, unknown>>(),
|
||||
```
|
||||
|
||||
### Indexes
|
||||
|
||||
```typescript
|
||||
@@ -284,52 +176,66 @@ const rows = await this.db
|
||||
|
||||
### Raw SQL and Advanced Queries
|
||||
|
||||
Prefer Drizzle builders whenever the query reads clearly with `select`,
|
||||
`insert().select()`, `update().from()`, joins, CTEs, and `groupBy` — this keeps
|
||||
table/column references tied to schema, so changes surface as TypeScript errors.
|
||||
Within a builder, expression-level `sql<T>` is fine for features lacking a helper
|
||||
(JSON path, casts, aggregates, `CASE`, `NOW()`). Row locks are clauses, not
|
||||
expressions — use `.for('update')`, never raw `FOR UPDATE`.
|
||||
Prefer Drizzle builders whenever the query can be expressed clearly with `select`,
|
||||
`insert().select()`, `update().from()`, joins, CTEs, `groupBy`, and typed selected
|
||||
columns. This keeps table and column references tied to schema definitions, so
|
||||
schema changes are more likely to surface as TypeScript errors.
|
||||
|
||||
Use `COALESCE` only when null-handling is part of required DB semantics (nullable
|
||||
JSONB append/merge, "keep first non-null"). Don't scatter
|
||||
`COALESCE(excluded.col, current.col)` across ordinary upsert scalars just to avoid
|
||||
an update object — build `set` from defined values only, and hide any remaining
|
||||
SQL behind named helpers (`appendJsonbArray`, `mergeJsonbObject`, `keepFirstValue`)
|
||||
so the method reads as business intent, not SQL plumbing.
|
||||
|
||||
```typescript
|
||||
// ✅ Scalars included only when present; SQL hidden behind a named helper.
|
||||
const updateValues = compactUndefined({
|
||||
email: record.email ?? undefined,
|
||||
ip: record.ip ?? undefined,
|
||||
});
|
||||
await db.insert(userSignupLogs).values(values).onConflictDoUpdate({
|
||||
set: { ...updateValues, stageResults: appendStageResult(stage, result), updatedAt: now },
|
||||
target: userSignupLogs.id,
|
||||
});
|
||||
|
||||
// ❌ Every scalar becomes SQL plumbing.
|
||||
set: {
|
||||
email: sql`COALESCE(excluded.email, ${userSignupLogs.email})`,
|
||||
ip: sql`COALESCE(excluded.ip, ${userSignupLogs.ip})`,
|
||||
}
|
||||
```
|
||||
Expression-level `sql<T>` is fine inside a Drizzle builder for PostgreSQL features
|
||||
that do not have a dedicated helper, such as JSON path extraction, casts, aggregate
|
||||
expressions, `CASE`, `NOW()`, or advisory locks. Row locks are query clauses, not
|
||||
expressions; use the select builder's `.for('update')` instead of raw
|
||||
`FOR UPDATE` SQL fragments.
|
||||
|
||||
When refactoring raw SQL:
|
||||
|
||||
- Preserve query shape on latency-sensitive paths. If raw SQL is one roundtrip,
|
||||
don't split it into multiple depth-based queries just to drop `execute`.
|
||||
- Use `$with(...)` + `insert().select()` / `update().from()` for multi-step
|
||||
single-roundtrip writes Drizzle can express.
|
||||
- Don't rely on `execute<MyRow>(sql...)` for safety — it types rows but doesn't keep
|
||||
selected columns in sync with schema changes.
|
||||
- If only a PostgreSQL feature Drizzle can't express works, keep the raw SQL and
|
||||
tighten it: schema refs in interpolations, explicit user scope, a narrow row
|
||||
interface, and regression tests.
|
||||
- Preserve the original query shape for latency-sensitive paths. If raw SQL is one
|
||||
database roundtrip, do not replace it with multiple depth-based queries just to
|
||||
remove `execute`.
|
||||
- Use `$with(...)` plus `insert().select()` / `update().from()` for multi-step
|
||||
single-roundtrip writes when Drizzle can express the data flow.
|
||||
- Avoid generic `execute<MyRow>(sql...)` as the main safety mechanism. It types the
|
||||
returned rows, but it does not keep selected columns in sync with schema changes.
|
||||
- If the only clean implementation is a PostgreSQL feature that Drizzle cannot
|
||||
express well, keep the raw SQL and tighten it instead: use schema references in
|
||||
interpolations, explicit user scope, a narrow row interface, and regression tests.
|
||||
|
||||
Recursive CTEs are the canonical "keep raw" case — there's no clean `WITH RECURSIVE`
|
||||
builder, and a rewrite would add depth-based roundtrips:
|
||||
Recursive CTEs are a special case: current Drizzle usage in this repo does not have
|
||||
a clean `WITH RECURSIVE` builder pattern. Keep recursive CTE raw SQL when replacing
|
||||
it would add extra database roundtrips or materially worsen performance.
|
||||
|
||||
Example: convert an aggregate query when Drizzle can preserve one roundtrip:
|
||||
|
||||
```typescript
|
||||
// ✅ Good: builder owns table and column references; sql<T> stays expression-level.
|
||||
const rows = await trx
|
||||
.select({
|
||||
model: messages.model,
|
||||
provider: messages.provider,
|
||||
totalCost: sql<string | null>`sum((${messages.metadata}->'usage'->>'cost')::numeric)`.as(
|
||||
'totalCost',
|
||||
),
|
||||
})
|
||||
.from(messages)
|
||||
.where(
|
||||
and(
|
||||
eq(messages.topicId, topicId),
|
||||
eq(messages.userId, userId),
|
||||
eq(messages.role, 'assistant'),
|
||||
sql`${messages.metadata} ? 'usage'`,
|
||||
),
|
||||
)
|
||||
.groupBy(messages.provider, messages.model);
|
||||
```
|
||||
|
||||
Example: use the select lock builder for row locks:
|
||||
|
||||
```typescript
|
||||
const [user] = await trx.select().from(users).where(eq(users.id, userId)).for('update');
|
||||
```
|
||||
|
||||
Example: keep a recursive CTE raw when replacing it would add depth-based DB
|
||||
roundtrips:
|
||||
|
||||
```typescript
|
||||
interface TaskTreeRow {
|
||||
@@ -337,13 +243,15 @@ interface TaskTreeRow {
|
||||
parent_task_id: string | null;
|
||||
}
|
||||
|
||||
// execute<T> acceptable: no clean WITH RECURSIVE builder. Keep schema refs in the
|
||||
// interpolations and scope every leg to the user.
|
||||
// execute<T> is acceptable here only because Drizzle has no clean WITH RECURSIVE
|
||||
// builder; a builder rewrite would add depth-based roundtrips. Keep schema refs in
|
||||
// the interpolations and scope every leg to the user.
|
||||
const { rows } = await db.execute<TaskTreeRow>(sql`
|
||||
WITH RECURSIVE task_tree AS (
|
||||
SELECT ${tasks.id}, ${tasks.parentTaskId}
|
||||
FROM ${tasks}
|
||||
WHERE ${tasks.id} = ${rootTaskId} AND ${tasks.createdByUserId} = ${userId}
|
||||
WHERE ${tasks.id} = ${rootTaskId}
|
||||
AND ${tasks.createdByUserId} = ${userId}
|
||||
UNION ALL
|
||||
SELECT ${tasks.id}, ${tasks.parentTaskId}
|
||||
FROM ${tasks}
|
||||
|
||||
@@ -56,8 +56,7 @@ git submodules.
|
||||
├── apps/
|
||||
│ ├── cli/ # LobeHub CLI
|
||||
│ ├── desktop/ # Electron desktop app
|
||||
│ ├── device-gateway/ # Device gateway service
|
||||
│ └── server/ # Next.js-backed server: featureFlags, globalConfig, modules, routers, services, utils, workflows (`@/server/*` alias)
|
||||
│ └── device-gateway/ # Device gateway service
|
||||
├── docs/ # changelog, development, self-hosting, usage
|
||||
├── locales/ # en-US, zh-CN, ...
|
||||
├── packages/ # ~80 @lobechat/* workspace packages — `ls` for the full set. Key ones:
|
||||
@@ -86,32 +85,32 @@ git submodules.
|
||||
├── business/ # Open-source stubs (client/server) — cloud repo provides real impls
|
||||
├── features/ # Domain business components
|
||||
├── store/ # ~30 zustand stores — `ls` for the full set
|
||||
├── server/ # standalone-Hono server pieces only: agent-hono, workflows-hono (main backend lives in `apps/server`)
|
||||
├── server/ # featureFlags, globalConfig, modules, routers, services, workflows, agent-hono
|
||||
└── ... # components, hooks, layout, libs, locales, services, types, utils
|
||||
```
|
||||
|
||||
## Architecture Map
|
||||
|
||||
| Layer | Location |
|
||||
| ---------------- | -------------------------------------------------------- |
|
||||
| UI Components | `src/components`, `src/features` |
|
||||
| SPA Pages | `src/routes/` |
|
||||
| React Router | `src/spa/router/` |
|
||||
| Global Providers | `src/layout` |
|
||||
| Zustand Stores | `src/store` |
|
||||
| Client Services | `src/services/` |
|
||||
| REST API | `src/app/(backend)/webapi` |
|
||||
| tRPC Routers | `apps/server/src/routers/{async\|lambda\|mobile\|tools}` |
|
||||
| Server Services | `apps/server/src/services` (can access DB) |
|
||||
| Server Modules | `apps/server/src/modules` (no DB access) |
|
||||
| Feature Flags | `apps/server/src/featureFlags` |
|
||||
| Global Config | `apps/server/src/globalConfig` |
|
||||
| DB Schema | `packages/database/src/schemas` |
|
||||
| DB Model | `packages/database/src/models` |
|
||||
| DB Repository | `packages/database/src/repositories` |
|
||||
| Third-party | `src/libs` (analytics, oidc, etc.) |
|
||||
| Builtin Tools | `packages/builtin-tool-*`, `packages/builtin-tools` |
|
||||
| Open-source stub | `src/business/*`, `packages/business/*` (this repo) |
|
||||
| Layer | Location |
|
||||
| ---------------- | --------------------------------------------------- |
|
||||
| UI Components | `src/components`, `src/features` |
|
||||
| SPA Pages | `src/routes/` |
|
||||
| React Router | `src/spa/router/` |
|
||||
| Global Providers | `src/layout` |
|
||||
| Zustand Stores | `src/store` |
|
||||
| Client Services | `src/services/` |
|
||||
| REST API | `src/app/(backend)/webapi` |
|
||||
| tRPC Routers | `src/server/routers/{async\|lambda\|mobile\|tools}` |
|
||||
| Server Services | `src/server/services` (can access DB) |
|
||||
| Server Modules | `src/server/modules` (no DB access) |
|
||||
| Feature Flags | `src/server/featureFlags` |
|
||||
| Global Config | `src/server/globalConfig` |
|
||||
| DB Schema | `packages/database/src/schemas` |
|
||||
| DB Model | `packages/database/src/models` |
|
||||
| DB Repository | `packages/database/src/repositories` |
|
||||
| Third-party | `src/libs` (analytics, oidc, etc.) |
|
||||
| Builtin Tools | `packages/builtin-tool-*`, `packages/builtin-tools` |
|
||||
| Open-source stub | `src/business/*`, `packages/business/*` (this repo) |
|
||||
|
||||
## Data Flow
|
||||
|
||||
|
||||
@@ -22,7 +22,6 @@ user-invocable: false
|
||||
|
||||
- Bug fixes must include tests covering the fixed scenario
|
||||
- New logic (services, store actions, utilities) should have test coverage
|
||||
- **New database Model/Repository** (`packages/database/src/models/**`, `src/repositories/**`) must ship a sibling `__tests__/<name>.test.ts` — incl. user-isolation tests; BM25 search guarded by `describe.skipIf(!isServerDB)` (see `/testing` → `db-model-test.md`)
|
||||
- Existing tests still cover the changed behavior?
|
||||
- Prefer `vi.spyOn` over `vi.mock` (see `/testing` skill)
|
||||
|
||||
|
||||
@@ -14,21 +14,15 @@ user-invocable: false
|
||||
# Run specific test file
|
||||
bunx vitest run --silent='passed-only' '[file-path]'
|
||||
|
||||
# Database package (client-db, PGlite — default, skips BM25/pg_search)
|
||||
# Database package (client)
|
||||
cd packages/database && bunx vitest run --silent='passed-only' '[file]'
|
||||
|
||||
# Database package (server-db, Postgres — BM25/pgvector parity, what CI measures coverage in)
|
||||
# Database package (server)
|
||||
cd packages/database && TEST_SERVER_DB=1 bunx vitest run --silent='passed-only' '[file]'
|
||||
```
|
||||
|
||||
**Never run** `bun run test` - it runs all 3000+ tests (\~10 minutes).
|
||||
|
||||
> **Database models/repositories:** every new file under `packages/database/src/models/**`
|
||||
> or `src/repositories/**` ships with a sibling `__tests__/<name>.test.ts` in the same PR.
|
||||
> Use the real DB via `getTestDB()` (integration style), guard BM25/full-text-search blocks
|
||||
> with `describe.skipIf(!isServerDB)`, and always test user-isolation. See
|
||||
> `references/db-model-test.md` for setup, schema gotchas, and the client-vs-server-db split.
|
||||
|
||||
## Test Categories
|
||||
|
||||
| Category | Location | Config |
|
||||
|
||||
@@ -1,74 +1,95 @@
|
||||
# Database Model Testing Guide
|
||||
|
||||
Test the `packages/database` Model and Repository layers.
|
||||
Test `packages/database` Model layer.
|
||||
|
||||
> **Rule: every new Model or Repository ships with a sibling test in the same PR.**
|
||||
> A new file under `src/models/**` or `src/repositories/**` must have a matching
|
||||
> `__tests__/<name>.test.ts`. Coverage runs in server-db mode in CI and the patch
|
||||
> gate will not always catch a brand-new untested file (a small new file barely
|
||||
> moves the project total) — so this is a convention, not something CI guarantees.
|
||||
> Start from the template: `packages/database/src/models/__tests__/_test_template.ts`.
|
||||
|
||||
## Two test environments: client-db vs server-db
|
||||
|
||||
`getTestDB()` (`src/core/getTestDB.ts`) returns different engines based on the
|
||||
`TEST_SERVER_DB` env var:
|
||||
|
||||
| Mode | Engine | When | Notes |
|
||||
| ----------------------- | ----------------------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **client-db** (default) | PGlite (in-memory) | `bunx vitest run` | Migration runner **skips any SQL containing `pg_search` / `bm25`** — the ParadeDB BM25 `@@@` operator does not exist here. |
|
||||
| **server-db** | node-postgres → `DATABASE_TEST_URL` | `TEST_SERVER_DB=1` | CI uses the `paradedb/paradedb` image (has `pg_search`). **Coverage is measured in this mode** (`test:coverage` → `vitest.config.server.mts`, uploaded to Codecov). |
|
||||
## Dual Environment Verification (Required)
|
||||
|
||||
```bash
|
||||
# 1. Client environment (fast, default — what most local runs use)
|
||||
cd packages/database && bunx vitest run --silent='passed-only' '[file]'
|
||||
# 1. Client environment (fast)
|
||||
cd packages/database && TEST_SERVER_DB=0 bunx vitest run --silent='passed-only' '[file]'
|
||||
|
||||
# 2. Server environment (BM25 / pg_search / pgvector parity, needs DATABASE_TEST_URL)
|
||||
# 2. Server environment (compatibility)
|
||||
cd packages/database && TEST_SERVER_DB=1 bunx vitest run --silent='passed-only' '[file]'
|
||||
```
|
||||
|
||||
Implication: client-db coverage **under-counts** any code that needs BM25 (e.g.
|
||||
`repositories/search/index.ts` reads near-0% locally but is fully covered in CI).
|
||||
Don't chase those lines locally — confirm via CI/Codecov.
|
||||
## User Permission Check - Security First 🔒
|
||||
|
||||
## BM25 / full-text search → `describe.skipIf(!isServerDB)`
|
||||
|
||||
Any method using the BM25 `@@@` operator or `sanitizeBm25` (keyword search:
|
||||
`queryByKeyword`, `searchAgents`, userMemory lexical search, …) **throws under
|
||||
PGlite** (often swallowed by a `catch` that returns `[]`, so the test silently
|
||||
fails with empty results). Guard those blocks so they only run in server-db:
|
||||
**Critical security requirement**: All user data operations must include permission checks.
|
||||
|
||||
```typescript
|
||||
// BM25 search requires the pg_search extension (ParadeDB), not available in PGlite
|
||||
const isServerDB = process.env.TEST_SERVER_DB === '1';
|
||||
describe.skipIf(!isServerDB)('queryByKeyword', () => {
|
||||
/* ... */
|
||||
// ❌ DANGEROUS: Missing permission check
|
||||
update = async (id: string, data: Partial<MyModel>) => {
|
||||
return this.db
|
||||
.update(myTable)
|
||||
.set(data)
|
||||
.where(eq(myTable.id, id)) // Only checks ID
|
||||
.returning();
|
||||
};
|
||||
|
||||
// ✅ SECURE: Permission check included
|
||||
update = async (id: string, data: Partial<MyModel>) => {
|
||||
return this.db
|
||||
.update(myTable)
|
||||
.set(data)
|
||||
.where(
|
||||
and(
|
||||
eq(myTable.id, id),
|
||||
eq(myTable.userId, this.userId), // ✅ Permission check
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
};
|
||||
```
|
||||
|
||||
## Test File Structure
|
||||
|
||||
```typescript
|
||||
// @vitest-environment node
|
||||
describe('MyModel', () => {
|
||||
describe('create', () => {
|
||||
/* ... */
|
||||
});
|
||||
describe('queryAll', () => {
|
||||
/* ... */
|
||||
});
|
||||
describe('update', () => {
|
||||
it('should update own records');
|
||||
it('should NOT update other users records'); // 🔒 Security
|
||||
});
|
||||
describe('delete', () => {
|
||||
it('should delete own records');
|
||||
it('should NOT delete other users records'); // 🔒 Security
|
||||
});
|
||||
describe('user isolation', () => {
|
||||
it('should enforce user data isolation'); // 🔒 Core security
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Convention already used in `session.test.ts`, `topic.query.test.ts`,
|
||||
`message.query.test.ts`, `home/index.test.ts`, `repositories/search/index.test.ts`.
|
||||
|
||||
## Setup boilerplate
|
||||
|
||||
Top-of-file pattern (see `_test_template.ts` for the full version). Use real DB
|
||||
integration via `getTestDB()` — **not a mocked `vi.fn()` db**; the integration
|
||||
style exercises real SQL and gives far deeper coverage.
|
||||
## Security Test Example
|
||||
|
||||
```typescript
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
it('should not update records of other users', async () => {
|
||||
const [otherUserRecord] = await serverDB
|
||||
.insert(myTable)
|
||||
.values({ userId: 'other-user', data: 'original' })
|
||||
.returning();
|
||||
|
||||
import { getTestDB } from '../../core/getTestDB';
|
||||
import { users } from '../../schemas';
|
||||
import type { LobeChatDatabase } from '../../type';
|
||||
import { MyModel } from '../myModel';
|
||||
const result = await myModel.update(otherUserRecord.id, { data: 'hacked' });
|
||||
|
||||
const serverDB: LobeChatDatabase = await getTestDB(); // top-level await is fine
|
||||
expect(result).toBeUndefined();
|
||||
const unchanged = await serverDB.query.myTable.findFirst({
|
||||
where: eq(myTable.id, otherUserRecord.id),
|
||||
});
|
||||
expect(unchanged?.data).toBe('original');
|
||||
});
|
||||
```
|
||||
|
||||
const userId = 'my-model-test-user';
|
||||
## Data Management
|
||||
|
||||
```typescript
|
||||
const userId = 'test-user';
|
||||
const otherUserId = 'other-user';
|
||||
const myModel = new MyModel(serverDB, userId);
|
||||
|
||||
beforeEach(async () => {
|
||||
await serverDB.delete(users);
|
||||
@@ -76,99 +97,40 @@ beforeEach(async () => {
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await serverDB.delete(users); // cascades to user-scoped rows
|
||||
await serverDB.delete(users);
|
||||
});
|
||||
```
|
||||
|
||||
Some tests need the Node environment (pgvector, server-only deps) — add
|
||||
`// @vitest-environment node` as the first line when required.
|
||||
|
||||
## User permission check — security first 🔒
|
||||
|
||||
**Every user-data operation must be ownership-scoped.** Always add a test proving
|
||||
another user cannot read/update/delete the row.
|
||||
## Foreign Key Handling
|
||||
|
||||
```typescript
|
||||
// ✅ SECURE: ownership in the WHERE clause
|
||||
update = async (id: string, data: Partial<MyModel>) =>
|
||||
this.db
|
||||
.update(myTable)
|
||||
.set(data)
|
||||
.where(and(eq(myTable.id, id), eq(myTable.userId, this.userId)))
|
||||
.returning();
|
||||
```
|
||||
|
||||
```typescript
|
||||
it('should NOT update another user's record', async () => {
|
||||
const otherModel = new MyModel(serverDB, otherUserId);
|
||||
const [row] = await otherModel.create({ data: 'original' });
|
||||
|
||||
await myModel.update(row.id, { data: 'hacked' });
|
||||
|
||||
const unchanged = await serverDB.query.myTable.findFirst({
|
||||
where: eq(myTable.id, row.id),
|
||||
});
|
||||
expect(unchanged?.data).toBe('original');
|
||||
});
|
||||
```
|
||||
|
||||
## What to cover
|
||||
|
||||
Aim each model/repository as close to 100% as practical (excluding BM25):
|
||||
|
||||
- Every public method
|
||||
- Both branches of conditionals; empty-list / `if (!x) return []` early returns
|
||||
- Error fallbacks (e.g. decrypt/JSON-parse failure → `null`)
|
||||
- Filters, pagination, ordering branches
|
||||
- Ownership / user isolation, and workspace scoping if the model takes a `workspaceId`
|
||||
|
||||
## Schema gotchas (real traps that fail inserts or types)
|
||||
|
||||
- **`workspaces`** requires `{ id, name, slug, primaryOwnerId }` and has **no
|
||||
`userId` column** — `insert(workspaces).values({ id, name, slug, primaryOwnerId })`.
|
||||
- **uuid columns**: a "not found" test must pass a _valid_ UUID
|
||||
(`'00000000-0000-0000-0000-000000000000'`); a random string raises a `22P02`
|
||||
DB error instead of returning `undefined`/`null`.
|
||||
- **Enum / `$type` columns** are type-checked: e.g. `files.source` is a
|
||||
`FileSource` enum (`image_generation` | `page-editor` | `video_generation`),
|
||||
not free text — passing `'upload'` is a type error.
|
||||
- Read the table's schema in `src/schemas/` for `notNull` columns **without
|
||||
defaults**; you must supply those on insert.
|
||||
|
||||
## Foreign key handling
|
||||
|
||||
```typescript
|
||||
// ❌ Wrong: invalid foreign key
|
||||
// ❌ Wrong: Invalid foreign key
|
||||
const testData = { asyncTaskId: 'invalid-uuid', fileId: 'non-existent' };
|
||||
|
||||
// ✅ Use null …
|
||||
// ✅ Correct: Use null
|
||||
const testData = { asyncTaskId: null, fileId: null };
|
||||
|
||||
// ✅ … or create the referenced row first
|
||||
const [asyncTask] = await serverDB.insert(asyncTasks).values({ status: 'pending' }).returning();
|
||||
testData.asyncTaskId = asyncTask.id;
|
||||
// ✅ Or: Create referenced record first
|
||||
beforeEach(async () => {
|
||||
const [asyncTask] = await serverDB
|
||||
.insert(asyncTasks)
|
||||
.values({ id: 'valid-id', status: 'pending' })
|
||||
.returning();
|
||||
testData.asyncTaskId = asyncTask.id;
|
||||
});
|
||||
```
|
||||
|
||||
## Predictable sorting
|
||||
## Predictable Sorting
|
||||
|
||||
```typescript
|
||||
// ✅ Use explicit timestamps — never rely on insert order
|
||||
// ✅ Use explicit timestamps
|
||||
const oldDate = new Date('2024-01-01T10:00:00Z');
|
||||
const newDate = new Date('2024-01-02T10:00:00Z');
|
||||
await serverDB.insert(table).values([
|
||||
{ ...data1, createdAt: new Date('2024-01-01T10:00:00Z') },
|
||||
{ ...data2, createdAt: new Date('2024-01-02T10:00:00Z') },
|
||||
{ ...data1, createdAt: oldDate },
|
||||
{ ...data2, createdAt: newDate },
|
||||
]);
|
||||
|
||||
// ❌ Don't rely on insert order
|
||||
await serverDB.insert(table).values([data1, data2]); // Unpredictable
|
||||
```
|
||||
|
||||
## Checking coverage of one file
|
||||
|
||||
```bash
|
||||
# Per-file coverage; read the "Uncovered Line #s" column to find gaps
|
||||
cd packages/database
|
||||
bunx vitest run --coverage --silent='passed-only' '[test-file]' 2>&1 | grep '[sourceFile].ts'
|
||||
```
|
||||
|
||||
## Before finishing
|
||||
|
||||
1. Tests pass: `bunx vitest run --silent='passed-only' '[file]'`
|
||||
2. Types pass: `bun run type-check` (vitest uses esbuild and does **not**
|
||||
type-check — a green test run can still have type errors).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: trpc-router
|
||||
description: 'TRPC router development guide. Use when creating or modifying apps/server/src/routers, adding procedures, or implementing server-side API endpoints.'
|
||||
description: 'TRPC router development guide. Use when creating or modifying src/server/routers, adding procedures, or implementing server-side API endpoints.'
|
||||
user-invocable: false
|
||||
---
|
||||
|
||||
@@ -8,9 +8,9 @@ user-invocable: false
|
||||
|
||||
## File Location
|
||||
|
||||
- Routers: `apps/server/src/routers/lambda/<domain>.ts`
|
||||
- Helpers: `apps/server/src/routers/lambda/_helpers/`
|
||||
- Schemas: `apps/server/src/routers/lambda/_schema/`
|
||||
- Routers: `src/server/routers/lambda/<domain>.ts`
|
||||
- Helpers: `src/server/routers/lambda/_helpers/`
|
||||
- Schemas: `src/server/routers/lambda/_schema/`
|
||||
|
||||
## Router Structure
|
||||
|
||||
|
||||
@@ -186,4 +186,4 @@ QSTASH_URL=https://custom-qstash.com
|
||||
- [Upstash Workflow Documentation](https://upstash.com/docs/workflow)
|
||||
- [QStash Documentation](https://upstash.com/docs/qstash)
|
||||
- [Example Workflows in Codebase](<../../src/app/(backend)/api/workflows/>)
|
||||
- [Workflow Classes](../../apps/server/src/workflows/)
|
||||
- [Workflow Classes](../../src/server/workflows/)
|
||||
|
||||
@@ -177,7 +177,7 @@ This allows cloud to override specific modules while using lobehub defaults.
|
||||
Place workflow class in cloud:
|
||||
|
||||
```text
|
||||
lobehub-cloud/apps/server/src/workflows/featureName/index.ts
|
||||
lobehub-cloud/src/server/workflows/featureName/index.ts
|
||||
```
|
||||
|
||||
### Shared Workflows
|
||||
@@ -185,7 +185,7 @@ lobehub-cloud/apps/server/src/workflows/featureName/index.ts
|
||||
Place workflow class in lobehub, re-export in cloud if needed:
|
||||
|
||||
```text
|
||||
lobehub/apps/server/src/workflows/featureName/index.ts
|
||||
lobehub/src/server/workflows/featureName/index.ts
|
||||
```
|
||||
|
||||
---
|
||||
@@ -294,8 +294,8 @@ export { POST } from 'lobehub/src/app/(backend)/api/workflows/feature/*/route';
|
||||
**Step 4**: Move workflow class to lobehub
|
||||
|
||||
```bash
|
||||
mv lobehub-cloud/apps/server/src/workflows/feature \
|
||||
lobehub/apps/server/src/workflows/
|
||||
mv lobehub-cloud/src/server/workflows/feature \
|
||||
lobehub/src/server/workflows/
|
||||
```
|
||||
|
||||
**Step 5**: Update cloud imports
|
||||
@@ -305,7 +305,7 @@ mv lobehub-cloud/apps/server/src/workflows/feature \
|
||||
import { Workflow } from '@/server/workflows/feature';
|
||||
|
||||
// To
|
||||
import { Workflow } from 'lobehub/apps/server/src/workflows/feature';
|
||||
import { Workflow } from 'lobehub/src/server/workflows/feature';
|
||||
```
|
||||
|
||||
---
|
||||
@@ -326,7 +326,7 @@ lobehub-cloud/
|
||||
│ ├── process-users/route.ts
|
||||
│ ├── paginate-users/route.ts
|
||||
│ └── generate-user/route.ts
|
||||
└── apps/server/src/workflows/welcomePlaceholder/
|
||||
└── src/server/workflows/welcomePlaceholder/
|
||||
└── index.ts
|
||||
```
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ Full code templates for the 3-layer architecture. Read this when actually writin
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Workflow Class](#workflow-class) — `apps/server/src/workflows/{workflowName}/index.ts`
|
||||
1. [Workflow Class](#workflow-class) — `src/server/workflows/{workflowName}/index.ts`
|
||||
2. [Layer 1: Entry Point](#layer-1-entry-point-process-) — `process-*` route
|
||||
3. [Layer 2: Pagination](#layer-2-pagination-paginate-) — `paginate-*` route
|
||||
4. [Layer 3: Execution](#layer-3-execution-execute--generate-) — `execute-*` / `generate-*` route
|
||||
@@ -13,7 +13,7 @@ Full code templates for the 3-layer architecture. Read this when actually writin
|
||||
|
||||
## Workflow Class
|
||||
|
||||
**Location:** `apps/server/src/workflows/{workflowName}/index.ts`
|
||||
**Location:** `src/server/workflows/{workflowName}/index.ts`
|
||||
|
||||
```typescript
|
||||
import { Client } from '@upstash/workflow';
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
name: Release CLI
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Tag name for the release (e.g. v0.1.0)'
|
||||
required: true
|
||||
default: 'v0.0.0'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build ${{ matrix.target }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
# skip pre-release tags (containing '-') on auto-trigger; always run on workflow_dispatch
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || !contains(github.ref_name, '-') }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
target: lobe-linux-x64
|
||||
- os: macos-latest
|
||||
target: lobe-macos-arm64
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build binary
|
||||
run: |
|
||||
mkdir -p dist
|
||||
bun build ./apps/cli/src/index.ts --compile --minify --outfile ./dist/${{ matrix.target }}
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.target }}
|
||||
path: ./dist/${{ matrix.target }}
|
||||
|
||||
release:
|
||||
name: Upload to Release
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: ./dist
|
||||
|
||||
- name: Upload to GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}
|
||||
files: |
|
||||
./dist/lobe-linux-x64/lobe-linux-x64
|
||||
./dist/lobe-macos-arm64/lobe-macos-arm64
|
||||
./apps/cli/install.sh
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
name: Test Packages
|
||||
env:
|
||||
PACKAGES: '@lobechat/file-loaders @lobechat/prompts @lobechat/model-runtime @lobechat/web-crawler @lobechat/electron-server-ipc @lobechat/utils @lobechat/python-interpreter @lobechat/context-engine @lobechat/agent-runtime @lobechat/conversation-flow @lobechat/ssrf-safe-fetch @lobechat/memory-user-memory @lobechat/types @lobechat/trpc @lobechat/app-config @lobechat/locales @lobechat/env @lobechat/builtin-tool-lobe-agent model-bank @lobechat/agent-gateway-client @lobechat/agent-manager-runtime @lobechat/device-gateway-client @lobechat/device-identity @lobechat/eval-dataset-parser @lobechat/eval-rubric @lobechat/fetch-sse @lobechat/heterogeneous-agents'
|
||||
PACKAGES: '@lobechat/file-loaders @lobechat/prompts @lobechat/model-runtime @lobechat/web-crawler @lobechat/electron-server-ipc @lobechat/utils @lobechat/python-interpreter @lobechat/context-engine @lobechat/agent-runtime @lobechat/conversation-flow @lobechat/ssrf-safe-fetch @lobechat/memory-user-memory @lobechat/types @lobechat/builtin-tool-lobe-agent model-bank @lobechat/agent-gateway-client @lobechat/agent-manager-runtime @lobechat/device-gateway-client @lobechat/device-identity @lobechat/eval-dataset-parser @lobechat/eval-rubric @lobechat/fetch-sse @lobechat/heterogeneous-agents'
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
||||
@@ -19,7 +19,7 @@ lobehub/
|
||||
├── apps/
|
||||
│ ├── desktop/ # Electron desktop app
|
||||
│ ├── cli/ # LobeHub CLI
|
||||
│ └── server/ # Server service
|
||||
│ └── device-gateway/ # Device gateway service
|
||||
├── packages/ # Shared packages (@lobechat/*)
|
||||
│ ├── database/ # Database schemas, models, repositories
|
||||
│ ├── agent-runtime/ # Agent runtime
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
REPO="lobehub/lobe-chat"
|
||||
BIN_NAME="lh"
|
||||
|
||||
# Detect OS
|
||||
case "$(uname -s)" in
|
||||
Linux) OS="linux" ;;
|
||||
Darwin) OS="macos" ;;
|
||||
*)
|
||||
printf 'Error: Unsupported OS: %s\n' "$(uname -s)" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Detect architecture
|
||||
case "$(uname -m)" in
|
||||
x86_64) ARCH="x64" ;;
|
||||
aarch64|arm64) ARCH="arm64" ;;
|
||||
*)
|
||||
printf 'Error: Unsupported architecture: %s\n' "$(uname -m)" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
BINARY="lobe-${OS}-${ARCH}"
|
||||
URL="https://github.com/${REPO}/releases/latest/download/${BINARY}"
|
||||
|
||||
printf 'Detected: %s/%s\n' "$OS" "$ARCH"
|
||||
printf 'Downloading %s...\n' "$BINARY"
|
||||
|
||||
TMP="$(mktemp)"
|
||||
trap 'rm -f "$TMP"' EXIT
|
||||
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
curl -fsSL "$URL" -o "$TMP"
|
||||
elif command -v wget >/dev/null 2>&1; then
|
||||
wget -qO "$TMP" "$URL"
|
||||
else
|
||||
printf 'Error: curl or wget is required\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
chmod +x "$TMP"
|
||||
|
||||
# Choose install directory: prefer /usr/local/bin, fall back to ~/.local/bin
|
||||
USE_SUDO=0
|
||||
if [ -w "/usr/local/bin" ]; then
|
||||
INSTALL_DIR="/usr/local/bin"
|
||||
elif command -v sudo >/dev/null 2>&1 && sudo -n true 2>/dev/null; then
|
||||
INSTALL_DIR="/usr/local/bin"
|
||||
USE_SUDO=1
|
||||
else
|
||||
INSTALL_DIR="${HOME}/.local/bin"
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
printf 'Note: No sudo access. Installing to %s\n' "$INSTALL_DIR"
|
||||
printf 'Add the following to your shell profile if needed:\n'
|
||||
printf ' export PATH="%s:$PATH"\n' "$INSTALL_DIR"
|
||||
fi
|
||||
|
||||
# Install binary and create symlinks
|
||||
if [ "$USE_SUDO" = "1" ]; then
|
||||
sudo cp "$TMP" "${INSTALL_DIR}/${BIN_NAME}"
|
||||
sudo chmod +x "${INSTALL_DIR}/${BIN_NAME}"
|
||||
sudo ln -sf "${INSTALL_DIR}/${BIN_NAME}" "${INSTALL_DIR}/lobe"
|
||||
sudo ln -sf "${INSTALL_DIR}/${BIN_NAME}" "${INSTALL_DIR}/lobehub"
|
||||
else
|
||||
cp "$TMP" "${INSTALL_DIR}/${BIN_NAME}"
|
||||
chmod +x "${INSTALL_DIR}/${BIN_NAME}"
|
||||
ln -sf "${INSTALL_DIR}/${BIN_NAME}" "${INSTALL_DIR}/lobe"
|
||||
ln -sf "${INSTALL_DIR}/${BIN_NAME}" "${INSTALL_DIR}/lobehub"
|
||||
fi
|
||||
|
||||
printf '\nInstalled successfully!\n'
|
||||
printf ' Binary: %s/%s\n' "$INSTALL_DIR" "$BIN_NAME"
|
||||
printf ' Symlinks: lobe, lobehub -> lh\n\n'
|
||||
"${INSTALL_DIR}/${BIN_NAME}" --version
|
||||
@@ -1,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.27" "User Commands"
|
||||
.TH LH 1 "" "@lobehub/cli 0.0.24" "User Commands"
|
||||
.SH NAME
|
||||
lh \- LobeHub CLI \- manage and connect to LobeHub services
|
||||
.SH SYNOPSIS
|
||||
@@ -113,9 +113,6 @@ Manage plugins
|
||||
.B user
|
||||
Manage user account and settings
|
||||
.TP
|
||||
.B verify
|
||||
Manage the Agent Run delivery checker (criteria, rubrics, plans, results)
|
||||
.TP
|
||||
.B whoami
|
||||
Display current user information
|
||||
.TP
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@lobehub/cli",
|
||||
"version": "0.0.27",
|
||||
"version": "0.0.24",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"lh": "./dist/index.js",
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import { mkdtemp, readdir, readFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { PassThrough } from 'node:stream';
|
||||
|
||||
import { Command } from 'commander';
|
||||
@@ -648,224 +645,4 @@ describe('hetero exec command', () => {
|
||||
'finish',
|
||||
]);
|
||||
});
|
||||
|
||||
it('resets the per-message text accumulator at message boundaries (no cross-message duplication)', async () => {
|
||||
// LOBE-10157 Bug 3: the `replace` snapshot accumulator must not span
|
||||
// message boundaries. Two assistant messages separated by a
|
||||
// stream_end/stream_start boundary must each snapshot only their OWN
|
||||
// text — otherwise the second message re-emits the first's text verbatim.
|
||||
const textSnapshots: string[] = [];
|
||||
mockHeteroIngestMutate.mockImplementation(async ({ events }: any) => {
|
||||
for (const e of events) {
|
||||
if (e.type === 'stream_chunk' && e.data?.chunkType === 'text') {
|
||||
textSnapshots.push(e.data.content);
|
||||
}
|
||||
}
|
||||
return { ack: true };
|
||||
});
|
||||
|
||||
mockSpawnAgent.mockReturnValue(
|
||||
createFakeHandle({
|
||||
events: [
|
||||
{
|
||||
data: { chunkType: 'text', content: 'first message' },
|
||||
operationId: 'op-server',
|
||||
stepIndex: 0,
|
||||
timestamp: 1,
|
||||
type: 'stream_chunk',
|
||||
},
|
||||
{ data: {}, operationId: 'op-server', stepIndex: 0, timestamp: 2, type: 'stream_end' },
|
||||
{
|
||||
data: { newStep: true, provider: 'claude-code' },
|
||||
operationId: 'op-server',
|
||||
stepIndex: 1,
|
||||
timestamp: 3,
|
||||
type: 'stream_start',
|
||||
},
|
||||
{
|
||||
data: { chunkType: 'text', content: 'second message' },
|
||||
operationId: 'op-server',
|
||||
stepIndex: 1,
|
||||
timestamp: 4,
|
||||
type: 'stream_chunk',
|
||||
},
|
||||
{
|
||||
data: { reason: 'success' },
|
||||
operationId: 'op-server',
|
||||
stepIndex: 1,
|
||||
timestamp: 5,
|
||||
type: 'agent_runtime_end',
|
||||
},
|
||||
],
|
||||
exitCode: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
await runCmd([
|
||||
'hetero',
|
||||
'exec',
|
||||
'--type',
|
||||
'claude-code',
|
||||
'--prompt',
|
||||
'hi',
|
||||
'--topic',
|
||||
'topic-1',
|
||||
'--operation-id',
|
||||
'op-server',
|
||||
'--render',
|
||||
'none',
|
||||
]);
|
||||
|
||||
// Second snapshot carries ONLY the second message — not "first messagesecond message".
|
||||
expect(textSnapshots).toEqual(['first message', 'second message']);
|
||||
});
|
||||
|
||||
it('forwards subagent text raw (no snapshot coalescing, no cross-scope pollution of main text)', async () => {
|
||||
// Subagent text is emitted as ONE full block per turn and the server's
|
||||
// subagent path *appends* it (no snapshot semantics). It must therefore
|
||||
// bypass the main-agent `replace`-snapshot coalescing: folding it into the
|
||||
// shared accumulator would (a) splice main text into the subagent message
|
||||
// and (b) make the server append a replace-snapshot → duplicated content.
|
||||
const ingested: any[] = [];
|
||||
mockHeteroIngestMutate.mockImplementation(async ({ events }: any) => {
|
||||
for (const e of events) ingested.push(e);
|
||||
return { ack: true };
|
||||
});
|
||||
|
||||
const subagent = { parentToolCallId: 'task-1', subagentMessageId: 'msg-sub-1' };
|
||||
|
||||
mockSpawnAgent.mockReturnValue(
|
||||
createFakeHandle({
|
||||
events: [
|
||||
// Main-agent streamed text delta (coalesced).
|
||||
{
|
||||
data: { chunkType: 'text', content: 'hello ' },
|
||||
operationId: 'op-server',
|
||||
stepIndex: 0,
|
||||
timestamp: 1,
|
||||
type: 'stream_chunk',
|
||||
},
|
||||
// Subagent full-block text — must pass through untouched.
|
||||
{
|
||||
data: { chunkType: 'text', content: 'I checked the files.', subagent },
|
||||
operationId: 'op-server',
|
||||
stepIndex: 0,
|
||||
timestamp: 2,
|
||||
type: 'stream_chunk',
|
||||
},
|
||||
{
|
||||
data: {
|
||||
chunkType: 'tools_calling',
|
||||
toolsCalling: [
|
||||
{
|
||||
apiName: 'Bash',
|
||||
arguments: '{"cmd":"ls"}',
|
||||
id: 'tc-1',
|
||||
identifier: 'bash',
|
||||
type: 'default',
|
||||
},
|
||||
],
|
||||
},
|
||||
operationId: 'op-server',
|
||||
stepIndex: 1,
|
||||
timestamp: 3,
|
||||
type: 'stream_chunk',
|
||||
},
|
||||
{
|
||||
data: { reason: 'success' },
|
||||
operationId: 'op-server',
|
||||
stepIndex: 1,
|
||||
timestamp: 4,
|
||||
type: 'agent_runtime_end',
|
||||
},
|
||||
],
|
||||
exitCode: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
await runCmd([
|
||||
'hetero',
|
||||
'exec',
|
||||
'--type',
|
||||
'claude-code',
|
||||
'--prompt',
|
||||
'hi',
|
||||
'--topic',
|
||||
'topic-1',
|
||||
'--operation-id',
|
||||
'op-server',
|
||||
'--render',
|
||||
'none',
|
||||
]);
|
||||
|
||||
const textEvents = ingested.filter(
|
||||
(e) => e.type === 'stream_chunk' && e.data?.chunkType === 'text',
|
||||
);
|
||||
|
||||
// Subagent text forwarded verbatim: keeps its subagent tag, original
|
||||
// content, and is NOT converted into a replace snapshot.
|
||||
const subagentText = textEvents.find((e) => e.data?.subagent);
|
||||
expect(subagentText).toBeDefined();
|
||||
expect(subagentText.data.content).toBe('I checked the files.');
|
||||
expect(subagentText.data.snapshotMode).toBeUndefined();
|
||||
|
||||
// Main snapshot is untainted by the subagent block.
|
||||
const mainText = textEvents.find((e) => !e.data?.subagent);
|
||||
expect(mainText).toBeDefined();
|
||||
expect(mainText.data.content).toBe('hello ');
|
||||
expect(mainText.data.snapshotMode).toBe('replace');
|
||||
expect(mainText.data.content).not.toContain('I checked');
|
||||
});
|
||||
|
||||
it('--raw-dump writes a session folder with meta.json, wires onRawStdout, and tees stderr', async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'hetero-rawdump-'));
|
||||
|
||||
mockSpawnAgent.mockReturnValue(
|
||||
createFakeHandle({
|
||||
events: [
|
||||
{
|
||||
data: { chunkType: 'text', content: 'hi' },
|
||||
operationId: 'op-raw',
|
||||
stepIndex: 0,
|
||||
timestamp: 1,
|
||||
type: 'stream_chunk',
|
||||
},
|
||||
],
|
||||
exitCode: 0,
|
||||
stderrChunks: ['warning: something happened\n'],
|
||||
}),
|
||||
);
|
||||
|
||||
await runCmd([
|
||||
'hetero',
|
||||
'exec',
|
||||
'--type',
|
||||
'claude-code',
|
||||
'--prompt',
|
||||
'hi',
|
||||
'--operation-id',
|
||||
'op-raw',
|
||||
'--render',
|
||||
'none',
|
||||
'--raw-dump',
|
||||
root,
|
||||
]);
|
||||
|
||||
// The raw stdout tee is handed to spawnAgent (the package captures the
|
||||
// pre-adapter bytes — exercised in spawnAgent.test.ts).
|
||||
expect(typeof mockSpawnAgent.mock.calls[0][0].onRawStdout).toBe('function');
|
||||
|
||||
// One session folder per exec, keyed by the operation id.
|
||||
const sessions = await readdir(root);
|
||||
expect(sessions).toHaveLength(1);
|
||||
expect(sessions[0]).toContain('op-raw');
|
||||
const sessionDir = path.join(root, sessions[0]!);
|
||||
|
||||
const meta = JSON.parse(await readFile(path.join(sessionDir, 'meta.json'), 'utf8'));
|
||||
expect(meta).toMatchObject({ agentType: 'claude-code', operationId: 'op-raw' });
|
||||
|
||||
// stderr is teed to the attempt's log file.
|
||||
const stderrDump = await readFile(path.join(sessionDir, 'attempt-1.stderr.log'), 'utf8');
|
||||
expect(stderrDump).toContain('warning: something happened');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { once } from 'node:events';
|
||||
import { createWriteStream } from 'node:fs';
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import type {
|
||||
@@ -60,12 +59,6 @@ interface ExecOptions {
|
||||
inputJson?: string;
|
||||
operationId?: string;
|
||||
prompt?: string;
|
||||
/**
|
||||
* When set, persist the agent process's RAW stdout/stderr (pre-adapter
|
||||
* stream-json) under `<rawDump>/<timestamp>-<operationId>/` for debugging.
|
||||
* Independent of `--render` and the server ingest path.
|
||||
*/
|
||||
rawDump?: string;
|
||||
/**
|
||||
* Output rendering mode.
|
||||
* jsonl — emit each `AgentStreamEvent` as a JSONL line on stdout (default
|
||||
@@ -224,25 +217,10 @@ class SerialServerIngester {
|
||||
push(event: AgentStreamEvent): void {
|
||||
if (this.fatalError) return;
|
||||
|
||||
// Text-snapshot coalescing is a MAIN-AGENT-ONLY transport optimization:
|
||||
// it debounces the main agent's token-level text *deltas* into one
|
||||
// `replace` snapshot to cut ingest calls. Subagent text is explicitly
|
||||
// excluded (`!event.data?.subagent`) for two reasons:
|
||||
// 1. Subagent text is emitted as ONE full block per turn (see
|
||||
// claudeCode adapter `handleSubagentAssistant` — "the full block IS
|
||||
// the only emission"), so there is nothing to coalesce.
|
||||
// 2. `accumulatedText` is a single shared accumulator with no subagent
|
||||
// scope. Folding subagent blocks in would (a) splice main-agent text
|
||||
// into the subagent message via the shared buffer, and (b) emit a
|
||||
// `replace` snapshot that the server's subagent path *appends*
|
||||
// (`persistSubagentText` has no snapshot semantics) → duplicated /
|
||||
// cross-scope content. Forwarding the raw block straight through lets
|
||||
// the server append it exactly once, correctly.
|
||||
if (
|
||||
event.type === 'stream_chunk' &&
|
||||
event.data?.chunkType === 'text' &&
|
||||
typeof event.data?.content === 'string' &&
|
||||
!event.data?.subagent
|
||||
typeof event.data?.content === 'string'
|
||||
) {
|
||||
this.accumulatedText += event.data.content;
|
||||
this.pendingTextEvent = event;
|
||||
@@ -255,17 +233,6 @@ class SerialServerIngester {
|
||||
}
|
||||
|
||||
this.queuePendingTextSnapshot();
|
||||
// `accumulatedText` is a PER-MESSAGE accumulator: it coalesces the text
|
||||
// deltas of the current assistant message into one `replace` snapshot.
|
||||
// A new message boundary (`stream_start` / `stream_end`, emitted by the
|
||||
// adapter's `openMainMessage`) must reset it — otherwise it spans the
|
||||
// whole run and every later message's snapshot re-emits all prior
|
||||
// messages' text verbatim, which the server then persists into the new
|
||||
// DB message (LOBE-10157 Bug 3: cross-message text duplication). Reset
|
||||
// AFTER flushing the just-ended message's pending snapshot above.
|
||||
if (event.type === 'stream_start' || event.type === 'stream_end') {
|
||||
this.accumulatedText = '';
|
||||
}
|
||||
this.enqueue(async () => {
|
||||
await this.sink.ingest([event]);
|
||||
});
|
||||
@@ -313,77 +280,6 @@ class SerialServerIngester {
|
||||
}
|
||||
}
|
||||
|
||||
interface RawStreamDumpAttempt {
|
||||
/** Flush + close both file streams. Resolves once the bytes are on disk. */
|
||||
close: () => Promise<void>;
|
||||
writeStderr: (chunk: Buffer) => void;
|
||||
writeStdout: (chunk: Buffer) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists the agent process's RAW stdout/stderr — the untouched stream-json,
|
||||
* BEFORE the adapter — to disk for post-hoc debugging. The adapted/ingested
|
||||
* view can't tell a CC-side empty `tool_result` apart from an adapter
|
||||
* extraction bug; the raw dump can.
|
||||
*
|
||||
* Enabled via `lh hetero exec --raw-dump <dir>`. Each exec gets its own
|
||||
* `<dir>/<timestamp>-<operationId>/` session folder; each spawn attempt (the
|
||||
* resume retry is a second attempt) writes `<label>.stdout.jsonl` /
|
||||
* `<label>.stderr.log`. Fully best-effort: any dump failure is logged and
|
||||
* swallowed so it never affects the run or its exit code.
|
||||
*
|
||||
* Future: the server-side sandbox runner (`spawnHeteroSandbox`) and the
|
||||
* desktop device path (`spawnLhHeteroExec`) can pass `--raw-dump` pointing at
|
||||
* a collectable location to capture remote runs the same way.
|
||||
*/
|
||||
class RawStreamDump {
|
||||
private constructor(private readonly dir: string) {}
|
||||
|
||||
static async create(
|
||||
root: string,
|
||||
operationId: string,
|
||||
meta: Record<string, unknown>,
|
||||
): Promise<RawStreamDump | undefined> {
|
||||
try {
|
||||
const safeTs = new Date().toISOString().replaceAll(/[.:]/g, '-');
|
||||
const dir = path.join(path.resolve(root), `${safeTs}-${operationId}`);
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(
|
||||
path.join(dir, 'meta.json'),
|
||||
`${JSON.stringify({ ...meta, operationId, startedAt: new Date().toISOString() }, null, 2)}\n`,
|
||||
);
|
||||
log.info(`Raw stream dump enabled → ${dir}`);
|
||||
return new RawStreamDump(dir);
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
`Failed to initialize raw stream dump: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
openAttempt(label: string): RawStreamDumpAttempt {
|
||||
const stdout = createWriteStream(path.join(this.dir, `${label}.stdout.jsonl`));
|
||||
const stderr = createWriteStream(path.join(this.dir, `${label}.stderr.log`));
|
||||
// A failed dump write must never crash the run — drop write errors.
|
||||
stdout.on('error', () => {});
|
||||
stderr.on('error', () => {});
|
||||
return {
|
||||
close: () =>
|
||||
Promise.all([
|
||||
new Promise<void>((resolve) => stdout.end(() => resolve())),
|
||||
new Promise<void>((resolve) => stderr.end(() => resolve())),
|
||||
]).then(() => undefined),
|
||||
writeStderr: (chunk: Buffer) => {
|
||||
stderr.write(chunk);
|
||||
},
|
||||
writeStdout: (chunk: Buffer) => {
|
||||
stdout.write(chunk);
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const exec = async (options: ExecOptions): Promise<void> => {
|
||||
if (!SUPPORTED_AGENT_TYPES.has(options.type)) {
|
||||
log.error(
|
||||
@@ -418,17 +314,6 @@ const exec = async (options: ExecOptions): Promise<void> => {
|
||||
|
||||
const operationId = options.operationId || randomUUID();
|
||||
|
||||
// Optional raw stream dump (pre-adapter stdout/stderr) for debugging.
|
||||
let rawDump: RawStreamDump | undefined;
|
||||
if (options.rawDump) {
|
||||
rawDump = await RawStreamDump.create(options.rawDump, operationId, {
|
||||
agentType: options.type,
|
||||
cwd: options.cwd || process.cwd(),
|
||||
resume: options.resume ?? null,
|
||||
topicId: options.topic ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
// Determine JSONL output mode.
|
||||
// Explicit --render flag always wins. Otherwise: emit JSONL in standalone
|
||||
// mode; suppress in server-ingest mode (sink handles the data path).
|
||||
@@ -472,7 +357,6 @@ const exec = async (options: ExecOptions): Promise<void> => {
|
||||
const runOneAgent = async (
|
||||
spawnOpts: Parameters<typeof spawnAgent>[0],
|
||||
interceptResumeErrors: boolean,
|
||||
runLabel: string,
|
||||
): Promise<{
|
||||
code: number | null;
|
||||
ingestError: boolean;
|
||||
@@ -481,17 +365,12 @@ const exec = async (options: ExecOptions): Promise<void> => {
|
||||
signal: NodeJS.Signals | null;
|
||||
stderrContent: string;
|
||||
}> => {
|
||||
// One raw-dump file pair per spawn attempt (the resume retry is a second
|
||||
// attempt). The stdout tee runs inside `spawnAgent` before the adapter.
|
||||
const dumpAttempt = rawDump?.openAttempt(runLabel);
|
||||
|
||||
// `spawnAgent` is async and can reject DURING image normalization — fetch
|
||||
// failures, missing local --image paths, decode errors.
|
||||
let handle: Awaited<ReturnType<typeof spawnAgent>>;
|
||||
try {
|
||||
handle = await spawnAgent({ ...spawnOpts, onRawStdout: dumpAttempt?.writeStdout });
|
||||
handle = await spawnAgent(spawnOpts);
|
||||
} catch (err) {
|
||||
await dumpAttempt?.close();
|
||||
log.error('Failed to start agent:', err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -508,7 +387,6 @@ const exec = async (options: ExecOptions): Promise<void> => {
|
||||
if (stderrContent.length < STDERR_CAP) {
|
||||
stderrContent += chunk.toString();
|
||||
}
|
||||
dumpAttempt?.writeStderr(chunk);
|
||||
});
|
||||
handle.stderr.pipe(process.stderr);
|
||||
|
||||
@@ -582,7 +460,6 @@ const exec = async (options: ExecOptions): Promise<void> => {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
await dumpAttempt?.close();
|
||||
process.exit(1);
|
||||
} finally {
|
||||
process.off('SIGINT', onSigint);
|
||||
@@ -591,7 +468,6 @@ const exec = async (options: ExecOptions): Promise<void> => {
|
||||
|
||||
const { code, signal } = await handle.exit;
|
||||
await stderrEnded;
|
||||
await dumpAttempt?.close();
|
||||
|
||||
// Fallback stderr detection: CC may exit non-zero without emitting a
|
||||
// result event (e.g. it writes to stderr and quits immediately).
|
||||
@@ -627,7 +503,6 @@ const exec = async (options: ExecOptions): Promise<void> => {
|
||||
resumeSessionId: options.resume,
|
||||
},
|
||||
interceptResume,
|
||||
'attempt-1',
|
||||
);
|
||||
|
||||
// ─── Auto-retry without --resume when the session cannot be used ─────────
|
||||
@@ -656,7 +531,6 @@ const exec = async (options: ExecOptions): Promise<void> => {
|
||||
// No resumeSessionId — start fresh
|
||||
},
|
||||
false, // no need to intercept resume errors on a fresh run
|
||||
'attempt-2-noresume',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -744,9 +618,5 @@ export function registerHeteroCommand(program: Command) {
|
||||
'--render <mode>',
|
||||
'Output mode: jsonl (emit events as JSONL on stdout) | none (suppress stdout). Defaults to jsonl in standalone, none in server-ingest mode.',
|
||||
)
|
||||
.option(
|
||||
'--raw-dump <dir>',
|
||||
'Persist the agent process RAW stdout/stderr (pre-adapter stream-json) under <dir>/<timestamp>-<operationId>/ for debugging. Each spawn attempt writes its own .stdout.jsonl / .stderr.log. Best-effort; never affects the run.',
|
||||
)
|
||||
.action(exec);
|
||||
}
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { fromIpcErrorEnvelope, isIpcErrorEnvelope, toIpcErrorEnvelope } from './ipcError';
|
||||
|
||||
describe('ipcError envelope', () => {
|
||||
it('round-trips an Error and preserves its cause + code', () => {
|
||||
const cause = Object.assign(new Error('getaddrinfo ENOTFOUND example.com'), {
|
||||
code: 'ENOTFOUND',
|
||||
});
|
||||
const error = new TypeError('fetch failed', { cause });
|
||||
|
||||
const envelope = toIpcErrorEnvelope(error);
|
||||
expect(isIpcErrorEnvelope(envelope)).toBe(true);
|
||||
|
||||
const revived = fromIpcErrorEnvelope(envelope);
|
||||
expect(revived).toBeInstanceOf(Error);
|
||||
expect(revived.name).toBe('TypeError');
|
||||
expect(revived.message).toBe('fetch failed');
|
||||
|
||||
const revivedCause = revived.cause as Error & { code?: unknown };
|
||||
expect(revivedCause).toBeInstanceOf(Error);
|
||||
expect(revivedCause.message).toBe('getaddrinfo ENOTFOUND example.com');
|
||||
expect(revivedCause.code).toBe('ENOTFOUND');
|
||||
});
|
||||
|
||||
it('is clone-safe: the envelope survives structuredClone (the IPC boundary)', () => {
|
||||
const error = new Error('boom', { cause: new Error('root') });
|
||||
const envelope = toIpcErrorEnvelope(error);
|
||||
|
||||
const cloned = structuredClone(envelope);
|
||||
const revived = fromIpcErrorEnvelope(cloned);
|
||||
|
||||
expect(revived.message).toBe('boom');
|
||||
expect((revived.cause as Error).message).toBe('root');
|
||||
});
|
||||
|
||||
it('handles non-Error thrown values', () => {
|
||||
const revived = fromIpcErrorEnvelope(toIpcErrorEnvelope('plain string failure'));
|
||||
expect(revived.message).toBe('plain string failure');
|
||||
});
|
||||
|
||||
it('caps a deep / cyclic cause chain instead of recursing forever', () => {
|
||||
const a = new Error('a');
|
||||
const b = new Error('b', { cause: a });
|
||||
(a as { cause?: unknown }).cause = b; // cycle
|
||||
|
||||
// Should not throw (stack overflow) — depth is bounded.
|
||||
expect(() => toIpcErrorEnvelope(b)).not.toThrow();
|
||||
});
|
||||
|
||||
it('isIpcErrorEnvelope rejects plain values and look-alikes', () => {
|
||||
expect(isIpcErrorEnvelope(null)).toBe(false);
|
||||
expect(isIpcErrorEnvelope(undefined)).toBe(false);
|
||||
expect(isIpcErrorEnvelope('error')).toBe(false);
|
||||
expect(isIpcErrorEnvelope({ data: 'ok' })).toBe(false);
|
||||
expect(isIpcErrorEnvelope({ __lobeIpcError__: false })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,86 +0,0 @@
|
||||
/**
|
||||
* IPC error envelope.
|
||||
*
|
||||
* Electron's `ipcRenderer.invoke` rebuilds a thrown handler error from a
|
||||
* *string* on the renderer side (roughly `new Error("Error invoking remote
|
||||
* method '<channel>': " + String(mainError))`), so the original error object —
|
||||
* including a non-enumerable `cause` — never crosses the boundary. The real
|
||||
* failure reason (e.g. undici's `ENOTFOUND` / `ECONNREFUSED` hidden under a
|
||||
* generic `TypeError: fetch failed`) is therefore lost.
|
||||
*
|
||||
* To preserve it, the main process *returns* a clone-safe envelope (a plain
|
||||
* object) instead of throwing, and the preload `invoke` wrapper rebuilds a real
|
||||
* `Error` (with `cause`) from the envelope before re-throwing — keeping the
|
||||
* existing "promise rejects on failure" contract for every caller.
|
||||
*/
|
||||
|
||||
const IPC_ERROR_MARKER = '__lobeIpcError__';
|
||||
|
||||
/** Bound recursion on a deliberately malicious / cyclic `cause` chain. */
|
||||
const MAX_CAUSE_DEPTH = 5;
|
||||
|
||||
export interface SerializedIpcError {
|
||||
cause?: SerializedIpcError | string;
|
||||
/** Node/undici machine-readable reason (`ENOTFOUND`, `ECONNREFUSED`, …). */
|
||||
code?: unknown;
|
||||
message: string;
|
||||
name: string;
|
||||
stack?: string;
|
||||
}
|
||||
|
||||
export interface IpcErrorEnvelope {
|
||||
error: SerializedIpcError;
|
||||
[IPC_ERROR_MARKER]: true;
|
||||
}
|
||||
|
||||
const serializeError = (value: unknown, depth: number): SerializedIpcError => {
|
||||
if (value instanceof Error) {
|
||||
const serialized: SerializedIpcError = { message: value.message, name: value.name };
|
||||
|
||||
if (typeof value.stack === 'string') serialized.stack = value.stack;
|
||||
|
||||
const { code } = value as { code?: unknown };
|
||||
if (code !== undefined) serialized.code = code;
|
||||
|
||||
if (value.cause !== undefined && value.cause !== null && depth < MAX_CAUSE_DEPTH) {
|
||||
serialized.cause =
|
||||
value.cause instanceof Error ? serializeError(value.cause, depth + 1) : String(value.cause);
|
||||
}
|
||||
|
||||
return serialized;
|
||||
}
|
||||
|
||||
return { message: typeof value === 'string' ? value : String(value), name: 'Error' };
|
||||
};
|
||||
|
||||
/** Build a clone-safe envelope from a thrown value (main process). */
|
||||
export const toIpcErrorEnvelope = (value: unknown): IpcErrorEnvelope => ({
|
||||
[IPC_ERROR_MARKER]: true,
|
||||
error: serializeError(value, 0),
|
||||
});
|
||||
|
||||
/** Detect an envelope produced by {@link toIpcErrorEnvelope} (preload). */
|
||||
export const isIpcErrorEnvelope = (value: unknown): value is IpcErrorEnvelope =>
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
(value as Record<string, unknown>)[IPC_ERROR_MARKER] === true;
|
||||
|
||||
const reviveError = (serialized: SerializedIpcError): Error => {
|
||||
const cause =
|
||||
serialized.cause === undefined
|
||||
? undefined
|
||||
: typeof serialized.cause === 'string'
|
||||
? serialized.cause
|
||||
: reviveError(serialized.cause);
|
||||
|
||||
const error = new Error(serialized.message, cause === undefined ? undefined : { cause });
|
||||
error.name = serialized.name;
|
||||
if (serialized.stack !== undefined) error.stack = serialized.stack;
|
||||
if (serialized.code !== undefined) (error as { code?: unknown }).code = serialized.code;
|
||||
|
||||
return error;
|
||||
};
|
||||
|
||||
/** Rebuild a real `Error` (with `cause`) from an envelope (preload). */
|
||||
export const fromIpcErrorEnvelope = (envelope: IpcErrorEnvelope): Error =>
|
||||
reviveError(envelope.error);
|
||||
@@ -321,9 +321,7 @@ export default class AuthCtr extends ControllerModule {
|
||||
this.stopAutoRefresh();
|
||||
await this.remoteServerConfigCtr.clearTokens();
|
||||
await this.remoteServerConfigCtr.setRemoteServerConfig({ active: false });
|
||||
this.broadcastAuthorizationRequired(
|
||||
`auto-refresh:non_retryable ${result.error ?? ''}`.trim(),
|
||||
);
|
||||
this.broadcastAuthorizationRequired();
|
||||
} else {
|
||||
// For other errors (after retries exhausted), log but don't clear tokens immediately
|
||||
// The next refresh cycle will retry
|
||||
@@ -434,7 +432,7 @@ export default class AuthCtr extends ControllerModule {
|
||||
this.stopAutoRefresh();
|
||||
await this.remoteServerConfigCtr.clearTokens();
|
||||
await this.remoteServerConfigCtr.setRemoteServerConfig({ active: false });
|
||||
this.broadcastAuthorizationRequired(`refresh:non_retryable ${result.error ?? ''}`.trim());
|
||||
this.broadcastAuthorizationRequired();
|
||||
} else {
|
||||
// For transient errors, don't clear tokens - allow manual retry
|
||||
logger.warn('Refresh failed but error may be transient, tokens preserved for retry');
|
||||
@@ -452,7 +450,7 @@ export default class AuthCtr extends ControllerModule {
|
||||
this.stopAutoRefresh();
|
||||
await this.remoteServerConfigCtr.clearTokens();
|
||||
await this.remoteServerConfigCtr.setRemoteServerConfig({ active: false });
|
||||
this.broadcastAuthorizationRequired(`refresh:exception ${errorMessage}`);
|
||||
this.broadcastAuthorizationRequired();
|
||||
}
|
||||
|
||||
return { error: errorMessage, success: false };
|
||||
@@ -620,17 +618,15 @@ export default class AuthCtr extends ControllerModule {
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast authorization required event.
|
||||
* `reason` is a short tag (e.g. `refresh:invalid_grant`, `startup:non_retryable`)
|
||||
* recorded so the renderer can log why the Session Expired modal appeared.
|
||||
* Broadcast authorization required event
|
||||
*/
|
||||
private broadcastAuthorizationRequired(reason: string) {
|
||||
logger.info(`Broadcasting authorizationRequired event (reason=${reason})`);
|
||||
private broadcastAuthorizationRequired() {
|
||||
logger.debug('Broadcasting authorizationRequired event to all windows');
|
||||
const allWindows = BrowserWindow.getAllWindows();
|
||||
|
||||
for (const win of allWindows) {
|
||||
if (!win.isDestroyed()) {
|
||||
win.webContents.send('authorizationRequired', { reason });
|
||||
win.webContents.send('authorizationRequired');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -755,9 +751,7 @@ export default class AuthCtr extends ControllerModule {
|
||||
logger.warn('Non-retryable error during proactive refresh, clearing tokens');
|
||||
await this.remoteServerConfigCtr.clearTokens();
|
||||
await this.remoteServerConfigCtr.setRemoteServerConfig({ active: false });
|
||||
this.broadcastAuthorizationRequired(
|
||||
`startup:non_retryable ${refreshResult.error ?? ''}`.trim(),
|
||||
);
|
||||
this.broadcastAuthorizationRequired();
|
||||
} else {
|
||||
// For transient errors, still start auto-refresh timer to retry later
|
||||
logger.warn('Transient error during proactive refresh, will retry via auto-refresh');
|
||||
|
||||
@@ -41,33 +41,6 @@ import { createLogger } from '@/utils/logger';
|
||||
import { ControllerModule, IpcMethod } from './index';
|
||||
|
||||
const logger = createLogger('controllers:HeterogeneousAgentCtr');
|
||||
|
||||
// Anthropic auth env vars that must NOT be inherited from the desktop process
|
||||
// when spawning a local CLI agent. A developer with `ANTHROPIC_API_KEY` (or an
|
||||
// auth token / base url) exported in their shell would otherwise have it
|
||||
// forwarded to `claude`, which then switches from its own subscription login to
|
||||
// that key — an expired / wrong key surfaces as a baffling "Invalid API key"
|
||||
// and the run exits non-zero. Agents that genuinely want an API key still set
|
||||
// it through `session.env`, which is spread AFTER the inherited env below and
|
||||
// therefore wins.
|
||||
const STRIPPED_INHERITED_ENV_KEYS = [
|
||||
'ANTHROPIC_API_KEY',
|
||||
'ANTHROPIC_AUTH_TOKEN',
|
||||
'ANTHROPIC_BASE_URL',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Inherited `process.env` with the Anthropic auth vars removed. Keep this pure
|
||||
* and exported so the "never leak host Anthropic creds into the CLI" invariant
|
||||
* can be unit-tested directly.
|
||||
*/
|
||||
export const buildInheritedSpawnEnv = (
|
||||
sourceEnv: NodeJS.ProcessEnv = process.env,
|
||||
): NodeJS.ProcessEnv => {
|
||||
const env = { ...sourceEnv };
|
||||
for (const key of STRIPPED_INHERITED_ENV_KEYS) delete env[key];
|
||||
return env;
|
||||
};
|
||||
const CODEX_RESUME_THREAD_NOT_FOUND_PATTERNS = [
|
||||
/no conversation found/i,
|
||||
/thread .*not found/i,
|
||||
@@ -947,10 +920,7 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
const spawnOptions = {
|
||||
cwd,
|
||||
detached: process.platform !== 'win32',
|
||||
// Strip host Anthropic creds from the inherited env so a developer's
|
||||
// shell `ANTHROPIC_API_KEY` can't hijack the CLI's own auth. `session.env`
|
||||
// is spread last, so an agent that explicitly configures a key still wins.
|
||||
env: { ...buildInheritedSpawnEnv(), ...proxyEnv, ...session.env },
|
||||
env: { ...process.env, ...proxyEnv, ...session.env },
|
||||
stdio: [useStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'] as ['pipe' | 'ignore', 'pipe', 'pipe'],
|
||||
};
|
||||
|
||||
@@ -1338,14 +1308,6 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
} = params;
|
||||
const workDir = cwd ?? process.cwd();
|
||||
|
||||
// When CLI tracing is enabled (dev builds, or the Help-menu toggle in
|
||||
// packaged builds), have `lh hetero exec` persist the agent process's RAW
|
||||
// stream-json (pre-adapter) on this device. The remote-device path
|
||||
// otherwise leaves no local record — the CLI consumes stdout internally and
|
||||
// only POSTs adapted events to the server — so without this there's nothing
|
||||
// to inspect when a remote run misbehaves.
|
||||
const rawDumpDir = this.shouldTraceCliOutput ? this.resolveTraceRootDir(workDir) : undefined;
|
||||
|
||||
const args = [
|
||||
'hetero',
|
||||
'exec',
|
||||
@@ -1362,7 +1324,6 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
'--cwd',
|
||||
workDir,
|
||||
...(resumeSessionId ? ['--resume', resumeSessionId] : []),
|
||||
...(rawDumpDir ? ['--raw-dump', rawDumpDir] : []),
|
||||
];
|
||||
|
||||
const env = {
|
||||
|
||||
@@ -797,12 +797,7 @@ describe('AuthCtr', () => {
|
||||
expect(mockRemoteServerConfigCtr.setRemoteServerConfig).toHaveBeenCalledWith({
|
||||
active: false,
|
||||
});
|
||||
expect(mockWindow.webContents.send).toHaveBeenCalledWith(
|
||||
'authorizationRequired',
|
||||
expect.objectContaining({
|
||||
reason: expect.stringContaining('startup:non_retryable'),
|
||||
}),
|
||||
);
|
||||
expect(mockWindow.webContents.send).toHaveBeenCalledWith('authorizationRequired');
|
||||
});
|
||||
|
||||
it('should preserve tokens on transient error', async () => {
|
||||
|
||||
@@ -313,53 +313,6 @@ describe('HeterogeneousAgentCtr', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not leak host Anthropic auth env into the spawned CLI', async () => {
|
||||
// A developer with these exported in their shell would otherwise have them
|
||||
// forwarded to `claude`, overriding its subscription login and surfacing
|
||||
// as a baffling "Invalid API key" / non-zero exit. Regression guard for
|
||||
// that env-leak.
|
||||
const original = {
|
||||
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
|
||||
ANTHROPIC_AUTH_TOKEN: process.env.ANTHROPIC_AUTH_TOKEN,
|
||||
ANTHROPIC_BASE_URL: process.env.ANTHROPIC_BASE_URL,
|
||||
};
|
||||
process.env.ANTHROPIC_API_KEY = 'sk-host-should-not-leak';
|
||||
process.env.ANTHROPIC_AUTH_TOKEN = 'host-token-should-not-leak';
|
||||
process.env.ANTHROPIC_BASE_URL = 'https://host.example/should-not-leak';
|
||||
|
||||
try {
|
||||
const { options } = await runSendPrompt('hello');
|
||||
|
||||
expect(options.env).not.toHaveProperty('ANTHROPIC_API_KEY');
|
||||
expect(options.env).not.toHaveProperty('ANTHROPIC_AUTH_TOKEN');
|
||||
expect(options.env).not.toHaveProperty('ANTHROPIC_BASE_URL');
|
||||
// Unrelated inherited vars must still pass through.
|
||||
expect(options.env.PATH).toBe(process.env.PATH);
|
||||
} finally {
|
||||
for (const [key, value] of Object.entries(original)) {
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('lets an agent-configured Anthropic key in session.env override the stripped host env', async () => {
|
||||
const originalKey = process.env.ANTHROPIC_API_KEY;
|
||||
process.env.ANTHROPIC_API_KEY = 'sk-host-should-not-leak';
|
||||
|
||||
try {
|
||||
const { options } = await runSendPrompt('hello', {
|
||||
env: { ANTHROPIC_API_KEY: 'sk-agent-explicit' },
|
||||
});
|
||||
|
||||
// Explicit per-agent config wins; the host value is never seen.
|
||||
expect(options.env.ANTHROPIC_API_KEY).toBe('sk-agent-explicit');
|
||||
} finally {
|
||||
if (originalKey === undefined) delete process.env.ANTHROPIC_API_KEY;
|
||||
else process.env.ANTHROPIC_API_KEY = originalKey;
|
||||
}
|
||||
});
|
||||
|
||||
it('captures the Claude Code session id from stream-json init events', async () => {
|
||||
const { ctr, sessionId } = await runSendPrompt('hello', {}, [
|
||||
`${JSON.stringify({ session_id: 'sess_cc_123', subtype: 'init', type: 'system' })}\n`,
|
||||
|
||||
@@ -32,30 +32,22 @@ export class BackendProxyProtocolManager {
|
||||
private readonly logger = createLogger('core:BackendProxyProtocolManager');
|
||||
|
||||
private authRequiredDebounceTimer: NodeJS.Timeout | null = null;
|
||||
private pendingAuthRequiredReason: string | null = null;
|
||||
private static readonly AUTH_REQUIRED_DEBOUNCE_MS = 1000;
|
||||
|
||||
private notifyAuthorizationRequired(reason: string) {
|
||||
private notifyAuthorizationRequired() {
|
||||
// Trailing-edge debounce: coalesce rapid 401 bursts and fire AFTER the burst settles.
|
||||
// This ensures the IPC event is sent after the renderer has had time to mount listeners.
|
||||
// The most recent reason wins — within a burst they almost always describe the same cause.
|
||||
this.pendingAuthRequiredReason = reason;
|
||||
|
||||
if (this.authRequiredDebounceTimer) {
|
||||
clearTimeout(this.authRequiredDebounceTimer);
|
||||
}
|
||||
|
||||
this.authRequiredDebounceTimer = setTimeout(() => {
|
||||
this.authRequiredDebounceTimer = null;
|
||||
const finalReason = this.pendingAuthRequiredReason ?? reason;
|
||||
this.pendingAuthRequiredReason = null;
|
||||
|
||||
this.logger.info(`Broadcasting authorizationRequired (reason=${finalReason})`);
|
||||
|
||||
const allWindows = BrowserWindow.getAllWindows();
|
||||
for (const win of allWindows) {
|
||||
if (!win.isDestroyed()) {
|
||||
win.webContents.send('authorizationRequired', { reason: finalReason });
|
||||
win.webContents.send('authorizationRequired');
|
||||
}
|
||||
}
|
||||
}, BackendProxyProtocolManager.AUTH_REQUIRED_DEBOUNCE_MS);
|
||||
@@ -204,32 +196,7 @@ export class BackendProxyProtocolManager {
|
||||
// Other failures keep 401 without this header (e.g., invalid API keys) and must not notify here.
|
||||
const authRequired = upstreamResponse.headers.get(AUTH_REQUIRED_HEADER) === 'true';
|
||||
if (authRequired) {
|
||||
const pathTag = (() => {
|
||||
try {
|
||||
return new URL(rewrittenUrl).pathname;
|
||||
} catch {
|
||||
return rewrittenUrl;
|
||||
}
|
||||
})();
|
||||
const sourceTag = context.source ? `${context.source}:` : '';
|
||||
const wwwAuth = upstreamResponse.headers.get('www-authenticate') ?? '';
|
||||
// Clone before forwarding the body downstream — the original stream stays
|
||||
// intact for the renderer. Body snippet is truncated to keep logs small
|
||||
// and to avoid leaking large payloads if the server ever returns one.
|
||||
let bodySnippet: string;
|
||||
try {
|
||||
bodySnippet = (await upstreamResponse.clone().text()).slice(0, 300).replaceAll(/\s+/g, ' ');
|
||||
} catch (error) {
|
||||
bodySnippet = `<body-read-failed:${error instanceof Error ? error.message : 'unknown'}>`;
|
||||
}
|
||||
const parts = [
|
||||
`proxy:${sourceTag}status=${upstreamResponse.status}`,
|
||||
`${request.method} ${pathTag}`,
|
||||
`hadToken=${Boolean(token)}`,
|
||||
];
|
||||
if (wwwAuth) parts.push(`wwwAuth=${wwwAuth}`);
|
||||
if (bodySnippet) parts.push(`body=${bodySnippet}`);
|
||||
this.notifyAuthorizationRequired(parts.join(' '));
|
||||
this.notifyAuthorizationRequired();
|
||||
}
|
||||
|
||||
return new Response(upstreamResponse.body, {
|
||||
|
||||
+1
-57
@@ -258,63 +258,7 @@ describe('BackendProxyProtocolManager', () => {
|
||||
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(send).toHaveBeenCalledWith(
|
||||
'authorizationRequired',
|
||||
expect.objectContaining({
|
||||
reason: expect.stringContaining('status=207'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('captures www-authenticate, body snippet and hadToken in reason on 401', async () => {
|
||||
vi.useFakeTimers();
|
||||
const send = vi.fn();
|
||||
vi.mocked(BrowserWindow.getAllWindows).mockReturnValue([
|
||||
{ isDestroyed: () => false, webContents: { send } },
|
||||
] as any);
|
||||
|
||||
const manager = new BackendProxyProtocolManager();
|
||||
const session = {} as any;
|
||||
|
||||
const upstreamBody = JSON.stringify({
|
||||
error: { json: { data: { code: 'UNAUTHORIZED' }, message: 'token expired at 2026-06-09' } },
|
||||
});
|
||||
const headers = new Headers({
|
||||
[AUTH_REQUIRED_HEADER]: 'true',
|
||||
'Content-Type': 'application/json',
|
||||
'www-authenticate': 'Bearer error="invalid_token", error_description="expired"',
|
||||
});
|
||||
const fetchMock = vi.fn<FetchMock>(
|
||||
async () => new Response(upstreamBody, { headers, status: 401, statusText: 'Unauthorized' }),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock as any);
|
||||
|
||||
manager.registerWithRemoteBaseUrl(session, {
|
||||
getAccessToken: async () => 'fake-token',
|
||||
getRemoteBaseUrl: async () => 'https://remote.example.com',
|
||||
});
|
||||
|
||||
const response = await manager.proxy(
|
||||
{
|
||||
headers: new Headers(),
|
||||
method: 'POST',
|
||||
url: 'app://renderer/trpc/lambda/me',
|
||||
} as any,
|
||||
session,
|
||||
);
|
||||
|
||||
// Original body is still readable by the downstream caller — clone() must not consume it.
|
||||
expect(await response!.text()).toBe(upstreamBody);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
const [, payload] = send.mock.calls[0];
|
||||
expect(payload.reason).toContain('status=401');
|
||||
expect(payload.reason).toContain('POST /trpc/lambda/me');
|
||||
expect(payload.reason).toContain('hadToken=true');
|
||||
expect(payload.reason).toContain('wwwAuth=Bearer error="invalid_token"');
|
||||
expect(payload.reason).toContain('UNAUTHORIZED');
|
||||
expect(payload.reason).toContain('token expired');
|
||||
expect(send).toHaveBeenCalledWith('authorizationRequired');
|
||||
});
|
||||
|
||||
describe('createAppRequestInterceptor', () => {
|
||||
|
||||
@@ -3,8 +3,6 @@ import { AsyncLocalStorage } from 'node:async_hooks';
|
||||
import type { IpcMainInvokeEvent, WebContents } from 'electron';
|
||||
import { ipcMain } from 'electron';
|
||||
|
||||
import { toIpcErrorEnvelope } from '~common/ipcError';
|
||||
|
||||
// Base context for IPC methods
|
||||
export interface IpcContext {
|
||||
event: IpcMainInvokeEvent;
|
||||
@@ -65,11 +63,7 @@ export class IpcHandler {
|
||||
return await handler(...typedArgs);
|
||||
} catch (error) {
|
||||
console.error(`Error in IPC method ${channel}:`, error);
|
||||
// Return a clone-safe envelope rather than throwing: Electron rebuilds
|
||||
// a thrown handler error from its string form, dropping `cause` and
|
||||
// other structured fields. The preload `invoke` wrapper rebuilds a
|
||||
// real Error from the envelope and re-throws it. See `~common/ipcError`.
|
||||
return toIpcErrorEnvelope(error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -62,30 +62,6 @@ describe('invoke', () => {
|
||||
expect(mockIpcRendererInvoke).toHaveBeenCalledWith('system.getAppVersion');
|
||||
});
|
||||
|
||||
it('should rebuild and throw a real Error (with cause) from a main-process error envelope', async () => {
|
||||
mockIpcRendererInvoke.mockResolvedValue({
|
||||
__lobeIpcError__: true,
|
||||
error: {
|
||||
cause: { code: 'ENOTFOUND', message: 'getaddrinfo ENOTFOUND example.com', name: 'Error' },
|
||||
message: 'fetch failed',
|
||||
name: 'TypeError',
|
||||
},
|
||||
});
|
||||
|
||||
await expect(invoke('heterogeneousAgent.sendPrompt')).rejects.toMatchObject({
|
||||
cause: { code: 'ENOTFOUND', message: 'getaddrinfo ENOTFOUND example.com' },
|
||||
message: 'fetch failed',
|
||||
name: 'TypeError',
|
||||
});
|
||||
});
|
||||
|
||||
it('should not treat a plain object result as an error envelope', async () => {
|
||||
const result = { __lobeIpcError__: false, data: 'ok' };
|
||||
mockIpcRendererInvoke.mockResolvedValue(result);
|
||||
|
||||
await expect(invoke('someEvent')).resolves.toEqual(result);
|
||||
});
|
||||
|
||||
it('should handle ipcRenderer returning undefined', async () => {
|
||||
mockIpcRendererInvoke.mockResolvedValue(undefined);
|
||||
|
||||
|
||||
@@ -1,24 +1,8 @@
|
||||
import { ipcRenderer } from 'electron';
|
||||
|
||||
import { fromIpcErrorEnvelope, isIpcErrorEnvelope } from '~common/ipcError';
|
||||
|
||||
type IpcInvoke = <T = unknown>(event: string, ...data: unknown[]) => Promise<T>;
|
||||
|
||||
/**
|
||||
* Client-side method to invoke electron main process.
|
||||
*
|
||||
* The main-process handler returns an error envelope instead of throwing (see
|
||||
* `~common/ipcError`), so structured failure detail — notably `cause` — isn't
|
||||
* flattened away by Electron's thrown-error serialization. Rebuild the real
|
||||
* Error here and re-throw it, preserving the "promise rejects on failure"
|
||||
* contract every caller already relies on.
|
||||
* Client-side method to invoke electron main process
|
||||
*/
|
||||
export const invoke: IpcInvoke = async (event, ...data) => {
|
||||
const result = await ipcRenderer.invoke(event, ...data);
|
||||
|
||||
if (isIpcErrorEnvelope(result)) {
|
||||
throw fromIpcErrorEnvelope(result);
|
||||
}
|
||||
|
||||
return result as never;
|
||||
};
|
||||
export const invoke: IpcInvoke = async (event, ...data) => ipcRenderer.invoke(event, ...data);
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"name": "@lobechat/server",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"type-check": "tsc --noEmit"
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
// @vitest-environment node
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
EvalDatasetRecordModel,
|
||||
EvalEvaluationModel,
|
||||
EvaluationRecordModel,
|
||||
} from '@/database/models/ragEval';
|
||||
|
||||
import { ragEvalRouter } from '../ragEval';
|
||||
|
||||
vi.mock('@/database/models/chunk', () => ({
|
||||
ChunkModel: vi.fn(() => ({})),
|
||||
}));
|
||||
vi.mock('@/database/models/embedding', () => ({
|
||||
EmbeddingModel: vi.fn(() => ({})),
|
||||
}));
|
||||
vi.mock('@/database/models/file', () => ({
|
||||
FileModel: vi.fn(() => ({})),
|
||||
}));
|
||||
vi.mock('@/database/models/ragEval', () => ({
|
||||
EvalDatasetRecordModel: vi.fn(() => ({ findById: vi.fn() })),
|
||||
EvalEvaluationModel: vi.fn(() => ({ update: vi.fn() })),
|
||||
EvaluationRecordModel: vi.fn(() => ({ findById: vi.fn().mockResolvedValue(null) })),
|
||||
}));
|
||||
vi.mock('@/server/modules/ModelRuntime', () => ({
|
||||
initModelRuntimeFromDB: vi.fn(),
|
||||
}));
|
||||
vi.mock('@/server/services/chunk', () => ({
|
||||
ChunkService: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock('@/libs/trpc/async', async () => {
|
||||
const init = await vi.importActual<{ asyncTrpc: any }>('@/libs/trpc/async/init');
|
||||
const { asyncTrpc } = init;
|
||||
return {
|
||||
asyncAuthedProcedure: asyncTrpc.procedure,
|
||||
asyncRouter: asyncTrpc.router,
|
||||
createAsyncCallerFactory: asyncTrpc.createCallerFactory,
|
||||
publicProcedure: asyncTrpc.procedure,
|
||||
};
|
||||
});
|
||||
|
||||
describe('ragEvalRouter.runRecordEvaluation', () => {
|
||||
const userId = 'user_test';
|
||||
const serverDB = {
|
||||
select: vi.fn(() => ({
|
||||
from: vi.fn(() => ({
|
||||
where: vi.fn(() => ({
|
||||
limit: vi.fn().mockResolvedValue([{ workspaceId: 'workspace-1' }]),
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('resolves workspaceId from the evaluation record before reading scoped models', async () => {
|
||||
const caller = ragEvalRouter.createCaller({ serverDB, userId } as any);
|
||||
|
||||
await expect(caller.runRecordEvaluation({ evalRecordId: 'eval-record-1' })).rejects.toThrow(
|
||||
TRPCError,
|
||||
);
|
||||
|
||||
expect(EvaluationRecordModel).toHaveBeenCalledWith(serverDB, userId, 'workspace-1');
|
||||
expect(EvalEvaluationModel).toHaveBeenCalledWith(serverDB, userId, 'workspace-1');
|
||||
expect(EvalDatasetRecordModel).toHaveBeenCalledWith(serverDB, userId, 'workspace-1');
|
||||
});
|
||||
});
|
||||
@@ -1,96 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { knowledgeBaseRouter } from '@/server/routers/lambda/knowledgeBase';
|
||||
import { TransferErrorCode } from '@/types/transferError';
|
||||
|
||||
const routerMocks = vi.hoisted(() => ({
|
||||
businessFileTransferStorageCheck: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockKnowledgeBaseModelCountFileUsage = vi.fn();
|
||||
const mockKnowledgeBaseModelCopyToWorkspace = vi.fn();
|
||||
const mockKnowledgeBaseModelFindById = vi.fn();
|
||||
const mockKnowledgeBaseModelTransferTo = vi.fn();
|
||||
|
||||
vi.mock('@/business/server/lambda-routers/file', () => ({
|
||||
businessFileTransferStorageCheck: routerMocks.businessFileTransferStorageCheck,
|
||||
}));
|
||||
|
||||
vi.mock('@/database/models/knowledgeBase', () => ({
|
||||
KnowledgeBaseModel: vi.fn(() => ({
|
||||
copyToWorkspace: mockKnowledgeBaseModelCopyToWorkspace,
|
||||
countFileUsage: mockKnowledgeBaseModelCountFileUsage,
|
||||
findById: mockKnowledgeBaseModelFindById,
|
||||
transferTo: mockKnowledgeBaseModelTransferTo,
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('knowledgeBaseRouter', () => {
|
||||
const ctx = {
|
||||
serverDB: {},
|
||||
userId: 'test-user',
|
||||
workspaceId: 'workspace-active',
|
||||
};
|
||||
|
||||
const caller = knowledgeBaseRouter.createCaller(ctx as any);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
routerMocks.businessFileTransferStorageCheck.mockResolvedValue(undefined);
|
||||
mockKnowledgeBaseModelCopyToWorkspace.mockResolvedValue({ id: 'kb-copy' });
|
||||
mockKnowledgeBaseModelCountFileUsage.mockResolvedValue(4096);
|
||||
mockKnowledgeBaseModelFindById.mockResolvedValue({ id: 'kb-1' });
|
||||
mockKnowledgeBaseModelTransferTo.mockResolvedValue({ id: 'kb-1' });
|
||||
});
|
||||
|
||||
describe('transferKnowledgeBase', () => {
|
||||
it('checks target storage before transferring a library', async () => {
|
||||
await caller.transferKnowledgeBase({
|
||||
id: 'kb-1',
|
||||
targetWorkspaceId: null,
|
||||
});
|
||||
|
||||
expect(mockKnowledgeBaseModelCountFileUsage).toHaveBeenCalledWith('kb-1');
|
||||
expect(routerMocks.businessFileTransferStorageCheck).toHaveBeenCalledWith({
|
||||
additionalSize: 4096,
|
||||
targetUserId: 'test-user',
|
||||
targetWorkspaceId: null,
|
||||
});
|
||||
expect(mockKnowledgeBaseModelTransferTo).toHaveBeenCalledWith('kb-1', null, 'test-user');
|
||||
});
|
||||
|
||||
it('returns a stable error code when the library no longer exists', async () => {
|
||||
mockKnowledgeBaseModelFindById.mockResolvedValue(undefined);
|
||||
|
||||
await expect(
|
||||
caller.transferKnowledgeBase({
|
||||
id: 'missing-kb',
|
||||
targetWorkspaceId: null,
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
cause: {
|
||||
data: {
|
||||
code: TransferErrorCode.ResourceNotFound,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('copyKnowledgeBaseToWorkspace', () => {
|
||||
it('checks target storage before copying a library', async () => {
|
||||
await caller.copyKnowledgeBaseToWorkspace({
|
||||
id: 'kb-1',
|
||||
targetWorkspaceId: null,
|
||||
});
|
||||
|
||||
expect(mockKnowledgeBaseModelCountFileUsage).toHaveBeenCalledWith('kb-1');
|
||||
expect(routerMocks.businessFileTransferStorageCheck).toHaveBeenCalledWith({
|
||||
additionalSize: 4096,
|
||||
targetUserId: 'test-user',
|
||||
targetWorkspaceId: null,
|
||||
});
|
||||
expect(mockKnowledgeBaseModelCopyToWorkspace).toHaveBeenCalledWith('kb-1', null, 'test-user');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,343 +0,0 @@
|
||||
import { BRANDING_PROVIDER } from '@lobechat/business-const';
|
||||
import { isLobeHubModelAvailable } from '@lobechat/business-model-bank/model-config';
|
||||
import { resolveBusinessModelMapping } from '@lobechat/business-model-runtime';
|
||||
import { ChatErrorType } from '@lobechat/types';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import debug from 'debug';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { chargeBeforeGenerate } from '@/business/server/image-generation/chargeBeforeGenerate';
|
||||
import { withScopedPermission } from '@/business/server/trpc-middlewares/rbacPermission';
|
||||
import { wsCompatProcedure } from '@/business/server/trpc-middlewares/workspaceAuth';
|
||||
import { AsyncTaskModel } from '@/database/models/asyncTask';
|
||||
import { UserModel } from '@/database/models/user';
|
||||
import { type NewGeneration, type NewGenerationBatch } from '@/database/schemas';
|
||||
import { asyncTasks, generationBatches, generations } from '@/database/schemas';
|
||||
import { router } from '@/libs/trpc/lambda';
|
||||
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
|
||||
import { createAsyncCaller } from '@/server/routers/async/caller';
|
||||
import { FileService } from '@/server/services/file';
|
||||
import {
|
||||
AsyncTaskError,
|
||||
AsyncTaskErrorType,
|
||||
AsyncTaskStatus,
|
||||
AsyncTaskType,
|
||||
} from '@/types/asyncTask';
|
||||
import { generateUniqueSeeds } from '@/utils/number';
|
||||
|
||||
import { validateNoUrlsInConfig } from './utils';
|
||||
|
||||
const log = debug('lobe-image:lambda');
|
||||
|
||||
const imageProcedure = wsCompatProcedure.use(serverDatabase).use(async (opts) => {
|
||||
const { ctx } = opts;
|
||||
const wsId = ctx.workspaceId ?? undefined;
|
||||
|
||||
return opts.next({
|
||||
ctx: {
|
||||
asyncTaskModel: new AsyncTaskModel(ctx.serverDB, ctx.userId, wsId),
|
||||
fileService: new FileService(ctx.serverDB, ctx.userId, wsId),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const imageCreateProcedure = imageProcedure.use(withScopedPermission('file:upload'));
|
||||
|
||||
const createImageInputSchema = z.object({
|
||||
generationTopicId: z.string(),
|
||||
imageNum: z.number(),
|
||||
model: z.string(),
|
||||
params: z
|
||||
.object({
|
||||
cfg: z.number().optional(),
|
||||
height: z.number().optional(),
|
||||
imageUrls: z.array(z.string()).optional(),
|
||||
prompt: z.string(),
|
||||
seed: z.number().nullable().optional(),
|
||||
steps: z.number().optional(),
|
||||
width: z.number().optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
provider: z.string(),
|
||||
});
|
||||
export type CreateImageServicePayload = z.infer<typeof createImageInputSchema>;
|
||||
|
||||
export const imageRouter = router({
|
||||
createImage: imageCreateProcedure
|
||||
.input(createImageInputSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { userId, serverDB, asyncTaskModel, fileService } = ctx;
|
||||
const wsId = ctx.workspaceId ?? undefined;
|
||||
const { generationTopicId, provider, model, imageNum, params } = input;
|
||||
|
||||
log('Starting image creation process, input: %O', input);
|
||||
|
||||
const { resolvedModelId } = await resolveBusinessModelMapping(provider, model);
|
||||
|
||||
// Reject lobehub model ids that are no longer in the model bank so callers get a
|
||||
// clear error instead of an opaque downstream failure when the underlying channel
|
||||
// can't serve the requested id.
|
||||
if (
|
||||
provider === BRANDING_PROVIDER &&
|
||||
!(await isLobeHubModelAvailable(resolvedModelId, 'image', {
|
||||
getUserEmail: async () => (await UserModel.findById(serverDB, userId))?.email,
|
||||
}))
|
||||
) {
|
||||
throw new TRPCError({
|
||||
cause: { data: { modelType: 'image', requestedModel: model } },
|
||||
code: 'BAD_REQUEST',
|
||||
message: ChatErrorType.LobeHubModelDeprecated,
|
||||
});
|
||||
}
|
||||
|
||||
// Normalize reference image addresses, store S3 keys uniformly (avoid storing expiring presigned URLs in database)
|
||||
let configForDatabase = { ...params };
|
||||
// 1) Process multiple images in imageUrls
|
||||
if (Array.isArray(params.imageUrls) && params.imageUrls.length > 0) {
|
||||
log('Converting imageUrls to S3 keys for database storage: %O', params.imageUrls);
|
||||
try {
|
||||
const imageKeysWithNull = await Promise.all(
|
||||
params.imageUrls.map(async (url) => {
|
||||
const key = await fileService.getKeyFromFullUrl(url);
|
||||
if (key) {
|
||||
log('Converted URL %s to key %s', url, key);
|
||||
} else {
|
||||
log('Failed to extract key from URL: %s', url);
|
||||
}
|
||||
return key;
|
||||
}),
|
||||
);
|
||||
const imageKeys = imageKeysWithNull.filter((key): key is string => key !== null);
|
||||
|
||||
configForDatabase = {
|
||||
...configForDatabase,
|
||||
imageUrls: imageKeys,
|
||||
};
|
||||
log('Successfully converted imageUrls to keys for database: %O', imageKeys);
|
||||
} catch (error) {
|
||||
console.error('Error converting imageUrls to keys: %O', error);
|
||||
console.error('Keeping original imageUrls due to conversion error');
|
||||
}
|
||||
}
|
||||
// 2) Process single image in imageUrl
|
||||
if (typeof params.imageUrl === 'string' && params.imageUrl) {
|
||||
try {
|
||||
const key = await fileService.getKeyFromFullUrl(params.imageUrl);
|
||||
if (key) {
|
||||
log('Converted single imageUrl to key: %s -> %s', params.imageUrl, key);
|
||||
configForDatabase = { ...configForDatabase, imageUrl: key };
|
||||
} else {
|
||||
log('Failed to extract key from single imageUrl: %s', params.imageUrl);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error converting imageUrl to key: %O', error);
|
||||
// Keep original value if conversion fails
|
||||
}
|
||||
}
|
||||
|
||||
// In development, convert localhost proxy URLs to S3 URLs for async task access
|
||||
let generationParams = params;
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
const updates: Record<string, unknown> = {};
|
||||
|
||||
// Handle single imageUrl: localhost/f/{id} -> S3 URL
|
||||
if (typeof params.imageUrl === 'string' && params.imageUrl) {
|
||||
const s3Url = await fileService.getFullFileUrl(configForDatabase.imageUrl as string);
|
||||
if (s3Url) {
|
||||
log('Dev: converted proxy URL to S3 URL: %s -> %s', params.imageUrl, s3Url);
|
||||
updates.imageUrl = s3Url;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle multiple imageUrls
|
||||
if (Array.isArray(params.imageUrls) && params.imageUrls.length > 0) {
|
||||
const s3Urls = await Promise.all(
|
||||
(configForDatabase.imageUrls as string[]).map((key) => fileService.getFullFileUrl(key)),
|
||||
);
|
||||
log('Dev: converted proxy URLs to S3 URLs: %O', s3Urls);
|
||||
updates.imageUrls = s3Urls;
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
generationParams = { ...params, ...updates };
|
||||
}
|
||||
}
|
||||
|
||||
// Defensive check: ensure no full URLs enter the database
|
||||
validateNoUrlsInConfig(configForDatabase, 'configForDatabase');
|
||||
|
||||
const chargeResult = await chargeBeforeGenerate({
|
||||
clientIp: ctx.clientIp,
|
||||
configForDatabase,
|
||||
generationParams,
|
||||
generationTopicId,
|
||||
imageNum,
|
||||
model,
|
||||
provider,
|
||||
userId,
|
||||
workspaceId: wsId,
|
||||
});
|
||||
if (chargeResult) {
|
||||
return chargeResult;
|
||||
}
|
||||
|
||||
// Step 1: Atomically create all database records in a transaction
|
||||
const { batch: createdBatch, generationsWithTasks } = await serverDB.transaction(
|
||||
async (tx) => {
|
||||
log('Starting database transaction for image generation');
|
||||
|
||||
// 1. Create generationBatch
|
||||
const newBatch: NewGenerationBatch = {
|
||||
config: configForDatabase,
|
||||
generationTopicId,
|
||||
height: params.height,
|
||||
model,
|
||||
prompt: params.prompt,
|
||||
provider,
|
||||
userId,
|
||||
workspaceId: wsId,
|
||||
width: params.width, // Use converted config for database storage
|
||||
};
|
||||
log('Creating generation batch: %O', newBatch);
|
||||
const [batch] = await tx.insert(generationBatches).values(newBatch).returning();
|
||||
log('Generation batch created successfully: %s', batch.id);
|
||||
|
||||
// 2. Create generations
|
||||
const seeds =
|
||||
'seed' in params
|
||||
? generateUniqueSeeds(imageNum)
|
||||
: Array.from({ length: imageNum }, () => null);
|
||||
const newGenerations: NewGeneration[] = Array.from({ length: imageNum }, (_, index) => {
|
||||
return {
|
||||
generationBatchId: batch.id,
|
||||
seed: seeds[index],
|
||||
userId,
|
||||
workspaceId: wsId,
|
||||
};
|
||||
});
|
||||
|
||||
log('Creating %d generations for batch: %s', newGenerations.length, batch.id);
|
||||
const createdGenerations = await tx
|
||||
.insert(generations)
|
||||
.values(newGenerations)
|
||||
.returning();
|
||||
log(
|
||||
'Generations created successfully: %O',
|
||||
createdGenerations.map((g) => g.id),
|
||||
);
|
||||
|
||||
// 3. Concurrently create asyncTask for each generation (within transaction)
|
||||
log('Creating async tasks for generations');
|
||||
const generationsWithTasks = await Promise.all(
|
||||
createdGenerations.map(async (generation) => {
|
||||
// Create asyncTask directly in transaction
|
||||
const [createdAsyncTask] = await tx
|
||||
.insert(asyncTasks)
|
||||
.values({
|
||||
status: AsyncTaskStatus.Pending,
|
||||
type: AsyncTaskType.ImageGeneration,
|
||||
userId,
|
||||
workspaceId: wsId,
|
||||
})
|
||||
.returning();
|
||||
|
||||
const asyncTaskId = createdAsyncTask.id;
|
||||
log('Created async task %s for generation %s', asyncTaskId, generation.id);
|
||||
|
||||
// Update generation's asyncTaskId
|
||||
await tx
|
||||
.update(generations)
|
||||
.set({ asyncTaskId })
|
||||
.where(and(eq(generations.id, generation.id), eq(generations.userId, userId)));
|
||||
|
||||
return { asyncTaskId, generation };
|
||||
}),
|
||||
);
|
||||
log('All async tasks created in transaction');
|
||||
|
||||
return {
|
||||
batch,
|
||||
generationsWithTasks,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
log('Database transaction completed successfully. Starting async task triggers directly.');
|
||||
|
||||
// Step 2: Trigger background image generation tasks using after() API
|
||||
log('Starting async image generation tasks with after()');
|
||||
|
||||
try {
|
||||
log('Creating unified async caller for userId: %s', userId);
|
||||
|
||||
// Async router will read keyVaults from DB, no need to pass jwtPayload
|
||||
const asyncCaller = await createAsyncCaller({
|
||||
userId: ctx.userId,
|
||||
});
|
||||
|
||||
log('Unified async caller created successfully for userId: %s', ctx.userId);
|
||||
log('Processing %d async image generation tasks', generationsWithTasks.length);
|
||||
|
||||
// Fire-and-forget: trigger async tasks without awaiting
|
||||
// These calls go to the async router which handles them independently
|
||||
// Do NOT use after() here as it would keep the lambda alive unnecessarily
|
||||
generationsWithTasks.forEach(({ generation, asyncTaskId }) => {
|
||||
log('Starting background async task %s for generation %s', asyncTaskId, generation.id);
|
||||
|
||||
asyncCaller.image.createImage({
|
||||
generationBatchId: createdBatch.id,
|
||||
generationId: generation.id,
|
||||
generationTopicId,
|
||||
model,
|
||||
params: generationParams,
|
||||
provider,
|
||||
taskId: asyncTaskId,
|
||||
workspaceId: wsId,
|
||||
});
|
||||
});
|
||||
|
||||
log('All %d background async image generation tasks started', generationsWithTasks.length);
|
||||
} catch (e) {
|
||||
console.error('Failed to process async tasks:', e);
|
||||
console.error('Failed to process async tasks: %O', e);
|
||||
|
||||
// If overall failure occurs, update all task statuses to failed
|
||||
try {
|
||||
await Promise.allSettled(
|
||||
generationsWithTasks.map(({ asyncTaskId }) =>
|
||||
asyncTaskModel.update(asyncTaskId, {
|
||||
error: new AsyncTaskError(
|
||||
AsyncTaskErrorType.ServerError,
|
||||
'start async task error: ' + (e instanceof Error ? e.message : 'Unknown error'),
|
||||
),
|
||||
status: AsyncTaskStatus.Error,
|
||||
}),
|
||||
),
|
||||
);
|
||||
} catch (batchUpdateError) {
|
||||
console.error('Failed to update batch task statuses:', batchUpdateError);
|
||||
}
|
||||
}
|
||||
|
||||
const createdGenerations = generationsWithTasks.map((item) => ({
|
||||
...item.generation,
|
||||
asyncTaskId: item.asyncTaskId,
|
||||
}));
|
||||
log('Image creation process completed successfully: %O', {
|
||||
batchId: createdBatch.id,
|
||||
generationCount: createdGenerations.length,
|
||||
generationIds: createdGenerations.map((g) => g.id),
|
||||
});
|
||||
|
||||
return {
|
||||
data: {
|
||||
batch: createdBatch,
|
||||
generations: createdGenerations,
|
||||
},
|
||||
success: true,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
export type ImageRouter = typeof imageRouter;
|
||||
@@ -1,238 +0,0 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { and, eq, isNull } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { businessFileTransferStorageCheck } from '@/business/server/lambda-routers/file';
|
||||
import { withScopedPermission } from '@/business/server/trpc-middlewares/rbacPermission';
|
||||
import { wsCompatProcedure } from '@/business/server/trpc-middlewares/workspaceAuth';
|
||||
import { serverDBEnv } from '@/config/db';
|
||||
import { KnowledgeBaseModel } from '@/database/models/knowledgeBase';
|
||||
import { insertKnowledgeBasesSchema, workspaceMembers } from '@/database/schemas';
|
||||
import { router } from '@/libs/trpc/lambda';
|
||||
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
|
||||
import { FileService } from '@/server/services/file';
|
||||
import { type KnowledgeBaseItem } from '@/types/knowledgeBase';
|
||||
import { TransferErrorCode } from '@/types/transferError';
|
||||
|
||||
const knowledgeBaseProcedure = wsCompatProcedure.use(serverDatabase).use(async (opts) => {
|
||||
const { ctx } = opts;
|
||||
const wsId = ctx.workspaceId ?? undefined;
|
||||
|
||||
return opts.next({
|
||||
ctx: {
|
||||
knowledgeBaseModel: new KnowledgeBaseModel(ctx.serverDB, ctx.userId, wsId),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
export const knowledgeBaseRouter = router({
|
||||
addFilesToKnowledgeBase: knowledgeBaseProcedure
|
||||
.use(withScopedPermission('knowledge_base:update'))
|
||||
.input(z.object({ ids: z.array(z.string()), knowledgeBaseId: z.string() }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
return await ctx.knowledgeBaseModel.addFilesToKnowledgeBase(
|
||||
input.knowledgeBaseId,
|
||||
input.ids,
|
||||
);
|
||||
} catch (e: any) {
|
||||
// Check for PostgreSQL unique constraint violation (code 23505)
|
||||
const pgErrorCode = e?.cause?.cause?.code || e?.cause?.code || e?.code;
|
||||
if (pgErrorCode === '23505') {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: 'FILE_ALREADY_IN_KNOWLEDGE_BASE',
|
||||
});
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}),
|
||||
|
||||
createKnowledgeBase: knowledgeBaseProcedure
|
||||
.use(withScopedPermission('knowledge_base:create'))
|
||||
.input(
|
||||
z.object({
|
||||
avatar: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
name: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const data = await ctx.knowledgeBaseModel.create({
|
||||
avatar: input.avatar,
|
||||
description: input.description,
|
||||
name: input.name,
|
||||
});
|
||||
|
||||
return data?.id;
|
||||
}),
|
||||
|
||||
copyKnowledgeBaseToWorkspace: knowledgeBaseProcedure
|
||||
.use(withScopedPermission('knowledge_base:create'))
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
targetWorkspaceId: z.string().nullable(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const knowledgeBase = await ctx.knowledgeBaseModel.findById(input.id);
|
||||
if (!knowledgeBase) {
|
||||
throw new TRPCError({
|
||||
cause: { data: { code: TransferErrorCode.ResourceNotFound } },
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Knowledge base not found',
|
||||
});
|
||||
}
|
||||
|
||||
if (input.targetWorkspaceId) {
|
||||
const [targetMembership] = await ctx.serverDB
|
||||
.select({ role: workspaceMembers.role })
|
||||
.from(workspaceMembers)
|
||||
.where(
|
||||
and(
|
||||
eq(workspaceMembers.workspaceId, input.targetWorkspaceId),
|
||||
eq(workspaceMembers.userId, ctx.userId),
|
||||
isNull(workspaceMembers.deletedAt),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (!targetMembership || targetMembership.role === 'viewer') {
|
||||
throw new TRPCError({
|
||||
cause: { data: { code: TransferErrorCode.TargetNoWriteAccess } },
|
||||
code: 'FORBIDDEN',
|
||||
message: 'No write access to target workspace',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const additionalSize = await ctx.knowledgeBaseModel.countFileUsage(input.id);
|
||||
await businessFileTransferStorageCheck({
|
||||
additionalSize,
|
||||
targetUserId: ctx.userId,
|
||||
targetWorkspaceId: input.targetWorkspaceId,
|
||||
});
|
||||
|
||||
return ctx.knowledgeBaseModel.copyToWorkspace(input.id, input.targetWorkspaceId, ctx.userId);
|
||||
}),
|
||||
|
||||
getKnowledgeBaseById: knowledgeBaseProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }): Promise<KnowledgeBaseItem | undefined> => {
|
||||
return ctx.knowledgeBaseModel.findById(input.id);
|
||||
}),
|
||||
|
||||
getKnowledgeBases: knowledgeBaseProcedure.query(async ({ ctx }): Promise<KnowledgeBaseItem[]> => {
|
||||
return ctx.knowledgeBaseModel.query();
|
||||
}),
|
||||
|
||||
removeAllKnowledgeBases: knowledgeBaseProcedure
|
||||
.use(withScopedPermission('knowledge_base:delete'))
|
||||
.mutation(async ({ ctx }) => {
|
||||
const result = await ctx.knowledgeBaseModel.deleteAllWithFiles(
|
||||
serverDBEnv.REMOVE_GLOBAL_FILE,
|
||||
);
|
||||
|
||||
if (result.deletedFiles.length > 0) {
|
||||
const fileService = new FileService(ctx.serverDB, ctx.userId, ctx.workspaceId ?? undefined);
|
||||
const urls = result.deletedFiles.map((f) => f.url).filter(Boolean) as string[];
|
||||
if (urls.length > 0) {
|
||||
await fileService.deleteFiles(urls);
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
removeFilesFromKnowledgeBase: knowledgeBaseProcedure
|
||||
.use(withScopedPermission('knowledge_base:update'))
|
||||
.input(z.object({ ids: z.array(z.string()), knowledgeBaseId: z.string() }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
return ctx.knowledgeBaseModel.removeFilesFromKnowledgeBase(input.knowledgeBaseId, input.ids);
|
||||
}),
|
||||
|
||||
removeKnowledgeBase: knowledgeBaseProcedure
|
||||
.use(withScopedPermission('knowledge_base:delete'))
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const result = await ctx.knowledgeBaseModel.deleteWithFiles(
|
||||
input.id,
|
||||
serverDBEnv.REMOVE_GLOBAL_FILE,
|
||||
);
|
||||
|
||||
if (result.deletedFiles.length > 0) {
|
||||
const fileService = new FileService(ctx.serverDB, ctx.userId, ctx.workspaceId ?? undefined);
|
||||
const urls = result.deletedFiles.map((f) => f.url).filter(Boolean) as string[];
|
||||
if (urls.length > 0) {
|
||||
await fileService.deleteFiles(urls);
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
transferKnowledgeBase: knowledgeBaseProcedure
|
||||
.use(withScopedPermission('knowledge_base:create'))
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
targetWorkspaceId: z.string().nullable(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
if (input.targetWorkspaceId === (ctx.workspaceId ?? null)) {
|
||||
throw new TRPCError({
|
||||
cause: { data: { code: TransferErrorCode.SameWorkspace } },
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Cannot transfer to the same workspace',
|
||||
});
|
||||
}
|
||||
|
||||
const knowledgeBase = await ctx.knowledgeBaseModel.findById(input.id);
|
||||
if (!knowledgeBase) {
|
||||
throw new TRPCError({
|
||||
cause: { data: { code: TransferErrorCode.ResourceNotFound } },
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Knowledge base not found',
|
||||
});
|
||||
}
|
||||
|
||||
if (input.targetWorkspaceId) {
|
||||
const [targetMembership] = await ctx.serverDB
|
||||
.select({ role: workspaceMembers.role })
|
||||
.from(workspaceMembers)
|
||||
.where(
|
||||
and(
|
||||
eq(workspaceMembers.workspaceId, input.targetWorkspaceId),
|
||||
eq(workspaceMembers.userId, ctx.userId),
|
||||
isNull(workspaceMembers.deletedAt),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (!targetMembership || targetMembership.role === 'viewer') {
|
||||
throw new TRPCError({
|
||||
cause: { data: { code: TransferErrorCode.TargetNoWriteAccess } },
|
||||
code: 'FORBIDDEN',
|
||||
message: 'No write access to target workspace',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const additionalSize = await ctx.knowledgeBaseModel.countFileUsage(input.id);
|
||||
await businessFileTransferStorageCheck({
|
||||
additionalSize,
|
||||
targetUserId: ctx.userId,
|
||||
targetWorkspaceId: input.targetWorkspaceId,
|
||||
});
|
||||
|
||||
return ctx.knowledgeBaseModel.transferTo(input.id, input.targetWorkspaceId, ctx.userId);
|
||||
}),
|
||||
|
||||
updateKnowledgeBase: knowledgeBaseProcedure
|
||||
.use(withScopedPermission('knowledge_base:update'))
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
value: insertKnowledgeBasesSchema.partial(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
return ctx.knowledgeBaseModel.update(input.id, input.value);
|
||||
}),
|
||||
});
|
||||
@@ -1,98 +0,0 @@
|
||||
// @vitest-environment node
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { agentRouter } from './agent';
|
||||
|
||||
const { mockMarketSDK, mockCreateAgentVersionHeader } = vi.hoisted(() => {
|
||||
const mockCreateAgentVersionHeader = vi.fn();
|
||||
const mockMarketSDK = {
|
||||
agents: {
|
||||
createAgent: vi.fn(),
|
||||
createAgentVersion: vi.fn(async () => {
|
||||
mockCreateAgentVersionHeader(mockMarketSDK.headers['x-lobe-owner-account-id']);
|
||||
return { success: true };
|
||||
}),
|
||||
getAgentDetail: vi.fn(),
|
||||
},
|
||||
headers: {} as Record<string, string>,
|
||||
};
|
||||
|
||||
return { mockCreateAgentVersionHeader, mockMarketSDK };
|
||||
});
|
||||
|
||||
vi.mock('@/business/server/trpc-middlewares/rbacPermission', () => ({
|
||||
withScopedPermission: vi.fn(() => (opts: any) => opts.next({ ctx: opts.ctx })),
|
||||
}));
|
||||
|
||||
vi.mock('@/database/models/user', () => ({
|
||||
UserModel: vi.fn(() => ({
|
||||
getUserState: vi.fn(async () => ({ settings: {} })),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@/libs/trpc/lambda/middleware', () => ({
|
||||
marketSDK: vi.fn((opts: any) =>
|
||||
opts.next({
|
||||
ctx: {
|
||||
...opts.ctx,
|
||||
marketSDK: mockMarketSDK,
|
||||
},
|
||||
}),
|
||||
),
|
||||
marketUserInfo: vi.fn((opts: any) =>
|
||||
opts.next({
|
||||
ctx: {
|
||||
...opts.ctx,
|
||||
marketUserInfo: { email: 'actor@example.com', name: 'Actor', userId: 'user-1' },
|
||||
},
|
||||
}),
|
||||
),
|
||||
serverDatabase: vi.fn((opts: any) => opts.next({ ctx: opts.ctx })),
|
||||
}));
|
||||
|
||||
vi.mock('@/libs/trusted-client', () => ({
|
||||
generateTrustedClientToken: vi.fn(() => 'trust-token'),
|
||||
}));
|
||||
|
||||
describe('agentRouter.publishOrCreate', () => {
|
||||
let fetchSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockMarketSDK.headers = {};
|
||||
mockMarketSDK.agents.getAgentDetail.mockResolvedValue({
|
||||
identifier: 'existing-agent',
|
||||
name: 'Existing Agent',
|
||||
ownerId: 123,
|
||||
});
|
||||
fetchSpy = vi.spyOn(globalThis, 'fetch' as never);
|
||||
fetchSpy.mockResolvedValue(
|
||||
new Response(JSON.stringify({ accountId: 999, sub: 'user-1' }), {
|
||||
status: 200,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the acting organization account for ownership checks and version uploads', async () => {
|
||||
const caller = agentRouter.createCaller({ serverDB: {}, userId: 'user-1' } as any);
|
||||
|
||||
const result = await caller.publishOrCreate({
|
||||
actAs: 123,
|
||||
identifier: 'existing-agent',
|
||||
name: 'Existing Agent',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
identifier: 'existing-agent',
|
||||
isNewAgent: false,
|
||||
success: true,
|
||||
});
|
||||
expect(mockMarketSDK.agents.createAgent).not.toHaveBeenCalled();
|
||||
expect(mockMarketSDK.agents.createAgentVersion).toHaveBeenCalledWith({
|
||||
identifier: 'existing-agent',
|
||||
name: 'Existing Agent',
|
||||
});
|
||||
expect(mockCreateAgentVersionHeader).toHaveBeenCalledWith('123');
|
||||
expect(mockMarketSDK.headers['x-lobe-owner-account-id']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,120 +0,0 @@
|
||||
// @vitest-environment node
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { agentGroupRouter } from './agentGroup';
|
||||
|
||||
const { mockMarketSDK } = vi.hoisted(() => ({
|
||||
mockMarketSDK: {
|
||||
agentGroups: {
|
||||
getAgentGroupDetail: vi.fn(),
|
||||
},
|
||||
headers: {} as Record<string, string>,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/business/server/trpc-middlewares/rbacPermission', () => ({
|
||||
withScopedPermission: vi.fn(() => (opts: any) => opts.next({ ctx: opts.ctx })),
|
||||
}));
|
||||
|
||||
vi.mock('@/database/models/user', () => ({
|
||||
UserModel: vi.fn(() => ({
|
||||
getUserState: vi.fn(async () => ({ settings: {} })),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@/libs/trpc/lambda/middleware', () => ({
|
||||
marketSDK: vi.fn((opts: any) =>
|
||||
opts.next({
|
||||
ctx: {
|
||||
...opts.ctx,
|
||||
marketSDK: mockMarketSDK,
|
||||
},
|
||||
}),
|
||||
),
|
||||
marketUserInfo: vi.fn((opts: any) =>
|
||||
opts.next({
|
||||
ctx: {
|
||||
...opts.ctx,
|
||||
marketUserInfo: { email: 'actor@example.com', name: 'Actor', userId: 'user-1' },
|
||||
},
|
||||
}),
|
||||
),
|
||||
serverDatabase: vi.fn((opts: any) => opts.next({ ctx: opts.ctx })),
|
||||
}));
|
||||
|
||||
vi.mock('@/libs/trusted-client', () => ({
|
||||
generateTrustedClientToken: vi.fn(() => 'trust-token'),
|
||||
}));
|
||||
|
||||
describe('agentGroupRouter.forkAgentGroup', () => {
|
||||
let fetchSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockMarketSDK.headers = {};
|
||||
fetchSpy = vi.spyOn(globalThis, 'fetch' as never);
|
||||
fetchSpy.mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
group: { identifier: 'forked-group' },
|
||||
groupVersion: { versionNumber: 1 },
|
||||
memberAgents: [],
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('attributes workspace forks to the acting organization account', async () => {
|
||||
const caller = agentGroupRouter.createCaller({ serverDB: {}, userId: 'user-1' } as any);
|
||||
|
||||
await caller.forkAgentGroup({
|
||||
actAs: 321,
|
||||
identifier: 'forked-group',
|
||||
name: 'Forked Group',
|
||||
sourceIdentifier: 'source-group',
|
||||
status: 'published',
|
||||
visibility: 'public',
|
||||
} as unknown as Parameters<typeof caller.forkAgentGroup>[0]);
|
||||
|
||||
const [, requestInit] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
||||
expect((requestInit.headers as Record<string, string>)['x-lobe-owner-account-id']).toBe('321');
|
||||
});
|
||||
});
|
||||
|
||||
describe('agentGroupRouter.checkOwnership', () => {
|
||||
let fetchSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockMarketSDK.agentGroups.getAgentGroupDetail.mockResolvedValue({
|
||||
group: {
|
||||
avatar: '👥',
|
||||
identifier: 'workspace-group',
|
||||
name: 'Workspace Group',
|
||||
ownerId: 321,
|
||||
},
|
||||
});
|
||||
fetchSpy = vi.spyOn(globalThis, 'fetch' as never);
|
||||
fetchSpy.mockResolvedValue(
|
||||
new Response(JSON.stringify({ accountId: 999, sub: 'user-1' }), {
|
||||
status: 200,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the acting organization account when checking workspace-owned groups', async () => {
|
||||
const caller = agentGroupRouter.createCaller({ serverDB: {}, userId: 'user-1' } as any);
|
||||
|
||||
const result = await caller.checkOwnership({
|
||||
actAs: 321,
|
||||
identifier: 'workspace-group',
|
||||
} as unknown as Parameters<typeof caller.checkOwnership>[0]);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
exists: true,
|
||||
isOwner: true,
|
||||
originalGroup: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,53 +0,0 @@
|
||||
// @vitest-environment node
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { socialProfileRouter } from './socialProfile';
|
||||
|
||||
const { mockMarketSDKHeaders } = vi.hoisted(() => ({
|
||||
mockMarketSDKHeaders: {
|
||||
Authorization: 'Bearer market-token',
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/libs/trpc/lambda/middleware', () => ({
|
||||
marketSDK: vi.fn((opts: any) =>
|
||||
opts.next({
|
||||
ctx: {
|
||||
...opts.ctx,
|
||||
marketSDK: {
|
||||
headers: mockMarketSDKHeaders,
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
marketUserInfo: vi.fn((opts: any) => opts.next({ ctx: opts.ctx })),
|
||||
serverDatabase: vi.fn((opts: any) => opts.next({ ctx: opts.ctx })),
|
||||
}));
|
||||
|
||||
describe('socialProfileRouter.submitRepo', () => {
|
||||
let fetchSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
fetchSpy = vi.spyOn(globalThis, 'fetch' as never);
|
||||
fetchSpy.mockResolvedValue(
|
||||
new Response(JSON.stringify({ message: 'submitted' }), {
|
||||
status: 200,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('attributes workspace skill repo submissions to the acting organization account', async () => {
|
||||
const caller = socialProfileRouter.createCaller({ userId: 'user-1' } as any);
|
||||
|
||||
await caller.submitRepo({
|
||||
actAs: 123,
|
||||
gitUrl: 'https://github.com/lobehub/example-skill',
|
||||
type: 'skill',
|
||||
});
|
||||
|
||||
const call = fetchSpy.mock.calls[0] as [string, RequestInit] | undefined;
|
||||
expect(String(call?.[0])).toMatch(/\/api\/v1\/user\/claims\/submit-repo$/);
|
||||
expect((call?.[1]?.headers as Record<string, string>)['x-lobe-owner-account-id']).toBe('123');
|
||||
});
|
||||
});
|
||||
@@ -1,375 +0,0 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
|
||||
import { BRANDING_PROVIDER } from '@lobechat/business-const';
|
||||
import { isLobeHubModelAvailable } from '@lobechat/business-model-bank/model-config';
|
||||
import {
|
||||
buildMappedBusinessModelFields,
|
||||
resolveBusinessModelMapping,
|
||||
} from '@lobechat/business-model-runtime';
|
||||
import { ChatErrorType, RequestTrigger } from '@lobechat/types';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import debug from 'debug';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { after } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { getProviderContentPolicyErrorMessage } from '@/business/server/getProviderContentPolicyErrorMessage';
|
||||
import { withScopedPermission } from '@/business/server/trpc-middlewares/rbacPermission';
|
||||
import { wsCompatProcedure } from '@/business/server/trpc-middlewares/workspaceAuth';
|
||||
import { chargeAfterGenerate } from '@/business/server/video-generation/chargeAfterGenerate';
|
||||
import { chargeBeforeGenerate } from '@/business/server/video-generation/chargeBeforeGenerate';
|
||||
import { getVideoFreeQuota } from '@/business/server/video-generation/getVideoFreeQuota';
|
||||
import { AsyncTaskModel } from '@/database/models/asyncTask';
|
||||
import { UserModel } from '@/database/models/user';
|
||||
import {
|
||||
asyncTasks,
|
||||
generationBatches,
|
||||
generations,
|
||||
type NewGeneration,
|
||||
type NewGenerationBatch,
|
||||
} from '@/database/schemas';
|
||||
import { getServerDB } from '@/database/server';
|
||||
import { appEnv } from '@/envs/app';
|
||||
import { authedProcedure, router } from '@/libs/trpc/lambda';
|
||||
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
|
||||
import { initModelRuntimeFromDB } from '@/server/modules/ModelRuntime';
|
||||
import { FileService } from '@/server/services/file';
|
||||
import { processBackgroundVideoPolling } from '@/server/services/generation/videoBackgroundPolling';
|
||||
import { AsyncTaskStatus, AsyncTaskType } from '@/types/asyncTask';
|
||||
|
||||
import { createVideoTaskSubmitError } from './error';
|
||||
|
||||
const log = debug('lobe-video:lambda');
|
||||
|
||||
const videoProcedure = wsCompatProcedure.use(serverDatabase).use(async (opts) => {
|
||||
const { ctx } = opts;
|
||||
const wsId = ctx.workspaceId ?? undefined;
|
||||
|
||||
return opts.next({
|
||||
ctx: {
|
||||
asyncTaskModel: new AsyncTaskModel(ctx.serverDB, ctx.userId, wsId),
|
||||
fileService: new FileService(ctx.serverDB, ctx.userId, wsId),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const videoCreateProcedure = videoProcedure.use(withScopedPermission('file:upload'));
|
||||
|
||||
const createVideoInputSchema = z.object({
|
||||
generationTopicId: z.string(),
|
||||
model: z.string(),
|
||||
params: z
|
||||
.object({
|
||||
aspectRatio: z.string().optional(),
|
||||
cameraFixed: z.boolean().optional(),
|
||||
duration: z.number().optional(),
|
||||
endImageUrl: z.string().nullable().optional(),
|
||||
generateAudio: z.boolean().optional(),
|
||||
imageUrl: z.string().nullable().optional(),
|
||||
prompt: z.string(),
|
||||
resolution: z.string().optional(),
|
||||
seed: z.number().nullable().optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
provider: z.string(),
|
||||
});
|
||||
export type CreateVideoServicePayload = z.infer<typeof createVideoInputSchema>;
|
||||
|
||||
export const videoRouter = router({
|
||||
createVideo: videoCreateProcedure
|
||||
.input(createVideoInputSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { userId, serverDB, asyncTaskModel, fileService } = ctx;
|
||||
const wsId = ctx.workspaceId ?? undefined;
|
||||
const { generationTopicId, provider, model, params } = input;
|
||||
|
||||
const { resolvedModelId } = await resolveBusinessModelMapping(provider, model);
|
||||
|
||||
// Reject lobehub model ids that are no longer in the model bank so callers get a
|
||||
// clear error instead of an opaque downstream failure when the resolved channel
|
||||
// model is no longer in the model bank.
|
||||
if (
|
||||
provider === BRANDING_PROVIDER &&
|
||||
!(await isLobeHubModelAvailable(resolvedModelId, 'video', {
|
||||
getUserEmail: async () => (await UserModel.findById(serverDB, userId))?.email,
|
||||
}))
|
||||
) {
|
||||
throw new TRPCError({
|
||||
cause: { data: { modelType: 'video', requestedModel: model } },
|
||||
code: 'BAD_REQUEST',
|
||||
message: ChatErrorType.LobeHubModelDeprecated,
|
||||
});
|
||||
}
|
||||
|
||||
log('Starting video creation process, input: %O', input);
|
||||
|
||||
// Normalize image URLs to S3 keys for database storage
|
||||
let configForDatabase = { ...params };
|
||||
|
||||
// Process first-frame imageUrl
|
||||
if (typeof params.imageUrl === 'string' && params.imageUrl) {
|
||||
try {
|
||||
const key = await fileService.getKeyFromFullUrl(params.imageUrl);
|
||||
if (key) {
|
||||
log('Converted imageUrl to key: %s -> %s', params.imageUrl, key);
|
||||
configForDatabase = { ...configForDatabase, imageUrl: key };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error converting imageUrl to key: %O', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Process last-frame endImageUrl
|
||||
if (typeof params.endImageUrl === 'string' && params.endImageUrl) {
|
||||
try {
|
||||
const key = await fileService.getKeyFromFullUrl(params.endImageUrl);
|
||||
if (key) {
|
||||
log('Converted endImageUrl to key: %s -> %s', params.endImageUrl, key);
|
||||
configForDatabase = { ...configForDatabase, endImageUrl: key };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error converting endImageUrl to key: %O', error);
|
||||
}
|
||||
}
|
||||
|
||||
// In development, convert localhost proxy URLs to S3 URLs for API access
|
||||
let generationParams = params;
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
const updates: Record<string, unknown> = {};
|
||||
|
||||
if (typeof params.imageUrl === 'string' && params.imageUrl) {
|
||||
const s3Url = await fileService.getFullFileUrl(configForDatabase.imageUrl as string);
|
||||
if (s3Url) {
|
||||
log('Dev: converted imageUrl proxy URL to S3 URL: %s -> %s', params.imageUrl, s3Url);
|
||||
updates.imageUrl = s3Url;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof params.endImageUrl === 'string' && params.endImageUrl) {
|
||||
const s3Url = await fileService.getFullFileUrl(configForDatabase.endImageUrl as string);
|
||||
if (s3Url) {
|
||||
log(
|
||||
'Dev: converted endImageUrl proxy URL to S3 URL: %s -> %s',
|
||||
params.endImageUrl,
|
||||
s3Url,
|
||||
);
|
||||
updates.endImageUrl = s3Url;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
generationParams = { ...params, ...updates };
|
||||
}
|
||||
}
|
||||
|
||||
// Step 0: Pre-charge (atomic budget deduction to prevent concurrent abuse)
|
||||
const { errorBatch, prechargeResult } = await chargeBeforeGenerate({
|
||||
generationTopicId,
|
||||
model,
|
||||
params,
|
||||
provider,
|
||||
userId,
|
||||
workspaceId: wsId,
|
||||
});
|
||||
if (errorBatch) return errorBatch;
|
||||
|
||||
// Generate a one-time token for webhook callback verification
|
||||
const webhookToken = randomBytes(32).toString('hex');
|
||||
|
||||
// Step 1: Atomically create all database records in a transaction
|
||||
const {
|
||||
asyncTaskCreatedAt,
|
||||
asyncTaskId,
|
||||
batch: createdBatch,
|
||||
generation: createdGeneration,
|
||||
} = await serverDB.transaction(async (tx) => {
|
||||
log('Starting database transaction for video generation');
|
||||
|
||||
// 1. Create generationBatch
|
||||
const newBatch: NewGenerationBatch = {
|
||||
config: configForDatabase,
|
||||
generationTopicId,
|
||||
model,
|
||||
prompt: params.prompt,
|
||||
provider,
|
||||
userId,
|
||||
workspaceId: wsId,
|
||||
};
|
||||
log('Creating generation batch: %O', newBatch);
|
||||
const [batch] = await tx.insert(generationBatches).values(newBatch).returning();
|
||||
log('Generation batch created: %s', batch.id);
|
||||
|
||||
// 2. Create single generation (video is always 1)
|
||||
const newGeneration: NewGeneration = {
|
||||
generationBatchId: batch.id,
|
||||
seed: params.seed ?? null,
|
||||
userId,
|
||||
workspaceId: wsId,
|
||||
};
|
||||
const [generation] = await tx.insert(generations).values(newGeneration).returning();
|
||||
log('Generation created: %s', generation.id);
|
||||
|
||||
// 3. Create asyncTask with precharge metadata
|
||||
const [asyncTask] = await tx
|
||||
.insert(asyncTasks)
|
||||
.values({
|
||||
metadata: {
|
||||
...(prechargeResult ? { precharge: prechargeResult } : {}),
|
||||
webhookToken,
|
||||
},
|
||||
status: AsyncTaskStatus.Pending,
|
||||
type: AsyncTaskType.VideoGeneration,
|
||||
userId,
|
||||
workspaceId: wsId,
|
||||
})
|
||||
.returning();
|
||||
log('Async task created: %s', asyncTask.id);
|
||||
|
||||
// 4. Link asyncTask to generation
|
||||
await tx
|
||||
.update(generations)
|
||||
.set({ asyncTaskId: asyncTask.id })
|
||||
.where(and(eq(generations.id, generation.id), eq(generations.userId, userId)));
|
||||
|
||||
return {
|
||||
asyncTaskCreatedAt: asyncTask.createdAt,
|
||||
asyncTaskId: asyncTask.id,
|
||||
batch,
|
||||
generation,
|
||||
};
|
||||
});
|
||||
|
||||
log('Database transaction completed. Calling model runtime for video generation.');
|
||||
|
||||
// Step 2: Call model runtime to submit video generation task
|
||||
try {
|
||||
const modelRuntime = await initModelRuntimeFromDB(serverDB, userId, provider, wsId);
|
||||
|
||||
const callbackBaseUrl = process.env.WEBHOOK_PROXY_URL || appEnv.APP_URL;
|
||||
const callbackUrl = `${callbackBaseUrl}/api/webhooks/video/${provider}?token=${webhookToken}`;
|
||||
log('Using callback URL: %s', callbackUrl);
|
||||
|
||||
const response = await modelRuntime.createVideo(
|
||||
{
|
||||
callbackUrl,
|
||||
model: resolvedModelId,
|
||||
params: generationParams,
|
||||
},
|
||||
{ metadata: { trigger: RequestTrigger.Video } },
|
||||
);
|
||||
|
||||
log('Video task submitted successfully, inferenceId: %s', response?.inferenceId);
|
||||
|
||||
// Determine async strategy based on response:
|
||||
// - useWebhook: provider registered a callback URL, wait for webhook
|
||||
// - otherwise: use background polling to check status
|
||||
const useWebhook = response && 'useWebhook' in response && response.useWebhook;
|
||||
|
||||
if (useWebhook) {
|
||||
// Webhook-based provider (e.g. Volcengine): wait for callback
|
||||
log('Webhook-based provider detected, waiting for callback');
|
||||
|
||||
await asyncTaskModel.update(asyncTaskId, {
|
||||
inferenceId: response?.inferenceId,
|
||||
status: AsyncTaskStatus.Processing,
|
||||
});
|
||||
} else if (response) {
|
||||
// Polling-based provider (e.g. OpenAI Sora): use background polling
|
||||
log(
|
||||
'Polling-based provider detected (inferenceId only), using after() for background polling',
|
||||
);
|
||||
|
||||
await asyncTaskModel.update(asyncTaskId, {
|
||||
inferenceId: response.inferenceId,
|
||||
status: AsyncTaskStatus.Processing,
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
log('After() hook executing background video polling for task: %s', asyncTaskId);
|
||||
|
||||
try {
|
||||
const db = await getServerDB();
|
||||
|
||||
await processBackgroundVideoPolling(db, {
|
||||
asyncTaskCreatedAt,
|
||||
asyncTaskId,
|
||||
generationBatchId: createdBatch.id,
|
||||
generationId: createdGeneration.id,
|
||||
generationTopicId,
|
||||
inferenceId: response.inferenceId,
|
||||
model,
|
||||
prechargeResult,
|
||||
provider,
|
||||
userId,
|
||||
workspaceId: wsId,
|
||||
});
|
||||
|
||||
log('Background video polling completed for task: %s', asyncTaskId);
|
||||
} catch (error) {
|
||||
console.error('[video] Background polling failed:', error);
|
||||
}
|
||||
});
|
||||
|
||||
log('After() hook registered for background video polling: %s', asyncTaskId);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to submit video generation task:', e);
|
||||
|
||||
const providerContentPolicyMessage = await getProviderContentPolicyErrorMessage({
|
||||
error: e,
|
||||
provider,
|
||||
trigger: RequestTrigger.Video,
|
||||
userId,
|
||||
});
|
||||
await asyncTaskModel.update(asyncTaskId, {
|
||||
error: createVideoTaskSubmitError(e, providerContentPolicyMessage),
|
||||
status: AsyncTaskStatus.Error,
|
||||
});
|
||||
|
||||
if (prechargeResult) {
|
||||
try {
|
||||
await chargeAfterGenerate({
|
||||
isError: true,
|
||||
metadata: {
|
||||
asyncTaskId,
|
||||
generationBatchId: createdBatch.id,
|
||||
topicId: generationTopicId,
|
||||
...buildMappedBusinessModelFields({
|
||||
provider,
|
||||
requestedModelId: resolvedModelId === model ? undefined : model,
|
||||
resolvedModelId,
|
||||
}),
|
||||
},
|
||||
model: resolvedModelId,
|
||||
prechargeResult,
|
||||
provider,
|
||||
userId,
|
||||
});
|
||||
} catch (chargeError) {
|
||||
console.error('[video] chargeAfterGenerate failed:', chargeError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log('Video creation process completed: %O', {
|
||||
batchId: createdBatch.id,
|
||||
generationId: createdGeneration.id,
|
||||
});
|
||||
|
||||
return {
|
||||
data: {
|
||||
batch: createdBatch,
|
||||
generations: [{ ...createdGeneration, asyncTaskId }],
|
||||
},
|
||||
success: true,
|
||||
};
|
||||
}),
|
||||
|
||||
getVideoFreeQuota: authedProcedure
|
||||
.input(z.object({ model: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return getVideoFreeQuota(ctx.userId, input.model);
|
||||
}),
|
||||
});
|
||||
|
||||
export type VideoRouter = typeof videoRouter;
|
||||
@@ -1,87 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { resolveDeviceWorkingDirectory } from './resolveDeviceWorkingDirectory';
|
||||
|
||||
describe('resolveDeviceWorkingDirectory', () => {
|
||||
it('prefers the existing topic override above everything else', () => {
|
||||
expect(
|
||||
resolveDeviceWorkingDirectory({
|
||||
deviceDefaultCwd: '/default',
|
||||
deviceId: 'device-1',
|
||||
initialWorkingDirectory: '/initial',
|
||||
topicWorkingDirectory: '/topic',
|
||||
workingDirByDevice: { 'device-1': '/per-device' },
|
||||
}),
|
||||
).toBe('/topic');
|
||||
});
|
||||
|
||||
it('falls back to the brand-new-topic initial metadata when no topic override', () => {
|
||||
expect(
|
||||
resolveDeviceWorkingDirectory({
|
||||
deviceDefaultCwd: '/default',
|
||||
deviceId: 'device-1',
|
||||
initialWorkingDirectory: '/initial',
|
||||
workingDirByDevice: { 'device-1': '/per-device' },
|
||||
}),
|
||||
).toBe('/initial');
|
||||
});
|
||||
|
||||
it("uses the agent's per-device pick when no topic/initial cwd (the remote-CC new-topic case)", () => {
|
||||
expect(
|
||||
resolveDeviceWorkingDirectory({
|
||||
deviceDefaultCwd: '/default',
|
||||
deviceId: 'device-1',
|
||||
workingDirByDevice: { 'device-1': '/per-device' },
|
||||
}),
|
||||
).toBe('/per-device');
|
||||
});
|
||||
|
||||
it('only matches the per-device pick for the dispatched device', () => {
|
||||
expect(
|
||||
resolveDeviceWorkingDirectory({
|
||||
deviceDefaultCwd: '/default',
|
||||
deviceId: 'device-2',
|
||||
workingDirByDevice: { 'device-1': '/per-device' },
|
||||
}),
|
||||
).toBe('/default');
|
||||
});
|
||||
|
||||
it('falls back to the device default last', () => {
|
||||
expect(
|
||||
resolveDeviceWorkingDirectory({
|
||||
deviceDefaultCwd: '/default',
|
||||
deviceId: 'device-1',
|
||||
workingDirByDevice: {},
|
||||
}),
|
||||
).toBe('/default');
|
||||
});
|
||||
|
||||
it('returns undefined when nothing resolves', () => {
|
||||
expect(
|
||||
resolveDeviceWorkingDirectory({
|
||||
deviceId: 'device-1',
|
||||
workingDirByDevice: {},
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ignores the per-device map when no deviceId is given', () => {
|
||||
expect(
|
||||
resolveDeviceWorkingDirectory({
|
||||
deviceDefaultCwd: '/default',
|
||||
workingDirByDevice: { 'device-1': '/per-device' },
|
||||
}),
|
||||
).toBe('/default');
|
||||
});
|
||||
|
||||
it('treats null/undefined inputs as absent', () => {
|
||||
expect(
|
||||
resolveDeviceWorkingDirectory({
|
||||
deviceDefaultCwd: null,
|
||||
deviceId: 'device-1',
|
||||
topicWorkingDirectory: undefined,
|
||||
workingDirByDevice: null,
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,31 +0,0 @@
|
||||
/**
|
||||
* Resolve the working directory for a device-bound run.
|
||||
*
|
||||
* Single source of truth for cwd precedence, shared by every server site that
|
||||
* needs it (hetero dispatch, workspace-init scan, new-topic backfill) so they
|
||||
* cannot drift. Mirrors the client picker's write rules in
|
||||
* `useCommitWorkingDirectory`:
|
||||
*
|
||||
* topic override > brand-new-topic initial metadata > agent's per-device
|
||||
* choice > device default.
|
||||
*
|
||||
* - `topicWorkingDirectory` — an existing topic's pinned cwd
|
||||
* (`topic.metadata.workingDirectory`); always wins once a conversation exists.
|
||||
* - `initialWorkingDirectory` — only populated for a brand-new topic
|
||||
* (`appContext.initialTopicMetadata.workingDirectory`, e.g. the primary repo).
|
||||
* - `workingDirByDevice[deviceId]` — the agent's per-device pick from the picker
|
||||
* when no topic existed yet.
|
||||
* - `deviceDefaultCwd` — the device's user-configured default.
|
||||
*/
|
||||
export const resolveDeviceWorkingDirectory = (params: {
|
||||
deviceDefaultCwd?: string | null;
|
||||
deviceId?: string;
|
||||
initialWorkingDirectory?: string;
|
||||
topicWorkingDirectory?: string;
|
||||
workingDirByDevice?: Record<string, string> | null;
|
||||
}): string | undefined =>
|
||||
params.topicWorkingDirectory ||
|
||||
params.initialWorkingDirectory ||
|
||||
(params.deviceId ? params.workingDirByDevice?.[params.deviceId] : undefined) ||
|
||||
params.deviceDefaultCwd ||
|
||||
undefined;
|
||||
@@ -1,15 +0,0 @@
|
||||
import type { OnboardingPhase } from '@lobechat/types';
|
||||
|
||||
const PHASE_TIPS: Record<OnboardingPhase, string> = {
|
||||
agent_identity:
|
||||
'Suggestions can be candidate agent names, emojis, or a deferral chip ("You pick one", "Let me think").',
|
||||
user_identity: 'Suggestions can be plausible names or roles, or a deferral chip.',
|
||||
discovery:
|
||||
'Suggestions can be plausible job titles, fields, or occupations, or a chip like "Let me explain in my own words".',
|
||||
summary: 'Skip — handled by the marketplace picker; you should not be invoked here.',
|
||||
};
|
||||
|
||||
export const buildOnboardingAddendum = (phase: OnboardingPhase): string =>
|
||||
[`This is an onboarding conversation. Phase: ${phase}.`, `Phase tip: ${PHASE_TIPS[phase]}`].join(
|
||||
'\n',
|
||||
);
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { SkillManagementDocumentService } from '@/server/services/skillManagement';
|
||||
|
||||
import { agentSignalSkillManagementRuntime } from '../agentSignalSkillManagement';
|
||||
|
||||
vi.mock('@/server/services/skillManagement');
|
||||
|
||||
describe('agentSignalSkillManagementRuntime', () => {
|
||||
it('throws if required server context is missing', () => {
|
||||
expect(() =>
|
||||
agentSignalSkillManagementRuntime.factory({
|
||||
serverDB: {} as never,
|
||||
toolManifestMap: {},
|
||||
userId: 'user-1',
|
||||
}),
|
||||
).toThrow('agent-signal-skill-management requires agentId, userId and serverDB');
|
||||
});
|
||||
|
||||
it('threads the workspaceId into the skill document service so writes stay workspace-scoped', () => {
|
||||
agentSignalSkillManagementRuntime.factory({
|
||||
agentId: 'agent-1',
|
||||
serverDB: {} as never,
|
||||
toolManifestMap: {},
|
||||
userId: 'user-1',
|
||||
workspaceId: 'ws-1',
|
||||
});
|
||||
|
||||
expect(SkillManagementDocumentService).toHaveBeenCalledWith({}, 'user-1', 'ws-1');
|
||||
});
|
||||
});
|
||||
@@ -1,29 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { DocumentModel } from '@/database/models/document';
|
||||
import { TopicDocumentModel } from '@/database/models/topicDocument';
|
||||
|
||||
import { createServerPlanRuntimeService } from '../lobeAgentPlan';
|
||||
|
||||
vi.mock('@/database/models/document', () => ({
|
||||
DocumentModel: vi.fn(() => ({
|
||||
findById: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@/database/models/topicDocument', () => ({
|
||||
TopicDocumentModel: vi.fn(() => ({
|
||||
findByTopicId: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('createServerPlanRuntimeService', () => {
|
||||
it('scopes document models to workspace context', () => {
|
||||
const serverDB = {} as never;
|
||||
|
||||
createServerPlanRuntimeService(serverDB, 'user-1', 'workspace-1');
|
||||
|
||||
expect(DocumentModel).toHaveBeenCalledWith(serverDB, 'user-1', 'workspace-1');
|
||||
expect(TopicDocumentModel).toHaveBeenCalledWith(serverDB, 'user-1', 'workspace-1');
|
||||
});
|
||||
});
|
||||
@@ -1,105 +0,0 @@
|
||||
import { BriefIdentifier } from '@lobechat/builtin-tool-brief';
|
||||
import type { LobeChatDatabase } from '@lobechat/database';
|
||||
import { formatBriefCreated, formatCheckpointCreated } from '@lobechat/prompts';
|
||||
import { DEFAULT_BRIEF_ACTIONS } from '@lobechat/types';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
import { BriefModel } from '@/database/models/brief';
|
||||
import { TaskModel } from '@/database/models/task';
|
||||
import { tasks } from '@/database/schemas';
|
||||
|
||||
import { type ServerRuntimeRegistration } from './types';
|
||||
|
||||
// Row-level fallback: the agent-runtime hasn't threaded `workspaceId` into
|
||||
// `ToolExecutionContext` yet, so we resolve it from the task row when the
|
||||
// runtime fires inside a task. Falls back to undefined (personal mode) when
|
||||
// there is no task association.
|
||||
const resolveWorkspaceId = async (
|
||||
db: LobeChatDatabase,
|
||||
taskId: string | undefined,
|
||||
): Promise<string | undefined> => {
|
||||
if (!taskId) return undefined;
|
||||
const [row] = await db
|
||||
.select({ workspaceId: tasks.workspaceId })
|
||||
.from(tasks)
|
||||
.where(eq(tasks.id, taskId))
|
||||
.limit(1);
|
||||
return row?.workspaceId ?? undefined;
|
||||
};
|
||||
|
||||
export const briefRuntime: ServerRuntimeRegistration = {
|
||||
factory: (context) => {
|
||||
if (!context.userId || !context.serverDB) {
|
||||
throw new Error('userId and serverDB are required for Brief tool execution');
|
||||
}
|
||||
|
||||
const db = context.serverDB;
|
||||
const userId = context.userId;
|
||||
const { agentId, taskId } = context;
|
||||
// Prefer the workspaceId threaded through the pipeline. Fall back to the
|
||||
// owning task row when an older caller still doesn't populate it.
|
||||
const resolveWs = async () => context.workspaceId ?? (await resolveWorkspaceId(db, taskId));
|
||||
|
||||
return {
|
||||
createBrief: async (args: {
|
||||
actions?: Array<{ key: string; label: string; type: string }>;
|
||||
priority?: string;
|
||||
summary: string;
|
||||
title: string;
|
||||
type: string;
|
||||
}) => {
|
||||
// 'result' briefs are terminal — the UI hardcodes a single approve action
|
||||
// and routes it through BriefService.resolve to complete the task. Custom
|
||||
// actions on result briefs would be ignored, so reject them at the source.
|
||||
const actions =
|
||||
args.type === 'result' ? null : args.actions || DEFAULT_BRIEF_ACTIONS[args.type] || [];
|
||||
|
||||
const workspaceId = await resolveWs();
|
||||
const briefModel = new BriefModel(db, userId, workspaceId);
|
||||
|
||||
const brief = await briefModel.create({
|
||||
actions,
|
||||
agentId,
|
||||
priority: args.priority || 'info',
|
||||
summary: args.summary,
|
||||
taskId,
|
||||
title: args.title,
|
||||
type: args.type,
|
||||
});
|
||||
|
||||
return {
|
||||
content: formatBriefCreated({
|
||||
id: brief.id,
|
||||
priority: args.priority || 'info',
|
||||
summary: args.summary,
|
||||
title: args.title,
|
||||
type: args.type,
|
||||
}),
|
||||
success: true,
|
||||
};
|
||||
},
|
||||
|
||||
requestCheckpoint: async (args: { reason: string }) => {
|
||||
const workspaceId = await resolveWs();
|
||||
const briefModel = new BriefModel(db, userId, workspaceId);
|
||||
const taskModel = new TaskModel(db, userId, workspaceId);
|
||||
|
||||
if (taskId) {
|
||||
await taskModel.updateStatus(taskId, 'paused');
|
||||
}
|
||||
|
||||
await briefModel.create({
|
||||
agentId,
|
||||
priority: 'normal',
|
||||
summary: args.reason,
|
||||
taskId,
|
||||
title: 'Checkpoint requested',
|
||||
type: 'decision',
|
||||
});
|
||||
|
||||
return { content: formatCheckpointCreated(args.reason), success: true };
|
||||
},
|
||||
};
|
||||
},
|
||||
identifier: BriefIdentifier,
|
||||
};
|
||||
@@ -1,24 +0,0 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
import { agentEvalRuns } from '@/database/schemas';
|
||||
import type { LobeChatDatabase } from '@/database/type';
|
||||
|
||||
/**
|
||||
* System-level workspace resolver for agent-eval-run workflow handlers.
|
||||
*
|
||||
* These workflow endpoints are server-to-server callbacks dispatched from
|
||||
* QStash and do not carry a workspace context. We derive the workspace from
|
||||
* the `runId` row so downstream `AgentEvalXxxModel` / `AgentEvalRunService`
|
||||
* instances ownership-filter to the correct workspace.
|
||||
*/
|
||||
export const resolveAgentEvalRunWorkspace = async (
|
||||
db: LobeChatDatabase,
|
||||
runId: string,
|
||||
): Promise<string | undefined> => {
|
||||
const [row] = await db
|
||||
.select({ workspaceId: agentEvalRuns.workspaceId })
|
||||
.from(agentEvalRuns)
|
||||
.where(eq(agentEvalRuns.id, runId))
|
||||
.limit(1);
|
||||
return row?.workspaceId ?? undefined;
|
||||
};
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"]
|
||||
}
|
||||
@@ -846,7 +846,7 @@
|
||||
"upload.preview.status.pending": "جارٍ التحضير للرفع...",
|
||||
"upload.preview.status.processing": "جارٍ معالجة الملف...",
|
||||
"upload.validation.unsupportedFileType": "نوع الملف غير مدعوم: {{files}}. الصور المدعومة: JPG، PNG، GIF، WebP. المستندات المدعومة تشمل PDF، Word، Excel، PowerPoint، Markdown، النص، CSV، JSON، وملفات التعليمات البرمجية.",
|
||||
"upload.validation.videoSizeExceeded": "يجب ألا يتجاوز حجم ملف الفيديو {{maxSize}}. الحجم الحالي هو {{actualSize}}.",
|
||||
"upload.validation.videoSizeExceeded": "يجب ألا يتجاوز حجم ملف الفيديو 20 ميغابايت. الحجم الحالي هو {{actualSize}}.",
|
||||
"viewMode.fullWidth": "العرض الكامل",
|
||||
"viewMode.normal": "قياسي",
|
||||
"viewMode.wideScreen": "شاشة عريضة",
|
||||
|
||||
@@ -355,6 +355,7 @@
|
||||
"referral.errors.updateFailed": "فشل التحديث، يرجى المحاولة لاحقًا",
|
||||
"referral.inviteCode.description": "شارك رمز الإحالة الحصري الخاص بك لدعوة الأصدقاء للتسجيل",
|
||||
"referral.inviteCode.title": "رمز الإحالة الخاص بي",
|
||||
"referral.inviteLink.description": "انسخ الرابط وشاركه مع الأصدقاء. بعد التسجيل، ستحصل على مكافآت",
|
||||
"referral.inviteLink.title": "رابط الإحالة",
|
||||
"referral.rules.antiAbuse": "إذا تم اكتشاف نشاط احتيالي (مثل التسجيل الجماعي لحسابات بريد إلكتروني مؤقتة)، فسيتم حظر الحسابات المرتبطة بشكل دائم.",
|
||||
"referral.rules.backfill.alreadyBound": "لقد قمت بربط رمز الدعوة مسبقًا",
|
||||
@@ -367,10 +368,13 @@
|
||||
"referral.rules.backfill.title": "إكمال رمز الدعوة",
|
||||
"referral.rules.description": "تعرف على قواعد برنامج مكافآت الإحالة",
|
||||
"referral.rules.expiry": "صلاحية الرصيد: يتم مسح أرصدة الإحالة بعد 100 يوم من عدم النشاط",
|
||||
"referral.rules.missedCode": "فاتك إدخال رمز الدعوة: يمكنك <0>إكماله</0> خلال 3 أيام من التسجيل",
|
||||
"referral.rules.priority": "أولوية استهلاك الرصيد: الرصيد المجاني → رصيد الاشتراك → رصيد الإحالة → الرصيد المشحون",
|
||||
"referral.rules.registration": "طريقة التسجيل: يسجل المستخدمون المدعوون عبر رابط الإحالة أو بإدخال الرمز عند التسجيل",
|
||||
"referral.rules.reward": "المكافأة: يحصل كل من الداعي والمدعو على {{reward}}M رصيد",
|
||||
"referral.rules.rewardDelay": "معالجة المكافآت: قد يستغرق توزيع الأرصدة ما يصل إلى 6 ساعات بعد إكمال إجراء صالح. يرجى التحلي بالصبر.",
|
||||
"referral.rules.title": "قواعد البرنامج",
|
||||
"referral.rules.validInvitation": "دعوة صالحة: يسجل المدعو باستخدام رمز الإحالة ويقوم بإجراء صالح واحد",
|
||||
"referral.rules.validOperation": "معايير الإجراء الصالح: إرسال رسالة واحدة أو إنشاء صورة واحدة",
|
||||
"referral.stats.availableBalance": "الرصيد المتاح",
|
||||
"referral.stats.description": "عرض إحصائيات الإحالة الخاصة بك",
|
||||
@@ -383,6 +387,7 @@
|
||||
"referral.table.columns.inviterRewardAmount": "مكافأتي",
|
||||
"referral.table.columns.rewardedAt": "وقت المكافأة",
|
||||
"referral.table.columns.status": "الحالة",
|
||||
"referral.table.status.pending_reward": "المكافأة المعلقة",
|
||||
"referral.table.status.registered": "مسجل",
|
||||
"referral.table.status.revoked": "تم الإلغاء",
|
||||
"referral.table.status.rewarded": "تمت المكافأة",
|
||||
|
||||
@@ -846,7 +846,7 @@
|
||||
"upload.preview.status.pending": "Подготовка за качване...",
|
||||
"upload.preview.status.processing": "Обработка на файла...",
|
||||
"upload.validation.unsupportedFileType": "Неподдържан тип файл: {{files}}. Поддържани изображения: JPG, PNG, GIF, WebP. Поддържани документи включват PDF, Word, Excel, PowerPoint, Markdown, текст, CSV, JSON и файлове с код.",
|
||||
"upload.validation.videoSizeExceeded": "Размерът на видеофайла не трябва да надвишава {{maxSize}}. Текущият размер е {{actualSize}}.",
|
||||
"upload.validation.videoSizeExceeded": "Размерът на видеофайла не трябва да надвишава 20MB. Текущият размер е {{actualSize}}.",
|
||||
"viewMode.fullWidth": "Пълна ширина",
|
||||
"viewMode.normal": "Стандартен",
|
||||
"viewMode.wideScreen": "Широкоекранен",
|
||||
|
||||
@@ -355,6 +355,7 @@
|
||||
"referral.errors.updateFailed": "Неуспешна актуализация, моля опитайте отново по-късно",
|
||||
"referral.inviteCode.description": "Споделете своя уникален код за покана, за да поканите приятели да се регистрират",
|
||||
"referral.inviteCode.title": "Моят код за покана",
|
||||
"referral.inviteLink.description": "Копирайте линка и го споделете с приятели. След регистрация ще получите награди",
|
||||
"referral.inviteLink.title": "Линк за покана",
|
||||
"referral.rules.antiAbuse": "Ако бъде открита измамна дейност (например масова регистрация на временни имейл акаунти), свързаните акаунти ще бъдат окончателно блокирани.",
|
||||
"referral.rules.backfill.alreadyBound": "Вече сте свързали поканен код",
|
||||
@@ -367,10 +368,13 @@
|
||||
"referral.rules.backfill.title": "Попълни код за покана",
|
||||
"referral.rules.description": "Научете правилата на програмата за покани и награди",
|
||||
"referral.rules.expiry": "Валидност на кредитите: наличните кредити от покани ще бъдат изчистени след 100 дни неактивност",
|
||||
"referral.rules.missedCode": "Пропуснат код за покана: Можете да го <0>попълните</0> в рамките на 3 дни след регистрацията",
|
||||
"referral.rules.priority": "Приоритет на използване: Безплатни кредити → Абонаментни кредити → Кредити от покани → Закупени кредити",
|
||||
"referral.rules.registration": "Метод на регистрация: Поканените потребители се регистрират чрез линк за покана или въвеждат код при регистрация",
|
||||
"referral.rules.reward": "Награда: Поканилият и поканеният получават по {{reward}}M кредита",
|
||||
"referral.rules.rewardDelay": "Обработка на награди: Кредитите може да отнемат до 6 часа, за да бъдат разпределени след извършване на валидно действие. Моля, бъдете търпеливи.",
|
||||
"referral.rules.title": "Правила на програмата",
|
||||
"referral.rules.validInvitation": "Валидна покана: Поканеният се регистрира с вашия код и извърши валидно действие",
|
||||
"referral.rules.validOperation": "Критерии за валидно действие: Изпращане на съобщение в Chat страницата или генериране на изображение",
|
||||
"referral.stats.availableBalance": "Налично салдо",
|
||||
"referral.stats.description": "Вижте статистиката на вашите покани",
|
||||
@@ -383,6 +387,7 @@
|
||||
"referral.table.columns.inviterRewardAmount": "Моята награда",
|
||||
"referral.table.columns.rewardedAt": "Време на награждаване",
|
||||
"referral.table.columns.status": "Статус",
|
||||
"referral.table.status.pending_reward": "Очаквана награда",
|
||||
"referral.table.status.registered": "Регистриран",
|
||||
"referral.table.status.revoked": "Отменен",
|
||||
"referral.table.status.rewarded": "Награден",
|
||||
|
||||
@@ -846,7 +846,7 @@
|
||||
"upload.preview.status.pending": "Vorbereitung zum Hochladen...",
|
||||
"upload.preview.status.processing": "Datei wird verarbeitet...",
|
||||
"upload.validation.unsupportedFileType": "Nicht unterstützter Dateityp: {{files}}. Unterstützte Bilder: JPG, PNG, GIF, WebP. Unterstützte Dokumente umfassen PDF, Word, Excel, PowerPoint, Markdown, Text, CSV, JSON und Code-Dateien.",
|
||||
"upload.validation.videoSizeExceeded": "Die Videodatei darf {{maxSize}} nicht überschreiten. Aktuelle Größe: {{actualSize}}.",
|
||||
"upload.validation.videoSizeExceeded": "Die Videodatei darf 20 MB nicht überschreiten. Aktuelle Größe: {{actualSize}}.",
|
||||
"viewMode.fullWidth": "Volle Breite",
|
||||
"viewMode.normal": "Standard",
|
||||
"viewMode.wideScreen": "Breitbild",
|
||||
|
||||
@@ -355,6 +355,7 @@
|
||||
"referral.errors.updateFailed": "Aktualisierung fehlgeschlagen, bitte später erneut versuchen",
|
||||
"referral.inviteCode.description": "Teilen Sie Ihren exklusiven Empfehlungscode, um Freunde einzuladen",
|
||||
"referral.inviteCode.title": "Mein Empfehlungscode",
|
||||
"referral.inviteLink.description": "Link kopieren und mit Freunden teilen. Nach Registrierung erhalten Sie Belohnungen",
|
||||
"referral.inviteLink.title": "Empfehlungslink",
|
||||
"referral.rules.antiAbuse": "Wenn betrügerische Aktivitäten festgestellt werden (z. B. Massenregistrierung von Wegwerf-E-Mail-Konten), werden die zugehörigen Konten dauerhaft gesperrt.",
|
||||
"referral.rules.backfill.alreadyBound": "Du hast bereits einen Einladungscode eingegeben",
|
||||
@@ -367,10 +368,13 @@
|
||||
"referral.rules.backfill.title": "Einladungscode nachtragen",
|
||||
"referral.rules.description": "Erfahren Sie mehr über die Regeln des Empfehlungsprogramms",
|
||||
"referral.rules.expiry": "Gültigkeit: Credits verfallen nach 100 Tagen Inaktivität",
|
||||
"referral.rules.missedCode": "Einladungscode verpasst: Du kannst ihn innerhalb von <0>3 Tagen</0> nach der Registrierung nachtragen",
|
||||
"referral.rules.priority": "Verbrauchsreihenfolge: Kostenlose Credits → Abo-Credits → Empfehlungs-Credits → Aufgeladene Credits",
|
||||
"referral.rules.registration": "Registrierung: Eingeladene registrieren sich über Link oder geben Code ein",
|
||||
"referral.rules.reward": "Belohnung: Werber und Geworbener erhalten jeweils {{reward}}M Credits",
|
||||
"referral.rules.rewardDelay": "Belohnungsverarbeitung: Es kann bis zu 6 Stunden dauern, bis Credits nach Abschluss einer gültigen Aktion gutgeschrieben werden. Bitte haben Sie Geduld.",
|
||||
"referral.rules.title": "Programmregeln",
|
||||
"referral.rules.validInvitation": "Gültige Einladung: Registrierung mit Ihrem Code und eine gültige Aktion",
|
||||
"referral.rules.validOperation": "Gültige Aktion: Eine Nachricht senden oder ein Bild generieren",
|
||||
"referral.stats.availableBalance": "Verfügbares Guthaben",
|
||||
"referral.stats.description": "Sehen Sie Ihre Empfehlungsstatistiken",
|
||||
@@ -383,6 +387,7 @@
|
||||
"referral.table.columns.inviterRewardAmount": "Meine Belohnung",
|
||||
"referral.table.columns.rewardedAt": "Belohnungszeitpunkt",
|
||||
"referral.table.columns.status": "Status",
|
||||
"referral.table.status.pending_reward": "Ausstehende Belohnung",
|
||||
"referral.table.status.registered": "Registriert",
|
||||
"referral.table.status.revoked": "Widerrufen",
|
||||
"referral.table.status.rewarded": "Belohnt",
|
||||
|
||||
@@ -149,8 +149,6 @@
|
||||
"extendParams.enableReasoning.title": "Enable Deep Thinking",
|
||||
"extendParams.imageAspectRatio.title": "Image Aspect Ratio",
|
||||
"extendParams.imageResolution.title": "Image Resolution",
|
||||
"extendParams.preserveThinking.desc": "When enabled, assistant historical reasoning will be sent back as context for compatible models. This may increase token usage.",
|
||||
"extendParams.preserveThinking.title": "Preserve Historical Thinking",
|
||||
"extendParams.reasoningBudgetToken.title": "Thinking Consumption Token",
|
||||
"extendParams.reasoningEffort.title": "Reasoning Intensity",
|
||||
"extendParams.textVerbosity.title": "Output Text Detail Level",
|
||||
@@ -867,7 +865,7 @@
|
||||
"upload.preview.status.pending": "Preparing to upload...",
|
||||
"upload.preview.status.processing": "Processing file...",
|
||||
"upload.validation.unsupportedFileType": "Unsupported file type: {{files}}. Supported images: JPG, PNG, GIF, WebP. Supported documents include PDF, Word, Excel, PowerPoint, Markdown, text, CSV, JSON, and code files.",
|
||||
"upload.validation.videoSizeExceeded": "Video file size must not exceed {{maxSize}}. Current file size is {{actualSize}}.",
|
||||
"upload.validation.videoSizeExceeded": "Video file size must not exceed 20MB. Current file size is {{actualSize}}.",
|
||||
"viewMode.fullWidth": "Full Width",
|
||||
"viewMode.normal": "Standard",
|
||||
"viewMode.wideScreen": "Widescreen",
|
||||
@@ -893,6 +891,7 @@
|
||||
"workflow.toolDisplayName.calculate": "Calculated",
|
||||
"workflow.toolDisplayName.callAgent": "Called an agent",
|
||||
"workflow.toolDisplayName.callSubAgent": "Call SubAgent",
|
||||
"workflow.toolDisplayName.callSubAgents": "Call SubAgents",
|
||||
"workflow.toolDisplayName.clearTodos": "Cleared todos",
|
||||
"workflow.toolDisplayName.copyDocument": "Copied a document",
|
||||
"workflow.toolDisplayName.crawlMultiPages": "Crawled pages",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -234,7 +234,6 @@
|
||||
"providerModels.item.modelConfig.extendParams.options.imageResolution.hint": "For Gemini 3 image generation models; controls resolution of generated images.",
|
||||
"providerModels.item.modelConfig.extendParams.options.imageResolution2.hint": "For Gemini 3.1 Flash Image models; controls resolution of generated images (supports 512px).",
|
||||
"providerModels.item.modelConfig.extendParams.options.opus47Effort.hint": "For Claude Opus 4.7 and later; controls effort level (low/medium/high/xhigh/max).",
|
||||
"providerModels.item.modelConfig.extendParams.options.preserveThinking.hint": "For Qwen3.6 Plus, GLM-5 and GLM-4.7; sends historical assistant reasoning back to model context (preserve_thinking).",
|
||||
"providerModels.item.modelConfig.extendParams.options.reasoningBudgetToken.hint": "For Claude, Qwen3 and similar; controls token budget for reasoning.",
|
||||
"providerModels.item.modelConfig.extendParams.options.reasoningBudgetToken32k.hint": "For GLM-5 and GLM-4.7; controls token budget for reasoning (max 32k).",
|
||||
"providerModels.item.modelConfig.extendParams.options.reasoningBudgetToken80k.hint": "For Qwen3 series; controls token budget for reasoning (max 80k).",
|
||||
|
||||
@@ -73,6 +73,8 @@
|
||||
"builtins.lobe-agent.apiName.analyzeVisualMedia.mediaCount": "{{count}} media",
|
||||
"builtins.lobe-agent.apiName.analyzeVisualMedia.result": "Analyze visual media: <question>{{question}}</question>",
|
||||
"builtins.lobe-agent.apiName.callSubAgent": "Call SubAgent",
|
||||
"builtins.lobe-agent.apiName.callSubAgents": "Call SubAgents",
|
||||
"builtins.lobe-agent.apiName.callSubAgents.more": "{{count}} in total",
|
||||
"builtins.lobe-agent.apiName.clearTodos": "Clear todos",
|
||||
"builtins.lobe-agent.apiName.clearTodos.modeAll": "all",
|
||||
"builtins.lobe-agent.apiName.clearTodos.modeCompleted": "completed",
|
||||
|
||||
@@ -117,7 +117,6 @@
|
||||
"currentPlan.seeAllFeaturesAndComparePlans": "See all features and compare plans",
|
||||
"currentPlan.title": "Current Plan",
|
||||
"discount.add": "Add",
|
||||
"discount.limitedTime": "Limited-time",
|
||||
"discount.maxOff": "Up to {{percent}}% off",
|
||||
"discount.off": "{{percent}}% off",
|
||||
"discount.save": "Save",
|
||||
@@ -141,10 +140,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.",
|
||||
@@ -316,7 +311,6 @@
|
||||
"plans.support.ultimate": "Priority Chat and Email Support",
|
||||
"plans.target": "Target Plan",
|
||||
"plans.unlimited": "Unlimited",
|
||||
"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.",
|
||||
@@ -354,7 +348,7 @@
|
||||
"referral.edit.placeholder": "Enter referral code",
|
||||
"referral.edit.save": "Save",
|
||||
"referral.errors.alreadyBound": "You have already bound an invite code",
|
||||
"referral.errors.backfillExpired": "Backfill period has expired. Cannot backfill after 7 days of registration",
|
||||
"referral.errors.backfillExpired": "Backfill period has expired. Cannot backfill after 3 days of registration",
|
||||
"referral.errors.codeExists": "This referral code is already in use, please choose another",
|
||||
"referral.errors.invalidCode": "Invite code does not exist, please check and try again",
|
||||
"referral.errors.invalidFormat": "Invalid referral code format, please enter 2-8 letters, numbers or underscores",
|
||||
@@ -362,12 +356,12 @@
|
||||
"referral.errors.updateFailed": "Update failed, please try again later",
|
||||
"referral.inviteCode.description": "Share your exclusive referral code to invite friends to register",
|
||||
"referral.inviteCode.title": "My 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.description": "Copy the link and share with friends. Complete registration to receive rewards",
|
||||
"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",
|
||||
"referral.rules.backfill.alreadyBound": "You have already bound an invite code",
|
||||
"referral.rules.backfill.description": "Forgot to enter invite code? You can backfill within 7 days of registration",
|
||||
"referral.rules.backfill.expiredTip": "Backfill period has expired. Cannot backfill after 7 days of registration",
|
||||
"referral.rules.backfill.description": "Forgot to enter invite code? You can backfill within 3 days of registration",
|
||||
"referral.rules.backfill.expiredTip": "Backfill period has expired. Cannot backfill after 3 days of registration",
|
||||
"referral.rules.backfill.link": "Backfill Invite Code",
|
||||
"referral.rules.backfill.placeholder": "Enter invite code or link",
|
||||
"referral.rules.backfill.submit": "Confirm Binding",
|
||||
@@ -375,13 +369,13 @@
|
||||
"referral.rules.backfill.title": "Backfill Invite Code",
|
||||
"referral.rules.description": "Learn about referral reward program rules",
|
||||
"referral.rules.expiry": "Credit validity: Available referral credits will be cleared after 100 days of user inactivity",
|
||||
"referral.rules.missedCode": "Missed invite code: You can <0>backfill</0> within 7 days of registration. After backfilling, you still need to perform a valid action and complete a payment to receive rewards",
|
||||
"referral.rules.missedCode": "Missed invite code: You can <0>backfill</0> within 3 days of registration. After backfilling, you still need to perform a valid action to receive rewards",
|
||||
"referral.rules.priority": "Credit consumption priority: Free credits → Subscription credits → Referral credits → Top-up credits",
|
||||
"referral.rules.registration": "Registration method: Invited users register via referral link or enter referral code on registration page",
|
||||
"referral.rules.reward": "Reward: Referrer and invitee each receive {{reward}}M credits",
|
||||
"referral.rules.rewardDelay": "Reward processing: Credits will be distributed within 1 hour after the invitee completes a payment and passes verification",
|
||||
"referral.rules.rewardDelay": "Reward processing: Credits will be distributed after verification, which may take up to 6 hours",
|
||||
"referral.rules.title": "Program Rules",
|
||||
"referral.rules.validInvitation": "Valid invitation: Invitee registers with your referral code, performs one valid action, and completes a payment (subscription or credit top-up)",
|
||||
"referral.rules.validInvitation": "Valid invitation: Invitee registers with your referral code and performs one valid action",
|
||||
"referral.rules.validOperation": "Valid action criteria: Send one message on Chat page, or generate one image on image page",
|
||||
"referral.stats.availableBalance": "Available Balance",
|
||||
"referral.stats.description": "View your referral statistics",
|
||||
@@ -394,7 +388,7 @@
|
||||
"referral.table.columns.inviterRewardAmount": "My Reward",
|
||||
"referral.table.columns.rewardedAt": "Reward Time",
|
||||
"referral.table.columns.status": "Status",
|
||||
"referral.table.status.pending_reward": "Pending Reward",
|
||||
"referral.table.status.pending_reward": "Under Review",
|
||||
"referral.table.status.registered": "Registered",
|
||||
"referral.table.status.revoked": "Revoked",
|
||||
"referral.table.status.rewarded": "Rewarded",
|
||||
|
||||
@@ -26,7 +26,6 @@
|
||||
"displayItems": "Display Items",
|
||||
"duplicateLoading": "Copying Topic...",
|
||||
"duplicateSuccess": "Topic Copied Successfully",
|
||||
"failedStatusTip": "This run hit an error — open it to take a look.",
|
||||
"favorite": "Favorite",
|
||||
"filter.filter": "Filter",
|
||||
"filter.groupMode.byProject": "By project",
|
||||
@@ -44,7 +43,6 @@
|
||||
"groupTitle.byStatus.completed": "Completed",
|
||||
"groupTitle.byStatus.failed": "Failed",
|
||||
"groupTitle.byStatus.paused": "Paused",
|
||||
"groupTitle.byStatus.pending": "Needs attention",
|
||||
"groupTitle.byStatus.running": "Running",
|
||||
"groupTitle.byStatus.waitingForHuman": "Awaiting input",
|
||||
"groupTitle.byTime.month": "This Month",
|
||||
|
||||
@@ -846,7 +846,7 @@
|
||||
"upload.preview.status.pending": "Preparando para subir...",
|
||||
"upload.preview.status.processing": "Procesando archivo...",
|
||||
"upload.validation.unsupportedFileType": "Tipo de archivo no compatible: {{files}}. Imágenes compatibles: JPG, PNG, GIF, WebP. Documentos compatibles incluyen PDF, Word, Excel, PowerPoint, Markdown, texto, CSV, JSON y archivos de código.",
|
||||
"upload.validation.videoSizeExceeded": "El tamaño del archivo de video no debe superar los {{maxSize}}. Tamaño actual: {{actualSize}}.",
|
||||
"upload.validation.videoSizeExceeded": "El tamaño del archivo de video no debe superar los 20MB. Tamaño actual: {{actualSize}}.",
|
||||
"viewMode.fullWidth": "Ancho completo",
|
||||
"viewMode.normal": "Estándar",
|
||||
"viewMode.wideScreen": "Pantalla ancha",
|
||||
|
||||
@@ -355,6 +355,7 @@
|
||||
"referral.errors.updateFailed": "Error al actualizar, por favor intenta más tarde",
|
||||
"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. Completa el registro para recibir recompensas",
|
||||
"referral.inviteLink.title": "Enlace de Referido",
|
||||
"referral.rules.antiAbuse": "Si se detecta actividad fraudulenta (por ejemplo, registro masivo de cuentas de correo electrónico desechables), las cuentas asociadas serán permanentemente bloqueadas.",
|
||||
"referral.rules.backfill.alreadyBound": "Ya has vinculado un código de invitación",
|
||||
@@ -367,10 +368,13 @@
|
||||
"referral.rules.backfill.title": "Recuperar código de invitación",
|
||||
"referral.rules.description": "Conoce las reglas del programa de recompensas por referidos",
|
||||
"referral.rules.expiry": "Validez de créditos: los créditos se eliminarán tras 100 días de inactividad",
|
||||
"referral.rules.missedCode": "Código de invitación perdido: Puedes <0>recuperarlo</0> dentro de los 3 días posteriores al registro",
|
||||
"referral.rules.priority": "Prioridad de consumo: Créditos gratuitos → Créditos de suscripción → Créditos por referidos → Créditos recargados",
|
||||
"referral.rules.registration": "Método de registro: los invitados se registran mediante el enlace o ingresan el código en la página de registro",
|
||||
"referral.rules.reward": "Recompensa: el referente y el invitado reciben {{reward}}M créditos cada uno",
|
||||
"referral.rules.rewardDelay": "Procesamiento de recompensas: Los créditos pueden tardar hasta 6 horas en distribuirse después de completar una acción válida. Por favor, sea paciente.",
|
||||
"referral.rules.title": "Reglas del Programa",
|
||||
"referral.rules.validInvitation": "Invitación válida: el invitado se registra con tu código y realiza una acción válida",
|
||||
"referral.rules.validOperation": "Criterios de acción válida: enviar un mensaje en la página de chat o generar una imagen",
|
||||
"referral.stats.availableBalance": "Saldo Disponible",
|
||||
"referral.stats.description": "Consulta tus estadísticas de referidos",
|
||||
@@ -383,6 +387,7 @@
|
||||
"referral.table.columns.inviterRewardAmount": "Mi Recompensa",
|
||||
"referral.table.columns.rewardedAt": "Fecha de Recompensa",
|
||||
"referral.table.columns.status": "Estado",
|
||||
"referral.table.status.pending_reward": "Recompensa Pendiente",
|
||||
"referral.table.status.registered": "Registrado",
|
||||
"referral.table.status.revoked": "Revocado",
|
||||
"referral.table.status.rewarded": "Recompensado",
|
||||
|
||||
@@ -846,7 +846,7 @@
|
||||
"upload.preview.status.pending": "در حال آمادهسازی برای بارگذاری...",
|
||||
"upload.preview.status.processing": "در حال پردازش فایل...",
|
||||
"upload.validation.unsupportedFileType": "نوع فایل پشتیبانی نشده: {{files}}. تصاویر پشتیبانی شده: JPG، PNG، GIF، WebP. اسناد پشتیبانی شده شامل PDF، Word، Excel، PowerPoint، Markdown، متن، CSV، JSON و فایلهای کد هستند.",
|
||||
"upload.validation.videoSizeExceeded": "حجم فایل ویدیو نباید بیش از {{maxSize}} باشد. حجم فعلی: {{actualSize}}.",
|
||||
"upload.validation.videoSizeExceeded": "حجم فایل ویدیو نباید بیش از ۲۰ مگابایت باشد. حجم فعلی: {{actualSize}}.",
|
||||
"viewMode.fullWidth": "تمامعرض",
|
||||
"viewMode.normal": "استاندارد",
|
||||
"viewMode.wideScreen": "نمای عریض",
|
||||
|
||||
@@ -355,6 +355,7 @@
|
||||
"referral.errors.updateFailed": "بهروزرسانی ناموفق بود، لطفاً بعداً دوباره تلاش کنید",
|
||||
"referral.inviteCode.description": "کد دعوت اختصاصی خود را به اشتراک بگذارید تا دوستان را به ثبتنام دعوت کنید",
|
||||
"referral.inviteCode.title": "کد دعوت من",
|
||||
"referral.inviteLink.description": "لینک را کپی کرده و با دوستان به اشتراک بگذارید. پس از ثبتنام، پاداش دریافت کنید",
|
||||
"referral.inviteLink.title": "لینک دعوت",
|
||||
"referral.rules.antiAbuse": "در صورت شناسایی فعالیتهای تقلبی (مانند ثبتنام انبوه با حسابهای ایمیل موقت)، حسابهای مرتبط بهطور دائمی مسدود خواهند شد.",
|
||||
"referral.rules.backfill.alreadyBound": "شما قبلاً یک کد دعوت را وارد کردهاید",
|
||||
@@ -367,10 +368,13 @@
|
||||
"referral.rules.backfill.title": "تکمیل کد دعوت",
|
||||
"referral.rules.description": "با قوانین برنامه پاداش دعوت آشنا شوید",
|
||||
"referral.rules.expiry": "اعتبار پاداش: اعتبارات دعوت پس از ۱۰۰ روز عدم فعالیت کاربر حذف میشوند",
|
||||
"referral.rules.missedCode": "کد دعوت را وارد نکردهاید: میتوانید تا <0>۳ روز پس از ثبتنام</0> آن را وارد کنید",
|
||||
"referral.rules.priority": "اولویت مصرف اعتبار: اعتبارات رایگان → اعتبارات اشتراک → اعتبارات دعوت → اعتبارات شارژشده",
|
||||
"referral.rules.registration": "روش ثبتنام: کاربران دعوتشده از طریق لینک دعوت ثبتنام میکنند یا کد دعوت را در صفحه ثبتنام وارد میکنند",
|
||||
"referral.rules.reward": "پاداش: دعوتکننده و دعوتشونده هرکدام {{reward}}M اعتبار دریافت میکنند",
|
||||
"referral.rules.rewardDelay": "پردازش پاداش: ممکن است توزیع اعتبار تا ۶ ساعت پس از انجام یک اقدام معتبر طول بکشد. لطفاً شکیبا باشید.",
|
||||
"referral.rules.title": "قوانین برنامه",
|
||||
"referral.rules.validInvitation": "دعوت معتبر: دعوتشونده با کد دعوت شما ثبتنام کرده و یک اقدام معتبر انجام دهد",
|
||||
"referral.rules.validOperation": "معیار اقدام معتبر: ارسال یک پیام در صفحه چت یا تولید یک تصویر در صفحه تصویر",
|
||||
"referral.stats.availableBalance": "موجودی قابل استفاده",
|
||||
"referral.stats.description": "آمار دعوتهای خود را مشاهده کنید",
|
||||
@@ -383,6 +387,7 @@
|
||||
"referral.table.columns.inviterRewardAmount": "پاداش من",
|
||||
"referral.table.columns.rewardedAt": "زمان دریافت پاداش",
|
||||
"referral.table.columns.status": "وضعیت",
|
||||
"referral.table.status.pending_reward": "پاداش در انتظار",
|
||||
"referral.table.status.registered": "ثبتنام شده",
|
||||
"referral.table.status.revoked": "لغو شده",
|
||||
"referral.table.status.rewarded": "پاداش داده شده",
|
||||
|
||||
@@ -846,7 +846,7 @@
|
||||
"upload.preview.status.pending": "Préparation au téléversement...",
|
||||
"upload.preview.status.processing": "Traitement du fichier...",
|
||||
"upload.validation.unsupportedFileType": "Type de fichier non pris en charge : {{files}}. Images prises en charge : JPG, PNG, GIF, WebP. Les documents pris en charge incluent PDF, Word, Excel, PowerPoint, Markdown, texte, CSV, JSON et fichiers de code.",
|
||||
"upload.validation.videoSizeExceeded": "La taille du fichier vidéo ne doit pas dépasser {{maxSize}}. Taille actuelle : {{actualSize}}.",
|
||||
"upload.validation.videoSizeExceeded": "La taille du fichier vidéo ne doit pas dépasser 20 Mo. Taille actuelle : {{actualSize}}.",
|
||||
"viewMode.fullWidth": "Pleine largeur",
|
||||
"viewMode.normal": "Standard",
|
||||
"viewMode.wideScreen": "Grand écran",
|
||||
|
||||
@@ -355,6 +355,7 @@
|
||||
"referral.errors.updateFailed": "Échec de la mise à jour, veuillez réessayer plus tard",
|
||||
"referral.inviteCode.description": "Partagez votre code de parrainage exclusif pour inviter vos amis",
|
||||
"referral.inviteCode.title": "Mon code de parrainage",
|
||||
"referral.inviteLink.description": "Copiez le lien et partagez-le. Une fois l’inscription terminée, recevez des récompenses",
|
||||
"referral.inviteLink.title": "Lien de parrainage",
|
||||
"referral.rules.antiAbuse": "Si une activité frauduleuse est détectée (par exemple, l'enregistrement massif de comptes avec des adresses e-mail jetables), les comptes associés seront définitivement bannis.",
|
||||
"referral.rules.backfill.alreadyBound": "Vous avez déjà lié un code d'invitation",
|
||||
@@ -367,10 +368,13 @@
|
||||
"referral.rules.backfill.title": "Saisir un code d'invitation",
|
||||
"referral.rules.description": "Découvrez les règles du programme de parrainage",
|
||||
"referral.rules.expiry": "Validité des crédits : les crédits seront supprimés après 100 jours d’inactivité",
|
||||
"referral.rules.missedCode": "Code d'invitation manqué : vous pouvez le <0>saisir</0> dans les 3 jours suivant votre inscription",
|
||||
"referral.rules.priority": "Ordre d’utilisation : Crédits gratuits → Crédits d’abonnement → Crédits de parrainage → Crédits achetés",
|
||||
"referral.rules.registration": "Méthode d’inscription : via lien ou code de parrainage",
|
||||
"referral.rules.reward": "Récompense : le parrain et le filleul reçoivent chacun {{reward}}M crédits",
|
||||
"referral.rules.rewardDelay": "Traitement des récompenses : Les crédits peuvent prendre jusqu'à 6 heures pour être distribués après avoir effectué une action valide. Merci de votre patience.",
|
||||
"referral.rules.title": "Règles du programme",
|
||||
"referral.rules.validInvitation": "Invitation valide : le filleul s’inscrit avec votre code et effectue une action valide",
|
||||
"referral.rules.validOperation": "Critères d’action valide : envoyer un message ou générer une image",
|
||||
"referral.stats.availableBalance": "Solde disponible",
|
||||
"referral.stats.description": "Consultez vos statistiques de parrainage",
|
||||
@@ -383,6 +387,7 @@
|
||||
"referral.table.columns.inviterRewardAmount": "Ma récompense",
|
||||
"referral.table.columns.rewardedAt": "Date de récompense",
|
||||
"referral.table.columns.status": "Statut",
|
||||
"referral.table.status.pending_reward": "Récompense en attente",
|
||||
"referral.table.status.registered": "Inscrit",
|
||||
"referral.table.status.revoked": "Révoqué",
|
||||
"referral.table.status.rewarded": "Récompensé",
|
||||
|
||||
@@ -846,7 +846,7 @@
|
||||
"upload.preview.status.pending": "Preparazione al caricamento...",
|
||||
"upload.preview.status.processing": "Elaborazione file...",
|
||||
"upload.validation.unsupportedFileType": "Tipo di file non supportato: {{files}}. Immagini supportate: JPG, PNG, GIF, WebP. Documenti supportati includono PDF, Word, Excel, PowerPoint, Markdown, testo, CSV, JSON e file di codice.",
|
||||
"upload.validation.videoSizeExceeded": "La dimensione del file video non deve superare i {{maxSize}}. Dimensione attuale: {{actualSize}}.",
|
||||
"upload.validation.videoSizeExceeded": "La dimensione del file video non deve superare i 20MB. Dimensione attuale: {{actualSize}}.",
|
||||
"viewMode.fullWidth": "Larghezza completa",
|
||||
"viewMode.normal": "Standard",
|
||||
"viewMode.wideScreen": "Widescreen",
|
||||
|
||||
@@ -355,6 +355,7 @@
|
||||
"referral.errors.updateFailed": "Aggiornamento fallito, riprova più tardi",
|
||||
"referral.inviteCode.description": "Condividi il tuo codice invito esclusivo per invitare amici a registrarsi",
|
||||
"referral.inviteCode.title": "Il Mio Codice Invito",
|
||||
"referral.inviteLink.description": "Copia il link e condividilo con gli amici. Completa la registrazione per ricevere ricompense",
|
||||
"referral.inviteLink.title": "Link Invito",
|
||||
"referral.rules.antiAbuse": "Se viene rilevata un'attività fraudolenta (ad esempio, registrazione di massa di account email usa e getta), gli account associati saranno permanentemente bannati.",
|
||||
"referral.rules.backfill.alreadyBound": "Hai già associato un codice invito",
|
||||
@@ -367,10 +368,13 @@
|
||||
"referral.rules.backfill.title": "Recupera Codice Invito",
|
||||
"referral.rules.description": "Scopri le regole del programma di ricompensa inviti",
|
||||
"referral.rules.expiry": "Validità dei crediti: i crediti invito disponibili verranno eliminati dopo 100 giorni di inattività dell'utente",
|
||||
"referral.rules.missedCode": "Hai dimenticato il codice invito: puoi <0>recuperarlo</0> entro 3 giorni dalla registrazione",
|
||||
"referral.rules.priority": "Priorità di consumo crediti: Crediti gratuiti → Crediti abbonamento → Crediti invito → Crediti ricaricati",
|
||||
"referral.rules.registration": "Metodo di registrazione: gli utenti invitati si registrano tramite link invito o inserendo il codice invito nella pagina di registrazione",
|
||||
"referral.rules.reward": "Ricompensa: sia il referente che l'invitato ricevono {{reward}}M crediti",
|
||||
"referral.rules.rewardDelay": "Elaborazione delle ricompense: i crediti potrebbero richiedere fino a 6 ore per essere distribuiti dopo aver completato un'azione valida. Ti preghiamo di essere paziente.",
|
||||
"referral.rules.title": "Regole del Programma",
|
||||
"referral.rules.validInvitation": "Invito valido: l'invitato si registra con il tuo codice invito ed esegue un'azione valida",
|
||||
"referral.rules.validOperation": "Criteri di azione valida: invia un messaggio nella pagina Chat o genera un'immagine nella pagina immagini",
|
||||
"referral.stats.availableBalance": "Saldo Disponibile",
|
||||
"referral.stats.description": "Visualizza le tue statistiche di invito",
|
||||
@@ -383,6 +387,7 @@
|
||||
"referral.table.columns.inviterRewardAmount": "Mia Ricompensa",
|
||||
"referral.table.columns.rewardedAt": "Data Ricompensa",
|
||||
"referral.table.columns.status": "Stato",
|
||||
"referral.table.status.pending_reward": "Ricompensa in sospeso",
|
||||
"referral.table.status.registered": "Registrato",
|
||||
"referral.table.status.revoked": "Revocato",
|
||||
"referral.table.status.rewarded": "Ricompensato",
|
||||
|
||||
@@ -846,7 +846,7 @@
|
||||
"upload.preview.status.pending": "アップロードの準備中…",
|
||||
"upload.preview.status.processing": "ファイル処理中…",
|
||||
"upload.validation.unsupportedFileType": "サポートされていないファイルタイプ: {{files}}。サポートされている画像形式: JPG、PNG、GIF、WebP。サポートされているドキュメント形式: PDF、Word、Excel、PowerPoint、Markdown、テキスト、CSV、JSON、コードファイル。",
|
||||
"upload.validation.videoSizeExceeded": "ビデオファイルは {{maxSize}} を超えることはできません。現在は {{actualSize}} です",
|
||||
"upload.validation.videoSizeExceeded": "ビデオファイルは 20MB を超えることはできません。現在は {{actualSize}} です",
|
||||
"viewMode.fullWidth": "全幅表示",
|
||||
"viewMode.normal": "標準",
|
||||
"viewMode.wideScreen": "ワイドスクリーン",
|
||||
|
||||
@@ -355,6 +355,7 @@
|
||||
"referral.errors.updateFailed": "更新に失敗しました。後でもう一度お試しください。",
|
||||
"referral.inviteCode.description": "あなた専用の紹介コードを共有して、友達を招待しましょう",
|
||||
"referral.inviteCode.title": "マイ紹介コード",
|
||||
"referral.inviteLink.description": "リンクをコピーして友達に共有。登録完了で報酬を獲得",
|
||||
"referral.inviteLink.title": "紹介リンク",
|
||||
"referral.rules.antiAbuse": "不正行為(例:使い捨てメールアカウントの大量登録)が検出された場合、関連するアカウントは永久に禁止されます。",
|
||||
"referral.rules.backfill.alreadyBound": "すでに招待コードが紐付けられています",
|
||||
@@ -367,10 +368,13 @@
|
||||
"referral.rules.backfill.title": "招待コードの追加入力",
|
||||
"referral.rules.description": "紹介報酬プログラムのルールを確認",
|
||||
"referral.rules.expiry": "クレジットの有効期限:100日間アクティビティがない場合、紹介クレジットは失効します",
|
||||
"referral.rules.missedCode": "招待コードを入力し忘れましたか?登録から3日以内であれば<0>追加入力</0>が可能です",
|
||||
"referral.rules.priority": "クレジット使用優先順位:無料クレジット → サブスククレジット → 紹介クレジット → チャージクレジット",
|
||||
"referral.rules.registration": "登録方法:紹介リンクから登録、または登録ページで紹介コードを入力",
|
||||
"referral.rules.reward": "報酬:紹介者と被紹介者の両方に{{reward}}Mクレジットを付与",
|
||||
"referral.rules.rewardDelay": "報酬処理: 有効なアクションを完了した後、クレジットの配布には最大6時間かかる場合があります。しばらくお待ちください。",
|
||||
"referral.rules.title": "プログラムルール",
|
||||
"referral.rules.validInvitation": "有効な招待:紹介コードで登録し、有効な操作を1回実行",
|
||||
"referral.rules.validOperation": "有効な操作の条件:チャットページで1回メッセージ送信、または画像ページで1枚生成",
|
||||
"referral.stats.availableBalance": "利用可能残高",
|
||||
"referral.stats.description": "紹介統計を確認",
|
||||
@@ -383,6 +387,7 @@
|
||||
"referral.table.columns.inviterRewardAmount": "自分の報酬",
|
||||
"referral.table.columns.rewardedAt": "報酬付与日時",
|
||||
"referral.table.columns.status": "ステータス",
|
||||
"referral.table.status.pending_reward": "保留中の報酬",
|
||||
"referral.table.status.registered": "登録済み",
|
||||
"referral.table.status.revoked": "取り消し",
|
||||
"referral.table.status.rewarded": "報酬済み",
|
||||
|
||||
@@ -846,7 +846,7 @@
|
||||
"upload.preview.status.pending": "업로드 준비 중…",
|
||||
"upload.preview.status.processing": "파일 처리 중…",
|
||||
"upload.validation.unsupportedFileType": "지원되지 않는 파일 형식: {{files}}. 지원되는 이미지: JPG, PNG, GIF, WebP. 지원되는 문서: PDF, Word, Excel, PowerPoint, Markdown, 텍스트, CSV, JSON 및 코드 파일.",
|
||||
"upload.validation.videoSizeExceeded": "비디오 파일은 {{maxSize}}를 초과할 수 없습니다. 현재 {{actualSize}}입니다",
|
||||
"upload.validation.videoSizeExceeded": "비디오 파일은 20MB를 초과할 수 없습니다. 현재 {{actualSize}}입니다",
|
||||
"viewMode.fullWidth": "전체 너비",
|
||||
"viewMode.normal": "일반",
|
||||
"viewMode.wideScreen": "와이드스크린",
|
||||
|
||||
@@ -355,6 +355,7 @@
|
||||
"referral.errors.updateFailed": "업데이트에 실패했습니다. 나중에 다시 시도해 주세요.",
|
||||
"referral.inviteCode.description": "나만의 추천 코드를 공유하여 친구를 초대하세요.",
|
||||
"referral.inviteCode.title": "내 추천 코드",
|
||||
"referral.inviteLink.description": "링크를 복사하여 친구에게 공유하세요. 가입 완료 시 보상을 받을 수 있습니다.",
|
||||
"referral.inviteLink.title": "추천 링크",
|
||||
"referral.rules.antiAbuse": "사기 행위가 감지될 경우(예: 일회용 이메일 계정의 대량 등록), 관련 계정은 영구적으로 차단됩니다.",
|
||||
"referral.rules.backfill.alreadyBound": "이미 초대 코드를 등록하셨습니다",
|
||||
@@ -367,10 +368,13 @@
|
||||
"referral.rules.backfill.title": "초대 코드 소급 입력",
|
||||
"referral.rules.description": "추천 보상 프로그램 규칙 알아보기",
|
||||
"referral.rules.expiry": "크레딧 유효기간: 100일간 활동이 없으면 추천 크레딧이 소멸됩니다.",
|
||||
"referral.rules.missedCode": "초대 코드 입력을 놓치셨나요? 가입 후 3일 이내에 <0>소급 입력</0>이 가능합니다",
|
||||
"referral.rules.priority": "크레딧 사용 우선순위: 무료 크레딧 → 구독 크레딧 → 추천 크레딧 → 충전 크레딧",
|
||||
"referral.rules.registration": "가입 방법: 추천 링크를 통해 가입하거나 가입 시 추천 코드 입력",
|
||||
"referral.rules.reward": "보상: 추천인과 피추천인 모두 {{reward}}M 크레딧 지급",
|
||||
"referral.rules.rewardDelay": "보상 처리: 유효한 작업 완료 후 크레딧이 분배되기까지 최대 6시간이 소요될 수 있습니다. 양해 부탁드립니다.",
|
||||
"referral.rules.title": "프로그램 규칙",
|
||||
"referral.rules.validInvitation": "유효한 초대: 추천 코드로 가입 후 유효한 행동 1회 수행",
|
||||
"referral.rules.validOperation": "유효한 행동 기준: 채팅 페이지에서 메시지 1회 전송 또는 이미지 생성 1회",
|
||||
"referral.stats.availableBalance": "사용 가능 잔액",
|
||||
"referral.stats.description": "추천 통계를 확인하세요",
|
||||
@@ -383,6 +387,7 @@
|
||||
"referral.table.columns.inviterRewardAmount": "내 보상",
|
||||
"referral.table.columns.rewardedAt": "보상 시간",
|
||||
"referral.table.columns.status": "상태",
|
||||
"referral.table.status.pending_reward": "보상 대기 중",
|
||||
"referral.table.status.registered": "가입 완료",
|
||||
"referral.table.status.revoked": "취소됨",
|
||||
"referral.table.status.rewarded": "보상 완료",
|
||||
|
||||
@@ -846,7 +846,7 @@
|
||||
"upload.preview.status.pending": "Voorbereiden op upload...",
|
||||
"upload.preview.status.processing": "Bestand verwerken...",
|
||||
"upload.validation.unsupportedFileType": "Niet-ondersteund bestandstype: {{files}}. Ondersteunde afbeeldingen: JPG, PNG, GIF, WebP. Ondersteunde documenten zijn onder andere PDF, Word, Excel, PowerPoint, Markdown, tekst, CSV, JSON en codebestanden.",
|
||||
"upload.validation.videoSizeExceeded": "De bestandsgrootte van de video mag niet groter zijn dan {{maxSize}}. Huidige grootte is {{actualSize}}.",
|
||||
"upload.validation.videoSizeExceeded": "De bestandsgrootte van de video mag niet groter zijn dan 20MB. Huidige grootte is {{actualSize}}.",
|
||||
"viewMode.fullWidth": "Volledige breedte",
|
||||
"viewMode.normal": "Standaard",
|
||||
"viewMode.wideScreen": "Widescreen",
|
||||
|
||||
@@ -355,6 +355,7 @@
|
||||
"referral.errors.updateFailed": "Bijwerken mislukt, probeer later opnieuw",
|
||||
"referral.inviteCode.description": "Deel je unieke verwijzingscode om vrienden uit te nodigen",
|
||||
"referral.inviteCode.title": "Mijn Verwijzingscode",
|
||||
"referral.inviteLink.description": "Kopieer de link en deel met vrienden. Na registratie ontvang je beloningen",
|
||||
"referral.inviteLink.title": "Verwijzingslink",
|
||||
"referral.rules.antiAbuse": "Als frauduleuze activiteiten worden gedetecteerd (bijv. massaregistratie van wegwerp-e-mailaccounts), worden de bijbehorende accounts permanent verbannen.",
|
||||
"referral.rules.backfill.alreadyBound": "Je hebt al een uitnodigingscode gekoppeld",
|
||||
@@ -367,10 +368,13 @@
|
||||
"referral.rules.backfill.title": "Uitnodigingscode invullen",
|
||||
"referral.rules.description": "Lees de regels van het verwijzingsprogramma",
|
||||
"referral.rules.expiry": "Geldigheid: credits vervallen na 100 dagen inactiviteit",
|
||||
"referral.rules.missedCode": "Uitnodigingscode gemist: je kunt deze <0>binnen 3 dagen</0> na registratie alsnog invullen",
|
||||
"referral.rules.priority": "Verbruikvolgorde: Gratis → Abonnement → Verwijzing → Opladen",
|
||||
"referral.rules.registration": "Registratie: via link of code op registratiepagina",
|
||||
"referral.rules.reward": "Beloning: Jij en je vriend ontvangen elk {{reward}}M credits",
|
||||
"referral.rules.rewardDelay": "Verwerking van beloningen: Het kan tot 6 uur duren voordat credits worden toegekend na het voltooien van een geldige actie. Wees alstublieft geduldig.",
|
||||
"referral.rules.title": "Programmaregels",
|
||||
"referral.rules.validInvitation": "Geldige uitnodiging: vriend registreert met jouw code en voert een geldige actie uit",
|
||||
"referral.rules.validOperation": "Geldige actie: één bericht verzenden of één afbeelding genereren",
|
||||
"referral.stats.availableBalance": "Beschikbaar Saldo",
|
||||
"referral.stats.description": "Bekijk je verwijzingsstatistieken",
|
||||
@@ -383,6 +387,7 @@
|
||||
"referral.table.columns.inviterRewardAmount": "Mijn Beloning",
|
||||
"referral.table.columns.rewardedAt": "Beloningstijd",
|
||||
"referral.table.columns.status": "Status",
|
||||
"referral.table.status.pending_reward": "In afwachting van beloning",
|
||||
"referral.table.status.registered": "Geregistreerd",
|
||||
"referral.table.status.revoked": "Ingetrokken",
|
||||
"referral.table.status.rewarded": "Beloond",
|
||||
|
||||
@@ -846,7 +846,7 @@
|
||||
"upload.preview.status.pending": "Przygotowywanie do przesłania...",
|
||||
"upload.preview.status.processing": "Przetwarzanie pliku...",
|
||||
"upload.validation.unsupportedFileType": "Nieobsługiwany typ pliku: {{files}}. Obsługiwane obrazy: JPG, PNG, GIF, WebP. Obsługiwane dokumenty obejmują PDF, Word, Excel, PowerPoint, Markdown, tekst, CSV, JSON i pliki kodu.",
|
||||
"upload.validation.videoSizeExceeded": "Rozmiar pliku wideo nie może przekraczać {{maxSize}}. Obecny rozmiar pliku to {{actualSize}}.",
|
||||
"upload.validation.videoSizeExceeded": "Rozmiar pliku wideo nie może przekraczać 20 MB. Obecny rozmiar pliku to {{actualSize}}.",
|
||||
"viewMode.fullWidth": "Pełna szerokość",
|
||||
"viewMode.normal": "Standardowy",
|
||||
"viewMode.wideScreen": "Szeroki ekran",
|
||||
|
||||
@@ -355,6 +355,7 @@
|
||||
"referral.errors.updateFailed": "Aktualizacja nie powiodła się, spróbuj ponownie później",
|
||||
"referral.inviteCode.description": "Udostępnij swój unikalny kod polecający, aby zaprosić znajomych do rejestracji",
|
||||
"referral.inviteCode.title": "Mój Kod Polecający",
|
||||
"referral.inviteLink.description": "Skopiuj link i udostępnij znajomym. Po zakończeniu rejestracji otrzymasz nagrody",
|
||||
"referral.inviteLink.title": "Link Polecający",
|
||||
"referral.rules.antiAbuse": "W przypadku wykrycia oszukańczej działalności (np. masowej rejestracji jednorazowych kont e-mail), powiązane konta zostaną trwale zablokowane.",
|
||||
"referral.rules.backfill.alreadyBound": "Kod zaproszenia został już powiązany",
|
||||
@@ -367,10 +368,13 @@
|
||||
"referral.rules.backfill.title": "Uzupełnij kod zaproszenia",
|
||||
"referral.rules.description": "Poznaj zasady programu nagród za polecenia",
|
||||
"referral.rules.expiry": "Ważność środków: dostępne środki z poleceń zostaną usunięte po 100 dniach braku aktywności użytkownika",
|
||||
"referral.rules.missedCode": "Brakujący kod zaproszenia: możesz go <0>uzupełnić</0> w ciągu 3 dni od rejestracji",
|
||||
"referral.rules.priority": "Priorytet wykorzystania środków: Darmowe środki → Środki z subskrypcji → Środki z poleceń → Doładowane środki",
|
||||
"referral.rules.registration": "Sposób rejestracji: zaproszeni użytkownicy rejestrują się przez link polecający lub wpisują kod polecający na stronie rejestracji",
|
||||
"referral.rules.reward": "Nagroda: Polecający i zaproszony otrzymują po {{reward}}M środków",
|
||||
"referral.rules.rewardDelay": "Przetwarzanie nagród: Kredyty mogą być przyznawane do 6 godzin po wykonaniu ważnej akcji. Prosimy o cierpliwość.",
|
||||
"referral.rules.title": "Zasady Programu",
|
||||
"referral.rules.validInvitation": "Ważne zaproszenie: zaproszony rejestruje się z Twoim kodem polecającym i wykonuje jedną ważną akcję",
|
||||
"referral.rules.validOperation": "Kryteria ważnej akcji: wysłanie jednej wiadomości na stronie czatu lub wygenerowanie jednego obrazu na stronie obrazów",
|
||||
"referral.stats.availableBalance": "Dostępne Środki",
|
||||
"referral.stats.description": "Zobacz swoje statystyki poleceń",
|
||||
@@ -383,6 +387,7 @@
|
||||
"referral.table.columns.inviterRewardAmount": "Moja Nagroda",
|
||||
"referral.table.columns.rewardedAt": "Czas Przyznania Nagrody",
|
||||
"referral.table.columns.status": "Status",
|
||||
"referral.table.status.pending_reward": "Oczekująca Nagroda",
|
||||
"referral.table.status.registered": "Zarejestrowany",
|
||||
"referral.table.status.revoked": "Cofnięty",
|
||||
"referral.table.status.rewarded": "Nagrodzony",
|
||||
|
||||
@@ -846,7 +846,7 @@
|
||||
"upload.preview.status.pending": "Preparando para envio...",
|
||||
"upload.preview.status.processing": "Processando arquivo...",
|
||||
"upload.validation.unsupportedFileType": "Tipo de arquivo não suportado: {{files}}. Imagens suportadas: JPG, PNG, GIF, WebP. Documentos suportados incluem PDF, Word, Excel, PowerPoint, Markdown, texto, CSV, JSON e arquivos de código.",
|
||||
"upload.validation.videoSizeExceeded": "O tamanho do vídeo não deve exceder {{maxSize}}. Tamanho atual: {{actualSize}}.",
|
||||
"upload.validation.videoSizeExceeded": "O tamanho do vídeo não deve exceder 20MB. Tamanho atual: {{actualSize}}.",
|
||||
"viewMode.fullWidth": "Largura Total",
|
||||
"viewMode.normal": "Padrão",
|
||||
"viewMode.wideScreen": "Tela Larga",
|
||||
|
||||
@@ -355,6 +355,7 @@
|
||||
"referral.errors.updateFailed": "Falha ao atualizar, tente novamente mais tarde",
|
||||
"referral.inviteCode.description": "Compartilhe seu código exclusivo para convidar amigos a se registrarem",
|
||||
"referral.inviteCode.title": "Meu Código de Indicação",
|
||||
"referral.inviteLink.description": "Copie o link e compartilhe com amigos. Ao se registrarem, ambos ganham recompensas",
|
||||
"referral.inviteLink.title": "Link de Indicação",
|
||||
"referral.rules.antiAbuse": "Se for detectada atividade fraudulenta (por exemplo, registro em massa de contas de e-mail descartáveis), as contas associadas serão permanentemente banidas.",
|
||||
"referral.rules.backfill.alreadyBound": "Você já vinculou um código de convite",
|
||||
@@ -367,10 +368,13 @@
|
||||
"referral.rules.backfill.title": "Preencher Código de Convite",
|
||||
"referral.rules.description": "Conheça as regras do programa de recompensas por indicação",
|
||||
"referral.rules.expiry": "Validade dos créditos: créditos de indicação serão expirados após 100 dias de inatividade",
|
||||
"referral.rules.missedCode": "Código de convite perdido: você pode <0>preencher</0> em até 3 dias após o registro",
|
||||
"referral.rules.priority": "Ordem de uso dos créditos: Gratuitos → Assinatura → Indicação → Recarga",
|
||||
"referral.rules.registration": "Método de registro: usuários convidados devem se registrar via link ou inserir o código na página de cadastro",
|
||||
"referral.rules.reward": "Recompensa: Indicador e convidado recebem {{reward}}M créditos cada",
|
||||
"referral.rules.rewardDelay": "Processamento de recompensas: Os créditos podem levar até 6 horas para serem distribuídos após a conclusão de uma ação válida. Por favor, seja paciente.",
|
||||
"referral.rules.title": "Regras do Programa",
|
||||
"referral.rules.validInvitation": "Convite válido: o convidado se registra com seu código e realiza uma ação válida",
|
||||
"referral.rules.validOperation": "Critérios de ação válida: Enviar uma mensagem na página de chat ou gerar uma imagem",
|
||||
"referral.stats.availableBalance": "Saldo Disponível",
|
||||
"referral.stats.description": "Veja suas estatísticas de indicação",
|
||||
@@ -383,6 +387,7 @@
|
||||
"referral.table.columns.inviterRewardAmount": "Minha Recompensa",
|
||||
"referral.table.columns.rewardedAt": "Data da Recompensa",
|
||||
"referral.table.columns.status": "Status",
|
||||
"referral.table.status.pending_reward": "Recompensa Pendente",
|
||||
"referral.table.status.registered": "Registrado",
|
||||
"referral.table.status.revoked": "Revogado",
|
||||
"referral.table.status.rewarded": "Recompensado",
|
||||
|
||||
@@ -846,7 +846,7 @@
|
||||
"upload.preview.status.pending": "Подготовка к загрузке...",
|
||||
"upload.preview.status.processing": "Обработка файла...",
|
||||
"upload.validation.unsupportedFileType": "Неподдерживаемый тип файла: {{files}}. Поддерживаемые изображения: JPG, PNG, GIF, WebP. Поддерживаемые документы: PDF, Word, Excel, PowerPoint, Markdown, текст, CSV, JSON и файлы кода.",
|
||||
"upload.validation.videoSizeExceeded": "Размер видеофайла не должен превышать {{maxSize}}. Текущий размер: {{actualSize}}.",
|
||||
"upload.validation.videoSizeExceeded": "Размер видеофайла не должен превышать 20 МБ. Текущий размер: {{actualSize}}.",
|
||||
"viewMode.fullWidth": "Полная ширина",
|
||||
"viewMode.normal": "Стандартный",
|
||||
"viewMode.wideScreen": "Широкий экран",
|
||||
|
||||
@@ -355,6 +355,7 @@
|
||||
"referral.errors.updateFailed": "Не удалось обновить, попробуйте позже",
|
||||
"referral.inviteCode.description": "Поделитесь своим кодом, чтобы пригласить друзей",
|
||||
"referral.inviteCode.title": "Мой реферальный код",
|
||||
"referral.inviteLink.description": "Скопируйте ссылку и отправьте друзьям. После регистрации получите бонус",
|
||||
"referral.inviteLink.title": "Реферальная ссылка",
|
||||
"referral.rules.antiAbuse": "Если будет обнаружена мошенническая активность (например, массовая регистрация временных электронных адресов), связанные аккаунты будут навсегда заблокированы.",
|
||||
"referral.rules.backfill.alreadyBound": "Вы уже привязали код приглашения",
|
||||
@@ -367,10 +368,13 @@
|
||||
"referral.rules.backfill.title": "Ввод кода приглашения",
|
||||
"referral.rules.description": "Узнайте правила программы вознаграждений",
|
||||
"referral.rules.expiry": "Срок действия: кредиты аннулируются после 100 дней неактивности",
|
||||
"referral.rules.missedCode": "Пропустили код приглашения: вы можете <0>ввести его</0> в течение 3 дней после регистрации",
|
||||
"referral.rules.priority": "Порядок списания: Бесплатные → Подписочные → Реферальные → Пополненные",
|
||||
"referral.rules.registration": "Регистрация: по ссылке или вводу кода на странице регистрации",
|
||||
"referral.rules.reward": "Награда: пригласивший и приглашённый получают по {{reward}}M кредитов",
|
||||
"referral.rules.rewardDelay": "Обработка вознаграждений: Кредиты могут быть распределены в течение 6 часов после выполнения действительного действия. Пожалуйста, будьте терпеливы.",
|
||||
"referral.rules.title": "Правила программы",
|
||||
"referral.rules.validInvitation": "Действительное приглашение: регистрация с кодом и одно активное действие",
|
||||
"referral.rules.validOperation": "Критерии действия: отправка сообщения или генерация изображения",
|
||||
"referral.stats.availableBalance": "Доступный баланс",
|
||||
"referral.stats.description": "Просмотр статистики приглашений",
|
||||
@@ -383,6 +387,7 @@
|
||||
"referral.table.columns.inviterRewardAmount": "Моя награда",
|
||||
"referral.table.columns.rewardedAt": "Дата награды",
|
||||
"referral.table.columns.status": "Статус",
|
||||
"referral.table.status.pending_reward": "Ожидаемое вознаграждение",
|
||||
"referral.table.status.registered": "Зарегистрирован",
|
||||
"referral.table.status.revoked": "Отменено",
|
||||
"referral.table.status.rewarded": "Награждён",
|
||||
|
||||
@@ -846,7 +846,7 @@
|
||||
"upload.preview.status.pending": "Yüklemeye hazırlanıyor...",
|
||||
"upload.preview.status.processing": "Dosya işleniyor...",
|
||||
"upload.validation.unsupportedFileType": "Desteklenmeyen dosya türü: {{files}}. Desteklenen görseller: JPG, PNG, GIF, WebP. Desteklenen belgeler: PDF, Word, Excel, PowerPoint, Markdown, metin, CSV, JSON ve kod dosyaları.",
|
||||
"upload.validation.videoSizeExceeded": "Video dosya boyutu {{maxSize}}'ı geçmemelidir. Mevcut dosya boyutu {{actualSize}}.",
|
||||
"upload.validation.videoSizeExceeded": "Video dosya boyutu 20MB'ı geçmemelidir. Mevcut dosya boyutu {{actualSize}}.",
|
||||
"viewMode.fullWidth": "Tam Genişlik",
|
||||
"viewMode.normal": "Standart",
|
||||
"viewMode.wideScreen": "Geniş Ekran",
|
||||
|
||||
@@ -355,6 +355,7 @@
|
||||
"referral.errors.updateFailed": "Güncelleme başarısız oldu, lütfen daha sonra tekrar deneyin",
|
||||
"referral.inviteCode.description": "Arkadaşlarınızı davet etmek için özel referans kodunuzu paylaşın",
|
||||
"referral.inviteCode.title": "Referans Kodum",
|
||||
"referral.inviteLink.description": "Bağlantıyı kopyalayıp arkadaşlarınızla paylaşın. Kayıt tamamlandığında ödül kazanın",
|
||||
"referral.inviteLink.title": "Referans Bağlantısı",
|
||||
"referral.rules.antiAbuse": "Eğer sahtecilik tespit edilirse (örneğin, geçici e-posta hesaplarının toplu kaydı), ilgili hesaplar kalıcı olarak yasaklanacaktır.",
|
||||
"referral.rules.backfill.alreadyBound": "Davet kodu zaten bağlandı",
|
||||
@@ -367,10 +368,13 @@
|
||||
"referral.rules.backfill.title": "Davet Kodunu Geriye Dönük Gir",
|
||||
"referral.rules.description": "Referans ödül programı kurallarını öğrenin",
|
||||
"referral.rules.expiry": "Kredi geçerliliği: Kullanıcının 100 gün boyunca etkin olmaması durumunda referans kredileri silinir",
|
||||
"referral.rules.missedCode": "Davet kodunu kaçırdınız: Kayıttan sonraki 3 gün içinde <0>geriye dönük giriş</0> yapabilirsiniz",
|
||||
"referral.rules.priority": "Kredi kullanım önceliği: Ücretsiz krediler → Abonelik kredileri → Referans kredileri → Yükleme kredileri",
|
||||
"referral.rules.registration": "Kayıt yöntemi: Davet edilen kullanıcılar referans bağlantısı ile kayıt olur veya kayıt sayfasında referans kodunu girer",
|
||||
"referral.rules.reward": "Ödül: Davet eden ve edilen kişi {{reward}}M kredi kazanır",
|
||||
"referral.rules.rewardDelay": "Ödül işleme: Geçerli bir işlem tamamlandıktan sonra kredilerin dağıtılması 6 saate kadar sürebilir. Lütfen sabırlı olun.",
|
||||
"referral.rules.title": "Program Kuralları",
|
||||
"referral.rules.validInvitation": "Geçerli davet: Davet edilen kişi referans kodunuzla kayıt olur ve geçerli bir işlem yapar",
|
||||
"referral.rules.validOperation": "Geçerli işlem kriteri: Sohbet sayfasında bir mesaj gönderme veya görsel sayfasında bir görsel oluşturma",
|
||||
"referral.stats.availableBalance": "Kullanılabilir Bakiye",
|
||||
"referral.stats.description": "Referans istatistiklerinizi görüntüleyin",
|
||||
@@ -383,6 +387,7 @@
|
||||
"referral.table.columns.inviterRewardAmount": "Benim Ödülüm",
|
||||
"referral.table.columns.rewardedAt": "Ödül Zamanı",
|
||||
"referral.table.columns.status": "Durum",
|
||||
"referral.table.status.pending_reward": "Bekleyen Ödül",
|
||||
"referral.table.status.registered": "Kayıtlı",
|
||||
"referral.table.status.revoked": "İptal Edildi",
|
||||
"referral.table.status.rewarded": "Ödüllendirildi",
|
||||
|
||||
@@ -846,7 +846,7 @@
|
||||
"upload.preview.status.pending": "Đang chuẩn bị tải lên...",
|
||||
"upload.preview.status.processing": "Đang xử lý tệp...",
|
||||
"upload.validation.unsupportedFileType": "Loại tệp không được hỗ trợ: {{files}}. Hình ảnh được hỗ trợ: JPG, PNG, GIF, WebP. Các tài liệu được hỗ trợ bao gồm PDF, Word, Excel, PowerPoint, Markdown, văn bản, CSV, JSON và tệp mã.",
|
||||
"upload.validation.videoSizeExceeded": "Kích thước tệp video không được vượt quá {{maxSize}}. Kích thước hiện tại là {{actualSize}}.",
|
||||
"upload.validation.videoSizeExceeded": "Kích thước tệp video không được vượt quá 20MB. Kích thước hiện tại là {{actualSize}}.",
|
||||
"viewMode.fullWidth": "Toàn chiều rộng",
|
||||
"viewMode.normal": "Chuẩn",
|
||||
"viewMode.wideScreen": "Toàn màn hình",
|
||||
|
||||
@@ -355,6 +355,7 @@
|
||||
"referral.errors.updateFailed": "Cập nhật thất bại, vui lòng thử lại sau",
|
||||
"referral.inviteCode.description": "Chia sẻ mã giới thiệu độc quyền của bạn để mời bạn bè đăng ký",
|
||||
"referral.inviteCode.title": "Mã giới thiệu của tôi",
|
||||
"referral.inviteLink.description": "Sao chép liên kết và chia sẻ với bạn bè. Hoàn tất đăng ký để nhận thưởng",
|
||||
"referral.inviteLink.title": "Liên kết giới thiệu",
|
||||
"referral.rules.antiAbuse": "Nếu phát hiện hoạt động gian lận (ví dụ: đăng ký hàng loạt bằng tài khoản email dùng một lần), các tài khoản liên quan sẽ bị cấm vĩnh viễn.",
|
||||
"referral.rules.backfill.alreadyBound": "Bạn đã liên kết mã mời rồi",
|
||||
@@ -367,10 +368,13 @@
|
||||
"referral.rules.backfill.title": "Bổ sung mã mời",
|
||||
"referral.rules.description": "Tìm hiểu quy tắc chương trình giới thiệu",
|
||||
"referral.rules.expiry": "Hiệu lực tín dụng: Tín dụng giới thiệu sẽ bị xóa sau 100 ngày không hoạt động",
|
||||
"referral.rules.missedCode": "Bỏ lỡ mã mời: Bạn có thể <0>bổ sung</0> trong vòng 3 ngày kể từ khi đăng ký",
|
||||
"referral.rules.priority": "Thứ tự sử dụng tín dụng: Miễn phí → Đăng ký → Giới thiệu → Nạp thêm",
|
||||
"referral.rules.registration": "Cách đăng ký: Người được mời đăng ký qua liên kết giới thiệu hoặc nhập mã giới thiệu trên trang đăng ký",
|
||||
"referral.rules.reward": "Phần thưởng: Người giới thiệu và người được mời mỗi người nhận {{reward}}M tín dụng",
|
||||
"referral.rules.rewardDelay": "Xử lý phần thưởng: Tín dụng có thể mất đến 6 giờ để được phân phối sau khi hoàn thành một hành động hợp lệ. Vui lòng kiên nhẫn.",
|
||||
"referral.rules.title": "Quy tắc chương trình",
|
||||
"referral.rules.validInvitation": "Lời mời hợp lệ: Người được mời đăng ký bằng mã giới thiệu của bạn và thực hiện một hành động hợp lệ",
|
||||
"referral.rules.validOperation": "Tiêu chí hành động hợp lệ: Gửi một tin nhắn trên trang Chat hoặc tạo một hình ảnh trên trang hình ảnh",
|
||||
"referral.stats.availableBalance": "Số dư khả dụng",
|
||||
"referral.stats.description": "Xem thống kê giới thiệu của bạn",
|
||||
@@ -383,6 +387,7 @@
|
||||
"referral.table.columns.inviterRewardAmount": "Phần thưởng của tôi",
|
||||
"referral.table.columns.rewardedAt": "Thời gian nhận thưởng",
|
||||
"referral.table.columns.status": "Trạng thái",
|
||||
"referral.table.status.pending_reward": "Phần thưởng đang chờ",
|
||||
"referral.table.status.registered": "Đã đăng ký",
|
||||
"referral.table.status.revoked": "Đã thu hồi",
|
||||
"referral.table.status.rewarded": "Đã nhận thưởng",
|
||||
|
||||
@@ -149,8 +149,6 @@
|
||||
"extendParams.enableReasoning.title": "开启深度思考",
|
||||
"extendParams.imageAspectRatio.title": "图片宽高比",
|
||||
"extendParams.imageResolution.title": "图片分辨率",
|
||||
"extendParams.preserveThinking.desc": "开启后会将历史助手思考过程作为上下文回传给模型,可能增加 Token 消耗。",
|
||||
"extendParams.preserveThinking.title": "传递历史思考过程",
|
||||
"extendParams.reasoningBudgetToken.title": "思考 Token 预算",
|
||||
"extendParams.reasoningEffort.title": "推理强度",
|
||||
"extendParams.textVerbosity.title": "输出详细程度",
|
||||
@@ -867,7 +865,7 @@
|
||||
"upload.preview.status.pending": "准备上传…",
|
||||
"upload.preview.status.processing": "文件处理中…",
|
||||
"upload.validation.unsupportedFileType": "不支持的文件类型:{{files}}。支持的图片格式:JPG、PNG、GIF、WebP。支持的文档包括PDF、Word、Excel、PowerPoint、Markdown、文本、CSV、JSON和代码文件。",
|
||||
"upload.validation.videoSizeExceeded": "视频文件不能超过 {{maxSize}}。当前为 {{actualSize}}",
|
||||
"upload.validation.videoSizeExceeded": "视频文件不能超过 20MB。当前为 {{actualSize}}",
|
||||
"viewMode.fullWidth": "全宽显示",
|
||||
"viewMode.normal": "普通",
|
||||
"viewMode.wideScreen": "宽屏",
|
||||
@@ -893,6 +891,7 @@
|
||||
"workflow.toolDisplayName.calculate": "完成了计算",
|
||||
"workflow.toolDisplayName.callAgent": "调用了助理",
|
||||
"workflow.toolDisplayName.callSubAgent": "Call SubAgent",
|
||||
"workflow.toolDisplayName.callSubAgents": "Call SubAgents",
|
||||
"workflow.toolDisplayName.clearTodos": "清空了待办",
|
||||
"workflow.toolDisplayName.copyDocument": "复制了文档",
|
||||
"workflow.toolDisplayName.crawlMultiPages": "抓取了多个页面",
|
||||
|
||||
@@ -38,9 +38,6 @@
|
||||
"brief.title": "简报",
|
||||
"brief.viewAllTasks": "查看全部任务",
|
||||
"brief.viewRun": "查看运行轨迹",
|
||||
"freeCreditBadge.cta": "开始免费试用",
|
||||
"freeCreditBadge.dismiss": "关闭",
|
||||
"freeCreditBadge.label": "{{model}} 专属免费积分",
|
||||
"project.create": "新建项目",
|
||||
"project.deleteConfirm": "此项目将被删除且无法恢复。确认继续操作。",
|
||||
"recommendations.heteroAgent.cta": "添加助手",
|
||||
|
||||
@@ -234,7 +234,6 @@
|
||||
"providerModels.item.modelConfig.extendParams.options.imageResolution.hint": "适用于 Gemini 3 图像生成模型;控制生成图像的分辨率。",
|
||||
"providerModels.item.modelConfig.extendParams.options.imageResolution2.hint": "适用于 Gemini 3.1 Flash Image 模型;控制生成图像的分辨率(支持 512px)。",
|
||||
"providerModels.item.modelConfig.extendParams.options.opus47Effort.hint": "适用于 Claude Opus 4.7 及更高版本;控制努力级别(低/中/高/超高/最大)。",
|
||||
"providerModels.item.modelConfig.extendParams.options.preserveThinking.hint": "适用于 Qwen3.6 Plus、GLM-5 与 GLM-4.7;将历史助手思考过程回传为模型上下文(preserve_thinking)。",
|
||||
"providerModels.item.modelConfig.extendParams.options.reasoningBudgetToken.hint": "适用于 Claude、Qwen3 等模型;控制用于推理的 Token 预算。",
|
||||
"providerModels.item.modelConfig.extendParams.options.reasoningBudgetToken32k.hint": "适用于GLM-5和GLM-4.7;控制推理的令牌预算(最大32k)。",
|
||||
"providerModels.item.modelConfig.extendParams.options.reasoningBudgetToken80k.hint": "适用于Qwen3系列;控制推理的令牌预算(最大80k)。",
|
||||
|
||||
@@ -73,6 +73,8 @@
|
||||
"builtins.lobe-agent.apiName.analyzeVisualMedia.mediaCount": "{{count}} 个媒体",
|
||||
"builtins.lobe-agent.apiName.analyzeVisualMedia.result": "分析视觉媒体:<question>{{question}}</question>",
|
||||
"builtins.lobe-agent.apiName.callSubAgent": "Call SubAgent",
|
||||
"builtins.lobe-agent.apiName.callSubAgents": "Call SubAgents",
|
||||
"builtins.lobe-agent.apiName.callSubAgents.more": "等 {{count}} 个",
|
||||
"builtins.lobe-agent.apiName.clearTodos": "清除待办",
|
||||
"builtins.lobe-agent.apiName.clearTodos.modeAll": "全部",
|
||||
"builtins.lobe-agent.apiName.clearTodos.modeCompleted": "已完成",
|
||||
|
||||
@@ -120,7 +120,6 @@
|
||||
"currentPlan.seeAllFeaturesAndComparePlans": "查看所有功能并比较套餐",
|
||||
"currentPlan.title": "当前套餐",
|
||||
"discount.add": "添加",
|
||||
"discount.limitedTime": "限时",
|
||||
"discount.maxOff": "最高优惠 {{percent}}%",
|
||||
"discount.off": "优惠 {{percent}}%",
|
||||
"discount.save": "节省",
|
||||
@@ -144,10 +143,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 以继续使用。",
|
||||
@@ -321,7 +316,6 @@
|
||||
"plans.support.ultimate": "优先聊天与邮件支持",
|
||||
"plans.target": "目标计划",
|
||||
"plans.unlimited": "无限制",
|
||||
"promoBanner.fableYearly": "年付订阅用户限时享 {{percent}}% 用量优惠",
|
||||
"qa.desc": "如果您的问题未被解答,请查看 <1>产品文档</1> 获取更多常见问题,或联系我们。",
|
||||
"qa.detail": "查看详情",
|
||||
"qa.list.credit.a": "积分是 {{cloud}} 用于衡量 AI 模型调用用量的指标。不同模型消耗的积分不同。",
|
||||
@@ -359,7 +353,7 @@
|
||||
"referral.edit.placeholder": "请输入推荐码",
|
||||
"referral.edit.save": "保存",
|
||||
"referral.errors.alreadyBound": "你已经绑定过邀请码",
|
||||
"referral.errors.backfillExpired": "补填期限已过,注册超过七天后无法补填",
|
||||
"referral.errors.backfillExpired": "补填期限已过,注册超过三天后无法补填",
|
||||
"referral.errors.codeExists": "该推荐码已被使用,请更换",
|
||||
"referral.errors.invalidCode": "邀请码不存在,请检查后重试",
|
||||
"referral.errors.invalidFormat": "推荐码格式无效,请输入 2-8 位字母、数字或下划线",
|
||||
@@ -367,12 +361,12 @@
|
||||
"referral.errors.updateFailed": "更新失败,请稍后重试",
|
||||
"referral.inviteCode.description": "分享您的专属推荐码,邀请好友注册",
|
||||
"referral.inviteCode.title": "我的推荐码",
|
||||
"referral.inviteLink.description": "复制链接并分享给好友,好友付费后双方均可获得奖励",
|
||||
"referral.inviteLink.description": "复制链接并分享给好友,完成注册即可获得奖励",
|
||||
"referral.inviteLink.title": "推荐链接",
|
||||
"referral.rules.antiAbuse": "如检测到通过不正当手段获取积分(如批量注册临时邮箱账号),相关账号将被永久封禁",
|
||||
"referral.rules.backfill.alreadyBound": "你已经绑定过邀请码",
|
||||
"referral.rules.backfill.description": "忘记填写邀请码?注册七天内可以补填",
|
||||
"referral.rules.backfill.expiredTip": "补填期限已过,注册超过七天后无法补填",
|
||||
"referral.rules.backfill.description": "忘记填写邀请码?注册三天内可以补填",
|
||||
"referral.rules.backfill.expiredTip": "补填期限已过,注册超过三天后无法补填",
|
||||
"referral.rules.backfill.link": "补填邀请码",
|
||||
"referral.rules.backfill.placeholder": "请输入邀请码或链接",
|
||||
"referral.rules.backfill.submit": "确认绑定",
|
||||
@@ -380,13 +374,13 @@
|
||||
"referral.rules.backfill.title": "补填邀请码",
|
||||
"referral.rules.description": "了解推荐奖励计划规则",
|
||||
"referral.rules.expiry": "积分有效期:用户 100 天未活跃后,返利积分将被清除",
|
||||
"referral.rules.missedCode": "忘记填写邀请码:注册七天内可以<0>补填邀请码</0>,补填后需完成一次有效操作并完成付费才可获得奖励",
|
||||
"referral.rules.missedCode": "忘记填写邀请码:注册三天内可以<0>补填邀请码</0>,补填后需完成一次有效操作才可获得奖励",
|
||||
"referral.rules.priority": "积分使用优先级:免费积分 → 订阅积分 → 返利积分 → 充值积分",
|
||||
"referral.rules.registration": "注册方式:被邀请用户通过推荐链接注册或在注册页输入推荐码",
|
||||
"referral.rules.reward": "奖励:邀请人和被邀请人各获得 {{reward}}M 积分",
|
||||
"referral.rules.rewardDelay": "奖励处理:被邀请人付费且审核通过后,积分将在 1 小时内发放",
|
||||
"referral.rules.rewardDelay": "奖励处理:积分将在审核通过后发放,审核最多需要 6 小时",
|
||||
"referral.rules.title": "计划规则",
|
||||
"referral.rules.validInvitation": "有效邀请:被邀请人使用您的推荐码注册、完成一次有效操作,并完成付费(订阅或积分充值)",
|
||||
"referral.rules.validInvitation": "有效邀请:被邀请人使用您的推荐码注册并完成一次有效操作",
|
||||
"referral.rules.validOperation": "有效操作标准:在对话页发送一条消息,或在图片页生成一张图片",
|
||||
"referral.stats.availableBalance": "可用余额",
|
||||
"referral.stats.description": "查看您的推荐统计数据",
|
||||
@@ -399,7 +393,7 @@
|
||||
"referral.table.columns.inviterRewardAmount": "我的奖励",
|
||||
"referral.table.columns.rewardedAt": "奖励时间",
|
||||
"referral.table.columns.status": "状态",
|
||||
"referral.table.status.pending_reward": "待发放",
|
||||
"referral.table.status.pending_reward": "审核中",
|
||||
"referral.table.status.registered": "已注册",
|
||||
"referral.table.status.revoked": "已撤销",
|
||||
"referral.table.status.rewarded": "已奖励",
|
||||
|
||||
@@ -26,7 +26,6 @@
|
||||
"displayItems": "显示条目",
|
||||
"duplicateLoading": "话题复制中…",
|
||||
"duplicateSuccess": "话题复制成功",
|
||||
"failedStatusTip": "当前执行遇到了错误,点击查看详情",
|
||||
"favorite": "收藏",
|
||||
"filter.filter": "筛选",
|
||||
"filter.groupMode.byProject": "按项目",
|
||||
@@ -44,7 +43,6 @@
|
||||
"groupTitle.byStatus.completed": "已完成",
|
||||
"groupTitle.byStatus.failed": "已失败",
|
||||
"groupTitle.byStatus.paused": "已暂停",
|
||||
"groupTitle.byStatus.pending": "待处理",
|
||||
"groupTitle.byStatus.running": "进行中",
|
||||
"groupTitle.byStatus.waitingForHuman": "等待处理",
|
||||
"groupTitle.byTime.month": "本月",
|
||||
|
||||
@@ -846,7 +846,7 @@
|
||||
"upload.preview.status.pending": "準備上傳...",
|
||||
"upload.preview.status.processing": "檔案處理中...",
|
||||
"upload.validation.unsupportedFileType": "不支援的檔案類型:{{files}}。支援的圖片格式:JPG、PNG、GIF、WebP。支援的文件包括 PDF、Word、Excel、PowerPoint、Markdown、文字檔、CSV、JSON 和程式碼檔案。",
|
||||
"upload.validation.videoSizeExceeded": "影片檔案大小不能超過 {{maxSize}},當前檔案大小為 {{actualSize}}",
|
||||
"upload.validation.videoSizeExceeded": "影片檔案大小不能超過 20MB,當前檔案大小為 {{actualSize}}",
|
||||
"viewMode.fullWidth": "全寬顯示",
|
||||
"viewMode.normal": "一般",
|
||||
"viewMode.wideScreen": "寬螢幕",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user