mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-17 13:06:21 +00:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e613eb46c | |||
| 798644414a | |||
| 54bb83f229 | |||
| 65da232c64 | |||
| dacc7798ab | |||
| 9508807da7 | |||
| 6a7eb17cd2 | |||
| c5da34b680 | |||
| 2a37b77482 | |||
| b814cf2611 | |||
| c37817e2d8 | |||
| bbf239705c | |||
| 8a9f42596d | |||
| 29235dc1ed | |||
| e326400dbe | |||
| deeb97ab5b | |||
| d73858ef42 | |||
| 6b9584714d | |||
| b9a4a9093c | |||
| ef5be7e17c | |||
| a4235d3f68 | |||
| fa508f4259 | |||
| 94767fddcb | |||
| 685b17e59e | |||
| 376976849b |
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
.\" Code generated by `npm run man:generate`; DO NOT EDIT.
|
||||
.\" Manual command details come from the Commander command tree.
|
||||
.TH LH 1 "" "@lobehub/cli 0.0.8" "User Commands"
|
||||
.TH LH 1 "" "@lobehub/cli 0.0.9" "User Commands"
|
||||
.SH NAME
|
||||
lh \- LobeHub CLI \- manage and connect to LobeHub services
|
||||
.SH SYNOPSIS
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@lobehub/cli",
|
||||
"version": "0.0.8",
|
||||
"version": "0.0.9",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"lh": "./dist/index.js",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"https://file.rene.wang/540830955-0fe626a3-0ddc-4f67-b595-3c5b3f1701e0.png": "/blog/assetsa8e504275f2cd891fabecca985998de0.webp",
|
||||
"https://file.rene.wang/changlog-04-14.png": "/blog/assets300abe7e259d293da6c5ed4f642a1be6.webp",
|
||||
"https://file.rene.wang/changlog-04-14.png": "/blog/assets300abe7e259d293da6c5ed4f642a1be6.webp",
|
||||
"https://file.rene.wang/clipboard-1768907980491-9cc0669fc3a38.png": "/blog/assets8be3a46c8f9c5d3b61bc541f44b7f245.webp",
|
||||
"https://file.rene.wang/clipboard-1768908081787-ed9eb1cb78bdb.png": "/blog/assetsab009b79dd794f02aec24b7607f342e8.webp",
|
||||
"https://file.rene.wang/clipboard-1768908121691-b3517bf882633.png": "/blog/assetsd3cae44cba0d3f57df6440b46246e5e7.webp",
|
||||
@@ -53,7 +52,6 @@
|
||||
"https://file.rene.wang/clipboard-1770266335710-1fec523143aab.png": "/blog/assets636c78daf95c590cd7d80284c68eb6d9.webp",
|
||||
"https://file.rene.wang/clipboard-1774923001079-89ce6aa271a62.png": "/blog/assets53e6ec9cf72554dbc1f8224fc0550a03.webp",
|
||||
"https://file.rene.wang/clipboard-1775701725582-123f8f8cf73f8.png": "/blog/assets7ea204859aeb5aa9be5810a20ba1669a.webp",
|
||||
"https://file.rene.wang/clipboard-1775701725582-123f8f8cf73f8.png": "/blog/assets7ea204859aeb5aa9be5810a20ba1669a.webp",
|
||||
"https://file.rene.wang/clipboard-1776909505252-94b051f3ea0a7.png": "/blog/assetsdfda32866c4bc59af0526e52f31d1da2.webp",
|
||||
"https://file.rene.wang/lobehub/467951f5-ad65-498d-aea9-fca8f35a4314.png": "/blog/assets907ea775d228958baca38e2dbb65939a.webp",
|
||||
"https://file.rene.wang/lobehub/58d91528-373a-4a42-b520-cf6cb1f8ce1e.png": "/blog/assets7dccdd4df55aede71001da649639437f.webp",
|
||||
@@ -470,5 +468,6 @@
|
||||
"https://github.com/user-attachments/assets/fa8fab19-ace2-4f85-8428-a3a0e28845bb": "/blog/assets/2d678631c55369ba7d753c3ffcb73782.webp",
|
||||
"https://github.com/user-attachments/assets/facdc83c-e789-4649-8060-7f7a10a1b1dd": "/blog/assets05b20e40c03ced0ec8707fed2e8e0f25.webp",
|
||||
"https://github.com/user-attachments/assets/fcdfb9c5-819a-488f-b28d-0857fe861219": "/blog/assets8477415ecec1f37e38ab38ff1217d0a7.webp",
|
||||
"https://github.com/user-attachments/assets/fd60ab55-ead2-4930-ad00-fdf77662f5a0": "/blog/assets276a4e8748e9bd300b30dcd9d0e24980.webp"
|
||||
}
|
||||
"https://github.com/user-attachments/assets/fd60ab55-ead2-4930-ad00-fdf77662f5a0": "/blog/assets276a4e8748e9bd300b30dcd9d0e24980.webp",
|
||||
"https://file.rene.wang/clipboard-1777343750668-9b3dcb0dfff86.png": "/blog/assetsfa267a02f20bc5ba6f1273bcf27b7c9f.webp"
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
---
|
||||
title: 'Plugin System: Extend Your Agents with Community Skills'
|
||||
description: LobeHub now supports a plugin ecosystem that lets Agents access real-time information, interact with external services, and handle specialized tasks without leaving the conversation.
|
||||
description: >-
|
||||
LobeHub now supports a plugin ecosystem that lets Agents access real-time
|
||||
information, interact with external services, and handle specialized tasks
|
||||
without leaving the conversation.
|
||||
tags:
|
||||
- LobeHub
|
||||
- Plugins
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: '插件系统:用社区技能扩展你的助理'
|
||||
title: 插件系统:用社区技能扩展你的助理
|
||||
description: LobeHub 现已支持插件生态,让助理能够获取实时信息、与外部服务交互,并在对话中处理各种专业任务。
|
||||
tags:
|
||||
- LobeHub
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
---
|
||||
title: 'Visual Recognition: Chat With Images, Not Just Text'
|
||||
description: LobeHub now supports multimodal models including GPT-4 Vision, Google Gemini Pro Vision, and GLM-4 Vision. Upload or drag images into conversations and your Agent will understand and respond to visual content.
|
||||
description: >-
|
||||
LobeHub now supports multimodal models including GPT-4 Vision, Google Gemini
|
||||
Pro Vision, and GLM-4 Vision. Upload or drag images into conversations and
|
||||
your Agent will understand and respond to visual content.
|
||||
tags:
|
||||
- Visual Recognition
|
||||
- LobeHub
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
title: '视觉识别:与图片对话,不只是文字'
|
||||
description: LobeHub 现已支持多模态模型,包括 GPT-4 Vision、Google Gemini Pro Vision 和 GLM-4 Vision。上传或拖拽图片到对话中,助理将理解视觉内容并作出回应。
|
||||
title: 视觉识别:与图片对话,不只是文字
|
||||
description: >-
|
||||
LobeHub 现已支持多模态模型,包括 GPT-4 Vision、Google Gemini Pro Vision 和 GLM-4
|
||||
Vision。上传或拖拽图片到对话中,助理将理解视觉内容并作出回应。
|
||||
tags:
|
||||
- 视觉识别
|
||||
- 多模态交互
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
---
|
||||
title: 'Voice Conversations: Talk Naturally With Your Agents'
|
||||
description: LobeHub now supports Text-to-Speech (TTS) and Speech-to-Text (STT), enabling natural voice interactions. Speak with your Agents and hear responses in clear, personalized voices.
|
||||
description: >-
|
||||
LobeHub now supports Text-to-Speech (TTS) and Speech-to-Text (STT), enabling
|
||||
natural voice interactions. Speak with your Agents and hear responses in
|
||||
clear, personalized voices.
|
||||
tags:
|
||||
- TTS
|
||||
- STT
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: '语音会话:与你的助理自然对话'
|
||||
title: 语音会话:与你的助理自然对话
|
||||
description: LobeHub 现已支持文字转语音(TTS)和语音转文字(STT),实现自然的语音交互。与助理对话并听到清晰、个性化的语音回复。
|
||||
tags:
|
||||
- TTS
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
---
|
||||
title: 'Text-to-Image: Create Visuals Directly in Chat'
|
||||
description: LobeHub now supports text-to-image generation. Invoke DALL-E 3, MidJourney, or Pollinations directly during conversations to turn your ideas into images without leaving the chat.
|
||||
description: >-
|
||||
LobeHub now supports text-to-image generation. Invoke DALL-E 3, MidJourney, or
|
||||
Pollinations directly during conversations to turn your ideas into images
|
||||
without leaving the chat.
|
||||
tags:
|
||||
- Text-to-Image
|
||||
- LobeHub
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
title: '文生图:在对话中直接创作视觉内容'
|
||||
description: LobeHub 现已支持文本到图片生成。在对话中直接调用 DALL-E 3、MidJourney 或 Pollinations,无需离开聊天界面即可将想法转化为图像。
|
||||
title: 文生图:在对话中直接创作视觉内容
|
||||
description: >-
|
||||
LobeHub 现已支持文本到图片生成。在对话中直接调用 DALL-E 3、MidJourney 或
|
||||
Pollinations,无需离开聊天界面即可将想法转化为图像。
|
||||
tags:
|
||||
- Text to Image
|
||||
- 文生图
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
title: 灵活适配的认证体系:Clerk 与 Next-Auth 双方案支持
|
||||
description: >-
|
||||
LobeHub 现已支持 Clerk 和 Next-Auth 两种认证方案,让团队可以根据部署模式和安全需求选择最适合的身份验证方式。
|
||||
description: LobeHub 现已支持 Clerk 和 Next-Auth 两种认证方案,让团队可以根据部署模式和安全需求选择最适合的身份验证方式。
|
||||
tags:
|
||||
- 用户管理
|
||||
- 身份验证
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
title: 本地模型与云端 AI 并行使用
|
||||
description: >-
|
||||
LobeHub v0.127.0 新增 Ollama 支持,让你可以用与云端模型相同的界面运行本地大语言模型。
|
||||
description: LobeHub v0.127.0 新增 Ollama 支持,让你可以用与云端模型相同的界面运行本地大语言模型。
|
||||
tags:
|
||||
- Ollama AI
|
||||
- LobeHub
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
---
|
||||
title: LobeHub 1.0:为持久化、多用户协作而生的新架构
|
||||
description: >-
|
||||
LobeHub 1.0 引入服务端数据库支持和完善的用户管理体系,实现知识库、跨设备同步和团队协作能力。
|
||||
LobeHub Cloud 同步开启 Beta 测试,内置全部新特性。
|
||||
LobeHub 1.0 引入服务端数据库支持和完善的用户管理体系,实现知识库、跨设备同步和团队协作能力。 LobeHub Cloud 同步开启 Beta
|
||||
测试,内置全部新特性。
|
||||
tags:
|
||||
- LobeHub
|
||||
- 服务端数据库
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
---
|
||||
title: LobeHub v1.6:GPT-4o mini 成为默认模型选项
|
||||
description: >-
|
||||
LobeHub v1.6 新增 GPT-4o mini 支持,同时 LobeHub Cloud 将默认模型升级为
|
||||
GPT-4o mini,让开箱即用的对话体验更进一步。
|
||||
LobeHub v1.6 新增 GPT-4o mini 支持,同时 LobeHub Cloud 将默认模型升级为 GPT-4o
|
||||
mini,让开箱即用的对话体验更进一步。
|
||||
tags:
|
||||
- LobeHub
|
||||
- GPT-4o mini
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: 'LobeHub Enters the Era of Artifacts'
|
||||
title: LobeHub Enters the Era of Artifacts
|
||||
description: >-
|
||||
LobeHub v1.19 brings significant updates, including full feature support for
|
||||
Claude Artifacts, a brand new discovery page design, and support for GitHub
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: '重磅更新:LobeHub 迎来 Artifacts 时代'
|
||||
title: 重磅更新:LobeHub 迎来 Artifacts 时代
|
||||
description: >-
|
||||
LobeHub v1.19 带来了重大更新,包括 Claude Artifacts 完整特性支持、全新的发现页面设计,以及 GitHub Models
|
||||
服务商支持,让 AI 助手的能力得到显著提升。
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
---
|
||||
title: Export Conversations as Markdown or OpenAI JSON
|
||||
description: >-
|
||||
LobeHub v1.28.0 adds Markdown and OpenAI-format JSON exports, making it
|
||||
easier to turn conversations into documentation, debugging payloads, or
|
||||
training datasets.
|
||||
LobeHub v1.28.0 adds Markdown and OpenAI-format JSON exports, making it easier
|
||||
to turn conversations into documentation, debugging payloads, or training
|
||||
datasets.
|
||||
tags:
|
||||
- Text Format Export
|
||||
- Markdown Export
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
---
|
||||
title: 支持导出对话为 Markdown 或 OpenAI JSON 格式
|
||||
description: >-
|
||||
LobeHub v1.28.0 新增 Markdown 与 OpenAI 格式 JSON 导出,方便将对话转为文档、
|
||||
调试数据或训练语料。
|
||||
description: LobeHub v1.28.0 新增 Markdown 与 OpenAI 格式 JSON 导出,方便将对话转为文档、 调试数据或训练语料。
|
||||
tags:
|
||||
- 文本格式导出
|
||||
- Markdown 导出
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
---
|
||||
title: 11 月更新 - 新增 4 家模型服务商
|
||||
description: >-
|
||||
LobeHub 新增支持 Gitee AI、InternLM、xAI 和 Cloudflare Workers AI,
|
||||
为团队提供更多模型接入选择。
|
||||
description: LobeHub 新增支持 Gitee AI、InternLM、xAI 和 Cloudflare Workers AI, 为团队提供更多模型接入选择。
|
||||
tags:
|
||||
- LobeHub
|
||||
- AI 模型服务
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
title: DeepSeek R1 Integration with Chain-of-Thought Transparency
|
||||
description: LobeHub now supports DeepSeek R1 with real-time reasoning display, making complex problem-solving more transparent and easier to follow.
|
||||
description: >-
|
||||
LobeHub now supports DeepSeek R1 with real-time reasoning display, making
|
||||
complex problem-solving more transparent and easier to follow.
|
||||
tags:
|
||||
- LobeHub
|
||||
- DeepSeek
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
title: "50+ New Models and 10+ Providers Added to the Ecosystem"
|
||||
description: LobeHub expands its AI ecosystem with 50+ new models and 10+ providers, making it easier to access diverse AI capabilities without changing your workflow.
|
||||
title: 50+ New Models and 10+ Providers Added to the Ecosystem
|
||||
description: >-
|
||||
LobeHub expands its AI ecosystem with 50+ new models and 10+ providers, making
|
||||
it easier to access diverse AI capabilities without changing your workflow.
|
||||
tags:
|
||||
- LobeHub
|
||||
- Model Providers
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: "AI 生态扩展:新增 50+ 模型与 10+ 服务商"
|
||||
title: AI 生态扩展:新增 50+ 模型与 10+ 服务商
|
||||
description: LobeHub 完成史上最大规模 AI 生态扩展,新增 50+ 模型和 10+ 服务商,让你无需改变工作流程即可接入更多 AI 能力。
|
||||
tags:
|
||||
- LobeHub
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
title: "Customizable Hotkeys, Data Export, and Provider Expansion"
|
||||
description: LobeHub adds customizable hotkeys, data export functionality, and expands provider support to make daily workflows smoother and more portable.
|
||||
title: 'Customizable Hotkeys, Data Export, and Provider Expansion'
|
||||
description: >-
|
||||
LobeHub adds customizable hotkeys, data export functionality, and expands
|
||||
provider support to make daily workflows smoother and more portable.
|
||||
tags:
|
||||
- LobeHub
|
||||
- Hotkeys
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: "快捷键自定义、数据导出与服务商扩展"
|
||||
title: 快捷键自定义、数据导出与服务商扩展
|
||||
description: LobeHub 新增快捷键自定义、数据导出功能,并扩展服务商支持,让日常使用更顺手、数据更可迁移。
|
||||
tags:
|
||||
- LobeHub
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
title: "Lobe UI v2 Design System and Desktop App Launch"
|
||||
description: LobeHub launches a refreshed visual design with Lobe UI v2 and officially releases the desktop app for Windows and macOS.
|
||||
title: Lobe UI v2 Design System and Desktop App Launch
|
||||
description: >-
|
||||
LobeHub launches a refreshed visual design with Lobe UI v2 and officially
|
||||
releases the desktop app for Windows and macOS.
|
||||
tags:
|
||||
- Desktop App
|
||||
- LobeHub
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: "Lobe UI v2 设计系统与桌面端正式发布"
|
||||
title: Lobe UI v2 设计系统与桌面端正式发布
|
||||
description: LobeHub 推出基于 Lobe UI v2 的全新视觉设计,并正式发布 Windows 与 macOS 桌面端应用。
|
||||
tags:
|
||||
- 桌面端
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
title: "Prompt Variables and Claude 4 Reasoning Model Support"
|
||||
description: LobeHub introduces prompt variables for reusable templates and adds full support for Claude 4 reasoning models with web search integration.
|
||||
title: Prompt Variables and Claude 4 Reasoning Model Support
|
||||
description: >-
|
||||
LobeHub introduces prompt variables for reusable templates and adds full
|
||||
support for Claude 4 reasoning models with web search integration.
|
||||
tags:
|
||||
- Prompt Variables
|
||||
- Claude 4
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: "提示词变量与 Claude 4 推理模型支持"
|
||||
title: 提示词变量与 Claude 4 推理模型支持
|
||||
description: LobeHub 引入提示词变量实现模板复用,并完整支持 Claude 4 推理模型及网页搜索集成。
|
||||
tags:
|
||||
- 提示词变量
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
---
|
||||
title: "MCP Marketplace and Search Provider Expansion \U0001F50D"
|
||||
description: >-
|
||||
MCP Marketplace is now live with one-click plugin installation, alongside expanded search providers and new SSO options for easier team access.
|
||||
MCP Marketplace is now live with one-click plugin installation, alongside
|
||||
expanded search providers and new SSO options for easier team access.
|
||||
tags:
|
||||
- MCP Marketplace
|
||||
- Best MCP
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
---
|
||||
title: "Image Generation, Desktop, and Auth Updates \U0001F3A8"
|
||||
description: >-
|
||||
Generate AI images across multiple providers, connect with expanded identity options, and run desktop workflows with fewer interruptions.
|
||||
Generate AI images across multiple providers, connect with expanded identity
|
||||
options, and run desktop workflows with fewer interruptions.
|
||||
tags:
|
||||
- Image Generation
|
||||
- Desktop App
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: 图像生成、桌面端与认证更新 🎨
|
||||
title: "图像生成、桌面端与认证更新 \U0001F3A8"
|
||||
description: 通过多个服务商生成 AI 图像,用更多身份系统完成接入,并在桌面端享受更顺畅的工作流。
|
||||
tags:
|
||||
- 图像生成
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
---
|
||||
title: "Gemini Image Generation and Non-Streaming Mode Support \U0001F3A8"
|
||||
description: >-
|
||||
Gemini 2.5 Flash Image generation, non-streaming response mode, and expanded model coverage give you more flexibility in how you generate and receive content.
|
||||
Gemini 2.5 Flash Image generation, non-streaming response mode, and expanded
|
||||
model coverage give you more flexibility in how you generate and receive
|
||||
content.
|
||||
tags:
|
||||
- Gemini
|
||||
- Nano Banana
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
---
|
||||
title: "Claude Sonnet 4.5 and Built-in Python Plugin \U0001F40D"
|
||||
description: >-
|
||||
Run Python directly in chat with the new built-in plugin, navigate long conversations faster, and work with Claude Sonnet 4.5 and other new models.
|
||||
Run Python directly in chat with the new built-in plugin, navigate long
|
||||
conversations faster, and work with Claude Sonnet 4.5 and other new models.
|
||||
tags:
|
||||
- Claude Sonnet 4.5
|
||||
- Chain of Thought
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
---
|
||||
title: ComfyUI Integration and Knowledge Base Improvements ⭐
|
||||
description: >-
|
||||
Run ComfyUI visual workflows directly in LobeHub, organize knowledge with waterfall layouts and auto-extraction, and share outputs as PDF.
|
||||
Run ComfyUI visual workflows directly in LobeHub, organize knowledge with
|
||||
waterfall layouts and auto-extraction, and share outputs as PDF.
|
||||
tags:
|
||||
- AI Knowledge Base
|
||||
- Workflow
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
---
|
||||
title: "MCP Cloud Endpoints and Model Library Expansion \U0001F50C"
|
||||
description: >-
|
||||
Connect to managed MCP tools from the marketplace without self-hosting, while new providers and knowledge base pages improve daily workflows.
|
||||
Connect to managed MCP tools from the marketplace without self-hosting, while
|
||||
new providers and knowledge base pages improve daily workflows.
|
||||
tags:
|
||||
- MCP
|
||||
- LobeHub
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
title: Coding Agent — Claude Code & Codex on Desktop
|
||||
description: >-
|
||||
Claude Code and Codex graduate to first-class desktop runtimes, alongside a
|
||||
new Agent Signal runtime and a wave of flagship models.
|
||||
tags:
|
||||
- Heterogeneous Agent
|
||||
- Desktop
|
||||
- Models
|
||||
---
|
||||
|
||||
# Claude Code & Codex on Desktop
|
||||
|
||||
## Features
|
||||
|
||||
- Topic remembers its own scroll position
|
||||
- User message stays pinned to the viewport top with long messages folded, the last user message can be edited and resent inline, and follow-up sends queue cleanly during a concurrent turn.
|
||||
- Delegating 3rd party coding agents such as Claude Code and Codex
|
||||
- Quick chat and capture your screen and ask LobeHun with desktop app
|
||||
- New models: GPT-5.5, DeepSeek V4 Flash and Pro with a reasoning-effort slider, LobeHub-hosted gpt-image-2, Kimi K2.6, MiMo-V2.5 and Pro
|
||||
- New providers: OpenCode Zen and OpenCode Go.
|
||||
|
||||
## Improvements and fixes
|
||||
|
||||
- Disabled markdown streaming on the first assistant block to avoid mid-stream layout shifts.
|
||||
- Conversation no longer repins to the bottom after a manual scroll.
|
||||
- Tool inspectors render correctly for Codex and heterogeneous-agent follow-ups.
|
||||
- FileEditor migrated from antd Modal to base-ui Modal for consistent focus and keyboard behavior.
|
||||
- QStash heartbeat self-reschedules to keep long-running tasks alive.
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
title: 编程 Agent —— Claude Code 与 Codex 进入桌面端
|
||||
description: Claude Code 与 Codex 成为桌面端的一等运行时,全新 Agent Signal 运行时上线,并迎来一批旗舰模型。
|
||||
tags:
|
||||
- 异构 Agent
|
||||
- 桌面端
|
||||
- 模型
|
||||
---
|
||||
|
||||
# Claude Code 与 Codex 进入桌面端
|
||||
|
||||
## 新功能
|
||||
|
||||
- 话题级别记忆滚动位置
|
||||
- 用户消息固定在视口顶部,过长内容自动折叠;最后一条用户消息可直接编辑并重发;并发对话期间的后续发送会顺序排队
|
||||
- 接入 Claude Code、Codex 等第三方编程 Agent
|
||||
- 在桌面端通过 Quick Chat 与屏幕截图直接向 LobeHub 提问
|
||||
- 新模型:GPT-5.5、DeepSeek V4 Flash / Pro(带思考强度滑块)、LobeHub 托管的 gpt-image-2、Kimi K2.6、MiMo-V2.5 与 Pro
|
||||
- 新提供商:OpenCode Zen 与 OpenCode Go
|
||||
|
||||
## 体验优化与修复
|
||||
|
||||
- 第一条助手消息不再启用 Markdown 流式渲染,避免渲染过程中的布局抖动。
|
||||
- 手动滚动后不再重新自动钉住对话底部。
|
||||
- 修复了 Codex 与异构 Agent 后续轮次中工具检查器渲染异常的问题。
|
||||
- FileEditor 从 antd Modal 迁移到 base-ui Modal,焦点与键盘行为更一致。
|
||||
- QStash 心跳支持自我重调度,长任务运行更稳定。
|
||||
+154
-37
@@ -2,225 +2,342 @@
|
||||
"$schema": "https://github.com/lobehub/lobe-chat/blob/main/docs/changelog/schema.json",
|
||||
"cloud": [],
|
||||
"community": [
|
||||
{
|
||||
"image": "/blog/assetsfa267a02f20bc5ba6f1273bcf27b7c9f.webp",
|
||||
"id": "2026-04-27-heterogeneous-agent",
|
||||
"date": "2026-04-27",
|
||||
"versionRange": [
|
||||
"2.1.53"
|
||||
]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assetsdfda32866c4bc59af0526e52f31d1da2.webp",
|
||||
"id": "2026-04-20-daily-brief",
|
||||
"date": "2026-04-20",
|
||||
"versionRange": ["2.1.50", "2.1.52"]
|
||||
"versionRange": [
|
||||
"2.1.50",
|
||||
"2.1.52"
|
||||
]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets300abe7e259d293da6c5ed4f642a1be6.webp",
|
||||
"id": "2026-04-13-gateway-sidebar",
|
||||
"date": "2026-04-13",
|
||||
"versionRange": ["2.1.48", "2.1.49"]
|
||||
"versionRange": [
|
||||
"2.1.48",
|
||||
"2.1.49"
|
||||
]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets7ea204859aeb5aa9be5810a20ba1669a.webp",
|
||||
"id": "2026-04-06-auto-completion",
|
||||
"date": "2026-04-06",
|
||||
"versionRange": ["2.1.47"]
|
||||
"versionRange": [
|
||||
"2.1.47"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "2026-03-30-agent-tasks",
|
||||
"date": "2026-03-30",
|
||||
"versionRange": ["2.1.45", "2.1.46"]
|
||||
"versionRange": [
|
||||
"2.1.45",
|
||||
"2.1.46"
|
||||
]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets53e6ec9cf72554dbc1f8224fc0550a03.webp",
|
||||
"id": "2026-03-23-media-memory",
|
||||
"date": "2026-03-23",
|
||||
"versionRange": ["2.1.44"]
|
||||
"versionRange": [
|
||||
"2.1.44"
|
||||
]
|
||||
},
|
||||
{
|
||||
"image": "https://hub-apac-1.lobeobjects.space/blog/assets/4a68a7644501cb513d08670b102a446e.webp",
|
||||
"id": "2026-03-16-search",
|
||||
"date": "2026-03-16",
|
||||
"versionRange": ["2.1.38", "2.1.43"]
|
||||
"versionRange": [
|
||||
"2.1.38",
|
||||
"2.1.43"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "2026-02-08-runtime-auth",
|
||||
"date": "2026-02-08",
|
||||
"versionRange": ["2.1.6", "2.1.26"]
|
||||
"versionRange": [
|
||||
"2.1.6",
|
||||
"2.1.26"
|
||||
]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assetsa8e504275f2cd891fabecca985998de0.webp",
|
||||
"id": "2026-01-27-v2",
|
||||
"date": "2026-01-27",
|
||||
"versionRange": ["2.0.1", "2.1.5"]
|
||||
"versionRange": [
|
||||
"2.0.1",
|
||||
"2.1.5"
|
||||
]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets7f3b38c1d76cceb91edb29d6b1eb60db.webp",
|
||||
"id": "2025-12-20-mcp",
|
||||
"date": "2025-12-20",
|
||||
"versionRange": ["1.142.8", "1.143.0"]
|
||||
"versionRange": [
|
||||
"1.142.8",
|
||||
"1.143.0"
|
||||
]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets3a7f0b29839603336e39e923b423409b.webp",
|
||||
"id": "2025-11-08-comfy-ui",
|
||||
"date": "2025-11-08",
|
||||
"versionRange": ["1.133.5", "1.142.8"]
|
||||
"versionRange": [
|
||||
"1.133.5",
|
||||
"1.142.8"
|
||||
]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets35e6aa692b0c16009c61964279514166.webp",
|
||||
"id": "2025-10-08-python",
|
||||
"date": "2025-10-08",
|
||||
"versionRange": ["1.120.7", "1.133.5"]
|
||||
"versionRange": [
|
||||
"1.120.7",
|
||||
"1.133.5"
|
||||
]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assetsce5d6dc93676f974be2e162e8ace03f0.webp",
|
||||
"id": "2025-09-08-gemini",
|
||||
"date": "2025-09-08",
|
||||
"versionRange": ["1.109.1", "1.120.7"]
|
||||
"versionRange": [
|
||||
"1.109.1",
|
||||
"1.120.7"
|
||||
]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assetsdf48eed9de76b7e37c269b294285f09d.webp",
|
||||
"id": "2025-08-08-image-generation",
|
||||
"date": "2025-08-08",
|
||||
"versionRange": ["1.97.10", "1.109.1"]
|
||||
"versionRange": [
|
||||
"1.97.10",
|
||||
"1.109.1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets902eb746fe2042fc2ea831c71002be72.webp",
|
||||
"id": "2025-07-08-mcp-market",
|
||||
"date": "2025-07-08",
|
||||
"versionRange": ["1.93.3", "1.97.10"]
|
||||
"versionRange": [
|
||||
"1.93.3",
|
||||
"1.97.10"
|
||||
]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets5cc27b8cae995074da20d4ffe06a1460.webp",
|
||||
"id": "2025-06-08-claude-4",
|
||||
"date": "2025-06-08",
|
||||
"versionRange": ["1.84.27", "1.93.3"]
|
||||
"versionRange": [
|
||||
"1.84.27",
|
||||
"1.93.3"
|
||||
]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets2a36d86a4eed6e7938dd6e9c684701ed.webp",
|
||||
"id": "2025-05-08-desktop-app",
|
||||
"date": "2025-05-08",
|
||||
"versionRange": ["1.77.17", "1.84.27"]
|
||||
"versionRange": [
|
||||
"1.77.17",
|
||||
"1.84.27"
|
||||
]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assetsc0efdb82443556ae3acefe00099b3f23.webp",
|
||||
"id": "2025-04-06-exports",
|
||||
"date": "2025-04-06",
|
||||
"versionRange": ["1.67.2", "1.77.17"]
|
||||
"versionRange": [
|
||||
"1.67.2",
|
||||
"1.77.17"
|
||||
]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assetse743f0a47127390dde766a0a790476db.webp",
|
||||
"id": "2025-03-02-new-models",
|
||||
"date": "2025-03-02",
|
||||
"versionRange": ["1.49.13", "1.67.2"]
|
||||
"versionRange": [
|
||||
"1.49.13",
|
||||
"1.67.2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets18168d5fe64ea34905a7e52fd82d0e9d.webp",
|
||||
"id": "2025-02-02-deepseek-r1",
|
||||
"date": "2025-02-02",
|
||||
"versionRange": ["1.47.8", "1.49.12"]
|
||||
"versionRange": [
|
||||
"1.47.8",
|
||||
"1.49.12"
|
||||
]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assetsf9ed064fe764cbeff2f46910e7099a91.webp",
|
||||
"id": "2025-01-22-new-ai-provider",
|
||||
"date": "2025-01-22",
|
||||
"versionRange": ["1.43.1", "1.47.7"]
|
||||
"versionRange": [
|
||||
"1.43.1",
|
||||
"1.47.7"
|
||||
]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets2d409f43b58953ad5396c6beab8a0719.webp",
|
||||
"id": "2025-01-03-user-profile",
|
||||
"date": "2025-01-03",
|
||||
"versionRange": ["1.34.1", "1.43.0"]
|
||||
"versionRange": [
|
||||
"1.34.1",
|
||||
"1.43.0"
|
||||
]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets/d9cbfcbef130183bc490d515d8a38aa4.webp",
|
||||
"id": "2024-11-27-forkable-chat",
|
||||
"date": "2024-11-27",
|
||||
"versionRange": ["1.33.1", "1.34.0"]
|
||||
"versionRange": [
|
||||
"1.33.1",
|
||||
"1.34.0"
|
||||
]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets/2d678631c55369ba7d753c3ffcb73782.webp",
|
||||
"id": "2024-11-25-november-providers",
|
||||
"date": "2024-11-25",
|
||||
"versionRange": ["1.30.1", "1.33.0"]
|
||||
"versionRange": [
|
||||
"1.30.1",
|
||||
"1.33.0"
|
||||
]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets/f10a4b98782e36797c38071eed785c6f.webp",
|
||||
"id": "2024-11-06-share-text-json",
|
||||
"date": "2024-11-06",
|
||||
"versionRange": ["1.26.1", "1.28.0"]
|
||||
"versionRange": [
|
||||
"1.26.1",
|
||||
"1.28.0"
|
||||
]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets/944c671604833cd2457445b211ebba33.webp",
|
||||
"id": "2024-10-27-pin-assistant",
|
||||
"date": "2024-10-27",
|
||||
"versionRange": ["1.19.1", "1.26.0"]
|
||||
"versionRange": [
|
||||
"1.19.1",
|
||||
"1.26.0"
|
||||
]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets/f6d047a345e47a52592cff916c9a64ce.webp",
|
||||
"id": "2024-09-20-artifacts",
|
||||
"date": "2024-09-20",
|
||||
"versionRange": ["1.17.1", "1.19.0"]
|
||||
"versionRange": [
|
||||
"1.17.1",
|
||||
"1.19.0"
|
||||
]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets/d7e57f8e69f97b76b3c2414f3441b6e4.webp",
|
||||
"id": "2024-09-13-openai-o1-models",
|
||||
"date": "2024-09-13",
|
||||
"versionRange": ["1.12.1", "1.17.0"]
|
||||
"versionRange": [
|
||||
"1.12.1",
|
||||
"1.17.0"
|
||||
]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets/d6129350de510a62fe87b2d2f0fb9477.webp",
|
||||
"id": "2024-08-21-file-upload-and-knowledge-base",
|
||||
"date": "2024-08-21",
|
||||
"versionRange": ["1.8.1", "1.12.0"]
|
||||
"versionRange": [
|
||||
"1.8.1",
|
||||
"1.12.0"
|
||||
]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets/37d85fdfccff9ed56e9c6827faee01c7.webp",
|
||||
"id": "2024-08-02-lobe-chat-database-docker",
|
||||
"date": "2024-08-02",
|
||||
"versionRange": ["1.6.1", "1.8.0"]
|
||||
"versionRange": [
|
||||
"1.6.1",
|
||||
"1.8.0"
|
||||
]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets/39d7890f8cbe21e77db8d3c94f7f22e4.webp",
|
||||
"id": "2024-07-19-gpt-4o-mini",
|
||||
"date": "2024-07-19",
|
||||
"versionRange": ["1.0.1", "1.6.0"]
|
||||
"versionRange": [
|
||||
"1.0.1",
|
||||
"1.6.0"
|
||||
]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets/eb477e62217f4d1b644eff975c7ac168.webp",
|
||||
"id": "2024-06-19-lobe-chat-v1",
|
||||
"date": "2024-06-19",
|
||||
"versionRange": ["0.147.0", "1.0.0"]
|
||||
"versionRange": [
|
||||
"0.147.0",
|
||||
"1.0.0"
|
||||
]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets/8a8d361b4c0cce6da350cc0de65c0ad6.webp",
|
||||
"id": "2024-02-14-ollama",
|
||||
"date": "2024-02-14",
|
||||
"versionRange": ["0.125.1", "0.127.0"]
|
||||
"versionRange": [
|
||||
"0.125.1",
|
||||
"0.127.0"
|
||||
]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets/9498087e85f27e692716a63cb3b58d79.webp",
|
||||
"id": "2024-02-08-sso-oauth",
|
||||
"date": "2024-02-08",
|
||||
"versionRange": ["0.118.1", "0.125.0"]
|
||||
"versionRange": [
|
||||
"0.118.1",
|
||||
"0.125.0"
|
||||
]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets/603fefbb944bc6761ebdab5956fc0084.webp",
|
||||
"id": "2023-12-22-dalle-3",
|
||||
"date": "2023-12-22",
|
||||
"versionRange": ["0.102.1", "0.118.0"]
|
||||
"versionRange": [
|
||||
"0.102.1",
|
||||
"0.118.0"
|
||||
]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets/8d4c2cc0ce8654fa8ac06cc036a7f941.webp",
|
||||
"id": "2023-11-19-tts-stt",
|
||||
"date": "2023-11-19",
|
||||
"versionRange": ["0.101.1", "0.102.0"]
|
||||
"versionRange": [
|
||||
"0.101.1",
|
||||
"0.102.0"
|
||||
]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets/d47654360d626f80144cdedb979a3526.webp",
|
||||
"id": "2023-11-14-gpt4-vision",
|
||||
"date": "2023-11-14",
|
||||
"versionRange": ["0.90.0", "0.101.0"]
|
||||
"versionRange": [
|
||||
"0.90.0",
|
||||
"0.101.0"
|
||||
]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets/50b38eac1769ae6f13aef72f3d725eec.webp",
|
||||
"id": "2023-09-09-plugin-system",
|
||||
"date": "2023-09-09",
|
||||
"versionRange": ["0.67.0", "0.72.0"]
|
||||
"versionRange": [
|
||||
"0.67.0",
|
||||
"0.72.0"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -570,12 +570,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 +590,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 +599,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",
|
||||
|
||||
@@ -570,12 +570,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 +590,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 +599,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": "查看完整子智能体对话",
|
||||
|
||||
@@ -624,6 +624,8 @@
|
||||
"user.logout": "退出登录",
|
||||
"user.myProfile": "我的主页",
|
||||
"user.noAgents": "该用户暂未发布助理",
|
||||
"user.noAgents.ownerDescription": "创建你的第一个助理,分享到社区。",
|
||||
"user.noAgents.title": "还没有助理",
|
||||
"user.noFavoriteAgents": "暂无收藏的助理",
|
||||
"user.noFavoritePlugins": "暂无收藏的插件",
|
||||
"user.noForkedAgentGroups": "尚无已派生的代理组",
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@lobehub/lobehub",
|
||||
"version": "2.1.52",
|
||||
"version": "2.1.53",
|
||||
"description": "LobeHub - an open-source,comprehensive AI Agent framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.",
|
||||
"keywords": [
|
||||
"framework",
|
||||
@@ -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;
|
||||
|
||||
@@ -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,76 +35,85 @@ export class RecentModel {
|
||||
}
|
||||
|
||||
queryRecent = async (limit: number = 10): Promise<RecentDbItem[]> => {
|
||||
// System-trigger topics live in their own surfaces (Task Manager, cron,
|
||||
// eval, task runs) and would clutter the main "Recent" sidebar. Mirrors
|
||||
// `MAIN_SIDEBAR_EXCLUDE_TRIGGERS` in `src/const/topic.ts`.
|
||||
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)
|
||||
)
|
||||
AND (
|
||||
${topics.trigger} IS NULL
|
||||
OR ${topics.trigger} NOT IN ('cron', 'eval', 'task_manager', 'task')
|
||||
)
|
||||
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,7 +175,7 @@ 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({
|
||||
|
||||
@@ -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,68 @@
|
||||
import { type AIChatModelCard } from '../types/aiModel';
|
||||
import { type AIChatModelCard, type AIImageModelCard } from '../types/aiModel';
|
||||
import { gptImage2Schema } from './lobehub';
|
||||
|
||||
const aihubmixModels: AIChatModelCard[] = [
|
||||
const aihubmixChatModels: 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 +75,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: {
|
||||
@@ -745,6 +806,87 @@ const aihubmixModels: AIChatModelCard[] = [
|
||||
},
|
||||
type: 'chat',
|
||||
},
|
||||
{
|
||||
abilities: {
|
||||
functionCall: true,
|
||||
search: true,
|
||||
structuredOutput: true,
|
||||
vision: true,
|
||||
},
|
||||
contextWindowTokens: 2_000_000,
|
||||
description: 'A non-reasoning variant for simple use cases',
|
||||
displayName: 'Grok 4.20 (Non-Reasoning)',
|
||||
enabled: true,
|
||||
id: 'grok-4-20-non-reasoning',
|
||||
maxOutput: 2_000_000,
|
||||
pricing: {
|
||||
units: [
|
||||
{ name: 'textInput_cacheRead', rate: 0.2, strategy: 'fixed', unit: 'millionTokens' },
|
||||
{ name: 'textInput', rate: 2, strategy: 'fixed', unit: 'millionTokens' },
|
||||
{ name: 'textOutput', rate: 6, strategy: 'fixed', unit: 'millionTokens' },
|
||||
],
|
||||
},
|
||||
releasedAt: '2026-03-09',
|
||||
settings: {
|
||||
searchImpl: 'params',
|
||||
},
|
||||
type: 'chat',
|
||||
},
|
||||
{
|
||||
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',
|
||||
enabled: true,
|
||||
id: 'grok-4-20-reasoning',
|
||||
maxOutput: 2_000_000,
|
||||
pricing: {
|
||||
units: [
|
||||
{ name: 'textInput_cacheRead', rate: 0.2, strategy: 'fixed', unit: 'millionTokens' },
|
||||
{ name: 'textInput', rate: 2, strategy: 'fixed', unit: 'millionTokens' },
|
||||
{ name: 'textOutput', rate: 6, strategy: 'fixed', unit: 'millionTokens' },
|
||||
],
|
||||
},
|
||||
releasedAt: '2026-03-09',
|
||||
settings: {
|
||||
searchImpl: 'params',
|
||||
},
|
||||
type: 'chat',
|
||||
},
|
||||
{
|
||||
abilities: {
|
||||
reasoning: true,
|
||||
search: true,
|
||||
structuredOutput: true,
|
||||
vision: true,
|
||||
},
|
||||
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',
|
||||
enabled: true,
|
||||
id: 'grok-4.20-multi-agent-0309',
|
||||
maxOutput: 2_000_000,
|
||||
pricing: {
|
||||
units: [
|
||||
{ name: 'textInput_cacheRead', rate: 0.2, strategy: 'fixed', unit: 'millionTokens' },
|
||||
{ name: 'textInput', rate: 2, strategy: 'fixed', unit: 'millionTokens' },
|
||||
{ name: 'textOutput', rate: 6, strategy: 'fixed', unit: 'millionTokens' },
|
||||
],
|
||||
},
|
||||
releasedAt: '2026-03-09',
|
||||
settings: {
|
||||
extendParams: ['grok4_20ReasoningEffort'],
|
||||
searchImpl: 'params',
|
||||
},
|
||||
type: 'chat',
|
||||
},
|
||||
{
|
||||
abilities: {
|
||||
functionCall: true,
|
||||
@@ -853,6 +995,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 +1037,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 +1366,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,
|
||||
@@ -1783,4 +1912,30 @@ const aihubmixModels: AIChatModelCard[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export default aihubmixModels;
|
||||
const aihubmixImageModels: 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://aihubmix.com/model/gpt-image-2
|
||||
approximatePricePerImage: 0.053,
|
||||
units: [
|
||||
{ name: 'textInput', rate: 5, strategy: 'fixed', unit: 'millionTokens' },
|
||||
{ name: 'textOutput', rate: 10, strategy: 'fixed', unit: 'millionTokens' },
|
||||
{ name: 'imageInput', rate: 8, strategy: 'fixed', unit: 'millionTokens' },
|
||||
{ name: 'imageOutput', rate: 30, strategy: 'fixed', unit: 'millionTokens' },
|
||||
],
|
||||
},
|
||||
releasedAt: '2026-04-21',
|
||||
type: 'image',
|
||||
},
|
||||
];
|
||||
|
||||
export const allModels = [...aihubmixChatModels, ...aihubmixImageModels];
|
||||
|
||||
export default allModels;
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -258,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}`;
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user