mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-17 21:08:36 +00:00
Compare commits
80 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d450efd3b2 | |||
| ab2e95ad7d | |||
| 9ffd9d39e3 | |||
| f1ea407d74 | |||
| 98d77166cc | |||
| 9821328d43 | |||
| 980c2e74d8 | |||
| 84598524df | |||
| 5e2ef88c13 | |||
| 403de538d6 | |||
| 8949e89535 | |||
| 8aa075cd80 | |||
| 9cc5f9e1a0 | |||
| bcf97d9487 | |||
| 3e4b81d2cc | |||
| 651d1a203a | |||
| 4c29515e4c | |||
| b4b1205ee9 | |||
| 8c0e66b633 | |||
| 1ae8498fc7 | |||
| c4b147554b | |||
| 5fb1f339a7 | |||
| 81fc1aaf7f | |||
| b14f1dba5c | |||
| 1d2b32bafc | |||
| 347e2eec0c | |||
| e8275a93ff | |||
| 49d191d2a7 | |||
| 35052416cc | |||
| 0c5ccc8770 | |||
| c8ff3ac43d | |||
| 718096e306 | |||
| f0eded2941 | |||
| 1f6d350dca | |||
| 5eee6d21e3 | |||
| bcc31ca331 | |||
| 72d34046c0 | |||
| 60f08f58e4 | |||
| 202f062a0d | |||
| be81c35e94 | |||
| 14357a3f51 | |||
| 0561a1d7eb | |||
| 3e0a396968 | |||
| 5f27cd8f26 | |||
| 1c80146a07 | |||
| 1d4d5c1c73 | |||
| d45257615a | |||
| b3cbc9a710 | |||
| e295f80235 | |||
| 5cd02b937b | |||
| cce2741de3 | |||
| 362d137a2b | |||
| 6859ee2374 | |||
| d6e641b790 | |||
| 2ee53bcd60 | |||
| 8b96d14347 | |||
| 248d6ecf76 | |||
| d4e8d6df6e | |||
| 4c6a3999c1 | |||
| 506b96af64 | |||
| 924ae8bf1f | |||
| 302755057e | |||
| eea9464b04 | |||
| 82cc885394 | |||
| e4ad195df9 | |||
| 47b6f3503a | |||
| bb4924fc5b | |||
| 46f884d5ed | |||
| 0fcc21895e | |||
| 3c52998157 | |||
| 8d4c48749f | |||
| 26aa28c263 | |||
| f3d5d03cf5 | |||
| d71686ba88 | |||
| f16c280e93 | |||
| be62847e00 | |||
| a8faccff66 | |||
| 63d8e07453 | |||
| 44e69af6cc | |||
| eedf46a11d |
@@ -1,13 +1,6 @@
|
||||
---
|
||||
name: chat-sdk
|
||||
description: >
|
||||
Build multi-platform chat bots with Chat SDK (`chat` npm package). Use when developers want to
|
||||
(1) Build a Slack, Teams, Google Chat, Discord, GitHub, or Linear bot,
|
||||
(2) Use the Chat SDK to handle mentions, messages, reactions, slash commands, cards, modals, or streaming,
|
||||
(3) Set up webhook handlers for chat platforms,
|
||||
(4) Send interactive cards or stream AI responses to chat platforms.
|
||||
Triggers on "chat sdk", "chat bot", "slack bot", "teams bot", "discord bot", "@chat-adapter",
|
||||
building bots that work across multiple chat platforms.
|
||||
description: "Build multi-platform chat bots with the Chat SDK (`chat` npm package) — Slack, Teams, Google Chat, Discord, GitHub, Linear. Use when building a chat bot, handling mentions / messages / reactions / slash commands / cards / modals / streaming, setting up a webhook handler, or sending interactive cards / streaming AI responses to a chat platform. Triggers on `@chat-adapter`, 'chat sdk', 'chat bot', 'slack bot', 'teams bot', 'discord bot', 'webhook handler', 'cross-platform bot'."
|
||||
user-invocable: false
|
||||
---
|
||||
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: data-fetching
|
||||
description: Data fetching architecture guide using Service layer + Zustand Store + SWR. Use when implementing data fetching, creating services, working with store hooks, or migrating from useEffect. Triggers on data loading, API calls, service creation, or store data fetching tasks.
|
||||
name: data-fetching-architecture
|
||||
description: Standardized data-fetching pipeline guide — Service layer + Zustand Store + SWR. Use when implementing a data-fetching feature, creating a `xxxService`, adding a `useFetchXxx` hook, wiring `useClientDataSWR`, or migrating ad-hoc `useEffect + fetch` to the standard pipeline. Triggers on `lambdaClient`, `useClientDataSWR`, `xxxService`, `useFetchXxx`, 'data fetching', 'fetch architecture', 'service layer', 'SWR hook', 'migrate useEffect'.
|
||||
user-invocable: false
|
||||
---
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: docs-changelog
|
||||
description: 'Writing guide for website changelog pages under docs/changelog/*.mdx. Use when creating or editing product update posts in EN/ZH. Not for GitHub Release notes.'
|
||||
description: "Writing guide for website changelog pages under `docs/changelog/*.mdx` (NOT GitHub Release notes — those live in the `version-release` skill). Use when creating or editing a product update post in EN/ZH. Triggers on `docs/changelog/*.mdx`, 'changelog post', 'product update post', 'add a changelog', '更新日志', 'changelog 文案'."
|
||||
---
|
||||
|
||||
# Docs Changelog Writing Guide
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: pr
|
||||
description: "Create a PR for the current branch. Use when the user asks to create a pull request, submit PR, or says 'pr'."
|
||||
description: "Create a PR for the current branch (targets `canary` by default). Use when the user asks to create a pull request, submit a PR, or says 'pr'. Triggers on 'pr', 'create pr', 'submit pr', 'open a PR', 'pull request', '提 PR', '提个 PR', '新建 PR'."
|
||||
user-invocable: true
|
||||
---
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: project-overview
|
||||
description: Complete project architecture and structure guide. Use when exploring the codebase, understanding project organization, finding files, or needing comprehensive architectural context. Triggers on architecture questions, directory navigation, or project overview needs.
|
||||
description: "LobeHub open-source monorepo architecture map — flat `apps/` + `packages/@lobechat/*` + `src/` layout, per-layer location table, and `src/business/` stubs that the cloud repo overrides. Use when exploring an unfamiliar part of the codebase, locating where a layer lives (store / service / router / schema / etc.), or onboarding to the monorepo. Triggers on 'where does X live', 'project structure', 'monorepo layout', `src/business/` stub, 'architecture overview', '项目结构', '架构总览'."
|
||||
user-invocable: false
|
||||
---
|
||||
|
||||
@@ -13,11 +13,12 @@ user-invocable: false
|
||||
## Project Description
|
||||
|
||||
Open-source, modern-design AI Agent Workspace: **LobeHub** (previously LobeChat).
|
||||
This repo is the **open-source root** (`github.com/lobehub/lobehub`, package `@lobehub/lobehub`).
|
||||
|
||||
**Supported platforms:**
|
||||
|
||||
- Web desktop/mobile
|
||||
- Desktop (Electron)
|
||||
- Desktop (Electron) — `apps/desktop`
|
||||
- Mobile app (React Native) — **separate repo, already launched** (not in this monorepo)
|
||||
|
||||
**Logo emoji:** 🤯
|
||||
@@ -47,30 +48,28 @@ Open-source, modern-design AI Agent Workspace: **LobeHub** (previously LobeChat)
|
||||
|
||||
## Monorepo Layout
|
||||
|
||||
This is a monorepo extending the open-source `lobehub` submodule. Two repos:
|
||||
|
||||
- **cloud repo root** — `src/` and `packages/business/` (`config`, `const`, `model-runtime`) hold cloud-only SaaS code that overrides/extends the submodule. See `AGENTS.md` for the override mechanism.
|
||||
- **`lobehub/` submodule** — the open-source product core.
|
||||
|
||||
### `lobehub/` submodule — key directories
|
||||
Flat layout — `apps/`, `packages/`, and `src/` all sit at the repo root. No
|
||||
git submodules.
|
||||
|
||||
```
|
||||
lobehub/
|
||||
(repo root)
|
||||
├── apps/
|
||||
│ ├── cli/ # LobeHub CLI
|
||||
│ ├── desktop/ # Electron desktop app
|
||||
│ └── device-gateway/ # Device gateway service
|
||||
├── docs/ # changelog, development, self-hosting, usage
|
||||
├── locales/ # en-US, zh-CN, ...
|
||||
├── packages/ # ~80 @lobechat/* workspace packages — `ls` for the full set. Key ones:
|
||||
│ ├── agent-runtime/ # Agent runtime
|
||||
│ ├── cli/ # LobeHub CLI
|
||||
│ ├── desktop/ # Electron desktop app
|
||||
│ └── device-gateway/ # Device gateway service
|
||||
├── docs/ # changelog, development, self-hosting, usage
|
||||
├── locales/ # en-US, zh-CN, ...
|
||||
├── packages/ # ~80 @lobechat/* workspace packages — `ls` for the full set. Key ones:
|
||||
│ ├── agent-runtime/ # Agent runtime core
|
||||
│ ├── agent-signal/ # Agent Signal pipeline
|
||||
│ ├── builtin-tool-*/ # Builtin tool packages
|
||||
│ ├── builtin-tools/ # Builtin tool registries
|
||||
│ ├── agent-tracing/ # Tracing / snapshots
|
||||
│ ├── builtin-tool-*/ # Per-tool packages (calculator, web-browsing, claude-code, ...)
|
||||
│ ├── builtin-tools/ # Central registries that compose builtin-tool-*
|
||||
│ ├── context-engine/
|
||||
│ ├── database/ # src/{models,schemas,repositories}
|
||||
│ ├── model-bank/ # Model definitions & provider cards
|
||||
│ ├── model-runtime/ # src/{core,providers}
|
||||
│ ├── business/ # Open-source stubs (config, const, model-bank, model-runtime) — overridden by cloud
|
||||
│ ├── types/
|
||||
│ └── utils/
|
||||
└── src/
|
||||
@@ -83,55 +82,54 @@ lobehub/
|
||||
├── spa/ # SPA entries + router config
|
||||
│ ├── entry.{web,mobile,desktop,popup}.tsx
|
||||
│ └── router/
|
||||
├── business/ # Open-source stubs (~50) overridden by cloud src/business/
|
||||
├── business/ # Open-source stubs (client/server) — cloud repo provides real impls
|
||||
├── features/ # Domain business components
|
||||
├── store/ # ~28 zustand stores — `ls` for the full set
|
||||
├── server/ # featureFlags, globalConfig, modules, routers, services
|
||||
├── store/ # ~30 zustand stores — `ls` for the full set
|
||||
├── server/ # featureFlags, globalConfig, modules, routers, services, workflows, agent-hono
|
||||
└── ... # components, hooks, layout, libs, locales, services, types, utils
|
||||
```
|
||||
|
||||
### cloud repo — key directories
|
||||
|
||||
```
|
||||
(cloud root)
|
||||
├── packages/business/ # Cloud overrides: config, const, model-runtime
|
||||
├── src/
|
||||
│ ├── business/ # Cloud impls of submodule stubs (client/server/locales)
|
||||
│ ├── routes/ # Cloud-only route groups: (cloud)/, embed/
|
||||
│ ├── store/ # Cloud-only stores (e.g. subscription/)
|
||||
│ ├── server/ # Cloud routers & services (billing, budget, risk control...)
|
||||
│ └── app/(backend)/cron/ # Vercel cron routes (schedules declared in root vercel.ts)
|
||||
└── vercel.ts # Cron schedule declarations
|
||||
```
|
||||
|
||||
> File search rule: a path like `@/store/x` resolves cloud `src/store/x` first, then
|
||||
> `lobehub/packages/store/src/x`, then `lobehub/src/store/x`. Cloud override wins.
|
||||
|
||||
## Architecture Map
|
||||
|
||||
| Layer | Location |
|
||||
| ---------------- | ---------------------------------------------------- |
|
||||
| UI Components | `src/components`, `src/features` |
|
||||
| SPA Pages | `src/routes/` |
|
||||
| React Router | `src/spa/router/` |
|
||||
| Global Providers | `src/layout` |
|
||||
| Zustand Stores | `src/store` |
|
||||
| Client Services | `src/services/` |
|
||||
| REST API | `src/app/(backend)/webapi` |
|
||||
| tRPC Routers | `src/server/routers/{async\|lambda\|mobile\|tools}` |
|
||||
| Server Services | `src/server/services` (can access DB) |
|
||||
| Server Modules | `src/server/modules` (no DB access) |
|
||||
| Feature Flags | `src/server/featureFlags` |
|
||||
| Global Config | `src/server/globalConfig` |
|
||||
| DB Schema | `packages/database/src/schemas` |
|
||||
| DB Model | `packages/database/src/models` |
|
||||
| DB Repository | `packages/database/src/repositories` |
|
||||
| Third-party | `src/libs` (analytics, oidc, etc.) |
|
||||
| Builtin Tools | `src/tools`, `packages/builtin-tool-*` |
|
||||
| Cloud-only | `src/business/*`, `packages/business/*` (cloud repo) |
|
||||
| Layer | Location |
|
||||
| ---------------- | --------------------------------------------------- |
|
||||
| UI Components | `src/components`, `src/features` |
|
||||
| SPA Pages | `src/routes/` |
|
||||
| React Router | `src/spa/router/` |
|
||||
| Global Providers | `src/layout` |
|
||||
| Zustand Stores | `src/store` |
|
||||
| Client Services | `src/services/` |
|
||||
| REST API | `src/app/(backend)/webapi` |
|
||||
| tRPC Routers | `src/server/routers/{async\|lambda\|mobile\|tools}` |
|
||||
| Server Services | `src/server/services` (can access DB) |
|
||||
| Server Modules | `src/server/modules` (no DB access) |
|
||||
| Feature Flags | `src/server/featureFlags` |
|
||||
| Global Config | `src/server/globalConfig` |
|
||||
| DB Schema | `packages/database/src/schemas` |
|
||||
| DB Model | `packages/database/src/models` |
|
||||
| DB Repository | `packages/database/src/repositories` |
|
||||
| Third-party | `src/libs` (analytics, oidc, etc.) |
|
||||
| Builtin Tools | `packages/builtin-tool-*`, `packages/builtin-tools` |
|
||||
| Open-source stub | `src/business/*`, `packages/business/*` (this repo) |
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
React UI → Store Actions → Client Service → TRPC Lambda → Server Services → DB Model → PostgreSQL
|
||||
```
|
||||
|
||||
## Note: Relationship to the Cloud Repo
|
||||
|
||||
This open-source repo is consumed by a **separate, private cloud (SaaS) repo**
|
||||
as a git submodule mounted at `lobehub/`. The cloud repo provides:
|
||||
|
||||
- **`src/business/{client,server}`** and **`packages/business/*`** implementations
|
||||
that override the stubs shipped here.
|
||||
- Cloud-only routes (e.g. `(cloud)/`, `embed/`), cloud-only stores (e.g.
|
||||
`subscription/`), cloud-only TRPC routers (billing, budget, risk control, …),
|
||||
and Vercel cron routes under `src/app/(backend)/cron/`.
|
||||
- File-resolution order in cloud: `@/store/x` → cloud `src/store/x` first, then
|
||||
`lobehub/packages/store/src/x`, then `lobehub/src/store/x`. **Cloud override wins.**
|
||||
|
||||
When working in this repo alone, ignore the cloud layer — the stubs in
|
||||
`src/business/` and `packages/business/` are the source of truth here.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: react
|
||||
description: 'Use when writing or editing any `.tsx` under `src/**`. Triggers: createStaticStyles, createStyles, cssVar, antd-style, Flexbox, Center, Select, Modal, Drawer, Button, Tooltip, DropdownMenu, Popover, Switch, ScrollArea, Link, useNavigate, react-router-dom, next/link, desktopRouter, componentMap.desktop, .desktop.tsx, new component, new page, edit layout, add styles, zustand selector, @lobehub/ui, antd import.'
|
||||
description: "LobeHub React component conventions — base-ui (`@lobehub/ui/base-ui`) first for headless primitives (Select, Modal, DropdownMenu, ContextMenu, Popover, ScrollArea, Switch, Toast, FloatingSheet), then `@lobehub/ui` root, antd as last resort; styling via `antd-style` `createStaticStyles` + `cssVar.*` (zero-runtime preferred over `createStyles` + `token`); routing via `react-router-dom` (not `next/link`). Use when writing or editing any `.tsx` under `src/**`. Triggers on `createStaticStyles`, `createStyles`, `cssVar`, `antd-style`, `Flexbox`, `Center`, `Select`, `Modal`, `Drawer`, `Button`, `Tooltip`, `DropdownMenu`, `ContextMenu`, `Popover`, `Switch`, `ScrollArea`, `Toast`, `FloatingSheet`, `Link`, `useNavigate`, `react-router-dom`, `next/link`, `desktopRouter`, `componentMap.desktop`, `.desktop.tsx`, `base-ui`, `@lobehub/ui/base-ui`, 'new component', 'new page', 'edit layout', 'add styles', 'zustand selector', '@lobehub/ui', 'antd import'."
|
||||
user-invocable: false
|
||||
---
|
||||
|
||||
@@ -17,22 +17,41 @@ user-invocable: false
|
||||
## Component Priority
|
||||
|
||||
1. **`src/components`** — project-specific reusable components
|
||||
2. **`@lobehub/ui/base-ui`** — headless primitives (Select, Modal, DropdownMenu, Popover, Switch, ScrollArea…)
|
||||
3. **`@lobehub/ui`** — higher-level components (ActionIcon, Markdown, DragPage…)
|
||||
4. **Custom implementation** — last resort; never reach for antd directly
|
||||
2. **`@lobehub/ui/base-ui`** — headless primitives. **If the component lives here, use it. Do NOT import the same-named root export.**
|
||||
3. **`@lobehub/ui`** — higher-level / antd-wrapping components (only when no base-ui equivalent)
|
||||
4. **antd** — only when neither base-ui nor `@lobehub/ui` root provides it
|
||||
5. **Custom implementation** — true last resort
|
||||
|
||||
If unsure about available components, search existing code or check `node_modules/@lobehub/ui/es/index.mjs`.
|
||||
If unsure about available components, search existing code or check `node_modules/@lobehub/ui/es/index.mjs` and `node_modules/@lobehub/ui/es/base-ui/`.
|
||||
|
||||
### Common @lobehub/ui Components
|
||||
### `@lobehub/ui/base-ui` — always prefer for these
|
||||
|
||||
| Category | Components |
|
||||
| ------------ | ------------------------------------------------------------------------------- |
|
||||
| General | ActionIcon, ActionIconGroup, Block, Button, Icon |
|
||||
| Data Display | Avatar, Collapse, Empty, Highlighter, Markdown, Tag, Tooltip |
|
||||
| Data Entry | CodeEditor, CopyButton, EditableText, Form, FormModal, Input, SearchBar, Select |
|
||||
| Feedback | Alert, Drawer, Modal |
|
||||
| Layout | Center, DraggablePanel, Flexbox, Grid, Header, MaskShadow |
|
||||
| Navigation | Burger, Dropdown, Menu, SideNav, Tabs |
|
||||
| Component | Import |
|
||||
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------- |
|
||||
| `Select` (+ `SelectProps`, `SelectOption`) | `import { Select } from '@lobehub/ui/base-ui';` |
|
||||
| `Modal` (imperative API) | `import { createModal, confirmModal, useModalContext, type ModalInstance } from '@lobehub/ui/base-ui';` |
|
||||
| `DropdownMenu` | `import { DropdownMenu } from '@lobehub/ui/base-ui';` |
|
||||
| `ContextMenu` | `import { ContextMenu } from '@lobehub/ui/base-ui';` |
|
||||
| `Popover` | `import { Popover } from '@lobehub/ui/base-ui';` |
|
||||
| `ScrollArea` | `import { ScrollArea } from '@lobehub/ui/base-ui';` |
|
||||
| `Switch` | `import { Switch } from '@lobehub/ui/base-ui';` |
|
||||
| `Toast` | `import { Toast } from '@lobehub/ui/base-ui';` |
|
||||
| `FloatingSheet` | `import { FloatingSheet } from '@lobehub/ui/base-ui';` |
|
||||
|
||||
For Modal specifically, see the dedicated **modal** skill — use the imperative `createModal({ content: … })` pattern over the legacy `<Modal open … />` declarative pattern. base-ui has its own `ModalHost` already mounted in `SPAGlobalProvider`.
|
||||
|
||||
> Common slip: `import { Select } from '@lobehub/ui'` looks fine but it's the antd-backed Select. Use base-ui Select. Same for `Modal`, `DropdownMenu`, etc.
|
||||
|
||||
### `@lobehub/ui` root — use when base-ui has no equivalent
|
||||
|
||||
| Category | Components |
|
||||
| ------------ | ------------------------------------------------------------------------------------- |
|
||||
| General | ActionIcon, ActionIconGroup, Block, Button, Icon |
|
||||
| Data Display | Avatar, Collapse, Empty, Highlighter, Markdown, Tag, Tooltip |
|
||||
| Data Entry | CodeEditor, CopyButton, EditableText, Form, Input, InputPassword, SearchBar, TextArea |
|
||||
| Feedback | Alert, Drawer |
|
||||
| Layout | Center, DraggablePanel, Flexbox, Grid, Header, MaskShadow |
|
||||
| Navigation | Burger, Menu, SideNav, Tabs |
|
||||
|
||||
## Layout
|
||||
|
||||
@@ -85,12 +104,15 @@ errorElement: <ErrorBoundary />;
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
| Mistake | Fix |
|
||||
| ----------------------------------------------------------------- | ----------------------------------------------------------------- |
|
||||
| Using `next/link` in SPA | Use `react-router-dom` `Link` |
|
||||
| Using antd directly | Use `@lobehub/ui/base-ui` first, then `@lobehub/ui` |
|
||||
| `createStyles` for static styles | Use `createStaticStyles` + `cssVar` |
|
||||
| Editing only `desktopRouter.config.tsx` | Must edit both `.tsx` and `.desktop.tsx` |
|
||||
| Using `margin` for flex spacing | Use `gap` prop on Flexbox |
|
||||
| Accessing zustand store without selector | Use selectors to access store data (see zustand skill) |
|
||||
| Text or icon-text actions built with `Flexbox`/`Text` + `onClick` | Use `Button type={'text'} size={'small'}` with `icon` when needed |
|
||||
| Mistake | Fix |
|
||||
| ------------------------------------------------------------------ | --------------------------------------------------------------------------- |
|
||||
| Using `next/link` in SPA | Use `react-router-dom` `Link` |
|
||||
| Using antd directly | Use `@lobehub/ui/base-ui` first, then `@lobehub/ui` |
|
||||
| `import { Select } from '@lobehub/ui'` | `import { Select } from '@lobehub/ui/base-ui'` |
|
||||
| `import { Modal } from '@lobehub/ui'` + `<Modal open>` declarative | `createModal` / `confirmModal` from `@lobehub/ui/base-ui` (see modal skill) |
|
||||
| `import { DropdownMenu/Popover/Switch } from '@lobehub/ui'` | Import same name from `@lobehub/ui/base-ui` instead |
|
||||
| `createStyles` for static styles | Use `createStaticStyles` + `cssVar` |
|
||||
| Editing only `desktopRouter.config.tsx` | Must edit both `.tsx` and `.desktop.tsx` |
|
||||
| Using `margin` for flex spacing | Use `gap` prop on Flexbox |
|
||||
| Accessing zustand store without selector | Use selectors to access store data (see zustand skill) |
|
||||
| Text or icon-text actions built with `Flexbox`/`Text` + `onClick` | Use `Button type={'text'} size={'small'}` with `icon` when needed |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: review-checklist
|
||||
description: 'Common recurring mistakes in LobeHub code review — console leftovers, missing return await, hardcoded secrets, hardcoded i18n strings, desktop router pair drift, antd vs @lobehub/ui, non-idempotent migrations, cloud impact red flags. Use as a quick checklist when reviewing PRs, diffs, or branch changes.'
|
||||
description: "Common recurring mistakes in LobeHub code review — `console.*` leftovers, missing `return await`, hardcoded secrets, hardcoded i18n strings, desktop router pair drift, antd vs `@lobehub/ui`, non-idempotent migrations, cloud impact red flags. Use as a quick checklist when reviewing a PR, diff, or branch change. Triggers on 'code review', 'review the diff', 'review this PR', 'review changes', 'PR review checklist', '审一下', '审 PR'."
|
||||
user-invocable: false
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
---
|
||||
name: skills-audit
|
||||
description: Weekly audit of `.agents/skills/*/SKILL.md` — surfaces duplicate / overlapping / stale skills, inconsistent descriptions, broken cross-references, and merge/delete candidates. Run as a recurring health-check, not during normal feature work.
|
||||
disable-model-invocation: true
|
||||
argument-hint: '[--verbose | --apply]'
|
||||
---
|
||||
|
||||
# Skills Audit
|
||||
|
||||
Periodic review of the project-local skill set under `.agents/skills/`. The goal is to catch drift before the catalog becomes confusing — too many skills, overlapping triggers, descriptions that no longer match the body, references to skills that were renamed/deleted.
|
||||
|
||||
**Recommended cadence:** weekly, or after any week where >1 skill was added/renamed.
|
||||
|
||||
## Procedure
|
||||
|
||||
### 1 — Inventory
|
||||
|
||||
Build a fresh census of all SKILL.md files. Do NOT trust any prior cached list.
|
||||
|
||||
```bash
|
||||
find .agents/skills -name SKILL.md | wc -l # total count
|
||||
find .agents/skills -name SKILL.md -exec wc -l {} \; | sort -rn # by body length
|
||||
```
|
||||
|
||||
Group by domain in a mental table (DB / state / UI / agent / testing / workflow / docs / etc.). Note new arrivals since last audit (`git log --since="1 week ago" -- .agents/skills/`).
|
||||
|
||||
### 2 — Pull frontmatter for all skills
|
||||
|
||||
```bash
|
||||
# Extract name + description for each SKILL.md
|
||||
for f in .agents/skills/*/SKILL.md; do
|
||||
echo "=== $(basename $(dirname $f)) ==="
|
||||
awk '/^---$/{c++; next} c==1' "$f" | head -20
|
||||
done
|
||||
```
|
||||
|
||||
Read the description block of every skill. The body can stay unread unless step 4 flags it.
|
||||
|
||||
### 3 — Detect overlap / redundancy
|
||||
|
||||
For each pair within the same domain, ask:
|
||||
|
||||
- **Same description**? → likely duplicate (one is probably a stale rename leftover, or a global-vs-local collision).
|
||||
- **Trigger keywords substantially overlap**? → either merge, OR tighten one description so the model can choose unambiguously.
|
||||
- **One skill's body says "see also: foo"**? → confirm `foo` still exists, AND confirm the cross-reference is still meaningful (the referenced skill may have absorbed the referrer's concerns).
|
||||
- **Skill duplicates content from `AGENTS.md`**? → fold into AGENTS.md or slim the skill to just the delta.
|
||||
|
||||
Common false positives (do NOT merge):
|
||||
|
||||
- `db-migrations` vs `drizzle` — distinct workflows (migration files vs schema authoring).
|
||||
- `microcopy` vs `i18n` — content vs mechanics.
|
||||
- `agent-runtime-hooks` vs `agent-tracing` vs `agent-signal` — different surfaces of the agent system.
|
||||
- `testing` vs `local-testing` vs `cli-backend-testing` — different test types.
|
||||
|
||||
### 4 — Description format consistency
|
||||
|
||||
Apply the **standard template**:
|
||||
|
||||
```
|
||||
{Topic + key conventions or scope}. Use when {scenarios — verbs + nouns}. Triggers on {`code-symbols`, 'natural phrases', '中文'}.
|
||||
```
|
||||
|
||||
Skills with `disable-model-invocation: true` (user-invoked only, slash commands) don't need `Triggers on` — they're never auto-routed.
|
||||
|
||||
Flag descriptions that:
|
||||
|
||||
- ❌ Have NO `Use when` clause (model can't decide when to load it).
|
||||
- ❌ Have NO `Triggers on` clause (and aren't `disable-model-invocation`).
|
||||
- ❌ Use weird formats (numbered lists `(1)(2)(3)`, `Triggers:` colon instead of `Triggers on`, `MUST use when ...` as opening word).
|
||||
- ❌ Are dramatically terse for a 200+ line body, or dramatically verbose for a 60-line body.
|
||||
- ❌ Reference deleted/renamed skills.
|
||||
|
||||
### 5 — Stale-skill check
|
||||
|
||||
For narrow domain skills (e.g. `response-compliance`, one-off CLI workflows):
|
||||
|
||||
```bash
|
||||
# Confirm the referenced code surface still exists
|
||||
rg -l "response-compliance|openresponses" packages/ src/ # adjust per skill
|
||||
git log --since="3 months ago" -- .agents/skills/ < skill > /SKILL.md # is it being maintained?
|
||||
```
|
||||
|
||||
If the underlying surface is gone and the skill hasn't been edited in 3+ months → flag for archival.
|
||||
|
||||
### 6 — Cross-reference integrity
|
||||
|
||||
Any skill body mentioning another skill by name:
|
||||
|
||||
```bash
|
||||
# Scan all skill bodies for skill-name references
|
||||
rg -o '`[a-z][a-z0-9-]+`' .agents/skills/*/SKILL.md | grep -v ':\s*$' | sort -u
|
||||
```
|
||||
|
||||
For each name extracted, confirm `.agents/skills/<name>/SKILL.md` exists. Broken references happen after renames — fix them in the same audit pass.
|
||||
|
||||
### 7 — Output report
|
||||
|
||||
Produce a markdown summary back to the user with the same structure as the original audit (this skill was created during one):
|
||||
|
||||
```markdown
|
||||
## 📊 Inventory
|
||||
|
||||
{count, domain breakdown}
|
||||
|
||||
## 🎯 Recommendations
|
||||
|
||||
### 🔴 High confidence
|
||||
|
||||
- {action} — {reason}
|
||||
|
||||
### 🟡 Medium confidence
|
||||
|
||||
- {action} — {reason needs verification}
|
||||
|
||||
### 🟢 Low confidence / no-op
|
||||
|
||||
- {item considered but skipping because ...}
|
||||
|
||||
## 📋 Suggested order
|
||||
|
||||
{table of actions with risk + LOC estimate}
|
||||
```
|
||||
|
||||
End by asking the user which actions to apply — do NOT auto-apply unless the user passed `--apply` and even then confirm destructive deletes individually.
|
||||
|
||||
## Output rules
|
||||
|
||||
- Be specific. "Skill X overlaps with Y" is useless without naming the overlapping triggers.
|
||||
- Cite line numbers when flagging description / body issues.
|
||||
- Don't recommend merges unless the call sites would actually load the merged skill in the same context.
|
||||
- Don't recommend deletes for skills that haven't been touched recently — "unused" can mean "stable", not "dead".
|
||||
|
||||
## What NOT to do
|
||||
|
||||
- ❌ Don't rename skill directories without checking for cross-references AND user memory entries that name the old slug.
|
||||
- ❌ Don't normalize a description by removing trigger keywords just to fit the template — the keywords are the routing signal.
|
||||
- ❌ Don't fold a heavy 200+ line skill into another just because they share a domain — large skills get loaded selectively and merging makes everything load.
|
||||
- ❌ Don't propose `.agents/skills/INDEX.md` or `<domain>-<skill>` prefix renames unless the user explicitly asks — costs > benefits for cosmetic reorgs.
|
||||
|
||||
## Related history
|
||||
|
||||
- First audit: `chore/skills-audit` branch (2026-05-25) — deleted `source-command-dedupe`, renamed `data-fetching` → `data-fetching-architecture`, normalized 9 descriptions, created this skill.
|
||||
@@ -1,44 +0,0 @@
|
||||
---
|
||||
name: 'source-command-dedupe'
|
||||
description: 'Find duplicate GitHub issues'
|
||||
---
|
||||
|
||||
# source-command-dedupe
|
||||
|
||||
Use this skill when the user asks to run the migrated source command `dedupe`.
|
||||
|
||||
## Command Template
|
||||
|
||||
Find up to 3 likely duplicate issues for a given GitHub issue.
|
||||
|
||||
To do this, follow these steps precisely:
|
||||
|
||||
1. Use an agent to check if the Github issue (a) is closed, (b) does not need to be deduped (eg. because it is broad product feedback without a specific solution, or positive feedback), or (c) already has a duplicates comment that you made earlier. If so, do not proceed.
|
||||
2. Use an agent to view a Github issue, and ask the agent to return a summary of the issue
|
||||
3. Then, launch 5 parallel agents to search Github for duplicates of this issue, using diverse keywords and search approaches, using the summary from #1
|
||||
4. Next, feed the results from #1 and #2 into another agent, so that it can filter out false positives, that are likely not actually duplicates of the original issue. If there are no duplicates remaining, do not proceed.
|
||||
5. Finally, comment back on the issue with a list of up to three duplicate issues (or zero, if there are no likely duplicates)
|
||||
|
||||
Notes (be sure to tell this to your agents, too):
|
||||
|
||||
- Use `gh` to interact with Github, rather than web fetch
|
||||
- Do not use other tools, beyond `gh` (eg. don't use other MCP servers, file edit, etc.)
|
||||
- Make a todo list first
|
||||
- For your comment, follow the following format precisely (assuming for this example that you found 3 suspected duplicates):
|
||||
|
||||
---
|
||||
|
||||
Found 3 possible duplicate issues:
|
||||
|
||||
1. <link to issue>
|
||||
2. <link to issue>
|
||||
3. <link to issue>
|
||||
|
||||
This issue will be automatically closed as a duplicate in 3 days.
|
||||
|
||||
- If your issue is a duplicate, please close it and 👍 the existing issue instead
|
||||
- To prevent auto-closure, add a comment or 👎 this comment
|
||||
|
||||
> 🤖 Generated with Codex
|
||||
|
||||
---
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: spa-routes
|
||||
description: MUST use when editing src/routes/ segments, src/spa/router/desktopRouter.config.tsx or desktopRouter.config.desktop.tsx (always change both together), mobileRouter.config.tsx, or when moving UI/logic between routes and src/features/.
|
||||
description: "SPA roots-vs-features split for LobeHub — thin route segments under `src/routes/` delegate to domain components under `src/features/`. Use when editing `src/routes/` segments, `src/spa/router/desktopRouter.config.tsx` or `desktopRouter.config.desktop.tsx` (MUST update both together — `desktopRouter.sync.test.tsx` enforces this), `mobileRouter.config.tsx`, `popupRouter.config.tsx`, or moving UI/logic between `routes/` and `features/`. Triggers on `desktopRouter.config`, `mobileRouter.config`, `popupRouter.config`, `src/routes/**`, `src/features/**`, 'add a route', 'new page', 'route segment', '路由'."
|
||||
user-invocable: false
|
||||
---
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: store-data-structures
|
||||
description: Zustand store data structure patterns for LobeHub. Covers List vs Detail data structures, Map + Reducer patterns, type definitions, and when to use each pattern. Use when designing store state, choosing data structures, or implementing list/detail pages.
|
||||
description: "Zustand store data-shape patterns for LobeHub — List vs Detail split, Map + Reducer, type definitions sourced from `@lobechat/types` (not `@lobechat/database`). Use when designing store state, choosing between Array (list) and `Record<string, Detail>` (detail map), or implementing a list/detail page pair. Triggers on `messagesMap`, `topicsMap`, `Record<string, Detail>`, 'list vs detail', 'store data shape', 'normalize state', 'state structure'."
|
||||
user-invocable: false
|
||||
---
|
||||
|
||||
@@ -310,5 +310,5 @@ export interface BenchmarkListItem {
|
||||
|
||||
## Related Skills
|
||||
|
||||
- `data-fetching` — how to fetch and update this data
|
||||
- `data-fetching-architecture` — how to fetch and update this data
|
||||
- `zustand` — general Zustand patterns
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: upstash-workflow
|
||||
description: 'Upstash Workflow implementation guide. Use when creating async workflows with QStash, implementing fan-out patterns, or building 3-layer workflow architecture (process → paginate → execute).'
|
||||
description: "Upstash Workflow + QStash implementation guide for LobeHub — 3-layer architecture (process → paginate → execute), fan-out patterns. Use when creating an async workflow, implementing fan-out (paginate → execute), or wiring `serve()` + `context.run` / `context.call` steps. Triggers on `serve()`, `context.run`, `context.call`, `context.sleep`, `qstash`, 'async workflow', 'fan-out workflow', 'QStash workflow'."
|
||||
user-invocable: false
|
||||
---
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ Guidelines for using AI coding agents in this LobeHub repository.
|
||||
- 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`.
|
||||
- **Component priority**: `@lobehub/ui/base-ui` (headless primitives) **first**, then `@lobehub/ui` root, then antd as last resort. When the component exists in base-ui, use it — never reach for the root or antd counterpart. Base-ui covers `Select`, `Modal` / `createModal` / `confirmModal`, `DropdownMenu`, `ContextMenu`, `Popover`, `ScrollArea`, `Switch`, `Toast`, `FloatingSheet`. Prefer `@lobehub/ui/base-ui` for new code and migrate root-package call sites opportunistically.
|
||||
- react-i18next for i18n; zustand for state management
|
||||
- SWR for data fetching; TRPC for type-safe backend
|
||||
- Drizzle ORM with PostgreSQL; Vitest for testing
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
.\" Code generated by `npm run man:generate`; DO NOT EDIT.
|
||||
.\" Manual command details come from the Commander command tree.
|
||||
.TH LH 1 "" "@lobehub/cli 0.0.20" "User Commands"
|
||||
.TH LH 1 "" "@lobehub/cli 0.0.22" "User Commands"
|
||||
.SH NAME
|
||||
lh \- LobeHub CLI \- manage and connect to LobeHub services
|
||||
.SH SYNOPSIS
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@lobehub/cli",
|
||||
"version": "0.0.20",
|
||||
"version": "0.0.22",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"lh": "./dist/index.js",
|
||||
@@ -44,7 +44,7 @@
|
||||
"picocolors": "^1.1.1",
|
||||
"superjson": "^2.2.6",
|
||||
"tsdown": "^0.21.4",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript": "^6.0.3",
|
||||
"ws": "^8.18.1"
|
||||
},
|
||||
"publishConfig": {
|
||||
|
||||
@@ -248,14 +248,14 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
|
||||
|
||||
// Handle tool call requests
|
||||
client.on('tool_call_request', async (request: ToolCallRequestMessage) => {
|
||||
const { requestId, toolCall } = request;
|
||||
const { requestId, timeout, toolCall } = request;
|
||||
if (isDaemonChild) {
|
||||
appendLog(`[TOOL] ${toolCall.apiName} (${requestId})`);
|
||||
} else {
|
||||
log.toolCall(toolCall.apiName, requestId, toolCall.arguments);
|
||||
}
|
||||
|
||||
const result = await executeToolCall(toolCall.apiName, toolCall.arguments);
|
||||
const result = await executeToolCall(toolCall.apiName, toolCall.arguments, timeout);
|
||||
|
||||
if (isDaemonChild) {
|
||||
appendLog(`[RESULT] ${result.success ? 'OK' : 'FAIL'} (${requestId})`);
|
||||
|
||||
@@ -8,11 +8,20 @@ import { registerHeteroCommand } from './hetero';
|
||||
const { mockSpawnAgent } = vi.hoisted(() => ({
|
||||
mockSpawnAgent: vi.fn(),
|
||||
}));
|
||||
const { mockGetTrpcClient, mockHeteroFinishMutate, mockHeteroIngestMutate } = vi.hoisted(() => ({
|
||||
mockGetTrpcClient: vi.fn(),
|
||||
mockHeteroFinishMutate: vi.fn(),
|
||||
mockHeteroIngestMutate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@lobechat/heterogeneous-agents/spawn', () => ({
|
||||
spawnAgent: mockSpawnAgent,
|
||||
}));
|
||||
|
||||
vi.mock('../api/client', () => ({
|
||||
getTrpcClient: mockGetTrpcClient,
|
||||
}));
|
||||
|
||||
vi.mock('../utils/logger', () => ({
|
||||
log: { debug: vi.fn(), error: vi.fn(), info: vi.fn(), warn: vi.fn() },
|
||||
setVerbose: vi.fn(),
|
||||
@@ -77,6 +86,17 @@ describe('hetero exec command', () => {
|
||||
}) as any);
|
||||
stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
||||
mockSpawnAgent.mockReset();
|
||||
mockHeteroIngestMutate.mockReset();
|
||||
mockHeteroFinishMutate.mockReset();
|
||||
mockGetTrpcClient.mockReset();
|
||||
mockHeteroIngestMutate.mockResolvedValue({ ack: true });
|
||||
mockHeteroFinishMutate.mockResolvedValue({ ack: true });
|
||||
mockGetTrpcClient.mockResolvedValue({
|
||||
aiAgent: {
|
||||
heteroFinish: { mutate: mockHeteroFinishMutate },
|
||||
heteroIngest: { mutate: mockHeteroIngestMutate },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -536,4 +556,93 @@ describe('hetero exec command', () => {
|
||||
expect(errorLine).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('sends full text snapshots before tools and waits for finish until all server ingests ack', async () => {
|
||||
const callOrder: string[] = [];
|
||||
mockHeteroIngestMutate.mockImplementation(async ({ events }: any) => {
|
||||
const first = events[0];
|
||||
callOrder.push(`ingest:${first.type}:${first.data?.chunkType ?? 'terminal'}`);
|
||||
return { ack: true };
|
||||
});
|
||||
mockHeteroFinishMutate.mockImplementation(async () => {
|
||||
callOrder.push('finish');
|
||||
return { ack: true };
|
||||
});
|
||||
|
||||
mockSpawnAgent.mockReturnValue(
|
||||
createFakeHandle({
|
||||
events: [
|
||||
{
|
||||
data: { chunkType: 'text', content: 'hello ' },
|
||||
operationId: 'op-server',
|
||||
stepIndex: 0,
|
||||
timestamp: 1,
|
||||
type: 'stream_chunk',
|
||||
},
|
||||
{
|
||||
data: { chunkType: 'text', content: 'world' },
|
||||
operationId: 'op-server',
|
||||
stepIndex: 0,
|
||||
timestamp: 2,
|
||||
type: 'stream_chunk',
|
||||
},
|
||||
{
|
||||
data: {
|
||||
chunkType: 'tools_calling',
|
||||
toolsCalling: [
|
||||
{
|
||||
apiName: 'Bash',
|
||||
arguments: '{"cmd":"ls"}',
|
||||
id: 'tc-1',
|
||||
identifier: 'bash',
|
||||
type: 'default',
|
||||
},
|
||||
],
|
||||
},
|
||||
operationId: 'op-server',
|
||||
stepIndex: 1,
|
||||
timestamp: 3,
|
||||
type: 'stream_chunk',
|
||||
},
|
||||
{
|
||||
data: { reason: 'success' },
|
||||
operationId: 'op-server',
|
||||
stepIndex: 1,
|
||||
timestamp: 4,
|
||||
type: 'agent_runtime_end',
|
||||
},
|
||||
],
|
||||
exitCode: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
await runCmd([
|
||||
'hetero',
|
||||
'exec',
|
||||
'--type',
|
||||
'claude-code',
|
||||
'--prompt',
|
||||
'hi',
|
||||
'--topic',
|
||||
'topic-1',
|
||||
'--operation-id',
|
||||
'op-server',
|
||||
'--render',
|
||||
'none',
|
||||
]);
|
||||
|
||||
expect(mockHeteroIngestMutate).toHaveBeenCalledTimes(3);
|
||||
expect(mockHeteroIngestMutate.mock.calls[0][0].events[0].data).toMatchObject({
|
||||
chunkType: 'text',
|
||||
content: 'hello world',
|
||||
snapshotMode: 'replace',
|
||||
snapshotSeq: 1,
|
||||
});
|
||||
expect(callOrder).toEqual([
|
||||
'ingest:stream_chunk:text',
|
||||
'ingest:stream_chunk:tools_calling',
|
||||
'ingest:agent_runtime_end:terminal',
|
||||
'finish',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
+103
-16
@@ -1,4 +1,5 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { once } from 'node:events';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
@@ -6,12 +7,12 @@ import type {
|
||||
AgentContentBlock,
|
||||
AgentImageSource,
|
||||
AgentPromptInput,
|
||||
AgentStreamEvent,
|
||||
} from '@lobechat/heterogeneous-agents/spawn';
|
||||
import { spawnAgent } from '@lobechat/heterogeneous-agents/spawn';
|
||||
import type { Command } from 'commander';
|
||||
|
||||
import { getTrpcClient } from '../api/client';
|
||||
import { BatchIngester, NoopIngestSink } from '../utils/BatchIngester';
|
||||
import { log } from '../utils/logger';
|
||||
import { TrpcIngestSink } from '../utils/TrpcIngestSink';
|
||||
|
||||
@@ -200,6 +201,85 @@ const resolvePrompt = async (options: ExecOptions): Promise<ResolvedPrompt> => {
|
||||
return buildPromptFromText(raw, images);
|
||||
};
|
||||
|
||||
class SerialServerIngester {
|
||||
private accumulatedText = '';
|
||||
private fatalError: Error | null = null;
|
||||
private inflight: Promise<void> = Promise.resolve();
|
||||
private nextSnapshotSeq = 0;
|
||||
private pendingTextEvent: AgentStreamEvent | undefined;
|
||||
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly sink: TrpcIngestSink,
|
||||
private readonly snapshotFlushMs = 200,
|
||||
) {}
|
||||
|
||||
push(event: AgentStreamEvent): void {
|
||||
if (this.fatalError) return;
|
||||
|
||||
if (
|
||||
event.type === 'stream_chunk' &&
|
||||
event.data?.chunkType === 'text' &&
|
||||
typeof event.data?.content === 'string'
|
||||
) {
|
||||
this.accumulatedText += event.data.content;
|
||||
this.pendingTextEvent = event;
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = null;
|
||||
this.queuePendingTextSnapshot();
|
||||
}, this.snapshotFlushMs);
|
||||
return;
|
||||
}
|
||||
|
||||
this.queuePendingTextSnapshot();
|
||||
this.enqueue(async () => {
|
||||
await this.sink.ingest([event]);
|
||||
});
|
||||
}
|
||||
|
||||
async drain(): Promise<void> {
|
||||
this.queuePendingTextSnapshot();
|
||||
try {
|
||||
await this.inflight;
|
||||
} catch {
|
||||
// `fatalError` is re-thrown below.
|
||||
}
|
||||
if (this.fatalError) throw this.fatalError;
|
||||
}
|
||||
|
||||
private enqueue(task: () => Promise<void>) {
|
||||
this.inflight = this.inflight.then(task).catch((err) => {
|
||||
this.fatalError = err instanceof Error ? err : new Error(String(err));
|
||||
throw this.fatalError;
|
||||
});
|
||||
}
|
||||
|
||||
private queuePendingTextSnapshot() {
|
||||
if (!this.pendingTextEvent || this.fatalError) return;
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
|
||||
const baseEvent = this.pendingTextEvent;
|
||||
this.pendingTextEvent = undefined;
|
||||
const snapshotEvent: AgentStreamEvent = {
|
||||
...baseEvent,
|
||||
data: {
|
||||
...baseEvent.data,
|
||||
content: this.accumulatedText,
|
||||
snapshotMode: 'replace',
|
||||
snapshotSeq: ++this.nextSnapshotSeq,
|
||||
},
|
||||
};
|
||||
|
||||
this.enqueue(async () => {
|
||||
await this.sink.ingest([snapshotEvent]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const exec = async (options: ExecOptions): Promise<void> => {
|
||||
if (!SUPPORTED_AGENT_TYPES.has(options.type)) {
|
||||
log.error(
|
||||
@@ -243,17 +323,22 @@ const exec = async (options: ExecOptions): Promise<void> => {
|
||||
// server-ingest mode. The tRPC client reads LOBEHUB_JWT (operation-scoped
|
||||
// JWT injected by the server) for authentication.
|
||||
const agentType = options.type as 'claude-code' | 'codex';
|
||||
let sink: InstanceType<typeof TrpcIngestSink> | InstanceType<typeof NoopIngestSink>;
|
||||
let sink: TrpcIngestSink | undefined;
|
||||
let serverIngester: SerialServerIngester | undefined;
|
||||
if (serverIngest) {
|
||||
const client = await getTrpcClient();
|
||||
sink = new TrpcIngestSink(client, agentType, operationId, options.topic!);
|
||||
} else {
|
||||
sink = new NoopIngestSink();
|
||||
sink = new TrpcIngestSink(
|
||||
client,
|
||||
agentType,
|
||||
operationId,
|
||||
options.topic!,
|
||||
process.env.LOBEHUB_ASSISTANT_MESSAGE_ID,
|
||||
);
|
||||
serverIngester = new SerialServerIngester(sink);
|
||||
}
|
||||
const ingester = new BatchIngester(sink);
|
||||
|
||||
/**
|
||||
* Spawn one agent process and stream all its events into `ingester`.
|
||||
* Spawn one agent process and stream all its events into the server ingester.
|
||||
*
|
||||
* When `interceptResumeErrors` is true, any `error`-type event whose
|
||||
* message matches `RESUME_RETRY_PATTERNS` is withheld from the
|
||||
@@ -297,6 +382,7 @@ const exec = async (options: ExecOptions): Promise<void> => {
|
||||
// Always pipe to process.stderr too so users see auth prompts / warnings.
|
||||
const STDERR_CAP = 8 * 1024;
|
||||
let stderrContent = '';
|
||||
const stderrEnded = once(handle.stderr, 'end').then(() => undefined);
|
||||
handle.stderr.on('data', (chunk: Buffer) => {
|
||||
if (stderrContent.length < STDERR_CAP) {
|
||||
stderrContent += chunk.toString();
|
||||
@@ -314,9 +400,9 @@ const exec = async (options: ExecOptions): Promise<void> => {
|
||||
}
|
||||
interrupted = true;
|
||||
handle.kill('SIGINT');
|
||||
if (serverIngest) {
|
||||
if (serverIngester && sink) {
|
||||
try {
|
||||
await ingester.drain();
|
||||
await serverIngester.drain();
|
||||
await sink.finish({ result: 'cancelled' });
|
||||
} catch {
|
||||
// best-effort; process is exiting anyway
|
||||
@@ -325,9 +411,9 @@ const exec = async (options: ExecOptions): Promise<void> => {
|
||||
};
|
||||
const onSigterm = async () => {
|
||||
handle.kill('SIGTERM');
|
||||
if (serverIngest) {
|
||||
if (serverIngester && sink) {
|
||||
try {
|
||||
await ingester.drain();
|
||||
await serverIngester.drain();
|
||||
await sink.finish({ result: 'cancelled' });
|
||||
} catch {
|
||||
// best-effort
|
||||
@@ -356,16 +442,16 @@ const exec = async (options: ExecOptions): Promise<void> => {
|
||||
}
|
||||
}
|
||||
if (emitJsonl) process.stdout.write(`${JSON.stringify(event)}\n`);
|
||||
ingester.push(event);
|
||||
serverIngester?.push(event);
|
||||
}
|
||||
} catch (err) {
|
||||
log.error(
|
||||
'Stream error from agent process:',
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
if (serverIngest) {
|
||||
if (serverIngester && sink) {
|
||||
try {
|
||||
await ingester.drain();
|
||||
await serverIngester.drain();
|
||||
await sink.finish({
|
||||
error: { message: String(err), type: 'stream_error' },
|
||||
result: 'error',
|
||||
@@ -381,6 +467,7 @@ const exec = async (options: ExecOptions): Promise<void> => {
|
||||
}
|
||||
|
||||
const { code, signal } = await handle.exit;
|
||||
await stderrEnded;
|
||||
|
||||
// Fallback stderr detection: CC may exit non-zero without emitting a
|
||||
// result event (e.g. it writes to stderr and quits immediately).
|
||||
@@ -451,9 +538,9 @@ const exec = async (options: ExecOptions): Promise<void> => {
|
||||
|
||||
const { code, signal, sessionId } = result;
|
||||
|
||||
if (serverIngest) {
|
||||
if (serverIngester && sink) {
|
||||
try {
|
||||
await ingester.drain();
|
||||
await serverIngester.drain();
|
||||
} catch (err) {
|
||||
log.error(
|
||||
'Failed to flush events to server:',
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
|
||||
import { getHermesPort } from './heteroTask';
|
||||
|
||||
export interface CheckPlatformCapabilityParams {
|
||||
platform: 'hermes' | 'openclaw';
|
||||
}
|
||||
@@ -42,26 +40,19 @@ export async function checkPlatformCapability(
|
||||
}
|
||||
|
||||
if (platform === 'hermes') {
|
||||
const port = getHermesPort();
|
||||
try {
|
||||
const res = await fetch(`http://localhost:${port}/health`, {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (res.ok) {
|
||||
let version: string | undefined;
|
||||
try {
|
||||
const body = (await res.json()) as { version?: string };
|
||||
version = body.version;
|
||||
} catch {
|
||||
/* ignore parse errors */
|
||||
}
|
||||
return { available: true, version };
|
||||
}
|
||||
return { available: false, reason: `Hermes gateway returned HTTP ${res.status}` };
|
||||
const output = execFileSync('hermes', ['--version'], {
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
}).trim();
|
||||
// output is typically "Hermes Agent vX.Y.Z (...)"
|
||||
const versionMatch = output.match(/v(\d+\.\d+\.\d+)/);
|
||||
const version = versionMatch ? versionMatch[1] : output.split(/\s+/).at(-1);
|
||||
return { available: true, version };
|
||||
} catch (err) {
|
||||
return {
|
||||
available: false,
|
||||
reason: err instanceof Error ? err.message : `Hermes gateway not reachable on port ${port}`,
|
||||
reason: err instanceof Error ? err.message : 'hermes not found or failed to run',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { RemoteHeterogeneousAgentType } from '@lobechat/heterogeneous-agents';
|
||||
@@ -80,13 +81,92 @@ function getOpenClawProfile(agentId?: string): AgentProfileResult {
|
||||
return { avatar, description, title };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the active Hermes profile name from `hermes profile list` output.
|
||||
* The active profile is marked with ◆ in the first column.
|
||||
*/
|
||||
function getActiveHermesProfileName(): string | undefined {
|
||||
try {
|
||||
const output = execFileSync('hermes', ['profile', 'list'], {
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
});
|
||||
const match = output.match(/◆(\S+)/);
|
||||
return match?.[1];
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the filesystem path of a Hermes profile from `hermes profile show <name>`.
|
||||
*/
|
||||
function getHermesProfilePath(profileName: string): string | undefined {
|
||||
try {
|
||||
const output = execFileSync('hermes', ['profile', 'show', profileName], {
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
});
|
||||
const match = output.match(/^Path:\s+(.+)/m);
|
||||
const raw = match?.[1]?.trim();
|
||||
// Expand leading `~` — Node does not auto-expand home-dir shorthands.
|
||||
return raw?.replace(/^~(?=\/|$)/, os.homedir());
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a one-line description from a Hermes SOUL.md file.
|
||||
* Strips HTML comments and Markdown headings, then returns the first
|
||||
* non-empty line of actual content.
|
||||
*/
|
||||
function readHermesSoulDescription(soulPath: string): string | undefined {
|
||||
try {
|
||||
const content = fs.readFileSync(soulPath, 'utf8');
|
||||
// Loop until stable to handle any malformed/nested comment sequences.
|
||||
let stripped = content;
|
||||
let previous: string;
|
||||
do {
|
||||
previous = stripped;
|
||||
stripped = stripped
|
||||
.replaceAll(/<!--[\s\S]*?-->/g, '') // strip complete HTML comments
|
||||
.replaceAll(/[<>]/g, '') // strip any remaining HTML delimiter chars
|
||||
.replaceAll(/^#+\s.*$/gm, ''); // strip Markdown headings
|
||||
} while (stripped !== previous);
|
||||
const line = stripped
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.find((l) => l.length > 0);
|
||||
return line || undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function getHermesProfile(): AgentProfileResult {
|
||||
const profileName = getActiveHermesProfileName();
|
||||
if (!profileName) return {};
|
||||
|
||||
const profilePath = getHermesProfilePath(profileName);
|
||||
const description = profilePath
|
||||
? readHermesSoulDescription(path.join(profilePath, 'SOUL.md'))
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
avatar: '⚡',
|
||||
description,
|
||||
title: profileName,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the agent profile (title, avatar, description) from the platform
|
||||
* installed on this device. Dispatched by the server via `device.getAgentProfile`.
|
||||
*
|
||||
* - openclaw: `openclaw agents list --json` for name + emoji, workspace
|
||||
* IDENTITY.md for description fallback
|
||||
* - hermes: not yet implemented — returns empty profile
|
||||
* - hermes: active profile name + SOUL.md description
|
||||
*/
|
||||
export async function getAgentProfile(params: GetAgentProfileParams): Promise<AgentProfileResult> {
|
||||
const { platform, agentId } = params;
|
||||
@@ -96,8 +176,7 @@ export async function getAgentProfile(params: GetAgentProfileParams): Promise<Ag
|
||||
}
|
||||
|
||||
if (platform === 'hermes') {
|
||||
// Profile fetch not yet implemented for Hermes — return empty
|
||||
return {};
|
||||
return getHermesProfile();
|
||||
}
|
||||
|
||||
return {};
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { RemoteHeterogeneousAgentType } from '@lobechat/heterogeneous-agents';
|
||||
|
||||
@@ -6,7 +9,36 @@ import { getTrpcClient } from '../api/client';
|
||||
import { getTask, listTasks, removeTask, saveTask } from '../daemon/taskRegistry';
|
||||
import { log } from '../utils/logger';
|
||||
|
||||
const DEFAULT_HERMES_PORT = 3456;
|
||||
// ─── Hermes session persistence ───
|
||||
// Maps topicId → hermes session_id so multi-turn conversations can resume
|
||||
// the same session across separate `runHeteroTask` invocations.
|
||||
|
||||
const LOBEHUB_DIR_NAME = process.env.LOBEHUB_CLI_HOME || '.lobehub';
|
||||
const HERMES_SESSIONS_FILE = path.join(os.homedir(), LOBEHUB_DIR_NAME, 'hermes-sessions.json');
|
||||
|
||||
function getHermesSessionId(topicId: string): string | undefined {
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(HERMES_SESSIONS_FILE, 'utf8')) as Record<
|
||||
string,
|
||||
string
|
||||
>;
|
||||
return data[topicId];
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function saveHermesSessionId(topicId: string, sessionId: string): void {
|
||||
let data: Record<string, string> = {};
|
||||
try {
|
||||
data = JSON.parse(fs.readFileSync(HERMES_SESSIONS_FILE, 'utf8')) as Record<string, string>;
|
||||
} catch {
|
||||
// File doesn't exist yet — start fresh.
|
||||
}
|
||||
data[topicId] = sessionId;
|
||||
fs.mkdirSync(path.dirname(HERMES_SESSIONS_FILE), { recursive: true });
|
||||
fs.writeFileSync(HERMES_SESSIONS_FILE, JSON.stringify(data), 'utf8');
|
||||
}
|
||||
|
||||
/** Resolve the absolute path to the `lh` binary to avoid PATH issues in child processes. */
|
||||
function resolveLhPath(): string {
|
||||
@@ -32,40 +64,6 @@ export interface CancelHeteroTaskParams {
|
||||
taskId: string;
|
||||
}
|
||||
|
||||
export function getHermesPort(): number {
|
||||
const env = process.env.HERMES_GATEWAY_PORT;
|
||||
if (env) {
|
||||
const parsed = Number.parseInt(env, 10);
|
||||
if (!Number.isNaN(parsed)) return parsed;
|
||||
}
|
||||
return DEFAULT_HERMES_PORT;
|
||||
}
|
||||
|
||||
async function isHermesGatewayRunning(port: number): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`http://localhost:${port}/health`);
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function startHermesGateway(port: number): Promise<void> {
|
||||
const child = spawn('hermes', ['gateway', 'start'], {
|
||||
detached: true,
|
||||
env: { ...process.env },
|
||||
stdio: 'ignore',
|
||||
});
|
||||
child.unref();
|
||||
|
||||
const deadline = Date.now() + 10_000;
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise<void>((r) => setTimeout(r, 500));
|
||||
if (await isHermesGatewayRunning(port)) return;
|
||||
}
|
||||
throw new Error(`Hermes gateway did not start within 10s on port ${port}`);
|
||||
}
|
||||
|
||||
async function sendAutoNotify(
|
||||
topicId: string,
|
||||
taskId: string,
|
||||
@@ -231,37 +229,84 @@ export async function runHeteroTask(params: RunHeteroTaskParams): Promise<string
|
||||
}
|
||||
|
||||
if (agentType === 'hermes') {
|
||||
const port = getHermesPort();
|
||||
|
||||
if (!(await isHermesGatewayRunning(port))) {
|
||||
log.info(`Hermes gateway not running on port ${port}, starting...`);
|
||||
await startHermesGateway(port);
|
||||
// Kill any existing hermes process for this topicId before spawning a new one.
|
||||
for (const existing of listTasks()) {
|
||||
if (existing.topicId === topicId && existing.agentType === 'hermes') {
|
||||
try {
|
||||
process.kill(existing.pid, 'SIGTERM');
|
||||
} catch {
|
||||
// Already exited — nothing to do.
|
||||
}
|
||||
removeTask(existing.taskId);
|
||||
}
|
||||
}
|
||||
|
||||
const res = await fetch(`http://localhost:${port}/message`, {
|
||||
body: JSON.stringify({ content: prompt, operationId }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method: 'POST',
|
||||
// Resume the previous session for this topic if one exists.
|
||||
const existingSessionId = getHermesSessionId(topicId);
|
||||
const hermesArgs: string[] = ['chat', '--query', prompt, '--quiet', '--accept-hooks'];
|
||||
if (existingSessionId) {
|
||||
hermesArgs.push('--resume', existingSessionId);
|
||||
}
|
||||
|
||||
// Hermes prints "session_id: <id>\n<response>" to stdout in --quiet mode.
|
||||
// We capture stdout, parse both fields on exit, and relay the response via notify.
|
||||
const child = spawn('hermes', hermesArgs, {
|
||||
cwd: workDir,
|
||||
detached: true,
|
||||
env: { ...process.env },
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Hermes gateway returned ${res.status}: ${await res.text()}`);
|
||||
}
|
||||
const pid = child.pid;
|
||||
if (pid === undefined) throw new Error('Failed to get PID for hermes process');
|
||||
child.unref();
|
||||
|
||||
// pid is 0 for Hermes — the gateway is long-lived and cancellation uses
|
||||
// the HTTP /stop API rather than direct signal delivery.
|
||||
saveTask({
|
||||
agentId,
|
||||
agentType,
|
||||
operationId,
|
||||
pid: 0,
|
||||
pid,
|
||||
startedAt: new Date().toISOString(),
|
||||
taskId,
|
||||
topicId,
|
||||
});
|
||||
log.info(`Hermes task dispatched: taskId=${taskId} operationId=${operationId}`);
|
||||
log.info(`Hermes task started: taskId=${taskId} pid=${pid}`);
|
||||
|
||||
return JSON.stringify({ operationId, taskId });
|
||||
let stdout = '';
|
||||
child.stdout.on('data', (chunk: Buffer) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
|
||||
child.on('close', (code, signal) => {
|
||||
removeTask(taskId);
|
||||
|
||||
if (code !== 0 || signal !== null) {
|
||||
const text = signal
|
||||
? `Task cancelled (signal: ${signal})`
|
||||
: `Task failed (exit code: ${code})`;
|
||||
void sendAutoNotify(topicId, taskId, text, agentId).finally(() =>
|
||||
sendDoneSignal(topicId, agentId),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse "session_id: <id>" from the first line, response from the rest.
|
||||
const sessionIdMatch = stdout.match(/^session_id:\s*(\S+)/m);
|
||||
const sessionId = sessionIdMatch?.[1];
|
||||
const response = stdout.replace(/^session_id:[^\n]*\n?/, '').trim();
|
||||
|
||||
if (sessionId) saveHermesSessionId(topicId, sessionId);
|
||||
|
||||
if (response) {
|
||||
void sendAutoNotify(topicId, taskId, response, agentId).finally(() =>
|
||||
sendDoneSignal(topicId, agentId),
|
||||
);
|
||||
} else {
|
||||
void sendDoneSignal(topicId, agentId);
|
||||
}
|
||||
});
|
||||
|
||||
return JSON.stringify({ pid, taskId });
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported agentType: ${agentType as string}`);
|
||||
@@ -275,25 +320,7 @@ export async function cancelHeteroTask(params: CancelHeteroTaskParams): Promise<
|
||||
return JSON.stringify({ message: `No task found with taskId: ${taskId}`, success: false });
|
||||
}
|
||||
|
||||
if (entry.agentType === 'hermes') {
|
||||
const port = getHermesPort();
|
||||
try {
|
||||
await fetch(`http://localhost:${port}/stop`, {
|
||||
body: JSON.stringify({ operationId: entry.operationId }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method: 'POST',
|
||||
});
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
`Failed to send /stop to Hermes gateway: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
removeTask(taskId);
|
||||
await sendAutoNotify(entry.topicId, taskId, 'Task cancelled', entry.agentId);
|
||||
return JSON.stringify({ taskId });
|
||||
}
|
||||
|
||||
// OpenClaw: kill by PID and let the child's close handler send the notify.
|
||||
// Both openclaw and hermes: kill by PID and let the child's close handler send the notify.
|
||||
try {
|
||||
process.kill(entry.pid, signal);
|
||||
} catch (err) {
|
||||
|
||||
@@ -41,6 +41,7 @@ const methodMap: Record<string, (args: any) => Promise<unknown>> = {
|
||||
export async function executeToolCall(
|
||||
apiName: string,
|
||||
argsStr: string,
|
||||
timeout?: number,
|
||||
): Promise<{
|
||||
content: string;
|
||||
error?: string;
|
||||
@@ -53,8 +54,12 @@ export async function executeToolCall(
|
||||
|
||||
try {
|
||||
const args = JSON.parse(argsStr);
|
||||
const finalArgs =
|
||||
typeof timeout === 'number' && Number.isFinite(timeout) && !('timeout' in args)
|
||||
? { ...args, timeout }
|
||||
: args;
|
||||
|
||||
const result = await handler(args);
|
||||
const result = await handler(finalArgs);
|
||||
const content = typeof result === 'string' ? result : JSON.stringify(result);
|
||||
|
||||
return { content, success: true };
|
||||
|
||||
@@ -16,6 +16,7 @@ export class TrpcIngestSink implements IngestSink {
|
||||
private readonly agentType: 'claude-code' | 'codex',
|
||||
private readonly operationId: string,
|
||||
private readonly topicId: string,
|
||||
private readonly assistantMessageId?: string,
|
||||
) {}
|
||||
|
||||
async finish(params: Parameters<IngestSink['finish']>[0]): Promise<void> {
|
||||
@@ -30,6 +31,7 @@ export class TrpcIngestSink implements IngestSink {
|
||||
async ingest(events: AgentStreamEvent[]): Promise<void> {
|
||||
await this.client.aiAgent.heteroIngest.mutate({
|
||||
agentType: this.agentType,
|
||||
assistantMessageId: this.assistantMessageId,
|
||||
events: events as any,
|
||||
operationId: this.operationId,
|
||||
topicId: this.topicId,
|
||||
|
||||
@@ -104,7 +104,7 @@
|
||||
"stylelint": "^15.11.0",
|
||||
"superjson": "^2.2.6",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript": "^6.0.3",
|
||||
"undici": "^7.16.0",
|
||||
"uuid": "^14.0.0",
|
||||
"vite": "8.0.14",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { execFileSync, execSync, spawn } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { AgentRunRequestMessage } from '@lobechat/device-gateway-client';
|
||||
@@ -13,8 +14,6 @@ import LocalFileCtr from './LocalFileCtr';
|
||||
import RemoteServerConfigCtr from './RemoteServerConfigCtr';
|
||||
import ShellCommandCtr from './ShellCommandCtr';
|
||||
|
||||
const DEFAULT_HERMES_PORT = 3456;
|
||||
|
||||
/**
|
||||
* Inject the lh-notify protocol into the first turn of a new hetero-agent session.
|
||||
* Tells the agent binary how to push results back to the LobeHub chat UI via `lh notify`.
|
||||
@@ -66,6 +65,9 @@ export default class GatewayConnectionCtr extends ControllerModule {
|
||||
/** In-memory registry for running platform agent tasks (openclaw / hermes). */
|
||||
private readonly platformTasks = new Map<string, PlatformTaskEntry>();
|
||||
|
||||
/** Maps topicId → hermes session_id for multi-turn conversation continuity. */
|
||||
private readonly hermesSessionMap = new Map<string, string>();
|
||||
|
||||
// ─── Service Accessor ───
|
||||
|
||||
private get service() {
|
||||
@@ -301,10 +303,71 @@ export default class GatewayConnectionCtr extends ControllerModule {
|
||||
return this.getOpenClawProfile(agentId);
|
||||
}
|
||||
|
||||
// hermes and unknown platforms: not yet implemented
|
||||
if (platform === 'hermes') {
|
||||
return this.getHermesProfile();
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
private getHermesProfile(): { avatar?: string; description?: string; title?: string } {
|
||||
// Find the active profile (marked with ◆ in `hermes profile list`).
|
||||
let profileName: string | undefined;
|
||||
try {
|
||||
const listOutput = execFileSync('hermes', ['profile', 'list'], {
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
});
|
||||
profileName = listOutput.match(/◆(\S+)/)?.[1];
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
if (!profileName) return {};
|
||||
|
||||
// Get the profile's filesystem path.
|
||||
let profilePath: string | undefined;
|
||||
try {
|
||||
const showOutput = execFileSync('hermes', ['profile', 'show', profileName], {
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
});
|
||||
const raw = showOutput.match(/^Path:\s+(.+)/m)?.[1]?.trim();
|
||||
profilePath = raw?.replace(/^~(?=\/|$)/, os.homedir());
|
||||
} catch {
|
||||
// Profile path unavailable — still return name + avatar.
|
||||
}
|
||||
|
||||
const description = profilePath
|
||||
? this.readHermesSoulDescription(path.join(profilePath, 'SOUL.md'))
|
||||
: undefined;
|
||||
|
||||
return { avatar: '⚡', description, title: profileName };
|
||||
}
|
||||
|
||||
private readHermesSoulDescription(soulPath: string): string | undefined {
|
||||
try {
|
||||
const content = fs.readFileSync(soulPath, 'utf8');
|
||||
// Loop until stable to handle any malformed/nested comment sequences.
|
||||
let stripped = content;
|
||||
let previous: string;
|
||||
do {
|
||||
previous = stripped;
|
||||
stripped = stripped
|
||||
.replaceAll(/<!--[\s\S]*?-->/g, '') // strip complete HTML comments
|
||||
.replaceAll(/[<>]/g, '') // strip any remaining HTML delimiter chars
|
||||
.replaceAll(/^#+\s.*$/gm, ''); // strip Markdown headings
|
||||
} while (stripped !== previous);
|
||||
return (
|
||||
stripped
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.find((l) => l.length > 0) || undefined
|
||||
);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private getOpenClawProfile(agentId?: string): {
|
||||
avatar?: string;
|
||||
description?: string;
|
||||
@@ -380,6 +443,18 @@ export default class GatewayConnectionCtr extends ControllerModule {
|
||||
const { agentId, agentType, cwd, operationId, prompt, taskId, topicId } = args;
|
||||
const workDir = cwd || process.cwd();
|
||||
|
||||
const [serverUrl, accessToken] = await Promise.all([
|
||||
this.remoteServerConfigCtr.getRemoteServerUrl(),
|
||||
this.remoteServerConfigCtr.getAccessToken(),
|
||||
]);
|
||||
|
||||
// Inject auth into child env so `lh notify` can authenticate without CLI config.
|
||||
const childEnv: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
...(accessToken && { LOBEHUB_JWT: accessToken }),
|
||||
...(serverUrl && { LOBEHUB_SERVER: serverUrl }),
|
||||
};
|
||||
|
||||
if (agentType === 'openclaw') {
|
||||
const lhPath = this.resolveLhPath();
|
||||
const openclawAgent = process.env['OPENCLAW_AGENT_ID'] ?? 'main';
|
||||
@@ -415,7 +490,7 @@ export default class GatewayConnectionCtr extends ControllerModule {
|
||||
enrichedPrompt,
|
||||
'--local',
|
||||
],
|
||||
{ cwd: workDir, detached: true, env: { ...process.env }, stdio: 'ignore' },
|
||||
{ cwd: workDir, detached: true, env: childEnv, stdio: 'ignore' },
|
||||
);
|
||||
|
||||
const pid = child.pid;
|
||||
@@ -442,20 +517,74 @@ export default class GatewayConnectionCtr extends ControllerModule {
|
||||
}
|
||||
|
||||
if (agentType === 'hermes') {
|
||||
const port = this.getHermesPort();
|
||||
if (!(await this.isHermesRunning(port))) {
|
||||
await this.startHermesGateway(port);
|
||||
// Kill any existing hermes process for this topicId before spawning a new one.
|
||||
for (const [existingTaskId, entry] of this.platformTasks) {
|
||||
if (entry.topicId === topicId && entry.agentType === 'hermes') {
|
||||
try {
|
||||
process.kill(entry.pid, 'SIGTERM');
|
||||
} catch {
|
||||
// Already exited — nothing to do.
|
||||
}
|
||||
this.platformTasks.delete(existingTaskId);
|
||||
}
|
||||
}
|
||||
|
||||
const res = await fetch(`http://localhost:${port}/message`, {
|
||||
body: JSON.stringify({ content: prompt, operationId }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method: 'POST',
|
||||
});
|
||||
if (!res.ok) throw new Error(`Hermes gateway returned ${res.status}: ${await res.text()}`);
|
||||
// Resume the previous session for this topic if one exists.
|
||||
const existingSessionId = this.hermesSessionMap.get(topicId);
|
||||
const hermesArgs: string[] = ['chat', '--query', prompt, '--quiet', '--accept-hooks'];
|
||||
if (existingSessionId) {
|
||||
hermesArgs.push('--resume', existingSessionId);
|
||||
}
|
||||
|
||||
this.platformTasks.set(taskId, { agentId, agentType, operationId, pid: 0, topicId });
|
||||
return JSON.stringify({ operationId, taskId });
|
||||
// Hermes prints "session_id: <id>\n<response>" to stdout in --quiet mode.
|
||||
const child = spawn('hermes', hermesArgs, {
|
||||
cwd: workDir,
|
||||
detached: true,
|
||||
env: childEnv,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
});
|
||||
|
||||
const pid = child.pid;
|
||||
if (pid === undefined) throw new Error('Failed to get PID for hermes process');
|
||||
child.unref();
|
||||
|
||||
this.platformTasks.set(taskId, { agentId, agentType, operationId, pid, topicId });
|
||||
|
||||
let stdout = '';
|
||||
child.stdout.on('data', (chunk: Buffer) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
|
||||
child.on('close', (code, signal) => {
|
||||
this.platformTasks.delete(taskId);
|
||||
|
||||
if (code !== 0 || signal !== null) {
|
||||
const text = signal
|
||||
? `Task cancelled (signal: ${signal})`
|
||||
: `Task failed (exit code: ${code})`;
|
||||
void this.sendNotify({ agentId, content: text, role: 'assistant', topicId }).finally(() =>
|
||||
this.sendNotify({ agentId, content: '', done: true, role: 'assistant', topicId }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse "session_id: <id>" from the first line, response from the rest.
|
||||
const sessionIdMatch = stdout.match(/^session_id:\s*(\S+)/m);
|
||||
const sessionId = sessionIdMatch?.[1];
|
||||
const response = stdout.replace(/^session_id:[^\n]*\n?/, '').trim();
|
||||
|
||||
if (sessionId) this.hermesSessionMap.set(topicId, sessionId);
|
||||
|
||||
if (response) {
|
||||
void this.sendNotify({ agentId, content: response, role: 'assistant', topicId }).finally(
|
||||
() => this.sendNotify({ agentId, content: '', done: true, role: 'assistant', topicId }),
|
||||
);
|
||||
} else {
|
||||
void this.sendNotify({ agentId, content: '', done: true, role: 'assistant', topicId });
|
||||
}
|
||||
});
|
||||
|
||||
return JSON.stringify({ pid, taskId });
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported agentType: ${agentType}`);
|
||||
@@ -469,28 +598,7 @@ export default class GatewayConnectionCtr extends ControllerModule {
|
||||
return JSON.stringify({ message: `No task found with taskId: ${taskId}`, success: false });
|
||||
}
|
||||
|
||||
if (entry.agentType === 'hermes') {
|
||||
const port = this.getHermesPort();
|
||||
try {
|
||||
await fetch(`http://localhost:${port}/stop`, {
|
||||
body: JSON.stringify({ operationId: entry.operationId }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method: 'POST',
|
||||
});
|
||||
} catch {
|
||||
// Hermes gateway may already have stopped; ignore
|
||||
}
|
||||
this.platformTasks.delete(taskId);
|
||||
await this.sendNotify({
|
||||
agentId: entry.agentId,
|
||||
content: 'Task cancelled',
|
||||
role: 'assistant',
|
||||
topicId: entry.topicId,
|
||||
});
|
||||
return JSON.stringify({ taskId });
|
||||
}
|
||||
|
||||
// openclaw: kill by PID; the close handler sends the done signal.
|
||||
// Both openclaw and hermes: kill by PID; the close handler sends the done signal.
|
||||
try {
|
||||
process.kill(entry.pid, signal);
|
||||
} catch {
|
||||
@@ -525,11 +633,11 @@ export default class GatewayConnectionCtr extends ControllerModule {
|
||||
]);
|
||||
if (!serverUrl || !token) return;
|
||||
|
||||
await fetch(`${serverUrl}/trpc/agentNotify.notify`, {
|
||||
await fetch(`${serverUrl}/trpc/lambda/agentNotify.notify`, {
|
||||
body: JSON.stringify({ json: params }),
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Oidc-Auth': token,
|
||||
},
|
||||
method: 'POST',
|
||||
});
|
||||
@@ -547,37 +655,4 @@ export default class GatewayConnectionCtr extends ControllerModule {
|
||||
return 'lh';
|
||||
}
|
||||
}
|
||||
|
||||
private getHermesPort(): number {
|
||||
const env = process.env['HERMES_GATEWAY_PORT'];
|
||||
if (env) {
|
||||
const parsed = Number.parseInt(env, 10);
|
||||
if (!Number.isNaN(parsed)) return parsed;
|
||||
}
|
||||
return DEFAULT_HERMES_PORT;
|
||||
}
|
||||
|
||||
private async isHermesRunning(port: number): Promise<boolean> {
|
||||
try {
|
||||
return (await fetch(`http://localhost:${port}/health`)).ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async startHermesGateway(port: number): Promise<void> {
|
||||
const child = spawn('hermes', ['gateway', 'start'], {
|
||||
detached: true,
|
||||
env: { ...process.env },
|
||||
stdio: 'ignore',
|
||||
});
|
||||
child.unref();
|
||||
|
||||
const deadline = Date.now() + 10_000;
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise<void>((r) => setTimeout(r, 500));
|
||||
if (await this.isHermesRunning(port)) return;
|
||||
}
|
||||
throw new Error(`Hermes gateway did not start within 10s on port ${port}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,14 +248,15 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
error?: string;
|
||||
success: boolean;
|
||||
}> {
|
||||
logger.debug('Attempting to open file:', { filePath });
|
||||
const resolvedPath = expandTilde(filePath) ?? filePath;
|
||||
logger.debug('Attempting to open file:', { filePath: resolvedPath });
|
||||
|
||||
try {
|
||||
await shell.openPath(filePath);
|
||||
logger.debug('File opened successfully:', { filePath });
|
||||
await shell.openPath(resolvedPath);
|
||||
logger.debug('File opened successfully:', { filePath: resolvedPath });
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logger.error(`Failed to open file ${filePath}:`, error);
|
||||
logger.error(`Failed to open file ${resolvedPath}:`, error);
|
||||
return { error: (error as Error).message, success: false };
|
||||
}
|
||||
}
|
||||
@@ -265,8 +266,13 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
error?: string;
|
||||
success: boolean;
|
||||
}> {
|
||||
const folderPath = isDirectory ? targetPath : path.dirname(targetPath);
|
||||
logger.debug('Attempting to open folder:', { folderPath, isDirectory, targetPath });
|
||||
const resolvedTarget = expandTilde(targetPath) ?? targetPath;
|
||||
const folderPath = isDirectory ? resolvedTarget : path.dirname(resolvedTarget);
|
||||
logger.debug('Attempting to open folder:', {
|
||||
folderPath,
|
||||
isDirectory,
|
||||
targetPath: resolvedTarget,
|
||||
});
|
||||
|
||||
try {
|
||||
await shell.openPath(folderPath);
|
||||
|
||||
@@ -143,6 +143,17 @@ describe('LocalFileCtr', () => {
|
||||
|
||||
expect(result).toEqual({ success: false, error: 'Failed to open' });
|
||||
});
|
||||
|
||||
it('should expand a leading ~ to the user home directory', async () => {
|
||||
const os = await import('node:os');
|
||||
const path = await import('node:path');
|
||||
vi.mocked(mockShell.openPath).mockResolvedValue('');
|
||||
|
||||
const result = await localFileCtr.handleOpenLocalFile({ path: '~/git/work/file.txt' });
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(mockShell.openPath).toHaveBeenCalledWith(path.join(os.homedir(), 'git/work/file.txt'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleOpenLocalFolder', () => {
|
||||
@@ -158,6 +169,20 @@ describe('LocalFileCtr', () => {
|
||||
expect(mockShell.openPath).toHaveBeenCalledWith('/test/folder');
|
||||
});
|
||||
|
||||
it('should expand a leading ~ when opening a directory', async () => {
|
||||
const os = await import('node:os');
|
||||
const path = await import('node:path');
|
||||
vi.mocked(mockShell.openPath).mockResolvedValue('');
|
||||
|
||||
const result = await localFileCtr.handleOpenLocalFolder({
|
||||
path: '~/git/work',
|
||||
isDirectory: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(mockShell.openPath).toHaveBeenCalledWith(path.join(os.homedir(), 'git/work'));
|
||||
});
|
||||
|
||||
it('should open parent directory when isDirectory is false', async () => {
|
||||
vi.mocked(mockShell.openPath).mockResolvedValue('');
|
||||
|
||||
|
||||
@@ -29,10 +29,6 @@ vi.mock('node:child_process', () => ({
|
||||
spawn: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('node:crypto', () => ({
|
||||
randomUUID: vi.fn(() => 'test-uuid-123'),
|
||||
}));
|
||||
|
||||
vi.mock('../CliCtr', () => ({
|
||||
default: class CliCtr {},
|
||||
}));
|
||||
@@ -59,7 +55,9 @@ describe('ShellCommandCtr (thin wrapper)', () => {
|
||||
mockChildProcess = {
|
||||
stdout: { on: vi.fn() },
|
||||
stderr: { on: vi.fn() },
|
||||
off: vi.fn(),
|
||||
on: vi.fn(),
|
||||
once: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
exitCode: null,
|
||||
};
|
||||
@@ -73,6 +71,10 @@ describe('ShellCommandCtr (thin wrapper)', () => {
|
||||
if (event === 'exit') setTimeout(() => callback(0), 10);
|
||||
return mockChildProcess;
|
||||
});
|
||||
mockChildProcess.once.mockImplementation((event: string, callback: any) => {
|
||||
if (event === 'exit') setTimeout(() => callback(0), 10);
|
||||
return mockChildProcess;
|
||||
});
|
||||
mockChildProcess.stdout.on.mockImplementation((event: string, callback: any) => {
|
||||
if (event === 'data') setTimeout(() => callback(Buffer.from('output\n')), 5);
|
||||
return mockChildProcess.stdout;
|
||||
@@ -89,14 +91,21 @@ describe('ShellCommandCtr (thin wrapper)', () => {
|
||||
});
|
||||
|
||||
it('should delegate handleGetCommandOutput to processManager', async () => {
|
||||
mockChildProcess.on.mockImplementation(() => mockChildProcess);
|
||||
mockChildProcess.on.mockImplementation((event: string, callback: any) => {
|
||||
if (event === 'exit') setTimeout(() => callback(0), 10);
|
||||
return mockChildProcess;
|
||||
});
|
||||
mockChildProcess.once.mockImplementation((event: string, callback: any) => {
|
||||
if (event === 'exit') setTimeout(() => callback(0), 10);
|
||||
return mockChildProcess;
|
||||
});
|
||||
mockChildProcess.stdout.on.mockImplementation((event: string, callback: any) => {
|
||||
if (event === 'data') setTimeout(() => callback(Buffer.from('bg output\n')), 5);
|
||||
return mockChildProcess.stdout;
|
||||
});
|
||||
mockChildProcess.stderr.on.mockImplementation(() => mockChildProcess.stderr);
|
||||
|
||||
await ctr.handleRunCommand({
|
||||
const runResult = await ctr.handleRunCommand({
|
||||
command: 'test',
|
||||
run_in_background: true,
|
||||
});
|
||||
@@ -104,7 +113,7 @@ describe('ShellCommandCtr (thin wrapper)', () => {
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
|
||||
const result = await ctr.handleGetCommandOutput({
|
||||
shell_id: 'test-uuid-123',
|
||||
shell_id: runResult.shell_id!,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
@@ -113,16 +122,17 @@ describe('ShellCommandCtr (thin wrapper)', () => {
|
||||
|
||||
it('should delegate handleKillCommand to processManager', async () => {
|
||||
mockChildProcess.on.mockImplementation(() => mockChildProcess);
|
||||
mockChildProcess.once.mockImplementation(() => mockChildProcess);
|
||||
mockChildProcess.stdout.on.mockImplementation(() => mockChildProcess.stdout);
|
||||
mockChildProcess.stderr.on.mockImplementation(() => mockChildProcess.stderr);
|
||||
|
||||
await ctr.handleRunCommand({
|
||||
const runResult = await ctr.handleRunCommand({
|
||||
command: 'test',
|
||||
run_in_background: true,
|
||||
});
|
||||
|
||||
const result = await ctr.handleKillCommand({
|
||||
shell_id: 'test-uuid-123',
|
||||
shell_id: runResult.shell_id!,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
@@ -7,7 +7,7 @@ import { app, protocol } from 'electron';
|
||||
import { LOCAL_FILE_PROTOCOL_HOST, LOCAL_FILE_PROTOCOL_SCHEME } from '@/const/protocol';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { getExportMimeType } from '../../utils/mime';
|
||||
import { resolveLocalFileMimeType } from '../../utils/mime';
|
||||
|
||||
const LOCAL_FILE_PROTOCOL_PRIVILEGES = {
|
||||
allowServiceWorkers: false,
|
||||
@@ -22,20 +22,6 @@ const LOCAL_FILE_PROTOCOL_PRIVILEGES = {
|
||||
const logger = createLogger('core:LocalFileProtocolManager');
|
||||
const PREVIEW_TOKEN_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
const EXTRA_MIME_TYPES: Record<string, string> = {
|
||||
'.avif': 'image/avif',
|
||||
'.bmp': 'image/bmp',
|
||||
'.heic': 'image/heic',
|
||||
'.heif': 'image/heif',
|
||||
'.tif': 'image/tiff',
|
||||
'.tiff': 'image/tiff',
|
||||
};
|
||||
|
||||
const getMimeType = (filePath: string): string => {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
return getExportMimeType(filePath) ?? EXTRA_MIME_TYPES[ext] ?? 'application/octet-stream';
|
||||
};
|
||||
|
||||
const normalizeAbsolutePath = (filePath: string): string | null => {
|
||||
const normalized = path.normalize(filePath);
|
||||
return path.isAbsolute(normalized) ? normalized : null;
|
||||
@@ -130,7 +116,7 @@ export class LocalFileProtocolManager {
|
||||
|
||||
const buffer = await readFile(realResolvedPath);
|
||||
const headers = new Headers();
|
||||
headers.set('Content-Type', getMimeType(realResolvedPath));
|
||||
headers.set('Content-Type', resolveLocalFileMimeType(realResolvedPath, buffer));
|
||||
headers.set('Content-Length', String(buffer.byteLength));
|
||||
// Local files are immutable from the renderer's perspective for a
|
||||
// single preview session; allow short-lived caching to avoid
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { getExportMimeType, resolveLocalFileMimeType } from '../mime';
|
||||
|
||||
describe('getExportMimeType', () => {
|
||||
it('returns the whitelisted MIME for a known extension', () => {
|
||||
expect(getExportMimeType('/abs/path/App.tsx')).toBe('text/plain; charset=utf-8');
|
||||
expect(getExportMimeType('icon.png')).toBe('image/png');
|
||||
});
|
||||
|
||||
it('returns undefined for unmapped extensions', () => {
|
||||
expect(getExportMimeType('.releaserc.cjs')).toBeUndefined();
|
||||
expect(getExportMimeType('Makefile')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveLocalFileMimeType', () => {
|
||||
it('uses the whitelist for known source extensions', () => {
|
||||
expect(resolveLocalFileMimeType('/repo/App.tsx', Buffer.from(''))).toBe(
|
||||
'text/plain; charset=utf-8',
|
||||
);
|
||||
expect(resolveLocalFileMimeType('/repo/data.json', Buffer.from('{}'))).toBe(
|
||||
'application/json; charset=utf-8',
|
||||
);
|
||||
});
|
||||
|
||||
it('serves preview-only image formats with their image MIME', () => {
|
||||
expect(resolveLocalFileMimeType('/repo/photo.heic', Buffer.from([0xff, 0xd8]))).toBe(
|
||||
'image/heic',
|
||||
);
|
||||
expect(resolveLocalFileMimeType('/repo/diagram.bmp', Buffer.from([0x42, 0x4d]))).toBe(
|
||||
'image/bmp',
|
||||
);
|
||||
});
|
||||
|
||||
it('treats unmapped text-looking files (.cjs/.mjs) as text via the sniff fallback', () => {
|
||||
const cjsContent = Buffer.from(`module.exports = { plugins: ['@semantic-release/npm'] };\n`);
|
||||
expect(resolveLocalFileMimeType('/repo/.releaserc.cjs', cjsContent)).toBe(
|
||||
'text/plain; charset=utf-8',
|
||||
);
|
||||
|
||||
const mjsContent = Buffer.from(`export default { settings: ['emoji'] };\n`);
|
||||
expect(resolveLocalFileMimeType('/repo/.remarkrc.mjs', mjsContent)).toBe(
|
||||
'text/plain; charset=utf-8',
|
||||
);
|
||||
});
|
||||
|
||||
it('treats no-extension config files as text via the sniff fallback', () => {
|
||||
const editorconfig = Buffer.from('root = true\n[*]\nindent_style = space\n');
|
||||
expect(resolveLocalFileMimeType('/repo/.editorconfig', editorconfig)).toBe(
|
||||
'text/plain; charset=utf-8',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to application/octet-stream when the sniff detects binary data', () => {
|
||||
// Embedded null byte → sniff classifies as binary.
|
||||
const binary = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00, 0x01, 0x02, 0x03]);
|
||||
expect(resolveLocalFileMimeType('/repo/strange.blob', binary)).toBe('application/octet-stream');
|
||||
});
|
||||
|
||||
it('forces known-binary extensions to octet-stream even when the prefix sniffs as text', () => {
|
||||
// PDF header + xref + dictionary is pure ASCII for the first few KB —
|
||||
// sniff would classify this as text without the extension short-circuit.
|
||||
const pdfPrintablePrefix = Buffer.from(
|
||||
'%PDF-1.7\n%\xC4\xE5\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n',
|
||||
);
|
||||
expect(resolveLocalFileMimeType('/repo/manual.pdf', pdfPrintablePrefix)).toBe(
|
||||
'application/octet-stream',
|
||||
);
|
||||
|
||||
// No null bytes in the first 8KB; without the short-circuit this would
|
||||
// also be misclassified as text.
|
||||
const fakeZipPrefix = Buffer.from('PK\x03\x04' + 'A'.repeat(64));
|
||||
expect(resolveLocalFileMimeType('/repo/bundle.zip', fakeZipPrefix)).toBe(
|
||||
'application/octet-stream',
|
||||
);
|
||||
|
||||
const fakeMp3Prefix = Buffer.from('ID3' + 'A'.repeat(64));
|
||||
expect(resolveLocalFileMimeType('/repo/song.mp3', fakeMp3Prefix)).toBe(
|
||||
'application/octet-stream',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,50 +1,156 @@
|
||||
import path from 'node:path';
|
||||
|
||||
export const getExportMimeType = (filePath: string) => {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
import { sniffBinaryBuffer } from '@lobechat/file-loaders';
|
||||
|
||||
const map: Record<string, string> = {
|
||||
'.bash': 'text/plain; charset=utf-8',
|
||||
'.c': 'text/plain; charset=utf-8',
|
||||
'.cpp': 'text/plain; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.csv': 'text/csv; charset=utf-8',
|
||||
'.dockerfile': 'text/plain; charset=utf-8',
|
||||
'.fish': 'text/plain; charset=utf-8',
|
||||
'.gif': 'image/gif',
|
||||
'.go': 'text/plain; charset=utf-8',
|
||||
'.graphql': 'application/graphql; charset=utf-8',
|
||||
'.h': 'text/plain; charset=utf-8',
|
||||
'.hpp': 'text/plain; charset=utf-8',
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.ico': 'image/x-icon',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.js': 'application/javascript; charset=utf-8',
|
||||
'.jsx': 'application/javascript; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.log': 'text/plain; charset=utf-8',
|
||||
'.map': 'application/json; charset=utf-8',
|
||||
'.md': 'text/markdown; charset=utf-8',
|
||||
'.mdx': 'text/markdown; charset=utf-8',
|
||||
'.mp4': 'video/mp4',
|
||||
'.png': 'image/png',
|
||||
'.py': 'text/plain; charset=utf-8',
|
||||
'.rs': 'text/plain; charset=utf-8',
|
||||
'.sh': 'text/plain; charset=utf-8',
|
||||
'.svg': 'image/svg+xml; charset=utf-8',
|
||||
'.toml': 'application/toml; charset=utf-8',
|
||||
'.ts': 'text/plain; charset=utf-8',
|
||||
'.tsx': 'text/plain; charset=utf-8',
|
||||
'.txt': 'text/plain; charset=utf-8',
|
||||
'.xml': 'application/xml; charset=utf-8',
|
||||
'.yaml': 'application/yaml; charset=utf-8',
|
||||
'.yml': 'application/yaml; charset=utf-8',
|
||||
'.webp': 'image/webp',
|
||||
'.woff': 'font/woff',
|
||||
'.woff2': 'font/woff2',
|
||||
'.zsh': 'text/plain; charset=utf-8',
|
||||
};
|
||||
|
||||
return map[ext];
|
||||
const EXPORT_MIME_MAP: Record<string, string> = {
|
||||
'.bash': 'text/plain; charset=utf-8',
|
||||
'.c': 'text/plain; charset=utf-8',
|
||||
'.cpp': 'text/plain; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.csv': 'text/csv; charset=utf-8',
|
||||
'.dockerfile': 'text/plain; charset=utf-8',
|
||||
'.fish': 'text/plain; charset=utf-8',
|
||||
'.gif': 'image/gif',
|
||||
'.go': 'text/plain; charset=utf-8',
|
||||
'.graphql': 'application/graphql; charset=utf-8',
|
||||
'.h': 'text/plain; charset=utf-8',
|
||||
'.hpp': 'text/plain; charset=utf-8',
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.ico': 'image/x-icon',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.js': 'application/javascript; charset=utf-8',
|
||||
'.jsx': 'application/javascript; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.log': 'text/plain; charset=utf-8',
|
||||
'.map': 'application/json; charset=utf-8',
|
||||
'.md': 'text/markdown; charset=utf-8',
|
||||
'.mdx': 'text/markdown; charset=utf-8',
|
||||
'.mp4': 'video/mp4',
|
||||
'.png': 'image/png',
|
||||
'.py': 'text/plain; charset=utf-8',
|
||||
'.rs': 'text/plain; charset=utf-8',
|
||||
'.sh': 'text/plain; charset=utf-8',
|
||||
'.svg': 'image/svg+xml; charset=utf-8',
|
||||
'.toml': 'application/toml; charset=utf-8',
|
||||
'.ts': 'text/plain; charset=utf-8',
|
||||
'.tsx': 'text/plain; charset=utf-8',
|
||||
'.txt': 'text/plain; charset=utf-8',
|
||||
'.xml': 'application/xml; charset=utf-8',
|
||||
'.yaml': 'application/yaml; charset=utf-8',
|
||||
'.yml': 'application/yaml; charset=utf-8',
|
||||
'.webp': 'image/webp',
|
||||
'.woff': 'font/woff',
|
||||
'.woff2': 'font/woff2',
|
||||
'.zsh': 'text/plain; charset=utf-8',
|
||||
};
|
||||
|
||||
/**
|
||||
* Lookup table for renderer-bundled assets. The set of extensions is closed
|
||||
* (whatever `electron-vite` produces under the renderer dir), so a whitelist
|
||||
* is appropriate here.
|
||||
*/
|
||||
export const getExportMimeType = (filePath: string): string | undefined => {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
return EXPORT_MIME_MAP[ext];
|
||||
};
|
||||
|
||||
// Image formats we render natively in the preview pane but don't ship as
|
||||
// bundled assets — kept separate from EXPORT_MIME_MAP so RendererProtocolManager
|
||||
// stays minimal.
|
||||
const PREVIEW_IMAGE_MIME_MAP: Record<string, string> = {
|
||||
'.avif': 'image/avif',
|
||||
'.bmp': 'image/bmp',
|
||||
'.heic': 'image/heic',
|
||||
'.heif': 'image/heif',
|
||||
'.tif': 'image/tiff',
|
||||
'.tiff': 'image/tiff',
|
||||
};
|
||||
|
||||
// Extensions whose contents are binary even when the first 8KB sniffs as
|
||||
// printable ASCII. The classic case is PDF: header + xref + dictionary are
|
||||
// ASCII and the compressed streams live deeper in the file, so the sniff
|
||||
// misses the binary body and would otherwise serve the file as text/plain
|
||||
// — the renderer then hands it to a text highlighter and shows garbage.
|
||||
//
|
||||
// Only formats where the printable-prefix problem is realistic need to be
|
||||
// listed; truly binary blobs with early null bytes still get caught by the
|
||||
// sniff fallback.
|
||||
const KNOWN_BINARY_EXTENSIONS = new Set<string>([
|
||||
// Documents
|
||||
'.doc',
|
||||
'.pdf',
|
||||
'.ppt',
|
||||
'.xls',
|
||||
// Archives
|
||||
'.7z',
|
||||
'.bz2',
|
||||
'.gz',
|
||||
'.rar',
|
||||
'.tar',
|
||||
'.tgz',
|
||||
'.zip',
|
||||
// Executables / libraries
|
||||
'.class',
|
||||
'.dll',
|
||||
'.dylib',
|
||||
'.exe',
|
||||
'.jar',
|
||||
'.so',
|
||||
'.war',
|
||||
'.wasm',
|
||||
// Disk / database images
|
||||
'.bin',
|
||||
'.dat',
|
||||
'.db',
|
||||
'.dmg',
|
||||
'.iso',
|
||||
'.sqlite',
|
||||
'.sqlite3',
|
||||
// Audio / video not already mapped above
|
||||
'.aac',
|
||||
'.avi',
|
||||
'.flac',
|
||||
'.m4a',
|
||||
'.mkv',
|
||||
'.mov',
|
||||
'.mp3',
|
||||
'.ogg',
|
||||
'.opus',
|
||||
'.wav',
|
||||
'.webm',
|
||||
// Design files
|
||||
'.ai',
|
||||
'.fig',
|
||||
'.psd',
|
||||
'.sketch',
|
||||
]);
|
||||
|
||||
const SNIFF_BYTES = 8192;
|
||||
const TEXT_FALLBACK_MIME = 'text/plain; charset=utf-8';
|
||||
const BINARY_FALLBACK_MIME = 'application/octet-stream';
|
||||
|
||||
/**
|
||||
* Resolve the MIME type to serve for a local file preview.
|
||||
*
|
||||
* 1. Known source/image extensions go through the whitelist for a stable,
|
||||
* accurate type (e.g. `.ts` → `text/plain`, not `video/mp2t`).
|
||||
* 2. Known-binary extensions (PDF, archives, executables, media, …)
|
||||
* short-circuit to `application/octet-stream`. Their first 8KB can be
|
||||
* printable ASCII (PDFs are the canonical offender) and we don't want
|
||||
* the sniff to mistakenly route them through the text highlighter.
|
||||
* 3. Anything else — no extension, `.cjs` / `.mjs`, `.lock`, `.editorconfig`,
|
||||
* an arbitrary user file — falls through to a binary sniff on the first
|
||||
* 8KB. Text → `text/plain`, otherwise `application/octet-stream`. This
|
||||
* removes the need to maintain an exhaustive text-extension allow-list.
|
||||
*/
|
||||
export const resolveLocalFileMimeType = (filePath: string, buffer: Buffer): string => {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
const fromWhitelist = EXPORT_MIME_MAP[ext] ?? PREVIEW_IMAGE_MIME_MAP[ext];
|
||||
if (fromWhitelist) return fromWhitelist;
|
||||
|
||||
if (KNOWN_BINARY_EXTENSIONS.has(ext)) return BINARY_FALLBACK_MIME;
|
||||
|
||||
const { isBinary } = sniffBinaryBuffer(buffer.subarray(0, SNIFF_BYTES));
|
||||
return isBinary ? BINARY_FALLBACK_MIME : TEXT_FALLBACK_MIME;
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Select } from '@base-ui/react/select';
|
||||
import type {
|
||||
OverlayCaptureUploadStatus,
|
||||
ScreenCaptureAgentOption,
|
||||
@@ -5,9 +6,8 @@ import type {
|
||||
ScreenCaptureOverlayTheme,
|
||||
} from '@lobechat/electron-client-ipc';
|
||||
import { ModelIcon } from '@lobehub/icons';
|
||||
import { AlertCircleIcon, ChevronDownIcon, Loader2Icon, XIcon } from 'lucide-react';
|
||||
import { AlertCircleIcon, CheckIcon, ChevronDownIcon, Loader2Icon, XIcon } from 'lucide-react';
|
||||
import type {
|
||||
ChangeEvent as ReactChangeEvent,
|
||||
CSSProperties,
|
||||
KeyboardEvent as ReactKeyboardEvent,
|
||||
MouseEvent as ReactMouseEvent,
|
||||
@@ -248,7 +248,7 @@ const ChatPanel = memo<ChatPanelProps>(
|
||||
};
|
||||
}, [activeSelection, dock]);
|
||||
|
||||
const themeStyle = useMemo<CSSProperties | undefined>(() => {
|
||||
const themeVars = useMemo<Record<string, string> | undefined>(() => {
|
||||
if (!theme) return undefined;
|
||||
|
||||
return {
|
||||
@@ -268,9 +268,24 @@ const ChatPanel = memo<ChatPanelProps>(
|
||||
'--lobe-overlay-text-quaternary': theme.colorTextQuaternary,
|
||||
'--lobe-overlay-text-secondary': theme.colorTextSecondary,
|
||||
'--lobe-overlay-text-tertiary': theme.colorTextTertiary,
|
||||
} as CSSProperties;
|
||||
};
|
||||
}, [theme]);
|
||||
|
||||
const themeStyle = themeVars as CSSProperties | undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (!themeVars) return;
|
||||
const root = document.documentElement;
|
||||
for (const [key, value] of Object.entries(themeVars)) {
|
||||
root.style.setProperty(key, value);
|
||||
}
|
||||
return () => {
|
||||
for (const key of Object.keys(themeVars)) {
|
||||
root.style.removeProperty(key);
|
||||
}
|
||||
};
|
||||
}, [themeVars]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!hidden && textareaRef.current) {
|
||||
textareaRef.current.focus();
|
||||
@@ -332,12 +347,12 @@ const ChatPanel = memo<ChatPanelProps>(
|
||||
|
||||
const canSend = selected && prompt.trim().length > 0 && allUploadsReady;
|
||||
|
||||
const handleAgentChange = useCallback((e: ReactChangeEvent<HTMLSelectElement>) => {
|
||||
setAgentId(e.target.value || undefined);
|
||||
const handleAgentChange = useCallback((value: string) => {
|
||||
setAgentId(value || undefined);
|
||||
}, []);
|
||||
|
||||
const handleModelChange = useCallback((e: ReactChangeEvent<HTMLSelectElement>) => {
|
||||
setModelId(e.target.value || undefined);
|
||||
const handleModelChange = useCallback((value: string) => {
|
||||
setModelId(value || undefined);
|
||||
}, []);
|
||||
|
||||
const hasAgents = !!agents && agents.length > 0;
|
||||
@@ -467,71 +482,95 @@ const ChatPanel = memo<ChatPanelProps>(
|
||||
|
||||
<div className={styles.actionBar}>
|
||||
<div className={styles.actionBarLeft}>
|
||||
<label
|
||||
aria-label={OVERLAY_COPY.agentSelectLabel}
|
||||
className={cn(styles.selectChip, !hasAgents && styles.selectChipDisabled)}
|
||||
<Select.Root
|
||||
disabled={!hasAgents}
|
||||
value={agentId ?? ''}
|
||||
onValueChange={handleAgentChange}
|
||||
>
|
||||
<OverlayAvatar
|
||||
avatar={currentAgent?.avatar}
|
||||
background={currentAgent?.backgroundColor}
|
||||
size={18}
|
||||
title={currentAgent?.title}
|
||||
/>
|
||||
<span className={styles.chipLabel}>
|
||||
{currentAgent?.title ?? OVERLAY_COPY.agentSelectPlaceholder}
|
||||
</span>
|
||||
<ChevronDownIcon className={styles.chevron} size={12} strokeWidth={2} />
|
||||
<select
|
||||
<Select.Trigger
|
||||
aria-label={OVERLAY_COPY.agentSelectLabel}
|
||||
className={styles.nativeSelect}
|
||||
disabled={!hasAgents}
|
||||
value={agentId ?? ''}
|
||||
onChange={handleAgentChange}
|
||||
className={cn(styles.selectChip, !hasAgents && styles.selectChipDisabled)}
|
||||
>
|
||||
{!hasAgents && <option value="">{OVERLAY_COPY.agentSelectPlaceholder}</option>}
|
||||
{agents?.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.avatar && typeof item.avatar === 'string' && item.avatar.length <= 4
|
||||
? `${item.avatar} ${item.title}`
|
||||
: item.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<OverlayAvatar
|
||||
avatar={currentAgent?.avatar}
|
||||
background={currentAgent?.backgroundColor}
|
||||
size={18}
|
||||
title={currentAgent?.title}
|
||||
/>
|
||||
<Select.Value className={styles.chipLabel}>
|
||||
{currentAgent?.title ?? OVERLAY_COPY.agentSelectPlaceholder}
|
||||
</Select.Value>
|
||||
<ChevronDownIcon className={styles.chevron} size={12} strokeWidth={2} />
|
||||
</Select.Trigger>
|
||||
<Select.Portal>
|
||||
<Select.Positioner
|
||||
align="start"
|
||||
className={styles.popupPositioner}
|
||||
sideOffset={6}
|
||||
>
|
||||
<Select.Popup className={styles.popup}>
|
||||
{agents?.map((item) => (
|
||||
<Select.Item className={styles.popupItem} key={item.id} value={item.id}>
|
||||
<Select.ItemIndicator className={styles.popupItemIndicator}>
|
||||
<CheckIcon size={12} strokeWidth={2.4} />
|
||||
</Select.ItemIndicator>
|
||||
<Select.ItemText>
|
||||
{item.avatar &&
|
||||
typeof item.avatar === 'string' &&
|
||||
item.avatar.length <= 4
|
||||
? `${item.avatar} ${item.title}`
|
||||
: item.title}
|
||||
</Select.ItemText>
|
||||
</Select.Item>
|
||||
))}
|
||||
</Select.Popup>
|
||||
</Select.Positioner>
|
||||
</Select.Portal>
|
||||
</Select.Root>
|
||||
|
||||
{showModelSelector && (
|
||||
<label
|
||||
aria-label={OVERLAY_COPY.modelSelectLabel}
|
||||
className={cn(styles.selectChip, !hasModels && styles.selectChipDisabled)}
|
||||
<Select.Root
|
||||
disabled={!hasModels}
|
||||
value={modelId ?? ''}
|
||||
onValueChange={handleModelChange}
|
||||
>
|
||||
{currentModel ? (
|
||||
<span className={styles.modelIconBox}>
|
||||
<ModelIcon model={currentModel.id} size={16} />
|
||||
</span>
|
||||
) : (
|
||||
<span className={styles.modelIconBoxFallback} />
|
||||
)}
|
||||
<span className={styles.chipLabel}>
|
||||
{currentModel?.displayName ??
|
||||
currentModel?.id ??
|
||||
OVERLAY_COPY.modelSelectPlaceholder}
|
||||
</span>
|
||||
<ChevronDownIcon className={styles.chevron} size={12} strokeWidth={2} />
|
||||
<select
|
||||
<Select.Trigger
|
||||
aria-label={OVERLAY_COPY.modelSelectLabel}
|
||||
className={styles.nativeSelect}
|
||||
disabled={!hasModels}
|
||||
value={modelId ?? ''}
|
||||
onChange={handleModelChange}
|
||||
className={cn(styles.selectChip, !hasModels && styles.selectChipDisabled)}
|
||||
>
|
||||
{!hasModels && <option value="">{OVERLAY_COPY.modelSelectPlaceholder}</option>}
|
||||
{models?.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.displayName ?? item.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{currentModel ? (
|
||||
<span className={styles.modelIconBox}>
|
||||
<ModelIcon model={currentModel.id} size={16} />
|
||||
</span>
|
||||
) : (
|
||||
<span className={styles.modelIconBoxFallback} />
|
||||
)}
|
||||
<Select.Value className={styles.chipLabel}>
|
||||
{currentModel?.displayName ??
|
||||
currentModel?.id ??
|
||||
OVERLAY_COPY.modelSelectPlaceholder}
|
||||
</Select.Value>
|
||||
<ChevronDownIcon className={styles.chevron} size={12} strokeWidth={2} />
|
||||
</Select.Trigger>
|
||||
<Select.Portal>
|
||||
<Select.Positioner
|
||||
align="start"
|
||||
className={styles.popupPositioner}
|
||||
sideOffset={6}
|
||||
>
|
||||
<Select.Popup className={styles.popup}>
|
||||
{models?.map((item) => (
|
||||
<Select.Item className={styles.popupItem} key={item.id} value={item.id}>
|
||||
<Select.ItemIndicator className={styles.popupItemIndicator}>
|
||||
<CheckIcon size={12} strokeWidth={2.4} />
|
||||
</Select.ItemIndicator>
|
||||
<Select.ItemText>{item.displayName ?? item.id}</Select.ItemText>
|
||||
</Select.Item>
|
||||
))}
|
||||
</Select.Popup>
|
||||
</Select.Positioner>
|
||||
</Select.Portal>
|
||||
</Select.Root>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -534,3 +534,57 @@ globalStyle(`.${multiSelectionRail}::-webkit-scrollbar`, {
|
||||
globalStyle(`.${textarea}::selection`, {
|
||||
background: 'color-mix(in srgb, var(--lobe-overlay-primary) 22%, transparent)',
|
||||
});
|
||||
|
||||
export const popupPositioner = style({
|
||||
outline: 'none',
|
||||
zIndex: 114_514,
|
||||
});
|
||||
|
||||
export const popup = style({
|
||||
background: v(vars.colorBgElevated),
|
||||
border: `1px solid ${v(vars.colorBorderSecondary)}`,
|
||||
borderRadius: 10,
|
||||
boxShadow: v(vars.panelShadow),
|
||||
color: v(vars.colorText),
|
||||
fontSize: 12,
|
||||
maxHeight: 240,
|
||||
minWidth: 180,
|
||||
outline: 'none',
|
||||
overflowY: 'auto',
|
||||
padding: 4,
|
||||
});
|
||||
|
||||
export const popupItem = style({
|
||||
alignItems: 'center',
|
||||
borderRadius: 6,
|
||||
color: v(vars.colorText),
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
gap: 6,
|
||||
outline: 'none',
|
||||
padding: '6px 8px 6px 24px',
|
||||
position: 'relative',
|
||||
userSelect: 'none',
|
||||
selectors: {
|
||||
'&[data-highlighted]': {
|
||||
background: v(vars.colorFillTertiary),
|
||||
},
|
||||
'&[data-disabled]': {
|
||||
cursor: 'not-allowed',
|
||||
opacity: 0.45,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const popupItemIndicator = style({
|
||||
alignItems: 'center',
|
||||
color: v(vars.colorPrimary),
|
||||
display: 'inline-flex',
|
||||
height: 12,
|
||||
justifyContent: 'center',
|
||||
left: 6,
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
width: 12,
|
||||
});
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"devDependencies": {
|
||||
"@cloudflare/vitest-pool-workers": "^0.12.19",
|
||||
"@cloudflare/workers-types": "^4.20260301.1",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "~3.2.4",
|
||||
"wrangler": "^4.70.0"
|
||||
}
|
||||
|
||||
@@ -427,10 +427,10 @@ When('用户选择删除选项', async function (this: CustomWorld) {
|
||||
When('用户确认删除', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 确认删除...');
|
||||
|
||||
// A confirmation modal should appear
|
||||
const confirmButton = this.page.locator('.ant-modal-confirm-btns button.ant-btn-dangerous');
|
||||
const confirmButton = this.page
|
||||
.getByRole('dialog')
|
||||
.getByRole('button', { name: /^(ok|delete|删除|确认|确定)$/i });
|
||||
|
||||
// Wait for modal to appear
|
||||
await expect(confirmButton).toBeVisible({ timeout: 5000 });
|
||||
await confirmButton.click();
|
||||
|
||||
|
||||
@@ -294,7 +294,9 @@ When('用户在菜单中选择删除', async function (this: CustomWorld) {
|
||||
When('用户在弹窗中确认删除', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 确认删除...');
|
||||
|
||||
const confirmButton = this.page.locator('.ant-modal-confirm-btns button.ant-btn-dangerous');
|
||||
const confirmButton = this.page
|
||||
.getByRole('dialog')
|
||||
.getByRole('button', { name: /^(ok|delete|删除|确认|确定)$/i });
|
||||
await expect(confirmButton).toBeVisible({ timeout: 5000 });
|
||||
await confirmButton.click();
|
||||
await this.page.waitForTimeout(500);
|
||||
|
||||
@@ -100,6 +100,35 @@
|
||||
"channel.groupPolicyOpenHint": "Respond in any group, channel, or thread",
|
||||
"channel.historyLimit": "History Message Limit",
|
||||
"channel.historyLimitHint": "Default number of messages to fetch when reading channel history",
|
||||
"channel.imessage.applicationIdHint": "A stable identifier shared by the cloud channel and the Desktop bridge.",
|
||||
"channel.imessage.applicationIdPlaceholder": "e.g. home-mac-mini",
|
||||
"channel.imessage.blueBubblesPassword": "BlueBubbles Password",
|
||||
"channel.imessage.blueBubblesPasswordHint": "Stored locally in LobeHub Desktop and used only to call the local BlueBubbles server.",
|
||||
"channel.imessage.blueBubblesServerUrl": "BlueBubbles Server URL",
|
||||
"channel.imessage.blueBubblesServerUrlHint": "The local BlueBubbles server URL reachable from this Desktop app.",
|
||||
"channel.imessage.bridgeEnabled": "Enable Bridge",
|
||||
"channel.imessage.bridgeEnabledHint": "When enabled, LobeHub Desktop receives local BlueBubbles webhooks and forwards them to LobeHub.",
|
||||
"channel.imessage.bridgeMissingApplicationId": "Enter the Application ID first.",
|
||||
"channel.imessage.bridgeMissingPassword": "Enter the BlueBubbles password first.",
|
||||
"channel.imessage.bridgeMissingServerUrl": "Enter the BlueBubbles Server URL first.",
|
||||
"channel.imessage.bridgeMissingWebhookSecret": "Enter the Webhook Secret first.",
|
||||
"channel.imessage.bridgePasswordSavedPlaceholder": "Leave blank to keep the saved password",
|
||||
"channel.imessage.bridgeRefresh": "Refresh",
|
||||
"channel.imessage.bridgeRefreshFailed": "Failed to refresh iMessage Desktop bridge",
|
||||
"channel.imessage.bridgeRunning": "Running",
|
||||
"channel.imessage.bridgeSave": "Save Bridge",
|
||||
"channel.imessage.bridgeSaveFailed": "Failed to save iMessage Desktop bridge",
|
||||
"channel.imessage.bridgeSaved": "iMessage Desktop bridge saved",
|
||||
"channel.imessage.bridgeStopped": "Stopped",
|
||||
"channel.imessage.bridgeTest": "Test BlueBubbles",
|
||||
"channel.imessage.bridgeTestFailed": "BlueBubbles test failed",
|
||||
"channel.imessage.bridgeTestSuccess": "BlueBubbles connection passed",
|
||||
"channel.imessage.description": "Connect this assistant to iMessage through the local LobeHub Desktop BlueBubbles bridge.",
|
||||
"channel.imessage.desktopBridge": "Desktop Bridge",
|
||||
"channel.imessage.desktopDeviceId": "Desktop Device ID",
|
||||
"channel.imessage.desktopDeviceIdHint": "The LobeHub Desktop device that runs the local BlueBubbles bridge. Find it in Desktop Gateway settings.",
|
||||
"channel.imessage.webhookSecret": "Webhook Secret",
|
||||
"channel.imessage.webhookSecretHint": "A shared secret used between LobeHub Desktop and the cloud webhook. Use the same value in the Desktop bridge config.",
|
||||
"channel.importConfig": "Import Configuration",
|
||||
"channel.importFailed": "Failed to import configuration",
|
||||
"channel.importInvalidFormat": "Invalid configuration file format",
|
||||
@@ -176,6 +205,7 @@
|
||||
"channel.userIdHint": "Lets AI tools reach you proactively (e.g. reminders); auto-trusted by the global allowlist",
|
||||
"channel.userIdHint.discord": "Enable Developer Mode (Settings → Advanced), then right-click your avatar → Copy User ID.",
|
||||
"channel.userIdHint.feishu": "Open your app on the Feishu / Lark Open Platform → Permissions, then look up your Open ID.",
|
||||
"channel.userIdHint.imessage": "Use your iMessage handle as seen in BlueBubbles, usually an email address or E.164 phone number.",
|
||||
"channel.userIdHint.line": "Open the LINE Developers Console → your channel → Basic settings tab, and copy \"Your user ID\" (starts with U, 33 chars).",
|
||||
"channel.userIdHint.qq": "Your QQ number, shown on your QQ profile page.",
|
||||
"channel.userIdHint.slack": "Open your Slack profile → ⋮ More → Copy member ID (starts with U).",
|
||||
|
||||
+20
-7
@@ -28,8 +28,8 @@
|
||||
"agentSignal.receipts.memory.detail": "Saved this for future replies",
|
||||
"agentSignal.receipts.memory.title": "Memory saved",
|
||||
"agentSignal.receipts.recentActivity": "Recent activity",
|
||||
"agentSignal.receipts.skill.detail": "Improved how this assistant handles similar requests",
|
||||
"agentSignal.receipts.skill.title": "Skill updated",
|
||||
"agentSignal.receipts.skill.detail": "Self-refined how this agent handles similar requests",
|
||||
"agentSignal.receipts.skill.title": "Auto-learned a new skill",
|
||||
"agents": "Agents",
|
||||
"artifact.generating": "Generating",
|
||||
"artifact.inThread": "Cannot view in subtopic, please switch to the main conversation area to open",
|
||||
@@ -208,6 +208,17 @@
|
||||
"heteroAgent.cloudRepo.noRepos": "No repositories configured. Add them in agent settings.",
|
||||
"heteroAgent.cloudRepo.notSet": "No repo selected",
|
||||
"heteroAgent.cloudRepo.sectionTitle": "Repositories",
|
||||
"heteroAgent.executionTarget.infoTooltip": "Pick a remote device to drive that machine from the web. \"This device\" runs the agent locally and is only available inside the desktop app.",
|
||||
"heteroAgent.executionTarget.loading": "Loading devices…",
|
||||
"heteroAgent.executionTarget.local": "This device",
|
||||
"heteroAgent.executionTarget.localDesc": "Run as a local process on this desktop app",
|
||||
"heteroAgent.executionTarget.noDevices": "No remote devices yet. Install the desktop app or run `lh connect` on another machine.",
|
||||
"heteroAgent.executionTarget.offline": "Offline",
|
||||
"heteroAgent.executionTarget.online": "Online",
|
||||
"heteroAgent.executionTarget.sandbox": "Cloud sandbox",
|
||||
"heteroAgent.executionTarget.sandboxDesc": "Run in an ephemeral cloud sandbox",
|
||||
"heteroAgent.executionTarget.title": "Execution Device",
|
||||
"heteroAgent.executionTarget.unknownDevice": "Unknown device",
|
||||
"heteroAgent.fullAccess.label": "Full access",
|
||||
"heteroAgent.fullAccess.tooltip": "Claude Code runs locally with full read/write access to the working directory. Switching permission modes is not available yet.",
|
||||
"heteroAgent.resumeReset.cwdChanged": "Working directory changed. Previous Claude Code session can only be resumed from its original directory, so a new conversation has started.",
|
||||
@@ -367,8 +378,10 @@
|
||||
"platformAgent.create.comingSoon": "Coming Soon",
|
||||
"platformAgent.create.create": "Create Agent",
|
||||
"platformAgent.create.creating": "Creating...",
|
||||
"platformAgent.create.desc.amp": "Connect to Amp running on one of your devices",
|
||||
"platformAgent.create.desc.hermes": "Connect to Hermes running on one of your devices",
|
||||
"platformAgent.create.desc.openclaw": "Connect to OpenClaw running on one of your devices",
|
||||
"platformAgent.create.desc.opencode": "Connect to OpenCode running on one of your devices",
|
||||
"platformAgent.create.descriptionPlaceholder": "Brief description (optional)",
|
||||
"platformAgent.create.downloadDesktop": "Download Desktop App",
|
||||
"platformAgent.create.fetchingProfile": "Fetching profile...",
|
||||
@@ -750,9 +763,9 @@
|
||||
"taskSchedule.weekdays.thu": "Thu",
|
||||
"taskSchedule.weekdays.tue": "Tue",
|
||||
"taskSchedule.weekdays.wed": "Wed",
|
||||
"thread.closeSubagentThread": "Collapse SubAgent conversation",
|
||||
"thread.closeSubagentThread": "Hide Detail",
|
||||
"thread.divider": "Subtopic",
|
||||
"thread.openSubagentThread": "View full SubAgent conversation",
|
||||
"thread.openSubagentThread": "View Detail",
|
||||
"thread.subagentReadOnlyHint": "SubAgent conversations are read-only — execution is driven by the parent agent.",
|
||||
"thread.threadMessageCount": "{{messageCount}} messages",
|
||||
"thread.title": "Subtopic",
|
||||
@@ -805,7 +818,7 @@
|
||||
"tool.intervention.viewParameters": "View parameters ({{count}})",
|
||||
"toolAuth.authorize": "Authorize",
|
||||
"toolAuth.authorizing": "Authorizing...",
|
||||
"toolAuth.hint": "Without authorization or configuration, Skills may not work. This can limit the Agent or cause errors.",
|
||||
"toolAuth.hint": "When Skills aren't authorized or configured, the related Skills won't work and the Agent's capabilities may be limited or run into errors.",
|
||||
"toolAuth.signIn": "Sign In",
|
||||
"toolAuth.title": "Authorize Skills for this Agent",
|
||||
"topic.checkOpenNewTopic": "Start a new topic?",
|
||||
@@ -862,8 +875,8 @@
|
||||
"workflow.toolDisplayName.addPreferenceMemory": "Saved memory",
|
||||
"workflow.toolDisplayName.calculate": "Calculated",
|
||||
"workflow.toolDisplayName.callAgent": "Called an agent",
|
||||
"workflow.toolDisplayName.callSubAgent": "Dispatched a sub-agent",
|
||||
"workflow.toolDisplayName.callSubAgents": "Dispatched sub-agents",
|
||||
"workflow.toolDisplayName.callSubAgent": "Call SubAgent",
|
||||
"workflow.toolDisplayName.callSubAgents": "Call SubAgents",
|
||||
"workflow.toolDisplayName.clearTodos": "Cleared todos",
|
||||
"workflow.toolDisplayName.copyDocument": "Copied a document",
|
||||
"workflow.toolDisplayName.crawlMultiPages": "Crawled pages",
|
||||
|
||||
@@ -353,6 +353,7 @@
|
||||
"messengerBanner.title": "Talk to Lobe AI on your favorite messaging apps",
|
||||
"more": "More",
|
||||
"navPanel.agent": "Agents",
|
||||
"navPanel.bottomDivider": "Items below anchor to bottom",
|
||||
"navPanel.customizeSidebar": "Customize Sidebar",
|
||||
"navPanel.displayItems": "Display Items",
|
||||
"navPanel.hidden": "Hidden",
|
||||
|
||||
@@ -60,17 +60,7 @@
|
||||
"response.520": "We apologize, the server encountered an unexpected issue that prevented it from completing your request. Please try again later; we are working to resolve this issue.",
|
||||
"response.522": "We apologize, the server connection timed out and was unable to respond to your request in a timely manner. This may be due to an unstable network or the server being temporarily inaccessible. Please try again later; we are working to restore service.",
|
||||
"response.524": "We apologize, the server timed out while waiting for a response, possibly due to a slow reply. Please try again later.",
|
||||
"response.AccountDeactivated": "Your account has been deactivated or suspended. This may be due to policy, security, or account review reasons. Please contact the provider support for assistance.",
|
||||
"response.AgentRuntimeError": "Lobe language model runtime execution error. Please troubleshoot or retry based on the following information.",
|
||||
"response.ComfyUIBizError": "An error occurred while requesting the ComfyUI service. Please troubleshoot using the information below or try again.",
|
||||
"response.ComfyUIEmptyResult": "No image was generated by ComfyUI. Please check the model configuration or try again.",
|
||||
"response.ComfyUIModelError": "Failed to load the ComfyUI model. Please ensure the model file exists.",
|
||||
"response.ComfyUIServiceUnavailable": "Failed to connect to the ComfyUI service. Please ensure it is running properly and the service URL is correctly configured.",
|
||||
"response.ComfyUIUploadFailed": "Failed to upload image to ComfyUI. Please check the server connection or try again.",
|
||||
"response.ComfyUIWorkflowError": "ComfyUI workflow execution failed. Please verify the workflow configuration.",
|
||||
"response.ConnectionCheckFailed": "The request returned empty. Please check if the API proxy address does not end with `/v1`.",
|
||||
"response.CreateMessageError": "Sorry, the message could not be sent successfully. Please copy the content and try sending it again. This message will not be retained after refreshing the page.",
|
||||
"response.ExceededContextWindow": "The current request content exceeds the length that the model can handle. Please reduce the amount of content and try again.",
|
||||
"response.ExceededContextWindowCloud": "The conversation is too long to process. Please edit your last message to reduce input or delete some messages and try again.",
|
||||
"response.FreePlanLimit": "You are currently a free user and cannot use this feature. Please upgrade to a paid plan to continue using it.",
|
||||
"response.GoogleAIBlockReason.BLOCKLIST": "The content includes blocked terms. Please rephrase and try again.",
|
||||
@@ -83,21 +73,9 @@
|
||||
"response.GoogleAIBlockReason.SPII": "The content may include sensitive personal information (SPII). Please remove sensitive details and try again.",
|
||||
"response.GoogleAIBlockReason.default": "The content was blocked ({{blockReason}}). Please adjust it and try again.",
|
||||
"response.InsufficientBudgetForModel": "Your remaining credits are insufficient for this model. Please top up credits, upgrade your plan, or try a less expensive model.",
|
||||
"response.InsufficientQuota": "Sorry, the quota for this key has been reached. Please check if your account balance is sufficient or try again after increasing the key's quota.",
|
||||
"response.InvalidAccessCode": "Invalid access code or empty. Please enter the correct access code or add a custom API Key.",
|
||||
"response.InvalidBedrockCredentials": "Bedrock authentication failed. Please check the AccessKeyId/SecretAccessKey and retry.",
|
||||
"response.InvalidComfyUIArgs": "Invalid ComfyUI configuration. Please check the settings and try again.",
|
||||
"response.InvalidGithubToken": "The GitHub Personal Access Token is incorrect or empty. Please check your GitHub Personal Access Token and try again.",
|
||||
"response.InvalidOllamaArgs": "Invalid Ollama configuration, please check Ollama configuration and try again",
|
||||
"response.InvalidProviderAPIKey": "{{provider}} API Key is incorrect or empty, please check your {{provider}} API Key and try again",
|
||||
"response.InvalidVertexCredentials": "Vertex authentication failed. Please check your credentials and try again.",
|
||||
"response.LobeHubModelDeprecated": "The model \"{{model}}\" is no longer available. Please pick a current model from the model selector.",
|
||||
"response.LocationNotSupportError": "We're sorry, your current location does not support this model service. This may be due to regional restrictions or the service not being available. Please confirm if the current location supports using this service, or try using a different location.",
|
||||
"response.ModelNotFound": "Sorry, the requested model could not be found. It may not exist or you may not have the necessary access permissions. Please try again after changing the API Key or adjusting your access permissions.",
|
||||
"response.NoOpenAIAPIKey": "OpenAI API Key is empty, please add a custom OpenAI API Key",
|
||||
"response.OllamaBizError": "Error requesting Ollama service, please troubleshoot or retry based on the following information",
|
||||
"response.OllamaServiceUnavailable": "Ollama service is unavailable. Please check if Ollama is running properly or if the cross-origin configuration of Ollama is set correctly.",
|
||||
"response.PermissionDenied": "Sorry, you do not have permission to access this service. Please check if your key has the necessary access rights.",
|
||||
"response.PluginApiNotFound": "Sorry, the API does not exist in the skill's manifest. Please check if your request method matches the skill manifest API",
|
||||
"response.PluginApiParamsError": "Sorry, the input parameter validation for the skill request failed. Please check if the input parameters match the API description",
|
||||
"response.PluginFailToTransformArguments": "Sorry, the skill failed to parse the arguments. Please try regenerating the agent message or switch to a more powerful AI model with Tools Calling capability and try again",
|
||||
@@ -111,14 +89,11 @@
|
||||
"response.PluginOpenApiInitError": "Sorry, the OpenAPI client failed to initialize. Please check if the OpenAPI configuration information is correct.",
|
||||
"response.PluginServerError": "Skill server request returned an error. Please check your skill manifest file, skill configuration, or server implementation based on the error information below",
|
||||
"response.PluginSettingsInvalid": "This skill needs to be correctly configured before it can be used. Please check if your configuration is correct",
|
||||
"response.ProviderBizError": "Error requesting {{provider}} service, please troubleshoot or retry based on the following information",
|
||||
"response.ProviderContentModeration": "Content policy check failed. Revise your prompt and try again.",
|
||||
"response.ProviderContentModerationWarning": "Repeated content policy rejections detected. Please revise your prompt before retrying.",
|
||||
"response.ProviderImageContentModerationWarning": "Repeated image safety rejections detected. Similar prompts may temporarily pause image generation.",
|
||||
"response.QuotaLimitReached": "Sorry, the token usage or request count has reached the quota limit for this key. Please increase the key's quota or try again later.",
|
||||
"response.QuotaLimitReachedCloud": "The model service is currently under heavy load. Please try again later or switch to another model.",
|
||||
"response.ServerAgentRuntimeError": "Sorry, the Agent service is currently unavailable. Please try again later or contact us via email for support.",
|
||||
"response.StreamChunkError": "Error parsing the message chunk of the streaming request. Please check if the current API interface complies with the standard specifications, or contact your API provider for assistance.",
|
||||
"response.SubscriptionKeyMismatch": "We apologize for the inconvenience. Due to a temporary system malfunction, your current subscription usage is inactive. Please click the button below to restore your subscription, or contact us via email for support.",
|
||||
"response.SubscriptionPlanLimit": "Your subscription points have been exhausted, and you cannot use this feature. Please upgrade to a higher plan or configure a custom model API to continue using it.",
|
||||
"response.SubscriptionPlanLimitUltimate": "Your subscription points have been exhausted, and you cannot use this feature. Please top up credits or configure a custom model API to continue using it.",
|
||||
|
||||
@@ -53,6 +53,8 @@
|
||||
"home.uploadEntries.folder.title": "Upload Folder",
|
||||
"home.uploadEntries.library.title": "Create New Library",
|
||||
"home.uploadEntries.newPage.title": "New Page",
|
||||
"library.hierarchy.empty.desc": "Add files or create a folder to get started",
|
||||
"library.hierarchy.empty.title": "Nothing here yet",
|
||||
"library.list.confirmRemoveLibrary": "You are about to delete this library. The files within it will not be deleted but moved to All Files. This action cannot be undone, so please proceed with caution.",
|
||||
"library.list.empty": "Click <1>+</1> to create a new library",
|
||||
"library.new": "New Library",
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
"generation.actions.seedCopied": "Seed Copied to Clipboard",
|
||||
"generation.actions.seedCopyFailed": "Failed to Copy Seed",
|
||||
"generation.metadata.count": "{{count}} Images",
|
||||
"generation.status.failed": "Generation Failed",
|
||||
"generation.status.failed": "Generation hit a problem. Adjust the prompt and try again",
|
||||
"generation.status.generating": "Generating...",
|
||||
"notSupportGuide.desc": "The current deployment mode does not support AI image generation. Switch to the <1>server database deployment mode</1>, or use <3>LobeHub Cloud</3>.",
|
||||
"notSupportGuide.features.fileIntegration.desc": "Deep integration with the file management system; generated images are automatically saved to the file system for unified management and organization.",
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
"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.executionDeviceSwitcher.desc": "Surface the execution-device switcher in the heterogeneous agent toolbar so you can route runs to this device, a cloud sandbox, or a bound remote device.",
|
||||
"features.executionDeviceSwitcher.title": "Execution Device Switcher",
|
||||
"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.",
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"AccountDeactivated": "Your account has been deactivated or suspended. This may be due to policy, security, or account review reasons. Please contact the provider support for assistance.",
|
||||
"AgentRuntimeError": "Lobe language model runtime execution error. Please troubleshoot or retry based on the following information.",
|
||||
"CapabilityNotSupported": "Sorry, this model does not support the requested capability (such as vision input or tool calling). Please switch to a model that supports it.",
|
||||
"ComfyUIBizError": "An error occurred while requesting the ComfyUI service. Please troubleshoot using the information below or try again.",
|
||||
"ComfyUIEmptyResult": "No image was generated by ComfyUI. Please check the model configuration or try again.",
|
||||
"ComfyUIModelError": "Failed to load the ComfyUI model. Please ensure the model file exists.",
|
||||
"ComfyUIServiceUnavailable": "Failed to connect to the ComfyUI service. Please ensure it is running properly and the service URL is correctly configured.",
|
||||
"ComfyUIUploadFailed": "Failed to upload image to ComfyUI. Please check the server connection or try again.",
|
||||
"ComfyUIWorkflowError": "ComfyUI workflow execution failed. Please verify the workflow configuration.",
|
||||
"ConnectionCheckFailed": "The request returned empty. Please check if the API proxy address does not end with `/v1`.",
|
||||
"ContentModeration": "Sorry, the content was rejected by the upstream safety filter. Please revise your prompt and try again.",
|
||||
"ExceededContextWindow": "The current request content exceeds the length that the model can handle. Please reduce the amount of content and try again.",
|
||||
"InsufficientQuota": "Sorry, the quota for this key has been reached. Please check if your account balance is sufficient or try again after increasing the key's quota.",
|
||||
"InvalidBedrockCredentials": "Bedrock authentication failed. Please check the AccessKeyId/SecretAccessKey and retry.",
|
||||
"InvalidComfyUIArgs": "Invalid ComfyUI configuration. Please check the settings and try again.",
|
||||
"InvalidGithubToken": "The GitHub Personal Access Token is incorrect or empty. Please check your GitHub Personal Access Token and try again.",
|
||||
"InvalidOllamaArgs": "Invalid Ollama configuration, please check Ollama configuration and try again",
|
||||
"InvalidProviderAPIKey": "{{provider}} API Key is incorrect or empty, please check your {{provider}} API Key and try again",
|
||||
"InvalidRequestFormat": "Sorry, the upstream provider rejected the request as malformed. Please check the input or try a different model.",
|
||||
"InvalidVertexCredentials": "Vertex authentication failed. Please check your credentials and try again.",
|
||||
"LocationNotSupportError": "We're sorry, your current location does not support this model service. This may be due to regional restrictions or the service not being available. Please confirm if the current location supports using this service, or try using a different location.",
|
||||
"ModelNotFound": "Sorry, the requested model could not be found. It may not exist or you may not have the necessary access permissions. Please try again after changing the API Key or adjusting your access permissions.",
|
||||
"NoAvailableChannel": "Sorry, the proxy or router has no available channel for the requested model. Please switch the channel/key configuration or try again later.",
|
||||
"OllamaBizError": "Error requesting Ollama service, please troubleshoot or retry based on the following information",
|
||||
"OllamaServiceUnavailable": "Ollama service is unavailable. Please check if Ollama is running properly or if the cross-origin configuration of Ollama is set correctly.",
|
||||
"OperationInactivityTimeout": "The agent operation was idle for too long and was terminated. Please retry the request.",
|
||||
"PermissionDenied": "Sorry, you do not have permission to access this service. Please check if your key has the necessary access rights.",
|
||||
"ProviderBizError": "Error requesting {{provider}} service, please troubleshoot or retry based on the following information",
|
||||
"ProviderNetworkError": "Connection to the provider timed out or was dropped. Please check your network and try again.",
|
||||
"ProviderServiceUnavailable": "The provider is temporarily overloaded or unavailable. Please try again shortly.",
|
||||
"QuotaLimitReached": "Sorry, the token usage or request count has reached the quota limit for this key. Please increase the key's quota or try again later.",
|
||||
"RateLimitExceeded": "Sorry, the token usage or request count has reached the rate limit for this key. Please try again later or increase the key's quota.",
|
||||
"StreamChunkError": "Error parsing the message chunk of the streaming request. Please check if the current API interface complies with the standard specifications, or contact your API provider for assistance.",
|
||||
"UserConfigError": "Provider configuration is invalid (incorrect base URL, missing environment variable, virtual-key restriction, etc.). Please review the provider settings."
|
||||
}
|
||||
@@ -400,6 +400,7 @@
|
||||
"deepseek-ai/DeepSeek-V3.2.description": "DeepSeek-V3.2 is a model that combines high computational efficiency with excellent reasoning and Agent performance. Its approach is based on three major technological breakthroughs: DeepSeek Sparse Attention (DSA), an efficient attention mechanism that significantly reduces computational complexity while maintaining model performance, and is specifically optimized for long-context scenarios; a scalable reinforcement learning framework, through which the model's performance can rival GPT-5, and its high-compute version can rival Gemini-3.0-Pro in reasoning capabilities; and a large-scale Agent task synthesis pipeline, designed to integrate reasoning capabilities into tool-using scenarios, thereby improving instruction-following and generalization abilities in complex interactive environments. The model achieved gold medal results in the 2025 International Mathematical Olympiad (IMO) and International Informatics Olympiad (IOI).",
|
||||
"deepseek-ai/DeepSeek-V3.description": "DeepSeek-V3 is a 671B-parameter MoE model using MLA and DeepSeekMoE with loss-free load balancing for efficient training and inference. Pretrained on 14.8T high-quality tokens with SFT and RL, it outperforms other open models and approaches leading closed models.",
|
||||
"deepseek-ai/DeepSeek-V4-Flash.description": "DeepSeek-V4-Flash is a preview version of the MoE language model in the DeepSeek-V4 series. The total parameter size is 284B, the activation parameter size is 13B, and it supports 1M tokens ultra-long context.The model uses a hybrid attention architecture that combines CSA and HCA, and introduces mHC and Muon Optimizer to improve long-context reasoning efficiency, training stability, and overall performance.",
|
||||
"deepseek-ai/DeepSeek-V4-Pro.description": "DeepSeek-V4-Pro is the flagship MoE language model in the DeepSeek-V4 series, with 1.6T total parameters and 49B active parameters, natively supporting an ultra-long context of 1 million tokens. The model adopts an innovative hybrid attention architecture combining Compressed Sparse Attention (CSA) and Highly Compressed Attention (HCA), requiring only 27% of DeepSeek-V3.2 per-token inference FLOPs and 10% KV cache at 1M context. It also introduces Manifold-Constrained Hyper Connections (mHC) to enhance inter-layer signal propagation stability, and employs the Muon optimizer to accelerate convergence. DeepSeek-V4-Pro is pretrained on over 32T high-quality diverse tokens, with post-training using a two-stage paradigm of independent domain expert cultivation plus online policy distillation for unified integration. Its maximum reasoning intensity mode DeepSeek-V4-Pro-Max achieves top performance on coding benchmarks and significantly narrows the gap with leading closed-source models on reasoning and agentic tasks, making it one of the strongest open-source models today, supporting Non-think, Think High, and Think Max reasoning intensity modes.",
|
||||
"deepseek-ai/deepseek-llm-67b-chat.description": "DeepSeek LLM Chat (67B) is an innovative model offering deep language understanding and interaction.",
|
||||
"deepseek-ai/deepseek-v3.1-terminus.description": "DeepSeek V3.1 is a next-gen reasoning model with stronger complex reasoning and chain-of-thought for deep analysis tasks.",
|
||||
"deepseek-ai/deepseek-v3.2.description": "DeepSeek V3.2 is a next-gen reasoning model with stronger complex reasoning and chain-of-thought capabilities.",
|
||||
|
||||
@@ -72,10 +72,9 @@
|
||||
"builtins.lobe-agent.apiName.analyzeVisualMedia": "Analyze visual media",
|
||||
"builtins.lobe-agent.apiName.analyzeVisualMedia.mediaCount": "{{count}} media",
|
||||
"builtins.lobe-agent.apiName.analyzeVisualMedia.result": "Analyze visual media: <question>{{question}}</question>",
|
||||
"builtins.lobe-agent.apiName.callSubAgent": "Call sub-agent",
|
||||
"builtins.lobe-agent.apiName.callSubAgent.completed": "Sub-agent dispatched: ",
|
||||
"builtins.lobe-agent.apiName.callSubAgent.loading": "Dispatching sub-agent: ",
|
||||
"builtins.lobe-agent.apiName.callSubAgents": "Call sub-agents",
|
||||
"builtins.lobe-agent.apiName.callSubAgent": "Call SubAgent",
|
||||
"builtins.lobe-agent.apiName.callSubAgents": "Call SubAgents",
|
||||
"builtins.lobe-agent.apiName.callSubAgents.more": "{{count}} in total",
|
||||
"builtins.lobe-agent.apiName.clearTodos": "Clear todos",
|
||||
"builtins.lobe-agent.apiName.clearTodos.modeAll": "all",
|
||||
"builtins.lobe-agent.apiName.clearTodos.modeCompleted": "completed",
|
||||
@@ -87,6 +86,8 @@
|
||||
"builtins.lobe-agent.apiName.updatePlan.completed": "Completed",
|
||||
"builtins.lobe-agent.apiName.updatePlan.modified": "Modified",
|
||||
"builtins.lobe-agent.apiName.updateTodos": "Update todos",
|
||||
"builtins.lobe-agent.subAgent.stats.tokens": "{{count}} tokens",
|
||||
"builtins.lobe-agent.subAgent.stats.tools": "{{count}} tools",
|
||||
"builtins.lobe-agent.title": "Lobe Agent",
|
||||
"builtins.lobe-claude-code.agent.instruction": "Instruction",
|
||||
"builtins.lobe-claude-code.agent.result": "Result",
|
||||
|
||||
@@ -503,6 +503,8 @@
|
||||
"plugin.settings.tooltip": "Skill Configuration",
|
||||
"plugin.store": "Skill Store",
|
||||
"publishToCommunity": "Publish to Community",
|
||||
"serviceModel.contextLimit.placeholder": "Context limit",
|
||||
"serviceModel.memoryModels.title": "Memory Models",
|
||||
"serviceModel.modelAssignments.title": "Model Assignments",
|
||||
"serviceModel.optionalFeatures.title": "Optional Features",
|
||||
"settingAgent.avatar.sizeExceeded": "Image size exceeds 1MB limit, please choose a smaller image",
|
||||
@@ -549,6 +551,9 @@
|
||||
"settingChat.enableAutoScrollOnStreaming.desc": "Override global setting for this assistant",
|
||||
"settingChat.enableAutoScrollOnStreaming.title": "Auto-scroll During AI Response",
|
||||
"settingChat.enableCompressHistory.title": "Enable Automatic Summary of Chat History",
|
||||
"settingChat.enableFollowUpChips.desc": "After each reply, show one-click follow-up reply chips below the message. Requires the global Follow-up model to be configured.",
|
||||
"settingChat.enableFollowUpChips.notConfiguredHint": "Configure the global Follow-up model first to enable this.",
|
||||
"settingChat.enableFollowUpChips.title": "Follow-up Suggestions",
|
||||
"settingChat.enableHistoryCount.alias": "Unlimited",
|
||||
"settingChat.enableHistoryCount.limited": "Include only {{number}} conversation messages",
|
||||
"settingChat.enableHistoryCount.setlimited": "Set limited history messages",
|
||||
@@ -840,6 +845,9 @@
|
||||
"systemAgent.customPrompt.desc": "Once filled out, the system agent will use the custom prompt when generating content",
|
||||
"systemAgent.customPrompt.placeholder": "Please enter custom prompt",
|
||||
"systemAgent.customPrompt.title": "Custom Prompt",
|
||||
"systemAgent.followUpAction.label": "Follow-up Suggestions Model",
|
||||
"systemAgent.followUpAction.modelDesc": "Model used to suggest one-click follow-up replies under each assistant message",
|
||||
"systemAgent.followUpAction.title": "Follow-up Suggestions",
|
||||
"systemAgent.generationTopic.label": "Model",
|
||||
"systemAgent.generationTopic.modelDesc": "Model used to name AI image topics",
|
||||
"systemAgent.generationTopic.title": "AI Image Topic Naming",
|
||||
@@ -850,6 +858,9 @@
|
||||
"systemAgent.inputCompletion.label": "Model",
|
||||
"systemAgent.inputCompletion.modelDesc": "Suggests text while you type. When enabled, this model generates the suggestions.",
|
||||
"systemAgent.inputCompletion.title": "Input Suggestions",
|
||||
"systemAgent.memoryAnalysisAgentConfig.label": "Model",
|
||||
"systemAgent.memoryAnalysisAgentConfig.modelDesc": "Model used to decide whether conversations contain memory and extract identities, preferences, contexts, activities, and experiences.",
|
||||
"systemAgent.memoryAnalysisAgentConfig.title": "Memory Analysis",
|
||||
"systemAgent.promptRewrite.label": "Model",
|
||||
"systemAgent.promptRewrite.modelDesc": "Improves prompts before generation. When enabled, this model rewrites the prompt.",
|
||||
"systemAgent.promptRewrite.title": "Prompt Rewriting",
|
||||
@@ -863,6 +874,12 @@
|
||||
"systemAgent.translation.label": "Model",
|
||||
"systemAgent.translation.modelDesc": "Model used to translate messages",
|
||||
"systemAgent.translation.title": "Message Translation",
|
||||
"systemAgent.userMemoryEmbedding.label": "Model",
|
||||
"systemAgent.userMemoryEmbedding.modelDesc": "Model used to embed memory content for retrieval. The context limit caps each embedding input.",
|
||||
"systemAgent.userMemoryEmbedding.title": "Memory Embedding",
|
||||
"systemAgent.userMemoryPersonaWriter.label": "Model",
|
||||
"systemAgent.userMemoryPersonaWriter.modelDesc": "Model used to write persona-oriented memory summaries.",
|
||||
"systemAgent.userMemoryPersonaWriter.title": "Memory Persona Writer",
|
||||
"tab.about": "About",
|
||||
"tab.addAgentSkill": "Add Agent Skill",
|
||||
"tab.addCustomMcp": "Add Custom MCP Skill",
|
||||
|
||||
@@ -51,6 +51,77 @@
|
||||
"inPopup.focus": "Focus Popup Window",
|
||||
"inPopup.title": "Open in Popup Window",
|
||||
"loadMore": "Load More",
|
||||
"management.actions.newChat": "New chat",
|
||||
"management.actions.select": "Select",
|
||||
"management.actionsMenu.archiveStale.confirm": "Archive {{count}} topics that have been inactive for over 3 months? They will be marked as completed.",
|
||||
"management.actionsMenu.archiveStale.confirmOk": "Archive",
|
||||
"management.actionsMenu.archiveStale.done": "Archived {{count}} stale topics.",
|
||||
"management.actionsMenu.archiveStale.label": "Archive topics inactive for 3+ months",
|
||||
"management.actionsMenu.archiveStale.noneFound": "No stale topics found.",
|
||||
"management.actionsMenu.archiveStale.title": "Archive stale topics?",
|
||||
"management.actionsMenu.autoSummarize.comingSoon": "Auto-summarization is coming soon — track on the roadmap.",
|
||||
"management.actionsMenu.autoSummarize.label": "Auto-generate summaries for topics without one",
|
||||
"management.actionsMenu.title": "More actions",
|
||||
"management.bulk.archive": "Archive",
|
||||
"management.bulk.cancel": "Cancel",
|
||||
"management.bulk.delete": "Delete",
|
||||
"management.bulk.deleteConfirm": "You are about to delete {{count}} topics. This action cannot be undone.",
|
||||
"management.bulk.deleteTitle": "Delete topics?",
|
||||
"management.bulk.favorite": "Favorite",
|
||||
"management.bulk.selectedCount_one": "{{count}} selected",
|
||||
"management.bulk.selectedCount_other": "{{count}} selected",
|
||||
"management.card.noPreview": "No preview available",
|
||||
"management.columns.project": "Project",
|
||||
"management.columns.status": "Status",
|
||||
"management.columns.title": "Title",
|
||||
"management.columns.trigger": "Trigger",
|
||||
"management.columns.updated": "Updated",
|
||||
"management.empty.filtered.action": "Clear filters",
|
||||
"management.empty.filtered.desc": "Try adjusting filters or clearing them to see more topics.",
|
||||
"management.empty.filtered.title": "No topics match these filters",
|
||||
"management.empty.noTopics.action": "Start new chat",
|
||||
"management.empty.noTopics.desc": "Start a conversation with this agent to create your first topic.",
|
||||
"management.empty.noTopics.title": "No topics yet",
|
||||
"management.filters.project.empty": "No projects",
|
||||
"management.filters.project.label": "Project",
|
||||
"management.filters.status.active": "Active",
|
||||
"management.filters.status.all": "All",
|
||||
"management.filters.status.archived": "Archived",
|
||||
"management.filters.status.completed": "Completed",
|
||||
"management.filters.status.favorite": "Favorites",
|
||||
"management.filters.status.running": "Running",
|
||||
"management.filters.time.all": "All time",
|
||||
"management.filters.time.label": "Time",
|
||||
"management.filters.time.month": "Past month",
|
||||
"management.filters.time.today": "Today",
|
||||
"management.filters.time.week": "Past week",
|
||||
"management.filters.trigger.api": "API",
|
||||
"management.filters.trigger.chat": "Chat",
|
||||
"management.filters.trigger.eval": "Eval",
|
||||
"management.filters.trigger.label": "Trigger",
|
||||
"management.filters.trigger.task": "Task",
|
||||
"management.group.byProject": "By project",
|
||||
"management.group.byTime": "By time",
|
||||
"management.group.label": "Group",
|
||||
"management.group.noProject": "No project",
|
||||
"management.group.none": "None",
|
||||
"management.loadingMore": "Loading more topics…",
|
||||
"management.searchPlaceholder": "Search this agent's topics…",
|
||||
"management.sidebarEntry": "Topics",
|
||||
"management.sort.createdAt": "Created time",
|
||||
"management.sort.label": "Sort",
|
||||
"management.sort.title": "Title",
|
||||
"management.sort.updatedAt": "Updated time",
|
||||
"management.status.active": "Active",
|
||||
"management.status.archived": "Archived",
|
||||
"management.status.completed": "Completed",
|
||||
"management.status.failed": "Failed",
|
||||
"management.status.paused": "Paused",
|
||||
"management.status.running": "Running",
|
||||
"management.status.waitingForHuman": "Awaiting input",
|
||||
"management.title": "Topics",
|
||||
"management.view.card": "Card",
|
||||
"management.view.list": "List",
|
||||
"newTopic": "New Topic",
|
||||
"renameModal.description": "Keep it short and easy to recognize.",
|
||||
"renameModal.title": "Rename Topic",
|
||||
|
||||
@@ -100,6 +100,35 @@
|
||||
"channel.groupPolicyOpenHint": "在所有群组、频道、子话题中响应",
|
||||
"channel.historyLimit": "历史消息条数",
|
||||
"channel.historyLimitHint": "读取频道历史消息时默认获取的消息数量",
|
||||
"channel.imessage.applicationIdHint": "云端渠道和桌面端桥接共同使用的稳定标识。",
|
||||
"channel.imessage.applicationIdPlaceholder": "例如 home-mac-mini",
|
||||
"channel.imessage.blueBubblesPassword": "BlueBubbles 密码",
|
||||
"channel.imessage.blueBubblesPasswordHint": "仅保存在 LobeHub Desktop 本地,用于访问本机 BlueBubbles Server。",
|
||||
"channel.imessage.blueBubblesServerUrl": "BlueBubbles Server URL",
|
||||
"channel.imessage.blueBubblesServerUrlHint": "当前桌面端可以访问到的本机 BlueBubbles Server 地址。",
|
||||
"channel.imessage.bridgeEnabled": "启用桥接",
|
||||
"channel.imessage.bridgeEnabledHint": "启用后,LobeHub Desktop 会接收本机 BlueBubbles webhook 并转发给 LobeHub。",
|
||||
"channel.imessage.bridgeMissingApplicationId": "请先填写 Application ID。",
|
||||
"channel.imessage.bridgeMissingPassword": "请先填写 BlueBubbles 密码。",
|
||||
"channel.imessage.bridgeMissingServerUrl": "请先填写 BlueBubbles Server URL。",
|
||||
"channel.imessage.bridgeMissingWebhookSecret": "请先填写 Webhook Secret。",
|
||||
"channel.imessage.bridgePasswordSavedPlaceholder": "留空则沿用已保存的密码",
|
||||
"channel.imessage.bridgeRefresh": "刷新",
|
||||
"channel.imessage.bridgeRefreshFailed": "刷新 iMessage Desktop 桥接失败",
|
||||
"channel.imessage.bridgeRunning": "运行中",
|
||||
"channel.imessage.bridgeSave": "保存桥接",
|
||||
"channel.imessage.bridgeSaveFailed": "保存 iMessage Desktop 桥接失败",
|
||||
"channel.imessage.bridgeSaved": "iMessage Desktop 桥接已保存",
|
||||
"channel.imessage.bridgeStopped": "已停止",
|
||||
"channel.imessage.bridgeTest": "测试 BlueBubbles",
|
||||
"channel.imessage.bridgeTestFailed": "BlueBubbles 测试失败",
|
||||
"channel.imessage.bridgeTestSuccess": "BlueBubbles 连接测试通过",
|
||||
"channel.imessage.description": "通过 LobeHub Desktop 本地 BlueBubbles 桥接将助手连接到 iMessage。",
|
||||
"channel.imessage.desktopBridge": "桌面端桥接",
|
||||
"channel.imessage.desktopDeviceId": "桌面端设备 ID",
|
||||
"channel.imessage.desktopDeviceIdHint": "运行本地 BlueBubbles 桥接的 LobeHub Desktop 设备,可在桌面端 Gateway 设置中找到。",
|
||||
"channel.imessage.webhookSecret": "Webhook Secret",
|
||||
"channel.imessage.webhookSecretHint": "LobeHub Desktop 与云端 webhook 之间使用的共享密钥,需要和桌面端桥接配置保持一致。",
|
||||
"channel.importConfig": "导入平台配置",
|
||||
"channel.importFailed": "配置导入失败",
|
||||
"channel.importInvalidFormat": "配置文件格式无效",
|
||||
@@ -176,6 +205,7 @@
|
||||
"channel.userIdHint": "供 AI 工具主动联系你(如提醒、通知),并自动加入全局白名单",
|
||||
"channel.userIdHint.discord": "在 Discord 设置 → 高级中开启开发者模式,然后右键你的头像 → 复制用户 ID。",
|
||||
"channel.userIdHint.feishu": "在飞书 / Lark 开放平台打开你的应用 → 权限管理,查看你的 Open ID。",
|
||||
"channel.userIdHint.imessage": "使用 BlueBubbles 中显示的 iMessage handle,通常是邮箱或 E.164 手机号。",
|
||||
"channel.userIdHint.line": "打开 LINE Developers Console → 你的 channel → Basic settings 选项卡,复制 \"Your user ID\"(以 U 开头共 33 位)。",
|
||||
"channel.userIdHint.qq": "你的 QQ 号,在 QQ 资料页可见。",
|
||||
"channel.userIdHint.slack": "打开 Slack 个人资料 → ⋮ 更多 → 复制 Member ID(以 U 开头)。",
|
||||
|
||||
+20
-7
@@ -28,8 +28,8 @@
|
||||
"agentSignal.receipts.memory.detail": "已保存以供未来回复使用",
|
||||
"agentSignal.receipts.memory.title": "记忆已保存",
|
||||
"agentSignal.receipts.recentActivity": "最近活动",
|
||||
"agentSignal.receipts.skill.detail": "改进了此助手处理类似请求的方式",
|
||||
"agentSignal.receipts.skill.title": "技能已更新",
|
||||
"agentSignal.receipts.skill.detail": "已自主优化处理相似请求的方式",
|
||||
"agentSignal.receipts.skill.title": "已自动习得新技能",
|
||||
"agents": "助理",
|
||||
"artifact.generating": "生成中",
|
||||
"artifact.inThread": "子话题中暂不支持查看。请回到主对话区打开",
|
||||
@@ -208,6 +208,17 @@
|
||||
"heteroAgent.cloudRepo.noRepos": "未配置仓库,请在助理设置中添加。",
|
||||
"heteroAgent.cloudRepo.notSet": "未选择仓库",
|
||||
"heteroAgent.cloudRepo.sectionTitle": "代码仓库",
|
||||
"heteroAgent.executionTarget.infoTooltip": "选择「远程设备」后可在网页中驱动该机器;「本机」仅在桌面端内运行 agent。",
|
||||
"heteroAgent.executionTarget.loading": "正在加载设备…",
|
||||
"heteroAgent.executionTarget.local": "本机",
|
||||
"heteroAgent.executionTarget.localDesc": "在当前桌面端以本地进程运行",
|
||||
"heteroAgent.executionTarget.noDevices": "暂无远程设备。在另一台机器上安装桌面端或执行 `lh connect` 接入。",
|
||||
"heteroAgent.executionTarget.offline": "离线",
|
||||
"heteroAgent.executionTarget.online": "在线",
|
||||
"heteroAgent.executionTarget.sandbox": "云端沙箱",
|
||||
"heteroAgent.executionTarget.sandboxDesc": "在临时云端沙箱中运行",
|
||||
"heteroAgent.executionTarget.title": "执行设备",
|
||||
"heteroAgent.executionTarget.unknownDevice": "未知设备",
|
||||
"heteroAgent.fullAccess.label": "完全访问权限",
|
||||
"heteroAgent.fullAccess.tooltip": "Claude Code 在本地运行,对工作目录拥有完全的读写权限。当前暂不支持切换权限模式。",
|
||||
"heteroAgent.resumeReset.cwdChanged": "工作目录已切换,之前的 Claude Code 会话只能在原目录下继续,已开始新对话。",
|
||||
@@ -367,8 +378,10 @@
|
||||
"platformAgent.create.comingSoon": "即将推出",
|
||||
"platformAgent.create.create": "创建 Agent",
|
||||
"platformAgent.create.creating": "创建中...",
|
||||
"platformAgent.create.desc.amp": "连接你某台设备上的 Amp Agent",
|
||||
"platformAgent.create.desc.hermes": "连接你某台设备上的 Hermes Agent",
|
||||
"platformAgent.create.desc.openclaw": "连接你某台设备上的 OpenClaw Agent",
|
||||
"platformAgent.create.desc.opencode": "连接你某台设备上的 OpenCode Agent",
|
||||
"platformAgent.create.descriptionPlaceholder": "简短描述(可选)",
|
||||
"platformAgent.create.downloadDesktop": "下载桌面端",
|
||||
"platformAgent.create.fetchingProfile": "正在读取配置...",
|
||||
@@ -750,9 +763,9 @@
|
||||
"taskSchedule.weekdays.thu": "四",
|
||||
"taskSchedule.weekdays.tue": "二",
|
||||
"taskSchedule.weekdays.wed": "三",
|
||||
"thread.closeSubagentThread": "收起 SubAgent 对话",
|
||||
"thread.closeSubagentThread": "隐藏详情",
|
||||
"thread.divider": "子话题",
|
||||
"thread.openSubagentThread": "查看完整 SubAgent 对话",
|
||||
"thread.openSubagentThread": "查看详情",
|
||||
"thread.subagentReadOnlyHint": "SubAgent 对话仅可查看,由父智能体驱动执行",
|
||||
"thread.threadMessageCount": "{{messageCount}} 条消息",
|
||||
"thread.title": "子话题",
|
||||
@@ -805,7 +818,7 @@
|
||||
"tool.intervention.viewParameters": "查看参数 ({{count}})",
|
||||
"toolAuth.authorize": "授权",
|
||||
"toolAuth.authorizing": "授权中…",
|
||||
"toolAuth.hint": "未授权或未配置时,相关技能无法使用。这可能导致助理能力受限或报错",
|
||||
"toolAuth.hint": "技能未授权或未配置时,相关技能无法使用,可能导致助理能力受限或报错",
|
||||
"toolAuth.signIn": "登录",
|
||||
"toolAuth.title": "为助理完成技能授权",
|
||||
"topic.checkOpenNewTopic": "要开启新话题吗?",
|
||||
@@ -862,8 +875,8 @@
|
||||
"workflow.toolDisplayName.addPreferenceMemory": "保存了记忆",
|
||||
"workflow.toolDisplayName.calculate": "完成了计算",
|
||||
"workflow.toolDisplayName.callAgent": "调用了助理",
|
||||
"workflow.toolDisplayName.callSubAgent": "派发了子代理",
|
||||
"workflow.toolDisplayName.callSubAgents": "派发了多个子代理",
|
||||
"workflow.toolDisplayName.callSubAgent": "Call SubAgent",
|
||||
"workflow.toolDisplayName.callSubAgents": "Call SubAgents",
|
||||
"workflow.toolDisplayName.clearTodos": "清空了待办",
|
||||
"workflow.toolDisplayName.copyDocument": "复制了文档",
|
||||
"workflow.toolDisplayName.crawlMultiPages": "抓取了多个页面",
|
||||
|
||||
@@ -353,6 +353,7 @@
|
||||
"messengerBanner.title": "在你喜爱的聊天应用中,与 Lobe AI 畅聊",
|
||||
"more": "更多",
|
||||
"navPanel.agent": "助理",
|
||||
"navPanel.bottomDivider": "下方条目锚定到底部",
|
||||
"navPanel.customizeSidebar": "自定义侧边栏",
|
||||
"navPanel.displayItems": "显示条目",
|
||||
"navPanel.hidden": "已隐藏",
|
||||
|
||||
@@ -53,6 +53,8 @@
|
||||
"home.uploadEntries.folder.title": "上传文件夹",
|
||||
"home.uploadEntries.library.title": "新建资源库",
|
||||
"home.uploadEntries.newPage.title": "新建文稿",
|
||||
"library.hierarchy.empty.desc": "上传文件或新建文件夹开始整理",
|
||||
"library.hierarchy.empty.title": "这里还没有内容",
|
||||
"library.list.confirmRemoveLibrary": "将删除该资源库(其中的文件不会删除,会移入「全部文件」)。删除后不可恢复,建议确认无误再继续",
|
||||
"library.list.empty": "点击 <1>+</1> 创建第一个资源库",
|
||||
"library.new": "新建资源库",
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
"generation.actions.seedCopied": "种子已复制到剪贴板",
|
||||
"generation.actions.seedCopyFailed": "复制遇到了问题。你可以再试一次",
|
||||
"generation.metadata.count": "{{count}} 张图片",
|
||||
"generation.status.failed": "生成遇到了问题。你可以重试,或调整描述后再试",
|
||||
"generation.status.failed": "生成遇到了问题,建议调整描述后重试",
|
||||
"generation.status.generating": "生成中…",
|
||||
"notSupportGuide.desc": "当前部署模式不支持 AI 图像生成功能。请切换到<1>服务端数据库部署模式</1>,或直接使用 <3>LobeHub Cloud</3>",
|
||||
"notSupportGuide.features.fileIntegration.desc": "与文件管理深度整合。生成的图片自动保存到文件系统,统一管理和组织",
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
"features.agentSelfIteration.title": "Agent 自我迭代",
|
||||
"features.assistantMessageGroup.desc": "将代理消息及其工具调用结果组合在一起显示",
|
||||
"features.assistantMessageGroup.title": "代理消息分组",
|
||||
"features.executionDeviceSwitcher.desc": "在异构 Agent 工具栏中展示「执行设备」切换器,可将运行任务路由到本机、云端沙箱或已绑定的远程设备。",
|
||||
"features.executionDeviceSwitcher.title": "执行设备切换器",
|
||||
"features.gatewayMode.desc": "通过 Gateway 在服务端执行 Agent 任务。可实现关闭浏览器后仍然执行 agent。",
|
||||
"features.gatewayMode.title": "服务端代理执行(Gateway)",
|
||||
"features.groupChat.desc": "启用多代理协同群聊功能。",
|
||||
|
||||
@@ -400,6 +400,7 @@
|
||||
"deepseek-ai/DeepSeek-V3.2.description": "DeepSeek-V3.2 是一款结合高计算效率与卓越推理和 Agent 性能的模型。其方法基于三项主要技术突破:DeepSeek 稀疏注意力(DSA),一种高效的注意力机制,在显著降低计算复杂度的同时保持模型性能,特别针对长上下文场景进行了优化;可扩展的强化学习框架,使模型性能可媲美 GPT-5,其高计算版本在推理能力上可媲美 Gemini-3.0-Pro;以及一个大规模 Agent 任务合成管道,旨在将推理能力集成到工具使用场景中,从而提升复杂交互环境中的指令遵循和泛化能力。该模型在 2025 年国际数学奥林匹克(IMO)和国际信息学奥林匹克(IOI)中获得金牌成绩。",
|
||||
"deepseek-ai/DeepSeek-V3.description": "DeepSeek-V3 是一个拥有 671B 参数的 MoE 模型,采用 MLA 和 DeepSeekMoE 架构,具备无损负载均衡,实现高效训练与推理。在 14.8T 高质量数据上预训练,并结合 SFT 与 RL,性能超越其他开源模型,接近领先闭源模型。",
|
||||
"deepseek-ai/DeepSeek-V4-Flash.description": "DeepSeek-V4-Flash 是 DeepSeek-V4 系列中 MoE 语言模型的预览版本。总参数规模为 2840 亿,激活参数规模为 130 亿,支持 1M 令牌超长上下文。该模型采用结合 CSA 和 HCA 的混合注意力架构,并引入 mHC 和 Muon 优化器,以提高长上下文推理效率、训练稳定性和整体性能。",
|
||||
"deepseek-ai/DeepSeek-V4-Pro.description": "DeepSeek-V4-Pro 是 DeepSeek-V4 系列中的旗舰 MoE 语言模型,拥有 1.6T 总参数、49B 激活参数,原生支持 100 万 tokens 的超长上下文。该模型采用创新的混合注意力架构,结合压缩稀疏注意力(CSA)与高度压缩注意力(HCA),在 1M 上下文下仅需 DeepSeek-V3.2 的 27% 单 token 推理 FLOPs 和 10% KV 缓存。模型还引入流形约束超连接(mHC)增强层间信号传播稳定性,并采用 Muon 优化器加速收敛。DeepSeek-V4-Pro 在超过 32T 高质量多样化 tokens 上预训练,后训练采用「领域专家独立培养 + 在线策略蒸馏统一整合」的两阶段范式。其最大推理强度模式 DeepSeek-V4-Pro-Max 在编程基准上取得顶尖表现,并在推理与 Agentic 任务上大幅缩小与领先闭源模型的差距,是目前最强的开源模型之一,支持 Non-think、Think High、Think Max 三种推理强度模式",
|
||||
"deepseek-ai/deepseek-llm-67b-chat.description": "DeepSeek LLM Chat(67B)是一款创新模型,具备深度语言理解与交互能力。",
|
||||
"deepseek-ai/deepseek-v3.1-terminus.description": "DeepSeek V3.1 是下一代推理模型,具备更强的复杂推理与链式思维能力,适用于深度分析任务。",
|
||||
"deepseek-ai/deepseek-v3.2.description": "DeepSeek V3.2是下一代推理模型,具备更强的复杂推理和链式思维能力。",
|
||||
|
||||
@@ -72,10 +72,9 @@
|
||||
"builtins.lobe-agent.apiName.analyzeVisualMedia": "分析视觉媒体",
|
||||
"builtins.lobe-agent.apiName.analyzeVisualMedia.mediaCount": "{{count}} 个媒体",
|
||||
"builtins.lobe-agent.apiName.analyzeVisualMedia.result": "分析视觉媒体:<question>{{question}}</question>",
|
||||
"builtins.lobe-agent.apiName.callSubAgent": "调用子代理",
|
||||
"builtins.lobe-agent.apiName.callSubAgent.completed": "已派发子代理:",
|
||||
"builtins.lobe-agent.apiName.callSubAgent.loading": "正在派发子代理:",
|
||||
"builtins.lobe-agent.apiName.callSubAgents": "调用多个子代理",
|
||||
"builtins.lobe-agent.apiName.callSubAgent": "Call SubAgent",
|
||||
"builtins.lobe-agent.apiName.callSubAgents": "Call SubAgents",
|
||||
"builtins.lobe-agent.apiName.callSubAgents.more": "等 {{count}} 个",
|
||||
"builtins.lobe-agent.apiName.clearTodos": "清除待办",
|
||||
"builtins.lobe-agent.apiName.clearTodos.modeAll": "全部",
|
||||
"builtins.lobe-agent.apiName.clearTodos.modeCompleted": "已完成",
|
||||
@@ -87,6 +86,8 @@
|
||||
"builtins.lobe-agent.apiName.updatePlan.completed": "已完成",
|
||||
"builtins.lobe-agent.apiName.updatePlan.modified": "已修改",
|
||||
"builtins.lobe-agent.apiName.updateTodos": "更新待办",
|
||||
"builtins.lobe-agent.subAgent.stats.tokens": "{{count}} tokens",
|
||||
"builtins.lobe-agent.subAgent.stats.tools": "{{count}} 个工具",
|
||||
"builtins.lobe-agent.title": "Lobe Agent",
|
||||
"builtins.lobe-claude-code.agent.instruction": "指令",
|
||||
"builtins.lobe-claude-code.agent.result": "结果",
|
||||
|
||||
@@ -503,6 +503,8 @@
|
||||
"plugin.settings.tooltip": "技能配置",
|
||||
"plugin.store": "技能商店",
|
||||
"publishToCommunity": "发布到社区",
|
||||
"serviceModel.contextLimit.placeholder": "上下文限制",
|
||||
"serviceModel.memoryModels.title": "记忆模型",
|
||||
"serviceModel.modelAssignments.title": "模型分配",
|
||||
"serviceModel.optionalFeatures.title": "可选功能",
|
||||
"settingAgent.avatar.sizeExceeded": "图片大小超过 1MB 限制,请选择更小的图片",
|
||||
@@ -549,6 +551,9 @@
|
||||
"settingChat.enableAutoScrollOnStreaming.desc": "覆盖此助手的全局设置",
|
||||
"settingChat.enableAutoScrollOnStreaming.title": "AI 回复时自动滚动",
|
||||
"settingChat.enableCompressHistory.title": "开启历史消息自动总结",
|
||||
"settingChat.enableFollowUpChips.desc": "每次回复后,在消息下方展示一键跟进的快捷气泡。需先配置全局跟进建议模型。",
|
||||
"settingChat.enableFollowUpChips.notConfiguredHint": "请先配置全局跟进建议模型后启用。",
|
||||
"settingChat.enableFollowUpChips.title": "跟进建议",
|
||||
"settingChat.enableHistoryCount.alias": "不限制",
|
||||
"settingChat.enableHistoryCount.limited": "只包含 {{number}} 条会话消息",
|
||||
"settingChat.enableHistoryCount.setlimited": "使用历史消息数",
|
||||
@@ -840,6 +845,9 @@
|
||||
"systemAgent.customPrompt.desc": "填写后,系统助理将在生成内容时使用自定义提示",
|
||||
"systemAgent.customPrompt.placeholder": "请输入自定义提示词",
|
||||
"systemAgent.customPrompt.title": "自定义提示词",
|
||||
"systemAgent.followUpAction.label": "跟进建议模型",
|
||||
"systemAgent.followUpAction.modelDesc": "用于在每条助手回复下生成一键跟进建议的模型",
|
||||
"systemAgent.followUpAction.title": "跟进建议",
|
||||
"systemAgent.generationTopic.label": "模型",
|
||||
"systemAgent.generationTopic.modelDesc": "用于自动命名 AI 图片话题的模型",
|
||||
"systemAgent.generationTopic.title": "AI 图片话题命名",
|
||||
@@ -850,6 +858,9 @@
|
||||
"systemAgent.inputCompletion.label": "模型",
|
||||
"systemAgent.inputCompletion.modelDesc": "输入时生成文本建议。开启后,由该模型生成建议。",
|
||||
"systemAgent.inputCompletion.title": "输入建议",
|
||||
"systemAgent.memoryAnalysisAgentConfig.label": "模型",
|
||||
"systemAgent.memoryAnalysisAgentConfig.modelDesc": "用于判断对话是否包含记忆,并提取身份、偏好、上下文、活动和经历。",
|
||||
"systemAgent.memoryAnalysisAgentConfig.title": "记忆分析",
|
||||
"systemAgent.promptRewrite.label": "模型",
|
||||
"systemAgent.promptRewrite.modelDesc": "生成前优化提示词。开启后,由该模型改写提示词。",
|
||||
"systemAgent.promptRewrite.title": "提示词改写",
|
||||
@@ -863,6 +874,12 @@
|
||||
"systemAgent.translation.label": "模型",
|
||||
"systemAgent.translation.modelDesc": "用于翻译消息内容的模型",
|
||||
"systemAgent.translation.title": "消息内容翻译",
|
||||
"systemAgent.userMemoryEmbedding.label": "模型",
|
||||
"systemAgent.userMemoryEmbedding.modelDesc": "用于为记忆内容生成向量以支持检索。上下文限制会约束每次向量化输入。",
|
||||
"systemAgent.userMemoryEmbedding.title": "记忆向量化",
|
||||
"systemAgent.userMemoryPersonaWriter.label": "模型",
|
||||
"systemAgent.userMemoryPersonaWriter.modelDesc": "用于生成面向画像的记忆摘要。",
|
||||
"systemAgent.userMemoryPersonaWriter.title": "记忆画像写入",
|
||||
"tab.about": "关于",
|
||||
"tab.addAgentSkill": "添加 Agent 技能",
|
||||
"tab.addCustomMcp": "添加自定义 MCP 技能",
|
||||
|
||||
@@ -51,6 +51,77 @@
|
||||
"inPopup.focus": "聚焦独立窗口",
|
||||
"inPopup.title": "已在独立窗口中打开",
|
||||
"loadMore": "更多",
|
||||
"management.actions.newChat": "新对话",
|
||||
"management.actions.select": "选择",
|
||||
"management.actionsMenu.archiveStale.confirm": "将 {{count}} 个超过 3 个月未活动的话题归档(标记为已完成)?",
|
||||
"management.actionsMenu.archiveStale.confirmOk": "归档",
|
||||
"management.actionsMenu.archiveStale.done": "已归档 {{count}} 个话题。",
|
||||
"management.actionsMenu.archiveStale.label": "归档 3 个月未活动的话题",
|
||||
"management.actionsMenu.archiveStale.noneFound": "没有需要归档的话题。",
|
||||
"management.actionsMenu.archiveStale.title": "归档过时话题?",
|
||||
"management.actionsMenu.autoSummarize.comingSoon": "自动生成摘要功能即将上线,敬请期待。",
|
||||
"management.actionsMenu.autoSummarize.label": "为缺少摘要的话题自动生成",
|
||||
"management.actionsMenu.title": "更多操作",
|
||||
"management.bulk.archive": "归档",
|
||||
"management.bulk.cancel": "取消",
|
||||
"management.bulk.delete": "删除",
|
||||
"management.bulk.deleteConfirm": "即将删除 {{count}} 个话题,此操作无法撤销。",
|
||||
"management.bulk.deleteTitle": "删除话题?",
|
||||
"management.bulk.favorite": "收藏",
|
||||
"management.bulk.selectedCount_one": "已选 {{count}} 项",
|
||||
"management.bulk.selectedCount_other": "已选 {{count}} 项",
|
||||
"management.card.noPreview": "暂无预览内容",
|
||||
"management.columns.project": "项目",
|
||||
"management.columns.status": "状态",
|
||||
"management.columns.title": "标题",
|
||||
"management.columns.trigger": "来源",
|
||||
"management.columns.updated": "更新时间",
|
||||
"management.empty.filtered.action": "清空筛选",
|
||||
"management.empty.filtered.desc": "试试调整或清空筛选条件,看更多话题。",
|
||||
"management.empty.filtered.title": "没有符合条件的话题",
|
||||
"management.empty.noTopics.action": "开始新对话",
|
||||
"management.empty.noTopics.desc": "和这个助手聊聊,创建第一个话题。",
|
||||
"management.empty.noTopics.title": "还没有话题",
|
||||
"management.filters.project.empty": "暂无项目",
|
||||
"management.filters.project.label": "项目",
|
||||
"management.filters.status.active": "活跃",
|
||||
"management.filters.status.all": "全部",
|
||||
"management.filters.status.archived": "已归档",
|
||||
"management.filters.status.completed": "已完成",
|
||||
"management.filters.status.favorite": "已收藏",
|
||||
"management.filters.status.running": "运行中",
|
||||
"management.filters.time.all": "全部时间",
|
||||
"management.filters.time.label": "时间",
|
||||
"management.filters.time.month": "最近一月",
|
||||
"management.filters.time.today": "今天",
|
||||
"management.filters.time.week": "最近一周",
|
||||
"management.filters.trigger.api": "API",
|
||||
"management.filters.trigger.chat": "对话",
|
||||
"management.filters.trigger.eval": "评测",
|
||||
"management.filters.trigger.label": "来源",
|
||||
"management.filters.trigger.task": "任务",
|
||||
"management.group.byProject": "按项目",
|
||||
"management.group.byTime": "按时间",
|
||||
"management.group.label": "分组",
|
||||
"management.group.noProject": "无项目",
|
||||
"management.group.none": "不分组",
|
||||
"management.loadingMore": "正在加载更多话题…",
|
||||
"management.searchPlaceholder": "在当前助手的话题中搜索…",
|
||||
"management.sidebarEntry": "话题",
|
||||
"management.sort.createdAt": "按创建时间",
|
||||
"management.sort.label": "排序",
|
||||
"management.sort.title": "按标题",
|
||||
"management.sort.updatedAt": "按更新时间",
|
||||
"management.status.active": "活跃",
|
||||
"management.status.archived": "已归档",
|
||||
"management.status.completed": "已完成",
|
||||
"management.status.failed": "已失败",
|
||||
"management.status.paused": "已暂停",
|
||||
"management.status.running": "运行中",
|
||||
"management.status.waitingForHuman": "等待响应",
|
||||
"management.title": "话题",
|
||||
"management.view.card": "卡片",
|
||||
"management.view.list": "列表",
|
||||
"newTopic": "新话题",
|
||||
"renameModal.description": "保持简短且易于识别。",
|
||||
"renameModal.title": "重命名话题",
|
||||
|
||||
+6
-4
@@ -245,6 +245,7 @@
|
||||
"@lobechat/business-model-bank": "workspace:*",
|
||||
"@lobechat/business-model-runtime": "workspace:*",
|
||||
"@lobechat/chat-adapter-feishu": "workspace:*",
|
||||
"@lobechat/chat-adapter-imessage": "workspace:*",
|
||||
"@lobechat/chat-adapter-line": "workspace:*",
|
||||
"@lobechat/chat-adapter-qq": "workspace:*",
|
||||
"@lobechat/chat-adapter-wechat": "workspace:*",
|
||||
@@ -280,11 +281,11 @@
|
||||
"@lobehub/analytics": "^1.6.2",
|
||||
"@lobehub/charts": "^5.0.0",
|
||||
"@lobehub/desktop-ipc-typings": "workspace:*",
|
||||
"@lobehub/editor": "^4.9.3",
|
||||
"@lobehub/editor": "^4.12.0",
|
||||
"@lobehub/icons": "^5.0.0",
|
||||
"@lobehub/market-sdk": "0.33.3",
|
||||
"@lobehub/tts": "^5.1.2",
|
||||
"@lobehub/ui": "^5.14.1",
|
||||
"@lobehub/ui": "^5.15.1",
|
||||
"@modelcontextprotocol/sdk": "^1.26.0",
|
||||
"@napi-rs/canvas": "^0.1.88",
|
||||
"@neondatabase/serverless": "^1.0.2",
|
||||
@@ -536,7 +537,7 @@
|
||||
"stylelint": "^16.12.0",
|
||||
"tsx": "^4.21.0",
|
||||
"type-fest": "^5.4.1",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript": "^6.0.3",
|
||||
"unified": "^11.0.5",
|
||||
"unist-util-visit": "^5.1.0",
|
||||
"vite": "8.0.14",
|
||||
@@ -567,7 +568,8 @@
|
||||
"pdfjs-dist": "5.4.530",
|
||||
"react": "19.2.5",
|
||||
"react-dom": "19.2.5",
|
||||
"stylelint-config-clean-order": "7.0.0"
|
||||
"stylelint-config-clean-order": "7.0.0",
|
||||
"typescript": "6.0.3"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"@upstash/qstash": "patches/@upstash__qstash.patch"
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
ToolsCalling,
|
||||
Usage,
|
||||
} from '../types';
|
||||
import { isBlockedStatus } from '../utils/status';
|
||||
|
||||
/**
|
||||
* Simplified Agent Runtime - The "Engine" that executes instructions from an "Agent" (Brain).
|
||||
@@ -197,7 +198,7 @@ export class AgentRuntime {
|
||||
}
|
||||
|
||||
// Stop execution if blocked
|
||||
if (currentState.status === 'waiting_for_human' || currentState.status === 'interrupted') {
|
||||
if (isBlockedStatus(currentState.status)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -214,12 +215,10 @@ export class AgentRuntime {
|
||||
return {
|
||||
events: allEvents,
|
||||
newState: currentState,
|
||||
// When execution is blocked (waiting for human or interrupted),
|
||||
// clear nextContext so the outer loop stops instead of continuing
|
||||
nextContext:
|
||||
currentState.status === 'waiting_for_human' || currentState.status === 'interrupted'
|
||||
? undefined
|
||||
: finalNextContext,
|
||||
// When execution is blocked (waiting for human, waiting for an async
|
||||
// tool result, or interrupted), clear nextContext so the outer loop
|
||||
// stops instead of continuing
|
||||
nextContext: isBlockedStatus(currentState.status) ? undefined : finalNextContext,
|
||||
};
|
||||
} catch (error) {
|
||||
const errorState = structuredClone(state);
|
||||
|
||||
@@ -113,7 +113,14 @@ export interface AgentState {
|
||||
*/
|
||||
securityBlacklist?: SecurityBlacklistConfig;
|
||||
// --- State Machine ---
|
||||
status: 'idle' | 'running' | 'waiting_for_human' | 'done' | 'error' | 'interrupted';
|
||||
status:
|
||||
| 'idle'
|
||||
| 'running'
|
||||
| 'waiting_for_human'
|
||||
| 'waiting_for_async_tool'
|
||||
| 'done'
|
||||
| 'error'
|
||||
| 'interrupted';
|
||||
|
||||
// --- Execution Tracking ---
|
||||
/**
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './messageSelectors';
|
||||
export * from './status';
|
||||
export * from './stepContextComputer';
|
||||
export * from './tokenCounter';
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { AgentState } from '../types/state';
|
||||
import { isBlockedStatus, isParkedStatus } from './status';
|
||||
|
||||
const ALL_STATUSES: AgentState['status'][] = [
|
||||
'idle',
|
||||
'running',
|
||||
'waiting_for_human',
|
||||
'waiting_for_async_tool',
|
||||
'done',
|
||||
'error',
|
||||
'interrupted',
|
||||
];
|
||||
|
||||
describe('isParkedStatus', () => {
|
||||
it('is true only for the non-terminal resumable pauses', () => {
|
||||
expect(isParkedStatus('waiting_for_human')).toBe(true);
|
||||
expect(isParkedStatus('waiting_for_async_tool')).toBe(true);
|
||||
});
|
||||
|
||||
it('is false for running, terminal, and interrupted', () => {
|
||||
const nonParked = ALL_STATUSES.filter(
|
||||
(s) => s !== 'waiting_for_human' && s !== 'waiting_for_async_tool',
|
||||
);
|
||||
for (const status of nonParked) expect(isParkedStatus(status)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isBlockedStatus', () => {
|
||||
it('is true for parked statuses and user interrupt', () => {
|
||||
expect(isBlockedStatus('waiting_for_human')).toBe(true);
|
||||
expect(isBlockedStatus('waiting_for_async_tool')).toBe(true);
|
||||
expect(isBlockedStatus('interrupted')).toBe(true);
|
||||
});
|
||||
|
||||
it('is false for idle, running, and terminal statuses', () => {
|
||||
expect(isBlockedStatus('idle')).toBe(false);
|
||||
expect(isBlockedStatus('running')).toBe(false);
|
||||
expect(isBlockedStatus('done')).toBe(false);
|
||||
expect(isBlockedStatus('error')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { AgentState } from '../types/state';
|
||||
|
||||
/**
|
||||
* Parked statuses are non-terminal, resumable pauses: the operation is still
|
||||
* alive but waiting on something out-of-band — human approval
|
||||
* (`waiting_for_human`) or an async tool / sub-agent result
|
||||
* (`waiting_for_async_tool`). They are deliberately distinct from `interrupted`
|
||||
* (user cancel) and the terminal `done` / `error`, so the completion lifecycle
|
||||
* never stamps `completedAt` and the scheduler keeps treating them as active.
|
||||
*/
|
||||
export const isParkedStatus = (status: AgentState['status']): boolean =>
|
||||
status === 'waiting_for_human' || status === 'waiting_for_async_tool';
|
||||
|
||||
/**
|
||||
* Blocked statuses halt the step loop — a parked pause or a user interrupt.
|
||||
* `done` / `error` terminate through their own handling.
|
||||
*/
|
||||
export const isBlockedStatus = (status: AgentState['status']): boolean =>
|
||||
isParkedStatus(status) || status === 'interrupted';
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { BuiltinAgentDefinition } from '../../types';
|
||||
import { BUILTIN_AGENT_SLUGS } from '../../types';
|
||||
|
||||
const SELF_ITERATION_TOOL_IDENTIFIER = 'agent-signal-self-iteration';
|
||||
|
||||
/**
|
||||
* Self-Iteration Agent - shared execAgent target for nightly review, post-turn
|
||||
* reflection, and declared feedback intents.
|
||||
*
|
||||
* All three flows share the same tool surface (`agent-signal-self-iteration`);
|
||||
* the mode-specific guidance is supplied per-call by the caller's prompt builder,
|
||||
* so the agent itself stays neutral.
|
||||
*/
|
||||
export const SELF_ITERATION: BuiltinAgentDefinition = {
|
||||
runtime: {
|
||||
plugins: [SELF_ITERATION_TOOL_IDENTIFIER],
|
||||
systemRole:
|
||||
'You are the self-iteration agent. Follow the mode-specific instructions in the user prompt and apply safe resource operations using the provided self-iteration tools. Be concise and evidence-driven.',
|
||||
},
|
||||
slug: BUILTIN_AGENT_SLUGS.selfIteration,
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import { GROUP_AGENT_BUILDER } from './agents/group-agent-builder';
|
||||
import { GROUP_SUPERVISOR } from './agents/group-supervisor';
|
||||
import { INBOX } from './agents/inbox';
|
||||
import { PAGE_AGENT } from './agents/page-agent';
|
||||
import { SELF_ITERATION } from './agents/self-iteration';
|
||||
import { TASK_AGENT } from './agents/task-agent';
|
||||
import { WEB_ONBOARDING } from './agents/web-onboarding';
|
||||
import type { BuiltinAgentDefinition, BuiltinAgentSlug, RuntimeContext } from './types';
|
||||
@@ -16,6 +17,7 @@ export { GROUP_AGENT_BUILDER } from './agents/group-agent-builder';
|
||||
export { GROUP_SUPERVISOR } from './agents/group-supervisor';
|
||||
export { INBOX } from './agents/inbox';
|
||||
export { PAGE_AGENT } from './agents/page-agent';
|
||||
export { SELF_ITERATION } from './agents/self-iteration';
|
||||
export { TASK_AGENT } from './agents/task-agent';
|
||||
export { WEB_ONBOARDING } from './agents/web-onboarding';
|
||||
|
||||
@@ -28,10 +30,20 @@ export const BUILTIN_AGENTS: Record<BuiltinAgentSlug, BuiltinAgentDefinition> =
|
||||
[BUILTIN_AGENT_SLUGS.groupSupervisor]: GROUP_SUPERVISOR,
|
||||
[BUILTIN_AGENT_SLUGS.inbox]: INBOX,
|
||||
[BUILTIN_AGENT_SLUGS.pageAgent]: PAGE_AGENT,
|
||||
[BUILTIN_AGENT_SLUGS.selfIteration]: SELF_ITERATION,
|
||||
[BUILTIN_AGENT_SLUGS.taskAgent]: TASK_AGENT,
|
||||
[BUILTIN_AGENT_SLUGS.webOnboarding]: WEB_ONBOARDING,
|
||||
};
|
||||
|
||||
/**
|
||||
* Slugs that belong to the self-iteration family.
|
||||
* Used by AgentSignal to skip re-triggering signal events
|
||||
* for builtin background runs (suppressSignal behaviour).
|
||||
*/
|
||||
export const SELF_ITERATION_AGENT_SLUGS = new Set<BuiltinAgentSlug>([
|
||||
BUILTIN_AGENT_SLUGS.selfIteration,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Get persist config for a builtin agent (for DB operations)
|
||||
* @param slug - The builtin agent slug
|
||||
|
||||
@@ -11,6 +11,7 @@ export const BUILTIN_AGENT_SLUGS = {
|
||||
groupSupervisor: 'group-supervisor',
|
||||
inbox: 'inbox',
|
||||
pageAgent: 'page-agent',
|
||||
selfIteration: 'self-iteration',
|
||||
taskAgent: 'task-agent',
|
||||
webOnboarding: 'web-onboarding',
|
||||
} as const;
|
||||
|
||||
@@ -1,53 +1,76 @@
|
||||
'use client';
|
||||
|
||||
import type { BuiltinInspectorProps } from '@lobechat/types';
|
||||
import { cx } from 'antd-style';
|
||||
import { GroupBotIcon } from '@lobehub/ui/icons';
|
||||
import { createStaticStyles, cx } from 'antd-style';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { highlightTextStyles, inspectorTextStyles, shinyTextStyles } from '@/styles';
|
||||
import { inspectorTextStyles, shinyTextStyles } from '@/styles';
|
||||
|
||||
import type { CallSubAgentParams, CallSubAgentState } from '../../../types';
|
||||
import { SubAgentStats } from '../../components/SubAgentStats';
|
||||
|
||||
const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
chip: css`
|
||||
overflow: hidden;
|
||||
display: inline-flex;
|
||||
flex-shrink: 1;
|
||||
align-items: center;
|
||||
|
||||
min-width: 0;
|
||||
padding-block: 2px;
|
||||
padding-inline: 10px;
|
||||
border-radius: 999px;
|
||||
|
||||
font-size: 12px;
|
||||
color: ${cssVar.colorText};
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
background: ${cssVar.colorFillTertiary};
|
||||
`,
|
||||
icon: css`
|
||||
flex-shrink: 0;
|
||||
color: ${cssVar.colorTextDescription};
|
||||
`,
|
||||
label: css`
|
||||
flex-shrink: 0;
|
||||
color: ${cssVar.colorText};
|
||||
`,
|
||||
root: css`
|
||||
gap: 6px;
|
||||
`,
|
||||
}));
|
||||
|
||||
/**
|
||||
* Collapsed row for lobe-agent's `callSubAgent`. Mirrors the Claude Code Agent
|
||||
* tool: leading bot icon + "Call SubAgent" label + the description in a chip.
|
||||
* Once the run finishes, the persisted state feeds a compact stats tail
|
||||
* (tool count · model · tokens).
|
||||
*/
|
||||
export const CallSubAgentInspector = memo<
|
||||
BuiltinInspectorProps<CallSubAgentParams, CallSubAgentState>
|
||||
>(({ args, partialArgs, isArgumentsStreaming, isLoading }) => {
|
||||
>(({ args, partialArgs, pluginState, isArgumentsStreaming, isLoading }) => {
|
||||
const { t } = useTranslation('plugin');
|
||||
|
||||
const description = args?.description || partialArgs?.description;
|
||||
|
||||
if (isArgumentsStreaming) {
|
||||
if (!description)
|
||||
return (
|
||||
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
|
||||
<span>{t('builtins.lobe-agent.apiName.callSubAgent')}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
|
||||
<span>{t('builtins.lobe-agent.apiName.callSubAgent.loading')}</span>
|
||||
<span className={highlightTextStyles.primary}>{description}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (description) {
|
||||
return (
|
||||
<div className={cx(inspectorTextStyles.root, isLoading && shinyTextStyles.shinyText)}>
|
||||
<span>
|
||||
{isLoading
|
||||
? t('builtins.lobe-agent.apiName.callSubAgent.loading')
|
||||
: t('builtins.lobe-agent.apiName.callSubAgent.completed')}
|
||||
</span>
|
||||
<span className={highlightTextStyles.primary}>{description}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const description = (args?.description || partialArgs?.description)?.trim();
|
||||
const isShiny = isArgumentsStreaming || isLoading;
|
||||
|
||||
return (
|
||||
<div className={inspectorTextStyles.root}>
|
||||
<span>{t('builtins.lobe-agent.apiName.callSubAgent')}</span>
|
||||
<div
|
||||
className={cx(inspectorTextStyles.root, styles.root, isShiny && shinyTextStyles.shinyText)}
|
||||
>
|
||||
<GroupBotIcon className={styles.icon} size={14} />
|
||||
<span className={styles.label}>{t('builtins.lobe-agent.apiName.callSubAgent')}</span>
|
||||
{description && <span className={styles.chip}>{description}</span>}
|
||||
{!isShiny && pluginState && (
|
||||
<SubAgentStats
|
||||
model={pluginState.model}
|
||||
totalTokens={pluginState.totalTokens}
|
||||
totalToolCalls={pluginState.totalToolCalls}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,69 +1,87 @@
|
||||
'use client';
|
||||
|
||||
import type { BuiltinInspectorProps } from '@lobechat/types';
|
||||
import { Icon } from '@lobehub/ui';
|
||||
import { GroupBotIcon } from '@lobehub/ui/icons';
|
||||
import { createStaticStyles, cx } from 'antd-style';
|
||||
import { ListTodo } from 'lucide-react';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { highlightTextStyles, shinyTextStyles } from '@/styles';
|
||||
import { inspectorTextStyles, shinyTextStyles } from '@/styles';
|
||||
|
||||
import type { CallSubAgentsParams, CallSubAgentsState } from '../../../types';
|
||||
|
||||
const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
count: css`
|
||||
flex-shrink: 0;
|
||||
margin-inline-start: 4px;
|
||||
color: ${cssVar.colorTextSecondary};
|
||||
`,
|
||||
description: css`
|
||||
chip: css`
|
||||
overflow: hidden;
|
||||
display: inline-flex;
|
||||
flex-shrink: 1;
|
||||
align-items: center;
|
||||
|
||||
min-width: 0;
|
||||
padding-block: 2px;
|
||||
padding-inline: 10px;
|
||||
border-radius: 999px;
|
||||
|
||||
font-size: 12px;
|
||||
color: ${cssVar.colorText};
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
background: ${cssVar.colorFillTertiary};
|
||||
`,
|
||||
root: css`
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
icon: css`
|
||||
flex-shrink: 0;
|
||||
color: ${cssVar.colorTextDescription};
|
||||
`,
|
||||
title: css`
|
||||
label: css`
|
||||
flex-shrink: 0;
|
||||
color: ${cssVar.colorText};
|
||||
`,
|
||||
more: css`
|
||||
flex-shrink: 0;
|
||||
color: ${cssVar.colorTextTertiary};
|
||||
`,
|
||||
root: css`
|
||||
gap: 6px;
|
||||
`,
|
||||
}));
|
||||
|
||||
/** Show every description when there are at most this many; otherwise collapse. */
|
||||
const MAX_VISIBLE = 2;
|
||||
|
||||
/**
|
||||
* Collapsed row for lobe-agent's `callSubAgents`. Leading bot icon + "Call
|
||||
* SubAgents" label, then each sub-agent description as a chip when there are
|
||||
* few (<= 2). Beyond that, only the first is shown followed by a "{{count}} in
|
||||
* total" tail to keep the row compact.
|
||||
*/
|
||||
export const CallSubAgentsInspector = memo<
|
||||
BuiltinInspectorProps<CallSubAgentsParams, CallSubAgentsState>
|
||||
>(({ args, partialArgs, isArgumentsStreaming }) => {
|
||||
const { t } = useTranslation('plugin');
|
||||
|
||||
const tasks = args?.tasks || partialArgs?.tasks || [];
|
||||
const count = tasks.length;
|
||||
const firstTask = tasks[0];
|
||||
const descriptions = tasks.map((task) => task?.description?.trim()).filter(Boolean) as string[];
|
||||
const count = descriptions.length;
|
||||
|
||||
if (isArgumentsStreaming && count === 0) {
|
||||
return (
|
||||
<div className={cx(styles.root, shinyTextStyles.shinyText)}>
|
||||
<span>{t('builtins.lobe-agent.apiName.callSubAgents')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const isShiny = isArgumentsStreaming;
|
||||
const visible = count > MAX_VISIBLE ? descriptions.slice(0, 1) : descriptions;
|
||||
const showMore = count > MAX_VISIBLE;
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<span className={cx(styles.title, isArgumentsStreaming && shinyTextStyles.shinyText)}>
|
||||
{t('builtins.lobe-agent.apiName.callSubAgents')}:
|
||||
</span>
|
||||
{firstTask?.description && (
|
||||
<span className={cx(styles.description, highlightTextStyles.primary)}>
|
||||
{firstTask.description}
|
||||
<div
|
||||
className={cx(inspectorTextStyles.root, styles.root, isShiny && shinyTextStyles.shinyText)}
|
||||
>
|
||||
<GroupBotIcon className={styles.icon} size={14} />
|
||||
<span className={styles.label}>{t('builtins.lobe-agent.apiName.callSubAgents')}</span>
|
||||
{visible.map((description, index) => (
|
||||
<span className={styles.chip} key={index}>
|
||||
{description}
|
||||
</span>
|
||||
)}
|
||||
{count > 1 && (
|
||||
<span className={styles.count}>
|
||||
<Icon icon={ListTodo} size={14} /> {count}
|
||||
))}
|
||||
{showMore && (
|
||||
<span className={styles.more}>
|
||||
{t('builtins.lobe-agent.apiName.callSubAgents.more', { count })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,64 +1,140 @@
|
||||
'use client';
|
||||
|
||||
import type { BuiltinRenderProps } from '@lobechat/types';
|
||||
import { Block } from '@lobehub/ui';
|
||||
import { Button, Flexbox, Markdown, Text } from '@lobehub/ui';
|
||||
import { createStaticStyles } from 'antd-style';
|
||||
import { memo } from 'react';
|
||||
import { ListTree } from 'lucide-react';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useChatStore } from '@/store/chat';
|
||||
import { portalThreadSelectors, threadSelectors } from '@/store/chat/selectors';
|
||||
|
||||
import type { CallSubAgentParams, CallSubAgentState } from '../../../types';
|
||||
|
||||
const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
instruction: css`
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
|
||||
container: css`
|
||||
padding-block: 4px;
|
||||
`,
|
||||
label: css`
|
||||
padding-inline-start: 4px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: ${cssVar.colorTextTertiary};
|
||||
`,
|
||||
taskContent: css`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
|
||||
min-width: 0;
|
||||
labelRow: css`
|
||||
margin-block-end: 4px;
|
||||
`,
|
||||
taskItem: css`
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
|
||||
padding-block: 10px;
|
||||
openThread: css`
|
||||
height: 22px;
|
||||
padding-inline: 6px;
|
||||
font-size: 12px;
|
||||
`,
|
||||
promptBox: css`
|
||||
padding-block: 8px;
|
||||
padding-inline: 12px;
|
||||
border-radius: ${cssVar.borderRadiusLG};
|
||||
background: ${cssVar.colorFillTertiary};
|
||||
`,
|
||||
title: css`
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
color: ${cssVar.colorText};
|
||||
resultBox: css`
|
||||
padding-block: 8px;
|
||||
padding-inline: 12px;
|
||||
border-radius: ${cssVar.borderRadiusLG};
|
||||
background: ${cssVar.colorBgContainer};
|
||||
`,
|
||||
}));
|
||||
|
||||
export const CallSubAgentRender = memo<BuiltinRenderProps<CallSubAgentParams, CallSubAgentState>>(
|
||||
({ pluginState }) => {
|
||||
const { task } = pluginState || {};
|
||||
/**
|
||||
* Render for lobe-agent's `callSubAgent` tool.
|
||||
*
|
||||
* A sub-agent runs in an isolated Thread via the current runtime, so this view
|
||||
* shows the instruction sent to it plus its closing summary (the tool result),
|
||||
* and exposes a toggle to open / collapse that Thread in the portal. The Thread
|
||||
* is located by the `threadId` persisted in tool state; while the run is still
|
||||
* starting the lookup can return `undefined`, so the button is hidden rather
|
||||
* than rendered as a dead no-op.
|
||||
*/
|
||||
export const CallSubAgentRender = memo<
|
||||
BuiltinRenderProps<CallSubAgentParams, CallSubAgentState, string>
|
||||
>(({ args, content, pluginState }) => {
|
||||
const { t } = useTranslation('plugin');
|
||||
const { t: tChat } = useTranslation('chat');
|
||||
const prompt = args?.instruction?.trim();
|
||||
const result = typeof content === 'string' ? content.trim() : '';
|
||||
const threadId = pluginState?.threadId;
|
||||
|
||||
if (!task) return null;
|
||||
const subagentThread = useChatStore((s) =>
|
||||
threadId
|
||||
? (threadSelectors.currentTopicThreads(s) ?? []).find((thread) => thread.id === threadId)
|
||||
: undefined,
|
||||
);
|
||||
const openThreadInPortal = useChatStore((s) => s.openThreadInPortal);
|
||||
const closeThreadPortal = useChatStore((s) => s.closeThreadPortal);
|
||||
const portalThreadId = useChatStore(portalThreadSelectors.portalThreadId);
|
||||
const isOpenInPortal = !!subagentThread && portalThreadId === subagentThread.id;
|
||||
|
||||
return (
|
||||
<Block variant={'outlined'} width="100%">
|
||||
<div className={styles.taskItem}>
|
||||
<div className={styles.taskContent}>
|
||||
{task.description && <div className={styles.title}>{task.description}</div>}
|
||||
{task.instruction && <div className={styles.instruction}>{task.instruction}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</Block>
|
||||
);
|
||||
},
|
||||
);
|
||||
const handleToggleThread = useCallback(() => {
|
||||
if (!subagentThread) return;
|
||||
if (isOpenInPortal) {
|
||||
closeThreadPortal();
|
||||
} else {
|
||||
openThreadInPortal(subagentThread.id, subagentThread.sourceMessageId);
|
||||
}
|
||||
}, [subagentThread, isOpenInPortal, openThreadInPortal, closeThreadPortal]);
|
||||
|
||||
if (!prompt && !result && !subagentThread) return null;
|
||||
|
||||
const showResultSection = !!result || !!subagentThread;
|
||||
|
||||
return (
|
||||
<Flexbox className={styles.container} gap={12}>
|
||||
{prompt && (
|
||||
<Flexbox>
|
||||
<Text className={styles.label} style={{ marginBlockEnd: 4 }}>
|
||||
{t('builtins.lobe-claude-code.agent.instruction')}
|
||||
</Text>
|
||||
<Flexbox className={styles.promptBox}>
|
||||
<Markdown style={{ maxHeight: 240, overflow: 'auto' }} variant={'chat'}>
|
||||
{prompt}
|
||||
</Markdown>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
)}
|
||||
|
||||
{showResultSection && (
|
||||
<Flexbox>
|
||||
<Flexbox
|
||||
horizontal
|
||||
align={'center'}
|
||||
className={styles.labelRow}
|
||||
justify={'space-between'}
|
||||
>
|
||||
<Text className={styles.label}>{t('builtins.lobe-claude-code.agent.result')}</Text>
|
||||
{subagentThread && (
|
||||
<Button
|
||||
className={styles.openThread}
|
||||
icon={ListTree}
|
||||
size={'small'}
|
||||
type={'text'}
|
||||
onClick={handleToggleThread}
|
||||
>
|
||||
{isOpenInPortal
|
||||
? tChat('thread.closeSubagentThread')
|
||||
: tChat('thread.openSubagentThread')}
|
||||
</Button>
|
||||
)}
|
||||
</Flexbox>
|
||||
{result && (
|
||||
<Flexbox className={styles.resultBox}>
|
||||
<Markdown style={{ maxHeight: 320, overflow: 'auto' }} variant={'chat'}>
|
||||
{result}
|
||||
</Markdown>
|
||||
</Flexbox>
|
||||
)}
|
||||
</Flexbox>
|
||||
)}
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
|
||||
CallSubAgentRender.displayName = 'CallSubAgentRender';
|
||||
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import type { BuiltinRenderProps } from '@lobechat/types';
|
||||
import { Block, Text } from '@lobehub/ui';
|
||||
import { Block, Button, Flexbox } from '@lobehub/ui';
|
||||
import { createStaticStyles } from 'antd-style';
|
||||
import { memo } from 'react';
|
||||
import { ListTree } from 'lucide-react';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { CallSubAgentsParams, CallSubAgentsState } from '../../../types';
|
||||
import { useChatStore } from '@/store/chat';
|
||||
import { portalThreadSelectors, threadSelectors } from '@/store/chat/selectors';
|
||||
|
||||
import type { CallSubAgentsParams, CallSubAgentsState, SubAgentRunStats } from '../../../types';
|
||||
import { SubAgentStats } from '../../components/SubAgentStats';
|
||||
|
||||
const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
index: css`
|
||||
@@ -13,13 +19,18 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
font-size: 12px;
|
||||
color: ${cssVar.colorTextQuaternary};
|
||||
`,
|
||||
|
||||
taskItem: css`
|
||||
openThread: css`
|
||||
height: 22px;
|
||||
padding-inline: 6px;
|
||||
font-size: 12px;
|
||||
`,
|
||||
row: css`
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
padding-block: 12px;
|
||||
padding-block: 10px;
|
||||
padding-inline: 12px;
|
||||
border-block-end: 1px dashed ${cssVar.colorBorderSecondary};
|
||||
|
||||
@@ -27,33 +38,93 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
border-block-end: none;
|
||||
}
|
||||
`,
|
||||
title: css`
|
||||
overflow: hidden;
|
||||
|
||||
font-size: 13px;
|
||||
color: ${cssVar.colorText};
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`,
|
||||
}));
|
||||
|
||||
interface SubAgentRowProps extends SubAgentRunStats {
|
||||
description: string;
|
||||
index: number;
|
||||
threadId: string;
|
||||
}
|
||||
|
||||
const SubAgentRow = memo<SubAgentRowProps>(
|
||||
({ description, index, threadId, model, totalToolCalls, totalTokens }) => {
|
||||
const { t: tChat } = useTranslation('chat');
|
||||
|
||||
const subagentThread = useChatStore((s) =>
|
||||
threadId
|
||||
? (threadSelectors.currentTopicThreads(s) ?? []).find((thread) => thread.id === threadId)
|
||||
: undefined,
|
||||
);
|
||||
const openThreadInPortal = useChatStore((s) => s.openThreadInPortal);
|
||||
const closeThreadPortal = useChatStore((s) => s.closeThreadPortal);
|
||||
const portalThreadId = useChatStore(portalThreadSelectors.portalThreadId);
|
||||
const isOpenInPortal = !!subagentThread && portalThreadId === subagentThread.id;
|
||||
|
||||
const handleToggleThread = useCallback(() => {
|
||||
if (!subagentThread) return;
|
||||
if (isOpenInPortal) {
|
||||
closeThreadPortal();
|
||||
} else {
|
||||
openThreadInPortal(subagentThread.id, subagentThread.sourceMessageId);
|
||||
}
|
||||
}, [subagentThread, isOpenInPortal, openThreadInPortal, closeThreadPortal]);
|
||||
|
||||
return (
|
||||
<div className={styles.row}>
|
||||
<Flexbox horizontal align={'center'} gap={8} style={{ minWidth: 0 }}>
|
||||
<span className={styles.index}>{index + 1}.</span>
|
||||
<span className={styles.title}>{description}</span>
|
||||
</Flexbox>
|
||||
<Flexbox horizontal align={'center'} gap={12} style={{ flexShrink: 0 }}>
|
||||
<SubAgentStats model={model} totalTokens={totalTokens} totalToolCalls={totalToolCalls} />
|
||||
{subagentThread && (
|
||||
<Button
|
||||
className={styles.openThread}
|
||||
icon={ListTree}
|
||||
size={'small'}
|
||||
type={'text'}
|
||||
onClick={handleToggleThread}
|
||||
>
|
||||
{isOpenInPortal
|
||||
? tChat('thread.closeSubagentThread')
|
||||
: tChat('thread.openSubagentThread')}
|
||||
</Button>
|
||||
)}
|
||||
</Flexbox>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
SubAgentRow.displayName = 'CallSubAgentsRow';
|
||||
|
||||
export const CallSubAgentsRender = memo<
|
||||
BuiltinRenderProps<CallSubAgentsParams, CallSubAgentsState>
|
||||
>(({ pluginState }) => {
|
||||
const { tasks } = pluginState || {};
|
||||
const subAgents = pluginState?.subAgents;
|
||||
|
||||
if (!tasks || tasks.length === 0) return null;
|
||||
if (!subAgents || subAgents.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Block variant={'outlined'} width="100%">
|
||||
{tasks.map((task, index) => (
|
||||
<div className={styles.taskItem} key={index}>
|
||||
<div className={styles.index}>{index + 1}.</div>
|
||||
<div>
|
||||
{task.description && (
|
||||
<Text as={'h4'} fontSize={14} weight={500}>
|
||||
{task.description}
|
||||
</Text>
|
||||
)}
|
||||
{task.instruction && (
|
||||
<Text as={'p'} ellipsis={{ rows: 2 }} fontSize={12} type={'secondary'}>
|
||||
{task.instruction}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{subAgents.map((subAgent, index) => (
|
||||
<SubAgentRow
|
||||
description={subAgent.description}
|
||||
index={index}
|
||||
key={subAgent.threadId || index}
|
||||
model={subAgent.model}
|
||||
threadId={subAgent.threadId}
|
||||
totalTokens={subAgent.totalTokens}
|
||||
totalToolCalls={subAgent.totalToolCalls}
|
||||
/>
|
||||
))}
|
||||
</Block>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
'use client';
|
||||
|
||||
import { createStaticStyles } from 'antd-style';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { SubAgentRunStats } from '../../types';
|
||||
|
||||
const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
root: css`
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
|
||||
font-size: 12px;
|
||||
color: ${cssVar.colorTextTertiary};
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`,
|
||||
}));
|
||||
|
||||
const formatTokens = (n: number): string => {
|
||||
if (n < 1000) return String(n);
|
||||
if (n < 1_000_000) return `${(n / 1000).toFixed(1).replace(/\.0$/, '')}k`;
|
||||
return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}m`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Compact one-line sub-agent run stats: tool count · model · token count.
|
||||
* Renders nothing when no stat is available (e.g. while the run is still in
|
||||
* flight, before the tool result state is persisted).
|
||||
*/
|
||||
export const SubAgentStats = memo<SubAgentRunStats>(({ model, totalToolCalls, totalTokens }) => {
|
||||
const { t } = useTranslation('plugin');
|
||||
|
||||
const items = [
|
||||
model || null,
|
||||
typeof totalToolCalls === 'number' && totalToolCalls > 0
|
||||
? t('builtins.lobe-agent.subAgent.stats.tools', { count: totalToolCalls })
|
||||
: null,
|
||||
typeof totalTokens === 'number' && totalTokens > 0
|
||||
? t('builtins.lobe-agent.subAgent.stats.tokens', { count: formatTokens(totalTokens) })
|
||||
: null,
|
||||
].filter(Boolean);
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return <span className={styles.root}>{items.join(' · ')}</span>;
|
||||
});
|
||||
|
||||
SubAgentStats.displayName = 'SubAgentStats';
|
||||
|
||||
export default SubAgentStats;
|
||||
@@ -359,28 +359,41 @@ class LobeAgentExecutor extends BaseExecutor<typeof LobeAgentApiName> {
|
||||
|
||||
// ==================== Sub-Agent ====================
|
||||
//
|
||||
// The executor only constructs the state payload that bridges the tool call
|
||||
// to the agent-runtime instruction layer. The actual sub-agent dispatch is
|
||||
// handled by `createAgentExecutors.ts` which reads `state.type` to emit the
|
||||
// matching `exec_sub_agent` / `exec_client_sub_agent(s)` instruction.
|
||||
// A sub-agent call is a normal tool call: the executor runs the sub-agent in
|
||||
// an isolated Thread via `ctx.subAgent` (the current runtime, injected by the
|
||||
// client) and returns the sub-agent's final output as the tool result. The
|
||||
// Thread id is persisted in state so the Render can open it in the portal.
|
||||
|
||||
callSubAgent = async (
|
||||
params: CallSubAgentParams,
|
||||
ctx: BuiltinToolContext,
|
||||
): Promise<BuiltinToolResult> => {
|
||||
const { description, instruction, inheritMessages, timeout, runInClient } = params;
|
||||
const { description, instruction, inheritMessages, timeout } = params;
|
||||
|
||||
if (!description || !instruction) {
|
||||
return { content: 'Sub-agent description and instruction are required.', success: false };
|
||||
}
|
||||
|
||||
const task = { description, inheritMessages, instruction, runInClient, timeout };
|
||||
const stateType = runInClient ? 'execClientSubAgent' : 'execSubAgent';
|
||||
if (!ctx.subAgent) {
|
||||
return { content: 'Sub-agent execution is not available in this runtime.', success: false };
|
||||
}
|
||||
|
||||
const { result, threadId, success, error, model, totalToolCalls, totalTokens } =
|
||||
await ctx.subAgent.run({
|
||||
description,
|
||||
inheritMessages,
|
||||
instruction,
|
||||
timeout,
|
||||
toolMessageId: ctx.messageId,
|
||||
});
|
||||
|
||||
if (!success) {
|
||||
return { content: error ?? 'Sub-agent execution failed.', success: false };
|
||||
}
|
||||
|
||||
return {
|
||||
content: `🚀 Dispatched sub-agent for ${runInClient ? 'client-side' : ''} execution:\n- ${description}`,
|
||||
state: { parentMessageId: ctx.messageId ?? '', task, type: stateType },
|
||||
stop: true,
|
||||
content: result,
|
||||
state: { model, threadId, totalToolCalls, totalTokens },
|
||||
success: true,
|
||||
};
|
||||
};
|
||||
@@ -395,16 +408,38 @@ class LobeAgentExecutor extends BaseExecutor<typeof LobeAgentApiName> {
|
||||
return { content: 'No sub-agents provided to dispatch.', success: false };
|
||||
}
|
||||
|
||||
const taskCount = tasks.length;
|
||||
const taskList = tasks.map((t, i) => `${i + 1}. ${t.description}`).join('\n');
|
||||
const hasClientTasks = tasks.some((t) => t.runInClient);
|
||||
const stateType = hasClientTasks ? 'execClientSubAgents' : 'execSubAgents';
|
||||
const executionMode = hasClientTasks ? 'client-side' : '';
|
||||
if (!ctx.subAgent) {
|
||||
return { content: 'Sub-agent execution is not available in this runtime.', success: false };
|
||||
}
|
||||
|
||||
const subAgent = ctx.subAgent;
|
||||
const results = await Promise.all(
|
||||
tasks.map((task) =>
|
||||
subAgent.run({
|
||||
description: task.description,
|
||||
inheritMessages: task.inheritMessages,
|
||||
instruction: task.instruction,
|
||||
timeout: task.timeout,
|
||||
toolMessageId: ctx.messageId,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const content = results
|
||||
.map((r, i) => `${i + 1}. ${tasks[i].description}\n${r.success ? r.result : `❌ ${r.error}`}`)
|
||||
.join('\n\n');
|
||||
|
||||
return {
|
||||
content: `🚀 Dispatched ${taskCount} sub-agent${taskCount > 1 ? 's' : ''} for ${executionMode} execution:\n${taskList}`,
|
||||
state: { parentMessageId: ctx.messageId ?? '', tasks, type: stateType },
|
||||
stop: true,
|
||||
content,
|
||||
state: {
|
||||
subAgents: results.map((r, i) => ({
|
||||
description: tasks[i].description,
|
||||
model: r.model,
|
||||
threadId: r.threadId,
|
||||
totalToolCalls: r.totalToolCalls,
|
||||
totalTokens: r.totalTokens,
|
||||
})),
|
||||
},
|
||||
success: true,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -82,38 +82,30 @@ export interface CallSubAgentsParams {
|
||||
tasks: SubAgentTask[];
|
||||
}
|
||||
|
||||
/** Execution stats reported back by a finished sub-agent run. */
|
||||
export interface SubAgentRunStats {
|
||||
/** Model the sub-agent ran on */
|
||||
model?: string;
|
||||
/** Total tokens consumed by the sub-agent run */
|
||||
totalTokens?: number;
|
||||
/** Number of tool calls the sub-agent made */
|
||||
totalToolCalls?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* State returned after dispatching a server-side sub-agent.
|
||||
* State persisted on the callSubAgent tool message.
|
||||
*
|
||||
* The `type` value is the wire-level discriminator the `agent-runtime`
|
||||
* layer (`GeneralChatAgent.tool_result`) inspects to emit the matching
|
||||
* `exec_sub_agent` / `exec_client_sub_agent` instruction.
|
||||
* The sub-agent runs in an isolated Thread via the current runtime; the Render
|
||||
* uses `threadId` to open that Thread in the portal, and the stats feed the
|
||||
* Inspector row.
|
||||
*/
|
||||
export interface CallSubAgentState {
|
||||
parentMessageId: string;
|
||||
task: SubAgentTask;
|
||||
type: 'execSubAgent';
|
||||
export interface CallSubAgentState extends SubAgentRunStats {
|
||||
threadId: string;
|
||||
}
|
||||
|
||||
/** State returned after dispatching multiple server-side sub-agents. */
|
||||
/** State persisted on the callSubAgents tool message (one entry per sub-agent). */
|
||||
export interface CallSubAgentsState {
|
||||
parentMessageId: string;
|
||||
tasks: SubAgentTask[];
|
||||
type: 'execSubAgents';
|
||||
}
|
||||
|
||||
/** State returned after dispatching a desktop-only client-side sub-agent. */
|
||||
export interface CallClientSubAgentState {
|
||||
parentMessageId: string;
|
||||
task: SubAgentTask;
|
||||
type: 'execClientSubAgent';
|
||||
}
|
||||
|
||||
/** State returned after dispatching multiple desktop-only client-side sub-agents. */
|
||||
export interface CallClientSubAgentsState {
|
||||
parentMessageId: string;
|
||||
tasks: SubAgentTask[];
|
||||
type: 'execClientSubAgents';
|
||||
subAgents: ({ description: string; threadId: string } & SubAgentRunStats)[];
|
||||
}
|
||||
|
||||
// ==================== Todo Item ====================
|
||||
|
||||
@@ -185,9 +185,9 @@ export class LocalSystemExecutionRuntime extends ComputerRuntime {
|
||||
case 'getCommandOutput': {
|
||||
return {
|
||||
result: {
|
||||
exitCode: raw.exit_code,
|
||||
error: raw.error,
|
||||
newOutput: raw.output,
|
||||
running: raw.running,
|
||||
success: raw.success,
|
||||
},
|
||||
success: raw.success,
|
||||
|
||||
@@ -148,7 +148,7 @@ describe('localSystemExecutor.getCommandOutput — filter forwarding', () => {
|
||||
};
|
||||
const spy = vi.spyOn(runtime, 'getCommandOutput').mockResolvedValue({
|
||||
content: '',
|
||||
state: { newOutput: '', running: false, success: true },
|
||||
state: { exitCode: 0, newOutput: '', success: true },
|
||||
success: true,
|
||||
});
|
||||
|
||||
@@ -161,6 +161,33 @@ describe('localSystemExecutor.getCommandOutput — filter forwarding', () => {
|
||||
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('preserves `exitCode` state from getCommandOutput', async () => {
|
||||
const runtime = (localSystemExecutor as any).runtime as {
|
||||
getCommandOutput: (args: any) => Promise<unknown>;
|
||||
};
|
||||
const spy = vi.spyOn(runtime, 'getCommandOutput');
|
||||
|
||||
spy.mockResolvedValueOnce({
|
||||
content: '',
|
||||
state: { exitCode: undefined, newOutput: '', success: true },
|
||||
success: true,
|
||||
});
|
||||
|
||||
const runningResult = await localSystemExecutor.getCommandOutput({ shell_id: 'sh-running' });
|
||||
expect(runningResult.state).toMatchObject({ exitCode: undefined });
|
||||
|
||||
spy.mockResolvedValueOnce({
|
||||
content: '',
|
||||
state: { exitCode: 0, newOutput: 'done', success: true },
|
||||
success: true,
|
||||
});
|
||||
|
||||
const doneResult = await localSystemExecutor.getCommandOutput({ shell_id: 'sh-done' });
|
||||
expect(doneResult.state).toMatchObject({ exitCode: 0 });
|
||||
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('localSystemExecutor.runCommand — background field normalization', () => {
|
||||
|
||||
@@ -263,9 +263,9 @@ export const LocalSystemManifest: BuiltinToolManifest = {
|
||||
},
|
||||
},
|
||||
{
|
||||
defaultTimeoutMs: 120_000,
|
||||
defaultTimeoutMs: 30_000,
|
||||
description:
|
||||
'Execute a shell command and return its output. Supports both synchronous and background execution with timeout control.',
|
||||
'Start a terminal session to execute a shell command and return console output collected during the wait window. If the command exits during that window, the result includes `exit_code`; if it is still running, the result includes `shell_id` for later output retrieval or termination.',
|
||||
humanIntervention: 'required',
|
||||
name: LocalSystemApiName.runCommand,
|
||||
parameters: {
|
||||
@@ -286,13 +286,9 @@ export const LocalSystemManifest: BuiltinToolManifest = {
|
||||
type: 'object',
|
||||
},
|
||||
run_in_background: {
|
||||
description: 'Set to true to run command in background and return shell_id',
|
||||
type: 'boolean',
|
||||
},
|
||||
timeout: {
|
||||
description:
|
||||
'Timeout in milliseconds for this command. Default 120000ms. Server clamps to [1000, 800000]; raise this for long-running tasks (builds, large searches) instead of letting them hit the default and fail.',
|
||||
type: 'number',
|
||||
'Set to true to return immediately after starting the terminal session. The result will include a `shell_id` for later observation or termination.',
|
||||
type: 'boolean',
|
||||
},
|
||||
},
|
||||
required: ['description', 'command'],
|
||||
|
||||
@@ -28,9 +28,9 @@ You have access to a set of tools to interact with the user's local file system:
|
||||
5. **moveFiles**: Moves multiple files or directories. Also handles renames — pass the original directory with the new filename in \`newPath\`.
|
||||
|
||||
**Shell Commands:**
|
||||
6. **runCommand**: Execute shell commands with timeout control. Supports both synchronous and background execution. When providing a description, always use the same language as the user's input.
|
||||
7. **getCommandOutput**: Retrieve output from running background commands. Returns only new output since last check.
|
||||
8. **killCommand**: Terminate a running background shell command by its ID.
|
||||
6. **runCommand**: Start a terminal session to execute shell commands and return console output collected during the wait window. When providing a description, always use the same language as the user's input.
|
||||
7. **getCommandOutput**: Retrieve output from an existing terminal session. Returns only new output since last check.
|
||||
8. **killCommand**: Terminate a running terminal session by its ID.
|
||||
|
||||
**Search & Find:**
|
||||
9. **searchFiles**: Searches for files based on keywords and other criteria using native search. Use this tool to find files if the user is unsure about the exact path.
|
||||
@@ -85,14 +85,17 @@ You have access to a set of tools to interact with the user's local file system:
|
||||
- For executing shell commands: Use 'runCommand'. Provide the following parameters:
|
||||
- 'command': The shell command to execute.
|
||||
- 'description' (Optional but recommended): A clear, concise description of what the command does (5-10 words, in active voice). **IMPORTANT: Always use the same language as the user's input.** If the user speaks Chinese, write the description in Chinese; if English, use English, etc.
|
||||
- 'run_in_background' (Optional): Set to true to run in background and get a shell_id for later checking output.
|
||||
- 'timeout' (Optional): Timeout in milliseconds (default: 120000ms, max: 800000ms).
|
||||
- 'run_in_background' (Optional): Set to true to return immediately after starting the terminal session. The result includes a 'shell_id' for later observation or termination.
|
||||
The command runs in cmd.exe on Windows or /bin/sh on macOS/Linux.
|
||||
- For retrieving output from background commands: Use 'getCommandOutput'. Provide:
|
||||
- 'shell_id': The ID returned from runCommand when run_in_background was true.
|
||||
- Result semantics:
|
||||
- 'success' indicates whether the tool call itself succeeded.
|
||||
- 'shell_id' identifies the terminal session for later observation/termination.
|
||||
- 'exit_code' is only present after the command has exited. If it is absent, the command is still running.
|
||||
- For retrieving output from terminal sessions: Use 'getCommandOutput'. Provide:
|
||||
- 'shell_id': The ID returned from runCommand.
|
||||
- 'filter' (Optional): A regex pattern to filter output lines.
|
||||
Returns only new output since the last check.
|
||||
- For killing background commands: Use 'killCommand' with 'shell_id'.
|
||||
- For killing running terminal sessions: Use 'killCommand' with 'shell_id'.
|
||||
- For searching content in files: Use 'grepContent'. Provide:
|
||||
- 'pattern': The regex pattern to search for.
|
||||
- 'scope' (Optional): Directory to search in. Defaults to the working directory if omitted.
|
||||
@@ -118,7 +121,6 @@ You have access to a set of tools to interact with the user's local file system:
|
||||
- Be cautious with commands that have side effects (e.g., rm, sudo, format).
|
||||
- Always describe what a command will do before running it, especially for non-trivial operations.
|
||||
- Always provide a clear 'description' parameter in the user's language to help them understand what the command does.
|
||||
- Use appropriate timeouts to prevent commands from running indefinitely.
|
||||
- When editing files:
|
||||
- Always read the file first to verify its current content.
|
||||
- Ensure old_string exactly matches the text to be replaced to avoid unintended changes.
|
||||
|
||||
@@ -17,9 +17,9 @@ You have access to a set of tools to interact with the user's local file system:
|
||||
5. **moveFiles**: Moves multiple files or directories. Also handles renames — pass the original directory with the new filename in \`newPath\`.
|
||||
|
||||
**Shell Commands:**
|
||||
6. **runCommand**: Execute shell commands with timeout control. Supports both synchronous and background execution. When providing a description, always use the same language as the user's input.
|
||||
7. **getCommandOutput**: Retrieve output from running background commands. Returns only new output since last check.
|
||||
8. **killCommand**: Terminate a running background shell command by its ID.
|
||||
6. **runCommand**: Start a terminal session to execute shell commands and return console output collected during the wait window. When providing a description, always use the same language as the user's input.
|
||||
7. **getCommandOutput**: Retrieve output from an existing terminal session. Returns only new output since last check.
|
||||
8. **killCommand**: Terminate a running terminal session by its ID.
|
||||
|
||||
**Search & Find:**
|
||||
9. **searchFiles**: Searches for files based on keywords and other criteria using native search. Use this tool to find files if the user is unsure about the exact path.
|
||||
@@ -74,14 +74,17 @@ You have access to a set of tools to interact with the user's local file system:
|
||||
- For executing shell commands: Use 'runCommand'. Provide the following parameters:
|
||||
- 'command': The shell command to execute.
|
||||
- 'description' (Optional but recommended): A clear, concise description of what the command does (5-10 words, in active voice). **IMPORTANT: Always use the same language as the user's input.** If the user speaks Chinese, write the description in Chinese; if English, use English, etc.
|
||||
- 'run_in_background' (Optional): Set to true to run in background and get a shell_id for later checking output.
|
||||
- 'timeout' (Optional): Timeout in milliseconds (default: 120000ms, max: 800000ms).
|
||||
- 'run_in_background' (Optional): Set to true to return immediately after starting the terminal session. The result includes a 'shell_id' for later observation or termination.
|
||||
The command runs in cmd.exe on Windows or /bin/sh on macOS/Linux.
|
||||
- For retrieving output from background commands: Use 'getCommandOutput'. Provide:
|
||||
- 'shell_id': The ID returned from runCommand when run_in_background was true.
|
||||
- Result semantics:
|
||||
- 'success' indicates whether the tool call itself succeeded.
|
||||
- 'shell_id' identifies the terminal session for later observation/termination.
|
||||
- 'exit_code' is only present after the command has exited. If it is absent, the command is still running.
|
||||
- For retrieving output from terminal sessions: Use 'getCommandOutput'. Provide:
|
||||
- 'shell_id': The ID returned from runCommand.
|
||||
- 'filter' (Optional): A regex pattern to filter output lines.
|
||||
Returns only new output since the last check.
|
||||
- For killing background commands: Use 'killCommand' with 'shell_id'.
|
||||
- For killing running terminal sessions: Use 'killCommand' with 'shell_id'.
|
||||
- For searching content in files: Use 'grepContent'. Provide:
|
||||
- 'pattern': The regex pattern to search for.
|
||||
- 'scope' (Optional): Directory to search in. Defaults to the working directory if omitted.
|
||||
|
||||
@@ -7,6 +7,7 @@ export const MessageToolIdentifier = 'lobe-message';
|
||||
export const MessagePlatform = {
|
||||
discord: 'discord',
|
||||
feishu: 'feishu',
|
||||
imessage: 'imessage',
|
||||
lark: 'lark',
|
||||
qq: 'qq',
|
||||
slack: 'slack',
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
'use client';
|
||||
|
||||
import type { InitDocumentArgs } from '@lobechat/editor-runtime';
|
||||
import type { BuiltinStreamingProps } from '@lobechat/types';
|
||||
import { Flexbox, Icon, Text } from '@lobehub/ui';
|
||||
import { createStaticStyles, cssVar } from 'antd-style';
|
||||
import { FileText, Hash, ListTree } from 'lucide-react';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import StreamingMarkdown from '@/components/StreamingMarkdown';
|
||||
|
||||
import { AnimatedNumber } from '../../components/AnimatedNumber';
|
||||
|
||||
const MAX_PREVIEW_CHARS = 4000;
|
||||
|
||||
const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
container: css`
|
||||
overflow: hidden;
|
||||
|
||||
width: 100%;
|
||||
border: 1px solid ${cssVar.colorBorderSecondary};
|
||||
border-radius: 8px;
|
||||
|
||||
background: ${cssVar.colorBgContainer};
|
||||
`,
|
||||
header: css`
|
||||
padding-block: 10px;
|
||||
padding-inline: 12px;
|
||||
border-block-end: 1px solid ${cssVar.colorBorderSecondary};
|
||||
`,
|
||||
icon: css`
|
||||
color: ${cssVar.colorPrimary};
|
||||
`,
|
||||
meta: css`
|
||||
color: ${cssVar.colorTextDescription};
|
||||
`,
|
||||
preview: css`
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
padding-block: 8px;
|
||||
padding-inline: 12px;
|
||||
`,
|
||||
title: css`
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 1;
|
||||
|
||||
font-weight: 500;
|
||||
color: ${cssVar.colorText};
|
||||
`,
|
||||
}));
|
||||
|
||||
const extractTitle = (markdown: string) => {
|
||||
const titleLine = markdown
|
||||
.split(/\r?\n/)
|
||||
.find((line) => line.startsWith('# ') && line.slice(2).trim().length > 0);
|
||||
|
||||
return titleLine?.slice(2).trim();
|
||||
};
|
||||
|
||||
export const InitPageStreaming = memo<BuiltinStreamingProps<InitDocumentArgs>>(({ args }) => {
|
||||
const { t } = useTranslation('plugin');
|
||||
const markdown = args?.markdown || '';
|
||||
|
||||
const { chars, lines, preview, title } = useMemo(() => {
|
||||
const preview =
|
||||
markdown.length > MAX_PREVIEW_CHARS
|
||||
? `${markdown.slice(0, MAX_PREVIEW_CHARS)}\n\n...`
|
||||
: markdown;
|
||||
|
||||
return {
|
||||
chars: markdown.length,
|
||||
lines: markdown ? markdown.split('\n').length : 0,
|
||||
preview,
|
||||
title: extractTitle(markdown),
|
||||
};
|
||||
}, [markdown]);
|
||||
|
||||
if (!markdown) return null;
|
||||
|
||||
return (
|
||||
<Flexbox className={styles.container}>
|
||||
<Flexbox horizontal align={'center'} className={styles.header} gap={8}>
|
||||
<FileText className={styles.icon} size={16} />
|
||||
<Flexbox flex={1} gap={2}>
|
||||
<div className={styles.title}>
|
||||
{title || t('builtins.lobe-page-agent.apiName.initPage.creating')}
|
||||
</div>
|
||||
<Flexbox horizontal align={'center'} className={styles.meta} gap={10}>
|
||||
<Text as={'span'} color={cssVar.colorTextDescription} fontSize={12}>
|
||||
<Icon icon={ListTree} size={12} /> <AnimatedNumber value={lines} />
|
||||
{t('builtins.lobe-page-agent.apiName.initPage.lines')}
|
||||
</Text>
|
||||
<Text as={'span'} color={cssVar.colorTextDescription} fontSize={12}>
|
||||
<Icon icon={Hash} size={12} /> <AnimatedNumber value={chars} />
|
||||
{t('builtins.lobe-page-agent.apiName.initPage.chars')}
|
||||
</Text>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
<div className={styles.preview}>
|
||||
<StreamingMarkdown>{preview}</StreamingMarkdown>
|
||||
</div>
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
|
||||
InitPageStreaming.displayName = 'PageAgentInitPageStreaming';
|
||||
|
||||
export default InitPageStreaming;
|
||||
@@ -1,9 +1,14 @@
|
||||
import type { BuiltinStreaming } from '@lobechat/types';
|
||||
|
||||
import { DocumentApiName } from '../../types';
|
||||
import { InitPageStreaming } from './InitPage';
|
||||
|
||||
/**
|
||||
* Page Agent Streaming Components Registry
|
||||
*
|
||||
* Streaming components are used to render tool calls while arguments
|
||||
* are still being generated, allowing real-time feedback to users.
|
||||
*/
|
||||
export const PageAgentStreamings: Record<string, BuiltinStreaming> = {};
|
||||
export const PageAgentStreamings: Record<string, BuiltinStreaming> = {
|
||||
[DocumentApiName.initPage]: InitPageStreaming as BuiltinStreaming,
|
||||
};
|
||||
|
||||
@@ -4,7 +4,6 @@ import { AGENT_SKILLS_IDENTIFIER_PREFIX } from '@lobechat/const';
|
||||
import { type BuiltinInspectorProps } from '@lobechat/types';
|
||||
import { SkillsIcon } from '@lobehub/ui/icons';
|
||||
import { createStaticStyles, cx } from 'antd-style';
|
||||
import { type TFunction } from 'i18next';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -12,33 +11,34 @@ import { inspectorTextStyles, shinyTextStyles } from '@/styles';
|
||||
|
||||
import type { ActivateSkillParams, ActivateSkillSource, ActivateSkillState } from '../../../types';
|
||||
|
||||
type SkillLabelKey =
|
||||
| 'builtins.lobe-skills.apiName.activateAgentSkill'
|
||||
| 'builtins.lobe-skills.apiName.activateProjectSkill'
|
||||
| 'builtins.lobe-skills.apiName.activateSkill';
|
||||
|
||||
/**
|
||||
* Resolve the inspector label. State-side `source` is the authority once the
|
||||
* Resolve the inspector label key. State-side `source` is the authority once the
|
||||
* tool result has streamed in; while args are still streaming we only have the
|
||||
* raw `name` to go on, so detect agent skills via the identifier prefix as a
|
||||
* best-effort fallback. Project skills can't be inferred from the bare name
|
||||
* (no prefix), so they show "Activate Skill" until the result lands.
|
||||
*
|
||||
* `t` is invoked with literal keys per branch so i18next's typed-key map can
|
||||
* still validate the call site.
|
||||
*/
|
||||
const resolveLabel = (
|
||||
t: TFunction<'plugin'>,
|
||||
const resolveLabelKey = (
|
||||
source: ActivateSkillSource | undefined,
|
||||
rawName: string | undefined,
|
||||
): string => {
|
||||
): SkillLabelKey => {
|
||||
const effective: ActivateSkillSource =
|
||||
source ?? (rawName?.startsWith(AGENT_SKILLS_IDENTIFIER_PREFIX) ? 'agent' : 'builtin');
|
||||
|
||||
switch (effective) {
|
||||
case 'agent': {
|
||||
return t('builtins.lobe-skills.apiName.activateAgentSkill');
|
||||
return 'builtins.lobe-skills.apiName.activateAgentSkill';
|
||||
}
|
||||
case 'project': {
|
||||
return t('builtins.lobe-skills.apiName.activateProjectSkill');
|
||||
return 'builtins.lobe-skills.apiName.activateProjectSkill';
|
||||
}
|
||||
default: {
|
||||
return t('builtins.lobe-skills.apiName.activateSkill');
|
||||
return 'builtins.lobe-skills.apiName.activateSkill';
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -84,7 +84,7 @@ export const RunSkillInspector = memo<
|
||||
|
||||
const name = args?.name || partialArgs?.name;
|
||||
const displayName = pluginState?.title || pluginState?.name || name;
|
||||
const label = resolveLabel(t, pluginState?.source, name);
|
||||
const label = t(resolveLabelKey(pluginState?.source, name));
|
||||
|
||||
if (isArgumentsStreaming) {
|
||||
if (!displayName)
|
||||
|
||||
@@ -66,6 +66,7 @@ import type { BuiltinInspector } from '@lobechat/types';
|
||||
import { CodexInspectors } from './codex';
|
||||
import { GithubIdentifier, GithubInspectors } from './github';
|
||||
import { LinearIdentifier, LinearInspectors } from './linear';
|
||||
import { TwitterIdentifier, TwitterInspectors } from './twitter';
|
||||
|
||||
/**
|
||||
* Builtin tools inspector registry
|
||||
@@ -113,6 +114,7 @@ const BuiltinToolInspectors: Record<string, Record<string, BuiltinInspector>> =
|
||||
},
|
||||
[GithubIdentifier]: GithubInspectors,
|
||||
[LinearIdentifier]: LinearInspectors,
|
||||
[TwitterIdentifier]: TwitterInspectors,
|
||||
};
|
||||
|
||||
export interface BuiltinInspectorRegistryEntry {
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
} from '@lobechat/builtin-tool-local-system/client';
|
||||
import { MemoryManifest, MemoryStreamings } from '@lobechat/builtin-tool-memory/client';
|
||||
import { MessageManifest, MessageStreamings } from '@lobechat/builtin-tool-message/client';
|
||||
import { PageAgentManifest, PageAgentStreamings } from '@lobechat/builtin-tool-page-agent/client';
|
||||
import { type BuiltinStreaming } from '@lobechat/types';
|
||||
|
||||
/**
|
||||
@@ -64,6 +65,7 @@ const BuiltinToolStreamings: Record<string, Record<string, BuiltinStreaming>> =
|
||||
[LocalSystemManifest.identifier]: LocalSystemStreamings as Record<string, BuiltinStreaming>,
|
||||
[MemoryManifest.identifier]: MemoryStreamings as Record<string, BuiltinStreaming>,
|
||||
[MessageManifest.identifier]: MessageStreamings as Record<string, BuiltinStreaming>,
|
||||
[PageAgentManifest.identifier]: PageAgentStreamings as Record<string, BuiltinStreaming>,
|
||||
};
|
||||
|
||||
export interface BuiltinStreamingRegistryEntry {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { TwitterInspector } from '@lobechat/shared-tool-ui/inspectors';
|
||||
import type { BuiltinInspector } from '@lobechat/types';
|
||||
|
||||
// LobeHub X (Twitter) skill: tool calls arrive with `identifier='twitter'`
|
||||
// and bare verb_noun apiNames (`get_tweet`, `get_user`, `post_tweet`,
|
||||
// `search_tweets`, …). The MCP surface isn't fixed in this repo, so we
|
||||
// register the inspector through a Proxy that returns it for any apiName
|
||||
// under the twitter identifier — the inspector's verb_noun parser handles
|
||||
// labels generically and falls back gracefully on unknown verbs.
|
||||
export const TwitterIdentifier = 'twitter';
|
||||
|
||||
export const TwitterInspectors: Record<string, BuiltinInspector> = new Proxy(
|
||||
{} as Record<string, BuiltinInspector>,
|
||||
{
|
||||
get: (_target, prop) => {
|
||||
if (typeof prop !== 'string') return undefined;
|
||||
return TwitterInspector;
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -21,6 +21,6 @@
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"tsup": "^8.3.5",
|
||||
"typescript": "^5.7.2"
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "@lobechat/chat-adapter-imessage",
|
||||
"version": "0.1.0",
|
||||
"description": "iMessage adapter for chat SDK via BlueBubbles",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"clean": "rm -rf dist",
|
||||
"dev": "tsup --watch",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"chat": "^4.23.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"tsup": "^8.3.5",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createImessageAdapter, extractAttachmentMetadata, ImessageAdapter } from './adapter';
|
||||
import { BlueBubblesApiClient } from './api';
|
||||
import type { BlueBubblesMessage, BlueBubblesWebhookEvent } from './types';
|
||||
|
||||
const baseConfig = {
|
||||
password: 'server-password',
|
||||
serverUrl: 'https://bluebubbles.example.com',
|
||||
webhookSecret: 'shared-secret',
|
||||
};
|
||||
|
||||
function makeAdapter(overrides: Partial<typeof baseConfig> = {}) {
|
||||
const adapter = createImessageAdapter({ ...baseConfig, ...overrides });
|
||||
const processMessage = vi.fn(
|
||||
async (_adapter: unknown, _threadId: string, factory: () => Promise<unknown> | unknown) =>
|
||||
factory(),
|
||||
);
|
||||
const chat = {
|
||||
getLogger: () => ({
|
||||
child: () => ({}),
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
}),
|
||||
getUserName: () => 'imessage-bot',
|
||||
processMessage,
|
||||
} as any;
|
||||
return { adapter, chat, processMessage };
|
||||
}
|
||||
|
||||
function makeRequest(body: BlueBubblesWebhookEvent, secret = baseConfig.webhookSecret): Request {
|
||||
return new Request(
|
||||
`https://lobehub.example.com/api/agent/webhooks/imessage/mac?secret=${secret}`,
|
||||
{
|
||||
body: JSON.stringify(body),
|
||||
method: 'POST',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function textMessage(overrides: Partial<BlueBubblesMessage> = {}): BlueBubblesMessage {
|
||||
return {
|
||||
chats: [{ guid: 'iMessage;-;chat-1', style: 45 }],
|
||||
dateCreated: 1_700_000_000_000,
|
||||
guid: 'msg-1',
|
||||
handle: { address: '+15551234567' },
|
||||
isFromMe: false,
|
||||
text: 'hello',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('ImessageAdapter webhook handling', () => {
|
||||
let fetchSpy: any;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
fetchSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('rejects POST with a missing or mismatched secret', async () => {
|
||||
const { adapter, chat } = makeAdapter();
|
||||
await adapter.initialize(chat);
|
||||
|
||||
const res = await adapter.handleWebhook(
|
||||
makeRequest({ data: textMessage(), type: 'new-message' }, 'wrong'),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('dispatches a BlueBubbles new-message webhook to the chat guid thread', async () => {
|
||||
const { adapter, chat, processMessage } = makeAdapter();
|
||||
await adapter.initialize(chat);
|
||||
|
||||
const res = await adapter.handleWebhook(
|
||||
makeRequest({ data: textMessage(), type: 'new-message' }),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(processMessage).toHaveBeenCalledTimes(1);
|
||||
expect(processMessage.mock.calls[0][1]).toBe('imessage:iMessage;-;chat-1');
|
||||
|
||||
const factory = processMessage.mock.calls[0][2] as () => Promise<any>;
|
||||
const message = await factory();
|
||||
expect(message.text).toBe('hello');
|
||||
expect(message.author.userId).toBe('+15551234567');
|
||||
expect(message.metadata.dateSent.getTime()).toBe(1_700_000_000_000);
|
||||
});
|
||||
|
||||
it('enriches webhook messages that do not carry chats', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: textMessage({ guid: 'msg-needs-enrichment', text: 'enriched' }),
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
|
||||
const { adapter, chat, processMessage } = makeAdapter();
|
||||
await adapter.initialize(chat);
|
||||
|
||||
const res = await adapter.handleWebhook(
|
||||
makeRequest({ data: { guid: 'msg-needs-enrichment', isFromMe: false }, type: 'new-message' }),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
expect(fetchSpy.mock.calls[0][0]).toBe(
|
||||
'https://bluebubbles.example.com/api/v1/message/msg-needs-enrichment?password=server-password&with=chats%2Cattachments',
|
||||
);
|
||||
expect(processMessage.mock.calls[0][1]).toBe('imessage:iMessage;-;chat-1');
|
||||
});
|
||||
|
||||
it('ignores messages sent by the hosted Mac account', async () => {
|
||||
const { adapter, chat, processMessage } = makeAdapter();
|
||||
await adapter.initialize(chat);
|
||||
|
||||
const res = await adapter.handleWebhook(
|
||||
makeRequest({ data: textMessage({ isFromMe: true }), type: 'new-message' }),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(processMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ImessageAdapter parsing and outbound', () => {
|
||||
let fetchSpy: any;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify({ data: { guid: 'sent-1', text: 'hi back' } }), {
|
||||
status: 200,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
fetchSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('extracts metadata-only attachments from BlueBubbles messages', () => {
|
||||
const attachments = extractAttachmentMetadata(
|
||||
textMessage({
|
||||
attachments: [
|
||||
{
|
||||
guid: 'att-1',
|
||||
mimeType: 'image/png',
|
||||
totalBytes: 123,
|
||||
transferName: 'photo.png',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(attachments).toHaveLength(1);
|
||||
expect(attachments[0].type).toBe('image');
|
||||
expect(attachments[0].mimeType).toBe('image/png');
|
||||
expect((attachments[0] as any).raw.guid).toBe('att-1');
|
||||
});
|
||||
|
||||
it('postMessage sends text through BlueBubbles /message/text', async () => {
|
||||
const adapter = new ImessageAdapter(baseConfig);
|
||||
const result = await adapter.postMessage('imessage:iMessage;-;chat-1', 'hi back' as any);
|
||||
|
||||
expect(result.id).toBe('sent-1');
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toBe(
|
||||
'https://bluebubbles.example.com/api/v1/message/text?password=server-password',
|
||||
);
|
||||
expect(init.method).toBe('POST');
|
||||
const body = JSON.parse(init.body as string);
|
||||
expect(body.chatGuid).toBe('iMessage;-;chat-1');
|
||||
expect(body.message).toBe('hi back');
|
||||
expect(body.method).toBe('apple-script');
|
||||
expect(body.tempGuid).toBeTruthy();
|
||||
});
|
||||
|
||||
it('BlueBubblesApiClient pings the authenticated API endpoint', async () => {
|
||||
const api = new BlueBubblesApiClient(baseConfig);
|
||||
await api.ping();
|
||||
|
||||
expect(fetchSpy.mock.calls[0][0]).toBe(
|
||||
'https://bluebubbles.example.com/api/v1/ping?password=server-password',
|
||||
);
|
||||
});
|
||||
|
||||
it('applies the request timeout when fetching outbound attachment URLs', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
fetchSpy.mockImplementationOnce(
|
||||
async (_url: string | URL | Request, init?: RequestInit) =>
|
||||
new Promise<Response>((_resolve, reject) => {
|
||||
init?.signal?.addEventListener('abort', () => {
|
||||
reject(new DOMException('Attachment fetch timed out', 'AbortError'));
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const api = new BlueBubblesApiClient({ ...baseConfig, requestTimeoutMs: 1000 });
|
||||
const sendPromise = api.sendAttachment('iMessage;-;chat-1', {
|
||||
fetchUrl: 'https://assets.example.com/photo.png',
|
||||
mimeType: 'image/png',
|
||||
name: 'photo.png',
|
||||
});
|
||||
const assertion = expect(sendPromise).rejects.toMatchObject({ name: 'AbortError' });
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
|
||||
await assertion;
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
expect(fetchSpy.mock.calls[0][0]).toBe('https://assets.example.com/photo.png');
|
||||
expect(fetchSpy.mock.calls[0][1]).toMatchObject({
|
||||
method: 'GET',
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,388 @@
|
||||
import type {
|
||||
Adapter,
|
||||
AdapterPostableMessage,
|
||||
Attachment,
|
||||
Author,
|
||||
ChatInstance,
|
||||
EmojiValue,
|
||||
FetchOptions,
|
||||
FetchResult,
|
||||
FormattedContent,
|
||||
Logger,
|
||||
RawMessage,
|
||||
ThreadInfo,
|
||||
WebhookOptions,
|
||||
} from 'chat';
|
||||
import { Message, parseMarkdown } from 'chat';
|
||||
|
||||
import { BlueBubblesApiClient, resolveAttachmentName } from './api';
|
||||
import { ImessageFormatConverter } from './format-converter';
|
||||
import type {
|
||||
BlueBubblesAttachment,
|
||||
BlueBubblesChat,
|
||||
BlueBubblesMessage,
|
||||
BlueBubblesWebhookEvent,
|
||||
ImessageAdapterConfig,
|
||||
ImessageBridgeTransport,
|
||||
ImessageThreadId,
|
||||
} from './types';
|
||||
|
||||
const NEW_MESSAGE_EVENT = 'new-message';
|
||||
|
||||
function senderIdFromMessage(message: BlueBubblesMessage): string {
|
||||
const handle = message.handle;
|
||||
return (
|
||||
handle?.address ||
|
||||
handle?.uncanonicalizedId ||
|
||||
String(message.handleId ?? message.otherHandle ?? 'unknown')
|
||||
);
|
||||
}
|
||||
|
||||
function extractText(message: BlueBubblesMessage): string {
|
||||
const text = message.text?.trim();
|
||||
if (text) return text;
|
||||
|
||||
const subject = message.subject?.trim();
|
||||
if (subject) return subject;
|
||||
|
||||
const attachments = message.attachments ?? [];
|
||||
if (attachments.length === 0) return '';
|
||||
return attachments
|
||||
.map((attachment) => {
|
||||
const name = resolveAttachmentName(attachment);
|
||||
return `[attachment: ${name}]`;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function attachmentType(mimeType: string | undefined): 'audio' | 'file' | 'image' | 'video' {
|
||||
if (mimeType?.startsWith('image/')) return 'image';
|
||||
if (mimeType?.startsWith('video/')) return 'video';
|
||||
if (mimeType?.startsWith('audio/')) return 'audio';
|
||||
return 'file';
|
||||
}
|
||||
|
||||
export function extractAttachmentMetadata(message: BlueBubblesMessage): Attachment[] {
|
||||
return (message.attachments ?? []).map((attachment) => ({
|
||||
mimeType: attachment.mimeType ?? 'application/octet-stream',
|
||||
name: resolveAttachmentName(attachment),
|
||||
raw: attachment,
|
||||
size: attachment.totalBytes,
|
||||
type: attachmentType(attachment.mimeType),
|
||||
url: '',
|
||||
})) as Attachment[];
|
||||
}
|
||||
|
||||
function dateFromBlueBubbles(timestamp: number | null | undefined): Date {
|
||||
if (!timestamp) return new Date();
|
||||
return new Date(timestamp);
|
||||
}
|
||||
|
||||
function isDirectChat(chat: BlueBubblesChat | undefined): boolean {
|
||||
if (!chat) return false;
|
||||
if (typeof chat.style === 'number') return chat.style !== 43;
|
||||
if (Array.isArray(chat.participants)) return chat.participants.length <= 1;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function encodeImessageThreadId(data: ImessageThreadId): string {
|
||||
return `imessage:${data.chatGuid}`;
|
||||
}
|
||||
|
||||
export function decodeImessageThreadId(threadId: string): ImessageThreadId {
|
||||
if (threadId.startsWith('imessage:')) {
|
||||
return { chatGuid: threadId.slice('imessage:'.length) };
|
||||
}
|
||||
return { chatGuid: threadId };
|
||||
}
|
||||
|
||||
export class ImessageAdapter implements Adapter<ImessageThreadId, BlueBubblesMessage> {
|
||||
readonly name = 'imessage';
|
||||
|
||||
private readonly api?: BlueBubblesApiClient;
|
||||
private readonly botId: string;
|
||||
private readonly formatConverter: ImessageFormatConverter;
|
||||
private readonly knownDmThreads = new Map<string, boolean>();
|
||||
private readonly transport?: ImessageBridgeTransport;
|
||||
private readonly webhookSecret: string;
|
||||
|
||||
private _userName: string;
|
||||
private chat!: ChatInstance;
|
||||
private logger!: Logger;
|
||||
|
||||
constructor(config: ImessageAdapterConfig) {
|
||||
if (!config.webhookSecret?.trim()) throw new Error('iMessage adapter requires webhookSecret');
|
||||
|
||||
if (config.transport) {
|
||||
this.transport = config.transport;
|
||||
} else {
|
||||
if (!config.serverUrl?.trim()) throw new Error('iMessage adapter requires serverUrl');
|
||||
if (!config.password?.trim()) throw new Error('iMessage adapter requires password');
|
||||
this.api = new BlueBubblesApiClient({
|
||||
password: config.password,
|
||||
requestTimeoutMs: config.requestTimeoutMs,
|
||||
serverUrl: config.serverUrl,
|
||||
});
|
||||
}
|
||||
this.webhookSecret = config.webhookSecret;
|
||||
this.botId = config.botUserId || 'imessage:self';
|
||||
this._userName = config.userName || 'imessage-bot';
|
||||
this.formatConverter = new ImessageFormatConverter();
|
||||
}
|
||||
|
||||
get botUserId(): string {
|
||||
return this.botId;
|
||||
}
|
||||
|
||||
get userName(): string {
|
||||
return this._userName;
|
||||
}
|
||||
|
||||
async initialize(chat: ChatInstance): Promise<void> {
|
||||
this.chat = chat;
|
||||
this.logger = chat.getLogger(this.name);
|
||||
this._userName = chat.getUserName();
|
||||
this.logger.info(
|
||||
this.transport
|
||||
? 'Initialized iMessage adapter via Desktop BlueBubbles bridge'
|
||||
: 'Initialized iMessage adapter via BlueBubbles',
|
||||
);
|
||||
}
|
||||
|
||||
async handleWebhook(request: Request, options?: WebhookOptions): Promise<Response> {
|
||||
if (request.method !== 'POST') {
|
||||
return new Response('Method not allowed', { status: 405 });
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
if (url.searchParams.get('secret') !== this.webhookSecret) {
|
||||
this.logger.warn('Rejected iMessage webhook with invalid secret');
|
||||
return new Response('Invalid secret', { status: 401 });
|
||||
}
|
||||
|
||||
let event: BlueBubblesWebhookEvent;
|
||||
try {
|
||||
event = (await request.json()) as BlueBubblesWebhookEvent;
|
||||
} catch {
|
||||
return new Response('Invalid JSON', { status: 400 });
|
||||
}
|
||||
|
||||
if (event.type !== NEW_MESSAGE_EVENT) {
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
|
||||
const message = await this.resolveWebhookMessage(event.data);
|
||||
if (!message?.guid) {
|
||||
this.logger.warn('Ignored iMessage webhook without message guid');
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
|
||||
if (message.isFromMe) {
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
|
||||
const chat = message.chats?.[0];
|
||||
const chatGuid = chat?.guid;
|
||||
if (!chatGuid) {
|
||||
this.logger.warn('Ignored iMessage webhook without chat guid for message=%s', message.guid);
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
|
||||
const threadId = this.encodeThreadId({ chatGuid });
|
||||
this.knownDmThreads.set(threadId, isDirectChat(chat));
|
||||
const messageFactory = async () => this.parseInbound(message, threadId);
|
||||
this.chat.processMessage(this, threadId, messageFactory, options);
|
||||
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
|
||||
async postMessage(
|
||||
threadId: string,
|
||||
message: AdapterPostableMessage,
|
||||
): Promise<RawMessage<BlueBubblesMessage>> {
|
||||
const { chatGuid } = this.decodeThreadId(threadId);
|
||||
const text = this.formatConverter.renderPostable(message);
|
||||
const raw = this.transport?.sendText
|
||||
? await this.transport.sendText(chatGuid, text)
|
||||
: await this.getApi().sendText(chatGuid, text);
|
||||
return {
|
||||
id: raw.guid || raw.tempGuid || `local_${Date.now()}`,
|
||||
raw,
|
||||
threadId,
|
||||
};
|
||||
}
|
||||
|
||||
async editMessage(
|
||||
threadId: string,
|
||||
_messageId: string,
|
||||
message: AdapterPostableMessage,
|
||||
): Promise<RawMessage<BlueBubblesMessage>> {
|
||||
return this.postMessage(threadId, message);
|
||||
}
|
||||
|
||||
async deleteMessage(_threadId: string, _messageId: string): Promise<void> {
|
||||
this.logger.warn('Message deletion not supported for iMessage via BlueBubbles');
|
||||
}
|
||||
|
||||
async fetchMessages(
|
||||
threadId: string,
|
||||
options?: FetchOptions,
|
||||
): Promise<FetchResult<BlueBubblesMessage>> {
|
||||
const { chatGuid } = this.decodeThreadId(threadId);
|
||||
const result = this.transport?.getChatMessages
|
||||
? await this.transport.getChatMessages(chatGuid, {
|
||||
limit: options?.limit,
|
||||
sort: 'DESC',
|
||||
withParts: ['attachments'],
|
||||
})
|
||||
: await this.getApi().getChatMessages(chatGuid, {
|
||||
limit: options?.limit,
|
||||
sort: 'DESC',
|
||||
withParts: ['attachments'],
|
||||
});
|
||||
return {
|
||||
messages: result.data.map((raw) => this.parseInbound(raw, threadId)).reverse(),
|
||||
nextCursor: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async fetchThread(threadId: string): Promise<ThreadInfo> {
|
||||
const { chatGuid } = this.decodeThreadId(threadId);
|
||||
try {
|
||||
const chat = this.transport?.getChat
|
||||
? await this.transport.getChat(chatGuid, ['participants'])
|
||||
: await this.getApi().getChat(chatGuid, ['participants']);
|
||||
const isDM = isDirectChat(chat);
|
||||
this.knownDmThreads.set(threadId, isDM);
|
||||
return {
|
||||
channelId: threadId,
|
||||
channelName: chat.displayName || chat.chatIdentifier,
|
||||
id: threadId,
|
||||
isDM,
|
||||
metadata: chat as unknown as Record<string, unknown>,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.warn('fetchThread failed for %s: %s', threadId, error);
|
||||
return {
|
||||
channelId: threadId,
|
||||
id: threadId,
|
||||
isDM: this.knownDmThreads.get(threadId) ?? false,
|
||||
metadata: { chatGuid },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async addReaction(
|
||||
_threadId: string,
|
||||
_messageId: string,
|
||||
_emoji: EmojiValue | string,
|
||||
): Promise<void> {}
|
||||
|
||||
async removeReaction(
|
||||
_threadId: string,
|
||||
_messageId: string,
|
||||
_emoji: EmojiValue | string,
|
||||
): Promise<void> {}
|
||||
|
||||
async startTyping(threadId: string): Promise<void> {
|
||||
const { chatGuid } = this.decodeThreadId(threadId);
|
||||
try {
|
||||
if (this.transport?.startTyping) {
|
||||
await this.transport.startTyping(chatGuid);
|
||||
} else {
|
||||
await this.getApi().startTyping(chatGuid);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn('startTyping failed for %s: %s', threadId, error);
|
||||
}
|
||||
}
|
||||
|
||||
parseMessage(raw: BlueBubblesMessage, threadId?: string): Message<BlueBubblesMessage> {
|
||||
return this.parseInbound(
|
||||
raw,
|
||||
threadId ?? this.encodeThreadId({ chatGuid: raw.chats?.[0]?.guid ?? this.botId }),
|
||||
);
|
||||
}
|
||||
|
||||
encodeThreadId(data: ImessageThreadId): string {
|
||||
return encodeImessageThreadId(data);
|
||||
}
|
||||
|
||||
decodeThreadId(threadId: string): ImessageThreadId {
|
||||
return decodeImessageThreadId(threadId);
|
||||
}
|
||||
|
||||
channelIdFromThreadId(threadId: string): string {
|
||||
return threadId;
|
||||
}
|
||||
|
||||
isDM(threadId: string): boolean {
|
||||
return this.knownDmThreads.get(threadId) ?? false;
|
||||
}
|
||||
|
||||
renderFormatted(content: FormattedContent): string {
|
||||
return this.formatConverter.fromAst(content);
|
||||
}
|
||||
|
||||
private async resolveWebhookMessage(
|
||||
message: BlueBubblesMessage | undefined,
|
||||
): Promise<BlueBubblesMessage | undefined> {
|
||||
if (!message?.guid) return message;
|
||||
if (message.chats?.[0]?.guid) return message;
|
||||
|
||||
if (!this.api) {
|
||||
this.logger.warn(
|
||||
'iMessage bridge webhook message=%s did not include chat data; configure Desktop bridge enrichment',
|
||||
message.guid,
|
||||
);
|
||||
return message;
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.api.getMessage(message.guid, ['chats', 'attachments']);
|
||||
} catch (error) {
|
||||
this.logger.warn('Failed to enrich iMessage webhook message=%s: %s', message.guid, error);
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
private getApi(): BlueBubblesApiClient {
|
||||
if (!this.api) throw new Error('BlueBubbles API is not available in Desktop bridge mode');
|
||||
return this.api;
|
||||
}
|
||||
|
||||
private parseInbound(message: BlueBubblesMessage, threadId: string): Message<BlueBubblesMessage> {
|
||||
const text = extractText(message);
|
||||
const formatted = parseMarkdown(text);
|
||||
const userId = message.isFromMe ? this.botId : senderIdFromMessage(message);
|
||||
const author: Author = {
|
||||
fullName: userId,
|
||||
isBot: Boolean(message.isFromMe),
|
||||
isMe: Boolean(message.isFromMe),
|
||||
userId,
|
||||
userName: userId,
|
||||
};
|
||||
|
||||
return new Message({
|
||||
attachments: extractAttachmentMetadata(message),
|
||||
author,
|
||||
formatted,
|
||||
id: message.guid,
|
||||
metadata: {
|
||||
dateSent: dateFromBlueBubbles(message.dateCreated),
|
||||
edited: false,
|
||||
},
|
||||
raw: message,
|
||||
text,
|
||||
threadId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function createImessageAdapter(config: ImessageAdapterConfig): ImessageAdapter {
|
||||
return new ImessageAdapter(config);
|
||||
}
|
||||
|
||||
export function resolveAttachmentGuid(raw: BlueBubblesAttachment | undefined): string | undefined {
|
||||
return raw?.guid;
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import type {
|
||||
BlueBubblesApiConfig,
|
||||
BlueBubblesAttachment,
|
||||
BlueBubblesChat,
|
||||
BlueBubblesDownloadedAttachment,
|
||||
BlueBubblesMessage,
|
||||
BlueBubblesOutboundAttachment,
|
||||
BlueBubblesQueryResult,
|
||||
BlueBubblesResponse,
|
||||
BlueBubblesSendOptions,
|
||||
BlueBubblesWebhook,
|
||||
} from './types';
|
||||
|
||||
const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
|
||||
|
||||
interface RequestOptions {
|
||||
body?: FormData | Record<string, unknown>;
|
||||
method?: 'DELETE' | 'GET' | 'POST';
|
||||
query?: Record<string, boolean | number | string | undefined>;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export class BlueBubblesApiClient {
|
||||
readonly password: string;
|
||||
readonly requestTimeoutMs: number;
|
||||
readonly serverUrl: string;
|
||||
|
||||
constructor(options: BlueBubblesApiConfig) {
|
||||
if (!options.serverUrl?.trim()) throw new Error('BlueBubbles serverUrl is required');
|
||||
if (!options.password?.trim()) throw new Error('BlueBubbles password is required');
|
||||
|
||||
this.serverUrl = stripTrailingSlashes(options.serverUrl.trim());
|
||||
this.password = options.password;
|
||||
this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
async ping(): Promise<void> {
|
||||
await this.requestData<Record<string, unknown>>('ping');
|
||||
}
|
||||
|
||||
async getMessage(
|
||||
guid: string,
|
||||
withParts: string[] = ['chats', 'attachments'],
|
||||
): Promise<BlueBubblesMessage> {
|
||||
return this.requestData<BlueBubblesMessage>(`message/${encodeURIComponent(guid)}`, {
|
||||
query: { with: withParts.join(',') },
|
||||
});
|
||||
}
|
||||
|
||||
async getChat(guid: string, withParts: string[] = ['participants']): Promise<BlueBubblesChat> {
|
||||
return this.requestData<BlueBubblesChat>(`chat/${encodeURIComponent(guid)}`, {
|
||||
query: { with: withParts.join(',') },
|
||||
});
|
||||
}
|
||||
|
||||
async getChatMessages(
|
||||
chatGuid: string,
|
||||
options: {
|
||||
after?: number | string;
|
||||
before?: number | string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
sort?: 'ASC' | 'DESC';
|
||||
withParts?: string[];
|
||||
} = {},
|
||||
): Promise<BlueBubblesQueryResult<BlueBubblesMessage>> {
|
||||
const response = await this.request<BlueBubblesMessage[]>(
|
||||
`chat/${encodeURIComponent(chatGuid)}/message`,
|
||||
{
|
||||
query: {
|
||||
after: options.after,
|
||||
before: options.before,
|
||||
limit: options.limit,
|
||||
offset: options.offset,
|
||||
sort: options.sort,
|
||||
with: (options.withParts ?? ['attachments']).join(','),
|
||||
},
|
||||
},
|
||||
);
|
||||
return { data: response.data ?? [], metadata: response.metadata };
|
||||
}
|
||||
|
||||
async queryMessages(
|
||||
body: Record<string, unknown>,
|
||||
): Promise<BlueBubblesQueryResult<BlueBubblesMessage>> {
|
||||
const response = await this.request<BlueBubblesMessage[]>('message/query', {
|
||||
body,
|
||||
method: 'POST',
|
||||
});
|
||||
return { data: response.data ?? [], metadata: response.metadata };
|
||||
}
|
||||
|
||||
async queryChats(
|
||||
body: Record<string, unknown>,
|
||||
): Promise<BlueBubblesQueryResult<BlueBubblesChat>> {
|
||||
const response = await this.request<BlueBubblesChat[]>('chat/query', {
|
||||
body,
|
||||
method: 'POST',
|
||||
});
|
||||
return { data: response.data ?? [], metadata: response.metadata };
|
||||
}
|
||||
|
||||
async registerWebhook(
|
||||
url: string,
|
||||
events: string[] = ['new-message'],
|
||||
): Promise<BlueBubblesWebhook> {
|
||||
return this.requestData<BlueBubblesWebhook>('webhook', {
|
||||
body: { events, url },
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
async listWebhooks(url?: string): Promise<BlueBubblesWebhook[]> {
|
||||
const response = await this.request<BlueBubblesWebhook[]>('webhook', {
|
||||
query: { url },
|
||||
});
|
||||
return response.data ?? [];
|
||||
}
|
||||
|
||||
async sendText(
|
||||
chatGuid: string,
|
||||
message: string,
|
||||
options: BlueBubblesSendOptions = {},
|
||||
): Promise<BlueBubblesMessage> {
|
||||
return this.requestData<BlueBubblesMessage>('message/text', {
|
||||
body: {
|
||||
chatGuid,
|
||||
message,
|
||||
method: options.method ?? 'apple-script',
|
||||
tempGuid: options.tempGuid ?? randomUUID(),
|
||||
},
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
async sendAttachment(
|
||||
chatGuid: string,
|
||||
attachment: BlueBubblesOutboundAttachment,
|
||||
options: BlueBubblesSendOptions = {},
|
||||
): Promise<BlueBubblesMessage> {
|
||||
const { buffer, mimeType } = await resolveAttachmentBytes(attachment, this.requestTimeoutMs);
|
||||
const name = attachment.name || inferFileName(mimeType || attachment.mimeType);
|
||||
const form = new FormData();
|
||||
const attachmentBytes = buffer.buffer.slice(
|
||||
buffer.byteOffset,
|
||||
buffer.byteOffset + buffer.byteLength,
|
||||
) as ArrayBuffer;
|
||||
form.set('chatGuid', chatGuid);
|
||||
form.set('tempGuid', options.tempGuid ?? randomUUID());
|
||||
form.set('method', options.method ?? 'apple-script');
|
||||
form.set('name', name);
|
||||
form.set(
|
||||
'attachment',
|
||||
new Blob([attachmentBytes], { type: mimeType ?? attachment.mimeType }),
|
||||
name,
|
||||
);
|
||||
|
||||
return this.requestData<BlueBubblesMessage>('message/attachment', {
|
||||
body: form,
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
async startTyping(chatGuid: string): Promise<void> {
|
||||
await this.requestData<Record<string, unknown>>(`chat/${encodeURIComponent(chatGuid)}/typing`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
async stopTyping(chatGuid: string): Promise<void> {
|
||||
await this.requestData<Record<string, unknown>>(`chat/${encodeURIComponent(chatGuid)}/typing`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
async downloadAttachment(guid: string): Promise<BlueBubblesDownloadedAttachment> {
|
||||
const url = this.buildUrl(`attachment/${encodeURIComponent(guid)}/download`, {
|
||||
original: true,
|
||||
});
|
||||
const response = await fetchWithTimeout(url, { method: 'GET' }, this.requestTimeoutMs);
|
||||
if (!response.ok) {
|
||||
const detail = await safeReadError(response);
|
||||
throw new Error(detail || `downloadAttachment ${guid} failed with HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return {
|
||||
buffer: Buffer.from(await response.arrayBuffer()),
|
||||
mimeType: response.headers.get('content-type') ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private async requestData<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
const response = await this.request<T>(path, options);
|
||||
return (response.data ?? ({} as T)) as T;
|
||||
}
|
||||
|
||||
private async request<T>(
|
||||
path: string,
|
||||
{ method = 'GET', body, query, signal }: RequestOptions = {},
|
||||
): Promise<BlueBubblesResponse<T>> {
|
||||
const url = this.buildUrl(path, query);
|
||||
const init: RequestInit = { method, signal };
|
||||
|
||||
if (body instanceof FormData) {
|
||||
init.body = body;
|
||||
} else if (body) {
|
||||
init.body = JSON.stringify(body);
|
||||
init.headers = { 'Content-Type': 'application/json' };
|
||||
}
|
||||
|
||||
const response = await fetchWithTimeout(url, init, this.requestTimeoutMs);
|
||||
return parseResponse<T>(response, path);
|
||||
}
|
||||
|
||||
private buildUrl(
|
||||
path: string,
|
||||
query?: Record<string, boolean | number | string | undefined>,
|
||||
): string {
|
||||
const url = new URL(path.replace(/^\/+/, ''), `${this.serverUrl}/api/v1/`);
|
||||
url.searchParams.set('password', this.password);
|
||||
for (const [key, value] of Object.entries(query ?? {})) {
|
||||
if (value === undefined) continue;
|
||||
url.searchParams.set(key, String(value));
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
}
|
||||
|
||||
async function parseResponse<T>(
|
||||
response: Response,
|
||||
label: string,
|
||||
): Promise<BlueBubblesResponse<T>> {
|
||||
const text = await response.text();
|
||||
const payload = parseJson<BlueBubblesResponse<T>>(text);
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = readBlueBubblesError(payload) ?? text;
|
||||
throw new Error(detail || `${label} failed with HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return payload ?? {};
|
||||
}
|
||||
|
||||
async function safeReadError(response: Response): Promise<string | undefined> {
|
||||
const text = await response.text();
|
||||
const payload = parseJson<BlueBubblesResponse>(text);
|
||||
return readBlueBubblesError(payload) ?? (text || undefined);
|
||||
}
|
||||
|
||||
function readBlueBubblesError(payload: BlueBubblesResponse | undefined): string | undefined {
|
||||
if (!payload) return undefined;
|
||||
const data = payload.data as { error?: unknown; message?: unknown } | undefined;
|
||||
if (typeof data?.error === 'string') return data.error;
|
||||
if (typeof data?.message === 'string') return data.message;
|
||||
return payload.message;
|
||||
}
|
||||
|
||||
function parseJson<T>(text: string): T | undefined {
|
||||
if (!text) return undefined;
|
||||
try {
|
||||
return JSON.parse(text) as T;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
timeoutMs: number,
|
||||
): Promise<Response> {
|
||||
const abort = new AbortController();
|
||||
const timer = setTimeout(() => abort.abort(), timeoutMs);
|
||||
const signal = init.signal ? AbortSignal.any([init.signal, abort.signal]) : abort.signal;
|
||||
|
||||
try {
|
||||
return await fetch(url, { ...init, signal });
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function stripTrailingSlashes(url: string): string {
|
||||
let end = url.length;
|
||||
while (end > 0 && url[end - 1] === '/') end--;
|
||||
return url.slice(0, end);
|
||||
}
|
||||
|
||||
async function resolveAttachmentBytes(
|
||||
attachment: BlueBubblesOutboundAttachment,
|
||||
requestTimeoutMs: number,
|
||||
): Promise<{ buffer: Buffer; mimeType?: string }> {
|
||||
if (attachment.data) {
|
||||
return { buffer: Buffer.from(attachment.data, 'base64'), mimeType: attachment.mimeType };
|
||||
}
|
||||
|
||||
if (!attachment.fetchUrl) {
|
||||
throw new Error('BlueBubbles attachment requires either data or fetchUrl');
|
||||
}
|
||||
|
||||
const response = await fetchWithTimeout(attachment.fetchUrl, { method: 'GET' }, requestTimeoutMs);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch attachment ${attachment.fetchUrl}: HTTP ${response.status}`);
|
||||
}
|
||||
return {
|
||||
buffer: Buffer.from(await response.arrayBuffer()),
|
||||
mimeType: response.headers.get('content-type') ?? attachment.mimeType,
|
||||
};
|
||||
}
|
||||
|
||||
function inferFileName(mimeType: string | undefined): string {
|
||||
if (!mimeType) return 'attachment.bin';
|
||||
const [topLevel, subtype] = mimeType.split('/');
|
||||
if (!subtype) return 'attachment.bin';
|
||||
if (topLevel === 'image') return `image.${subtype}`;
|
||||
if (topLevel === 'video') return `video.${subtype}`;
|
||||
if (topLevel === 'audio') return `audio.${subtype}`;
|
||||
return `attachment.${subtype}`;
|
||||
}
|
||||
|
||||
export function resolveAttachmentName(attachment: BlueBubblesAttachment): string {
|
||||
return attachment.transferName || attachment.filename || `${attachment.guid}.bin`;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Root } from 'chat';
|
||||
import { BaseFormatConverter, parseMarkdown, stringifyMarkdown } from 'chat';
|
||||
|
||||
/**
|
||||
* iMessage ultimately receives plain text through BlueBubbles. Keeping the
|
||||
* markdown markers here preserves Chat SDK compatibility; the LobeHub platform
|
||||
* client strips markdown before final bot replies are sent.
|
||||
*/
|
||||
export class ImessageFormatConverter extends BaseFormatConverter {
|
||||
fromAst(ast: Root): string {
|
||||
return stringifyMarkdown(ast);
|
||||
}
|
||||
|
||||
toAst(text: string): Root {
|
||||
return parseMarkdown(text.trim());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
export {
|
||||
createImessageAdapter,
|
||||
decodeImessageThreadId,
|
||||
encodeImessageThreadId,
|
||||
extractAttachmentMetadata,
|
||||
ImessageAdapter,
|
||||
resolveAttachmentGuid,
|
||||
} from './adapter';
|
||||
export { BlueBubblesApiClient, resolveAttachmentName } from './api';
|
||||
export { ImessageFormatConverter } from './format-converter';
|
||||
export type {
|
||||
BlueBubblesApiConfig,
|
||||
BlueBubblesAttachment,
|
||||
BlueBubblesChat,
|
||||
BlueBubblesDownloadedAttachment,
|
||||
BlueBubblesHandle,
|
||||
BlueBubblesMessage,
|
||||
BlueBubblesOutboundAttachment,
|
||||
BlueBubblesQueryResult,
|
||||
BlueBubblesResponse,
|
||||
BlueBubblesSendOptions,
|
||||
BlueBubblesWebhook,
|
||||
BlueBubblesWebhookEvent,
|
||||
ImessageAdapterConfig,
|
||||
ImessageBridgeTransport,
|
||||
ImessageThreadId,
|
||||
} from './types';
|
||||
@@ -0,0 +1,137 @@
|
||||
export interface BlueBubblesApiConfig {
|
||||
/**
|
||||
* BlueBubbles API password. The server accepts it as the `password` query
|
||||
* parameter for REST calls.
|
||||
*/
|
||||
password: string;
|
||||
requestTimeoutMs?: number;
|
||||
/**
|
||||
* Public base URL of the BlueBubbles server, e.g. `https://mac.example.com`.
|
||||
*/
|
||||
serverUrl: string;
|
||||
}
|
||||
|
||||
export interface ImessageBridgeTransport {
|
||||
getChat?: (guid: string, withParts?: string[]) => Promise<BlueBubblesChat>;
|
||||
getChatMessages?: (
|
||||
chatGuid: string,
|
||||
options?: {
|
||||
after?: number | string;
|
||||
before?: number | string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
sort?: 'ASC' | 'DESC';
|
||||
withParts?: string[];
|
||||
},
|
||||
) => Promise<BlueBubblesQueryResult<BlueBubblesMessage>>;
|
||||
sendText?: (
|
||||
chatGuid: string,
|
||||
message: string,
|
||||
options?: BlueBubblesSendOptions,
|
||||
) => Promise<BlueBubblesMessage>;
|
||||
startTyping?: (chatGuid: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface ImessageAdapterConfig {
|
||||
botUserId?: string;
|
||||
password?: string;
|
||||
requestTimeoutMs?: number;
|
||||
serverUrl?: string;
|
||||
transport?: ImessageBridgeTransport;
|
||||
userName?: string;
|
||||
/**
|
||||
* Shared secret appended to the LobeHub webhook URL. BlueBubbles webhooks are
|
||||
* not signed, so the route-level secret is the lightweight authenticity gate.
|
||||
*/
|
||||
webhookSecret: string;
|
||||
}
|
||||
|
||||
export interface BlueBubblesResponse<T = unknown> {
|
||||
data?: T;
|
||||
message?: string;
|
||||
metadata?: {
|
||||
count?: number;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
total?: number;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
status?: number;
|
||||
}
|
||||
|
||||
export interface BlueBubblesHandle {
|
||||
address?: string;
|
||||
country?: string;
|
||||
guid?: string;
|
||||
service?: string;
|
||||
uncanonicalizedId?: string;
|
||||
}
|
||||
|
||||
export interface BlueBubblesChat {
|
||||
chatIdentifier?: string;
|
||||
displayName?: string;
|
||||
guid: string;
|
||||
lastMessage?: BlueBubblesMessage;
|
||||
participants?: BlueBubblesHandle[];
|
||||
serviceName?: string;
|
||||
style?: number;
|
||||
}
|
||||
|
||||
export interface BlueBubblesAttachment {
|
||||
filename?: string;
|
||||
guid: string;
|
||||
mimeType?: string;
|
||||
totalBytes?: number;
|
||||
transferName?: string;
|
||||
}
|
||||
|
||||
export interface BlueBubblesMessage {
|
||||
attachments?: BlueBubblesAttachment[];
|
||||
chats?: BlueBubblesChat[];
|
||||
dateCreated?: number | null;
|
||||
guid: string;
|
||||
handle?: BlueBubblesHandle | null;
|
||||
handleId?: number | string | null;
|
||||
isFromMe?: boolean;
|
||||
otherHandle?: number | string | null;
|
||||
subject?: string | null;
|
||||
tempGuid?: string;
|
||||
text?: string | null;
|
||||
}
|
||||
|
||||
export interface BlueBubblesWebhookEvent {
|
||||
data?: BlueBubblesMessage;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface BlueBubblesWebhook {
|
||||
events: string[];
|
||||
id: number;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface BlueBubblesQueryResult<T> {
|
||||
data: T[];
|
||||
metadata?: BlueBubblesResponse<T[]>['metadata'];
|
||||
}
|
||||
|
||||
export interface BlueBubblesSendOptions {
|
||||
method?: 'apple-script' | 'private-api';
|
||||
tempGuid?: string;
|
||||
}
|
||||
|
||||
export interface BlueBubblesOutboundAttachment {
|
||||
data?: string;
|
||||
fetchUrl?: string;
|
||||
mimeType?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface BlueBubblesDownloadedAttachment {
|
||||
buffer: Buffer;
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
export interface ImessageThreadId {
|
||||
chatGuid: string;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"lib": ["ES2022"]
|
||||
},
|
||||
"exclude": ["node_modules", "dist"],
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'tsup';
|
||||
|
||||
export default defineConfig({
|
||||
dts: true,
|
||||
entry: ['src/index.ts'],
|
||||
format: ['esm'],
|
||||
sourcemap: true,
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
coverage: {
|
||||
all: false,
|
||||
},
|
||||
environment: 'node',
|
||||
},
|
||||
});
|
||||
@@ -22,6 +22,6 @@
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"tsup": "^8.3.5",
|
||||
"typescript": "^5.7.2"
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,6 @@
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"tsup": "^8.3.5",
|
||||
"typescript": "^5.7.2"
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user