mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-15 20:16:02 +00:00
Compare commits
70 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6a37972483 | |||
| 376976849b | |||
| a52104552a | |||
| 57781850ce | |||
| a101957715 | |||
| 4e309e6f26 | |||
| fd9b0531ec | |||
| 91db61b74f | |||
| 1d7b81233a | |||
| 35c3d5e08d | |||
| a176288670 | |||
| f0ba92776b | |||
| d12e050157 | |||
| cc48e9ff8e | |||
| 939f20e783 | |||
| 8f6848fba2 | |||
| 8b22e55271 | |||
| 196c0a7650 | |||
| ec7e696587 | |||
| 9b48e24ded | |||
| 79d5d2286a | |||
| 998c22890d | |||
| d5315fe745 | |||
| 5c75b0865f | |||
| 7f6f77ec9d | |||
| 7c0203a9c7 | |||
| 84fd8da4a3 | |||
| f98a314cf5 | |||
| 35c43fb580 | |||
| 56bc216c5e | |||
| 66c25cce4b | |||
| 774e29e400 | |||
| eec89338da | |||
| 91cb2a8e65 | |||
| 61d27b46a0 | |||
| 01f6858cc1 | |||
| b3e993f7b1 | |||
| 22e6e1dbcc | |||
| f7205552e8 | |||
| 0077a7286a | |||
| 697ac3bf6e | |||
| fc12fac53b | |||
| ba59d85ae6 | |||
| a6cb200d5b | |||
| 87d7b41186 | |||
| 8e807c6b10 | |||
| 53c5a014ba | |||
| ba05c32489 | |||
| d4a12c0ebb | |||
| 7f025b9c5a | |||
| 35c9e1b224 | |||
| 043d2a81fb | |||
| f39392749a | |||
| b3dc59f77a | |||
| 9b6a60339f | |||
| b55cf6b936 | |||
| 933cfbf789 | |||
| 0e11d3d9c0 | |||
| 600f10fcea | |||
| 421427f3a2 | |||
| 5dc7c2592c | |||
| a19b6b50e0 | |||
| fd2112cbcd | |||
| 0b57c9d3da | |||
| 1958a59f4e | |||
| f7ed6df35b | |||
| a18569c690 | |||
| 4ff4dead20 | |||
| 5a7d46e900 | |||
| 57e3940bc6 |
@@ -0,0 +1,209 @@
|
||||
---
|
||||
name: agent-runtime-hooks
|
||||
description: "Agent runtime lifecycle hooks for observing and intercepting agent execution. Use when adding hooks to agent operations, mocking tool calls, logging step events, handling human intervention, sub-agent calls, context compression, or building eval/tracing integrations. Triggers on 'hooks', 'beforeToolCall', 'afterToolCall', 'beforeStep', 'afterStep', 'onComplete', 'onError', 'tool mock', 'agent lifecycle', 'human intervention', 'callAgent', 'compact'."
|
||||
user-invocable: false
|
||||
---
|
||||
|
||||
# Agent Runtime Hooks
|
||||
|
||||
Lifecycle hooks for observing and intercepting agent execution. Hooks are registered per-operation via `execAgent({ hooks })` and dispatched by `HookDispatcher`.
|
||||
|
||||
## Hook Types
|
||||
|
||||
16 hook types across 5 categories:
|
||||
|
||||
```
|
||||
execAgent({ hooks })
|
||||
│
|
||||
├─ beforeStep ──────────── Before each step executes
|
||||
│ │
|
||||
│ ├─ [call_llm] LLM inference
|
||||
│ │
|
||||
│ ├─ [call_tool]
|
||||
│ │ ├─ beforeToolCall ── Before tool executes (supports mocking)
|
||||
│ │ ├─ (tool execution)
|
||||
│ │ ├─ afterToolCall ─── After tool completes (observation only)
|
||||
│ │ └─ onToolCallError ─ Tool threw an exception
|
||||
│ │
|
||||
│ ├─ [request_human_approve]
|
||||
│ │ ├─ beforeHumanIntervention ── Before agent pauses
|
||||
│ │ ├─ afterHumanIntervention ─── After approve/reject + resume
|
||||
│ │ └─ onStopByHumanIntervention ── User rejected, agent halted
|
||||
│ │
|
||||
│ ├─ [compress_context]
|
||||
│ │ ├─ beforeCompact ──── Before compression starts
|
||||
│ │ ├─ afterCompact ───── After compression completes
|
||||
│ │ └─ onCompactError ─── Compression failed
|
||||
│ │
|
||||
│ ├─ [callAgent] (via execSubAgentTask)
|
||||
│ │ ├─ beforeCallAgent ── Before sub-agent starts
|
||||
│ │ ├─ afterCallAgent ─── After sub-agent completes
|
||||
│ │ └─ onCallAgentError ── Sub-agent failed
|
||||
│ │
|
||||
│ └─ afterStep ──────────── After step completes
|
||||
│
|
||||
├─ (next step...)
|
||||
│
|
||||
├─ onComplete ───────────── Operation reaches terminal state
|
||||
└─ onError ──────────────── Error during execution
|
||||
```
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Role |
|
||||
| ---------------------------------------------------------- | ------------------------------------------------------ |
|
||||
| `packages/agent-runtime/src/types/hooks.ts` | Type definitions (AgentHookType, all event interfaces) |
|
||||
| `src/server/services/agentRuntime/hooks/types.ts` | Server-side types (AgentHook, re-exports) |
|
||||
| `src/server/services/agentRuntime/hooks/HookDispatcher.ts` | Registration, dispatch, dispatchBeforeToolCall |
|
||||
| `src/server/modules/AgentRuntime/RuntimeExecutors.ts` | Tool/Compact/HumanIntervention hook dispatch |
|
||||
| `src/server/services/agentRuntime/AgentRuntimeService.ts` | Step hooks + HumanIntervention resume/reject |
|
||||
| `src/server/services/aiAgent/index.ts` | CallAgent hook dispatch |
|
||||
|
||||
## Registration Flow
|
||||
|
||||
```ts
|
||||
const hooks: AgentHook[] = [
|
||||
{ id: 'my-hook', type: 'afterStep', handler: async (event) => { ... } },
|
||||
];
|
||||
await aiAgentService.execAgent({ agentId, prompt, hooks });
|
||||
// Internally: hookDispatcher.register(operationId, hooks)
|
||||
// Cleanup: hookDispatcher.unregister(operationId)
|
||||
```
|
||||
|
||||
## Hook Reference
|
||||
|
||||
### Step Level
|
||||
|
||||
**`beforeStep`** — Before each step. `event: AgentHookEvent`
|
||||
**`afterStep`** — After each step. `event: AgentHookEvent` (content, toolsCalling, totalCost, etc.)
|
||||
**`onComplete`** — Terminal state. `event: AgentHookEvent` (reason: done/error/interrupted/max_steps/cost_limit)
|
||||
**`onError`** — Error occurred. `event: AgentHookEvent` (errorMessage, errorDetail)
|
||||
|
||||
### Tool Call Level
|
||||
|
||||
**`beforeToolCall`** — Before tool executes. **Supports mocking** via `event.mock()`.
|
||||
|
||||
```ts
|
||||
// event: ToolCallHookEvent
|
||||
{
|
||||
(identifier, apiName, args, callIndex, stepIndex, operationId, mock);
|
||||
}
|
||||
// Mock example:
|
||||
event.mock({ content: '{"error":"rate limited"}' });
|
||||
```
|
||||
|
||||
Dispatch method: `hookDispatcher.dispatchBeforeToolCall()` (returns mock result or null).
|
||||
|
||||
**`afterToolCall`** — After tool completes. Observation only.
|
||||
|
||||
```ts
|
||||
// event: AfterToolCallHookEvent
|
||||
{
|
||||
(identifier, apiName, args, callIndex, content, success, mocked, executionTimeMs, stepIndex);
|
||||
}
|
||||
```
|
||||
|
||||
**`onToolCallError`** — Tool threw an exception (catch block, not just `success=false`).
|
||||
|
||||
```ts
|
||||
// event: ToolCallErrorHookEvent
|
||||
{
|
||||
(identifier, apiName, args, callIndex, error, stepIndex);
|
||||
}
|
||||
```
|
||||
|
||||
### Human Intervention
|
||||
|
||||
**`beforeHumanIntervention`** — Before agent pauses for approval.
|
||||
|
||||
```ts
|
||||
// event: BeforeHumanInterventionHookEvent
|
||||
{ operationId, stepIndex, pendingTools: [{ identifier, apiName }] }
|
||||
```
|
||||
|
||||
**`afterHumanIntervention`** — After approve/reject, agent resumes.
|
||||
|
||||
```ts
|
||||
// event: AfterHumanInterventionHookEvent
|
||||
{ operationId, action: 'approve' | 'reject' | 'rejectAndContinue', toolCallId?, rejectionReason? }
|
||||
```
|
||||
|
||||
**`onStopByHumanIntervention`** — User rejected, agent halted.
|
||||
|
||||
```ts
|
||||
// event: StopByHumanInterventionHookEvent
|
||||
{ operationId, toolCallId?, rejectionReason? }
|
||||
```
|
||||
|
||||
### Context Compression
|
||||
|
||||
**`beforeCompact`** — Before compression starts.
|
||||
|
||||
```ts
|
||||
// event: BeforeCompactHookEvent
|
||||
{
|
||||
(operationId, stepIndex, messageCount, tokenCount);
|
||||
}
|
||||
```
|
||||
|
||||
**`afterCompact`** — After compression completes.
|
||||
|
||||
```ts
|
||||
// event: AfterCompactHookEvent
|
||||
{
|
||||
(operationId, stepIndex, groupId, messagesBefore, messagesAfter, summary);
|
||||
}
|
||||
```
|
||||
|
||||
**`onCompactError`** — Compression failed.
|
||||
|
||||
```ts
|
||||
// event: CompactErrorHookEvent
|
||||
{
|
||||
(operationId, stepIndex, tokenCount, error);
|
||||
}
|
||||
```
|
||||
|
||||
### Sub-Agent (CallAgent)
|
||||
|
||||
**`beforeCallAgent`** — Before calling sub-agent. Dispatched on **parent** operation.
|
||||
|
||||
```ts
|
||||
// event: BeforeCallAgentHookEvent
|
||||
{
|
||||
(operationId, agentId, instruction);
|
||||
}
|
||||
```
|
||||
|
||||
**`afterCallAgent`** — Sub-agent completed. Dispatched on **parent** operation.
|
||||
|
||||
```ts
|
||||
// event: AfterCallAgentHookEvent
|
||||
{
|
||||
(operationId, agentId, subOperationId, threadId, success);
|
||||
}
|
||||
```
|
||||
|
||||
**`onCallAgentError`** — Sub-agent failed. Dispatched on **parent** operation.
|
||||
|
||||
```ts
|
||||
// event: CallAgentErrorHookEvent
|
||||
{
|
||||
(operationId, agentId, error);
|
||||
}
|
||||
```
|
||||
|
||||
Note: CallAgent hooks require `parentOperationId` in `ExecSubAgentTaskParams`.
|
||||
|
||||
## Design Notes
|
||||
|
||||
- **Fire-and-forget**: All handlers return `Promise<void>`. Errors are non-fatal.
|
||||
- **Exception**: `beforeToolCall` supports mock via `event.mock()` — uses `dispatchBeforeToolCall()` which returns the mock result.
|
||||
- **Sequential**: Same-type hooks run in registration order.
|
||||
- **Local only**: `beforeToolCall` mock only works in local mode (in-memory hooks). Webhook mode does not support mocking.
|
||||
- **Scoped per operation**: Auto-cleaned via `hookDispatcher.unregister()` on completion.
|
||||
- **Sandbox/MCP**: No separate hooks — they go through `executeTool`, so `beforeToolCall`/`afterToolCall` cover them. Use `event.identifier` to filter.
|
||||
|
||||
## Real-World Example: agent-evals
|
||||
|
||||
See `devtools/agent-evals/helpers/runner.ts` — `createEvalHooks()` uses `afterStep`, `onComplete`, `afterToolCall`, and `beforeToolCall` (for mock).
|
||||
@@ -146,4 +146,5 @@ apps/desktop/resources/cli-package.json
|
||||
|
||||
# Superpowers plugin brainstorm/spec outputs (local only; do not commit)
|
||||
.superpowers/
|
||||
docs/superpowers/
|
||||
.heerogeneous-tracing
|
||||
|
||||
@@ -1,100 +1,124 @@
|
||||
# LobeHub Development Guidelines
|
||||
|
||||
This document serves as a comprehensive guide for all team members when developing LobeHub.
|
||||
|
||||
## Project Description
|
||||
|
||||
You are developing an open-source, modern-design AI Agent Workspace: LobeHub (previously LobeChat).
|
||||
Guidelines for using AI coding agents in this LobeHub repository.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Frontend**: Next.js 16, React 19, TypeScript
|
||||
- **UI Components**: Ant Design, @lobehub/ui, antd-style
|
||||
- **State Management**: Zustand, SWR
|
||||
- **Database**: PostgreSQL, PGLite, Drizzle ORM
|
||||
- **Testing**: Vitest, Testing Library
|
||||
- **Package Manager**: pnpm (monorepo structure)
|
||||
- Next.js 16 + React 19 + TypeScript
|
||||
- SPA inside Next.js with `react-router-dom`
|
||||
- `@lobehub/ui`, antd for components; antd-style for CSS-in-JS — **prefer `createStaticStyles` with `cssVar.*`** (zero-runtime); only fall back to `createStyles` + `token` when styles genuinely need runtime computation. See `.cursor/docs/createStaticStyles_migration_guide.md`.
|
||||
- react-i18next for i18n; zustand for state management
|
||||
- SWR for data fetching; TRPC for type-safe backend
|
||||
- Drizzle ORM with PostgreSQL; Vitest for testing
|
||||
|
||||
## Directory Structure
|
||||
## Project Structure
|
||||
|
||||
```plaintext
|
||||
lobehub/
|
||||
├── apps/desktop/ # Electron desktop app
|
||||
├── apps/
|
||||
│ ├── desktop/ # Electron desktop app
|
||||
│ ├── cli/ # LobeHub CLI
|
||||
│ └── device-gateway/ # Device gateway service
|
||||
├── packages/ # Shared packages (@lobechat/*)
|
||||
│ ├── database/ # Database schemas, models, repositories
|
||||
│ ├── agent-runtime/ # Agent runtime
|
||||
│ └── ...
|
||||
├── src/
|
||||
│ ├── app/ # Next.js app router
|
||||
│ ├── spa/ # SPA entry points (entry.*.tsx) and router config
|
||||
│ ├── routes/ # SPA page components (roots)
|
||||
│ ├── features/ # Business components by domain
|
||||
│ ├── app/ # Next.js App Router (backend API + auth)
|
||||
│ │ ├── (backend)/ # API routes (trpc, webapi, etc.)
|
||||
│ │ ├── spa/ # SPA HTML template service
|
||||
│ │ └── [variants]/(auth)/ # Auth pages (SSR required)
|
||||
│ ├── routes/ # SPA page components (Vite)
|
||||
│ │ ├── (main)/ # Desktop pages
|
||||
│ │ ├── (mobile)/ # Mobile pages
|
||||
│ │ ├── (desktop)/ # Desktop-specific pages
|
||||
│ │ ├── (popup)/ # Popup window pages
|
||||
│ │ ├── onboarding/ # Onboarding pages
|
||||
│ │ └── share/ # Share pages
|
||||
│ ├── spa/ # SPA entry points and router config
|
||||
│ │ ├── entry.web.tsx # Web entry
|
||||
│ │ ├── entry.mobile.tsx
|
||||
│ │ ├── entry.desktop.tsx
|
||||
│ │ ├── entry.popup.tsx
|
||||
│ │ └── router/ # React Router configuration
|
||||
│ ├── store/ # Zustand stores
|
||||
│ ├── services/ # Client services
|
||||
│ ├── server/ # Server services and routers
|
||||
│ └── ...
|
||||
├── .agents/skills/ # AI development skills
|
||||
└── e2e/ # E2E tests (Cucumber + Playwright)
|
||||
```
|
||||
|
||||
## Development Workflow
|
||||
## SPA Routes and Features
|
||||
|
||||
SPA-related code is grouped under `src/spa/` (entries + router) and `src/routes/` (page segments). We use a **roots vs features** split: route trees only hold page segments; business logic and UI live in features.
|
||||
|
||||
- **`src/spa/`** – SPA entry points (`entry.web.tsx`, `entry.mobile.tsx`, `entry.desktop.tsx`, `entry.popup.tsx`) and React Router config (`router/`, with `desktopRouter.config.*`, `mobileRouter.config.tsx`, `popupRouter.config.tsx`). Keeps router config next to entries to avoid confusion with `src/routes/`.
|
||||
|
||||
- **`src/routes/` (roots)**\
|
||||
Only page-segment files: `_layout/index.tsx`, `index.tsx` (or `page.tsx`), and dynamic segments like `[id]/index.tsx`. Keep these **thin**: they should only import from `@/features/*` and compose layout/page, with no business logic or heavy UI.
|
||||
|
||||
- **`src/features/`**\
|
||||
Business components by **domain** (e.g. `Pages`, `PageEditor`, `Home`). Put layout chunks (sidebar, header, body), hooks, and domain-specific UI here. Each feature exposes an `index.ts` (or `index.tsx`) with clear exports.
|
||||
|
||||
When adding or changing SPA routes:
|
||||
|
||||
1. In `src/routes/`, add only the route segment files (layout + page) that delegate to features.
|
||||
2. Implement layout and page content under `src/features/<Domain>/` and export from there.
|
||||
3. In route files, use `import { X } from '@/features/<Domain>'` (or `import Y from '@/features/<Domain>/...'`). Do not add new `features/` folders inside `src/routes/`.
|
||||
4. **Register the desktop route tree in both configs:** `src/spa/router/desktopRouter.config.tsx` and `src/spa/router/desktopRouter.config.desktop.tsx` must stay in sync (same paths and nesting). Updating only one can cause **blank screens** if the other build path expects the route. `desktopRouter.sync.test.tsx` guards this invariant — keep it passing.
|
||||
|
||||
See the **spa-routes** skill (`.agents/skills/spa-routes/SKILL.md`) for the full convention and file-division rules.
|
||||
|
||||
## Development
|
||||
|
||||
### Starting the Dev Environment
|
||||
|
||||
```bash
|
||||
# SPA dev mode (frontend only, proxies API to localhost:3010)
|
||||
bun run dev:spa
|
||||
|
||||
# Full-stack dev (Next.js + Vite SPA concurrently)
|
||||
bun run dev
|
||||
```
|
||||
|
||||
After `dev:spa` starts, the terminal prints a **Debug Proxy** URL:
|
||||
|
||||
```plaintext
|
||||
Debug Proxy: https://app.lobehub.com/_dangerous_local_dev_proxy?debug-host=http%3A%2F%2Flocalhost%3A9876
|
||||
```
|
||||
|
||||
Open this URL to develop locally against the production backend (app.lobehub.com). The proxy page loads your local Vite dev server's SPA into the online environment, enabling HMR with real server config.
|
||||
|
||||
### Git Workflow
|
||||
|
||||
- **Branch strategy**: `canary` is the development branch (cloud production); `main` is the release branch (periodically cherry-picks from canary)
|
||||
- New branches should be created from `canary`; PRs should target `canary`
|
||||
- Use rebase for git pull
|
||||
- Git commit messages should prefix with gitmoji
|
||||
- Git branch name format: `feat/feature-name`
|
||||
- Use `.github/PULL_REQUEST_TEMPLATE.md` for PR descriptions
|
||||
- **Protection of local changes**: Never use `git restore`, `git checkout --`, `git reset --hard`, or any other command or workflow that can forcibly overwrite, discard, or silently replace user-owned uncommitted changes. Before any revert or restoration affecting existing files, inspect the working tree carefully and obtain explicit user confirmation.
|
||||
- Use rebase for `git pull`
|
||||
- Commit messages: prefix with gitmoji
|
||||
- Branch format: `<type>/<feature-name>`
|
||||
|
||||
### Package Management
|
||||
|
||||
- Use `pnpm` as the primary package manager
|
||||
- Use `bun` to run npm scripts
|
||||
- Use `bunx` to run executable npm packages
|
||||
- `pnpm` for dependency management
|
||||
- `bun` to run npm scripts
|
||||
- `bunx` for executable npm packages
|
||||
|
||||
### Code Style Guidelines
|
||||
|
||||
#### TypeScript
|
||||
|
||||
- Prefer interfaces over types for object shapes
|
||||
|
||||
### Testing Strategy
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
# Web tests
|
||||
bunx vitest run --silent='passed-only' '[file-path-pattern]'
|
||||
# Run specific test (NEVER run `bun run test` - takes ~10 minutes)
|
||||
bunx vitest run --silent='passed-only' '[file-path]'
|
||||
|
||||
# Package tests (e.g., database)
|
||||
cd packages/[package-name] && bunx vitest run --silent='passed-only' '[file-path-pattern]'
|
||||
# Database package
|
||||
cd packages/database && bunx vitest run --silent='passed-only' '[file]'
|
||||
```
|
||||
|
||||
**Important Notes**:
|
||||
|
||||
- Wrap file paths in single quotes to avoid shell expansion
|
||||
- Never run `bun run test` - this runs all tests and takes \~10 minutes
|
||||
|
||||
### Type Checking
|
||||
|
||||
- Use `bun run type-check` to check for type errors
|
||||
- Prefer `vi.spyOn` over `vi.mock`
|
||||
- Tests must pass type check: `bun run type-check`
|
||||
- After 2 failed fix attempts, stop and ask for help
|
||||
|
||||
### i18n
|
||||
|
||||
- **Keys**: Add to `src/locales/default/namespace.ts`
|
||||
- **Dev**: Translate `locales/zh-CN/namespace.json` locale file only for preview
|
||||
- DON'T run `pnpm i18n`, let CI auto handle it
|
||||
|
||||
## SPA Routes and Features
|
||||
|
||||
- **`src/routes/`** holds only page segments (`_layout/index.tsx`, `index.tsx`, `[id]/index.tsx`). Keep route files **thin** — import from `@/features/*` and compose, no business logic.
|
||||
- **`src/features/`** holds business components by **domain** (e.g. `Pages`, `PageEditor`, `Home`). Layout pieces, hooks, and domain UI go here.
|
||||
- **Desktop router parity:** When changing the main SPA route tree, update **both** `src/spa/router/desktopRouter.config.tsx` (dynamic imports) and `src/spa/router/desktopRouter.config.desktop.tsx` (sync imports) so paths and nesting match. Changing only one can leave routes unregistered and cause **blank screens**.
|
||||
- See the **spa-routes** skill (`.agents/skills/spa-routes/SKILL.md`) for the full convention and file-division rules.
|
||||
|
||||
## Skills (Auto-loaded)
|
||||
|
||||
All AI development skills are available in `.agents/skills/` directory and auto-loaded by Claude Code when relevant.
|
||||
|
||||
**IMPORTANT**: When reviewing PRs or code diffs, ALWAYS read `.agents/skills/code-review/SKILL.md` first.
|
||||
- Add keys to a namespace file under `src/locales/default/` (e.g. `agent.ts`, `auth.ts`)
|
||||
- For dev preview: translate `locales/zh-CN/` and `locales/en-US/`
|
||||
- Don't run `pnpm i18n` - CI handles it
|
||||
|
||||
@@ -2,6 +2,31 @@
|
||||
|
||||
# Changelog
|
||||
|
||||
### [Version 2.1.52](https://github.com/lobehub/lobe-chat/compare/v2.1.51...v2.1.52)
|
||||
|
||||
<sup>Released on **2026-04-20**</sup>
|
||||
|
||||
#### 👷 Build System
|
||||
|
||||
- **database**: add topic status and tasks automation mode.
|
||||
|
||||
<br/>
|
||||
|
||||
<details>
|
||||
<summary><kbd>Improvements and Fixes</kbd></summary>
|
||||
|
||||
#### Build System
|
||||
|
||||
- **database**: add topic status and tasks automation mode, closes [#13994](https://github.com/lobehub/lobe-chat/issues/13994) ([3bcd581](https://github.com/lobehub/lobe-chat/commit/3bcd581))
|
||||
|
||||
</details>
|
||||
|
||||
<div align="right">
|
||||
|
||||
[](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
## [Version 2.1.51](https://github.com/lobehub/lobe-chat/compare/v0.0.0-nightly.pr13850.8503...v2.1.51)
|
||||
|
||||
<sup>Released on **2026-04-16**</sup>
|
||||
|
||||
@@ -1,123 +1 @@
|
||||
# CLAUDE.md
|
||||
|
||||
Guidelines for using Claude Code in this LobeHub repository.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- Next.js 16 + React 19 + TypeScript
|
||||
- SPA inside Next.js with `react-router-dom`
|
||||
- `@lobehub/ui`, antd for components; antd-style for CSS-in-JS — **prefer `createStaticStyles` with `cssVar.*`** (zero-runtime); only fall back to `createStyles` + `token` when styles genuinely need runtime computation. See `.cursor/docs/createStaticStyles_migration_guide.md`.
|
||||
- react-i18next for i18n; zustand for state management
|
||||
- SWR for data fetching; TRPC for type-safe backend
|
||||
- Drizzle ORM with PostgreSQL; Vitest for testing
|
||||
|
||||
## Project Structure
|
||||
|
||||
```plaintext
|
||||
lobehub/
|
||||
├── apps/desktop/ # Electron desktop app
|
||||
├── packages/ # Shared packages (@lobechat/*)
|
||||
│ ├── database/ # Database schemas, models, repositories
|
||||
│ ├── agent-runtime/ # Agent runtime
|
||||
│ └── ...
|
||||
├── src/
|
||||
│ ├── app/ # Next.js App Router (backend API + auth)
|
||||
│ │ ├── (backend)/ # API routes (trpc, webapi, etc.)
|
||||
│ │ ├── spa/ # SPA HTML template service
|
||||
│ │ └── [variants]/(auth)/ # Auth pages (SSR required)
|
||||
│ ├── routes/ # SPA page components (Vite)
|
||||
│ │ ├── (main)/ # Desktop pages
|
||||
│ │ ├── (mobile)/ # Mobile pages
|
||||
│ │ ├── (desktop)/ # Desktop-specific pages
|
||||
│ │ ├── onboarding/ # Onboarding pages
|
||||
│ │ └── share/ # Share pages
|
||||
│ ├── spa/ # SPA entry points and router config
|
||||
│ │ ├── entry.web.tsx # Web entry
|
||||
│ │ ├── entry.mobile.tsx
|
||||
│ │ ├── entry.desktop.tsx
|
||||
│ │ └── router/ # React Router configuration
|
||||
│ ├── store/ # Zustand stores
|
||||
│ ├── services/ # Client services
|
||||
│ ├── server/ # Server services and routers
|
||||
│ └── ...
|
||||
└── e2e/ # E2E tests (Cucumber + Playwright)
|
||||
```
|
||||
|
||||
## SPA Routes and Features
|
||||
|
||||
SPA-related code is grouped under `src/spa/` (entries + router) and `src/routes/` (page segments). We use a **roots vs features** split: route trees only hold page segments; business logic and UI live in features.
|
||||
|
||||
- **`src/spa/`** – SPA entry points (`entry.web.tsx`, `entry.mobile.tsx`, `entry.desktop.tsx`) and React Router config (`router/`). Keeps router config next to entries to avoid confusion with `src/routes/`.
|
||||
|
||||
- **`src/routes/` (roots)**\
|
||||
Only page-segment files: `_layout/index.tsx`, `index.tsx` (or `page.tsx`), and dynamic segments like `[id]/index.tsx`. Keep these **thin**: they should only import from `@/features/*` and compose layout/page, with no business logic or heavy UI.
|
||||
|
||||
- **`src/features/`**\
|
||||
Business components by **domain** (e.g. `Pages`, `PageEditor`, `Home`). Put layout chunks (sidebar, header, body), hooks, and domain-specific UI here. Each feature exposes an `index.ts` (or `index.tsx`) with clear exports.
|
||||
|
||||
When adding or changing SPA routes:
|
||||
|
||||
1. In `src/routes/`, add only the route segment files (layout + page) that delegate to features.
|
||||
2. Implement layout and page content under `src/features/<Domain>/` and export from there.
|
||||
3. In route files, use `import { X } from '@/features/<Domain>'` (or `import Y from '@/features/<Domain>/...'`). Do not add new `features/` folders inside `src/routes/`.
|
||||
4. **Register the desktop route tree in both configs:** `src/spa/router/desktopRouter.config.tsx` and `src/spa/router/desktopRouter.config.desktop.tsx` must stay in sync (same paths and nesting). Updating only one can cause **blank screens** if the other build path expects the route.
|
||||
|
||||
See the **spa-routes** skill (`.agents/skills/spa-routes/SKILL.md`) for the full convention and file-division rules.
|
||||
|
||||
## Development
|
||||
|
||||
### Starting the Dev Environment
|
||||
|
||||
```bash
|
||||
# SPA dev mode (frontend only, proxies API to localhost:3010)
|
||||
bun run dev:spa
|
||||
|
||||
# Full-stack dev (Next.js + Vite SPA concurrently)
|
||||
bun run dev
|
||||
```
|
||||
|
||||
After `dev:spa` starts, the terminal prints a **Debug Proxy** URL:
|
||||
|
||||
```plaintext
|
||||
Debug Proxy: https://app.lobehub.com/_dangerous_local_dev_proxy?debug-host=http%3A%2F%2Flocalhost%3A9876
|
||||
```
|
||||
|
||||
Open this URL to develop locally against the production backend (app.lobehub.com). The proxy page loads your local Vite dev server's SPA into the online environment, enabling HMR with real server config.
|
||||
|
||||
### Git Workflow
|
||||
|
||||
- **Branch strategy**: `canary` is the development branch (cloud production); `main` is the release branch (periodically cherry-picks from canary)
|
||||
- New branches should be created from `canary`; PRs should target `canary`
|
||||
- Use rebase for `git pull`
|
||||
- Commit messages: prefix with gitmoji
|
||||
- Branch format: `<type>/<feature-name>`
|
||||
|
||||
### Package Management
|
||||
|
||||
- `pnpm` for dependency management
|
||||
- `bun` to run npm scripts
|
||||
- `bunx` for executable npm packages
|
||||
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
# Run specific test (NEVER run `bun run test` - takes ~10 minutes)
|
||||
bunx vitest run --silent='passed-only' '[file-path]'
|
||||
|
||||
# Database package
|
||||
cd packages/database && bunx vitest run --silent='passed-only' '[file]'
|
||||
```
|
||||
|
||||
- Prefer `vi.spyOn` over `vi.mock`
|
||||
- Tests must pass type check: `bun run type-check`
|
||||
- After 2 failed fix attempts, stop and ask for help
|
||||
|
||||
### i18n
|
||||
|
||||
- Add keys to `src/locales/default/namespace.ts`
|
||||
- For dev preview: translate `locales/zh-CN/` and `locales/en-US/`
|
||||
- Don't run `pnpm i18n` - CI handles it
|
||||
|
||||
## Skills (Auto-loaded by Claude)
|
||||
|
||||
Claude Code automatically loads relevant skills from `.agents/skills/`.
|
||||
@AGENTS.md
|
||||
|
||||
@@ -111,7 +111,7 @@ describe('cron command', () => {
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agentCronJob.create.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ agentId: 'a1', name: 'My Job', schedule: '* * * * *' }),
|
||||
expect.objectContaining({ agentId: 'a1', cronPattern: '* * * * *', name: 'My Job' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -125,10 +125,10 @@ export function registerCronCommand(program: Command) {
|
||||
|
||||
const input: Record<string, any> = {
|
||||
agentId: options.agentId,
|
||||
schedule: options.schedule,
|
||||
cronPattern: options.schedule,
|
||||
};
|
||||
if (options.name) input.name = options.name;
|
||||
if (options.prompt) input.prompt = options.prompt;
|
||||
if (options.prompt) input.content = options.prompt;
|
||||
if (options.maxExecutions) input.maxExecutions = Number.parseInt(options.maxExecutions, 10);
|
||||
|
||||
const result = await client.agentCronJob.create.mutate(input as any);
|
||||
@@ -168,8 +168,8 @@ export function registerCronCommand(program: Command) {
|
||||
) => {
|
||||
const data: Record<string, any> = {};
|
||||
if (options.name) data.name = options.name;
|
||||
if (options.schedule) data.schedule = options.schedule;
|
||||
if (options.prompt) data.prompt = options.prompt;
|
||||
if (options.schedule) data.cronPattern = options.schedule;
|
||||
if (options.prompt) data.content = options.prompt;
|
||||
if (options.maxExecutions) data.maxExecutions = Number.parseInt(options.maxExecutions, 10);
|
||||
if (options.enable) data.enabled = true;
|
||||
if (options.disable) data.enabled = false;
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"macOS.services": "خدمات",
|
||||
"macOS.unhide": "إظهار الكل",
|
||||
"tray.open": "فتح {{appName}}",
|
||||
"tray.openMiniToolbar": "Quick Composer",
|
||||
"tray.quit": "خروج",
|
||||
"tray.show": "عرض {{appName}}",
|
||||
"view.forceReload": "إعادة تحميل قسري",
|
||||
@@ -86,6 +87,5 @@
|
||||
"window.minimize": "تصغير",
|
||||
"window.title": "نافذة",
|
||||
"window.toggleFullscreen": "تبديل وضع ملء الشاشة",
|
||||
"window.zoom": "تكبير",
|
||||
"tray.openMiniToolbar": "Quick Composer"
|
||||
"window.zoom": "تكبير"
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"macOS.services": "Услуги",
|
||||
"macOS.unhide": "Покажи всичко",
|
||||
"tray.open": "Отвори {{appName}}",
|
||||
"tray.openMiniToolbar": "Quick Composer",
|
||||
"tray.quit": "Изход",
|
||||
"tray.show": "Покажи {{appName}}",
|
||||
"view.forceReload": "Принудително презареждане",
|
||||
@@ -86,6 +87,5 @@
|
||||
"window.minimize": "Минимизирай",
|
||||
"window.title": "Прозорец",
|
||||
"window.toggleFullscreen": "Превключи на цял екран",
|
||||
"window.zoom": "Мащаб",
|
||||
"tray.openMiniToolbar": "Quick Composer"
|
||||
"window.zoom": "Мащаб"
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"macOS.services": "Dienste",
|
||||
"macOS.unhide": "Alle anzeigen",
|
||||
"tray.open": "{{appName}} öffnen",
|
||||
"tray.openMiniToolbar": "Quick Composer",
|
||||
"tray.quit": "Beenden",
|
||||
"tray.show": "{{appName}} anzeigen",
|
||||
"view.forceReload": "Erzwinge Neuladen",
|
||||
@@ -86,6 +87,5 @@
|
||||
"window.minimize": "Minimieren",
|
||||
"window.title": "Fenster",
|
||||
"window.toggleFullscreen": "Vollbild umschalten",
|
||||
"window.zoom": "Zoom",
|
||||
"tray.openMiniToolbar": "Quick Composer"
|
||||
"window.zoom": "Zoom"
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"macOS.services": "Servicios",
|
||||
"macOS.unhide": "Mostrar todo",
|
||||
"tray.open": "Abrir {{appName}}",
|
||||
"tray.openMiniToolbar": "Quick Composer",
|
||||
"tray.quit": "Salir",
|
||||
"tray.show": "Mostrar {{appName}}",
|
||||
"view.forceReload": "Recargar forzosamente",
|
||||
@@ -86,6 +87,5 @@
|
||||
"window.minimize": "Minimizar",
|
||||
"window.title": "Ventana",
|
||||
"window.toggleFullscreen": "Alternar pantalla completa",
|
||||
"window.zoom": "Zoom",
|
||||
"tray.openMiniToolbar": "Quick Composer"
|
||||
"window.zoom": "Zoom"
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"macOS.services": "خدمات",
|
||||
"macOS.unhide": "نمایش همه",
|
||||
"tray.open": "باز کردن {{appName}}",
|
||||
"tray.openMiniToolbar": "Quick Composer",
|
||||
"tray.quit": "خروج",
|
||||
"tray.show": "نمایش {{appName}}",
|
||||
"view.forceReload": "بارگذاری اجباری",
|
||||
@@ -86,6 +87,5 @@
|
||||
"window.minimize": "کوچک کردن",
|
||||
"window.title": "پنجره",
|
||||
"window.toggleFullscreen": "تغییر به حالت تمام صفحه",
|
||||
"window.zoom": "زوم",
|
||||
"tray.openMiniToolbar": "Quick Composer"
|
||||
"window.zoom": "زوم"
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"macOS.services": "Services",
|
||||
"macOS.unhide": "Tout afficher",
|
||||
"tray.open": "Ouvrir {{appName}}",
|
||||
"tray.openMiniToolbar": "Quick Composer",
|
||||
"tray.quit": "Quitter",
|
||||
"tray.show": "Afficher {{appName}}",
|
||||
"view.forceReload": "Recharger de force",
|
||||
@@ -86,6 +87,5 @@
|
||||
"window.minimize": "Réduire",
|
||||
"window.title": "Fenêtre",
|
||||
"window.toggleFullscreen": "Basculer en plein écran",
|
||||
"window.zoom": "Zoom",
|
||||
"tray.openMiniToolbar": "Quick Composer"
|
||||
"window.zoom": "Zoom"
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"macOS.services": "Servizi",
|
||||
"macOS.unhide": "Mostra tutto",
|
||||
"tray.open": "Apri {{appName}}",
|
||||
"tray.openMiniToolbar": "Quick Composer",
|
||||
"tray.quit": "Esci",
|
||||
"tray.show": "Mostra {{appName}}",
|
||||
"view.forceReload": "Ricarica forzata",
|
||||
@@ -86,6 +87,5 @@
|
||||
"window.minimize": "Minimizza",
|
||||
"window.title": "Finestra",
|
||||
"window.toggleFullscreen": "Attiva/disattiva schermo intero",
|
||||
"window.zoom": "Zoom",
|
||||
"tray.openMiniToolbar": "Quick Composer"
|
||||
"window.zoom": "Zoom"
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"macOS.services": "サービス",
|
||||
"macOS.unhide": "すべて表示",
|
||||
"tray.open": "{{appName}} を開く",
|
||||
"tray.openMiniToolbar": "Quick Composer",
|
||||
"tray.quit": "終了",
|
||||
"tray.show": "{{appName}} を表示",
|
||||
"view.forceReload": "強制再読み込み",
|
||||
@@ -86,6 +87,5 @@
|
||||
"window.minimize": "最小化",
|
||||
"window.title": "ウィンドウ",
|
||||
"window.toggleFullscreen": "フルスクリーン切替",
|
||||
"window.zoom": "ズーム",
|
||||
"tray.openMiniToolbar": "Quick Composer"
|
||||
"window.zoom": "ズーム"
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"macOS.services": "서비스",
|
||||
"macOS.unhide": "모두 표시",
|
||||
"tray.open": "{{appName}} 열기",
|
||||
"tray.openMiniToolbar": "Quick Composer",
|
||||
"tray.quit": "종료",
|
||||
"tray.show": "{{appName}} 표시",
|
||||
"view.forceReload": "강제 새로 고침",
|
||||
@@ -86,6 +87,5 @@
|
||||
"window.minimize": "최소화",
|
||||
"window.title": "창",
|
||||
"window.toggleFullscreen": "전체 화면 전환",
|
||||
"window.zoom": "줌",
|
||||
"tray.openMiniToolbar": "Quick Composer"
|
||||
"window.zoom": "줌"
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"macOS.services": "Diensten",
|
||||
"macOS.unhide": "Toon alles",
|
||||
"tray.open": "Open {{appName}}",
|
||||
"tray.openMiniToolbar": "Quick Composer",
|
||||
"tray.quit": "Afsluiten",
|
||||
"tray.show": "Toon {{appName}}",
|
||||
"view.forceReload": "Forceer herladen",
|
||||
@@ -86,6 +87,5 @@
|
||||
"window.minimize": "Minimaliseren",
|
||||
"window.title": "Venster",
|
||||
"window.toggleFullscreen": "Schakel volledig scherm in/uit",
|
||||
"window.zoom": "Inzoomen",
|
||||
"tray.openMiniToolbar": "Quick Composer"
|
||||
"window.zoom": "Inzoomen"
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"macOS.services": "Usługi",
|
||||
"macOS.unhide": "Pokaż wszystko",
|
||||
"tray.open": "Otwórz {{appName}}",
|
||||
"tray.openMiniToolbar": "Quick Composer",
|
||||
"tray.quit": "Zakończ",
|
||||
"tray.show": "Pokaż {{appName}}",
|
||||
"view.forceReload": "Wymuś ponowne załadowanie",
|
||||
@@ -86,6 +87,5 @@
|
||||
"window.minimize": "Zminimalizuj",
|
||||
"window.title": "Okno",
|
||||
"window.toggleFullscreen": "Przełącz tryb pełnoekranowy",
|
||||
"window.zoom": "Powiększenie",
|
||||
"tray.openMiniToolbar": "Quick Composer"
|
||||
"window.zoom": "Powiększenie"
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"macOS.services": "Serviços",
|
||||
"macOS.unhide": "Mostrar Todos",
|
||||
"tray.open": "Abrir {{appName}}",
|
||||
"tray.openMiniToolbar": "Quick Composer",
|
||||
"tray.quit": "Sair",
|
||||
"tray.show": "Mostrar {{appName}}",
|
||||
"view.forceReload": "Recarregar Forçadamente",
|
||||
@@ -86,6 +87,5 @@
|
||||
"window.minimize": "Minimizar",
|
||||
"window.title": "Janela",
|
||||
"window.toggleFullscreen": "Alternar Tela Cheia",
|
||||
"window.zoom": "Zoom",
|
||||
"tray.openMiniToolbar": "Quick Composer"
|
||||
"window.zoom": "Zoom"
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"macOS.services": "Сервисы",
|
||||
"macOS.unhide": "Показать все",
|
||||
"tray.open": "Открыть {{appName}}",
|
||||
"tray.openMiniToolbar": "Quick Composer",
|
||||
"tray.quit": "Выйти",
|
||||
"tray.show": "Показать {{appName}}",
|
||||
"view.forceReload": "Принудительная перезагрузка",
|
||||
@@ -86,6 +87,5 @@
|
||||
"window.minimize": "Свернуть",
|
||||
"window.title": "Окно",
|
||||
"window.toggleFullscreen": "Переключить полноэкранный режим",
|
||||
"window.zoom": "Масштаб",
|
||||
"tray.openMiniToolbar": "Quick Composer"
|
||||
"window.zoom": "Масштаб"
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"macOS.services": "Hizmetler",
|
||||
"macOS.unhide": "Hepsini Göster",
|
||||
"tray.open": "{{appName}}'i Aç",
|
||||
"tray.openMiniToolbar": "Quick Composer",
|
||||
"tray.quit": "Çık",
|
||||
"tray.show": "{{appName}}'i Göster",
|
||||
"view.forceReload": "Zorla Yenile",
|
||||
@@ -86,6 +87,5 @@
|
||||
"window.minimize": "Küçült",
|
||||
"window.title": "Pencere",
|
||||
"window.toggleFullscreen": "Tam Ekrana Geç",
|
||||
"window.zoom": "Yakınlaştır",
|
||||
"tray.openMiniToolbar": "Quick Composer"
|
||||
"window.zoom": "Yakınlaştır"
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"macOS.services": "Dịch vụ",
|
||||
"macOS.unhide": "Hiện tất cả",
|
||||
"tray.open": "Mở {{appName}}",
|
||||
"tray.openMiniToolbar": "Quick Composer",
|
||||
"tray.quit": "Thoát",
|
||||
"tray.show": "Hiện {{appName}}",
|
||||
"view.forceReload": "Tải lại cưỡng bức",
|
||||
@@ -86,6 +87,5 @@
|
||||
"window.minimize": "Thu nhỏ",
|
||||
"window.title": "Cửa sổ",
|
||||
"window.toggleFullscreen": "Chuyển đổi toàn màn hình",
|
||||
"window.zoom": "Thu phóng",
|
||||
"tray.openMiniToolbar": "Quick Composer"
|
||||
"window.zoom": "Thu phóng"
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"macOS.services": "服務",
|
||||
"macOS.unhide": "全部顯示",
|
||||
"tray.open": "打開 {{appName}}",
|
||||
"tray.openMiniToolbar": "Quick Composer",
|
||||
"tray.quit": "退出",
|
||||
"tray.show": "顯示 {{appName}}",
|
||||
"view.forceReload": "強制重新載入",
|
||||
@@ -86,6 +87,5 @@
|
||||
"window.minimize": "最小化",
|
||||
"window.title": "視窗",
|
||||
"window.toggleFullscreen": "切換全螢幕",
|
||||
"window.zoom": "縮放",
|
||||
"tray.openMiniToolbar": "Quick Composer"
|
||||
"window.zoom": "縮放"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ChildProcess } from 'node:child_process';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { access, appendFile, mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import type { Readable, Writable } from 'node:stream';
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { app as electronApp, BrowserWindow } from 'electron';
|
||||
|
||||
import { getHeterogeneousAgentDriver } from '@/modules/heterogeneousAgent';
|
||||
import { CodexFileChangeTracker } from '@/modules/heterogeneousAgent/codexFileChangeTracker';
|
||||
import type {
|
||||
HeterogeneousAgentImageAttachment,
|
||||
HeterogeneousAgentParsedOutput,
|
||||
@@ -50,6 +51,21 @@ const CODEX_RESUME_CWD_MISMATCH_PATTERNS = [
|
||||
|
||||
/** Directory under appStoragePath for caching downloaded files */
|
||||
const FILE_CACHE_DIR = 'heteroAgent/files';
|
||||
const CLI_TRACE_DIR = '.heerogeneous-tracing';
|
||||
const IMAGE_EXTENSIONS_BY_MIME = {
|
||||
'image/gif': '.gif',
|
||||
'image/jpg': '.jpg',
|
||||
'image/jpeg': '.jpg',
|
||||
'image/pjpeg': '.jpg',
|
||||
'image/png': '.png',
|
||||
'image/webp': '.webp',
|
||||
'image/x-png': '.png',
|
||||
} as const satisfies Record<string, string>;
|
||||
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
const CODEX_STDERR_STATUS_LINE = 'Reading prompt from stdin...';
|
||||
const CODEX_WARN_LOG_PATTERN = /^\d{4}-\d{2}-\d{2}T\S+\s+WARN\s+/;
|
||||
const CODEX_LOG_PATTERN = /^\d{4}-\d{2}-\d{2}T\S+\s+(?:DEBUG|ERROR|INFO|TRACE|WARN)\s+/;
|
||||
const CLI_ERROR_LINE_PATTERN = /^(?:error:|Error:|Usage:)/;
|
||||
|
||||
// ─── IPC types ───
|
||||
|
||||
@@ -119,6 +135,11 @@ interface AgentSession {
|
||||
|
||||
type SessionErrorPayload = HeterogeneousAgentSessionError | string;
|
||||
|
||||
interface CliTraceSession {
|
||||
dir: string;
|
||||
writeQueue: Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* External Agent Controller — manages external agent CLI processes via Electron IPC.
|
||||
*
|
||||
@@ -305,6 +326,49 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
private getRelevantCodexStderr(stderr: string): string {
|
||||
const keptLines: string[] = [];
|
||||
let droppingWarnBlock = false;
|
||||
|
||||
for (const line of stderr.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed === CODEX_STDERR_STATUS_LINE) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (CODEX_WARN_LOG_PATTERN.test(trimmed)) {
|
||||
droppingWarnBlock = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (CODEX_LOG_PATTERN.test(trimmed)) {
|
||||
droppingWarnBlock = false;
|
||||
keptLines.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (droppingWarnBlock && !CLI_ERROR_LINE_PATTERN.test(trimmed)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
droppingWarnBlock = false;
|
||||
keptLines.push(line);
|
||||
}
|
||||
|
||||
return keptLines.join('\n').trim();
|
||||
}
|
||||
|
||||
private getExitErrorMessage(
|
||||
code: number | null,
|
||||
session: AgentSession,
|
||||
stderrOutput: string,
|
||||
): string {
|
||||
const relevantStderr =
|
||||
session.agentType === 'codex' ? this.getRelevantCodexStderr(stderrOutput) : stderrOutput;
|
||||
|
||||
return relevantStderr || `Agent exited with code ${code}`;
|
||||
}
|
||||
|
||||
private async getSpawnPreflightError(
|
||||
session: AgentSession,
|
||||
): Promise<HeterogeneousAgentSessionError | undefined> {
|
||||
@@ -331,6 +395,168 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
return cliMissingError;
|
||||
}
|
||||
|
||||
private get shouldTraceCliOutput(): boolean {
|
||||
return process.env.NODE_ENV !== 'test' && !electronApp.isPackaged;
|
||||
}
|
||||
|
||||
private formatTraceTimestamp(date: Date): string {
|
||||
const pad = (value: number) => value.toString().padStart(2, '0');
|
||||
|
||||
return [
|
||||
date.getFullYear(),
|
||||
pad(date.getMonth() + 1),
|
||||
pad(date.getDate()),
|
||||
'-',
|
||||
pad(date.getHours()),
|
||||
pad(date.getMinutes()),
|
||||
pad(date.getSeconds()),
|
||||
].join('');
|
||||
}
|
||||
|
||||
private sanitizeTracePathSegment(value: string): string {
|
||||
const sanitized = value
|
||||
.replaceAll(path.sep, '-')
|
||||
.replaceAll(/[^\w.-]+/g, '-')
|
||||
.replaceAll(/^-+|-+$/g, '')
|
||||
.slice(0, 80);
|
||||
|
||||
return sanitized || 'unknown';
|
||||
}
|
||||
|
||||
private getAttachmentTraceSummary(image: HeterogeneousAgentImageAttachment) {
|
||||
let urlKind = 'unknown';
|
||||
|
||||
try {
|
||||
urlKind = new URL(image.url).protocol.replace(/:$/, '') || urlKind;
|
||||
} catch {
|
||||
urlKind = image.url.startsWith('data:') ? 'data' : 'unknown';
|
||||
}
|
||||
|
||||
return {
|
||||
id: image.id,
|
||||
urlKind,
|
||||
};
|
||||
}
|
||||
|
||||
private async createCliTraceSession({
|
||||
cliArgs,
|
||||
cwd,
|
||||
imageList,
|
||||
session,
|
||||
stdinPayload,
|
||||
}: {
|
||||
cliArgs: string[];
|
||||
cwd: string;
|
||||
imageList: HeterogeneousAgentImageAttachment[];
|
||||
session: AgentSession;
|
||||
stdinPayload?: string;
|
||||
}): Promise<CliTraceSession | undefined> {
|
||||
if (!this.shouldTraceCliOutput) return;
|
||||
|
||||
// Don't materialize the cwd via mkdir — if the caller passed a stale or
|
||||
// typo'd path, we want spawn() to fail loudly instead of silently running
|
||||
// the agent in an empty auto-created directory.
|
||||
try {
|
||||
await access(cwd);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const createdAt = new Date();
|
||||
const rootDir = path.join(cwd, CLI_TRACE_DIR);
|
||||
const agentDir = path.join(rootDir, this.sanitizeTracePathSegment(session.agentType));
|
||||
const traceId = `${this.formatTraceTimestamp(createdAt)}-${this.sanitizeTracePathSegment(
|
||||
session.sessionId,
|
||||
)}`;
|
||||
const dir = path.join(agentDir, traceId);
|
||||
|
||||
try {
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(path.join(rootDir, '.last-live-trace'), `${dir}\n`);
|
||||
await writeFile(path.join(dir, 'stdout.jsonl'), '');
|
||||
await writeFile(path.join(dir, 'stderr.log'), '');
|
||||
if (stdinPayload !== undefined) {
|
||||
await writeFile(path.join(dir, 'stdin.txt'), '');
|
||||
}
|
||||
await writeFile(
|
||||
path.join(dir, 'meta.json'),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
agentSessionId: session.agentSessionId,
|
||||
agentType: session.agentType,
|
||||
args: cliArgs,
|
||||
attachments: imageList.map((image) => this.getAttachmentTraceSummary(image)),
|
||||
command: session.command,
|
||||
createdAt: createdAt.toISOString(),
|
||||
cwd,
|
||||
envKeys: session.env ? Object.keys(session.env).sort() : [],
|
||||
resumeSessionId: session.resumeSessionId,
|
||||
sessionId: session.sessionId,
|
||||
stdinBytes: stdinPayload === undefined ? 0 : Buffer.byteLength(stdinPayload),
|
||||
stdinFile: stdinPayload === undefined ? undefined : 'stdin.txt',
|
||||
stderrFile: 'stderr.log',
|
||||
stdoutFile: 'stdout.jsonl',
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
|
||||
return { dir, writeQueue: Promise.resolve() };
|
||||
} catch (error) {
|
||||
logger.warn('Failed to initialize CLI trace directory:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private queueCliTraceWrite(
|
||||
trace: CliTraceSession | undefined,
|
||||
write: () => Promise<void>,
|
||||
): Promise<void> | undefined {
|
||||
if (!trace) return;
|
||||
|
||||
trace.writeQueue = trace.writeQueue.then(write).catch((error) => {
|
||||
logger.warn('Failed to write CLI trace file:', error);
|
||||
});
|
||||
|
||||
return trace.writeQueue;
|
||||
}
|
||||
|
||||
private appendCliTraceFile(
|
||||
trace: CliTraceSession | undefined,
|
||||
fileName: string,
|
||||
data: Buffer | string,
|
||||
): Promise<void> | undefined {
|
||||
if (!trace) return;
|
||||
|
||||
const filePath = path.join(trace.dir, fileName);
|
||||
|
||||
return this.queueCliTraceWrite(trace, () => appendFile(filePath, data));
|
||||
}
|
||||
|
||||
private writeCliTraceFile(
|
||||
trace: CliTraceSession | undefined,
|
||||
fileName: string,
|
||||
data: string,
|
||||
): Promise<void> | undefined {
|
||||
if (!trace) return;
|
||||
|
||||
const filePath = path.join(trace.dir, fileName);
|
||||
|
||||
return this.queueCliTraceWrite(trace, () => writeFile(filePath, data));
|
||||
}
|
||||
|
||||
private writeCliTraceJson(
|
||||
trace: CliTraceSession | undefined,
|
||||
fileName: string,
|
||||
payload: unknown,
|
||||
): Promise<void> | undefined {
|
||||
return this.writeCliTraceFile(trace, fileName, `${JSON.stringify(payload, null, 2)}\n`);
|
||||
}
|
||||
|
||||
private async flushCliTrace(trace: CliTraceSession | undefined): Promise<void> {
|
||||
await trace?.writeQueue;
|
||||
}
|
||||
|
||||
// ─── Broadcast ───
|
||||
|
||||
private broadcast<T>(channel: string, data: T) {
|
||||
@@ -400,26 +626,42 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
return { buffer, mimeType };
|
||||
}
|
||||
|
||||
private normalizeMimeType(mimeType: string): string {
|
||||
return mimeType.split(';')[0]?.trim().toLowerCase() || '';
|
||||
}
|
||||
|
||||
private guessImageExtensionByBuffer(buffer: Buffer): string | undefined {
|
||||
if (buffer.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) return '.png';
|
||||
if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return '.jpg';
|
||||
|
||||
const gifSignature = buffer.subarray(0, 6).toString('ascii');
|
||||
if (gifSignature === 'GIF87a' || gifSignature === 'GIF89a') return '.gif';
|
||||
|
||||
if (
|
||||
buffer.subarray(0, 4).toString('ascii') === 'RIFF' &&
|
||||
buffer.subarray(8, 12).toString('ascii') === 'WEBP'
|
||||
) {
|
||||
return '.webp';
|
||||
}
|
||||
}
|
||||
|
||||
private guessImageExtension(
|
||||
mimeType: string,
|
||||
image: HeterogeneousAgentImageAttachment,
|
||||
buffer: Buffer,
|
||||
): string | undefined {
|
||||
const knownByMime: Record<string, string> = {
|
||||
'image/gif': '.gif',
|
||||
'image/jpeg': '.jpg',
|
||||
'image/png': '.png',
|
||||
'image/webp': '.webp',
|
||||
};
|
||||
|
||||
if (knownByMime[mimeType]) return knownByMime[mimeType];
|
||||
const knownByMime = IMAGE_EXTENSIONS_BY_MIME[this.normalizeMimeType(mimeType)];
|
||||
if (knownByMime) return knownByMime;
|
||||
|
||||
try {
|
||||
const pathname = new URL(image.url).pathname;
|
||||
const ext = path.extname(pathname);
|
||||
return ext || undefined;
|
||||
const ext = path.extname(pathname).toLowerCase();
|
||||
if (ext) return ext === '.jpeg' ? '.jpg' : ext;
|
||||
} catch {
|
||||
return undefined;
|
||||
// Fall through to byte sniffing below.
|
||||
}
|
||||
|
||||
return this.guessImageExtensionByBuffer(buffer);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -429,7 +671,11 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
private async resolveCliImagePath(image: HeterogeneousAgentImageAttachment): Promise<string> {
|
||||
const { buffer, mimeType } = await this.resolveImage(image);
|
||||
const cacheKey = this.getImageCacheKey(image.id);
|
||||
const ext = this.guessImageExtension(mimeType, image) || '';
|
||||
const ext = this.guessImageExtension(mimeType, image, buffer);
|
||||
if (!ext) {
|
||||
throw new Error(`Unsupported image type for ${image.id}`);
|
||||
}
|
||||
|
||||
const filePath = path.join(this.fileCacheDir, `${cacheKey}${ext}`);
|
||||
|
||||
try {
|
||||
@@ -445,18 +691,31 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
private async resolveCliImagePaths(
|
||||
imageList: HeterogeneousAgentImageAttachment[] = [],
|
||||
): Promise<string[]> {
|
||||
const resolved = await Promise.all(
|
||||
imageList.map(async (image) => {
|
||||
try {
|
||||
return await this.resolveCliImagePath(image);
|
||||
} catch (err) {
|
||||
logger.error(`Failed to materialize image ${image.id} for CLI:`, err);
|
||||
return undefined;
|
||||
}
|
||||
}),
|
||||
const results = await Promise.allSettled(
|
||||
imageList.map((image) => this.resolveCliImagePath(image)),
|
||||
);
|
||||
|
||||
return resolved.filter(Boolean) as string[];
|
||||
const imagePaths: string[] = [];
|
||||
const failures: string[] = [];
|
||||
|
||||
for (const [index, result] of results.entries()) {
|
||||
const imageId = imageList[index]?.id ?? `image-${index + 1}`;
|
||||
|
||||
if (result.status === 'fulfilled') {
|
||||
imagePaths.push(result.value);
|
||||
continue;
|
||||
}
|
||||
|
||||
const message = this.getErrorMessage(result.reason) || 'Unknown error';
|
||||
logger.error(`Failed to materialize image ${imageId} for CLI:`, result.reason);
|
||||
failures.push(`${imageId}: ${message}`);
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
throw new Error(`Failed to attach image(s) to CLI: ${failures.join('; ')}`);
|
||||
}
|
||||
|
||||
return imagePaths;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -550,14 +809,20 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
resumeSessionId: session.agentSessionId,
|
||||
});
|
||||
const useStdin = spawnPlan.stdinPayload !== undefined;
|
||||
const cliArgs = spawnPlan.args;
|
||||
|
||||
// Fall back to the user's Desktop so the process never inherits
|
||||
// the Electron parent's cwd (which is `/` when launched from Finder).
|
||||
const cwd = session.cwd || electronApp.getPath('desktop');
|
||||
const traceSession = await this.createCliTraceSession({
|
||||
cliArgs,
|
||||
cwd,
|
||||
imageList: params.imageList ?? [],
|
||||
session,
|
||||
stdinPayload: spawnPlan.stdinPayload,
|
||||
});
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const cliArgs = spawnPlan.args;
|
||||
|
||||
// Fall back to the user's Desktop so the process never inherits
|
||||
// the Electron parent's cwd (which is `/` when launched from Finder).
|
||||
const cwd = session.cwd || electronApp.getPath('desktop');
|
||||
|
||||
logger.info('Spawning agent:', session.command, cliArgs.join(' '), `(cwd: ${cwd})`);
|
||||
|
||||
// `detached: true` on Unix puts the child in a new process group so we
|
||||
@@ -579,6 +844,7 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
|
||||
// In stdin mode, write the prepared payload and close stdin.
|
||||
if (useStdin && spawnPlan.stdinPayload !== undefined && proc.stdin) {
|
||||
void this.writeCliTraceFile(traceSession, 'stdin.txt', spawnPlan.stdinPayload);
|
||||
const stdin = proc.stdin as Writable;
|
||||
stdin.write(spawnPlan.stdinPayload, () => {
|
||||
stdin.end();
|
||||
@@ -587,23 +853,37 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
|
||||
session.process = proc;
|
||||
const streamProcessor = driver.createStreamProcessor();
|
||||
const codexFileChangeTracker =
|
||||
session.agentType === 'codex' ? new CodexFileChangeTracker() : undefined;
|
||||
let stdoutBroadcastQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
const broadcastParsedOutputs = (parsedOutputs: HeterogeneousAgentParsedOutput[]) => {
|
||||
for (const parsedOutput of parsedOutputs) {
|
||||
if (parsedOutput.agentSessionId) {
|
||||
session.agentSessionId = parsedOutput.agentSessionId;
|
||||
}
|
||||
stdoutBroadcastQueue = stdoutBroadcastQueue
|
||||
.then(async () => {
|
||||
for (const parsedOutput of parsedOutputs) {
|
||||
if (parsedOutput.agentSessionId) {
|
||||
session.agentSessionId = parsedOutput.agentSessionId;
|
||||
}
|
||||
|
||||
this.broadcast('heteroAgentRawLine', {
|
||||
line: parsedOutput.payload,
|
||||
sessionId: session.sessionId,
|
||||
const line = codexFileChangeTracker
|
||||
? await codexFileChangeTracker.track(parsedOutput.payload)
|
||||
: parsedOutput.payload;
|
||||
|
||||
this.broadcast('heteroAgentRawLine', {
|
||||
line,
|
||||
sessionId: session.sessionId,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error('Failed to broadcast parsed agent output:', error);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Stream stdout events as raw provider payloads to Renderer.
|
||||
const stdout = proc.stdout as Readable;
|
||||
stdout.on('data', (chunk: Buffer) => {
|
||||
void this.appendCliTraceFile(traceSession, 'stdout.jsonl', chunk);
|
||||
broadcastParsedOutputs(streamProcessor.push(chunk));
|
||||
});
|
||||
stdout.on('end', () => {
|
||||
@@ -614,11 +894,17 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
const stderrChunks: string[] = [];
|
||||
const stderr = proc.stderr as Readable;
|
||||
stderr.on('data', (chunk: Buffer) => {
|
||||
void this.appendCliTraceFile(traceSession, 'stderr.log', chunk);
|
||||
stderrChunks.push(chunk.toString('utf8'));
|
||||
});
|
||||
|
||||
proc.on('error', (err) => {
|
||||
logger.error('Agent process error:', err);
|
||||
void this.writeCliTraceJson(traceSession, 'process-error.json', {
|
||||
message: err.message,
|
||||
name: err.name,
|
||||
});
|
||||
void this.flushCliTrace(traceSession);
|
||||
const sessionError = this.getSessionErrorPayload(err, session);
|
||||
this.broadcast('heteroAgentSessionError', {
|
||||
error: sessionError,
|
||||
@@ -628,33 +914,44 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
});
|
||||
|
||||
proc.on('exit', (code, signal) => {
|
||||
logger.info('Agent process exited:', { code, sessionId: session.sessionId, signal });
|
||||
session.process = undefined;
|
||||
|
||||
// If *we* killed it (cancel / stop / before-quit), treat the non-zero
|
||||
// exit as a clean shutdown — surfacing it as an error would make a
|
||||
// user-initiated cancel look like an agent failure, and an Electron
|
||||
// shutdown affecting OTHER running CC sessions would pollute their
|
||||
// topics with a misleading "Agent exited with code 143" message.
|
||||
if (session.cancelledByUs) {
|
||||
this.broadcast('heteroAgentSessionComplete', { sessionId: session.sessionId });
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
if (code === 0) {
|
||||
this.broadcast('heteroAgentSessionComplete', { sessionId: session.sessionId });
|
||||
resolve();
|
||||
} else {
|
||||
const stderrOutput = stderrChunks.join('').trim();
|
||||
const errorMsg = stderrOutput || `Agent exited with code ${code}`;
|
||||
const sessionError = this.getSessionErrorPayload(errorMsg, session);
|
||||
this.broadcast('heteroAgentSessionError', {
|
||||
error: sessionError,
|
||||
sessionId: session.sessionId,
|
||||
void stdoutBroadcastQueue.finally(async () => {
|
||||
void this.writeCliTraceJson(traceSession, 'exit.json', {
|
||||
code,
|
||||
finishedAt: new Date().toISOString(),
|
||||
signal,
|
||||
});
|
||||
reject(new Error(typeof sessionError === 'string' ? sessionError : sessionError.message));
|
||||
}
|
||||
await this.flushCliTrace(traceSession);
|
||||
|
||||
logger.info('Agent process exited:', { code, sessionId: session.sessionId, signal });
|
||||
session.process = undefined;
|
||||
|
||||
// If *we* killed it (cancel / stop / before-quit), treat the non-zero
|
||||
// exit as a clean shutdown — surfacing it as an error would make a
|
||||
// user-initiated cancel look like an agent failure, and an Electron
|
||||
// shutdown affecting OTHER running CC sessions would pollute their
|
||||
// topics with a misleading "Agent exited with code 143" message.
|
||||
if (session.cancelledByUs) {
|
||||
this.broadcast('heteroAgentSessionComplete', { sessionId: session.sessionId });
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
if (code === 0) {
|
||||
this.broadcast('heteroAgentSessionComplete', { sessionId: session.sessionId });
|
||||
resolve();
|
||||
} else {
|
||||
const stderrOutput = stderrChunks.join('').trim();
|
||||
const errorMsg = this.getExitErrorMessage(code, session, stderrOutput);
|
||||
const sessionError = this.getSessionErrorPayload(errorMsg, session);
|
||||
this.broadcast('heteroAgentSessionError', {
|
||||
error: sessionError,
|
||||
sessionId: session.sessionId,
|
||||
});
|
||||
reject(
|
||||
new Error(typeof sessionError === 'string' ? sessionError : sessionError.message),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import type { UploadFileParams } from '@lobechat/electron-client-ipc';
|
||||
|
||||
import FileService from '@/services/fileSrv';
|
||||
|
||||
import { ControllerModule, IpcMethod } from './index';
|
||||
|
||||
export default class UploadFileCtr extends ControllerModule {
|
||||
static override readonly groupName = 'upload';
|
||||
private get fileService() {
|
||||
return this.app.getService(FileService);
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async uploadFile(params: UploadFileParams) {
|
||||
return this.fileService.uploadFile(params);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ vi.mock('electron', () => ({
|
||||
BrowserWindow: { getAllWindows: () => [] },
|
||||
app: {
|
||||
getPath: vi.fn((name: string) => (name === 'desktop' ? FAKE_DESKTOP_PATH : `/fake/${name}`)),
|
||||
isPackaged: false,
|
||||
on: vi.fn(),
|
||||
},
|
||||
ipcMain: { handle: vi.fn() },
|
||||
@@ -56,9 +57,11 @@ vi.mock('node:child_process', async (importOriginal) => {
|
||||
*/
|
||||
const createFakeProc = ({
|
||||
exitCode = 0,
|
||||
stderrLines = [],
|
||||
stdoutLines = [],
|
||||
}: {
|
||||
exitCode?: number;
|
||||
stderrLines?: string[];
|
||||
stdoutLines?: string[];
|
||||
} = {}) => {
|
||||
const proc = new EventEmitter() as any;
|
||||
@@ -86,6 +89,9 @@ const createFakeProc = ({
|
||||
for (const line of stdoutLines) {
|
||||
stdout.write(line);
|
||||
}
|
||||
for (const line of stderrLines) {
|
||||
stderr.write(line);
|
||||
}
|
||||
stdout.end();
|
||||
stderr.end();
|
||||
proc.emit('exit', exitCode);
|
||||
@@ -381,8 +387,9 @@ describe('HeterogeneousAgentCtr', () => {
|
||||
expect(command).toBe('codex');
|
||||
expect(cliArgs).not.toContain(prompt);
|
||||
expect(cliArgs).toEqual(
|
||||
expect.arrayContaining(['exec', '--json', '--skip-git-repo-check', '--full-auto', '-']),
|
||||
expect.arrayContaining(['exec', '--json', '--skip-git-repo-check', '--full-auto']),
|
||||
);
|
||||
expect(cliArgs).not.toContain('-');
|
||||
expect(writes).toEqual([prompt]);
|
||||
});
|
||||
|
||||
@@ -398,8 +405,11 @@ describe('HeterogeneousAgentCtr', () => {
|
||||
const imagePaths = getFlagValues(cliArgs, '--image');
|
||||
|
||||
expect(cliArgs).not.toContain('describe these screenshots');
|
||||
expect(cliArgs).not.toContain('-');
|
||||
expect(cliArgs.filter((arg) => arg === '--image')).toHaveLength(2);
|
||||
expect(imagePaths).toHaveLength(2);
|
||||
expect(imagePaths).not.toContain('-');
|
||||
expect(cliArgs.at(-1)).toBe(imagePaths[1]);
|
||||
expect(imagePaths[0]).toMatch(/\.png$/);
|
||||
expect(imagePaths[1]).toMatch(/\.jpg$/);
|
||||
expect(
|
||||
@@ -413,22 +423,94 @@ describe('HeterogeneousAgentCtr', () => {
|
||||
expect(writes).toEqual(['describe these screenshots']);
|
||||
});
|
||||
|
||||
it('skips images that fail to materialize and still forwards the remaining --image args', async () => {
|
||||
it('normalizes parameterized image MIME types before choosing the CLI file extension', async () => {
|
||||
const imageList = [
|
||||
{ id: 'image-with-params', url: 'data:image/png;charset=utf-8;base64,UE5HX1RFU1Q=' },
|
||||
];
|
||||
const { cliArgs } = await runSendPrompt('describe this screenshot', {}, [], { imageList });
|
||||
|
||||
const imagePaths = getFlagValues(cliArgs, '--image');
|
||||
|
||||
expect(imagePaths).toHaveLength(1);
|
||||
expect(imagePaths[0]).toMatch(/\.png$/);
|
||||
await expect(readFile(imagePaths[0], 'utf8')).resolves.toBe('PNG_TEST');
|
||||
});
|
||||
|
||||
it('sniffs image bytes when MIME and URL do not expose a usable extension', async () => {
|
||||
const pngBytes = Buffer.concat([
|
||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||
Buffer.from('PNG_TEST'),
|
||||
]);
|
||||
const imageList = [
|
||||
{
|
||||
id: 'image-octet',
|
||||
url: `data:application/octet-stream;base64,${pngBytes.toString('base64')}`,
|
||||
},
|
||||
];
|
||||
const { cliArgs } = await runSendPrompt('describe this screenshot', {}, [], { imageList });
|
||||
|
||||
const imagePaths = getFlagValues(cliArgs, '--image');
|
||||
|
||||
expect(imagePaths).toHaveLength(1);
|
||||
expect(imagePaths[0]).toMatch(/\.png$/);
|
||||
await expect(readFile(imagePaths[0])).resolves.toEqual(pngBytes);
|
||||
});
|
||||
|
||||
it('fails before spawning Codex when any image cannot be materialized', async () => {
|
||||
const imageList = [
|
||||
{ id: 'good-image', url: 'data:image/png;base64,VkFMSURfSU1BR0U=' },
|
||||
{ id: 'bad-image', url: 'bad://broken-image' },
|
||||
];
|
||||
const { cliArgs, writes } = await runSendPrompt('inspect the valid screenshot only', {}, [], {
|
||||
imageList,
|
||||
const { proc } = createFakeProc();
|
||||
nextFakeProc = proc;
|
||||
const ctr = new HeterogeneousAgentCtr({
|
||||
appStoragePath,
|
||||
storeManager: { get: vi.fn() },
|
||||
} as any);
|
||||
const { sessionId } = await ctr.startSession({
|
||||
agentType: 'codex',
|
||||
command: 'codex',
|
||||
});
|
||||
|
||||
const imagePaths = getFlagValues(cliArgs, '--image');
|
||||
await expect(
|
||||
ctr.sendPrompt({
|
||||
imageList,
|
||||
prompt: 'inspect the screenshots',
|
||||
sessionId,
|
||||
}),
|
||||
).rejects.toThrow('Failed to attach image(s) to CLI');
|
||||
expect(spawnCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
expect(cliArgs.filter((arg) => arg === '--image')).toHaveLength(1);
|
||||
expect(imagePaths).toHaveLength(1);
|
||||
expect(imagePaths[0]).toMatch(/\.png$/);
|
||||
await expect(readFile(imagePaths[0], 'utf8')).resolves.toBe('VALID_IMAGE');
|
||||
expect(writes).toEqual(['inspect the valid screenshot only']);
|
||||
it('does not surface Codex stderr status and warn logs as the terminal error', async () => {
|
||||
const { proc } = createFakeProc({
|
||||
exitCode: 1,
|
||||
stderrLines: [
|
||||
'Reading prompt from stdin...\n',
|
||||
'2026-04-25T09:24:08.165782Z WARN codex_core::session_startup_prewarm: startup websocket prewarm setup failed\n',
|
||||
'<html>\n',
|
||||
' <body>challenge page</body>\n',
|
||||
'</html>\n',
|
||||
],
|
||||
stdoutLines: [
|
||||
`${JSON.stringify({ thread_id: 'thread_codex_123', type: 'thread.started' })}\n`,
|
||||
`${JSON.stringify({ type: 'turn.started' })}\n`,
|
||||
`${JSON.stringify({ message: 'real Codex JSONL error', type: 'error' })}\n`,
|
||||
],
|
||||
});
|
||||
nextFakeProc = proc;
|
||||
const ctr = new HeterogeneousAgentCtr({
|
||||
appStoragePath,
|
||||
storeManager: { get: vi.fn() },
|
||||
} as any);
|
||||
const { sessionId } = await ctr.startSession({
|
||||
agentType: 'codex',
|
||||
command: 'codex',
|
||||
});
|
||||
|
||||
await expect(ctr.sendPrompt({ prompt: 'hello', sessionId })).rejects.toThrow(
|
||||
'Agent exited with code 1',
|
||||
);
|
||||
});
|
||||
|
||||
it('uses codex exec resume syntax when continuing an existing thread', async () => {
|
||||
@@ -437,9 +519,73 @@ describe('HeterogeneousAgentCtr', () => {
|
||||
expect(cliArgs.slice(0, 2)).toEqual(['exec', 'resume']);
|
||||
expect(cliArgs).toContain('thread_abc');
|
||||
expect(cliArgs).not.toContain('--resume');
|
||||
expect(cliArgs.at(-2)).toBe('thread_abc');
|
||||
expect(cliArgs.at(-1)).toBe('-');
|
||||
});
|
||||
|
||||
it('writes raw CLI streams to a dev trace directory grouped by agent type', async () => {
|
||||
const originalNodeEnv = process.env.NODE_ENV;
|
||||
process.env.NODE_ENV = 'development';
|
||||
|
||||
try {
|
||||
const prompt = 'trace this run';
|
||||
const rawLine = `${JSON.stringify({
|
||||
thread_id: 'thread_codex_trace',
|
||||
type: 'thread.started',
|
||||
})}\n`;
|
||||
const { sessionId } = await runSendPrompt(prompt, { cwd: appStoragePath }, [rawLine], {
|
||||
imageList: [{ id: 'image-1', url: 'data:image/png;base64,UE5HX1RFU1Q=' }],
|
||||
});
|
||||
const traceRoot = path.join(appStoragePath, '.heerogeneous-tracing');
|
||||
const agentTraceRoot = path.join(traceRoot, 'codex');
|
||||
const traceDirs = await readdir(agentTraceRoot);
|
||||
|
||||
expect(traceDirs).toHaveLength(1);
|
||||
|
||||
const traceDir = path.join(agentTraceRoot, traceDirs[0]);
|
||||
|
||||
await expect(readFile(path.join(traceRoot, '.last-live-trace'), 'utf8')).resolves.toBe(
|
||||
`${traceDir}\n`,
|
||||
);
|
||||
await expect(readFile(path.join(traceDir, 'stdin.txt'), 'utf8')).resolves.toBe(prompt);
|
||||
await expect(readFile(path.join(traceDir, 'stdout.jsonl'), 'utf8')).resolves.toBe(rawLine);
|
||||
await expect(readFile(path.join(traceDir, 'stderr.log'), 'utf8')).resolves.toBe('');
|
||||
await expect(readFile(path.join(traceDir, 'exit.json'), 'utf8')).resolves.toContain(
|
||||
'"code": 0',
|
||||
);
|
||||
|
||||
const meta = JSON.parse(await readFile(path.join(traceDir, 'meta.json'), 'utf8'));
|
||||
|
||||
expect(meta).toMatchObject({
|
||||
agentType: 'codex',
|
||||
command: 'codex',
|
||||
cwd: appStoragePath,
|
||||
sessionId,
|
||||
stdinBytes: Buffer.byteLength(prompt),
|
||||
stdoutFile: 'stdout.jsonl',
|
||||
});
|
||||
expect(meta.args).not.toContain('-');
|
||||
expect(meta.attachments).toEqual([{ id: 'image-1', urlKind: 'data' }]);
|
||||
} finally {
|
||||
process.env.NODE_ENV = originalNodeEnv;
|
||||
}
|
||||
});
|
||||
|
||||
it('skips trace creation (and never auto-creates the cwd) when the cwd is missing', async () => {
|
||||
const originalNodeEnv = process.env.NODE_ENV;
|
||||
process.env.NODE_ENV = 'development';
|
||||
|
||||
const missingCwd = path.join(appStoragePath, 'does-not-exist');
|
||||
|
||||
try {
|
||||
await runSendPrompt('trace this run', { cwd: missingCwd });
|
||||
|
||||
await expect(access(missingCwd)).rejects.toThrow();
|
||||
} finally {
|
||||
process.env.NODE_ENV = originalNodeEnv;
|
||||
}
|
||||
});
|
||||
|
||||
it('captures the Codex thread id from json output for later resume', async () => {
|
||||
const { ctr, sessionId } = await runSendPrompt('hello', {}, [
|
||||
`${JSON.stringify({ thread_id: 'thread_codex_123', type: 'thread.started' })}\n`,
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { App } from '@/core/App';
|
||||
import { IpcHandler } from '@/utils/ipc/base';
|
||||
|
||||
import UploadFileCtr from '../UploadFileCtr';
|
||||
|
||||
const { ipcHandlers, ipcMainHandleMock } = vi.hoisted(() => {
|
||||
const handlers = new Map<string, (event: any, ...args: any[]) => any>();
|
||||
const handle = vi.fn((channel: string, handler: any) => {
|
||||
handlers.set(channel, handler);
|
||||
});
|
||||
return { ipcHandlers: handlers, ipcMainHandleMock: handle };
|
||||
});
|
||||
|
||||
const invokeIpc = async <T = any>(channel: string, payload?: any): Promise<T> => {
|
||||
const handler = ipcHandlers.get(channel);
|
||||
if (!handler) throw new Error(`IPC handler for ${channel} not found`);
|
||||
|
||||
const fakeEvent = { sender: { id: 'test' } as any };
|
||||
if (payload === undefined) return handler(fakeEvent);
|
||||
return handler(fakeEvent, payload);
|
||||
};
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
handle: ipcMainHandleMock,
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock FileService module to prevent electron dependency issues
|
||||
vi.mock('@/services/fileSrv', () => ({
|
||||
default: class MockFileService {},
|
||||
}));
|
||||
|
||||
// Mock FileService instance methods
|
||||
const mockFileService = {
|
||||
uploadFile: vi.fn(),
|
||||
};
|
||||
|
||||
const mockApp = {
|
||||
getService: vi.fn(() => mockFileService),
|
||||
} as unknown as App;
|
||||
|
||||
describe('UploadFileCtr', () => {
|
||||
let _controller: UploadFileCtr;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
ipcHandlers.clear();
|
||||
ipcMainHandleMock.mockClear();
|
||||
(IpcHandler.getInstance() as any).registeredChannels?.clear();
|
||||
_controller = new UploadFileCtr(mockApp);
|
||||
});
|
||||
|
||||
describe('uploadFile', () => {
|
||||
it('should upload file successfully', async () => {
|
||||
const params = {
|
||||
hash: 'abc123',
|
||||
path: '/test/file.txt',
|
||||
content: new ArrayBuffer(16),
|
||||
filename: 'file.txt',
|
||||
type: 'text/plain',
|
||||
};
|
||||
const expectedResult = { id: 'file-id-123', url: '/files/file-id-123' };
|
||||
mockFileService.uploadFile.mockResolvedValue(expectedResult);
|
||||
|
||||
const result = await invokeIpc('upload.uploadFile', params);
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
expect(mockFileService.uploadFile).toHaveBeenCalledWith(params);
|
||||
});
|
||||
|
||||
it('should handle upload error', async () => {
|
||||
const params = {
|
||||
hash: 'abc123',
|
||||
path: '/test/file.txt',
|
||||
content: new ArrayBuffer(16),
|
||||
filename: 'file.txt',
|
||||
type: 'text/plain',
|
||||
};
|
||||
const error = new Error('Upload failed');
|
||||
mockFileService.uploadFile.mockRejectedValue(error);
|
||||
|
||||
await expect(invokeIpc('upload.uploadFile', params)).rejects.toThrow('Upload failed');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -22,7 +22,6 @@ import SystemController from './SystemCtr';
|
||||
import ToolDetectorCtr from './ToolDetectorCtr';
|
||||
import TrayMenuCtr from './TrayMenuCtr';
|
||||
import UpdaterCtr from './UpdaterCtr';
|
||||
import UploadFileCtr from './UploadFileCtr';
|
||||
|
||||
export const controllerIpcConstructors = [
|
||||
HeterogeneousAgentCtr,
|
||||
@@ -47,7 +46,6 @@ export const controllerIpcConstructors = [
|
||||
ToolDetectorCtr,
|
||||
TrayMenuCtr,
|
||||
UpdaterCtr,
|
||||
UploadFileCtr,
|
||||
] as const satisfies readonly IpcServiceConstructor[];
|
||||
|
||||
type DesktopControllerIpcConstructors = typeof controllerIpcConstructors;
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { mkdtemp, rename, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { CodexFileChangeTracker } from './codexFileChangeTracker';
|
||||
|
||||
describe('CodexFileChangeTracker', () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempDirs.map((dir) => rm(dir, { force: true, recursive: true })));
|
||||
tempDirs.length = 0;
|
||||
});
|
||||
|
||||
it('enriches completed file_change payloads with per-file and total line stats', async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'codex-file-change-tracker-'));
|
||||
tempDirs.push(dir);
|
||||
|
||||
const updatePath = path.join(dir, 'a.txt');
|
||||
const addPath = path.join(dir, 'b.txt');
|
||||
|
||||
await writeFile(updatePath, 'hello\n', 'utf8');
|
||||
|
||||
const tracker = new CodexFileChangeTracker();
|
||||
|
||||
await tracker.track({
|
||||
item: {
|
||||
changes: [
|
||||
{ kind: 'update', path: updatePath },
|
||||
{ kind: 'add', path: addPath },
|
||||
],
|
||||
id: 'item_1',
|
||||
type: 'file_change',
|
||||
},
|
||||
type: 'item.started',
|
||||
});
|
||||
|
||||
await writeFile(updatePath, 'hello\nappended line\n', 'utf8');
|
||||
await writeFile(addPath, 'line one\nline two\n', 'utf8');
|
||||
|
||||
const enriched = await tracker.track({
|
||||
item: {
|
||||
changes: [
|
||||
{ kind: 'update', path: updatePath },
|
||||
{ kind: 'add', path: addPath },
|
||||
],
|
||||
id: 'item_1',
|
||||
type: 'file_change',
|
||||
},
|
||||
type: 'item.completed',
|
||||
});
|
||||
|
||||
expect(enriched.item).toMatchObject({
|
||||
changes: [
|
||||
{
|
||||
kind: 'update',
|
||||
linesAdded: 1,
|
||||
linesDeleted: 0,
|
||||
path: updatePath,
|
||||
},
|
||||
{
|
||||
kind: 'add',
|
||||
linesAdded: 2,
|
||||
linesDeleted: 0,
|
||||
path: addPath,
|
||||
},
|
||||
],
|
||||
linesAdded: 3,
|
||||
linesDeleted: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('treats rename changes as metadata-only and keeps line stats at zero', async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'codex-file-change-tracker-'));
|
||||
tempDirs.push(dir);
|
||||
|
||||
const beforePath = path.join(dir, 'before.txt');
|
||||
const afterPath = path.join(dir, 'after.txt');
|
||||
|
||||
await writeFile(beforePath, 'content\n', 'utf8');
|
||||
|
||||
const tracker = new CodexFileChangeTracker();
|
||||
|
||||
await tracker.track({
|
||||
item: {
|
||||
changes: [{ kind: 'rename', path: afterPath }],
|
||||
id: 'item_rename',
|
||||
type: 'file_change',
|
||||
},
|
||||
type: 'item.started',
|
||||
});
|
||||
|
||||
await rename(beforePath, afterPath);
|
||||
|
||||
const enriched = await tracker.track({
|
||||
item: {
|
||||
changes: [{ kind: 'rename', path: afterPath }],
|
||||
id: 'item_rename',
|
||||
type: 'file_change',
|
||||
},
|
||||
type: 'item.completed',
|
||||
});
|
||||
|
||||
expect(enriched.item).toMatchObject({
|
||||
changes: [{ kind: 'rename', linesAdded: 0, linesDeleted: 0, path: afterPath }],
|
||||
linesAdded: 0,
|
||||
linesDeleted: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('counts added lines even when file content begins with repeated plus markers', async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'codex-file-change-tracker-'));
|
||||
tempDirs.push(dir);
|
||||
|
||||
const addPath = path.join(dir, 'plus-prefixed.txt');
|
||||
const tracker = new CodexFileChangeTracker();
|
||||
|
||||
await tracker.track({
|
||||
item: {
|
||||
changes: [{ kind: 'add', path: addPath }],
|
||||
id: 'item_plus_prefix',
|
||||
type: 'file_change',
|
||||
},
|
||||
type: 'item.started',
|
||||
});
|
||||
|
||||
await writeFile(addPath, '++leading content\n+++header lookalike\n', 'utf8');
|
||||
|
||||
const enriched = await tracker.track({
|
||||
item: {
|
||||
changes: [{ kind: 'add', path: addPath }],
|
||||
id: 'item_plus_prefix',
|
||||
type: 'file_change',
|
||||
},
|
||||
type: 'item.completed',
|
||||
});
|
||||
|
||||
expect(enriched.item).toMatchObject({
|
||||
changes: [{ kind: 'add', linesAdded: 2, linesDeleted: 0, path: addPath }],
|
||||
linesAdded: 2,
|
||||
linesDeleted: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
import { access, readFile } from 'node:fs/promises';
|
||||
|
||||
import { createPatch } from 'diff';
|
||||
|
||||
interface CodexFileChangeEntry {
|
||||
kind?: string;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
interface CodexFileChangeSnapshot {
|
||||
content?: string;
|
||||
exists: boolean;
|
||||
}
|
||||
|
||||
interface CodexFileChangeItem {
|
||||
changes?: CodexFileChangeEntry[];
|
||||
id?: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
interface CodexFileChangePayload {
|
||||
item?: CodexFileChangeItem;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
interface CodexFileChangeLineStats {
|
||||
linesAdded: number;
|
||||
linesDeleted: number;
|
||||
}
|
||||
|
||||
interface CodexTrackedFileChangeEntry extends CodexFileChangeEntry, CodexFileChangeLineStats {}
|
||||
|
||||
interface CodexTrackedFileChangeItem extends CodexFileChangeItem, CodexFileChangeLineStats {
|
||||
changes?: CodexTrackedFileChangeEntry[];
|
||||
}
|
||||
|
||||
const isCodexFileChangePayload = (
|
||||
payload: CodexFileChangePayload,
|
||||
): payload is Required<CodexFileChangePayload> =>
|
||||
payload?.item?.type === 'file_change' && !!payload.item.id;
|
||||
|
||||
const readTextFileSnapshot = async (filePath: string): Promise<CodexFileChangeSnapshot> => {
|
||||
try {
|
||||
await access(filePath);
|
||||
} catch {
|
||||
return { exists: false };
|
||||
}
|
||||
|
||||
try {
|
||||
return {
|
||||
content: await readFile(filePath, 'utf8'),
|
||||
exists: true,
|
||||
};
|
||||
} catch {
|
||||
return { exists: true };
|
||||
}
|
||||
};
|
||||
|
||||
const countPatchLines = (
|
||||
previousContent: string,
|
||||
nextContent: string,
|
||||
): CodexFileChangeLineStats => {
|
||||
if (previousContent === nextContent) return { linesAdded: 0, linesDeleted: 0 };
|
||||
|
||||
const patch = createPatch('codex-file-change', previousContent, nextContent, '', '');
|
||||
let insideHunk = false;
|
||||
let linesAdded = 0;
|
||||
let linesDeleted = 0;
|
||||
|
||||
for (const line of patch.split('\n')) {
|
||||
if (line.startsWith('@@')) {
|
||||
insideHunk = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!insideHunk) continue;
|
||||
|
||||
if (line.startsWith('+')) {
|
||||
linesAdded += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('-')) {
|
||||
linesDeleted += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { linesAdded, linesDeleted };
|
||||
};
|
||||
|
||||
const computeLineStats = async (
|
||||
change: CodexFileChangeEntry,
|
||||
snapshot?: CodexFileChangeSnapshot,
|
||||
): Promise<CodexFileChangeLineStats> => {
|
||||
const filePath = change.path;
|
||||
if (!filePath) return { linesAdded: 0, linesDeleted: 0 };
|
||||
|
||||
const kind = change.kind ?? 'update';
|
||||
if (kind === 'rename') return { linesAdded: 0, linesDeleted: 0 };
|
||||
|
||||
const previousContent = snapshot?.content ?? '';
|
||||
const current = await readTextFileSnapshot(filePath);
|
||||
const nextContent = current.content ?? '';
|
||||
|
||||
if (kind === 'add') {
|
||||
if (!current.exists) return { linesAdded: 0, linesDeleted: 0 };
|
||||
return countPatchLines('', nextContent);
|
||||
}
|
||||
|
||||
if (kind === 'delete' || kind === 'remove') {
|
||||
if (!snapshot?.exists) return { linesAdded: 0, linesDeleted: 0 };
|
||||
return countPatchLines(previousContent, '');
|
||||
}
|
||||
|
||||
if (!snapshot?.exists && !current.exists) return { linesAdded: 0, linesDeleted: 0 };
|
||||
|
||||
return countPatchLines(previousContent, nextContent);
|
||||
};
|
||||
|
||||
export class CodexFileChangeTracker {
|
||||
private snapshots = new Map<string, Map<string, CodexFileChangeSnapshot>>();
|
||||
|
||||
async track<T extends CodexFileChangePayload>(payload: T): Promise<T> {
|
||||
if (!isCodexFileChangePayload(payload)) return payload;
|
||||
|
||||
const itemId = payload.item.id;
|
||||
const changes = payload.item.changes ?? [];
|
||||
|
||||
if (payload.type === 'item.started') {
|
||||
const snapshots = new Map<string, CodexFileChangeSnapshot>();
|
||||
|
||||
await Promise.all(
|
||||
changes.map(async (change) => {
|
||||
if (!change.path || snapshots.has(change.path)) return;
|
||||
snapshots.set(change.path, await readTextFileSnapshot(change.path));
|
||||
}),
|
||||
);
|
||||
|
||||
this.snapshots.set(itemId, snapshots);
|
||||
return payload;
|
||||
}
|
||||
|
||||
if (payload.type !== 'item.completed') return payload;
|
||||
|
||||
const snapshots = this.snapshots.get(itemId);
|
||||
this.snapshots.delete(itemId);
|
||||
|
||||
if (!snapshots) return payload;
|
||||
|
||||
const trackedChanges = await Promise.all(
|
||||
changes.map(async (change) => {
|
||||
const stats = await computeLineStats(
|
||||
change,
|
||||
change.path ? snapshots.get(change.path) : undefined,
|
||||
);
|
||||
|
||||
return {
|
||||
...change,
|
||||
...stats,
|
||||
} satisfies CodexTrackedFileChangeEntry;
|
||||
}),
|
||||
);
|
||||
|
||||
const totals = trackedChanges.reduce<CodexFileChangeLineStats>(
|
||||
(acc, change) => ({
|
||||
linesAdded: acc.linesAdded + change.linesAdded,
|
||||
linesDeleted: acc.linesDeleted + change.linesDeleted,
|
||||
}),
|
||||
{ linesAdded: 0, linesDeleted: 0 },
|
||||
);
|
||||
|
||||
return {
|
||||
...payload,
|
||||
item: {
|
||||
...payload.item,
|
||||
...totals,
|
||||
changes: trackedChanges,
|
||||
} satisfies CodexTrackedFileChangeItem,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ const buildCodexOptionArgs = async ({
|
||||
const imageArgs = imagePaths.flatMap((filePath) => ['--image', filePath]);
|
||||
const autoExecutionArgs = hasAnyFlag(args, CODEX_AUTO_EXECUTION_FLAGS) ? [] : ['--full-auto'];
|
||||
|
||||
return [...CODEX_REQUIRED_ARGS, ...autoExecutionArgs, ...imageArgs, ...args];
|
||||
return [...CODEX_REQUIRED_ARGS, ...autoExecutionArgs, ...args, ...imageArgs];
|
||||
};
|
||||
|
||||
export const codexDriver: HeterogeneousAgentDriver = {
|
||||
@@ -37,7 +37,7 @@ export const codexDriver: HeterogeneousAgentDriver = {
|
||||
return {
|
||||
args: resumeSessionId
|
||||
? ['exec', 'resume', ...optionArgs, resumeSessionId, '-']
|
||||
: ['exec', ...optionArgs, '-'],
|
||||
: ['exec', ...optionArgs],
|
||||
stdinPayload: prompt,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -17,7 +17,7 @@ const isUrl = (value: string) => URL_PATTERN.test(value);
|
||||
const firstGlyph = (value?: string | null) => {
|
||||
if (!value) return '?';
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? Array.from(trimmed)[0] ?? '?' : '?';
|
||||
return trimmed ? (Array.from(trimmed)[0] ?? '?') : '?';
|
||||
};
|
||||
|
||||
const OverlayAvatar = memo<OverlayAvatarProps>(({ avatar, background, size = 18, title }) => {
|
||||
|
||||
@@ -252,7 +252,7 @@ const ChatPanel = memo<ChatPanelProps>(
|
||||
}, [theme]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (selected && !hidden && textareaRef.current) {
|
||||
if (!hidden && textareaRef.current) {
|
||||
textareaRef.current.focus();
|
||||
}
|
||||
}, [hidden, selected]);
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
resolveCommittedSelectionRect,
|
||||
shouldHideChatPanel,
|
||||
} from './overlaySelectionState';
|
||||
import { resolveCommittedSelectionRect, shouldHideChatPanel } from './overlaySelectionState';
|
||||
|
||||
describe('overlaySelectionState', () => {
|
||||
it('keeps the pending selection rect visible until the committed selection arrives', () => {
|
||||
|
||||
@@ -15,8 +15,7 @@ export interface DockResult {
|
||||
top: number;
|
||||
}
|
||||
|
||||
const clamp = (v: number, lo: number, hi: number): number =>
|
||||
Math.max(lo, Math.min(hi, v));
|
||||
const clamp = (v: number, lo: number, hi: number): number => Math.max(lo, Math.min(hi, v));
|
||||
|
||||
export function computeDockPosition({
|
||||
rect,
|
||||
|
||||
@@ -3,9 +3,7 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { getTopmostWindowAtPoint } from './useWindowHighlight';
|
||||
|
||||
const createWindow = (
|
||||
overrides: Partial<ScreenCaptureWindowInfo>,
|
||||
): ScreenCaptureWindowInfo => ({
|
||||
const createWindow = (overrides: Partial<ScreenCaptureWindowInfo>): ScreenCaptureWindowInfo => ({
|
||||
appName: 'Test App',
|
||||
bounds: { height: 300, width: 400, x: 1000, y: 200 },
|
||||
order: 0,
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
[
|
||||
{
|
||||
"children": {},
|
||||
"date": "2026-04-20",
|
||||
"version": "2.1.52"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["fix minify cli.", "recent delete."]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"https://file.rene.wang/540830955-0fe626a3-0ddc-4f67-b595-3c5b3f1701e0.png": "/blog/assetsa8e504275f2cd891fabecca985998de0.webp",
|
||||
"https://file.rene.wang/changlog-04-14.png": "/blog/assets300abe7e259d293da6c5ed4f642a1be6.webp",
|
||||
"https://file.rene.wang/changlog-04-14.png": "/blog/assets300abe7e259d293da6c5ed4f642a1be6.webp",
|
||||
"https://file.rene.wang/clipboard-1768907980491-9cc0669fc3a38.png": "/blog/assets8be3a46c8f9c5d3b61bc541f44b7f245.webp",
|
||||
"https://file.rene.wang/clipboard-1768908081787-ed9eb1cb78bdb.png": "/blog/assetsab009b79dd794f02aec24b7607f342e8.webp",
|
||||
"https://file.rene.wang/clipboard-1768908121691-b3517bf882633.png": "/blog/assetsd3cae44cba0d3f57df6440b46246e5e7.webp",
|
||||
@@ -52,6 +53,8 @@
|
||||
"https://file.rene.wang/clipboard-1770266335710-1fec523143aab.png": "/blog/assets636c78daf95c590cd7d80284c68eb6d9.webp",
|
||||
"https://file.rene.wang/clipboard-1774923001079-89ce6aa271a62.png": "/blog/assets53e6ec9cf72554dbc1f8224fc0550a03.webp",
|
||||
"https://file.rene.wang/clipboard-1775701725582-123f8f8cf73f8.png": "/blog/assets7ea204859aeb5aa9be5810a20ba1669a.webp",
|
||||
"https://file.rene.wang/clipboard-1775701725582-123f8f8cf73f8.png": "/blog/assets7ea204859aeb5aa9be5810a20ba1669a.webp",
|
||||
"https://file.rene.wang/clipboard-1776909505252-94b051f3ea0a7.png": "/blog/assetsdfda32866c4bc59af0526e52f31d1da2.webp",
|
||||
"https://file.rene.wang/lobehub/467951f5-ad65-498d-aea9-fca8f35a4314.png": "/blog/assets907ea775d228958baca38e2dbb65939a.webp",
|
||||
"https://file.rene.wang/lobehub/58d91528-373a-4a42-b520-cf6cb1f8ce1e.png": "/blog/assets7dccdd4df55aede71001da649639437f.webp",
|
||||
"https://file.rene.wang/lobehub/ee700103-3c08-41dc-9ddf-c7705bb7bc6a.png": "/blog/assets196d679bc7071abbf71f2a8566f05aa3.webp",
|
||||
@@ -467,8 +470,5 @@
|
||||
"https://github.com/user-attachments/assets/fa8fab19-ace2-4f85-8428-a3a0e28845bb": "/blog/assets/2d678631c55369ba7d753c3ffcb73782.webp",
|
||||
"https://github.com/user-attachments/assets/facdc83c-e789-4649-8060-7f7a10a1b1dd": "/blog/assets05b20e40c03ced0ec8707fed2e8e0f25.webp",
|
||||
"https://github.com/user-attachments/assets/fcdfb9c5-819a-488f-b28d-0857fe861219": "/blog/assets8477415ecec1f37e38ab38ff1217d0a7.webp",
|
||||
"https://github.com/user-attachments/assets/fd60ab55-ead2-4930-ad00-fdf77662f5a0": "/blog/assets276a4e8748e9bd300b30dcd9d0e24980.webp",
|
||||
"https://file.rene.wang/clipboard-1775701725582-123f8f8cf73f8.png": "/blog/assets7ea204859aeb5aa9be5810a20ba1669a.webp",
|
||||
"https://file.rene.wang/changlog-04-14.png": "/blog/assets300abe7e259d293da6c5ed4f642a1be6.webp",
|
||||
"https://file.rene.wang/clipboard-1776909505252-94b051f3ea0a7.png": "/blog/assetsdfda32866c4bc59af0526e52f31d1da2.webp"
|
||||
"https://github.com/user-attachments/assets/fd60ab55-ead2-4930-ad00-fdf77662f5a0": "/blog/assets276a4e8748e9bd300b30dcd9d0e24980.webp"
|
||||
}
|
||||
|
||||
+37
-146
@@ -6,330 +6,221 @@
|
||||
"image": "/blog/assetsdfda32866c4bc59af0526e52f31d1da2.webp",
|
||||
"id": "2026-04-20-daily-brief",
|
||||
"date": "2026-04-20",
|
||||
"versionRange": [
|
||||
"2.1.50",
|
||||
"2.1.52"
|
||||
]
|
||||
"versionRange": ["2.1.50", "2.1.52"]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets300abe7e259d293da6c5ed4f642a1be6.webp",
|
||||
"id": "2026-04-13-gateway-sidebar",
|
||||
"date": "2026-04-13",
|
||||
"versionRange": [
|
||||
"2.1.48",
|
||||
"2.1.49"
|
||||
]
|
||||
"versionRange": ["2.1.48", "2.1.49"]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets7ea204859aeb5aa9be5810a20ba1669a.webp",
|
||||
"id": "2026-04-06-auto-completion",
|
||||
"date": "2026-04-06",
|
||||
"versionRange": [
|
||||
"2.1.47"
|
||||
]
|
||||
"versionRange": ["2.1.47"]
|
||||
},
|
||||
{
|
||||
"id": "2026-03-30-agent-tasks",
|
||||
"date": "2026-03-30",
|
||||
"versionRange": [
|
||||
"2.1.45",
|
||||
"2.1.46"
|
||||
]
|
||||
"versionRange": ["2.1.45", "2.1.46"]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets53e6ec9cf72554dbc1f8224fc0550a03.webp",
|
||||
"id": "2026-03-23-media-memory",
|
||||
"date": "2026-03-23",
|
||||
"versionRange": [
|
||||
"2.1.44"
|
||||
]
|
||||
"versionRange": ["2.1.44"]
|
||||
},
|
||||
{
|
||||
"image": "https://hub-apac-1.lobeobjects.space/blog/assets/4a68a7644501cb513d08670b102a446e.webp",
|
||||
"id": "2026-03-16-search",
|
||||
"date": "2026-03-16",
|
||||
"versionRange": [
|
||||
"2.1.38",
|
||||
"2.1.43"
|
||||
]
|
||||
"versionRange": ["2.1.38", "2.1.43"]
|
||||
},
|
||||
{
|
||||
"id": "2026-02-08-runtime-auth",
|
||||
"date": "2026-02-08",
|
||||
"versionRange": [
|
||||
"2.1.6",
|
||||
"2.1.26"
|
||||
]
|
||||
"versionRange": ["2.1.6", "2.1.26"]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assetsa8e504275f2cd891fabecca985998de0.webp",
|
||||
"id": "2026-01-27-v2",
|
||||
"date": "2026-01-27",
|
||||
"versionRange": [
|
||||
"2.0.1",
|
||||
"2.1.5"
|
||||
]
|
||||
"versionRange": ["2.0.1", "2.1.5"]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets7f3b38c1d76cceb91edb29d6b1eb60db.webp",
|
||||
"id": "2025-12-20-mcp",
|
||||
"date": "2025-12-20",
|
||||
"versionRange": [
|
||||
"1.142.8",
|
||||
"1.143.0"
|
||||
]
|
||||
"versionRange": ["1.142.8", "1.143.0"]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets3a7f0b29839603336e39e923b423409b.webp",
|
||||
"id": "2025-11-08-comfy-ui",
|
||||
"date": "2025-11-08",
|
||||
"versionRange": [
|
||||
"1.133.5",
|
||||
"1.142.8"
|
||||
]
|
||||
"versionRange": ["1.133.5", "1.142.8"]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets35e6aa692b0c16009c61964279514166.webp",
|
||||
"id": "2025-10-08-python",
|
||||
"date": "2025-10-08",
|
||||
"versionRange": [
|
||||
"1.120.7",
|
||||
"1.133.5"
|
||||
]
|
||||
"versionRange": ["1.120.7", "1.133.5"]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assetsce5d6dc93676f974be2e162e8ace03f0.webp",
|
||||
"id": "2025-09-08-gemini",
|
||||
"date": "2025-09-08",
|
||||
"versionRange": [
|
||||
"1.109.1",
|
||||
"1.120.7"
|
||||
]
|
||||
"versionRange": ["1.109.1", "1.120.7"]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assetsdf48eed9de76b7e37c269b294285f09d.webp",
|
||||
"id": "2025-08-08-image-generation",
|
||||
"date": "2025-08-08",
|
||||
"versionRange": [
|
||||
"1.97.10",
|
||||
"1.109.1"
|
||||
]
|
||||
"versionRange": ["1.97.10", "1.109.1"]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets902eb746fe2042fc2ea831c71002be72.webp",
|
||||
"id": "2025-07-08-mcp-market",
|
||||
"date": "2025-07-08",
|
||||
"versionRange": [
|
||||
"1.93.3",
|
||||
"1.97.10"
|
||||
]
|
||||
"versionRange": ["1.93.3", "1.97.10"]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets5cc27b8cae995074da20d4ffe06a1460.webp",
|
||||
"id": "2025-06-08-claude-4",
|
||||
"date": "2025-06-08",
|
||||
"versionRange": [
|
||||
"1.84.27",
|
||||
"1.93.3"
|
||||
]
|
||||
"versionRange": ["1.84.27", "1.93.3"]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets2a36d86a4eed6e7938dd6e9c684701ed.webp",
|
||||
"id": "2025-05-08-desktop-app",
|
||||
"date": "2025-05-08",
|
||||
"versionRange": [
|
||||
"1.77.17",
|
||||
"1.84.27"
|
||||
]
|
||||
"versionRange": ["1.77.17", "1.84.27"]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assetsc0efdb82443556ae3acefe00099b3f23.webp",
|
||||
"id": "2025-04-06-exports",
|
||||
"date": "2025-04-06",
|
||||
"versionRange": [
|
||||
"1.67.2",
|
||||
"1.77.17"
|
||||
]
|
||||
"versionRange": ["1.67.2", "1.77.17"]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assetse743f0a47127390dde766a0a790476db.webp",
|
||||
"id": "2025-03-02-new-models",
|
||||
"date": "2025-03-02",
|
||||
"versionRange": [
|
||||
"1.49.13",
|
||||
"1.67.2"
|
||||
]
|
||||
"versionRange": ["1.49.13", "1.67.2"]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets18168d5fe64ea34905a7e52fd82d0e9d.webp",
|
||||
"id": "2025-02-02-deepseek-r1",
|
||||
"date": "2025-02-02",
|
||||
"versionRange": [
|
||||
"1.47.8",
|
||||
"1.49.12"
|
||||
]
|
||||
"versionRange": ["1.47.8", "1.49.12"]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assetsf9ed064fe764cbeff2f46910e7099a91.webp",
|
||||
"id": "2025-01-22-new-ai-provider",
|
||||
"date": "2025-01-22",
|
||||
"versionRange": [
|
||||
"1.43.1",
|
||||
"1.47.7"
|
||||
]
|
||||
"versionRange": ["1.43.1", "1.47.7"]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets2d409f43b58953ad5396c6beab8a0719.webp",
|
||||
"id": "2025-01-03-user-profile",
|
||||
"date": "2025-01-03",
|
||||
"versionRange": [
|
||||
"1.34.1",
|
||||
"1.43.0"
|
||||
]
|
||||
"versionRange": ["1.34.1", "1.43.0"]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets/d9cbfcbef130183bc490d515d8a38aa4.webp",
|
||||
"id": "2024-11-27-forkable-chat",
|
||||
"date": "2024-11-27",
|
||||
"versionRange": [
|
||||
"1.33.1",
|
||||
"1.34.0"
|
||||
]
|
||||
"versionRange": ["1.33.1", "1.34.0"]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets/2d678631c55369ba7d753c3ffcb73782.webp",
|
||||
"id": "2024-11-25-november-providers",
|
||||
"date": "2024-11-25",
|
||||
"versionRange": [
|
||||
"1.30.1",
|
||||
"1.33.0"
|
||||
]
|
||||
"versionRange": ["1.30.1", "1.33.0"]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets/f10a4b98782e36797c38071eed785c6f.webp",
|
||||
"id": "2024-11-06-share-text-json",
|
||||
"date": "2024-11-06",
|
||||
"versionRange": [
|
||||
"1.26.1",
|
||||
"1.28.0"
|
||||
]
|
||||
"versionRange": ["1.26.1", "1.28.0"]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets/944c671604833cd2457445b211ebba33.webp",
|
||||
"id": "2024-10-27-pin-assistant",
|
||||
"date": "2024-10-27",
|
||||
"versionRange": [
|
||||
"1.19.1",
|
||||
"1.26.0"
|
||||
]
|
||||
"versionRange": ["1.19.1", "1.26.0"]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets/f6d047a345e47a52592cff916c9a64ce.webp",
|
||||
"id": "2024-09-20-artifacts",
|
||||
"date": "2024-09-20",
|
||||
"versionRange": [
|
||||
"1.17.1",
|
||||
"1.19.0"
|
||||
]
|
||||
"versionRange": ["1.17.1", "1.19.0"]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets/d7e57f8e69f97b76b3c2414f3441b6e4.webp",
|
||||
"id": "2024-09-13-openai-o1-models",
|
||||
"date": "2024-09-13",
|
||||
"versionRange": [
|
||||
"1.12.1",
|
||||
"1.17.0"
|
||||
]
|
||||
"versionRange": ["1.12.1", "1.17.0"]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets/d6129350de510a62fe87b2d2f0fb9477.webp",
|
||||
"id": "2024-08-21-file-upload-and-knowledge-base",
|
||||
"date": "2024-08-21",
|
||||
"versionRange": [
|
||||
"1.8.1",
|
||||
"1.12.0"
|
||||
]
|
||||
"versionRange": ["1.8.1", "1.12.0"]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets/37d85fdfccff9ed56e9c6827faee01c7.webp",
|
||||
"id": "2024-08-02-lobe-chat-database-docker",
|
||||
"date": "2024-08-02",
|
||||
"versionRange": [
|
||||
"1.6.1",
|
||||
"1.8.0"
|
||||
]
|
||||
"versionRange": ["1.6.1", "1.8.0"]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets/39d7890f8cbe21e77db8d3c94f7f22e4.webp",
|
||||
"id": "2024-07-19-gpt-4o-mini",
|
||||
"date": "2024-07-19",
|
||||
"versionRange": [
|
||||
"1.0.1",
|
||||
"1.6.0"
|
||||
]
|
||||
"versionRange": ["1.0.1", "1.6.0"]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets/eb477e62217f4d1b644eff975c7ac168.webp",
|
||||
"id": "2024-06-19-lobe-chat-v1",
|
||||
"date": "2024-06-19",
|
||||
"versionRange": [
|
||||
"0.147.0",
|
||||
"1.0.0"
|
||||
]
|
||||
"versionRange": ["0.147.0", "1.0.0"]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets/8a8d361b4c0cce6da350cc0de65c0ad6.webp",
|
||||
"id": "2024-02-14-ollama",
|
||||
"date": "2024-02-14",
|
||||
"versionRange": [
|
||||
"0.125.1",
|
||||
"0.127.0"
|
||||
]
|
||||
"versionRange": ["0.125.1", "0.127.0"]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets/9498087e85f27e692716a63cb3b58d79.webp",
|
||||
"id": "2024-02-08-sso-oauth",
|
||||
"date": "2024-02-08",
|
||||
"versionRange": [
|
||||
"0.118.1",
|
||||
"0.125.0"
|
||||
]
|
||||
"versionRange": ["0.118.1", "0.125.0"]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets/603fefbb944bc6761ebdab5956fc0084.webp",
|
||||
"id": "2023-12-22-dalle-3",
|
||||
"date": "2023-12-22",
|
||||
"versionRange": [
|
||||
"0.102.1",
|
||||
"0.118.0"
|
||||
]
|
||||
"versionRange": ["0.102.1", "0.118.0"]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets/8d4c2cc0ce8654fa8ac06cc036a7f941.webp",
|
||||
"id": "2023-11-19-tts-stt",
|
||||
"date": "2023-11-19",
|
||||
"versionRange": [
|
||||
"0.101.1",
|
||||
"0.102.0"
|
||||
]
|
||||
"versionRange": ["0.101.1", "0.102.0"]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets/d47654360d626f80144cdedb979a3526.webp",
|
||||
"id": "2023-11-14-gpt4-vision",
|
||||
"date": "2023-11-14",
|
||||
"versionRange": [
|
||||
"0.90.0",
|
||||
"0.101.0"
|
||||
]
|
||||
"versionRange": ["0.90.0", "0.101.0"]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets/50b38eac1769ae6f13aef72f3d725eec.webp",
|
||||
"id": "2023-09-09-plugin-system",
|
||||
"date": "2023-09-09",
|
||||
"versionRange": [
|
||||
"0.67.0",
|
||||
"0.72.0"
|
||||
]
|
||||
"versionRange": ["0.67.0", "0.72.0"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -129,6 +129,8 @@ table agent_documents {
|
||||
user_id text [not null]
|
||||
agent_id text [not null]
|
||||
document_id varchar(255) [not null]
|
||||
parent_id uuid
|
||||
filename text
|
||||
template_id varchar(100)
|
||||
access_self integer [not null, default: 31]
|
||||
access_shared integer [not null, default: 0]
|
||||
@@ -160,6 +162,7 @@ table agent_documents {
|
||||
deleted_at [name: 'agent_documents_deleted_at_idx']
|
||||
(agent_id, deleted_at, policy_load) [name: 'agent_documents_agent_autoload_deleted_idx']
|
||||
document_id [name: 'agent_documents_document_id_idx']
|
||||
parent_id [name: 'agent_documents_parent_id_idx']
|
||||
(agent_id, document_id, user_id) [name: 'agent_documents_agent_document_user_unique', unique]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,14 +91,13 @@ Configuration details:
|
||||
|
||||
Available system agents and their functions:
|
||||
|
||||
| System Agent | Key Name | Function Description |
|
||||
| ------------------- | ----------------- | --------------------------------------------------------------------------------------------------- |
|
||||
| Topic Generation | `topic` | Automatically generates topic names and summaries based on chat content |
|
||||
| Translation | `translation` | Handles text translation between multiple languages |
|
||||
| Metadata Generation | `agentMeta` | Generates descriptive information and metadata for assistants |
|
||||
| History Compression | `historyCompress` | Compresses and organizes history for long conversations, optimizing context management |
|
||||
| Query Rewrite | `queryRewrite` | Rewrites follow-up questions as standalone questions with context, improving conversation coherence |
|
||||
| Thread Management | `thread` | Handles the creation and management of conversation threads |
|
||||
| System Agent | Key Name | Function Description |
|
||||
| ------------------- | ----------------- | -------------------------------------------------------------------------------------- |
|
||||
| Topic Generation | `topic` | Automatically generates topic names and summaries based on chat content |
|
||||
| Translation | `translation` | Handles text translation between multiple languages |
|
||||
| Metadata Generation | `agentMeta` | Generates descriptive information and metadata for assistants |
|
||||
| History Compression | `historyCompress` | Compresses and organizes history for long conversations, optimizing context management |
|
||||
| Thread Management | `thread` | Handles the creation and management of conversation threads |
|
||||
|
||||
### `FEATURE_FLAGS`
|
||||
|
||||
|
||||
@@ -88,14 +88,13 @@ LobeHub 在部署时提供了一些额外的配置项,你可以使用环境变
|
||||
|
||||
可配置的系统助手及其作用:
|
||||
|
||||
| 系统助手 | 键名 | 作用描述 |
|
||||
| ------- | ----------------- | --------------------------- |
|
||||
| 主题生成 | `topic` | 根据聊天内容自动生成主题名称和摘要 |
|
||||
| 翻译 | `translation` | 文本翻译使用的助手 |
|
||||
| 元数据生成 | `agentMeta` | 为助手生成描述性信息和元数据 |
|
||||
| 历史记录压缩 | `historyCompress` | 压缩和整理长对话的历史记录,优化上下文管理 |
|
||||
| 知识库查询重写 | `queryRewrite` | 将后续问题改写为包含上下文的独立问题,提升对话的连贯性 |
|
||||
| 分支对话 | `thread` | 自定生成分支对话的标题 |
|
||||
| 系统助手 | 键名 | 作用描述 |
|
||||
| ------ | ----------------- | --------------------- |
|
||||
| 主题生成 | `topic` | 根据聊天内容自动生成主题名称和摘要 |
|
||||
| 翻译 | `translation` | 文本翻译使用的助手 |
|
||||
| 元数据生成 | `agentMeta` | 为助手生成描述性信息和元数据 |
|
||||
| 历史记录压缩 | `historyCompress` | 压缩和整理长对话的历史记录,优化上下文管理 |
|
||||
| 分支对话 | `thread` | 自定生成分支对话的标题 |
|
||||
|
||||
### `FEATURE_FLAGS`
|
||||
|
||||
|
||||
@@ -104,15 +104,17 @@ By connecting a Discord channel to your LobeHub agent, users can interact with t
|
||||
Under **Bot Permissions**, select:
|
||||
|
||||
- View Channels
|
||||
- Send Messages
|
||||
- Read Message History
|
||||
- Send Messages
|
||||
- Create Public Threads
|
||||
- Send Messages in Threads
|
||||
- Embed Links
|
||||
- Attach Files
|
||||
- Add Reactions (optional)
|
||||
|
||||
### Authorize the Bot
|
||||
|
||||

|
||||

|
||||
|
||||
Copy the generated URL, open it in your browser, select the server you want to add the bot to, and click **Authorize**.
|
||||
</Steps>
|
||||
@@ -121,15 +123,52 @@ By connecting a Discord channel to your LobeHub agent, users can interact with t
|
||||
|
||||
Back in LobeHub's channel settings for Discord, click **Test Connection** to verify everything is configured correctly. Then send a message to your bot in Discord to confirm it responds.
|
||||
|
||||
## Access Policies
|
||||
|
||||
LobeHub gates inbound traffic with three layered settings, all under **Advanced Settings** and all defaulting to permissive.
|
||||
|
||||
### Allowed User IDs (global)
|
||||
|
||||
A *global* user-level gate. When populated, **only** the listed users can interact with the bot — DM, group @mention, threads, all of it — regardless of DM Policy / Group Policy mode. Empty means "no user-level filter; let per-scope policies decide". Right-click a user → **Copy User ID** (Developer Mode must be enabled in **Settings → Advanced**) to grab an ID.
|
||||
|
||||
DMs from non-allowlisted users get a "you aren't authorized" notice. Group @mentions from non-allowlisted users get the same kind of notice posted **inside the auto-created reply thread** Discord makes for the @-mention — the parent channel stays quiet.
|
||||
|
||||
### DM Policy
|
||||
|
||||
Controls 1:1 direct messages.
|
||||
|
||||
- **Open (default)** — Anyone who shares a server with the bot can DM it (subject to the global allowlist when set).
|
||||
- **Allowlist** — DMs require the sender to be in **Allowed User IDs**. Differs from `Open` only when the list is empty: `Allowlist` fails closed (no DMs), `Open` still lets anyone DM.
|
||||
- **Disabled** — The bot ignores all DMs. Senders get a one-line notice pointing them at @mentioning the bot in a shared channel instead.
|
||||
|
||||
> Discord bots can be reached by anyone in any shared server, so consider populating **Allowed User IDs** or switching DM Policy to **Disabled** if your bot is meant to be private.
|
||||
|
||||
### Group Policy
|
||||
|
||||
Controls @mentions in server channels and threads.
|
||||
|
||||
- **Open (default)** — Respond to @mentions anywhere the bot can read.
|
||||
- **Allowlist** — Respond only in channels listed in **Allowed Channel IDs**. Right-click a channel → **Copy Channel ID** to grab one.
|
||||
- **Disabled** — Ignore all server traffic; the bot only responds to DMs.
|
||||
|
||||
> The two policies are independent. You can run a DM-only bot with `groupPolicy=disabled`, a channel-only bot with `dmPolicy=disabled`, or scope both with allowlists.
|
||||
|
||||
See the [Channels overview](/docs/usage/channels/overview#direct-message-policy) for cross-platform details.
|
||||
|
||||
## Configuration Reference
|
||||
|
||||
| Field | Required | Description |
|
||||
| ------------------ | -------- | ------------------------------------------------ |
|
||||
| **Application ID** | Yes | Your Discord application's ID |
|
||||
| **Bot Token** | Yes | Authentication token for your Discord bot |
|
||||
| **Public Key** | Yes | Used to verify interaction requests from Discord |
|
||||
| Field | Required | Description |
|
||||
| ----------------------- | -------- | ------------------------------------------------------------------------------------------------- |
|
||||
| **Application ID** | Yes | Your Discord application's ID |
|
||||
| **Bot Token** | Yes | Authentication token for your Discord bot |
|
||||
| **Public Key** | Yes | Used to verify interaction requests from Discord |
|
||||
| **Allowed User IDs** | No | Comma- or whitespace-separated Discord user IDs. Global gate — applies to DMs and group @mentions |
|
||||
| **DM Policy** | No | `open` (default), `allowlist`, or `disabled` — who is allowed to DM the bot |
|
||||
| **Group Policy** | No | `open` (default), `allowlist`, or `disabled` — where the bot responds to @mentions |
|
||||
| **Allowed Channel IDs** | No | Comma- or whitespace-separated Discord channel IDs. Used when Group Policy is Allowlist |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Bot not responding in server:** Confirm the bot has been invited to the server with the correct permissions, and Message Content Intent is enabled.
|
||||
- **Bot not responding in server:** Confirm the bot has been invited to the server with the correct permissions, and Message Content Intent is enabled. If **Group Policy** is `Disabled` or `Allowlist`, double-check the channel is in **Allowed Channel IDs**. If **Allowed User IDs** is set, the sender's user ID must be in it.
|
||||
- **Bot not responding to DMs:** Open **Advanced Settings** and confirm **DM Policy** is not set to `Disabled`. If **Allowed User IDs** is set, make sure the sender's Discord user ID is in it.
|
||||
- **Test Connection failed:** Double-check the Application ID, Bot Token, and Public Key are correct.
|
||||
|
||||
@@ -103,15 +103,17 @@ tags:
|
||||
在 **机器人权限** 下选择:
|
||||
|
||||
- 查看频道
|
||||
- 阅读消息历史记录
|
||||
- 发送消息
|
||||
- 读取消息历史
|
||||
- 创建公共子区
|
||||
- 在子区内发送消息
|
||||
- 嵌入链接
|
||||
- 附加文件
|
||||
- 添加文件
|
||||
- 添加反应(可选)
|
||||
|
||||
### 授权机器人
|
||||
|
||||

|
||||

|
||||
|
||||
复制生成的链接,在浏览器中打开,选择您希望添加机器人的服务器,然后点击 **授权**。
|
||||
</Steps>
|
||||
@@ -120,15 +122,52 @@ tags:
|
||||
|
||||
返回 LobeHub 的 Discord 渠道设置,点击 **测试连接** 以验证配置是否正确。然后在 Discord 中向您的机器人发送消息,确认其是否响应。
|
||||
|
||||
## 接入策略
|
||||
|
||||
LobeHub 通过三层叠加配置控制入站消息,全部位于 **高级设置**,默认都为宽松。
|
||||
|
||||
### 允许的用户 ID(全局)
|
||||
|
||||
**全局**用户级闸门。填入后,**只有**列表里的用户可以与机器人交互 — 私信、群聊 @、子话题,所有入口都生效,不受 DM Policy / Group Policy 模式影响。留空则不做用户级过滤,交给各 scope 自己的策略决定。在 **设置 → 高级** 启用开发者模式后,右键用户 → **复制用户 ID** 即可获取。
|
||||
|
||||
非白名单用户的私信会收到 "你没有权限" 的系统提示。群里被 @ 时也会收到类似提示,但是**发到 Discord 因 @ 自动创建的回复 thread 里**,不会污染父频道。
|
||||
|
||||
### 私信策略
|
||||
|
||||
控制 1:1 私聊。
|
||||
|
||||
- **开放 (Open)(默认)** — 任何与机器人共享服务器的用户都可以私信(若设置了全局白名单则受其约束)。
|
||||
- **白名单 (Allowlist)** — 私信需要发送者在 **允许的用户 ID** 里。和 `Open` 的差别在白名单为空时:`Allowlist` 模式**全部拒绝**,`Open` 模式仍然放任何人私信。
|
||||
- **禁用 (Disabled)** — 机器人忽略所有私信,发起方会收到一条提示,引导其在共享频道里 @机器人。
|
||||
|
||||
> Discord 机器人可被任意共享服务器的用户私信,如果你的机器人是私有用途,建议填入 **允许的用户 ID** 或将私信策略切到 **禁用**。
|
||||
|
||||
### 群组策略
|
||||
|
||||
控制服务器频道与子话题里的 @提及。
|
||||
|
||||
- **开放 (Open)(默认)** — 在机器人能读取的任何频道里被 @就响应。
|
||||
- **白名单 (Allowlist)** — 只在 **允许的频道 ID** 列出的频道里响应。右键频道 → **复制频道 ID** 即可获取。
|
||||
- **禁用 (Disabled)** — 忽略所有服务器频道流量,只接受私信。
|
||||
|
||||
> 两个策略相互独立。你可以做纯私信机器人(`groupPolicy=disabled`)、纯频道机器人(`dmPolicy=disabled`),也可以两边都用白名单收紧范围。
|
||||
|
||||
跨平台细节见 [渠道概览](/docs/usage/channels/overview#direct-message-policy)。
|
||||
|
||||
## 配置参考
|
||||
|
||||
| 字段 | 是否必需 | 描述 |
|
||||
| ----------- | ---- | -------------------- |
|
||||
| **应用程序 ID** | 是 | 您的 Discord 应用程序的 ID |
|
||||
| **机器人令牌** | 是 | 您的 Discord 机器人的认证令牌 |
|
||||
| **公钥** | 是 | 用于验证来自 Discord 的交互请求 |
|
||||
| 字段 | 是否必需 | 描述 |
|
||||
| ------------ | ---- | -------------------------------------------------- |
|
||||
| **应用程序 ID** | 是 | 您的 Discord 应用程序的 ID |
|
||||
| **机器人令牌** | 是 | 您的 Discord 机器人的认证令牌 |
|
||||
| **公钥** | 是 | 用于验证来自 Discord 的交互请求 |
|
||||
| **允许的用户 ID** | 否 | 逗号或空格分隔的 Discord 用户 ID。全局闸门 — 私信和群聊 @ 都受其约束 |
|
||||
| **私信策略** | 否 | `open`(默认)、`allowlist` 或 `disabled` — 控制谁可以私信机器人 |
|
||||
| **群组策略** | 否 | `open`(默认)、`allowlist` 或 `disabled` — 控制机器人在哪些频道响应 |
|
||||
| **允许的频道 ID** | 否 | 逗号或空格分隔的 Discord 频道 ID。仅在群组策略为白名单时使用 |
|
||||
|
||||
## 故障排除
|
||||
|
||||
- **机器人未在服务器中响应:** 确认机器人已被邀请到服务器并拥有正确的权限,同时启用了消息内容意图。
|
||||
- **机器人未在服务器中响应:** 确认机器人已被邀请到服务器并拥有正确的权限,同时启用了消息内容意图。如果 **群组策略** 是 `Disabled` 或 `Allowlist`,确认目标频道在 **允许的频道 ID** 列表里。如果 **允许的用户 ID** 已填,发送者的用户 ID 必须在列表里。
|
||||
- **机器人不回私信:** 打开 **高级设置**,确认 **私信策略** 不是 `Disabled`。如果 **允许的用户 ID** 已填,确认发起方的 Discord 用户 ID 在列表里。
|
||||
- **测试连接失败:** 仔细检查应用程序 ID、机器人令牌和公钥是否正确。
|
||||
|
||||
@@ -174,19 +174,48 @@ By connecting a Feishu channel to your LobeHub agent, team members can interact
|
||||
|
||||
Back in LobeHub's channel settings, click **Test Connection** to verify the credentials. Then find your bot in Feishu by searching its name and send it a message to confirm it responds.
|
||||
|
||||
## Access Policies
|
||||
|
||||
Two independent policies gate inbound traffic. Both default to **Open**.
|
||||
|
||||
### Allowed User IDs (global)
|
||||
|
||||
A populated **Allowed User IDs** field is a global gate — DMs *and* group `@mentions` are restricted to listed Feishu `open_id` values. Empty means "no user-level filter". Read the `open_id` from the event payload, or copy the **User ID** displayed in the Feishu Developer Portal.
|
||||
|
||||
### DM Policy
|
||||
|
||||
- **Open (default)** — Any tenant member can DM the bot (subject to the global allowlist when set).
|
||||
- **Allowlist** — DMs require the sender to be in **Allowed User IDs**. Differs from `Open` only when the list is empty: `Allowlist` then fails closed (no DMs).
|
||||
- **Disabled** — The bot ignores all DMs and only responds to chat-group `@mentions`.
|
||||
|
||||
### Group Policy
|
||||
|
||||
Controls which Feishu chat groups the bot will respond in.
|
||||
|
||||
- **Open (default)** — Respond to `@mentions` in any chat group the bot has been added to.
|
||||
- **Allowlist** — Respond only in chats whose `chat_id` is listed in **Allowed Channel IDs** (read it from the event payload).
|
||||
- **Disabled** — Ignore all group traffic; the bot only responds to DMs.
|
||||
|
||||
See the [Channels overview](/docs/usage/channels/overview#direct-message-policy) for cross-platform details.
|
||||
|
||||
## Configuration Reference
|
||||
|
||||
| Field | Required | Description |
|
||||
| -------------------------- | -------- | --------------------------------------------------------------- |
|
||||
| **App ID** | Yes | Your Feishu app's App ID (`cli_xxx`) |
|
||||
| **App Secret** | Yes | Your Feishu app's App Secret |
|
||||
| **Verification Token** | No | Verifies webhook event source (recommended) |
|
||||
| **Encrypt Key** | No | Decrypts encrypted event payloads |
|
||||
| **Event Subscription URL** | — | Auto-generated after saving; paste into Feishu Developer Portal |
|
||||
| Field | Required | Description |
|
||||
| -------------------------- | -------- | -------------------------------------------------------------------------------------------------------- |
|
||||
| **App ID** | Yes | Your Feishu app's App ID (`cli_xxx`) |
|
||||
| **App Secret** | Yes | Your Feishu app's App Secret |
|
||||
| **Verification Token** | No | Verifies webhook event source (recommended) |
|
||||
| **Encrypt Key** | No | Decrypts encrypted event payloads |
|
||||
| **Event Subscription URL** | — | Auto-generated after saving; paste into Feishu Developer Portal |
|
||||
| **Allowed User IDs** | No | Comma- or whitespace-separated Feishu `open_id` values. Global gate — applies to DMs and group @mentions |
|
||||
| **DM Policy** | No | `open` (default), `allowlist`, or `disabled` — who is allowed to DM the bot |
|
||||
| **Group Policy** | No | `open` (default), `allowlist`, or `disabled` — where the bot responds to @mentions |
|
||||
| **Allowed Channel IDs** | No | Comma- or whitespace-separated Feishu `chat_id` values. Used when Group Policy is Allowlist |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Event Subscription URL verification failed:** Ensure you saved the configuration in LobeHub first, and the URL was copied correctly.
|
||||
- **Bot not responding:** Verify the app is published and approved, the bot capability is enabled, and the `im.message.receive_v1` event is subscribed.
|
||||
- **Bot ignores DMs:** Open **Advanced Settings** in LobeHub and check **DM Policy**. If it is `Disabled`, switch to `Open` or `Allowlist`. If it is `Allowlist`, confirm the sender's `open_id` is listed in **Allowed User IDs**.
|
||||
- **Permission errors:** Confirm all required permissions are added and approved in the Developer Portal.
|
||||
- **Test Connection failed:** Double-check the App ID and App Secret.
|
||||
|
||||
@@ -170,19 +170,48 @@ tags:
|
||||
|
||||
回到 LobeHub 的渠道设置,点击 **测试连接** 以验证凭证。然后在飞书中搜索您的机器人名称并发送消息,确认其是否响应。
|
||||
|
||||
## 接入策略
|
||||
|
||||
两个独立的策略控制入站消息,默认都为 **开放**。
|
||||
|
||||
### 允许的用户 ID(全局)
|
||||
|
||||
填入 **允许的用户 ID** 后,**所有**入站消息(私信和群聊 `@提及`)都必须来自列表里的飞书 `open_id`。留空则不做用户级过滤。`open_id` 可从事件 payload 读取,或在飞书开发者后台查看 **User ID**。
|
||||
|
||||
### 私信策略
|
||||
|
||||
- **开放 (Open)(默认)** — 租户内任何成员都可以私信机器人(若设置了全局白名单则受其约束)。
|
||||
- **白名单 (Allowlist)** — 私信需要发送者在 **允许的用户 ID** 里。和 `Open` 的差别在白名单为空时:`Allowlist` 模式**全部拒绝**。
|
||||
- **禁用 (Disabled)** — 机器人忽略所有私信,只在群聊里被 `@提及` 时回复。
|
||||
|
||||
### 群组策略
|
||||
|
||||
控制机器人会在哪些飞书群里响应。
|
||||
|
||||
- **开放 (Open)(默认)** — 在机器人加入的任何群里被 `@` 都响应。
|
||||
- **白名单 (Allowlist)** — 只在 **允许的频道 ID** 列出的会话里响应(使用事件 payload 里的 `chat_id`)。
|
||||
- **禁用 (Disabled)** — 忽略所有群聊流量,机器人只接受私信。
|
||||
|
||||
跨平台细节见 [渠道概览](/docs/usage/channels/overview#direct-message-policy)。
|
||||
|
||||
## 配置参考
|
||||
|
||||
| 字段 | 是否必需 | 描述 |
|
||||
| ---------------------- | ---- | ----------------------- |
|
||||
| **应用 ID** | 是 | 您的飞书应用的应用 ID(`cli_xxx`) |
|
||||
| **应用密钥** | 是 | 您的飞书应用的应用密钥 |
|
||||
| **Verification Token** | 否 | 验证 webhook 事件来源(推荐) |
|
||||
| **Encrypt Key** | 否 | 解密加密事件负载 |
|
||||
| **事件订阅 URL** | — | 保存后自动生成;粘贴到飞书开发者门户 |
|
||||
| 字段 | 是否必需 | 描述 |
|
||||
| ---------------------- | ---- | -------------------------------------------------- |
|
||||
| **应用 ID** | 是 | 您的飞书应用的应用 ID(`cli_xxx`) |
|
||||
| **应用密钥** | 是 | 您的飞书应用的应用密钥 |
|
||||
| **Verification Token** | 否 | 验证 webhook 事件来源(推荐) |
|
||||
| **Encrypt Key** | 否 | 解密加密事件负载 |
|
||||
| **事件订阅 URL** | — | 保存后自动生成;粘贴到飞书开发者门户 |
|
||||
| **允许的用户 ID** | 否 | 逗号或空格分隔的飞书 `open_id`。全局闸门 — 私信和群聊 @ 都受其约束 |
|
||||
| **私信策略** | 否 | `open`(默认)、`allowlist` 或 `disabled` — 控制谁可以私信机器人 |
|
||||
| **群组策略** | 否 | `open`(默认)、`allowlist` 或 `disabled` — 控制机器人在哪些群中响应 |
|
||||
| **允许的频道 ID** | 否 | 逗号或空格分隔的飞书 `chat_id`。仅在群组策略为白名单时使用 |
|
||||
|
||||
## 故障排除
|
||||
|
||||
- **事件订阅 URL 验证失败:** 确保您已在 LobeHub 中保存配置,并正确复制了 URL。
|
||||
- **机器人未响应:** 验证应用已发布并获得批准,机器人功能已启用,并订阅了 `im.message.receive_v1` 事件。
|
||||
- **机器人不回私信:** 在 LobeHub 的 **高级设置** 检查 **私信策略**。如果是 `Disabled`,改成 `Open` 或 `Allowlist`;如果是 `Allowlist`,确认发起方的 `open_id` 已加入 **允许的用户 ID**。
|
||||
- **权限错误:** 确保所有所需权限已在开发者门户中添加并获得批准。
|
||||
- **测试连接失败:** 仔细检查应用 ID 和应用密钥。
|
||||
|
||||
@@ -165,19 +165,48 @@ By connecting a Lark channel to your LobeHub agent, team members can interact wi
|
||||
|
||||
Back in LobeHub's channel settings, click **Test Connection** to verify the credentials. Then find your bot in Lark by searching its name and send it a message to confirm it responds.
|
||||
|
||||
## Access Policies
|
||||
|
||||
Two independent policies gate inbound traffic. Both default to **Open**.
|
||||
|
||||
### Allowed User IDs (global)
|
||||
|
||||
A populated **Allowed User IDs** field is a global gate — DMs *and* group `@mentions` are restricted to listed Lark `open_id` values. Empty means "no user-level filter". Read the `open_id` from the event payload, or copy the **User ID** displayed in the Lark Developer Portal.
|
||||
|
||||
### DM Policy
|
||||
|
||||
- **Open (default)** — Any tenant member can DM the bot (subject to the global allowlist when set).
|
||||
- **Allowlist** — DMs require the sender to be in **Allowed User IDs**. Differs from `Open` only when the list is empty: `Allowlist` then fails closed (no DMs).
|
||||
- **Disabled** — The bot ignores all DMs and only responds to chat-group `@mentions`.
|
||||
|
||||
### Group Policy
|
||||
|
||||
Controls which Lark chat groups the bot will respond in.
|
||||
|
||||
- **Open (default)** — Respond to `@mentions` in any chat group the bot has been added to.
|
||||
- **Allowlist** — Respond only in chats whose `chat_id` is listed in **Allowed Channel IDs** (read it from the event payload).
|
||||
- **Disabled** — Ignore all group traffic; the bot only responds to DMs.
|
||||
|
||||
See the [Channels overview](/docs/usage/channels/overview#direct-message-policy) for cross-platform details.
|
||||
|
||||
## Configuration Reference
|
||||
|
||||
| Field | Required | Description |
|
||||
| -------------------------- | -------- | ------------------------------------------------------------- |
|
||||
| **App ID** | Yes | Your Lark app's App ID (`cli_xxx`) |
|
||||
| **App Secret** | Yes | Your Lark app's App Secret |
|
||||
| **Verification Token** | No | Verifies webhook event source (recommended) |
|
||||
| **Encrypt Key** | No | Decrypts encrypted event payloads |
|
||||
| **Event Subscription URL** | — | Auto-generated after saving; paste into Lark Developer Portal |
|
||||
| Field | Required | Description |
|
||||
| -------------------------- | -------- | ------------------------------------------------------------------------------------------------------ |
|
||||
| **App ID** | Yes | Your Lark app's App ID (`cli_xxx`) |
|
||||
| **App Secret** | Yes | Your Lark app's App Secret |
|
||||
| **Verification Token** | No | Verifies webhook event source (recommended) |
|
||||
| **Encrypt Key** | No | Decrypts encrypted event payloads |
|
||||
| **Event Subscription URL** | — | Auto-generated after saving; paste into Lark Developer Portal |
|
||||
| **Allowed User IDs** | No | Comma- or whitespace-separated Lark `open_id` values. Global gate — applies to DMs and group @mentions |
|
||||
| **DM Policy** | No | `open` (default), `allowlist`, or `disabled` — who is allowed to DM the bot |
|
||||
| **Group Policy** | No | `open` (default), `allowlist`, or `disabled` — where the bot responds to @mentions |
|
||||
| **Allowed Channel IDs** | No | Comma- or whitespace-separated Lark `chat_id` values. Used when Group Policy is Allowlist |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Event Subscription URL verification failed:** Ensure you saved the configuration in LobeHub first, and the URL was copied correctly.
|
||||
- **Bot not responding:** Verify the app is published and approved, the bot capability is enabled, and the `im.message.receive_v1` event is subscribed.
|
||||
- **Bot ignores DMs:** Open **Advanced Settings** in LobeHub and check **DM Policy**. If it is `Disabled`, switch to `Open` or `Allowlist`. If it is `Allowlist`, confirm the sender's `open_id` is listed in **Allowed User IDs**.
|
||||
- **Permission errors:** Confirm all required permissions are added and approved in the Developer Portal.
|
||||
- **Test Connection failed:** Double-check the App ID and App Secret. Make sure you selected "Lark" (not "飞书") in LobeHub's channel settings.
|
||||
|
||||
@@ -162,19 +162,48 @@ tags:
|
||||
|
||||
回到 LobeHub 的渠道设置,点击 **Test Connection** 以验证凭证。然后在 Lark 中搜索您的机器人名称并发送消息,确认其是否响应。
|
||||
|
||||
## 接入策略
|
||||
|
||||
两个独立的策略控制入站消息,默认都为 **开放**。
|
||||
|
||||
### 允许的用户 ID(全局)
|
||||
|
||||
填入 **允许的用户 ID** 后,**所有**入站消息(私信和群聊 `@提及`)都必须来自列表里的 Lark `open_id`。留空则不做用户级过滤。`open_id` 可从事件 payload 读取,或在 Lark 开发者后台查看 **User ID**。
|
||||
|
||||
### 私信策略
|
||||
|
||||
- **开放 (Open)(默认)** — 租户内任何成员都可以私信机器人(若设置了全局白名单则受其约束)。
|
||||
- **白名单 (Allowlist)** — 私信需要发送者在 **允许的用户 ID** 里。和 `Open` 的差别在白名单为空时:`Allowlist` 模式**全部拒绝**。
|
||||
- **禁用 (Disabled)** — 机器人忽略所有私信,只在群聊里被 `@提及` 时回复。
|
||||
|
||||
### 群组策略
|
||||
|
||||
控制机器人会在哪些 Lark 群里响应。
|
||||
|
||||
- **开放 (Open)(默认)** — 在机器人加入的任何群里被 `@` 都响应。
|
||||
- **白名单 (Allowlist)** — 只在 **允许的频道 ID** 列出的会话里响应(使用事件 payload 里的 `chat_id`)。
|
||||
- **禁用 (Disabled)** — 忽略所有群聊流量,机器人只接受私信。
|
||||
|
||||
跨平台细节见 [渠道概览](/docs/usage/channels/overview#direct-message-policy)。
|
||||
|
||||
## 配置参考
|
||||
|
||||
| 字段 | 是否必需 | 描述 |
|
||||
| -------------------------- | ---- | ----------------------------- |
|
||||
| **App ID** | 是 | 您的 Lark 应用的 App ID(`cli_xxx`) |
|
||||
| **App Secret** | 是 | 您的 Lark 应用的 App Secret |
|
||||
| **Verification Token** | 否 | 验证 webhook 事件来源(推荐) |
|
||||
| **Encrypt Key** | 否 | 解密加密事件负载 |
|
||||
| **Event Subscription URL** | — | 保存后自动生成;粘贴到 Lark 开发者门户 |
|
||||
| 字段 | 是否必需 | 描述 |
|
||||
| -------------------------- | ---- | -------------------------------------------------- |
|
||||
| **App ID** | 是 | 您的 Lark 应用的 App ID(`cli_xxx`) |
|
||||
| **App Secret** | 是 | 您的 Lark 应用的 App Secret |
|
||||
| **Verification Token** | 否 | 验证 webhook 事件来源(推荐) |
|
||||
| **Encrypt Key** | 否 | 解密加密事件负载 |
|
||||
| **Event Subscription URL** | — | 保存后自动生成;粘贴到 Lark 开发者门户 |
|
||||
| **允许的用户 ID** | 否 | 逗号或空格分隔的 Lark `open_id`。全局闸门 — 私信和群聊 @ 都受其约束 |
|
||||
| **私信策略** | 否 | `open`(默认)、`allowlist` 或 `disabled` — 控制谁可以私信机器人 |
|
||||
| **群组策略** | 否 | `open`(默认)、`allowlist` 或 `disabled` — 控制机器人在哪些群中响应 |
|
||||
| **允许的频道 ID** | 否 | 逗号或空格分隔的 Lark `chat_id`。仅在群组策略为白名单时使用 |
|
||||
|
||||
## 故障排除
|
||||
|
||||
- **Event Subscription URL 验证失败:** 确保您已在 LobeHub 中保存配置,并正确复制了 URL。
|
||||
- **机器人未响应:** 验证应用已发布并获得批准,机器人功能已启用,并订阅了 `im.message.receive_v1` 事件。
|
||||
- **机器人不回私信:** 在 LobeHub 的 **高级设置** 检查 **私信策略**。如果是 `Disabled`,改成 `Open` 或 `Allowlist`;如果是 `Allowlist`,确认发起方的 `open_id` 已加入 **允许的用户 ID**。
|
||||
- **权限错误:** 确保所有所需权限已在开发者门户中添加并获得批准。
|
||||
- **测试连接失败:** 仔细检查 App ID 和 App Secret。确保您在 LobeHub 的渠道设置中选择了 "Lark"(而不是 "飞书")。
|
||||
|
||||
@@ -21,21 +21,21 @@ tags:
|
||||
|
||||
Channels allow you to connect your LobeHub agents to external messaging platforms. Once connected, users can interact with your AI assistant directly in the chat apps they already use — no need to visit LobeHub.
|
||||
|
||||
> [!NOTE]
|
||||
> \[!NOTE]
|
||||
>
|
||||
> WeChat currently requires an active subscription. If you are using the community edition without a subscription, the WeChat channel option may not appear in the Channels settings yet.
|
||||
|
||||
## Supported Platforms
|
||||
|
||||
| Platform | Description |
|
||||
| ------------------------------------------ | --------------------------------------------------------------- |
|
||||
| [Discord](/docs/usage/channels/discord) | Connect to Discord servers for channel chat and direct messages |
|
||||
| [Slack](/docs/usage/channels/slack) | Connect to Slack for channel and direct message conversations |
|
||||
| [Telegram](/docs/usage/channels/telegram) | Connect to Telegram for private and group conversations |
|
||||
| [QQ](/docs/usage/channels/qq) | Connect to QQ for group chats and direct messages |
|
||||
| Platform | Description |
|
||||
| ------------------------------------------ | --------------------------------------------------------------------------------------------- |
|
||||
| [Discord](/docs/usage/channels/discord) | Connect to Discord servers for channel chat and direct messages |
|
||||
| [Slack](/docs/usage/channels/slack) | Connect to Slack for channel and direct message conversations |
|
||||
| [Telegram](/docs/usage/channels/telegram) | Connect to Telegram for private and group conversations |
|
||||
| [QQ](/docs/usage/channels/qq) | Connect to QQ for group chats and direct messages |
|
||||
| [WeChat (微信)](/docs/usage/channels/wechat) | Connect to WeChat via iLink Bot for private and group chats (requires an active subscription) |
|
||||
| [Feishu (飞书)](/docs/usage/channels/feishu) | Connect to Feishu for team collaboration (Chinese version) |
|
||||
| [Lark](/docs/usage/channels/lark) | Connect to Lark for team collaboration (international version) |
|
||||
| [Feishu (飞书)](/docs/usage/channels/feishu) | Connect to Feishu for team collaboration (Chinese version) |
|
||||
| [Lark](/docs/usage/channels/lark) | Connect to Lark for team collaboration (international version) |
|
||||
|
||||
## How It Works
|
||||
|
||||
@@ -70,3 +70,61 @@ Text messages are supported across all platforms. Some features vary by platform
|
||||
| Group chats | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
|
||||
| Reactions | Yes | Yes | Yes | No | No | Partial | Partial |
|
||||
| Image/file attachments | Yes | Yes | Yes | Yes | No | Yes | Yes |
|
||||
|
||||
## Allowed Users (global)
|
||||
|
||||
The **Allowed Users** list at the top of **Advanced Settings** is a *global* user gate. When populated, only the listed users can interact with the bot — in DMs **and** in group `@mentions` — regardless of DM Policy or Group Policy mode. Leave it empty to disable user-level filtering and let per-scope policies decide on their own.
|
||||
|
||||
Behaviour when populated:
|
||||
|
||||
| Surface | Non-allowlisted sender |
|
||||
| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **DM** | Blocked. Sender receives a one-line "you aren't authorized to send direct messages" notice in the same DM. |
|
||||
| **Group / channel @mention** | Blocked. A short "you aren't authorized to interact with this bot" notice is posted in the same thread the @-mention arrived on (on Discord this is the auto-created reply thread, not the parent channel). |
|
||||
|
||||
Add one entry per row. Each row holds a platform user ID (required) and an optional **Note** — a private label that's only ever shown back to you on the settings page. The note is what saves you from having to remember whether `U01ABCXYZ` was Alice or the on-call account six months from now; the runtime ignores it entirely.
|
||||
|
||||
> **Anti-lockout**: if you have **Your Platform User ID** set (the AI-tools field), that ID is implicitly trusted by the global allowlist. Forgetting to add yourself to **Allowed Users** when scoping the bot to friends won't lock you out.
|
||||
|
||||
## Direct Message Policy
|
||||
|
||||
DM Policy only governs DMs — group `@mentions` are gated independently by **Group Policy** below. The user-level filter from the global **Allowed Users** is also applied; per-scope policy stacks on top.
|
||||
|
||||
| Policy | Behavior |
|
||||
| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Open** | Any user on the platform can DM the bot (subject to the global allowlist when set). Best for public-facing assistants. |
|
||||
| **Allowlist** | DMs require the sender to be in **Allowed Users**. Distinct from `Open` only when the list is empty: `Allowlist` then **fails closed** (no DMs); `Open` still lets anyone DM. |
|
||||
| **Disabled** | The bot ignores all DMs entirely. Use this when the bot should only reply in shared channels via `@mention`. |
|
||||
|
||||
## Group Policy
|
||||
|
||||
For the same group-capable platforms, each channel has a **Group Policy** that controls where the bot responds to `@mentions`. This is independent of DM Policy: a `groupPolicy=disabled` bot still accepts DMs (subject to DM Policy), and vice versa.
|
||||
|
||||
| Policy | Behavior |
|
||||
| ------------- | ---------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Open** | Respond to `@mentions` in any group, channel, or thread the bot can read. Default. |
|
||||
| **Allowlist** | Respond only when the channel / group / chat ID is in **Allowed Channels**. Use to scope a bot to specific workspaces. |
|
||||
| **Disabled** | Ignore all non-DM traffic. The bot becomes DM-only. |
|
||||
|
||||
When **Allowlist** is selected, **Allowed Channels** appears as a row editor — one entry per channel / group / chat ID, with an optional note (e.g. `#general`) shown only to you so you can recognise each ID later.
|
||||
|
||||
> **Discord parent channels**: an `@mention` in a Discord channel automatically spawns a reply thread, so the inbound thread ID is *not* what Discord's "Copy Channel ID" gives you. You can paste the **parent channel ID** here — the bot accepts any auto-created reply thread under it. Pasting a specific thread ID instead works too, in which case only that thread is allowed.
|
||||
|
||||
### Per-platform defaults
|
||||
|
||||
Every supported platform defaults to **Open** for both policies so the bot stays reachable out of the box. Tighten per-channel via **Allowlist** or **Disabled** when you want a private bot. WeChat does not expose these settings because the WeChat integration is DM-only by design.
|
||||
|
||||
### Finding a user's platform ID
|
||||
|
||||
- **Discord** — Enable Developer Mode in user settings, right-click the user, and choose **Copy User ID**.
|
||||
- **Slack** — Open the user's profile → click the `⋮` menu → **Copy member ID** (starts with `U`).
|
||||
- **Telegram** — Ask the user to message [@userinfobot](https://t.me/userinfobot), or read `from.id` from the bot's incoming update.
|
||||
- **QQ** — Use the `tiny_id` from the OpenAPI event payload (the public-facing QQ number is not guaranteed to be the platform ID).
|
||||
- **Feishu / Lark** — Use the `open_id` from the event payload, or the **User ID** displayed in the developer portal.
|
||||
|
||||
### Finding a channel / group ID
|
||||
|
||||
- **Discord** — Enable Developer Mode, right-click the channel, and choose **Copy Channel ID**.
|
||||
- **Slack** — Open the channel's About panel and copy the channel ID at the bottom (starts with `C`).
|
||||
- **Telegram** — Forward a message from the group to [@userinfobot](https://t.me/userinfobot), or read `chat.id` from the bot's incoming update (group IDs are negative).
|
||||
- **Feishu / Lark** — Use the `chat_id` from the event payload.
|
||||
|
||||
@@ -20,21 +20,21 @@ tags:
|
||||
|
||||
渠道功能允许您将 LobeHub 代理连接到外部消息平台。一旦连接,用户可以直接在他们已经使用的聊天应用中与您的 AI 助手互动,无需访问 LobeHub。
|
||||
|
||||
> [!NOTE]
|
||||
> \[!NOTE]
|
||||
>
|
||||
> 微信渠道目前需要有效订阅。如果您使用的是没有订阅的社区版,**渠道**设置中可能暂时不会显示微信选项。
|
||||
|
||||
## 支持的平台
|
||||
|
||||
| 平台 | 描述 |
|
||||
| ----------------------------------------- | -------------------------- |
|
||||
| [Discord](/docs/usage/channels/discord) | 连接到 Discord 服务器,用于频道聊天和私信 |
|
||||
| [Slack](/docs/usage/channels/slack) | 连接到 Slack,用于频道和私信对话 |
|
||||
| [Telegram](/docs/usage/channels/telegram) | 连接到 Telegram,用于私人和群组对话 |
|
||||
| [QQ](/docs/usage/channels/qq) | 连接到 QQ,用于群聊和私信 |
|
||||
| 平台 | 描述 |
|
||||
| ----------------------------------------- | ---------------------------------- |
|
||||
| [Discord](/docs/usage/channels/discord) | 连接到 Discord 服务器,用于频道聊天和私信 |
|
||||
| [Slack](/docs/usage/channels/slack) | 连接到 Slack,用于频道和私信对话 |
|
||||
| [Telegram](/docs/usage/channels/telegram) | 连接到 Telegram,用于私人和群组对话 |
|
||||
| [QQ](/docs/usage/channels/qq) | 连接到 QQ,用于群聊和私信 |
|
||||
| [微信](/docs/usage/channels/wechat) | 通过 iLink Bot 连接到微信,用于私聊和群聊(需要有效订阅) |
|
||||
| [飞书](/docs/usage/channels/feishu) | 连接到飞书,用于团队协作(中国版) |
|
||||
| [Lark](/docs/usage/channels/lark) | 连接到 Lark,用于团队协作(国际版) |
|
||||
| [飞书](/docs/usage/channels/feishu) | 连接到飞书,用于团队协作(中国版) |
|
||||
| [Lark](/docs/usage/channels/lark) | 连接到 Lark,用于团队协作(国际版) |
|
||||
|
||||
## 工作原理
|
||||
|
||||
@@ -69,3 +69,61 @@ tags:
|
||||
| 群组聊天 | 是 | 是 | 是 | 是 | 是 | 是 | 是 |
|
||||
| 表情反应 | 是 | 是 | 是 | 否 | 否 | 部分支持 | 部分支持 |
|
||||
| 图片 / 文件附件 | 是 | 是 | 是 | 是 | 否 | 是 | 是 |
|
||||
|
||||
## 允许的用户(全局)
|
||||
|
||||
**高级设置** 里的 **允许的用户 (allowFrom)** 是一道**全局**用户级闸门。填入后,**所有**入站消息(私信和群聊 `@提及` 都算)都必须来自列表里的用户,无论私信策略 / 群组策略选什么都生效。留空则不做用户级过滤,由各 scope 的策略自行决定。
|
||||
|
||||
填入后的行为:
|
||||
|
||||
| 入口 | 不在白名单的发送者 |
|
||||
| --------------- | ------------------------------------------------------------------------------------------ |
|
||||
| **私信** | 被拒绝,对方在私信里收到一条 "您没有私信此机器人的权限" 的系统提示。 |
|
||||
| **群组 / 频道 @提及** | 被拒绝,会在 @ 所在的同一线程里发一条 "您没有与该机器人交互的权限" 的提示(Discord 上这是 @ 时自动创建的 thread,不会污染父频道;其他平台则可见于该群组)。 |
|
||||
|
||||
每行填一个用户:左边是平台用户 ID(必填),右边是可选的 **备注** —— 这个备注只会在设置页展示给你自己,让你日后回看时不用费力辨认 `U01ABCXYZ` 到底是张三还是值班号;运行时完全不读它。
|
||||
|
||||
> **防自我锁出**:如果你已经填了 **你的平台用户 ID**(AI 工具字段),那个 ID 会被全局白名单隐式信任。即便你把 bot 收紧到只给朋友、忘了把自己加进 **允许的用户**,也不会被锁出。
|
||||
|
||||
## 私信策略
|
||||
|
||||
私信策略只影响私信 — 群聊里的 `@提及` 由下面的 **群组策略** 单独管理。全局 **允许的用户** 的用户级过滤也会同时生效;各 scope 的策略叠加在上面。
|
||||
|
||||
| 策略 | 行为 |
|
||||
| ------------------- | -------------------------------------------------------------------------------------- |
|
||||
| **开放 (Open)** | 平台上的任何用户都可以私信机器人(如设置了全局白名单则受其约束)。适合面向所有人开放的助手。 |
|
||||
| **白名单 (Allowlist)** | 私信需要发送者在 **允许的用户** 里。和 `Open` 的差别在白名单为空时:`Allowlist` 模式下会**全部拒绝**,而 `Open` 模式下任何人都能私信。 |
|
||||
| **禁用 (Disabled)** | 机器人会忽略所有私信。适合那种 " 只在群里被 `@` 时才回复 " 的场景。 |
|
||||
|
||||
## 群组策略
|
||||
|
||||
同样适用于支持群聊的那些平台,每个渠道都有一个 **群组策略 (Group Policy)**,用来控制机器人在群组、频道、子话题里的响应范围。它跟私信策略相互独立:`groupPolicy=disabled` 的机器人仍会接收私信(受私信策略约束),反之亦然。
|
||||
|
||||
| 策略 | 行为 |
|
||||
| ------------------- | ---------------------------------------- |
|
||||
| **开放 (Open)** | 在机器人能读取的任何群组、频道、子话题里被 `@` 都响应(默认)。 |
|
||||
| **白名单 (Allowlist)** | 只有 **允许的频道** 里列出的频道 / 群组 / 会话才会响应,其他都忽略。 |
|
||||
| **禁用 (Disabled)** | 忽略所有非私信流量,机器人退化为纯私信模式。 |
|
||||
|
||||
选择 **白名单** 时会出现 **允许的频道** 编辑器:每行填一个平台原生的频道 / 群组 / 会话 ID,可附带一个备注(比如 `#general`),备注只展示给你自己,方便日后辨认每个 ID 是哪个频道。
|
||||
|
||||
> **Discord 父频道**:在 Discord 频道里 `@提及` 机器人时,平台会自动开一个 reply thread,入站 thread ID **不等于** Discord「复制频道 ID」给你的那个值。这里直接粘贴**父频道 ID** 就行 —— 它会让父频道下的所有自动 reply thread 都通过白名单。如果你只想放行某个具体 thread,也可以填那个 thread 的 ID,效果就是只放行该 thread。
|
||||
|
||||
### 各平台默认值
|
||||
|
||||
所有支持的平台两个策略默认都为 **开放 (Open)**,开箱即用。需要私有化时按渠道改成 **白名单 (Allowlist)** 或 **禁用 (Disabled)** 即可。微信因本身就是 DM 模式,没有这两个策略选项。
|
||||
|
||||
### 如何获取用户的平台 ID
|
||||
|
||||
- **Discord** — 在用户设置里开启开发者模式,右键用户头像选 **复制用户 ID**。
|
||||
- **Slack** — 打开用户资料 → 点击 `⋮` 菜单 → **复制成员 ID**(以 `U` 开头)。
|
||||
- **Telegram** — 让用户私信 [@userinfobot](https://t.me/userinfobot),或者从机器人收到的 update 里读 `from.id`。
|
||||
- **QQ** — 使用 OpenAPI 事件 payload 里的 `tiny_id`(用户对外可见的 QQ 号不一定就是平台 ID)。
|
||||
- **飞书 / Lark** — 使用事件 payload 里的 `open_id`,或开发者后台显示的 **User ID**。
|
||||
|
||||
### 如何获取频道 / 群组 ID
|
||||
|
||||
- **Discord** — 开启开发者模式,右键频道选 **复制频道 ID**。
|
||||
- **Slack** — 打开频道详情面板,底部能看到频道 ID(以 `C` 开头)。
|
||||
- **Telegram** — 把群里的一条消息转发给 [@userinfobot](https://t.me/userinfobot),或者从机器人收到的 update 里读 `chat.id`(群组是负数)。
|
||||
- **飞书 / Lark** — 使用事件 payload 里的 `chat_id`。
|
||||
|
||||
@@ -140,13 +140,41 @@ To use the bot in QQ groups:
|
||||
2. @mention the bot in a message to trigger a response
|
||||
3. The bot will reply in the group conversation
|
||||
|
||||
## Access Policies
|
||||
|
||||
Two independent policies gate inbound traffic. Both default to **Open**.
|
||||
|
||||
### Allowed User IDs (global)
|
||||
|
||||
A populated **Allowed User IDs** field is a global gate — DMs *and* group `@mentions` are restricted to listed QQ `tiny_id` values. Empty means "no user-level filter". Use the platform `tiny_id` from the OpenAPI event payload — the visible QQ number is not always the same as the platform ID.
|
||||
|
||||
### DM Policy
|
||||
|
||||
- **Open (default)** — Any QQ user who shares context with the bot can DM it (subject to the global allowlist when set).
|
||||
- **Allowlist** — DMs require the sender to be in **Allowed User IDs**. Differs from `Open` only when the list is empty: `Allowlist` then fails closed (no DMs).
|
||||
- **Disabled** — The bot ignores all DMs and only responds to group `@mentions`.
|
||||
|
||||
### Group Policy
|
||||
|
||||
Controls which QQ groups the bot will respond in.
|
||||
|
||||
- **Open (default)** — Respond to `@mentions` in any group the bot has been added to.
|
||||
- **Allowlist** — Respond only in groups whose ID is listed in **Allowed Channel IDs** (use the platform group ID from the OpenAPI event payload).
|
||||
- **Disabled** — Ignore all group traffic; the bot only responds to DMs.
|
||||
|
||||
See the [Channels overview](/docs/usage/channels/overview#direct-message-policy) for cross-platform details.
|
||||
|
||||
## Configuration Reference
|
||||
|
||||
| Field | Required | Description |
|
||||
| ------------------- | -------- | --------------------------------------------------------------------------------------- |
|
||||
| **Application ID** | Yes | Your bot's App ID from QQ Open Platform |
|
||||
| **App Secret** | Yes | Your bot's App Secret from QQ Open Platform |
|
||||
| **Connection Mode** | No | `websocket` (default) or `webhook`. Choose based on your QQ Open Platform configuration |
|
||||
| Field | Required | Description |
|
||||
| ----------------------- | -------- | ---------------------------------------------------------------------------------------------------- |
|
||||
| **Application ID** | Yes | Your bot's App ID from QQ Open Platform |
|
||||
| **App Secret** | Yes | Your bot's App Secret from QQ Open Platform |
|
||||
| **Connection Mode** | No | `websocket` (default) or `webhook`. Choose based on your QQ Open Platform configuration |
|
||||
| **Allowed User IDs** | No | Comma- or whitespace-separated QQ `tiny_id` values. Global gate — applies to DMs and group @mentions |
|
||||
| **DM Policy** | No | `open` (default), `allowlist`, or `disabled` — who is allowed to DM the bot |
|
||||
| **Group Policy** | No | `open` (default), `allowlist`, or `disabled` — where the bot responds to @mentions |
|
||||
| **Allowed Channel IDs** | No | Comma- or whitespace-separated QQ group IDs. Used when Group Policy is Allowlist |
|
||||
|
||||
## Limitations
|
||||
|
||||
|
||||
@@ -137,13 +137,41 @@ LobeHub ��持两种 QQ 机器人连接模式:
|
||||
2. 在消息中 @提及机器人以触发响应
|
||||
3. 机器人将在群聊中回复
|
||||
|
||||
## 接入策略
|
||||
|
||||
两个独立的策略控制入站消息,默认都为 **开放**。
|
||||
|
||||
### 允许的用户 ID(全局)
|
||||
|
||||
填入 **允许的用户 ID** 后,**所有**入站消息(私信和群聊 `@提及`)都必须来自列表里的 QQ `tiny_id`。留空则不做用户级过滤。`tiny_id` 来自 OpenAPI 事件 payload —— 用户对外可见的 QQ 号不一定就是平台 ID。
|
||||
|
||||
### 私信策略
|
||||
|
||||
- **开放 (Open)(默认)** — 任何与机器人有上下文交集的 QQ 用户都可以私信(若设置了全局白名单则受其约束)。
|
||||
- **白名单 (Allowlist)** — 私信需要发送者在 **允许的用户 ID** 里。和 `Open` 的差别在白名单为空时:`Allowlist` 模式**全部拒绝**。
|
||||
- **禁用 (Disabled)** — 机器人忽略所有私信,只在群聊里被 `@提及` 时回复。
|
||||
|
||||
### 群组策略
|
||||
|
||||
控制机器人会在哪些 QQ 群里响应。
|
||||
|
||||
- **开放 (Open)(默认)** — 在机器人加入的任何群里被 `@` 都响应。
|
||||
- **白名单 (Allowlist)** — 只在 **允许的频道 ID** 列出的群里响应(使用 OpenAPI 事件 payload 里的群 ID)。
|
||||
- **禁用 (Disabled)** — 忽略所有群聊流量,机器人只接受私信。
|
||||
|
||||
跨平台细节见 [渠道概览](/docs/usage/channels/overview#direct-message-policy)。
|
||||
|
||||
## 配置参考
|
||||
|
||||
| 字段 | 是否必需 | 描述 |
|
||||
| -------------- | ---- | ----------------------------------------- |
|
||||
| **应用 ID** | 是 | 来自 QQ 开放平台的 App ID |
|
||||
| **App Secret** | 是 | 来自 QQ 开放平台的 App Secret |
|
||||
| **连接模式** | 否 | `websocket`(默认)或 `webhook`,根据 QQ 开放平台配置选择 |
|
||||
| 字段 | 是否必需 | 描述 |
|
||||
| -------------- | ---- | -------------------------------------------------- |
|
||||
| **应用 ID** | 是 | 来自 QQ 开放平台的 App ID |
|
||||
| **App Secret** | 是 | 来自 QQ 开放平台的 App Secret |
|
||||
| **连接模式** | 否 | `websocket`(默认)或 `webhook`,根据 QQ 开放平台配置选择 |
|
||||
| **允许的用户 ID** | 否 | 逗号或空格分隔的 QQ `tiny_id`。全局闸门 — 私信和群聊 @ 都受其约束 |
|
||||
| **私信策略** | 否 | `open`(默认)、`allowlist` 或 `disabled` — 控制谁可以私信机器人 |
|
||||
| **群组策略** | 否 | `open`(默认)、`allowlist` 或 `disabled` — 控制机器人在哪些群中响应 |
|
||||
| **允许的频道 ID** | 否 | 逗号或空格分隔的 QQ 群 ID。仅在群组策略为白名单时使用 |
|
||||
|
||||
## 功能限制
|
||||
|
||||
|
||||
@@ -213,19 +213,48 @@ Use this method if your Slack app already has Event Subscriptions configured wit
|
||||
Also ensure you add the `commands` scope under **OAuth & Permissions** → **Bot Token Scopes**, and enable **Interactivity & Shortcuts** with the same Webhook URL as the Request URL.
|
||||
</Steps>
|
||||
|
||||
## Access Policies
|
||||
|
||||
Two independent policies gate inbound traffic. Both default to **Open**.
|
||||
|
||||
### Allowed User IDs (global)
|
||||
|
||||
A populated **Allowed User IDs** field is a global gate — DMs *and* channel `@mentions` are restricted to listed Slack member IDs. Empty means "no user-level filter". Open a user's profile → click `⋮` → **Copy member ID** (starts with `U`).
|
||||
|
||||
### DM Policy
|
||||
|
||||
- **Open (default)** — Any workspace member can DM the bot (subject to the global allowlist when set).
|
||||
- **Allowlist** — DMs require the sender to be in **Allowed User IDs**. Differs from `Open` only when the list is empty: `Allowlist` then fails closed (no DMs).
|
||||
- **Disabled** — The bot ignores all DMs and only replies to channel `@mentions`.
|
||||
|
||||
### Group Policy
|
||||
|
||||
Controls which Slack channels the bot will respond in.
|
||||
|
||||
- **Open (default)** — Respond to `@mentions` in any channel the bot has been added to.
|
||||
- **Allowlist** — Respond only in channels whose ID is in **Allowed Channel IDs**. Open the channel's About panel to copy the channel ID (starts with `C`).
|
||||
- **Disabled** — Ignore all channel traffic; the bot only responds to DMs.
|
||||
|
||||
See the [Channels overview](/docs/usage/channels/overview#direct-message-policy) for cross-platform details.
|
||||
|
||||
## Configuration Reference
|
||||
|
||||
| Field | Required | Description |
|
||||
| ------------------- | ---------------- | ----------------------------------------------------- |
|
||||
| **Application ID** | Yes | Your Slack app's ID |
|
||||
| **Bot Token** | Yes | Bot User OAuth Token (`xoxb-...`) |
|
||||
| **Signing Secret** | Yes | Used to verify requests from Slack |
|
||||
| **App-Level Token** | Socket Mode only | App-level token (`xapp-...`) for WebSocket connection |
|
||||
| **Connection Mode** | No | `websocket` or `webhook` (default: `webhook`) |
|
||||
| Field | Required | Description |
|
||||
| ----------------------- | ---------------- | ------------------------------------------------------------------------------------------------------ |
|
||||
| **Application ID** | Yes | Your Slack app's ID |
|
||||
| **Bot Token** | Yes | Bot User OAuth Token (`xoxb-...`) |
|
||||
| **Signing Secret** | Yes | Used to verify requests from Slack |
|
||||
| **App-Level Token** | Socket Mode only | App-level token (`xapp-...`) for WebSocket connection |
|
||||
| **Connection Mode** | No | `websocket` or `webhook` (default: `webhook`) |
|
||||
| **Allowed User IDs** | No | Comma- or whitespace-separated Slack member IDs. Global gate — applies to DMs and channel @mentions |
|
||||
| **DM Policy** | No | `open` (default), `allowlist`, or `disabled` — who is allowed to DM the bot |
|
||||
| **Group Policy** | No | `open` (default), `allowlist`, or `disabled` — where the bot responds to @mentions |
|
||||
| **Allowed Channel IDs** | No | Comma- or whitespace-separated Slack channel IDs (start with `C`). Used when Group Policy is Allowlist |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **DM shows "Sending messages to this app has been turned off":** In the Slack API Dashboard → **App Home** → **Show Tabs**, make sure **Messages Tab** is enabled and "Allow users to send Slash commands and messages from the messages tab" is checked. This is already enabled if you created the app using the Manifest template.
|
||||
- **Bot ignores DMs even though Slack is configured correctly:** Open **Advanced Settings** in LobeHub and check **DM Policy**. If it is `Disabled`, switch to `Open` or `Allowlist`. If it is `Allowlist`, confirm the sender's Slack member ID is listed in **Allowed User IDs**.
|
||||
- **Bot not responding:** Confirm the bot has been invited to the channel. For Socket Mode, ensure the App-Level Token is correct and Socket Mode is enabled in Slack app settings.
|
||||
- **Test Connection failed:** Double-check the Application ID and Bot Token. Ensure the app is installed to the workspace.
|
||||
- **Webhook verification failed (Webhook mode):** Make sure the Signing Secret matches and the Webhook URL is correct.
|
||||
|
||||
@@ -210,19 +210,48 @@ LobeHub 支持两种 Slack 连接模式:
|
||||
同时确保在 **OAuth & Permissions** → **Bot Token Scopes** 中添加 `commands` 权限,并在 **Interactivity & Shortcuts** 中启用 Interactivity,将 Request URL 设为相同的 Webhook URL。
|
||||
</Steps>
|
||||
|
||||
## 接入策略
|
||||
|
||||
两个独立的策略控制入站消息,默认都为 **开放**。
|
||||
|
||||
### 允许的用户 ID(全局)
|
||||
|
||||
填入 **允许的用户 ID** 后,**所有**入站消息(私信和频道 `@提及`)都必须来自列表里的 Slack 成员 ID。留空则不做用户级过滤。打开用户资料 → 点击 `⋮` → **复制成员 ID**(以 `U` 开头)。
|
||||
|
||||
### 私信策略
|
||||
|
||||
- **开放 (Open)(默认)** — workspace 内任何成员都可以私信机器人(若设置了全局白名单则受其约束)。
|
||||
- **白名单 (Allowlist)** — 私信需要发送者在 **允许的用户 ID** 里。和 `Open` 的差别在白名单为空时:`Allowlist` 模式**全部拒绝**。
|
||||
- **禁用 (Disabled)** — 机器人忽略所有私信,只在频道里被 `@提及` 时回复。
|
||||
|
||||
### 群组策略
|
||||
|
||||
控制机器人会在哪些 Slack 频道里响应。
|
||||
|
||||
- **开放 (Open)(默认)** — 在机器人加入的任何频道里被 `@` 都响应。
|
||||
- **白名单 (Allowlist)** — 只在 **允许的频道 ID** 列出的频道里响应。打开频道详情面板,底部能看到频道 ID(以 `C` 开头)。
|
||||
- **禁用 (Disabled)** — 忽略所有频道流量,机器人只接受私信。
|
||||
|
||||
跨平台细节见 [渠道概览](/docs/usage/channels/overview#direct-message-policy)。
|
||||
|
||||
## 配置参考
|
||||
|
||||
| 字段 | 是否必需 | 描述 |
|
||||
| -------------- | ------------- | -------------------------------------- |
|
||||
| **应用 ID** | 是 | 您的 Slack 应用 ID |
|
||||
| **Bot Token** | 是 | Bot User OAuth Token(`xoxb-...`) |
|
||||
| **签名密钥** | 是 | 用于验证来自 Slack 的请求 |
|
||||
| **应用级别 Token** | 仅 Socket Mode | 应用级别 Token(`xapp-...`),用于 WebSocket 连接 |
|
||||
| **连接模式** | 否 | `websocket` 或 `webhook`(默认:`webhook`) |
|
||||
| 字段 | 是否必需 | 描述 |
|
||||
| -------------- | ------------- | --------------------------------------------------- |
|
||||
| **应用 ID** | 是 | 您的 Slack 应用 ID |
|
||||
| **Bot Token** | 是 | Bot User OAuth Token(`xoxb-...`) |
|
||||
| **签名密钥** | 是 | 用于验证来自 Slack 的请求 |
|
||||
| **应用级别 Token** | 仅 Socket Mode | 应用级别 Token(`xapp-...`),用于 WebSocket 连接 |
|
||||
| **连接模式** | 否 | `websocket` 或 `webhook`(默认:`webhook`) |
|
||||
| **允许的用户 ID** | 否 | 逗号或空格分隔的 Slack 成员 ID。全局闸门 — 私信和频道 @ 都受其约束 |
|
||||
| **私信策略** | 否 | `open`(默认)、`allowlist` 或 `disabled` — 控制谁可以私信机器人 |
|
||||
| **群组策略** | 否 | `open`(默认)、`allowlist` 或 `disabled` — 控制机器人在哪些频道中响应 |
|
||||
| **允许的频道 ID** | 否 | 逗号或空格分隔的 Slack 频道 ID(以 `C` 开头)。仅在群组策略为白名单时使用 |
|
||||
|
||||
## 故障排除
|
||||
|
||||
- **私信显示 "Sending messages to this app has been turned off":** 在 Slack API 控制台 → **App Home** → **Show Tabs** 中,确保 **Messages Tab** 已启用,并勾选 "Allow users to send Slash commands and messages from the messages tab"。如果使用 Manifest 模板创建应用则默认已开启。
|
||||
- **Slack 配置正常但机器人不回私信:** 在 LobeHub 的 **高级设置** 里检查 **私信策略**。如果是 `Disabled`,改成 `Open` 或 `Allowlist`;如果是 `Allowlist`,确认发起方的 Slack 成员 ID 已加入 **允许的用户 ID**。
|
||||
- **机器人未响应:** 确认机器人已被邀请到频道。Socket Mode 下请确保应用级别 Token 正确且 Socket Mode 已在 Slack 应用设置中启用。
|
||||
- **测试连接失败:** 仔细检查应用 ID 和 Bot Token 是否正确。确保应用已安装到工作区。
|
||||
- **Webhook 验证失败(Webhook 模式):** 确保签名密钥匹配且 Webhook URL 正确。
|
||||
|
||||
@@ -93,16 +93,45 @@ To use the bot in Telegram groups:
|
||||
**About Group Privacy Mode:** Telegram bots have privacy mode enabled by default, which means they only receive messages that @mention the bot, reply to the bot, or contain /commands. If you change the privacy mode setting after creating the bot, you **must remove and re-add the bot to the group** for the new setting to take effect in that group.
|
||||
</Callout>
|
||||
|
||||
## Access Policies
|
||||
|
||||
Two independent policies gate inbound traffic. Both default to **Open**.
|
||||
|
||||
### Allowed User IDs (global)
|
||||
|
||||
A populated **Allowed User IDs** field acts as a global gate — DMs *and* group `@mentions` are restricted to listed Telegram numeric user IDs. Empty means "no user-level filter". Grab a user's numeric ID via [@userinfobot](https://t.me/userinfobot), or read the `from.id` field from the bot's incoming update.
|
||||
|
||||
### DM Policy
|
||||
|
||||
- **Open (default)** — Anyone on Telegram can DM the bot (subject to the global allowlist when set).
|
||||
- **Allowlist** — DMs require the sender to be in **Allowed User IDs**. Differs from `Open` only when the list is empty: `Allowlist` then fails closed (no DMs).
|
||||
- **Disabled** — The bot ignores all DMs and only responds to group `@mentions`.
|
||||
|
||||
### Group Policy
|
||||
|
||||
Controls which Telegram groups / channels the bot will respond in.
|
||||
|
||||
- **Open (default)** — Respond in any group / channel the bot has been added to.
|
||||
- **Allowlist** — Respond only in chats whose ID is listed in **Allowed Channel IDs**. Forward a message from the chat to [@userinfobot](https://t.me/userinfobot) to grab the chat ID (group IDs are negative).
|
||||
- **Disabled** — Ignore all group traffic; the bot only responds to DMs.
|
||||
|
||||
See the [Channels overview](/docs/usage/channels/overview#direct-message-policy) for cross-platform details.
|
||||
|
||||
## Configuration Reference
|
||||
|
||||
| Field | Required | Description |
|
||||
| ------------------------ | -------- | ---------------------------------------------- |
|
||||
| **Bot Token** | Yes | API token from BotFather |
|
||||
| **Bot User ID** | Auto | Automatically derived from the bot token |
|
||||
| **Webhook Secret Token** | No | Optional secret for verifying webhook requests |
|
||||
| Field | Required | Description |
|
||||
| ------------------------ | -------- | -------------------------------------------------------------------------------------------------------------- |
|
||||
| **Bot Token** | Yes | API token from BotFather |
|
||||
| **Bot User ID** | Auto | Automatically derived from the bot token |
|
||||
| **Webhook Secret Token** | No | Optional secret for verifying webhook requests |
|
||||
| **Allowed User IDs** | No | Comma- or whitespace-separated Telegram numeric user IDs. Global gate — applies to DMs and group @mentions |
|
||||
| **DM Policy** | No | `open` (default), `allowlist`, or `disabled` — who is allowed to DM the bot |
|
||||
| **Group Policy** | No | `open` (default), `allowlist`, or `disabled` — where the bot responds in groups |
|
||||
| **Allowed Channel IDs** | No | Comma- or whitespace-separated Telegram chat IDs (group IDs are negative). Used when Group Policy is Allowlist |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Bot not responding:** Verify the bot token is correct and the configuration is saved. Click **Test Connection** to diagnose.
|
||||
- **Bot ignores DMs:** Open **Advanced Settings** and check **DM Policy**. If it is `Disabled`, switch to `Open` or `Allowlist`. If it is `Allowlist`, confirm the sender's Telegram user ID (from [@userinfobot](https://t.me/userinfobot)) is listed in **Allowed User IDs**.
|
||||
- **Webhook registration failed:** Ensure your LobeHub subscription is active. Telegram requires HTTPS endpoints for webhooks, which LobeHub provides automatically.
|
||||
- **Group chat issues:** Make sure the bot has been added to the group and has permission to read messages. Mention the bot with `@username` to trigger a response. If the bot doesn't respond in a group, try removing the bot from the group and re-adding it — Telegram's privacy mode changes require re-joining the group to take effect.
|
||||
|
||||
@@ -92,16 +92,45 @@ tags:
|
||||
**关于隐私模式(Group Privacy):** Telegram 机器人默认启用隐私模式,仅接收群组中 @提及、回复机器人的消息以及 / 命令。如果您在创建机器人后更改了隐私模式设置,**必须将机器人从群组中移除后重新加入**,新的设置才会对该群组生效。
|
||||
</Callout>
|
||||
|
||||
## 接入策略
|
||||
|
||||
两个独立的策略控制入站消息,默认都为 **开放**。
|
||||
|
||||
### 允许的用户 ID(全局)
|
||||
|
||||
填入 **允许的用户 ID** 后,**所有**入站消息(私信和群聊 `@提及`)都必须来自列表里的 Telegram 数字用户 ID。留空则不做用户级过滤。让目标用户私信 [@userinfobot](https://t.me/userinfobot) 拿到自己的数字 ID,或直接从机器人收到的 update 里读 `from.id`。
|
||||
|
||||
### 私信策略
|
||||
|
||||
- **开放 (Open)(默认)** — Telegram 上任何用户都可以私信机器人(若设置了全局白名单则受其约束)。
|
||||
- **白名单 (Allowlist)** — 私信需要发送者在 **允许的用户 ID** 里。和 `Open` 的差别在白名单为空时:`Allowlist` 模式**全部拒绝**。
|
||||
- **禁用 (Disabled)** — 机器人忽略所有私信,只在群组里被 `@提及` 时回复。
|
||||
|
||||
### 群组策略
|
||||
|
||||
控制机器人会在哪些 Telegram 群组 / 频道里响应。
|
||||
|
||||
- **开放 (Open)(默认)** — 在机器人加入的任何群组 / 频道里都响应。
|
||||
- **白名单 (Allowlist)** — 只在 **允许的频道 ID** 列出的会话里响应。把群里的一条消息转发给 [@userinfobot](https://t.me/userinfobot) 即可拿到 chat ID(群组是负数)。
|
||||
- **禁用 (Disabled)** — 忽略所有群组流量,机器人只接受私信。
|
||||
|
||||
跨平台细节见 [渠道概览](/docs/usage/channels/overview#direct-message-policy)。
|
||||
|
||||
## 配置参考
|
||||
|
||||
| 字段 | 是否必需 | 描述 |
|
||||
| ---------------- | ---- | --------------------- |
|
||||
| **机器人令牌** | 是 | 来自 BotFather 的 API 令牌 |
|
||||
| **机器人用户 ID** | 自动 | 根据机器人令牌自动生成 |
|
||||
| **Webhook 密钥令牌** | 否 | 用于验证 Webhook 请求的可选密钥 |
|
||||
| 字段 | 是否必需 | 描述 |
|
||||
| ---------------- | ---- | --------------------------------------------------- |
|
||||
| **机器人令牌** | 是 | 来自 BotFather 的 API 令牌 |
|
||||
| **机器人用户 ID** | 自动 | 根据机器人令牌自动生成 |
|
||||
| **Webhook 密钥令牌** | 否 | 用于验证 Webhook 请求的可选密钥 |
|
||||
| **允许的用户 ID** | 否 | 逗号或空格分隔的 Telegram 数字用户 ID。全局闸门 — 私信和群聊 @ 都受其约束 |
|
||||
| **私信策略** | 否 | `open`(默认)、`allowlist` 或 `disabled` — 控制谁可以私信机器人 |
|
||||
| **群组策略** | 否 | `open`(默认)、`allowlist` 或 `disabled` — 控制机器人在哪些群组中响应 |
|
||||
| **允许的频道 ID** | 否 | 逗号或空格分隔的 Telegram chat ID(群组为负数)。仅在群组策略为白名单时使用 |
|
||||
|
||||
## 故障排除
|
||||
|
||||
- **机器人未响应:** 验证机器人令牌是否正确并确保配置已保存。点击 **测试连接** 进行诊断。
|
||||
- **机器人不回私信:** 打开 **高级设置** 检查 **私信策略**。如果是 `Disabled`,改成 `Open` 或 `Allowlist`;如果是 `Allowlist`,确认发起方的 Telegram 用户 ID(可通过 [@userinfobot](https://t.me/userinfobot) 获取)已加入 **允许的用户 ID**。
|
||||
- **Webhook 注册失败:** 确保您的 LobeHub 订阅处于活动状态。Telegram 要求 Webhook 使用 HTTPS 端点,LobeHub 会自动提供。
|
||||
- **群组聊天问题:** 确保机器人已被添加到群组并具有读取消息的权限。使用 `@username` 提及机器人以触发响应。如果机器人在群组中没有响应,尝试将机器人从群组中移除后重新加入 ——Telegram 的隐私模式设置变更需要重新加入群组才能生效。
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
@journey @agent @conversation @scroll
|
||||
Feature: 发送消息与流式输出期间的视口滚动行为
|
||||
作为用户,我希望视口能按我的设置正确响应 AI 流式输出:
|
||||
- 开启时视口应跟随最新内容,保持贴近底部
|
||||
- 关闭时视口应停留在我刚发送的消息,便于阅读
|
||||
- 流式过程中我手动向上滚动后,视口不应被自动拉回底
|
||||
|
||||
Background:
|
||||
Given 用户已登录系统
|
||||
|
||||
@AGENT-SCROLL-001 @P0 @journey
|
||||
Scenario: 开启流式自动滚动后,视口在流式输出结束时贴近底部
|
||||
Given 用户在设置中开启 "AI 回复时自动滚动"
|
||||
And 用户进入 Lobe AI 对话页面
|
||||
When 用户发送长文消息并等待回复完成
|
||||
Then 视口应贴近聊天列表底部
|
||||
|
||||
@AGENT-SCROLL-002 @P0 @journey
|
||||
Scenario: 关闭流式自动滚动后,用户消息固定在顶部且视口不跟随
|
||||
Given 用户在设置中关闭 "AI 回复时自动滚动"
|
||||
And 用户进入 Lobe AI 对话页面
|
||||
When 用户发送长文消息并等待回复完成
|
||||
Then 用户消息应固定在聊天列表顶部
|
||||
And 视口不应贴近聊天列表底部
|
||||
|
||||
# Mid-stream scroll-up cancellation is covered at the unit level in
|
||||
# `useConversationScroll.test.ts`. An end-to-end version is pending until
|
||||
# the LLM mock can emit a truly chunked SSE response (the current mock
|
||||
# fulfils the whole body at once, which collapses the mid-stream window
|
||||
# to a few hundred milliseconds and makes the interaction race-prone).
|
||||
@AGENT-SCROLL-003 @P1 @journey @skip
|
||||
Scenario: 流式输出过程中手动向上滚动后,消息 pin 被取消
|
||||
Given 用户在设置中开启 "AI 回复时自动滚动"
|
||||
And 流式响应被放慢以模拟长文输出
|
||||
And 用户进入 Lobe AI 对话页面
|
||||
When 用户发送一条触发长文输出的消息
|
||||
And 用户在流式响应进行中向上滚动 200 像素
|
||||
Then 用户消息不应固定在聊天列表顶部
|
||||
|
||||
@AGENT-SCROLL-004 @P0 @journey
|
||||
Scenario: 发送消息后,滚动条自动把用户发送的消息顶到列表顶部
|
||||
Given 流式响应被放慢以模拟长文输出
|
||||
And 用户进入 Lobe AI 对话页面
|
||||
When 用户发送一条触发长文输出的消息
|
||||
Then 用户消息应固定在聊天列表顶部
|
||||
|
||||
# Regression guard for the memo-staleness issue where the message
|
||||
# ResizeObserver could skip rebinding to the new turn's user/assistant DOM
|
||||
# nodes, making spacer height drift off the second send.
|
||||
@AGENT-SCROLL-005 @P0 @journey
|
||||
Scenario: 连续发送两轮消息后,第二轮用户消息仍固定在顶部
|
||||
Given 流式响应被放慢以模拟长文输出
|
||||
And 用户进入 Lobe AI 对话页面
|
||||
When 用户发送一条触发长文输出的消息
|
||||
And 等待流式响应结束
|
||||
And 用户发送一条触发长文输出的消息
|
||||
Then 用户消息应固定在聊天列表顶部
|
||||
@@ -111,6 +111,22 @@ export class LLMMockManager {
|
||||
this.customResponses.set(userMessage.toLowerCase().trim(), response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge partial config overrides. Used by tests that need a slower or faster
|
||||
* stream than the defaults (e.g. to simulate mid-stream user interactions).
|
||||
*/
|
||||
setConfig(partial: Partial<LLMMockConfig>): void {
|
||||
this.config = { ...this.config, ...partial };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset config to factory defaults. Call from `After` hooks so a test's
|
||||
* timing overrides do not bleed into the next scenario.
|
||||
*/
|
||||
resetConfig(): void {
|
||||
this.config = { ...defaultConfig };
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all custom responses
|
||||
*/
|
||||
@@ -232,6 +248,18 @@ export const presetResponses = {
|
||||
error: 'I apologize, but I encountered an error processing your request.',
|
||||
greeting: 'Hello! I am Lobe AI, your AI assistant. How can I help you today?',
|
||||
|
||||
// Much longer response so the chat surely exceeds the viewport and scroll
|
||||
// behavior is observable (used by @AGENT-SCROLL-* scenarios).
|
||||
longScrollArticle: Array.from({ length: 30 }, (_, i) => `这是第 ${i + 1} 段内容。`)
|
||||
.concat(
|
||||
Array.from(
|
||||
{ length: 30 },
|
||||
(_, i) =>
|
||||
`段落 ${i + 1}:人工智能是计算机科学的一个分支,它企图了解智能的实质,并生产出一种新的能以人类智能相似的方式做出反应的智能机器。研究领域包括机器人、语言识别、图像识别、自然语言处理和专家系统等。`,
|
||||
),
|
||||
)
|
||||
.join('\n\n'),
|
||||
|
||||
// Long response for stop generation test
|
||||
longArticle:
|
||||
'这是一篇很长的文章。第一段:人工智能是计算机科学的一个分支,它企图了解智能的实质,并生产出一种新的能以人类智能相似的方式做出反应的智能机器。第二段:人工智能研究的主要目标包括推理、知识、规划、学习、自然语言处理、感知和移动与操控物体的能力。第三段:目前,人工智能已经在许多领域取得了重大突破,包括图像识别、语音识别、自然语言处理等。',
|
||||
|
||||
@@ -312,21 +312,21 @@ When('用户输入新的对话名称 {string}', async function (this: CustomWorl
|
||||
// Wait a short moment for the popover to render
|
||||
await this.page.waitForTimeout(300);
|
||||
|
||||
// Try to find the popover input using various selectors
|
||||
// @lobehub/ui Popover uses antd's Popover internally
|
||||
const popoverInputSelectors = [
|
||||
// antd popover structure
|
||||
// The rename UI can render as a dialog/modal in CI, not only as a popover.
|
||||
const renameInputSelectors = [
|
||||
'[role="dialog"] input[type="text"]',
|
||||
'.ant-modal input[type="text"]',
|
||||
'[data-testid="editing-popover"] input',
|
||||
'.ant-popover-inner input',
|
||||
'.ant-popover-content input',
|
||||
'.ant-popover input',
|
||||
// Generic input that's visible and not the chat input
|
||||
'input:not([data-testid="chat-input"] input)',
|
||||
'input[type="text"]:visible',
|
||||
];
|
||||
|
||||
let renameInput = null;
|
||||
|
||||
// Wait for any popover input to appear
|
||||
for (const selector of popoverInputSelectors) {
|
||||
// Wait for any rename input to appear
|
||||
for (const selector of renameInputSelectors) {
|
||||
try {
|
||||
const locator = this.page.locator(selector).first();
|
||||
await locator.waitFor({ state: 'visible', timeout: 2000 });
|
||||
@@ -348,18 +348,23 @@ When('用户输入新的对话名称 {string}', async function (this: CustomWorl
|
||||
for (let i = 0; i < count; i++) {
|
||||
const input = allInputs.nth(i);
|
||||
const placeholder = await input.getAttribute('placeholder').catch(() => '');
|
||||
const testId = await input.dataset.testid.catch(() => '');
|
||||
const testId = await input.getAttribute('data-testid').catch(() => '');
|
||||
|
||||
// Skip search inputs and chat inputs
|
||||
if (placeholder?.includes('Search') || placeholder?.includes('搜索')) continue;
|
||||
if (testId === 'chat-input') continue;
|
||||
|
||||
// Check if it's inside a popover-like container
|
||||
const isInPopover = await input.evaluate((el) => {
|
||||
return el.closest('.ant-popover') !== null || el.closest('[class*="popover"]') !== null;
|
||||
// Prefer inputs rendered inside rename containers.
|
||||
const isInRenameContainer = await input.evaluate((el) => {
|
||||
return (
|
||||
el.closest('[role="dialog"]') !== null ||
|
||||
el.closest('.ant-modal') !== null ||
|
||||
el.closest('.ant-popover') !== null ||
|
||||
el.closest('[class*="popover"]') !== null
|
||||
);
|
||||
});
|
||||
|
||||
if (isInPopover || count === 1) {
|
||||
if (isInRenameContainer || count === 1) {
|
||||
renameInput = input;
|
||||
console.log(` 📍 Found candidate input at index ${i}`);
|
||||
break;
|
||||
@@ -374,8 +379,21 @@ When('用户输入新的对话名称 {string}', async function (this: CustomWorl
|
||||
await renameInput.fill(newName);
|
||||
console.log(` 📍 Filled input with "${newName}"`);
|
||||
|
||||
// Press Enter to confirm
|
||||
await renameInput.press('Enter');
|
||||
const saveButton = this.page
|
||||
.locator('[role="dialog"]')
|
||||
.getByRole('button', { exact: true, name: /^(Save|保存)$/ })
|
||||
.first();
|
||||
|
||||
try {
|
||||
await saveButton.waitFor({ state: 'visible', timeout: 1000 });
|
||||
await saveButton.click();
|
||||
console.log(' 📍 Clicked save button');
|
||||
} catch {
|
||||
// Popover-based rename UIs still confirm with Enter.
|
||||
await renameInput.press('Enter');
|
||||
console.log(' 📍 Confirmed rename with Enter');
|
||||
}
|
||||
|
||||
console.log(` ✅ 已输入新名称 "${newName}"`);
|
||||
} else {
|
||||
// Last resort: the input should have autoFocus, so keyboard should work
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* Agent Scroll Steps
|
||||
*
|
||||
* Step definitions for @AGENT-SCROLL-* scenarios. These verify that the
|
||||
* `useConversationScroll` hook + `<AutoScroll />` component cooperate
|
||||
* correctly under the three real-world cases:
|
||||
*
|
||||
* 1. `enableAutoScrollOnStreaming = true` → viewport stays near bottom
|
||||
* 2. `enableAutoScrollOnStreaming = false` → user message pinned to top
|
||||
* 3. User scrolls up mid-stream → viewport stays put
|
||||
*/
|
||||
import { After, Given, Then, When } from '@cucumber/cucumber';
|
||||
import { expect } from '@playwright/test';
|
||||
|
||||
import { llmMockManager, presetResponses } from '../../mocks/llm';
|
||||
import type { CustomWorld } from '../../support/world';
|
||||
|
||||
// How close to the scroll container's bottom is considered "at bottom".
|
||||
// Matches (with slack) the product's own AT_BOTTOM_THRESHOLD (300 px).
|
||||
const AT_BOTTOM_EPSILON = 320;
|
||||
// Distance the user manually scrolls up for scenario 3.
|
||||
const MANUAL_SCROLL_UP_DELTA = 200;
|
||||
|
||||
interface ScrollSnapshot {
|
||||
clientHeight: number;
|
||||
distanceToBottom: number;
|
||||
scrollHeight: number;
|
||||
scrollTop: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DOM helpers (executed inside the page)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Find the scrollable ancestor of the first `.message-wrapper`. This is the
|
||||
// virtua VList's inner scroll container — product code doesn't add a stable
|
||||
// data-testid to it, but the structure is reliable enough for an e2e test.
|
||||
async function getScrollSnapshot(world: CustomWorld): Promise<ScrollSnapshot | null> {
|
||||
return world.page.evaluate(() => {
|
||||
const msg = document.querySelector('.message-wrapper');
|
||||
let el: HTMLElement | null = (msg?.parentElement as HTMLElement) || null;
|
||||
while (el) {
|
||||
const style = window.getComputedStyle(el);
|
||||
if (style.overflowY === 'auto' || style.overflowY === 'scroll') {
|
||||
return {
|
||||
clientHeight: el.clientHeight,
|
||||
distanceToBottom: el.scrollHeight - el.scrollTop - el.clientHeight,
|
||||
scrollHeight: el.scrollHeight,
|
||||
scrollTop: el.scrollTop,
|
||||
};
|
||||
}
|
||||
el = el.parentElement;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
async function scrollBy(world: CustomWorld, deltaY: number): Promise<void> {
|
||||
await world.page.evaluate((dy) => {
|
||||
const msg = document.querySelector('.message-wrapper');
|
||||
let el: HTMLElement | null = (msg?.parentElement as HTMLElement) || null;
|
||||
while (el) {
|
||||
const style = window.getComputedStyle(el);
|
||||
if (style.overflowY === 'auto' || style.overflowY === 'scroll') {
|
||||
el.scrollTop = Math.max(0, el.scrollTop + dy);
|
||||
el.dispatchEvent(new Event('scroll', { bubbles: true }));
|
||||
return;
|
||||
}
|
||||
el = el.parentElement;
|
||||
}
|
||||
}, deltaY);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Setting toggle via the chat-appearance settings page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function setAutoScrollEnabled(world: CustomWorld, desired: boolean): Promise<void> {
|
||||
await world.page.goto('/settings/chat-appearance');
|
||||
// `networkidle` can take a while on the very first compile in dev mode
|
||||
// (Next.js builds the settings route on demand); a generous timeout avoids
|
||||
// flakes when the test suite warms up a cold server.
|
||||
await world.page.waitForLoadState('networkidle', { timeout: 60_000 });
|
||||
|
||||
// Match both EN ("Auto-scroll During AI Response") and zh-CN ("AI 回复时自动滚动").
|
||||
const title = world.page.getByText(/Auto-scroll During AI Response|AI 回复时自动滚动/);
|
||||
await expect(title).toBeVisible({ timeout: 45_000 });
|
||||
|
||||
// The switch lives inside the same FormGroup as the title.
|
||||
const switcher = world.page
|
||||
.locator('[role="switch"], button.ant-switch')
|
||||
.filter({
|
||||
has: title,
|
||||
})
|
||||
.or(
|
||||
world.page
|
||||
.locator('div')
|
||||
.filter({ has: title })
|
||||
.last()
|
||||
.locator('[role="switch"], button.ant-switch')
|
||||
.first(),
|
||||
);
|
||||
|
||||
// Fall back: the switch is the nearest role=switch sibling of the title node.
|
||||
const nearestSwitch = world.page.locator('[role="switch"]').first();
|
||||
const target = (await switcher.count()) > 0 ? switcher.first() : nearestSwitch;
|
||||
|
||||
const currentChecked = (await target.getAttribute('aria-checked')) === 'true';
|
||||
if (currentChecked !== desired) {
|
||||
await target.click();
|
||||
// Give the optimistic update + debounced server call a moment to settle.
|
||||
await world.page.waitForTimeout(400);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Given steps
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Given(
|
||||
'用户在设置中开启 {string}',
|
||||
{ timeout: 90_000 },
|
||||
async function (this: CustomWorld, _label: string) {
|
||||
await setAutoScrollEnabled(this, true);
|
||||
},
|
||||
);
|
||||
|
||||
Given(
|
||||
'用户在设置中关闭 {string}',
|
||||
{ timeout: 90_000 },
|
||||
async function (this: CustomWorld, _label: string) {
|
||||
await setAutoScrollEnabled(this, false);
|
||||
},
|
||||
);
|
||||
|
||||
Given('流式响应被放慢以模拟长文输出', async function (this: CustomWorld) {
|
||||
// A ~1.5s head-delay gives the test room to interact (manual scroll) while
|
||||
// the assistant placeholder is mounted but no tokens have streamed yet.
|
||||
llmMockManager.setConfig({ responseDelay: 1500, streamChunkSize: 8, streamDelay: 60 });
|
||||
this.testContext.scrollMockAdjusted = true;
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// When steps
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
When('用户发送长文消息并等待回复完成', { timeout: 45_000 }, async function (this: CustomWorld) {
|
||||
const prompt = '请输出一篇很长的文章';
|
||||
llmMockManager.setResponse(prompt, presetResponses.longScrollArticle);
|
||||
|
||||
await this.page.keyboard.type(prompt, { delay: 20 });
|
||||
await this.page.waitForTimeout(200);
|
||||
await this.page.keyboard.press('Enter');
|
||||
|
||||
// Wait for assistant message to appear and its content to stabilize.
|
||||
const messageWrappers = this.page.locator('.message-wrapper');
|
||||
await expect(messageWrappers)
|
||||
.toHaveCount(2, { timeout: 15_000 })
|
||||
.catch(() => {});
|
||||
|
||||
const assistantMessage = this.page
|
||||
.locator('.message-wrapper')
|
||||
.filter({ has: this.page.locator('text=Lobe AI') })
|
||||
.last();
|
||||
await expect(assistantMessage).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Poll until text has grown past an obvious threshold, then plateaus.
|
||||
let prevLen = 0;
|
||||
let stableTicks = 0;
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const len =
|
||||
(await assistantMessage
|
||||
.innerText()
|
||||
.then((t) => t.length)
|
||||
.catch(() => 0)) || 0;
|
||||
if (len > 200 && len === prevLen) stableTicks += 1;
|
||||
else stableTicks = 0;
|
||||
prevLen = len;
|
||||
if (stableTicks >= 3) break;
|
||||
await this.page.waitForTimeout(250);
|
||||
}
|
||||
});
|
||||
|
||||
When('用户发送一条触发长文输出的消息', async function (this: CustomWorld) {
|
||||
const prompt = '请输出一篇很长的文章';
|
||||
llmMockManager.setResponse(prompt, presetResponses.longScrollArticle);
|
||||
|
||||
await this.page.keyboard.type(prompt, { delay: 20 });
|
||||
await this.page.waitForTimeout(200);
|
||||
await this.page.keyboard.press('Enter');
|
||||
|
||||
// Wait long enough for pin's smooth scrollToIndex to finish. Virtua drives
|
||||
// the smooth animation via rAF and would otherwise overwrite a manual
|
||||
// scroll while the animation is still in flight.
|
||||
await this.page.waitForTimeout(1200);
|
||||
});
|
||||
|
||||
When('用户在流式响应进行中向上滚动 {int} 像素', async function (this: CustomWorld, px: number) {
|
||||
const delta = Math.abs(px) || MANUAL_SCROLL_UP_DELTA;
|
||||
// Mouse wheel over the list, more faithful to real-user interaction than
|
||||
// setting `scrollTop` directly. Move the cursor into the list viewport
|
||||
// first — wheel events fire against whatever element is under the cursor.
|
||||
await this.page.mouse.move(640, 400);
|
||||
await this.page.mouse.wheel(0, -delta);
|
||||
await scrollBy(this, -delta);
|
||||
// Let the onScroll handler run (pin cancel + spacer shrink).
|
||||
await this.page.waitForTimeout(400);
|
||||
});
|
||||
|
||||
When('等待流式响应结束', { timeout: 30_000 }, async function (this: CustomWorld) {
|
||||
const assistantMessage = this.page
|
||||
.locator('.message-wrapper')
|
||||
.filter({ has: this.page.locator('text=Lobe AI') })
|
||||
.last();
|
||||
|
||||
let prevLen = 0;
|
||||
let stableTicks = 0;
|
||||
for (let i = 0; i < 60; i++) {
|
||||
const len =
|
||||
(await assistantMessage
|
||||
.innerText()
|
||||
.then((t) => t.length)
|
||||
.catch(() => 0)) || 0;
|
||||
if (len > 200 && len === prevLen) stableTicks += 1;
|
||||
else stableTicks = 0;
|
||||
prevLen = len;
|
||||
if (stableTicks >= 3) break;
|
||||
await this.page.waitForTimeout(250);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Then steps
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Then('视口应贴近聊天列表底部', async function (this: CustomWorld) {
|
||||
const snap = await getScrollSnapshot(this);
|
||||
expect(snap, 'failed to locate scroll container').not.toBeNull();
|
||||
expect(snap!.distanceToBottom).toBeLessThanOrEqual(AT_BOTTOM_EPSILON);
|
||||
});
|
||||
|
||||
Then('视口不应贴近聊天列表底部', async function (this: CustomWorld) {
|
||||
const snap = await getScrollSnapshot(this);
|
||||
expect(snap, 'failed to locate scroll container').not.toBeNull();
|
||||
expect(snap!.distanceToBottom).toBeGreaterThan(AT_BOTTOM_EPSILON);
|
||||
});
|
||||
|
||||
// Reset LLM mock timing overrides so the slowdown from scenario 3 does not
|
||||
// leak into later unrelated scenarios.
|
||||
After({ tags: '@scroll' }, async function (this: CustomWorld) {
|
||||
if (this.testContext.scrollMockAdjusted) {
|
||||
llmMockManager.resetConfig();
|
||||
this.testContext.scrollMockAdjusted = false;
|
||||
}
|
||||
});
|
||||
|
||||
Then('用户消息不应固定在聊天列表顶部', async function (this: CustomWorld) {
|
||||
const rect = await this.page.evaluate(() => {
|
||||
const wrappers = Array.from(document.querySelectorAll('.message-wrapper'));
|
||||
if (wrappers.length < 2) return null;
|
||||
const userWrapper = wrappers.at(-2) as HTMLElement;
|
||||
let scrollParent: HTMLElement | null = userWrapper.parentElement;
|
||||
while (scrollParent) {
|
||||
const style = window.getComputedStyle(scrollParent);
|
||||
if (style.overflowY === 'auto' || style.overflowY === 'scroll') break;
|
||||
scrollParent = scrollParent.parentElement;
|
||||
}
|
||||
if (!scrollParent) return null;
|
||||
const wrapperRect = userWrapper.getBoundingClientRect();
|
||||
const parentRect = scrollParent.getBoundingClientRect();
|
||||
return { delta: wrapperRect.top - parentRect.top };
|
||||
});
|
||||
|
||||
expect(rect).not.toBeNull();
|
||||
// Pin is cancelled: the user message should have been pushed down by the
|
||||
// manual scroll. Anything beyond the "pinned" slack (150 px) means the
|
||||
// anchor was released.
|
||||
expect(Math.abs(rect!.delta)).toBeGreaterThan(150);
|
||||
});
|
||||
|
||||
Then('用户消息应固定在聊天列表顶部', async function (this: CustomWorld) {
|
||||
// Find the user message (the penultimate `.message-wrapper`) and check its
|
||||
// top is close to the scroll container top. We allow some slack because
|
||||
// virtua lays out with some padding and the header can stick.
|
||||
const rect = await this.page.evaluate(() => {
|
||||
const wrappers = Array.from(document.querySelectorAll('.message-wrapper'));
|
||||
if (wrappers.length < 2) return null;
|
||||
const userWrapper = wrappers.at(-2) as HTMLElement;
|
||||
let scrollParent: HTMLElement | null = userWrapper.parentElement;
|
||||
while (scrollParent) {
|
||||
const style = window.getComputedStyle(scrollParent);
|
||||
if (style.overflowY === 'auto' || style.overflowY === 'scroll') break;
|
||||
scrollParent = scrollParent.parentElement;
|
||||
}
|
||||
if (!scrollParent) return null;
|
||||
const wrapperRect = userWrapper.getBoundingClientRect();
|
||||
const parentRect = scrollParent.getBoundingClientRect();
|
||||
return {
|
||||
parentTop: parentRect.top,
|
||||
userTop: wrapperRect.top,
|
||||
delta: wrapperRect.top - parentRect.top,
|
||||
};
|
||||
});
|
||||
|
||||
expect(rect, 'failed to resolve user message + scroll parent').not.toBeNull();
|
||||
// Pin anchors with `align: 'start'` — tolerate ~150 px of slack for headers.
|
||||
expect(Math.abs(rect!.delta)).toBeLessThanOrEqual(150);
|
||||
});
|
||||
@@ -798,9 +798,6 @@
|
||||
"systemAgent.promptRewrite.label": "النموذج",
|
||||
"systemAgent.promptRewrite.modelDesc": "حدد النموذج المستخدم لإعادة صياغة المطالبات",
|
||||
"systemAgent.promptRewrite.title": "وكيل إعادة صياغة المطالبات",
|
||||
"systemAgent.queryRewrite.label": "النموذج",
|
||||
"systemAgent.queryRewrite.modelDesc": "تحديد النموذج المستخدم لتحسين استفسارات المستخدم",
|
||||
"systemAgent.queryRewrite.title": "وكيل إعادة صياغة استعلام المكتبة",
|
||||
"systemAgent.thread.label": "النموذج",
|
||||
"systemAgent.thread.modelDesc": "النموذج المخصص لإعادة تسمية المواضيع الفرعية تلقائيًا",
|
||||
"systemAgent.thread.title": "وكيل التسمية التلقائية للمواضيع الفرعية",
|
||||
|
||||
@@ -798,9 +798,6 @@
|
||||
"systemAgent.promptRewrite.label": "Модел",
|
||||
"systemAgent.promptRewrite.modelDesc": "Определете модела, използван за пренаписване на подсказки",
|
||||
"systemAgent.promptRewrite.title": "Агент за пренаписване на подсказки",
|
||||
"systemAgent.queryRewrite.label": "Модел",
|
||||
"systemAgent.queryRewrite.modelDesc": "Посочете модел за оптимизиране на потребителски заявки",
|
||||
"systemAgent.queryRewrite.title": "Агент за пренаписване на заявки в библиотеката",
|
||||
"systemAgent.thread.label": "Модел",
|
||||
"systemAgent.thread.modelDesc": "Модел, използван за автоматично преименуване на подтеми",
|
||||
"systemAgent.thread.title": "Агент за автоматично именуване на подтеми",
|
||||
|
||||
@@ -798,9 +798,6 @@
|
||||
"systemAgent.promptRewrite.label": "Modell",
|
||||
"systemAgent.promptRewrite.modelDesc": "Geben Sie das Modell an, das zum Umschreiben von Prompts verwendet wird",
|
||||
"systemAgent.promptRewrite.title": "Prompt-Umschreibagent",
|
||||
"systemAgent.queryRewrite.label": "Modell",
|
||||
"systemAgent.queryRewrite.modelDesc": "Modell zur Optimierung von Benutzeranfragen",
|
||||
"systemAgent.queryRewrite.title": "Agent zur Umschreibung von Bibliotheksanfragen",
|
||||
"systemAgent.thread.label": "Modell",
|
||||
"systemAgent.thread.modelDesc": "Modell zur automatischen Umbenennung von Unterthemen",
|
||||
"systemAgent.thread.title": "Agent zur automatischen Unterthemenbenennung",
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
{
|
||||
"channel.allowFrom": "Allowed Users",
|
||||
"channel.allowFromAdd": "Add user",
|
||||
"channel.allowFromEmpty": "No users added yet — anyone can interact with the bot.",
|
||||
"channel.allowFromHint": "Only listed users can interact with the bot; your 'Platform User ID' is auto-included.",
|
||||
"channel.allowFromIdLabel": "User ID",
|
||||
"channel.allowFromIdPlaceholder": "Platform user ID",
|
||||
"channel.allowFromNameLabel": "Note",
|
||||
"channel.allowFromNamePlaceholder": "e.g. Alice (your reminder)",
|
||||
"channel.allowListRemove": "Remove",
|
||||
"channel.appSecret": "App Secret",
|
||||
"channel.appSecretHint": "The App Secret of your bot application. It will be encrypted and stored securely.",
|
||||
"channel.appSecretPlaceholder": "Paste your app secret here",
|
||||
@@ -45,8 +54,6 @@
|
||||
"channel.displayToolCalls": "Display Tool Calls",
|
||||
"channel.displayToolCallsHint": "Show tool call details during AI responses. When disabled, only the final response is displayed for a cleaner experience.",
|
||||
"channel.dm": "Direct Messages",
|
||||
"channel.dmEnabled": "Enable DMs",
|
||||
"channel.dmEnabledHint": "Allow the bot to receive and respond to direct messages",
|
||||
"channel.dmPolicy": "DM Policy",
|
||||
"channel.dmPolicyAllowlist": "Allowlist",
|
||||
"channel.dmPolicyDisabled": "Disabled",
|
||||
@@ -63,6 +70,19 @@
|
||||
"channel.feishu.description": "Connect this assistant to Feishu for private and group chats.",
|
||||
"channel.feishu.webhookMigrationDesc": "WebSocket mode provides real-time event delivery without needing a public callback URL. To migrate, switch the Connection Mode to WebSocket in Advanced Settings. No additional configuration is needed on the Feishu/Lark Open Platform.",
|
||||
"channel.feishu.webhookMigrationTitle": "Consider migrating to WebSocket mode",
|
||||
"channel.groupAllowFrom": "Allowed Channels",
|
||||
"channel.groupAllowFromAdd": "Add channel",
|
||||
"channel.groupAllowFromEmpty": "No channels added yet — the bot will not respond anywhere.",
|
||||
"channel.groupAllowFromHint": "Channel / group / chat IDs the bot may respond in.",
|
||||
"channel.groupAllowFromIdLabel": "Channel ID",
|
||||
"channel.groupAllowFromIdPlaceholder": "Channel / group / chat ID",
|
||||
"channel.groupAllowFromNameLabel": "Note",
|
||||
"channel.groupAllowFromNamePlaceholder": "e.g. #general (your reminder)",
|
||||
"channel.groupPolicy": "Group Policy",
|
||||
"channel.groupPolicyAllowlist": "Allowlist",
|
||||
"channel.groupPolicyDisabled": "Disabled",
|
||||
"channel.groupPolicyHint": "Control where the bot responds in groups, channels, and threads",
|
||||
"channel.groupPolicyOpen": "Open",
|
||||
"channel.historyLimit": "History Message Limit",
|
||||
"channel.historyLimitHint": "Default number of messages to fetch when reading channel history",
|
||||
"channel.importConfig": "Import Configuration",
|
||||
@@ -91,8 +111,8 @@
|
||||
"channel.secretToken": "Webhook Secret Token",
|
||||
"channel.secretTokenHint": "Optional. Used to verify webhook requests from Telegram.",
|
||||
"channel.secretTokenPlaceholder": "Optional secret for webhook verification",
|
||||
"channel.serverId": "Default Server / Guild ID",
|
||||
"channel.serverIdHint": "Your default server or guild ID on this platform. The AI uses it to list channels without asking.",
|
||||
"channel.serverId": "Default Server (for AI tools)",
|
||||
"channel.serverIdHint": "The server / guild ID AI tools should default to when you ask the bot to act on a server (e.g. 'list channels', 'send to #announcements'). Independent of access control — see Group Policy for that.",
|
||||
"channel.settings": "Advanced Settings",
|
||||
"channel.settingsResetConfirm": "Are you sure you want to reset advanced settings to default?",
|
||||
"channel.settingsResetDefault": "Reset to Default",
|
||||
@@ -118,8 +138,8 @@
|
||||
"channel.testFailed": "Connection test failed",
|
||||
"channel.testSuccess": "Connection test passed",
|
||||
"channel.updateFailed": "Failed to update status",
|
||||
"channel.userId": "Your Platform User ID",
|
||||
"channel.userIdHint": "Your user ID on this platform. The AI can use it to send you direct messages.",
|
||||
"channel.userId": "Your Platform User ID (for AI tools)",
|
||||
"channel.userIdHint": "AI tools use this to reach you proactively (e.g. reminders, notifications); also auto-trusted by the global allowlist.",
|
||||
"channel.validationError": "Please fill in Application ID and Token",
|
||||
"channel.verificationToken": "Verification Token",
|
||||
"channel.verificationTokenHint": "Optional. Used to verify webhook event source.",
|
||||
|
||||
+29
-3
@@ -137,6 +137,7 @@
|
||||
"extendParams.urlContext.desc": "When enabled, web links will be automatically parsed to retrieve the actual webpage context content",
|
||||
"extendParams.urlContext.title": "Extract Webpage Link Content",
|
||||
"followUpPlaceholder": "Follow up. @ to assign tasks to other agents.",
|
||||
"followUpPlaceholderHeterogeneous": "Follow up.",
|
||||
"group.desc": "Move a task forward with multiple Agents in one shared space.",
|
||||
"group.memberTooltip": "There are {{count}} members in the group",
|
||||
"group.orchestratorThinking": "Orchestrator is thinking...",
|
||||
@@ -204,6 +205,9 @@
|
||||
"input.stop": "Stop",
|
||||
"input.warp": "New Line",
|
||||
"input.warpWithKey": "Press <key/> to insert a line break",
|
||||
"inputQueue.delete": "Delete",
|
||||
"inputQueue.edit": "Edit",
|
||||
"inputQueue.sendNow": "Send now (interrupts current run)",
|
||||
"intentUnderstanding.title": "Understanding your intent...",
|
||||
"inviteMembers": "Invite members",
|
||||
"knowledgeBase.all": "All Content",
|
||||
@@ -478,7 +482,7 @@
|
||||
"taskDetail.comment.deleteConfirm.title": "Delete this comment?",
|
||||
"taskDetail.comment.edit": "Edit",
|
||||
"taskDetail.comment.save": "Save",
|
||||
"taskDetail.commentPlaceholder": "Leave a comment...",
|
||||
"taskDetail.commentPlaceholder": "Leave feedback to guide the agent — your comments shape the next run...",
|
||||
"taskDetail.deleteConfirm.content": "This action cannot be undone.",
|
||||
"taskDetail.deleteConfirm.ok": "Delete",
|
||||
"taskDetail.deleteConfirm.title": "Delete this task?",
|
||||
@@ -509,7 +513,7 @@
|
||||
"taskDetail.status.canceled": "Canceled",
|
||||
"taskDetail.status.completed": "Completed",
|
||||
"taskDetail.status.failed": "Failed",
|
||||
"taskDetail.status.paused": "Paused",
|
||||
"taskDetail.status.paused": "Pending review",
|
||||
"taskDetail.status.running": "In progress",
|
||||
"taskDetail.stopTask": "Stop task",
|
||||
"taskDetail.subIssueOf": "Sub-issue of",
|
||||
@@ -520,21 +524,39 @@
|
||||
"taskDetail.updateFailed": "Failed to update task",
|
||||
"taskList.activeTasks": "Active Tasks",
|
||||
"taskList.all": "All tasks",
|
||||
"taskList.assigneeSearch.empty": "No matching agent",
|
||||
"taskList.assigneeSearch.placeholder": "Search agent...",
|
||||
"taskList.breadcrumb.task": "Task",
|
||||
"taskList.contextMenu.copyId": "Copy ID",
|
||||
"taskList.contextMenu.copyIdSuccess": "ID copied",
|
||||
"taskList.contextMenu.copyLink": "Copy link",
|
||||
"taskList.contextMenu.copyLinkSuccess": "Link copied",
|
||||
"taskList.contextMenu.priority": "Priority",
|
||||
"taskList.contextMenu.status": "Status",
|
||||
"taskList.empty": "No tasks yet",
|
||||
"taskList.form.grouping": "Grouping",
|
||||
"taskList.form.orderCompletedByRecency": "Sort completed tasks by recency",
|
||||
"taskList.form.ordering": "Ordering",
|
||||
"taskList.form.showCompleted": "Show completed & canceled",
|
||||
"taskList.form.subGrouping": "Sub-grouping",
|
||||
"taskList.groupBy.assignee": "Assignee",
|
||||
"taskList.groupBy.none": "No grouping",
|
||||
"taskList.groupBy.priority": "Priority",
|
||||
"taskList.groupBy.status": "Status",
|
||||
"taskList.hiddenCompleted.count_one": "{{count}} task",
|
||||
"taskList.hiddenCompleted.count_other": "{{count}} tasks",
|
||||
"taskList.hiddenCompleted.show": "Show",
|
||||
"taskList.hiddenCompleted.suffix": "hidden by display options",
|
||||
"taskList.kanban.addTask": "Create task",
|
||||
"taskList.kanban.backlog": "Backlog",
|
||||
"taskList.kanban.canceled": "Canceled",
|
||||
"taskList.kanban.done": "Done",
|
||||
"taskList.kanban.emptyColumn": "No tasks",
|
||||
"taskList.kanban.needsInput": "Needs input",
|
||||
"taskList.kanban.hiddenColumns": "Hidden columns",
|
||||
"taskList.kanban.hideColumn": "Hide column",
|
||||
"taskList.kanban.needsInput": "Pending review",
|
||||
"taskList.kanban.running": "In progress",
|
||||
"taskList.kanban.showColumn": "Show column",
|
||||
"taskList.orderBy.assignee": "Assignee",
|
||||
"taskList.orderBy.createdAt": "Created at",
|
||||
"taskList.orderBy.priority": "Priority",
|
||||
@@ -543,6 +565,7 @@
|
||||
"taskList.orderBy.updatedAt": "Updated at",
|
||||
"taskList.title": "Tasks",
|
||||
"taskList.unassigned": "Unassigned",
|
||||
"taskList.unassignedHint": "Lobe AI will run this task when no assignee is set",
|
||||
"taskList.view.board": "Board",
|
||||
"taskList.view.list": "List",
|
||||
"taskList.viewAll": "View all",
|
||||
@@ -656,6 +679,9 @@
|
||||
"viewMode.fullWidth": "Full Width",
|
||||
"viewMode.normal": "Standard",
|
||||
"viewMode.wideScreen": "Widescreen",
|
||||
"viewSwitcher.chat": "Chat",
|
||||
"viewSwitcher.page": "Page",
|
||||
"viewSwitcher.task": "Task",
|
||||
"workflow.awaitingConfirmation": "Awaiting your confirmation",
|
||||
"workflow.collapse": "Collapse",
|
||||
"workflow.expandFull": "Expand fully",
|
||||
|
||||
@@ -146,6 +146,7 @@
|
||||
"cmdk.keywords.starGitHub": "github star favorite like",
|
||||
"cmdk.keywords.stats": "stats statistics analytics",
|
||||
"cmdk.keywords.submitIssue": "issue bug problem feedback",
|
||||
"cmdk.keywords.tasks": "tasks todo agent kanban",
|
||||
"cmdk.keywords.usage": "usage statistics consumption quota",
|
||||
"cmdk.keywords.video": "video,generate,seedance,kling",
|
||||
"cmdk.memory": "Memory",
|
||||
@@ -195,6 +196,7 @@
|
||||
"cmdk.settings": "Settings",
|
||||
"cmdk.starOnGitHub": "Star us on GitHub",
|
||||
"cmdk.submitIssue": "Submit Issue",
|
||||
"cmdk.tasks": "Tasks",
|
||||
"cmdk.theme": "Theme",
|
||||
"cmdk.themeAuto": "Auto",
|
||||
"cmdk.themeCurrent": "Current",
|
||||
@@ -433,6 +435,7 @@
|
||||
"tab.resource": "Resources",
|
||||
"tab.search": "Search",
|
||||
"tab.setting": "Settings",
|
||||
"tab.tasks": "Tasks",
|
||||
"tab.video": "Video",
|
||||
"telemetry.allow": "Allow",
|
||||
"telemetry.deny": "Deny",
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"navigation.recentView": "Recent pages",
|
||||
"navigation.resources": "Resources",
|
||||
"navigation.settings": "Settings",
|
||||
"navigation.tasks": "Tasks",
|
||||
"navigation.unpin": "Unpin",
|
||||
"notification.finishChatGeneration": "AI message generation completed",
|
||||
"proxy.auth": "Authentication Required",
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"features.agentSelfIteration.desc": "Allow the assistant to reflect, build self-awareness, and continuously iterate through ongoing attempts and interactions.",
|
||||
"features.agentSelfIteration.title": "Agent Self-iteration",
|
||||
"features.assistantMessageGroup.desc": "Group agent messages and their tool call results together for display",
|
||||
"features.assistantMessageGroup.title": "Agent Message Grouping",
|
||||
"features.gatewayMode.desc": "Execute agent tasks on the server via Gateway WebSocket instead of running locally. Enables faster execution and reduces client resource usage.",
|
||||
"features.gatewayMode.title": "Server-Side Agent Execution (Gateway)",
|
||||
"features.groupChat.desc": "Enable multi-agent group chat coordination.",
|
||||
"features.groupChat.title": "Group Chat (Multi-Agent)",
|
||||
"features.heterogeneousAgent.desc": "Enable heterogeneous agent execution with Claude Code, Codex CLI, and other external agent CLIs. Creates a \"Claude Code Agent\" option in the sidebar agent menu.",
|
||||
"features.heterogeneousAgent.title": "Heterogeneous Agent (Claude Code)",
|
||||
"features.inputMarkdown.desc": "Render Markdown in the input area in real time (bold text, code blocks, tables, etc.).",
|
||||
"features.inputMarkdown.title": "Input Markdown Rendering",
|
||||
"title": "Labs"
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
"azure.azureApiVersion.fetch": "Fetch List",
|
||||
"azure.azureApiVersion.title": "Azure API Version",
|
||||
"azure.empty": "Please enter a model ID to add the first model",
|
||||
"azure.endpoint.desc": "When checking resources from the Azure portal, you can find this value in the 'Keys and Endpoints' section",
|
||||
"azure.endpoint.placeholder": "https://docs-test-001.openai.azure.com",
|
||||
"azure.endpoint.desc": "When checking resources from the Azure portal, you can find this value in the 'Keys and Endpoints' section. Azure OpenAI endpoints from the Responses API path are also supported.",
|
||||
"azure.endpoint.placeholder": "https://your-resource.cognitiveservices.azure.com/openai/responses",
|
||||
"azure.endpoint.title": "Azure API Address",
|
||||
"azure.modelListPlaceholder": "Select or add the OpenAI model you deployed",
|
||||
"azure.title": "Azure OpenAI",
|
||||
|
||||
@@ -506,6 +506,8 @@
|
||||
"doubao-seedream-4-0-250828.description": "Seedream 4.0 is an image generation model from ByteDance Seed, supporting text and image inputs with highly controllable, high-quality image generation. It generates images from text prompts.",
|
||||
"doubao-seedream-4-5-251128.description": "Seedream 4.5 is ByteDance’s latest multimodal image model, integrating text-to-image, image-to-image, and batch image generation capabilities, while incorporating commonsense and reasoning abilities. Compared to the previous 4.0 version, it delivers significantly improved generation quality, with better editing consistency and multi-image fusion. It offers more precise control over visual details, producing small text and small faces more naturally, and achieves more harmonious layout and color, enhancing overall aesthetics.",
|
||||
"doubao-seedream-5-0-260128.description": "Doubao-Seedream-5.0-lite is ByteDance’s latest image-generation model. For the first time, it integrates online retrieval capabilities, allowing it to incorporate real-time web information and enhance the timeliness of generated images. The model’s intelligence has also been upgraded, enabling precise interpretation of complex instructions and visual content. Additionally, it offers improved global knowledge coverage, reference consistency, and generation quality in professional scenarios, better meeting enterprise-level visual creation needs.",
|
||||
"dreamina-seedance-2-0-260128.description": "Seedance 2.0 by ByteDance is the most powerful video generation model, supporting multimodal reference video generation, video editing, video extension, text-to-video, and image-to-video with synchronized audio.",
|
||||
"dreamina-seedance-2-0-fast-260128.description": "Seedance 2.0 Fast by ByteDance offers the same capabilities as Seedance 2.0 with faster generation speeds at a more competitive price.",
|
||||
"emohaa.description": "Emohaa is a mental health model with professional counseling abilities to help users understand emotional issues.",
|
||||
"ernie-4.5-0.3b.description": "ERNIE 4.5 0.3B is an open-source lightweight model for local and customized deployment.",
|
||||
"ernie-4.5-21b-a3b-thinking.description": "ERNIE-4.5-21B-A3B-Thinking is a text MoE (Mixture-of-Experts) post-trained model with a total of 21B parameters and 3B active parameters, offering significantly enhanced reasoning quality and depth.",
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
{
|
||||
"agent_cron_job_failed": "Your scheduled task \"{{jobName}}\" failed. Open the task to see the full error.",
|
||||
"agent_cron_job_failed_insufficient_budget": "Your scheduled task \"{{jobName}}\" couldn't run because your account is out of credits. Top up or upgrade your plan to resume future runs.",
|
||||
"agent_cron_job_failed_insufficient_budget_title": "Scheduled task paused: insufficient credits",
|
||||
"agent_cron_job_failed_title": "Scheduled task failed",
|
||||
"billboard.learnMore": "Learn more",
|
||||
"billboard.menuLabel": "Announcements",
|
||||
"image_generation_completed": "Your image \"{{prompt}}\" is ready.",
|
||||
"image_generation_completed_title": "Image generation completed",
|
||||
"inbox.archiveAll": "Archive all",
|
||||
"inbox.empty": "No notifications yet",
|
||||
"inbox.emptyUnread": "No unread notifications",
|
||||
"inbox.filterUnread": "Show unread only",
|
||||
"inbox.markAllRead": "Mark all as read",
|
||||
"inbox.title": "Notifications"
|
||||
"inbox.title": "Notifications",
|
||||
"video_generation_completed": "Your video \"{{prompt}}\" is ready.",
|
||||
"video_generation_completed_title": "Video generation completed"
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"builtins.lobe-agent-documents.apiName.createDocument": "Create document",
|
||||
"builtins.lobe-agent-documents.apiName.editDocument": "Edit document",
|
||||
"builtins.lobe-agent-documents.apiName.listDocuments": "List documents",
|
||||
"builtins.lobe-agent-documents.apiName.patchDocument": "Patch document",
|
||||
"builtins.lobe-agent-documents.apiName.modifyNodes": "Modify document",
|
||||
"builtins.lobe-agent-documents.apiName.readDocument": "Read document",
|
||||
"builtins.lobe-agent-documents.apiName.readDocumentByFilename": "Read document by filename",
|
||||
"builtins.lobe-agent-documents.apiName.removeDocument": "Remove document",
|
||||
|
||||
@@ -447,11 +447,18 @@
|
||||
"myAgents.status.published": "Published",
|
||||
"myAgents.status.unpublished": "Unpublished",
|
||||
"myAgents.title": "My Published Agents",
|
||||
"notification.category.generation.desc": "Image and video completion notifications",
|
||||
"notification.category.generation.title": "Generation",
|
||||
"notification.category.schedule.desc": "Scheduled agent run failures and pauses",
|
||||
"notification.category.schedule.title": "Scheduled tasks",
|
||||
"notification.email.desc": "Receive email notifications when important events occur",
|
||||
"notification.email.title": "Email Notifications",
|
||||
"notification.enabled": "Enabled",
|
||||
"notification.inbox.desc": "Show notifications in the in-app inbox",
|
||||
"notification.inbox.title": "Inbox Notifications",
|
||||
"notification.item.agent_cron_job_failed": "Scheduled task failures",
|
||||
"notification.item.image_generation_completed": "Image generation completed",
|
||||
"notification.item.video_generation_completed": "Video generation completed",
|
||||
"notification.title": "Notification Channels",
|
||||
"plugin.addMCPPlugin": "Add MCP",
|
||||
"plugin.addTooltip": "Custom Skills",
|
||||
@@ -797,9 +804,6 @@
|
||||
"systemAgent.promptRewrite.label": "Model",
|
||||
"systemAgent.promptRewrite.modelDesc": "Specify the model used to rewrite prompts",
|
||||
"systemAgent.promptRewrite.title": "Prompt Rewrite Agent",
|
||||
"systemAgent.queryRewrite.label": "Model",
|
||||
"systemAgent.queryRewrite.modelDesc": "Specify the model used to optimize user inquiries",
|
||||
"systemAgent.queryRewrite.title": "Library query rewrite Agent",
|
||||
"systemAgent.thread.label": "Model",
|
||||
"systemAgent.thread.modelDesc": "The model designated for automatic renaming of subtopics",
|
||||
"systemAgent.thread.title": "Subtopic Auto-Naming Agent",
|
||||
|
||||
@@ -798,9 +798,6 @@
|
||||
"systemAgent.promptRewrite.label": "Modelo",
|
||||
"systemAgent.promptRewrite.modelDesc": "Especifica el modelo utilizado para reescribir indicaciones",
|
||||
"systemAgent.promptRewrite.title": "Agente de Reescritura de Indicaciones",
|
||||
"systemAgent.queryRewrite.label": "Modelo",
|
||||
"systemAgent.queryRewrite.modelDesc": "Especifica el modelo usado para optimizar las consultas del usuario",
|
||||
"systemAgent.queryRewrite.title": "Agente de Reescritura de Consultas",
|
||||
"systemAgent.thread.label": "Modelo",
|
||||
"systemAgent.thread.modelDesc": "Modelo designado para renombrar automáticamente subtemas",
|
||||
"systemAgent.thread.title": "Agente de Renombrado Automático de Subtemas",
|
||||
|
||||
@@ -798,9 +798,6 @@
|
||||
"systemAgent.promptRewrite.label": "مدل",
|
||||
"systemAgent.promptRewrite.modelDesc": "مدلی را که برای بازنویسی پرامپت استفاده میشود مشخص کنید",
|
||||
"systemAgent.promptRewrite.title": "عامل بازنویسی پرامپت",
|
||||
"systemAgent.queryRewrite.label": "مدل",
|
||||
"systemAgent.queryRewrite.modelDesc": "مدلی که برای بهینهسازی پرسشهای کاربران استفاده میشود",
|
||||
"systemAgent.queryRewrite.title": "عامل بازنویسی پرسش کتابخانه",
|
||||
"systemAgent.thread.label": "مدل",
|
||||
"systemAgent.thread.modelDesc": "مدلی که برای نامگذاری خودکار زیرموضوعات استفاده میشود",
|
||||
"systemAgent.thread.title": "عامل نامگذاری خودکار زیرموضوع",
|
||||
|
||||
@@ -798,9 +798,6 @@
|
||||
"systemAgent.promptRewrite.label": "Modèle",
|
||||
"systemAgent.promptRewrite.modelDesc": "Spécifiez le modèle utilisé pour réécrire les invites",
|
||||
"systemAgent.promptRewrite.title": "Agent de Réécriture d’Invites",
|
||||
"systemAgent.queryRewrite.label": "Modèle",
|
||||
"systemAgent.queryRewrite.modelDesc": "Modèle utilisé pour optimiser les requêtes des utilisateurs",
|
||||
"systemAgent.queryRewrite.title": "Agent de réécriture de requêtes",
|
||||
"systemAgent.thread.label": "Modèle",
|
||||
"systemAgent.thread.modelDesc": "Modèle utilisé pour renommer automatiquement les sous-sujets",
|
||||
"systemAgent.thread.title": "Agent de renommage de sous-sujets",
|
||||
|
||||
@@ -798,9 +798,6 @@
|
||||
"systemAgent.promptRewrite.label": "Modello",
|
||||
"systemAgent.promptRewrite.modelDesc": "Specifica il modello utilizzato per riscrivere i prompt",
|
||||
"systemAgent.promptRewrite.title": "Agente di Riscrittura Prompt",
|
||||
"systemAgent.queryRewrite.label": "Modello",
|
||||
"systemAgent.queryRewrite.modelDesc": "Specifica il modello utilizzato per ottimizzare le richieste degli utenti",
|
||||
"systemAgent.queryRewrite.title": "Agente Riscrittura Richieste Libreria",
|
||||
"systemAgent.thread.label": "Modello",
|
||||
"systemAgent.thread.modelDesc": "Modello designato per la rinominazione automatica dei sottotemi",
|
||||
"systemAgent.thread.title": "Agente Rinomina Automatica Sottotemi",
|
||||
|
||||
@@ -798,9 +798,6 @@
|
||||
"systemAgent.promptRewrite.label": "モデル",
|
||||
"systemAgent.promptRewrite.modelDesc": "プロンプトを書き換える際に使用するモデルを指定します",
|
||||
"systemAgent.promptRewrite.title": "プロンプトリライトエージェント",
|
||||
"systemAgent.queryRewrite.label": "モデル",
|
||||
"systemAgent.queryRewrite.modelDesc": "ユーザーの質問を最適化するために指定されたモデル",
|
||||
"systemAgent.queryRewrite.title": "リソースライブラリ質問リライトアシスタント",
|
||||
"systemAgent.thread.label": "モデル",
|
||||
"systemAgent.thread.modelDesc": "サブトピックの自動命名に使用されるモデルを指定します",
|
||||
"systemAgent.thread.title": "サブトピック自動命名アシスタント",
|
||||
|
||||
@@ -798,9 +798,6 @@
|
||||
"systemAgent.promptRewrite.label": "모델",
|
||||
"systemAgent.promptRewrite.modelDesc": "프롬프트 재작성에 사용할 모델을 지정합니다",
|
||||
"systemAgent.promptRewrite.title": "프롬프트 재작성 에이전트",
|
||||
"systemAgent.queryRewrite.label": "모델",
|
||||
"systemAgent.queryRewrite.modelDesc": "사용자의 질문을 최적화하는 데 사용되는 모델 지정",
|
||||
"systemAgent.queryRewrite.title": "자료실 질문 재작성 도우미",
|
||||
"systemAgent.thread.label": "모델",
|
||||
"systemAgent.thread.modelDesc": "하위 주제 자동 이름 변경에 사용되는 모델 지정",
|
||||
"systemAgent.thread.title": "하위 주제 자동 명명 도우미",
|
||||
|
||||
@@ -798,9 +798,6 @@
|
||||
"systemAgent.promptRewrite.label": "Model",
|
||||
"systemAgent.promptRewrite.modelDesc": "Geef het model op dat wordt gebruikt om prompts te herschrijven",
|
||||
"systemAgent.promptRewrite.title": "Prompt Rewrite Agent",
|
||||
"systemAgent.queryRewrite.label": "Model",
|
||||
"systemAgent.queryRewrite.modelDesc": "Specificeer het model dat wordt gebruikt om gebruikersvragen te optimaliseren",
|
||||
"systemAgent.queryRewrite.title": "Bibliotheekvraag Herschrijfagent",
|
||||
"systemAgent.thread.label": "Model",
|
||||
"systemAgent.thread.modelDesc": "Model dat wordt gebruikt voor automatische hernoeming van subonderwerpen",
|
||||
"systemAgent.thread.title": "Subonderwerp Auto-Naamgevingsagent",
|
||||
|
||||
@@ -798,9 +798,6 @@
|
||||
"systemAgent.promptRewrite.label": "Model",
|
||||
"systemAgent.promptRewrite.modelDesc": "Określ model używany do przepisywania promptów",
|
||||
"systemAgent.promptRewrite.title": "Agent Przepisywania Promptów",
|
||||
"systemAgent.queryRewrite.label": "Model",
|
||||
"systemAgent.queryRewrite.modelDesc": "Określ model używany do optymalizacji zapytań użytkownika",
|
||||
"systemAgent.queryRewrite.title": "Agent przekształcania zapytań bibliotecznych",
|
||||
"systemAgent.thread.label": "Model",
|
||||
"systemAgent.thread.modelDesc": "Model przeznaczony do automatycznego nadawania nazw podtematom",
|
||||
"systemAgent.thread.title": "Agent automatycznego nazewnictwa podtematów",
|
||||
|
||||
@@ -798,9 +798,6 @@
|
||||
"systemAgent.promptRewrite.label": "Modelo",
|
||||
"systemAgent.promptRewrite.modelDesc": "Especifique o modelo usado para reescrever prompts",
|
||||
"systemAgent.promptRewrite.title": "Agente de Reescrita de Prompt",
|
||||
"systemAgent.queryRewrite.label": "Modelo",
|
||||
"systemAgent.queryRewrite.modelDesc": "Especifique o modelo usado para otimizar as perguntas dos usuários",
|
||||
"systemAgent.queryRewrite.title": "Agente de Reescrita de Consultas da Biblioteca",
|
||||
"systemAgent.thread.label": "Modelo",
|
||||
"systemAgent.thread.modelDesc": "Modelo designado para renomeação automática de subtópicos",
|
||||
"systemAgent.thread.title": "Agente de Nomeação Automática de Subtópicos",
|
||||
|
||||
@@ -798,9 +798,6 @@
|
||||
"systemAgent.promptRewrite.label": "Модель",
|
||||
"systemAgent.promptRewrite.modelDesc": "Укажите модель, используемую для переформулирования запросов",
|
||||
"systemAgent.promptRewrite.title": "Агент перезаписи запросов",
|
||||
"systemAgent.queryRewrite.label": "Модель",
|
||||
"systemAgent.queryRewrite.modelDesc": "Укажите модель для оптимизации пользовательских запросов",
|
||||
"systemAgent.queryRewrite.title": "Агент переформулировки запросов",
|
||||
"systemAgent.thread.label": "Модель",
|
||||
"systemAgent.thread.modelDesc": "Модель, используемая для автоматического наименования подтем",
|
||||
"systemAgent.thread.title": "Агент автонаименования подтем",
|
||||
|
||||
@@ -798,9 +798,6 @@
|
||||
"systemAgent.promptRewrite.label": "Model",
|
||||
"systemAgent.promptRewrite.modelDesc": "İstemleri yeniden yazmak için kullanılan modeli belirtin",
|
||||
"systemAgent.promptRewrite.title": "İstem Yenileme Aracısı",
|
||||
"systemAgent.queryRewrite.label": "Model",
|
||||
"systemAgent.queryRewrite.modelDesc": "Kullanıcı sorgularını optimize etmek için kullanılacak modeli belirtin",
|
||||
"systemAgent.queryRewrite.title": "Kütüphane Sorgusu Yeniden Yazma Temsilcisi",
|
||||
"systemAgent.thread.label": "Model",
|
||||
"systemAgent.thread.modelDesc": "Alt konuların otomatik yeniden adlandırılması için belirlenen model",
|
||||
"systemAgent.thread.title": "Alt Konu Otomatik Adlandırma Temsilcisi",
|
||||
|
||||
@@ -798,9 +798,6 @@
|
||||
"systemAgent.promptRewrite.label": "Mô hình",
|
||||
"systemAgent.promptRewrite.modelDesc": "Chỉ định mô hình được sử dụng để viết lại prompt",
|
||||
"systemAgent.promptRewrite.title": "Tác nhân Viết lại Prompt",
|
||||
"systemAgent.queryRewrite.label": "Mô Hình",
|
||||
"systemAgent.queryRewrite.modelDesc": "Chỉ định mô hình dùng để tối ưu hóa câu hỏi người dùng",
|
||||
"systemAgent.queryRewrite.title": "Tác Nhân Viết Lại Truy Vấn Thư Viện",
|
||||
"systemAgent.thread.label": "Mô Hình",
|
||||
"systemAgent.thread.modelDesc": "Mô hình được chỉ định để tự động đặt tên cho các chủ đề phụ",
|
||||
"systemAgent.thread.title": "Tác Nhân Đặt Tên Chủ Đề Phụ Tự Động",
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
{
|
||||
"channel.allowFrom": "允许的用户",
|
||||
"channel.allowFromAdd": "添加用户",
|
||||
"channel.allowFromEmpty": "尚未添加任何用户——任何人都可以与机器人交互。",
|
||||
"channel.allowFromHint": "仅列表内用户可与机器人交互,「你的平台用户 ID」会自动加入。",
|
||||
"channel.allowFromIdLabel": "用户 ID",
|
||||
"channel.allowFromIdPlaceholder": "平台用户 ID",
|
||||
"channel.allowFromNameLabel": "备注",
|
||||
"channel.allowFromNamePlaceholder": "如:张三(仅你自己可见)",
|
||||
"channel.allowListRemove": "删除",
|
||||
"channel.appSecret": "App Secret",
|
||||
"channel.appSecretHint": "你的机器人应用的 App Secret,将被加密存储。",
|
||||
"channel.appSecretPlaceholder": "在此粘贴你的 App Secret",
|
||||
@@ -45,8 +54,6 @@
|
||||
"channel.displayToolCalls": "展示工具调用",
|
||||
"channel.displayToolCallsHint": "在 AI 回复过程中展示工具调用详情。关闭后仅展示最终回复,获得更简洁的体验。",
|
||||
"channel.dm": "私信",
|
||||
"channel.dmEnabled": "启用私信",
|
||||
"channel.dmEnabledHint": "允许机器人接收和回复私信",
|
||||
"channel.dmPolicy": "私信策略",
|
||||
"channel.dmPolicyAllowlist": "白名单",
|
||||
"channel.dmPolicyDisabled": "禁用",
|
||||
@@ -63,6 +70,19 @@
|
||||
"channel.feishu.description": "将助手连接到飞书,支持私聊和群聊。",
|
||||
"channel.feishu.webhookMigrationDesc": "WebSocket 模式提供实时事件推送,无需配置公网回调地址。如需迁移,在高级设置中将连接模式切换为 WebSocket 即可,无需在飞书/Lark 开放平台进行额外配置。",
|
||||
"channel.feishu.webhookMigrationTitle": "建议迁移到 WebSocket 模式",
|
||||
"channel.groupAllowFrom": "允许的频道",
|
||||
"channel.groupAllowFromAdd": "添加频道",
|
||||
"channel.groupAllowFromEmpty": "尚未添加任何频道——机器人不会在任何地方响应。",
|
||||
"channel.groupAllowFromHint": "机器人允许响应的频道 / 群组 / 会话 ID。",
|
||||
"channel.groupAllowFromIdLabel": "频道 ID",
|
||||
"channel.groupAllowFromIdPlaceholder": "频道 / 群组 / 会话 ID",
|
||||
"channel.groupAllowFromNameLabel": "备注",
|
||||
"channel.groupAllowFromNamePlaceholder": "如:#general(仅你自己可见)",
|
||||
"channel.groupPolicy": "群组策略",
|
||||
"channel.groupPolicyAllowlist": "白名单",
|
||||
"channel.groupPolicyDisabled": "禁用",
|
||||
"channel.groupPolicyHint": "控制机器人在群组、频道、子话题里的响应范围",
|
||||
"channel.groupPolicyOpen": "开放",
|
||||
"channel.historyLimit": "历史消息条数",
|
||||
"channel.historyLimitHint": "读取频道历史消息时默认获取的消息数量",
|
||||
"channel.importConfig": "导入平台配置",
|
||||
@@ -91,8 +111,8 @@
|
||||
"channel.secretToken": "Webhook 密钥",
|
||||
"channel.secretTokenHint": "可选。用于验证来自 Telegram 的 Webhook 请求。",
|
||||
"channel.secretTokenPlaceholder": "可选的 Webhook 验证密钥",
|
||||
"channel.serverId": "默认服务器 / Guild ID",
|
||||
"channel.serverIdHint": "你在该平台上的默认服务器或 Guild ID,AI 可以用它自动列出频道。",
|
||||
"channel.serverId": "默认服务器(供 AI 工具使用)",
|
||||
"channel.serverIdHint": "你让 bot 在某个服务器上做事时(比如 \"列出频道\"、\"发到 #announcements\"),AI 工具默认操作的服务器 / Guild ID。跟访问控制无关 —— 那是群组策略的事。",
|
||||
"channel.settings": "高级设置",
|
||||
"channel.settingsResetConfirm": "确定要将高级设置恢复为默认配置吗?",
|
||||
"channel.settingsResetDefault": "恢复默认配置",
|
||||
@@ -118,8 +138,8 @@
|
||||
"channel.testFailed": "连接测试失败",
|
||||
"channel.testSuccess": "连接测试通过",
|
||||
"channel.updateFailed": "更新状态失败",
|
||||
"channel.userId": "你的平台用户 ID",
|
||||
"channel.userIdHint": "你在该平台上的用户 ID,AI 可以用它向你发送私信。",
|
||||
"channel.userId": "你的平台用户 ID(供 AI 工具使用)",
|
||||
"channel.userIdHint": "AI 工具用它主动联系你(如定时提醒、通知);该 ID 也会被全局白名单自动信任。",
|
||||
"channel.validationError": "请填写应用 ID 和 Token",
|
||||
"channel.verificationToken": "Verification Token",
|
||||
"channel.verificationTokenHint": "可选。用于验证事件推送来源。",
|
||||
|
||||
+30
-4
@@ -123,7 +123,7 @@
|
||||
"extendParams.effort.title": "努力程度",
|
||||
"extendParams.enableAdaptiveThinking.desc": "启用自适应思维模式,让 Claude 动态决定思考的时机和深度。",
|
||||
"extendParams.enableAdaptiveThinking.title": "启用自适应思维",
|
||||
"extendParams.enableReasoning.desc": "基于Claude推理机制的限制。<1>了解更多</1>",
|
||||
"extendParams.enableReasoning.desc": "开启后模型会先进行推理,适合复杂问题。",
|
||||
"extendParams.enableReasoning.title": "开启深度思考",
|
||||
"extendParams.imageAspectRatio.title": "图片宽高比",
|
||||
"extendParams.imageResolution.title": "图片分辨率",
|
||||
@@ -137,6 +137,7 @@
|
||||
"extendParams.urlContext.desc": "开启后会自动解析网页链接,提取网页内容作为上下文",
|
||||
"extendParams.urlContext.title": "提取网页链接内容",
|
||||
"followUpPlaceholder": "继续跟进。使用 @ 分配任务给其他智能体。",
|
||||
"followUpPlaceholderHeterogeneous": "继续跟进。",
|
||||
"group.desc": "在同一对话空间,让多个助理一起推进任务",
|
||||
"group.memberTooltip": "群组内有 {{count}} 名成员",
|
||||
"group.orchestratorThinking": "主持人思考中…",
|
||||
@@ -204,6 +205,9 @@
|
||||
"input.stop": "停止",
|
||||
"input.warp": "换行",
|
||||
"input.warpWithKey": "按 <key/> 换行",
|
||||
"inputQueue.delete": "删除",
|
||||
"inputQueue.edit": "编辑",
|
||||
"inputQueue.sendNow": "立即发送(中断当前运行)",
|
||||
"intentUnderstanding.title": "正在理解你的意图…",
|
||||
"inviteMembers": "邀请成员",
|
||||
"knowledgeBase.all": "全部内容",
|
||||
@@ -478,7 +482,7 @@
|
||||
"taskDetail.comment.deleteConfirm.title": "删除这条评论?",
|
||||
"taskDetail.comment.edit": "编辑",
|
||||
"taskDetail.comment.save": "保存",
|
||||
"taskDetail.commentPlaceholder": "留下评论...",
|
||||
"taskDetail.commentPlaceholder": "留下反馈,帮助 Agent 在下次执行时产出更准确的结果...",
|
||||
"taskDetail.deleteConfirm.content": "此操作不可撤销。",
|
||||
"taskDetail.deleteConfirm.ok": "删除",
|
||||
"taskDetail.deleteConfirm.title": "确认删除此任务?",
|
||||
@@ -509,7 +513,7 @@
|
||||
"taskDetail.status.canceled": "已取消",
|
||||
"taskDetail.status.completed": "已完成",
|
||||
"taskDetail.status.failed": "失败",
|
||||
"taskDetail.status.paused": "已暂停",
|
||||
"taskDetail.status.paused": "待审阅",
|
||||
"taskDetail.status.running": "进行中",
|
||||
"taskDetail.stopTask": "停止任务",
|
||||
"taskDetail.subIssueOf": "隶属于",
|
||||
@@ -520,21 +524,39 @@
|
||||
"taskDetail.updateFailed": "任务更新失败",
|
||||
"taskList.activeTasks": "进行中的任务",
|
||||
"taskList.all": "全部任务",
|
||||
"taskList.assigneeSearch.empty": "未找到匹配的 Agent",
|
||||
"taskList.assigneeSearch.placeholder": "搜索 Agent...",
|
||||
"taskList.breadcrumb.task": "任务",
|
||||
"taskList.contextMenu.copyId": "复制 ID",
|
||||
"taskList.contextMenu.copyIdSuccess": "ID 已复制",
|
||||
"taskList.contextMenu.copyLink": "复制链接",
|
||||
"taskList.contextMenu.copyLinkSuccess": "链接已复制",
|
||||
"taskList.contextMenu.priority": "优先级",
|
||||
"taskList.contextMenu.status": "状态",
|
||||
"taskList.empty": "暂无任务",
|
||||
"taskList.form.grouping": "分组",
|
||||
"taskList.form.orderCompletedByRecency": "按最近完成排序",
|
||||
"taskList.form.ordering": "排序",
|
||||
"taskList.form.showCompleted": "显示已完成与已取消",
|
||||
"taskList.form.subGrouping": "子分组",
|
||||
"taskList.groupBy.assignee": "负责人",
|
||||
"taskList.groupBy.none": "不分组",
|
||||
"taskList.groupBy.priority": "优先级",
|
||||
"taskList.groupBy.status": "状态",
|
||||
"taskList.hiddenCompleted.count_one": "{{count}} 个任务",
|
||||
"taskList.hiddenCompleted.count_other": "{{count}} 个任务",
|
||||
"taskList.hiddenCompleted.show": "显示",
|
||||
"taskList.hiddenCompleted.suffix": "已按显示选项隐藏",
|
||||
"taskList.kanban.addTask": "新建任务",
|
||||
"taskList.kanban.backlog": "待办",
|
||||
"taskList.kanban.canceled": "已取消",
|
||||
"taskList.kanban.done": "已完成",
|
||||
"taskList.kanban.emptyColumn": "暂无任务",
|
||||
"taskList.kanban.needsInput": "需要输入",
|
||||
"taskList.kanban.hiddenColumns": "隐藏的分组",
|
||||
"taskList.kanban.hideColumn": "隐藏分组",
|
||||
"taskList.kanban.needsInput": "待审阅",
|
||||
"taskList.kanban.running": "进行中",
|
||||
"taskList.kanban.showColumn": "显示分组",
|
||||
"taskList.orderBy.assignee": "负责人",
|
||||
"taskList.orderBy.createdAt": "创建时间",
|
||||
"taskList.orderBy.priority": "优先级",
|
||||
@@ -543,6 +565,7 @@
|
||||
"taskList.orderBy.updatedAt": "更新时间",
|
||||
"taskList.title": "任务",
|
||||
"taskList.unassigned": "未分配",
|
||||
"taskList.unassignedHint": "未分配时将由 Lobe AI 执行该任务",
|
||||
"taskList.view.board": "看板",
|
||||
"taskList.view.list": "列表",
|
||||
"taskList.viewAll": "查看全部",
|
||||
@@ -656,6 +679,9 @@
|
||||
"viewMode.fullWidth": "全宽显示",
|
||||
"viewMode.normal": "普通",
|
||||
"viewMode.wideScreen": "宽屏",
|
||||
"viewSwitcher.chat": "对话",
|
||||
"viewSwitcher.page": "文稿",
|
||||
"viewSwitcher.task": "任务",
|
||||
"workflow.awaitingConfirmation": "需要你确认",
|
||||
"workflow.collapse": "收起",
|
||||
"workflow.expandFull": "全部展开",
|
||||
|
||||
@@ -146,6 +146,7 @@
|
||||
"cmdk.keywords.starGitHub": "github star 收藏 喜欢 点赞",
|
||||
"cmdk.keywords.stats": "统计 数据 分析",
|
||||
"cmdk.keywords.submitIssue": "问题 错误 反馈 建议 提交",
|
||||
"cmdk.keywords.tasks": "任务 todo agent 看板",
|
||||
"cmdk.keywords.usage": "用量 统计 消耗 配额",
|
||||
"cmdk.keywords.video": "视频,生成,Seedance,Kling",
|
||||
"cmdk.memory": "记忆",
|
||||
@@ -195,6 +196,7 @@
|
||||
"cmdk.settings": "设置",
|
||||
"cmdk.starOnGitHub": "在 GitHub 上给我们 Star",
|
||||
"cmdk.submitIssue": "提交问题",
|
||||
"cmdk.tasks": "任务",
|
||||
"cmdk.theme": "主题",
|
||||
"cmdk.themeAuto": "跟随系统",
|
||||
"cmdk.themeCurrent": "当前",
|
||||
@@ -266,6 +268,7 @@
|
||||
"footer.title": "喜欢我们的产品?",
|
||||
"fullscreen": "全屏模式",
|
||||
"generation.hero.taglinePrefix": "即刻创作",
|
||||
"generation.promptModeration.blocked": "请求内容可能违反内容政策。请调整提示词后重试",
|
||||
"getDesktopApp": "获取桌面应用",
|
||||
"historyRange": "历史范围",
|
||||
"home.suggestQuestions": "试试这些示例",
|
||||
@@ -433,6 +436,7 @@
|
||||
"tab.resource": "资源",
|
||||
"tab.search": "搜索",
|
||||
"tab.setting": "设置",
|
||||
"tab.tasks": "任务",
|
||||
"tab.video": "视频",
|
||||
"telemetry.allow": "允许",
|
||||
"telemetry.deny": "拒绝",
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"navigation.recentView": "最近访问",
|
||||
"navigation.resources": "资源",
|
||||
"navigation.settings": "设置",
|
||||
"navigation.tasks": "任务",
|
||||
"navigation.unpin": "取消固定",
|
||||
"notification.finishChatGeneration": "AI 消息已生成完毕",
|
||||
"proxy.auth": "需要认证",
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"features.agentSelfIteration.desc": "允许助理自我反思、自我感知,并在持续的尝试与交互中不断自我迭代。",
|
||||
"features.agentSelfIteration.title": "Agent 自我迭代",
|
||||
"features.assistantMessageGroup.desc": "将代理消息及其工具调用结果组合在一起显示",
|
||||
"features.assistantMessageGroup.title": "代理消息分组",
|
||||
"features.gatewayMode.desc": "通过 Gateway 在服务端执行 Agent 任务。可实现关闭浏览器后仍然执行 agent。",
|
||||
"features.gatewayMode.title": "服务端代理执行(Gateway)",
|
||||
"features.groupChat.desc": "启用多代理协同群聊功能。",
|
||||
"features.groupChat.title": "群聊(多代理)",
|
||||
"features.heterogeneousAgent.desc": "启用与 Claude Code、Codex CLI 及其他外部代理 CLI 的异构代理协同执行。在侧边栏的代理菜单中创建“Claude Code 代理”选项。",
|
||||
"features.heterogeneousAgent.title": "异构代理(Claude Code)",
|
||||
"features.inputMarkdown.desc": "在输入区域实时渲染 Markdown(粗体、代码块、表格等)",
|
||||
"features.inputMarkdown.title": "输入框 Markdown 渲染",
|
||||
"title": "实验室"
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
"azure.azureApiVersion.fetch": "获取列表",
|
||||
"azure.azureApiVersion.title": "Azure API Version",
|
||||
"azure.empty": "请输入模型 ID 添加第一个模型",
|
||||
"azure.endpoint.desc": "从 Azure 门户检查资源时,可在“密钥和终结点”部分中找到此值",
|
||||
"azure.endpoint.placeholder": "https://docs-test-001.openai.azure.com",
|
||||
"azure.endpoint.desc": "从 Azure 门户检查资源时,可在“密钥和终结点”部分中找到此值。也支持 Azure OpenAI Responses API 路径形式的终结点。",
|
||||
"azure.endpoint.placeholder": "https://your-resource.cognitiveservices.azure.com/openai/responses",
|
||||
"azure.endpoint.title": "Azure API 地址",
|
||||
"azure.modelListPlaceholder": "请选择或添加你部署的 OpenAI 模型",
|
||||
"azure.title": "Azure OpenAI",
|
||||
|
||||
@@ -506,6 +506,8 @@
|
||||
"doubao-seedream-4-0-250828.description": "Seedream 4.0 是字节跳动 Seed 推出的图像生成模型,支持文本与图像输入,具备高度可控的高质量图像生成能力。可根据文本提示生成图像。",
|
||||
"doubao-seedream-4-5-251128.description": "Seedream 4.5是字节跳动最新的多模态图像模型,集成了文本生成图像、图像生成图像和批量图像生成功能,同时具备常识和推理能力。与之前的4.0版本相比,生成质量显著提升,编辑一致性和多图融合效果更好。它对视觉细节的控制更加精确,能够更自然地生成小文字和小面部,并实现更和谐的布局和色彩,提升整体美感。",
|
||||
"doubao-seedream-5-0-260128.description": "Doubao-Seedream-5.0-lite是字节跳动最新的图像生成模型。首次集成在线检索功能,能够结合实时网络信息,提升生成图像的时效性。模型智能性也得到升级,能够精确解析复杂指令和视觉内容。此外,它在专业场景中的全球知识覆盖、参考一致性和生成质量方面均有提升,更好地满足企业级视觉创作需求。",
|
||||
"dreamina-seedance-2-0-260128.description": "字节跳动的 Seedance 2.0 是最强大的视频生成模型,支持多模态参考视频生成、视频编辑、视频扩展、文本转视频和图像转视频,并同步音频。",
|
||||
"dreamina-seedance-2-0-fast-260128.description": "字节跳动的 Seedance 2.0 Fast 提供与 Seedance 2.0 相同的功能,但生成速度更快,价格更具竞争力。",
|
||||
"emohaa.description": "Emohaa 是一款心理健康模型,具备专业咨询能力,帮助用户理解情绪问题。",
|
||||
"ernie-4.5-0.3b.description": "ERNIE 4.5 0.3B 是一款开源轻量级模型,适用于本地和定制化部署。",
|
||||
"ernie-4.5-21b-a3b-thinking.description": "ERNIE-4.5-21B-A3B-Thinking是一个拥有21B总参数和3B活跃参数的文本专家混合模型,提供显著增强的推理质量和深度。",
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
{
|
||||
"agent_cron_job_failed": "定时任务「{{jobName}}」执行失败,点击查看详情。",
|
||||
"agent_cron_job_failed_insufficient_budget": "定时任务「{{jobName}}」因账户额度不足未能执行。请充值或升级套餐以恢复后续运行。",
|
||||
"agent_cron_job_failed_insufficient_budget_title": "定时任务已暂停:额度不足",
|
||||
"agent_cron_job_failed_title": "定时任务执行失败",
|
||||
"billboard.learnMore": "了解更多",
|
||||
"billboard.menuLabel": "公告",
|
||||
"image_generation_completed": "图片「{{prompt}}」已生成。",
|
||||
"image_generation_completed_title": "图片生成完成",
|
||||
"inbox.archiveAll": "全部归档",
|
||||
"inbox.empty": "暂无通知",
|
||||
"inbox.emptyUnread": "没有未读通知",
|
||||
"inbox.filterUnread": "仅显示未读",
|
||||
"inbox.markAllRead": "全部标为已读",
|
||||
"inbox.title": "通知"
|
||||
"inbox.title": "通知",
|
||||
"video_generation_completed": "视频「{{prompt}}」已生成。",
|
||||
"video_generation_completed_title": "视频生成完成"
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"builtins.lobe-agent-documents.apiName.createDocument": "创建文档",
|
||||
"builtins.lobe-agent-documents.apiName.editDocument": "编辑文档",
|
||||
"builtins.lobe-agent-documents.apiName.listDocuments": "列出文档",
|
||||
"builtins.lobe-agent-documents.apiName.patchDocument": "修订文档",
|
||||
"builtins.lobe-agent-documents.apiName.modifyNodes": "修改文档",
|
||||
"builtins.lobe-agent-documents.apiName.readDocument": "读取文档",
|
||||
"builtins.lobe-agent-documents.apiName.readDocumentByFilename": "按文件名读取文档",
|
||||
"builtins.lobe-agent-documents.apiName.removeDocument": "删除文档",
|
||||
|
||||
@@ -454,11 +454,18 @@
|
||||
"myAgents.status.published": "已上架",
|
||||
"myAgents.status.unpublished": "未上架",
|
||||
"myAgents.title": "我发布的助理",
|
||||
"notification.category.generation.desc": "图片和视频生成完成通知",
|
||||
"notification.category.generation.title": "生成",
|
||||
"notification.category.schedule.desc": "定时助理运行失败和暂停通知",
|
||||
"notification.category.schedule.title": "定时任务",
|
||||
"notification.email.desc": "当重要事件发生时接收邮件通知",
|
||||
"notification.email.title": "邮件通知",
|
||||
"notification.enabled": "启用",
|
||||
"notification.inbox.desc": "在应用内收件箱中显示通知",
|
||||
"notification.inbox.title": "站内通知",
|
||||
"notification.item.agent_cron_job_failed": "定时任务执行失败",
|
||||
"notification.item.image_generation_completed": "图片生成完成",
|
||||
"notification.item.video_generation_completed": "视频生成完成",
|
||||
"notification.title": "通知渠道",
|
||||
"plugin.addMCPPlugin": "添加 MCP",
|
||||
"plugin.addTooltip": "自定义技能",
|
||||
@@ -804,9 +811,6 @@
|
||||
"systemAgent.promptRewrite.label": "模型",
|
||||
"systemAgent.promptRewrite.modelDesc": "指定用于重写提示词的模型",
|
||||
"systemAgent.promptRewrite.title": "提示词重写助理",
|
||||
"systemAgent.queryRewrite.label": "模型",
|
||||
"systemAgent.queryRewrite.modelDesc": "指定用于优化用户提问的模型",
|
||||
"systemAgent.queryRewrite.title": "资源库提问重写助理",
|
||||
"systemAgent.thread.label": "模型",
|
||||
"systemAgent.thread.modelDesc": "指定用于子话题自动重命名的模型",
|
||||
"systemAgent.thread.title": "子话题自动命名助理",
|
||||
|
||||
@@ -798,9 +798,6 @@
|
||||
"systemAgent.promptRewrite.label": "模型",
|
||||
"systemAgent.promptRewrite.modelDesc": "指定用於重寫提示詞的模型",
|
||||
"systemAgent.promptRewrite.title": "提示詞重寫代理",
|
||||
"systemAgent.queryRewrite.label": "模型",
|
||||
"systemAgent.queryRewrite.modelDesc": "指定用於優化用戶提問的模型",
|
||||
"systemAgent.queryRewrite.title": "資源庫提問重寫助手",
|
||||
"systemAgent.thread.label": "模型",
|
||||
"systemAgent.thread.modelDesc": "指定用於子主題自動重命名的模型",
|
||||
"systemAgent.thread.title": "子主題自動命名助手",
|
||||
|
||||
+5
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@lobehub/lobehub",
|
||||
"version": "2.1.51",
|
||||
"version": "2.1.53",
|
||||
"description": "LobeHub - an open-source,comprehensive AI Agent framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.",
|
||||
"keywords": [
|
||||
"framework",
|
||||
@@ -202,6 +202,7 @@
|
||||
"@lexical/utils": "^0.42.0",
|
||||
"@lobechat/agent-gateway-client": "workspace:*",
|
||||
"@lobechat/agent-runtime": "workspace:*",
|
||||
"@lobechat/agent-signal": "workspace:*",
|
||||
"@lobechat/agent-templates": "workspace:*",
|
||||
"@lobechat/builtin-agents": "workspace:*",
|
||||
"@lobechat/builtin-skills": "workspace:*",
|
||||
@@ -270,11 +271,11 @@
|
||||
"@lobehub/analytics": "^1.6.2",
|
||||
"@lobehub/charts": "^5.0.0",
|
||||
"@lobehub/desktop-ipc-typings": "workspace:*",
|
||||
"@lobehub/editor": "^4.8.1",
|
||||
"@lobehub/editor": "^4.9.3",
|
||||
"@lobehub/icons": "^5.0.0",
|
||||
"@lobehub/market-sdk": "0.32.2",
|
||||
"@lobehub/tts": "^5.1.2",
|
||||
"@lobehub/ui": "^5.9.0",
|
||||
"@lobehub/ui": "^5.9.6",
|
||||
"@modelcontextprotocol/sdk": "^1.26.0",
|
||||
"@napi-rs/canvas": "^0.1.88",
|
||||
"@neondatabase/serverless": "^1.0.2",
|
||||
@@ -479,7 +480,7 @@
|
||||
"@types/unist": "^3.0.3",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@types/xast": "^2.0.4",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260207.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260425.1",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"ajv": "^8.17.1",
|
||||
|
||||
@@ -304,6 +304,42 @@ export class GeneralChatAgent implements Agent {
|
||||
return { hasToolsCalling, parentMessageId, toolsCalling };
|
||||
}
|
||||
|
||||
/**
|
||||
* Pending-tool scope guard for the main loop.
|
||||
*
|
||||
* The pending-approval check must only count tool messages produced by the
|
||||
* **current** assistant turn. Stale `pluginIntervention.status === 'pending'`
|
||||
* rows from a previous turn (e.g. an abandoned approval flow whose user
|
||||
* never clicked approve/reject) get loaded back into `state.messages` via
|
||||
* `historyMessages` and would otherwise hijack every subsequent
|
||||
* `tool_result` / `tools_batch_result` phase, parking the loop in
|
||||
* `waiting_for_human` forever.
|
||||
*
|
||||
* "Current turn" = the most recent assistant message that emitted tool calls,
|
||||
* stored as either model-native `tool_calls` or persisted `tools`. All pending
|
||||
* tool messages legitimately belonging to this turn have
|
||||
* `parentId === currentAssistantId`.
|
||||
*/
|
||||
private getCurrentTurnPendingToolMessages(state: AgentState): any[] {
|
||||
let currentAssistantId: string | undefined;
|
||||
for (let i = state.messages.length - 1; i >= 0; i--) {
|
||||
const m = state.messages[i] as any;
|
||||
if (m.role === 'assistant' && (m.tool_calls?.length > 0 || m.tools?.length > 0)) {
|
||||
currentAssistantId = m.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!currentAssistantId) return [];
|
||||
|
||||
return state.messages.filter(
|
||||
(m: any) =>
|
||||
m.role === 'tool' &&
|
||||
m.pluginIntervention?.status === 'pending' &&
|
||||
m.parentId === currentAssistantId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find existing compression summary from messages
|
||||
* Looks for MessageGroup with type 'compression' and extracts its content
|
||||
@@ -543,10 +579,9 @@ export class GeneralChatAgent implements Agent {
|
||||
}
|
||||
}
|
||||
|
||||
// Check if there are still pending tool messages waiting for approval
|
||||
const pendingToolMessages = state.messages.filter(
|
||||
(m: any) => m.role === 'tool' && m.pluginIntervention?.status === 'pending',
|
||||
);
|
||||
// Scope pending check to the current assistant turn so stale
|
||||
// `pending` rows from prior turns can never block the loop.
|
||||
const pendingToolMessages = this.getCurrentTurnPendingToolMessages(state);
|
||||
|
||||
// If there are pending tools, wait for human approval
|
||||
if (pendingToolMessages.length > 0) {
|
||||
@@ -577,10 +612,9 @@ export class GeneralChatAgent implements Agent {
|
||||
case 'tools_batch_result': {
|
||||
const { parentMessageId } = context.payload as GeneralAgentCallToolResultPayload;
|
||||
|
||||
// Check if there are still pending tool messages waiting for approval
|
||||
const pendingToolMessages = state.messages.filter(
|
||||
(m: any) => m.role === 'tool' && m.pluginIntervention?.status === 'pending',
|
||||
);
|
||||
// Scope pending check to the current assistant turn so stale
|
||||
// `pending` rows from prior turns can never block the loop.
|
||||
const pendingToolMessages = this.getCurrentTurnPendingToolMessages(state);
|
||||
|
||||
// If there are pending tools, wait for human approval
|
||||
if (pendingToolMessages.length > 0) {
|
||||
|
||||
@@ -667,12 +667,32 @@ describe('GeneralChatAgent', () => {
|
||||
type: 'default',
|
||||
};
|
||||
|
||||
// Pending tool messages must hang off the *current* assistant turn for
|
||||
// the runner to treat them as live (otherwise they're treated as stale
|
||||
// history). Mirror the real persisted shape: assistant carries
|
||||
// `tool_calls`, pending tool message carries `parentId`.
|
||||
const state = createMockState({
|
||||
messages: [
|
||||
{ role: 'user', content: 'Hello' },
|
||||
{ role: 'assistant', content: '', tools: [] },
|
||||
{ role: 'tool', content: 'Result', tool_call_id: 'call-1' },
|
||||
{
|
||||
id: 'assistant-1',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
tool_calls: [
|
||||
{ id: 'call-1', function: { name: 'plugin-1', arguments: '{}' }, type: 'function' },
|
||||
{ id: 'call-2', function: { name: 'plugin-2', arguments: '{}' }, type: 'function' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'tool-1',
|
||||
parentId: 'assistant-1',
|
||||
role: 'tool',
|
||||
content: 'Result',
|
||||
tool_call_id: 'call-1',
|
||||
},
|
||||
{
|
||||
id: 'tool-2',
|
||||
parentId: 'assistant-1',
|
||||
role: 'tool',
|
||||
content: '',
|
||||
tool_call_id: 'call-2',
|
||||
@@ -695,6 +715,151 @@ describe('GeneralChatAgent', () => {
|
||||
skipCreateToolMessage: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return request_human_approve when current assistant turn stores calls in tools', async () => {
|
||||
const agent = new GeneralChatAgent({
|
||||
agentConfig: { maxSteps: 100 },
|
||||
operationId: 'test-session',
|
||||
modelRuntimeConfig: mockModelRuntimeConfig,
|
||||
});
|
||||
|
||||
const pendingPlugin: ChatToolPayload = {
|
||||
id: 'call-2',
|
||||
identifier: 'plugin-2',
|
||||
apiName: 'api-2',
|
||||
arguments: '{}',
|
||||
type: 'default',
|
||||
};
|
||||
|
||||
const state = createMockState({
|
||||
messages: [
|
||||
{ role: 'user', content: 'Hello' },
|
||||
{
|
||||
id: 'assistant-1',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
tools: [
|
||||
{
|
||||
apiName: 'api-1',
|
||||
arguments: '{}',
|
||||
id: 'call-1',
|
||||
identifier: 'plugin-1',
|
||||
type: 'default',
|
||||
},
|
||||
pendingPlugin,
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'tool-1',
|
||||
parentId: 'assistant-1',
|
||||
role: 'tool',
|
||||
content: 'Result',
|
||||
tool_call_id: 'call-1',
|
||||
},
|
||||
{
|
||||
id: 'tool-2',
|
||||
parentId: 'assistant-1',
|
||||
role: 'tool',
|
||||
content: '',
|
||||
tool_call_id: 'call-2',
|
||||
plugin: pendingPlugin,
|
||||
pluginIntervention: { status: 'pending' },
|
||||
},
|
||||
] as any,
|
||||
});
|
||||
|
||||
const context = createMockContext('tool_result', {
|
||||
parentMessageId: 'tool-msg-1',
|
||||
});
|
||||
|
||||
const result = await agent.runner(context, state);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'request_human_approve',
|
||||
pendingToolsCalling: [pendingPlugin],
|
||||
reason: 'Some tools still pending approval',
|
||||
skipCreateToolMessage: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should ignore stale pending tool messages from a previous assistant turn', async () => {
|
||||
// Regression: before scoping, a previous turn's never-resolved
|
||||
// `pluginIntervention.status === 'pending'` row would be loaded back
|
||||
// into state.messages via historyMessages and hijack every subsequent
|
||||
// tool_result phase, parking the loop in waiting_for_human forever.
|
||||
const agent = new GeneralChatAgent({
|
||||
agentConfig: { maxSteps: 100 },
|
||||
operationId: 'test-session',
|
||||
modelRuntimeConfig: mockModelRuntimeConfig,
|
||||
});
|
||||
|
||||
const stalePendingPlugin: ChatToolPayload = {
|
||||
id: 'old-call-1',
|
||||
identifier: 'plugin-old',
|
||||
apiName: 'api-old',
|
||||
arguments: '{}',
|
||||
type: 'default',
|
||||
};
|
||||
|
||||
const state = createMockState({
|
||||
messages: [
|
||||
// Previous turn — abandoned, leaves a pending tool message behind.
|
||||
{ role: 'user', content: 'old prompt' },
|
||||
{
|
||||
id: 'old-assistant',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'old-call-1',
|
||||
function: { name: 'plugin-old', arguments: '{}' },
|
||||
type: 'function',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'old-tool-1',
|
||||
parentId: 'old-assistant',
|
||||
role: 'tool',
|
||||
content: '',
|
||||
tool_call_id: 'old-call-1',
|
||||
plugin: stalePendingPlugin,
|
||||
pluginIntervention: { status: 'pending' },
|
||||
},
|
||||
// Current turn — assistant called a different tool and it succeeded.
|
||||
{ role: 'user', content: 'new prompt' },
|
||||
{
|
||||
id: 'current-assistant',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'new-call-1',
|
||||
function: { name: 'plugin-new', arguments: '{}' },
|
||||
type: 'function',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'current-tool-1',
|
||||
parentId: 'current-assistant',
|
||||
role: 'tool',
|
||||
content: 'OK',
|
||||
tool_call_id: 'new-call-1',
|
||||
},
|
||||
] as any,
|
||||
});
|
||||
|
||||
const context = createMockContext('tool_result', {
|
||||
parentMessageId: 'current-tool-1',
|
||||
});
|
||||
|
||||
const result = await agent.runner(context, state);
|
||||
|
||||
// The loop must continue with another LLM call, NOT get hijacked into
|
||||
// request_human_approve by the stale pending row from the prior turn.
|
||||
expect((result as any).type).toBe('call_llm');
|
||||
});
|
||||
});
|
||||
|
||||
describe('tools_batch_result phase', () => {
|
||||
@@ -747,13 +912,120 @@ describe('GeneralChatAgent', () => {
|
||||
type: 'default',
|
||||
};
|
||||
|
||||
// Pending tool messages must hang off the *current* assistant turn for
|
||||
// the runner to treat them as live (otherwise they're treated as stale
|
||||
// history). Mirror the real persisted shape: assistant carries
|
||||
// `tool_calls`, pending tool message carries `parentId`.
|
||||
const state = createMockState({
|
||||
messages: [
|
||||
{ role: 'user', content: 'Hello' },
|
||||
{ role: 'assistant', content: '', tools: [] },
|
||||
{ role: 'tool', content: 'Result 1', tool_call_id: 'call-1' },
|
||||
{ role: 'tool', content: 'Result 2', tool_call_id: 'call-2' },
|
||||
{
|
||||
id: 'assistant-1',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
tool_calls: [
|
||||
{ id: 'call-1', function: { name: 'plugin-1', arguments: '{}' }, type: 'function' },
|
||||
{ id: 'call-2', function: { name: 'plugin-2', arguments: '{}' }, type: 'function' },
|
||||
{ id: 'call-3', function: { name: 'plugin-3', arguments: '{}' }, type: 'function' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'tool-1',
|
||||
parentId: 'assistant-1',
|
||||
role: 'tool',
|
||||
content: 'Result 1',
|
||||
tool_call_id: 'call-1',
|
||||
},
|
||||
{
|
||||
id: 'tool-2',
|
||||
parentId: 'assistant-1',
|
||||
role: 'tool',
|
||||
content: 'Result 2',
|
||||
tool_call_id: 'call-2',
|
||||
},
|
||||
{
|
||||
id: 'tool-3',
|
||||
parentId: 'assistant-1',
|
||||
role: 'tool',
|
||||
content: '',
|
||||
tool_call_id: 'call-3',
|
||||
plugin: pendingPlugin,
|
||||
pluginIntervention: { status: 'pending' },
|
||||
},
|
||||
] as any,
|
||||
});
|
||||
|
||||
const context = createMockContext('tools_batch_result', {
|
||||
parentMessageId: 'tool-msg-2',
|
||||
});
|
||||
|
||||
const result = await agent.runner(context, state);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'request_human_approve',
|
||||
pendingToolsCalling: [pendingPlugin],
|
||||
reason: 'Some tools still pending approval',
|
||||
skipCreateToolMessage: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return request_human_approve when batch current assistant turn stores calls in tools', async () => {
|
||||
const agent = new GeneralChatAgent({
|
||||
agentConfig: { maxSteps: 100 },
|
||||
operationId: 'test-session',
|
||||
modelRuntimeConfig: mockModelRuntimeConfig,
|
||||
});
|
||||
|
||||
const pendingPlugin: ChatToolPayload = {
|
||||
id: 'call-3',
|
||||
identifier: 'plugin-3',
|
||||
apiName: 'api-3',
|
||||
arguments: '{}',
|
||||
type: 'default',
|
||||
};
|
||||
|
||||
const state = createMockState({
|
||||
messages: [
|
||||
{ role: 'user', content: 'Hello' },
|
||||
{
|
||||
id: 'assistant-1',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
tools: [
|
||||
{
|
||||
apiName: 'api-1',
|
||||
arguments: '{}',
|
||||
id: 'call-1',
|
||||
identifier: 'plugin-1',
|
||||
type: 'default',
|
||||
},
|
||||
{
|
||||
apiName: 'api-2',
|
||||
arguments: '{}',
|
||||
id: 'call-2',
|
||||
identifier: 'plugin-2',
|
||||
type: 'default',
|
||||
},
|
||||
pendingPlugin,
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'tool-1',
|
||||
parentId: 'assistant-1',
|
||||
role: 'tool',
|
||||
content: 'Result 1',
|
||||
tool_call_id: 'call-1',
|
||||
},
|
||||
{
|
||||
id: 'tool-2',
|
||||
parentId: 'assistant-1',
|
||||
role: 'tool',
|
||||
content: 'Result 2',
|
||||
tool_call_id: 'call-2',
|
||||
},
|
||||
{
|
||||
id: 'tool-3',
|
||||
parentId: 'assistant-1',
|
||||
role: 'tool',
|
||||
content: '',
|
||||
tool_call_id: 'call-3',
|
||||
|
||||
@@ -11,9 +11,21 @@
|
||||
*/
|
||||
export type AgentHookType =
|
||||
| 'afterStep' // After each step completes
|
||||
| 'afterToolCall' // After a tool call completes (observation only)
|
||||
| 'beforeStep' // Before each step executes
|
||||
| 'beforeToolCall' // Before a tool call executes (supports mocking via event.mock())
|
||||
| 'beforeCallAgent' // Before calling a sub-agent
|
||||
| 'afterCallAgent' // After sub-agent completes
|
||||
| 'beforeCompact' // Before context compression starts
|
||||
| 'beforeHumanIntervention' // Before agent pauses for human approval
|
||||
| 'afterCompact' // After context compression completes
|
||||
| 'afterHumanIntervention' // After human approves/rejects and agent resumes
|
||||
| 'onCallAgentError' // Sub-agent execution failed
|
||||
| 'onCompactError' // Context compression failed
|
||||
| 'onComplete' // Operation reaches terminal state (done/error/interrupted)
|
||||
| 'onError'; // Error during execution
|
||||
| 'onStopByHumanIntervention' // Human rejected and agent halted
|
||||
| 'onError' // Error during execution
|
||||
| 'onToolCallError'; // Tool call threw an exception (not just success=false)
|
||||
|
||||
/**
|
||||
* Unified event payload passed to hook handlers and webhook payloads
|
||||
@@ -90,3 +102,145 @@ export interface AgentHookEvent {
|
||||
|
||||
userId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event payload for beforeToolCall hooks.
|
||||
* Call `mock()` to skip real tool execution and return a fake result.
|
||||
*/
|
||||
export interface ToolCallHookEvent {
|
||||
apiName: string;
|
||||
args: Record<string, any>;
|
||||
callIndex: number;
|
||||
identifier: string;
|
||||
mock: (result: { content: string }) => void;
|
||||
operationId: string;
|
||||
stepIndex: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event payload for beforeToolCall observation dispatch (webhook/logging).
|
||||
* Same fields as ToolCallHookEvent but without mock() — used for production webhook delivery.
|
||||
*/
|
||||
export interface BeforeToolCallObservationEvent {
|
||||
apiName: string;
|
||||
args: Record<string, any>;
|
||||
callIndex: number;
|
||||
identifier: string;
|
||||
operationId: string;
|
||||
stepIndex: number;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export interface AfterToolCallHookEvent {
|
||||
apiName: string;
|
||||
args: Record<string, any>;
|
||||
callIndex: number;
|
||||
content: string;
|
||||
executionTimeMs: number;
|
||||
identifier: string;
|
||||
mocked: boolean;
|
||||
operationId: string;
|
||||
stepIndex: number;
|
||||
success: boolean;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export interface ToolCallErrorHookEvent {
|
||||
apiName: string;
|
||||
args: Record<string, any>;
|
||||
callIndex: number;
|
||||
error: string;
|
||||
identifier: string;
|
||||
operationId: string;
|
||||
stepIndex: number;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export interface BeforeCompactHookEvent {
|
||||
messageCount: number;
|
||||
operationId: string;
|
||||
stepIndex: number;
|
||||
tokenCount: number;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export interface AfterCompactHookEvent {
|
||||
groupId: string;
|
||||
messagesAfter: number;
|
||||
messagesBefore: number;
|
||||
operationId: string;
|
||||
stepIndex: number;
|
||||
summary: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export interface CompactErrorHookEvent {
|
||||
error: string;
|
||||
operationId: string;
|
||||
stepIndex: number;
|
||||
tokenCount: number;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export interface BeforeHumanInterventionHookEvent {
|
||||
operationId: string;
|
||||
pendingTools: Array<{ apiName: string; identifier: string }>;
|
||||
stepIndex: number;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export interface AfterHumanInterventionHookEvent {
|
||||
action: 'approve' | 'reject' | 'rejectAndContinue';
|
||||
operationId: string;
|
||||
rejectionReason?: string;
|
||||
toolCallId?: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export interface StopByHumanInterventionHookEvent {
|
||||
operationId: string;
|
||||
rejectionReason?: string;
|
||||
toolCallId?: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export interface BeforeCallAgentHookEvent {
|
||||
agentId: string;
|
||||
instruction: string;
|
||||
operationId: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export interface AfterCallAgentHookEvent {
|
||||
agentId: string;
|
||||
operationId: string;
|
||||
subOperationId: string;
|
||||
success: boolean;
|
||||
threadId: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export interface CallAgentErrorHookEvent {
|
||||
agentId: string;
|
||||
error: string;
|
||||
operationId: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Union of all hook event types for dispatch methods that accept any hook event.
|
||||
*/
|
||||
export type AnyHookEvent =
|
||||
| AfterCallAgentHookEvent
|
||||
| AfterCompactHookEvent
|
||||
| AfterHumanInterventionHookEvent
|
||||
| AfterToolCallHookEvent
|
||||
| AgentHookEvent
|
||||
| BeforeCallAgentHookEvent
|
||||
| BeforeCompactHookEvent
|
||||
| BeforeHumanInterventionHookEvent
|
||||
| BeforeToolCallObservationEvent
|
||||
| CallAgentErrorHookEvent
|
||||
| CompactErrorHookEvent
|
||||
| StopByHumanInterventionHookEvent
|
||||
| ToolCallErrorHookEvent;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user