mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-15 20:16:02 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cdeec322bd |
@@ -1,209 +0,0 @@
|
||||
---
|
||||
name: agent-runtime-hooks
|
||||
description: "Agent runtime lifecycle hooks for observing and intercepting agent execution. Use when adding hooks to agent operations, mocking tool calls, logging step events, handling human intervention, sub-agent calls, context compression, or building eval/tracing integrations. Triggers on 'hooks', 'beforeToolCall', 'afterToolCall', 'beforeStep', 'afterStep', 'onComplete', 'onError', 'tool mock', 'agent lifecycle', 'human intervention', 'callAgent', 'compact'."
|
||||
user-invocable: false
|
||||
---
|
||||
|
||||
# Agent Runtime Hooks
|
||||
|
||||
Lifecycle hooks for observing and intercepting agent execution. Hooks are registered per-operation via `execAgent({ hooks })` and dispatched by `HookDispatcher`.
|
||||
|
||||
## Hook Types
|
||||
|
||||
16 hook types across 5 categories:
|
||||
|
||||
```
|
||||
execAgent({ hooks })
|
||||
│
|
||||
├─ beforeStep ──────────── Before each step executes
|
||||
│ │
|
||||
│ ├─ [call_llm] LLM inference
|
||||
│ │
|
||||
│ ├─ [call_tool]
|
||||
│ │ ├─ beforeToolCall ── Before tool executes (supports mocking)
|
||||
│ │ ├─ (tool execution)
|
||||
│ │ ├─ afterToolCall ─── After tool completes (observation only)
|
||||
│ │ └─ onToolCallError ─ Tool threw an exception
|
||||
│ │
|
||||
│ ├─ [request_human_approve]
|
||||
│ │ ├─ beforeHumanIntervention ── Before agent pauses
|
||||
│ │ ├─ afterHumanIntervention ─── After approve/reject + resume
|
||||
│ │ └─ onStopByHumanIntervention ── User rejected, agent halted
|
||||
│ │
|
||||
│ ├─ [compress_context]
|
||||
│ │ ├─ beforeCompact ──── Before compression starts
|
||||
│ │ ├─ afterCompact ───── After compression completes
|
||||
│ │ └─ onCompactError ─── Compression failed
|
||||
│ │
|
||||
│ ├─ [callAgent] (via execSubAgentTask)
|
||||
│ │ ├─ beforeCallAgent ── Before sub-agent starts
|
||||
│ │ ├─ afterCallAgent ─── After sub-agent completes
|
||||
│ │ └─ onCallAgentError ── Sub-agent failed
|
||||
│ │
|
||||
│ └─ afterStep ──────────── After step completes
|
||||
│
|
||||
├─ (next step...)
|
||||
│
|
||||
├─ onComplete ───────────── Operation reaches terminal state
|
||||
└─ onError ──────────────── Error during execution
|
||||
```
|
||||
|
||||
## Key Files
|
||||
|
||||
| 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
|
||||
|
||||
```ts
|
||||
const hooks: AgentHook[] = [
|
||||
{ id: 'my-hook', type: 'afterStep', handler: async (event) => { ... } },
|
||||
];
|
||||
await aiAgentService.execAgent({ agentId, prompt, hooks });
|
||||
// Internally: hookDispatcher.register(operationId, hooks)
|
||||
// Cleanup: hookDispatcher.unregister(operationId)
|
||||
```
|
||||
|
||||
## Hook Reference
|
||||
|
||||
### Step Level
|
||||
|
||||
**`beforeStep`** — Before each step. `event: AgentHookEvent`
|
||||
**`afterStep`** — After each step. `event: AgentHookEvent` (content, toolsCalling, totalCost, etc.)
|
||||
**`onComplete`** — Terminal state. `event: AgentHookEvent` (reason: done/error/interrupted/max_steps/cost_limit)
|
||||
**`onError`** — Error occurred. `event: AgentHookEvent` (errorMessage, errorDetail)
|
||||
|
||||
### Tool Call Level
|
||||
|
||||
**`beforeToolCall`** — Before tool executes. **Supports mocking** via `event.mock()`.
|
||||
|
||||
```ts
|
||||
// event: ToolCallHookEvent
|
||||
{
|
||||
(identifier, apiName, args, callIndex, stepIndex, operationId, mock);
|
||||
}
|
||||
// Mock example:
|
||||
event.mock({ content: '{"error":"rate limited"}' });
|
||||
```
|
||||
|
||||
Dispatch method: `hookDispatcher.dispatchBeforeToolCall()` (returns mock result or null).
|
||||
|
||||
**`afterToolCall`** — After tool completes. Observation only.
|
||||
|
||||
```ts
|
||||
// event: AfterToolCallHookEvent
|
||||
{
|
||||
(identifier, apiName, args, callIndex, content, success, mocked, executionTimeMs, stepIndex);
|
||||
}
|
||||
```
|
||||
|
||||
**`onToolCallError`** — Tool threw an exception (catch block, not just `success=false`).
|
||||
|
||||
```ts
|
||||
// event: ToolCallErrorHookEvent
|
||||
{
|
||||
(identifier, apiName, args, callIndex, error, stepIndex);
|
||||
}
|
||||
```
|
||||
|
||||
### Human Intervention
|
||||
|
||||
**`beforeHumanIntervention`** — Before agent pauses for approval.
|
||||
|
||||
```ts
|
||||
// event: BeforeHumanInterventionHookEvent
|
||||
{ operationId, stepIndex, pendingTools: [{ identifier, apiName }] }
|
||||
```
|
||||
|
||||
**`afterHumanIntervention`** — After approve/reject, agent resumes.
|
||||
|
||||
```ts
|
||||
// event: AfterHumanInterventionHookEvent
|
||||
{ operationId, action: 'approve' | 'reject' | 'rejectAndContinue', toolCallId?, rejectionReason? }
|
||||
```
|
||||
|
||||
**`onStopByHumanIntervention`** — User rejected, agent halted.
|
||||
|
||||
```ts
|
||||
// event: StopByHumanInterventionHookEvent
|
||||
{ operationId, toolCallId?, rejectionReason? }
|
||||
```
|
||||
|
||||
### Context Compression
|
||||
|
||||
**`beforeCompact`** — Before compression starts.
|
||||
|
||||
```ts
|
||||
// event: BeforeCompactHookEvent
|
||||
{
|
||||
(operationId, stepIndex, messageCount, tokenCount);
|
||||
}
|
||||
```
|
||||
|
||||
**`afterCompact`** — After compression completes.
|
||||
|
||||
```ts
|
||||
// event: AfterCompactHookEvent
|
||||
{
|
||||
(operationId, stepIndex, groupId, messagesBefore, messagesAfter, summary);
|
||||
}
|
||||
```
|
||||
|
||||
**`onCompactError`** — Compression failed.
|
||||
|
||||
```ts
|
||||
// event: CompactErrorHookEvent
|
||||
{
|
||||
(operationId, stepIndex, tokenCount, error);
|
||||
}
|
||||
```
|
||||
|
||||
### Sub-Agent (CallAgent)
|
||||
|
||||
**`beforeCallAgent`** — Before calling sub-agent. Dispatched on **parent** operation.
|
||||
|
||||
```ts
|
||||
// event: BeforeCallAgentHookEvent
|
||||
{
|
||||
(operationId, agentId, instruction);
|
||||
}
|
||||
```
|
||||
|
||||
**`afterCallAgent`** — Sub-agent completed. Dispatched on **parent** operation.
|
||||
|
||||
```ts
|
||||
// event: AfterCallAgentHookEvent
|
||||
{
|
||||
(operationId, agentId, subOperationId, threadId, success);
|
||||
}
|
||||
```
|
||||
|
||||
**`onCallAgentError`** — Sub-agent failed. Dispatched on **parent** operation.
|
||||
|
||||
```ts
|
||||
// event: CallAgentErrorHookEvent
|
||||
{
|
||||
(operationId, agentId, error);
|
||||
}
|
||||
```
|
||||
|
||||
Note: CallAgent hooks require `parentOperationId` in `ExecSubAgentTaskParams`.
|
||||
|
||||
## Design Notes
|
||||
|
||||
- **Fire-and-forget**: All handlers return `Promise<void>`. Errors are non-fatal.
|
||||
- **Exception**: `beforeToolCall` supports mock via `event.mock()` — uses `dispatchBeforeToolCall()` which returns the mock result.
|
||||
- **Sequential**: Same-type hooks run in registration order.
|
||||
- **Local only**: `beforeToolCall` mock only works in local mode (in-memory hooks). Webhook mode does not support mocking.
|
||||
- **Scoped per operation**: Auto-cleaned via `hookDispatcher.unregister()` on completion.
|
||||
- **Sandbox/MCP**: No separate hooks — they go through `executeTool`, so `beforeToolCall`/`afterToolCall` cover them. Use `event.identifier` to filter.
|
||||
|
||||
## Real-World Example: agent-evals
|
||||
|
||||
See `devtools/agent-evals/helpers/runner.ts` — `createEvalHooks()` uses `afterStep`, `onComplete`, `afterToolCall`, and `beforeToolCall` (for mock).
|
||||
@@ -1,95 +0,0 @@
|
||||
---
|
||||
name: agent-signal
|
||||
description: Build or extend LobeHub Agent Signal pipelines for background or quiet agent work driven by event sources, semantic signals, and action handlers. Use when adding a new Agent Signal source, signal or action type, policy, middleware handler, workflow handoff, dedupe or scope behavior, or observability around `src/server/services/agentSignal/**`, `packages/agent-signal`, or `packages/observability-otel/src/modules/agent-signal`.
|
||||
---
|
||||
|
||||
# Agent Signal
|
||||
|
||||
Use this skill to implement event-driven background work for agents without coupling the work to the foreground chat request.
|
||||
|
||||
Agent Signal has one consistent shape:
|
||||
|
||||
`source event` -> `signal interpretation` -> `action execution` -> built-in result signals
|
||||
|
||||
## Start Here
|
||||
|
||||
1. Read `references/architecture.md` to map the package boundary, runtime queue, scope model, and async workflow handoff.
|
||||
2. Read `references/handlers.md` before writing any new policy, source handler, signal handler, or action handler.
|
||||
3. Read `references/observability.md` when you need tracing, metrics, debugging, or workflow snapshot visibility.
|
||||
|
||||
## Use The Right Entry Point
|
||||
|
||||
- Use `emitAgentSignalSourceEvent(...)` when a server-owned producer should execute the pipeline immediately.
|
||||
- Use `executeAgentSignalSourceEvent(...)` when a worker or controlled backend path already owns execution timing and may inject a runtime guard backend.
|
||||
- Use `enqueueAgentSignalSourceEvent(...)` when the caller should return quickly and let Upstash Workflow process the event out-of-band.
|
||||
- Use `emitAgentSignalSourceEventWithStore(...)` for isolated tests or evals that should avoid ambient Redis state.
|
||||
|
||||
Read:
|
||||
|
||||
- `src/server/services/agentSignal/index.ts`
|
||||
- `src/server/workflows/agentSignal/index.ts`
|
||||
- `src/server/workflows/agentSignal/run.ts`
|
||||
|
||||
## Core Model
|
||||
|
||||
- `source`: A normalized fact that happened. Sources come from producers such as runtime lifecycle events, user messages, or bot ingress.
|
||||
- `signal`: A semantic interpretation derived from one source or from another signal. Signals express meaning, routing, or policy state.
|
||||
- `action`: A concrete side effect planned from one signal. Actions do the work.
|
||||
- `policy`: An installable middleware bundle that registers source, signal, and action handlers.
|
||||
- `procedure`: Not a distinct runtime node. Treat "procedure" as the end-to-end flow for one use case: ingress source, matching handlers, planned actions, execution result, and observability.
|
||||
|
||||
Keep the boundaries strict:
|
||||
|
||||
- Add a new `source` when the outside world produced a new event.
|
||||
- Add a new `signal` when the system needs a reusable semantic interpretation.
|
||||
- Add a new `action` when the runtime needs a concrete side effect.
|
||||
- Add or update a `policy` when you are wiring those pieces together.
|
||||
|
||||
## Implementation Workflow
|
||||
|
||||
1. Decide whether the use case is synchronous or quiet background work.
|
||||
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 `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.
|
||||
|
||||
## Default Reading Set
|
||||
|
||||
- Shared semantic core:
|
||||
`packages/agent-signal/src/index.ts`
|
||||
`packages/agent-signal/src/base/builders.ts`
|
||||
`packages/agent-signal/src/base/types.ts`
|
||||
- Server-owned runtime and middleware:
|
||||
`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:
|
||||
`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:
|
||||
`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
|
||||
|
||||
- Reuse existing source, signal, and action types before adding new ones.
|
||||
- Keep source handlers focused on interpretation and fan-out, not heavy side effects.
|
||||
- Keep action handlers responsible for side effects, idempotency, and executor-style result reporting.
|
||||
- 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 `src/server/services/agentSignal/**/__tests__` are the reference pattern.
|
||||
|
||||
## References
|
||||
|
||||
- Architecture and boundaries: `references/architecture.md`
|
||||
- Writing handlers and policies: `references/handlers.md`
|
||||
- Observability, metrics, and debugging: `references/observability.md`
|
||||
@@ -1,4 +0,0 @@
|
||||
interface:
|
||||
display_name: 'Agent Signal'
|
||||
short_description: 'Build AgentSignal sources, signals, actions, and policies.'
|
||||
default_prompt: 'Use $agent-signal to add a new Agent Signal source, policy, handler, or observability flow.'
|
||||
@@ -1,199 +0,0 @@
|
||||
# Agent Signal Architecture
|
||||
|
||||
## Pipeline
|
||||
|
||||
Use this mental model first:
|
||||
|
||||
```text
|
||||
producer
|
||||
-> emitAgentSignalSourceEvent(...) or enqueueAgentSignalSourceEvent(...)
|
||||
-> emitSourceEvent(...)
|
||||
-> dedupe + scope lock + source normalization
|
||||
-> runtime.emitNormalized(source)
|
||||
-> source handlers
|
||||
-> signal handlers
|
||||
-> action handlers
|
||||
-> built-in result signals
|
||||
-> observability projection + persistence
|
||||
```
|
||||
|
||||
The scheduler is queue-driven, not hard-coded for one policy:
|
||||
|
||||
```text
|
||||
source node
|
||||
-> matching source handlers
|
||||
-> dispatch signals/actions
|
||||
-> matching signal handlers
|
||||
-> dispatch more signals/actions
|
||||
-> matching action handlers
|
||||
-> ExecutorResult
|
||||
-> signal.action.applied | signal.action.skipped | signal.action.failed
|
||||
```
|
||||
|
||||
Read:
|
||||
|
||||
- `src/server/services/agentSignal/index.ts`
|
||||
- `src/server/services/agentSignal/sources/index.ts`
|
||||
- `src/server/services/agentSignal/runtime/AgentSignalScheduler.ts`
|
||||
|
||||
## Package Boundaries
|
||||
|
||||
### `packages/agent-signal`
|
||||
|
||||
Treat this as the shared semantic core.
|
||||
|
||||
It provides:
|
||||
|
||||
- base node types: source, signal, action
|
||||
- builders: `createSource`, `createSignal`, `createAction`
|
||||
- built-in result signal types
|
||||
- runtime result contracts such as `RuntimeProcessorResult` and `ExecutorResult`
|
||||
|
||||
Read:
|
||||
|
||||
- `packages/agent-signal/src/base/types.ts`
|
||||
- `packages/agent-signal/src/base/builders.ts`
|
||||
- `packages/agent-signal/src/types/events.ts`
|
||||
- `packages/agent-signal/src/types/builtin.ts`
|
||||
|
||||
### `src/server/services/agentSignal`
|
||||
|
||||
Treat this as the server-owned implementation layer.
|
||||
|
||||
It owns:
|
||||
|
||||
- source catalogs and payload maps
|
||||
- policy-specific signal and action catalogs
|
||||
- middleware registration
|
||||
- runtime scheduling and guard backends
|
||||
- Redis-backed dedupe, waypoint, and policy state
|
||||
- service entrypoints for synchronous and async execution
|
||||
|
||||
### `packages/observability-otel/src/modules/agent-signal`
|
||||
|
||||
Treat this as shared OTEL ownership for Agent Signal metrics and tracer instances.
|
||||
|
||||
## Core Vocabulary
|
||||
|
||||
### Source
|
||||
|
||||
A source is the normalized external fact that started the chain.
|
||||
|
||||
Examples:
|
||||
|
||||
- `agent.user.message`
|
||||
- `runtime.before_step`
|
||||
- `runtime.after_step`
|
||||
- `client.runtime.start`
|
||||
- `bot.message.merged`
|
||||
|
||||
Define source payloads in:
|
||||
|
||||
- `src/server/services/agentSignal/sourceTypes.ts`
|
||||
|
||||
Build normalized sources in:
|
||||
|
||||
- `src/server/services/agentSignal/sources/buildSource.ts`
|
||||
- `packages/agent-signal/src/base/builders.ts`
|
||||
|
||||
### Signal
|
||||
|
||||
A signal is a semantic interpretation. Signals should be reusable and meaning-oriented.
|
||||
|
||||
Examples from `analyzeIntent`:
|
||||
|
||||
- `signal.feedback.satisfaction`
|
||||
- `signal.feedback.domain.memory`
|
||||
- `signal.feedback.domain.prompt`
|
||||
- `signal.feedback.domain.skill`
|
||||
|
||||
Define server-owned signal types in:
|
||||
|
||||
- `src/server/services/agentSignal/policies/types.ts`
|
||||
|
||||
### Action
|
||||
|
||||
An action is a concrete side effect the runtime should execute.
|
||||
|
||||
Example:
|
||||
|
||||
- `action.user-memory.handle`
|
||||
|
||||
Action handlers usually:
|
||||
|
||||
- check idempotency
|
||||
- call tools, models, or services
|
||||
- return `ExecutorResult`
|
||||
|
||||
### Policy
|
||||
|
||||
A policy is an installable bundle of handlers. It is the composition unit that turns the generic runtime into a feature.
|
||||
|
||||
Example:
|
||||
|
||||
- `createAnalyzeIntentPolicy(...)`
|
||||
|
||||
### Procedure
|
||||
|
||||
"Procedure" is not a first-class type in this runtime. Use the word to describe one end-to-end use case:
|
||||
|
||||
1. define ingress source
|
||||
2. emit or enqueue the source
|
||||
3. interpret source into signals
|
||||
4. plan actions from signals
|
||||
5. execute actions
|
||||
6. persist trace and metrics
|
||||
|
||||
When a user asks for "the procedure", document the flow above and point to the exact producer, handlers, and execution entrypoint.
|
||||
|
||||
## Scope, Deduping, And Quiet Background Work
|
||||
|
||||
`scopeKey` is the serialization boundary for related work. It is used for:
|
||||
|
||||
- source dedupe windows
|
||||
- scope locks during source generation
|
||||
- runtime guard state
|
||||
- waypoint persistence for queued processing
|
||||
|
||||
Read:
|
||||
|
||||
- `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:
|
||||
|
||||
1. normalizes the source envelope
|
||||
2. derives or reuses `scopeKey`
|
||||
3. triggers `AgentSignalWorkflow`
|
||||
4. executes later in `runAgentSignalWorkflow`
|
||||
|
||||
This is the preferred path when the UI request should finish immediately and the policy can run in the background.
|
||||
|
||||
Read:
|
||||
|
||||
- `src/server/workflows/agentSignal/index.ts`
|
||||
- `src/server/workflows/agentSignal/run.ts`
|
||||
|
||||
## Existing Example: `analyzeIntent`
|
||||
|
||||
Use `analyzeIntent` as the reference chain:
|
||||
|
||||
```text
|
||||
agent.user.message
|
||||
-> feedback satisfaction source handler
|
||||
-> signal.feedback.satisfaction
|
||||
-> feedback domain signal handler
|
||||
-> signal.feedback.domain.*
|
||||
-> feedback action planner
|
||||
-> action.user-memory.handle
|
||||
-> signal.action.applied | skipped | failed
|
||||
```
|
||||
|
||||
Read:
|
||||
|
||||
- `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`
|
||||
@@ -1,228 +0,0 @@
|
||||
# Writing Handlers And Policies
|
||||
|
||||
## Fluent Registration API
|
||||
|
||||
Use the middleware helpers in `src/server/services/agentSignal/runtime/middleware.ts`.
|
||||
|
||||
They provide:
|
||||
|
||||
- `defineSourceHandler(...)`
|
||||
- `defineSignalHandler(...)`
|
||||
- `defineActionHandler(...)`
|
||||
- `defineAgentSignalHandlers(...)`
|
||||
|
||||
These helpers do two jobs:
|
||||
|
||||
1. keep handler registration terse
|
||||
2. preserve strong typing when `listen` points at concrete source, signal, or action types
|
||||
|
||||
## Handler Shape
|
||||
|
||||
Each handler receives:
|
||||
|
||||
- the current runtime node
|
||||
- `RuntimeProcessorContext`
|
||||
|
||||
The context gives you:
|
||||
|
||||
- `scopeKey`
|
||||
- `now()`
|
||||
- `runtimeState.getGuardState(lane)`
|
||||
- `runtimeState.touchGuardState(lane, now?)`
|
||||
|
||||
Read:
|
||||
|
||||
- `src/server/services/agentSignal/runtime/context.ts`
|
||||
|
||||
## Return Contracts
|
||||
|
||||
Return one of these shapes:
|
||||
|
||||
- `void`: no fan-out, stop at this handler
|
||||
- `{ status: 'dispatch', signals?, actions? }`: continue the chain
|
||||
- `{ status: 'wait', pending? }`: pause for later host coordination
|
||||
- `{ status: 'schedule', nextHop }`: schedule another hop
|
||||
- `{ status: 'conclude', concluded? }`: stop with a terminal runtime result
|
||||
- `ExecutorResult`: only for action handlers that performed a concrete side effect
|
||||
|
||||
Read:
|
||||
|
||||
- `packages/agent-signal/src/base/types.ts`
|
||||
- `src/server/services/agentSignal/runtime/AgentSignalScheduler.ts`
|
||||
|
||||
## Policy Composition Pattern
|
||||
|
||||
Use `defineAgentSignalHandlers([...])` to bundle related handlers into one policy.
|
||||
|
||||
Example from `analyzeIntent`:
|
||||
|
||||
```ts
|
||||
return defineAgentSignalHandlers([
|
||||
createFeedbackSatisfactionJudgeProcessor(...),
|
||||
createFeedbackDomainJudgeSignalHandler(...),
|
||||
createFeedbackActionPlannerSignalHandler(),
|
||||
defineUserMemoryActionHandler(...),
|
||||
]);
|
||||
```
|
||||
|
||||
That bundle is later passed into the runtime via:
|
||||
|
||||
- `createDefaultAgentSignalPolicies(...)`
|
||||
- `createAgentSignalRuntime({ policies })`
|
||||
|
||||
Read:
|
||||
|
||||
- `src/server/services/agentSignal/policies/index.ts`
|
||||
- `src/server/services/agentSignal/policies/analyzeIntent/index.ts`
|
||||
|
||||
## Source Handler Pattern
|
||||
|
||||
Use a source handler when you are interpreting a producer event into semantic signals.
|
||||
|
||||
Reference:
|
||||
|
||||
- `src/server/services/agentSignal/policies/analyzeIntent/feedbackSatisfaction.ts`
|
||||
|
||||
Pattern:
|
||||
|
||||
```ts
|
||||
return defineSourceHandler(
|
||||
AGENT_SIGNAL_SOURCE_TYPES.agentUserMessage,
|
||||
'agent.user.message:my-handler',
|
||||
async (source, ctx): Promise<RuntimeProcessorResult | void> => {
|
||||
// interpret source payload
|
||||
// optionally use ctx.runtimeState
|
||||
|
||||
return {
|
||||
signals: [
|
||||
/* one or more semantic signals */
|
||||
],
|
||||
status: 'dispatch',
|
||||
};
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
Write source handlers when:
|
||||
|
||||
- a raw message, lifecycle event, or bot ingress needs interpretation
|
||||
- the work is still semantic, not side-effectful
|
||||
|
||||
## Signal Handler Pattern
|
||||
|
||||
Use a signal handler when one semantic state should branch into more semantic states or planned actions.
|
||||
|
||||
References:
|
||||
|
||||
- `src/server/services/agentSignal/policies/analyzeIntent/feedbackDomain.ts`
|
||||
- `src/server/services/agentSignal/policies/analyzeIntent/feedbackAction.ts`
|
||||
|
||||
Pattern:
|
||||
|
||||
```ts
|
||||
return defineSignalHandler(
|
||||
MY_SIGNAL_TYPE,
|
||||
'signal.my-policy-router',
|
||||
async (signal): Promise<RuntimeProcessorResult | void> => {
|
||||
return {
|
||||
actions: [
|
||||
/* planned work */
|
||||
],
|
||||
status: 'dispatch',
|
||||
};
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
Use signal handlers for:
|
||||
|
||||
- routing
|
||||
- fan-out
|
||||
- filtering
|
||||
- conflict resolution
|
||||
- converting interpretation into planned actions
|
||||
|
||||
## Action Handler Pattern
|
||||
|
||||
Use an action handler when the runtime should do actual work.
|
||||
|
||||
Reference:
|
||||
|
||||
- `src/server/services/agentSignal/policies/analyzeIntent/actions/userMemory.ts`
|
||||
|
||||
Pattern:
|
||||
|
||||
```ts
|
||||
return defineActionHandler(
|
||||
MY_ACTION_TYPE,
|
||||
'action.my-policy-executor',
|
||||
async (action, ctx): Promise<ExecutorResult> => {
|
||||
// run service/tool/model side effect
|
||||
// check idempotency if needed
|
||||
|
||||
return {
|
||||
actionId: action.actionId,
|
||||
attempt: {
|
||||
completedAt: ctx.now(),
|
||||
current: 1,
|
||||
startedAt,
|
||||
status: 'succeeded',
|
||||
},
|
||||
status: 'applied',
|
||||
};
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
Keep these rules:
|
||||
|
||||
- perform idempotency checks here or immediately before side effects
|
||||
- return stable `actionId`
|
||||
- include failure detail in `error`
|
||||
- let the scheduler turn the `ExecutorResult` into built-in result signals
|
||||
|
||||
## Source, Signal, And Action Type Placement
|
||||
|
||||
Use this split:
|
||||
|
||||
- external event payloads:
|
||||
`src/server/services/agentSignal/sourceTypes.ts`
|
||||
- policy-owned signal and action payloads:
|
||||
`src/server/services/agentSignal/policies/types.ts`
|
||||
- normalized shared node contracts:
|
||||
`packages/agent-signal/src/base/types.ts`
|
||||
|
||||
Do not put app-specific signal catalogs into `packages/agent-signal`. That package should stay generic and reusable.
|
||||
|
||||
## Choosing The Right Node
|
||||
|
||||
Choose `source` when:
|
||||
|
||||
- the outside world emitted a new fact
|
||||
|
||||
Choose `signal` when:
|
||||
|
||||
- the system needs semantic meaning that downstream handlers can reuse
|
||||
|
||||
Choose `action` when:
|
||||
|
||||
- the runtime is ready for a concrete side effect
|
||||
|
||||
If a handler both interprets meaning and performs side effects, split it. That keeps chains inspectable and testable.
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
Prefer focused tests near the touched code.
|
||||
|
||||
Useful references:
|
||||
|
||||
- `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:
|
||||
|
||||
- handler unit test for one routing rule
|
||||
- runtime test for queue fan-out
|
||||
- integration test for service ingress and observability persistence
|
||||
@@ -1,118 +0,0 @@
|
||||
# Observability And Debugging
|
||||
|
||||
## OTEL Ownership
|
||||
|
||||
Use `packages/observability-otel/src/modules/agent-signal/index.ts` for the shared tracer and metrics.
|
||||
|
||||
Available instruments:
|
||||
|
||||
- `tracer`
|
||||
- `sourceCounter`
|
||||
- `signalCounter`
|
||||
- `actionCounter`
|
||||
- `actionResultCounter`
|
||||
- `chainCounter`
|
||||
- `signalActionTransitionCounter`
|
||||
- `chainDurationHistogram`
|
||||
- `actionDurationHistogram`
|
||||
|
||||
Use this module when you need shared telemetry ownership instead of creating feature-local meters or tracers.
|
||||
|
||||
## Projection Pipeline
|
||||
|
||||
After runtime execution, the service projects one compact observability model from the full chain.
|
||||
|
||||
Read:
|
||||
|
||||
- `src/server/services/agentSignal/observability/projector.ts`
|
||||
- `src/server/services/agentSignal/observability/traceEvents.ts`
|
||||
- `src/server/services/agentSignal/observability/store.ts`
|
||||
|
||||
Projection outputs:
|
||||
|
||||
- a trace envelope with source, signals, actions, results, edges, and handler runs
|
||||
- a compact telemetry record with dominant path, status breakdown, and chain metadata
|
||||
|
||||
This projection is built from:
|
||||
|
||||
- source node
|
||||
- emitted signals
|
||||
- planned actions
|
||||
- executor results
|
||||
|
||||
## How To Inspect A Chain
|
||||
|
||||
Use this order:
|
||||
|
||||
1. Inspect the source type and payload.
|
||||
2. Inspect emitted signals.
|
||||
3. Inspect planned actions.
|
||||
4. Inspect executor results.
|
||||
5. Inspect projected edges and dominant path.
|
||||
|
||||
The helper `toAgentSignalTraceEvents(...)` flattens a chain into compact event records suitable for tracing snapshots.
|
||||
|
||||
## Workflow Snapshot Bridge
|
||||
|
||||
Workflow-triggered runs do not naturally pass through the normal foreground runtime snapshot path, so `runAgentSignalWorkflow` adds a development-only bridge into `.agent-tracing/`.
|
||||
|
||||
Read:
|
||||
|
||||
- `src/server/workflows/agentSignal/run.ts`
|
||||
|
||||
Use that path when:
|
||||
|
||||
- the source was enqueued with `enqueueAgentSignalSourceEvent(...)`
|
||||
- you need local trace visibility for quiet background work
|
||||
|
||||
## Common Debug Questions
|
||||
|
||||
### The source emits but nothing happens
|
||||
|
||||
Check:
|
||||
|
||||
- feature gate enabled for the user
|
||||
- source type matches a registered source handler
|
||||
- dedupe or scope lock did not short-circuit generation
|
||||
|
||||
Read:
|
||||
|
||||
- `src/server/services/agentSignal/index.ts`
|
||||
- `src/server/services/agentSignal/sources/index.ts`
|
||||
|
||||
### The signal exists but no action runs
|
||||
|
||||
Check:
|
||||
|
||||
- the signal type has a registered signal handler
|
||||
- the signal handler returns `status: 'dispatch'`
|
||||
- the handler actually returned actions
|
||||
|
||||
### The action runs twice
|
||||
|
||||
Check:
|
||||
|
||||
- source dedupe key stability
|
||||
- action idempotency strategy
|
||||
- scope key stability across retries and workflow handoff
|
||||
|
||||
Reference:
|
||||
|
||||
- `src/server/services/agentSignal/policies/actionIdempotency.ts`
|
||||
- `src/server/services/agentSignal/policies/analyzeIntent/actions/userMemory.ts`
|
||||
|
||||
### Background runs are hard to discover
|
||||
|
||||
Check:
|
||||
|
||||
- workflow snapshot bridge in development
|
||||
- projected telemetry record contents
|
||||
- OTEL counters and histograms in the shared module
|
||||
|
||||
## Minimal Completion Checklist
|
||||
|
||||
- source ingress is testable
|
||||
- handler registration is discoverable from the policy factory
|
||||
- action executor returns structured results
|
||||
- projection includes the new path cleanly
|
||||
- tests cover at least one happy path and one no-op or failure path
|
||||
@@ -1,298 +0,0 @@
|
||||
---
|
||||
name: bot
|
||||
description: 'Bot platform architecture (Discord, Slack, Telegram, Feishu/Lark, QQ, WeChat). Use when working on inbound webhooks, Chat SDK message routing, agent execution from chat platforms, queue-mode callbacks, gateway lifecycle (websocket/polling), bot provider CRUD/credentials, or platform-specific clients/adapters/schemas. Triggers on bot, channel, webhook, mention, Chat SDK, agent bot provider, gateway, bot-callback, qstash bot.'
|
||||
---
|
||||
|
||||
# Bot System
|
||||
|
||||
> **Last updated: 2026-04-08.** Implementation evolves quickly — this doc is a map, not the source of truth. Always read the key files below to verify behavior, especially per-platform quirks. Update this doc when the architecture changes.
|
||||
|
||||
LobeChat agents can answer inside external chat platforms. Inbound messages flow through the Chat SDK (`chat` npm package), get routed to the right agent by `(platform, applicationId)`, executed via `AiAgentService`, and replied back through a per-platform `PlatformClient`. There are **two execution modes** (in-memory vs queue/QStash) and **three connection modes** (`webhook`, `websocket`, `polling`).
|
||||
|
||||
## Supported Platforms
|
||||
|
||||
| Platform | id | Default mode | Markdown | Edit | Notes |
|
||||
| -------- | ---------- | ------------------------------- | ----------------- | ------ | -------------------------------------------------------------------------------------- |
|
||||
| Discord | `discord` | `websocket` | yes | yes | Persistent gateway via Chat SDK adapter; reaction-thread quirks; native slash commands |
|
||||
| Slack | `slack` | `websocket` (Socket Mode) | yes (mrkdwn) | yes | Multi-mode — user can pick `webhook` per provider |
|
||||
| Telegram | `telegram` | `webhook` | yes (HTML) | yes | `setMyCommands` menu via `registerBotCommands` |
|
||||
| Feishu | `feishu` | `websocket` (Lark SDK WSClient) | **no** (stripped) | yes | Multi-mode; shared client with Lark |
|
||||
| Lark | `lark` | `websocket` | **no** | yes | Same client/schema as Feishu, different domain |
|
||||
| QQ | `qq` | `websocket` | **no** | **no** | All replies are final-only |
|
||||
| WeChat | `wechat` | `polling` (iLink long-poll) | **no** | **no** | 10-minute gateway window |
|
||||
|
||||
`supportsMarkdown=false` ⇒ outbound markdown is stripped to plain text via `stripMarkdown` and the AI is told not to use markdown. `supportsMessageEdit=false` ⇒ no progress edits — only the final reply is sent.
|
||||
|
||||
**Multi-mode connection** — Slack/Feishu/Lark/QQ ship as websocket but support `webhook` per-provider via `settings.connectionMode`. The runtime always merges schema defaults into stored settings before resolving the mode (`resolveBotProviderConfig` / `resolveConnectionMode` in `platforms/utils.ts`), so the schema's `field.default` is the source of truth — set it correctly when adding a new multi-mode platform.
|
||||
|
||||
## Inbound Flow (one webhook → reply)
|
||||
|
||||
```
|
||||
Platform server
|
||||
│ POST /api/agent/webhooks/[platform]/[appId]
|
||||
▼
|
||||
route.ts ── catch-all `[[...appId]]` route
|
||||
│
|
||||
▼
|
||||
BotMessageRouter (singleton)
|
||||
│ • lazy-loads bot per `platform:applicationId`
|
||||
│ • merges schema defaults + provider.settings (mergeWithDefaults)
|
||||
│ • builds Chat SDK Chat<any> with createIoRedisState (if Redis available)
|
||||
│ • registerHandlers: onNewMention / onSubscribedMessage / onNewMessage(/.dm)
|
||||
│ • registerCommands: /new (reset topic), /stop (interrupt)
|
||||
│
|
||||
▼
|
||||
chatBot.webhooks[platform](req) ← Chat SDK parses → fires events
|
||||
│
|
||||
▼
|
||||
AgentBridgeService.handleMention / handleSubscribedMessage
|
||||
│ • activeThreads guard (no duplicate runs per thread)
|
||||
│ • adds 👀 reaction (eyes), startTyping
|
||||
│ • merges debounced/queued skipped messages (mergeSkippedMessages)
|
||||
│ • extractFiles (buffer → fetchData → url)
|
||||
│ • formatPrompt (sanitize mention + speaker tag + referenced_message)
|
||||
│
|
||||
├── In-memory mode ──► AiAgentService.execAgent({ stepCallbacks })
|
||||
│ → onAfterStep edits progress message live
|
||||
│ → onComplete edits final reply, splits via splitMessage(charLimit)
|
||||
│
|
||||
└── Queue mode (isQueueAgentRuntimeEnabled) ──► execAgent({ stepWebhook, completionWebhook, webhookDelivery: 'qstash' })
|
||||
→ returns immediately, callbacks land at /api/agent/webhooks/bot-callback
|
||||
```
|
||||
|
||||
The router caches loaded bots in memory. Cache is **invalidated** by `BotMessageRouter.invalidateBot(platform, appId)` whenever the TRPC `update`/`delete` mutations run, so new credentials/settings take effect on the next webhook.
|
||||
|
||||
## Execution Modes
|
||||
|
||||
### In-memory (default)
|
||||
|
||||
`AgentBridgeService.executeWithInMemoryCallbacks` wraps `execAgent` with `stepCallbacks`. Lives in one process — Promise-based wait, 30-min timeout, edits the same `progressMessage` after every step. Topic title is summarized inline via `SystemAgentService`.
|
||||
|
||||
### Queue (`isQueueAgentRuntimeEnabled`)
|
||||
|
||||
`AgentBridgeService.executeWithWebhooks`:
|
||||
|
||||
1. Posts the `renderStart` placeholder, captures `progressMessageId`.
|
||||
2. Calls `execAgent` with `stepWebhook` and `completionWebhook` pointing at `${INTERNAL_APP_URL ?? APP_URL}/api/agent/webhooks/bot-callback`, plus `webhookDelivery: 'qstash'`.
|
||||
3. Returns immediately; the bridge `finally` block keeps the active-thread marker held until the `completion` callback fires.
|
||||
|
||||
`/api/agent/webhooks/bot-callback/route.ts` verifies the QStash signature and hands off to `BotCallbackService.handleCallback`:
|
||||
|
||||
- `type: 'step'` → `handleStep` re-renders `renderStepProgress`, edits `progressMessageId` (skipped if `displayToolCalls=false` or platform `supportsMessageEdit=false`).
|
||||
- `type: 'completion'` → `handleCompletion` writes the final reply (or error/interrupted message), removes the 👀 reaction, clears active-thread tracker, fires async `summarizeTopicTitle`.
|
||||
|
||||
`BotCallbackService.createMessenger` reloads provider + credentials from DB and rebuilds a `PlatformClient` per call (no in-memory state).
|
||||
|
||||
## Commands
|
||||
|
||||
Defined in `BotMessageRouter.buildCommands` and registered via two paths:
|
||||
|
||||
- **Native slash commands** (Slack/Discord): `bot.onSlashCommand('/<name>', ...)`
|
||||
- **Text-based fallback** (Telegram/Feishu/QQ/Lark/WeChat): `bot.onNewMessage(/^\/(new|stop)(\s|$|@)/, ...)` plus a per-mention `tryDispatch` so commands work even before subscribe.
|
||||
|
||||
Built-in commands:
|
||||
|
||||
- `/new` — clears `topicId` in thread state, next message starts a fresh topic.
|
||||
- `/stop` — interrupts the active execution (calls `AiAgentService.interruptTask` if `operationId` is known; otherwise queues a deferred stop via `requestStop`/`pendingStopThreads`, also aborts the startup phase via `startupControllers`).
|
||||
|
||||
To add a command, append to `buildCommands` — it auto-registers everywhere; on Telegram it also surfaces in the `/` menu via `client.registerBotCommands` → `setMyCommands`.
|
||||
|
||||
## Active-thread State (statics on `AgentBridgeService`)
|
||||
|
||||
- `activeThreads: Set<threadId>` — prevents duplicate runs per thread (must guard before stale-topic check, otherwise concurrent messages can drop).
|
||||
- `activeOperations: Map<threadId, operationId>` — needed by `/stop` once `execAgent` returns.
|
||||
- `startupControllers: Map<threadId, AbortController>` — cancels pre-`operationId` work (topic/tool prep).
|
||||
- `pendingStopThreads: Set<threadId>` — `/stop` arrived before `operationId` existed; consumed once available.
|
||||
|
||||
In **queue mode**, the bridge `finally` skips cleanup so the marker persists until `BotCallbackService.handleCompletion` calls `clearActiveThread`.
|
||||
|
||||
## Topic Lifecycle in Threads
|
||||
|
||||
- `handleMention` always treats the message as the start of a new conversation.
|
||||
- `handleSubscribedMessage` reads `topicId` from `thread.state`. If the topic is stale (`> 4 hours` since `updatedAt`), state is cleared and it retries as a fresh mention.
|
||||
- If `execAgent` fails with a Postgres FK violation on `topic_id` (cached topic was deleted), the bridge clears state and retries as a mention.
|
||||
- `subscribe()` is gated by `client.shouldSubscribe(threadId)` — Discord top-level channels return `false` so we don't follow up there.
|
||||
|
||||
## Attachments
|
||||
|
||||
`AgentBridgeService.extractFiles` resolves attachments in priority order:
|
||||
|
||||
1. `att.buffer` — already downloaded by the adapter (WeChat/Feishu inbound).
|
||||
2. `att.fetchData()` — adapter-provided lazy download with auth (Telegram, Slack, Feishu history). **Required** when URLs are token-protected — naive `fetch(url)` later in `ingestAttachment.ts` has no credentials.
|
||||
3. `att.url` — public CDN fallback (Discord, public QQ).
|
||||
|
||||
`inferMimeType` / `inferName` patch Telegram-style `photo` payloads (no `mimeType`/`name` from Bot API → defaults to `image/jpeg`) so vision models actually see them. Quoted-message attachments are also pulled from `raw.referenced_message.attachments` (Discord).
|
||||
|
||||
## Concurrency
|
||||
|
||||
`settings.concurrency` is `'queue'` or `'debounce'`:
|
||||
|
||||
- `debounce` → Chat SDK debounces inbound messages by `debounceMs`; `mergeSkippedMessages` joins skipped texts/attachments into the current message before handing to the agent.
|
||||
- `queue` → Chat SDK serializes per-thread; the bridge's own `activeThreads` set is still required because in queue mode the SDK lock releases before the agent finishes.
|
||||
|
||||
## Gateway (persistent platforms)
|
||||
|
||||
Webhook platforms run fine in serverless functions. Persistent platforms (`websocket`, `polling`) need a long-running listener — that's the **gateway**.
|
||||
|
||||
**`GatewayService.startClient(platform, appId, userId)`** (`src/server/services/gateway/index.ts`):
|
||||
|
||||
- On Vercel + persistent mode → `BotConnectQueue.push` (Redis hash) and mark runtime status `queued`. The cron picks it up.
|
||||
- On Vercel + webhook mode → start the client inline (one HTTP call).
|
||||
- Off-Vercel → `GatewayManager` singleton holds long-lived clients in process.
|
||||
|
||||
**`GET /api/agent/gateway/route.ts`** (cron, `Bearer ${CRON_SECRET}`):
|
||||
|
||||
- Iterates registered platforms and starts every enabled persistent provider with `durationMs = 10min`, then in `after(...)` polls `BotConnectQueue` every 30s for new connect requests, until the window expires.
|
||||
- `getEffectiveConnectionMode(platform, settings)` is the only place that resolves per-provider mode — respect it everywhere.
|
||||
|
||||
**`POST /api/agent/gateway/start/route.ts`** is the non-Vercel `ensureRunning` entry point (`Bearer ${KEY_VAULTS_SECRET}`).
|
||||
|
||||
**Runtime status** is stored in Redis at `bot:runtime-status:platform:appId` with TTL ≈ `durationMs + 60s`. States: `starting | connected | disconnected | failed | queued`. Updated by each `PlatformClient.start/stop` and by the gateway service.
|
||||
|
||||
## Platform Definitions
|
||||
|
||||
Each platform exposes a `PlatformDefinition` registered in `platforms/index.ts`:
|
||||
|
||||
```ts
|
||||
{
|
||||
id: 'discord',
|
||||
name: 'Discord',
|
||||
connectionMode: 'websocket', // recommended default
|
||||
schema: FieldSchema[], // applicationId + credentials + settings
|
||||
clientFactory: new DiscordClientFactory(),
|
||||
supportsMarkdown?: boolean, // default true
|
||||
supportsMessageEdit?: boolean, // default true
|
||||
documentation?: { portalUrl, setupGuideUrl },
|
||||
}
|
||||
```
|
||||
|
||||
`schema` drives both server validation (`mergeWithDefaults`, `extractDefaults`) **and** the auto-generated UI form. Top-level keys `applicationId` / `credentials` / `settings` map to DB columns. Common settings fields live in `platforms/const.ts` (`displayToolCallsField`, `makeServerIdField(platform?)`, `makeUserIdField(platform?)`). The `serverId` / `userId` factories take a platform identifier so the field's hint can render platform-specific "how to find this ID" guidance (Discord Developer Mode, Telegram @userinfobot, etc.); pass no argument to fall back to generic copy.
|
||||
|
||||
Each platform implements `PlatformClient` (see `platforms/types.ts`):
|
||||
|
||||
- Lifecycle: `start(opts?)`, `stop()`
|
||||
- Inbound: `createAdapter()` → Chat SDK adapter map
|
||||
- Outbound: `getMessenger(platformThreadId)` → `{ createMessage, editMessage, removeReaction, triggerTyping, updateThreadName? }`
|
||||
- Formatting: `formatMarkdown?`, `formatReply?` (usage-stats footer when `showUsageStats`)
|
||||
- Helpers: `extractChatId`, `parseMessageId`, `sanitizeUserInput`, `shouldSubscribe`, `resolveReactionThreadId`
|
||||
- Optional patches: `applyChatPatches(chatBot)` (Discord uses this for `forwardedInteractions` + `threadRecovery`)
|
||||
- Optional menu: `registerBotCommands(commands)` (Telegram `setMyCommands`)
|
||||
|
||||
`ClientFactory.validateCredentials` is called from the TRPC `testConnection` mutation — implement it to hit the platform API and return useful per-field errors.
|
||||
|
||||
## Database
|
||||
|
||||
**Schema** (`packages/database/src/schemas/agentBotProvider.ts`):
|
||||
|
||||
```ts
|
||||
agent_bot_providers (
|
||||
id uuid pk,
|
||||
agent_id text fk → agents.id (cascade),
|
||||
user_id text fk → users.id (cascade),
|
||||
platform varchar(50), // 'discord' | 'slack' | …
|
||||
application_id varchar(255),
|
||||
credentials text, // KeyVaults-encrypted JSON
|
||||
settings jsonb default '{}',
|
||||
enabled boolean default true,
|
||||
…timestamps
|
||||
)
|
||||
unique (platform, application_id)
|
||||
```
|
||||
|
||||
**Model** (`packages/database/src/models/agentBotProvider.ts`):
|
||||
|
||||
- User-scoped: `create / update / delete / query / findById / findByAgentId / findEnabledByApplicationId`. Credentials are encrypted/decrypted via the injected `KeyVaultsGateKeeper`.
|
||||
- Static (system-wide): `findByPlatformAndAppId`, `findEnabledByPlatform` — used by webhook routing & gateway sync, since they don't have a user context yet.
|
||||
|
||||
**TRPC router** (`src/server/routers/lambda/agentBotProvider.ts`):
|
||||
|
||||
| Procedure | Notes | |
|
||||
| -------------------------------------------- | ------------------------------------------------------------------------------------------- | ------------ |
|
||||
| `listPlatforms` | Returns `SerializedPlatformDefinition[]` (no `clientFactory`) | |
|
||||
| `create` / `update` / `delete` | Calls `BotMessageRouter.invalidateBot` + `GatewayService.stopClient` so changes take effect | |
|
||||
| `list` / `getByAgentId` / `getRuntimeStatus` | Decorate rows with Redis runtime status | |
|
||||
| `connectBot` | Returns \`{ status: 'started' | 'queued' }\` |
|
||||
| `testConnection` | Calls `clientFactory.validateCredentials` | |
|
||||
| `wechatGetQrCode` / `wechatPollQrStatus` | iLink onboarding flow | |
|
||||
|
||||
Client service: `src/services/agentBotProvider.ts`. Store actions: `src/store/agent/slices/bot/action.ts`. UI: `src/routes/(main)/agent/channel/{list,detail}` — settings form is auto-generated from each platform's `schema`.
|
||||
|
||||
## Reply Templates
|
||||
|
||||
`src/server/services/bot/replyTemplate.ts` exports `renderStart`, `renderStepProgress`, `renderFinalReply`, `renderError`, `renderStopped`, `splitMessage`. Step progress carries elapsed time, last LLM content, last tools, totals; final reply uses `client.formatMarkdown` then `client.formatReply` (which optionally appends `formatUsageStats`). `splitMessage(text, charLimit)` chunks at paragraph → line → hard cut.
|
||||
|
||||
`src/server/services/bot/ackPhrases/` provides randomized ack phrases.
|
||||
|
||||
## Key Files
|
||||
|
||||
```plaintext
|
||||
Webhook routes:
|
||||
src/app/(backend)/api/agent/webhooks/[platform]/[[...appId]]/route.ts — inbound catch-all
|
||||
src/app/(backend)/api/agent/webhooks/bot-callback/route.ts — qstash bot callback
|
||||
src/app/(backend)/api/agent/gateway/route.ts — cron gateway (10min window)
|
||||
src/app/(backend)/api/agent/gateway/start/route.ts — non-Vercel ensureRunning
|
||||
|
||||
Bot service:
|
||||
src/server/services/bot/index.ts — barrel
|
||||
src/server/services/bot/BotMessageRouter.ts — lazy bot loading + handler registration + commands
|
||||
src/server/services/bot/AgentBridgeService.ts — Chat SDK ↔ AiAgentService bridge, both exec modes
|
||||
src/server/services/bot/BotCallbackService.ts — qstash callback handler
|
||||
src/server/services/bot/formatPrompt.ts — speaker tag + referenced_message + sanitize
|
||||
src/server/services/bot/replyTemplate.ts — render*/splitMessage
|
||||
src/server/services/bot/ackPhrases/ — randomized acks
|
||||
src/server/services/bot/__tests__/ — unit tests for the above
|
||||
|
||||
Platform abstraction:
|
||||
src/server/services/bot/platforms/index.ts — registry singleton + exports
|
||||
src/server/services/bot/platforms/types.ts — PlatformClient/Definition/FieldSchema/ClientFactory
|
||||
src/server/services/bot/platforms/registry.ts — PlatformRegistry class
|
||||
src/server/services/bot/platforms/utils.ts — mergeWithDefaults, getEffectiveConnectionMode, formatUsageStats, runtimeKey
|
||||
src/server/services/bot/platforms/const.ts — shared FieldSchema fragments (displayToolCalls, serverId, userId)
|
||||
src/server/services/bot/platforms/stripMarkdown.ts — used by no-markdown platforms
|
||||
|
||||
Per-platform (each ships definition.ts, schema.ts, client.ts, const.ts, protocol-spec.md):
|
||||
src/server/services/bot/platforms/discord/ — websocket gateway + chat patches
|
||||
src/server/services/bot/platforms/slack/ — multi-mode (Socket Mode / webhook), markdownToMrkdwn
|
||||
src/server/services/bot/platforms/telegram/ — webhook, markdownToHTML, registerBotCommands
|
||||
src/server/services/bot/platforms/feishu/ — feishu + lark share client/schema (definitions/{feishu,lark,shared}.ts)
|
||||
src/server/services/bot/platforms/qq/ — websocket, no markdown, no edit
|
||||
src/server/services/bot/platforms/wechat/ — long-poll, no markdown, no edit
|
||||
|
||||
Gateway:
|
||||
src/server/services/gateway/index.ts — GatewayService (Vercel-aware startClient/stopClient)
|
||||
src/server/services/gateway/GatewayManager.ts — long-running client registry (non-Vercel)
|
||||
src/server/services/gateway/botConnectQueue.ts — Redis hash queue with TTL
|
||||
src/server/services/gateway/runtimeStatus.ts — Redis bot:runtime-status keys
|
||||
|
||||
Database:
|
||||
packages/database/src/schemas/agentBotProvider.ts — agent_bot_providers table
|
||||
packages/database/src/models/agentBotProvider.ts — encrypted CRUD + system-wide finders
|
||||
|
||||
TRPC + client:
|
||||
src/server/routers/lambda/agentBotProvider.ts — TRPC router
|
||||
src/services/agentBotProvider.ts — client wrapper
|
||||
src/store/agent/slices/bot/action.ts — Zustand actions
|
||||
|
||||
UI:
|
||||
src/routes/(main)/agent/channel/list.tsx — channel list
|
||||
src/routes/(main)/agent/channel/detail/ — auto-generated form (Header/Body/Footer)
|
||||
src/routes/(main)/agent/channel/const.ts — platform icons
|
||||
|
||||
Types & runtime status:
|
||||
src/types/botRuntimeStatus.ts — BOT_RUNTIME_STATUSES enum + snapshot type
|
||||
```
|
||||
|
||||
## Adding a New Platform
|
||||
|
||||
1. Create `src/server/services/bot/platforms/<id>/`:
|
||||
- `definition.ts` — `PlatformDefinition` registered in `platforms/index.ts`
|
||||
- `schema.ts` — `FieldSchema[]` (`applicationId` + `credentials` + `settings`); reuse fragments from `../const.ts`
|
||||
- `client.ts` — `class XClientFactory extends ClientFactory` returning a `PlatformClient` (lifecycle + adapter + messenger + helpers)
|
||||
- `const.ts` — `DEFAULT_X_CONNECTION_MODE`, history limits, etc.
|
||||
- `protocol-spec.md` — protocol notes (every existing platform has one)
|
||||
2. Pick the right `connectionMode` — webhook is much simpler if the platform supports it.
|
||||
3. If the platform can't render markdown, set `supportsMarkdown: false` and implement `formatMarkdown` via `stripMarkdown`.
|
||||
4. If it can't edit messages, set `supportsMessageEdit: false` — `BotCallbackService` will skip step edits and only send the final reply.
|
||||
5. Implement `validateCredentials` so the UI's "Test connection" button gives useful errors.
|
||||
6. Add the platform icon in `src/routes/(main)/agent/channel/const.ts` and register the platform in `src/server/services/bot/platforms/index.ts`.
|
||||
7. Add i18n keys under `channel.*` in `src/locales/default/setting.ts` (or wherever the channel namespace lives) — the schema's `label`/`description`/`placeholder`/`enumLabels` are i18n keys.
|
||||
@@ -1,218 +0,0 @@
|
||||
---
|
||||
name: cli-backend-testing
|
||||
description: >
|
||||
CLI + Backend integration testing workflow. Use when verifying backend API changes
|
||||
(TRPC routers, services, models) via the LobeHub CLI against a local dev server.
|
||||
Triggers on 'cli test', 'test with cli', 'verify with cli', 'local cli test',
|
||||
'backend test with cli', or when needing to validate server-side changes end-to-end.
|
||||
---
|
||||
|
||||
# CLI + Backend Integration Testing
|
||||
|
||||
Standard workflow for verifying backend changes using the LobeHub CLI (`lh`) against a local dev server.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Verifying TRPC router / service / model changes end-to-end
|
||||
- Testing new API fields or response structure changes
|
||||
- Validating CLI command output after backend modifications
|
||||
- Debugging data flow issues between server and CLI
|
||||
|
||||
## Prerequisites
|
||||
|
||||
| Requirement | Details |
|
||||
| ------------ | ------------------------------------------------------------- |
|
||||
| Dev server | `localhost:3011` (Next.js) |
|
||||
| CLI source | `lobehub/apps/cli/` |
|
||||
| CLI dev mode | Uses `LOBEHUB_CLI_HOME=.lobehub-dev` for isolated credentials |
|
||||
| Auth | Device Code Flow login to local server |
|
||||
|
||||
## Quick Reference
|
||||
|
||||
All CLI dev commands run from `lobehub/apps/cli/`:
|
||||
|
||||
```bash
|
||||
# Shorthand for all commands below
|
||||
CLI="LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts"
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1: Ensure Dev Server is Running
|
||||
|
||||
Check if the dev server is already running:
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w '%{http_code}' http://localhost:3011/ 2> /dev/null
|
||||
```
|
||||
|
||||
- **If reachable** (returns any HTTP status): server is running. Skip to Step 2.
|
||||
- **If unreachable**: start the server:
|
||||
|
||||
```bash
|
||||
# From cloud repo root
|
||||
pnpm run dev:next
|
||||
```
|
||||
|
||||
To **restart** (pick up server-side code changes):
|
||||
|
||||
```bash
|
||||
lsof -ti:3011 | xargs kill
|
||||
pnpm run dev:next
|
||||
```
|
||||
|
||||
**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
|
||||
|
||||
Check if dev credentials already exist:
|
||||
|
||||
```bash
|
||||
cat lobehub/apps/cli/.lobehub-dev/settings.json 2> /dev/null
|
||||
```
|
||||
|
||||
- **If file exists and contains `"serverUrl": "http://localhost:3011"`**: already authenticated. Skip to Step 3.
|
||||
- **If file missing or points to wrong server**: login is needed. Ask the user to run:
|
||||
|
||||
```bash
|
||||
! cd lobehub/apps/cli && LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts login --server http://localhost:3011
|
||||
```
|
||||
|
||||
> Login requires interactive browser authorization (OIDC Device Code Flow), so the user must run it themselves via `!` prefix. After login, credentials are saved to `lobehub/apps/cli/.lobehub-dev/` and persist across sessions.
|
||||
|
||||
### Step 3: Test with CLI Commands
|
||||
|
||||
CLI runs from source (`bun src/index.ts`), so CLI-side code changes take effect immediately without rebuilding.
|
||||
|
||||
```bash
|
||||
cd lobehub/apps/cli
|
||||
LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts <command>
|
||||
```
|
||||
|
||||
### Step 4: Clean Up Test Data
|
||||
|
||||
Delete any test data created during verification:
|
||||
|
||||
```bash
|
||||
LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts task delete < id > -y
|
||||
LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts agent delete < id > -y
|
||||
```
|
||||
|
||||
## Common Testing Patterns
|
||||
|
||||
### Task System
|
||||
|
||||
```bash
|
||||
# List tasks
|
||||
$CLI task list
|
||||
|
||||
# Create test data with nesting
|
||||
$CLI task create -n "Root Task" -i "Test instruction"
|
||||
$CLI task create -n "Child Task" -i "Sub instruction" --parent T-1
|
||||
|
||||
# View task detail (tests getTaskDetail service)
|
||||
$CLI task view T-1
|
||||
|
||||
# View task tree
|
||||
$CLI task tree T-1
|
||||
|
||||
# Test lifecycle
|
||||
$CLI task edit T-1 --status running
|
||||
$CLI task comment T-1 -m "Test comment"
|
||||
|
||||
# Clean up
|
||||
$CLI task delete T-1 -y
|
||||
```
|
||||
|
||||
### Agent System
|
||||
|
||||
```bash
|
||||
# List agents
|
||||
$CLI agent list
|
||||
|
||||
# View agent detail
|
||||
$CLI agent view <agent-id>
|
||||
|
||||
# Run agent (tests agent execution pipeline)
|
||||
$CLI agent run <agent-id> -m "Test prompt"
|
||||
```
|
||||
|
||||
### Document & Knowledge Base
|
||||
|
||||
```bash
|
||||
# List documents
|
||||
$CLI doc list
|
||||
|
||||
# Create and view
|
||||
$CLI doc create -t "Test Doc" -c "Content here"
|
||||
$CLI doc view <doc-id>
|
||||
|
||||
# Knowledge base
|
||||
$CLI kb list
|
||||
$CLI kb tree <kb-id>
|
||||
```
|
||||
|
||||
### Model & Provider
|
||||
|
||||
```bash
|
||||
# List models and providers
|
||||
$CLI model list
|
||||
$CLI provider list
|
||||
|
||||
# Test provider connectivity
|
||||
$CLI provider test <provider-id>
|
||||
```
|
||||
|
||||
## Dev-Test Cycle
|
||||
|
||||
The standard cycle for backend development:
|
||||
|
||||
```
|
||||
1. Make code changes (service/model/router/type)
|
||||
|
|
||||
2. Run unit tests (fast feedback)
|
||||
bunx vitest run --silent='passed-only' '<test-file>'
|
||||
|
|
||||
3. Restart dev server (if server-side changes)
|
||||
lsof -ti:3011 | xargs kill && pnpm run dev:next
|
||||
|
|
||||
4. CLI verification (end-to-end)
|
||||
LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts <command>
|
||||
|
|
||||
5. Clean up test data
|
||||
```
|
||||
|
||||
### When Server Restart is Needed
|
||||
|
||||
| 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 |
|
||||
|
||||
### When Server Restart is NOT Needed
|
||||
|
||||
CLI runs from source via `bun src/index.ts`, so any changes to `lobehub/apps/cli/src/` take effect immediately on next command invocation.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Issue | Solution |
|
||||
| --------------------------- | --------------------------------------------------------------------- |
|
||||
| `No authentication found` | Run `login --server http://localhost:3011` |
|
||||
| `UNAUTHORIZED` on API calls | Token expired; re-run login |
|
||||
| `ECONNREFUSED` | Dev server not running; start with `pnpm run dev:next` |
|
||||
| CLI shows old data/behavior | Server needs restart to pick up code changes |
|
||||
| `EADDRINUSE` on port 3011 | Server already running; kill with `lsof -ti:3011 \| xargs kill` |
|
||||
| Login opens wrong server | Must use `--server http://localhost:3011` flag (env var doesn't work) |
|
||||
|
||||
## Credential Isolation
|
||||
|
||||
| Mode | Credential Dir | Server |
|
||||
| ---------- | -------------------------------- | ----------------- |
|
||||
| Dev | `lobehub/apps/cli/.lobehub-dev/` | `localhost:3011` |
|
||||
| Production | `~/.lobehub/` | `app.lobehub.com` |
|
||||
|
||||
The two environments are completely isolated. Dev mode credentials are gitignored.
|
||||
@@ -8,20 +8,16 @@ Generate text, images, videos, speech, and transcriptions.
|
||||
|
||||
```
|
||||
lh generate (alias: gen)
|
||||
├── text <prompt> # Text generation
|
||||
├── image <prompt> # Image generation
|
||||
├── video <prompt> # Video generation
|
||||
├── tts <text> # Text-to-speech
|
||||
├── asr <audioFile> # Audio-to-text (speech recognition)
|
||||
├── download <generationId> <asyncTaskId> # Wait & download generation result
|
||||
├── status <generationId> <asyncTaskId> # Check async task status
|
||||
└── list # List generation topics
|
||||
├── text <prompt> # Text generation
|
||||
├── image <prompt> # Image generation
|
||||
├── video <prompt> # Video generation
|
||||
├── tts <text> # Text-to-speech
|
||||
├── asr <audioFile> # Audio-to-text (speech recognition)
|
||||
├── download <genId> <taskId> # Wait & download generation result
|
||||
├── status <genId> <taskId> # Check async task status
|
||||
└── list # List generation topics
|
||||
```
|
||||
|
||||
> ⚠️ **Important**: `status` and `download` require an `asyncTaskId` (UUID format, e.g.
|
||||
> `7ad0eb13-e9a5-4403-8070-1f7fe95b2f95`), **not** the generation ID (`gen_xxx`).
|
||||
> The asyncTaskId is printed after "→ Task" in the `video` / `image` command output.
|
||||
|
||||
---
|
||||
|
||||
## `lh generate text <prompt>` / `lh gen text <prompt>`
|
||||
@@ -58,7 +54,7 @@ cat README.md | lh gen text "summarize this" --pipe
|
||||
|
||||
## `lh generate image <prompt>` / `lh gen image <prompt>`
|
||||
|
||||
Generate images from text prompt. This is an async operation — the command submits the task and returns a generation ID + async task ID for tracking.
|
||||
Generate images from text prompt. This is an async operation — the command submits the task and returns a generation ID + task ID for tracking.
|
||||
|
||||
**Source**: `apps/cli/src/commands/generate/image.ts`
|
||||
|
||||
@@ -84,22 +80,17 @@ lh gen image "A cute cat" --model dall-e-3 --provider openai --json
|
||||
✓ Image generation started
|
||||
Batch ID: gb_xxx
|
||||
1 image(s) queued
|
||||
Generation gen_xxx → Task 7ad0eb13-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
This is the asyncTaskId — use this for status/download
|
||||
Generation gen_xxx → Task <taskId>
|
||||
|
||||
Use "lh generate status <generationId> <asyncTaskId>" to check progress.
|
||||
Use "lh generate status <generationId> <taskId>" to check progress.
|
||||
```
|
||||
|
||||
**Typical workflow**:
|
||||
|
||||
```bash
|
||||
# 1. Submit generation — note down BOTH IDs from the output
|
||||
# Generate image, then wait & download
|
||||
lh gen image "A cute cat"
|
||||
# Generation gen_abc123 → Task 7ad0eb13-e9a5-4403-8070-1f7fe95b2f95
|
||||
|
||||
# 2. Wait & download using generationId + asyncTaskId (the UUID)
|
||||
lh gen download gen_abc123 7ad0eb13-e9a5-4403-8070-1f7fe95b2f95 -o cat.png
|
||||
lh gen download <generationId> <taskId> -o cat.png
|
||||
```
|
||||
|
||||
---
|
||||
@@ -111,7 +102,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 |
|
||||
@@ -131,26 +122,9 @@ lh gen video "A cat playing piano" -m <model> -p <provider> [options]
|
||||
```
|
||||
✓ Video generation started
|
||||
Batch ID: gb_xxx
|
||||
Generation gen_xxx → Task 7ad0eb13-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
This is the asyncTaskId — use this for status/download
|
||||
Generation gen_xxx → Task <taskId>
|
||||
|
||||
Use "lh generate status <generationId> <asyncTaskId>" to check progress.
|
||||
```
|
||||
|
||||
**Typical workflow**:
|
||||
|
||||
```bash
|
||||
# 1. Find available video models for a provider
|
||||
lh model list volcengine --json | grep -i seedance
|
||||
|
||||
# 2. Submit generation — note down BOTH IDs from the output
|
||||
lh gen video "A cat on a runway" -m doubao-seedance-2-0-260128 -p volcengine \
|
||||
--aspect-ratio 9:16 --duration 5 --resolution 1080p
|
||||
# Generation gen_abc123 → Task 7ad0eb13-e9a5-4403-8070-1f7fe95b2f95
|
||||
|
||||
# 3. Wait & download using generationId + asyncTaskId (the UUID)
|
||||
lh gen download gen_abc123 7ad0eb13-e9a5-4403-8070-1f7fe95b2f95 -o result.mp4 --timeout 600
|
||||
Use "lh generate status <generationId> <taskId>" to check progress.
|
||||
```
|
||||
|
||||
---
|
||||
@@ -179,18 +153,15 @@ lh gen asr recording.wav [options]
|
||||
|
||||
---
|
||||
|
||||
## `lh generate download <generationId> <asyncTaskId>`
|
||||
## `lh generate download <generationId> <taskId>`
|
||||
|
||||
Wait for an async generation task to complete and download the result file.
|
||||
|
||||
**Source**: `apps/cli/src/commands/generate/index.ts`
|
||||
|
||||
> ⚠️ `<asyncTaskId>` is the UUID printed after "→ Task" in the video/image output.
|
||||
> Do **not** pass the generation ID (`gen_xxx`) here — that will cause a server error.
|
||||
|
||||
```bash
|
||||
lh gen download <generationId> <asyncTaskId> [-o output.png]
|
||||
lh gen download gen_xxx 7ad0eb13-xxxx-xxxx-xxxx-xxxxxxxxxxxx -o ~/Desktop/result.mp4 --timeout 600
|
||||
lh gen download <generationId> <taskId> [-o output.png]
|
||||
lh gen download gen_xxx task_xxx -o ~/Desktop/result.mp4 --timeout 600
|
||||
```
|
||||
|
||||
| Option | Description | Default |
|
||||
@@ -204,21 +175,30 @@ lh gen download gen_xxx 7ad0eb13-xxxx-xxxx-xxxx-xxxxxxxxxxxx -o ~/Desktop/result
|
||||
1. Polls `generation.getGenerationStatus` at the specified interval
|
||||
2. Shows live progress: `⋯ Status: processing... (42s)`
|
||||
3. On success: downloads asset URL to local file
|
||||
4. On error / wrong ID: displays a clear message pointing to the correct ID format
|
||||
4. On error: displays error message and exits
|
||||
5. On timeout: suggests using `lh gen status` to check later
|
||||
|
||||
**Typical workflow**:
|
||||
|
||||
```bash
|
||||
# One-shot: generate and download
|
||||
lh gen image "A sunset"
|
||||
# Copy the generation ID and task ID from output
|
||||
lh gen download gen_xxx taskId_xxx -o sunset.png
|
||||
|
||||
# Video (longer timeout)
|
||||
lh gen video "A cat running" -m model -p provider
|
||||
lh gen download gen_xxx taskId_xxx -o cat.mp4 --timeout 600
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `lh generate status <generationId> <asyncTaskId>`
|
||||
## `lh generate status <generationId> <taskId>`
|
||||
|
||||
Check the status of an async generation task.
|
||||
|
||||
> ⚠️ `<asyncTaskId>` is the UUID printed after "→ Task" in the video/image output.
|
||||
> Do **not** pass the generation ID (`gen_xxx`) here — that will cause a server error.
|
||||
|
||||
```bash
|
||||
lh gen status <generationId> <asyncTaskId> [--json]
|
||||
lh gen status gen_xxx 7ad0eb13-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
||||
lh gen status <generationId> <taskId> [--json]
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
@@ -255,17 +235,12 @@ Image and video generation use an async task pattern:
|
||||
- Triggers async background task (image via `createAsyncCaller`, video via `initModelRuntimeFromDB`)
|
||||
- Returns `{ data: { batch, generations }, success }` with `asyncTaskId` in each generation
|
||||
3. **Poll status** → `generation.getGenerationStatus`
|
||||
- Input: `{ generationId, asyncTaskId }` — both are required, and `asyncTaskId` must be the
|
||||
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`)
|
||||
|
||||
**Server routes**:
|
||||
|
||||
- `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`.
|
||||
|
||||
@@ -1,51 +1,58 @@
|
||||
---
|
||||
name: review-checklist
|
||||
description: 'Common recurring mistakes in LobeHub code review — console leftovers, missing return await, hardcoded secrets, hardcoded i18n strings, desktop router pair drift, antd vs @lobehub/ui, non-idempotent migrations, cloud impact red flags. Use as a quick checklist when reviewing PRs, diffs, or branch changes.'
|
||||
name: code-review
|
||||
description: 'Code review checklist for LobeHub. Use when reviewing PRs, diffs, or code changes. Covers correctness, security, quality, and project-specific patterns.'
|
||||
---
|
||||
|
||||
# Review Checklist
|
||||
# Code Review Guide
|
||||
|
||||
## Correctness
|
||||
## Before You Start
|
||||
|
||||
1. Read `/typescript` and `/testing` skills for code style and test conventions
|
||||
2. Get the diff (skip if already in context, e.g., injected by GitHub review app): `git diff` or `git diff origin/canary..HEAD`
|
||||
|
||||
## Checklist
|
||||
|
||||
### Correctness
|
||||
|
||||
- Leftover `console.log` / `console.debug` — should use `debug` package or remove
|
||||
- Missing `return await` in try/catch — see <https://typescript-eslint.io/rules/return-await/> (not in our ESLint config yet, requires type info)
|
||||
- Can the fix/implementation be more concise, efficient, or have better compatibility?
|
||||
|
||||
## Security
|
||||
### Security
|
||||
|
||||
- No sensitive data (API keys, tokens, credentials) in `console.*` or `debug()` output
|
||||
- No base64 output to terminal — extremely long, freezes output
|
||||
- No hardcoded secrets — use environment variables
|
||||
|
||||
## Testing
|
||||
### Testing
|
||||
|
||||
- Bug fixes must include tests covering the fixed scenario
|
||||
- New logic (services, store actions, utilities) should have test coverage
|
||||
- Existing tests still cover the changed behavior?
|
||||
- Prefer `vi.spyOn` over `vi.mock` (see `/testing` skill)
|
||||
|
||||
## i18n
|
||||
### i18n
|
||||
|
||||
- New user-facing strings use i18n keys, not hardcoded text
|
||||
- Keys added to `src/locales/default/{namespace}.ts` with `{feature}.{context}.{action|status}` naming
|
||||
- For PRs: `locales/` translations for all languages updated (`pnpm i18n`)
|
||||
|
||||
## SPA / routing
|
||||
### SPA / routing
|
||||
|
||||
- **`desktopRouter` pair:** If the diff touches `src/spa/router/desktopRouter.config.tsx`, does it also update `src/spa/router/desktopRouter.config.desktop.tsx` with the same route paths and nesting? Single-file edits often cause drift and blank screens.
|
||||
|
||||
## Reuse
|
||||
### Reuse
|
||||
|
||||
- Newly written code duplicates existing utilities in `packages/utils` or shared modules?
|
||||
- Copy-pasted blocks with slight variation — extract into shared function
|
||||
- `antd` imports replaceable with `@lobehub/ui` wrapped components (`Input`, `Button`, `Modal`, `Avatar`, etc.)
|
||||
- Use `antd-style` token system, not hardcoded colors; prefer `createStaticStyles` + `cssVar.*` over `createStyles` + `token` unless runtime computation is required
|
||||
- Use `antd-style` token system, not hardcoded colors
|
||||
|
||||
## Database
|
||||
### Database
|
||||
|
||||
- Migration scripts must be idempotent (`IF NOT EXISTS`, `IF EXISTS` guards)
|
||||
|
||||
## Cloud Impact
|
||||
### Cloud Impact
|
||||
|
||||
A downstream cloud deployment depends on this repo. Flag changes that may require cloud-side updates:
|
||||
|
||||
@@ -54,3 +61,13 @@ A downstream cloud deployment depends on this repo. Flag changes that may requir
|
||||
- **Dependency versions bumped** — e.g., upgrading `next` or `drizzle-orm` in `package.json`
|
||||
- **`@lobechat/business-*` exports changed** — e.g., renaming a function in `src/business/` or changing type signatures in `packages/business/`
|
||||
- `src/business/` and `packages/business/` must not expose cloud commercial logic in comments or code
|
||||
|
||||
## Output Format
|
||||
|
||||
For local CLI review only (GitHub review app posts inline PR comments instead):
|
||||
|
||||
- Number all findings sequentially
|
||||
- Indicate priority: `[high]` / `[medium]` / `[low]`
|
||||
- Include file path and line number for each finding
|
||||
- Only list problems — no summary, no praise
|
||||
- Re-read full source for each finding to verify it's real, then output "All findings verified."
|
||||
@@ -1,155 +0,0 @@
|
||||
---
|
||||
name: docs-changelog
|
||||
description: 'Writing guide for website changelog pages under docs/changelog/*.mdx. Use when creating or editing product update posts in EN/ZH. Not for GitHub Release notes.'
|
||||
---
|
||||
|
||||
# Docs Changelog Writing Guide
|
||||
|
||||
## Scope Boundary (Important)
|
||||
|
||||
This skill is only for changelog pages in:
|
||||
|
||||
- `docs/changelog/*.mdx`
|
||||
|
||||
This skill is **not** for GitHub Releases.\
|
||||
If the user asks for release PR body / GitHub Release notes, load `../version-release/SKILL.md`.
|
||||
|
||||
## Mandatory Companion Skills
|
||||
|
||||
For every docs changelog task, you MUST load:
|
||||
|
||||
- `../microcopy/SKILL.md`
|
||||
- `../i18n/SKILL.md` (when EN/ZH pair is involved)
|
||||
|
||||
## File and Naming Convention
|
||||
|
||||
Use date-based file names:
|
||||
|
||||
- English: `docs/changelog/YYYY-MM-DD-topic.mdx`
|
||||
- Chinese: `docs/changelog/YYYY-MM-DD-topic.zh-CN.mdx`
|
||||
|
||||
EN and ZH files must exist as a pair and describe the same release facts.
|
||||
|
||||
## Frontmatter Requirements
|
||||
|
||||
Each file should include:
|
||||
|
||||
```md
|
||||
---
|
||||
title: <Title>
|
||||
description: <1 sentence summary>
|
||||
tags:
|
||||
- <Tag 1>
|
||||
- <Tag 2>
|
||||
---
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
1. `title` should match the H1 title in meaning.
|
||||
2. `description` should be concise and user-facing.
|
||||
3. `tags` should be feature-oriented, not internal-team labels.
|
||||
|
||||
## Content Structure (Recommended)
|
||||
|
||||
Use this shape unless the user requests otherwise:
|
||||
|
||||
1. `# <Title>`
|
||||
2. Opening paragraph (2-4 sentences): user-visible impact
|
||||
3. 1-3 capability sections (optional `##` headings)
|
||||
4. `## Improvements and fixes` / `## 体验优化与修复` with concise bullets
|
||||
|
||||
Keep heading count low and avoid heading-per-bullet structure.
|
||||
|
||||
## Writing Rules
|
||||
|
||||
1. Keep all claims factual and tied to actual shipped changes.
|
||||
2. Explain user value first, implementation second.
|
||||
3. Prefer natural narrative paragraphs over pure bullet dumps.
|
||||
4. Avoid marketing exaggeration and vague adjectives.
|
||||
5. Keep internal terms consistent across EN/ZH files.
|
||||
6. Keep EN/ZH section order aligned and scope-aligned.
|
||||
|
||||
## EN/ZH Synchronization Rules
|
||||
|
||||
When generating bilingual changelogs:
|
||||
|
||||
1. Keep the same key facts in the same order.
|
||||
2. Localize naturally; do not do literal sentence-by-sentence translation.
|
||||
3. If one version has an `Improvements and fixes` bullet list, the other should have equivalent list intent.
|
||||
4. Do not introduce capabilities in only one language unless explicitly requested.
|
||||
|
||||
## Length Guidance
|
||||
|
||||
- Small update: 3-5 short paragraphs total
|
||||
- Medium update: 4-7 short paragraphs + concise fix bullets
|
||||
- Large update: 6-10 short paragraphs split into 2-4 sections
|
||||
|
||||
Do not pad content when changes are limited.
|
||||
|
||||
## Authoring Workflow
|
||||
|
||||
1. Collect source facts from PRs/commits/issues.
|
||||
2. Group changes by user workflow (not by internal module path).
|
||||
3. Draft EN and ZH versions with aligned structure.
|
||||
4. Verify terminology using `microcopy`/`i18n` guidance.
|
||||
5. Final pass: remove AI-like filler and tighten sentences.
|
||||
|
||||
## Docs Changelog Template (English)
|
||||
|
||||
```md
|
||||
---
|
||||
title: <Feature title>
|
||||
description: <One-sentence summary for users>
|
||||
tags:
|
||||
- <Tag A>
|
||||
- <Tag B>
|
||||
---
|
||||
|
||||
# <Feature title>
|
||||
|
||||
<Opening paragraph: what changed for users and why it matters.>
|
||||
|
||||
<Optional section paragraph for key capability 1.>
|
||||
|
||||
<Optional section paragraph for key capability 2.>
|
||||
|
||||
## Improvements and fixes
|
||||
|
||||
- <Fix or optimization 1>
|
||||
- <Fix or optimization 2>
|
||||
```
|
||||
|
||||
## Docs Changelog Template (Chinese)
|
||||
|
||||
```md
|
||||
---
|
||||
title: <功能标题>
|
||||
description: <一句话说明>
|
||||
tags:
|
||||
- <标签 A>
|
||||
- <标签 B>
|
||||
---
|
||||
|
||||
# <功能标题>
|
||||
|
||||
<开场段:这次更新给用户带来的直接变化。>
|
||||
|
||||
<可选能力段 1。>
|
||||
|
||||
<可选能力段 2。>
|
||||
|
||||
## 体验优化与修复
|
||||
|
||||
- <优化或修复 1>
|
||||
- <优化或修复 2>
|
||||
```
|
||||
|
||||
## Quick Checklist
|
||||
|
||||
- [ ] File path matches `docs/changelog` naming convention
|
||||
- [ ] EN and ZH versions both exist and match in facts
|
||||
- [ ] Opening paragraph explains user-facing outcome
|
||||
- [ ] Main body is narrative-first, not bullet-only
|
||||
- [ ] `Improvements and fixes` section is concise and concrete
|
||||
- [ ] No fabricated claims or unsupported scope
|
||||
@@ -1,83 +0,0 @@
|
||||
---
|
||||
name: heterogeneous-agent
|
||||
description: Guide for implementing and debugging LobeHub heterogeneous agent integrations such as Claude Code, Codex, and future external CLI agents. Use when working on adapter event mapping, Electron IPC transport, renderer persistence, tool-call chaining, subagent threads, resume/session handling, or regressions like mixed multi-tool messages, broken step boundaries, stuck tool loading, and orphan tool messages. Triggers on 'heterogeneous agent', 'hetero agent', '异构 agent', 'claude code adapter', 'codex adapter', 'external agent CLI', '孤立 tool 消息', 'raw Codex trace', or adapter/executor bugs.
|
||||
---
|
||||
|
||||
# Heterogeneous Agent Development
|
||||
|
||||
Use this skill when the bug or feature lives in the external CLI agent pipeline, not the normal server-side agent runtime.
|
||||
|
||||
## Use This Skill For
|
||||
|
||||
- Adding or changing a driver under `apps/desktop/src/main/modules/heterogeneousAgent/drivers/`
|
||||
- Editing an adapter under `packages/heterogeneous-agents/src/adapters/`
|
||||
- Debugging `heteroAgentRawLine` transport, `window.__HETERO_AGENT_TRACE`, or `executeHeterogeneousAgent`
|
||||
- Fixing Claude Code stream-json bugs such as duplicate partial/full chunks, broken `message.id` boundaries, missing `tool_result`, TodoWrite state drift, or subagent thread routing
|
||||
- Fixing Codex JSONL bugs such as mixed multi-tool messages, broken turn boundaries, or missing tool-result mapping
|
||||
- Fixing step-boundary, tool persistence, subagent thread, or resume bugs in Claude Code / Codex flows
|
||||
- Reproducing multi-tool mixing, orphan tool messages, or stuck tool-result loading
|
||||
|
||||
## Pipeline Map
|
||||
|
||||
1. CLI raw stdout / JSONL
|
||||
2. Electron main spawns the CLI and broadcasts `heteroAgentRawLine`
|
||||
3. Adapter maps raw provider events into `HeterogeneousAgentEvent`
|
||||
4. `executeHeterogeneousAgent` persists assistant/tool messages and forwards stream events
|
||||
5. `createGatewayEventHandler` hydrates the UI
|
||||
6. Only after this path looks correct should you move on to `agent-tracing` or context-engine debugging
|
||||
|
||||
## Read These Files First
|
||||
|
||||
- `apps/desktop/src/main/controllers/HeterogeneousAgentCtr.ts`
|
||||
- `apps/desktop/src/main/modules/heterogeneousAgent/drivers/claudeCode.ts`
|
||||
- `apps/desktop/src/main/modules/heterogeneousAgent/drivers/codex.ts`
|
||||
- `packages/heterogeneous-agents/src/adapters/claudeCode.ts`
|
||||
- `packages/heterogeneous-agents/src/adapters/codex.ts`
|
||||
- `src/store/chat/slices/aiChat/actions/heterogeneousAgentExecutor.ts`
|
||||
- `src/store/chat/slices/aiChat/actions/__tests__/heterogeneousAgentExecutor.test.ts`
|
||||
|
||||
## Default Debug Order
|
||||
|
||||
1. Prove whether the raw CLI output is correct before touching UI code.
|
||||
2. If raw output is correct, compare it with adapter output. In dev, `executeHeterogeneousAgent` exposes `window.__HETERO_AGENT_TRACE`.
|
||||
3. If adapted events look correct, inspect `persistToolBatch`, `persistToolResult`, step transitions, and subagent routing.
|
||||
4. Turn the repro into a focused test before fixing.
|
||||
5. Only after the transport/adapter/executor path looks sound should you debug later-stage message processing.
|
||||
|
||||
## Critical Invariants
|
||||
|
||||
- One raw tool item must map to one stable `ToolCallPayload.id`.
|
||||
- A new main-agent step must emit a boundary signal before events are forwarded to the new assistant.
|
||||
- In Claude Code, multiple assistant events with the same `message.id` are one turn, not multiple turns.
|
||||
- In Claude Code, `tool_result` lives in `type: 'user'` events, not assistant events.
|
||||
- In Claude Code partial mode, `message_delta.usage` is authoritative; do not trust echoed usage on every assistant block.
|
||||
- `persistToolBatch` must pre-register assistant `tools[]` before creating tool messages.
|
||||
- Every tool message must keep `parentId` equal to the owning assistant and `tool_call_id` equal to the tool id.
|
||||
- `tool_result` must resolve an existing `toolMsgIdByCallId`.
|
||||
- Subagent chunks must stay in thread scope and must not be forwarded into the main assistant stream.
|
||||
- Never clear the global `toolMsgIdByCallId` map at main step boundaries.
|
||||
|
||||
## Common Bug Patterns
|
||||
|
||||
- Claude Code duplicates text or thinking:
|
||||
check whether partial deltas and the later full assistant block are both being emitted.
|
||||
- Claude Code opens too many assistant messages:
|
||||
check whether the adapter is cutting steps on every assistant event instead of only on `message.id` changes.
|
||||
- Claude Code tool results never land:
|
||||
check whether `type: 'user'` `tool_result` blocks are being ignored because the code only inspects assistant events.
|
||||
- Claude Code TodoWrite cards look stale:
|
||||
check whether synthesized `pluginState.todos` is being attached at tool-result time.
|
||||
- Claude Code subagent transcript leaks into the main bubble:
|
||||
check `parent_tool_use_id` handling and whether subagent chunks are being forwarded to the main gateway handler.
|
||||
- Multiple Codex tools collapse into one assistant message:
|
||||
first check whether the adapter emits a usable step boundary such as `newStep` or an equivalent turn-change signal.
|
||||
- Orphan tool messages:
|
||||
first check step-transition ordering and whether `persistToolBatch` Phase 1 ran before tool message creation.
|
||||
- Tool bubble stays loading:
|
||||
look for `tool_result for unknown toolCallId` and missing `result_msg_id` backfill.
|
||||
- Subagent tools show up in the main bubble:
|
||||
check for subagent chunks reaching the main gateway handler.
|
||||
|
||||
## References
|
||||
|
||||
- For commands, trace capture, invariants, and focused test commands, read [references/debug-workflow.md](./references/debug-workflow.md).
|
||||
@@ -1,246 +0,0 @@
|
||||
# Heterogeneous Agent Debug Workflow
|
||||
|
||||
## Contents
|
||||
|
||||
1. Pipeline map
|
||||
2. Capture raw CLI traces first
|
||||
3. Compare raw and adapted events
|
||||
4. Check step boundaries before persistence
|
||||
5. Check tool persistence invariants
|
||||
6. Focused tests
|
||||
7. Repro-to-fix workflow
|
||||
|
||||
## 1. Pipeline Map
|
||||
|
||||
```
|
||||
CLI raw stdout
|
||||
-> HeterogeneousAgentCtr (Electron main)
|
||||
-> heteroAgentRawLine broadcast
|
||||
-> createAdapter(...)
|
||||
-> executeHeterogeneousAgent(...)
|
||||
-> persistToolBatch / persistToolResult
|
||||
-> createGatewayEventHandler(...)
|
||||
-> UI hydration
|
||||
```
|
||||
|
||||
Start at the leftmost broken layer. Do not jump straight to UI rendering unless raw and adapted events already look correct.
|
||||
|
||||
## 2. Capture Raw CLI Traces First
|
||||
|
||||
### Codex raw JSONL
|
||||
|
||||
Use a read-only prompt and save traces under the repo-local scratch directory `.heerogeneous-tracing/`.
|
||||
|
||||
```bash
|
||||
ts=$(date +%Y%m%d-%H%M%S)
|
||||
out=".heerogeneous-tracing/codex-${ts}.jsonl"
|
||||
last=".heerogeneous-tracing/codex-${ts}.last.txt"
|
||||
|
||||
cat << 'EOF' | codex exec --json --skip-git-repo-check --sandbox read-only -C "$PWD" -o "$last" - > "$out"
|
||||
You are being run only to collect a raw Codex JSON event trace.
|
||||
Do not modify any files.
|
||||
Use at least 4 separate shell tool invocations, one invocation per command.
|
||||
Run a short sequence of read-only repo checks and then reply with a one-sentence summary.
|
||||
EOF
|
||||
```
|
||||
|
||||
What to look for in the JSONL:
|
||||
|
||||
- `thread.started`
|
||||
- `turn.started`
|
||||
- `item.started` / `item.completed`
|
||||
- `item.type === 'command_execution'`
|
||||
- `item.type === 'agent_message'`
|
||||
- `turn.completed`
|
||||
|
||||
If raw Codex already merges tools into one item, the adapter is innocent. If raw Codex emits independent items but UI collapses them, the bug is downstream.
|
||||
|
||||
If the repo already contains useful traces under `.heerogeneous-tracing/`, inspect them before reproducing.
|
||||
|
||||
### Claude Code raw NDJSON
|
||||
|
||||
Mirror the arguments from `apps/desktop/src/main/modules/heterogeneousAgent/drivers/claudeCode.ts`.
|
||||
|
||||
- `-p`
|
||||
- `--input-format stream-json`
|
||||
- `--output-format stream-json`
|
||||
- `--verbose`
|
||||
- `--include-partial-messages`
|
||||
- `--permission-mode bypassPermissions`
|
||||
|
||||
You can capture a local raw trace like this:
|
||||
|
||||
```bash
|
||||
ts=$(date +%Y%m%d-%H%M%S)
|
||||
out=".heerogeneous-tracing/claude-${ts}.ndjson"
|
||||
|
||||
cat << 'EOF' | claude -p \
|
||||
--input-format stream-json \
|
||||
--output-format stream-json \
|
||||
--verbose \
|
||||
--include-partial-messages \
|
||||
--permission-mode bypassPermissions \
|
||||
> "$out"
|
||||
{"type":"user","message":{"role":"user","content":[{"type":"text","text":"Do a few read-only repo checks, use several tool calls, and then summarize briefly."}]}}
|
||||
EOF
|
||||
```
|
||||
|
||||
What to look for in Claude Code raw traces:
|
||||
|
||||
- `type: 'system', subtype: 'init'`
|
||||
- `type: 'assistant'` blocks for `thinking`, `tool_use`, and `text`
|
||||
- `type: 'user'` blocks containing `tool_result`
|
||||
- `type: 'stream_event'` with `message_start`, `content_block_delta`, and `message_delta`
|
||||
- `type: 'result'`
|
||||
- `type: 'rate_limit_event'`
|
||||
|
||||
Important Claude Code semantics:
|
||||
|
||||
- Each content block often arrives as its own assistant event.
|
||||
- Multiple assistant events can share the same `message.id`; that is still one turn.
|
||||
- `message.id` change is the main-step boundary.
|
||||
- Partial deltas arrive before the later full assistant block.
|
||||
- `message_delta.usage` is the authoritative per-turn usage.
|
||||
- Subagent events are tagged with `parent_tool_use_id`.
|
||||
|
||||
If the repo already contains useful references, inspect these first:
|
||||
|
||||
- `.heerogeneous-tracing/cc-monitor-real-trace.jsonl`
|
||||
- `.heerogeneous-tracing/cc-stream-chain-reference.md`
|
||||
|
||||
If you only need boundary semantics or tool persistence behavior, prefer existing adapter tests under:
|
||||
|
||||
- `packages/heterogeneous-agents/src/adapters/claudeCode.test.ts`
|
||||
- `packages/heterogeneous-agents/src/adapters/claudeCode.e2e.test.ts`
|
||||
|
||||
## 3. Compare Raw And Adapted Events
|
||||
|
||||
In dev builds, `executeHeterogeneousAgent` stores raw lines plus adapted events on:
|
||||
|
||||
- `window.__HETERO_AGENT_TRACE`
|
||||
|
||||
Use that trace to compare:
|
||||
|
||||
- raw `item.started` / `item.completed`
|
||||
- adapted `stream_chunk { chunkType: 'tools_calling' }`
|
||||
- adapted `tool_result`
|
||||
- adapted `tool_end`
|
||||
|
||||
For Codex, the usual mapping is:
|
||||
|
||||
- raw `item.started(command_execution)` -> `tools_calling` + `tool_start`
|
||||
- raw `item.completed(command_execution)` -> `tool_result` + `tool_end`
|
||||
- raw `item.completed(agent_message)` -> `stream_chunk(text)`
|
||||
|
||||
If the raw trace is right but adapted events are wrong, fix the adapter before touching persistence.
|
||||
|
||||
## 4. Check Step Boundaries Before Persistence
|
||||
|
||||
This is the first thing to verify for "mixed tools in one assistant" bugs.
|
||||
|
||||
### Claude Code
|
||||
|
||||
Claude Code step boundaries are keyed off assistant `message.id` changes. The adapter should emit:
|
||||
|
||||
- `stream_end`
|
||||
- `stream_start { newStep: true }`
|
||||
|
||||
Also verify these Claude-specific invariants:
|
||||
|
||||
- the first assistant after init does not open a new step
|
||||
- repeated assistant events with the same `message.id` do not open a new step
|
||||
- partial `content_block_delta` text/thinking does not get duplicated by the later full assistant event
|
||||
- `tool_result` from `type: 'user'` updates the matching tool row
|
||||
- `parent_tool_use_id` creates thread-scoped subagent chunks instead of main-stream chunks
|
||||
- TodoWrite `tool_use.input` is converted into synthesized `pluginState.todos` on `tool_result`
|
||||
|
||||
Good references:
|
||||
|
||||
- `packages/heterogeneous-agents/src/adapters/claudeCode.ts`
|
||||
- `packages/heterogeneous-agents/src/adapters/claudeCode.test.ts`
|
||||
|
||||
### Codex
|
||||
|
||||
Codex raw traces usually provide turn-level boundaries through:
|
||||
|
||||
- `turn.started`
|
||||
- `turn.completed`
|
||||
|
||||
The executor only cuts a new assistant message when it receives a step-boundary signal it understands. If the adapter emits `stream_start` without `newStep`, multiple Codex tools and text chunks can accumulate under the same assistant longer than intended.
|
||||
|
||||
Relevant files:
|
||||
|
||||
- `packages/heterogeneous-agents/src/adapters/codex.ts`
|
||||
- `src/store/chat/slices/aiChat/actions/heterogeneousAgentExecutor.ts`
|
||||
|
||||
## 5. Check Tool Persistence Invariants
|
||||
|
||||
Read `persistToolBatch` and `persistToolResult` before changing UI code.
|
||||
|
||||
### `persistToolBatch`
|
||||
|
||||
The expected order is:
|
||||
|
||||
1. Pre-register assistant `tools[]`
|
||||
2. Create `role: 'tool'` messages
|
||||
3. Backfill `result_msg_id` onto assistant `tools[]`
|
||||
|
||||
If tool rows are created before assistant `tools[]` are registered, orphan tool messages are likely.
|
||||
|
||||
### `persistToolResult`
|
||||
|
||||
`tool_result` must resolve the tool row through `toolMsgIdByCallId`.
|
||||
|
||||
Warning signs:
|
||||
|
||||
- `tool_result for unknown toolCallId`
|
||||
- tool rows with empty content forever
|
||||
- missing `result_msg_id`
|
||||
|
||||
For Claude Code, remember that tool results originate from raw `type: 'user'` events.
|
||||
|
||||
### Main vs subagent scope
|
||||
|
||||
- Main-agent tool state is per-step.
|
||||
- `toolMsgIdByCallId` is global across main and subagent scopes.
|
||||
- Subagent chunks must not be forwarded into the main gateway handler.
|
||||
|
||||
If subagent events leak to the main handler, the main bubble can inherit the wrong `tools[]` and content.
|
||||
|
||||
## 6. Focused Tests
|
||||
|
||||
Run the smallest useful test set first.
|
||||
|
||||
```bash
|
||||
bunx vitest run --silent='passed-only' 'packages/heterogeneous-agents/src/adapters/codex.test.ts'
|
||||
bunx vitest run --silent='passed-only' 'packages/heterogeneous-agents/src/adapters/claudeCode.test.ts'
|
||||
bunx vitest run --silent='passed-only' 'src/store/chat/slices/aiChat/actions/__tests__/heterogeneousAgentExecutor.test.ts'
|
||||
```
|
||||
|
||||
Especially useful places:
|
||||
|
||||
- `packages/heterogeneous-agents/src/adapters/codex.test.ts`
|
||||
- `packages/heterogeneous-agents/src/adapters/claudeCode.test.ts`
|
||||
- `src/store/chat/slices/aiChat/actions/__tests__/heterogeneousAgentExecutor.test.ts`
|
||||
|
||||
Claude Code-specific assertions worth adding when fixing bugs:
|
||||
|
||||
- same `message.id` does not emit `newStep`
|
||||
- changed `message.id` does emit `stream_end` plus `stream_start { newStep: true }`
|
||||
- partial text/thinking is emitted once
|
||||
- `tool_result` from `user` events reaches the right tool row
|
||||
- subagent chunks carry `subagent.parentToolCallId`
|
||||
- TodoWrite result synthesizes `pluginState.todos`
|
||||
|
||||
When the bug comes from a real trace, distill it into the closest existing test file instead of relying on manual UI-only repros.
|
||||
|
||||
## 7. Repro-To-Fix Workflow
|
||||
|
||||
1. Capture a raw trace and save it under `.heerogeneous-tracing/`.
|
||||
2. Confirm whether the bug appears in raw events, adapted events, or persistence.
|
||||
3. Add or update the narrowest failing test near the broken layer.
|
||||
4. Fix the smallest layer that can explain the symptom.
|
||||
5. Re-run focused tests.
|
||||
6. Only then do an Electron smoke test with the `local-testing` skill if UI confirmation is still needed.
|
||||
|
||||
Do not start with a broad Electron repro if a raw trace or adapter test can prove the fault zone faster.
|
||||
@@ -5,7 +5,7 @@ description: Internationalization guide using react-i18next. Use when adding tra
|
||||
|
||||
# LobeHub Internationalization Guide
|
||||
|
||||
- Default language: English (en-US)
|
||||
- Default language: Chinese (zh-CN)
|
||||
- Framework: react-i18next
|
||||
- **Only edit files in `src/locales/default/`** - Never edit JSON files in `locales/`
|
||||
- Run `pnpm i18n` to generate translations (or manually translate zh-CN/en-US for dev preview)
|
||||
|
||||
@@ -20,84 +20,14 @@ This is NON-NEGOTIABLE. Skipping Linear comments is a workflow violation.
|
||||
## Workflow
|
||||
|
||||
1. **Retrieve issue details** before starting: `mcp__linear-server__get_issue`
|
||||
2. **Read images**: If the issue description contains images, MUST use `mcp__linear-server__extract_images` to read image content for full context
|
||||
3. **Check for sub-issues**: Use `mcp__linear-server__list_issues` with `parentId` filter
|
||||
4. **Mark as In Progress**: When starting to plan or implement an issue, immediately update status to **"In Progress"** via `mcp__linear-server__update_issue`
|
||||
5. **Update issue status** when completing: `mcp__linear-server__update_issue`
|
||||
6. **Add completion comment** (REQUIRED): `mcp__linear-server__create_comment`
|
||||
2. **Check for sub-issues**: Use `mcp__linear-server__list_issues` with `parentId` filter
|
||||
3. **Update issue status** when completing: `mcp__linear-server__update_issue`
|
||||
4. **Add completion comment** (REQUIRED): `mcp__linear-server__create_comment`
|
||||
|
||||
## Creating Issues
|
||||
|
||||
When creating issues with `mcp__linear-server__create_issue`, **MUST add the `claude code` label**.
|
||||
|
||||
## Language
|
||||
|
||||
Issue titles, descriptions, and comments **MUST follow the language of the current conversation**, not default to English.
|
||||
|
||||
- Conversation in 中文 → issue body in 中文;technical terms (file paths, identifiers, library names, commands, error messages) stay in English.
|
||||
- Conversation in English → issue body in English.
|
||||
- Code blocks, file paths, and quoted strings always stay in their original form regardless of surrounding language.
|
||||
- This applies equally to **updates** — when editing an existing issue (description **and titles**), preserve the language of the conversation that triggered the edit; do not switch the issue language during a refactor (Chinese → English or vice versa).
|
||||
|
||||
Rationale: the issue is a continuation of the conversation. Forcing English when the discussion is in Chinese creates translation friction for the collaborator who came from that thread.
|
||||
|
||||
## Creating Sub-issue Trees
|
||||
|
||||
When breaking a parent issue into a tree of sub-issues (e.g., task decomposition for LOBE-xxx), follow these rules — they work around real limitations of the Linear MCP tools.
|
||||
|
||||
### 1. ALWAYS prefix titles with an ordering index
|
||||
|
||||
The Linear Sub-issues panel displays children by `sortOrder`, which **defaults to newest-first** (most recently created appears on top). Neither parallel nor serial creation will produce the intended top-to-bottom reading order, and the MCP `save_issue` tool does **not expose a `sortOrder` parameter** — you cannot set order at create time.
|
||||
|
||||
**Workaround**: encode execution order in the title itself:
|
||||
|
||||
```plaintext
|
||||
[1] [db] add schema fields
|
||||
[2] [db] new table + repository
|
||||
[3] [service] business logic layer
|
||||
[4] [api] REST endpoints
|
||||
[4.1] [sdk] client SDK wrapper
|
||||
[4.1.1] [app] consumer integration
|
||||
[4.1.2] [app] UI surface
|
||||
[4.2] [ui] dashboard page
|
||||
```
|
||||
|
||||
Even when the panel shuffles, the reader can mentally reconstruct the dependency graph at a glance. Dotted numbering `[n.m.k]` should mirror the parent-child nesting so the index and the tree agree.
|
||||
|
||||
### 2. Nest sub-issues by logical parent-child, not flat under the root
|
||||
|
||||
Linear supports **unlimited sub-issue depth**. A flat list of 8+ siblings under one root is hard to scan. Group by main-subordinate logic:
|
||||
|
||||
- Core service → its SDK → SDK consumers
|
||||
- Don't create a sibling when a child is more accurate
|
||||
|
||||
Use `parentId: "LOBE-xxxx"` at creation (or `save_issue` to move). Moving an issue's parent does not disturb its `blockedBy` relations.
|
||||
|
||||
### 3. Sub-issue creation order is dictated by `blockedBy`
|
||||
|
||||
`blockedBy` requires the blocker to exist first (you need its LOBE-id). So:
|
||||
|
||||
1. **Topologically sort** the DAG — leaves (no deps) first, roots last
|
||||
2. Create issues with zero deps in the first wave
|
||||
3. Create dependent issues only after collecting the blocker IDs from prior responses
|
||||
4. `blockedBy` is **append-only**; passing it again does not overwrite — safe to re-run
|
||||
|
||||
### 4. Don't waste rounds trying to parallelize
|
||||
|
||||
MCP tool calls in a single message look parallel but execute sequentially on the server, and you still need blocker IDs from earlier responses. Just issue calls in dependency order; optimizing for parallelism gains nothing here.
|
||||
|
||||
### 5. Keep each sub-issue description self-contained
|
||||
|
||||
Each sub-issue should state:
|
||||
|
||||
- Goal (1–2 lines)
|
||||
- Key files to touch
|
||||
- Concrete changes / acceptance criteria
|
||||
- Dependencies (link to blocker issues by `LOBE-xxxx`)
|
||||
- Validation steps
|
||||
|
||||
The implementer may open only the sub-issue, not the parent — don't rely on context that lives only in the parent description.
|
||||
|
||||
## Completion Comment Format
|
||||
|
||||
Every completed issue MUST have a comment summarizing work done:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,110 +0,0 @@
|
||||
# Log `agent-browser` into a local LobeHub dev server
|
||||
|
||||
`agent-browser --headed` on macOS often creates the Chromium window off-screen — the user can't see or interact with it, so manual login inside the agent-browser session fails. Instead of sharing the user's real Chrome profile, copy the **better-auth session cookie** out of a request in DevTools and inject it into the agent-browser session as a Playwright-style state file.
|
||||
|
||||
## When to use
|
||||
|
||||
- You need `agent-browser` to reach an authenticated page on `http://localhost:<port>` (e.g. `localhost:3011`).
|
||||
- The user already has a logged-in tab of the same dev server in their own Chrome.
|
||||
- Spawning a headed Chromium to let the user log in manually is unreliable (window off-screen, no interaction).
|
||||
|
||||
Do **not** use this on production URLs — only local dev. Treat the cookie as a secret: don't paste it into shared logs, PRs, or commit it anywhere.
|
||||
|
||||
## Step 1 — Ask the user to copy the cookie from a Network request, NOT `document.cookie`
|
||||
|
||||
`document.cookie` will not return HttpOnly cookies, which is exactly where better-auth puts its session. Instruct the user:
|
||||
|
||||
1. Open the logged-in tab (`http://localhost:<port>/…`) in their own Chrome.
|
||||
2. `Cmd+Option+I` → **Network** tab.
|
||||
3. Refresh, click any same-origin request (e.g. the top-level document request).
|
||||
4. In the right pane under **Request Headers**, right-click the `Cookie:` line → **Copy value** (or copy the entire header).
|
||||
5. Paste the string into chat.
|
||||
|
||||
You only need the better-auth pieces. Everything else (Clerk, `LOBE_LOCALE`, HMR hash, theme vars) is noise and can stay. The minimum viable set is:
|
||||
|
||||
```
|
||||
better-auth.session_token=<value>; better-auth.state=<value>
|
||||
```
|
||||
|
||||
## Step 2 — Build a Playwright-style state file
|
||||
|
||||
`agent-browser state load` expects Playwright's `storageState` format: a JSON with a `cookies` array and an `origins` array.
|
||||
|
||||
```bash
|
||||
cat > /tmp/mkstate.py << 'PY'
|
||||
import json, sys, time
|
||||
|
||||
# Read the Cookie header from stdin (allows optional "Cookie: " prefix).
|
||||
raw = sys.stdin.read().strip()
|
||||
if raw.lower().startswith("cookie:"):
|
||||
raw = raw.split(":", 1)[1].strip()
|
||||
|
||||
# Keep only better-auth cookies. Extend this set if the app genuinely needs more.
|
||||
WANTED = {"better-auth.session_token", "better-auth.state"}
|
||||
|
||||
cookies = []
|
||||
exp = int(time.time()) + 30 * 24 * 3600 # 30 days
|
||||
for pair in raw.split("; "):
|
||||
if "=" not in pair:
|
||||
continue
|
||||
name, _, value = pair.partition("=")
|
||||
if name not in WANTED:
|
||||
continue
|
||||
cookies.append({
|
||||
"name": name,
|
||||
"value": value,
|
||||
"domain": "localhost",
|
||||
"path": "/",
|
||||
"expires": exp,
|
||||
"httpOnly": False,
|
||||
"secure": False,
|
||||
"sameSite": "Lax",
|
||||
})
|
||||
|
||||
if not cookies:
|
||||
sys.stderr.write("no better-auth cookies found in input\n")
|
||||
sys.exit(1)
|
||||
|
||||
print(json.dumps({"cookies": cookies, "origins": []}, indent=2))
|
||||
PY
|
||||
|
||||
# Feed the copied Cookie header in via env var or heredoc.
|
||||
printf '%s' "$COOKIE_HEADER" | python3 /tmp/mkstate.py > /tmp/state.json
|
||||
```
|
||||
|
||||
**Note on `httpOnly`**: the real cookie in the user's browser is HttpOnly, but `storageState` doesn't enforce the flag on load — it just attaches the value. Storing with `httpOnly: false` is fine for local dev and sidesteps a CDP-context quirk where HttpOnly cookies sometimes fail to attach.
|
||||
|
||||
## Step 3 — Load state and navigate
|
||||
|
||||
```bash
|
||||
SESSION="my-test" # any stable session name
|
||||
|
||||
agent-browser --session "$SESSION" state load /tmp/state.json
|
||||
agent-browser --session "$SESSION" open "http://localhost:3011/"
|
||||
agent-browser --session "$SESSION" get url
|
||||
# Expect NOT /signin?callbackUrl=… — if you still see signin, cookie didn't apply.
|
||||
```
|
||||
|
||||
## Step 4 — Verify
|
||||
|
||||
```bash
|
||||
agent-browser --session "$SESSION" snapshot -i | head -20
|
||||
# Look for the user's avatar/name in the sidebar, or absence of the signin form.
|
||||
```
|
||||
|
||||
## Common failure modes
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
| ----------------------------------------------- | ----------------------------------------------------------------------- | ---------------------------------------------------- |
|
||||
| Still redirects to `/signin` after `state load` | User pasted from `document.cookie` → missed HttpOnly session | Re-pull from Network request Headers, not console |
|
||||
| `state load` reports 0 cookies | Separator wrong, or user pasted URL-decoded value | Keep the raw `Cookie:` header as-is; split on `"; "` |
|
||||
| Login works briefly then expires | `better-auth.session_token` rotated (user logged out / signed in again) | Re-copy and re-load |
|
||||
| Domain mismatch | Use `domain: "localhost"` literally, no leading dot for local dev | — |
|
||||
|
||||
## Scope
|
||||
|
||||
Only covers authenticating an **agent-browser** session into a **local** LobeHub dev server. It does not:
|
||||
|
||||
- Work for production — production cookies are `Secure; HttpOnly; Domain=.lobehub.com` and must be delivered over HTTPS.
|
||||
- Replace real OAuth flows — tests that must exercise the login UI need a real Chromium with `--remote-debugging-port` or a bot account.
|
||||
- Flow cookies back to the user's Chrome — injection is one-way (into agent-browser only).
|
||||
@@ -1,97 +0,0 @@
|
||||
# Discord Bot Testing
|
||||
|
||||
**App name:** `Discord` | **Process name:** `Discord`
|
||||
|
||||
See [osascript-common.md](./osascript-common.md) for shared patterns.
|
||||
|
||||
## Activate & Navigate
|
||||
|
||||
```bash
|
||||
# Activate Discord
|
||||
osascript -e 'tell application "Discord" to activate'
|
||||
sleep 1
|
||||
|
||||
# Open Quick Switcher (Cmd+K) to navigate to a channel
|
||||
osascript -e 'tell application "System Events" to keystroke "k" using command down'
|
||||
sleep 0.5
|
||||
osascript -e 'tell application "System Events" to keystroke "bot-testing"'
|
||||
sleep 1
|
||||
osascript -e 'tell application "System Events" to key code 36' # Enter
|
||||
sleep 2
|
||||
```
|
||||
|
||||
## Send Message to Bot
|
||||
|
||||
```bash
|
||||
# The message input is focused after navigating to a channel
|
||||
# Type a message
|
||||
osascript -e 'tell application "System Events" to keystroke "/hello"'
|
||||
sleep 0.5
|
||||
osascript -e 'tell application "System Events" to key code 36' # Enter
|
||||
```
|
||||
|
||||
## Send Long Message (via clipboard)
|
||||
|
||||
```bash
|
||||
osascript -e '
|
||||
tell application "Discord" to activate
|
||||
delay 0.5
|
||||
set the clipboard to "Write a 3000 word essay about space exploration"
|
||||
tell application "System Events"
|
||||
keystroke "v" using command down
|
||||
delay 0.3
|
||||
key code 36 -- Enter
|
||||
end tell
|
||||
'
|
||||
```
|
||||
|
||||
## Verify Bot Response
|
||||
|
||||
```bash
|
||||
# Wait for bot to respond, then screenshot
|
||||
sleep 10
|
||||
screencapture /tmp/discord-bot-response.png
|
||||
# Read with the Read tool for visual verification
|
||||
```
|
||||
|
||||
## Full Bot Test Example
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
# test-discord-bot.sh — Send message and verify bot response
|
||||
|
||||
# 1. Activate Discord and navigate to channel
|
||||
osascript -e '
|
||||
tell application "Discord" to activate
|
||||
delay 1
|
||||
-- Quick Switcher
|
||||
tell application "System Events" to keystroke "k" using command down
|
||||
delay 0.5
|
||||
tell application "System Events" to keystroke "bot-testing"
|
||||
delay 1
|
||||
tell application "System Events" to key code 36
|
||||
delay 2
|
||||
'
|
||||
|
||||
# 2. Send test message
|
||||
osascript -e '
|
||||
set the clipboard to "!ping"
|
||||
tell application "System Events"
|
||||
keystroke "v" using command down
|
||||
delay 0.3
|
||||
key code 36
|
||||
end tell
|
||||
'
|
||||
|
||||
# 3. Wait for response and capture
|
||||
sleep 5
|
||||
screencapture /tmp/discord-test-result.png
|
||||
echo "Screenshot saved to /tmp/discord-test-result.png"
|
||||
```
|
||||
|
||||
## Script
|
||||
|
||||
```bash
|
||||
./.agents/skills/local-testing/scripts/test-discord-bot.sh "bot-testing" "!ping"
|
||||
./.agents/skills/local-testing/scripts/test-discord-bot.sh "bot-testing" "/ask Tell me a joke" 30
|
||||
```
|
||||
@@ -1,61 +0,0 @@
|
||||
# Lark / 飞书 Bot Testing
|
||||
|
||||
**App name:** `Lark` or `飞书` | **Process name:** `Lark` or `飞书`
|
||||
|
||||
See [osascript-common.md](./osascript-common.md) for shared patterns.
|
||||
|
||||
## Activate & Navigate
|
||||
|
||||
```bash
|
||||
# Activate Lark (auto-detects Lark or 飞书)
|
||||
osascript -e 'tell application "Lark" to activate' 2> /dev/null \
|
||||
|| osascript -e 'tell application "飞书" to activate'
|
||||
sleep 1
|
||||
|
||||
# Quick Switcher / Search (Cmd+K)
|
||||
osascript -e 'tell application "System Events" to keystroke "k" using command down'
|
||||
sleep 0.5
|
||||
osascript -e '
|
||||
set the clipboard to "bot-testing"
|
||||
tell application "System Events"
|
||||
keystroke "v" using command down
|
||||
delay 1.5
|
||||
key code 36 -- Enter
|
||||
end tell
|
||||
'
|
||||
sleep 2
|
||||
```
|
||||
|
||||
## Send Message to Bot
|
||||
|
||||
```bash
|
||||
osascript -e '
|
||||
set the clipboard to "@MyBot help me with this task"
|
||||
tell application "System Events"
|
||||
keystroke "v" using command down
|
||||
delay 0.3
|
||||
key code 36 -- Enter
|
||||
end tell
|
||||
'
|
||||
```
|
||||
|
||||
## Verify Response
|
||||
|
||||
```bash
|
||||
sleep 10
|
||||
screencapture /tmp/lark-bot-response.png
|
||||
```
|
||||
|
||||
## Lark-Specific Notes
|
||||
|
||||
- App name varies: `Lark` (international) vs `飞书` (China mainland) — the script auto-detects
|
||||
- Uses `Cmd+K` for quick search (same as Discord/Slack)
|
||||
- Enter sends message by default
|
||||
- Always use clipboard paste for CJK characters
|
||||
|
||||
## Script
|
||||
|
||||
```bash
|
||||
./.agents/skills/local-testing/scripts/test-lark-bot.sh "bot-testing" "@MyBot hello"
|
||||
./.agents/skills/local-testing/scripts/test-lark-bot.sh "bot-testing" "Help me with this" 30
|
||||
```
|
||||
@@ -1,217 +0,0 @@
|
||||
# osascript Common Patterns
|
||||
|
||||
Shared AppleScript / `osascript` patterns used by all platform bot tests. Read this first, then refer to the per-platform file for app-specific quirks.
|
||||
|
||||
## Core Patterns
|
||||
|
||||
### Activate an App
|
||||
|
||||
```bash
|
||||
osascript -e 'tell application "Discord" to activate'
|
||||
```
|
||||
|
||||
### Type Text
|
||||
|
||||
```bash
|
||||
# Type character by character (reliable, but slow for long text)
|
||||
osascript -e 'tell application "System Events" to keystroke "Hello world"'
|
||||
|
||||
# Press Enter
|
||||
osascript -e 'tell application "System Events" to key code 36'
|
||||
|
||||
# Press Tab
|
||||
osascript -e 'tell application "System Events" to key code 48'
|
||||
|
||||
# Press Escape
|
||||
osascript -e 'tell application "System Events" to key code 53'
|
||||
```
|
||||
|
||||
### Paste from Clipboard (fast, for long text)
|
||||
|
||||
```bash
|
||||
# Set clipboard and paste — much faster than keystroke for long messages
|
||||
osascript -e 'set the clipboard to "Your long message here"'
|
||||
osascript -e 'tell application "System Events" to keystroke "v" using command down'
|
||||
```
|
||||
|
||||
Or in one shot:
|
||||
|
||||
```bash
|
||||
osascript -e '
|
||||
set the clipboard to "Your long message here"
|
||||
tell application "System Events" to keystroke "v" using command down
|
||||
'
|
||||
```
|
||||
|
||||
### Keyboard Shortcuts
|
||||
|
||||
```bash
|
||||
# Cmd+K (quick switcher in Discord/Slack)
|
||||
osascript -e 'tell application "System Events" to keystroke "k" using command down'
|
||||
|
||||
# Cmd+F (search)
|
||||
osascript -e 'tell application "System Events" to keystroke "f" using command down'
|
||||
|
||||
# Cmd+N (new message/chat)
|
||||
osascript -e 'tell application "System Events" to keystroke "n" using command down'
|
||||
|
||||
# Cmd+Shift+K (example: multi-modifier)
|
||||
osascript -e 'tell application "System Events" to keystroke "k" using {command down, shift down}'
|
||||
```
|
||||
|
||||
### Click at Position
|
||||
|
||||
```bash
|
||||
# Click at absolute screen coordinates
|
||||
osascript -e '
|
||||
tell application "System Events"
|
||||
click at {500, 300}
|
||||
end tell
|
||||
'
|
||||
```
|
||||
|
||||
### Get Window Info
|
||||
|
||||
```bash
|
||||
# Get window position and size
|
||||
osascript -e '
|
||||
tell application "System Events"
|
||||
tell process "Discord"
|
||||
get {position, size} of window 1
|
||||
end tell
|
||||
end tell
|
||||
'
|
||||
```
|
||||
|
||||
### Screenshot
|
||||
|
||||
```bash
|
||||
# Full screen
|
||||
screencapture /tmp/screenshot.png
|
||||
|
||||
# Interactive region select
|
||||
screencapture -i /tmp/screenshot.png
|
||||
|
||||
# Specific window (by window ID from CGWindowList)
|
||||
screencapture -l < WINDOW_ID > /tmp/screenshot.png
|
||||
```
|
||||
|
||||
To get window ID for a specific app:
|
||||
|
||||
```bash
|
||||
osascript -e '
|
||||
tell application "System Events"
|
||||
tell process "Discord"
|
||||
get id of window 1
|
||||
end tell
|
||||
end tell
|
||||
'
|
||||
```
|
||||
|
||||
### Read Accessibility Elements
|
||||
|
||||
```bash
|
||||
# Get all UI elements of the frontmost window (can be slow/large)
|
||||
osascript -e '
|
||||
tell application "System Events"
|
||||
tell process "Discord"
|
||||
entire contents of window 1
|
||||
end tell
|
||||
end tell
|
||||
'
|
||||
|
||||
# Get a specific element's value
|
||||
osascript -e '
|
||||
tell application "System Events"
|
||||
tell process "Discord"
|
||||
get value of text field 1 of window 1
|
||||
end tell
|
||||
end tell
|
||||
'
|
||||
```
|
||||
|
||||
> **Warning:** `entire contents` can be extremely slow on complex UIs. Prefer screenshots + `Read` tool for visual verification.
|
||||
|
||||
### Read Screen Text via Clipboard
|
||||
|
||||
For reading the latest message or response from an app:
|
||||
|
||||
```bash
|
||||
# Select all text in the focused area and copy
|
||||
osascript -e '
|
||||
tell application "System Events"
|
||||
keystroke "a" using command down
|
||||
keystroke "c" using command down
|
||||
end tell
|
||||
'
|
||||
sleep 0.5
|
||||
# Read clipboard
|
||||
pbpaste
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common Bot Testing Workflow
|
||||
|
||||
Regardless of platform, the pattern is:
|
||||
|
||||
```bash
|
||||
APP_NAME="Discord" # or "Slack", "Telegram", "微信"
|
||||
CHANNEL="bot-testing"
|
||||
MESSAGE="Hello bot!"
|
||||
WAIT_SECONDS=10
|
||||
|
||||
# 1. Activate
|
||||
osascript -e "tell application \"$APP_NAME\" to activate"
|
||||
sleep 1
|
||||
|
||||
# 2. Navigate to channel/chat (via Quick Switcher or Search)
|
||||
osascript -e 'tell application "System Events" to keystroke "k" using command down'
|
||||
sleep 0.5
|
||||
osascript -e "tell application \"System Events\" to keystroke \"$CHANNEL\""
|
||||
sleep 1
|
||||
osascript -e 'tell application "System Events" to key code 36'
|
||||
sleep 2
|
||||
|
||||
# 3. Send message
|
||||
osascript -e "set the clipboard to \"$MESSAGE\""
|
||||
osascript -e '
|
||||
tell application "System Events"
|
||||
keystroke "v" using command down
|
||||
delay 0.3
|
||||
key code 36
|
||||
end tell
|
||||
'
|
||||
|
||||
# 4. Wait for bot response
|
||||
sleep "$WAIT_SECONDS"
|
||||
|
||||
# 5. Screenshot for verification
|
||||
screencapture /tmp/"${APP_NAME,,}"-bot-test.png
|
||||
echo "Result saved to /tmp/${APP_NAME,,}-bot-test.png"
|
||||
```
|
||||
|
||||
### Tips
|
||||
|
||||
- **Use clipboard paste** (`Cmd+V`) for messages containing special characters or long text — `keystroke` can mangle non-ASCII
|
||||
- **Add `delay`** between actions — apps need time to process UI events
|
||||
- **Screenshot for verification** — use `screencapture` + `Read` tool for visual checks
|
||||
- **Use a dedicated test channel/chat** — avoid polluting real conversations
|
||||
- **Check app name** — some apps have different names in different locales (e.g., `微信` vs `WeChat`)
|
||||
- **Accessibility permissions required** — System Events automation requires granting Accessibility access in System Preferences > Privacy & Security > Accessibility
|
||||
|
||||
---
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Accessibility permission required** — first run will prompt for access; grant it in System Preferences > Privacy & Security > Accessibility for Terminal / iTerm / Claude Code
|
||||
- **`keystroke` is slow for long text** — always use clipboard paste (`Cmd+V`) for messages over \~20 characters
|
||||
- **`keystroke` can mangle non-ASCII** — use clipboard paste for Chinese, emoji, or special characters
|
||||
- **`key code 36` is Enter** — this is the hardware key code, works regardless of keyboard layout
|
||||
- **`entire contents` is extremely slow** — avoid for complex UIs; use screenshots instead
|
||||
- **App name varies by locale** — `微信` vs `WeChat`, `企业微信` vs `WeCom`; handle both
|
||||
- **WeChat Enter sends immediately** — use `Shift+Enter` for newlines within a message
|
||||
- **Rate limiting** — don't send messages too fast; platforms may throttle or flag automated input
|
||||
- **Lark / 飞书 app name varies** — `Lark` (international) vs `飞书` (China mainland); scripts auto-detect
|
||||
- **QQ uses `Cmd+F` for search** — not `Cmd+K` like Discord/Slack/Lark
|
||||
- **Bot response times vary** — AI-powered bots may take 10-60s; use generous sleep values
|
||||
@@ -1,62 +0,0 @@
|
||||
# QQ Bot Testing
|
||||
|
||||
**App name:** `QQ` | **Process name:** `QQ`
|
||||
|
||||
See [osascript-common.md](./osascript-common.md) for shared patterns.
|
||||
|
||||
## Activate & Navigate
|
||||
|
||||
```bash
|
||||
osascript -e 'tell application "QQ" to activate'
|
||||
sleep 1
|
||||
|
||||
# Search for contact/group (Cmd+F)
|
||||
osascript -e '
|
||||
tell application "System Events"
|
||||
keystroke "f" using command down
|
||||
delay 0.8
|
||||
end tell
|
||||
'
|
||||
osascript -e '
|
||||
set the clipboard to "bot-testing"
|
||||
tell application "System Events"
|
||||
keystroke "v" using command down
|
||||
delay 1.5
|
||||
key code 36 -- Enter
|
||||
end tell
|
||||
'
|
||||
sleep 2
|
||||
```
|
||||
|
||||
## Send Message to Bot
|
||||
|
||||
```bash
|
||||
osascript -e '
|
||||
set the clipboard to "Hello bot!"
|
||||
tell application "System Events"
|
||||
keystroke "v" using command down
|
||||
delay 0.3
|
||||
key code 36 -- Enter
|
||||
end tell
|
||||
'
|
||||
```
|
||||
|
||||
## Verify Response
|
||||
|
||||
```bash
|
||||
sleep 10
|
||||
screencapture /tmp/qq-bot-response.png
|
||||
```
|
||||
|
||||
## QQ-Specific Notes
|
||||
|
||||
- Enter sends message by default; Shift+Enter for newlines
|
||||
- Uses `Cmd+F` for search (not `Cmd+K` like Discord/Slack/Lark)
|
||||
- Always use clipboard paste for CJK characters
|
||||
|
||||
## Script
|
||||
|
||||
```bash
|
||||
./.agents/skills/local-testing/scripts/test-qq-bot.sh "bot-testing" "Hello bot" 15
|
||||
./.agents/skills/local-testing/scripts/test-qq-bot.sh "MyBot" "/help" 10
|
||||
```
|
||||
@@ -1,142 +0,0 @@
|
||||
# record-app-screen.sh
|
||||
|
||||
General-purpose screen recording tool for the Electron app. Captures CDP screenshots as video frames and gallery snapshots, then assembles into an MP4 on stop.
|
||||
|
||||
## Why CDP Screenshots Instead of ffmpeg Screen Capture
|
||||
|
||||
- **Works on any screen** — CDP screenshots capture the browser viewport directly, so external monitors, Retina scaling, and window positioning are all handled automatically
|
||||
- **No signal handling issues** — ffmpeg-static (npm) produces corrupt MP4 files when killed (missing moov atom). CDP screenshots avoid this entirely
|
||||
- **Consistent output** — Screenshots are resolution-independent and don't require crop coordinate calculations
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Start recording (Electron must be running with CDP)
|
||||
.agents/skills/local-testing/scripts/record-app-screen.sh start [output_name]
|
||||
|
||||
# Stop recording and assemble video
|
||||
.agents/skills/local-testing/scripts/record-app-screen.sh stop
|
||||
|
||||
# Check if recording is active
|
||||
.agents/skills/local-testing/scripts/record-app-screen.sh status
|
||||
```
|
||||
|
||||
### Arguments
|
||||
|
||||
| Argument | Default | Description |
|
||||
| ------------- | --------------------------- | -------------------------- |
|
||||
| `output_name` | `recording-YYYYMMDD-HHMMSS` | Base name for output files |
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
| ---------------------- | ------- | -------------------------------------- |
|
||||
| `CDP_PORT` | `9222` | Chrome DevTools Protocol port |
|
||||
| `SCREENSHOT_INTERVAL` | `3` | Seconds between gallery screenshots |
|
||||
| `VIDEO_FRAME_INTERVAL` | `0.5` | Seconds between video frames (\~2 fps) |
|
||||
|
||||
## Output Structure
|
||||
|
||||
```
|
||||
.records/
|
||||
<name>.mp4 # Video assembled from frames (~2 fps)
|
||||
<name>/ # Gallery screenshots (every 3s)
|
||||
0000.png
|
||||
0001.png
|
||||
0002.png
|
||||
...
|
||||
```
|
||||
|
||||
The `.records/` directory is at the project root and is gitignored.
|
||||
|
||||
## How It Works
|
||||
|
||||
### Start
|
||||
|
||||
1. Creates two background loops:
|
||||
- **Video frames** — `agent-browser screenshot` every `VIDEO_FRAME_INTERVAL` seconds into a temp directory (`/tmp/record-frames-XXXXXX/`)
|
||||
- **Gallery screenshots** — `agent-browser screenshot` every `SCREENSHOT_INTERVAL` seconds into `.records/<name>/`
|
||||
2. Saves PIDs and paths to `/tmp/record-app-screen.pids` and `/tmp/record-app-screen.state`
|
||||
|
||||
### Stop
|
||||
|
||||
1. Kills both background loops
|
||||
2. Assembles video frames into MP4 using ffmpeg:
|
||||
```
|
||||
ffmpeg -framerate 2 -i frame_%06d.png -c:v libx264 -crf 23 -pix_fmt yuv420p <output>.mp4
|
||||
```
|
||||
3. Cleans up temp frame directory
|
||||
4. Reports file sizes and paths
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic Test Recording
|
||||
|
||||
```bash
|
||||
# Start Electron
|
||||
.agents/skills/local-testing/scripts/electron-dev.sh start
|
||||
|
||||
# Start recording
|
||||
.agents/skills/local-testing/scripts/record-app-screen.sh start my-test
|
||||
|
||||
# Run automation
|
||||
agent-browser --cdp 9222 click @e61
|
||||
agent-browser --cdp 9222 type @e42 "hello"
|
||||
agent-browser --cdp 9222 press Enter
|
||||
sleep 10
|
||||
|
||||
# Stop and get results
|
||||
.agents/skills/local-testing/scripts/record-app-screen.sh stop
|
||||
# → .records/my-test.mp4 + .records/my-test/*.png
|
||||
```
|
||||
|
||||
### Gateway Streaming Demo
|
||||
|
||||
```bash
|
||||
.agents/skills/local-testing/scripts/electron-dev.sh start
|
||||
|
||||
# Inject gateway URL
|
||||
agent-browser --cdp 9222 eval --stdin << 'EOF'
|
||||
(function() {
|
||||
var store = window.global_serverConfigStore;
|
||||
store.setState({ serverConfig: { ...store.getState().serverConfig,
|
||||
agentGatewayUrl: 'https://agent-gateway.lobehub.com' } });
|
||||
return 'ready';
|
||||
})()
|
||||
EOF
|
||||
|
||||
# Record
|
||||
.agents/skills/local-testing/scripts/record-app-screen.sh start gateway-demo
|
||||
|
||||
# Navigate to agent, send message, wait for completion...
|
||||
# (automation commands here)
|
||||
|
||||
.agents/skills/local-testing/scripts/record-app-screen.sh stop
|
||||
open .records/gateway-demo.mp4
|
||||
```
|
||||
|
||||
### Check Active Recording
|
||||
|
||||
```bash
|
||||
.agents/skills/local-testing/scripts/record-app-screen.sh status
|
||||
# [record] Active recording
|
||||
# Frames: 42 captured (running: yes)
|
||||
# Screenshots: 14 captured (running: yes)
|
||||
# Output: .records/my-test.mp4
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **ffmpeg** — For video assembly. Install via `bun add -g ffmpeg-static` or `brew install ffmpeg`
|
||||
- **agent-browser** — For CDP screenshots. Install via `npm i -g agent-browser`
|
||||
- **Electron app running** — With CDP enabled (use `electron-dev.sh start`)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Problem | Solution |
|
||||
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------ |
|
||||
| "No active recording found" on stop | PID file was cleaned up. Check if background processes are still running with `ps aux \| grep agent-browser` |
|
||||
| "A recording is already active" | Run `stop` first, or manually clean: `rm /tmp/record-app-screen.pids /tmp/record-app-screen.state` |
|
||||
| Video is 0 bytes | No frames were captured. Ensure Electron is running and CDP port is correct |
|
||||
| Screenshots are blank/white | SPA may not have loaded yet. Wait for `electron-dev.sh` to report "Renderer ready" |
|
||||
| ffmpeg assembly fails | Check `/tmp/ffmpeg-assemble.log`. Ensure ffmpeg is installed and frames exist |
|
||||
@@ -1,73 +0,0 @@
|
||||
# Slack Bot Testing
|
||||
|
||||
**App name:** `Slack` | **Process name:** `Slack`
|
||||
|
||||
See [osascript-common.md](./osascript-common.md) for shared patterns.
|
||||
|
||||
## Activate & Navigate
|
||||
|
||||
```bash
|
||||
# Activate Slack
|
||||
osascript -e 'tell application "Slack" to activate'
|
||||
sleep 1
|
||||
|
||||
# Quick Switcher (Cmd+K)
|
||||
osascript -e 'tell application "System Events" to keystroke "k" using command down'
|
||||
sleep 0.5
|
||||
osascript -e 'tell application "System Events" to keystroke "bot-testing"'
|
||||
sleep 1
|
||||
osascript -e 'tell application "System Events" to key code 36' # Enter
|
||||
sleep 2
|
||||
```
|
||||
|
||||
## Send Message to Bot
|
||||
|
||||
```bash
|
||||
# Direct message input (focused after channel nav)
|
||||
osascript -e 'tell application "System Events" to keystroke "@mybot hello"'
|
||||
sleep 0.3
|
||||
osascript -e 'tell application "System Events" to key code 36'
|
||||
```
|
||||
|
||||
## Send Long Message
|
||||
|
||||
```bash
|
||||
osascript -e '
|
||||
tell application "Slack" to activate
|
||||
delay 0.5
|
||||
set the clipboard to "A long test message for the bot..."
|
||||
tell application "System Events"
|
||||
keystroke "v" using command down
|
||||
delay 0.3
|
||||
key code 36
|
||||
end tell
|
||||
'
|
||||
```
|
||||
|
||||
## Slash Command Test
|
||||
|
||||
```bash
|
||||
osascript -e '
|
||||
tell application "Slack" to activate
|
||||
delay 0.5
|
||||
tell application "System Events"
|
||||
keystroke "/ask What is the meaning of life?"
|
||||
delay 0.5
|
||||
key code 36
|
||||
end tell
|
||||
'
|
||||
```
|
||||
|
||||
## Verify Response
|
||||
|
||||
```bash
|
||||
sleep 10
|
||||
screencapture /tmp/slack-bot-response.png
|
||||
```
|
||||
|
||||
## Script
|
||||
|
||||
```bash
|
||||
./.agents/skills/local-testing/scripts/test-slack-bot.sh "bot-testing" "@mybot hello"
|
||||
./.agents/skills/local-testing/scripts/test-slack-bot.sh "bot-testing" "/ask What is 2+2?" 20
|
||||
```
|
||||
@@ -1,80 +0,0 @@
|
||||
# Telegram Bot Testing
|
||||
|
||||
**App name:** `Telegram` | **Process name:** `Telegram`
|
||||
|
||||
See [osascript-common.md](./osascript-common.md) for shared patterns.
|
||||
|
||||
## Activate & Navigate
|
||||
|
||||
```bash
|
||||
# Activate Telegram
|
||||
osascript -e 'tell application "Telegram" to activate'
|
||||
sleep 1
|
||||
|
||||
# Search for a bot (Cmd+F or click search)
|
||||
osascript -e '
|
||||
tell application "System Events"
|
||||
keystroke "f" using command down
|
||||
delay 0.5
|
||||
keystroke "MyTestBot"
|
||||
delay 1
|
||||
key code 36 -- Enter to select
|
||||
end tell
|
||||
'
|
||||
sleep 2
|
||||
```
|
||||
|
||||
## Send Message to Bot
|
||||
|
||||
```bash
|
||||
# After navigating to bot chat, input is focused
|
||||
osascript -e '
|
||||
tell application "System Events"
|
||||
keystroke "/start"
|
||||
delay 0.3
|
||||
key code 36
|
||||
end tell
|
||||
'
|
||||
```
|
||||
|
||||
## Send Long Message
|
||||
|
||||
```bash
|
||||
osascript -e '
|
||||
tell application "Telegram" to activate
|
||||
delay 0.5
|
||||
set the clipboard to "Tell me about quantum computing in detail"
|
||||
tell application "System Events"
|
||||
keystroke "v" using command down
|
||||
delay 0.3
|
||||
key code 36
|
||||
end tell
|
||||
'
|
||||
```
|
||||
|
||||
## Verify Response
|
||||
|
||||
```bash
|
||||
sleep 10
|
||||
screencapture /tmp/telegram-bot-response.png
|
||||
```
|
||||
|
||||
## Telegram Bot API (programmatic alternative)
|
||||
|
||||
For sending messages directly to the bot's chat without UI:
|
||||
|
||||
```bash
|
||||
# Send message as the bot (for testing webhooks/responses)
|
||||
curl -s "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/sendMessage" \
|
||||
-d "chat_id=$CHAT_ID&text=test message"
|
||||
|
||||
# Get recent updates
|
||||
curl -s "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/getUpdates?limit=5" | jq .
|
||||
```
|
||||
|
||||
## Script
|
||||
|
||||
```bash
|
||||
./.agents/skills/local-testing/scripts/test-telegram-bot.sh "MyTestBot" "/start"
|
||||
./.agents/skills/local-testing/scripts/test-telegram-bot.sh "GPTBot" "Hello" 60
|
||||
```
|
||||
@@ -1,81 +0,0 @@
|
||||
# WeChat / 微信 Bot Testing
|
||||
|
||||
**App name:** `微信` or `WeChat` | **Process name:** `WeChat`
|
||||
|
||||
See [osascript-common.md](./osascript-common.md) for shared patterns.
|
||||
|
||||
## Activate & Navigate
|
||||
|
||||
```bash
|
||||
# Activate WeChat
|
||||
osascript -e 'tell application "微信" to activate'
|
||||
sleep 1
|
||||
|
||||
# Search for a contact/bot (Cmd+F)
|
||||
osascript -e '
|
||||
tell application "System Events"
|
||||
keystroke "f" using command down
|
||||
delay 0.5
|
||||
keystroke "TestBot"
|
||||
delay 1
|
||||
key code 36 -- Enter to select
|
||||
end tell
|
||||
'
|
||||
sleep 2
|
||||
```
|
||||
|
||||
## Send Message
|
||||
|
||||
```bash
|
||||
# After navigating to a chat, the input is focused
|
||||
osascript -e '
|
||||
tell application "System Events"
|
||||
keystroke "Hello bot!"
|
||||
delay 0.3
|
||||
key code 36
|
||||
end tell
|
||||
'
|
||||
```
|
||||
|
||||
## Send Long Message (clipboard)
|
||||
|
||||
```bash
|
||||
osascript -e '
|
||||
tell application "微信" to activate
|
||||
delay 0.5
|
||||
set the clipboard to "Please help me with this task..."
|
||||
tell application "System Events"
|
||||
keystroke "v" using command down
|
||||
delay 0.3
|
||||
key code 36
|
||||
end tell
|
||||
'
|
||||
```
|
||||
|
||||
## Verify Response
|
||||
|
||||
```bash
|
||||
sleep 10
|
||||
screencapture /tmp/wechat-bot-response.png
|
||||
```
|
||||
|
||||
## WeChat-Specific Notes
|
||||
|
||||
- WeChat macOS app name can be `微信` or `WeChat` depending on system language. Try both:
|
||||
```bash
|
||||
osascript -e 'tell application "微信" to activate' 2> /dev/null \
|
||||
|| osascript -e 'tell application "WeChat" to activate'
|
||||
```
|
||||
- WeChat uses **Enter** to send (not Cmd+Enter by default, but configurable)
|
||||
- For multi-line messages without sending, use **Shift+Enter**:
|
||||
```bash
|
||||
osascript -e 'tell application "System Events" to key code 36 using shift down'
|
||||
```
|
||||
- Always use clipboard paste for CJK characters — `keystroke` mangles non-ASCII
|
||||
|
||||
## Script
|
||||
|
||||
```bash
|
||||
./.agents/skills/local-testing/scripts/test-wechat-bot.sh "文件传输助手" "test message" 5
|
||||
./.agents/skills/local-testing/scripts/test-wechat-bot.sh "MyBot" "Tell me a joke" 30
|
||||
```
|
||||
@@ -1,244 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# electron-dev.sh — Manage Electron dev environment for testing
|
||||
#
|
||||
# Usage:
|
||||
# ./electron-dev.sh start # Kill existing, start fresh, wait until ready
|
||||
# ./electron-dev.sh stop # Kill all Electron-related processes
|
||||
# ./electron-dev.sh status # Check if Electron is running and CDP is reachable
|
||||
# ./electron-dev.sh restart # Stop then start
|
||||
#
|
||||
# Environment variables:
|
||||
# CDP_PORT — Chrome DevTools Protocol port (default: 9222)
|
||||
# ELECTRON_LOG — Log file path (default: /tmp/electron-dev.log)
|
||||
# ELECTRON_WAIT_S — Max seconds to wait for Electron process (default: 60)
|
||||
# RENDERER_WAIT_S — Max seconds to wait for renderer/SPA (default: 60)
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
CDP_PORT="${CDP_PORT:-9222}"
|
||||
ELECTRON_LOG="${ELECTRON_LOG:-/tmp/electron-dev.log}"
|
||||
ELECTRON_WAIT_S="${ELECTRON_WAIT_S:-60}"
|
||||
RENDERER_WAIT_S="${RENDERER_WAIT_S:-60}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
|
||||
PIDFILE="/tmp/electron-dev-cdp-${CDP_PORT}.pid"
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
# Get the Electron binary path used by this project
|
||||
electron_bin_pattern() {
|
||||
echo "${PROJECT_ROOT}/apps/desktop/node_modules/.pnpm/electron@*/node_modules/electron/dist/Electron.app"
|
||||
}
|
||||
|
||||
# Find all PIDs related to the project's Electron dev session
|
||||
find_electron_pids() {
|
||||
local pids=""
|
||||
|
||||
# 1. Main Electron process (launched with --remote-debugging-port)
|
||||
local main_pids
|
||||
main_pids=$(pgrep -f "Electron\.app.*--remote-debugging-port=${CDP_PORT}" 2>/dev/null || true)
|
||||
[ -n "$main_pids" ] && pids="$pids $main_pids"
|
||||
|
||||
# 2. Electron Helper processes (gpu, renderer, utility) spawned from the project's electron binary
|
||||
local helper_pids
|
||||
helper_pids=$(pgrep -f "${PROJECT_ROOT}/apps/desktop/node_modules/.*Electron Helper" 2>/dev/null || true)
|
||||
[ -n "$helper_pids" ] && pids="$pids $helper_pids"
|
||||
|
||||
# 3. electron-vite dev server
|
||||
local vite_pids
|
||||
vite_pids=$(pgrep -f "electron-vite.*dev" 2>/dev/null || true)
|
||||
[ -n "$vite_pids" ] && pids="$pids $vite_pids"
|
||||
|
||||
# 4. PID from pidfile (fallback)
|
||||
if [ -f "$PIDFILE" ]; then
|
||||
local saved_pid
|
||||
saved_pid=$(cat "$PIDFILE")
|
||||
if kill -0 "$saved_pid" 2>/dev/null; then
|
||||
pids="$pids $saved_pid"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Deduplicate
|
||||
echo "$pids" | tr ' ' '\n' | sort -u | grep -v '^$' | tr '\n' ' ' || true
|
||||
}
|
||||
|
||||
do_stop() {
|
||||
echo "[electron-dev] Stopping Electron dev environment..."
|
||||
|
||||
local pids
|
||||
pids=$(find_electron_pids)
|
||||
|
||||
if [ -z "$pids" ]; then
|
||||
echo "[electron-dev] No Electron processes found."
|
||||
else
|
||||
echo "[electron-dev] Killing PIDs: $pids"
|
||||
for pid in $pids; do
|
||||
kill "$pid" 2>/dev/null || true
|
||||
done
|
||||
|
||||
# Wait up to 5s for graceful exit, then force-kill survivors
|
||||
local waited=0
|
||||
while [ $waited -lt 5 ]; do
|
||||
local alive=""
|
||||
for pid in $pids; do
|
||||
kill -0 "$pid" 2>/dev/null && alive="$alive $pid"
|
||||
done
|
||||
[ -z "$alive" ] && break
|
||||
sleep 1
|
||||
waited=$((waited + 1))
|
||||
done
|
||||
|
||||
# Force-kill any remaining
|
||||
for pid in $pids; do
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
echo "[electron-dev] Force-killing PID $pid"
|
||||
kill -9 "$pid" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# Also close any agent-browser sessions connected to this port
|
||||
agent-browser --cdp "$CDP_PORT" close --all 2>/dev/null || true
|
||||
|
||||
rm -f "$PIDFILE"
|
||||
echo "[electron-dev] Stopped."
|
||||
}
|
||||
|
||||
do_status() {
|
||||
local pids
|
||||
pids=$(find_electron_pids)
|
||||
|
||||
if [ -z "$pids" ]; then
|
||||
echo "[electron-dev] Electron is NOT running."
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "[electron-dev] Electron is running (PIDs: $pids)"
|
||||
|
||||
# Check CDP connectivity
|
||||
if agent-browser --cdp "$CDP_PORT" get url >/dev/null 2>&1; then
|
||||
local url
|
||||
url=$(agent-browser --cdp "$CDP_PORT" get url 2>&1 | tail -1)
|
||||
echo "[electron-dev] CDP port ${CDP_PORT} is reachable. URL: $url"
|
||||
return 0
|
||||
else
|
||||
echo "[electron-dev] CDP port ${CDP_PORT} is NOT reachable (Electron may still be loading)."
|
||||
return 2
|
||||
fi
|
||||
}
|
||||
|
||||
wait_for_electron() {
|
||||
echo "[electron-dev] Waiting for Electron process (up to ${ELECTRON_WAIT_S}s)..."
|
||||
local elapsed=0
|
||||
local interval=3
|
||||
while [ $elapsed -lt "$ELECTRON_WAIT_S" ]; do
|
||||
if strings "$ELECTRON_LOG" 2>/dev/null | grep -q "starting electron"; then
|
||||
echo "[electron-dev] Electron process started."
|
||||
return 0
|
||||
fi
|
||||
sleep "$interval"
|
||||
elapsed=$((elapsed + interval))
|
||||
echo "[electron-dev] Still waiting... (${elapsed}/${ELECTRON_WAIT_S}s)"
|
||||
done
|
||||
echo "[electron-dev] ERROR: Electron did not start within ${ELECTRON_WAIT_S}s"
|
||||
echo "[electron-dev] Last 20 lines of log:"
|
||||
tail -20 "$ELECTRON_LOG" 2>/dev/null || true
|
||||
return 1
|
||||
}
|
||||
|
||||
wait_for_renderer() {
|
||||
echo "[electron-dev] Waiting for renderer/SPA to load (up to ${RENDERER_WAIT_S}s)..."
|
||||
|
||||
# Initial delay — renderer needs time to bootstrap
|
||||
sleep 10
|
||||
|
||||
local elapsed=10
|
||||
local interval=5
|
||||
while [ $elapsed -lt "$RENDERER_WAIT_S" ]; do
|
||||
if agent-browser --cdp "$CDP_PORT" wait 2000 >/dev/null 2>&1; then
|
||||
# Check if interactive elements are present (SPA loaded)
|
||||
local snap
|
||||
snap=$(agent-browser --cdp "$CDP_PORT" snapshot -i 2>&1 || true)
|
||||
if echo "$snap" | grep -qE 'link |button '; then
|
||||
echo "[electron-dev] Renderer ready (interactive elements found)."
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
sleep "$interval"
|
||||
elapsed=$((elapsed + interval))
|
||||
echo "[electron-dev] SPA still loading... (${elapsed}/${RENDERER_WAIT_S}s)"
|
||||
done
|
||||
|
||||
echo "[electron-dev] WARNING: Timed out waiting for renderer, proceeding anyway."
|
||||
return 0
|
||||
}
|
||||
|
||||
do_start() {
|
||||
# If already running and healthy, skip
|
||||
local status_ok=0
|
||||
do_status >/dev/null 2>&1 || status_ok=$?
|
||||
if [ "$status_ok" -eq 0 ]; then
|
||||
echo "[electron-dev] Electron is already running and CDP is reachable. Skipping start."
|
||||
echo "[electron-dev] Use 'restart' to force a fresh session, or 'stop' to tear down."
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Clean up any stale processes
|
||||
do_stop
|
||||
|
||||
# Start fresh
|
||||
echo "[electron-dev] Starting Electron dev server..."
|
||||
echo "[electron-dev] Project: $PROJECT_ROOT"
|
||||
echo "[electron-dev] CDP port: $CDP_PORT"
|
||||
echo "[electron-dev] Log: $ELECTRON_LOG"
|
||||
|
||||
: > "$ELECTRON_LOG" # Truncate log
|
||||
|
||||
(
|
||||
cd "$PROJECT_ROOT/apps/desktop" && \
|
||||
ELECTRON_ENABLE_LOGGING=1 npx electron-vite dev -- --remote-debugging-port="$CDP_PORT" \
|
||||
>> "$ELECTRON_LOG" 2>&1
|
||||
) &
|
||||
local bg_pid=$!
|
||||
echo "$bg_pid" > "$PIDFILE"
|
||||
echo "[electron-dev] Background PID: $bg_pid"
|
||||
|
||||
# Wait for Electron process to start
|
||||
if ! wait_for_electron; then
|
||||
echo "[electron-dev] Failed to start. Cleaning up..."
|
||||
do_stop
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Wait for renderer to be interactive
|
||||
if ! wait_for_renderer; then
|
||||
echo "[electron-dev] Renderer not ready, but Electron is running. You may need to wait more."
|
||||
fi
|
||||
|
||||
echo "[electron-dev] Ready! Use: agent-browser --cdp $CDP_PORT snapshot -i"
|
||||
}
|
||||
|
||||
do_restart() {
|
||||
do_stop
|
||||
sleep 2
|
||||
do_start
|
||||
}
|
||||
|
||||
# ── Main ─────────────────────────────────────────────────────────────
|
||||
|
||||
case "${1:-help}" in
|
||||
start) do_start ;;
|
||||
stop) do_stop ;;
|
||||
status) do_status ;;
|
||||
restart) do_restart ;;
|
||||
*)
|
||||
echo "Usage: $0 {start|stop|status|restart}"
|
||||
echo ""
|
||||
echo " start — Start Electron dev with CDP (idempotent, skips if already running)"
|
||||
echo " stop — Kill all Electron dev processes (main + helpers + vite)"
|
||||
echo " status — Check if Electron is running and CDP is reachable"
|
||||
echo " restart — Stop then start"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -1,189 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# record-app-screen.sh — Record the Electron app window (video + screenshots)
|
||||
#
|
||||
# Captures screenshots via agent-browser (CDP), then assembles into video on stop.
|
||||
# Works on any screen (including external monitors) since it uses CDP, not screen capture.
|
||||
#
|
||||
# Usage:
|
||||
# ./record-app-screen.sh start [output_name] # Begin recording
|
||||
# ./record-app-screen.sh stop # Stop and save
|
||||
# ./record-app-screen.sh status # Check recording state
|
||||
#
|
||||
# Outputs to .records/ directory:
|
||||
# .records/<name>.mp4 — Video assembled from screenshots (~2 fps)
|
||||
# .records/<name>/ — Screenshots every SCREENSHOT_INTERVAL seconds
|
||||
#
|
||||
# Prerequisites:
|
||||
# - ffmpeg installed (bun add -g ffmpeg-static, or brew install ffmpeg)
|
||||
# - agent-browser CLI installed
|
||||
# - Electron app already running with CDP enabled
|
||||
#
|
||||
# Environment variables:
|
||||
# CDP_PORT — Chrome DevTools Protocol port (default: 9222)
|
||||
# SCREENSHOT_INTERVAL — Seconds between gallery screenshots (default: 3)
|
||||
# VIDEO_FRAME_INTERVAL — Seconds between video frames (default: 0.5)
|
||||
#
|
||||
# Examples:
|
||||
# ./electron-dev.sh start
|
||||
# ./record-app-screen.sh start gateway-demo
|
||||
# # ... run automation via agent-browser ...
|
||||
# ./record-app-screen.sh stop
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_DIR="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
|
||||
|
||||
RECORDS_DIR="$PROJECT_DIR/.records"
|
||||
PID_FILE="/tmp/record-app-screen.pids"
|
||||
STATE_FILE="/tmp/record-app-screen.state"
|
||||
|
||||
CDP_PORT="${CDP_PORT:-9222}"
|
||||
SCREENSHOT_INTERVAL="${SCREENSHOT_INTERVAL:-3}"
|
||||
VIDEO_FRAME_INTERVAL="${VIDEO_FRAME_INTERVAL:-0.5}"
|
||||
|
||||
AB="agent-browser --cdp $CDP_PORT"
|
||||
|
||||
# ─── Commands ───
|
||||
|
||||
cmd_start() {
|
||||
local output_name="${1:-recording-$(date +%Y%m%d-%H%M%S)}"
|
||||
local output_video="$RECORDS_DIR/${output_name}.mp4"
|
||||
local screenshot_dir="$RECORDS_DIR/${output_name}"
|
||||
local frames_dir
|
||||
frames_dir=$(mktemp -d /tmp/record-frames-XXXXXX)
|
||||
|
||||
if [ -f "$PID_FILE" ]; then
|
||||
echo "[record] A recording is already active. Run '$0 stop' first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$RECORDS_DIR" "$screenshot_dir"
|
||||
|
||||
# Video frames loop (~2 fps via agent-browser CDP screenshots)
|
||||
(
|
||||
local idx=0
|
||||
while true; do
|
||||
local fname
|
||||
fname=$(printf "%s/frame_%06d.png" "$frames_dir" "$idx")
|
||||
$AB screenshot "$fname" 2>/dev/null || true
|
||||
idx=$((idx + 1))
|
||||
sleep "$VIDEO_FRAME_INTERVAL"
|
||||
done
|
||||
) &
|
||||
local frames_pid=$!
|
||||
|
||||
# Gallery screenshots loop (every N seconds for human review)
|
||||
(
|
||||
local idx=0
|
||||
while true; do
|
||||
local fname
|
||||
fname=$(printf "%s/%04d.png" "$screenshot_dir" "$idx")
|
||||
$AB screenshot "$fname" 2>/dev/null || true
|
||||
idx=$((idx + 1))
|
||||
sleep "$SCREENSHOT_INTERVAL"
|
||||
done
|
||||
) &
|
||||
local screenshot_pid=$!
|
||||
|
||||
# Save state
|
||||
echo "$frames_pid $screenshot_pid" > "$PID_FILE"
|
||||
echo "$output_video $frames_dir $screenshot_dir" > "$STATE_FILE"
|
||||
|
||||
echo "[record] Started!"
|
||||
echo " Video frames: every ${VIDEO_FRAME_INTERVAL}s (PID $frames_pid)"
|
||||
echo " Screenshots: every ${SCREENSHOT_INTERVAL}s → $screenshot_dir/"
|
||||
echo " Stop with: $0 stop"
|
||||
}
|
||||
|
||||
cmd_stop() {
|
||||
if [ ! -f "$PID_FILE" ] || [ ! -f "$STATE_FILE" ]; then
|
||||
echo "[record] No active recording found."
|
||||
return 0
|
||||
fi
|
||||
|
||||
local frames_pid screenshot_pid
|
||||
read -r frames_pid screenshot_pid < "$PID_FILE"
|
||||
|
||||
local output_video frames_dir screenshot_dir
|
||||
read -r output_video frames_dir screenshot_dir < "$STATE_FILE"
|
||||
|
||||
# Stop both capture loops
|
||||
kill "$frames_pid" 2>/dev/null || true
|
||||
kill "$screenshot_pid" 2>/dev/null || true
|
||||
wait "$frames_pid" 2>/dev/null || true
|
||||
wait "$screenshot_pid" 2>/dev/null || true
|
||||
|
||||
# Assemble frames into video
|
||||
local frame_count
|
||||
frame_count=$(ls -1 "$frames_dir"/frame_*.png 2>/dev/null | wc -l | tr -d ' ')
|
||||
|
||||
if [ "$frame_count" -gt 0 ]; then
|
||||
echo "[record] Assembling $frame_count frames into video..."
|
||||
ffmpeg -y -framerate 2 -i "$frames_dir/frame_%06d.png" \
|
||||
-c:v libx264 -crf 23 -pix_fmt yuv420p -an \
|
||||
"$output_video" > /tmp/ffmpeg-assemble.log 2>&1
|
||||
|
||||
if [ ! -s "$output_video" ]; then
|
||||
echo " [warn] Video assembly failed. Check /tmp/ffmpeg-assemble.log"
|
||||
echo " Frames preserved in: $frames_dir/"
|
||||
fi
|
||||
else
|
||||
echo " [warn] No frames captured."
|
||||
fi
|
||||
|
||||
rm -rf "$frames_dir" 2>/dev/null
|
||||
rm -f "$PID_FILE" "$STATE_FILE"
|
||||
|
||||
local video_size screenshot_count
|
||||
video_size=$(ls -lh "$output_video" 2>/dev/null | awk '{print $5}' || echo "?")
|
||||
screenshot_count=$(ls -1 "$screenshot_dir"/*.png 2>/dev/null | wc -l | tr -d ' ' || echo "0")
|
||||
|
||||
echo "[record] Stopped!"
|
||||
echo " Video: $output_video ($video_size)"
|
||||
echo " Screenshots: ${screenshot_count} files in $screenshot_dir/"
|
||||
echo " Play: open $output_video"
|
||||
}
|
||||
|
||||
cmd_status() {
|
||||
if [ ! -f "$PID_FILE" ]; then
|
||||
echo "[record] No active recording."
|
||||
return 0
|
||||
fi
|
||||
|
||||
local frames_pid screenshot_pid
|
||||
read -r frames_pid screenshot_pid < "$PID_FILE"
|
||||
|
||||
local frames_ok="no" screenshot_ok="no"
|
||||
kill -0 "$frames_pid" 2>/dev/null && frames_ok="yes"
|
||||
kill -0 "$screenshot_pid" 2>/dev/null && screenshot_ok="yes"
|
||||
|
||||
if [ -f "$STATE_FILE" ]; then
|
||||
local output_video frames_dir screenshot_dir
|
||||
read -r output_video frames_dir screenshot_dir < "$STATE_FILE"
|
||||
local frame_count ss_count
|
||||
frame_count=$(ls -1 "$frames_dir"/frame_*.png 2>/dev/null | wc -l | tr -d ' ' || echo "0")
|
||||
ss_count=$(ls -1 "$screenshot_dir"/*.png 2>/dev/null | wc -l | tr -d ' ' || echo "0")
|
||||
echo "[record] Active recording"
|
||||
echo " Frames: $frame_count captured (running: $frames_ok)"
|
||||
echo " Screenshots: $ss_count captured (running: $screenshot_ok)"
|
||||
echo " Output: $output_video"
|
||||
fi
|
||||
}
|
||||
|
||||
# ─── Main ───
|
||||
|
||||
case "${1:-}" in
|
||||
start) shift; cmd_start "$@" ;;
|
||||
stop) cmd_stop ;;
|
||||
status) cmd_status ;;
|
||||
*)
|
||||
echo "Usage: $0 {start [name] | stop | status}"
|
||||
echo ""
|
||||
echo " start [name] Start recording (default: recording-YYYYMMDD-HHMMSS)"
|
||||
echo " stop Stop recording and save outputs"
|
||||
echo " status Check if recording is active"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -1,76 +1,64 @@
|
||||
---
|
||||
name: modal
|
||||
description: MUST use when creating, editing, or writing modal dialogs or imperative modals. Prefer createModal / useModalContext / confirmModal from @lobehub/ui/base-ui; root @lobehub/ui is legacy (antd Modal). Covers patterns, ModalHost, and migration notes.
|
||||
description: Modal imperative API guide. Use when creating modal dialogs using createModal from @lobehub/ui. Triggers on modal component implementation or dialog creation tasks.
|
||||
user-invocable: false
|
||||
---
|
||||
|
||||
# Modal Imperative API Guide
|
||||
|
||||
## Recommended: `@lobehub/ui/base-ui`
|
||||
Use `createModal` from `@lobehub/ui` for imperative modal dialogs.
|
||||
|
||||
New code should use the **base-ui** modal stack (headless primitives, not antd `Modal`):
|
||||
## Why Imperative?
|
||||
|
||||
- `createModal`, `confirmModal`, `ModalHost` from `@lobehub/ui/base-ui`
|
||||
- `useModalContext` from `@lobehub/ui/base-ui` inside modal **content**
|
||||
| Mode | Characteristics | Recommended |
|
||||
| ----------- | ------------------------------------- | ----------- |
|
||||
| Declarative | Need `open` state, render `<Modal />` | ❌ |
|
||||
| Imperative | Call function directly, no state | ✅ |
|
||||
|
||||
Body slot: pass **`content`** (or `children`; runtime uses `content ?? children`).
|
||||
|
||||
### Global `ModalHost` (required)
|
||||
|
||||
Base-ui `createModal` renders through a **separate** host from the root package. The app must mount **`ModalHost`** from `@lobehub/ui/base-ui` once near the root (e.g. next to other global hosts). Without it, `createModal` calls will not appear.
|
||||
|
||||
If the project only mounts `ModalHost` from `@lobehub/ui`, add a second lazy `ModalHost` from `@lobehub/ui/base-ui` until all imperative modals are migrated.
|
||||
|
||||
### Why imperative?
|
||||
|
||||
| Mode | Characteristics | Recommended |
|
||||
| ----------- | ------------------------------------ | ----------- |
|
||||
| Declarative | `open` state + `<Modal />` | ❌ |
|
||||
| Imperative | Call `createModal()`, no local state | ✅ |
|
||||
|
||||
### File structure
|
||||
## File Structure
|
||||
|
||||
```
|
||||
features/
|
||||
└── MyFeatureModal/
|
||||
├── index.tsx # export createXxxModal
|
||||
└── MyFeatureContent.tsx # modal body
|
||||
├── index.tsx # Export createXxxModal
|
||||
└── MyFeatureContent.tsx # Modal content
|
||||
```
|
||||
|
||||
### 1. Content (`MyFeatureContent.tsx`)
|
||||
## Implementation
|
||||
|
||||
### 1. Content Component (`MyFeatureContent.tsx`)
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import { useModalContext } from '@lobehub/ui/base-ui';
|
||||
import { useModalContext } from '@lobehub/ui';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export const MyFeatureContent = () => {
|
||||
const { t } = useTranslation('namespace');
|
||||
const { close } = useModalContext();
|
||||
const { close } = useModalContext(); // Optional: get close method
|
||||
|
||||
return <div>{/* ... */}</div>;
|
||||
return <div>{/* Modal content */}</div>;
|
||||
};
|
||||
```
|
||||
|
||||
### 2. `createModal` (`index.tsx`)
|
||||
### 2. Export createModal (`index.tsx`)
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import { createModal } from '@lobehub/ui/base-ui';
|
||||
import { t } from 'i18next';
|
||||
import { createModal } from '@lobehub/ui';
|
||||
import { t } from 'i18next'; // Note: use i18next, not react-i18next
|
||||
|
||||
import { MyFeatureContent } from './MyFeatureContent';
|
||||
|
||||
export const createMyFeatureModal = () =>
|
||||
createModal({
|
||||
content: <MyFeatureContent />,
|
||||
allowFullscreen: true,
|
||||
children: <MyFeatureContent />,
|
||||
destroyOnHidden: false,
|
||||
footer: null,
|
||||
maskClosable: true,
|
||||
styles: {
|
||||
content: { overflow: 'hidden', padding: 0 },
|
||||
},
|
||||
styles: { body: { overflow: 'hidden', padding: 0 } },
|
||||
title: t('myFeature.title', { ns: 'setting' }),
|
||||
width: 'min(80%, 800px)',
|
||||
});
|
||||
@@ -88,52 +76,27 @@ const handleOpen = useCallback(() => {
|
||||
return <Button onClick={handleOpen}>Open</Button>;
|
||||
```
|
||||
|
||||
### i18n
|
||||
## i18n Handling
|
||||
|
||||
- **Content**: `useTranslation` in components.
|
||||
- **`createModal` options**: `import { t } from 'i18next'` where hooks are unavailable.
|
||||
- **Content component**: `useTranslation` hook (React context)
|
||||
- **createModal params**: `import { t } from 'i18next'` (non-hook, imperative)
|
||||
|
||||
### `useModalContext`
|
||||
## useModalContext Hook
|
||||
|
||||
```tsx
|
||||
const { close, setCanDismissByClickOutside } = useModalContext();
|
||||
```
|
||||
|
||||
### Common options (base-ui)
|
||||
## Common Config
|
||||
|
||||
`ImperativeModalProps` builds on `BaseModalProps`: `title`, `width`, `maskClosable`, `open`, `onOpenChange`, `footer`, `styles` / `classNames` (keys: `backdrop`, `popup`, `header`, `title`, `close`, `content`, …).
|
||||
|
||||
| Property | Notes |
|
||||
| -------------- | ---------------------------------------- |
|
||||
| `content` | Main body (preferred name vs `children`) |
|
||||
| `maskClosable` | Click outside to dismiss |
|
||||
| `styles.*` | Semantic regions, not antd `styles.body` |
|
||||
|
||||
### Confirm
|
||||
|
||||
```tsx
|
||||
import { confirmModal } from '@lobehub/ui/base-ui';
|
||||
|
||||
confirmModal({
|
||||
title: '…',
|
||||
content: '…',
|
||||
okText: '…',
|
||||
cancelText: '…',
|
||||
onOk: async () => {},
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Legacy: `@lobehub/ui` (root)
|
||||
|
||||
Older call sites use **`createModal` from `@lobehub/ui`**, which is typed as **antd `Modal` props** (`children`, `allowFullscreen`, `getContainer`, `destroyOnHidden`, `styles.body`, etc.). Prefer migrating new work to **`@lobehub/ui/base-ui`**.
|
||||
|
||||
Examples (legacy): `src/features/SkillStore/index.tsx`, `src/features/LibraryModal/CreateNew/index.tsx`.
|
||||
|
||||
---
|
||||
| Property | Type | Description |
|
||||
| ----------------- | ------------------- | ------------------------ |
|
||||
| `allowFullscreen` | `boolean` | Allow fullscreen mode |
|
||||
| `destroyOnHidden` | `boolean` | Destroy content on close |
|
||||
| `footer` | `ReactNode \| null` | Footer content |
|
||||
| `width` | `string \| number` | Modal width |
|
||||
|
||||
## Examples
|
||||
|
||||
- Base-ui (preferred): follow sections above; ensure **base-ui `ModalHost`** is mounted.
|
||||
- Legacy: `src/features/SkillStore/index.tsx`, `src/features/LibraryModal/CreateNew/index.tsx`
|
||||
- `src/features/SkillStore/index.tsx`
|
||||
- `src/features/LibraryModal/CreateNew/index.tsx`
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: pr
|
||||
description: "Create a PR for the current branch. Use when the user asks to create a pull request, submit PR, or says 'pr'."
|
||||
user-invocable: true
|
||||
user_invocable: true
|
||||
---
|
||||
|
||||
# Create Pull Request
|
||||
|
||||
@@ -6,9 +6,6 @@ description: React component development guide. Use when working with React comp
|
||||
# React Component Writing Guide
|
||||
|
||||
- Use antd-style for complex styles; for simple cases, use inline `style` attribute
|
||||
- **Prefer `createStaticStyles` with `cssVar.*`** (zero-runtime) — module-level, no hook call required
|
||||
- Only fall back to `createStyles` + `token` when styles genuinely need runtime computation (dynamic props, JS color fns like `readableColor`/`chroma`)
|
||||
- See `.cursor/docs/createStaticStyles_migration_guide.md` for full pattern
|
||||
- Use `Flexbox` and `Center` from `@lobehub/ui` for layouts (see `references/layout-kit.md`)
|
||||
- Component priority: `src/components` > `@lobehub/ui/base-ui` > `@lobehub/ui` > custom implementation
|
||||
- Always prefer `@lobehub/ui/base-ui` primitives (Select, Modal, DropdownMenu, Popover, Switch, ScrollArea…) over antd equivalents
|
||||
@@ -67,7 +64,7 @@ import { dynamicElement, redirectElement, ErrorBoundary } from '@/utils/router';
|
||||
|
||||
element: dynamicElement(() => import('./chat'), 'Desktop > Chat');
|
||||
element: redirectElement('/settings/profile');
|
||||
errorElement: <ErrorBoundary />;
|
||||
errorElement: <ErrorBoundary resetPath="/chat" />;
|
||||
```
|
||||
|
||||
### Navigation
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
---
|
||||
name: recent-data
|
||||
description: Guide for using Recent Data (topics, resources, pages). Use when working with recently accessed items, implementing recent lists, or accessing session store recent data. Triggers on recent data usage or implementation tasks.
|
||||
user-invocable: false
|
||||
---
|
||||
|
||||
# Recent Data Usage Guide
|
||||
|
||||
Recent data (recentTopics, recentResources, recentPages) is stored in session store.
|
||||
|
||||
## Initialization
|
||||
|
||||
In app top-level (e.g., `RecentHydration.tsx`):
|
||||
|
||||
```tsx
|
||||
import { useInitRecentTopic } from '@/hooks/useInitRecentTopic';
|
||||
import { useInitRecentResource } from '@/hooks/useInitRecentResource';
|
||||
import { useInitRecentPage } from '@/hooks/useInitRecentPage';
|
||||
|
||||
const App = () => {
|
||||
useInitRecentTopic();
|
||||
useInitRecentResource();
|
||||
useInitRecentPage();
|
||||
return <YourComponents />;
|
||||
};
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Method 1: Read from Store (Recommended)
|
||||
|
||||
```tsx
|
||||
import { useSessionStore } from '@/store/session';
|
||||
import { recentSelectors } from '@/store/session/selectors';
|
||||
|
||||
const Component = () => {
|
||||
const recentTopics = useSessionStore(recentSelectors.recentTopics);
|
||||
const isInit = useSessionStore(recentSelectors.isRecentTopicsInit);
|
||||
|
||||
if (!isInit) return <div>Loading...</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{recentTopics.map((topic) => (
|
||||
<div key={topic.id}>{topic.title}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### Method 2: Use Hook Return (Single component)
|
||||
|
||||
```tsx
|
||||
const { data: recentTopics, isLoading } = useInitRecentTopic();
|
||||
```
|
||||
|
||||
## Available Selectors
|
||||
|
||||
### Recent Topics
|
||||
|
||||
```tsx
|
||||
const recentTopics = useSessionStore(recentSelectors.recentTopics);
|
||||
// Type: RecentTopic[]
|
||||
|
||||
const isInit = useSessionStore(recentSelectors.isRecentTopicsInit);
|
||||
// Type: boolean
|
||||
```
|
||||
|
||||
**RecentTopic type:**
|
||||
|
||||
```typescript
|
||||
interface RecentTopic {
|
||||
agent: {
|
||||
avatar: string | null;
|
||||
backgroundColor: string | null;
|
||||
id: string;
|
||||
title: string | null;
|
||||
} | null;
|
||||
id: string;
|
||||
title: string | null;
|
||||
updatedAt: Date;
|
||||
}
|
||||
```
|
||||
|
||||
### Recent Resources
|
||||
|
||||
```tsx
|
||||
const recentResources = useSessionStore(recentSelectors.recentResources);
|
||||
// Type: FileListItem[]
|
||||
|
||||
const isInit = useSessionStore(recentSelectors.isRecentResourcesInit);
|
||||
```
|
||||
|
||||
### Recent Pages
|
||||
|
||||
```tsx
|
||||
const recentPages = useSessionStore(recentSelectors.recentPages);
|
||||
const isInit = useSessionStore(recentSelectors.isRecentPagesInit);
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
1. **Auto login detection**: Only loads when user is logged in
|
||||
2. **Data caching**: Stored in store, no repeated loading
|
||||
3. **Auto refresh**: SWR refreshes on focus (5-minute interval)
|
||||
4. **Type safe**: Full TypeScript types
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. Initialize all recent data at app top-level
|
||||
2. Use selectors to read from store
|
||||
3. For multi-component use, prefer Method 1
|
||||
4. Use selectors for render optimization
|
||||
@@ -117,7 +117,7 @@ it('should handle tool calls', async () => {
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'call_123',
|
||||
name: 'lobe-web-browsing____search',
|
||||
name: 'lobe-web-browsing____search____builtin',
|
||||
arguments: JSON.stringify({ query: 'weather' }),
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,27 +1,10 @@
|
||||
---
|
||||
name: version-release
|
||||
description: "Version release workflow. Use when the user mentions 'release', 'hotfix', 'version upgrade', 'weekly release', or '发版'/'发布'/'小班车'. This skill is for release process and GitHub Release notes (not docs/changelog page writing)."
|
||||
description: "Version release workflow. Use when the user mentions 'release', 'hotfix', 'version upgrade', 'weekly release', or '发版'/'发布'/'小班车'. Provides guides for Minor Release and Patch Release workflows."
|
||||
---
|
||||
|
||||
# Version Release Workflow
|
||||
|
||||
## Scope Boundary (Important)
|
||||
|
||||
This skill is only for:
|
||||
|
||||
1. Release branch / PR workflow
|
||||
2. CI trigger constraints (`auto-tag-release.yml`)
|
||||
3. GitHub Release note writing
|
||||
|
||||
This skill is **not** for writing `docs/changelog/*.mdx`.\
|
||||
If the user asks for website changelog pages, load `../docs-changelog/SKILL.md`.
|
||||
|
||||
## Mandatory Companion Skill
|
||||
|
||||
For every `/version-release` execution, you MUST load and apply:
|
||||
|
||||
- `../microcopy/SKILL.md`
|
||||
|
||||
## Overview
|
||||
|
||||
The primary development branch is **canary**. All day-to-day development happens on canary. When releasing, canary is merged into main. After merge, `auto-tag-release.yml` automatically handles tagging, version bumping, creating a GitHub Release, and syncing back to the canary branch.
|
||||
@@ -35,7 +18,7 @@ Only two release types are used in practice (major releases are extremely rare a
|
||||
|
||||
## Minor Release Workflow
|
||||
|
||||
Used to publish a new minor version (e.g. `v2.2.0`), roughly every 4 weeks.
|
||||
Used to publish a new minor version (e.g. v2.2.0), roughly every 4 weeks.
|
||||
|
||||
### Steps
|
||||
|
||||
@@ -48,7 +31,7 @@ git checkout -b release/v{version}
|
||||
git push -u origin release/v{version}
|
||||
```
|
||||
|
||||
2. **Determine the version number** — Read the current version from `package.json` and compute the next minor version (e.g. 2.1.x -> 2.2.0)
|
||||
2. **Determine the version number** — Read the current version from `package.json` and compute the next minor version (e.g. 2.1.x → 2.2.0)
|
||||
|
||||
3. **Create a PR to main**
|
||||
|
||||
@@ -60,10 +43,9 @@ gh pr create \
|
||||
--body "## 📦 Release v{version} ..."
|
||||
```
|
||||
|
||||
> \[!IMPORTANT]
|
||||
> The PR title must strictly match the `🚀 release: v{x.y.z}` format. CI uses a regex on this title to determine the exact version number.
|
||||
> \[!IMPORTANT]: The PR title must strictly match the `🚀 release: v{x.y.z}` format. CI uses a regex on this title to determine the exact version number.
|
||||
|
||||
4. **Automatic trigger after merge**: `auto-tag-release` detects the title format and uses the version number from the title to complete the release.
|
||||
4. **Automatic trigger after merge**: auto-tag-release detects the title format and uses the version number from the title to complete the release.
|
||||
|
||||
### Scripts
|
||||
|
||||
@@ -78,7 +60,7 @@ Version number is automatically bumped by patch +1. There are 4 common scenarios
|
||||
|
||||
| Scenario | Source Branch | Branch Naming | Description |
|
||||
| ------------------- | ------------- | ----------------------------- | ------------------------------------------------ |
|
||||
| Weekly Release | canary | `release/weekly-{YYYYMMDD}` | Weekly release train, canary -> main |
|
||||
| Weekly Release | canary | `release/weekly-{YYYYMMDD}` | Weekly release train, canary → main |
|
||||
| Bug Hotfix | main | `hotfix/v{version}-{hash}` | Emergency bug fix |
|
||||
| New Model Launch | canary | Community PR merged directly | New model launch, triggered by PR title prefix |
|
||||
| DB Schema Migration | main | `release/db-migration-{name}` | Database migration, requires dedicated changelog |
|
||||
@@ -91,19 +73,19 @@ All scenarios auto-bump patch +1. Patch PR titles do not need a version number.
|
||||
bun run hotfix:branch # Hotfix scenario
|
||||
```
|
||||
|
||||
## Auto-Release Trigger Rules (`auto-tag-release.yml`)
|
||||
## Auto-Release Trigger Rules (auto-tag-release.yml)
|
||||
|
||||
After a PR is merged into main, CI determines whether to release based on the following priority:
|
||||
|
||||
### 1. Minor Release (Exact Version)
|
||||
|
||||
PR title matches `🚀 release: v{x.y.z}` -> uses the version number from the title.
|
||||
PR title matches `🚀 release: v{x.y.z}` → uses the version number from the title.
|
||||
|
||||
### 2. Patch Release (Auto patch +1)
|
||||
|
||||
Triggered by the following priority:
|
||||
|
||||
- **Branch name match**: `hotfix/*` or `release/*` -> triggers directly (skips title detection)
|
||||
- **Branch name match**: `hotfix/*` or `release/*` → triggers directly (skips title detection)
|
||||
- **Title prefix match**: PRs with the following title prefixes will trigger:
|
||||
- `style` / `💄 style`
|
||||
- `feat` / `✨ feat`
|
||||
@@ -114,225 +96,64 @@ Triggered by the following priority:
|
||||
|
||||
### 3. No Trigger
|
||||
|
||||
PRs that don't match any conditions above (e.g. `docs`, `chore`, `ci`, `test`) will not trigger a release when merged into main.
|
||||
PRs that don't match any of the above conditions (e.g. `docs`, `chore`, `ci`, `test` prefixes) will not trigger a release when merged into main.
|
||||
|
||||
## Post-Release Automated Actions
|
||||
|
||||
1. **Bump `package.json`** — commits `🔖 chore(release): release version v{x.y.z} [skip ci]`
|
||||
1. **Bump package.json** — commits `🔖 chore(release): release version v{x.y.z} [skip ci]`
|
||||
2. **Create annotated tag** — `v{x.y.z}`
|
||||
3. **Create GitHub Release**
|
||||
4. **Dispatch `sync-main-to-canary`** — syncs main back to canary
|
||||
4. **Dispatch sync-main-to-canary** — syncs main back to the canary branch
|
||||
|
||||
## Agent Action Guide
|
||||
## Claude Action Guide
|
||||
|
||||
When the user requests a release:
|
||||
|
||||
### Minor Release
|
||||
|
||||
1. Read `package.json` to get the current version and compute the next minor version
|
||||
2. Create a `release/v{version}` branch from canary
|
||||
3. Push and create a PR — **title must be `🚀 release: v{version}`**
|
||||
4. Inform the user that merging the PR will automatically trigger the release
|
||||
|
||||
### Precheck
|
||||
|
||||
Before creating the release branch, verify the source branch:
|
||||
|
||||
- **Weekly Release** (`release/weekly-*`): must branch from `canary`
|
||||
- **All other release/hotfix branches**: must branch from `main`; run `git merge-base --is-ancestor main <branch> && echo OK`
|
||||
- If the branch is based on the wrong source, recreate from the correct base
|
||||
|
||||
### Minor Release
|
||||
|
||||
1. Read `package.json` to get the current version and compute the next minor version
|
||||
2. Create a `release/v{version}` branch from canary
|
||||
3. Push and create PR — **title must be `🚀 release: v{version}`**
|
||||
4. Inform the user that merge will auto-trigger release
|
||||
- **All other release/hotfix branches**: must branch from `main` — run `git merge-base --is-ancestor main <branch> && echo OK` to confirm
|
||||
- If the branch is based on the wrong source, delete and recreate from the correct base
|
||||
|
||||
### Patch Release
|
||||
|
||||
Choose workflow by scenario (see `reference/patch-release-scenarios.md`):
|
||||
Choose the appropriate workflow based on the scenario (see `reference/patch-release-scenarios.md`):
|
||||
|
||||
- **Weekly Release**: create `release/weekly-{YYYYMMDD}` from canary; use `git log main..canary` for release note inputs; title like `🚀 release: 20260222`
|
||||
- **Bug Hotfix**: create `hotfix/` from main; use gitmoji prefix title (e.g. `🐛 fix: ...`)
|
||||
- **New Model Launch**: community PRs trigger automatically via title prefix (`feat` / `style`)
|
||||
- **DB Migration**: create `release/db-migration-{name}` from main; cherry-pick migration commits; include dedicated migration notes
|
||||
- **Weekly Release**: Create a `release/weekly-{YYYYMMDD}` branch from canary, scan `git log main..canary` to write the changelog, title like `🚀 release: 20260222`
|
||||
- **Bug Hotfix**: Create a `hotfix/` branch from main, use a gitmoji prefix title (e.g. `🐛 fix: ...`)
|
||||
- **New Model Launch**: Community PRs trigger automatically via title prefix (`feat` / `style`), no extra steps needed
|
||||
- **DB Migration**: Create a `release/db-migration-{name}` branch from main, cherry-pick migration commits, write a dedicated migration changelog
|
||||
|
||||
### Hard Rules
|
||||
### Important Notes
|
||||
|
||||
- **Do NOT** manually modify `package.json` version
|
||||
- **Do NOT** manually create tags
|
||||
- Minor PR title format is strict
|
||||
- Patch PRs do not need explicit version number
|
||||
- Keep release facts accurate; do not invent metrics or availability statements
|
||||
- **Do NOT manually modify the version in package.json** — CI will auto-bump it
|
||||
- **Do NOT manually create tags** — CI will create them automatically
|
||||
- The Minor Release PR title format is a hard requirement — incorrect format will not use the specified version number
|
||||
- Patch PRs do not need a version number — CI auto-bumps patch +1
|
||||
- All release PRs must include a user-facing changelog
|
||||
|
||||
## GitHub Release Changelog Standard (Long-Form Style)
|
||||
## Changelog Writing Guidelines
|
||||
|
||||
Use this section for writing **GitHub Release notes** (or release PR body when the PR body is intended to become release notes).\
|
||||
Do not use this as `docs/changelog` page guidance.
|
||||
All release PR bodies (both Minor and Patch) must include a user-facing changelog. Scan changes via `git log main..canary --oneline` or `git diff main...canary --stat`, then write following the format below.
|
||||
|
||||
### Positioning
|
||||
### Format Reference
|
||||
|
||||
This release-note style is:
|
||||
- Weekly Release: See `reference/changelog-example/weekly-release.md`
|
||||
- DB Migration: See `reference/changelog-example/db-migration.md`
|
||||
|
||||
1. **Data-backed at the top** (date, range, key metrics)
|
||||
2. **Narrative first, then structured detail**
|
||||
3. **Deep but scannable** (clear sectioning + compact bullets)
|
||||
4. **Contributor-forward** (credits are part of the release story)
|
||||
### Writing Tips
|
||||
|
||||
### Required Inputs Before Writing
|
||||
|
||||
Collect these inputs first:
|
||||
|
||||
1. Compare range (`<prev_tag>...<current_tag>`)
|
||||
2. Release metrics (commits, merged PRs, resolved issues, contributors, optional files/insertions/deletions)
|
||||
3. High-impact changes by domain (core loop, platform/gateway, UX, tooling, security, reliability)
|
||||
4. Contributor list (with standout contributions if known)
|
||||
5. Known risks / migrations / rollout notes (if any)
|
||||
|
||||
If metrics cannot be reliably computed, omit unknown numbers instead of guessing.
|
||||
|
||||
### Canonical Structure
|
||||
|
||||
Follow this section order unless the user asks otherwise:
|
||||
|
||||
1. `# 🚀 LobeHub v<x.y.z> (<YYYYMMDD>)`
|
||||
2. Metadata lines:
|
||||
- `Release Date`
|
||||
- `Since <Previous Version>` metrics
|
||||
3. One quoted release thesis (single paragraph, 1-2 lines)
|
||||
4. `## ✨ Highlights` (6-12 bullets for major releases; 3-8 for weekly)
|
||||
5. Domain blocks with optional `###` subsections:
|
||||
- `## 🏗️ Core Agent & Architecture` (or equivalent product core)
|
||||
- `## 📱 Platforms / Integrations`
|
||||
- `## 🖥️ CLI & User Experience`
|
||||
- `## 🔧 Tooling`
|
||||
- `## 🔒 Security & Reliability`
|
||||
- `## 📚 Documentation` (optional if meaningful)
|
||||
6. `## 👥 Contributors`
|
||||
7. `**Full Changelog**: <prev>...<current>`
|
||||
|
||||
Use `---` separators between major blocks for long releases.
|
||||
|
||||
### Writing Rules (Hard)
|
||||
|
||||
1. **No fabricated metrics**: all numbers must be traceable.
|
||||
2. **No vague headline bullets**: each bullet must include capability + impact.
|
||||
3. **No internal-only framing**: phrase from user/operator perspective.
|
||||
4. **Security must be explicit** when security-sensitive fixes are present.
|
||||
5. **PR/issue linkage**: use `(#1234)` when IDs are available.
|
||||
6. **Terminology consistency**: same feature/provider name across sections.
|
||||
7. **Do not bury migration or breaking changes**: elevate to dedicated section or callout.
|
||||
|
||||
### Style Rules (Long-Form)
|
||||
|
||||
1. Start with an "everyday use" framing, not implementation internals.
|
||||
2. Mix narrative sentence + evidence bullets.
|
||||
3. Keep bullets compact but informative:
|
||||
- Good: `**Fast Mode (`/fast`)** — Priority routing for OpenAI and Anthropic, reducing latency on supported models. (#6875, #6960)`
|
||||
4. Use bold only for capability names, not for whole sentences.
|
||||
5. Keep heading depth <= 3 levels.
|
||||
|
||||
### Release Size Heuristics
|
||||
|
||||
- **Minor / major milestone release**
|
||||
- Include full structure with multiple domain blocks.
|
||||
- `Highlights` usually 8-12 bullets.
|
||||
- **Weekly patch release**
|
||||
- Keep full skeleton but reduce subsection count.
|
||||
- `Highlights` usually 4-8 bullets.
|
||||
- **DB migration release**
|
||||
- Keep concise.
|
||||
- Must include `Migration overview`, operator impact, and rollback/backup note.
|
||||
|
||||
### Contributor Ordering
|
||||
|
||||
Render contributors as a **single flat list** (no separate "Community" / "Core Team" subsections). Order: **community contributors first, team members after**. Within each group, sort by PR count desc. Bots (`@lobehubbot`, `renovate[bot]`) go on a separate "maintenance" line.
|
||||
|
||||
**LobeHub team roster** — anyone in this list is a team member; anyone not in this list is a community contributor:
|
||||
|
||||
- @arvinxx
|
||||
- @Innei
|
||||
- @tjx666 (commit author name: YuTengjing)
|
||||
- @LiJian
|
||||
- @Neko
|
||||
- @Rdmclin2
|
||||
- @AmAzing129
|
||||
- @sudongyuer
|
||||
- @rivertwilight
|
||||
- @CanisMinor
|
||||
|
||||
> **Resolving handles** — git author names (e.g. `YuTengjing`) are not always the GitHub handle. Verify via `gh pr view <PR> --json author` or `gh api search/users -f q='<email>'` before listing.
|
||||
|
||||
If a new contributor appears who is not on this list, treat them as community by default and ask the user whether to add them to the roster.
|
||||
|
||||
### GitHub Release Changelog Template
|
||||
|
||||
```md
|
||||
# 🚀 LobeHub v<x.y.z> (<YYYYMMDD>)
|
||||
|
||||
**Release Date:** <Month DD, YYYY>
|
||||
**Since <Previous Version>:** <N merged PRs> · <N resolved issues> · <N contributors>
|
||||
|
||||
> <One release thesis sentence: what this release unlocks in practice.>
|
||||
|
||||
---
|
||||
|
||||
## ✨ Highlights
|
||||
|
||||
- **<Capability A>** — <What changed and why it matters>. (#1234)
|
||||
- **<Capability B>** — <What changed and why it matters>. (#2345)
|
||||
- **<Capability C>** — <What changed and why it matters>. (#3456)
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Core Product & Architecture
|
||||
|
||||
### <Subdomain>
|
||||
|
||||
- <Concrete change + impact>. (#...)
|
||||
- <Concrete change + impact>. (#...)
|
||||
|
||||
---
|
||||
|
||||
## 📱 Platforms / Integrations
|
||||
|
||||
- <Platform update + impact>. (#...)
|
||||
- <Compatibility/reliability fix + impact>. (#...)
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ CLI & User Experience
|
||||
|
||||
- <User-facing workflow improvement>. (#...)
|
||||
- <Quality-of-life fix>. (#...)
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Tooling
|
||||
|
||||
- <Tool/runtime improvement>. (#...)
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Security & Reliability
|
||||
|
||||
- **Security:** <hardening or vulnerability fix>. (#...)
|
||||
- **Reliability:** <stability/performance behavior improvement>. (#...)
|
||||
|
||||
---
|
||||
|
||||
## 👥 Contributors
|
||||
|
||||
Huge thanks to **<N contributors>** who shipped **<N merged PRs>** this cycle.
|
||||
|
||||
@<community-handle> · @<community-handle> · @<team-handle> · @<team-handle>
|
||||
|
||||
Plus @lobehubbot and renovate[bot] for maintenance.
|
||||
|
||||
---
|
||||
|
||||
**Full Changelog**: <previous_tag>...<current_tag>
|
||||
```
|
||||
|
||||
### Quick Checklist
|
||||
|
||||
- [ ] Uses top metadata and a clear release thesis
|
||||
- [ ] Includes `Highlights` plus domain-grouped sections
|
||||
- [ ] Every major bullet states both change and user/operator impact
|
||||
- [ ] Security and reliability updates are explicitly surfaced (when present)
|
||||
- [ ] Contributor credits and compare range are included
|
||||
- [ ] All numbers and claims are verifiable
|
||||
- **User-facing**: Describe changes that users can perceive, not internal implementation details
|
||||
- **Clear categories**: Group by features, models/providers, desktop, stability/fixes, etc.
|
||||
- **Highlight key items**: Use `**bold**` for important feature names
|
||||
- **Credit contributors**: Collect all committers via `git log` and list alphabetically
|
||||
- **Flexible categories**: Choose categories based on actual changes — no need to force-fit all categories
|
||||
|
||||
@@ -1,60 +1,20 @@
|
||||
# 🚀 LobeHub v2.1.50 (20260416)
|
||||
# DB Schema Migration Changelog Example
|
||||
|
||||
**Release Date:** April 20, 2026\
|
||||
**Migration Scope:** Agent benchmark data model bootstrap (5 new tables, 2 new indexes)
|
||||
|
||||
> This release introduces a schema foundation for benchmark execution and reporting, so agent evaluation data is stored as a complete lifecycle instead of fragmented records.
|
||||
A changelog reference for database migration release PR bodies.
|
||||
|
||||
---
|
||||
|
||||
## ✨ Highlights
|
||||
This release includes a **database schema migration** involving **5 new tables** for the Agent Evaluation Benchmark system.
|
||||
|
||||
- **Benchmark Lifecycle Schema** — Added a relational model that tracks benchmark setup, runs, per-topic execution, and record outputs end-to-end.
|
||||
- **Queryability Upgrade** — Added indexes for run status and benchmark-topic joins, improving operational queries in dashboard and debugging workflows.
|
||||
- **Safer Operator Rollout** — Migration is startup-driven and backward-compatible with existing non-benchmark chat workflows.
|
||||
### Migration: Add Agent Evaluation Benchmark Tables
|
||||
|
||||
---
|
||||
- Added 5 new tables: `agent_eval_benchmarks`, `agent_eval_datasets`, `agent_eval_records`, `agent_eval_runs`, `agent_eval_run_topics`
|
||||
|
||||
## 🗄️ Migration Overview
|
||||
### Notes for Self-hosted Users
|
||||
|
||||
Added tables:
|
||||
- The migration runs automatically on application startup
|
||||
- No manual intervention required
|
||||
|
||||
- `agent_eval_benchmarks`
|
||||
- `agent_eval_datasets`
|
||||
- `agent_eval_runs`
|
||||
- `agent_eval_run_topics`
|
||||
- `agent_eval_records`
|
||||
The migration owner: @{pr-author} — responsible for this database schema change, reach out for any migration-related issues.
|
||||
|
||||
Added indexes:
|
||||
|
||||
- `idx_agent_eval_runs_status_created_at`
|
||||
- `idx_agent_eval_run_topics_run_id_topic_id`
|
||||
|
||||
These additions close a previous gap where benchmark data existed in partial forms but lacked a stable relational backbone for auditing and historical analysis.
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Operator Notes
|
||||
|
||||
- Migration runs automatically on application startup.
|
||||
- No manual SQL is required in standard deployment paths.
|
||||
- Schedule rollout in a low-traffic window and take a backup snapshot before deployment.
|
||||
- If migration fails, do not retry repeatedly; inspect migration logs and lock state first.
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Reliability & Risk
|
||||
|
||||
- Existing chat/session paths are unaffected unless benchmark features are enabled.
|
||||
- Migration is additive (new tables/indexes only), minimizing downgrade risk to existing entities.
|
||||
- Rollback should follow your standard DB restore or migration rollback policy if your environment requires strict reversibility.
|
||||
|
||||
---
|
||||
|
||||
## 👥 Owner
|
||||
|
||||
Migration owner: @{pr-author}
|
||||
|
||||
The migration owner is responsible for rollout follow-up and incident handling for this schema change.
|
||||
|
||||
> **Note for Claude**: Replace `{pr-author}` with the actual PR author. Retrieve via `gh pr view <number> --json author --jq '.author.login'` or from commit metadata. Do not hardcode a username.
|
||||
> **Note for Claude**: Replace `{pr-author}` with the actual PR author. Retrieve via `gh pr view <number> --json author --jq '.author.login'` or `git log` commit author. Do NOT hardcode a username.
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
# 🚀 LobeHub v2.1.54 (20260427)
|
||||
|
||||
**Hotfix Scope:** Agent topic-switching regression — stale chat state on agent change
|
||||
|
||||
> Clears residual topic state when navigating between agents and restores blank-canvas behavior on agent switch.
|
||||
|
||||
## 🐛 What's Fixed
|
||||
|
||||
- **Stale topic on agent switch** — Switching from `/agent/agt_A/tpc_X` to `/agent/agt_B` no longer leaves the previous topic's messages on screen, and _Start new topic_ responds again. (#14231)
|
||||
- **Header & sidebar consistency** — Conversation header now shows the active subtopic's title, and the sidebar keeps the parent topic's thread list expanded while a thread is open.
|
||||
|
||||
## ⚙️ Upgrade
|
||||
|
||||
- Self-hosted: pull the new image and restart. No schema or env changes.
|
||||
- Cloud: applied automatically.
|
||||
|
||||
## 👥 Owner
|
||||
|
||||
@{pr-author}
|
||||
|
||||
> **Note for Claude**: Replace `{pr-author}` with the actual PR author. Retrieve via `gh pr view <number> --json author --jq '.author.login'`. Do not hardcode a username.
|
||||
@@ -1,80 +1,46 @@
|
||||
# 🚀 LobeHub v2.1.50 (20260420)
|
||||
# Patch Release (Weekly) Changelog Example
|
||||
|
||||
**Release Date:** April 20, 2026\
|
||||
**Since v2026.04.13:** 96 commits · 58 merged PRs · 31 resolved issues · 17 contributors
|
||||
|
||||
> This weekly release focuses on reducing friction in everyday agent work: faster model routing, smoother gateway behavior, stronger task continuity, and clearer operator diagnostics when something goes wrong.
|
||||
A real-world changelog reference for weekly patch release PR bodies.
|
||||
|
||||
---
|
||||
|
||||
## ✨ Highlights
|
||||
This release includes **82 commits** , Key updates are below.
|
||||
|
||||
- **Gateway Session Recovery** — Agent sessions now recover more reliably after short network interruptions, so long-running tasks continue with less manual retry. (#10121, #10133)
|
||||
- **Fast Model Routing** — Expanded low-latency routing for priority model tiers, reducing wait time in high-frequency generation workflows. (#10102, #10117)
|
||||
- **Agent Task Workspace** — Running tasks now remain isolated from main chat state, which keeps primary conversations cleaner while background work progresses. (#10088)
|
||||
- **Provider Coverage Update** — Added support for new model variants across OpenAI-compatible and regional providers, improving fallback options in production. (#10094, #10109)
|
||||
- **Desktop Attachment Flow** — File and screenshot attachment behavior is more predictable in desktop sessions, especially for mixed text + media prompts. (#10073)
|
||||
- **Security Hardening Pass** — Closed multiple input validation gaps in webhook and file-path handling paths. (#10141, #10152)
|
||||
### New Features and Enhancements
|
||||
|
||||
---
|
||||
- Added **Agent Benchmark** support for more systematic agent performance evaluation.
|
||||
- Introduced the **video generation** feature end-to-end, including entry points, sidebar "new" badge support, and skeleton loading for topic switching.
|
||||
- Expanded memory capabilities: support for memory effort/tool permission configuration and improved timeout calculation for memory analysis tasks.
|
||||
- Added desktop editor support for image upload via file picker.
|
||||
|
||||
## 🏗️ Core Agent & Architecture
|
||||
### Models and Provider Expansion
|
||||
|
||||
### Agent loop and context handling
|
||||
- Added a new provider: **Straico**.
|
||||
- Added/updated support for:
|
||||
- Claude Sonnet 4.6
|
||||
- Gemini 3.1 Pro Preview
|
||||
- Qwen3.5 series
|
||||
- Grok Imagine (`grok-imagine-image`)
|
||||
- MiniMax 2.5
|
||||
- Added related i18n copy and model parameter adaptations.
|
||||
|
||||
- Improved context compaction thresholds to reduce mid-task exits under tight token budgets. (#10079)
|
||||
- Added better diagnostics for tool-call truncation and recovery behavior during streamed responses. (#10106)
|
||||
- Refined delegate task activity propagation to improve parent-child task status consistency. (#10098)
|
||||
### Desktop Improvements
|
||||
|
||||
### Provider and model behavior
|
||||
- Integrated `electron-liquid-glass` (macOS Tahoe).
|
||||
- Improved DMG background assets and desktop release workflow.
|
||||
|
||||
- Unified provider-side timeout handling in fallback chains to reduce false failure classification. (#10097)
|
||||
- Updated reasoning-model defaults and response normalization for better cross-provider consistency. (#10109)
|
||||
### Stability, Security, and UX Fixes
|
||||
|
||||
---
|
||||
- Fixed multiple video generation pipeline issues: precharge refund handling, webhook token verification, pricing parameter usage, asset cleanup, and type safety.
|
||||
- Fixed `sanitizeFileName` path traversal risks and added unit tests.
|
||||
- Fixed MCP media URL generation with duplicated `APP_URL` prefix.
|
||||
- Fixed Qwen3 embedding failures caused by batch-size limits.
|
||||
- Fixed multiple UI/interaction issues, including mobile header agent selector/topic count, ChatInput scrolling behavior, and tooltip stacking context.
|
||||
- Fixed missing `@napi-rs/canvas` native bindings in Docker standalone builds.
|
||||
- Improved GitHub Copilot authentication retry behavior and response error handling in edge cases.
|
||||
|
||||
## 📱 Gateway & Platform Integrations
|
||||
### Credits
|
||||
|
||||
- Gateway now drains in-flight events more safely before restart, reducing duplicate notification bursts. (#10125)
|
||||
- Discord and Slack adapters received retry/backoff tuning for unstable webhook windows. (#10091, #10119)
|
||||
- WeCom callback-mode message state persistence now uses safer atomic updates. (#10114)
|
||||
Huge thanks to these contributors (alphabetical):
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ CLI & User Experience
|
||||
|
||||
- Improved slash command discoverability in CLI and gateway contexts with clearer hint messages. (#10086)
|
||||
- `/model` switching feedback now returns clearer success/failure states in cross-platform chats. (#10108)
|
||||
- Setup flow now warns earlier about missing provider credentials in first-run scenarios. (#10115)
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Tooling
|
||||
|
||||
- MCP registration flow now validates duplicate tool names before activation, reducing runtime conflicts. (#10093)
|
||||
- Browser tooling improved stale-session cleanup to prevent orphaned local resources. (#10112)
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Security & Reliability
|
||||
|
||||
- **Security:** Hardened path sanitization for uploaded assets and webhook callback validation. (#10141, #10152)
|
||||
- **Reliability:** Reduced empty-response retry storms by refining retry-classification conditions. (#10130)
|
||||
- **Reliability:** Improved timeout defaults for long-running background processes in constrained environments. (#10122)
|
||||
|
||||
---
|
||||
|
||||
## 👥 Contributors
|
||||
|
||||
**58 merged PRs** from **17 contributors** across **96 commits**.
|
||||
|
||||
### Community Contributors
|
||||
|
||||
- @alice-example - Gateway recovery and retry improvements
|
||||
- @bob-example - Provider fallback normalization
|
||||
- @charlie-example - Desktop media attachment flow
|
||||
- @dora-example - Webhook validation hardening
|
||||
|
||||
---
|
||||
|
||||
**Full Changelog**: v2026.04.13...v2026.04.20
|
||||
@AmAzing129 @Coooolfan @Innei @ONLY-yours @Zhouguanyang @arvinxx @eaten-cake @hezhijie0327 @nekomeowww @rdmclin2 @rivertwilight @sxjeru @tjx666
|
||||
|
||||
@@ -59,10 +59,7 @@ git push -u origin hotfix/v{version}-{short-hash}
|
||||
|
||||
2. **Create PR to main** with a gitmoji prefix title (e.g. `🐛 fix: description`)
|
||||
|
||||
3. **Write a short hotfix changelog** — See `changelog-example/hotfix.md`. Keep it minimal: scope line, 1-3 fix bullets (symptom + fix in one sentence), upgrade note, owner. No long root-cause section — that lives in the commit message.
|
||||
- **Hotfix owner**: Use the actual PR author (retrieve via `gh pr view <number> --json author --jq '.author.login'`), never hardcode a username.
|
||||
|
||||
4. **After merge**: auto-tag-release detects `hotfix/*` branch → auto patch +1.
|
||||
3. **After merge**: auto-tag-release detects `hotfix/*` branch → auto patch +1.
|
||||
|
||||
### Script
|
||||
|
||||
|
||||
@@ -71,18 +71,15 @@ internal_createTopic: async (params) => {
|
||||
**Actions:**
|
||||
|
||||
- Public: `createTopic`, `sendMessage`
|
||||
|
||||
- Internal: `internal_createTopic`, `internal_updateMessageContent`
|
||||
|
||||
- Dispatch: `internal_dispatchTopic`
|
||||
**State:**
|
||||
- Toggle: `internal_toggleMessageLoading`
|
||||
|
||||
- ID arrays: `topicEditingIds`
|
||||
**State:**
|
||||
|
||||
- ID arrays: `messageLoadingIds`, `topicEditingIds`
|
||||
- Maps: `topicMaps`, `messagesMap`
|
||||
|
||||
- Active: `activeTopicId`
|
||||
|
||||
- Init flags: `topicsInit`
|
||||
|
||||
## Detailed Guides
|
||||
|
||||
@@ -30,13 +30,16 @@ internal_createMessage: async (message, context) => {
|
||||
let tempId = context?.tempMessageId;
|
||||
if (!tempId) {
|
||||
tempId = internal_createTmpMessage(message);
|
||||
internal_toggleMessageLoading(true, tempId);
|
||||
}
|
||||
|
||||
try {
|
||||
const id = await messageService.createMessage(message);
|
||||
await refreshMessages();
|
||||
internal_toggleMessageLoading(false, tempId);
|
||||
return id;
|
||||
} catch (e) {
|
||||
internal_toggleMessageLoading(false, tempId);
|
||||
internal_dispatchMessage({
|
||||
id: tempId,
|
||||
type: 'updateMessage',
|
||||
|
||||
@@ -162,15 +162,11 @@ describe('ModuleName', () => {
|
||||
### 5. Create Pull Request
|
||||
|
||||
- Create a new branch: `automatic/add-tests-[module-name]-[date]`
|
||||
|
||||
- Commit changes with message format:
|
||||
|
||||
```
|
||||
✅ test: add unit tests for [module-name]
|
||||
```
|
||||
|
||||
- Push the branch
|
||||
|
||||
- Create a PR with:
|
||||
|
||||
- Title: `✅ test: add unit tests for [module-name]`
|
||||
@@ -202,7 +198,6 @@ describe('ModuleName', () => {
|
||||
- Test approach: [brief description]
|
||||
|
||||
---
|
||||
|
||||
🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
||||
```
|
||||
|
||||
|
||||
@@ -77,24 +77,20 @@ Create `e2e/src/features/{module-name}/README.md` with:
|
||||
# {Module} 模块 E2E 测试覆盖
|
||||
|
||||
## 模块概述
|
||||
|
||||
**路由**: `/module`, `/module/[id]`
|
||||
|
||||
## 功能清单与测试覆盖
|
||||
|
||||
### 1. 功能分组名称
|
||||
|
||||
| 功能点 | 描述 | 优先级 | 状态 | 测试文件 |
|
||||
| ------ | ---- | ------ | ---- | ------------- |
|
||||
| 功能点 | 描述 | 优先级 | 状态 | 测试文件 |
|
||||
| ------ | ---- | ------ | ---- | -------- |
|
||||
| 功能A | xxx | P0 | ✅ | `xxx.feature` |
|
||||
| 功能B | xxx | P1 | ⏳ | |
|
||||
| 功能B | xxx | P1 | ⏳ | |
|
||||
|
||||
## 测试文件结构
|
||||
|
||||
## 测试执行
|
||||
|
||||
## 已知问题
|
||||
|
||||
## 更新记录
|
||||
```
|
||||
|
||||
@@ -232,7 +228,7 @@ const testId = pickle.tags.find(
|
||||
tag.name.startsWith('@COMMUNITY-') ||
|
||||
tag.name.startsWith('@AGENT-') ||
|
||||
tag.name.startsWith('@HOME-') ||
|
||||
tag.name.startsWith('@PAGE-') || // Add new prefix
|
||||
tag.name.startsWith('@PAGE-') || // Add new prefix
|
||||
tag.name.startsWith('@ROUTES-'),
|
||||
);
|
||||
```
|
||||
@@ -304,15 +300,11 @@ HEADLESS=true BASE_URL=http://localhost:3006 \
|
||||
### 10. Create Pull Request
|
||||
|
||||
- Branch name: `test/e2e-{module-name}`
|
||||
|
||||
- Commit message format:
|
||||
|
||||
```
|
||||
✅ test: add E2E tests for {module-name}
|
||||
```
|
||||
|
||||
- PR title: `✅ test: add E2E tests for {module-name}`
|
||||
|
||||
- PR body template:
|
||||
|
||||
````markdown
|
||||
|
||||
@@ -36,7 +36,6 @@ If you detect any leaked secrets, respond IMMEDIATELY with:
|
||||
⚠️ **Security Warning**: Your comment appears to contain sensitive information (API keys, secrets, or credentials).
|
||||
|
||||
**Please delete your comment immediately** to protect your account security, then:
|
||||
|
||||
1. Rotate/regenerate any exposed credentials
|
||||
2. Re-post your question with secrets redacted (e.g., `AUTH_SECRET=***`)
|
||||
|
||||
@@ -74,17 +73,12 @@ Look for the "Troubleshooting" or "FAQ" section in the migration docs and match
|
||||
## Response Guidelines
|
||||
|
||||
1. **Be helpful and friendly** - Users are often frustrated when migration doesn't work
|
||||
|
||||
2. **Be specific** - Provide exact commands or configuration examples
|
||||
|
||||
3. **Reference documentation** - Point users to relevant docs sections
|
||||
|
||||
4. **Ask for logs** - If the issue is unclear, ask for Docker logs:
|
||||
|
||||
```bash
|
||||
docker logs <container_name> 2>&1 | tail -100
|
||||
```
|
||||
|
||||
5. **One issue at a time** - Focus on solving one problem before moving to the next
|
||||
|
||||
## Response Format
|
||||
@@ -96,7 +90,6 @@ Use this format for your responses:
|
||||
|
||||
[If missing information]
|
||||
To help you effectively, please provide:
|
||||
|
||||
- [List missing items]
|
||||
|
||||
[If you can help]
|
||||
@@ -109,7 +102,6 @@ Based on your description, here's what I suggest:
|
||||
|
||||
[If the issue is complex or unknown]
|
||||
This issue needs further investigation. I've notified the team. In the meantime, please:
|
||||
|
||||
1. [Any immediate steps they can try]
|
||||
2. Share your Docker logs if you haven't already
|
||||
```
|
||||
|
||||
@@ -2,14 +2,15 @@
|
||||
|
||||
## Quick Reference by Name
|
||||
|
||||
- **@arvinxx**: General/uncategorized issues (default assignee), priority:high issues, tool calling, mcp, database
|
||||
- **@arvinxx**: Last resort only, mention for priority:high issues, tool calling , mcp
|
||||
- **@canisminor1990**: Design, UI components, editor, markdown rendering
|
||||
- **@tjx666**: Model providers and configuration, new model additions, image/video generation, vision, cloud version, documentation, TTS, auth, login/register, database
|
||||
- **@ONLY-yours**: Performance, streaming, settings, web platform, marketplace, agent builder, schedule task
|
||||
- **@Innei**: Knowledge base, files (KB-related), group chat, Electron, desktop client, build system
|
||||
- **@nekomeowww**: Memory, backend, deployment, DevOps, database
|
||||
- **@tjx666**: Image/video generation, vision, cloud version, documentation, TTS, auth, login/register
|
||||
- **@ONLY-yours**: Performance, streaming, settings, general bugs, web platform, marketplace
|
||||
- **@Innei**: Knowledge base, files (KB-related), group chat
|
||||
- **@nekomeowww**: Memory, backend, deployment, DevOps
|
||||
- **@sudongyuer**: Mobile app (React Native)
|
||||
- **@rdmclin2**: Team workspace, IM and bot integration
|
||||
- **@sxjeru**: Model providers and configuration
|
||||
- **@rdmclin2**: Team workspace
|
||||
- **@tcmonster**: Subscription, refund, recharge, business cooperation
|
||||
|
||||
Quick reference for assigning issues based on labels.
|
||||
@@ -20,14 +21,14 @@ Quick reference for assigning issues based on labels.
|
||||
|
||||
| Label | Owner | Notes |
|
||||
| ---------------- | ------- | -------------------------------------------- |
|
||||
| All `provider:*` | @tjx666 | Model configuration and provider integration |
|
||||
| All `provider:*` | @sxjeru | Model configuration and provider integration |
|
||||
|
||||
### Platform Labels (platform:\*)
|
||||
|
||||
| Label | Owner | Notes |
|
||||
| ------------------ | ----------- | -------------------------------------- |
|
||||
| `platform:mobile` | @sudongyuer | React Native mobile app |
|
||||
| `platform:desktop` | @Innei | Electron desktop client, build system |
|
||||
| `platform:desktop` | @ONLY-yours | Electron desktop client (general) |
|
||||
| `platform:web` | @ONLY-yours | Web platform (unless specific feature) |
|
||||
|
||||
### Feature Labels (feature:\*)
|
||||
@@ -59,9 +60,6 @@ Quick reference for assigning issues based on labels.
|
||||
| `feature:group-chat` | @arvinxx | Group chat functionality |
|
||||
| `feature:memory` | @nekomeowww | Memory feature |
|
||||
| `feature:team-workspace` | @rdmclin2 | Team workspace application |
|
||||
| `feature:im-integration` | @rdmclin2 | IM and bot integration (Slack, Discord, etc.) |
|
||||
| `feature:agent-builder` | @ONLY-yours | Agent builder |
|
||||
| `feature:schedule-task` | @ONLY-yours | Schedule task |
|
||||
| `feature:subscription` | @tcmonster | Subscription and billing |
|
||||
| `feature:refund` | @tcmonster | Refund requests |
|
||||
| `feature:recharge` | @tcmonster | Recharge and payment |
|
||||
@@ -99,10 +97,11 @@ Quick reference for assigning issues based on labels.
|
||||
|
||||
1. **Specific feature owner** - e.g., `feature:knowledge-base` → @RiverTwilight
|
||||
2. **Platform owner** - e.g., `platform:mobile` → @sudongyuer
|
||||
3. **Provider owner** - e.g., `provider:*` → @tjx666
|
||||
3. **Provider owner** - e.g., `provider:*` → @sxjeru
|
||||
4. **Component owner** - e.g., 💄 Design → @canisminor1990
|
||||
5. **Infrastructure owner** - e.g., `deployment:*` → @nekomeowww
|
||||
6. **Default assignee** - @arvinxx for general/uncategorized issues
|
||||
6. **General maintainer** - @ONLY-yours for general bugs/issues
|
||||
7. **Last resort** - @arvinxx (only if no clear owner)
|
||||
|
||||
### Special Cases
|
||||
|
||||
@@ -119,24 +118,25 @@ Quick reference for assigning issues based on labels.
|
||||
|
||||
**No clear owner:**
|
||||
|
||||
- Assign to @arvinxx for general issues
|
||||
- Assign to @ONLY-yours for general issues
|
||||
- Only mention @arvinxx if critical and truly unclear
|
||||
|
||||
## Comment Templates
|
||||
|
||||
**Single owner:**
|
||||
|
||||
```plaintext
|
||||
```
|
||||
@username - This is a [feature/component] issue. Please take a look.
|
||||
```
|
||||
|
||||
**Multiple owners:**
|
||||
|
||||
```plaintext
|
||||
```
|
||||
@primary @secondary - This involves [features]. Please coordinate.
|
||||
```
|
||||
|
||||
**High priority:**
|
||||
|
||||
```plaintext
|
||||
```
|
||||
@owner @arvinxx - High priority [feature] issue.
|
||||
```
|
||||
|
||||
@@ -72,15 +72,11 @@ Module granularity examples:
|
||||
### 5. Create Pull Request
|
||||
|
||||
- Create a new branch: `automatic/translate-comments-[module-name]-[date]`
|
||||
|
||||
- Commit changes with message format:
|
||||
|
||||
```
|
||||
🌐 chore: translate non-English comments to English in [module-name]
|
||||
```
|
||||
|
||||
- Push the branch
|
||||
|
||||
- Create a PR with:
|
||||
|
||||
- Title: `🌐 chore: translate non-English comments to English in [module-name]`
|
||||
@@ -104,7 +100,6 @@ Module granularity examples:
|
||||
`[module-path]`
|
||||
|
||||
---
|
||||
|
||||
🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
||||
```
|
||||
|
||||
|
||||
@@ -136,11 +136,6 @@ OPENAI_API_KEY=sk-xxxxxxxxx
|
||||
|
||||
# MOONSHOT_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
# ## Kimi Code Plan ####
|
||||
|
||||
# KIMICODINGPLAN_PROXY_URL=https://api.kimi.com/coding
|
||||
# KIMICODINGPLAN_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
# ## Minimax AI ####
|
||||
|
||||
# MINIMAX_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
@@ -413,14 +408,3 @@ OPENAI_API_KEY=sk-xxxxxxxxx
|
||||
# IMPORTANT: This key is stored server-side only and NEVER exposed to the client
|
||||
# When this key is set, Klavis integration will be automatically enabled
|
||||
# KLAVIS_API_KEY=your_klavis_api_key_here
|
||||
|
||||
# #######################################
|
||||
# #### Message Gateway (IM Integration) ##
|
||||
# #######################################
|
||||
|
||||
# External message-gateway for unified IM platform connection management.
|
||||
# Set ENABLED=1 to activate. To migrate away, remove ENABLED first (keep URL/TOKEN)
|
||||
# so LobeHub can automatically disconnect leftover gateway connections.
|
||||
# MESSAGE_GATEWAY_ENABLED=1
|
||||
# MESSAGE_GATEWAY_URL=https://message-gateway.lobehub.com
|
||||
# MESSAGE_GATEWAY_SERVICE_TOKEN=your_service_token_here
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
AmAzing129
|
||||
arvinxx
|
||||
canisminor1990
|
||||
ilimei
|
||||
Innei
|
||||
lobehubbot
|
||||
nekomeowww
|
||||
ONLY-yours
|
||||
rdmclin2
|
||||
rivertwilight
|
||||
sudongyuer
|
||||
tcmonster
|
||||
tjx666
|
||||
@@ -18,16 +18,6 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Check if author is a team member
|
||||
id: check-team
|
||||
run: |
|
||||
ISSUE_AUTHOR="${{ github.event.issue.user.login }}"
|
||||
if grep -iq "^${ISSUE_AUTHOR}$" .github/maintainers.txt; then
|
||||
echo "is_team=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "is_team=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Copy triage prompts
|
||||
run: |
|
||||
mkdir -p /tmp/claude-prompts
|
||||
@@ -72,7 +62,7 @@ jobs:
|
||||
**IMPORTANT**:
|
||||
- Follow ALL steps in the issue-triage.md guide
|
||||
- Apply labels according to the guide's rules
|
||||
- ${{ steps.check-team.outputs.is_team == 'true' && 'The issue author is a team member. Do NOT post any @mention comment.' || 'Post a mention comment to the appropriate team member(s) based on team-assignment.md' }}
|
||||
- Post a mention comment to the appropriate team member(s) based on team-assignment.md
|
||||
- Replace [ISSUE_NUMBER] with: ${{ github.event.issue.number }}
|
||||
|
||||
**Start the triage process now.**
|
||||
|
||||
@@ -21,18 +21,7 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Check if author is a team member
|
||||
id: check-team
|
||||
run: |
|
||||
PR_AUTHOR="${{ github.event.pull_request.user.login }}"
|
||||
if grep -iq "^${PR_AUTHOR}$" .github/maintainers.txt; then
|
||||
echo "is_team=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "is_team=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Copy prompts
|
||||
if: steps.check-team.outputs.is_team == 'false'
|
||||
run: |
|
||||
mkdir -p /tmp/claude-prompts
|
||||
cp .claude/prompts/pr-assign.md /tmp/claude-prompts/
|
||||
@@ -40,7 +29,6 @@ jobs:
|
||||
cp .claude/prompts/security-rules.md /tmp/claude-prompts/
|
||||
|
||||
- name: Run Claude Code for PR Reviewer Assignment
|
||||
if: steps.check-team.outputs.is_team == 'false'
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
github_token: ${{ secrets.GH_TOKEN }}
|
||||
|
||||
@@ -28,21 +28,9 @@ jobs:
|
||||
✅ @{{ author }}
|
||||
|
||||
This issue is closed, If you have any questions, you can comment and reply.
|
||||
- name: Checkout repository
|
||||
if: github.event_name == 'pull_request_target' && github.event.pull_request.merged == true
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Check if PR author is maintainer
|
||||
if: github.event.pull_request.merged == true
|
||||
id: maintainer-check
|
||||
run: |
|
||||
if [ -f .github/maintainers.txt ] && grep -qx "${{ github.event.pull_request.user.login }}" .github/maintainers.txt; then
|
||||
echo "skip=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Auto Comment on Pull Request Merged
|
||||
uses: actions-cool/pr-welcome@main
|
||||
if: github.event.pull_request.merged == true && steps.maintainer-check.outputs.skip != 'true'
|
||||
if: github.event.pull_request.merged == true
|
||||
with:
|
||||
token: ${{ secrets.GH_TOKEN }}
|
||||
comment: |
|
||||
|
||||
@@ -6,10 +6,10 @@ on:
|
||||
channel:
|
||||
description: 'Release channel for desktop build (affects version suffix and workflow:set-desktop-version)'
|
||||
required: true
|
||||
default: canary
|
||||
default: nightly
|
||||
type: choice
|
||||
options:
|
||||
- canary
|
||||
- nightly
|
||||
- beta
|
||||
- stable
|
||||
build_macos:
|
||||
@@ -118,8 +118,8 @@ jobs:
|
||||
KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE='
|
||||
CSC_LINK: ${{ secrets.APPLE_CERTIFICATE_BASE64 }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ inputs.channel == 'stable' && secrets.UMAMI_STABLE_DESKTOP_PROJECT_ID || secrets.UMAMI_BETA_DESKTOP_PROJECT_ID }}
|
||||
NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ inputs.channel == 'stable' && secrets.UMAMI_STABLE_DESKTOP_BASE_URL || secrets.UMAMI_BETA_DESKTOP_BASE_URL }}
|
||||
NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ inputs.channel == 'beta' && secrets.UMAMI_BETA_DESKTOP_PROJECT_ID || secrets.UMAMI_NIGHTLY_DESKTOP_PROJECT_ID }}
|
||||
NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ inputs.channel == 'beta' && secrets.UMAMI_BETA_DESKTOP_BASE_URL || secrets.UMAMI_NIGHTLY_DESKTOP_BASE_URL }}
|
||||
CSC_FOR_PULL_REQUEST: true
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
@@ -184,8 +184,8 @@ jobs:
|
||||
APP_URL: http://localhost:3015
|
||||
DATABASE_URL: 'postgresql://postgres@localhost:5432/postgres'
|
||||
KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE='
|
||||
NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ inputs.channel == 'stable' && secrets.UMAMI_STABLE_DESKTOP_PROJECT_ID || secrets.UMAMI_BETA_DESKTOP_PROJECT_ID }}
|
||||
NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ inputs.channel == 'stable' && secrets.UMAMI_STABLE_DESKTOP_BASE_URL || secrets.UMAMI_BETA_DESKTOP_BASE_URL }}
|
||||
NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ inputs.channel == 'beta' && secrets.UMAMI_BETA_DESKTOP_PROJECT_ID || secrets.UMAMI_NIGHTLY_DESKTOP_PROJECT_ID }}
|
||||
NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ inputs.channel == 'beta' && secrets.UMAMI_BETA_DESKTOP_BASE_URL || secrets.UMAMI_NIGHTLY_DESKTOP_BASE_URL }}
|
||||
TEMP: C:\temp
|
||||
TMP: C:\temp
|
||||
|
||||
@@ -228,8 +228,8 @@ jobs:
|
||||
APP_URL: http://localhost:3015
|
||||
DATABASE_URL: 'postgresql://postgres@localhost:5432/postgres'
|
||||
KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE='
|
||||
NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ inputs.channel == 'stable' && secrets.UMAMI_STABLE_DESKTOP_PROJECT_ID || secrets.UMAMI_BETA_DESKTOP_PROJECT_ID }}
|
||||
NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ inputs.channel == 'stable' && secrets.UMAMI_STABLE_DESKTOP_BASE_URL || secrets.UMAMI_BETA_DESKTOP_BASE_URL }}
|
||||
NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ inputs.channel == 'beta' && secrets.UMAMI_BETA_DESKTOP_PROJECT_ID || secrets.UMAMI_NIGHTLY_DESKTOP_PROJECT_ID }}
|
||||
NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ inputs.channel == 'beta' && secrets.UMAMI_BETA_DESKTOP_BASE_URL || secrets.UMAMI_NIGHTLY_DESKTOP_BASE_URL }}
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
|
||||
@@ -7,7 +7,7 @@ name: Release Desktop Beta
|
||||
# 如: v2.0.0-beta.1, v2.0.0-alpha.1, v2.0.0-rc.1
|
||||
#
|
||||
# 注意: Stable 版本 (如 v2.0.0) 由 release-desktop-stable.yml 处理
|
||||
# 注意: Nightly 版本已停用,不再参与 Desktop 发布流程
|
||||
# 注意: Nightly 版本 (如 v2.1.0-nightly.xxx) 由 release-desktop-nightly.yml 处理
|
||||
# ============================================
|
||||
|
||||
on:
|
||||
@@ -41,10 +41,10 @@ jobs:
|
||||
version="${version#v}"
|
||||
echo "version=${version}" >> $GITHUB_OUTPUT
|
||||
|
||||
# Beta 版本包含 beta/alpha/rc;nightly 标签已停用
|
||||
# Beta 版本包含 beta/alpha/rc (nightly 由 release-desktop-nightly.yml 处理)
|
||||
if [[ "$version" == *"nightly"* ]]; then
|
||||
echo "is_beta=false" >> $GITHUB_OUTPUT
|
||||
echo "⏭️ Skipping: $version is a disabled nightly release tag"
|
||||
echo "⏭️ Skipping: $version is a nightly release (handled by release-desktop-nightly.yml)"
|
||||
elif [[ "$version" == *"beta"* ]] || [[ "$version" == *"alpha"* ]] || [[ "$version" == *"rc"* ]]; then
|
||||
echo "is_beta=true" >> $GITHUB_OUTPUT
|
||||
echo "✅ Beta release detected: $version"
|
||||
|
||||
@@ -45,7 +45,6 @@ jobs:
|
||||
name: Calculate Canary Version
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
release_notes: ${{ steps.release-notes.outputs.release_notes }}
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
tag: ${{ steps.version.outputs.tag }}
|
||||
should_build: ${{ steps.check.outputs.should_build }}
|
||||
@@ -122,66 +121,6 @@ jobs:
|
||||
echo "✅ Canary version: ${version}"
|
||||
echo "🏷️ Tag: ${tag}"
|
||||
|
||||
- name: Generate canary release notes
|
||||
if: steps.check.outputs.should_build == 'true'
|
||||
id: release-notes
|
||||
env:
|
||||
TAG: ${{ steps.version.outputs.tag }}
|
||||
run: |
|
||||
previous_canary=$(git tag --sort=-creatordate | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+-canary\.[0-9]+$' | head -n 1)
|
||||
latest_stable=$(git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n 1)
|
||||
|
||||
if [ -n "$previous_canary" ]; then
|
||||
compare_from="$previous_canary"
|
||||
compare_range="${previous_canary}..HEAD"
|
||||
elif [ -n "$latest_stable" ]; then
|
||||
compare_from="$latest_stable"
|
||||
compare_range="${latest_stable}..HEAD"
|
||||
else
|
||||
compare_from="initial commit"
|
||||
compare_range="HEAD"
|
||||
fi
|
||||
|
||||
commit_count=$(git rev-list --count "$compare_range")
|
||||
commits=$(git log --no-merges --pretty='- `%h` %s (%an)' "$compare_range")
|
||||
|
||||
if [ -z "$commits" ]; then
|
||||
commits='- No new commits recorded.'
|
||||
fi
|
||||
|
||||
{
|
||||
echo "release_notes<<EOF"
|
||||
echo "## 🐤 Canary Build — ${TAG}"
|
||||
echo
|
||||
echo "> Automated canary build from \`canary\` branch."
|
||||
echo
|
||||
echo "### Commit Information"
|
||||
echo
|
||||
echo "- Based on changes since \`${compare_from}\`"
|
||||
echo "- Commit count: ${commit_count}"
|
||||
echo
|
||||
printf '%s\n' "$commits"
|
||||
echo
|
||||
echo "### ⚠️ Important Notes"
|
||||
echo
|
||||
echo "- **This is an automated canary build and is NOT intended for production use.**"
|
||||
echo "- Canary builds are triggered by \`build\`/\`fix\`/\`style\` commits on the \`canary\` branch."
|
||||
echo "- May contain **unstable or incomplete changes**. **Use at your own risk.**"
|
||||
echo "- It is strongly recommended to **back up your data** before using a canary build."
|
||||
echo
|
||||
echo "### 📦 Installation"
|
||||
echo
|
||||
echo "Download the appropriate installer for your platform from the assets below."
|
||||
echo
|
||||
echo "| Platform | File |"
|
||||
echo "|----------|------|"
|
||||
echo "| macOS (Apple Silicon) | \`.dmg\` (arm64) |"
|
||||
echo "| macOS (Intel) | \`.dmg\` (x64) |"
|
||||
echo "| Windows | \`.exe\` |"
|
||||
echo "| Linux | \`.AppImage\` / \`.deb\` |"
|
||||
echo "EOF"
|
||||
} >> $GITHUB_OUTPUT
|
||||
|
||||
# ============================================
|
||||
# 代码质量检查
|
||||
# ============================================
|
||||
@@ -243,7 +182,6 @@ jobs:
|
||||
env:
|
||||
UPDATE_CHANNEL: canary
|
||||
UPDATE_SERVER_URL: ${{ secrets.UPDATE_SERVER_URL }}
|
||||
RELEASE_NOTES: ${{ needs.calculate-version.outputs.release_notes }}
|
||||
APP_URL: http://localhost:3015
|
||||
DATABASE_URL: 'postgresql://postgres@localhost:5432/postgres'
|
||||
KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE='
|
||||
@@ -263,7 +201,6 @@ jobs:
|
||||
env:
|
||||
UPDATE_CHANNEL: canary
|
||||
UPDATE_SERVER_URL: ${{ secrets.UPDATE_SERVER_URL }}
|
||||
RELEASE_NOTES: ${{ needs.calculate-version.outputs.release_notes }}
|
||||
APP_URL: http://localhost:3015
|
||||
DATABASE_URL: 'postgresql://postgres@localhost:5432/postgres'
|
||||
KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE='
|
||||
@@ -279,7 +216,6 @@ jobs:
|
||||
env:
|
||||
UPDATE_CHANNEL: canary
|
||||
UPDATE_SERVER_URL: ${{ secrets.UPDATE_SERVER_URL }}
|
||||
RELEASE_NOTES: ${{ needs.calculate-version.outputs.release_notes }}
|
||||
APP_URL: http://localhost:3015
|
||||
DATABASE_URL: 'postgresql://postgres@localhost:5432/postgres'
|
||||
KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE='
|
||||
@@ -363,7 +299,28 @@ jobs:
|
||||
tag_name: ${{ needs.calculate-version.outputs.tag }}
|
||||
name: 'Desktop Canary ${{ needs.calculate-version.outputs.tag }}'
|
||||
prerelease: true
|
||||
body: ${{ needs.calculate-version.outputs.release_notes }}
|
||||
body: |
|
||||
## 🐤 Canary Build — ${{ needs.calculate-version.outputs.tag }}
|
||||
|
||||
> Automated canary build from `canary` branch.
|
||||
|
||||
### ⚠️ Important Notes
|
||||
|
||||
- **This is an automated canary build and is NOT intended for production use.**
|
||||
- Canary builds are triggered by `build`/`fix`/`style` commits on the `canary` branch.
|
||||
- May contain **unstable or incomplete changes**. **Use at your own risk.**
|
||||
- It is strongly recommended to **back up your data** before using a canary build.
|
||||
|
||||
### 📦 Installation
|
||||
|
||||
Download the appropriate installer for your platform from the assets below.
|
||||
|
||||
| Platform | File |
|
||||
|----------|------|
|
||||
| macOS (Apple Silicon) | `.dmg` (arm64) |
|
||||
| macOS (Intel) | `.dmg` (x64) |
|
||||
| Windows | `.exe` |
|
||||
| Linux | `.AppImage` / `.deb` |
|
||||
files: |
|
||||
release/latest*
|
||||
release/*.dmg*
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
name: Release Desktop Nightly
|
||||
|
||||
# ============================================
|
||||
# Nightly 自动发版工作流
|
||||
# ============================================
|
||||
# 触发条件:
|
||||
# 1. 定时: 每天 UTC+8 14:00 (UTC 06:00)
|
||||
# 2. 手动触发 (workflow_dispatch)
|
||||
#
|
||||
# 版本策略:
|
||||
# 基于最新 tag 的 minor+1, 格式: X.(Y+1).0-nightly.YYYYMMDDHHMM
|
||||
# 例: 当前 tag v2.0.12 → v2.1.0-nightly.202502091400
|
||||
# 使用精确到分钟的时间戳避免同一天多次触发时 tag 冲突
|
||||
# ============================================
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 6 * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
force:
|
||||
description: 'Force build (skip diff check)'
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions: read-all
|
||||
|
||||
env:
|
||||
NODE_VERSION: '24.11.1'
|
||||
|
||||
jobs:
|
||||
# ============================================
|
||||
# 计算 Nightly 版本号
|
||||
# ============================================
|
||||
calculate-version:
|
||||
name: Calculate Nightly Version
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
tag: ${{ steps.version.outputs.tag }}
|
||||
has_changes: ${{ steps.changes.outputs.has_changes }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Check for code changes since last nightly
|
||||
id: changes
|
||||
run: |
|
||||
# 手动触发 + force 时跳过 diff 检查
|
||||
if [ "${{ inputs.force }}" == "true" ]; then
|
||||
echo "has_changes=true" >> $GITHUB_OUTPUT
|
||||
echo "🔧 Force build requested, skipping diff check"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 查找上一个 nightly tag
|
||||
last_nightly=$(git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+-nightly\.' | head -n 1)
|
||||
|
||||
if [ -z "$last_nightly" ]; then
|
||||
echo "has_changes=true" >> $GITHUB_OUTPUT
|
||||
echo "📦 No previous nightly tag found, proceeding with first nightly build"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "📌 Last nightly tag: $last_nightly"
|
||||
|
||||
# 对比指定目录是否有变更
|
||||
changes=$(git diff --name-only "$last_nightly"..HEAD -- package.json src/ packages/ apps/desktop/)
|
||||
|
||||
if [ -z "$changes" ]; then
|
||||
echo "has_changes=false" >> $GITHUB_OUTPUT
|
||||
echo "⏭️ No code changes since $last_nightly, skipping nightly build"
|
||||
else
|
||||
echo "has_changes=true" >> $GITHUB_OUTPUT
|
||||
change_count=$(echo "$changes" | wc -l | tr -d ' ')
|
||||
echo "✅ ${change_count} file(s) changed since $last_nightly:"
|
||||
echo "$changes" | head -20
|
||||
[ "$change_count" -gt 20 ] && echo " ... and $((change_count - 20)) more"
|
||||
fi
|
||||
|
||||
- name: Calculate nightly version
|
||||
if: steps.changes.outputs.has_changes == 'true'
|
||||
id: version
|
||||
run: |
|
||||
# 获取最新的 tag (排除 nightly tag)
|
||||
latest_tag=$(git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n 1)
|
||||
|
||||
if [ -z "$latest_tag" ]; then
|
||||
echo "❌ No stable tag found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "📌 Latest stable tag: $latest_tag"
|
||||
|
||||
# 去掉 v 前缀
|
||||
base_version="${latest_tag#v}"
|
||||
|
||||
# 解析 major.minor.patch
|
||||
IFS='.' read -r major minor patch <<< "$base_version"
|
||||
|
||||
# minor + 1, patch 归零
|
||||
new_minor=$((minor + 1))
|
||||
timestamp=$(date -u +"%Y%m%d%H%M")
|
||||
|
||||
version="${major}.${new_minor}.0-nightly.${timestamp}"
|
||||
tag="v${version}"
|
||||
|
||||
echo "version=${version}" >> $GITHUB_OUTPUT
|
||||
echo "tag=${tag}" >> $GITHUB_OUTPUT
|
||||
echo "✅ Nightly version: ${version}"
|
||||
echo "🏷️ Tag: ${tag}"
|
||||
|
||||
# ============================================
|
||||
# 代码质量检查
|
||||
# ============================================
|
||||
test:
|
||||
name: Code quality check
|
||||
needs: [calculate-version]
|
||||
if: needs.calculate-version.outputs.has_changes == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout base
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- name: Install deps
|
||||
run: pnpm install
|
||||
|
||||
- name: Lint
|
||||
run: bun run lint
|
||||
|
||||
# ============================================
|
||||
# 多平台构建
|
||||
# ============================================
|
||||
build:
|
||||
needs: [calculate-version, test]
|
||||
if: needs.calculate-version.outputs.has_changes == 'true'
|
||||
name: Build Desktop App
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [macos-15, macos-15-intel, windows-2025, ubuntu-latest]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup build environment
|
||||
uses: ./.github/actions/desktop-build-setup
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- name: Set package version
|
||||
run: npm run workflow:set-desktop-version ${{ needs.calculate-version.outputs.version }} nightly
|
||||
|
||||
# macOS 构建前清理 (修复 hdiutil 问题 https://github.com/electron-userland/electron-builder/issues/8415)
|
||||
- name: Clean previous build artifacts (macOS)
|
||||
if: runner.os == 'macOS'
|
||||
run: |
|
||||
sudo rm -rf apps/desktop/release || true
|
||||
sudo rm -rf apps/desktop/dist || true
|
||||
sudo rm -rf /tmp/electron-builder* || true
|
||||
|
||||
# macOS 构建
|
||||
- name: Build artifact on macOS
|
||||
if: runner.os == 'macOS'
|
||||
run: npm run desktop:package:app
|
||||
env:
|
||||
UPDATE_CHANNEL: nightly
|
||||
UPDATE_SERVER_URL: ${{ secrets.UPDATE_SERVER_URL }}
|
||||
APP_URL: http://localhost:3015
|
||||
DATABASE_URL: 'postgresql://postgres@localhost:5432/postgres'
|
||||
KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE='
|
||||
CSC_LINK: ${{ secrets.APPLE_CERTIFICATE_BASE64 }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
CSC_FOR_PULL_REQUEST: true
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ secrets.UMAMI_BETA_DESKTOP_PROJECT_ID }}
|
||||
NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ secrets.UMAMI_BETA_DESKTOP_BASE_URL }}
|
||||
|
||||
# Windows 构建
|
||||
- name: Build artifact on Windows
|
||||
if: runner.os == 'Windows'
|
||||
run: npm run desktop:package:app
|
||||
env:
|
||||
UPDATE_CHANNEL: nightly
|
||||
UPDATE_SERVER_URL: ${{ secrets.UPDATE_SERVER_URL }}
|
||||
APP_URL: http://localhost:3015
|
||||
DATABASE_URL: 'postgresql://postgres@localhost:5432/postgres'
|
||||
KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE='
|
||||
NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ secrets.UMAMI_BETA_DESKTOP_PROJECT_ID }}
|
||||
NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ secrets.UMAMI_BETA_DESKTOP_BASE_URL }}
|
||||
TEMP: C:\temp
|
||||
TMP: C:\temp
|
||||
|
||||
# Linux 构建
|
||||
- name: Build artifact on Linux
|
||||
if: runner.os == 'Linux'
|
||||
run: npm run desktop:package:app
|
||||
env:
|
||||
UPDATE_CHANNEL: nightly
|
||||
UPDATE_SERVER_URL: ${{ secrets.UPDATE_SERVER_URL }}
|
||||
APP_URL: http://localhost:3015
|
||||
DATABASE_URL: 'postgresql://postgres@localhost:5432/postgres'
|
||||
KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE='
|
||||
NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ secrets.UMAMI_BETA_DESKTOP_PROJECT_ID }}
|
||||
NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ secrets.UMAMI_BETA_DESKTOP_BASE_URL }}
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: ./.github/actions/desktop-upload-artifacts
|
||||
with:
|
||||
artifact-name: release-${{ matrix.os }}
|
||||
retention-days: 3
|
||||
|
||||
# ============================================
|
||||
# 合并 macOS 多架构 latest-mac.yml 文件
|
||||
# ============================================
|
||||
merge-mac-files:
|
||||
needs: [build]
|
||||
name: Merge macOS Release Files
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
path: release
|
||||
pattern: release-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: List downloaded artifacts
|
||||
run: ls -R release
|
||||
|
||||
- name: Install yaml only for merge step
|
||||
run: |
|
||||
cd scripts/electronWorkflow
|
||||
if [ ! -f package.json ]; then
|
||||
echo '{"name":"merge-mac-release","private":true}' > package.json
|
||||
fi
|
||||
bun add --no-save yaml@2.8.1
|
||||
|
||||
- name: Merge latest-mac.yml files
|
||||
run: bun run scripts/electronWorkflow/mergeMacReleaseFiles.js
|
||||
|
||||
- name: Upload artifacts with merged macOS files
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: merged-release
|
||||
path: release/
|
||||
retention-days: 1
|
||||
|
||||
# ============================================
|
||||
# 创建 Nightly Release
|
||||
# ============================================
|
||||
publish-release:
|
||||
needs: [merge-mac-files, calculate-version]
|
||||
name: Publish Nightly Release
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Download merged artifacts
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: merged-release
|
||||
path: release
|
||||
|
||||
- name: List final artifacts
|
||||
run: ls -R release
|
||||
|
||||
- name: Create Nightly Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
tag_name: ${{ needs.calculate-version.outputs.tag }}
|
||||
name: 'Desktop Nightly ${{ needs.calculate-version.outputs.tag }}'
|
||||
prerelease: true
|
||||
body: |
|
||||
## 🌙 Nightly Build — ${{ needs.calculate-version.outputs.tag }}
|
||||
|
||||
> Automated nightly build from `main` branch.
|
||||
|
||||
### ⚠️ Important Notes
|
||||
|
||||
- **This is an automated nightly build and is NOT intended for production use.**
|
||||
- Nightly builds are generated from the latest `main` branch and may contain **unstable, untested, or incomplete features**.
|
||||
- **No guarantees** are made regarding stability, data integrity, or backward compatibility.
|
||||
- Bugs, crashes, and breaking changes are expected. **Use at your own risk.**
|
||||
- **Do NOT report bugs** from nightly builds unless you can reproduce them on the latest beta or stable release.
|
||||
- Nightly builds may have **different update channels** — they will not auto-update to/from stable or beta versions.
|
||||
- It is strongly recommended to **back up your data** before using a nightly build.
|
||||
|
||||
### 📦 Installation
|
||||
|
||||
Download the appropriate installer for your platform from the assets below.
|
||||
|
||||
| Platform | File |
|
||||
|----------|------|
|
||||
| macOS (Apple Silicon) | `.dmg` (arm64) |
|
||||
| macOS (Intel) | `.dmg` (x64) |
|
||||
| Windows | `.exe` |
|
||||
| Linux | `.AppImage` / `.deb` |
|
||||
files: |
|
||||
release/latest*
|
||||
release/*.dmg*
|
||||
release/*.zip*
|
||||
release/*.exe*
|
||||
release/*.AppImage
|
||||
release/*.deb*
|
||||
release/*.snap*
|
||||
release/*.rpm*
|
||||
release/*.tar.gz*
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# ============================================
|
||||
# 发布到 S3 更新服务器
|
||||
# ============================================
|
||||
publish-s3:
|
||||
needs: [merge-mac-files, calculate-version]
|
||||
name: Publish to S3
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: ./.github/actions/desktop-publish-s3
|
||||
with:
|
||||
channel: nightly
|
||||
version: ${{ needs.calculate-version.outputs.version }}
|
||||
aws-access-key-id: ${{ secrets.UPDATE_AWS_ACCESS_KEY_ID }}
|
||||
aws-secret-access-key: ${{ secrets.UPDATE_AWS_SECRET_ACCESS_KEY }}
|
||||
s3-bucket: ${{ secrets.UPDATE_S3_BUCKET }}
|
||||
s3-region: ${{ secrets.UPDATE_S3_REGION }}
|
||||
s3-endpoint: ${{ secrets.UPDATE_S3_ENDPOINT }}
|
||||
|
||||
# ============================================
|
||||
# 清理旧的 Nightly Releases (保留最近 7 个)
|
||||
# ============================================
|
||||
cleanup-old-nightlies:
|
||||
needs: [publish-release, publish-s3]
|
||||
name: Cleanup Old Nightly Releases
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Delete old nightly GitHub releases
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const { data: releases } = await github.rest.repos.listReleases({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const nightlyReleases = releases
|
||||
.filter(r => r.tag_name.includes('-nightly.'))
|
||||
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
|
||||
|
||||
const toDelete = nightlyReleases.slice(7);
|
||||
|
||||
for (const release of toDelete) {
|
||||
console.log(`🗑️ Deleting old nightly release: ${release.tag_name}`);
|
||||
|
||||
// Delete the release
|
||||
await github.rest.repos.deleteRelease({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
release_id: release.id,
|
||||
});
|
||||
|
||||
// Delete the tag
|
||||
try {
|
||||
await github.rest.git.deleteRef({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
ref: `tags/${release.tag_name}`,
|
||||
});
|
||||
} catch (e) {
|
||||
console.log(`⚠️ Could not delete tag ${release.tag_name}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`✅ Cleanup complete. Kept ${Math.min(nightlyReleases.length, 7)} nightly releases, deleted ${toDelete.length}.`);
|
||||
|
||||
- name: Cleanup old S3 versions
|
||||
uses: ./.github/actions/desktop-cleanup-s3
|
||||
with:
|
||||
channel: nightly
|
||||
keep-count: '15'
|
||||
aws-access-key-id: ${{ secrets.UPDATE_AWS_ACCESS_KEY_ID }}
|
||||
aws-secret-access-key: ${{ secrets.UPDATE_AWS_SECRET_ACCESS_KEY }}
|
||||
s3-bucket: ${{ secrets.UPDATE_S3_BUCKET }}
|
||||
s3-region: ${{ secrets.UPDATE_S3_REGION }}
|
||||
s3-endpoint: ${{ secrets.UPDATE_S3_ENDPOINT }}
|
||||
@@ -1,129 +0,0 @@
|
||||
name: Release ModelBank
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- canary
|
||||
paths:
|
||||
- packages/model-bank/**
|
||||
workflow_dispatch: {}
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build ModelBank
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24.11.1
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Build package
|
||||
run: pnpm --filter model-bank build
|
||||
|
||||
publish:
|
||||
name: Publish ModelBank
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24.11.1
|
||||
registry-url: https://registry.npmjs.org
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Build package
|
||||
run: pnpm --filter model-bank build
|
||||
|
||||
- name: Prepare publish package
|
||||
id: version
|
||||
run: |
|
||||
BASE_VERSION=$(node -p "require('./packages/model-bank/package.json').version.split('.').slice(0, 2).join('.')")
|
||||
MODEL_BANK_VERSION="${BASE_VERSION}.$(date -u +%Y%m%d%H%M%S)"
|
||||
export MODEL_BANK_VERSION
|
||||
|
||||
node <<'NODE'
|
||||
const fs = require('node:fs');
|
||||
|
||||
const packagePath = 'packages/model-bank/package.json';
|
||||
const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
|
||||
const toDistExport = (sourcePath) => sourcePath.replace('./src/', './dist/').replace(/\.ts$/, '.mjs');
|
||||
|
||||
packageJson.version = process.env.MODEL_BANK_VERSION;
|
||||
packageJson.type = 'module';
|
||||
packageJson.main = './dist/index.mjs';
|
||||
packageJson.types = './dist/index.d.mts';
|
||||
packageJson.files = ['dist'];
|
||||
packageJson.repository = {
|
||||
type: 'git',
|
||||
url: 'https://github.com/lobehub/lobehub',
|
||||
directory: 'packages/model-bank',
|
||||
};
|
||||
packageJson.exports = Object.fromEntries(
|
||||
Object.entries(packageJson.exports).map(([key, value]) => {
|
||||
if (typeof value !== 'string') return [key, value];
|
||||
|
||||
const distPath = toDistExport(value);
|
||||
|
||||
return [
|
||||
key,
|
||||
{
|
||||
types: distPath.replace(/\.mjs$/, '.d.mts'),
|
||||
import: distPath,
|
||||
default: distPath,
|
||||
},
|
||||
];
|
||||
}),
|
||||
);
|
||||
|
||||
delete packageJson.private;
|
||||
delete packageJson.devDependencies;
|
||||
delete packageJson.scripts;
|
||||
|
||||
if (packageJson.dependencies) {
|
||||
delete packageJson.dependencies['@lobechat/business-const'];
|
||||
|
||||
if (Object.keys(packageJson.dependencies).length === 0) {
|
||||
delete packageJson.dependencies;
|
||||
}
|
||||
}
|
||||
|
||||
fs.writeFileSync(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`);
|
||||
NODE
|
||||
|
||||
echo "version=${MODEL_BANK_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "Prepared model-bank@${MODEL_BANK_VERSION}"
|
||||
|
||||
- name: Publish to npm
|
||||
run: npm publish --provenance --access public
|
||||
working-directory: packages/model-bank
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
@@ -97,8 +97,8 @@ jobs:
|
||||
if: needs.check-duplicate-run.outputs.should_skip != 'true'
|
||||
strategy:
|
||||
matrix:
|
||||
shard: [1, 2, 3]
|
||||
name: Test App (shard ${{ matrix.shard }}/3)
|
||||
shard: [1, 2]
|
||||
name: Test App (shard ${{ matrix.shard }}/2)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
@@ -110,7 +110,7 @@ jobs:
|
||||
run: pnpm install
|
||||
|
||||
- name: Run tests
|
||||
run: bunx vitest --coverage --silent='passed-only' --reporter=default --reporter=blob --shard=${{ matrix.shard }}/3
|
||||
run: bunx vitest --coverage --silent='passed-only' --reporter=default --reporter=blob --shard=${{ matrix.shard }}/2
|
||||
|
||||
- name: Upload blob report
|
||||
if: ${{ !cancelled() }}
|
||||
|
||||
+1
-10
@@ -25,9 +25,6 @@ Desktop.ini
|
||||
*.code-workspace
|
||||
.vscode/sessions.json
|
||||
prd
|
||||
# Recordings
|
||||
.records/
|
||||
|
||||
# Temporary files
|
||||
.temp/
|
||||
temp/
|
||||
@@ -140,11 +137,5 @@ pnpm-lock.yaml
|
||||
.turbo
|
||||
spaHtmlTemplates.ts
|
||||
|
||||
# Embedded CLI bundle (built at pack time)
|
||||
apps/desktop/resources/bin/lobe-cli.js
|
||||
apps/desktop/resources/cli-package.json
|
||||
|
||||
# Superpowers plugin brainstorm/spec outputs (local only; do not commit)
|
||||
.superpowers/
|
||||
docs/superpowers/
|
||||
.heerogeneous-tracing
|
||||
docs/superpowers
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
const { defineConfig } = require('@lobehub/i18n-cli');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
module.exports = defineConfig({
|
||||
entry: 'locales/en-US',
|
||||
@@ -27,14 +27,14 @@ module.exports = defineConfig({
|
||||
],
|
||||
temperature: 0,
|
||||
saveImmediately: true,
|
||||
modelName: 'gpt-4o',
|
||||
modelName: 'chatgpt-4o-latest',
|
||||
experimental: {
|
||||
jsonMode: true,
|
||||
},
|
||||
markdown: {
|
||||
reference:
|
||||
'You need to maintain the component format of the mdx file; the output text does not need to be wrapped in any code block syntax on the outermost layer.\n' +
|
||||
fs.readFileSync(path.join(__dirname, 'docs/glossary.md'), 'utf8'),
|
||||
fs.readFileSync(path.join(__dirname, 'docs/glossary.md'), 'utf-8'),
|
||||
entry: ['./README.md', './docs/**/*.md', './docs/**/*.mdx'],
|
||||
entryLocale: 'en-US',
|
||||
outputLocales: ['zh-CN'],
|
||||
|
||||
Vendored
+4
-6
@@ -6,11 +6,7 @@
|
||||
},
|
||||
"editor.formatOnSave": true,
|
||||
// don't show errors, but fix when save and git pre commit
|
||||
"eslint.rules.customizations": [
|
||||
{ "rule": "simple-import-sort/exports", "severity": "off" },
|
||||
{ "rule": "perfectionist/sort-interfaces", "severity": "off" },
|
||||
{ "rule": "simple-import-sort/imports", "severity": "off" }
|
||||
],
|
||||
"eslint.rules.customizations": [],
|
||||
"eslint.validate": [
|
||||
"json",
|
||||
"javascript",
|
||||
@@ -20,7 +16,7 @@
|
||||
// support mdx
|
||||
"mdx"
|
||||
],
|
||||
"js/ts.tsdk.path": "node_modules/typescript/lib",
|
||||
"mdx.server.enable": false,
|
||||
"npm.packageManager": "pnpm",
|
||||
"search.exclude": {
|
||||
"**/node_modules": true,
|
||||
@@ -48,7 +44,9 @@
|
||||
// make stylelint work with tsx antd-style css template string
|
||||
"typescriptreact"
|
||||
],
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"vitest.disableWorkspaceWarning": true,
|
||||
"vitest.maximumConfigs": 10,
|
||||
"workbench.editor.customLabels.patterns": {
|
||||
"**/app/**/[[]*[]]/[[]*[]]/page.tsx": "${dirname(2)}/${dirname(1)}/${dirname} • page component",
|
||||
"**/app/**/[[]*[]]/page.tsx": "${dirname(1)}/${dirname} • page component",
|
||||
|
||||
@@ -1,128 +1,100 @@
|
||||
# LobeHub Development Guidelines
|
||||
|
||||
Guidelines for using AI coding agents in this LobeHub repository.
|
||||
This document serves as a comprehensive guide for all team members when developing LobeHub.
|
||||
|
||||
## Project Description
|
||||
|
||||
You are developing an open-source, modern-design AI Agent Workspace: LobeHub (previously LobeChat).
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- Next.js 16 + React 19 + TypeScript
|
||||
- SPA inside Next.js with `react-router-dom`
|
||||
- `@lobehub/ui`, antd for components; antd-style for CSS-in-JS — **prefer `createStaticStyles` with `cssVar.*`** (zero-runtime); only fall back to `createStyles` + `token` when styles genuinely need runtime computation. See `.cursor/docs/createStaticStyles_migration_guide.md`.
|
||||
- react-i18next for i18n; zustand for state management
|
||||
- SWR for data fetching; TRPC for type-safe backend
|
||||
- Drizzle ORM with PostgreSQL; Vitest for testing
|
||||
- **Frontend**: Next.js 16, React 19, TypeScript
|
||||
- **UI Components**: Ant Design, @lobehub/ui, antd-style
|
||||
- **State Management**: Zustand, SWR
|
||||
- **Database**: PostgreSQL, PGLite, Drizzle ORM
|
||||
- **Testing**: Vitest, Testing Library
|
||||
- **Package Manager**: pnpm (monorepo structure)
|
||||
|
||||
## Project Structure
|
||||
## Directory Structure
|
||||
|
||||
```plaintext
|
||||
lobehub/
|
||||
├── apps/
|
||||
│ ├── desktop/ # Electron desktop app
|
||||
│ ├── cli/ # LobeHub CLI
|
||||
│ └── device-gateway/ # Device gateway service
|
||||
├── apps/desktop/ # Electron desktop app
|
||||
├── packages/ # Shared packages (@lobechat/*)
|
||||
│ ├── database/ # Database schemas, models, repositories
|
||||
│ ├── agent-runtime/ # Agent runtime
|
||||
│ └── ...
|
||||
├── src/
|
||||
│ ├── app/ # Next.js App Router (backend API + auth)
|
||||
│ │ ├── (backend)/ # API routes (trpc, webapi, etc.)
|
||||
│ │ ├── spa/ # SPA HTML template service
|
||||
│ │ └── [variants]/(auth)/ # Auth pages (SSR required)
|
||||
│ ├── routes/ # SPA page components (Vite)
|
||||
│ │ ├── (main)/ # Desktop pages
|
||||
│ │ ├── (mobile)/ # Mobile pages
|
||||
│ │ ├── (desktop)/ # Desktop-specific pages
|
||||
│ │ ├── (popup)/ # Popup window pages
|
||||
│ │ ├── onboarding/ # Onboarding pages
|
||||
│ │ └── share/ # Share pages
|
||||
│ ├── spa/ # SPA entry points and router config
|
||||
│ │ ├── entry.web.tsx # Web entry
|
||||
│ │ ├── entry.mobile.tsx
|
||||
│ │ ├── entry.desktop.tsx
|
||||
│ │ ├── entry.popup.tsx
|
||||
│ │ └── router/ # React Router configuration
|
||||
│ ├── app/ # Next.js app router
|
||||
│ ├── spa/ # SPA entry points (entry.*.tsx) and router config
|
||||
│ ├── routes/ # SPA page components (roots)
|
||||
│ ├── features/ # Business components by domain
|
||||
│ ├── store/ # Zustand stores
|
||||
│ ├── services/ # Client services
|
||||
│ ├── server/ # Server services and routers
|
||||
│ └── ...
|
||||
├── .agents/skills/ # AI development skills
|
||||
└── e2e/ # E2E tests (Cucumber + Playwright)
|
||||
```
|
||||
|
||||
## SPA Routes and Features
|
||||
|
||||
SPA-related code is grouped under `src/spa/` (entries + router) and `src/routes/` (page segments). We use a **roots vs features** split: route trees only hold page segments; business logic and UI live in features.
|
||||
|
||||
- **`src/spa/`** – SPA entry points (`entry.web.tsx`, `entry.mobile.tsx`, `entry.desktop.tsx`, `entry.popup.tsx`) and React Router config (`router/`, with `desktopRouter.config.*`, `mobileRouter.config.tsx`, `popupRouter.config.tsx`). Keeps router config next to entries to avoid confusion with `src/routes/`.
|
||||
|
||||
- **`src/routes/` (roots)**\
|
||||
Only page-segment files: `_layout/index.tsx`, `index.tsx` (or `page.tsx`), and dynamic segments like `[id]/index.tsx`. Keep these **thin**: they should only import from `@/features/*` and compose layout/page, with no business logic or heavy UI.
|
||||
|
||||
- **`src/features/`**\
|
||||
Business components by **domain** (e.g. `Pages`, `PageEditor`, `Home`). Put layout chunks (sidebar, header, body), hooks, and domain-specific UI here. Each feature exposes an `index.ts` (or `index.tsx`) with clear exports.
|
||||
|
||||
When adding or changing SPA routes:
|
||||
|
||||
1. In `src/routes/`, add only the route segment files (layout + page) that delegate to features.
|
||||
2. Implement layout and page content under `src/features/<Domain>/` and export from there.
|
||||
3. In route files, use `import { X } from '@/features/<Domain>'` (or `import Y from '@/features/<Domain>/...'`). Do not add new `features/` folders inside `src/routes/`.
|
||||
4. **Register the desktop route tree in both configs:** `src/spa/router/desktopRouter.config.tsx` and `src/spa/router/desktopRouter.config.desktop.tsx` must stay in sync (same paths and nesting). Updating only one can cause **blank screens** if the other build path expects the route. `desktopRouter.sync.test.tsx` guards this invariant — keep it passing.
|
||||
|
||||
See the **spa-routes** skill (`.agents/skills/spa-routes/SKILL.md`) for the full convention and file-division rules.
|
||||
|
||||
## Development
|
||||
|
||||
### Starting the Dev Environment
|
||||
|
||||
```bash
|
||||
# SPA dev mode (frontend only, proxies API to localhost:3010)
|
||||
bun run dev:spa
|
||||
|
||||
# Full-stack dev (Next.js + Vite SPA concurrently)
|
||||
bun run dev
|
||||
```
|
||||
|
||||
After `dev:spa` starts, the terminal prints a **Debug Proxy** URL:
|
||||
|
||||
```plaintext
|
||||
Debug Proxy: https://app.lobehub.com/_dangerous_local_dev_proxy?debug-host=http%3A%2F%2Flocalhost%3A9876
|
||||
```
|
||||
|
||||
Open this URL to develop locally against the production backend (app.lobehub.com). The proxy page loads your local Vite dev server's SPA into the online environment, enabling HMR with real server config.
|
||||
## Development Workflow
|
||||
|
||||
### Git Workflow
|
||||
|
||||
- **Branch strategy**: `canary` is the development branch (cloud production); `main` is the release branch (periodically cherry-picks from canary)
|
||||
- New branches should be created from `canary`; PRs should target `canary`
|
||||
- Use rebase for `git pull`
|
||||
- Commit messages: prefix with gitmoji
|
||||
- Branch format: `<type>/<feature-name>`
|
||||
- Use rebase for git pull
|
||||
- Git commit messages should prefix with gitmoji
|
||||
- Git branch name format: `feat/feature-name`
|
||||
- Use `.github/PULL_REQUEST_TEMPLATE.md` for PR descriptions
|
||||
- **Protection of local changes**: Never use `git restore`, `git checkout --`, `git reset --hard`, or any other command or workflow that can forcibly overwrite, discard, or silently replace user-owned uncommitted changes. Before any revert or restoration affecting existing files, inspect the working tree carefully and obtain explicit user confirmation.
|
||||
|
||||
### Package Management
|
||||
|
||||
- `pnpm` for dependency management
|
||||
- `bun` to run npm scripts
|
||||
- `bunx` for executable npm packages
|
||||
- Use `pnpm` as the primary package manager
|
||||
- Use `bun` to run npm scripts
|
||||
- Use `bunx` to run executable npm packages
|
||||
|
||||
### Testing
|
||||
### Code Style Guidelines
|
||||
|
||||
#### TypeScript
|
||||
|
||||
- Prefer interfaces over types for object shapes
|
||||
|
||||
### Testing Strategy
|
||||
|
||||
```bash
|
||||
# Run specific test (NEVER run `bun run test` - takes ~10 minutes)
|
||||
bunx vitest run --silent='passed-only' '[file-path]'
|
||||
# Web tests
|
||||
bunx vitest run --silent='passed-only' '[file-path-pattern]'
|
||||
|
||||
# Database package
|
||||
cd packages/database && bunx vitest run --silent='passed-only' '[file]'
|
||||
# Package tests (e.g., database)
|
||||
cd packages/[package-name] && bunx vitest run --silent='passed-only' '[file-path-pattern]'
|
||||
```
|
||||
|
||||
- Prefer `vi.spyOn` over `vi.mock`
|
||||
- Tests must pass type check: `bun run type-check`
|
||||
- After 2 failed fix attempts, stop and ask for help
|
||||
**Important Notes**:
|
||||
|
||||
- Wrap file paths in single quotes to avoid shell expansion
|
||||
- Never run `bun run test` - this runs all tests and takes \~10 minutes
|
||||
|
||||
### Type Checking
|
||||
|
||||
- Use `bun run type-check` to check for type errors
|
||||
|
||||
### i18n
|
||||
|
||||
- Add keys to a namespace file under `src/locales/default/` (e.g. `agent.ts`, `auth.ts`)
|
||||
- For dev preview: translate `locales/zh-CN/` and `locales/en-US/`
|
||||
- `pnpm i18n` is slow; run it manually when locale keys need updating (e.g. before opening a PR).
|
||||
- **Keys**: Add to `src/locales/default/namespace.ts`
|
||||
- **Dev**: Translate `locales/zh-CN/namespace.json` locale file only for preview
|
||||
- DON'T run `pnpm i18n`, let CI auto handle it
|
||||
|
||||
### Code Review
|
||||
## SPA Routes and Features
|
||||
|
||||
Before reviewing a PR / diff / branch change, read the **review-checklist** skill (`.agents/skills/review-checklist/SKILL.md`) — it lists the recurring mistakes specific to this codebase.
|
||||
- **`src/routes/`** holds only page segments (`_layout/index.tsx`, `index.tsx`, `[id]/index.tsx`). Keep route files **thin** — import from `@/features/*` and compose, no business logic.
|
||||
- **`src/features/`** holds business components by **domain** (e.g. `Pages`, `PageEditor`, `Home`). Layout pieces, hooks, and domain UI go here.
|
||||
- **Desktop router parity:** When changing the main SPA route tree, update **both** `src/spa/router/desktopRouter.config.tsx` (dynamic imports) and `src/spa/router/desktopRouter.config.desktop.tsx` (sync imports) so paths and nesting match. Changing only one can leave routes unregistered and cause **blank screens**.
|
||||
- See the **spa-routes** skill (`.agents/skills/spa-routes/SKILL.md`) for the full convention and file-division rules.
|
||||
|
||||
## Skills (Auto-loaded)
|
||||
|
||||
All AI development skills are available in `.agents/skills/` directory and auto-loaded by Claude Code when relevant.
|
||||
|
||||
**IMPORTANT**: When reviewing PRs or code diffs, ALWAYS read `.agents/skills/code-review/SKILL.md` first.
|
||||
|
||||
-169
@@ -2,175 +2,6 @@
|
||||
|
||||
# Changelog
|
||||
|
||||
### [Version 2.1.55](https://github.com/lobehub/lobe-chat/compare/v2.1.54...v2.1.55)
|
||||
|
||||
<sup>Released on **2026-04-29**</sup>
|
||||
|
||||
#### 🐛 Bug Fixes
|
||||
|
||||
- **chat**: preserve topics across cold route sends.
|
||||
|
||||
<br/>
|
||||
|
||||
<details>
|
||||
<summary><kbd>Improvements and Fixes</kbd></summary>
|
||||
|
||||
#### What's fixed
|
||||
|
||||
- **chat**: preserve topics across cold route sends, closes [#14284](https://github.com/lobehub/lobe-chat/issues/14284) ([b8fe675](https://github.com/lobehub/lobe-chat/commit/b8fe675))
|
||||
|
||||
</details>
|
||||
|
||||
<div align="right">
|
||||
|
||||
[](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
### [Version 2.1.54](https://github.com/lobehub/lobe-chat/compare/v2.1.53...v2.1.54)
|
||||
|
||||
<sup>Released on **2026-04-27**</sup>
|
||||
|
||||
#### 🐛 Bug Fixes
|
||||
|
||||
- **misc**: clear stale topic when switching agents from a topic route.
|
||||
|
||||
<br/>
|
||||
|
||||
<details>
|
||||
<summary><kbd>Improvements and Fixes</kbd></summary>
|
||||
|
||||
#### What's fixed
|
||||
|
||||
- **misc**: clear stale topic when switching agents from a topic route, closes [#14231](https://github.com/lobehub/lobe-chat/issues/14231) ([deeb97a](https://github.com/lobehub/lobe-chat/commit/deeb97a))
|
||||
|
||||
</details>
|
||||
|
||||
<div align="right">
|
||||
|
||||
[](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
### [Version 2.1.52](https://github.com/lobehub/lobe-chat/compare/v2.1.51...v2.1.52)
|
||||
|
||||
<sup>Released on **2026-04-20**</sup>
|
||||
|
||||
#### 👷 Build System
|
||||
|
||||
- **database**: add topic status and tasks automation mode.
|
||||
|
||||
<br/>
|
||||
|
||||
<details>
|
||||
<summary><kbd>Improvements and Fixes</kbd></summary>
|
||||
|
||||
#### Build System
|
||||
|
||||
- **database**: add topic status and tasks automation mode, closes [#13994](https://github.com/lobehub/lobe-chat/issues/13994) ([3bcd581](https://github.com/lobehub/lobe-chat/commit/3bcd581))
|
||||
|
||||
</details>
|
||||
|
||||
<div align="right">
|
||||
|
||||
[](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
## [Version 2.1.51](https://github.com/lobehub/lobe-chat/compare/v0.0.0-nightly.pr13850.8503...v2.1.51)
|
||||
|
||||
<sup>Released on **2026-04-16**</sup>
|
||||
|
||||
#### 👷 Build System
|
||||
|
||||
- **database**: add document history schema.
|
||||
- **database**: add document history schema.
|
||||
|
||||
#### 🐛 Bug Fixes
|
||||
|
||||
- **misc**: fix minify cli.
|
||||
- **misc**: recent delete.
|
||||
- **deps**: pin @react-pdf/image to 3.0.4 to avoid privatized @react-pdf/svg.
|
||||
- **database**: enforce document history ownership and pagination.
|
||||
|
||||
#### ✨ Features
|
||||
|
||||
- **database**: add document history table and update related models.
|
||||
|
||||
<br/>
|
||||
|
||||
<details>
|
||||
<summary><kbd>Improvements and Fixes</kbd></summary>
|
||||
|
||||
#### Build System
|
||||
|
||||
- **database**: add document history schema, closes [#13789](https://github.com/lobehub/lobe-chat/issues/13789) ([c1174d3](https://github.com/lobehub/lobe-chat/commit/c1174d3))
|
||||
- **database**: add document history schema ([e3eef04](https://github.com/lobehub/lobe-chat/commit/e3eef04))
|
||||
|
||||
#### What's fixed
|
||||
|
||||
- **misc**: fix minify cli, closes [#13888](https://github.com/lobehub/lobe-chat/issues/13888) ([cb4ad01](https://github.com/lobehub/lobe-chat/commit/cb4ad01))
|
||||
- **misc**: recent delete, closes [#13878](https://github.com/lobehub/lobe-chat/issues/13878) ([85227cf](https://github.com/lobehub/lobe-chat/commit/85227cf))
|
||||
- **deps**: pin @react-pdf/image to 3.0.4 to avoid privatized @react-pdf/svg ([d526b40](https://github.com/lobehub/lobe-chat/commit/d526b40))
|
||||
- **database**: enforce document history ownership and pagination ([b9c4b87](https://github.com/lobehub/lobe-chat/commit/b9c4b87))
|
||||
|
||||
#### What's improved
|
||||
|
||||
- **database**: add document history table and update related models ([64fc6d4](https://github.com/lobehub/lobe-chat/commit/64fc6d4))
|
||||
|
||||
</details>
|
||||
|
||||
<div align="right">
|
||||
|
||||
[](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
## [Version 2.1.50](https://github.com/lobehub/lobe-chat/compare/v2.1.49...v2.1.50)
|
||||
|
||||
<sup>Released on **2026-04-16**</sup>
|
||||
|
||||
#### 👷 Build System
|
||||
|
||||
- **database**: add document history schema.
|
||||
- **database**: add document history schema.
|
||||
|
||||
#### 🐛 Bug Fixes
|
||||
|
||||
- **deps**: pin @react-pdf/image to 3.0.4 to avoid privatized @react-pdf/svg.
|
||||
- **database**: enforce document history ownership and pagination.
|
||||
|
||||
#### ✨ Features
|
||||
|
||||
- **database**: add document history table and update related models.
|
||||
|
||||
<br/>
|
||||
|
||||
<details>
|
||||
<summary><kbd>Improvements and Fixes</kbd></summary>
|
||||
|
||||
#### Build System
|
||||
|
||||
- **database**: add document history schema, closes [#13789](https://github.com/lobehub/lobe-chat/issues/13789) ([c1174d3](https://github.com/lobehub/lobe-chat/commit/c1174d3))
|
||||
- **database**: add document history schema ([e3eef04](https://github.com/lobehub/lobe-chat/commit/e3eef04))
|
||||
|
||||
#### What's fixed
|
||||
|
||||
- **deps**: pin @react-pdf/image to 3.0.4 to avoid privatized @react-pdf/svg ([d526b40](https://github.com/lobehub/lobe-chat/commit/d526b40))
|
||||
- **database**: enforce document history ownership and pagination ([b9c4b87](https://github.com/lobehub/lobe-chat/commit/b9c4b87))
|
||||
|
||||
#### What's improved
|
||||
|
||||
- **database**: add document history table and update related models ([64fc6d4](https://github.com/lobehub/lobe-chat/commit/64fc6d4))
|
||||
|
||||
</details>
|
||||
|
||||
<div align="right">
|
||||
|
||||
[](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
### [Version 2.1.45](https://github.com/lobehub/lobe-chat/compare/v2.1.44...v2.1.45)
|
||||
|
||||
<sup>Released on **2026-03-26**</sup>
|
||||
|
||||
@@ -1 +1,123 @@
|
||||
@AGENTS.md
|
||||
# CLAUDE.md
|
||||
|
||||
Guidelines for using Claude Code in this LobeHub repository.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- Next.js 16 + React 19 + TypeScript
|
||||
- SPA inside Next.js with `react-router-dom`
|
||||
- `@lobehub/ui`, antd for components; antd-style for CSS-in-JS
|
||||
- react-i18next for i18n; zustand for state management
|
||||
- SWR for data fetching; TRPC for type-safe backend
|
||||
- Drizzle ORM with PostgreSQL; Vitest for testing
|
||||
|
||||
## Project Structure
|
||||
|
||||
```plaintext
|
||||
lobehub/
|
||||
├── apps/desktop/ # Electron desktop app
|
||||
├── packages/ # Shared packages (@lobechat/*)
|
||||
│ ├── database/ # Database schemas, models, repositories
|
||||
│ ├── agent-runtime/ # Agent runtime
|
||||
│ └── ...
|
||||
├── src/
|
||||
│ ├── app/ # Next.js App Router (backend API + auth)
|
||||
│ │ ├── (backend)/ # API routes (trpc, webapi, etc.)
|
||||
│ │ ├── spa/ # SPA HTML template service
|
||||
│ │ └── [variants]/(auth)/ # Auth pages (SSR required)
|
||||
│ ├── routes/ # SPA page components (Vite)
|
||||
│ │ ├── (main)/ # Desktop pages
|
||||
│ │ ├── (mobile)/ # Mobile pages
|
||||
│ │ ├── (desktop)/ # Desktop-specific pages
|
||||
│ │ ├── onboarding/ # Onboarding pages
|
||||
│ │ └── share/ # Share pages
|
||||
│ ├── spa/ # SPA entry points and router config
|
||||
│ │ ├── entry.web.tsx # Web entry
|
||||
│ │ ├── entry.mobile.tsx
|
||||
│ │ ├── entry.desktop.tsx
|
||||
│ │ └── router/ # React Router configuration
|
||||
│ ├── store/ # Zustand stores
|
||||
│ ├── services/ # Client services
|
||||
│ ├── server/ # Server services and routers
|
||||
│ └── ...
|
||||
└── e2e/ # E2E tests (Cucumber + Playwright)
|
||||
```
|
||||
|
||||
## SPA Routes and Features
|
||||
|
||||
SPA-related code is grouped under `src/spa/` (entries + router) and `src/routes/` (page segments). We use a **roots vs features** split: route trees only hold page segments; business logic and UI live in features.
|
||||
|
||||
- **`src/spa/`** – SPA entry points (`entry.web.tsx`, `entry.mobile.tsx`, `entry.desktop.tsx`) and React Router config (`router/`). Keeps router config next to entries to avoid confusion with `src/routes/`.
|
||||
|
||||
- **`src/routes/` (roots)**\
|
||||
Only page-segment files: `_layout/index.tsx`, `index.tsx` (or `page.tsx`), and dynamic segments like `[id]/index.tsx`. Keep these **thin**: they should only import from `@/features/*` and compose layout/page, with no business logic or heavy UI.
|
||||
|
||||
- **`src/features/`**\
|
||||
Business components by **domain** (e.g. `Pages`, `PageEditor`, `Home`). Put layout chunks (sidebar, header, body), hooks, and domain-specific UI here. Each feature exposes an `index.ts` (or `index.tsx`) with clear exports.
|
||||
|
||||
When adding or changing SPA routes:
|
||||
|
||||
1. In `src/routes/`, add only the route segment files (layout + page) that delegate to features.
|
||||
2. Implement layout and page content under `src/features/<Domain>/` and export from there.
|
||||
3. In route files, use `import { X } from '@/features/<Domain>'` (or `import Y from '@/features/<Domain>/...'`). Do not add new `features/` folders inside `src/routes/`.
|
||||
4. **Register the desktop route tree in both configs:** `src/spa/router/desktopRouter.config.tsx` and `src/spa/router/desktopRouter.config.desktop.tsx` must stay in sync (same paths and nesting). Updating only one can cause **blank screens** if the other build path expects the route.
|
||||
|
||||
See the **spa-routes** skill (`.agents/skills/spa-routes/SKILL.md`) for the full convention and file-division rules.
|
||||
|
||||
## Development
|
||||
|
||||
### Starting the Dev Environment
|
||||
|
||||
```bash
|
||||
# SPA dev mode (frontend only, proxies API to localhost:3010)
|
||||
bun run dev:spa
|
||||
|
||||
# Full-stack dev (Next.js + Vite SPA concurrently)
|
||||
bun run dev
|
||||
```
|
||||
|
||||
After `dev:spa` starts, the terminal prints a **Debug Proxy** URL:
|
||||
|
||||
```plaintext
|
||||
Debug Proxy: https://app.lobehub.com/_dangerous_local_dev_proxy?debug-host=http%3A%2F%2Flocalhost%3A9876
|
||||
```
|
||||
|
||||
Open this URL to develop locally against the production backend (app.lobehub.com). The proxy page loads your local Vite dev server's SPA into the online environment, enabling HMR with real server config.
|
||||
|
||||
### Git Workflow
|
||||
|
||||
- **Branch strategy**: `canary` is the development branch (cloud production); `main` is the release branch (periodically cherry-picks from canary)
|
||||
- New branches should be created from `canary`; PRs should target `canary`
|
||||
- Use rebase for `git pull`
|
||||
- Commit messages: prefix with gitmoji
|
||||
- Branch format: `<type>/<feature-name>`
|
||||
|
||||
### Package Management
|
||||
|
||||
- `pnpm` for dependency management
|
||||
- `bun` to run npm scripts
|
||||
- `bunx` for executable npm packages
|
||||
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
# Run specific test (NEVER run `bun run test` - takes ~10 minutes)
|
||||
bunx vitest run --silent='passed-only' '[file-path]'
|
||||
|
||||
# Database package
|
||||
cd packages/database && bunx vitest run --silent='passed-only' '[file]'
|
||||
```
|
||||
|
||||
- Prefer `vi.spyOn` over `vi.mock`
|
||||
- Tests must pass type check: `bun run type-check`
|
||||
- After 2 failed fix attempts, stop and ask for help
|
||||
|
||||
### i18n
|
||||
|
||||
- Add keys to `src/locales/default/namespace.ts`
|
||||
- For dev preview: translate `locales/zh-CN/` and `locales/en-US/`
|
||||
- Don't run `pnpm i18n` - CI handles it
|
||||
|
||||
## Skills (Auto-loaded by Claude)
|
||||
|
||||
Claude Code automatically loads relevant skills from `.agents/skills/`.
|
||||
|
||||
+7
-7
@@ -1,8 +1,8 @@
|
||||
# LobeHub - Contributing Guide 🌟
|
||||
# Lobe Chat - Contributing Guide 🌟
|
||||
|
||||
We're thrilled that you want to contribute to LobeHub, the future of communication! 😄
|
||||
We're thrilled that you want to contribute to Lobe Chat, the future of communication! 😄
|
||||
|
||||
LobeHub is an open-source project, and we welcome your collaboration. Before you jump in, let's make sure you're all set to contribute effectively and have loads of fun along the way!
|
||||
Lobe Chat is an open-source project, and we welcome your collaboration. Before you jump in, let's make sure you're all set to contribute effectively and have loads of fun along the way!
|
||||
|
||||
## Table of Contents
|
||||
|
||||
@@ -69,11 +69,11 @@ git fetch upstream
|
||||
git merge upstream/main
|
||||
```
|
||||
|
||||
This ensures you're working on the most current version of LobeHub. Stay fresh! 💨
|
||||
This ensures you're working on the most current version of Lobe Chat. Stay fresh! 💨
|
||||
|
||||
## Open a Pull Request
|
||||
|
||||
🚀 Time to share your contribution! Head over to the original LobeHub repository and open a Pull Request (PR). Our maintainers will review your work.
|
||||
🚀 Time to share your contribution! Head over to the original Lobe Chat repository and open a Pull Request (PR). Our maintainers will review your work.
|
||||
|
||||
## Review and Collaboration
|
||||
|
||||
@@ -81,8 +81,8 @@ This ensures you're working on the most current version of LobeHub. Stay fresh!
|
||||
|
||||
## Celebrate 🎉
|
||||
|
||||
🎈 Congratulations! Your contribution is now part of LobeHub. 🥳
|
||||
🎈 Congratulations! Your contribution is now part of Lobe Chat. 🥳
|
||||
|
||||
Thank you for making LobeHub even more magical. We can't wait to see what you create! 🌠
|
||||
Thank you for making Lobe Chat even more magical. We can't wait to see what you create! 🌠
|
||||
|
||||
Happy Coding! 🚀🦄
|
||||
|
||||
-81
@@ -1,81 +0,0 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
We only provide security fixes for the **latest 2.x release**. Older versions (including all 1.x releases) are end-of-life and will not receive patches.
|
||||
|
||||
| Version | Supported |
|
||||
| ------------ | --------- |
|
||||
| 2.x (latest) | ✅ |
|
||||
| 1.x | ❌ |
|
||||
| 0.x | ❌ |
|
||||
|
||||
If you are running a 1.x deployment, we strongly recommend upgrading to the latest 2.x release.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Please report security vulnerabilities through the GitHub Security Advisory ["Report a Vulnerability"](https://github.com/lobehub/lobehub/security/advisories/new) tab.
|
||||
|
||||
**Please do not report security vulnerabilities through public GitHub issues.**
|
||||
|
||||
### Response Timeline
|
||||
|
||||
- **Acknowledgement**: We aim to respond to all reports within **7 days**.
|
||||
- **Fix**: Confirmed vulnerabilities will be addressed within **30 days**.
|
||||
- **Urgent issues**: If you believe the vulnerability is critical and actively exploitable, you can reach out directly on Discord (`arvinxu`) for faster coordination.
|
||||
|
||||
### What to Include
|
||||
|
||||
A good vulnerability report should include:
|
||||
|
||||
- A clear description of the issue and its potential impact
|
||||
- The affected version (must be the latest 2.x release)
|
||||
- Step-by-step reproduction instructions or a working PoC
|
||||
- Any relevant logs, screenshots, or code references
|
||||
|
||||
## Scope
|
||||
|
||||
### In Scope
|
||||
|
||||
- Security issues affecting the **latest 2.x release** of LobeHub
|
||||
- Vulnerabilities in the **server-side deployment** (LobeHub Cloud or self-hosted server mode)
|
||||
- Issues that can be exploited **without requiring admin/owner access** to the deployment
|
||||
|
||||
### Out of Scope (Not a Vulnerability)
|
||||
|
||||
The following are considered **by design** or **out of scope** and will not be accepted as vulnerability reports:
|
||||
|
||||
#### 1. End-of-Life Versions
|
||||
|
||||
Any issue that only affects 1.x or earlier versions. This includes but is not limited to the `X-lobe-chat-auth` header mechanism, `webapi` route authentication, and other 1.x-specific architectures that have been completely removed in 2.x.
|
||||
|
||||
#### 2. File Proxy Public Access (`/f/:id`)
|
||||
|
||||
The file proxy endpoint `/f/:id` uses randomly generated, non-enumerable IDs as [capability URLs](https://www.w3.org/TR/capability-urls/). This is a deliberate design choice, similar to how S3 presigned URLs or Google Docs sharing links work. Knowing the URL grants access — this is by design, not an authorization bypass.
|
||||
|
||||
#### 3. User Enumeration on Login Flows
|
||||
|
||||
Endpoints such as `check-user` that indicate whether an account exists are part of the standard login UX. This is a common and intentional pattern used by most modern authentication flows.
|
||||
|
||||
#### 4. Self-Hosted Client-Side API Key Storage
|
||||
|
||||
In self-hosted client-side mode, users configure their own API keys which are stored in the browser's local storage. This is the expected behavior for client-side deployments where the user is both the operator and the consumer.
|
||||
|
||||
#### 5. Issues Requiring Admin or Owner Privileges
|
||||
|
||||
Actions that require administrative access to the deployment (e.g., environment variable configuration, server-side settings) are not considered security vulnerabilities, as the admin is already a trusted party.
|
||||
|
||||
#### 6. Theoretical Attacks Without Practical Impact
|
||||
|
||||
Reports based on theoretical attack scenarios without a working proof of concept against a realistic deployment, or issues that require unlikely preconditions (e.g., physical access to the server, pre-existing compromise of the host system).
|
||||
|
||||
## Disclosure Policy
|
||||
|
||||
- We follow [coordinated vulnerability disclosure](https://en.wikipedia.org/wiki/Coordinated_vulnerability_disclosure).
|
||||
- We will credit reporters in the security advisory unless they prefer to remain anonymous.
|
||||
- Please allow us reasonable time to address the issue before any public disclosure.
|
||||
|
||||
## Contact
|
||||
|
||||
- **Primary**: [GitHub Security Advisories](https://github.com/lobehub/lobehub/security/advisories/new)
|
||||
- **Urgent**: Discord — `arvinxu`
|
||||
@@ -1,80 +0,0 @@
|
||||
import { execSync } from 'node:child_process';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* Manual E2E coverage for `lh agent space fs` against a real backend.
|
||||
*
|
||||
* Run when:
|
||||
* - A local or remote LobeHub backend is reachable by the CLI
|
||||
* - `AGENT_FS_E2E_AGENT_ID` points at an agent with document access
|
||||
*
|
||||
* Expects:
|
||||
* - The command creates and cleans up a temporary VFS directory
|
||||
* - This suite is skipped unless `AGENT_FS_E2E_AGENT_ID` is set
|
||||
*/
|
||||
const AGENT_ID = process.env.AGENT_FS_E2E_AGENT_ID;
|
||||
const CLI = process.env.LH_CLI_PATH || 'LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts';
|
||||
const TIMEOUT = 30_000;
|
||||
|
||||
function run(args: string): string {
|
||||
return execSync(`${CLI} ${args}`, {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, PATH: `${process.env.HOME}/.bun/bin:${process.env.PATH}` },
|
||||
timeout: TIMEOUT,
|
||||
}).trim();
|
||||
}
|
||||
|
||||
describe.skipIf(!AGENT_ID)('lh agent space fs unified VFS - manual E2E', () => {
|
||||
const testRoot = `agent:/vfs-cli-e2e-${Date.now()}`;
|
||||
|
||||
it('exercises root, mounted namespaces, writes, copy, move, trash, and cleanup', () => {
|
||||
const root = run(`agent space fs ls --agent-id ${AGENT_ID} agent:/`);
|
||||
expect(root).toContain('lobe/');
|
||||
|
||||
const mountedRoot = run(`agent space fs ls --agent-id ${AGENT_ID} agent:/lobe/skills`);
|
||||
expect(mountedRoot).toContain('builtin/');
|
||||
expect(mountedRoot).toContain('agent/');
|
||||
|
||||
try {
|
||||
expect(run(`agent space fs mkdir --agent-id ${AGENT_ID} --parents ${testRoot}`)).toContain(
|
||||
'created',
|
||||
);
|
||||
expect(
|
||||
run(
|
||||
`agent space fs write --agent-id ${AGENT_ID} --content "# VFS E2E" ${testRoot}/source.md`,
|
||||
),
|
||||
).toContain('created');
|
||||
expect(run(`agent space fs cat --agent-id ${AGENT_ID} ${testRoot}/source.md`)).toContain(
|
||||
'# VFS E2E',
|
||||
);
|
||||
expect(
|
||||
run(`agent space fs cp --agent-id ${AGENT_ID} ${testRoot}/source.md ${testRoot}/copied.md`),
|
||||
).toContain('copied');
|
||||
expect(
|
||||
run(`agent space fs mv --agent-id ${AGENT_ID} ${testRoot}/copied.md ${testRoot}/moved.md`),
|
||||
).toContain('moved');
|
||||
expect(run(`agent space fs rm --agent-id ${AGENT_ID} --yes ${testRoot}/moved.md`)).toContain(
|
||||
'deleted',
|
||||
);
|
||||
expect(run(`agent space fs trash ls --agent-id ${AGENT_ID} ${testRoot}`)).toContain(
|
||||
`${testRoot}/moved.md`,
|
||||
);
|
||||
expect(
|
||||
run(`agent space fs trash restore --agent-id ${AGENT_ID} ${testRoot}/moved.md`),
|
||||
).toContain('restored');
|
||||
} finally {
|
||||
try {
|
||||
run(`agent space fs rm --agent-id ${AGENT_ID} --yes --recursive ${testRoot}`);
|
||||
} catch {
|
||||
// Cleanup is best-effort because earlier assertions may fail before creation.
|
||||
}
|
||||
|
||||
try {
|
||||
run(`agent space fs trash rm --agent-id ${AGENT_ID} --yes --recursive --force ${testRoot}`);
|
||||
} catch {
|
||||
// Cleanup is best-effort because the trash entry may not exist.
|
||||
}
|
||||
}
|
||||
}, 60_000);
|
||||
});
|
||||
+1
-10
@@ -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.8" "User Commands"
|
||||
.TH LH 1 "" "@lobehub/cli 0.0.1\-canary.14" "User Commands"
|
||||
.SH NAME
|
||||
lh \- LobeHub CLI \- manage and connect to LobeHub services
|
||||
.SH SYNOPSIS
|
||||
@@ -83,9 +83,6 @@ Manage agent skills
|
||||
.B session\-group
|
||||
Manage agent session groups
|
||||
.TP
|
||||
.B task
|
||||
Manage agent tasks
|
||||
.TP
|
||||
.B thread
|
||||
Manage message threads
|
||||
.TP
|
||||
@@ -98,9 +95,6 @@ Manage messages
|
||||
.B model
|
||||
Manage AI models
|
||||
.TP
|
||||
.B notify
|
||||
Send a callback message to a topic and trigger the agent to process it
|
||||
.TP
|
||||
.B provider
|
||||
Manage AI providers
|
||||
.TP
|
||||
@@ -118,9 +112,6 @@ View usage statistics
|
||||
.TP
|
||||
.B eval
|
||||
Manage evaluation workflows
|
||||
.TP
|
||||
.B migrate
|
||||
Migrate data from external tools (OpenClaw, ChatGPT, Claude, etc.)
|
||||
.SH OPTIONS
|
||||
.TP
|
||||
.B \-V, \-\-version
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@lobehub/cli",
|
||||
"version": "0.0.8",
|
||||
"version": "0.0.1-canary.14",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"lh": "./dist/index.js",
|
||||
@@ -28,18 +28,15 @@
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lobechat/agent-gateway-client": "workspace:*",
|
||||
"@lobechat/device-gateway-client": "workspace:*",
|
||||
"@lobechat/local-file-shell": "workspace:*",
|
||||
"@trpc/client": "^11.8.1",
|
||||
"@types/node": "^22.13.5",
|
||||
"@types/ws": "^8.18.1",
|
||||
"commander": "^13.1.0",
|
||||
"dayjs": "^1.11.19",
|
||||
"debug": "^4.4.0",
|
||||
"diff": "^8.0.3",
|
||||
"fast-glob": "^3.3.3",
|
||||
"ignore": "^7.0.5",
|
||||
"picocolors": "^1.1.1",
|
||||
"superjson": "^2.2.6",
|
||||
"tsdown": "^0.21.4",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
packages:
|
||||
- '../../packages/agent-gateway-client'
|
||||
- '../../packages/device-gateway-client'
|
||||
- '../../packages/local-file-shell'
|
||||
- '../../packages/file-loaders'
|
||||
|
||||
@@ -39,9 +39,7 @@ async function getAuthAndServer() {
|
||||
|
||||
const result = await getValidToken();
|
||||
if (!result) {
|
||||
log.error(
|
||||
`No authentication found. Run 'lh login' (or 'npx -y @lobehub/cli login') first, or set ${CLI_API_KEY_ENV}.`,
|
||||
);
|
||||
log.error(`No authentication found. Run 'lh login' first, or set ${CLI_API_KEY_ENV}.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
||||
+22
-63
@@ -3,9 +3,29 @@ import { CLI_API_KEY_ENV } from '../constants/auth';
|
||||
import { resolveServerUrl } from '../settings';
|
||||
import { log } from '../utils/logger';
|
||||
|
||||
// Must match the server's SECRET_XOR_KEY (src/envs/auth.ts)
|
||||
const SECRET_XOR_KEY = 'LobeHub · LobeHub';
|
||||
|
||||
/**
|
||||
* XOR-obfuscate a payload and encode as Base64.
|
||||
* The /webapi/* routes require `X-lobe-chat-auth` with this encoding.
|
||||
*/
|
||||
function obfuscatePayloadWithXOR(payload: Record<string, any>): string {
|
||||
const jsonString = JSON.stringify(payload);
|
||||
const dataBytes = new TextEncoder().encode(jsonString);
|
||||
const keyBytes = new TextEncoder().encode(SECRET_XOR_KEY);
|
||||
|
||||
const result = new Uint8Array(dataBytes.length);
|
||||
for (let i = 0; i < dataBytes.length; i++) {
|
||||
result[i] = dataBytes[i] ^ keyBytes[i % keyBytes.length];
|
||||
}
|
||||
|
||||
return btoa(String.fromCharCode(...result));
|
||||
}
|
||||
|
||||
export interface AuthInfo {
|
||||
accessToken: string;
|
||||
/** Headers required for /webapi/* endpoints (Oidc-Auth for authentication) */
|
||||
/** Headers required for /webapi/* endpoints (includes both X-lobe-chat-auth and Oidc-Auth) */
|
||||
headers: Record<string, string>;
|
||||
serverUrl: string;
|
||||
}
|
||||
@@ -32,69 +52,8 @@ export async function getAuthInfo(): Promise<AuthInfo> {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Oidc-Auth': accessToken,
|
||||
'X-lobe-chat-auth': obfuscatePayloadWithXOR({}),
|
||||
},
|
||||
serverUrl,
|
||||
};
|
||||
}
|
||||
|
||||
export type AgentStreamTokenType = 'jwt' | 'apiKey';
|
||||
|
||||
export interface AgentStreamAuthInfo {
|
||||
headers: Record<string, string>;
|
||||
serverUrl: string;
|
||||
/**
|
||||
* Raw token value (without header prefix). Used for WebSocket auth messages
|
||||
* where header-based auth is not available.
|
||||
*/
|
||||
token: string;
|
||||
/**
|
||||
* How the token should be verified by downstream services (agent gateway WS).
|
||||
* jwt → validate with JWKS
|
||||
* apiKey → validate by calling /api/v1/users/me
|
||||
*/
|
||||
tokenType: AgentStreamTokenType;
|
||||
}
|
||||
|
||||
export async function getAgentStreamAuthInfo(): Promise<AgentStreamAuthInfo> {
|
||||
const serverUrl = resolveServerUrl();
|
||||
|
||||
const envJwt = process.env.LOBEHUB_JWT;
|
||||
if (envJwt) {
|
||||
return {
|
||||
headers: { 'Oidc-Auth': envJwt },
|
||||
serverUrl,
|
||||
token: envJwt,
|
||||
tokenType: 'jwt',
|
||||
};
|
||||
}
|
||||
|
||||
const envApiKey = process.env[CLI_API_KEY_ENV];
|
||||
if (envApiKey) {
|
||||
return {
|
||||
headers: { 'X-API-Key': envApiKey },
|
||||
serverUrl,
|
||||
token: envApiKey,
|
||||
tokenType: 'apiKey',
|
||||
};
|
||||
}
|
||||
|
||||
const result = await getValidToken();
|
||||
if (!result) {
|
||||
log.error(`No authentication found. Run 'lh login' first, or set ${CLI_API_KEY_ENV}.`);
|
||||
process.exit(1);
|
||||
|
||||
return {
|
||||
headers: {},
|
||||
serverUrl,
|
||||
token: '',
|
||||
tokenType: 'jwt',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
headers: { 'Oidc-Auth': result.credentials.accessToken },
|
||||
serverUrl,
|
||||
token: result.credentials.accessToken,
|
||||
tokenType: 'jwt',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,14 +7,12 @@ const CLIENT_ID = 'lobehub-cli';
|
||||
* Get a valid access token, refreshing if expired.
|
||||
* Returns null if no credentials or refresh fails.
|
||||
*/
|
||||
export async function getValidToken(
|
||||
bufferSeconds = 60,
|
||||
): Promise<{ credentials: StoredCredentials } | null> {
|
||||
export async function getValidToken(): Promise<{ credentials: StoredCredentials } | null> {
|
||||
const credentials = loadCredentials();
|
||||
if (!credentials) return null;
|
||||
|
||||
// Check if token is still valid (with configurable buffer)
|
||||
if (credentials.expiresAt && Date.now() / 1000 < credentials.expiresAt - bufferSeconds) {
|
||||
// Check if token is still valid (with 60s buffer)
|
||||
if (credentials.expiresAt && Date.now() / 1000 < credentials.expiresAt - 60) {
|
||||
return { credentials };
|
||||
}
|
||||
|
||||
|
||||
@@ -23,31 +23,10 @@ const { mockTrpcClient } = vi.hoisted(() => ({
|
||||
updateAgentConfig: { mutate: vi.fn() },
|
||||
updateAgentPinned: { mutate: vi.fn() },
|
||||
},
|
||||
agentDocument: {
|
||||
copyDocumentByPath: { mutate: vi.fn() },
|
||||
deleteDocumentByPath: { mutate: vi.fn() },
|
||||
deleteDocumentPermanentlyByPath: { mutate: vi.fn() },
|
||||
statDocumentByPath: { query: vi.fn() },
|
||||
listDocumentsByPath: { query: vi.fn() },
|
||||
listTrashDocumentsByPath: { query: vi.fn() },
|
||||
mkdirDocumentByPath: { mutate: vi.fn() },
|
||||
readDocumentByPath: { query: vi.fn() },
|
||||
renameDocumentByPath: { mutate: vi.fn() },
|
||||
restoreDocumentFromTrashByPath: { mutate: vi.fn() },
|
||||
writeDocumentByPath: { mutate: vi.fn() },
|
||||
},
|
||||
agentSkills: {
|
||||
createSkill: { mutate: vi.fn() },
|
||||
deleteSkill: { mutate: vi.fn() },
|
||||
updateSkill: { mutate: vi.fn() },
|
||||
},
|
||||
aiAgent: {
|
||||
execAgent: { mutate: vi.fn() },
|
||||
getOperationStatus: { query: vi.fn() },
|
||||
},
|
||||
device: {
|
||||
listDevices: { query: vi.fn() },
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -59,32 +38,13 @@ const { mockStreamAgentEvents } = vi.hoisted(() => ({
|
||||
mockStreamAgentEvents: vi.fn(),
|
||||
}));
|
||||
|
||||
const { mockReplayAgentEvents, mockStreamAgentEventsViaWebSocket } = vi.hoisted(() => ({
|
||||
mockReplayAgentEvents: vi.fn(),
|
||||
mockStreamAgentEventsViaWebSocket: vi.fn(),
|
||||
const { mockGetAuthInfo } = vi.hoisted(() => ({
|
||||
mockGetAuthInfo: vi.fn(),
|
||||
}));
|
||||
|
||||
const { mockGetAgentStreamAuthInfo } = vi.hoisted(() => ({
|
||||
mockGetAgentStreamAuthInfo: vi.fn(),
|
||||
}));
|
||||
|
||||
const { mockResolveLocalDeviceId } = vi.hoisted(() => ({
|
||||
mockResolveLocalDeviceId: vi.fn(),
|
||||
}));
|
||||
|
||||
const { mockReadStdinText } = vi.hoisted(() => ({
|
||||
mockReadStdinText: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('node:stream/consumers', () => ({ text: mockReadStdinText }));
|
||||
vi.mock('../api/client', () => ({ getTrpcClient: mockGetTrpcClient }));
|
||||
vi.mock('../api/http', () => ({ getAgentStreamAuthInfo: mockGetAgentStreamAuthInfo }));
|
||||
vi.mock('../utils/agentStream', () => ({
|
||||
replayAgentEvents: mockReplayAgentEvents,
|
||||
streamAgentEvents: mockStreamAgentEvents,
|
||||
streamAgentEventsViaWebSocket: mockStreamAgentEventsViaWebSocket,
|
||||
}));
|
||||
vi.mock('../utils/device', () => ({ resolveLocalDeviceId: mockResolveLocalDeviceId }));
|
||||
vi.mock('../api/http', () => ({ getAuthInfo: mockGetAuthInfo }));
|
||||
vi.mock('../utils/agentStream', () => ({ streamAgentEvents: mockStreamAgentEvents }));
|
||||
vi.mock('../utils/logger', () => ({
|
||||
log: { debug: vi.fn(), error: vi.fn(), heartbeat: vi.fn(), info: vi.fn(), warn: vi.fn() },
|
||||
setVerbose: vi.fn(),
|
||||
@@ -98,41 +58,22 @@ describe('agent command', () => {
|
||||
exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any);
|
||||
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
mockGetTrpcClient.mockResolvedValue(mockTrpcClient);
|
||||
mockGetAgentStreamAuthInfo.mockResolvedValue({
|
||||
headers: { 'Oidc-Auth': 'test-token' },
|
||||
mockGetAuthInfo.mockResolvedValue({
|
||||
accessToken: 'test-token',
|
||||
headers: { 'Content-Type': 'application/json', 'Oidc-Auth': 'test-token' },
|
||||
serverUrl: 'https://example.com',
|
||||
});
|
||||
mockStreamAgentEvents.mockResolvedValue(undefined);
|
||||
mockReplayAgentEvents.mockReset();
|
||||
mockStreamAgentEventsViaWebSocket.mockReset();
|
||||
mockStreamAgentEventsViaWebSocket.mockResolvedValue(undefined);
|
||||
mockResolveLocalDeviceId.mockReset();
|
||||
mockReadStdinText.mockReset();
|
||||
for (const method of Object.values(mockTrpcClient.agent)) {
|
||||
for (const fn of Object.values(method)) {
|
||||
(fn as ReturnType<typeof vi.fn>).mockReset();
|
||||
}
|
||||
}
|
||||
for (const method of Object.values(mockTrpcClient.agentDocument)) {
|
||||
for (const fn of Object.values(method)) {
|
||||
(fn as ReturnType<typeof vi.fn>).mockReset();
|
||||
}
|
||||
}
|
||||
for (const method of Object.values(mockTrpcClient.agentSkills)) {
|
||||
for (const fn of Object.values(method)) {
|
||||
(fn as ReturnType<typeof vi.fn>).mockReset();
|
||||
}
|
||||
}
|
||||
for (const method of Object.values(mockTrpcClient.aiAgent)) {
|
||||
for (const fn of Object.values(method)) {
|
||||
(fn as ReturnType<typeof vi.fn>).mockReset();
|
||||
}
|
||||
}
|
||||
for (const method of Object.values(mockTrpcClient.device)) {
|
||||
for (const fn of Object.values(method)) {
|
||||
(fn as ReturnType<typeof vi.fn>).mockReset();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -328,7 +269,7 @@ describe('agent command', () => {
|
||||
});
|
||||
|
||||
describe('run', () => {
|
||||
it('should exec agent and connect to the gateway WebSocket stream by default', async () => {
|
||||
it('should exec agent and connect to SSE stream', async () => {
|
||||
mockTrpcClient.aiAgent.execAgent.mutate.mockResolvedValue({
|
||||
operationId: 'op-123',
|
||||
success: true,
|
||||
@@ -350,46 +291,13 @@ describe('agent command', () => {
|
||||
expect(mockTrpcClient.aiAgent.execAgent.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ agentId: 'a1', prompt: 'Hello' }),
|
||||
);
|
||||
expect(mockStreamAgentEventsViaWebSocket).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
gatewayUrl: expect.any(String),
|
||||
json: undefined,
|
||||
operationId: 'op-123',
|
||||
serverUrl: 'https://example.com',
|
||||
token: undefined,
|
||||
tokenType: undefined,
|
||||
verbose: undefined,
|
||||
}),
|
||||
);
|
||||
expect(mockStreamAgentEvents).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fall back to SSE when --sse is provided', async () => {
|
||||
mockTrpcClient.aiAgent.execAgent.mutate.mockResolvedValue({
|
||||
operationId: 'op-sse',
|
||||
success: true,
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'agent',
|
||||
'run',
|
||||
'--agent-id',
|
||||
'a1',
|
||||
'--prompt',
|
||||
'Hello',
|
||||
'--sse',
|
||||
]);
|
||||
|
||||
expect(mockStreamAgentEvents).toHaveBeenCalledWith(
|
||||
'https://example.com/api/agent/stream?operationId=op-sse',
|
||||
'https://example.com/api/agent/stream?operationId=op-123',
|
||||
expect.objectContaining({ 'Oidc-Auth': 'test-token' }),
|
||||
expect.objectContaining({ json: undefined, verbose: undefined }),
|
||||
);
|
||||
expect(mockStreamAgentEventsViaWebSocket).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should support --slug option', async () => {
|
||||
mockTrpcClient.aiAgent.execAgent.mutate.mockResolvedValue({
|
||||
operationId: 'op-456',
|
||||
@@ -476,186 +384,6 @@ describe('agent command', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass --device local as deviceId', async () => {
|
||||
mockResolveLocalDeviceId.mockReturnValue('local-device-1');
|
||||
mockTrpcClient.device.listDevices.query.mockResolvedValue([
|
||||
{ deviceId: 'local-device-1', online: true },
|
||||
]);
|
||||
mockTrpcClient.aiAgent.execAgent.mutate.mockResolvedValue({
|
||||
operationId: 'op-device',
|
||||
success: true,
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'agent',
|
||||
'run',
|
||||
'--agent-id',
|
||||
'a1',
|
||||
'--prompt',
|
||||
'Hi',
|
||||
'--device',
|
||||
'local',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.aiAgent.execAgent.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ agentId: 'a1', deviceId: 'local-device-1', prompt: 'Hi' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass --topic-id and --device local together', async () => {
|
||||
mockResolveLocalDeviceId.mockReturnValue('local-device-1');
|
||||
mockTrpcClient.device.listDevices.query.mockResolvedValue([
|
||||
{ deviceId: 'local-device-1', online: true },
|
||||
]);
|
||||
mockTrpcClient.aiAgent.execAgent.mutate.mockResolvedValue({
|
||||
operationId: 'op-topic-device',
|
||||
success: true,
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'agent',
|
||||
'run',
|
||||
'--agent-id',
|
||||
'a1',
|
||||
'--prompt',
|
||||
'Hi',
|
||||
'--topic-id',
|
||||
't1',
|
||||
'--device',
|
||||
'local',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.aiAgent.execAgent.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ appContext: { topicId: 't1' }, deviceId: 'local-device-1' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass explicit --device id as deviceId', async () => {
|
||||
mockTrpcClient.device.listDevices.query.mockResolvedValue([
|
||||
{ deviceId: 'device-remote-1', online: true },
|
||||
]);
|
||||
mockTrpcClient.aiAgent.execAgent.mutate.mockResolvedValue({
|
||||
operationId: 'op-explicit-device',
|
||||
success: true,
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'agent',
|
||||
'run',
|
||||
'--agent-id',
|
||||
'a1',
|
||||
'--prompt',
|
||||
'Hi',
|
||||
'--device',
|
||||
'device-remote-1',
|
||||
]);
|
||||
|
||||
expect(mockResolveLocalDeviceId).not.toHaveBeenCalled();
|
||||
expect(mockTrpcClient.aiAgent.execAgent.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ agentId: 'a1', deviceId: 'device-remote-1', prompt: 'Hi' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should exit when explicit device is not found', async () => {
|
||||
mockTrpcClient.device.listDevices.query.mockResolvedValue([
|
||||
{ deviceId: 'other-device', online: true },
|
||||
]);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'agent',
|
||||
'run',
|
||||
'--agent-id',
|
||||
'a1',
|
||||
'--prompt',
|
||||
'Hi',
|
||||
'--device',
|
||||
'device-remote-1',
|
||||
]);
|
||||
|
||||
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('was not found'));
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should exit when local device cannot be resolved', async () => {
|
||||
mockResolveLocalDeviceId.mockReturnValue(undefined);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'agent',
|
||||
'run',
|
||||
'--agent-id',
|
||||
'a1',
|
||||
'--prompt',
|
||||
'Hi',
|
||||
'--device',
|
||||
'local',
|
||||
]);
|
||||
|
||||
expect(log.error).toHaveBeenCalledWith(expect.stringContaining("Run 'lh connect' first"));
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should exit when local device is offline', async () => {
|
||||
mockResolveLocalDeviceId.mockReturnValue('local-device-1');
|
||||
mockTrpcClient.device.listDevices.query.mockResolvedValue([
|
||||
{ deviceId: 'local-device-1', online: false },
|
||||
]);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'agent',
|
||||
'run',
|
||||
'--agent-id',
|
||||
'a1',
|
||||
'--prompt',
|
||||
'Hi',
|
||||
'--device',
|
||||
'local',
|
||||
]);
|
||||
|
||||
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('is not online'));
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should exit when explicit device is offline', async () => {
|
||||
mockTrpcClient.device.listDevices.query.mockResolvedValue([
|
||||
{ deviceId: 'device-remote-1', online: false },
|
||||
]);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'agent',
|
||||
'run',
|
||||
'--agent-id',
|
||||
'a1',
|
||||
'--prompt',
|
||||
'Hi',
|
||||
'--device',
|
||||
'device-remote-1',
|
||||
]);
|
||||
|
||||
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('Bring it online'));
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should pass --json to stream options', async () => {
|
||||
mockTrpcClient.aiAgent.execAgent.mutate.mockResolvedValue({
|
||||
operationId: 'op-j',
|
||||
@@ -675,8 +403,10 @@ describe('agent command', () => {
|
||||
'--json',
|
||||
]);
|
||||
|
||||
expect(mockStreamAgentEventsViaWebSocket).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ json: true, operationId: 'op-j' }),
|
||||
expect(mockStreamAgentEvents).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.any(Object),
|
||||
expect.objectContaining({ json: true }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -872,540 +602,4 @@ describe('agent command', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fs', () => {
|
||||
it('should list VFS entries from the unified agent root alias', async () => {
|
||||
mockTrpcClient.agentDocument.listDocumentsByPath.query.mockResolvedValue([
|
||||
{
|
||||
mode: 8,
|
||||
mount: { driver: 'synthetic', source: 'virtual' },
|
||||
name: 'writer',
|
||||
path: './lobe',
|
||||
type: 'directory',
|
||||
},
|
||||
]);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'agent',
|
||||
'space',
|
||||
'fs',
|
||||
'ls',
|
||||
'--agent-id',
|
||||
'a1',
|
||||
'agent:/',
|
||||
'--json',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agentDocument.listDocumentsByPath.query).toHaveBeenCalledWith({
|
||||
agentId: 'a1',
|
||||
cursor: undefined,
|
||||
limit: undefined,
|
||||
path: './',
|
||||
topicId: undefined,
|
||||
});
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
JSON.stringify(
|
||||
[
|
||||
{
|
||||
mode: 8,
|
||||
mount: { driver: 'synthetic', source: 'virtual' },
|
||||
name: 'writer',
|
||||
path: './lobe',
|
||||
type: 'directory',
|
||||
},
|
||||
],
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass pagination options to VFS ls', async () => {
|
||||
mockTrpcClient.agentDocument.listDocumentsByPath.query.mockResolvedValue([]);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'agent',
|
||||
'space',
|
||||
'fs',
|
||||
'ls',
|
||||
'--agent-id',
|
||||
'a1',
|
||||
'--cursor',
|
||||
'100',
|
||||
'--limit',
|
||||
'25',
|
||||
'agent:/notes',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agentDocument.listDocumentsByPath.query).toHaveBeenCalledWith({
|
||||
agentId: 'a1',
|
||||
cursor: '100',
|
||||
limit: 25,
|
||||
path: './notes',
|
||||
topicId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should print unix-like long listings with ls -la', async () => {
|
||||
mockTrpcClient.agentDocument.listDocumentsByPath.query.mockResolvedValue([
|
||||
{
|
||||
mode: 14,
|
||||
name: '.config',
|
||||
path: './notes/.config',
|
||||
size: 0,
|
||||
type: 'directory',
|
||||
updatedAt: '2026-04-27T07:18:00',
|
||||
},
|
||||
{
|
||||
mode: 6,
|
||||
name: 'SOUL.md',
|
||||
path: './notes/SOUL.md',
|
||||
size: 399,
|
||||
type: 'file',
|
||||
updatedAt: '2026-04-27T07:19:00',
|
||||
},
|
||||
]);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'agent',
|
||||
'space',
|
||||
'fs',
|
||||
'ls',
|
||||
'-la',
|
||||
'--agent-id',
|
||||
'a1',
|
||||
'agent:/notes',
|
||||
]);
|
||||
|
||||
expect(consoleSpy).toHaveBeenNthCalledWith(1, 'total 1');
|
||||
expect(consoleSpy).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.stringMatching(/^dr-x------ {2}1 agent {2}agent {4}0 --- -- --:-- \.$/),
|
||||
);
|
||||
expect(consoleSpy).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
expect.stringMatching(/^dr-x------ {2}1 agent {2}agent {4}0 --- -- --:-- \.\.$/),
|
||||
);
|
||||
expect(consoleSpy).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
expect.stringMatching(/^drwx------ {2}1 agent {2}agent {4}0 Apr 27 07:18 \.config\/$/),
|
||||
);
|
||||
expect(consoleSpy).toHaveBeenNthCalledWith(
|
||||
5,
|
||||
expect.stringMatching(/^-rw------- {2}1 agent {2}agent {2}399 Apr 27 07:19 SOUL\.md$/),
|
||||
);
|
||||
});
|
||||
|
||||
it('should expose VFS commands through agent space fs', async () => {
|
||||
mockTrpcClient.agentDocument.listDocumentsByPath.query.mockResolvedValue([]);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'agent',
|
||||
'space',
|
||||
'fs',
|
||||
'ls',
|
||||
'--agent-id',
|
||||
'a1',
|
||||
'agent:/notes',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agentDocument.listDocumentsByPath.query).toHaveBeenCalledWith({
|
||||
agentId: 'a1',
|
||||
cursor: undefined,
|
||||
limit: undefined,
|
||||
path: './notes',
|
||||
topicId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should collect tree traversal warnings instead of failing the whole tree', async () => {
|
||||
mockTrpcClient.agentDocument.listDocumentsByPath.query
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
mode: 8,
|
||||
name: 'builtin',
|
||||
path: './lobe/skills/builtin',
|
||||
type: 'directory',
|
||||
},
|
||||
])
|
||||
.mockRejectedValueOnce(new Error('Failed to list builtin skills'));
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'agent',
|
||||
'space',
|
||||
'fs',
|
||||
'tree',
|
||||
'--agent-id',
|
||||
'a1',
|
||||
'agent:/lobe/skills',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agentDocument.listDocumentsByPath.query).toHaveBeenNthCalledWith(1, {
|
||||
agentId: 'a1',
|
||||
path: './lobe/skills',
|
||||
topicId: undefined,
|
||||
});
|
||||
expect(mockTrpcClient.agentDocument.listDocumentsByPath.query).toHaveBeenNthCalledWith(2, {
|
||||
agentId: 'a1',
|
||||
path: './lobe/skills/builtin',
|
||||
topicId: undefined,
|
||||
});
|
||||
expect(log.warn).toHaveBeenCalledWith('./lobe/skills/builtin: Failed to list builtin skills');
|
||||
});
|
||||
|
||||
it('should read SKILL.md when cat targets a skill directory alias', async () => {
|
||||
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
||||
mockTrpcClient.agentDocument.statDocumentByPath.query.mockResolvedValue({
|
||||
content: '# Writer',
|
||||
mode: 2,
|
||||
mount: { driver: 'skills', namespace: 'builtin', source: 'builtin' },
|
||||
name: 'SKILL.md',
|
||||
path: './lobe/skills/builtin/skills/writer/SKILL.md',
|
||||
type: 'file',
|
||||
});
|
||||
mockTrpcClient.agentDocument.readDocumentByPath.query.mockResolvedValue({
|
||||
content: '# Writer',
|
||||
contentType: 'text/markdown',
|
||||
path: './lobe/skills/builtin/skills/writer/SKILL.md',
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'agent',
|
||||
'space',
|
||||
'fs',
|
||||
'cat',
|
||||
'--agent-id',
|
||||
'a1',
|
||||
'builtin:/writer',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agentDocument.statDocumentByPath.query).toHaveBeenCalledWith({
|
||||
agentId: 'a1',
|
||||
path: './lobe/skills/builtin/skills/writer/SKILL.md',
|
||||
topicId: undefined,
|
||||
});
|
||||
expect(mockTrpcClient.agentDocument.readDocumentByPath.query).toHaveBeenCalledWith({
|
||||
agentId: 'a1',
|
||||
path: './lobe/skills/builtin/skills/writer/SKILL.md',
|
||||
topicId: undefined,
|
||||
});
|
||||
expect(stdoutSpy).toHaveBeenCalledWith('# Writer');
|
||||
stdoutSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should create a writable skill through touch when the path does not exist', async () => {
|
||||
mockTrpcClient.agentDocument.statDocumentByPath.query.mockRejectedValue({
|
||||
data: { code: 'NOT_FOUND' },
|
||||
});
|
||||
mockTrpcClient.agentDocument.writeDocumentByPath.mutate.mockResolvedValue({
|
||||
path: './lobe/skills/agent/skills/writer/SKILL.md',
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'agent',
|
||||
'space',
|
||||
'fs',
|
||||
'touch',
|
||||
'--agent-id',
|
||||
'a1',
|
||||
'skills:/writer',
|
||||
'--content',
|
||||
'# Writer',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agentDocument.writeDocumentByPath.mutate).toHaveBeenCalledWith({
|
||||
agentId: 'a1',
|
||||
content: '# Writer',
|
||||
createMode: 'if-missing',
|
||||
path: './lobe/skills/agent/skills/writer',
|
||||
topicId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should read write content from stdin when no content option is provided', async () => {
|
||||
const stdinDescriptor = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY');
|
||||
Object.defineProperty(process.stdin, 'isTTY', { configurable: true, value: false });
|
||||
mockReadStdinText.mockResolvedValue('# Piped Content');
|
||||
mockTrpcClient.agentDocument.statDocumentByPath.query.mockRejectedValue({
|
||||
data: { code: 'NOT_FOUND' },
|
||||
});
|
||||
mockTrpcClient.agentDocument.writeDocumentByPath.mutate.mockResolvedValue({
|
||||
path: './notes/piped.md',
|
||||
});
|
||||
|
||||
try {
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'agent',
|
||||
'space',
|
||||
'fs',
|
||||
'write',
|
||||
'--agent-id',
|
||||
'a1',
|
||||
'agent:/notes/piped.md',
|
||||
]);
|
||||
|
||||
expect(mockReadStdinText).toHaveBeenCalledWith(process.stdin);
|
||||
expect(mockTrpcClient.agentDocument.writeDocumentByPath.mutate).toHaveBeenCalledWith({
|
||||
agentId: 'a1',
|
||||
content: '# Piped Content',
|
||||
createMode: 'if-missing',
|
||||
path: './notes/piped.md',
|
||||
topicId: undefined,
|
||||
});
|
||||
} finally {
|
||||
if (stdinDescriptor) {
|
||||
Object.defineProperty(process.stdin, 'isTTY', stdinDescriptor);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should create directories through the generic mkdir path API', async () => {
|
||||
mockTrpcClient.agentDocument.mkdirDocumentByPath.mutate.mockResolvedValue({
|
||||
path: './notes/archive',
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'agent',
|
||||
'space',
|
||||
'fs',
|
||||
'mkdir',
|
||||
'--agent-id',
|
||||
'a1',
|
||||
'--parents',
|
||||
'agent:/notes/archive',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agentDocument.mkdirDocumentByPath.mutate).toHaveBeenCalledWith({
|
||||
agentId: 'a1',
|
||||
path: './notes/archive',
|
||||
recursive: true,
|
||||
topicId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should stat unified root paths', async () => {
|
||||
mockTrpcClient.agentDocument.statDocumentByPath.query.mockResolvedValue({
|
||||
mode: 8,
|
||||
name: 'lobe',
|
||||
path: './lobe',
|
||||
type: 'directory',
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'agent',
|
||||
'space',
|
||||
'fs',
|
||||
'stat',
|
||||
'--agent-id',
|
||||
'a1',
|
||||
'agent:/lobe',
|
||||
'--json',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agentDocument.statDocumentByPath.query).toHaveBeenCalledWith({
|
||||
agentId: 'a1',
|
||||
path: './lobe',
|
||||
topicId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should copy paths through the generic copyDocumentByPath API', async () => {
|
||||
mockTrpcClient.agentDocument.copyDocumentByPath.mutate.mockResolvedValue({
|
||||
path: './notes/published.md',
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'agent',
|
||||
'space',
|
||||
'fs',
|
||||
'cp',
|
||||
'--agent-id',
|
||||
'a1',
|
||||
'--force',
|
||||
'agent:/notes/draft.md',
|
||||
'agent:/notes/published.md',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agentDocument.copyDocumentByPath.mutate).toHaveBeenCalledWith({
|
||||
agentId: 'a1',
|
||||
force: true,
|
||||
fromPath: './notes/draft.md',
|
||||
toPath: './notes/published.md',
|
||||
topicId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should rename paths through the generic renameDocumentByPath API', async () => {
|
||||
mockTrpcClient.agentDocument.renameDocumentByPath.mutate.mockResolvedValue({
|
||||
path: './notes/final.md',
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'agent',
|
||||
'space',
|
||||
'fs',
|
||||
'mv',
|
||||
'--agent-id',
|
||||
'a1',
|
||||
'agent:/notes/draft.md',
|
||||
'agent:/notes/final.md',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agentDocument.renameDocumentByPath.mutate).toHaveBeenCalledWith({
|
||||
agentId: 'a1',
|
||||
force: undefined,
|
||||
fromPath: './notes/draft.md',
|
||||
toPath: './notes/final.md',
|
||||
topicId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should soft-delete paths through the generic deleteDocumentByPath API', async () => {
|
||||
mockTrpcClient.agentDocument.deleteDocumentByPath.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'agent',
|
||||
'space',
|
||||
'fs',
|
||||
'rm',
|
||||
'--agent-id',
|
||||
'a1',
|
||||
'--yes',
|
||||
'--recursive',
|
||||
'agent:/notes',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agentDocument.deleteDocumentByPath.mutate).toHaveBeenCalledWith({
|
||||
agentId: 'a1',
|
||||
force: undefined,
|
||||
path: './notes',
|
||||
recursive: true,
|
||||
topicId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should list trash through the generic trash path API', async () => {
|
||||
mockTrpcClient.agentDocument.listTrashDocumentsByPath.query.mockResolvedValue([
|
||||
{ path: './notes/deleted.md' },
|
||||
]);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'agent',
|
||||
'space',
|
||||
'fs',
|
||||
'trash',
|
||||
'ls',
|
||||
'--agent-id',
|
||||
'a1',
|
||||
'agent:/notes',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agentDocument.listTrashDocumentsByPath.query).toHaveBeenCalledWith({
|
||||
agentId: 'a1',
|
||||
path: './notes',
|
||||
topicId: undefined,
|
||||
});
|
||||
expect(consoleSpy).toHaveBeenCalledWith('agent:/notes/deleted.md');
|
||||
});
|
||||
|
||||
it('should restore trash entries through the generic trash restore API', async () => {
|
||||
mockTrpcClient.agentDocument.restoreDocumentFromTrashByPath.mutate.mockResolvedValue({
|
||||
path: './notes/deleted.md',
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'agent',
|
||||
'space',
|
||||
'fs',
|
||||
'trash',
|
||||
'restore',
|
||||
'--agent-id',
|
||||
'a1',
|
||||
'agent:/notes/deleted.md',
|
||||
]);
|
||||
|
||||
expect(
|
||||
mockTrpcClient.agentDocument.restoreDocumentFromTrashByPath.mutate,
|
||||
).toHaveBeenCalledWith({
|
||||
agentId: 'a1',
|
||||
path: './notes/deleted.md',
|
||||
topicId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should permanently delete trash entries through the generic trash rm API', async () => {
|
||||
mockTrpcClient.agentDocument.deleteDocumentPermanentlyByPath.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'agent',
|
||||
'space',
|
||||
'fs',
|
||||
'trash',
|
||||
'rm',
|
||||
'--agent-id',
|
||||
'a1',
|
||||
'--yes',
|
||||
'--force',
|
||||
'agent:/notes/deleted.md',
|
||||
]);
|
||||
|
||||
expect(
|
||||
mockTrpcClient.agentDocument.deleteDocumentPermanentlyByPath.mutate,
|
||||
).toHaveBeenCalledWith({
|
||||
agentId: 'a1',
|
||||
force: true,
|
||||
path: './notes/deleted.md',
|
||||
recursive: undefined,
|
||||
topicId: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,22 +4,37 @@ import type { Command } from 'commander';
|
||||
import pc from 'picocolors';
|
||||
|
||||
import { getTrpcClient } from '../api/client';
|
||||
import { getAgentStreamAuthInfo } from '../api/http';
|
||||
import { resolveAgentGatewayUrl } from '../settings';
|
||||
import {
|
||||
replayAgentEvents,
|
||||
streamAgentEvents,
|
||||
streamAgentEventsViaWebSocket,
|
||||
} from '../utils/agentStream';
|
||||
import { resolveLocalDeviceId } from '../utils/device';
|
||||
import { getAuthInfo } from '../api/http';
|
||||
import { replayAgentEvents, streamAgentEvents } from '../utils/agentStream';
|
||||
import { confirm, outputJson, printTable, truncate } from '../utils/format';
|
||||
import { log, setVerbose } from '../utils/logger';
|
||||
import { resolveAgentId } from './agent/resolveAgentId';
|
||||
import { registerAgentSpaceFsCommand } from './agent/spaceFs';
|
||||
|
||||
/**
|
||||
* Resolve an agent identifier (agentId or slug) to a concrete agentId.
|
||||
* When a slug is provided, uses getBuiltinAgent to look up the agent.
|
||||
*/
|
||||
async function resolveAgentId(
|
||||
client: any,
|
||||
opts: { agentId?: string; slug?: string },
|
||||
): Promise<string> {
|
||||
if (opts.agentId) return opts.agentId;
|
||||
|
||||
if (opts.slug) {
|
||||
const agent = await client.agent.getBuiltinAgent.query({ slug: opts.slug });
|
||||
if (!agent) {
|
||||
log.error(`Agent not found for slug: ${opts.slug}`);
|
||||
process.exit(1);
|
||||
}
|
||||
return (agent as any).id || (agent as any).agentId;
|
||||
}
|
||||
|
||||
log.error('Either <agentId> or --slug is required.');
|
||||
process.exit(1);
|
||||
return ''; // unreachable
|
||||
}
|
||||
|
||||
export function registerAgentCommand(program: Command) {
|
||||
const agent = program.command('agent').description('Manage agents');
|
||||
registerAgentSpaceFsCommand(agent);
|
||||
|
||||
// ── list ──────────────────────────────────────────────
|
||||
|
||||
@@ -233,29 +248,17 @@ export function registerAgentCommand(program: Command) {
|
||||
.option('-p, --prompt <text>', 'User prompt')
|
||||
.option('-t, --topic-id <id>', 'Reuse an existing topic')
|
||||
.option('--no-auto-start', 'Do not auto-start the agent')
|
||||
.option(
|
||||
'--device <target>',
|
||||
'Target device ID, or use "local" for the current connected device',
|
||||
)
|
||||
.option(
|
||||
'--no-headless',
|
||||
"Disable headless mode and wait for human approval on tool calls (default: headless — tools auto-run, matching the CLI's non-interactive nature)",
|
||||
)
|
||||
.option('--json', 'Output full JSON event stream')
|
||||
.option('-v, --verbose', 'Show detailed tool call info')
|
||||
.option('--replay <file>', 'Replay events from a saved JSON file (offline)')
|
||||
.option('--sse', 'Force SSE stream instead of WebSocket gateway')
|
||||
.action(
|
||||
async (options: {
|
||||
agentId?: string;
|
||||
autoStart?: boolean;
|
||||
device?: string;
|
||||
headless?: boolean;
|
||||
json?: boolean;
|
||||
prompt?: string;
|
||||
replay?: string;
|
||||
slug?: string;
|
||||
sse?: boolean;
|
||||
topicId?: string;
|
||||
verbose?: boolean;
|
||||
}) => {
|
||||
@@ -282,53 +285,12 @@ export function registerAgentCommand(program: Command) {
|
||||
|
||||
const client = await getTrpcClient();
|
||||
|
||||
let deviceId: string | undefined;
|
||||
if (options.device !== undefined) {
|
||||
if (options.device === 'local') {
|
||||
deviceId = resolveLocalDeviceId();
|
||||
if (!deviceId) {
|
||||
log.error(
|
||||
"No local device found. Run 'lh connect' first, then retry with --device local.",
|
||||
);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
deviceId = options.device;
|
||||
}
|
||||
|
||||
const devices = await client.device.listDevices.query();
|
||||
const matchedDevice = devices.find(
|
||||
(device: { deviceId?: string; online?: boolean }) => device.deviceId === deviceId,
|
||||
);
|
||||
if (!matchedDevice) {
|
||||
log.error(`Device "${deviceId}" was not found. Check 'lh device list' and try again.`);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
if (!matchedDevice.online) {
|
||||
log.error(
|
||||
options.device === 'local'
|
||||
? `Local device "${deviceId}" is not online. Reconnect with 'lh connect' and try again.`
|
||||
: `Device "${deviceId}" is not online. Bring it online and try again.`,
|
||||
);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Exec agent to get operationId
|
||||
const input: Record<string, any> = { prompt: options.prompt };
|
||||
if (options.agentId) input.agentId = options.agentId;
|
||||
if (deviceId) input.deviceId = deviceId;
|
||||
if (options.slug) input.slug = options.slug;
|
||||
if (options.topicId) input.appContext = { topicId: options.topicId };
|
||||
if (options.autoStart === false) input.autoStart = false;
|
||||
// commander's --no-headless sets `headless` to false. Anything else
|
||||
// (undefined, true) → headless mode is on and tool calls auto-execute.
|
||||
if (options.headless !== false) {
|
||||
input.userInterventionConfig = { approvalMode: 'headless' };
|
||||
}
|
||||
|
||||
const result = await client.aiAgent.execAgent.mutate(input as any);
|
||||
const r = result as any;
|
||||
@@ -343,27 +305,14 @@ export function registerAgentCommand(program: Command) {
|
||||
log.info(`Operation: ${pc.dim(operationId)} · Topic: ${pc.dim(r.topicId || 'n/a')}`);
|
||||
}
|
||||
|
||||
// 2. Connect to stream (WebSocket via Gateway, or fallback to SSE)
|
||||
const { serverUrl, headers, token, tokenType } = await getAgentStreamAuthInfo();
|
||||
const agentGatewayUrl = options.sse ? undefined : resolveAgentGatewayUrl();
|
||||
// 2. Connect to SSE stream
|
||||
const { serverUrl, headers } = await getAuthInfo();
|
||||
const streamUrl = `${serverUrl}/api/agent/stream?operationId=${encodeURIComponent(operationId)}`;
|
||||
|
||||
if (agentGatewayUrl) {
|
||||
await streamAgentEventsViaWebSocket({
|
||||
gatewayUrl: agentGatewayUrl,
|
||||
json: options.json,
|
||||
operationId,
|
||||
serverUrl,
|
||||
token,
|
||||
tokenType,
|
||||
verbose: options.verbose,
|
||||
});
|
||||
} else {
|
||||
const streamUrl = `${serverUrl}/api/agent/stream?operationId=${encodeURIComponent(operationId)}`;
|
||||
await streamAgentEvents(streamUrl, headers, {
|
||||
json: options.json,
|
||||
verbose: options.verbose,
|
||||
});
|
||||
}
|
||||
await streamAgentEvents(streamUrl, headers, {
|
||||
json: options.json,
|
||||
verbose: options.verbose,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import { log } from '../../utils/logger';
|
||||
|
||||
interface AgentLookupClient {
|
||||
agent: {
|
||||
getBuiltinAgent: {
|
||||
query: (input: { slug: string }) => Promise<{ agentId?: string; id?: string } | null>;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an agent identifier into a concrete agent id.
|
||||
*
|
||||
* Use when:
|
||||
* - A command accepts either a positional agent id or `--slug`.
|
||||
* - Downstream tRPC calls require the concrete agent id.
|
||||
*
|
||||
* Expects:
|
||||
* - `opts.agentId` to win over `opts.slug`.
|
||||
* - `client.agent.getBuiltinAgent` to resolve slugs when needed.
|
||||
*
|
||||
* Returns:
|
||||
* - The resolved agent id, or exits the process after logging a CLI-facing error.
|
||||
*/
|
||||
export async function resolveAgentId(
|
||||
client: AgentLookupClient,
|
||||
opts: { agentId?: string; slug?: string },
|
||||
): Promise<string> {
|
||||
if (opts.agentId) return opts.agentId;
|
||||
|
||||
if (opts.slug) {
|
||||
const agent = await client.agent.getBuiltinAgent.query({ slug: opts.slug });
|
||||
if (!agent) {
|
||||
log.error(`Agent not found for slug: ${opts.slug}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return agent.id || agent.agentId || '';
|
||||
}
|
||||
|
||||
log.error('Either <agentId> or --slug is required.');
|
||||
process.exit(1);
|
||||
return '';
|
||||
}
|
||||
@@ -1,908 +0,0 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { text } from 'node:stream/consumers';
|
||||
|
||||
import type { Command } from 'commander';
|
||||
import dayjs from 'dayjs';
|
||||
import pc from 'picocolors';
|
||||
|
||||
import { getTrpcClient } from '../../api/client';
|
||||
import { confirm, outputJson } from '../../utils/format';
|
||||
import { log } from '../../utils/logger';
|
||||
import { resolveAgentId } from './resolveAgentId';
|
||||
|
||||
const SKILL_FILE_NAME = 'SKILL.md';
|
||||
|
||||
const SKILL_NAMESPACE_PREFIXES = {
|
||||
'agent': './lobe/skills/agent/skills',
|
||||
'builtin': './lobe/skills/builtin/skills',
|
||||
'installed-active': './lobe/skills/installed/active/skills',
|
||||
'installed-all': './lobe/skills/installed/all/skills',
|
||||
} as const;
|
||||
|
||||
const FS_PATH_ALIASES = {
|
||||
'agent': './',
|
||||
'builtin': 'builtin',
|
||||
'skills': 'agent',
|
||||
'installed-active': 'installed-active',
|
||||
'installed-all': 'installed-all',
|
||||
} as const;
|
||||
|
||||
type SkillFsNamespace = keyof typeof SKILL_NAMESPACE_PREFIXES;
|
||||
type AgentFsClient = Awaited<ReturnType<typeof getTrpcClient>>;
|
||||
|
||||
interface AgentFsContext {
|
||||
agentId: string;
|
||||
topicId?: string;
|
||||
}
|
||||
|
||||
interface AgentFsNode {
|
||||
content?: string;
|
||||
createdAt?: Date | string;
|
||||
mode?: number;
|
||||
mount?: {
|
||||
driver?: string;
|
||||
namespace?: string;
|
||||
};
|
||||
name: string;
|
||||
path: string;
|
||||
size?: number;
|
||||
type: 'directory' | 'file';
|
||||
updatedAt?: Date | string;
|
||||
}
|
||||
|
||||
interface AgentFsResolvedPath {
|
||||
filePath?: string;
|
||||
namespace?: SkillFsNamespace;
|
||||
path: string;
|
||||
skillName?: string;
|
||||
}
|
||||
|
||||
interface AgentFsOptions {
|
||||
agentId?: string;
|
||||
slug?: string;
|
||||
topicId?: string;
|
||||
}
|
||||
|
||||
function getTrpcErrorCode(error: unknown): string | undefined {
|
||||
if (!error || typeof error !== 'object') return undefined;
|
||||
|
||||
const value = error as {
|
||||
data?: { code?: string };
|
||||
shape?: { data?: { code?: string } };
|
||||
};
|
||||
|
||||
return value.data?.code ?? value.shape?.data?.code;
|
||||
}
|
||||
|
||||
function exitWithError(message: string): never {
|
||||
log.error(message);
|
||||
process.exit(1);
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
function resolveAgentFsPath(input = 'agent:/'): AgentFsResolvedPath {
|
||||
const raw = input.trim();
|
||||
|
||||
const aliasMatch = raw.match(/^([a-z-]+):(\/.*)?$/);
|
||||
|
||||
if (aliasMatch) {
|
||||
const alias = aliasMatch[1] as keyof typeof FS_PATH_ALIASES;
|
||||
const target = FS_PATH_ALIASES[alias];
|
||||
|
||||
if (!target) {
|
||||
exitWithError(
|
||||
`Unknown fs namespace "${aliasMatch[1]}". Use agent, skills, builtin, installed-all, or installed-active.`,
|
||||
);
|
||||
}
|
||||
|
||||
const suffix = aliasMatch[2]?.replace(/^\/+/, '').replace(/\/+$/, '') ?? '';
|
||||
const prefix = target === './' ? './' : SKILL_NAMESPACE_PREFIXES[target as SkillFsNamespace];
|
||||
|
||||
return resolveAgentFsPath(suffix ? `${prefix}/${suffix}` : prefix);
|
||||
}
|
||||
|
||||
if (raw === './' || raw === '.' || raw === '/') {
|
||||
return { path: './' };
|
||||
}
|
||||
|
||||
const match = Object.entries(SKILL_NAMESPACE_PREFIXES).find(([, prefix]) => {
|
||||
return raw === prefix || raw.startsWith(`${prefix}/`);
|
||||
});
|
||||
|
||||
if (!match) {
|
||||
if (!raw.startsWith('./')) {
|
||||
exitWithError(`Invalid fs path "${input}". Use aliases like "agent:/" or a full VFS path.`);
|
||||
}
|
||||
|
||||
const normalizedPath = raw.replaceAll(/\/+/g, '/').replace(/\/+$/, '') || './';
|
||||
return { path: normalizedPath };
|
||||
}
|
||||
|
||||
const [namespace, prefix] = match as [
|
||||
SkillFsNamespace,
|
||||
(typeof SKILL_NAMESPACE_PREFIXES)[SkillFsNamespace],
|
||||
];
|
||||
const relativePath = raw.slice(prefix.length).replace(/^\/+/, '').replace(/\/+$/, '');
|
||||
|
||||
if (
|
||||
relativePath.includes('//') ||
|
||||
relativePath.split('/').some((segment) => segment === '.' || segment === '..')
|
||||
) {
|
||||
exitWithError(`Invalid fs path "${input}"`);
|
||||
}
|
||||
|
||||
if (!relativePath) {
|
||||
return { namespace, path: prefix };
|
||||
}
|
||||
|
||||
const separatorIndex = relativePath.indexOf('/');
|
||||
|
||||
if (separatorIndex < 0) {
|
||||
return {
|
||||
namespace,
|
||||
path: `${prefix}/${relativePath}`,
|
||||
skillName: relativePath,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
filePath: relativePath.slice(separatorIndex + 1),
|
||||
namespace,
|
||||
path: `${prefix}/${relativePath}`,
|
||||
skillName: relativePath.slice(0, separatorIndex),
|
||||
};
|
||||
}
|
||||
|
||||
function requireSkillNamespace(resolved: AgentFsResolvedPath): SkillFsNamespace {
|
||||
if (!resolved.namespace) {
|
||||
exitWithError(`Expected a skill namespace path, but received "${resolved.path}".`);
|
||||
}
|
||||
|
||||
return resolved.namespace;
|
||||
}
|
||||
|
||||
function canonicalSkillFilePath(resolved: AgentFsResolvedPath) {
|
||||
if (!resolved.skillName) {
|
||||
exitWithError('Expected a skill path, but received a namespace root.');
|
||||
}
|
||||
|
||||
if (resolved.filePath && resolved.filePath !== SKILL_FILE_NAME) {
|
||||
exitWithError(`Unsupported writable path "${resolved.path}". Only SKILL.md is mutable.`);
|
||||
}
|
||||
|
||||
return `${SKILL_NAMESPACE_PREFIXES[requireSkillNamespace(resolved)]}/${resolved.skillName}/${SKILL_FILE_NAME}`;
|
||||
}
|
||||
|
||||
function toDisplayPath(path: string) {
|
||||
if (path === './') return 'agent:/';
|
||||
if (path.startsWith('./') && path !== './lobe' && !path.startsWith('./lobe/')) {
|
||||
return `agent:/${path.slice(2)}`;
|
||||
}
|
||||
|
||||
for (const [namespace, prefix] of Object.entries(SKILL_NAMESPACE_PREFIXES) as Array<
|
||||
[SkillFsNamespace, string]
|
||||
>) {
|
||||
const alias = namespace === 'agent' ? 'skills' : namespace;
|
||||
if (path === prefix) return `${alias}:/`;
|
||||
if (path.startsWith(`${prefix}/`)) return `${alias}:/${path.slice(prefix.length + 1)}`;
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
function isWritableNode(node: { mode?: number }) {
|
||||
return ((node.mode ?? 0) & 4) !== 0;
|
||||
}
|
||||
|
||||
function parseOptionalPositiveInteger(value?: string) {
|
||||
if (value === undefined) return undefined;
|
||||
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
exitWithError(`Expected a positive integer, received "${value}".`);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function formatFsNodeName(node: { mode?: number; name: string; type: 'directory' | 'file' }) {
|
||||
const suffix = node.type === 'directory' ? '/' : '';
|
||||
return isWritableNode(node) ? `${node.name}${suffix}` : pc.dim(`${node.name}${suffix}`);
|
||||
}
|
||||
|
||||
function getFsNodeDisplayName(node: Pick<AgentFsNode, 'name' | 'type'>) {
|
||||
if (node.name === '.' || node.name === '..') return node.name;
|
||||
|
||||
return `${node.name}${node.type === 'directory' ? '/' : ''}`;
|
||||
}
|
||||
|
||||
function getParentFsPath(path: string) {
|
||||
if (path === './') return './';
|
||||
|
||||
const segments = path.replace(/^\.\//, '').split('/').filter(Boolean);
|
||||
if (segments.length <= 1) return './';
|
||||
|
||||
return `./${segments.slice(0, -1).join('/')}`;
|
||||
}
|
||||
|
||||
function createSyntheticListingNode(name: '.' | '..', path: string): AgentFsNode {
|
||||
return {
|
||||
mode: 10,
|
||||
name,
|
||||
path,
|
||||
size: 0,
|
||||
type: 'directory',
|
||||
};
|
||||
}
|
||||
|
||||
function formatFsPermissions(node: Pick<AgentFsNode, 'mode' | 'type'>) {
|
||||
const mode = node.mode ?? 0;
|
||||
const canRead = (mode & 2) !== 0 || (mode & 8) !== 0;
|
||||
const canWrite = (mode & 4) !== 0;
|
||||
const canExecute = (mode & 1) !== 0 || (node.type === 'directory' && (mode & 8) !== 0);
|
||||
const owner = `${canRead ? 'r' : '-'}${canWrite ? 'w' : '-'}${canExecute ? 'x' : '-'}`;
|
||||
|
||||
return `${node.type === 'directory' ? 'd' : '-'}${owner}------`;
|
||||
}
|
||||
|
||||
function formatFsLongDate(value?: Date | string) {
|
||||
if (!value) return '--- -- --:--';
|
||||
|
||||
const date = dayjs(value);
|
||||
if (!date.isValid()) return '--- -- --:--';
|
||||
|
||||
return date.format('MMM DD HH:mm');
|
||||
}
|
||||
|
||||
function formatFsLongListing(nodes: AgentFsNode[]) {
|
||||
const sizeWidth = Math.max(1, ...nodes.map((node) => String(node.size ?? 0).length));
|
||||
const totalBlocks = nodes.reduce((total, node) => total + Math.ceil((node.size ?? 0) / 512), 0);
|
||||
const lines = [`total ${totalBlocks}`];
|
||||
|
||||
for (const node of nodes) {
|
||||
const size = String(node.size ?? 0).padStart(sizeWidth, ' ');
|
||||
const mtime = formatFsLongDate(node.updatedAt ?? node.createdAt);
|
||||
lines.push(
|
||||
`${formatFsPermissions(node)} 1 agent agent ${size} ${mtime} ${getFsNodeDisplayName(node)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
async function readFsContentInput(options: { content?: string; contentFile?: string }) {
|
||||
if (options.contentFile) {
|
||||
return readFileSync(options.contentFile, 'utf8');
|
||||
}
|
||||
|
||||
if (options.content !== undefined) return options.content;
|
||||
|
||||
// NOTICE:
|
||||
// CLI write commands should compose with shell pipelines without blocking interactive runs.
|
||||
// Node marks piped stdin with `isTTY === false`, while normal terminals are `true` or undefined in tests.
|
||||
// Remove this branch only if Commander gains first-class stdin option support for these commands.
|
||||
if (process.stdin.isTTY === false) return text(process.stdin);
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
async function resolveAgentFsContext(client: AgentFsClient, options: AgentFsOptions) {
|
||||
const agentId = await resolveAgentId(client, options);
|
||||
return { agentId, topicId: options.topicId };
|
||||
}
|
||||
|
||||
async function getFsNode(client: AgentFsClient, context: AgentFsContext, path: string) {
|
||||
try {
|
||||
return (await client.agentDocument.statDocumentByPath.query({
|
||||
agentId: context.agentId,
|
||||
path,
|
||||
topicId: context.topicId,
|
||||
})) as AgentFsNode;
|
||||
} catch (error) {
|
||||
if (getTrpcErrorCode(error) === 'NOT_FOUND') return undefined;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function readFsFile(client: AgentFsClient, context: AgentFsContext, inputPath: string) {
|
||||
const resolved = resolveAgentFsPath(inputPath);
|
||||
|
||||
const readPath =
|
||||
resolved.skillName && !resolved.filePath
|
||||
? `${SKILL_NAMESPACE_PREFIXES[requireSkillNamespace(resolved)]}/${resolved.skillName}/${SKILL_FILE_NAME}`
|
||||
: resolved.path;
|
||||
|
||||
const stat = await getFsNode(client, context, readPath);
|
||||
|
||||
if (!stat) {
|
||||
exitWithError(`Path not found: ${inputPath}`);
|
||||
}
|
||||
|
||||
if (stat.type !== 'file') {
|
||||
exitWithError(`Cannot read directory path: ${inputPath}`);
|
||||
}
|
||||
|
||||
const node = (await client.agentDocument.readDocumentByPath.query({
|
||||
agentId: context.agentId,
|
||||
path: readPath,
|
||||
topicId: context.topicId,
|
||||
})) as AgentFsNode;
|
||||
|
||||
return { node, resolved: resolveAgentFsPath(readPath) };
|
||||
}
|
||||
|
||||
async function writeFsFile(
|
||||
client: AgentFsClient,
|
||||
context: AgentFsContext,
|
||||
inputPath: string,
|
||||
content: string,
|
||||
) {
|
||||
const resolved = resolveAgentFsPath(inputPath);
|
||||
const existing = await getFsNode(
|
||||
client,
|
||||
context,
|
||||
resolved.skillName && !resolved.filePath ? canonicalSkillFilePath(resolved) : resolved.path,
|
||||
);
|
||||
const result = await client.agentDocument.writeDocumentByPath.mutate({
|
||||
agentId: context.agentId,
|
||||
content,
|
||||
createMode: existing ? 'must-exist' : 'if-missing',
|
||||
path: resolved.path,
|
||||
topicId: context.topicId,
|
||||
});
|
||||
|
||||
return {
|
||||
action: existing ? ('updated' as const) : ('created' as const),
|
||||
path: result?.path ?? resolved.path,
|
||||
};
|
||||
}
|
||||
|
||||
async function mkdirFsPath(
|
||||
client: AgentFsClient,
|
||||
context: AgentFsContext,
|
||||
inputPath: string,
|
||||
options?: { recursive?: boolean },
|
||||
) {
|
||||
const resolved = resolveAgentFsPath(inputPath);
|
||||
|
||||
return client.agentDocument.mkdirDocumentByPath.mutate({
|
||||
agentId: context.agentId,
|
||||
path: resolved.path,
|
||||
recursive: options?.recursive,
|
||||
topicId: context.topicId,
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteFsPath(
|
||||
client: AgentFsClient,
|
||||
context: AgentFsContext,
|
||||
inputPath: string,
|
||||
options?: { force?: boolean; recursive?: boolean },
|
||||
) {
|
||||
const resolved = resolveAgentFsPath(inputPath);
|
||||
|
||||
return client.agentDocument.deleteDocumentByPath.mutate({
|
||||
agentId: context.agentId,
|
||||
force: options?.force,
|
||||
path: resolved.path,
|
||||
recursive: options?.recursive,
|
||||
topicId: context.topicId,
|
||||
});
|
||||
}
|
||||
|
||||
async function copyFsPath(
|
||||
client: AgentFsClient,
|
||||
context: AgentFsContext,
|
||||
source: string,
|
||||
destination: string,
|
||||
force?: boolean,
|
||||
) {
|
||||
const sourceResolved = resolveAgentFsPath(source);
|
||||
const destinationResolved = resolveAgentFsPath(destination);
|
||||
|
||||
return client.agentDocument.copyDocumentByPath.mutate({
|
||||
agentId: context.agentId,
|
||||
force,
|
||||
fromPath: sourceResolved.path,
|
||||
toPath: destinationResolved.path,
|
||||
topicId: context.topicId,
|
||||
});
|
||||
}
|
||||
|
||||
async function renameFsPath(
|
||||
client: AgentFsClient,
|
||||
context: AgentFsContext,
|
||||
source: string,
|
||||
destination: string,
|
||||
force?: boolean,
|
||||
) {
|
||||
const sourceResolved = resolveAgentFsPath(source);
|
||||
const destinationResolved = resolveAgentFsPath(destination);
|
||||
|
||||
return client.agentDocument.renameDocumentByPath.mutate({
|
||||
agentId: context.agentId,
|
||||
force,
|
||||
fromPath: sourceResolved.path,
|
||||
toPath: destinationResolved.path,
|
||||
topicId: context.topicId,
|
||||
});
|
||||
}
|
||||
|
||||
async function listTrashFsPath(client: AgentFsClient, context: AgentFsContext, inputPath?: string) {
|
||||
const resolved = resolveAgentFsPath(inputPath || 'agent:/');
|
||||
|
||||
return (await client.agentDocument.listTrashDocumentsByPath.query({
|
||||
agentId: context.agentId,
|
||||
path: resolved.path,
|
||||
topicId: context.topicId,
|
||||
})) as Array<Pick<AgentFsNode, 'path'>>;
|
||||
}
|
||||
|
||||
async function restoreTrashFsPath(
|
||||
client: AgentFsClient,
|
||||
context: AgentFsContext,
|
||||
inputPath: string,
|
||||
) {
|
||||
const resolved = resolveAgentFsPath(inputPath);
|
||||
|
||||
return client.agentDocument.restoreDocumentFromTrashByPath.mutate({
|
||||
agentId: context.agentId,
|
||||
path: resolved.path,
|
||||
topicId: context.topicId,
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteTrashFsPath(
|
||||
client: AgentFsClient,
|
||||
context: AgentFsContext,
|
||||
inputPath: string,
|
||||
options?: { force?: boolean; recursive?: boolean },
|
||||
) {
|
||||
const resolved = resolveAgentFsPath(inputPath);
|
||||
|
||||
return client.agentDocument.deleteDocumentPermanentlyByPath.mutate({
|
||||
agentId: context.agentId,
|
||||
force: options?.force,
|
||||
path: resolved.path,
|
||||
recursive: options?.recursive,
|
||||
topicId: context.topicId,
|
||||
});
|
||||
}
|
||||
|
||||
async function printFsTree(
|
||||
client: AgentFsClient,
|
||||
context: AgentFsContext,
|
||||
path: string,
|
||||
prefix = '',
|
||||
warnings: string[] = [],
|
||||
) {
|
||||
let nodes: AgentFsNode[];
|
||||
|
||||
try {
|
||||
nodes = (await client.agentDocument.listDocumentsByPath.query({
|
||||
agentId: context.agentId,
|
||||
path,
|
||||
topicId: context.topicId,
|
||||
})) as AgentFsNode[];
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'failed to list path';
|
||||
warnings.push(`${toDisplayPath(path)}: ${message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [index, node] of nodes.entries()) {
|
||||
const last = index === nodes.length - 1;
|
||||
console.log(`${prefix}${last ? '└── ' : '├── '}${formatFsNodeName(node)}`);
|
||||
|
||||
if (node.type === 'directory') {
|
||||
await printFsTree(client, context, node.path, `${prefix}${last ? ' ' : '│ '}`, warnings);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function registerFsCommands(fsCommand: Command) {
|
||||
fsCommand
|
||||
.command('ls [path]')
|
||||
.description('List VFS entries')
|
||||
.option('-a, --all', 'Include hidden entries')
|
||||
.option('-l, --long', 'Use long listing format')
|
||||
.option('-A, --agent-id <id>', 'Agent ID')
|
||||
.option('-s, --slug <slug>', 'Agent slug')
|
||||
.option('--cursor <cursor>', 'Directory pagination cursor')
|
||||
.option('-L, --limit <n>', 'Maximum number of entries')
|
||||
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
|
||||
.action(
|
||||
async (
|
||||
inputPath: string | undefined,
|
||||
options: {
|
||||
agentId?: string;
|
||||
all?: boolean;
|
||||
cursor?: string;
|
||||
json?: string | boolean;
|
||||
limit?: string;
|
||||
long?: boolean;
|
||||
slug?: string;
|
||||
topicId?: string;
|
||||
},
|
||||
) => {
|
||||
const client = await getTrpcClient();
|
||||
const context = await resolveAgentFsContext(client, options);
|
||||
const resolved = resolveAgentFsPath(inputPath || 'agent:/');
|
||||
|
||||
const nodes = ((await client.agentDocument.listDocumentsByPath.query({
|
||||
agentId: context.agentId,
|
||||
cursor: options.cursor,
|
||||
limit: parseOptionalPositiveInteger(options.limit),
|
||||
path: resolved.path,
|
||||
topicId: context.topicId,
|
||||
})) ?? []) as AgentFsNode[];
|
||||
const filtered = options.all ? nodes : nodes.filter((node) => !node.name.startsWith('.'));
|
||||
|
||||
if (options.json !== undefined) {
|
||||
const fields = typeof options.json === 'string' ? options.json : undefined;
|
||||
outputJson(filtered, fields);
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.long) {
|
||||
const longNodes = options.all
|
||||
? [
|
||||
createSyntheticListingNode('.', resolved.path),
|
||||
createSyntheticListingNode('..', getParentFsPath(resolved.path)),
|
||||
...filtered,
|
||||
]
|
||||
: filtered;
|
||||
formatFsLongListing(longNodes).forEach((line) => console.log(line));
|
||||
return;
|
||||
}
|
||||
|
||||
filtered.forEach((node) => console.log(formatFsNodeName(node)));
|
||||
},
|
||||
);
|
||||
|
||||
fsCommand
|
||||
.command('tree [path]')
|
||||
.description('Print a tree view of the VFS')
|
||||
.option('-A, --agent-id <id>', 'Agent ID')
|
||||
.option('-s, --slug <slug>', 'Agent slug')
|
||||
.action(
|
||||
async (
|
||||
inputPath: string | undefined,
|
||||
options: { agentId?: string; slug?: string; topicId?: string },
|
||||
) => {
|
||||
const client = await getTrpcClient();
|
||||
const context = await resolveAgentFsContext(client, options);
|
||||
const resolved = resolveAgentFsPath(inputPath || 'agent:/');
|
||||
|
||||
console.log(pc.bold(toDisplayPath(resolved.path)));
|
||||
const warnings: string[] = [];
|
||||
await printFsTree(client, context, resolved.path, '', warnings);
|
||||
|
||||
for (const warning of warnings) {
|
||||
log.warn(warning);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
fsCommand
|
||||
.command('cat <path>')
|
||||
.description('Read a VFS file')
|
||||
.option('-A, --agent-id <id>', 'Agent ID')
|
||||
.option('-s, --slug <slug>', 'Agent slug')
|
||||
.action(
|
||||
async (inputPath: string, options: { agentId?: string; slug?: string; topicId?: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
const context = await resolveAgentFsContext(client, options);
|
||||
const { node } = await readFsFile(client, context, inputPath);
|
||||
process.stdout.write(node.content ?? '');
|
||||
},
|
||||
);
|
||||
|
||||
fsCommand
|
||||
.command('stat <path>')
|
||||
.description('Show VFS node metadata')
|
||||
.option('-A, --agent-id <id>', 'Agent ID')
|
||||
.option('-s, --slug <slug>', 'Agent slug')
|
||||
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
|
||||
.action(
|
||||
async (
|
||||
inputPath: string,
|
||||
options: {
|
||||
agentId?: string;
|
||||
json?: string | boolean;
|
||||
slug?: string;
|
||||
topicId?: string;
|
||||
},
|
||||
) => {
|
||||
const client = await getTrpcClient();
|
||||
const context = await resolveAgentFsContext(client, options);
|
||||
const resolved = resolveAgentFsPath(inputPath);
|
||||
|
||||
const node = await getFsNode(client, context, resolved.path);
|
||||
|
||||
if (!node) {
|
||||
exitWithError(`Path not found: ${inputPath}`);
|
||||
}
|
||||
|
||||
if (options.json !== undefined) {
|
||||
const fields = typeof options.json === 'string' ? options.json : undefined;
|
||||
outputJson(node, fields);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(node, null, 2));
|
||||
},
|
||||
);
|
||||
|
||||
fsCommand
|
||||
.command('touch <path>')
|
||||
.description('Create or update a VFS file')
|
||||
.option('-A, --agent-id <id>', 'Agent ID')
|
||||
.option('-s, --slug <slug>', 'Agent slug')
|
||||
.option('-c, --content <content>', 'File content')
|
||||
.option('-F, --content-file <path>', 'Read content from a local file')
|
||||
.action(
|
||||
async (
|
||||
inputPath: string,
|
||||
options: {
|
||||
agentId?: string;
|
||||
content?: string;
|
||||
contentFile?: string;
|
||||
slug?: string;
|
||||
topicId?: string;
|
||||
},
|
||||
) => {
|
||||
const client = await getTrpcClient();
|
||||
const context = await resolveAgentFsContext(client, options);
|
||||
const content = await readFsContentInput(options);
|
||||
const result = await writeFsFile(client, context, inputPath, content);
|
||||
console.log(`${pc.green('✓')} ${result.action} ${pc.bold(toDisplayPath(result.path))}`);
|
||||
},
|
||||
);
|
||||
|
||||
fsCommand
|
||||
.command('write <path>')
|
||||
.description('Write content to a VFS file')
|
||||
.option('-A, --agent-id <id>', 'Agent ID')
|
||||
.option('-s, --slug <slug>', 'Agent slug')
|
||||
.option('-c, --content <content>', 'File content')
|
||||
.option('-F, --content-file <path>', 'Read content from a local file')
|
||||
.action(
|
||||
async (
|
||||
inputPath: string,
|
||||
options: {
|
||||
agentId?: string;
|
||||
content?: string;
|
||||
contentFile?: string;
|
||||
slug?: string;
|
||||
topicId?: string;
|
||||
},
|
||||
) => {
|
||||
const client = await getTrpcClient();
|
||||
const context = await resolveAgentFsContext(client, options);
|
||||
const content = await readFsContentInput(options);
|
||||
const result = await writeFsFile(client, context, inputPath, content);
|
||||
console.log(`${pc.green('✓')} ${result.action} ${pc.bold(toDisplayPath(result.path))}`);
|
||||
},
|
||||
);
|
||||
|
||||
fsCommand
|
||||
.command('mkdir <path>')
|
||||
.description('Create a VFS directory')
|
||||
.option('-A, --agent-id <id>', 'Agent ID')
|
||||
.option('-s, --slug <slug>', 'Agent slug')
|
||||
.option('-p, --parents', 'Create parent directories as needed')
|
||||
.action(
|
||||
async (
|
||||
inputPath: string,
|
||||
options: { agentId?: string; parents?: boolean; slug?: string; topicId?: string },
|
||||
) => {
|
||||
const client = await getTrpcClient();
|
||||
const context = await resolveAgentFsContext(client, options);
|
||||
const result = await mkdirFsPath(client, context, inputPath, {
|
||||
recursive: options.parents,
|
||||
});
|
||||
console.log(
|
||||
`${pc.green('✓')} created ${pc.bold(toDisplayPath(result?.path ?? inputPath))}`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
fsCommand
|
||||
.command('rm <path>')
|
||||
.description('Delete a VFS node into trash')
|
||||
.option('-A, --agent-id <id>', 'Agent ID')
|
||||
.option('-s, --slug <slug>', 'Agent slug')
|
||||
.option('-r, --recursive', 'Recursively delete a directory subtree')
|
||||
.option('-f, --force', 'Forward force semantics to the VFS delete primitive')
|
||||
.option('--yes', 'Skip confirmation prompt')
|
||||
.action(
|
||||
async (
|
||||
inputPath: string,
|
||||
options: {
|
||||
agentId?: string;
|
||||
force?: boolean;
|
||||
recursive?: boolean;
|
||||
slug?: string;
|
||||
topicId?: string;
|
||||
yes?: boolean;
|
||||
},
|
||||
) => {
|
||||
if (!options.yes) {
|
||||
const confirmed = await confirm(`Delete ${inputPath}?`);
|
||||
if (!confirmed) {
|
||||
console.log('Cancelled.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const client = await getTrpcClient();
|
||||
const context = await resolveAgentFsContext(client, options);
|
||||
await deleteFsPath(client, context, inputPath, {
|
||||
force: options.force,
|
||||
recursive: options.recursive,
|
||||
});
|
||||
console.log(`${pc.green('✓')} deleted ${pc.bold(inputPath)}`);
|
||||
},
|
||||
);
|
||||
|
||||
fsCommand
|
||||
.command('cp <source> <destination>')
|
||||
.description('Copy a VFS node')
|
||||
.option('-A, --agent-id <id>', 'Agent ID')
|
||||
.option('-s, --slug <slug>', 'Agent slug')
|
||||
.option('-f, --force', 'Overwrite the destination if it exists')
|
||||
.action(
|
||||
async (
|
||||
source: string,
|
||||
destination: string,
|
||||
options: { agentId?: string; force?: boolean; slug?: string; topicId?: string },
|
||||
) => {
|
||||
const client = await getTrpcClient();
|
||||
const context = await resolveAgentFsContext(client, options);
|
||||
const result = await copyFsPath(client, context, source, destination, options.force);
|
||||
console.log(
|
||||
`${pc.green('✓')} copied ${pc.bold(source)} → ${pc.bold(toDisplayPath(result?.path ?? destination))}`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
fsCommand
|
||||
.command('mv <source> <destination>')
|
||||
.description('Move or rename a VFS node')
|
||||
.option('-A, --agent-id <id>', 'Agent ID')
|
||||
.option('-s, --slug <slug>', 'Agent slug')
|
||||
.option('-f, --force', 'Overwrite the destination if it exists')
|
||||
.action(
|
||||
async (
|
||||
source: string,
|
||||
destination: string,
|
||||
options: { agentId?: string; force?: boolean; slug?: string; topicId?: string },
|
||||
) => {
|
||||
const client = await getTrpcClient();
|
||||
const context = await resolveAgentFsContext(client, options);
|
||||
const sourceResolved = resolveAgentFsPath(source);
|
||||
const destinationResolved = resolveAgentFsPath(destination);
|
||||
|
||||
if (sourceResolved.path === destinationResolved.path) {
|
||||
console.log(`${pc.yellow('!')} source and destination are the same.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await renameFsPath(client, context, source, destination, options.force);
|
||||
console.log(
|
||||
`${pc.green('✓')} moved ${pc.bold(source)} → ${pc.bold(toDisplayPath(result?.path ?? destination))}`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const trashCommand = fsCommand.command('trash').description('Operate on soft-deleted VFS nodes');
|
||||
|
||||
trashCommand
|
||||
.command('ls [path]')
|
||||
.description('List trashed VFS nodes')
|
||||
.option('-A, --agent-id <id>', 'Agent ID')
|
||||
.option('-s, --slug <slug>', 'Agent slug')
|
||||
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
|
||||
.action(
|
||||
async (
|
||||
inputPath: string | undefined,
|
||||
options: {
|
||||
agentId?: string;
|
||||
json?: string | boolean;
|
||||
slug?: string;
|
||||
topicId?: string;
|
||||
},
|
||||
) => {
|
||||
const client = await getTrpcClient();
|
||||
const context = await resolveAgentFsContext(client, options);
|
||||
const nodes = await listTrashFsPath(client, context, inputPath);
|
||||
|
||||
if (options.json !== undefined) {
|
||||
const fields = typeof options.json === 'string' ? options.json : undefined;
|
||||
outputJson(nodes, fields);
|
||||
return;
|
||||
}
|
||||
|
||||
if (nodes.length === 0) {
|
||||
console.log('Trash is empty.');
|
||||
return;
|
||||
}
|
||||
|
||||
nodes.forEach((node) => console.log(toDisplayPath(node.path)));
|
||||
},
|
||||
);
|
||||
|
||||
trashCommand
|
||||
.command('restore <path>')
|
||||
.description('Restore a soft-deleted VFS node')
|
||||
.option('-A, --agent-id <id>', 'Agent ID')
|
||||
.option('-s, --slug <slug>', 'Agent slug')
|
||||
.action(
|
||||
async (inputPath: string, options: { agentId?: string; slug?: string; topicId?: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
const context = await resolveAgentFsContext(client, options);
|
||||
const result = await restoreTrashFsPath(client, context, inputPath);
|
||||
console.log(
|
||||
`${pc.green('✓')} restored ${pc.bold(toDisplayPath(result?.path ?? inputPath))}`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
trashCommand
|
||||
.command('rm <path>')
|
||||
.description('Permanently delete a trashed VFS node')
|
||||
.option('-A, --agent-id <id>', 'Agent ID')
|
||||
.option('-s, --slug <slug>', 'Agent slug')
|
||||
.option('-r, --recursive', 'Recursively delete a directory subtree')
|
||||
.option('-f, --force', 'Forward force semantics to the permanent delete primitive')
|
||||
.option('--yes', 'Skip confirmation prompt')
|
||||
.action(
|
||||
async (
|
||||
inputPath: string,
|
||||
options: {
|
||||
agentId?: string;
|
||||
force?: boolean;
|
||||
recursive?: boolean;
|
||||
slug?: string;
|
||||
topicId?: string;
|
||||
yes?: boolean;
|
||||
},
|
||||
) => {
|
||||
if (!options.yes) {
|
||||
const confirmed = await confirm(`Permanently delete ${inputPath}?`);
|
||||
if (!confirmed) {
|
||||
console.log('Cancelled.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const client = await getTrpcClient();
|
||||
const context = await resolveAgentFsContext(client, options);
|
||||
await deleteTrashFsPath(client, context, inputPath, {
|
||||
force: options.force,
|
||||
recursive: options.recursive,
|
||||
});
|
||||
console.log(`${pc.green('✓')} permanently deleted ${pc.bold(inputPath)}`);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register agent document VFS commands under `agent space fs`.
|
||||
*
|
||||
* Use when:
|
||||
* - The CLI should expose filesystem-like operations for an agent document space.
|
||||
* - Command registration should stay outside the core `agent` command file.
|
||||
*
|
||||
* Expects:
|
||||
* - `agentCommand` to be the existing `agent` command group.
|
||||
*
|
||||
* Returns:
|
||||
* - Registered Commander subcommands.
|
||||
*/
|
||||
export function registerAgentSpaceFsCommand(agentCommand: Command) {
|
||||
const spaceCommand = agentCommand.command('space').description('Manage agent document space');
|
||||
const fsCommand = spaceCommand.command('fs').description('Operate on the agent document VFS');
|
||||
registerFsCommands(fsCommand);
|
||||
}
|
||||
@@ -7,54 +7,7 @@ import { confirm, outputJson, printBoxTable, printTable, timeAgo } from '../util
|
||||
import { log } from '../utils/logger';
|
||||
import { registerBotMessageCommands } from './botMessage';
|
||||
|
||||
// ── Access policy helpers ──────────────────────────────
|
||||
|
||||
const DM_POLICIES = ['open', 'allowlist', 'pairing', 'disabled'] as const;
|
||||
const GROUP_POLICIES = ['open', 'allowlist', 'disabled'] as const;
|
||||
type DmPolicy = (typeof DM_POLICIES)[number];
|
||||
type GroupPolicy = (typeof GROUP_POLICIES)[number];
|
||||
|
||||
interface AllowEntry {
|
||||
id: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize an allow-list value into `{id, name?}[]`. Mirrors the server-side
|
||||
* back-compat parser — `settings.allowFrom` may be on disk as a comma-separated
|
||||
* string, a bare `string[]`, or the current `{id, name?}[]` shape. The CLI
|
||||
* needs the canonical form before push/filter operations and before sending
|
||||
* back to the server.
|
||||
*/
|
||||
function normalizeAllowList(raw: unknown): AllowEntry[] {
|
||||
if (typeof raw === 'string') {
|
||||
return raw
|
||||
.split(/[\s,]+/)
|
||||
.map((id) => id.trim())
|
||||
.filter(Boolean)
|
||||
.map((id) => ({ id }));
|
||||
}
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const out: AllowEntry[] = [];
|
||||
for (const entry of raw) {
|
||||
if (typeof entry === 'string') {
|
||||
const id = entry.trim();
|
||||
if (id) out.push({ id });
|
||||
continue;
|
||||
}
|
||||
if (entry && typeof entry === 'object' && 'id' in entry) {
|
||||
const id = (entry as { id?: unknown }).id;
|
||||
if (typeof id !== 'string' || !id.trim()) continue;
|
||||
const name = (entry as { name?: unknown }).name;
|
||||
out.push(
|
||||
typeof name === 'string' && name.trim()
|
||||
? { id: id.trim(), name: name.trim() }
|
||||
: { id: id.trim() },
|
||||
);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
// ── Helpers ──────────────────────────────────────────────
|
||||
|
||||
function maskValue(val: string): string {
|
||||
if (val.length > 8) return val.slice(0, 4) + '****' + val.slice(-4);
|
||||
@@ -125,150 +78,6 @@ async function resolvePlatform(client: TrpcClient, platformId: string) {
|
||||
return def;
|
||||
}
|
||||
|
||||
// ── Allowlist subcommand factory ────────────────────────
|
||||
|
||||
interface AllowlistGroupOptions {
|
||||
/** Description shown by `lh bot <name> --help`. */
|
||||
description: string;
|
||||
/** Settings field to mutate — `allowFrom` (user IDs) or `groupAllowFrom` (channel IDs). */
|
||||
fieldKey: 'allowFrom' | 'groupAllowFrom';
|
||||
/** Human-friendly description of what the `<id>` arg represents. */
|
||||
idLabel: string;
|
||||
/** Subcommand group name (`allowlist` or `group-allowlist`). */
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `list / add / remove / clear` subcommand group around an
|
||||
* array-typed settings field (`allowFrom` or `groupAllowFrom`). All write
|
||||
* paths read existing settings first and merge — passing only a partial
|
||||
* `settings` object to the TRPC `update` would replace the whole JSONB
|
||||
* column and silently drop unrelated fields.
|
||||
*/
|
||||
function registerAllowlistCommand(bot: Command, opts: AllowlistGroupOptions) {
|
||||
const group = bot.command(opts.name).description(opts.description);
|
||||
|
||||
// Read the current entries off a freshly-fetched bot row.
|
||||
const readEntries = (bot: any): AllowEntry[] =>
|
||||
normalizeAllowList((bot.settings as Record<string, unknown> | null)?.[opts.fieldKey]);
|
||||
|
||||
// Build the next settings payload from existing settings + the new entries.
|
||||
const buildPayload = (bot: any, nextEntries: AllowEntry[]) => ({
|
||||
id: bot.id,
|
||||
settings: {
|
||||
...(bot.settings as Record<string, unknown>),
|
||||
[opts.fieldKey]: nextEntries,
|
||||
},
|
||||
});
|
||||
|
||||
group
|
||||
.command('list <botId>')
|
||||
.description(`List ${opts.fieldKey} entries`)
|
||||
.option('--json', 'Output JSON')
|
||||
.action(async (botId: string, options: { json?: boolean }) => {
|
||||
const client = await getTrpcClient();
|
||||
const b = await findBot(client, botId);
|
||||
const entries = readEntries(b);
|
||||
|
||||
if (options.json) {
|
||||
outputJson(entries);
|
||||
return;
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
console.log(`${pc.dim(`No ${opts.fieldKey} entries.`)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
printTable(
|
||||
entries.map((e) => [e.id, e.name ?? pc.dim('-')]),
|
||||
['ID', 'NAME'],
|
||||
);
|
||||
});
|
||||
|
||||
group
|
||||
.command('add <botId> <id>')
|
||||
.description(`Add a ${opts.idLabel} to ${opts.fieldKey}`)
|
||||
.option('--name <name>', 'Optional human-friendly label so you can recognise the entry later')
|
||||
.action(async (botId: string, id: string, options: { name?: string }) => {
|
||||
const trimmedId = id.trim();
|
||||
if (!trimmedId) {
|
||||
log.error('ID cannot be empty.');
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const client = await getTrpcClient();
|
||||
const b = await findBot(client, botId);
|
||||
const entries = readEntries(b);
|
||||
|
||||
if (entries.some((e) => e.id === trimmedId)) {
|
||||
log.info(`${trimmedId} is already on the ${opts.fieldKey} list — nothing to do.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmedName = options.name?.trim();
|
||||
const next = [
|
||||
...entries,
|
||||
trimmedName ? { id: trimmedId, name: trimmedName } : { id: trimmedId },
|
||||
];
|
||||
|
||||
await client.agentBotProvider.update.mutate(buildPayload(b, next) as any);
|
||||
console.log(
|
||||
`${pc.green('✓')} Added ${pc.bold(trimmedId)}${trimmedName ? ` (${trimmedName})` : ''} to ${opts.fieldKey} (now ${next.length} entr${next.length === 1 ? 'y' : 'ies'})`,
|
||||
);
|
||||
});
|
||||
|
||||
group
|
||||
.command('remove <botId> <id>')
|
||||
.description(`Remove a ${opts.idLabel} from ${opts.fieldKey}`)
|
||||
.action(async (botId: string, id: string) => {
|
||||
const trimmedId = id.trim();
|
||||
const client = await getTrpcClient();
|
||||
const b = await findBot(client, botId);
|
||||
const entries = readEntries(b);
|
||||
const next = entries.filter((e) => e.id !== trimmedId);
|
||||
|
||||
if (next.length === entries.length) {
|
||||
log.info(`${trimmedId} is not on the ${opts.fieldKey} list — nothing to do.`);
|
||||
return;
|
||||
}
|
||||
|
||||
await client.agentBotProvider.update.mutate(buildPayload(b, next) as any);
|
||||
console.log(
|
||||
`${pc.green('✓')} Removed ${pc.bold(trimmedId)} from ${opts.fieldKey} (${next.length} entr${next.length === 1 ? 'y' : 'ies'} left)`,
|
||||
);
|
||||
});
|
||||
|
||||
group
|
||||
.command('clear <botId>')
|
||||
.description(`Clear all entries from ${opts.fieldKey}`)
|
||||
.option('--yes', 'Skip confirmation prompt')
|
||||
.action(async (botId: string, options: { yes?: boolean }) => {
|
||||
const client = await getTrpcClient();
|
||||
const b = await findBot(client, botId);
|
||||
const entries = readEntries(b);
|
||||
|
||||
if (entries.length === 0) {
|
||||
log.info(`${opts.fieldKey} is already empty — nothing to do.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!options.yes) {
|
||||
const confirmed = await confirm(
|
||||
`Clear all ${entries.length} ${opts.fieldKey} entr${entries.length === 1 ? 'y' : 'ies'} from this bot?`,
|
||||
);
|
||||
if (!confirmed) {
|
||||
console.log('Cancelled.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await client.agentBotProvider.update.mutate(buildPayload(b, []) as any);
|
||||
console.log(`${pc.green('✓')} Cleared ${opts.fieldKey} on bot ${pc.bold(botId)}`);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Command Registration ─────────────────────────────────
|
||||
|
||||
export function registerBotCommand(program: Command) {
|
||||
@@ -504,16 +313,6 @@ export function registerBotCommand(program: Command) {
|
||||
.option('--verification-token <token>', 'New verification token')
|
||||
.option('--app-id <appId>', 'New application ID')
|
||||
.option('--platform <platform>', 'New platform')
|
||||
.option(
|
||||
'--dm-policy <policy>',
|
||||
`DM access policy (${DM_POLICIES.join('|')}). 'pairing' requires --user-id.`,
|
||||
)
|
||||
.option('--group-policy <policy>', `Group/channel access policy (${GROUP_POLICIES.join('|')})`)
|
||||
.option(
|
||||
'--user-id <id>',
|
||||
"Owner's platform user ID (required for --dm-policy=pairing; auto-trusts the operator in the global allowlist)",
|
||||
)
|
||||
.option('--server-id <id>', 'Default server / guild / workspace ID for AI tool calls')
|
||||
.action(
|
||||
async (
|
||||
botId: string,
|
||||
@@ -522,15 +321,11 @@ export function registerBotCommand(program: Command) {
|
||||
appSecret?: string;
|
||||
botId?: string;
|
||||
botToken?: string;
|
||||
dmPolicy?: string;
|
||||
encryptKey?: string;
|
||||
groupPolicy?: string;
|
||||
platform?: string;
|
||||
publicKey?: string;
|
||||
secretToken?: string;
|
||||
serverId?: string;
|
||||
signingSecret?: string;
|
||||
userId?: string;
|
||||
verificationToken?: string;
|
||||
webhookProxyUrl?: string;
|
||||
},
|
||||
@@ -547,40 +342,6 @@ export function registerBotCommand(program: Command) {
|
||||
if (options.appId) input.applicationId = options.appId;
|
||||
if (options.platform) input.platform = options.platform;
|
||||
|
||||
// ── Settings (DM / group policy + identity fields) ────────────
|
||||
// Read-modify-write so we don't wipe `allowFrom`, `groupAllowFrom`,
|
||||
// or any other settings field the operator already configured.
|
||||
const settingsPatch: Record<string, unknown> = {};
|
||||
if (options.dmPolicy !== undefined) {
|
||||
if (!(DM_POLICIES as readonly string[]).includes(options.dmPolicy)) {
|
||||
log.error(
|
||||
`Invalid --dm-policy "${options.dmPolicy}". Must be one of: ${DM_POLICIES.join(', ')}`,
|
||||
);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
settingsPatch.dmPolicy = options.dmPolicy as DmPolicy;
|
||||
}
|
||||
if (options.groupPolicy !== undefined) {
|
||||
if (!(GROUP_POLICIES as readonly string[]).includes(options.groupPolicy)) {
|
||||
log.error(
|
||||
`Invalid --group-policy "${options.groupPolicy}". Must be one of: ${GROUP_POLICIES.join(', ')}`,
|
||||
);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
settingsPatch.groupPolicy = options.groupPolicy as GroupPolicy;
|
||||
}
|
||||
if (options.userId !== undefined) settingsPatch.userId = options.userId;
|
||||
if (options.serverId !== undefined) settingsPatch.serverId = options.serverId;
|
||||
|
||||
if (Object.keys(settingsPatch).length > 0) {
|
||||
input.settings = {
|
||||
...(existing.settings as Record<string, unknown>),
|
||||
...settingsPatch,
|
||||
};
|
||||
}
|
||||
|
||||
if (Object.keys(input).length <= 1) {
|
||||
log.error('No changes specified.');
|
||||
process.exit(1);
|
||||
@@ -592,22 +353,6 @@ export function registerBotCommand(program: Command) {
|
||||
},
|
||||
);
|
||||
|
||||
// ── allowlist (DM / group user gate) ──────────────────
|
||||
|
||||
registerAllowlistCommand(bot, {
|
||||
description: 'Manage the global user allowlist (gates DMs and group @mentions)',
|
||||
fieldKey: 'allowFrom',
|
||||
idLabel: 'platform user ID',
|
||||
name: 'allowlist',
|
||||
});
|
||||
|
||||
registerAllowlistCommand(bot, {
|
||||
description: 'Manage the group/channel allowlist (used when groupPolicy=allowlist)',
|
||||
fieldKey: 'groupAllowFrom',
|
||||
idLabel: 'channel / group / thread ID',
|
||||
name: 'group-allowlist',
|
||||
});
|
||||
|
||||
// ── remove ────────────────────────────────────────────
|
||||
|
||||
bot
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import { Command } from 'commander';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../auth/refresh', () => ({
|
||||
getValidToken: vi.fn().mockResolvedValue({
|
||||
credentials: { accessToken: 'test-token', expiresAt: undefined, refreshToken: 'test-refresh' },
|
||||
}),
|
||||
}));
|
||||
vi.mock('../auth/resolveToken', () => ({
|
||||
resolveToken: vi.fn().mockResolvedValue({
|
||||
serverUrl: 'https://app.lobehub.com',
|
||||
@@ -88,25 +83,20 @@ vi.mock('@lobechat/device-gateway-client', () => ({
|
||||
on: vi.fn().mockImplementation((event: string, handler: (...args: any[]) => any) => {
|
||||
clientEventHandlers[event] = handler;
|
||||
}),
|
||||
reconnect: vi.fn().mockResolvedValue(undefined),
|
||||
sendSystemInfoResponse: vi.fn().mockImplementation((data: any) => {
|
||||
lastSentSystemInfoResponse = data;
|
||||
}),
|
||||
sendToolCallResponse: vi.fn().mockImplementation((data: any) => {
|
||||
lastSentToolResponse = data;
|
||||
}),
|
||||
updateToken: vi.fn(),
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line import-x/first
|
||||
import { GatewayClient } from '@lobechat/device-gateway-client';
|
||||
|
||||
// eslint-disable-next-line import-x/first
|
||||
import { resolveToken } from '../auth/resolveToken';
|
||||
// eslint-disable-next-line import-x/first
|
||||
import { removeStatus, spawnDaemon, stopDaemon, writeStatus } from '../daemon/manager';
|
||||
import { spawnDaemon, stopDaemon } from '../daemon/manager';
|
||||
// eslint-disable-next-line import-x/first
|
||||
import { loadSettings, saveSettings } from '../settings';
|
||||
// eslint-disable-next-line import-x/first
|
||||
@@ -140,36 +130,6 @@ describe('connect command', () => {
|
||||
return program;
|
||||
}
|
||||
|
||||
it('should persist deviceId in status for foreground connections', async () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'connect']);
|
||||
|
||||
expect(writeStatus).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ connectionStatus: 'connecting', deviceId: 'mock-device-id' }),
|
||||
);
|
||||
|
||||
clientEventHandlers.connected?.();
|
||||
|
||||
expect(writeStatus).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ connectionStatus: 'connected', deviceId: 'mock-device-id' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should persist deviceId in status for daemon child connections', async () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'connect', '--daemon-child']);
|
||||
|
||||
expect(writeStatus).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ connectionStatus: 'connecting', deviceId: 'mock-device-id' }),
|
||||
);
|
||||
|
||||
clientEventHandlers.connected?.();
|
||||
|
||||
expect(writeStatus).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ connectionStatus: 'connected', deviceId: 'mock-device-id' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should connect to gateway', async () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'connect']);
|
||||
@@ -252,33 +212,13 @@ describe('connect command', () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'connect']);
|
||||
|
||||
await clientEventHandlers['auth_failed']?.('invalid token');
|
||||
clientEventHandlers['auth_failed']?.('invalid token');
|
||||
|
||||
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('Authentication failed'));
|
||||
expect(cleanupAllProcesses).toHaveBeenCalled();
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should retry auth_failed with token refresh when new token available', async () => {
|
||||
vi.mocked(resolveToken).mockResolvedValueOnce({
|
||||
serverUrl: 'https://app.lobehub.com',
|
||||
token: 'refreshed-token',
|
||||
tokenType: 'jwt',
|
||||
userId: 'test-user',
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'connect']);
|
||||
|
||||
const mockClient = vi.mocked(GatewayClient).mock.results[0].value;
|
||||
|
||||
await clientEventHandlers['auth_failed']?.('token expired');
|
||||
|
||||
expect(log.info).toHaveBeenCalledWith(expect.stringContaining('Token refreshed'));
|
||||
expect(mockClient.updateToken).toHaveBeenCalledWith('refreshed-token');
|
||||
expect(exitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle auth_expired', async () => {
|
||||
vi.mocked(resolveToken).mockResolvedValueOnce({
|
||||
serverUrl: 'https://app.lobehub.com',
|
||||
@@ -348,7 +288,6 @@ describe('connect command', () => {
|
||||
}
|
||||
|
||||
expect(cleanupAllProcesses).toHaveBeenCalled();
|
||||
expect(removeStatus).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle auth_expired when refresh fails', async () => {
|
||||
|
||||
@@ -10,7 +10,6 @@ import type {
|
||||
import { GatewayClient } from '@lobechat/device-gateway-client';
|
||||
import type { Command } from 'commander';
|
||||
|
||||
import { getValidToken } from '../auth/refresh';
|
||||
import { resolveToken } from '../auth/resolveToken';
|
||||
import { CLI_API_KEY_ENV } from '../constants/auth';
|
||||
import { OFFICIAL_GATEWAY_URL } from '../constants/urls';
|
||||
@@ -222,15 +221,16 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
|
||||
info(` Mode : ${isDaemonChild ? 'daemon' : 'foreground'}`);
|
||||
info('───────────────────');
|
||||
|
||||
// Update local connection status so other CLI commands can resolve the current device
|
||||
// Update status file for daemon mode
|
||||
const updateStatus = (connectionStatus: string) => {
|
||||
writeStatus({
|
||||
connectionStatus,
|
||||
deviceId: client.currentDeviceId,
|
||||
gatewayUrl: resolvedGatewayUrl,
|
||||
pid: process.pid,
|
||||
startedAt: startedAt.toISOString(),
|
||||
});
|
||||
if (isDaemonChild) {
|
||||
writeStatus({
|
||||
connectionStatus,
|
||||
gatewayUrl: resolvedGatewayUrl,
|
||||
pid: process.pid,
|
||||
startedAt: startedAt.toISOString(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const startedAt = new Date();
|
||||
@@ -285,44 +285,8 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
|
||||
updateStatus('reconnecting');
|
||||
});
|
||||
|
||||
// Proactive token refresh — schedule before JWT expires
|
||||
const startProactiveRefresh = () =>
|
||||
scheduleProactiveRefresh(
|
||||
auth,
|
||||
(refreshed) => {
|
||||
client.updateToken(refreshed.token);
|
||||
auth = refreshed;
|
||||
// Schedule next refresh based on the new token
|
||||
cancelRefreshTimer = startProactiveRefresh();
|
||||
},
|
||||
info,
|
||||
error,
|
||||
);
|
||||
let cancelRefreshTimer = startProactiveRefresh();
|
||||
|
||||
// Handle auth failed — attempt token refresh once before giving up
|
||||
// (e.g., auto-reconnect may send an expired JWT before proactive refresh fires)
|
||||
let authFailedRefreshAttempted = false;
|
||||
client.on('auth_failed', async (reason) => {
|
||||
if (auth.tokenType === 'jwt' && !authFailedRefreshAttempted) {
|
||||
authFailedRefreshAttempted = true;
|
||||
info(`Authentication failed (${reason}). Attempting token refresh...`);
|
||||
try {
|
||||
const refreshed = await resolveToken({});
|
||||
if (refreshed && refreshed.token !== auth.token) {
|
||||
info('Token refreshed successfully. Reconnecting...');
|
||||
client.updateToken(refreshed.token);
|
||||
auth = refreshed;
|
||||
authFailedRefreshAttempted = false;
|
||||
cancelRefreshTimer = startProactiveRefresh();
|
||||
await client.reconnect();
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
|
||||
// Handle auth failed
|
||||
client.on('auth_failed', (reason) => {
|
||||
error(`Authentication failed: ${reason}`);
|
||||
error(
|
||||
`Run 'lh login', or set ${CLI_API_KEY_ENV} and run 'lh login --server <url>' to configure API key authentication.`,
|
||||
@@ -345,8 +309,8 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
|
||||
if (refreshed) {
|
||||
info('Token refreshed successfully. Reconnecting...');
|
||||
client.updateToken(refreshed.token);
|
||||
// Update cached auth so subsequent refreshes use the latest token
|
||||
auth = refreshed;
|
||||
cancelRefreshTimer = startProactiveRefresh();
|
||||
await client.reconnect();
|
||||
return;
|
||||
}
|
||||
@@ -367,11 +331,10 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
|
||||
// Graceful shutdown
|
||||
const cleanup = () => {
|
||||
info('Shutting down...');
|
||||
cancelRefreshTimer?.();
|
||||
cleanupAllProcesses();
|
||||
client.disconnect();
|
||||
removeStatus();
|
||||
if (isDaemonChild) {
|
||||
removeStatus();
|
||||
removePid();
|
||||
}
|
||||
};
|
||||
@@ -412,69 +375,6 @@ function formatUptime(startedAt: Date): string {
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
// How far before expiry to proactively refresh (1 hour)
|
||||
const PROACTIVE_REFRESH_BUFFER = 60 * 60;
|
||||
|
||||
/**
|
||||
* Parse the `exp` claim from a JWT without verifying the signature.
|
||||
*/
|
||||
function parseJwtExp(token: string): number | undefined {
|
||||
try {
|
||||
const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString());
|
||||
return typeof payload.exp === 'number' ? payload.exp : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a proactive token refresh before the JWT expires.
|
||||
* Returns a cleanup function that cancels the scheduled timer.
|
||||
*/
|
||||
function scheduleProactiveRefresh(
|
||||
auth: { token: string; tokenType: string },
|
||||
onRefreshed: (newAuth: Awaited<ReturnType<typeof resolveToken>>) => void,
|
||||
info: (msg: string) => void,
|
||||
error: (msg: string) => void,
|
||||
): (() => void) | null {
|
||||
if (auth.tokenType !== 'jwt') return null;
|
||||
|
||||
const exp = parseJwtExp(auth.token);
|
||||
if (!exp) return null;
|
||||
|
||||
const refreshAt = (exp - PROACTIVE_REFRESH_BUFFER) * 1000;
|
||||
const delay = refreshAt - Date.now();
|
||||
|
||||
if (delay < 0) {
|
||||
// Already past the refresh window — refresh immediately on next tick
|
||||
void doRefresh();
|
||||
return null;
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => void doRefresh(), delay);
|
||||
return () => clearTimeout(timer);
|
||||
|
||||
async function doRefresh() {
|
||||
try {
|
||||
// Use the same buffer so getValidToken actually triggers a refresh
|
||||
const result = await getValidToken(PROACTIVE_REFRESH_BUFFER);
|
||||
if (!result) {
|
||||
error('Proactive token refresh failed — no valid credentials.');
|
||||
return;
|
||||
}
|
||||
|
||||
const refreshed = await resolveToken({});
|
||||
// Only notify if the token actually changed to avoid reschedule loops
|
||||
if (refreshed.token !== auth.token) {
|
||||
info('Proactively refreshed token.');
|
||||
onRefreshed(refreshed);
|
||||
}
|
||||
} catch {
|
||||
error('Proactive token refresh failed.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function collectSystemInfo(): DeviceSystemInfo {
|
||||
const home = os.homedir();
|
||||
const platform = process.platform;
|
||||
|
||||
@@ -111,7 +111,7 @@ describe('cron command', () => {
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agentCronJob.create.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ agentId: 'a1', cronPattern: '* * * * *', name: 'My Job' }),
|
||||
expect.objectContaining({ agentId: 'a1', name: 'My Job', schedule: '* * * * *' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -125,10 +125,10 @@ export function registerCronCommand(program: Command) {
|
||||
|
||||
const input: Record<string, any> = {
|
||||
agentId: options.agentId,
|
||||
cronPattern: options.schedule,
|
||||
schedule: options.schedule,
|
||||
};
|
||||
if (options.name) input.name = options.name;
|
||||
if (options.prompt) input.content = options.prompt;
|
||||
if (options.prompt) input.prompt = options.prompt;
|
||||
if (options.maxExecutions) input.maxExecutions = Number.parseInt(options.maxExecutions, 10);
|
||||
|
||||
const result = await client.agentCronJob.create.mutate(input as any);
|
||||
@@ -168,8 +168,8 @@ export function registerCronCommand(program: Command) {
|
||||
) => {
|
||||
const data: Record<string, any> = {};
|
||||
if (options.name) data.name = options.name;
|
||||
if (options.schedule) data.cronPattern = options.schedule;
|
||||
if (options.prompt) data.content = options.prompt;
|
||||
if (options.schedule) data.schedule = options.schedule;
|
||||
if (options.prompt) data.prompt = options.prompt;
|
||||
if (options.maxExecutions) data.maxExecutions = Number.parseInt(options.maxExecutions, 10);
|
||||
if (options.enable) data.enabled = true;
|
||||
if (options.disable) data.enabled = false;
|
||||
|
||||
@@ -61,6 +61,7 @@ describe('generate command', () => {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Oidc-Auth': 'test-token',
|
||||
'X-lobe-chat-auth': 'test-xor-token',
|
||||
},
|
||||
serverUrl: 'https://app.lobehub.com',
|
||||
});
|
||||
@@ -270,48 +271,6 @@ describe('generate command', () => {
|
||||
);
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Video generation started'));
|
||||
});
|
||||
|
||||
it('should pass image-to-video params', async () => {
|
||||
mockTrpcClient.generationTopic.createTopic.mutate.mockResolvedValue('topic-3');
|
||||
mockTrpcClient.video.createVideo.mutate.mockResolvedValue({
|
||||
data: { generationId: 'gen-v2' },
|
||||
success: true,
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'generate',
|
||||
'video',
|
||||
'a cat waving',
|
||||
'--model',
|
||||
'cogvideox',
|
||||
'--provider',
|
||||
'zhipu',
|
||||
'--image',
|
||||
'https://example.com/first.png',
|
||||
'--end-image',
|
||||
'https://example.com/last.png',
|
||||
'--images',
|
||||
'https://example.com/a.png',
|
||||
'https://example.com/b.png',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.video.createVideo.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
generationTopicId: 'topic-3',
|
||||
model: 'cogvideox',
|
||||
params: {
|
||||
endImageUrl: 'https://example.com/last.png',
|
||||
imageUrl: 'https://example.com/first.png',
|
||||
imageUrls: ['https://example.com/a.png', 'https://example.com/b.png'],
|
||||
prompt: 'a cat waving',
|
||||
},
|
||||
provider: 'zhipu',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tts', () => {
|
||||
|
||||
@@ -9,61 +9,6 @@ import { registerTextCommand } from './text';
|
||||
import { registerTtsCommand } from './tts';
|
||||
import { registerVideoCommand } from './video';
|
||||
|
||||
/**
|
||||
* Parse a tRPC/server error and return a user-friendly message for gen status/download.
|
||||
*
|
||||
* getGenerationStatus throws NOT_FOUND in two distinct cases:
|
||||
* 1. "Async task not found" → asyncTaskId is wrong (user passed gen_xxx instead of UUID)
|
||||
* 2. "Generation not found" → generationId is wrong
|
||||
*
|
||||
* INTERNAL_SERVER_ERROR with a message mentioning "async_tasks" also indicates a bad asyncTaskId
|
||||
* (e.g. the server SQL query fails when a non-UUID is passed).
|
||||
*/
|
||||
function parseGenStatusError(
|
||||
err: any,
|
||||
generationId: string,
|
||||
asyncTaskId: string,
|
||||
command: 'status' | 'download',
|
||||
): string | null {
|
||||
const code = err?.data?.code || err?.shape?.data?.code;
|
||||
const message: string = err?.message || err?.shape?.message || '';
|
||||
|
||||
const isAsyncTaskNotFound =
|
||||
(code === 'NOT_FOUND' && message.includes('Async task not found')) ||
|
||||
(code === 'INTERNAL_SERVER_ERROR' && message.includes('async_tasks'));
|
||||
|
||||
const isGenerationNotFound = code === 'NOT_FOUND' && message.includes('Generation not found');
|
||||
|
||||
if (isAsyncTaskNotFound) {
|
||||
return (
|
||||
`${pc.red('✗')} Async task not found: ${pc.bold(asyncTaskId)}\n` +
|
||||
`\n` +
|
||||
` The second argument must be the ${pc.bold('asyncTaskId')} — the UUID printed after\n` +
|
||||
` "→ Task" in the video/image output, not the generation ID (gen_xxx).\n` +
|
||||
`\n` +
|
||||
` Example output from "lh gen video":\n` +
|
||||
` Generation ${pc.bold('gen_abc123')} → Task ${pc.dim('7ad0eb13-e9a5-4403-8070-1f7fe95b2f95')}\n` +
|
||||
`\n` +
|
||||
` Correct usage:\n` +
|
||||
` ${pc.cyan(`lh gen ${command} gen_abc123 7ad0eb13-e9a5-4403-8070-1f7fe95b2f95`)}`
|
||||
);
|
||||
}
|
||||
|
||||
if (isGenerationNotFound) {
|
||||
return (
|
||||
`${pc.red('✗')} Generation not found: ${pc.bold(generationId)}\n` +
|
||||
`\n` +
|
||||
` The first argument must be the ${pc.bold('generationId')} (gen_xxx) from the\n` +
|
||||
` video/image output.\n` +
|
||||
`\n` +
|
||||
` Correct usage:\n` +
|
||||
` ${pc.cyan(`lh gen ${command} <generationId> <asyncTaskId>`)}`
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function registerGenerateCommand(program: Command) {
|
||||
const generate = program
|
||||
.command('generate')
|
||||
@@ -78,26 +23,15 @@ export function registerGenerateCommand(program: Command) {
|
||||
|
||||
// ── status ──────────────────────────────────────────
|
||||
generate
|
||||
.command('status <generationId> <asyncTaskId>')
|
||||
.command('status <generationId> <taskId>')
|
||||
.description('Check generation task status')
|
||||
.option('--json', 'Output raw JSON')
|
||||
.action(async (generationId: string, asyncTaskId: string, options: { json?: boolean }) => {
|
||||
.action(async (generationId: string, taskId: string, options: { json?: boolean }) => {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
let result: any;
|
||||
try {
|
||||
result = await client.generation.getGenerationStatus.query({
|
||||
asyncTaskId,
|
||||
generationId,
|
||||
});
|
||||
} catch (err: any) {
|
||||
const msg = parseGenStatusError(err, generationId, asyncTaskId, 'status');
|
||||
if (msg) {
|
||||
console.error(msg);
|
||||
process.exit(1);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const result = await client.generation.getGenerationStatus.query({
|
||||
asyncTaskId: taskId,
|
||||
generationId,
|
||||
});
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
@@ -119,7 +53,7 @@ export function registerGenerateCommand(program: Command) {
|
||||
|
||||
// ── download ──────────────────────────────────────────
|
||||
generate
|
||||
.command('download <generationId> <asyncTaskId>')
|
||||
.command('download <generationId> <taskId>')
|
||||
.description('Wait for generation to complete and download the result')
|
||||
.option('-o, --output <path>', 'Output file path (default: auto-detect from asset)')
|
||||
.option('--interval <sec>', 'Polling interval in seconds', '5')
|
||||
@@ -127,7 +61,7 @@ export function registerGenerateCommand(program: Command) {
|
||||
.action(
|
||||
async (
|
||||
generationId: string,
|
||||
asyncTaskId: string,
|
||||
taskId: string,
|
||||
options: { interval?: string; output?: string; timeout?: string },
|
||||
) => {
|
||||
const client = await getTrpcClient();
|
||||
@@ -139,20 +73,10 @@ export function registerGenerateCommand(program: Command) {
|
||||
|
||||
// Poll for completion
|
||||
while (true) {
|
||||
let result: any;
|
||||
try {
|
||||
result = await client.generation.getGenerationStatus.query({
|
||||
asyncTaskId,
|
||||
generationId,
|
||||
});
|
||||
} catch (err: any) {
|
||||
const msg = parseGenStatusError(err, generationId, asyncTaskId, 'download');
|
||||
if (msg) {
|
||||
console.error(`\n${msg}`);
|
||||
process.exit(1);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const result = (await client.generation.getGenerationStatus.query({
|
||||
asyncTaskId: taskId,
|
||||
generationId,
|
||||
})) as any;
|
||||
|
||||
if (result.status === 'success' && result.generation) {
|
||||
const gen = result.generation;
|
||||
@@ -201,7 +125,7 @@ export function registerGenerateCommand(program: Command) {
|
||||
console.log(
|
||||
`${pc.red('✗')} Timed out after ${options.timeout}s. Task still ${result.status}.`,
|
||||
);
|
||||
console.log(pc.dim(`Run "lh gen status ${generationId} ${asyncTaskId}" to check later.`));
|
||||
console.log(pc.dim(`Run "lh gen status ${generationId} ${taskId}" to check later.`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,16 +6,13 @@ import { getTrpcClient } from '../../api/client';
|
||||
export function registerVideoCommand(parent: Command) {
|
||||
parent
|
||||
.command('video <prompt>')
|
||||
.description('Generate a video from text or image(s)')
|
||||
.description('Generate a video from text')
|
||||
.requiredOption('-m, --model <model>', 'Model ID')
|
||||
.requiredOption('-p, --provider <provider>', 'Provider name')
|
||||
.option('--aspect-ratio <ratio>', 'Aspect ratio (e.g. 16:9)')
|
||||
.option('--duration <sec>', 'Duration in seconds')
|
||||
.option('--resolution <res>', 'Resolution (e.g. 720p, 1080p)')
|
||||
.option('--seed <n>', 'Random seed')
|
||||
.option('--image <url>', 'First-frame image URL (image-to-video)')
|
||||
.option('--images <urls...>', 'Multiple reference image URLs')
|
||||
.option('--end-image <url>', 'Last-frame image URL')
|
||||
.option('--json', 'Output raw JSON')
|
||||
.action(
|
||||
async (
|
||||
@@ -23,9 +20,6 @@ export function registerVideoCommand(parent: Command) {
|
||||
options: {
|
||||
aspectRatio?: string;
|
||||
duration?: string;
|
||||
endImage?: string;
|
||||
image?: string;
|
||||
images?: string[];
|
||||
json?: boolean;
|
||||
model: string;
|
||||
provider: string;
|
||||
@@ -41,9 +35,6 @@ export function registerVideoCommand(parent: Command) {
|
||||
if (options.duration) params.duration = Number.parseInt(options.duration, 10);
|
||||
if (options.resolution) params.resolution = options.resolution;
|
||||
if (options.seed) params.seed = Number.parseInt(options.seed, 10);
|
||||
if (options.image) params.imageUrl = options.image;
|
||||
if (options.images && options.images.length > 0) params.imageUrls = options.images;
|
||||
if (options.endImage) params.endImageUrl = options.endImage;
|
||||
|
||||
const result = await client.video.createVideo.mutate({
|
||||
generationTopicId: topicId as string,
|
||||
|
||||
@@ -79,57 +79,6 @@ describe('message command', () => {
|
||||
);
|
||||
expect(mockTrpcClient.message.listAll.query).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should keep first page on the backend default offset for filtered queries', async () => {
|
||||
mockTrpcClient.message.getMessages.query.mockResolvedValue([]);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'message',
|
||||
'list',
|
||||
'--topic-id',
|
||||
't1',
|
||||
'-L',
|
||||
'200',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.message.getMessages.query).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ pageSize: 200, topicId: 't1' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should convert page 2 to current 1 for filtered queries', async () => {
|
||||
mockTrpcClient.message.getMessages.query.mockResolvedValue([]);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'message',
|
||||
'list',
|
||||
'--topic-id',
|
||||
't1',
|
||||
'--page',
|
||||
'2',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.message.getMessages.query).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ current: 1, topicId: 't1' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should support the short page flag for filtered queries', async () => {
|
||||
mockTrpcClient.message.getMessages.query.mockResolvedValue([]);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'message', 'list', '--topic-id', 't1', '-P', '2']);
|
||||
|
||||
expect(mockTrpcClient.message.getMessages.query).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ current: 1, topicId: 't1' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('search', () => {
|
||||
|
||||
@@ -16,7 +16,7 @@ export function registerMessageCommand(program: Command) {
|
||||
.option('--topic-id <id>', 'Filter by topic ID')
|
||||
.option('--agent-id <id>', 'Filter by agent ID')
|
||||
.option('-L, --limit <n>', 'Page size', '30')
|
||||
.option('-P, --page <n>', 'Page number', '1')
|
||||
.option('--page <n>', 'Page number', '1')
|
||||
.option('--user', 'Only show user messages')
|
||||
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
|
||||
.action(
|
||||
@@ -32,9 +32,7 @@ export function registerMessageCommand(program: Command) {
|
||||
|
||||
const hasFilter = options.topicId || options.agentId;
|
||||
const pageSize = options.limit ? Number.parseInt(options.limit, 10) : undefined;
|
||||
const current = options.page
|
||||
? Math.max(Number.parseInt(options.page, 10) - 1, 0)
|
||||
: undefined;
|
||||
const current = options.page ? Number.parseInt(options.page, 10) : undefined;
|
||||
|
||||
let items: any[];
|
||||
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import type { Command } from 'commander';
|
||||
|
||||
import { registerOpenClawMigration } from './openclaw';
|
||||
|
||||
export function registerMigrateCommand(program: Command) {
|
||||
const migrate = program
|
||||
.command('migrate')
|
||||
.description('Migrate data from external tools (OpenClaw, ChatGPT, Claude, etc.)');
|
||||
|
||||
registerOpenClawMigration(migrate);
|
||||
}
|
||||
@@ -1,588 +0,0 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { Command } from 'commander';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// ── Mocks ──────────────────────────────────────────────
|
||||
|
||||
const { mockTrpcClient } = vi.hoisted(() => ({
|
||||
mockTrpcClient: {
|
||||
agent: {
|
||||
createAgent: { mutate: vi.fn() },
|
||||
getBuiltinAgent: { query: vi.fn() },
|
||||
},
|
||||
agentDocument: {
|
||||
upsertDocument: { mutate: vi.fn() },
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const { getTrpcClient: mockGetTrpcClient } = vi.hoisted(() => ({
|
||||
getTrpcClient: vi.fn(),
|
||||
}));
|
||||
|
||||
const { mockConfirm } = vi.hoisted(() => ({
|
||||
mockConfirm: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../api/client', () => ({
|
||||
getTrpcClient: mockGetTrpcClient,
|
||||
}));
|
||||
|
||||
vi.mock('../../settings', () => ({
|
||||
resolveServerUrl: () => 'https://app.lobehub.com',
|
||||
}));
|
||||
|
||||
vi.mock('../../utils/format', async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return { ...actual, confirm: mockConfirm };
|
||||
});
|
||||
|
||||
vi.mock('../../utils/logger', () => ({
|
||||
log: {
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
},
|
||||
setVerbose: vi.fn(),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line import-x/first
|
||||
import { log } from '../../utils/logger';
|
||||
// eslint-disable-next-line import-x/first
|
||||
import { registerOpenClawMigration } from './openclaw';
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────
|
||||
|
||||
let tmpDir: string;
|
||||
|
||||
function createProgram() {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
const migrate = program.command('migrate');
|
||||
registerOpenClawMigration(migrate);
|
||||
return program;
|
||||
}
|
||||
|
||||
function writeFile(relativePath: string, content: string) {
|
||||
const fullPath = path.join(tmpDir, relativePath);
|
||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||
fs.writeFileSync(fullPath, content);
|
||||
}
|
||||
|
||||
// ── Setup / teardown ───────────────────────────────────
|
||||
|
||||
let exitSpy: ReturnType<typeof vi.spyOn>;
|
||||
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openclaw-test-'));
|
||||
exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {
|
||||
throw new Error('process.exit');
|
||||
}) as any);
|
||||
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
mockGetTrpcClient.mockResolvedValue(mockTrpcClient);
|
||||
mockConfirm.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
exitSpy.mockRestore();
|
||||
consoleSpy.mockRestore();
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ── Tests ──────────────────────────────────────────────
|
||||
|
||||
describe('migrate openclaw', () => {
|
||||
// ── Profile parsing ────────────────────────────────
|
||||
|
||||
describe('agent profile from workspace', () => {
|
||||
it('should read name, description, and emoji from IDENTITY.md', async () => {
|
||||
writeFile(
|
||||
'IDENTITY.md',
|
||||
['# IDENTITY.md', '- **Name:** 龙虾', '- **Creature:** AI 助手', '- **Emoji:** 🦞'].join(
|
||||
'\n',
|
||||
),
|
||||
);
|
||||
writeFile('hello.md', 'hello');
|
||||
|
||||
mockTrpcClient.agent.createAgent.mutate.mockResolvedValue({ agentId: 'agt_test' });
|
||||
mockTrpcClient.agentDocument.upsertDocument.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'migrate',
|
||||
'openclaw',
|
||||
'--source',
|
||||
tmpDir,
|
||||
'--yes',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agent.createAgent.mutate).toHaveBeenCalledWith({
|
||||
config: {
|
||||
avatar: '🦞',
|
||||
description: 'AI 助手',
|
||||
title: '龙虾',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should filter out placeholder emoji like (待定)', async () => {
|
||||
writeFile(
|
||||
'IDENTITY.md',
|
||||
['# IDENTITY.md', '- **Name:** TestBot', '- **Emoji:**', ' _(待定)_'].join('\n'),
|
||||
);
|
||||
writeFile('hello.md', 'hello');
|
||||
|
||||
mockTrpcClient.agent.createAgent.mutate.mockResolvedValue({ agentId: 'agt_test' });
|
||||
mockTrpcClient.agentDocument.upsertDocument.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'migrate',
|
||||
'openclaw',
|
||||
'--source',
|
||||
tmpDir,
|
||||
'--yes',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agent.createAgent.mutate).toHaveBeenCalledWith({
|
||||
config: {
|
||||
avatar: undefined,
|
||||
description: undefined,
|
||||
title: 'TestBot',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should fall back to "OpenClaw" when no identity files exist', async () => {
|
||||
writeFile('doc.md', 'content');
|
||||
|
||||
mockTrpcClient.agent.createAgent.mutate.mockResolvedValue({ agentId: 'agt_test' });
|
||||
mockTrpcClient.agentDocument.upsertDocument.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'migrate',
|
||||
'openclaw',
|
||||
'--source',
|
||||
tmpDir,
|
||||
'--yes',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agent.createAgent.mutate).toHaveBeenCalledWith({
|
||||
config: {
|
||||
avatar: undefined,
|
||||
description: undefined,
|
||||
title: 'OpenClaw',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── File filtering ─────────────────────────────────
|
||||
|
||||
describe('file collection and filtering', () => {
|
||||
it('should exclude common directories like node_modules and .git', async () => {
|
||||
writeFile('README.md', 'readme');
|
||||
writeFile('node_modules/pkg/index.js', 'module');
|
||||
writeFile('.git/config', 'git');
|
||||
writeFile('.idea/workspace.xml', 'ide');
|
||||
|
||||
mockTrpcClient.agent.createAgent.mutate.mockResolvedValue({ agentId: 'agt_test' });
|
||||
mockTrpcClient.agentDocument.upsertDocument.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'migrate',
|
||||
'openclaw',
|
||||
'--source',
|
||||
tmpDir,
|
||||
'--yes',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agentDocument.upsertDocument.mutate).toHaveBeenCalledTimes(1);
|
||||
expect(mockTrpcClient.agentDocument.upsertDocument.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ filename: 'README.md' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should exclude files matching glob patterns like *.pyc and *.log', async () => {
|
||||
writeFile('main.py', 'print("hi")');
|
||||
writeFile('main.pyc', 'bytecode');
|
||||
writeFile('app.log', 'log data');
|
||||
|
||||
mockTrpcClient.agent.createAgent.mutate.mockResolvedValue({ agentId: 'agt_test' });
|
||||
mockTrpcClient.agentDocument.upsertDocument.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'migrate',
|
||||
'openclaw',
|
||||
'--source',
|
||||
tmpDir,
|
||||
'--yes',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agentDocument.upsertDocument.mutate).toHaveBeenCalledTimes(1);
|
||||
expect(mockTrpcClient.agentDocument.upsertDocument.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ filename: 'main.py' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should respect workspace .gitignore', async () => {
|
||||
writeFile('.gitignore', 'secret.txt\ndata/\n');
|
||||
writeFile('README.md', 'readme');
|
||||
writeFile('secret.txt', 'password');
|
||||
writeFile('data/dump.sql', 'sql');
|
||||
|
||||
mockTrpcClient.agent.createAgent.mutate.mockResolvedValue({ agentId: 'agt_test' });
|
||||
mockTrpcClient.agentDocument.upsertDocument.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'migrate',
|
||||
'openclaw',
|
||||
'--source',
|
||||
tmpDir,
|
||||
'--yes',
|
||||
]);
|
||||
|
||||
const filenames = mockTrpcClient.agentDocument.upsertDocument.mutate.mock.calls.map(
|
||||
(c: any[]) => c[0].filename,
|
||||
);
|
||||
expect(filenames).toContain('README.md');
|
||||
expect(filenames).not.toContain('secret.txt');
|
||||
expect(filenames).not.toContain('data/dump.sql');
|
||||
});
|
||||
|
||||
it('should skip binary files during import', async () => {
|
||||
writeFile('readme.md', 'text content');
|
||||
// Write a file with null bytes (binary)
|
||||
const binPath = path.join(tmpDir, 'image.dat');
|
||||
fs.writeFileSync(binPath, Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00, 0x00, 0x01]));
|
||||
|
||||
mockTrpcClient.agent.createAgent.mutate.mockResolvedValue({ agentId: 'agt_test' });
|
||||
mockTrpcClient.agentDocument.upsertDocument.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'migrate',
|
||||
'openclaw',
|
||||
'--source',
|
||||
tmpDir,
|
||||
'--yes',
|
||||
]);
|
||||
|
||||
// Only the text file should be upserted
|
||||
expect(mockTrpcClient.agentDocument.upsertDocument.mutate).toHaveBeenCalledTimes(1);
|
||||
expect(mockTrpcClient.agentDocument.upsertDocument.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ filename: 'readme.md' }),
|
||||
);
|
||||
// Binary file should show as skipped in output
|
||||
const allOutput = consoleSpy.mock.calls.map((c: any[]) => c[0]).join('\n');
|
||||
expect(allOutput).toContain('skipped');
|
||||
});
|
||||
|
||||
it('should exclude database files by extension', async () => {
|
||||
writeFile('data.md', 'notes');
|
||||
writeFile('local.sqlite', 'fake-sqlite');
|
||||
writeFile('app.db', 'fake-db');
|
||||
|
||||
mockTrpcClient.agent.createAgent.mutate.mockResolvedValue({ agentId: 'agt_test' });
|
||||
mockTrpcClient.agentDocument.upsertDocument.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'migrate',
|
||||
'openclaw',
|
||||
'--source',
|
||||
tmpDir,
|
||||
'--yes',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agentDocument.upsertDocument.mutate).toHaveBeenCalledTimes(1);
|
||||
expect(mockTrpcClient.agentDocument.upsertDocument.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ filename: 'data.md' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should collect files in subdirectories', async () => {
|
||||
writeFile('docs/guide.md', 'guide');
|
||||
writeFile('docs/api.md', 'api');
|
||||
|
||||
mockTrpcClient.agent.createAgent.mutate.mockResolvedValue({ agentId: 'agt_test' });
|
||||
mockTrpcClient.agentDocument.upsertDocument.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'migrate',
|
||||
'openclaw',
|
||||
'--source',
|
||||
tmpDir,
|
||||
'--yes',
|
||||
]);
|
||||
|
||||
const filenames = mockTrpcClient.agentDocument.upsertDocument.mutate.mock.calls
|
||||
.map((c: any[]) => c[0].filename)
|
||||
.sort();
|
||||
expect(filenames).toEqual(['docs/api.md', 'docs/guide.md']);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Dry run ────────────────────────────────────────
|
||||
|
||||
describe('--dry-run', () => {
|
||||
it('should list files without calling API', async () => {
|
||||
writeFile('file.md', 'content');
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'migrate',
|
||||
'openclaw',
|
||||
'--source',
|
||||
tmpDir,
|
||||
'--dry-run',
|
||||
]);
|
||||
|
||||
expect(mockGetTrpcClient).not.toHaveBeenCalled();
|
||||
expect(mockTrpcClient.agent.createAgent.mutate).not.toHaveBeenCalled();
|
||||
expect(mockTrpcClient.agentDocument.upsertDocument.mutate).not.toHaveBeenCalled();
|
||||
expect(log.info).toHaveBeenCalledWith(expect.stringContaining('Dry run'));
|
||||
});
|
||||
});
|
||||
|
||||
// ── Agent resolution ───────────────────────────────
|
||||
|
||||
describe('agent resolution', () => {
|
||||
it('should use --agent-id directly when provided', async () => {
|
||||
writeFile('file.md', 'content');
|
||||
mockTrpcClient.agentDocument.upsertDocument.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'migrate',
|
||||
'openclaw',
|
||||
'--source',
|
||||
tmpDir,
|
||||
'--agent-id',
|
||||
'agt_existing',
|
||||
'--yes',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agent.createAgent.mutate).not.toHaveBeenCalled();
|
||||
expect(mockTrpcClient.agentDocument.upsertDocument.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ agentId: 'agt_existing' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should resolve agent by --slug', async () => {
|
||||
writeFile('file.md', 'content');
|
||||
mockTrpcClient.agent.getBuiltinAgent.query.mockResolvedValue({ id: 'agt_inbox' });
|
||||
mockTrpcClient.agentDocument.upsertDocument.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'migrate',
|
||||
'openclaw',
|
||||
'--source',
|
||||
tmpDir,
|
||||
'--slug',
|
||||
'inbox',
|
||||
'--yes',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agent.getBuiltinAgent.query).toHaveBeenCalledWith({ slug: 'inbox' });
|
||||
expect(mockTrpcClient.agentDocument.upsertDocument.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ agentId: 'agt_inbox' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should create a new agent by default', async () => {
|
||||
writeFile('file.md', 'content');
|
||||
mockTrpcClient.agent.createAgent.mutate.mockResolvedValue({ agentId: 'agt_new' });
|
||||
mockTrpcClient.agentDocument.upsertDocument.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'migrate',
|
||||
'openclaw',
|
||||
'--source',
|
||||
tmpDir,
|
||||
'--yes',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agent.createAgent.mutate).toHaveBeenCalledTimes(1);
|
||||
expect(mockTrpcClient.agentDocument.upsertDocument.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ agentId: 'agt_new' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Confirmation ───────────────────────────────────
|
||||
|
||||
describe('confirmation', () => {
|
||||
it('should cancel when user declines', async () => {
|
||||
writeFile('file.md', 'content');
|
||||
mockConfirm.mockResolvedValue(false);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'migrate', 'openclaw', '--source', tmpDir]);
|
||||
|
||||
expect(mockTrpcClient.agent.createAgent.mutate).not.toHaveBeenCalled();
|
||||
expect(consoleSpy).toHaveBeenCalledWith('Cancelled.');
|
||||
});
|
||||
|
||||
it('should skip confirmation with --yes', async () => {
|
||||
writeFile('file.md', 'content');
|
||||
mockTrpcClient.agent.createAgent.mutate.mockResolvedValue({ agentId: 'agt_test' });
|
||||
mockTrpcClient.agentDocument.upsertDocument.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'migrate',
|
||||
'openclaw',
|
||||
'--source',
|
||||
tmpDir,
|
||||
'--yes',
|
||||
]);
|
||||
|
||||
expect(mockConfirm).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Error handling ─────────────────────────────────
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should exit when source path does not exist', async () => {
|
||||
const program = createProgram();
|
||||
await program
|
||||
.parseAsync(['node', 'test', 'migrate', 'openclaw', '--source', '/nonexistent/path'])
|
||||
.catch(() => {}); // process.exit throws
|
||||
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('not found'));
|
||||
});
|
||||
|
||||
it('should report failed files without aborting', async () => {
|
||||
writeFile('a.md', 'ok');
|
||||
writeFile('b.md', 'fail');
|
||||
|
||||
mockTrpcClient.agent.createAgent.mutate.mockResolvedValue({ agentId: 'agt_test' });
|
||||
// Files are iterated in readdir order; mock first success then failure
|
||||
mockTrpcClient.agentDocument.upsertDocument.mutate
|
||||
.mockResolvedValueOnce({})
|
||||
.mockRejectedValueOnce(new Error('upload error'));
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'migrate',
|
||||
'openclaw',
|
||||
'--source',
|
||||
tmpDir,
|
||||
'--yes',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agentDocument.upsertDocument.mutate).toHaveBeenCalledTimes(2);
|
||||
const allOutput = consoleSpy.mock.calls.map((c: any[]) => c[0]).join('\n');
|
||||
expect(allOutput).toContain('1 imported');
|
||||
expect(allOutput).toContain('1 failed');
|
||||
});
|
||||
|
||||
it('should show no files message for empty workspace', async () => {
|
||||
// Only excluded items
|
||||
writeFile('.git/config', 'git');
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'migrate',
|
||||
'openclaw',
|
||||
'--source',
|
||||
tmpDir,
|
||||
'--dry-run',
|
||||
]);
|
||||
|
||||
expect(log.info).toHaveBeenCalledWith('No files found in workspace.');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Output ─────────────────────────────────────────
|
||||
|
||||
describe('output', () => {
|
||||
it('should print agent URL on completion', async () => {
|
||||
writeFile('file.md', 'content');
|
||||
mockTrpcClient.agent.createAgent.mutate.mockResolvedValue({ agentId: 'agt_abc123' });
|
||||
mockTrpcClient.agentDocument.upsertDocument.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'migrate',
|
||||
'openclaw',
|
||||
'--source',
|
||||
tmpDir,
|
||||
'--yes',
|
||||
]);
|
||||
|
||||
const allOutput = consoleSpy.mock.calls.map((c: any[]) => c[0]).join('\n');
|
||||
expect(allOutput).toContain('https://app.lobehub.com/agent/agt_abc123');
|
||||
});
|
||||
|
||||
it('should show friendly completion message on success', async () => {
|
||||
writeFile('file.md', 'content');
|
||||
mockTrpcClient.agent.createAgent.mutate.mockResolvedValue({ agentId: 'agt_test' });
|
||||
mockTrpcClient.agentDocument.upsertDocument.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'migrate',
|
||||
'openclaw',
|
||||
'--source',
|
||||
tmpDir,
|
||||
'--yes',
|
||||
]);
|
||||
|
||||
const allOutput = consoleSpy.mock.calls.map((c: any[]) => c[0]).join('\n');
|
||||
expect(allOutput).toContain('Migration complete');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,466 +0,0 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { Command } from 'commander';
|
||||
import ignore from 'ignore';
|
||||
import pc from 'picocolors';
|
||||
|
||||
import type { TrpcClient } from '../../api/client';
|
||||
import { getTrpcClient } from '../../api/client';
|
||||
import { resolveServerUrl } from '../../settings';
|
||||
import { confirm } from '../../utils/format';
|
||||
import { log } from '../../utils/logger';
|
||||
|
||||
const DEFAULT_AGENT_NAME = 'OpenClaw';
|
||||
|
||||
// Files to look for agent identity (tried in order)
|
||||
const IDENTITY_FILES = ['IDENTITY.md', 'SOUL.md'];
|
||||
|
||||
// Default ignore rules (gitignore syntax) applied when no .gitignore is found
|
||||
const DEFAULT_IGNORE_RULES = [
|
||||
// VCS
|
||||
'.git',
|
||||
'.svn',
|
||||
'.hg',
|
||||
|
||||
// OpenClaw internal
|
||||
'.openclaw',
|
||||
|
||||
// OS artifacts
|
||||
'.DS_Store',
|
||||
'Thumbs.db',
|
||||
'desktop.ini',
|
||||
|
||||
// IDE / editor
|
||||
'.idea',
|
||||
'.vscode',
|
||||
'.fleet',
|
||||
'.cursor',
|
||||
'.zed',
|
||||
'*.swp',
|
||||
'*.swo',
|
||||
'*~',
|
||||
|
||||
// Dependencies
|
||||
'node_modules',
|
||||
'.pnp',
|
||||
'.yarn',
|
||||
'bower_components',
|
||||
'vendor',
|
||||
'jspm_packages',
|
||||
|
||||
// Python
|
||||
'.venv',
|
||||
'venv',
|
||||
'env',
|
||||
'__pycache__',
|
||||
'*.pyc',
|
||||
'*.pyo',
|
||||
'.mypy_cache',
|
||||
'.ruff_cache',
|
||||
'.pytest_cache',
|
||||
'.tox',
|
||||
'.eggs',
|
||||
'*.egg-info',
|
||||
|
||||
// Ruby
|
||||
'.bundle',
|
||||
|
||||
// Rust
|
||||
'target',
|
||||
|
||||
// Go
|
||||
'go.sum',
|
||||
|
||||
// Java / JVM
|
||||
'.gradle',
|
||||
'.m2',
|
||||
|
||||
// .NET
|
||||
'bin',
|
||||
'obj',
|
||||
'packages',
|
||||
|
||||
// Build / cache / output
|
||||
'.cache',
|
||||
'.parcel-cache',
|
||||
'.next',
|
||||
'.nuxt',
|
||||
'.turbo',
|
||||
'.output',
|
||||
'dist',
|
||||
'build',
|
||||
'out',
|
||||
'.sass-cache',
|
||||
|
||||
// Env / secrets
|
||||
'.env',
|
||||
'.env.*',
|
||||
|
||||
// Test / coverage
|
||||
'coverage',
|
||||
'.nyc_output',
|
||||
|
||||
// Infra
|
||||
'.terraform',
|
||||
|
||||
// Temp
|
||||
'tmp',
|
||||
'.tmp',
|
||||
|
||||
// Logs
|
||||
'*.log',
|
||||
'logs',
|
||||
|
||||
// Databases
|
||||
'*.sqlite',
|
||||
'*.sqlite3',
|
||||
'*.db',
|
||||
'*.db-shm',
|
||||
'*.db-wal',
|
||||
'*.ldb',
|
||||
'*.mdb',
|
||||
'*.accdb',
|
||||
|
||||
// Archives / binaries
|
||||
'*.zip',
|
||||
'*.tar',
|
||||
'*.tar.gz',
|
||||
'*.tgz',
|
||||
'*.gz',
|
||||
'*.bz2',
|
||||
'*.xz',
|
||||
'*.rar',
|
||||
'*.7z',
|
||||
'*.jar',
|
||||
'*.war',
|
||||
'*.dll',
|
||||
'*.so',
|
||||
'*.dylib',
|
||||
'*.exe',
|
||||
'*.bin',
|
||||
'*.o',
|
||||
'*.a',
|
||||
'*.lib',
|
||||
'*.class',
|
||||
|
||||
// Images / media / fonts
|
||||
'*.png',
|
||||
'*.jpg',
|
||||
'*.jpeg',
|
||||
'*.gif',
|
||||
'*.bmp',
|
||||
'*.ico',
|
||||
'*.webp',
|
||||
'*.svg',
|
||||
'*.mp3',
|
||||
'*.mp4',
|
||||
'*.wav',
|
||||
'*.avi',
|
||||
'*.mov',
|
||||
'*.mkv',
|
||||
'*.flac',
|
||||
'*.ogg',
|
||||
'*.pdf',
|
||||
'*.woff',
|
||||
'*.woff2',
|
||||
'*.ttf',
|
||||
'*.otf',
|
||||
'*.eot',
|
||||
|
||||
// Lock files
|
||||
'package-lock.json',
|
||||
'yarn.lock',
|
||||
'pnpm-lock.yaml',
|
||||
'Gemfile.lock',
|
||||
'Cargo.lock',
|
||||
'poetry.lock',
|
||||
'composer.lock',
|
||||
];
|
||||
|
||||
interface AgentProfile {
|
||||
avatar?: string;
|
||||
description?: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to extract the agent name, description, and avatar emoji from
|
||||
* IDENTITY.md or SOUL.md. Falls back to "OpenClaw" if neither file
|
||||
* exists or parsing fails.
|
||||
*/
|
||||
function readAgentProfile(workspacePath: string): AgentProfile {
|
||||
for (const filename of IDENTITY_FILES) {
|
||||
const filePath = path.join(workspacePath, filename);
|
||||
if (!fs.existsSync(filePath)) continue;
|
||||
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
|
||||
// Try to extract **Name:** value
|
||||
const nameMatch = content.match(/\*{0,2}Name:?\*{0,2}\s*(.+)/i);
|
||||
const title = nameMatch ? nameMatch[1].trim() : DEFAULT_AGENT_NAME;
|
||||
|
||||
// Try to extract **Creature:** or **Vibe:** or **Description:** as description
|
||||
const descMatch = content.match(/\*{0,2}(?:Creature|Vibe|Description):?\*{0,2}\s*(.+)/i);
|
||||
const description = descMatch ? descMatch[1].trim() : undefined;
|
||||
|
||||
// Try to extract **Emoji:** value (single emoji)
|
||||
const emojiMatch = content.match(/\*{0,2}Emoji:?\*{0,2}\s*(.+)/i);
|
||||
const rawAvatar = emojiMatch ? emojiMatch[1].trim() : undefined;
|
||||
// Filter out placeholder text like (待定)(Chinese TBD), _(待定)_, (TBD), N/A, etc.
|
||||
const isPlaceholder =
|
||||
rawAvatar && /^[_*((].*[))_*]$|^(?:tbd|todo|n\/?a|none|待定|未定)$/i.test(rawAvatar);
|
||||
const avatar = rawAvatar && !isPlaceholder ? rawAvatar : undefined;
|
||||
|
||||
return { avatar, description, title };
|
||||
}
|
||||
|
||||
return { title: DEFAULT_AGENT_NAME };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an ignore filter for the workspace. Uses .gitignore if present,
|
||||
* otherwise falls back to a comprehensive default rule set.
|
||||
*/
|
||||
function buildIgnoreFilter(workspacePath: string) {
|
||||
const ig = ignore();
|
||||
|
||||
const gitignorePath = path.join(workspacePath, '.gitignore');
|
||||
if (fs.existsSync(gitignorePath)) {
|
||||
ig.add(fs.readFileSync(gitignorePath, 'utf8'));
|
||||
}
|
||||
|
||||
// Always apply default rules on top
|
||||
ig.add(DEFAULT_IGNORE_RULES);
|
||||
|
||||
return ig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively collect all files under `dir`, filtered by ignore rules.
|
||||
* Returns paths relative to `baseDir`.
|
||||
*/
|
||||
function collectFiles(dir: string, baseDir: string, ig: ReturnType<typeof ignore>): string[] {
|
||||
const results: string[] = [];
|
||||
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const relativePath = path.relative(baseDir, path.join(dir, entry.name));
|
||||
|
||||
// Directories need a trailing slash for ignore to match correctly
|
||||
const testPath = entry.isDirectory() ? `${relativePath}/` : relativePath;
|
||||
if (ig.ignores(testPath)) continue;
|
||||
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
results.push(...collectFiles(fullPath, baseDir, ig));
|
||||
} else if (entry.isFile()) {
|
||||
results.push(relativePath);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick check: read the first 8KB and look for null bytes.
|
||||
* If found, the file is likely binary and should be skipped.
|
||||
*/
|
||||
function isBinaryFile(filePath: string): boolean {
|
||||
const fd = fs.openSync(filePath, 'r');
|
||||
try {
|
||||
const buf = Buffer.alloc(8192);
|
||||
const bytesRead = fs.readSync(fd, buf, 0, 8192, 0);
|
||||
for (let i = 0; i < bytesRead; i++) {
|
||||
if (buf[i] === 0) return true;
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
}
|
||||
|
||||
function formatAgentLabel(profile: AgentProfile): string {
|
||||
return profile.avatar ? `${profile.avatar} ${profile.title}` : profile.title;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the target agent ID.
|
||||
* Priority: --agent-id > --slug > create new agent from workspace profile.
|
||||
*/
|
||||
async function resolveAgentId(
|
||||
client: TrpcClient,
|
||||
opts: { agentId?: string; slug?: string },
|
||||
profile: AgentProfile,
|
||||
): Promise<string> {
|
||||
if (opts.agentId) return opts.agentId;
|
||||
|
||||
if (opts.slug) {
|
||||
const agent = await client.agent.getBuiltinAgent.query({ slug: opts.slug });
|
||||
if (!agent) {
|
||||
log.error(`Agent not found for slug: ${opts.slug}`);
|
||||
process.exit(1);
|
||||
}
|
||||
return agent.id;
|
||||
}
|
||||
|
||||
const label = formatAgentLabel(profile);
|
||||
log.info(`Creating new agent ${pc.bold(label)}...`);
|
||||
const result = await client.agent.createAgent.mutate({
|
||||
config: {
|
||||
avatar: profile.avatar,
|
||||
description: profile.description,
|
||||
title: profile.title,
|
||||
},
|
||||
});
|
||||
|
||||
const id = result.agentId;
|
||||
if (!id) {
|
||||
log.error('Failed to create agent — no agentId returned.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`${pc.green('✓')} Agent created: ${pc.bold(label)}`);
|
||||
return id;
|
||||
}
|
||||
|
||||
export function registerOpenClawMigration(migrate: Command) {
|
||||
migrate
|
||||
.command('openclaw')
|
||||
.description('Import OpenClaw workspace files as agent documents')
|
||||
.option(
|
||||
'--source <path>',
|
||||
'Path to OpenClaw workspace',
|
||||
path.join(os.homedir(), '.openclaw', 'workspace'),
|
||||
)
|
||||
.option('--agent-id <id>', 'Import into an existing agent by ID')
|
||||
.option('--slug <slug>', 'Import into an existing agent by slug (e.g. "inbox")')
|
||||
.option('--dry-run', 'Preview files without importing')
|
||||
.option('--yes', 'Skip confirmation prompt')
|
||||
.action(
|
||||
async (options: {
|
||||
agentId?: string;
|
||||
dryRun?: boolean;
|
||||
slug?: string;
|
||||
source: string;
|
||||
yes?: boolean;
|
||||
}) => {
|
||||
// Check auth early so users don't scan files only to find out they're not logged in
|
||||
if (!options.dryRun) {
|
||||
await getTrpcClient();
|
||||
}
|
||||
|
||||
const workspacePath = path.resolve(options.source);
|
||||
|
||||
// Validate source directory
|
||||
if (!fs.existsSync(workspacePath)) {
|
||||
log.error(`OpenClaw workspace not found: ${workspacePath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!fs.statSync(workspacePath).isDirectory()) {
|
||||
log.error(`Not a directory: ${workspacePath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Read agent profile from workspace identity files
|
||||
const profile = readAgentProfile(workspacePath);
|
||||
const label = formatAgentLabel(profile);
|
||||
|
||||
// Collect files (respects .gitignore + default rules)
|
||||
const ig = buildIgnoreFilter(workspacePath);
|
||||
const files = collectFiles(workspacePath, workspacePath, ig);
|
||||
|
||||
if (files.length === 0) {
|
||||
log.info('No files found in workspace.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Found ${pc.bold(String(files.length))} file(s) in ${pc.dim(workspacePath)}:\n`,
|
||||
);
|
||||
for (const f of files) {
|
||||
console.log(` ${pc.dim('•')} ${f}`);
|
||||
}
|
||||
console.log();
|
||||
|
||||
if (options.dryRun) {
|
||||
log.info('Dry run — no changes made.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Confirm
|
||||
if (!options.yes) {
|
||||
const target = options.agentId
|
||||
? `agent ${pc.bold(options.agentId)}`
|
||||
: options.slug
|
||||
? `agent slug "${pc.bold(options.slug)}"`
|
||||
: `a new ${pc.bold(label)} agent`;
|
||||
const confirmed = await confirm(
|
||||
`Import ${files.length} file(s) as agent documents into ${target}?`,
|
||||
);
|
||||
if (!confirmed) {
|
||||
console.log('Cancelled.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const client = await getTrpcClient();
|
||||
|
||||
// Create or reuse agent
|
||||
const agentId = await resolveAgentId(client, options, profile);
|
||||
|
||||
console.log(`\nImporting to ${pc.bold(label)}...\n`);
|
||||
|
||||
let success = 0;
|
||||
let failed = 0;
|
||||
|
||||
let skipped = 0;
|
||||
|
||||
for (const relativePath of files) {
|
||||
const fullPath = path.join(workspacePath, relativePath);
|
||||
|
||||
try {
|
||||
// Skip binary files that slipped through the extension filter
|
||||
if (isBinaryFile(fullPath)) {
|
||||
console.log(` ${pc.dim('○')} ${relativePath} ${pc.dim('(binary, skipped)')}`);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(fullPath, 'utf8');
|
||||
const stat = fs.statSync(fullPath);
|
||||
|
||||
await client.agentDocument.upsertDocument.mutate({
|
||||
agentId,
|
||||
content,
|
||||
createdAt: stat.birthtime,
|
||||
filename: relativePath,
|
||||
updatedAt: stat.mtime,
|
||||
});
|
||||
console.log(` ${pc.green('✓')} ${relativePath}`);
|
||||
success++;
|
||||
} catch (err: any) {
|
||||
console.log(` ${pc.red('✗')} ${relativePath} — ${err.message || err}`);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
const agentUrl = `${resolveServerUrl()}/agent/${agentId}`;
|
||||
const skippedInfo = skipped > 0 ? `, ${skipped} skipped` : '';
|
||||
console.log();
|
||||
if (failed === 0) {
|
||||
console.log(
|
||||
`${pc.green('✓')} Migration complete! ${pc.bold(String(success))} file(s) imported to ${pc.bold(label)}.${skippedInfo}`,
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
`${pc.yellow('⚠')} Migration finished with issues: ${pc.bold(String(success))} imported, ${pc.red(String(failed))} failed${skippedInfo}.`,
|
||||
);
|
||||
}
|
||||
console.log(`\n ${pc.dim('→')} ${pc.underline(agentUrl)}`);
|
||||
console.log();
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import type { Command } from 'commander';
|
||||
import pc from 'picocolors';
|
||||
|
||||
import { getTrpcClient } from '../api/client';
|
||||
import { log } from '../utils/logger';
|
||||
|
||||
export function registerNotifyCommand(program: Command) {
|
||||
program
|
||||
.command('notify')
|
||||
.description('Send a callback message to a topic and trigger the agent to process it')
|
||||
.requiredOption('--topic <topicId>', 'Target topic ID')
|
||||
.requiredOption('-c, --content <content>', 'Message content')
|
||||
.option('--agent-id <agentId>', 'Agent ID (overrides topic default)')
|
||||
.option('--thread-id <threadId>', 'Thread ID for threaded conversations')
|
||||
.option('--json', 'Output JSON')
|
||||
.action(
|
||||
async (options: {
|
||||
agentId?: string;
|
||||
content: string;
|
||||
json?: boolean;
|
||||
threadId?: string;
|
||||
topic: string;
|
||||
}) => {
|
||||
log.debug('notify: topic=%s, agentId=%s', options.topic, options.agentId);
|
||||
|
||||
const client = await getTrpcClient();
|
||||
|
||||
try {
|
||||
const result = await client.agentNotify.notify.mutate({
|
||||
agentId: options.agentId,
|
||||
content: options.content,
|
||||
threadId: options.threadId,
|
||||
topicId: options.topic,
|
||||
});
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`${pc.green('✓')} Message sent to topic ${pc.bold(result.topicId)}`);
|
||||
if (result.operationId) {
|
||||
console.log(` Operation ID: ${result.operationId}`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(`${pc.red('✗')} Failed to send notification: ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -2,12 +2,10 @@ import type { Command } from 'commander';
|
||||
import pc from 'picocolors';
|
||||
|
||||
import { getTrpcClient } from '../../api/client';
|
||||
import type { KanbanColumn } from '../../utils/format';
|
||||
import {
|
||||
confirm,
|
||||
displayWidth,
|
||||
outputJson,
|
||||
printKanban,
|
||||
printTable,
|
||||
timeAgo,
|
||||
truncate,
|
||||
@@ -39,12 +37,10 @@ export function registerTaskCommand(program: Command) {
|
||||
.option('-L, --limit <n>', 'Page size', '50')
|
||||
.option('--offset <n>', 'Offset', '0')
|
||||
.option('--tree', 'Display as tree structure')
|
||||
.option('--board', 'Display as kanban board grouped by status')
|
||||
.option('--json [fields]', 'Output JSON')
|
||||
.action(
|
||||
async (options: {
|
||||
agent?: string;
|
||||
board?: boolean;
|
||||
json?: string | boolean;
|
||||
limit?: string;
|
||||
offset?: string;
|
||||
@@ -56,15 +52,15 @@ export function registerTaskCommand(program: Command) {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
const input: Record<string, any> = {};
|
||||
if (options.status) input.statuses = [options.status];
|
||||
if (options.status) input.status = options.status;
|
||||
if (options.root) input.parentTaskId = null;
|
||||
if (options.parent) input.parentTaskId = options.parent;
|
||||
if (options.agent) input.assigneeAgentId = options.agent;
|
||||
if (options.limit) input.limit = Number.parseInt(options.limit, 10);
|
||||
if (options.offset) input.offset = Number.parseInt(options.offset, 10);
|
||||
|
||||
// For tree/board mode, fetch all tasks (no pagination limit)
|
||||
if (options.tree || options.board) {
|
||||
// For tree mode, fetch all tasks (no pagination limit)
|
||||
if (options.tree) {
|
||||
input.limit = 100;
|
||||
delete input.offset;
|
||||
}
|
||||
@@ -81,58 +77,6 @@ export function registerTaskCommand(program: Command) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.board) {
|
||||
// Kanban board grouped by status
|
||||
const statusOrder = [
|
||||
'backlog',
|
||||
'blocked',
|
||||
'running',
|
||||
'paused',
|
||||
'completed',
|
||||
'failed',
|
||||
'timeout',
|
||||
'canceled',
|
||||
];
|
||||
|
||||
const statusColors: Record<string, (s: string) => string> = {
|
||||
backlog: pc.dim,
|
||||
blocked: pc.red,
|
||||
canceled: pc.dim,
|
||||
completed: pc.green,
|
||||
failed: pc.red,
|
||||
paused: pc.yellow,
|
||||
running: pc.blue,
|
||||
timeout: pc.red,
|
||||
};
|
||||
|
||||
// Group tasks by status
|
||||
const grouped = new Map<string, any[]>();
|
||||
for (const t of result.data) {
|
||||
const status = t.status || 'backlog';
|
||||
const list = grouped.get(status) || [];
|
||||
list.push(t);
|
||||
grouped.set(status, list);
|
||||
}
|
||||
|
||||
const kanbanColumns: KanbanColumn[] = statusOrder
|
||||
.filter((s) => grouped.has(s))
|
||||
.map((status) => ({
|
||||
color: statusColors[status],
|
||||
items: grouped.get(status)!.map((t: any) => ({
|
||||
badge: pc.dim(t.identifier),
|
||||
meta: t.assigneeAgentId ? `agent: ${t.assigneeAgentId}` : undefined,
|
||||
title: t.name || t.instruction,
|
||||
})),
|
||||
title: status.toUpperCase(),
|
||||
}));
|
||||
|
||||
console.log();
|
||||
printKanban(kanbanColumns);
|
||||
console.log();
|
||||
log.info(`Total: ${result.total}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.tree) {
|
||||
// Build tree display
|
||||
const taskMap = new Map<string, any>();
|
||||
@@ -296,34 +240,23 @@ export function registerTaskCommand(program: Command) {
|
||||
}
|
||||
if (t.error) console.log(`${pc.red('Error:')} ${t.error}`);
|
||||
|
||||
// ── Subtasks (nested tree) ──
|
||||
// ── Subtasks ──
|
||||
if (t.subtasks && t.subtasks.length > 0) {
|
||||
// Build lookup: which subtasks are completed (flatten tree)
|
||||
const collectCompleted = (nodes: typeof t.subtasks, set: Set<string>): Set<string> => {
|
||||
for (const s of nodes!) {
|
||||
if (s.status === 'completed') set.add(s.identifier);
|
||||
if (s.children) collectCompleted(s.children, set);
|
||||
}
|
||||
return set;
|
||||
};
|
||||
const completedIdentifiers = collectCompleted(t.subtasks, new Set());
|
||||
|
||||
const renderSubtasks = (nodes: typeof t.subtasks, indent: string) => {
|
||||
for (const s of nodes!) {
|
||||
const depInfo = s.blockedBy ? pc.dim(` ← blocks: ${s.blockedBy}`) : '';
|
||||
const isBlocked = s.blockedBy && !completedIdentifiers.has(s.blockedBy);
|
||||
const displayStatus = s.status === 'backlog' && isBlocked ? 'blocked' : s.status;
|
||||
console.log(
|
||||
`${indent}${pc.dim(s.identifier)} ${statusBadge(displayStatus)} ${s.name || '(unnamed)'}${depInfo}`,
|
||||
);
|
||||
if (s.children && s.children.length > 0) {
|
||||
renderSubtasks(s.children, indent + ' ');
|
||||
}
|
||||
}
|
||||
};
|
||||
// Build lookup: which subtasks are completed
|
||||
const completedIdentifiers = new Set(
|
||||
t.subtasks.filter((s) => s.status === 'completed').map((s) => s.identifier),
|
||||
);
|
||||
|
||||
console.log(`\n${pc.bold('Subtasks:')}`);
|
||||
renderSubtasks(t.subtasks, ' ');
|
||||
for (const s of t.subtasks) {
|
||||
const depInfo = s.blockedBy ? pc.dim(` ← blocks: ${s.blockedBy}`) : '';
|
||||
// Show 'blocked' instead of 'backlog' if task has unresolved dependencies
|
||||
const isBlocked = s.blockedBy && !completedIdentifiers.has(s.blockedBy);
|
||||
const displayStatus = s.status === 'backlog' && isBlocked ? 'blocked' : s.status;
|
||||
console.log(
|
||||
` ${pc.dim(s.identifier)} ${statusBadge(displayStatus)} ${s.name || '(unnamed)'}${depInfo}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Dependencies ──
|
||||
@@ -466,12 +399,7 @@ export function registerTaskCommand(program: Command) {
|
||||
: act.priority === 'normal'
|
||||
? pc.yellow(' [normal]')
|
||||
: '';
|
||||
const resolvedLabel = act.resolvedAction
|
||||
? act.resolvedComment
|
||||
? `${act.resolvedAction}: ${act.resolvedComment}`
|
||||
: act.resolvedAction
|
||||
: '';
|
||||
const resolved = resolvedLabel ? pc.green(` ✏️ ${resolvedLabel}`) : '';
|
||||
const resolved = act.resolvedAction ? pc.green(` ✏️ ${act.resolvedAction}`) : '';
|
||||
const typeLabel = pc.dim(`[${act.briefType}]`);
|
||||
console.log(
|
||||
` ${icon} ${pc.dim(ago.padStart(7))} Brief ${typeLabel} ${act.title}${pri}${resolved}${idSuffix}`,
|
||||
|
||||
@@ -77,48 +77,6 @@ describe('topic command', () => {
|
||||
expect.objectContaining({ agentId: 'a1' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should keep first page on the backend default offset', async () => {
|
||||
mockTrpcClient.topic.getTopics.query.mockResolvedValue([]);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'topic', 'list', '--agent-id', 'a1', '-L', '200']);
|
||||
|
||||
expect(mockTrpcClient.topic.getTopics.query).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ agentId: 'a1', pageSize: 200 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should convert page 2 to current 1', async () => {
|
||||
mockTrpcClient.topic.getTopics.query.mockResolvedValue([]);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'topic',
|
||||
'list',
|
||||
'--agent-id',
|
||||
'a1',
|
||||
'--page',
|
||||
'2',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.topic.getTopics.query).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ agentId: 'a1', current: 1 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should support the short page flag', async () => {
|
||||
mockTrpcClient.topic.getTopics.query.mockResolvedValue([]);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'topic', 'list', '--agent-id', 'a1', '-P', '2']);
|
||||
|
||||
expect(mockTrpcClient.topic.getTopics.query).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ agentId: 'a1', current: 1 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('search', () => {
|
||||
|
||||
@@ -17,7 +17,7 @@ export function registerTopicCommand(program: Command) {
|
||||
.description('List topics')
|
||||
.option('--agent-id <id>', 'Filter by agent ID')
|
||||
.option('-L, --limit <n>', 'Page size', '30')
|
||||
.option('-P, --page <n>', 'Page number', '1')
|
||||
.option('--page <n>', 'Page number', '1')
|
||||
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
|
||||
.action(
|
||||
async (options: {
|
||||
@@ -31,8 +31,7 @@ export function registerTopicCommand(program: Command) {
|
||||
const input: Record<string, any> = {};
|
||||
if (options.agentId) input.agentId = options.agentId;
|
||||
if (options.limit) input.pageSize = Number.parseInt(options.limit, 10);
|
||||
const page = options.page ? Number.parseInt(options.page, 10) : undefined;
|
||||
if (page !== undefined && page > 1) input.current = page - 1;
|
||||
if (options.page) input.current = Number.parseInt(options.page, 10);
|
||||
|
||||
const result = await client.topic.getTopics.query(input as any);
|
||||
const items = Array.isArray(result) ? result : ((result as any).items ?? []);
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
export const OFFICIAL_AGENT_GATEWAY_URL = 'https://agent-gateway.lobehub.com';
|
||||
export const OFFICIAL_SERVER_URL = 'https://app.lobehub.com';
|
||||
export const OFFICIAL_GATEWAY_URL = 'https://device-gateway.lobehub.com';
|
||||
|
||||
@@ -23,7 +23,6 @@ function getLogFilePath() {
|
||||
|
||||
export interface DaemonStatus {
|
||||
connectionStatus: string;
|
||||
deviceId?: string;
|
||||
gatewayUrl: string;
|
||||
pid: number;
|
||||
startedAt: string;
|
||||
@@ -160,7 +159,7 @@ export function spawnDaemon(args: string[]): number {
|
||||
// Re-run the same entry with --daemon-child (internal flag)
|
||||
const child = spawn(process.execPath, [...process.execArgv, ...args, '--daemon-child'], {
|
||||
detached: true,
|
||||
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1', LOBEHUB_DAEMON: '1' },
|
||||
env: { ...process.env, LOBEHUB_DAEMON: '1' },
|
||||
stdio: ['ignore', logFd, logFd],
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
import { createProgram } from './program';
|
||||
|
||||
createProgram().parse(process.argv, { from: 'node' });
|
||||
createProgram().parse();
|
||||
|
||||
@@ -20,16 +20,13 @@ import { registerLogoutCommand } from './commands/logout';
|
||||
import { registerManCommand } from './commands/man';
|
||||
import { registerMemoryCommand } from './commands/memory';
|
||||
import { registerMessageCommand } from './commands/message';
|
||||
import { registerMigrateCommand } from './commands/migrate';
|
||||
import { registerModelCommand } from './commands/model';
|
||||
import { registerNotifyCommand } from './commands/notify';
|
||||
import { registerPluginCommand } from './commands/plugin';
|
||||
import { registerProviderCommand } from './commands/provider';
|
||||
import { registerSearchCommand } from './commands/search';
|
||||
import { registerSessionGroupCommand } from './commands/session-group';
|
||||
import { registerSkillCommand } from './commands/skill';
|
||||
import { registerStatusCommand } from './commands/status';
|
||||
import { registerTaskCommand } from './commands/task';
|
||||
import { registerThreadCommand } from './commands/thread';
|
||||
import { registerTopicCommand } from './commands/topic';
|
||||
import { registerUserCommand } from './commands/user';
|
||||
@@ -64,18 +61,15 @@ export function createProgram() {
|
||||
registerFileCommand(program);
|
||||
registerSkillCommand(program);
|
||||
registerSessionGroupCommand(program);
|
||||
registerTaskCommand(program);
|
||||
registerThreadCommand(program);
|
||||
registerTopicCommand(program);
|
||||
registerMessageCommand(program);
|
||||
registerModelCommand(program);
|
||||
registerNotifyCommand(program);
|
||||
registerProviderCommand(program);
|
||||
registerPluginCommand(program);
|
||||
registerUserCommand(program);
|
||||
registerConfigCommand(program);
|
||||
registerEvalCommand(program);
|
||||
registerMigrateCommand(program);
|
||||
|
||||
return program;
|
||||
}
|
||||
|
||||
@@ -2,11 +2,10 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { OFFICIAL_AGENT_GATEWAY_URL, OFFICIAL_SERVER_URL } from '../constants/urls';
|
||||
import { OFFICIAL_SERVER_URL } from '../constants/urls';
|
||||
import { log } from '../utils/logger';
|
||||
|
||||
export interface StoredSettings {
|
||||
agentGatewayUrl?: string;
|
||||
gatewayUrl?: string;
|
||||
serverUrl?: string;
|
||||
}
|
||||
@@ -26,24 +25,15 @@ export function resolveServerUrl(): string {
|
||||
return envServerUrl || settingsServerUrl || OFFICIAL_SERVER_URL;
|
||||
}
|
||||
|
||||
export function resolveAgentGatewayUrl(): string | undefined {
|
||||
const envUrl = normalizeUrl(process.env.AGENT_GATEWAY_URL);
|
||||
const settingsUrl = normalizeUrl(loadSettings()?.agentGatewayUrl);
|
||||
|
||||
return envUrl || settingsUrl || OFFICIAL_AGENT_GATEWAY_URL;
|
||||
}
|
||||
|
||||
export function saveSettings(settings: StoredSettings): void {
|
||||
const agentGatewayUrl = normalizeUrl(settings.agentGatewayUrl);
|
||||
const gatewayUrl = normalizeUrl(settings.gatewayUrl);
|
||||
const serverUrl = normalizeUrl(settings.serverUrl);
|
||||
const gatewayUrl = normalizeUrl(settings.gatewayUrl);
|
||||
const normalized: StoredSettings = {
|
||||
agentGatewayUrl: agentGatewayUrl === OFFICIAL_AGENT_GATEWAY_URL ? undefined : agentGatewayUrl,
|
||||
gatewayUrl,
|
||||
serverUrl: serverUrl === OFFICIAL_SERVER_URL ? undefined : serverUrl,
|
||||
};
|
||||
|
||||
if (!normalized.serverUrl && !normalized.gatewayUrl && !normalized.agentGatewayUrl) {
|
||||
if (!normalized.serverUrl && !normalized.gatewayUrl) {
|
||||
try {
|
||||
fs.unlinkSync(SETTINGS_FILE);
|
||||
} catch {}
|
||||
@@ -60,16 +50,14 @@ export function loadSettings(): StoredSettings | null {
|
||||
try {
|
||||
const data = fs.readFileSync(SETTINGS_FILE, 'utf8');
|
||||
const parsed = JSON.parse(data) as StoredSettings;
|
||||
const agentGatewayUrl = normalizeUrl(parsed.agentGatewayUrl);
|
||||
const gatewayUrl = normalizeUrl(parsed.gatewayUrl);
|
||||
const serverUrl = normalizeUrl(parsed.serverUrl);
|
||||
const normalized: StoredSettings = {
|
||||
agentGatewayUrl: agentGatewayUrl === OFFICIAL_AGENT_GATEWAY_URL ? undefined : agentGatewayUrl,
|
||||
gatewayUrl,
|
||||
serverUrl: serverUrl === OFFICIAL_SERVER_URL ? undefined : serverUrl,
|
||||
};
|
||||
|
||||
if (!normalized.serverUrl && !normalized.gatewayUrl && !normalized.agentGatewayUrl) return null;
|
||||
if (!normalized.serverUrl && !normalized.gatewayUrl) return null;
|
||||
|
||||
return normalized;
|
||||
} catch {
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { streamAgentEvents, streamAgentEventsViaWebSocket } from './agentStream';
|
||||
import { streamAgentEvents } from './agentStream';
|
||||
|
||||
vi.mock('./logger', () => ({
|
||||
log: {
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
heartbeat: vi.fn(),
|
||||
info: vi.fn(),
|
||||
@@ -194,418 +193,3 @@ describe('streamAgentEvents', () => {
|
||||
exitSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
// ── WebSocket stream tests ──────────────────────────────
|
||||
|
||||
let capturedWs: MockWebSocket | undefined;
|
||||
|
||||
class MockWebSocket {
|
||||
static OPEN = 1;
|
||||
static CONNECTING = 0;
|
||||
static CLOSED = 3;
|
||||
|
||||
readyState = MockWebSocket.CONNECTING;
|
||||
onopen: ((ev: any) => void) | null = null;
|
||||
onmessage: ((ev: any) => void) | null = null;
|
||||
onerror: ((ev: any) => void) | null = null;
|
||||
onclose: ((ev: any) => void) | null = null;
|
||||
|
||||
sent: string[] = [];
|
||||
private autoAuthSuccess = true;
|
||||
|
||||
constructor(
|
||||
public url: string,
|
||||
autoAuth = true,
|
||||
) {
|
||||
this.autoAuthSuccess = autoAuth;
|
||||
capturedWs = this; // eslint-disable-line @typescript-eslint/no-this-alias
|
||||
// Trigger onopen on next microtask (after handlers are assigned)
|
||||
queueMicrotask(() => {
|
||||
this.readyState = MockWebSocket.OPEN;
|
||||
this.onopen?.({ type: 'open' });
|
||||
});
|
||||
}
|
||||
|
||||
send(data: string) {
|
||||
this.sent.push(data);
|
||||
const msg = JSON.parse(data);
|
||||
|
||||
if (msg.type === 'auth' && this.autoAuthSuccess) {
|
||||
queueMicrotask(() => {
|
||||
this.onmessage?.({ data: JSON.stringify({ type: 'auth_success' }) });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
this.readyState = MockWebSocket.CLOSED;
|
||||
// Async like real WebSocket — fires after current microtask
|
||||
queueMicrotask(() => this.onclose?.({ code: 1000, reason: '' }));
|
||||
}
|
||||
|
||||
simulateMessage(msg: Record<string, unknown>) {
|
||||
this.onmessage?.({ data: JSON.stringify(msg) });
|
||||
}
|
||||
}
|
||||
|
||||
describe('streamAgentEventsViaWebSocket', () => {
|
||||
let stdoutSpy: ReturnType<typeof vi.spyOn>;
|
||||
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
||||
const originalWebSocket = globalThis.WebSocket;
|
||||
|
||||
beforeEach(() => {
|
||||
capturedWs = undefined;
|
||||
stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
||||
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
(globalThis as any).WebSocket = MockWebSocket;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stdoutSpy.mockRestore();
|
||||
consoleSpy.mockRestore();
|
||||
globalThis.WebSocket = originalWebSocket;
|
||||
});
|
||||
|
||||
/** Wait for microtasks + short delay so WS open/auth cycle completes */
|
||||
const flush = () => new Promise((r) => setTimeout(r, 20));
|
||||
|
||||
it('should connect, authenticate, and send resume', async () => {
|
||||
const promise = streamAgentEventsViaWebSocket({
|
||||
gatewayUrl: 'https://gw.test.com',
|
||||
operationId: 'op-1',
|
||||
token: 'test-token',
|
||||
});
|
||||
|
||||
await flush();
|
||||
|
||||
const ws = capturedWs!;
|
||||
// Note: serverUrl is not set here, and JSON.stringify drops undefined keys,
|
||||
// so the parsed auth message will not contain a `serverUrl` field.
|
||||
expect(ws.sent.map((s) => JSON.parse(s))).toEqual([
|
||||
{ token: 'test-token', tokenType: 'jwt', type: 'auth' },
|
||||
{ lastEventId: '', type: 'resume' },
|
||||
]);
|
||||
|
||||
ws.simulateMessage({ id: '1', type: 'session_complete' });
|
||||
await promise;
|
||||
});
|
||||
|
||||
it('should send tokenType=apiKey and serverUrl when the caller uses an API key', async () => {
|
||||
const promise = streamAgentEventsViaWebSocket({
|
||||
gatewayUrl: 'https://gw.test.com',
|
||||
operationId: 'op-1',
|
||||
serverUrl: 'https://app.lobehub.com',
|
||||
token: 'lh_sk_abc',
|
||||
tokenType: 'apiKey',
|
||||
});
|
||||
|
||||
await flush();
|
||||
|
||||
const ws = capturedWs!;
|
||||
// serverUrl is forwarded so the gateway can call back to /api/v1/users/me
|
||||
// to verify the API key.
|
||||
expect(ws.sent.map((s) => JSON.parse(s))[0]).toEqual({
|
||||
serverUrl: 'https://app.lobehub.com',
|
||||
token: 'lh_sk_abc',
|
||||
tokenType: 'apiKey',
|
||||
type: 'auth',
|
||||
});
|
||||
|
||||
ws.simulateMessage({ id: '1', type: 'session_complete' });
|
||||
await promise;
|
||||
});
|
||||
|
||||
it('should render agent_event messages using existing renderEvent', async () => {
|
||||
const promise = streamAgentEventsViaWebSocket({
|
||||
gatewayUrl: 'https://gw.test.com',
|
||||
operationId: 'op-1',
|
||||
token: 'test-token',
|
||||
});
|
||||
|
||||
await flush();
|
||||
const ws = capturedWs!;
|
||||
|
||||
ws.simulateMessage({
|
||||
event: { data: null, operationId: 'op-1', stepIndex: 0, timestamp: 1, type: 'step_start' },
|
||||
id: '1',
|
||||
type: 'agent_event',
|
||||
});
|
||||
ws.simulateMessage({
|
||||
event: {
|
||||
data: { chunkType: 'text', content: 'Hello WS!' },
|
||||
operationId: 'op-1',
|
||||
stepIndex: 0,
|
||||
timestamp: 2,
|
||||
type: 'stream_chunk',
|
||||
},
|
||||
id: '2',
|
||||
type: 'agent_event',
|
||||
});
|
||||
ws.simulateMessage({
|
||||
event: {
|
||||
data: { stepCount: 1 },
|
||||
operationId: 'op-1',
|
||||
stepIndex: 0,
|
||||
timestamp: 3,
|
||||
type: 'agent_runtime_end',
|
||||
},
|
||||
id: '3',
|
||||
type: 'agent_event',
|
||||
});
|
||||
|
||||
await promise;
|
||||
expect(stdoutSpy).toHaveBeenCalledWith('Hello WS!');
|
||||
});
|
||||
|
||||
it('should output JSON when json option is set', async () => {
|
||||
const promise = streamAgentEventsViaWebSocket({
|
||||
gatewayUrl: 'https://gw.test.com',
|
||||
json: true,
|
||||
operationId: 'op-1',
|
||||
token: 'test-token',
|
||||
});
|
||||
|
||||
await flush();
|
||||
const ws = capturedWs!;
|
||||
|
||||
ws.simulateMessage({
|
||||
event: {
|
||||
data: null,
|
||||
operationId: 'op-1',
|
||||
stepIndex: 0,
|
||||
timestamp: 1,
|
||||
type: 'agent_runtime_init',
|
||||
},
|
||||
id: '1',
|
||||
type: 'agent_event',
|
||||
});
|
||||
ws.simulateMessage({
|
||||
event: {
|
||||
data: { stepCount: 1 },
|
||||
operationId: 'op-1',
|
||||
stepIndex: 0,
|
||||
timestamp: 2,
|
||||
type: 'agent_runtime_end',
|
||||
},
|
||||
id: '2',
|
||||
type: 'agent_event',
|
||||
});
|
||||
|
||||
await promise;
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('"agent_runtime_init"'));
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('"agent_runtime_end"'));
|
||||
});
|
||||
|
||||
it('should reject on auth failure', async () => {
|
||||
// Override mock to return auth_failed instead of auth_success
|
||||
(globalThis as any).WebSocket = class extends MockWebSocket {
|
||||
constructor(url: string) {
|
||||
super(url, false); // disable auto auth_success
|
||||
capturedWs = this; // eslint-disable-line @typescript-eslint/no-this-alias
|
||||
}
|
||||
|
||||
override send(data: string) {
|
||||
this.sent.push(data);
|
||||
const msg = JSON.parse(data);
|
||||
if (msg.type === 'auth') {
|
||||
queueMicrotask(() => {
|
||||
this.onmessage?.({
|
||||
data: JSON.stringify({ reason: 'invalid token', type: 'auth_failed' }),
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await expect(
|
||||
streamAgentEventsViaWebSocket({
|
||||
gatewayUrl: 'https://gw.test.com',
|
||||
operationId: 'op-1',
|
||||
token: 'bad-token',
|
||||
}),
|
||||
).rejects.toThrow('Gateway auth failed');
|
||||
});
|
||||
|
||||
it('should resolve on session_complete', async () => {
|
||||
const promise = streamAgentEventsViaWebSocket({
|
||||
gatewayUrl: 'https://gw.test.com',
|
||||
operationId: 'op-1',
|
||||
token: 'test-token',
|
||||
});
|
||||
|
||||
await flush();
|
||||
capturedWs!.simulateMessage({ id: '1', summary: 'All done', type: 'session_complete' });
|
||||
|
||||
await expect(promise).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should ignore heartbeat_ack messages', async () => {
|
||||
const promise = streamAgentEventsViaWebSocket({
|
||||
gatewayUrl: 'https://gw.test.com',
|
||||
operationId: 'op-1',
|
||||
token: 'test-token',
|
||||
});
|
||||
|
||||
await flush();
|
||||
const ws = capturedWs!;
|
||||
|
||||
ws.simulateMessage({ type: 'heartbeat_ack' });
|
||||
expect(stdoutSpy).not.toHaveBeenCalled();
|
||||
|
||||
ws.simulateMessage({ id: '1', type: 'session_complete' });
|
||||
await promise;
|
||||
});
|
||||
|
||||
it('should construct correct WebSocket URL from HTTPS gateway URL', async () => {
|
||||
const promise = streamAgentEventsViaWebSocket({
|
||||
gatewayUrl: 'https://agent-gateway.lobehub.com',
|
||||
operationId: 'op-123',
|
||||
token: 'tok',
|
||||
});
|
||||
|
||||
await flush();
|
||||
expect(capturedWs!.url).toBe('wss://agent-gateway.lobehub.com/ws?operationId=op-123');
|
||||
|
||||
capturedWs!.simulateMessage({ id: '1', type: 'session_complete' });
|
||||
await promise;
|
||||
});
|
||||
|
||||
it('should render a multi-step agent run with tool calls', async () => {
|
||||
const promise = streamAgentEventsViaWebSocket({
|
||||
gatewayUrl: 'https://gw.test.com',
|
||||
operationId: 'op-1',
|
||||
token: 'tok',
|
||||
verbose: true,
|
||||
});
|
||||
|
||||
await flush();
|
||||
const ws = capturedWs!;
|
||||
const { log } = await import('./logger');
|
||||
|
||||
// Step 1: thinking + text + tool call
|
||||
ws.simulateMessage({
|
||||
event: {
|
||||
data: null,
|
||||
operationId: 'op-1',
|
||||
stepIndex: 0,
|
||||
timestamp: 1,
|
||||
type: 'agent_runtime_init',
|
||||
},
|
||||
id: '1',
|
||||
type: 'agent_event',
|
||||
});
|
||||
ws.simulateMessage({
|
||||
event: { data: null, operationId: 'op-1', stepIndex: 0, timestamp: 2, type: 'step_start' },
|
||||
id: '2',
|
||||
type: 'agent_event',
|
||||
});
|
||||
ws.simulateMessage({
|
||||
event: {
|
||||
data: { chunkType: 'reasoning', reasoning: 'Let me search...' },
|
||||
operationId: 'op-1',
|
||||
stepIndex: 0,
|
||||
timestamp: 3,
|
||||
type: 'stream_chunk',
|
||||
},
|
||||
id: '3',
|
||||
type: 'agent_event',
|
||||
});
|
||||
ws.simulateMessage({
|
||||
event: {
|
||||
data: { chunkType: 'text', content: 'Searching for news.' },
|
||||
operationId: 'op-1',
|
||||
stepIndex: 0,
|
||||
timestamp: 4,
|
||||
type: 'stream_chunk',
|
||||
},
|
||||
id: '4',
|
||||
type: 'agent_event',
|
||||
});
|
||||
ws.simulateMessage({
|
||||
event: {
|
||||
data: { toolCalling: { apiName: 'search', id: 'tc-1' } },
|
||||
operationId: 'op-1',
|
||||
stepIndex: 0,
|
||||
timestamp: 5,
|
||||
type: 'tool_start',
|
||||
},
|
||||
id: '5',
|
||||
type: 'agent_event',
|
||||
});
|
||||
ws.simulateMessage({
|
||||
event: { data: null, operationId: 'op-1', stepIndex: 0, timestamp: 6, type: 'stream_end' },
|
||||
id: '6',
|
||||
type: 'agent_event',
|
||||
});
|
||||
ws.simulateMessage({
|
||||
event: {
|
||||
data: { stepIndex: 0 },
|
||||
operationId: 'op-1',
|
||||
stepIndex: 0,
|
||||
timestamp: 7,
|
||||
type: 'step_complete',
|
||||
},
|
||||
id: '7',
|
||||
type: 'agent_event',
|
||||
});
|
||||
|
||||
// Step 2: tool result + final text
|
||||
ws.simulateMessage({
|
||||
event: { data: null, operationId: 'op-1', stepIndex: 1, timestamp: 8, type: 'step_start' },
|
||||
id: '8',
|
||||
type: 'agent_event',
|
||||
});
|
||||
ws.simulateMessage({
|
||||
event: {
|
||||
data: {
|
||||
isSuccess: true,
|
||||
payload: { toolCalling: { id: 'tc-1' } },
|
||||
result: { content: 'Results...' },
|
||||
},
|
||||
operationId: 'op-1',
|
||||
stepIndex: 1,
|
||||
timestamp: 9,
|
||||
type: 'tool_end',
|
||||
},
|
||||
id: '9',
|
||||
type: 'agent_event',
|
||||
});
|
||||
ws.simulateMessage({
|
||||
event: {
|
||||
data: { chunkType: 'text', content: 'Here are the results.' },
|
||||
operationId: 'op-1',
|
||||
stepIndex: 1,
|
||||
timestamp: 10,
|
||||
type: 'stream_chunk',
|
||||
},
|
||||
id: '10',
|
||||
type: 'agent_event',
|
||||
});
|
||||
ws.simulateMessage({
|
||||
event: {
|
||||
data: { cost: { total: 0.05 }, stepCount: 2, usage: { total_tokens: 500 } },
|
||||
operationId: 'op-1',
|
||||
stepIndex: 1,
|
||||
timestamp: 11,
|
||||
type: 'agent_runtime_end',
|
||||
},
|
||||
id: '11',
|
||||
type: 'agent_event',
|
||||
});
|
||||
|
||||
await promise;
|
||||
|
||||
// Verify reasoning was rendered (dim)
|
||||
expect(stdoutSpy).toHaveBeenCalledWith(expect.stringContaining('Let me search...'));
|
||||
// Verify text chunks
|
||||
expect(stdoutSpy).toHaveBeenCalledWith('Searching for news.');
|
||||
expect(stdoutSpy).toHaveBeenCalledWith('Here are the results.');
|
||||
// Verify tool call was logged
|
||||
expect(log.toolCall).toHaveBeenCalledWith('search', 'tc-1', undefined);
|
||||
// Verify tool result was logged
|
||||
expect(log.toolResult).toHaveBeenCalled();
|
||||
// Verify finish line
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Agent finished'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,33 +1,21 @@
|
||||
import type { AgentStreamEvent } from '@lobechat/agent-gateway-client';
|
||||
import pc from 'picocolors';
|
||||
import urlJoin from 'url-join';
|
||||
|
||||
import { log } from './logger';
|
||||
|
||||
export type { AgentStreamEvent } from '@lobechat/agent-gateway-client';
|
||||
export interface AgentStreamEvent {
|
||||
data: any;
|
||||
id?: string;
|
||||
operationId: string;
|
||||
stepIndex: number;
|
||||
timestamp: number;
|
||||
type: string;
|
||||
}
|
||||
|
||||
interface StreamOptions {
|
||||
json?: boolean;
|
||||
verbose?: boolean;
|
||||
}
|
||||
|
||||
interface WebSocketStreamOptions extends StreamOptions {
|
||||
gatewayUrl: string;
|
||||
operationId: string;
|
||||
/**
|
||||
* LobeHub server URL the gateway should call back to when verifying
|
||||
* an apiKey token (via `/api/v1/users/me`). Required when
|
||||
* `tokenType === 'apiKey'`; ignored for JWT.
|
||||
*/
|
||||
serverUrl?: string;
|
||||
token: string;
|
||||
/**
|
||||
* How the gateway should verify `token`. `jwt` is the default for
|
||||
* backwards compatibility with existing callers.
|
||||
*/
|
||||
tokenType?: 'jwt' | 'apiKey';
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the agent SSE stream and render events to the terminal.
|
||||
* Resolves when the stream ends (agent_runtime_end or connection close).
|
||||
@@ -164,129 +152,6 @@ export function replayAgentEvents(events: AgentStreamEvent[], options: StreamOpt
|
||||
}
|
||||
}
|
||||
|
||||
const HEARTBEAT_INTERVAL = 30_000;
|
||||
|
||||
/**
|
||||
* Connect to the Agent Gateway via WebSocket and render events to the terminal.
|
||||
* Resolves when the session completes or the connection closes.
|
||||
*/
|
||||
export async function streamAgentEventsViaWebSocket(
|
||||
options: WebSocketStreamOptions,
|
||||
): Promise<void> {
|
||||
const { gatewayUrl, operationId, serverUrl, token, tokenType = 'jwt', ...streamOpts } = options;
|
||||
const wsUrl = urlJoin(
|
||||
gatewayUrl.replace(/^http/, 'ws'),
|
||||
`/ws?operationId=${encodeURIComponent(operationId)}`,
|
||||
);
|
||||
|
||||
log.debug(`Connecting to gateway: ${wsUrl} (auth: ${tokenType})`);
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const ws = new WebSocket(wsUrl);
|
||||
const jsonEvents: AgentStreamEvent[] = [];
|
||||
const ctx = createRenderContext();
|
||||
let lastEventId = '';
|
||||
let heartbeatTimer: ReturnType<typeof setInterval> | undefined;
|
||||
let jsonPrinted = false;
|
||||
|
||||
const cleanup = () => {
|
||||
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
||||
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
|
||||
ws.close();
|
||||
}
|
||||
};
|
||||
|
||||
ws.onopen = () => {
|
||||
// `serverUrl` is required so the gateway can call back to verify an
|
||||
// apiKey token. Harmless (but unused) for JWT, so we always include it
|
||||
// when available to match the device-gateway-client contract.
|
||||
ws.send(JSON.stringify({ serverUrl, token, tokenType, type: 'auth' }));
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data as string);
|
||||
|
||||
if (msg.type === 'auth_success') {
|
||||
log.debug('Gateway authenticated');
|
||||
// Request all buffered events (covers events pushed before WS connected)
|
||||
ws.send(JSON.stringify({ lastEventId: '', type: 'resume' }));
|
||||
heartbeatTimer = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'heartbeat' }));
|
||||
}
|
||||
}, HEARTBEAT_INTERVAL);
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'auth_failed') {
|
||||
cleanup();
|
||||
reject(new Error(`Gateway auth failed: ${msg.reason}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'heartbeat_ack') return;
|
||||
|
||||
if (msg.type === 'agent_event') {
|
||||
const agentEvent: AgentStreamEvent = msg.event;
|
||||
if (msg.id) lastEventId = msg.id;
|
||||
|
||||
if (streamOpts.json) {
|
||||
jsonEvents.push(agentEvent);
|
||||
} else {
|
||||
renderEvent(agentEvent, ctx, streamOpts);
|
||||
}
|
||||
|
||||
if (agentEvent.type === 'agent_runtime_end') {
|
||||
if (streamOpts.json && !jsonPrinted) {
|
||||
jsonPrinted = true;
|
||||
console.log(JSON.stringify(jsonEvents, null, 2));
|
||||
} else if (!streamOpts.json) {
|
||||
renderEnd(agentEvent);
|
||||
}
|
||||
cleanup();
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
if (agentEvent.type === 'error') {
|
||||
if (streamOpts.json && !jsonPrinted) {
|
||||
jsonPrinted = true;
|
||||
console.log(JSON.stringify(jsonEvents, null, 2));
|
||||
}
|
||||
log.error(
|
||||
`Agent error: ${agentEvent.data?.message || agentEvent.data?.error || 'Unknown error'}`,
|
||||
);
|
||||
cleanup();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (msg.type === 'session_complete') {
|
||||
if (streamOpts.json && jsonEvents.length > 0 && !jsonPrinted) {
|
||||
jsonPrinted = true;
|
||||
console.log(JSON.stringify(jsonEvents, null, 2));
|
||||
}
|
||||
cleanup();
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (err) => {
|
||||
cleanup();
|
||||
reject(err);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
||||
if (streamOpts.json && jsonEvents.length > 0 && !jsonPrinted) {
|
||||
jsonPrinted = true;
|
||||
console.log(JSON.stringify(jsonEvents, null, 2));
|
||||
}
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ── Render helpers ──────────────────────────────────────
|
||||
|
||||
interface RenderContext {
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import { readStatus } from '../daemon/manager';
|
||||
|
||||
export function resolveLocalDeviceId(): string | undefined {
|
||||
return readStatus()?.deviceId;
|
||||
}
|
||||
@@ -387,102 +387,6 @@ export function printCalendarHeatmap(
|
||||
console.log();
|
||||
}
|
||||
|
||||
// ── Kanban Board ─────────────────────────────────────
|
||||
|
||||
export interface KanbanColumn {
|
||||
color?: (s: string) => string;
|
||||
items: KanbanCard[];
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface KanbanCard {
|
||||
badge?: string;
|
||||
meta?: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a kanban board with side-by-side columns.
|
||||
* Adapts column width to terminal width automatically.
|
||||
*/
|
||||
export function printKanban(columns: KanbanColumn[]) {
|
||||
// Filter out empty columns
|
||||
const cols = columns.filter((c) => c.items.length > 0);
|
||||
if (cols.length === 0) return;
|
||||
|
||||
const termWidth = process.stdout.columns || 100;
|
||||
// Each column gets equal width, with 1-char gap between
|
||||
const colWidth = Math.max(20, Math.floor((termWidth - (cols.length - 1)) / cols.length));
|
||||
const innerWidth = colWidth - 4; // 2 chars border + 2 padding
|
||||
|
||||
const maxRows = Math.max(...cols.map((c) => c.items.length));
|
||||
|
||||
// ── Header ──
|
||||
const topBorder = cols
|
||||
.map((c) => {
|
||||
const titleStr = ` ${c.title} (${c.items.length}) `;
|
||||
const color = c.color || pc.white;
|
||||
const remaining = colWidth - 2 - displayWidth(titleStr);
|
||||
const left = Math.floor(remaining / 2);
|
||||
const right = remaining - left;
|
||||
return color(
|
||||
'┌' + '─'.repeat(Math.max(0, left)) + titleStr + '─'.repeat(Math.max(0, right)) + '┐',
|
||||
);
|
||||
})
|
||||
.join(' ');
|
||||
console.log(topBorder);
|
||||
|
||||
// ── Rows ──
|
||||
for (let row = 0; row < maxRows; row++) {
|
||||
const line = cols
|
||||
.map((c) => {
|
||||
const color = c.color || pc.white;
|
||||
const item = c.items[row];
|
||||
if (!item) {
|
||||
return color('│') + ' '.repeat(colWidth - 2) + color('│');
|
||||
}
|
||||
|
||||
const badge = item.badge ? item.badge + ' ' : '';
|
||||
const badgeWidth = displayWidth(badge);
|
||||
const titleMaxWidth = innerWidth - badgeWidth;
|
||||
const title = truncate(item.title, titleMaxWidth);
|
||||
const titleWidth = displayWidth(title);
|
||||
const pad = ' '.repeat(Math.max(0, colWidth - 2 - badgeWidth - titleWidth - 2));
|
||||
return color('│') + ' ' + badge + title + pad + ' ' + color('│');
|
||||
})
|
||||
.join(' ');
|
||||
console.log(line);
|
||||
|
||||
// Print meta line if any card in this row has meta
|
||||
const hasMeta = cols.some((c) => c.items[row]?.meta);
|
||||
if (hasMeta) {
|
||||
const metaLine = cols
|
||||
.map((c) => {
|
||||
const color = c.color || pc.white;
|
||||
const item = c.items[row];
|
||||
if (!item?.meta) {
|
||||
return color('│') + ' '.repeat(colWidth - 2) + color('│');
|
||||
}
|
||||
const meta = truncate(item.meta, innerWidth);
|
||||
const metaWidth = displayWidth(meta);
|
||||
const pad = ' '.repeat(Math.max(0, colWidth - 2 - metaWidth - 2));
|
||||
return color('│') + ' ' + pc.dim(meta) + pad + ' ' + color('│');
|
||||
})
|
||||
.join(' ');
|
||||
console.log(metaLine);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Bottom border ──
|
||||
const bottomBorder = cols
|
||||
.map((c) => {
|
||||
const color = c.color || pc.white;
|
||||
return color('└' + '─'.repeat(colWidth - 2) + '┘');
|
||||
})
|
||||
.join(' ');
|
||||
console.log(bottomBorder);
|
||||
}
|
||||
|
||||
export function confirm(message: string): Promise<boolean> {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
||||
return new Promise((resolve) => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user