mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-18 05:18:31 +00:00
Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f049ab1610 | |||
| 542aacd07f | |||
| 4be4bc69c2 | |||
| a62def2fe9 | |||
| 54f972b7dd | |||
| be676bca4f | |||
| cdfa64956f | |||
| 65da232c64 | |||
| dacc7798ab | |||
| 9508807da7 | |||
| 6a7eb17cd2 | |||
| c5da34b680 | |||
| 2a37b77482 | |||
| b814cf2611 | |||
| c37817e2d8 | |||
| bbf239705c | |||
| 8a9f42596d | |||
| 29235dc1ed | |||
| e326400dbe | |||
| deeb97ab5b | |||
| d73858ef42 | |||
| 6b9584714d | |||
| b9a4a9093c | |||
| ef5be7e17c | |||
| a4235d3f68 | |||
| fa508f4259 | |||
| 94767fddcb | |||
| 685b17e59e | |||
| 9acb128943 | |||
| ee55d74dd4 | |||
| cca1050e82 | |||
| 92a848c69c | |||
| f32fff19dd | |||
| 38d7bdbd96 | |||
| 3e236ec36f |
@@ -8,16 +8,20 @@ Generate text, images, videos, speech, and transcriptions.
|
||||
|
||||
```
|
||||
lh generate (alias: gen)
|
||||
├── text <prompt> # Text generation
|
||||
├── image <prompt> # Image generation
|
||||
├── video <prompt> # Video generation
|
||||
├── tts <text> # Text-to-speech
|
||||
├── asr <audioFile> # Audio-to-text (speech recognition)
|
||||
├── download <genId> <taskId> # Wait & download generation result
|
||||
├── status <genId> <taskId> # Check async task status
|
||||
└── list # List generation topics
|
||||
├── text <prompt> # Text generation
|
||||
├── image <prompt> # Image generation
|
||||
├── video <prompt> # Video generation
|
||||
├── tts <text> # Text-to-speech
|
||||
├── asr <audioFile> # Audio-to-text (speech recognition)
|
||||
├── download <generationId> <asyncTaskId> # Wait & download generation result
|
||||
├── status <generationId> <asyncTaskId> # Check async task status
|
||||
└── list # List generation topics
|
||||
```
|
||||
|
||||
> ⚠️ **Important**: `status` and `download` require an `asyncTaskId` (UUID format, e.g.
|
||||
> `7ad0eb13-e9a5-4403-8070-1f7fe95b2f95`), **not** the generation ID (`gen_xxx`).
|
||||
> The asyncTaskId is printed after "→ Task" in the `video` / `image` command output.
|
||||
|
||||
---
|
||||
|
||||
## `lh generate text <prompt>` / `lh gen text <prompt>`
|
||||
@@ -54,7 +58,7 @@ cat README.md | lh gen text "summarize this" --pipe
|
||||
|
||||
## `lh generate image <prompt>` / `lh gen image <prompt>`
|
||||
|
||||
Generate images from text prompt. This is an async operation — the command submits the task and returns a generation ID + task ID for tracking.
|
||||
Generate images from text prompt. This is an async operation — the command submits the task and returns a generation ID + async task ID for tracking.
|
||||
|
||||
**Source**: `apps/cli/src/commands/generate/image.ts`
|
||||
|
||||
@@ -80,17 +84,22 @@ lh gen image "A cute cat" --model dall-e-3 --provider openai --json
|
||||
✓ Image generation started
|
||||
Batch ID: gb_xxx
|
||||
1 image(s) queued
|
||||
Generation gen_xxx → Task <taskId>
|
||||
Generation gen_xxx → Task 7ad0eb13-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
This is the asyncTaskId — use this for status/download
|
||||
|
||||
Use "lh generate status <generationId> <taskId>" to check progress.
|
||||
Use "lh generate status <generationId> <asyncTaskId>" to check progress.
|
||||
```
|
||||
|
||||
**Typical workflow**:
|
||||
|
||||
```bash
|
||||
# Generate image, then wait & download
|
||||
# 1. Submit generation — note down BOTH IDs from the output
|
||||
lh gen image "A cute cat"
|
||||
lh gen download <generationId> <taskId> -o cat.png
|
||||
# Generation gen_abc123 → Task 7ad0eb13-e9a5-4403-8070-1f7fe95b2f95
|
||||
|
||||
# 2. Wait & download using generationId + asyncTaskId (the UUID)
|
||||
lh gen download gen_abc123 7ad0eb13-e9a5-4403-8070-1f7fe95b2f95 -o cat.png
|
||||
```
|
||||
|
||||
---
|
||||
@@ -102,7 +111,7 @@ Generate video from text prompt. This is an async operation.
|
||||
**Source**: `apps/cli/src/commands/generate/video.ts`
|
||||
|
||||
```bash
|
||||
lh gen video "A cat playing piano" -m < model > -p < provider > [options]
|
||||
lh gen video "A cat playing piano" -m <model> -p <provider> [options]
|
||||
```
|
||||
|
||||
| Option | Description | Required |
|
||||
@@ -122,9 +131,26 @@ lh gen video "A cat playing piano" -m < model > -p < provider > [options]
|
||||
```
|
||||
✓ Video generation started
|
||||
Batch ID: gb_xxx
|
||||
Generation gen_xxx → Task <taskId>
|
||||
Generation gen_xxx → Task 7ad0eb13-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
This is the asyncTaskId — use this for status/download
|
||||
|
||||
Use "lh generate status <generationId> <taskId>" to check progress.
|
||||
Use "lh generate status <generationId> <asyncTaskId>" to check progress.
|
||||
```
|
||||
|
||||
**Typical workflow**:
|
||||
|
||||
```bash
|
||||
# 1. Find available video models for a provider
|
||||
lh model list volcengine --json | grep -i seedance
|
||||
|
||||
# 2. Submit generation — note down BOTH IDs from the output
|
||||
lh gen video "A cat on a runway" -m doubao-seedance-2-0-260128 -p volcengine \
|
||||
--aspect-ratio 9:16 --duration 5 --resolution 1080p
|
||||
# Generation gen_abc123 → Task 7ad0eb13-e9a5-4403-8070-1f7fe95b2f95
|
||||
|
||||
# 3. Wait & download using generationId + asyncTaskId (the UUID)
|
||||
lh gen download gen_abc123 7ad0eb13-e9a5-4403-8070-1f7fe95b2f95 -o result.mp4 --timeout 600
|
||||
```
|
||||
|
||||
---
|
||||
@@ -153,15 +179,18 @@ lh gen asr recording.wav [options]
|
||||
|
||||
---
|
||||
|
||||
## `lh generate download <generationId> <taskId>`
|
||||
## `lh generate download <generationId> <asyncTaskId>`
|
||||
|
||||
Wait for an async generation task to complete and download the result file.
|
||||
|
||||
**Source**: `apps/cli/src/commands/generate/index.ts`
|
||||
|
||||
> ⚠️ `<asyncTaskId>` is the UUID printed after "→ Task" in the video/image output.
|
||||
> Do **not** pass the generation ID (`gen_xxx`) here — that will cause a server error.
|
||||
|
||||
```bash
|
||||
lh gen download <generationId> <taskId> [-o output.png]
|
||||
lh gen download gen_xxx task_xxx -o ~/Desktop/result.mp4 --timeout 600
|
||||
lh gen download <generationId> <asyncTaskId> [-o output.png]
|
||||
lh gen download gen_xxx 7ad0eb13-xxxx-xxxx-xxxx-xxxxxxxxxxxx -o ~/Desktop/result.mp4 --timeout 600
|
||||
```
|
||||
|
||||
| Option | Description | Default |
|
||||
@@ -175,30 +204,21 @@ lh gen download gen_xxx task_xxx -o ~/Desktop/result.mp4 --timeout 600
|
||||
1. Polls `generation.getGenerationStatus` at the specified interval
|
||||
2. Shows live progress: `⋯ Status: processing... (42s)`
|
||||
3. On success: downloads asset URL to local file
|
||||
4. On error: displays error message and exits
|
||||
4. On error / wrong ID: displays a clear message pointing to the correct ID format
|
||||
5. On timeout: suggests using `lh gen status` to check later
|
||||
|
||||
**Typical workflow**:
|
||||
|
||||
```bash
|
||||
# One-shot: generate and download
|
||||
lh gen image "A sunset"
|
||||
# Copy the generation ID and task ID from output
|
||||
lh gen download gen_xxx taskId_xxx -o sunset.png
|
||||
|
||||
# Video (longer timeout)
|
||||
lh gen video "A cat running" -m model -p provider
|
||||
lh gen download gen_xxx taskId_xxx -o cat.mp4 --timeout 600
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `lh generate status <generationId> <taskId>`
|
||||
## `lh generate status <generationId> <asyncTaskId>`
|
||||
|
||||
Check the status of an async generation task.
|
||||
|
||||
> ⚠️ `<asyncTaskId>` is the UUID printed after "→ Task" in the video/image output.
|
||||
> Do **not** pass the generation ID (`gen_xxx`) here — that will cause a server error.
|
||||
|
||||
```bash
|
||||
lh gen status <generationId> <taskId> [--json]
|
||||
lh gen status <generationId> <asyncTaskId> [--json]
|
||||
lh gen status gen_xxx 7ad0eb13-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
@@ -235,12 +255,17 @@ Image and video generation use an async task pattern:
|
||||
- Triggers async background task (image via `createAsyncCaller`, video via `initModelRuntimeFromDB`)
|
||||
- Returns `{ data: { batch, generations }, success }` with `asyncTaskId` in each generation
|
||||
3. **Poll status** → `generation.getGenerationStatus`
|
||||
- Input: `{ generationId, asyncTaskId }` — both are required, and `asyncTaskId` must be the
|
||||
UUID from the `async_tasks` table, not `gen_xxx`
|
||||
- Returns `{ status, error, generation }` (generation includes asset URLs on success)
|
||||
- Before querying, calls `checkTimeoutTasks` which marks tasks as `error` if they have been
|
||||
`pending` or `processing` for more than ~5 minutes (`ASYNC_TASK_TIMEOUT = 298s`)
|
||||
|
||||
**Server routes**:
|
||||
|
||||
- `src/server/routers/lambda/image/index.ts` — image creation (uses `authedProcedure` + `serverDatabase`)
|
||||
- `src/server/routers/lambda/video/index.ts` — video creation (uses `authedProcedure` + `serverDatabase`)
|
||||
- `src/server/routers/lambda/generation.ts` — status checking
|
||||
- `packages/database/src/models/asyncTask.ts` — `AsyncTaskModel` including `checkTimeoutTasks`
|
||||
|
||||
**Note**: Image/video routes do NOT use the `keyVaults` middleware — they read API keys from the database via `initModelRuntimeFromDB` or `createAsyncCaller`.
|
||||
|
||||
@@ -1,58 +1,51 @@
|
||||
---
|
||||
name: code-review
|
||||
description: 'Code review checklist for LobeHub. Use when reviewing PRs, diffs, or code changes. Covers correctness, security, quality, and project-specific patterns.'
|
||||
name: review-checklist
|
||||
description: 'Common recurring mistakes in LobeHub code review — console leftovers, missing return await, hardcoded secrets, hardcoded i18n strings, desktop router pair drift, antd vs @lobehub/ui, non-idempotent migrations, cloud impact red flags. Use as a quick checklist when reviewing PRs, diffs, or branch changes.'
|
||||
---
|
||||
|
||||
# Code Review Guide
|
||||
# Review Checklist
|
||||
|
||||
## Before You Start
|
||||
|
||||
1. Read `/typescript` and `/testing` skills for code style and test conventions
|
||||
2. Get the diff (skip if already in context, e.g., injected by GitHub review app): `git diff` or `git diff origin/canary..HEAD`
|
||||
|
||||
## Checklist
|
||||
|
||||
### Correctness
|
||||
## Correctness
|
||||
|
||||
- Leftover `console.log` / `console.debug` — should use `debug` package or remove
|
||||
- Missing `return await` in try/catch — see <https://typescript-eslint.io/rules/return-await/> (not in our ESLint config yet, requires type info)
|
||||
- Can the fix/implementation be more concise, efficient, or have better compatibility?
|
||||
|
||||
### Security
|
||||
## Security
|
||||
|
||||
- No sensitive data (API keys, tokens, credentials) in `console.*` or `debug()` output
|
||||
- No base64 output to terminal — extremely long, freezes output
|
||||
- No hardcoded secrets — use environment variables
|
||||
|
||||
### Testing
|
||||
## Testing
|
||||
|
||||
- Bug fixes must include tests covering the fixed scenario
|
||||
- New logic (services, store actions, utilities) should have test coverage
|
||||
- Existing tests still cover the changed behavior?
|
||||
- Prefer `vi.spyOn` over `vi.mock` (see `/testing` skill)
|
||||
|
||||
### i18n
|
||||
## i18n
|
||||
|
||||
- New user-facing strings use i18n keys, not hardcoded text
|
||||
- Keys added to `src/locales/default/{namespace}.ts` with `{feature}.{context}.{action|status}` naming
|
||||
- For PRs: `locales/` translations for all languages updated (`pnpm i18n`)
|
||||
|
||||
### SPA / routing
|
||||
## SPA / routing
|
||||
|
||||
- **`desktopRouter` pair:** If the diff touches `src/spa/router/desktopRouter.config.tsx`, does it also update `src/spa/router/desktopRouter.config.desktop.tsx` with the same route paths and nesting? Single-file edits often cause drift and blank screens.
|
||||
|
||||
### Reuse
|
||||
## Reuse
|
||||
|
||||
- Newly written code duplicates existing utilities in `packages/utils` or shared modules?
|
||||
- Copy-pasted blocks with slight variation — extract into shared function
|
||||
- `antd` imports replaceable with `@lobehub/ui` wrapped components (`Input`, `Button`, `Modal`, `Avatar`, etc.)
|
||||
- Use `antd-style` token system, not hardcoded colors; prefer `createStaticStyles` + `cssVar.*` over `createStyles` + `token` unless runtime computation is required
|
||||
|
||||
### Database
|
||||
## Database
|
||||
|
||||
- Migration scripts must be idempotent (`IF NOT EXISTS`, `IF EXISTS` guards)
|
||||
|
||||
### Cloud Impact
|
||||
## Cloud Impact
|
||||
|
||||
A downstream cloud deployment depends on this repo. Flag changes that may require cloud-side updates:
|
||||
|
||||
@@ -61,13 +54,3 @@ A downstream cloud deployment depends on this repo. Flag changes that may requir
|
||||
- **Dependency versions bumped** — e.g., upgrading `next` or `drizzle-orm` in `package.json`
|
||||
- **`@lobechat/business-*` exports changed** — e.g., renaming a function in `src/business/` or changing type signatures in `packages/business/`
|
||||
- `src/business/` and `packages/business/` must not expose cloud commercial logic in comments or code
|
||||
|
||||
## Output Format
|
||||
|
||||
For local CLI review only (GitHub review app posts inline PR comments instead):
|
||||
|
||||
- Number all findings sequentially
|
||||
- Indicate priority: `[high]` / `[medium]` / `[low]`
|
||||
- Include file path and line number for each finding
|
||||
- Only list problems — no summary, no praise
|
||||
- Re-read full source for each finding to verify it's real, then output "All findings verified."
|
||||
@@ -238,13 +238,34 @@ Use `---` separators between major blocks for long releases.
|
||||
- Keep concise.
|
||||
- Must include `Migration overview`, operator impact, and rollback/backup note.
|
||||
|
||||
### Contributor Ordering
|
||||
|
||||
Render contributors as a **single flat list** (no separate "Community" / "Core Team" subsections). Order: **community contributors first, team members after**. Within each group, sort by PR count desc. Bots (`@lobehubbot`, `renovate[bot]`) go on a separate "maintenance" line.
|
||||
|
||||
**LobeHub team roster** — anyone in this list is a team member; anyone not in this list is a community contributor:
|
||||
|
||||
- @arvinxx
|
||||
- @Innei
|
||||
- @tjx666 (commit author name: YuTengjing)
|
||||
- @LiJian
|
||||
- @Neko
|
||||
- @Rdmclin2
|
||||
- @AmAzing129
|
||||
- @sudongyuer
|
||||
- @rivertwilight
|
||||
- @CanisMinor
|
||||
|
||||
> **Resolving handles** — git author names (e.g. `YuTengjing`) are not always the GitHub handle. Verify via `gh pr view <PR> --json author` or `gh api search/users -f q='<email>'` before listing.
|
||||
|
||||
If a new contributor appears who is not on this list, treat them as community by default and ask the user whether to add them to the roster.
|
||||
|
||||
### GitHub Release Changelog Template
|
||||
|
||||
```md
|
||||
# 🚀 LobeHub v<x.y.z> (<YYYYMMDD>)
|
||||
|
||||
**Release Date:** <Month DD, YYYY>
|
||||
**Since <Previous Version>:** <N commits> · <N merged PRs> · <N resolved issues> · <N contributors>
|
||||
**Since <Previous Version>:** <N merged PRs> · <N resolved issues> · <N contributors>
|
||||
|
||||
> <One release thesis sentence: what this release unlocks in practice.>
|
||||
|
||||
@@ -296,12 +317,11 @@ Use `---` separators between major blocks for long releases.
|
||||
|
||||
## 👥 Contributors
|
||||
|
||||
**<N merged PRs>** from **<N contributors>** across **<N commits>**.
|
||||
Huge thanks to **<N contributors>** who shipped **<N merged PRs>** this cycle.
|
||||
|
||||
### Community Contributors
|
||||
@<community-handle> · @<community-handle> · @<team-handle> · @<team-handle>
|
||||
|
||||
- @<username> - <notable contribution area>
|
||||
- @<username> - <notable contribution area>
|
||||
Plus @lobehubbot and renovate[bot] for maintenance.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# 🚀 LobeHub v2.1.54 (20260427)
|
||||
|
||||
**Hotfix Scope:** Agent topic-switching regression — stale chat state on agent change
|
||||
|
||||
> Clears residual topic state when navigating between agents and restores blank-canvas behavior on agent switch.
|
||||
|
||||
## 🐛 What's Fixed
|
||||
|
||||
- **Stale topic on agent switch** — Switching from `/agent/agt_A/tpc_X` to `/agent/agt_B` no longer leaves the previous topic's messages on screen, and _Start new topic_ responds again. (#14231)
|
||||
- **Header & sidebar consistency** — Conversation header now shows the active subtopic's title, and the sidebar keeps the parent topic's thread list expanded while a thread is open.
|
||||
|
||||
## ⚙️ Upgrade
|
||||
|
||||
- Self-hosted: pull the new image and restart. No schema or env changes.
|
||||
- Cloud: applied automatically.
|
||||
|
||||
## 👥 Owner
|
||||
|
||||
@{pr-author}
|
||||
|
||||
> **Note for Claude**: Replace `{pr-author}` with the actual PR author. Retrieve via `gh pr view <number> --json author --jq '.author.login'`. Do not hardcode a username.
|
||||
@@ -59,7 +59,10 @@ git push -u origin hotfix/v{version}-{short-hash}
|
||||
|
||||
2. **Create PR to main** with a gitmoji prefix title (e.g. `🐛 fix: description`)
|
||||
|
||||
3. **After merge**: auto-tag-release detects `hotfix/*` branch → auto patch +1.
|
||||
3. **Write a short hotfix changelog** — See `changelog-example/hotfix.md`. Keep it minimal: scope line, 1-3 fix bullets (symptom + fix in one sentence), upgrade note, owner. No long root-cause section — that lives in the commit message.
|
||||
- **Hotfix owner**: Use the actual PR author (retrieve via `gh pr view <number> --json author --jq '.author.login'`), never hardcode a username.
|
||||
|
||||
4. **After merge**: auto-tag-release detects `hotfix/*` branch → auto patch +1.
|
||||
|
||||
### Script
|
||||
|
||||
|
||||
@@ -2,14 +2,13 @@
|
||||
|
||||
## Quick Reference by Name
|
||||
|
||||
- **@arvinxx**: Last resort only, mention for priority:high issues, tool calling, mcp, database
|
||||
- **@arvinxx**: General/uncategorized issues (default assignee), priority:high issues, tool calling, mcp, database
|
||||
- **@canisminor1990**: Design, UI components, editor, markdown rendering
|
||||
- **@tjx666**: Image/video generation, vision, cloud version, documentation, TTS, auth, login/register, database
|
||||
- **@ONLY-yours**: Performance, streaming, settings, general bugs, web platform, marketplace, agent builder, schedule task
|
||||
- **@tjx666**: Model providers and configuration, new model additions, image/video generation, vision, cloud version, documentation, TTS, auth, login/register, database
|
||||
- **@ONLY-yours**: Performance, streaming, settings, web platform, marketplace, agent builder, schedule task
|
||||
- **@Innei**: Knowledge base, files (KB-related), group chat, Electron, desktop client, build system
|
||||
- **@nekomeowww**: Memory, backend, deployment, DevOps, database
|
||||
- **@sudongyuer**: Mobile app (React Native)
|
||||
- **@sxjeru**: Model providers and configuration
|
||||
- **@rdmclin2**: Team workspace, IM and bot integration
|
||||
- **@tcmonster**: Subscription, refund, recharge, business cooperation
|
||||
|
||||
@@ -21,7 +20,7 @@ Quick reference for assigning issues based on labels.
|
||||
|
||||
| Label | Owner | Notes |
|
||||
| ---------------- | ------- | -------------------------------------------- |
|
||||
| All `provider:*` | @sxjeru | Model configuration and provider integration |
|
||||
| All `provider:*` | @tjx666 | Model configuration and provider integration |
|
||||
|
||||
### Platform Labels (platform:\*)
|
||||
|
||||
@@ -100,11 +99,10 @@ Quick reference for assigning issues based on labels.
|
||||
|
||||
1. **Specific feature owner** - e.g., `feature:knowledge-base` → @RiverTwilight
|
||||
2. **Platform owner** - e.g., `platform:mobile` → @sudongyuer
|
||||
3. **Provider owner** - e.g., `provider:*` → @sxjeru
|
||||
3. **Provider owner** - e.g., `provider:*` → @tjx666
|
||||
4. **Component owner** - e.g., 💄 Design → @canisminor1990
|
||||
5. **Infrastructure owner** - e.g., `deployment:*` → @nekomeowww
|
||||
6. **General maintainer** - @ONLY-yours for general bugs/issues
|
||||
7. **Last resort** - @arvinxx (only if no clear owner)
|
||||
6. **Default assignee** - @arvinxx for general/uncategorized issues
|
||||
|
||||
### Special Cases
|
||||
|
||||
@@ -121,8 +119,7 @@ Quick reference for assigning issues based on labels.
|
||||
|
||||
**No clear owner:**
|
||||
|
||||
- Assign to @ONLY-yours for general issues
|
||||
- Only mention @arvinxx if critical and truly unclear
|
||||
- Assign to @arvinxx for general issues
|
||||
|
||||
## Comment Templates
|
||||
|
||||
|
||||
@@ -121,4 +121,8 @@ cd packages/database && bunx vitest run --silent='passed-only' '[file]'
|
||||
|
||||
- Add keys to a namespace file under `src/locales/default/` (e.g. `agent.ts`, `auth.ts`)
|
||||
- For dev preview: translate `locales/zh-CN/` and `locales/en-US/`
|
||||
- Don't run `pnpm i18n` - CI handles it
|
||||
- `pnpm i18n` is slow; run it manually when locale keys need updating (e.g. before opening a PR).
|
||||
|
||||
### Code Review
|
||||
|
||||
Before reviewing a PR / diff / branch change, read the **review-checklist** skill (`.agents/skills/review-checklist/SKILL.md`) — it lists the recurring mistakes specific to this codebase.
|
||||
|
||||
@@ -7,12 +7,14 @@ const CLIENT_ID = 'lobehub-cli';
|
||||
* Get a valid access token, refreshing if expired.
|
||||
* Returns null if no credentials or refresh fails.
|
||||
*/
|
||||
export async function getValidToken(): Promise<{ credentials: StoredCredentials } | null> {
|
||||
export async function getValidToken(
|
||||
bufferSeconds = 60,
|
||||
): Promise<{ credentials: StoredCredentials } | null> {
|
||||
const credentials = loadCredentials();
|
||||
if (!credentials) return null;
|
||||
|
||||
// Check if token is still valid (with 60s buffer)
|
||||
if (credentials.expiresAt && Date.now() / 1000 < credentials.expiresAt - 60) {
|
||||
// Check if token is still valid (with configurable buffer)
|
||||
if (credentials.expiresAt && Date.now() / 1000 < credentials.expiresAt - bufferSeconds) {
|
||||
return { credentials };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { Command } from 'commander';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../auth/refresh', () => ({
|
||||
getValidToken: vi.fn().mockResolvedValue({
|
||||
credentials: { accessToken: 'test-token', expiresAt: undefined, refreshToken: 'test-refresh' },
|
||||
}),
|
||||
}));
|
||||
vi.mock('../auth/resolveToken', () => ({
|
||||
resolveToken: vi.fn().mockResolvedValue({
|
||||
serverUrl: 'https://app.lobehub.com',
|
||||
@@ -83,16 +88,21 @@ vi.mock('@lobechat/device-gateway-client', () => ({
|
||||
on: vi.fn().mockImplementation((event: string, handler: (...args: any[]) => any) => {
|
||||
clientEventHandlers[event] = handler;
|
||||
}),
|
||||
reconnect: vi.fn().mockResolvedValue(undefined),
|
||||
sendSystemInfoResponse: vi.fn().mockImplementation((data: any) => {
|
||||
lastSentSystemInfoResponse = data;
|
||||
}),
|
||||
sendToolCallResponse: vi.fn().mockImplementation((data: any) => {
|
||||
lastSentToolResponse = data;
|
||||
}),
|
||||
updateToken: vi.fn(),
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line import-x/first
|
||||
import { GatewayClient } from '@lobechat/device-gateway-client';
|
||||
|
||||
// eslint-disable-next-line import-x/first
|
||||
import { resolveToken } from '../auth/resolveToken';
|
||||
// eslint-disable-next-line import-x/first
|
||||
@@ -242,13 +252,33 @@ describe('connect command', () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'connect']);
|
||||
|
||||
clientEventHandlers['auth_failed']?.('invalid token');
|
||||
await clientEventHandlers['auth_failed']?.('invalid token');
|
||||
|
||||
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('Authentication failed'));
|
||||
expect(cleanupAllProcesses).toHaveBeenCalled();
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should retry auth_failed with token refresh when new token available', async () => {
|
||||
vi.mocked(resolveToken).mockResolvedValueOnce({
|
||||
serverUrl: 'https://app.lobehub.com',
|
||||
token: 'refreshed-token',
|
||||
tokenType: 'jwt',
|
||||
userId: 'test-user',
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'connect']);
|
||||
|
||||
const mockClient = vi.mocked(GatewayClient).mock.results[0].value;
|
||||
|
||||
await clientEventHandlers['auth_failed']?.('token expired');
|
||||
|
||||
expect(log.info).toHaveBeenCalledWith(expect.stringContaining('Token refreshed'));
|
||||
expect(mockClient.updateToken).toHaveBeenCalledWith('refreshed-token');
|
||||
expect(exitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle auth_expired', async () => {
|
||||
vi.mocked(resolveToken).mockResolvedValueOnce({
|
||||
serverUrl: 'https://app.lobehub.com',
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
import { GatewayClient } from '@lobechat/device-gateway-client';
|
||||
import type { Command } from 'commander';
|
||||
|
||||
import { getValidToken } from '../auth/refresh';
|
||||
import { resolveToken } from '../auth/resolveToken';
|
||||
import { CLI_API_KEY_ENV } from '../constants/auth';
|
||||
import { OFFICIAL_GATEWAY_URL } from '../constants/urls';
|
||||
@@ -284,8 +285,44 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
|
||||
updateStatus('reconnecting');
|
||||
});
|
||||
|
||||
// Handle auth failed
|
||||
client.on('auth_failed', (reason) => {
|
||||
// Proactive token refresh — schedule before JWT expires
|
||||
const startProactiveRefresh = () =>
|
||||
scheduleProactiveRefresh(
|
||||
auth,
|
||||
(refreshed) => {
|
||||
client.updateToken(refreshed.token);
|
||||
auth = refreshed;
|
||||
// Schedule next refresh based on the new token
|
||||
cancelRefreshTimer = startProactiveRefresh();
|
||||
},
|
||||
info,
|
||||
error,
|
||||
);
|
||||
let cancelRefreshTimer = startProactiveRefresh();
|
||||
|
||||
// Handle auth failed — attempt token refresh once before giving up
|
||||
// (e.g., auto-reconnect may send an expired JWT before proactive refresh fires)
|
||||
let authFailedRefreshAttempted = false;
|
||||
client.on('auth_failed', async (reason) => {
|
||||
if (auth.tokenType === 'jwt' && !authFailedRefreshAttempted) {
|
||||
authFailedRefreshAttempted = true;
|
||||
info(`Authentication failed (${reason}). Attempting token refresh...`);
|
||||
try {
|
||||
const refreshed = await resolveToken({});
|
||||
if (refreshed && refreshed.token !== auth.token) {
|
||||
info('Token refreshed successfully. Reconnecting...');
|
||||
client.updateToken(refreshed.token);
|
||||
auth = refreshed;
|
||||
authFailedRefreshAttempted = false;
|
||||
cancelRefreshTimer = startProactiveRefresh();
|
||||
await client.reconnect();
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
|
||||
error(`Authentication failed: ${reason}`);
|
||||
error(
|
||||
`Run 'lh login', or set ${CLI_API_KEY_ENV} and run 'lh login --server <url>' to configure API key authentication.`,
|
||||
@@ -308,8 +345,8 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
|
||||
if (refreshed) {
|
||||
info('Token refreshed successfully. Reconnecting...');
|
||||
client.updateToken(refreshed.token);
|
||||
// Update cached auth so subsequent refreshes use the latest token
|
||||
auth = refreshed;
|
||||
cancelRefreshTimer = startProactiveRefresh();
|
||||
await client.reconnect();
|
||||
return;
|
||||
}
|
||||
@@ -330,6 +367,7 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
|
||||
// Graceful shutdown
|
||||
const cleanup = () => {
|
||||
info('Shutting down...');
|
||||
cancelRefreshTimer?.();
|
||||
cleanupAllProcesses();
|
||||
client.disconnect();
|
||||
removeStatus();
|
||||
@@ -374,6 +412,69 @@ function formatUptime(startedAt: Date): string {
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
// How far before expiry to proactively refresh (1 hour)
|
||||
const PROACTIVE_REFRESH_BUFFER = 60 * 60;
|
||||
|
||||
/**
|
||||
* Parse the `exp` claim from a JWT without verifying the signature.
|
||||
*/
|
||||
function parseJwtExp(token: string): number | undefined {
|
||||
try {
|
||||
const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString());
|
||||
return typeof payload.exp === 'number' ? payload.exp : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a proactive token refresh before the JWT expires.
|
||||
* Returns a cleanup function that cancels the scheduled timer.
|
||||
*/
|
||||
function scheduleProactiveRefresh(
|
||||
auth: { token: string; tokenType: string },
|
||||
onRefreshed: (newAuth: Awaited<ReturnType<typeof resolveToken>>) => void,
|
||||
info: (msg: string) => void,
|
||||
error: (msg: string) => void,
|
||||
): (() => void) | null {
|
||||
if (auth.tokenType !== 'jwt') return null;
|
||||
|
||||
const exp = parseJwtExp(auth.token);
|
||||
if (!exp) return null;
|
||||
|
||||
const refreshAt = (exp - PROACTIVE_REFRESH_BUFFER) * 1000;
|
||||
const delay = refreshAt - Date.now();
|
||||
|
||||
if (delay < 0) {
|
||||
// Already past the refresh window — refresh immediately on next tick
|
||||
void doRefresh();
|
||||
return null;
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => void doRefresh(), delay);
|
||||
return () => clearTimeout(timer);
|
||||
|
||||
async function doRefresh() {
|
||||
try {
|
||||
// Use the same buffer so getValidToken actually triggers a refresh
|
||||
const result = await getValidToken(PROACTIVE_REFRESH_BUFFER);
|
||||
if (!result) {
|
||||
error('Proactive token refresh failed — no valid credentials.');
|
||||
return;
|
||||
}
|
||||
|
||||
const refreshed = await resolveToken({});
|
||||
// Only notify if the token actually changed to avoid reschedule loops
|
||||
if (refreshed.token !== auth.token) {
|
||||
info('Proactively refreshed token.');
|
||||
onRefreshed(refreshed);
|
||||
}
|
||||
} catch {
|
||||
error('Proactive token refresh failed.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function collectSystemInfo(): DeviceSystemInfo {
|
||||
const home = os.homedir();
|
||||
const platform = process.platform;
|
||||
|
||||
@@ -9,6 +9,61 @@ import { registerTextCommand } from './text';
|
||||
import { registerTtsCommand } from './tts';
|
||||
import { registerVideoCommand } from './video';
|
||||
|
||||
/**
|
||||
* Parse a tRPC/server error and return a user-friendly message for gen status/download.
|
||||
*
|
||||
* getGenerationStatus throws NOT_FOUND in two distinct cases:
|
||||
* 1. "Async task not found" → asyncTaskId is wrong (user passed gen_xxx instead of UUID)
|
||||
* 2. "Generation not found" → generationId is wrong
|
||||
*
|
||||
* INTERNAL_SERVER_ERROR with a message mentioning "async_tasks" also indicates a bad asyncTaskId
|
||||
* (e.g. the server SQL query fails when a non-UUID is passed).
|
||||
*/
|
||||
function parseGenStatusError(
|
||||
err: any,
|
||||
generationId: string,
|
||||
asyncTaskId: string,
|
||||
command: 'status' | 'download',
|
||||
): string | null {
|
||||
const code = err?.data?.code || err?.shape?.data?.code;
|
||||
const message: string = err?.message || err?.shape?.message || '';
|
||||
|
||||
const isAsyncTaskNotFound =
|
||||
(code === 'NOT_FOUND' && message.includes('Async task not found')) ||
|
||||
(code === 'INTERNAL_SERVER_ERROR' && message.includes('async_tasks'));
|
||||
|
||||
const isGenerationNotFound = code === 'NOT_FOUND' && message.includes('Generation not found');
|
||||
|
||||
if (isAsyncTaskNotFound) {
|
||||
return (
|
||||
`${pc.red('✗')} Async task not found: ${pc.bold(asyncTaskId)}\n` +
|
||||
`\n` +
|
||||
` The second argument must be the ${pc.bold('asyncTaskId')} — the UUID printed after\n` +
|
||||
` "→ Task" in the video/image output, not the generation ID (gen_xxx).\n` +
|
||||
`\n` +
|
||||
` Example output from "lh gen video":\n` +
|
||||
` Generation ${pc.bold('gen_abc123')} → Task ${pc.dim('7ad0eb13-e9a5-4403-8070-1f7fe95b2f95')}\n` +
|
||||
`\n` +
|
||||
` Correct usage:\n` +
|
||||
` ${pc.cyan(`lh gen ${command} gen_abc123 7ad0eb13-e9a5-4403-8070-1f7fe95b2f95`)}`
|
||||
);
|
||||
}
|
||||
|
||||
if (isGenerationNotFound) {
|
||||
return (
|
||||
`${pc.red('✗')} Generation not found: ${pc.bold(generationId)}\n` +
|
||||
`\n` +
|
||||
` The first argument must be the ${pc.bold('generationId')} (gen_xxx) from the\n` +
|
||||
` video/image output.\n` +
|
||||
`\n` +
|
||||
` Correct usage:\n` +
|
||||
` ${pc.cyan(`lh gen ${command} <generationId> <asyncTaskId>`)}`
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function registerGenerateCommand(program: Command) {
|
||||
const generate = program
|
||||
.command('generate')
|
||||
@@ -23,15 +78,26 @@ export function registerGenerateCommand(program: Command) {
|
||||
|
||||
// ── status ──────────────────────────────────────────
|
||||
generate
|
||||
.command('status <generationId> <taskId>')
|
||||
.command('status <generationId> <asyncTaskId>')
|
||||
.description('Check generation task status')
|
||||
.option('--json', 'Output raw JSON')
|
||||
.action(async (generationId: string, taskId: string, options: { json?: boolean }) => {
|
||||
.action(async (generationId: string, asyncTaskId: string, options: { json?: boolean }) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.generation.getGenerationStatus.query({
|
||||
asyncTaskId: taskId,
|
||||
generationId,
|
||||
});
|
||||
|
||||
let result: any;
|
||||
try {
|
||||
result = await client.generation.getGenerationStatus.query({
|
||||
asyncTaskId,
|
||||
generationId,
|
||||
});
|
||||
} catch (err: any) {
|
||||
const msg = parseGenStatusError(err, generationId, asyncTaskId, 'status');
|
||||
if (msg) {
|
||||
console.error(msg);
|
||||
process.exit(1);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
@@ -53,7 +119,7 @@ export function registerGenerateCommand(program: Command) {
|
||||
|
||||
// ── download ──────────────────────────────────────────
|
||||
generate
|
||||
.command('download <generationId> <taskId>')
|
||||
.command('download <generationId> <asyncTaskId>')
|
||||
.description('Wait for generation to complete and download the result')
|
||||
.option('-o, --output <path>', 'Output file path (default: auto-detect from asset)')
|
||||
.option('--interval <sec>', 'Polling interval in seconds', '5')
|
||||
@@ -61,7 +127,7 @@ export function registerGenerateCommand(program: Command) {
|
||||
.action(
|
||||
async (
|
||||
generationId: string,
|
||||
taskId: string,
|
||||
asyncTaskId: string,
|
||||
options: { interval?: string; output?: string; timeout?: string },
|
||||
) => {
|
||||
const client = await getTrpcClient();
|
||||
@@ -73,10 +139,20 @@ export function registerGenerateCommand(program: Command) {
|
||||
|
||||
// Poll for completion
|
||||
while (true) {
|
||||
const result = (await client.generation.getGenerationStatus.query({
|
||||
asyncTaskId: taskId,
|
||||
generationId,
|
||||
})) as any;
|
||||
let result: any;
|
||||
try {
|
||||
result = await client.generation.getGenerationStatus.query({
|
||||
asyncTaskId,
|
||||
generationId,
|
||||
});
|
||||
} catch (err: any) {
|
||||
const msg = parseGenStatusError(err, generationId, asyncTaskId, 'download');
|
||||
if (msg) {
|
||||
console.error(`\n${msg}`);
|
||||
process.exit(1);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (result.status === 'success' && result.generation) {
|
||||
const gen = result.generation;
|
||||
@@ -125,7 +201,7 @@ export function registerGenerateCommand(program: Command) {
|
||||
console.log(
|
||||
`${pc.red('✗')} Timed out after ${options.timeout}s. Task still ${result.status}.`,
|
||||
);
|
||||
console.log(pc.dim(`Run "lh gen status ${generationId} ${taskId}" to check later.`));
|
||||
console.log(pc.dim(`Run "lh gen status ${generationId} ${asyncTaskId}" to check later.`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { createInterface } from 'node:readline';
|
||||
|
||||
import type { Command } from 'commander';
|
||||
import pc from 'picocolors';
|
||||
|
||||
import { getTrpcClient } from '../api/client';
|
||||
import { log } from '../utils/logger';
|
||||
|
||||
/**
|
||||
* Detect step boundaries in Claude Code stream-json output.
|
||||
*
|
||||
* A "step" boundary occurs when a new assistant `message.id` appears,
|
||||
* indicating the start of a new CC turn. We flush the accumulated lines
|
||||
* for the previous step before starting the new one.
|
||||
*
|
||||
* On stdin EOF (CC process exits), we flush the remaining buffer.
|
||||
*/
|
||||
export function registerIngestCommand(program: Command) {
|
||||
program
|
||||
.command('ingest')
|
||||
.description(
|
||||
'Pipe Claude Code stream-json stdout to LobeHub, persisting structured messages per step',
|
||||
)
|
||||
.requiredOption('--topic-id <id>', 'Target topic ID')
|
||||
.option('--agent-id <id>', 'Agent ID')
|
||||
.option('--json', 'Output JSON results')
|
||||
.action(async (options: { agentId?: string; json?: boolean; topicId: string }) => {
|
||||
log.debug('ingest: topicId=%s, agentId=%s', options.topicId, options.agentId);
|
||||
|
||||
const client = await getTrpcClient();
|
||||
const rl = createInterface({ input: process.stdin });
|
||||
|
||||
let buffer: any[] = [];
|
||||
let currentMessageId: string | undefined;
|
||||
let stepCount = 0;
|
||||
|
||||
const flush = async (lines: any[]) => {
|
||||
if (lines.length === 0) return;
|
||||
stepCount++;
|
||||
|
||||
try {
|
||||
const result = await (client as any).cloudClaudeCode.ingest.mutate({
|
||||
agentId: options.agentId,
|
||||
lines,
|
||||
topicId: options.topicId,
|
||||
});
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify({ step: stepCount, ...result }));
|
||||
} else {
|
||||
const toolInfo =
|
||||
result.toolMessageIds?.length > 0 ? ` + ${result.toolMessageIds.length} tool(s)` : '';
|
||||
console.error(
|
||||
`${pc.green('↑')} Step ${stepCount}: ${pc.bold(result.assistantMessageId || 'no-msg')}${toolInfo}`,
|
||||
);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(`${pc.red('✗')} Step ${stepCount} failed: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
for await (const raw of rl) {
|
||||
let line: any;
|
||||
try {
|
||||
line = JSON.parse(raw);
|
||||
} catch {
|
||||
// Skip non-JSON lines (stderr leaks, etc.)
|
||||
continue;
|
||||
}
|
||||
|
||||
// Detect step boundary: assistant message.id change
|
||||
if (line.type === 'assistant' && line.message?.id) {
|
||||
if (currentMessageId && line.message.id !== currentMessageId) {
|
||||
// New message.id → previous step is complete → flush
|
||||
const prevStepLines = buffer;
|
||||
buffer = [line];
|
||||
await flush(prevStepLines);
|
||||
} else {
|
||||
buffer.push(line);
|
||||
}
|
||||
currentMessageId = line.message.id;
|
||||
} else {
|
||||
buffer.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
// stdin EOF → CC finished → flush remaining
|
||||
await flush(buffer);
|
||||
|
||||
if (!options.json) {
|
||||
console.error(
|
||||
`${pc.green('✓')} Done: ${stepCount} step(s) ingested to topic ${pc.bold(options.topicId)}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { registerDocCommand } from './commands/doc';
|
||||
import { registerEvalCommand } from './commands/eval';
|
||||
import { registerFileCommand } from './commands/file';
|
||||
import { registerGenerateCommand } from './commands/generate';
|
||||
import { registerIngestCommand } from './commands/ingest';
|
||||
import { registerKbCommand } from './commands/kb';
|
||||
import { registerLoginCommand } from './commands/login';
|
||||
import { registerLogoutCommand } from './commands/logout';
|
||||
@@ -61,6 +62,7 @@ export function createProgram() {
|
||||
registerBotCommand(program);
|
||||
registerCronCommand(program);
|
||||
registerGenerateCommand(program);
|
||||
registerIngestCommand(program);
|
||||
registerFileCommand(program);
|
||||
registerSkillCommand(program);
|
||||
registerSessionGroupCommand(program);
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
"cookie": "^1.1.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"diff": "^8.0.4",
|
||||
"electron": "41.1.0",
|
||||
"electron": "41.3.0",
|
||||
"electron-builder": "^26.8.1",
|
||||
"electron-devtools-installer": "4.0.0",
|
||||
"electron-is": "^3.0.0",
|
||||
|
||||
@@ -155,6 +155,9 @@ export default class NotificationCtr extends ControllerModule {
|
||||
const mainWindow = this.app.browserManager.getMainWindow();
|
||||
mainWindow.show();
|
||||
mainWindow.browserWindow.focus();
|
||||
if (params.navigate?.path) {
|
||||
mainWindow.broadcast('navigate', params.navigate);
|
||||
}
|
||||
});
|
||||
|
||||
notification.on('close', () => {
|
||||
|
||||
@@ -123,6 +123,26 @@ By connecting a Discord channel to your LobeHub agent, users can interact with t
|
||||
|
||||
Back in LobeHub's channel settings for Discord, click **Test Connection** to verify everything is configured correctly. Then send a message to your bot in Discord to confirm it responds.
|
||||
|
||||
## Step 5: Set Your Platform Identity (Recommended)
|
||||
|
||||
Two optional fields under **Advanced Settings** carry a lot of weight in day-to-day use — fill them in once and most surprises go away.
|
||||
|
||||
### Your Platform User ID
|
||||
|
||||
This is your own Discord user ID, used by:
|
||||
|
||||
- **Pairing approval** — required when **DM Policy** is set to **Pairing**, since `/approve <code>` is the owner's command and the runtime checks the sender against this ID.
|
||||
- **AI tools push** — lets the agent reach you proactively (reminders, notifications) by mapping its internal user reference to your Discord account.
|
||||
- **Anti-lockout** — auto-trusted by **Allowed Users**, so scoping the bot to friends won't accidentally lock you out.
|
||||
|
||||
To get it: in Discord, open **User Settings → Advanced** and turn on **Developer Mode**. Then right-click your own username anywhere in Discord and choose **Copy User ID**. Paste the numeric ID into **Your Platform User ID** in LobeHub's Advanced Settings.
|
||||
|
||||
### Default Server
|
||||
|
||||
The Discord guild ID the bot's AI tools should default to when you ask it to "list channels", "send to #announcements", or anything else that needs a server context without naming one explicitly. Doesn't affect access control — that's **Group Policy**'s job.
|
||||
|
||||
To get it: with **Developer Mode** on, right-click the server name in your server list and choose **Copy Server ID**. Paste it into **Default Server** in LobeHub's Advanced Settings.
|
||||
|
||||
## Access Policies
|
||||
|
||||
LobeHub gates inbound traffic with three layered settings, all under **Advanced Settings** and all defaulting to permissive.
|
||||
@@ -139,9 +159,10 @@ Controls 1:1 direct messages.
|
||||
|
||||
- **Open (default)** — Anyone who shares a server with the bot can DM it (subject to the global allowlist when set).
|
||||
- **Allowlist** — DMs require the sender to be in **Allowed User IDs**. Differs from `Open` only when the list is empty: `Allowlist` fails closed (no DMs), `Open` still lets anyone DM.
|
||||
- **Pairing** — Same gate as `Allowlist`, but a non-listed sender receives a one-time pairing code instead of a flat rejection. Approve via `/approve <code>` and the applicant is auto-appended to **Allowed User IDs**. Requires **Your Platform User ID** to be set (the runtime checks the `/approve` sender against it) and a configured Redis backend.
|
||||
- **Disabled** — The bot ignores all DMs. Senders get a one-line notice pointing them at @mentioning the bot in a shared channel instead.
|
||||
|
||||
> Discord bots can be reached by anyone in any shared server, so consider populating **Allowed User IDs** or switching DM Policy to **Disabled** if your bot is meant to be private.
|
||||
> Discord bots can be reached by anyone in any shared server, so consider populating **Allowed User IDs**, switching DM Policy to **Pairing** for self-service approval, or **Disabled** if your bot is meant to be private.
|
||||
|
||||
### Group Policy
|
||||
|
||||
@@ -163,7 +184,7 @@ See the [Channels overview](/docs/usage/channels/overview#direct-message-policy)
|
||||
| **Bot Token** | Yes | Authentication token for your Discord bot |
|
||||
| **Public Key** | Yes | Used to verify interaction requests from Discord |
|
||||
| **Allowed User IDs** | No | Comma- or whitespace-separated Discord user IDs. Global gate — applies to DMs and group @mentions |
|
||||
| **DM Policy** | No | `open` (default), `allowlist`, or `disabled` — who is allowed to DM the bot |
|
||||
| **DM Policy** | No | `open` (default), `allowlist`, `pairing`, or `disabled` — who is allowed to DM the bot |
|
||||
| **Group Policy** | No | `open` (default), `allowlist`, or `disabled` — where the bot responds to @mentions |
|
||||
| **Allowed Channel IDs** | No | Comma- or whitespace-separated Discord channel IDs. Used when Group Policy is Allowlist |
|
||||
|
||||
|
||||
@@ -122,6 +122,26 @@ tags:
|
||||
|
||||
返回 LobeHub 的 Discord 渠道设置,点击 **测试连接** 以验证配置是否正确。然后在 Discord 中向您的机器人发送消息,确认其是否响应。
|
||||
|
||||
## 第五步:填写你的平台身份(推荐)
|
||||
|
||||
**高级设置**里有两个可选字段,影响着日常使用体验,建议一开始就填好。
|
||||
|
||||
### 你的平台用户 ID
|
||||
|
||||
也就是你自己的 Discord 用户 ID,用于:
|
||||
|
||||
- **配对审批** — 当 **私信策略** 为 **配对审批** 时为必填项,`/approve <code>` 是属主命令,runtime 会用这个 ID 校验发起人。
|
||||
- **AI 工具主动推送** — 让 Agent 能主动联系你(提醒、通知),把内部用户引用映射到你的 Discord 账号。
|
||||
- **防自锁** — 自动被 **允许的用户** 信任,给好友收紧 bot 时不会把自己挡在外面。
|
||||
|
||||
获取方式:在 Discord 中打开 **用户设置 → 高级**,启用 **开发者模式**。然后在任意位置右键你自己的用户名,选 **复制用户 ID**。把数字 ID 粘贴到 LobeHub 高级设置的 **你的平台用户 ID** 字段。
|
||||
|
||||
### 默认服务器
|
||||
|
||||
Discord 的 guild ID。当你让 bot 做 "列出频道"、"发送到 #announcements" 这类需要服务器上下文但没指明哪台的事时,AI 工具会默认用这个 server。和访问控制无关 —— 那是 **群组策略** 的活。
|
||||
|
||||
获取方式:在 **开发者模式** 已开启的情况下,在服务器列表中右键服务器名,选 **复制服务器 ID**。粘贴到 LobeHub 高级设置的 **默认服务器** 字段。
|
||||
|
||||
## 接入策略
|
||||
|
||||
LobeHub 通过三层叠加配置控制入站消息,全部位于 **高级设置**,默认都为宽松。
|
||||
@@ -138,9 +158,10 @@ LobeHub 通过三层叠加配置控制入站消息,全部位于 **高级设置
|
||||
|
||||
- **开放 (Open)(默认)** — 任何与机器人共享服务器的用户都可以私信(若设置了全局白名单则受其约束)。
|
||||
- **白名单 (Allowlist)** — 私信需要发送者在 **允许的用户 ID** 里。和 `Open` 的差别在白名单为空时:`Allowlist` 模式**全部拒绝**,`Open` 模式仍然放任何人私信。
|
||||
- **配对审批 (Pairing)** — 与 `Allowlist` 共享同一份名单,但非名单用户被拒后会收到一次性配对码,由你(属主)通过 `/approve <code>` 审批。审批通过的用户会被自动追加到 **允许的用户 ID**,后续 DM 直通。需先填 **你的平台用户 ID**(runtime 用它校验 `/approve` 发起人),并需要部署 Redis。
|
||||
- **禁用 (Disabled)** — 机器人忽略所有私信,发起方会收到一条提示,引导其在共享频道里 @机器人。
|
||||
|
||||
> Discord 机器人可被任意共享服务器的用户私信,如果你的机器人是私有用途,建议填入 **允许的用户 ID** 或将私信策略切到 **禁用**。
|
||||
> Discord 机器人可被任意共享服务器的用户私信,如果你的机器人是私有用途,建议填入 **允许的用户 ID**、把私信策略切到 **配对审批** 让陌生人走自助申请通道,或干脆设为 **禁用**。
|
||||
|
||||
### 群组策略
|
||||
|
||||
@@ -156,15 +177,15 @@ LobeHub 通过三层叠加配置控制入站消息,全部位于 **高级设置
|
||||
|
||||
## 配置参考
|
||||
|
||||
| 字段 | 是否必需 | 描述 |
|
||||
| ------------ | ---- | -------------------------------------------------- |
|
||||
| **应用程序 ID** | 是 | 您的 Discord 应用程序的 ID |
|
||||
| **机器人令牌** | 是 | 您的 Discord 机器人的认证令牌 |
|
||||
| **公钥** | 是 | 用于验证来自 Discord 的交互请求 |
|
||||
| **允许的用户 ID** | 否 | 逗号或空格分隔的 Discord 用户 ID。全局闸门 — 私信和群聊 @ 都受其约束 |
|
||||
| **私信策略** | 否 | `open`(默认)、`allowlist` 或 `disabled` — 控制谁可以私信机器人 |
|
||||
| **群组策略** | 否 | `open`(默认)、`allowlist` 或 `disabled` — 控制机器人在哪些频道响应 |
|
||||
| **允许的频道 ID** | 否 | 逗号或空格分隔的 Discord 频道 ID。仅在群组策略为白名单时使用 |
|
||||
| 字段 | 是否必需 | 描述 |
|
||||
| ------------ | ---- | ---------------------------------------------------------- |
|
||||
| **应用程序 ID** | 是 | 您的 Discord 应用程序的 ID |
|
||||
| **机器人令牌** | 是 | 您的 Discord 机器人的认证令牌 |
|
||||
| **公钥** | 是 | 用于验证来自 Discord 的交互请求 |
|
||||
| **允许的用户 ID** | 否 | 逗号或空格分隔的 Discord 用户 ID。全局闸门 — 私信和群聊 @ 都受其约束 |
|
||||
| **私信策略** | 否 | `open`(默认)、`allowlist`、`pairing` 或 `disabled` — 控制谁可以私信机器人 |
|
||||
| **群组策略** | 否 | `open`(默认)、`allowlist` 或 `disabled` — 控制机器人在哪些频道响应 |
|
||||
| **允许的频道 ID** | 否 | 逗号或空格分隔的 Discord 频道 ID。仅在群组策略为白名单时使用 |
|
||||
|
||||
## 故障排除
|
||||
|
||||
|
||||
@@ -174,6 +174,22 @@ By connecting a Feishu channel to your LobeHub agent, team members can interact
|
||||
|
||||
Back in LobeHub's channel settings, click **Test Connection** to verify the credentials. Then find your bot in Feishu by searching its name and send it a message to confirm it responds.
|
||||
|
||||
## Step 7: Set Your Platform Identity (Recommended)
|
||||
|
||||
One optional field under **Advanced Settings** carries a lot of weight in day-to-day use — fill it in once and most surprises go away.
|
||||
|
||||
### Your Platform User ID
|
||||
|
||||
This is your own Feishu `open_id` (the per-app, per-user identifier — **not** the same as your Feishu mobile number or email), used by:
|
||||
|
||||
- **Pairing approval** — required when **DM Policy** is set to **Pairing**, since `/approve <code>` is the owner's command and the runtime checks the sender against this ID.
|
||||
- **AI tools push** — lets the agent reach you proactively (reminders, notifications) by mapping its internal user reference to your Feishu account.
|
||||
- **Anti-lockout** — auto-trusted by **Allowed Users**, so scoping the bot to teammates won't accidentally lock you out.
|
||||
|
||||
To get it: DM the bot once and inspect the inbound event payload — the `open_id` field on the sender is yours. The Feishu Developer Portal also exposes a **User ID** lookup that maps mobile/email to `open_id`. Paste it into **Your Platform User ID** in LobeHub's Advanced Settings.
|
||||
|
||||
> Feishu doesn't expose a single "default server" concept that AI tools can pivot on (the bot operates per-tenant via credentials), so the **Default Server** field is not exposed for Feishu channels.
|
||||
|
||||
## Access Policies
|
||||
|
||||
Two independent policies gate inbound traffic. Both default to **Open**.
|
||||
@@ -186,6 +202,7 @@ A populated **Allowed User IDs** field is a global gate — DMs *and* group `@me
|
||||
|
||||
- **Open (default)** — Any tenant member can DM the bot (subject to the global allowlist when set).
|
||||
- **Allowlist** — DMs require the sender to be in **Allowed User IDs**. Differs from `Open` only when the list is empty: `Allowlist` then fails closed (no DMs).
|
||||
- **Pairing** — Same gate as `Allowlist`, but a non-listed sender receives a one-time pairing code instead of a flat rejection. Approve via `/approve <code>` and the applicant is auto-appended to **Allowed User IDs**. Requires **Your Platform User ID** to be set (the runtime checks the `/approve` sender against it) and a configured Redis backend.
|
||||
- **Disabled** — The bot ignores all DMs and only responds to chat-group `@mentions`.
|
||||
|
||||
### Group Policy
|
||||
@@ -208,7 +225,7 @@ See the [Channels overview](/docs/usage/channels/overview#direct-message-policy)
|
||||
| **Encrypt Key** | No | Decrypts encrypted event payloads |
|
||||
| **Event Subscription URL** | — | Auto-generated after saving; paste into Feishu Developer Portal |
|
||||
| **Allowed User IDs** | No | Comma- or whitespace-separated Feishu `open_id` values. Global gate — applies to DMs and group @mentions |
|
||||
| **DM Policy** | No | `open` (default), `allowlist`, or `disabled` — who is allowed to DM the bot |
|
||||
| **DM Policy** | No | `open` (default), `allowlist`, `pairing`, or `disabled` — who is allowed to DM the bot |
|
||||
| **Group Policy** | No | `open` (default), `allowlist`, or `disabled` — where the bot responds to @mentions |
|
||||
| **Allowed Channel IDs** | No | Comma- or whitespace-separated Feishu `chat_id` values. Used when Group Policy is Allowlist |
|
||||
|
||||
|
||||
@@ -170,6 +170,22 @@ tags:
|
||||
|
||||
回到 LobeHub 的渠道设置,点击 **测试连接** 以验证凭证。然后在飞书中搜索您的机器人名称并发送消息,确认其是否响应。
|
||||
|
||||
## 第七步:填写你的平台身份(推荐)
|
||||
|
||||
**高级设置**里有一个可选字段影响日常使用体验,建议一开始就填好。
|
||||
|
||||
### 你的平台用户 ID
|
||||
|
||||
也就是你自己的飞书 `open_id`(按应用、按用户隔离的标识符 ——**不是**手机号或邮箱),用于:
|
||||
|
||||
- **配对审批** — 当 **私信策略** 为 **配对审批** 时为必填项,`/approve <code>` 是属主命令,runtime 会用这个 ID 校验发起人。
|
||||
- **AI 工具主动推送** — 让 Agent 能主动联系你(提醒、通知),把内部用户引用映射到你的飞书账号。
|
||||
- **防自锁** — 自动被 **允许的用户** 信任,给同事收紧 bot 时不会把自己挡在外面。
|
||||
|
||||
获取方式:先用任意消息私信 bot 一次,查看入站事件 payload 中发送方的 `open_id` 字段,那就是你的。飞书开发者后台也提供 **User ID 查询** 工具,用手机号 / 邮箱反查 `open_id`。粘贴到 LobeHub 高级设置的 **你的平台用户 ID** 字段。
|
||||
|
||||
> 飞书没有一个 AI 工具能默认指向的 "默认服务器" 概念(bot 通过凭证按租户运行),因此飞书渠道不展示 **默认服务器** 字段。
|
||||
|
||||
## 接入策略
|
||||
|
||||
两个独立的策略控制入站消息,默认都为 **开放**。
|
||||
@@ -182,6 +198,7 @@ tags:
|
||||
|
||||
- **开放 (Open)(默认)** — 租户内任何成员都可以私信机器人(若设置了全局白名单则受其约束)。
|
||||
- **白名单 (Allowlist)** — 私信需要发送者在 **允许的用户 ID** 里。和 `Open` 的差别在白名单为空时:`Allowlist` 模式**全部拒绝**。
|
||||
- **配对审批 (Pairing)** — 与 `Allowlist` 共享同一份名单,但非名单用户被拒后会收到一次性配对码,由你(属主)通过 `/approve <code>` 审批。审批通过的用户会被自动追加到 **允许的用户 ID**,后续 DM 直通。需先填 **你的平台用户 ID**(runtime 用它校验 `/approve` 发起人),并需要部署 Redis。
|
||||
- **禁用 (Disabled)** — 机器人忽略所有私信,只在群聊里被 `@提及` 时回复。
|
||||
|
||||
### 群组策略
|
||||
@@ -196,17 +213,17 @@ tags:
|
||||
|
||||
## 配置参考
|
||||
|
||||
| 字段 | 是否必需 | 描述 |
|
||||
| ---------------------- | ---- | -------------------------------------------------- |
|
||||
| **应用 ID** | 是 | 您的飞书应用的应用 ID(`cli_xxx`) |
|
||||
| **应用密钥** | 是 | 您的飞书应用的应用密钥 |
|
||||
| **Verification Token** | 否 | 验证 webhook 事件来源(推荐) |
|
||||
| **Encrypt Key** | 否 | 解密加密事件负载 |
|
||||
| **事件订阅 URL** | — | 保存后自动生成;粘贴到飞书开发者门户 |
|
||||
| **允许的用户 ID** | 否 | 逗号或空格分隔的飞书 `open_id`。全局闸门 — 私信和群聊 @ 都受其约束 |
|
||||
| **私信策略** | 否 | `open`(默认)、`allowlist` 或 `disabled` — 控制谁可以私信机器人 |
|
||||
| **群组策略** | 否 | `open`(默认)、`allowlist` 或 `disabled` — 控制机器人在哪些群中响应 |
|
||||
| **允许的频道 ID** | 否 | 逗号或空格分隔的飞书 `chat_id`。仅在群组策略为白名单时使用 |
|
||||
| 字段 | 是否必需 | 描述 |
|
||||
| ---------------------- | ---- | ---------------------------------------------------------- |
|
||||
| **应用 ID** | 是 | 您的飞书应用的应用 ID(`cli_xxx`) |
|
||||
| **应用密钥** | 是 | 您的飞书应用的应用密钥 |
|
||||
| **Verification Token** | 否 | 验证 webhook 事件来源(推荐) |
|
||||
| **Encrypt Key** | 否 | 解密加密事件负载 |
|
||||
| **事件订阅 URL** | — | 保存后自动生成;粘贴到飞书开发者门户 |
|
||||
| **允许的用户 ID** | 否 | 逗号或空格分隔的飞书 `open_id`。全局闸门 — 私信和群聊 @ 都受其约束 |
|
||||
| **私信策略** | 否 | `open`(默认)、`allowlist`、`pairing` 或 `disabled` — 控制谁可以私信机器人 |
|
||||
| **群组策略** | 否 | `open`(默认)、`allowlist` 或 `disabled` — 控制机器人在哪些群中响应 |
|
||||
| **允许的频道 ID** | 否 | 逗号或空格分隔的飞书 `chat_id`。仅在群组策略为白名单时使用 |
|
||||
|
||||
## 故障排除
|
||||
|
||||
|
||||
@@ -165,6 +165,22 @@ By connecting a Lark channel to your LobeHub agent, team members can interact wi
|
||||
|
||||
Back in LobeHub's channel settings, click **Test Connection** to verify the credentials. Then find your bot in Lark by searching its name and send it a message to confirm it responds.
|
||||
|
||||
## Step 7: Set Your Platform Identity (Recommended)
|
||||
|
||||
One optional field under **Advanced Settings** carries a lot of weight in day-to-day use — fill it in once and most surprises go away.
|
||||
|
||||
### Your Platform User ID
|
||||
|
||||
This is your own Lark `open_id` (the per-app, per-user identifier — **not** the same as your Lark mobile number or email), used by:
|
||||
|
||||
- **Pairing approval** — required when **DM Policy** is set to **Pairing**, since `/approve <code>` is the owner's command and the runtime checks the sender against this ID.
|
||||
- **AI tools push** — lets the agent reach you proactively (reminders, notifications) by mapping its internal user reference to your Lark account.
|
||||
- **Anti-lockout** — auto-trusted by **Allowed Users**, so scoping the bot to teammates won't accidentally lock you out.
|
||||
|
||||
To get it: DM the bot once and inspect the inbound event payload — the `open_id` field on the sender is yours. The Lark Developer Portal also exposes a **User ID** lookup that maps mobile/email to `open_id`. Paste it into **Your Platform User ID** in LobeHub's Advanced Settings.
|
||||
|
||||
> Lark doesn't expose a single "default server" concept that AI tools can pivot on (the bot operates per-tenant via credentials), so the **Default Server** field is not exposed for Lark channels.
|
||||
|
||||
## Access Policies
|
||||
|
||||
Two independent policies gate inbound traffic. Both default to **Open**.
|
||||
@@ -177,6 +193,7 @@ A populated **Allowed User IDs** field is a global gate — DMs *and* group `@me
|
||||
|
||||
- **Open (default)** — Any tenant member can DM the bot (subject to the global allowlist when set).
|
||||
- **Allowlist** — DMs require the sender to be in **Allowed User IDs**. Differs from `Open` only when the list is empty: `Allowlist` then fails closed (no DMs).
|
||||
- **Pairing** — Same gate as `Allowlist`, but a non-listed sender receives a one-time pairing code instead of a flat rejection. Approve via `/approve <code>` and the applicant is auto-appended to **Allowed User IDs**. Requires **Your Platform User ID** to be set (the runtime checks the `/approve` sender against it) and a configured Redis backend.
|
||||
- **Disabled** — The bot ignores all DMs and only responds to chat-group `@mentions`.
|
||||
|
||||
### Group Policy
|
||||
@@ -199,7 +216,7 @@ See the [Channels overview](/docs/usage/channels/overview#direct-message-policy)
|
||||
| **Encrypt Key** | No | Decrypts encrypted event payloads |
|
||||
| **Event Subscription URL** | — | Auto-generated after saving; paste into Lark Developer Portal |
|
||||
| **Allowed User IDs** | No | Comma- or whitespace-separated Lark `open_id` values. Global gate — applies to DMs and group @mentions |
|
||||
| **DM Policy** | No | `open` (default), `allowlist`, or `disabled` — who is allowed to DM the bot |
|
||||
| **DM Policy** | No | `open` (default), `allowlist`, `pairing`, or `disabled` — who is allowed to DM the bot |
|
||||
| **Group Policy** | No | `open` (default), `allowlist`, or `disabled` — where the bot responds to @mentions |
|
||||
| **Allowed Channel IDs** | No | Comma- or whitespace-separated Lark `chat_id` values. Used when Group Policy is Allowlist |
|
||||
|
||||
|
||||
@@ -162,6 +162,22 @@ tags:
|
||||
|
||||
回到 LobeHub 的渠道设置,点击 **Test Connection** 以验证凭证。然后在 Lark 中搜索您的机器人名称并发送消息,确认其是否响应。
|
||||
|
||||
## 第七步:填写你的平台身份(推荐)
|
||||
|
||||
**高级设置**里有一个可选字段影响日常使用体验,建议一开始就填好。
|
||||
|
||||
### 你的平台用户 ID
|
||||
|
||||
也就是你自己的 Lark `open_id`(按应用、按用户隔离的标识符 ——**不是**手机号或邮箱),用于:
|
||||
|
||||
- **配对审批** — 当 **私信策略** 为 **配对审批** 时为必填项,`/approve <code>` 是属主命令,runtime 会用这个 ID 校验发起人。
|
||||
- **AI 工具主动推送** — 让 Agent 能主动联系你(提醒、通知),把内部用户引用映射到你的 Lark 账号。
|
||||
- **防自锁** — 自动被 **允许的用户** 信任,给同事收紧 bot 时不会把自己挡在外面。
|
||||
|
||||
获取方式:先用任意消息私信 bot 一次,查看入站事件 payload 中发送方的 `open_id` 字段,那就是你的。Lark 开发者后台也提供 **User ID 查询** 工具,用手机号 / 邮箱反查 `open_id`。粘贴到 LobeHub 高级设置的 **你的平台用户 ID** 字段。
|
||||
|
||||
> Lark 没有一个 AI 工具能默认指向的 "默认服务器" 概念(bot 通过凭证按租户运行),因此 Lark 渠道不展示 **默认服务器** 字段。
|
||||
|
||||
## 接入策略
|
||||
|
||||
两个独立的策略控制入站消息,默认都为 **开放**。
|
||||
@@ -174,6 +190,7 @@ tags:
|
||||
|
||||
- **开放 (Open)(默认)** — 租户内任何成员都可以私信机器人(若设置了全局白名单则受其约束)。
|
||||
- **白名单 (Allowlist)** — 私信需要发送者在 **允许的用户 ID** 里。和 `Open` 的差别在白名单为空时:`Allowlist` 模式**全部拒绝**。
|
||||
- **配对审批 (Pairing)** — 与 `Allowlist` 共享同一份名单,但非名单用户被拒后会收到一次性配对码,由你(属主)通过 `/approve <code>` 审批。审批通过的用户会被自动追加到 **允许的用户 ID**,后续 DM 直通。需先填 **你的平台用户 ID**(runtime 用它校验 `/approve` 发起人),并需要部署 Redis。
|
||||
- **禁用 (Disabled)** — 机器人忽略所有私信,只在群聊里被 `@提及` 时回复。
|
||||
|
||||
### 群组策略
|
||||
@@ -188,17 +205,17 @@ tags:
|
||||
|
||||
## 配置参考
|
||||
|
||||
| 字段 | 是否必需 | 描述 |
|
||||
| -------------------------- | ---- | -------------------------------------------------- |
|
||||
| **App ID** | 是 | 您的 Lark 应用的 App ID(`cli_xxx`) |
|
||||
| **App Secret** | 是 | 您的 Lark 应用的 App Secret |
|
||||
| **Verification Token** | 否 | 验证 webhook 事件来源(推荐) |
|
||||
| **Encrypt Key** | 否 | 解密加密事件负载 |
|
||||
| **Event Subscription URL** | — | 保存后自动生成;粘贴到 Lark 开发者门户 |
|
||||
| **允许的用户 ID** | 否 | 逗号或空格分隔的 Lark `open_id`。全局闸门 — 私信和群聊 @ 都受其约束 |
|
||||
| **私信策略** | 否 | `open`(默认)、`allowlist` 或 `disabled` — 控制谁可以私信机器人 |
|
||||
| **群组策略** | 否 | `open`(默认)、`allowlist` 或 `disabled` — 控制机器人在哪些群中响应 |
|
||||
| **允许的频道 ID** | 否 | 逗号或空格分隔的 Lark `chat_id`。仅在群组策略为白名单时使用 |
|
||||
| 字段 | 是否必需 | 描述 |
|
||||
| -------------------------- | ---- | ---------------------------------------------------------- |
|
||||
| **App ID** | 是 | 您的 Lark 应用的 App ID(`cli_xxx`) |
|
||||
| **App Secret** | 是 | 您的 Lark 应用的 App Secret |
|
||||
| **Verification Token** | 否 | 验证 webhook 事件来源(推荐) |
|
||||
| **Encrypt Key** | 否 | 解密加密事件负载 |
|
||||
| **Event Subscription URL** | — | 保存后自动生成;粘贴到 Lark 开发者门户 |
|
||||
| **允许的用户 ID** | 否 | 逗号或空格分隔的 Lark `open_id`。全局闸门 — 私信和群聊 @ 都受其约束 |
|
||||
| **私信策略** | 否 | `open`(默认)、`allowlist`、`pairing` 或 `disabled` — 控制谁可以私信机器人 |
|
||||
| **群组策略** | 否 | `open`(默认)、`allowlist` 或 `disabled` — 控制机器人在哪些群中响应 |
|
||||
| **允许的频道 ID** | 否 | 逗号或空格分隔的 Lark `chat_id`。仅在群组策略为白名单时使用 |
|
||||
|
||||
## 故障排除
|
||||
|
||||
|
||||
@@ -90,11 +90,12 @@ Add one entry per row. Each row holds a platform user ID (required) and an optio
|
||||
|
||||
DM Policy only governs DMs — group `@mentions` are gated independently by **Group Policy** below. The user-level filter from the global **Allowed Users** is also applied; per-scope policy stacks on top.
|
||||
|
||||
| Policy | Behavior |
|
||||
| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Open** | Any user on the platform can DM the bot (subject to the global allowlist when set). Best for public-facing assistants. |
|
||||
| **Allowlist** | DMs require the sender to be in **Allowed Users**. Distinct from `Open` only when the list is empty: `Allowlist` then **fails closed** (no DMs); `Open` still lets anyone DM. |
|
||||
| **Disabled** | The bot ignores all DMs entirely. Use this when the bot should only reply in shared channels via `@mention`. |
|
||||
| Policy | Behavior |
|
||||
| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Open** | Any user on the platform can DM the bot (subject to the global allowlist when set). Best for public-facing assistants. |
|
||||
| **Allowlist** | DMs require the sender to be in **Allowed Users**. Distinct from `Open` only when the list is empty: `Allowlist` then **fails closed** (no DMs); `Open` still lets anyone DM. |
|
||||
| **Pairing** | Same gate as `Allowlist`, but a non-listed sender receives a one-time pairing code instead of a flat rejection. The owner approves via `/approve <code>`, which appends the applicant to **Allowed Users** so future DMs flow normally. Requires **Your Platform User ID** and a configured Redis. |
|
||||
| **Disabled** | The bot ignores all DMs entirely. Use this when the bot should only reply in shared channels via `@mention`. |
|
||||
|
||||
## Group Policy
|
||||
|
||||
|
||||
@@ -89,11 +89,12 @@ tags:
|
||||
|
||||
私信策略只影响私信 — 群聊里的 `@提及` 由下面的 **群组策略** 单独管理。全局 **允许的用户** 的用户级过滤也会同时生效;各 scope 的策略叠加在上面。
|
||||
|
||||
| 策略 | 行为 |
|
||||
| ------------------- | -------------------------------------------------------------------------------------- |
|
||||
| **开放 (Open)** | 平台上的任何用户都可以私信机器人(如设置了全局白名单则受其约束)。适合面向所有人开放的助手。 |
|
||||
| **白名单 (Allowlist)** | 私信需要发送者在 **允许的用户** 里。和 `Open` 的差别在白名单为空时:`Allowlist` 模式下会**全部拒绝**,而 `Open` 模式下任何人都能私信。 |
|
||||
| **禁用 (Disabled)** | 机器人会忽略所有私信。适合那种 " 只在群里被 `@` 时才回复 " 的场景。 |
|
||||
| 策略 | 行为 |
|
||||
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **开放 (Open)** | 平台上的任何用户都可以私信机器人(如设置了全局白名单则受其约束)。适合面向所有人开放的助手。 |
|
||||
| **白名单 (Allowlist)** | 私信需要发送者在 **允许的用户** 里。和 `Open` 的差别在白名单为空时:`Allowlist` 模式下会**全部拒绝**,而 `Open` 模式下任何人都能私信。 |
|
||||
| **配对审批 (Pairing)** | 与 `Allowlist` 共享同一份名单,但非名单用户被拒后会收到一次性配对码,由你(属主)通过 `/approve <code>` 审批。审批通过的用户会被自动追加到 **允许的用户**,后续 DM 直通。需先填 **你的平台用户 ID** 并部署 Redis。 |
|
||||
| **禁用 (Disabled)** | 机器人会忽略所有私信。适合那种 " 只在群里被 `@` 时才回复 " 的场景。 |
|
||||
|
||||
## 群组策略
|
||||
|
||||
|
||||
@@ -132,6 +132,22 @@ LobeHub supports two connection modes for QQ bots:
|
||||
|
||||
Click **Test Connection** in LobeHub's channel settings to verify the integration. Then open QQ, find your bot, and send a message. The bot should respond through your LobeHub agent.
|
||||
|
||||
## Set Your Platform Identity (Recommended)
|
||||
|
||||
One optional field under **Advanced Settings** carries a lot of weight in day-to-day use — fill it in once and most surprises go away.
|
||||
|
||||
### Your Platform User ID
|
||||
|
||||
This is your own QQ `tiny_id` (the platform-level user identifier — **not** the public-facing QQ number, which doesn't always match), used by:
|
||||
|
||||
- **Pairing approval** — required when **DM Policy** is set to **Pairing**, since `/approve <code>` is the owner's command and the runtime checks the sender against this ID.
|
||||
- **AI tools push** — lets the agent reach you proactively (reminders, notifications) by mapping its internal user reference to your QQ account.
|
||||
- **Anti-lockout** — auto-trusted by **Allowed Users**, so scoping the bot to friends won't accidentally lock you out.
|
||||
|
||||
To get it: DM the bot once with any message and check the server logs for the `tiny_id` field on the inbound event payload (or read it from the OpenAPI dashboard if available). Paste the long numeric ID into **Your Platform User ID** in LobeHub's Advanced Settings.
|
||||
|
||||
> QQ doesn't expose a single "default server" concept that AI tools can pivot on, so the **Default Server** field is not exposed for QQ channels.
|
||||
|
||||
## Adding the Bot to Group Chats
|
||||
|
||||
To use the bot in QQ groups:
|
||||
@@ -152,6 +168,7 @@ A populated **Allowed User IDs** field is a global gate — DMs *and* group `@me
|
||||
|
||||
- **Open (default)** — Any QQ user who shares context with the bot can DM it (subject to the global allowlist when set).
|
||||
- **Allowlist** — DMs require the sender to be in **Allowed User IDs**. Differs from `Open` only when the list is empty: `Allowlist` then fails closed (no DMs).
|
||||
- **Pairing** — Same gate as `Allowlist`, but a non-listed sender receives a one-time pairing code instead of a flat rejection. Approve via `/approve <code>` and the applicant is auto-appended to **Allowed User IDs**. Requires **Your Platform User ID** to be set (the runtime checks the `/approve` sender against it) and a configured Redis backend.
|
||||
- **Disabled** — The bot ignores all DMs and only responds to group `@mentions`.
|
||||
|
||||
### Group Policy
|
||||
@@ -172,7 +189,7 @@ See the [Channels overview](/docs/usage/channels/overview#direct-message-policy)
|
||||
| **App Secret** | Yes | Your bot's App Secret from QQ Open Platform |
|
||||
| **Connection Mode** | No | `websocket` (default) or `webhook`. Choose based on your QQ Open Platform configuration |
|
||||
| **Allowed User IDs** | No | Comma- or whitespace-separated QQ `tiny_id` values. Global gate — applies to DMs and group @mentions |
|
||||
| **DM Policy** | No | `open` (default), `allowlist`, or `disabled` — who is allowed to DM the bot |
|
||||
| **DM Policy** | No | `open` (default), `allowlist`, `pairing`, or `disabled` — who is allowed to DM the bot |
|
||||
| **Group Policy** | No | `open` (default), `allowlist`, or `disabled` — where the bot responds to @mentions |
|
||||
| **Allowed Channel IDs** | No | Comma- or whitespace-separated QQ group IDs. Used when Group Policy is Allowlist |
|
||||
|
||||
|
||||
@@ -129,6 +129,22 @@ LobeHub ��持两种 QQ 机器人连接模式:
|
||||
|
||||
在 LobeHub 的渠道设置中点击 **测试连接** 以验证集成。然后打开 QQ,找到您的机器人并发送消息。机器人应通过您的 LobeHub 代理进行响应。
|
||||
|
||||
## 填写你的平台身份(推荐)
|
||||
|
||||
**高级设置**里有一个可选字段影响日常使用体验,建议一开始就填好。
|
||||
|
||||
### 你的平台用户 ID
|
||||
|
||||
也就是你自己的 QQ `tiny_id`(平台级用户标识符 ——**不是**对外可见的 QQ 号,两者不一定一致),用于:
|
||||
|
||||
- **配对审批** — 当 **私信策略** 为 **配对审批** 时为必填项,`/approve <code>` 是属主命令,runtime 会用这个 ID 校验发起人。
|
||||
- **AI 工具主动推送** — 让 Agent 能主动联系你(提醒、通知),把内部用户引用映射到你的 QQ 账号。
|
||||
- **防自锁** — 自动被 **允许的用户** 信任,给好友收紧 bot 时不会把自己挡在外面。
|
||||
|
||||
获取方式:先用任意消息私信 bot 一次,然后在 server log 里查看入站事件 payload 中的 `tiny_id` 字段(或在 OpenAPI 控制台读取,如果有)。把那串长数字 ID 粘贴到 LobeHub 高级设置的 **你的平台用户 ID** 字段。
|
||||
|
||||
> QQ 没有一个稳定的 "默认服务器" 概念可让 AI 工具默认指向,因此 QQ 渠道不展示 **默认服务器** 字段。
|
||||
|
||||
## 将机器人添加到群聊
|
||||
|
||||
要在 QQ 群聊中使用机器人:
|
||||
@@ -149,6 +165,7 @@ LobeHub ��持两种 QQ 机器人连接模式:
|
||||
|
||||
- **开放 (Open)(默认)** — 任何与机器人有上下文交集的 QQ 用户都可以私信(若设置了全局白名单则受其约束)。
|
||||
- **白名单 (Allowlist)** — 私信需要发送者在 **允许的用户 ID** 里。和 `Open` 的差别在白名单为空时:`Allowlist` 模式**全部拒绝**。
|
||||
- **配对审批 (Pairing)** — 与 `Allowlist` 共享同一份名单,但非名单用户被拒后会收到一次性配对码,由你(属主)通过 `/approve <code>` 审批。审批通过的用户会被自动追加到 **允许的用户 ID**,后续 DM 直通。需先填 **你的平台用户 ID**(runtime 用它校验 `/approve` 发起人),并需要部署 Redis。
|
||||
- **禁用 (Disabled)** — 机器人忽略所有私信,只在群聊里被 `@提及` 时回复。
|
||||
|
||||
### 群组策略
|
||||
@@ -163,15 +180,15 @@ LobeHub ��持两种 QQ 机器人连接模式:
|
||||
|
||||
## 配置参考
|
||||
|
||||
| 字段 | 是否必需 | 描述 |
|
||||
| -------------- | ---- | -------------------------------------------------- |
|
||||
| **应用 ID** | 是 | 来自 QQ 开放平台的 App ID |
|
||||
| **App Secret** | 是 | 来自 QQ 开放平台的 App Secret |
|
||||
| **连接模式** | 否 | `websocket`(默认)或 `webhook`,根据 QQ 开放平台配置选择 |
|
||||
| **允许的用户 ID** | 否 | 逗号或空格分隔的 QQ `tiny_id`。全局闸门 — 私信和群聊 @ 都受其约束 |
|
||||
| **私信策略** | 否 | `open`(默认)、`allowlist` 或 `disabled` — 控制谁可以私信机器人 |
|
||||
| **群组策略** | 否 | `open`(默认)、`allowlist` 或 `disabled` — 控制机器人在哪些群中响应 |
|
||||
| **允许的频道 ID** | 否 | 逗号或空格分隔的 QQ 群 ID。仅在群组策略为白名单时使用 |
|
||||
| 字段 | 是否必需 | 描述 |
|
||||
| -------------- | ---- | ---------------------------------------------------------- |
|
||||
| **应用 ID** | 是 | 来自 QQ 开放平台的 App ID |
|
||||
| **App Secret** | 是 | 来自 QQ 开放平台的 App Secret |
|
||||
| **连接模式** | 否 | `websocket`(默认)或 `webhook`,根据 QQ 开放平台配置选择 |
|
||||
| **允许的用户 ID** | 否 | 逗号或空格分隔的 QQ `tiny_id`。全局闸门 — 私信和群聊 @ 都受其约束 |
|
||||
| **私信策略** | 否 | `open`(默认)、`allowlist`、`pairing` 或 `disabled` — 控制谁可以私信机器人 |
|
||||
| **群组策略** | 否 | `open`(默认)、`allowlist` 或 `disabled` — 控制机器人在哪些群中响应 |
|
||||
| **允许的频道 ID** | 否 | 逗号或空格分隔的 QQ 群 ID。仅在群组策略为白名单时使用 |
|
||||
|
||||
## 功能限制
|
||||
|
||||
|
||||
@@ -213,6 +213,26 @@ Use this method if your Slack app already has Event Subscriptions configured wit
|
||||
Also ensure you add the `commands` scope under **OAuth & Permissions** → **Bot Token Scopes**, and enable **Interactivity & Shortcuts** with the same Webhook URL as the Request URL.
|
||||
</Steps>
|
||||
|
||||
## Set Your Platform Identity (Recommended)
|
||||
|
||||
Two optional fields under **Advanced Settings** carry a lot of weight in day-to-day use — fill them in once and most surprises go away.
|
||||
|
||||
### Your Platform User ID
|
||||
|
||||
This is your own Slack member ID, used by:
|
||||
|
||||
- **Pairing approval** — required when **DM Policy** is set to **Pairing**, since `/approve <code>` is the owner's command and the runtime checks the sender against this ID.
|
||||
- **AI tools push** — lets the agent reach you proactively (reminders, notifications) by mapping its internal user reference to your Slack account.
|
||||
- **Anti-lockout** — auto-trusted by **Allowed Users**, so scoping the bot to teammates won't accidentally lock you out.
|
||||
|
||||
To get it: in Slack, click your avatar → **Profile**, then click the `⋮` overflow menu and choose **Copy member ID**. Member IDs start with `U`. Paste it into **Your Platform User ID** in LobeHub's Advanced Settings.
|
||||
|
||||
### Default Server
|
||||
|
||||
The Slack workspace (team) ID the bot's AI tools should default to when you ask it to "list channels", "send to #announcements", or anything else that needs a workspace context without naming one explicitly. Doesn't affect access control — that's **Group Policy**'s job.
|
||||
|
||||
To get it: open Slack in the browser; the URL contains the team ID (`https://app.slack.com/client/T01ABCDEF/...`) — copy the part starting with `T`. Paste it into **Default Server** in LobeHub's Advanced Settings.
|
||||
|
||||
## Access Policies
|
||||
|
||||
Two independent policies gate inbound traffic. Both default to **Open**.
|
||||
@@ -225,6 +245,7 @@ A populated **Allowed User IDs** field is a global gate — DMs *and* channel `@
|
||||
|
||||
- **Open (default)** — Any workspace member can DM the bot (subject to the global allowlist when set).
|
||||
- **Allowlist** — DMs require the sender to be in **Allowed User IDs**. Differs from `Open` only when the list is empty: `Allowlist` then fails closed (no DMs).
|
||||
- **Pairing** — Same gate as `Allowlist`, but a non-listed sender receives a one-time pairing code instead of a flat rejection. Approve via `/approve <code>` and the applicant is auto-appended to **Allowed User IDs**. Requires **Your Platform User ID** to be set (the runtime checks the `/approve` sender against it) and a configured Redis backend.
|
||||
- **Disabled** — The bot ignores all DMs and only replies to channel `@mentions`.
|
||||
|
||||
### Group Policy
|
||||
@@ -247,7 +268,7 @@ See the [Channels overview](/docs/usage/channels/overview#direct-message-policy)
|
||||
| **App-Level Token** | Socket Mode only | App-level token (`xapp-...`) for WebSocket connection |
|
||||
| **Connection Mode** | No | `websocket` or `webhook` (default: `webhook`) |
|
||||
| **Allowed User IDs** | No | Comma- or whitespace-separated Slack member IDs. Global gate — applies to DMs and channel @mentions |
|
||||
| **DM Policy** | No | `open` (default), `allowlist`, or `disabled` — who is allowed to DM the bot |
|
||||
| **DM Policy** | No | `open` (default), `allowlist`, `pairing`, or `disabled` — who is allowed to DM the bot |
|
||||
| **Group Policy** | No | `open` (default), `allowlist`, or `disabled` — where the bot responds to @mentions |
|
||||
| **Allowed Channel IDs** | No | Comma- or whitespace-separated Slack channel IDs (start with `C`). Used when Group Policy is Allowlist |
|
||||
|
||||
|
||||
@@ -210,6 +210,26 @@ LobeHub 支持两种 Slack 连接模式:
|
||||
同时确保在 **OAuth & Permissions** → **Bot Token Scopes** 中添加 `commands` 权限,并在 **Interactivity & Shortcuts** 中启用 Interactivity,将 Request URL 设为相同的 Webhook URL。
|
||||
</Steps>
|
||||
|
||||
## 填写你的平台身份(推荐)
|
||||
|
||||
**高级设置**里有两个可选字段,影响着日常使用体验,建议一开始就填好。
|
||||
|
||||
### 你的平台用户 ID
|
||||
|
||||
也就是你自己的 Slack member ID,用于:
|
||||
|
||||
- **配对审批** — 当 **私信策略** 为 **配对审批** 时为必填项,`/approve <code>` 是属主命令,runtime 会用这个 ID 校验发起人。
|
||||
- **AI 工具主动推送** — 让 Agent 能主动联系你(提醒、通知),把内部用户引用映射到你的 Slack 账号。
|
||||
- **防自锁** — 自动被 **允许的用户** 信任,给同事收紧 bot 时不会把自己挡在外面。
|
||||
|
||||
获取方式:在 Slack 中点击你的头像 → **个人资料**,点击 `⋮` 溢出菜单,选 **复制 member ID**。member ID 以 `U` 开头。粘贴到 LobeHub 高级设置的 **你的平台用户 ID** 字段。
|
||||
|
||||
### 默认服务器
|
||||
|
||||
Slack workspace(team)ID。当你让 bot 做 "列出频道"、"发送到 #announcements" 这类需要 workspace 上下文但没指明哪个的事时,AI 工具会默认用这个。和访问控制无关 —— 那是 **群组策略** 的活。
|
||||
|
||||
获取方式:用浏览器打开 Slack,URL 里就有 team ID(`https://app.slack.com/client/T01ABCDEF/...`)—— 复制以 `T` 开头那段。粘贴到 LobeHub 高级设置的 **默认服务器** 字段。
|
||||
|
||||
## 接入策略
|
||||
|
||||
两个独立的策略控制入站消息,默认都为 **开放**。
|
||||
@@ -222,6 +242,7 @@ LobeHub 支持两种 Slack 连接模式:
|
||||
|
||||
- **开放 (Open)(默认)** — workspace 内任何成员都可以私信机器人(若设置了全局白名单则受其约束)。
|
||||
- **白名单 (Allowlist)** — 私信需要发送者在 **允许的用户 ID** 里。和 `Open` 的差别在白名单为空时:`Allowlist` 模式**全部拒绝**。
|
||||
- **配对审批 (Pairing)** — 与 `Allowlist` 共享同一份名单,但非名单用户被拒后会收到一次性配对码,由你(属主)通过 `/approve <code>` 审批。审批通过的用户会被自动追加到 **允许的用户 ID**,后续 DM 直通。需先填 **你的平台用户 ID**(runtime 用它校验 `/approve` 发起人),并需要部署 Redis。
|
||||
- **禁用 (Disabled)** — 机器人忽略所有私信,只在频道里被 `@提及` 时回复。
|
||||
|
||||
### 群组策略
|
||||
@@ -236,17 +257,17 @@ LobeHub 支持两种 Slack 连接模式:
|
||||
|
||||
## 配置参考
|
||||
|
||||
| 字段 | 是否必需 | 描述 |
|
||||
| -------------- | ------------- | --------------------------------------------------- |
|
||||
| **应用 ID** | 是 | 您的 Slack 应用 ID |
|
||||
| **Bot Token** | 是 | Bot User OAuth Token(`xoxb-...`) |
|
||||
| **签名密钥** | 是 | 用于验证来自 Slack 的请求 |
|
||||
| **应用级别 Token** | 仅 Socket Mode | 应用级别 Token(`xapp-...`),用于 WebSocket 连接 |
|
||||
| **连接模式** | 否 | `websocket` 或 `webhook`(默认:`webhook`) |
|
||||
| **允许的用户 ID** | 否 | 逗号或空格分隔的 Slack 成员 ID。全局闸门 — 私信和频道 @ 都受其约束 |
|
||||
| **私信策略** | 否 | `open`(默认)、`allowlist` 或 `disabled` — 控制谁可以私信机器人 |
|
||||
| **群组策略** | 否 | `open`(默认)、`allowlist` 或 `disabled` — 控制机器人在哪些频道中响应 |
|
||||
| **允许的频道 ID** | 否 | 逗号或空格分隔的 Slack 频道 ID(以 `C` 开头)。仅在群组策略为白名单时使用 |
|
||||
| 字段 | 是否必需 | 描述 |
|
||||
| -------------- | ------------- | ---------------------------------------------------------- |
|
||||
| **应用 ID** | 是 | 您的 Slack 应用 ID |
|
||||
| **Bot Token** | 是 | Bot User OAuth Token(`xoxb-...`) |
|
||||
| **签名密钥** | 是 | 用于验证来自 Slack 的请求 |
|
||||
| **应用级别 Token** | 仅 Socket Mode | 应用级别 Token(`xapp-...`),用于 WebSocket 连接 |
|
||||
| **连接模式** | 否 | `websocket` 或 `webhook`(默认:`webhook`) |
|
||||
| **允许的用户 ID** | 否 | 逗号或空格分隔的 Slack 成员 ID。全局闸门 — 私信和频道 @ 都受其约束 |
|
||||
| **私信策略** | 否 | `open`(默认)、`allowlist`、`pairing` 或 `disabled` — 控制谁可以私信机器人 |
|
||||
| **群组策略** | 否 | `open`(默认)、`allowlist` 或 `disabled` — 控制机器人在哪些频道中响应 |
|
||||
| **允许的频道 ID** | 否 | 逗号或空格分隔的 Slack 频道 ID(以 `C` 开头)。仅在群组策略为白名单时使用 |
|
||||
|
||||
## 故障排除
|
||||
|
||||
|
||||
@@ -79,6 +79,22 @@ Click **Test Connection** in LobeHub's channel settings to verify the integratio
|
||||
|
||||

|
||||
|
||||
## Set Your Platform Identity (Recommended)
|
||||
|
||||
One optional field under **Advanced Settings** carries a lot of weight in day-to-day use — fill it in once and most surprises go away.
|
||||
|
||||
### Your Platform User ID
|
||||
|
||||
This is your own Telegram numeric user ID, used by:
|
||||
|
||||
- **Pairing approval** — required when **DM Policy** is set to **Pairing**, since `/approve <code>` is the owner's command and the runtime checks the sender against this ID.
|
||||
- **AI tools push** — lets the agent reach you proactively (reminders, notifications) by mapping its internal user reference to your Telegram account.
|
||||
- **Anti-lockout** — auto-trusted by **Allowed Users**, so scoping the bot to friends won't accidentally lock you out.
|
||||
|
||||
To get it: open Telegram, message [@userinfobot](https://t.me/userinfobot), and it will reply with your numeric user ID. Paste it into **Your Platform User ID** in LobeHub's Advanced Settings.
|
||||
|
||||
> Telegram doesn't have a "default server" concept (each chat is its own surface), so the **Default Server** field is not exposed for Telegram channels.
|
||||
|
||||
## Adding the Bot to Group Chats
|
||||
|
||||
To use the bot in Telegram groups:
|
||||
@@ -105,6 +121,7 @@ A populated **Allowed User IDs** field acts as a global gate — DMs *and* group
|
||||
|
||||
- **Open (default)** — Anyone on Telegram can DM the bot (subject to the global allowlist when set).
|
||||
- **Allowlist** — DMs require the sender to be in **Allowed User IDs**. Differs from `Open` only when the list is empty: `Allowlist` then fails closed (no DMs).
|
||||
- **Pairing** — Same gate as `Allowlist`, but a non-listed sender receives a one-time pairing code instead of a flat rejection. Approve via `/approve <code>` and the applicant is auto-appended to **Allowed User IDs**. Requires **Your Platform User ID** to be set (the runtime checks the `/approve` sender against it) and a configured Redis backend.
|
||||
- **Disabled** — The bot ignores all DMs and only responds to group `@mentions`.
|
||||
|
||||
### Group Policy
|
||||
@@ -125,7 +142,7 @@ See the [Channels overview](/docs/usage/channels/overview#direct-message-policy)
|
||||
| **Bot User ID** | Auto | Automatically derived from the bot token |
|
||||
| **Webhook Secret Token** | No | Optional secret for verifying webhook requests |
|
||||
| **Allowed User IDs** | No | Comma- or whitespace-separated Telegram numeric user IDs. Global gate — applies to DMs and group @mentions |
|
||||
| **DM Policy** | No | `open` (default), `allowlist`, or `disabled` — who is allowed to DM the bot |
|
||||
| **DM Policy** | No | `open` (default), `allowlist`, `pairing`, or `disabled` — who is allowed to DM the bot |
|
||||
| **Group Policy** | No | `open` (default), `allowlist`, or `disabled` — where the bot responds in groups |
|
||||
| **Allowed Channel IDs** | No | Comma- or whitespace-separated Telegram chat IDs (group IDs are negative). Used when Group Policy is Allowlist |
|
||||
|
||||
|
||||
@@ -78,6 +78,22 @@ tags:
|
||||
|
||||

|
||||
|
||||
## 填写你的平台身份(推荐)
|
||||
|
||||
**高级设置**里有一个可选字段影响日常使用体验,建议一开始就填好。
|
||||
|
||||
### 你的平台用户 ID
|
||||
|
||||
也就是你自己的 Telegram 数字用户 ID,用于:
|
||||
|
||||
- **配对审批** — 当 **私信策略** 为 **配对审批** 时为必填项,`/approve <code>` 是属主命令,runtime 会用这个 ID 校验发起人。
|
||||
- **AI 工具主动推送** — 让 Agent 能主动联系你(提醒、通知),把内部用户引用映射到你的 Telegram 账号。
|
||||
- **防自锁** — 自动被 **允许的用户** 信任,给好友收紧 bot 时不会把自己挡在外面。
|
||||
|
||||
获取方式:打开 Telegram,私信 [@userinfobot](https://t.me/userinfobot),它会把你的数字用户 ID 回给你。粘贴到 LobeHub 高级设置的 **你的平台用户 ID** 字段。
|
||||
|
||||
> Telegram 没有 "默认服务器" 的概念(每个会话各自独立),因此 Telegram 渠道不展示 **默认服务器** 字段。
|
||||
|
||||
## 将机器人添加到群组聊天
|
||||
|
||||
要在 Telegram 群组中使用机器人:
|
||||
@@ -104,6 +120,7 @@ tags:
|
||||
|
||||
- **开放 (Open)(默认)** — Telegram 上任何用户都可以私信机器人(若设置了全局白名单则受其约束)。
|
||||
- **白名单 (Allowlist)** — 私信需要发送者在 **允许的用户 ID** 里。和 `Open` 的差别在白名单为空时:`Allowlist` 模式**全部拒绝**。
|
||||
- **配对审批 (Pairing)** — 与 `Allowlist` 共享同一份名单,但非名单用户被拒后会收到一次性配对码,由你(属主)通过 `/approve <code>` 审批。审批通过的用户会被自动追加到 **允许的用户 ID**,后续 DM 直通。需先填 **你的平台用户 ID**(runtime 用它校验 `/approve` 发起人),并需要部署 Redis。
|
||||
- **禁用 (Disabled)** — 机器人忽略所有私信,只在群组里被 `@提及` 时回复。
|
||||
|
||||
### 群组策略
|
||||
@@ -118,15 +135,15 @@ tags:
|
||||
|
||||
## 配置参考
|
||||
|
||||
| 字段 | 是否必需 | 描述 |
|
||||
| ---------------- | ---- | --------------------------------------------------- |
|
||||
| **机器人令牌** | 是 | 来自 BotFather 的 API 令牌 |
|
||||
| **机器人用户 ID** | 自动 | 根据机器人令牌自动生成 |
|
||||
| **Webhook 密钥令牌** | 否 | 用于验证 Webhook 请求的可选密钥 |
|
||||
| **允许的用户 ID** | 否 | 逗号或空格分隔的 Telegram 数字用户 ID。全局闸门 — 私信和群聊 @ 都受其约束 |
|
||||
| **私信策略** | 否 | `open`(默认)、`allowlist` 或 `disabled` — 控制谁可以私信机器人 |
|
||||
| **群组策略** | 否 | `open`(默认)、`allowlist` 或 `disabled` — 控制机器人在哪些群组中响应 |
|
||||
| **允许的频道 ID** | 否 | 逗号或空格分隔的 Telegram chat ID(群组为负数)。仅在群组策略为白名单时使用 |
|
||||
| 字段 | 是否必需 | 描述 |
|
||||
| ---------------- | ---- | ---------------------------------------------------------- |
|
||||
| **机器人令牌** | 是 | 来自 BotFather 的 API 令牌 |
|
||||
| **机器人用户 ID** | 自动 | 根据机器人令牌自动生成 |
|
||||
| **Webhook 密钥令牌** | 否 | 用于验证 Webhook 请求的可选密钥 |
|
||||
| **允许的用户 ID** | 否 | 逗号或空格分隔的 Telegram 数字用户 ID。全局闸门 — 私信和群聊 @ 都受其约束 |
|
||||
| **私信策略** | 否 | `open`(默认)、`allowlist`、`pairing` 或 `disabled` — 控制谁可以私信机器人 |
|
||||
| **群组策略** | 否 | `open`(默认)、`allowlist` 或 `disabled` — 控制机器人在哪些群组中响应 |
|
||||
| **允许的频道 ID** | 否 | 逗号或空格分隔的 Telegram chat ID(群组为负数)。仅在群组策略为白名单时使用 |
|
||||
|
||||
## 故障排除
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Then, When } from '@cucumber/cucumber';
|
||||
import { expect } from '@playwright/test';
|
||||
|
||||
import { CustomWorld } from '../../support/world';
|
||||
import type { CustomWorld } from '../../support/world';
|
||||
|
||||
// ============================================
|
||||
// When Steps (Actions)
|
||||
@@ -143,7 +143,9 @@ When('I wait for the next page to load', async function (this: CustomWorld) {
|
||||
When('I click on the first assistant card', async function (this: CustomWorld) {
|
||||
await this.page.waitForLoadState('networkidle', { timeout: 30_000 });
|
||||
|
||||
const firstCard = this.page.locator('[data-testid="assistant-item"]').first();
|
||||
const firstCard = this.page
|
||||
.locator('[data-testid="assistant-item"][data-agent-type="agent"]')
|
||||
.first();
|
||||
await firstCard.waitFor({ state: 'visible', timeout: 30_000 });
|
||||
|
||||
// Store the current URL before clicking
|
||||
|
||||
@@ -23,8 +23,10 @@
|
||||
"channel.charLimitHint": "Maximum number of characters per message",
|
||||
"channel.concurrency": "Concurrency Mode",
|
||||
"channel.concurrencyDebounce": "Debounce",
|
||||
"channel.concurrencyHint": "Queue processes messages one at a time; Debounce waits for a burst of messages to finish before processing",
|
||||
"channel.concurrencyDebounceHint": "Only process the last message in a burst (earlier ones are dropped)",
|
||||
"channel.concurrencyHint": "How concurrent messages are batched",
|
||||
"channel.concurrencyQueue": "Queue",
|
||||
"channel.concurrencyQueueHint": "Process messages one at a time",
|
||||
"channel.connectFailed": "Bot connection failed",
|
||||
"channel.connectQueued": "Bot connection queued. It will start shortly.",
|
||||
"channel.connectStarting": "Bot is starting. Please wait a moment.",
|
||||
@@ -32,9 +34,11 @@
|
||||
"channel.connecting": "Connecting...",
|
||||
"channel.connectionConfig": "Connection Configuration",
|
||||
"channel.connectionMode": "Connection Mode",
|
||||
"channel.connectionModeHint": "WebSocket is recommended for new bots. Use Webhook if your bot already has a callback URL configured.",
|
||||
"channel.connectionModeHint": "How the platform delivers events to the bot",
|
||||
"channel.connectionModeWebSocket": "WebSocket",
|
||||
"channel.connectionModeWebSocketHint": "Recommended for new bots",
|
||||
"channel.connectionModeWebhook": "Webhook",
|
||||
"channel.connectionModeWebhookHint": "Use if your bot has a callback URL configured",
|
||||
"channel.copied": "Copied to clipboard",
|
||||
"channel.copy": "Copy",
|
||||
"channel.credentials": "Credentials",
|
||||
@@ -56,9 +60,14 @@
|
||||
"channel.dm": "Direct Messages",
|
||||
"channel.dmPolicy": "DM Policy",
|
||||
"channel.dmPolicyAllowlist": "Allowlist",
|
||||
"channel.dmPolicyAllowlistHint": "Only listed users can DM the bot",
|
||||
"channel.dmPolicyDisabled": "Disabled",
|
||||
"channel.dmPolicyHint": "Control who can send direct messages to the bot",
|
||||
"channel.dmPolicyDisabledHint": "Reject all DMs",
|
||||
"channel.dmPolicyHint": "Who can DM the bot",
|
||||
"channel.dmPolicyOpen": "Open",
|
||||
"channel.dmPolicyOpenHint": "Accept DMs from anyone",
|
||||
"channel.dmPolicyPairing": "Pairing",
|
||||
"channel.dmPolicyPairingHint": "Strangers need /approve to DM",
|
||||
"channel.documentation": "Documentation",
|
||||
"channel.enabled": "Enabled",
|
||||
"channel.encryptKey": "Encrypt Key",
|
||||
@@ -80,9 +89,12 @@
|
||||
"channel.groupAllowFromNamePlaceholder": "e.g. #general (your reminder)",
|
||||
"channel.groupPolicy": "Group Policy",
|
||||
"channel.groupPolicyAllowlist": "Allowlist",
|
||||
"channel.groupPolicyAllowlistHint": "Only respond in listed channels",
|
||||
"channel.groupPolicyDisabled": "Disabled",
|
||||
"channel.groupPolicyHint": "Control where the bot responds in groups, channels, and threads",
|
||||
"channel.groupPolicyDisabledHint": "Ignore all group messages",
|
||||
"channel.groupPolicyHint": "Where the bot responds in groups, channels, and threads",
|
||||
"channel.groupPolicyOpen": "Open",
|
||||
"channel.groupPolicyOpenHint": "Respond in any group, channel, or thread",
|
||||
"channel.historyLimit": "History Message Limit",
|
||||
"channel.historyLimitHint": "Default number of messages to fetch when reading channel history",
|
||||
"channel.importConfig": "Import Configuration",
|
||||
@@ -111,8 +123,8 @@
|
||||
"channel.secretToken": "Webhook Secret Token",
|
||||
"channel.secretTokenHint": "Optional. Used to verify webhook requests from Telegram.",
|
||||
"channel.secretTokenPlaceholder": "Optional secret for webhook verification",
|
||||
"channel.serverId": "Default Server (for AI tools)",
|
||||
"channel.serverIdHint": "The server / guild ID AI tools should default to when you ask the bot to act on a server (e.g. 'list channels', 'send to #announcements'). Independent of access control — see Group Policy for that.",
|
||||
"channel.serverId": "Default Server ID",
|
||||
"channel.serverIdHint": "Default server / guild AI tools act on; not used for access control",
|
||||
"channel.settings": "Advanced Settings",
|
||||
"channel.settingsResetConfirm": "Are you sure you want to reset advanced settings to default?",
|
||||
"channel.settingsResetDefault": "Reset to Default",
|
||||
@@ -138,8 +150,8 @@
|
||||
"channel.testFailed": "Connection test failed",
|
||||
"channel.testSuccess": "Connection test passed",
|
||||
"channel.updateFailed": "Failed to update status",
|
||||
"channel.userId": "Your Platform User ID (for AI tools)",
|
||||
"channel.userIdHint": "AI tools use this to reach you proactively (e.g. reminders, notifications); also auto-trusted by the global allowlist.",
|
||||
"channel.userId": "Your Platform User ID",
|
||||
"channel.userIdHint": "Lets AI tools reach you proactively (e.g. reminders); auto-trusted by the global allowlist",
|
||||
"channel.validationError": "Please fill in Application ID and Token",
|
||||
"channel.verificationToken": "Verification Token",
|
||||
"channel.verificationTokenHint": "Optional. Used to verify webhook event source.",
|
||||
|
||||
@@ -68,6 +68,25 @@
|
||||
"cliRateLimitGuide.resetAt": "Resets at",
|
||||
"cliRateLimitGuide.resetInApprox": "Resets in about {{duration}}",
|
||||
"cliRateLimitGuide.title": "{{name}} usage limit reached",
|
||||
"cloudClaudeCodeSetup.actions.cancel": "Cancel",
|
||||
"cloudClaudeCodeSetup.actions.confirm": "Continue",
|
||||
"cloudClaudeCodeSetup.desc": "Before creating the agent, complete the Claude Code token and GitHub authorization setup.",
|
||||
"cloudClaudeCodeSetup.errors.githubRequired": "Connect GitHub before continuing.",
|
||||
"cloudClaudeCodeSetup.errors.refresh": "Failed to refresh the current authorization status.",
|
||||
"cloudClaudeCodeSetup.errors.submit": "Failed to save the Cloud Claude Code prerequisites.",
|
||||
"cloudClaudeCodeSetup.errors.tokenRequired": "Paste your CLAUDE_CODE_OAUTH_TOKEN first.",
|
||||
"cloudClaudeCodeSetup.github.authorized": "GitHub authorization is already available. We will create the injectable credential automatically when you continue.",
|
||||
"cloudClaudeCodeSetup.github.connected": "Detected an available GitHub credential.",
|
||||
"cloudClaudeCodeSetup.github.desc": "Claude Code will use your GitHub identity to access repositories and write code for you.",
|
||||
"cloudClaudeCodeSetup.github.footer": "After GitHub authorization succeeds, continue creating the agent.",
|
||||
"cloudClaudeCodeSetup.github.title": "GitHub Authorization",
|
||||
"cloudClaudeCodeSetup.title": "Enable Cloud Claude Code",
|
||||
"cloudClaudeCodeSetup.token.commandPrefix": "Run this inside your Claude Code session",
|
||||
"cloudClaudeCodeSetup.token.connected": "Detected an existing CLAUDE_CODE_OAUTH_TOKEN and will reuse it.",
|
||||
"cloudClaudeCodeSetup.token.desc": "This token lets Cloud Claude Code run on your behalf.",
|
||||
"cloudClaudeCodeSetup.token.hint": "If you do not have the token yet, get the long-lived credential from your own Claude Code first.",
|
||||
"cloudClaudeCodeSetup.token.placeholder": "Paste CLAUDE_CODE_OAUTH_TOKEN",
|
||||
"cloudClaudeCodeSetup.token.title": "Claude Code Token",
|
||||
"codexInstallGuide.actions.openDocs": "Open Install Guide",
|
||||
"codexInstallGuide.actions.openSystemTools": "Open System Tools",
|
||||
"codexInstallGuide.afterInstall": "After installing, run Codex once to sign in, then retry your message or click Re-detect in System Tools.",
|
||||
@@ -294,6 +313,7 @@
|
||||
"minimap.senderUser": "You",
|
||||
"newAgent": "Create Agent",
|
||||
"newClaudeCodeAgent": "Add Claude Code",
|
||||
"newCloudClaudeCode": "Add Cloud Claude Code",
|
||||
"newCodexAgent": "Add Codex",
|
||||
"newGroupChat": "Create Group",
|
||||
"newPage": "Create Page",
|
||||
@@ -570,12 +590,18 @@
|
||||
"taskList.view.list": "List",
|
||||
"taskList.viewAll": "View all",
|
||||
"taskSchedule.clear": "Clear",
|
||||
"taskSchedule.continuous": "Continuous",
|
||||
"taskSchedule.enable": "Enable automation",
|
||||
"taskSchedule.every": "Every",
|
||||
"taskSchedule.frequency": "Frequency",
|
||||
"taskSchedule.hours": "Hours",
|
||||
"taskSchedule.interval": "Recurring",
|
||||
"taskSchedule.intervalTab": "Recurring",
|
||||
"taskSchedule.maxExecutions": "Max runs",
|
||||
"taskSchedule.minutes": "Minutes",
|
||||
"taskSchedule.scheduleType.daily": "Daily",
|
||||
"taskSchedule.scheduleType.hourly": "Hourly",
|
||||
"taskSchedule.scheduleType.weekly": "Weekly",
|
||||
"taskSchedule.scheduler": "Scheduler",
|
||||
"taskSchedule.schedulerNotReady": "Scheduler is coming soon. Use Recurring for now.",
|
||||
"taskSchedule.schedulerTab": "Scheduler",
|
||||
@@ -584,6 +610,8 @@
|
||||
"taskSchedule.tag.every": "Every {{interval}}",
|
||||
"taskSchedule.tag.heartbeat": "Heartbeat · {{every}}",
|
||||
"taskSchedule.tag.schedule": "Schedule · {{schedule}}{{timezone}}",
|
||||
"taskSchedule.time": "Time",
|
||||
"taskSchedule.timezone": "Timezone",
|
||||
"taskSchedule.title": "Schedule",
|
||||
"taskSchedule.unit.hour_one": "{{count}} hr",
|
||||
"taskSchedule.unit.hour_other": "{{count}} hrs",
|
||||
@@ -591,6 +619,14 @@
|
||||
"taskSchedule.unit.minute_other": "{{count}} mins",
|
||||
"taskSchedule.unit.second_one": "{{count}} sec",
|
||||
"taskSchedule.unit.second_other": "{{count}} secs",
|
||||
"taskSchedule.weekday": "Weekday",
|
||||
"taskSchedule.weekdays.fri": "Fri",
|
||||
"taskSchedule.weekdays.mon": "Mon",
|
||||
"taskSchedule.weekdays.sat": "Sat",
|
||||
"taskSchedule.weekdays.sun": "Sun",
|
||||
"taskSchedule.weekdays.thu": "Thu",
|
||||
"taskSchedule.weekdays.tue": "Tue",
|
||||
"taskSchedule.weekdays.wed": "Wed",
|
||||
"thread.closeSubagentThread": "Collapse subagent conversation",
|
||||
"thread.divider": "Subtopic",
|
||||
"thread.openSubagentThread": "View full subagent conversation",
|
||||
|
||||
@@ -624,6 +624,8 @@
|
||||
"user.logout": "Logout",
|
||||
"user.myProfile": "My Profile",
|
||||
"user.noAgents": "This user hasn’t published any Agents yet",
|
||||
"user.noAgents.ownerDescription": "Create your first Agent and share it with the Community.",
|
||||
"user.noAgents.title": "No Agents yet",
|
||||
"user.noFavoriteAgents": "No saved Agents yet",
|
||||
"user.noFavoritePlugins": "No saved Skills yet",
|
||||
"user.noForkedAgentGroups": "No forked Agent Groups yet",
|
||||
|
||||
@@ -5,12 +5,14 @@
|
||||
"agentSelection.search": "No matching agents found",
|
||||
"brief.action.acknowledge": "Acknowledge",
|
||||
"brief.action.approve": "Approve",
|
||||
"brief.action.confirmDone": "Confirm complete",
|
||||
"brief.action.feedback": "Feedback",
|
||||
"brief.action.retry": "Retry",
|
||||
"brief.addFeedback": "Share feedback",
|
||||
"brief.collapse": "Show less",
|
||||
"brief.commentPlaceholder": "Share your feedback...",
|
||||
"brief.commentSubmit": "Submit feedback",
|
||||
"brief.editResult": "Edit",
|
||||
"brief.expandAll": "Show more",
|
||||
"brief.feedbackSent": "Feedback shared",
|
||||
"brief.resolved": "Marked as resolved",
|
||||
|
||||
@@ -23,8 +23,10 @@
|
||||
"channel.charLimitHint": "单条消息的最大字符数",
|
||||
"channel.concurrency": "并发模式",
|
||||
"channel.concurrencyDebounce": "防抖",
|
||||
"channel.concurrencyHint": "队列模式逐条处理消息;防抖模式等待连续消息发送完毕后再统一处理",
|
||||
"channel.concurrencyDebounceHint": "仅处理连续消息中的最后一条,前序消息会被丢弃",
|
||||
"channel.concurrencyHint": "并发消息的处理方式",
|
||||
"channel.concurrencyQueue": "队列",
|
||||
"channel.concurrencyQueueHint": "逐条处理消息",
|
||||
"channel.connectFailed": "Bot 连接失败",
|
||||
"channel.connectQueued": "机器人连接已排队。即将启动。",
|
||||
"channel.connectStarting": "机器人正在启动。请稍候。",
|
||||
@@ -32,9 +34,11 @@
|
||||
"channel.connecting": "连接中...",
|
||||
"channel.connectionConfig": "连接配置",
|
||||
"channel.connectionMode": "连接模式",
|
||||
"channel.connectionModeHint": "新机器人推荐使用 WebSocket。如果你的机器人已配置了回调地址,请选择 Webhook。",
|
||||
"channel.connectionModeHint": "平台向机器人推送事件的方式",
|
||||
"channel.connectionModeWebSocket": "WebSocket",
|
||||
"channel.connectionModeWebSocketHint": "推荐用于新机器人",
|
||||
"channel.connectionModeWebhook": "Webhook",
|
||||
"channel.connectionModeWebhookHint": "已配置回调地址时选用",
|
||||
"channel.copied": "已复制到剪贴板",
|
||||
"channel.copy": "复制",
|
||||
"channel.credentials": "凭证配置",
|
||||
@@ -56,9 +60,14 @@
|
||||
"channel.dm": "私信",
|
||||
"channel.dmPolicy": "私信策略",
|
||||
"channel.dmPolicyAllowlist": "白名单",
|
||||
"channel.dmPolicyAllowlistHint": "仅允许名单内的用户发送私信",
|
||||
"channel.dmPolicyDisabled": "禁用",
|
||||
"channel.dmPolicyHint": "控制谁可以向机器人发送私信",
|
||||
"channel.dmPolicyDisabledHint": "拒绝所有私信",
|
||||
"channel.dmPolicyHint": "谁可以向机器人发送私信",
|
||||
"channel.dmPolicyOpen": "开放",
|
||||
"channel.dmPolicyOpenHint": "接受任何人发送的私信",
|
||||
"channel.dmPolicyPairing": "配对审批",
|
||||
"channel.dmPolicyPairingHint": "陌生人需经 /approve 审批后才能私信",
|
||||
"channel.documentation": "文档",
|
||||
"channel.enabled": "已启用",
|
||||
"channel.encryptKey": "Encrypt Key",
|
||||
@@ -80,9 +89,12 @@
|
||||
"channel.groupAllowFromNamePlaceholder": "如:#general(仅你自己可见)",
|
||||
"channel.groupPolicy": "群组策略",
|
||||
"channel.groupPolicyAllowlist": "白名单",
|
||||
"channel.groupPolicyAllowlistHint": "仅在名单内的频道中响应",
|
||||
"channel.groupPolicyDisabled": "禁用",
|
||||
"channel.groupPolicyHint": "控制机器人在群组、频道、子话题里的响应范围",
|
||||
"channel.groupPolicyDisabledHint": "忽略所有群组消息",
|
||||
"channel.groupPolicyHint": "机器人在群组、频道、子话题中的响应范围",
|
||||
"channel.groupPolicyOpen": "开放",
|
||||
"channel.groupPolicyOpenHint": "在所有群组、频道、子话题中响应",
|
||||
"channel.historyLimit": "历史消息条数",
|
||||
"channel.historyLimitHint": "读取频道历史消息时默认获取的消息数量",
|
||||
"channel.importConfig": "导入平台配置",
|
||||
@@ -111,8 +123,8 @@
|
||||
"channel.secretToken": "Webhook 密钥",
|
||||
"channel.secretTokenHint": "可选。用于验证来自 Telegram 的 Webhook 请求。",
|
||||
"channel.secretTokenPlaceholder": "可选的 Webhook 验证密钥",
|
||||
"channel.serverId": "默认服务器(供 AI 工具使用)",
|
||||
"channel.serverIdHint": "你让 bot 在某个服务器上做事时(比如 \"列出频道\"、\"发到 #announcements\"),AI 工具默认操作的服务器 / Guild ID。跟访问控制无关 —— 那是群组策略的事。",
|
||||
"channel.serverId": "默认服务器 ID",
|
||||
"channel.serverIdHint": "AI 工具默认作用的服务器 / Guild,与访问控制无关",
|
||||
"channel.settings": "高级设置",
|
||||
"channel.settingsResetConfirm": "确定要将高级设置恢复为默认配置吗?",
|
||||
"channel.settingsResetDefault": "恢复默认配置",
|
||||
@@ -138,8 +150,8 @@
|
||||
"channel.testFailed": "连接测试失败",
|
||||
"channel.testSuccess": "连接测试通过",
|
||||
"channel.updateFailed": "更新状态失败",
|
||||
"channel.userId": "你的平台用户 ID(供 AI 工具使用)",
|
||||
"channel.userIdHint": "AI 工具用它主动联系你(如定时提醒、通知);该 ID 也会被全局白名单自动信任。",
|
||||
"channel.userId": "你的平台用户 ID",
|
||||
"channel.userIdHint": "供 AI 工具主动联系你(如提醒、通知),并自动加入全局白名单",
|
||||
"channel.validationError": "请填写应用 ID 和 Token",
|
||||
"channel.verificationToken": "Verification Token",
|
||||
"channel.verificationTokenHint": "可选。用于验证事件推送来源。",
|
||||
|
||||
@@ -68,6 +68,25 @@
|
||||
"cliRateLimitGuide.resetAt": "重置时间",
|
||||
"cliRateLimitGuide.resetInApprox": "约 {{duration}} 后重置",
|
||||
"cliRateLimitGuide.title": "{{name}} 已达到使用上限",
|
||||
"cloudClaudeCodeSetup.actions.cancel": "取消",
|
||||
"cloudClaudeCodeSetup.actions.confirm": "继续创建",
|
||||
"cloudClaudeCodeSetup.desc": "创建前需要先完成 Claude Code 凭证和 GitHub 授权。",
|
||||
"cloudClaudeCodeSetup.errors.githubRequired": "请先完成 GitHub 授权。",
|
||||
"cloudClaudeCodeSetup.errors.refresh": "刷新当前授权状态失败。",
|
||||
"cloudClaudeCodeSetup.errors.submit": "保存 Cloud Claude Code 前置凭证失败。",
|
||||
"cloudClaudeCodeSetup.errors.tokenRequired": "请先填写 CLAUDE_CODE_OAUTH_TOKEN。",
|
||||
"cloudClaudeCodeSetup.github.authorized": "已检测到 GitHub 授权,继续创建时会自动生成可注入凭证。",
|
||||
"cloudClaudeCodeSetup.github.connected": "已检测到可用的 GitHub 凭证。",
|
||||
"cloudClaudeCodeSetup.github.desc": "让 Claude Code 继承你的 GitHub 身份,用于访问仓库和编写代码。",
|
||||
"cloudClaudeCodeSetup.github.footer": "GitHub 授权完成后,继续创建即可。",
|
||||
"cloudClaudeCodeSetup.github.title": "GitHub 授权",
|
||||
"cloudClaudeCodeSetup.title": "启用云端 Claude Code",
|
||||
"cloudClaudeCodeSetup.token.commandPrefix": "在你自己的 Claude Code 会话里运行",
|
||||
"cloudClaudeCodeSetup.token.connected": "已检测到现有 CLAUDE_CODE_OAUTH_TOKEN,会直接复用。",
|
||||
"cloudClaudeCodeSetup.token.desc": "这个 token 用于让云端 Claude Code 代表你运行。",
|
||||
"cloudClaudeCodeSetup.token.hint": "如果你还没有这个 token,请先到你自己的 Claude Code 里拿到长期有效凭证。",
|
||||
"cloudClaudeCodeSetup.token.placeholder": "粘贴 CLAUDE_CODE_OAUTH_TOKEN",
|
||||
"cloudClaudeCodeSetup.token.title": "Claude Code 凭证",
|
||||
"codexInstallGuide.actions.openDocs": "打开安装指南",
|
||||
"codexInstallGuide.actions.openSystemTools": "打开系统工具",
|
||||
"codexInstallGuide.afterInstall": "安装完成后,请先运行一次 Codex 完成登录,然后重试刚才的消息,或在系统工具中点击“重新检测”。",
|
||||
@@ -294,6 +313,7 @@
|
||||
"minimap.senderUser": "你",
|
||||
"newAgent": "创建助理",
|
||||
"newClaudeCodeAgent": "添加 Claude Code",
|
||||
"newCloudClaudeCode": "新增云端 Claude Code",
|
||||
"newCodexAgent": "添加 Codex",
|
||||
"newGroupChat": "创建群组",
|
||||
"newPage": "创建文稿",
|
||||
@@ -570,12 +590,18 @@
|
||||
"taskList.view.list": "列表",
|
||||
"taskList.viewAll": "查看全部",
|
||||
"taskSchedule.clear": "清除",
|
||||
"taskSchedule.continuous": "持续执行",
|
||||
"taskSchedule.enable": "启用自动化",
|
||||
"taskSchedule.every": "每",
|
||||
"taskSchedule.frequency": "执行频率",
|
||||
"taskSchedule.hours": "小时",
|
||||
"taskSchedule.interval": "循环任务",
|
||||
"taskSchedule.intervalTab": "循环任务",
|
||||
"taskSchedule.maxExecutions": "最大次数",
|
||||
"taskSchedule.minutes": "分钟",
|
||||
"taskSchedule.scheduleType.daily": "每日",
|
||||
"taskSchedule.scheduleType.hourly": "每小时",
|
||||
"taskSchedule.scheduleType.weekly": "每周",
|
||||
"taskSchedule.scheduler": "定时任务",
|
||||
"taskSchedule.schedulerNotReady": "定时任务即将上线。暂时请使用“循环任务”。",
|
||||
"taskSchedule.schedulerTab": "定时任务",
|
||||
@@ -584,6 +610,8 @@
|
||||
"taskSchedule.tag.every": "每 {{interval}}",
|
||||
"taskSchedule.tag.heartbeat": "心跳 · {{every}}",
|
||||
"taskSchedule.tag.schedule": "计划 · {{schedule}}{{timezone}}",
|
||||
"taskSchedule.time": "时间",
|
||||
"taskSchedule.timezone": "时区",
|
||||
"taskSchedule.title": "计划",
|
||||
"taskSchedule.unit.hour_one": "{{count}} 小时",
|
||||
"taskSchedule.unit.hour_other": "{{count}} 小时",
|
||||
@@ -591,6 +619,14 @@
|
||||
"taskSchedule.unit.minute_other": "{{count}} 分钟",
|
||||
"taskSchedule.unit.second_one": "{{count}} 秒",
|
||||
"taskSchedule.unit.second_other": "{{count}} 秒",
|
||||
"taskSchedule.weekday": "星期",
|
||||
"taskSchedule.weekdays.fri": "五",
|
||||
"taskSchedule.weekdays.mon": "一",
|
||||
"taskSchedule.weekdays.sat": "六",
|
||||
"taskSchedule.weekdays.sun": "日",
|
||||
"taskSchedule.weekdays.thu": "四",
|
||||
"taskSchedule.weekdays.tue": "二",
|
||||
"taskSchedule.weekdays.wed": "三",
|
||||
"thread.closeSubagentThread": "收起子智能体对话",
|
||||
"thread.divider": "子话题",
|
||||
"thread.openSubagentThread": "查看完整子智能体对话",
|
||||
|
||||
@@ -268,7 +268,7 @@
|
||||
"footer.title": "喜欢我们的产品?",
|
||||
"fullscreen": "全屏模式",
|
||||
"generation.hero.taglinePrefix": "即刻创作",
|
||||
"generation.promptModeration.blocked": "请求内容可能违反内容政策。请调整提示词后重试",
|
||||
"generation.promptModeration.blocked": "内容安全检查未通过,请调整提示词后重试。",
|
||||
"getDesktopApp": "获取桌面应用",
|
||||
"historyRange": "历史范围",
|
||||
"home.suggestQuestions": "试试这些示例",
|
||||
|
||||
@@ -624,6 +624,8 @@
|
||||
"user.logout": "退出登录",
|
||||
"user.myProfile": "我的主页",
|
||||
"user.noAgents": "该用户暂未发布助理",
|
||||
"user.noAgents.ownerDescription": "创建你的第一个助理,分享到社区。",
|
||||
"user.noAgents.title": "还没有助理",
|
||||
"user.noFavoriteAgents": "暂无收藏的助理",
|
||||
"user.noFavoritePlugins": "暂无收藏的插件",
|
||||
"user.noForkedAgentGroups": "尚无已派生的代理组",
|
||||
|
||||
@@ -111,6 +111,8 @@
|
||||
"response.PluginServerError": "技能服务端返回错误,请根据下方信息检查技能描述、配置或服务端实现",
|
||||
"response.PluginSettingsInvalid": "该技能需要完成配置后才能使用,请检查技能配置",
|
||||
"response.ProviderBizError": "模型服务商返回错误。请根据以下信息排查,或稍后重试",
|
||||
"response.ProviderContentModeration": "内容安全检查未通过,请调整描述后重试。",
|
||||
"response.ProviderContentModerationWarning": "多次触发内容安全限制,继续违规可能导致账号受限。",
|
||||
"response.QuotaLimitReached": "Token 用量或请求次数已达配额上限。请提升配额或稍后再试",
|
||||
"response.QuotaLimitReachedCloud": "当前模型服务负载较高,请稍后重试或切换其他模型。",
|
||||
"response.ServerAgentRuntimeError": "助理运行服务暂不可用。请稍后再试,或邮件联系我们",
|
||||
|
||||
@@ -5,12 +5,14 @@
|
||||
"agentSelection.search": "未找到匹配的助理",
|
||||
"brief.action.acknowledge": "确认",
|
||||
"brief.action.approve": "批准",
|
||||
"brief.action.confirmDone": "确认完成",
|
||||
"brief.action.feedback": "反馈",
|
||||
"brief.action.retry": "重试",
|
||||
"brief.addFeedback": "分享反馈",
|
||||
"brief.collapse": "收起",
|
||||
"brief.commentPlaceholder": "分享你的反馈…",
|
||||
"brief.commentSubmit": "提交反馈",
|
||||
"brief.editResult": "编辑",
|
||||
"brief.expandAll": "展开全部",
|
||||
"brief.feedbackSent": "反馈已提交",
|
||||
"brief.resolved": "已标记为已解决",
|
||||
|
||||
+1
-1
@@ -165,7 +165,7 @@
|
||||
"stylelint-config-clean-order": "7.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.1.0",
|
||||
"@ant-design/icons": "^6.2.1",
|
||||
"@ant-design/pro-components": "^2.8.10",
|
||||
"@anthropic-ai/sdk": "^0.73.0",
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
|
||||
|
||||
@@ -6,11 +6,11 @@ Generate text, images, videos, and audio. Alias: \`lh generate\`.
|
||||
|
||||
- \`lh gen text <prompt> [-m <model>] [-p <provider>] [--stream] [--temperature <t>]\` - Generate text
|
||||
- \`lh gen image <prompt> [-m <model>] [-n <count>] [--width <w>] [--height <h>]\` - Generate image
|
||||
- \`lh gen video <prompt> [-m <model>] [--aspect-ratio <r>] [--duration <d>]\` - Generate video
|
||||
- \`lh gen video <prompt> -m <model> -p <provider> [--aspect-ratio <r>] [--duration <d>] [--resolution <res>]\` - Generate video
|
||||
- \`lh gen tts <text> [-o <output>] [--voice <v>] [--speed <s>]\` - Text-to-speech
|
||||
- \`lh gen asr <audioFile> [--model <m>] [--language <l>]\` - Speech-to-text
|
||||
- \`lh gen status <generationId> <taskId>\` - Check generation task status
|
||||
- \`lh gen download <generationId> <taskId> [-o <output>]\` - Wait and download result
|
||||
- \`lh gen status <generationId> <asyncTaskId>\` - Check generation task status
|
||||
- \`lh gen download <generationId> <asyncTaskId> [-o <output>]\` - Wait and download result
|
||||
- \`lh gen list\` - List generation topics
|
||||
|
||||
## Tips
|
||||
@@ -18,6 +18,30 @@ Generate text, images, videos, and audio. Alias: \`lh generate\`.
|
||||
- Image/video generation is async; use \`status\` or \`download\` to get results
|
||||
- \`--stream\` for text generation outputs tokens as they arrive
|
||||
- \`--pipe\` for text generation outputs only the raw text (no formatting)
|
||||
|
||||
## ⚠️ asyncTaskId vs generationId
|
||||
|
||||
\`gen status\` and \`gen download\` require TWO different IDs:
|
||||
|
||||
- \`<generationId>\` — prefixed with \`gen_\`, e.g. \`gen_abc123\`
|
||||
- \`<asyncTaskId>\` — a UUID printed after \`→ Task\` in the \`gen image\` / \`gen video\` output,
|
||||
e.g. \`7ad0eb13-e9a5-4403-8070-1f7fe95b2f95\`
|
||||
|
||||
Passing \`gen_xxx\` as \`<asyncTaskId>\` will cause a server error. Always use the UUID.
|
||||
|
||||
Example output from \`lh gen video\`:
|
||||
\`\`\`
|
||||
✓ Video generation started
|
||||
Batch ID: gb_xxx
|
||||
Generation gen_abc123 → Task 7ad0eb13-e9a5-4403-8070-1f7fe95b2f95
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ← this is asyncTaskId
|
||||
\`\`\`
|
||||
|
||||
Correct usage:
|
||||
\`\`\`bash
|
||||
lh gen status gen_abc123 7ad0eb13-e9a5-4403-8070-1f7fe95b2f95
|
||||
lh gen download gen_abc123 7ad0eb13-e9a5-4403-8070-1f7fe95b2f95 -o result.mp4
|
||||
\`\`\`
|
||||
`;
|
||||
|
||||
export default content;
|
||||
|
||||
@@ -15,7 +15,7 @@ export const BriefManifest: BuiltinToolManifest = {
|
||||
properties: {
|
||||
actions: {
|
||||
description:
|
||||
'Custom action buttons for the user. If omitted, defaults are generated based on type. Each action has key (identifier), label (display text), and type ("resolve" to close, "comment" to prompt feedback).',
|
||||
'Custom action buttons for the user. Ignored when type is "result" (result briefs render a fixed approve button). For other types, if omitted, defaults are generated based on type. Each action has key (identifier), label (display text), and type ("resolve" to close, "comment" to prompt feedback).',
|
||||
items: {
|
||||
properties: {
|
||||
key: { description: 'Action identifier, e.g. "approve", "split"', type: 'string' },
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
export const TASK_STATUSES = [
|
||||
'backlog',
|
||||
'running',
|
||||
'scheduled',
|
||||
'paused',
|
||||
'completed',
|
||||
'failed',
|
||||
'canceled',
|
||||
] as const;
|
||||
|
||||
export const UNFINISHED_TASK_STATUSES = ['backlog', 'running', 'paused'] as const;
|
||||
export const UNFINISHED_TASK_STATUSES = ['backlog', 'running', 'scheduled', 'paused'] as const;
|
||||
|
||||
@@ -0,0 +1,477 @@
|
||||
// @vitest-environment node
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { getTestDB } from '../../core/getTestDB';
|
||||
import { agents, chatGroups, documents, knowledgeBases, tasks, topics, users } from '../../schemas';
|
||||
import type { LobeChatDatabase } from '../../type';
|
||||
import { RecentModel } from '../recent';
|
||||
|
||||
const serverDB: LobeChatDatabase = await getTestDB();
|
||||
|
||||
const userId = 'recent-model-test-user';
|
||||
const otherUserId = 'recent-model-test-other-user';
|
||||
|
||||
const recentModel = new RecentModel(serverDB, userId);
|
||||
|
||||
const now = () => new Date();
|
||||
const minutesAgo = (n: number) => new Date(Date.now() - n * 60 * 1000);
|
||||
|
||||
const baseDocFields = {
|
||||
fileType: 'markdown',
|
||||
source: 'document',
|
||||
totalCharCount: 100,
|
||||
totalLineCount: 5,
|
||||
} as const;
|
||||
|
||||
const baseTaskFields = {
|
||||
instruction: 'do the thing',
|
||||
seq: 1,
|
||||
} as const;
|
||||
|
||||
describe('RecentModel', () => {
|
||||
beforeEach(async () => {
|
||||
await serverDB.delete(users);
|
||||
await serverDB.insert(users).values([{ id: userId }, { id: otherUserId }]);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await serverDB.delete(users);
|
||||
});
|
||||
|
||||
describe('queryRecent', () => {
|
||||
it('returns empty array when user has no recent items', async () => {
|
||||
const result = await recentModel.queryRecent();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('only returns rows for the calling user', async () => {
|
||||
await serverDB.insert(agents).values({ id: 'agent-mine', userId, slug: 'inbox' });
|
||||
await serverDB
|
||||
.insert(agents)
|
||||
.values({ id: 'agent-other', userId: otherUserId, slug: 'inbox' });
|
||||
|
||||
await serverDB.insert(topics).values([
|
||||
{ id: 'topic-mine', userId, agentId: 'agent-mine', title: 'mine', updatedAt: now() },
|
||||
{
|
||||
id: 'topic-other',
|
||||
userId: otherUserId,
|
||||
agentId: 'agent-other',
|
||||
title: 'other',
|
||||
updatedAt: now(),
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await recentModel.queryRecent();
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({ id: 'topic-mine', type: 'topic' });
|
||||
});
|
||||
|
||||
describe('topics arm', () => {
|
||||
it('includes inbox-agent topics and group topics', async () => {
|
||||
await serverDB.insert(agents).values({ id: 'agent-inbox', userId, slug: 'inbox' });
|
||||
await serverDB.insert(chatGroups).values({ id: 'group-1', userId });
|
||||
|
||||
await serverDB.insert(topics).values([
|
||||
{
|
||||
id: 'topic-inbox',
|
||||
userId,
|
||||
agentId: 'agent-inbox',
|
||||
title: 'inbox topic',
|
||||
updatedAt: minutesAgo(5),
|
||||
},
|
||||
{
|
||||
id: 'topic-group',
|
||||
userId,
|
||||
groupId: 'group-1',
|
||||
title: 'group topic',
|
||||
updatedAt: minutesAgo(2),
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await recentModel.queryRecent();
|
||||
expect(result.map((r) => r.id)).toEqual(['topic-group', 'topic-inbox']);
|
||||
expect(result[0]).toMatchObject({
|
||||
id: 'topic-group',
|
||||
type: 'topic',
|
||||
routeId: null,
|
||||
routeGroupId: 'group-1',
|
||||
});
|
||||
expect(result[1]).toMatchObject({
|
||||
id: 'topic-inbox',
|
||||
type: 'topic',
|
||||
routeId: 'agent-inbox',
|
||||
routeGroupId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('includes topics on non-virtual non-group agents', async () => {
|
||||
await serverDB.insert(agents).values({ id: 'agent-real', userId, virtual: false });
|
||||
|
||||
await serverDB.insert(topics).values({
|
||||
id: 'topic-real',
|
||||
userId,
|
||||
agentId: 'agent-real',
|
||||
title: 'real',
|
||||
updatedAt: now(),
|
||||
});
|
||||
|
||||
const result = await recentModel.queryRecent();
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe('topic-real');
|
||||
});
|
||||
|
||||
it('excludes topics on virtual agents that are not in a group', async () => {
|
||||
await serverDB.insert(agents).values({ id: 'agent-virtual', userId, virtual: true });
|
||||
|
||||
await serverDB.insert(topics).values({
|
||||
id: 'topic-virtual',
|
||||
userId,
|
||||
agentId: 'agent-virtual',
|
||||
title: 'virtual',
|
||||
updatedAt: now(),
|
||||
});
|
||||
|
||||
const result = await recentModel.queryRecent();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('excludes topics with system triggers', async () => {
|
||||
await serverDB.insert(agents).values({ id: 'agent-inbox', userId, slug: 'inbox' });
|
||||
|
||||
await serverDB.insert(topics).values([
|
||||
{ id: 'topic-cron', userId, agentId: 'agent-inbox', trigger: 'cron', updatedAt: now() },
|
||||
{ id: 'topic-eval', userId, agentId: 'agent-inbox', trigger: 'eval', updatedAt: now() },
|
||||
{
|
||||
id: 'topic-task',
|
||||
userId,
|
||||
agentId: 'agent-inbox',
|
||||
trigger: 'task_manager',
|
||||
updatedAt: now(),
|
||||
},
|
||||
{
|
||||
id: 'topic-task2',
|
||||
userId,
|
||||
agentId: 'agent-inbox',
|
||||
trigger: 'task',
|
||||
updatedAt: now(),
|
||||
},
|
||||
{
|
||||
id: 'topic-chat',
|
||||
userId,
|
||||
agentId: 'agent-inbox',
|
||||
trigger: 'chat',
|
||||
updatedAt: now(),
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await recentModel.queryRecent();
|
||||
expect(result.map((r) => r.id)).toEqual(['topic-chat']);
|
||||
});
|
||||
|
||||
it('falls back to "Untitled Topic" when title is null', async () => {
|
||||
await serverDB.insert(agents).values({ id: 'agent-inbox', userId, slug: 'inbox' });
|
||||
await serverDB.insert(topics).values({
|
||||
id: 'topic-untitled',
|
||||
userId,
|
||||
agentId: 'agent-inbox',
|
||||
title: null,
|
||||
updatedAt: now(),
|
||||
});
|
||||
|
||||
const result = await recentModel.queryRecent();
|
||||
expect(result[0].title).toBe('Untitled Topic');
|
||||
});
|
||||
|
||||
it('returns topic metadata when present', async () => {
|
||||
await serverDB.insert(agents).values({ id: 'agent-inbox', userId, slug: 'inbox' });
|
||||
await serverDB.insert(topics).values({
|
||||
id: 'topic-with-meta',
|
||||
userId,
|
||||
agentId: 'agent-inbox',
|
||||
metadata: { bot: { platform: 'slack' } } as any,
|
||||
updatedAt: now(),
|
||||
});
|
||||
|
||||
const result = await recentModel.queryRecent();
|
||||
expect(result[0].metadata).toEqual({ bot: { platform: 'slack' } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('documents arm', () => {
|
||||
it('includes user-authored "api" pages', async () => {
|
||||
await serverDB.insert(documents).values({
|
||||
id: 'doc-api',
|
||||
userId,
|
||||
title: 'My Page',
|
||||
sourceType: 'api',
|
||||
updatedAt: now(),
|
||||
...baseDocFields,
|
||||
});
|
||||
|
||||
const result = await recentModel.queryRecent();
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
id: 'doc-api',
|
||||
type: 'document',
|
||||
title: 'My Page',
|
||||
routeId: null,
|
||||
routeGroupId: null,
|
||||
metadata: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('excludes web-browsing scraped pages (sourceType "web")', async () => {
|
||||
await serverDB.insert(documents).values([
|
||||
{
|
||||
id: 'doc-api',
|
||||
userId,
|
||||
title: 'Real Page',
|
||||
sourceType: 'api',
|
||||
updatedAt: minutesAgo(1),
|
||||
...baseDocFields,
|
||||
},
|
||||
{
|
||||
id: 'doc-web',
|
||||
userId,
|
||||
title: 'XAU USD | Gold Spot US Dollar',
|
||||
sourceType: 'web',
|
||||
updatedAt: now(),
|
||||
...baseDocFields,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await recentModel.queryRecent();
|
||||
expect(result.map((r) => r.id)).toEqual(['doc-api']);
|
||||
});
|
||||
|
||||
it('excludes file uploads (sourceType "file")', async () => {
|
||||
await serverDB.insert(documents).values({
|
||||
id: 'doc-file',
|
||||
userId,
|
||||
sourceType: 'file',
|
||||
updatedAt: now(),
|
||||
...baseDocFields,
|
||||
});
|
||||
|
||||
const result = await recentModel.queryRecent();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('excludes documents inside a knowledge base', async () => {
|
||||
await serverDB.insert(knowledgeBases).values({ id: 'kb-1', userId, name: 'kb' });
|
||||
await serverDB.insert(documents).values({
|
||||
id: 'doc-kb',
|
||||
userId,
|
||||
title: 'kb doc',
|
||||
sourceType: 'api',
|
||||
knowledgeBaseId: 'kb-1',
|
||||
updatedAt: now(),
|
||||
...baseDocFields,
|
||||
});
|
||||
|
||||
const result = await recentModel.queryRecent();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('excludes folder documents', async () => {
|
||||
await serverDB.insert(documents).values({
|
||||
id: 'doc-folder',
|
||||
userId,
|
||||
title: 'Folder',
|
||||
sourceType: 'api',
|
||||
updatedAt: now(),
|
||||
...baseDocFields,
|
||||
fileType: 'custom/folder',
|
||||
});
|
||||
|
||||
const result = await recentModel.queryRecent();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('falls back to filename then "Untitled Document" when title is null', async () => {
|
||||
await serverDB.insert(documents).values([
|
||||
{
|
||||
id: 'doc-fallback-filename',
|
||||
userId,
|
||||
title: null,
|
||||
filename: 'notes.md',
|
||||
sourceType: 'api',
|
||||
updatedAt: minutesAgo(1),
|
||||
...baseDocFields,
|
||||
},
|
||||
{
|
||||
id: 'doc-untitled',
|
||||
userId,
|
||||
title: null,
|
||||
filename: null,
|
||||
sourceType: 'api',
|
||||
updatedAt: now(),
|
||||
...baseDocFields,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await recentModel.queryRecent();
|
||||
const byId = Object.fromEntries(result.map((r) => [r.id, r.title]));
|
||||
expect(byId['doc-fallback-filename']).toBe('notes.md');
|
||||
expect(byId['doc-untitled']).toBe('Untitled Document');
|
||||
});
|
||||
});
|
||||
|
||||
describe('tasks arm', () => {
|
||||
it('includes active tasks and surfaces assigneeAgentId as routeId', async () => {
|
||||
await serverDB.insert(agents).values({ id: 'agent-assignee', userId });
|
||||
|
||||
await serverDB.insert(tasks).values({
|
||||
id: 'task-active',
|
||||
createdByUserId: userId,
|
||||
assigneeAgentId: 'agent-assignee',
|
||||
identifier: 'T-1',
|
||||
name: 'Active Task',
|
||||
status: 'running',
|
||||
updatedAt: now(),
|
||||
...baseTaskFields,
|
||||
});
|
||||
|
||||
const result = await recentModel.queryRecent();
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
id: 'task-active',
|
||||
type: 'task',
|
||||
title: 'Active Task',
|
||||
routeId: 'agent-assignee',
|
||||
routeGroupId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('excludes completed and canceled tasks', async () => {
|
||||
await serverDB.insert(tasks).values([
|
||||
{
|
||||
id: 'task-done',
|
||||
createdByUserId: userId,
|
||||
identifier: 'T-2',
|
||||
status: 'completed',
|
||||
updatedAt: now(),
|
||||
...baseTaskFields,
|
||||
},
|
||||
{
|
||||
id: 'task-canceled',
|
||||
createdByUserId: userId,
|
||||
identifier: 'T-3',
|
||||
status: 'canceled',
|
||||
updatedAt: now(),
|
||||
...baseTaskFields,
|
||||
},
|
||||
{
|
||||
id: 'task-running',
|
||||
createdByUserId: userId,
|
||||
identifier: 'T-4',
|
||||
status: 'running',
|
||||
updatedAt: now(),
|
||||
...baseTaskFields,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await recentModel.queryRecent();
|
||||
expect(result.map((r) => r.id)).toEqual(['task-running']);
|
||||
});
|
||||
|
||||
it('falls back from name → instruction → "Untitled Task"', async () => {
|
||||
await serverDB.insert(tasks).values([
|
||||
{
|
||||
id: 'task-named',
|
||||
createdByUserId: userId,
|
||||
identifier: 'T-A',
|
||||
name: 'Named',
|
||||
instruction: 'do A',
|
||||
seq: 1,
|
||||
status: 'running',
|
||||
updatedAt: minutesAgo(2),
|
||||
},
|
||||
{
|
||||
id: 'task-instruction',
|
||||
createdByUserId: userId,
|
||||
identifier: 'T-B',
|
||||
name: null,
|
||||
instruction: 'fallback to instruction',
|
||||
seq: 2,
|
||||
status: 'running',
|
||||
updatedAt: minutesAgo(1),
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await recentModel.queryRecent();
|
||||
const byId = Object.fromEntries(result.map((r) => [r.id, r.title]));
|
||||
expect(byId['task-named']).toBe('Named');
|
||||
expect(byId['task-instruction']).toBe('fallback to instruction');
|
||||
});
|
||||
});
|
||||
|
||||
describe('combined results', () => {
|
||||
it('orders all three types by updatedAt desc and applies the limit', async () => {
|
||||
await serverDB.insert(agents).values({ id: 'agent-inbox', userId, slug: 'inbox' });
|
||||
|
||||
await serverDB.insert(topics).values({
|
||||
id: 'topic-1',
|
||||
userId,
|
||||
agentId: 'agent-inbox',
|
||||
title: 'topic',
|
||||
updatedAt: minutesAgo(10),
|
||||
});
|
||||
await serverDB.insert(documents).values({
|
||||
id: 'doc-1',
|
||||
userId,
|
||||
title: 'doc',
|
||||
sourceType: 'api',
|
||||
updatedAt: minutesAgo(5),
|
||||
...baseDocFields,
|
||||
});
|
||||
await serverDB.insert(tasks).values({
|
||||
id: 'task-1',
|
||||
createdByUserId: userId,
|
||||
identifier: 'T-1',
|
||||
name: 'task',
|
||||
status: 'running',
|
||||
updatedAt: minutesAgo(1),
|
||||
...baseTaskFields,
|
||||
});
|
||||
|
||||
const result = await recentModel.queryRecent(10);
|
||||
expect(result.map((r) => `${r.type}:${r.id}`)).toEqual([
|
||||
'task:task-1',
|
||||
'document:doc-1',
|
||||
'topic:topic-1',
|
||||
]);
|
||||
});
|
||||
|
||||
it('respects the limit parameter', async () => {
|
||||
await serverDB.insert(agents).values({ id: 'agent-inbox', userId, slug: 'inbox' });
|
||||
await serverDB.insert(topics).values(
|
||||
Array.from({ length: 5 }, (_, i) => ({
|
||||
id: `topic-${i}`,
|
||||
userId,
|
||||
agentId: 'agent-inbox',
|
||||
title: `t${i}`,
|
||||
updatedAt: minutesAgo(i),
|
||||
})),
|
||||
);
|
||||
|
||||
const result = await recentModel.queryRecent(2);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((r) => r.id)).toEqual(['topic-0', 'topic-1']);
|
||||
});
|
||||
|
||||
it('returns Date objects for updatedAt', async () => {
|
||||
await serverDB.insert(agents).values({ id: 'agent-inbox', userId, slug: 'inbox' });
|
||||
await serverDB.insert(topics).values({
|
||||
id: 'topic-date',
|
||||
userId,
|
||||
agentId: 'agent-inbox',
|
||||
updatedAt: now(),
|
||||
});
|
||||
|
||||
const [row] = await recentModel.queryRecent();
|
||||
expect(row.updatedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { and, desc, eq, inArray, isNotNull, isNull, ne, not, or, sql } from 'drizzle-orm';
|
||||
import { unionAll } from 'drizzle-orm/pg-core';
|
||||
|
||||
import { agents, DOCUMENT_FOLDER_TYPE, documents, tasks, topics } from '../schemas';
|
||||
import type { LobeChatDatabase } from '../type';
|
||||
@@ -13,6 +14,17 @@ export interface RecentDbItem {
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
// Mirrors `MAIN_SIDEBAR_EXCLUDE_TRIGGERS` in `src/const/topic.ts`. System-trigger
|
||||
// topics live in their own surfaces (Task Manager, cron, eval, task runs) and
|
||||
// would clutter the main "Recent" list.
|
||||
const SYSTEM_TOPIC_TRIGGERS = ['cron', 'eval', 'task_manager', 'task'];
|
||||
|
||||
// Excluded so file uploads and web-browsing tool scrapes don't surface as
|
||||
// "recent docs"; only user-authored pages ('api') and legacy 'topic' rows remain.
|
||||
const TOOL_DOCUMENT_SOURCE_TYPES = ['file', 'web'] as const;
|
||||
|
||||
const TASK_FINAL_STATUSES = ['completed', 'canceled'];
|
||||
|
||||
export class RecentModel {
|
||||
private userId: string;
|
||||
private db: LobeChatDatabase;
|
||||
@@ -23,69 +35,85 @@ export class RecentModel {
|
||||
}
|
||||
|
||||
queryRecent = async (limit: number = 10): Promise<RecentDbItem[]> => {
|
||||
const query = sql`
|
||||
SELECT * FROM (
|
||||
SELECT
|
||||
${topics.id} as id,
|
||||
COALESCE(${topics.title}, 'Untitled Topic') as title,
|
||||
'topic' as type,
|
||||
${topics.agentId} as route_id,
|
||||
${topics.groupId} as route_group_id,
|
||||
${topics.updatedAt} as updated_at,
|
||||
${topics.metadata} as metadata
|
||||
FROM ${topics}
|
||||
LEFT JOIN ${agents} ON ${topics.agentId} = ${agents.id}
|
||||
WHERE ${topics.userId} = ${this.userId}
|
||||
AND (
|
||||
${topics.groupId} IS NOT NULL
|
||||
OR ${agents.slug} = 'inbox'
|
||||
OR (${topics.groupId} IS NULL AND ${agents.virtual} != true)
|
||||
)
|
||||
const topicArm = this.db
|
||||
.select({
|
||||
id: topics.id,
|
||||
metadata: sql<any>`${topics.metadata}`.as('metadata'),
|
||||
routeGroupId: sql<string | null>`${topics.groupId}`.as('route_group_id'),
|
||||
routeId: sql<string | null>`${topics.agentId}`.as('route_id'),
|
||||
title: sql<string>`COALESCE(${topics.title}, 'Untitled Topic')`.as('title'),
|
||||
type: sql<RecentDbItem['type']>`'topic'`.as('type'),
|
||||
updatedAt: topics.updatedAt,
|
||||
})
|
||||
.from(topics)
|
||||
.leftJoin(agents, eq(topics.agentId, agents.id))
|
||||
.where(
|
||||
and(
|
||||
eq(topics.userId, this.userId),
|
||||
or(
|
||||
isNotNull(topics.groupId),
|
||||
eq(agents.slug, 'inbox'),
|
||||
and(isNull(topics.groupId), ne(agents.virtual, true)),
|
||||
),
|
||||
or(isNull(topics.trigger), not(inArray(topics.trigger, SYSTEM_TOPIC_TRIGGERS))),
|
||||
),
|
||||
);
|
||||
|
||||
UNION ALL
|
||||
const documentArm = this.db
|
||||
.select({
|
||||
id: documents.id,
|
||||
metadata: sql<any>`NULL`.as('metadata'),
|
||||
routeGroupId: sql<string | null>`NULL`.as('route_group_id'),
|
||||
routeId: sql<string | null>`NULL`.as('route_id'),
|
||||
title:
|
||||
sql<string>`COALESCE(${documents.title}, ${documents.filename}, 'Untitled Document')`.as(
|
||||
'title',
|
||||
),
|
||||
type: sql<RecentDbItem['type']>`'document'`.as('type'),
|
||||
updatedAt: documents.updatedAt,
|
||||
})
|
||||
.from(documents)
|
||||
.where(
|
||||
and(
|
||||
eq(documents.userId, this.userId),
|
||||
not(inArray(documents.sourceType, TOOL_DOCUMENT_SOURCE_TYPES)),
|
||||
isNull(documents.knowledgeBaseId),
|
||||
ne(documents.fileType, DOCUMENT_FOLDER_TYPE),
|
||||
),
|
||||
);
|
||||
|
||||
SELECT
|
||||
${documents.id} as id,
|
||||
COALESCE(${documents.title}, ${documents.filename}, 'Untitled Document') as title,
|
||||
'document' as type,
|
||||
NULL as route_id,
|
||||
NULL as route_group_id,
|
||||
${documents.updatedAt} as updated_at,
|
||||
NULL as metadata
|
||||
FROM ${documents}
|
||||
WHERE ${documents.userId} = ${this.userId}
|
||||
AND ${documents.sourceType} != 'file'
|
||||
AND ${documents.knowledgeBaseId} IS NULL
|
||||
AND ${documents.fileType} != ${DOCUMENT_FOLDER_TYPE}
|
||||
const taskArm = this.db
|
||||
.select({
|
||||
id: tasks.id,
|
||||
metadata: sql<any>`NULL`.as('metadata'),
|
||||
routeGroupId: sql<string | null>`NULL`.as('route_group_id'),
|
||||
routeId: sql<string | null>`${tasks.assigneeAgentId}`.as('route_id'),
|
||||
title: sql<string>`COALESCE(${tasks.name}, ${tasks.instruction}, 'Untitled Task')`.as(
|
||||
'title',
|
||||
),
|
||||
type: sql<RecentDbItem['type']>`'task'`.as('type'),
|
||||
updatedAt: tasks.updatedAt,
|
||||
})
|
||||
.from(tasks)
|
||||
.where(
|
||||
and(
|
||||
eq(tasks.createdByUserId, this.userId),
|
||||
not(inArray(tasks.status, TASK_FINAL_STATUSES)),
|
||||
),
|
||||
);
|
||||
|
||||
UNION ALL
|
||||
const rows = await unionAll(topicArm, documentArm, taskArm)
|
||||
.orderBy(desc(sql`updated_at`))
|
||||
.limit(limit);
|
||||
|
||||
SELECT
|
||||
${tasks.id} as id,
|
||||
COALESCE(${tasks.name}, ${tasks.instruction}, 'Untitled Task') as title,
|
||||
'task' as type,
|
||||
${tasks.assigneeAgentId} as route_id,
|
||||
NULL as route_group_id,
|
||||
${tasks.updatedAt} as updated_at,
|
||||
NULL as metadata
|
||||
FROM ${tasks}
|
||||
WHERE ${tasks.createdByUserId} = ${this.userId}
|
||||
AND ${tasks.status} NOT IN ('completed', 'canceled')
|
||||
) AS combined
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT ${limit}
|
||||
`;
|
||||
|
||||
const result = await this.db.execute(query);
|
||||
|
||||
return result.rows.map((row: any) => ({
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
metadata: row.metadata ?? undefined,
|
||||
routeGroupId: row.route_group_id,
|
||||
routeId: row.route_id,
|
||||
routeGroupId: row.routeGroupId,
|
||||
routeId: row.routeId,
|
||||
title: row.title,
|
||||
type: row.type as RecentDbItem['type'],
|
||||
updatedAt: new Date(row.updated_at),
|
||||
type: row.type,
|
||||
updatedAt: row.updatedAt instanceof Date ? row.updatedAt : new Date(row.updatedAt as any),
|
||||
}));
|
||||
};
|
||||
}
|
||||
|
||||
@@ -175,12 +175,13 @@ export class TaskTopicModel {
|
||||
.orderBy(desc(taskTopics.seq));
|
||||
}
|
||||
|
||||
async findWithHandoff(taskId: string, limit = 4) {
|
||||
async findWithHandoff(taskId: string, limit: number) {
|
||||
const { topics } = await import('../schemas/topic');
|
||||
return this.db
|
||||
.select({
|
||||
createdAt: taskTopics.createdAt,
|
||||
handoff: taskTopics.handoff,
|
||||
metadata: topics.metadata,
|
||||
seq: taskTopics.seq,
|
||||
status: taskTopics.status,
|
||||
title: topics.title,
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
export interface ShowDesktopNotificationParams {
|
||||
body: string;
|
||||
force?: boolean;
|
||||
/**
|
||||
* SPA path to navigate to when the user clicks the notification.
|
||||
* Reuses the existing `navigate` main-broadcast pipeline, so it requires
|
||||
* `DesktopNavigationBridge` to be mounted on the renderer side.
|
||||
*/
|
||||
navigate?: { path: string; replace?: boolean };
|
||||
requestAttention?: boolean;
|
||||
silent?: boolean;
|
||||
title: string;
|
||||
|
||||
@@ -1,6 +1,67 @@
|
||||
import { type AIChatModelCard } from '../types/aiModel';
|
||||
|
||||
const aihubmixModels: AIChatModelCard[] = [
|
||||
{
|
||||
abilities: {
|
||||
functionCall: true,
|
||||
reasoning: true,
|
||||
search: true,
|
||||
structuredOutput: true,
|
||||
vision: true,
|
||||
},
|
||||
contextWindowTokens: 1_050_000,
|
||||
description: 'GPT-5.5 is our newest frontier model for the most complex professional work.',
|
||||
displayName: 'GPT-5.5',
|
||||
enabled: true,
|
||||
id: 'gpt-5.5',
|
||||
maxOutput: 128_000,
|
||||
pricing: {
|
||||
units: [
|
||||
{
|
||||
lookup: {
|
||||
prices: {
|
||||
'[0, 0.272]': 5,
|
||||
'[0.272, infinity]': 10,
|
||||
},
|
||||
pricingParams: ['textInput'],
|
||||
},
|
||||
name: 'textInput',
|
||||
strategy: 'lookup',
|
||||
unit: 'millionTokens',
|
||||
},
|
||||
{
|
||||
lookup: {
|
||||
prices: {
|
||||
'[0, 0.272]': 0.5,
|
||||
'[0.272, infinity]': 1,
|
||||
},
|
||||
pricingParams: ['textInput'],
|
||||
},
|
||||
name: 'textInput_cacheRead',
|
||||
strategy: 'lookup',
|
||||
unit: 'millionTokens',
|
||||
},
|
||||
{
|
||||
lookup: {
|
||||
prices: {
|
||||
'[0, 0.272]': 30,
|
||||
'[0.272, infinity]': 45,
|
||||
},
|
||||
pricingParams: ['textInput'],
|
||||
},
|
||||
name: 'textOutput',
|
||||
strategy: 'lookup',
|
||||
unit: 'millionTokens',
|
||||
},
|
||||
],
|
||||
},
|
||||
releasedAt: '2026-04-23',
|
||||
settings: {
|
||||
extendParams: ['gpt5_2ReasoningEffort', 'textVerbosity'],
|
||||
searchImpl: 'params',
|
||||
},
|
||||
type: 'chat',
|
||||
},
|
||||
{
|
||||
abilities: {
|
||||
functionCall: true,
|
||||
@@ -13,7 +74,6 @@ const aihubmixModels: AIChatModelCard[] = [
|
||||
description:
|
||||
'GPT-5.4 is the frontier model for complex professional work with highest reasoning capability.',
|
||||
displayName: 'GPT-5.4',
|
||||
enabled: true,
|
||||
id: 'gpt-5.4',
|
||||
maxOutput: 128_000,
|
||||
pricing: {
|
||||
@@ -853,6 +913,36 @@ const aihubmixModels: AIChatModelCard[] = [
|
||||
},
|
||||
type: 'chat',
|
||||
},
|
||||
{
|
||||
abilities: {
|
||||
functionCall: true,
|
||||
reasoning: true,
|
||||
search: true,
|
||||
structuredOutput: true,
|
||||
vision: true,
|
||||
},
|
||||
contextWindowTokens: 1_000_000,
|
||||
description:
|
||||
"Claude Opus 4.7 is Anthropic's most capable generally available model for complex reasoning and agentic coding.",
|
||||
displayName: 'Claude Opus 4.7',
|
||||
enabled: true,
|
||||
id: 'claude-opus-4-7',
|
||||
maxOutput: 128_000,
|
||||
pricing: {
|
||||
units: [
|
||||
{ name: 'textInput_cacheRead', rate: 0.5, strategy: 'fixed', unit: 'millionTokens' },
|
||||
{ name: 'textInput', rate: 5, strategy: 'fixed', unit: 'millionTokens' },
|
||||
{ name: 'textOutput', rate: 25, strategy: 'fixed', unit: 'millionTokens' },
|
||||
{ name: 'textInput_cacheWrite', rate: 6.25, strategy: 'fixed', unit: 'millionTokens' },
|
||||
],
|
||||
},
|
||||
releasedAt: '2026-04-16',
|
||||
settings: {
|
||||
extendParams: ['disableContextCaching', 'enableAdaptiveThinking', 'opus47Effort'],
|
||||
searchImpl: 'params',
|
||||
},
|
||||
type: 'chat',
|
||||
},
|
||||
{
|
||||
abilities: {
|
||||
functionCall: true,
|
||||
@@ -865,7 +955,6 @@ const aihubmixModels: AIChatModelCard[] = [
|
||||
description:
|
||||
'Claude Opus 4.6 is Anthropic’s most intelligent model for building agents and coding.',
|
||||
displayName: 'Claude Opus 4.6',
|
||||
enabled: true,
|
||||
id: 'claude-opus-4-6',
|
||||
maxOutput: 128_000,
|
||||
pricing: {
|
||||
@@ -1195,48 +1284,6 @@ const aihubmixModels: AIChatModelCard[] = [
|
||||
},
|
||||
type: 'chat',
|
||||
},
|
||||
{
|
||||
abilities: {
|
||||
functionCall: true,
|
||||
},
|
||||
contextWindowTokens: 131_072,
|
||||
description:
|
||||
'DeepSeek-V3.2 is an efficient LLM with DSA sparse attention and strengthened reasoning. Its key strength is agent capability, combining reasoning with real tool use through large-scale task synthesis for more robust, compliant, and generalizable agents.',
|
||||
displayName: 'DeepSeek V3.2',
|
||||
id: 'deepseek-chat',
|
||||
maxOutput: 8192,
|
||||
pricing: {
|
||||
units: [
|
||||
{ name: 'textInput', rate: 0.3, strategy: 'fixed', unit: 'millionTokens' },
|
||||
{ name: 'textOutput', rate: 0.45, strategy: 'fixed', unit: 'millionTokens' },
|
||||
{ name: 'textInput_cacheRead', rate: 0.03, strategy: 'fixed', unit: 'millionTokens' },
|
||||
],
|
||||
},
|
||||
releasedAt: '2025-12-01',
|
||||
type: 'chat',
|
||||
},
|
||||
{
|
||||
abilities: {
|
||||
functionCall: true,
|
||||
reasoning: true,
|
||||
},
|
||||
contextWindowTokens: 131_072,
|
||||
description:
|
||||
'DeepSeek V3.2 thinking mode outputs a chain-of-thought before the final answer to improve accuracy.',
|
||||
displayName: 'DeepSeek V3.2 Thinking',
|
||||
enabled: true,
|
||||
id: 'deepseek-reasoner',
|
||||
maxOutput: 65_536,
|
||||
pricing: {
|
||||
units: [
|
||||
{ name: 'textInput', rate: 0.3, strategy: 'fixed', unit: 'millionTokens' },
|
||||
{ name: 'textOutput', rate: 0.45, strategy: 'fixed', unit: 'millionTokens' },
|
||||
{ name: 'textInput_cacheRead', rate: 0.03, strategy: 'fixed', unit: 'millionTokens' },
|
||||
],
|
||||
},
|
||||
releasedAt: '2025-12-01',
|
||||
type: 'chat',
|
||||
},
|
||||
{
|
||||
abilities: {
|
||||
functionCall: true,
|
||||
|
||||
@@ -1,6 +1,37 @@
|
||||
import { type AIChatModelCard } from '../types/aiModel';
|
||||
|
||||
const anthropicChatModels: AIChatModelCard[] = [
|
||||
{
|
||||
abilities: {
|
||||
functionCall: true,
|
||||
reasoning: true,
|
||||
search: true,
|
||||
structuredOutput: true,
|
||||
vision: true,
|
||||
},
|
||||
contextWindowTokens: 1_000_000,
|
||||
description:
|
||||
"Claude Opus 4.7 is Anthropic's most capable generally available model for complex reasoning and agentic coding.",
|
||||
displayName: 'Claude Opus 4.7',
|
||||
enabled: true,
|
||||
id: 'claude-opus-4-7',
|
||||
maxOutput: 128_000,
|
||||
pricing: {
|
||||
units: [
|
||||
{ name: 'textInput_cacheRead', rate: 0.5, strategy: 'fixed', unit: 'millionTokens' },
|
||||
{ name: 'textInput', rate: 5, strategy: 'fixed', unit: 'millionTokens' },
|
||||
{ name: 'textOutput', rate: 25, strategy: 'fixed', unit: 'millionTokens' },
|
||||
{ name: 'textInput_cacheWrite', rate: 6.25, strategy: 'fixed', unit: 'millionTokens' },
|
||||
],
|
||||
},
|
||||
releasedAt: '2026-04-16',
|
||||
settings: {
|
||||
disabledParams: ['temperature', 'top_p'],
|
||||
extendParams: ['disableContextCaching', 'enableAdaptiveThinking', 'opus47Effort'],
|
||||
searchImpl: 'params',
|
||||
},
|
||||
type: 'chat',
|
||||
},
|
||||
{
|
||||
abilities: {
|
||||
functionCall: true,
|
||||
@@ -13,7 +44,6 @@ const anthropicChatModels: AIChatModelCard[] = [
|
||||
description:
|
||||
'Claude Opus 4.6 is Anthropic’s most intelligent model for building agents and coding.',
|
||||
displayName: 'Claude Opus 4.6',
|
||||
enabled: true,
|
||||
id: 'claude-opus-4-6',
|
||||
maxOutput: 128_000,
|
||||
pricing: {
|
||||
|
||||
@@ -1,6 +1,97 @@
|
||||
import type { AIChatModelCard } from '../types/aiModel';
|
||||
|
||||
const baichuanChatModels: AIChatModelCard[] = [
|
||||
{
|
||||
abilities: {
|
||||
reasoning: true,
|
||||
search: true,
|
||||
},
|
||||
contextWindowTokens: 32_768,
|
||||
description:
|
||||
'We introduce Baichuan-M3, a new-generation medical-enhanced large language model designed to support clinical-grade medical assistance. In contrast to prior approaches that primarily focus on static question answering or superficial role-playing, Baichuan-M3 is trained to explicitly model the clinical decision-making process, aiming to improve usability and reliability in real-world medical practice. Rather than merely producing plausible-sounding answers, fluent doctor-like questioning, or high-frequency but vague recommendations such as “you should seek medical attention as soon as possible,” Baichuan-M3 is explicitly trained to proactively acquire critical clinical information, construct coherent medical reasoning pathways, and systematically constrain hallucination-prone behaviors throughout the decision process. This design endows the model with intrinsic medical-enhanced capabilities aligned with real clinical workflows. Across evaluations of clinical inquiry, medical hallucination robustness, HealthBench, and HealthBench-Hard, Baichuan-M3 surpasses the latest flagship model released by OpenAI, GPT-5.2, establishing a new state of the art in medical-enhanced language models.',
|
||||
displayName: 'Baichuan M3 Plus',
|
||||
id: 'Baichuan-M3-Plus',
|
||||
maxOutput: 32_768,
|
||||
pricing: {
|
||||
currency: 'CNY',
|
||||
units: [
|
||||
{ name: 'textInput', rate: 5, strategy: 'fixed', unit: 'millionTokens' },
|
||||
{ name: 'textOutput', rate: 9, strategy: 'fixed', unit: 'millionTokens' },
|
||||
],
|
||||
},
|
||||
settings: {
|
||||
searchImpl: 'internal',
|
||||
},
|
||||
type: 'chat',
|
||||
},
|
||||
{
|
||||
abilities: {
|
||||
reasoning: true,
|
||||
},
|
||||
contextWindowTokens: 32_768,
|
||||
description:
|
||||
'We introduce Baichuan-M3, a new-generation medical-enhanced large language model designed to support clinical-grade medical assistance. In contrast to prior approaches that primarily focus on static question answering or superficial role-playing, Baichuan-M3 is trained to explicitly model the clinical decision-making process, aiming to improve usability and reliability in real-world medical practice. Rather than merely producing plausible-sounding answers, fluent doctor-like questioning, or high-frequency but vague recommendations such as “you should seek medical attention as soon as possible,” Baichuan-M3 is explicitly trained to proactively acquire critical clinical information, construct coherent medical reasoning pathways, and systematically constrain hallucination-prone behaviors throughout the decision process. This design endows the model with intrinsic medical-enhanced capabilities aligned with real clinical workflows. Across evaluations of clinical inquiry, medical hallucination robustness, HealthBench, and HealthBench-Hard, Baichuan-M3 surpasses the latest flagship model released by OpenAI, GPT-5.2, establishing a new state of the art in medical-enhanced language models.',
|
||||
displayName: 'Baichuan M3',
|
||||
enabled: true,
|
||||
id: 'Baichuan-M3',
|
||||
maxOutput: 32_768,
|
||||
pricing: {
|
||||
currency: 'CNY',
|
||||
units: [
|
||||
{ name: 'textInput', rate: 10, strategy: 'fixed', unit: 'millionTokens' },
|
||||
{ name: 'textOutput', rate: 30, strategy: 'fixed', unit: 'millionTokens' },
|
||||
],
|
||||
},
|
||||
settings: {
|
||||
extendParams: ['reasoningBudgetToken'],
|
||||
},
|
||||
type: 'chat',
|
||||
},
|
||||
{
|
||||
abilities: {
|
||||
reasoning: true,
|
||||
search: true,
|
||||
},
|
||||
contextWindowTokens: 32_768,
|
||||
description:
|
||||
"We introduce Baichuan-M2, a medically-enhanced reasoning model, designed for real-world medical reasoning tasks. We start from real-world medical questions and conduct reinforcement learning training based on a large-scale verifier system. While maintaining the model's general capabilities, the medical effectiveness of the Baichuan-M2 model has achieved a breakthrough improvement. Baichuan-M2 is the best open-source medical model in the world to date. It surpasses all open-source models, including gpt-oss-120b, as well as many cutting-edge closed-source models on the HealthBench Benchmark. It is the open-source model closest to GPT-5 in medical capabilities. Our practice demonstrates that a robust verifier is crucial for linking model capabilities to the real world and an end-to-end reinforcement learning approach fundamentally enhances the model's medical reasoning abilities. The release of Baichuan-M2 advances the cutting edge of technology in the field of medical artificial intelligence.",
|
||||
displayName: 'Baichuan M2 Plus',
|
||||
id: 'Baichuan-M2-Plus',
|
||||
maxOutput: 32_768,
|
||||
pricing: {
|
||||
currency: 'CNY',
|
||||
units: [
|
||||
{ name: 'textInput', rate: 10, strategy: 'fixed', unit: 'millionTokens' },
|
||||
{ name: 'textOutput', rate: 30, strategy: 'fixed', unit: 'millionTokens' },
|
||||
],
|
||||
},
|
||||
settings: {
|
||||
searchImpl: 'internal',
|
||||
},
|
||||
type: 'chat',
|
||||
},
|
||||
{
|
||||
abilities: {
|
||||
reasoning: true,
|
||||
},
|
||||
contextWindowTokens: 32_768,
|
||||
description:
|
||||
"We introduce Baichuan-M2, a medically-enhanced reasoning model, designed for real-world medical reasoning tasks. We start from real-world medical questions and conduct reinforcement learning training based on a large-scale verifier system. While maintaining the model's general capabilities, the medical effectiveness of the Baichuan-M2 model has achieved a breakthrough improvement. Baichuan-M2 is the best open-source medical model in the world to date. It surpasses all open-source models, including gpt-oss-120b, as well as many cutting-edge closed-source models on the HealthBench Benchmark. It is the open-source model closest to GPT-5 in medical capabilities. Our practice demonstrates that a robust verifier is crucial for linking model capabilities to the real world and an end-to-end reinforcement learning approach fundamentally enhances the model's medical reasoning abilities. The release of Baichuan-M2 advances the cutting edge of technology in the field of medical artificial intelligence.",
|
||||
displayName: 'Baichuan M2',
|
||||
id: 'Baichuan-M2',
|
||||
maxOutput: 32_768,
|
||||
pricing: {
|
||||
currency: 'CNY',
|
||||
units: [
|
||||
{ name: 'textInput', rate: 2, strategy: 'fixed', unit: 'millionTokens' },
|
||||
{ name: 'textOutput', rate: 20, strategy: 'fixed', unit: 'millionTokens' },
|
||||
],
|
||||
},
|
||||
settings: {
|
||||
extendParams: ['reasoningBudgetToken'],
|
||||
},
|
||||
type: 'chat',
|
||||
},
|
||||
{
|
||||
abilities: {
|
||||
functionCall: true,
|
||||
|
||||
@@ -1,6 +1,35 @@
|
||||
import type { AIChatModelCard } from '../types/aiModel';
|
||||
|
||||
const bedrockChatModels: AIChatModelCard[] = [
|
||||
{
|
||||
abilities: {
|
||||
functionCall: true,
|
||||
reasoning: true,
|
||||
structuredOutput: true,
|
||||
vision: true,
|
||||
},
|
||||
contextWindowTokens: 1_000_000,
|
||||
description:
|
||||
"Claude Opus 4.7 is Anthropic's most capable generally available model for complex reasoning and agentic coding.",
|
||||
displayName: 'Claude Opus 4.7',
|
||||
enabled: true,
|
||||
id: 'global.anthropic.claude-opus-4-7',
|
||||
maxOutput: 128_000,
|
||||
pricing: {
|
||||
units: [
|
||||
{ name: 'textInput_cacheRead', rate: 0.5, strategy: 'fixed', unit: 'millionTokens' },
|
||||
{ name: 'textInput', rate: 5, strategy: 'fixed', unit: 'millionTokens' },
|
||||
{ name: 'textOutput', rate: 25, strategy: 'fixed', unit: 'millionTokens' },
|
||||
{ name: 'textInput_cacheWrite', rate: 6.25, strategy: 'fixed', unit: 'millionTokens' },
|
||||
],
|
||||
},
|
||||
releasedAt: '2026-04-16',
|
||||
settings: {
|
||||
disabledParams: ['temperature', 'top_p'],
|
||||
extendParams: ['disableContextCaching', 'enableAdaptiveThinking', 'opus47Effort'],
|
||||
},
|
||||
type: 'chat',
|
||||
},
|
||||
{
|
||||
abilities: {
|
||||
functionCall: true,
|
||||
@@ -12,7 +41,6 @@ const bedrockChatModels: AIChatModelCard[] = [
|
||||
description:
|
||||
"Claude Opus 4.6 is Anthropic's most intelligent model for building agents and coding.",
|
||||
displayName: 'Claude Opus 4.6',
|
||||
enabled: true,
|
||||
id: 'global.anthropic.claude-opus-4-6-v1',
|
||||
maxOutput: 128_000,
|
||||
pricing: {
|
||||
|
||||
@@ -220,7 +220,7 @@ export { default as internlm } from './internlm';
|
||||
export { default as jina } from './jina';
|
||||
export { default as kimicodingplan } from './kimiCodingPlan';
|
||||
export { default as lmstudio } from './lmstudio';
|
||||
export { default as lobehub } from './lobehub/index';
|
||||
export { gptImage1Schema, default as lobehub } from './lobehub/index';
|
||||
export { default as longcat } from './longcat';
|
||||
export { default as minimax } from './minimax';
|
||||
export { default as minimaxcodingplan } from './minimaxCodingPlan';
|
||||
@@ -233,7 +233,7 @@ export { default as novita } from './novita';
|
||||
export { default as nvidia } from './nvidia';
|
||||
export { default as ollama } from './ollama';
|
||||
export { default as ollamacloud } from './ollamacloud';
|
||||
export { gptImage1ParamsSchema, default as openai, openaiChatModels } from './openai';
|
||||
export { default as openai, openaiChatModels } from './openai';
|
||||
export { default as opencodecodingplan } from './opencodeCodingPlan';
|
||||
export { default as opencodezen } from './opencodeZen';
|
||||
export { default as openrouter } from './openrouter';
|
||||
|
||||
@@ -1,6 +1,26 @@
|
||||
import type { AIChatModelCard } from '../types/aiModel';
|
||||
|
||||
const longcatModels: AIChatModelCard[] = [
|
||||
{
|
||||
abilities: {
|
||||
functionCall: true,
|
||||
},
|
||||
contextWindowTokens: 1_000_000,
|
||||
description:
|
||||
'The core features of LongCat-2.0-Preview are as follows: Designed for agent development scenarios, with native support for tool use, multi-step reasoning, and long-context tasks; Excels in code generation, automated workflows, and complex instruction execution; Deeply integrated with productivity tools such as Claude Code, OpenClaw, OpenCode, and Kilo Code.',
|
||||
displayName: 'LongCat-2.0-Preview',
|
||||
enabled: true,
|
||||
id: 'LongCat-2.0-Preview',
|
||||
maxOutput: 128_000,
|
||||
pricing: {
|
||||
units: [
|
||||
{ name: 'textInput', rate: 0, strategy: 'fixed', unit: 'millionTokens' },
|
||||
{ name: 'textOutput', rate: 0, strategy: 'fixed', unit: 'millionTokens' },
|
||||
],
|
||||
},
|
||||
releasedAt: '2026-04-20',
|
||||
type: 'chat',
|
||||
},
|
||||
{
|
||||
abilities: {
|
||||
functionCall: true,
|
||||
@@ -11,6 +31,7 @@ const longcatModels: AIChatModelCard[] = [
|
||||
displayName: 'LongCat-Flash-Lite',
|
||||
enabled: true,
|
||||
id: 'LongCat-Flash-Lite',
|
||||
maxOutput: 262_144,
|
||||
pricing: {
|
||||
units: [
|
||||
{ name: 'textInput', rate: 0, strategy: 'fixed', unit: 'millionTokens' },
|
||||
@@ -27,10 +48,11 @@ const longcatModels: AIChatModelCard[] = [
|
||||
},
|
||||
contextWindowTokens: 262_144,
|
||||
description:
|
||||
'The LongCat-Flash-Thinking-2601 model has been officially released. As an upgraded reasoning model built on a Mixture-of-Experts (MoE) architecture, it features a total of 560 billion parameters. While maintaining strong competitiveness across traditional reasoning benchmarks, it systematically enhances Agent-level reasoning capabilities through large-scale multi-environment reinforcement learning. Compared to the LongCat-Flash-Thinking model, the key upgrades are as follows: Extreme Robustness in Noisy Environments: Through systematic curriculum-style training targeting noise and uncertainty in real-world settings, the model demonstrates outstanding performance in Agent tool invocation, Agent-based search, and tool-integrated reasoning, with significantly improved generalization. Powerful Agent Capabilities: By constructing a tightly coupled dependency graph encompassing more than 60 tools, and scaling training through multi-environment expansion and large-scale exploratory learning, the model markedly improves its ability to generalize to complex and out-of-distribution real-world scenarios. Advanced Deep Thinking Mode: It expands the breadth of reasoning via parallel inference and deepens analytical capability through recursive feedback-driven summarization and abstraction mechanisms, effectively addressing highly challenging problems.',
|
||||
displayName: 'LongCat-Flash-Thinking-2601',
|
||||
'To ensure you receive top-tier reasoning performance, the LongCat API platform has unified and upgraded calls to the LongCat-Flash-Thinking model. All existing requests using `model=LongCat-Flash-Thinking` will be automatically routed to the latest version, LongCat-Flash-Thinking-2601, with no code changes required.',
|
||||
displayName: 'LongCat-Flash-Thinking',
|
||||
enabled: true,
|
||||
id: 'LongCat-Flash-Thinking-2601',
|
||||
id: 'LongCat-Flash-Thinking',
|
||||
maxOutput: 262_144,
|
||||
pricing: {
|
||||
units: [
|
||||
{ name: 'textInput', rate: 0, strategy: 'fixed', unit: 'millionTokens' },
|
||||
@@ -47,16 +69,17 @@ const longcatModels: AIChatModelCard[] = [
|
||||
},
|
||||
contextWindowTokens: 262_144,
|
||||
description:
|
||||
'LongCat-Flash-Thinking has been officially released and open-sourced simultaneously. It is a deep reasoning model that can be used for free conversations within LongCat Chat, or accessed via API by specifying model=LongCat-Flash-Thinking.',
|
||||
displayName: 'LongCat-Flash-Thinking',
|
||||
id: 'LongCat-Flash-Thinking',
|
||||
'The LongCat-Flash-Thinking-2601 model has been officially released. As an upgraded reasoning model built on a Mixture-of-Experts (MoE) architecture, it features a total of 560 billion parameters. While maintaining strong competitiveness across traditional reasoning benchmarks, it systematically enhances Agent-level reasoning capabilities through large-scale multi-environment reinforcement learning. Compared to the LongCat-Flash-Thinking model, the key upgrades are as follows: Extreme Robustness in Noisy Environments: Through systematic curriculum-style training targeting noise and uncertainty in real-world settings, the model demonstrates outstanding performance in Agent tool invocation, Agent-based search, and tool-integrated reasoning, with significantly improved generalization. Powerful Agent Capabilities: By constructing a tightly coupled dependency graph encompassing more than 60 tools, and scaling training through multi-environment expansion and large-scale exploratory learning, the model markedly improves its ability to generalize to complex and out-of-distribution real-world scenarios. Advanced Deep Thinking Mode: It expands the breadth of reasoning via parallel inference and deepens analytical capability through recursive feedback-driven summarization and abstraction mechanisms, effectively addressing highly challenging problems.',
|
||||
displayName: 'LongCat-Flash-Thinking-2601',
|
||||
id: 'LongCat-Flash-Thinking-2601',
|
||||
maxOutput: 262_144,
|
||||
pricing: {
|
||||
units: [
|
||||
{ name: 'textInput', rate: 0, strategy: 'fixed', unit: 'millionTokens' },
|
||||
{ name: 'textOutput', rate: 0, strategy: 'fixed', unit: 'millionTokens' },
|
||||
],
|
||||
},
|
||||
releasedAt: '2025-09-22',
|
||||
releasedAt: '2026-01-14',
|
||||
type: 'chat',
|
||||
},
|
||||
{
|
||||
@@ -69,6 +92,7 @@ const longcatModels: AIChatModelCard[] = [
|
||||
displayName: 'LongCat-Flash-Chat',
|
||||
enabled: true,
|
||||
id: 'LongCat-Flash-Chat',
|
||||
maxOutput: 262_144,
|
||||
pricing: {
|
||||
units: [
|
||||
{ name: 'textInput', rate: 0, strategy: 'fixed', unit: 'millionTokens' },
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { type ModelParamsSchema } from '../standard-parameters';
|
||||
import {
|
||||
type AIChatModelCard,
|
||||
type AIEmbeddingModelCard,
|
||||
@@ -8,17 +7,107 @@ import {
|
||||
type AITTSModelCard,
|
||||
type AIVideoModelCard,
|
||||
} from '../types/aiModel';
|
||||
|
||||
export const gptImage1ParamsSchema: ModelParamsSchema = {
|
||||
imageUrls: { default: [] },
|
||||
prompt: { default: '' },
|
||||
size: {
|
||||
default: 'auto',
|
||||
enum: ['auto', '1024x1024', '1536x1024', '1024x1536'],
|
||||
},
|
||||
};
|
||||
import { gptImage1Schema, gptImage2Schema } from './lobehub';
|
||||
|
||||
export const openaiChatModels: AIChatModelCard[] = [
|
||||
{
|
||||
abilities: {
|
||||
functionCall: true,
|
||||
reasoning: true,
|
||||
search: true,
|
||||
structuredOutput: true,
|
||||
vision: true,
|
||||
},
|
||||
contextWindowTokens: 1_050_000,
|
||||
description: 'GPT-5.5 is our newest frontier model for the most complex professional work.',
|
||||
displayName: 'GPT-5.5',
|
||||
enabled: true,
|
||||
id: 'gpt-5.5',
|
||||
maxOutput: 128_000,
|
||||
pricing: {
|
||||
units: [
|
||||
{
|
||||
lookup: {
|
||||
prices: {
|
||||
'[0, 0.272]': 5,
|
||||
'[0.272, infinity]': 10,
|
||||
},
|
||||
pricingParams: ['textInput'],
|
||||
},
|
||||
name: 'textInput',
|
||||
strategy: 'lookup',
|
||||
unit: 'millionTokens',
|
||||
},
|
||||
{
|
||||
lookup: {
|
||||
prices: {
|
||||
'[0, 0.272]': 0.5,
|
||||
'[0.272, infinity]': 1,
|
||||
},
|
||||
pricingParams: ['textInput'],
|
||||
},
|
||||
name: 'textInput_cacheRead',
|
||||
strategy: 'lookup',
|
||||
unit: 'millionTokens',
|
||||
},
|
||||
{
|
||||
lookup: {
|
||||
prices: {
|
||||
'[0, 0.272]': 30,
|
||||
'[0.272, infinity]': 45,
|
||||
},
|
||||
pricingParams: ['textInput'],
|
||||
},
|
||||
name: 'textOutput',
|
||||
strategy: 'lookup',
|
||||
unit: 'millionTokens',
|
||||
},
|
||||
],
|
||||
},
|
||||
releasedAt: '2026-04-23',
|
||||
settings: {
|
||||
extendParams: ['gpt5_2ReasoningEffort', 'textVerbosity'],
|
||||
searchImpl: 'params',
|
||||
},
|
||||
type: 'chat',
|
||||
},
|
||||
{
|
||||
abilities: {
|
||||
functionCall: true,
|
||||
reasoning: true,
|
||||
search: true,
|
||||
structuredOutput: true,
|
||||
vision: true,
|
||||
},
|
||||
contextWindowTokens: 1_050_000,
|
||||
description:
|
||||
'GPT-5.5 pro uses more compute to think harder and provide consistently better answers.',
|
||||
displayName: 'GPT-5.5 Pro',
|
||||
id: 'gpt-5.5-pro',
|
||||
maxOutput: 128_000,
|
||||
pricing: {
|
||||
units: [
|
||||
{
|
||||
name: 'textInput',
|
||||
rate: 30,
|
||||
strategy: 'fixed',
|
||||
unit: 'millionTokens',
|
||||
},
|
||||
{
|
||||
name: 'textOutput',
|
||||
rate: 180,
|
||||
strategy: 'fixed',
|
||||
unit: 'millionTokens',
|
||||
},
|
||||
],
|
||||
},
|
||||
releasedAt: '2026-04-23',
|
||||
settings: {
|
||||
extendParams: ['gpt5_2ProReasoningEffort', 'textVerbosity'],
|
||||
searchImpl: 'params',
|
||||
},
|
||||
type: 'chat',
|
||||
},
|
||||
{
|
||||
abilities: {
|
||||
functionCall: true,
|
||||
@@ -232,7 +321,6 @@ export const openaiChatModels: AIChatModelCard[] = [
|
||||
description:
|
||||
"GPT-5.4 nano is OpenAI's cheapest GPT-5.4-class model for simple high-volume tasks.",
|
||||
displayName: 'GPT-5.4 nano',
|
||||
enabled: true,
|
||||
id: 'gpt-5.4-nano',
|
||||
maxOutput: 128_000,
|
||||
pricing: {
|
||||
@@ -1509,13 +1597,34 @@ export const openaiSTTModels: AISTTModelCard[] = [
|
||||
|
||||
// Image generation models
|
||||
export const openaiImageModels: AIImageModelCard[] = [
|
||||
{
|
||||
description:
|
||||
"OpenAI's next-generation multimodal image model with native reasoning, up to 4K resolution, near-perfect text rendering, and high-fidelity multilingual support.",
|
||||
displayName: 'GPT Image 2',
|
||||
enabled: true,
|
||||
id: 'gpt-image-2',
|
||||
parameters: gptImage2Schema,
|
||||
pricing: {
|
||||
// Medium quality at 1024x1024: ~1767 output tokens * $30/M = $0.053 per image.
|
||||
// Source: https://developers.openai.com/api/docs/guides/image-generation#calculating-costs
|
||||
approximatePricePerImage: 0.053,
|
||||
units: [
|
||||
{ name: 'textInput', rate: 5, strategy: 'fixed', unit: 'millionTokens' },
|
||||
{ name: 'textInput_cacheRead', rate: 1.25, strategy: 'fixed', unit: 'millionTokens' },
|
||||
{ name: 'imageInput', rate: 8, strategy: 'fixed', unit: 'millionTokens' },
|
||||
{ name: 'imageInput_cacheRead', rate: 2, strategy: 'fixed', unit: 'millionTokens' },
|
||||
{ name: 'imageOutput', rate: 30, strategy: 'fixed', unit: 'millionTokens' },
|
||||
],
|
||||
},
|
||||
releasedAt: '2026-04-21',
|
||||
type: 'image',
|
||||
},
|
||||
{
|
||||
description:
|
||||
'An enhanced GPT Image 1 model with 4× faster generation, more precise editing, and improved text rendering.',
|
||||
displayName: 'GPT Image 1.5',
|
||||
enabled: true,
|
||||
id: 'gpt-image-1.5',
|
||||
parameters: gptImage1ParamsSchema,
|
||||
parameters: gptImage1Schema,
|
||||
pricing: {
|
||||
approximatePricePerImage: 0.034,
|
||||
units: [
|
||||
@@ -1533,9 +1642,8 @@ export const openaiImageModels: AIImageModelCard[] = [
|
||||
{
|
||||
description: 'ChatGPT native multimodal image generation model.',
|
||||
displayName: 'GPT Image 1',
|
||||
enabled: true,
|
||||
id: 'gpt-image-1',
|
||||
parameters: gptImage1ParamsSchema,
|
||||
parameters: gptImage1Schema,
|
||||
pricing: {
|
||||
approximatePricePerImage: 0.042,
|
||||
units: [
|
||||
@@ -1552,9 +1660,8 @@ export const openaiImageModels: AIImageModelCard[] = [
|
||||
description:
|
||||
'A lower-cost GPT Image 1 variant with native text and image input and image output.',
|
||||
displayName: 'GPT Image 1 Mini',
|
||||
enabled: true,
|
||||
id: 'gpt-image-1-mini',
|
||||
parameters: gptImage1ParamsSchema,
|
||||
parameters: gptImage1Schema,
|
||||
pricing: {
|
||||
approximatePricePerImage: 0.011,
|
||||
units: [
|
||||
@@ -1572,7 +1679,6 @@ export const openaiImageModels: AIImageModelCard[] = [
|
||||
description:
|
||||
'The latest DALL·E model, released in November 2023, supports more realistic, accurate image generation with stronger detail.',
|
||||
displayName: 'DALL·E 3',
|
||||
enabled: true,
|
||||
id: 'dall-e-3',
|
||||
parameters: {
|
||||
prompt: { default: '' },
|
||||
|
||||
@@ -7,6 +7,30 @@ import {
|
||||
// https://help.aliyun.com/zh/model-studio/models?spm=a2c4g.11186623
|
||||
|
||||
const qwenChatModels: AIChatModelCard[] = [
|
||||
{
|
||||
abilities: {
|
||||
functionCall: true,
|
||||
reasoning: true,
|
||||
vision: true,
|
||||
},
|
||||
contextWindowTokens: 262_144,
|
||||
description:
|
||||
"Kimi K2.6 is Kimi's latest and most capable model, delivering stronger long-horizon coding, instruction following, and self-correction while supporting text, image, and video inputs plus chat and agent tasks.",
|
||||
displayName: 'Kimi K2.6',
|
||||
id: 'kimi-k2.6',
|
||||
maxOutput: 32_768,
|
||||
pricing: {
|
||||
currency: 'CNY',
|
||||
units: [
|
||||
{ name: 'textInput', rate: 6.5, strategy: 'fixed', unit: 'millionTokens' },
|
||||
{ name: 'textOutput', rate: 27, strategy: 'fixed', unit: 'millionTokens' },
|
||||
],
|
||||
},
|
||||
settings: {
|
||||
extendParams: ['enableReasoning', 'reasoningBudgetToken'],
|
||||
},
|
||||
type: 'chat',
|
||||
},
|
||||
{
|
||||
abilities: {
|
||||
functionCall: true,
|
||||
@@ -184,6 +208,56 @@ const qwenChatModels: AIChatModelCard[] = [
|
||||
},
|
||||
type: 'chat',
|
||||
},
|
||||
{
|
||||
abilities: {
|
||||
functionCall: true,
|
||||
reasoning: true,
|
||||
search: true,
|
||||
},
|
||||
contextWindowTokens: 1_000_000,
|
||||
description:
|
||||
'DeepSeek V4 Flash is the cost-efficient member of the V4 family with a 1M context window and hybrid thinking. Thinking mode is on by default and can be toggled via the `thinking` parameter; non-thinking mode is optimized for latency-sensitive workflows.',
|
||||
displayName: 'DeepSeek V4 Flash',
|
||||
id: 'deepseek-v4-flash',
|
||||
maxOutput: 393_216,
|
||||
pricing: {
|
||||
currency: 'CNY',
|
||||
units: [
|
||||
{ name: 'textInput', rate: 1, strategy: 'fixed', unit: 'millionTokens' },
|
||||
{ name: 'textOutput', rate: 2, strategy: 'fixed', unit: 'millionTokens' },
|
||||
],
|
||||
},
|
||||
settings: {
|
||||
extendParams: ['enableReasoning', 'deepseekV4ReasoningEffort'],
|
||||
searchImpl: 'params',
|
||||
},
|
||||
type: 'chat',
|
||||
},
|
||||
{
|
||||
abilities: {
|
||||
functionCall: true,
|
||||
reasoning: true,
|
||||
search: true,
|
||||
},
|
||||
contextWindowTokens: 1_000_000,
|
||||
description:
|
||||
'DeepSeek V4 Pro is the flagship of the V4 family, optimized for high-intensity reasoning, agentic workflows, and long-horizon planning. Thinking mode is on by default and can be toggled via the `thinking` parameter.',
|
||||
displayName: 'DeepSeek V4 Pro',
|
||||
id: 'deepseek-v4-pro',
|
||||
maxOutput: 393_216,
|
||||
pricing: {
|
||||
currency: 'CNY',
|
||||
units: [
|
||||
{ name: 'textInput', rate: 12, strategy: 'fixed', unit: 'millionTokens' },
|
||||
{ name: 'textOutput', rate: 24, strategy: 'fixed', unit: 'millionTokens' },
|
||||
],
|
||||
},
|
||||
settings: {
|
||||
extendParams: ['enableReasoning', 'deepseekV4ReasoningEffort'],
|
||||
searchImpl: 'params',
|
||||
},
|
||||
type: 'chat',
|
||||
},
|
||||
{
|
||||
abilities: {
|
||||
functionCall: true,
|
||||
@@ -205,6 +279,7 @@ const qwenChatModels: AIChatModelCard[] = [
|
||||
},
|
||||
settings: {
|
||||
extendParams: ['enableReasoning', 'reasoningBudgetToken'],
|
||||
searchImpl: 'params',
|
||||
},
|
||||
type: 'chat',
|
||||
},
|
||||
@@ -229,6 +304,7 @@ const qwenChatModels: AIChatModelCard[] = [
|
||||
},
|
||||
settings: {
|
||||
extendParams: ['enableReasoning', 'reasoningBudgetToken'],
|
||||
searchImpl: 'params',
|
||||
},
|
||||
type: 'chat',
|
||||
},
|
||||
@@ -253,6 +329,7 @@ const qwenChatModels: AIChatModelCard[] = [
|
||||
},
|
||||
settings: {
|
||||
extendParams: ['enableReasoning', 'reasoningBudgetToken'],
|
||||
searchImpl: 'params',
|
||||
},
|
||||
type: 'chat',
|
||||
},
|
||||
@@ -305,6 +382,51 @@ const qwenChatModels: AIChatModelCard[] = [
|
||||
},
|
||||
type: 'chat',
|
||||
},
|
||||
{
|
||||
abilities: {
|
||||
functionCall: true,
|
||||
reasoning: true,
|
||||
},
|
||||
contextWindowTokens: 202_752,
|
||||
description:
|
||||
'GLM-5.1 is Zhipu’s latest flagship model, aligned with Claude Opus 4.6 on overall and coding capabilities. It excels at long-horizon tasks, able to autonomously plan, execute, and iterate for up to 8 hours in a single task, making it an ideal foundation for Autonomous Agents and long-horizon Coding Agents.',
|
||||
displayName: 'GLM-5.1',
|
||||
id: 'glm-5.1',
|
||||
maxOutput: 16_384,
|
||||
pricing: {
|
||||
currency: 'CNY',
|
||||
units: [
|
||||
{
|
||||
lookup: {
|
||||
prices: {
|
||||
'[0, 0.032]': 6,
|
||||
'[0.032, infinity]': 8,
|
||||
},
|
||||
pricingParams: ['textInputRange'],
|
||||
},
|
||||
name: 'textInput',
|
||||
strategy: 'lookup',
|
||||
unit: 'millionTokens',
|
||||
},
|
||||
{
|
||||
lookup: {
|
||||
prices: {
|
||||
'[0, 0.032]': 24,
|
||||
'[0.032, infinity]': 28,
|
||||
},
|
||||
pricingParams: ['textInputRange'],
|
||||
},
|
||||
name: 'textOutput',
|
||||
strategy: 'lookup',
|
||||
unit: 'millionTokens',
|
||||
},
|
||||
],
|
||||
},
|
||||
settings: {
|
||||
extendParams: ['enableReasoning', 'reasoningBudgetToken'],
|
||||
},
|
||||
type: 'chat',
|
||||
},
|
||||
{
|
||||
abilities: {
|
||||
functionCall: true,
|
||||
@@ -818,6 +940,33 @@ const qwenChatModels: AIChatModelCard[] = [
|
||||
},
|
||||
type: 'chat',
|
||||
},
|
||||
{
|
||||
abilities: {
|
||||
functionCall: true,
|
||||
reasoning: true,
|
||||
video: true,
|
||||
vision: true,
|
||||
},
|
||||
contextWindowTokens: 262_144,
|
||||
description:
|
||||
'The Qwen 3.6 series 27B is a native vision-language dense model. Compared to version 3.5-27B, it delivers significant improvements in agentic coding capabilities, with further enhancements in STEM performance and reasoning ability. On the visual side, it shows notable gains in spatial intelligence, object localization, and detection, while also steadily improving in video understanding, document OCR, and visual agent capabilities.',
|
||||
displayName: 'Qwen3.6-27B',
|
||||
id: 'qwen3.6-27b',
|
||||
maxOutput: 65_536,
|
||||
organization: 'Qwen',
|
||||
pricing: {
|
||||
currency: 'CNY',
|
||||
units: [
|
||||
{ name: 'textInput', rate: 3, strategy: 'fixed', unit: 'millionTokens' },
|
||||
{ name: 'textOutput', rate: 18, strategy: 'fixed', unit: 'millionTokens' },
|
||||
],
|
||||
},
|
||||
releasedAt: '2026-04-22',
|
||||
settings: {
|
||||
extendParams: ['enableReasoning', 'reasoningBudgetToken'],
|
||||
},
|
||||
type: 'chat',
|
||||
},
|
||||
{
|
||||
abilities: {
|
||||
functionCall: true,
|
||||
@@ -1687,6 +1836,88 @@ const qwenChatModels: AIChatModelCard[] = [
|
||||
},
|
||||
type: 'chat',
|
||||
},
|
||||
{
|
||||
abilities: {
|
||||
functionCall: true,
|
||||
reasoning: true,
|
||||
search: true,
|
||||
video: true,
|
||||
vision: true,
|
||||
},
|
||||
config: {
|
||||
deploymentName: 'qwen3.5-plus-2026-04-20', // Supports context caching
|
||||
},
|
||||
contextWindowTokens: 1_000_000,
|
||||
description:
|
||||
'Qwen 3.5 is a native vision-language Plus model. Compared to the February 15 snapshot, this version delivers substantial improvements in agentic coding capabilities and significantly faster inference speed. Its knowledge, reasoning, and long-context abilities remain at a high level, meeting the demands of complex agent tasks. It is well-suited for coding agents, production workflows, and high-throughput scenarios. This version corresponds to the April 20, 2026 snapshot.',
|
||||
displayName: 'Qwen3.5 Plus 2026-04-20',
|
||||
id: 'qwen3.5-plus-2026-04-20',
|
||||
maxOutput: 65_536,
|
||||
organization: 'Qwen',
|
||||
pricing: {
|
||||
currency: 'CNY',
|
||||
units: [
|
||||
{
|
||||
lookup: {
|
||||
prices: {
|
||||
'[0, 0.128]': 0.8 * 0.1,
|
||||
'[0.128, 0.256]': 2 * 0.1,
|
||||
'[0.256, infinity]': 4 * 0.1,
|
||||
},
|
||||
pricingParams: ['textInputRange'],
|
||||
},
|
||||
name: 'textInput_cacheRead',
|
||||
strategy: 'lookup',
|
||||
unit: 'millionTokens',
|
||||
},
|
||||
{
|
||||
lookup: {
|
||||
prices: {
|
||||
'[0, 0.128]': 0.8 * 1.25,
|
||||
'[0.128, 0.256]': 2 * 1.25,
|
||||
'[0.256, infinity]': 4 * 1.25,
|
||||
},
|
||||
pricingParams: ['textInputRange'],
|
||||
},
|
||||
name: 'textInput_cacheWrite',
|
||||
strategy: 'lookup',
|
||||
unit: 'millionTokens',
|
||||
},
|
||||
{
|
||||
lookup: {
|
||||
prices: {
|
||||
'[0, 0.128]': 0.8,
|
||||
'[0.128, 0.256]': 2,
|
||||
'[0.256, infinity]': 4,
|
||||
},
|
||||
pricingParams: ['textInputRange'],
|
||||
},
|
||||
name: 'textInput',
|
||||
strategy: 'lookup',
|
||||
unit: 'millionTokens',
|
||||
},
|
||||
{
|
||||
lookup: {
|
||||
prices: {
|
||||
'[0, 0.128]': 4.8,
|
||||
'[0.128, 0.256]': 12,
|
||||
'[0.256, infinity]': 24,
|
||||
},
|
||||
pricingParams: ['textInputRange'],
|
||||
},
|
||||
name: 'textOutput',
|
||||
strategy: 'lookup',
|
||||
unit: 'millionTokens',
|
||||
},
|
||||
],
|
||||
},
|
||||
releasedAt: '2026-04-22',
|
||||
settings: {
|
||||
extendParams: ['enableReasoning', 'reasoningBudgetToken'],
|
||||
searchImpl: 'params',
|
||||
},
|
||||
type: 'chat',
|
||||
},
|
||||
{
|
||||
abilities: {
|
||||
functionCall: true,
|
||||
@@ -1851,6 +2082,7 @@ const qwenChatModels: AIChatModelCard[] = [
|
||||
description:
|
||||
'The largest closed-source model in the Qwen3.6 series. It delivers stronger world knowledge, instruction following, and agentic coding performance for complex tasks. It is text-only, supports thinking mode by default, explicit caching, and function calling.',
|
||||
displayName: 'Qwen3.6 Max Preview',
|
||||
enabled: true,
|
||||
id: 'qwen3.6-max-preview',
|
||||
maxOutput: 65_536,
|
||||
organization: 'Qwen',
|
||||
@@ -1915,7 +2147,6 @@ const qwenChatModels: AIChatModelCard[] = [
|
||||
description:
|
||||
'Qwen3 Max models deliver large gains over the 2.5 series in general ability, Chinese/English understanding, complex instruction following, subjective open tasks, multilingual ability, and tool use, with fewer hallucinations. The latest qwen3-max improves agentic programming and tool use over qwen3-max-preview. This release reaches field SOTA and targets more complex agent needs.',
|
||||
displayName: 'Qwen3 Max',
|
||||
enabled: true,
|
||||
id: 'qwen3-max',
|
||||
maxOutput: 65_536,
|
||||
organization: 'Qwen',
|
||||
@@ -2996,6 +3227,9 @@ const qwenChatModels: AIChatModelCard[] = [
|
||||
],
|
||||
},
|
||||
releasedAt: '2025-05-28',
|
||||
settings: {
|
||||
searchImpl: 'params',
|
||||
},
|
||||
type: 'chat',
|
||||
},
|
||||
{
|
||||
@@ -3018,6 +3252,9 @@ const qwenChatModels: AIChatModelCard[] = [
|
||||
],
|
||||
},
|
||||
releasedAt: '2025-01-27',
|
||||
settings: {
|
||||
searchImpl: 'params',
|
||||
},
|
||||
type: 'chat',
|
||||
},
|
||||
{
|
||||
@@ -3166,12 +3403,38 @@ const qwenImageModels: AIImageModelCard[] = [
|
||||
releasedAt: '2025-12-19',
|
||||
type: 'image',
|
||||
},
|
||||
{
|
||||
description:
|
||||
'The Qwen-Image-2.0 series full-version model integrates image generation and image editing into a unified capability. It supports more professional text rendering with up to 1k token instruction capacity, delivers more delicate and realistic visual textures, enables fine-grained depiction of realistic scenes, and demonstrates stronger semantic alignment with prompts. The full-version model provides the strongest text rendering capability and the highest level of realism within the 2.0 series.',
|
||||
displayName: 'Qwen Image 2.0 Pro 2026-04-22',
|
||||
id: 'qwen-image-2.0-pro-2026-04-22',
|
||||
enabled: true,
|
||||
organization: 'Qwen',
|
||||
parameters: {
|
||||
height: { default: 1024, max: 4096, min: 256, step: 1 },
|
||||
imageUrls: {
|
||||
default: [],
|
||||
},
|
||||
prompt: {
|
||||
default: '',
|
||||
},
|
||||
seed: { default: null },
|
||||
width: { default: 1024, max: 4096, min: 256, step: 1 },
|
||||
promptExtend: { default: false },
|
||||
watermark: { default: false },
|
||||
},
|
||||
pricing: {
|
||||
currency: 'CNY',
|
||||
units: [{ name: 'imageGeneration', rate: 0.5, strategy: 'fixed', unit: 'image' }],
|
||||
},
|
||||
releasedAt: '2026-04-22',
|
||||
type: 'image',
|
||||
},
|
||||
{
|
||||
description:
|
||||
'The Qwen-Image-2.0 series full-version model integrates image generation and image editing into a unified capability. It supports more professional text rendering with up to 1k token instruction capacity, delivers more delicate and realistic visual textures, enables fine-grained depiction of realistic scenes, and demonstrates stronger semantic alignment with prompts. The full-version model provides the strongest text rendering capability and the highest level of realism within the 2.0 series.',
|
||||
displayName: 'Qwen Image 2.0 Pro',
|
||||
id: 'qwen-image-2.0-pro',
|
||||
enabled: true,
|
||||
organization: 'Qwen',
|
||||
parameters: {
|
||||
height: { default: 1024, max: 4096, min: 256, step: 1 },
|
||||
@@ -3721,11 +3984,124 @@ const qwenImageModels: AIImageModelCard[] = [
|
||||
];
|
||||
|
||||
const qwenVideoModels: AIVideoModelCard[] = [
|
||||
{
|
||||
description:
|
||||
'HappyHorse-1.0-I2V supports text-to-video generation, delivering highly faithful dynamic visuals. It can accurately understand textual semantics and produce high-quality videos that are smooth, natural, and rich in detail.',
|
||||
displayName: 'HappyHorse-1.0-I2V',
|
||||
enabled: true,
|
||||
id: 'happyhorse-1.0-i2v',
|
||||
parameters: {
|
||||
duration: { default: 5, max: 15, min: 3 },
|
||||
imageUrl: {
|
||||
default: null,
|
||||
},
|
||||
prompt: { default: '' },
|
||||
resolution: {
|
||||
default: '1080P',
|
||||
enum: ['720P', '1080P'],
|
||||
},
|
||||
seed: { default: null },
|
||||
watermark: { default: false },
|
||||
},
|
||||
pricing: {
|
||||
currency: 'CNY',
|
||||
units: [{ name: 'videoGeneration', rate: 1.6, strategy: 'fixed', unit: 'second' }],
|
||||
},
|
||||
releasedAt: '2026-04-22',
|
||||
type: 'video',
|
||||
},
|
||||
{
|
||||
description:
|
||||
'HappyHorse-1.0-R2V supports reference-based video generation, offering more stable subject and scene consistency. It supports up to 9 reference images, accurately preserves creative intent, and delivers enhanced expressive capability.',
|
||||
displayName: 'HappyHorse-1.0-R2V',
|
||||
enabled: true,
|
||||
id: 'happyhorse-1.0-r2v',
|
||||
parameters: {
|
||||
aspectRatio: {
|
||||
default: '16:9',
|
||||
enum: ['16:9', '9:16', '1:1', '4:3', '3:4'],
|
||||
},
|
||||
duration: { default: 5, max: 10, min: 3 },
|
||||
imageUrls: {
|
||||
default: [],
|
||||
maxCount: 9,
|
||||
},
|
||||
prompt: { default: '' },
|
||||
resolution: {
|
||||
default: '1080P',
|
||||
enum: ['720P', '1080P'],
|
||||
},
|
||||
seed: { default: null },
|
||||
watermark: { default: false },
|
||||
},
|
||||
pricing: {
|
||||
currency: 'CNY',
|
||||
units: [{ name: 'videoGeneration', rate: 1.6, strategy: 'fixed', unit: 'second' }],
|
||||
},
|
||||
releasedAt: '2026-04-26',
|
||||
type: 'video',
|
||||
},
|
||||
{
|
||||
description:
|
||||
'HappyHorse-1.0-T2V supports text-to-video generation, delivering highly faithful dynamic visuals. It can accurately understand textual semantics and produce high-quality videos that are smooth, natural, and rich in detail.',
|
||||
displayName: 'HappyHorse-1.0-T2V',
|
||||
enabled: true,
|
||||
id: 'happyhorse-1.0-t2v',
|
||||
parameters: {
|
||||
aspectRatio: {
|
||||
default: '16:9',
|
||||
enum: ['16:9', '9:16', '1:1', '4:3', '3:4'],
|
||||
},
|
||||
duration: { default: 5, max: 15, min: 3 },
|
||||
prompt: { default: '' },
|
||||
resolution: {
|
||||
default: '1080P',
|
||||
enum: ['720P', '1080P'],
|
||||
},
|
||||
seed: { default: null },
|
||||
watermark: { default: false },
|
||||
},
|
||||
pricing: {
|
||||
currency: 'CNY',
|
||||
units: [{ name: 'videoGeneration', rate: 1.6, strategy: 'fixed', unit: 'second' }],
|
||||
},
|
||||
releasedAt: '2026-04-21',
|
||||
type: 'video',
|
||||
},
|
||||
{
|
||||
description:
|
||||
'Wanxiang 2.7 Image-to-Video delivers a comprehensive upgrade in performance capabilities. Dramatic scenes feature delicate and natural emotional expression, while action sequences are intense and impactful. Combined with more dynamic and rhythmically driven shot transitions, it achieves stronger overall performance and storytelling.',
|
||||
displayName: 'Wan2.7 I2V 2026-04-25',
|
||||
enabled: true,
|
||||
id: 'wan2.7-i2v-2026-04-25',
|
||||
parameters: {
|
||||
duration: { default: 5, max: 15, min: 2 },
|
||||
endImageUrl: {
|
||||
default: null,
|
||||
},
|
||||
imageUrl: {
|
||||
default: null,
|
||||
},
|
||||
prompt: { default: '' },
|
||||
resolution: {
|
||||
default: '1080P',
|
||||
enum: ['720P', '1080P'],
|
||||
},
|
||||
seed: { default: null },
|
||||
promptExtend: { default: false },
|
||||
watermark: { default: false },
|
||||
},
|
||||
pricing: {
|
||||
currency: 'CNY',
|
||||
units: [{ name: 'videoGeneration', rate: 1, strategy: 'fixed', unit: 'second' }],
|
||||
},
|
||||
releasedAt: '2026-04-26',
|
||||
type: 'video',
|
||||
},
|
||||
{
|
||||
description:
|
||||
'Wanxiang 2.7 Image-to-Video delivers a comprehensive upgrade in performance capabilities. Dramatic scenes feature delicate and natural emotional expression, while action sequences are intense and impactful. Combined with more dynamic and rhythmically driven shot transitions, it achieves stronger overall performance and storytelling.',
|
||||
displayName: 'Wan2.7 I2V',
|
||||
enabled: true,
|
||||
id: 'wan2.7-i2v',
|
||||
parameters: {
|
||||
duration: { default: 5, max: 15, min: 2 },
|
||||
@@ -3786,8 +4162,35 @@ const qwenVideoModels: AIVideoModelCard[] = [
|
||||
{
|
||||
description:
|
||||
'Wanxiang 2.7 Text-to-Video delivers a comprehensive upgrade in performance capabilities. Dramatic scenes feature delicate and natural emotional expression, while action sequences are intense and impactful. Enhanced with more dynamic and rhythmically driven shot transitions, it achieves stronger overall acting and storytelling performance.',
|
||||
displayName: 'Wan2.7 T2V',
|
||||
displayName: 'Wan2.7 T2V 2026-04-25',
|
||||
enabled: true,
|
||||
id: 'wan2.7-t2v-2026-04-25',
|
||||
parameters: {
|
||||
aspectRatio: {
|
||||
default: '16:9',
|
||||
enum: ['16:9', '9:16', '1:1', '4:3', '3:4'],
|
||||
},
|
||||
duration: { default: 5, max: 15, min: 2 },
|
||||
prompt: { default: '' },
|
||||
resolution: {
|
||||
default: '1080P',
|
||||
enum: ['720P', '1080P'],
|
||||
},
|
||||
seed: { default: null },
|
||||
promptExtend: { default: false },
|
||||
watermark: { default: false },
|
||||
},
|
||||
pricing: {
|
||||
currency: 'CNY',
|
||||
units: [{ name: 'videoGeneration', rate: 1, strategy: 'fixed', unit: 'second' }],
|
||||
},
|
||||
releasedAt: '2026-04-26',
|
||||
type: 'video',
|
||||
},
|
||||
{
|
||||
description:
|
||||
'Wanxiang 2.7 Text-to-Video delivers a comprehensive upgrade in performance capabilities. Dramatic scenes feature delicate and natural emotional expression, while action sequences are intense and impactful. Enhanced with more dynamic and rhythmically driven shot transitions, it achieves stronger overall acting and storytelling performance.',
|
||||
displayName: 'Wan2.7 T2V',
|
||||
id: 'wan2.7-t2v',
|
||||
parameters: {
|
||||
aspectRatio: {
|
||||
@@ -3815,7 +4218,6 @@ const qwenVideoModels: AIVideoModelCard[] = [
|
||||
description:
|
||||
'Wanxiang 2.6 introduces multi-shot narrative capabilities, while also supporting automatic voiceover generation and the ability to incorporate custom audio files.',
|
||||
displayName: 'Wan2.6 I2V Flash',
|
||||
enabled: true,
|
||||
id: 'wan2.6-i2v-flash',
|
||||
parameters: {
|
||||
duration: { default: 5, max: 15, min: 2 },
|
||||
@@ -3869,7 +4271,6 @@ const qwenVideoModels: AIVideoModelCard[] = [
|
||||
description:
|
||||
'Wanxiang 2.6 Reference-to-Video – Flash offers faster generation and better cost performance. It supports referencing specific characters or any objects, accurately maintaining consistency in appearance and voice, and enables multi-character reference for co-performance.',
|
||||
displayName: 'Wan2.6 R2V Flash',
|
||||
enabled: true,
|
||||
id: 'wan2.6-r2v-flash',
|
||||
parameters: {
|
||||
duration: { default: 5, max: 10, min: 2 },
|
||||
@@ -3947,7 +4348,6 @@ const qwenVideoModels: AIVideoModelCard[] = [
|
||||
description:
|
||||
'Wanxiang 2.6 introduces multi-shot narrative capabilities, while also supporting automatic voiceover generation and the ability to incorporate custom audio files.',
|
||||
displayName: 'Wan2.6 T2V',
|
||||
enabled: true,
|
||||
id: 'wan2.6-t2v',
|
||||
parameters: {
|
||||
duration: { default: 5, max: 15, min: 2 },
|
||||
|
||||
@@ -3,6 +3,28 @@ import type { AIChatModelCard, AIImageModelCard } from '../types/aiModel';
|
||||
// https://platform.stepfun.com/docs/pricing/details
|
||||
|
||||
const stepfunChatModels: AIChatModelCard[] = [
|
||||
{
|
||||
abilities: {
|
||||
functionCall: true,
|
||||
reasoning: true,
|
||||
search: true,
|
||||
},
|
||||
contextWindowTokens: 256_000,
|
||||
description:
|
||||
'Built on Step 3.5 Flash and optimized for high-frequency agent scenarios, it further improves token efficiency and inference speed while retaining flagship-level reasoning and tool-calling capabilities. It also supports switching to a low-reasoning mode to reduce resource consumption. Additionally, targeted optimizations have been made to enhance compatibility with coding tasks and agent frameworks.',
|
||||
displayName: 'Step 3.5 Flash 2603',
|
||||
enabled: true,
|
||||
id: 'step-3.5-flash-2603',
|
||||
pricing: {
|
||||
currency: 'CNY',
|
||||
units: [
|
||||
{ name: 'textInput_cacheRead', rate: 0.14, strategy: 'fixed', unit: 'millionTokens' },
|
||||
{ name: 'textInput', rate: 0.7, strategy: 'fixed', unit: 'millionTokens' },
|
||||
{ name: 'textOutput', rate: 2.1, strategy: 'fixed', unit: 'millionTokens' },
|
||||
],
|
||||
},
|
||||
type: 'chat',
|
||||
},
|
||||
{
|
||||
abilities: {
|
||||
functionCall: true,
|
||||
@@ -13,7 +35,6 @@ const stepfunChatModels: AIChatModelCard[] = [
|
||||
description:
|
||||
'Stepfun’s flagship language reasoning model.This model has top-notch reasoning capabilities and fast and reliable execution capabilities.Able to decompose and plan complex tasks, call tools quickly and reliably to perform tasks, and be competent in various complex tasks such as logical reasoning, mathematics, software engineering, and in-depth research.',
|
||||
displayName: 'Step 3.5 Flash',
|
||||
enabled: true,
|
||||
id: 'step-3.5-flash',
|
||||
pricing: {
|
||||
currency: 'CNY',
|
||||
|
||||
@@ -14,6 +14,7 @@ const doubaoChatModels: AIChatModelCard[] = [
|
||||
reasoning: true,
|
||||
video: true,
|
||||
vision: true,
|
||||
search: true,
|
||||
},
|
||||
config: {
|
||||
deploymentName: 'doubao-seed-2-0-pro-260215',
|
||||
@@ -66,6 +67,7 @@ const doubaoChatModels: AIChatModelCard[] = [
|
||||
releasedAt: '2026-02-15',
|
||||
settings: {
|
||||
extendParams: ['gpt5ReasoningEffort'],
|
||||
searchImpl: 'params',
|
||||
},
|
||||
type: 'chat',
|
||||
},
|
||||
@@ -75,6 +77,7 @@ const doubaoChatModels: AIChatModelCard[] = [
|
||||
reasoning: true,
|
||||
video: true,
|
||||
vision: true,
|
||||
search: true,
|
||||
},
|
||||
config: {
|
||||
deploymentName: 'doubao-seed-2-0-lite-260215',
|
||||
@@ -127,6 +130,7 @@ const doubaoChatModels: AIChatModelCard[] = [
|
||||
releasedAt: '2026-02-15',
|
||||
settings: {
|
||||
extendParams: ['gpt5ReasoningEffort'],
|
||||
searchImpl: 'params',
|
||||
},
|
||||
type: 'chat',
|
||||
},
|
||||
@@ -136,6 +140,7 @@ const doubaoChatModels: AIChatModelCard[] = [
|
||||
reasoning: true,
|
||||
video: true,
|
||||
vision: true,
|
||||
search: true,
|
||||
},
|
||||
config: {
|
||||
deploymentName: 'doubao-seed-2-0-mini-260215',
|
||||
@@ -187,6 +192,7 @@ const doubaoChatModels: AIChatModelCard[] = [
|
||||
releasedAt: '2026-02-15',
|
||||
settings: {
|
||||
extendParams: ['gpt5ReasoningEffort'],
|
||||
searchImpl: 'params',
|
||||
},
|
||||
type: 'chat',
|
||||
},
|
||||
@@ -196,6 +202,7 @@ const doubaoChatModels: AIChatModelCard[] = [
|
||||
reasoning: true,
|
||||
video: true,
|
||||
vision: true,
|
||||
search: true,
|
||||
},
|
||||
config: {
|
||||
deploymentName: 'doubao-seed-2-0-code-preview-260215',
|
||||
@@ -247,6 +254,7 @@ const doubaoChatModels: AIChatModelCard[] = [
|
||||
releasedAt: '2026-02-15',
|
||||
settings: {
|
||||
extendParams: ['gpt5ReasoningEffort'],
|
||||
searchImpl: 'params',
|
||||
},
|
||||
type: 'chat',
|
||||
},
|
||||
@@ -256,6 +264,7 @@ const doubaoChatModels: AIChatModelCard[] = [
|
||||
reasoning: true,
|
||||
video: true,
|
||||
vision: true,
|
||||
search: true,
|
||||
},
|
||||
config: {
|
||||
deploymentName: 'doubao-seed-1-8-251228',
|
||||
@@ -308,6 +317,7 @@ const doubaoChatModels: AIChatModelCard[] = [
|
||||
releasedAt: '2025-12-18',
|
||||
settings: {
|
||||
extendParams: ['gpt5ReasoningEffort'],
|
||||
searchImpl: 'params',
|
||||
},
|
||||
type: 'chat',
|
||||
},
|
||||
@@ -317,6 +327,7 @@ const doubaoChatModels: AIChatModelCard[] = [
|
||||
reasoning: true,
|
||||
video: true,
|
||||
vision: true,
|
||||
search: true,
|
||||
},
|
||||
config: {
|
||||
deploymentName: 'doubao-seed-code-preview-251028',
|
||||
@@ -362,6 +373,7 @@ const doubaoChatModels: AIChatModelCard[] = [
|
||||
},
|
||||
settings: {
|
||||
extendParams: ['enableReasoning'],
|
||||
searchImpl: 'params',
|
||||
},
|
||||
type: 'chat',
|
||||
},
|
||||
@@ -564,6 +576,7 @@ const doubaoChatModels: AIChatModelCard[] = [
|
||||
reasoning: true,
|
||||
video: true,
|
||||
vision: true,
|
||||
search: true,
|
||||
},
|
||||
config: {
|
||||
deploymentName: 'doubao-seed-1-6-vision-250815',
|
||||
@@ -608,6 +621,7 @@ const doubaoChatModels: AIChatModelCard[] = [
|
||||
},
|
||||
settings: {
|
||||
extendParams: ['enableReasoning'],
|
||||
searchImpl: 'params',
|
||||
},
|
||||
type: 'chat',
|
||||
},
|
||||
@@ -617,6 +631,7 @@ const doubaoChatModels: AIChatModelCard[] = [
|
||||
reasoning: true,
|
||||
video: true,
|
||||
vision: true,
|
||||
search: true,
|
||||
},
|
||||
config: {
|
||||
deploymentName: 'doubao-seed-1-6-thinking-250715',
|
||||
@@ -659,6 +674,9 @@ const doubaoChatModels: AIChatModelCard[] = [
|
||||
{ name: 'textInput_cacheRead', rate: 0.16, strategy: 'fixed', unit: 'millionTokens' },
|
||||
],
|
||||
},
|
||||
settings: {
|
||||
searchImpl: 'params',
|
||||
},
|
||||
type: 'chat',
|
||||
},
|
||||
{
|
||||
@@ -667,6 +685,7 @@ const doubaoChatModels: AIChatModelCard[] = [
|
||||
reasoning: true,
|
||||
video: true,
|
||||
vision: true,
|
||||
search: true,
|
||||
},
|
||||
config: {
|
||||
deploymentName: 'doubao-seed-1-6-251015',
|
||||
@@ -712,6 +731,7 @@ const doubaoChatModels: AIChatModelCard[] = [
|
||||
},
|
||||
settings: {
|
||||
extendParams: ['gpt5ReasoningEffort'],
|
||||
searchImpl: 'params',
|
||||
},
|
||||
type: 'chat',
|
||||
},
|
||||
@@ -721,6 +741,7 @@ const doubaoChatModels: AIChatModelCard[] = [
|
||||
reasoning: true,
|
||||
video: true,
|
||||
vision: true,
|
||||
search: true,
|
||||
},
|
||||
config: {
|
||||
deploymentName: 'doubao-seed-1-6-lite-251015',
|
||||
@@ -766,6 +787,7 @@ const doubaoChatModels: AIChatModelCard[] = [
|
||||
},
|
||||
settings: {
|
||||
extendParams: ['gpt5ReasoningEffort'],
|
||||
searchImpl: 'params',
|
||||
},
|
||||
type: 'chat',
|
||||
},
|
||||
@@ -775,6 +797,7 @@ const doubaoChatModels: AIChatModelCard[] = [
|
||||
reasoning: true,
|
||||
video: true,
|
||||
vision: true,
|
||||
search: true,
|
||||
},
|
||||
config: {
|
||||
deploymentName: 'doubao-seed-1-6-flash-250828',
|
||||
@@ -819,6 +842,7 @@ const doubaoChatModels: AIChatModelCard[] = [
|
||||
},
|
||||
settings: {
|
||||
extendParams: ['enableReasoning'],
|
||||
searchImpl: 'params',
|
||||
},
|
||||
type: 'chat',
|
||||
},
|
||||
|
||||
@@ -3,6 +3,56 @@ import type { AIChatModelCard, AIImageModelCard, AIVideoModelCard } from '../typ
|
||||
// https://cloud.baidu.com/doc/qianfan/s/rmh4stp0j
|
||||
|
||||
const wenxinChatModels: AIChatModelCard[] = [
|
||||
{
|
||||
abilities: {
|
||||
functionCall: true,
|
||||
reasoning: true,
|
||||
search: true,
|
||||
video: true,
|
||||
vision: true,
|
||||
},
|
||||
contextWindowTokens: 131_072,
|
||||
description:
|
||||
'ERNIE 5.0, the new-generation model in the ERNIE series, is a natively multimodal large model. It adopts a unified multimodal modeling approach, jointly modeling text, images, audio, and video to deliver comprehensive multimodal capabilities. Its foundational abilities have been significantly upgraded, achieving strong performance on benchmark evaluations. It particularly excels in multimodal understanding, instruction following, creative writing, factual accuracy, agent planning, and tool utilization.',
|
||||
displayName: 'ERNIE 5.0',
|
||||
enabled: true,
|
||||
id: 'ernie-5.0',
|
||||
maxOutput: 65_536,
|
||||
pricing: {
|
||||
currency: 'CNY',
|
||||
units: [
|
||||
{
|
||||
lookup: {
|
||||
prices: {
|
||||
'[0, 0.032]': 6,
|
||||
'[0.032, 0.128]': 10,
|
||||
},
|
||||
pricingParams: ['textInput'],
|
||||
},
|
||||
name: 'textInput',
|
||||
strategy: 'lookup',
|
||||
unit: 'millionTokens',
|
||||
},
|
||||
{
|
||||
lookup: {
|
||||
prices: {
|
||||
'[0, 0.032]': 24,
|
||||
'[0.032, 0.128]': 40,
|
||||
},
|
||||
pricingParams: ['textInput'],
|
||||
},
|
||||
name: 'textOutput',
|
||||
strategy: 'lookup',
|
||||
unit: 'millionTokens',
|
||||
},
|
||||
],
|
||||
},
|
||||
releasedAt: '2026-03-05',
|
||||
settings: {
|
||||
searchImpl: 'params',
|
||||
},
|
||||
type: 'chat',
|
||||
},
|
||||
{
|
||||
abilities: {
|
||||
functionCall: true,
|
||||
@@ -15,7 +65,6 @@ const wenxinChatModels: AIChatModelCard[] = [
|
||||
description:
|
||||
'Wenxin 5.0 Thinking is a native full-modal flagship model with unified text, image, audio, and video modeling. It delivers broad capability upgrades for complex QA, creation, and agent scenarios.',
|
||||
displayName: 'ERNIE 5.0 Thinking',
|
||||
enabled: true,
|
||||
id: 'ernie-5.0-thinking-latest',
|
||||
maxOutput: 65_536,
|
||||
pricing: {
|
||||
@@ -1780,11 +1829,32 @@ const wenxinImageModels: AIImageModelCard[] = [
|
||||
},
|
||||
type: 'image',
|
||||
},
|
||||
{
|
||||
description:
|
||||
'ERNIE-Image is an 8B-parameter text-to-image model developed by Baidu. It ranks among the top on multiple benchmarks, achieving a tied first place in SuperCLUE in China and leading in the open-source track.',
|
||||
displayName: 'ERNIE Image Turbo',
|
||||
enabled: true,
|
||||
id: 'ernie-image-turbo',
|
||||
parameters: {
|
||||
prompt: {
|
||||
default: '',
|
||||
},
|
||||
size: {
|
||||
default: '1024x1024',
|
||||
enum: ['1024x1024', '848x1264', '768x1376', '896x1200', '1264x848', '1376x768', '1200x896'],
|
||||
},
|
||||
watermark: { default: false },
|
||||
},
|
||||
pricing: {
|
||||
currency: 'CNY',
|
||||
units: [{ name: 'imageGeneration', rate: 0.11, strategy: 'fixed', unit: 'image' }],
|
||||
},
|
||||
type: 'image',
|
||||
},
|
||||
{
|
||||
description:
|
||||
'ERNIE iRAG is an image retrieval-augmented generation model for image search, image-text retrieval, and content generation.',
|
||||
displayName: 'ERNIE iRAG',
|
||||
enabled: true,
|
||||
id: 'irag-1.0',
|
||||
parameters: {
|
||||
height: { default: 1024, max: 2048, min: 512, step: 1 },
|
||||
@@ -1805,7 +1875,6 @@ const wenxinImageModels: AIImageModelCard[] = [
|
||||
description:
|
||||
'ERNIE iRAG Edit is an image editing model supporting erasing, repainting, and variant generation.',
|
||||
displayName: 'ERNIE iRAG Edit',
|
||||
enabled: true,
|
||||
id: 'ernie-irag-edit',
|
||||
parameters: {
|
||||
height: { default: 1024, max: 2048, min: 512, step: 1 },
|
||||
|
||||
@@ -5,16 +5,15 @@ const xaiChatModels: AIChatModelCard[] = [
|
||||
{
|
||||
abilities: {
|
||||
functionCall: true,
|
||||
reasoning: true,
|
||||
search: true,
|
||||
structuredOutput: true,
|
||||
vision: true,
|
||||
},
|
||||
contextWindowTokens: 2_000_000,
|
||||
description: 'Intelligent, blazing-fast model that reasons before responding',
|
||||
displayName: 'Grok 4.20 Beta',
|
||||
description: 'A non-reasoning variant for simple use cases',
|
||||
displayName: 'Grok 4.20 (Non-Reasoning)',
|
||||
enabled: true,
|
||||
id: 'grok-4.20-beta-0309-reasoning',
|
||||
id: 'grok-4.20-0309-non-reasoning',
|
||||
pricing: {
|
||||
units: [
|
||||
{
|
||||
@@ -55,15 +54,16 @@ const xaiChatModels: AIChatModelCard[] = [
|
||||
{
|
||||
abilities: {
|
||||
functionCall: true,
|
||||
reasoning: true,
|
||||
search: true,
|
||||
structuredOutput: true,
|
||||
vision: true,
|
||||
},
|
||||
contextWindowTokens: 2_000_000,
|
||||
description: 'A non-reasoning variant for simple use cases',
|
||||
displayName: 'Grok 4.20 Beta (Non-Reasoning)',
|
||||
description: 'Intelligent, blazing-fast model that reasons before responding',
|
||||
displayName: 'Grok 4.20',
|
||||
enabled: true,
|
||||
id: 'grok-4.20-beta-0309-non-reasoning',
|
||||
id: 'grok-4.20-0309-reasoning',
|
||||
pricing: {
|
||||
units: [
|
||||
{
|
||||
@@ -111,9 +111,9 @@ const xaiChatModels: AIChatModelCard[] = [
|
||||
contextWindowTokens: 2_000_000,
|
||||
description:
|
||||
'A team of 4 or 16 agents, Excels at research use cases, Does not currently support client-side tools. Only supports xAI server side tools (eg X Search, Web Search tools) and remote MCP tools.',
|
||||
displayName: 'Grok 4.20 Multi-Agent Beta',
|
||||
displayName: 'Grok 4.20 Multi-Agent',
|
||||
enabled: true,
|
||||
id: 'grok-4.20-multi-agent-beta-0309',
|
||||
id: 'grok-4.20-multi-agent-0309',
|
||||
pricing: {
|
||||
units: [
|
||||
{
|
||||
@@ -235,192 +235,6 @@ const xaiChatModels: AIChatModelCard[] = [
|
||||
},
|
||||
type: 'chat',
|
||||
},
|
||||
{
|
||||
abilities: {
|
||||
functionCall: true,
|
||||
search: true,
|
||||
structuredOutput: true,
|
||||
vision: true,
|
||||
},
|
||||
contextWindowTokens: 2_000_000,
|
||||
description:
|
||||
'We’re excited to release Grok 4 Fast, our latest progress in cost-effective reasoning models.',
|
||||
displayName: 'Grok 4 Fast (Non-Reasoning)',
|
||||
id: 'grok-4-fast-non-reasoning',
|
||||
pricing: {
|
||||
units: [
|
||||
{ name: 'textInput_cacheRead', rate: 0.05, strategy: 'fixed', unit: 'millionTokens' },
|
||||
{
|
||||
name: 'textInput',
|
||||
strategy: 'tiered',
|
||||
tiers: [
|
||||
{ rate: 0.2, upTo: 0.128 },
|
||||
{ rate: 0.4, upTo: 'infinity' },
|
||||
],
|
||||
unit: 'millionTokens',
|
||||
},
|
||||
{
|
||||
name: 'textOutput',
|
||||
strategy: 'tiered',
|
||||
tiers: [
|
||||
{ rate: 0.5, upTo: 0.128 },
|
||||
{ rate: 1, upTo: 'infinity' },
|
||||
],
|
||||
unit: 'millionTokens',
|
||||
},
|
||||
],
|
||||
},
|
||||
releasedAt: '2025-09-09',
|
||||
settings: {
|
||||
searchImpl: 'params',
|
||||
},
|
||||
type: 'chat',
|
||||
},
|
||||
{
|
||||
abilities: {
|
||||
functionCall: true,
|
||||
reasoning: true,
|
||||
search: true,
|
||||
structuredOutput: true,
|
||||
vision: true,
|
||||
},
|
||||
contextWindowTokens: 2_000_000,
|
||||
description:
|
||||
'We’re excited to release Grok 4 Fast, our latest progress in cost-effective reasoning models.',
|
||||
displayName: 'Grok 4 Fast',
|
||||
id: 'grok-4-fast-reasoning',
|
||||
pricing: {
|
||||
units: [
|
||||
{ name: 'textInput_cacheRead', rate: 0.05, strategy: 'fixed', unit: 'millionTokens' },
|
||||
{
|
||||
name: 'textInput',
|
||||
strategy: 'tiered',
|
||||
tiers: [
|
||||
{ rate: 0.2, upTo: 0.128 },
|
||||
{ rate: 0.4, upTo: 'infinity' },
|
||||
],
|
||||
unit: 'millionTokens',
|
||||
},
|
||||
{
|
||||
name: 'textOutput',
|
||||
strategy: 'tiered',
|
||||
tiers: [
|
||||
{ rate: 0.5, upTo: 0.128 },
|
||||
{ rate: 1, upTo: 'infinity' },
|
||||
],
|
||||
unit: 'millionTokens',
|
||||
},
|
||||
],
|
||||
},
|
||||
releasedAt: '2025-09-09',
|
||||
settings: {
|
||||
searchImpl: 'params',
|
||||
},
|
||||
type: 'chat',
|
||||
},
|
||||
{
|
||||
abilities: {
|
||||
functionCall: true,
|
||||
reasoning: true,
|
||||
structuredOutput: true,
|
||||
},
|
||||
contextWindowTokens: 256_000,
|
||||
description:
|
||||
'We’re excited to launch grok-code-fast-1, a fast and cost-effective reasoning model that excels at agentic coding.',
|
||||
displayName: 'Grok Code Fast 1',
|
||||
id: 'grok-code-fast-1',
|
||||
pricing: {
|
||||
units: [
|
||||
{ name: 'textInput_cacheRead', rate: 0.02, strategy: 'fixed', unit: 'millionTokens' },
|
||||
{ name: 'textInput', rate: 0.2, strategy: 'fixed', unit: 'millionTokens' },
|
||||
{ name: 'textOutput', rate: 1.5, strategy: 'fixed', unit: 'millionTokens' },
|
||||
],
|
||||
},
|
||||
releasedAt: '2025-08-27',
|
||||
// settings: {
|
||||
// reasoning_effort is not supported by grok-code. Specifying reasoning_effort parameter will get an error response.
|
||||
// extendParams: ['reasoningEffort'],
|
||||
// },
|
||||
type: 'chat',
|
||||
},
|
||||
{
|
||||
abilities: {
|
||||
functionCall: true,
|
||||
reasoning: true,
|
||||
search: true,
|
||||
structuredOutput: true,
|
||||
vision: true,
|
||||
},
|
||||
contextWindowTokens: 256_000,
|
||||
description:
|
||||
'Our newest and strongest flagship model, excelling in NLP, math, and reasoning—an ideal all-rounder.',
|
||||
displayName: 'Grok 4 0709',
|
||||
id: 'grok-4',
|
||||
pricing: {
|
||||
units: [
|
||||
{ name: 'textInput_cacheRead', rate: 0.75, strategy: 'fixed', unit: 'millionTokens' },
|
||||
{ name: 'textInput', rate: 3, strategy: 'fixed', unit: 'millionTokens' },
|
||||
{ name: 'textOutput', rate: 15, strategy: 'fixed', unit: 'millionTokens' },
|
||||
],
|
||||
},
|
||||
releasedAt: '2025-07-09',
|
||||
settings: {
|
||||
// reasoning_effort is not supported by grok-4. Specifying reasoning_effort parameter will get an error response.
|
||||
// extendParams: ['reasoningEffort'],
|
||||
searchImpl: 'params',
|
||||
},
|
||||
type: 'chat',
|
||||
},
|
||||
{
|
||||
abilities: {
|
||||
functionCall: true,
|
||||
search: true,
|
||||
structuredOutput: true,
|
||||
},
|
||||
contextWindowTokens: 131_072,
|
||||
description:
|
||||
'A flagship model that excels at enterprise use cases like data extraction, coding, and summarization, with deep domain knowledge in finance, healthcare, law, and science.',
|
||||
displayName: 'Grok 3',
|
||||
id: 'grok-3',
|
||||
pricing: {
|
||||
units: [
|
||||
{ name: 'textInput_cacheRead', rate: 0.75, strategy: 'fixed', unit: 'millionTokens' },
|
||||
{ name: 'textInput', rate: 3, strategy: 'fixed', unit: 'millionTokens' },
|
||||
{ name: 'textOutput', rate: 15, strategy: 'fixed', unit: 'millionTokens' },
|
||||
],
|
||||
},
|
||||
releasedAt: '2025-04-03',
|
||||
settings: {
|
||||
searchImpl: 'params',
|
||||
},
|
||||
type: 'chat',
|
||||
},
|
||||
{
|
||||
abilities: {
|
||||
functionCall: true,
|
||||
reasoning: true,
|
||||
search: true,
|
||||
structuredOutput: true,
|
||||
},
|
||||
contextWindowTokens: 131_072,
|
||||
description:
|
||||
'A lightweight model that thinks before responding. It’s fast and smart for logic tasks that don’t require deep domain knowledge, with access to raw reasoning traces.',
|
||||
displayName: 'Grok 3 Mini',
|
||||
id: 'grok-3-mini',
|
||||
pricing: {
|
||||
units: [
|
||||
{ name: 'textInput_cacheRead', rate: 0.075, strategy: 'fixed', unit: 'millionTokens' },
|
||||
{ name: 'textInput', rate: 0.3, strategy: 'fixed', unit: 'millionTokens' },
|
||||
{ name: 'textOutput', rate: 0.5, strategy: 'fixed', unit: 'millionTokens' },
|
||||
],
|
||||
},
|
||||
releasedAt: '2025-04-03',
|
||||
settings: {
|
||||
extendParams: ['reasoningEffort'],
|
||||
searchImpl: 'params',
|
||||
},
|
||||
type: 'chat',
|
||||
},
|
||||
];
|
||||
|
||||
const xaiImageModels: AIImageModelCard[] = [
|
||||
|
||||
@@ -120,7 +120,6 @@ const xiaomimimoChatModels: AIChatModelCard[] = [
|
||||
description:
|
||||
'MiMo-V2-Pro is specifically designed for high-intensity agent workflows in real-world scenarios. It features over 1 trillion total parameters (42B activated parameters), adopts an innovative hybrid attention architecture, and supports an ultra-long context length of up to 1 million tokens. Built on a powerful foundational model, we continuously scale computational resources across a broader range of agent scenarios, further expanding the action space of intelligence and achieving significant generalization—from coding to real-world task execution (“claw”).',
|
||||
displayName: 'MiMo-V2 Pro',
|
||||
enabled: true,
|
||||
id: 'mimo-v2-pro',
|
||||
maxOutput: 131_072,
|
||||
pricing: {
|
||||
@@ -175,7 +174,6 @@ const xiaomimimoChatModels: AIChatModelCard[] = [
|
||||
description:
|
||||
'MiMo-V2-Omni is purpose-built for complex multimodal interaction and execution scenarios in the real world. We constructed a full-modality foundation from the ground up, integrating text, vision, and speech, and unified “perception” and “action” within a single architecture. This not only breaks the traditional limitation of models that emphasize understanding over execution, but also endows the model with native capabilities in multimodal perception, tool usage, function execution, and GUI operations. MiMo-V2-Omni can seamlessly integrate with major agent frameworks, achieving a leap from understanding to control while significantly lowering the barrier to deploying fully multimodal agents.',
|
||||
displayName: 'MiMo-V2 Omni',
|
||||
enabled: true,
|
||||
id: 'mimo-v2-omni',
|
||||
maxOutput: 131_072,
|
||||
pricing: {
|
||||
|
||||
@@ -21,6 +21,7 @@ const Doubao: ModelProviderCard = {
|
||||
sdkType: 'openai',
|
||||
showDeployName: true,
|
||||
showModelFetcher: false,
|
||||
supportResponsesApi: true,
|
||||
},
|
||||
url: 'https://www.volcengine.com/product/ark',
|
||||
};
|
||||
|
||||
@@ -51,6 +51,7 @@ export const responsesAPIModels = new Set([
|
||||
'gpt-5.4-nano',
|
||||
'gpt-5.4-pro',
|
||||
'gpt-5.5',
|
||||
'gpt-5.5-pro',
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -465,6 +465,57 @@ describe('createRouterRuntime', () => {
|
||||
expect(mockChatSuccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not retry when shouldStopFallback returns true', async () => {
|
||||
const moderationError = {
|
||||
errorType: AgentRuntimeErrorType.ProviderBizError,
|
||||
error: { message: 'Content violates usage guidelines' },
|
||||
provider: 'test',
|
||||
};
|
||||
|
||||
const mockChatFail = vi.fn().mockRejectedValue(moderationError);
|
||||
const mockChatSuccess = vi.fn().mockResolvedValue('success');
|
||||
const shouldStopFallback = vi.fn().mockResolvedValue(true);
|
||||
|
||||
class FailRuntime implements LobeRuntimeAI {
|
||||
chat = mockChatFail;
|
||||
}
|
||||
|
||||
class SuccessRuntime implements LobeRuntimeAI {
|
||||
chat = mockChatSuccess;
|
||||
}
|
||||
|
||||
const Runtime = createRouterRuntime({
|
||||
id: 'test-runtime',
|
||||
routers: [
|
||||
{
|
||||
apiType: 'openai',
|
||||
options: [
|
||||
{ apiKey: 'key-1', runtime: FailRuntime as any },
|
||||
{ apiKey: 'key-2', runtime: SuccessRuntime as any },
|
||||
],
|
||||
runtime: FailRuntime as any,
|
||||
models: ['gpt-4'],
|
||||
},
|
||||
],
|
||||
shouldStopFallback,
|
||||
});
|
||||
|
||||
const runtime = new Runtime();
|
||||
await expect(
|
||||
runtime.chat({ model: 'gpt-4', messages: [], temperature: 0.7 }),
|
||||
).rejects.toEqual(moderationError);
|
||||
|
||||
expect(shouldStopFallback).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: moderationError,
|
||||
model: 'gpt-4',
|
||||
optionIndex: 0,
|
||||
}),
|
||||
);
|
||||
expect(mockChatFail).toHaveBeenCalledTimes(1);
|
||||
expect(mockChatSuccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should still retry on other error types', async () => {
|
||||
const bizError = {
|
||||
errorType: AgentRuntimeErrorType.ProviderBizError,
|
||||
|
||||
@@ -164,6 +164,12 @@ export interface CreateRouterRuntimeOptions<T extends Record<string, any> = any>
|
||||
) => ChatStreamPayload;
|
||||
};
|
||||
routers: Routers;
|
||||
shouldStopFallback?: (params: {
|
||||
error: unknown;
|
||||
metadata?: Record<string, unknown>;
|
||||
model: string;
|
||||
optionIndex: number;
|
||||
}) => boolean | Promise<boolean>;
|
||||
}
|
||||
|
||||
export const createRouterRuntime = ({
|
||||
@@ -406,6 +412,25 @@ export const createRouterRuntime = ({
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
const shouldStopFallback = await params.shouldStopFallback?.({
|
||||
error,
|
||||
metadata,
|
||||
model,
|
||||
optionIndex: index,
|
||||
});
|
||||
|
||||
if (shouldStopFallback) {
|
||||
throw error;
|
||||
}
|
||||
} catch (fallbackError) {
|
||||
if (fallbackError === error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
log('shouldStopFallback callback error: %O', fallbackError);
|
||||
}
|
||||
|
||||
if (attempt < totalOptions) {
|
||||
log(
|
||||
'attempt %d/%d failed (model=%s apiType=%s channelId=%s remark=%s), trying next',
|
||||
|
||||
@@ -18,7 +18,7 @@ export const params = {
|
||||
baseURL: 'https://api.baichuan-ai.com/v1',
|
||||
chatCompletion: {
|
||||
handlePayload: (payload: ChatStreamPayload) => {
|
||||
const { enabledSearch, temperature, tools, ...rest } = payload;
|
||||
const { model, enabledSearch, temperature, thinking, tools, ...rest } = payload;
|
||||
|
||||
const baichuanTools = enabledSearch
|
||||
? [
|
||||
@@ -38,8 +38,19 @@ export const params = {
|
||||
|
||||
return {
|
||||
...rest,
|
||||
model,
|
||||
temperature: resolvedParams.temperature,
|
||||
tools: baichuanTools,
|
||||
...(model?.startsWith('Baichuan-M') && {
|
||||
frequency_penalty: undefined,
|
||||
presence_penalty: undefined,
|
||||
}),
|
||||
...(thinking && {
|
||||
budget_tokens:
|
||||
thinking?.budget_tokens === 0
|
||||
? 0
|
||||
: Math.min(thinking?.budget_tokens, 1024) || undefined,
|
||||
}),
|
||||
} as any;
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
import { ModelProvider } from 'model-bank';
|
||||
import { longcat as longchatCahtModels, ModelProvider } from 'model-bank';
|
||||
|
||||
import { createOpenAICompatibleRuntime } from '../../core/openaiCompatibleFactory';
|
||||
import { getModelMaxOutputs } from '../../utils/getModelMaxOutputs';
|
||||
|
||||
export const LobeLongCatAI = createOpenAICompatibleRuntime({
|
||||
baseURL: 'https://api.longcat.chat/openai/v1',
|
||||
chatCompletion: {
|
||||
handlePayload: (payload) => {
|
||||
const { frequency_penalty, presence_penalty, ...rest } = payload;
|
||||
const { frequency_penalty, max_tokens, presence_penalty, ...rest } = payload;
|
||||
|
||||
return {
|
||||
...rest,
|
||||
frequency_penalty: undefined,
|
||||
max_tokens:
|
||||
max_tokens !== undefined
|
||||
? max_tokens
|
||||
: getModelMaxOutputs(payload.model, longchatCahtModels),
|
||||
presence_penalty: undefined,
|
||||
stream: true,
|
||||
} as any;
|
||||
|
||||
@@ -237,7 +237,7 @@ async function createVideoTask(
|
||||
if (media.length > 0) {
|
||||
input.media = media;
|
||||
}
|
||||
} else if (model.startsWith('wan2.7')) {
|
||||
} else if (model.startsWith('wan2.7') || model.startsWith('happyhorse')) {
|
||||
const media = [];
|
||||
if (imageUrl) {
|
||||
media.push({
|
||||
|
||||
@@ -122,14 +122,7 @@ export const params = {
|
||||
// Merge all applicable extendParams for settings
|
||||
...(() => {
|
||||
const extendParams: string[] = [];
|
||||
if (
|
||||
tags.includes('reasoning') &&
|
||||
m.id.includes('gpt-5') &&
|
||||
!m.id.includes('gpt-5.1') &&
|
||||
!m.id.includes('gpt-5.2') &&
|
||||
!m.id.includes('gpt-5.4') &&
|
||||
!m.id.includes('gpt-5.5')
|
||||
) {
|
||||
if (tags.includes('reasoning') && m.id.includes('gpt-5') && !m.id.includes('gpt-5.')) {
|
||||
extendParams.push('gpt5ReasoningEffort', 'textVerbosity');
|
||||
}
|
||||
if (tags.includes('reasoning') && m.id.includes('gpt-5.1') && !m.id.includes('gpt-5.2')) {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { ModelProvider } from 'model-bank';
|
||||
|
||||
import type { ChatStreamPayload } from '@/types/index';
|
||||
|
||||
import { createOpenAICompatibleRuntime } from '../../core/openaiCompatibleFactory';
|
||||
import { createVolcengineImage } from './createImage';
|
||||
import { createVolcengineVideo } from './video/createVideo';
|
||||
@@ -9,11 +11,20 @@ export const LobeVolcengineAI = createOpenAICompatibleRuntime({
|
||||
baseURL: 'https://ark.cn-beijing.volces.com/api/v3',
|
||||
chatCompletion: {
|
||||
handlePayload: (payload) => {
|
||||
const { model, thinking, reasoning_effort, ...rest } = payload;
|
||||
const { enabledSearch, thinking, reasoning_effort, ...rest } = payload;
|
||||
|
||||
if (enabledSearch) {
|
||||
return {
|
||||
...rest,
|
||||
apiMode: 'responses',
|
||||
enabledSearch,
|
||||
thinking,
|
||||
reasoning_effort,
|
||||
} as ChatStreamPayload;
|
||||
}
|
||||
|
||||
return {
|
||||
...rest,
|
||||
model,
|
||||
...(thinking?.type && { thinking: { type: thinking.type } }),
|
||||
...(reasoning_effort && { reasoning_effort }),
|
||||
} as any;
|
||||
@@ -23,7 +34,32 @@ export const LobeVolcengineAI = createOpenAICompatibleRuntime({
|
||||
createVideo: createVolcengineVideo,
|
||||
debug: {
|
||||
chatCompletion: () => process.env.DEBUG_VOLCENGINE_CHAT_COMPLETION === '1',
|
||||
responses: () => process.env.DEBUG_VOLCENGINE_RESPONSES === '1',
|
||||
},
|
||||
handleCreateVideoWebhook: handleVolcengineVideoWebhook,
|
||||
provider: ModelProvider.Volcengine,
|
||||
responses: {
|
||||
handlePayload: (payload) => {
|
||||
const { enabledSearch, tools, thinking, reasoning_effort, ...rest } = payload;
|
||||
|
||||
const volcengineTools = enabledSearch
|
||||
? [
|
||||
...(tools || []),
|
||||
{
|
||||
function: {
|
||||
sources: ['douyin', 'moji', 'toutiao'], // Additional search sources (Douyin Baike, Moji Weather, Toutiao, etc.)
|
||||
},
|
||||
type: 'web_search',
|
||||
},
|
||||
]
|
||||
: tools;
|
||||
|
||||
return {
|
||||
...rest,
|
||||
tools: volcengineTools,
|
||||
...(thinking?.type && { thinking: { type: thinking.type } }),
|
||||
...(reasoning_effort && { reasoning_effort }),
|
||||
} as any;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -36,6 +36,8 @@ export async function createWenxinImage(
|
||||
|
||||
if (model.startsWith('musesteamer')) {
|
||||
endpoint = `${baseURL}/musesteamer/images/generations`;
|
||||
} else if (model.startsWith('ernie-image')) {
|
||||
endpoint = `${baseURL}/ernie-image/images/generations`;
|
||||
} else {
|
||||
if (images) {
|
||||
endpoint = `${baseURL}/images/edits`;
|
||||
|
||||
@@ -11,7 +11,7 @@ export interface HeterogeneousProviderConfig {
|
||||
/** Custom environment variables */
|
||||
env?: Record<string, string>;
|
||||
/** Agent runtime type */
|
||||
type: 'claude-code' | 'codex';
|
||||
type: 'claude-code' | 'cloud-claude-code' | 'codex';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type SearchMode } from '../search';
|
||||
import { type UserMemoryEffort } from '../user/settings/memory';
|
||||
import { type RuntimeEnvConfig } from './agentConfig';
|
||||
import type { SearchMode } from '../search';
|
||||
import type { TopicGroupMode } from '../topic';
|
||||
import type { UserMemoryEffort } from '../user/settings/memory';
|
||||
import type { RuntimeEnvConfig } from './agentConfig';
|
||||
|
||||
export interface WorkingModel {
|
||||
model: string;
|
||||
@@ -145,6 +146,11 @@ export interface LobeAgentChatConfig extends AgentMemoryChatConfig {
|
||||
*/
|
||||
toolResultMaxLength?: number;
|
||||
|
||||
/**
|
||||
* Agent-specific topic list organization preference.
|
||||
*/
|
||||
topicGroupMode?: TopicGroupMode;
|
||||
|
||||
urlContext?: boolean;
|
||||
|
||||
useModelBuiltinSearch?: boolean;
|
||||
@@ -221,6 +227,7 @@ export const AgentChatConfigSchema = z
|
||||
thinkingLevel4: z.enum(['minimal', 'high']).optional(),
|
||||
thinkingLevel5: z.enum(['minimal', 'low', 'medium', 'high']).optional(),
|
||||
toolResultMaxLength: z.number().default(25000),
|
||||
topicGroupMode: z.enum(['byTime', 'byProject', 'flat']).optional(),
|
||||
urlContext: z.boolean().optional(),
|
||||
useModelBuiltinSearch: z.boolean().optional(),
|
||||
})
|
||||
|
||||
@@ -82,6 +82,17 @@ export interface SendMessageServerParams {
|
||||
preloadMessages?: SendPreloadMessage[];
|
||||
sessionId?: string;
|
||||
threadId?: string;
|
||||
/**
|
||||
* Filters applied to the topic list returned alongside the message.
|
||||
* Callers pass whatever filter the active sidebar is using so the server
|
||||
* doesn't echo back topics the UI was already excluding (e.g. completed
|
||||
* status), which would overwrite the filtered list in `topicDataMap`.
|
||||
*/
|
||||
topicFilter?: {
|
||||
excludeStatuses?: string[];
|
||||
excludeTriggers?: string[];
|
||||
includeTriggers?: string[];
|
||||
};
|
||||
// if there is activeTopicId, then add topicId to message
|
||||
topicId?: string;
|
||||
}
|
||||
@@ -136,6 +147,13 @@ export const AiSendMessageServerSchema = z.object({
|
||||
}),
|
||||
sessionId: z.string().optional(),
|
||||
threadId: z.string().optional(),
|
||||
topicFilter: z
|
||||
.object({
|
||||
excludeStatuses: z.array(z.string()).optional(),
|
||||
excludeTriggers: z.array(z.string()).optional(),
|
||||
includeTriggers: z.array(z.string()).optional(),
|
||||
})
|
||||
.optional(),
|
||||
topicId: z.string().optional(),
|
||||
});
|
||||
|
||||
|
||||
@@ -23,16 +23,17 @@ export enum AsyncTaskErrorType {
|
||||
FreePlanLimit = 'FreePlanLimit',
|
||||
|
||||
InvalidProviderAPIKey = 'InvalidProviderAPIKey',
|
||||
/* ↑ cloud slot ↑ */
|
||||
|
||||
/**
|
||||
* Model not found on server
|
||||
*/
|
||||
ModelNotFound = 'ModelNotFound',
|
||||
/* ↑ cloud slot ↑ */
|
||||
|
||||
/**
|
||||
* the chunk parse result it empty
|
||||
*/
|
||||
NoChunkError = 'NoChunkError',
|
||||
ProviderContentModeration = 'ProviderContentModeration',
|
||||
ServerError = 'ServerError',
|
||||
/**
|
||||
* Subscription plan limit reached (paid users run out of credits)
|
||||
|
||||
@@ -14,7 +14,13 @@ export interface BriefAction {
|
||||
url?: string;
|
||||
}
|
||||
|
||||
/** Default actions by brief type */
|
||||
/**
|
||||
* Default actions by brief type.
|
||||
*
|
||||
* Note: `result` briefs intentionally have no defaults — they are terminal and
|
||||
* render a fixed single-button UI (approve → completes the task). Custom
|
||||
* actions on result briefs are dropped at creation time.
|
||||
*/
|
||||
export const DEFAULT_BRIEF_ACTIONS: Record<string, BriefAction[]> = {
|
||||
decision: [
|
||||
{ key: 'approve', label: '✅ 确认', type: 'resolve' },
|
||||
@@ -25,10 +31,6 @@ export const DEFAULT_BRIEF_ACTIONS: Record<string, BriefAction[]> = {
|
||||
{ key: 'feedback', label: '💬 反馈', type: 'comment' },
|
||||
],
|
||||
insight: [{ key: 'acknowledge', label: '👍 知悉', type: 'resolve' }],
|
||||
result: [
|
||||
{ key: 'approve', label: '✅ 通过', type: 'resolve' },
|
||||
{ key: 'feedback', label: '💬 修改意见', type: 'comment' },
|
||||
],
|
||||
};
|
||||
|
||||
/** Brief type — must match DEFAULT_BRIEF_ACTIONS keys and DB schema comment */
|
||||
|
||||
@@ -98,6 +98,11 @@ export const EmojiReactionSchema = z.object({
|
||||
|
||||
export const MessageMetadataSchema = ModelUsageSchema.merge(ModelPerformanceSchema).extend({
|
||||
collapsed: z.boolean().optional(),
|
||||
cloudClaudeCodeCompletedAt: z.string().optional(),
|
||||
cloudClaudeCodeError: z.string().optional(),
|
||||
cloudClaudeCodeRunId: z.string().optional(),
|
||||
cloudClaudeCodeRunStatus: z.enum(['running', 'completed', 'failed']).optional(),
|
||||
cloudClaudeCodeStartedAt: z.string().optional(),
|
||||
inspectExpanded: z.boolean().optional(),
|
||||
isMultimodal: z.boolean().optional(),
|
||||
isSupervisor: z.boolean().optional(),
|
||||
@@ -150,6 +155,11 @@ export interface MessageMetadata {
|
||||
acceptedPredictionTokens?: number;
|
||||
activeBranchIndex?: number;
|
||||
activeColumn?: boolean;
|
||||
cloudClaudeCodeCompletedAt?: string;
|
||||
cloudClaudeCodeError?: string;
|
||||
cloudClaudeCodeRunId?: string;
|
||||
cloudClaudeCodeRunStatus?: 'running' | 'completed' | 'failed';
|
||||
cloudClaudeCodeStartedAt?: string;
|
||||
/**
|
||||
* Message collapse state
|
||||
* true: collapsed, false/undefined: expanded
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
// ── Task type aliases ──
|
||||
|
||||
export type TaskStatus = 'backlog' | 'canceled' | 'completed' | 'failed' | 'paused' | 'running';
|
||||
export type TaskStatus =
|
||||
| 'backlog'
|
||||
| 'canceled'
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
| 'paused'
|
||||
| 'running'
|
||||
| 'scheduled';
|
||||
|
||||
export type TaskPriority = 0 | 1 | 2 | 3 | 4;
|
||||
|
||||
@@ -162,11 +169,14 @@ export interface TaskDetailSubtaskAssignee {
|
||||
|
||||
export interface TaskDetailSubtask {
|
||||
assignee?: TaskDetailSubtaskAssignee | null;
|
||||
automationMode?: TaskAutomationMode | null;
|
||||
blockedBy?: string;
|
||||
children?: TaskDetailSubtask[];
|
||||
heartbeat?: { interval?: number | null };
|
||||
identifier: string;
|
||||
name?: string | null;
|
||||
priority?: number | null;
|
||||
schedule?: { pattern?: string | null; timezone?: string | null };
|
||||
status: string;
|
||||
}
|
||||
|
||||
@@ -210,6 +220,17 @@ export interface TaskDetailActivity {
|
||||
resolvedAction?: string | null;
|
||||
resolvedAt?: string | null;
|
||||
resolvedComment?: string | null;
|
||||
/**
|
||||
* Topic-only: currently running Gateway operation, mirrored from
|
||||
* `topics.metadata.runningOperation`. Lets the task topic drawer establish
|
||||
* a Gateway WebSocket reconnection without a separate topic lookup.
|
||||
*/
|
||||
runningOperation?: {
|
||||
assistantMessageId: string;
|
||||
operationId: string;
|
||||
scope?: string;
|
||||
threadId?: string | null;
|
||||
} | null;
|
||||
seq?: number | null;
|
||||
status?: string | null;
|
||||
summary?: string;
|
||||
@@ -244,6 +265,11 @@ export interface TaskDetailData {
|
||||
parent?: { identifier: string; name: string | null } | null;
|
||||
priority?: number | null;
|
||||
review?: Record<string, any> | null;
|
||||
schedule?: {
|
||||
maxExecutions?: number | null;
|
||||
pattern?: string | null;
|
||||
timezone?: string | null;
|
||||
};
|
||||
status: string;
|
||||
subtasks?: TaskDetailSubtask[];
|
||||
topicCount?: number;
|
||||
|
||||
@@ -9,10 +9,12 @@ import compressImage, {
|
||||
|
||||
const getContextSpy = vi.spyOn(global.HTMLCanvasElement.prototype, 'getContext');
|
||||
const drawImageSpy = vi.spyOn(CanvasRenderingContext2D.prototype, 'drawImage');
|
||||
const toDataURLSpy = vi.spyOn(global.HTMLCanvasElement.prototype, 'toDataURL');
|
||||
|
||||
beforeEach(() => {
|
||||
getContextSpy.mockClear();
|
||||
drawImageSpy.mockClear();
|
||||
toDataURLSpy.mockClear();
|
||||
});
|
||||
|
||||
describe('compressImage', () => {
|
||||
@@ -58,6 +60,36 @@ describe('compressImage', () => {
|
||||
expect(r).toMatch(/^data:image\/jpeg;base64,/);
|
||||
});
|
||||
|
||||
it('should encode JPEG inputs as JPEG with 0.85 quality (preserve format, lossy)', () => {
|
||||
const img = document.createElement('img');
|
||||
img.width = 100;
|
||||
img.height = 100;
|
||||
|
||||
compressImage({ img, type: 'image/jpeg' });
|
||||
|
||||
expect(toDataURLSpy).toHaveBeenCalledWith('image/jpeg', 0.85);
|
||||
});
|
||||
|
||||
it('should encode non-JPEG inputs as PNG without a quality argument (lossless)', () => {
|
||||
const img = document.createElement('img');
|
||||
img.width = 100;
|
||||
img.height = 100;
|
||||
|
||||
compressImage({ img, type: 'image/png' });
|
||||
|
||||
expect(toDataURLSpy).toHaveBeenCalledWith('image/png');
|
||||
});
|
||||
|
||||
it('should default to PNG when no type is provided', () => {
|
||||
const img = document.createElement('img');
|
||||
img.width = 100;
|
||||
img.height = 100;
|
||||
|
||||
compressImage({ img });
|
||||
|
||||
expect(toDataURLSpy).toHaveBeenCalledWith('image/png');
|
||||
});
|
||||
|
||||
it('should support custom maxSize', () => {
|
||||
const img = document.createElement('img');
|
||||
img.width = 500;
|
||||
@@ -139,6 +171,52 @@ describe('compressImageFile', () => {
|
||||
global.Image = originalImage;
|
||||
});
|
||||
|
||||
it('should preserve JPEG type when compressing large JPEG inputs (no PNG re-encoding)', async () => {
|
||||
const file = createMockFile('photo.jpg', 'image/jpeg', 1000);
|
||||
|
||||
const originalImage = global.Image;
|
||||
global.Image = class MockImage extends originalImage {
|
||||
constructor() {
|
||||
super();
|
||||
Object.defineProperty(this, 'width', { value: 3000, writable: false });
|
||||
Object.defineProperty(this, 'height', { value: 2000, writable: false });
|
||||
setTimeout(() => this.dispatchEvent(new Event('load')), 0);
|
||||
}
|
||||
} as any;
|
||||
|
||||
const result = await compressImageFile(file);
|
||||
|
||||
expect(result).not.toBe(file);
|
||||
// Output File MIME type must match the source format — previously this
|
||||
// was hardcoded to 'image/png', which inflated photographic JPEGs.
|
||||
expect(result.type).toBe('image/jpeg');
|
||||
expect(result.name).toBe('photo.jpg');
|
||||
expect(toDataURLSpy).toHaveBeenCalledWith('image/jpeg', 0.85);
|
||||
global.Image = originalImage;
|
||||
});
|
||||
|
||||
it('should preserve WebP inputs as PNG (existing fallback behaviour)', async () => {
|
||||
const file = createMockFile('photo.webp', 'image/webp', 1000);
|
||||
|
||||
const originalImage = global.Image;
|
||||
global.Image = class MockImage extends originalImage {
|
||||
constructor() {
|
||||
super();
|
||||
Object.defineProperty(this, 'width', { value: 3000, writable: false });
|
||||
Object.defineProperty(this, 'height', { value: 2000, writable: false });
|
||||
setTimeout(() => this.dispatchEvent(new Event('load')), 0);
|
||||
}
|
||||
} as any;
|
||||
|
||||
const result = await compressImageFile(file);
|
||||
|
||||
expect(result).not.toBe(file);
|
||||
// WebP isn't supported as a canvas output target, so we still fall back
|
||||
// to lossless PNG. Documents the deliberate choice.
|
||||
expect(result.type).toBe('image/png');
|
||||
global.Image = originalImage;
|
||||
});
|
||||
|
||||
it('should compress images exceeding max file size even if dimensions are small', async () => {
|
||||
const file = createMockFile('heavy.png', 'image/png', 6 * 1024 * 1024);
|
||||
|
||||
|
||||
@@ -3,6 +3,9 @@ export const MAX_IMAGE_BYTES = 5 * 1024 * 1024; // 5MB
|
||||
|
||||
export const COMPRESSIBLE_IMAGE_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']);
|
||||
|
||||
// JPEG quality for canvas re-encoding (0.85 balances size and quality)
|
||||
const JPEG_QUALITY = 0.85;
|
||||
|
||||
const compressImage = ({
|
||||
img,
|
||||
type,
|
||||
@@ -33,16 +36,23 @@ const compressImage = ({
|
||||
|
||||
ctx.drawImage(img, 0, 0, img.width, img.height, 0, 0, width, height);
|
||||
|
||||
return canvas.toDataURL(type);
|
||||
// Preserve JPEG format with lossy compression to avoid inflating small JPEGs;
|
||||
// fall back to PNG for other formats (lossless and universally supported).
|
||||
if (type === 'image/jpeg') {
|
||||
return canvas.toDataURL('image/jpeg', JPEG_QUALITY);
|
||||
}
|
||||
return canvas.toDataURL('image/png');
|
||||
};
|
||||
|
||||
export default compressImage;
|
||||
|
||||
const dataUrlToFile = (dataUrl: string, name: string): File => {
|
||||
// Extract the actual MIME type from the data URL to keep content and type consistent
|
||||
const mimeType = dataUrl.split(',')[0].split(':')[1].split(';')[0];
|
||||
const binary = atob(dataUrl.split(',')[1]);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
return new File([bytes], name, { type: 'image/png' });
|
||||
return new File([bytes], name, { type: mimeType });
|
||||
};
|
||||
|
||||
export const compressImageFile = (file: File): Promise<File> =>
|
||||
@@ -67,7 +77,7 @@ export const compressImageFile = (file: File): Promise<File> =>
|
||||
let maxSize = MAX_IMAGE_SIZE;
|
||||
let result: File;
|
||||
do {
|
||||
const dataUrl = compressImage({ img, maxSize });
|
||||
const dataUrl = compressImage({ img, maxSize, type: file.type });
|
||||
result = dataUrlToFile(dataUrl, file.name);
|
||||
maxSize = Math.round(maxSize * 0.8);
|
||||
} while (result.size > MAX_IMAGE_BYTES && maxSize > 100);
|
||||
|
||||
@@ -185,6 +185,19 @@ describe('format', () => {
|
||||
expect(formatPrice(0.99)).toBe('0.99');
|
||||
expect(formatPrice(1000000.01, 0)).toBe('1,000,000');
|
||||
});
|
||||
|
||||
it('should expand precision when a positive price would round to zero', () => {
|
||||
expect(formatPrice(0.003625)).toBe('0.004');
|
||||
expect(formatPrice(0.0001)).toBe('0.0001');
|
||||
expect(formatPrice(0)).toBe('0.00');
|
||||
});
|
||||
|
||||
it('should not throw RangeError for sub-1e-100 prices', () => {
|
||||
// Number.prototype.toFixed accepts digits in [0, 100]; without a clamp
|
||||
// Math.ceil(-Math.log10(price)) can exceed that and throw RangeError.
|
||||
expect(() => formatPrice(1e-101)).not.toThrow();
|
||||
expect(() => formatPrice(Number.MIN_VALUE)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatPriceByCurrency', () => {
|
||||
|
||||
@@ -25,26 +25,10 @@ export const formatSize = (bytes: number, fractionDigits: number = 1): string =>
|
||||
export const formatSpeed = (byte: number, fractionDigits = 2) => {
|
||||
if (!byte && byte !== 0) return '--';
|
||||
|
||||
let word = '';
|
||||
|
||||
// Byte
|
||||
if (byte <= 1000) {
|
||||
word = byte.toFixed(fractionDigits) + ' Byte/s';
|
||||
}
|
||||
// KB
|
||||
else if (byte / 1024 <= 1000) {
|
||||
word = (byte / 1024).toFixed(fractionDigits) + ' KB/s';
|
||||
}
|
||||
// MB
|
||||
else if (byte / 1024 / 1024 <= 1000) {
|
||||
word = (byte / 1024 / 1024).toFixed(fractionDigits) + ' MB/s';
|
||||
}
|
||||
// GB
|
||||
else {
|
||||
word = (byte / 1024 / 1024 / 1024).toFixed(fractionDigits) + ' GB/s';
|
||||
}
|
||||
|
||||
return word;
|
||||
if (byte <= 1000) return byte.toFixed(fractionDigits) + ' Byte/s';
|
||||
if (byte / 1024 <= 1000) return (byte / 1024).toFixed(fractionDigits) + ' KB/s';
|
||||
if (byte / 1024 / 1024 <= 1000) return (byte / 1024 / 1024).toFixed(fractionDigits) + ' MB/s';
|
||||
return (byte / 1024 / 1024 / 1024).toFixed(fractionDigits) + ' GB/s';
|
||||
};
|
||||
|
||||
export const formatTime = (timeInSeconds: number): string => {
|
||||
@@ -118,7 +102,15 @@ export const formatPrice = (price: number, fractionDigits: number = 2) => {
|
||||
|
||||
if (fractionDigits === 0) return numeral(price).format('0,0');
|
||||
|
||||
const [a, b] = price.toFixed(fractionDigits).split('.');
|
||||
// Expand precision when a positive price would round to zero at the requested
|
||||
// precision (e.g. $0.003625 → "0.00"), so users can tell it isn't actually free.
|
||||
// Cap at 100 because Number.prototype.toFixed throws RangeError beyond that.
|
||||
let digits = fractionDigits;
|
||||
if (price > 0 && Number(price.toFixed(fractionDigits)) === 0) {
|
||||
digits = Math.min(100, Math.ceil(-Math.log10(price)));
|
||||
}
|
||||
|
||||
const [a, b] = price.toFixed(digits).split('.');
|
||||
return `${numeral(a).format('0,0')}.${b}`;
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export const getProviderContentPolicyErrorMessage = async (_params: {
|
||||
error: unknown;
|
||||
provider: string;
|
||||
userId?: string;
|
||||
}): Promise<string | undefined> => undefined;
|
||||
@@ -0,0 +1,11 @@
|
||||
export interface TrackProviderContentPolicyViolationParams {
|
||||
error: unknown;
|
||||
model?: string;
|
||||
provider: string;
|
||||
trigger?: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export const trackProviderContentPolicyViolation = async (
|
||||
_params: TrackProviderContentPolicyViolationParams,
|
||||
): Promise<void> => {};
|
||||
+4
-1
@@ -1,11 +1,14 @@
|
||||
/**
|
||||
* Well-known `topic.trigger` values used to segment the same agent's topics
|
||||
* across different panels (Task Manager vs. main chat).
|
||||
*
|
||||
* `RunTask` is what `TaskRunnerService` writes when starting an agent run for
|
||||
* a task; the literal `'task'` is intentional and matches existing DB rows.
|
||||
*/
|
||||
export const TopicTrigger = {
|
||||
Cron: 'cron',
|
||||
Eval: 'eval',
|
||||
RunTask: 'run_task',
|
||||
RunTask: 'task',
|
||||
TaskManager: 'task_manager',
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -98,7 +98,8 @@ const CommentCard = memo<CommentCardProps>(({ activity }) => {
|
||||
<Block
|
||||
className={styles.commentCard}
|
||||
gap={8}
|
||||
padding={12}
|
||||
paddingBlock={12}
|
||||
paddingInline={8}
|
||||
style={{ borderRadius: cssVar.borderRadiusLG }}
|
||||
variant={'outlined'}
|
||||
>
|
||||
|
||||
@@ -8,7 +8,6 @@ import { memo, useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import AgentProfilePopup from '@/features/AgentProfileCard/AgentProfilePopup';
|
||||
import BriefCard from '@/features/DailyBrief/BriefCard';
|
||||
import type { BriefItem } from '@/features/DailyBrief/types';
|
||||
import { useTaskStore } from '@/store/task';
|
||||
import { taskActivitySelectors, taskDetailSelectors } from '@/store/task/selectors';
|
||||
@@ -16,6 +15,7 @@ import { taskActivitySelectors, taskDetailSelectors } from '@/store/task/selecto
|
||||
import { styles } from '../shared/style';
|
||||
import CommentCard from './CommentCard';
|
||||
import CommentInput from './CommentInput';
|
||||
import TaskBriefCard from './TaskBriefCard';
|
||||
import TopicCard from './TopicCard';
|
||||
|
||||
const ROW_TYPE_ICON = {
|
||||
@@ -164,9 +164,8 @@ const TaskActivities = memo(() => {
|
||||
items.map(({ activity, brief, key }) => {
|
||||
if (brief) {
|
||||
return (
|
||||
<BriefCard
|
||||
<TaskBriefCard
|
||||
brief={brief}
|
||||
enableNavigation={false}
|
||||
key={key}
|
||||
onAfterAddComment={refreshActiveTask}
|
||||
onAfterResolve={refreshActiveTask}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { TaskDetailWorkspaceNode } from '@lobechat/types';
|
||||
import { Block, Flexbox, Icon, Tag, Text } from '@lobehub/ui';
|
||||
import { ConfigProvider, Tree } from 'antd';
|
||||
import type { DataNode } from 'antd/es/tree';
|
||||
import { cssVar } from 'antd-style';
|
||||
import { ChevronDown, FileText, FolderClosed, Package } from 'lucide-react';
|
||||
import { memo, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useTaskStore } from '@/store/task';
|
||||
import { taskDetailSelectors } from '@/store/task/selectors';
|
||||
|
||||
import { styles } from '../shared/style';
|
||||
|
||||
const formatSize = (size?: number | null): string | undefined => {
|
||||
if (size == null) return undefined;
|
||||
if (size < 1000) return `${size} chars`;
|
||||
return `${(size / 1000).toFixed(1)}k chars`;
|
||||
};
|
||||
|
||||
const ArtifactTitle = memo<{ node: TaskDetailWorkspaceNode }>(({ node }) => {
|
||||
const isFolder = (node.children?.length ?? 0) > 0;
|
||||
const sizeLabel = formatSize(node.size);
|
||||
|
||||
return (
|
||||
<Flexbox
|
||||
horizontal
|
||||
align="center"
|
||||
gap={8}
|
||||
style={{ lineHeight: 1, minWidth: 0, overflow: 'hidden', width: '100%' }}
|
||||
>
|
||||
<Icon
|
||||
color={cssVar.colorTextDescription}
|
||||
icon={isFolder ? FolderClosed : FileText}
|
||||
size={14}
|
||||
style={{ flex: 'none' }}
|
||||
/>
|
||||
<Text ellipsis fontSize={13} style={{ flex: 1, minWidth: 0 }}>
|
||||
{node.title || 'Untitled'}
|
||||
</Text>
|
||||
{sizeLabel && (
|
||||
<Text style={{ color: cssVar.colorTextQuaternary, flex: 'none', fontSize: 12 }}>
|
||||
{sizeLabel}
|
||||
</Text>
|
||||
)}
|
||||
{node.sourceTaskIdentifier && (
|
||||
<Tag size="small" style={{ flexShrink: 0 }}>
|
||||
{node.sourceTaskIdentifier}
|
||||
</Tag>
|
||||
)}
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
|
||||
const toTreeData = (nodes: TaskDetailWorkspaceNode[]): DataNode[] =>
|
||||
nodes.map((node) => ({
|
||||
children: node.children?.length ? toTreeData(node.children) : undefined,
|
||||
key: node.documentId,
|
||||
title: <ArtifactTitle node={node} />,
|
||||
}));
|
||||
|
||||
const TaskArtifacts = memo(() => {
|
||||
const { t } = useTranslation('chat');
|
||||
const workspace = useTaskStore(taskDetailSelectors.activeTaskWorkspace);
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
|
||||
const treeData = useMemo(() => toTreeData(workspace), [workspace]);
|
||||
|
||||
if (workspace.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Flexbox gap={8}>
|
||||
<Flexbox horizontal align="center" justify="space-between">
|
||||
<Block
|
||||
clickable
|
||||
horizontal
|
||||
align="center"
|
||||
gap={8}
|
||||
paddingBlock={4}
|
||||
paddingInline={8}
|
||||
style={{ cursor: 'pointer', width: 'fit-content' }}
|
||||
variant="borderless"
|
||||
onClick={() => setIsExpanded((prev) => !prev)}
|
||||
>
|
||||
<Icon color={cssVar.colorTextDescription} icon={Package} size={16} />
|
||||
<Text color={cssVar.colorTextSecondary} fontSize={13} weight={500}>
|
||||
{t('taskDetail.artifacts')}
|
||||
</Text>
|
||||
<Tag size="small">{workspace.length}</Tag>
|
||||
<Icon
|
||||
color={cssVar.colorTextDescription}
|
||||
icon={ChevronDown}
|
||||
size={14}
|
||||
style={{
|
||||
transform: isExpanded ? 'rotate(0deg)' : 'rotate(-90deg)',
|
||||
transition: 'transform 200ms',
|
||||
}}
|
||||
/>
|
||||
</Block>
|
||||
</Flexbox>
|
||||
{isExpanded && (
|
||||
<ConfigProvider theme={{ components: { Tree: { titleHeight: 32 } } }}>
|
||||
<Tree
|
||||
blockNode
|
||||
defaultExpandAll
|
||||
showLine
|
||||
className={styles.subtaskTree}
|
||||
selectable={false}
|
||||
switcherIcon={<Icon icon={ChevronDown} size={14} />}
|
||||
treeData={treeData}
|
||||
/>
|
||||
</ConfigProvider>
|
||||
)}
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
|
||||
export default TaskArtifacts;
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Block, Flexbox, Text } from '@lobehub/ui';
|
||||
import { cssVar } from 'antd-style';
|
||||
import { memo } from 'react';
|
||||
|
||||
import BriefCardActions from '@/features/DailyBrief/BriefCardActions';
|
||||
import BriefCardSummary from '@/features/DailyBrief/BriefCardSummary';
|
||||
import BriefIcon from '@/features/DailyBrief/BriefIcon';
|
||||
import { styles as briefStyles } from '@/features/DailyBrief/style';
|
||||
import type { BriefItem } from '@/features/DailyBrief/types';
|
||||
import Time from '@/routes/(main)/home/features/components/Time';
|
||||
|
||||
interface TaskBriefCardProps {
|
||||
brief: BriefItem;
|
||||
onAfterAddComment?: () => void | Promise<void>;
|
||||
onAfterResolve?: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
const TaskBriefCard = memo<TaskBriefCardProps>(({ brief, onAfterResolve, onAfterAddComment }) => {
|
||||
return (
|
||||
<Block
|
||||
className={briefStyles.card}
|
||||
gap={12}
|
||||
padding={12}
|
||||
style={{ borderRadius: cssVar.borderRadiusLG }}
|
||||
variant={'outlined'}
|
||||
>
|
||||
<Flexbox horizontal align={'center'} gap={8} style={{ overflow: 'hidden' }}>
|
||||
<BriefIcon size={20} type={brief.type} />
|
||||
<Text ellipsis style={{ flex: 1 }} weight={500}>
|
||||
{brief.title}
|
||||
</Text>
|
||||
<Time date={brief.createdAt} />
|
||||
</Flexbox>
|
||||
<BriefCardSummary summary={brief.summary} />
|
||||
<BriefCardActions
|
||||
actions={brief.actions}
|
||||
briefId={brief.id}
|
||||
briefType={brief.type}
|
||||
resolvedAction={brief.resolvedAction}
|
||||
taskId={brief.taskId}
|
||||
onAfterAddComment={onAfterAddComment}
|
||||
onAfterResolve={onAfterResolve}
|
||||
/>
|
||||
</Block>
|
||||
);
|
||||
});
|
||||
|
||||
export default TaskBriefCard;
|
||||
@@ -12,6 +12,7 @@ import { taskDetailSelectors } from '@/store/task/selectors';
|
||||
|
||||
import Breadcrumb from '../shared/Breadcrumb';
|
||||
import TaskActivities from './TaskActivities';
|
||||
import TaskArtifacts from './TaskArtifacts';
|
||||
import TaskDetailAssignee from './TaskDetailAssignee';
|
||||
import TaskDetailHeaderActions from './TaskDetailHeaderActions';
|
||||
import TaskDetailRunPauseAction from './TaskDetailRunPauseAction';
|
||||
@@ -95,6 +96,7 @@ const TaskDetailPage = memo<TaskDetailPageProps>(({ agentId, taskId }) => {
|
||||
<Flexbox gap={24} style={{ paddingBottom: 120 }}>
|
||||
<TaskInstruction />
|
||||
<TaskSubtasks />
|
||||
<TaskArtifacts />
|
||||
<TaskActivities />
|
||||
</Flexbox>
|
||||
</>
|
||||
|
||||
@@ -22,6 +22,7 @@ const STATUS_META: Record<TaskStatus, StatusMeta> = {
|
||||
failed: { labelKey: 'status.failed' },
|
||||
paused: { labelKey: 'status.paused' },
|
||||
running: { labelKey: 'status.running' },
|
||||
scheduled: { labelKey: 'status.scheduled' },
|
||||
};
|
||||
|
||||
interface PriorityMeta {
|
||||
@@ -43,6 +44,9 @@ const TaskProperties = memo(() => {
|
||||
const status = useTaskStore(taskDetailSelectors.activeTaskStatus) as TaskStatus | undefined;
|
||||
const priority = useTaskStore(taskDetailSelectors.activeTaskPriority);
|
||||
const heartbeatInterval = useTaskStore(taskDetailSelectors.activeTaskPeriodicInterval);
|
||||
const automationMode = useTaskStore(taskDetailSelectors.activeTaskAutomationMode);
|
||||
const schedulePattern = useTaskStore(taskDetailSelectors.activeTaskSchedulePattern);
|
||||
const scheduleTimezone = useTaskStore(taskDetailSelectors.activeTaskScheduleTimezone);
|
||||
|
||||
if (!taskId) return null;
|
||||
|
||||
@@ -91,7 +95,12 @@ const TaskProperties = memo(() => {
|
||||
paddingInline={8}
|
||||
variant={'borderless'}
|
||||
>
|
||||
<TaskTriggerTag heartbeatInterval={heartbeatInterval} mode="inline" />
|
||||
<TaskTriggerTag
|
||||
heartbeatInterval={automationMode === 'heartbeat' ? heartbeatInterval : undefined}
|
||||
mode="inline"
|
||||
schedulePattern={automationMode === 'schedule' ? schedulePattern : undefined}
|
||||
scheduleTimezone={automationMode === 'schedule' ? scheduleTimezone : undefined}
|
||||
/>
|
||||
</Block>
|
||||
</TaskScheduleConfig>
|
||||
</Block>
|
||||
|
||||
@@ -18,6 +18,8 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useTaskStore } from '@/store/task';
|
||||
import { taskDetailSelectors } from '@/store/task/selectors';
|
||||
|
||||
import SchedulerForm, { type SchedulerFormChange } from './scheduler/SchedulerForm';
|
||||
|
||||
type IntervalUnit = 'hours' | 'minutes' | 'seconds';
|
||||
|
||||
interface IntervalTabProps {
|
||||
@@ -104,14 +106,12 @@ const IntervalTab = memo<IntervalTabProps>(({ currentInterval, taskId }) => {
|
||||
<InputNumber
|
||||
min={1}
|
||||
placeholder="10"
|
||||
size={'small'}
|
||||
style={{ width: 80 }}
|
||||
style={{ width: 100 }}
|
||||
value={localValue}
|
||||
onChange={handleValueChange}
|
||||
/>
|
||||
<Select
|
||||
size={'small'}
|
||||
style={{ width: 90 }}
|
||||
style={{ width: 110 }}
|
||||
value={localUnit}
|
||||
variant="outlined"
|
||||
options={[
|
||||
@@ -123,22 +123,36 @@ const IntervalTab = memo<IntervalTabProps>(({ currentInterval, taskId }) => {
|
||||
/>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
{currentInterval > 0 && (
|
||||
<Button size={'small'} onClick={handleClear}>
|
||||
{t('taskSchedule.clear')}
|
||||
</Button>
|
||||
)}
|
||||
{currentInterval > 0 && <Button onClick={handleClear}>{t('taskSchedule.clear')}</Button>}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
const SchedulerTab = memo(() => {
|
||||
const { t } = useTranslation('chat');
|
||||
interface SchedulerTabProps {
|
||||
taskId?: string;
|
||||
}
|
||||
|
||||
const SchedulerTab = memo<SchedulerTabProps>(({ taskId }) => {
|
||||
const updateSchedule = useTaskStore((s) => s.updateSchedule);
|
||||
const pattern = useTaskStore(taskDetailSelectors.activeTaskSchedulePattern);
|
||||
const timezone = useTaskStore(taskDetailSelectors.activeTaskScheduleTimezone);
|
||||
const maxExecutions = useTaskStore(taskDetailSelectors.activeTaskScheduleMaxExecutions);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(change: SchedulerFormChange) => {
|
||||
if (!taskId) return;
|
||||
updateSchedule(taskId, change);
|
||||
},
|
||||
[taskId, updateSchedule],
|
||||
);
|
||||
|
||||
return (
|
||||
<Flexbox align="center" justify="center" style={{ minHeight: 80, padding: 16 }}>
|
||||
<Text type="secondary">{t('taskSchedule.schedulerNotReady')}</Text>
|
||||
</Flexbox>
|
||||
<SchedulerForm
|
||||
maxExecutions={maxExecutions}
|
||||
pattern={pattern}
|
||||
timezone={timezone}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -181,27 +195,26 @@ const TaskScheduleConfig = memo(function TaskScheduleConfig({
|
||||
);
|
||||
|
||||
const content = (
|
||||
<Flexbox gap={12} style={{ minWidth: 240, padding: 4 }} onClick={(e) => e.stopPropagation()}>
|
||||
<Flexbox gap={16} style={{ padding: 8, width: 420 }} onClick={(e) => e.stopPropagation()}>
|
||||
<Flexbox horizontal align="center" gap={8}>
|
||||
<Text weight={500}>{t('taskSchedule.enable')}</Text>
|
||||
<Switch checked={enabled} size="small" onChange={handleEnableChange} />
|
||||
<Switch checked={enabled} onChange={handleEnableChange} />
|
||||
</Flexbox>
|
||||
{enabled && (
|
||||
<>
|
||||
<Segmented
|
||||
block
|
||||
size="small"
|
||||
value={automationMode ?? 'heartbeat'}
|
||||
options={[
|
||||
{ label: t('taskSchedule.intervalTab'), value: 'heartbeat' },
|
||||
{ label: t('taskSchedule.schedulerTab'), value: 'schedule' },
|
||||
{ label: t('taskSchedule.intervalTab'), value: 'heartbeat' },
|
||||
]}
|
||||
onChange={handleModeChange}
|
||||
/>
|
||||
{automationMode === 'heartbeat' && (
|
||||
<IntervalTab currentInterval={finalCurrentInterval} taskId={finalTaskId} />
|
||||
)}
|
||||
{automationMode === 'schedule' && <SchedulerTab />}
|
||||
{automationMode === 'schedule' && <SchedulerTab taskId={finalTaskId} />}
|
||||
</>
|
||||
)}
|
||||
</Flexbox>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { TaskDetailSubtask } from '@lobechat/types';
|
||||
import { ActionIcon, Avatar, Block, ContextMenuTrigger, Flexbox, Icon, Text } from '@lobehub/ui';
|
||||
import { ActionIcon, Block, ContextMenuTrigger, Flexbox, Icon, Text } from '@lobehub/ui';
|
||||
import { Button, ConfigProvider, Tree } from 'antd';
|
||||
import type { DataNode } from 'antd/es/tree';
|
||||
import { cssVar } from 'antd-style';
|
||||
@@ -12,9 +12,12 @@ import { useTaskStore } from '@/store/task';
|
||||
import { taskDetailSelectors } from '@/store/task/selectors';
|
||||
|
||||
import CreateTaskInlineEntry from '../AgentTaskList/CreateTaskInlineEntry';
|
||||
import AssigneeAgentSelector from '../features/AssigneeAgentSelector';
|
||||
import AssigneeAvatar from '../features/AssigneeAvatar';
|
||||
import TaskPriorityTag from '../features/TaskPriorityTag';
|
||||
import TaskStatusTag from '../features/TaskStatusTag';
|
||||
import TaskSubtaskProgressTag from '../features/TaskSubtaskProgressTag';
|
||||
import TaskTriggerTag from '../features/TaskTriggerTag';
|
||||
import { useTaskItemContextMenu } from '../features/useTaskItemContextMenu';
|
||||
import { styles } from '../shared/style';
|
||||
|
||||
@@ -78,13 +81,16 @@ const SubtaskTitle = memo<{ task: TaskDetailSubtask }>(({ task }) => {
|
||||
status: task.status,
|
||||
});
|
||||
|
||||
const isRunning = status === 'running';
|
||||
|
||||
return (
|
||||
<ContextMenuTrigger items={items} onContextMenu={onContextMenu}>
|
||||
<Flexbox
|
||||
horizontal
|
||||
align="center"
|
||||
gap={8}
|
||||
style={{ lineHeight: 1, minWidth: 0, overflow: 'hidden' }}
|
||||
justify="space-between"
|
||||
style={{ lineHeight: 1, minWidth: 0, overflow: 'hidden', width: '100%' }}
|
||||
>
|
||||
<span
|
||||
style={{ alignItems: 'center', display: 'inline-flex', flex: 'none' }}
|
||||
@@ -101,21 +107,34 @@ const SubtaskTitle = memo<{ task: TaskDetailSubtask }>(({ task }) => {
|
||||
<Text ellipsis fontSize={13} style={{ flex: 1, minWidth: 0 }}>
|
||||
{task.name || task.identifier}
|
||||
</Text>
|
||||
{task.assignee && (
|
||||
{task.automationMode ? (
|
||||
<span
|
||||
style={{ alignItems: 'center', display: 'inline-flex', flex: 'none' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Avatar
|
||||
avatar={task.assignee.avatar ?? ''}
|
||||
background={task.assignee.backgroundColor || cssVar.colorBgContainer}
|
||||
shape="circle"
|
||||
size={18}
|
||||
title={task.assignee.title ?? ''}
|
||||
variant="outlined"
|
||||
<TaskTriggerTag
|
||||
heartbeatInterval={task.heartbeat?.interval}
|
||||
schedulePattern={task.schedule?.pattern}
|
||||
scheduleTimezone={task.schedule?.timezone}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
) : null}
|
||||
<AssigneeAgentSelector
|
||||
currentAgentId={task.assignee?.id ?? null}
|
||||
disabled={isRunning}
|
||||
taskIdentifier={task.identifier}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
cursor: isRunning ? 'not-allowed' : 'pointer',
|
||||
display: 'inline-flex',
|
||||
flex: 'none',
|
||||
}}
|
||||
>
|
||||
<AssigneeAvatar agentId={task.assignee?.id} size={18} />
|
||||
</span>
|
||||
</AssigneeAgentSelector>
|
||||
</Flexbox>
|
||||
</ContextMenuTrigger>
|
||||
);
|
||||
|
||||
@@ -6,17 +6,18 @@ import {
|
||||
type DropdownItem,
|
||||
DropdownMenu,
|
||||
Flexbox,
|
||||
Icon,
|
||||
Text,
|
||||
} from '@lobehub/ui';
|
||||
import { cssVar } from 'antd-style';
|
||||
import dayjs from 'dayjs';
|
||||
import { CalendarDays, Copy, ExternalLink, MoreHorizontal } from 'lucide-react';
|
||||
import { CircleDot, Copy, ExternalLink, MoreHorizontal } from 'lucide-react';
|
||||
import { memo, useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import AgentProfilePopup from '@/features/AgentProfileCard/AgentProfilePopup';
|
||||
import { useTaskStore } from '@/store/task';
|
||||
|
||||
import { styles } from '../shared/style';
|
||||
import TopicStatusIcon from './TopicStatusIcon';
|
||||
|
||||
const formatDuration = (ms: number): string => {
|
||||
@@ -76,17 +77,39 @@ const TopicCard = memo<TopicCardProps>(({ activity }) => {
|
||||
},
|
||||
];
|
||||
|
||||
const isAgent = activity.author?.type === 'agent';
|
||||
|
||||
const avatarNode = activity.author?.avatar ? (
|
||||
<Avatar avatar={activity.author.avatar} size={24} />
|
||||
) : (
|
||||
<div className={styles.activityAvatar}>
|
||||
<CircleDot size={12} />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Block
|
||||
clickable={!!activity.id}
|
||||
gap={8}
|
||||
padding={12}
|
||||
paddingBlock={8}
|
||||
paddingInline={8}
|
||||
style={{ borderRadius: cssVar.borderRadiusLG }}
|
||||
variant={'outlined'}
|
||||
onClick={activity.id ? handleOpen : undefined}
|
||||
>
|
||||
<Flexbox horizontal align={'center'} gap={12} justify={'space-between'}>
|
||||
<Flexbox horizontal align={'center'} gap={8} justify={'space-between'}>
|
||||
<Flexbox horizontal align={'center'} gap={8} style={{ minWidth: 0, overflow: 'hidden' }}>
|
||||
{isAgent && activity.author?.id ? (
|
||||
<AgentProfilePopup
|
||||
agent={{ avatar: activity.author.avatar, title: activity.author.name }}
|
||||
agentId={activity.author.id}
|
||||
trigger={'hover'}
|
||||
>
|
||||
{avatarNode}
|
||||
</AgentProfilePopup>
|
||||
) : (
|
||||
avatarNode
|
||||
)}
|
||||
<TopicStatusIcon size={16} status={activity.status} />
|
||||
<Text ellipsis weight={500}>
|
||||
{activity.title}
|
||||
@@ -104,21 +127,10 @@ const TopicCard = memo<TopicCardProps>(({ activity }) => {
|
||||
</Flexbox>
|
||||
|
||||
<Flexbox horizontal align={'center'} flex={'none'} gap={8}>
|
||||
{activity.author && (
|
||||
<Flexbox horizontal align={'center'} gap={6}>
|
||||
{activity.author.avatar && <Avatar avatar={activity.author.avatar} size={20} />}
|
||||
<Text fontSize={12} type={'secondary'}>
|
||||
{activity.author.name}
|
||||
</Text>
|
||||
</Flexbox>
|
||||
)}
|
||||
{startedAt && (
|
||||
<Flexbox horizontal align={'center'} gap={4}>
|
||||
<Icon color={cssVar.colorTextTertiary} icon={CalendarDays} size={12} />
|
||||
<Text fontSize={12} type={'secondary'}>
|
||||
{startedAt}
|
||||
</Text>
|
||||
</Flexbox>
|
||||
<Text fontSize={12} type={'secondary'}>
|
||||
{startedAt}
|
||||
</Text>
|
||||
)}
|
||||
<DropdownMenu items={menuItems}>
|
||||
<ActionIcon
|
||||
|
||||
@@ -7,6 +7,7 @@ import { memo, useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { ChatList, ConversationProvider, MessageItem } from '@/features/Conversation';
|
||||
import { useGatewayReconnect } from '@/hooks/useGatewayReconnect';
|
||||
import { useOperationState } from '@/hooks/useOperationState';
|
||||
import { useChatStore } from '@/store/chat';
|
||||
import { messageMapKey } from '@/store/chat/utils/messageMapKey';
|
||||
@@ -36,6 +37,11 @@ const TopicChatDrawerBody = memo<TopicChatDrawerBodyProps>(({ agentId, topicId }
|
||||
const replaceMessages = useChatStore((s) => s.replaceMessages);
|
||||
const operationState = useOperationState(context);
|
||||
|
||||
const runningOperation = useTaskStore(
|
||||
(s) => taskActivitySelectors.activeDrawerTopicActivity(s)?.runningOperation,
|
||||
);
|
||||
useGatewayReconnect(topicId, runningOperation);
|
||||
|
||||
const itemContent = useCallback(
|
||||
(index: number, id: string) => (
|
||||
<MessageItem
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { type Dayjs } from 'dayjs';
|
||||
|
||||
export type ScheduleType = 'daily' | 'hourly' | 'weekly';
|
||||
|
||||
export const SCHEDULE_TYPE_OPTIONS = [
|
||||
{ label: 'taskSchedule.scheduleType.daily', value: 'daily' },
|
||||
{ label: 'taskSchedule.scheduleType.hourly', value: 'hourly' },
|
||||
{ label: 'taskSchedule.scheduleType.weekly', value: 'weekly' },
|
||||
] as const;
|
||||
|
||||
export const TIMEZONE_OPTIONS = [
|
||||
{ label: 'UTC', value: 'UTC' },
|
||||
|
||||
// Americas
|
||||
{ label: 'America/New_York (EST/EDT, UTC-5/-4)', value: 'America/New_York' },
|
||||
{ label: 'America/Chicago (CST/CDT, UTC-6/-5)', value: 'America/Chicago' },
|
||||
{ label: 'America/Denver (MST/MDT, UTC-7/-6)', value: 'America/Denver' },
|
||||
{ label: 'America/Los_Angeles (PST/PDT, UTC-8/-7)', value: 'America/Los_Angeles' },
|
||||
{ label: 'America/Toronto (EST/EDT, UTC-5/-4)', value: 'America/Toronto' },
|
||||
{ label: 'America/Vancouver (PST/PDT, UTC-8/-7)', value: 'America/Vancouver' },
|
||||
{ label: 'America/Mexico_City (CST, UTC-6)', value: 'America/Mexico_City' },
|
||||
{ label: 'America/Sao_Paulo (BRT, UTC-3)', value: 'America/Sao_Paulo' },
|
||||
{ label: 'America/Buenos_Aires (ART, UTC-3)', value: 'America/Buenos_Aires' },
|
||||
|
||||
// Europe
|
||||
{ label: 'Europe/London (GMT/BST, UTC+0/+1)', value: 'Europe/London' },
|
||||
{ label: 'Europe/Paris (CET/CEST, UTC+1/+2)', value: 'Europe/Paris' },
|
||||
{ label: 'Europe/Berlin (CET/CEST, UTC+1/+2)', value: 'Europe/Berlin' },
|
||||
{ label: 'Europe/Madrid (CET/CEST, UTC+1/+2)', value: 'Europe/Madrid' },
|
||||
{ label: 'Europe/Rome (CET/CEST, UTC+1/+2)', value: 'Europe/Rome' },
|
||||
{ label: 'Europe/Amsterdam (CET/CEST, UTC+1/+2)', value: 'Europe/Amsterdam' },
|
||||
{ label: 'Europe/Brussels (CET/CEST, UTC+1/+2)', value: 'Europe/Brussels' },
|
||||
{ label: 'Europe/Moscow (MSK, UTC+3)', value: 'Europe/Moscow' },
|
||||
{ label: 'Europe/Istanbul (TRT, UTC+3)', value: 'Europe/Istanbul' },
|
||||
|
||||
// Asia
|
||||
{ label: 'Asia/Dubai (GST, UTC+4)', value: 'Asia/Dubai' },
|
||||
{ label: 'Asia/Kolkata (IST, UTC+5:30)', value: 'Asia/Kolkata' },
|
||||
{ label: 'Asia/Shanghai (CST, UTC+8)', value: 'Asia/Shanghai' },
|
||||
{ label: 'Asia/Hong_Kong (HKT, UTC+8)', value: 'Asia/Hong_Kong' },
|
||||
{ label: 'Asia/Taipei (CST, UTC+8)', value: 'Asia/Taipei' },
|
||||
{ label: 'Asia/Singapore (SGT, UTC+8)', value: 'Asia/Singapore' },
|
||||
{ label: 'Asia/Tokyo (JST, UTC+9)', value: 'Asia/Tokyo' },
|
||||
{ label: 'Asia/Seoul (KST, UTC+9)', value: 'Asia/Seoul' },
|
||||
{ label: 'Asia/Bangkok (ICT, UTC+7)', value: 'Asia/Bangkok' },
|
||||
{ label: 'Asia/Jakarta (WIB, UTC+7)', value: 'Asia/Jakarta' },
|
||||
|
||||
// Oceania
|
||||
{ label: 'Australia/Sydney (AEDT/AEST, UTC+11/+10)', value: 'Australia/Sydney' },
|
||||
{ label: 'Australia/Melbourne (AEDT/AEST, UTC+11/+10)', value: 'Australia/Melbourne' },
|
||||
{ label: 'Australia/Brisbane (AEST, UTC+10)', value: 'Australia/Brisbane' },
|
||||
{ label: 'Australia/Perth (AWST, UTC+8)', value: 'Australia/Perth' },
|
||||
{ label: 'Pacific/Auckland (NZDT/NZST, UTC+13/+12)', value: 'Pacific/Auckland' },
|
||||
|
||||
// Africa & Middle East
|
||||
{ label: 'Africa/Cairo (EET, UTC+2)', value: 'Africa/Cairo' },
|
||||
{ label: 'Africa/Johannesburg (SAST, UTC+2)', value: 'Africa/Johannesburg' },
|
||||
];
|
||||
|
||||
export const WEEKDAYS = [
|
||||
{ key: 1, label: 'taskSchedule.weekdays.mon' },
|
||||
{ key: 2, label: 'taskSchedule.weekdays.tue' },
|
||||
{ key: 3, label: 'taskSchedule.weekdays.wed' },
|
||||
{ key: 4, label: 'taskSchedule.weekdays.thu' },
|
||||
{ key: 5, label: 'taskSchedule.weekdays.fri' },
|
||||
{ key: 6, label: 'taskSchedule.weekdays.sat' },
|
||||
{ key: 0, label: 'taskSchedule.weekdays.sun' },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Parse cron pattern (minute hour day month weekday) into editable schedule info.
|
||||
*/
|
||||
export const parseCronPattern = (
|
||||
cronPattern: string,
|
||||
): {
|
||||
hourlyInterval?: number;
|
||||
scheduleType: ScheduleType;
|
||||
triggerHour: number;
|
||||
triggerMinute: number;
|
||||
weekdays?: number[];
|
||||
} => {
|
||||
const parts = cronPattern.split(' ');
|
||||
if (parts.length !== 5) {
|
||||
return { scheduleType: 'daily', triggerHour: 0, triggerMinute: 0 };
|
||||
}
|
||||
|
||||
const [minute, hour, , , weekday] = parts;
|
||||
const rawMinute = minute === '*' ? 0 : Number.parseInt(minute);
|
||||
const triggerMinute = rawMinute >= 15 && rawMinute < 45 ? 30 : 0;
|
||||
|
||||
if (hour.startsWith('*/')) {
|
||||
const interval = Number.parseInt(hour.slice(2));
|
||||
return { hourlyInterval: interval, scheduleType: 'hourly', triggerHour: 0, triggerMinute };
|
||||
}
|
||||
if (hour === '*') {
|
||||
return { hourlyInterval: 1, scheduleType: 'hourly', triggerHour: 0, triggerMinute };
|
||||
}
|
||||
|
||||
const triggerHour = Number.parseInt(hour);
|
||||
|
||||
if (weekday !== '*') {
|
||||
const weekdays = weekday.split(',').map((d) => Number.parseInt(d));
|
||||
return { scheduleType: 'weekly', triggerHour, triggerMinute, weekdays };
|
||||
}
|
||||
|
||||
return { scheduleType: 'daily', triggerHour, triggerMinute };
|
||||
};
|
||||
|
||||
/**
|
||||
* Build cron pattern (minute hour day month weekday) from editable schedule info.
|
||||
*/
|
||||
export const buildCronPattern = (
|
||||
scheduleType: ScheduleType,
|
||||
triggerTime: Dayjs,
|
||||
hourlyInterval?: number,
|
||||
weekdays?: number[],
|
||||
): string => {
|
||||
const rawMinute = triggerTime.minute();
|
||||
const minute = rawMinute >= 15 && rawMinute < 45 ? 30 : 0;
|
||||
const hour = triggerTime.hour();
|
||||
|
||||
switch (scheduleType) {
|
||||
case 'hourly': {
|
||||
const interval = hourlyInterval || 1;
|
||||
if (interval === 1) return `${minute} * * * *`;
|
||||
return `${minute} */${interval} * * *`;
|
||||
}
|
||||
case 'daily': {
|
||||
return `${minute} ${hour} * * *`;
|
||||
}
|
||||
case 'weekly': {
|
||||
const days = weekdays && weekdays.length > 0 ? weekdays.sort().join(',') : '0,1,2,3,4,5,6';
|
||||
return `${minute} ${hour} * * ${days}`;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,302 @@
|
||||
import { Checkbox, Flexbox, InputNumber, Select, Text } from '@lobehub/ui';
|
||||
import { TimePicker } from 'antd';
|
||||
import { createStaticStyles, cx } from 'antd-style';
|
||||
import dayjs, { type Dayjs } from 'dayjs';
|
||||
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
buildCronPattern,
|
||||
parseCronPattern,
|
||||
SCHEDULE_TYPE_OPTIONS,
|
||||
type ScheduleType,
|
||||
TIMEZONE_OPTIONS,
|
||||
WEEKDAYS,
|
||||
} from './CronConfig';
|
||||
|
||||
const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
label: css`
|
||||
flex-shrink: 0;
|
||||
width: 80px;
|
||||
color: ${cssVar.colorTextSecondary};
|
||||
`,
|
||||
weekdayButton: css`
|
||||
cursor: pointer;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 36px;
|
||||
height: 32px;
|
||||
border: 1px solid ${cssVar.colorBorder};
|
||||
border-radius: 6px;
|
||||
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: ${cssVar.colorTextSecondary};
|
||||
|
||||
background: transparent;
|
||||
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: ${cssVar.colorPrimary};
|
||||
color: ${cssVar.colorPrimary};
|
||||
}
|
||||
`,
|
||||
weekdayButtonActive: css`
|
||||
border-color: ${cssVar.colorPrimary};
|
||||
color: ${cssVar.colorTextLightSolid};
|
||||
background: ${cssVar.colorPrimary};
|
||||
|
||||
&:hover {
|
||||
border-color: ${cssVar.colorPrimaryHover};
|
||||
color: ${cssVar.colorTextLightSolid};
|
||||
background: ${cssVar.colorPrimaryHover};
|
||||
}
|
||||
`,
|
||||
}));
|
||||
|
||||
const DEFAULT_PATTERN = '0 9 * * *';
|
||||
const DEFAULT_TIMEZONE = 'UTC';
|
||||
|
||||
export interface SchedulerFormChange {
|
||||
maxExecutions: number | null;
|
||||
pattern: string;
|
||||
timezone: string;
|
||||
}
|
||||
|
||||
interface SchedulerFormProps {
|
||||
maxExecutions?: number | null;
|
||||
onChange: (change: SchedulerFormChange) => void;
|
||||
pattern?: string | null;
|
||||
timezone?: string | null;
|
||||
}
|
||||
|
||||
const SchedulerForm = memo<SchedulerFormProps>(({ maxExecutions, onChange, pattern, timezone }) => {
|
||||
const { t } = useTranslation('chat');
|
||||
|
||||
const initial = useMemo(() => {
|
||||
const parsed = parseCronPattern(pattern || DEFAULT_PATTERN);
|
||||
return {
|
||||
...parsed,
|
||||
triggerTime: dayjs().hour(parsed.triggerHour).minute(parsed.triggerMinute),
|
||||
};
|
||||
}, [pattern]);
|
||||
|
||||
const [scheduleType, setScheduleType] = useState<ScheduleType>(initial.scheduleType);
|
||||
const [triggerTime, setTriggerTime] = useState<Dayjs>(initial.triggerTime);
|
||||
const [hourlyInterval, setHourlyInterval] = useState<number>(initial.hourlyInterval ?? 1);
|
||||
const [weekdays, setWeekdays] = useState<number[]>(
|
||||
initial.weekdays ?? (initial.scheduleType === 'weekly' ? [1, 2, 3, 4, 5] : []),
|
||||
);
|
||||
const [tz, setTz] = useState<string>(timezone || DEFAULT_TIMEZONE);
|
||||
const [maxExec, setMaxExec] = useState<number | null>(maxExecutions ?? null);
|
||||
|
||||
useEffect(() => {
|
||||
setScheduleType(initial.scheduleType);
|
||||
setTriggerTime(initial.triggerTime);
|
||||
setHourlyInterval(initial.hourlyInterval ?? 1);
|
||||
setWeekdays(initial.weekdays ?? (initial.scheduleType === 'weekly' ? [1, 2, 3, 4, 5] : []));
|
||||
}, [initial]);
|
||||
|
||||
useEffect(() => {
|
||||
setTz(timezone || DEFAULT_TIMEZONE);
|
||||
}, [timezone]);
|
||||
|
||||
useEffect(() => {
|
||||
setMaxExec(maxExecutions ?? null);
|
||||
}, [maxExecutions]);
|
||||
|
||||
const emit = useCallback(
|
||||
(
|
||||
overrides: Partial<{
|
||||
hourlyInterval: number;
|
||||
maxExec: number | null;
|
||||
scheduleType: ScheduleType;
|
||||
triggerTime: Dayjs;
|
||||
tz: string;
|
||||
weekdays: number[];
|
||||
}>,
|
||||
) => {
|
||||
const next = {
|
||||
hourlyInterval: overrides.hourlyInterval ?? hourlyInterval,
|
||||
maxExec: overrides.maxExec === undefined ? maxExec : overrides.maxExec,
|
||||
scheduleType: overrides.scheduleType ?? scheduleType,
|
||||
triggerTime: overrides.triggerTime ?? triggerTime,
|
||||
tz: overrides.tz ?? tz,
|
||||
weekdays: overrides.weekdays ?? weekdays,
|
||||
};
|
||||
const nextPattern = buildCronPattern(
|
||||
next.scheduleType,
|
||||
next.triggerTime,
|
||||
next.hourlyInterval,
|
||||
next.weekdays,
|
||||
);
|
||||
onChange({ maxExecutions: next.maxExec, pattern: nextPattern, timezone: next.tz });
|
||||
},
|
||||
[hourlyInterval, maxExec, onChange, scheduleType, triggerTime, tz, weekdays],
|
||||
);
|
||||
|
||||
const handleScheduleTypeChange = (value: ScheduleType) => {
|
||||
const nextWeekdays = value === 'weekly' ? (weekdays.length ? weekdays : [1, 2, 3, 4, 5]) : [];
|
||||
setScheduleType(value);
|
||||
setWeekdays(nextWeekdays);
|
||||
emit({ scheduleType: value, weekdays: nextWeekdays });
|
||||
};
|
||||
|
||||
const handleTimeChange = (value: Dayjs | null) => {
|
||||
if (!value) return;
|
||||
setTriggerTime(value);
|
||||
emit({ triggerTime: value });
|
||||
};
|
||||
|
||||
const handleHourlyMinuteChange = (minute: number) => {
|
||||
const next = dayjs().hour(0).minute(minute);
|
||||
setTriggerTime(next);
|
||||
emit({ triggerTime: next });
|
||||
};
|
||||
|
||||
const handleHourlyIntervalChange = (value: number | string | null) => {
|
||||
const next = typeof value === 'number' && value > 0 ? value : 1;
|
||||
setHourlyInterval(next);
|
||||
emit({ hourlyInterval: next });
|
||||
};
|
||||
|
||||
const toggleWeekday = (day: number) => {
|
||||
const next = weekdays.includes(day) ? weekdays.filter((d) => d !== day) : [...weekdays, day];
|
||||
setWeekdays(next);
|
||||
emit({ weekdays: next });
|
||||
};
|
||||
|
||||
const handleTimezoneChange = (value: string) => {
|
||||
setTz(value);
|
||||
emit({ tz: value });
|
||||
};
|
||||
|
||||
const handleMaxExecChange = (value: number | string | null) => {
|
||||
const next = typeof value === 'number' && value > 0 ? value : null;
|
||||
setMaxExec(next);
|
||||
emit({ maxExec: next });
|
||||
};
|
||||
|
||||
const handleContinuousChange = (checked: boolean) => {
|
||||
const next = checked ? null : 100;
|
||||
setMaxExec(next);
|
||||
emit({ maxExec: next });
|
||||
};
|
||||
|
||||
const isUnlimited = maxExec === null || maxExec === undefined;
|
||||
|
||||
return (
|
||||
<Flexbox gap={14} paddingBlock={4}>
|
||||
<Flexbox horizontal align="center" gap={12} justify="space-between">
|
||||
<Text className={styles.label}>{t('taskSchedule.frequency')}</Text>
|
||||
<Select
|
||||
style={{ width: 200 }}
|
||||
value={scheduleType}
|
||||
variant="outlined"
|
||||
options={SCHEDULE_TYPE_OPTIONS.map((opt) => ({
|
||||
label: t(opt.label as any),
|
||||
value: opt.value,
|
||||
}))}
|
||||
onChange={handleScheduleTypeChange}
|
||||
/>
|
||||
</Flexbox>
|
||||
|
||||
{scheduleType !== 'hourly' && (
|
||||
<Flexbox horizontal align="center" gap={12} justify="space-between">
|
||||
<Text className={styles.label}>{t('taskSchedule.time')}</Text>
|
||||
<TimePicker
|
||||
format="HH:mm"
|
||||
minuteStep={15}
|
||||
style={{ width: 200 }}
|
||||
value={triggerTime}
|
||||
onChange={handleTimeChange}
|
||||
/>
|
||||
</Flexbox>
|
||||
)}
|
||||
|
||||
{scheduleType === 'hourly' && (
|
||||
<Flexbox horizontal align="center" gap={12} justify="space-between">
|
||||
<Text className={styles.label}>{t('taskSchedule.every')}</Text>
|
||||
<Flexbox horizontal align="center" gap={8}>
|
||||
<InputNumber
|
||||
max={24}
|
||||
min={1}
|
||||
style={{ width: 70 }}
|
||||
value={hourlyInterval}
|
||||
onChange={handleHourlyIntervalChange}
|
||||
/>
|
||||
<Text type="secondary">{t('taskSchedule.hours')}</Text>
|
||||
<Select
|
||||
style={{ width: 80 }}
|
||||
value={triggerTime.minute()}
|
||||
variant="outlined"
|
||||
options={[
|
||||
{ label: ':00', value: 0 },
|
||||
{ label: ':15', value: 15 },
|
||||
{ label: ':30', value: 30 },
|
||||
{ label: ':45', value: 45 },
|
||||
]}
|
||||
onChange={handleHourlyMinuteChange}
|
||||
/>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
)}
|
||||
|
||||
{scheduleType === 'weekly' && (
|
||||
<Flexbox horizontal align="center" gap={12} justify="space-between">
|
||||
<Text className={styles.label}>{t('taskSchedule.weekday')}</Text>
|
||||
<Flexbox horizontal gap={6}>
|
||||
{WEEKDAYS.map(({ key, label }) => (
|
||||
<div
|
||||
key={key}
|
||||
className={cx(
|
||||
styles.weekdayButton,
|
||||
weekdays.includes(key) && styles.weekdayButtonActive,
|
||||
)}
|
||||
onClick={() => toggleWeekday(key)}
|
||||
>
|
||||
{t(label as any)}
|
||||
</div>
|
||||
))}
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
)}
|
||||
|
||||
<Flexbox horizontal align="center" gap={12} justify="space-between">
|
||||
<Text className={styles.label}>{t('taskSchedule.timezone')}</Text>
|
||||
<Select
|
||||
showSearch
|
||||
options={TIMEZONE_OPTIONS}
|
||||
popupMatchSelectWidth={false}
|
||||
style={{ width: 280 }}
|
||||
value={tz}
|
||||
variant="outlined"
|
||||
onChange={handleTimezoneChange}
|
||||
/>
|
||||
</Flexbox>
|
||||
|
||||
<Flexbox horizontal align="center" gap={12} justify="space-between">
|
||||
<Text className={styles.label}>{t('taskSchedule.maxExecutions')}</Text>
|
||||
<Flexbox horizontal align="center" gap={10}>
|
||||
<InputNumber
|
||||
disabled={isUnlimited}
|
||||
min={1}
|
||||
placeholder="100"
|
||||
style={{ width: 90 }}
|
||||
value={maxExec ?? undefined}
|
||||
onChange={handleMaxExecChange}
|
||||
/>
|
||||
<Checkbox checked={isUnlimited} onChange={handleContinuousChange}>
|
||||
{t('taskSchedule.continuous')}
|
||||
</Checkbox>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
|
||||
export default SchedulerForm;
|
||||
@@ -1,13 +1,20 @@
|
||||
import type { TaskStatus } from '@lobechat/types';
|
||||
import { ActionIcon } from '@lobehub/ui';
|
||||
import { cssVar } from 'antd-style';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { CircleCheck, CircleDashed, CircleDot, CircleSlash, CircleX, HandIcon } from 'lucide-react';
|
||||
import {
|
||||
CircleCheck,
|
||||
CircleDashed,
|
||||
CircleDot,
|
||||
CircleSlash,
|
||||
CircleX,
|
||||
Clock,
|
||||
HandIcon,
|
||||
} from 'lucide-react';
|
||||
import { memo } from 'react';
|
||||
|
||||
import { taskListSelectors } from '@/store/task/selectors';
|
||||
|
||||
type TaskStatus = 'backlog' | 'canceled' | 'completed' | 'failed' | 'paused' | 'running';
|
||||
|
||||
interface StatusMeta {
|
||||
color: string;
|
||||
icon: LucideIcon;
|
||||
@@ -20,11 +27,12 @@ const STATUS_META: Record<TaskStatus, StatusMeta> = {
|
||||
failed: { color: cssVar.colorError, icon: CircleX },
|
||||
paused: { color: cssVar.colorInfo, icon: HandIcon },
|
||||
running: { color: cssVar.colorWarning, icon: CircleDot },
|
||||
scheduled: { color: cssVar.colorTextDescription, icon: Clock },
|
||||
};
|
||||
|
||||
interface TaskStatusIconProps {
|
||||
size?: number;
|
||||
status: 'backlog' | 'canceled' | 'completed' | 'failed' | 'paused' | 'running';
|
||||
status: TaskStatus;
|
||||
}
|
||||
|
||||
const TaskStatusIcon = memo<TaskStatusIconProps>(({ size = 16, status }) => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
CircleDot,
|
||||
CircleSlash,
|
||||
CircleX,
|
||||
Clock,
|
||||
HandIcon,
|
||||
Loader2Icon,
|
||||
} from 'lucide-react';
|
||||
@@ -64,6 +65,12 @@ export const STATUS_META: Record<TaskStatus, StatusMeta> = {
|
||||
label: 'Running',
|
||||
labelKey: 'status.running',
|
||||
},
|
||||
scheduled: {
|
||||
color: cssVar.colorTextDescription,
|
||||
icon: Clock,
|
||||
label: 'Scheduled',
|
||||
labelKey: 'status.scheduled',
|
||||
},
|
||||
};
|
||||
|
||||
export const USER_SELECTABLE_STATUSES: TaskStatus[] = [
|
||||
|
||||
@@ -61,14 +61,22 @@ export const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
|
||||
min-width: 0;
|
||||
min-height: 36px;
|
||||
|
||||
color: ${cssVar.colorTextSecondary};
|
||||
}
|
||||
|
||||
.ant-tree-title {
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ant-tree-switcher {
|
||||
margin-inline-end: 0;
|
||||
color: ${cssVar.colorTextDescription};
|
||||
@@ -136,16 +144,22 @@ export const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
commentInputCard: css`
|
||||
padding-block: 4px;
|
||||
padding-inline: 8px;
|
||||
border: 1px solid ${cssVar.colorBorderSecondary};
|
||||
border: 1px solid transparent;
|
||||
border-radius: ${cssVar.borderRadiusLG};
|
||||
|
||||
background: ${cssVar.colorBgElevated};
|
||||
background: ${cssVar.colorFillTertiary};
|
||||
|
||||
transition: border-color 0.15s ease;
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: ${cssVar.colorFillSecondary};
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus-within {
|
||||
border-color: ${cssVar.colorBorder};
|
||||
background: ${cssVar.colorFillTertiary};
|
||||
}
|
||||
`,
|
||||
}));
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user