mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-20 14:20:27 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e613eb46c |
@@ -1,95 +0,0 @@
|
||||
---
|
||||
name: agent-signal
|
||||
description: Build or extend LobeHub Agent Signal pipelines for background or quiet agent work driven by event sources, semantic signals, and action handlers. Use when adding a new Agent Signal source, signal or action type, policy, middleware handler, workflow handoff, dedupe or scope behavior, or observability around `src/server/services/agentSignal/**`, `packages/agent-signal`, or `packages/observability-otel/src/modules/agent-signal`.
|
||||
---
|
||||
|
||||
# Agent Signal
|
||||
|
||||
Use this skill to implement event-driven background work for agents without coupling the work to the foreground chat request.
|
||||
|
||||
Agent Signal has one consistent shape:
|
||||
|
||||
`source event` -> `signal interpretation` -> `action execution` -> built-in result signals
|
||||
|
||||
## Start Here
|
||||
|
||||
1. Read `references/architecture.md` to map the package boundary, runtime queue, scope model, and async workflow handoff.
|
||||
2. Read `references/handlers.md` before writing any new policy, source handler, signal handler, or action handler.
|
||||
3. Read `references/observability.md` when you need tracing, metrics, debugging, or workflow snapshot visibility.
|
||||
|
||||
## Use The Right Entry Point
|
||||
|
||||
- Use `emitAgentSignalSourceEvent(...)` when a server-owned producer should execute the pipeline immediately.
|
||||
- Use `executeAgentSignalSourceEvent(...)` when a worker or controlled backend path already owns execution timing and may inject a runtime guard backend.
|
||||
- Use `enqueueAgentSignalSourceEvent(...)` when the caller should return quickly and let Upstash Workflow process the event out-of-band.
|
||||
- Use `emitAgentSignalSourceEventWithStore(...)` for isolated tests or evals that should avoid ambient Redis state.
|
||||
|
||||
Read:
|
||||
|
||||
- `src/server/services/agentSignal/index.ts`
|
||||
- `src/server/workflows/agentSignal/index.ts`
|
||||
- `src/server/workflows/agentSignal/run.ts`
|
||||
|
||||
## Core Model
|
||||
|
||||
- `source`: A normalized fact that happened. Sources come from producers such as runtime lifecycle events, user messages, or bot ingress.
|
||||
- `signal`: A semantic interpretation derived from one source or from another signal. Signals express meaning, routing, or policy state.
|
||||
- `action`: A concrete side effect planned from one signal. Actions do the work.
|
||||
- `policy`: An installable middleware bundle that registers source, signal, and action handlers.
|
||||
- `procedure`: Not a distinct runtime node. Treat "procedure" as the end-to-end flow for one use case: ingress source, matching handlers, planned actions, execution result, and observability.
|
||||
|
||||
Keep the boundaries strict:
|
||||
|
||||
- Add a new `source` when the outside world produced a new event.
|
||||
- Add a new `signal` when the system needs a reusable semantic interpretation.
|
||||
- Add a new `action` when the runtime needs a concrete side effect.
|
||||
- Add or update a `policy` when you are wiring those pieces together.
|
||||
|
||||
## Implementation Workflow
|
||||
|
||||
1. Decide whether the use case is synchronous or quiet background work.
|
||||
2. Define or reuse a source type in `src/server/services/agentSignal/sourceTypes.ts`.
|
||||
3. Define or reuse signal and action types in `src/server/services/agentSignal/policies/types.ts`.
|
||||
4. Implement handlers with `defineSourceHandler`, `defineSignalHandler`, or `defineActionHandler`.
|
||||
5. Bundle handlers with `defineAgentSignalHandlers(...)`.
|
||||
6. Register the policy in `src/server/services/agentSignal/policies/index.ts` and pass it into the runtime factory if needed.
|
||||
7. Add or update ingress code that emits or enqueues the source event.
|
||||
8. Add observability and tests before considering the flow complete.
|
||||
|
||||
## Default Reading Set
|
||||
|
||||
- Shared semantic core:
|
||||
`packages/agent-signal/src/index.ts`
|
||||
`packages/agent-signal/src/base/builders.ts`
|
||||
`packages/agent-signal/src/base/types.ts`
|
||||
- Server-owned runtime and middleware:
|
||||
`src/server/services/agentSignal/runtime/AgentSignalRuntime.ts`
|
||||
`src/server/services/agentSignal/runtime/AgentSignalScheduler.ts`
|
||||
`src/server/services/agentSignal/runtime/middleware.ts`
|
||||
`src/server/services/agentSignal/runtime/context.ts`
|
||||
- Existing policy example:
|
||||
`src/server/services/agentSignal/policies/analyzeIntent/index.ts`
|
||||
`src/server/services/agentSignal/policies/analyzeIntent/feedbackSatisfaction.ts`
|
||||
`src/server/services/agentSignal/policies/analyzeIntent/feedbackDomain.ts`
|
||||
`src/server/services/agentSignal/policies/analyzeIntent/feedbackAction.ts`
|
||||
`src/server/services/agentSignal/policies/analyzeIntent/actions/userMemory.ts`
|
||||
- Observability:
|
||||
`src/server/services/agentSignal/observability/projector.ts`
|
||||
`src/server/services/agentSignal/observability/traceEvents.ts`
|
||||
`packages/observability-otel/src/modules/agent-signal/index.ts`
|
||||
|
||||
## Implementation Rules
|
||||
|
||||
- Reuse existing source, signal, and action types before adding new ones.
|
||||
- Keep source handlers focused on interpretation and fan-out, not heavy side effects.
|
||||
- Keep action handlers responsible for side effects, idempotency, and executor-style result reporting.
|
||||
- Use stable ids and idempotency keys when the same source can arrive more than once.
|
||||
- Preserve scope discipline. The runtime uses `scopeKey` to serialize related background work.
|
||||
- Prefer the dedicated shared package types and builders from `@lobechat/agent-signal` for normalized nodes and result contracts.
|
||||
- Add focused tests near the touched runtime, policy, or store module. Existing tests under `src/server/services/agentSignal/**/__tests__` are the reference pattern.
|
||||
|
||||
## References
|
||||
|
||||
- Architecture and boundaries: `references/architecture.md`
|
||||
- Writing handlers and policies: `references/handlers.md`
|
||||
- Observability, metrics, and debugging: `references/observability.md`
|
||||
@@ -1,4 +0,0 @@
|
||||
interface:
|
||||
display_name: 'Agent Signal'
|
||||
short_description: 'Build AgentSignal sources, signals, actions, and policies.'
|
||||
default_prompt: 'Use $agent-signal to add a new Agent Signal source, policy, handler, or observability flow.'
|
||||
@@ -1,199 +0,0 @@
|
||||
# Agent Signal Architecture
|
||||
|
||||
## Pipeline
|
||||
|
||||
Use this mental model first:
|
||||
|
||||
```text
|
||||
producer
|
||||
-> emitAgentSignalSourceEvent(...) or enqueueAgentSignalSourceEvent(...)
|
||||
-> emitSourceEvent(...)
|
||||
-> dedupe + scope lock + source normalization
|
||||
-> runtime.emitNormalized(source)
|
||||
-> source handlers
|
||||
-> signal handlers
|
||||
-> action handlers
|
||||
-> built-in result signals
|
||||
-> observability projection + persistence
|
||||
```
|
||||
|
||||
The scheduler is queue-driven, not hard-coded for one policy:
|
||||
|
||||
```text
|
||||
source node
|
||||
-> matching source handlers
|
||||
-> dispatch signals/actions
|
||||
-> matching signal handlers
|
||||
-> dispatch more signals/actions
|
||||
-> matching action handlers
|
||||
-> ExecutorResult
|
||||
-> signal.action.applied | signal.action.skipped | signal.action.failed
|
||||
```
|
||||
|
||||
Read:
|
||||
|
||||
- `src/server/services/agentSignal/index.ts`
|
||||
- `src/server/services/agentSignal/sources/index.ts`
|
||||
- `src/server/services/agentSignal/runtime/AgentSignalScheduler.ts`
|
||||
|
||||
## Package Boundaries
|
||||
|
||||
### `packages/agent-signal`
|
||||
|
||||
Treat this as the shared semantic core.
|
||||
|
||||
It provides:
|
||||
|
||||
- base node types: source, signal, action
|
||||
- builders: `createSource`, `createSignal`, `createAction`
|
||||
- built-in result signal types
|
||||
- runtime result contracts such as `RuntimeProcessorResult` and `ExecutorResult`
|
||||
|
||||
Read:
|
||||
|
||||
- `packages/agent-signal/src/base/types.ts`
|
||||
- `packages/agent-signal/src/base/builders.ts`
|
||||
- `packages/agent-signal/src/types/events.ts`
|
||||
- `packages/agent-signal/src/types/builtin.ts`
|
||||
|
||||
### `src/server/services/agentSignal`
|
||||
|
||||
Treat this as the server-owned implementation layer.
|
||||
|
||||
It owns:
|
||||
|
||||
- source catalogs and payload maps
|
||||
- policy-specific signal and action catalogs
|
||||
- middleware registration
|
||||
- runtime scheduling and guard backends
|
||||
- Redis-backed dedupe, waypoint, and policy state
|
||||
- service entrypoints for synchronous and async execution
|
||||
|
||||
### `packages/observability-otel/src/modules/agent-signal`
|
||||
|
||||
Treat this as shared OTEL ownership for Agent Signal metrics and tracer instances.
|
||||
|
||||
## Core Vocabulary
|
||||
|
||||
### Source
|
||||
|
||||
A source is the normalized external fact that started the chain.
|
||||
|
||||
Examples:
|
||||
|
||||
- `agent.user.message`
|
||||
- `runtime.before_step`
|
||||
- `runtime.after_step`
|
||||
- `client.runtime.start`
|
||||
- `bot.message.merged`
|
||||
|
||||
Define source payloads in:
|
||||
|
||||
- `src/server/services/agentSignal/sourceTypes.ts`
|
||||
|
||||
Build normalized sources in:
|
||||
|
||||
- `src/server/services/agentSignal/sources/buildSource.ts`
|
||||
- `packages/agent-signal/src/base/builders.ts`
|
||||
|
||||
### Signal
|
||||
|
||||
A signal is a semantic interpretation. Signals should be reusable and meaning-oriented.
|
||||
|
||||
Examples from `analyzeIntent`:
|
||||
|
||||
- `signal.feedback.satisfaction`
|
||||
- `signal.feedback.domain.memory`
|
||||
- `signal.feedback.domain.prompt`
|
||||
- `signal.feedback.domain.skill`
|
||||
|
||||
Define server-owned signal types in:
|
||||
|
||||
- `src/server/services/agentSignal/policies/types.ts`
|
||||
|
||||
### Action
|
||||
|
||||
An action is a concrete side effect the runtime should execute.
|
||||
|
||||
Example:
|
||||
|
||||
- `action.user-memory.handle`
|
||||
|
||||
Action handlers usually:
|
||||
|
||||
- check idempotency
|
||||
- call tools, models, or services
|
||||
- return `ExecutorResult`
|
||||
|
||||
### Policy
|
||||
|
||||
A policy is an installable bundle of handlers. It is the composition unit that turns the generic runtime into a feature.
|
||||
|
||||
Example:
|
||||
|
||||
- `createAnalyzeIntentPolicy(...)`
|
||||
|
||||
### Procedure
|
||||
|
||||
"Procedure" is not a first-class type in this runtime. Use the word to describe one end-to-end use case:
|
||||
|
||||
1. define ingress source
|
||||
2. emit or enqueue the source
|
||||
3. interpret source into signals
|
||||
4. plan actions from signals
|
||||
5. execute actions
|
||||
6. persist trace and metrics
|
||||
|
||||
When a user asks for "the procedure", document the flow above and point to the exact producer, handlers, and execution entrypoint.
|
||||
|
||||
## Scope, Deduping, And Quiet Background Work
|
||||
|
||||
`scopeKey` is the serialization boundary for related work. It is used for:
|
||||
|
||||
- source dedupe windows
|
||||
- scope locks during source generation
|
||||
- runtime guard state
|
||||
- waypoint persistence for queued processing
|
||||
|
||||
Read:
|
||||
|
||||
- `src/server/services/agentSignal/sources/index.ts`
|
||||
- `src/server/services/agentSignal/runtime/context.ts`
|
||||
- `src/server/services/agentSignal/constants.ts`
|
||||
|
||||
Use `enqueueAgentSignalSourceEvent(...)` when the work should stay quiet and out-of-band. That path:
|
||||
|
||||
1. normalizes the source envelope
|
||||
2. derives or reuses `scopeKey`
|
||||
3. triggers `AgentSignalWorkflow`
|
||||
4. executes later in `runAgentSignalWorkflow`
|
||||
|
||||
This is the preferred path when the UI request should finish immediately and the policy can run in the background.
|
||||
|
||||
Read:
|
||||
|
||||
- `src/server/workflows/agentSignal/index.ts`
|
||||
- `src/server/workflows/agentSignal/run.ts`
|
||||
|
||||
## Existing Example: `analyzeIntent`
|
||||
|
||||
Use `analyzeIntent` as the reference chain:
|
||||
|
||||
```text
|
||||
agent.user.message
|
||||
-> feedback satisfaction source handler
|
||||
-> signal.feedback.satisfaction
|
||||
-> feedback domain signal handler
|
||||
-> signal.feedback.domain.*
|
||||
-> feedback action planner
|
||||
-> action.user-memory.handle
|
||||
-> signal.action.applied | skipped | failed
|
||||
```
|
||||
|
||||
Read:
|
||||
|
||||
- `src/server/services/agentSignal/policies/analyzeIntent/index.ts`
|
||||
- `src/server/services/agentSignal/policies/analyzeIntent/feedbackSatisfaction.ts`
|
||||
- `src/server/services/agentSignal/policies/analyzeIntent/feedbackDomain.ts`
|
||||
- `src/server/services/agentSignal/policies/analyzeIntent/feedbackAction.ts`
|
||||
- `src/server/services/agentSignal/policies/analyzeIntent/actions/userMemory.ts`
|
||||
@@ -1,228 +0,0 @@
|
||||
# Writing Handlers And Policies
|
||||
|
||||
## Fluent Registration API
|
||||
|
||||
Use the middleware helpers in `src/server/services/agentSignal/runtime/middleware.ts`.
|
||||
|
||||
They provide:
|
||||
|
||||
- `defineSourceHandler(...)`
|
||||
- `defineSignalHandler(...)`
|
||||
- `defineActionHandler(...)`
|
||||
- `defineAgentSignalHandlers(...)`
|
||||
|
||||
These helpers do two jobs:
|
||||
|
||||
1. keep handler registration terse
|
||||
2. preserve strong typing when `listen` points at concrete source, signal, or action types
|
||||
|
||||
## Handler Shape
|
||||
|
||||
Each handler receives:
|
||||
|
||||
- the current runtime node
|
||||
- `RuntimeProcessorContext`
|
||||
|
||||
The context gives you:
|
||||
|
||||
- `scopeKey`
|
||||
- `now()`
|
||||
- `runtimeState.getGuardState(lane)`
|
||||
- `runtimeState.touchGuardState(lane, now?)`
|
||||
|
||||
Read:
|
||||
|
||||
- `src/server/services/agentSignal/runtime/context.ts`
|
||||
|
||||
## Return Contracts
|
||||
|
||||
Return one of these shapes:
|
||||
|
||||
- `void`: no fan-out, stop at this handler
|
||||
- `{ status: 'dispatch', signals?, actions? }`: continue the chain
|
||||
- `{ status: 'wait', pending? }`: pause for later host coordination
|
||||
- `{ status: 'schedule', nextHop }`: schedule another hop
|
||||
- `{ status: 'conclude', concluded? }`: stop with a terminal runtime result
|
||||
- `ExecutorResult`: only for action handlers that performed a concrete side effect
|
||||
|
||||
Read:
|
||||
|
||||
- `packages/agent-signal/src/base/types.ts`
|
||||
- `src/server/services/agentSignal/runtime/AgentSignalScheduler.ts`
|
||||
|
||||
## Policy Composition Pattern
|
||||
|
||||
Use `defineAgentSignalHandlers([...])` to bundle related handlers into one policy.
|
||||
|
||||
Example from `analyzeIntent`:
|
||||
|
||||
```ts
|
||||
return defineAgentSignalHandlers([
|
||||
createFeedbackSatisfactionJudgeProcessor(...),
|
||||
createFeedbackDomainJudgeSignalHandler(...),
|
||||
createFeedbackActionPlannerSignalHandler(),
|
||||
defineUserMemoryActionHandler(...),
|
||||
]);
|
||||
```
|
||||
|
||||
That bundle is later passed into the runtime via:
|
||||
|
||||
- `createDefaultAgentSignalPolicies(...)`
|
||||
- `createAgentSignalRuntime({ policies })`
|
||||
|
||||
Read:
|
||||
|
||||
- `src/server/services/agentSignal/policies/index.ts`
|
||||
- `src/server/services/agentSignal/policies/analyzeIntent/index.ts`
|
||||
|
||||
## Source Handler Pattern
|
||||
|
||||
Use a source handler when you are interpreting a producer event into semantic signals.
|
||||
|
||||
Reference:
|
||||
|
||||
- `src/server/services/agentSignal/policies/analyzeIntent/feedbackSatisfaction.ts`
|
||||
|
||||
Pattern:
|
||||
|
||||
```ts
|
||||
return defineSourceHandler(
|
||||
AGENT_SIGNAL_SOURCE_TYPES.agentUserMessage,
|
||||
'agent.user.message:my-handler',
|
||||
async (source, ctx): Promise<RuntimeProcessorResult | void> => {
|
||||
// interpret source payload
|
||||
// optionally use ctx.runtimeState
|
||||
|
||||
return {
|
||||
signals: [
|
||||
/* one or more semantic signals */
|
||||
],
|
||||
status: 'dispatch',
|
||||
};
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
Write source handlers when:
|
||||
|
||||
- a raw message, lifecycle event, or bot ingress needs interpretation
|
||||
- the work is still semantic, not side-effectful
|
||||
|
||||
## Signal Handler Pattern
|
||||
|
||||
Use a signal handler when one semantic state should branch into more semantic states or planned actions.
|
||||
|
||||
References:
|
||||
|
||||
- `src/server/services/agentSignal/policies/analyzeIntent/feedbackDomain.ts`
|
||||
- `src/server/services/agentSignal/policies/analyzeIntent/feedbackAction.ts`
|
||||
|
||||
Pattern:
|
||||
|
||||
```ts
|
||||
return defineSignalHandler(
|
||||
MY_SIGNAL_TYPE,
|
||||
'signal.my-policy-router',
|
||||
async (signal): Promise<RuntimeProcessorResult | void> => {
|
||||
return {
|
||||
actions: [
|
||||
/* planned work */
|
||||
],
|
||||
status: 'dispatch',
|
||||
};
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
Use signal handlers for:
|
||||
|
||||
- routing
|
||||
- fan-out
|
||||
- filtering
|
||||
- conflict resolution
|
||||
- converting interpretation into planned actions
|
||||
|
||||
## Action Handler Pattern
|
||||
|
||||
Use an action handler when the runtime should do actual work.
|
||||
|
||||
Reference:
|
||||
|
||||
- `src/server/services/agentSignal/policies/analyzeIntent/actions/userMemory.ts`
|
||||
|
||||
Pattern:
|
||||
|
||||
```ts
|
||||
return defineActionHandler(
|
||||
MY_ACTION_TYPE,
|
||||
'action.my-policy-executor',
|
||||
async (action, ctx): Promise<ExecutorResult> => {
|
||||
// run service/tool/model side effect
|
||||
// check idempotency if needed
|
||||
|
||||
return {
|
||||
actionId: action.actionId,
|
||||
attempt: {
|
||||
completedAt: ctx.now(),
|
||||
current: 1,
|
||||
startedAt,
|
||||
status: 'succeeded',
|
||||
},
|
||||
status: 'applied',
|
||||
};
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
Keep these rules:
|
||||
|
||||
- perform idempotency checks here or immediately before side effects
|
||||
- return stable `actionId`
|
||||
- include failure detail in `error`
|
||||
- let the scheduler turn the `ExecutorResult` into built-in result signals
|
||||
|
||||
## Source, Signal, And Action Type Placement
|
||||
|
||||
Use this split:
|
||||
|
||||
- external event payloads:
|
||||
`src/server/services/agentSignal/sourceTypes.ts`
|
||||
- policy-owned signal and action payloads:
|
||||
`src/server/services/agentSignal/policies/types.ts`
|
||||
- normalized shared node contracts:
|
||||
`packages/agent-signal/src/base/types.ts`
|
||||
|
||||
Do not put app-specific signal catalogs into `packages/agent-signal`. That package should stay generic and reusable.
|
||||
|
||||
## Choosing The Right Node
|
||||
|
||||
Choose `source` when:
|
||||
|
||||
- the outside world emitted a new fact
|
||||
|
||||
Choose `signal` when:
|
||||
|
||||
- the system needs semantic meaning that downstream handlers can reuse
|
||||
|
||||
Choose `action` when:
|
||||
|
||||
- the runtime is ready for a concrete side effect
|
||||
|
||||
If a handler both interprets meaning and performs side effects, split it. That keeps chains inspectable and testable.
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
Prefer focused tests near the touched code.
|
||||
|
||||
Useful references:
|
||||
|
||||
- `src/server/services/agentSignal/runtime/__tests__/AgentSignalRuntime.test.ts`
|
||||
- `src/server/services/agentSignal/__tests__/index.integration.test.ts`
|
||||
- `src/server/services/agentSignal/policies/analyzeIntent/__tests__/*`
|
||||
- `src/server/services/agentSignal/policies/analyzeIntent/actions/__tests__/*`
|
||||
|
||||
Test at the smallest level that proves the behavior:
|
||||
|
||||
- handler unit test for one routing rule
|
||||
- runtime test for queue fan-out
|
||||
- integration test for service ingress and observability persistence
|
||||
@@ -1,118 +0,0 @@
|
||||
# Observability And Debugging
|
||||
|
||||
## OTEL Ownership
|
||||
|
||||
Use `packages/observability-otel/src/modules/agent-signal/index.ts` for the shared tracer and metrics.
|
||||
|
||||
Available instruments:
|
||||
|
||||
- `tracer`
|
||||
- `sourceCounter`
|
||||
- `signalCounter`
|
||||
- `actionCounter`
|
||||
- `actionResultCounter`
|
||||
- `chainCounter`
|
||||
- `signalActionTransitionCounter`
|
||||
- `chainDurationHistogram`
|
||||
- `actionDurationHistogram`
|
||||
|
||||
Use this module when you need shared telemetry ownership instead of creating feature-local meters or tracers.
|
||||
|
||||
## Projection Pipeline
|
||||
|
||||
After runtime execution, the service projects one compact observability model from the full chain.
|
||||
|
||||
Read:
|
||||
|
||||
- `src/server/services/agentSignal/observability/projector.ts`
|
||||
- `src/server/services/agentSignal/observability/traceEvents.ts`
|
||||
- `src/server/services/agentSignal/observability/store.ts`
|
||||
|
||||
Projection outputs:
|
||||
|
||||
- a trace envelope with source, signals, actions, results, edges, and handler runs
|
||||
- a compact telemetry record with dominant path, status breakdown, and chain metadata
|
||||
|
||||
This projection is built from:
|
||||
|
||||
- source node
|
||||
- emitted signals
|
||||
- planned actions
|
||||
- executor results
|
||||
|
||||
## How To Inspect A Chain
|
||||
|
||||
Use this order:
|
||||
|
||||
1. Inspect the source type and payload.
|
||||
2. Inspect emitted signals.
|
||||
3. Inspect planned actions.
|
||||
4. Inspect executor results.
|
||||
5. Inspect projected edges and dominant path.
|
||||
|
||||
The helper `toAgentSignalTraceEvents(...)` flattens a chain into compact event records suitable for tracing snapshots.
|
||||
|
||||
## Workflow Snapshot Bridge
|
||||
|
||||
Workflow-triggered runs do not naturally pass through the normal foreground runtime snapshot path, so `runAgentSignalWorkflow` adds a development-only bridge into `.agent-tracing/`.
|
||||
|
||||
Read:
|
||||
|
||||
- `src/server/workflows/agentSignal/run.ts`
|
||||
|
||||
Use that path when:
|
||||
|
||||
- the source was enqueued with `enqueueAgentSignalSourceEvent(...)`
|
||||
- you need local trace visibility for quiet background work
|
||||
|
||||
## Common Debug Questions
|
||||
|
||||
### The source emits but nothing happens
|
||||
|
||||
Check:
|
||||
|
||||
- feature gate enabled for the user
|
||||
- source type matches a registered source handler
|
||||
- dedupe or scope lock did not short-circuit generation
|
||||
|
||||
Read:
|
||||
|
||||
- `src/server/services/agentSignal/index.ts`
|
||||
- `src/server/services/agentSignal/sources/index.ts`
|
||||
|
||||
### The signal exists but no action runs
|
||||
|
||||
Check:
|
||||
|
||||
- the signal type has a registered signal handler
|
||||
- the signal handler returns `status: 'dispatch'`
|
||||
- the handler actually returned actions
|
||||
|
||||
### The action runs twice
|
||||
|
||||
Check:
|
||||
|
||||
- source dedupe key stability
|
||||
- action idempotency strategy
|
||||
- scope key stability across retries and workflow handoff
|
||||
|
||||
Reference:
|
||||
|
||||
- `src/server/services/agentSignal/policies/actionIdempotency.ts`
|
||||
- `src/server/services/agentSignal/policies/analyzeIntent/actions/userMemory.ts`
|
||||
|
||||
### Background runs are hard to discover
|
||||
|
||||
Check:
|
||||
|
||||
- workflow snapshot bridge in development
|
||||
- projected telemetry record contents
|
||||
- OTEL counters and histograms in the shared module
|
||||
|
||||
## Minimal Completion Checklist
|
||||
|
||||
- source ingress is testable
|
||||
- handler registration is discoverable from the policy factory
|
||||
- action executor returns structured results
|
||||
- projection includes the new path cleanly
|
||||
- tests cover at least one happy path and one no-op or failure path
|
||||
@@ -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`, `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.
|
||||
`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`).
|
||||
|
||||
Each platform implements `PlatformClient` (see `platforms/types.ts`):
|
||||
|
||||
|
||||
@@ -30,17 +30,6 @@ 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,80 +0,0 @@
|
||||
import { execSync } from 'node:child_process';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* Manual E2E coverage for `lh agent space fs` against a real backend.
|
||||
*
|
||||
* Run when:
|
||||
* - A local or remote LobeHub backend is reachable by the CLI
|
||||
* - `AGENT_FS_E2E_AGENT_ID` points at an agent with document access
|
||||
*
|
||||
* Expects:
|
||||
* - The command creates and cleans up a temporary VFS directory
|
||||
* - This suite is skipped unless `AGENT_FS_E2E_AGENT_ID` is set
|
||||
*/
|
||||
const AGENT_ID = process.env.AGENT_FS_E2E_AGENT_ID;
|
||||
const CLI = process.env.LH_CLI_PATH || 'LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts';
|
||||
const TIMEOUT = 30_000;
|
||||
|
||||
function run(args: string): string {
|
||||
return execSync(`${CLI} ${args}`, {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, PATH: `${process.env.HOME}/.bun/bin:${process.env.PATH}` },
|
||||
timeout: TIMEOUT,
|
||||
}).trim();
|
||||
}
|
||||
|
||||
describe.skipIf(!AGENT_ID)('lh agent space fs unified VFS - manual E2E', () => {
|
||||
const testRoot = `agent:/vfs-cli-e2e-${Date.now()}`;
|
||||
|
||||
it('exercises root, mounted namespaces, writes, copy, move, trash, and cleanup', () => {
|
||||
const root = run(`agent space fs ls --agent-id ${AGENT_ID} agent:/`);
|
||||
expect(root).toContain('lobe/');
|
||||
|
||||
const mountedRoot = run(`agent space fs ls --agent-id ${AGENT_ID} agent:/lobe/skills`);
|
||||
expect(mountedRoot).toContain('builtin/');
|
||||
expect(mountedRoot).toContain('agent/');
|
||||
|
||||
try {
|
||||
expect(run(`agent space fs mkdir --agent-id ${AGENT_ID} --parents ${testRoot}`)).toContain(
|
||||
'created',
|
||||
);
|
||||
expect(
|
||||
run(
|
||||
`agent space fs write --agent-id ${AGENT_ID} --content "# VFS E2E" ${testRoot}/source.md`,
|
||||
),
|
||||
).toContain('created');
|
||||
expect(run(`agent space fs cat --agent-id ${AGENT_ID} ${testRoot}/source.md`)).toContain(
|
||||
'# VFS E2E',
|
||||
);
|
||||
expect(
|
||||
run(`agent space fs cp --agent-id ${AGENT_ID} ${testRoot}/source.md ${testRoot}/copied.md`),
|
||||
).toContain('copied');
|
||||
expect(
|
||||
run(`agent space fs mv --agent-id ${AGENT_ID} ${testRoot}/copied.md ${testRoot}/moved.md`),
|
||||
).toContain('moved');
|
||||
expect(run(`agent space fs rm --agent-id ${AGENT_ID} --yes ${testRoot}/moved.md`)).toContain(
|
||||
'deleted',
|
||||
);
|
||||
expect(run(`agent space fs trash ls --agent-id ${AGENT_ID} ${testRoot}`)).toContain(
|
||||
`${testRoot}/moved.md`,
|
||||
);
|
||||
expect(
|
||||
run(`agent space fs trash restore --agent-id ${AGENT_ID} ${testRoot}/moved.md`),
|
||||
).toContain('restored');
|
||||
} finally {
|
||||
try {
|
||||
run(`agent space fs rm --agent-id ${AGENT_ID} --yes --recursive ${testRoot}`);
|
||||
} catch {
|
||||
// Cleanup is best-effort because earlier assertions may fail before creation.
|
||||
}
|
||||
|
||||
try {
|
||||
run(`agent space fs trash rm --agent-id ${AGENT_ID} --yes --recursive --force ${testRoot}`);
|
||||
} catch {
|
||||
// Cleanup is best-effort because the trash entry may not exist.
|
||||
}
|
||||
}
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
.\" Code generated by `npm run man:generate`; DO NOT EDIT.
|
||||
.\" Manual command details come from the Commander command tree.
|
||||
.TH LH 1 "" "@lobehub/cli 0.0.8" "User Commands"
|
||||
.TH LH 1 "" "@lobehub/cli 0.0.9" "User Commands"
|
||||
.SH NAME
|
||||
lh \- LobeHub CLI \- manage and connect to LobeHub services
|
||||
.SH SYNOPSIS
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@lobehub/cli",
|
||||
"version": "0.0.8",
|
||||
"version": "0.0.9",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"lh": "./dist/index.js",
|
||||
@@ -35,7 +35,6 @@
|
||||
"@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",
|
||||
|
||||
@@ -23,25 +23,6 @@ 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() },
|
||||
promoteSkill: { mutate: vi.fn() },
|
||||
updateSkill: { mutate: vi.fn() },
|
||||
},
|
||||
aiAgent: {
|
||||
execAgent: { mutate: vi.fn() },
|
||||
getOperationStatus: { query: vi.fn() },
|
||||
@@ -60,11 +41,6 @@ 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(),
|
||||
}));
|
||||
@@ -73,18 +49,9 @@ const { mockResolveLocalDeviceId } = vi.hoisted(() => ({
|
||||
mockResolveLocalDeviceId: vi.fn(),
|
||||
}));
|
||||
|
||||
const { mockReadStdinText } = vi.hoisted(() => ({
|
||||
mockReadStdinText: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('node:stream/consumers', () => ({ text: mockReadStdinText }));
|
||||
vi.mock('../api/client', () => ({ getTrpcClient: mockGetTrpcClient }));
|
||||
vi.mock('../api/http', () => ({ getAgentStreamAuthInfo: mockGetAgentStreamAuthInfo }));
|
||||
vi.mock('../utils/agentStream', () => ({
|
||||
replayAgentEvents: mockReplayAgentEvents,
|
||||
streamAgentEvents: mockStreamAgentEvents,
|
||||
streamAgentEventsViaWebSocket: mockStreamAgentEventsViaWebSocket,
|
||||
}));
|
||||
vi.mock('../utils/agentStream', () => ({ streamAgentEvents: mockStreamAgentEvents }));
|
||||
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() },
|
||||
@@ -104,26 +71,12 @@ 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();
|
||||
@@ -329,7 +282,7 @@ describe('agent command', () => {
|
||||
});
|
||||
|
||||
describe('run', () => {
|
||||
it('should exec agent and connect to the gateway WebSocket stream by default', async () => {
|
||||
it('should exec agent and connect to SSE stream', async () => {
|
||||
mockTrpcClient.aiAgent.execAgent.mutate.mockResolvedValue({
|
||||
operationId: 'op-123',
|
||||
success: true,
|
||||
@@ -351,45 +304,11 @@ describe('agent command', () => {
|
||||
expect(mockTrpcClient.aiAgent.execAgent.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ agentId: 'a1', prompt: 'Hello' }),
|
||||
);
|
||||
expect(mockStreamAgentEventsViaWebSocket).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
gatewayUrl: expect.any(String),
|
||||
json: undefined,
|
||||
operationId: 'op-123',
|
||||
serverUrl: 'https://example.com',
|
||||
token: undefined,
|
||||
tokenType: undefined,
|
||||
verbose: undefined,
|
||||
}),
|
||||
);
|
||||
expect(mockStreamAgentEvents).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fall back to SSE when --sse is provided', async () => {
|
||||
mockTrpcClient.aiAgent.execAgent.mutate.mockResolvedValue({
|
||||
operationId: 'op-sse',
|
||||
success: true,
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'agent',
|
||||
'run',
|
||||
'--agent-id',
|
||||
'a1',
|
||||
'--prompt',
|
||||
'Hello',
|
||||
'--sse',
|
||||
]);
|
||||
|
||||
expect(mockStreamAgentEvents).toHaveBeenCalledWith(
|
||||
'https://example.com/api/agent/stream?operationId=op-sse',
|
||||
'https://example.com/api/agent/stream?operationId=op-123',
|
||||
expect.objectContaining({ 'Oidc-Auth': 'test-token' }),
|
||||
expect.objectContaining({ json: undefined, verbose: undefined }),
|
||||
);
|
||||
expect(mockStreamAgentEventsViaWebSocket).not.toHaveBeenCalled();
|
||||
});
|
||||
it('should support --slug option', async () => {
|
||||
mockTrpcClient.aiAgent.execAgent.mutate.mockResolvedValue({
|
||||
@@ -676,8 +595,10 @@ describe('agent command', () => {
|
||||
'--json',
|
||||
]);
|
||||
|
||||
expect(mockStreamAgentEventsViaWebSocket).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ json: true, operationId: 'op-j' }),
|
||||
expect(mockStreamAgentEvents).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.any(Object),
|
||||
expect.objectContaining({ json: true }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -873,542 +794,4 @@ describe('agent command', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fs', () => {
|
||||
it('should list VFS entries from the unified agent root alias', async () => {
|
||||
mockTrpcClient.agentDocument.listDocumentsByPath.query.mockResolvedValue([
|
||||
{
|
||||
mode: 8,
|
||||
mount: { driver: 'synthetic', source: 'virtual' },
|
||||
name: 'writer',
|
||||
path: './lobe',
|
||||
type: 'directory',
|
||||
},
|
||||
]);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'agent',
|
||||
'space',
|
||||
'fs',
|
||||
'ls',
|
||||
'--agent-id',
|
||||
'a1',
|
||||
'agent:/',
|
||||
'--json',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agentDocument.listDocumentsByPath.query).toHaveBeenCalledWith({
|
||||
agentId: 'a1',
|
||||
cursor: undefined,
|
||||
limit: undefined,
|
||||
path: './',
|
||||
topicId: undefined,
|
||||
});
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
JSON.stringify(
|
||||
[
|
||||
{
|
||||
mode: 8,
|
||||
mount: { driver: 'synthetic', source: 'virtual' },
|
||||
name: 'writer',
|
||||
path: './lobe',
|
||||
type: 'directory',
|
||||
},
|
||||
],
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass pagination options to VFS ls', async () => {
|
||||
mockTrpcClient.agentDocument.listDocumentsByPath.query.mockResolvedValue([]);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'agent',
|
||||
'space',
|
||||
'fs',
|
||||
'ls',
|
||||
'--agent-id',
|
||||
'a1',
|
||||
'--cursor',
|
||||
'100',
|
||||
'--limit',
|
||||
'25',
|
||||
'agent:/notes',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agentDocument.listDocumentsByPath.query).toHaveBeenCalledWith({
|
||||
agentId: 'a1',
|
||||
cursor: '100',
|
||||
limit: 25,
|
||||
path: './notes',
|
||||
topicId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should print unix-like long listings with ls -la', async () => {
|
||||
mockTrpcClient.agentDocument.listDocumentsByPath.query.mockResolvedValue([
|
||||
{
|
||||
mode: 14,
|
||||
name: '.config',
|
||||
path: './notes/.config',
|
||||
size: 0,
|
||||
type: 'directory',
|
||||
updatedAt: '2026-04-27T07:18:00',
|
||||
},
|
||||
{
|
||||
mode: 6,
|
||||
name: 'SOUL.md',
|
||||
path: './notes/SOUL.md',
|
||||
size: 399,
|
||||
type: 'file',
|
||||
updatedAt: '2026-04-27T07:19:00',
|
||||
},
|
||||
]);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'agent',
|
||||
'space',
|
||||
'fs',
|
||||
'ls',
|
||||
'-la',
|
||||
'--agent-id',
|
||||
'a1',
|
||||
'agent:/notes',
|
||||
]);
|
||||
|
||||
expect(consoleSpy).toHaveBeenNthCalledWith(1, 'total 1');
|
||||
expect(consoleSpy).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.stringMatching(/^dr-x------ {2}1 agent {2}agent {4}0 --- -- --:-- \.$/),
|
||||
);
|
||||
expect(consoleSpy).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
expect.stringMatching(/^dr-x------ {2}1 agent {2}agent {4}0 --- -- --:-- \.\.$/),
|
||||
);
|
||||
expect(consoleSpy).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
expect.stringMatching(/^drwx------ {2}1 agent {2}agent {4}0 Apr 27 07:18 \.config\/$/),
|
||||
);
|
||||
expect(consoleSpy).toHaveBeenNthCalledWith(
|
||||
5,
|
||||
expect.stringMatching(/^-rw------- {2}1 agent {2}agent {2}399 Apr 27 07:19 SOUL\.md$/),
|
||||
);
|
||||
});
|
||||
|
||||
it('should expose VFS commands through agent space fs', async () => {
|
||||
mockTrpcClient.agentDocument.listDocumentsByPath.query.mockResolvedValue([]);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'agent',
|
||||
'space',
|
||||
'fs',
|
||||
'ls',
|
||||
'--agent-id',
|
||||
'a1',
|
||||
'agent:/notes',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agentDocument.listDocumentsByPath.query).toHaveBeenCalledWith({
|
||||
agentId: 'a1',
|
||||
cursor: undefined,
|
||||
limit: undefined,
|
||||
path: './notes',
|
||||
topicId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should collect tree traversal warnings instead of failing the whole tree', async () => {
|
||||
mockTrpcClient.agentDocument.listDocumentsByPath.query
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
mode: 8,
|
||||
name: 'agent-topic',
|
||||
path: './lobe/skills/agent-topic',
|
||||
type: 'directory',
|
||||
},
|
||||
])
|
||||
.mockRejectedValueOnce(new Error('Topic ID is required for the agent-topic namespace'));
|
||||
|
||||
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/agent-topic',
|
||||
topicId: undefined,
|
||||
});
|
||||
expect(log.warn).toHaveBeenCalledWith(
|
||||
'./lobe/skills/agent-topic: Topic ID is required for the agent-topic namespace',
|
||||
);
|
||||
});
|
||||
|
||||
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,12 +14,33 @@ import {
|
||||
import { resolveLocalDeviceId } from '../utils/device';
|
||||
import { confirm, outputJson, printTable, truncate } from '../utils/format';
|
||||
import { log, setVerbose } from '../utils/logger';
|
||||
import { resolveAgentId } from './agent/resolveAgentId';
|
||||
import { registerAgentSpaceFsCommand } from './agent/spaceFs';
|
||||
|
||||
/**
|
||||
* Resolve an agent identifier (agentId or slug) to a concrete agentId.
|
||||
* When a slug is provided, uses getBuiltinAgent to look up the agent.
|
||||
*/
|
||||
async function resolveAgentId(
|
||||
client: any,
|
||||
opts: { agentId?: string; slug?: string },
|
||||
): Promise<string> {
|
||||
if (opts.agentId) return opts.agentId;
|
||||
|
||||
if (opts.slug) {
|
||||
const agent = await client.agent.getBuiltinAgent.query({ slug: opts.slug });
|
||||
if (!agent) {
|
||||
log.error(`Agent not found for slug: ${opts.slug}`);
|
||||
process.exit(1);
|
||||
}
|
||||
return (agent as any).id || (agent as any).agentId;
|
||||
}
|
||||
|
||||
log.error('Either <agentId> or --slug is required.');
|
||||
process.exit(1);
|
||||
return ''; // unreachable
|
||||
}
|
||||
|
||||
export function registerAgentCommand(program: Command) {
|
||||
const agent = program.command('agent').description('Manage agents');
|
||||
registerAgentSpaceFsCommand(agent);
|
||||
|
||||
// ── list ──────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import { log } from '../../utils/logger';
|
||||
|
||||
interface AgentLookupClient {
|
||||
agent: {
|
||||
getBuiltinAgent: {
|
||||
query: (input: { slug: string }) => Promise<{ agentId?: string; id?: string } | null>;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an agent identifier into a concrete agent id.
|
||||
*
|
||||
* Use when:
|
||||
* - A command accepts either a positional agent id or `--slug`.
|
||||
* - Downstream tRPC calls require the concrete agent id.
|
||||
*
|
||||
* Expects:
|
||||
* - `opts.agentId` to win over `opts.slug`.
|
||||
* - `client.agent.getBuiltinAgent` to resolve slugs when needed.
|
||||
*
|
||||
* Returns:
|
||||
* - The resolved agent id, or exits the process after logging a CLI-facing error.
|
||||
*/
|
||||
export async function resolveAgentId(
|
||||
client: AgentLookupClient,
|
||||
opts: { agentId?: string; slug?: string },
|
||||
): Promise<string> {
|
||||
if (opts.agentId) return opts.agentId;
|
||||
|
||||
if (opts.slug) {
|
||||
const agent = await client.agent.getBuiltinAgent.query({ slug: opts.slug });
|
||||
if (!agent) {
|
||||
log.error(`Agent not found for slug: ${opts.slug}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return agent.id || agent.agentId || '';
|
||||
}
|
||||
|
||||
log.error('Either <agentId> or --slug is required.');
|
||||
process.exit(1);
|
||||
return '';
|
||||
}
|
||||
@@ -1,947 +0,0 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { text } from 'node:stream/consumers';
|
||||
|
||||
import type { Command } from 'commander';
|
||||
import dayjs from 'dayjs';
|
||||
import pc from 'picocolors';
|
||||
|
||||
import { getTrpcClient } from '../../api/client';
|
||||
import { confirm, outputJson } from '../../utils/format';
|
||||
import { log } from '../../utils/logger';
|
||||
import { resolveAgentId } from './resolveAgentId';
|
||||
|
||||
const SKILL_FILE_NAME = 'SKILL.md';
|
||||
|
||||
const SKILL_NAMESPACE_PREFIXES = {
|
||||
'agent': './lobe/skills/agent/skills',
|
||||
'agent-topic': './lobe/skills/agent-topic/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',
|
||||
'topic-skills': 'agent-topic',
|
||||
'topic': 'agent-topic',
|
||||
} 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, topic-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 requireTopicId(namespace: SkillFsNamespace | undefined, topicId?: string) {
|
||||
if (namespace === 'agent-topic' && !topicId) {
|
||||
exitWithError('--topic-id is required for agent-topic fs paths.');
|
||||
}
|
||||
}
|
||||
|
||||
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 === 'agent-topic' ? 'topic-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);
|
||||
requireTopicId(resolved.namespace, context.topicId);
|
||||
|
||||
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);
|
||||
requireTopicId(resolved.namespace, context.topicId);
|
||||
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);
|
||||
requireTopicId(resolved.namespace, context.topicId);
|
||||
|
||||
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);
|
||||
requireTopicId(resolved.namespace, context.topicId);
|
||||
|
||||
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);
|
||||
|
||||
requireTopicId(sourceResolved.namespace, context.topicId);
|
||||
requireTopicId(destinationResolved.namespace, context.topicId);
|
||||
|
||||
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);
|
||||
|
||||
requireTopicId(sourceResolved.namespace, context.topicId);
|
||||
requireTopicId(destinationResolved.namespace, context.topicId);
|
||||
|
||||
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:/');
|
||||
requireTopicId(resolved.namespace, context.topicId);
|
||||
|
||||
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);
|
||||
requireTopicId(resolved.namespace, context.topicId);
|
||||
|
||||
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);
|
||||
requireTopicId(resolved.namespace, context.topicId);
|
||||
|
||||
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('-t, --topic-id <id>', 'Topic ID for agent-topic paths')
|
||||
.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:/');
|
||||
requireTopicId(resolved.namespace, context.topicId);
|
||||
|
||||
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')
|
||||
.option('-t, --topic-id <id>', 'Topic ID for agent-topic paths')
|
||||
.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:/');
|
||||
requireTopicId(resolved.namespace, context.topicId);
|
||||
|
||||
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')
|
||||
.option('-t, --topic-id <id>', 'Topic ID for agent-topic paths')
|
||||
.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('-t, --topic-id <id>', 'Topic ID for agent-topic paths')
|
||||
.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);
|
||||
requireTopicId(resolved.namespace, context.topicId);
|
||||
|
||||
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('-t, --topic-id <id>', 'Topic ID for agent-topic paths')
|
||||
.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('-t, --topic-id <id>', 'Topic ID for agent-topic paths')
|
||||
.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('-t, --topic-id <id>', 'Topic ID for agent-topic paths')
|
||||
.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('-t, --topic-id <id>', 'Topic ID for agent-topic paths')
|
||||
.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('-t, --topic-id <id>', 'Topic ID for agent-topic source or destination paths')
|
||||
.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('-t, --topic-id <id>', 'Topic ID for agent-topic source or destination paths')
|
||||
.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('-t, --topic-id <id>', 'Topic ID for agent-topic paths')
|
||||
.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')
|
||||
.option('-t, --topic-id <id>', 'Topic ID for agent-topic paths')
|
||||
.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('-t, --topic-id <id>', 'Topic ID for agent-topic paths')
|
||||
.option('-r, --recursive', 'Recursively delete a directory subtree')
|
||||
.option('-f, --force', 'Forward force semantics to the permanent delete primitive')
|
||||
.option('--yes', 'Skip confirmation prompt')
|
||||
.action(
|
||||
async (
|
||||
inputPath: string,
|
||||
options: {
|
||||
agentId?: string;
|
||||
force?: boolean;
|
||||
recursive?: boolean;
|
||||
slug?: string;
|
||||
topicId?: string;
|
||||
yes?: boolean;
|
||||
},
|
||||
) => {
|
||||
if (!options.yes) {
|
||||
const confirmed = await confirm(`Permanently delete ${inputPath}?`);
|
||||
if (!confirmed) {
|
||||
console.log('Cancelled.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const client = await getTrpcClient();
|
||||
const context = await resolveAgentFsContext(client, options);
|
||||
await deleteTrashFsPath(client, context, inputPath, {
|
||||
force: options.force,
|
||||
recursive: options.recursive,
|
||||
});
|
||||
console.log(`${pc.green('✓')} permanently deleted ${pc.bold(inputPath)}`);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register agent document VFS commands under `agent space fs`.
|
||||
*
|
||||
* Use when:
|
||||
* - The CLI should expose filesystem-like operations for an agent document space.
|
||||
* - Command registration should stay outside the core `agent` command file.
|
||||
*
|
||||
* Expects:
|
||||
* - `agentCommand` to be the existing `agent` command group.
|
||||
*
|
||||
* Returns:
|
||||
* - Registered Commander subcommands.
|
||||
*/
|
||||
export function registerAgentSpaceFsCommand(agentCommand: Command) {
|
||||
const spaceCommand = agentCommand.command('space').description('Manage agent document space');
|
||||
const fsCommand = spaceCommand.command('fs').description('Operate on the agent document VFS');
|
||||
registerFsCommands(fsCommand);
|
||||
}
|
||||
@@ -7,54 +7,7 @@ import { confirm, outputJson, printBoxTable, printTable, timeAgo } from '../util
|
||||
import { log } from '../utils/logger';
|
||||
import { registerBotMessageCommands } from './botMessage';
|
||||
|
||||
// ── Access policy helpers ──────────────────────────────
|
||||
|
||||
const DM_POLICIES = ['open', 'allowlist', 'pairing', 'disabled'] as const;
|
||||
const GROUP_POLICIES = ['open', 'allowlist', 'disabled'] as const;
|
||||
type DmPolicy = (typeof DM_POLICIES)[number];
|
||||
type GroupPolicy = (typeof GROUP_POLICIES)[number];
|
||||
|
||||
interface AllowEntry {
|
||||
id: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize an allow-list value into `{id, name?}[]`. Mirrors the server-side
|
||||
* back-compat parser — `settings.allowFrom` may be on disk as a comma-separated
|
||||
* string, a bare `string[]`, or the current `{id, name?}[]` shape. The CLI
|
||||
* needs the canonical form before push/filter operations and before sending
|
||||
* back to the server.
|
||||
*/
|
||||
function normalizeAllowList(raw: unknown): AllowEntry[] {
|
||||
if (typeof raw === 'string') {
|
||||
return raw
|
||||
.split(/[\s,]+/)
|
||||
.map((id) => id.trim())
|
||||
.filter(Boolean)
|
||||
.map((id) => ({ id }));
|
||||
}
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const out: AllowEntry[] = [];
|
||||
for (const entry of raw) {
|
||||
if (typeof entry === 'string') {
|
||||
const id = entry.trim();
|
||||
if (id) out.push({ id });
|
||||
continue;
|
||||
}
|
||||
if (entry && typeof entry === 'object' && 'id' in entry) {
|
||||
const id = (entry as { id?: unknown }).id;
|
||||
if (typeof id !== 'string' || !id.trim()) continue;
|
||||
const name = (entry as { name?: unknown }).name;
|
||||
out.push(
|
||||
typeof name === 'string' && name.trim()
|
||||
? { id: id.trim(), name: name.trim() }
|
||||
: { id: id.trim() },
|
||||
);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
// ── Helpers ──────────────────────────────────────────────
|
||||
|
||||
function maskValue(val: string): string {
|
||||
if (val.length > 8) return val.slice(0, 4) + '****' + val.slice(-4);
|
||||
@@ -125,150 +78,6 @@ async function resolvePlatform(client: TrpcClient, platformId: string) {
|
||||
return def;
|
||||
}
|
||||
|
||||
// ── Allowlist subcommand factory ────────────────────────
|
||||
|
||||
interface AllowlistGroupOptions {
|
||||
/** Description shown by `lh bot <name> --help`. */
|
||||
description: string;
|
||||
/** Settings field to mutate — `allowFrom` (user IDs) or `groupAllowFrom` (channel IDs). */
|
||||
fieldKey: 'allowFrom' | 'groupAllowFrom';
|
||||
/** Human-friendly description of what the `<id>` arg represents. */
|
||||
idLabel: string;
|
||||
/** Subcommand group name (`allowlist` or `group-allowlist`). */
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `list / add / remove / clear` subcommand group around an
|
||||
* array-typed settings field (`allowFrom` or `groupAllowFrom`). All write
|
||||
* paths read existing settings first and merge — passing only a partial
|
||||
* `settings` object to the TRPC `update` would replace the whole JSONB
|
||||
* column and silently drop unrelated fields.
|
||||
*/
|
||||
function registerAllowlistCommand(bot: Command, opts: AllowlistGroupOptions) {
|
||||
const group = bot.command(opts.name).description(opts.description);
|
||||
|
||||
// Read the current entries off a freshly-fetched bot row.
|
||||
const readEntries = (bot: any): AllowEntry[] =>
|
||||
normalizeAllowList((bot.settings as Record<string, unknown> | null)?.[opts.fieldKey]);
|
||||
|
||||
// Build the next settings payload from existing settings + the new entries.
|
||||
const buildPayload = (bot: any, nextEntries: AllowEntry[]) => ({
|
||||
id: bot.id,
|
||||
settings: {
|
||||
...(bot.settings as Record<string, unknown>),
|
||||
[opts.fieldKey]: nextEntries,
|
||||
},
|
||||
});
|
||||
|
||||
group
|
||||
.command('list <botId>')
|
||||
.description(`List ${opts.fieldKey} entries`)
|
||||
.option('--json', 'Output JSON')
|
||||
.action(async (botId: string, options: { json?: boolean }) => {
|
||||
const client = await getTrpcClient();
|
||||
const b = await findBot(client, botId);
|
||||
const entries = readEntries(b);
|
||||
|
||||
if (options.json) {
|
||||
outputJson(entries);
|
||||
return;
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
console.log(`${pc.dim(`No ${opts.fieldKey} entries.`)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
printTable(
|
||||
entries.map((e) => [e.id, e.name ?? pc.dim('-')]),
|
||||
['ID', 'NAME'],
|
||||
);
|
||||
});
|
||||
|
||||
group
|
||||
.command('add <botId> <id>')
|
||||
.description(`Add a ${opts.idLabel} to ${opts.fieldKey}`)
|
||||
.option('--name <name>', 'Optional human-friendly label so you can recognise the entry later')
|
||||
.action(async (botId: string, id: string, options: { name?: string }) => {
|
||||
const trimmedId = id.trim();
|
||||
if (!trimmedId) {
|
||||
log.error('ID cannot be empty.');
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const client = await getTrpcClient();
|
||||
const b = await findBot(client, botId);
|
||||
const entries = readEntries(b);
|
||||
|
||||
if (entries.some((e) => e.id === trimmedId)) {
|
||||
log.info(`${trimmedId} is already on the ${opts.fieldKey} list — nothing to do.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmedName = options.name?.trim();
|
||||
const next = [
|
||||
...entries,
|
||||
trimmedName ? { id: trimmedId, name: trimmedName } : { id: trimmedId },
|
||||
];
|
||||
|
||||
await client.agentBotProvider.update.mutate(buildPayload(b, next) as any);
|
||||
console.log(
|
||||
`${pc.green('✓')} Added ${pc.bold(trimmedId)}${trimmedName ? ` (${trimmedName})` : ''} to ${opts.fieldKey} (now ${next.length} entr${next.length === 1 ? 'y' : 'ies'})`,
|
||||
);
|
||||
});
|
||||
|
||||
group
|
||||
.command('remove <botId> <id>')
|
||||
.description(`Remove a ${opts.idLabel} from ${opts.fieldKey}`)
|
||||
.action(async (botId: string, id: string) => {
|
||||
const trimmedId = id.trim();
|
||||
const client = await getTrpcClient();
|
||||
const b = await findBot(client, botId);
|
||||
const entries = readEntries(b);
|
||||
const next = entries.filter((e) => e.id !== trimmedId);
|
||||
|
||||
if (next.length === entries.length) {
|
||||
log.info(`${trimmedId} is not on the ${opts.fieldKey} list — nothing to do.`);
|
||||
return;
|
||||
}
|
||||
|
||||
await client.agentBotProvider.update.mutate(buildPayload(b, next) as any);
|
||||
console.log(
|
||||
`${pc.green('✓')} Removed ${pc.bold(trimmedId)} from ${opts.fieldKey} (${next.length} entr${next.length === 1 ? 'y' : 'ies'} left)`,
|
||||
);
|
||||
});
|
||||
|
||||
group
|
||||
.command('clear <botId>')
|
||||
.description(`Clear all entries from ${opts.fieldKey}`)
|
||||
.option('--yes', 'Skip confirmation prompt')
|
||||
.action(async (botId: string, options: { yes?: boolean }) => {
|
||||
const client = await getTrpcClient();
|
||||
const b = await findBot(client, botId);
|
||||
const entries = readEntries(b);
|
||||
|
||||
if (entries.length === 0) {
|
||||
log.info(`${opts.fieldKey} is already empty — nothing to do.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!options.yes) {
|
||||
const confirmed = await confirm(
|
||||
`Clear all ${entries.length} ${opts.fieldKey} entr${entries.length === 1 ? 'y' : 'ies'} from this bot?`,
|
||||
);
|
||||
if (!confirmed) {
|
||||
console.log('Cancelled.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await client.agentBotProvider.update.mutate(buildPayload(b, []) as any);
|
||||
console.log(`${pc.green('✓')} Cleared ${opts.fieldKey} on bot ${pc.bold(botId)}`);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Command Registration ─────────────────────────────────
|
||||
|
||||
export function registerBotCommand(program: Command) {
|
||||
@@ -504,16 +313,6 @@ export function registerBotCommand(program: Command) {
|
||||
.option('--verification-token <token>', 'New verification token')
|
||||
.option('--app-id <appId>', 'New application ID')
|
||||
.option('--platform <platform>', 'New platform')
|
||||
.option(
|
||||
'--dm-policy <policy>',
|
||||
`DM access policy (${DM_POLICIES.join('|')}). 'pairing' requires --user-id.`,
|
||||
)
|
||||
.option('--group-policy <policy>', `Group/channel access policy (${GROUP_POLICIES.join('|')})`)
|
||||
.option(
|
||||
'--user-id <id>',
|
||||
"Owner's platform user ID (required for --dm-policy=pairing; auto-trusts the operator in the global allowlist)",
|
||||
)
|
||||
.option('--server-id <id>', 'Default server / guild / workspace ID for AI tool calls')
|
||||
.action(
|
||||
async (
|
||||
botId: string,
|
||||
@@ -522,15 +321,11 @@ export function registerBotCommand(program: Command) {
|
||||
appSecret?: string;
|
||||
botId?: string;
|
||||
botToken?: string;
|
||||
dmPolicy?: string;
|
||||
encryptKey?: string;
|
||||
groupPolicy?: string;
|
||||
platform?: string;
|
||||
publicKey?: string;
|
||||
secretToken?: string;
|
||||
serverId?: string;
|
||||
signingSecret?: string;
|
||||
userId?: string;
|
||||
verificationToken?: string;
|
||||
webhookProxyUrl?: string;
|
||||
},
|
||||
@@ -547,40 +342,6 @@ export function registerBotCommand(program: Command) {
|
||||
if (options.appId) input.applicationId = options.appId;
|
||||
if (options.platform) input.platform = options.platform;
|
||||
|
||||
// ── Settings (DM / group policy + identity fields) ────────────
|
||||
// Read-modify-write so we don't wipe `allowFrom`, `groupAllowFrom`,
|
||||
// or any other settings field the operator already configured.
|
||||
const settingsPatch: Record<string, unknown> = {};
|
||||
if (options.dmPolicy !== undefined) {
|
||||
if (!(DM_POLICIES as readonly string[]).includes(options.dmPolicy)) {
|
||||
log.error(
|
||||
`Invalid --dm-policy "${options.dmPolicy}". Must be one of: ${DM_POLICIES.join(', ')}`,
|
||||
);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
settingsPatch.dmPolicy = options.dmPolicy as DmPolicy;
|
||||
}
|
||||
if (options.groupPolicy !== undefined) {
|
||||
if (!(GROUP_POLICIES as readonly string[]).includes(options.groupPolicy)) {
|
||||
log.error(
|
||||
`Invalid --group-policy "${options.groupPolicy}". Must be one of: ${GROUP_POLICIES.join(', ')}`,
|
||||
);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
settingsPatch.groupPolicy = options.groupPolicy as GroupPolicy;
|
||||
}
|
||||
if (options.userId !== undefined) settingsPatch.userId = options.userId;
|
||||
if (options.serverId !== undefined) settingsPatch.serverId = options.serverId;
|
||||
|
||||
if (Object.keys(settingsPatch).length > 0) {
|
||||
input.settings = {
|
||||
...(existing.settings as Record<string, unknown>),
|
||||
...settingsPatch,
|
||||
};
|
||||
}
|
||||
|
||||
if (Object.keys(input).length <= 1) {
|
||||
log.error('No changes specified.');
|
||||
process.exit(1);
|
||||
@@ -592,22 +353,6 @@ export function registerBotCommand(program: Command) {
|
||||
},
|
||||
);
|
||||
|
||||
// ── allowlist (DM / group user gate) ──────────────────
|
||||
|
||||
registerAllowlistCommand(bot, {
|
||||
description: 'Manage the global user allowlist (gates DMs and group @mentions)',
|
||||
fieldKey: 'allowFrom',
|
||||
idLabel: 'platform user ID',
|
||||
name: 'allowlist',
|
||||
});
|
||||
|
||||
registerAllowlistCommand(bot, {
|
||||
description: 'Manage the group/channel allowlist (used when groupPolicy=allowlist)',
|
||||
fieldKey: 'groupAllowFrom',
|
||||
idLabel: 'channel / group / thread ID',
|
||||
name: 'group-allowlist',
|
||||
});
|
||||
|
||||
// ── remove ────────────────────────────────────────────
|
||||
|
||||
bot
|
||||
|
||||
@@ -1943,7 +1943,6 @@ table user_memory_persona_documents {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
ref: agent_skills.user_id - users.id
|
||||
|
||||
ref: agent_skills.zip_file_hash - global_files.hash_id
|
||||
|
||||
@@ -125,8 +125,6 @@
|
||||
"channel.secretTokenPlaceholder": "Optional secret for webhook verification",
|
||||
"channel.serverId": "Default Server ID",
|
||||
"channel.serverIdHint": "Default server / guild AI tools act on; not used for access control",
|
||||
"channel.serverIdHint.discord": "Enable Developer Mode (Settings → Advanced), then right-click the server icon → Copy Server ID.",
|
||||
"channel.serverIdHint.slack": "Workspace ID (starts with T). Find it under Settings & administration → Workspace settings, or in the workspace URL.",
|
||||
"channel.settings": "Advanced Settings",
|
||||
"channel.settingsResetConfirm": "Are you sure you want to reset advanced settings to default?",
|
||||
"channel.settingsResetDefault": "Reset to Default",
|
||||
@@ -154,13 +152,6 @@
|
||||
"channel.updateFailed": "Failed to update status",
|
||||
"channel.userId": "Your Platform User ID",
|
||||
"channel.userIdHint": "Lets AI tools reach you proactively (e.g. reminders); auto-trusted by the global allowlist",
|
||||
"channel.userIdHint.discord": "Enable Developer Mode (Settings → Advanced), then right-click your avatar → Copy User ID.",
|
||||
"channel.userIdHint.feishu": "Open your app on the Feishu / Lark Open Platform → Permissions, then look up your Open ID.",
|
||||
"channel.userIdHint.qq": "Your QQ number, shown on your QQ profile page.",
|
||||
"channel.userIdHint.slack": "Open your Slack profile → ⋮ More → Copy member ID (starts with U).",
|
||||
"channel.userIdHint.telegram": "Send any message to @userinfobot in Telegram — it replies with your numeric User ID.",
|
||||
"channel.userIdMissingDesc": "Without it, AI tools can't reach you with reminders, and pairing approvals will fail. Fill it in under Advanced Settings.",
|
||||
"channel.userIdMissingTitle": "Add your platform User ID",
|
||||
"channel.validationError": "Please fill in Application ID and Token",
|
||||
"channel.verificationToken": "Verification Token",
|
||||
"channel.verificationTokenHint": "Optional. Used to verify webhook event source.",
|
||||
|
||||
+6
-25
@@ -474,14 +474,7 @@
|
||||
"taskDetail.activities.fallback.topic": "started a topic",
|
||||
"taskDetail.activitiesEmpty": "No activity yet",
|
||||
"taskDetail.addSubtask": "Add sub-task",
|
||||
"taskDetail.artifactMenu.delete": "Remove from task",
|
||||
"taskDetail.artifactMenu.deleteConfirm.content": "This artifact will no longer appear in this task workspace.",
|
||||
"taskDetail.artifactMenu.deleteConfirm.ok": "Remove",
|
||||
"taskDetail.artifactMenu.deleteConfirm.title": "Remove this artifact?",
|
||||
"taskDetail.artifactSize": "{{value}} chars",
|
||||
"taskDetail.artifacts": "Artifacts",
|
||||
"taskDetail.blockedBy": "Blocked by {{id}}",
|
||||
"taskDetail.cancelSchedule": "Cancel schedule",
|
||||
"taskDetail.comment.cancel": "Cancel",
|
||||
"taskDetail.comment.delete": "Delete",
|
||||
"taskDetail.comment.deleteConfirm.content": "This comment will be permanently removed.",
|
||||
@@ -496,6 +489,7 @@
|
||||
"taskDetail.instruction": "Instruction",
|
||||
"taskDetail.instructionPlaceholder": "Click to edit task instructions...",
|
||||
"taskDetail.latestActivity.brief": "Brief: {{title}}",
|
||||
"taskDetail.latestActivity.briefOnly": "Brief",
|
||||
"taskDetail.latestActivity.briefWithAction": "{{title}} - {{action}}",
|
||||
"taskDetail.latestActivity.briefWithType": "Brief ({{type}}): {{title}}",
|
||||
"taskDetail.latestActivity.briefWithTypeOnly": "Brief ({{type}})",
|
||||
@@ -504,7 +498,6 @@
|
||||
"taskDetail.latestActivity.untitledTopic": "Untitled topic",
|
||||
"taskDetail.modelConfig": "Model Override",
|
||||
"taskDetail.navigation": "Navigation",
|
||||
"taskDetail.nextRunCountdown": "Next run in {{countdown}}",
|
||||
"taskDetail.pauseTask": "Pause task",
|
||||
"taskDetail.priority.high": "High",
|
||||
"taskDetail.priority.low": "Low",
|
||||
@@ -522,14 +515,12 @@
|
||||
"taskDetail.status.failed": "Failed",
|
||||
"taskDetail.status.paused": "Pending review",
|
||||
"taskDetail.status.running": "In progress",
|
||||
"taskDetail.status.scheduled": "Scheduled",
|
||||
"taskDetail.stopTask": "Stop task",
|
||||
"taskDetail.subIssueOf": "Sub-issue of",
|
||||
"taskDetail.subtaskInstructionPlaceholder": "Describe the sub-task...",
|
||||
"taskDetail.subtasks": "Subtasks",
|
||||
"taskDetail.titlePlaceholder": "Enter task title...",
|
||||
"taskDetail.topicDrawer.untitled": "Untitled",
|
||||
"taskDetail.untitled": "Untitled",
|
||||
"taskDetail.updateFailed": "Failed to update task",
|
||||
"taskList.activeTasks": "Active Tasks",
|
||||
"taskList.all": "All tasks",
|
||||
@@ -578,33 +569,23 @@
|
||||
"taskList.view.board": "Board",
|
||||
"taskList.view.list": "List",
|
||||
"taskList.viewAll": "View all",
|
||||
"taskSchedule.advancedSettings": "Advanced settings",
|
||||
"taskSchedule.clear": "Clear",
|
||||
"taskSchedule.continuous": "Continuous",
|
||||
"taskSchedule.enable": "Enable automation",
|
||||
"taskSchedule.every": "Every",
|
||||
"taskSchedule.frequency": "Frequency",
|
||||
"taskSchedule.heading": "Automation",
|
||||
"taskSchedule.hours": "Hours",
|
||||
"taskSchedule.intervalLabel": "Run interval",
|
||||
"taskSchedule.intervalSuffix": "each time",
|
||||
"taskSchedule.intervalTab": "Heartbeat",
|
||||
"taskSchedule.interval": "Recurring",
|
||||
"taskSchedule.intervalTab": "Recurring",
|
||||
"taskSchedule.maxExecutions": "Max runs",
|
||||
"taskSchedule.maxExecutionsPlaceholder": "Unlimited",
|
||||
"taskSchedule.minutes": "Minutes",
|
||||
"taskSchedule.nextRun": "Next run",
|
||||
"taskSchedule.nextRun.format": "MMM D HH:mm",
|
||||
"taskSchedule.scheduleType.daily": "Daily",
|
||||
"taskSchedule.scheduleType.hourly": "Hourly",
|
||||
"taskSchedule.scheduleType.weekly": "Weekly",
|
||||
"taskSchedule.scheduler": "Scheduler",
|
||||
"taskSchedule.schedulerTab": "Scheduled",
|
||||
"taskSchedule.summary.daily": "Daily at {{time}}",
|
||||
"taskSchedule.summary.disabled": "Automation is off",
|
||||
"taskSchedule.summary.everyNHours": "Every {{count}} hours{{minute}}",
|
||||
"taskSchedule.summary.heartbeat": "Runs every {{interval}}",
|
||||
"taskSchedule.summary.hourly": "Every hour{{minute}}",
|
||||
"taskSchedule.summary.weekly": "Every {{days}} at {{time}}",
|
||||
"taskSchedule.schedulerNotReady": "Scheduler is coming soon. Use Recurring for now.",
|
||||
"taskSchedule.schedulerTab": "Scheduler",
|
||||
"taskSchedule.seconds": "Seconds",
|
||||
"taskSchedule.tag.add": "Set schedule",
|
||||
"taskSchedule.tag.every": "Every {{interval}}",
|
||||
"taskSchedule.tag.heartbeat": "Heartbeat · {{every}}",
|
||||
|
||||
@@ -12,10 +12,6 @@
|
||||
"brief.collapse": "Show less",
|
||||
"brief.commentPlaceholder": "Share your feedback...",
|
||||
"brief.commentSubmit": "Submit feedback",
|
||||
"brief.delete": "Delete",
|
||||
"brief.deleteConfirm.content": "This brief will be permanently removed.",
|
||||
"brief.deleteConfirm.ok": "Delete",
|
||||
"brief.deleteConfirm.title": "Delete this brief?",
|
||||
"brief.editResult": "Edit",
|
||||
"brief.expandAll": "Show more",
|
||||
"brief.feedbackSent": "Feedback shared",
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
{
|
||||
"action.connect.button": "Connect {{provider}}",
|
||||
"action.create.error": "Failed to create task. Please try again.",
|
||||
"action.create.success": "Scheduled task added. Find it in Lobe AI.",
|
||||
"action.createButton": "Add as scheduled task",
|
||||
"action.creating": "Creating...",
|
||||
"action.dismiss.error": "Failed to dismiss. Please try again.",
|
||||
"action.dismiss.tooltip": "Not interested",
|
||||
"arxiv-curated-daily.description": "Every morning, pick 5 fresh papers in your research area with one-line summaries. Cut your paper-scanning time in half.",
|
||||
"arxiv-curated-daily.prompt": "Every morning at 9:00, pick 5 of the latest arXiv papers in my research area and give me a one-line summary for each, so I can decide which to read in depth.",
|
||||
"arxiv-curated-daily.title": "ArXiv daily picks",
|
||||
"competitor-radar-weekly.description": "Every Monday, scan your top competitors for product launches, pricing changes, hiring signals, and press mentions.",
|
||||
"competitor-radar-weekly.prompt": "Every Monday at 10:00, scan my top competitors for the past week — product launches, pricing changes, hiring signals, press mentions — and summarize what each move implies strategically.",
|
||||
"competitor-radar-weekly.title": "Competitor radar weekly",
|
||||
"daily-design-inspiration.description": "Each morning, curate 10 works from Dribbble, Behance, Awwwards and Pinterest that match your style.",
|
||||
"daily-design-inspiration.prompt": "Every morning at 9:00, curate 10 design works from Dribbble, Behance, Awwwards, and Pinterest that match my style, with a short note on what makes each one stand out.",
|
||||
"daily-design-inspiration.title": "Daily design inspiration",
|
||||
"daily-learning-bite.description": "Each morning, deliver one 15-minute curated piece (article, video, or podcast) in your learning area.",
|
||||
"daily-learning-bite.prompt": "Every morning at 7:30, bring me one 15-minute curated piece (article, video, or podcast) in my learning area, with a quick takeaway.",
|
||||
"daily-learning-bite.title": "Daily learning bite",
|
||||
"daily-topic-pick.description": "Each morning, scan the top 10 pieces that performed best in your niche yesterday and break down the angles.",
|
||||
"daily-topic-pick.prompt": "Every morning at 9:00, gather the 10 best-performing pieces of content from my niche yesterday, break down their angles, and pick 1-2 I could publish today.",
|
||||
"daily-topic-pick.title": "Daily topic radar",
|
||||
"feature-ideation-friday.description": "Every Friday afternoon, generate 5 feature ideas based on this week's user feedback and competitor moves.",
|
||||
"feature-ideation-friday.prompt": "Every Friday at 15:00, synthesize this week's user feedback and competitor activity into 5 concrete feature ideas, each with a one-line user-value statement and a rough effort tag (S/M/L).",
|
||||
"feature-ideation-friday.title": "Feature ideation Friday",
|
||||
"font-of-the-week.description": "Each Wednesday, one handpicked typeface with use cases, pairing suggestions, and where to get it.",
|
||||
"font-of-the-week.prompt": "Every Wednesday at 9:00, pick one noteworthy typeface, explain its best use cases, suggest 2 pairings, and list where designers can license or download it.",
|
||||
"font-of-the-week.title": "Font of the week",
|
||||
"frontend-weekly-digest.description": "Every Monday, a curated digest of frontend news: browser updates, framework releases, notable blog posts.",
|
||||
"frontend-weekly-digest.prompt": "Every Monday at 9:00, curate a frontend weekly digest: browser engine updates, framework releases (React, Vue, Svelte, etc.), notable engineering blog posts — 10 items with a one-line takeaway each.",
|
||||
"frontend-weekly-digest.title": "Frontend weekly digest",
|
||||
"github-pr-review-daily.description": "Each morning, list the PRs awaiting your review across your GitHub repos with a one-line takeaway each.",
|
||||
"github-pr-review-daily.prompt": "Every morning at 9:00, fetch open PRs across my GitHub repos that are waiting on my review. For each, summarize the change in one line and flag anything that looks risky or has been sitting for more than 2 days.",
|
||||
"github-pr-review-daily.title": "GitHub PR review queue",
|
||||
"hn-writing-angles.description": "Each morning, mine Hacker News front-page discussions and pull out 5 angles you could write about today.",
|
||||
"hn-writing-angles.prompt": "Every morning at 10:00, scan today's Hacker News front page and comments, surface 5 writable angles relevant to my niche, and note why each one has momentum right now.",
|
||||
"hn-writing-angles.title": "HN writing angles",
|
||||
"industry-morning-brief.description": "Each morning, condense 5 important news items, funding rounds and policy shifts in your industry into a 5-minute read.",
|
||||
"industry-morning-brief.prompt": "Every morning at 8:00, condense 5 important news items, funding rounds, and policy shifts from my industry into a 5-minute read.",
|
||||
"industry-morning-brief.title": "Industry morning brief",
|
||||
"leetcode-daily.description": "One LeetCode problem every evening with a reference solution and two alternative approaches.",
|
||||
"leetcode-daily.prompt": "Every evening at 19:00, pick one LeetCode problem at an appropriate difficulty, show the reference solution, and explain two alternative approaches with tradeoffs.",
|
||||
"leetcode-daily.title": "LeetCode daily drill",
|
||||
"marketing-hot-radar.description": "Each morning, track 5 marketing topics heating up in your industry — which to ride, which to avoid.",
|
||||
"marketing-hot-radar.prompt": "Every morning at 10:00, track 5 marketing topics heating up in my industry, flag which ones to ride and which to avoid, with 1-2 sentence reasoning.",
|
||||
"marketing-hot-radar.title": "Marketing hot radar",
|
||||
"notion-weekly-digest.description": "Every Monday, summarize last week's edits and new pages in your Notion workspace, grouped by area.",
|
||||
"notion-weekly-digest.prompt": "Every Monday at 9:00, scan my Notion workspace for pages edited or created in the last 7 days. Group by top-level area and pick the 5 most important changes worth re-reading.",
|
||||
"notion-weekly-digest.title": "Notion weekly digest",
|
||||
"oss-intel-daily.description": "Each morning, get 10 tech stack updates: GitHub Trending, big-name open-sourcing, key repo releases.",
|
||||
"oss-intel-daily.prompt": "Every morning at 9:00, give me 10 tech-stack updates: GitHub Trending, notable open-source releases from big companies, and new releases from repos in my stack.",
|
||||
"oss-intel-daily.title": "Open-source intel daily",
|
||||
"sales-pipeline-review.description": "Every Friday, review your pipeline: stalled deals, upcoming renewals, and 3 high-priority follow-ups for next week.",
|
||||
"sales-pipeline-review.prompt": "Every Friday at 17:00, review my sales pipeline: list deals stalled for over 7 days, upcoming renewals in the next 30 days, and suggest 3 high-priority follow-ups for next Monday with talking points.",
|
||||
"sales-pipeline-review.title": "Sales pipeline review",
|
||||
"schedule.daily": "Every day at {{time}}",
|
||||
"schedule.weekly": "Every {{weekday}} at {{time}}",
|
||||
"section.title": "Try these scheduled tasks",
|
||||
"seo-weekly-report.description": "Every Monday, a lightweight SEO report: ranking movement, new keywords to chase, and pages worth refreshing.",
|
||||
"seo-weekly-report.prompt": "Every Monday at 9:00, give me a lightweight SEO weekly: top ranking movers (up/down), 5 emerging keywords worth targeting, and 3 existing pages ripe for a content refresh.",
|
||||
"seo-weekly-report.title": "SEO weekly report",
|
||||
"user-feedback-daily.description": "Each morning, aggregate feedback from all channels (stores, social, support) into top 20 items, sorted by sentiment and theme.",
|
||||
"user-feedback-daily.prompt": "Every morning at 9:00, aggregate user feedback from all channels (app stores, social media, customer support) into the top 20 items, sorted by sentiment and theme.",
|
||||
"user-feedback-daily.title": "User feedback daily",
|
||||
"weekly-engineering-digest.description": "Every Friday afternoon, cross-reference your GitHub PR activity and Linear sprint progress into a single end-of-week status.",
|
||||
"weekly-engineering-digest.prompt": "Every Friday at 17:00, combine my GitHub PR activity (merged, reviewed, opened) with my Linear sprint status (issues closed, in progress, blockers) into one end-of-week status. Highlight 3 things shipped and 1-2 risks for next week.",
|
||||
"weekly-engineering-digest.title": "Weekly engineering digest"
|
||||
}
|
||||
@@ -125,8 +125,6 @@
|
||||
"channel.secretTokenPlaceholder": "可选的 Webhook 验证密钥",
|
||||
"channel.serverId": "默认服务器 ID",
|
||||
"channel.serverIdHint": "AI 工具默认作用的服务器 / Guild,与访问控制无关",
|
||||
"channel.serverIdHint.discord": "在 Discord 设置 → 高级中开启开发者模式,然后右键服务器图标 → 复制服务器 ID。",
|
||||
"channel.serverIdHint.slack": "Workspace ID(以 T 开头),在 设置与管理 → 工作空间设置 中查看,或从工作空间 URL 中获取。",
|
||||
"channel.settings": "高级设置",
|
||||
"channel.settingsResetConfirm": "确定要将高级设置恢复为默认配置吗?",
|
||||
"channel.settingsResetDefault": "恢复默认配置",
|
||||
@@ -154,13 +152,6 @@
|
||||
"channel.updateFailed": "更新状态失败",
|
||||
"channel.userId": "你的平台用户 ID",
|
||||
"channel.userIdHint": "供 AI 工具主动联系你(如提醒、通知),并自动加入全局白名单",
|
||||
"channel.userIdHint.discord": "在 Discord 设置 → 高级中开启开发者模式,然后右键你的头像 → 复制用户 ID。",
|
||||
"channel.userIdHint.feishu": "在飞书 / Lark 开放平台打开你的应用 → 权限管理,查看你的 Open ID。",
|
||||
"channel.userIdHint.qq": "你的 QQ 号,在 QQ 资料页可见。",
|
||||
"channel.userIdHint.slack": "打开 Slack 个人资料 → ⋮ 更多 → 复制 Member ID(以 U 开头)。",
|
||||
"channel.userIdHint.telegram": "在 Telegram 中给 @userinfobot 发送任意消息,它会回复你的数字 User ID。",
|
||||
"channel.userIdMissingDesc": "未填写时,AI 工具无法主动联系你,配对申请也将无法被批准。请在「高级设置」中补充。",
|
||||
"channel.userIdMissingTitle": "建议补充你的平台用户 ID",
|
||||
"channel.validationError": "请填写应用 ID 和 Token",
|
||||
"channel.verificationToken": "Verification Token",
|
||||
"channel.verificationTokenHint": "可选。用于验证事件推送来源。",
|
||||
|
||||
+8
-27
@@ -474,14 +474,7 @@
|
||||
"taskDetail.activities.fallback.topic": "发起了主题",
|
||||
"taskDetail.activitiesEmpty": "暂无活动记录",
|
||||
"taskDetail.addSubtask": "添加子任务",
|
||||
"taskDetail.artifactMenu.delete": "从任务中移除",
|
||||
"taskDetail.artifactMenu.deleteConfirm.content": "该产物将不再出现在此任务工作区中。",
|
||||
"taskDetail.artifactMenu.deleteConfirm.ok": "移除",
|
||||
"taskDetail.artifactMenu.deleteConfirm.title": "移除该产物?",
|
||||
"taskDetail.artifactSize": "{{value}} 字",
|
||||
"taskDetail.artifacts": "产物",
|
||||
"taskDetail.blockedBy": "被 {{id}} 阻塞",
|
||||
"taskDetail.cancelSchedule": "取消定时",
|
||||
"taskDetail.comment.cancel": "取消",
|
||||
"taskDetail.comment.delete": "删除",
|
||||
"taskDetail.comment.deleteConfirm.content": "此评论将被永久删除。",
|
||||
@@ -496,6 +489,7 @@
|
||||
"taskDetail.instruction": "任务说明",
|
||||
"taskDetail.instructionPlaceholder": "点击编辑任务说明…",
|
||||
"taskDetail.latestActivity.brief": "简要:{{title}}",
|
||||
"taskDetail.latestActivity.briefOnly": "简要",
|
||||
"taskDetail.latestActivity.briefWithAction": "{{title}} - {{action}}",
|
||||
"taskDetail.latestActivity.briefWithType": "简要({{type}}):{{title}}",
|
||||
"taskDetail.latestActivity.briefWithTypeOnly": "简要({{type}})",
|
||||
@@ -504,7 +498,6 @@
|
||||
"taskDetail.latestActivity.untitledTopic": "未命名主题",
|
||||
"taskDetail.modelConfig": "模型覆盖",
|
||||
"taskDetail.navigation": "导航",
|
||||
"taskDetail.nextRunCountdown": "{{countdown}} 后执行",
|
||||
"taskDetail.pauseTask": "暂停任务",
|
||||
"taskDetail.priority.high": "高",
|
||||
"taskDetail.priority.low": "低",
|
||||
@@ -522,14 +515,12 @@
|
||||
"taskDetail.status.failed": "失败",
|
||||
"taskDetail.status.paused": "待审阅",
|
||||
"taskDetail.status.running": "进行中",
|
||||
"taskDetail.status.scheduled": "已排期",
|
||||
"taskDetail.stopTask": "停止任务",
|
||||
"taskDetail.subIssueOf": "隶属于",
|
||||
"taskDetail.subtaskInstructionPlaceholder": "描述这个子任务…",
|
||||
"taskDetail.subtasks": "子任务",
|
||||
"taskDetail.titlePlaceholder": "输入任务标题…",
|
||||
"taskDetail.topicDrawer.untitled": "未命名",
|
||||
"taskDetail.untitled": "无标题",
|
||||
"taskDetail.updateFailed": "任务更新失败",
|
||||
"taskList.activeTasks": "进行中的任务",
|
||||
"taskList.all": "全部任务",
|
||||
@@ -578,33 +569,23 @@
|
||||
"taskList.view.board": "看板",
|
||||
"taskList.view.list": "列表",
|
||||
"taskList.viewAll": "查看全部",
|
||||
"taskSchedule.advancedSettings": "高级设置",
|
||||
"taskSchedule.clear": "清除",
|
||||
"taskSchedule.continuous": "持续运行",
|
||||
"taskSchedule.continuous": "持续执行",
|
||||
"taskSchedule.enable": "启用自动化",
|
||||
"taskSchedule.every": "每",
|
||||
"taskSchedule.frequency": "运行频率",
|
||||
"taskSchedule.heading": "自动化",
|
||||
"taskSchedule.frequency": "执行频率",
|
||||
"taskSchedule.hours": "小时",
|
||||
"taskSchedule.intervalLabel": "运行间隔",
|
||||
"taskSchedule.intervalSuffix": "运行一次",
|
||||
"taskSchedule.intervalTab": "心跳模式",
|
||||
"taskSchedule.maxExecutions": "运行次数限制",
|
||||
"taskSchedule.maxExecutionsPlaceholder": "无限制",
|
||||
"taskSchedule.interval": "循环任务",
|
||||
"taskSchedule.intervalTab": "循环任务",
|
||||
"taskSchedule.maxExecutions": "最大次数",
|
||||
"taskSchedule.minutes": "分钟",
|
||||
"taskSchedule.nextRun": "下次运行",
|
||||
"taskSchedule.nextRun.format": "M月D日 HH:mm",
|
||||
"taskSchedule.scheduleType.daily": "每日",
|
||||
"taskSchedule.scheduleType.hourly": "每小时",
|
||||
"taskSchedule.scheduleType.weekly": "每周",
|
||||
"taskSchedule.scheduler": "定时任务",
|
||||
"taskSchedule.schedulerNotReady": "定时任务即将上线。暂时请使用“循环任务”。",
|
||||
"taskSchedule.schedulerTab": "定时任务",
|
||||
"taskSchedule.summary.daily": "每天 {{time}} 运行",
|
||||
"taskSchedule.summary.disabled": "自动化未启用",
|
||||
"taskSchedule.summary.everyNHours": "每 {{count}} 小时{{minute}}",
|
||||
"taskSchedule.summary.heartbeat": "每 {{interval}} 运行一次",
|
||||
"taskSchedule.summary.hourly": "每小时{{minute}}",
|
||||
"taskSchedule.summary.weekly": "每周 {{days}} {{time}} 运行",
|
||||
"taskSchedule.seconds": "秒",
|
||||
"taskSchedule.tag.add": "设置计划",
|
||||
"taskSchedule.tag.every": "每 {{interval}}",
|
||||
"taskSchedule.tag.heartbeat": "心跳 · {{every}}",
|
||||
|
||||
@@ -12,10 +12,6 @@
|
||||
"brief.collapse": "收起",
|
||||
"brief.commentPlaceholder": "分享你的反馈…",
|
||||
"brief.commentSubmit": "提交反馈",
|
||||
"brief.delete": "删除",
|
||||
"brief.deleteConfirm.content": "该简报将被永久删除,且无法恢复。",
|
||||
"brief.deleteConfirm.ok": "删除",
|
||||
"brief.deleteConfirm.title": "确定删除此简报?",
|
||||
"brief.editResult": "编辑",
|
||||
"brief.expandAll": "展开全部",
|
||||
"brief.feedbackSent": "反馈已提交",
|
||||
|
||||
@@ -87,19 +87,10 @@
|
||||
"finish": "开始使用",
|
||||
"interests.area.business": "商业与战略",
|
||||
"interests.area.coding": "编程与开发",
|
||||
"interests.area.creator": "创作者经济",
|
||||
"interests.area.design": "设计与创意",
|
||||
"interests.area.education": "学习与研究",
|
||||
"interests.area.finance-legal": "财务与法务",
|
||||
"interests.area.health": "健康与习惯",
|
||||
"interests.area.hobbies": "兴趣与探索",
|
||||
"interests.area.hr": "人力资源",
|
||||
"interests.area.investing": "投资与理财",
|
||||
"interests.area.marketing": "市场与推广",
|
||||
"interests.area.operations": "运营与行政",
|
||||
"interests.area.other": "其他领域",
|
||||
"interests.area.parenting": "家庭与育儿",
|
||||
"interests.area.personal": "个人生活",
|
||||
"interests.area.product": "产品与管理",
|
||||
"interests.area.sales": "销售与客户",
|
||||
"interests.area.writing": "内容创作",
|
||||
|
||||
@@ -1,349 +0,0 @@
|
||||
{
|
||||
"action.connect.button": "连接 {{provider}}",
|
||||
"action.create.error": "创建任务失败,请稍后再试",
|
||||
"action.create.success": "定时任务已创建,可在 Lobe AI 中查看",
|
||||
"action.createButton": "添加为定时任务",
|
||||
"action.creating": "创建中…",
|
||||
"action.dismiss.error": "操作失败,请稍后再试",
|
||||
"action.dismiss.tooltip": "不感兴趣",
|
||||
"action.optionalConnect.button": "连接 {{provider}} 获取更丰富的内容",
|
||||
"ad-creative-inspiration.description": "每天扫一遍竞品和标杆品牌的新广告素材(Meta / Google Ads Library),挖能复刻的 10 条",
|
||||
"ad-creative-inspiration.prompt": "每天早上 10:00 扫一遍我的竞品和标杆品牌在 Meta 和 Google Ads Library 上的新素材,挑出 10 条值得复刻的,并说明每条值得的理由。",
|
||||
|
||||
"ad-creative-inspiration.title": "投放素材灵感",
|
||||
"aigc-prompt-inspiration.description": "每天 5 组精选 Prompt(Midjourney / SD / Flux),按风格分类,今天就能试",
|
||||
"aigc-prompt-inspiration.prompt": "每天早上 10:00 给我 5 组精选 Prompt(Midjourney / Stable Diffusion / Flux),按风格分类,每条都要可以直接复制使用。",
|
||||
|
||||
"aigc-prompt-inspiration.title": "AIGC Prompt 灵感",
|
||||
"arxiv-curated-daily.description": "每天早上帮你筛 5 篇最新论文 + 一句话摘要,刷论文时间省一半",
|
||||
"arxiv-curated-daily.prompt": "每天早上 9:00 帮我从 arXiv 筛 5 篇和我研究方向相关的最新论文,每篇附一句话摘要,帮我判断哪些值得精读。",
|
||||
|
||||
"arxiv-curated-daily.title": "ArXiv 精选",
|
||||
"bedtime-gratitude.description": "每天 22 点引导你写下今天三件感谢的事 + 学到的一件事,沉淀进笔记",
|
||||
"bedtime-gratitude.prompt": "每天晚上 22:00 引导我写下今天三件感谢的事和学到的一件事,回我一段温柔的小结。如果连接了 Notion,把这条感恩记录追加到我的日记页。",
|
||||
|
||||
"bedtime-gratitude.title": "睡前感恩",
|
||||
"brand-collab-weekly.description": "每周一扫一遍正在找创作者的品牌和公开招募,匹配你的领域和粉丝规模",
|
||||
"brand-collab-weekly.prompt": "每周一早上 10:00 扫一遍正在公开招募创作者的品牌,按我的赛道和粉丝量匹配,挑出 5 个值得申请的机会。",
|
||||
|
||||
"brand-collab-weekly.title": "品牌合作机会",
|
||||
"brand-mention-daily.description": "告诉我要追踪的品牌 / 关键词,每天傍晚汇总当天提及量、情绪、热门发言者",
|
||||
"brand-mention-daily.prompt": "每天傍晚 18:00 在 X (Twitter) 上汇总我追踪的品牌和关键词当天的提及量、情绪、TOP 发言者,标出异常波动。",
|
||||
|
||||
"brand-mention-daily.title": "品牌声量日报",
|
||||
"brand-watch-weekly.description": "每周一追踪 10 家大厂品牌升级、Logo 改版、官网重设计,附拆解视角",
|
||||
"brand-watch-weekly.prompt": "每周一早上 10:00 追踪 10 家我关注的品牌动态:品牌升级、Logo 改版、官网重设计,每条附一段拆解。",
|
||||
|
||||
"brand-watch-weekly.title": "大厂品牌动态",
|
||||
"calendar-conflict-check.description": "每天早上检查日程有没有冲突、间隔太紧、通勤不够",
|
||||
"calendar-conflict-check.prompt": "每天早上 7:30 检查我今天的日历,找出冲突、连背靠背会议、通勤或缓冲时间不够的情况,给出建议调整方案。",
|
||||
|
||||
"calendar-conflict-check.title": "日历冲突检查",
|
||||
"cashflow-weekly.description": "每周一扫一下本周该收的款、该付的账、下周大额支出预警",
|
||||
"cashflow-weekly.prompt": "每周一早上 9:00 扫一遍本周应收款、应付账,并预警下周的大额支出。",
|
||||
|
||||
"cashflow-weekly.title": "现金流周报",
|
||||
"child-growth-weekly.description": "告诉我孩子年龄,每周一给你本周发育重点、亲子活动建议、注意事项",
|
||||
"child-growth-weekly.prompt": "每周一早上 9:00 根据我孩子的年龄,给出本周的发育重点、亲子活动建议和需要留心的注意事项。",
|
||||
|
||||
"child-growth-weekly.title": "孩子成长周报",
|
||||
"child-study-weekly.description": "告诉我孩子在学的科目,每周日帮你回顾本周完成情况 + 下周重点",
|
||||
"child-study-weekly.prompt": "每周日晚 20:00 回顾我孩子本周的学习进度,并梳理下周的学习重点,按科目给出练习建议。",
|
||||
|
||||
"child-study-weekly.title": "孩子学习追踪",
|
||||
"competitor-creator-tracking.description": "告诉我 3-5 个你关注的创作者,每天看他们发了什么、数据怎样,挖能复刻的思路",
|
||||
"competitor-creator-tracking.prompt": "每天早上 9:00 追踪我设定的 3-5 个对标创作者:他们发了什么内容、数据如何,挖出我可以复刻的思路。",
|
||||
|
||||
"competitor-creator-tracking.title": "竞品创作者追踪",
|
||||
"competitor-radar-daily.description": "告诉我 3-5 个竞争对手,每天帮你盯他们的官网更新、产品发布、招聘信号、社媒动态",
|
||||
"competitor-radar-daily.prompt": "每天早上 9:00 追踪我设定的 3-5 个竞争对手:官网更新、产品发布、招聘信号、社媒动态,分析每个动作的战略含义。",
|
||||
|
||||
"competitor-radar-daily.title": "竞品动态追踪",
|
||||
"competitor-update-daily.description": "告诉我 3-5 个竞品,每天看他们的更新日志、新功能、官网变化",
|
||||
"competitor-update-daily.prompt": "每天早上 10:00 监测 3-5 个竞品产品的更新:更新日志、新功能、官网文案变化,标出值得深入研究的信号。",
|
||||
|
||||
"competitor-update-daily.title": "竞品更新追踪",
|
||||
"content-calendar-weekly.description": "每周日晚帮你规划下周 7 天的发布计划,对齐节日和热点节奏",
|
||||
"content-calendar-weekly.prompt": "每周日晚 20:00 帮我规划下周 7 天的发布计划:把日程对齐近期节日和热点节奏,每个时段给一个建议选题。如果连接了 Notion,把这份计划同步成排期表。",
|
||||
|
||||
"content-calendar-weekly.title": "内容日历",
|
||||
"contract-expiry-weekly.description": "每周一检查下个月到期的合同(订阅 / 租赁 / 合作),提前续签或解约",
|
||||
"contract-expiry-weekly.prompt": "每周一早上 9:00 列出未来 30 天到期的合同(订阅、租赁、合作),标注哪些需要续签、哪些可以解约。",
|
||||
|
||||
"contract-expiry-weekly.title": "合同到期预警",
|
||||
"core-metric-daily.description": "告诉我要看的指标(DAU、留存、转化),每天早上自动同步变化",
|
||||
"core-metric-daily.prompt": "每天早上 9:00 同步我的核心指标变化(DAU、留存、转化),与昨天和 7 日均值做对比。",
|
||||
|
||||
"core-metric-daily.title": "核心指标日报",
|
||||
"cross-platform-engagement-daily.description": "每天早上聚合你全平台的评论、私信、提及、新粉丝",
|
||||
"cross-platform-engagement-daily.prompt": "每天早上 9:00 聚合我各平台的评论、私信、提及和新粉丝,标出 5 条最值得回复的。",
|
||||
|
||||
"cross-platform-engagement-daily.title": "全平台互动日报",
|
||||
"crypto-market-daily.description": "每天早上看比特币、以太坊、你关注币种的 24h 变化 + 重要链上事件",
|
||||
"crypto-market-daily.prompt": "每天早上 9:00 给我比特币、以太坊和我关注币种的 24h 价格变化,加上过去一天最重要的链上事件。",
|
||||
|
||||
"crypto-market-daily.title": "加密市场日报",
|
||||
"daily-design-inspiration.description": "每天早上从 Dribbble、Behance、Awwwards、Pinterest 挑 10 个和你风格匹配的作品",
|
||||
"daily-design-inspiration.prompt": "每天早上 9:00 从 Dribbble、Behance、Awwwards、Pinterest 挑 10 个和我风格匹配的作品,各附一句亮点点评。",
|
||||
|
||||
"daily-design-inspiration.title": "每日灵感",
|
||||
"daily-followup-list.description": "每天早上按优先级排一遍今天该跟进的客户,附上次沟通要点",
|
||||
"daily-followup-list.prompt": "每天早上 9:00 从 HubSpot 中排出今天该跟进的客户优先级清单,每条附上次沟通要点。",
|
||||
|
||||
"daily-followup-list.title": "今日跟进清单",
|
||||
"daily-learning-bite.description": "每天给你一条 15 分钟能看完的学习内容(文章 / 视频 / 播客)",
|
||||
"daily-learning-bite.prompt": "每天早上 7:30 给我一条 15 分钟能看完的学习内容(文章 / 视频 / 播客),附一句关键收获。",
|
||||
|
||||
"daily-learning-bite.title": "每日学习料",
|
||||
"daily-topic-pick.description": "每天早上帮你扒一遍你赛道前一天跑得最好的 10 条内容,拆解选题角度",
|
||||
"daily-topic-pick.prompt": "每天早上 9:00 帮我扒我赛道前一天跑得最好的 10 条内容,拆解选题角度,挑 1-2 个我今天能直接发的。",
|
||||
|
||||
"daily-topic-pick.title": "今日选题",
|
||||
"deal-pipeline-weekly.description": "每周五盘点管道里所有 Deal:哪些推进、哪些停滞、本月能成多少",
|
||||
"deal-pipeline-weekly.prompt": "每周五下午 16:00 盘点 HubSpot 管道里的所有 Deal:本周推进的、停滞的,并预测本月能成多少。",
|
||||
|
||||
"deal-pipeline-weekly.title": "Deal Pipeline 周报",
|
||||
"dependency-security-weekly.description": "每周一自动扫一遍你项目的漏洞和过期版本,给出升级优先级",
|
||||
"dependency-security-weekly.prompt": "每周一早上 10:00 扫一遍我 GitHub 项目里有漏洞或过期的依赖,按严重程度和升级风险排出优先级。",
|
||||
|
||||
"dependency-security-weekly.title": "依赖安全周检",
|
||||
"design-trend-weekly.description": "每周一给你本周 UI / 品牌 / 插画 3 个新趋势 + 5 个代表案例",
|
||||
"design-trend-weekly.prompt": "每周一早上 9:00 给我 UI、品牌、插画领域本周 3 个新趋势,每个趋势配 5 个代表案例。",
|
||||
|
||||
"design-trend-weekly.title": "设计趋势周报",
|
||||
"diet-log-companion.description": "每天晚上帮你回顾今天吃了什么,给下次的调整建议。不强迫、不批判",
|
||||
"diet-log-companion.prompt": "每天晚上 21:00 陪我回顾今天的饮食,温柔地给一两条明天可以调整的建议,不强迫、不批判。",
|
||||
|
||||
"diet-log-companion.title": "饮食记录陪跑",
|
||||
"exhibition-event-weekly.description": "告诉我你所在城市,每周一给你本周的展览、演出、livehouse 信息",
|
||||
"exhibition-event-weekly.prompt": "每周一早上 10:00 列出我所在城市本周的展览、演出、livehouse 演出信息,给出最值得去的几条简介。",
|
||||
|
||||
"exhibition-event-weekly.title": "展览演出日历",
|
||||
"family-finance-weekly.description": "每周日晚回顾本周支出结构、预算完成度、下周大额计划",
|
||||
"family-finance-weekly.prompt": "每周日晚 20:00 基于 Google Sheets 流水回顾本周家庭支出结构、预算完成度,并预告下周的大额支出计划。",
|
||||
|
||||
"family-finance-weekly.title": "家庭财务周报",
|
||||
"family-task-schedule.description": "每周一早上分配本周家务、采购、接送、缴费,家庭群可同步",
|
||||
"family-task-schedule.prompt": "每周一早上 8:00 帮我排好本周家庭任务计划:家务、采购、接送、缴费,给每项一个暂定责任人和时间段。如果连接了 Google Calendar,建议可直接落进日程的时间块。",
|
||||
|
||||
"family-task-schedule.title": "家庭任务排期",
|
||||
"figma-files-cleanup.description": "每周五下班前帮你盘点近期更新的 Figma 文件,标记该归档的、该同步开发的",
|
||||
"figma-files-cleanup.prompt": "每周五下午 17:00 盘点近期更新的 Figma 文件,标记哪些该归档、哪些需要同步给开发,还有哪些需要继续打磨。",
|
||||
|
||||
"figma-files-cleanup.title": "Figma 文件整理",
|
||||
"follower-growth-weekly.description": "每周一看跨平台的粉丝变化:哪个平台在涨、哪个在跌、该加码哪里",
|
||||
"follower-growth-weekly.prompt": "每周一早上 10:00 回顾我 X (Twitter) 等平台的粉丝增长,标出该加码的平台和互动下滑的平台。",
|
||||
|
||||
"follower-growth-weekly.title": "粉丝增长周报",
|
||||
"font-color-weekly.description": "每周三给你 3 组值得收藏的字体组合 + 3 组配色方案,直接存进灵感库",
|
||||
"font-color-weekly.prompt": "每周三早上 10:00 给我 3 组值得收藏的字体组合和 3 组配色方案,每组字体附授权或下载渠道。",
|
||||
|
||||
"font-color-weekly.title": "字体配色周报",
|
||||
"friday-wrap-list.description": "每周五下午列一份:这周没做完的、周一要交付的、下周第一件事",
|
||||
"friday-wrap-list.prompt": "每周五下午 16:00 列一份:本周没做完的、周一要交付的、下周第一件事。",
|
||||
|
||||
"friday-wrap-list.title": "周五收尾清单",
|
||||
"funding-intel-daily.description": "每天给你 3-5 条你赛道的融资快讯:谁拿钱、估值多少、投资人是谁",
|
||||
"funding-intel-daily.prompt": "每天早上 10:00 给我赛道里过去 24 小时的 3-5 条融资快讯:谁拿了钱、金额、估值、领投方。",
|
||||
|
||||
"funding-intel-daily.title": "融资情报日报",
|
||||
"headline-inspiration.description": "每天给你 10 个符合你调性的标题模板,从近期爆款反推的结构",
|
||||
"headline-inspiration.prompt": "每天早上 10:00 从近期爆款反推 10 个符合我调性的标题模板,卡标题时直接抄。",
|
||||
|
||||
"headline-inspiration.title": "标题灵感",
|
||||
"hot-topic-radar.description": "每天早上一次性看完你领域里正在升温的 5 个话题,趁还没挤满就能下手",
|
||||
"hot-topic-radar.prompt": "每天早上 10:00 给我我赛道里正在升温但还没饱和的 5 个话题,每条说明现在为什么值得下手。",
|
||||
|
||||
"hot-topic-radar.title": "热点雷达",
|
||||
"hubspot-funnel-daily.description": "每天早上看 MQL、SQL、成交漏斗变化,标出掉单高发环节",
|
||||
"hubspot-funnel-daily.prompt": "每天早上 9:00 看 HubSpot 漏斗:MQL、SQL、成交各阶段的变化,对比上周标出掉单高发的环节。",
|
||||
|
||||
"hubspot-funnel-daily.title": "HubSpot 漏斗日报",
|
||||
"industry-morning-brief.description": "每天早上把你行业 5 条重要新闻、融资、政策变化做成 5 分钟读物",
|
||||
"industry-morning-brief.prompt": "每天早上 8:00 把我行业 5 条重要新闻、融资、政策变化汇总成 5 分钟读物。",
|
||||
|
||||
"industry-morning-brief.title": "行业早餐",
|
||||
"industry-research-weekly.description": "告诉我你研究的赛道,每周一给你一份市场动态、融资、新玩家、监管变化汇总",
|
||||
"industry-research-weekly.prompt": "每周一早上 9:00 汇总我赛道的市场动态、融资、新玩家、监管变化,整理成研究简报。",
|
||||
|
||||
"industry-research-weekly.title": "行业研究周报",
|
||||
"invoice-collection-daily.description": "每天早上看哪些发票逾期了、逾期多少天、该发催款邮件了",
|
||||
"invoice-collection-daily.prompt": "每天早上 10:00 列出逾期发票和对接联系人,并为每条草拟一封礼貌的催款邮件。",
|
||||
|
||||
"invoice-collection-daily.title": "发票催收日报",
|
||||
"iteration-recap-weekly.description": "每周五下班前帮你拉本周迭代数据:完成率、逾期项、新增 Bug",
|
||||
"iteration-recap-weekly.prompt": "每周五下午 17:00 复盘本周迭代:完成率、逾期项、新增 Bug,整理成下周一可直接用的复盘材料。",
|
||||
|
||||
"iteration-recap-weekly.title": "迭代复盘周报",
|
||||
"key-account-radar.description": "告诉我核心客户的公司名,每天盯他们的新闻、融资、高管变动",
|
||||
"key-account-radar.prompt": "每天早上 9:00 扫一遍我核心客户的公司新闻、融资、高管变动,挑出可作为续约谈话切入点的素材。",
|
||||
|
||||
"key-account-radar.title": "客户动态雷达",
|
||||
"keyword-tech-feed.description": "告诉我你想追踪的技术关键词,每天带回 5 条高质量新问答 / 新博客",
|
||||
"keyword-tech-feed.prompt": "每天早上 10:00 按我设定的技术关键词带回 5 条高质量的新博客或新问答。",
|
||||
|
||||
"keyword-tech-feed.title": "关键词技术订阅",
|
||||
"kol-collab-calendar.description": "每周一同步正在合作的 KOL 进度:谁该发了、谁逾期、数据怎样",
|
||||
"kol-collab-calendar.prompt": "每周一早上 9:00 同步正在进行的 KOL 合作:谁该发了、谁逾期、已发出内容的数据。",
|
||||
|
||||
"kol-collab-calendar.title": "KOL 合作日历",
|
||||
"language-morning-bite.description": "每天给你一段 3 分钟能读完的目标语言内容 + 5 个生词卡",
|
||||
"language-morning-bite.prompt": "每天早上 7:30 给我一段 3 分钟能读完的目标语言内容,加上 5 个生词卡(单词、释义、例句)。",
|
||||
|
||||
"language-morning-bite.title": "语言早报",
|
||||
"linear-sprint-daily.description": "每天早上同步 Sprint 进度:哪些卡住了、哪些逾期、今天该做什么",
|
||||
"linear-sprint-daily.prompt": "每天早上 8:30 从 Linear 拉一份 Sprint 进度:今日重点、阻塞项、昨日完成,整理成 3 条站会前可直接念的要点。",
|
||||
|
||||
"linear-sprint-daily.title": "Linear Sprint 日报",
|
||||
"macro-economy-weekly.description": "每周一早给你汇率、利率、原油、金银、主要指数汇总",
|
||||
"macro-economy-weekly.prompt": "每周一早上 8:00 给我宏观快照:汇率、利率、原油、金银、主要指数,加上一段「本周变化」总结。",
|
||||
|
||||
"macro-economy-weekly.title": "宏观经济周报",
|
||||
"marketing-hot-radar.description": "每天追踪你行业正在发酵的 5 个营销话题:哪些值得蹭、哪些要避雷",
|
||||
"marketing-hot-radar.prompt": "每天早上 10:00 追踪我行业正在发酵的 5 个营销话题,标注哪些值得蹭、哪些要避雷,各附 1-2 句理由。",
|
||||
|
||||
"marketing-hot-radar.title": "营销热点雷达",
|
||||
"meeting-brief.description": "每天早上把今天所有会议的背景、参会人、上次纪要整理成 1 页",
|
||||
"meeting-brief.prompt": "每天早上 8:30 为今天日历上的每个会议生成一页简报:背景、参会人、上次纪要,进会议室前看一眼。",
|
||||
|
||||
"meeting-brief.title": "会议简报",
|
||||
"monetization-opportunity-weekly.description": "每周三给你内容创作者的新变现渠道和案例:广告 / 知识付费 / 会员 / 电商",
|
||||
"monetization-opportunity-weekly.prompt": "每周三早上 10:00 给我我赛道相关的新变现渠道和案例:广告、知识付费、会员订阅、电商。",
|
||||
|
||||
"monetization-opportunity-weekly.title": "变现机会播报",
|
||||
"morning-brief.description": "每天 8 点推一份:今天日程、待回邮件数、待办清单、天气",
|
||||
"morning-brief.prompt": "每天早上 8:00 推送:今天日程、待回邮件数、TOP 3 待办、天气,整理成 1 分钟能读完的早报。",
|
||||
|
||||
"morning-brief.title": "晨间早报",
|
||||
"morning-ritual.description": "每天 7 点:天气 + 今日日程 + 一条金句 + 一个动一动建议,温柔开启一天",
|
||||
"morning-ritual.prompt": "每天早上 7:00 给我一份温柔的晨间仪式:天气、今日日程、一条短金句、一个轻量运动建议。如果连接了 Google Calendar,把日程锚定在那里。",
|
||||
|
||||
"morning-ritual.title": "晨间仪式",
|
||||
"must-read-papers-weekly.description": "每周日晚帮你挑本周被引最多、讨论最热的 3 篇论文,做成精读清单",
|
||||
"must-read-papers-weekly.prompt": "每周日晚 20:00 帮我挑本周我研究方向被引最多或讨论最热的 3 篇论文,整理成周末可读完的精读清单。",
|
||||
|
||||
"must-read-papers-weekly.title": "本周必读论文",
|
||||
"newsletter-aggregator.description": "每周日晚把你订阅的 Newsletter 合并成一份摘要,周末一次性读完",
|
||||
"newsletter-aggregator.prompt": "每周日晚 20:00 扫一遍我 Gmail 收件箱里本周收到的 Newsletter,按主题合并成一份周末摘要。",
|
||||
|
||||
"newsletter-aggregator.title": "Newsletter 聚合",
|
||||
"newsletter-perf-weekly.description": "每周一帮你看 Newsletter 的打开率、点击率、取关率变化趋势,标出需要优化的环节",
|
||||
"newsletter-perf-weekly.prompt": "每周一早上 10:00 回顾过去 4 周 Newsletter 的打开率、点击率、取关率,标出需要优化的人群分层。",
|
||||
|
||||
"newsletter-perf-weekly.title": "Newsletter 表现周报",
|
||||
"onboarding-buddy-weekly.description": "新人入职 90 天内,每周一生成他的进度:任务完成、Buddy 反馈、该关注什么",
|
||||
"onboarding-buddy-weekly.prompt": "每周一早上 9:00 为还在 90 天试用期内的新人生成进度卡:任务完成度、Buddy 反馈、本周该关注什么。",
|
||||
|
||||
"onboarding-buddy-weekly.title": "新人入职陪跑",
|
||||
"oss-intel-daily.description": "每天早上给你 10 条技术栈动态:GitHub Trending、大厂新开源、关键 repo 的 release",
|
||||
"oss-intel-daily.prompt": "每天早上 9:00 给我 10 条技术栈动态:GitHub Trending、大厂新开源、我关注的 repo 新 release。",
|
||||
|
||||
"oss-intel-daily.title": "开源情报日报",
|
||||
"podcast-new-episodes.description": "告诉我你订阅的播客,每周一给你本周新集 + 值得听的 3 集推荐",
|
||||
"podcast-new-episodes.prompt": "每周一早上 9:00 列出我订阅播客本周的新集,并推荐其中最值得先听的 3 集。",
|
||||
|
||||
"podcast-new-episodes.title": "播客新集聚合",
|
||||
"portfolio-daily.description": "告诉我你的持仓股票 / 基金 / 加密货币,每天收盘后给你涨跌、重要新闻、持仓公司动态",
|
||||
"portfolio-daily.prompt": "每天 16:00 收盘后给我每个持仓的当日涨跌、影响新闻和公司公告。",
|
||||
|
||||
"portfolio-daily.title": "持仓日报",
|
||||
"prd-review-reminder.description": "每周五盘点本周该评审的 PRD 和决策项,别让文档压在草稿箱",
|
||||
"prd-review-reminder.prompt": "每周五下午 15:00 盘点我 Notion 里本周该评审的 PRD 和决策文档,标出仍卡在草稿状态的。",
|
||||
|
||||
"prd-review-reminder.title": "PRD 评审提醒",
|
||||
"pre-market-brief.description": "每天开盘前 30 分钟给你宏观要闻、重要财报、你持仓公司的动态",
|
||||
"pre-market-brief.prompt": "每天早上 9:00 给我开盘前简报:宏观要闻、今日重要财报、我持仓公司的动态。",
|
||||
|
||||
"pre-market-brief.title": "开盘前简报",
|
||||
"precious-metals-daily.description": "每天收盘后推送金银铜油主要品种的价格和日变化幅度,波动超阈值立刻标红",
|
||||
"precious-metals-daily.prompt": "每天 16:00 收盘后给我金、银、铜、原油的当日价格和日变化幅度,单日波动超过 2% 立刻标红。",
|
||||
|
||||
"precious-metals-daily.title": "金银油价日报",
|
||||
"recruit-funnel-daily.description": "每天早上看各岗位新投递、待面试、待反馈数,标出面试官拖着的人选",
|
||||
"recruit-funnel-daily.prompt": "每天早上 9:00 按岗位汇总招聘漏斗:新投递、待面试、待反馈数,标出被面试官卡住的人选。",
|
||||
|
||||
"recruit-funnel-daily.title": "招聘漏斗日报",
|
||||
"regulation-watch-weekly.description": "告诉我你关注的合规领域(数据 / 税务 / 劳动法),每周一给你一份变更摘要和影响判断",
|
||||
"regulation-watch-weekly.prompt": "每周一早上 10:00 汇总我追踪的合规领域(数据 / 税务 / 劳动法)过去一周的变更,并判断对我们的影响。",
|
||||
|
||||
"regulation-watch-weekly.title": "法规变更追踪",
|
||||
"renewal-risk-weekly.description": "每周一扫一遍本月到期合同,标出使用频次下降的高风险客户",
|
||||
"renewal-risk-weekly.prompt": "每周一早上 9:00 扫一遍 HubSpot 中本月到期合同,标出使用频次下降的高风险客户,并为每条高风险账户建议挽留动作。",
|
||||
|
||||
"renewal-risk-weekly.title": "续费风险预警",
|
||||
"repo-health-weekly.description": "每周一帮你看你维护的 repo:Issue 堆积、PR 停滞、CI 失败、依赖告警",
|
||||
"repo-health-weekly.prompt": "每周一早上 9:00 检查我维护的 GitHub repo:Issue 堆积、PR 停滞、CI 失败、依赖告警,挑出本周该处理的事项。",
|
||||
|
||||
"repo-health-weekly.title": "仓库健康周报",
|
||||
"schedule.daily": "每天 {{time}}",
|
||||
"schedule.weekly": "每{{weekday}} {{time}}",
|
||||
"section.title": "试试这些定时任务",
|
||||
|
||||
"seo-weekly-report.description": "每周一一份轻量 SEO 报告:排名变化、新关键词机会、值得翻新的页面",
|
||||
"seo-weekly-report.prompt": "每周一早上 9:00 给我一份轻量 SEO 周报:排名升 / 降 TOP 变动、5 个值得关注的新关键词、3 个适合内容翻新的老页面。",
|
||||
|
||||
"seo-weekly-report.title": "SEO 排名周报",
|
||||
"series-update-weekly.description": "告诉我你在追的剧 / 小说 / 漫画,每周给你更新提醒和剧情回顾",
|
||||
"series-update-weekly.prompt": "每周一早上 9:00 给我我在追的剧、小说、漫画的更新提醒和短剧情回顾。",
|
||||
|
||||
"series-update-weekly.title": "追更日报",
|
||||
"standup-brief.description": "每天站会前 15 分钟帮你拉一份 Linear 进度简报:今日重点、阻塞项、昨日完成",
|
||||
"standup-brief.prompt": "每天早上 8:30 拉一份 Linear 站会简报:今日重点、阻塞项、昨日完成,整理成 3 条站会前可直接念的要点。",
|
||||
|
||||
"standup-brief.title": "站会简报",
|
||||
"sunday-reflection.description": "每周日晚陪你走 5 个问题:最有成就感的事、最想吐槽的事、下周 3 件重要事",
|
||||
"sunday-reflection.prompt": "每周日晚 21:00 陪我走 5 个复盘问题:本周最有成就感的事、最想吐槽的事、下周 3 件重要事、本周学到的、本周应该放下的。",
|
||||
|
||||
"sunday-reflection.title": "周日复盘",
|
||||
"team-status-weekly.description": "每周一看团队请假、加班、会议时长趋势,预警潜在倦怠信号",
|
||||
"team-status-weekly.prompt": "每周一早上 9:00 回顾团队过去一周的请假、加班、会议时长趋势,预警有倦怠风险的成员。",
|
||||
|
||||
"team-status-weekly.title": "团队状态周报",
|
||||
"tech-trend-weekly.description": "每周一帮你总结前端 / 后端 / AI 圈的重要动态:论文、框架、融资",
|
||||
"tech-trend-weekly.prompt": "每周一早上 8:00 总结前端、后端、AI 圈过去一周的重要动态:论文、框架、融资,整理成 10 条 + 一句要点。",
|
||||
|
||||
"tech-trend-weekly.title": "技术趋势周刊",
|
||||
"travel-inspiration-weekly.description": "每周三给你想去城市的机票价格变动、签证政策、最佳出行时间",
|
||||
"travel-inspiration-weekly.prompt": "每周三早上 10:00 给我心愿城市的机票价格变化、签证政策、最佳出行时间。",
|
||||
|
||||
"travel-inspiration-weekly.title": "旅行灵感周报",
|
||||
"twitter-weekly-recap.description": "每周一帮你复盘过去 7 天的推文表现:涨粉最猛的、互动最差的、为什么",
|
||||
"twitter-weekly-recap.prompt": "每周一早上 10:00 复盘我 X (Twitter) 过去 7 天的推文表现:涨粉最猛的、互动最差的,并给出原因假设和下周 3 个尝试方向。",
|
||||
|
||||
"twitter-weekly-recap.title": "推特周报",
|
||||
"user-feedback-daily.description": "每天把各渠道用户反馈(应用商店、社媒、客服)聚合成 TOP 20 条,按情绪和主题分类",
|
||||
"user-feedback-daily.prompt": "每天早上 9:00 把各渠道用户反馈(应用商店、社媒、客服)聚合成 TOP 20 条,按情绪和主题分类。",
|
||||
|
||||
"user-feedback-daily.title": "用户反馈日报",
|
||||
"user-interview-schedule.description": "每周一帮你梳理本周访谈:谁、什么时候、问题列表准备好没",
|
||||
"user-interview-schedule.prompt": "每周一早上 9:00 列出本周已排期的用户访谈:访谈对象、时间、准备清单(问题列表是否齐全、录制设备是否就位)。",
|
||||
|
||||
"user-interview-schedule.title": "用户访谈排期",
|
||||
"vercel-health-weekly.description": "每周一帮你盘点上周部署成功率、构建时长、流量异常",
|
||||
"vercel-health-weekly.prompt": "每周一早上 10:00 盘点 Vercel 过去一周的部署:成功率、构建时长、流量异常,标出累积问题。",
|
||||
|
||||
"vercel-health-weekly.title": "Vercel 健康周报",
|
||||
"viral-content-breakdown.description": "每天从你领域挑 1 条爆款帮你拆:选题、开头、结构、结尾",
|
||||
"viral-content-breakdown.prompt": "每天早上 10:00 从我领域挑 1 条爆款帮我拆:选题、开头、结构、结尾,整理成可复刻的模板。",
|
||||
|
||||
"viral-content-breakdown.title": "爆款拆解",
|
||||
"watchlist-friday.description": "每周五给你本周豆瓣 / IMDb 新上映的 5 部高分作品,附一句话短评",
|
||||
"watchlist-friday.prompt": "每周五晚 18:00 从豆瓣和 IMDb 挑 5 部本周新上映的高分作品,每部附一句短评。",
|
||||
|
||||
"watchlist-friday.title": "观影清单",
|
||||
"weekly-meeting-brief.description": "每周一早上帮你准备本周战略会的 3 个讨论要点:行业动态、内部指标、决策建议",
|
||||
"weekly-meeting-brief.prompt": "每周一早上 8:30 准备本周战略会的 3 个讨论要点:行业动态、值得提的内部指标、需要决策的事项。",
|
||||
|
||||
"weekly-meeting-brief.title": "周会简报",
|
||||
"youtube-channel-weekly.description": "每周一拉频道数据:订阅变化、热门视频、观众留存、收益变化",
|
||||
"youtube-channel-weekly.prompt": "每周一早上 9:00 拉我 YouTube 频道的数据:订阅变化、热门视频、观众留存、收益变化。",
|
||||
|
||||
"youtube-channel-weekly.title": "YouTube 频道周报",
|
||||
"youtube-weekly-recap.description": "每周一帮你拉频道过去 7 天的播放、CTR、留存曲线,标出值得拍续集的选题",
|
||||
"youtube-weekly-recap.prompt": "每周一早上 9:00 拉我 YouTube 频道过去 7 天的播放、CTR、留存曲线,标出值得拍续集的选题。",
|
||||
|
||||
"youtube-weekly-recap.title": "YouTube 周报",
|
||||
"zendesk-ticket-daily.description": "每天早上盘一下 Zendesk 工单:积压多少、SLA 逾期多少、重复问题前三名",
|
||||
"zendesk-ticket-daily.prompt": "每天早上 9:00 给我 Zendesk 快照:未关单积压、SLA 逾期数量、过去 24 小时 TOP 3 重复问题。",
|
||||
"zendesk-ticket-daily.title": "Zendesk 工单日报"
|
||||
}
|
||||
@@ -1,42 +1,20 @@
|
||||
import { RunCommandRender } from '@lobechat/shared-tool-ui/renders';
|
||||
import type { BuiltinStreaming } from '@lobechat/types';
|
||||
|
||||
import { ClaudeCodeApiName } from '../../types';
|
||||
import EditRender from '../Render/Edit';
|
||||
import GlobRender from '../Render/Glob';
|
||||
import GrepRender from '../Render/Grep';
|
||||
import ReadRender from '../Render/Read';
|
||||
import SkillRender from '../Render/Skill';
|
||||
import TodoWriteRender from '../Render/TodoWrite';
|
||||
import WriteRender from '../Render/Write';
|
||||
import AgentStreaming from './Agent';
|
||||
import { wrapRender } from './wrapRender';
|
||||
|
||||
/**
|
||||
* Claude Code Streaming Components Registry.
|
||||
*
|
||||
* Rendered while a CC tool is still executing (args parsed, no tool_result
|
||||
* yet). Without an entry here, the tool detail falls back to the generic
|
||||
* `参数列表` argument table.
|
||||
*
|
||||
* - `Agent` has its own bespoke streaming view (drops the result block,
|
||||
* surfaces the subagent thread toggle).
|
||||
* - The rest reuse their result Render via `wrapRender`. Those Renders
|
||||
* already gracefully degrade when `content`/`pluginState` are absent
|
||||
* (header-only for Read/Glob/Grep/Skill/Bash; full diff or file body
|
||||
* for Edit/Write since those live in args). Same component for live and
|
||||
* final keeps the UI stable across the result transition.
|
||||
* `参数列表` argument table. Register only tools whose live state is more
|
||||
* useful as bespoke UI than as an arg dump — e.g. `Agent`, where we want to
|
||||
* surface the instruction and let the user jump into the subagent thread
|
||||
* while the subagent is still running.
|
||||
*/
|
||||
export const ClaudeCodeStreamings: Record<string, BuiltinStreaming> = {
|
||||
[ClaudeCodeApiName.Agent]: AgentStreaming as BuiltinStreaming,
|
||||
[ClaudeCodeApiName.Bash]: wrapRender(RunCommandRender),
|
||||
[ClaudeCodeApiName.Edit]: wrapRender(EditRender),
|
||||
[ClaudeCodeApiName.Glob]: wrapRender(GlobRender),
|
||||
[ClaudeCodeApiName.Grep]: wrapRender(GrepRender),
|
||||
[ClaudeCodeApiName.Read]: wrapRender(ReadRender),
|
||||
[ClaudeCodeApiName.Skill]: wrapRender(SkillRender),
|
||||
[ClaudeCodeApiName.TodoWrite]: wrapRender(TodoWriteRender),
|
||||
[ClaudeCodeApiName.Write]: wrapRender(WriteRender),
|
||||
};
|
||||
|
||||
export { default as AgentStreaming } from './Agent';
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import type { BuiltinRenderProps, BuiltinStreaming } from '@lobechat/types';
|
||||
import { createElement, type ReactNode } from 'react';
|
||||
|
||||
type AnyRender = (props: BuiltinRenderProps<any, any, any>) => ReactNode;
|
||||
|
||||
/**
|
||||
* Adapt a result `BuiltinRender` so it can stand in as a `BuiltinStreaming`.
|
||||
*
|
||||
* Most CC Renders (`Read`, `Write`, `Edit`, `Glob`, `Grep`, `Skill`,
|
||||
* `Bash`/`RunCommand`, `TodoWrite`) read `args` for the header/diff/list and
|
||||
* gracefully omit the body when `content`/`pluginState` are absent — exactly
|
||||
* the streaming-phase shape. Reusing the Render keeps live and final views
|
||||
* visually identical and avoids duplicating per-tool layouts.
|
||||
*/
|
||||
export const wrapRender = (Render: AnyRender): BuiltinStreaming => {
|
||||
const Streaming: BuiltinStreaming = ({ args, messageId, apiName, identifier, toolCallId }) =>
|
||||
createElement(Render, { apiName, args, content: null, identifier, messageId, toolCallId });
|
||||
return Streaming;
|
||||
};
|
||||
@@ -153,7 +153,6 @@ export interface BotProviderQuery {
|
||||
applicationId: string;
|
||||
credentials: Record<string, string>;
|
||||
platform: string;
|
||||
settings?: Record<string, unknown>;
|
||||
}) => Promise<{ id: string; platform: string }>;
|
||||
deleteBot: (botId: string) => Promise<void>;
|
||||
getBotDetail: (botId: string) => Promise<GetBotDetailState | null>;
|
||||
|
||||
@@ -5,80 +5,6 @@ import { MessageApiName, MessageToolIdentifier } from './types';
|
||||
|
||||
const platformEnum = ['discord', 'telegram', 'slack', 'feishu', 'lark', 'qq', 'wechat'];
|
||||
|
||||
/**
|
||||
* Schema for the bot's `settings` JSON column. Both `createBot` and
|
||||
* `updateBot` accept a partial object — only the keys you pass are written
|
||||
* (everything else preserved). Use this as the single source of truth for
|
||||
* what the AI is allowed to toggle on a bot.
|
||||
*/
|
||||
const botSettingsSchema = {
|
||||
additionalProperties: true,
|
||||
properties: {
|
||||
allowFrom: {
|
||||
description:
|
||||
'Global user-ID allowlist. When non-empty, ONLY listed users may interact with the bot anywhere — DMs, group @mentions, threads — regardless of dmPolicy/groupPolicy. Empty array means "no user-level filter". Pass the FULL desired list (this field is overwrite-replace, not append): to add or remove a single user, first call getBotDetail to read settings.allowFrom, mutate locally, then write back the entire array.',
|
||||
items: {
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
id: {
|
||||
description: 'Platform user ID (e.g. Discord snowflake, Telegram user_id)',
|
||||
type: 'string',
|
||||
},
|
||||
name: {
|
||||
description:
|
||||
'Optional human-friendly label so the operator can recognise the entry later (e.g. "Ada from Product"). Runtime ignores this; only id is matched.',
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
required: ['id'],
|
||||
type: 'object',
|
||||
},
|
||||
type: 'array',
|
||||
},
|
||||
dmPolicy: {
|
||||
description:
|
||||
'Direct-message gate. open=accept DMs from anyone (default); allowlist=only users in allowFrom can DM, fails closed if list is empty; pairing=non-listed senders get a one-time code and the owner runs /approve <code> to add them; disabled=ignore all DMs. pairing requires settings.userId (owner platform ID).',
|
||||
enum: ['open', 'allowlist', 'pairing', 'disabled'],
|
||||
type: 'string',
|
||||
},
|
||||
groupAllowFrom: {
|
||||
description:
|
||||
'Channel/group/thread ID allowlist for group traffic. Only consulted when groupPolicy="allowlist". Same overwrite-replace semantics as allowFrom — read-modify-write to add/remove entries.',
|
||||
items: {
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
id: {
|
||||
description:
|
||||
'Channel / group / thread ID (e.g. Discord channel ID copied via "Copy Channel ID")',
|
||||
type: 'string',
|
||||
},
|
||||
name: { description: 'Optional human-friendly label.', type: 'string' },
|
||||
},
|
||||
required: ['id'],
|
||||
type: 'object',
|
||||
},
|
||||
type: 'array',
|
||||
},
|
||||
groupPolicy: {
|
||||
description:
|
||||
'Group/channel @mention gate. open=respond to @mentions in any channel (default); allowlist=respond only in channels listed in groupAllowFrom; disabled=ignore all non-DM traffic.',
|
||||
enum: ['open', 'allowlist', 'disabled'],
|
||||
type: 'string',
|
||||
},
|
||||
serverId: {
|
||||
description:
|
||||
'Default server / guild / workspace ID used when the AI calls listChannels/getMemberInfo without an explicit serverId. Optional; populated automatically once the bot has been used in a server.',
|
||||
type: 'string',
|
||||
},
|
||||
userId: {
|
||||
description:
|
||||
"The bot owner's platform user ID. Required when dmPolicy='pairing' (used as approver identity and as an implicit member of allowFrom). Also used to push owner-only notifications.",
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
type: 'object',
|
||||
};
|
||||
|
||||
export const MessageManifest: BuiltinToolManifest = {
|
||||
api: [
|
||||
// ==================== Direct Messaging ====================
|
||||
@@ -655,19 +581,13 @@ export const MessageManifest: BuiltinToolManifest = {
|
||||
enum: platformEnum,
|
||||
type: 'string',
|
||||
},
|
||||
settings: {
|
||||
...botSettingsSchema,
|
||||
description:
|
||||
'Optional initial settings (DM policy, allowlists, owner userId, etc.). Omit to use schema defaults — open DMs, no allowlist. See field descriptions for each key.',
|
||||
},
|
||||
},
|
||||
required: ['platform', 'agentId', 'applicationId', 'credentials'],
|
||||
type: 'object',
|
||||
},
|
||||
},
|
||||
{
|
||||
description:
|
||||
'Update credentials or settings of an existing bot integration. Use this to adjust DM policy (e.g. switch to pairing mode), edit the allowlist, or rotate credentials. Settings is merged at the key level — only keys you pass are written. For array fields like allowFrom/groupAllowFrom, the array is REPLACED, not merged: read-modify-write via getBotDetail before adding/removing entries.',
|
||||
description: 'Update credentials or settings of an existing bot integration.',
|
||||
name: MessageApiName.updateBot,
|
||||
parameters: {
|
||||
additionalProperties: false,
|
||||
@@ -681,9 +601,8 @@ export const MessageManifest: BuiltinToolManifest = {
|
||||
type: 'object',
|
||||
},
|
||||
settings: {
|
||||
...botSettingsSchema,
|
||||
description:
|
||||
'Updated settings (partial update at the key level). See nested field descriptions for the allowed keys (dmPolicy, allowFrom, userId, groupPolicy, groupAllowFrom, serverId).',
|
||||
description: 'Updated settings (partial update)',
|
||||
type: 'object',
|
||||
},
|
||||
},
|
||||
required: ['botId'],
|
||||
|
||||
@@ -13,43 +13,14 @@ export const systemPrompt = `You have access to a Message tool that provides uni
|
||||
<bot_management>
|
||||
1. **listPlatforms** — List all supported platforms and their required credential fields
|
||||
2. **listBots** — List configured bots for the current agent (with runtime status)
|
||||
3. **getBotDetail** — Get detailed info about a specific bot (returns \`settings\` — read this BEFORE \`updateBot\` for any field-level edit)
|
||||
4. **createBot** — Create a new bot integration (requires agentId, platform, applicationId, credentials; optional initial settings)
|
||||
5. **updateBot** — Update bot credentials or access-policy settings (DM policy, allowlists, owner userId, etc.)
|
||||
3. **getBotDetail** — Get detailed info about a specific bot
|
||||
4. **createBot** — Create a new bot integration (requires agentId, platform, applicationId, credentials)
|
||||
5. **updateBot** — Update bot credentials or settings
|
||||
6. **deleteBot** — Remove a bot integration
|
||||
7. **toggleBot** — Enable or disable a bot
|
||||
8. **connectBot** — Start a bot (establish connection to the platform)
|
||||
</bot_management>
|
||||
|
||||
<access_policies>
|
||||
The bot's \`settings\` JSON column controls **who can talk to the bot** on every platform. Use \`updateBot({ botId, settings: {...} })\` to change any of the keys below. Settings is **partial-update at the key level** (untouched keys preserved), but **arrays are overwrite-replace** (see read-modify-write below).
|
||||
|
||||
**dmPolicy** — gate inbound 1:1 DMs:
|
||||
- \`open\` (default): anyone can DM the bot
|
||||
- \`allowlist\`: only users in \`allowFrom\` can DM (fails closed when list is empty)
|
||||
- \`pairing\`: same as allowlist, but a non-listed sender receives a one-time code; the owner runs \`/approve <code>\` in their own DM to add the applicant. **Requires \`settings.userId\`** (owner's platform user ID) — without it the validator rejects the save.
|
||||
- \`disabled\`: ignore all DMs
|
||||
|
||||
Typical asks → action:
|
||||
- "lock my bot down so only I can DM" → \`updateBot({ settings: { dmPolicy: 'pairing', userId: '<owner platform ID>' } })\`
|
||||
- "let anyone DM again" → \`updateBot({ settings: { dmPolicy: 'open' } })\`
|
||||
- "stop accepting DMs for now" → \`updateBot({ settings: { dmPolicy: 'disabled' } })\`
|
||||
|
||||
**allowFrom** — global user-ID allowlist, format \`[{ id, name? }]\`. When non-empty, applies to **every** inbound surface (DM, group, threads), regardless of dmPolicy/groupPolicy. The runtime only matches \`id\`; \`name\` is an operator-facing label so the human can recognise the entry months later — always include a name when you have one (display name, handle, etc.).
|
||||
|
||||
**groupPolicy** + **groupAllowFrom** — same shape but for group/channel/thread traffic. \`groupAllowFrom\` items are channel/group/thread IDs (e.g. Discord channel IDs from "Copy Channel ID"), not user IDs.
|
||||
|
||||
**Read-modify-write for allowFrom and groupAllowFrom (CRITICAL):**
|
||||
Both arrays are written as a whole — passing \`{ allowFrom: [{ id: 'X' }] }\` REPLACES the entire list, not appends. To add or remove a single entry:
|
||||
1. Call \`getBotDetail(botId)\` and read \`settings.allowFrom\` (it may be missing — treat as \`[]\`).
|
||||
2. Mutate the array locally (\`push\` to add, \`filter\` to remove). Preserve every existing \`{ id, name }\` you didn't intend to touch.
|
||||
3. Call \`updateBot({ botId, settings: { allowFrom: [...newArray] } })\`.
|
||||
|
||||
Skipping step 1 will silently wipe other entries. Same workflow applies to \`groupAllowFrom\`.
|
||||
|
||||
**Validation behaviour:** the server validates settings before persisting and returns \`updateBot error: <field>: <reason>\` when something fails (e.g. \`userId: Pairing policy requires the owner's Platform User ID.\`). Surface that message to the user and ask for the missing value rather than retrying blindly.
|
||||
</access_policies>
|
||||
|
||||
<messaging_capabilities>
|
||||
1. **sendDirectMessage** — Send a private/direct message to a user by their platform user ID (auto-creates DM channel)
|
||||
2. **sendMessage** — Send a message to a channel or conversation
|
||||
|
||||
@@ -442,11 +442,6 @@ export interface CreateBotParams {
|
||||
credentials: Record<string, string>;
|
||||
/** Target platform */
|
||||
platform: string;
|
||||
/**
|
||||
* Optional initial settings (DM policy, allowlist, server/user IDs, etc.).
|
||||
* Same shape as `UpdateBotParams.settings`. Omit to use schema defaults.
|
||||
*/
|
||||
settings?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface CreateBotState {
|
||||
|
||||
@@ -14,7 +14,6 @@ export * from './plugin';
|
||||
export * from './recommendedSkill';
|
||||
export * from './session';
|
||||
export * from './settings';
|
||||
export * from './taskTemplate';
|
||||
export * from './theme';
|
||||
export * from './trace';
|
||||
export * from './url';
|
||||
|
||||
@@ -6,7 +6,6 @@ export const ARTIFACT_THINKING_TAG = 'lobeThinking';
|
||||
export const MENTION_TAG = 'mention';
|
||||
export const THINKING_TAG = 'think';
|
||||
export const LOCAL_FILE_TAG = 'localFile';
|
||||
export const TASK_TAG = 'task';
|
||||
// https://regex101.com/r/TwzTkf/2
|
||||
export const ARTIFACT_TAG_REGEX = /<lobeArtifact\b[^>]*>(?<content>[\S\s]*?)(?:<\/lobeArtifact>|$)/;
|
||||
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
import { parseCronPattern } from '@lobechat/utils/cron';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { TASK_TEMPLATE_FALLBACK_CATEGORIES, taskTemplates } from './taskTemplate';
|
||||
|
||||
const CRON_FIELDS = 5;
|
||||
// Keep in sync with INTEREST_AREAS in lobehub/src/routes/onboarding/config.ts —
|
||||
// those are the only values `users.interests` can hold.
|
||||
const VALID_INTEREST_KEYS = new Set([
|
||||
'writing',
|
||||
'coding',
|
||||
'design',
|
||||
'education',
|
||||
'business',
|
||||
'marketing',
|
||||
'product',
|
||||
'sales',
|
||||
'operations',
|
||||
'hr',
|
||||
'finance-legal',
|
||||
'creator',
|
||||
'investing',
|
||||
'parenting',
|
||||
'health',
|
||||
'hobbies',
|
||||
'personal',
|
||||
]);
|
||||
|
||||
describe('taskTemplates', () => {
|
||||
it('has the expected number of templates', () => {
|
||||
expect(taskTemplates).toHaveLength(84);
|
||||
});
|
||||
|
||||
it('has unique ids', () => {
|
||||
const ids = taskTemplates.map((t) => t.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
it('every template has non-empty interests from INTEREST_AREAS', () => {
|
||||
for (const t of taskTemplates) {
|
||||
expect(t.interests.length, `template ${t.id} interests`).toBeGreaterThan(0);
|
||||
for (const key of t.interests) {
|
||||
expect(VALID_INTEREST_KEYS.has(key), `template ${t.id} interest "${key}"`).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('every template has a 5-field cron pattern', () => {
|
||||
for (const t of taskTemplates) {
|
||||
expect(t.cronPattern.trim().split(/\s+/), `template ${t.id} cron`).toHaveLength(CRON_FIELDS);
|
||||
}
|
||||
});
|
||||
|
||||
// parseCronPattern only renders 'daily' / 'weekly' / 'hourly' schedule strings.
|
||||
// Monthly or event-driven cron patterns silently fall back to daily display —
|
||||
// guard against accidental introduction here.
|
||||
it('every template parses to daily or weekly schedule', () => {
|
||||
for (const t of taskTemplates) {
|
||||
const { scheduleType } = parseCronPattern(t.cronPattern);
|
||||
expect(['daily', 'weekly'], `template ${t.id} scheduleType`).toContain(scheduleType);
|
||||
}
|
||||
});
|
||||
|
||||
it('covers every fallback category at least once', () => {
|
||||
const categories = new Set(taskTemplates.map((t) => t.category));
|
||||
for (const fallback of TASK_TEMPLATE_FALLBACK_CATEGORIES) {
|
||||
expect(categories.has(fallback), `fallback category ${fallback}`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('every optionalSkills entry uses a valid source and non-empty provider', () => {
|
||||
for (const t of taskTemplates) {
|
||||
if (!t.optionalSkills) continue;
|
||||
for (const spec of t.optionalSkills) {
|
||||
expect(['klavis', 'lobehub'], `template ${t.id} optional source`).toContain(spec.source);
|
||||
expect(
|
||||
spec.provider.length,
|
||||
`template ${t.id} optional provider "${spec.provider}"`,
|
||||
).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('optionalSkills do not duplicate requiresSkills', () => {
|
||||
for (const t of taskTemplates) {
|
||||
if (!t.optionalSkills || !t.requiresSkills) continue;
|
||||
const reqKeys = new Set(t.requiresSkills.map((s) => `${s.source}:${s.provider}`));
|
||||
for (const spec of t.optionalSkills) {
|
||||
expect(
|
||||
reqKeys.has(`${spec.source}:${spec.provider}`),
|
||||
`template ${t.id} duplicate skill ${spec.source}:${spec.provider}`,
|
||||
).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,635 +0,0 @@
|
||||
import type { IconType } from '@icons-pack/react-simple-icons';
|
||||
import { SiGithub } from '@icons-pack/react-simple-icons';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* Task Template catalog used by home "Try following tasks" recommendation.
|
||||
* I18n keys: `taskTemplate:${id}.title|description|prompt`.
|
||||
*
|
||||
* `interests` values must be keys from `INTEREST_AREAS` in
|
||||
* `src/routes/onboarding/config.ts` — that's what `users.interests` stores.
|
||||
*/
|
||||
export interface TaskTemplate {
|
||||
category: TaskTemplateCategory;
|
||||
cronPattern: string;
|
||||
/** Per-template icon override. Falls back to a category-level default when omitted. */
|
||||
icon?: IconType | LucideIcon;
|
||||
id: string;
|
||||
interests: string[];
|
||||
/** Skills that enrich the brief but are not required to run it. */
|
||||
optionalSkills?: TaskTemplateSkillRequirement[];
|
||||
/** Skill dependencies. The `source` field routes the connection flow. */
|
||||
requiresSkills?: TaskTemplateSkillRequirement[];
|
||||
}
|
||||
|
||||
export interface TaskTemplateSkillRequirement {
|
||||
/** Short identifier from `LOBEHUB_SKILL_PROVIDERS[i].id` or `KLAVIS_SERVER_TYPES[i].identifier`. */
|
||||
provider: string;
|
||||
source: TaskTemplateSkillSource;
|
||||
}
|
||||
|
||||
export type TaskTemplateSkillSource = 'klavis' | 'lobehub';
|
||||
|
||||
export type TaskTemplateCategory =
|
||||
| 'content-creation'
|
||||
| 'engineering'
|
||||
| 'design'
|
||||
| 'learning-research'
|
||||
| 'business'
|
||||
| 'marketing'
|
||||
| 'product'
|
||||
| 'sales-customer'
|
||||
| 'operations'
|
||||
| 'hr'
|
||||
| 'finance-legal'
|
||||
| 'creator'
|
||||
| 'investing'
|
||||
| 'parenting'
|
||||
| 'health'
|
||||
| 'hobbies'
|
||||
| 'personal-life';
|
||||
|
||||
/** Generic categories used to fill the pool when interest-matched picks are insufficient. */
|
||||
export const TASK_TEMPLATE_FALLBACK_CATEGORIES: TaskTemplateCategory[] = [
|
||||
'personal-life',
|
||||
'learning-research',
|
||||
];
|
||||
|
||||
export const taskTemplates: TaskTemplate[] = [
|
||||
// content-creation
|
||||
{
|
||||
id: 'daily-topic-pick',
|
||||
category: 'content-creation',
|
||||
cronPattern: '0 9 * * *',
|
||||
interests: ['writing'],
|
||||
},
|
||||
{
|
||||
id: 'hot-topic-radar',
|
||||
category: 'content-creation',
|
||||
cronPattern: '0 10 * * *',
|
||||
interests: ['writing'],
|
||||
},
|
||||
{
|
||||
id: 'headline-inspiration',
|
||||
category: 'content-creation',
|
||||
cronPattern: '0 10 * * *',
|
||||
interests: ['writing'],
|
||||
},
|
||||
{
|
||||
id: 'viral-content-breakdown',
|
||||
category: 'content-creation',
|
||||
cronPattern: '0 10 * * *',
|
||||
interests: ['writing'],
|
||||
},
|
||||
{
|
||||
id: 'twitter-weekly-recap',
|
||||
category: 'content-creation',
|
||||
cronPattern: '0 10 * * 1',
|
||||
interests: ['writing', 'creator'],
|
||||
requiresSkills: [{ provider: 'twitter', source: 'lobehub' }],
|
||||
},
|
||||
{
|
||||
id: 'youtube-weekly-recap',
|
||||
category: 'content-creation',
|
||||
cronPattern: '0 9 * * 1',
|
||||
interests: ['writing', 'creator'],
|
||||
requiresSkills: [{ provider: 'youtube', source: 'klavis' }],
|
||||
},
|
||||
{
|
||||
id: 'competitor-creator-tracking',
|
||||
category: 'content-creation',
|
||||
cronPattern: '0 9 * * *',
|
||||
interests: ['writing', 'creator'],
|
||||
},
|
||||
{
|
||||
id: 'content-calendar-weekly',
|
||||
category: 'content-creation',
|
||||
cronPattern: '0 20 * * 0',
|
||||
interests: ['writing', 'creator'],
|
||||
optionalSkills: [{ provider: 'notion', source: 'klavis' }],
|
||||
},
|
||||
|
||||
// engineering
|
||||
{
|
||||
id: 'oss-intel-daily',
|
||||
category: 'engineering',
|
||||
cronPattern: '0 9 * * *',
|
||||
icon: SiGithub,
|
||||
interests: ['coding'],
|
||||
},
|
||||
{
|
||||
id: 'repo-health-weekly',
|
||||
category: 'engineering',
|
||||
cronPattern: '0 9 * * 1',
|
||||
interests: ['coding'],
|
||||
requiresSkills: [{ provider: 'github', source: 'lobehub' }],
|
||||
},
|
||||
{
|
||||
id: 'dependency-security-weekly',
|
||||
category: 'engineering',
|
||||
cronPattern: '0 10 * * 1',
|
||||
interests: ['coding'],
|
||||
requiresSkills: [{ provider: 'github', source: 'lobehub' }],
|
||||
},
|
||||
{
|
||||
id: 'vercel-health-weekly',
|
||||
category: 'engineering',
|
||||
cronPattern: '0 10 * * 1',
|
||||
interests: ['coding'],
|
||||
requiresSkills: [{ provider: 'vercel', source: 'lobehub' }],
|
||||
},
|
||||
{
|
||||
id: 'linear-sprint-daily',
|
||||
category: 'engineering',
|
||||
cronPattern: '30 8 * * *',
|
||||
interests: ['coding', 'product'],
|
||||
requiresSkills: [{ provider: 'linear', source: 'lobehub' }],
|
||||
},
|
||||
{
|
||||
id: 'tech-trend-weekly',
|
||||
category: 'engineering',
|
||||
cronPattern: '0 8 * * 1',
|
||||
interests: ['coding'],
|
||||
},
|
||||
{
|
||||
id: 'keyword-tech-feed',
|
||||
category: 'engineering',
|
||||
cronPattern: '0 10 * * *',
|
||||
interests: ['coding'],
|
||||
},
|
||||
|
||||
// design
|
||||
{
|
||||
id: 'daily-design-inspiration',
|
||||
category: 'design',
|
||||
cronPattern: '0 9 * * *',
|
||||
interests: ['design'],
|
||||
},
|
||||
{
|
||||
id: 'design-trend-weekly',
|
||||
category: 'design',
|
||||
cronPattern: '0 9 * * 1',
|
||||
interests: ['design'],
|
||||
},
|
||||
{
|
||||
id: 'figma-files-cleanup',
|
||||
category: 'design',
|
||||
cronPattern: '0 17 * * 5',
|
||||
interests: ['design'],
|
||||
requiresSkills: [{ provider: 'figma', source: 'klavis' }],
|
||||
},
|
||||
{
|
||||
id: 'aigc-prompt-inspiration',
|
||||
category: 'design',
|
||||
cronPattern: '0 10 * * *',
|
||||
interests: ['design'],
|
||||
},
|
||||
{
|
||||
id: 'brand-watch-weekly',
|
||||
category: 'design',
|
||||
cronPattern: '0 10 * * 1',
|
||||
interests: ['design'],
|
||||
},
|
||||
{
|
||||
id: 'font-color-weekly',
|
||||
category: 'design',
|
||||
cronPattern: '0 10 * * 3',
|
||||
interests: ['design'],
|
||||
},
|
||||
|
||||
// learning-research
|
||||
{
|
||||
id: 'arxiv-curated-daily',
|
||||
category: 'learning-research',
|
||||
cronPattern: '0 9 * * *',
|
||||
interests: ['education'],
|
||||
},
|
||||
{
|
||||
id: 'must-read-papers-weekly',
|
||||
category: 'learning-research',
|
||||
cronPattern: '0 20 * * 0',
|
||||
interests: ['education'],
|
||||
},
|
||||
{
|
||||
id: 'language-morning-bite',
|
||||
category: 'learning-research',
|
||||
cronPattern: '30 7 * * *',
|
||||
interests: ['education'],
|
||||
},
|
||||
{
|
||||
id: 'industry-research-weekly',
|
||||
category: 'learning-research',
|
||||
cronPattern: '0 9 * * 1',
|
||||
interests: ['education', 'business'],
|
||||
},
|
||||
|
||||
// business
|
||||
{
|
||||
id: 'industry-morning-brief',
|
||||
category: 'business',
|
||||
cronPattern: '0 8 * * *',
|
||||
interests: ['business'],
|
||||
},
|
||||
{
|
||||
id: 'competitor-radar-daily',
|
||||
category: 'business',
|
||||
cronPattern: '0 9 * * *',
|
||||
interests: ['business'],
|
||||
},
|
||||
{
|
||||
id: 'funding-intel-daily',
|
||||
category: 'business',
|
||||
cronPattern: '0 10 * * *',
|
||||
interests: ['business'],
|
||||
},
|
||||
{
|
||||
id: 'macro-economy-weekly',
|
||||
category: 'business',
|
||||
cronPattern: '0 8 * * 1',
|
||||
interests: ['business', 'investing'],
|
||||
},
|
||||
{
|
||||
id: 'weekly-meeting-brief',
|
||||
category: 'business',
|
||||
cronPattern: '30 8 * * 1',
|
||||
interests: ['business'],
|
||||
},
|
||||
|
||||
// marketing
|
||||
{
|
||||
id: 'marketing-hot-radar',
|
||||
category: 'marketing',
|
||||
cronPattern: '0 10 * * *',
|
||||
interests: ['marketing'],
|
||||
},
|
||||
{
|
||||
id: 'ad-creative-inspiration',
|
||||
category: 'marketing',
|
||||
cronPattern: '0 10 * * *',
|
||||
interests: ['marketing'],
|
||||
},
|
||||
{
|
||||
id: 'brand-mention-daily',
|
||||
category: 'marketing',
|
||||
cronPattern: '0 18 * * *',
|
||||
interests: ['marketing'],
|
||||
requiresSkills: [{ provider: 'twitter', source: 'lobehub' }],
|
||||
},
|
||||
{
|
||||
id: 'seo-weekly-report',
|
||||
category: 'marketing',
|
||||
cronPattern: '0 9 * * 1',
|
||||
interests: ['marketing'],
|
||||
},
|
||||
{
|
||||
id: 'newsletter-perf-weekly',
|
||||
category: 'marketing',
|
||||
cronPattern: '0 10 * * 1',
|
||||
interests: ['marketing'],
|
||||
requiresSkills: [{ provider: 'gmail', source: 'klavis' }],
|
||||
},
|
||||
{
|
||||
id: 'kol-collab-calendar',
|
||||
category: 'marketing',
|
||||
cronPattern: '0 9 * * 1',
|
||||
interests: ['marketing'],
|
||||
requiresSkills: [{ provider: 'airtable', source: 'klavis' }],
|
||||
},
|
||||
{
|
||||
id: 'hubspot-funnel-daily',
|
||||
category: 'marketing',
|
||||
cronPattern: '0 9 * * *',
|
||||
interests: ['marketing', 'sales'],
|
||||
requiresSkills: [{ provider: 'hubspot', source: 'klavis' }],
|
||||
},
|
||||
|
||||
// product
|
||||
{
|
||||
id: 'user-feedback-daily',
|
||||
category: 'product',
|
||||
cronPattern: '0 9 * * *',
|
||||
interests: ['product'],
|
||||
},
|
||||
{
|
||||
id: 'competitor-update-daily',
|
||||
category: 'product',
|
||||
cronPattern: '0 10 * * *',
|
||||
interests: ['product'],
|
||||
},
|
||||
{
|
||||
id: 'standup-brief',
|
||||
category: 'product',
|
||||
cronPattern: '30 8 * * *',
|
||||
interests: ['product'],
|
||||
requiresSkills: [{ provider: 'linear', source: 'lobehub' }],
|
||||
},
|
||||
{
|
||||
id: 'iteration-recap-weekly',
|
||||
category: 'product',
|
||||
cronPattern: '0 17 * * 5',
|
||||
interests: ['product'],
|
||||
requiresSkills: [{ provider: 'linear', source: 'lobehub' }],
|
||||
},
|
||||
{
|
||||
id: 'core-metric-daily',
|
||||
category: 'product',
|
||||
cronPattern: '0 9 * * *',
|
||||
interests: ['product'],
|
||||
},
|
||||
{
|
||||
id: 'user-interview-schedule',
|
||||
category: 'product',
|
||||
cronPattern: '0 9 * * 1',
|
||||
interests: ['product'],
|
||||
requiresSkills: [{ provider: 'google-calendar', source: 'klavis' }],
|
||||
},
|
||||
{
|
||||
id: 'prd-review-reminder',
|
||||
category: 'product',
|
||||
cronPattern: '0 15 * * 5',
|
||||
interests: ['product'],
|
||||
requiresSkills: [{ provider: 'notion', source: 'klavis' }],
|
||||
},
|
||||
|
||||
// sales-customer
|
||||
{
|
||||
id: 'daily-followup-list',
|
||||
category: 'sales-customer',
|
||||
cronPattern: '0 9 * * *',
|
||||
interests: ['sales'],
|
||||
requiresSkills: [{ provider: 'hubspot', source: 'klavis' }],
|
||||
},
|
||||
{
|
||||
id: 'renewal-risk-weekly',
|
||||
category: 'sales-customer',
|
||||
cronPattern: '0 9 * * 1',
|
||||
interests: ['sales'],
|
||||
requiresSkills: [{ provider: 'hubspot', source: 'klavis' }],
|
||||
},
|
||||
{
|
||||
id: 'deal-pipeline-weekly',
|
||||
category: 'sales-customer',
|
||||
cronPattern: '0 16 * * 5',
|
||||
interests: ['sales'],
|
||||
requiresSkills: [{ provider: 'hubspot', source: 'klavis' }],
|
||||
},
|
||||
{
|
||||
id: 'key-account-radar',
|
||||
category: 'sales-customer',
|
||||
cronPattern: '0 9 * * *',
|
||||
interests: ['sales'],
|
||||
},
|
||||
{
|
||||
id: 'zendesk-ticket-daily',
|
||||
category: 'sales-customer',
|
||||
cronPattern: '0 9 * * *',
|
||||
interests: ['sales'],
|
||||
requiresSkills: [{ provider: 'zendesk', source: 'klavis' }],
|
||||
},
|
||||
|
||||
// operations
|
||||
{
|
||||
id: 'morning-brief',
|
||||
category: 'operations',
|
||||
cronPattern: '0 8 * * *',
|
||||
interests: ['operations'],
|
||||
requiresSkills: [{ provider: 'google-calendar', source: 'klavis' }],
|
||||
},
|
||||
{
|
||||
id: 'meeting-brief',
|
||||
category: 'operations',
|
||||
cronPattern: '30 8 * * *',
|
||||
interests: ['operations'],
|
||||
requiresSkills: [{ provider: 'google-calendar', source: 'klavis' }],
|
||||
},
|
||||
{
|
||||
id: 'calendar-conflict-check',
|
||||
category: 'operations',
|
||||
cronPattern: '30 7 * * *',
|
||||
interests: ['operations'],
|
||||
requiresSkills: [{ provider: 'google-calendar', source: 'klavis' }],
|
||||
},
|
||||
{
|
||||
id: 'friday-wrap-list',
|
||||
category: 'operations',
|
||||
cronPattern: '0 16 * * 5',
|
||||
interests: ['operations'],
|
||||
requiresSkills: [{ provider: 'linear', source: 'lobehub' }],
|
||||
},
|
||||
|
||||
// hr
|
||||
{
|
||||
id: 'recruit-funnel-daily',
|
||||
category: 'hr',
|
||||
cronPattern: '0 9 * * *',
|
||||
interests: ['hr'],
|
||||
requiresSkills: [{ provider: 'airtable', source: 'klavis' }],
|
||||
},
|
||||
{
|
||||
id: 'onboarding-buddy-weekly',
|
||||
category: 'hr',
|
||||
cronPattern: '0 9 * * 1',
|
||||
interests: ['hr'],
|
||||
requiresSkills: [{ provider: 'notion', source: 'klavis' }],
|
||||
},
|
||||
{
|
||||
id: 'team-status-weekly',
|
||||
category: 'hr',
|
||||
cronPattern: '0 9 * * 1',
|
||||
interests: ['hr'],
|
||||
requiresSkills: [{ provider: 'google-calendar', source: 'klavis' }],
|
||||
},
|
||||
|
||||
// finance-legal
|
||||
{
|
||||
id: 'precious-metals-daily',
|
||||
category: 'finance-legal',
|
||||
cronPattern: '0 16 * * *',
|
||||
interests: ['finance-legal', 'investing'],
|
||||
},
|
||||
{
|
||||
id: 'pre-market-brief',
|
||||
category: 'finance-legal',
|
||||
cronPattern: '0 9 * * *',
|
||||
interests: ['finance-legal', 'investing'],
|
||||
},
|
||||
{
|
||||
id: 'cashflow-weekly',
|
||||
category: 'finance-legal',
|
||||
cronPattern: '0 9 * * 1',
|
||||
interests: ['finance-legal'],
|
||||
requiresSkills: [{ provider: 'airtable', source: 'klavis' }],
|
||||
},
|
||||
{
|
||||
id: 'contract-expiry-weekly',
|
||||
category: 'finance-legal',
|
||||
cronPattern: '0 9 * * 1',
|
||||
interests: ['finance-legal'],
|
||||
requiresSkills: [{ provider: 'notion', source: 'klavis' }],
|
||||
},
|
||||
{
|
||||
id: 'regulation-watch-weekly',
|
||||
category: 'finance-legal',
|
||||
cronPattern: '0 10 * * 1',
|
||||
interests: ['finance-legal'],
|
||||
},
|
||||
{
|
||||
id: 'invoice-collection-daily',
|
||||
category: 'finance-legal',
|
||||
cronPattern: '0 10 * * *',
|
||||
interests: ['finance-legal'],
|
||||
requiresSkills: [{ provider: 'gmail', source: 'klavis' }],
|
||||
},
|
||||
|
||||
// creator
|
||||
{
|
||||
id: 'cross-platform-engagement-daily',
|
||||
category: 'creator',
|
||||
cronPattern: '0 9 * * *',
|
||||
interests: ['creator'],
|
||||
requiresSkills: [{ provider: 'twitter', source: 'lobehub' }],
|
||||
},
|
||||
{
|
||||
id: 'brand-collab-weekly',
|
||||
category: 'creator',
|
||||
cronPattern: '0 10 * * 1',
|
||||
interests: ['creator'],
|
||||
},
|
||||
{
|
||||
id: 'follower-growth-weekly',
|
||||
category: 'creator',
|
||||
cronPattern: '0 10 * * 1',
|
||||
interests: ['creator'],
|
||||
requiresSkills: [{ provider: 'twitter', source: 'lobehub' }],
|
||||
},
|
||||
{
|
||||
id: 'youtube-channel-weekly',
|
||||
category: 'creator',
|
||||
cronPattern: '0 9 * * 1',
|
||||
interests: ['creator'],
|
||||
requiresSkills: [{ provider: 'youtube', source: 'klavis' }],
|
||||
},
|
||||
{
|
||||
id: 'monetization-opportunity-weekly',
|
||||
category: 'creator',
|
||||
cronPattern: '0 10 * * 3',
|
||||
interests: ['creator'],
|
||||
},
|
||||
|
||||
// investing
|
||||
{
|
||||
id: 'portfolio-daily',
|
||||
category: 'investing',
|
||||
cronPattern: '0 16 * * *',
|
||||
interests: ['investing'],
|
||||
},
|
||||
{
|
||||
id: 'crypto-market-daily',
|
||||
category: 'investing',
|
||||
cronPattern: '0 9 * * *',
|
||||
interests: ['investing'],
|
||||
},
|
||||
|
||||
// parenting
|
||||
{
|
||||
id: 'child-growth-weekly',
|
||||
category: 'parenting',
|
||||
cronPattern: '0 9 * * 1',
|
||||
interests: ['parenting'],
|
||||
},
|
||||
{
|
||||
id: 'child-study-weekly',
|
||||
category: 'parenting',
|
||||
cronPattern: '0 20 * * 0',
|
||||
interests: ['parenting', 'education'],
|
||||
},
|
||||
{
|
||||
id: 'family-finance-weekly',
|
||||
category: 'parenting',
|
||||
cronPattern: '0 20 * * 0',
|
||||
interests: ['parenting', 'finance-legal'],
|
||||
requiresSkills: [{ provider: 'google-sheets', source: 'klavis' }],
|
||||
},
|
||||
{
|
||||
id: 'family-task-schedule',
|
||||
category: 'parenting',
|
||||
cronPattern: '0 8 * * 1',
|
||||
interests: ['parenting'],
|
||||
optionalSkills: [{ provider: 'google-calendar', source: 'klavis' }],
|
||||
},
|
||||
|
||||
// health
|
||||
{
|
||||
id: 'diet-log-companion',
|
||||
category: 'health',
|
||||
cronPattern: '0 21 * * *',
|
||||
interests: ['health'],
|
||||
},
|
||||
|
||||
// hobbies
|
||||
{
|
||||
id: 'podcast-new-episodes',
|
||||
category: 'hobbies',
|
||||
cronPattern: '0 9 * * 1',
|
||||
interests: ['hobbies'],
|
||||
},
|
||||
{
|
||||
id: 'newsletter-aggregator',
|
||||
category: 'hobbies',
|
||||
cronPattern: '0 20 * * 0',
|
||||
interests: ['hobbies'],
|
||||
requiresSkills: [{ provider: 'gmail', source: 'klavis' }],
|
||||
},
|
||||
{
|
||||
id: 'series-update-weekly',
|
||||
category: 'hobbies',
|
||||
cronPattern: '0 9 * * 1',
|
||||
interests: ['hobbies'],
|
||||
},
|
||||
{
|
||||
id: 'travel-inspiration-weekly',
|
||||
category: 'hobbies',
|
||||
cronPattern: '0 10 * * 3',
|
||||
interests: ['hobbies'],
|
||||
},
|
||||
{
|
||||
id: 'watchlist-friday',
|
||||
category: 'hobbies',
|
||||
cronPattern: '0 18 * * 5',
|
||||
interests: ['hobbies'],
|
||||
},
|
||||
{
|
||||
id: 'exhibition-event-weekly',
|
||||
category: 'hobbies',
|
||||
cronPattern: '0 10 * * 1',
|
||||
interests: ['hobbies'],
|
||||
},
|
||||
|
||||
// personal-life
|
||||
{
|
||||
id: 'daily-learning-bite',
|
||||
category: 'personal-life',
|
||||
cronPattern: '30 7 * * *',
|
||||
interests: ['education', 'personal'],
|
||||
},
|
||||
{
|
||||
id: 'sunday-reflection',
|
||||
category: 'personal-life',
|
||||
cronPattern: '0 21 * * 0',
|
||||
interests: ['personal'],
|
||||
},
|
||||
{
|
||||
id: 'morning-ritual',
|
||||
category: 'personal-life',
|
||||
cronPattern: '0 7 * * *',
|
||||
interests: ['personal'],
|
||||
optionalSkills: [{ provider: 'google-calendar', source: 'klavis' }],
|
||||
},
|
||||
{
|
||||
id: 'bedtime-gratitude',
|
||||
category: 'personal-life',
|
||||
cronPattern: '0 22 * * *',
|
||||
interests: ['personal'],
|
||||
optionalSkills: [{ provider: 'notion', source: 'klavis' }],
|
||||
},
|
||||
];
|
||||
@@ -1,7 +0,0 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'happy-dom',
|
||||
},
|
||||
});
|
||||
@@ -1,5 +1,4 @@
|
||||
// @vitest-environment node
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { getTestDB } from '../../core/getTestDB';
|
||||
@@ -18,9 +17,6 @@ const createTopic = async (id: string, uid = userId) => {
|
||||
return id;
|
||||
};
|
||||
|
||||
const getTopic = async (id: string) =>
|
||||
(await serverDB.select().from(topics).where(eq(topics.id, id)).limit(1))[0];
|
||||
|
||||
beforeEach(async () => {
|
||||
await serverDB.delete(users);
|
||||
await serverDB.insert(users).values([{ id: userId }, { id: userId2 }]);
|
||||
@@ -77,84 +73,6 @@ describe('TaskTopicModel', () => {
|
||||
const topics = await topicModel.findByTaskId(task.id);
|
||||
expect(topics[0].status).toBe('completed');
|
||||
});
|
||||
|
||||
it('should mirror completed status to topics row', async () => {
|
||||
const taskModel = new TaskModel(serverDB, userId);
|
||||
const topicModel = new TaskTopicModel(serverDB, userId);
|
||||
const task = await taskModel.create({ instruction: 'Test' });
|
||||
await createTopic('tpc_done');
|
||||
|
||||
await topicModel.add(task.id, 'tpc_done', { seq: 1 });
|
||||
await topicModel.updateStatus(task.id, 'tpc_done', 'completed');
|
||||
|
||||
const topic = await getTopic('tpc_done');
|
||||
expect(topic.status).toBe('completed');
|
||||
expect(topic.completedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('should stamp completedAt without promoting status for non-completed terminal states', async () => {
|
||||
const taskModel = new TaskModel(serverDB, userId);
|
||||
const topicModel = new TaskTopicModel(serverDB, userId);
|
||||
const task = await taskModel.create({ instruction: 'Test' });
|
||||
await createTopic('tpc_failed');
|
||||
|
||||
await topicModel.add(task.id, 'tpc_failed', { seq: 1 });
|
||||
await topicModel.updateStatus(task.id, 'tpc_failed', 'failed');
|
||||
|
||||
const topic = await getTopic('tpc_failed');
|
||||
expect(topic.completedAt).toBeInstanceOf(Date);
|
||||
// topic.status stays whatever it was (default null), not promoted to 'completed'
|
||||
expect(topic.status).not.toBe('completed');
|
||||
});
|
||||
|
||||
it('should not stamp completedAt for non-terminal status', async () => {
|
||||
const taskModel = new TaskModel(serverDB, userId);
|
||||
const topicModel = new TaskTopicModel(serverDB, userId);
|
||||
const task = await taskModel.create({ instruction: 'Test' });
|
||||
await createTopic('tpc_running');
|
||||
|
||||
await topicModel.add(task.id, 'tpc_running', { seq: 1 });
|
||||
await topicModel.updateStatus(task.id, 'tpc_running', 'running');
|
||||
|
||||
const topic = await getTopic('tpc_running');
|
||||
expect(topic.completedAt).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancelIfRunning', () => {
|
||||
it('should cancel + stamp completedAt when topic was running', async () => {
|
||||
const taskModel = new TaskModel(serverDB, userId);
|
||||
const topicModel = new TaskTopicModel(serverDB, userId);
|
||||
const task = await taskModel.create({ instruction: 'Test' });
|
||||
await createTopic('tpc_cancel');
|
||||
|
||||
await topicModel.add(task.id, 'tpc_cancel', { seq: 1 });
|
||||
const updated = await topicModel.cancelIfRunning(task.id, 'tpc_cancel');
|
||||
|
||||
expect(updated).toBe(true);
|
||||
const topic = await getTopic('tpc_cancel');
|
||||
expect(topic.completedAt).toBeInstanceOf(Date);
|
||||
expect(topic.status).not.toBe('completed');
|
||||
});
|
||||
|
||||
it('should be a no-op when topic is not running', async () => {
|
||||
const taskModel = new TaskModel(serverDB, userId);
|
||||
const topicModel = new TaskTopicModel(serverDB, userId);
|
||||
const task = await taskModel.create({ instruction: 'Test' });
|
||||
await createTopic('tpc_already_done');
|
||||
|
||||
await topicModel.add(task.id, 'tpc_already_done', { seq: 1 });
|
||||
await topicModel.updateStatus(task.id, 'tpc_already_done', 'completed');
|
||||
const completedAtBefore = (await getTopic('tpc_already_done')).completedAt;
|
||||
|
||||
// sleep one tick so a re-stamp would be detectable
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
const updated = await topicModel.cancelIfRunning(task.id, 'tpc_already_done');
|
||||
|
||||
expect(updated).toBe(false);
|
||||
const topic = await getTopic('tpc_already_done');
|
||||
expect(topic.completedAt?.getTime()).toBe(completedAtBefore?.getTime());
|
||||
});
|
||||
});
|
||||
|
||||
describe('timeoutRunning', () => {
|
||||
@@ -178,44 +96,6 @@ describe('TaskTopicModel', () => {
|
||||
expect(tpcA!.status).toBe('completed');
|
||||
expect(tpcB!.status).toBe('timeout');
|
||||
});
|
||||
|
||||
it('should stamp completedAt on each timed-out topic', async () => {
|
||||
const taskModel = new TaskModel(serverDB, userId);
|
||||
const topicModel = new TaskTopicModel(serverDB, userId);
|
||||
const task = await taskModel.create({ instruction: 'Test' });
|
||||
await createTopic('tpc_t1');
|
||||
await createTopic('tpc_t2');
|
||||
|
||||
await topicModel.add(task.id, 'tpc_t1', { seq: 1 });
|
||||
await topicModel.add(task.id, 'tpc_t2', { seq: 2 });
|
||||
|
||||
await topicModel.timeoutRunning(task.id);
|
||||
|
||||
const t1 = await getTopic('tpc_t1');
|
||||
const t2 = await getTopic('tpc_t2');
|
||||
expect(t1.completedAt).toBeInstanceOf(Date);
|
||||
expect(t2.completedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findWithHandoff', () => {
|
||||
it('should return completedAt joined from topics', async () => {
|
||||
const taskModel = new TaskModel(serverDB, userId);
|
||||
const topicModel = new TaskTopicModel(serverDB, userId);
|
||||
const task = await taskModel.create({ instruction: 'Test' });
|
||||
await createTopic('tpc_h1');
|
||||
await createTopic('tpc_h2');
|
||||
|
||||
await topicModel.add(task.id, 'tpc_h1', { seq: 1 });
|
||||
await topicModel.add(task.id, 'tpc_h2', { seq: 2 });
|
||||
await topicModel.updateStatus(task.id, 'tpc_h1', 'completed');
|
||||
|
||||
const rows = await topicModel.findWithHandoff(task.id, 10);
|
||||
const h1 = rows.find((r) => r.topicId === 'tpc_h1');
|
||||
const h2 = rows.find((r) => r.topicId === 'tpc_h2');
|
||||
expect(h1?.completedAt).toBeInstanceOf(Date);
|
||||
expect(h2?.completedAt).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateHandoff', () => {
|
||||
|
||||
@@ -60,7 +60,6 @@ describe('UserModel', () => {
|
||||
await serverDB.insert(userSettings).values({
|
||||
id: userId,
|
||||
general: { fontSize: 14 },
|
||||
notification: { inbox: { enabled: false } },
|
||||
tts: { voice: 'default' },
|
||||
});
|
||||
|
||||
@@ -71,7 +70,6 @@ describe('UserModel', () => {
|
||||
expect(result.fullName).toBe('Test User');
|
||||
expect(result.settings.general).toEqual({ fontSize: 14 });
|
||||
expect(result.settings.tts).toEqual({ voice: 'default' });
|
||||
expect(result.settings.notification).toEqual({ inbox: { enabled: false } });
|
||||
});
|
||||
|
||||
it('should throw UserNotFoundError for non-existent user', async () => {
|
||||
|
||||
@@ -4,7 +4,6 @@ import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { getTestDB } from '../../../core/getTestDB';
|
||||
import { agentDocuments, agents, documents, users } from '../../../schemas';
|
||||
import { DOCUMENT_FOLDER_TYPE } from '../../../schemas/file';
|
||||
import type { LobeChatDatabase } from '../../../type';
|
||||
import {
|
||||
AgentDocumentModel,
|
||||
@@ -128,28 +127,6 @@ describe('AgentDocumentModel', () => {
|
||||
|
||||
expect(countAfter.length).toBe(countBefore.length);
|
||||
});
|
||||
|
||||
it('should reject associating a document when a live sibling already owns the same path', async () => {
|
||||
await agentDocumentModel.create(agentId, 'associated.md', 'managed');
|
||||
const [doc] = await serverDB
|
||||
.insert(documents)
|
||||
.values({
|
||||
content: 'existing',
|
||||
fileType: 'article',
|
||||
filename: 'associated.md',
|
||||
source: 'https://example.com/associated',
|
||||
sourceType: 'web',
|
||||
title: 'Associated',
|
||||
totalCharCount: 8,
|
||||
totalLineCount: 1,
|
||||
userId,
|
||||
})
|
||||
.returning();
|
||||
|
||||
await expect(
|
||||
agentDocumentModel.associate({ agentId, documentId: doc!.id }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
@@ -194,45 +171,6 @@ describe('AgentDocumentModel', () => {
|
||||
expect(result.accessShared).toBe(0);
|
||||
expect(result.accessPublic).toBe(0);
|
||||
});
|
||||
|
||||
it('should reject duplicate live siblings at the database boundary', async () => {
|
||||
await agentDocumentModel.create(agentId, 'duplicate.md', 'first');
|
||||
|
||||
await expect(agentDocumentModel.create(agentId, 'duplicate.md', 'second')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should allow different agents to use the same root filename', async () => {
|
||||
const first = await agentDocumentModel.create(agentId, 'shared.md', 'first');
|
||||
const second = await agentDocumentModel.create(secondAgentId, 'shared.md', 'second');
|
||||
|
||||
expect(first.agentId).toBe(agentId);
|
||||
expect(second.agentId).toBe(secondAgentId);
|
||||
expect(first.documentId).not.toBe(second.documentId);
|
||||
});
|
||||
|
||||
it('should allow recreating a filename after the previous sibling is soft deleted', async () => {
|
||||
const first = await agentDocumentModel.create(agentId, 'recreated.md', 'first');
|
||||
|
||||
await agentDocumentModel.delete(first.id, 'replace');
|
||||
|
||||
const second = await agentDocumentModel.create(agentId, 'recreated.md', 'second');
|
||||
|
||||
expect(second.id).not.toBe(first.id);
|
||||
expect(second.content).toBe('second');
|
||||
});
|
||||
|
||||
it('should allow callers to opt out of sibling uniqueness for managed mount documents', async () => {
|
||||
const first = await agentDocumentModel.create(agentId, 'skills', '', {
|
||||
uniqueSibling: false,
|
||||
metadata: { mount: { namespace: 'topic', role: 'root' } },
|
||||
});
|
||||
const second = await agentDocumentModel.create(agentId, 'skills', '', {
|
||||
uniqueSibling: false,
|
||||
metadata: { mount: { namespace: 'agent', role: 'root' } },
|
||||
});
|
||||
|
||||
expect(first.documentId).not.toBe(second.documentId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findById and findByFilename', () => {
|
||||
@@ -371,49 +309,6 @@ describe('AgentDocumentModel', () => {
|
||||
expect(renamed?.filename).toBe('IDENTITY 2');
|
||||
});
|
||||
|
||||
it('should move path metadata without changing agent document identity', async () => {
|
||||
const folder = await agentDocumentModel.create(agentId, 'folder', '', {
|
||||
fileType: DOCUMENT_FOLDER_TYPE,
|
||||
title: 'folder',
|
||||
});
|
||||
const created = await agentDocumentModel.create(agentId, 'old.md', 'hello');
|
||||
|
||||
const moved = await agentDocumentModel.movePath(created.id, {
|
||||
filename: 'new.md',
|
||||
parentId: folder.documentId,
|
||||
});
|
||||
|
||||
expect(moved?.id).toBe(created.id);
|
||||
expect(moved?.documentId).toBe(created.documentId);
|
||||
expect(moved?.filename).toBe('new.md');
|
||||
expect(moved?.parentId).toBe(folder.documentId);
|
||||
|
||||
const [doc] = await serverDB
|
||||
.select()
|
||||
.from(documents)
|
||||
.where(eq(documents.id, created.documentId));
|
||||
|
||||
expect(doc?.source).toBe(`agent-document://${agentId}/${encodeURIComponent('new.md')}`);
|
||||
});
|
||||
|
||||
it('should reject moving a document over an existing live sibling path', async () => {
|
||||
const folder = await agentDocumentModel.create(agentId, 'move-folder', '', {
|
||||
fileType: DOCUMENT_FOLDER_TYPE,
|
||||
title: 'move-folder',
|
||||
});
|
||||
const source = await agentDocumentModel.create(agentId, 'source.md', 'source');
|
||||
await agentDocumentModel.create(agentId, 'target.md', 'target', {
|
||||
parentId: folder.documentId,
|
||||
});
|
||||
|
||||
await expect(
|
||||
agentDocumentModel.movePath(source.id, {
|
||||
filename: 'target.md',
|
||||
parentId: folder.documentId,
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should copy into a new record and keep policy/template metadata', async () => {
|
||||
const created = await agentDocumentModel.create(agentId, 'copy-source.md', 'copy me', {
|
||||
loadPosition: DocumentLoadPosition.BEFORE_SYSTEM,
|
||||
@@ -610,15 +505,6 @@ describe('AgentDocumentModel', () => {
|
||||
expect(rawDoc).toBeDefined();
|
||||
});
|
||||
|
||||
it('should reject restoring a deleted document over an existing live sibling path', async () => {
|
||||
const first = await agentDocumentModel.create(agentId, 'restore-conflict.md', 'first');
|
||||
|
||||
await agentDocumentModel.delete(first.id, 'replace');
|
||||
await agentDocumentModel.create(agentId, 'restore-conflict.md', 'second');
|
||||
|
||||
await expect(agentDocumentModel.restore(first.id)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should return empty string from getAgentContext when no loadable docs exist', async () => {
|
||||
const context = await agentDocumentModel.getAgentContext(agentId);
|
||||
expect(context).toBe('');
|
||||
@@ -656,149 +542,5 @@ describe('AgentDocumentModel', () => {
|
||||
.where(and(eq(agentDocuments.id, otherTemplateDoc.id), eq(agentDocuments.userId, userId)));
|
||||
expect(otherTemplateRow?.deletedAt).toBeNull();
|
||||
});
|
||||
|
||||
it('should support include-deleted lookups and deleted-only child listings', async () => {
|
||||
const folder = await agentDocumentModel.create(agentId, 'notes', '', {
|
||||
fileType: DOCUMENT_FOLDER_TYPE,
|
||||
title: 'notes',
|
||||
});
|
||||
const visibleChild = await agentDocumentModel.create(agentId, 'visible.md', 'visible', {
|
||||
parentId: folder.documentId,
|
||||
});
|
||||
const deletedChild = await agentDocumentModel.create(agentId, 'deleted.md', 'deleted', {
|
||||
parentId: folder.documentId,
|
||||
});
|
||||
|
||||
await agentDocumentModel.delete(deletedChild.id, 'trash it');
|
||||
|
||||
expect(await agentDocumentModel.findById(deletedChild.id)).toBeUndefined();
|
||||
expect(
|
||||
await agentDocumentModel.findById(deletedChild.id, {
|
||||
includeDeleted: true,
|
||||
}),
|
||||
).toMatchObject({ id: deletedChild.id });
|
||||
|
||||
const liveChildren = await agentDocumentModel.listByParent(agentId, folder.documentId);
|
||||
const allChildren = await agentDocumentModel.listByParent(agentId, folder.documentId, {
|
||||
includeDeleted: true,
|
||||
});
|
||||
const deletedChildren = await agentDocumentModel.listByParent(agentId, folder.documentId, {
|
||||
deletedOnly: true,
|
||||
});
|
||||
|
||||
expect(liveChildren.map((item) => item.id)).toEqual([visibleChild.id]);
|
||||
expect(allChildren.map((item) => item.id).sort()).toEqual(
|
||||
[visibleChild.id, deletedChild.id].sort(),
|
||||
);
|
||||
expect(deletedChildren.map((item) => item.id)).toEqual([deletedChild.id]);
|
||||
|
||||
const deletedByPath = await agentDocumentModel.findByParentAndFilename(
|
||||
agentId,
|
||||
folder.documentId,
|
||||
'deleted.md',
|
||||
{
|
||||
includeDeleted: true,
|
||||
},
|
||||
);
|
||||
expect(deletedByPath?.id).toBe(deletedChild.id);
|
||||
|
||||
const liveByPath = await agentDocumentModel.listByParentAndFilename(
|
||||
agentId,
|
||||
folder.documentId,
|
||||
'visible.md',
|
||||
{
|
||||
limit: 1,
|
||||
},
|
||||
);
|
||||
expect(liveByPath.map((item) => item.id)).toEqual([visibleChild.id]);
|
||||
});
|
||||
|
||||
it('should soft-delete, restore, and permanently delete a subtree by root document id', async () => {
|
||||
const rootFolder = await agentDocumentModel.create(agentId, 'workspace', '', {
|
||||
fileType: DOCUMENT_FOLDER_TYPE,
|
||||
title: 'workspace',
|
||||
});
|
||||
const nestedFolder = await agentDocumentModel.create(agentId, 'drafts', '', {
|
||||
fileType: DOCUMENT_FOLDER_TYPE,
|
||||
parentId: rootFolder.documentId,
|
||||
title: 'drafts',
|
||||
});
|
||||
const nestedFile = await agentDocumentModel.create(agentId, 'plan.md', 'v1', {
|
||||
parentId: nestedFolder.documentId,
|
||||
});
|
||||
const siblingFile = await agentDocumentModel.create(agentId, 'keep.md', 'keep me');
|
||||
|
||||
await agentDocumentModel.deleteSubtreeByDocumentId(
|
||||
agentId,
|
||||
rootFolder.documentId,
|
||||
'recursive cleanup',
|
||||
);
|
||||
|
||||
expect(await agentDocumentModel.findById(rootFolder.id)).toBeUndefined();
|
||||
expect(await agentDocumentModel.findById(nestedFolder.id)).toBeUndefined();
|
||||
expect(await agentDocumentModel.findById(nestedFile.id)).toBeUndefined();
|
||||
expect(await agentDocumentModel.findById(siblingFile.id)).toBeDefined();
|
||||
|
||||
const deletedTree = await agentDocumentModel.listSubtreeByDocumentId(
|
||||
agentId,
|
||||
rootFolder.documentId,
|
||||
{
|
||||
includeDeleted: true,
|
||||
},
|
||||
);
|
||||
expect(deletedTree.map((item) => item.id).sort()).toEqual(
|
||||
[rootFolder.id, nestedFolder.id, nestedFile.id].sort(),
|
||||
);
|
||||
|
||||
const trashItems = await agentDocumentModel.listDeletedByAgent(agentId);
|
||||
expect(trashItems.map((item) => item.id).sort()).toEqual(
|
||||
[rootFolder.id, nestedFolder.id, nestedFile.id].sort(),
|
||||
);
|
||||
|
||||
await agentDocumentModel.restoreSubtreeByDocumentId(agentId, rootFolder.documentId);
|
||||
|
||||
const restoredTree = await agentDocumentModel.listSubtreeByDocumentId(
|
||||
agentId,
|
||||
rootFolder.documentId,
|
||||
);
|
||||
expect(restoredTree.map((item) => item.id).sort()).toEqual(
|
||||
[rootFolder.id, nestedFolder.id, nestedFile.id].sort(),
|
||||
);
|
||||
expect(await agentDocumentModel.listDeletedByAgent(agentId)).toEqual([]);
|
||||
|
||||
await agentDocumentModel.deleteSubtreeByDocumentId(
|
||||
agentId,
|
||||
rootFolder.documentId,
|
||||
'recursive cleanup',
|
||||
);
|
||||
await agentDocumentModel.permanentlyDeleteSubtreeByDocumentId(agentId, rootFolder.documentId);
|
||||
|
||||
expect(
|
||||
await agentDocumentModel.findByDocumentId(agentId, rootFolder.documentId, {
|
||||
includeDeleted: true,
|
||||
}),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
await agentDocumentModel.findByDocumentId(agentId, nestedFolder.documentId, {
|
||||
includeDeleted: true,
|
||||
}),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
await agentDocumentModel.findByDocumentId(agentId, nestedFile.documentId, {
|
||||
includeDeleted: true,
|
||||
}),
|
||||
).toBeUndefined();
|
||||
expect(await agentDocumentModel.findById(siblingFile.id)).toBeDefined();
|
||||
|
||||
const remainingRows = await serverDB
|
||||
.select()
|
||||
.from(documents)
|
||||
.where(eq(documents.userId, userId));
|
||||
|
||||
expect(remainingRows.map((item) => item.id)).toContain(siblingFile.documentId);
|
||||
expect(remainingRows.map((item) => item.id)).not.toContain(rootFolder.documentId);
|
||||
expect(remainingRows.map((item) => item.id)).not.toContain(nestedFolder.documentId);
|
||||
expect(remainingRows.map((item) => item.id)).not.toContain(nestedFile.documentId);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, desc, eq, inArray, isNotNull, isNull, ne } from 'drizzle-orm';
|
||||
import { and, desc, eq, inArray, isNull } from 'drizzle-orm';
|
||||
|
||||
import type { DocumentItem, NewAgentDocument, NewDocument } from '../../schemas';
|
||||
import { agentDocuments, documents } from '../../schemas';
|
||||
@@ -29,30 +29,6 @@ import {
|
||||
|
||||
export * from './types';
|
||||
|
||||
interface AgentDocumentQueryOptions {
|
||||
cursor?: string;
|
||||
deletedOnly?: boolean;
|
||||
includeDeleted?: boolean;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for runtime VFS sibling uniqueness checks.
|
||||
*/
|
||||
interface AgentDocumentSiblingUniquenessOptions {
|
||||
/**
|
||||
* Whether to enforce one live ordinary document per `agentId + parentId + filename`.
|
||||
*
|
||||
* @default true
|
||||
*
|
||||
* NOTICE:
|
||||
* Set this to `false` only for managed mount documents that are not resolved through the
|
||||
* ordinary document VFS tree. Managed mounts can reuse backing storage names such as `skills`
|
||||
* while exposing distinct mounted namespaces.
|
||||
*/
|
||||
uniqueSibling?: boolean;
|
||||
}
|
||||
|
||||
export class AgentDocumentModel {
|
||||
private userId: string;
|
||||
private db: LobeChatDatabase;
|
||||
@@ -96,11 +72,9 @@ export class AgentDocumentModel {
|
||||
description: doc.description ?? null,
|
||||
documentId: settings.documentId,
|
||||
editorData: doc.editorData ?? null,
|
||||
fileType: doc.fileType,
|
||||
filename: doc.filename ?? '',
|
||||
id: settings.id,
|
||||
metadata: (doc.metadata as Record<string, any> | null) ?? null,
|
||||
parentId: doc.parentId ?? null,
|
||||
policy,
|
||||
policyLoadFormat,
|
||||
policyLoadPosition: settings.policyLoadPosition,
|
||||
@@ -114,181 +88,45 @@ export class AgentDocumentModel {
|
||||
};
|
||||
}
|
||||
|
||||
private buildDeletedAtFilters(options?: AgentDocumentQueryOptions) {
|
||||
if (options?.deletedOnly) return [isNotNull(agentDocuments.deletedAt)];
|
||||
if (options?.includeDeleted) return [];
|
||||
|
||||
return [isNull(agentDocuments.deletedAt)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Guards the agent-visible VFS sibling invariant before writes.
|
||||
*
|
||||
* Runtime enforcement is required for now because `agentId`/`deletedAt` live on `agent_documents`
|
||||
* while `parentId`/`filename` live on `documents`.
|
||||
*/
|
||||
private async assertNoLiveSiblingConflict(
|
||||
db: Pick<LobeChatDatabase, 'select'>,
|
||||
params: {
|
||||
agentId: string;
|
||||
excludeAgentDocumentId?: string;
|
||||
excludeDocumentId?: string;
|
||||
filename: string;
|
||||
parentId: string | null;
|
||||
},
|
||||
) {
|
||||
const excludeAgentDocument = params.excludeAgentDocumentId
|
||||
? ne(agentDocuments.id, params.excludeAgentDocumentId)
|
||||
: undefined;
|
||||
const excludeDocument = params.excludeDocumentId
|
||||
? ne(agentDocuments.documentId, params.excludeDocumentId)
|
||||
: undefined;
|
||||
|
||||
// TODO: Move this extra conflict query to a schema-level invariant when VFS path ownership fields live on one table.
|
||||
const [conflict] = await db
|
||||
.select({ id: agentDocuments.id })
|
||||
.from(agentDocuments)
|
||||
.innerJoin(documents, eq(agentDocuments.documentId, documents.id))
|
||||
.where(
|
||||
and(
|
||||
eq(agentDocuments.userId, this.userId),
|
||||
eq(agentDocuments.agentId, params.agentId),
|
||||
eq(documents.filename, params.filename),
|
||||
params.parentId ? eq(documents.parentId, params.parentId) : isNull(documents.parentId),
|
||||
isNull(agentDocuments.deletedAt),
|
||||
...(excludeAgentDocument ? [excludeAgentDocument] : []),
|
||||
...(excludeDocument ? [excludeDocument] : []),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (conflict) {
|
||||
throw new Error(`Agent document sibling already exists: ${params.filename}`);
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeListOffset(cursor?: string): number {
|
||||
if (!cursor) return 0;
|
||||
|
||||
const parsed = Number.parseInt(cursor, 10);
|
||||
|
||||
// Offset cursors keep the first VFS pagination pass storage-neutral.
|
||||
// Callers that need opaque cursors can wrap this model helper at the service layer later.
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
||||
}
|
||||
|
||||
private async listByParentIds(
|
||||
agentId: string,
|
||||
parentIds: string[],
|
||||
options?: AgentDocumentQueryOptions,
|
||||
): Promise<AgentDocument[]> {
|
||||
if (parentIds.length === 0) return [];
|
||||
|
||||
const results = await this.db
|
||||
.select({ doc: documents, settings: agentDocuments })
|
||||
.from(agentDocuments)
|
||||
.innerJoin(documents, eq(agentDocuments.documentId, documents.id))
|
||||
.where(
|
||||
and(
|
||||
eq(agentDocuments.userId, this.userId),
|
||||
eq(agentDocuments.agentId, agentId),
|
||||
inArray(documents.parentId, parentIds),
|
||||
...this.buildDeletedAtFilters(options),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(agentDocuments.updatedAt));
|
||||
|
||||
return results.map(({ settings, doc }) => this.toAgentDocument(settings, doc));
|
||||
}
|
||||
|
||||
/**
|
||||
* Associates an existing document row with an agent document binding.
|
||||
*
|
||||
* Use when:
|
||||
* - A document already exists and should become visible to an agent.
|
||||
* - Managed mount providers need to bind pre-created document tree nodes.
|
||||
*
|
||||
* Expects:
|
||||
* - `documentId` belongs to the current user.
|
||||
* - `uniqueSibling` defaults to `true` for ordinary VFS documents.
|
||||
*
|
||||
* Returns:
|
||||
* - The inserted agent document binding id, or an empty id when the source document is missing.
|
||||
*
|
||||
* NOTICE:
|
||||
* Pass `uniqueSibling: false` only for managed mount documents whose visible path is resolved by
|
||||
* the mount provider instead of the ordinary `parentId + filename` VFS tree.
|
||||
*/
|
||||
async associate(params: {
|
||||
agentId: string;
|
||||
documentId: string;
|
||||
uniqueSibling?: boolean;
|
||||
policyLoad?: PolicyLoad;
|
||||
}): Promise<{ id: string }> {
|
||||
const { agentId, documentId, uniqueSibling = true, policyLoad } = params;
|
||||
const { agentId, documentId, policyLoad } = params;
|
||||
|
||||
return this.db.transaction(async (trx) => {
|
||||
const [doc] = await trx
|
||||
.select()
|
||||
.from(documents)
|
||||
.where(and(eq(documents.id, documentId), eq(documents.userId, this.userId)))
|
||||
.limit(1);
|
||||
|
||||
if (!doc) return { id: '' };
|
||||
|
||||
if (uniqueSibling) {
|
||||
await this.assertNoLiveSiblingConflict(trx, {
|
||||
agentId,
|
||||
excludeDocumentId: documentId,
|
||||
filename: doc.filename ?? '',
|
||||
parentId: doc.parentId ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
const [result] = await trx
|
||||
.insert(agentDocuments)
|
||||
.values({
|
||||
accessPublic: 0,
|
||||
accessSelf:
|
||||
AgentAccess.EXECUTE |
|
||||
AgentAccess.LIST |
|
||||
AgentAccess.READ |
|
||||
AgentAccess.WRITE |
|
||||
AgentAccess.DELETE,
|
||||
accessShared: 0,
|
||||
agentId,
|
||||
documentId,
|
||||
policyLoad: policyLoad ?? PolicyLoad.PROGRESSIVE,
|
||||
policyLoadFormat: DocumentLoadFormat.RAW,
|
||||
policyLoadPosition: DocumentLoadPosition.BEFORE_FIRST_USER,
|
||||
policyLoadRule: DocumentLoadRule.ALWAYS,
|
||||
userId: this.userId,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning({ id: agentDocuments.id });
|
||||
|
||||
return { id: result?.id };
|
||||
// Verify the document belongs to the current user
|
||||
const doc = await this.db.query.documents.findFirst({
|
||||
where: and(eq(documents.id, documentId), eq(documents.userId, this.userId)),
|
||||
});
|
||||
|
||||
if (!doc) return { id: '' };
|
||||
|
||||
const [result] = await this.db
|
||||
.insert(agentDocuments)
|
||||
.values({
|
||||
accessPublic: 0,
|
||||
accessSelf:
|
||||
AgentAccess.EXECUTE |
|
||||
AgentAccess.LIST |
|
||||
AgentAccess.READ |
|
||||
AgentAccess.WRITE |
|
||||
AgentAccess.DELETE,
|
||||
accessShared: 0,
|
||||
agentId,
|
||||
documentId,
|
||||
policyLoad: policyLoad ?? PolicyLoad.PROGRESSIVE,
|
||||
policyLoadFormat: DocumentLoadFormat.RAW,
|
||||
policyLoadPosition: DocumentLoadPosition.BEFORE_FIRST_USER,
|
||||
policyLoadRule: DocumentLoadRule.ALWAYS,
|
||||
userId: this.userId,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning({ id: agentDocuments.id });
|
||||
|
||||
return { id: result?.id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a document row and links it to an agent in one transaction.
|
||||
*
|
||||
* Use when:
|
||||
* - Creating ordinary agent-visible VFS files or folders.
|
||||
* - Creating model-owned documents that still need agent document policy metadata.
|
||||
*
|
||||
* Expects:
|
||||
* - `filename` is a single VFS segment supplied by the caller.
|
||||
* - `uniqueSibling` defaults to `true` for ordinary VFS documents.
|
||||
*
|
||||
* Returns:
|
||||
* - The created agent document with joined document content and metadata.
|
||||
*
|
||||
* NOTICE:
|
||||
* Pass `uniqueSibling: false` only for managed mount documents whose storage filename can
|
||||
* intentionally collide with ordinary-looking names outside the ordinary VFS resolver.
|
||||
*/
|
||||
async create(
|
||||
agentId: string,
|
||||
filename: string,
|
||||
@@ -296,27 +134,22 @@ export class AgentDocumentModel {
|
||||
params?: {
|
||||
createdAt?: Date;
|
||||
editorData?: Record<string, any>;
|
||||
fileType?: string;
|
||||
loadPosition?: DocumentLoadPosition;
|
||||
loadRules?: DocumentLoadRules;
|
||||
metadata?: Record<string, any>;
|
||||
parentId?: string | null;
|
||||
policy?: AgentDocumentPolicy;
|
||||
policyLoad?: PolicyLoad;
|
||||
templateId?: string;
|
||||
title?: string;
|
||||
updatedAt?: Date;
|
||||
} & AgentDocumentSiblingUniquenessOptions,
|
||||
},
|
||||
): Promise<AgentDocument> {
|
||||
const {
|
||||
createdAt,
|
||||
editorData,
|
||||
uniqueSibling = true,
|
||||
fileType = 'agent/document',
|
||||
loadPosition,
|
||||
loadRules,
|
||||
metadata,
|
||||
parentId,
|
||||
policy,
|
||||
policyLoad,
|
||||
templateId,
|
||||
@@ -329,22 +162,13 @@ export class AgentDocumentModel {
|
||||
const normalizedPolicy = normalizePolicy(loadPosition, loadRules, policy);
|
||||
|
||||
return this.db.transaction(async (trx) => {
|
||||
if (uniqueSibling) {
|
||||
await this.assertNoLiveSiblingConflict(trx, {
|
||||
agentId,
|
||||
filename,
|
||||
parentId: parentId ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
const documentPayload: NewDocument = {
|
||||
content,
|
||||
createdAt,
|
||||
description: metadata?.description,
|
||||
editorData,
|
||||
fileType,
|
||||
fileType: 'agent/document',
|
||||
filename,
|
||||
parentId,
|
||||
metadata,
|
||||
source: `agent-document://${agentId}/${encodeURIComponent(filename)}`,
|
||||
sourceType: 'file',
|
||||
@@ -471,29 +295,7 @@ export class AgentDocumentModel {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Renames an agent document by updating the backing document filename and title.
|
||||
*
|
||||
* Use when:
|
||||
* - A caller wants title-style rename behavior.
|
||||
* - The document should keep its binding, content, policy, and document identity.
|
||||
*
|
||||
* Expects:
|
||||
* - `newTitle` is a human-readable title that can be normalized into a filename.
|
||||
* - `uniqueSibling` defaults to `true` for ordinary VFS documents.
|
||||
*
|
||||
* Returns:
|
||||
* - The renamed agent document, or `undefined` when the binding is not visible.
|
||||
*
|
||||
* NOTICE:
|
||||
* Pass `uniqueSibling: false` only for managed mount documents whose provider owns collision
|
||||
* handling outside the ordinary document VFS tree.
|
||||
*/
|
||||
async rename(
|
||||
documentId: string,
|
||||
newTitle: string,
|
||||
options: AgentDocumentSiblingUniquenessOptions = {},
|
||||
): Promise<AgentDocument | undefined> {
|
||||
async rename(documentId: string, newTitle: string): Promise<AgentDocument | undefined> {
|
||||
const existing = await this.findById(documentId);
|
||||
if (!existing) return undefined;
|
||||
|
||||
@@ -502,83 +304,15 @@ export class AgentDocumentModel {
|
||||
|
||||
const filename = buildDocumentFilename(title);
|
||||
const source = `agent-document://${existing.agentId}/${encodeURIComponent(filename)}`;
|
||||
const { uniqueSibling = true } = options;
|
||||
|
||||
await this.db.transaction(async (trx) => {
|
||||
if (uniqueSibling) {
|
||||
await this.assertNoLiveSiblingConflict(trx, {
|
||||
agentId: existing.agentId,
|
||||
excludeAgentDocumentId: existing.id,
|
||||
filename,
|
||||
parentId: existing.parentId,
|
||||
});
|
||||
}
|
||||
|
||||
await trx
|
||||
.update(documents)
|
||||
.set({
|
||||
filename,
|
||||
source,
|
||||
title,
|
||||
})
|
||||
.where(and(eq(documents.id, existing.documentId), eq(documents.userId, this.userId)));
|
||||
});
|
||||
|
||||
return this.findById(documentId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves or renames an agent document without changing its binding or document identity.
|
||||
*
|
||||
* Use when:
|
||||
* - VFS `rename(from, to)` needs filesystem-style metadata mutation
|
||||
* - Callers must preserve document id, agent document id, policy, history, and load settings
|
||||
*
|
||||
* Expects:
|
||||
* - `filename` is already validated as a single VFS path segment
|
||||
* - `parentId` points to a document row owned by the same user, or `null` for root
|
||||
* - `uniqueSibling` defaults to `true` for ordinary VFS documents
|
||||
*
|
||||
* Returns:
|
||||
* - The same agent document binding after the backing document row is moved
|
||||
*
|
||||
* NOTICE:
|
||||
* Pass `uniqueSibling: false` only for managed mount documents whose provider owns collision
|
||||
* handling outside the ordinary document VFS tree.
|
||||
*/
|
||||
async movePath(
|
||||
documentId: string,
|
||||
params: { filename: string; parentId: string | null } & AgentDocumentSiblingUniquenessOptions,
|
||||
): Promise<AgentDocument | undefined> {
|
||||
const existing = await this.findById(documentId);
|
||||
if (!existing) return undefined;
|
||||
|
||||
const filename = params.filename.trim();
|
||||
if (!filename) return existing;
|
||||
|
||||
const source = `agent-document://${existing.agentId}/${encodeURIComponent(filename)}`;
|
||||
const { uniqueSibling = true } = params;
|
||||
|
||||
await this.db.transaction(async (trx) => {
|
||||
if (uniqueSibling) {
|
||||
await this.assertNoLiveSiblingConflict(trx, {
|
||||
agentId: existing.agentId,
|
||||
excludeAgentDocumentId: existing.id,
|
||||
filename,
|
||||
parentId: params.parentId,
|
||||
});
|
||||
}
|
||||
|
||||
await trx
|
||||
.update(documents)
|
||||
.set({
|
||||
filename,
|
||||
parentId: params.parentId,
|
||||
source,
|
||||
title: filename,
|
||||
})
|
||||
.where(and(eq(documents.id, existing.documentId), eq(documents.userId, this.userId)));
|
||||
});
|
||||
await this.db
|
||||
.update(documents)
|
||||
.set({
|
||||
filename,
|
||||
source,
|
||||
title,
|
||||
})
|
||||
.where(and(eq(documents.id, existing.documentId), eq(documents.userId, this.userId)));
|
||||
|
||||
return this.findById(documentId);
|
||||
}
|
||||
@@ -633,17 +367,7 @@ export class AgentDocumentModel {
|
||||
return this.findById(documentId);
|
||||
}
|
||||
|
||||
async findById(
|
||||
documentId: string,
|
||||
options?: AgentDocumentQueryOptions,
|
||||
): Promise<AgentDocument | undefined> {
|
||||
return this.findByIdWithOptions(documentId, options);
|
||||
}
|
||||
|
||||
async findByIdWithOptions(
|
||||
documentId: string,
|
||||
options?: AgentDocumentQueryOptions,
|
||||
): Promise<AgentDocument | undefined> {
|
||||
async findById(documentId: string): Promise<AgentDocument | undefined> {
|
||||
const [result] = await this.db
|
||||
.select({ doc: documents, settings: agentDocuments })
|
||||
.from(agentDocuments)
|
||||
@@ -652,7 +376,7 @@ export class AgentDocumentModel {
|
||||
and(
|
||||
eq(agentDocuments.id, documentId),
|
||||
eq(agentDocuments.userId, this.userId),
|
||||
...this.buildDeletedAtFilters(options),
|
||||
isNull(agentDocuments.deletedAt),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
@@ -662,24 +386,6 @@ export class AgentDocumentModel {
|
||||
return this.toAgentDocument(result.settings, result.doc);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an existing document by filename or creates a new one when missing.
|
||||
*
|
||||
* Use when:
|
||||
* - Callers want idempotent writes keyed by agent and filename.
|
||||
* - Existing document policy and metadata should be merged on update.
|
||||
*
|
||||
* Expects:
|
||||
* - `filename` addresses the current agent's ordinary filename lookup.
|
||||
* - `uniqueSibling` is forwarded only to the create path and defaults to `true` there.
|
||||
*
|
||||
* Returns:
|
||||
* - The updated or created agent document.
|
||||
*
|
||||
* NOTICE:
|
||||
* Pass `uniqueSibling: false` only when the create path is creating managed mount documents
|
||||
* whose visible uniqueness is enforced by a provider-specific resolver.
|
||||
*/
|
||||
async upsert(
|
||||
agentId: string,
|
||||
filename: string,
|
||||
@@ -694,12 +400,11 @@ export class AgentDocumentModel {
|
||||
policyLoad?: PolicyLoad;
|
||||
templateId?: string;
|
||||
updatedAt?: Date;
|
||||
} & AgentDocumentSiblingUniquenessOptions,
|
||||
},
|
||||
): Promise<AgentDocument> {
|
||||
const {
|
||||
createdAt,
|
||||
editorData,
|
||||
uniqueSibling,
|
||||
loadPosition,
|
||||
loadRules,
|
||||
metadata,
|
||||
@@ -734,7 +439,6 @@ export class AgentDocumentModel {
|
||||
return this.create(agentId, filename, content, {
|
||||
createdAt,
|
||||
editorData,
|
||||
uniqueSibling,
|
||||
loadPosition,
|
||||
loadRules,
|
||||
metadata,
|
||||
@@ -837,11 +541,7 @@ export class AgentDocumentModel {
|
||||
});
|
||||
}
|
||||
|
||||
async findByFilename(
|
||||
agentId: string,
|
||||
filename: string,
|
||||
options?: AgentDocumentQueryOptions,
|
||||
): Promise<AgentDocument | undefined> {
|
||||
async findByFilename(agentId: string, filename: string): Promise<AgentDocument | undefined> {
|
||||
const [result] = await this.db
|
||||
.select({ doc: documents, settings: agentDocuments })
|
||||
.from(agentDocuments)
|
||||
@@ -851,7 +551,7 @@ export class AgentDocumentModel {
|
||||
eq(agentDocuments.userId, this.userId),
|
||||
eq(agentDocuments.agentId, agentId),
|
||||
eq(documents.filename, filename),
|
||||
...this.buildDeletedAtFilters(options),
|
||||
isNull(agentDocuments.deletedAt),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(agentDocuments.updatedAt))
|
||||
@@ -862,164 +562,6 @@ export class AgentDocumentModel {
|
||||
return this.toAgentDocument(result.settings, result.doc);
|
||||
}
|
||||
|
||||
async findByParentAndFilename(
|
||||
agentId: string,
|
||||
parentId: string | null,
|
||||
filename: string,
|
||||
options?: AgentDocumentQueryOptions,
|
||||
): Promise<AgentDocument | undefined> {
|
||||
const [result] = await this.db
|
||||
.select({ doc: documents, settings: agentDocuments })
|
||||
.from(agentDocuments)
|
||||
.innerJoin(documents, eq(agentDocuments.documentId, documents.id))
|
||||
.where(
|
||||
and(
|
||||
eq(agentDocuments.userId, this.userId),
|
||||
eq(agentDocuments.agentId, agentId),
|
||||
eq(documents.filename, filename),
|
||||
parentId ? eq(documents.parentId, parentId) : isNull(documents.parentId),
|
||||
...this.buildDeletedAtFilters(options),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(agentDocuments.updatedAt))
|
||||
.limit(1);
|
||||
|
||||
if (!result) return undefined;
|
||||
|
||||
return this.toAgentDocument(result.settings, result.doc);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists agent document bindings that share one tree segment.
|
||||
*
|
||||
* Use when:
|
||||
* - VFS callers need to reject ambiguous sibling paths before create, write, or restore
|
||||
* - Migration checks need to detect duplicate `parentId + filename` rows
|
||||
*
|
||||
* Expects:
|
||||
* - `parentId` is the canonical document row parent id, not the agent document id
|
||||
*
|
||||
* Returns:
|
||||
* - Matching bindings ordered newest first, optionally capped for conflict probes
|
||||
*/
|
||||
async listByParentAndFilename(
|
||||
agentId: string,
|
||||
parentId: string | null,
|
||||
filename: string,
|
||||
options?: AgentDocumentQueryOptions,
|
||||
): Promise<AgentDocument[]> {
|
||||
const results = await this.db
|
||||
.select({ doc: documents, settings: agentDocuments })
|
||||
.from(agentDocuments)
|
||||
.innerJoin(documents, eq(agentDocuments.documentId, documents.id))
|
||||
.where(
|
||||
and(
|
||||
eq(agentDocuments.userId, this.userId),
|
||||
eq(agentDocuments.agentId, agentId),
|
||||
eq(documents.filename, filename),
|
||||
parentId ? eq(documents.parentId, parentId) : isNull(documents.parentId),
|
||||
...this.buildDeletedAtFilters(options),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(agentDocuments.updatedAt))
|
||||
.limit(options?.limit ?? 9999)
|
||||
.offset(this.normalizeListOffset(options?.cursor));
|
||||
|
||||
return results.map(({ settings, doc }) => this.toAgentDocument(settings, doc));
|
||||
}
|
||||
|
||||
async findByDocumentId(
|
||||
agentId: string,
|
||||
documentId: string,
|
||||
options?: AgentDocumentQueryOptions,
|
||||
): Promise<AgentDocument | undefined> {
|
||||
const [result] = await this.db
|
||||
.select({ doc: documents, settings: agentDocuments })
|
||||
.from(agentDocuments)
|
||||
.innerJoin(documents, eq(agentDocuments.documentId, documents.id))
|
||||
.where(
|
||||
and(
|
||||
eq(agentDocuments.userId, this.userId),
|
||||
eq(agentDocuments.agentId, agentId),
|
||||
eq(agentDocuments.documentId, documentId),
|
||||
...this.buildDeletedAtFilters(options),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(agentDocuments.updatedAt))
|
||||
.limit(1);
|
||||
|
||||
if (!result) return undefined;
|
||||
|
||||
return this.toAgentDocument(result.settings, result.doc);
|
||||
}
|
||||
|
||||
async listByParent(
|
||||
agentId: string,
|
||||
parentId: string | null,
|
||||
options?: AgentDocumentQueryOptions,
|
||||
): Promise<AgentDocument[]> {
|
||||
const results = await this.db
|
||||
.select({ doc: documents, settings: agentDocuments })
|
||||
.from(agentDocuments)
|
||||
.innerJoin(documents, eq(agentDocuments.documentId, documents.id))
|
||||
.where(
|
||||
and(
|
||||
eq(agentDocuments.userId, this.userId),
|
||||
eq(agentDocuments.agentId, agentId),
|
||||
parentId ? eq(documents.parentId, parentId) : isNull(documents.parentId),
|
||||
...this.buildDeletedAtFilters(options),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(agentDocuments.updatedAt))
|
||||
.limit(options?.limit ?? 9999)
|
||||
.offset(this.normalizeListOffset(options?.cursor));
|
||||
|
||||
return results.map(({ settings, doc }) => this.toAgentDocument(settings, doc));
|
||||
}
|
||||
|
||||
async listDeletedByAgent(agentId: string): Promise<AgentDocument[]> {
|
||||
const results = await this.db
|
||||
.select({ doc: documents, settings: agentDocuments })
|
||||
.from(agentDocuments)
|
||||
.innerJoin(documents, eq(agentDocuments.documentId, documents.id))
|
||||
.where(
|
||||
and(
|
||||
eq(agentDocuments.userId, this.userId),
|
||||
eq(agentDocuments.agentId, agentId),
|
||||
eq(documents.userId, this.userId),
|
||||
isNotNull(agentDocuments.deletedAt),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(agentDocuments.deletedAt), desc(agentDocuments.updatedAt));
|
||||
|
||||
return results.map(({ settings, doc }) => this.toAgentDocument(settings, doc));
|
||||
}
|
||||
|
||||
async listSubtreeByDocumentId(
|
||||
agentId: string,
|
||||
rootDocumentId: string,
|
||||
options?: AgentDocumentQueryOptions,
|
||||
): Promise<AgentDocument[]> {
|
||||
const root = await this.findByDocumentId(agentId, rootDocumentId, options);
|
||||
|
||||
if (!root) return [];
|
||||
|
||||
const subtree = [root];
|
||||
const pendingParentIds = [root.documentId];
|
||||
|
||||
while (pendingParentIds.length > 0) {
|
||||
const parentIds = pendingParentIds.splice(0, pendingParentIds.length);
|
||||
const children = await this.listByParentIds(agentId, parentIds, options);
|
||||
|
||||
for (const child of children) {
|
||||
subtree.push(child);
|
||||
pendingParentIds.push(child.documentId);
|
||||
}
|
||||
}
|
||||
|
||||
return subtree;
|
||||
}
|
||||
|
||||
async delete(documentId: string, deleteReason?: string): Promise<void> {
|
||||
// Soft delete only: mark deleted metadata and stop autoload.
|
||||
// We intentionally keep both agent_documents row and linked documents row for recovery.
|
||||
@@ -1041,155 +583,6 @@ export class AgentDocumentModel {
|
||||
);
|
||||
}
|
||||
|
||||
async deleteSubtreeByDocumentId(
|
||||
agentId: string,
|
||||
rootDocumentId: string,
|
||||
deleteReason?: string,
|
||||
): Promise<void> {
|
||||
const subtree = await this.listSubtreeByDocumentId(agentId, rootDocumentId);
|
||||
|
||||
if (subtree.length === 0) return;
|
||||
|
||||
await this.db
|
||||
.update(agentDocuments)
|
||||
.set({
|
||||
policyLoad: PolicyLoad.DISABLED,
|
||||
deleteReason,
|
||||
deletedAt: new Date(),
|
||||
deletedByAgentId: null,
|
||||
deletedByUserId: this.userId,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(agentDocuments.userId, this.userId),
|
||||
inArray(
|
||||
agentDocuments.id,
|
||||
subtree.map((item) => item.id),
|
||||
),
|
||||
isNull(agentDocuments.deletedAt),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores a soft-deleted agent document binding to the live tree.
|
||||
*
|
||||
* Use when:
|
||||
* - Moving a deleted document back into active VFS visibility.
|
||||
* - Preserving the existing document row and agent document id.
|
||||
*
|
||||
* Expects:
|
||||
* - `documentId` may refer to a deleted binding owned by the current user.
|
||||
* - `uniqueSibling` defaults to `true` for ordinary VFS documents.
|
||||
*
|
||||
* Returns:
|
||||
* - Nothing; missing bindings are ignored.
|
||||
*
|
||||
* NOTICE:
|
||||
* Pass `uniqueSibling: false` only for managed mount documents whose provider owns restore
|
||||
* collision handling outside the ordinary document VFS tree.
|
||||
*/
|
||||
async restore(
|
||||
documentId: string,
|
||||
options: AgentDocumentSiblingUniquenessOptions = {},
|
||||
): Promise<void> {
|
||||
const existing = await this.findByIdWithOptions(documentId, { includeDeleted: true });
|
||||
|
||||
if (!existing) return;
|
||||
const { uniqueSibling = true } = options;
|
||||
|
||||
await this.db.transaction(async (trx) => {
|
||||
if (uniqueSibling) {
|
||||
await this.assertNoLiveSiblingConflict(trx, {
|
||||
agentId: existing.agentId,
|
||||
excludeAgentDocumentId: existing.id,
|
||||
filename: existing.filename,
|
||||
parentId: existing.parentId,
|
||||
});
|
||||
}
|
||||
|
||||
await trx
|
||||
.update(agentDocuments)
|
||||
.set({
|
||||
deleteReason: null,
|
||||
deletedAt: null,
|
||||
deletedByAgentId: null,
|
||||
deletedByUserId: null,
|
||||
policyLoad: PolicyLoad.PROGRESSIVE,
|
||||
})
|
||||
.where(and(eq(agentDocuments.id, documentId), eq(agentDocuments.userId, this.userId)));
|
||||
});
|
||||
}
|
||||
|
||||
async restoreSubtreeByDocumentId(agentId: string, rootDocumentId: string): Promise<void> {
|
||||
const subtree = await this.listSubtreeByDocumentId(agentId, rootDocumentId, {
|
||||
includeDeleted: true,
|
||||
});
|
||||
|
||||
if (subtree.length === 0) return;
|
||||
|
||||
await this.db
|
||||
.update(agentDocuments)
|
||||
.set({
|
||||
deleteReason: null,
|
||||
deletedAt: null,
|
||||
deletedByAgentId: null,
|
||||
deletedByUserId: null,
|
||||
policyLoad: PolicyLoad.PROGRESSIVE,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(agentDocuments.userId, this.userId),
|
||||
inArray(
|
||||
agentDocuments.id,
|
||||
subtree.map((item) => item.id),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async permanentlyDelete(documentId: string): Promise<void> {
|
||||
const existing = await this.findByIdWithOptions(documentId, { includeDeleted: true });
|
||||
|
||||
if (!existing) return;
|
||||
|
||||
await this.db.transaction(async (trx) => {
|
||||
await trx
|
||||
.delete(agentDocuments)
|
||||
.where(and(eq(agentDocuments.id, documentId), eq(agentDocuments.userId, this.userId)));
|
||||
|
||||
await trx
|
||||
.delete(documents)
|
||||
.where(and(eq(documents.id, existing.documentId), eq(documents.userId, this.userId)));
|
||||
});
|
||||
}
|
||||
|
||||
async permanentlyDeleteSubtreeByDocumentId(
|
||||
agentId: string,
|
||||
rootDocumentId: string,
|
||||
): Promise<void> {
|
||||
const subtree = await this.listSubtreeByDocumentId(agentId, rootDocumentId, {
|
||||
includeDeleted: true,
|
||||
});
|
||||
|
||||
if (subtree.length === 0) return;
|
||||
|
||||
const agentDocumentIds = subtree.map((item) => item.id);
|
||||
const documentIds = subtree.map((item) => item.documentId);
|
||||
|
||||
await this.db.transaction(async (trx) => {
|
||||
await trx
|
||||
.delete(agentDocuments)
|
||||
.where(
|
||||
and(eq(agentDocuments.userId, this.userId), inArray(agentDocuments.id, agentDocumentIds)),
|
||||
);
|
||||
|
||||
await trx
|
||||
.delete(documents)
|
||||
.where(and(eq(documents.userId, this.userId), inArray(documents.id, documentIds)));
|
||||
});
|
||||
}
|
||||
|
||||
async deleteByAgent(agentId: string, deleteReason?: string): Promise<void> {
|
||||
await this.db
|
||||
.update(agentDocuments)
|
||||
|
||||
@@ -39,10 +39,8 @@ export interface AgentDocument {
|
||||
documentId: string;
|
||||
editorData: Record<string, any> | null;
|
||||
filename: string;
|
||||
fileType: string;
|
||||
id: string;
|
||||
metadata: Record<string, any> | null;
|
||||
parentId: string | null;
|
||||
policy: AgentDocumentPolicy | null;
|
||||
policyLoad: PolicyLoad;
|
||||
policyLoadFormat: DocumentLoadFormat;
|
||||
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
WorkspaceDocNode,
|
||||
WorkspaceTreeNode,
|
||||
} from '@lobechat/types';
|
||||
import { and, desc, eq, inArray, isNotNull, isNull, ne, notInArray, sql } from 'drizzle-orm';
|
||||
import { and, desc, eq, inArray, isNotNull, isNull, ne, sql } from 'drizzle-orm';
|
||||
|
||||
import { merge } from '@/utils/merge';
|
||||
|
||||
@@ -478,22 +478,6 @@ export class TaskModel {
|
||||
.where(eq(tasks.id, id));
|
||||
}
|
||||
|
||||
// Tasks eligible for cron-based dispatch.
|
||||
// Excludes terminal/paused/running — `paused` requires user attention,
|
||||
// `running` is already in flight (and `runTask` would CONFLICT anyway).
|
||||
static async getScheduledTasks(db: LobeChatDatabase): Promise<TaskItem[]> {
|
||||
return db
|
||||
.select()
|
||||
.from(tasks)
|
||||
.where(
|
||||
and(
|
||||
eq(tasks.automationMode, 'schedule'),
|
||||
isNotNull(tasks.schedulePattern),
|
||||
notInArray(tasks.status, ['canceled', 'completed', 'failed', 'paused', 'running']),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Find stuck tasks (running but heartbeat timed out)
|
||||
// Only checks tasks that have both lastHeartbeatAt and heartbeatTimeout set
|
||||
static async findStuckTasks(db: LobeChatDatabase): Promise<TaskItem[]> {
|
||||
@@ -651,7 +635,6 @@ export class TaskModel {
|
||||
fileType: row.document_file_type,
|
||||
parentId: row.document_parent_id,
|
||||
pinnedBy: row.pinned_by,
|
||||
sourceTaskId: row.source_task_id,
|
||||
sourceTaskIdentifier: row.source_task_id !== rootTaskId ? row.source_task_identifier : null,
|
||||
title: row.document_title || 'Untitled',
|
||||
updatedAt: row.document_updated_at,
|
||||
|
||||
@@ -3,11 +3,8 @@ import { and, desc, eq, sql } from 'drizzle-orm';
|
||||
|
||||
import type { TaskTopicItem } from '../schemas/task';
|
||||
import { tasks, taskTopics } from '../schemas/task';
|
||||
import { topics } from '../schemas/topic';
|
||||
import type { LobeChatDatabase } from '../type';
|
||||
|
||||
const TERMINAL_TOPIC_STATUSES = new Set(['canceled', 'completed', 'failed', 'timeout']);
|
||||
|
||||
export class TaskTopicModel {
|
||||
private readonly userId: string;
|
||||
private readonly db: LobeChatDatabase;
|
||||
@@ -17,21 +14,6 @@ export class TaskTopicModel {
|
||||
this.userId = userId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror a terminal taskTopic transition onto the underlying topic record:
|
||||
* stamp `topics.completedAt` so duration can be computed at read time, and
|
||||
* promote `topics.status` to 'completed' on a clean finish.
|
||||
*/
|
||||
private async markTopicEnded(topicId: string, status: string): Promise<void> {
|
||||
const setClause: { completedAt: Date; status?: 'completed' } = { completedAt: new Date() };
|
||||
if (status === 'completed') setClause.status = 'completed';
|
||||
|
||||
await this.db
|
||||
.update(topics)
|
||||
.set(setClause)
|
||||
.where(and(eq(topics.id, topicId), eq(topics.userId, this.userId)));
|
||||
}
|
||||
|
||||
async add(
|
||||
taskId: string,
|
||||
topicId: string,
|
||||
@@ -60,10 +42,6 @@ export class TaskTopicModel {
|
||||
eq(taskTopics.userId, this.userId),
|
||||
),
|
||||
);
|
||||
|
||||
if (TERMINAL_TOPIC_STATUSES.has(status)) {
|
||||
await this.markTopicEnded(topicId, status);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -83,10 +61,7 @@ export class TaskTopicModel {
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
|
||||
const updated = result.length > 0;
|
||||
if (updated) await this.markTopicEnded(topicId, 'canceled');
|
||||
return updated;
|
||||
return result.length > 0;
|
||||
}
|
||||
|
||||
async updateOperationId(taskId: string, topicId: string, operationId?: string): Promise<void> {
|
||||
@@ -154,15 +129,7 @@ export class TaskTopicModel {
|
||||
eq(taskTopics.userId, this.userId),
|
||||
),
|
||||
)
|
||||
.returning({ topicId: taskTopics.topicId });
|
||||
|
||||
await Promise.all(
|
||||
result
|
||||
.map((r) => r.topicId)
|
||||
.filter((id): id is string => !!id)
|
||||
.map((id) => this.markTopicEnded(id, 'timeout')),
|
||||
);
|
||||
|
||||
.returning();
|
||||
return result.length;
|
||||
}
|
||||
|
||||
@@ -184,6 +151,7 @@ export class TaskTopicModel {
|
||||
}
|
||||
|
||||
async findWithDetails(taskId: string) {
|
||||
const { topics } = await import('../schemas/topic');
|
||||
return this.db
|
||||
.select({
|
||||
createdAt: topics.createdAt,
|
||||
@@ -208,9 +176,9 @@ export class TaskTopicModel {
|
||||
}
|
||||
|
||||
async findWithHandoff(taskId: string, limit: number) {
|
||||
const { topics } = await import('../schemas/topic');
|
||||
return this.db
|
||||
.select({
|
||||
completedAt: topics.completedAt,
|
||||
createdAt: taskTopics.createdAt,
|
||||
handoff: taskTopics.handoff,
|
||||
metadata: topics.metadata,
|
||||
|
||||
@@ -98,7 +98,6 @@ export class UserModel {
|
||||
settingsLanguageModel: userSettings.languageModel,
|
||||
settingsMarket: userSettings.market,
|
||||
settingsMemory: userSettings.memory,
|
||||
settingsNotification: userSettings.notification,
|
||||
settingsSystemAgent: userSettings.systemAgent,
|
||||
settingsTTS: userSettings.tts,
|
||||
settingsTool: userSettings.tool,
|
||||
@@ -133,7 +132,6 @@ export class UserModel {
|
||||
languageModel: state.settingsLanguageModel || {},
|
||||
market: state.settingsMarket || undefined,
|
||||
memory: state.settingsMemory || {},
|
||||
notification: state.settingsNotification || {},
|
||||
systemAgent: state.settingsSystemAgent || {},
|
||||
tool: state.settingsTool || {},
|
||||
tts: state.settingsTTS || {},
|
||||
|
||||
@@ -83,48 +83,6 @@ const coerceDate = (input: unknown): Date | null => {
|
||||
return null;
|
||||
};
|
||||
|
||||
const escapeLikePattern = (value: string) =>
|
||||
value.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_');
|
||||
|
||||
const buildContainsCondition = (column: unknown, q?: string) => {
|
||||
const normalized = q?.trim();
|
||||
if (!normalized) return undefined;
|
||||
|
||||
return sql<boolean>`${column} ILIKE ${`%${escapeLikePattern(normalized)}%`} ESCAPE '\\'`;
|
||||
};
|
||||
|
||||
const isPGliteDatabase = (db: LobeChatDatabase) => {
|
||||
const client = (
|
||||
db as unknown as {
|
||||
$client?: {
|
||||
dataDir?: unknown;
|
||||
waitReady?: unknown;
|
||||
};
|
||||
}
|
||||
).$client;
|
||||
|
||||
return 'waitReady' in (client ?? {}) && 'dataDir' in (client ?? {});
|
||||
};
|
||||
|
||||
const buildTextSearchCondition = (params: {
|
||||
bm25Query: string;
|
||||
columns: unknown[];
|
||||
normalizedQuery: string;
|
||||
supportsBm25: boolean;
|
||||
}) => {
|
||||
const { bm25Query, columns, normalizedQuery, supportsBm25 } = params;
|
||||
|
||||
if (!normalizedQuery) return undefined;
|
||||
|
||||
const conditions = supportsBm25
|
||||
? columns.map((column) => sql<boolean>`${column} @@@ ${bm25Query}`)
|
||||
: columns
|
||||
.map((column) => buildContainsCondition(column, normalizedQuery))
|
||||
.filter((condition): condition is SQL<boolean> => Boolean(condition));
|
||||
|
||||
return conditions.length > 0 ? or(...conditions) : undefined;
|
||||
};
|
||||
|
||||
const parseAssociationExtra = (
|
||||
value: string | null | undefined,
|
||||
): Record<string, unknown> | null => {
|
||||
@@ -945,24 +903,6 @@ export class UserMemoryModel {
|
||||
const bm25Query = normalizedQuery
|
||||
? sanitizeBm25Query(normalizedQuery, SAFE_BM25_QUERY_OPTIONS)
|
||||
: '';
|
||||
// NOTICE:
|
||||
// Why this workaround is needed.
|
||||
// PGlite-based tests do not provide ParadeDB `pg_search`, so BM25 `@@@`
|
||||
// operators fail during local/test execution.
|
||||
//
|
||||
// Root cause summary.
|
||||
// `getTestDB.ts` intentionally skips migrations containing `pg_search` or
|
||||
// `bm25` for PGlite compatibility, which means lexical search paths in this
|
||||
// model cannot rely on BM25 there.
|
||||
//
|
||||
// Source/context.
|
||||
// See `packages/database/src/core/getTestDB.ts` where those migration
|
||||
// statements are filtered out before executing test migrations.
|
||||
//
|
||||
// Removal condition.
|
||||
// Remove this fallback once the test database can execute the same BM25
|
||||
// operators/indexes as the production PostgreSQL environment.
|
||||
const supportsBm25 = !isPGliteDatabase(this.db);
|
||||
|
||||
const conditions: Array<SQL | undefined> = [
|
||||
eq(userMemories.userId, this.userId),
|
||||
@@ -1029,19 +969,9 @@ export class UserMemoryModel {
|
||||
|
||||
const contextFilters: Array<SQL | undefined> = [
|
||||
whereClause,
|
||||
buildTextSearchCondition({
|
||||
bm25Query,
|
||||
columns: [
|
||||
userMemories.title,
|
||||
userMemories.summary,
|
||||
userMemories.details,
|
||||
userMemoriesContexts.title,
|
||||
userMemoriesContexts.description,
|
||||
userMemoriesContexts.currentStatus,
|
||||
],
|
||||
normalizedQuery,
|
||||
supportsBm25,
|
||||
}),
|
||||
normalizedQuery
|
||||
? sql`(${userMemories.title} @@@ ${bm25Query} OR ${userMemories.summary} @@@ ${bm25Query} OR ${userMemories.details} @@@ ${bm25Query} OR ${userMemoriesContexts.title} @@@ ${bm25Query} OR ${userMemoriesContexts.description} @@@ ${bm25Query} OR ${userMemoriesContexts.currentStatus} @@@ ${bm25Query})`
|
||||
: undefined,
|
||||
types && types.length > 0 ? inArray(userMemoriesContexts.type, types) : undefined,
|
||||
tags && tags.length > 0
|
||||
? or(
|
||||
@@ -1128,19 +1058,9 @@ export class UserMemoryModel {
|
||||
|
||||
const activityFilters: Array<SQL | undefined> = [
|
||||
whereClause,
|
||||
buildTextSearchCondition({
|
||||
bm25Query,
|
||||
columns: [
|
||||
userMemories.title,
|
||||
userMemories.summary,
|
||||
userMemories.details,
|
||||
userMemoriesActivities.narrative,
|
||||
userMemoriesActivities.notes,
|
||||
userMemoriesActivities.feedback,
|
||||
],
|
||||
normalizedQuery,
|
||||
supportsBm25,
|
||||
}),
|
||||
normalizedQuery
|
||||
? sql`(${userMemories.title} @@@ ${bm25Query} OR ${userMemories.summary} @@@ ${bm25Query} OR ${userMemories.details} @@@ ${bm25Query} OR ${userMemoriesActivities.narrative} @@@ ${bm25Query} OR ${userMemoriesActivities.notes} @@@ ${bm25Query} OR ${userMemoriesActivities.feedback} @@@ ${bm25Query})`
|
||||
: undefined,
|
||||
types && types.length > 0 ? inArray(userMemoriesActivities.type, types) : undefined,
|
||||
status && status.length > 0 ? inArray(userMemoriesActivities.status, status) : undefined,
|
||||
tags && tags.length > 0
|
||||
@@ -1235,19 +1155,9 @@ export class UserMemoryModel {
|
||||
|
||||
const experienceFilters: Array<SQL | undefined> = [
|
||||
whereClause,
|
||||
buildTextSearchCondition({
|
||||
bm25Query,
|
||||
columns: [
|
||||
userMemories.title,
|
||||
userMemories.summary,
|
||||
userMemories.details,
|
||||
userMemoriesExperiences.situation,
|
||||
userMemoriesExperiences.keyLearning,
|
||||
userMemoriesExperiences.action,
|
||||
],
|
||||
normalizedQuery,
|
||||
supportsBm25,
|
||||
}),
|
||||
normalizedQuery
|
||||
? sql`(${userMemories.title} @@@ ${bm25Query} OR ${userMemories.summary} @@@ ${bm25Query} OR ${userMemories.details} @@@ ${bm25Query} OR ${userMemoriesExperiences.situation} @@@ ${bm25Query} OR ${userMemoriesExperiences.keyLearning} @@@ ${bm25Query} OR ${userMemoriesExperiences.action} @@@ ${bm25Query})`
|
||||
: undefined,
|
||||
types && types.length > 0 ? inArray(userMemoriesExperiences.type, types) : undefined,
|
||||
tags && tags.length > 0
|
||||
? or(...tags.map((tag) => sql<boolean>`${tag} = ANY(${userMemoriesExperiences.tags})`))
|
||||
@@ -1322,18 +1232,9 @@ export class UserMemoryModel {
|
||||
|
||||
const identityFilters: Array<SQL | undefined> = [
|
||||
whereClause,
|
||||
buildTextSearchCondition({
|
||||
bm25Query,
|
||||
columns: [
|
||||
userMemories.title,
|
||||
userMemories.summary,
|
||||
userMemories.details,
|
||||
userMemoriesIdentities.description,
|
||||
userMemoriesIdentities.role,
|
||||
],
|
||||
normalizedQuery,
|
||||
supportsBm25,
|
||||
}),
|
||||
normalizedQuery
|
||||
? sql`(${userMemories.title} @@@ ${bm25Query} OR ${userMemories.summary} @@@ ${bm25Query} OR ${userMemories.details} @@@ ${bm25Query} OR ${userMemoriesIdentities.description} @@@ ${bm25Query} OR ${userMemoriesIdentities.role} @@@ ${bm25Query})`
|
||||
: undefined,
|
||||
types && types.length > 0 ? inArray(userMemoriesIdentities.type, types) : undefined,
|
||||
tags && tags.length > 0
|
||||
? or(...tags.map((tag) => sql<boolean>`${tag} = ANY(${userMemoriesIdentities.tags})`))
|
||||
@@ -1415,18 +1316,9 @@ export class UserMemoryModel {
|
||||
|
||||
const preferenceFilters: Array<SQL | undefined> = [
|
||||
whereClause,
|
||||
buildTextSearchCondition({
|
||||
bm25Query,
|
||||
columns: [
|
||||
userMemories.title,
|
||||
userMemories.summary,
|
||||
userMemories.details,
|
||||
userMemoriesPreferences.conclusionDirectives,
|
||||
userMemoriesPreferences.suggestions,
|
||||
],
|
||||
normalizedQuery,
|
||||
supportsBm25,
|
||||
}),
|
||||
normalizedQuery
|
||||
? sql`(${userMemories.title} @@@ ${bm25Query} OR ${userMemories.summary} @@@ ${bm25Query} OR ${userMemories.details} @@@ ${bm25Query} OR ${userMemoriesPreferences.conclusionDirectives} @@@ ${bm25Query} OR ${userMemoriesPreferences.suggestions} @@@ ${bm25Query})`
|
||||
: undefined,
|
||||
types && types.length > 0 ? inArray(userMemoriesPreferences.type, types) : undefined,
|
||||
tags && tags.length > 0
|
||||
? or(...tags.map((tag) => sql<boolean>`${tag} = ANY(${userMemoriesPreferences.tags})`))
|
||||
@@ -2641,7 +2533,7 @@ export class UserMemoryModel {
|
||||
.select(selectNonVectorColumns(userMemoriesIdentities))
|
||||
.from(userMemoriesIdentities)
|
||||
.where(eq(userMemoriesIdentities.userId, this.userId))
|
||||
.orderBy(desc(userMemoriesIdentities.capturedAt), desc(userMemoriesIdentities.createdAt));
|
||||
.orderBy(desc(userMemoriesIdentities.capturedAt));
|
||||
|
||||
return res;
|
||||
};
|
||||
@@ -2655,7 +2547,7 @@ export class UserMemoryModel {
|
||||
.from(userMemoriesIdentities)
|
||||
.innerJoin(userMemories, eq(userMemories.id, userMemoriesIdentities.userMemoryId))
|
||||
.where(eq(userMemoriesIdentities.userId, this.userId))
|
||||
.orderBy(desc(userMemoriesIdentities.capturedAt), desc(userMemoriesIdentities.createdAt));
|
||||
.orderBy(desc(userMemoriesIdentities.capturedAt));
|
||||
|
||||
return res;
|
||||
};
|
||||
@@ -2667,7 +2559,7 @@ export class UserMemoryModel {
|
||||
.where(
|
||||
and(eq(userMemoriesIdentities.userId, this.userId), eq(userMemoriesIdentities.type, type)),
|
||||
)
|
||||
.orderBy(desc(userMemoriesIdentities.capturedAt), desc(userMemoriesIdentities.createdAt));
|
||||
.orderBy(desc(userMemoriesIdentities.capturedAt));
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
@@ -82,65 +82,3 @@ export const actionDurationHistogram = meter.createHistogram('agent_signal_actio
|
||||
description: 'Observed duration for one AgentSignal action attempt.',
|
||||
unit: 'ms',
|
||||
});
|
||||
|
||||
/**
|
||||
* Count of source-event generation attempts grouped by source type and outcome.
|
||||
*/
|
||||
export const sourceEventCounter = meter.createCounter('agent_signal_source_events_total', {
|
||||
description: 'Count of AgentSignal source-event generation attempts grouped by outcome.',
|
||||
unit: '{event}',
|
||||
});
|
||||
|
||||
/**
|
||||
* Duration histogram for one source-event generation attempt.
|
||||
*/
|
||||
export const sourceEventDurationHistogram = meter.createHistogram(
|
||||
'agent_signal_source_event_duration_ms',
|
||||
{
|
||||
description: 'Observed duration for one AgentSignal source-event generation attempt.',
|
||||
unit: 'ms',
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Count of workflow runs grouped by source type and outcome.
|
||||
*/
|
||||
export const workflowRunCounter = meter.createCounter('agent_signal_workflow_runs_total', {
|
||||
description: 'Count of AgentSignal workflow runs grouped by outcome.',
|
||||
unit: '{workflow-run}',
|
||||
});
|
||||
|
||||
/**
|
||||
* Duration histogram for one workflow run.
|
||||
*/
|
||||
export const workflowRunDurationHistogram = meter.createHistogram(
|
||||
'agent_signal_workflow_duration_ms',
|
||||
{
|
||||
description: 'Observed duration for one AgentSignal workflow run.',
|
||||
unit: 'ms',
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Count of scheduler handler invocations grouped by handler identity and outcome.
|
||||
*/
|
||||
export const handlerCounter = meter.createCounter('agent_signal_handler_runs_total', {
|
||||
description: 'Count of AgentSignal scheduler handler invocations grouped by outcome.',
|
||||
unit: '{handler-run}',
|
||||
});
|
||||
|
||||
/**
|
||||
* Duration histogram for one scheduler handler invocation.
|
||||
*/
|
||||
export const handlerDurationHistogram = meter.createHistogram('agent_signal_handler_duration_ms', {
|
||||
description: 'Observed duration for one AgentSignal scheduler handler invocation.',
|
||||
unit: 'ms',
|
||||
});
|
||||
|
||||
/**
|
||||
* Count of terminal runtime results such as wait, schedule, or conclude.
|
||||
*/
|
||||
export const terminalResultCounter = meter.createCounter('agent_signal_terminal_results_total', {
|
||||
description: 'Count of AgentSignal terminal runtime results grouped by status and reason.',
|
||||
unit: '{terminal-result}',
|
||||
});
|
||||
|
||||
@@ -36,7 +36,6 @@ export interface WorkspaceDocNode {
|
||||
fileType: string;
|
||||
parentId: string | null;
|
||||
pinnedBy: string;
|
||||
sourceTaskId: string;
|
||||
sourceTaskIdentifier: string | null;
|
||||
title: string;
|
||||
updatedAt: string | null;
|
||||
@@ -187,7 +186,6 @@ export interface TaskDetailWorkspaceNode {
|
||||
documentId: string;
|
||||
fileType?: string;
|
||||
size?: number | null;
|
||||
sourceTaskId?: string;
|
||||
sourceTaskIdentifier?: string | null;
|
||||
title?: string;
|
||||
}
|
||||
@@ -213,12 +211,6 @@ export interface TaskDetailActivity {
|
||||
artifacts?: unknown;
|
||||
author?: TaskDetailActivityAuthor;
|
||||
briefType?: string;
|
||||
/**
|
||||
* Topic-only: ISO timestamp when the topic run terminated (any of
|
||||
* completed / failed / canceled / timeout). Pair with `time` (start) to
|
||||
* compute elapsed duration.
|
||||
*/
|
||||
completedAt?: string;
|
||||
content?: string;
|
||||
createdAt?: string;
|
||||
cronJobId?: string | null;
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
import dayjs from 'dayjs';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildCronPattern, formatScheduleTime, parseCronPattern, WEEKDAY_I18N_KEYS } from './cron';
|
||||
|
||||
describe('parseCronPattern', () => {
|
||||
describe('daily', () => {
|
||||
it('parses every-day-at-9am', () => {
|
||||
expect(parseCronPattern('0 9 * * *')).toEqual({
|
||||
scheduleType: 'daily',
|
||||
triggerHour: 9,
|
||||
triggerMinute: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses 7:30am (minute=30 stays 30 after normalization)', () => {
|
||||
expect(parseCronPattern('30 7 * * *')).toEqual({
|
||||
scheduleType: 'daily',
|
||||
triggerHour: 7,
|
||||
triggerMinute: 30,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses evening hour', () => {
|
||||
expect(parseCronPattern('0 19 * * *')).toEqual({
|
||||
scheduleType: 'daily',
|
||||
triggerHour: 19,
|
||||
triggerMinute: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('weekly', () => {
|
||||
it('parses single weekday (Monday)', () => {
|
||||
expect(parseCronPattern('0 9 * * 1')).toEqual({
|
||||
scheduleType: 'weekly',
|
||||
triggerHour: 9,
|
||||
triggerMinute: 0,
|
||||
weekdays: [1],
|
||||
});
|
||||
});
|
||||
|
||||
it('parses Friday afternoon', () => {
|
||||
expect(parseCronPattern('0 15 * * 5')).toEqual({
|
||||
scheduleType: 'weekly',
|
||||
triggerHour: 15,
|
||||
triggerMinute: 0,
|
||||
weekdays: [5],
|
||||
});
|
||||
});
|
||||
|
||||
it('parses comma-separated weekdays', () => {
|
||||
expect(parseCronPattern('0 9 * * 1,3,5')).toEqual({
|
||||
scheduleType: 'weekly',
|
||||
triggerHour: 9,
|
||||
triggerMinute: 0,
|
||||
weekdays: [1, 3, 5],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('hourly', () => {
|
||||
it('parses every-hour (*)', () => {
|
||||
expect(parseCronPattern('0 * * * *')).toEqual({
|
||||
hourlyInterval: 1,
|
||||
scheduleType: 'hourly',
|
||||
triggerHour: 0,
|
||||
triggerMinute: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses every-N-hours (*/N)', () => {
|
||||
expect(parseCronPattern('0 */6 * * *')).toEqual({
|
||||
hourlyInterval: 6,
|
||||
scheduleType: 'hourly',
|
||||
triggerHour: 0,
|
||||
triggerMinute: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('minute normalization', () => {
|
||||
it.each([
|
||||
[0, 0],
|
||||
[14, 0],
|
||||
[15, 30],
|
||||
[29, 30],
|
||||
[30, 30],
|
||||
[44, 30],
|
||||
[45, 0],
|
||||
[59, 0],
|
||||
])('normalizes minute %i to %i', (input, expected) => {
|
||||
const parsed = parseCronPattern(`${input} 9 * * *`);
|
||||
expect(parsed.triggerMinute).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fallback', () => {
|
||||
it.each([
|
||||
['empty', ''],
|
||||
['too few fields', '0 9 * *'],
|
||||
['too many fields', '0 9 * * * *'],
|
||||
])('falls back to daily 0:00 for %s', (_label, cron) => {
|
||||
expect(parseCronPattern(cron)).toEqual({
|
||||
scheduleType: 'daily',
|
||||
triggerHour: 0,
|
||||
triggerMinute: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildCronPattern', () => {
|
||||
const at = (h: number, m: number) => dayjs().hour(h).minute(m);
|
||||
|
||||
it('builds daily pattern', () => {
|
||||
expect(buildCronPattern('daily', at(9, 0))).toBe('0 9 * * *');
|
||||
expect(buildCronPattern('daily', at(7, 30))).toBe('30 7 * * *');
|
||||
});
|
||||
|
||||
it('builds weekly pattern with weekdays', () => {
|
||||
expect(buildCronPattern('weekly', at(9, 0), undefined, [1])).toBe('0 9 * * 1');
|
||||
expect(buildCronPattern('weekly', at(10, 0), undefined, [5, 1, 3])).toBe('0 10 * * 1,3,5');
|
||||
});
|
||||
|
||||
it('builds weekly with all weekdays when none specified', () => {
|
||||
expect(buildCronPattern('weekly', at(9, 0))).toBe('0 9 * * 0,1,2,3,4,5,6');
|
||||
});
|
||||
|
||||
it('builds hourly pattern with interval 1 as star', () => {
|
||||
expect(buildCronPattern('hourly', at(0, 0), 1)).toBe('0 * * * *');
|
||||
});
|
||||
|
||||
it('builds hourly pattern with N-interval', () => {
|
||||
expect(buildCronPattern('hourly', at(0, 30), 6)).toBe('30 */6 * * *');
|
||||
});
|
||||
|
||||
it('normalizes raw minutes to 0 or 30', () => {
|
||||
expect(buildCronPattern('daily', at(9, 14))).toBe('0 9 * * *');
|
||||
expect(buildCronPattern('daily', at(9, 20))).toBe('30 9 * * *');
|
||||
expect(buildCronPattern('daily', at(9, 50))).toBe('0 9 * * *');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatScheduleTime', () => {
|
||||
it('zero-pads hours and minutes', () => {
|
||||
expect(formatScheduleTime(9, 0)).toBe('09:00');
|
||||
expect(formatScheduleTime(7, 30)).toBe('07:30');
|
||||
expect(formatScheduleTime(15, 5)).toBe('15:05');
|
||||
expect(formatScheduleTime(0, 0)).toBe('00:00');
|
||||
});
|
||||
});
|
||||
|
||||
describe('WEEKDAY_I18N_KEYS', () => {
|
||||
it('orders Sunday-first to match cron weekday numbering', () => {
|
||||
expect(WEEKDAY_I18N_KEYS[0]).toBe('sunday');
|
||||
expect(WEEKDAY_I18N_KEYS[1]).toBe('monday');
|
||||
expect(WEEKDAY_I18N_KEYS[6]).toBe('saturday');
|
||||
});
|
||||
});
|
||||
@@ -1,131 +0,0 @@
|
||||
import type { Dayjs } from 'dayjs';
|
||||
|
||||
export type ScheduleType = 'daily' | 'hourly' | 'weekly';
|
||||
|
||||
/** Schedule UI only exposes :00 / :30 — minutes are normalized to one of these. */
|
||||
const SCHEDULE_MINUTE_STEP = 30;
|
||||
/** Cron weekday list when no specific weekdays are selected (Sun..Sat). */
|
||||
const ALL_WEEKDAYS = '0,1,2,3,4,5,6';
|
||||
|
||||
const normalizeMinuteToHalfHour = (raw: number): 0 | 30 =>
|
||||
raw >= SCHEDULE_MINUTE_STEP / 2 && raw < SCHEDULE_MINUTE_STEP + SCHEDULE_MINUTE_STEP / 2 ? 30 : 0;
|
||||
|
||||
export interface ParsedSchedule {
|
||||
hourlyInterval?: number;
|
||||
scheduleType: ScheduleType;
|
||||
triggerHour: number;
|
||||
triggerMinute: number;
|
||||
weekdays?: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* i18n key suffixes for cron weekday numbers (0=Sunday, 1=Monday, ..., 6=Saturday).
|
||||
* Combine with `setting:agentCronJobs.weekday.${WEEKDAY_I18N_KEYS[n]}`.
|
||||
*/
|
||||
export const WEEKDAY_I18N_KEYS = [
|
||||
'sunday',
|
||||
'monday',
|
||||
'tuesday',
|
||||
'wednesday',
|
||||
'thursday',
|
||||
'friday',
|
||||
'saturday',
|
||||
] as const;
|
||||
|
||||
export type WeekdayI18nKey = (typeof WEEKDAY_I18N_KEYS)[number];
|
||||
|
||||
/** Format `HH:mm` from numeric hour/minute, zero-padded. */
|
||||
export const formatScheduleTime = (hour: number, minute: number): string =>
|
||||
`${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
|
||||
|
||||
/**
|
||||
* Parse cron pattern to extract schedule info.
|
||||
* Format: minute hour day month weekday
|
||||
*
|
||||
* Falls back to `{ scheduleType: 'daily', triggerHour: 0, triggerMinute: 0 }`
|
||||
* for malformed or unsupported patterns — matches the legacy behavior used by
|
||||
* the cron-edit form.
|
||||
*/
|
||||
export const parseCronPattern = (cronPattern: string): ParsedSchedule => {
|
||||
const parts = cronPattern.trim().split(/\s+/);
|
||||
if (parts.length !== 5) {
|
||||
return { scheduleType: 'daily', triggerHour: 0, triggerMinute: 0 };
|
||||
}
|
||||
|
||||
const [minute, hour, , , weekday] = parts;
|
||||
const rawMinute = minute === '*' ? 0 : Number.parseInt(minute, 10);
|
||||
const triggerMinute = normalizeMinuteToHalfHour(rawMinute);
|
||||
|
||||
// Hourly: 0 * * * * or 0 */N * * *
|
||||
if (hour.startsWith('*/')) {
|
||||
const interval = Number.parseInt(hour.slice(2), 10);
|
||||
return {
|
||||
hourlyInterval: interval,
|
||||
scheduleType: 'hourly',
|
||||
triggerHour: 0,
|
||||
triggerMinute,
|
||||
};
|
||||
}
|
||||
if (hour === '*') {
|
||||
return {
|
||||
hourlyInterval: 1,
|
||||
scheduleType: 'hourly',
|
||||
triggerHour: 0,
|
||||
triggerMinute,
|
||||
};
|
||||
}
|
||||
|
||||
const triggerHour = Number.parseInt(hour, 10);
|
||||
|
||||
// Weekly: has specific weekday(s)
|
||||
if (weekday !== '*') {
|
||||
const weekdays = weekday.split(',').map((d) => Number.parseInt(d, 10));
|
||||
return {
|
||||
scheduleType: 'weekly',
|
||||
triggerHour,
|
||||
triggerMinute,
|
||||
weekdays,
|
||||
};
|
||||
}
|
||||
|
||||
// Daily: specific hour, any weekday
|
||||
return {
|
||||
scheduleType: 'daily',
|
||||
triggerHour,
|
||||
triggerMinute,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Build cron pattern from schedule info.
|
||||
* Format: minute hour day month weekday
|
||||
*/
|
||||
export const buildCronPattern = (
|
||||
scheduleType: ScheduleType,
|
||||
triggerTime: Dayjs,
|
||||
hourlyInterval?: number,
|
||||
weekdays?: number[],
|
||||
): string => {
|
||||
const minute = normalizeMinuteToHalfHour(triggerTime.minute());
|
||||
const hour = triggerTime.hour();
|
||||
|
||||
switch (scheduleType) {
|
||||
case 'hourly': {
|
||||
const interval = hourlyInterval || 1;
|
||||
if (interval === 1) {
|
||||
return `${minute} * * * *`;
|
||||
}
|
||||
return `${minute} */${interval} * * *`;
|
||||
}
|
||||
case 'daily': {
|
||||
return `${minute} ${hour} * * *`;
|
||||
}
|
||||
case 'weekly': {
|
||||
const days =
|
||||
weekdays && weekdays.length > 0
|
||||
? [...weekdays].sort((a, b) => a - b).join(',')
|
||||
: ALL_WEEKDAYS;
|
||||
return `${minute} ${hour} * * ${days}`;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,163 +0,0 @@
|
||||
import dayjs from 'dayjs';
|
||||
import timezone from 'dayjs/plugin/timezone';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
export interface IsExecutionTimeInput {
|
||||
/** Cron pattern in standard 5-field form: `minute hour day month weekday`. */
|
||||
cronPattern: string;
|
||||
/** Defaults to `Date.now()` when omitted — exposed for tests. */
|
||||
currentTime?: Date;
|
||||
/** Last successful execution; used to dedup within the same window. */
|
||||
lastExecutedAt?: Date | null;
|
||||
/** IANA timezone (e.g. `Asia/Shanghai`); defaults to `UTC` when null/empty. */
|
||||
timezone: string | null;
|
||||
/** Tolerance window in minutes — central dispatchers run on a coarse cadence
|
||||
* (e.g. every 30 min), so an exact-minute match is too brittle. */
|
||||
toleranceMinutes?: number;
|
||||
}
|
||||
|
||||
const DAILY_PATTERN_HOUR_REGEX = /^\d+$/;
|
||||
|
||||
/**
|
||||
* Decide whether a cron pattern is "due now" within a tolerance window.
|
||||
*
|
||||
* Designed for a central dispatcher polling on a fixed cadence (e.g. QStash
|
||||
* Schedule firing every 30 minutes). The matcher:
|
||||
*
|
||||
* - Converts the dispatcher's UTC `now` to the pattern's local timezone.
|
||||
* - Dedups against `lastExecutedAt` so the same pattern doesn't fire twice
|
||||
* within its own interval (e.g. a daily 09:00 job won't refire at 09:15
|
||||
* on the same day in `Asia/Shanghai`).
|
||||
* - Catches up missed daily runs: if the dispatcher missed the scheduled hour
|
||||
* (downtime / cold start) and the job hasn't run today yet, a later tick
|
||||
* on the same day still fires it.
|
||||
*
|
||||
* Supported patterns (matches what `packages/utils/src/cron.ts` produces):
|
||||
*
|
||||
* - `*\/N * * * *` — every N minutes
|
||||
* - `M * * * *` — every hour at minute M
|
||||
* - `M *\/N * * *` — every N hours at minute M
|
||||
* - `M H * * *` — daily at H:M
|
||||
* - `M H * * D[,D]` — weekly on weekday list at H:M
|
||||
* - `M *,M * * *` — minute list (e.g. `0,15,30,45`)
|
||||
* - `M H,H * * *` — hour list
|
||||
*/
|
||||
export const isExecutionTime = (input: IsExecutionTimeInput): boolean => {
|
||||
const {
|
||||
cronPattern,
|
||||
timezone: tz,
|
||||
lastExecutedAt,
|
||||
currentTime = new Date(),
|
||||
toleranceMinutes = 5,
|
||||
} = input;
|
||||
|
||||
const jobTimezone = tz || 'UTC';
|
||||
const localTime = dayjs(currentTime).tz(jobTimezone);
|
||||
const minute = localTime.minute();
|
||||
const hour = localTime.hour();
|
||||
|
||||
const parts = cronPattern.trim().split(/\s+/);
|
||||
if (parts.length !== 5) return false;
|
||||
const [cronMinute, cronHour, , , cronWeekday] = parts;
|
||||
|
||||
// ── Dedup against last execution ────────────────────────────────
|
||||
if (lastExecutedAt) {
|
||||
const last = new Date(lastExecutedAt);
|
||||
const minutesSince = (currentTime.getTime() - last.getTime()) / (1000 * 60);
|
||||
|
||||
if (cronMinute.startsWith('*/')) {
|
||||
const minIntervalMin = Number.parseInt(cronMinute.slice(2), 10);
|
||||
if (Number.isFinite(minIntervalMin) && minutesSince < minIntervalMin) return false;
|
||||
} else if (cronHour.startsWith('*/')) {
|
||||
const hourInterval = Number.parseInt(cronHour.slice(2), 10);
|
||||
if (Number.isFinite(hourInterval) && minutesSince < hourInterval * 60) return false;
|
||||
} else if (cronHour === '*') {
|
||||
// Every hour at a specific minute (e.g. `30 * * * *`)
|
||||
if (minutesSince < 60) return false;
|
||||
} else if (DAILY_PATTERN_HOUR_REGEX.test(cronHour)) {
|
||||
// Daily at specific hour — dedup by calendar day in the job's timezone
|
||||
const lastLocal = dayjs(last).tz(jobTimezone);
|
||||
const nowLocal = dayjs(currentTime).tz(jobTimezone);
|
||||
if (
|
||||
lastLocal.year() === nowLocal.year() &&
|
||||
lastLocal.month() === nowLocal.month() &&
|
||||
lastLocal.date() === nowLocal.date()
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const weekday = localTime.day();
|
||||
|
||||
// ── Daily catch-up: scheduled hour passed today and we haven't run yet ──
|
||||
const isDailyPattern =
|
||||
DAILY_PATTERN_HOUR_REGEX.test(cronHour) && /^(?:\d+|\*\/\d+)$/.test(cronMinute);
|
||||
|
||||
if (isDailyPattern) {
|
||||
const targetHour = Number.parseInt(cronHour, 10);
|
||||
let shouldCatchUp: boolean;
|
||||
|
||||
if (lastExecutedAt) {
|
||||
const lastLocal = dayjs(lastExecutedAt).tz(jobTimezone);
|
||||
const nowLocal = dayjs(currentTime).tz(jobTimezone);
|
||||
const lastIsToday =
|
||||
lastLocal.year() === nowLocal.year() &&
|
||||
lastLocal.month() === nowLocal.month() &&
|
||||
lastLocal.date() === nowLocal.date();
|
||||
shouldCatchUp = !lastIsToday && hour > targetHour;
|
||||
} else {
|
||||
shouldCatchUp = hour > targetHour;
|
||||
}
|
||||
|
||||
if (shouldCatchUp) {
|
||||
if (cronWeekday !== '*') {
|
||||
const allowedWeekdays = cronWeekday.split(',').map((d) => Number.parseInt(d.trim(), 10));
|
||||
if (!allowedWeekdays.includes(weekday)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Minute field ────────────────────────────────────────────────
|
||||
if (cronMinute !== '*') {
|
||||
if (cronMinute.startsWith('*/')) {
|
||||
const interval = Number.parseInt(cronMinute.slice(2), 10);
|
||||
if (!Number.isFinite(interval) || interval <= 0) return false;
|
||||
const lastSlot = Math.floor(minute / interval) * interval;
|
||||
if (minute - lastSlot > toleranceMinutes) return false;
|
||||
} else if (cronMinute.includes(',')) {
|
||||
const allowed = cronMinute.split(',').map((m) => Number.parseInt(m, 10));
|
||||
if (!allowed.some((target) => Math.abs(minute - target) <= toleranceMinutes)) return false;
|
||||
} else {
|
||||
const target = Number.parseInt(cronMinute, 10);
|
||||
if (Math.abs(minute - target) > toleranceMinutes) return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Hour field ──────────────────────────────────────────────────
|
||||
if (cronHour !== '*') {
|
||||
if (cronHour.startsWith('*/')) {
|
||||
const interval = Number.parseInt(cronHour.slice(2), 10);
|
||||
if (!Number.isFinite(interval) || interval <= 0) return false;
|
||||
const lastSlot = Math.floor(hour / interval) * interval;
|
||||
if (hour - lastSlot > 0) return false;
|
||||
} else if (cronHour.includes(',')) {
|
||||
const allowed = cronHour.split(',').map((h) => Number.parseInt(h, 10));
|
||||
if (!allowed.includes(hour)) return false;
|
||||
} else {
|
||||
if (hour !== Number.parseInt(cronHour, 10)) return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Weekday field ───────────────────────────────────────────────
|
||||
if (cronWeekday !== '*') {
|
||||
const allowed = cronWeekday.split(',').map((d) => Number.parseInt(d.trim(), 10));
|
||||
if (!allowed.includes(weekday)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
@@ -1,8 +1,6 @@
|
||||
import { TimeoutError } from './errorType';
|
||||
|
||||
export const DEFAULT_TIMEOUT = process.env.CRAWLER_TIMEOUT
|
||||
? Number(process.env.CRAWLER_TIMEOUT)
|
||||
: 10_000;
|
||||
export const DEFAULT_TIMEOUT = 10_000;
|
||||
|
||||
/**
|
||||
* Wraps a factory function with a timeout and abort support.
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import { memo } from 'react';
|
||||
|
||||
const RecommendTaskTemplates = memo(() => null);
|
||||
|
||||
export default RecommendTaskTemplates;
|
||||
@@ -1,3 +0,0 @@
|
||||
import { router } from '@/libs/trpc/lambda';
|
||||
|
||||
export const taskTemplateRouter = router({});
|
||||
@@ -1,5 +0,0 @@
|
||||
/**
|
||||
* postMessage type the OAuth callback page (`/oauth/callback/success`)
|
||||
* sends back to the opener so the connect flow knows the popup is done.
|
||||
*/
|
||||
export const LOBEHUB_SKILL_AUTH_SUCCESS_MESSAGE = 'LOBEHUB_SKILL_AUTH_SUCCESS';
|
||||
@@ -111,11 +111,6 @@ const AgentProfilePopup = memo<AgentProfilePopupProps>(
|
||||
navigate(`/group/${groupId}/profile?tab=${agentId}`);
|
||||
};
|
||||
|
||||
const handleHeaderClick = () => {
|
||||
setOpen(false);
|
||||
navigate(`/agent/${agentId}/profile`);
|
||||
};
|
||||
|
||||
const hasDisplay = Boolean(merged.title || merged.avatar || merged.description);
|
||||
const showSkeleton = !hasDisplay && isLoading;
|
||||
|
||||
@@ -202,7 +197,6 @@ const AgentProfilePopup = memo<AgentProfilePopupProps>(
|
||||
</Flexbox>
|
||||
) : undefined
|
||||
}
|
||||
onHeaderClick={handleHeaderClick}
|
||||
>
|
||||
{modelSection}
|
||||
</AgentProfileCard>
|
||||
|
||||
@@ -15,16 +15,6 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
bannerInner: css`
|
||||
filter: blur(44px);
|
||||
`,
|
||||
clickableAvatar: css`
|
||||
cursor: pointer;
|
||||
`,
|
||||
clickableTitle: css`
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: ${cssVar.colorPrimary};
|
||||
}
|
||||
`,
|
||||
container: css`
|
||||
overflow: hidden;
|
||||
width: 280px;
|
||||
@@ -73,22 +63,11 @@ export interface AgentProfileCardProps {
|
||||
headerAction?: ReactNode;
|
||||
/** Show inline skeletons for fields that are still loading. */
|
||||
loading?: boolean;
|
||||
/** When set, avatar + title become clickable and trigger this handler. */
|
||||
onHeaderClick?: () => void;
|
||||
title: string;
|
||||
}
|
||||
|
||||
const AgentProfileCard = memo<AgentProfileCardProps>(
|
||||
({
|
||||
avatar,
|
||||
backgroundColor,
|
||||
description,
|
||||
headerAction,
|
||||
loading,
|
||||
onHeaderClick,
|
||||
title,
|
||||
children,
|
||||
}) => {
|
||||
({ avatar, backgroundColor, description, headerAction, loading, title, children }) => {
|
||||
return (
|
||||
<Flexbox className={styles.container}>
|
||||
<Center className={styles.banner} style={{ background: cssVar.colorFillTertiary }}>
|
||||
@@ -107,19 +86,13 @@ const AgentProfileCard = memo<AgentProfileCardProps>(
|
||||
emojiScaleWithBackground
|
||||
avatar={avatar || DEFAULT_AVATAR}
|
||||
background={backgroundColor ?? undefined}
|
||||
className={onHeaderClick ? styles.clickableAvatar : undefined}
|
||||
shape={'square'}
|
||||
size={48}
|
||||
style={{ border: `2px solid ${cssVar.colorBgElevated}` }}
|
||||
onClick={onHeaderClick}
|
||||
/>
|
||||
<Flexbox gap={2}>
|
||||
<Flexbox horizontal align={'center'} justify={'space-between'}>
|
||||
<Text
|
||||
ellipsis
|
||||
className={`${styles.name} ${onHeaderClick ? styles.clickableTitle : ''}`}
|
||||
onClick={onHeaderClick}
|
||||
>
|
||||
<Text ellipsis className={styles.name}>
|
||||
{title}
|
||||
</Text>
|
||||
{headerAction}
|
||||
|
||||
@@ -21,7 +21,7 @@ const Toolbar = memo(() => {
|
||||
const [activeTopicId, switchTopic] = useTaskChatStore((s) => [s.activeTopicId, s.switchTopic]);
|
||||
const topics = useTaskChatStore((s) => s.topics);
|
||||
|
||||
const toggleTaskAgentPanel = useGlobalStore((s) => s.toggleTaskAgentPanel);
|
||||
const toggleRightPanel = useGlobalStore((s) => s.toggleRightPanel);
|
||||
|
||||
const isLoadingTopics = topics === undefined;
|
||||
const topicTitle = topics?.find((topic) => topic.id === activeTopicId)?.title || t('title');
|
||||
@@ -108,7 +108,7 @@ const Toolbar = memo(() => {
|
||||
<ActionIcon
|
||||
icon={PanelRightCloseIcon}
|
||||
size={DESKTOP_HEADER_ICON_SIZE}
|
||||
onClick={() => toggleTaskAgentPanel()}
|
||||
onClick={() => toggleRightPanel()}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -1,25 +1,12 @@
|
||||
import { memo } from 'react';
|
||||
|
||||
import RightPanel from '@/features/RightPanel';
|
||||
import { useGlobalStore } from '@/store/global';
|
||||
import { systemStatusSelectors } from '@/store/global/selectors';
|
||||
|
||||
import Conversation from './Conversation';
|
||||
|
||||
const AgentTaskManager = memo(() => {
|
||||
const [expand, toggleTaskAgentPanel] = useGlobalStore((s) => [
|
||||
systemStatusSelectors.showTaskAgentPanel(s),
|
||||
s.toggleTaskAgentPanel,
|
||||
]);
|
||||
|
||||
return (
|
||||
<RightPanel
|
||||
defaultWidth={420}
|
||||
expand={expand}
|
||||
maxWidth={720}
|
||||
minWidth={320}
|
||||
onExpandChange={(next) => toggleTaskAgentPanel(next)}
|
||||
>
|
||||
<RightPanel defaultWidth={420} maxWidth={720} minWidth={320}>
|
||||
<Conversation />
|
||||
</RightPanel>
|
||||
);
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { ActionIcon, Avatar, Flexbox, Text } from '@lobehub/ui';
|
||||
import { cssVar } from 'antd-style';
|
||||
import { XIcon } from 'lucide-react';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { DESKTOP_HEADER_ICON_SIZE } from '@/const/layoutTokens';
|
||||
import { AutoSaveHint } from '@/features/EditorCanvas';
|
||||
import { usePageEditorStore } from '@/features/PageEditor/store';
|
||||
import ToggleRightPanelButton from '@/features/RightPanel/ToggleRightPanelButton';
|
||||
import { useGlobalStore } from '@/store/global';
|
||||
import { systemStatusSelectors } from '@/store/global/selectors';
|
||||
import { useTaskStore } from '@/store/task';
|
||||
|
||||
const HEADER_HEIGHT = 44;
|
||||
|
||||
const PageModalHeader = memo(() => {
|
||||
const { t } = useTranslation(['file', 'common']);
|
||||
|
||||
const [documentId, emoji, title] = usePageEditorStore((s) => [s.documentId, s.emoji, s.title]);
|
||||
|
||||
const [showPageAgentPanel, togglePageAgentPanel] = useGlobalStore((s) => [
|
||||
systemStatusSelectors.showPageAgentPanel(s),
|
||||
s.togglePageAgentPanel,
|
||||
]);
|
||||
|
||||
const closePageModal = useTaskStore((s) => s.closePageModal);
|
||||
|
||||
return (
|
||||
<Flexbox
|
||||
horizontal
|
||||
align={'center'}
|
||||
flex={'none'}
|
||||
gap={4}
|
||||
height={HEADER_HEIGHT}
|
||||
justify={'space-between'}
|
||||
padding={8}
|
||||
style={{ borderBlockEnd: `1px solid ${cssVar.colorBorderSecondary}` }}
|
||||
>
|
||||
<Flexbox allowShrink horizontal align={'center'} gap={6} style={{ minWidth: 0 }}>
|
||||
{emoji && <Avatar avatar={emoji} shape={'square'} size={24} />}
|
||||
<Text ellipsis style={{ minWidth: 0 }} weight={500}>
|
||||
{title || t('pageEditor.titlePlaceholder')}
|
||||
</Text>
|
||||
{documentId && <AutoSaveHint documentId={documentId} style={{ marginLeft: 4 }} />}
|
||||
</Flexbox>
|
||||
<Flexbox horizontal align={'center'} gap={4}>
|
||||
<ToggleRightPanelButton
|
||||
hideWhenExpanded
|
||||
expand={showPageAgentPanel}
|
||||
showActive={false}
|
||||
onToggle={() => togglePageAgentPanel()}
|
||||
/>
|
||||
<ActionIcon
|
||||
icon={XIcon}
|
||||
size={DESKTOP_HEADER_ICON_SIZE}
|
||||
title={t('close', { ns: 'common' })}
|
||||
onClick={closePageModal}
|
||||
/>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
|
||||
PageModalHeader.displayName = 'PageModalHeader';
|
||||
|
||||
export default PageModalHeader;
|
||||
@@ -1,41 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Modal } from '@lobehub/ui';
|
||||
import { memo } from 'react';
|
||||
|
||||
import PageExplorer from '@/features/PageExplorer';
|
||||
import { useTaskStore } from '@/store/task';
|
||||
import { taskDetailSelectors } from '@/store/task/selectors';
|
||||
|
||||
import PageModalHeader from './Header';
|
||||
|
||||
const PageModal = memo(() => {
|
||||
const pageId = useTaskStore(taskDetailSelectors.activePageModalId);
|
||||
const closePageModal = useTaskStore((s) => s.closePageModal);
|
||||
|
||||
const open = !!pageId;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
allowFullscreen
|
||||
centered
|
||||
destroyOnHidden
|
||||
closable={false}
|
||||
footer={null}
|
||||
open={open}
|
||||
title={null}
|
||||
width={'min(95vw, 1600px)'}
|
||||
styles={{
|
||||
body: { flex: 1, maxHeight: 'none', minHeight: 0, overflow: 'hidden', padding: 0 },
|
||||
container: { display: 'flex', flexDirection: 'column', height: '92vh' },
|
||||
}}
|
||||
onCancel={closePageModal}
|
||||
>
|
||||
{open && pageId && <PageExplorer header={<PageModalHeader />} pageId={pageId} />}
|
||||
</Modal>
|
||||
);
|
||||
});
|
||||
|
||||
PageModal.displayName = 'PageModal';
|
||||
|
||||
export default PageModal;
|
||||
@@ -168,7 +168,6 @@ const TaskActivities = memo(() => {
|
||||
brief={brief}
|
||||
key={key}
|
||||
onAfterAddComment={refreshActiveTask}
|
||||
onAfterDelete={refreshActiveTask}
|
||||
onAfterResolve={refreshActiveTask}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,90 +1,45 @@
|
||||
import type { TaskDetailWorkspaceNode } from '@lobechat/types';
|
||||
import {
|
||||
ActionIcon,
|
||||
Block,
|
||||
type DropdownItem,
|
||||
DropdownMenu,
|
||||
Flexbox,
|
||||
Icon,
|
||||
Tag,
|
||||
Text,
|
||||
} from '@lobehub/ui';
|
||||
import { App } from 'antd';
|
||||
import { Block, Flexbox, Icon, Tag, Text } from '@lobehub/ui';
|
||||
import { ConfigProvider, Tree } from 'antd';
|
||||
import type { DataNode } from 'antd/es/tree';
|
||||
import { cssVar } from 'antd-style';
|
||||
import { FileTextIcon, MoreHorizontal, Package, Trash } from 'lucide-react';
|
||||
import { memo, useCallback, useMemo, useState } from 'react';
|
||||
import { ChevronDown, FileText, FolderClosed, Package } from 'lucide-react';
|
||||
import { memo, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useTaskStore } from '@/store/task';
|
||||
import { taskDetailSelectors } from '@/store/task/selectors';
|
||||
|
||||
import AccordionArrowIcon from '../shared/AccordionArrowIcon';
|
||||
import { styles } from '../shared/style';
|
||||
|
||||
const flattenWorkspace = (nodes: TaskDetailWorkspaceNode[]): TaskDetailWorkspaceNode[] =>
|
||||
nodes.flatMap((node) => [
|
||||
node,
|
||||
...(node.children?.length ? flattenWorkspace(node.children) : []),
|
||||
]);
|
||||
const formatSize = (size?: number | null): string | undefined => {
|
||||
if (size == null) return undefined;
|
||||
if (size < 1000) return `${size} chars`;
|
||||
return `${(size / 1000).toFixed(1)}k chars`;
|
||||
};
|
||||
|
||||
const ArtifactCard = memo<{ node: TaskDetailWorkspaceNode }>(({ node }) => {
|
||||
const { t } = useTranslation('chat');
|
||||
const { modal } = App.useApp();
|
||||
const openPageModal = useTaskStore((s) => s.openPageModal);
|
||||
const unpinDocument = useTaskStore((s) => s.unpinDocument);
|
||||
const activeTaskId = useTaskStore(taskDetailSelectors.activeTaskId);
|
||||
const title = node.title || 'Untitled';
|
||||
const sizeLabel =
|
||||
node.size == null ? undefined : t('taskDetail.artifactSize', { value: node.size });
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
const taskId = node.sourceTaskId ?? activeTaskId;
|
||||
if (!taskId) return;
|
||||
modal.confirm({
|
||||
centered: true,
|
||||
content: t('taskDetail.artifactMenu.deleteConfirm.content'),
|
||||
okButtonProps: { danger: true },
|
||||
okText: t('taskDetail.artifactMenu.deleteConfirm.ok'),
|
||||
onOk: () => unpinDocument(taskId, node.documentId),
|
||||
title: t('taskDetail.artifactMenu.deleteConfirm.title'),
|
||||
type: 'error',
|
||||
});
|
||||
}, [activeTaskId, modal, node.documentId, node.sourceTaskId, t, unpinDocument]);
|
||||
|
||||
const menuItems = useMemo<DropdownItem[]>(
|
||||
() => [
|
||||
{
|
||||
danger: true,
|
||||
icon: <Icon icon={Trash} />,
|
||||
key: 'delete',
|
||||
label: t('taskDetail.artifactMenu.delete'),
|
||||
onClick: handleDelete,
|
||||
},
|
||||
],
|
||||
[handleDelete, t],
|
||||
);
|
||||
const ArtifactTitle = memo<{ node: TaskDetailWorkspaceNode }>(({ node }) => {
|
||||
const isFolder = (node.children?.length ?? 0) > 0;
|
||||
const sizeLabel = formatSize(node.size);
|
||||
|
||||
return (
|
||||
<Block
|
||||
clickable
|
||||
<Flexbox
|
||||
horizontal
|
||||
align="center"
|
||||
gap={10}
|
||||
paddingBlock={8}
|
||||
paddingInline={12}
|
||||
variant="outlined"
|
||||
onClick={() => openPageModal(node.documentId)}
|
||||
gap={8}
|
||||
style={{ lineHeight: 1, minWidth: 0, overflow: 'hidden', width: '100%' }}
|
||||
>
|
||||
<Icon
|
||||
color={cssVar.colorTextSecondary}
|
||||
icon={FileTextIcon}
|
||||
size={{ size: 18, strokeWidth: 1.5 }}
|
||||
style={{ flexShrink: 0 }}
|
||||
color={cssVar.colorTextDescription}
|
||||
icon={isFolder ? FolderClosed : FileText}
|
||||
size={14}
|
||||
style={{ flex: 'none' }}
|
||||
/>
|
||||
<Text ellipsis style={{ flex: 1, minWidth: 0 }}>
|
||||
{title}
|
||||
<Text ellipsis fontSize={13} style={{ flex: 1, minWidth: 0 }}>
|
||||
{node.title || 'Untitled'}
|
||||
</Text>
|
||||
{sizeLabel && (
|
||||
<Text fontSize={12} style={{ flexShrink: 0 }} type="secondary">
|
||||
<Text style={{ color: cssVar.colorTextQuaternary, flex: 'none', fontSize: 12 }}>
|
||||
{sizeLabel}
|
||||
</Text>
|
||||
)}
|
||||
@@ -93,27 +48,25 @@ const ArtifactCard = memo<{ node: TaskDetailWorkspaceNode }>(({ node }) => {
|
||||
{node.sourceTaskIdentifier}
|
||||
</Tag>
|
||||
)}
|
||||
<DropdownMenu items={menuItems}>
|
||||
<ActionIcon
|
||||
icon={MoreHorizontal}
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
/>
|
||||
</DropdownMenu>
|
||||
</Block>
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
|
||||
const toTreeData = (nodes: TaskDetailWorkspaceNode[]): DataNode[] =>
|
||||
nodes.map((node) => ({
|
||||
children: node.children?.length ? toTreeData(node.children) : undefined,
|
||||
key: node.documentId,
|
||||
title: <ArtifactTitle node={node} />,
|
||||
}));
|
||||
|
||||
const TaskArtifacts = memo(() => {
|
||||
const { t } = useTranslation('chat');
|
||||
const workspace = useTaskStore(taskDetailSelectors.activeTaskWorkspace);
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
|
||||
const items = useMemo(() => flattenWorkspace(workspace), [workspace]);
|
||||
const treeData = useMemo(() => toTreeData(workspace), [workspace]);
|
||||
|
||||
if (items.length === 0) return null;
|
||||
if (workspace.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Flexbox gap={8}>
|
||||
@@ -133,16 +86,30 @@ const TaskArtifacts = memo(() => {
|
||||
<Text color={cssVar.colorTextSecondary} fontSize={13} weight={500}>
|
||||
{t('taskDetail.artifacts')}
|
||||
</Text>
|
||||
<Tag size="small">{items.length}</Tag>
|
||||
<AccordionArrowIcon isOpen={isExpanded} style={{ color: cssVar.colorTextDescription }} />
|
||||
<Tag size="small">{workspace.length}</Tag>
|
||||
<Icon
|
||||
color={cssVar.colorTextDescription}
|
||||
icon={ChevronDown}
|
||||
size={14}
|
||||
style={{
|
||||
transform: isExpanded ? 'rotate(0deg)' : 'rotate(-90deg)',
|
||||
transition: 'transform 200ms',
|
||||
}}
|
||||
/>
|
||||
</Block>
|
||||
</Flexbox>
|
||||
{isExpanded && (
|
||||
<Flexbox gap={8} paddingInline={12}>
|
||||
{items.map((node) => (
|
||||
<ArtifactCard key={node.documentId} node={node} />
|
||||
))}
|
||||
</Flexbox>
|
||||
<ConfigProvider theme={{ components: { Tree: { titleHeight: 32 } } }}>
|
||||
<Tree
|
||||
blockNode
|
||||
defaultExpandAll
|
||||
showLine
|
||||
className={styles.subtaskTree}
|
||||
selectable={false}
|
||||
switcherIcon={<Icon icon={ChevronDown} size={14} />}
|
||||
treeData={treeData}
|
||||
/>
|
||||
</ConfigProvider>
|
||||
)}
|
||||
</Flexbox>
|
||||
);
|
||||
|
||||
@@ -1,17 +1,6 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Block,
|
||||
type DropdownItem,
|
||||
DropdownMenu,
|
||||
Flexbox,
|
||||
Icon,
|
||||
Text,
|
||||
} from '@lobehub/ui';
|
||||
import { App } from 'antd';
|
||||
import { Block, Flexbox, Text } from '@lobehub/ui';
|
||||
import { cssVar } from 'antd-style';
|
||||
import { Check, ChevronDownIcon, ChevronUpIcon, MoreHorizontal, Trash } from 'lucide-react';
|
||||
import { memo, useCallback, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { memo } from 'react';
|
||||
|
||||
import BriefCardActions from '@/features/DailyBrief/BriefCardActions';
|
||||
import BriefCardSummary from '@/features/DailyBrief/BriefCardSummary';
|
||||
@@ -19,102 +8,41 @@ import BriefIcon from '@/features/DailyBrief/BriefIcon';
|
||||
import { styles as briefStyles } from '@/features/DailyBrief/style';
|
||||
import type { BriefItem } from '@/features/DailyBrief/types';
|
||||
import Time from '@/routes/(main)/home/features/components/Time';
|
||||
import { useBriefStore } from '@/store/brief';
|
||||
|
||||
interface TaskBriefCardProps {
|
||||
brief: BriefItem;
|
||||
onAfterAddComment?: () => void | Promise<void>;
|
||||
onAfterDelete?: () => void | Promise<void>;
|
||||
onAfterResolve?: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
const TaskBriefCard = memo<TaskBriefCardProps>(
|
||||
({ brief, onAfterResolve, onAfterAddComment, onAfterDelete }) => {
|
||||
const { t } = useTranslation('home');
|
||||
const { modal } = App.useApp();
|
||||
const deleteBrief = useBriefStore((s) => s.deleteBrief);
|
||||
const isResolved = Boolean(brief.resolvedAction);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const showFull = !isResolved || expanded;
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
modal.confirm({
|
||||
centered: true,
|
||||
content: t('brief.deleteConfirm.content'),
|
||||
okButtonProps: { danger: true },
|
||||
okText: t('brief.deleteConfirm.ok'),
|
||||
onOk: async () => {
|
||||
await deleteBrief(brief.id);
|
||||
await onAfterDelete?.();
|
||||
},
|
||||
title: t('brief.deleteConfirm.title'),
|
||||
type: 'error',
|
||||
});
|
||||
}, [brief.id, deleteBrief, modal, onAfterDelete, t]);
|
||||
|
||||
const menuItems = useMemo<DropdownItem[]>(
|
||||
() => [
|
||||
{
|
||||
danger: true,
|
||||
icon: <Icon icon={Trash} />,
|
||||
key: 'delete',
|
||||
label: t('brief.delete'),
|
||||
onClick: handleDelete,
|
||||
},
|
||||
],
|
||||
[handleDelete, t],
|
||||
);
|
||||
|
||||
return (
|
||||
<Block
|
||||
className={briefStyles.card}
|
||||
gap={12}
|
||||
paddingBlock={12}
|
||||
paddingInline={8}
|
||||
style={{ borderRadius: cssVar.borderRadiusLG }}
|
||||
variant={'outlined'}
|
||||
>
|
||||
<Flexbox horizontal align={'center'} gap={8} style={{ overflow: 'hidden' }}>
|
||||
<BriefIcon size={24} type={brief.type} />
|
||||
<Text ellipsis style={{ flex: 1 }} weight={500}>
|
||||
{brief.title}
|
||||
</Text>
|
||||
{isResolved && !expanded && (
|
||||
<Flexbox horizontal align={'center'} gap={4}>
|
||||
<Icon color={cssVar.colorTextQuaternary} icon={Check} size={14} />
|
||||
<Text className={briefStyles.resolvedTag}>{t('brief.resolved')}</Text>
|
||||
</Flexbox>
|
||||
)}
|
||||
<Time date={brief.createdAt} />
|
||||
{isResolved && (
|
||||
<ActionIcon
|
||||
icon={expanded ? ChevronUpIcon : ChevronDownIcon}
|
||||
size={'small'}
|
||||
title={expanded ? t('brief.collapse') : t('brief.expandAll')}
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
/>
|
||||
)}
|
||||
<DropdownMenu items={menuItems}>
|
||||
<ActionIcon icon={MoreHorizontal} size={'small'} />
|
||||
</DropdownMenu>
|
||||
</Flexbox>
|
||||
{showFull && (
|
||||
<>
|
||||
<BriefCardSummary summary={brief.summary} />
|
||||
<BriefCardActions
|
||||
actions={brief.actions}
|
||||
briefId={brief.id}
|
||||
briefType={brief.type}
|
||||
resolvedAction={brief.resolvedAction}
|
||||
taskId={brief.taskId}
|
||||
onAfterAddComment={onAfterAddComment}
|
||||
onAfterResolve={onAfterResolve}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Block>
|
||||
);
|
||||
},
|
||||
);
|
||||
const TaskBriefCard = memo<TaskBriefCardProps>(({ brief, onAfterResolve, onAfterAddComment }) => {
|
||||
return (
|
||||
<Block
|
||||
className={briefStyles.card}
|
||||
gap={12}
|
||||
padding={12}
|
||||
style={{ borderRadius: cssVar.borderRadiusLG }}
|
||||
variant={'outlined'}
|
||||
>
|
||||
<Flexbox horizontal align={'center'} gap={8} style={{ overflow: 'hidden' }}>
|
||||
<BriefIcon size={20} type={brief.type} />
|
||||
<Text ellipsis style={{ flex: 1 }} weight={500}>
|
||||
{brief.title}
|
||||
</Text>
|
||||
<Time date={brief.createdAt} />
|
||||
</Flexbox>
|
||||
<BriefCardSummary summary={brief.summary} />
|
||||
<BriefCardActions
|
||||
actions={brief.actions}
|
||||
briefId={brief.id}
|
||||
briefType={brief.type}
|
||||
resolvedAction={brief.resolvedAction}
|
||||
taskId={brief.taskId}
|
||||
onAfterAddComment={onAfterAddComment}
|
||||
onAfterResolve={onAfterResolve}
|
||||
/>
|
||||
</Block>
|
||||
);
|
||||
});
|
||||
|
||||
export default TaskBriefCard;
|
||||
|
||||
@@ -7,13 +7,10 @@ import NavHeader from '@/features/NavHeader';
|
||||
import ToggleRightPanelButton from '@/features/RightPanel/ToggleRightPanelButton';
|
||||
import WideScreenContainer from '@/features/WideScreenContainer';
|
||||
import { useChatStore } from '@/store/chat';
|
||||
import { useGlobalStore } from '@/store/global';
|
||||
import { systemStatusSelectors } from '@/store/global/selectors';
|
||||
import { useTaskStore } from '@/store/task';
|
||||
import { taskDetailSelectors } from '@/store/task/selectors';
|
||||
|
||||
import Breadcrumb from '../shared/Breadcrumb';
|
||||
import PageModal from './PageModal';
|
||||
import TaskActivities from './TaskActivities';
|
||||
import TaskArtifacts from './TaskArtifacts';
|
||||
import TaskDetailAssignee from './TaskDetailAssignee';
|
||||
@@ -38,11 +35,6 @@ const TaskDetailPage = memo<TaskDetailPageProps>(({ agentId, taskId }) => {
|
||||
const isLoading = useTaskStore(taskDetailSelectors.isTaskDetailLoading);
|
||||
const saveStatus = useTaskStore(taskDetailSelectors.taskSaveStatus);
|
||||
|
||||
const [showTaskAgentPanel, toggleTaskAgentPanel] = useGlobalStore((s) => [
|
||||
systemStatusSelectors.showTaskAgentPanel(s),
|
||||
s.toggleTaskAgentPanel,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveTaskId(taskId);
|
||||
return () => setActiveTaskId(undefined);
|
||||
@@ -66,6 +58,7 @@ const TaskDetailPage = memo<TaskDetailPageProps>(({ agentId, taskId }) => {
|
||||
return (
|
||||
<Flexbox flex={1} height={'100%'} style={{ minHeight: 0 }}>
|
||||
<NavHeader
|
||||
right={<ToggleRightPanelButton hideWhenExpanded />}
|
||||
left={
|
||||
<>
|
||||
<Breadcrumb taskId={taskId} />
|
||||
@@ -73,13 +66,6 @@ const TaskDetailPage = memo<TaskDetailPageProps>(({ agentId, taskId }) => {
|
||||
{saveStatus === 'saving' ? <AutoSaveHint saveStatus={saveStatus} /> : undefined}
|
||||
</>
|
||||
}
|
||||
right={
|
||||
<ToggleRightPanelButton
|
||||
hideWhenExpanded
|
||||
expand={showTaskAgentPanel}
|
||||
onToggle={() => toggleTaskAgentPanel()}
|
||||
/>
|
||||
}
|
||||
styles={{
|
||||
left: {
|
||||
paddingLeft: 4,
|
||||
@@ -118,7 +104,6 @@ const TaskDetailPage = memo<TaskDetailPageProps>(({ agentId, taskId }) => {
|
||||
</WideScreenContainer>
|
||||
</Flexbox>
|
||||
<TopicChatDrawer />
|
||||
<PageModal />
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Button, Flexbox, Text } from '@lobehub/ui';
|
||||
import { CalendarOffIcon, PlayIcon, RotateCcwIcon } from 'lucide-react';
|
||||
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Button } from '@lobehub/ui';
|
||||
import { PlayIcon, RotateCcwIcon } from 'lucide-react';
|
||||
import { memo, useCallback, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import StopLoadingIcon from '@/components/StopLoading';
|
||||
@@ -9,40 +9,20 @@ import { builtinAgentSelectors } from '@/store/agent/selectors';
|
||||
import { useTaskStore } from '@/store/task';
|
||||
import { taskDetailSelectors } from '@/store/task/selectors';
|
||||
|
||||
import { nextHeartbeatFiring, nextScheduleFiring } from './scheduler/helpers';
|
||||
|
||||
const padTime = (n: number) => String(n).padStart(2, '0');
|
||||
|
||||
const formatCountdown = (msRemaining: number): string => {
|
||||
const totalSeconds = Math.max(0, Math.floor(msRemaining / 1000));
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
if (hours > 0) return `${padTime(hours)}:${padTime(minutes)}:${padTime(seconds)}`;
|
||||
return `${padTime(minutes)}:${padTime(seconds)}`;
|
||||
};
|
||||
|
||||
const TaskDetailRunPauseAction = memo(() => {
|
||||
const { t } = useTranslation('chat');
|
||||
const taskId = useTaskStore(taskDetailSelectors.activeTaskId);
|
||||
const canRun = useTaskStore(taskDetailSelectors.canRunActiveTask);
|
||||
const canPause = useTaskStore(taskDetailSelectors.canPauseActiveTask);
|
||||
const status = useTaskStore(taskDetailSelectors.activeTaskStatus);
|
||||
const detail = useTaskStore(taskDetailSelectors.activeTaskDetail);
|
||||
const automationMode = useTaskStore(taskDetailSelectors.activeTaskAutomationMode);
|
||||
const interval = useTaskStore(taskDetailSelectors.activeTaskPeriodicInterval);
|
||||
const schedulePattern = useTaskStore(taskDetailSelectors.activeTaskSchedulePattern);
|
||||
const scheduleTimezone = useTaskStore(taskDetailSelectors.activeTaskScheduleTimezone);
|
||||
const assigneeAgentId = useTaskStore(taskDetailSelectors.activeTaskAgentId);
|
||||
const inboxAgentId = useAgentStore(builtinAgentSelectors.inboxAgentId);
|
||||
const isRerun = status === 'completed';
|
||||
const runTask = useTaskStore((s) => s.runTask);
|
||||
const updateTask = useTaskStore((s) => s.updateTask);
|
||||
const updateTaskStatus = useTaskStore((s) => s.updateTaskStatus);
|
||||
const setAutomationMode = useTaskStore((s) => s.setAutomationMode);
|
||||
|
||||
const [isStarting, setIsStarting] = useState(false);
|
||||
const [isCancellingSchedule, setIsCancellingSchedule] = useState(false);
|
||||
|
||||
const handleRunOrPause = useCallback(async () => {
|
||||
if (!taskId) return;
|
||||
@@ -71,67 +51,6 @@ const TaskDetailRunPauseAction = memo(() => {
|
||||
updateTaskStatus,
|
||||
]);
|
||||
|
||||
const handleCancelSchedule = useCallback(async () => {
|
||||
if (!taskId) return;
|
||||
setIsCancellingSchedule(true);
|
||||
try {
|
||||
await setAutomationMode(taskId, null);
|
||||
if (status === 'scheduled') {
|
||||
await updateTaskStatus(taskId, 'backlog');
|
||||
}
|
||||
} finally {
|
||||
setIsCancellingSchedule(false);
|
||||
}
|
||||
}, [taskId, setAutomationMode, updateTaskStatus, status]);
|
||||
|
||||
const isScheduled = status === 'scheduled';
|
||||
|
||||
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
if (!isScheduled) return;
|
||||
const id = setInterval(() => setNowMs(Date.now()), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [isScheduled]);
|
||||
|
||||
const countdownText = useMemo(() => {
|
||||
if (!isScheduled) return null;
|
||||
let next = null;
|
||||
if (automationMode === 'heartbeat') {
|
||||
next = nextHeartbeatFiring(detail?.heartbeat?.lastAt, interval);
|
||||
} else if (automationMode === 'schedule' && schedulePattern) {
|
||||
next = nextScheduleFiring(schedulePattern, scheduleTimezone);
|
||||
}
|
||||
if (!next) return null;
|
||||
return formatCountdown(next.toDate().getTime() - nowMs);
|
||||
}, [
|
||||
isScheduled,
|
||||
automationMode,
|
||||
detail?.heartbeat?.lastAt,
|
||||
interval,
|
||||
schedulePattern,
|
||||
scheduleTimezone,
|
||||
nowMs,
|
||||
]);
|
||||
|
||||
if (isScheduled) {
|
||||
return (
|
||||
<Flexbox horizontal align={'center'} gap={12}>
|
||||
<Button
|
||||
icon={CalendarOffIcon}
|
||||
loading={isCancellingSchedule}
|
||||
onClick={handleCancelSchedule}
|
||||
>
|
||||
{t('taskDetail.cancelSchedule')}
|
||||
</Button>
|
||||
{countdownText && (
|
||||
<Text fontSize={12} type={'secondary'}>
|
||||
{t('taskDetail.nextRunCountdown', { countdown: countdownText })}
|
||||
</Text>
|
||||
)}
|
||||
</Flexbox>
|
||||
);
|
||||
}
|
||||
|
||||
if (!canRun && !canPause && !isStarting) return null;
|
||||
|
||||
if (isStarting) {
|
||||
|
||||
@@ -96,11 +96,10 @@ const TaskProperties = memo(() => {
|
||||
variant={'borderless'}
|
||||
>
|
||||
<TaskTriggerTag
|
||||
automationMode={automationMode}
|
||||
heartbeatInterval={heartbeatInterval}
|
||||
heartbeatInterval={automationMode === 'heartbeat' ? heartbeatInterval : undefined}
|
||||
mode="inline"
|
||||
schedulePattern={schedulePattern}
|
||||
scheduleTimezone={scheduleTimezone}
|
||||
schedulePattern={automationMode === 'schedule' ? schedulePattern : undefined}
|
||||
scheduleTimezone={automationMode === 'schedule' ? scheduleTimezone : undefined}
|
||||
/>
|
||||
</Block>
|
||||
</TaskScheduleConfig>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { TaskAutomationMode } from '@lobechat/types';
|
||||
import {
|
||||
ActionIcon,
|
||||
Avatar,
|
||||
Button,
|
||||
Flexbox,
|
||||
Icon,
|
||||
InputNumber,
|
||||
Popover,
|
||||
Segmented,
|
||||
@@ -11,9 +10,7 @@ import {
|
||||
Text,
|
||||
} from '@lobehub/ui';
|
||||
import { Switch } from 'antd';
|
||||
import { createStaticStyles, cssVar } from 'antd-style';
|
||||
import dayjs from 'dayjs';
|
||||
import { CalendarDays, Clock, RefreshCw, TimerIcon, Zap } from 'lucide-react';
|
||||
import { TimerIcon } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -21,36 +18,9 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useTaskStore } from '@/store/task';
|
||||
import { taskDetailSelectors } from '@/store/task/selectors';
|
||||
|
||||
import {
|
||||
formatIntervalLabel,
|
||||
formatScheduleDescription,
|
||||
formatTimezoneName,
|
||||
nextHeartbeatFiring,
|
||||
nextScheduleFiring,
|
||||
} from './scheduler/helpers';
|
||||
import SchedulerForm, { type SchedulerFormChange } from './scheduler/SchedulerForm';
|
||||
|
||||
type IntervalUnit = 'hours' | 'minutes';
|
||||
|
||||
const MIN_MINUTES = 10;
|
||||
|
||||
const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
fieldLabel: css`
|
||||
font-size: 12px;
|
||||
color: ${cssVar.colorTextSecondary};
|
||||
`,
|
||||
popover: css`
|
||||
border: 1px solid ${cssVar.colorBorderSecondary};
|
||||
border-radius: 12px;
|
||||
background: ${cssVar.colorBgContainer};
|
||||
`,
|
||||
preview: css`
|
||||
padding-block: 12px;
|
||||
padding-inline: 14px;
|
||||
border-radius: 12px;
|
||||
background: ${cssVar.colorFillQuaternary};
|
||||
`,
|
||||
}));
|
||||
type IntervalUnit = 'hours' | 'minutes' | 'seconds';
|
||||
|
||||
interface IntervalTabProps {
|
||||
currentInterval: number;
|
||||
@@ -63,13 +33,12 @@ const IntervalTab = memo<IntervalTabProps>(({ currentInterval, taskId }) => {
|
||||
|
||||
const derived = useMemo(() => {
|
||||
if (!currentInterval || currentInterval === 0)
|
||||
return { displayValue: MIN_MINUTES, unit: 'minutes' as IntervalUnit };
|
||||
return { displayValue: undefined, unit: 'minutes' as IntervalUnit };
|
||||
if (currentInterval >= 3600 && currentInterval % 3600 === 0)
|
||||
return { displayValue: currentInterval / 3600, unit: 'hours' as IntervalUnit };
|
||||
return {
|
||||
displayValue: Math.max(MIN_MINUTES, Math.round(currentInterval / 60)),
|
||||
unit: 'minutes' as IntervalUnit,
|
||||
};
|
||||
if (currentInterval >= 60 && currentInterval % 60 === 0)
|
||||
return { displayValue: currentInterval / 60, unit: 'minutes' as IntervalUnit };
|
||||
return { displayValue: currentInterval, unit: 'seconds' as IntervalUnit };
|
||||
}, [currentInterval]);
|
||||
|
||||
const [localUnit, setLocalUnit] = useState<IntervalUnit>(derived.unit);
|
||||
@@ -82,7 +51,17 @@ const IntervalTab = memo<IntervalTabProps>(({ currentInterval, taskId }) => {
|
||||
|
||||
const toSeconds = (val: number | null, u: IntervalUnit): number | null => {
|
||||
if (!val || val <= 0) return null;
|
||||
return u === 'hours' ? val * 3600 : val * 60;
|
||||
switch (u) {
|
||||
case 'hours': {
|
||||
return val * 3600;
|
||||
}
|
||||
case 'minutes': {
|
||||
return val * 60;
|
||||
}
|
||||
default: {
|
||||
return val;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleValueChange = useCallback(
|
||||
@@ -108,40 +87,44 @@ const IntervalTab = memo<IntervalTabProps>(({ currentInterval, taskId }) => {
|
||||
(u: IntervalUnit) => {
|
||||
setLocalUnit(u);
|
||||
if (!taskId || !localValue) return;
|
||||
const clamped = u === 'minutes' ? Math.max(MIN_MINUTES, localValue) : localValue;
|
||||
if (clamped !== localValue) setLocalValue(clamped);
|
||||
const seconds = toSeconds(clamped, u);
|
||||
const seconds = toSeconds(localValue, u);
|
||||
updatePeriodicInterval(taskId, seconds);
|
||||
},
|
||||
[taskId, localValue, updatePeriodicInterval],
|
||||
);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
setLocalValue(undefined);
|
||||
if (taskId) updatePeriodicInterval(taskId, null);
|
||||
}, [taskId, updatePeriodicInterval]);
|
||||
|
||||
return (
|
||||
<Flexbox gap={6}>
|
||||
<Text className={styles.fieldLabel}>{t('taskSchedule.intervalLabel')}</Text>
|
||||
<Flexbox horizontal align="center" gap={8}>
|
||||
<Text type="secondary">{t('taskSchedule.every')}</Text>
|
||||
<InputNumber
|
||||
min={localUnit === 'minutes' ? MIN_MINUTES : 1}
|
||||
placeholder={localUnit === 'minutes' ? String(MIN_MINUTES) : '1'}
|
||||
style={{ width: 100 }}
|
||||
value={localValue}
|
||||
variant="filled"
|
||||
onChange={handleValueChange}
|
||||
/>
|
||||
<Select
|
||||
style={{ flex: 1 }}
|
||||
value={localUnit}
|
||||
variant="filled"
|
||||
options={[
|
||||
{ label: t('taskSchedule.minutes'), value: 'minutes' },
|
||||
{ label: t('taskSchedule.hours'), value: 'hours' },
|
||||
]}
|
||||
onChange={handleUnitChange}
|
||||
/>
|
||||
<Text type="secondary">{t('taskSchedule.intervalSuffix')}</Text>
|
||||
<>
|
||||
<Flexbox horizontal align="center" gap={16} justify={'space-between'}>
|
||||
<Text weight={500}>{t('taskSchedule.every')}</Text>
|
||||
<Flexbox horizontal align="center" gap={8}>
|
||||
<InputNumber
|
||||
min={1}
|
||||
placeholder="10"
|
||||
style={{ width: 100 }}
|
||||
value={localValue}
|
||||
onChange={handleValueChange}
|
||||
/>
|
||||
<Select
|
||||
style={{ width: 110 }}
|
||||
value={localUnit}
|
||||
variant="outlined"
|
||||
options={[
|
||||
{ label: t('taskSchedule.seconds'), value: 'seconds' },
|
||||
{ label: t('taskSchedule.minutes'), value: 'minutes' },
|
||||
{ label: t('taskSchedule.hours'), value: 'hours' },
|
||||
]}
|
||||
onChange={handleUnitChange}
|
||||
/>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
{currentInterval > 0 && <Button onClick={handleClear}>{t('taskSchedule.clear')}</Button>}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -184,76 +167,21 @@ const TaskScheduleConfig = memo(function TaskScheduleConfig({
|
||||
currentInterval,
|
||||
taskId,
|
||||
}: TaskScheduleConfigProps) {
|
||||
const { t, i18n } = useTranslation('chat');
|
||||
const { t } = useTranslation('chat');
|
||||
const activeTaskId = useTaskStore(taskDetailSelectors.activeTaskId);
|
||||
const activeTaskInterval = useTaskStore(taskDetailSelectors.activeTaskPeriodicInterval);
|
||||
const automationMode = useTaskStore(taskDetailSelectors.activeTaskAutomationMode);
|
||||
const setAutomationMode = useTaskStore((s) => s.setAutomationMode);
|
||||
const detail = useTaskStore(taskDetailSelectors.activeTaskDetail);
|
||||
const schedulePattern = useTaskStore(taskDetailSelectors.activeTaskSchedulePattern);
|
||||
const scheduleTimezone = useTaskStore(taskDetailSelectors.activeTaskScheduleTimezone);
|
||||
|
||||
const finalTaskId = taskId ?? activeTaskId;
|
||||
const finalCurrentInterval = currentInterval ?? activeTaskInterval;
|
||||
|
||||
const enabled = !!automationMode;
|
||||
|
||||
const summary = useMemo<{ primary: string; secondary?: string } | null>(() => {
|
||||
if (automationMode === 'heartbeat' && finalCurrentInterval > 0) {
|
||||
return {
|
||||
primary: t('taskSchedule.summary.heartbeat', {
|
||||
interval: formatIntervalLabel(finalCurrentInterval, t),
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (automationMode === 'schedule' && schedulePattern) {
|
||||
return {
|
||||
primary: formatScheduleDescription(schedulePattern, t),
|
||||
secondary: scheduleTimezone
|
||||
? formatTimezoneName(scheduleTimezone, i18n.language)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}, [automationMode, finalCurrentInterval, schedulePattern, scheduleTimezone, t, i18n.language]);
|
||||
|
||||
const [nowTick, setNowTick] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const id = setInterval(() => setNowTick((n) => n + 1), 60_000);
|
||||
return () => clearInterval(id);
|
||||
}, [enabled]);
|
||||
|
||||
const nextRun = useMemo(() => {
|
||||
if (!enabled) return null;
|
||||
if (automationMode === 'heartbeat') {
|
||||
return nextHeartbeatFiring(detail?.heartbeat?.lastAt, finalCurrentInterval);
|
||||
}
|
||||
if (automationMode === 'schedule' && schedulePattern) {
|
||||
return nextScheduleFiring(schedulePattern, scheduleTimezone);
|
||||
}
|
||||
return null;
|
||||
}, [
|
||||
automationMode,
|
||||
detail?.heartbeat?.lastAt,
|
||||
enabled,
|
||||
finalCurrentInterval,
|
||||
schedulePattern,
|
||||
scheduleTimezone,
|
||||
nowTick,
|
||||
]);
|
||||
|
||||
const nextRunText = useMemo(() => {
|
||||
if (!nextRun) return null;
|
||||
return dayjs(nextRun.toDate()).format(t('taskSchedule.nextRun.format'));
|
||||
}, [nextRun, t]);
|
||||
|
||||
const handleEnableChange = useCallback(
|
||||
(checked: boolean) => {
|
||||
if (!finalTaskId) return;
|
||||
// Schedule (cron) is the more common, predictable choice; users who want
|
||||
// a fixed interval can switch to the heartbeat tab from there.
|
||||
setAutomationMode(finalTaskId, checked ? 'schedule' : null);
|
||||
// When enabling, default to heartbeat (the more common mode)
|
||||
setAutomationMode(finalTaskId, checked ? 'heartbeat' : null);
|
||||
},
|
||||
[finalTaskId, setAutomationMode],
|
||||
);
|
||||
@@ -267,62 +195,19 @@ const TaskScheduleConfig = memo(function TaskScheduleConfig({
|
||||
);
|
||||
|
||||
const content = (
|
||||
<Flexbox gap={16} style={{ padding: 4, width: 440 }} onClick={(e) => e.stopPropagation()}>
|
||||
<Flexbox horizontal align="center" gap={12}>
|
||||
<Avatar
|
||||
avatar={<Icon color={cssVar.colorSuccess} icon={Zap} size={20} />}
|
||||
background={cssVar.colorSuccessBg}
|
||||
shape="square"
|
||||
size={40}
|
||||
/>
|
||||
<Flexbox flex={1} gap={2}>
|
||||
<Text weight={500}>{t('taskSchedule.heading')}</Text>
|
||||
<Text style={{ color: cssVar.colorTextSecondary, fontSize: 12 }}>
|
||||
{summary?.primary ?? t('taskSchedule.summary.disabled')}
|
||||
</Text>
|
||||
{summary?.secondary && (
|
||||
<Text style={{ color: cssVar.colorTextDescription, fontSize: 11 }}>
|
||||
{summary.secondary}
|
||||
</Text>
|
||||
)}
|
||||
</Flexbox>
|
||||
<Flexbox gap={16} style={{ padding: 8, width: 420 }} onClick={(e) => e.stopPropagation()}>
|
||||
<Flexbox horizontal align="center" gap={8}>
|
||||
<Text weight={500}>{t('taskSchedule.enable')}</Text>
|
||||
<Switch checked={enabled} onChange={handleEnableChange} />
|
||||
</Flexbox>
|
||||
|
||||
{enabled && nextRunText && (
|
||||
<Flexbox horizontal align="center" className={styles.preview} gap={10}>
|
||||
<Icon color={cssVar.colorTextDescription} icon={Clock} size={16} />
|
||||
<Text style={{ color: cssVar.colorTextSecondary }}>{t('taskSchedule.nextRun')}</Text>
|
||||
<Text style={{ flex: 1, textAlign: 'right' }} weight={500}>
|
||||
{nextRunText}
|
||||
</Text>
|
||||
</Flexbox>
|
||||
)}
|
||||
|
||||
{enabled && (
|
||||
<>
|
||||
<Segmented
|
||||
block
|
||||
value={automationMode ?? 'heartbeat'}
|
||||
options={[
|
||||
{
|
||||
label: (
|
||||
<Flexbox horizontal align="center" gap={6} justify="center">
|
||||
<Icon icon={CalendarDays} size={14} />
|
||||
<span>{t('taskSchedule.schedulerTab')}</span>
|
||||
</Flexbox>
|
||||
),
|
||||
value: 'schedule',
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<Flexbox horizontal align="center" gap={6} justify="center">
|
||||
<Icon icon={RefreshCw} size={14} />
|
||||
<span>{t('taskSchedule.intervalTab')}</span>
|
||||
</Flexbox>
|
||||
),
|
||||
value: 'heartbeat',
|
||||
},
|
||||
{ label: t('taskSchedule.schedulerTab'), value: 'schedule' },
|
||||
{ label: t('taskSchedule.intervalTab'), value: 'heartbeat' },
|
||||
]}
|
||||
onChange={handleModeChange}
|
||||
/>
|
||||
@@ -336,7 +221,7 @@ const TaskScheduleConfig = memo(function TaskScheduleConfig({
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover className={styles.popover} content={content} placement="bottomRight" trigger="click">
|
||||
<Popover content={content} placement="bottomRight" trigger="click">
|
||||
{children ? (
|
||||
<div onClick={(e) => e.stopPropagation()}>{children}</div>
|
||||
) : (
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import type { TaskDetailSubtask } from '@lobechat/types';
|
||||
import { ActionIcon, Block, Flexbox, Icon, showContextMenu, Text } from '@lobehub/ui';
|
||||
import { ActionIcon, Block, ContextMenuTrigger, Flexbox, Icon, Text } from '@lobehub/ui';
|
||||
import { Button, ConfigProvider, Tree } from 'antd';
|
||||
import type { DataNode } from 'antd/es/tree';
|
||||
import { cssVar } from 'antd-style';
|
||||
import { ChevronDown, ListTodoIcon, Plus } from 'lucide-react';
|
||||
import type { Key, MouseEvent } from 'react';
|
||||
import { memo, useCallback, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
@@ -19,8 +18,7 @@ import TaskPriorityTag from '../features/TaskPriorityTag';
|
||||
import TaskStatusTag from '../features/TaskStatusTag';
|
||||
import TaskSubtaskProgressTag from '../features/TaskSubtaskProgressTag';
|
||||
import TaskTriggerTag from '../features/TaskTriggerTag';
|
||||
import { useTaskContextMenuActions } from '../features/useTaskItemContextMenu';
|
||||
import AccordionArrowIcon from '../shared/AccordionArrowIcon';
|
||||
import { useTaskItemContextMenu } from '../features/useTaskItemContextMenu';
|
||||
import { styles } from '../shared/style';
|
||||
|
||||
type TaskStatus = 'backlog' | 'canceled' | 'completed' | 'failed' | 'paused' | 'running';
|
||||
@@ -42,75 +40,103 @@ interface TaskTreeNode {
|
||||
task: TaskDetailSubtask;
|
||||
}
|
||||
|
||||
const buildTree = (subtasks: TaskDetailSubtask[]): TaskTreeNode[] =>
|
||||
subtasks.map((task) => ({
|
||||
children: buildTree(task.children ?? []),
|
||||
task,
|
||||
}));
|
||||
const buildTree = (subtasks: TaskDetailSubtask[]): TaskTreeNode[] => {
|
||||
if (subtasks.some((item) => (item.children?.length ?? 0) > 0)) {
|
||||
return subtasks.map((task) => ({
|
||||
children: buildTree(task.children ?? []),
|
||||
task,
|
||||
}));
|
||||
}
|
||||
|
||||
const nodeMap = new Map(
|
||||
subtasks.map((task) => [
|
||||
task.identifier,
|
||||
{ children: [] as TaskTreeNode[], task } satisfies TaskTreeNode,
|
||||
]),
|
||||
);
|
||||
const roots: TaskTreeNode[] = [];
|
||||
|
||||
for (const task of subtasks) {
|
||||
const node = nodeMap.get(task.identifier);
|
||||
if (!node) continue;
|
||||
|
||||
const parentIdentifier = task.blockedBy;
|
||||
const parent = parentIdentifier ? nodeMap.get(parentIdentifier) : undefined;
|
||||
if (parent && parent.task.identifier !== task.identifier) {
|
||||
parent.children.push(node);
|
||||
continue;
|
||||
}
|
||||
|
||||
roots.push(node);
|
||||
}
|
||||
|
||||
return roots;
|
||||
};
|
||||
|
||||
const SubtaskTitle = memo<{ task: TaskDetailSubtask }>(({ task }) => {
|
||||
const status = toTaskStatus(task.status);
|
||||
const { items, onContextMenu } = useTaskItemContextMenu({
|
||||
identifier: task.identifier,
|
||||
priority: task.priority,
|
||||
status: task.status,
|
||||
});
|
||||
|
||||
const isRunning = status === 'running';
|
||||
const hasName = !!task.name;
|
||||
|
||||
return (
|
||||
<Flexbox
|
||||
horizontal
|
||||
align="center"
|
||||
gap={8}
|
||||
justify="space-between"
|
||||
style={{ lineHeight: 1, minWidth: 0, overflow: 'hidden', width: '100%' }}
|
||||
>
|
||||
<span
|
||||
style={{ alignItems: 'center', display: 'inline-flex', flex: 'none' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
<ContextMenuTrigger items={items} onContextMenu={onContextMenu}>
|
||||
<Flexbox
|
||||
horizontal
|
||||
align="center"
|
||||
gap={8}
|
||||
justify="space-between"
|
||||
style={{ lineHeight: 1, minWidth: 0, overflow: 'hidden', width: '100%' }}
|
||||
>
|
||||
<TaskPriorityTag priority={task.priority} size={14} taskIdentifier={task.identifier} />
|
||||
</span>
|
||||
<span
|
||||
style={{ alignItems: 'center', display: 'inline-flex', flex: 'none' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<TaskStatusTag size={14} status={status} taskIdentifier={task.identifier} />
|
||||
</span>
|
||||
{hasName && (
|
||||
<Text fontSize={13} style={{ flex: 'none' }} type={'secondary'}>
|
||||
{task.identifier}
|
||||
</Text>
|
||||
)}
|
||||
<Text ellipsis fontSize={13} style={{ flex: 1, minWidth: 0 }}>
|
||||
{task.name || task.identifier}
|
||||
</Text>
|
||||
{task.automationMode ? (
|
||||
<span
|
||||
style={{ alignItems: 'center', display: 'inline-flex', flex: 'none' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<TaskTriggerTag
|
||||
automationMode={task.automationMode}
|
||||
heartbeatInterval={task.heartbeat?.interval}
|
||||
schedulePattern={task.schedule?.pattern}
|
||||
scheduleTimezone={task.schedule?.timezone}
|
||||
/>
|
||||
<TaskPriorityTag priority={task.priority} size={14} taskIdentifier={task.identifier} />
|
||||
</span>
|
||||
) : null}
|
||||
<AssigneeAgentSelector
|
||||
currentAgentId={task.assignee?.id ?? null}
|
||||
disabled={isRunning}
|
||||
taskIdentifier={task.identifier}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
cursor: isRunning ? 'not-allowed' : 'pointer',
|
||||
display: 'inline-flex',
|
||||
flex: 'none',
|
||||
}}
|
||||
style={{ alignItems: 'center', display: 'inline-flex', flex: 'none' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<AssigneeAvatar agentId={task.assignee?.id} size={18} />
|
||||
<TaskStatusTag size={14} status={status} taskIdentifier={task.identifier} />
|
||||
</span>
|
||||
</AssigneeAgentSelector>
|
||||
</Flexbox>
|
||||
<Text ellipsis fontSize={13} style={{ flex: 1, minWidth: 0 }}>
|
||||
{task.name || task.identifier}
|
||||
</Text>
|
||||
{task.automationMode ? (
|
||||
<span
|
||||
style={{ alignItems: 'center', display: 'inline-flex', flex: 'none' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<TaskTriggerTag
|
||||
heartbeatInterval={task.heartbeat?.interval}
|
||||
schedulePattern={task.schedule?.pattern}
|
||||
scheduleTimezone={task.schedule?.timezone}
|
||||
/>
|
||||
</span>
|
||||
) : null}
|
||||
<AssigneeAgentSelector
|
||||
currentAgentId={task.assignee?.id ?? null}
|
||||
disabled={isRunning}
|
||||
taskIdentifier={task.identifier}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
cursor: isRunning ? 'not-allowed' : 'pointer',
|
||||
display: 'inline-flex',
|
||||
flex: 'none',
|
||||
}}
|
||||
>
|
||||
<AssigneeAvatar agentId={task.assignee?.id} size={18} />
|
||||
</span>
|
||||
</AssigneeAgentSelector>
|
||||
</Flexbox>
|
||||
</ContextMenuTrigger>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -129,8 +155,6 @@ const TaskSubtasks = memo(() => {
|
||||
const subtasks = useTaskStore(taskDetailSelectors.activeTaskSubtasks);
|
||||
const taskId = useTaskStore(taskDetailSelectors.activeTaskId);
|
||||
|
||||
const { buildItems, installKeyboardHandlers } = useTaskContextMenuActions();
|
||||
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
|
||||
@@ -141,44 +165,11 @@ const TaskSubtasks = memo(() => {
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const subtaskMap = useMemo(() => {
|
||||
const map = new Map<string, TaskDetailSubtask>();
|
||||
const walk = (items: TaskDetailSubtask[]) => {
|
||||
for (const item of items) {
|
||||
map.set(item.identifier, item);
|
||||
if (item.children?.length) walk(item.children);
|
||||
}
|
||||
};
|
||||
walk(subtasks);
|
||||
return map;
|
||||
}, [subtasks]);
|
||||
|
||||
const treeData = useMemo(() => {
|
||||
if (subtasks.length === 0) return [];
|
||||
return toTreeData(buildTree(subtasks));
|
||||
}, [subtasks]);
|
||||
|
||||
const handleRightClick = useCallback(
|
||||
({ event, node }: { event: MouseEvent; node: { key: Key } }) => {
|
||||
const subtask = subtaskMap.get(String(node.key));
|
||||
if (!subtask) return;
|
||||
event.preventDefault();
|
||||
showContextMenu(
|
||||
buildItems({
|
||||
identifier: subtask.identifier,
|
||||
priority: subtask.priority,
|
||||
status: subtask.status,
|
||||
}),
|
||||
);
|
||||
installKeyboardHandlers({
|
||||
identifier: subtask.identifier,
|
||||
priority: subtask.priority,
|
||||
status: subtask.status,
|
||||
});
|
||||
},
|
||||
[subtaskMap, buildItems, installKeyboardHandlers],
|
||||
);
|
||||
|
||||
const toggleCreating = useCallback(() => setIsCreating((prev) => !prev), []);
|
||||
|
||||
if (!taskId) return null;
|
||||
@@ -206,9 +197,14 @@ const TaskSubtasks = memo(() => {
|
||||
<Text color={cssVar.colorTextSecondary} fontSize={13} weight={500}>
|
||||
{t('taskDetail.subtasks')}
|
||||
</Text>
|
||||
<AccordionArrowIcon
|
||||
isOpen={isExpanded}
|
||||
style={{ color: cssVar.colorTextDescription }}
|
||||
<Icon
|
||||
color={cssVar.colorTextDescription}
|
||||
icon={ChevronDown}
|
||||
size={14}
|
||||
style={{
|
||||
transform: isExpanded ? 'rotate(0deg)' : 'rotate(-90deg)',
|
||||
transition: 'transform 200ms',
|
||||
}}
|
||||
/>
|
||||
</Block>
|
||||
<TaskSubtaskProgressTag
|
||||
@@ -244,7 +240,6 @@ const TaskSubtasks = memo(() => {
|
||||
className={styles.subtaskTree}
|
||||
switcherIcon={<Icon icon={ChevronDown} size={14} />}
|
||||
treeData={treeData}
|
||||
onRightClick={handleRightClick}
|
||||
onSelect={(keys) => {
|
||||
const key = keys[0];
|
||||
if (!key) return;
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
type DropdownItem,
|
||||
DropdownMenu,
|
||||
Flexbox,
|
||||
stopPropagation,
|
||||
Text,
|
||||
} from '@lobehub/ui';
|
||||
import { cssVar } from 'antd-style';
|
||||
@@ -39,13 +38,8 @@ const TopicCard = memo<TopicCardProps>(({ activity }) => {
|
||||
const openTopicDrawer = useTaskStore((s) => s.openTopicDrawer);
|
||||
const isRunning = activity.status === 'running';
|
||||
|
||||
const finalDuration =
|
||||
!isRunning && activity.time && activity.completedAt
|
||||
? new Date(activity.completedAt).getTime() - new Date(activity.time).getTime()
|
||||
: null;
|
||||
|
||||
const [elapsed, setElapsed] = useState(() =>
|
||||
isRunning && activity.time ? Date.now() - new Date(activity.time).getTime() : 0,
|
||||
activity.time ? Date.now() - new Date(activity.time).getTime() : 0,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -65,11 +59,7 @@ const TopicCard = memo<TopicCardProps>(({ activity }) => {
|
||||
}, [activity.id]);
|
||||
|
||||
const startedAt = activity.time ? dayjs(activity.time).fromNow() : '';
|
||||
const durationText = isRunning
|
||||
? formatDuration(elapsed)
|
||||
: finalDuration != null && finalDuration >= 0
|
||||
? formatDuration(finalDuration)
|
||||
: '';
|
||||
const durationText = isRunning ? formatDuration(elapsed) : '';
|
||||
|
||||
const menuItems: DropdownItem[] = [
|
||||
{
|
||||
@@ -82,7 +72,7 @@ const TopicCard = memo<TopicCardProps>(({ activity }) => {
|
||||
disabled: !activity.id,
|
||||
icon: Copy,
|
||||
key: 'copy',
|
||||
label: t('taskDetail.topicMenu.copyId', { defaultValue: 'Copy topic ID' }),
|
||||
label: t('taskDetail.topicMenu.copyId', { defaultValue: 'Copy run ID' }),
|
||||
onClick: handleCopyId,
|
||||
},
|
||||
];
|
||||
@@ -142,11 +132,15 @@ const TopicCard = memo<TopicCardProps>(({ activity }) => {
|
||||
{startedAt}
|
||||
</Text>
|
||||
)}
|
||||
<Flexbox onClick={stopPropagation}>
|
||||
<DropdownMenu items={menuItems}>
|
||||
<ActionIcon icon={MoreHorizontal} size={'small'} />
|
||||
</DropdownMenu>
|
||||
</Flexbox>
|
||||
<DropdownMenu items={menuItems}>
|
||||
<ActionIcon
|
||||
icon={MoreHorizontal}
|
||||
size={'small'}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
/>
|
||||
</DropdownMenu>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import { memo, useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { ChatList, ConversationProvider, MessageItem } from '@/features/Conversation';
|
||||
import { TaskCardScopeProvider } from '@/features/Conversation/Markdown/plugins/Task';
|
||||
import { useGatewayReconnect } from '@/hooks/useGatewayReconnect';
|
||||
import { useOperationState } from '@/hooks/useOperationState';
|
||||
import { useChatStore } from '@/store/chat';
|
||||
@@ -66,11 +65,9 @@ const TopicChatDrawerBody = memo<TopicChatDrawerBodyProps>(({ agentId, topicId }
|
||||
replaceMessages(msgs, { context: ctx });
|
||||
}}
|
||||
>
|
||||
<TaskCardScopeProvider value={true}>
|
||||
<Flexbox flex={1} height={'100%'} style={{ overflow: 'hidden' }}>
|
||||
<ChatList disableActionsBar itemContent={itemContent} />
|
||||
</Flexbox>
|
||||
</TaskCardScopeProvider>
|
||||
<Flexbox flex={1} height={'100%'} style={{ overflow: 'hidden' }}>
|
||||
<ChatList disableActionsBar itemContent={itemContent} />
|
||||
</Flexbox>
|
||||
</ConversationProvider>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,16 +1,7 @@
|
||||
import {
|
||||
Accordion,
|
||||
AccordionItem,
|
||||
Checkbox,
|
||||
Flexbox,
|
||||
Icon,
|
||||
InputNumber,
|
||||
Select,
|
||||
Text,
|
||||
} from '@lobehub/ui';
|
||||
import { createStaticStyles, cssVar, cx } from 'antd-style';
|
||||
import { Checkbox, Flexbox, InputNumber, Select, Text } from '@lobehub/ui';
|
||||
import { TimePicker } from 'antd';
|
||||
import { createStaticStyles, cx } from 'antd-style';
|
||||
import dayjs, { type Dayjs } from 'dayjs';
|
||||
import { Globe, Hash, SlidersHorizontal } from 'lucide-react';
|
||||
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -24,8 +15,9 @@ import {
|
||||
} from './CronConfig';
|
||||
|
||||
const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
fieldLabel: css`
|
||||
font-size: 12px;
|
||||
label: css`
|
||||
flex-shrink: 0;
|
||||
width: 80px;
|
||||
color: ${cssVar.colorTextSecondary};
|
||||
`,
|
||||
weekdayButton: css`
|
||||
@@ -37,28 +29,31 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
|
||||
width: 36px;
|
||||
height: 32px;
|
||||
border: 1px solid ${cssVar.colorBorder};
|
||||
border-radius: 6px;
|
||||
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: ${cssVar.colorTextSecondary};
|
||||
|
||||
background: ${cssVar.colorFillTertiary};
|
||||
background: transparent;
|
||||
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
color: ${cssVar.colorText};
|
||||
background: ${cssVar.colorFillSecondary};
|
||||
border-color: ${cssVar.colorPrimary};
|
||||
color: ${cssVar.colorPrimary};
|
||||
}
|
||||
`,
|
||||
weekdayButtonActive: css`
|
||||
color: ${cssVar.colorPrimary};
|
||||
background: ${cssVar.colorPrimaryBg};
|
||||
border-color: ${cssVar.colorPrimary};
|
||||
color: ${cssVar.colorTextLightSolid};
|
||||
background: ${cssVar.colorPrimary};
|
||||
|
||||
&:hover {
|
||||
color: ${cssVar.colorPrimary};
|
||||
background: ${cssVar.colorPrimaryBgHover};
|
||||
border-color: ${cssVar.colorPrimaryHover};
|
||||
color: ${cssVar.colorTextLightSolid};
|
||||
background: ${cssVar.colorPrimaryHover};
|
||||
}
|
||||
`,
|
||||
}));
|
||||
@@ -66,22 +61,6 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
const DEFAULT_PATTERN = '0 9 * * *';
|
||||
const DEFAULT_TIMEZONE = 'UTC';
|
||||
|
||||
// Cron storage rounds minutes to 0 or 30 (see buildCronPattern), so the picker
|
||||
// only needs to offer half-hour slots — flatten to a single column instead of
|
||||
// antd's hour×minute grid.
|
||||
const TIME_OPTIONS = Array.from({ length: 48 }, (_, i) => {
|
||||
const hour = Math.floor(i / 2);
|
||||
const minute = i % 2 === 0 ? 0 : 30;
|
||||
const label = `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
|
||||
return { label, value: hour * 60 + minute };
|
||||
});
|
||||
|
||||
// The parent Popover (Base UI) treats any click outside its popup root as an
|
||||
// outside-click and dismisses. antd Select's dropdown defaults to a body-level
|
||||
// portal, which trips that detection — anchor it inside the Popover's DOM so
|
||||
// option clicks stay "inside".
|
||||
const getPopupContainer = (triggerNode: HTMLElement) => triggerNode.parentElement ?? document.body;
|
||||
|
||||
export interface SchedulerFormChange {
|
||||
maxExecutions: number | null;
|
||||
pattern: string;
|
||||
@@ -167,12 +146,10 @@ const SchedulerForm = memo<SchedulerFormProps>(({ maxExecutions, onChange, patte
|
||||
emit({ scheduleType: value, weekdays: nextWeekdays });
|
||||
};
|
||||
|
||||
const handleTimeChange = (totalMinutes: number) => {
|
||||
const next = dayjs()
|
||||
.hour(Math.floor(totalMinutes / 60))
|
||||
.minute(totalMinutes % 60);
|
||||
setTriggerTime(next);
|
||||
emit({ triggerTime: next });
|
||||
const handleTimeChange = (value: Dayjs | null) => {
|
||||
if (!value) return;
|
||||
setTriggerTime(value);
|
||||
emit({ triggerTime: value });
|
||||
};
|
||||
|
||||
const handleHourlyMinuteChange = (minute: number) => {
|
||||
@@ -212,70 +189,66 @@ const SchedulerForm = memo<SchedulerFormProps>(({ maxExecutions, onChange, patte
|
||||
|
||||
const isUnlimited = maxExec === null || maxExec === undefined;
|
||||
|
||||
const showTimeRow = scheduleType !== 'hourly';
|
||||
|
||||
return (
|
||||
<Flexbox gap={16}>
|
||||
<Flexbox horizontal gap={12}>
|
||||
<Flexbox flex={1} gap={6}>
|
||||
<Text className={styles.fieldLabel}>{t('taskSchedule.frequency')}</Text>
|
||||
<Select
|
||||
getPopupContainer={getPopupContainer}
|
||||
value={scheduleType}
|
||||
variant="filled"
|
||||
options={SCHEDULE_TYPE_OPTIONS.map((opt) => ({
|
||||
label: t(opt.label as any),
|
||||
value: opt.value,
|
||||
}))}
|
||||
onChange={handleScheduleTypeChange}
|
||||
/>
|
||||
</Flexbox>
|
||||
{showTimeRow && (
|
||||
<Flexbox flex={1} gap={6}>
|
||||
<Text className={styles.fieldLabel}>{t('taskSchedule.time')}</Text>
|
||||
<Select
|
||||
getPopupContainer={getPopupContainer}
|
||||
options={TIME_OPTIONS}
|
||||
value={triggerTime.hour() * 60 + triggerTime.minute()}
|
||||
variant="filled"
|
||||
onChange={handleTimeChange}
|
||||
/>
|
||||
</Flexbox>
|
||||
)}
|
||||
{scheduleType === 'hourly' && (
|
||||
<Flexbox flex={1} gap={6}>
|
||||
<Text className={styles.fieldLabel}>{t('taskSchedule.every')}</Text>
|
||||
<Flexbox horizontal align="center" gap={6}>
|
||||
<InputNumber
|
||||
max={24}
|
||||
min={1}
|
||||
style={{ flex: 1 }}
|
||||
value={hourlyInterval}
|
||||
variant="filled"
|
||||
onChange={handleHourlyIntervalChange}
|
||||
/>
|
||||
<Text type="secondary">{t('taskSchedule.hours')}</Text>
|
||||
<Select
|
||||
getPopupContainer={getPopupContainer}
|
||||
style={{ width: 80 }}
|
||||
value={triggerTime.minute()}
|
||||
variant="filled"
|
||||
options={[
|
||||
{ label: ':00', value: 0 },
|
||||
{ label: ':15', value: 15 },
|
||||
{ label: ':30', value: 30 },
|
||||
{ label: ':45', value: 45 },
|
||||
]}
|
||||
onChange={handleHourlyMinuteChange}
|
||||
/>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
)}
|
||||
<Flexbox gap={14} paddingBlock={4}>
|
||||
<Flexbox horizontal align="center" gap={12} justify="space-between">
|
||||
<Text className={styles.label}>{t('taskSchedule.frequency')}</Text>
|
||||
<Select
|
||||
style={{ width: 200 }}
|
||||
value={scheduleType}
|
||||
variant="outlined"
|
||||
options={SCHEDULE_TYPE_OPTIONS.map((opt) => ({
|
||||
label: t(opt.label as any),
|
||||
value: opt.value,
|
||||
}))}
|
||||
onChange={handleScheduleTypeChange}
|
||||
/>
|
||||
</Flexbox>
|
||||
|
||||
{scheduleType !== 'hourly' && (
|
||||
<Flexbox horizontal align="center" gap={12} justify="space-between">
|
||||
<Text className={styles.label}>{t('taskSchedule.time')}</Text>
|
||||
<TimePicker
|
||||
format="HH:mm"
|
||||
minuteStep={15}
|
||||
style={{ width: 200 }}
|
||||
value={triggerTime}
|
||||
onChange={handleTimeChange}
|
||||
/>
|
||||
</Flexbox>
|
||||
)}
|
||||
|
||||
{scheduleType === 'hourly' && (
|
||||
<Flexbox horizontal align="center" gap={12} justify="space-between">
|
||||
<Text className={styles.label}>{t('taskSchedule.every')}</Text>
|
||||
<Flexbox horizontal align="center" gap={8}>
|
||||
<InputNumber
|
||||
max={24}
|
||||
min={1}
|
||||
style={{ width: 70 }}
|
||||
value={hourlyInterval}
|
||||
onChange={handleHourlyIntervalChange}
|
||||
/>
|
||||
<Text type="secondary">{t('taskSchedule.hours')}</Text>
|
||||
<Select
|
||||
style={{ width: 80 }}
|
||||
value={triggerTime.minute()}
|
||||
variant="outlined"
|
||||
options={[
|
||||
{ label: ':00', value: 0 },
|
||||
{ label: ':15', value: 15 },
|
||||
{ label: ':30', value: 30 },
|
||||
{ label: ':45', value: 45 },
|
||||
]}
|
||||
onChange={handleHourlyMinuteChange}
|
||||
/>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
)}
|
||||
|
||||
{scheduleType === 'weekly' && (
|
||||
<Flexbox gap={6}>
|
||||
<Text className={styles.fieldLabel}>{t('taskSchedule.weekday')}</Text>
|
||||
<Flexbox horizontal align="center" gap={12} justify="space-between">
|
||||
<Text className={styles.label}>{t('taskSchedule.weekday')}</Text>
|
||||
<Flexbox horizontal gap={6}>
|
||||
{WEEKDAYS.map(({ key, label }) => (
|
||||
<div
|
||||
@@ -293,60 +266,35 @@ const SchedulerForm = memo<SchedulerFormProps>(({ maxExecutions, onChange, patte
|
||||
</Flexbox>
|
||||
)}
|
||||
|
||||
<Accordion gap={0}>
|
||||
<AccordionItem
|
||||
itemKey="advanced"
|
||||
paddingBlock={6}
|
||||
paddingInline={0}
|
||||
title={
|
||||
<Flexbox horizontal align="center" gap={8}>
|
||||
<Icon color={cssVar.colorTextDescription} icon={SlidersHorizontal} size={14} />
|
||||
<Text style={{ color: cssVar.colorTextSecondary }}>
|
||||
{t('taskSchedule.advancedSettings')}
|
||||
</Text>
|
||||
</Flexbox>
|
||||
}
|
||||
>
|
||||
<Flexbox gap={14} paddingBlock={'8px 4px'}>
|
||||
<Flexbox gap={6}>
|
||||
<Flexbox horizontal align="center" gap={6}>
|
||||
<Icon color={cssVar.colorTextDescription} icon={Globe} size={14} />
|
||||
<Text className={styles.fieldLabel}>{t('taskSchedule.timezone')}</Text>
|
||||
</Flexbox>
|
||||
<Select
|
||||
showSearch
|
||||
getPopupContainer={getPopupContainer}
|
||||
options={TIMEZONE_OPTIONS}
|
||||
popupMatchSelectWidth={false}
|
||||
value={tz}
|
||||
variant="filled"
|
||||
onChange={handleTimezoneChange}
|
||||
/>
|
||||
</Flexbox>
|
||||
<Flexbox horizontal align="center" gap={12} justify="space-between">
|
||||
<Text className={styles.label}>{t('taskSchedule.timezone')}</Text>
|
||||
<Select
|
||||
showSearch
|
||||
options={TIMEZONE_OPTIONS}
|
||||
popupMatchSelectWidth={false}
|
||||
style={{ width: 280 }}
|
||||
value={tz}
|
||||
variant="outlined"
|
||||
onChange={handleTimezoneChange}
|
||||
/>
|
||||
</Flexbox>
|
||||
|
||||
<Flexbox gap={6}>
|
||||
<Flexbox horizontal align="center" gap={6}>
|
||||
<Icon color={cssVar.colorTextDescription} icon={Hash} size={14} />
|
||||
<Text className={styles.fieldLabel}>{t('taskSchedule.maxExecutions')}</Text>
|
||||
</Flexbox>
|
||||
<Flexbox horizontal align="center" gap={12}>
|
||||
<InputNumber
|
||||
disabled={isUnlimited}
|
||||
min={1}
|
||||
placeholder={t('taskSchedule.maxExecutionsPlaceholder')}
|
||||
style={{ flex: 1 }}
|
||||
value={maxExec ?? undefined}
|
||||
variant="filled"
|
||||
onChange={handleMaxExecChange}
|
||||
/>
|
||||
<Checkbox checked={isUnlimited} onChange={handleContinuousChange}>
|
||||
{t('taskSchedule.continuous')}
|
||||
</Checkbox>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
<Flexbox horizontal align="center" gap={12} justify="space-between">
|
||||
<Text className={styles.label}>{t('taskSchedule.maxExecutions')}</Text>
|
||||
<Flexbox horizontal align="center" gap={10}>
|
||||
<InputNumber
|
||||
disabled={isUnlimited}
|
||||
min={1}
|
||||
placeholder="100"
|
||||
style={{ width: 90 }}
|
||||
value={maxExec ?? undefined}
|
||||
onChange={handleMaxExecChange}
|
||||
/>
|
||||
<Checkbox checked={isUnlimited} onChange={handleContinuousChange}>
|
||||
{t('taskSchedule.continuous')}
|
||||
</Checkbox>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
import dayjs, { type Dayjs } from 'dayjs';
|
||||
import timezone from 'dayjs/plugin/timezone';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
import type { TFunction } from 'i18next';
|
||||
|
||||
import { parseCronPattern, type ScheduleType, WEEKDAYS } from './CronConfig';
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
const padTime = (n: number) => String(n).padStart(2, '0');
|
||||
|
||||
const formatHHmm = (hour: number, minute: number) => `${padTime(hour)}:${padTime(minute)}`;
|
||||
|
||||
/**
|
||||
* Pretty interval like "10 min" / "2 hr" / "30 sec".
|
||||
* Reuses the same `taskSchedule.unit.*` plural keys as TaskTriggerTag.
|
||||
*/
|
||||
export const formatIntervalLabel = (seconds: number, t: TFunction<'chat'>): string => {
|
||||
if (seconds <= 0) return '';
|
||||
if (seconds % 3600 === 0) return t('taskSchedule.unit.hour', { count: seconds / 3600 });
|
||||
if (seconds % 60 === 0) return t('taskSchedule.unit.minute', { count: seconds / 60 });
|
||||
return t('taskSchedule.unit.second', { count: seconds });
|
||||
};
|
||||
|
||||
/**
|
||||
* Localized timezone display name (e.g. "中国标准时间", "Pacific Daylight Time").
|
||||
* Falls back to the IANA id when the runtime can't resolve a long name.
|
||||
*/
|
||||
export const formatTimezoneName = (tz: string, locale: string): string => {
|
||||
try {
|
||||
const parts = new Intl.DateTimeFormat(locale, {
|
||||
timeZone: tz,
|
||||
timeZoneName: 'long',
|
||||
}).formatToParts(new Date());
|
||||
return parts.find((p) => p.type === 'timeZoneName')?.value ?? tz;
|
||||
} catch {
|
||||
return tz;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Human description of a cron pattern like "每天 09:00" or "Every 2 hours :30".
|
||||
*/
|
||||
export const formatScheduleDescription = (pattern: string, t: TFunction<'chat'>): string => {
|
||||
const parsed = parseCronPattern(pattern);
|
||||
switch (parsed.scheduleType) {
|
||||
case 'hourly': {
|
||||
const interval = parsed.hourlyInterval ?? 1;
|
||||
const minute = `:${padTime(parsed.triggerMinute)}`;
|
||||
return interval === 1
|
||||
? t('taskSchedule.summary.hourly', { minute })
|
||||
: t('taskSchedule.summary.everyNHours', { count: interval, minute });
|
||||
}
|
||||
case 'daily': {
|
||||
return t('taskSchedule.summary.daily', {
|
||||
time: formatHHmm(parsed.triggerHour, parsed.triggerMinute),
|
||||
});
|
||||
}
|
||||
case 'weekly': {
|
||||
const days = parsed.weekdays ?? [];
|
||||
const allDays = days.length === 7;
|
||||
if (allDays) {
|
||||
return t('taskSchedule.summary.daily', {
|
||||
time: formatHHmm(parsed.triggerHour, parsed.triggerMinute),
|
||||
});
|
||||
}
|
||||
const labels = [...days]
|
||||
.sort((a, b) => a - b)
|
||||
.map((d) => t(WEEKDAYS.find((w) => w.key === d)!.label as any))
|
||||
.join('/');
|
||||
return t('taskSchedule.summary.weekly', {
|
||||
days: labels,
|
||||
time: formatHHmm(parsed.triggerHour, parsed.triggerMinute),
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Compute the next firing time for a parsed cron schedule, in the given timezone.
|
||||
* Returns a Dayjs in `tz` so callers can format relative to user locale.
|
||||
*/
|
||||
const computeNextScheduleFiring = (
|
||||
scheduleType: ScheduleType,
|
||||
triggerHour: number,
|
||||
triggerMinute: number,
|
||||
hourlyInterval: number | undefined,
|
||||
weekdays: number[] | undefined,
|
||||
tz: string,
|
||||
): Dayjs => {
|
||||
const now = dayjs().tz(tz);
|
||||
|
||||
switch (scheduleType) {
|
||||
case 'hourly': {
|
||||
const interval = hourlyInterval && hourlyInterval > 0 ? hourlyInterval : 1;
|
||||
let candidate = now.minute(triggerMinute).second(0).millisecond(0);
|
||||
if (!candidate.isAfter(now)) candidate = candidate.add(1, 'hour');
|
||||
while (candidate.hour() % interval !== 0) {
|
||||
candidate = candidate.add(1, 'hour');
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
case 'daily': {
|
||||
let candidate = now.hour(triggerHour).minute(triggerMinute).second(0).millisecond(0);
|
||||
if (!candidate.isAfter(now)) candidate = candidate.add(1, 'day');
|
||||
return candidate;
|
||||
}
|
||||
case 'weekly': {
|
||||
const days = weekdays && weekdays.length > 0 ? weekdays : [0, 1, 2, 3, 4, 5, 6];
|
||||
for (let offset = 0; offset < 8; offset += 1) {
|
||||
const candidate = now
|
||||
.add(offset, 'day')
|
||||
.hour(triggerHour)
|
||||
.minute(triggerMinute)
|
||||
.second(0)
|
||||
.millisecond(0);
|
||||
if (days.includes(candidate.day()) && candidate.isAfter(now)) return candidate;
|
||||
}
|
||||
return now.add(1, 'week');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Next firing time for a cron pattern in IANA timezone. Returns null if pattern is
|
||||
* unparseable.
|
||||
*/
|
||||
export const nextScheduleFiring = (pattern: string, tz: string | null): Dayjs | null => {
|
||||
const parsed = parseCronPattern(pattern);
|
||||
const safeTz = tz || dayjs.tz.guess();
|
||||
try {
|
||||
return computeNextScheduleFiring(
|
||||
parsed.scheduleType,
|
||||
parsed.triggerHour,
|
||||
parsed.triggerMinute,
|
||||
parsed.hourlyInterval,
|
||||
parsed.weekdays,
|
||||
safeTz,
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Next heartbeat firing time strictly after now. Catches up by interval steps
|
||||
* when `lastAt` is stale (e.g. task was paused for hours).
|
||||
*/
|
||||
export const nextHeartbeatFiring = (
|
||||
lastAt: string | null | undefined,
|
||||
intervalSeconds: number,
|
||||
): Dayjs | null => {
|
||||
if (!intervalSeconds || intervalSeconds <= 0) return null;
|
||||
const now = dayjs();
|
||||
if (!lastAt) return now.add(intervalSeconds, 'second');
|
||||
const base = dayjs(lastAt);
|
||||
const next = base.add(intervalSeconds, 'second');
|
||||
if (next.isAfter(now)) return next;
|
||||
const intervalMs = intervalSeconds * 1000;
|
||||
const steps = Math.floor(now.diff(base) / intervalMs) + 1;
|
||||
return base.add(steps * intervalSeconds, 'second');
|
||||
};
|
||||
@@ -55,7 +55,7 @@ const AgentTasksPage = memo(() => {
|
||||
left={<Breadcrumb />}
|
||||
right={
|
||||
<Flexbox horizontal align={'center'} gap={4}>
|
||||
{(inlineCollapsed || viewMode === 'kanban') && (
|
||||
{inlineCollapsed && (
|
||||
<ActionIcon icon={Plus} size={DESKTOP_HEADER_ICON_SIZE} onClick={handleCreateTask} />
|
||||
)}
|
||||
<TasksGroupConfig options={viewOptions} setOptions={setViewOptions} />
|
||||
|
||||
@@ -89,11 +89,11 @@ const PRIORITY_RANK_MAP: Record<number, number> = {
|
||||
};
|
||||
|
||||
const STATUS_GROUP_RANK_MAP: Record<NonNullable<TaskGroupMeta['status']>, number> = {
|
||||
paused: 0,
|
||||
failed: 1,
|
||||
running: 2,
|
||||
backlog: 3,
|
||||
completed: 4,
|
||||
backlog: 0,
|
||||
running: 1,
|
||||
paused: 2,
|
||||
completed: 3,
|
||||
failed: 4,
|
||||
canceled: 5,
|
||||
};
|
||||
|
||||
@@ -104,7 +104,6 @@ const TASK_STATUS_TO_GROUP_MAP: Record<string, NonNullable<TaskGroupMeta['status
|
||||
failed: 'failed',
|
||||
paused: 'paused',
|
||||
running: 'running',
|
||||
scheduled: 'running',
|
||||
};
|
||||
|
||||
const getPriorityValue = (task: TaskListItem) => task.priority ?? 0;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { TaskStatus } from '@lobechat/types';
|
||||
import { Block, ContextMenuTrigger, Flexbox, Text } from '@lobehub/ui';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -23,22 +22,22 @@ interface TaskItemProps {
|
||||
variant?: 'compact' | 'default';
|
||||
}
|
||||
|
||||
const TASK_STATUS_SET = new Set<TaskStatus>([
|
||||
const TASK_STATUS_SET = new Set([
|
||||
'backlog',
|
||||
'canceled',
|
||||
'completed',
|
||||
'failed',
|
||||
'paused',
|
||||
'running',
|
||||
'scheduled',
|
||||
]);
|
||||
|
||||
type TaskStatus = 'backlog' | 'canceled' | 'completed' | 'failed' | 'paused' | 'running';
|
||||
|
||||
const toTaskStatus = (status: string): TaskStatus =>
|
||||
TASK_STATUS_SET.has(status as TaskStatus) ? (status as TaskStatus) : 'backlog';
|
||||
TASK_STATUS_SET.has(status) ? (status as TaskStatus) : 'backlog';
|
||||
|
||||
const AgentTaskItem = memo<TaskItemProps>(({ task, variant = 'default' }) => {
|
||||
const { t } = useTranslation('discover');
|
||||
const { t: tChat } = useTranslation('chat');
|
||||
const useFetchTaskDetail = useTaskStore((s) => s.useFetchTaskDetail);
|
||||
useFetchTaskDetail(task.identifier);
|
||||
|
||||
@@ -65,23 +64,6 @@ const AgentTaskItem = memo<TaskItemProps>(({ task, variant = 'default' }) => {
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const scheduledBadge =
|
||||
status === 'scheduled' ? (
|
||||
<Block
|
||||
horizontal
|
||||
align={'center'}
|
||||
flex={'none'}
|
||||
height={20}
|
||||
paddingInline={8}
|
||||
style={{ borderRadius: 24 }}
|
||||
variant={'outlined'}
|
||||
>
|
||||
<Text fontSize={12} type={'secondary'}>
|
||||
{tChat('taskDetail.status.scheduled', { defaultValue: 'Scheduled' })}
|
||||
</Text>
|
||||
</Block>
|
||||
) : null;
|
||||
|
||||
const titleRow = (
|
||||
<Flexbox horizontal align={'center'} gap={8} style={{ minWidth: 0 }}>
|
||||
<TaskPriorityTag priority={task.priority} taskIdentifier={task.identifier} />
|
||||
@@ -100,7 +82,6 @@ const AgentTaskItem = memo<TaskItemProps>(({ task, variant = 'default' }) => {
|
||||
{task.identifier}
|
||||
</Text>
|
||||
)}
|
||||
{scheduledBadge}
|
||||
<TaskSubtaskProgressTag
|
||||
currentIdentifier={task.identifier}
|
||||
subtasks={taskDetail?.subtasks}
|
||||
@@ -125,7 +106,6 @@ const AgentTaskItem = memo<TaskItemProps>(({ task, variant = 'default' }) => {
|
||||
taskId={task.identifier}
|
||||
>
|
||||
<TaskTriggerTag
|
||||
automationMode={task.automationMode}
|
||||
heartbeatInterval={taskDetail?.heartbeat?.interval}
|
||||
schedulePattern={task.schedulePattern}
|
||||
scheduleTimezone={task.scheduleTimezone}
|
||||
@@ -159,7 +139,6 @@ const AgentTaskItem = memo<TaskItemProps>(({ task, variant = 'default' }) => {
|
||||
<Text ellipsis style={{ minWidth: 0 }} weight={500}>
|
||||
{hasName ? task.name : task.identifier}
|
||||
</Text>
|
||||
{scheduledBadge}
|
||||
<TaskSubtaskProgressTag
|
||||
currentIdentifier={task.identifier}
|
||||
subtasks={taskDetail?.subtasks}
|
||||
|
||||
@@ -21,7 +21,7 @@ const getActivityText = (activity: TaskDetailActivity | undefined, t: TFunction<
|
||||
if (!briefTitle) {
|
||||
return activity.briefType
|
||||
? t('taskDetail.latestActivity.briefWithTypeOnly', { type: activity.briefType })
|
||||
: undefined;
|
||||
: t('taskDetail.latestActivity.briefOnly');
|
||||
}
|
||||
|
||||
if (activity.resolvedAction) {
|
||||
|
||||
@@ -27,7 +27,7 @@ const STATUS_META: Record<TaskStatus, StatusMeta> = {
|
||||
failed: { color: cssVar.colorError, icon: CircleX },
|
||||
paused: { color: cssVar.colorInfo, icon: HandIcon },
|
||||
running: { color: cssVar.colorWarning, icon: CircleDot },
|
||||
scheduled: { color: cssVar.colorWarning, icon: Clock },
|
||||
scheduled: { color: cssVar.colorTextDescription, icon: Clock },
|
||||
};
|
||||
|
||||
interface TaskStatusIconProps {
|
||||
|
||||
@@ -66,7 +66,7 @@ export const STATUS_META: Record<TaskStatus, StatusMeta> = {
|
||||
labelKey: 'status.running',
|
||||
},
|
||||
scheduled: {
|
||||
color: cssVar.colorWarning,
|
||||
color: cssVar.colorTextDescription,
|
||||
icon: Clock,
|
||||
label: 'Scheduled',
|
||||
labelKey: 'status.scheduled',
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { Block, Flexbox, Icon, Text, Tooltip } from '@lobehub/ui';
|
||||
import { cssVar } from 'antd-style';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { ClockIcon } from 'lucide-react';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
formatIntervalLabel,
|
||||
formatScheduleDescription,
|
||||
formatTimezoneName,
|
||||
} from '@/features/AgentTasks/AgentTaskDetail/scheduler/helpers';
|
||||
const formatInterval = (seconds: number, t: TFunction<'chat'>) => {
|
||||
if (seconds < 60) return t('taskSchedule.unit.second', { count: seconds });
|
||||
if (seconds % 3600 === 0) return t('taskSchedule.unit.hour', { count: seconds / 3600 });
|
||||
if (seconds % 60 === 0) return t('taskSchedule.unit.minute', { count: seconds / 60 });
|
||||
return t('taskSchedule.unit.second', { count: seconds });
|
||||
};
|
||||
|
||||
interface TaskTriggerTagProps {
|
||||
automationMode?: 'heartbeat' | 'schedule' | null;
|
||||
heartbeatInterval?: number | null;
|
||||
mode?: 'inline' | 'tag';
|
||||
schedulePattern?: string | null;
|
||||
@@ -19,63 +20,41 @@ interface TaskTriggerTagProps {
|
||||
}
|
||||
|
||||
const TaskTriggerTag = memo<TaskTriggerTagProps>(
|
||||
({ automationMode, heartbeatInterval, mode = 'tag', schedulePattern, scheduleTimezone }) => {
|
||||
const { t, i18n } = useTranslation('chat');
|
||||
const data = useMemo<
|
||||
| {
|
||||
primary: string;
|
||||
secondary?: string;
|
||||
tooltip: string;
|
||||
}
|
||||
| undefined
|
||||
>(() => {
|
||||
// automationMode is the source of truth — DB may carry stale fields from
|
||||
// a previous mode (e.g. a heartbeat task that was once on a schedule).
|
||||
if (automationMode === 'schedule' && schedulePattern) {
|
||||
const primary = formatScheduleDescription(schedulePattern, t);
|
||||
const tzName = scheduleTimezone
|
||||
? formatTimezoneName(scheduleTimezone, i18n.language)
|
||||
: undefined;
|
||||
({ heartbeatInterval, mode = 'tag', schedulePattern, scheduleTimezone }) => {
|
||||
const { t } = useTranslation('chat');
|
||||
const data = useMemo(() => {
|
||||
if (schedulePattern) {
|
||||
const timezone = scheduleTimezone ? ` (${scheduleTimezone})` : '';
|
||||
return {
|
||||
primary,
|
||||
secondary: tzName,
|
||||
tooltip: tzName ? `${primary} · ${tzName}` : primary,
|
||||
tooltip: t('taskSchedule.tag.schedule', {
|
||||
schedule: schedulePattern,
|
||||
timezone,
|
||||
}),
|
||||
text: `${schedulePattern} ${timezone}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (automationMode === 'heartbeat' && heartbeatInterval && heartbeatInterval > 0) {
|
||||
if (heartbeatInterval && heartbeatInterval > 0) {
|
||||
const every = t('taskSchedule.tag.every', {
|
||||
interval: formatIntervalLabel(heartbeatInterval, t),
|
||||
interval: formatInterval(heartbeatInterval, t),
|
||||
});
|
||||
return {
|
||||
primary: every,
|
||||
tooltip: t('taskSchedule.tag.heartbeat', { every }),
|
||||
text: every,
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, [automationMode, heartbeatInterval, schedulePattern, scheduleTimezone, t, i18n.language]);
|
||||
}, [heartbeatInterval, schedulePattern, scheduleTimezone, t]);
|
||||
|
||||
if (mode === 'inline') {
|
||||
return (
|
||||
<Tooltip title={data?.tooltip}>
|
||||
<Flexbox horizontal align="flex-start" gap={10}>
|
||||
<Icon
|
||||
color={cssVar.colorTextDescription}
|
||||
icon={ClockIcon}
|
||||
size={16}
|
||||
style={{ marginTop: 2 }}
|
||||
/>
|
||||
<Flexbox gap={2}>
|
||||
<Text type={data ? undefined : 'secondary'} weight={data ? 500 : undefined}>
|
||||
{data?.primary ?? t('taskSchedule.tag.add')}
|
||||
</Text>
|
||||
{data?.secondary && (
|
||||
<Text style={{ color: cssVar.colorTextDescription, fontSize: 11 }}>
|
||||
{data.secondary}
|
||||
</Text>
|
||||
)}
|
||||
</Flexbox>
|
||||
<Flexbox horizontal align="center" gap={10}>
|
||||
<Icon color={cssVar.colorTextDescription} icon={ClockIcon} size={16} />
|
||||
<Text type={data ? undefined : 'secondary'} weight={data ? 500 : undefined}>
|
||||
{data?.text ?? t('taskSchedule.tag.add')}
|
||||
</Text>
|
||||
</Flexbox>
|
||||
</Tooltip>
|
||||
);
|
||||
@@ -83,8 +62,6 @@ const TaskTriggerTag = memo<TaskTriggerTagProps>(
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
// Pill height (24px) only fits one line — drop the timezone here; the
|
||||
// tooltip surfaces it on hover.
|
||||
return (
|
||||
<Tooltip title={data.tooltip}>
|
||||
<Block
|
||||
@@ -98,7 +75,7 @@ const TaskTriggerTag = memo<TaskTriggerTagProps>(
|
||||
>
|
||||
<Icon color={cssVar.colorTextDescription} icon={ClockIcon} size={16} />
|
||||
<Text fontSize={12} type={'secondary'}>
|
||||
{data.primary}
|
||||
{data.text}
|
||||
</Text>
|
||||
</Block>
|
||||
</Tooltip>
|
||||
|
||||
@@ -28,18 +28,13 @@ interface TaskItemContextMenu {
|
||||
onContextMenu: () => void;
|
||||
}
|
||||
|
||||
export interface TaskContextMenuTarget {
|
||||
interface TaskContextMenuTarget {
|
||||
identifier: string;
|
||||
priority?: number | null;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface TaskContextMenuActions {
|
||||
buildItems: (task: TaskContextMenuTarget) => GenericItemType[];
|
||||
installKeyboardHandlers: (task: TaskContextMenuTarget) => void;
|
||||
}
|
||||
|
||||
export const useTaskContextMenuActions = (): TaskContextMenuActions => {
|
||||
export const useTaskItemContextMenu = (task: TaskContextMenuTarget): TaskItemContextMenu => {
|
||||
const { t } = useTranslation(['chat', 'common']);
|
||||
const { modal, message } = App.useApp();
|
||||
const appOrigin = useAppOrigin();
|
||||
@@ -49,215 +44,216 @@ export const useTaskContextMenuActions = (): TaskContextMenuActions => {
|
||||
const refreshTaskList = useTaskStore((s) => s.refreshTaskList);
|
||||
const deleteTask = useTaskStore((s) => s.deleteTask);
|
||||
|
||||
const currentStatus = task.status as TaskStatus;
|
||||
const currentPriority = task.priority ?? 0;
|
||||
|
||||
const triggerDelete = useCallback(() => {
|
||||
modal.confirm({
|
||||
centered: true,
|
||||
content: t('taskDetail.deleteConfirm.content'),
|
||||
okButtonProps: { danger: true },
|
||||
okText: t('taskDetail.deleteConfirm.ok'),
|
||||
onOk: async () => {
|
||||
await deleteTask(task.identifier);
|
||||
},
|
||||
title: t('taskDetail.deleteConfirm.title'),
|
||||
type: 'error',
|
||||
});
|
||||
}, [modal, t, deleteTask, task.identifier]);
|
||||
|
||||
const items = useMemo<GenericItemType[]>(() => {
|
||||
const statusChildren = USER_SELECTABLE_STATUSES.map((status, index) => {
|
||||
const meta = STATUS_META[status];
|
||||
const isCurrent = status === currentStatus;
|
||||
return {
|
||||
extra: renderMenuExtra(String(index + 1), isCurrent),
|
||||
icon: <Icon color={meta.color} icon={meta.icon} />,
|
||||
key: `status-${status}`,
|
||||
label: t(`taskDetail.status.${status}`, { defaultValue: meta.label }),
|
||||
onClick: ({ domEvent }) => {
|
||||
domEvent.stopPropagation();
|
||||
if (status === currentStatus) return;
|
||||
void updateTaskStatus(task.identifier, status);
|
||||
},
|
||||
} as GenericItemType;
|
||||
});
|
||||
|
||||
const priorityChildren = PRIORITY_LEVELS.map((level, index) => {
|
||||
const meta = PRIORITY_META[level];
|
||||
const PriorityIcon = meta.icon;
|
||||
const isUrgent = level === 1;
|
||||
const isCurrent = level === currentPriority;
|
||||
return {
|
||||
extra: renderMenuExtra(String(index + 1), isCurrent),
|
||||
icon: (
|
||||
<PriorityIcon color={isUrgent ? cssVar.orange : cssVar.colorTextSecondary} size={16} />
|
||||
),
|
||||
key: `priority-${level}`,
|
||||
label: t(`taskDetail.${meta.labelKey}` as never, { defaultValue: meta.label }),
|
||||
onClick: async ({ domEvent }) => {
|
||||
domEvent.stopPropagation();
|
||||
if (level === currentPriority) return;
|
||||
await updateTask(task.identifier, { priority: level });
|
||||
await refreshTaskList();
|
||||
},
|
||||
} as GenericItemType;
|
||||
});
|
||||
|
||||
const taskUrl = `${appOrigin}/task/${task.identifier}`;
|
||||
|
||||
return [
|
||||
{
|
||||
children: statusChildren,
|
||||
icon: <Icon icon={CircleDashedIcon} />,
|
||||
key: 'status',
|
||||
label: t('taskList.contextMenu.status'),
|
||||
onTitleMouseEnter: () => {
|
||||
activeSubmenuRef.current = 'status';
|
||||
},
|
||||
},
|
||||
{
|
||||
children: priorityChildren,
|
||||
icon: <Icon icon={BarChart3Icon} />,
|
||||
key: 'priority',
|
||||
label: t('taskList.contextMenu.priority'),
|
||||
onTitleMouseEnter: () => {
|
||||
activeSubmenuRef.current = 'priority';
|
||||
},
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
icon: <Icon icon={CopyIcon} />,
|
||||
key: 'copyId',
|
||||
label: t('taskList.contextMenu.copyId'),
|
||||
onClick: async ({ domEvent }) => {
|
||||
domEvent.stopPropagation();
|
||||
await copyToClipboard(task.identifier);
|
||||
message.success(t('taskList.contextMenu.copyIdSuccess'));
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: <Icon icon={LinkIcon} />,
|
||||
key: 'copyLink',
|
||||
label: t('taskList.contextMenu.copyLink'),
|
||||
onClick: async ({ domEvent }) => {
|
||||
domEvent.stopPropagation();
|
||||
await copyToClipboard(taskUrl);
|
||||
message.success(t('taskList.contextMenu.copyLinkSuccess'));
|
||||
},
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
danger: true,
|
||||
extra: (
|
||||
<Hotkey keys={combineKeys([KeyEnum.Mod, KeyEnum.Backspace])} variant={'borderless'} />
|
||||
),
|
||||
icon: <Icon icon={Trash2Icon} />,
|
||||
key: 'delete',
|
||||
label: t('delete', { ns: 'common' }),
|
||||
onClick: ({ domEvent }) => {
|
||||
domEvent.stopPropagation();
|
||||
triggerDelete();
|
||||
},
|
||||
},
|
||||
];
|
||||
}, [
|
||||
task.identifier,
|
||||
currentStatus,
|
||||
currentPriority,
|
||||
appOrigin,
|
||||
t,
|
||||
message,
|
||||
updateTaskStatus,
|
||||
updateTask,
|
||||
refreshTaskList,
|
||||
triggerDelete,
|
||||
]);
|
||||
|
||||
const cleanupRef = useRef<(() => void) | null>(null);
|
||||
const activeSubmenuRef = useRef<'status' | 'priority'>('status');
|
||||
|
||||
useEffect(() => () => cleanupRef.current?.(), []);
|
||||
const onContextMenu = useCallback(() => {
|
||||
cleanupRef.current?.();
|
||||
activeSubmenuRef.current = 'status';
|
||||
|
||||
return useMemo<TaskContextMenuActions>(() => {
|
||||
const triggerDelete = (identifier: string) => {
|
||||
modal.confirm({
|
||||
centered: true,
|
||||
content: t('taskDetail.deleteConfirm.content'),
|
||||
okButtonProps: { danger: true },
|
||||
okText: t('taskDetail.deleteConfirm.ok'),
|
||||
onOk: async () => {
|
||||
await deleteTask(identifier);
|
||||
},
|
||||
title: t('taskDetail.deleteConfirm.title'),
|
||||
type: 'error',
|
||||
});
|
||||
const cleanup = () => {
|
||||
document.removeEventListener('keydown', keyHandler, true);
|
||||
window.removeEventListener('pointerdown', pointerHandler, true);
|
||||
window.removeEventListener('contextmenu', contextHandler, true);
|
||||
cleanupRef.current = null;
|
||||
};
|
||||
|
||||
const buildItems = (task: TaskContextMenuTarget): GenericItemType[] => {
|
||||
const currentStatus = task.status as TaskStatus;
|
||||
const currentPriority = task.priority ?? 0;
|
||||
const keyHandler = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
const statusChildren = USER_SELECTABLE_STATUSES.map((status, index) => {
|
||||
const meta = STATUS_META[status];
|
||||
const isCurrent = status === currentStatus;
|
||||
return {
|
||||
extra: renderMenuExtra(String(index + 1), isCurrent),
|
||||
icon: <Icon color={meta.color} icon={meta.icon} />,
|
||||
key: `status-${status}`,
|
||||
label: t(`taskDetail.status.${status}`, { defaultValue: meta.label }),
|
||||
onClick: ({ domEvent }) => {
|
||||
domEvent.stopPropagation();
|
||||
if (status === currentStatus) return;
|
||||
void updateTaskStatus(task.identifier, status);
|
||||
},
|
||||
} as GenericItemType;
|
||||
});
|
||||
|
||||
const priorityChildren = PRIORITY_LEVELS.map((level, index) => {
|
||||
const meta = PRIORITY_META[level];
|
||||
const PriorityIcon = meta.icon;
|
||||
const isUrgent = level === 1;
|
||||
const isCurrent = level === currentPriority;
|
||||
return {
|
||||
extra: renderMenuExtra(String(index + 1), isCurrent),
|
||||
icon: (
|
||||
<PriorityIcon color={isUrgent ? cssVar.orange : cssVar.colorTextSecondary} size={16} />
|
||||
),
|
||||
key: `priority-${level}`,
|
||||
label: t(`taskDetail.${meta.labelKey}` as never, { defaultValue: meta.label }),
|
||||
onClick: async ({ domEvent }) => {
|
||||
domEvent.stopPropagation();
|
||||
if (level === currentPriority) return;
|
||||
await updateTask(task.identifier, { priority: level });
|
||||
await refreshTaskList();
|
||||
},
|
||||
} as GenericItemType;
|
||||
});
|
||||
|
||||
const taskUrl = `${appOrigin}/task/${task.identifier}`;
|
||||
|
||||
return [
|
||||
{
|
||||
children: statusChildren,
|
||||
icon: <Icon icon={CircleDashedIcon} />,
|
||||
key: 'status',
|
||||
label: t('taskList.contextMenu.status'),
|
||||
onTitleMouseEnter: () => {
|
||||
activeSubmenuRef.current = 'status';
|
||||
},
|
||||
},
|
||||
{
|
||||
children: priorityChildren,
|
||||
icon: <Icon icon={BarChart3Icon} />,
|
||||
key: 'priority',
|
||||
label: t('taskList.contextMenu.priority'),
|
||||
onTitleMouseEnter: () => {
|
||||
activeSubmenuRef.current = 'priority';
|
||||
},
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
icon: <Icon icon={CopyIcon} />,
|
||||
key: 'copyId',
|
||||
label: t('taskList.contextMenu.copyId'),
|
||||
onClick: async ({ domEvent }) => {
|
||||
domEvent.stopPropagation();
|
||||
await copyToClipboard(task.identifier);
|
||||
message.success(t('taskList.contextMenu.copyIdSuccess'));
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: <Icon icon={LinkIcon} />,
|
||||
key: 'copyLink',
|
||||
label: t('taskList.contextMenu.copyLink'),
|
||||
onClick: async ({ domEvent }) => {
|
||||
domEvent.stopPropagation();
|
||||
await copyToClipboard(taskUrl);
|
||||
message.success(t('taskList.contextMenu.copyLinkSuccess'));
|
||||
},
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
danger: true,
|
||||
extra: (
|
||||
<Hotkey keys={combineKeys([KeyEnum.Mod, KeyEnum.Backspace])} variant={'borderless'} />
|
||||
),
|
||||
icon: <Icon icon={Trash2Icon} />,
|
||||
key: 'delete',
|
||||
label: t('delete', { ns: 'common' }),
|
||||
onClick: ({ domEvent }) => {
|
||||
domEvent.stopPropagation();
|
||||
triggerDelete(task.identifier);
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const installKeyboardHandlers = (task: TaskContextMenuTarget) => {
|
||||
cleanupRef.current?.();
|
||||
activeSubmenuRef.current = 'status';
|
||||
|
||||
const currentStatus = task.status as TaskStatus;
|
||||
const currentPriority = task.priority ?? 0;
|
||||
|
||||
const cleanup = () => {
|
||||
document.removeEventListener('keydown', keyHandler, true);
|
||||
window.removeEventListener('pointerdown', pointerHandler, true);
|
||||
window.removeEventListener('contextmenu', contextHandler, true);
|
||||
cleanupRef.current = null;
|
||||
};
|
||||
|
||||
const keyHandler = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === 'Backspace') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
closeContextMenu();
|
||||
cleanup();
|
||||
triggerDelete(task.identifier);
|
||||
return;
|
||||
}
|
||||
|
||||
const num = Number.parseInt(event.key, 10);
|
||||
if (Number.isNaN(num)) return;
|
||||
const idx = num - 1;
|
||||
|
||||
// Route 1–N to whichever submenu is currently focused (hover defaults to status).
|
||||
if (activeSubmenuRef.current === 'priority') {
|
||||
if (idx < 0 || idx >= PRIORITY_LEVELS.length) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const nextLevel = PRIORITY_LEVELS[idx];
|
||||
if (nextLevel !== currentPriority) {
|
||||
void (async () => {
|
||||
await updateTask(task.identifier, { priority: nextLevel });
|
||||
await refreshTaskList();
|
||||
})();
|
||||
}
|
||||
closeContextMenu();
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
if (idx < 0 || idx >= USER_SELECTABLE_STATUSES.length) return;
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === 'Backspace') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const nextStatus = USER_SELECTABLE_STATUSES[idx];
|
||||
if (nextStatus !== currentStatus) {
|
||||
void updateTaskStatus(task.identifier, nextStatus);
|
||||
closeContextMenu();
|
||||
cleanup();
|
||||
triggerDelete();
|
||||
return;
|
||||
}
|
||||
|
||||
const num = Number.parseInt(event.key, 10);
|
||||
if (Number.isNaN(num)) return;
|
||||
const idx = num - 1;
|
||||
|
||||
// Route 1–N to whichever submenu is currently focused (hover defaults to status).
|
||||
if (activeSubmenuRef.current === 'priority') {
|
||||
if (idx < 0 || idx >= PRIORITY_LEVELS.length) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const nextLevel = PRIORITY_LEVELS[idx];
|
||||
if (nextLevel !== currentPriority) {
|
||||
void (async () => {
|
||||
await updateTask(task.identifier, { priority: nextLevel });
|
||||
await refreshTaskList();
|
||||
})();
|
||||
}
|
||||
closeContextMenu();
|
||||
cleanup();
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const pointerHandler = () => {
|
||||
cleanup();
|
||||
};
|
||||
|
||||
const contextHandler = () => {
|
||||
cleanup();
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', keyHandler, true);
|
||||
window.addEventListener('pointerdown', pointerHandler, true);
|
||||
window.addEventListener('contextmenu', contextHandler, true);
|
||||
|
||||
cleanupRef.current = cleanup;
|
||||
if (idx < 0 || idx >= USER_SELECTABLE_STATUSES.length) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const nextStatus = USER_SELECTABLE_STATUSES[idx];
|
||||
if (nextStatus !== currentStatus) {
|
||||
void updateTaskStatus(task.identifier, nextStatus);
|
||||
}
|
||||
closeContextMenu();
|
||||
cleanup();
|
||||
};
|
||||
|
||||
return { buildItems, installKeyboardHandlers };
|
||||
}, [modal, message, t, appOrigin, updateTaskStatus, updateTask, refreshTaskList, deleteTask]);
|
||||
};
|
||||
const pointerHandler = () => {
|
||||
cleanup();
|
||||
};
|
||||
|
||||
const contextHandler = () => {
|
||||
cleanup();
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', keyHandler, true);
|
||||
window.addEventListener('pointerdown', pointerHandler, true);
|
||||
window.addEventListener('contextmenu', contextHandler, true);
|
||||
|
||||
cleanupRef.current = cleanup;
|
||||
}, [
|
||||
task.identifier,
|
||||
currentStatus,
|
||||
currentPriority,
|
||||
updateTaskStatus,
|
||||
updateTask,
|
||||
refreshTaskList,
|
||||
triggerDelete,
|
||||
]);
|
||||
|
||||
useEffect(() => () => cleanupRef.current?.(), []);
|
||||
|
||||
export const useTaskItemContextMenu = (task: TaskContextMenuTarget): TaskItemContextMenu => {
|
||||
const { buildItems, installKeyboardHandlers } = useTaskContextMenuActions();
|
||||
const items = useMemo(
|
||||
() => buildItems(task),
|
||||
[buildItems, task.identifier, task.status, task.priority],
|
||||
);
|
||||
const onContextMenu = useCallback(
|
||||
() => installKeyboardHandlers(task),
|
||||
[installKeyboardHandlers, task.identifier, task.status, task.priority],
|
||||
);
|
||||
return { items, onContextMenu };
|
||||
};
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import { memo, type SVGProps } from 'react';
|
||||
|
||||
interface AccordionArrowIconProps extends Omit<SVGProps<SVGSVGElement>, 'fill'> {
|
||||
isOpen?: boolean;
|
||||
size?: number | string;
|
||||
}
|
||||
|
||||
const AccordionArrowIcon = memo<AccordionArrowIconProps>(
|
||||
({ isOpen = false, size = 18, style, ...rest }) => (
|
||||
<svg
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
height={size}
|
||||
viewBox="0 0 16 16"
|
||||
width={size}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style={{
|
||||
flex: 'none',
|
||||
lineHeight: 1,
|
||||
transform: isOpen ? 'rotate(90deg)' : undefined,
|
||||
transition: 'transform 200ms',
|
||||
...style,
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
<path d="M7.002 10.624a.5.5 0 01-.752-.432V5.808a.5.5 0 01.752-.432l3.758 2.192a.5.5 0 010 .864l-3.758 2.192z" />
|
||||
</svg>
|
||||
),
|
||||
);
|
||||
|
||||
export default AccordionArrowIcon;
|
||||
@@ -1,299 +0,0 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { type RefObject } from 'react';
|
||||
import { type VListHandle } from 'virtua';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { loadScrollSnapshot, saveScrollSnapshot } from '../utils/scrollSnapshotStore';
|
||||
import { useTopicScrollPersist } from './useTopicScrollPersist';
|
||||
|
||||
interface FakeVList {
|
||||
scrollOffset: number;
|
||||
scrollSize: number;
|
||||
scrollTo: ReturnType<typeof vi.fn>;
|
||||
scrollToIndex: ReturnType<typeof vi.fn>;
|
||||
viewportSize: number;
|
||||
}
|
||||
|
||||
const createFakeVList = (overrides: Partial<FakeVList> = {}): FakeVList => ({
|
||||
scrollOffset: 0,
|
||||
scrollSize: 0,
|
||||
scrollTo: vi.fn(),
|
||||
scrollToIndex: vi.fn(),
|
||||
viewportSize: 800,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const refOf = (handle: FakeVList | null): RefObject<VListHandle | null> => ({
|
||||
current: handle as unknown as VListHandle | null,
|
||||
});
|
||||
|
||||
// One rAF tick in happy-dom is ~16ms; advancing 32ms covers two scheduled
|
||||
// frames reliably without running all queued timers (which would fire the
|
||||
// entire poll loop at once).
|
||||
const advanceFrames = async (frames: number) => {
|
||||
await vi.advanceTimersByTimeAsync(frames * 32);
|
||||
};
|
||||
|
||||
describe('useTopicScrollPersist', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('initial restore', () => {
|
||||
it('falls back to scrollToIndex(last, end) when there is no snapshot', async () => {
|
||||
const handle = createFakeVList({ scrollSize: 5000 });
|
||||
renderHook(() =>
|
||||
useTopicScrollPersist({
|
||||
contextKey: 'main_agt_1_tpc_a',
|
||||
dataSourceLength: 50,
|
||||
virtuaRef: refOf(handle),
|
||||
}),
|
||||
);
|
||||
|
||||
await advanceFrames(2);
|
||||
|
||||
expect(handle.scrollToIndex).toHaveBeenCalledTimes(1);
|
||||
expect(handle.scrollToIndex).toHaveBeenCalledWith(49, { align: 'end' });
|
||||
expect(handle.scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to scrollToIndex(last, end) when snapshot.atBottom is true', async () => {
|
||||
saveScrollSnapshot('main_agt_1_tpc_a', {
|
||||
atBottom: true,
|
||||
offset: 9999,
|
||||
savedAt: Date.now(),
|
||||
});
|
||||
const handle = createFakeVList({ scrollSize: 5000 });
|
||||
renderHook(() =>
|
||||
useTopicScrollPersist({
|
||||
contextKey: 'main_agt_1_tpc_a',
|
||||
dataSourceLength: 50,
|
||||
virtuaRef: refOf(handle),
|
||||
}),
|
||||
);
|
||||
|
||||
await advanceFrames(2);
|
||||
|
||||
expect(handle.scrollToIndex).toHaveBeenCalledWith(49, { align: 'end' });
|
||||
expect(handle.scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not call scrollTo immediately when virtua scrollSize is too small', async () => {
|
||||
saveScrollSnapshot('main_agt_1_tpc_a', {
|
||||
atBottom: false,
|
||||
offset: 5000,
|
||||
savedAt: Date.now(),
|
||||
});
|
||||
const handle = createFakeVList({ scrollSize: 1000, viewportSize: 800 });
|
||||
renderHook(() =>
|
||||
useTopicScrollPersist({
|
||||
contextKey: 'main_agt_1_tpc_a',
|
||||
dataSourceLength: 50,
|
||||
virtuaRef: refOf(handle),
|
||||
}),
|
||||
);
|
||||
|
||||
// A few frames in, virtua still hasn't measured items below the fold —
|
||||
// scrollTo would be clamped, so the hook must keep polling instead.
|
||||
await advanceFrames(4);
|
||||
expect(handle.scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls scrollTo with the saved offset once virtua has measured enough', async () => {
|
||||
saveScrollSnapshot('main_agt_1_tpc_a', {
|
||||
atBottom: false,
|
||||
offset: 5000,
|
||||
savedAt: Date.now(),
|
||||
});
|
||||
const handle = createFakeVList({ scrollSize: 1000, viewportSize: 800 });
|
||||
renderHook(() =>
|
||||
useTopicScrollPersist({
|
||||
contextKey: 'main_agt_1_tpc_a',
|
||||
dataSourceLength: 50,
|
||||
virtuaRef: refOf(handle),
|
||||
}),
|
||||
);
|
||||
|
||||
await advanceFrames(3);
|
||||
expect(handle.scrollTo).not.toHaveBeenCalled();
|
||||
|
||||
// Simulate virtua finishing layout — now scrollSize is big enough to
|
||||
// accommodate target + viewport.
|
||||
handle.scrollSize = 6000;
|
||||
await advanceFrames(3);
|
||||
|
||||
expect(handle.scrollTo).toHaveBeenCalledTimes(1);
|
||||
expect(handle.scrollTo).toHaveBeenCalledWith(5000);
|
||||
});
|
||||
|
||||
it('gives up polling after the cap and calls scrollTo anyway', async () => {
|
||||
saveScrollSnapshot('main_agt_1_tpc_a', {
|
||||
atBottom: false,
|
||||
offset: 999_999,
|
||||
savedAt: Date.now(),
|
||||
});
|
||||
const handle = createFakeVList({ scrollSize: 1000, viewportSize: 800 });
|
||||
renderHook(() =>
|
||||
useTopicScrollPersist({
|
||||
contextKey: 'main_agt_1_tpc_a',
|
||||
dataSourceLength: 50,
|
||||
virtuaRef: refOf(handle),
|
||||
}),
|
||||
);
|
||||
|
||||
// 30-frame cap + a few extra for the release rAFs.
|
||||
await advanceFrames(40);
|
||||
|
||||
expect(handle.scrollTo).toHaveBeenCalledTimes(1);
|
||||
expect(handle.scrollTo).toHaveBeenCalledWith(999_999);
|
||||
});
|
||||
|
||||
it('converges the snapshot to the actual landing position after capping out', async () => {
|
||||
saveScrollSnapshot('main_agt_1_tpc_a', {
|
||||
atBottom: false,
|
||||
offset: 999_999,
|
||||
savedAt: Date.now() - 60_000,
|
||||
});
|
||||
const handle = createFakeVList({ scrollSize: 1500, viewportSize: 800 });
|
||||
// Simulate virtua clamping the request to the actual scrollable range.
|
||||
handle.scrollTo.mockImplementation((offset: number) => {
|
||||
handle.scrollOffset = Math.min(offset, handle.scrollSize - handle.viewportSize);
|
||||
});
|
||||
|
||||
renderHook(() =>
|
||||
useTopicScrollPersist({
|
||||
contextKey: 'main_agt_1_tpc_a',
|
||||
dataSourceLength: 50,
|
||||
virtuaRef: refOf(handle),
|
||||
}),
|
||||
);
|
||||
|
||||
await advanceFrames(40);
|
||||
|
||||
// 1500 - 800 = 700; this is what virtua actually landed on.
|
||||
const persisted = loadScrollSnapshot('main_agt_1_tpc_a');
|
||||
expect(persisted?.offset).toBe(700);
|
||||
// 1500 - 700 - 800 = 0 ≤ 300 → at bottom now that we've clamped.
|
||||
expect(persisted?.atBottom).toBe(true);
|
||||
});
|
||||
|
||||
it('does not rewrite the snapshot when the saved offset was reached without capping', async () => {
|
||||
const originalSavedAt = Date.now() - 60_000;
|
||||
saveScrollSnapshot('main_agt_1_tpc_a', {
|
||||
atBottom: false,
|
||||
offset: 5000,
|
||||
savedAt: originalSavedAt,
|
||||
});
|
||||
const handle = createFakeVList({ scrollSize: 6000, viewportSize: 800 });
|
||||
handle.scrollTo.mockImplementation((offset: number) => {
|
||||
handle.scrollOffset = offset;
|
||||
});
|
||||
|
||||
renderHook(() =>
|
||||
useTopicScrollPersist({
|
||||
contextKey: 'main_agt_1_tpc_a',
|
||||
dataSourceLength: 50,
|
||||
virtuaRef: refOf(handle),
|
||||
}),
|
||||
);
|
||||
|
||||
await advanceFrames(20);
|
||||
|
||||
// Snapshot is untouched — same offset and same savedAt.
|
||||
const persisted = loadScrollSnapshot('main_agt_1_tpc_a');
|
||||
expect(persisted?.offset).toBe(5000);
|
||||
expect(persisted?.savedAt).toBe(originalSavedAt);
|
||||
});
|
||||
|
||||
it('skips the entire restore until dataSourceLength becomes non-zero', async () => {
|
||||
saveScrollSnapshot('main_agt_1_tpc_a', {
|
||||
atBottom: false,
|
||||
offset: 5000,
|
||||
savedAt: Date.now(),
|
||||
});
|
||||
const handle = createFakeVList({ scrollSize: 6000 });
|
||||
const { rerender } = renderHook(
|
||||
({ length }: { length: number }) =>
|
||||
useTopicScrollPersist({
|
||||
contextKey: 'main_agt_1_tpc_a',
|
||||
dataSourceLength: length,
|
||||
virtuaRef: refOf(handle),
|
||||
}),
|
||||
{ initialProps: { length: 0 } },
|
||||
);
|
||||
|
||||
await advanceFrames(3);
|
||||
expect(handle.scrollTo).not.toHaveBeenCalled();
|
||||
expect(handle.scrollToIndex).not.toHaveBeenCalled();
|
||||
|
||||
rerender({ length: 50 });
|
||||
await advanceFrames(3);
|
||||
|
||||
expect(handle.scrollTo).toHaveBeenCalledWith(5000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recordScroll suppression during restore', () => {
|
||||
it('drops recordScroll calls fired before the restore lands', async () => {
|
||||
const startingSnapshot = { atBottom: false, offset: 5000, savedAt: Date.now() };
|
||||
saveScrollSnapshot('main_agt_1_tpc_a', startingSnapshot);
|
||||
const handle = createFakeVList({ scrollSize: 6000 });
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useTopicScrollPersist({
|
||||
contextKey: 'main_agt_1_tpc_a',
|
||||
dataSourceLength: 50,
|
||||
virtuaRef: refOf(handle),
|
||||
}),
|
||||
);
|
||||
|
||||
// Simulate the onScroll volley triggered by the programmatic scrollTo
|
||||
// landing at offset 0 (because virtua clamped — what used to corrupt
|
||||
// the snapshot before the fix).
|
||||
act(() => {
|
||||
result.current.recordScroll(0, true);
|
||||
});
|
||||
|
||||
// Let the restore + flush window pass.
|
||||
await advanceFrames(20);
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
|
||||
expect(loadScrollSnapshot('main_agt_1_tpc_a')?.offset).toBe(5000);
|
||||
});
|
||||
|
||||
it('resumes recordScroll after the restore settles', async () => {
|
||||
saveScrollSnapshot('main_agt_1_tpc_a', {
|
||||
atBottom: false,
|
||||
offset: 5000,
|
||||
savedAt: Date.now(),
|
||||
});
|
||||
const handle = createFakeVList({ scrollSize: 6000 });
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useTopicScrollPersist({
|
||||
contextKey: 'main_agt_1_tpc_a',
|
||||
dataSourceLength: 50,
|
||||
virtuaRef: refOf(handle),
|
||||
}),
|
||||
);
|
||||
|
||||
// Wait for restore to land + guard release (2 rAFs after scrollTo).
|
||||
await advanceFrames(20);
|
||||
|
||||
act(() => {
|
||||
result.current.recordScroll(7777, false);
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
|
||||
expect(loadScrollSnapshot('main_agt_1_tpc_a')?.offset).toBe(7777);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,6 @@ import type { RefObject } from 'react';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import type { VListHandle } from 'virtua';
|
||||
|
||||
import { AT_BOTTOM_THRESHOLD } from '../components/AutoScroll/const';
|
||||
import {
|
||||
isDraftPromotionKey,
|
||||
loadScrollSnapshot,
|
||||
@@ -12,9 +11,6 @@ import {
|
||||
} from '../utils/scrollSnapshotStore';
|
||||
|
||||
const FLUSH_THROTTLE_MS = 200;
|
||||
// Cap polling for virtua's scrollSize to settle so we don't loop forever when
|
||||
// the saved offset is unreachable (e.g. messages were trimmed since save).
|
||||
const RESTORE_MAX_FRAMES = 30;
|
||||
|
||||
interface PendingWrite {
|
||||
atBottom: boolean;
|
||||
@@ -31,11 +27,9 @@ interface UseTopicScrollPersistOptions {
|
||||
/**
|
||||
* Persists per-topic chat scroll position to localStorage.
|
||||
*
|
||||
* In practice `ChatList` shows a SkeletonList while `messagesInit` is false,
|
||||
* so VirtualizedList unmounts on every topic switch and this hook is
|
||||
* re-initialized fresh. The contextKey-change branch below still exists for
|
||||
* the in-place draft → real-id promotion path, where the same instance
|
||||
* survives the key change.
|
||||
* The Provider does not remount on topic switch — the same VirtualizedList
|
||||
* instance handles every topic, so we react to `contextKey` changes ourselves
|
||||
* to flush the previous topic and restore the next.
|
||||
*/
|
||||
export const useTopicScrollPersist = ({
|
||||
contextKey,
|
||||
@@ -49,11 +43,6 @@ export const useTopicScrollPersist = ({
|
||||
const prevContextKeyRef = useRef(contextKey);
|
||||
const dataSourceLengthRef = useRef(dataSourceLength);
|
||||
dataSourceLengthRef.current = dataSourceLength;
|
||||
// True from the moment a restore starts until the resulting onScroll has
|
||||
// settled. Without this guard, the programmatic scroll would feed back into
|
||||
// recordScroll and overwrite the snapshot — typically with offset 0 when
|
||||
// virtua clamps the target, locking the user at the top on every revisit.
|
||||
const restoringRef = useRef(false);
|
||||
|
||||
const flushNow = useCallback(() => {
|
||||
if (flushTimerRef.current) {
|
||||
@@ -72,7 +61,6 @@ export const useTopicScrollPersist = ({
|
||||
|
||||
const recordScroll = useCallback(
|
||||
(offset: number, atBottom: boolean) => {
|
||||
if (restoringRef.current) return;
|
||||
pendingWriteRef.current = { atBottom, key: contextKey, offset };
|
||||
if (flushTimerRef.current) return;
|
||||
flushTimerRef.current = setTimeout(() => {
|
||||
@@ -117,66 +105,20 @@ export const useTopicScrollPersist = ({
|
||||
if (!virtuaRef.current || dataSourceLength === 0) return;
|
||||
|
||||
needsRestoreRef.current = false;
|
||||
restoringRef.current = true;
|
||||
|
||||
// After two rAFs the programmatic scroll's onScroll volley has flushed,
|
||||
// so we can re-enable recording. When `convergeSnapshot` is set, the
|
||||
// target was unreachable — persist the actual landing position so the
|
||||
// snapshot self-heals and future revisits don't burn the polling budget.
|
||||
const finalize = (convergeSnapshot: boolean) => {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
if (convergeSnapshot) {
|
||||
const ref = virtuaRef.current;
|
||||
if (ref) {
|
||||
const isAtBottom =
|
||||
ref.scrollSize - ref.scrollOffset - ref.viewportSize <= AT_BOTTOM_THRESHOLD;
|
||||
pendingWriteRef.current = {
|
||||
atBottom: isAtBottom,
|
||||
key: contextKey,
|
||||
offset: ref.scrollOffset,
|
||||
};
|
||||
flushNow();
|
||||
}
|
||||
}
|
||||
restoringRef.current = false;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const snapshot = loadScrollSnapshot(contextKey);
|
||||
const targetOffset = snapshot && !snapshot.atBottom ? snapshot.offset : null;
|
||||
|
||||
if (targetOffset === null) {
|
||||
virtuaRef.current.scrollToIndex(dataSourceLength - 1, { align: 'end' });
|
||||
finalize(false);
|
||||
if (snapshot && !snapshot.atBottom) {
|
||||
// virtua needs item sizes measured before scrollTo lands at the right
|
||||
// pixel — defer one frame so the just-mounted items have layout.
|
||||
requestAnimationFrame(() => {
|
||||
virtuaRef.current?.scrollTo(snapshot.offset);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for virtua to measure enough items so scrollTo(targetOffset)
|
||||
// doesn't get clamped against the still-incomplete scrollSize of the
|
||||
// freshly-mounted VList. A single rAF isn't enough — only the viewport-
|
||||
// visible items have laid out by then, and ResizeObserver hasn't reported
|
||||
// below-the-fold heights yet.
|
||||
let attempts = 0;
|
||||
const tryScroll = () => {
|
||||
const ref = virtuaRef.current;
|
||||
if (!ref) {
|
||||
restoringRef.current = false;
|
||||
return;
|
||||
}
|
||||
const required = targetOffset + ref.viewportSize;
|
||||
const cappedOut = attempts >= RESTORE_MAX_FRAMES;
|
||||
if (ref.scrollSize >= required || cappedOut) {
|
||||
ref.scrollTo(targetOffset);
|
||||
finalize(cappedOut);
|
||||
return;
|
||||
}
|
||||
attempts += 1;
|
||||
requestAnimationFrame(tryScroll);
|
||||
};
|
||||
requestAnimationFrame(tryScroll);
|
||||
}, [contextKey, dataSourceLength, flushNow, virtuaRef]);
|
||||
virtuaRef.current.scrollToIndex(dataSourceLength - 1, { align: 'end' });
|
||||
}, [contextKey, dataSourceLength, virtuaRef]);
|
||||
|
||||
// One-shot housekeeping: drop expired entries and enforce the cap.
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,257 +0,0 @@
|
||||
import type { TaskStatus } from '@lobechat/types';
|
||||
import { Block, Flexbox, Text } from '@lobehub/ui';
|
||||
import { createStaticStyles, cssVar } from 'antd-style';
|
||||
import { ClipboardList } from 'lucide-react';
|
||||
import { memo, useMemo } from 'react';
|
||||
|
||||
import TaskStatusIcon from '@/features/AgentTasks/features/TaskStatusIcon';
|
||||
|
||||
import { type MarkdownElementProps } from '../type';
|
||||
import { useTaskCardScope } from './context';
|
||||
import { type ParsedTaskContent, parseTaskContent } from './parseTaskContent';
|
||||
|
||||
const KNOWN_STATUSES: TaskStatus[] = [
|
||||
'backlog',
|
||||
'canceled',
|
||||
'completed',
|
||||
'failed',
|
||||
'paused',
|
||||
'running',
|
||||
'scheduled',
|
||||
];
|
||||
|
||||
const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
container: css`
|
||||
overflow: hidden;
|
||||
border-radius: 12px;
|
||||
box-shadow: ${cssVar.boxShadowTertiary};
|
||||
`,
|
||||
divider: css`
|
||||
inline-size: 100%;
|
||||
block-size: 1px;
|
||||
background: ${cssVar.colorSplit};
|
||||
`,
|
||||
fallback: css`
|
||||
overflow: auto;
|
||||
|
||||
padding-block: 12px;
|
||||
padding-inline: 14px;
|
||||
border: 1px dashed ${cssVar.colorBorderSecondary};
|
||||
border-radius: 8px;
|
||||
|
||||
font-family: ${cssVar.fontFamilyCode};
|
||||
font-size: 12px;
|
||||
color: ${cssVar.colorTextSecondary};
|
||||
white-space: pre-wrap;
|
||||
`,
|
||||
fieldKey: css`
|
||||
flex: none;
|
||||
min-inline-size: 64px;
|
||||
color: ${cssVar.colorTextTertiary};
|
||||
`,
|
||||
fieldRow: css`
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
`,
|
||||
fieldValue: css`
|
||||
color: ${cssVar.colorTextSecondary};
|
||||
word-break: break-word;
|
||||
`,
|
||||
headerIcon: css`
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
inline-size: 32px;
|
||||
block-size: 32px;
|
||||
border-radius: 8px;
|
||||
|
||||
color: ${cssVar.colorPrimary};
|
||||
|
||||
background: ${cssVar.colorPrimaryBg};
|
||||
`,
|
||||
identifier: css`
|
||||
flex: none;
|
||||
|
||||
padding-block: 1px;
|
||||
padding-inline: 6px;
|
||||
border-radius: 4px;
|
||||
|
||||
font-family: ${cssVar.fontFamilyCode};
|
||||
font-size: 12px;
|
||||
color: ${cssVar.colorTextSecondary};
|
||||
|
||||
background: ${cssVar.colorFillQuaternary};
|
||||
`,
|
||||
instruction: css`
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: ${cssVar.colorText};
|
||||
white-space: pre-wrap;
|
||||
`,
|
||||
rawList: css`
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
font-family: ${cssVar.fontFamilyCode};
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: ${cssVar.colorTextSecondary};
|
||||
list-style: none;
|
||||
|
||||
li {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
`,
|
||||
section: css`
|
||||
font-size: 12px;
|
||||
color: ${cssVar.colorTextTertiary};
|
||||
|
||||
summary {
|
||||
cursor: pointer;
|
||||
padding-block: 4px;
|
||||
color: ${cssVar.colorTextSecondary};
|
||||
|
||||
&:hover {
|
||||
color: ${cssVar.colorText};
|
||||
}
|
||||
}
|
||||
`,
|
||||
}));
|
||||
|
||||
const isKnownStatus = (status?: string): status is TaskStatus =>
|
||||
!!status && (KNOWN_STATUSES as string[]).includes(status);
|
||||
|
||||
const FieldRow = memo<{ label: string; value?: string }>(({ label, value }) => {
|
||||
if (!value) return null;
|
||||
return (
|
||||
<Flexbox horizontal className={styles.fieldRow} gap={8}>
|
||||
<span className={styles.fieldKey}>{label}</span>
|
||||
<span className={styles.fieldValue}>{value}</span>
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
|
||||
const RawSection = memo<{ items: string[]; label: string }>(({ items, label }) => {
|
||||
if (!items || items.length === 0) return null;
|
||||
return (
|
||||
<details className={styles.section}>
|
||||
<summary>
|
||||
{label} ({items.length})
|
||||
</summary>
|
||||
<ul className={styles.rawList}>
|
||||
{items.map((line, idx) => (
|
||||
<li key={idx}>{line}</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
);
|
||||
});
|
||||
|
||||
interface TaskRenderProps extends MarkdownElementProps {
|
||||
raw?: string;
|
||||
}
|
||||
|
||||
const Render = memo<TaskRenderProps>(({ children }) => {
|
||||
const enabled = useTaskCardScope();
|
||||
const text = typeof children === 'string' ? children : String(children ?? '');
|
||||
const parsed = useMemo<ParsedTaskContent>(() => parseTaskContent(text), [text]);
|
||||
|
||||
if (!enabled) {
|
||||
return <pre className={styles.fallback}>{text}</pre>;
|
||||
}
|
||||
|
||||
const titleText = parsed.name || parsed.identifier || '';
|
||||
|
||||
return (
|
||||
<Block
|
||||
className={styles.container}
|
||||
gap={12}
|
||||
paddingBlock={14}
|
||||
paddingInline={16}
|
||||
variant={'outlined'}
|
||||
>
|
||||
<Flexbox horizontal align={'center'} gap={12}>
|
||||
<span className={styles.headerIcon}>
|
||||
<ClipboardList size={16} />
|
||||
</span>
|
||||
<Flexbox flex={1} gap={4} style={{ minWidth: 0 }}>
|
||||
<Flexbox horizontal align={'center'} gap={8} style={{ minWidth: 0 }}>
|
||||
{parsed.identifier && <span className={styles.identifier}>{parsed.identifier}</span>}
|
||||
<Text ellipsis weight={500}>
|
||||
{titleText}
|
||||
</Text>
|
||||
</Flexbox>
|
||||
{(parsed.status || parsed.priority) && (
|
||||
<Flexbox horizontal align={'center'} gap={8}>
|
||||
{parsed.status && (
|
||||
<Flexbox horizontal align={'center'} gap={4}>
|
||||
{isKnownStatus(parsed.status) ? (
|
||||
<TaskStatusIcon size={14} status={parsed.status} />
|
||||
) : (
|
||||
<span style={{ color: cssVar.colorTextTertiary, fontSize: 12 }}>
|
||||
{parsed.statusIcon}
|
||||
</span>
|
||||
)}
|
||||
<Text fontSize={12} type={'secondary'}>
|
||||
{parsed.status}
|
||||
</Text>
|
||||
</Flexbox>
|
||||
)}
|
||||
{parsed.priority && (
|
||||
<Text fontSize={12} type={'secondary'}>
|
||||
· Priority: {parsed.priority}
|
||||
</Text>
|
||||
)}
|
||||
</Flexbox>
|
||||
)}
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
|
||||
{parsed.instruction && (
|
||||
<>
|
||||
<div className={styles.divider} />
|
||||
<Flexbox gap={4}>
|
||||
<Text fontSize={12} type={'secondary'}>
|
||||
Instruction
|
||||
</Text>
|
||||
<div className={styles.instruction}>{parsed.instruction}</div>
|
||||
</Flexbox>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(parsed.description ||
|
||||
parsed.agent ||
|
||||
parsed.parent ||
|
||||
parsed.topics ||
|
||||
parsed.dependencies ||
|
||||
parsed.review) && (
|
||||
<Flexbox gap={4}>
|
||||
<FieldRow label="Description" value={parsed.description} />
|
||||
<FieldRow label="Agent" value={parsed.agent} />
|
||||
<FieldRow label="Parent" value={parsed.parent} />
|
||||
<FieldRow label="Topics" value={parsed.topics} />
|
||||
<FieldRow label="Dependencies" value={parsed.dependencies} />
|
||||
<FieldRow label="Review" value={parsed.review} />
|
||||
</Flexbox>
|
||||
)}
|
||||
|
||||
{(parsed.subtasks?.length ||
|
||||
parsed.activities?.length ||
|
||||
parsed.workspace?.length ||
|
||||
parsed.reviewRubrics?.length) && (
|
||||
<Flexbox gap={4}>
|
||||
<RawSection items={parsed.subtasks ?? []} label="Subtasks" />
|
||||
<RawSection items={parsed.activities ?? []} label="Activities" />
|
||||
<RawSection items={parsed.workspace ?? []} label="Workspace" />
|
||||
<RawSection items={parsed.reviewRubrics ?? []} label="Review rubrics" />
|
||||
</Flexbox>
|
||||
)}
|
||||
</Block>
|
||||
);
|
||||
});
|
||||
|
||||
Render.displayName = 'TaskRender';
|
||||
|
||||
export default Render;
|
||||
@@ -1,7 +0,0 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
const TaskCardScopeContext = createContext(false);
|
||||
|
||||
export const TaskCardScopeProvider = TaskCardScopeContext.Provider;
|
||||
|
||||
export const useTaskCardScope = () => useContext(TaskCardScopeContext);
|
||||
@@ -1,16 +0,0 @@
|
||||
import { TASK_TAG } from '@/const/plugin';
|
||||
|
||||
import { type MarkdownElement } from '../type';
|
||||
import { remarkTaskBlock } from './remarkTaskBlock';
|
||||
import Component from './Render';
|
||||
|
||||
export { TaskCardScopeProvider } from './context';
|
||||
|
||||
const TaskElement: MarkdownElement = {
|
||||
Component,
|
||||
remarkPlugin: remarkTaskBlock,
|
||||
scope: 'user',
|
||||
tag: TASK_TAG,
|
||||
};
|
||||
|
||||
export default TaskElement;
|
||||
@@ -1,64 +0,0 @@
|
||||
import remarkParse from 'remark-parse';
|
||||
import { unified } from 'unified';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { remarkTaskBlock } from './remarkTaskBlock';
|
||||
|
||||
const runRemark = (markdown: string) => {
|
||||
const processor = unified().use(remarkParse).use(remarkTaskBlock);
|
||||
const tree = processor.parse(markdown);
|
||||
return processor.runSync(tree);
|
||||
};
|
||||
|
||||
describe('task remark integration', () => {
|
||||
it('handles a leading user_feedback block before <task>', () => {
|
||||
const markdown = `<user_feedback>
|
||||
<comment id="c1" time="1m ago">looks good</comment>
|
||||
</user_feedback>
|
||||
|
||||
<task>
|
||||
T-1 demo
|
||||
Status: ⭕ backlog Priority: -
|
||||
Instruction: do it.
|
||||
|
||||
Review: (not configured)
|
||||
</task>`;
|
||||
|
||||
const tree: any = runRemark(markdown);
|
||||
const taskNodes = (tree.children as any[]).filter((c) => c.type === 'taskBlock');
|
||||
expect(taskNodes).toHaveLength(1);
|
||||
const value = taskNodes[0].data.hChildren[0].value as string;
|
||||
expect(value).toContain('T-1 demo');
|
||||
expect(value).toContain('Review: (not configured)');
|
||||
});
|
||||
|
||||
it('does not crash when no <task> tag present', () => {
|
||||
const markdown = `Just a plain message without task xml.`;
|
||||
const tree: any = runRemark(markdown);
|
||||
const taskNodes = (tree.children as any[]).filter((c) => c.type === 'taskBlock');
|
||||
expect(taskNodes).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('captures the entire <task> block content as a single text child', () => {
|
||||
const markdown = `<task>
|
||||
<hint>This tag contains the complete task context. Do NOT call viewTask to re-fetch it.</hint>
|
||||
T-32 写一本《AI Agent 实战指南》
|
||||
Status: ⭕ backlog Priority: -
|
||||
Instruction: 帮我写一本面向开发者的 AI Agent 技术书籍。
|
||||
|
||||
Review: (not configured)
|
||||
</task>`;
|
||||
|
||||
const tree: any = runRemark(markdown);
|
||||
|
||||
const taskNodes = (tree.children as any[]).filter((c) => c.type === 'taskBlock');
|
||||
expect(taskNodes).toHaveLength(1);
|
||||
|
||||
const value = taskNodes[0].data.hChildren[0].value as string;
|
||||
expect(value).toContain('T-32');
|
||||
expect(value).toContain('写一本《AI Agent 实战指南》');
|
||||
expect(value).toContain('Status:');
|
||||
expect(value).toContain('Instruction:');
|
||||
expect(value).toContain('Review: (not configured)');
|
||||
});
|
||||
});
|
||||
@@ -1,89 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { parseTaskContent } from './parseTaskContent';
|
||||
|
||||
describe('parseTaskContent', () => {
|
||||
it('parses the basic task block from the screenshot', () => {
|
||||
const raw = `<hint>This tag contains the complete task context. Do NOT call viewTask to re-fetch it.</hint>
|
||||
T-32 写一本《AI Agent 实战指南》
|
||||
Status: ⭕ backlog Priority: -
|
||||
Instruction: 帮我写一本面向开发者的 AI Agent 技术书籍,涵盖基础概念、主流框架、实战案例。目标 8 章,每章 5000-8000 字。
|
||||
请先制定大纲让我确认后再动笔。
|
||||
Agent: agt_9GOn6nUgGw35
|
||||
|
||||
Review: (not configured)`;
|
||||
|
||||
const parsed = parseTaskContent(raw);
|
||||
|
||||
expect(parsed.identifier).toBe('T-32');
|
||||
expect(parsed.name).toBe('写一本《AI Agent 实战指南》');
|
||||
expect(parsed.statusIcon).toBe('⭕');
|
||||
expect(parsed.status).toBe('backlog');
|
||||
expect(parsed.priority).toBe('-');
|
||||
expect(parsed.instruction).toContain('帮我写一本面向开发者的 AI Agent 技术书籍');
|
||||
expect(parsed.instruction).toContain('请先制定大纲让我确认后再动笔。');
|
||||
expect(parsed.agent).toBe('agt_9GOn6nUgGw35');
|
||||
expect(parsed.review).toBe('(not configured)');
|
||||
});
|
||||
|
||||
it('parses subtasks, activities, and workspace sections', () => {
|
||||
const raw = `T-1 Demo task
|
||||
Status: ● running Priority: high
|
||||
Instruction: do the thing
|
||||
Topics: 2
|
||||
|
||||
Subtasks:
|
||||
T-2 ✓ completed Subtask A
|
||||
T-3 ○ backlog Subtask B ← blocks: T-2
|
||||
|
||||
Workspace (1):
|
||||
📁 plans (doc-1)
|
||||
└── 📄 outline (doc-2) 120 chars
|
||||
|
||||
Activities:
|
||||
💬 1h ago Topic #1 Outline ✓ completed
|
||||
💭 30m ago 👤 user Looks good
|
||||
|
||||
Review: (not configured)`;
|
||||
|
||||
const parsed = parseTaskContent(raw);
|
||||
|
||||
expect(parsed.identifier).toBe('T-1');
|
||||
expect(parsed.name).toBe('Demo task');
|
||||
expect(parsed.subtasks).toHaveLength(2);
|
||||
expect(parsed.subtasks?.[0]).toContain('T-2');
|
||||
expect(parsed.workspace).toHaveLength(2);
|
||||
expect(parsed.activities).toHaveLength(2);
|
||||
expect(parsed.review).toBe('(not configured)');
|
||||
});
|
||||
|
||||
it('captures rubrics when review is configured', () => {
|
||||
const raw = `T-1 Demo
|
||||
Status: ○ backlog Priority: -
|
||||
Instruction: x
|
||||
|
||||
Review (maxIterations: 3):
|
||||
- clarity [llm] ≥ 80%
|
||||
- depth [llm]`;
|
||||
|
||||
const parsed = parseTaskContent(raw);
|
||||
|
||||
expect(parsed.review).toBe('Review (maxIterations: 3):');
|
||||
expect(parsed.reviewRubrics).toEqual(['clarity [llm] ≥ 80%', 'depth [llm]']);
|
||||
});
|
||||
|
||||
it('captures parentTask block', () => {
|
||||
const raw = `T-2 sub
|
||||
Status: ○ backlog Priority: -
|
||||
Instruction: child task
|
||||
|
||||
<parentTask identifier="T-1" name="parent">
|
||||
Instruction: parent goal
|
||||
</parentTask>`;
|
||||
|
||||
const parsed = parseTaskContent(raw);
|
||||
|
||||
expect(parsed.parentTaskBlock).toContain('<parentTask identifier="T-1"');
|
||||
expect(parsed.parentTaskBlock).toContain('</parentTask>');
|
||||
});
|
||||
});
|
||||
@@ -1,216 +0,0 @@
|
||||
export interface ParsedTaskContent {
|
||||
activities?: string[];
|
||||
agent?: string;
|
||||
dependencies?: string;
|
||||
description?: string;
|
||||
identifier?: string;
|
||||
instruction?: string;
|
||||
name?: string;
|
||||
parent?: string;
|
||||
parentTaskBlock?: string;
|
||||
priority?: string;
|
||||
review?: string;
|
||||
reviewRubrics?: string[];
|
||||
status?: string;
|
||||
statusIcon?: string;
|
||||
subtasks?: string[];
|
||||
topics?: string;
|
||||
workspace?: string[];
|
||||
}
|
||||
|
||||
const SECTION_KEYS = new Set(['Activities:', 'Subtasks:', 'Workspace', 'Review', 'Review:']);
|
||||
|
||||
const splitStatus = (raw: string): { icon?: string; status: string } => {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return { status: '' };
|
||||
const [first, ...rest] = trimmed.split(/\s+/);
|
||||
if (rest.length === 0) return { status: first };
|
||||
// Heuristic: first token is non-ascii (icon) or short symbol → treat as icon.
|
||||
if (first.length <= 2 || !/^[A-Z]/i.test(first)) {
|
||||
return { icon: first, status: rest.join(' ') };
|
||||
}
|
||||
return { status: trimmed };
|
||||
};
|
||||
|
||||
const isSectionHeader = (line: string): boolean => {
|
||||
if (SECTION_KEYS.has(line.trim())) return true;
|
||||
if (/^Workspace\s*\(/.test(line)) return true;
|
||||
if (/^Review\s*\(/.test(line)) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
const isFieldLine = (line: string): boolean => {
|
||||
return /^[A-Z][A-Za-z]*:\s/.test(line);
|
||||
};
|
||||
|
||||
export const parseTaskContent = (raw: string): ParsedTaskContent => {
|
||||
if (!raw) return {};
|
||||
|
||||
// Strip the <hint>...</hint> block if present.
|
||||
const cleaned = raw.replaceAll(/<hint>[\S\s]*?<\/hint>/g, '').trim();
|
||||
const lines = cleaned.split('\n');
|
||||
|
||||
const result: ParsedTaskContent = {};
|
||||
let i = 0;
|
||||
|
||||
const skipBlankLines = () => {
|
||||
while (i < lines.length && lines[i].trim() === '') i++;
|
||||
};
|
||||
|
||||
// Title line: "<identifier> <name...>" or just "<identifier>"
|
||||
skipBlankLines();
|
||||
const titleLine = lines[i]?.trim();
|
||||
if (titleLine) {
|
||||
const spaceIdx = titleLine.indexOf(' ');
|
||||
if (spaceIdx === -1) {
|
||||
result.identifier = titleLine;
|
||||
} else {
|
||||
result.identifier = titleLine.slice(0, spaceIdx);
|
||||
result.name = titleLine.slice(spaceIdx + 1).trim();
|
||||
}
|
||||
i++;
|
||||
}
|
||||
|
||||
// Field block until the first blank line or a section header.
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
if (line.trim() === '') {
|
||||
i++;
|
||||
break;
|
||||
}
|
||||
if (isSectionHeader(line)) break;
|
||||
|
||||
if (line.startsWith('Status:')) {
|
||||
const rest = line.slice('Status:'.length).trim();
|
||||
const priorityMarker = ' Priority:';
|
||||
const priorityIdx = rest.indexOf(priorityMarker);
|
||||
if (priorityIdx === -1) {
|
||||
const { icon, status } = splitStatus(rest);
|
||||
result.statusIcon = icon;
|
||||
result.status = status;
|
||||
} else {
|
||||
const statusPart = rest.slice(0, priorityIdx).trimEnd();
|
||||
const priorityPart = rest.slice(priorityIdx + priorityMarker.length).trim();
|
||||
const { icon, status } = splitStatus(statusPart);
|
||||
result.statusIcon = icon;
|
||||
result.status = status;
|
||||
result.priority = priorityPart;
|
||||
}
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const colonIdx = line.indexOf(':');
|
||||
if (colonIdx > 0 && /^[A-Z][A-Za-z]*$/.test(line.slice(0, colonIdx))) {
|
||||
const key = line.slice(0, colonIdx).toLowerCase() as keyof ParsedTaskContent;
|
||||
let value = line.slice(colonIdx + 1).trim();
|
||||
|
||||
// For Instruction / Description, accumulate continuation lines until a
|
||||
// blank line, another field, or a section header.
|
||||
if (key === 'instruction' || key === 'description') {
|
||||
i++;
|
||||
while (i < lines.length) {
|
||||
const next = lines[i];
|
||||
if (next.trim() === '') break;
|
||||
if (isFieldLine(next) || isSectionHeader(next)) break;
|
||||
value += '\n' + next;
|
||||
i++;
|
||||
}
|
||||
(result as any)[key] = value.trim();
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (key) {
|
||||
case 'agent':
|
||||
case 'parent':
|
||||
case 'topics':
|
||||
case 'dependencies': {
|
||||
(result as any)[key] = value;
|
||||
break;
|
||||
}
|
||||
// No default
|
||||
}
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
|
||||
// Section parsing.
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
if (line.trim() === '') {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('Subtasks:')) {
|
||||
i++;
|
||||
const block: string[] = [];
|
||||
while (i < lines.length && /^\s+\S/.test(lines[i])) {
|
||||
block.push(lines[i].replace(/^\s{2}/, ''));
|
||||
i++;
|
||||
}
|
||||
result.subtasks = block;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^Review(?:\s*\(|:)/.test(line)) {
|
||||
const header = line.trim();
|
||||
i++;
|
||||
const rubrics: string[] = [];
|
||||
while (i < lines.length && /^\s+-/.test(lines[i])) {
|
||||
rubrics.push(lines[i].trim().replace(/^-\s*/, ''));
|
||||
i++;
|
||||
}
|
||||
if (rubrics.length > 0) {
|
||||
result.review = header;
|
||||
result.reviewRubrics = rubrics;
|
||||
} else {
|
||||
result.review = header.replace(/^Review:\s*/, '').replace(/^Review\s*/, '');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^Workspace\s*\(/.test(line)) {
|
||||
i++;
|
||||
const block: string[] = [];
|
||||
while (i < lines.length && /^\s+\S/.test(lines[i])) {
|
||||
block.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
result.workspace = block;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('Activities:')) {
|
||||
i++;
|
||||
const block: string[] = [];
|
||||
while (i < lines.length && /^\s+\S/.test(lines[i])) {
|
||||
block.push(lines[i].trim());
|
||||
i++;
|
||||
}
|
||||
result.activities = block;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('<parentTask')) {
|
||||
const block: string[] = [line];
|
||||
i++;
|
||||
while (i < lines.length) {
|
||||
block.push(lines[i]);
|
||||
if (lines[i].trim() === '</parentTask>') {
|
||||
i++;
|
||||
break;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
result.parentTaskBlock = block.join('\n');
|
||||
continue;
|
||||
}
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
@@ -1,132 +0,0 @@
|
||||
import { SKIP, visit } from 'unist-util-visit';
|
||||
|
||||
import { TASK_TAG } from '@/const/plugin';
|
||||
|
||||
import { treeNodeToString } from '../remarkPlugins/getNodeContent';
|
||||
|
||||
const OPEN_TAG = `<${TASK_TAG}>`;
|
||||
const CLOSE_TAG = `</${TASK_TAG}>`;
|
||||
|
||||
/**
|
||||
* Captures `<task>...</task>` blocks from a markdown AST. Unlike
|
||||
* `createRemarkCustomTagPlugin`, this handles the common case where remark
|
||||
* parses the opening `<task>` together with the surrounding lines into a
|
||||
* single html block, and where the closing `</task>` is buried inside a
|
||||
* later paragraph node.
|
||||
*/
|
||||
export const remarkTaskBlock = () => (tree: any) => {
|
||||
visit(tree, (node, index, parent) => {
|
||||
if (!parent || index == null) return;
|
||||
if (node.type !== 'html') return;
|
||||
if (typeof node.value !== 'string') return;
|
||||
|
||||
const openIdx = node.value.indexOf(OPEN_TAG);
|
||||
if (openIdx === -1) return;
|
||||
|
||||
// Same node may already contain the closing tag.
|
||||
const sameNodeCloseIdx = node.value.indexOf(CLOSE_TAG, openIdx + OPEN_TAG.length);
|
||||
if (sameNodeCloseIdx !== -1) {
|
||||
const inner = node.value.slice(openIdx + OPEN_TAG.length, sameNodeCloseIdx).trim();
|
||||
const replacement = buildTaskNode(inner, node.position);
|
||||
parent.children.splice(index, 1, replacement);
|
||||
return [SKIP, index + 1];
|
||||
}
|
||||
|
||||
// Otherwise capture content from this node up to the closing tag in a
|
||||
// sibling node.
|
||||
const headInner = node.value.slice(openIdx + OPEN_TAG.length);
|
||||
const collected: string[] = [headInner];
|
||||
let cursor = (index as number) + 1;
|
||||
let closingFound = false;
|
||||
let closingTrailing = '';
|
||||
|
||||
while (cursor < parent.children.length) {
|
||||
const sibling = parent.children[cursor];
|
||||
const closeInSibling = findCloseInSubtree(sibling);
|
||||
if (closeInSibling) {
|
||||
collected.push(closeInSibling.before);
|
||||
closingTrailing = closeInSibling.after.trim();
|
||||
closingFound = true;
|
||||
break;
|
||||
}
|
||||
collected.push(treeNodeToString([sibling]));
|
||||
cursor++;
|
||||
}
|
||||
|
||||
if (!closingFound) return;
|
||||
|
||||
const inner = collected.join('\n').trim();
|
||||
const replacement = buildTaskNode(inner, node.position);
|
||||
|
||||
const removeCount = cursor - (index as number) + 1;
|
||||
const newNodes: any[] = [replacement];
|
||||
if (closingTrailing) {
|
||||
newNodes.push({
|
||||
type: 'paragraph',
|
||||
children: [{ type: 'text', value: closingTrailing }],
|
||||
});
|
||||
}
|
||||
parent.children.splice(index, removeCount, ...newNodes);
|
||||
return [SKIP, index + newNodes.length];
|
||||
});
|
||||
};
|
||||
|
||||
const buildTaskNode = (inner: string, position?: any) => ({
|
||||
type: `${TASK_TAG}Block`,
|
||||
data: {
|
||||
hName: TASK_TAG,
|
||||
hChildren: [{ type: 'text', value: inner }],
|
||||
},
|
||||
position,
|
||||
});
|
||||
|
||||
interface CloseMatch {
|
||||
after: string;
|
||||
before: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks a node subtree looking for the closing tag. Returns the text content
|
||||
* before the tag (preserving everything we want as the inner body) and any
|
||||
* stray text after the tag (so we don't drop content that follows in the same
|
||||
* paragraph). Returns null if the closing tag is not found.
|
||||
*/
|
||||
const findCloseInSubtree = (root: any): CloseMatch | null => {
|
||||
// Direct html node with the closing tag.
|
||||
if (root.type === 'html' && typeof root.value === 'string') {
|
||||
const idx = root.value.indexOf(CLOSE_TAG);
|
||||
if (idx !== -1) {
|
||||
return {
|
||||
before: root.value.slice(0, idx),
|
||||
after: root.value.slice(idx + CLOSE_TAG.length),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!root.children || !Array.isArray(root.children)) return null;
|
||||
|
||||
const beforeParts: string[] = [];
|
||||
for (let i = 0; i < root.children.length; i++) {
|
||||
const child = root.children[i];
|
||||
if (child.type === 'html' && typeof child.value === 'string') {
|
||||
const idx = child.value.indexOf(CLOSE_TAG);
|
||||
if (idx !== -1) {
|
||||
beforeParts.push(child.value.slice(0, idx));
|
||||
const after = child.value.slice(idx + CLOSE_TAG.length);
|
||||
return {
|
||||
before: beforeParts.join(''),
|
||||
after,
|
||||
};
|
||||
}
|
||||
}
|
||||
const nested = findCloseInSubtree(child);
|
||||
if (nested) {
|
||||
beforeParts.push(nested.before);
|
||||
return { before: beforeParts.join(''), after: nested.after };
|
||||
}
|
||||
beforeParts.push(treeNodeToString([child]));
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -3,7 +3,6 @@ import LobeArtifact from './LobeArtifact';
|
||||
import LobeThinking from './LobeThinking';
|
||||
import LocalFile from './LocalFile';
|
||||
import Mention from './Mention';
|
||||
import Task from './Task';
|
||||
import Thinking from './Thinking';
|
||||
import { type MarkdownElement } from './type';
|
||||
|
||||
@@ -15,6 +14,5 @@ export const markdownElements: MarkdownElement[] = [
|
||||
LobeThinking,
|
||||
LocalFile,
|
||||
Mention,
|
||||
Task,
|
||||
ImageSearchRef,
|
||||
];
|
||||
|
||||
-22
@@ -17,9 +17,6 @@ vi.mock('@lobehub/ui', () => ({
|
||||
ActionIcon: ({ children, ...props }: { children?: ReactNode; [key: string]: unknown }) => (
|
||||
<button {...props}>{children}</button>
|
||||
),
|
||||
Avatar: ({ avatar, title }: { avatar?: string; title?: string }) => (
|
||||
<img alt={title} src={avatar} />
|
||||
),
|
||||
Flexbox: ({ children, ...props }: { children?: ReactNode; [key: string]: unknown }) => (
|
||||
<div {...props}>{children}</div>
|
||||
),
|
||||
@@ -100,23 +97,4 @@ describe('FallbackIntervention', () => {
|
||||
screen.getByText('Tools & Skills Activator → Activate Tools (Web Search, Calculator)'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders URL avatars as images instead of visible text', () => {
|
||||
const iconUrl = 'https://example.com/icon.png';
|
||||
metaMap.search.avatar = iconUrl;
|
||||
|
||||
render(
|
||||
<FallbackIntervention
|
||||
apiName="search"
|
||||
assistantGroupId="assistant-group-1"
|
||||
id="message-1"
|
||||
identifier="search"
|
||||
requestArgs="{}"
|
||||
toolCallId="tool-call-1"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText(iconUrl)).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('img', { name: 'Web Search' })).toHaveAttribute('src', iconUrl);
|
||||
});
|
||||
});
|
||||
|
||||
+6
-10
@@ -5,7 +5,7 @@ import {
|
||||
} from '@lobechat/builtin-tool-activator';
|
||||
import { builtinToolIdentifiers } from '@lobechat/builtin-tools/identifiers';
|
||||
import { safeParseJSON } from '@lobechat/utils';
|
||||
import { ActionIcon, Avatar, Flexbox, Icon } from '@lobehub/ui';
|
||||
import { ActionIcon, Flexbox, Icon } from '@lobehub/ui';
|
||||
import { createStaticStyles } from 'antd-style';
|
||||
import isEqual from 'fast-deep-equal';
|
||||
import { ChevronDown, ChevronRight, Edit3Icon } from 'lucide-react';
|
||||
@@ -38,6 +38,10 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
color: ${cssVar.colorTextSecondary};
|
||||
}
|
||||
`,
|
||||
avatar: css`
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
`,
|
||||
description: css`
|
||||
padding-block: 8px;
|
||||
padding-inline: 16px;
|
||||
@@ -149,15 +153,7 @@ const FallbackIntervention = memo<FallbackInterventionProps>(
|
||||
return (
|
||||
<Flexbox gap={4}>
|
||||
<Flexbox horizontal align="center" className={styles.description} gap={6}>
|
||||
{pluginMeta?.avatar && (
|
||||
<Avatar
|
||||
avatar={pluginMeta.avatar}
|
||||
shape={'square'}
|
||||
size={16}
|
||||
style={{ flex: 'none' }}
|
||||
title={toolTitle}
|
||||
/>
|
||||
)}
|
||||
{pluginMeta?.avatar && <span className={styles.avatar}>{pluginMeta.avatar}</span>}
|
||||
<span>
|
||||
{toolTitle} → {actionTitle}
|
||||
{actionTitleSuffix}
|
||||
|
||||
@@ -96,11 +96,6 @@ const blk = (p: Partial<AssistantContentBlock> & { id: string }): AssistantConte
|
||||
const parseAnswerSegment = () =>
|
||||
JSON.parse(screen.getByTestId('answer-segment').getAttribute('data-block') || '{}');
|
||||
|
||||
const parseAnswerSegments = () =>
|
||||
screen
|
||||
.queryAllByTestId('answer-segment')
|
||||
.map((node) => JSON.parse(node.getAttribute('data-block') || '{}'));
|
||||
|
||||
const parseWorkflowSegment = () =>
|
||||
JSON.parse(screen.getByTestId('workflow-segment').getAttribute('data-blocks') || '[]');
|
||||
|
||||
@@ -111,7 +106,7 @@ describe('Group', () => {
|
||||
mockIsGenerating = false;
|
||||
});
|
||||
|
||||
it('keeps long structured mixed content visible and renders the single tool inline', () => {
|
||||
it('keeps long structured mixed content visible and moves only tools into workflow', () => {
|
||||
const longContent =
|
||||
'后宫番 + 实际项目中的状态管理问题,这个组合挺有意思的!\n\n对于实际项目中的状态管理,你目前遇到的具体问题是什么?比如:\n- 不知道什么时候该用 useState,什么时候该用 Context\n- 组件间状态传递变得混乱\n- 性能问题(不必要的重渲染)';
|
||||
|
||||
@@ -133,28 +128,27 @@ describe('Group', () => {
|
||||
node.getAttribute('data-testid'),
|
||||
);
|
||||
|
||||
expect(sequence).toEqual(['answer-segment', 'answer-segment']);
|
||||
expect(parseAnswerSegments()).toEqual([
|
||||
{
|
||||
content: longContent,
|
||||
disableMarkdownStreaming: true,
|
||||
domId: 'block-1__answer',
|
||||
id: 'block-1',
|
||||
isFirstBlock: false,
|
||||
toolCount: 0,
|
||||
},
|
||||
expect(sequence).toEqual(['answer-segment', 'workflow-segment']);
|
||||
|
||||
expect(parseAnswerSegment()).toEqual({
|
||||
content: longContent,
|
||||
disableMarkdownStreaming: true,
|
||||
domId: 'block-1__answer',
|
||||
id: 'block-1',
|
||||
isFirstBlock: false,
|
||||
toolCount: 0,
|
||||
});
|
||||
expect(parseWorkflowSegment()).toEqual([
|
||||
{
|
||||
content: '',
|
||||
disableMarkdownStreaming: true,
|
||||
domId: 'block-1__workflow',
|
||||
id: 'block-1',
|
||||
isFirstBlock: false,
|
||||
toolCount: 1,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps a short mixed status block inline when there is only one tool call', () => {
|
||||
it('keeps short mixed status text inside workflow', () => {
|
||||
render(
|
||||
<Group
|
||||
id="assistant-1"
|
||||
@@ -169,91 +163,12 @@ describe('Group', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('workflow-segment')).not.toBeInTheDocument();
|
||||
expect(parseAnswerSegments()).toEqual([
|
||||
expect(screen.queryByTestId('answer-segment')).not.toBeInTheDocument();
|
||||
expect(parseWorkflowSegment()).toEqual([
|
||||
{
|
||||
content: '现在我来搜索资料。',
|
||||
disableMarkdownStreaming: true,
|
||||
domId: undefined,
|
||||
id: 'block-1',
|
||||
isFirstBlock: false,
|
||||
toolCount: 1,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('promotes the first sentence before folding a multi-tool workflow', () => {
|
||||
const { container } = render(
|
||||
<Group
|
||||
id="assistant-1"
|
||||
messageIndex={0}
|
||||
blocks={[
|
||||
blk({
|
||||
content: '我先帮你查一下。接下来我会继续整理结果。',
|
||||
id: 'block-1',
|
||||
tools: [{ apiName: 'search', id: 'tool-1' } as any],
|
||||
}),
|
||||
blk({
|
||||
content: '',
|
||||
id: 'block-2',
|
||||
tools: [{ apiName: 'readFile', id: 'tool-2' } as any],
|
||||
}),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const sequence = Array.from(container.querySelectorAll('[data-testid]')).map((node) =>
|
||||
node.getAttribute('data-testid'),
|
||||
);
|
||||
|
||||
expect(sequence).toEqual(['answer-segment', 'workflow-segment']);
|
||||
expect(parseAnswerSegment()).toEqual({
|
||||
content: '我先帮你查一下。',
|
||||
disableMarkdownStreaming: true,
|
||||
domId: 'block-1__answer',
|
||||
id: 'block-1',
|
||||
isFirstBlock: false,
|
||||
toolCount: 0,
|
||||
});
|
||||
expect(parseWorkflowSegment()).toEqual([
|
||||
{
|
||||
content: '接下来我会继续整理结果。',
|
||||
disableMarkdownStreaming: true,
|
||||
domId: 'block-1__workflow',
|
||||
toolCount: 1,
|
||||
},
|
||||
{
|
||||
content: '',
|
||||
disableMarkdownStreaming: false,
|
||||
domId: undefined,
|
||||
toolCount: 1,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('renders a single tool call inline instead of folding it', () => {
|
||||
render(
|
||||
<Group
|
||||
id="assistant-1"
|
||||
messageIndex={0}
|
||||
blocks={[
|
||||
blk({
|
||||
content: '',
|
||||
id: 'block-1',
|
||||
tools: [{ apiName: 'search', id: 'tool-1' } as any],
|
||||
}),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('workflow-segment')).not.toBeInTheDocument();
|
||||
expect(parseAnswerSegments()).toEqual([
|
||||
{
|
||||
content: '',
|
||||
disableMarkdownStreaming: true,
|
||||
domId: undefined,
|
||||
id: 'block-1',
|
||||
isFirstBlock: false,
|
||||
toolCount: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -57,11 +57,6 @@ interface PartitionedBlocks {
|
||||
segments: GroupRenderSegment[];
|
||||
}
|
||||
|
||||
interface LeadingSentenceSplit {
|
||||
lead: string;
|
||||
remainder: string;
|
||||
}
|
||||
|
||||
const ANSWER_DOM_ID_SUFFIX = '__answer';
|
||||
const WORKFLOW_DOM_ID_SUFFIX = '__workflow';
|
||||
|
||||
@@ -87,55 +82,6 @@ const hasReasoningContent = (block: AssistantContentBlock): boolean => {
|
||||
return !!block.reasoning?.content?.trim();
|
||||
};
|
||||
|
||||
const isSentenceBoundary = (content: string, index: number): boolean => {
|
||||
const char = content[index];
|
||||
if (!char) return false;
|
||||
if (char === '。' || char === '!' || char === '?' || char === '!' || char === '?') return true;
|
||||
if (char !== '.') return false;
|
||||
|
||||
const prev = content[index - 1] ?? '';
|
||||
const next = content[index + 1] ?? '';
|
||||
if (/[a-z\d]/i.test(prev) && /[a-z\d]/i.test(next)) return false;
|
||||
if (/\d/.test(prev) && /\d/.test(next)) return false;
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const extractLeadingSentenceSplit = (block: AssistantContentBlock): LeadingSentenceSplit | null => {
|
||||
const content = block.content ?? '';
|
||||
const trimmed = content.trim();
|
||||
|
||||
if (!trimmed || trimmed === LOADING_FLAT) return null;
|
||||
|
||||
let splitIndex = -1;
|
||||
|
||||
for (let i = 0; i < content.length; i++) {
|
||||
if (!isSentenceBoundary(content, i)) continue;
|
||||
splitIndex = i + 1;
|
||||
break;
|
||||
}
|
||||
|
||||
if (splitIndex === -1) {
|
||||
const paragraphBreak = content.search(/\n\s*\n/);
|
||||
if (paragraphBreak >= 0) splitIndex = paragraphBreak;
|
||||
}
|
||||
|
||||
if (splitIndex === -1) {
|
||||
const firstLineBreak = content.indexOf('\n');
|
||||
if (firstLineBreak >= 0) splitIndex = firstLineBreak;
|
||||
}
|
||||
|
||||
if (splitIndex === -1) return null;
|
||||
|
||||
const lead = content.slice(0, splitIndex).trim();
|
||||
const remainder = content.slice(splitIndex).trimStart();
|
||||
|
||||
if (!lead) return null;
|
||||
if (!remainder && !hasTools(block) && !hasReasoningContent(block) && !block.error) return null;
|
||||
|
||||
return { lead, remainder };
|
||||
};
|
||||
|
||||
const isTrailingReasoningCandidate = (block: AssistantContentBlock): boolean => {
|
||||
return hasReasoningContent(block) && !hasTools(block) && !block.error;
|
||||
};
|
||||
@@ -194,37 +140,8 @@ const shouldPromoteMixedBlockContent = (block: AssistantContentBlock): boolean =
|
||||
);
|
||||
};
|
||||
|
||||
const appendWorkflowRangeBlock = (
|
||||
segments: GroupRenderSegment[],
|
||||
block: AssistantContentBlock,
|
||||
allowLeadingSentencePromotion = false,
|
||||
) => {
|
||||
const appendWorkflowRangeBlock = (segments: GroupRenderSegment[], block: AssistantContentBlock) => {
|
||||
if (!shouldPromoteMixedBlockContent(block)) {
|
||||
const leadingSentenceSplit =
|
||||
allowLeadingSentencePromotion && segments.length === 0 && hasTools(block)
|
||||
? extractLeadingSentenceSplit(block)
|
||||
: null;
|
||||
|
||||
if (leadingSentenceSplit) {
|
||||
appendAnswerBlock(
|
||||
segments,
|
||||
createAnswerRenderBlock(block, {
|
||||
content: leadingSentenceSplit.lead,
|
||||
error: undefined,
|
||||
imageList: undefined,
|
||||
reasoning: undefined,
|
||||
tools: undefined,
|
||||
}),
|
||||
);
|
||||
appendWorkflowBlock(
|
||||
segments,
|
||||
createWorkflowRenderBlock(block, {
|
||||
content: leadingSentenceSplit.remainder,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
appendWorkflowBlock(segments, block);
|
||||
return;
|
||||
}
|
||||
@@ -313,8 +230,6 @@ const partitionBlocks = (
|
||||
}
|
||||
}
|
||||
|
||||
const totalToolCount = blocks.reduce((sum, block) => sum + (block.tools?.length ?? 0), 0);
|
||||
|
||||
for (const block of blocks.slice(0, firstToolIndex)) {
|
||||
appendAnswerBlock(segments, block);
|
||||
}
|
||||
@@ -333,7 +248,7 @@ const partitionBlocks = (
|
||||
}
|
||||
|
||||
for (const block of blocks.slice(firstToolIndex, workingEndExclusive)) {
|
||||
appendWorkflowRangeBlock(segments, block, totalToolCount > 1);
|
||||
appendWorkflowRangeBlock(segments, block);
|
||||
}
|
||||
|
||||
for (const block of blocks.slice(workingEndExclusive)) {
|
||||
@@ -347,7 +262,7 @@ const partitionBlocks = (
|
||||
}
|
||||
|
||||
for (const block of blocks.slice(firstToolIndex, lastToolIndex + 1)) {
|
||||
appendWorkflowRangeBlock(segments, block, totalToolCount > 1);
|
||||
appendWorkflowRangeBlock(segments, block);
|
||||
}
|
||||
|
||||
appendPostToolBlocks(segments, blocks.slice(lastToolIndex + 1));
|
||||
@@ -366,17 +281,6 @@ const withMarkdownStreamingState = (
|
||||
disableMarkdownStreaming: block.disableMarkdownStreaming || block.id === firstBlockId,
|
||||
});
|
||||
|
||||
const shouldInlineWorkflowSegment = (blocks: RenderableAssistantContentBlock[]): boolean => {
|
||||
let toolCount = 0;
|
||||
|
||||
for (const block of blocks) {
|
||||
toolCount += block.tools?.length ?? 0;
|
||||
if (toolCount > 1) return false;
|
||||
}
|
||||
|
||||
return toolCount === 1;
|
||||
};
|
||||
|
||||
const Group = memo<GroupChildrenProps>(
|
||||
({
|
||||
blocks,
|
||||
@@ -418,24 +322,6 @@ const Group = memo<GroupChildrenProps>(
|
||||
if (segment.kind === 'workflow') {
|
||||
if (segment.blocks.length === 0) return null;
|
||||
|
||||
if (shouldInlineWorkflowSegment(segment.blocks)) {
|
||||
return segment.blocks.map((block, blockIndex) => {
|
||||
const item = withMarkdownStreamingState(block, firstBlockId);
|
||||
if (!isGenerating && isEmptyBlock(item)) return null;
|
||||
|
||||
return (
|
||||
<GroupItem
|
||||
{...item}
|
||||
assistantId={id}
|
||||
contentId={contentId}
|
||||
disableEditing={disableEditing}
|
||||
key={item.renderKey ?? `${id}.workflow-inline.${index}.${blockIndex}`}
|
||||
messageIndex={messageIndex}
|
||||
/>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<WorkflowCollapse
|
||||
assistantMessageId={id}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { DEFAULT_AVATAR, INBOX_SESSION_ID } from '@lobechat/const';
|
||||
import { ActionIcon, Avatar, Block, Flexbox, Icon, Text } from '@lobehub/ui';
|
||||
import { Avatar, Block, Flexbox, Text } from '@lobehub/ui';
|
||||
import { Divider } from 'antd';
|
||||
import { cssVar } from 'antd-style';
|
||||
import { Check, ChevronDownIcon, ChevronUpIcon } from 'lucide-react';
|
||||
import { memo, useState } from 'react';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
@@ -54,10 +53,6 @@ interface BriefCardProps {
|
||||
const BriefCard = memo<BriefCardProps>(
|
||||
({ brief, enableNavigation = true, onAfterResolve, onAfterAddComment }) => {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation('home');
|
||||
const isResolved = Boolean(brief.resolvedAction);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const showFull = !isResolved || expanded;
|
||||
|
||||
const canNavigate = enableNavigation && Boolean(brief.taskId);
|
||||
|
||||
@@ -84,42 +79,19 @@ const BriefCard = memo<BriefCardProps>(
|
||||
</Text>
|
||||
<Time date={brief.createdAt} />
|
||||
</Flexbox>
|
||||
<Flexbox horizontal align={'center'} gap={8}>
|
||||
{isResolved && !expanded && (
|
||||
<Flexbox horizontal align={'center'} gap={4}>
|
||||
<Icon color={cssVar.colorTextQuaternary} icon={Check} size={14} />
|
||||
<Text className={styles.resolvedTag}>{t('brief.resolved')}</Text>
|
||||
</Flexbox>
|
||||
)}
|
||||
{brief.agents.length > 0 && <AgentAvatars agents={brief.agents} />}
|
||||
{isResolved && (
|
||||
<ActionIcon
|
||||
icon={expanded ? ChevronUpIcon : ChevronDownIcon}
|
||||
size={'small'}
|
||||
title={expanded ? t('brief.collapse') : t('brief.expandAll')}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setExpanded((v) => !v);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Flexbox>
|
||||
{brief.agents.length > 0 && <AgentAvatars agents={brief.agents} />}
|
||||
</Flexbox>
|
||||
{showFull && (
|
||||
<>
|
||||
<Divider dashed style={{ marginBlock: 0 }} />
|
||||
<BriefCardSummary summary={brief.summary} />
|
||||
<BriefCardActions
|
||||
actions={brief.actions}
|
||||
briefId={brief.id}
|
||||
briefType={brief.type}
|
||||
resolvedAction={brief.resolvedAction}
|
||||
taskId={brief.taskId}
|
||||
onAfterAddComment={onAfterAddComment}
|
||||
onAfterResolve={onAfterResolve}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Divider dashed style={{ marginBlock: 0 }} />
|
||||
<BriefCardSummary summary={brief.summary} />
|
||||
<BriefCardActions
|
||||
actions={brief.actions}
|
||||
briefId={brief.id}
|
||||
briefType={brief.type}
|
||||
resolvedAction={brief.resolvedAction}
|
||||
taskId={brief.taskId}
|
||||
onAfterAddComment={onAfterAddComment}
|
||||
onAfterResolve={onAfterResolve}
|
||||
/>
|
||||
</Block>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@ import { type BriefAction, DEFAULT_BRIEF_ACTIONS } from '@lobechat/types';
|
||||
import { Button, Flexbox, Icon, Text, Tooltip } from '@lobehub/ui';
|
||||
import { cssVar } from 'antd-style';
|
||||
import { Check, SquarePen } from 'lucide-react';
|
||||
import { memo, useCallback, useState } from 'react';
|
||||
import { memo, useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
|
||||
@@ -46,11 +46,18 @@ const BriefCardActions = memo<BriefCardActionsProps>(
|
||||
const { t } = useTranslation('home');
|
||||
const [commentMode, setCommentMode] = useState<CommentMode | null>(null);
|
||||
const [loadingKey, setLoadingKey] = useState<string | null>(null);
|
||||
const { resolveBrief, submitFeedback } = useBriefStore(
|
||||
(s) => ({ resolveBrief: s.resolveBrief, submitFeedback: s.submitFeedback }),
|
||||
const [feedbackSent, setFeedbackSent] = useState(false);
|
||||
const { addComment, resolveBrief } = useBriefStore(
|
||||
(s) => ({ addComment: s.addComment, resolveBrief: s.resolveBrief }),
|
||||
shallow,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!feedbackSent) return;
|
||||
const timer = setTimeout(() => setFeedbackSent(false), 1500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [feedbackSent]);
|
||||
|
||||
const isResult = briefType === 'result';
|
||||
|
||||
const actions: BriefAction[] = isResult
|
||||
@@ -92,29 +99,21 @@ const BriefCardActions = memo<BriefCardActionsProps>(
|
||||
} finally {
|
||||
setLoadingKey(null);
|
||||
}
|
||||
} else if (taskId) {
|
||||
// Free-form feedback must resolve the brief (so the heartbeat
|
||||
// re-arm gate stops blocking on this urgent brief) AND re-run
|
||||
// the task so the agent picks up `resolvedComment` next turn.
|
||||
await submitFeedback(briefId, taskId, text);
|
||||
await onAfterAddComment?.();
|
||||
await onAfterResolve?.();
|
||||
} else {
|
||||
if (taskId) {
|
||||
await addComment(briefId, taskId, text);
|
||||
await onAfterAddComment?.();
|
||||
}
|
||||
setFeedbackSent(true);
|
||||
}
|
||||
|
||||
setCommentMode(null);
|
||||
},
|
||||
[
|
||||
briefId,
|
||||
commentMode,
|
||||
resolveBrief,
|
||||
submitFeedback,
|
||||
taskId,
|
||||
onAfterResolve,
|
||||
onAfterAddComment,
|
||||
],
|
||||
[addComment, briefId, commentMode, resolveBrief, taskId, onAfterResolve, onAfterAddComment],
|
||||
);
|
||||
|
||||
if (resolvedAction) return <SuccessTag label={t('brief.resolved')} />;
|
||||
if (feedbackSent) return <SuccessTag label={t('brief.feedbackSent')} />;
|
||||
if (commentMode) {
|
||||
return <CommentInput onCancel={() => setCommentMode(null)} onSubmit={handleCommentSubmit} />;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { styles } from './style';
|
||||
|
||||
export const COLLAPSED_MAX_HEIGHT = 180;
|
||||
export const COLLAPSED_MAX_HEIGHT = 240;
|
||||
|
||||
interface BriefCardSummaryProps {
|
||||
summary: string;
|
||||
|
||||
@@ -24,7 +24,8 @@ const DailyBrief = memo(() => {
|
||||
const briefs = useBriefStore(briefListSelectors.briefs);
|
||||
const isInit = useBriefStore(briefListSelectors.isBriefsInit);
|
||||
|
||||
if (!enableAgentTask || !isInit || briefs.length === 0) return null;
|
||||
if (!enableAgentTask) return null;
|
||||
if (!isInit || briefs.length === 0) return null;
|
||||
|
||||
return (
|
||||
<GroupBlock
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { isChatGroupSessionId } from '@lobechat/types';
|
||||
import { Flexbox } from '@lobehub/ui';
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import { memo, useCallback, useEffect, useMemo } from 'react';
|
||||
|
||||
import DragUploadZone, { useUploadFiles } from '@/components/DragUploadZone';
|
||||
import { actionMap } from '@/features/ChatInput/ActionBar/config';
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from '@/features/Conversation';
|
||||
import { useAgentStore } from '@/store/agent';
|
||||
import { agentByIdSelectors } from '@/store/agent/selectors';
|
||||
import { useChatStore } from '@/store/chat';
|
||||
|
||||
import AgentSelectorAction from './AgentSelector/AgentSelectorAction';
|
||||
import CopilotModelSelect from './CopilotModelSelect';
|
||||
@@ -35,6 +36,25 @@ const Conversation = memo(() => {
|
||||
]);
|
||||
const currentAgentId = useConversationStore(conversationSelectors.agentId);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentAgentId) return;
|
||||
|
||||
if (useAgentStore.getState().activeAgentId !== currentAgentId) {
|
||||
setActiveAgentId(currentAgentId);
|
||||
}
|
||||
|
||||
const { activeAgentId, activeTopicId, switchTopic } = useChatStore.getState();
|
||||
|
||||
if (activeAgentId !== currentAgentId) {
|
||||
useChatStore.setState({ activeAgentId: currentAgentId });
|
||||
}
|
||||
|
||||
// Reset topic on agent/context switch to avoid reusing old topic scope.
|
||||
if (activeAgentId !== currentAgentId || !!activeTopicId) {
|
||||
void switchTopic(null, { scope: 'page', skipRefreshMessage: true });
|
||||
}
|
||||
}, [currentAgentId, setActiveAgentId]);
|
||||
|
||||
useFetchAgentConfig(true, currentAgentId);
|
||||
|
||||
const model = useAgentStore((s) => agentByIdSelectors.getAgentModelById(currentAgentId)(s));
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user