mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-18 13:25:45 +00:00
Compare commits
165 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f1380b07c | |||
| 13e8ef9c7b | |||
| 8387067807 | |||
| 375e6381ce | |||
| f7c1ebf652 | |||
| 156a870cf3 | |||
| f017dcd0ea | |||
| 719a554456 | |||
| 3b1eef72d8 | |||
| 9e20cd6b3a | |||
| a5f4b4b569 | |||
| 5a15f759d6 | |||
| b7ecf2fd4d | |||
| 24062bb412 | |||
| 61d432a991 | |||
| f59954137a | |||
| 1324b67590 | |||
| f390d04ef2 | |||
| 84df8a9994 | |||
| 9aea74659f | |||
| 105321bfe1 | |||
| b0b6e67d5f | |||
| d2aa3cd1b4 | |||
| babdc6ade5 | |||
| 7e6255096a | |||
| 0e7eda4b47 | |||
| 990942fb45 | |||
| ecec2e87e3 | |||
| 7b6978271a | |||
| 28c2e9002a | |||
| b9034ce9c1 | |||
| 2eb7ee824f | |||
| e78949cd23 | |||
| afae236628 | |||
| 8830c6d560 | |||
| f42fc7d65d | |||
| e5e154afcb | |||
| 346812ab88 | |||
| a099749b41 | |||
| fbe8ab3891 | |||
| 2965cbc83a | |||
| fc44aaef38 | |||
| a2b8f4c81a | |||
| 6f9f5643d1 | |||
| e4877436fe | |||
| 04775f66ff | |||
| 9fff5fccf0 | |||
| 5a46c5a971 | |||
| 5722b7159b | |||
| 49a71bed6e | |||
| d5511a6af2 | |||
| e46e81a08a | |||
| 9555e4fda3 | |||
| 729fbc72d5 | |||
| 0e1a55f2f8 | |||
| c1e2d134ed | |||
| 8663991c7c | |||
| 35edca5531 | |||
| 101b9f9973 | |||
| c6a013a1a1 | |||
| 19643ba662 | |||
| 2654c4d31e | |||
| b94aa1da90 | |||
| e896024b68 | |||
| 2835b99d1a | |||
| 47812b2be9 | |||
| 798644414a | |||
| 54bb83f229 | |||
| 65da232c64 | |||
| dacc7798ab | |||
| 9508807da7 | |||
| 6a7eb17cd2 | |||
| c5da34b680 | |||
| 2a37b77482 | |||
| b814cf2611 | |||
| c37817e2d8 | |||
| bbf239705c | |||
| 8a9f42596d | |||
| 29235dc1ed | |||
| e326400dbe | |||
| deeb97ab5b | |||
| d73858ef42 | |||
| 6b9584714d | |||
| b9a4a9093c | |||
| ef5be7e17c | |||
| a4235d3f68 | |||
| fa508f4259 | |||
| 94767fddcb | |||
| 685b17e59e | |||
| 9acb128943 | |||
| ee55d74dd4 | |||
| cca1050e82 | |||
| 92a848c69c | |||
| f32fff19dd | |||
| 376976849b | |||
| 38d7bdbd96 | |||
| a52104552a | |||
| 3e236ec36f | |||
| 57781850ce | |||
| a101957715 | |||
| 4e309e6f26 | |||
| fd9b0531ec | |||
| 91db61b74f | |||
| 1d7b81233a | |||
| 35c3d5e08d | |||
| a176288670 | |||
| f0ba92776b | |||
| d12e050157 | |||
| cc48e9ff8e | |||
| 939f20e783 | |||
| 8f6848fba2 | |||
| 8b22e55271 | |||
| 196c0a7650 | |||
| ec7e696587 | |||
| 9b48e24ded | |||
| 79d5d2286a | |||
| 998c22890d | |||
| d5315fe745 | |||
| 5c75b0865f | |||
| 7f6f77ec9d | |||
| 7c0203a9c7 | |||
| 84fd8da4a3 | |||
| f98a314cf5 | |||
| 35c43fb580 | |||
| 56bc216c5e | |||
| 66c25cce4b | |||
| 774e29e400 | |||
| eec89338da | |||
| 91cb2a8e65 | |||
| 61d27b46a0 | |||
| 01f6858cc1 | |||
| b3e993f7b1 | |||
| 22e6e1dbcc | |||
| f7205552e8 | |||
| 0077a7286a | |||
| 697ac3bf6e | |||
| fc12fac53b | |||
| ba59d85ae6 | |||
| a6cb200d5b | |||
| 87d7b41186 | |||
| 8e807c6b10 | |||
| 53c5a014ba | |||
| ba05c32489 | |||
| d4a12c0ebb | |||
| 7f025b9c5a | |||
| 35c9e1b224 | |||
| 043d2a81fb | |||
| f39392749a | |||
| b3dc59f77a | |||
| 9b6a60339f | |||
| b55cf6b936 | |||
| 933cfbf789 | |||
| 0e11d3d9c0 | |||
| 600f10fcea | |||
| 421427f3a2 | |||
| 5dc7c2592c | |||
| a19b6b50e0 | |||
| fd2112cbcd | |||
| 0b57c9d3da | |||
| 1958a59f4e | |||
| f7ed6df35b | |||
| a18569c690 | |||
| 4ff4dead20 | |||
| 5a7d46e900 | |||
| 57e3940bc6 |
@@ -0,0 +1,209 @@
|
||||
---
|
||||
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).
|
||||
@@ -0,0 +1,95 @@
|
||||
---
|
||||
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`
|
||||
@@ -0,0 +1,4 @@
|
||||
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.'
|
||||
@@ -0,0 +1,199 @@
|
||||
# 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`
|
||||
@@ -0,0 +1,228 @@
|
||||
# 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
|
||||
@@ -0,0 +1,118 @@
|
||||
# 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
|
||||
@@ -166,7 +166,7 @@ Each platform exposes a `PlatformDefinition` registered in `platforms/index.ts`:
|
||||
}
|
||||
```
|
||||
|
||||
`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`, `serverIdField`, `userIdField`).
|
||||
`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`):
|
||||
|
||||
|
||||
@@ -8,16 +8,20 @@ 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 <genId> <taskId> # Wait & download generation result
|
||||
├── status <genId> <taskId> # 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 <generationId> <asyncTaskId> # Wait & download generation result
|
||||
├── status <generationId> <asyncTaskId> # 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>`
|
||||
@@ -54,7 +58,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 + task ID for tracking.
|
||||
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.
|
||||
|
||||
**Source**: `apps/cli/src/commands/generate/image.ts`
|
||||
|
||||
@@ -80,17 +84,22 @@ 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 <taskId>
|
||||
Generation gen_xxx → Task 7ad0eb13-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
This is the asyncTaskId — use this for status/download
|
||||
|
||||
Use "lh generate status <generationId> <taskId>" to check progress.
|
||||
Use "lh generate status <generationId> <asyncTaskId>" to check progress.
|
||||
```
|
||||
|
||||
**Typical workflow**:
|
||||
|
||||
```bash
|
||||
# Generate image, then wait & download
|
||||
# 1. Submit generation — note down BOTH IDs from the output
|
||||
lh gen image "A cute cat"
|
||||
lh gen download <generationId> <taskId> -o cat.png
|
||||
# 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
|
||||
```
|
||||
|
||||
---
|
||||
@@ -102,7 +111,7 @@ Generate video from text prompt. This is an async operation.
|
||||
**Source**: `apps/cli/src/commands/generate/video.ts`
|
||||
|
||||
```bash
|
||||
lh gen video "A cat playing piano" -m < model > -p < provider > [options]
|
||||
lh gen video "A cat playing piano" -m <model> -p <provider> [options]
|
||||
```
|
||||
|
||||
| Option | Description | Required |
|
||||
@@ -122,9 +131,26 @@ lh gen video "A cat playing piano" -m < model > -p < provider > [options]
|
||||
```
|
||||
✓ Video generation started
|
||||
Batch ID: gb_xxx
|
||||
Generation gen_xxx → Task <taskId>
|
||||
Generation gen_xxx → Task 7ad0eb13-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
This is the asyncTaskId — use this for status/download
|
||||
|
||||
Use "lh generate status <generationId> <taskId>" to check progress.
|
||||
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
|
||||
```
|
||||
|
||||
---
|
||||
@@ -153,15 +179,18 @@ lh gen asr recording.wav [options]
|
||||
|
||||
---
|
||||
|
||||
## `lh generate download <generationId> <taskId>`
|
||||
## `lh generate download <generationId> <asyncTaskId>`
|
||||
|
||||
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> <taskId> [-o output.png]
|
||||
lh gen download gen_xxx task_xxx -o ~/Desktop/result.mp4 --timeout 600
|
||||
lh gen download <generationId> <asyncTaskId> [-o output.png]
|
||||
lh gen download gen_xxx 7ad0eb13-xxxx-xxxx-xxxx-xxxxxxxxxxxx -o ~/Desktop/result.mp4 --timeout 600
|
||||
```
|
||||
|
||||
| Option | Description | Default |
|
||||
@@ -175,30 +204,21 @@ lh gen download gen_xxx task_xxx -o ~/Desktop/result.mp4 --timeout 600
|
||||
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: displays error message and exits
|
||||
4. On error / wrong ID: displays a clear message pointing to the correct ID format
|
||||
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> <taskId>`
|
||||
## `lh generate status <generationId> <asyncTaskId>`
|
||||
|
||||
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> <taskId> [--json]
|
||||
lh gen status <generationId> <asyncTaskId> [--json]
|
||||
lh gen status gen_xxx 7ad0eb13-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
@@ -235,12 +255,17 @@ 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`.
|
||||
|
||||
@@ -30,6 +30,17 @@ This is NON-NEGOTIABLE. Skipping Linear comments is a workflow violation.
|
||||
|
||||
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,58 +1,51 @@
|
||||
---
|
||||
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.'
|
||||
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.'
|
||||
---
|
||||
|
||||
# Code Review Guide
|
||||
# Review Checklist
|
||||
|
||||
## 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
|
||||
## 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
|
||||
|
||||
### 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:
|
||||
|
||||
@@ -61,13 +54,3 @@ 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."
|
||||
@@ -117,7 +117,7 @@ it('should handle tool calls', async () => {
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'call_123',
|
||||
name: 'lobe-web-browsing____search____builtin',
|
||||
name: 'lobe-web-browsing____search',
|
||||
arguments: JSON.stringify({ query: 'weather' }),
|
||||
},
|
||||
],
|
||||
|
||||
@@ -238,13 +238,34 @@ Use `---` separators between major blocks for long releases.
|
||||
- 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 commits> · <N merged PRs> · <N resolved issues> · <N contributors>
|
||||
**Since <Previous Version>:** <N merged PRs> · <N resolved issues> · <N contributors>
|
||||
|
||||
> <One release thesis sentence: what this release unlocks in practice.>
|
||||
|
||||
@@ -296,12 +317,11 @@ Use `---` separators between major blocks for long releases.
|
||||
|
||||
## 👥 Contributors
|
||||
|
||||
**<N merged PRs>** from **<N contributors>** across **<N commits>**.
|
||||
Huge thanks to **<N contributors>** who shipped **<N merged PRs>** this cycle.
|
||||
|
||||
### Community Contributors
|
||||
@<community-handle> · @<community-handle> · @<team-handle> · @<team-handle>
|
||||
|
||||
- @<username> - <notable contribution area>
|
||||
- @<username> - <notable contribution area>
|
||||
Plus @lobehubbot and renovate[bot] for maintenance.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# 🚀 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.
|
||||
@@ -59,7 +59,10 @@ git push -u origin hotfix/v{version}-{short-hash}
|
||||
|
||||
2. **Create PR to main** with a gitmoji prefix title (e.g. `🐛 fix: description`)
|
||||
|
||||
3. **After merge**: auto-tag-release detects `hotfix/*` branch → auto patch +1.
|
||||
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.
|
||||
|
||||
### Script
|
||||
|
||||
|
||||
@@ -2,14 +2,13 @@
|
||||
|
||||
## Quick Reference by Name
|
||||
|
||||
- **@arvinxx**: Last resort only, mention for priority:high issues, tool calling, mcp, database
|
||||
- **@arvinxx**: General/uncategorized issues (default assignee), priority:high issues, tool calling, mcp, database
|
||||
- **@canisminor1990**: Design, UI components, editor, markdown rendering
|
||||
- **@tjx666**: Image/video generation, vision, cloud version, documentation, TTS, auth, login/register, database
|
||||
- **@ONLY-yours**: Performance, streaming, settings, general bugs, web platform, marketplace, agent builder, schedule task
|
||||
- **@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
|
||||
- **@sudongyuer**: Mobile app (React Native)
|
||||
- **@sxjeru**: Model providers and configuration
|
||||
- **@rdmclin2**: Team workspace, IM and bot integration
|
||||
- **@tcmonster**: Subscription, refund, recharge, business cooperation
|
||||
|
||||
@@ -21,7 +20,7 @@ Quick reference for assigning issues based on labels.
|
||||
|
||||
| Label | Owner | Notes |
|
||||
| ---------------- | ------- | -------------------------------------------- |
|
||||
| All `provider:*` | @sxjeru | Model configuration and provider integration |
|
||||
| All `provider:*` | @tjx666 | Model configuration and provider integration |
|
||||
|
||||
### Platform Labels (platform:\*)
|
||||
|
||||
@@ -100,11 +99,10 @@ 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:*` → @sxjeru
|
||||
3. **Provider owner** - e.g., `provider:*` → @tjx666
|
||||
4. **Component owner** - e.g., 💄 Design → @canisminor1990
|
||||
5. **Infrastructure owner** - e.g., `deployment:*` → @nekomeowww
|
||||
6. **General maintainer** - @ONLY-yours for general bugs/issues
|
||||
7. **Last resort** - @arvinxx (only if no clear owner)
|
||||
6. **Default assignee** - @arvinxx for general/uncategorized issues
|
||||
|
||||
### Special Cases
|
||||
|
||||
@@ -121,8 +119,7 @@ Quick reference for assigning issues based on labels.
|
||||
|
||||
**No clear owner:**
|
||||
|
||||
- Assign to @ONLY-yours for general issues
|
||||
- Only mention @arvinxx if critical and truly unclear
|
||||
- Assign to @arvinxx for general issues
|
||||
|
||||
## Comment Templates
|
||||
|
||||
|
||||
@@ -146,4 +146,5 @@ apps/desktop/resources/cli-package.json
|
||||
|
||||
# Superpowers plugin brainstorm/spec outputs (local only; do not commit)
|
||||
.superpowers/
|
||||
docs/superpowers/
|
||||
.heerogeneous-tracing
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ module.exports = defineConfig({
|
||||
],
|
||||
temperature: 0,
|
||||
saveImmediately: true,
|
||||
modelName: 'gpt-5.1-chat-latest',
|
||||
modelName: 'gpt-4o',
|
||||
experimental: {
|
||||
jsonMode: true,
|
||||
},
|
||||
|
||||
@@ -1,100 +1,128 @@
|
||||
# LobeHub Development Guidelines
|
||||
|
||||
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).
|
||||
Guidelines for using AI coding agents in this LobeHub repository.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **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)
|
||||
- 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
|
||||
|
||||
## Directory Structure
|
||||
## Project Structure
|
||||
|
||||
```plaintext
|
||||
lobehub/
|
||||
├── apps/desktop/ # Electron desktop app
|
||||
├── apps/
|
||||
│ ├── desktop/ # Electron desktop app
|
||||
│ ├── cli/ # LobeHub CLI
|
||||
│ └── device-gateway/ # Device gateway service
|
||||
├── packages/ # Shared packages (@lobechat/*)
|
||||
│ ├── database/ # Database schemas, models, repositories
|
||||
│ ├── agent-runtime/ # Agent runtime
|
||||
│ └── ...
|
||||
├── src/
|
||||
│ ├── app/ # Next.js app router
|
||||
│ ├── spa/ # SPA entry points (entry.*.tsx) and router config
|
||||
│ ├── routes/ # SPA page components (roots)
|
||||
│ ├── features/ # Business components by domain
|
||||
│ ├── 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
|
||||
│ ├── store/ # Zustand stores
|
||||
│ ├── services/ # Client services
|
||||
│ ├── server/ # Server services and routers
|
||||
│ └── ...
|
||||
├── .agents/skills/ # AI development skills
|
||||
└── e2e/ # E2E tests (Cucumber + Playwright)
|
||||
```
|
||||
|
||||
## Development Workflow
|
||||
## 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.
|
||||
|
||||
### 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
|
||||
- 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.
|
||||
- Use rebase for `git pull`
|
||||
- Commit messages: prefix with gitmoji
|
||||
- Branch format: `<type>/<feature-name>`
|
||||
|
||||
### Package Management
|
||||
|
||||
- Use `pnpm` as the primary package manager
|
||||
- Use `bun` to run npm scripts
|
||||
- Use `bunx` to run executable npm packages
|
||||
- `pnpm` for dependency management
|
||||
- `bun` to run npm scripts
|
||||
- `bunx` for executable npm packages
|
||||
|
||||
### Code Style Guidelines
|
||||
|
||||
#### TypeScript
|
||||
|
||||
- Prefer interfaces over types for object shapes
|
||||
|
||||
### Testing Strategy
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
# Web tests
|
||||
bunx vitest run --silent='passed-only' '[file-path-pattern]'
|
||||
# Run specific test (NEVER run `bun run test` - takes ~10 minutes)
|
||||
bunx vitest run --silent='passed-only' '[file-path]'
|
||||
|
||||
# Package tests (e.g., database)
|
||||
cd packages/[package-name] && bunx vitest run --silent='passed-only' '[file-path-pattern]'
|
||||
# Database package
|
||||
cd packages/database && bunx vitest run --silent='passed-only' '[file]'
|
||||
```
|
||||
|
||||
**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
|
||||
- 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
|
||||
|
||||
- **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
|
||||
- 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).
|
||||
|
||||
## SPA Routes and Features
|
||||
### Code Review
|
||||
|
||||
- **`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.
|
||||
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.
|
||||
|
||||
@@ -2,6 +2,81 @@
|
||||
|
||||
# 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>
|
||||
|
||||
@@ -1,123 +1 @@
|
||||
# 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 — **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
|
||||
|
||||
## 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/`.
|
||||
@AGENTS.md
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
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);
|
||||
});
|
||||
@@ -35,6 +35,7 @@
|
||||
"@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",
|
||||
|
||||
@@ -7,12 +7,14 @@ 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(): Promise<{ credentials: StoredCredentials } | null> {
|
||||
export async function getValidToken(
|
||||
bufferSeconds = 60,
|
||||
): Promise<{ credentials: StoredCredentials } | null> {
|
||||
const credentials = loadCredentials();
|
||||
if (!credentials) return null;
|
||||
|
||||
// Check if token is still valid (with 60s buffer)
|
||||
if (credentials.expiresAt && Date.now() / 1000 < credentials.expiresAt - 60) {
|
||||
// Check if token is still valid (with configurable buffer)
|
||||
if (credentials.expiresAt && Date.now() / 1000 < credentials.expiresAt - bufferSeconds) {
|
||||
return { credentials };
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,24 @@ 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() },
|
||||
@@ -41,6 +59,11 @@ const { mockStreamAgentEvents } = vi.hoisted(() => ({
|
||||
mockStreamAgentEvents: vi.fn(),
|
||||
}));
|
||||
|
||||
const { mockReplayAgentEvents, mockStreamAgentEventsViaWebSocket } = vi.hoisted(() => ({
|
||||
mockReplayAgentEvents: vi.fn(),
|
||||
mockStreamAgentEventsViaWebSocket: vi.fn(),
|
||||
}));
|
||||
|
||||
const { mockGetAgentStreamAuthInfo } = vi.hoisted(() => ({
|
||||
mockGetAgentStreamAuthInfo: vi.fn(),
|
||||
}));
|
||||
@@ -49,9 +72,18 @@ 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', () => ({ streamAgentEvents: mockStreamAgentEvents }));
|
||||
vi.mock('../utils/agentStream', () => ({
|
||||
replayAgentEvents: mockReplayAgentEvents,
|
||||
streamAgentEvents: mockStreamAgentEvents,
|
||||
streamAgentEventsViaWebSocket: mockStreamAgentEventsViaWebSocket,
|
||||
}));
|
||||
vi.mock('../utils/device', () => ({ resolveLocalDeviceId: mockResolveLocalDeviceId }));
|
||||
vi.mock('../utils/logger', () => ({
|
||||
log: { debug: vi.fn(), error: vi.fn(), heartbeat: vi.fn(), info: vi.fn(), warn: vi.fn() },
|
||||
@@ -71,12 +103,26 @@ describe('agent command', () => {
|
||||
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();
|
||||
@@ -282,7 +328,7 @@ describe('agent command', () => {
|
||||
});
|
||||
|
||||
describe('run', () => {
|
||||
it('should exec agent and connect to SSE stream', async () => {
|
||||
it('should exec agent and connect to the gateway WebSocket stream by default', async () => {
|
||||
mockTrpcClient.aiAgent.execAgent.mutate.mockResolvedValue({
|
||||
operationId: 'op-123',
|
||||
success: true,
|
||||
@@ -304,11 +350,45 @@ 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-123',
|
||||
'https://example.com/api/agent/stream?operationId=op-sse',
|
||||
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({
|
||||
@@ -595,10 +675,8 @@ describe('agent command', () => {
|
||||
'--json',
|
||||
]);
|
||||
|
||||
expect(mockStreamAgentEvents).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.any(Object),
|
||||
expect.objectContaining({ json: true }),
|
||||
expect(mockStreamAgentEventsViaWebSocket).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ json: true, operationId: 'op-j' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -794,4 +872,540 @@ 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,33 +14,12 @@ import {
|
||||
import { resolveLocalDeviceId } from '../utils/device';
|
||||
import { confirm, outputJson, printTable, truncate } from '../utils/format';
|
||||
import { log, setVerbose } from '../utils/logger';
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
import { resolveAgentId } from './agent/resolveAgentId';
|
||||
import { registerAgentSpaceFsCommand } from './agent/spaceFs';
|
||||
|
||||
export function registerAgentCommand(program: Command) {
|
||||
const agent = program.command('agent').description('Manage agents');
|
||||
registerAgentSpaceFsCommand(agent);
|
||||
|
||||
// ── list ──────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
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 '';
|
||||
}
|
||||
@@ -0,0 +1,908 @@
|
||||
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,7 +7,54 @@ import { confirm, outputJson, printBoxTable, printTable, timeAgo } from '../util
|
||||
import { log } from '../utils/logger';
|
||||
import { registerBotMessageCommands } from './botMessage';
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────
|
||||
// ── 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;
|
||||
}
|
||||
|
||||
function maskValue(val: string): string {
|
||||
if (val.length > 8) return val.slice(0, 4) + '****' + val.slice(-4);
|
||||
@@ -78,6 +125,150 @@ 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) {
|
||||
@@ -313,6 +504,16 @@ 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,
|
||||
@@ -321,11 +522,15 @@ 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;
|
||||
},
|
||||
@@ -342,6 +547,40 @@ 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);
|
||||
@@ -353,6 +592,22 @@ 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,6 +1,11 @@
|
||||
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',
|
||||
@@ -83,16 +88,21 @@ 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
|
||||
@@ -242,13 +252,33 @@ describe('connect command', () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'connect']);
|
||||
|
||||
clientEventHandlers['auth_failed']?.('invalid token');
|
||||
await 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',
|
||||
|
||||
@@ -10,6 +10,7 @@ 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';
|
||||
@@ -284,8 +285,44 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
|
||||
updateStatus('reconnecting');
|
||||
});
|
||||
|
||||
// Handle auth failed
|
||||
client.on('auth_failed', (reason) => {
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
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.`,
|
||||
@@ -308,8 +345,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;
|
||||
}
|
||||
@@ -330,6 +367,7 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
|
||||
// Graceful shutdown
|
||||
const cleanup = () => {
|
||||
info('Shutting down...');
|
||||
cancelRefreshTimer?.();
|
||||
cleanupAllProcesses();
|
||||
client.disconnect();
|
||||
removeStatus();
|
||||
@@ -374,6 +412,69 @@ 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', name: 'My Job', schedule: '* * * * *' }),
|
||||
expect.objectContaining({ agentId: 'a1', cronPattern: '* * * * *', name: 'My Job' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -125,10 +125,10 @@ export function registerCronCommand(program: Command) {
|
||||
|
||||
const input: Record<string, any> = {
|
||||
agentId: options.agentId,
|
||||
schedule: options.schedule,
|
||||
cronPattern: options.schedule,
|
||||
};
|
||||
if (options.name) input.name = options.name;
|
||||
if (options.prompt) input.prompt = options.prompt;
|
||||
if (options.prompt) input.content = 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.schedule = options.schedule;
|
||||
if (options.prompt) data.prompt = options.prompt;
|
||||
if (options.schedule) data.cronPattern = options.schedule;
|
||||
if (options.prompt) data.content = options.prompt;
|
||||
if (options.maxExecutions) data.maxExecutions = Number.parseInt(options.maxExecutions, 10);
|
||||
if (options.enable) data.enabled = true;
|
||||
if (options.disable) data.enabled = false;
|
||||
|
||||
@@ -9,6 +9,61 @@ 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')
|
||||
@@ -23,15 +78,26 @@ export function registerGenerateCommand(program: Command) {
|
||||
|
||||
// ── status ──────────────────────────────────────────
|
||||
generate
|
||||
.command('status <generationId> <taskId>')
|
||||
.command('status <generationId> <asyncTaskId>')
|
||||
.description('Check generation task status')
|
||||
.option('--json', 'Output raw JSON')
|
||||
.action(async (generationId: string, taskId: string, options: { json?: boolean }) => {
|
||||
.action(async (generationId: string, asyncTaskId: string, options: { json?: boolean }) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.generation.getGenerationStatus.query({
|
||||
asyncTaskId: taskId,
|
||||
generationId,
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
@@ -53,7 +119,7 @@ export function registerGenerateCommand(program: Command) {
|
||||
|
||||
// ── download ──────────────────────────────────────────
|
||||
generate
|
||||
.command('download <generationId> <taskId>')
|
||||
.command('download <generationId> <asyncTaskId>')
|
||||
.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')
|
||||
@@ -61,7 +127,7 @@ export function registerGenerateCommand(program: Command) {
|
||||
.action(
|
||||
async (
|
||||
generationId: string,
|
||||
taskId: string,
|
||||
asyncTaskId: string,
|
||||
options: { interval?: string; output?: string; timeout?: string },
|
||||
) => {
|
||||
const client = await getTrpcClient();
|
||||
@@ -73,10 +139,20 @@ export function registerGenerateCommand(program: Command) {
|
||||
|
||||
// Poll for completion
|
||||
while (true) {
|
||||
const result = (await client.generation.getGenerationStatus.query({
|
||||
asyncTaskId: taskId,
|
||||
generationId,
|
||||
})) as any;
|
||||
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;
|
||||
}
|
||||
|
||||
if (result.status === 'success' && result.generation) {
|
||||
const gen = result.generation;
|
||||
@@ -125,7 +201,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} ${taskId}" to check later.`));
|
||||
console.log(pc.dim(`Run "lh gen status ${generationId} ${asyncTaskId}" to check later.`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
"cookie": "^1.1.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"diff": "^8.0.4",
|
||||
"electron": "41.1.0",
|
||||
"electron": "41.3.0",
|
||||
"electron-builder": "^26.8.1",
|
||||
"electron-devtools-installer": "4.0.0",
|
||||
"electron-is": "^3.0.0",
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"macOS.services": "خدمات",
|
||||
"macOS.unhide": "إظهار الكل",
|
||||
"tray.open": "فتح {{appName}}",
|
||||
"tray.openMiniToolbar": "Quick Composer",
|
||||
"tray.quit": "خروج",
|
||||
"tray.show": "عرض {{appName}}",
|
||||
"view.forceReload": "إعادة تحميل قسري",
|
||||
@@ -86,6 +87,5 @@
|
||||
"window.minimize": "تصغير",
|
||||
"window.title": "نافذة",
|
||||
"window.toggleFullscreen": "تبديل وضع ملء الشاشة",
|
||||
"window.zoom": "تكبير",
|
||||
"tray.openMiniToolbar": "Quick Composer"
|
||||
"window.zoom": "تكبير"
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"macOS.services": "Услуги",
|
||||
"macOS.unhide": "Покажи всичко",
|
||||
"tray.open": "Отвори {{appName}}",
|
||||
"tray.openMiniToolbar": "Quick Composer",
|
||||
"tray.quit": "Изход",
|
||||
"tray.show": "Покажи {{appName}}",
|
||||
"view.forceReload": "Принудително презареждане",
|
||||
@@ -86,6 +87,5 @@
|
||||
"window.minimize": "Минимизирай",
|
||||
"window.title": "Прозорец",
|
||||
"window.toggleFullscreen": "Превключи на цял екран",
|
||||
"window.zoom": "Мащаб",
|
||||
"tray.openMiniToolbar": "Quick Composer"
|
||||
"window.zoom": "Мащаб"
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"macOS.services": "Dienste",
|
||||
"macOS.unhide": "Alle anzeigen",
|
||||
"tray.open": "{{appName}} öffnen",
|
||||
"tray.openMiniToolbar": "Quick Composer",
|
||||
"tray.quit": "Beenden",
|
||||
"tray.show": "{{appName}} anzeigen",
|
||||
"view.forceReload": "Erzwinge Neuladen",
|
||||
@@ -86,6 +87,5 @@
|
||||
"window.minimize": "Minimieren",
|
||||
"window.title": "Fenster",
|
||||
"window.toggleFullscreen": "Vollbild umschalten",
|
||||
"window.zoom": "Zoom",
|
||||
"tray.openMiniToolbar": "Quick Composer"
|
||||
"window.zoom": "Zoom"
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"macOS.services": "Servicios",
|
||||
"macOS.unhide": "Mostrar todo",
|
||||
"tray.open": "Abrir {{appName}}",
|
||||
"tray.openMiniToolbar": "Quick Composer",
|
||||
"tray.quit": "Salir",
|
||||
"tray.show": "Mostrar {{appName}}",
|
||||
"view.forceReload": "Recargar forzosamente",
|
||||
@@ -86,6 +87,5 @@
|
||||
"window.minimize": "Minimizar",
|
||||
"window.title": "Ventana",
|
||||
"window.toggleFullscreen": "Alternar pantalla completa",
|
||||
"window.zoom": "Zoom",
|
||||
"tray.openMiniToolbar": "Quick Composer"
|
||||
"window.zoom": "Zoom"
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"macOS.services": "خدمات",
|
||||
"macOS.unhide": "نمایش همه",
|
||||
"tray.open": "باز کردن {{appName}}",
|
||||
"tray.openMiniToolbar": "Quick Composer",
|
||||
"tray.quit": "خروج",
|
||||
"tray.show": "نمایش {{appName}}",
|
||||
"view.forceReload": "بارگذاری اجباری",
|
||||
@@ -86,6 +87,5 @@
|
||||
"window.minimize": "کوچک کردن",
|
||||
"window.title": "پنجره",
|
||||
"window.toggleFullscreen": "تغییر به حالت تمام صفحه",
|
||||
"window.zoom": "زوم",
|
||||
"tray.openMiniToolbar": "Quick Composer"
|
||||
"window.zoom": "زوم"
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"macOS.services": "Services",
|
||||
"macOS.unhide": "Tout afficher",
|
||||
"tray.open": "Ouvrir {{appName}}",
|
||||
"tray.openMiniToolbar": "Quick Composer",
|
||||
"tray.quit": "Quitter",
|
||||
"tray.show": "Afficher {{appName}}",
|
||||
"view.forceReload": "Recharger de force",
|
||||
@@ -86,6 +87,5 @@
|
||||
"window.minimize": "Réduire",
|
||||
"window.title": "Fenêtre",
|
||||
"window.toggleFullscreen": "Basculer en plein écran",
|
||||
"window.zoom": "Zoom",
|
||||
"tray.openMiniToolbar": "Quick Composer"
|
||||
"window.zoom": "Zoom"
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"macOS.services": "Servizi",
|
||||
"macOS.unhide": "Mostra tutto",
|
||||
"tray.open": "Apri {{appName}}",
|
||||
"tray.openMiniToolbar": "Quick Composer",
|
||||
"tray.quit": "Esci",
|
||||
"tray.show": "Mostra {{appName}}",
|
||||
"view.forceReload": "Ricarica forzata",
|
||||
@@ -86,6 +87,5 @@
|
||||
"window.minimize": "Minimizza",
|
||||
"window.title": "Finestra",
|
||||
"window.toggleFullscreen": "Attiva/disattiva schermo intero",
|
||||
"window.zoom": "Zoom",
|
||||
"tray.openMiniToolbar": "Quick Composer"
|
||||
"window.zoom": "Zoom"
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"macOS.services": "サービス",
|
||||
"macOS.unhide": "すべて表示",
|
||||
"tray.open": "{{appName}} を開く",
|
||||
"tray.openMiniToolbar": "Quick Composer",
|
||||
"tray.quit": "終了",
|
||||
"tray.show": "{{appName}} を表示",
|
||||
"view.forceReload": "強制再読み込み",
|
||||
@@ -86,6 +87,5 @@
|
||||
"window.minimize": "最小化",
|
||||
"window.title": "ウィンドウ",
|
||||
"window.toggleFullscreen": "フルスクリーン切替",
|
||||
"window.zoom": "ズーム",
|
||||
"tray.openMiniToolbar": "Quick Composer"
|
||||
"window.zoom": "ズーム"
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"macOS.services": "서비스",
|
||||
"macOS.unhide": "모두 표시",
|
||||
"tray.open": "{{appName}} 열기",
|
||||
"tray.openMiniToolbar": "Quick Composer",
|
||||
"tray.quit": "종료",
|
||||
"tray.show": "{{appName}} 표시",
|
||||
"view.forceReload": "강제 새로 고침",
|
||||
@@ -86,6 +87,5 @@
|
||||
"window.minimize": "최소화",
|
||||
"window.title": "창",
|
||||
"window.toggleFullscreen": "전체 화면 전환",
|
||||
"window.zoom": "줌",
|
||||
"tray.openMiniToolbar": "Quick Composer"
|
||||
"window.zoom": "줌"
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"macOS.services": "Diensten",
|
||||
"macOS.unhide": "Toon alles",
|
||||
"tray.open": "Open {{appName}}",
|
||||
"tray.openMiniToolbar": "Quick Composer",
|
||||
"tray.quit": "Afsluiten",
|
||||
"tray.show": "Toon {{appName}}",
|
||||
"view.forceReload": "Forceer herladen",
|
||||
@@ -86,6 +87,5 @@
|
||||
"window.minimize": "Minimaliseren",
|
||||
"window.title": "Venster",
|
||||
"window.toggleFullscreen": "Schakel volledig scherm in/uit",
|
||||
"window.zoom": "Inzoomen",
|
||||
"tray.openMiniToolbar": "Quick Composer"
|
||||
"window.zoom": "Inzoomen"
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"macOS.services": "Usługi",
|
||||
"macOS.unhide": "Pokaż wszystko",
|
||||
"tray.open": "Otwórz {{appName}}",
|
||||
"tray.openMiniToolbar": "Quick Composer",
|
||||
"tray.quit": "Zakończ",
|
||||
"tray.show": "Pokaż {{appName}}",
|
||||
"view.forceReload": "Wymuś ponowne załadowanie",
|
||||
@@ -86,6 +87,5 @@
|
||||
"window.minimize": "Zminimalizuj",
|
||||
"window.title": "Okno",
|
||||
"window.toggleFullscreen": "Przełącz tryb pełnoekranowy",
|
||||
"window.zoom": "Powiększenie",
|
||||
"tray.openMiniToolbar": "Quick Composer"
|
||||
"window.zoom": "Powiększenie"
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"macOS.services": "Serviços",
|
||||
"macOS.unhide": "Mostrar Todos",
|
||||
"tray.open": "Abrir {{appName}}",
|
||||
"tray.openMiniToolbar": "Quick Composer",
|
||||
"tray.quit": "Sair",
|
||||
"tray.show": "Mostrar {{appName}}",
|
||||
"view.forceReload": "Recarregar Forçadamente",
|
||||
@@ -86,6 +87,5 @@
|
||||
"window.minimize": "Minimizar",
|
||||
"window.title": "Janela",
|
||||
"window.toggleFullscreen": "Alternar Tela Cheia",
|
||||
"window.zoom": "Zoom",
|
||||
"tray.openMiniToolbar": "Quick Composer"
|
||||
"window.zoom": "Zoom"
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"macOS.services": "Сервисы",
|
||||
"macOS.unhide": "Показать все",
|
||||
"tray.open": "Открыть {{appName}}",
|
||||
"tray.openMiniToolbar": "Quick Composer",
|
||||
"tray.quit": "Выйти",
|
||||
"tray.show": "Показать {{appName}}",
|
||||
"view.forceReload": "Принудительная перезагрузка",
|
||||
@@ -86,6 +87,5 @@
|
||||
"window.minimize": "Свернуть",
|
||||
"window.title": "Окно",
|
||||
"window.toggleFullscreen": "Переключить полноэкранный режим",
|
||||
"window.zoom": "Масштаб",
|
||||
"tray.openMiniToolbar": "Quick Composer"
|
||||
"window.zoom": "Масштаб"
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"macOS.services": "Hizmetler",
|
||||
"macOS.unhide": "Hepsini Göster",
|
||||
"tray.open": "{{appName}}'i Aç",
|
||||
"tray.openMiniToolbar": "Quick Composer",
|
||||
"tray.quit": "Çık",
|
||||
"tray.show": "{{appName}}'i Göster",
|
||||
"view.forceReload": "Zorla Yenile",
|
||||
@@ -86,6 +87,5 @@
|
||||
"window.minimize": "Küçült",
|
||||
"window.title": "Pencere",
|
||||
"window.toggleFullscreen": "Tam Ekrana Geç",
|
||||
"window.zoom": "Yakınlaştır",
|
||||
"tray.openMiniToolbar": "Quick Composer"
|
||||
"window.zoom": "Yakınlaştır"
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"macOS.services": "Dịch vụ",
|
||||
"macOS.unhide": "Hiện tất cả",
|
||||
"tray.open": "Mở {{appName}}",
|
||||
"tray.openMiniToolbar": "Quick Composer",
|
||||
"tray.quit": "Thoát",
|
||||
"tray.show": "Hiện {{appName}}",
|
||||
"view.forceReload": "Tải lại cưỡng bức",
|
||||
@@ -86,6 +87,5 @@
|
||||
"window.minimize": "Thu nhỏ",
|
||||
"window.title": "Cửa sổ",
|
||||
"window.toggleFullscreen": "Chuyển đổi toàn màn hình",
|
||||
"window.zoom": "Thu phóng",
|
||||
"tray.openMiniToolbar": "Quick Composer"
|
||||
"window.zoom": "Thu phóng"
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"macOS.services": "服務",
|
||||
"macOS.unhide": "全部顯示",
|
||||
"tray.open": "打開 {{appName}}",
|
||||
"tray.openMiniToolbar": "Quick Composer",
|
||||
"tray.quit": "退出",
|
||||
"tray.show": "顯示 {{appName}}",
|
||||
"view.forceReload": "強制重新載入",
|
||||
@@ -86,6 +87,5 @@
|
||||
"window.minimize": "最小化",
|
||||
"window.title": "視窗",
|
||||
"window.toggleFullscreen": "切換全螢幕",
|
||||
"window.zoom": "縮放",
|
||||
"tray.openMiniToolbar": "Quick Composer"
|
||||
"window.zoom": "縮放"
|
||||
}
|
||||
|
||||
@@ -8,10 +8,13 @@ import type {
|
||||
GitBranchInfo,
|
||||
GitBranchListItem,
|
||||
GitCheckoutResult,
|
||||
GitFileDiffStatus,
|
||||
GitLinkedPullRequestResult,
|
||||
GitPullResult,
|
||||
GitPushResult,
|
||||
GitWorkingTreeFiles,
|
||||
GitWorkingTreePatch,
|
||||
GitWorkingTreePatches,
|
||||
GitWorkingTreeStatus,
|
||||
} from '@lobechat/electron-client-ipc';
|
||||
|
||||
@@ -261,6 +264,168 @@ export default class GitController extends ControllerModule {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull every dirty file's unified diff in one shot — one IPC call returns
|
||||
* the patches the renderer needs to render `<PatchDiff />` per file. We do
|
||||
* the per-file `git diff` invocations in parallel inside this method so
|
||||
* the renderer doesn't have to fan out N IPC round trips.
|
||||
*
|
||||
* Tracked changes (modified / deleted / staged-A) come from
|
||||
* `git diff HEAD -- <file>`; pure untracked files come from
|
||||
* `git diff --no-index /dev/null <file>` (which exits with code 1 when
|
||||
* there are differences — that's success, not failure).
|
||||
*
|
||||
* Per-file patches are capped at 256 KB; oversized or binary entries get an
|
||||
* empty `patch` string and a flag the renderer can use for a placeholder.
|
||||
*/
|
||||
@IpcMethod()
|
||||
async getGitWorkingTreePatches(dirPath: string): Promise<GitWorkingTreePatches> {
|
||||
const MAX_PATCH_BYTES = 256 * 1024;
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
interface Entry {
|
||||
filePath: string;
|
||||
isUntracked: boolean;
|
||||
status: GitFileDiffStatus;
|
||||
}
|
||||
|
||||
// Step 1 — classify every dirty path. Mirrors getGitWorkingTreeFiles but
|
||||
// also distinguishes untracked (`??`) from staged-add (`A`) so we can pick
|
||||
// the right diff command per entry.
|
||||
const entries: Entry[] = [];
|
||||
try {
|
||||
const { stdout } = await execFileAsync('git', ['status', '--porcelain', '-z'], {
|
||||
cwd: dirPath,
|
||||
timeout: 5000,
|
||||
});
|
||||
const tokens = stdout.split('\0');
|
||||
let i = 0;
|
||||
while (i < tokens.length) {
|
||||
const entry = tokens[i];
|
||||
i++;
|
||||
if (entry.length < 3) continue;
|
||||
const x = entry[0];
|
||||
const y = entry[1];
|
||||
const filePath = entry.slice(3);
|
||||
// R/C entries carry an extra source-path token we must consume.
|
||||
if (x === 'R' || x === 'C') i++;
|
||||
if (!filePath) continue;
|
||||
if (x === '?' && y === '?') {
|
||||
entries.push({ filePath, isUntracked: true, status: 'added' });
|
||||
} else if (x === '!' && y === '!') {
|
||||
// ignored
|
||||
} else if (x === 'D' || y === 'D') {
|
||||
entries.push({ filePath, isUntracked: false, status: 'deleted' });
|
||||
} else if (x === 'A' || y === 'A') {
|
||||
entries.push({ filePath, isUntracked: false, status: 'added' });
|
||||
} else {
|
||||
entries.push({ filePath, isUntracked: false, status: 'modified' });
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.warn('[getGitWorkingTreePatches] status failed', {
|
||||
cwd: dirPath,
|
||||
stderr: error?.stderr?.toString?.() ?? error?.stderr,
|
||||
});
|
||||
return { patches: [] };
|
||||
}
|
||||
|
||||
// Walk the patch line-by-line counting `+`/`-` payload lines while
|
||||
// skipping the `+++ b/...` / `--- a/...` headers (they look like
|
||||
// additions/deletions but aren't). Cheap enough to do inline per file —
|
||||
// each patch is capped at MAX_PATCH_BYTES.
|
||||
const countAddDel = (patch: string): { additions: number; deletions: number } => {
|
||||
let additions = 0;
|
||||
let deletions = 0;
|
||||
for (const line of patch.split('\n')) {
|
||||
if (line.startsWith('+++') || line.startsWith('---')) continue;
|
||||
if (line.startsWith('+')) additions++;
|
||||
else if (line.startsWith('-')) deletions++;
|
||||
}
|
||||
return { additions, deletions };
|
||||
};
|
||||
|
||||
// Step 2 — per-file diff in parallel. `--no-index` exits 1 when there's a
|
||||
// diff (which is the expected outcome for untracked files), so we have to
|
||||
// pull stdout off the rejected error rather than letting it throw.
|
||||
const patches = await Promise.all(
|
||||
entries.map(async ({ filePath, isUntracked, status }): Promise<GitWorkingTreePatch> => {
|
||||
const args = isUntracked
|
||||
? ['diff', '--no-color', '--no-index', '/dev/null', filePath]
|
||||
: ['diff', '--no-color', 'HEAD', '--', filePath];
|
||||
|
||||
let text: string;
|
||||
try {
|
||||
const { stdout } = await execFileAsync('git', args, {
|
||||
cwd: dirPath,
|
||||
encoding: 'utf8',
|
||||
maxBuffer: MAX_PATCH_BYTES * 4,
|
||||
timeout: 10_000,
|
||||
});
|
||||
text = stdout as string;
|
||||
} catch (error: any) {
|
||||
if (error?.stdout == null) {
|
||||
logger.debug('[getGitWorkingTreePatches] diff failed', {
|
||||
filePath,
|
||||
status,
|
||||
stderr: error?.stderr?.toString?.() ?? error?.stderr,
|
||||
});
|
||||
return {
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
filePath,
|
||||
isBinary: false,
|
||||
patch: '',
|
||||
status,
|
||||
truncated: false,
|
||||
};
|
||||
}
|
||||
text = error.stdout.toString();
|
||||
}
|
||||
|
||||
if (text.length > MAX_PATCH_BYTES) {
|
||||
return {
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
filePath,
|
||||
isBinary: false,
|
||||
patch: '',
|
||||
status,
|
||||
truncated: true,
|
||||
};
|
||||
}
|
||||
if (/^Binary files .* differ$/m.test(text)) {
|
||||
return {
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
filePath,
|
||||
isBinary: true,
|
||||
patch: '',
|
||||
status,
|
||||
truncated: false,
|
||||
};
|
||||
}
|
||||
const { additions, deletions } = countAddDel(text);
|
||||
return {
|
||||
additions,
|
||||
deletions,
|
||||
filePath,
|
||||
isBinary: false,
|
||||
patch: text,
|
||||
status,
|
||||
truncated: false,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
// Re-bucket so the UI sees added → modified → deleted (matches the
|
||||
// working-tree popover order).
|
||||
const order: Record<GitFileDiffStatus, number> = { added: 0, modified: 1, deleted: 2 };
|
||||
patches.sort((a, b) => order[a.status] - order[b.status]);
|
||||
|
||||
return { patches };
|
||||
}
|
||||
|
||||
/**
|
||||
* Count commits HEAD is ahead/behind its upstream tracking ref.
|
||||
* Returns `hasUpstream: false` when the branch has no upstream configured
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ChildProcess } from 'node:child_process';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { access, appendFile, mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import type { Readable, Writable } from 'node:stream';
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { app as electronApp, BrowserWindow } from 'electron';
|
||||
|
||||
import { getHeterogeneousAgentDriver } from '@/modules/heterogeneousAgent';
|
||||
import { CodexFileChangeTracker } from '@/modules/heterogeneousAgent/codexFileChangeTracker';
|
||||
import type {
|
||||
HeterogeneousAgentImageAttachment,
|
||||
HeterogeneousAgentParsedOutput,
|
||||
@@ -50,6 +51,21 @@ const CODEX_RESUME_CWD_MISMATCH_PATTERNS = [
|
||||
|
||||
/** Directory under appStoragePath for caching downloaded files */
|
||||
const FILE_CACHE_DIR = 'heteroAgent/files';
|
||||
const CLI_TRACE_DIR = '.heerogeneous-tracing';
|
||||
const IMAGE_EXTENSIONS_BY_MIME = {
|
||||
'image/gif': '.gif',
|
||||
'image/jpg': '.jpg',
|
||||
'image/jpeg': '.jpg',
|
||||
'image/pjpeg': '.jpg',
|
||||
'image/png': '.png',
|
||||
'image/webp': '.webp',
|
||||
'image/x-png': '.png',
|
||||
} as const satisfies Record<string, string>;
|
||||
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
const CODEX_STDERR_STATUS_LINE = 'Reading prompt from stdin...';
|
||||
const CODEX_WARN_LOG_PATTERN = /^\d{4}-\d{2}-\d{2}T\S+\s+WARN\s+/;
|
||||
const CODEX_LOG_PATTERN = /^\d{4}-\d{2}-\d{2}T\S+\s+(?:DEBUG|ERROR|INFO|TRACE|WARN)\s+/;
|
||||
const CLI_ERROR_LINE_PATTERN = /^(?:error:|Error:|Usage:)/;
|
||||
|
||||
// ─── IPC types ───
|
||||
|
||||
@@ -119,6 +135,11 @@ interface AgentSession {
|
||||
|
||||
type SessionErrorPayload = HeterogeneousAgentSessionError | string;
|
||||
|
||||
interface CliTraceSession {
|
||||
dir: string;
|
||||
writeQueue: Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* External Agent Controller — manages external agent CLI processes via Electron IPC.
|
||||
*
|
||||
@@ -305,6 +326,49 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
private getRelevantCodexStderr(stderr: string): string {
|
||||
const keptLines: string[] = [];
|
||||
let droppingWarnBlock = false;
|
||||
|
||||
for (const line of stderr.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed === CODEX_STDERR_STATUS_LINE) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (CODEX_WARN_LOG_PATTERN.test(trimmed)) {
|
||||
droppingWarnBlock = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (CODEX_LOG_PATTERN.test(trimmed)) {
|
||||
droppingWarnBlock = false;
|
||||
keptLines.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (droppingWarnBlock && !CLI_ERROR_LINE_PATTERN.test(trimmed)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
droppingWarnBlock = false;
|
||||
keptLines.push(line);
|
||||
}
|
||||
|
||||
return keptLines.join('\n').trim();
|
||||
}
|
||||
|
||||
private getExitErrorMessage(
|
||||
code: number | null,
|
||||
session: AgentSession,
|
||||
stderrOutput: string,
|
||||
): string {
|
||||
const relevantStderr =
|
||||
session.agentType === 'codex' ? this.getRelevantCodexStderr(stderrOutput) : stderrOutput;
|
||||
|
||||
return relevantStderr || `Agent exited with code ${code}`;
|
||||
}
|
||||
|
||||
private async getSpawnPreflightError(
|
||||
session: AgentSession,
|
||||
): Promise<HeterogeneousAgentSessionError | undefined> {
|
||||
@@ -331,6 +395,168 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
return cliMissingError;
|
||||
}
|
||||
|
||||
private get shouldTraceCliOutput(): boolean {
|
||||
return process.env.NODE_ENV !== 'test' && !electronApp.isPackaged;
|
||||
}
|
||||
|
||||
private formatTraceTimestamp(date: Date): string {
|
||||
const pad = (value: number) => value.toString().padStart(2, '0');
|
||||
|
||||
return [
|
||||
date.getFullYear(),
|
||||
pad(date.getMonth() + 1),
|
||||
pad(date.getDate()),
|
||||
'-',
|
||||
pad(date.getHours()),
|
||||
pad(date.getMinutes()),
|
||||
pad(date.getSeconds()),
|
||||
].join('');
|
||||
}
|
||||
|
||||
private sanitizeTracePathSegment(value: string): string {
|
||||
const sanitized = value
|
||||
.replaceAll(path.sep, '-')
|
||||
.replaceAll(/[^\w.-]+/g, '-')
|
||||
.replaceAll(/^-+|-+$/g, '')
|
||||
.slice(0, 80);
|
||||
|
||||
return sanitized || 'unknown';
|
||||
}
|
||||
|
||||
private getAttachmentTraceSummary(image: HeterogeneousAgentImageAttachment) {
|
||||
let urlKind = 'unknown';
|
||||
|
||||
try {
|
||||
urlKind = new URL(image.url).protocol.replace(/:$/, '') || urlKind;
|
||||
} catch {
|
||||
urlKind = image.url.startsWith('data:') ? 'data' : 'unknown';
|
||||
}
|
||||
|
||||
return {
|
||||
id: image.id,
|
||||
urlKind,
|
||||
};
|
||||
}
|
||||
|
||||
private async createCliTraceSession({
|
||||
cliArgs,
|
||||
cwd,
|
||||
imageList,
|
||||
session,
|
||||
stdinPayload,
|
||||
}: {
|
||||
cliArgs: string[];
|
||||
cwd: string;
|
||||
imageList: HeterogeneousAgentImageAttachment[];
|
||||
session: AgentSession;
|
||||
stdinPayload?: string;
|
||||
}): Promise<CliTraceSession | undefined> {
|
||||
if (!this.shouldTraceCliOutput) return;
|
||||
|
||||
// Don't materialize the cwd via mkdir — if the caller passed a stale or
|
||||
// typo'd path, we want spawn() to fail loudly instead of silently running
|
||||
// the agent in an empty auto-created directory.
|
||||
try {
|
||||
await access(cwd);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const createdAt = new Date();
|
||||
const rootDir = path.join(cwd, CLI_TRACE_DIR);
|
||||
const agentDir = path.join(rootDir, this.sanitizeTracePathSegment(session.agentType));
|
||||
const traceId = `${this.formatTraceTimestamp(createdAt)}-${this.sanitizeTracePathSegment(
|
||||
session.sessionId,
|
||||
)}`;
|
||||
const dir = path.join(agentDir, traceId);
|
||||
|
||||
try {
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(path.join(rootDir, '.last-live-trace'), `${dir}\n`);
|
||||
await writeFile(path.join(dir, 'stdout.jsonl'), '');
|
||||
await writeFile(path.join(dir, 'stderr.log'), '');
|
||||
if (stdinPayload !== undefined) {
|
||||
await writeFile(path.join(dir, 'stdin.txt'), '');
|
||||
}
|
||||
await writeFile(
|
||||
path.join(dir, 'meta.json'),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
agentSessionId: session.agentSessionId,
|
||||
agentType: session.agentType,
|
||||
args: cliArgs,
|
||||
attachments: imageList.map((image) => this.getAttachmentTraceSummary(image)),
|
||||
command: session.command,
|
||||
createdAt: createdAt.toISOString(),
|
||||
cwd,
|
||||
envKeys: session.env ? Object.keys(session.env).sort() : [],
|
||||
resumeSessionId: session.resumeSessionId,
|
||||
sessionId: session.sessionId,
|
||||
stdinBytes: stdinPayload === undefined ? 0 : Buffer.byteLength(stdinPayload),
|
||||
stdinFile: stdinPayload === undefined ? undefined : 'stdin.txt',
|
||||
stderrFile: 'stderr.log',
|
||||
stdoutFile: 'stdout.jsonl',
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
|
||||
return { dir, writeQueue: Promise.resolve() };
|
||||
} catch (error) {
|
||||
logger.warn('Failed to initialize CLI trace directory:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private queueCliTraceWrite(
|
||||
trace: CliTraceSession | undefined,
|
||||
write: () => Promise<void>,
|
||||
): Promise<void> | undefined {
|
||||
if (!trace) return;
|
||||
|
||||
trace.writeQueue = trace.writeQueue.then(write).catch((error) => {
|
||||
logger.warn('Failed to write CLI trace file:', error);
|
||||
});
|
||||
|
||||
return trace.writeQueue;
|
||||
}
|
||||
|
||||
private appendCliTraceFile(
|
||||
trace: CliTraceSession | undefined,
|
||||
fileName: string,
|
||||
data: Buffer | string,
|
||||
): Promise<void> | undefined {
|
||||
if (!trace) return;
|
||||
|
||||
const filePath = path.join(trace.dir, fileName);
|
||||
|
||||
return this.queueCliTraceWrite(trace, () => appendFile(filePath, data));
|
||||
}
|
||||
|
||||
private writeCliTraceFile(
|
||||
trace: CliTraceSession | undefined,
|
||||
fileName: string,
|
||||
data: string,
|
||||
): Promise<void> | undefined {
|
||||
if (!trace) return;
|
||||
|
||||
const filePath = path.join(trace.dir, fileName);
|
||||
|
||||
return this.queueCliTraceWrite(trace, () => writeFile(filePath, data));
|
||||
}
|
||||
|
||||
private writeCliTraceJson(
|
||||
trace: CliTraceSession | undefined,
|
||||
fileName: string,
|
||||
payload: unknown,
|
||||
): Promise<void> | undefined {
|
||||
return this.writeCliTraceFile(trace, fileName, `${JSON.stringify(payload, null, 2)}\n`);
|
||||
}
|
||||
|
||||
private async flushCliTrace(trace: CliTraceSession | undefined): Promise<void> {
|
||||
await trace?.writeQueue;
|
||||
}
|
||||
|
||||
// ─── Broadcast ───
|
||||
|
||||
private broadcast<T>(channel: string, data: T) {
|
||||
@@ -400,26 +626,42 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
return { buffer, mimeType };
|
||||
}
|
||||
|
||||
private normalizeMimeType(mimeType: string): string {
|
||||
return mimeType.split(';')[0]?.trim().toLowerCase() || '';
|
||||
}
|
||||
|
||||
private guessImageExtensionByBuffer(buffer: Buffer): string | undefined {
|
||||
if (buffer.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) return '.png';
|
||||
if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return '.jpg';
|
||||
|
||||
const gifSignature = buffer.subarray(0, 6).toString('ascii');
|
||||
if (gifSignature === 'GIF87a' || gifSignature === 'GIF89a') return '.gif';
|
||||
|
||||
if (
|
||||
buffer.subarray(0, 4).toString('ascii') === 'RIFF' &&
|
||||
buffer.subarray(8, 12).toString('ascii') === 'WEBP'
|
||||
) {
|
||||
return '.webp';
|
||||
}
|
||||
}
|
||||
|
||||
private guessImageExtension(
|
||||
mimeType: string,
|
||||
image: HeterogeneousAgentImageAttachment,
|
||||
buffer: Buffer,
|
||||
): string | undefined {
|
||||
const knownByMime: Record<string, string> = {
|
||||
'image/gif': '.gif',
|
||||
'image/jpeg': '.jpg',
|
||||
'image/png': '.png',
|
||||
'image/webp': '.webp',
|
||||
};
|
||||
|
||||
if (knownByMime[mimeType]) return knownByMime[mimeType];
|
||||
const knownByMime = IMAGE_EXTENSIONS_BY_MIME[this.normalizeMimeType(mimeType)];
|
||||
if (knownByMime) return knownByMime;
|
||||
|
||||
try {
|
||||
const pathname = new URL(image.url).pathname;
|
||||
const ext = path.extname(pathname);
|
||||
return ext || undefined;
|
||||
const ext = path.extname(pathname).toLowerCase();
|
||||
if (ext) return ext === '.jpeg' ? '.jpg' : ext;
|
||||
} catch {
|
||||
return undefined;
|
||||
// Fall through to byte sniffing below.
|
||||
}
|
||||
|
||||
return this.guessImageExtensionByBuffer(buffer);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -429,7 +671,11 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
private async resolveCliImagePath(image: HeterogeneousAgentImageAttachment): Promise<string> {
|
||||
const { buffer, mimeType } = await this.resolveImage(image);
|
||||
const cacheKey = this.getImageCacheKey(image.id);
|
||||
const ext = this.guessImageExtension(mimeType, image) || '';
|
||||
const ext = this.guessImageExtension(mimeType, image, buffer);
|
||||
if (!ext) {
|
||||
throw new Error(`Unsupported image type for ${image.id}`);
|
||||
}
|
||||
|
||||
const filePath = path.join(this.fileCacheDir, `${cacheKey}${ext}`);
|
||||
|
||||
try {
|
||||
@@ -445,18 +691,31 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
private async resolveCliImagePaths(
|
||||
imageList: HeterogeneousAgentImageAttachment[] = [],
|
||||
): Promise<string[]> {
|
||||
const resolved = await Promise.all(
|
||||
imageList.map(async (image) => {
|
||||
try {
|
||||
return await this.resolveCliImagePath(image);
|
||||
} catch (err) {
|
||||
logger.error(`Failed to materialize image ${image.id} for CLI:`, err);
|
||||
return undefined;
|
||||
}
|
||||
}),
|
||||
const results = await Promise.allSettled(
|
||||
imageList.map((image) => this.resolveCliImagePath(image)),
|
||||
);
|
||||
|
||||
return resolved.filter(Boolean) as string[];
|
||||
const imagePaths: string[] = [];
|
||||
const failures: string[] = [];
|
||||
|
||||
for (const [index, result] of results.entries()) {
|
||||
const imageId = imageList[index]?.id ?? `image-${index + 1}`;
|
||||
|
||||
if (result.status === 'fulfilled') {
|
||||
imagePaths.push(result.value);
|
||||
continue;
|
||||
}
|
||||
|
||||
const message = this.getErrorMessage(result.reason) || 'Unknown error';
|
||||
logger.error(`Failed to materialize image ${imageId} for CLI:`, result.reason);
|
||||
failures.push(`${imageId}: ${message}`);
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
throw new Error(`Failed to attach image(s) to CLI: ${failures.join('; ')}`);
|
||||
}
|
||||
|
||||
return imagePaths;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -466,7 +725,8 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
prompt: string,
|
||||
imageList: HeterogeneousAgentImageAttachment[] = [],
|
||||
): Promise<string> {
|
||||
const content: any[] = [{ text: prompt, type: 'text' }];
|
||||
const content: any[] = [];
|
||||
if (prompt && prompt.length > 0) content.push({ text: prompt, type: 'text' });
|
||||
|
||||
for (const image of imageList) {
|
||||
try {
|
||||
@@ -550,14 +810,20 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
resumeSessionId: session.agentSessionId,
|
||||
});
|
||||
const useStdin = spawnPlan.stdinPayload !== undefined;
|
||||
const cliArgs = spawnPlan.args;
|
||||
|
||||
// Fall back to the user's Desktop so the process never inherits
|
||||
// the Electron parent's cwd (which is `/` when launched from Finder).
|
||||
const cwd = session.cwd || electronApp.getPath('desktop');
|
||||
const traceSession = await this.createCliTraceSession({
|
||||
cliArgs,
|
||||
cwd,
|
||||
imageList: params.imageList ?? [],
|
||||
session,
|
||||
stdinPayload: spawnPlan.stdinPayload,
|
||||
});
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const cliArgs = spawnPlan.args;
|
||||
|
||||
// Fall back to the user's Desktop so the process never inherits
|
||||
// the Electron parent's cwd (which is `/` when launched from Finder).
|
||||
const cwd = session.cwd || electronApp.getPath('desktop');
|
||||
|
||||
logger.info('Spawning agent:', session.command, cliArgs.join(' '), `(cwd: ${cwd})`);
|
||||
|
||||
// `detached: true` on Unix puts the child in a new process group so we
|
||||
@@ -579,6 +845,7 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
|
||||
// In stdin mode, write the prepared payload and close stdin.
|
||||
if (useStdin && spawnPlan.stdinPayload !== undefined && proc.stdin) {
|
||||
void this.writeCliTraceFile(traceSession, 'stdin.txt', spawnPlan.stdinPayload);
|
||||
const stdin = proc.stdin as Writable;
|
||||
stdin.write(spawnPlan.stdinPayload, () => {
|
||||
stdin.end();
|
||||
@@ -587,23 +854,37 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
|
||||
session.process = proc;
|
||||
const streamProcessor = driver.createStreamProcessor();
|
||||
const codexFileChangeTracker =
|
||||
session.agentType === 'codex' ? new CodexFileChangeTracker() : undefined;
|
||||
let stdoutBroadcastQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
const broadcastParsedOutputs = (parsedOutputs: HeterogeneousAgentParsedOutput[]) => {
|
||||
for (const parsedOutput of parsedOutputs) {
|
||||
if (parsedOutput.agentSessionId) {
|
||||
session.agentSessionId = parsedOutput.agentSessionId;
|
||||
}
|
||||
stdoutBroadcastQueue = stdoutBroadcastQueue
|
||||
.then(async () => {
|
||||
for (const parsedOutput of parsedOutputs) {
|
||||
if (parsedOutput.agentSessionId) {
|
||||
session.agentSessionId = parsedOutput.agentSessionId;
|
||||
}
|
||||
|
||||
this.broadcast('heteroAgentRawLine', {
|
||||
line: parsedOutput.payload,
|
||||
sessionId: session.sessionId,
|
||||
const line = codexFileChangeTracker
|
||||
? await codexFileChangeTracker.track(parsedOutput.payload)
|
||||
: parsedOutput.payload;
|
||||
|
||||
this.broadcast('heteroAgentRawLine', {
|
||||
line,
|
||||
sessionId: session.sessionId,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error('Failed to broadcast parsed agent output:', error);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Stream stdout events as raw provider payloads to Renderer.
|
||||
const stdout = proc.stdout as Readable;
|
||||
stdout.on('data', (chunk: Buffer) => {
|
||||
void this.appendCliTraceFile(traceSession, 'stdout.jsonl', chunk);
|
||||
broadcastParsedOutputs(streamProcessor.push(chunk));
|
||||
});
|
||||
stdout.on('end', () => {
|
||||
@@ -614,11 +895,17 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
const stderrChunks: string[] = [];
|
||||
const stderr = proc.stderr as Readable;
|
||||
stderr.on('data', (chunk: Buffer) => {
|
||||
void this.appendCliTraceFile(traceSession, 'stderr.log', chunk);
|
||||
stderrChunks.push(chunk.toString('utf8'));
|
||||
});
|
||||
|
||||
proc.on('error', (err) => {
|
||||
logger.error('Agent process error:', err);
|
||||
void this.writeCliTraceJson(traceSession, 'process-error.json', {
|
||||
message: err.message,
|
||||
name: err.name,
|
||||
});
|
||||
void this.flushCliTrace(traceSession);
|
||||
const sessionError = this.getSessionErrorPayload(err, session);
|
||||
this.broadcast('heteroAgentSessionError', {
|
||||
error: sessionError,
|
||||
@@ -628,33 +915,44 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
});
|
||||
|
||||
proc.on('exit', (code, signal) => {
|
||||
logger.info('Agent process exited:', { code, sessionId: session.sessionId, signal });
|
||||
session.process = undefined;
|
||||
|
||||
// If *we* killed it (cancel / stop / before-quit), treat the non-zero
|
||||
// exit as a clean shutdown — surfacing it as an error would make a
|
||||
// user-initiated cancel look like an agent failure, and an Electron
|
||||
// shutdown affecting OTHER running CC sessions would pollute their
|
||||
// topics with a misleading "Agent exited with code 143" message.
|
||||
if (session.cancelledByUs) {
|
||||
this.broadcast('heteroAgentSessionComplete', { sessionId: session.sessionId });
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
if (code === 0) {
|
||||
this.broadcast('heteroAgentSessionComplete', { sessionId: session.sessionId });
|
||||
resolve();
|
||||
} else {
|
||||
const stderrOutput = stderrChunks.join('').trim();
|
||||
const errorMsg = stderrOutput || `Agent exited with code ${code}`;
|
||||
const sessionError = this.getSessionErrorPayload(errorMsg, session);
|
||||
this.broadcast('heteroAgentSessionError', {
|
||||
error: sessionError,
|
||||
sessionId: session.sessionId,
|
||||
void stdoutBroadcastQueue.finally(async () => {
|
||||
void this.writeCliTraceJson(traceSession, 'exit.json', {
|
||||
code,
|
||||
finishedAt: new Date().toISOString(),
|
||||
signal,
|
||||
});
|
||||
reject(new Error(typeof sessionError === 'string' ? sessionError : sessionError.message));
|
||||
}
|
||||
await this.flushCliTrace(traceSession);
|
||||
|
||||
logger.info('Agent process exited:', { code, sessionId: session.sessionId, signal });
|
||||
session.process = undefined;
|
||||
|
||||
// If *we* killed it (cancel / stop / before-quit), treat the non-zero
|
||||
// exit as a clean shutdown — surfacing it as an error would make a
|
||||
// user-initiated cancel look like an agent failure, and an Electron
|
||||
// shutdown affecting OTHER running CC sessions would pollute their
|
||||
// topics with a misleading "Agent exited with code 143" message.
|
||||
if (session.cancelledByUs) {
|
||||
this.broadcast('heteroAgentSessionComplete', { sessionId: session.sessionId });
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
if (code === 0) {
|
||||
this.broadcast('heteroAgentSessionComplete', { sessionId: session.sessionId });
|
||||
resolve();
|
||||
} else {
|
||||
const stderrOutput = stderrChunks.join('').trim();
|
||||
const errorMsg = this.getExitErrorMessage(code, session, stderrOutput);
|
||||
const sessionError = this.getSessionErrorPayload(errorMsg, session);
|
||||
this.broadcast('heteroAgentSessionError', {
|
||||
error: sessionError,
|
||||
sessionId: session.sessionId,
|
||||
});
|
||||
reject(
|
||||
new Error(typeof sessionError === 'string' ? sessionError : sessionError.message),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { constants } from 'node:fs';
|
||||
import { access, mkdir, readFile, realpath, rm, writeFile } from 'node:fs/promises';
|
||||
import { access, mkdir, readFile, realpath, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
@@ -24,6 +24,9 @@ import {
|
||||
type PickFileResult,
|
||||
type PrepareSkillDirectoryParams,
|
||||
type PrepareSkillDirectoryResult,
|
||||
type ProjectFileIndexEntry,
|
||||
type ProjectFileIndexParams,
|
||||
type ProjectFileIndexResult,
|
||||
type RenameLocalFileResult,
|
||||
type ResolveSkillResourcePathParams,
|
||||
type ResolveSkillResourcePathResult,
|
||||
@@ -42,6 +45,7 @@ import {
|
||||
writeLocalFile,
|
||||
} from '@lobechat/local-file-shell';
|
||||
import { dialog, shell } from 'electron';
|
||||
import { execa } from 'execa';
|
||||
import { unzipSync } from 'fflate';
|
||||
|
||||
import { type FileResult, type SearchOptions } from '@/modules/fileSearch';
|
||||
@@ -81,6 +85,50 @@ const resolveNearestExistingRealPath = async (targetPath: string): Promise<strin
|
||||
}
|
||||
};
|
||||
|
||||
const toPosixRelativePath = (filePath: string) => filePath.split(path.sep).join('/');
|
||||
|
||||
const createProjectFileEntry = (
|
||||
root: string,
|
||||
absolutePath: string,
|
||||
isDirectory: boolean,
|
||||
): ProjectFileIndexEntry => {
|
||||
const relativePath = toPosixRelativePath(path.relative(root, absolutePath));
|
||||
|
||||
return {
|
||||
isDirectory,
|
||||
name: path.basename(absolutePath),
|
||||
path: absolutePath,
|
||||
relativePath: isDirectory ? `${relativePath}/` : relativePath,
|
||||
};
|
||||
};
|
||||
|
||||
const collectProjectDirectories = (files: string[], root: string): ProjectFileIndexEntry[] => {
|
||||
const directories = new Set<string>();
|
||||
|
||||
for (const filePath of files) {
|
||||
let current = path.dirname(filePath);
|
||||
while (current && current !== root && current.startsWith(`${root}${path.sep}`)) {
|
||||
if (directories.has(current)) break;
|
||||
directories.add(current);
|
||||
current = path.dirname(current);
|
||||
}
|
||||
}
|
||||
|
||||
return [...directories].map((directory) => createProjectFileEntry(root, directory, true));
|
||||
};
|
||||
|
||||
const createDetectedProjectFileEntry = async (
|
||||
root: string,
|
||||
absolutePath: string,
|
||||
): Promise<ProjectFileIndexEntry> => {
|
||||
try {
|
||||
const stats = await stat(absolutePath);
|
||||
return createProjectFileEntry(root, absolutePath, stats.isDirectory());
|
||||
} catch {
|
||||
return createProjectFileEntry(root, absolutePath, false);
|
||||
}
|
||||
};
|
||||
|
||||
const resolveSafePathRealPrefixes = async (): Promise<string[]> => {
|
||||
const prefixes = new Set<string>(SAFE_PATH_PREFIXES);
|
||||
|
||||
@@ -413,14 +461,127 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
|
||||
// ==================== Search & Find ====================
|
||||
|
||||
@IpcMethod()
|
||||
async getProjectFileIndex(params: ProjectFileIndexParams = {}): Promise<ProjectFileIndexResult> {
|
||||
const requestedScope = params.scope || process.cwd();
|
||||
const startedAt = Date.now();
|
||||
|
||||
try {
|
||||
const rootResult = await execa(
|
||||
'git',
|
||||
['-C', requestedScope, 'rev-parse', '--show-toplevel'],
|
||||
{
|
||||
reject: false,
|
||||
timeout: 5000,
|
||||
},
|
||||
);
|
||||
const root = rootResult.exitCode === 0 ? rootResult.stdout.trim() : requestedScope;
|
||||
|
||||
if (rootResult.exitCode === 0) {
|
||||
const [trackedResult, untrackedResult] = await Promise.all([
|
||||
execa(
|
||||
'git',
|
||||
['-C', root, '-c', 'core.quotepath=false', 'ls-files', '--recurse-submodules'],
|
||||
{
|
||||
reject: false,
|
||||
timeout: 10_000,
|
||||
},
|
||||
),
|
||||
execa(
|
||||
'git',
|
||||
[
|
||||
'-C',
|
||||
root,
|
||||
'-c',
|
||||
'core.quotepath=false',
|
||||
'ls-files',
|
||||
'--others',
|
||||
'--exclude-standard',
|
||||
],
|
||||
{ reject: false, timeout: 10_000 },
|
||||
),
|
||||
]);
|
||||
|
||||
if (trackedResult.exitCode !== 0) {
|
||||
throw new Error(trackedResult.stderr || 'git ls-files failed');
|
||||
}
|
||||
|
||||
const files = [
|
||||
...trackedResult.stdout.split('\n'),
|
||||
...(untrackedResult.exitCode === 0 ? untrackedResult.stdout.split('\n') : []),
|
||||
]
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.map((relativePath) => path.resolve(root, relativePath));
|
||||
|
||||
const seen = new Set<string>();
|
||||
const fileEntries = files
|
||||
.filter((filePath) => {
|
||||
if (seen.has(filePath)) return false;
|
||||
seen.add(filePath);
|
||||
return true;
|
||||
})
|
||||
.map((filePath) => createProjectFileEntry(root, filePath, false));
|
||||
|
||||
const entries = [...collectProjectDirectories(files, root), ...fileEntries];
|
||||
logger.debug('Project file index built from git', {
|
||||
duration: Date.now() - startedAt,
|
||||
entries: entries.length,
|
||||
files: fileEntries.length,
|
||||
requestedScope,
|
||||
root,
|
||||
});
|
||||
|
||||
return {
|
||||
entries,
|
||||
indexedAt: new Date().toISOString(),
|
||||
root,
|
||||
source: 'git',
|
||||
totalCount: entries.length,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug('Git project file index failed, falling back to glob', {
|
||||
error,
|
||||
requestedScope,
|
||||
});
|
||||
}
|
||||
|
||||
const fallback = await this.searchService.glob({ pattern: '**/*', scope: requestedScope });
|
||||
const files = fallback.files.map((filePath) => path.resolve(filePath));
|
||||
const entries = await Promise.all(
|
||||
files.map((filePath) => createDetectedProjectFileEntry(requestedScope, filePath)),
|
||||
);
|
||||
|
||||
logger.debug('Project file index built from glob', {
|
||||
duration: Date.now() - startedAt,
|
||||
entries: entries.length,
|
||||
engine: fallback.engine,
|
||||
requestedScope,
|
||||
});
|
||||
|
||||
return {
|
||||
entries,
|
||||
indexedAt: new Date().toISOString(),
|
||||
root: requestedScope,
|
||||
source: 'glob',
|
||||
totalCount: entries.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle IPC event for local file search
|
||||
*/
|
||||
@IpcMethod()
|
||||
async handleLocalFilesSearch(params: LocalSearchFilesParams): Promise<FileResult[]> {
|
||||
const effectiveDirectory = params.directory ?? params.scope;
|
||||
|
||||
logger.debug('Received file search request:', {
|
||||
directory: params.directory,
|
||||
effectiveDirectory,
|
||||
limit: params.limit,
|
||||
keywords: params.keywords,
|
||||
scope: params.scope,
|
||||
});
|
||||
|
||||
// Build search options from params, mapping directory to onlyIn
|
||||
@@ -436,7 +597,7 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
liveUpdate: params.liveUpdate,
|
||||
modifiedAfter: params.modifiedAfter ? new Date(params.modifiedAfter) : undefined,
|
||||
modifiedBefore: params.modifiedBefore ? new Date(params.modifiedBefore) : undefined,
|
||||
onlyIn: params.directory, // Map directory param to onlyIn option
|
||||
onlyIn: effectiveDirectory,
|
||||
sortBy: params.sortBy,
|
||||
sortDirection: params.sortDirection,
|
||||
};
|
||||
@@ -446,6 +607,14 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
logger.debug('File search completed', {
|
||||
count: results.length,
|
||||
directory: params.directory,
|
||||
effectiveDirectory,
|
||||
results: results.slice(0, 5).map((result) => ({
|
||||
engine: result.engine,
|
||||
isDirectory: result.isDirectory,
|
||||
name: result.name,
|
||||
path: result.path,
|
||||
})),
|
||||
scope: params.scope,
|
||||
});
|
||||
return results;
|
||||
} catch (error) {
|
||||
|
||||
@@ -155,6 +155,9 @@ export default class NotificationCtr extends ControllerModule {
|
||||
const mainWindow = this.app.browserManager.getMainWindow();
|
||||
mainWindow.show();
|
||||
mainWindow.browserWindow.focus();
|
||||
if (params.navigate?.path) {
|
||||
mainWindow.broadcast('navigate', params.navigate);
|
||||
}
|
||||
});
|
||||
|
||||
notification.on('close', () => {
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import type { UploadFileParams } from '@lobechat/electron-client-ipc';
|
||||
|
||||
import FileService from '@/services/fileSrv';
|
||||
|
||||
import { ControllerModule, IpcMethod } from './index';
|
||||
|
||||
export default class UploadFileCtr extends ControllerModule {
|
||||
static override readonly groupName = 'upload';
|
||||
private get fileService() {
|
||||
return this.app.getService(FileService);
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async uploadFile(params: UploadFileParams) {
|
||||
return this.fileService.uploadFile(params);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ vi.mock('electron', () => ({
|
||||
BrowserWindow: { getAllWindows: () => [] },
|
||||
app: {
|
||||
getPath: vi.fn((name: string) => (name === 'desktop' ? FAKE_DESKTOP_PATH : `/fake/${name}`)),
|
||||
isPackaged: false,
|
||||
on: vi.fn(),
|
||||
},
|
||||
ipcMain: { handle: vi.fn() },
|
||||
@@ -56,9 +57,11 @@ vi.mock('node:child_process', async (importOriginal) => {
|
||||
*/
|
||||
const createFakeProc = ({
|
||||
exitCode = 0,
|
||||
stderrLines = [],
|
||||
stdoutLines = [],
|
||||
}: {
|
||||
exitCode?: number;
|
||||
stderrLines?: string[];
|
||||
stdoutLines?: string[];
|
||||
} = {}) => {
|
||||
const proc = new EventEmitter() as any;
|
||||
@@ -86,6 +89,9 @@ const createFakeProc = ({
|
||||
for (const line of stdoutLines) {
|
||||
stdout.write(line);
|
||||
}
|
||||
for (const line of stderrLines) {
|
||||
stderr.write(line);
|
||||
}
|
||||
stdout.end();
|
||||
stderr.end();
|
||||
proc.emit('exit', exitCode);
|
||||
@@ -179,6 +185,7 @@ describe('HeterogeneousAgentCtr', () => {
|
||||
prompt: string,
|
||||
sessionOverrides: Record<string, any> = {},
|
||||
stdoutLines: string[] = [],
|
||||
sendPromptOverrides: Partial<{ imageList: Array<{ id: string; url: string }> }> = {},
|
||||
) => {
|
||||
const { proc, writes } = createFakeProc({ stdoutLines });
|
||||
nextFakeProc = proc;
|
||||
@@ -192,7 +199,7 @@ describe('HeterogeneousAgentCtr', () => {
|
||||
command: 'claude',
|
||||
...sessionOverrides,
|
||||
});
|
||||
await ctr.sendPrompt({ prompt, sessionId });
|
||||
await ctr.sendPrompt({ prompt, sessionId, ...sendPromptOverrides });
|
||||
|
||||
const { args: cliArgs, command, options } = spawnCalls[0];
|
||||
return { cliArgs, command, ctr, options, sessionId, writes };
|
||||
@@ -255,6 +262,23 @@ describe('HeterogeneousAgentCtr', () => {
|
||||
expect(options.cwd).toBe(explicitCwd);
|
||||
});
|
||||
|
||||
it('omits the empty text block when only images are attached', async () => {
|
||||
const { writes } = await runSendPrompt('', {}, [], {
|
||||
imageList: [{ id: 'image-1', url: 'data:image/png;base64,UE5HX1RFU1Q=' }],
|
||||
});
|
||||
|
||||
expect(writes).toHaveLength(1);
|
||||
const msg = JSON.parse(writes[0].trimEnd());
|
||||
// Anthropic rejects `{ text: '', type: 'text' }` with
|
||||
// "messages: text content blocks must be non-empty".
|
||||
expect(msg.message.content).toEqual([
|
||||
{
|
||||
source: { data: 'UE5HX1RFU1Q=', media_type: 'image/png', type: 'base64' },
|
||||
type: 'image',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('captures the Claude Code session id from stream-json init events', async () => {
|
||||
const { ctr, sessionId } = await runSendPrompt('hello', {}, [
|
||||
`${JSON.stringify({ session_id: 'sess_cc_123', subtype: 'init', type: 'system' })}\n`,
|
||||
@@ -381,8 +405,9 @@ describe('HeterogeneousAgentCtr', () => {
|
||||
expect(command).toBe('codex');
|
||||
expect(cliArgs).not.toContain(prompt);
|
||||
expect(cliArgs).toEqual(
|
||||
expect.arrayContaining(['exec', '--json', '--skip-git-repo-check', '--full-auto', '-']),
|
||||
expect.arrayContaining(['exec', '--json', '--skip-git-repo-check', '--full-auto']),
|
||||
);
|
||||
expect(cliArgs).not.toContain('-');
|
||||
expect(writes).toEqual([prompt]);
|
||||
});
|
||||
|
||||
@@ -398,8 +423,11 @@ describe('HeterogeneousAgentCtr', () => {
|
||||
const imagePaths = getFlagValues(cliArgs, '--image');
|
||||
|
||||
expect(cliArgs).not.toContain('describe these screenshots');
|
||||
expect(cliArgs).not.toContain('-');
|
||||
expect(cliArgs.filter((arg) => arg === '--image')).toHaveLength(2);
|
||||
expect(imagePaths).toHaveLength(2);
|
||||
expect(imagePaths).not.toContain('-');
|
||||
expect(cliArgs.at(-1)).toBe(imagePaths[1]);
|
||||
expect(imagePaths[0]).toMatch(/\.png$/);
|
||||
expect(imagePaths[1]).toMatch(/\.jpg$/);
|
||||
expect(
|
||||
@@ -413,22 +441,94 @@ describe('HeterogeneousAgentCtr', () => {
|
||||
expect(writes).toEqual(['describe these screenshots']);
|
||||
});
|
||||
|
||||
it('skips images that fail to materialize and still forwards the remaining --image args', async () => {
|
||||
it('normalizes parameterized image MIME types before choosing the CLI file extension', async () => {
|
||||
const imageList = [
|
||||
{ id: 'image-with-params', url: 'data:image/png;charset=utf-8;base64,UE5HX1RFU1Q=' },
|
||||
];
|
||||
const { cliArgs } = await runSendPrompt('describe this screenshot', {}, [], { imageList });
|
||||
|
||||
const imagePaths = getFlagValues(cliArgs, '--image');
|
||||
|
||||
expect(imagePaths).toHaveLength(1);
|
||||
expect(imagePaths[0]).toMatch(/\.png$/);
|
||||
await expect(readFile(imagePaths[0], 'utf8')).resolves.toBe('PNG_TEST');
|
||||
});
|
||||
|
||||
it('sniffs image bytes when MIME and URL do not expose a usable extension', async () => {
|
||||
const pngBytes = Buffer.concat([
|
||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||
Buffer.from('PNG_TEST'),
|
||||
]);
|
||||
const imageList = [
|
||||
{
|
||||
id: 'image-octet',
|
||||
url: `data:application/octet-stream;base64,${pngBytes.toString('base64')}`,
|
||||
},
|
||||
];
|
||||
const { cliArgs } = await runSendPrompt('describe this screenshot', {}, [], { imageList });
|
||||
|
||||
const imagePaths = getFlagValues(cliArgs, '--image');
|
||||
|
||||
expect(imagePaths).toHaveLength(1);
|
||||
expect(imagePaths[0]).toMatch(/\.png$/);
|
||||
await expect(readFile(imagePaths[0])).resolves.toEqual(pngBytes);
|
||||
});
|
||||
|
||||
it('fails before spawning Codex when any image cannot be materialized', async () => {
|
||||
const imageList = [
|
||||
{ id: 'good-image', url: 'data:image/png;base64,VkFMSURfSU1BR0U=' },
|
||||
{ id: 'bad-image', url: 'bad://broken-image' },
|
||||
];
|
||||
const { cliArgs, writes } = await runSendPrompt('inspect the valid screenshot only', {}, [], {
|
||||
imageList,
|
||||
const { proc } = createFakeProc();
|
||||
nextFakeProc = proc;
|
||||
const ctr = new HeterogeneousAgentCtr({
|
||||
appStoragePath,
|
||||
storeManager: { get: vi.fn() },
|
||||
} as any);
|
||||
const { sessionId } = await ctr.startSession({
|
||||
agentType: 'codex',
|
||||
command: 'codex',
|
||||
});
|
||||
|
||||
const imagePaths = getFlagValues(cliArgs, '--image');
|
||||
await expect(
|
||||
ctr.sendPrompt({
|
||||
imageList,
|
||||
prompt: 'inspect the screenshots',
|
||||
sessionId,
|
||||
}),
|
||||
).rejects.toThrow('Failed to attach image(s) to CLI');
|
||||
expect(spawnCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
expect(cliArgs.filter((arg) => arg === '--image')).toHaveLength(1);
|
||||
expect(imagePaths).toHaveLength(1);
|
||||
expect(imagePaths[0]).toMatch(/\.png$/);
|
||||
await expect(readFile(imagePaths[0], 'utf8')).resolves.toBe('VALID_IMAGE');
|
||||
expect(writes).toEqual(['inspect the valid screenshot only']);
|
||||
it('does not surface Codex stderr status and warn logs as the terminal error', async () => {
|
||||
const { proc } = createFakeProc({
|
||||
exitCode: 1,
|
||||
stderrLines: [
|
||||
'Reading prompt from stdin...\n',
|
||||
'2026-04-25T09:24:08.165782Z WARN codex_core::session_startup_prewarm: startup websocket prewarm setup failed\n',
|
||||
'<html>\n',
|
||||
' <body>challenge page</body>\n',
|
||||
'</html>\n',
|
||||
],
|
||||
stdoutLines: [
|
||||
`${JSON.stringify({ thread_id: 'thread_codex_123', type: 'thread.started' })}\n`,
|
||||
`${JSON.stringify({ type: 'turn.started' })}\n`,
|
||||
`${JSON.stringify({ message: 'real Codex JSONL error', type: 'error' })}\n`,
|
||||
],
|
||||
});
|
||||
nextFakeProc = proc;
|
||||
const ctr = new HeterogeneousAgentCtr({
|
||||
appStoragePath,
|
||||
storeManager: { get: vi.fn() },
|
||||
} as any);
|
||||
const { sessionId } = await ctr.startSession({
|
||||
agentType: 'codex',
|
||||
command: 'codex',
|
||||
});
|
||||
|
||||
await expect(ctr.sendPrompt({ prompt: 'hello', sessionId })).rejects.toThrow(
|
||||
'Agent exited with code 1',
|
||||
);
|
||||
});
|
||||
|
||||
it('uses codex exec resume syntax when continuing an existing thread', async () => {
|
||||
@@ -437,9 +537,73 @@ describe('HeterogeneousAgentCtr', () => {
|
||||
expect(cliArgs.slice(0, 2)).toEqual(['exec', 'resume']);
|
||||
expect(cliArgs).toContain('thread_abc');
|
||||
expect(cliArgs).not.toContain('--resume');
|
||||
expect(cliArgs.at(-2)).toBe('thread_abc');
|
||||
expect(cliArgs.at(-1)).toBe('-');
|
||||
});
|
||||
|
||||
it('writes raw CLI streams to a dev trace directory grouped by agent type', async () => {
|
||||
const originalNodeEnv = process.env.NODE_ENV;
|
||||
process.env.NODE_ENV = 'development';
|
||||
|
||||
try {
|
||||
const prompt = 'trace this run';
|
||||
const rawLine = `${JSON.stringify({
|
||||
thread_id: 'thread_codex_trace',
|
||||
type: 'thread.started',
|
||||
})}\n`;
|
||||
const { sessionId } = await runSendPrompt(prompt, { cwd: appStoragePath }, [rawLine], {
|
||||
imageList: [{ id: 'image-1', url: 'data:image/png;base64,UE5HX1RFU1Q=' }],
|
||||
});
|
||||
const traceRoot = path.join(appStoragePath, '.heerogeneous-tracing');
|
||||
const agentTraceRoot = path.join(traceRoot, 'codex');
|
||||
const traceDirs = await readdir(agentTraceRoot);
|
||||
|
||||
expect(traceDirs).toHaveLength(1);
|
||||
|
||||
const traceDir = path.join(agentTraceRoot, traceDirs[0]);
|
||||
|
||||
await expect(readFile(path.join(traceRoot, '.last-live-trace'), 'utf8')).resolves.toBe(
|
||||
`${traceDir}\n`,
|
||||
);
|
||||
await expect(readFile(path.join(traceDir, 'stdin.txt'), 'utf8')).resolves.toBe(prompt);
|
||||
await expect(readFile(path.join(traceDir, 'stdout.jsonl'), 'utf8')).resolves.toBe(rawLine);
|
||||
await expect(readFile(path.join(traceDir, 'stderr.log'), 'utf8')).resolves.toBe('');
|
||||
await expect(readFile(path.join(traceDir, 'exit.json'), 'utf8')).resolves.toContain(
|
||||
'"code": 0',
|
||||
);
|
||||
|
||||
const meta = JSON.parse(await readFile(path.join(traceDir, 'meta.json'), 'utf8'));
|
||||
|
||||
expect(meta).toMatchObject({
|
||||
agentType: 'codex',
|
||||
command: 'codex',
|
||||
cwd: appStoragePath,
|
||||
sessionId,
|
||||
stdinBytes: Buffer.byteLength(prompt),
|
||||
stdoutFile: 'stdout.jsonl',
|
||||
});
|
||||
expect(meta.args).not.toContain('-');
|
||||
expect(meta.attachments).toEqual([{ id: 'image-1', urlKind: 'data' }]);
|
||||
} finally {
|
||||
process.env.NODE_ENV = originalNodeEnv;
|
||||
}
|
||||
});
|
||||
|
||||
it('skips trace creation (and never auto-creates the cwd) when the cwd is missing', async () => {
|
||||
const originalNodeEnv = process.env.NODE_ENV;
|
||||
process.env.NODE_ENV = 'development';
|
||||
|
||||
const missingCwd = path.join(appStoragePath, 'does-not-exist');
|
||||
|
||||
try {
|
||||
await runSendPrompt('trace this run', { cwd: missingCwd });
|
||||
|
||||
await expect(access(missingCwd)).rejects.toThrow();
|
||||
} finally {
|
||||
process.env.NODE_ENV = originalNodeEnv;
|
||||
}
|
||||
});
|
||||
|
||||
it('captures the Codex thread id from json output for later resume', async () => {
|
||||
const { ctr, sessionId } = await runSendPrompt('hello', {}, [
|
||||
`${JSON.stringify({ thread_id: 'thread_codex_123', type: 'thread.started' })}\n`,
|
||||
|
||||
@@ -5,7 +5,8 @@ import { type App } from '@/core/App';
|
||||
|
||||
import LocalFileCtr from '../LocalFileCtr';
|
||||
|
||||
const { ipcMainHandleMock, fetchMock } = vi.hoisted(() => ({
|
||||
const { execaMock, ipcMainHandleMock, fetchMock } = vi.hoisted(() => ({
|
||||
execaMock: vi.fn(),
|
||||
ipcMainHandleMock: vi.fn(),
|
||||
fetchMock: vi.fn(),
|
||||
}));
|
||||
@@ -14,6 +15,10 @@ vi.mock('@/utils/net-fetch', () => ({
|
||||
netFetch: fetchMock,
|
||||
}));
|
||||
|
||||
vi.mock('execa', () => ({
|
||||
execa: execaMock,
|
||||
}));
|
||||
|
||||
// Mock logger
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
@@ -535,6 +540,18 @@ describe('LocalFileCtr', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should use scope as the default search directory', async () => {
|
||||
mockSearchService.search.mockResolvedValue([]);
|
||||
|
||||
await localFileCtr.handleLocalFilesSearch({ keywords: 'src', scope: '/workspace/project' });
|
||||
|
||||
expect(mockSearchService.search).toHaveBeenCalledWith('src', {
|
||||
keywords: 'src',
|
||||
limit: 30,
|
||||
onlyIn: '/workspace/project',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty array on search error', async () => {
|
||||
mockSearchService.search.mockRejectedValue(new Error('Search failed'));
|
||||
|
||||
@@ -544,6 +561,94 @@ describe('LocalFileCtr', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getProjectFileIndex', () => {
|
||||
it('should build a project file index from git files', async () => {
|
||||
execaMock
|
||||
.mockResolvedValueOnce({ exitCode: 0, stdout: '/workspace/project' })
|
||||
.mockResolvedValueOnce({
|
||||
exitCode: 0,
|
||||
stdout: 'src/index.ts\nsrc/components/Button.tsx',
|
||||
})
|
||||
.mockResolvedValueOnce({ exitCode: 0, stdout: 'tmp/local.ts' });
|
||||
|
||||
const result = await localFileCtr.getProjectFileIndex({ scope: '/workspace/project' });
|
||||
|
||||
expect(result.source).toBe('git');
|
||||
expect(result.root).toBe('/workspace/project');
|
||||
expect(result.entries).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
isDirectory: true,
|
||||
path: '/workspace/project/src',
|
||||
relativePath: 'src/',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
isDirectory: false,
|
||||
path: '/workspace/project/src/index.ts',
|
||||
relativePath: 'src/index.ts',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
isDirectory: false,
|
||||
path: '/workspace/project/tmp/local.ts',
|
||||
relativePath: 'tmp/local.ts',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(result.totalCount).toBe(result.entries.length);
|
||||
});
|
||||
|
||||
it('should fall back to glob when git indexing fails', async () => {
|
||||
execaMock.mockResolvedValueOnce({ exitCode: 1, stdout: '' });
|
||||
mockSearchService.glob.mockResolvedValue({
|
||||
engine: 'fast-glob',
|
||||
files: ['/workspace/project/src', '/workspace/project/src/index.ts'],
|
||||
success: true,
|
||||
total_files: 2,
|
||||
});
|
||||
vi.mocked(mockFsPromises.stat).mockImplementation(async (filePath: string) => ({
|
||||
isDirectory: () => filePath === '/workspace/project/src',
|
||||
}));
|
||||
|
||||
const result = await localFileCtr.getProjectFileIndex({ scope: '/workspace/project' });
|
||||
|
||||
expect(result.source).toBe('glob');
|
||||
expect(result.entries).toEqual([
|
||||
expect.objectContaining({
|
||||
isDirectory: true,
|
||||
path: '/workspace/project/src',
|
||||
relativePath: 'src/',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
isDirectory: false,
|
||||
path: '/workspace/project/src/index.ts',
|
||||
relativePath: 'src/index.ts',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should mark glob entries as files when stat fails', async () => {
|
||||
execaMock.mockResolvedValueOnce({ exitCode: 1, stdout: '' });
|
||||
mockSearchService.glob.mockResolvedValue({
|
||||
engine: 'fast-glob',
|
||||
files: ['/workspace/project/src/index.ts'],
|
||||
success: true,
|
||||
total_files: 1,
|
||||
});
|
||||
vi.mocked(mockFsPromises.stat).mockRejectedValue(new Error('missing'));
|
||||
|
||||
const result = await localFileCtr.getProjectFileIndex({ scope: '/workspace/project' });
|
||||
|
||||
expect(result.source).toBe('glob');
|
||||
expect(result.entries).toEqual([
|
||||
expect.objectContaining({
|
||||
isDirectory: false,
|
||||
path: '/workspace/project/src/index.ts',
|
||||
relativePath: 'src/index.ts',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleGlobFiles', () => {
|
||||
it('should glob files successfully', async () => {
|
||||
const mockResult = {
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { App } from '@/core/App';
|
||||
import { IpcHandler } from '@/utils/ipc/base';
|
||||
|
||||
import UploadFileCtr from '../UploadFileCtr';
|
||||
|
||||
const { ipcHandlers, ipcMainHandleMock } = vi.hoisted(() => {
|
||||
const handlers = new Map<string, (event: any, ...args: any[]) => any>();
|
||||
const handle = vi.fn((channel: string, handler: any) => {
|
||||
handlers.set(channel, handler);
|
||||
});
|
||||
return { ipcHandlers: handlers, ipcMainHandleMock: handle };
|
||||
});
|
||||
|
||||
const invokeIpc = async <T = any>(channel: string, payload?: any): Promise<T> => {
|
||||
const handler = ipcHandlers.get(channel);
|
||||
if (!handler) throw new Error(`IPC handler for ${channel} not found`);
|
||||
|
||||
const fakeEvent = { sender: { id: 'test' } as any };
|
||||
if (payload === undefined) return handler(fakeEvent);
|
||||
return handler(fakeEvent, payload);
|
||||
};
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
handle: ipcMainHandleMock,
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock FileService module to prevent electron dependency issues
|
||||
vi.mock('@/services/fileSrv', () => ({
|
||||
default: class MockFileService {},
|
||||
}));
|
||||
|
||||
// Mock FileService instance methods
|
||||
const mockFileService = {
|
||||
uploadFile: vi.fn(),
|
||||
};
|
||||
|
||||
const mockApp = {
|
||||
getService: vi.fn(() => mockFileService),
|
||||
} as unknown as App;
|
||||
|
||||
describe('UploadFileCtr', () => {
|
||||
let _controller: UploadFileCtr;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
ipcHandlers.clear();
|
||||
ipcMainHandleMock.mockClear();
|
||||
(IpcHandler.getInstance() as any).registeredChannels?.clear();
|
||||
_controller = new UploadFileCtr(mockApp);
|
||||
});
|
||||
|
||||
describe('uploadFile', () => {
|
||||
it('should upload file successfully', async () => {
|
||||
const params = {
|
||||
hash: 'abc123',
|
||||
path: '/test/file.txt',
|
||||
content: new ArrayBuffer(16),
|
||||
filename: 'file.txt',
|
||||
type: 'text/plain',
|
||||
};
|
||||
const expectedResult = { id: 'file-id-123', url: '/files/file-id-123' };
|
||||
mockFileService.uploadFile.mockResolvedValue(expectedResult);
|
||||
|
||||
const result = await invokeIpc('upload.uploadFile', params);
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
expect(mockFileService.uploadFile).toHaveBeenCalledWith(params);
|
||||
});
|
||||
|
||||
it('should handle upload error', async () => {
|
||||
const params = {
|
||||
hash: 'abc123',
|
||||
path: '/test/file.txt',
|
||||
content: new ArrayBuffer(16),
|
||||
filename: 'file.txt',
|
||||
type: 'text/plain',
|
||||
};
|
||||
const error = new Error('Upload failed');
|
||||
mockFileService.uploadFile.mockRejectedValue(error);
|
||||
|
||||
await expect(invokeIpc('upload.uploadFile', params)).rejects.toThrow('Upload failed');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -22,7 +22,6 @@ import SystemController from './SystemCtr';
|
||||
import ToolDetectorCtr from './ToolDetectorCtr';
|
||||
import TrayMenuCtr from './TrayMenuCtr';
|
||||
import UpdaterCtr from './UpdaterCtr';
|
||||
import UploadFileCtr from './UploadFileCtr';
|
||||
|
||||
export const controllerIpcConstructors = [
|
||||
HeterogeneousAgentCtr,
|
||||
@@ -47,7 +46,6 @@ export const controllerIpcConstructors = [
|
||||
ToolDetectorCtr,
|
||||
TrayMenuCtr,
|
||||
UpdaterCtr,
|
||||
UploadFileCtr,
|
||||
] as const satisfies readonly IpcServiceConstructor[];
|
||||
|
||||
type DesktopControllerIpcConstructors = typeof controllerIpcConstructors;
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { mkdtemp, rename, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { CodexFileChangeTracker } from './codexFileChangeTracker';
|
||||
|
||||
describe('CodexFileChangeTracker', () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempDirs.map((dir) => rm(dir, { force: true, recursive: true })));
|
||||
tempDirs.length = 0;
|
||||
});
|
||||
|
||||
it('enriches completed file_change payloads with per-file and total line stats', async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'codex-file-change-tracker-'));
|
||||
tempDirs.push(dir);
|
||||
|
||||
const updatePath = path.join(dir, 'a.txt');
|
||||
const addPath = path.join(dir, 'b.txt');
|
||||
|
||||
await writeFile(updatePath, 'hello\n', 'utf8');
|
||||
|
||||
const tracker = new CodexFileChangeTracker();
|
||||
|
||||
await tracker.track({
|
||||
item: {
|
||||
changes: [
|
||||
{ kind: 'update', path: updatePath },
|
||||
{ kind: 'add', path: addPath },
|
||||
],
|
||||
id: 'item_1',
|
||||
type: 'file_change',
|
||||
},
|
||||
type: 'item.started',
|
||||
});
|
||||
|
||||
await writeFile(updatePath, 'hello\nappended line\n', 'utf8');
|
||||
await writeFile(addPath, 'line one\nline two\n', 'utf8');
|
||||
|
||||
const enriched = await tracker.track({
|
||||
item: {
|
||||
changes: [
|
||||
{ kind: 'update', path: updatePath },
|
||||
{ kind: 'add', path: addPath },
|
||||
],
|
||||
id: 'item_1',
|
||||
type: 'file_change',
|
||||
},
|
||||
type: 'item.completed',
|
||||
});
|
||||
|
||||
expect(enriched.item).toMatchObject({
|
||||
changes: [
|
||||
{
|
||||
kind: 'update',
|
||||
linesAdded: 1,
|
||||
linesDeleted: 0,
|
||||
path: updatePath,
|
||||
},
|
||||
{
|
||||
kind: 'add',
|
||||
linesAdded: 2,
|
||||
linesDeleted: 0,
|
||||
path: addPath,
|
||||
},
|
||||
],
|
||||
linesAdded: 3,
|
||||
linesDeleted: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('treats rename changes as metadata-only and keeps line stats at zero', async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'codex-file-change-tracker-'));
|
||||
tempDirs.push(dir);
|
||||
|
||||
const beforePath = path.join(dir, 'before.txt');
|
||||
const afterPath = path.join(dir, 'after.txt');
|
||||
|
||||
await writeFile(beforePath, 'content\n', 'utf8');
|
||||
|
||||
const tracker = new CodexFileChangeTracker();
|
||||
|
||||
await tracker.track({
|
||||
item: {
|
||||
changes: [{ kind: 'rename', path: afterPath }],
|
||||
id: 'item_rename',
|
||||
type: 'file_change',
|
||||
},
|
||||
type: 'item.started',
|
||||
});
|
||||
|
||||
await rename(beforePath, afterPath);
|
||||
|
||||
const enriched = await tracker.track({
|
||||
item: {
|
||||
changes: [{ kind: 'rename', path: afterPath }],
|
||||
id: 'item_rename',
|
||||
type: 'file_change',
|
||||
},
|
||||
type: 'item.completed',
|
||||
});
|
||||
|
||||
expect(enriched.item).toMatchObject({
|
||||
changes: [{ kind: 'rename', linesAdded: 0, linesDeleted: 0, path: afterPath }],
|
||||
linesAdded: 0,
|
||||
linesDeleted: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('counts added lines even when file content begins with repeated plus markers', async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'codex-file-change-tracker-'));
|
||||
tempDirs.push(dir);
|
||||
|
||||
const addPath = path.join(dir, 'plus-prefixed.txt');
|
||||
const tracker = new CodexFileChangeTracker();
|
||||
|
||||
await tracker.track({
|
||||
item: {
|
||||
changes: [{ kind: 'add', path: addPath }],
|
||||
id: 'item_plus_prefix',
|
||||
type: 'file_change',
|
||||
},
|
||||
type: 'item.started',
|
||||
});
|
||||
|
||||
await writeFile(addPath, '++leading content\n+++header lookalike\n', 'utf8');
|
||||
|
||||
const enriched = await tracker.track({
|
||||
item: {
|
||||
changes: [{ kind: 'add', path: addPath }],
|
||||
id: 'item_plus_prefix',
|
||||
type: 'file_change',
|
||||
},
|
||||
type: 'item.completed',
|
||||
});
|
||||
|
||||
expect(enriched.item).toMatchObject({
|
||||
changes: [{ kind: 'add', linesAdded: 2, linesDeleted: 0, path: addPath }],
|
||||
linesAdded: 2,
|
||||
linesDeleted: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
import { access, readFile } from 'node:fs/promises';
|
||||
|
||||
import { createPatch } from 'diff';
|
||||
|
||||
interface CodexFileChangeEntry {
|
||||
kind?: string;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
interface CodexFileChangeSnapshot {
|
||||
content?: string;
|
||||
exists: boolean;
|
||||
}
|
||||
|
||||
interface CodexFileChangeItem {
|
||||
changes?: CodexFileChangeEntry[];
|
||||
id?: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
interface CodexFileChangePayload {
|
||||
item?: CodexFileChangeItem;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
interface CodexFileChangeLineStats {
|
||||
linesAdded: number;
|
||||
linesDeleted: number;
|
||||
}
|
||||
|
||||
interface CodexTrackedFileChangeEntry extends CodexFileChangeEntry, CodexFileChangeLineStats {}
|
||||
|
||||
interface CodexTrackedFileChangeItem extends CodexFileChangeItem, CodexFileChangeLineStats {
|
||||
changes?: CodexTrackedFileChangeEntry[];
|
||||
}
|
||||
|
||||
const isCodexFileChangePayload = (
|
||||
payload: CodexFileChangePayload,
|
||||
): payload is Required<CodexFileChangePayload> =>
|
||||
payload?.item?.type === 'file_change' && !!payload.item.id;
|
||||
|
||||
const readTextFileSnapshot = async (filePath: string): Promise<CodexFileChangeSnapshot> => {
|
||||
try {
|
||||
await access(filePath);
|
||||
} catch {
|
||||
return { exists: false };
|
||||
}
|
||||
|
||||
try {
|
||||
return {
|
||||
content: await readFile(filePath, 'utf8'),
|
||||
exists: true,
|
||||
};
|
||||
} catch {
|
||||
return { exists: true };
|
||||
}
|
||||
};
|
||||
|
||||
const countPatchLines = (
|
||||
previousContent: string,
|
||||
nextContent: string,
|
||||
): CodexFileChangeLineStats => {
|
||||
if (previousContent === nextContent) return { linesAdded: 0, linesDeleted: 0 };
|
||||
|
||||
const patch = createPatch('codex-file-change', previousContent, nextContent, '', '');
|
||||
let insideHunk = false;
|
||||
let linesAdded = 0;
|
||||
let linesDeleted = 0;
|
||||
|
||||
for (const line of patch.split('\n')) {
|
||||
if (line.startsWith('@@')) {
|
||||
insideHunk = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!insideHunk) continue;
|
||||
|
||||
if (line.startsWith('+')) {
|
||||
linesAdded += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('-')) {
|
||||
linesDeleted += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { linesAdded, linesDeleted };
|
||||
};
|
||||
|
||||
const computeLineStats = async (
|
||||
change: CodexFileChangeEntry,
|
||||
snapshot?: CodexFileChangeSnapshot,
|
||||
): Promise<CodexFileChangeLineStats> => {
|
||||
const filePath = change.path;
|
||||
if (!filePath) return { linesAdded: 0, linesDeleted: 0 };
|
||||
|
||||
const kind = change.kind ?? 'update';
|
||||
if (kind === 'rename') return { linesAdded: 0, linesDeleted: 0 };
|
||||
|
||||
const previousContent = snapshot?.content ?? '';
|
||||
const current = await readTextFileSnapshot(filePath);
|
||||
const nextContent = current.content ?? '';
|
||||
|
||||
if (kind === 'add') {
|
||||
if (!current.exists) return { linesAdded: 0, linesDeleted: 0 };
|
||||
return countPatchLines('', nextContent);
|
||||
}
|
||||
|
||||
if (kind === 'delete' || kind === 'remove') {
|
||||
if (!snapshot?.exists) return { linesAdded: 0, linesDeleted: 0 };
|
||||
return countPatchLines(previousContent, '');
|
||||
}
|
||||
|
||||
if (!snapshot?.exists && !current.exists) return { linesAdded: 0, linesDeleted: 0 };
|
||||
|
||||
return countPatchLines(previousContent, nextContent);
|
||||
};
|
||||
|
||||
export class CodexFileChangeTracker {
|
||||
private snapshots = new Map<string, Map<string, CodexFileChangeSnapshot>>();
|
||||
|
||||
async track<T extends CodexFileChangePayload>(payload: T): Promise<T> {
|
||||
if (!isCodexFileChangePayload(payload)) return payload;
|
||||
|
||||
const itemId = payload.item.id;
|
||||
const changes = payload.item.changes ?? [];
|
||||
|
||||
if (payload.type === 'item.started') {
|
||||
const snapshots = new Map<string, CodexFileChangeSnapshot>();
|
||||
|
||||
await Promise.all(
|
||||
changes.map(async (change) => {
|
||||
if (!change.path || snapshots.has(change.path)) return;
|
||||
snapshots.set(change.path, await readTextFileSnapshot(change.path));
|
||||
}),
|
||||
);
|
||||
|
||||
this.snapshots.set(itemId, snapshots);
|
||||
return payload;
|
||||
}
|
||||
|
||||
if (payload.type !== 'item.completed') return payload;
|
||||
|
||||
const snapshots = this.snapshots.get(itemId);
|
||||
this.snapshots.delete(itemId);
|
||||
|
||||
if (!snapshots) return payload;
|
||||
|
||||
const trackedChanges = await Promise.all(
|
||||
changes.map(async (change) => {
|
||||
const stats = await computeLineStats(
|
||||
change,
|
||||
change.path ? snapshots.get(change.path) : undefined,
|
||||
);
|
||||
|
||||
return {
|
||||
...change,
|
||||
...stats,
|
||||
} satisfies CodexTrackedFileChangeEntry;
|
||||
}),
|
||||
);
|
||||
|
||||
const totals = trackedChanges.reduce<CodexFileChangeLineStats>(
|
||||
(acc, change) => ({
|
||||
linesAdded: acc.linesAdded + change.linesAdded,
|
||||
linesDeleted: acc.linesDeleted + change.linesDeleted,
|
||||
}),
|
||||
{ linesAdded: 0, linesDeleted: 0 },
|
||||
);
|
||||
|
||||
return {
|
||||
...payload,
|
||||
item: {
|
||||
...payload.item,
|
||||
...totals,
|
||||
changes: trackedChanges,
|
||||
} satisfies CodexTrackedFileChangeItem,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ const buildCodexOptionArgs = async ({
|
||||
const imageArgs = imagePaths.flatMap((filePath) => ['--image', filePath]);
|
||||
const autoExecutionArgs = hasAnyFlag(args, CODEX_AUTO_EXECUTION_FLAGS) ? [] : ['--full-auto'];
|
||||
|
||||
return [...CODEX_REQUIRED_ARGS, ...autoExecutionArgs, ...imageArgs, ...args];
|
||||
return [...CODEX_REQUIRED_ARGS, ...autoExecutionArgs, ...args, ...imageArgs];
|
||||
};
|
||||
|
||||
export const codexDriver: HeterogeneousAgentDriver = {
|
||||
@@ -37,7 +37,7 @@ export const codexDriver: HeterogeneousAgentDriver = {
|
||||
return {
|
||||
args: resumeSessionId
|
||||
? ['exec', 'resume', ...optionArgs, resumeSessionId, '-']
|
||||
: ['exec', ...optionArgs, '-'],
|
||||
: ['exec', ...optionArgs],
|
||||
stdinPayload: prompt,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -17,7 +17,7 @@ const isUrl = (value: string) => URL_PATTERN.test(value);
|
||||
const firstGlyph = (value?: string | null) => {
|
||||
if (!value) return '?';
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? Array.from(trimmed)[0] ?? '?' : '?';
|
||||
return trimmed ? (Array.from(trimmed)[0] ?? '?') : '?';
|
||||
};
|
||||
|
||||
const OverlayAvatar = memo<OverlayAvatarProps>(({ avatar, background, size = 18, title }) => {
|
||||
|
||||
@@ -252,7 +252,7 @@ const ChatPanel = memo<ChatPanelProps>(
|
||||
}, [theme]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (selected && !hidden && textareaRef.current) {
|
||||
if (!hidden && textareaRef.current) {
|
||||
textareaRef.current.focus();
|
||||
}
|
||||
}, [hidden, selected]);
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
resolveCommittedSelectionRect,
|
||||
shouldHideChatPanel,
|
||||
} from './overlaySelectionState';
|
||||
import { resolveCommittedSelectionRect, shouldHideChatPanel } from './overlaySelectionState';
|
||||
|
||||
describe('overlaySelectionState', () => {
|
||||
it('keeps the pending selection rect visible until the committed selection arrives', () => {
|
||||
|
||||
@@ -15,8 +15,7 @@ export interface DockResult {
|
||||
top: number;
|
||||
}
|
||||
|
||||
const clamp = (v: number, lo: number, hi: number): number =>
|
||||
Math.max(lo, Math.min(hi, v));
|
||||
const clamp = (v: number, lo: number, hi: number): number => Math.max(lo, Math.min(hi, v));
|
||||
|
||||
export function computeDockPosition({
|
||||
rect,
|
||||
|
||||
@@ -3,9 +3,7 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { getTopmostWindowAtPoint } from './useWindowHighlight';
|
||||
|
||||
const createWindow = (
|
||||
overrides: Partial<ScreenCaptureWindowInfo>,
|
||||
): ScreenCaptureWindowInfo => ({
|
||||
const createWindow = (overrides: Partial<ScreenCaptureWindowInfo>): ScreenCaptureWindowInfo => ({
|
||||
appName: 'Test App',
|
||||
bounds: { height: 300, width: 400, x: 1000, y: 200 },
|
||||
order: 0,
|
||||
|
||||
@@ -2,5 +2,6 @@ export const BRANDING_LOGO_URL = '';
|
||||
export const BRANDING_NAME = 'LobeHub';
|
||||
export const DEFAULT_EMBEDDING_PROVIDER = 'openai';
|
||||
export const DEFAULT_MINI_PROVIDER = 'openai';
|
||||
export const DEFAULT_ONBOARDING_MODEL = 'gemini-3-flash-preview';
|
||||
export const DEFAULT_PROVIDER = 'openai';
|
||||
export const ORG_NAME = 'LobeHub';
|
||||
|
||||
@@ -1,4 +1,21 @@
|
||||
[
|
||||
{
|
||||
"children": {},
|
||||
"date": "2026-04-29",
|
||||
"version": "2.1.55"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["clear stale topic when switching agents from a topic route."]
|
||||
},
|
||||
"date": "2026-04-27",
|
||||
"version": "2.1.54"
|
||||
},
|
||||
{
|
||||
"children": {},
|
||||
"date": "2026-04-20",
|
||||
"version": "2.1.52"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["fix minify cli.", "recent delete."]
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
"https://file.rene.wang/clipboard-1770266335710-1fec523143aab.png": "/blog/assets636c78daf95c590cd7d80284c68eb6d9.webp",
|
||||
"https://file.rene.wang/clipboard-1774923001079-89ce6aa271a62.png": "/blog/assets53e6ec9cf72554dbc1f8224fc0550a03.webp",
|
||||
"https://file.rene.wang/clipboard-1775701725582-123f8f8cf73f8.png": "/blog/assets7ea204859aeb5aa9be5810a20ba1669a.webp",
|
||||
"https://file.rene.wang/clipboard-1776909505252-94b051f3ea0a7.png": "/blog/assetsdfda32866c4bc59af0526e52f31d1da2.webp",
|
||||
"https://file.rene.wang/lobehub/467951f5-ad65-498d-aea9-fca8f35a4314.png": "/blog/assets907ea775d228958baca38e2dbb65939a.webp",
|
||||
"https://file.rene.wang/lobehub/58d91528-373a-4a42-b520-cf6cb1f8ce1e.png": "/blog/assets7dccdd4df55aede71001da649639437f.webp",
|
||||
"https://file.rene.wang/lobehub/ee700103-3c08-41dc-9ddf-c7705bb7bc6a.png": "/blog/assets196d679bc7071abbf71f2a8566f05aa3.webp",
|
||||
@@ -468,7 +469,5 @@
|
||||
"https://github.com/user-attachments/assets/facdc83c-e789-4649-8060-7f7a10a1b1dd": "/blog/assets05b20e40c03ced0ec8707fed2e8e0f25.webp",
|
||||
"https://github.com/user-attachments/assets/fcdfb9c5-819a-488f-b28d-0857fe861219": "/blog/assets8477415ecec1f37e38ab38ff1217d0a7.webp",
|
||||
"https://github.com/user-attachments/assets/fd60ab55-ead2-4930-ad00-fdf77662f5a0": "/blog/assets276a4e8748e9bd300b30dcd9d0e24980.webp",
|
||||
"https://file.rene.wang/clipboard-1775701725582-123f8f8cf73f8.png": "/blog/assets7ea204859aeb5aa9be5810a20ba1669a.webp",
|
||||
"https://file.rene.wang/changlog-04-14.png": "/blog/assets300abe7e259d293da6c5ed4f642a1be6.webp",
|
||||
"https://file.rene.wang/clipboard-1776909505252-94b051f3ea0a7.png": "/blog/assetsdfda32866c4bc59af0526e52f31d1da2.webp"
|
||||
}
|
||||
"https://file.rene.wang/clipboard-1777343750668-9b3dcb0dfff86.png": "/blog/assetsfa267a02f20bc5ba6f1273bcf27b7c9f.webp"
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
---
|
||||
title: 'Plugin System: Extend Your Agents with Community Skills'
|
||||
description: LobeHub now supports a plugin ecosystem that lets Agents access real-time information, interact with external services, and handle specialized tasks without leaving the conversation.
|
||||
description: >-
|
||||
LobeHub now supports a plugin ecosystem that lets Agents access real-time
|
||||
information, interact with external services, and handle specialized tasks
|
||||
without leaving the conversation.
|
||||
tags:
|
||||
- LobeHub
|
||||
- Plugins
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: '插件系统:用社区技能扩展你的助理'
|
||||
title: 插件系统:用社区技能扩展你的助理
|
||||
description: LobeHub 现已支持插件生态,让助理能够获取实时信息、与外部服务交互,并在对话中处理各种专业任务。
|
||||
tags:
|
||||
- LobeHub
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
---
|
||||
title: 'Visual Recognition: Chat With Images, Not Just Text'
|
||||
description: LobeHub now supports multimodal models including GPT-4 Vision, Google Gemini Pro Vision, and GLM-4 Vision. Upload or drag images into conversations and your Agent will understand and respond to visual content.
|
||||
description: >-
|
||||
LobeHub now supports multimodal models including GPT-4 Vision, Google Gemini
|
||||
Pro Vision, and GLM-4 Vision. Upload or drag images into conversations and
|
||||
your Agent will understand and respond to visual content.
|
||||
tags:
|
||||
- Visual Recognition
|
||||
- LobeHub
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
title: '视觉识别:与图片对话,不只是文字'
|
||||
description: LobeHub 现已支持多模态模型,包括 GPT-4 Vision、Google Gemini Pro Vision 和 GLM-4 Vision。上传或拖拽图片到对话中,助理将理解视觉内容并作出回应。
|
||||
title: 视觉识别:与图片对话,不只是文字
|
||||
description: >-
|
||||
LobeHub 现已支持多模态模型,包括 GPT-4 Vision、Google Gemini Pro Vision 和 GLM-4
|
||||
Vision。上传或拖拽图片到对话中,助理将理解视觉内容并作出回应。
|
||||
tags:
|
||||
- 视觉识别
|
||||
- 多模态交互
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
---
|
||||
title: 'Voice Conversations: Talk Naturally With Your Agents'
|
||||
description: LobeHub now supports Text-to-Speech (TTS) and Speech-to-Text (STT), enabling natural voice interactions. Speak with your Agents and hear responses in clear, personalized voices.
|
||||
description: >-
|
||||
LobeHub now supports Text-to-Speech (TTS) and Speech-to-Text (STT), enabling
|
||||
natural voice interactions. Speak with your Agents and hear responses in
|
||||
clear, personalized voices.
|
||||
tags:
|
||||
- TTS
|
||||
- STT
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: '语音会话:与你的助理自然对话'
|
||||
title: 语音会话:与你的助理自然对话
|
||||
description: LobeHub 现已支持文字转语音(TTS)和语音转文字(STT),实现自然的语音交互。与助理对话并听到清晰、个性化的语音回复。
|
||||
tags:
|
||||
- TTS
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
---
|
||||
title: 'Text-to-Image: Create Visuals Directly in Chat'
|
||||
description: LobeHub now supports text-to-image generation. Invoke DALL-E 3, MidJourney, or Pollinations directly during conversations to turn your ideas into images without leaving the chat.
|
||||
description: >-
|
||||
LobeHub now supports text-to-image generation. Invoke DALL-E 3, MidJourney, or
|
||||
Pollinations directly during conversations to turn your ideas into images
|
||||
without leaving the chat.
|
||||
tags:
|
||||
- Text-to-Image
|
||||
- LobeHub
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
title: '文生图:在对话中直接创作视觉内容'
|
||||
description: LobeHub 现已支持文本到图片生成。在对话中直接调用 DALL-E 3、MidJourney 或 Pollinations,无需离开聊天界面即可将想法转化为图像。
|
||||
title: 文生图:在对话中直接创作视觉内容
|
||||
description: >-
|
||||
LobeHub 现已支持文本到图片生成。在对话中直接调用 DALL-E 3、MidJourney 或
|
||||
Pollinations,无需离开聊天界面即可将想法转化为图像。
|
||||
tags:
|
||||
- Text to Image
|
||||
- 文生图
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
title: 灵活适配的认证体系:Clerk 与 Next-Auth 双方案支持
|
||||
description: >-
|
||||
LobeHub 现已支持 Clerk 和 Next-Auth 两种认证方案,让团队可以根据部署模式和安全需求选择最适合的身份验证方式。
|
||||
description: LobeHub 现已支持 Clerk 和 Next-Auth 两种认证方案,让团队可以根据部署模式和安全需求选择最适合的身份验证方式。
|
||||
tags:
|
||||
- 用户管理
|
||||
- 身份验证
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
title: 本地模型与云端 AI 并行使用
|
||||
description: >-
|
||||
LobeHub v0.127.0 新增 Ollama 支持,让你可以用与云端模型相同的界面运行本地大语言模型。
|
||||
description: LobeHub v0.127.0 新增 Ollama 支持,让你可以用与云端模型相同的界面运行本地大语言模型。
|
||||
tags:
|
||||
- Ollama AI
|
||||
- LobeHub
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
---
|
||||
title: LobeHub 1.0:为持久化、多用户协作而生的新架构
|
||||
description: >-
|
||||
LobeHub 1.0 引入服务端数据库支持和完善的用户管理体系,实现知识库、跨设备同步和团队协作能力。
|
||||
LobeHub Cloud 同步开启 Beta 测试,内置全部新特性。
|
||||
LobeHub 1.0 引入服务端数据库支持和完善的用户管理体系,实现知识库、跨设备同步和团队协作能力。 LobeHub Cloud 同步开启 Beta
|
||||
测试,内置全部新特性。
|
||||
tags:
|
||||
- LobeHub
|
||||
- 服务端数据库
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
---
|
||||
title: LobeHub v1.6:GPT-4o mini 成为默认模型选项
|
||||
description: >-
|
||||
LobeHub v1.6 新增 GPT-4o mini 支持,同时 LobeHub Cloud 将默认模型升级为
|
||||
GPT-4o mini,让开箱即用的对话体验更进一步。
|
||||
LobeHub v1.6 新增 GPT-4o mini 支持,同时 LobeHub Cloud 将默认模型升级为 GPT-4o
|
||||
mini,让开箱即用的对话体验更进一步。
|
||||
tags:
|
||||
- LobeHub
|
||||
- GPT-4o mini
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: 'LobeHub Enters the Era of Artifacts'
|
||||
title: LobeHub Enters the Era of Artifacts
|
||||
description: >-
|
||||
LobeHub v1.19 brings significant updates, including full feature support for
|
||||
Claude Artifacts, a brand new discovery page design, and support for GitHub
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: '重磅更新:LobeHub 迎来 Artifacts 时代'
|
||||
title: 重磅更新:LobeHub 迎来 Artifacts 时代
|
||||
description: >-
|
||||
LobeHub v1.19 带来了重大更新,包括 Claude Artifacts 完整特性支持、全新的发现页面设计,以及 GitHub Models
|
||||
服务商支持,让 AI 助手的能力得到显著提升。
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
---
|
||||
title: Export Conversations as Markdown or OpenAI JSON
|
||||
description: >-
|
||||
LobeHub v1.28.0 adds Markdown and OpenAI-format JSON exports, making it
|
||||
easier to turn conversations into documentation, debugging payloads, or
|
||||
training datasets.
|
||||
LobeHub v1.28.0 adds Markdown and OpenAI-format JSON exports, making it easier
|
||||
to turn conversations into documentation, debugging payloads, or training
|
||||
datasets.
|
||||
tags:
|
||||
- Text Format Export
|
||||
- Markdown Export
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
---
|
||||
title: 支持导出对话为 Markdown 或 OpenAI JSON 格式
|
||||
description: >-
|
||||
LobeHub v1.28.0 新增 Markdown 与 OpenAI 格式 JSON 导出,方便将对话转为文档、
|
||||
调试数据或训练语料。
|
||||
description: LobeHub v1.28.0 新增 Markdown 与 OpenAI 格式 JSON 导出,方便将对话转为文档、 调试数据或训练语料。
|
||||
tags:
|
||||
- 文本格式导出
|
||||
- Markdown 导出
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
---
|
||||
title: 11 月更新 - 新增 4 家模型服务商
|
||||
description: >-
|
||||
LobeHub 新增支持 Gitee AI、InternLM、xAI 和 Cloudflare Workers AI,
|
||||
为团队提供更多模型接入选择。
|
||||
description: LobeHub 新增支持 Gitee AI、InternLM、xAI 和 Cloudflare Workers AI, 为团队提供更多模型接入选择。
|
||||
tags:
|
||||
- LobeHub
|
||||
- AI 模型服务
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
title: DeepSeek R1 Integration with Chain-of-Thought Transparency
|
||||
description: LobeHub now supports DeepSeek R1 with real-time reasoning display, making complex problem-solving more transparent and easier to follow.
|
||||
description: >-
|
||||
LobeHub now supports DeepSeek R1 with real-time reasoning display, making
|
||||
complex problem-solving more transparent and easier to follow.
|
||||
tags:
|
||||
- LobeHub
|
||||
- DeepSeek
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
title: "50+ New Models and 10+ Providers Added to the Ecosystem"
|
||||
description: LobeHub expands its AI ecosystem with 50+ new models and 10+ providers, making it easier to access diverse AI capabilities without changing your workflow.
|
||||
title: 50+ New Models and 10+ Providers Added to the Ecosystem
|
||||
description: >-
|
||||
LobeHub expands its AI ecosystem with 50+ new models and 10+ providers, making
|
||||
it easier to access diverse AI capabilities without changing your workflow.
|
||||
tags:
|
||||
- LobeHub
|
||||
- Model Providers
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: "AI 生态扩展:新增 50+ 模型与 10+ 服务商"
|
||||
title: AI 生态扩展:新增 50+ 模型与 10+ 服务商
|
||||
description: LobeHub 完成史上最大规模 AI 生态扩展,新增 50+ 模型和 10+ 服务商,让你无需改变工作流程即可接入更多 AI 能力。
|
||||
tags:
|
||||
- LobeHub
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
title: "Customizable Hotkeys, Data Export, and Provider Expansion"
|
||||
description: LobeHub adds customizable hotkeys, data export functionality, and expands provider support to make daily workflows smoother and more portable.
|
||||
title: 'Customizable Hotkeys, Data Export, and Provider Expansion'
|
||||
description: >-
|
||||
LobeHub adds customizable hotkeys, data export functionality, and expands
|
||||
provider support to make daily workflows smoother and more portable.
|
||||
tags:
|
||||
- LobeHub
|
||||
- Hotkeys
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: "快捷键自定义、数据导出与服务商扩展"
|
||||
title: 快捷键自定义、数据导出与服务商扩展
|
||||
description: LobeHub 新增快捷键自定义、数据导出功能,并扩展服务商支持,让日常使用更顺手、数据更可迁移。
|
||||
tags:
|
||||
- LobeHub
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
title: "Lobe UI v2 Design System and Desktop App Launch"
|
||||
description: LobeHub launches a refreshed visual design with Lobe UI v2 and officially releases the desktop app for Windows and macOS.
|
||||
title: Lobe UI v2 Design System and Desktop App Launch
|
||||
description: >-
|
||||
LobeHub launches a refreshed visual design with Lobe UI v2 and officially
|
||||
releases the desktop app for Windows and macOS.
|
||||
tags:
|
||||
- Desktop App
|
||||
- LobeHub
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: "Lobe UI v2 设计系统与桌面端正式发布"
|
||||
title: Lobe UI v2 设计系统与桌面端正式发布
|
||||
description: LobeHub 推出基于 Lobe UI v2 的全新视觉设计,并正式发布 Windows 与 macOS 桌面端应用。
|
||||
tags:
|
||||
- 桌面端
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
title: "Prompt Variables and Claude 4 Reasoning Model Support"
|
||||
description: LobeHub introduces prompt variables for reusable templates and adds full support for Claude 4 reasoning models with web search integration.
|
||||
title: Prompt Variables and Claude 4 Reasoning Model Support
|
||||
description: >-
|
||||
LobeHub introduces prompt variables for reusable templates and adds full
|
||||
support for Claude 4 reasoning models with web search integration.
|
||||
tags:
|
||||
- Prompt Variables
|
||||
- Claude 4
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: "提示词变量与 Claude 4 推理模型支持"
|
||||
title: 提示词变量与 Claude 4 推理模型支持
|
||||
description: LobeHub 引入提示词变量实现模板复用,并完整支持 Claude 4 推理模型及网页搜索集成。
|
||||
tags:
|
||||
- 提示词变量
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
---
|
||||
title: "MCP Marketplace and Search Provider Expansion \U0001F50D"
|
||||
description: >-
|
||||
MCP Marketplace is now live with one-click plugin installation, alongside expanded search providers and new SSO options for easier team access.
|
||||
MCP Marketplace is now live with one-click plugin installation, alongside
|
||||
expanded search providers and new SSO options for easier team access.
|
||||
tags:
|
||||
- MCP Marketplace
|
||||
- Best MCP
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
---
|
||||
title: "Image Generation, Desktop, and Auth Updates \U0001F3A8"
|
||||
description: >-
|
||||
Generate AI images across multiple providers, connect with expanded identity options, and run desktop workflows with fewer interruptions.
|
||||
Generate AI images across multiple providers, connect with expanded identity
|
||||
options, and run desktop workflows with fewer interruptions.
|
||||
tags:
|
||||
- Image Generation
|
||||
- Desktop App
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: 图像生成、桌面端与认证更新 🎨
|
||||
title: "图像生成、桌面端与认证更新 \U0001F3A8"
|
||||
description: 通过多个服务商生成 AI 图像,用更多身份系统完成接入,并在桌面端享受更顺畅的工作流。
|
||||
tags:
|
||||
- 图像生成
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
---
|
||||
title: "Gemini Image Generation and Non-Streaming Mode Support \U0001F3A8"
|
||||
description: >-
|
||||
Gemini 2.5 Flash Image generation, non-streaming response mode, and expanded model coverage give you more flexibility in how you generate and receive content.
|
||||
Gemini 2.5 Flash Image generation, non-streaming response mode, and expanded
|
||||
model coverage give you more flexibility in how you generate and receive
|
||||
content.
|
||||
tags:
|
||||
- Gemini
|
||||
- Nano Banana
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user