mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-15 12:10:16 +00:00
Compare commits
89 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 694a25822f | |||
| 46818e9571 | |||
| e5666882d4 | |||
| 469a8e6661 | |||
| 7798e4b0b5 | |||
| 654035e7b0 | |||
| eb39f193c9 | |||
| 7e514ac3e3 | |||
| f3f2bda880 | |||
| 6434ee9a5d | |||
| b52ff52949 | |||
| 4766bb3eb3 | |||
| 7ab111fcc5 | |||
| 6281ca4228 | |||
| 73fa3b1689 | |||
| 04e9f7fcea | |||
| 1cc92db5e2 | |||
| 2d088ca6e2 | |||
| 43b0b5e854 | |||
| 0e46085176 | |||
| e50e6859e7 | |||
| 70097ad315 | |||
| 929d23a94e | |||
| ad75e25443 | |||
| 93492382ca | |||
| 4ea80c2915 | |||
| f94f941fe8 | |||
| fbc42b725e | |||
| f94e4f46a4 | |||
| 6478c6012f | |||
| ff259bdc51 | |||
| 7b61b9526f | |||
| 8c4fbf4a81 | |||
| d91132c155 | |||
| b8a03bdc08 | |||
| 8385a7c447 | |||
| c814c566d4 | |||
| 5e03311d21 | |||
| 03f99bfeeb | |||
| 224079b420 | |||
| 081a0886aa | |||
| d9eba30519 | |||
| a47d29b0bb | |||
| 3864a1eaab | |||
| 8ca3f9a372 | |||
| a2d91b205e | |||
| a35c55c57b | |||
| 625cf80b84 | |||
| d02df7b897 | |||
| 19b11f05be | |||
| 59d2915bf9 | |||
| 17506e30ee | |||
| 1a48642a2d | |||
| 205b9de5c6 | |||
| 20a631a637 | |||
| ba6980ffe9 | |||
| 55b4842f00 | |||
| 6e6970f1b2 | |||
| da7e18281d | |||
| 7083ab4ef5 | |||
| 3dae46911b | |||
| 36d0994ec2 | |||
| 516c04797d | |||
| f3cf7f4aed | |||
| df8111aca0 | |||
| 566b261a12 | |||
| e00c299d1c | |||
| e0d20e86fc | |||
| b5871d327a | |||
| 875c9b49eb | |||
| 1914ae6d43 | |||
| ffd66d5465 | |||
| d00770a956 | |||
| 20267fc77c | |||
| 4630785870 | |||
| 5b7611615e | |||
| ec547a3b57 | |||
| 36c4be46f0 | |||
| 7b136a210f | |||
| 9075d5dfd3 | |||
| 1c429f8d28 | |||
| ac250b9897 | |||
| e8b7fe14e1 | |||
| 79cf5febed | |||
| 4b6b341951 | |||
| 44892960e0 | |||
| dc86f38dc1 | |||
| 3e43683132 | |||
| b125565597 |
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: add-provider-doc
|
||||
description: Guide for adding new AI provider documentation. Use when adding documentation for a new AI provider (like OpenAI, Anthropic, etc.), including usage docs, environment variables, Docker config, and image resources. Triggers on provider documentation tasks.
|
||||
description: Add documentation for a new AI provider — usage docs, env vars, Docker config, image resources.
|
||||
disable-model-invocation: true
|
||||
argument-hint: '[provider-name]'
|
||||
---
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: add-setting-env
|
||||
description: Guide for adding environment variables to configure user settings. Use when implementing server-side environment variables that control default values for user settings. Triggers on env var configuration or setting default value tasks.
|
||||
description: Add server-side environment variables that control default values for user settings.
|
||||
disable-model-invocation: true
|
||||
argument-hint: '[setting-name]'
|
||||
---
|
||||
|
||||
@@ -18,6 +18,27 @@ The two reference tools to read end-to-end:
|
||||
|
||||
---
|
||||
|
||||
## Tool Render 设计原则(中文草案)
|
||||
|
||||
这些原则用于判断一个 builtin tool 的 Inspector / Render / Placeholder / Streaming / Intervention / Portal 应该做什么,以及做到什么程度。
|
||||
|
||||
1. **先保证折叠态可读。** 每个 API 都必须有 Inspector;用户不展开也应该能看懂 “正在做什么 / 对什么做 / 当前结果是什么”。Inspector 不应该只展示函数名和原始参数。
|
||||
2. **Inspector 是一句话,不是详情页。** 优先表达动作、关键对象、数量、状态,例如 “分析图片 3 张”“搜索 12 个结果”“读取 config.json”。长文本、列表和结构化结果放到 Render 或 Portal。
|
||||
3. **Inspector 要覆盖执行生命周期。** `args` 还在 streaming、工具执行中、执行完成、执行失败时都应该有稳定展示;必要时同时读取 `args`、`partialArgs` 和 `pluginState`,避免出现空白、跳变或只显示半截参数。
|
||||
4. **只有结构化结果才需要 Render。** 如果工具结果只是自然语言总结,通常不需要 Render;如果结果包含列表、媒体、文件、表格、代码、diff、地图、时间线、权限请求等结构,就应该提供 Render。
|
||||
5. **Render 要帮助用户检查结果,而不是复述参数。** Render 的主体应该围绕工具产物组织:可预览、可比较、可筛选、可定位。参数只作为上下文辅助出现,不要把 Render 做成一块更大的 args dump。
|
||||
6. **参数和结果要一起参与渲染。** 好的 Tool UI 通常同时用 `args` 解释意图,用 `pluginState` 展示真实执行结果;但 `pluginState` 只放结果域数据,不要反向塞入可以从 `args` 推导出的内容。
|
||||
7. **慢操作要有 Placeholder。** 如果工具通常需要等待网络、文件系统、模型或外部进程,Placeholder 应该先占住最终 Render 的版式,让用户知道即将看到什么,而不是只显示一个泛化 loading。
|
||||
8. **Streaming 只用于连续产物。** 搜索列表、日志、长文本、文件分析、分阶段计划适合 Streaming;一次性小结果不需要强行做 Streaming。Streaming UI 要能渐进追加,并且完成后自然过渡到最终 Render。
|
||||
9. **有风险的动作必须 Intervention。** 写文件、删除、发送、安装、执行命令、外部可见操作、权限敏感操作,都应该在执行前给出可理解的确认界面;确认文案要说明影响范围,而不是只问 “是否继续”。
|
||||
10. **错误、空态和截断都是正式状态。** Render 不能在失败、无结果、超长结果时退化成空白。错误要说明发生在哪一步;空态要告诉用户没有产物;超长内容要明确 “展示前 N 项 / 还有 N 项”。
|
||||
11. **信息密度要克制。** 默认展示最有判断价值的部分:标题、来源、状态、摘要、少量关键字段。大对象、长列表、原文、调试数据放进可展开区域或 Portal,避免把聊天流撑成后台管理页。
|
||||
12. **视觉上融入聊天流。** Tool UI 应该使用 `@lobehub/ui` / base-ui、`Flexbox`、`createStaticStyles` 和 `cssVar.*`,遵循现有间距、圆角、颜色、字号;不要为单个工具发明一套独立视觉语言。
|
||||
13. **Devtools fixture 是验收入口。** 新增或修改 Tool UI 时,应在 `/devtools` 里准备覆盖典型态、loading/streaming、空态、错误态、长内容态的 fixture;一个 API 如果在真实聊天里会出现,就不应该在 devtools 中缺席。
|
||||
14. **先做用户会看的 UI,再做调试 UI。** Raw JSON、trace、schema、内部 id 可以存在,但应默认收起或放到调试区;主界面先回答用户最关心的问题:工具做了什么,结果值不值得信任,下一步能做什么。
|
||||
|
||||
---
|
||||
|
||||
## 0. Shared Style Rules
|
||||
|
||||
These apply across every surface.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: cli
|
||||
description: LobeHub CLI (@lobehub/cli) development guide. Use when working on CLI commands, adding new subcommands, fixing CLI bugs, or understanding CLI architecture. Triggers on CLI development, command implementation, or `lh` command questions.
|
||||
description: LobeHub CLI (@lobehub/cli) development guide — commands, subcommands, architecture.
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: desktop
|
||||
description: Electron desktop development guide. Use when implementing desktop features, IPC handlers, controllers, preload scripts, window management, menu configuration, or Electron-specific functionality. Triggers on desktop app development, Electron IPC, or desktop local tools implementation.
|
||||
description: Electron desktop development guide — IPC handlers, controllers, preload scripts, window/menu management.
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
|
||||
@@ -6,6 +6,10 @@ user-invocable: false
|
||||
|
||||
# LobeHub Project Overview
|
||||
|
||||
> The directory listings below are a **curated map of key locations**, not an
|
||||
> exhaustive tree. `packages/`, `src/store/`, route groups etc. grow over time —
|
||||
> run `ls` against the real directory for the current set.
|
||||
|
||||
## Project Description
|
||||
|
||||
Open-source, modern-design AI Agent Workspace: **LobeHub** (previously LobeChat).
|
||||
@@ -14,7 +18,7 @@ Open-source, modern-design AI Agent Workspace: **LobeHub** (previously LobeChat)
|
||||
|
||||
- Web desktop/mobile
|
||||
- Desktop (Electron)
|
||||
- Mobile app (React Native) - coming soon
|
||||
- Mobile app (React Native) — **separate repo, already launched** (not in this monorepo)
|
||||
|
||||
**Logo emoji:** 🤯
|
||||
|
||||
@@ -39,147 +43,92 @@ Open-source, modern-design AI Agent Workspace: **LobeHub** (previously LobeChat)
|
||||
| Database | Neon PostgreSQL + Drizzle ORM |
|
||||
| Testing | Vitest |
|
||||
|
||||
## Complete Project Structure
|
||||
> Exact versions live in the root `package.json` — check there, not here.
|
||||
|
||||
Monorepo using `@lobechat/` namespace for workspace packages.
|
||||
## 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
|
||||
|
||||
```
|
||||
lobehub/
|
||||
├── apps/
|
||||
│ └── desktop/ # Electron desktop app
|
||||
├── docs/
|
||||
│ ├── changelog/
|
||||
│ ├── development/
|
||||
│ ├── self-hosting/
|
||||
│ └── usage/
|
||||
├── locales/
|
||||
│ ├── en-US/
|
||||
│ └── zh-CN/
|
||||
├── packages/
|
||||
│ ├── agent-runtime/ # Agent runtime
|
||||
│ ├── builtin-agents/
|
||||
│ ├── builtin-tool-*/ # Builtin tool packages
|
||||
│ ├── business/ # Cloud-only business logic
|
||||
│ │ ├── config/
|
||||
│ │ ├── const/
|
||||
│ │ └── model-runtime/
|
||||
│ ├── config/
|
||||
│ ├── const/
|
||||
│ ├── 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
|
||||
│ ├── agent-signal/ # Agent Signal pipeline
|
||||
│ ├── builtin-tool-*/ # Builtin tool packages
|
||||
│ ├── builtin-tools/ # Builtin tool registries
|
||||
│ ├── context-engine/
|
||||
│ ├── conversation-flow/
|
||||
│ ├── database/
|
||||
│ │ └── src/
|
||||
│ │ ├── models/
|
||||
│ │ ├── schemas/
|
||||
│ │ └── repositories/
|
||||
│ ├── desktop-bridge/
|
||||
│ ├── edge-config/
|
||||
│ ├── editor-runtime/
|
||||
│ ├── electron-client-ipc/
|
||||
│ ├── electron-server-ipc/
|
||||
│ ├── fetch-sse/
|
||||
│ ├── file-loaders/
|
||||
│ ├── memory-user-memory/
|
||||
│ ├── model-bank/
|
||||
│ ├── model-runtime/
|
||||
│ │ └── src/
|
||||
│ │ ├── core/
|
||||
│ │ └── providers/
|
||||
│ ├── observability-otel/
|
||||
│ ├── prompts/
|
||||
│ ├── python-interpreter/
|
||||
│ ├── ssrf-safe-fetch/
|
||||
│ ├── types/
|
||||
│ ├── utils/
|
||||
│ └── web-crawler/
|
||||
├── src/
|
||||
│ ├── app/
|
||||
│ │ ├── (backend)/
|
||||
│ │ │ ├── api/
|
||||
│ │ │ ├── f/
|
||||
│ │ │ ├── market/
|
||||
│ │ │ ├── middleware/
|
||||
│ │ │ ├── oidc/
|
||||
│ │ │ ├── trpc/
|
||||
│ │ │ └── webapi/
|
||||
│ │ ├── spa/ # SPA HTML template service
|
||||
│ │ └── [variants]/
|
||||
│ │ └── (auth)/ # Auth pages (SSR required)
|
||||
│ ├── routes/ # SPA page components (Vite)
|
||||
│ │ ├── (main)/
|
||||
│ │ ├── (mobile)/
|
||||
│ │ ├── (desktop)/
|
||||
│ │ ├── onboarding/
|
||||
│ │ └── share/
|
||||
│ ├── spa/ # SPA entry points and router config
|
||||
│ │ ├── entry.web.tsx
|
||||
│ │ ├── entry.mobile.tsx
|
||||
│ │ ├── entry.desktop.tsx
|
||||
│ │ └── router/
|
||||
│ ├── business/ # Cloud-only (client/server)
|
||||
│ │ ├── client/
|
||||
│ │ ├── locales/
|
||||
│ │ └── server/
|
||||
│ ├── components/
|
||||
│ ├── config/
|
||||
│ ├── const/
|
||||
│ ├── envs/
|
||||
│ ├── features/
|
||||
│ ├── helpers/
|
||||
│ ├── hooks/
|
||||
│ ├── layout/
|
||||
│ │ ├── AuthProvider/
|
||||
│ │ └── GlobalProvider/
|
||||
│ ├── libs/
|
||||
│ │ ├── better-auth/
|
||||
│ │ ├── oidc-provider/
|
||||
│ │ └── trpc/
|
||||
│ ├── locales/
|
||||
│ │ └── default/
|
||||
│ ├── server/
|
||||
│ │ ├── featureFlags/
|
||||
│ │ ├── globalConfig/
|
||||
│ │ ├── modules/
|
||||
│ │ ├── routers/
|
||||
│ │ │ ├── async/
|
||||
│ │ │ ├── lambda/
|
||||
│ │ │ ├── mobile/
|
||||
│ │ │ └── tools/
|
||||
│ │ └── services/
|
||||
│ ├── services/
|
||||
│ ├── store/
|
||||
│ │ ├── agent/
|
||||
│ │ ├── chat/
|
||||
│ │ └── user/
|
||||
│ ├── styles/
|
||||
│ ├── tools/
|
||||
│ ├── database/ # src/{models,schemas,repositories}
|
||||
│ ├── model-bank/ # Model definitions & provider cards
|
||||
│ ├── model-runtime/ # src/{core,providers}
|
||||
│ ├── types/
|
||||
│ └── utils/
|
||||
└── e2e/ # E2E tests (Cucumber + Playwright)
|
||||
└── src/
|
||||
├── app/
|
||||
│ ├── (backend)/ # api, f, market, middleware, oidc, trpc, webapi
|
||||
│ ├── spa/ # SPA HTML template service
|
||||
│ └── [variants]/(auth)/ # Auth pages (SSR required)
|
||||
├── routes/ # SPA page segments (thin — delegate to features/)
|
||||
│ └── (main)/ (mobile)/ (desktop)/ (popup)/ onboarding/ share/
|
||||
├── spa/ # SPA entries + router config
|
||||
│ ├── entry.{web,mobile,desktop,popup}.tsx
|
||||
│ └── router/
|
||||
├── business/ # Open-source stubs (~50) overridden by cloud src/business/
|
||||
├── features/ # Domain business components
|
||||
├── store/ # ~28 zustand stores — `ls` for the full set
|
||||
├── server/ # featureFlags, globalConfig, modules, routers, services
|
||||
└── ... # 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/*` |
|
||||
| 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) |
|
||||
|
||||
## Data Flow
|
||||
|
||||
|
||||
@@ -1,95 +1,96 @@
|
||||
---
|
||||
name: react
|
||||
description: "LobeHub React/SPA component conventions: antd-style with `createStaticStyles` + `cssVar.*` (prefer zero-runtime over `createStyles` + `token`), `@lobehub/ui/base-ui` primitives before `@lobehub/ui` before antd, `Flexbox`/`Center` for layouts, react-router-dom navigation, and the `.desktop.tsx` sync rule. Use when writing or editing any `.tsx` under `src/**`, picking a styling helper, choosing a component (Select/Modal/Drawer/Button/Tooltip), wiring routes in `desktopRouter.config.tsx`/`.desktop.tsx`, or adding a `Link`/`useNavigate` call in the SPA. Triggers on `createStyles`/`createStaticStyles`, `cssVar`, `@lobehub/ui`, `antd-style`, `Flexbox`, `useNavigate`, `react-router-dom`, `Link`, 'new component', 'add a page', 'edit a layout', 'desktopRouter', 'componentMap.desktop'."
|
||||
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.'
|
||||
user-invocable: false
|
||||
---
|
||||
|
||||
# React Component Writing Guide
|
||||
|
||||
- Use antd-style for complex styles; for simple cases, use inline `style` attribute
|
||||
- **Prefer `createStaticStyles` with `cssVar.*`** (zero-runtime) — module-level, no hook call required
|
||||
- Only fall back to `createStyles` + `token` when styles genuinely need runtime computation (dynamic props, JS color fns like `readableColor`/`chroma`)
|
||||
- See `.cursor/docs/createStaticStyles_migration_guide.md` for full pattern
|
||||
- Use `Flexbox` and `Center` from `@lobehub/ui` for layouts (see `references/layout-kit.md`)
|
||||
- Component priority: `src/components` > `@lobehub/ui/base-ui` > `@lobehub/ui` > custom implementation
|
||||
- Always prefer `@lobehub/ui/base-ui` primitives (Select, Modal, DropdownMenu, Popover, Switch, ScrollArea…) over antd equivalents
|
||||
- Fall back to `@lobehub/ui` higher-level components when base-ui has no match
|
||||
- Only implement a custom component as a last resort — never reach for antd directly
|
||||
- Use selectors to access zustand store data
|
||||
## Styling
|
||||
|
||||
## @lobehub/ui Components
|
||||
| Scenario | Approach |
|
||||
| ---------------------------------------------------------- | -------------------------------------------------------------- |
|
||||
| Most cases | `createStaticStyles` + `cssVar.*` (zero-runtime, module-level) |
|
||||
| Simple one-off | Inline `style` attribute |
|
||||
| Truly dynamic (JS color fns like `readableColor`/`chroma`) | `createStyles` + `token` — **last resort** |
|
||||
|
||||
If unsure about component usage, search existing code in this project. Most components extend antd with additional props.
|
||||
## Component Priority
|
||||
|
||||
Reference: `node_modules/@lobehub/ui/es/index.mjs` for all available components.
|
||||
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
|
||||
|
||||
**Common Components:**
|
||||
If unsure about available components, search existing code or check `node_modules/@lobehub/ui/es/index.mjs`.
|
||||
|
||||
- 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
|
||||
### Common @lobehub/ui Components
|
||||
|
||||
| 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 |
|
||||
|
||||
## Layout
|
||||
|
||||
Use `Flexbox` and `Center` from `@lobehub/ui`. See `references/layout-kit.md` for full props and examples.
|
||||
|
||||
- Use `gap` instead of `margin` for spacing between flex children
|
||||
- Use `flex={1}` to fill available space
|
||||
- Nest Flexbox for complex layouts; set `overflow: 'auto'` for scrollable regions
|
||||
|
||||
## Navigation
|
||||
|
||||
**For SPA pages, use `react-router-dom`, NOT `next/link`.**
|
||||
|
||||
```tsx
|
||||
// ❌ Wrong
|
||||
import Link from 'next/link';
|
||||
|
||||
// ✅ Correct
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
```
|
||||
|
||||
Access navigate from stores: `useGlobalStore.getState().navigate?.('/settings');`
|
||||
|
||||
## Desktop File Sync Rule
|
||||
|
||||
Files with a `.desktop.ts(x)` variant must be edited **in sync**. Drift causes blank pages in Electron.
|
||||
|
||||
| Base file (web) | Desktop file (Electron) |
|
||||
| -------------------------- | ---------------------------------- |
|
||||
| `desktopRouter.config.tsx` | `desktopRouter.config.desktop.tsx` |
|
||||
| `componentMap.ts` | `componentMap.desktop.ts` |
|
||||
|
||||
**After editing any `.ts`/`.tsx`:** glob for `<filename>.desktop.{ts,tsx}` in the same directory. If found, apply the equivalent sync-import change.
|
||||
|
||||
## Routing Architecture
|
||||
|
||||
Hybrid routing: Next.js App Router (static pages) + React Router DOM (main SPA).
|
||||
| Route Type | Use Case | Implementation |
|
||||
| ------------------ | ---------- | -------------------------------------------------- |
|
||||
| Next.js App Router | Auth pages | `src/app/[variants]/(auth)/` |
|
||||
| React Router DOM | Main SPA | `desktopRouter.config.tsx` + `.desktop.tsx` (pair) |
|
||||
|
||||
| Route Type | Use Case | Implementation |
|
||||
| ------------------ | --------------------------------- | ---------------------------------------------------------------------------- |
|
||||
| Next.js App Router | Auth pages (login, signup, oauth) | `src/app/[variants]/(auth)/` |
|
||||
| React Router DOM | Main SPA (chat, settings) | `desktopRouter.config.tsx` + `desktopRouter.config.desktop.tsx` (must match) |
|
||||
|
||||
### Key Files
|
||||
|
||||
- Entry: `src/spa/entry.web.tsx` (web), `src/spa/entry.mobile.tsx`, `src/spa/entry.desktop.tsx`
|
||||
- Desktop router (pair — **always edit both** when changing routes): `src/spa/router/desktopRouter.config.tsx` (dynamic imports) and `src/spa/router/desktopRouter.config.desktop.tsx` (sync imports). Drift can cause unregistered routes / blank screen.
|
||||
- Mobile router: `src/spa/router/mobileRouter.config.tsx`
|
||||
- Router utilities: `src/utils/router.tsx`
|
||||
|
||||
### `.desktop.{ts,tsx}` File Sync Rule
|
||||
|
||||
**CRITICAL**: Some files have a `.desktop.ts(x)` variant that Electron uses instead of the base file. When editing a base file, **always check** if a `.desktop` counterpart exists and update it in sync. Drift causes blank pages or missing features in Electron.
|
||||
|
||||
Known pairs that must stay in sync:
|
||||
|
||||
| Base file (web, dynamic imports) | Desktop file (Electron, sync imports) |
|
||||
| ----------------------------------------------------- | ------------------------------------------------------------- |
|
||||
| `src/spa/router/desktopRouter.config.tsx` | `src/spa/router/desktopRouter.config.desktop.tsx` |
|
||||
| `src/routes/(main)/settings/features/componentMap.ts` | `src/routes/(main)/settings/features/componentMap.desktop.ts` |
|
||||
|
||||
**How to check**: After editing any `.ts` / `.tsx` file, run `Glob` for `<filename>.desktop.{ts,tsx}` in the same directory. If a match exists, update it with the equivalent sync-import change.
|
||||
|
||||
### Router Utilities
|
||||
Router utilities:
|
||||
|
||||
```tsx
|
||||
import { dynamicElement, redirectElement, ErrorBoundary } from '@/utils/router';
|
||||
|
||||
element: dynamicElement(() => import('./chat'), 'Desktop > Chat');
|
||||
element: redirectElement('/settings/profile');
|
||||
errorElement: <ErrorBoundary />;
|
||||
```
|
||||
|
||||
### Navigation
|
||||
## Common Mistakes
|
||||
|
||||
**Important**: For SPA pages, use `Link` from `react-router-dom`, NOT `next/link`.
|
||||
|
||||
```tsx
|
||||
// ❌ Wrong
|
||||
import Link from 'next/link';
|
||||
<Link href="/">Home</Link>;
|
||||
|
||||
// ✅ Correct
|
||||
import { Link } from 'react-router-dom';
|
||||
<Link to="/">Home</Link>;
|
||||
|
||||
// In components
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
const navigate = useNavigate();
|
||||
navigate('/chat');
|
||||
|
||||
// From stores
|
||||
const navigate = useGlobalStore.getState().navigate;
|
||||
navigate?.('/settings');
|
||||
```
|
||||
| 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 |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: version-release
|
||||
description: "Version release workflow. Use when the user mentions 'release', 'hotfix', 'version upgrade', 'weekly release', or '发版'/'发布'/'小班车'. This skill is for release process and GitHub Release notes (not docs/changelog page writing)."
|
||||
description: 'Version release workflow — release process and GitHub Release notes (not docs/changelog pages).'
|
||||
disable-model-invocation: true
|
||||
argument-hint: '[minor|patch] [version?]'
|
||||
---
|
||||
|
||||
@@ -21,6 +21,46 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
# Remind contributors when a non-release PR targets `main`.
|
||||
# Day-to-day PRs should target `canary`; `main` is reserved for releases
|
||||
# (see .agents/skills/version-release/SKILL.md). Allowed exceptions:
|
||||
# - PR title matches `🚀 release: v{x.y.z}` (minor release)
|
||||
# - head branch matches `hotfix/*` or `release/*` (patch release)
|
||||
- name: Remind contributor if base branch is not canary
|
||||
if: github.event.action == 'opened' && github.event.pull_request.base.ref == 'main'
|
||||
env:
|
||||
HEAD_REF: ${{ github.event.pull_request.head.ref }}
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
run: |
|
||||
if [[ "$HEAD_REF" == hotfix/* ]] || [[ "$HEAD_REF" == release/* ]]; then
|
||||
echo "✅ Release/hotfix branch ($HEAD_REF) -> main is allowed"
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$PR_TITLE" =~ ^🚀[[:space:]]+release: ]]; then
|
||||
echo "✅ Release-titled PR -> main is allowed"
|
||||
exit 0
|
||||
fi
|
||||
echo "⚠️ Non-release PR targets main; posting reminder comment."
|
||||
gh pr comment "$PR_NUMBER" --body "$(cat <<'EOF'
|
||||
👋 Thanks for your contribution!
|
||||
|
||||
This PR currently targets the **`main`** branch, but `main` is reserved for release PRs only. Day-to-day development (features, fixes, refactors, docs, etc.) should target the **`canary`** branch.
|
||||
|
||||
### How to fix
|
||||
|
||||
On the PR page, click **Edit** next to the title, then change the base branch from `main` to `canary`.
|
||||
|
||||
### When targeting `main` is allowed
|
||||
|
||||
- PR title starts with `🚀 release: v{x.y.z}` (minor release)
|
||||
- Head branch matches `hotfix/*` or `release/*` (patch release)
|
||||
|
||||
If your PR fits one of these cases, please ignore this message.
|
||||
EOF
|
||||
)"
|
||||
|
||||
- name: Check if author is a team member
|
||||
id: check-team
|
||||
run: |
|
||||
|
||||
+236
@@ -2,6 +2,242 @@
|
||||
|
||||
# Changelog
|
||||
|
||||
### [Version 2.2.0](https://github.com/lobehub/lobe-chat/compare/v2.1.59-canary.27...v2.2.0)
|
||||
|
||||
<sup>Released on **2026-05-18**</sup>
|
||||
|
||||
#### 💄 Styles
|
||||
|
||||
- **pricing**: restore DeepSeek models to official pricing.
|
||||
|
||||
#### 🐛 Bug Fixes
|
||||
|
||||
- **conversation**: animate only the last markdown block + drop clearMessages hotkey.
|
||||
|
||||
<br/>
|
||||
|
||||
<details>
|
||||
<summary><kbd>Improvements and Fixes</kbd></summary>
|
||||
|
||||
#### Styles
|
||||
|
||||
- **pricing**: restore DeepSeek models to official pricing, closes [#14911](https://github.com/lobehub/lobe-chat/issues/14911) ([e566688](https://github.com/lobehub/lobe-chat/commit/e566688))
|
||||
|
||||
#### What's fixed
|
||||
|
||||
- **conversation**: animate only the last markdown block + drop clearMessages hotkey, closes [#14906](https://github.com/lobehub/lobe-chat/issues/14906) ([469a8e6](https://github.com/lobehub/lobe-chat/commit/469a8e6))
|
||||
|
||||
</details>
|
||||
|
||||
<div align="right">
|
||||
|
||||
[](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
## [Version 2.1.58](https://github.com/lobehub/lobe-chat/compare/v2.1.57...v2.1.58)
|
||||
|
||||
<sup>Released on **2026-05-13**</sup>
|
||||
|
||||
#### ✨ Features
|
||||
|
||||
- **agent-runtime**: persist agent operations to `agent_operations` table.
|
||||
- **misc**: support slack mpim and fix discord dm problem.
|
||||
- **database**: add `agent_operations` table.
|
||||
- **markdown**: user_feedback card + task card polish + Run now context menu.
|
||||
- **documents**: add optimistic create/delete and inline rename for document tree.
|
||||
- **devtools**: add dev-only feature flag override panel.
|
||||
- **misc**: add service model assignments settings.
|
||||
- **misc**: inline skill auth in recommended task templates.
|
||||
- **activator**: require activation reason.
|
||||
- **agent-signal,server,prompts**: consolidate in self-review implemented.
|
||||
- **hetero-agent**: support AskUserQuestion tools for claude code.
|
||||
- **bot**: gate device tools by sender identity.
|
||||
- **misc**: add user activity business hook.
|
||||
- **misc**: add Gemini 3.1 Flash-Lite provider cards.
|
||||
- **misc**: home daily brief with linkable welcome + paired input hint.
|
||||
- **agent-signal,prompts,database**: self-review now proposal actions to briefs, and automatically execute actions.
|
||||
- **misc**: add signOperationJwt with 4h expiry for hetero-agent operations.
|
||||
- **misc**: migrate Notion to LobeHub Market.
|
||||
- **misc**: Cloud Claude Code V3 — repo picker, GitHub token, sandbox context.
|
||||
|
||||
#### 🐛 Bug Fixes
|
||||
|
||||
- **hetero-agent**: wire AskUserBridge response events to renderer.
|
||||
- **home**: blank user bubble when sending the placeholder hint.
|
||||
- **conversation**: prevent synthetic scroll from shrinking spacer.
|
||||
- **task-card**: localize task card date independent of dayjs global locale.
|
||||
- **web-crawler**: cap response body size to prevent serverless OOM.
|
||||
- **desktop**: focus onboarding auth success state.
|
||||
- **misc**: Docs image.
|
||||
- **desktop**: detect Windows npm .cmd shims for CLI agents (claude/codex/…).
|
||||
- **misc**: update Task page placeholder copy.
|
||||
- **builtin-tool-task**: expose `lobe-task` and add `setTaskSchedule`.
|
||||
- **desktop**: reset pendingLoginMethod on auth failure/cancel paths.
|
||||
- **utils**: cap image binary at 3.75MB so base64 payload stays under Anthropic 5MB limit.
|
||||
- **tasks**: scheduler, hotkey, comment & TodoList polish.
|
||||
- **cli**: remove stale cron entry from generated man page.
|
||||
- **misc**: sidebar add agent.
|
||||
- **misc**: replace ScrollShadow with ScrollArea to fix React #185 infinite render loop.
|
||||
- **heteroFinish**: trigger task lifecycle on cloud sandbox agent completion.
|
||||
- **hotkey**: remove redundant onClear to prevent double updateHotkey calls.
|
||||
- **misc**: reject inactive OIDC access.
|
||||
- **misc**: drop unreachable aihubmix empty-apiKey test.
|
||||
- **aihubmix**: use full models endpoint to return complete model list.
|
||||
- **onboarding**: skip marketplace on early exit, drop CJK in prompts.
|
||||
- **model-runtime**: enrich stream parse errors with provider/model context.
|
||||
- **home**: strip markdown links from daily-brief input placeholder.
|
||||
- **misc**: consume visual content parts in server runtime.
|
||||
- **misc**: store onboarding interests as keys.
|
||||
- **hetero-agent**: sync new-step assistant across replicas.
|
||||
- **misc**: remove the old cron job from lobehub.
|
||||
- **misc**: refresh content baseline from DB on every ingest call.
|
||||
- **hetero-agent**: disable Claude Code AskUserQuestion to avoid auto-decline.
|
||||
- **local-system**: guard readFile against binary blobs and oversized output.
|
||||
- **database,utils,userMemories**: should perfer to use `paradedb.match(...)` instead of hardcoded normalizer.
|
||||
- **database**: attach error listeners to Neon/Node pools to prevent Lambda crash.
|
||||
- **misc**: gateway client-tool pluginState + drop redundant `Exit code: 0` tail.
|
||||
- **gemini**: handle zero cachedContentTokenCount in usage conversion.
|
||||
- **misc**: first inject the cloudecc runtime session should use the existingStatus.
|
||||
- **misc**: slack connect error & slash commands.
|
||||
- **misc**: polish task agent manager.
|
||||
- **agent-runtime**: recover malformed tool_call names instead of finishing silently.
|
||||
- **misc**: remove signin captcha flow.
|
||||
- **misc**: add temporary email auth error locale.
|
||||
- **misc**: add bot callback service.
|
||||
- **misc**: sanitize sensitive comments and examples from production JS bundle.
|
||||
- **misc**: multiple account link.
|
||||
|
||||
#### 💄 Styles
|
||||
|
||||
- **misc**: use @lobehub/ui built-in HtmlPreview instead of custom component.
|
||||
- **misc**: polish desktop header icons, sidebar density, and task menus.
|
||||
- **review-panel**: hover revert button to discard per-file working-tree changes.
|
||||
- **misc**: standardize header action icon sizes.
|
||||
- **tool**: add word wrap toggle to tool arguments display.
|
||||
- **nav**: unify ActionIcon sizing and improve TodoList encapsulation.
|
||||
- **web-onboarding**: add Render for saveUserQuestion & showAgentMarketplace.
|
||||
- **misc**: add `reasoning_effort` support for Grok 4.3.
|
||||
- **misc**: increase chat topic title length.
|
||||
- **hetero-agent**: read-only SubAgent threads with breadcrumb header and thread switcher.
|
||||
- **chat-input**: show skeleton in action bar while config is loading.
|
||||
- **home**: add Recommendations module with hetero agent action library.
|
||||
- **copyable-label**: wrap long tool-call params instead of truncating.
|
||||
- **misc**: format tool execution time as Xmin Ys instead of X.Y min.
|
||||
- **misc**: Add new DeepSeek-V4 models.
|
||||
- **topic**: add copy session ID to topic dropdown menu.
|
||||
- **misc**: use visible divider between queued messages.
|
||||
- **intervention**: polish confirmation bar layout.
|
||||
- **settings**: remove image avatar from lab input markdown rendering item.
|
||||
- **task**: activity card stop run + register /tasks in SPA proxy.
|
||||
- **misc**: update auth captcha retry copy.
|
||||
|
||||
<br/>
|
||||
|
||||
<details>
|
||||
<summary><kbd>Improvements and Fixes</kbd></summary>
|
||||
|
||||
#### What's improved
|
||||
|
||||
- **agent-runtime**: persist agent operations to `agent_operations` table, closes [#14736](https://github.com/lobehub/lobe-chat/issues/14736) ([a772341](https://github.com/lobehub/lobe-chat/commit/a772341))
|
||||
- **misc**: support slack mpim and fix discord dm problem, closes [#14733](https://github.com/lobehub/lobe-chat/issues/14733) ([729265a](https://github.com/lobehub/lobe-chat/commit/729265a))
|
||||
- **database**: add `agent_operations` table, closes [#14416](https://github.com/lobehub/lobe-chat/issues/14416) ([cb8b616](https://github.com/lobehub/lobe-chat/commit/cb8b616))
|
||||
- **markdown**: user_feedback card + task card polish + Run now context menu, closes [#14727](https://github.com/lobehub/lobe-chat/issues/14727) ([79152fa](https://github.com/lobehub/lobe-chat/commit/79152fa))
|
||||
- **documents**: add optimistic create/delete and inline rename for document tree, closes [#14714](https://github.com/lobehub/lobe-chat/issues/14714) ([0007984](https://github.com/lobehub/lobe-chat/commit/0007984))
|
||||
- **devtools**: add dev-only feature flag override panel, closes [#14565](https://github.com/lobehub/lobe-chat/issues/14565) ([18b1c25](https://github.com/lobehub/lobe-chat/commit/18b1c25))
|
||||
- **misc**: add service model assignments settings, closes [#14712](https://github.com/lobehub/lobe-chat/issues/14712) ([eb924ec](https://github.com/lobehub/lobe-chat/commit/eb924ec))
|
||||
- **misc**: inline skill auth in recommended task templates, closes [#14676](https://github.com/lobehub/lobe-chat/issues/14676) ([4490e3e](https://github.com/lobehub/lobe-chat/commit/4490e3e))
|
||||
- **activator**: require activation reason, closes [#14597](https://github.com/lobehub/lobe-chat/issues/14597) ([5f14b7e](https://github.com/lobehub/lobe-chat/commit/5f14b7e))
|
||||
- **agent-signal,server,prompts**: consolidate in self-review implemented, closes [#14657](https://github.com/lobehub/lobe-chat/issues/14657) ([1374fd2](https://github.com/lobehub/lobe-chat/commit/1374fd2))
|
||||
- **hetero-agent**: support AskUserQuestion tools for claude code, closes [#14639](https://github.com/lobehub/lobe-chat/issues/14639) ([49c3d7e](https://github.com/lobehub/lobe-chat/commit/49c3d7e))
|
||||
- **bot**: gate device tools by sender identity, closes [#14634](https://github.com/lobehub/lobe-chat/issues/14634) ([3c81011](https://github.com/lobehub/lobe-chat/commit/3c81011))
|
||||
- **misc**: add user activity business hook, closes [#14601](https://github.com/lobehub/lobe-chat/issues/14601) ([521566b](https://github.com/lobehub/lobe-chat/commit/521566b))
|
||||
- **misc**: add Gemini 3.1 Flash-Lite provider cards, closes [#14604](https://github.com/lobehub/lobe-chat/issues/14604) ([9b032f0](https://github.com/lobehub/lobe-chat/commit/9b032f0))
|
||||
- **misc**: home daily brief with linkable welcome + paired input hint, closes [#14589](https://github.com/lobehub/lobe-chat/issues/14589) ([12e37f1](https://github.com/lobehub/lobe-chat/commit/12e37f1))
|
||||
- **agent-signal,prompts,database**: self-review now proposal actions to briefs, and automatically execute actions, closes [#14583](https://github.com/lobehub/lobe-chat/issues/14583) ([b7a5020](https://github.com/lobehub/lobe-chat/commit/b7a5020))
|
||||
- **misc**: add signOperationJwt with 4h expiry for hetero-agent operations, closes [#14586](https://github.com/lobehub/lobe-chat/issues/14586) ([d2c379c](https://github.com/lobehub/lobe-chat/commit/d2c379c))
|
||||
- **misc**: migrate Notion to LobeHub Market, closes [#14578](https://github.com/lobehub/lobe-chat/issues/14578) ([f1f2e58](https://github.com/lobehub/lobe-chat/commit/f1f2e58))
|
||||
- **misc**: Cloud Claude Code V3 — repo picker, GitHub token, sandbox context, closes [#14568](https://github.com/lobehub/lobe-chat/issues/14568) ([7792f63](https://github.com/lobehub/lobe-chat/commit/7792f63))
|
||||
|
||||
#### What's fixed
|
||||
|
||||
- **hetero-agent**: wire AskUserBridge response events to renderer, closes [#14732](https://github.com/lobehub/lobe-chat/issues/14732) ([5174c13](https://github.com/lobehub/lobe-chat/commit/5174c13))
|
||||
- **home**: blank user bubble when sending the placeholder hint, closes [#14678](https://github.com/lobehub/lobe-chat/issues/14678) ([fc275ca](https://github.com/lobehub/lobe-chat/commit/fc275ca))
|
||||
- **conversation**: prevent synthetic scroll from shrinking spacer, closes [#14584](https://github.com/lobehub/lobe-chat/issues/14584) ([217afcf](https://github.com/lobehub/lobe-chat/commit/217afcf))
|
||||
- **task-card**: localize task card date independent of dayjs global locale, closes [#14730](https://github.com/lobehub/lobe-chat/issues/14730) ([df0e635](https://github.com/lobehub/lobe-chat/commit/df0e635))
|
||||
- **web-crawler**: cap response body size to prevent serverless OOM, closes [#14660](https://github.com/lobehub/lobe-chat/issues/14660) ([2202189](https://github.com/lobehub/lobe-chat/commit/2202189))
|
||||
- **desktop**: focus onboarding auth success state, closes [#14694](https://github.com/lobehub/lobe-chat/issues/14694) ([4e4294f](https://github.com/lobehub/lobe-chat/commit/4e4294f))
|
||||
- **misc**: Docs image, closes [#14726](https://github.com/lobehub/lobe-chat/issues/14726) ([3a4bd4a](https://github.com/lobehub/lobe-chat/commit/3a4bd4a))
|
||||
- **desktop**: detect Windows npm .cmd shims for CLI agents (claude/codex/…), closes [#14720](https://github.com/lobehub/lobe-chat/issues/14720) ([a40fe91](https://github.com/lobehub/lobe-chat/commit/a40fe91))
|
||||
- **misc**: update Task page placeholder copy, closes [#14704](https://github.com/lobehub/lobe-chat/issues/14704) ([eea742f](https://github.com/lobehub/lobe-chat/commit/eea742f))
|
||||
- **builtin-tool-task**: expose `lobe-task` and add `setTaskSchedule`, closes [#14713](https://github.com/lobehub/lobe-chat/issues/14713) ([5ff4590](https://github.com/lobehub/lobe-chat/commit/5ff4590))
|
||||
- **desktop**: reset pendingLoginMethod on auth failure/cancel paths, closes [#14695](https://github.com/lobehub/lobe-chat/issues/14695) ([51cefe0](https://github.com/lobehub/lobe-chat/commit/51cefe0))
|
||||
- **utils**: cap image binary at 3.75MB so base64 payload stays under Anthropic 5MB limit, closes [#14711](https://github.com/lobehub/lobe-chat/issues/14711) ([948e48b](https://github.com/lobehub/lobe-chat/commit/948e48b))
|
||||
- **tasks**: scheduler, hotkey, comment & TodoList polish, closes [#14707](https://github.com/lobehub/lobe-chat/issues/14707) ([1ae774d](https://github.com/lobehub/lobe-chat/commit/1ae774d))
|
||||
- **cli**: remove stale cron entry from generated man page, closes [#14709](https://github.com/lobehub/lobe-chat/issues/14709) ([94e4ea6](https://github.com/lobehub/lobe-chat/commit/94e4ea6))
|
||||
- **misc**: sidebar add agent, closes [#14693](https://github.com/lobehub/lobe-chat/issues/14693) ([fdedc96](https://github.com/lobehub/lobe-chat/commit/fdedc96))
|
||||
- **misc**: replace ScrollShadow with ScrollArea to fix React #185 infinite render loop, closes [#185](https://github.com/lobehub/lobe-chat/issues/185), closes [#14689](https://github.com/lobehub/lobe-chat/issues/14689) ([7349ad0](https://github.com/lobehub/lobe-chat/commit/7349ad0))
|
||||
- **heteroFinish**: trigger task lifecycle on cloud sandbox agent completion, closes [#14681](https://github.com/lobehub/lobe-chat/issues/14681) ([744059c](https://github.com/lobehub/lobe-chat/commit/744059c))
|
||||
- **hotkey**: remove redundant onClear to prevent double updateHotkey calls, closes [#14663](https://github.com/lobehub/lobe-chat/issues/14663) ([dfe1932](https://github.com/lobehub/lobe-chat/commit/dfe1932))
|
||||
- **misc**: reject inactive OIDC access, closes [#14674](https://github.com/lobehub/lobe-chat/issues/14674) ([b79c5d8](https://github.com/lobehub/lobe-chat/commit/b79c5d8))
|
||||
- **misc**: drop unreachable aihubmix empty-apiKey test, closes [#14669](https://github.com/lobehub/lobe-chat/issues/14669) ([b0ee35d](https://github.com/lobehub/lobe-chat/commit/b0ee35d))
|
||||
- **aihubmix**: use full models endpoint to return complete model list, closes [#14511](https://github.com/lobehub/lobe-chat/issues/14511) ([f4de472](https://github.com/lobehub/lobe-chat/commit/f4de472))
|
||||
- **onboarding**: skip marketplace on early exit, drop CJK in prompts, closes [#14598](https://github.com/lobehub/lobe-chat/issues/14598) ([a9eb904](https://github.com/lobehub/lobe-chat/commit/a9eb904))
|
||||
- **model-runtime**: enrich stream parse errors with provider/model context, closes [#14636](https://github.com/lobehub/lobe-chat/issues/14636) ([7daed90](https://github.com/lobehub/lobe-chat/commit/7daed90))
|
||||
- **home**: strip markdown links from daily-brief input placeholder, closes [#14635](https://github.com/lobehub/lobe-chat/issues/14635) ([0babdcf](https://github.com/lobehub/lobe-chat/commit/0babdcf))
|
||||
- **misc**: consume visual content parts in server runtime, closes [#14637](https://github.com/lobehub/lobe-chat/issues/14637) ([d445a89](https://github.com/lobehub/lobe-chat/commit/d445a89))
|
||||
- **misc**: store onboarding interests as keys, closes [#14624](https://github.com/lobehub/lobe-chat/issues/14624) ([9982de3](https://github.com/lobehub/lobe-chat/commit/9982de3))
|
||||
- **hetero-agent**: sync new-step assistant across replicas, closes [#14631](https://github.com/lobehub/lobe-chat/issues/14631) ([7675bd9](https://github.com/lobehub/lobe-chat/commit/7675bd9))
|
||||
- **misc**: remove the old cron job from lobehub, closes [#14630](https://github.com/lobehub/lobe-chat/issues/14630) ([457d112](https://github.com/lobehub/lobe-chat/commit/457d112))
|
||||
- **misc**: refresh content baseline from DB on every ingest call, closes [#14603](https://github.com/lobehub/lobe-chat/issues/14603) ([6595961](https://github.com/lobehub/lobe-chat/commit/6595961))
|
||||
- **hetero-agent**: disable Claude Code AskUserQuestion to avoid auto-decline, closes [#14629](https://github.com/lobehub/lobe-chat/issues/14629) ([ae8f9cf](https://github.com/lobehub/lobe-chat/commit/ae8f9cf))
|
||||
- **local-system**: guard readFile against binary blobs and oversized output, closes [#14602](https://github.com/lobehub/lobe-chat/issues/14602) ([96165e4](https://github.com/lobehub/lobe-chat/commit/96165e4))
|
||||
- **database,utils,userMemories**: should perfer to use `paradedb.match(...)` instead of hardcoded normalizer, closes [#14590](https://github.com/lobehub/lobe-chat/issues/14590) ([38b793f](https://github.com/lobehub/lobe-chat/commit/38b793f))
|
||||
- **database**: attach error listeners to Neon/Node pools to prevent Lambda crash, closes [#14606](https://github.com/lobehub/lobe-chat/issues/14606) ([11ec59b](https://github.com/lobehub/lobe-chat/commit/11ec59b))
|
||||
- **misc**: gateway client-tool pluginState + drop redundant `Exit code: 0` tail, closes [#14596](https://github.com/lobehub/lobe-chat/issues/14596) ([4bfd434](https://github.com/lobehub/lobe-chat/commit/4bfd434))
|
||||
- **gemini**: handle zero cachedContentTokenCount in usage conversion, closes [#14567](https://github.com/lobehub/lobe-chat/issues/14567) ([307cd8e](https://github.com/lobehub/lobe-chat/commit/307cd8e))
|
||||
- **misc**: first inject the cloudecc runtime session should use the existingStatus, closes [#14592](https://github.com/lobehub/lobe-chat/issues/14592) ([09c66ff](https://github.com/lobehub/lobe-chat/commit/09c66ff))
|
||||
- **misc**: slack connect error & slash commands, closes [#14591](https://github.com/lobehub/lobe-chat/issues/14591) ([8274be0](https://github.com/lobehub/lobe-chat/commit/8274be0))
|
||||
- **misc**: polish task agent manager, closes [#14569](https://github.com/lobehub/lobe-chat/issues/14569) ([a02ecbc](https://github.com/lobehub/lobe-chat/commit/a02ecbc))
|
||||
- **agent-runtime**: recover malformed tool_call names instead of finishing silently, closes [#14577](https://github.com/lobehub/lobe-chat/issues/14577) ([5f8ec8b](https://github.com/lobehub/lobe-chat/commit/5f8ec8b))
|
||||
- **misc**: remove signin captcha flow, closes [#14573](https://github.com/lobehub/lobe-chat/issues/14573) ([181b7eb](https://github.com/lobehub/lobe-chat/commit/181b7eb))
|
||||
- **misc**: add temporary email auth error locale, closes [#14564](https://github.com/lobehub/lobe-chat/issues/14564) ([2bdd901](https://github.com/lobehub/lobe-chat/commit/2bdd901))
|
||||
- **misc**: add bot callback service, closes [#14570](https://github.com/lobehub/lobe-chat/issues/14570) ([e4b5e52](https://github.com/lobehub/lobe-chat/commit/e4b5e52))
|
||||
- **misc**: sanitize sensitive comments and examples from production JS bundle, closes [#14557](https://github.com/lobehub/lobe-chat/issues/14557) ([1a6e07b](https://github.com/lobehub/lobe-chat/commit/1a6e07b))
|
||||
- **misc**: multiple account link, closes [#14562](https://github.com/lobehub/lobe-chat/issues/14562) ([760a342](https://github.com/lobehub/lobe-chat/commit/760a342))
|
||||
|
||||
#### Styles
|
||||
|
||||
- **misc**: use @lobehub/ui built-in HtmlPreview instead of custom component, closes [#14703](https://github.com/lobehub/lobe-chat/issues/14703) ([266d102](https://github.com/lobehub/lobe-chat/commit/266d102))
|
||||
- **misc**: polish desktop header icons, sidebar density, and task menus, closes [#14724](https://github.com/lobehub/lobe-chat/issues/14724) ([e56edab](https://github.com/lobehub/lobe-chat/commit/e56edab))
|
||||
- **review-panel**: hover revert button to discard per-file working-tree changes, closes [#14716](https://github.com/lobehub/lobe-chat/issues/14716) ([846e648](https://github.com/lobehub/lobe-chat/commit/846e648))
|
||||
- **misc**: standardize header action icon sizes, closes [#14717](https://github.com/lobehub/lobe-chat/issues/14717) ([ca9a781](https://github.com/lobehub/lobe-chat/commit/ca9a781))
|
||||
- **tool**: add word wrap toggle to tool arguments display, closes [#14706](https://github.com/lobehub/lobe-chat/issues/14706) ([bfa2850](https://github.com/lobehub/lobe-chat/commit/bfa2850))
|
||||
- **nav**: unify ActionIcon sizing and improve TodoList encapsulation, closes [#14692](https://github.com/lobehub/lobe-chat/issues/14692) ([877052f](https://github.com/lobehub/lobe-chat/commit/877052f))
|
||||
- **web-onboarding**: add Render for saveUserQuestion & showAgentMarketplace, closes [#14667](https://github.com/lobehub/lobe-chat/issues/14667) ([f591f7a](https://github.com/lobehub/lobe-chat/commit/f591f7a))
|
||||
- **misc**: add `reasoning_effort` support for Grok 4.3, closes [#14642](https://github.com/lobehub/lobe-chat/issues/14642) ([a1fac45](https://github.com/lobehub/lobe-chat/commit/a1fac45))
|
||||
- **misc**: increase chat topic title length, closes [#14659](https://github.com/lobehub/lobe-chat/issues/14659) ([e0ead0c](https://github.com/lobehub/lobe-chat/commit/e0ead0c))
|
||||
- **hetero-agent**: read-only SubAgent threads with breadcrumb header and thread switcher, closes [#14658](https://github.com/lobehub/lobe-chat/issues/14658) ([31e9130](https://github.com/lobehub/lobe-chat/commit/31e9130))
|
||||
- **chat-input**: show skeleton in action bar while config is loading, closes [#14656](https://github.com/lobehub/lobe-chat/issues/14656) ([84b802c](https://github.com/lobehub/lobe-chat/commit/84b802c))
|
||||
- **home**: add Recommendations module with hetero agent action library, closes [#14645](https://github.com/lobehub/lobe-chat/issues/14645) ([e261a6f](https://github.com/lobehub/lobe-chat/commit/e261a6f))
|
||||
- **copyable-label**: wrap long tool-call params instead of truncating, closes [#14640](https://github.com/lobehub/lobe-chat/issues/14640) ([60a127b](https://github.com/lobehub/lobe-chat/commit/60a127b))
|
||||
- **misc**: format tool execution time as Xmin Ys instead of X.Y min, closes [#14641](https://github.com/lobehub/lobe-chat/issues/14641) ([b85a1ad](https://github.com/lobehub/lobe-chat/commit/b85a1ad))
|
||||
- **misc**: Add new DeepSeek-V4 models, closes [#14110](https://github.com/lobehub/lobe-chat/issues/14110) ([867e22a](https://github.com/lobehub/lobe-chat/commit/867e22a))
|
||||
- **topic**: add copy session ID to topic dropdown menu, closes [#14595](https://github.com/lobehub/lobe-chat/issues/14595) ([a275009](https://github.com/lobehub/lobe-chat/commit/a275009))
|
||||
- **misc**: use visible divider between queued messages, closes [#14593](https://github.com/lobehub/lobe-chat/issues/14593) ([909b1ec](https://github.com/lobehub/lobe-chat/commit/909b1ec))
|
||||
- **intervention**: polish confirmation bar layout, closes [#14587](https://github.com/lobehub/lobe-chat/issues/14587) ([5c11130](https://github.com/lobehub/lobe-chat/commit/5c11130))
|
||||
- **settings**: remove image avatar from lab input markdown rendering item, closes [#14582](https://github.com/lobehub/lobe-chat/issues/14582) ([d73de25](https://github.com/lobehub/lobe-chat/commit/d73de25))
|
||||
- **task**: activity card stop run + register /tasks in SPA proxy, closes [#14559](https://github.com/lobehub/lobe-chat/issues/14559) ([a7cc553](https://github.com/lobehub/lobe-chat/commit/a7cc553))
|
||||
- **misc**: update auth captcha retry copy, closes [#14561](https://github.com/lobehub/lobe-chat/issues/14561) ([c208723](https://github.com/lobehub/lobe-chat/commit/c208723))
|
||||
|
||||
</details>
|
||||
|
||||
<div align="right">
|
||||
|
||||
[](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
## [Version 2.1.57](https://github.com/lobehub/lobe-chat/compare/v2.1.57-canary.33...v2.1.57)
|
||||
|
||||
<sup>Released on **2026-05-09**</sup>
|
||||
|
||||
@@ -269,6 +269,204 @@ function registerAllowlistCommand(bot: Command, opts: AllowlistGroupOptions) {
|
||||
});
|
||||
}
|
||||
|
||||
// ── Watch keywords subcommand factory ──────────────────
|
||||
|
||||
interface WatchKeywordEntry {
|
||||
instruction?: string;
|
||||
keyword: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise `settings.watchKeywords` into the canonical
|
||||
* `{keyword, instruction?}[]` shape. Mirrors `extractWatchKeywordEntries`
|
||||
* in `src/server/services/bot/platforms/const.ts` so the CLI accepts the
|
||||
* same legacy on-disk shapes (`string`, `string[]`, `{keyword, …}[]`)
|
||||
* the runtime is forgiving about — including the rare comma/whitespace
|
||||
* separated string from a hand-pasted upgrade.
|
||||
*/
|
||||
function normalizeWatchKeywords(raw: unknown): WatchKeywordEntry[] {
|
||||
const push = (out: Map<string, WatchKeywordEntry>, keyword: unknown, instruction?: unknown) => {
|
||||
if (typeof keyword !== 'string') return;
|
||||
const normalised = keyword.trim().toLowerCase();
|
||||
if (!normalised) return;
|
||||
const trimmedInstruction =
|
||||
typeof instruction === 'string' && instruction.trim() ? instruction.trim() : undefined;
|
||||
const existing = out.get(normalised);
|
||||
if (!existing) {
|
||||
out.set(normalised, { instruction: trimmedInstruction, keyword: normalised });
|
||||
return;
|
||||
}
|
||||
if (!existing.instruction && trimmedInstruction) existing.instruction = trimmedInstruction;
|
||||
};
|
||||
const collected = new Map<string, WatchKeywordEntry>();
|
||||
if (typeof raw === 'string') {
|
||||
for (const piece of raw.split(/[\s,]+/)) push(collected, piece);
|
||||
} else if (Array.isArray(raw)) {
|
||||
for (const entry of raw) {
|
||||
if (typeof entry === 'string') {
|
||||
push(collected, entry);
|
||||
continue;
|
||||
}
|
||||
if (entry && typeof entry === 'object' && 'keyword' in entry) {
|
||||
const obj = entry as { instruction?: unknown; keyword?: unknown };
|
||||
push(collected, obj.keyword, obj.instruction);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...collected.values()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `list / add / remove / clear` subcommand group around
|
||||
* `settings.watchKeywords`. Shape differs from the user/channel allowlists
|
||||
* (`{keyword, instruction?}` vs `{id, name?}`), so we duplicate the
|
||||
* scaffolding instead of squeezing both shapes through one factory — the
|
||||
* help text, column headers, and `--instruction` flag are all keyword-
|
||||
* specific and would just bloat the unified version.
|
||||
*/
|
||||
function registerWatchKeywordsCommand(bot: Command) {
|
||||
const group = bot
|
||||
.command('watch-keywords')
|
||||
.description(
|
||||
'Manage watch keywords (non-mention channel triggers; the optional instruction is prepended to the user message before being sent to the AI)',
|
||||
);
|
||||
|
||||
const readEntries = (bot: any): WatchKeywordEntry[] =>
|
||||
normalizeWatchKeywords((bot.settings as Record<string, unknown> | null)?.watchKeywords);
|
||||
|
||||
const buildPayload = (bot: any, nextEntries: WatchKeywordEntry[]) => ({
|
||||
id: bot.id,
|
||||
settings: {
|
||||
...(bot.settings as Record<string, unknown>),
|
||||
watchKeywords: nextEntries,
|
||||
},
|
||||
});
|
||||
|
||||
group
|
||||
.command('list <botId>')
|
||||
.description('List watch-keyword entries')
|
||||
.option('--json', 'Output JSON')
|
||||
.action(async (botId: string, options: { json?: boolean }) => {
|
||||
const client = await getTrpcClient();
|
||||
const b = await findBot(client, botId);
|
||||
const entries = readEntries(b);
|
||||
|
||||
if (options.json) {
|
||||
outputJson(entries);
|
||||
return;
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
console.log(`${pc.dim('No watch-keyword entries.')}`);
|
||||
return;
|
||||
}
|
||||
|
||||
printTable(
|
||||
entries.map((e) => [e.keyword, e.instruction ?? pc.dim('-')]),
|
||||
['KEYWORD', 'INSTRUCTION'],
|
||||
);
|
||||
});
|
||||
|
||||
group
|
||||
.command('add <botId> <keyword>')
|
||||
.description('Add a watch keyword (with optional instruction prefix)')
|
||||
.option(
|
||||
'--instruction <text>',
|
||||
'Prompt prepended to the user message when this keyword fires (omit for "just wake the bot")',
|
||||
)
|
||||
.action(async (botId: string, keyword: string, options: { instruction?: string }) => {
|
||||
const trimmedKeyword = keyword.trim().toLowerCase();
|
||||
if (!trimmedKeyword) {
|
||||
log.error('Keyword cannot be empty.');
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmedInstruction = options.instruction?.trim();
|
||||
|
||||
const client = await getTrpcClient();
|
||||
const b = await findBot(client, botId);
|
||||
const entries = readEntries(b);
|
||||
|
||||
const existing = entries.find((e) => e.keyword === trimmedKeyword);
|
||||
if (existing) {
|
||||
// Upsert instruction on duplicate keyword — operators commonly
|
||||
// re-run `add` to tweak the prompt without remembering to remove first.
|
||||
if (trimmedInstruction && existing.instruction !== trimmedInstruction) {
|
||||
existing.instruction = trimmedInstruction;
|
||||
await client.agentBotProvider.update.mutate(buildPayload(b, entries) as any);
|
||||
console.log(
|
||||
`${pc.green('✓')} Updated instruction for ${pc.bold(trimmedKeyword)} (${entries.length} entr${entries.length === 1 ? 'y' : 'ies'})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
log.info(`${trimmedKeyword} is already on watchKeywords — nothing to do.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const next = [
|
||||
...entries,
|
||||
trimmedInstruction
|
||||
? { instruction: trimmedInstruction, keyword: trimmedKeyword }
|
||||
: { keyword: trimmedKeyword },
|
||||
];
|
||||
|
||||
await client.agentBotProvider.update.mutate(buildPayload(b, next) as any);
|
||||
console.log(
|
||||
`${pc.green('✓')} Added ${pc.bold(trimmedKeyword)}${trimmedInstruction ? ' (with instruction)' : ''} to watchKeywords (now ${next.length} entr${next.length === 1 ? 'y' : 'ies'})`,
|
||||
);
|
||||
});
|
||||
|
||||
group
|
||||
.command('remove <botId> <keyword>')
|
||||
.description('Remove a watch keyword')
|
||||
.action(async (botId: string, keyword: string) => {
|
||||
const trimmedKeyword = keyword.trim().toLowerCase();
|
||||
const client = await getTrpcClient();
|
||||
const b = await findBot(client, botId);
|
||||
const entries = readEntries(b);
|
||||
const next = entries.filter((e) => e.keyword !== trimmedKeyword);
|
||||
|
||||
if (next.length === entries.length) {
|
||||
log.info(`${trimmedKeyword} is not on watchKeywords — nothing to do.`);
|
||||
return;
|
||||
}
|
||||
|
||||
await client.agentBotProvider.update.mutate(buildPayload(b, next) as any);
|
||||
console.log(
|
||||
`${pc.green('✓')} Removed ${pc.bold(trimmedKeyword)} from watchKeywords (${next.length} entr${next.length === 1 ? 'y' : 'ies'} left)`,
|
||||
);
|
||||
});
|
||||
|
||||
group
|
||||
.command('clear <botId>')
|
||||
.description('Clear all watch keywords')
|
||||
.option('--yes', 'Skip confirmation prompt')
|
||||
.action(async (botId: string, options: { yes?: boolean }) => {
|
||||
const client = await getTrpcClient();
|
||||
const b = await findBot(client, botId);
|
||||
const entries = readEntries(b);
|
||||
|
||||
if (entries.length === 0) {
|
||||
log.info('watchKeywords is already empty — nothing to do.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!options.yes) {
|
||||
const confirmed = await confirm(
|
||||
`Clear all ${entries.length} watch-keyword entr${entries.length === 1 ? 'y' : 'ies'} from this bot?`,
|
||||
);
|
||||
if (!confirmed) {
|
||||
console.log('Cancelled.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await client.agentBotProvider.update.mutate(buildPayload(b, []) as any);
|
||||
console.log(`${pc.green('✓')} Cleared watchKeywords on bot ${pc.bold(botId)}`);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Command Registration ─────────────────────────────────
|
||||
|
||||
export function registerBotCommand(program: Command) {
|
||||
@@ -608,6 +806,10 @@ export function registerBotCommand(program: Command) {
|
||||
name: 'group-allowlist',
|
||||
});
|
||||
|
||||
// ── watch-keywords (LOBE-8891) ────────────────────────
|
||||
|
||||
registerWatchKeywordsCommand(bot);
|
||||
|
||||
// ── remove ────────────────────────────────────────────
|
||||
|
||||
bot
|
||||
|
||||
@@ -6,6 +6,10 @@ import { fileURLToPath } from 'node:url';
|
||||
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
import {
|
||||
copyExternalRuntimeModulesToSource,
|
||||
getExternalRuntimeModulesFilesConfig,
|
||||
} from './external-runtime-deps.config.mjs';
|
||||
import {
|
||||
copyNativeModules,
|
||||
copyNativeModulesToSource,
|
||||
@@ -106,6 +110,7 @@ const config = {
|
||||
*/
|
||||
beforePack: async () => {
|
||||
await copyNativeModulesToSource();
|
||||
await copyExternalRuntimeModulesToSource();
|
||||
|
||||
console.info('📦 Downloading agent-browser binary...');
|
||||
execSync('node scripts/download-agent-browser.mjs', { stdio: 'inherit', cwd: __dirname });
|
||||
@@ -251,6 +256,8 @@ const config = {
|
||||
'!node_modules',
|
||||
// Then explicitly include native modules using object form (handles pnpm symlinks)
|
||||
...getNativeModulesFilesConfig(),
|
||||
// Include non-native runtime modules that are intentionally externalized from Vite.
|
||||
...getExternalRuntimeModulesFilesConfig(),
|
||||
],
|
||||
generateUpdatesFilesForAllChannels: true,
|
||||
linux: {
|
||||
|
||||
@@ -13,7 +13,8 @@ import {
|
||||
sharedRendererPlugins,
|
||||
sharedRollupOutput,
|
||||
} from '../../plugins/vite/sharedRendererConfig';
|
||||
import { getExternalDependencies } from './native-deps.config.mjs';
|
||||
import { externalRuntimeModules } from './external-runtime-deps.config.mjs';
|
||||
import { getNativeExternalDependencies } from './native-deps.config.mjs';
|
||||
|
||||
/**
|
||||
* Force `base: '/'` in renderer config. The `electron-vite` preset
|
||||
@@ -99,7 +100,11 @@ const desktopPackageJson = JSON.parse(
|
||||
readFileSync(path.resolve(__dirname, 'package.json'), 'utf8'),
|
||||
) as { version: string };
|
||||
const electronRuntimeExternals = ['electron'];
|
||||
const mainProcessRuntimeExternals = [...electronRuntimeExternals, 'node-mac-permissions'];
|
||||
const mainProcessRuntimeExternals = [
|
||||
...electronRuntimeExternals,
|
||||
...externalRuntimeModules,
|
||||
'node-mac-permissions',
|
||||
];
|
||||
|
||||
console.info(`[electron-vite.config.ts] Detected UPDATE_CHANNEL: ${updateChannel}`);
|
||||
|
||||
@@ -113,17 +118,45 @@ export default defineConfig({
|
||||
// bufferutil and utf-8-validate are optional peer deps of ws that may not be installed.
|
||||
external: [
|
||||
...mainProcessRuntimeExternals,
|
||||
...getExternalDependencies(),
|
||||
...getNativeExternalDependencies(),
|
||||
'bufferutil',
|
||||
'utf-8-validate',
|
||||
],
|
||||
output: {
|
||||
// Prevent debug package from being bundled into index.js to avoid side-effect pollution
|
||||
// Prevent shared deps from being bundled into index.js to avoid side-effect pollution.
|
||||
// Pattern: when a module is imported by both the main bundle (statically) and a
|
||||
// dynamic-import chunk (lazy loader), rolldown places it in main and makes the
|
||||
// chunk back-reference `require("./index.js")`. Electron's main entry isn't in
|
||||
// Node's CJS cache, so that require recompiles `index.js` from scratch — which
|
||||
// re-runs `new App()` at top-level and triggers `protocol.registerSchemesAsPrivileged`
|
||||
// *after* the app is ready → throw.
|
||||
//
|
||||
// Same root cause as the original `debug` regression fixed in #11827. Isolate
|
||||
// each shared module into its own vendor chunk so both ends reference the vendor
|
||||
// chunk instead of back-referencing main.
|
||||
manualChunks(id) {
|
||||
if (id.includes('node_modules/debug')) {
|
||||
return 'vendor-debug';
|
||||
}
|
||||
|
||||
// Small text/binary detection utilities in file-loaders/utils. Imported by
|
||||
// main (via `sniffBinaryFile`) and potentially by lazy loader chunks.
|
||||
// Explicitly enumerated to avoid catching `parser-utils.ts`, which pulls in
|
||||
// xmldom / yauzl / concat-stream — those belong in docx/pptx loader chunks.
|
||||
if (
|
||||
/packages\/file-loaders\/src\/utils\/(?:detectUtf16|isBinaryContent|isTextReadableFile)\.ts$/.test(
|
||||
id,
|
||||
)
|
||||
) {
|
||||
return 'vendor-file-loaders-utils';
|
||||
}
|
||||
|
||||
// jszip — imported by main (via some static path) AND by the docx loader chunk.
|
||||
// Without this, reading a .docx file throws the protocol re-init error.
|
||||
if (id.includes('node_modules/jszip')) {
|
||||
return 'vendor-jszip';
|
||||
}
|
||||
|
||||
// Split i18n json resources by namespace (ns), not by locale.
|
||||
// Example: ".../resources/locales/zh-CN/common.json?import" -> "locales-common"
|
||||
const normalizedId = id.replaceAll('\\', '/').split('?')[0];
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
copyModulesToSource,
|
||||
getDependenciesForModules,
|
||||
getModuleFilesConfig,
|
||||
} from './module-deps.config.mjs';
|
||||
|
||||
/**
|
||||
* Non-native modules intentionally externalized from the main-process bundle.
|
||||
*
|
||||
* These modules are not native dependencies. They stay external because their
|
||||
* process-level side effects must be owned by one Node runtime module instance.
|
||||
*/
|
||||
export const externalRuntimeModules = ['electron-log'];
|
||||
|
||||
/**
|
||||
* Get all dependencies for runtime external modules.
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function getAllExternalRuntimeDependencies() {
|
||||
return getDependenciesForModules(externalRuntimeModules);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate files config objects for non-native runtime external modules.
|
||||
* @returns {Array<{from: string, to: string, filter: string[]}>}
|
||||
*/
|
||||
export function getExternalRuntimeModulesFilesConfig() {
|
||||
return getModuleFilesConfig(externalRuntimeModules);
|
||||
}
|
||||
|
||||
export async function copyExternalRuntimeModulesToSource() {
|
||||
await copyModulesToSource(externalRuntimeModules, 'runtime external module');
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
/* eslint-disable no-console */
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const sourceNodeModules = path.join(__dirname, 'node_modules');
|
||||
|
||||
/**
|
||||
* Recursively resolve all dependencies of a module.
|
||||
* @param {string} moduleName - The module to resolve
|
||||
* @param {Set<string>} visited - Set of already visited modules
|
||||
* @param {string} nodeModulesPath - Path to node_modules directory
|
||||
* @returns {Set<string>} Set of all dependencies
|
||||
*/
|
||||
function resolveDependencies(moduleName, visited = new Set(), nodeModulesPath = sourceNodeModules) {
|
||||
if (visited.has(moduleName)) {
|
||||
return visited;
|
||||
}
|
||||
|
||||
// Always add the module name first. Workspace and optional platform modules
|
||||
// may not be materialized locally, but they still need stable package rules.
|
||||
visited.add(moduleName);
|
||||
|
||||
const packageJsonPath = path.join(nodeModulesPath, moduleName, 'package.json');
|
||||
|
||||
if (!fs.existsSync(packageJsonPath)) {
|
||||
return visited;
|
||||
}
|
||||
|
||||
try {
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||
const dependencies = packageJson.dependencies || {};
|
||||
const optionalDependencies = packageJson.optionalDependencies || {};
|
||||
|
||||
for (const dep of Object.keys(dependencies)) {
|
||||
resolveDependencies(dep, visited, nodeModulesPath);
|
||||
}
|
||||
|
||||
for (const dep of Object.keys(optionalDependencies)) {
|
||||
resolveDependencies(dep, visited, nodeModulesPath);
|
||||
}
|
||||
} catch {
|
||||
// Ignore unreadable package.json files; electron-builder will surface any
|
||||
// actual missing runtime dependency during packaging or startup.
|
||||
}
|
||||
|
||||
return visited;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all transitive dependencies for a set of top-level modules.
|
||||
* @param {string[]} modules
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function getDependenciesForModules(modules) {
|
||||
const allDeps = new Set();
|
||||
|
||||
for (const moduleName of modules) {
|
||||
const deps = resolveDependencies(moduleName);
|
||||
for (const dep of deps) {
|
||||
allDeps.add(dep);
|
||||
}
|
||||
}
|
||||
|
||||
return [...allDeps];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate glob patterns for electron-builder files config.
|
||||
* @param {string[]} modules
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function getModuleFilesPatterns(modules) {
|
||||
return getDependenciesForModules(modules).map((dep) => `node_modules/${dep}/**/*`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate object-form electron-builder files config.
|
||||
* Object form is required because pnpm symlinks are resolved before packaging.
|
||||
* @param {string[]} modules
|
||||
* @returns {Array<{from: string, to: string, filter: string[]}>}
|
||||
*/
|
||||
export function getModuleFilesConfig(modules) {
|
||||
return getDependenciesForModules(modules).map((dep) => ({
|
||||
filter: ['**/*'],
|
||||
from: `node_modules/${dep}`,
|
||||
to: `node_modules/${dep}`,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy module symlinks in source node_modules to real directories so
|
||||
* electron-builder can include them via file rules.
|
||||
* @param {string[]} modules
|
||||
* @param {string} label
|
||||
*/
|
||||
export async function copyModulesToSource(modules, label) {
|
||||
const deps = getDependenciesForModules(modules);
|
||||
|
||||
console.log(`📦 Resolving ${deps.length} ${label} symlinks for packaging...`);
|
||||
|
||||
for (const dep of deps) {
|
||||
const modulePath = path.join(sourceNodeModules, dep);
|
||||
|
||||
try {
|
||||
const stat = await fs.promises.lstat(modulePath);
|
||||
|
||||
if (stat.isSymbolicLink()) {
|
||||
const realPath = await fs.promises.realpath(modulePath);
|
||||
console.log(` 📎 ${dep} (resolving symlink)`);
|
||||
|
||||
await fs.promises.rm(modulePath, { force: true, recursive: true });
|
||||
await fs.promises.mkdir(path.dirname(modulePath), { recursive: true });
|
||||
await copyDir(realPath, modulePath);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(` ⏭️ ${dep} (skipped: ${err.code || err.message})`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`✅ ${label} symlinks resolved`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy modules to a destination node_modules directory, resolving symlinks.
|
||||
* @param {string[]} modules
|
||||
* @param {string} destNodeModules
|
||||
* @param {string} label
|
||||
*/
|
||||
export async function copyModulesToDirectory(modules, destNodeModules, label) {
|
||||
const deps = getDependenciesForModules(modules);
|
||||
|
||||
console.log(`📦 Copying ${deps.length} ${label} to unpacked directory...`);
|
||||
|
||||
for (const dep of deps) {
|
||||
const sourcePath = path.join(sourceNodeModules, dep);
|
||||
const destPath = path.join(destNodeModules, dep);
|
||||
|
||||
try {
|
||||
const stat = await fs.promises.lstat(sourcePath);
|
||||
|
||||
if (stat.isSymbolicLink()) {
|
||||
const realPath = await fs.promises.realpath(sourcePath);
|
||||
console.log(` 📎 ${dep} (symlink -> ${path.relative(sourceNodeModules, realPath)})`);
|
||||
|
||||
await fs.promises.mkdir(path.dirname(destPath), { recursive: true });
|
||||
await copyDir(realPath, destPath);
|
||||
} else if (stat.isDirectory()) {
|
||||
console.log(` 📁 ${dep}`);
|
||||
await fs.promises.mkdir(path.dirname(destPath), { recursive: true });
|
||||
await copyDir(sourcePath, destPath);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(` ⏭️ ${dep} (skipped: ${err.code || err.message})`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`✅ ${label} copied successfully`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively copy a directory.
|
||||
* @param {string} src
|
||||
* @param {string} dest
|
||||
*/
|
||||
async function copyDir(src, dest) {
|
||||
await fs.promises.mkdir(dest, { recursive: true });
|
||||
const entries = await fs.promises.readdir(src, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const srcPath = path.join(src, entry.name);
|
||||
const destPath = path.join(dest, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
await copyDir(srcPath, destPath);
|
||||
} else if (entry.isSymbolicLink()) {
|
||||
const realPath = await fs.promises.realpath(srcPath);
|
||||
const realStat = await fs.promises.stat(realPath);
|
||||
if (realStat.isDirectory()) {
|
||||
await copyDir(realPath, destPath);
|
||||
} else {
|
||||
await fs.promises.copyFile(realPath, destPath);
|
||||
}
|
||||
} else {
|
||||
await fs.promises.copyFile(srcPath, destPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable no-console */
|
||||
/**
|
||||
* Native dependencies configuration for Electron build
|
||||
*
|
||||
@@ -9,12 +8,15 @@
|
||||
*
|
||||
* This module automatically resolves the full dependency tree.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
import {
|
||||
copyModulesToDirectory,
|
||||
copyModulesToSource,
|
||||
getDependenciesForModules,
|
||||
getModuleFilesConfig,
|
||||
getModuleFilesPatterns,
|
||||
} from './module-deps.config.mjs';
|
||||
|
||||
/**
|
||||
* Get the current target platform
|
||||
@@ -40,78 +42,20 @@ export const nativeModules = [
|
||||
'node-screenshots',
|
||||
];
|
||||
|
||||
/**
|
||||
* Recursively resolve all dependencies of a module
|
||||
* @param {string} moduleName - The module to resolve
|
||||
* @param {Set<string>} visited - Set of already visited modules (to avoid cycles)
|
||||
* @param {string} nodeModulesPath - Path to node_modules directory
|
||||
* @returns {Set<string>} Set of all dependencies
|
||||
*/
|
||||
function resolveDependencies(
|
||||
moduleName,
|
||||
visited = new Set(),
|
||||
nodeModulesPath = path.join(__dirname, 'node_modules'),
|
||||
) {
|
||||
if (visited.has(moduleName)) {
|
||||
return visited;
|
||||
}
|
||||
|
||||
// Always add the module name first (important for workspace dependencies
|
||||
// that may not be in local node_modules but are declared in nativeModules)
|
||||
visited.add(moduleName);
|
||||
|
||||
const packageJsonPath = path.join(nodeModulesPath, moduleName, 'package.json');
|
||||
|
||||
// If module doesn't exist locally, still keep it in visited but skip dependency resolution
|
||||
if (!fs.existsSync(packageJsonPath)) {
|
||||
return visited;
|
||||
}
|
||||
|
||||
try {
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||
const dependencies = packageJson.dependencies || {};
|
||||
const optionalDependencies = packageJson.optionalDependencies || {};
|
||||
|
||||
// Resolve regular dependencies
|
||||
for (const dep of Object.keys(dependencies)) {
|
||||
resolveDependencies(dep, visited, nodeModulesPath);
|
||||
}
|
||||
|
||||
// Also resolve optional dependencies (important for native modules like @napi-rs/canvas
|
||||
// which have platform-specific binaries in optional deps)
|
||||
for (const dep of Object.keys(optionalDependencies)) {
|
||||
resolveDependencies(dep, visited, nodeModulesPath);
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors reading package.json
|
||||
}
|
||||
|
||||
return visited;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all dependencies for all native modules (including transitive dependencies)
|
||||
* @returns {string[]} Array of all dependency names
|
||||
*/
|
||||
export function getAllDependencies() {
|
||||
const allDeps = new Set();
|
||||
|
||||
for (const nativeModule of nativeModules) {
|
||||
const deps = resolveDependencies(nativeModule);
|
||||
for (const dep of deps) {
|
||||
allDeps.add(dep);
|
||||
}
|
||||
}
|
||||
|
||||
return [...allDeps];
|
||||
export function getAllNativeDependencies() {
|
||||
return getDependenciesForModules(nativeModules);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate glob patterns for electron-builder files config
|
||||
* @returns {string[]} Array of glob patterns
|
||||
*/
|
||||
export function getFilesPatterns() {
|
||||
return getAllDependencies().map((dep) => `node_modules/${dep}/**/*`);
|
||||
export function getNativeModuleFilesPatterns() {
|
||||
return getModuleFilesPatterns(nativeModules);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -120,11 +64,7 @@ export function getFilesPatterns() {
|
||||
* @returns {Array<{from: string, to: string, filter: string[]}>}
|
||||
*/
|
||||
export function getNativeModulesFilesConfig() {
|
||||
return getAllDependencies().map((dep) => ({
|
||||
filter: ['**/*'],
|
||||
from: `node_modules/${dep}`,
|
||||
to: `node_modules/${dep}`,
|
||||
}));
|
||||
return getModuleFilesConfig(nativeModules);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -132,15 +72,15 @@ export function getNativeModulesFilesConfig() {
|
||||
* @returns {string[]} Array of glob patterns
|
||||
*/
|
||||
export function getAsarUnpackPatterns() {
|
||||
return getAllDependencies().map((dep) => `node_modules/${dep}/**/*`);
|
||||
return getNativeModuleFilesPatterns();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the list of native dependencies for Vite external config
|
||||
* @returns {string[]} Array of dependency names
|
||||
*/
|
||||
export function getExternalDependencies() {
|
||||
return getAllDependencies();
|
||||
export function getNativeExternalDependencies() {
|
||||
return getAllNativeDependencies();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -149,39 +89,7 @@ export function getExternalDependencies() {
|
||||
* included in the asar archive (electron-builder glob doesn't follow symlinks).
|
||||
*/
|
||||
export async function copyNativeModulesToSource() {
|
||||
const fsPromises = await import('node:fs/promises');
|
||||
const deps = getAllDependencies();
|
||||
const sourceNodeModules = path.join(__dirname, 'node_modules');
|
||||
|
||||
console.log(`📦 Resolving ${deps.length} native module symlinks for packaging...`);
|
||||
|
||||
for (const dep of deps) {
|
||||
const modulePath = path.join(sourceNodeModules, dep);
|
||||
|
||||
try {
|
||||
const stat = await fsPromises.lstat(modulePath);
|
||||
|
||||
if (stat.isSymbolicLink()) {
|
||||
// Resolve the symlink to get the real path
|
||||
const realPath = await fsPromises.realpath(modulePath);
|
||||
console.log(` 📎 ${dep} (resolving symlink)`);
|
||||
|
||||
// Remove the symlink
|
||||
await fsPromises.rm(modulePath, { force: true, recursive: true });
|
||||
|
||||
// Create parent directory if needed (for scoped packages like @napi-rs)
|
||||
await fsPromises.mkdir(path.dirname(modulePath), { recursive: true });
|
||||
|
||||
// Copy the actual directory content in place of the symlink
|
||||
await copyDir(realPath, modulePath);
|
||||
}
|
||||
} catch (err) {
|
||||
// Module might not exist (optional dependency for different platform)
|
||||
console.log(` ⏭️ ${dep} (skipped: ${err.code || err.message})`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`✅ Native module symlinks resolved`);
|
||||
await copyModulesToSource(nativeModules, 'native module');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -190,72 +98,5 @@ export async function copyNativeModulesToSource() {
|
||||
* @param {string} destNodeModules - Destination node_modules path
|
||||
*/
|
||||
export async function copyNativeModules(destNodeModules) {
|
||||
const fsPromises = await import('node:fs/promises');
|
||||
const deps = getAllDependencies();
|
||||
const sourceNodeModules = path.join(__dirname, 'node_modules');
|
||||
|
||||
console.log(`📦 Copying ${deps.length} native modules to unpacked directory...`);
|
||||
|
||||
for (const dep of deps) {
|
||||
const sourcePath = path.join(sourceNodeModules, dep);
|
||||
const destPath = path.join(destNodeModules, dep);
|
||||
|
||||
try {
|
||||
// Check if source exists (might be a symlink)
|
||||
const stat = await fsPromises.lstat(sourcePath);
|
||||
|
||||
if (stat.isSymbolicLink()) {
|
||||
// Resolve the symlink to get the real path
|
||||
const realPath = await fsPromises.realpath(sourcePath);
|
||||
console.log(` 📎 ${dep} (symlink -> ${path.relative(sourceNodeModules, realPath)})`);
|
||||
|
||||
// Create destination directory
|
||||
await fsPromises.mkdir(path.dirname(destPath), { recursive: true });
|
||||
|
||||
// Copy the actual directory content (not the symlink)
|
||||
await copyDir(realPath, destPath);
|
||||
} else if (stat.isDirectory()) {
|
||||
console.log(` 📁 ${dep}`);
|
||||
await fsPromises.mkdir(path.dirname(destPath), { recursive: true });
|
||||
await copyDir(sourcePath, destPath);
|
||||
}
|
||||
} catch (err) {
|
||||
// Module might not exist (optional dependency for different platform)
|
||||
console.log(` ⏭️ ${dep} (skipped: ${err.code || err.message})`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`✅ Native modules copied successfully`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively copy a directory
|
||||
* @param {string} src - Source directory
|
||||
* @param {string} dest - Destination directory
|
||||
*/
|
||||
async function copyDir(src, dest) {
|
||||
const fsPromises = await import('node:fs/promises');
|
||||
|
||||
await fsPromises.mkdir(dest, { recursive: true });
|
||||
const entries = await fsPromises.readdir(src, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const srcPath = path.join(src, entry.name);
|
||||
const destPath = path.join(dest, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
await copyDir(srcPath, destPath);
|
||||
} else if (entry.isSymbolicLink()) {
|
||||
// For symlinks within the module, resolve and copy the actual file
|
||||
const realPath = await fsPromises.realpath(srcPath);
|
||||
const realStat = await fsPromises.stat(realPath);
|
||||
if (realStat.isDirectory()) {
|
||||
await copyDir(realPath, destPath);
|
||||
} else {
|
||||
await fsPromises.copyFile(realPath, destPath);
|
||||
}
|
||||
} else {
|
||||
await fsPromises.copyFile(srcPath, destPath);
|
||||
}
|
||||
}
|
||||
await copyModulesToDirectory(nativeModules, destNodeModules, 'native modules');
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
"dependencies": {
|
||||
"@lobehub/fluent-emoji": "^4.1.0",
|
||||
"@napi-rs/canvas": "^0.1.70",
|
||||
"electron-log": "^5.4.3",
|
||||
"get-windows": "^9.3.0",
|
||||
"node-screenshots": "^0.2.8"
|
||||
},
|
||||
@@ -79,7 +80,6 @@
|
||||
"electron-builder": "^26.8.1",
|
||||
"electron-devtools-installer": "4.0.0",
|
||||
"electron-is": "^3.0.0",
|
||||
"electron-log": "^5.4.3",
|
||||
"electron-store": "^8.2.0",
|
||||
"electron-updater": "^6.6.2",
|
||||
"electron-vite": "6.0.0-beta.1",
|
||||
@@ -109,7 +109,7 @@
|
||||
"typescript": "^5.9.3",
|
||||
"undici": "^7.16.0",
|
||||
"uuid": "^14.0.0",
|
||||
"vite": "^8.0.9",
|
||||
"vite": "8.0.12",
|
||||
"vitest": "^3.2.4",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
|
||||
@@ -1 +1,4 @@
|
||||
export const ELECTRON_BE_PROTOCOL_SCHEME = 'lobe-backend';
|
||||
|
||||
export const LOCAL_FILE_PROTOCOL_SCHEME = 'localfile';
|
||||
export const LOCAL_FILE_PROTOCOL_HOST = 'file';
|
||||
|
||||
@@ -35,6 +35,7 @@ export const STORE_DEFAULTS: ElectronMainStore = {
|
||||
gatewayEnabled: true,
|
||||
gatewayUrl: 'https://device-gateway.lobehub.com',
|
||||
locale: 'auto',
|
||||
localFileWorkspaceRoots: [],
|
||||
networkProxy: defaultProxySettings,
|
||||
shortcuts: DEFAULT_ELECTRON_DESKTOP_SHORTCUTS,
|
||||
storagePath: appStorageDir,
|
||||
|
||||
@@ -24,11 +24,15 @@ import {
|
||||
buildAgentInput,
|
||||
materializeImageToPath,
|
||||
normalizeImage,
|
||||
resolveCliSpawnPlan,
|
||||
} from '@lobechat/heterogeneous-agents/spawn';
|
||||
import { app as electronApp, BrowserWindow } from 'electron';
|
||||
|
||||
import { getHeterogeneousAgentDriver } from '@/modules/heterogeneousAgent';
|
||||
import type { HeterogeneousAgentImageAttachment } from '@/modules/heterogeneousAgent/types';
|
||||
import type {
|
||||
HeterogeneousAgentBuildPlan,
|
||||
HeterogeneousAgentImageAttachment,
|
||||
} from '@/modules/heterogeneousAgent/types';
|
||||
import { buildProxyEnv } from '@/modules/networkProxy/envBuilder';
|
||||
import { detectHeterogeneousCliCommand } from '@/modules/toolDetectors';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
@@ -868,169 +872,210 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
}
|
||||
const useStdin = spawnPlan.stdinPayload !== undefined;
|
||||
const cliArgs = spawnPlan.args;
|
||||
const resolvedCliSpawnPlan = await resolveCliSpawnPlan(session.command, cliArgs);
|
||||
|
||||
logger.info(
|
||||
'Spawning agent:',
|
||||
resolvedCliSpawnPlan.command,
|
||||
resolvedCliSpawnPlan.args.join(' '),
|
||||
`(cwd: ${cwd})`,
|
||||
);
|
||||
|
||||
// `detached: true` on Unix puts the child in a new process group so we
|
||||
// can SIGINT/SIGKILL the whole tree (claude + any tool subprocesses)
|
||||
// via `process.kill(-pid, sig)` on cancel. Without this, SIGINT to just
|
||||
// the claude binary can leave bash/grep/etc. tool children running and
|
||||
// the CLI hung waiting on them. Windows has different semantics — use
|
||||
// taskkill /T /F there; no detached flag needed.
|
||||
// Forward the user's proxy settings to the CLI. The main-process undici
|
||||
// dispatcher doesn't reach child processes — they need env vars.
|
||||
const proxyEnv = buildProxyEnv(this.app.storeManager.get('networkProxy'));
|
||||
|
||||
const spawnOptions = {
|
||||
cwd,
|
||||
detached: process.platform !== 'win32',
|
||||
env: { ...process.env, ...proxyEnv, ...session.env },
|
||||
stdio: [useStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'] as ['pipe' | 'ignore', 'pipe', 'pipe'],
|
||||
};
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
logger.info('Spawning agent:', session.command, cliArgs.join(' '), `(cwd: ${cwd})`);
|
||||
|
||||
// `detached: true` on Unix puts the child in a new process group so we
|
||||
// can SIGINT/SIGKILL the whole tree (claude + any tool subprocesses)
|
||||
// via `process.kill(-pid, sig)` on cancel. Without this, SIGINT to just
|
||||
// the claude binary can leave bash/grep/etc. tool children running and
|
||||
// the CLI hung waiting on them. Windows has different semantics — use
|
||||
// taskkill /T /F there; no detached flag needed.
|
||||
// Forward the user's proxy settings to the CLI. The main-process undici
|
||||
// dispatcher doesn't reach child processes — they need env vars.
|
||||
const proxyEnv = buildProxyEnv(this.app.storeManager.get('networkProxy'));
|
||||
|
||||
const proc = spawn(session.command, cliArgs, {
|
||||
cwd,
|
||||
detached: process.platform !== 'win32',
|
||||
env: { ...process.env, ...proxyEnv, ...session.env },
|
||||
stdio: [useStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'],
|
||||
const proc = spawn(resolvedCliSpawnPlan.command, resolvedCliSpawnPlan.args, spawnOptions);
|
||||
this.handleSpawnedAgentProcess({
|
||||
intervention,
|
||||
params,
|
||||
proc,
|
||||
reject,
|
||||
resolve,
|
||||
session,
|
||||
traceSession,
|
||||
useStdin,
|
||||
spawnPlan,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// In stdin mode, write the prepared payload and close stdin.
|
||||
if (useStdin && spawnPlan.stdinPayload !== undefined && proc.stdin) {
|
||||
void this.writeCliTraceFile(traceSession, 'stdin.txt', spawnPlan.stdinPayload);
|
||||
const stdin = proc.stdin as Writable;
|
||||
stdin.write(spawnPlan.stdinPayload, () => {
|
||||
stdin.end();
|
||||
});
|
||||
}
|
||||
|
||||
session.process = proc;
|
||||
|
||||
// Producer-side conversion (V3 contract): JSONL framing + adapter +
|
||||
// toStreamEvent all run inside the shared pipeline, so renderer + future
|
||||
// server `heteroIngest` see the same `AgentStreamEvent` wire shape with
|
||||
// no per-consumer adapter. The pipeline auto-wires the Codex
|
||||
// file-change line-stat tracker when `agentType === 'codex'`, so this
|
||||
// controller stays agent-agnostic.
|
||||
const pipeline = new AgentStreamPipeline({
|
||||
agentType: session.agentType,
|
||||
operationId: params.operationId,
|
||||
private handleSpawnedAgentProcess({
|
||||
intervention,
|
||||
params,
|
||||
proc,
|
||||
reject,
|
||||
resolve,
|
||||
session,
|
||||
spawnPlan,
|
||||
traceSession,
|
||||
useStdin,
|
||||
}: {
|
||||
intervention?: Awaited<ReturnType<HeterogeneousAgentCtr['setupInterventionForOp']>>;
|
||||
params: SendPromptParams;
|
||||
proc: ChildProcess;
|
||||
reject: (reason?: unknown) => void;
|
||||
resolve: () => void;
|
||||
session: AgentSession;
|
||||
spawnPlan: HeterogeneousAgentBuildPlan;
|
||||
traceSession: CliTraceSession | undefined;
|
||||
useStdin: boolean;
|
||||
}) {
|
||||
proc.on('error', (err) => {
|
||||
logger.error('Agent process error:', err);
|
||||
void this.writeCliTraceJson(traceSession, 'process-error.json', {
|
||||
message: err.message,
|
||||
name: err.name,
|
||||
});
|
||||
let stdoutBroadcastQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
const broadcastPipelineBatch = (produce: () => ReturnType<AgentStreamPipeline['push']>) => {
|
||||
stdoutBroadcastQueue = stdoutBroadcastQueue
|
||||
.then(async () => {
|
||||
const events = await produce();
|
||||
// Adapter-extracted CC/Codex session id powers `--resume` on the
|
||||
// next prompt; surface it through the existing `getSessionInfo`
|
||||
// IPC by mirroring the freshest value onto the session record.
|
||||
if (pipeline.sessionId && pipeline.sessionId !== session.agentSessionId) {
|
||||
session.agentSessionId = pipeline.sessionId;
|
||||
}
|
||||
for (const event of events) {
|
||||
this.broadcast('heteroAgentEvent', {
|
||||
event,
|
||||
sessionId: session.sessionId,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error('Failed to broadcast agent stream batch:', error);
|
||||
});
|
||||
};
|
||||
|
||||
// Stream stdout events through the producer pipeline.
|
||||
const stdout = proc.stdout as Readable;
|
||||
stdout.on('data', (chunk: Buffer) => {
|
||||
void this.appendCliTraceFile(traceSession, 'stdout.jsonl', chunk);
|
||||
broadcastPipelineBatch(() => pipeline.push(chunk));
|
||||
void this.flushCliTrace(traceSession);
|
||||
const sessionError = this.getSessionErrorPayload(err, session);
|
||||
this.broadcast('heteroAgentSessionError', {
|
||||
error: sessionError,
|
||||
sessionId: session.sessionId,
|
||||
});
|
||||
stdout.on('end', () => {
|
||||
broadcastPipelineBatch(() => pipeline.flush());
|
||||
reject(new Error(typeof sessionError === 'string' ? sessionError : sessionError.message));
|
||||
});
|
||||
|
||||
// In stdin mode, write the prepared payload and close stdin.
|
||||
if (useStdin && spawnPlan.stdinPayload !== undefined && proc.stdin) {
|
||||
void this.writeCliTraceFile(traceSession, 'stdin.txt', spawnPlan.stdinPayload);
|
||||
const stdin = proc.stdin as Writable;
|
||||
stdin.write(spawnPlan.stdinPayload, () => {
|
||||
stdin.end();
|
||||
});
|
||||
}
|
||||
|
||||
// Capture stderr
|
||||
const stderrChunks: string[] = [];
|
||||
const stderr = proc.stderr as Readable;
|
||||
stderr.on('data', (chunk: Buffer) => {
|
||||
void this.appendCliTraceFile(traceSession, 'stderr.log', chunk);
|
||||
stderrChunks.push(chunk.toString('utf8'));
|
||||
});
|
||||
session.process = proc;
|
||||
|
||||
proc.on('error', (err) => {
|
||||
logger.error('Agent process error:', err);
|
||||
void this.writeCliTraceJson(traceSession, 'process-error.json', {
|
||||
message: err.message,
|
||||
name: err.name,
|
||||
});
|
||||
void this.flushCliTrace(traceSession);
|
||||
const sessionError = this.getSessionErrorPayload(err, session);
|
||||
this.broadcast('heteroAgentSessionError', {
|
||||
error: sessionError,
|
||||
sessionId: session.sessionId,
|
||||
});
|
||||
reject(new Error(typeof sessionError === 'string' ? sessionError : sessionError.message));
|
||||
});
|
||||
// Producer-side conversion (V3 contract): JSONL framing + adapter +
|
||||
// toStreamEvent all run inside the shared pipeline, so renderer + future
|
||||
// server `heteroIngest` see the same `AgentStreamEvent` wire shape with
|
||||
// no per-consumer adapter. The pipeline auto-wires the Codex
|
||||
// file-change line-stat tracker when `agentType === 'codex'`, so this
|
||||
// controller stays agent-agnostic.
|
||||
const pipeline = new AgentStreamPipeline({
|
||||
agentType: session.agentType,
|
||||
operationId: params.operationId,
|
||||
});
|
||||
let stdoutBroadcastQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
proc.on('exit', (code, signal) => {
|
||||
// Node may emit `'exit'` BEFORE stdio finishes draining (documented:
|
||||
// child_process docs note "stdio streams might still be open" at exit
|
||||
// time). Wait for stdout to fully end/close so the `stdout.on('end')`
|
||||
// handler has scheduled `pipeline.flush()` onto `stdoutBroadcastQueue`,
|
||||
// THEN wait for the queue itself to settle. Without this two-step
|
||||
// gate, trailing flushed events (final synthesized tool_end /
|
||||
// tool_result) would race against — and lose to — the
|
||||
// `heteroAgentSessionComplete` broadcast, leaving renderer-side
|
||||
// persistence to finalize on incomplete state.
|
||||
const stdoutDrained = streamFinished(stdout, { writable: false }).catch(() => {
|
||||
/* end / close / error are all "done"; we still want to settle. */
|
||||
});
|
||||
|
||||
void stdoutDrained
|
||||
.then(() => stdoutBroadcastQueue)
|
||||
.finally(async () => {
|
||||
// Tear down the AskUserQuestion bridge / temp `mcp.json` for this
|
||||
// op. Pending MCP handlers get a `session_ended` cancellation so
|
||||
// they return cleanly even if CC was killed mid-tool-call.
|
||||
if (intervention) {
|
||||
await intervention.cleanup().catch((err) => {
|
||||
logger.warn('AskUserQuestion cleanup error:', err);
|
||||
});
|
||||
}
|
||||
|
||||
void this.writeCliTraceJson(traceSession, 'exit.json', {
|
||||
code,
|
||||
finishedAt: new Date().toISOString(),
|
||||
signal,
|
||||
const broadcastPipelineBatch = (produce: () => ReturnType<AgentStreamPipeline['push']>) => {
|
||||
stdoutBroadcastQueue = stdoutBroadcastQueue
|
||||
.then(async () => {
|
||||
const events = await produce();
|
||||
// Adapter-extracted CC/Codex session id powers `--resume` on the
|
||||
// next prompt; surface it through the existing `getSessionInfo`
|
||||
// IPC by mirroring the freshest value onto the session record.
|
||||
if (pipeline.sessionId && pipeline.sessionId !== session.agentSessionId) {
|
||||
session.agentSessionId = pipeline.sessionId;
|
||||
}
|
||||
for (const event of events) {
|
||||
this.broadcast('heteroAgentEvent', {
|
||||
event,
|
||||
sessionId: session.sessionId,
|
||||
});
|
||||
await this.flushCliTrace(traceSession);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error('Failed to broadcast agent stream batch:', error);
|
||||
});
|
||||
};
|
||||
|
||||
logger.info('Agent process exited:', { code, sessionId: session.sessionId, signal });
|
||||
session.process = undefined;
|
||||
// Stream stdout events through the producer pipeline.
|
||||
const stdout = proc.stdout as Readable;
|
||||
stdout.on('data', (chunk: Buffer) => {
|
||||
void this.appendCliTraceFile(traceSession, 'stdout.jsonl', chunk);
|
||||
broadcastPipelineBatch(() => pipeline.push(chunk));
|
||||
});
|
||||
stdout.on('end', () => {
|
||||
broadcastPipelineBatch(() => pipeline.flush());
|
||||
});
|
||||
|
||||
// If *we* killed it (cancel / stop / before-quit), treat the non-zero
|
||||
// exit as a clean shutdown — surfacing it as an error would make a
|
||||
// user-initiated cancel look like an agent failure, and an Electron
|
||||
// shutdown affecting OTHER running CC sessions would pollute their
|
||||
// topics with a misleading "Agent exited with code 143" message.
|
||||
if (session.cancelledByUs) {
|
||||
this.broadcast('heteroAgentSessionComplete', { sessionId: session.sessionId });
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
// Capture stderr
|
||||
const stderrChunks: string[] = [];
|
||||
const stderr = proc.stderr as Readable;
|
||||
stderr.on('data', (chunk: Buffer) => {
|
||||
void this.appendCliTraceFile(traceSession, 'stderr.log', chunk);
|
||||
stderrChunks.push(chunk.toString('utf8'));
|
||||
});
|
||||
|
||||
if (code === 0) {
|
||||
this.broadcast('heteroAgentSessionComplete', { sessionId: session.sessionId });
|
||||
resolve();
|
||||
} else {
|
||||
const stderrOutput = stderrChunks.join('').trim();
|
||||
const errorMsg = this.getExitErrorMessage(code, session, stderrOutput);
|
||||
const sessionError = this.getSessionErrorPayload(errorMsg, session);
|
||||
this.broadcast('heteroAgentSessionError', {
|
||||
error: sessionError,
|
||||
sessionId: session.sessionId,
|
||||
});
|
||||
reject(
|
||||
new Error(typeof sessionError === 'string' ? sessionError : sessionError.message),
|
||||
);
|
||||
}
|
||||
});
|
||||
proc.on('exit', (code, signal) => {
|
||||
// Node may emit `'exit'` BEFORE stdio finishes draining (documented:
|
||||
// child_process docs note "stdio streams might still be open" at exit
|
||||
// time). Wait for stdout to fully end/close so the `stdout.on('end')`
|
||||
// handler has scheduled `pipeline.flush()` onto `stdoutBroadcastQueue`,
|
||||
// THEN wait for the queue itself to settle. Without this two-step
|
||||
// gate, trailing flushed events (final synthesized tool_end /
|
||||
// tool_result) would race against — and lose to — the
|
||||
// `heteroAgentSessionComplete` broadcast, leaving renderer-side
|
||||
// persistence to finalize on incomplete state.
|
||||
const stdoutDrained = streamFinished(stdout, { writable: false }).catch(() => {
|
||||
/* end / close / error are all "done"; we still want to settle. */
|
||||
});
|
||||
|
||||
void stdoutDrained
|
||||
.then(() => stdoutBroadcastQueue)
|
||||
.finally(async () => {
|
||||
// Tear down the AskUserQuestion bridge / temp `mcp.json` for this
|
||||
// op. Pending MCP handlers get a `session_ended` cancellation so
|
||||
// they return cleanly even if CC was killed mid-tool-call.
|
||||
if (intervention) {
|
||||
await intervention.cleanup().catch((err) => {
|
||||
logger.warn('AskUserQuestion cleanup error:', err);
|
||||
});
|
||||
}
|
||||
|
||||
void this.writeCliTraceJson(traceSession, 'exit.json', {
|
||||
code,
|
||||
finishedAt: new Date().toISOString(),
|
||||
signal,
|
||||
});
|
||||
await this.flushCliTrace(traceSession);
|
||||
|
||||
logger.info('Agent process exited:', { code, sessionId: session.sessionId, signal });
|
||||
session.process = undefined;
|
||||
|
||||
// If *we* killed it (cancel / stop / before-quit), treat the non-zero
|
||||
// exit as a clean shutdown — surfacing it as an error would make a
|
||||
// user-initiated cancel look like an agent failure, and an Electron
|
||||
// shutdown affecting OTHER running CC sessions would pollute their
|
||||
// topics with a misleading "Agent exited with code 143" message.
|
||||
if (session.cancelledByUs) {
|
||||
this.broadcast('heteroAgentSessionComplete', { sessionId: session.sessionId });
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
if (code === 0) {
|
||||
this.broadcast('heteroAgentSessionComplete', { sessionId: session.sessionId });
|
||||
resolve();
|
||||
} else {
|
||||
const stderrOutput = stderrChunks.join('').trim();
|
||||
const errorMsg = this.getExitErrorMessage(code, session, stderrOutput);
|
||||
const sessionError = this.getSessionErrorPayload(errorMsg, session);
|
||||
this.broadcast('heteroAgentSessionError', {
|
||||
error: sessionError,
|
||||
sessionId: session.sessionId,
|
||||
});
|
||||
reject(
|
||||
new Error(typeof sessionError === 'string' ? sessionError : sessionError.message),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { constants } from 'node:fs';
|
||||
import { access, mkdir, readFile, realpath, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { access, mkdir, readdir, readFile, realpath, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
@@ -12,6 +12,10 @@ import {
|
||||
type GrepContentParams,
|
||||
type GrepContentResult,
|
||||
type ListLocalFileParams,
|
||||
type ListProjectSkillsParams,
|
||||
type ListProjectSkillsResult,
|
||||
type LocalFilePreviewUrlParams,
|
||||
type LocalFilePreviewUrlResult,
|
||||
type LocalMoveFilesResultItem,
|
||||
type LocalReadFileParams,
|
||||
type LocalReadFileResult,
|
||||
@@ -118,6 +122,62 @@ const collectProjectDirectories = (files: string[], root: string): ProjectFileIn
|
||||
return [...directories].map((directory) => createProjectFileEntry(root, directory, true));
|
||||
};
|
||||
|
||||
const SKILL_FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/;
|
||||
|
||||
// Cap recursion to guard against pathological directory trees.
|
||||
const MAX_SKILL_FILE_COUNT = 1000;
|
||||
|
||||
const listSkillFilesRecursive = async (dir: string): Promise<string[]> => {
|
||||
const results: string[] = [];
|
||||
const stack: string[] = [dir];
|
||||
|
||||
while (stack.length > 0 && results.length < MAX_SKILL_FILE_COUNT) {
|
||||
const current = stack.pop()!;
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(current, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith('.')) continue;
|
||||
const full = path.join(current, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
stack.push(full);
|
||||
} else if (entry.isFile()) {
|
||||
results.push(toPosixRelativePath(path.relative(dir, full)));
|
||||
if (results.length >= MAX_SKILL_FILE_COUNT) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return results.sort();
|
||||
};
|
||||
|
||||
// Parse a minimal YAML frontmatter block for SKILL.md files.
|
||||
// Only handles `key: value` lines; multi-line block scalars fall back to the first line.
|
||||
const parseSkillFrontmatter = (raw: string): Record<string, string> => {
|
||||
const match = raw.match(SKILL_FRONTMATTER_RE);
|
||||
if (!match) return {};
|
||||
|
||||
const fields: Record<string, string> = {};
|
||||
for (const line of match[1].split(/\r?\n/)) {
|
||||
const colonIdx = line.indexOf(':');
|
||||
if (colonIdx === -1) continue;
|
||||
const key = line.slice(0, colonIdx).trim();
|
||||
if (!key || key.startsWith('#')) continue;
|
||||
let value = line.slice(colonIdx + 1).trim();
|
||||
if (value.startsWith('|') || value.startsWith('>')) continue;
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
fields[key] = value;
|
||||
}
|
||||
return fields;
|
||||
};
|
||||
|
||||
const createDetectedProjectFileEntry = async (
|
||||
root: string,
|
||||
absolutePath: string,
|
||||
@@ -370,6 +430,28 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
};
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async getLocalFilePreviewUrl({
|
||||
path: filePath,
|
||||
workingDirectory,
|
||||
}: LocalFilePreviewUrlParams): Promise<LocalFilePreviewUrlResult> {
|
||||
try {
|
||||
const url = await this.app.localFileProtocolManager.createPreviewUrl({
|
||||
filePath,
|
||||
workspaceRoot: workingDirectory,
|
||||
});
|
||||
|
||||
if (!url) {
|
||||
return { error: 'File is outside the approved workspace', success: false };
|
||||
}
|
||||
|
||||
return { success: true, url };
|
||||
} catch (error) {
|
||||
logger.error('Failed to create local file preview URL:', error);
|
||||
return { error: (error as Error).message, success: false };
|
||||
}
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async handlePrepareSkillDirectory({
|
||||
forceRefresh,
|
||||
@@ -532,6 +614,7 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
requestedScope,
|
||||
root,
|
||||
});
|
||||
await this.approveProjectRootForPreview(root);
|
||||
|
||||
return {
|
||||
entries,
|
||||
@@ -560,6 +643,7 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
engine: fallback.engine,
|
||||
requestedScope,
|
||||
});
|
||||
await this.approveProjectRootForPreview(requestedScope);
|
||||
|
||||
return {
|
||||
entries,
|
||||
@@ -570,6 +654,61 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan agent skill directories under the project root and return parsed
|
||||
* frontmatter for each SKILL.md. Used by the hetero agent's working sidebar
|
||||
* to surface skills available in the current project.
|
||||
*/
|
||||
@IpcMethod()
|
||||
async listProjectSkills(params: ListProjectSkillsParams): Promise<ListProjectSkillsResult> {
|
||||
const root = params.scope;
|
||||
const sources = ['.agents/skills', '.claude/skills'] as const;
|
||||
|
||||
for (const source of sources) {
|
||||
const dir = path.join(root, source);
|
||||
try {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
const skills = (
|
||||
await Promise.all(
|
||||
entries
|
||||
.filter((entry) => entry.isDirectory() || entry.isSymbolicLink())
|
||||
.map(async (entry) => {
|
||||
const skillDir = path.join(dir, entry.name);
|
||||
const skillFile = path.join(skillDir, 'SKILL.md');
|
||||
try {
|
||||
const raw = await readFile(skillFile, 'utf8');
|
||||
const fields = parseSkillFrontmatter(raw);
|
||||
const files = await listSkillFilesRecursive(skillDir);
|
||||
return {
|
||||
description: fields.description || undefined,
|
||||
fileCount: files.length,
|
||||
files,
|
||||
name: fields.name || entry.name,
|
||||
path: skillFile,
|
||||
skillDir,
|
||||
source,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
)
|
||||
)
|
||||
.filter((skill): skill is NonNullable<typeof skill> => skill !== null)
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
if (skills.length > 0) {
|
||||
await this.approveProjectRootForPreview(root);
|
||||
return { root, skills, source };
|
||||
}
|
||||
} catch {
|
||||
// Directory does not exist or is not readable; try the next candidate.
|
||||
}
|
||||
}
|
||||
|
||||
return { root, skills: [], source: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle IPC event for local file search
|
||||
*/
|
||||
@@ -641,4 +780,12 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
logger.debug(`Editing file ${params.file_path}`, { replace_all: params.replace_all });
|
||||
return editLocalFile(params);
|
||||
}
|
||||
|
||||
private async approveProjectRootForPreview(root: string) {
|
||||
try {
|
||||
await this.app.localFileProtocolManager.approveIndexedProjectRoot(root);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to approve project preview root ${root}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import type {
|
||||
DetectAppsResult,
|
||||
OpenInAppParams,
|
||||
OpenInAppResult,
|
||||
} from '@lobechat/electron-client-ipc';
|
||||
|
||||
import { getCachedDetection } from '@/modules/openInApp/cache';
|
||||
import { detectApp } from '@/modules/openInApp/detectors';
|
||||
import { launchApp } from '@/modules/openInApp/launchers';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { ControllerModule, IpcMethod } from './index';
|
||||
|
||||
const logger = createLogger('controllers:OpenInAppCtr');
|
||||
|
||||
export default class OpenInAppCtr extends ControllerModule {
|
||||
static override readonly groupName = 'openInApp';
|
||||
|
||||
@IpcMethod()
|
||||
async detectApps(): Promise<DetectAppsResult> {
|
||||
const apps = await getCachedDetection();
|
||||
return { apps };
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async openInApp({ appId, path }: OpenInAppParams): Promise<OpenInAppResult> {
|
||||
// Re-validate installation status before launching: per spec, the main
|
||||
// process must reject if the app disappeared between probe and launch.
|
||||
const installed = await detectApp(appId, process.platform);
|
||||
if (!installed) {
|
||||
logger.warn(`openInApp: ${appId} reported not installed`);
|
||||
return { error: `${appId} is not installed`, success: false };
|
||||
}
|
||||
|
||||
const result = await launchApp(appId, path, process.platform);
|
||||
if (result.success) {
|
||||
logger.info(`openInApp: launched ${appId} with path ${path}`);
|
||||
} else {
|
||||
logger.error(`openInApp: launch failed for ${appId}: ${result.error}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -186,6 +186,19 @@ export default class SystemController extends ControllerModule {
|
||||
const folderPath = result.filePaths[0];
|
||||
const repoType = await detectRepoType(folderPath);
|
||||
|
||||
try {
|
||||
const approvedRoot = await this.app.localFileProtocolManager.approveWorkspaceRoot(folderPath);
|
||||
|
||||
if (approvedRoot) {
|
||||
const storedRoots = this.app.storeManager.get('localFileWorkspaceRoots', []);
|
||||
if (!storedRoots.includes(approvedRoot)) {
|
||||
this.app.storeManager.set('localFileWorkspaceRoots', [approvedRoot, ...storedRoots]);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to approve local file workspace root ${folderPath}:`, error);
|
||||
}
|
||||
|
||||
return { path: folderPath, repoType };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { access, mkdtemp, readdir, readFile, rm, unlink, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import * as os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { PassThrough } from 'node:stream';
|
||||
|
||||
@@ -9,6 +9,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import HeterogeneousAgentCtr from '../HeterogeneousAgentCtr';
|
||||
|
||||
vi.mock('node:os', async () => {
|
||||
const actual = await vi.importActual<typeof os>('node:os');
|
||||
return { ...actual, platform: vi.fn(() => 'linux') };
|
||||
});
|
||||
|
||||
const FAKE_DESKTOP_PATH = '/Users/fake/Desktop';
|
||||
|
||||
const { mockGetAllWindows } = vi.hoisted(() => ({
|
||||
@@ -111,7 +116,7 @@ describe('HeterogeneousAgentCtr', () => {
|
||||
let appStoragePath: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
appStoragePath = await mkdtemp(path.join(tmpdir(), 'lobehub-hetero-'));
|
||||
appStoragePath = await mkdtemp(path.join(os.tmpdir(), 'lobehub-hetero-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -817,7 +822,7 @@ describe('HeterogeneousAgentCtr', () => {
|
||||
* it like a real pending intervention and tries to unlink it.
|
||||
*/
|
||||
const seedPendingIntervention = async (ctr: HeterogeneousAgentCtr, opId: string) => {
|
||||
const tmpConfigPath = path.join(tmpdir(), `lobe-cc-mcp-test-${opId}.json`);
|
||||
const tmpConfigPath = path.join(os.tmpdir(), `lobe-cc-mcp-test-${opId}.json`);
|
||||
await writeFile(tmpConfigPath, '{"mcpServers":{}}');
|
||||
const slot = {
|
||||
bridge: {} as any,
|
||||
|
||||
@@ -84,6 +84,12 @@ const mockContentSearchService = {
|
||||
checkToolAvailable: vi.fn(),
|
||||
};
|
||||
|
||||
const mockLocalFileProtocolManager = {
|
||||
approveIndexedProjectRoot: vi.fn(),
|
||||
approveProjectRootFromScope: vi.fn(),
|
||||
createPreviewUrl: vi.fn(),
|
||||
};
|
||||
|
||||
// Mock makeSureDirExist
|
||||
vi.mock('@/utils/file-system', () => ({
|
||||
makeSureDirExist: vi.fn(),
|
||||
@@ -98,6 +104,7 @@ const mockApp = {
|
||||
}
|
||||
return mockSearchService;
|
||||
}),
|
||||
localFileProtocolManager: mockLocalFileProtocolManager,
|
||||
toolDetectorManager: {
|
||||
getBestTool: vi.fn(() => null), // No external tools available, use Node.js fallback
|
||||
},
|
||||
@@ -180,6 +187,42 @@ describe('LocalFileCtr', () => {
|
||||
// they exercise real fs + file-loaders without fighting the heavy mocks
|
||||
// this suite needs for execa-driven tools, electron, and the like.
|
||||
|
||||
describe('getLocalFilePreviewUrl', () => {
|
||||
it('should return a main-issued preview URL for an approved workspace file', async () => {
|
||||
mockLocalFileProtocolManager.createPreviewUrl.mockResolvedValue(
|
||||
'localfile://file/workspace/app.ts?token=abc',
|
||||
);
|
||||
|
||||
const result = await localFileCtr.getLocalFilePreviewUrl({
|
||||
path: '/workspace/app.ts',
|
||||
workingDirectory: '/workspace',
|
||||
});
|
||||
|
||||
expect(mockLocalFileProtocolManager.createPreviewUrl).toHaveBeenCalledWith({
|
||||
filePath: '/workspace/app.ts',
|
||||
workspaceRoot: '/workspace',
|
||||
});
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
url: 'localfile://file/workspace/app.ts?token=abc',
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject preview URL creation outside an approved workspace', async () => {
|
||||
mockLocalFileProtocolManager.createPreviewUrl.mockResolvedValue(null);
|
||||
|
||||
const result = await localFileCtr.getLocalFilePreviewUrl({
|
||||
path: '/Users/alice/.ssh/id_rsa',
|
||||
workingDirectory: '/workspace',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
error: 'File is outside the approved workspace',
|
||||
success: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleWriteFile', () => {
|
||||
it('should write file successfully', async () => {
|
||||
vi.mocked(mockFsPromises.mkdir).mockResolvedValue(undefined);
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import type { DetectedApp, OpenInAppResult } from '@lobechat/electron-client-ipc';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { App } from '@/core/App';
|
||||
import type { IpcContext } from '@/utils/ipc';
|
||||
import { IpcHandler } from '@/utils/ipc/base';
|
||||
|
||||
import OpenInAppCtr from '../OpenInAppCtr';
|
||||
|
||||
const { getCachedDetectionMock, detectAppMock, launchAppMock, ipcHandlers, ipcMainHandleMock } =
|
||||
vi.hoisted(() => {
|
||||
const handlers = new Map<string, (event: any, ...args: any[]) => any>();
|
||||
const handle = vi.fn((channel: string, handler: any) => {
|
||||
handlers.set(channel, handler);
|
||||
});
|
||||
return {
|
||||
detectAppMock: vi.fn(),
|
||||
getCachedDetectionMock: vi.fn(),
|
||||
ipcHandlers: handlers,
|
||||
ipcMainHandleMock: handle,
|
||||
launchAppMock: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const invokeIpc = async <T = any>(
|
||||
channel: string,
|
||||
payload?: any,
|
||||
context?: Partial<IpcContext>,
|
||||
): Promise<T> => {
|
||||
const handler = ipcHandlers.get(channel);
|
||||
if (!handler) throw new Error(`IPC handler for ${channel} not found`);
|
||||
|
||||
const fakeEvent = {
|
||||
sender: context?.sender ?? ({ id: 'test' } as any),
|
||||
};
|
||||
|
||||
if (payload === undefined) {
|
||||
return handler(fakeEvent);
|
||||
}
|
||||
|
||||
return handler(fakeEvent, payload);
|
||||
};
|
||||
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
handle: ipcMainHandleMock,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/modules/openInApp/cache', () => ({
|
||||
getCachedDetection: getCachedDetectionMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/modules/openInApp/detectors', () => ({
|
||||
detectApp: detectAppMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/modules/openInApp/launchers', () => ({
|
||||
launchApp: launchAppMock,
|
||||
}));
|
||||
|
||||
const mockApp = {} as unknown as App;
|
||||
|
||||
describe('OpenInAppCtr', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
ipcHandlers.clear();
|
||||
ipcMainHandleMock.mockClear();
|
||||
(IpcHandler.getInstance() as any).registeredChannels?.clear();
|
||||
new OpenInAppCtr(mockApp);
|
||||
});
|
||||
|
||||
describe('detectApps', () => {
|
||||
it('should call getCachedDetection and return the apps list', async () => {
|
||||
const apps: DetectedApp[] = [
|
||||
{ displayName: 'Visual Studio Code', id: 'vscode', installed: true },
|
||||
{ displayName: 'Cursor', id: 'cursor', installed: false },
|
||||
];
|
||||
getCachedDetectionMock.mockResolvedValue(apps);
|
||||
|
||||
const result = await invokeIpc('openInApp.detectApps');
|
||||
|
||||
expect(getCachedDetectionMock).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual({ apps });
|
||||
});
|
||||
});
|
||||
|
||||
describe('openInApp', () => {
|
||||
it('should launch the app when installed', async () => {
|
||||
detectAppMock.mockResolvedValue(true);
|
||||
const launchResult: OpenInAppResult = { success: true };
|
||||
launchAppMock.mockResolvedValue(launchResult);
|
||||
|
||||
const result = await invokeIpc('openInApp.openInApp', {
|
||||
appId: 'vscode',
|
||||
path: '/tmp/project',
|
||||
});
|
||||
|
||||
expect(detectAppMock).toHaveBeenCalledWith('vscode', process.platform);
|
||||
expect(launchAppMock).toHaveBeenCalledWith('vscode', '/tmp/project', process.platform);
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('should not launch and return error when app is not installed', async () => {
|
||||
detectAppMock.mockResolvedValue(false);
|
||||
|
||||
const result = await invokeIpc('openInApp.openInApp', {
|
||||
appId: 'cursor',
|
||||
path: '/tmp/project',
|
||||
});
|
||||
|
||||
expect(detectAppMock).toHaveBeenCalledWith('cursor', process.platform);
|
||||
expect(launchAppMock).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
error: 'cursor is not installed',
|
||||
success: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass through launch errors when launchApp fails', async () => {
|
||||
detectAppMock.mockResolvedValue(true);
|
||||
const launchResult: OpenInAppResult = {
|
||||
error: 'Path not found: /tmp/missing',
|
||||
success: false,
|
||||
};
|
||||
launchAppMock.mockResolvedValue(launchResult);
|
||||
|
||||
const result = await invokeIpc('openInApp.openInApp', {
|
||||
appId: 'vscode',
|
||||
path: '/tmp/missing',
|
||||
});
|
||||
|
||||
expect(detectAppMock).toHaveBeenCalledWith('vscode', process.platform);
|
||||
expect(launchAppMock).toHaveBeenCalledWith('vscode', '/tmp/missing', process.platform);
|
||||
expect(result).toEqual(launchResult);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import McpInstallCtr from './McpInstallCtr';
|
||||
import MenuController from './MenuCtr';
|
||||
import NetworkProxyCtr from './NetworkProxyCtr';
|
||||
import NotificationCtr from './NotificationCtr';
|
||||
import OpenInAppCtr from './OpenInAppCtr';
|
||||
import RemoteServerConfigCtr from './RemoteServerConfigCtr';
|
||||
import RemoteServerSyncCtr from './RemoteServerSyncCtr';
|
||||
import ScreenCaptureCtr from './ScreenCaptureCtr';
|
||||
@@ -37,6 +38,7 @@ export const controllerIpcConstructors = [
|
||||
MenuController,
|
||||
NetworkProxyCtr,
|
||||
NotificationCtr,
|
||||
OpenInAppCtr,
|
||||
RemoteServerConfigCtr,
|
||||
RemoteServerSyncCtr,
|
||||
ScreenCaptureCtr,
|
||||
|
||||
@@ -31,6 +31,7 @@ import { createLogger } from '@/utils/logger';
|
||||
import { BrowserManager } from './browser/BrowserManager';
|
||||
import { I18nManager } from './infrastructure/I18nManager';
|
||||
import { IoCContainer } from './infrastructure/IoCContainer';
|
||||
import { LocalFileProtocolManager } from './infrastructure/LocalFileProtocolManager';
|
||||
import { ProtocolManager } from './infrastructure/ProtocolManager';
|
||||
import { RendererUrlManager } from './infrastructure/RendererUrlManager';
|
||||
import { StaticFileServerManager } from './infrastructure/StaticFileServerManager';
|
||||
@@ -62,6 +63,7 @@ export class App {
|
||||
staticFileServerManager: StaticFileServerManager;
|
||||
protocolManager: ProtocolManager;
|
||||
rendererUrlManager: RendererUrlManager;
|
||||
localFileProtocolManager: LocalFileProtocolManager;
|
||||
toolDetectorManager: ToolDetectorManager;
|
||||
screenCaptureManager: ScreenCaptureManager;
|
||||
chromeFlags: string[] = ['OverlayScrollbar', 'FluentOverlayScrollbar', 'FluentScrollbar'];
|
||||
@@ -102,6 +104,10 @@ export class App {
|
||||
this.storeManager = new StoreManager(this);
|
||||
|
||||
this.rendererUrlManager = new RendererUrlManager();
|
||||
this.localFileProtocolManager = new LocalFileProtocolManager();
|
||||
void this.localFileProtocolManager.approveWorkspaceRoots(
|
||||
this.storeManager.get('localFileWorkspaceRoots', []),
|
||||
);
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{
|
||||
privileges: {
|
||||
@@ -114,6 +120,7 @@ export class App {
|
||||
scheme: ELECTRON_BE_PROTOCOL_SCHEME,
|
||||
},
|
||||
this.rendererUrlManager.protocolScheme,
|
||||
this.localFileProtocolManager.protocolScheme,
|
||||
]);
|
||||
|
||||
// load controllers
|
||||
@@ -152,6 +159,10 @@ export class App {
|
||||
// should register before app ready
|
||||
this.rendererUrlManager.configureRendererLoader();
|
||||
|
||||
// Serves arbitrary local files (e.g. project file previews) via
|
||||
// `localfile://` to the renderer. Active in both dev and prod.
|
||||
this.localFileProtocolManager.registerHandler();
|
||||
|
||||
// initialize protocol handlers
|
||||
this.protocolManager.initialize();
|
||||
|
||||
|
||||
@@ -115,9 +115,9 @@ vi.mock('../infrastructure/I18nManager', () => ({
|
||||
|
||||
vi.mock('../infrastructure/StoreManager', () => ({
|
||||
StoreManager: vi.fn().mockImplementation(() => ({
|
||||
get: vi.fn((key) => {
|
||||
if (key === 'storagePath') return '/mock/storage/path';
|
||||
return undefined;
|
||||
get: vi.fn((_key, defaultValue) => {
|
||||
if (_key === 'storagePath') return '/mock/storage/path';
|
||||
return defaultValue;
|
||||
}),
|
||||
set: vi.fn(),
|
||||
})),
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { readFile, realpath, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
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';
|
||||
|
||||
const LOCAL_FILE_PROTOCOL_PRIVILEGES = {
|
||||
allowServiceWorkers: false,
|
||||
bypassCSP: false,
|
||||
corsEnabled: true,
|
||||
secure: true,
|
||||
standard: true,
|
||||
stream: true,
|
||||
supportFetchAPI: true,
|
||||
} as const;
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
const isPathWithinRoot = (targetPath: string, rootPath: string): boolean => {
|
||||
const relative = path.relative(rootPath, targetPath);
|
||||
return (
|
||||
relative === '' || (!!relative && !relative.startsWith('..') && !path.isAbsolute(relative))
|
||||
);
|
||||
};
|
||||
|
||||
const buildLocalFileUrl = (absolutePath: string, token: string): string => {
|
||||
const forwardSlashed = absolutePath.replaceAll('\\', '/');
|
||||
const stripped = forwardSlashed.startsWith('/') ? forwardSlashed.slice(1) : forwardSlashed;
|
||||
const encoded = stripped.split('/').map(encodeURIComponent).join('/');
|
||||
const url = new URL(`${LOCAL_FILE_PROTOCOL_SCHEME}://${LOCAL_FILE_PROTOCOL_HOST}/${encoded}`);
|
||||
url.searchParams.set('token', token);
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
interface PreviewTokenRecord {
|
||||
expiresAt: number;
|
||||
realPath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom `localfile://` protocol for project file previews.
|
||||
*
|
||||
* URL shape: `localfile://file/<percent-encoded-absolute-path>?token=<main-issued-token>`
|
||||
* - host is fixed to `file` so the scheme behaves as `standard`
|
||||
* - the absolute path is encoded in the URL pathname
|
||||
* - every request must carry a short-lived token minted by the main process
|
||||
*
|
||||
* Examples:
|
||||
* localfile://file//Users/alice/project/cat.png?token=...
|
||||
* localfile://file/C:/Users/alice/project/cat.png?token=...
|
||||
*/
|
||||
export class LocalFileProtocolManager {
|
||||
private readonly approvedWorkspaceRoots = new Set<string>();
|
||||
|
||||
private readonly indexedProjectRoots = new Set<string>();
|
||||
|
||||
private handlerRegistered = false;
|
||||
|
||||
private readonly previewTokens = new Map<string, PreviewTokenRecord>();
|
||||
|
||||
get protocolScheme() {
|
||||
return {
|
||||
privileges: LOCAL_FILE_PROTOCOL_PRIVILEGES,
|
||||
scheme: LOCAL_FILE_PROTOCOL_SCHEME,
|
||||
};
|
||||
}
|
||||
|
||||
registerHandler() {
|
||||
if (this.handlerRegistered) return;
|
||||
|
||||
const register = () => {
|
||||
if (this.handlerRegistered) return;
|
||||
|
||||
protocol.handle(LOCAL_FILE_PROTOCOL_SCHEME, async (request) => {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (url.hostname !== LOCAL_FILE_PROTOCOL_HOST) {
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
const resolvedPath = this.resolveFilePath(url.pathname);
|
||||
if (!resolvedPath) {
|
||||
return new Response('Invalid path', { status: 400 });
|
||||
}
|
||||
|
||||
const token = url.searchParams.get('token');
|
||||
if (!token) {
|
||||
return new Response('Forbidden', { status: 403 });
|
||||
}
|
||||
|
||||
if (!this.hasPreviewToken(token)) {
|
||||
return new Response('Forbidden', { status: 403 });
|
||||
}
|
||||
|
||||
const realResolvedPath = normalizeAbsolutePath(await realpath(resolvedPath));
|
||||
if (!realResolvedPath || !this.verifyPreviewToken(token, realResolvedPath)) {
|
||||
return new Response('Forbidden', { status: 403 });
|
||||
}
|
||||
|
||||
const fileStat = await stat(realResolvedPath);
|
||||
if (!fileStat.isFile()) {
|
||||
return new Response('Not a file', { status: 404 });
|
||||
}
|
||||
|
||||
const buffer = await readFile(realResolvedPath);
|
||||
const headers = new Headers();
|
||||
headers.set('Content-Type', getMimeType(realResolvedPath));
|
||||
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
|
||||
// re-reading large images during scrolling/refresh.
|
||||
headers.set('Cache-Control', 'private, max-age=60');
|
||||
|
||||
return new Response(buffer, { headers, status: 200 });
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code === 'ENOENT' || code === 'ENOTDIR') {
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}
|
||||
if (code === 'EACCES' || code === 'EPERM') {
|
||||
return new Response('Forbidden', { status: 403 });
|
||||
}
|
||||
logger.error(`Failed to serve localfile request ${request.url}:`, error);
|
||||
return new Response('Internal Server Error', { status: 500 });
|
||||
}
|
||||
});
|
||||
|
||||
this.handlerRegistered = true;
|
||||
logger.debug(`Registered ${LOCAL_FILE_PROTOCOL_SCHEME}:// handler`);
|
||||
};
|
||||
|
||||
if (app.isReady()) {
|
||||
register();
|
||||
} else {
|
||||
app.whenReady().then(register);
|
||||
}
|
||||
}
|
||||
|
||||
async approveWorkspaceRoot(rootPath: string): Promise<string | null> {
|
||||
const normalizedRoot = normalizeAbsolutePath(rootPath);
|
||||
if (!normalizedRoot) return null;
|
||||
|
||||
const realRoot = normalizeAbsolutePath(await realpath(normalizedRoot));
|
||||
if (!realRoot) return null;
|
||||
|
||||
this.approvedWorkspaceRoots.add(realRoot);
|
||||
return realRoot;
|
||||
}
|
||||
|
||||
async approveWorkspaceRoots(rootPaths: string[] = []): Promise<string[]> {
|
||||
const approvedRoots = await Promise.allSettled(
|
||||
rootPaths.map((rootPath) => this.approveWorkspaceRoot(rootPath)),
|
||||
);
|
||||
|
||||
return approvedRoots
|
||||
.map((result) => (result.status === 'fulfilled' ? result.value : null))
|
||||
.filter((rootPath): rootPath is string => !!rootPath);
|
||||
}
|
||||
|
||||
async approveProjectRootFromScope({
|
||||
projectRoot,
|
||||
requestedScope,
|
||||
}: {
|
||||
projectRoot: string;
|
||||
requestedScope: string;
|
||||
}): Promise<string | null> {
|
||||
const [realProjectRoot, realRequestedScope] = await Promise.all([
|
||||
realpath(projectRoot),
|
||||
realpath(requestedScope),
|
||||
]);
|
||||
const normalizedProjectRoot = normalizeAbsolutePath(realProjectRoot);
|
||||
const normalizedRequestedScope = normalizeAbsolutePath(realRequestedScope);
|
||||
if (!normalizedProjectRoot || !normalizedRequestedScope) return null;
|
||||
|
||||
const scopeIsApproved = [...this.approvedWorkspaceRoots].some(
|
||||
(approvedRoot) =>
|
||||
normalizedRequestedScope === approvedRoot ||
|
||||
isPathWithinRoot(normalizedRequestedScope, approvedRoot),
|
||||
);
|
||||
if (!scopeIsApproved) return null;
|
||||
|
||||
this.approvedWorkspaceRoots.add(normalizedProjectRoot);
|
||||
return normalizedProjectRoot;
|
||||
}
|
||||
|
||||
async approveIndexedProjectRoot(projectRoot: string): Promise<string | null> {
|
||||
const normalizedProjectRoot = normalizeAbsolutePath(projectRoot);
|
||||
if (!normalizedProjectRoot) return null;
|
||||
|
||||
const realProjectRoot = normalizeAbsolutePath(await realpath(normalizedProjectRoot));
|
||||
if (!realProjectRoot) return null;
|
||||
|
||||
this.indexedProjectRoots.add(realProjectRoot);
|
||||
return realProjectRoot;
|
||||
}
|
||||
|
||||
async createPreviewUrl({
|
||||
filePath,
|
||||
workspaceRoot,
|
||||
}: {
|
||||
filePath: string;
|
||||
workspaceRoot: string;
|
||||
}): Promise<string | null> {
|
||||
const normalizedFilePath = normalizeAbsolutePath(filePath);
|
||||
const normalizedWorkspaceRoot = normalizeAbsolutePath(workspaceRoot);
|
||||
if (!normalizedFilePath || !normalizedWorkspaceRoot) return null;
|
||||
|
||||
const [realFilePath, realWorkspaceRoot] = await Promise.all([
|
||||
realpath(normalizedFilePath),
|
||||
realpath(normalizedWorkspaceRoot),
|
||||
]);
|
||||
const normalizedRealFilePath = normalizeAbsolutePath(realFilePath);
|
||||
const normalizedRealWorkspaceRoot = normalizeAbsolutePath(realWorkspaceRoot);
|
||||
|
||||
if (!normalizedRealFilePath || !normalizedRealWorkspaceRoot) return null;
|
||||
if (
|
||||
!this.approvedWorkspaceRoots.has(normalizedRealWorkspaceRoot) &&
|
||||
!this.indexedProjectRoots.has(normalizedRealWorkspaceRoot)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (!isPathWithinRoot(normalizedRealFilePath, normalizedRealWorkspaceRoot)) return null;
|
||||
|
||||
this.cleanupExpiredTokens();
|
||||
|
||||
const token = randomUUID();
|
||||
this.previewTokens.set(token, {
|
||||
expiresAt: Date.now() + PREVIEW_TOKEN_TTL_MS,
|
||||
realPath: normalizedRealFilePath,
|
||||
});
|
||||
|
||||
return buildLocalFileUrl(normalizedFilePath, token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode the URL pathname back into an absolute filesystem path.
|
||||
*
|
||||
* Pathname examples produced by `new URL('localfile://file//abs/path')`:
|
||||
* posix: `//abs/path` -> `/abs/path`
|
||||
* windows: `/C:/abs/path` -> `C:/abs/path`
|
||||
*
|
||||
* Returns null when the path is non-absolute or escapes via segments we
|
||||
* cannot safely normalize (defense-in-depth, not a sandbox).
|
||||
*/
|
||||
private resolveFilePath(pathname: string): string | null {
|
||||
let decoded: string;
|
||||
try {
|
||||
decoded = decodeURIComponent(pathname);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Strip the single leading slash inserted by URL parsing on standard
|
||||
// schemes; what remains should already be an absolute filesystem path.
|
||||
let candidate = decoded.startsWith('/') ? decoded.slice(1) : decoded;
|
||||
if (!candidate) return null;
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
// posix-style absolute path won't have a drive letter; treat as invalid
|
||||
// on Windows.
|
||||
candidate = candidate.replaceAll('/', '\\');
|
||||
} else if (!candidate.startsWith('/')) {
|
||||
// We expect an absolute POSIX path: `localfile://file//abs/path` yields
|
||||
// pathname `//abs/path` -> after stripping one slash -> `/abs/path`.
|
||||
candidate = `/${candidate}`;
|
||||
}
|
||||
|
||||
const normalized = path.normalize(candidate);
|
||||
if (!path.isAbsolute(normalized)) return null;
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private cleanupExpiredTokens() {
|
||||
const now = Date.now();
|
||||
for (const [token, record] of this.previewTokens) {
|
||||
if (record.expiresAt <= now) {
|
||||
this.previewTokens.delete(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private hasPreviewToken(token: string): boolean {
|
||||
const record = this.previewTokens.get(token);
|
||||
if (!record) return false;
|
||||
|
||||
if (record.expiresAt <= Date.now()) {
|
||||
this.previewTokens.delete(token);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private verifyPreviewToken(token: string, realResolvedPath: string): boolean {
|
||||
const record = this.previewTokens.get(token);
|
||||
if (!record) return false;
|
||||
|
||||
return record.realPath === realResolvedPath;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { LocalFileProtocolManager } from '../LocalFileProtocolManager';
|
||||
|
||||
const { mockApp, mockProtocol, mockReadFile, mockRealpath, mockStat, protocolHandlerRef } =
|
||||
vi.hoisted(() => {
|
||||
const protocolHandlerRef = { current: null as any };
|
||||
|
||||
return {
|
||||
mockApp: {
|
||||
isReady: vi.fn().mockReturnValue(true),
|
||||
whenReady: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
mockProtocol: {
|
||||
handle: vi.fn((_scheme: string, handler: any) => {
|
||||
protocolHandlerRef.current = handler;
|
||||
}),
|
||||
},
|
||||
mockReadFile: vi.fn(),
|
||||
mockRealpath: vi.fn(),
|
||||
mockStat: vi.fn(),
|
||||
protocolHandlerRef,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: mockApp,
|
||||
protocol: mockProtocol,
|
||||
}));
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
realpath: mockRealpath,
|
||||
readFile: mockReadFile,
|
||||
stat: mockStat,
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('LocalFileProtocolManager', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
protocolHandlerRef.current = null;
|
||||
mockApp.isReady.mockReturnValue(true);
|
||||
mockRealpath.mockImplementation(async (filePath: string) => filePath);
|
||||
mockStat.mockImplementation(async () => ({ isFile: () => true, size: 1024 }));
|
||||
mockReadFile.mockImplementation(async () => Buffer.from('image-bytes'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
protocolHandlerRef.current = null;
|
||||
});
|
||||
|
||||
it('exposes scheme metadata for registerSchemesAsPrivileged', () => {
|
||||
const manager = new LocalFileProtocolManager();
|
||||
expect(manager.protocolScheme).toEqual({
|
||||
privileges: expect.objectContaining({
|
||||
bypassCSP: false,
|
||||
secure: true,
|
||||
standard: true,
|
||||
supportFetchAPI: true,
|
||||
}),
|
||||
scheme: 'localfile',
|
||||
});
|
||||
});
|
||||
|
||||
it('serves a POSIX absolute path with the correct mime type', async () => {
|
||||
const manager = new LocalFileProtocolManager();
|
||||
manager.registerHandler();
|
||||
await manager.approveWorkspaceRoot('/Users/alice');
|
||||
const url = await manager.createPreviewUrl({
|
||||
filePath: '/Users/alice/Pictures/cat.png',
|
||||
workspaceRoot: '/Users/alice',
|
||||
});
|
||||
if (!url) throw new Error('Expected local file preview URL');
|
||||
|
||||
expect(mockProtocol.handle).toHaveBeenCalledWith('localfile', expect.any(Function));
|
||||
const handler = protocolHandlerRef.current;
|
||||
|
||||
const response = await handler({
|
||||
headers: new Headers(),
|
||||
method: 'GET',
|
||||
url,
|
||||
});
|
||||
|
||||
expect(mockStat).toHaveBeenCalledWith('/Users/alice/Pictures/cat.png');
|
||||
expect(mockReadFile).toHaveBeenCalledWith('/Users/alice/Pictures/cat.png');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('Content-Type')).toBe('image/png');
|
||||
expect(response.headers.get('Content-Length')).toBe('11'); // 'image-bytes'.length
|
||||
});
|
||||
|
||||
it('serves source files as text through the localfile protocol', async () => {
|
||||
const manager = new LocalFileProtocolManager();
|
||||
manager.registerHandler();
|
||||
await manager.approveWorkspaceRoot('/Users/alice/project');
|
||||
const url = await manager.createPreviewUrl({
|
||||
filePath: '/Users/alice/project/App.tsx',
|
||||
workspaceRoot: '/Users/alice/project',
|
||||
});
|
||||
if (!url) throw new Error('Expected local file preview URL');
|
||||
const handler = protocolHandlerRef.current;
|
||||
|
||||
const response = await handler({
|
||||
headers: new Headers(),
|
||||
method: 'GET',
|
||||
url,
|
||||
});
|
||||
|
||||
expect(mockStat).toHaveBeenCalledWith('/Users/alice/project/App.tsx');
|
||||
expect(mockReadFile).toHaveBeenCalledWith('/Users/alice/project/App.tsx');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('Content-Type')).toBe('text/plain; charset=utf-8');
|
||||
});
|
||||
|
||||
it('decodes percent-encoded characters in the path', async () => {
|
||||
const manager = new LocalFileProtocolManager();
|
||||
manager.registerHandler();
|
||||
await manager.approveWorkspaceRoot('/Users/alice');
|
||||
const url = await manager.createPreviewUrl({
|
||||
filePath: '/Users/alice/My Pictures/图 #.png',
|
||||
workspaceRoot: '/Users/alice',
|
||||
});
|
||||
if (!url) throw new Error('Expected local file preview URL');
|
||||
const handler = protocolHandlerRef.current;
|
||||
|
||||
await handler({
|
||||
headers: new Headers(),
|
||||
method: 'GET',
|
||||
url,
|
||||
});
|
||||
|
||||
expect(mockStat).toHaveBeenCalledWith('/Users/alice/My Pictures/图 #.png');
|
||||
});
|
||||
|
||||
it('rejects requests to a different host', async () => {
|
||||
const manager = new LocalFileProtocolManager();
|
||||
manager.registerHandler();
|
||||
const handler = protocolHandlerRef.current;
|
||||
|
||||
const response = await handler({
|
||||
headers: new Headers(),
|
||||
method: 'GET',
|
||||
url: 'localfile://other/Users/alice/cat.png',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(mockStat).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 404 when the path is a directory', async () => {
|
||||
mockStat.mockImplementation(async () => ({ isFile: () => false, size: 0 }));
|
||||
|
||||
const manager = new LocalFileProtocolManager();
|
||||
manager.registerHandler();
|
||||
await manager.approveWorkspaceRoot('/Users/alice');
|
||||
const url = await manager.createPreviewUrl({
|
||||
filePath: '/Users/alice/folder',
|
||||
workspaceRoot: '/Users/alice',
|
||||
});
|
||||
if (!url) throw new Error('Expected local file preview URL');
|
||||
const handler = protocolHandlerRef.current;
|
||||
|
||||
const response = await handler({
|
||||
headers: new Headers(),
|
||||
method: 'GET',
|
||||
url,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(mockReadFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('maps ENOENT errors to a 404 response', async () => {
|
||||
mockStat.mockImplementation(async () => {
|
||||
const err: NodeJS.ErrnoException = new Error('no such file');
|
||||
err.code = 'ENOENT';
|
||||
throw err;
|
||||
});
|
||||
|
||||
const manager = new LocalFileProtocolManager();
|
||||
manager.registerHandler();
|
||||
await manager.approveWorkspaceRoot('/');
|
||||
const handler = protocolHandlerRef.current;
|
||||
const url = await manager.createPreviewUrl({
|
||||
filePath: '/nonexistent.png',
|
||||
workspaceRoot: '/',
|
||||
});
|
||||
if (!url) throw new Error('Expected local file preview URL');
|
||||
|
||||
const response = await handler({
|
||||
headers: new Headers(),
|
||||
method: 'GET',
|
||||
url,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it('rejects direct localfile requests without a main-issued preview token', async () => {
|
||||
const manager = new LocalFileProtocolManager();
|
||||
manager.registerHandler();
|
||||
const handler = protocolHandlerRef.current;
|
||||
|
||||
const response = await handler({
|
||||
headers: new Headers(),
|
||||
method: 'GET',
|
||||
url: 'localfile://file/Users/alice/.ssh/id_rsa',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(mockStat).not.toHaveBeenCalled();
|
||||
expect(mockReadFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects forged preview tokens before resolving the requested path', async () => {
|
||||
const manager = new LocalFileProtocolManager();
|
||||
manager.registerHandler();
|
||||
const handler = protocolHandlerRef.current;
|
||||
|
||||
const response = await handler({
|
||||
headers: new Headers(),
|
||||
method: 'GET',
|
||||
url: 'localfile://file/Users/alice/.ssh/id_rsa?token=forged',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(mockRealpath).not.toHaveBeenCalled();
|
||||
expect(mockStat).not.toHaveBeenCalled();
|
||||
expect(mockReadFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not mint preview URLs outside an approved workspace root', async () => {
|
||||
const manager = new LocalFileProtocolManager();
|
||||
await manager.approveWorkspaceRoot('/Users/alice/project');
|
||||
|
||||
const url = await manager.createPreviewUrl({
|
||||
filePath: '/Users/alice/.ssh/id_rsa',
|
||||
workspaceRoot: '/Users/alice/project',
|
||||
});
|
||||
|
||||
expect(url).toBeNull();
|
||||
});
|
||||
|
||||
it('can approve a project root derived from an already approved nested scope', async () => {
|
||||
const manager = new LocalFileProtocolManager();
|
||||
await manager.approveWorkspaceRoot('/Users/alice/project/packages/app');
|
||||
await manager.approveProjectRootFromScope({
|
||||
projectRoot: '/Users/alice/project',
|
||||
requestedScope: '/Users/alice/project/packages/app',
|
||||
});
|
||||
|
||||
const url = await manager.createPreviewUrl({
|
||||
filePath: '/Users/alice/project/root.ts',
|
||||
workspaceRoot: '/Users/alice/project',
|
||||
});
|
||||
if (!url) throw new Error('Expected local file preview URL');
|
||||
|
||||
expect(url).toContain('token=');
|
||||
});
|
||||
|
||||
it('can mint preview URLs for roots produced by the main-process project index', async () => {
|
||||
const manager = new LocalFileProtocolManager();
|
||||
await manager.approveIndexedProjectRoot('/Users/alice/project');
|
||||
|
||||
const url = await manager.createPreviewUrl({
|
||||
filePath: '/Users/alice/project/App.tsx',
|
||||
workspaceRoot: '/Users/alice/project',
|
||||
});
|
||||
if (!url) throw new Error('Expected local file preview URL');
|
||||
|
||||
expect(url).toContain('token=');
|
||||
});
|
||||
|
||||
it('defers registration until app ready when not yet ready', async () => {
|
||||
mockApp.isReady.mockReturnValue(false);
|
||||
let resolveReady: () => void = () => undefined;
|
||||
mockApp.whenReady.mockReturnValue(
|
||||
new Promise<void>((resolve) => {
|
||||
resolveReady = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const manager = new LocalFileProtocolManager();
|
||||
manager.registerHandler();
|
||||
|
||||
expect(mockProtocol.handle).not.toHaveBeenCalled();
|
||||
resolveReady();
|
||||
await new Promise((r) => setImmediate(r));
|
||||
expect(mockProtocol.handle).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -49,6 +49,10 @@ class TestContentSearch extends BaseContentSearch {
|
||||
public testGetDefaultIgnorePatterns(): string[] {
|
||||
return this.getDefaultIgnorePatterns();
|
||||
}
|
||||
|
||||
public testResolveSearchPath(params: GrepContentParams): string {
|
||||
return this.resolveSearchPath(params);
|
||||
}
|
||||
}
|
||||
|
||||
describe('BaseContentSearch', () => {
|
||||
@@ -255,6 +259,33 @@ describe('BaseContentSearch', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveSearchPath', () => {
|
||||
it('prefers scope when path is not set', () => {
|
||||
const resolved = contentSearch.testResolveSearchPath({
|
||||
pattern: 'x',
|
||||
scope: '/Users/arvinxx/repo',
|
||||
});
|
||||
|
||||
expect(resolved).toBe('/Users/arvinxx/repo');
|
||||
});
|
||||
|
||||
it('honors legacy path over scope when both are set', () => {
|
||||
const resolved = contentSearch.testResolveSearchPath({
|
||||
path: '/legacy/path',
|
||||
pattern: 'x',
|
||||
scope: '/scope/path',
|
||||
});
|
||||
|
||||
expect(resolved).toBe('/legacy/path');
|
||||
});
|
||||
|
||||
it('falls back to process.cwd() when neither is provided', () => {
|
||||
const resolved = contentSearch.testResolveSearchPath({ pattern: 'x' });
|
||||
|
||||
expect(resolved).toBe(process.cwd());
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDefaultIgnorePatterns', () => {
|
||||
it('should return default ignore patterns', () => {
|
||||
const patterns = contentSearch.testGetDefaultIgnorePatterns();
|
||||
|
||||
@@ -46,6 +46,18 @@ export abstract class BaseContentSearch {
|
||||
*/
|
||||
abstract checkToolAvailable(tool: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Resolve the directory to run the search in.
|
||||
*
|
||||
* The builtin-tool manifest documents `scope`, while the legacy IPC type also accepts
|
||||
* `path`. Read both so an agent calling with `scope` (per the manifest) doesn't silently
|
||||
* fall through to `process.cwd()` — which in a packaged Electron app isn't the project
|
||||
* root and therefore has no `.gitignore` for ripgrep to honor.
|
||||
*/
|
||||
protected resolveSearchPath(params: GrepContentParams): string {
|
||||
return params.path ?? params.scope ?? process.cwd();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build command-line arguments for grep tools
|
||||
*/
|
||||
@@ -141,11 +153,8 @@ export abstract class BaseContentSearch {
|
||||
* Grep using Node.js native implementation (fallback)
|
||||
*/
|
||||
protected async grepWithNodejs(params: GrepContentParams): Promise<GrepContentResult> {
|
||||
const {
|
||||
pattern,
|
||||
path: searchPath = process.cwd(),
|
||||
output_mode = 'files_with_matches',
|
||||
} = params;
|
||||
const { pattern, output_mode = 'files_with_matches' } = params;
|
||||
const searchPath = this.resolveSearchPath(params);
|
||||
const logPrefix = `[grepContent:nodejs]`;
|
||||
|
||||
const flags = `${params['-i'] ? 'i' : ''}${params.multiline ? 's' : ''}`;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
import type { GrepContentParams, GrepContentResult } from '@lobechat/electron-client-ipc';
|
||||
import { execa } from 'execa';
|
||||
|
||||
@@ -179,7 +178,8 @@ export abstract class UnixContentSearch extends BaseContentSearch {
|
||||
tool: 'rg' | 'ag' | 'grep',
|
||||
params: GrepContentParams,
|
||||
): Promise<GrepContentResult> {
|
||||
const { path: searchPath = process.cwd(), output_mode = 'files_with_matches' } = params;
|
||||
const { output_mode = 'files_with_matches' } = params;
|
||||
const searchPath = this.resolveSearchPath(params);
|
||||
const logPrefix = `[grepContent:${tool}]`;
|
||||
|
||||
try {
|
||||
@@ -272,7 +272,7 @@ export abstract class UnixContentSearch extends BaseContentSearch {
|
||||
|
||||
try {
|
||||
const { stdout } = await execa(tool, args, {
|
||||
cwd: params.path || process.cwd(),
|
||||
cwd: this.resolveSearchPath(params),
|
||||
reject: false,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
import type { GrepContentParams, GrepContentResult } from '@lobechat/electron-client-ipc';
|
||||
import { execa } from 'execa';
|
||||
|
||||
@@ -146,7 +145,8 @@ export class WindowsContentSearchImpl extends BaseContentSearch {
|
||||
* Grep using ripgrep (rg) - cross-platform
|
||||
*/
|
||||
private async grepWithRipgrep(params: GrepContentParams): Promise<GrepContentResult> {
|
||||
const { path: searchPath = process.cwd(), output_mode = 'files_with_matches' } = params;
|
||||
const { output_mode = 'files_with_matches' } = params;
|
||||
const searchPath = this.resolveSearchPath(params);
|
||||
const logPrefix = `[grepContent:rg]`;
|
||||
|
||||
try {
|
||||
@@ -230,7 +230,7 @@ export class WindowsContentSearchImpl extends BaseContentSearch {
|
||||
|
||||
try {
|
||||
const { stdout } = await execa('rg', args, {
|
||||
cwd: params.path || process.cwd(),
|
||||
cwd: this.resolveSearchPath(params),
|
||||
reject: false,
|
||||
});
|
||||
|
||||
@@ -252,11 +252,8 @@ export class WindowsContentSearchImpl extends BaseContentSearch {
|
||||
* Note: findstr has limited functionality compared to ripgrep
|
||||
*/
|
||||
private async grepWithFindstr(params: GrepContentParams): Promise<GrepContentResult> {
|
||||
const {
|
||||
pattern,
|
||||
path: searchPath = process.cwd(),
|
||||
output_mode = 'files_with_matches',
|
||||
} = params;
|
||||
const { pattern, output_mode = 'files_with_matches' } = params;
|
||||
const searchPath = this.resolveSearchPath(params);
|
||||
const logPrefix = `[grepContent:findstr]`;
|
||||
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { clearDetectionCache, getCachedDetection } from '../cache';
|
||||
import { detectAllApps } from '../detectors';
|
||||
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../detectors', () => ({
|
||||
detectAllApps: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockedDetectAll = vi.mocked(detectAllApps);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
clearDetectionCache();
|
||||
});
|
||||
|
||||
describe('getCachedDetection', () => {
|
||||
it('invokes detection on first call', async () => {
|
||||
mockedDetectAll.mockResolvedValueOnce([
|
||||
{ displayName: 'VS Code', id: 'vscode', installed: true },
|
||||
]);
|
||||
|
||||
const result = await getCachedDetection('darwin');
|
||||
|
||||
expect(result).toEqual([{ displayName: 'VS Code', id: 'vscode', installed: true }]);
|
||||
expect(mockedDetectAll).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('concurrent callers share a single inflight promise', async () => {
|
||||
let resolveFn: (value: any) => void = () => {};
|
||||
const inflight = new Promise<any>((resolve) => {
|
||||
resolveFn = resolve;
|
||||
});
|
||||
mockedDetectAll.mockReturnValueOnce(inflight);
|
||||
|
||||
const p1 = getCachedDetection('darwin');
|
||||
const p2 = getCachedDetection('darwin');
|
||||
const p3 = getCachedDetection('darwin');
|
||||
|
||||
expect(mockedDetectAll).toHaveBeenCalledTimes(1);
|
||||
|
||||
resolveFn([{ displayName: 'VS Code', id: 'vscode', installed: true }]);
|
||||
const results = await Promise.all([p1, p2, p3]);
|
||||
|
||||
// all three share the same resolved value
|
||||
expect(results[0]).toBe(results[1]);
|
||||
expect(results[1]).toBe(results[2]);
|
||||
expect(mockedDetectAll).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('subsequent serial calls reuse the cached promise', async () => {
|
||||
mockedDetectAll.mockResolvedValueOnce([
|
||||
{ displayName: 'VS Code', id: 'vscode', installed: true },
|
||||
]);
|
||||
|
||||
await getCachedDetection('darwin');
|
||||
await getCachedDetection('darwin');
|
||||
await getCachedDetection('darwin');
|
||||
|
||||
expect(mockedDetectAll).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('re-invokes detection after clearDetectionCache', async () => {
|
||||
mockedDetectAll.mockResolvedValueOnce([
|
||||
{ displayName: 'VS Code', id: 'vscode', installed: true },
|
||||
]);
|
||||
await getCachedDetection('darwin');
|
||||
expect(mockedDetectAll).toHaveBeenCalledTimes(1);
|
||||
|
||||
clearDetectionCache();
|
||||
mockedDetectAll.mockResolvedValueOnce([
|
||||
{ displayName: 'VS Code', id: 'vscode', installed: false },
|
||||
]);
|
||||
await getCachedDetection('darwin');
|
||||
|
||||
expect(mockedDetectAll).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,274 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { access } from 'node:fs/promises';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { detectAllApps, detectApp } from '../detectors';
|
||||
import { extractAllIcons } from '../iconExtractor';
|
||||
|
||||
// Mock logger
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock node:fs/promises
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
access: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock node:child_process - execFile is wrapped via promisify, so the mock must
|
||||
// expose execFile as the underlying callback-style function we can drive.
|
||||
vi.mock('node:child_process', () => ({
|
||||
execFile: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the icon extractor — detection tests should not depend on real icon
|
||||
// extraction. The default returns an empty Map (no icons) which leaves the
|
||||
// `icon` field absent from all detection results.
|
||||
vi.mock('../iconExtractor', () => ({
|
||||
extractAllIcons: vi.fn(async () => new Map<string, string>()),
|
||||
}));
|
||||
|
||||
const mockedAccess = vi.mocked(access);
|
||||
const mockedExecFile = vi.mocked(execFile) as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
interface ExecOutcome {
|
||||
code: number;
|
||||
error?: NodeJS.ErrnoException;
|
||||
stderr?: string;
|
||||
stdout?: string;
|
||||
}
|
||||
|
||||
const respondExec = (outcome: ExecOutcome) => {
|
||||
mockedExecFile.mockImplementationOnce(
|
||||
(_file: string, _args: string[], _opts: unknown, cb: any) => {
|
||||
const callback = typeof _opts === 'function' ? _opts : cb;
|
||||
if (outcome.code === 0) {
|
||||
callback(null, outcome.stdout ?? '', outcome.stderr ?? '');
|
||||
} else {
|
||||
const err: NodeJS.ErrnoException & { stderr?: string } =
|
||||
outcome.error ?? new Error('exec failed');
|
||||
err.stderr = outcome.stderr ?? '';
|
||||
(err as any).code = outcome.code;
|
||||
callback(err, '', outcome.stderr ?? '');
|
||||
}
|
||||
return undefined as any;
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('detectApp', () => {
|
||||
describe('appBundle strategy', () => {
|
||||
it('returns true when fs.access resolves for any path', async () => {
|
||||
mockedAccess.mockRejectedValueOnce(new Error('missing'));
|
||||
mockedAccess.mockResolvedValueOnce(undefined);
|
||||
|
||||
const result = await detectApp('terminal', 'darwin');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockedAccess).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('returns false when all paths reject', async () => {
|
||||
mockedAccess.mockRejectedValue(new Error('missing'));
|
||||
|
||||
const result = await detectApp('vscode', 'darwin');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('commandV strategy', () => {
|
||||
it('returns true on exit 0', async () => {
|
||||
respondExec({ code: 0, stdout: '/usr/bin/zed' });
|
||||
|
||||
const result = await detectApp('zed', 'linux');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockedExecFile).toHaveBeenCalledWith(
|
||||
'/bin/sh',
|
||||
['-c', 'command -v "zed"'],
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns false on non-zero exit', async () => {
|
||||
respondExec({ code: 1, stderr: 'not found' });
|
||||
|
||||
const result = await detectApp('zed', 'linux');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects unsafe binary names without spawning a shell', async () => {
|
||||
// We monkey-patch a registry entry transiently to inject a malicious binary.
|
||||
const registry = await import('../registry');
|
||||
const originalGhostty = registry.APP_REGISTRY.ghostty.detect.linux;
|
||||
registry.APP_REGISTRY.ghostty.detect.linux = {
|
||||
binary: 'foo; rm -rf /',
|
||||
type: 'commandV',
|
||||
};
|
||||
|
||||
const result = await detectApp('ghostty', 'linux');
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(mockedExecFile).not.toHaveBeenCalled();
|
||||
|
||||
registry.APP_REGISTRY.ghostty.detect.linux = originalGhostty;
|
||||
});
|
||||
});
|
||||
|
||||
describe('registryAppPaths strategy', () => {
|
||||
it('returns true on exit 0', async () => {
|
||||
respondExec({ code: 0, stdout: 'C:\\Program Files\\code.exe' });
|
||||
|
||||
const result = await detectApp('vscode', 'win32');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockedExecFile).toHaveBeenCalledWith(
|
||||
'where',
|
||||
['Code.exe'],
|
||||
{ windowsHide: true },
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns false on non-zero exit', async () => {
|
||||
respondExec({ code: 1, stderr: 'not found' });
|
||||
|
||||
const result = await detectApp('vscode', 'win32');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns false when platform has no detect entry for the app', async () => {
|
||||
const result = await detectApp('xcode', 'linux');
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(mockedAccess).not.toHaveBeenCalled();
|
||||
expect(mockedExecFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns true for ALWAYS_INSTALLED entries without probing', async () => {
|
||||
const darwinFinder = await detectApp('finder', 'darwin');
|
||||
const win32Explorer = await detectApp('explorer', 'win32');
|
||||
const linuxFiles = await detectApp('files', 'linux');
|
||||
|
||||
expect(darwinFinder).toBe(true);
|
||||
expect(win32Explorer).toBe(true);
|
||||
expect(linuxFiles).toBe(true);
|
||||
expect(mockedAccess).not.toHaveBeenCalled();
|
||||
expect(mockedExecFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectAllApps', () => {
|
||||
it('returns one entry per AppId regardless of platform', async () => {
|
||||
mockedAccess.mockRejectedValue(new Error('missing'));
|
||||
mockedExecFile.mockImplementation((_file: string, _args: string[], _opts: unknown, cb: any) => {
|
||||
const callback = typeof _opts === 'function' ? _opts : cb;
|
||||
const err: NodeJS.ErrnoException = new Error('fail');
|
||||
callback(err, '', '');
|
||||
return undefined as any;
|
||||
});
|
||||
|
||||
const apps = await detectAllApps('linux');
|
||||
|
||||
const registry = await import('../registry');
|
||||
expect(apps.length).toBe(Object.keys(registry.APP_REGISTRY).length);
|
||||
// every entry has the three required fields
|
||||
for (const app of apps) {
|
||||
expect(app).toEqual(
|
||||
expect.objectContaining({
|
||||
displayName: expect.any(String),
|
||||
id: expect.any(String),
|
||||
installed: expect.any(Boolean),
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('marks unsupported-on-platform apps as not installed', async () => {
|
||||
mockedAccess.mockRejectedValue(new Error('missing'));
|
||||
mockedExecFile.mockImplementation((_file: string, _args: string[], _opts: unknown, cb: any) => {
|
||||
const callback = typeof _opts === 'function' ? _opts : cb;
|
||||
const err: NodeJS.ErrnoException = new Error('fail');
|
||||
callback(err, '', '');
|
||||
return undefined as any;
|
||||
});
|
||||
|
||||
const apps = await detectAllApps('linux');
|
||||
|
||||
const xcode = apps.find((a) => a.id === 'xcode');
|
||||
expect(xcode?.installed).toBe(false);
|
||||
});
|
||||
|
||||
it('marks ALWAYS_INSTALLED platform file manager as installed without probes', async () => {
|
||||
mockedAccess.mockRejectedValue(new Error('missing'));
|
||||
mockedExecFile.mockImplementation((_file: string, _args: string[], _opts: unknown, cb: any) => {
|
||||
const callback = typeof _opts === 'function' ? _opts : cb;
|
||||
const err: NodeJS.ErrnoException = new Error('fail');
|
||||
callback(err, '', '');
|
||||
return undefined as any;
|
||||
});
|
||||
|
||||
const apps = await detectAllApps('darwin');
|
||||
|
||||
const finder = apps.find((a) => a.id === 'finder');
|
||||
expect(finder?.installed).toBe(true);
|
||||
});
|
||||
|
||||
it('merges extracted icons onto installed apps only', async () => {
|
||||
mockedAccess.mockRejectedValue(new Error('missing'));
|
||||
mockedExecFile.mockImplementation((_file: string, _args: string[], _opts: unknown, cb: any) => {
|
||||
const callback = typeof _opts === 'function' ? _opts : cb;
|
||||
const err: NodeJS.ErrnoException = new Error('fail');
|
||||
callback(err, '', '');
|
||||
return undefined as any;
|
||||
});
|
||||
|
||||
vi.mocked(extractAllIcons).mockResolvedValueOnce(
|
||||
new Map([['finder', 'data:image/png;base64,FAKE']]),
|
||||
);
|
||||
|
||||
const apps = await detectAllApps('darwin');
|
||||
|
||||
const finder = apps.find((a) => a.id === 'finder');
|
||||
expect(finder?.icon).toBe('data:image/png;base64,FAKE');
|
||||
|
||||
// not-installed apps must not have an icon field
|
||||
const xcode = apps.find((a) => a.id === 'xcode');
|
||||
expect(xcode?.installed).toBe(false);
|
||||
expect(xcode?.icon).toBeUndefined();
|
||||
});
|
||||
|
||||
it('passes only installed AppIds to extractAllIcons', async () => {
|
||||
mockedAccess.mockRejectedValue(new Error('missing'));
|
||||
mockedExecFile.mockImplementation((_file: string, _args: string[], _opts: unknown, cb: any) => {
|
||||
const callback = typeof _opts === 'function' ? _opts : cb;
|
||||
const err: NodeJS.ErrnoException = new Error('fail');
|
||||
callback(err, '', '');
|
||||
return undefined as any;
|
||||
});
|
||||
|
||||
vi.mocked(extractAllIcons).mockResolvedValueOnce(new Map());
|
||||
|
||||
await detectAllApps('darwin');
|
||||
|
||||
expect(extractAllIcons).toHaveBeenCalledTimes(1);
|
||||
const [ids, platform] = vi.mocked(extractAllIcons).mock.calls[0];
|
||||
expect(platform).toBe('darwin');
|
||||
// only finder is ALWAYS_INSTALLED on darwin; all others fail probes
|
||||
expect(ids).toEqual(['finder']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,261 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { access, mkdtemp, readFile, unlink } from 'node:fs/promises';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { __resetForTest, extractAllIcons, extractAppIcon } from '../iconExtractor';
|
||||
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
access: vi.fn(),
|
||||
mkdtemp: vi.fn(),
|
||||
readFile: vi.fn(),
|
||||
unlink: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
execFile: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockedAccess = vi.mocked(access);
|
||||
const mockedMkdtemp = vi.mocked(mkdtemp);
|
||||
const mockedReadFile = vi.mocked(readFile);
|
||||
const mockedUnlink = vi.mocked(unlink);
|
||||
const mockedExecFile = vi.mocked(execFile) as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
/**
|
||||
* Drives the next execFile call. The promisified callback signature is
|
||||
* `(error, stdout, stderr)`; non-error responses resolve with stdout.
|
||||
*/
|
||||
const respondExec = (
|
||||
match: { args?: string[]; binary: string },
|
||||
outcome: { error?: Error; stderr?: string; stdout?: string },
|
||||
) => {
|
||||
mockedExecFile.mockImplementationOnce(
|
||||
(_file: string, _args: string[], _opts: unknown, cb: any) => {
|
||||
const callback = typeof _opts === 'function' ? _opts : cb;
|
||||
if (_file !== match.binary) {
|
||||
callback(new Error(`unexpected binary: ${_file}`), '', '');
|
||||
return undefined as any;
|
||||
}
|
||||
if (match.args && JSON.stringify(_args) !== JSON.stringify(match.args)) {
|
||||
callback(new Error(`unexpected args: ${JSON.stringify(_args)}`), '', '');
|
||||
return undefined as any;
|
||||
}
|
||||
if (outcome.error) {
|
||||
callback(outcome.error, '', outcome.stderr ?? '');
|
||||
} else {
|
||||
callback(null, outcome.stdout ?? '', outcome.stderr ?? '');
|
||||
}
|
||||
return undefined as any;
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
// Shorthand: tools-available probe passes (which plutil + which sips both 0).
|
||||
const respondToolsAvailable = () => {
|
||||
// /usr/bin/which plutil
|
||||
respondExec({ binary: '/usr/bin/which' }, { stdout: '/usr/bin/plutil\n' });
|
||||
// /usr/bin/which sips
|
||||
respondExec({ binary: '/usr/bin/which' }, { stdout: '/usr/bin/sips\n' });
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockedAccess.mockReset();
|
||||
mockedMkdtemp.mockReset();
|
||||
mockedReadFile.mockReset();
|
||||
mockedUnlink.mockReset();
|
||||
mockedExecFile.mockReset();
|
||||
mockedUnlink.mockResolvedValue(undefined);
|
||||
__resetForTest();
|
||||
});
|
||||
|
||||
describe('extractAppIcon', () => {
|
||||
it('returns a data URL when plutil + sips succeed on darwin', async () => {
|
||||
respondToolsAvailable();
|
||||
mockedAccess.mockResolvedValueOnce(undefined); // bundle exists
|
||||
// plutil CFBundleIconFile lookup
|
||||
respondExec({ binary: 'plutil' }, { stdout: 'Code.icns\n' });
|
||||
mockedAccess.mockResolvedValueOnce(undefined); // .icns exists
|
||||
mockedMkdtemp.mockResolvedValueOnce('/tmp/lobehub-openinapp-test');
|
||||
// sips conversion
|
||||
respondExec({ binary: 'sips' }, { stdout: '' });
|
||||
mockedReadFile.mockResolvedValueOnce(Buffer.from([0x89, 0x50, 0x4e, 0x47])); // PNG header
|
||||
|
||||
const result = await extractAppIcon('vscode', 'darwin');
|
||||
|
||||
expect(result).toBe(
|
||||
`data:image/png;base64,${Buffer.from([0x89, 0x50, 0x4e, 0x47]).toString('base64')}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('appends .icns suffix when CFBundleIconFile has no extension', async () => {
|
||||
respondToolsAvailable();
|
||||
mockedAccess.mockResolvedValueOnce(undefined); // bundle exists
|
||||
respondExec({ binary: 'plutil' }, { stdout: 'Terminal\n' });
|
||||
mockedAccess.mockImplementationOnce(async (p: any) => {
|
||||
// .icns existence check — verify suffix appended
|
||||
if (typeof p === 'string' && p.endsWith('Terminal.icns')) return undefined;
|
||||
throw new Error('wrong path: ' + String(p));
|
||||
});
|
||||
mockedMkdtemp.mockResolvedValueOnce('/tmp/lobehub-openinapp-test');
|
||||
respondExec({ binary: 'sips' }, { stdout: '' });
|
||||
mockedReadFile.mockResolvedValueOnce(Buffer.from([0x89, 0x50]));
|
||||
|
||||
const result = await extractAppIcon('terminal', 'darwin');
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.startsWith('data:image/png;base64,')).toBe(true);
|
||||
});
|
||||
|
||||
it('falls back to the next path when the first bundle does not exist', async () => {
|
||||
respondToolsAvailable();
|
||||
// terminal has two candidate paths; first fails, second succeeds.
|
||||
mockedAccess.mockRejectedValueOnce(new Error('missing'));
|
||||
mockedAccess.mockResolvedValueOnce(undefined);
|
||||
respondExec({ binary: 'plutil' }, { stdout: 'Terminal\n' });
|
||||
mockedAccess.mockResolvedValueOnce(undefined);
|
||||
mockedMkdtemp.mockResolvedValueOnce('/tmp/lobehub-openinapp-test');
|
||||
respondExec({ binary: 'sips' }, { stdout: '' });
|
||||
mockedReadFile.mockResolvedValueOnce(Buffer.from([0xff]));
|
||||
|
||||
const result = await extractAppIcon('terminal', 'darwin');
|
||||
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
it('returns undefined when no bundle path exists', async () => {
|
||||
respondToolsAvailable();
|
||||
mockedAccess.mockRejectedValue(new Error('missing'));
|
||||
|
||||
const result = await extractAppIcon('vscode', 'darwin');
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when plutil cannot read CFBundleIconFile', async () => {
|
||||
respondToolsAvailable();
|
||||
mockedAccess.mockResolvedValueOnce(undefined);
|
||||
respondExec({ binary: 'plutil' }, { error: new Error('plutil: not found') });
|
||||
|
||||
const result = await extractAppIcon('vscode', 'darwin');
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when the resolved .icns is missing', async () => {
|
||||
respondToolsAvailable();
|
||||
mockedAccess.mockResolvedValueOnce(undefined); // bundle exists
|
||||
respondExec({ binary: 'plutil' }, { stdout: 'Code.icns\n' });
|
||||
mockedAccess.mockRejectedValueOnce(new Error('missing icns')); // .icns missing
|
||||
|
||||
const result = await extractAppIcon('vscode', 'darwin');
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when sips fails', async () => {
|
||||
respondToolsAvailable();
|
||||
mockedAccess.mockResolvedValueOnce(undefined);
|
||||
respondExec({ binary: 'plutil' }, { stdout: 'Code.icns\n' });
|
||||
mockedAccess.mockResolvedValueOnce(undefined);
|
||||
mockedMkdtemp.mockResolvedValueOnce('/tmp/lobehub-openinapp-test');
|
||||
respondExec({ binary: 'sips' }, { error: new Error('sips error') });
|
||||
|
||||
const result = await extractAppIcon('vscode', 'darwin');
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when the produced PNG is empty', async () => {
|
||||
respondToolsAvailable();
|
||||
mockedAccess.mockResolvedValueOnce(undefined);
|
||||
respondExec({ binary: 'plutil' }, { stdout: 'Code.icns\n' });
|
||||
mockedAccess.mockResolvedValueOnce(undefined);
|
||||
mockedMkdtemp.mockResolvedValueOnce('/tmp/lobehub-openinapp-test');
|
||||
respondExec({ binary: 'sips' }, { stdout: '' });
|
||||
mockedReadFile.mockResolvedValueOnce(Buffer.alloc(0));
|
||||
|
||||
const result = await extractAppIcon('vscode', 'darwin');
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when registry has no darwin entry for the app', async () => {
|
||||
respondToolsAvailable();
|
||||
const result = await extractAppIcon('explorer', 'darwin');
|
||||
expect(result).toBeUndefined();
|
||||
expect(mockedAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns undefined on win32 (extractor is macOS-only)', async () => {
|
||||
const result = await extractAppIcon('vscode', 'win32');
|
||||
expect(result).toBeUndefined();
|
||||
expect(mockedExecFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns undefined on linux (extractor is macOS-only)', async () => {
|
||||
const result = await extractAppIcon('vscode', 'linux');
|
||||
expect(result).toBeUndefined();
|
||||
expect(mockedExecFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractAllIcons', () => {
|
||||
it('returns a map of only AppIds with successfully extracted icons', async () => {
|
||||
respondToolsAvailable();
|
||||
|
||||
// vscode succeeds
|
||||
mockedAccess.mockResolvedValueOnce(undefined); // bundle
|
||||
respondExec({ binary: 'plutil' }, { stdout: 'Code.icns\n' });
|
||||
mockedAccess.mockResolvedValueOnce(undefined); // .icns
|
||||
mockedMkdtemp.mockResolvedValueOnce('/tmp/lobehub-openinapp-test');
|
||||
respondExec({ binary: 'sips' }, { stdout: '' });
|
||||
mockedReadFile.mockResolvedValueOnce(Buffer.from('vscode'));
|
||||
|
||||
// cursor fails at bundle access (try all paths fail)
|
||||
mockedAccess.mockRejectedValue(new Error('missing'));
|
||||
|
||||
// xcode succeeds — reset access for it
|
||||
// (subsequent calls to mockedAccess will keep returning rejection)
|
||||
// So this test exercises: success, fail-no-bundle.
|
||||
|
||||
const map = await extractAllIcons(['vscode', 'cursor'], 'darwin');
|
||||
|
||||
expect(map.has('vscode')).toBe(true);
|
||||
expect(map.has('cursor')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns empty map when input list is empty', async () => {
|
||||
const map = await extractAllIcons([], 'darwin');
|
||||
expect(map.size).toBe(0);
|
||||
});
|
||||
|
||||
it('does not throw when extraction errors', async () => {
|
||||
respondToolsAvailable();
|
||||
mockedAccess.mockResolvedValueOnce(undefined);
|
||||
respondExec({ binary: 'plutil' }, { error: new Error('boom') });
|
||||
|
||||
const map = await extractAllIcons(['vscode'], 'darwin');
|
||||
|
||||
expect(map.size).toBe(0);
|
||||
});
|
||||
|
||||
it('skips all when tools are unavailable', async () => {
|
||||
// /usr/bin/which plutil fails
|
||||
respondExec({ binary: '/usr/bin/which' }, { error: new Error('not found') });
|
||||
|
||||
const map = await extractAllIcons(['vscode', 'terminal'], 'darwin');
|
||||
|
||||
expect(map.size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,247 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { access } from 'node:fs/promises';
|
||||
|
||||
import { shell } from 'electron';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { launchApp } from '../launchers';
|
||||
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
access: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
execFile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
shell: {
|
||||
openPath: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockedAccess = vi.mocked(access);
|
||||
const mockedExecFile = vi.mocked(execFile) as unknown as ReturnType<typeof vi.fn>;
|
||||
const mockedShell = vi.mocked(shell);
|
||||
|
||||
type LastCall = { file: string; args: string[] };
|
||||
|
||||
const captureExec = (): LastCall => {
|
||||
expect(mockedExecFile).toHaveBeenCalled();
|
||||
const [file, args] = mockedExecFile.mock.calls[0];
|
||||
return { args: args as string[], file: file as string };
|
||||
};
|
||||
|
||||
interface ExecOutcome {
|
||||
code: number;
|
||||
stderr?: string;
|
||||
stdout?: string;
|
||||
}
|
||||
|
||||
const respondExec = (outcome: ExecOutcome) => {
|
||||
mockedExecFile.mockImplementationOnce(
|
||||
(_file: string, _args: string[], _opts: unknown, cb: any) => {
|
||||
const callback = typeof _opts === 'function' ? _opts : cb;
|
||||
if (outcome.code === 0) {
|
||||
callback(null, outcome.stdout ?? '', outcome.stderr ?? '');
|
||||
} else {
|
||||
const err: NodeJS.ErrnoException & { stderr?: string } = new Error('exec failed');
|
||||
err.stderr = outcome.stderr ?? '';
|
||||
(err as any).code = outcome.code;
|
||||
callback(err, '', outcome.stderr ?? '');
|
||||
}
|
||||
return undefined as any;
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockedAccess.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe('launchApp - path validation', () => {
|
||||
it('rejects relative paths', async () => {
|
||||
const result = await launchApp('vscode', 'relative/path', 'darwin');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Path must be absolute');
|
||||
expect(mockedExecFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects paths that do not exist', async () => {
|
||||
mockedAccess.mockRejectedValueOnce(new Error('ENOENT'));
|
||||
|
||||
const result = await launchApp('vscode', '/missing', 'darwin');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Path not found: /missing');
|
||||
expect(mockedExecFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns error when app is not available on platform', async () => {
|
||||
const result = await launchApp('xcode', '/some/path', 'linux');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Xcode');
|
||||
expect(result.error).toContain('not available on this platform');
|
||||
});
|
||||
});
|
||||
|
||||
describe('launchApp - macOpenA strategy', () => {
|
||||
it('spawns open -a <appName> <path>', async () => {
|
||||
respondExec({ code: 0 });
|
||||
|
||||
const result = await launchApp('vscode', '/work/dir', 'darwin');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const call = captureExec();
|
||||
expect(call.file).toBe('open');
|
||||
expect(call.args).toEqual(['-a', 'Visual Studio Code', '/work/dir']);
|
||||
});
|
||||
|
||||
it('returns stderr substring on failure', async () => {
|
||||
respondExec({ code: 1, stderr: ' cannot open Cursor.app ' });
|
||||
|
||||
const result = await launchApp('cursor', '/work/dir', 'darwin');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('cannot open Cursor.app');
|
||||
});
|
||||
});
|
||||
|
||||
describe('launchApp - macOpen strategy', () => {
|
||||
it('spawns open <path>', async () => {
|
||||
respondExec({ code: 0 });
|
||||
|
||||
const result = await launchApp('finder', '/work/dir', 'darwin');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const call = captureExec();
|
||||
expect(call.file).toBe('open');
|
||||
expect(call.args).toEqual(['/work/dir']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('launchApp - exec strategy', () => {
|
||||
it('spawns <binary> <path>', async () => {
|
||||
respondExec({ code: 0 });
|
||||
|
||||
const result = await launchApp('vscode', '/work/dir', 'linux');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const call = captureExec();
|
||||
expect(call.file).toBe('code');
|
||||
expect(call.args).toEqual(['/work/dir']);
|
||||
});
|
||||
|
||||
it('appends registry-provided args before path', async () => {
|
||||
const registry = await import('../registry');
|
||||
const original = registry.APP_REGISTRY.vscode.launch.linux;
|
||||
registry.APP_REGISTRY.vscode.launch.linux = {
|
||||
args: ['--new-window'],
|
||||
binary: 'code',
|
||||
type: 'exec',
|
||||
};
|
||||
|
||||
respondExec({ code: 0 });
|
||||
|
||||
const result = await launchApp('vscode', '/work/dir', 'linux');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const call = captureExec();
|
||||
expect(call.args).toEqual(['--new-window', '/work/dir']);
|
||||
|
||||
registry.APP_REGISTRY.vscode.launch.linux = original;
|
||||
});
|
||||
|
||||
it('rejects suspicious binary names', async () => {
|
||||
const registry = await import('../registry');
|
||||
const original = registry.APP_REGISTRY.vscode.launch.linux;
|
||||
registry.APP_REGISTRY.vscode.launch.linux = {
|
||||
binary: 'rm; ls',
|
||||
type: 'exec',
|
||||
};
|
||||
|
||||
const result = await launchApp('vscode', '/work/dir', 'linux');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Invalid binary name');
|
||||
expect(mockedExecFile).not.toHaveBeenCalled();
|
||||
|
||||
registry.APP_REGISTRY.vscode.launch.linux = original;
|
||||
});
|
||||
|
||||
it('rejects binary names with spaces', async () => {
|
||||
const registry = await import('../registry');
|
||||
const original = registry.APP_REGISTRY.vscode.launch.linux;
|
||||
registry.APP_REGISTRY.vscode.launch.linux = {
|
||||
binary: 'foo bar',
|
||||
type: 'exec',
|
||||
};
|
||||
|
||||
const result = await launchApp('vscode', '/work/dir', 'linux');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Invalid binary name');
|
||||
|
||||
registry.APP_REGISTRY.vscode.launch.linux = original;
|
||||
});
|
||||
|
||||
it('accepts absolute-path binary names', async () => {
|
||||
const registry = await import('../registry');
|
||||
const original = registry.APP_REGISTRY.vscode.launch.linux;
|
||||
registry.APP_REGISTRY.vscode.launch.linux = {
|
||||
binary: '/usr/local/bin/code',
|
||||
type: 'exec',
|
||||
};
|
||||
|
||||
respondExec({ code: 0 });
|
||||
|
||||
const result = await launchApp('vscode', '/work/dir', 'linux');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const call = captureExec();
|
||||
expect(call.file).toBe('/usr/local/bin/code');
|
||||
|
||||
registry.APP_REGISTRY.vscode.launch.linux = original;
|
||||
});
|
||||
|
||||
it('returns stderr substring on non-zero exit', async () => {
|
||||
respondExec({ code: 1, stderr: 'command not found' });
|
||||
|
||||
const result = await launchApp('vscode', '/work/dir', 'linux');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('command not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('launchApp - shellOpenPath strategy', () => {
|
||||
it('delegates to shell.openPath', async () => {
|
||||
mockedShell.openPath.mockResolvedValueOnce('');
|
||||
|
||||
const result = await launchApp('explorer', '/abs/work-dir', 'win32');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockedShell.openPath).toHaveBeenCalledWith('/abs/work-dir');
|
||||
});
|
||||
|
||||
it('returns error string from shell.openPath as error', async () => {
|
||||
mockedShell.openPath.mockResolvedValueOnce('cannot open');
|
||||
|
||||
const result = await launchApp('files', '/some/dir', 'linux');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('cannot open');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { DetectedApp } from '@lobechat/electron-client-ipc';
|
||||
|
||||
import { detectAllApps } from './detectors';
|
||||
|
||||
let cachedPromise: Promise<DetectedApp[]> | null = null;
|
||||
|
||||
export const getCachedDetection = (
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): Promise<DetectedApp[]> => {
|
||||
if (!cachedPromise) {
|
||||
cachedPromise = detectAllApps(platform);
|
||||
}
|
||||
return cachedPromise;
|
||||
};
|
||||
|
||||
export const clearDetectionCache = (): void => {
|
||||
cachedPromise = null;
|
||||
};
|
||||
@@ -0,0 +1,109 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { access } from 'node:fs/promises';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
import type { DetectedApp, OpenInAppId } from '@lobechat/electron-client-ipc';
|
||||
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { extractAllIcons } from './iconExtractor';
|
||||
import type { DetectStrategy } from './registry';
|
||||
import { ALWAYS_INSTALLED, APP_REGISTRY } from './registry';
|
||||
|
||||
// Icon extraction shells out to plutil + sips on macOS (see iconExtractor.ts)
|
||||
// so Electron itself cannot crash on `app.getFileIcon` regressions. Renderer
|
||||
// falls back to lucide if extraction returns undefined.
|
||||
|
||||
const logger = createLogger('modules:openInApp:detectors');
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const SAFE_BINARY_REGEX = /^[\w.-]+$/;
|
||||
|
||||
const probeAppBundle = async (paths: string[]): Promise<boolean> => {
|
||||
for (const path of paths) {
|
||||
try {
|
||||
await access(path);
|
||||
return true;
|
||||
} catch {
|
||||
// try next
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const probeCommandV = async (binary: string): Promise<boolean> => {
|
||||
if (!SAFE_BINARY_REGEX.test(binary)) {
|
||||
logger.debug(`rejecting unsafe binary name for commandV: ${binary}`);
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await execFileAsync('/bin/sh', ['-c', `command -v "${binary}"`]);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.debug(`commandV probe failed for ${binary}: ${(error as Error).message}`);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const probeRegistryAppPaths = async (exeName: string): Promise<boolean> => {
|
||||
try {
|
||||
await execFileAsync('where', [exeName], { windowsHide: true });
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.debug(`where probe failed for ${exeName}: ${(error as Error).message}`);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const runDetectStrategy = (strategy: DetectStrategy): Promise<boolean> => {
|
||||
switch (strategy.type) {
|
||||
case 'appBundle': {
|
||||
return probeAppBundle(strategy.paths);
|
||||
}
|
||||
case 'commandV': {
|
||||
return probeCommandV(strategy.binary);
|
||||
}
|
||||
case 'registryAppPaths': {
|
||||
return probeRegistryAppPaths(strategy.exeName);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const detectApp = async (id: OpenInAppId, platform: NodeJS.Platform): Promise<boolean> => {
|
||||
if (ALWAYS_INSTALLED[platform] === id) {
|
||||
return true;
|
||||
}
|
||||
const descriptor = APP_REGISTRY[id];
|
||||
const strategy = descriptor?.detect[platform];
|
||||
if (!strategy) {
|
||||
return false;
|
||||
}
|
||||
return runDetectStrategy(strategy);
|
||||
};
|
||||
|
||||
export const detectAllApps = async (
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): Promise<DetectedApp[]> => {
|
||||
const entries = Object.entries(APP_REGISTRY) as Array<
|
||||
[OpenInAppId, (typeof APP_REGISTRY)[OpenInAppId]]
|
||||
>;
|
||||
const installedFlags = await Promise.all(entries.map(([id]) => detectApp(id, platform)));
|
||||
|
||||
// Extract icons for installed apps only. Extraction shells out to plutil +
|
||||
// sips (see iconExtractor.ts) so it cannot crash the renderer; failures
|
||||
// resolve to undefined and the renderer falls back to lucide icons.
|
||||
const installedIds = entries.filter((_entry, i) => installedFlags[i]).map(([id]) => id);
|
||||
const icons = await extractAllIcons(installedIds, platform);
|
||||
|
||||
return entries.map(([id, descriptor], i) => {
|
||||
const installed = installedFlags[i];
|
||||
const icon = installed ? icons.get(id) : undefined;
|
||||
return {
|
||||
displayName: descriptor.displayName,
|
||||
id,
|
||||
installed,
|
||||
...(icon ? { icon } : {}),
|
||||
} satisfies DetectedApp;
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,210 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { access, mkdtemp, readFile, unlink } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { OpenInAppId } from '@lobechat/electron-client-ipc';
|
||||
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { APP_REGISTRY } from './registry';
|
||||
|
||||
const logger = createLogger('modules:openInApp:iconExtractor');
|
||||
|
||||
// Manual promise wrapper rather than util.promisify(execFile): the latter
|
||||
// relies on execFile's custom `util.promisify.custom` symbol to return
|
||||
// `{ stdout, stderr }`, which vi.fn() mocks don't carry — so destructuring
|
||||
// silently yields `undefined` under test. This wrapper resolves directly to
|
||||
// the stdout string and is mock-friendly.
|
||||
const execFileToString = (
|
||||
file: string,
|
||||
args: string[],
|
||||
opts?: { timeout?: number },
|
||||
): Promise<string> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const cb = (err: Error | null, stdout: string, stderr: string) => {
|
||||
if (err) {
|
||||
(err as Error & { stderr?: string }).stderr = stderr;
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(stdout);
|
||||
}
|
||||
};
|
||||
if (opts) execFile(file, args, opts, cb);
|
||||
else execFile(file, args, cb);
|
||||
});
|
||||
|
||||
/** Render dimensions for the extracted PNG. 64 keeps the payload tiny while
|
||||
* staying crisp at the renderer's 16-20 px display size on retina. */
|
||||
const ICON_SIZE = 64;
|
||||
|
||||
/** Per-extraction bound. plutil and sips are local file ops; tens of ms is
|
||||
* typical, so a generous timeout still catches real hangs. */
|
||||
const EXEC_TIMEOUT_MS = 5000;
|
||||
|
||||
let tmpDirPromise: Promise<string | undefined> | undefined;
|
||||
|
||||
const ensureTmpDir = async (): Promise<string | undefined> => {
|
||||
if (tmpDirPromise) return tmpDirPromise;
|
||||
tmpDirPromise = (async () => {
|
||||
try {
|
||||
return await mkdtemp(path.join(tmpdir(), 'lobehub-openinapp-'));
|
||||
} catch (error) {
|
||||
logger.debug(`failed to create tmp dir: ${(error as Error).message}`);
|
||||
return undefined;
|
||||
}
|
||||
})();
|
||||
return tmpDirPromise;
|
||||
};
|
||||
|
||||
let toolsAvailablePromise: Promise<boolean> | undefined;
|
||||
|
||||
/**
|
||||
* Confirm `plutil` and `sips` are both on PATH. Both ship with every macOS
|
||||
* install so this is effectively a sanity check; cached for the process lifetime.
|
||||
*/
|
||||
const areToolsAvailable = (): Promise<boolean> => {
|
||||
if (toolsAvailablePromise) return toolsAvailablePromise;
|
||||
toolsAvailablePromise = (async () => {
|
||||
try {
|
||||
await execFileToString('/usr/bin/which', ['plutil']);
|
||||
await execFileToString('/usr/bin/which', ['sips']);
|
||||
return true;
|
||||
} catch {
|
||||
logger.debug('plutil or sips missing from PATH; falling back to renderer icons');
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
return toolsAvailablePromise;
|
||||
};
|
||||
|
||||
const resolveDarwinBundlePath = async (id: OpenInAppId): Promise<string | undefined> => {
|
||||
const strategy = APP_REGISTRY[id]?.detect.darwin;
|
||||
if (!strategy || strategy.type !== 'appBundle') return undefined;
|
||||
for (const candidate of strategy.paths) {
|
||||
try {
|
||||
await access(candidate);
|
||||
return candidate;
|
||||
} catch {
|
||||
// try next
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Look up the bundle's icon file name via Info.plist (`CFBundleIconFile`).
|
||||
* Returns the resolved absolute .icns path, or undefined if not derivable.
|
||||
*/
|
||||
const resolveIcnsPath = async (bundlePath: string): Promise<string | undefined> => {
|
||||
const plistPath = path.join(bundlePath, 'Contents', 'Info.plist');
|
||||
try {
|
||||
const stdout = await execFileToString(
|
||||
'plutil',
|
||||
['-extract', 'CFBundleIconFile', 'raw', plistPath],
|
||||
{ timeout: EXEC_TIMEOUT_MS },
|
||||
);
|
||||
const iconName = stdout.trim();
|
||||
if (!iconName) return undefined;
|
||||
const fileName = iconName.endsWith('.icns') ? iconName : `${iconName}.icns`;
|
||||
const icnsPath = path.join(bundlePath, 'Contents', 'Resources', fileName);
|
||||
await access(icnsPath);
|
||||
return icnsPath;
|
||||
} catch (error) {
|
||||
logger.debug(`resolveIcnsPath failed for ${bundlePath}: ${(error as Error).message}`);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Resize/convert the given .icns to a 64×64 PNG using sips, then return the
|
||||
* base64 data URL. The PNG file is unlinked after read.
|
||||
*/
|
||||
const renderIcnsToDataUrl = async (
|
||||
icnsPath: string,
|
||||
tmpDir: string,
|
||||
filename: string,
|
||||
): Promise<string | undefined> => {
|
||||
const outPath = path.join(tmpDir, filename);
|
||||
try {
|
||||
await execFileToString(
|
||||
'sips',
|
||||
[
|
||||
'-z',
|
||||
String(ICON_SIZE),
|
||||
String(ICON_SIZE),
|
||||
'-s',
|
||||
'format',
|
||||
'png',
|
||||
icnsPath,
|
||||
'--out',
|
||||
outPath,
|
||||
],
|
||||
{ timeout: EXEC_TIMEOUT_MS },
|
||||
);
|
||||
const buf = await readFile(outPath);
|
||||
if (buf.length === 0) return undefined;
|
||||
return `data:image/png;base64,${buf.toString('base64')}`;
|
||||
} catch (error) {
|
||||
logger.debug(`sips failed for ${icnsPath}: ${(error as Error).message}`);
|
||||
return undefined;
|
||||
} finally {
|
||||
unlink(outPath).catch(() => undefined);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract the real macOS app icon for the given AppId by reading the bundle's
|
||||
* Info.plist (`CFBundleIconFile`) and rendering the resolved .icns via `sips`.
|
||||
* Both `plutil` and `sips` ship with every macOS install — no Xcode, swift, or
|
||||
* electron-builder bundling required, and no JXA / NSImage drawing path
|
||||
* (which is broken in JXA: lockFocus and NSGraphicsContext class methods are
|
||||
* not exposed). macOS only; other platforms return undefined.
|
||||
*/
|
||||
export const extractAppIcon = async (
|
||||
id: OpenInAppId,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): Promise<string | undefined> => {
|
||||
if (platform !== 'darwin') return undefined;
|
||||
try {
|
||||
if (!(await areToolsAvailable())) return undefined;
|
||||
const bundlePath = await resolveDarwinBundlePath(id);
|
||||
if (!bundlePath) return undefined;
|
||||
const icnsPath = await resolveIcnsPath(bundlePath);
|
||||
if (!icnsPath) return undefined;
|
||||
const tmpDir = await ensureTmpDir();
|
||||
if (!tmpDir) return undefined;
|
||||
return await renderIcnsToDataUrl(icnsPath, tmpDir, `${id}.png`);
|
||||
} catch (error) {
|
||||
logger.debug(`extractAppIcon error for ${id}: ${(error as Error).message}`);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve icons for a list of installed AppIds. Sequential — keeps spawn
|
||||
* pressure low and matches the underlying single-thread tools.
|
||||
*/
|
||||
export const extractAllIcons = async (
|
||||
installedIds: OpenInAppId[],
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): Promise<Map<OpenInAppId, string>> => {
|
||||
const map = new Map<OpenInAppId, string>();
|
||||
for (const id of installedIds) {
|
||||
try {
|
||||
const icon = await extractAppIcon(id, platform);
|
||||
if (icon) map.set(id, icon);
|
||||
} catch (error) {
|
||||
logger.debug(`extractAllIcons: skipping ${id} after error: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
};
|
||||
|
||||
/**
|
||||
* Test-only: reset the module-level caches so each test starts fresh.
|
||||
*/
|
||||
export const __resetForTest = () => {
|
||||
tmpDirPromise = undefined;
|
||||
toolsAvailablePromise = undefined;
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { access } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
import type { OpenInAppId, OpenInAppResult } from '@lobechat/electron-client-ipc';
|
||||
import { shell } from 'electron';
|
||||
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import type { LaunchStrategy } from './registry';
|
||||
import { APP_REGISTRY } from './registry';
|
||||
|
||||
const logger = createLogger('modules:openInApp:launchers');
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const SAFE_BINARY_REGEX = /^[\w.-]+$/;
|
||||
|
||||
const isAllowedBinary = (binary: string): boolean =>
|
||||
SAFE_BINARY_REGEX.test(binary) || path.isAbsolute(binary);
|
||||
|
||||
interface ExecError extends Error {
|
||||
stderr?: string;
|
||||
}
|
||||
|
||||
const formatExecError = (error: unknown): string => {
|
||||
const err = error as ExecError;
|
||||
const stderr = typeof err?.stderr === 'string' ? err.stderr.trim() : '';
|
||||
const fallback = err?.message ?? 'Launch failed';
|
||||
return (stderr || fallback).slice(0, 200);
|
||||
};
|
||||
|
||||
const runLaunchStrategy = async (
|
||||
strategy: LaunchStrategy,
|
||||
absolutePath: string,
|
||||
): Promise<OpenInAppResult> => {
|
||||
switch (strategy.type) {
|
||||
case 'macOpenA': {
|
||||
try {
|
||||
await execFileAsync('open', ['-a', strategy.appName, absolutePath]);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { error: formatExecError(error), success: false };
|
||||
}
|
||||
}
|
||||
case 'macOpen': {
|
||||
try {
|
||||
await execFileAsync('open', [absolutePath]);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { error: formatExecError(error), success: false };
|
||||
}
|
||||
}
|
||||
case 'exec': {
|
||||
if (!isAllowedBinary(strategy.binary)) {
|
||||
return { error: 'Invalid binary name', success: false };
|
||||
}
|
||||
const extraArgs = strategy.args ?? [];
|
||||
try {
|
||||
await execFileAsync(strategy.binary, [...extraArgs, absolutePath]);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { error: formatExecError(error), success: false };
|
||||
}
|
||||
}
|
||||
case 'shellOpenPath': {
|
||||
const result = await shell.openPath(absolutePath);
|
||||
return result ? { error: result, success: false } : { success: true };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const launchApp = async (
|
||||
id: OpenInAppId,
|
||||
absolutePath: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): Promise<OpenInAppResult> => {
|
||||
const descriptor = APP_REGISTRY[id];
|
||||
const strategy = descriptor?.launch[platform];
|
||||
if (!descriptor || !strategy) {
|
||||
const displayName = descriptor?.displayName ?? id;
|
||||
return {
|
||||
error: `${displayName} is not available on this platform`,
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (!path.isAbsolute(absolutePath)) {
|
||||
return { error: 'Path must be absolute', success: false };
|
||||
}
|
||||
|
||||
try {
|
||||
await access(absolutePath);
|
||||
} catch {
|
||||
return { error: `Path not found: ${absolutePath}`, success: false };
|
||||
}
|
||||
|
||||
const result = await runLaunchStrategy(strategy, absolutePath);
|
||||
if (result.success) {
|
||||
logger.info(`launched ${id} at ${absolutePath}`);
|
||||
} else {
|
||||
logger.error(`failed to launch ${id} at ${absolutePath}: ${result.error}`);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { OpenInAppId } from '@lobechat/electron-client-ipc';
|
||||
|
||||
export type DetectStrategy =
|
||||
| { paths: string[]; type: 'appBundle' }
|
||||
| { exeName: string; type: 'registryAppPaths' }
|
||||
| { binary: string; type: 'commandV' };
|
||||
|
||||
export type LaunchStrategy =
|
||||
| { appName: string; type: 'macOpenA' }
|
||||
| { type: 'macOpen' }
|
||||
| { args?: string[]; binary: string; type: 'exec' }
|
||||
| { type: 'shellOpenPath' };
|
||||
|
||||
export interface AppDescriptor {
|
||||
detect: Partial<Record<NodeJS.Platform, DetectStrategy>>;
|
||||
displayName: string;
|
||||
launch: Partial<Record<NodeJS.Platform, LaunchStrategy>>;
|
||||
}
|
||||
|
||||
export const APP_REGISTRY: Record<OpenInAppId, AppDescriptor> = {
|
||||
vscode: {
|
||||
detect: {
|
||||
darwin: { paths: ['/Applications/Visual Studio Code.app'], type: 'appBundle' },
|
||||
linux: { binary: 'code', type: 'commandV' },
|
||||
win32: { exeName: 'Code.exe', type: 'registryAppPaths' },
|
||||
},
|
||||
displayName: 'VS Code',
|
||||
launch: {
|
||||
darwin: { appName: 'Visual Studio Code', type: 'macOpenA' },
|
||||
linux: { binary: 'code', type: 'exec' },
|
||||
win32: { binary: 'code', type: 'exec' },
|
||||
},
|
||||
},
|
||||
cursor: {
|
||||
detect: {
|
||||
darwin: { paths: ['/Applications/Cursor.app'], type: 'appBundle' },
|
||||
linux: { binary: 'cursor', type: 'commandV' },
|
||||
win32: { exeName: 'Cursor.exe', type: 'registryAppPaths' },
|
||||
},
|
||||
displayName: 'Cursor',
|
||||
launch: {
|
||||
darwin: { appName: 'Cursor', type: 'macOpenA' },
|
||||
linux: { binary: 'cursor', type: 'exec' },
|
||||
win32: { binary: 'cursor', type: 'exec' },
|
||||
},
|
||||
},
|
||||
zed: {
|
||||
detect: {
|
||||
darwin: { paths: ['/Applications/Zed.app'], type: 'appBundle' },
|
||||
linux: { binary: 'zed', type: 'commandV' },
|
||||
},
|
||||
displayName: 'Zed',
|
||||
launch: {
|
||||
darwin: { appName: 'Zed', type: 'macOpenA' },
|
||||
linux: { binary: 'zed', type: 'exec' },
|
||||
},
|
||||
},
|
||||
webstorm: {
|
||||
detect: {
|
||||
darwin: { paths: ['/Applications/WebStorm.app'], type: 'appBundle' },
|
||||
linux: { binary: 'webstorm', type: 'commandV' },
|
||||
win32: { exeName: 'webstorm64.exe', type: 'registryAppPaths' },
|
||||
},
|
||||
displayName: 'WebStorm',
|
||||
launch: {
|
||||
darwin: { appName: 'WebStorm', type: 'macOpenA' },
|
||||
linux: { binary: 'webstorm', type: 'exec' },
|
||||
win32: { binary: 'webstorm', type: 'exec' },
|
||||
},
|
||||
},
|
||||
xcode: {
|
||||
detect: { darwin: { paths: ['/Applications/Xcode.app'], type: 'appBundle' } },
|
||||
displayName: 'Xcode',
|
||||
launch: { darwin: { appName: 'Xcode', type: 'macOpenA' } },
|
||||
},
|
||||
finder: {
|
||||
detect: {
|
||||
darwin: { paths: ['/System/Library/CoreServices/Finder.app'], type: 'appBundle' },
|
||||
},
|
||||
displayName: 'Finder',
|
||||
launch: { darwin: { type: 'macOpen' } },
|
||||
},
|
||||
explorer: {
|
||||
detect: { win32: { exeName: 'explorer.exe', type: 'registryAppPaths' } },
|
||||
displayName: 'Explorer',
|
||||
launch: { win32: { type: 'shellOpenPath' } },
|
||||
},
|
||||
files: {
|
||||
detect: { linux: { binary: 'xdg-open', type: 'commandV' } },
|
||||
displayName: 'Files',
|
||||
launch: { linux: { type: 'shellOpenPath' } },
|
||||
},
|
||||
terminal: {
|
||||
detect: {
|
||||
darwin: {
|
||||
paths: [
|
||||
'/System/Applications/Utilities/Terminal.app',
|
||||
'/Applications/Utilities/Terminal.app',
|
||||
],
|
||||
type: 'appBundle',
|
||||
},
|
||||
},
|
||||
displayName: 'Terminal',
|
||||
launch: { darwin: { appName: 'Terminal', type: 'macOpenA' } },
|
||||
},
|
||||
iterm2: {
|
||||
detect: { darwin: { paths: ['/Applications/iTerm.app'], type: 'appBundle' } },
|
||||
displayName: 'iTerm2',
|
||||
launch: { darwin: { appName: 'iTerm', type: 'macOpenA' } },
|
||||
},
|
||||
ghostty: {
|
||||
detect: {
|
||||
darwin: { paths: ['/Applications/Ghostty.app'], type: 'appBundle' },
|
||||
linux: { binary: 'ghostty', type: 'commandV' },
|
||||
},
|
||||
displayName: 'Ghostty',
|
||||
launch: {
|
||||
darwin: { appName: 'Ghostty', type: 'macOpenA' },
|
||||
linux: { binary: 'ghostty', type: 'exec' },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** AppIds that are always considered "installed" — file managers, which we treat as platform-provided. */
|
||||
export const ALWAYS_INSTALLED: Partial<Record<NodeJS.Platform, OpenInAppId>> = {
|
||||
darwin: 'finder',
|
||||
linux: 'files',
|
||||
win32: 'explorer',
|
||||
};
|
||||
@@ -19,6 +19,7 @@ export interface ElectronMainStore {
|
||||
gatewayEnabled: boolean;
|
||||
gatewayUrl: string;
|
||||
locale: string;
|
||||
localFileWorkspaceRoots: string[];
|
||||
networkProxy: NetworkProxySettings;
|
||||
shortcuts: Record<string, string>;
|
||||
storagePath: string;
|
||||
|
||||
@@ -4,22 +4,46 @@ export const getExportMimeType = (filePath: string) => {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
|
||||
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];
|
||||
|
||||
@@ -1,4 +1,58 @@
|
||||
[
|
||||
{
|
||||
"children": {},
|
||||
"date": "2026-05-18",
|
||||
"version": "2.2.0"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"features": [
|
||||
"support slack mpim and fix discord dm problem.",
|
||||
"add service model assignments settings.",
|
||||
"inline skill auth in recommended task templates.",
|
||||
"add user activity business hook.",
|
||||
"add Gemini 3.1 Flash-Lite provider cards.",
|
||||
"home daily brief with linkable welcome + paired input hint.",
|
||||
"add signOperationJwt with 4h expiry for hetero-agent operations.",
|
||||
"migrate Notion to LobeHub Market.",
|
||||
"Cloud Claude Code V3 — repo picker, GitHub token, sandbox context."
|
||||
],
|
||||
"fixes": [
|
||||
"Docs image.",
|
||||
"update Task page placeholder copy.",
|
||||
"sidebar add agent.",
|
||||
"replace ScrollShadow with ScrollArea to fix React #185 infinite render loop.",
|
||||
"reject inactive OIDC access.",
|
||||
"drop unreachable aihubmix empty-apiKey test.",
|
||||
"consume visual content parts in server runtime.",
|
||||
"store onboarding interests as keys.",
|
||||
"remove the old cron job from lobehub.",
|
||||
"refresh content baseline from DB on every ingest call.",
|
||||
"gateway client-tool pluginState + drop redundant Exit code: 0 tail.",
|
||||
"first inject the cloudecc runtime session should use the existingStatus.",
|
||||
"slack connect error & slash commands.",
|
||||
"polish task agent manager.",
|
||||
"remove signin captcha flow.",
|
||||
"add temporary email auth error locale.",
|
||||
"add bot callback service.",
|
||||
"sanitize sensitive comments and examples from production JS bundle.",
|
||||
"multiple account link."
|
||||
],
|
||||
"improvements": [
|
||||
"use @lobehub/ui built-in HtmlPreview instead of custom component.",
|
||||
"polish desktop header icons, sidebar density, and task menus.",
|
||||
"standardize header action icon sizes.",
|
||||
"add reasoning_effort support for Grok 4.3.",
|
||||
"increase chat topic title length.",
|
||||
"format tool execution time as Xmin Ys instead of X.Y min.",
|
||||
"Add new DeepSeek-V4 models.",
|
||||
"use visible divider between queued messages.",
|
||||
"update auth captcha retry copy."
|
||||
]
|
||||
},
|
||||
"date": "2026-05-13",
|
||||
"version": "2.1.58"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["hide runtime-only model aliases."],
|
||||
|
||||
@@ -19,10 +19,10 @@ tags:
|
||||
|
||||
## 连接模式
|
||||
|
||||
LobeHub ��持两种 QQ 机器人连接模式:
|
||||
LobeHub 支持两种 QQ 机器人连接模式:
|
||||
|
||||
- **WebSocket(推荐)** — 持久连接。事件通过 WebSocket 实时推送,无需配置回调地址。这是新机器人的默认模式。
|
||||
- **Webhook** — 无状态 HTTP ��调。如果您的机器人已在 QQ 开放平台配置了回调地址且无法切换,请使用此模式。
|
||||
- **Webhook** — 无状态 HTTP 回调。如果您的机器人已在 QQ 开放平台配置了回调地址且无法切换,请使用此模式。
|
||||
|
||||
> **注意:** 在 QQ 开放平台上,一旦机器人配置了 Webhook 回调地址,就无法切换到 WebSocket 模式。尚未配置回调地址的新机器人应使用 WebSocket 模式。
|
||||
|
||||
@@ -60,7 +60,7 @@ LobeHub ��持两种 QQ 机器人连接模式:
|
||||
<Steps>
|
||||
### 打开渠道设置
|
||||
|
||||
在 LobeHub 中,导航到您的代理设置,然后选择 **渠道** 标签页。��平台列表中点击 **QQ**。
|
||||
在 LobeHub 中,导航到您的代理设置,然后选择 **渠道** 标签页。从平台列表中点击 **QQ**。
|
||||
|
||||
### 输入应用凭证
|
||||
|
||||
@@ -199,7 +199,7 @@ LobeHub ��持两种 QQ 机器人连接模式:
|
||||
|
||||
## 故障排除
|
||||
|
||||
- **机器人无法连接(WebSocket 模式):** 验证 App ID 和 App Secret 是否正确。确保机��人在 QQ 开放平台上未配置回调地址 — 一旦设置了回调地址,WebSocket 模式将不可用。
|
||||
- **机器人无法连接(WebSocket 模式):** 验证 App ID 和 App Secret 是否正确。确保机器人在 QQ 开放平台上未配置回调地址 — 一旦设置了回调地址,WebSocket 模式将不可用。
|
||||
- **回调地址验证失败(Webhook 模式):** 确保您已在 LobeHub 中保存配置,并正确复制了 URL。LobeHub 会自动处理 Ed25519 验证。
|
||||
- **机器人未响应:** 验证 App ID 和 App Secret 是否正确,机器人是否已发布(或您是沙盒测试用户),以及是否订阅了所需的消息事件。
|
||||
- **群聊问题:** 确保机器人已被添加到群聊中。@提及机器人以触发响应。
|
||||
|
||||
@@ -85,6 +85,7 @@ LobeHub supports two connection modes for Slack:
|
||||
event_subscriptions:
|
||||
bot_events:
|
||||
- app_mention
|
||||
- app_home_opened
|
||||
- message.channels
|
||||
- message.groups
|
||||
- message.im
|
||||
@@ -195,7 +196,7 @@ Use this method if your Slack app already has Event Subscriptions configured wit
|
||||
|
||||
### Configure Event Subscriptions
|
||||
|
||||
In the Slack API Dashboard → **Event Subscriptions**, enable events, paste the Webhook URL as the **Request URL**, and subscribe to bot events: `app_mention`, `message.channels`, `message.groups`, `message.im`, `message.mpim`, `member_joined_channel`.
|
||||
In the Slack API Dashboard → **Event Subscriptions**, enable events, paste the Webhook URL as the **Request URL**, and subscribe to bot events: `app_mention`, `app_home_opened`, `message.channels`, `message.groups`, `message.im`, `message.mpim`, `member_joined_channel`.
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -82,6 +82,7 @@ LobeHub 支持两种 Slack 连接模式:
|
||||
event_subscriptions:
|
||||
bot_events:
|
||||
- app_mention
|
||||
- app_home_opened
|
||||
- message.channels
|
||||
- message.groups
|
||||
- message.im
|
||||
@@ -192,7 +193,7 @@ LobeHub 支持两种 Slack 连接模式:
|
||||
|
||||
### 配置事件订阅
|
||||
|
||||
在 Slack API 控制台 → **Event Subscriptions** 中,启用事件,将 Webhook URL 粘贴为 **Request URL**,订阅事件:`app_mention`、`message.channels`、`message.groups`、`message.im`、`message.mpim`、`member_joined_channel`。
|
||||
在 Slack API 控制台 → **Event Subscriptions** 中,启用事件,将 Webhook URL 粘贴为 **Request URL**,订阅事件:`app_mention`、`app_home_opened`、`message.channels`、`message.groups`、`message.im`、`message.mpim`、`member_joined_channel`。
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
---
|
||||
title: Use LobeHub on Discord
|
||||
description: >-
|
||||
Add the official LobeHub bot to a Discord server, then link your Discord
|
||||
account to LobeHub. Pick a default agent and chat with your AI assistants in
|
||||
Discord DMs — no bot setup required.
|
||||
tags:
|
||||
- Messenger
|
||||
- Discord
|
||||
- LobeHub Bot
|
||||
- Account Linking
|
||||
- Server Install
|
||||
---
|
||||
|
||||
# Use LobeHub on Discord
|
||||
|
||||
Discord works in two phases: a **server admin adds** the official LobeHub bot to a Discord server once, and then **each member links** their personal Discord account to LobeHub. Both phases happen from **Settings → Messenger → Discord**.
|
||||
|
||||
> The Discord install audit is per-server, but your **personal link is global** to your Discord account — you only link once, and the same link works in every server that has the bot.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A LobeHub account
|
||||
- A Discord account
|
||||
- For the install step: **Manage Server** permission on the target Discord server
|
||||
|
||||
## Phase A — Add the LobeHub bot to a server (server admin)
|
||||
|
||||
<Steps>
|
||||
### Open Settings → Messenger → Discord
|
||||
|
||||
In LobeHub, open **Settings → Messenger** and click the **Discord** card. Click **Connect** in the top-right.
|
||||
|
||||
{/* TODO: screenshot — Discord detail page with empty Connections list and the Connect button */}
|
||||
|
||||
### Authorise in Discord
|
||||
|
||||
You'll be redirected to Discord's bot-add screen. Pick the server you want to add the bot to and click **Authorise**.
|
||||
|
||||
{/* TODO: screenshot — Discord OAuth consent screen with the server picker */}
|
||||
|
||||
### Server appears under Connections
|
||||
|
||||
After approval you're redirected back to LobeHub. The server appears as a **server** row in **Connections**.
|
||||
|
||||
{/* TODO: screenshot — Discord detail page showing one connected server row */}
|
||||
|
||||
> **Server already connected by someone else?** LobeHub shows a "Server already connected" notice. You don't need to add the bot again — just DM the LobeHub bot in Discord to link your personal account.
|
||||
</Steps>
|
||||
|
||||
## Phase B — Link your personal account (each member)
|
||||
|
||||
<Steps>
|
||||
### Open the LobeHub bot in Discord
|
||||
|
||||
Open the LobeHub bot in Discord — the **Open in Discord** button on the Discord detail page (or the pending-link row) takes you straight there.
|
||||
|
||||
{/* TODO: screenshot — Discord detail page with the pending user row + Open in Discord button */}
|
||||
|
||||
### Send any message
|
||||
|
||||
In the bot DM, send any message to trigger the linking flow. The bot replies with a one-time confirmation link.
|
||||
|
||||
{/* TODO: screenshot — Discord DM with the bot's reply containing the link button */}
|
||||
|
||||
### Confirm the link in your browser
|
||||
|
||||
Tap the link, sign in to LobeHub if asked, and choose a **default agent**. Every message you DM the LobeHub bot in Discord (across all servers) will route to this agent.
|
||||
|
||||
{/* TODO: screenshot — confirm-link page in LobeHub with the agent picker */}
|
||||
|
||||
Your link appears as a **user** row in **Connections**.
|
||||
|
||||
{/* TODO: screenshot — Discord detail page showing server row + connected user row */}
|
||||
</Steps>
|
||||
|
||||
## Switching the Active Agent
|
||||
|
||||
Two equivalent ways:
|
||||
|
||||
- **In Discord** — DM the bot `/agents` and pick a different agent.
|
||||
- **In LobeHub** — open **Settings → Messenger → Discord** and use the agent picker on your link row.
|
||||
|
||||
The change takes effect on the next message you send.
|
||||
|
||||
## Disconnecting
|
||||
|
||||
Discord has two distinct disconnect actions, with one important difference from Slack:
|
||||
|
||||
| Action | Effect |
|
||||
| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Disconnect** on a *user* row | Unlinks **your** LobeHub account from your Discord account. The bot stops accepting your DMs until you message it and link again. |
|
||||
| **Disconnect** on a *server* row | Removes the install **audit entry only**. **The bot stays in the Discord server** until a server admin manually kicks it. Other people's personal links are unaffected. |
|
||||
|
||||
You can re-add the bot to a server (or re-link personally) at any time by repeating the relevant phase.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **"Server already connected"** — Another LobeHub user already added the bot to this server. DM the LobeHub bot in Discord to link your personal account; you do not need to add the bot again.
|
||||
- **Discord install failed (`<reason>`)** — Common reasons: authorisation cancelled, install session expired (re-open the modal and try again), Discord returned incomplete data (retry; if persistent, contact support).
|
||||
- **Bot is in the server but doesn't reply** — Check that you have a personal link under **Settings → Messenger → Discord**. The bot only answers users with a confirmed personal link.
|
||||
- **Removed the audit row but the bot is still in my server** — That's expected. Disconnecting in LobeHub only removes the audit entry; a Discord server admin must kick the bot from Discord itself.
|
||||
- **"This link is already used"** — One-time confirmation links expire after one use. DM the bot again to get a new link.
|
||||
- **"This account is already linked"** — Your Discord account is bound to a different LobeHub account. Sign in to that LobeHub account to manage the link, or unlink there before retrying.
|
||||
- **"Another Discord account is already linked"** — Your LobeHub account already has a Discord link. Disconnect it in **Settings → Messenger → Discord** before linking a new Discord account.
|
||||
@@ -0,0 +1,104 @@
|
||||
---
|
||||
title: 在 Discord 使用 LobeHub
|
||||
description: >-
|
||||
把官方 LobeHub 机器人加入 Discord 服务器,再把自己的 Discord 账号关联到
|
||||
LobeHub;选择默认 Agent,就能在 Discord DM 里直接和 AI 助手对话,无需自建机器人。
|
||||
tags:
|
||||
- Messenger
|
||||
- Discord
|
||||
- LobeHub 机器人
|
||||
- 账号关联
|
||||
- 服务器安装
|
||||
---
|
||||
|
||||
# 在 Discord 使用 LobeHub
|
||||
|
||||
Discord 接入分两步:**服务器管理员把 LobeHub 机器人加入 Discord 服务器**(每个服务器一次),然后 **每位成员把自己的 Discord 账号关联到 LobeHub**。两步都在 **设置 → Messenger → Discord** 里完成。
|
||||
|
||||
> Discord 安装审计是按服务器记录的,但你的 **个人关联是全局的**,挂在你的 Discord 账号下 —— 只需关联一次,所有装了机器人的服务器里它都生效。
|
||||
|
||||
## 前置条件
|
||||
|
||||
- 一个 LobeHub 账号
|
||||
- 一个 Discord 账号
|
||||
- 安装环节:目标 Discord 服务器的 **Manage Server(管理服务器)** 权限
|
||||
|
||||
## 阶段 A —— 把 LobeHub 机器人加入服务器(服务器管理员)
|
||||
|
||||
<Steps>
|
||||
### 打开「设置 → Messenger → Discord」
|
||||
|
||||
在 LobeHub 中打开 **设置 → Messenger**,点击 **Discord** 卡片,再点右上角的 **连接**。
|
||||
|
||||
{/* TODO: 截图 —— Discord 详情页(Connections 列表为空 + Connect 按钮) */}
|
||||
|
||||
### 在 Discord 中授权
|
||||
|
||||
页面跳转到 Discord 添加机器人页面。选择目标服务器,点击 **Authorise(授权)**。
|
||||
|
||||
{/* TODO: 截图 —— Discord OAuth 授权页(含服务器选择器) */}
|
||||
|
||||
### 服务器出现在 Connections 列表
|
||||
|
||||
授权成功后跳回 LobeHub。该服务器会以 **server** 行的形式出现在 **Connections** 中。
|
||||
|
||||
{/* TODO: 截图 —— Discord 详情页:已连接的 server 行 */}
|
||||
|
||||
> **服务器已被他人连接?** LobeHub 会提示「Server already connected」。你**不需要**再次添加机器人,只要在 Discord 里私聊 LobeHub 机器人完成个人账号关联即可。
|
||||
</Steps>
|
||||
|
||||
## 阶段 B —— 关联个人账号(每位成员)
|
||||
|
||||
<Steps>
|
||||
### 在 Discord 中打开 LobeHub 机器人
|
||||
|
||||
在 Discord 中打开 LobeHub 机器人 —— Discord 详情页(或 pending 状态的 user 行)上的 **在 Discord 中打开** 按钮可以直接跳过去。
|
||||
|
||||
{/* TODO: 截图 —— Discord 详情页:pending 的 user 行 + Open in Discord 按钮 */}
|
||||
|
||||
### 发送任意一条消息
|
||||
|
||||
在机器人 DM 里发送任意一条消息触发关联流程。机器人会回复一个一次性确认链接。
|
||||
|
||||
{/* TODO: 截图 —— Discord DM 中机器人发送的关联按钮消息 */}
|
||||
|
||||
### 在浏览器里确认关联
|
||||
|
||||
点击链接,必要时登录 LobeHub,然后选择一个 **默认 Agent**。之后你在 Discord 任意服务器里私聊 LobeHub 机器人的每条消息都会路由到这个 Agent。
|
||||
|
||||
{/* TODO: 截图 —— LobeHub 确认关联页面,含 Agent 选择器 */}
|
||||
|
||||
关联完成后会在 **Connections** 里以 **user** 行的形式出现。
|
||||
|
||||
{/* TODO: 截图 —— Discord 详情页:server 行 + 已连接的 user 行 */}
|
||||
</Steps>
|
||||
|
||||
## 切换接收消息的 Agent
|
||||
|
||||
两种等价方式:
|
||||
|
||||
- **在 Discord 里** —— 私聊机器人发送 `/agents`,挑一个新的 Agent。
|
||||
- **在 LobeHub 里** —— 打开 **设置 → Messenger → Discord**,在你的关联行里使用 Agent 选择器。
|
||||
|
||||
切换会对你发送的下一条消息立即生效。
|
||||
|
||||
## 断开连接
|
||||
|
||||
Discord 也有两种含义不同的断开操作,有一处和 Slack 不同需要特别注意:
|
||||
|
||||
| 操作 | 效果 |
|
||||
| -------------------- | ----------------------------------------------------------------------------- |
|
||||
| 在 *user* 行点 **断开** | 解除 **你自己** 的 LobeHub 账号与 Discord 账号的关联。机器人不再接收你的 DM,直到你再次发消息并完成关联。 |
|
||||
| 在 *server* 行点 **断开** | **只移除安装审计记录**。**机器人会继续留在 Discord 服务器里**,直到 Discord 服务器管理员手动把它踢出。其他人的个人关联不受影响。 |
|
||||
|
||||
任何时候都可以重新走对应阶段把机器人加回服务器、或重新建立个人关联。
|
||||
|
||||
## 故障排查
|
||||
|
||||
- **"Server already connected"(服务器已连接)** —— 服务器已被另一位 LobeHub 用户加过机器人。直接在 Discord 私聊 LobeHub 机器人完成个人关联即可,无需重新添加。
|
||||
- **Discord 安装失败(`<原因>`)** —— 常见原因:用户取消授权、安装会话过期(重新打开弹窗再试)、Discord 返回的数据不完整(重试;持续失败请联系支持)。
|
||||
- **机器人在服务器但不回我消息** —— 检查 **设置 → Messenger → Discord** 下你是否有个人关联。机器人只回复完成了个人关联的用户。
|
||||
- **断开了审计行,但机器人还在服务器里** —— 这是预期行为。在 LobeHub 里断开只移除审计记录,需要 Discord 服务器管理员手动从 Discord 那边把机器人踢出。
|
||||
- **"This link is already used"** —— 一次性确认链接只能用一次,给机器人再发一条消息获取新链接。
|
||||
- **"This account is already linked"** —— 这个 Discord 账号已绑定到另一个 LobeHub 账号。请用那个 LobeHub 账号登录管理,或先在那边解绑。
|
||||
- **"Another Discord account is already linked"** —— 你的 LobeHub 账号在 Discord 上已有关联。先在 **设置 → Messenger → Discord** 断开旧关联,再绑定新的 Discord 账号。
|
||||
@@ -0,0 +1,84 @@
|
||||
---
|
||||
title: Messenger Overview
|
||||
description: >-
|
||||
Connect your LobeHub account to the official LobeHub bot on Telegram, Slack,
|
||||
and Discord. Link once, pick an active agent, and chat with your assistants
|
||||
from the chat apps you already use — no bot setup required.
|
||||
tags:
|
||||
- Messenger
|
||||
- Telegram
|
||||
- Slack
|
||||
- Discord
|
||||
- Integration
|
||||
---
|
||||
|
||||
# Messenger
|
||||
|
||||
Messenger lets you talk to your LobeHub agents through the **official LobeHub bot** on Telegram, Slack, and Discord. Link your LobeHub account once, choose which agent should answer, and you're done — every message you send to the bot is routed to your agent and replied to in the same conversation.
|
||||
|
||||
You manage everything from **Settings → Messenger** in LobeHub.
|
||||
|
||||
> \[!NOTE]
|
||||
>
|
||||
> Messenger is for **personal use** of LobeHub agents from your favourite chat app. If you want to expose an agent to a public community with your own bot identity, set up a [Channel](/docs/usage/channels/overview) on the agent instead.
|
||||
|
||||
## Messenger vs. Channels
|
||||
|
||||
| | **Messenger** | **Channels** |
|
||||
| ------------- | --------------------------------------------- | --------------------------------------------------------------------- |
|
||||
| Bot identity | Official **@LobeHub** bot, hosted by LobeHub | Your own bot, you bring the token |
|
||||
| Setup effort | Tap **Connect**, send `/start`, pick an agent | Create the bot on the platform, paste credentials, configure policies |
|
||||
| Scope | Personal — only you talk to the bot | Public — anyone in the channel/server can talk to it |
|
||||
| Active agent | One per platform link, switchable any time | One agent per channel binding |
|
||||
| Configured at | Settings → Messenger | Agent → Channels |
|
||||
|
||||
## Supported Platforms
|
||||
|
||||
| Platform | Setup model | Guide |
|
||||
| ------------ | ---------------------------------------- | --------------------------------------------------------- |
|
||||
| **Telegram** | Global bot — any account can DM directly | [Use LobeHub on Telegram](/docs/usage/messenger/telegram) |
|
||||
| **Slack** | Per-workspace install + per-member link | [Use LobeHub on Slack](/docs/usage/messenger/slack) |
|
||||
| **Discord** | Per-server install + per-member link | [Use LobeHub on Discord](/docs/usage/messenger/discord) |
|
||||
|
||||
If a platform card does not appear at **Settings → Messenger**, it has not been enabled on your deployment yet — check back later or ask your administrator.
|
||||
|
||||
{/* TODO: screenshot — Settings → Messenger landing screen showing the three platform cards */}
|
||||
|
||||
## How It Works
|
||||
|
||||
1. You **link** your LobeHub account to a platform account through a short OAuth-style flow.
|
||||
2. You pick a **default agent** during linking. Every message you send to the bot from that platform routes to this agent.
|
||||
3. To switch agents, send `/agents` inside the bot or open **Settings → Messenger** in LobeHub.
|
||||
4. **Disconnect** any time from the same screen — inbound messages stop until you `/start` again.
|
||||
|
||||
Each LobeHub account can hold one link per platform (Slack also tracks one link per workspace).
|
||||
|
||||
## Switching the Active Agent
|
||||
|
||||
You can switch the agent that answers your messages at any time:
|
||||
|
||||
- **From the bot** — send `/agents` and pick a different agent.
|
||||
- **From LobeHub** — open **Settings → Messenger**, select the platform, and use the agent picker on your link row.
|
||||
|
||||
Changes take effect immediately for the next message you send.
|
||||
|
||||
## Disconnecting
|
||||
|
||||
There are two distinct disconnect actions per platform:
|
||||
|
||||
| Action | Effect |
|
||||
| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Disconnect** on a *user* row | Unlinks **your** LobeHub account from the platform account. Inbound messages stop until you `/start` again. |
|
||||
| **Disconnect** on a *workspace / server* row | Removes the install audit entry. On Slack, this also freezes the workspace bot for everyone. On Discord, the bot stays in the server until a server admin kicks it. |
|
||||
|
||||
You can re-link at any time by repeating the **Connect** flow.
|
||||
|
||||
## Common Errors
|
||||
|
||||
These messages can appear during linking regardless of platform:
|
||||
|
||||
- **"This link is already used"** — The one-time confirm link can only be used once. Return to the bot and send `/start` again to issue a new link.
|
||||
- **"This account is already linked"** — The platform account is bound to a different LobeHub account. Sign in to that account to manage it, or unlink it there before retrying.
|
||||
- **"Another \<platform> account is already linked"** — Your LobeHub account already has a link on this platform. Disconnect the existing link in **Settings → Messenger** before linking a new one.
|
||||
|
||||
For platform-specific issues, see the troubleshooting section on each platform's guide.
|
||||
@@ -0,0 +1,83 @@
|
||||
---
|
||||
title: Messenger 概览
|
||||
description: >-
|
||||
将 LobeHub 账号一键关联到 Telegram、Slack、Discord 上的官方 LobeHub
|
||||
机器人。只需绑定一次,选择默认 Agent,就能在常用聊天工具里直接和你的 AI 助手对话,无需自建机器人。
|
||||
tags:
|
||||
- Messenger
|
||||
- Telegram
|
||||
- Slack
|
||||
- Discord
|
||||
- 集成
|
||||
---
|
||||
|
||||
# Messenger
|
||||
|
||||
Messenger 让你通过 **官方 LobeHub 机器人** 在 Telegram、Slack、Discord 上直接和 LobeHub Agent 对话。只需将 LobeHub 账号绑定一次、选好接收消息的 Agent,之后你在机器人里发的每一条消息都会被路由到该 Agent 并在同一会话里回复给你。
|
||||
|
||||
所有配置都集中在 LobeHub 的 **设置 → Messenger** 页面。
|
||||
|
||||
> \[!NOTE]
|
||||
>
|
||||
> Messenger 面向 **个人使用** —— 让你在常用聊天工具里方便地用自己的 LobeHub Agent。如果你想以自家机器人身份把某个 Agent 公开给社区使用,请改用 Agent 上的 [渠道(Channels)](/docs/usage/channels/overview)。
|
||||
|
||||
## Messenger 与渠道(Channels)的区别
|
||||
|
||||
| | **Messenger** | **渠道(Channels)** |
|
||||
| -------- | -------------------------------- | ---------------------- |
|
||||
| 机器人身份 | 官方 **@LobeHub** 机器人,由 LobeHub 托管 | 你自己的机器人,需要自带 Token |
|
||||
| 配置成本 | 点 **连接**、发送 `/start`、选一个 Agent | 在平台上创建机器人、粘贴凭据、配置策略 |
|
||||
| 适用场景 | 个人 —— 只有你自己和机器人对话 | 公开 —— 频道 / 服务器里所有人都能对话 |
|
||||
| 接收 Agent | 每个平台绑定一个,可随时切换 | 每个频道绑定一个 Agent |
|
||||
| 配置入口 | 设置 → Messenger | Agent → 渠道 |
|
||||
|
||||
## 支持的平台
|
||||
|
||||
| 平台 | 接入模式 | 文档 |
|
||||
| ------------ | ------------------- | ------------------------------------------------------- |
|
||||
| **Telegram** | 全局机器人 —— 任意账号都可直接私聊 | [在 Telegram 使用 LobeHub](/docs/usage/messenger/telegram) |
|
||||
| **Slack** | 按工作区安装 + 成员各自关联 | [在 Slack 使用 LobeHub](/docs/usage/messenger/slack) |
|
||||
| **Discord** | 按服务器安装 + 成员各自关联 | [在 Discord 使用 LobeHub](/docs/usage/messenger/discord) |
|
||||
|
||||
如果 **设置 → Messenger** 页面没有出现某个平台的卡片,说明当前部署尚未启用该平台 —— 请稍后再来,或联系管理员开启。
|
||||
|
||||
{/* TODO: 截图 —— 设置 → Messenger 入口页,三个平台卡片 */}
|
||||
|
||||
## 工作原理
|
||||
|
||||
1. 通过一段类 OAuth 的流程,将你的 LobeHub 账号 **关联** 到平台账号。
|
||||
2. 关联时选择一个 **默认 Agent**,之后该平台收到的消息都会路由到这个 Agent。
|
||||
3. 切换 Agent:在机器人里发送 `/agents`,或回到 LobeHub 的 **设置 → Messenger**。
|
||||
4. 随时可以在同一页面 **断开连接** —— 断开后机器人将不再接收消息,直到你重新 `/start`。
|
||||
|
||||
每个 LobeHub 账号在每个平台上可保留一条关联(Slack 还会按工作区各保留一条)。
|
||||
|
||||
## 切换接收消息的 Agent
|
||||
|
||||
你可以随时切换接收你消息的 Agent:
|
||||
|
||||
- **在机器人里** —— 发送 `/agents`,挑一个新的 Agent。
|
||||
- **在 LobeHub 里** —— 打开 **设置 → Messenger**,选择平台,在你的关联行里使用 Agent 选择器。
|
||||
|
||||
切换会立即对你发送的下一条消息生效。
|
||||
|
||||
## 断开连接
|
||||
|
||||
每个平台有两种含义不同的断开操作:
|
||||
|
||||
| 操作 | 效果 |
|
||||
| -------------------------------- | ------------------------------------------------------------------------ |
|
||||
| 在 *user* 行点 **断开** | 解除 **你自己** 的 LobeHub 账号与该平台账号的关联。机器人不再接收你的消息,直到你重新 `/start`。 |
|
||||
| 在 *workspace / server* 行点 **断开** | 移除安装审计记录。Slack 会因此让该工作区的整个机器人失效;Discord 上机器人会继续留在服务器,直到 Discord 管理员把它踢出。 |
|
||||
|
||||
任何时候都可以重新走一次 **连接** 流程恢复关联。
|
||||
|
||||
## 通用报错
|
||||
|
||||
下面这几条提示在任何平台关联时都可能出现:
|
||||
|
||||
- **"This link is already used"(链接已被使用)** —— 一次性确认链接只能使用一次。请回到机器人重新发送 `/start` 获取新链接。
|
||||
- **"This account is already linked"(该账号已被关联)** —— 该平台账号已绑定到另一个 LobeHub 账号。请用那个 LobeHub 账号登录管理;或先在那边解绑后再尝试。
|
||||
- **"Another \<platform> account is already linked"(另一个平台账号已关联)** —— 你的 LobeHub 账号在该平台上已有关联。先在 **设置 → Messenger** 里断开旧关联,再绑定新账号。
|
||||
|
||||
平台特有的报错和细节请见各平台文档的「故障排查」一节。
|
||||
@@ -0,0 +1,102 @@
|
||||
---
|
||||
title: Use LobeHub on Slack
|
||||
description: >-
|
||||
Install the official LobeHub Slack app to your workspace, then link each
|
||||
member's Slack account to LobeHub. Pick a default agent and chat with your AI
|
||||
assistants from Slack DMs — no bot setup required.
|
||||
tags:
|
||||
- Messenger
|
||||
- Slack
|
||||
- LobeHub Bot
|
||||
- Account Linking
|
||||
- Workspace Install
|
||||
---
|
||||
|
||||
# Use LobeHub on Slack
|
||||
|
||||
Slack works in two phases: a **workspace admin installs** the official LobeHub Slack app once, and then **each member links** their personal LobeHub account. Both phases happen from **Settings → Messenger → Slack**.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A LobeHub account
|
||||
- A Slack workspace
|
||||
- For the install step: permission to install Slack apps in that workspace (typically Workspace Admin or a permission granted by one)
|
||||
|
||||
## Phase A — Install the LobeHub Slack app (admin, once per workspace)
|
||||
|
||||
<Steps>
|
||||
### Open Settings → Messenger → Slack
|
||||
|
||||
In LobeHub, open **Settings → Messenger** and click the **Slack** card. Click **Connect** in the top-right.
|
||||
|
||||
{/* TODO: screenshot — Slack detail page with empty Connections list and the Connect button */}
|
||||
|
||||
### Authorise in Slack
|
||||
|
||||
You'll be redirected to Slack's authorisation screen. Pick the workspace you want to install into and click **Allow**.
|
||||
|
||||
{/* TODO: screenshot — Slack OAuth consent screen */}
|
||||
|
||||
### Workspace appears under Connections
|
||||
|
||||
After approval you're redirected back to LobeHub. The workspace shows up as a **workspace** row in **Connections**, with a status of **Connected**.
|
||||
|
||||
{/* TODO: screenshot — Slack detail page showing one connected workspace row */}
|
||||
|
||||
> **Workspace already connected by someone else?** LobeHub blocks the install and shows a "Workspace already connected" notice. You don't need to install again — just DM **@LobeHub** in Slack to link your personal account. If you want to take over ownership, ask the original installer to disconnect the workspace first.
|
||||
</Steps>
|
||||
|
||||
## Phase B — Link your personal account (each member)
|
||||
|
||||
<Steps>
|
||||
### Open the LobeHub bot in Slack
|
||||
|
||||
In Slack, open the **Apps** sidebar and find **LobeHub**, or search for `@LobeHub`. Open a DM with the bot.
|
||||
|
||||
{/* TODO: screenshot — Slack apps sidebar with LobeHub highlighted */}
|
||||
|
||||
### Send any message
|
||||
|
||||
Send any message to the bot to trigger the linking flow. The bot replies with a one-time confirmation link.
|
||||
|
||||
{/* TODO: screenshot — Slack DM showing the bot's "Link Account" reply */}
|
||||
|
||||
### Confirm the link in your browser
|
||||
|
||||
Tap the link, sign in to LobeHub if asked, and choose a **default agent**. Every message you DM the bot in this workspace will route to this agent.
|
||||
|
||||
{/* TODO: screenshot — confirm-link page in LobeHub with the agent picker */}
|
||||
|
||||
Your link appears as a **user** row under the workspace install in **Connections**.
|
||||
|
||||
{/* TODO: screenshot — Slack detail page showing the workspace row + a connected user row */}
|
||||
</Steps>
|
||||
|
||||
## Switching the Active Agent
|
||||
|
||||
Two equivalent ways:
|
||||
|
||||
- **In Slack** — DM the bot `/agents` and pick a different agent.
|
||||
- **In LobeHub** — open **Settings → Messenger → Slack** and use the agent picker on your link row.
|
||||
|
||||
The change takes effect on the next message you send.
|
||||
|
||||
## Disconnecting
|
||||
|
||||
Slack has two distinct disconnect actions:
|
||||
|
||||
| Action | Effect |
|
||||
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Disconnect** on a *user* row | Unlinks **your** LobeHub account from your Slack account in this workspace. Your inbound DMs stop until you message the bot and link again. |
|
||||
| **Disconnect** on a *workspace* row | Removes the workspace install. The bot is **frozen for everyone** in that workspace because dispatch is token-gated — existing user links remain on file but pause until the workspace is re-installed. |
|
||||
|
||||
You can re-install (workspace) or re-link (personal) at any time by repeating the relevant phase.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **"Workspace already connected"** — Another LobeHub user already installed the app to this workspace. DM **@LobeHub** to link your personal account; you do not need to install again. To take over ownership, ask the original installer to disconnect first.
|
||||
- **Slack install failed (`<reason>`)** — Common reasons: authorisation cancelled, install session expired (re-open the modal and try again), Slack returned incomplete data (retry; if persistent, contact support).
|
||||
- **Bot does not reply to your DM** — The workspace install may have been disconnected. Check **Settings → Messenger → Slack** for a workspace row; if missing, ask an admin to re-install.
|
||||
- **"This link is already used"** — One-time confirmation links expire after one use. DM the bot again to get a new link.
|
||||
- **"This account is already linked"** — Your Slack account is bound to a different LobeHub account. Sign in to that LobeHub account to manage the link, or unlink there before retrying.
|
||||
- **"Another Slack account is already linked"** — Your LobeHub account already has a Slack link in this workspace. Disconnect it in **Settings → Messenger → Slack** before linking a new Slack account.
|
||||
@@ -0,0 +1,101 @@
|
||||
---
|
||||
title: 在 Slack 使用 LobeHub
|
||||
description: >-
|
||||
在 Slack 工作区安装官方 LobeHub Slack 应用,再让每位成员各自关联自己的 LobeHub
|
||||
账号;选择默认 Agent,就能在 Slack DM 里直接和 AI 助手对话,无需自建机器人。
|
||||
tags:
|
||||
- Messenger
|
||||
- Slack
|
||||
- LobeHub 机器人
|
||||
- 账号关联
|
||||
- 工作区安装
|
||||
---
|
||||
|
||||
# 在 Slack 使用 LobeHub
|
||||
|
||||
Slack 接入分两步:**工作区管理员安装一次** 官方 LobeHub Slack 应用,然后 **每位成员各自关联** 自己的 LobeHub 账号。两步都在 **设置 → Messenger → Slack** 里完成。
|
||||
|
||||
## 前置条件
|
||||
|
||||
- 一个 LobeHub 账号
|
||||
- 一个 Slack 工作区
|
||||
- 安装环节:在该工作区里安装 Slack 应用的权限(通常是 Workspace Admin,或由管理员授予的权限)
|
||||
|
||||
## 阶段 A —— 安装 LobeHub Slack 应用(管理员,每个工作区一次)
|
||||
|
||||
<Steps>
|
||||
### 打开「设置 → Messenger → Slack」
|
||||
|
||||
在 LobeHub 中打开 **设置 → Messenger**,点击 **Slack** 卡片,再点右上角的 **连接**。
|
||||
|
||||
{/* TODO: 截图 —— Slack 详情页(Connections 列表为空 + Connect 按钮) */}
|
||||
|
||||
### 在 Slack 中授权
|
||||
|
||||
页面跳转到 Slack 授权界面。选择要安装的目标工作区,点击 **Allow(允许)**。
|
||||
|
||||
{/* TODO: 截图 —— Slack OAuth 授权页 */}
|
||||
|
||||
### 工作区出现在 Connections 列表
|
||||
|
||||
授权成功后跳回 LobeHub。该工作区会以 **workspace** 行的形式出现在 **Connections** 中,状态为 **已连接 (Connected)**。
|
||||
|
||||
{/* TODO: 截图 —— Slack 详情页:已连接的 workspace 行 */}
|
||||
|
||||
> **工作区已被他人连接?** LobeHub 会阻止安装并提示「Workspace already connected」。你**不需要**重新安装,只要在 Slack 里私聊 **@LobeHub** 完成个人账号关联即可。如果想接管所有权,请请求最初的安装者先断开连接。
|
||||
</Steps>
|
||||
|
||||
## 阶段 B —— 关联个人账号(每位成员)
|
||||
|
||||
<Steps>
|
||||
### 在 Slack 里打开 LobeHub 机器人
|
||||
|
||||
在 Slack 左侧 **Apps** 栏里找到 **LobeHub**,或直接搜索 `@LobeHub`,打开它的 DM 会话。
|
||||
|
||||
{/* TODO: 截图 —— Slack Apps 栏里高亮 LobeHub */}
|
||||
|
||||
### 发送任意一条消息
|
||||
|
||||
给机器人发送任意一条消息触发关联流程。机器人会回复一个一次性确认链接。
|
||||
|
||||
{/* TODO: 截图 —— Slack DM 中机器人发送 "Link Account" 按钮 */}
|
||||
|
||||
### 在浏览器里确认关联
|
||||
|
||||
点击链接,必要时登录 LobeHub,然后选择一个 **默认 Agent**。之后在该工作区私聊机器人的每条消息都会路由到这个 Agent。
|
||||
|
||||
{/* TODO: 截图 —— LobeHub 确认关联页面,含 Agent 选择器 */}
|
||||
|
||||
关联完成后,会在 **Connections** 里以 **user** 行的形式出现在该工作区下方。
|
||||
|
||||
{/* TODO: 截图 —— Slack 详情页:workspace 行 + 一条已连接的 user 行 */}
|
||||
</Steps>
|
||||
|
||||
## 切换接收消息的 Agent
|
||||
|
||||
两种等价方式:
|
||||
|
||||
- **在 Slack 里** —— 私聊机器人发送 `/agents`,挑一个新的 Agent。
|
||||
- **在 LobeHub 里** —— 打开 **设置 → Messenger → Slack**,在你的关联行里使用 Agent 选择器。
|
||||
|
||||
切换会对你发送的下一条消息立即生效。
|
||||
|
||||
## 断开连接
|
||||
|
||||
Slack 有两种含义不同的断开操作:
|
||||
|
||||
| 操作 | 效果 |
|
||||
| ----------------------- | ------------------------------------------------------------------------------- |
|
||||
| 在 *user* 行点 **断开** | 解除 **你自己** 的 LobeHub 账号与该工作区 Slack 账号的关联。你的 DM 不再被接收,直到你再次给机器人发消息并完成关联。 |
|
||||
| 在 *workspace* 行点 **断开** | 移除工作区安装。由于消息分发受 token 控制,这会让该工作区里**所有人的机器人都失效**;现有 user 关联记录还在,但暂停工作直到工作区被重新安装。 |
|
||||
|
||||
任何时候都可以重走对应阶段重新安装(工作区)或重新关联(个人)。
|
||||
|
||||
## 故障排查
|
||||
|
||||
- **"Workspace already connected"(工作区已连接)** —— 工作区已被另一位 LobeHub 用户安装过。私聊 **@LobeHub** 完成个人关联即可,无需重装。如需接管,请请求最初的安装者先断开。
|
||||
- **Slack 安装失败(`<原因>`)** —— 常见原因:用户取消授权、安装会话过期(重新打开弹窗再试)、Slack 返回的数据不完整(重试;持续失败请联系支持)。
|
||||
- **机器人不回 DM** —— 工作区安装可能已被断开。检查 **设置 → Messenger → Slack** 是否还有 workspace 行;没有就请管理员重新安装。
|
||||
- **"This link is already used"** —— 一次性确认链接只能用一次,给机器人再发一条消息获取新链接。
|
||||
- **"This account is already linked"** —— 这个 Slack 账号已绑定到另一个 LobeHub 账号。请用那个 LobeHub 账号登录管理,或先在那边解绑。
|
||||
- **"Another Slack account is already linked"** —— 你的 LobeHub 账号在该工作区里已有 Slack 关联。先在 **设置 → Messenger → Slack** 断开旧关联,再绑定新的 Slack 账号。
|
||||
@@ -0,0 +1,77 @@
|
||||
---
|
||||
title: Use LobeHub on Telegram
|
||||
description: >-
|
||||
Link your LobeHub account to the official LobeHub bot on Telegram. Send /start
|
||||
to the bot, pick a default agent, and chat with your AI assistants directly
|
||||
from any Telegram conversation — no bot setup required.
|
||||
tags:
|
||||
- Messenger
|
||||
- Telegram
|
||||
- LobeHub Bot
|
||||
- Account Linking
|
||||
---
|
||||
|
||||
# Use LobeHub on Telegram
|
||||
|
||||
Telegram is the simplest Messenger platform: there is one global LobeHub bot, and any Telegram account can DM it. You only need to link your LobeHub account once.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A LobeHub account
|
||||
- A Telegram account on any device
|
||||
|
||||
## Step 1: Open Settings → Messenger
|
||||
|
||||
In LobeHub, open **Settings → Messenger** and click the **Telegram** card.
|
||||
|
||||
{/* TODO: screenshot — Settings → Messenger with Telegram card highlighted */}
|
||||
|
||||
## Step 2: Click Connect
|
||||
|
||||
On the Telegram detail page, click **Connect** in the top-right corner. A modal opens with two ways to reach the bot.
|
||||
|
||||
{/* TODO: screenshot — Telegram detail page with empty Connections list and the Connect button */}
|
||||
|
||||
## Step 3: Open the bot in Telegram
|
||||
|
||||
Either tap **Open in Telegram** in the modal, or scan the QR code with your phone. Telegram opens the official LobeHub bot.
|
||||
|
||||
{/* TODO: screenshot — LinkModal showing the Open in Telegram button + QR code */}
|
||||
|
||||
## Step 4: Send `/start`
|
||||
|
||||
Inside the bot, send the `/start` command. The bot replies with a one-time confirmation link.
|
||||
|
||||
{/* TODO: screenshot — Telegram bot conversation showing /start sent and the bot's reply with the Link Account button */}
|
||||
|
||||
## Step 5: Confirm the link in your browser
|
||||
|
||||
Tap the link in the bot's reply. You'll be taken back to LobeHub. Sign in if asked, then choose a **default agent** — every message you DM the bot from now on will route to this agent.
|
||||
|
||||
{/* TODO: screenshot — confirm-link page in LobeHub with the agent picker visible */}
|
||||
|
||||
After confirming, the Telegram detail page shows your link as a "user" row with the active agent.
|
||||
|
||||
{/* TODO: screenshot — Telegram detail page with one connected user row */}
|
||||
|
||||
## Switching the Active Agent
|
||||
|
||||
Two equivalent ways:
|
||||
|
||||
- **In Telegram** — send `/agents` to the bot and pick a different agent.
|
||||
- **In LobeHub** — open **Settings → Messenger → Telegram** and use the agent picker on your link row.
|
||||
|
||||
The change takes effect on the next message you send.
|
||||
|
||||
## Disconnecting
|
||||
|
||||
In **Settings → Messenger → Telegram**, click **Disconnect** on the link row. The bot will stop accepting your messages until you re-link by sending `/start` again.
|
||||
|
||||
> Disconnecting from LobeHub does not remove the bot from your Telegram chat list — you can manually delete the chat in Telegram if you no longer want to see it.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **The bot does not reply to `/start`** — Check that the platform card actually exists in **Settings → Messenger**. If your deployment hasn't enabled Telegram yet, the bot won't respond.
|
||||
- **"This link is already used"** — One-time links can only be used once. Send `/start` again to get a new link.
|
||||
- **"This account is already linked"** — Your Telegram account is bound to a different LobeHub account. Sign in to that LobeHub account to manage the link, or unlink there first.
|
||||
- **"Another Telegram account is already linked"** — Your LobeHub account already has a Telegram link. Disconnect it in **Settings → Messenger → Telegram** before linking a new Telegram account.
|
||||
@@ -0,0 +1,76 @@
|
||||
---
|
||||
title: 在 Telegram 使用 LobeHub
|
||||
description: >-
|
||||
将 LobeHub 账号关联到 Telegram 上的官方 LobeHub 机器人。给机器人发送
|
||||
/start,选择默认 Agent,就能在任意 Telegram 会话里直接和你的 AI 助手对话,无需自建机器人。
|
||||
tags:
|
||||
- Messenger
|
||||
- Telegram
|
||||
- LobeHub 机器人
|
||||
- 账号关联
|
||||
---
|
||||
|
||||
# 在 Telegram 使用 LobeHub
|
||||
|
||||
Telegram 是 Messenger 里最简单的平台:只有一个全局 LobeHub 机器人,任何 Telegram 账号都能直接私聊它。你只需要把自己的 LobeHub 账号绑一次。
|
||||
|
||||
## 前置条件
|
||||
|
||||
- 一个 LobeHub 账号
|
||||
- 任意设备上的 Telegram 账号
|
||||
|
||||
## 第 1 步:打开「设置 → Messenger」
|
||||
|
||||
在 LobeHub 中打开 **设置 → Messenger**,点击 **Telegram** 卡片。
|
||||
|
||||
{/* TODO: 截图 —— 设置 → Messenger 入口页,高亮 Telegram 卡片 */}
|
||||
|
||||
## 第 2 步:点击「连接」
|
||||
|
||||
在 Telegram 详情页右上角点击 **连接**。弹窗里会给出两种方式打开机器人。
|
||||
|
||||
{/* TODO: 截图 —— Telegram 详情页(Connections 列表为空 + Connect 按钮) */}
|
||||
|
||||
## 第 3 步:在 Telegram 中打开机器人
|
||||
|
||||
在弹窗里点 **在 Telegram 中打开**,或用手机扫描二维码,Telegram 会自动打开官方 LobeHub 机器人。
|
||||
|
||||
{/* TODO: 截图 —— LinkModal 弹窗(含 Open in Telegram 按钮和二维码) */}
|
||||
|
||||
## 第 4 步:发送 `/start`
|
||||
|
||||
在机器人会话里发送 `/start`。机器人会回复一个一次性确认链接。
|
||||
|
||||
{/* TODO: 截图 —— Telegram 机器人会话:用户发送 /start,机器人回复带 Link Account 按钮的消息 */}
|
||||
|
||||
## 第 5 步:在浏览器里确认关联
|
||||
|
||||
点击机器人回复里的链接,会跳回 LobeHub 网页。如未登录请先登录,然后选择一个 **默认 Agent** —— 之后你在该机器人里发的每条消息都会路由到这个 Agent。
|
||||
|
||||
{/* TODO: 截图 —— LobeHub 确认关联页面,含 Agent 选择器 */}
|
||||
|
||||
确认后,Telegram 详情页会以 “user” 行的形式显示你的关联,旁边可以看到当前 Agent。
|
||||
|
||||
{/* TODO: 截图 —— Telegram 详情页:已连接的 user 行 */}
|
||||
|
||||
## 切换接收消息的 Agent
|
||||
|
||||
两种等价方式:
|
||||
|
||||
- **在 Telegram 里** —— 给机器人发送 `/agents`,挑一个新的 Agent。
|
||||
- **在 LobeHub 里** —— 打开 **设置 → Messenger → Telegram**,在你的关联行里使用 Agent 选择器。
|
||||
|
||||
切换会对你发送的下一条消息立即生效。
|
||||
|
||||
## 断开连接
|
||||
|
||||
在 **设置 → Messenger → Telegram** 里点关联行上的 **断开**。断开后机器人不再接收你的消息,直到你重新发送 `/start` 关联。
|
||||
|
||||
> 在 LobeHub 里断开不会从 Telegram 聊天列表里删掉机器人 —— 如果不想再看到它,可以在 Telegram 里手动删除该会话。
|
||||
|
||||
## 故障排查
|
||||
|
||||
- **机器人不回复 `/start`** —— 先确认 **设置 → Messenger** 里有 Telegram 卡片。如果当前部署没启用 Telegram,机器人不会响应。
|
||||
- **"This link is already used"** —— 一次性链接只能用一次,重新发送 `/start` 获取新链接。
|
||||
- **"This account is already linked"** —— 这个 Telegram 账号已绑定到另一个 LobeHub 账号。请用那个 LobeHub 账号登录管理,或先在那边解绑。
|
||||
- **"Another Telegram account is already linked"** —— 你的 LobeHub 账号在 Telegram 上已有关联。先在 **设置 → Messenger → Telegram** 断开旧关联,再绑定新的 Telegram 账号。
|
||||
@@ -37,16 +37,15 @@ Click your user avatar in the top-right corner → **App Settings** → **Shortc
|
||||
|
||||
## Conversation Shortcuts
|
||||
|
||||
| Action | Shortcut |
|
||||
| ------------------------------- | ---------------------------------------------------- |
|
||||
| **Open conversation settings** | `⌘ + ,` / `Ctrl + ,` |
|
||||
| **Regenerate message** | `⌘ + R` / `Ctrl + R` |
|
||||
| **Delete last message** | `⌘ + D` / `Ctrl + D` |
|
||||
| **Delete and regenerate** | `⌘ + Shift + R` / `Ctrl + Shift + R` |
|
||||
| **New Topic** | `⌘ + N` / `Ctrl + N` |
|
||||
| **Add message without sending** | `⌘ + Enter` / `Ctrl + Enter` |
|
||||
| **Edit message** | `Ctrl + Alt` + double-click message |
|
||||
| **Clear all messages** | `⌘ + Shift + Backspace` / `Ctrl + Shift + Backspace` |
|
||||
| Action | Shortcut |
|
||||
| ------------------------------- | ------------------------------------ |
|
||||
| **Open conversation settings** | `⌘ + ,` / `Ctrl + ,` |
|
||||
| **Regenerate message** | `⌘ + R` / `Ctrl + R` |
|
||||
| **Delete last message** | `⌘ + D` / `Ctrl + D` |
|
||||
| **Delete and regenerate** | `⌘ + Shift + R` / `Ctrl + Shift + R` |
|
||||
| **New Topic** | `⌘ + N` / `Ctrl + N` |
|
||||
| **Add message without sending** | `⌘ + Enter` / `Ctrl + Enter` |
|
||||
| **Edit message** | `Ctrl + Alt` + double-click message |
|
||||
|
||||
**Add message without sending** — useful when you want to add context to the conversation without triggering an immediate response. The Agent will see the new message when it next replies.
|
||||
|
||||
|
||||
@@ -35,16 +35,15 @@ LobeHub 提供丰富的键盘快捷键,让你少用鼠标、多用手感。掌
|
||||
|
||||
## 会话快捷键
|
||||
|
||||
| 操作 | 快捷键 |
|
||||
| ------------ | ---------------------------------------------------- |
|
||||
| **打开会话设置** | `⌘ + ,` / `Ctrl + ,` |
|
||||
| **重新生成消息** | `⌘ + R` / `Ctrl + R` |
|
||||
| **删除最后一条消息** | `⌘ + D` / `Ctrl + D` |
|
||||
| **删除并重新生成** | `⌘ + Shift + R` / `Ctrl + Shift + R` |
|
||||
| **新建话题** | `⌘ + N` / `Ctrl + N` |
|
||||
| **添加消息但不发送** | `⌘ + Enter` / `Ctrl + Enter` |
|
||||
| **编辑消息** | `Ctrl + Alt` + 双击消息 |
|
||||
| **清空所有消息** | `⌘ + Shift + Backspace` / `Ctrl + Shift + Backspace` |
|
||||
| 操作 | 快捷键 |
|
||||
| ------------ | ------------------------------------ |
|
||||
| **打开会话设置** | `⌘ + ,` / `Ctrl + ,` |
|
||||
| **重新生成消息** | `⌘ + R` / `Ctrl + R` |
|
||||
| **删除最后一条消息** | `⌘ + D` / `Ctrl + D` |
|
||||
| **删除并重新生成** | `⌘ + Shift + R` / `Ctrl + Shift + R` |
|
||||
| **新建话题** | `⌘ + N` / `Ctrl + N` |
|
||||
| **添加消息但不发送** | `⌘ + Enter` / `Ctrl + Enter` |
|
||||
| **编辑消息** | `Ctrl + Alt` + 双击消息 |
|
||||
|
||||
**添加消息但不发送** —— 想在不触发生成的情况下补充上下文时使用。助理会在下次回复时看到这条新消息。
|
||||
|
||||
|
||||
@@ -10,7 +10,8 @@ import { Given, Then, When } from '@cucumber/cucumber';
|
||||
import { expect } from '@playwright/test';
|
||||
|
||||
import { TEST_USER } from '../../support/seedTestUser';
|
||||
import { CustomWorld, WAIT_TIMEOUT } from '../../support/world';
|
||||
import type { CustomWorld } from '../../support/world';
|
||||
import { WAIT_TIMEOUT } from '../../support/world';
|
||||
|
||||
/**
|
||||
* Create a test chat group directly in database
|
||||
@@ -68,55 +69,97 @@ Given('用户在 Home 页面有一个 Agent Group', async function (this: Custom
|
||||
console.log(` ✅ 找到 Agent Group: ${groupLabel}, id: ${groupId}`);
|
||||
});
|
||||
|
||||
Given('该 Agent Group 未被置顶', async function (this: CustomWorld) {
|
||||
Given('该 Agent Group 未被置顶', { timeout: 30_000 }, async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 检查 Agent Group 未被置顶...');
|
||||
const targetItem = this.page.locator(this.testContext.targetItemSelector).first();
|
||||
const pinIcon = targetItem.locator('svg.lucide-pin');
|
||||
// Pin icon uses lucide-react which adds class "lucide lucide-pin"
|
||||
const pinIcon = targetItem.locator('svg[class*="lucide-pin"]');
|
||||
|
||||
if ((await pinIcon.count()) > 0) {
|
||||
await targetItem.click({ button: 'right' });
|
||||
await this.page.waitForTimeout(300);
|
||||
console.log(' 📍 Agent Group 已置顶,开始取消置顶操作...');
|
||||
await targetItem.hover();
|
||||
await this.page.waitForTimeout(200);
|
||||
await targetItem.click({ button: 'right', force: true });
|
||||
await this.page.waitForTimeout(500);
|
||||
const unpinOption = this.page.getByRole('menuitem', { name: /取消置顶|unpin/i });
|
||||
await unpinOption.waitFor({ state: 'visible', timeout: 5000 }).catch(() => {
|
||||
console.log(' ⚠️ 取消置顶选项未找到');
|
||||
});
|
||||
if ((await unpinOption.count()) > 0) {
|
||||
await unpinOption.click();
|
||||
await this.page.waitForTimeout(500);
|
||||
}
|
||||
await this.page.click('body', { position: { x: 10, y: 10 } });
|
||||
// Close menu if still open
|
||||
await this.page.keyboard.press('Escape');
|
||||
await this.page.waitForTimeout(300);
|
||||
}
|
||||
|
||||
console.log(' ✅ Agent Group 未被置顶');
|
||||
});
|
||||
|
||||
Given('该 Agent Group 已被置顶', async function (this: CustomWorld) {
|
||||
Given('该 Agent Group 已被置顶', { timeout: 30_000 }, async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 确保 Agent Group 已被置顶...');
|
||||
const targetItem = this.page.locator(this.testContext.targetItemSelector).first();
|
||||
const pinIcon = targetItem.locator('svg.lucide-pin');
|
||||
// Pin icon uses lucide-react which adds class "lucide lucide-pin"
|
||||
const pinIcon = targetItem.locator('svg[class*="lucide-pin"]');
|
||||
|
||||
if ((await pinIcon.count()) === 0) {
|
||||
await targetItem.click({ button: 'right' });
|
||||
await this.page.waitForTimeout(300);
|
||||
console.log(' 📍 Agent Group 未置顶,开始置顶操作...');
|
||||
await targetItem.hover();
|
||||
await this.page.waitForTimeout(200);
|
||||
await targetItem.click({ button: 'right', force: true });
|
||||
await this.page.waitForTimeout(500);
|
||||
|
||||
const menuItems = await this.page.locator('[role="menuitem"]').count();
|
||||
console.log(` 📍 Debug: 发现 ${menuItems} 个菜单项`);
|
||||
|
||||
const pinOption = this.page.getByRole('menuitem', { name: /置顶|pin/i });
|
||||
await pinOption.waitFor({ state: 'visible', timeout: 5000 }).catch(() => {
|
||||
console.log(' ⚠️ 置顶选项未找到');
|
||||
});
|
||||
if ((await pinOption.count()) > 0) {
|
||||
await pinOption.click();
|
||||
await this.page.waitForTimeout(500);
|
||||
console.log(' ✅ 已点击置顶选项');
|
||||
}
|
||||
await this.page.click('body', { position: { x: 10, y: 10 } });
|
||||
// Close menu if still open
|
||||
await this.page.keyboard.press('Escape');
|
||||
await this.page.waitForTimeout(300);
|
||||
}
|
||||
|
||||
console.log(' ✅ Agent Group 已被置顶');
|
||||
// Verify pin is now visible
|
||||
await this.page.waitForTimeout(500);
|
||||
const pinIconAfter = targetItem.locator('svg[class*="lucide-pin"]');
|
||||
const isPinned = (await pinIconAfter.count()) > 0;
|
||||
console.log(` ✅ Agent Group 已被置顶: ${isPinned}`);
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// When Steps
|
||||
// ============================================
|
||||
|
||||
When('用户右键点击该 Agent Group', async function (this: CustomWorld) {
|
||||
When('用户右键点击该 Agent Group', { timeout: 30_000 }, async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 右键点击 Agent Group...');
|
||||
|
||||
const targetItem = this.page.locator(this.testContext.targetItemSelector).first();
|
||||
await targetItem.click({ button: 'right' });
|
||||
|
||||
// Hover first to ensure element is interactive
|
||||
await targetItem.hover();
|
||||
await this.page.waitForTimeout(200);
|
||||
|
||||
// Right-click with force option to ensure it triggers
|
||||
await targetItem.click({ button: 'right', force: true });
|
||||
await this.page.waitForTimeout(500);
|
||||
|
||||
// Wait for context menu to appear
|
||||
const menuItem = this.page.locator('[role="menuitem"]').first();
|
||||
await menuItem.waitFor({ state: 'visible', timeout: 5000 }).catch(() => {
|
||||
console.log(' ⚠️ 菜单未出现');
|
||||
});
|
||||
|
||||
const menuItems = await this.page.locator('[role="menuitem"]').count();
|
||||
console.log(` 📍 Debug: Found ${menuItems} menu items after right-click`);
|
||||
|
||||
console.log(' ✅ 已右键点击 Agent Group');
|
||||
});
|
||||
|
||||
@@ -139,7 +182,8 @@ Then('Agent Group 应该显示置顶图标', async function (this: CustomWorld)
|
||||
|
||||
await this.page.waitForTimeout(500);
|
||||
const targetItem = this.page.locator(this.testContext.targetItemSelector).first();
|
||||
const pinIcon = targetItem.locator('svg.lucide-pin');
|
||||
// Pin icon uses lucide-react which adds class "lucide lucide-pin"
|
||||
const pinIcon = targetItem.locator('svg[class*="lucide-pin"]');
|
||||
await expect(pinIcon).toBeVisible({ timeout: 5000 });
|
||||
|
||||
console.log(' ✅ 置顶图标已显示');
|
||||
@@ -150,7 +194,8 @@ Then('Agent Group 不应该显示置顶图标', async function (this: CustomWorl
|
||||
|
||||
await this.page.waitForTimeout(500);
|
||||
const targetItem = this.page.locator(this.testContext.targetItemSelector).first();
|
||||
const pinIcon = targetItem.locator('svg.lucide-pin');
|
||||
// Pin icon uses lucide-react which adds class "lucide lucide-pin"
|
||||
const pinIcon = targetItem.locator('svg[class*="lucide-pin"]');
|
||||
await expect(pinIcon).not.toBeVisible({ timeout: 5000 });
|
||||
|
||||
console.log(' ✅ 置顶图标未显示');
|
||||
|
||||
@@ -21,6 +21,9 @@
|
||||
"channel.botTokenPlaceholderNew": "الصق رمز البوت هنا",
|
||||
"channel.charLimit": "حد الأحرف",
|
||||
"channel.charLimitHint": "الحد الأقصى لعدد الأحرف لكل رسالة",
|
||||
"channel.comingSoon": "قريبًا",
|
||||
"channel.comingSoonDesc": "نعمل على جلب هذا التكامل إلى LobeHub. تابعنا للحصول على التحديثات.",
|
||||
"channel.comingSoonTitle": "تكامل {{name}} قادم قريبًا",
|
||||
"channel.concurrency": "وضع التزامن",
|
||||
"channel.concurrencyDebounce": "إزالة الارتداد",
|
||||
"channel.concurrencyDebounceHint": "معالجة آخر رسالة فقط في الدفعة (يتم تجاهل الرسائل السابقة)",
|
||||
@@ -183,6 +186,14 @@
|
||||
"channel.verificationToken": "رمز التحقق",
|
||||
"channel.verificationTokenHint": "اختياري. يُستخدم للتحقق من مصدر أحداث الويب هوك.",
|
||||
"channel.verificationTokenPlaceholder": "الصق رمز التحقق هنا",
|
||||
"channel.watchKeywordInstructionLabel": "تعليمات",
|
||||
"channel.watchKeywordInstructionPlaceholder": "على سبيل المثال: قم بمسح المحادثة الأخيرة ورد إذا كان هناك تقرير خطأ قابل للتنفيذ",
|
||||
"channel.watchKeywordLabel": "الكلمة المفتاحية",
|
||||
"channel.watchKeywordPlaceholder": "على سبيل المثال: خطأ",
|
||||
"channel.watchKeywords": "الكلمات المفتاحية المراقبة",
|
||||
"channel.watchKeywordsAdd": "إضافة كلمة مفتاحية",
|
||||
"channel.watchKeywordsEmpty": "لم تتم إضافة أي كلمات مفتاحية بعد — يستيقظ الروبوت فقط عند الإشارة إليه @mention أو في الرسائل المباشرة في القنوات المشتركة.",
|
||||
"channel.watchKeywordsHint": "عندما تتطابق رسالة في قناة مشتركة مع كلمة مفتاحية، يستيقظ الروبوت دون الحاجة إلى الإشارة إليه @mention ويتم إضافة التعليمات إلى رسالة المستخدم قبل إرسالها إلى الذكاء الاصطناعي. تطابق غير حساس لحالة الأحرف وكلمة كاملة.",
|
||||
"channel.wechat.description": "قم بتوصيل هذا المساعد بـ WeChat عبر iLink Bot للمحادثات الخاصة والجماعية.",
|
||||
"channel.wechatBotId": "معرّف الروبوت",
|
||||
"channel.wechatBotIdHint": "معرّف الروبوت المخصص بعد تفويض رمز الاستجابة السريعة.",
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"agentProfile.knowledgeBases_other": "{{count}} قواعد معرفة",
|
||||
"agentProfile.skills_one": "{{count}} مهارة",
|
||||
"agentProfile.skills_other": "{{count}} مهارات",
|
||||
"agentSignal.receipts.agentSignalLabel": "إشارة الوكيل",
|
||||
"agentSignal.receipts.memory.detail": "تم حفظ هذا للردود المستقبلية",
|
||||
"agentSignal.receipts.memory.title": "تم حفظ الذاكرة",
|
||||
"agentSignal.receipts.recentActivity": "النشاط الأخير",
|
||||
@@ -41,6 +42,16 @@
|
||||
"builtinCopilot": "المساعد المدمج",
|
||||
"chatList.expandMessage": "توسيع الرسالة",
|
||||
"chatList.longMessageDetail": "عرض التفاصيل",
|
||||
"chatMode.agent": "وكيل",
|
||||
"chatMode.agentCap.env": "بيئة التشغيل",
|
||||
"chatMode.agentCap.files": "الوصول إلى الملفات",
|
||||
"chatMode.agentCap.memory": "الذاكرة",
|
||||
"chatMode.agentCap.tools": "استدعاء الأدوات",
|
||||
"chatMode.agentCap.web": "البحث على الويب",
|
||||
"chatMode.agentDesc": "يمكن للوكيل استخدام الأدوات والبيئة لإكمال المهام تلقائيًا",
|
||||
"chatMode.chat": "دردشة",
|
||||
"chatMode.chatDesc": "لا توجد بيئة تشغيل أو استقلالية؛ يستخدم عددًا أقل من الرموز",
|
||||
"chatMode.select": "تبديل الوضع",
|
||||
"claudeCodeInstallGuide.actions.openDocs": "افتح دليل التثبيت",
|
||||
"claudeCodeInstallGuide.actions.openSystemTools": "افتح أدوات النظام",
|
||||
"claudeCodeInstallGuide.afterInstall": "بعد التثبيت، شغّل Claude Code مرة واحدة لتسجيل الدخول، ثم أعد محاولة إرسال رسالتك أو انقر على إعادة الكشف في أدوات النظام.",
|
||||
@@ -59,6 +70,7 @@
|
||||
"cliAuthGuide.runCommand": "شغّل هذا في الطرفية",
|
||||
"cliAuthGuide.title": "سجّل الدخول إلى {{name}}",
|
||||
"cliRateLimitGuide.actions.openSystemTools": "افتح أدوات النظام",
|
||||
"cliRateLimitGuide.actions.retry": "إعادة المحاولة",
|
||||
"cliRateLimitGuide.afterReset": "انتظر حتى وقت إعادة التعيين، ثم أعد محاولة إرسال رسالتك. إذا كنت تستخدم ترخيص API، يمكنك أيضًا التحقق من الحصة والحالة المالية لدى مزود الخدمة.",
|
||||
"cliRateLimitGuide.desc": "لقد وصل {{name}} إلى حد الاستخدام الحالي ولا يمكنه متابعة التشغيل الآن.",
|
||||
"cliRateLimitGuide.limitType": "نافذة الحد",
|
||||
@@ -223,6 +235,8 @@
|
||||
"knowledgeBase.allFiles": "كل الملفات",
|
||||
"knowledgeBase.allLibraries": "كل المكتبات",
|
||||
"knowledgeBase.disabled": "دردشة المكتبة غير متاحة في هذا النشر. يرجى التبديل إلى قاعدة بيانات على الخادم أو استخدام {{cloud}}.",
|
||||
"knowledgeBase.files": "الملفات",
|
||||
"knowledgeBase.libraries": "المكتبات",
|
||||
"knowledgeBase.library.action.add": "إضافة",
|
||||
"knowledgeBase.library.action.detail": "تفاصيل",
|
||||
"knowledgeBase.library.action.remove": "إزالة",
|
||||
@@ -326,6 +340,15 @@
|
||||
"pageSelection.reference": "النص المحدد",
|
||||
"pin": "تثبيت",
|
||||
"pinOff": "إلغاء التثبيت",
|
||||
"plus.addSkills": "إضافة مهارات...",
|
||||
"plus.search.appSearch": "بحث ذكي",
|
||||
"plus.search.appSearchDesc": "خدمة بحث محسّنة من LobeHub، تقدم أفضل نتائج الاسترجاع.",
|
||||
"plus.search.modelSearch": "بحث المزود",
|
||||
"plus.search.modelSearchDesc": "قد يسبب سلوكًا غير متوقع عند التمكين، غير موصى به.",
|
||||
"plus.search.off": "إيقاف",
|
||||
"plus.search.offDesc": "",
|
||||
"plus.title": "إضافة",
|
||||
"plus.tooltip": "إضافة ملفات، مهارات، والمزيد من السياق...",
|
||||
"rag.referenceChunks": "مصدر المرجع",
|
||||
"rag.userQuery.actions.delete": "حذف إعادة صياغة الاستعلام",
|
||||
"rag.userQuery.actions.regenerate": "إعادة توليد الاستعلام",
|
||||
@@ -357,6 +380,8 @@
|
||||
"searchAgents": "البحث عن وكلاء...",
|
||||
"selectedAgents": "الوكلاء المحددون",
|
||||
"sendPlaceholder": "اطرح سؤالًا، أنشئ، أو ابدأ مهمة، <hotkey><hotkey/>",
|
||||
"sendPlaceholderChat": "اسأل، ابحث، أو فكر، <hotkey><hotkey/>",
|
||||
"sendPlaceholderChatWithAgentAssignment": "اسأل، ابحث، أو فكر. @ لإحضار وكلاء آخرين.",
|
||||
"sendPlaceholderHeterogeneous": "اطلب من {{name}} تنفيذ مهمة...",
|
||||
"sendPlaceholderWithAgentAssignment": "اطلب أو أنشئ أو ابدأ مهمة. @ لإسناد مهام لوكلاء آخرين.",
|
||||
"sessionGroup.config": "إدارة المجموعة",
|
||||
@@ -734,6 +759,7 @@
|
||||
"untitledAgent": "وكيل بدون اسم",
|
||||
"untitledGroup": "مجموعة بدون اسم",
|
||||
"updateAgent": "تحديث معلومات الوكيل",
|
||||
"upload.action.fileOrImageUpload": "تحميل ملف أو صورة",
|
||||
"upload.action.fileUpload": "رفع ملف",
|
||||
"upload.action.folderUpload": "رفع مجلد",
|
||||
"upload.action.imageDisabled": "النموذج الحالي لا يدعم التعرف البصري. يرجى التبديل إلى نموذج آخر لاستخدام هذه الميزة.",
|
||||
@@ -846,6 +872,23 @@
|
||||
"workingPanel.documents.saved": "All changes saved",
|
||||
"workingPanel.documents.title": "Document",
|
||||
"workingPanel.documents.unsaved": "Unsaved changes",
|
||||
"workingPanel.files.copyAbsolutePath": "نسخ المسار المطلق",
|
||||
"workingPanel.files.copyRelativePath": "نسخ المسار النسبي",
|
||||
"workingPanel.files.count_one": "{{count}} ملف",
|
||||
"workingPanel.files.count_other": "{{count}} ملفات",
|
||||
"workingPanel.files.empty": "لا توجد ملفات في مساحة العمل هذه",
|
||||
"workingPanel.files.open": "فتح الملف",
|
||||
"workingPanel.files.refresh": "تحديث",
|
||||
"workingPanel.files.showInReview": "عرض في المراجعة",
|
||||
"workingPanel.files.showInSystem": "إظهار في المجلد",
|
||||
"workingPanel.files.title": "الملفات",
|
||||
"workingPanel.localFile.binary": "ملف ثنائي — المعاينة غير متوفرة",
|
||||
"workingPanel.localFile.close": "إغلاق",
|
||||
"workingPanel.localFile.closeLeft": "إغلاق إلى اليسار",
|
||||
"workingPanel.localFile.closeOther": "إغلاق الآخرين",
|
||||
"workingPanel.localFile.closeRight": "إغلاق إلى اليمين",
|
||||
"workingPanel.localFile.error": "تعذر تحميل هذا الملف",
|
||||
"workingPanel.localFile.truncated": "تم تقليص معاينة الملف إلى {{limit}} حرفًا",
|
||||
"workingPanel.progress": "Progress",
|
||||
"workingPanel.progress.allCompleted": "All tasks completed",
|
||||
"workingPanel.resources": "Resources",
|
||||
@@ -892,6 +935,8 @@
|
||||
"workingPanel.review.mode.unstaged": "غير مُرتب",
|
||||
"workingPanel.review.more": "خيارات إضافية",
|
||||
"workingPanel.review.refresh": "تحديث",
|
||||
"workingPanel.review.revealInTree": "إظهار في الشجرة",
|
||||
"workingPanel.review.revealNotFound": "الملف غير موجود في فهرس المشروع",
|
||||
"workingPanel.review.revert": "تجاهل التغييرات",
|
||||
"workingPanel.review.revert.confirm.cancel": "إلغاء",
|
||||
"workingPanel.review.revert.confirm.description": "سيتم تجاهل تغييرات شجرة العمل على {{filePath}} نهائيًا. ستُحذف الملفات غير المتعقبة من القرص.",
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"brief.action.confirm": "تأكيد",
|
||||
"brief.action.confirmDone": "تأكيد",
|
||||
"brief.action.feedback": "ملاحظات",
|
||||
"brief.action.ignore": "تجاهل",
|
||||
"brief.action.retry": "إعادة المحاولة",
|
||||
"brief.addFeedback": "مشاركة الملاحظات",
|
||||
"brief.collapse": "عرض أقل",
|
||||
|
||||
@@ -20,6 +20,22 @@
|
||||
"messenger.discord.connections.disconnectFailed": "فشل في إزالة الخادم.",
|
||||
"messenger.discord.connections.disconnectSuccess": "تمت إزالة الخادم.",
|
||||
"messenger.discord.connections.disconnectTitle": "إزالة الخادم",
|
||||
"messenger.discord.installBlocked.dismiss": "فهمت",
|
||||
"messenger.discord.installBlocked.suggestion": "أرسل رسالة مباشرة إلى بوت LobeHub في Discord لربط حسابك الشخصي — لا تحتاج إلى إضافة البوت مرة أخرى. أو اطلب من المثبت الأصلي إزالة هذا الخادم في إعدادات LobeHub → Messenger قبل إعادة إضافته.",
|
||||
"messenger.discord.installBlocked.title": "الخادم متصل بالفعل",
|
||||
"messenger.discord.installBlocked.withName": "الخادم \"{{workspace}}\" متصل بالفعل بـ LobeHub بواسطة مستخدم آخر.",
|
||||
"messenger.discord.installBlocked.withoutName": "هذا الخادم في Discord متصل بالفعل بـ LobeHub بواسطة مستخدم آخر.",
|
||||
"messenger.discord.installResult.failed": "فشل تثبيت Discord ({{reason}}). يرجى المحاولة مرة أخرى أو الاتصال بالدعم.",
|
||||
"messenger.discord.installResult.reasons.accessDenied": "تم إلغاء التفويض",
|
||||
"messenger.discord.installResult.reasons.exchangeFailed": "فشل تفويض Discord",
|
||||
"messenger.discord.installResult.reasons.generic": "حدث خطأ غير معروف",
|
||||
"messenger.discord.installResult.reasons.invalidState": "انتهت صلاحية جلسة التثبيت",
|
||||
"messenger.discord.installResult.reasons.missingAppId": "أعاد Discord معلومات تطبيق غير مكتملة",
|
||||
"messenger.discord.installResult.reasons.missingCodeOrState": "أعاد Discord معلمات تثبيت غير مكتملة",
|
||||
"messenger.discord.installResult.reasons.missingTenant": "لم يُرجع Discord معرف الخادم",
|
||||
"messenger.discord.installResult.reasons.missingToken": "لم يُرجع Discord رمز الوصول",
|
||||
"messenger.discord.installResult.reasons.persistFailed": "تعذر حفظ اتصال الخادم",
|
||||
"messenger.discord.installResult.success": "تم توصيل خادم Discord.",
|
||||
"messenger.discord.userPending.cta": "افتح في Discord",
|
||||
"messenger.discord.userPending.hint": "افتح البوت في Discord وأرسل أي رسالة لإكمال ربط حسابك.",
|
||||
"messenger.discord.userPending.name": "لم يتم الربط بعد",
|
||||
@@ -96,9 +112,6 @@
|
||||
"verify.error.missingToken": "رابط غير صالح. افتح هذه الصفحة من البوت.",
|
||||
"verify.error.title": "تعذر تأكيد الرابط",
|
||||
"verify.error.unlinkBeforeRelink": "تم ربط حساب LobeHub هذا بالفعل بحساب Telegram آخر. قم بفصله في الإعدادات → المراسلة قبل ربط حساب جديد.",
|
||||
"verify.labRequired.description": "المراسلة حاليًا ميزة تجريبية. قم بتمكينها في الإعدادات → متقدم → الميزات التجريبية وأعد تحميل هذه الصفحة.",
|
||||
"verify.labRequired.openSettings": "افتح إعدادات الميزات التجريبية",
|
||||
"verify.labRequired.title": "قم بتمكين المراسلة للمتابعة",
|
||||
"verify.signInCta": "تسجيل الدخول للمتابعة",
|
||||
"verify.signInRequired": "يرجى تسجيل الدخول إلى LobeHub لتأكيد الرابط.",
|
||||
"verify.success.description": "تم الآن ربط حسابك بـ {{platform}}. افتح {{platform}} وأرسل رسالتك الأولى.",
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"dropdownLabel": "افتح دليل العمل في",
|
||||
"errors.appNotInstalled": "{{appName}} غير مثبت",
|
||||
"errors.launchFailed": "فشل الفتح في {{appName}}: {{error}}",
|
||||
"errors.pathNotFound": "المسار غير موجود: {{path}}",
|
||||
"errors.unknown": "خطأ غير معروف",
|
||||
"tooltip": "افتح في {{appName}}"
|
||||
}
|
||||
@@ -69,6 +69,9 @@
|
||||
"builtins.lobe-agent-management.render.installPlugin.plugin": "الملحق",
|
||||
"builtins.lobe-agent-management.render.installPlugin.success": "تم التثبيت بنجاح",
|
||||
"builtins.lobe-agent-management.title": "مدير الوكلاء",
|
||||
"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": "جارٍ إرسال الوكيل الفرعي: ",
|
||||
|
||||
+21
-6
@@ -187,6 +187,7 @@
|
||||
"agentTab.opening": "إعدادات البداية",
|
||||
"agentTab.plugin": "إعدادات المهارات",
|
||||
"agentTab.prompt": "ملف تعريف الوكيل",
|
||||
"agentTab.selfIteration": "التكرار الذاتي",
|
||||
"agentTab.tts": "خدمة الصوت",
|
||||
"analytics.telemetry.desc": "ساعدنا في تحسين {{appName}} من خلال بيانات استخدام مجهولة",
|
||||
"analytics.telemetry.title": "إرسال بيانات استخدام مجهولة",
|
||||
@@ -554,9 +555,6 @@
|
||||
"settingChat.inputTemplate.desc": "سيتم ملء أحدث رسالة للمستخدم في هذا القالب",
|
||||
"settingChat.inputTemplate.placeholder": "سيتم استبدال قالب المعالجة المسبقة {{text}} بمعلومات الإدخال الفعلية",
|
||||
"settingChat.inputTemplate.title": "معالجة مسبقة لإدخال المستخدم",
|
||||
"settingChat.selfIteration.enabled.desc": "Allow this assistant to review recent signals and improve its own skills when the lab workflow runs",
|
||||
"settingChat.selfIteration.enabled.title": "Enable Self-Iteration",
|
||||
"settingChat.selfIteration.title": "Advanced Labs",
|
||||
"settingChat.submit": "تحديث تفضيلات الدردشة",
|
||||
"settingChat.title": "إعدادات الدردشة",
|
||||
"settingChatAppearance.autoScrollOnStreaming.desc": "التمرير تلقائيًا إلى الأسفل عند توليد الذكاء الاصطناعي للاستجابة",
|
||||
@@ -659,6 +657,17 @@
|
||||
"settingModel.maxTokens.title": "حد الرموز القصوى",
|
||||
"settingModel.model.desc": "نموذج {{provider}}",
|
||||
"settingModel.model.title": "النموذج",
|
||||
"settingModel.params.panel.advanced": "إعدادات متقدمة",
|
||||
"settingModel.params.panel.agentTitle": "إعدادات الوكيل المتقدمة",
|
||||
"settingModel.params.panel.contextCompression": "ضغط السياق تلقائيًا",
|
||||
"settingModel.params.panel.creativity": "الإبداع",
|
||||
"settingModel.params.panel.historyLimit": "تحديد رسائل السجل",
|
||||
"settingModel.params.panel.openness": "الانفتاح",
|
||||
"settingModel.params.panel.responseLength": "تحديد طول الاستجابة",
|
||||
"settingModel.params.panel.tab": "المعلمات",
|
||||
"settingModel.params.panel.title": "إعدادات معلمات الدردشة",
|
||||
"settingModel.params.panel.topicDivergence": "تباين الموضوع",
|
||||
"settingModel.params.panel.vocabularyRichness": "ثراء المفردات",
|
||||
"settingModel.params.title": "إعدادات متقدمة",
|
||||
"settingModel.presencePenalty.desc": "كلما زادت القيمة، زاد الميل لاستخدام تعبيرات متنوعة وتجنب تكرار المفاهيم؛ وكلما انخفضت، زاد الميل لتكرار المفاهيم أو السرد، مما يؤدي إلى تعبير أكثر اتساقًا.",
|
||||
"settingModel.presencePenalty.title": "تنوع التعبير",
|
||||
@@ -684,6 +693,10 @@
|
||||
"settingOpening.openingQuestions.title": "الأسئلة الافتتاحية",
|
||||
"settingOpening.title": "إعدادات البداية",
|
||||
"settingPlugin.title": "قائمة المهارات",
|
||||
"settingSelfIteration.enabled.desc": "السماح لهذا المساعد بمراجعة الإشارات الأخيرة وتحسين مهاراته الخاصة عند تشغيل سير عمل التكرار الذاتي.",
|
||||
"settingSelfIteration.enabled.managedDesc": "مفعّل دائمًا لـ Lobe AI أثناء توفر التكرار الذاتي.",
|
||||
"settingSelfIteration.enabled.title": "تفعيل التكرار الذاتي",
|
||||
"settingSelfIteration.title": "التكرار الذاتي",
|
||||
"settingSystem.oauth.info.desc": "تم تسجيل الدخول",
|
||||
"settingSystem.oauth.info.title": "معلومات الحساب",
|
||||
"settingSystem.oauth.signin.action": "تسجيل الدخول",
|
||||
@@ -897,7 +910,12 @@
|
||||
"tab.uploadZip": "رفع ملف مضغوط",
|
||||
"tab.uploadZip.desc": "رفع ملف .zip أو .skill محلي",
|
||||
"tab.usage": "إحصائيات الاستخدام",
|
||||
"tools.activation.auto": "تلقائي",
|
||||
"tools.activation.auto.desc": "ذكي",
|
||||
"tools.activation.pinned": "مثبت",
|
||||
"tools.activation.pinned.desc": "دائمًا قيد التشغيل",
|
||||
"tools.add": "إضافة مهارة",
|
||||
"tools.builtins.configure": "تهيئة",
|
||||
"tools.builtins.find-skills.description": "يساعد المستخدمين في اكتشاف وتثبيت مهارات الوكلاء عند سؤالهم \"كيف أفعل كذا\" أو \"اعثر على مهارة لكذا\" أو عند رغبتهم في توسيع القدرات",
|
||||
"tools.builtins.find-skills.title": "العثور على المهارات",
|
||||
"tools.builtins.groupName": "المهارات المدمجة",
|
||||
@@ -932,9 +950,6 @@
|
||||
"tools.builtins.lobe-group-agent-builder.title": "منشئ وكيل المجموعة",
|
||||
"tools.builtins.lobe-group-management.description": "تنظيم وإدارة المحادثات الجماعية لوكلاء متعددين",
|
||||
"tools.builtins.lobe-group-management.title": "إدارة المجموعات",
|
||||
"tools.builtins.lobe-gtd.description": "خطط للأهداف وتابع التقدم باستخدام منهجية GTD. أنشئ خططًا استراتيجية، وأدر قوائم المهام مع تتبع الحالة، ونفّذ مهام غير متزامنة طويلة الأمد.",
|
||||
"tools.builtins.lobe-gtd.readme": "خطط لأهدافك وتابع تقدمك باستخدام منهجية GTD. أنشئ خططًا استراتيجية، وأدر قوائم المهام مع تتبع الحالة، ونفّذ المهام غير المتزامنة طويلة الأمد.",
|
||||
"tools.builtins.lobe-gtd.title": "أدوات GTD",
|
||||
"tools.builtins.lobe-knowledge-base.description": "البحث في المستندات المرفوعة والمعرفة المتخصصة عبر البحث الدلالي — للرجوع الدائم والقابل لإعادة الاستخدام",
|
||||
"tools.builtins.lobe-knowledge-base.title": "قاعدة المعرفة",
|
||||
"tools.builtins.lobe-local-system.description": "الوصول إلى نظام الملفات المحلي على سطح المكتب. قراءة، وكتابة، والبحث، وتنظيم الملفات. تنفيذ أوامر الصدفة مع دعم المهام الخلفية والبحث في المحتوى باستخدام تعبيرات regex.",
|
||||
|
||||
@@ -16,11 +16,15 @@
|
||||
"table.columns.trigger.enums.api": "استدعاء API",
|
||||
"table.columns.trigger.enums.bot": "رسالة بوت",
|
||||
"table.columns.trigger.enums.chat": "رسالة دردشة",
|
||||
"table.columns.trigger.enums.cli": "واجهة سطر الأوامر",
|
||||
"table.columns.trigger.enums.cron": "مهمة مجدولة",
|
||||
"table.columns.trigger.enums.eval": "تقييم الأداء",
|
||||
"table.columns.trigger.enums.file_embedding": "تضمين ملف",
|
||||
"table.columns.trigger.enums.image": "توليد الصور",
|
||||
"table.columns.trigger.enums.memory": "استخراج الذاكرة",
|
||||
"table.columns.trigger.enums.notify": "إشعار",
|
||||
"table.columns.trigger.enums.onboarding": "التسجيل",
|
||||
"table.columns.trigger.enums.openapi": "واجهة برمجة التطبيقات المفتوحة",
|
||||
"table.columns.trigger.enums.semantic_search": "بحث المعرفة",
|
||||
"table.columns.trigger.enums.topic": "ملخص الموضوع",
|
||||
"table.columns.trigger.enums.video": "توليد الفيديو",
|
||||
|
||||
@@ -21,6 +21,9 @@
|
||||
"channel.botTokenPlaceholderNew": "Поставете вашия токен на бота тук",
|
||||
"channel.charLimit": "Ограничение на символите",
|
||||
"channel.charLimitHint": "Максимален брой символи на съобщение",
|
||||
"channel.comingSoon": "Очаквайте скоро",
|
||||
"channel.comingSoonDesc": "Работим върху интеграцията на това в LobeHub. Следете за актуализации.",
|
||||
"channel.comingSoonTitle": "Интеграцията на {{name}} идва скоро",
|
||||
"channel.concurrency": "Режим на едновременност",
|
||||
"channel.concurrencyDebounce": "Забавяне",
|
||||
"channel.concurrencyDebounceHint": "Обработва само последното съобщение от серия (по-ранните се игнорират)",
|
||||
@@ -183,6 +186,14 @@
|
||||
"channel.verificationToken": "Токен за проверка",
|
||||
"channel.verificationTokenHint": "По избор. Използва се за проверка на източника на събития за уебхук.",
|
||||
"channel.verificationTokenPlaceholder": "Поставете вашия токен за проверка тук",
|
||||
"channel.watchKeywordInstructionLabel": "Инструкция",
|
||||
"channel.watchKeywordInstructionPlaceholder": "напр. Сканирай последната тема и отговори, ако има доклад за грешка, който изисква действие",
|
||||
"channel.watchKeywordLabel": "Ключова дума",
|
||||
"channel.watchKeywordPlaceholder": "напр. грешка",
|
||||
"channel.watchKeywords": "Наблюдавани ключови думи",
|
||||
"channel.watchKeywordsAdd": "Добави ключова дума",
|
||||
"channel.watchKeywordsEmpty": "Все още няма добавени ключови думи — ботът се активира само при @споменаване или директно съобщение в абонираните канали.",
|
||||
"channel.watchKeywordsHint": "Когато съобщение в абониран канал съвпадне с ключова дума, ботът се активира без @споменаване и инструкцията се добавя към съобщението на потребителя преди да бъде изпратено към AI. Без значение от главни/малки букви, съвпадение на цяла дума.",
|
||||
"channel.wechat.description": "Свържете този асистент с WeChat чрез iLink Bot за лични и групови чатове.",
|
||||
"channel.wechatBotId": "ID на бота",
|
||||
"channel.wechatBotIdHint": "Идентификатор на бота, присвоен след оторизация чрез QR код.",
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"agentProfile.knowledgeBases_other": "{{count}} бази знания",
|
||||
"agentProfile.skills_one": "{{count}} умение",
|
||||
"agentProfile.skills_other": "{{count}} умения",
|
||||
"agentSignal.receipts.agentSignalLabel": "Сигнал на агент",
|
||||
"agentSignal.receipts.memory.detail": "Запазено за бъдещи отговори",
|
||||
"agentSignal.receipts.memory.title": "Паметта е запазена",
|
||||
"agentSignal.receipts.recentActivity": "Скорошна активност",
|
||||
@@ -41,6 +42,16 @@
|
||||
"builtinCopilot": "Вграден Копилот",
|
||||
"chatList.expandMessage": "Разгъни съобщението",
|
||||
"chatList.longMessageDetail": "Прегледай подробности",
|
||||
"chatMode.agent": "Агент",
|
||||
"chatMode.agentCap.env": "Работна среда",
|
||||
"chatMode.agentCap.files": "Достъп до файлове",
|
||||
"chatMode.agentCap.memory": "Памет",
|
||||
"chatMode.agentCap.tools": "Използване на инструменти",
|
||||
"chatMode.agentCap.web": "Уеб търсене",
|
||||
"chatMode.agentDesc": "Агентът може да използва инструменти и среда за автоматично изпълнение на задачи",
|
||||
"chatMode.chat": "Чат",
|
||||
"chatMode.chatDesc": "Без работна среда или автономност; използва по-малко токени",
|
||||
"chatMode.select": "Смяна на режим",
|
||||
"claudeCodeInstallGuide.actions.openDocs": "Отвори ръководството за инсталиране",
|
||||
"claudeCodeInstallGuide.actions.openSystemTools": "Отвори системните инструменти",
|
||||
"claudeCodeInstallGuide.afterInstall": "След инсталиране стартирайте Claude Code веднъж, за да влезете, след което опитайте отново или натиснете „Повторно откриване“ в Системни инструменти.",
|
||||
@@ -59,6 +70,7 @@
|
||||
"cliAuthGuide.runCommand": "Изпълнете това в терминала",
|
||||
"cliAuthGuide.title": "Влезте в {{name}}",
|
||||
"cliRateLimitGuide.actions.openSystemTools": "Отвори системните инструменти",
|
||||
"cliRateLimitGuide.actions.retry": "Опитай отново",
|
||||
"cliRateLimitGuide.afterReset": "Изчакайте до времето за нулиране, след което опитайте отново. Ако използвате API удостоверяване, проверете квотата и фактурирането при вашия доставчик.",
|
||||
"cliRateLimitGuide.desc": "{{name}} достигна текущия си лимит на употреба и не може да продължи в момента.",
|
||||
"cliRateLimitGuide.limitType": "Период на лимит",
|
||||
@@ -223,6 +235,8 @@
|
||||
"knowledgeBase.allFiles": "Всички файлове",
|
||||
"knowledgeBase.allLibraries": "Всички библиотеки",
|
||||
"knowledgeBase.disabled": "Чатът с библиотеката не е наличен в тази инсталация. Превключете към сървърна база данни или използвайте {{cloud}}.",
|
||||
"knowledgeBase.files": "Файлове",
|
||||
"knowledgeBase.libraries": "Библиотеки",
|
||||
"knowledgeBase.library.action.add": "Добави",
|
||||
"knowledgeBase.library.action.detail": "Детайли",
|
||||
"knowledgeBase.library.action.remove": "Премахни",
|
||||
@@ -326,6 +340,15 @@
|
||||
"pageSelection.reference": "Избран текст",
|
||||
"pin": "Закачи",
|
||||
"pinOff": "Откачи",
|
||||
"plus.addSkills": "Добавяне на умения...",
|
||||
"plus.search.appSearch": "Интелигентно търсене",
|
||||
"plus.search.appSearchDesc": "Оптимизирана търсачка на LobeHub, предоставяща най-добри резултати от търсенето.",
|
||||
"plus.search.modelSearch": "Търсене по доставчик",
|
||||
"plus.search.modelSearchDesc": "Може да предизвика неочаквано поведение при активиране, не се препоръчва.",
|
||||
"plus.search.off": "Изключено",
|
||||
"plus.search.offDesc": "",
|
||||
"plus.title": "Добавяне",
|
||||
"plus.tooltip": "Добавяне на файлове, умения и повече контекст...",
|
||||
"rag.referenceChunks": "Източник на препратки",
|
||||
"rag.userQuery.actions.delete": "Изтрий пренаписаното запитване",
|
||||
"rag.userQuery.actions.regenerate": "Генерирай запитване отново",
|
||||
@@ -357,6 +380,8 @@
|
||||
"searchAgents": "Търсене на агенти...",
|
||||
"selectedAgents": "Избрани агенти",
|
||||
"sendPlaceholder": "Попитай, създай или започни задача, <hotkey><hotkey/>",
|
||||
"sendPlaceholderChat": "Попитай, търси или генерирай идеи, <hotkey><hotkey/>",
|
||||
"sendPlaceholderChatWithAgentAssignment": "Попитай, търси или генерирай идеи. @ за включване на други агенти.",
|
||||
"sendPlaceholderHeterogeneous": "Помолете {{name}} да изпълни задача...",
|
||||
"sendPlaceholderWithAgentAssignment": "Питайте, създайте или започнете задача. Използвайте @, за да възлагате задачи на други агенти.",
|
||||
"sessionGroup.config": "Управление на групата",
|
||||
@@ -734,6 +759,7 @@
|
||||
"untitledAgent": "Агент без име",
|
||||
"untitledGroup": "Група без име",
|
||||
"updateAgent": "Актуализирай информацията за агента",
|
||||
"upload.action.fileOrImageUpload": "Качване на файл или изображение",
|
||||
"upload.action.fileUpload": "Качи файл",
|
||||
"upload.action.folderUpload": "Качи папка",
|
||||
"upload.action.imageDisabled": "Текущият модел не поддържа визуално разпознаване. Моля, сменете модела, за да използвате тази функция.",
|
||||
@@ -846,6 +872,23 @@
|
||||
"workingPanel.documents.saved": "All changes saved",
|
||||
"workingPanel.documents.title": "Document",
|
||||
"workingPanel.documents.unsaved": "Unsaved changes",
|
||||
"workingPanel.files.copyAbsolutePath": "Копирай пътя",
|
||||
"workingPanel.files.copyRelativePath": "Копирай относителния път",
|
||||
"workingPanel.files.count_one": "{{count}} файл",
|
||||
"workingPanel.files.count_other": "{{count}} файла",
|
||||
"workingPanel.files.empty": "Няма файлове в това работно пространство",
|
||||
"workingPanel.files.open": "Отвори файл",
|
||||
"workingPanel.files.refresh": "Обнови",
|
||||
"workingPanel.files.showInReview": "Покажи в преглед",
|
||||
"workingPanel.files.showInSystem": "Покажи в папка",
|
||||
"workingPanel.files.title": "Файлове",
|
||||
"workingPanel.localFile.binary": "Бинарен файл — прегледът не е наличен",
|
||||
"workingPanel.localFile.close": "Затвори",
|
||||
"workingPanel.localFile.closeLeft": "Затвори наляво",
|
||||
"workingPanel.localFile.closeOther": "Затвори другите",
|
||||
"workingPanel.localFile.closeRight": "Затвори надясно",
|
||||
"workingPanel.localFile.error": "Не може да се зареди този файл",
|
||||
"workingPanel.localFile.truncated": "Прегледът на файла е съкратен до {{limit}} символа",
|
||||
"workingPanel.progress": "Progress",
|
||||
"workingPanel.progress.allCompleted": "All tasks completed",
|
||||
"workingPanel.resources": "Resources",
|
||||
@@ -892,6 +935,8 @@
|
||||
"workingPanel.review.mode.unstaged": "Неинсценирано",
|
||||
"workingPanel.review.more": "Още опции",
|
||||
"workingPanel.review.refresh": "Обнови",
|
||||
"workingPanel.review.revealInTree": "Покажи в дървото",
|
||||
"workingPanel.review.revealNotFound": "Файлът не е намерен в индекса на проекта",
|
||||
"workingPanel.review.revert": "Отхвърли промените",
|
||||
"workingPanel.review.revert.confirm.cancel": "Отказ",
|
||||
"workingPanel.review.revert.confirm.description": "Промените в работното дърво за {{filePath}} ще бъдат изтрити окончателно. Неследените файлове ще бъдат изтрити от диска.",
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"brief.action.confirm": "Потвърди",
|
||||
"brief.action.confirmDone": "Потвърди",
|
||||
"brief.action.feedback": "Обратна връзка",
|
||||
"brief.action.ignore": "Игнорирай",
|
||||
"brief.action.retry": "Опит отново",
|
||||
"brief.addFeedback": "Споделяне на обратна връзка",
|
||||
"brief.collapse": "Покажи по-малко",
|
||||
|
||||
@@ -20,6 +20,22 @@
|
||||
"messenger.discord.connections.disconnectFailed": "Неуспешно премахване на сървъра.",
|
||||
"messenger.discord.connections.disconnectSuccess": "Сървърът е премахнат.",
|
||||
"messenger.discord.connections.disconnectTitle": "Премахване на сървър",
|
||||
"messenger.discord.installBlocked.dismiss": "Разбрах",
|
||||
"messenger.discord.installBlocked.suggestion": "Изпратете лично съобщение на бота LobeHub в Discord, за да свържете личния си акаунт — не е необходимо да добавяте бота отново. Или помолете първоначалния инсталатор да премахне този сървър в LobeHub Настройки → Messenger, преди да го добавите отново.",
|
||||
"messenger.discord.installBlocked.title": "Сървърът вече е свързан",
|
||||
"messenger.discord.installBlocked.withName": "\"{{workspace}}\" вече е свързан с LobeHub от друг потребител.",
|
||||
"messenger.discord.installBlocked.withoutName": "Този Discord сървър вече е свързан с LobeHub от друг потребител.",
|
||||
"messenger.discord.installResult.failed": "Инсталирането на Discord не бе успешно ({{reason}}). Моля, опитайте отново или се свържете с поддръжката.",
|
||||
"messenger.discord.installResult.reasons.accessDenied": "авторизацията беше отменена",
|
||||
"messenger.discord.installResult.reasons.exchangeFailed": "авторизацията в Discord не бе успешна",
|
||||
"messenger.discord.installResult.reasons.generic": "възникна неизвестна грешка",
|
||||
"messenger.discord.installResult.reasons.invalidState": "сесията за инсталиране изтече",
|
||||
"messenger.discord.installResult.reasons.missingAppId": "Discord върна непълна информация за приложението",
|
||||
"messenger.discord.installResult.reasons.missingCodeOrState": "Discord върна непълни параметри за инсталиране",
|
||||
"messenger.discord.installResult.reasons.missingTenant": "Discord не върна идентификатор на сървъра",
|
||||
"messenger.discord.installResult.reasons.missingToken": "Discord не върна токен за достъп",
|
||||
"messenger.discord.installResult.reasons.persistFailed": "връзката със сървъра не можа да бъде запазена",
|
||||
"messenger.discord.installResult.success": "Discord сървърът е свързан.",
|
||||
"messenger.discord.userPending.cta": "Отворете в Discord",
|
||||
"messenger.discord.userPending.hint": "Отворете бота в Discord и изпратете съобщение, за да завършите свързването на акаунта си.",
|
||||
"messenger.discord.userPending.name": "Все още не е свързан",
|
||||
@@ -96,9 +112,6 @@
|
||||
"verify.error.missingToken": "Невалидна връзка. Отворете тази страница от бота.",
|
||||
"verify.error.title": "Неуспешно потвърждаване на връзката",
|
||||
"verify.error.unlinkBeforeRelink": "Този LobeHub акаунт вече е свързан с друг Telegram акаунт. Прекъснете връзката в Настройки → Messenger, преди да свържете нов.",
|
||||
"verify.labRequired.description": "Messenger в момента е функция в Labs. Активирайте я в Настройки → Разширени → Labs и презаредете тази страница.",
|
||||
"verify.labRequired.openSettings": "Отворете настройките на Labs",
|
||||
"verify.labRequired.title": "Активирайте Messenger, за да продължите",
|
||||
"verify.signInCta": "Влезте, за да продължите",
|
||||
"verify.signInRequired": "Моля, влезте в LobeHub, за да потвърдите връзката.",
|
||||
"verify.success.description": "Вашият акаунт вече е свързан с {{platform}}. Отворете {{platform}} и изпратете първото си съобщение.",
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"dropdownLabel": "Отвори работната директория в",
|
||||
"errors.appNotInstalled": "{{appName}} не е инсталиран",
|
||||
"errors.launchFailed": "Неуспешно отваряне в {{appName}}: {{error}}",
|
||||
"errors.pathNotFound": "Пътят не е намерен: {{path}}",
|
||||
"errors.unknown": "неизвестна грешка",
|
||||
"tooltip": "Отвори в {{appName}}"
|
||||
}
|
||||
@@ -69,6 +69,9 @@
|
||||
"builtins.lobe-agent-management.render.installPlugin.plugin": "Плъгин",
|
||||
"builtins.lobe-agent-management.render.installPlugin.success": "Успешно инсталиран",
|
||||
"builtins.lobe-agent-management.title": "Мениджър на агенти",
|
||||
"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": "Изпращане на под-агент: ",
|
||||
|
||||
@@ -187,6 +187,7 @@
|
||||
"agentTab.opening": "Начални настройки",
|
||||
"agentTab.plugin": "Настройки на уменията",
|
||||
"agentTab.prompt": "Профил на агента",
|
||||
"agentTab.selfIteration": "Само-итерация",
|
||||
"agentTab.tts": "Гласова услуга",
|
||||
"analytics.telemetry.desc": "Помогнете ни да подобрим {{appName}} с анонимни данни за използване",
|
||||
"analytics.telemetry.title": "Изпращане на анонимни данни за използване",
|
||||
@@ -554,9 +555,6 @@
|
||||
"settingChat.inputTemplate.desc": "Последното съобщение на потребителя ще бъде вмъкнато в този шаблон",
|
||||
"settingChat.inputTemplate.placeholder": "Шаблон за предварителна обработка {{text}} ще бъде заменен с реално въведена информация",
|
||||
"settingChat.inputTemplate.title": "Предварителна обработка на входа",
|
||||
"settingChat.selfIteration.enabled.desc": "Allow this assistant to review recent signals and improve its own skills when the lab workflow runs",
|
||||
"settingChat.selfIteration.enabled.title": "Enable Self-Iteration",
|
||||
"settingChat.selfIteration.title": "Advanced Labs",
|
||||
"settingChat.submit": "Актуализирай предпочитанията за чат",
|
||||
"settingChat.title": "Настройки на чата",
|
||||
"settingChatAppearance.autoScrollOnStreaming.desc": "Автоматично превъртане до дъното, когато ИИ генерира отговор",
|
||||
@@ -659,6 +657,17 @@
|
||||
"settingModel.maxTokens.title": "Лимит на токени",
|
||||
"settingModel.model.desc": "Модел на {{provider}}",
|
||||
"settingModel.model.title": "Модел",
|
||||
"settingModel.params.panel.advanced": "Разширени настройки",
|
||||
"settingModel.params.panel.agentTitle": "Разширени настройки на агент",
|
||||
"settingModel.params.panel.contextCompression": "Автоматично компресиране на контекста",
|
||||
"settingModel.params.panel.creativity": "Креативност",
|
||||
"settingModel.params.panel.historyLimit": "Ограничаване на съобщенията в историята",
|
||||
"settingModel.params.panel.openness": "Отвореност",
|
||||
"settingModel.params.panel.responseLength": "Ограничаване на дължината на отговора",
|
||||
"settingModel.params.panel.tab": "Параметри",
|
||||
"settingModel.params.panel.title": "Настройки на параметрите на чата",
|
||||
"settingModel.params.panel.topicDivergence": "Отклонение на темата",
|
||||
"settingModel.params.panel.vocabularyRichness": "Богатство на речника",
|
||||
"settingModel.params.title": "Разширени параметри",
|
||||
"settingModel.presencePenalty.desc": "Колкото по-висока е стойността, толкова по-склонен е моделът да използва различни изрази и да избягва повторения; по-ниска стойност води до по-последователно, но повтарящо се изразяване.",
|
||||
"settingModel.presencePenalty.title": "Разнообразие на изразяване",
|
||||
@@ -684,6 +693,10 @@
|
||||
"settingOpening.openingQuestions.title": "Начални въпроси",
|
||||
"settingOpening.title": "Настройки за начало",
|
||||
"settingPlugin.title": "Списък с умения",
|
||||
"settingSelfIteration.enabled.desc": "Позволете на този асистент да преглежда последните сигнали и да подобрява собствените си умения, когато работният процес за само-итерация се изпълнява.",
|
||||
"settingSelfIteration.enabled.managedDesc": "Винаги включено за Lobe AI, докато само-итерацията е налична.",
|
||||
"settingSelfIteration.enabled.title": "Разрешаване на само-итерация",
|
||||
"settingSelfIteration.title": "Само-итерация",
|
||||
"settingSystem.oauth.info.desc": "Вход изпълнен",
|
||||
"settingSystem.oauth.info.title": "Информация за акаунта",
|
||||
"settingSystem.oauth.signin.action": "Вход",
|
||||
@@ -897,7 +910,12 @@
|
||||
"tab.uploadZip": "Качване на Zip",
|
||||
"tab.uploadZip.desc": "Качване на локален .zip или .skill файл",
|
||||
"tab.usage": "Статистика на използване",
|
||||
"tools.activation.auto": "Автоматично",
|
||||
"tools.activation.auto.desc": "Интелигентно",
|
||||
"tools.activation.pinned": "Закрепено",
|
||||
"tools.activation.pinned.desc": "Винаги включено",
|
||||
"tools.add": "Добави умение",
|
||||
"tools.builtins.configure": "Конфигуриране",
|
||||
"tools.builtins.find-skills.description": "Помага на потребителите да откриват и инсталират умения за агенти, когато питат „как да направя X“, „намери умение за X“ или когато искат да разширят възможностите",
|
||||
"tools.builtins.find-skills.title": "Намиране на умения",
|
||||
"tools.builtins.groupName": "Вградени",
|
||||
@@ -932,9 +950,6 @@
|
||||
"tools.builtins.lobe-group-agent-builder.title": "Създател на групови агенти",
|
||||
"tools.builtins.lobe-group-management.description": "Оркестрирайте и управлявайте разговори в групи от агенти",
|
||||
"tools.builtins.lobe-group-management.title": "Групово управление",
|
||||
"tools.builtins.lobe-gtd.description": "Планирайте цели и следете напредъка с помощта на методологията GTD. Създавайте стратегически планове, управлявайте списъци със задачи със следене на статус и изпълнявайте дълготрайни асинхронни задачи.",
|
||||
"tools.builtins.lobe-gtd.readme": "Планирайте цели и следете напредъка си с помощта на методологията GTD. Създавайте стратегически планове, управлявайте списъци със задачи със следене на статус и изпълнявайте дълготрайни асинхронни задачи.",
|
||||
"tools.builtins.lobe-gtd.title": "GTD Инструменти",
|
||||
"tools.builtins.lobe-knowledge-base.description": "Търсене в качени документи и специализирани знания чрез семантично векторно търсене — за постоянно и многократно използване",
|
||||
"tools.builtins.lobe-knowledge-base.title": "База знания",
|
||||
"tools.builtins.lobe-local-system.description": "Достъп до локалната файлова система на настолния компютър. Четете, записвайте, търсете и организирайте файлове. Изпълнявайте shell команди с поддръжка на фонови задачи и търсете съдържание с regex шаблони.",
|
||||
|
||||
@@ -16,11 +16,15 @@
|
||||
"table.columns.trigger.enums.api": "API Обаждане",
|
||||
"table.columns.trigger.enums.bot": "Съобщение от бот",
|
||||
"table.columns.trigger.enums.chat": "Съобщение в чат",
|
||||
"table.columns.trigger.enums.cli": "CLI",
|
||||
"table.columns.trigger.enums.cron": "Планирана задача",
|
||||
"table.columns.trigger.enums.eval": "Оценка на производителност",
|
||||
"table.columns.trigger.enums.file_embedding": "Вграждане на файл",
|
||||
"table.columns.trigger.enums.image": "Генериране на изображения",
|
||||
"table.columns.trigger.enums.memory": "Извличане на памет",
|
||||
"table.columns.trigger.enums.notify": "Известие",
|
||||
"table.columns.trigger.enums.onboarding": "Въвеждане",
|
||||
"table.columns.trigger.enums.openapi": "OpenAPI",
|
||||
"table.columns.trigger.enums.semantic_search": "Търсене на знания",
|
||||
"table.columns.trigger.enums.topic": "Резюме на тема",
|
||||
"table.columns.trigger.enums.video": "Генериране на видеа",
|
||||
|
||||
@@ -21,6 +21,9 @@
|
||||
"channel.botTokenPlaceholderNew": "Fügen Sie hier Ihr Bot-Token ein",
|
||||
"channel.charLimit": "Zeichenlimit",
|
||||
"channel.charLimitHint": "Maximale Anzahl von Zeichen pro Nachricht",
|
||||
"channel.comingSoon": "Demnächst verfügbar",
|
||||
"channel.comingSoonDesc": "Wir arbeiten daran, diese Integration in LobeHub zu bringen. Bleiben Sie dran für Updates.",
|
||||
"channel.comingSoonTitle": "{{name}}-Integration kommt bald",
|
||||
"channel.concurrency": "Konkurrenzmodus",
|
||||
"channel.concurrencyDebounce": "Entprellen",
|
||||
"channel.concurrencyDebounceHint": "Nur die letzte Nachricht in einer Serie verarbeiten (frühere werden verworfen)",
|
||||
@@ -183,6 +186,14 @@
|
||||
"channel.verificationToken": "Verifizierungstoken",
|
||||
"channel.verificationTokenHint": "Optional. Wird verwendet, um die Quelle von Webhook-Ereignissen zu überprüfen.",
|
||||
"channel.verificationTokenPlaceholder": "Fügen Sie hier Ihr Verifizierungstoken ein",
|
||||
"channel.watchKeywordInstructionLabel": "Anweisung",
|
||||
"channel.watchKeywordInstructionPlaceholder": "z. B. Scannen Sie den aktuellen Thread und antworten Sie, wenn ein umsetzbarer Fehlerbericht vorliegt",
|
||||
"channel.watchKeywordLabel": "Schlüsselwort",
|
||||
"channel.watchKeywordPlaceholder": "z. B. Fehler",
|
||||
"channel.watchKeywords": "Schlüsselwörter überwachen",
|
||||
"channel.watchKeywordsAdd": "Schlüsselwort hinzufügen",
|
||||
"channel.watchKeywordsEmpty": "Noch keine Schlüsselwörter hinzugefügt — der Bot reagiert nur auf @Erwähnungen oder Direktnachrichten in abonnierten Kanälen.",
|
||||
"channel.watchKeywordsHint": "Wenn eine Nachricht in einem abonnierten Kanal mit einem Schlüsselwort übereinstimmt, reagiert der Bot ohne @Erwähnung und die Anweisung wird der Benutzernachricht vorangestellt, bevor sie an die KI gesendet wird. Groß-/Kleinschreibung wird ignoriert, Übereinstimmung ganzer Wörter.",
|
||||
"channel.wechat.description": "Verbinden Sie diesen Assistenten mit WeChat über iLink Bot für private und Gruppenchats.",
|
||||
"channel.wechatBotId": "Bot-ID",
|
||||
"channel.wechatBotIdHint": "Bot-Kennung, die nach der QR-Code-Autorisierung zugewiesen wurde.",
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"agentProfile.knowledgeBases_other": "{{count}} Wissensbasen",
|
||||
"agentProfile.skills_one": "{{count}} Fähigkeit",
|
||||
"agentProfile.skills_other": "{{count}} Fähigkeiten",
|
||||
"agentSignal.receipts.agentSignalLabel": "Agentensignal",
|
||||
"agentSignal.receipts.memory.detail": "Für zukünftige Antworten gespeichert",
|
||||
"agentSignal.receipts.memory.title": "Erinnerung gespeichert",
|
||||
"agentSignal.receipts.recentActivity": "Letzte Aktivität",
|
||||
@@ -41,6 +42,16 @@
|
||||
"builtinCopilot": "Integrierter Copilot",
|
||||
"chatList.expandMessage": "Nachricht erweitern",
|
||||
"chatList.longMessageDetail": "Details anzeigen",
|
||||
"chatMode.agent": "Agent",
|
||||
"chatMode.agentCap.env": "Laufzeitumgebung",
|
||||
"chatMode.agentCap.files": "Dateizugriff",
|
||||
"chatMode.agentCap.memory": "Speicher",
|
||||
"chatMode.agentCap.tools": "Werkzeugaufrufe",
|
||||
"chatMode.agentCap.web": "Websuche",
|
||||
"chatMode.agentDesc": "Agent kann Werkzeuge und Umgebung nutzen, um Aufgaben automatisch zu erledigen",
|
||||
"chatMode.chat": "Chat",
|
||||
"chatMode.chatDesc": "Keine Laufzeitumgebung oder Autonomie; verwendet weniger Tokens",
|
||||
"chatMode.select": "Modus wechseln",
|
||||
"claudeCodeInstallGuide.actions.openDocs": "Installationsanleitung öffnen",
|
||||
"claudeCodeInstallGuide.actions.openSystemTools": "Systemwerkzeuge öffnen",
|
||||
"claudeCodeInstallGuide.afterInstall": "Führen Sie nach der Installation Claude Code einmal aus, um sich anzumelden. Versuchen Sie danach erneut Ihre Nachricht zu senden oder klicken Sie in den Systemwerkzeugen auf „Erneut erkennen“.",
|
||||
@@ -59,6 +70,7 @@
|
||||
"cliAuthGuide.runCommand": "Führen Sie dies im Terminal aus",
|
||||
"cliAuthGuide.title": "Bei {{name}} anmelden",
|
||||
"cliRateLimitGuide.actions.openSystemTools": "Systemwerkzeuge öffnen",
|
||||
"cliRateLimitGuide.actions.retry": "Erneut versuchen",
|
||||
"cliRateLimitGuide.afterReset": "Warten Sie bis zum Reset-Zeitpunkt und versuchen Sie dann erneut, Ihre Nachricht zu senden. Wenn Sie eine API-Autorisierung verwenden, können Sie außerdem Ihr Kontingent und Ihren Abrechnungsstatus prüfen.",
|
||||
"cliRateLimitGuide.desc": "{{name}} hat das aktuelle Nutzungslimit erreicht und kann diesen Vorgang momentan nicht fortsetzen.",
|
||||
"cliRateLimitGuide.limitType": "Limit-Zeitraum",
|
||||
@@ -223,6 +235,8 @@
|
||||
"knowledgeBase.allFiles": "Alle Dateien",
|
||||
"knowledgeBase.allLibraries": "Alle Bibliotheken",
|
||||
"knowledgeBase.disabled": "Bibliotheks-Chat ist in dieser Bereitstellung nicht verfügbar. Wechseln Sie zu einer serverseitigen Datenbank oder verwenden Sie {{cloud}}.",
|
||||
"knowledgeBase.files": "Dateien",
|
||||
"knowledgeBase.libraries": "Bibliotheken",
|
||||
"knowledgeBase.library.action.add": "Hinzufügen",
|
||||
"knowledgeBase.library.action.detail": "Details",
|
||||
"knowledgeBase.library.action.remove": "Entfernen",
|
||||
@@ -326,6 +340,15 @@
|
||||
"pageSelection.reference": "Ausgewählter Text",
|
||||
"pin": "Anheften",
|
||||
"pinOff": "Lösen",
|
||||
"plus.addSkills": "Fähigkeiten hinzufügen...",
|
||||
"plus.search.appSearch": "Intelligente Suche",
|
||||
"plus.search.appSearchDesc": "LobeHub-optimierter Suchdienst, der die besten Suchergebnisse liefert.",
|
||||
"plus.search.modelSearch": "Anbietersuche",
|
||||
"plus.search.modelSearchDesc": "Kann unerwartetes Verhalten verursachen, nicht empfohlen.",
|
||||
"plus.search.off": "Aus",
|
||||
"plus.search.offDesc": "",
|
||||
"plus.title": "Hinzufügen",
|
||||
"plus.tooltip": "Dateien, Fähigkeiten und mehr Kontext hinzufügen...",
|
||||
"rag.referenceChunks": "Referenzquelle",
|
||||
"rag.userQuery.actions.delete": "Abfrage-Neuschreibung löschen",
|
||||
"rag.userQuery.actions.regenerate": "Abfrage neu generieren",
|
||||
@@ -357,6 +380,8 @@
|
||||
"searchAgents": "Agenten suchen...",
|
||||
"selectedAgents": "Ausgewählte Agenten",
|
||||
"sendPlaceholder": "Fragen, erstellen oder Aufgabe starten, <hotkey><hotkey/>",
|
||||
"sendPlaceholderChat": "Fragen, suchen oder brainstormen, <hotkey><hotkey/>",
|
||||
"sendPlaceholderChatWithAgentAssignment": "Fragen, suchen oder brainstormen. @, um andere Agenten hinzuzuziehen.",
|
||||
"sendPlaceholderHeterogeneous": "Bitte {{name}} eine Aufgabe erledigen...",
|
||||
"sendPlaceholderWithAgentAssignment": "Fragen, erstellen oder eine Aufgabe starten. @, um Aufgaben anderen Agenten zuzuweisen.",
|
||||
"sessionGroup.config": "Gruppenverwaltung",
|
||||
@@ -734,6 +759,7 @@
|
||||
"untitledAgent": "Unbenannter Agent",
|
||||
"untitledGroup": "Unbenannte Gruppe",
|
||||
"updateAgent": "Agenteninformationen aktualisieren",
|
||||
"upload.action.fileOrImageUpload": "Datei oder Bild hochladen",
|
||||
"upload.action.fileUpload": "Datei hochladen",
|
||||
"upload.action.folderUpload": "Ordner hochladen",
|
||||
"upload.action.imageDisabled": "Das aktuelle Modell unterstützt keine visuelle Erkennung. Bitte wechsle das Modell, um diese Funktion zu nutzen.",
|
||||
@@ -846,6 +872,23 @@
|
||||
"workingPanel.documents.saved": "All changes saved",
|
||||
"workingPanel.documents.title": "Document",
|
||||
"workingPanel.documents.unsaved": "Unsaved changes",
|
||||
"workingPanel.files.copyAbsolutePath": "Pfad kopieren",
|
||||
"workingPanel.files.copyRelativePath": "Relativen Pfad kopieren",
|
||||
"workingPanel.files.count_one": "{{count}} Datei",
|
||||
"workingPanel.files.count_other": "{{count}} Dateien",
|
||||
"workingPanel.files.empty": "Keine Dateien in diesem Arbeitsbereich",
|
||||
"workingPanel.files.open": "Datei öffnen",
|
||||
"workingPanel.files.refresh": "Aktualisieren",
|
||||
"workingPanel.files.showInReview": "Im Review anzeigen",
|
||||
"workingPanel.files.showInSystem": "Im Ordner anzeigen",
|
||||
"workingPanel.files.title": "Dateien",
|
||||
"workingPanel.localFile.binary": "Binärdatei — Vorschau nicht verfügbar",
|
||||
"workingPanel.localFile.close": "Schließen",
|
||||
"workingPanel.localFile.closeLeft": "Links schließen",
|
||||
"workingPanel.localFile.closeOther": "Andere schließen",
|
||||
"workingPanel.localFile.closeRight": "Rechts schließen",
|
||||
"workingPanel.localFile.error": "Diese Datei konnte nicht geladen werden",
|
||||
"workingPanel.localFile.truncated": "Dateivorschau auf {{limit}} Zeichen gekürzt",
|
||||
"workingPanel.progress": "Progress",
|
||||
"workingPanel.progress.allCompleted": "All tasks completed",
|
||||
"workingPanel.resources": "Resources",
|
||||
@@ -892,6 +935,8 @@
|
||||
"workingPanel.review.mode.unstaged": "Nicht gestaged",
|
||||
"workingPanel.review.more": "Weitere Optionen",
|
||||
"workingPanel.review.refresh": "Aktualisieren",
|
||||
"workingPanel.review.revealInTree": "Im Baum anzeigen",
|
||||
"workingPanel.review.revealNotFound": "Datei im Projektindex nicht gefunden",
|
||||
"workingPanel.review.revert": "Änderungen verwerfen",
|
||||
"workingPanel.review.revert.confirm.cancel": "Abbrechen",
|
||||
"workingPanel.review.revert.confirm.description": "Die Änderungen in der Arbeitskopie an {{filePath}} werden dauerhaft verworfen. Nicht verfolgte Dateien werden von der Festplatte gelöscht.",
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"brief.action.confirm": "Bestätigen",
|
||||
"brief.action.confirmDone": "Bestätigen",
|
||||
"brief.action.feedback": "Feedback",
|
||||
"brief.action.ignore": "Ignorieren",
|
||||
"brief.action.retry": "Erneut versuchen",
|
||||
"brief.addFeedback": "Feedback teilen",
|
||||
"brief.collapse": "Weniger anzeigen",
|
||||
|
||||
@@ -20,6 +20,22 @@
|
||||
"messenger.discord.connections.disconnectFailed": "Server konnte nicht entfernt werden.",
|
||||
"messenger.discord.connections.disconnectSuccess": "Server entfernt.",
|
||||
"messenger.discord.connections.disconnectTitle": "Server entfernen",
|
||||
"messenger.discord.installBlocked.dismiss": "Verstanden",
|
||||
"messenger.discord.installBlocked.suggestion": "Senden Sie dem LobeHub-Bot in Discord eine Direktnachricht, um Ihr persönliches Konto zu verknüpfen – Sie müssen den Bot nicht erneut hinzufügen. Oder bitten Sie den ursprünglichen Installateur, diesen Server in den LobeHub-Einstellungen → Messenger zu entfernen, bevor Sie ihn erneut hinzufügen.",
|
||||
"messenger.discord.installBlocked.title": "Server bereits verbunden",
|
||||
"messenger.discord.installBlocked.withName": "\"{{workspace}}\" ist bereits von einem anderen Benutzer mit LobeHub verbunden.",
|
||||
"messenger.discord.installBlocked.withoutName": "Dieser Discord-Server ist bereits von einem anderen Benutzer mit LobeHub verbunden.",
|
||||
"messenger.discord.installResult.failed": "Discord-Installation fehlgeschlagen ({{reason}}). Bitte versuchen Sie es erneut oder kontaktieren Sie den Support.",
|
||||
"messenger.discord.installResult.reasons.accessDenied": "Die Autorisierung wurde abgebrochen",
|
||||
"messenger.discord.installResult.reasons.exchangeFailed": "Discord-Autorisierung fehlgeschlagen",
|
||||
"messenger.discord.installResult.reasons.generic": "Ein unbekannter Fehler ist aufgetreten",
|
||||
"messenger.discord.installResult.reasons.invalidState": "Die Installationssitzung ist abgelaufen",
|
||||
"messenger.discord.installResult.reasons.missingAppId": "Discord hat unvollständige App-Informationen zurückgegeben",
|
||||
"messenger.discord.installResult.reasons.missingCodeOrState": "Discord hat unvollständige Installationsparameter zurückgegeben",
|
||||
"messenger.discord.installResult.reasons.missingTenant": "Discord hat keine Serverkennung zurückgegeben",
|
||||
"messenger.discord.installResult.reasons.missingToken": "Discord hat kein Zugriffstoken zurückgegeben",
|
||||
"messenger.discord.installResult.reasons.persistFailed": "Die Serververbindung konnte nicht gespeichert werden",
|
||||
"messenger.discord.installResult.success": "Discord-Server verbunden.",
|
||||
"messenger.discord.userPending.cta": "In Discord öffnen",
|
||||
"messenger.discord.userPending.hint": "Öffnen Sie den Bot in Discord und senden Sie eine Nachricht, um die Verknüpfung Ihres Kontos abzuschließen.",
|
||||
"messenger.discord.userPending.name": "Noch nicht verknüpft",
|
||||
@@ -96,9 +112,6 @@
|
||||
"verify.error.missingToken": "Ungültiger Link. Öffnen Sie diese Seite über den Bot.",
|
||||
"verify.error.title": "Verknüpfung konnte nicht bestätigt werden",
|
||||
"verify.error.unlinkBeforeRelink": "Dieses LobeHub-Konto ist bereits mit einem anderen Telegram-Konto verknüpft. Trennen Sie es in Einstellungen → Messenger, bevor Sie ein neues verknüpfen.",
|
||||
"verify.labRequired.description": "Messenger ist derzeit eine Labs-Funktion. Aktivieren Sie sie unter Einstellungen → Erweitert → Labs und laden Sie diese Seite neu.",
|
||||
"verify.labRequired.openSettings": "Labs-Einstellungen öffnen",
|
||||
"verify.labRequired.title": "Messenger aktivieren, um fortzufahren",
|
||||
"verify.signInCta": "Anmelden, um fortzufahren",
|
||||
"verify.signInRequired": "Bitte melden Sie sich bei LobeHub an, um die Verknüpfung zu bestätigen.",
|
||||
"verify.success.description": "Ihr Konto ist jetzt mit {{platform}} verbunden. Öffnen Sie {{platform}} und senden Sie Ihre erste Nachricht.",
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"dropdownLabel": "Arbeitsverzeichnis öffnen in",
|
||||
"errors.appNotInstalled": "{{appName}} ist nicht installiert",
|
||||
"errors.launchFailed": "Fehler beim Öffnen in {{appName}}: {{error}}",
|
||||
"errors.pathNotFound": "Pfad nicht gefunden: {{path}}",
|
||||
"errors.unknown": "Unbekannter Fehler",
|
||||
"tooltip": "Öffnen in {{appName}}"
|
||||
}
|
||||
@@ -69,6 +69,9 @@
|
||||
"builtins.lobe-agent-management.render.installPlugin.plugin": "Plugin",
|
||||
"builtins.lobe-agent-management.render.installPlugin.success": "Erfolgreich installiert",
|
||||
"builtins.lobe-agent-management.title": "Agenten-Manager",
|
||||
"builtins.lobe-agent.apiName.analyzeVisualMedia": "Visuelle Medien analysieren",
|
||||
"builtins.lobe-agent.apiName.analyzeVisualMedia.mediaCount": "{{count}} Medien",
|
||||
"builtins.lobe-agent.apiName.analyzeVisualMedia.result": "Visuelle Medien analysieren: <question>{{question}}</question>",
|
||||
"builtins.lobe-agent.apiName.callSubAgent": "Sub-Agent aufrufen",
|
||||
"builtins.lobe-agent.apiName.callSubAgent.completed": "Sub-Agent entsendet: ",
|
||||
"builtins.lobe-agent.apiName.callSubAgent.loading": "Sub-Agent wird entsendet: ",
|
||||
|
||||
@@ -187,6 +187,7 @@
|
||||
"agentTab.opening": "Startnachricht",
|
||||
"agentTab.plugin": "Fähigkeitseinstellungen",
|
||||
"agentTab.prompt": "Agentenprofil",
|
||||
"agentTab.selfIteration": "Selbstiteration",
|
||||
"agentTab.tts": "Sprachdienst",
|
||||
"analytics.telemetry.desc": "Hilf uns, {{appName}} mit anonymen Nutzungsdaten zu verbessern",
|
||||
"analytics.telemetry.title": "Anonyme Nutzungsdaten senden",
|
||||
@@ -554,9 +555,6 @@
|
||||
"settingChat.inputTemplate.desc": "Die neueste Nachricht des Benutzers wird in diese Vorlage eingefügt",
|
||||
"settingChat.inputTemplate.placeholder": "Vorverarbeitungsvorlage {{text}} wird durch Echtzeiteingabe ersetzt",
|
||||
"settingChat.inputTemplate.title": "Benutzereingabe-Vorverarbeitung",
|
||||
"settingChat.selfIteration.enabled.desc": "Allow this assistant to review recent signals and improve its own skills when the lab workflow runs",
|
||||
"settingChat.selfIteration.enabled.title": "Enable Self-Iteration",
|
||||
"settingChat.selfIteration.title": "Advanced Labs",
|
||||
"settingChat.submit": "Chat-Einstellungen aktualisieren",
|
||||
"settingChat.title": "Chat-Einstellungen",
|
||||
"settingChatAppearance.autoScrollOnStreaming.desc": "Automatisch nach unten scrollen, wenn die KI eine Antwort generiert",
|
||||
@@ -659,6 +657,17 @@
|
||||
"settingModel.maxTokens.title": "Maximale Tokens",
|
||||
"settingModel.model.desc": "{{provider}} Modell",
|
||||
"settingModel.model.title": "Modell",
|
||||
"settingModel.params.panel.advanced": "Erweiterte Einstellungen",
|
||||
"settingModel.params.panel.agentTitle": "Erweiterte Agenteneinstellungen",
|
||||
"settingModel.params.panel.contextCompression": "Kontext automatisch komprimieren",
|
||||
"settingModel.params.panel.creativity": "Kreativität",
|
||||
"settingModel.params.panel.historyLimit": "Nachrichtenverlauf begrenzen",
|
||||
"settingModel.params.panel.openness": "Offenheit",
|
||||
"settingModel.params.panel.responseLength": "Antwortlänge begrenzen",
|
||||
"settingModel.params.panel.tab": "Parameter",
|
||||
"settingModel.params.panel.title": "Chat-Parameter-Einstellungen",
|
||||
"settingModel.params.panel.topicDivergence": "Themenabweichung",
|
||||
"settingModel.params.panel.vocabularyRichness": "Wortschatzreichtum",
|
||||
"settingModel.params.title": "Erweiterte Parameter",
|
||||
"settingModel.presencePenalty.desc": "Je höher der Wert, desto mehr unterschiedliche Ausdrücke; je niedriger, desto mehr Wiederholungen.",
|
||||
"settingModel.presencePenalty.title": "Ausdrucksvielfalt",
|
||||
@@ -684,6 +693,10 @@
|
||||
"settingOpening.openingQuestions.title": "Einstiegsfragen",
|
||||
"settingOpening.title": "Begrüßungseinstellungen",
|
||||
"settingPlugin.title": "Fähigkeitenliste",
|
||||
"settingSelfIteration.enabled.desc": "Erlauben Sie diesem Assistenten, kürzlich empfangene Signale zu überprüfen und seine eigenen Fähigkeiten zu verbessern, wenn der Selbstiterations-Workflow ausgeführt wird.",
|
||||
"settingSelfIteration.enabled.managedDesc": "Immer aktiviert für Lobe AI, solange Selbstiteration verfügbar ist.",
|
||||
"settingSelfIteration.enabled.title": "Selbstiteration aktivieren",
|
||||
"settingSelfIteration.title": "Selbstiteration",
|
||||
"settingSystem.oauth.info.desc": "Angemeldet",
|
||||
"settingSystem.oauth.info.title": "Kontoinformationen",
|
||||
"settingSystem.oauth.signin.action": "Anmelden",
|
||||
@@ -897,7 +910,12 @@
|
||||
"tab.uploadZip": "Zip hochladen",
|
||||
"tab.uploadZip.desc": "Laden Sie eine lokale .zip- oder .skill-Datei hoch",
|
||||
"tab.usage": "Nutzungsstatistik",
|
||||
"tools.activation.auto": "Automatisch",
|
||||
"tools.activation.auto.desc": "Intelligent",
|
||||
"tools.activation.pinned": "Angeheftet",
|
||||
"tools.activation.pinned.desc": "Immer an",
|
||||
"tools.add": "Fähigkeit hinzufügen",
|
||||
"tools.builtins.configure": "Konfigurieren",
|
||||
"tools.builtins.find-skills.description": "Hilft Nutzern, Agenten‑Fähigkeiten zu entdecken und zu installieren, wenn sie fragen „Wie mache ich X?“, „Finde eine Fähigkeit für X“ oder Funktionen erweitern möchten",
|
||||
"tools.builtins.find-skills.title": "Fähigkeiten finden",
|
||||
"tools.builtins.groupName": "Integriert",
|
||||
@@ -932,9 +950,6 @@
|
||||
"tools.builtins.lobe-group-agent-builder.title": "Gruppen‑Agent‑Builder",
|
||||
"tools.builtins.lobe-group-management.description": "Unterhaltungen von Multi‑Agenten‑Gruppen orchestrieren und verwalten",
|
||||
"tools.builtins.lobe-group-management.title": "Gruppenverwaltung",
|
||||
"tools.builtins.lobe-gtd.description": "Ziele planen und Fortschritte mit der GTD-Methode verfolgen. Strategische Pläne erstellen, Aufgabenlisten mit Statusverfolgung verwalten und lang laufende asynchrone Aufgaben ausführen.",
|
||||
"tools.builtins.lobe-gtd.readme": "Planen Sie Ziele und verfolgen Sie Fortschritte mit der GTD-Methodik. Erstellen Sie strategische Pläne, verwalten Sie Aufgabenlisten mit Statusverfolgung und führen Sie lang laufende asynchrone Aufgaben aus.",
|
||||
"tools.builtins.lobe-gtd.title": "GTD-Werkzeuge",
|
||||
"tools.builtins.lobe-knowledge-base.description": "Hochgeladene Dokumente und Domainwissen per semantischer Vektorsuche durchsuchen – für persistente, wiederverwendbare Referenzen",
|
||||
"tools.builtins.lobe-knowledge-base.title": "Wissensdatenbank",
|
||||
"tools.builtins.lobe-local-system.description": "Zugriff auf Ihr lokales Dateisystem auf dem Desktop. Dateien lesen, schreiben, durchsuchen und organisieren. Shell-Befehle mit Unterstützung für Hintergrundaufgaben ausführen und Inhalte mit Regex-Mustern durchsuchen.",
|
||||
|
||||
@@ -16,11 +16,15 @@
|
||||
"table.columns.trigger.enums.api": "API-Aufruf",
|
||||
"table.columns.trigger.enums.bot": "Bot-Nachricht",
|
||||
"table.columns.trigger.enums.chat": "Chat-Nachricht",
|
||||
"table.columns.trigger.enums.cli": "CLI",
|
||||
"table.columns.trigger.enums.cron": "Geplanter Task",
|
||||
"table.columns.trigger.enums.eval": "Benchmark-Auswertung",
|
||||
"table.columns.trigger.enums.file_embedding": "Datei-Einbettung",
|
||||
"table.columns.trigger.enums.image": "Bildgenerierung",
|
||||
"table.columns.trigger.enums.memory": "Speicherextraktion",
|
||||
"table.columns.trigger.enums.notify": "Benachrichtigung",
|
||||
"table.columns.trigger.enums.onboarding": "Einführung",
|
||||
"table.columns.trigger.enums.openapi": "OpenAPI",
|
||||
"table.columns.trigger.enums.semantic_search": "Wissenssuche",
|
||||
"table.columns.trigger.enums.topic": "Themenzusammenfassung",
|
||||
"table.columns.trigger.enums.video": "Videogenerierung",
|
||||
|
||||
@@ -21,6 +21,9 @@
|
||||
"channel.botTokenPlaceholderNew": "Paste your bot token here",
|
||||
"channel.charLimit": "Character Limit",
|
||||
"channel.charLimitHint": "Maximum number of characters per message",
|
||||
"channel.comingSoon": "Coming Soon",
|
||||
"channel.comingSoonDesc": "We are working on bringing this integration to LobeHub. Stay tuned for updates.",
|
||||
"channel.comingSoonTitle": "{{name}} integration is coming soon",
|
||||
"channel.concurrency": "Concurrency Mode",
|
||||
"channel.concurrencyDebounce": "Debounce",
|
||||
"channel.concurrencyDebounceHint": "Only process the last message in a burst (earlier ones are dropped)",
|
||||
@@ -183,6 +186,14 @@
|
||||
"channel.verificationToken": "Verification Token",
|
||||
"channel.verificationTokenHint": "Optional. Used to verify webhook event source.",
|
||||
"channel.verificationTokenPlaceholder": "Paste your verification token here",
|
||||
"channel.watchKeywordInstructionLabel": "Instruction",
|
||||
"channel.watchKeywordInstructionPlaceholder": "e.g. Scan the recent thread and reply if there is an actionable bug report",
|
||||
"channel.watchKeywordLabel": "Keyword",
|
||||
"channel.watchKeywordPlaceholder": "e.g. bug",
|
||||
"channel.watchKeywords": "Watch Keywords",
|
||||
"channel.watchKeywordsAdd": "Add keyword",
|
||||
"channel.watchKeywordsEmpty": "No keywords added yet — bot only wakes on @mention or DM in subscribed channels.",
|
||||
"channel.watchKeywordsHint": "A keyword match wakes the bot without an @mention; its instruction is prepended to the user message. Whole-word, case-insensitive.",
|
||||
"channel.wechat.description": "Connect this assistant to WeChat via iLink Bot for private and group chats.",
|
||||
"channel.wechatBotId": "Bot ID",
|
||||
"channel.wechatBotIdHint": "Bot identifier assigned after QR code authorization.",
|
||||
|
||||
+53
-1
@@ -24,6 +24,7 @@
|
||||
"agentProfile.knowledgeBases_other": "{{count}} knowledge bases",
|
||||
"agentProfile.skills_one": "{{count}} skill",
|
||||
"agentProfile.skills_other": "{{count}} skills",
|
||||
"agentSignal.receipts.agentSignalLabel": "Agent Signal",
|
||||
"agentSignal.receipts.memory.detail": "Saved this for future replies",
|
||||
"agentSignal.receipts.memory.title": "Memory saved",
|
||||
"agentSignal.receipts.recentActivity": "Recent activity",
|
||||
@@ -41,6 +42,16 @@
|
||||
"builtinCopilot": "Built-in Copilot",
|
||||
"chatList.expandMessage": "Expand Message",
|
||||
"chatList.longMessageDetail": "View Details",
|
||||
"chatMode.agent": "Agent",
|
||||
"chatMode.agentCap.env": "Runtime env",
|
||||
"chatMode.agentCap.files": "File access",
|
||||
"chatMode.agentCap.memory": "Memory",
|
||||
"chatMode.agentCap.tools": "Tool calls",
|
||||
"chatMode.agentCap.web": "Web search",
|
||||
"chatMode.agentDesc": "Agent can use tools and environment to complete tasks automatically",
|
||||
"chatMode.chat": "Chat",
|
||||
"chatMode.chatDesc": "No runtime environment or autonomy; uses fewer tokens",
|
||||
"chatMode.select": "Switch Mode",
|
||||
"claudeCodeInstallGuide.actions.openDocs": "Open Install Guide",
|
||||
"claudeCodeInstallGuide.actions.openSystemTools": "Open System Tools",
|
||||
"claudeCodeInstallGuide.afterInstall": "After installing, run Claude Code once to sign in, then retry your message or click Re-detect in System Tools.",
|
||||
@@ -59,6 +70,7 @@
|
||||
"cliAuthGuide.runCommand": "Run this in Terminal",
|
||||
"cliAuthGuide.title": "Sign in to {{name}}",
|
||||
"cliRateLimitGuide.actions.openSystemTools": "Open System Tools",
|
||||
"cliRateLimitGuide.actions.retry": "Retry",
|
||||
"cliRateLimitGuide.afterReset": "Wait until the reset time, then retry your message. If you are using API authorization, you can also check your provider quota and billing status.",
|
||||
"cliRateLimitGuide.desc": "{{name}} has reached its current usage limit and cannot continue this run right now.",
|
||||
"cliRateLimitGuide.limitType": "Limit window",
|
||||
@@ -141,7 +153,7 @@
|
||||
"extendParams.title": "Model Extension Features",
|
||||
"extendParams.urlContext.desc": "When enabled, web links will be automatically parsed to retrieve the actual webpage context content",
|
||||
"extendParams.urlContext.title": "Extract Webpage Link Content",
|
||||
"followUpPlaceholder": "Follow up. @ to assign tasks to other agents.",
|
||||
"followUpPlaceholder": "Follow up.",
|
||||
"followUpPlaceholderHeterogeneous": "Follow up.",
|
||||
"group.desc": "Move a task forward with multiple Agents in one shared space.",
|
||||
"group.memberTooltip": "There are {{count}} members in the group",
|
||||
@@ -184,6 +196,9 @@
|
||||
"groupWizard.searchTemplates": "Search templates...",
|
||||
"groupWizard.title": "Create Group",
|
||||
"groupWizard.useTemplate": "Use Template",
|
||||
"heteroAgent.cloudNotConfigured.action": "Configure",
|
||||
"heteroAgent.cloudNotConfigured.desc": "Configure your Claude Code token in agent profile to start sending messages.",
|
||||
"heteroAgent.cloudNotConfigured.title": "Cloud credentials required",
|
||||
"heteroAgent.cloudRepo.multiSelected": "{{count}} repos selected",
|
||||
"heteroAgent.cloudRepo.noRepos": "No repositories configured. Add them in agent settings.",
|
||||
"heteroAgent.cloudRepo.notSet": "No repo selected",
|
||||
@@ -223,6 +238,8 @@
|
||||
"knowledgeBase.allFiles": "All Files",
|
||||
"knowledgeBase.allLibraries": "All Libraries",
|
||||
"knowledgeBase.disabled": "Library chat isn’t available in this deployment. Switch to a server-side database, or use {{cloud}}.",
|
||||
"knowledgeBase.files": "Files",
|
||||
"knowledgeBase.libraries": "Libraries",
|
||||
"knowledgeBase.library.action.add": "Add",
|
||||
"knowledgeBase.library.action.detail": "Details",
|
||||
"knowledgeBase.library.action.remove": "Remove",
|
||||
@@ -326,6 +343,15 @@
|
||||
"pageSelection.reference": "Selected Text",
|
||||
"pin": "Pin",
|
||||
"pinOff": "Unpin",
|
||||
"plus.addSkills": "Add Skills...",
|
||||
"plus.search.appSearch": "Smart Search",
|
||||
"plus.search.appSearchDesc": "LobeHub optimized search service, delivering best retrieval results.",
|
||||
"plus.search.modelSearch": "Provider Search",
|
||||
"plus.search.modelSearchDesc": "May cause unexpected behavior when enabled, not recommended.",
|
||||
"plus.search.off": "Off",
|
||||
"plus.search.offDesc": "",
|
||||
"plus.title": "Add",
|
||||
"plus.tooltip": "Add files, skills, and more context...",
|
||||
"rag.referenceChunks": "Reference Source",
|
||||
"rag.userQuery.actions.delete": "Delete Query Rewrite",
|
||||
"rag.userQuery.actions.regenerate": "Regenerate Query",
|
||||
@@ -357,6 +383,8 @@
|
||||
"searchAgents": "Search agents...",
|
||||
"selectedAgents": "Selected agents",
|
||||
"sendPlaceholder": "Ask, create, or start a task, <hotkey><hotkey/>",
|
||||
"sendPlaceholderChat": "Ask, search, or brainstorm, <hotkey><hotkey/>",
|
||||
"sendPlaceholderChatWithAgentAssignment": "Ask, search, or brainstorm. @ to bring in other agents.",
|
||||
"sendPlaceholderHeterogeneous": "Describe a task or ask a question to {{name}}",
|
||||
"sendPlaceholderWithAgentAssignment": "Ask, create, or start a task. @ to assign tasks to other agents.",
|
||||
"sessionGroup.config": "Category Management",
|
||||
@@ -734,6 +762,7 @@
|
||||
"untitledAgent": "Untitled Agent",
|
||||
"untitledGroup": "Untitled Group",
|
||||
"updateAgent": "Update Agent Information",
|
||||
"upload.action.fileOrImageUpload": "Upload File or Image",
|
||||
"upload.action.fileUpload": "Upload File",
|
||||
"upload.action.folderUpload": "Upload Folder",
|
||||
"upload.action.imageDisabled": "The current model does not support visual recognition. Please switch models to use this feature.",
|
||||
@@ -846,6 +875,25 @@
|
||||
"workingPanel.documents.saved": "All changes saved",
|
||||
"workingPanel.documents.title": "Document",
|
||||
"workingPanel.documents.unsaved": "Unsaved changes",
|
||||
"workingPanel.files.copyAbsolutePath": "Copy Path",
|
||||
"workingPanel.files.copyRelativePath": "Copy Relative Path",
|
||||
"workingPanel.files.count_one": "{{count}} file",
|
||||
"workingPanel.files.count_other": "{{count}} files",
|
||||
"workingPanel.files.empty": "No files in this workspace",
|
||||
"workingPanel.files.open": "Open File",
|
||||
"workingPanel.files.refresh": "Refresh",
|
||||
"workingPanel.files.showInReview": "Show in Review",
|
||||
"workingPanel.files.showInSystem": "Reveal in Folder",
|
||||
"workingPanel.files.title": "Files",
|
||||
"workingPanel.localFile.binary": "Binary file — preview unavailable",
|
||||
"workingPanel.localFile.close": "Close",
|
||||
"workingPanel.localFile.closeLeft": "Close to the Left",
|
||||
"workingPanel.localFile.closeOther": "Close Others",
|
||||
"workingPanel.localFile.closeRight": "Close to the Right",
|
||||
"workingPanel.localFile.error": "Couldn't load this file",
|
||||
"workingPanel.localFile.preview.raw": "Raw",
|
||||
"workingPanel.localFile.preview.render": "Preview",
|
||||
"workingPanel.localFile.truncated": "File preview truncated to {{limit}} characters",
|
||||
"workingPanel.progress": "Progress",
|
||||
"workingPanel.progress.allCompleted": "All tasks completed",
|
||||
"workingPanel.resources": "Resources",
|
||||
@@ -892,6 +940,8 @@
|
||||
"workingPanel.review.mode.unstaged": "Unstaged",
|
||||
"workingPanel.review.more": "More options",
|
||||
"workingPanel.review.refresh": "Refresh",
|
||||
"workingPanel.review.revealInTree": "Reveal in tree",
|
||||
"workingPanel.review.revealNotFound": "File not found in project index",
|
||||
"workingPanel.review.revert": "Discard changes",
|
||||
"workingPanel.review.revert.confirm.cancel": "Cancel",
|
||||
"workingPanel.review.revert.confirm.description": "Working tree changes to {{filePath}} will be permanently discarded. Untracked files are deleted from disk.",
|
||||
@@ -908,6 +958,8 @@
|
||||
"workingPanel.review.viewMode.unified": "Switch to unified view",
|
||||
"workingPanel.review.wordWrap.disable": "Disable word wrap",
|
||||
"workingPanel.review.wordWrap.enable": "Enable word wrap",
|
||||
"workingPanel.skills.empty": "No skills found in this project",
|
||||
"workingPanel.skills.title": "Skills",
|
||||
"workingPanel.space": "Space",
|
||||
"workingPanel.title": "Working Panel",
|
||||
"you": "You",
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
{
|
||||
"actionTag.category.command": "Command",
|
||||
"actionTag.category.projectSkill": "Project skill",
|
||||
"actionTag.category.skill": "Skill",
|
||||
"actionTag.category.tool": "Tool",
|
||||
"actionTag.tooltip.command": "Runs a client-side slash command before sending.",
|
||||
"actionTag.tooltip.projectSkill": "Sent as a slash invocation so the agent's CLI runs the matching project skill.",
|
||||
"actionTag.tooltip.skill": "Loads a reusable skill package for this request.",
|
||||
"actionTag.tooltip.tool": "Marks a tool the user explicitly selected for this request.",
|
||||
"actions.expand.off": "Collapse",
|
||||
|
||||
@@ -11,6 +11,19 @@
|
||||
"brief.action.ignore": "Ignore",
|
||||
"brief.action.retry": "Retry",
|
||||
"brief.addFeedback": "Share feedback",
|
||||
"brief.agentSignal.selfReview.applied.heading": "Updated",
|
||||
"brief.agentSignal.selfReview.applied.summary": "{{count}} dream update was applied.",
|
||||
"brief.agentSignal.selfReview.applied.summary_plural": "{{count}} dream updates were applied.",
|
||||
"brief.agentSignal.selfReview.applied.title": "Dream updated resources",
|
||||
"brief.agentSignal.selfReview.error.heading": "Issue",
|
||||
"brief.agentSignal.selfReview.error.summary": "Some work could not be completed during this dream.",
|
||||
"brief.agentSignal.selfReview.error.title": "Dream ran into an issue",
|
||||
"brief.agentSignal.selfReview.ideas.summary": "Saved dream notes for future review.",
|
||||
"brief.agentSignal.selfReview.ideas.title": "Dream notes",
|
||||
"brief.agentSignal.selfReview.proposal.heading": "Suggestion",
|
||||
"brief.agentSignal.selfReview.proposal.summary": "{{count}} dream suggestion needs your review.",
|
||||
"brief.agentSignal.selfReview.proposal.summary_plural": "{{count}} dream suggestions need your review.",
|
||||
"brief.agentSignal.selfReview.proposal.title": "Dream suggestion needs review",
|
||||
"brief.collapse": "Show less",
|
||||
"brief.commentPlaceholder": "Share your feedback...",
|
||||
"brief.commentSubmit": "Submit feedback",
|
||||
|
||||
@@ -20,6 +20,22 @@
|
||||
"messenger.discord.connections.disconnectFailed": "Failed to remove server.",
|
||||
"messenger.discord.connections.disconnectSuccess": "Server removed.",
|
||||
"messenger.discord.connections.disconnectTitle": "Remove server",
|
||||
"messenger.discord.installBlocked.dismiss": "Got it",
|
||||
"messenger.discord.installBlocked.suggestion": "DM the LobeHub bot in Discord to link your personal account — you don't need to add the bot again. Or ask the original installer to remove this server in LobeHub Settings → Messenger before re-adding it.",
|
||||
"messenger.discord.installBlocked.title": "Server already connected",
|
||||
"messenger.discord.installBlocked.withName": "\"{{workspace}}\" is already connected to LobeHub by another user.",
|
||||
"messenger.discord.installBlocked.withoutName": "This Discord server is already connected to LobeHub by another user.",
|
||||
"messenger.discord.installResult.failed": "Discord install failed ({{reason}}). Please try again or contact support.",
|
||||
"messenger.discord.installResult.reasons.accessDenied": "authorization was cancelled",
|
||||
"messenger.discord.installResult.reasons.exchangeFailed": "Discord authorization failed",
|
||||
"messenger.discord.installResult.reasons.generic": "an unknown error occurred",
|
||||
"messenger.discord.installResult.reasons.invalidState": "the install session expired",
|
||||
"messenger.discord.installResult.reasons.missingAppId": "Discord returned incomplete app information",
|
||||
"messenger.discord.installResult.reasons.missingCodeOrState": "Discord returned incomplete install parameters",
|
||||
"messenger.discord.installResult.reasons.missingTenant": "Discord did not return a server identifier",
|
||||
"messenger.discord.installResult.reasons.missingToken": "Discord did not return an access token",
|
||||
"messenger.discord.installResult.reasons.persistFailed": "the server connection could not be saved",
|
||||
"messenger.discord.installResult.success": "Discord server connected.",
|
||||
"messenger.discord.userPending.cta": "Open in Discord",
|
||||
"messenger.discord.userPending.hint": "Open the bot in Discord and send any message to finish linking your account.",
|
||||
"messenger.discord.userPending.name": "Not linked yet",
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"dropdownLabel": "Open working directory in",
|
||||
"errors.appNotInstalled": "{{appName}} is not installed",
|
||||
"errors.launchFailed": "Failed to open in {{appName}}: {{error}}",
|
||||
"errors.pathNotFound": "Path not found: {{path}}",
|
||||
"errors.unknown": "unknown error",
|
||||
"tooltip": "Open in {{appName}}"
|
||||
}
|
||||
@@ -69,6 +69,9 @@
|
||||
"builtins.lobe-agent-management.render.installPlugin.plugin": "Plugin",
|
||||
"builtins.lobe-agent-management.render.installPlugin.success": "Installed successfully",
|
||||
"builtins.lobe-agent-management.title": "Agent Manager",
|
||||
"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: ",
|
||||
@@ -87,6 +90,14 @@
|
||||
"builtins.lobe-agent.title": "Lobe Agent",
|
||||
"builtins.lobe-claude-code.agent.instruction": "Instruction",
|
||||
"builtins.lobe-claude-code.agent.result": "Result",
|
||||
"builtins.lobe-claude-code.task.createLabel": "Creating task: ",
|
||||
"builtins.lobe-claude-code.task.getLabel": "Inspecting task #{{taskId}}",
|
||||
"builtins.lobe-claude-code.task.listLabel": "Listing tasks",
|
||||
"builtins.lobe-claude-code.task.updateCompleted": "Completed",
|
||||
"builtins.lobe-claude-code.task.updateDeleted": "Deleted",
|
||||
"builtins.lobe-claude-code.task.updateInProgress": "Started",
|
||||
"builtins.lobe-claude-code.task.updateLabel": "Updating task #{{taskId}}",
|
||||
"builtins.lobe-claude-code.task.updatePending": "Reset",
|
||||
"builtins.lobe-claude-code.todoWrite.allDone": "All tasks completed",
|
||||
"builtins.lobe-claude-code.todoWrite.currentStep": "Current step",
|
||||
"builtins.lobe-claude-code.todoWrite.todos": "Todos",
|
||||
@@ -234,6 +245,14 @@
|
||||
"builtins.lobe-page-agent.apiName.updateNode": "Update node",
|
||||
"builtins.lobe-page-agent.apiName.wrapNodes": "Wrap nodes",
|
||||
"builtins.lobe-page-agent.title": "Page",
|
||||
"builtins.lobe-self-feedback-intent.apiName.declareSelfFeedbackIntent": "Record improvement idea",
|
||||
"builtins.lobe-self-feedback-intent.inspector.gap.proposal": "Suggest improvement",
|
||||
"builtins.lobe-self-feedback-intent.inspector.memory.write": "Record preference",
|
||||
"builtins.lobe-self-feedback-intent.inspector.rejected": "Not recorded",
|
||||
"builtins.lobe-self-feedback-intent.inspector.skill.consolidate": "Organize methods",
|
||||
"builtins.lobe-self-feedback-intent.inspector.skill.create": "New method found",
|
||||
"builtins.lobe-self-feedback-intent.inspector.skill.refine": "Improve method",
|
||||
"builtins.lobe-self-feedback-intent.title": "Improvement Ideas",
|
||||
"builtins.lobe-skill-store.apiName.importFromMarket": "Import from Market",
|
||||
"builtins.lobe-skill-store.apiName.importSkill": "Import Skill",
|
||||
"builtins.lobe-skill-store.apiName.searchSkill": "Search Skills",
|
||||
|
||||
@@ -181,12 +181,11 @@
|
||||
"agentSkillModal.url.urlPlaceholder": "https://example.com/path/to/SKILL.md",
|
||||
"agentSkillTag": "Agent Skill",
|
||||
"agentTab.chat": "Chat Preferences",
|
||||
"agentTab.documents": "Documents",
|
||||
"agentTab.meta": "Agent info",
|
||||
"agentTab.modal": "Model Settings",
|
||||
"agentTab.opening": "Opening Settings",
|
||||
"agentTab.plugin": "Skill Settings",
|
||||
"agentTab.prompt": "Agent Profile",
|
||||
"agentTab.selfIteration": "Self-Iteration",
|
||||
"agentTab.tts": "Voice Service",
|
||||
"analytics.telemetry.desc": "Help us improve {{appName}} with anonymous usage data",
|
||||
"analytics.telemetry.title": "Send Anonymous Usage Data",
|
||||
@@ -554,9 +553,6 @@
|
||||
"settingChat.inputTemplate.desc": "The user's latest message will be filled into this template",
|
||||
"settingChat.inputTemplate.placeholder": "Preprocessing template {{text}} will be replaced with real-time input information",
|
||||
"settingChat.inputTemplate.title": "User Input Preprocessing",
|
||||
"settingChat.selfIteration.enabled.desc": "Allow this assistant to review recent signals and improve its own skills when the lab workflow runs",
|
||||
"settingChat.selfIteration.enabled.title": "Enable Self-Iteration",
|
||||
"settingChat.selfIteration.title": "Advanced Labs",
|
||||
"settingChat.submit": "Update Chat Preferences",
|
||||
"settingChat.title": "Chat Settings",
|
||||
"settingChatAppearance.autoScrollOnStreaming.desc": "Automatically scroll to bottom when AI is generating response",
|
||||
@@ -659,6 +655,17 @@
|
||||
"settingModel.maxTokens.title": "Max Tokens Limit",
|
||||
"settingModel.model.desc": "{{provider}} model",
|
||||
"settingModel.model.title": "Model",
|
||||
"settingModel.params.panel.advanced": "Advanced Settings",
|
||||
"settingModel.params.panel.agentTitle": "Agent Advanced Settings",
|
||||
"settingModel.params.panel.contextCompression": "Auto-compress Context",
|
||||
"settingModel.params.panel.creativity": "Creativity",
|
||||
"settingModel.params.panel.historyLimit": "Limit History Messages",
|
||||
"settingModel.params.panel.openness": "Openness",
|
||||
"settingModel.params.panel.responseLength": "Limit Response Length",
|
||||
"settingModel.params.panel.tab": "Params",
|
||||
"settingModel.params.panel.title": "Chat Parameter Settings",
|
||||
"settingModel.params.panel.topicDivergence": "Topic Divergence",
|
||||
"settingModel.params.panel.vocabularyRichness": "Vocabulary Richness",
|
||||
"settingModel.params.title": "Advanced Parameters",
|
||||
"settingModel.presencePenalty.desc": "The higher the value, the more inclined to use different expressions and avoid concept repetition; the lower the value, the more inclined to use repeated concepts or narratives, resulting in more consistent expression.",
|
||||
"settingModel.presencePenalty.title": "Expression Divergence",
|
||||
@@ -684,6 +691,10 @@
|
||||
"settingOpening.openingQuestions.title": "Opening Questions",
|
||||
"settingOpening.title": "Opening Settings",
|
||||
"settingPlugin.title": "Skill List",
|
||||
"settingSelfIteration.enabled.desc": "Allow this assistant to review recent signals and improve its own skills when the self-iteration workflow runs.",
|
||||
"settingSelfIteration.enabled.managedDesc": "Always on for Lobe AI while Self-Iteration is available.",
|
||||
"settingSelfIteration.enabled.title": "Enable Self-Iteration",
|
||||
"settingSelfIteration.title": "Self-Iteration",
|
||||
"settingSystem.oauth.info.desc": "Logged in",
|
||||
"settingSystem.oauth.info.title": "Account Information",
|
||||
"settingSystem.oauth.signin.action": "Sign In",
|
||||
@@ -897,7 +908,12 @@
|
||||
"tab.uploadZip": "Upload Zip",
|
||||
"tab.uploadZip.desc": "Upload a local .zip or .skill file",
|
||||
"tab.usage": "Usage",
|
||||
"tools.activation.auto": "Auto",
|
||||
"tools.activation.auto.desc": "Smart",
|
||||
"tools.activation.pinned": "Pinned",
|
||||
"tools.activation.pinned.desc": "Always On",
|
||||
"tools.add": "Add Skill",
|
||||
"tools.builtins.configure": "Configure",
|
||||
"tools.builtins.find-skills.description": "Helps users discover and install agent skills when they ask \"how do I do X\", \"find a skill for X\", or want to extend capabilities",
|
||||
"tools.builtins.find-skills.title": "Find Skills",
|
||||
"tools.builtins.groupName": "Built-ins",
|
||||
|
||||
@@ -16,11 +16,15 @@
|
||||
"table.columns.trigger.enums.api": "API Call",
|
||||
"table.columns.trigger.enums.bot": "Bot Message",
|
||||
"table.columns.trigger.enums.chat": "Chat Message",
|
||||
"table.columns.trigger.enums.cli": "CLI",
|
||||
"table.columns.trigger.enums.cron": "Scheduled Task",
|
||||
"table.columns.trigger.enums.eval": "Benchmark Eval",
|
||||
"table.columns.trigger.enums.file_embedding": "File Embedding",
|
||||
"table.columns.trigger.enums.image": "Image Generation",
|
||||
"table.columns.trigger.enums.memory": "Memory Extraction",
|
||||
"table.columns.trigger.enums.notify": "Notification",
|
||||
"table.columns.trigger.enums.onboarding": "Onboarding",
|
||||
"table.columns.trigger.enums.openapi": "OpenAPI",
|
||||
"table.columns.trigger.enums.semantic_search": "Knowledge Search",
|
||||
"table.columns.trigger.enums.topic": "Topic Summary",
|
||||
"table.columns.trigger.enums.video": "Video Generation",
|
||||
|
||||
@@ -41,7 +41,9 @@
|
||||
"credits.autoTopUp.monthlyLimitDesc": "Maximum amount that can be auto-charged per month. Leave empty for no limit",
|
||||
"credits.autoTopUp.monthlyLimitPlaceholder": "No limit",
|
||||
"credits.autoTopUp.monthlyTopUpAmount": "Monthly Top-Up Amount",
|
||||
"credits.autoTopUp.noCustomerHint": "Purchase credits once to save a payment method before enabling auto top-up.",
|
||||
"credits.autoTopUp.noPaymentMethodHint": "No payment method on file. Auto top-up needs a saved card to charge automatically.",
|
||||
"credits.autoTopUp.purchaseCredits": "Purchase Credits",
|
||||
"credits.autoTopUp.saveError": "Failed to save auto top-up settings",
|
||||
"credits.autoTopUp.saveSuccess": "Auto top-up settings saved",
|
||||
"credits.autoTopUp.setupPaymentMethod": "Add Payment Method",
|
||||
@@ -83,6 +85,7 @@
|
||||
"credits.packages.title": "My Credit Packages",
|
||||
"credits.topUp.cancel": "Cancel",
|
||||
"credits.topUp.custom": "Custom",
|
||||
"credits.topUp.freeFeeHint": "Free plan top-ups include a {{fee}} service fee per 1M credits.",
|
||||
"credits.topUp.maxAmountError": "Single purchase amount cannot exceed ${{max}}",
|
||||
"credits.topUp.purchaseError": "Purchase failed, please try again later",
|
||||
"credits.topUp.purchaseNow": "Purchase Now",
|
||||
|
||||
@@ -40,6 +40,14 @@
|
||||
"agentMarketplace.render.alreadyInLibraryTag": "Already in library",
|
||||
"agentMarketplace.render.alreadyInLibrary_one": "{{count}} already in library",
|
||||
"agentMarketplace.render.alreadyInLibrary_other": "{{count}} already in library",
|
||||
"claudeCode.askUserQuestion.escape.back": "Back to options",
|
||||
"claudeCode.askUserQuestion.escape.enter": "Or type directly",
|
||||
"claudeCode.askUserQuestion.escape.placeholder": "Type your answer here…",
|
||||
"claudeCode.askUserQuestion.multiSelectTag": "(multi-select)",
|
||||
"claudeCode.askUserQuestion.skip": "Skip",
|
||||
"claudeCode.askUserQuestion.submit": "Submit",
|
||||
"claudeCode.askUserQuestion.timeExpired": "Time expired — using option 1 of each question.",
|
||||
"claudeCode.askUserQuestion.timeRemaining": "Time remaining: {{time}} · unanswered questions default to option 1 on timeout.",
|
||||
"codeInterpreter-legacy.error": "Execution Error",
|
||||
"codeInterpreter-legacy.executing": "Executing...",
|
||||
"codeInterpreter-legacy.files": "Files:",
|
||||
|
||||
@@ -21,6 +21,9 @@
|
||||
"channel.botTokenPlaceholderNew": "Pega tu token del bot aquí",
|
||||
"channel.charLimit": "Límite de caracteres",
|
||||
"channel.charLimitHint": "Número máximo de caracteres por mensaje",
|
||||
"channel.comingSoon": "Próximamente",
|
||||
"channel.comingSoonDesc": "Estamos trabajando para traer esta integración a LobeHub. Mantente atento para más actualizaciones.",
|
||||
"channel.comingSoonTitle": "La integración de {{name}} estará disponible próximamente",
|
||||
"channel.concurrency": "Modo de Concurrencia",
|
||||
"channel.concurrencyDebounce": "Antirrebote",
|
||||
"channel.concurrencyDebounceHint": "Procesar solo el último mensaje de una ráfaga (los anteriores se descartan)",
|
||||
@@ -183,6 +186,14 @@
|
||||
"channel.verificationToken": "Token de Verificación",
|
||||
"channel.verificationTokenHint": "Opcional. Usado para verificar la fuente de eventos del webhook.",
|
||||
"channel.verificationTokenPlaceholder": "Pega tu token de verificación aquí",
|
||||
"channel.watchKeywordInstructionLabel": "Instrucción",
|
||||
"channel.watchKeywordInstructionPlaceholder": "p. ej., Escanea el hilo reciente y responde si hay un informe de error accionable",
|
||||
"channel.watchKeywordLabel": "Palabra clave",
|
||||
"channel.watchKeywordPlaceholder": "p. ej., error",
|
||||
"channel.watchKeywords": "Palabras clave de vigilancia",
|
||||
"channel.watchKeywordsAdd": "Agregar palabra clave",
|
||||
"channel.watchKeywordsEmpty": "No se han agregado palabras clave aún — el bot solo se activa con una @mención o un mensaje directo en los canales suscritos.",
|
||||
"channel.watchKeywordsHint": "Cuando un mensaje en un canal suscrito coincide con una palabra clave, el bot se activa sin una @mención y la instrucción se antepone al mensaje del usuario antes de enviarlo a la IA. Coincidencia insensible a mayúsculas, de palabra completa.",
|
||||
"channel.wechat.description": "Conecta este asistente a WeChat a través de iLink Bot para chats privados y grupales.",
|
||||
"channel.wechatBotId": "ID del Bot",
|
||||
"channel.wechatBotIdHint": "Identificador del bot asignado tras la autorización mediante código QR.",
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"agentProfile.knowledgeBases_other": "{{count}} bases de conocimiento",
|
||||
"agentProfile.skills_one": "{{count}} habilidad",
|
||||
"agentProfile.skills_other": "{{count}} habilidades",
|
||||
"agentSignal.receipts.agentSignalLabel": "Señal del Agente",
|
||||
"agentSignal.receipts.memory.detail": "Guardado para respuestas futuras",
|
||||
"agentSignal.receipts.memory.title": "Memoria guardada",
|
||||
"agentSignal.receipts.recentActivity": "Actividad reciente",
|
||||
@@ -41,6 +42,16 @@
|
||||
"builtinCopilot": "Copiloto integrado",
|
||||
"chatList.expandMessage": "Expandir mensaje",
|
||||
"chatList.longMessageDetail": "Ver detalles",
|
||||
"chatMode.agent": "Agente",
|
||||
"chatMode.agentCap.env": "Entorno de ejecución",
|
||||
"chatMode.agentCap.files": "Acceso a archivos",
|
||||
"chatMode.agentCap.memory": "Memoria",
|
||||
"chatMode.agentCap.tools": "Llamadas de herramientas",
|
||||
"chatMode.agentCap.web": "Búsqueda web",
|
||||
"chatMode.agentDesc": "El agente puede usar herramientas y el entorno para completar tareas automáticamente",
|
||||
"chatMode.chat": "Chat",
|
||||
"chatMode.chatDesc": "Sin entorno de ejecución ni autonomía; usa menos tokens",
|
||||
"chatMode.select": "Cambiar modo",
|
||||
"claudeCodeInstallGuide.actions.openDocs": "Abrir guía de instalación",
|
||||
"claudeCodeInstallGuide.actions.openSystemTools": "Abrir herramientas del sistema",
|
||||
"claudeCodeInstallGuide.afterInstall": "Después de instalar, ejecuta Claude Code una vez para iniciar sesión; luego vuelve a intentar tu mensaje o haz clic en Detectar de nuevo en Herramientas del sistema.",
|
||||
@@ -59,6 +70,7 @@
|
||||
"cliAuthGuide.runCommand": "Ejecuta esto en la Terminal",
|
||||
"cliAuthGuide.title": "Iniciar sesión en {{name}}",
|
||||
"cliRateLimitGuide.actions.openSystemTools": "Abrir herramientas del sistema",
|
||||
"cliRateLimitGuide.actions.retry": "Reintentar",
|
||||
"cliRateLimitGuide.afterReset": "Espera hasta la hora de restablecimiento y vuelve a intentar tu mensaje. Si usas autorización por API, también puedes revisar tu cuota y estado de facturación.",
|
||||
"cliRateLimitGuide.desc": "{{name}} ha alcanzado su límite de uso actual y no puede continuar esta ejecución por ahora.",
|
||||
"cliRateLimitGuide.limitType": "Ventana de límite",
|
||||
@@ -223,6 +235,8 @@
|
||||
"knowledgeBase.allFiles": "Todos los archivos",
|
||||
"knowledgeBase.allLibraries": "Todas las bibliotecas",
|
||||
"knowledgeBase.disabled": "El chat de biblioteca no está disponible en esta implementación. Cambia a una base de datos del lado del servidor o usa {{cloud}}.",
|
||||
"knowledgeBase.files": "Archivos",
|
||||
"knowledgeBase.libraries": "Bibliotecas",
|
||||
"knowledgeBase.library.action.add": "Agregar",
|
||||
"knowledgeBase.library.action.detail": "Detalles",
|
||||
"knowledgeBase.library.action.remove": "Eliminar",
|
||||
@@ -326,6 +340,15 @@
|
||||
"pageSelection.reference": "Texto Seleccionado",
|
||||
"pin": "Fijar",
|
||||
"pinOff": "Desfijar",
|
||||
"plus.addSkills": "Agregar habilidades...",
|
||||
"plus.search.appSearch": "Búsqueda inteligente",
|
||||
"plus.search.appSearchDesc": "Servicio de búsqueda optimizado de LobeHub, ofreciendo los mejores resultados de recuperación.",
|
||||
"plus.search.modelSearch": "Búsqueda de proveedor",
|
||||
"plus.search.modelSearchDesc": "Puede causar un comportamiento inesperado cuando está habilitado, no recomendado.",
|
||||
"plus.search.off": "Apagado",
|
||||
"plus.search.offDesc": "",
|
||||
"plus.title": "Agregar",
|
||||
"plus.tooltip": "Agregar archivos, habilidades y más contexto...",
|
||||
"rag.referenceChunks": "Fuente de referencia",
|
||||
"rag.userQuery.actions.delete": "Eliminar reescritura de consulta",
|
||||
"rag.userQuery.actions.regenerate": "Regenerar consulta",
|
||||
@@ -357,6 +380,8 @@
|
||||
"searchAgents": "Buscar agentes...",
|
||||
"selectedAgents": "Agentes seleccionados",
|
||||
"sendPlaceholder": "Pregunta, crea o inicia una tarea, <hotkey><hotkey/>",
|
||||
"sendPlaceholderChat": "Pregunta, busca o genera ideas, <hotkey><hotkey/>",
|
||||
"sendPlaceholderChatWithAgentAssignment": "Pregunta, busca o genera ideas. @ para incluir a otros agentes.",
|
||||
"sendPlaceholderHeterogeneous": "Pide a {{name}} que realice una tarea...",
|
||||
"sendPlaceholderWithAgentAssignment": "Pregunta, crea o inicia una tarea. Usa @ para asignar tareas a otros agentes.",
|
||||
"sessionGroup.config": "Gestión de grupos",
|
||||
@@ -734,6 +759,7 @@
|
||||
"untitledAgent": "Agente sin título",
|
||||
"untitledGroup": "Grupo sin título",
|
||||
"updateAgent": "Actualizar información del agente",
|
||||
"upload.action.fileOrImageUpload": "Subir archivo o imagen",
|
||||
"upload.action.fileUpload": "Subir archivo",
|
||||
"upload.action.folderUpload": "Subir carpeta",
|
||||
"upload.action.imageDisabled": "El modelo actual no admite reconocimiento visual. Cambia de modelo para usar esta función.",
|
||||
@@ -846,6 +872,23 @@
|
||||
"workingPanel.documents.saved": "All changes saved",
|
||||
"workingPanel.documents.title": "Document",
|
||||
"workingPanel.documents.unsaved": "Unsaved changes",
|
||||
"workingPanel.files.copyAbsolutePath": "Copiar Ruta Absoluta",
|
||||
"workingPanel.files.copyRelativePath": "Copiar Ruta Relativa",
|
||||
"workingPanel.files.count_one": "{{count}} archivo",
|
||||
"workingPanel.files.count_other": "{{count}} archivos",
|
||||
"workingPanel.files.empty": "No hay archivos en este espacio de trabajo",
|
||||
"workingPanel.files.open": "Abrir Archivo",
|
||||
"workingPanel.files.refresh": "Actualizar",
|
||||
"workingPanel.files.showInReview": "Mostrar en Revisión",
|
||||
"workingPanel.files.showInSystem": "Revelar en Carpeta",
|
||||
"workingPanel.files.title": "Archivos",
|
||||
"workingPanel.localFile.binary": "Archivo binario — vista previa no disponible",
|
||||
"workingPanel.localFile.close": "Cerrar",
|
||||
"workingPanel.localFile.closeLeft": "Cerrar a la Izquierda",
|
||||
"workingPanel.localFile.closeOther": "Cerrar Otros",
|
||||
"workingPanel.localFile.closeRight": "Cerrar a la Derecha",
|
||||
"workingPanel.localFile.error": "No se pudo cargar este archivo",
|
||||
"workingPanel.localFile.truncated": "Vista previa del archivo truncada a {{limit}} caracteres",
|
||||
"workingPanel.progress": "Progress",
|
||||
"workingPanel.progress.allCompleted": "All tasks completed",
|
||||
"workingPanel.resources": "Resources",
|
||||
@@ -892,6 +935,8 @@
|
||||
"workingPanel.review.mode.unstaged": "No preparado",
|
||||
"workingPanel.review.more": "Más opciones",
|
||||
"workingPanel.review.refresh": "Actualizar",
|
||||
"workingPanel.review.revealInTree": "Revelar en el árbol",
|
||||
"workingPanel.review.revealNotFound": "Archivo no encontrado en el índice del proyecto",
|
||||
"workingPanel.review.revert": "Descartar cambios",
|
||||
"workingPanel.review.revert.confirm.cancel": "Cancelar",
|
||||
"workingPanel.review.revert.confirm.description": "Los cambios en el árbol de trabajo de {{filePath}} se descartarán permanentemente. Los archivos sin seguimiento se eliminarán del disco.",
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"brief.action.confirm": "Confirmar",
|
||||
"brief.action.confirmDone": "Confirmar",
|
||||
"brief.action.feedback": "Comentarios",
|
||||
"brief.action.ignore": "Ignorar",
|
||||
"brief.action.retry": "Reintentar",
|
||||
"brief.addFeedback": "Compartir comentarios",
|
||||
"brief.collapse": "Mostrar menos",
|
||||
|
||||
@@ -20,6 +20,22 @@
|
||||
"messenger.discord.connections.disconnectFailed": "No se pudo eliminar el servidor.",
|
||||
"messenger.discord.connections.disconnectSuccess": "Servidor eliminado.",
|
||||
"messenger.discord.connections.disconnectTitle": "Eliminar servidor",
|
||||
"messenger.discord.installBlocked.dismiss": "Entendido",
|
||||
"messenger.discord.installBlocked.suggestion": "Envía un mensaje directo al bot de LobeHub en Discord para vincular tu cuenta personal; no necesitas agregar el bot nuevamente. O pide al instalador original que elimine este servidor en Configuración de LobeHub → Messenger antes de volver a agregarlo.",
|
||||
"messenger.discord.installBlocked.title": "Servidor ya conectado",
|
||||
"messenger.discord.installBlocked.withName": "\"{{workspace}}\" ya está conectado a LobeHub por otro usuario.",
|
||||
"messenger.discord.installBlocked.withoutName": "Este servidor de Discord ya está conectado a LobeHub por otro usuario.",
|
||||
"messenger.discord.installResult.failed": "La instalación de Discord falló ({{reason}}). Por favor, inténtalo de nuevo o contacta con soporte.",
|
||||
"messenger.discord.installResult.reasons.accessDenied": "la autorización fue cancelada",
|
||||
"messenger.discord.installResult.reasons.exchangeFailed": "la autorización de Discord falló",
|
||||
"messenger.discord.installResult.reasons.generic": "ocurrió un error desconocido",
|
||||
"messenger.discord.installResult.reasons.invalidState": "la sesión de instalación expiró",
|
||||
"messenger.discord.installResult.reasons.missingAppId": "Discord devolvió información incompleta de la aplicación",
|
||||
"messenger.discord.installResult.reasons.missingCodeOrState": "Discord devolvió parámetros de instalación incompletos",
|
||||
"messenger.discord.installResult.reasons.missingTenant": "Discord no devolvió un identificador de servidor",
|
||||
"messenger.discord.installResult.reasons.missingToken": "Discord no devolvió un token de acceso",
|
||||
"messenger.discord.installResult.reasons.persistFailed": "no se pudo guardar la conexión del servidor",
|
||||
"messenger.discord.installResult.success": "Servidor de Discord conectado.",
|
||||
"messenger.discord.userPending.cta": "Abrir en Discord",
|
||||
"messenger.discord.userPending.hint": "Abre el bot en Discord y envía cualquier mensaje para finalizar la vinculación de tu cuenta.",
|
||||
"messenger.discord.userPending.name": "Aún no vinculado",
|
||||
@@ -96,9 +112,6 @@
|
||||
"verify.error.missingToken": "Enlace no válido. Abre esta página desde el bot.",
|
||||
"verify.error.title": "No se pudo confirmar la vinculación",
|
||||
"verify.error.unlinkBeforeRelink": "Esta cuenta de LobeHub ya está vinculada a otra cuenta de Telegram. Desconéctala en Configuración → Messenger antes de vincular una nueva.",
|
||||
"verify.labRequired.description": "Messenger es actualmente una función de Labs. Actívala en Configuración → Avanzado → Labs y recarga esta página.",
|
||||
"verify.labRequired.openSettings": "Abrir configuración de Labs",
|
||||
"verify.labRequired.title": "Habilita Messenger para continuar",
|
||||
"verify.signInCta": "Inicia sesión para continuar",
|
||||
"verify.signInRequired": "Por favor, inicia sesión en LobeHub para confirmar la vinculación.",
|
||||
"verify.success.description": "Tu cuenta ahora está conectada a {{platform}}. Abre {{platform}} y envía tu primer mensaje.",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user