feat(image): improve image generation with new models and bug fixes (#11311)

This commit is contained in:
YuTengjing
2026-01-07 19:16:24 +08:00
committed by GitHub
parent 1cbc5919f1
commit 4fc03bbf66
29 changed files with 333 additions and 177 deletions

View File

@@ -98,7 +98,7 @@ jobs:
echo "- \`pnpm-lock.yaml\` - pnpm lockfile (for reproducible builds)" >> bundle-report/README.md
- name: Upload bundle analyzer reports
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: bundle-report-${{ github.run_id }}
path: bundle-report/

View File

@@ -1,14 +1,14 @@
name: E2E CI
on: [push, pull_request]
permissions:
actions: write
contents: read
on:
pull_request:
push:
concurrency:
group: e2e-${{ github.ref }}
cancel-in-progress: true
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres
@@ -24,59 +24,74 @@ env:
S3_ENDPOINT: https://e2e-mock-s3.localhost
jobs:
e2e:
name: Test Web App
runs-on: ubuntu-latest
services:
postgres:
image: paradedb/paradedb:latest
env:
POSTGRES_PASSWORD: postgres
options: >-
--health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
# Check for duplicate runs
check-duplicate-run:
name: Check Duplicate Run
runs-on: ubuntu-latest
outputs:
should_skip: ${{ steps.skip_check.outputs.should_skip }}
steps:
- id: skip_check
uses: fkirc/skip-duplicate-actions@v5
with:
concurrent_skipping: 'same_content_newer'
skip_after_successful_duplicate: 'true'
do_not_skip: '["workflow_dispatch", "schedule"]'
ports:
- 5432:5432
e2e:
needs: check-duplicate-run
if: needs.check-duplicate-run.outputs.should_skip != 'true'
name: Test Web App
runs-on: ubuntu-latest
services:
postgres:
image: paradedb/paradedb:latest
env:
POSTGRES_PASSWORD: postgres
options: >-
--health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
ports:
- 5432:5432
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v6
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies (bun)
run: bun install
- name: Install dependencies (bun)
run: bun install
- name: Install Playwright browsers (with system deps)
run: bunx playwright install --with-deps chromium
- name: Install Playwright browsers (with system deps)
run: bunx playwright install --with-deps chromium
- name: Run database migrations
run: bun run db:migrate
- name: Run database migrations
run: bun run db:migrate
- name: Build application
run: bun run build
env:
SKIP_LINT: '1'
- name: Build application
run: bun run build
env:
SKIP_LINT: '1'
- name: Run E2E tests
run: bun run e2e
- name: Run E2E tests
run: bun run e2e
- name: Upload Cucumber HTML report (on failure)
if: failure()
uses: actions/upload-artifact@v5
with:
name: cucumber-report
path: e2e/reports
if-no-files-found: ignore
- name: Upload Cucumber HTML report (on failure)
if: failure()
uses: actions/upload-artifact@v6
with:
name: cucumber-report
path: e2e/reports
if-no-files-found: ignore
- name: Upload screenshots (on failure)
if: failure()
uses: actions/upload-artifact@v5
with:
name: test-screenshots
path: e2e/screenshots
if-no-files-found: ignore
- name: Upload screenshots (on failure)
if: failure()
uses: actions/upload-artifact@v6
with:
name: test-screenshots
path: e2e/screenshots
if-no-files-found: ignore

View File

@@ -171,7 +171,7 @@ jobs:
fi
- name: Upload artifact
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v6
with:
name: release-${{ matrix.os }}
path: |
@@ -224,7 +224,7 @@ jobs:
TMP: C:\temp
- name: Upload artifact
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v6
with:
name: release-windows-2025
path: |
@@ -275,7 +275,7 @@ jobs:
NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ inputs.channel == 'beta' && secrets.UMAMI_BETA_DESKTOP_BASE_URL || secrets.UMAMI_NIGHTLY_DESKTOP_BASE_URL }}
- name: Upload artifact
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v6
with:
name: release-ubuntu-latest
path: |
@@ -309,7 +309,7 @@ jobs:
package-manager-cache: 'false'
- name: Download artifacts
uses: actions/download-artifact@v6
uses: actions/download-artifact@v7
with:
path: release
pattern: release-*
@@ -330,7 +330,7 @@ jobs:
run: bun run scripts/electronWorkflow/mergeMacReleaseFiles.js
- name: Upload artifacts with merged macOS files
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v6
with:
name: merged-release-manual
path: release/

View File

@@ -194,7 +194,7 @@ jobs:
# 上传构建产物
- name: Upload artifact
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v6
with:
name: release-${{ matrix.os }}
path: |
@@ -229,7 +229,7 @@ jobs:
# 下载所有平台的构建产物
- name: Download artifacts
uses: actions/download-artifact@v6
uses: actions/download-artifact@v7
with:
path: release
pattern: release-*
@@ -255,7 +255,7 @@ jobs:
# 上传合并后的构建产物
- name: Upload artifacts with merged macOS files
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v6
with:
name: merged-release-pr
path: release/
@@ -280,7 +280,7 @@ jobs:
# 下载合并后的构建产物
- name: Download merged artifacts
uses: actions/download-artifact@v6
uses: actions/download-artifact@v7
with:
name: merged-release-pr
path: release

View File

@@ -91,7 +91,7 @@ jobs:
touch "/tmp/digests/${digest#sha256:}"
- name: Upload artifact
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v6
with:
name: digest-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
@@ -111,7 +111,7 @@ jobs:
fetch-depth: 0
- name: Download digests
uses: actions/download-artifact@v6
uses: actions/download-artifact@v7
with:
path: /tmp/digests
pattern: digest-*

View File

@@ -181,7 +181,7 @@ jobs:
# 上传构建产物 (工作流处理重命名,不依赖 electron-builder 钩子)
- name: Upload artifact
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v6
with:
name: release-${{ matrix.os }}
path: |
@@ -220,7 +220,7 @@ jobs:
# 下载所有平台的构建产物
- name: Download artifacts
uses: actions/download-artifact@v6
uses: actions/download-artifact@v7
with:
path: release
pattern: release-*
@@ -246,7 +246,7 @@ jobs:
# 上传合并后的构建产物
- name: Upload artifacts with merged macOS files
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v6
with:
name: merged-release
path: release/
@@ -262,7 +262,7 @@ jobs:
steps:
# 下载合并后的构建产物
- name: Download merged artifacts
uses: actions/download-artifact@v6
uses: actions/download-artifact@v7
with:
name: merged-release
path: release

View File

@@ -80,7 +80,7 @@ jobs:
touch "/tmp/digests/${digest#sha256:}"
- name: Upload artifact
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v6
with:
name: digest-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
@@ -98,7 +98,7 @@ jobs:
fetch-depth: 0
- name: Download digests
uses: actions/download-artifact@v6
uses: actions/download-artifact@v7
with:
path: /tmp/digests
pattern: digest-*

View File

@@ -76,14 +76,15 @@ jobs:
fi
done
# App tests
test-website:
# App tests - run sharded tests
test-app:
needs: check-duplicate-run
if: needs.check-duplicate-run.outputs.should_skip != 'true'
name: Test Website
strategy:
matrix:
shard: [1, 2]
name: Test App (shard ${{ matrix.shard }}/2)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
@@ -101,8 +102,44 @@ jobs:
- name: Install deps
run: bun i
- name: Test App Coverage
run: bun run test-app:coverage
- name: Run tests with blob reporter
run: bunx vitest --coverage --reporter=blob --silent='passed-only' --shard=${{ matrix.shard }}/2
- name: Upload blob report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v6
with:
name: blob-report-${{ matrix.shard }}
path: .vitest-reports
include-hidden-files: true
retention-days: 1
# Merge sharded test reports and upload coverage
merge-app-coverage:
needs: test-app
if: ${{ !cancelled() && needs.test-app.result == 'success' }}
name: Merge and Upload App Coverage
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Install bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install deps
run: bun i
- name: Download blob reports
uses: actions/download-artifact@v7
with:
path: .vitest-reports
pattern: blob-report-*
merge-multiple: true
- name: Merge reports
run: bunx vitest --merge-reports --coverage
- name: Upload App Coverage to Codecov
uses: codecov/codecov-action@v5

View File

@@ -63,6 +63,13 @@ LobeChat provides a complete authentication service capability when deployed. Th
<OIDCJWKs />
#### `INTERNAL_JWT_EXPIRATION`
- Type: Optional
- Description: Expiration time for internal JWT tokens used in lambda → async calls. Format: number followed by unit (s=seconds, m=minutes, h=hours). Should be as short as possible for security, but long enough to account for network latency and server processing time.
- Default: `30s`
- Example: `30s`, `1m`, `1h`
### Email Service (SMTP)
These settings are required for email verification and password reset features.

View File

@@ -61,6 +61,13 @@ LobeChat 在部署时提供了完善的身份验证服务能力,以下是相
<OIDCJWKs />
#### `INTERNAL_JWT_EXPIRATION`
- 类型:可选
- 描述:内部 JWT 令牌的过期时间,用于 lambda → async 调用。格式数字后跟单位s = 秒m = 分钟h = 小时)。为了安全性应尽可能短,但需要足够长以应对网络延迟和服务器处理时间。
- 默认值:`30s`
- 示例:`30s`、`1m`、`1h`
### 邮件服务SMTP
启用邮箱验证和密码重置功能需要配置以下设置。

View File

@@ -23,11 +23,16 @@ const genUserLLMConfig = (specificConfig: Record<any, any>): UserModelProviderCo
};
export const DEFAULT_LLM_CONFIG = genUserLLMConfig({
anthropic: {
enabled: true,
},
google: {
enabled: true,
},
lmstudio: {
fetchOnClient: true,
},
ollama: {
enabled: true,
fetchOnClient: true,
},
openai: {

View File

@@ -4,5 +4,5 @@ export const MIN_DEFAULT_IMAGE_NUM = 1;
export const MAX_DEFAULT_IMAGE_NUM = 20;
export const DEFAULT_IMAGE_CONFIG: UserImageConfig = {
defaultImageNum: 4,
defaultImageNum: 2,
};

View File

@@ -218,7 +218,8 @@ const azureChatModels: AIChatModelCard[] = [
deploymentName: 'gpt-4.1',
},
contextWindowTokens: 1_047_576,
description: 'GPT-4.1 is our flagship model for complex tasks and cross-domain problem solving.',
description:
'GPT-4.1 is our flagship model for complex tasks and cross-domain problem solving.',
displayName: 'GPT-4.1',
enabled: true,
id: 'gpt-4.1',
@@ -467,7 +468,6 @@ const azureImageModels: AIImageModelCard[] = [
enum: ['auto', '1024x1024', '1792x1024', '1024x1792'],
},
},
resolutions: ['1024x1024', '1024x1792', '1792x1024'],
type: 'image',
},
{

View File

@@ -951,6 +951,7 @@ const googleImageModels: AIImageModelCard[] = [
{
displayName: 'Nano Banana',
id: 'gemini-2.5-flash-image:image',
enabled: true,
type: 'image',
description:
'Nano Banana is Googles newest, fastest, and most efficient native multimodal model, enabling conversational image generation and editing.',

View File

@@ -1063,6 +1063,14 @@ export const nanoBananaProParameters: ModelParamsSchema = {
},
};
const gptImage1Schema = {
imageUrls: { default: [], maxCount: 1, maxFileSize: 5 },
prompt: { default: '' },
size: {
default: 'auto',
enum: ['auto', '1024x1024', '1536x1024', '1024x1536'],
},
};
const lobehubImageModels: AIImageModelCard[] = [
{
description:
@@ -1104,7 +1112,7 @@ const lobehubImageModels: AIImageModelCard[] = [
{
description: 'Imagen 4th generation text-to-image model series',
displayName: 'Imagen 4 Fast',
enabled: false,
enabled: true,
id: 'imagen-4.0-fast-generate-001',
organization: 'Deepmind',
parameters: imagenBaseParameters,
@@ -1117,7 +1125,7 @@ const lobehubImageModels: AIImageModelCard[] = [
{
description: 'Imagen 4th generation text-to-image model series',
displayName: 'Imagen 4',
enabled: false,
enabled: true,
id: 'imagen-4.0-generate-001',
organization: 'Deepmind',
parameters: imagenBaseParameters,
@@ -1130,7 +1138,7 @@ const lobehubImageModels: AIImageModelCard[] = [
{
description: 'Imagen 4th generation text-to-image model series Ultra version',
displayName: 'Imagen 4 Ultra',
enabled: false,
enabled: true,
id: 'imagen-4.0-ultra-generate-001',
organization: 'Deepmind',
parameters: imagenBaseParameters,
@@ -1140,19 +1148,32 @@ const lobehubImageModels: AIImageModelCard[] = [
releasedAt: '2025-08-15',
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: gptImage1Schema,
pricing: {
approximatePricePerImage: 0.034,
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: 32, strategy: 'fixed', unit: 'millionTokens' },
],
},
releasedAt: '2025-12-16',
type: 'image',
},
{
description: 'ChatGPT native multimodal image generation model.',
displayName: 'GPT Image 1',
enabled: true,
id: 'gpt-image-1',
parameters: {
imageUrls: { default: [], maxCount: 1, maxFileSize: 5 },
prompt: { default: '' },
size: {
default: 'auto',
enum: ['auto', '1024x1024', '1536x1024', '1024x1536'],
},
},
parameters: gptImage1Schema,
pricing: {
approximatePricePerImage: 0.042,
units: [
@@ -1169,7 +1190,7 @@ const lobehubImageModels: 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: false,
enabled: true,
id: 'dall-e-3',
parameters: {
prompt: { default: '' },
@@ -1203,7 +1224,6 @@ const lobehubImageModels: AIImageModelCard[] = [
},
],
},
resolutions: ['1024x1024', '1024x1792', '1792x1024'],
type: 'image',
},
{

View File

@@ -1219,6 +1219,26 @@ export const openaiSTTModels: AISTTModelCard[] = [
// Image generation models
export const openaiImageModels: AIImageModelCard[] = [
{
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,
pricing: {
approximatePricePerImage: 0.034,
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: 32, strategy: 'fixed', unit: 'millionTokens' },
],
},
releasedAt: '2025-12-16',
type: 'image',
},
// https://platform.openai.com/docs/models/gpt-image-1
{
description: 'ChatGPT native multimodal image generation model.',
@@ -1236,7 +1256,6 @@ export const openaiImageModels: AIImageModelCard[] = [
{ name: 'imageOutput', rate: 40, strategy: 'fixed', unit: 'millionTokens' },
],
},
resolutions: ['1024x1024', '1024x1536', '1536x1024'],
type: 'image',
},
{
@@ -1257,13 +1276,13 @@ export const openaiImageModels: AIImageModelCard[] = [
],
},
releasedAt: '2025-10-06',
resolutions: ['1024x1024', '1024x1536', '1536x1024'],
type: 'image',
},
{
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: '' },
@@ -1296,7 +1315,6 @@ export const openaiImageModels: AIImageModelCard[] = [
},
],
},
resolutions: ['1024x1024', '1024x1792', '1792x1024'],
type: 'image',
},
{
@@ -1329,7 +1347,6 @@ export const openaiImageModels: AIImageModelCard[] = [
},
],
},
resolutions: ['256x256', '512x512', '1024x1024'],
type: 'image',
},
];

View File

@@ -67,7 +67,10 @@ async function generateByImageMode(
const defaultInput = {
n: 1,
...(model.includes('dall-e') ? { response_format: 'b64_json' } : {}),
...(isImageEdit && model === 'gpt-image-1' ? { input_fidelity: 'high' } : {}),
// https://platform.openai.com/docs/api-reference/images/createEdit#images_createedit-input_fidelity
...(isImageEdit && model.includes('gpt-image-') && !model.includes('mini')
? { input_fidelity: 'high' }
: {}),
};
const options = cleanObject({

View File

@@ -311,7 +311,7 @@ exports[`LobeOpenAI > models > should get models 1`] = `
"contextWindowTokens": undefined,
"description": "The latest DALL·E model, released in November 2023, supports more realistic, accurate image generation with stronger detail.",
"displayName": "DALL·E 3",
"enabled": false,
"enabled": true,
"functionCall": false,
"id": "dall-e-3",
"imageOutput": false,

View File

@@ -49,14 +49,7 @@ describe('ssrfSafeFetch', () => {
expect(mockFetch).toHaveBeenCalledWith(
'https://httpbin.org/get',
expect.objectContaining({
agent: expect.objectContaining({
requestFilterOptions: expect.objectContaining({
allowIPAddressList: [],
allowMetaIPAddress: false,
allowPrivateIPAddress: false,
denyIPAddressList: [],
}),
}),
agent: expect.any(Function),
}),
);
expect(response).toBeInstanceOf(Response);
@@ -80,14 +73,7 @@ describe('ssrfSafeFetch', () => {
'https://httpbin.org/post',
expect.objectContaining({
...requestOptions,
agent: expect.objectContaining({
requestFilterOptions: expect.objectContaining({
allowIPAddressList: [],
allowMetaIPAddress: false,
allowPrivateIPAddress: false,
denyIPAddressList: [],
}),
}),
agent: expect.any(Function),
}),
);
});
@@ -302,14 +288,7 @@ describe('ssrfSafeFetch', () => {
'https://api.example.com/data',
expect.objectContaining({
...requestOptions,
agent: expect.objectContaining({
requestFilterOptions: expect.objectContaining({
allowIPAddressList: ['127.0.0.1'],
allowMetaIPAddress: true,
allowPrivateIPAddress: true,
denyIPAddressList: [],
}),
}),
agent: expect.any(Function),
}),
);
@@ -323,19 +302,11 @@ describe('ssrfSafeFetch', () => {
await ssrfSafeFetch('https://secure.example.com/api');
// Verify that the agent is properly configured for HTTPS
// Verify that the agent function is passed
expect(mockFetch).toHaveBeenCalledWith(
'https://secure.example.com/api',
expect.objectContaining({
agent: expect.objectContaining({
protocol: 'https:',
requestFilterOptions: expect.objectContaining({
allowIPAddressList: [],
allowMetaIPAddress: false,
allowPrivateIPAddress: false,
denyIPAddressList: [],
}),
}),
agent: expect.any(Function),
}),
);
});

View File

@@ -1,5 +1,9 @@
import fetch from 'node-fetch';
import { RequestFilteringAgentOptions, useAgent as ssrfAgent } from 'request-filtering-agent';
import {
RequestFilteringAgentOptions,
RequestFilteringHttpAgent,
RequestFilteringHttpsAgent,
} from 'request-filtering-agent';
/**
* Options for per-call SSRF configuration overrides
@@ -42,10 +46,16 @@ export const ssrfSafeFetch = async (
denyIPAddressList: [],
};
// Create agents for both protocols
const httpAgent = new RequestFilteringHttpAgent(agentOptions);
const httpsAgent = new RequestFilteringHttpsAgent(agentOptions);
// Use node-fetch with SSRF protection agent
// Pass a function to dynamically select agent based on URL protocol
// This handles redirects from HTTP to HTTPS correctly
const response = await fetch(url, {
...options,
agent: ssrfAgent(url, agentOptions),
agent: (parsedURL: URL) => (parsedURL.protocol === 'https:' ? httpsAgent : httpAgent),
} as any);
// Convert node-fetch Response to standard Response

View File

@@ -93,8 +93,8 @@ const styles = createStaticStyles(({ css }) => {
overflow: hidden;
width: ${thumbnailSize};
height: ${thumbnailSize};
width: ${thumbnailSize}px;
height: ${thumbnailSize}px;
border-radius: ${cssVar.borderRadius};
background: ${cssVar.colorBgContainer};
@@ -112,7 +112,7 @@ const styles = createStaticStyles(({ css }) => {
gap: 8px;
width: 100%;
height: ${thumbnailSize};
height: ${thumbnailSize}px;
padding: 0;
border-radius: ${cssVar.borderRadiusLG};

View File

@@ -77,15 +77,8 @@ const GenerationFeed = memo(() => {
}
return (
<>
<Flexbox
gap={16}
ref={parent}
style={{
minHeight: 'calc(100vh - 180px)',
}}
width="100%"
>
<Flexbox flex={1}>
<Flexbox gap={16} ref={parent} width="100%">
{currentGenerationBatches.map((batch, index) => (
<Fragment key={batch.id}>
{Boolean(index !== 0) && <Divider dashed style={{ margin: 0 }} />}
@@ -95,7 +88,7 @@ const GenerationFeed = memo(() => {
</Flexbox>
{/* Invisible element for scroll target */}
<div ref={containerRef} style={{ height: 1 }} />
</>
</Flexbox>
);
});

View File

@@ -15,7 +15,7 @@ const DesktopImagePage = memo(() => {
<>
<NavHeader right={<WideScreenButton />} />
<Flexbox height={'100%'} style={{ overflowY: 'auto', position: 'relative' }} width={'100%'}>
<WideScreenContainer>
<WideScreenContainer height={'100%'} wrapperStyle={{ height: '100%' }}>
<Suspense fallback={<SkeletonList />}>
<ImageWorkspace />
</Suspense>

View File

@@ -158,6 +158,15 @@ declare global {
* Can be generated using `node scripts/generate-oidc-jwk.mjs`.
*/
JWKS_KEY?: string;
/**
* Internal JWT expiration time for lambda → async calls.
* Format: number followed by unit (s=seconds, m=minutes, h=hours)
* Examples: '10s', '1m', '1h'
* Should be as short as possible for security, but long enough to account for network latency and server processing time.
* @default '30s'
*/
INTERNAL_JWT_EXPIRATION?: string;
}
}
}
@@ -285,6 +294,9 @@ export const getAuthConfig = () => {
// Generic JWKS key for signing/verifying JWTs
JWKS_KEY: z.string().optional(),
ENABLE_OIDC: z.boolean(),
// Internal JWT expiration time (e.g., '10s', '1m', '1h')
INTERNAL_JWT_EXPIRATION: z.string().default('30s'),
},
runtimeEnv: {
@@ -415,6 +427,9 @@ export const getAuthConfig = () => {
// Generic JWKS key (fallback to OIDC_JWKS_KEY for backward compatibility)
JWKS_KEY: process.env.JWKS_KEY || process.env.OIDC_JWKS_KEY,
ENABLE_OIDC: !!(process.env.JWKS_KEY || process.env.OIDC_JWKS_KEY),
// Internal JWT expiration time
INTERNAL_JWT_EXPIRATION: process.env.INTERNAL_JWT_EXPIRATION,
},
});
};

View File

@@ -1,12 +1,26 @@
import { useEffect } from 'react';
import { useEffect, useMemo } from 'react';
import { aiProviderSelectors, useAiInfraStore } from '@/store/aiInfra';
import { useGlobalStore } from '@/store/global';
import { systemStatusSelectors } from '@/store/global/selectors';
import { useImageStore } from '@/store/image';
import {
DEFAULT_AI_IMAGE_MODEL,
DEFAULT_AI_IMAGE_PROVIDER,
} from '@/store/image/slices/generationConfig/initialState';
import { useUserStore } from '@/store/user';
import { authSelectors } from '@/store/user/selectors';
const checkModelEnabled = (
enabledImageModelList: ReturnType<typeof aiProviderSelectors.enabledImageModelList>,
provider: string,
model: string,
) => {
return enabledImageModelList.some(
(p) => p.id === provider && p.children.some((m) => m.id === model),
);
};
export const useFetchAiImageConfig = () => {
const isStatusInit = useGlobalStore(systemStatusSelectors.isStatusInit);
const isInitAiProviderRuntimeState = useAiInfraStore(
@@ -29,16 +43,46 @@ export const useFetchAiImageConfig = () => {
const isInitializedImageConfig = useImageStore((s) => s.isInit);
const initializeImageConfig = useImageStore((s) => s.initializeImageConfig);
const enabledImageModelList = useAiInfraStore(aiProviderSelectors.enabledImageModelList);
// Determine which model/provider to use for initialization
const initParams = useMemo(() => {
// 1. Try lastSelected if enabled
if (
lastSelectedImageModel &&
lastSelectedImageProvider &&
checkModelEnabled(enabledImageModelList, lastSelectedImageProvider, lastSelectedImageModel)
) {
return { model: lastSelectedImageModel, provider: lastSelectedImageProvider };
}
// 2. Try default model from any enabled provider (prefer default provider first)
if (
checkModelEnabled(enabledImageModelList, DEFAULT_AI_IMAGE_PROVIDER, DEFAULT_AI_IMAGE_MODEL)
) {
return { model: undefined, provider: undefined }; // Use initialState defaults
}
const providerWithDefaultModel = enabledImageModelList.find((p) =>
p.children.some((m) => m.id === DEFAULT_AI_IMAGE_MODEL),
);
if (providerWithDefaultModel) {
return { model: DEFAULT_AI_IMAGE_MODEL, provider: providerWithDefaultModel.id };
}
// 3. Fallback to first enabled model
const firstProvider = enabledImageModelList[0];
const firstModel = firstProvider?.children[0];
if (firstProvider && firstModel) {
return { model: firstModel.id, provider: firstProvider.id };
}
// No enabled models
return { model: undefined, provider: undefined };
}, [lastSelectedImageModel, lastSelectedImageProvider, enabledImageModelList]);
useEffect(() => {
if (!isInitializedImageConfig && isReadyForInit) {
initializeImageConfig(isLogin, lastSelectedImageModel, lastSelectedImageProvider);
initializeImageConfig(isLogin, initParams.model, initParams.provider);
}
}, [
isReadyForInit,
isInitializedImageConfig,
isLogin,
lastSelectedImageModel,
lastSelectedImageProvider,
initializeImageConfig,
]);
}, [isReadyForInit, isInitializedImageConfig, isLogin, initParams, initializeImageConfig]);
};

View File

@@ -66,7 +66,7 @@ const getVerificationKey = async () => {
/**
* Sign JWT for internal lambda → async calls
* Uses JWKS private key with short expiration (3s)
* Uses JWKS private key with configurable expiration (default: 30s)
* The JWT only proves the request is from lambda, payload is sent via LOBE_CHAT_AUTH_HEADER
*/
export const signInternalJWT = async (): Promise<string> => {
@@ -75,7 +75,7 @@ export const signInternalJWT = async (): Promise<string> => {
return new SignJWT({ purpose: INTERNAL_JWT_PURPOSE })
.setProtectedHeader({ alg: 'RS256', kid })
.setIssuedAt()
.setExpirationTime('3s')
.setExpirationTime(authEnv.INTERNAL_JWT_EXPIRATION)
.sign(key);
};

View File

@@ -4,13 +4,13 @@ import {
ModelProvider,
type RuntimeImageGenParams,
extractDefaultValues,
gptImage1ParamsSchema,
} from 'model-bank';
import { nanoBananaProParameters } from 'model-bank/google';
import { DEFAULT_IMAGE_CONFIG } from '@/const/settings';
export const DEFAULT_AI_IMAGE_PROVIDER = ModelProvider.OpenAI;
export const DEFAULT_AI_IMAGE_MODEL = 'gpt-image-1';
export const DEFAULT_AI_IMAGE_PROVIDER = ModelProvider.Google;
export const DEFAULT_AI_IMAGE_MODEL = 'gemini-3-pro-image-preview:image';
export interface GenerationConfigState {
parameters: RuntimeImageGenParams;
@@ -30,14 +30,14 @@ export interface GenerationConfigState {
}
export const DEFAULT_IMAGE_GENERATION_PARAMETERS: RuntimeImageGenParams =
extractDefaultValues(gptImage1ParamsSchema);
extractDefaultValues(nanoBananaProParameters);
export const initialGenerationConfigState: GenerationConfigState = {
model: DEFAULT_AI_IMAGE_MODEL,
provider: DEFAULT_AI_IMAGE_PROVIDER,
imageNum: DEFAULT_IMAGE_CONFIG.defaultImageNum,
parameters: DEFAULT_IMAGE_GENERATION_PARAMETERS,
parametersSchema: gptImage1ParamsSchema,
parametersSchema: nanoBananaProParameters,
isAspectRatioLocked: false,
activeAspectRatio: null,
isInit: false,

View File

@@ -6,6 +6,7 @@ import { ImageStore } from '@/store/image';
import { initialState } from '@/store/image/initialState';
import { merge } from '@/utils/merge';
import { DEFAULT_AI_IMAGE_MODEL, DEFAULT_AI_IMAGE_PROVIDER } from './initialState';
import { imageGenerationConfigSelectors } from './selectors';
// Mock external dependencies
@@ -57,7 +58,7 @@ describe('imageGenerationConfigSelectors', () => {
it('should return the default model from initial state', () => {
const result = imageGenerationConfigSelectors.model(initialStore);
expect(result).toBe('gpt-image-1'); // Default model from initialState
expect(result).toBe(DEFAULT_AI_IMAGE_MODEL);
});
});
@@ -70,7 +71,7 @@ describe('imageGenerationConfigSelectors', () => {
it('should return the default provider from initial state', () => {
const result = imageGenerationConfigSelectors.provider(initialStore);
expect(result).toBe('openai'); // Default provider from initialState
expect(result).toBe(DEFAULT_AI_IMAGE_PROVIDER);
});
});
@@ -98,7 +99,10 @@ describe('imageGenerationConfigSelectors', () => {
it('should return the current parameters', () => {
const state = merge(initialStore, { parameters: testParameters });
const result = imageGenerationConfigSelectors.parameters(state);
expect(result).toEqual(testParameters);
// merge does deep merge, so result contains both default and test values
expect(result.prompt).toBe(testParameters.prompt);
expect(result.size).toBe(testParameters.size);
expect(result.imageUrls).toEqual(testParameters.imageUrls);
});
it('should return the default parameters from initial state', () => {
@@ -120,7 +124,10 @@ describe('imageGenerationConfigSelectors', () => {
it('should return the current parametersSchema', () => {
const state = merge(initialStore, { parametersSchema: testModelSchema });
const result = imageGenerationConfigSelectors.parametersSchema(state);
expect(result).toEqual(testModelSchema);
// merge does deep merge, so result contains both default and test values
expect(result.prompt).toEqual(testModelSchema.prompt);
expect(result.size).toEqual(testModelSchema.size);
expect(result.imageUrls).toEqual(testModelSchema.imageUrls);
});
it('should return default parametersSchema when not explicitly overridden', () => {

View File

@@ -2,6 +2,10 @@ import { dirname, join, resolve } from 'node:path';
import { coverageConfigDefaults, defineConfig } from 'vitest/config';
export default defineConfig({
optimizeDeps: {
exclude: ['crypto', 'util', 'tty'],
include: ['@lobehub/tts'],
},
plugins: [
/**
* @lobehub/fluent-emoji@4.0.0 ships `es/FluentEmoji/style.js` but its `es/FluentEmoji/index.js`
@@ -27,16 +31,13 @@ export default defineConfig({
id.endsWith('/FluentEmoji/style/index.js') ||
id.endsWith('/FluentEmoji/style/index.js?');
if (isFluentEmojiEntry && isMissingStyleIndex) return resolve(dirname(importer), 'style.js');
if (isFluentEmojiEntry && isMissingStyleIndex)
return resolve(dirname(importer), 'style.js');
return null;
},
},
],
optimizeDeps: {
exclude: ['crypto', 'util', 'tty'],
include: ['@lobehub/tts'],
},
test: {
alias: {
/* eslint-disable sort-keys-fix/sort-keys-fix */
@@ -77,11 +78,14 @@ export default defineConfig({
environment: 'happy-dom',
exclude: [
'**/node_modules/**',
'**/.*/**',
'**/dist/**',
'**/build/**',
'**/tmp/**',
'**/temp/**',
'**/.temp/**',
'**/docs/**',
'**/locales/**',
'**/public/**',
'**/apps/desktop/**',
'**/apps/mobile/**',
'**/packages/**',