Compare commits

..

No commits in common. "pascal-editor-ai-agent" and "main" have entirely different histories.

3558 changed files with 544446 additions and 1588 deletions

333
.cursor/rules/ultracite.mdc Normal file
View File

@ -0,0 +1,333 @@
---
description: Ultracite Rules - AI-Ready Formatter and Linter
globs: "**/*.{ts,tsx,js,jsx}"
alwaysApply: true
---
# Project Context
Ultracite enforces strict type safety, accessibility standards, and consistent code quality for JavaScript/TypeScript projects using Biome's lightning-fast formatter and linter.
## Key Principles
- Zero configuration required
- Subsecond performance
- Maximum type safety
- AI-friendly code generation
## Before Writing Code
1. Analyze existing patterns in the codebase
2. Consider edge cases and error scenarios
3. Follow the rules below strictly
4. Validate accessibility requirements
## Rules
### Accessibility (a11y)
- Don't use `accessKey` attribute on any HTML element.
- Don't set `aria-hidden="true"` on focusable elements.
- Don't add ARIA roles, states, and properties to elements that don't support them.
- Don't use distracting elements like `<marquee>` or `<blink>`.
- Only use the `scope` prop on `<th>` elements.
- Don't assign non-interactive ARIA roles to interactive HTML elements.
- Make sure label elements have text content and are associated with an input.
- Don't assign interactive ARIA roles to non-interactive HTML elements.
- Don't assign `tabIndex` to non-interactive HTML elements.
- Don't use positive integers for `tabIndex` property.
- Don't include "image", "picture", or "photo" in img alt prop.
- Don't use explicit role property that's the same as the implicit/default role.
- Make static elements with click handlers use a valid role attribute.
- Always include a `title` element for SVG elements.
- Give all elements requiring alt text meaningful information for screen readers.
- Make sure anchors have content that's accessible to screen readers.
- Assign `tabIndex` to non-interactive HTML elements with `aria-activedescendant`.
- Include all required ARIA attributes for elements with ARIA roles.
- Make sure ARIA properties are valid for the element's supported roles.
- Always include a `type` attribute for button elements.
- Make elements with interactive roles and handlers focusable.
- Give heading elements content that's accessible to screen readers (not hidden with `aria-hidden`).
- Always include a `lang` attribute on the html element.
- Always include a `title` attribute for iframe elements.
- Accompany `onClick` with at least one of: `onKeyUp`, `onKeyDown`, or `onKeyPress`.
- Accompany `onMouseOver`/`onMouseOut` with `onFocus`/`onBlur`.
- Include caption tracks for audio and video elements.
- Use semantic elements instead of role attributes in JSX.
- Make sure all anchors are valid and navigable.
- Ensure all ARIA properties (`aria-*`) are valid.
- Use valid, non-abstract ARIA roles for elements with ARIA roles.
- Use valid ARIA state and property values.
- Use valid values for the `autocomplete` attribute on input elements.
- Use correct ISO language/country codes for the `lang` attribute.
### Code Complexity and Quality
- Don't use consecutive spaces in regular expression literals.
- Don't use the `arguments` object.
- Don't use primitive type aliases or misleading types.
- Don't use the comma operator.
- Don't use empty type parameters in type aliases and interfaces.
- Don't write functions that exceed a given Cognitive Complexity score.
- Don't nest describe() blocks too deeply in test files.
- Don't use unnecessary boolean casts.
- Don't use unnecessary callbacks with flatMap.
- Use for...of statements instead of Array.forEach.
- Don't create classes that only have static members (like a static namespace).
- Don't use this and super in static contexts.
- Don't use unnecessary catch clauses.
- Don't use unnecessary constructors.
- Don't use unnecessary continue statements.
- Don't export empty modules that don't change anything.
- Don't use unnecessary escape sequences in regular expression literals.
- Don't use unnecessary fragments.
- Don't use unnecessary labels.
- Don't use unnecessary nested block statements.
- Don't rename imports, exports, and destructured assignments to the same name.
- Don't use unnecessary string or template literal concatenation.
- Don't use String.raw in template literals when there are no escape sequences.
- Don't use useless case statements in switch statements.
- Don't use ternary operators when simpler alternatives exist.
- Don't use useless `this` aliasing.
- Don't use any or unknown as type constraints.
- Don't initialize variables to undefined.
- Don't use the void operators (they're not familiar).
- Use arrow functions instead of function expressions.
- Use Date.now() to get milliseconds since the Unix Epoch.
- Use .flatMap() instead of map().flat() when possible.
- Use literal property access instead of computed property access.
- Don't use parseInt() or Number.parseInt() when binary, octal, or hexadecimal literals work.
- Use concise optional chaining instead of chained logical expressions.
- Use regular expression literals instead of the RegExp constructor when possible.
- Don't use number literal object member names that aren't base 10 or use underscore separators.
- Remove redundant terms from logical expressions.
- Use while loops instead of for loops when you don't need initializer and update expressions.
- Don't pass children as props.
- Don't reassign const variables.
- Don't use constant expressions in conditions.
- Don't use `Math.min` and `Math.max` to clamp values when the result is constant.
- Don't return a value from a constructor.
- Don't use empty character classes in regular expression literals.
- Don't use empty destructuring patterns.
- Don't call global object properties as functions.
- Don't declare functions and vars that are accessible outside their block.
- Make sure builtins are correctly instantiated.
- Don't use super() incorrectly inside classes. Also check that super() is called in classes that extend other constructors.
- Don't use variables and function parameters before they're declared.
- Don't use 8 and 9 escape sequences in string literals.
- Don't use literal numbers that lose precision.
### React and JSX Best Practices
- Don't use the return value of React.render.
- Make sure all dependencies are correctly specified in React hooks.
- Make sure all React hooks are called from the top level of component functions.
- Don't forget key props in iterators and collection literals.
- Don't destructure props inside JSX components in Solid projects.
- Don't define React components inside other components.
- Don't use event handlers on non-interactive elements.
- Don't assign to React component props.
- Don't use both `children` and `dangerouslySetInnerHTML` props on the same element.
- Don't use dangerous JSX props.
- Don't use Array index in keys.
- Don't insert comments as text nodes.
- Don't assign JSX properties multiple times.
- Don't add extra closing tags for components without children.
- Use `<>...</>` instead of `<Fragment>...</Fragment>`.
- Watch out for possible "wrong" semicolons inside JSX elements.
### Correctness and Safety
- Don't assign a value to itself.
- Don't return a value from a setter.
- Don't compare expressions that modify string case with non-compliant values.
- Don't use lexical declarations in switch clauses.
- Don't use variables that haven't been declared in the document.
- Don't write unreachable code.
- Make sure super() is called exactly once on every code path in a class constructor before this is accessed if the class has a superclass.
- Don't use control flow statements in finally blocks.
- Don't use optional chaining where undefined values aren't allowed.
- Don't have unused function parameters.
- Don't have unused imports.
- Don't have unused labels.
- Don't have unused private class members.
- Don't have unused variables.
- Make sure void (self-closing) elements don't have children.
- Don't return a value from a function with the return type 'void'
- Use isNaN() when checking for NaN.
- Make sure "for" loop update clauses move the counter in the right direction.
- Make sure typeof expressions are compared to valid values.
- Make sure generator functions contain yield.
- Don't use await inside loops.
- Don't use bitwise operators.
- Don't use expressions where the operation doesn't change the value.
- Make sure Promise-like statements are handled appropriately.
- Don't use __dirname and __filename in the global scope.
- Prevent import cycles.
- Don't use configured elements.
- Don't hardcode sensitive data like API keys and tokens.
- Don't let variable declarations shadow variables from outer scopes.
- Don't use the TypeScript directive @ts-ignore.
- Prevent duplicate polyfills from Polyfill.io.
- Don't use useless backreferences in regular expressions that always match empty strings.
- Don't use unnecessary escapes in string literals.
- Don't use useless undefined.
- Make sure getters and setters for the same property are next to each other in class and object definitions.
- Make sure object literals are declared consistently (defaults to explicit definitions).
- Use static Response methods instead of new Response() constructor when possible.
- Make sure switch-case statements are exhaustive.
- Make sure the `preconnect` attribute is used when using Google Fonts.
- Use `Array#{indexOf,lastIndexOf}()` instead of `Array#{findIndex,findLastIndex}()` when looking for the index of an item.
- Make sure iterable callbacks return consistent values.
- Use `with { type: "json" }` for JSON module imports.
- Use numeric separators in numeric literals.
- Use object spread instead of `Object.assign()` when constructing new objects.
- Always use the radix argument when using `parseInt()`.
- Make sure JSDoc comment lines start with a single asterisk, except for the first one.
- Include a description parameter for `Symbol()`.
- Don't use spread (`...`) syntax on accumulators.
- Don't use the `delete` operator.
- Don't access namespace imports dynamically.
- Don't use namespace imports.
- Declare regex literals at the top level.
- Don't use `target="_blank"` without `rel="noopener"`.
### TypeScript Best Practices
- Don't use TypeScript enums.
- Don't export imported variables.
- Don't add type annotations to variables, parameters, and class properties that are initialized with literal expressions.
- Don't use TypeScript namespaces.
- Don't use non-null assertions with the `!` postfix operator.
- Don't use parameter properties in class constructors.
- Don't use user-defined types.
- Use `as const` instead of literal types and type annotations.
- Use either `T[]` or `Array<T>` consistently.
- Initialize each enum member value explicitly.
- Use `export type` for types.
- Use `import type` for types.
- Make sure all enum members are literal values.
- Don't use TypeScript const enum.
- Don't declare empty interfaces.
- Don't let variables evolve into any type through reassignments.
- Don't use the any type.
- Don't misuse the non-null assertion operator (!) in TypeScript files.
- Don't use implicit any type on variable declarations.
- Don't merge interfaces and classes unsafely.
- Don't use overload signatures that aren't next to each other.
- Use the namespace keyword instead of the module keyword to declare TypeScript namespaces.
### Style and Consistency
- Don't use global `eval()`.
- Don't use callbacks in asynchronous tests and hooks.
- Don't use negation in `if` statements that have `else` clauses.
- Don't use nested ternary expressions.
- Don't reassign function parameters.
- This rule lets you specify global variable names you don't want to use in your application.
- Don't use specified modules when loaded by import or require.
- Don't use constants whose value is the upper-case version of their name.
- Use `String.slice()` instead of `String.substr()` and `String.substring()`.
- Don't use template literals if you don't need interpolation or special-character handling.
- Don't use `else` blocks when the `if` block breaks early.
- Don't use yoda expressions.
- Don't use Array constructors.
- Use `at()` instead of integer index access.
- Follow curly brace conventions.
- Use `else if` instead of nested `if` statements in `else` clauses.
- Use single `if` statements instead of nested `if` clauses.
- Use `new` for all builtins except `String`, `Number`, and `Boolean`.
- Use consistent accessibility modifiers on class properties and methods.
- Use `const` declarations for variables that are only assigned once.
- Put default function parameters and optional function parameters last.
- Include a `default` clause in switch statements.
- Use the `**` operator instead of `Math.pow`.
- Use `for-of` loops when you need the index to extract an item from the iterated array.
- Use `node:assert/strict` over `node:assert`.
- Use the `node:` protocol for Node.js builtin modules.
- Use Number properties instead of global ones.
- Use assignment operator shorthand where possible.
- Use function types instead of object types with call signatures.
- Use template literals over string concatenation.
- Use `new` when throwing an error.
- Don't throw non-Error values.
- Use `String.trimStart()` and `String.trimEnd()` over `String.trimLeft()` and `String.trimRight()`.
- Use standard constants instead of approximated literals.
- Don't assign values in expressions.
- Don't use async functions as Promise executors.
- Don't reassign exceptions in catch clauses.
- Don't reassign class members.
- Don't compare against -0.
- Don't use labeled statements that aren't loops.
- Don't use void type outside of generic or return types.
- Don't use console.
- Don't use control characters and escape sequences that match control characters in regular expression literals.
- Don't use debugger.
- Don't assign directly to document.cookie.
- Use `===` and `!==`.
- Don't use duplicate case labels.
- Don't use duplicate class members.
- Don't use duplicate conditions in if-else-if chains.
- Don't use two keys with the same name inside objects.
- Don't use duplicate function parameter names.
- Don't have duplicate hooks in describe blocks.
- Don't use empty block statements and static blocks.
- Don't let switch clauses fall through.
- Don't reassign function declarations.
- Don't allow assignments to native objects and read-only global variables.
- Use Number.isFinite instead of global isFinite.
- Use Number.isNaN instead of global isNaN.
- Don't assign to imported bindings.
- Don't use irregular whitespace characters.
- Don't use labels that share a name with a variable.
- Don't use characters made with multiple code points in character class syntax.
- Make sure to use new and constructor properly.
- Don't use shorthand assign when the variable appears on both sides.
- Don't use octal escape sequences in string literals.
- Don't use Object.prototype builtins directly.
- Don't redeclare variables, functions, classes, and types in the same scope.
- Don't have redundant "use strict".
- Don't compare things where both sides are exactly the same.
- Don't let identifiers shadow restricted names.
- Don't use sparse arrays (arrays with holes).
- Don't use template literal placeholder syntax in regular strings.
- Don't use the then property.
- Don't use unsafe negation.
- Don't use var.
- Don't use with statements in non-strict contexts.
- Make sure async functions actually use await.
- Make sure default clauses in switch statements come last.
- Make sure to pass a message value when creating a built-in error.
- Make sure get methods always return a value.
- Use a recommended display strategy with Google Fonts.
- Make sure for-in loops include an if statement.
- Use Array.isArray() instead of instanceof Array.
- Make sure to use the digits argument with Number#toFixed().
- Make sure to use the "use strict" directive in script files.
### Next.js Specific Rules
- Don't use `<img>` elements in Next.js projects.
- Don't use `<head>` elements in Next.js projects.
- Don't import next/document outside of pages/_document.jsx in Next.js projects.
- Don't use the next/head module in pages/_document.js on Next.js projects.
### Testing Best Practices
- Don't use export or module.exports in test files.
- Don't use focused tests.
- Make sure the assertion function, like expect, is placed inside an it() function call.
- Don't use disabled tests.
## Common Tasks
- `npx ultracite init` - Initialize Ultracite in your project
- `npx ultracite fix` - Format and fix code automatically
- `npx ultracite check` - Check for issues without fixing
## Example: Error Handling
```typescript
// ✅ Good: Comprehensive error handling
try {
const result = await fetchData();
return { success: true, data: result };
} catch (error) {
console.error('API call failed:', error);
return { success: false, error: error.message };
}
// ❌ Bad: Swallowing errors
try {
return await fetchData();
} catch (e) {
console.log(e);
}
```

View File

@ -1,33 +1,218 @@
# Optional environment variables — the editor works without any of these
# yedir-ai environment template
# Copy this file to .env.local and fill real values locally or in your hosting platform.
# Never commit real secrets.
# Google Maps API key (enables address search)
# NEXT_PUBLIC_GOOGLE_MAPS_API_KEY=your_google_maps_api_key
# -----------------------------------------------------------------------------
# Core / Auth
# -----------------------------------------------------------------------------
# Dev server port (default: 3000)
# PORT=3000
# Generate with: openssl rand -base64 32
AUTH_SECRET=replace-with-random-secret
# AI design agent. The endpoint must implement the OpenAI-compatible chat API.
# PASCAL_AGENT_API_KEY=your_api_key
# PASCAL_AGENT_BASE_URL=https://api.openai.com/v1
# PASCAL_AGENT_MODEL=gpt-4.1-mini
# Stable pepper for user Access Key hashes. Generate independently from AUTH_SECRET.
ACCESS_KEY_PEPPER=replace-with-random-secret
# Optional agent safety limits.
# PASCAL_AGENT_MAX_STEPS=18
# PASCAL_AGENT_STEP_TIMEOUT_MS=90000
# PASCAL_AGENT_TOTAL_TIMEOUT_MS=300000
# Authorizes trusted third-party services to provision user-scoped MCP keys.
# Must contain at least 32 characters. Never expose it to end users or browsers.
MCP_KEY_PROVISIONING_SECRET=replace-with-random-secret
# Required in production. Configure the reverse proxy to overwrite this header
# before forwarding requests to Pascal; never expose the value to browser code.
# PASCAL_AGENT_PROXY_SECRET=replace_with_a_long_random_value
# Public application URL. Required for production and self-hosted Auth.js.
AUTH_URL=https://your-domain.com
NEXTAUTH_URL=https://your-domain.com
NEXT_PUBLIC_APP_URL=https://your-domain.com
# Optional when Bun or the built MCP executable are not discoverable automatically.
# PASCAL_MCP_COMMAND=/absolute/path/to/bun
# PASCAL_MCP_ENTRY=/absolute/path/to/packages/mcp/dist/bin/pascal-mcp.js
# Only enable behind a trusted reverse proxy with validated Host headers.
AUTH_TRUST_HOST=false
# Shared scene storage used by the editor and each isolated Pascal MCP process.
# PASCAL_DATA_DIR=/absolute/path/to/pascal-data
# Optional OAuth providers.
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# Hosted integration. The reverse proxy must authenticate the request, strip
# any client-supplied user header, and inject the trusted host user id.
# NEXT_PUBLIC_PASCAL_BASE_PATH=/pascal-service
# PASCAL_REQUIRE_HOST_USER=true
# Optional initial admin provisioning.
ADMIN_EMAIL=
ADMIN_PASSWORD=
# Optional email verification.
RESEND_API_KEY=
RESEND_FROM_EMAIL=
EMAIL_VERIFICATION_SECRET=
# Optional scheduled job protection.
CRON_SECRET=
# -----------------------------------------------------------------------------
# Database
# -----------------------------------------------------------------------------
POSTGRES_URL=postgres://user:password@localhost:5432/yedir_ai
# Production-safe defaults used by lib/db/index.ts.
POSTGRES_MAX_CONNECTIONS=10
POSTGRES_IDLE_TIMEOUT_SECONDS=20
POSTGRES_CONNECT_TIMEOUT_SECONDS=5
POSTGRES_MIGRATION_TIMEOUT_SECONDS=120
# -----------------------------------------------------------------------------
# Object Storage
# -----------------------------------------------------------------------------
# Vercel Blob fallback.
BLOB_READ_WRITE_TOKEN=
# S3-compatible storage fallback. Database-configured storage takes precedence.
S3_ENDPOINT=
S3_REGION=auto
S3_BUCKET=
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
S3_PUBLIC_URL=
# -----------------------------------------------------------------------------
# AI Providers
# -----------------------------------------------------------------------------
AI_GATEWAY_API_KEY=
NEXT_PUBLIC_DEFAULT_CHAT_MODEL=
# DashScope / Qwen OpenAI-compatible endpoint.
DASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
DASHSCOPE_API_KEY=
# Third-party GPT-compatible provider.
GTP5_API_URL=
GTP5_API_KEY=
GTP5_MODEL=gpt-5-nano
# FAL image/video/realtime providers.
FAL_KEY=
FAL_API_KEY=
FAL_BASE_URL=https://fal.run
FAL_MODEL=
FAL_REALTIME_MODEL=
# Realtime Canvas fallback providers.
REPLICATE_API_TOKEN=
BFL_API_KEY=
BFL_KEY=
BFL_BASE_URL=
BFL_MODEL=
RUNWARE_MODEL=
# 3D / CAD providers.
TRIPO_API_KEY=
TRIPO_BASE_URL=https://api.tripo3d.ai/v2/openapi
WORLDLABS_API_KEY=
WORLDLABS_BASE_URL=
WORLDLABS_IMPORT_TOKEN=
WORLDLABS_KEY_POOL_IMPORT_TOKEN=
WORLDLABS_API_TIMEOUT_MS=180000
WORLDLABS_TASK_CREATE_TIMEOUT_MS=180000
WORLDLABS_TASK_STATUS_TIMEOUT_MS=180000
KITTYCAD_API_TOKEN=
KITTYCAD_API_KEY=
KITTYCAD_BASE_URL=https://api.zoo.dev
ZOO_API_TOKEN=
ZOO_BASE_URL=https://api.zoo.dev
# Pascal Editor self-hosted workspace. In local development, leaving this blank
# makes /pascal-editor auto-detect http://localhost:3002. For production or a
# fixed deployment, point this to your self-hosted Pascal service or static
# /vendor/pascal-editor/index.html bundle.
NEXT_PUBLIC_PASCAL_EDITOR_URL=
PASCAL_EDITOR_URL=
# Shared only by the main app, Nginx, and the private Pascal service. Generate a
# long random value; never expose it through NEXT_PUBLIC_* variables.
PASCAL_PROXY_SECRET=
# RUXA / video callback.
RUXA_CALLBACK_URL=
RUXA_VIDEO_CALLBACK_URL=
# -----------------------------------------------------------------------------
# Product Planning Integrations
# -----------------------------------------------------------------------------
PRODUCT_PLANNING_DETAIL_PLUS_BASE_URL=
PRODUCT_PLANNING_DETAIL_PLUS_INTERNAL_BASE_URL=
PRODUCT_PLANNING_DETAIL_PLUS_API_KEY=
PRODUCT_PLANNING_QA_TOKEN=
TAOBAO_API_KEY=
TAOBAO_API_SECRET=
TAOBAO_SALE_API_TOKEN=
PRODUCT_PLANNING_DB_HOST=
PRODUCT_PLANNING_DB_PORT=3306
PRODUCT_PLANNING_DB_DATABASE=
PRODUCT_PLANNING_DB_NAME=
PRODUCT_PLANNING_DB_USER=
PRODUCT_PLANNING_DB_PASSWORD=
PRODUCT_PLANNING_DB_CHARSET=utf8mb4
PRODUCT_PLANNING_DB_SSL=false
# -----------------------------------------------------------------------------
# Home Performance / Queues
# -----------------------------------------------------------------------------
CANVAS_IMAGE_TASK_CONCURRENCY=3
CANVAS_IMAGE_TASK_BATCH_DISPATCH_INTERVAL_MS=1000
EXTERNAL_IMAGE_API_MAX_CONCURRENT=3
EXTERNAL_IMAGE_API_MAX_QUEUE=20
EXTERNAL_IMAGE_API_QUEUE_TIMEOUT_MS=120000
# Browser direct uploads use signed OSS/S3 URLs. Configure CORS and lifecycle
# with the scripts below after changing the default storage bucket.
DIRECT_UPLOAD_CORS_ORIGINS=https://ai.yydd.ai,https://yydd.ai
DIRECT_UPLOAD_ABORT_INCOMPLETE_DAYS=2
DIRECT_UPLOAD_LIFECYCLE_PREFIX=uploads/direct/
DIRECT_UPLOAD_LIFECYCLE_RULE_ID=abort-incomplete-direct-upload-parts
# -----------------------------------------------------------------------------
# Agent / PSD Runtime
# -----------------------------------------------------------------------------
AGENT_FORCE_LLM_PLANNER=false
AGENT_FORCE_QWEN3VL_FALLBACK=false
AGENT_IMAGE_GEN_LLM_OPTIMIZER_ENABLED=true
AGENT_LLM_PROMPT_OPTIMIZER_ENABLED=true
AGENT_REPLACEMENT_PARALLEL_WORKER_COUNT=2
AGENT_PSD_LOCAL_CACHE_DIR=
AGENT_PSD_REMOTE_ASSET_CACHE_DISABLED=false
AGENT_PSD_REMOTE_ASSET_CACHE_MAX_BYTES=
AGENT_PSD_REMOTE_ASSET_CACHE_MAX_ENTRY_BYTES=
AGENT_PSD_REMOTE_ASSET_CACHE_TTL_MS=
AGENT_PSD_RENDER_PNG_COMPRESSION_LEVEL=6
AGENT_PSD_RENDER_TASK_CONCURRENCY=2
NEXT_PUBLIC_AGENT_STYLE_PERF_MONITOR=true
NEXT_PUBLIC_AGENT_STYLE_PERF_SAMPLE_RATE=0.01
NEXT_PUBLIC_AGENT_STYLE_OPTIMIZED_RENDER=true
# -----------------------------------------------------------------------------
# Logging / Observability
# -----------------------------------------------------------------------------
# debug | info | warn | error | silent
LOG_LEVEL=info
# Stack traces remain disabled in production unless explicitly enabled.
LOG_INCLUDE_STACK=false
# Client logs are silent in production unless this is true.
NEXT_PUBLIC_ENABLE_CLIENT_LOGS=false
# -----------------------------------------------------------------------------
# Optional Jobs
# -----------------------------------------------------------------------------
VIDEO_ARCHIVE_DELAY_HOURS=24
# -----------------------------------------------------------------------------
# Test / Local Dev
# -----------------------------------------------------------------------------
PORT=3001
PLAYWRIGHT=false
CI_PLAYWRIGHT=false
PLAYWRIGHT_TEST_BASE_URL=http://127.0.0.1:3001
SMOKE_BASE_URL=http://127.0.0.1:3001
SMOKE_TIMEOUT_MS=20000
DISABLE_PERF_GUARD_AUTO_INSTALL=false

35
.github/CODEOWNERS vendored Normal file
View File

@ -0,0 +1,35 @@
# Default owner for all changes.
* @lanmu1818-ship-it
# Repository governance and release safety.
/.github/ @lanmu1818-ship-it
/SECURITY.md @lanmu1818-ship-it
/CONTRIBUTING.md @lanmu1818-ship-it
/README.md @lanmu1818-ship-it
/docs/release-readiness.md @lanmu1818-ship-it
/docs/operations.md @lanmu1818-ship-it
/docs/deployment.md @lanmu1818-ship-it
# Core application surfaces.
/app/(admin)/settings/ @lanmu1818-ship-it
/app/(chat)/api/chat/ @lanmu1818-ship-it
/app/api/canvas/ @lanmu1818-ship-it
/components/canvas/ @lanmu1818-ship-it
/components/home/ @lanmu1818-ship-it
# Infrastructure, security, and data boundaries.
/lib/observability/ @lanmu1818-ship-it
/lib/security/ @lanmu1818-ship-it
/lib/storage/ @lanmu1818-ship-it
/lib/db/ @lanmu1818-ship-it
/lib/ai/ @lanmu1818-ship-it
/scripts/check-*.mjs @lanmu1818-ship-it
/scripts/check-*.ts @lanmu1818-ship-it
/scripts/security/ @lanmu1818-ship-it
# Quality gates and dependency manifests.
/package.json @lanmu1818-ship-it
/pnpm-lock.yaml @lanmu1818-ship-it
/tsconfig.json @lanmu1818-ship-it
/next.config.ts @lanmu1818-ship-it
/biome.jsonc @lanmu1818-ship-it

View File

@ -1,64 +1,67 @@
name: 🐛 Bug Report
description: Report something that isn't working correctly
name: Bug report
description: Report a reproducible product, API, or operations bug.
title: "[Bug]: "
labels: ["bug", "needs-triage"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to report a bug! Please fill out the sections below so we can reproduce and fix the issue.
Thanks for helping improve yedir-ai.
Do not include secrets, cookies, Authorization headers, OAuth codes, signed URLs, `.env.local` values, private prompts, base64 data, or private user content. If this is a security issue, do not open a public issue. Follow `SECURITY.md` instead.
- type: textarea
id: description
id: summary
attributes:
label: What happened?
description: A clear description of the bug.
placeholder: Describe what went wrong...
label: Summary
description: What happened, and what did you expect instead?
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to reproduce
description: How can we reproduce the issue?
label: Reproduction steps
description: Provide the smallest reliable reproduction.
placeholder: |
1. Go to '...'
2. Click on '...'
3. See error
1. Go to ...
2. Click ...
3. Observe ...
validations:
required: true
- type: textarea
id: expected
id: impact
attributes:
label: Expected behavior
description: What did you expect to happen?
label: Impact
description: Who is affected and how severe is it?
validations:
required: true
- type: input
id: browser
- type: textarea
id: environment
attributes:
label: Browser & OS
description: e.g. Chrome 120, macOS 14
placeholder: Chrome 120, macOS 14
validations:
required: false
label: Environment
description: Browser, OS, deployment target, Node/pnpm versions, or route/API involved.
placeholder: |
Browser:
OS:
Node:
pnpm:
Route/API:
- type: textarea
id: screenshots
id: logs
attributes:
label: Screenshots or screen recordings
description: |
A short screen recording is the fastest way to show us the bug.
Drag and drop a video file or paste a link — even a quick clip helps a lot.
validations:
required: false
label: Sanitized logs or screenshots
description: Include only sanitized output. Remove prompts, URLs, tokens, cookies, headers, base64, and private content.
- type: textarea
id: additional
- type: checkboxes
id: checks
attributes:
label: Additional context
description: Any other information that might be helpful (console errors, related issues, etc.)
validations:
required: false
label: Pre-submit checklist
options:
- label: I removed secrets, tokens, signed URLs, cookies, private prompts, and private user content.
required: true
- label: I searched existing issues and docs before filing this.
required: true

View File

@ -1,8 +1,11 @@
blank_issues_enabled: false
contact_links:
- name: 💬 Questions & Help
url: https://github.com/pascalorg/editor/discussions/categories/q-a
about: Ask questions and get help from the community
- name: 💡 Ideas & Discussion
url: https://github.com/pascalorg/editor/discussions/categories/ideas
about: Share ideas and discuss features before opening a formal request
- name: Security vulnerability
url: https://github.com/lanmu1818-ship-it/yedir-ai/blob/main/SECURITY.md
about: Do not open public issues for vulnerabilities. Follow SECURITY.md and report privately to the project maintainer.
- name: Release readiness and contribution rules
url: https://github.com/lanmu1818-ship-it/yedir-ai/blob/main/CONTRIBUTING.md
about: Read docs/release-readiness.md and CONTRIBUTING.md before opening broad engineering requests.
- name: Support and operations help
url: https://github.com/lanmu1818-ship-it/yedir-ai/blob/main/SUPPORT.md
about: Read SUPPORT.md for bug, feature, deployment, operations, and security support boundaries.

View File

@ -1,41 +1,68 @@
name: ✨ Feature Request
description: Suggest a new feature or improvement
name: Feature request
description: Propose a product, workflow, integration, or engineering improvement.
title: "[Feature]: "
labels: ["enhancement", "needs-triage"]
body:
- type: markdown
attributes:
value: |
We love hearing ideas from the community! Please describe what you'd like to see in Pascal Editor.
Please keep proposals focused and avoid sharing private customer data, prompts, screenshots with secrets, or provider credentials.
- type: textarea
id: problem
attributes:
label: Problem or motivation
description: What problem does this solve? Why do you want this?
placeholder: I'm always frustrated when...
label: Problem or opportunity
description: What user, operator, or developer problem does this solve?
validations:
required: true
- type: textarea
id: solution
id: proposal
attributes:
label: Proposed solution
description: How do you think this should work?
description: Describe the behavior, API, UI, or documentation change.
validations:
required: false
required: true
- type: dropdown
id: area
attributes:
label: Area
options:
- Lovart home
- Canvas / Agent
- Image or video generation
- 3D / CAD
- Settings / admin
- API / backend
- Performance
- Security / observability
- Documentation / CI
- Other
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives considered
description: Any workarounds or alternative approaches you've thought about?
validations:
required: false
description: What other approaches did you consider?
- type: textarea
id: additional
id: acceptance
attributes:
label: Additional context
description: Mockups, screenshots, references to other tools, etc.
validations:
required: false
label: Acceptance criteria
description: How should reviewers verify that this is done?
placeholder: |
- ...
- ...
- type: checkboxes
id: checks
attributes:
label: Pre-submit checklist
options:
- label: This request does not include real secrets, private prompts, signed URLs, or private user data.
required: true
- label: I checked the README, release readiness notes, and existing issues first.
required: true

42
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View File

@ -0,0 +1,42 @@
## Summary
<!-- What changed, and why? Keep this focused on user/business impact. -->
## Scope
- [ ] UI / product behavior
- [ ] API / backend logic
- [ ] Settings / admin configuration
- [ ] Performance / caching
- [ ] Logging / security
- [ ] Documentation / CI / repository hygiene
## Required Verification
- [ ] `pnpm check:branch`
- [ ] `pnpm check:quality`
- [ ] `pnpm build`
- [ ] `AUTH_TRUST_HOST=true PORT=3001 pnpm dev`
- [ ] `SMOKE_BASE_URL=http://127.0.0.1:3001 pnpm smoke:routes`
If any required check was not run, explain why:
```text
```
## Release Readiness
- [ ] No real secrets, OAuth codes, cookies, Authorization headers, signed URLs, or `.env.local` values are included.
- [ ] No tracked local artifacts such as `.DS_Store`, swap files, `.claude/`, migration backups, or `tsconfig.tsbuildinfo`.
- [ ] No new public UI links point to `/classic` or `/chat/[id]`.
- [ ] `/api/chat` remains treated as an internal Canvas/Agent generation API unless this PR explicitly migrates callers.
- [ ] New or modified high-risk logging uses `createLogger` / `createClientLogger` and is covered by `pnpm check:logs` when applicable.
## Screenshots / Evidence
<!-- Add screenshots, route smoke output, CI links, or notes for reviewers. -->
## Follow-ups
<!-- List intentional non-blocking follow-ups, if any. -->

37
.github/dependabot.yml vendored Normal file
View File

@ -0,0 +1,37 @@
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
time: "09:00"
timezone: "Asia/Shanghai"
open-pull-requests-limit: 5
labels:
- "dependencies"
- "needs-triage"
commit-message:
prefix: "chore"
include: "scope"
groups:
minor-and-patch:
update-types:
- "minor"
- "patch"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
time: "09:30"
timezone: "Asia/Shanghai"
open-pull-requests-limit: 3
labels:
- "dependencies"
- "ci"
- "needs-triage"
commit-message:
prefix: "ci"
include: "scope"

45
.github/workflows/build.yml vendored Normal file
View File

@ -0,0 +1,45 @@
name: Production Build
on:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: build-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-22.04
timeout-minutes: 25
env:
AUTH_SECRET: ci-build-placeholder-secret
AUTH_URL: http://localhost:3000
NEXTAUTH_URL: http://localhost:3000
NEXT_PUBLIC_APP_URL: http://localhost:3000
POSTGRES_URL: postgres://user:password@localhost:5432/yedir_ai
DISABLE_PERF_GUARD_AUTO_INSTALL: "1"
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9.12.3
run_install: false
- name: Use Node.js 20
uses: actions/setup-node@v4
with:
node-version: 20
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run production build
run: pnpm build

38
.github/workflows/dependency-audit.yml vendored Normal file
View File

@ -0,0 +1,38 @@
name: Dependency Audit
on:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: dependency-audit-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
audit:
runs-on: ubuntu-22.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9.12.3
run_install: false
- name: Use Node.js 20
uses: actions/setup-node@v4
with:
node-version: 20
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Audit high and critical dependency advisories
run: pnpm audit:security

38
.github/workflows/lint.yml vendored Normal file
View File

@ -0,0 +1,38 @@
name: Quality Gates
on:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: quality-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
quality:
runs-on: ubuntu-22.04
timeout-minutes: 20
strategy:
matrix:
node-version: [20]
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9.12.3
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run quality gate
run: pnpm check:quality

60
.github/workflows/playwright.yml vendored Normal file
View File

@ -0,0 +1,60 @@
name: Playwright Tests
on:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: playwright-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
timeout-minutes: 30
runs-on: ubuntu-22.04
env:
AUTH_SECRET: ${{ secrets.AUTH_SECRET }}
POSTGRES_URL: ${{ secrets.POSTGRES_URL }}
BLOB_READ_WRITE_TOKEN: ${{ secrets.BLOB_READ_WRITE_TOKEN }}
REDIS_URL: ${{ secrets.REDIS_URL }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "pnpm"
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9.12.3
run_install: false
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Cache Playwright browsers
uses: actions/cache@v4
id: playwright-cache
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('**/pnpm-lock.yaml') }}
- name: Install Playwright Browsers
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: pnpm exec playwright install --with-deps chromium
- name: Run Playwright tests
run: pnpm test
- uses: actions/upload-artifact@v4
if: always() && !cancelled()
with:
name: playwright-report
path: playwright-report/
retention-days: 7

118
.gitignore vendored
View File

@ -1,56 +1,102 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# Dependencies
# dependencies
node_modules
package-lock.json
.pnp
.pnp.js
.pnpm-store
# Local env files
.env
.env.local
.env.development.local
.env.test.local
.env.production
.env.production.local
# Testing
# testing
coverage
# Turbo
.turbo
# Supabase local state
supabase/.branches/
supabase/.temp/
# Vercel
.vercel
# Build Outputs
# next.js
.next/
.next-build/
.next-previous/
out/
build
dist
*.tsbuildinfo
# misc
.DS_Store
*.swp
*.pem
*.log
*.har
tsconfig.tsbuildinfo
# Debug
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# Misc
.DS_Store
*.pem
/.playwright-mcp
og-test
# local env files
.env.local
.env.development.local
.env.test.local
.env.production.local
# turbo
.turbo
.env
.vercel
.env*.local
.claude/
# Worktrees
.worktrees
# Playwright
/test-results/
/playwright-report/
/blob-report/
/playwright/*
deploy.tar.gz
libvips-8.14.5-linux-x64.tar.br
.temp-packages/
# Local IDE/agent configs
.claude/launch.json
.claude/settings.local.json
.vscode/launch.json
# 迁移备份文件
migration-backup-*/
migration-backup-complete/
*.dump
*.sql
*.zip
*.tar
*.tar.gz
*.tgz
migration-*.log
migration-*.txt
# 本地抓包/竞品参考产物,不能提交
artifacts/liblib-*/
analysis/qiaodou-extract/
# 模型文件
*.model
*.pth
*.pt
*.ckpt
*.safetensors
*.bin
models/
checkpoints/
*.onnx
*.h5
*.pb
*.tflite
*.mlmodel
# 第三方参考源码(不提交到git)
reference-sources/
# Legacy external Pascal Editor checkout (the managed source is under services/).
.pascal-editor/
# Local self-hosted Zoo Design Studio checkout.
.zoo-modeling-app/
# Generated official Zoo Design Studio web bundle.
public/vendor/zoo-modeling-app/
# Drizzle migrations must be versioned (override the global *.sql ignore above)
!lib/db/migrations/*.sql

3
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"recommendations": ["biomejs.biome"]
}

40
.vscode/settings.json vendored
View File

@ -1,41 +1,35 @@
{
"typescript.tsdk": "node_modules/typescript/lib",
"biome.configPath": "./biome.jsonc",
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": false,
"editor.formatOnPaste": true,
"emmet.showExpandedAbbreviation": "never",
"editor.codeActionsOnSave": {
"source.fixAll.biome": "always",
"quickfix.biome": "always",
"source.organizeImports.biome": "always",
"source.organizeImports": "never"
"editor.defaultFormatter": "esbenp.prettier-vscode",
"[javascript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[javascriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[json]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[javascript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[jsonc]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescriptreact]": {
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnPaste": false
},
"[javascriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[css]": {
"editor.defaultFormatter": "biomejs.biome"
},
"css.lint.unknownAtRules": "ignore",
"[graphql]": {
"editor.defaultFormatter": "biomejs.biome"
},
"evenBetterToml.formatter.allowedBlankLines": 4
"typescript.tsdk": "node_modules/typescript/lib",
"editor.formatOnSave": true,
"editor.formatOnPaste": true,
"emmet.showExpandedAbbreviation": "never",
"editor.codeActionsOnSave": {
"source.fixAll.biome": "explicit",
"source.organizeImports.biome": "explicit"
}
}

160
3001端口检查报告.md Normal file
View File

@ -0,0 +1,160 @@
# 3001 端口开放状态检查报告
## 检查时间
2025-12-04 14:10
## 检查结果
### ✅ 端口监听状态
- **状态**: 正常监听
- **监听地址**: `*:3001` (所有网络接口)
- **进程**: `next-server (v1)` (PID: 3704508)
- **说明**: 应用正在监听 3001 端口,可以从所有网络接口访问
### ✅ 应用状态
- **PM2 状态**: `online`
- **应用名称**: `ai-chatbot`
- **进程ID**: 3704508
- **运行时间**: 2小时
- **内存使用**: 166.3MB
### ✅ 端口访问测试
#### 1. 本地访问测试
- **localhost:3001**: ✅ 可访问
- **127.0.0.1:3001**: ✅ 可访问
- **响应**: 返回 `/api/auth/guest` 重定向(正常)
#### 2. 内网IP访问测试
- **192.168.150.22:3001**: ✅ 可访问
- **响应**: 返回 `/api/auth/guest` 重定向(正常)
### ✅ 防火墙状态
- **firewalld**: 未运行或未安装
- **ufw**: 未运行或未安装
- **iptables**:
- 未找到针对 3001 端口的特殊规则
- INPUT 链默认策略:允许(未限制)
### ✅ 端口开放结论
**3001 端口已完全开放,可以从以下方式访问:**
1. **本地访问**
- `http://localhost:3001`
- `http://127.0.0.1:3001`
2. **内网访问**
- `http://192.168.150.22:3001`
3. **通过FRPC穿透访问**
- `http://156.225.27.52:5245` (通过FRPC TCP穿透)
## 详细检查信息
### 端口监听详情
```
LISTEN 0 511 *:3001 *:* users:(("next-server (v1",pid=3704508,fd=19))
```
**说明**:
- `*:3001`: 监听所有网络接口的 3001 端口
- 这意味着可以从内网、外网(如果防火墙允许)访问
### 防火墙检查
#### iptables 规则
- 未找到针对 3001 端口的特殊限制规则
- INPUT 链默认允许所有连接
#### 防火墙服务
- 未发现运行中的防火墙服务(firewalld/ufw)
- 系统可能使用默认的 iptables 规则,且未限制 3001 端口
## 安全建议
### 当前状态
✅ **端口已开放**: 3001 端口可以从内网访问
### 建议配置
如果需要限制访问,可以考虑:
1. **仅允许内网访问**(推荐)
```bash
# 允许内网访问
iptables -A INPUT -p tcp --dport 3001 -s 192.168.150.0/24 -j ACCEPT
# 拒绝其他访问
iptables -A INPUT -p tcp --dport 3001 -j DROP
```
2. **使用防火墙服务管理**
```bash
# 如果使用 firewalld
firewall-cmd --permanent --add-port=3001/tcp
firewall-cmd --reload
```
3. **通过FRPC访问(当前方案)**
- 应用监听在 127.0.0.1:3001(仅本地)
- 通过 FRPC 穿透到外网端口 5245
- 这样更安全,不需要直接开放 3001 端口到外网
## 当前配置分析
### FRPC 配置
```ini
[ai-chatbot]
name = ai-chatbot
type = tcp
local_ip = 127.0.0.1
local_port = 3001
remote_port = 5245
```
**说明**:
- FRPC 配置使用 `127.0.0.1:3001`(本地回环)
- 但实际上应用监听在 `*:3001`(所有接口)
- 这没有问题,FRPC 可以从本地回环访问应用
### 建议优化
如果希望更安全,可以:
1. **修改应用监听地址**
- 让应用只监听 `127.0.0.1:3001`
- 这样只能从本地访问,更安全
2. **保持当前配置**
- 当前配置允许内网访问,方便调试和管理
- 通过 FRPC 提供外网访问
## 总结
✅ **3001 端口完全开放**
- 可以从本地访问
- 可以从内网访问(192.168.150.22:3001)
- 可以通过 FRPC 穿透访问(156.225.27.52:5245)
✅ **应用正常运行**
- PM2 管理,状态正常
- 响应正常,返回预期的重定向
✅ **防火墙未限制**
- 未发现运行中的防火墙服务
- iptables 未限制 3001 端口
---
**检查命令**:
```bash
# 检查端口监听
ss -tlnp | grep ':3001'
# 测试访问
curl http://192.168.150.22:3001
# 检查防火墙
iptables -L INPUT -n
```

262
AI_MODEL_CONFIG_GUIDE.md Normal file
View File

@ -0,0 +1,262 @@
# AI 模型和存储配置指南
## 功能概述
本系统实现了完整的 AI 模型配置和存储管理功能,无需修改 `.env` 文件即可在系统设置中动态配置。
## 核心功能
### 1. AI 提供商配置
支持配置多个 AI 服务提供商,包括:
- **阿里云通义千问** (Alibaba)
- **OpenAI 兼容接口** (OpenAI)
- **自定义提供商** (Custom)
**访问路径**: `/admin/settings` → AI 模型 → AI 提供商
**配置项**:
- 提供商名称:自定义名称,如"阿里云通义千问"
- 提供商类型:选择类型
- API 地址:提供商的 API 基础 URL(例如:https://dashscope.aliyuncs.com/compatible-mode/v1)
- API Key:访问令牌(不回显已保存的密钥)
- 启用状态:勾选启用此提供商
**API 端点**: `/api/admin/ai-providers`
- `GET` - 获取所有提供商列表
- `POST` - 创建新提供商
- `PUT` - 更新提供商配置
- `DELETE?id={id}` - 删除提供商
### 2. AI 模型配置
为每个提供商配置具体的模型,支持设置视觉能力和图片生成能力。
**访问路径**: `/admin/settings` → AI 模型 → AI 模型
**配置项**:
- 所属提供商:选择已配置的提供商
- 模型 ID:提供商中的模型标识(例如:qwen-turbo, qwen-vl-plus)
- 显示名称:用户界面上显示的名称
- 描述:模型的简要说明
- 排序顺序:确定前端显示顺序
- 支持图片输入:模型是否支持视觉(图片识别)
- 支持图片生成:模型是否支持图片生成
- 启用:模型是否可用
- 设为默认:标记为默认使用的模型
**API 端点**: `/api/admin/ai-models`
- `GET` - 获取模型列表(可选 `providerId` 筛选)
- `POST` - 创建新模型
- `PUT` - 更新模型配置
- `DELETE?id={id}` - 删除模型
**用户端 API**: `/api/models`
- `GET` - 获取已启用的模型列表(用于前端选择)
### 3. S3 存储配置
配置 S3 兼容的对象存储服务,用于图片上传和多媒体处理。
**访问路径**: `/admin/settings` → 存储配置
**配置项**:
- 配置名称:自定义名称
- 存储类型:S3 兼容存储或本地存储
- 端点:S3 服务端点 URL
- 区域:地区代码
- 存储桶:目标存储桶名称
- Access Key ID:访问凭证
- Secret Access Key:秘密凭证(不回显)
- 公开访问 URL 前缀:文件的公开访问地址前缀
- 启用:是否启用此配置
- 设为默认:优先使用的存储配置
**API 端点**: `/api/admin/storage`
- `GET` - 获取存储配置列表
- `POST` - 创建新配置
- `PUT` - 更新配置
- `DELETE?id={id}` - 删除配置
### 4. 前端模型选择
用户在聊天界面选择模型时,系统会:
1. 首先加载数据库中配置的动态模型
2. 如果没有配置,则显示预定义的静态模型
3. 优先显示标记为"默认"的模型
**调用流程**:
- 前端组件:`components/model-selector.tsx`
- 调用 API:`/api/models` 获取可用模型列表
- 支持视觉模型标签显示
- 支持默认模型标签显示
## 数据库结构
### AiProvider 表
```sql
CREATE TABLE "AiProvider" (
id uuid PRIMARY KEY,
name varchar(128) NOT NULL, -- 提供商名称
type varchar NOT NULL, -- 类型: alibaba, openai, custom
baseUrl text NOT NULL, -- API 基础 URL
apiKey text NOT NULL, -- API Key
isEnabled boolean DEFAULT true, -- 是否启用
createdAt timestamp DEFAULT now(),
updatedAt timestamp DEFAULT now(),
updatedBy uuid REFERENCES "User"(id)
);
```
### AiModel 表
```sql
CREATE TABLE "AiModel" (
id uuid PRIMARY KEY,
providerId uuid NOT NULL REFERENCES "AiProvider"(id) ON DELETE CASCADE,
modelId varchar(128) NOT NULL, -- 模型 ID
displayName varchar(128) NOT NULL, -- 显示名称
description text, -- 描述
supportsVision boolean DEFAULT false, -- 支持图片输入
supportsImageGen boolean DEFAULT false, -- 支持图片生成
isEnabled boolean DEFAULT true,
isDefault boolean DEFAULT false,
sortOrder json DEFAULT 0,
createdAt timestamp DEFAULT now(),
updatedAt timestamp DEFAULT now()
);
```
### StorageConfig 表
```sql
CREATE TABLE "StorageConfig" (
id uuid PRIMARY KEY,
name varchar(128) NOT NULL, -- 配置名称
type varchar DEFAULT 's3', -- 类型: s3, local
endpoint text, -- S3 端点
region varchar(64), -- 区域
bucket varchar(128), -- 存储桶
accessKeyId text, -- Access Key
secretAccessKey text, -- Secret Key
publicUrl text, -- 公开访问 URL 前缀
isDefault boolean DEFAULT false,
isEnabled boolean DEFAULT true,
createdAt timestamp DEFAULT now(),
updatedAt timestamp DEFAULT now(),
updatedBy uuid REFERENCES "User"(id)
);
```
## 配置优先级
### AI 模型使用顺序
1. **数据库配置优先** - 从 AiModel 表动态加载
2. **环境变量回退** - 使用 DASHSCOPE_API_KEY 等
3. **预定义模型** - 最后使用预定义的模型列表
### 存储服务优先级
1. **数据库配置** - 优先使用标记为默认的存储配置
2. **环境变量** - 使用 S3_* 系列环境变量
3. **Vercel Blob** - 最后回退到 Vercel Blob
## 实现示例
### 配置阿里云通义千问
1. **创建提供商**
```
名称: 阿里云通义千问
类型: Alibaba
API 地址: https://dashscope.aliyuncs.com/compatible-mode/v1
API Key: sk-xxxxxxxxxxxxxxxxxxxxxxxx
```
2. **创建模型**
```
提供商: 阿里云通义千问
模型 ID: qwen-turbo
显示名称: 通义千问 Turbo
支持图片输入: ☑
设为默认: ☑
```
### 配置 S3 存储
```
配置名称: 阿里云 OSS
类型: S3 兼容存储
端点: https://oss-cn-hangzhou.aliyuncs.com
区域: cn-hangzhou
存储桶: my-chatbot-bucket
Access Key ID: LTAI5t...
Secret Access Key: xxxxxxxxxxxxxxxx
公开访问 URL 前缀: https://my-chatbot-bucket.oss-cn-hangzhou.aliyuncs.com
设为默认: ☑
```
## 关键代码位置
### 后端核心文件
- `lib/db/schema.ts` - 数据库表定义
- `lib/db/queries.ts` - 数据库查询函数
- `lib/ai/dynamic-provider.ts` - 动态模型加载
- `app/(admin)/api/ai-providers/route.ts` - 提供商 API
- `app/(admin)/api/ai-models/route.ts` - 模型 API
- `app/(admin)/api/storage/route.ts` - 存储 API
- `app/(chat)/api/models/route.ts` - 前端模型列表 API
### 前端核心文件
- `components/model-selector.tsx` - 模型选择组件
- `app/(admin)/settings/admin-settings-client.tsx` - 管理设置页面
- `app/(chat)/api/files/upload/route.ts` - 文件上传处理
## 环境变量(可选)
虽然不再需要配置这些,但如果设置了环境变量,系统会作为回退使用:
```env
# AI 配置回退
DASHSCOPE_API_KEY=your_key
DASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
# S3 存储回退
S3_ENDPOINT=https://s3.amazonaws.com
S3_REGION=us-east-1
S3_BUCKET=my-bucket
S3_ACCESS_KEY_ID=your_access_key
S3_SECRET_ACCESS_KEY=your_secret_key
S3_PUBLIC_URL=https://my-bucket.s3.amazonaws.com
```
## 故障排除
### 模型不显示
- 检查 AiProvider 是否启用 (isEnabled = true)
- 检查 AiModel 是否启用 (isEnabled = true)
- 检查 API 密钥和地址是否正确
- 查看浏览器开发者工具中 `/api/models` 的响应
### 文件上传失败
- 确认 StorageConfig 已设置为默认 (isDefault = true)
- 验证 S3 凭证和端点
- 检查存储桶权限设置
### 默认模型不生效
- 确保只有一个模型设置为默认 (isDefault = true)
- 重启应用使缓存更新
- 检查前端是否正确读取 defaultModelId
## 安全考虑
- **API Key 保护** - API Key 在数据库中存储,不在响应中回显
- **权限控制** - 所有管理 API 需要 admin 权限
- **数据加密** - 建议在生产环境中加密存储的敏感信息
- **访问控制** - 限制对 `/admin/settings` 的访问
## 迁移说明
如果从旧版本升级:
1. 运行数据库迁移:`npm run db:migrate`
2. 访问管理设置重新配置 AI 模型和存储
3. 删除 `.env.local` 中的旧配置(可选)
4. 重启应用

421
AI供应商配置指南.md Normal file
View File

@ -0,0 +1,421 @@
# AI 供应商配置指南
## 概述
本系统支持多种 AI 供应商的动态配置,所有供应商都使用 **OpenAI 兼容的 API 格式**,但根据不同供应商的特点进行了优化。
## 支持的供应商类型
### 1. 阿里云 (alibaba)
**适用场景:** 通义千问系列模型(Qwen)
**配置示例:**
```json
{
"name": "阿里云通义千问",
"type": "alibaba",
"baseUrl": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"apiKey": "sk-xxxxxxxxxxxxxxxx"
}
```
**可用模型:**
- `qwen-turbo` - 快速响应,适合日常对话
- `qwen-plus` - 更强性能,适合复杂任务
- `qwen-max` - 最强性能
- `qwen-vl-plus` - 支持图像输入的多模态模型
- `qwen3-vl-plus` - 最新多模态模型
**特点:**
- 完全兼容 OpenAI Chat Completions API
- 支持流式响应 (stream: true)
- 支持 Function Calling
- 支持多模态输入(图片、视频)
**请求示例:**
```bash
POST https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions
Authorization: Bearer sk-xxxxxxxxxxxxxxxx
Content-Type: application/json
{
"model": "qwen-plus",
"messages": [
{"role": "system", "content": "你是一个有帮助的助手"},
{"role": "user", "content": "你好"}
],
"stream": true
}
```
---
### 2. OpenAI 官方 (openai)
**适用场景:** OpenAI 官方模型(GPT-4、GPT-3.5 等)
**配置示例:**
```json
{
"name": "OpenAI 官方",
"type": "openai",
"baseUrl": "https://api.openai.com/v1",
"apiKey": "sk-proj-xxxxxxxxxxxxxxxx"
}
```
**可用模型:**
- `gpt-4-turbo` - GPT-4 Turbo
- `gpt-4` - GPT-4
- `gpt-3.5-turbo` - GPT-3.5 Turbo
- `o1` - 推理模型
- `o1-mini` - 轻量推理模型
**特点:**
- 标准 OpenAI API
- 支持所有官方功能
- 支持 Function Calling、Vision、Audio
**请求示例:**
```bash
POST https://api.openai.com/v1/chat/completions
Authorization: Bearer sk-proj-xxxxxxxxxxxxxxxx
Content-Type: application/json
{
"model": "gpt-4-turbo",
"messages": [
{"role": "system", "content": "You are a helpful assistant"},
{"role": "user", "content": "Hello"}
]
}
```
---
### 3. OpenAI 兼容 (custom)
**适用场景:** 任何提供 OpenAI 兼容 API 的第三方服务
**配置示例:**
```json
{
"name": "GPT-5 Nano",
"type": "custom",
"baseUrl": "https://api.huandutech.com/v1",
"apiKey": "sk-xxxxxxxxxxxxxxxx"
}
```
**典型的兼容服务:**
- Claude via OpenRouter
- GPT-5 Nano 等第三方优化模型
- 本地部署的 OpenAI 兼容服务(如 vLLM、Text Generation WebUI)
- Azure OpenAI Service
- Cloudflare Workers AI
**请求格式:**
```bash
POST <baseUrl>/chat/completions
Authorization: Bearer <apiKey>
Content-Type: application/json
{
"model": "<model_name>",
"messages": [
{"role": "user", "content": "Hello"}
],
"stream": true // 可选
}
```
**兼容性要求:**
1. **必需端点:** `/chat/completions` 或 `/v1/chat/completions`
2. **必需请求头:** `Authorization: Bearer <apiKey>`
3. **请求格式:** 符合 OpenAI Chat Completions API 规范
4. **响应格式:** 返回标准 OpenAI 格式的 JSON 响应
**完整请求示例:**
```json
{
"model": "gpt-5-nano",
"messages": [
{
"role": "system",
"content": "你是一个有帮助的助手。"
},
{
"role": "user",
"content": "你能做什么?"
}
],
"temperature": 0.7,
"max_tokens": 2000,
"stream": true,
"top_p": 1,
"frequency_penalty": 0,
"presence_penalty": 0
}
```
---
## 系统设置配置步骤
### 1. 添加 AI 供应商
1. 登录管理员账户
2. 进入 **系统设置** → **AI 提供商**
3. 点击 **添加提供商**
4. 填写信息:
- **名称:** 供应商显示名称(如"阿里云通义千问")
- **类型:** 选择 alibaba / openai / custom
- **Base URL:** API 基础地址
- **API Key:** 您的 API 密钥
5. 点击 **保存**
### 2. 添加 AI 模型
1. 在 **系统设置** → **AI 模型** 中
2. 点击 **添加模型**
3. 填写信息:
- **供应商:** 选择刚创建的供应商
- **模型 ID:** 模型的实际标识符(如 `qwen-plus`、`gpt-4`)
- **显示名称:** 前端显示的名称(如"通义千问 Plus")
- **描述:** 模型的简要说明
- **支持视觉:** 是否支持图像输入
- **默认模型:** 是否设为系统默认
- **排序顺序:** 前端显示的顺序
4. 点击 **保存**
### 3. 测试配置
1. 返回聊天界面
2. 在模型选择器中应该能看到新配置的模型
3. 选择模型并发送测试消息
4. 查看服务器日志确认请求正常
---
## API 请求格式详解
所有供应商都遵循 OpenAI Chat Completions API 规范:
### 基本请求结构
```typescript
interface ChatCompletionRequest {
model: string; // 模型 ID
messages: Message[]; // 消息列表
temperature?: number; // 0-2,控制随机性
max_tokens?: number; // 最大生成 token 数
stream?: boolean; // 是否流式响应
top_p?: number; // 核采样参数
frequency_penalty?: number; // 频率惩罚
presence_penalty?: number; // 存在惩罚
stop?: string | string[]; // 停止序列
}
interface Message {
role: "system" | "user" | "assistant" | "tool";
content: string | ContentPart[];
name?: string;
tool_calls?: ToolCall[];
}
```
### 多模态输入(支持的模型)
```json
{
"model": "qwen-vl-plus",
"messages": [
{
"role": "user",
"content": [
{
"type": "text",
"text": "这张图片里有什么?"
},
{
"type": "image_url",
"image_url": {
"url": "https://example.com/image.jpg"
}
}
]
}
]
}
```
### Function Calling
```json
{
"model": "qwen-plus",
"messages": [
{
"role": "user",
"content": "波士顿今天的天气怎么样?"
}
],
"tools": [
{
"type": "function",
"function": {
"name": "get_current_weather",
"description": "获取指定位置的当前天气",
"parameters": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "城市和州,例如 San Francisco, CA"
}
},
"required": ["location"]
}
}
}
],
"tool_choice": "auto"
}
```
---
## 常见问题
### Q: 配置后前端没有显示新模型?
**A:** 请检查:
1. 模型是否启用(`isEnabled = true`)
2. 供应商是否启用(`isEnabled = true`)
3. 清除浏览器缓存和 Cookie
4. 硬刷新页面(Ctrl+Shift+R / Cmd+Shift+R)
### Q: 模型请求失败,返回 503 错误?
**A:** 可能原因:
1. API Key 无效或过期
2. Base URL 配置错误
3. 供应商服务不可用
4. 网络连接问题
**调试步骤:**
1. 检查服务器日志:`pm2 logs ai-chatbot`
2. 使用 curl 测试 API:
```bash
curl -X POST <baseUrl>/chat/completions \
-H "Authorization: Bearer <apiKey>" \
-H "Content-Type: application/json" \
-d '{"model":"<model>","messages":[{"role":"user","content":"test"}]}'
```
### Q: 阿里云模型和 OpenAI 模型有什么区别?
**A:**
- **API 格式:** 完全相同(OpenAI 兼容)
- **模型 ID:** 不同(qwen-plus vs gpt-4)
- **Base URL:** 不同(dashscope.aliyuncs.com vs api.openai.com)
- **功能支持:** 基本相同,部分高级功能可能有差异
### Q: 如何配置本地部署的模型?
**A:** 选择 **custom** 类型,配置:
```json
{
"type": "custom",
"baseUrl": "http://localhost:8000/v1",
"apiKey": "your-local-key"
}
```
确保本地服务提供 OpenAI 兼容的 API 接口。
---
## 技术实现说明
### 代码位置
- **动态 Provider:** `lib/ai/dynamic-provider.ts`
- **数据库 Schema:** `lib/db/schema.ts`
- **API 路由:** `app/api/admin/ai-providers/route.ts`、`app/api/admin/ai-models/route.ts`
- **前端组件:** `components/model-selector.tsx`、`components/multimodal-input.tsx`
### Provider 创建逻辑
```typescript
function getOrCreateProvider(provider: AiProvider) {
switch (provider.type) {
case "alibaba":
return createOpenAI({
apiKey: provider.apiKey,
baseURL: provider.baseUrl, // https://dashscope.aliyuncs.com/compatible-mode/v1
});
case "openai":
return createOpenAI({
apiKey: provider.apiKey,
baseURL: provider.baseUrl || "https://api.openai.com/v1",
});
case "custom":
default:
return createOpenAI({
apiKey: provider.apiKey,
baseURL: provider.baseUrl, // 任何 OpenAI 兼容的 URL
});
}
}
```
### 缓存机制
系统使用 `Map` 缓存已创建的 provider 实例,避免重复创建:
```typescript
const providerCache = new Map<string, ReturnType<typeof createOpenAI>>();
```
缓存键格式:`${providerId}-${baseUrl}`
---
## 最佳实践
1. **分类管理:** 为不同用途的模型创建不同的供应商配置(如"快速对话"、"深度思考")
2. **设置默认:** 为最常用的模型设置 `isDefault = true`
3. **排序优化:** 使用 `sortOrder` 控制前端显示顺序
4. **描述清晰:** 为每个模型添加清晰的描述,帮助用户选择
5. **定期更新:** 供应商可能推出新模型,及时更新配置
6. **监控使用:** 查看日志了解各模型的使用情况和错误率
---
## 安全建议
1. **API Key 保护:**
- 不要在前端代码中暴露 API Key
- API Key 存储在数据库中(建议加密)
- 定期轮换 API Key
2. **访问控制:**
- 只有管理员可以配置供应商和模型
- 使用 `requireAdmin()` 中间件保护 API 端点
3. **费用控制:**
- 设置合理的 `max_tokens` 限制
- 监控 API 使用量
- 考虑实现用户级别的配额限制
---
## 更新日志
- **2025-12-05:** 实现多供应商独立配置,支持阿里云、OpenAI、OpenAI兼容
- **未来计划:** 支持更多供应商类型(Claude、Gemini等)

View File

@ -0,0 +1,332 @@
# AI 生图平台桶缓存落地对接清单
> 交付对象:AI 生图平台负责人
> 当前主接口域名:`https://sc.jiansun.vip`
> 当前资源主域名:`https://s3.jiansun.vip`
> 备用资源域名:`https://s3.yydd.ai`
> 文档时间:`2026-03-18`
## 1. 这次要做的事情
计划把新入口服务器 `180.97.215.57` 上独立数据盘中的 `180G` 空间,用作 AI 生图平台相关资源的**读取缓存**。
这件事的目标是:
- 提升 AI 生图平台读取图片资源时的稳定性
- 降低反复回源到生产机的压力
- 让热点资源第二次及之后访问更快
## 2. 这次不会变的东西
下面这些默认都**不变**:
- 桶本体位置不变,真实存储仍在生产机 `192.168.58.20:9000`
- AI 生图平台的接口入口仍然是 `https://sc.jiansun.vip`
- 对外资源主域名仍然是 `https://s3.jiansun.vip`
- `https://s3.yydd.ai` 先保留为备用域名,不作为默认对接地址
- 这次做的是“读缓存”,不是改上传链路,也不是迁桶
一句话说清楚:
**缓存只是在入口服务器上多放一份“最近常用内容的副本”,不是把桶搬走。**
## 3. 当前已经确认的事实
当前资源入口链路是:
```text
s3.jiansun.vip
-> 180.97.215.57
-> nginx 磁盘缓存
-> 127.0.0.1:29000
-> FRP
-> 192.168.58.20:9000
```
当前缓存盘配置已经到位:
- 缓存目录:`/data/nginx-cache/s3`
- 缓存上限:`180g`
- 当前入口机资源盘:`/data`,总容量约 `200G`
`2026-03-18` 实测真实桶链路时,使用了一个约 `145MB` 的真实桶对象做下载测试:
- 直回源 `127.0.0.1:29000`:约 `23.2 MB/s`
- 走 `https://s3.jiansun.vip` 第一次请求:`X-Cache-Status: MISS`,约 `22.7 MB/s`
- 同一个 URL 第二次请求:`X-Cache-Status: HIT`,约 `63.2 MB/s`
这说明:
- 首次访问主要受回源链路影响
- 命中缓存后,速度会明显提升
基于当前已验证结果,可以先明确 3 个结论:
- 这套 `180G` 读缓存方案本身是可行的
- 它适合承接 AI 生图平台里“会被重复访问”的热点资源
- 在不改业务入口、不迁桶的前提下,就可以先做灰度验证
## 4. 我还需要 AI 生图平台这边提供什么
为了把这 `180G` 真正用于 AI 生图平台的热点流量,而不是“缓存了一堆不重要的文件”,还需要下面这些信息。
### 4.1 需要确认的资源范围
请提供:
- AI 生图平台当前实际会读取的资源类型
- 这些资源对应的 URL 样例
- 最常访问的路径前缀
最好直接给:
- 近 3 天最常访问的 20 个资源 URL 样例
- 主要路径前缀清单
例如是否主要集中在:
- 生图输入参考图
- 生图结果图
- 放大图、变体图、蒙版图、压缩包等固定路径前缀
### 4.2 需要确认是否只使用一个主资源域名
这个点非常关键。
请确认:
- AI 生图平台后续是否统一只读 `https://s3.jiansun.vip`
- `https://s3.yydd.ai` 是否只作为备用 / 回切,不参与日常默认调用
推荐口径:
- 主用:`https://s3.jiansun.vip`
- 备用:`https://s3.yydd.ai`
如果两个域名同时长期对外使用,那么**同一份内容就可能同时出现两个公网 URL**。
### 4.3 需要确认文件是否“同 key 覆盖”
这个点决定缓存能不能放心加大。
请确认:
- 同一张图后续会不会“覆盖原来的对象 key”
- 还是每次生成新图都使用新文件名 / 新路径
最推荐的方式是:
- **新内容永远用新 key**
比如:
- `image-20260318-001.png`
- `image-20260318-002.png`
不推荐的方式是:
- 总是写回同一个 URL
比如始终覆盖:
- `latest.png`
原因很简单:
- 如果同一个 URL 对应的文件内容被覆盖了,缓存里可能暂时还是旧版本
- 这样就会出现“URL 没变,但内容短时间还是旧图”的情况
### 4.4 需要确认时效要求
请确认:
- 图片更新后,允许多久内出现旧图
- 删除后,是否允许短时间还能访问
- 是否存在“必须秒级生效”的路径
如果某些资源要求非常高的实时性,就不能和普通静态资源用完全同一套缓存策略。
### 4.5 需要确认流量规模
请提供一个大致量级即可,不需要特别精确。
需要知道:
- 峰值并发大概多少
- 每分钟 / 每小时大概多少请求
- 热门图片是否会被大量重复读取
- 单张图通常多大
- 是否有明显的突发高峰时段
### 4.6 需要一批联调样本
请提供:
- 5 到 10 个真实可访问的 AI 生图平台资源 URL
- 最好覆盖“结果图 / 参考图 / 较大文件”几类
这样我可以直接按真实资源做灰度验证,而不是拿不相关对象代替。
### 4.7 这次不需要 AI 生图平台配合改的内容
为了避免沟通误差,这里也明确一下当前**不要求**你们先改的东西:
- 不要求先改接口域名,接口仍然走 `https://sc.jiansun.vip`
- 不要求先迁桶,桶本体仍然在 `192.168.58.20:9000`
- 不要求先切到 `s3.yydd.ai`
- 不要求先改上传链路
这次优先要拿到的是“真实读流量信息”,不是让业务先大改。
## 5. 对正常使用有没有影响
正常情况下,**对用户是无感的**。
前提是满足下面两个条件:
- 对外主资源域名统一使用 `https://s3.jiansun.vip`
- 资源尽量使用“新内容新 key”的方式,不要反复覆盖同一个 URL
只要做到这两点,缓存的效果就是:
- 第一次访问走回源
- 后续重复访问直接命中入口缓存
- 用户看到的还是同一个公网 URL
## 6. 会不会出现“同一张图两个 URL”
### 情况 1:缓存本身
**不会。**
缓存不会凭空再生成一个给用户使用的新 URL。
用户访问的仍然是原来的公网地址,比如:
```text
https://s3.jiansun.vip/xxx.png
```
缓存只是入口服务器内部把这个 URL 对应的内容存到磁盘里。
也就是说:
- 对用户看到的 URL 不变
- 对程序调用的 URL 不变
- 只是入口机内部多了一份缓存文件
### 情况 2:两个域名同时都在用
**会。**
如果下面两个地址同时长期对外使用:
- `https://s3.jiansun.vip/xxx.png`
- `https://s3.yydd.ai/xxx.png`
那么从“公网 URL”角度看,就是两条 URL 指向了同一份内容。
所以当前建议非常明确:
- `s3.jiansun.vip` 主用
- `s3.yydd.ai` 备用
不要两个一起作为默认入口使用。
## 7. 会不会出现“缓存里一张图,桶里又是另一个 URL”
从用户角度看,**不会多出一个可见 URL**。
但有一个需要特别注意的现实问题:
### 如果同一个 URL 被反复覆盖内容
那么就可能出现:
- 桶里已经是新图
- 缓存里短时间还是旧图
这时候不是“两个 URL”,而是:
- **同一个 URL,在不同时间点可能先返回旧内容,再返回新内容**
这也是为什么我强烈建议:
- 新图使用新 key
- 不要反复覆盖同一个对象地址
如果业务上确实必须覆盖同一个 URL,那就必须额外做:
- 清缓存机制
- 或单独降低这类路径的缓存时间
## 8. 推荐的最终口径
建议 AI 生图平台后续按下面这套规则执行:
### 域名规则
- 主用资源域名:`https://s3.jiansun.vip`
- 备用资源域名:`https://s3.yydd.ai`
### URL 规则
- 对外只认主域名,不混用两个资源域名
- 新内容尽量使用新 key,不覆盖老 key
### 缓存规则
- 把 AI 生图平台的热点读流量优先收敛到 `s3.jiansun.vip`
- 入口机 `180G` 只做读缓存,不改桶本体位置
## 9. 现在就可以推进的下一步
如果 AI 生图平台负责人能补齐下面 4 项,我就可以继续往下收口:
1. 确认主用资源域名是否统一为 `s3.jiansun.vip`
2. 提供 AI 生图平台真实会读取的路径前缀或 URL 样例
3. 明确是否存在“同 key 覆盖”
4. 提供一组真实联调 URL 用于灰度测试
拿到这些信息后,我这边会继续做:
1. 按真实热点路径确认是否需要“分路径缓存策略”
2. 区分哪些资源适合长缓存,哪些资源要短缓存
3. 如果存在“同 key 覆盖”,评估是否需要补清缓存机制
4. 用真实 URL 做灰度验证,再决定是否正式放量
## 10. 建议负责人直接按下面模板回复
为了节省来回沟通时间,建议 AI 生图平台负责人直接按这个格式回:
```text
1. 主用资源域名:
例如:统一使用 https://s3.jiansun.vip
2. 是否保留备用域名:
例如:保留 https://s3.yydd.ai,仅用于备用,不作为默认入口
3. 主要读取的资源类型:
例如:生图结果图、参考图、放大图、变体图、压缩包
4. 主要路径前缀或 URL 样例:
例如:/gongxiangpan/ai-output/... 或 /gongxiangpan/ai-input/...
5. 是否存在同 key 覆盖:
例如:有 / 没有
6. 时效要求:
例如:更新后允许 5 分钟内旧图;某些路径要求秒级生效
7. 流量规模:
例如:高峰每分钟约多少次读取,热门图片是否会被重复访问
8. 联调样本:
例如:提供 5 到 10 个真实可访问 URL
```
---
如果只要一句最短版本,可以直接转给对方:
**这次不是迁桶,只是在 `180.97.215.57` 上给 `s3.jiansun.vip` 做 180G 读缓存。要继续推进,需要 AI 生图平台确认主用域名、真实热点路径、是否会覆盖同一 URL、以及给一组联调样本。只要统一用一个主域名,并尽量做到新内容新 key,缓存对用户基本无感,也不会凭空多出第二个 URL。**

View File

@ -1,59 +1,45 @@
# Changelog
## 0.6.0 (2026-04-21)
All notable changes to yedir-ai will be documented in this file.
### Features
This project follows a pragmatic changelog format inspired by Keep a Changelog. Dates use ISO format.
- **Multi-surface material system** — per-surface materials for walls, stairs, roofs with click-targeted 3D editing ([#266](https://github.com/pascalorg/editor/pull/266)) by [@sudhir9297](https://github.com/sudhir9297)
- **Automatic wall-room generation** — closed wall loops auto-split and generate slabs ([#255](https://github.com/pascalorg/editor/pull/255), [#257](https://github.com/pascalorg/editor/pull/257)) by [@sudhir9297](https://github.com/sudhir9297)
- **Stair-slab integration** — stair-driven cutouts in slabs and ceilings, auto ceilings from wall loops
- **Curved fence support** + endpoint move tools ([#267](https://github.com/pascalorg/editor/pull/267)) by [@sudhir9297](https://github.com/sudhir9297)
- **13 material presets** — granite, marble, parquet, wallpaper, wood and more ([#231](https://github.com/pascalorg/editor/pull/231)) by [@sudhir9297](https://github.com/sudhir9297)
- **Export scene system** — GLB, STL, OBJ formats ([#203](https://github.com/pascalorg/editor/pull/203)) by [@zephran-dev](https://github.com/zephran-dev), with STL/OBJ groundwork by [@mvanhorn](https://github.com/mvanhorn) ([#175](https://github.com/pascalorg/editor/pull/175))
- **Street view / walkthrough mode** ([#173](https://github.com/pascalorg/editor/pull/173)) by [@Yashism](https://github.com/Yashism)
- **Duplicate project** ([#178](https://github.com/pascalorg/editor/pull/178)) by [@kleenkanteen](https://github.com/kleenkanteen)
- **Editable wall length slider** ([#195](https://github.com/pascalorg/editor/pull/195)) by [@zephran-dev](https://github.com/zephran-dev)
- **Infinity dragging slider** using PointerLock API ([#206](https://github.com/pascalorg/editor/pull/206)) by [@claygeo](https://github.com/claygeo)
- **Material system enhancements** ([#201](https://github.com/pascalorg/editor/pull/201)) by [@PMAT77](https://github.com/PMAT77)
- **Editor layout redesign v2** + 3D box select
- **Move/rotate building** + relative positioning for all tools
- **Grid snap toolbar controls**
- **Cut-out button** in floating action menu for slabs and ceilings
## Unreleased
### Fixes
### Added
- **WebGPU renderer** — await `renderer.init()` in Canvas GL factory ([#233](https://github.com/pascalorg/editor/pull/233)) by [@b9llach](https://github.com/b9llach)
- **WebGPU fallback** — skip post-processing when unavailable ([#234](https://github.com/pascalorg/editor/pull/234)) by [@b9llach](https://github.com/b9llach)
- **Crash on mode switch** — fix crash when switching to Furniture mode ([#237](https://github.com/pascalorg/editor/pull/237)) by [@txhno](https://github.com/txhno)
- **Crash on duplicate** — prevent crash when duplicating elements ([#239](https://github.com/pascalorg/editor/pull/239)) by [@nnhhoang](https://github.com/nnhhoang)
- **Delete walls/slabs** via floating action menu ([#180](https://github.com/pascalorg/editor/pull/180)) by [@nnhhoang](https://github.com/nnhhoang)
- **Counter-clockwise rotation** — T key for CCW rotation on selected nodes ([#184](https://github.com/pascalorg/editor/pull/184)) by [@nnhhoang](https://github.com/nnhhoang)
- **Scene singleton cleanup** — release singletons on Editor unmount ([#214](https://github.com/pascalorg/editor/pull/214)) by [@geopenta](https://github.com/geopenta)
- **State management & memory leaks** ([#152](https://github.com/pascalorg/editor/pull/152)) by [@hobostay](https://github.com/hobostay)
- **Ghost wall prevention** — use WALL_MIN_LENGTH constant ([#168](https://github.com/pascalorg/editor/pull/168)) by [@zephran-dev](https://github.com/zephran-dev)
- **Catalog image optimization** — add sizes and loading props ([#189](https://github.com/pascalorg/editor/pull/189)) by [@korvixhq](https://github.com/korvixhq)
- **Code cleanup** — remove unused `@ts-expect-error` directive ([#150](https://github.com/pascalorg/editor/pull/150)) by [@cs68614-hash](https://github.com/cs68614-hash)
- Robust undo/redo with nested history pause/resume
- Post-processing recovery after duplicate scene mutations
- Improved snapping across all geometry types
- Thumbnails, placement, and responsiveness improvements
- Stair elevation sync with floor slabs
- Lovart home release smoke script via `pnpm smoke:routes`.
- Unified local and CI quality gate via `pnpm check:quality`.
- Release readiness checklist, deployment guide, operations guide, architecture overview, security policy, contribution guide, support policy, code of conduct, PR template, issue templates, CODEOWNERS, and Dependabot configuration.
- Scoped log hygiene gate for high-risk server/client files.
- Route-level helper tests for Canvas chat helper behavior.
### Contributors
### Changed
A huge thank you to everyone who contributed to this release! 🎉
- `/` home now prioritizes a lightweight shell and progressively loads personalized data.
- `/classic` and `/chat/[id]` legacy UI routes redirect to `/`.
- `/api/chat` is documented and treated as an internal Canvas/Agent generation API.
- Admin settings local AI sections were modularized into focused components with shared section shells and types.
- Lovart home and Canvas/ImageNode presentation surfaces were split into more focused modules.
- Chat API internals were partially extracted into focused `_lib` helpers.
- CI now reuses the same quality gate command as local development.
- [@sudhir9297](https://github.com/sudhir9297) — material system, wall-room generation, curved walls, stairs, fences (7 PRs!)
- [@zephran-dev](https://github.com/zephran-dev) — export system, wall length slider, ghost wall fix
- [@nnhhoang](https://github.com/nnhhoang) — rotation controls, delete actions, crash fix
- [@b9llach](https://github.com/b9llach) — WebGPU renderer fixes
- [@txhno](https://github.com/txhno) — furniture mode crash fix
- [@Yashism](https://github.com/Yashism) — street view / walkthrough mode
- [@claygeo](https://github.com/claygeo) — infinity dragging slider
- [@geopenta](https://github.com/geopenta) — scene singleton cleanup
- [@kleenkanteen](https://github.com/kleenkanteen) — duplicate project feature
- [@mvanhorn](https://github.com/mvanhorn) — STL/OBJ export formats
- [@PMAT77](https://github.com/PMAT77) — material system enhancements
- [@korvixhq](https://github.com/korvixhq) — catalog image optimization
- [@hobostay](https://github.com/hobostay) — state management & memory leak fixes
- [@cs68614-hash](https://github.com/cs68614-hash) — code cleanup
### Security
- Production logging now uses structured redaction for storage/upload, auth, AI provider, Canvas, scene fusion, Pick Object, Tripo 3D, World Labs, and internal generation paths.
- Sensitive values such as tokens, cookies, Authorization headers, signed URLs, base64 payloads, prompts, provider responses, and private user content are documented as non-loggable/non-shareable.
- Tracked local artifact checks prevent `.env.local`, `.DS_Store`, swap files, `.claude/`, migration backups, and `tsconfig.tsbuildinfo` from entering Git.
- Security disclosure guidance is documented in `SECURITY.md`.
### Verification
- Current local release checks:
- `pnpm check:quality`
- `AUTH_TRUST_HOST=true PORT=3001 pnpm dev`
- `SMOKE_BASE_URL=http://127.0.0.1:3001 pnpm smoke:routes`
### Known Follow-ups
- Confirm remote GitHub Actions on a pushed PR.
- Continue optional log hygiene coverage for remaining historical image enhancement interfaces.
- Continue optional modularization of large Canvas/Agent historical modules.

31
CODE_OF_CONDUCT.md Normal file
View File

@ -0,0 +1,31 @@
# Code of Conduct
yedir-ai contributors, maintainers, users, and reviewers are expected to keep collaboration respectful, professional, and focused on improving the project.
## Expected Behavior
- Be respectful and constructive in issues, pull requests, reviews, and discussions.
- Assume good intent, but ask for clarification when something is unclear.
- Give actionable feedback with context and concrete examples.
- Respect privacy and confidentiality. Do not post secrets, private user data, private prompts, signed URLs, screenshots with credentials, or sensitive business information.
- Keep disagreements technical and evidence-based.
## Unacceptable Behavior
- Harassment, intimidation, discrimination, personal attacks, or abusive language.
- Publishing private information without explicit permission.
- Posting real credentials, tokens, cookies, OAuth codes, Authorization headers, signed URLs, or private user content.
- Deliberately disrupting discussions, reviews, CI, or project operations.
- Ignoring maintainer requests to move security-sensitive details to a private channel.
## Security-Sensitive Reports
Do not report vulnerabilities in public issues. Follow `SECURITY.md` and contact the project maintainer privately.
## Enforcement
Maintainers may edit or remove comments, close issues, block users, or restrict participation when behavior violates this code of conduct. Enforcement should be proportional, documented when appropriate, and focused on protecting contributors and users.
## Scope
This code applies to project spaces including GitHub issues, pull requests, discussions, documentation, community chat, and any other collaboration channel used for yedir-ai.

View File

@ -1,75 +1,69 @@
# Contributing to Pascal Editor
# Contributing to yedir-ai
Thanks for your interest in contributing! We welcome all kinds of contributions — bug fixes, new features, documentation, and ideas.
Thank you for contributing. yedir-ai is maintained as a production-oriented AI design workspace, so changes should be easy to review, safe to roll back, and supported by clear verification evidence.
## Getting started
## Development Setup
### Prerequisites
- [Bun](https://bun.sh/) 1.3+ (or Node.js 18+)
### Setup
Use Node.js 20 and pnpm 9.12.3.
```bash
git clone https://github.com/pascalorg/editor.git
cd editor
bun install
bun dev
pnpm install
cp .env.example .env.local
pnpm db:migrate
AUTH_TRUST_HOST=true PORT=3001 pnpm dev
```
The editor will be running at **http://localhost:3000**. That's it!
Never commit `.env.local` or real credentials.
### Optional
## Branches and Commits
Copy `.env.example` to `.env` and add a Google Maps API key if you want address search functionality. The editor works fully without it.
- Use focused branches for a single theme, such as `refactor/settings-local-ai` or `chore/log-hygiene-canvas`.
- Keep commits small enough to review and revert independently.
- Do not mix broad formatting changes with functional changes.
- Do not push generated local artifacts, worktrees, swap files, migration backups, or `tsconfig.tsbuildinfo`.
## Making changes
## Change Scope
### Code style
Prefer narrow, explicit scopes:
We use [Biome](https://biomejs.dev/) for linting and formatting. Before submitting a PR:
- UI changes should preserve existing product behavior unless the PR states otherwise.
- API changes must describe compatibility impact and response-shape changes.
- Settings changes must preserve existing setting keys, SWR keys, and save semantics unless a migration is included.
- Logging/security changes must avoid exposing prompts, URLs, base64 data, provider payloads, user content, tokens, cookies, or signed URLs.
`/api/chat` is currently an internal Canvas/Agent generation API. Do not treat it as a public legacy Chat UI contract unless the PR explicitly migrates callers.
## Required Checks
Before opening or updating a PR, run:
```bash
bun check # Check for issues
bun check:fix # Auto-fix issues
pnpm check:quality
AUTH_TRUST_HOST=true PORT=3001 pnpm dev
SMOKE_BASE_URL=http://127.0.0.1:3001 pnpm smoke:routes
```
### Project structure
`pnpm check:quality` covers lint, scoped log hygiene, typecheck, diff hygiene, and tracked artifact checks.
| Package | What it does |
|---------|-------------|
| `packages/core` | Scene schema, state management, systems — no UI |
| `packages/viewer` | 3D rendering with React Three Fiber |
| `apps/editor` | The full editor app (Next.js) |
If a route or workflow cannot be tested locally, explain the gap in the PR.
A key rule: **`packages/viewer` must never import from `apps/editor`**. The viewer is a standalone component; editor-specific behavior is injected via props/children.
## Pull Requests
### Building a plugin
Use the PR template and include:
New node kinds and sidebar panels can ship as a plugin instead of editing the built-ins. Read [`wiki/architecture/plugin-authoring.md`](wiki/architecture/plugin-authoring.md) for the contract, and copy [`packages/plugin-trees`](packages/plugin-trees) as a worked example.
- What changed and why.
- The user or operational impact.
- Verification output or screenshots when relevant.
- Any intentional follow-up work.
## Submitting a PR
CODEOWNERS is configured for governance, security, CI, settings, Canvas, API, and infrastructure paths. If your PR touches those areas, expect owner review before merge.
1. **Fork the repo** and create a branch from `main`
2. **Make your changes** and test locally with `bun dev`
3. **Run `bun check`** to make sure linting passes
4. **Open a PR** with a clear description of what changed and why
5. **Link related issues** if applicable (e.g., "Fixes #42")
For frontend changes, include screenshots or a short visual verification note. For route/API changes, include status codes, response-shape notes, or targeted test evidence.
### PR tips
## Security
- Keep PRs focused — one feature or fix per PR
- Include screenshots or recordings for visual changes
- If you're unsure about an approach, open an issue or discussion first
Follow `SECURITY.md` for vulnerability reporting and sensitive-data handling. Report suspected vulnerabilities privately; do not open public issues for secrets, auth bypasses, signed URL leaks, or data exposure.
## Reporting bugs
## Conduct
Use the [Bug Report](https://github.com/pascalorg/editor/issues/new?template=bug_report.yml) template. Include steps to reproduce — this helps us fix things faster.
## Suggesting features
Use the [Feature Request](https://github.com/pascalorg/editor/issues/new?template=feature_request.yml) template, or start a [Discussion](https://github.com/pascalorg/editor/discussions) if you want to brainstorm first.
## Questions?
Head to [Discussions](https://github.com/pascalorg/editor/discussions) — we're happy to help!
Follow `CODE_OF_CONDUCT.md` in issues, pull requests, reviews, and community spaces.

371
DATABASE_SCHEMA.md Normal file
View File

@ -0,0 +1,371 @@
# 数据库表结构说明
本文档描述项目使用的 PostgreSQL 数据库表及字段,便于配置数据库、排查问题或二次开发。
表名与代码中 `lib/db/schema.ts` 一致,迁移文件位于 `lib/db/migrations/`。
---
## 1. User(用户表)
| 字段名 | 类型 | 必填 | 默认 | 说明 |
|--------|------|------|------|------|
| id | uuid | 是 | defaultRandom() | 主键 |
| email | varchar(64) | 是 | - | 邮箱 |
| password | varchar(64) | 否 | - | 密码(可为空,如钉钉登录) |
| name | varchar(100) | 否 | - | 用户姓名(显示用) |
| department | varchar(256) | 否 | - | 部门 |
| dingtalkUserId | varchar(128) | 否 | - | 钉钉用户ID |
| role | varchar | 是 | 'regular' | 角色:guest / regular / admin |
| isActive | boolean | 是 | true | 是否启用 |
| monthlyImageLimit | integer | 否 | - | 每月出图限额,NULL/0 表示无限制 |
| createdAt | timestamp | 是 | now() | 创建时间 |
| updatedAt | timestamp | 是 | now() | 更新时间 |
---
## 2. Chat(会话表)
| 字段名 | 类型 | 必填 | 默认 | 说明 |
|--------|------|------|------|------|
| id | uuid | 是 | defaultRandom() | 主键 |
| createdAt | timestamp | 是 | - | 创建时间 |
| title | text | 是 | - | 会话标题 |
| userId | uuid | 是 | - | 用户ID,外键 → User.id |
| visibility | varchar | 是 | 'private' | 可见性:public / private |
| lastContext | jsonb | 否 | - | 上次使用上下文(AppUsage) |
---
## 3. Message(消息表,新)— 表名:Message_v2
| 字段名 | 类型 | 必填 | 默认 | 说明 |
|--------|------|------|------|------|
| id | uuid | 是 | defaultRandom() | 主键 |
| chatId | uuid | 是 | - | 会话ID,外键 → Chat.id |
| role | varchar | 是 | - | 角色(user/assistant 等) |
| parts | json | 是 | - | 消息内容片段 |
| attachments | json | 是 | - | 附件 |
| createdAt | timestamp | 是 | - | 创建时间 |
| video_storage_status | varchar(20) | 否 | 'temporary' | 视频存储状态 |
| temporary_video_url | text | 否 | - | 临时视频URL |
| permanent_video_url | text | 否 | - | 永久视频URL |
| archived_at | timestamp | 否 | - | 归档时间 |
| video_metadata | jsonb | 否 | - | 视频元数据 |
---
## 4. Message(旧版,已弃用)— 表名:Message
保留兼容,新逻辑请用 Message_v2。
| 字段名 | 类型 | 必填 | 默认 | 说明 |
|--------|------|------|------|------|
| id | uuid | 是 | defaultRandom() | 主键 |
| chatId | uuid | 是 | - | 会话ID |
| role | varchar | 是 | - | 角色 |
| content | json | 是 | - | 内容 |
| createdAt | timestamp | 是 | - | 创建时间 |
---
## 5. Vote_v2(投票表,新)
| 字段名 | 类型 | 必填 | 默认 | 说明 |
|--------|------|------|------|------|
| chatId | uuid | 是 | - | 会话ID,外键 → Chat.id |
| messageId | uuid | 是 | - | 消息ID,外键 → Message_v2.id |
| isUpvoted | boolean | 是 | - | 是否点赞 |
| (主键) | - | - | - | (chatId, messageId) 联合主键 |
---
## 6. Vote(旧版,已弃用)— 表名:Vote
| 字段名 | 类型 | 必填 | 默认 | 说明 |
|--------|------|------|------|------|
| chatId | uuid | 是 | - | 会话ID |
| messageId | uuid | 是 | - | 消息ID(旧 Message 表) |
| isUpvoted | boolean | 是 | - | 是否点赞 |
---
## 7. Document(文档表)
| 字段名 | 类型 | 必填 | 默认 | 说明 |
|--------|------|------|------|------|
| id | uuid | 是 | defaultRandom() | 主键之一 |
| createdAt | timestamp | 是 | - | 主键之一 |
| title | text | 是 | - | 标题 |
| content | text | 否 | - | 内容 |
| text | varchar | 是 | 'text' | 类型:text / code / image / sheet |
| userId | uuid | 是 | - | 用户ID,外键 → User.id |
主键:(id, createdAt)
---
## 8. Suggestion(建议表)
| 字段名 | 类型 | 必填 | 默认 | 说明 |
|--------|------|------|------|------|
| id | uuid | 是 | defaultRandom() | 主键 |
| documentId | uuid | 是 | - | 文档ID |
| documentCreatedAt | timestamp | 是 | - | 文档创建时间 |
| originalText | text | 是 | - | 原文 |
| suggestedText | text | 是 | - | 建议文本 |
| description | text | 否 | - | 描述 |
| isResolved | boolean | 是 | false | 是否已解决 |
| userId | uuid | 是 | - | 用户ID,外键 → User.id |
| createdAt | timestamp | 是 | - | 创建时间 |
---
## 9. Stream(流表)
| 字段名 | 类型 | 必填 | 默认 | 说明 |
|--------|------|------|------|------|
| id | uuid | 是 | defaultRandom() | 主键 |
| chatId | uuid | 是 | - | 会话ID,外键 → Chat.id |
| createdAt | timestamp | 是 | - | 创建时间 |
---
## 10. SystemSettings(系统设置表)
| 字段名 | 类型 | 必填 | 默认 | 说明 |
|--------|------|------|------|------|
| id | uuid | 是 | defaultRandom() | 主键 |
| key | varchar(128) | 是 | - | 配置键,唯一 |
| value | jsonb | 是 | - | 配置值 |
| updatedAt | timestamp | 是 | now() | 更新时间 |
| updatedBy | uuid | 否 | - | 更新人,外键 → User.id |
---
## 11. ToolConfig(工具配置表)
| 字段名 | 类型 | 必填 | 默认 | 说明 |
|--------|------|------|------|------|
| id | uuid | 是 | defaultRandom() | 主键 |
| toolName | varchar(128) | 是 | - | 工具名称,唯一 |
| isEnabled | boolean | 是 | true | 是否启用 |
| description | text | 否 | - | 描述 |
| config | jsonb | 否 | - | 配置 |
| updatedAt | timestamp | 是 | now() | 更新时间 |
| updatedBy | uuid | 否 | - | 更新人,外键 → User.id |
---
## 12. AiProvider(AI 提供商表)
| 字段名 | 类型 | 必填 | 默认 | 说明 |
|--------|------|------|------|------|
| id | uuid | 是 | defaultRandom() | 主键 |
| name | varchar(128) | 是 | - | 提供商名称 |
| type | varchar | 是 | 'custom' | 类型:alibaba / openai / custom / local / gemini / huanke-gemini / qianwen-video / volcano-vision / volcano-video / alibaba-image |
| baseUrl | text | 是 | - | API 基础 URL |
| apiKey | text | 是 | - | API Key |
| isEnabled | boolean | 是 | true | 是否启用 |
| createdAt | timestamp | 是 | now() | 创建时间 |
| updatedAt | timestamp | 是 | now() | 更新时间 |
| updatedBy | uuid | 否 | - | 更新人,外键 → User.id |
---
## 13. AiModel(AI 模型表)
| 字段名 | 类型 | 必填 | 默认 | 说明 |
|--------|------|------|------|------|
| id | uuid | 是 | defaultRandom() | 主键 |
| providerId | uuid | 是 | - | 提供商ID,外键 → AiProvider.id |
| modelId | varchar(128) | 是 | - | 模型ID(如 qwen-turbo) |
| displayName | varchar(128) | 是 | - | 显示名称 |
| description | text | 否 | - | 描述 |
| supportsVision | boolean | 是 | false | 是否支持视觉 |
| supportsImageGen | boolean | 是 | false | 是否支持生图 |
| isEnabled | boolean | 是 | true | 是否启用 |
| isDefault | boolean | 是 | false | 是否默认模型 |
| sortOrder | integer | 是 | 0 | 排序 |
| capabilities | jsonb | 否 | - | 能力配置(如视频) |
| defaultParams | jsonb | 否 | - | 默认参数 |
| is_sub_model | boolean | 是 | false | 是否子模型(不单独展示) |
| parent_model_id | uuid | 否 | - | 父模型ID |
| fallbackModelId1 | varchar(128) | 否 | - | 备用模型ID 1 |
| fallbackModelId2 | varchar(128) | 否 | - | 备用模型ID 2 |
| imageGenConfig | jsonb | 否 | - | 生图配置 |
| createdAt | timestamp | 是 | now() | 创建时间 |
| updatedAt | timestamp | 是 | now() | 更新时间 |
---
## 14. StorageConfig(存储配置表)
| 字段名 | 类型 | 必填 | 默认 | 说明 |
|--------|------|------|------|------|
| id | uuid | 是 | defaultRandom() | 主键 |
| name | varchar(128) | 是 | - | 配置名称 |
| type | varchar | 是 | 's3' | 类型:s3 / local |
| endpoint | text | 否 | - | S3 端点 |
| region | varchar(64) | 否 | - | 区域 |
| bucket | varchar(128) | 否 | - | 存储桶 |
| accessKeyId | text | 否 | - | Access Key ID |
| secretAccessKey | text | 否 | - | Secret Access Key |
| publicUrl | text | 否 | - | 公开访问 URL 前缀 |
| isDefault | boolean | 是 | false | 是否默认存储 |
| isEnabled | boolean | 是 | true | 是否启用 |
| createdAt | timestamp | 是 | now() | 创建时间 |
| updatedAt | timestamp | 是 | now() | 更新时间 |
| updatedBy | uuid | 否 | - | 更新人,外键 → User.id |
---
## 15. SiteSettings(网站设置表)
| 字段名 | 类型 | 必填 | 默认 | 说明 |
|--------|------|------|------|------|
| id | uuid | 是 | defaultRandom() | 主键 |
| siteName | varchar(128) | 是 | 'AI Chatbot' | 站点名称 |
| siteLogo | text | 否 | - | 站点 LOGO(日间) |
| siteLogoDark | text | 否 | - | 站点 LOGO(夜间) |
| favicon | text | 否 | - | favicon |
| showAutoModel | boolean | 是 | true | 是否显示「自动」模型 |
| defaultTheme | varchar(16) | 是 | 'system' | 默认主题:system / light / dark |
| usageTimeEnabled | boolean | 是 | false | 是否启用使用时间限制 |
| usageTimeStart | varchar(5) | 否 | '08:00' | 开始时间 HH:mm |
| usageTimeEnd | varchar(5) | 否 | '20:00' | 结束时间 HH:mm |
| usageTimeMessage | text | 否 | - | 超时提示文案 |
| bannerEnabled | boolean | 是 | true | 是否显示广告横幅 |
| bannerText | text | 否 | - | 横幅文案 |
| bannerLink | text | 否 | - | 横幅链接 |
| toolbox1Name | varchar(64) | 否 | '工具箱' | 工具箱1名称 |
| toolbox1Enabled | boolean | 是 | true | 工具箱1是否显示 |
| toolbox2Name | varchar(64) | 否 | '工具箱2' | 工具箱2名称 |
| toolbox2Enabled | boolean | 是 | false | 工具箱2是否显示 |
| tutorialVideoUrl | text | 否 | - | 教程视频链接 |
| tutorialEnabled | boolean | 是 | false | 是否显示教程 |
| rankingEnabled | boolean | 是 | true | 是否显示精选榜 |
| canvasEnabled | boolean | 是 | true | 是否启用无限画布 |
| canvasDefaultModelId | varchar(128) | 否 | - | 画布默认模型ID |
| canvasAutoModeDefaultModelId | varchar(128) | 否 | - | 画布智能自动模式默认模型ID |
| updatedAt | timestamp | 是 | now() | 更新时间 |
| updatedBy | uuid | 否 | - | 更新人,外键 → User.id |
---
## 16. NavLinks(导航链接表)
| 字段名 | 类型 | 必填 | 默认 | 说明 |
|--------|------|------|------|------|
| id | uuid | 是 | defaultRandom() | 主键 |
| name | varchar(64) | 是 | - | 名称 |
| url | text | 是 | - | 链接 |
| order | integer | 是 | 0 | 排序 |
| isEnabled | boolean | 是 | true | 是否启用 |
| toolboxGroup | integer | 是 | 1 | 工具箱分组:1 或 2 |
| createdAt | timestamp | 是 | now() | 创建时间 |
| updatedAt | timestamp | 是 | now() | 更新时间 |
| createdBy | uuid | 否 | - | 创建人,外键 → User.id |
---
## 17. TutorialVideos(教程视频表)
| 字段名 | 类型 | 必填 | 默认 | 说明 |
|--------|------|------|------|------|
| id | uuid | 是 | defaultRandom() | 主键 |
| title | varchar(128) | 是 | - | 标题 |
| videoUrl | text | 是 | - | 视频链接 |
| coverUrl | text | 否 | - | 封面图链接 |
| description | text | 否 | - | 描述 |
| order | integer | 是 | 0 | 排序 |
| isEnabled | boolean | 是 | true | 是否启用 |
| createdAt | timestamp | 是 | now() | 创建时间 |
| updatedAt | timestamp | 是 | now() | 更新时间 |
| createdBy | uuid | 否 | - | 创建人,外键 → User.id |
---
## 18. AuthSettings(认证配置表)
| 字段名 | 类型 | 必填 | 默认 | 说明 |
|--------|------|------|------|------|
| id | uuid | 是 | defaultRandom() | 主键 |
| dingTalkAppKey | text | 否 | - | 钉钉 AppKey |
| dingTalkAppSecret | text | 否 | - | 钉钉 AppSecret |
| dingTalkOrganizations | jsonb | 否 | - | 多组织钉钉配置 [{ name, appKey, appSecret }] |
| isDingTalkEnabled | boolean | 是 | false | 是否启用钉钉登录 |
| isRegistrationEnabled | boolean | 是 | true | 是否开放注册 |
| updatedAt | timestamp | 是 | now() | 更新时间 |
| updatedBy | uuid | 否 | - | 更新人,外键 → User.id |
---
## 19. LoginToken(临时登录令牌表)
| 字段名 | 类型 | 必填 | 默认 | 说明 |
|--------|------|------|------|------|
| id | uuid | 是 | defaultRandom() | 主键 |
| token | text | 是 | - | 令牌,唯一 |
| email | varchar(64) | 是 | - | 邮箱 |
| expiresAt | timestamp | 是 | - | 过期时间 |
| createdAt | timestamp | 是 | now() | 创建时间 |
---
## 20. GeneratedImage(生成图片记录表)
| 字段名 | 类型 | 必填 | 默认 | 说明 |
|--------|------|------|------|------|
| id | uuid | 是 | defaultRandom() | 主键 |
| userId | uuid | 是 | - | 用户ID,外键 → User.id |
| chatId | uuid | 否 | - | 会话ID,外键 → Chat.id |
| messageId | uuid | 否 | - | 消息ID |
| thumbnailUrl | text | 是 | - | 缩略图 URL |
| originalUrl | text | 是 | - | 原图 URL |
| imageSize | varchar(10) | 否 | '2K' | 图片尺寸 |
| prompt | text | 否 | - | 提示词 |
| modelId | varchar(128) | 否 | - | 模型ID |
| isFeatured | boolean | 是 | false | 是否精选 |
| featuredAt | timestamp | 否 | - | 精选时间 |
| featuredBy | uuid | 否 | - | 精选操作人,外键 → User.id |
| createdAt | timestamp | 是 | now() | 创建时间 |
---
## 21. CanvasProject(画布项目表)
| 字段名 | 类型 | 必填 | 默认 | 说明 |
|--------|------|------|------|------|
| id | uuid | 是 | defaultRandom() | 主键 |
| userId | uuid | 是 | - | 用户ID,外键 → User.id |
| title | varchar(256) | 是 | '未命名画布' | 标题 |
| description | text | 否 | - | 描述 |
| thumbnail | text | 否 | - | 缩略图 URL |
| canvasData | jsonb | 否 | - | 画布数据 { nodes, edges, viewport } |
| createdAt | timestamp | 是 | now() | 创建时间 |
| updatedAt | timestamp | 是 | now() | 更新时间 |
---
## 22. CanvasNode(画布节点表)
| 字段名 | 类型 | 必填 | 默认 | 说明 |
|--------|------|------|------|------|
| id | uuid | 是 | defaultRandom() | 主键 |
| projectId | uuid | 是 | - | 画布项目ID,外键 → CanvasProject.id |
| type | varchar(32) | 是 | - | 类型:message / image / video / text / scene-fusion |
| position | jsonb | 否 | - | 位置 { x, y } |
| size | jsonb | 否 | - | 尺寸 { width, height } |
| data | jsonb | 否 | - | 节点数据 |
| connections | jsonb | 否 | [] | 连接列表 |
| parentId | uuid | 否 | - | 父节点ID |
| createdAt | timestamp | 是 | now() | 创建时间 |
| updatedAt | timestamp | 是 | now() | 更新时间 |
---
## 使用说明
- **建表**:项目使用 Drizzle 迁移,首次部署请执行 `pnpm run db:migrate` 或项目内定义的迁移命令,表会按 `lib/db/migrations/` 中的 SQL 自动创建/更新。
- **连接**:数据库连接由环境变量配置(如 `POSTGRES_URL`),请勿将包含密码的 `.env` 提交到仓库。
- **系统配置**:部分功能依赖 `SystemSettings` 表中的 key(如 `scene_fusion_config`、站点相关 key),具体 key 名见代码中 `getSystemSetting` 的用法。

142
FRPC修复完成报告.md Normal file
View File

@ -0,0 +1,142 @@
# FRPC 修复完成报告
## 问题总结
### 原始问题
1. 访问 `http://156.225.27.52:5245/` 时端口号消失,返回 Nginx 404
2. FRPC 服务无法启动,一直处于重启循环
### 根本原因
1. **FRPC 配置错误**: `ai-chatbot` 配置使用了 HTTP 类型,但缺少 `custom_domains`
2. **其他代理配置错误**: 多个 HTTP 类型的代理(`http044`, `bt-panel` 等)缺少必需的 `custom_domains` 或 `subdomain` 配置
3. **配置冲突**: HTTP 类型配置不完整导致 FRPC 无法启动
## 修复方案
### 1. 将所有 HTTP 类型改为 TCP 类型
- HTTP 类型需要配置 `custom_domains` 或 `subdomain`,配置复杂
- TCP 类型更简单直接,适合端口转发场景
- 将所有代理配置统一改为 TCP 类型
### 2. 修复后的配置
```ini
[ai-chatbot]
name = ai-chatbot
type = tcp
local_ip = 127.0.0.1
local_port = 3001
remote_port = 5245
```
### 3. 修复的其他代理
- `http044`: SSH (5204) - 改为 TCP
- `bt-panel`: 宝塔面板 (5205) - 改为 TCP
- `ai-classification`: AI分类系统 (5206) - 改为 TCP
- `performance-api`: 性能API (5234) - 改为 TCP
- `qwen-api`: Qwen API (5236) - 改为 TCP
- `weipinghui-parser`: 微品汇解析器 (5238) - 改为 TCP
- `shebeiguanli-frontend`: 设备管理前端 (5243) - 改为 TCP
- `shebeiguanli-backend`: 设备管理后端 (5244) - 改为 TCP
## 修复结果
### ✅ FRPC 服务状态
- **状态**: `active (running)`
- **启动时间**: 2025-12-04 14:05:50
- **所有代理**: 全部启动成功
### ✅ ai-chatbot 代理状态
- **代理名称**: `ai-chatbot`
- **状态**: `start proxy success`
- **本地地址**: `127.0.0.1:3001`
- **远程端口**: `5245`
### ✅ 访问测试
- **访问地址**: `http://156.225.27.52:5245`
- **连接状态**: ✅ 成功连接
- **响应**: HTTP 307 重定向到 `/api/auth/guest`
- **说明**: 应用正常工作,返回了预期的认证重定向
## 当前状态
### 应用访问
- **外网访问**: `http://156.225.27.52:5245`
- **内网访问**: `http://192.168.150.22:3001`
- **状态**: ✅ 正常运行
### FRPC 服务
- **服务状态**: ✅ 运行中
- **配置文件**: `/etc/frp/frpc.ini`
- **备份文件**: `/etc/frp/frpc.ini.backup.before_fix`
### 应用服务
- **PM2 状态**: ✅ 运行中
- **进程名称**: `ai-chatbot`
- **监听端口**: `3001`
## 注意事项
### 1. 重定向 URL 问题
应用返回的重定向 URL 中包含 `localhost:3001`,这可能导致浏览器访问时出现问题。建议:
- 检查应用的重定向逻辑,确保使用正确的域名和端口
- 或者在应用配置中设置正确的 `NEXT_PUBLIC_BASE_URL`
### 2. 端口冲突
`shebeiguanli-backend` 和 `ai-chatbot` 都使用 3001 端口,但:
- `shebeiguanli-backend` 使用内网 IP `192.168.150.22:3001`
- `ai-chatbot` 使用本地回环 `127.0.0.1:3001`
- 目前没有冲突,但需要注意
### 3. 配置备份
- 原始配置已备份到: `/etc/frp/frpc.ini.backup.before_fix`
- 如需恢复,可以使用备份文件
## 验证命令
### 检查 FRPC 状态
```bash
ssh root@192.168.150.22
systemctl status frpc.service
```
### 查看 FRPC 日志
```bash
journalctl -u frpc.service -n 50 | grep ai-chatbot
```
### 测试本地应用
```bash
curl http://localhost:3001
```
### 测试外网访问
```bash
curl http://156.225.27.52:5245
```
## 后续优化建议
1. **应用配置优化**
- 设置正确的 `NEXT_PUBLIC_BASE_URL` 环境变量
- 修复重定向 URL 中的 localhost 问题
2. **监控和日志**
- 设置 FRPC 日志轮转
- 添加应用健康检查
3. **安全性**
- 考虑添加访问控制
- 配置 HTTPS(如果需要)
## 总结
✅ **问题已解决**: FRPC 服务正常运行,ai-chatbot 代理成功启动
✅ **访问正常**: 外网可以通过 `http://156.225.27.52:5245` 访问应用
✅ **配置已优化**: 所有代理配置统一使用 TCP 类型,更稳定可靠
---
**修复时间**: 2025-12-04 14:06
**修复方式**: 通过内网 SSH (192.168.150.22) 连接并修复配置

153
FRPC穿透配置说明.md Normal file
View File

@ -0,0 +1,153 @@
# FRPC 穿透配置说明
## 配置信息
### 穿透配置已成功添加
**配置名称**: `ai-chatbot`
**配置详情**:
- **本地地址**: `127.0.0.1:3001` (已修改,避免与3000端口冲突)
- **远程端口**: `5245`
- **穿透类型**: TCP
- **状态**: ✅ 已启动成功
### 访问方式
**通过穿透访问**:
- **穿透地址**: `156.225.27.52:5245`
- **访问URL**: `http://156.225.27.52:5245`
**直接访问**(如果服务器开放80端口):
- **直接地址**: `http://156.225.27.52`
### 配置位置
- **配置文件**: `/etc/frp/frpc.ini`
- **备份文件**: `/etc/frp/frpc.ini.backup`
- **服务状态**: `systemctl status frpc.service`
## 当前配置内容
```ini
[ai-chatbot]
name = ai-chatbot
type = tcp
local_ip = 127.0.0.1
local_port = 3001
remote_port = 5245
```
**注意**: 应用端口已从3000改为3001,因为3000端口被其他服务占用。
## 验证穿透
### 检查FRPC服务状态
```bash
# 查看服务状态
systemctl status frpc.service
# 查看日志
journalctl -u frpc.service -n 50 | grep ai-chatbot
# 查看所有代理状态
journalctl -u frpc.service -n 100 | grep "start proxy success"
```
### 测试连接
```bash
# 测试本地端口
curl http://localhost:3000
# 测试穿透端口(从外网)
curl http://156.225.27.52:5245
```
## 注意事项
1. **端口冲突**: 如果3000端口被其他服务占用,需要:
- 修改应用监听端口
- 或修改FRPC配置中的 `local_port`
2. **防火墙**: 确保FRP服务器端(156.225.27.52)开放了5245端口
3. **应用状态**: 确保应用正常运行在3000端口:
```bash
pm2 status ai-chatbot
pm2 logs ai-chatbot
```
## 修改配置
如果需要修改穿透配置:
```bash
# 1. 编辑配置文件
nano /etc/frp/frpc.ini
# 2. 修改配置后重启服务
systemctl restart frpc.service
# 3. 检查状态
systemctl status frpc.service
```
## 常用命令
```bash
# 查看FRPC配置
cat /etc/frp/frpc.ini
# 重启FRPC服务
systemctl restart frpc.service
# 查看FRPC日志
journalctl -u frpc.service -f
# 查看所有穿透代理
journalctl -u frpc.service | grep "start proxy success"
```
## 故障排查
### 如果穿透不工作
1. **检查应用是否运行**:
```bash
pm2 status ai-chatbot
```
2. **检查端口监听**:
```bash
ss -tlnp | grep 3000
```
3. **检查FRPC日志**:
```bash
journalctl -u frpc.service -n 50
```
4. **检查FRPC配置**:
```bash
/usr/local/bin/frpc -c /etc/frp/frpc.ini -L /tmp/frpc.log
```
5. **检查FRP服务器端端口**:
- 确保FRP服务器(156.225.27.52)的7000端口正常
- 确保远程端口5245未被占用
## 其他穿透配置
当前服务器还有其他穿透配置:
- `http044`: SSH (5204)
- `bt-panel`: 宝塔面板 (5205)
- `ai-classification`: AI分类系统 (5206)
- `performance-api`: 性能API (5234)
- `qwen-api`: Qwen API (5236)
- `weipinghui-parser`: 微品汇解析器 (5238)
- `shebeiguanli-frontend`: 设备管理前端 (5243)
- `shebeiguanli-backend`: 设备管理后端 (5244)
- `ai-chatbot`: AI聊天机器人 (5245) ✅ 新增

170
FRPC访问问题说明.md Normal file
View File

@ -0,0 +1,170 @@
# FRPC 访问问题说明
## 问题描述
访问 `http://156.225.27.52:5245/` 时:
- 浏览器地址栏变成 `156.225.27.52`(端口号消失)
- 返回 404 Not Found(Nginx)
## 问题分析
### 可能的原因
1. **FRPC 配置类型问题**
- 之前将配置改为 HTTP 类型,但 HTTP 类型需要通过 FRPS 的 HTTP 端口访问
- TCP 类型更适合直接端口访问
2. **Nginx 拦截请求**
- Nginx 的 `default_server` 可能拦截了未匹配的请求
- 5245 端口应该由 FRPS 监听,而不是 Nginx
3. **FRPS 服务端配置**
- FRPS 可能没有正确配置 5245 端口的 TCP 穿透
- 需要检查 FRPS 服务端的配置
4. **浏览器行为**
- 某些浏览器在遇到重定向时会去掉端口号
- 需要确保应用正确处理端口
## 解决方案
### 方案 1: 使用 TCP 类型(推荐)
TCP 类型穿透更简单直接,适合直接端口访问:
```ini
[ai-chatbot]
name = ai-chatbot
type = tcp
local_ip = 127.0.0.1
local_port = 3001
remote_port = 5245
```
**访问方式**: `http://156.225.27.52:5245/`
### 方案 2: 使用 HTTP 类型(需要 FRPS 支持)
如果使用 HTTP 类型,需要:
1. FRPS 配置 HTTP 端口(通常是 80 或 8080)
2. 通过 FRPS 的 HTTP 端口 + custom_domains 访问
3. 配置示例:
```ini
[ai-chatbot]
name = ai-chatbot
type = http
local_ip = 127.0.0.1
local_port = 3001
custom_domains = 156.225.27.52
```
**访问方式**: `http://156.225.27.52:FRPS_HTTP_PORT/`(不是 5245)
### 方案 3: 使用 Nginx 反向代理
如果 FRPS 不支持 HTTP 类型,可以:
1. 保持 FRPC 使用 TCP 类型
2. 配置 Nginx 反向代理到本地 3001 端口
3. 通过 Nginx 的 80 端口访问
## 修复步骤
### 自动修复
运行修复脚本:
```bash
./fix-frpc-access.sh
```
### 手动修复
1. **SSH 连接到服务器**
```bash
ssh -p 5204 root@156.225.27.52
```
2. **检查 FRPC 配置**
```bash
cat /etc/frp/frpc.ini | grep -A 6 'ai-chatbot'
```
3. **修复配置(使用 TCP 类型)**
```bash
# 备份配置
cp /etc/frp/frpc.ini /etc/frp/frpc.ini.backup
# 删除旧的 ai-chatbot 配置
sed -i '/\[ai-chatbot\]/,+6d' /etc/frp/frpc.ini
# 添加正确的 TCP 配置
cat >> /etc/frp/frpc.ini << 'EOF'
[ai-chatbot]
name = ai-chatbot
type = tcp
local_ip = 127.0.0.1
local_port = 3001
remote_port = 5245
EOF
```
4. **重启 FRPC 服务**
```bash
systemctl restart frpc.service
```
5. **检查服务状态**
```bash
systemctl status frpc.service
journalctl -u frpc.service -n 50 | grep ai-chatbot
```
6. **测试本地应用**
```bash
curl http://localhost:3001
```
## 检查清单
- [ ] FRPC 服务运行正常
- [ ] FRPC 配置使用 TCP 类型
- [ ] 应用运行在 3001 端口
- [ ] FRPS 服务端配置了 5245 端口
- [ ] 防火墙开放了 5245 端口
- [ ] 本地应用可以访问(curl http://localhost:3001)
## 常见问题
### Q: 为什么访问时端口号会消失?
A: 这可能是浏览器的行为,或者应用返回了重定向。检查应用的重定向逻辑,确保保留端口号。
### Q: 为什么返回 Nginx 404?
A: 说明请求被 Nginx 拦截了。检查:
1. FRPS 是否正确监听 5245 端口
2. Nginx 是否配置了 default_server 拦截所有请求
3. FRPC 配置是否正确
### Q: TCP 和 HTTP 类型有什么区别?
A:
- **TCP**: 直接端口转发,访问时使用 `http://IP:PORT`
- **HTTP**: 通过 FRPS 的 HTTP 端口访问,需要配置 custom_domains
### Q: 如何确认 FRPS 配置?
A: FRPS 配置通常在 FRP 服务端服务器上,需要检查:
1. FRPS 配置文件中的 `allow_ports` 是否包含 5245
2. FRPS 服务是否正常运行
3. FRPS 日志中是否有 ai-chatbot 的连接记录
## 下一步
1. 运行修复脚本或手动修复配置
2. 确认 FRPS 服务端配置正确
3. 测试访问 `http://156.225.27.52:5245/`
4. 如果仍有问题,检查 FRPS 服务端配置

View File

@ -0,0 +1,256 @@
# GEMINI生图二次对话4K/2K问题分析报告
## 🔍 问题描述
使用GEMINI模型生图时,在二次对话中选择4K生成的图片,但实际生成的是2K。
---
## 📋 代码流程分析
### 1. 图片参数保存机制
**`components/chat.tsx`** (第129-150行):
```typescript
// 🆕 从 localStorage 读取保存的图片参数
useEffect(() => {
try {
const saved = localStorage.getItem("gemini-image-params");
if (saved) {
setImageParams(JSON.parse(saved));
}
} catch (e) {
console.error("Failed to load image params:", e);
}
}, []);
// 🆕 保存图片参数到 localStorage
const handleImageParamsChange = (params: ImageGenerationParams) => {
setImageParams(params);
try {
localStorage.setItem("gemini-image-params", JSON.stringify(params));
} catch (e) {
console.error("Failed to save image params:", e);
}
console.log("[Chat] Image params updated:", params);
};
```
**结论**:✅ 图片参数会保存到 `localStorage`,并在组件加载时恢复。
---
### 2. 图片参数传递流程
**`components/chat.tsx`** (第262-263行):
```typescript
imageParams: imageParamsRef.current || undefined, // 🆕 传递图片参数(使用ref获取最新值)
```
**`app/(chat)/api/chat/route.ts`** (第663行):
```typescript
imageParams: requestBody.imageParams || { aspectRatio: "auto", imageSize: "2K" },
```
**`lib/ai/providers/gemini-image.ts`** (第621-624行):
```typescript
// 确保imageSize有值,优先级:用户选择 > 默认2K
const imageParams = {
aspectRatio: options.imageParams?.aspectRatio || "auto",
imageSize: options.imageParams?.imageSize || "2K"
};
```
**结论**:✅ 图片参数会从 `chat.tsx` → `chat/route.ts` → `gemini-image.ts` 传递。
---
### 3. ImageParamsBar 组件逻辑
**`components/image-params-bar.tsx`** (第77-88行):
```typescript
// 🆕 监听 initialParams 变化,确保状态同步
useEffect(() => {
console.log("[ImageParamsBar] Mount/Update - initialParams:", initialParams);
if (initialParams) {
setParams({
aspectRatio: "auto",
imageSize: "2K",
imageCount: 1,
...initialParams,
});
}
}, [initialParams]);
```
**问题发现**:⚠️ **如果 `initialParams` 是 `undefined` 或 `null`,这个 `useEffect` 不会执行,导致使用组件内部的默认值 `"2K"`。**
**组件内部默认值** (第58-63行):
```typescript
const [params, setParams] = useState<ImageGenerationParams>({
aspectRatio: "auto",
imageSize: "2K", // 默认 2K
imageCount: 1,
...initialParams,
});
```
---
## 🐛 问题根源
### 问题1:`ImageParamsBar` 的 `useEffect` 条件判断
**当前代码**:
```typescript
if (initialParams) {
setParams({
aspectRatio: "auto",
imageSize: "2K", // ⚠️ 如果 initialParams 没有 imageSize,会使用默认值 "2K"
imageCount: 1,
...initialParams,
});
}
```
**问题**:
- 如果 `initialParams` 是 `undefined` 或 `null`,`useEffect` 不会执行
- 如果 `initialParams` 存在但没有 `imageSize` 字段,会使用默认值 `"2K"`
### 问题2:`initialParams` 可能未正确传递
**`components/chat.tsx`** (第1012行):
```typescript
imageParams={imageParams}
```
**`components/multimodal-input.tsx`** (第884行):
```typescript
initialParams={imageParams || undefined}
```
**问题**:
- 如果 `imageParams` 是 `null`,会传递 `undefined`
- 如果 `imageParams` 是空对象 `{}`,`ImageParamsBar` 的 `useEffect` 会执行,但会使用默认值 `"2K"`
---
## ✅ 解决方案
### 方案1:修复 `ImageParamsBar` 的 `useEffect`
**修改前**:
```typescript
useEffect(() => {
console.log("[ImageParamsBar] Mount/Update - initialParams:", initialParams);
if (initialParams) {
setParams({
aspectRatio: "auto",
imageSize: "2K",
imageCount: 1,
...initialParams,
});
}
}, [initialParams]);
```
**修改后**:
```typescript
useEffect(() => {
console.log("[ImageParamsBar] Mount/Update - initialParams:", initialParams);
// 始终更新参数,即使 initialParams 是 undefined
setParams({
aspectRatio: "auto",
imageSize: "2K",
imageCount: 1,
...initialParams, // 如果 initialParams 是 undefined,会使用默认值
});
}, [initialParams]);
```
**优点**:
- 确保参数始终同步
- 即使 `initialParams` 是 `undefined`,也会使用默认值
**缺点**:
- 如果 `initialParams` 是 `undefined`,会覆盖用户的选择(但这是预期的行为)
### 方案2:确保 `imageParams` 始终有值
**修改 `components/chat.tsx`**:
```typescript
// 确保 imageParams 始终有值
const [imageParams, setImageParams] = useState<ImageGenerationParams>({
aspectRatio: "auto",
imageSize: "2K"
});
// 从 localStorage 读取保存的图片参数
useEffect(() => {
try {
const saved = localStorage.getItem("gemini-image-params");
if (saved) {
const parsed = JSON.parse(saved);
// 确保所有字段都有值
setImageParams({
aspectRatio: "auto",
imageSize: "2K",
imageCount: 1,
...parsed,
});
}
} catch (e) {
console.error("Failed to load image params:", e);
}
}, []);
```
**优点**:
- 确保 `imageParams` 始终有值
- 避免传递 `undefined` 或 `null`
---
## 📋 推荐修复方案
**同时修复两个问题**:
1. **修复 `ImageParamsBar` 的 `useEffect`**:移除 `if (initialParams)` 条件判断
2. **确保 `imageParams` 始终有值**:在 `chat.tsx` 中确保 `imageParams` 始终是对象
---
## 🔍 验证步骤
1. **第一次对话**:
- 选择4K
- 生成图片
- 检查 `localStorage.getItem("gemini-image-params")` 是否为 `{"imageSize":"4K",...}`
2. **二次对话**:
- 打开同一个聊天或新聊天
- 检查 `imageParams` 是否为 `{"imageSize":"4K",...}`
- 检查 `ImageParamsBar` 的 `initialParams` 是否为 `{"imageSize":"4K",...}`
- 检查生成的图片是否为4K
---
## 🎯 总结
**问题根源**:
- `ImageParamsBar` 的 `useEffect` 只在 `initialParams` 存在时执行
- 如果 `initialParams` 是 `undefined` 或 `null`,会使用组件内部的默认值 `"2K"`
**解决方案**:
- 移除 `if (initialParams)` 条件判断,确保参数始终同步
- 确保 `imageParams` 始终有值,避免传递 `undefined` 或 `null`

File diff suppressed because one or more lines are too long

28
LICENSE
View File

@ -1,21 +1,13 @@
MIT License
Copyright 2024 Vercel, Inc.
Copyright (c) 2026 Pascal Group Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
http://www.apache.org/licenses/LICENSE-2.0
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

525
README.md
View File

@ -1,429 +1,152 @@
# Pascal Editor
# yedir-ai
A 3D building editor built with React Three Fiber and WebGPU.
yedir-ai 是一个面向商业化交付的 AI 设计工作台。它把 Lovart 风格的新首页、无限画布、Agent 工作流、图像生成与编辑、3D/CAD、本地 AI 配置中心和后台治理能力整合到同一个 Next.js 应用中。
[![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
[![npm @pascal-app/core](https://img.shields.io/npm/v/@pascal-app/core?label=%40pascal-app%2Fcore)](https://www.npmjs.com/package/@pascal-app/core)
[![npm @pascal-app/viewer](https://img.shields.io/npm/v/@pascal-app/viewer?label=%40pascal-app%2Fviewer)](https://www.npmjs.com/package/@pascal-app/viewer)
[![Discord](https://img.shields.io/badge/Discord-Join%20Server-5865F2?logo=discord&logoColor=white)](https://discord.gg/SaBRA9t2)
[![X (Twitter)](https://img.shields.io/badge/follow-%40pascal__app-black?logo=x&logoColor=white)](https://x.com/pascal_app)
这个仓库已经不再定位为通用 Chat 模板。旧 Chat 页面入口已移除,`/api/chat` 目前保留为 Canvas/Agent 等内部生成链路使用。
https://github.com/user-attachments/assets/8b50e7cf-cebe-4579-9cf3-8786b35f7b6b
## 当前能力
- **Lovart 风格首页**:静态轻量壳优先渲染,站点配置、精选图、项目、教程和会员信息通过客户端渐进加载。
- **Canvas/Agent 工作台**:支持多模态生成、图片编辑、素材编排、Agent 模式和项目化创作流程。
- **图像与视频生成链路**:支持 Gemini、Volcano、Qwen、FAL、RUXA、Krea、Runway、Midjourney 等可配置供应商。
- **3D / CAD 能力**:集成 Tripo3D、World Labs、KittyCAD/Zoo,并支持后台模型别名和 Key 池配置。
- **后台设置中心**:本地 AI 设置已拆分为领域组件,支持折叠持久化、状态摘要、快捷目录和统一 section shell。
- **生产治理**:包含类型检查、lint、日志脱敏、首页公开接口缓存/降级、仓库污染物检查和 CI 质量门禁。
## 技术栈
## Repository Architecture
- Next.js 16 App Router
- React 19 RC
- TypeScript
- Tailwind CSS + Radix UI
- Drizzle ORM + Postgres
- Auth.js
- Vercel Blob / S3-compatible object storage
- Playwright
- Ultracite / Biome
This is a Turborepo monorepo with three main packages:
## 快速开始
```
editor/
├── apps/
│ └── editor/ # Next.js application
├── packages/
│ ├── core/ # Schema definitions, state management, systems
│ ├── viewer/ # 3D rendering components
│ └── ui/ # Shared UI components
```
### Separation of Concerns
| Package | Responsibility |
|---------|---------------|
| **@pascal-app/core** | Node schemas, scene state (Zustand), systems (geometry generation), spatial queries, event bus |
| **@pascal-app/viewer** | 3D rendering via React Three Fiber, default camera/controls, post-processing |
| **apps/editor** | UI components, tools, custom behaviors, editor-specific systems |
The **viewer** renders the scene with sensible defaults. The **editor** extends it with interactive tools, selection management, and editing capabilities.
### Stores
Each package has its own Zustand store for managing state:
| Store | Package | Responsibility |
|-------|---------|----------------|
| `useScene` | `@pascal-app/core` | Scene data: nodes, root IDs, dirty nodes, CRUD operations. Persisted to IndexedDB with undo/redo via Zundo. |
| `useViewer` | `@pascal-app/viewer` | Viewer state: current selection (building/level/zone IDs), level display mode (stacked/exploded/solo), camera mode. |
| `useEditor` | `apps/editor` | Editor state: active tool, structure layer visibility, panel states, editor-specific preferences. |
**Access patterns:**
```typescript
// Subscribe to state changes (React component)
const nodes = useScene((state) => state.nodes)
const levelId = useViewer((state) => state.selection.levelId)
const activeTool = useEditor((state) => state.tool)
// Access state outside React (callbacks, systems)
const node = useScene.getState().nodes[id]
useViewer.getState().setSelection({ levelId: 'level_123' })
```
---
## Core Concepts
### Nodes
Nodes are the data primitives that describe the 3D scene. All nodes extend `BaseNode`:
```typescript
BaseNode {
id: string // Auto-generated with type prefix (e.g., "wall_abc123")
type: string // Discriminator for type-safe handling
parentId: string | null // Parent node reference
visible: boolean
camera?: Camera // Optional saved camera position
metadata?: JSON // Arbitrary metadata (e.g., { isTransient: true })
}
```
**Node Hierarchy:**
```
Site
└── Building
└── Level
├── Wall → Item (doors, windows)
├── Slab
├── Ceiling → Item (lights)
├── Roof
├── Zone
├── Scan (3D reference)
└── Guide (2D reference)
```
Nodes are stored in a **flat dictionary** (`Record<id, Node>`), not a nested tree. Parent-child relationships are defined via `parentId` and `children` arrays.
---
### Scene State (Zustand Store)
The scene is managed by a Zustand store in `@pascal-app/core`:
```typescript
useScene.getState() = {
nodes: Record<id, AnyNode>, // All nodes
rootNodeIds: string[], // Top-level nodes (sites)
dirtyNodes: Set<string>, // Nodes pending system updates
createNode(node, parentId),
updateNode(id, updates),
deleteNode(id),
}
```
**Middleware:**
- **Persist** - Saves to IndexedDB (excludes transient nodes)
- **Temporal** (Zundo) - Undo/redo with 50-step history
---
### Scene Registry
The registry maps node IDs to their Three.js objects for fast lookup:
```typescript
sceneRegistry = {
nodes: Map<id, Object3D>, // ID → 3D object
byType: {
wall: Set<id>,
item: Set<id>,
zone: Set<id>,
// ...
}
}
```
Renderers register their refs using the `useRegistry` hook:
```tsx
const ref = useRef<Mesh>(null!)
useRegistry(node.id, 'wall', ref)
```
This allows systems to access 3D objects directly without traversing the scene graph.
---
### Node Renderers
Renderers are React components that create Three.js objects for each node type:
```
SceneRenderer
└── NodeRenderer (dispatches by type)
├── BuildingRenderer
├── LevelRenderer
├── WallRenderer
├── SlabRenderer
├── ZoneRenderer
├── ItemRenderer
└── ...
```
**Pattern:**
1. Renderer creates a placeholder mesh/group
2. Registers it with `useRegistry`
3. Systems update geometry based on node data
Example (simplified):
```tsx
const WallRenderer = ({ node }) => {
const ref = useRef<Mesh>(null!)
useRegistry(node.id, 'wall', ref)
return (
<mesh ref={ref}>
<boxGeometry args={[0, 0, 0]} /> {/* Replaced by WallSystem */}
<meshStandardMaterial />
{node.children.map(id => <NodeRenderer key={id} nodeId={id} />)}
</mesh>
)
}
```
---
### Systems
Systems are React components that run in the render loop (`useFrame`) to update geometry and transforms. They process **dirty nodes** marked by the store.
**Core Systems (in `@pascal-app/core`):**
| System | Responsibility |
|--------|---------------|
| `WallSystem` | Generates wall geometry with mitering and CSG cutouts for doors/windows |
| `SlabSystem` | Generates floor geometry from polygons |
| `CeilingSystem` | Generates ceiling geometry |
| `RoofSystem` | Generates roof geometry |
| `ItemSystem` | Positions items on walls, ceilings, or floors (slab elevation) |
**Viewer Systems (in `@pascal-app/viewer`):**
| System | Responsibility |
|--------|---------------|
| `LevelSystem` | Handles level visibility and vertical positioning (stacked/exploded/solo modes) |
| `ScanSystem` | Controls 3D scan visibility |
| `GuideSystem` | Controls guide image visibility |
**Processing Pattern:**
```typescript
useFrame(() => {
for (const id of dirtyNodes) {
const obj = sceneRegistry.nodes.get(id)
const node = useScene.getState().nodes[id]
// Update geometry, transforms, etc.
updateGeometry(obj, node)
dirtyNodes.delete(id)
}
})
```
---
### Dirty Nodes
When a node changes, it's marked as **dirty** in `useScene.getState().dirtyNodes`. Systems check this set each frame and only recompute geometry for dirty nodes.
```typescript
// Automatic: createNode, updateNode, deleteNode mark nodes dirty
useScene.getState().updateNode(wallId, { thickness: 0.2 })
// → wallId added to dirtyNodes
// → WallSystem regenerates geometry next frame
// → wallId removed from dirtyNodes
```
**Manual marking:**
```typescript
useScene.getState().dirtyNodes.add(wallId)
```
---
### Event Bus
Inter-component communication uses a typed event emitter (mitt):
```typescript
// Node events
emitter.on('wall:click', (event) => { ... })
emitter.on('item:enter', (event) => { ... })
emitter.on('zone:context-menu', (event) => { ... })
// Grid events (background)
emitter.on('grid:click', (event) => { ... })
// Event payload
NodeEvent {
node: AnyNode
position: [x, y, z]
localPosition: [x, y, z]
normal?: [x, y, z]
stopPropagation: () => void
}
```
---
### Spatial Grid Manager
Handles collision detection and placement validation:
```typescript
spatialGridManager.canPlaceOnFloor(levelId, position, dimensions, rotation)
spatialGridManager.canPlaceOnWall(wallId, t, height, dimensions)
spatialGridManager.getSlabElevationAt(levelId, x, z)
```
Used by item placement tools to validate positions and calculate slab elevations.
---
## Editor Architecture
The editor extends the viewer with:
### Tools
Tools are activated via the toolbar and handle user input for specific operations:
- **SelectTool** - Selection and manipulation
- **WallTool** - Draw walls
- **ZoneTool** - Create zones
- **ItemTool** - Place furniture/fixtures
- **SlabTool** - Create floor slabs
### Selection Manager
The editor uses a custom selection manager with hierarchical navigation:
```
Site → Building → Level → Zone → Items
```
Each depth level has its own selection strategy for hover/click behavior.
### Editor-Specific Systems
- `ZoneSystem` - Controls zone visibility based on level mode
- Custom camera controls with node focusing
---
## Data Flow
```
User Action (click, drag)
↓
Tool Handler
↓
useScene.createNode() / updateNode()
↓
Node added/updated in store
Node marked dirty
↓
React re-renders NodeRenderer
useRegistry() registers 3D object
↓
System detects dirty node (useFrame)
Updates geometry via sceneRegistry
Clears dirty flag
```
---
## Building a Plugin
The editor is extensible: a plugin ships node kinds (schema, 3D/2D rendering, placement tools, inspector parametrics) and left-rail panels through the same `Plugin` manifest the built-ins use — there is no separate internal API.
- **Contract reference** — [`wiki/architecture/plugin-authoring.md`](wiki/architecture/plugin-authoring.md): the `Plugin` shape, panel contributions, discovery (`setPluginDiscovery`), lifecycle, and what's in/out of v1.
- **Worked example** — [`packages/plugin-trees`](packages/plugin-trees): a first-party plugin (procedural trees, flowers, grass + a presets panel) structurally identical to a third-party pack. Copy it as a starting point.
---
## Technology Stack
- **React 19** + **Next.js 16**
- **Three.js** (WebGPU renderer)
- **React Three Fiber** + **Drei**
- **Zustand** (state management)
- **Zod** (schema validation)
- **Zundo** (undo/redo)
- **three-bvh-csg** (Boolean geometry operations)
- **Turborepo** (monorepo management)
- **Bun** (package manager)
---
## Getting Started
### Development
Run the development server from the **root directory** to enable hot reload for all packages:
准备 Node.js 20、pnpm 9.12.3 和 Postgres。
```bash
# Install dependencies
bun install
# Run development server (builds packages + starts editor with watch mode)
bun dev
# This will:
# 1. Build @pascal-app/core and @pascal-app/viewer
# 2. Start watching both packages for changes
# 3. Start the Next.js editor dev server
# Open http://localhost:3000
pnpm install
cp .env.example .env.local
pnpm db:migrate
AUTH_TRUST_HOST=true PORT=3001 pnpm dev
```
**Important:** Always run `bun dev` from the root directory to ensure the package watchers are running. This enables hot reload when you edit files in `packages/core/src/` or `packages/viewer/src/`.
默认访问地址:
### Building for Production
```text
http://localhost:3001/
```
后台设置页:
```text
http://localhost:3001/settings
```
未登录访问后台会重定向到 `/login?callbackUrl=%2Fsettings`。
## 环境变量
先从 `.env.example` 复制到 `.env.local`,再按部署环境填写真实值。不要提交 `.env.local` 或任何真实密钥。
最小本地运行通常需要:
- `AUTH_SECRET`
- `AUTH_URL` 或本地开发使用 `AUTH_TRUST_HOST=true`
- `POSTGRES_URL`
- `BLOB_READ_WRITE_TOKEN` 或一组 S3 配置
- 至少一个可用 AI Provider Key,或在后台设置中配置供应商
更多分组说明见 [docs/deployment.md](docs/deployment.md)。
## 常用命令
```bash
# Build all packages
turbo build
# Build specific package
turbo build --filter=@pascal-app/core
pnpm dev
pnpm lint
pnpm check:logs
pnpm check:brand
pnpm check:ci
pnpm check:branch
pnpm check:env
pnpm check:secrets
pnpm check:metadata
pnpm check:tests
pnpm test:helpers
pnpm check:artifacts
pnpm check:quality
pnpm clean:artifacts
pnpm audit:security
pnpm verify:release
pnpm exec tsc --noEmit --pretty false
pnpm test
pnpm build
pnpm build:with-migrate
pnpm db:migrate
pnpm db:generate
```
### Publishing Packages
推荐提交前至少运行:
```bash
# Build packages
turbo build --filter=@pascal-app/core --filter=@pascal-app/viewer
# Publish to npm
npm publish --workspace=@pascal-app/core --access public
npm publish --workspace=@pascal-app/viewer --access public
git fetch github main --prune
pnpm check:branch
pnpm check:quality
pnpm audit:security
pnpm build
AUTH_TRUST_HOST=true PORT=3001 pnpm dev
SMOKE_BASE_URL=http://127.0.0.1:3001 pnpm smoke:routes
```
---
发布或创建 PR 前可使用一条命令串起质量、安全、生产构建和路由烟测:
## Key Files
```bash
SMOKE_BASE_URL=http://127.0.0.1:3001 pnpm verify:release
```
| Path | Description |
|------|-------------|
| `packages/core/src/schema/` | Node type definitions (Zod schemas) |
| `packages/core/src/store/use-scene.ts` | Scene state store |
| `packages/core/src/hooks/scene-registry/` | 3D object registry |
| `packages/core/src/systems/` | Geometry generation systems |
| `packages/viewer/src/components/renderers/` | Node renderers |
| `packages/viewer/src/components/viewer/` | Main Viewer component |
| `apps/editor/components/tools/` | Editor tools |
| `apps/editor/store/` | Editor-specific state |
## 目录结构
---
```text
app/ Next.js routes, route handlers, admin pages
components/ UI, Canvas, home, editor and product modules
components/home/lovart/ Lovart home focused render sections
lib/ DB, AI providers, storage, observability, domain logic
docs/ Deployment, operations, architecture and feature notes
tests/ Playwright e2e and route-level tests
.github/workflows/ Manual quality gates, dependency audit and Playwright CI
```
## Contributors
## 部署与运维
<a href="https://github.com/Aymericr"><img src="https://avatars.githubusercontent.com/u/4444492?v=4" width="60" height="60" alt="Aymeric Rabot" style="border-radius:50%"></a>
<a href="https://github.com/wass08"><img src="https://avatars.githubusercontent.com/u/6551176?v=4" width="60" height="60" alt="Wassim Samad" style="border-radius:50%"></a>
<a href="https://github.com/sudhir9297"><img src="https://avatars.githubusercontent.com/sudhir9297?v=4" width="60" height="60" alt="Sudhir" style="border-radius:50%"></a>
- 生产部署建议使用受控反向代理,并只在 Host 头可信时设置 `AUTH_TRUST_HOST=true`。
- 首页公开接口应保持快速返回,慢数据库或外部服务异常时返回缓存或 fallback。
- 生产日志默认通过 `LOG_LEVEL=info` 控制,敏感字段会被 `lib/observability` 脱敏。
- GitHub Actions workflow 默认只允许手动触发,避免私有仓库免费额度被 push 自动消耗。
- Dependency Audit workflow 独立守住 high/critical 依赖审计基线,避免安全回归只靠人工发现。
- Playwright workflow 独立于基础质量门禁,避免把密钥依赖和 e2e 成本混入每次 lint/typecheck。
---
详细说明:
<a href="https://trendshift.io/repositories/23831" target="_blank"><img src="https://trendshift.io/api/badge/repositories/23831" alt="pascalorg/editor | Trendshift" width="250" height="55"/></a>
- [部署指南](docs/deployment.md)
- [运维与质量门禁](docs/operations.md)
- [系统架构说明](docs/architecture.md)
- [发布就绪检查清单](docs/release-readiness.md)
- [交付就绪审查摘要](docs/delivery-readiness-audit.md)
- [依赖风险登记](docs/dependency-risk-register.md)
- [安全策略](SECURITY.md)
- [贡献指南](CONTRIBUTING.md)
- [行为准则](CODE_OF_CONDUCT.md)
- [支持说明](SUPPORT.md)
- [更新日志](CHANGELOG.md)
## 开发边界
- `/classic` 和 `/chat/[id]` 旧 Chat UI 已重定向到 `/`。
- `/api/chat` 暂时是内部生成 API,不作为公开旧 Chat 页面入口。
- Canvas、Agent、图像处理和后台设置仍有大型历史模块,后续应按领域继续拆分。
- License 以仓库根目录 [LICENSE](LICENSE) 为准;本阶段未修改许可证和版权主体。

511
RMBG_API_文档.md Normal file
View File

@ -0,0 +1,511 @@
# RMBG-2.0 API 接口文档
## 服务信息
- **服务地址**: http://156.225.27.52:8000
- **API版本**: 2.0.0
- **模型**: RMBG-2.0 (背景移除模型)
- **框架**: FastAPI
- **API文档**: http://156.225.27.52:8000/docs
- **OpenAPI规范**: http://156.225.27.52:8000/openapi.json
- **API密钥**: 所有核心接口需要API密钥认证(详见下方认证说明)
---
## 认证方式
所有核心接口(`/rmbg/file` 和 `/rmbg/base64`)需要在请求头中添加API密钥:
**请求头**:
```
X-API-Key: rmbg_sk_live_7K9mP2nQ5rT8vW1xY4zA6bC9dE0fG3hI6jK9mL2nO5pQ8rS1tU4vW7xY0zA3bC6dE9fG
```
**注意**: 以下端点无需API密钥:
- `GET /` - 根端点(服务状态检查)
- `GET /health` - 健康检查
- `GET /api-key-info` - API密钥信息
---
## 接口列表
### 1. 根端点 - 服务状态检查
**接口地址**: `GET /`
**描述**: 检查服务是否正常运行
**请求示例**:
```bash
curl http://156.225.27.52:8000/
```
**响应示例**:
```json
{
"message": "RMBG-2.0 API 服务运行正常",
"version": "2.0.0",
"model": "RMBG-2.0"
}
```
---
### 2. 健康检查
**接口地址**: `GET /health`
**描述**: 检查服务健康状态和模型加载状态(无需API密钥)
**请求示例**:
```bash
curl http://156.225.27.52:8000/health
```
**响应示例**:
```json
{
"status": "healthy",
"model_loaded": true,
"model": "RMBG-2.0"
}
```
---
### 3. API密钥信息
**接口地址**: `GET /api-key-info`
**描述**: 获取API密钥使用说明(无需API密钥)
**请求示例**:
```bash
curl http://156.225.27.52:8000/api-key-info
```
**响应示例**:
```json
{
"api_key_name": "X-API-Key",
"description": "请在请求头中添加API密钥"
}
```
---
### 4. 文件上传背景移除
**接口地址**: `POST /rmbg/file`
**描述**: 通过文件上传方式移除图片背景(需要API密钥)
**Content-Type**: `multipart/form-data`
**请求头**:
```
X-API-Key: rmbg_sk_live_7K9mP2nQ5rT8vW1xY4zA6bC9dE0fG3hI6jK9mL2nO5pQ8rS1tU4vW7xY0zA3bC6dE9fG
```
**请求参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| file | File | 是 | 图片文件(支持常见图片格式) |
**请求示例 (cURL)**:
```bash
curl -X POST http://156.225.27.52:8000/rmbg/file \
-H "X-API-Key: rmbg_sk_live_7K9mP2nQ5rT8vW1xY4zA6bC9dE0fG3hI6jK9mL2nO5pQ8rS1tU4vW7xY0zA3bC6dE9fG" \
-F "file=@/path/to/your/image.jpg"
```
**请求示例 (Python)**:
```python
import requests
url = "http://156.225.27.52:8000/rmbg/file"
headers = {
"X-API-Key": "rmbg_sk_live_7K9mP2nQ5rT8vW1xY4zA6bC9dE0fG3hI6jK9mL2nO5pQ8rS1tU4vW7xY0zA3bC6dE9fG"
}
files = {"file": open("image.jpg", "rb")}
response = requests.post(url, files=files, headers=headers)
# 保存结果
with open("result.png", "wb") as f:
f.write(response.content)
print(f"处理时间: {response.headers.get('X-Processing-Time')} 秒")
```
**请求示例 (JavaScript/Node.js)**:
```javascript
const FormData = require('form-data');
const fs = require('fs');
const axios = require('axios');
const form = new FormData();
form.append('file', fs.createReadStream('image.jpg'));
axios.post('http://156.225.27.52:8000/rmbg/file', form, {
headers: {
...form.getHeaders(),
'X-API-Key': 'rmbg_sk_live_7K9mP2nQ5rT8vW1xY4zA6bC9dE0fG3hI6jK9mL2nO5pQ8rS1tU4vW7xY0zA3bC6dE9fG'
},
responseType: 'arraybuffer'
})
.then(response => {
fs.writeFileSync('result.png', response.data);
console.log('处理时间:', response.headers['x-processing-time'], '秒');
});
```
**响应**:
- **Content-Type**: `image/png`
- **Body**: PNG格式的图片二进制数据(背景已移除)
- **Headers**:
- `X-Processing-Time`: 处理时间(秒)
**错误响应**:
```json
{
"detail": "Invalid API key"
}
```
(当API密钥无效或缺失时)
或
```json
{
"detail": "只支持图片文件"
}
```
(当文件格式不支持时)
或
```json
{
"detail": "处理失败: [错误信息]"
}
```
(当处理失败时)
---
### 5. Base64 图片背景移除
**接口地址**: `POST /rmbg/base64`
**描述**: 通过Base64编码的图片数据移除背景(需要API密钥)
**Content-Type**: `application/json`
**请求头**:
```
X-API-Key: rmbg_sk_live_7K9mP2nQ5rT8vW1xY4zA6bC9dE0fG3hI6jK9mL2nO5pQ8rS1tU4vW7xY0zA3bC6dE9fG
```
**请求体**:
```json
{
"image_base64": "base64编码的图片字符串"
}
```
**请求参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| image_base64 | string | 是 | Base64编码的图片数据 |
**请求示例 (cURL)**:
```bash
curl -X POST http://156.225.27.52:8000/rmbg/base64 \
-H "Content-Type: application/json" \
-H "X-API-Key: rmbg_sk_live_7K9mP2nQ5rT8vW1xY4zA6bC9dE0fG3hI6jK9mL2nO5pQ8rS1tU4vW7xY0zA3bC6dE9fG" \
-d '{
"image_base64": "iVBORw0KGgoAAAANSUhEUgAA..."
}'
```
**请求示例 (Python)**:
```python
import requests
import base64
# 读取图片并转换为base64
with open("image.jpg", "rb") as f:
image_base64 = base64.b64encode(f.read()).decode('utf-8')
url = "http://156.225.27.52:8000/rmbg/base64"
headers = {
"Content-Type": "application/json",
"X-API-Key": "rmbg_sk_live_7K9mP2nQ5rT8vW1xY4zA6bC9dE0fG3hI6jK9mL2nO5pQ8rS1tU4vW7xY0zA3bC6dE9fG"
}
data = {"image_base64": image_base64}
response = requests.post(url, json=data, headers=headers)
result = response.json()
if result["success"]:
# 保存结果
result_image = base64.b64decode(result["image_base64"])
with open("result.png", "wb") as f:
f.write(result_image)
print(f"处理时间: {result['processing_time']} 秒")
```
**请求示例 (JavaScript)**:
```javascript
const fs = require('fs');
const axios = require('axios');
// 读取图片并转换为base64
const imageBuffer = fs.readFileSync('image.jpg');
const imageBase64 = imageBuffer.toString('base64');
axios.post('http://156.225.27.52:8000/rmbg/base64', {
image_base64: imageBase64
}, {
headers: {
'Content-Type': 'application/json',
'X-API-Key': 'rmbg_sk_live_7K9mP2nQ5rT8vW1xY4zA6bC9dE0fG3hI6jK9mL2nO5pQ8rS1tU4vW7xY0zA3bC6dE9fG'
}
})
.then(response => {
if (response.data.success) {
const resultImage = Buffer.from(response.data.image_base64, 'base64');
fs.writeFileSync('result.png', resultImage);
console.log('处理时间:', response.data.processing_time, '秒');
}
});
```
**响应示例**:
```json
{
"success": true,
"image_base64": "iVBORw0KGgoAAAANSUhEUgAA...",
"processing_time": 1.234
}
```
**响应参数**:
| 参数名 | 类型 | 说明 |
|--------|------|------|
| success | boolean | 是否成功 |
| image_base64 | string | Base64编码的结果图片(PNG格式) |
| processing_time | number | 处理时间(秒) |
**错误响应**:
```json
{
"detail": "Invalid API key"
}
```
(当API密钥无效或缺失时)
或
```json
{
"detail": "处理失败: [错误信息]"
}
```
(当处理失败时)
---
## 错误码说明
| HTTP状态码 | 说明 |
|-----------|------|
| 200 | 请求成功 |
| 400 | 请求参数错误(如文件格式不支持) |
| 401 | 未授权(API密钥无效或缺失) |
| 422 | 请求参数验证失败 |
| 500 | 服务器内部错误(如处理失败) |
---
## 注意事项
1. **API密钥认证**:
- 所有核心接口(`/rmbg/file` 和 `/rmbg/base64`)都需要在请求头中添加 `X-API-Key`
- API密钥:`rmbg_sk_live_7K9mP2nQ5rT8vW1xY4zA6bC9dE0fG3hI6jK9mL2nO5pQ8rS1tU4vW7xY0zA3bC6dE9fG`
- 健康检查和根端点无需密钥
2. **支持的图片格式**: 所有常见图片格式(JPG, PNG, JPEG等)
3. **输出格式**: 所有接口返回PNG格式的透明背景图片(RGBA模式)
4. **文件大小**: 建议图片大小不超过50MB
5. **处理时间**: 处理时间取决于图片大小和服务器负载,通常在0.5-5秒之间
6. **CORS**: 服务已配置CORS,支持跨域请求
---
## 完整使用示例
### Python完整示例
```python
import requests
import base64
from pathlib import Path
# 方法1: 文件上传
def remove_bg_file(image_path):
url = "http://156.225.27.52:8000/rmbg/file"
headers = {
"X-API-Key": "rmbg_sk_live_7K9mP2nQ5rT8vW1xY4zA6bC9dE0fG3hI6jK9mL2nO5pQ8rS1tU4vW7xY0zA3bC6dE9fG"
}
with open(image_path, "rb") as f:
files = {"file": f}
response = requests.post(url, files=files, headers=headers)
if response.status_code == 200:
output_path = Path(image_path).stem + "_nobg.png"
with open(output_path, "wb") as f:
f.write(response.content)
print(f"✅ 成功! 结果已保存到: {output_path}")
return output_path
else:
print(f"❌ 失败: {response.json()}")
return None
# 方法2: Base64
def remove_bg_base64(image_path):
url = "http://156.225.27.52:8000/rmbg/base64"
headers = {
"Content-Type": "application/json",
"X-API-Key": "rmbg_sk_live_7K9mP2nQ5rT8vW1xY4zA6bC9dE0fG3hI6jK9mL2nO5pQ8rS1tU4vW7xY0zA3bC6dE9fG"
}
with open(image_path, "rb") as f:
image_base64 = base64.b64encode(f.read()).decode('utf-8')
data = {"image_base64": image_base64}
response = requests.post(url, json=data, headers=headers)
if response.status_code == 200:
result = response.json()
if result["success"]:
output_path = Path(image_path).stem + "_nobg.png"
result_image = base64.b64decode(result["image_base64"])
with open(output_path, "wb") as f:
f.write(result_image)
print(f"✅ 成功! 处理时间: {result['processing_time']:.2f}秒")
return output_path
else:
print(f"❌ 失败: {response.json()}")
return None
# 使用示例
if __name__ == "__main__":
image_path = "test_image.jpg"
# 使用文件上传方式
remove_bg_file(image_path)
# 或使用Base64方式
# remove_bg_base64(image_path)
```
### JavaScript/Node.js完整示例
```javascript
const axios = require('axios');
const FormData = require('form-data');
const fs = require('fs');
const path = require('path');
// 方法1: 文件上传
async function removeBgFile(imagePath) {
const form = new FormData();
form.append('file', fs.createReadStream(imagePath));
try {
const response = await axios.post(
'http://156.225.27.52:8000/rmbg/file',
form,
{
headers: {
...form.getHeaders(),
'X-API-Key': 'rmbg_sk_live_7K9mP2nQ5rT8vW1xY4zA6bC9dE0fG3hI6jK9mL2nO5pQ8rS1tU4vW7xY0zA3bC6dE9fG'
},
responseType: 'arraybuffer'
}
);
const outputPath = path.parse(imagePath).name + '_nobg.png';
fs.writeFileSync(outputPath, response.data);
console.log(`✅ 成功! 结果已保存到: ${outputPath}`);
return outputPath;
} catch (error) {
console.error('❌ 失败:', error.response?.data || error.message);
return null;
}
}
// 方法2: Base64
async function removeBgBase64(imagePath) {
const imageBuffer = fs.readFileSync(imagePath);
const imageBase64 = imageBuffer.toString('base64');
try {
const response = await axios.post(
'http://156.225.27.52:8000/rmbg/base64',
{ image_base64: imageBase64 },
{
headers: {
'Content-Type': 'application/json',
'X-API-Key': 'rmbg_sk_live_7K9mP2nQ5rT8vW1xY4zA6bC9dE0fG3hI6jK9mL2nO5pQ8rS1tU4vW7xY0zA3bC6dE9fG'
}
}
);
if (response.data.success) {
const outputPath = path.parse(imagePath).name + '_nobg.png';
const resultImage = Buffer.from(response.data.image_base64, 'base64');
fs.writeFileSync(outputPath, resultImage);
console.log(`✅ 成功! 处理时间: ${response.data.processing_time.toFixed(2)}秒`);
return outputPath;
}
} catch (error) {
console.error('❌ 失败:', error.response?.data || error.message);
return null;
}
}
// 使用示例
(async () => {
const imagePath = 'test_image.jpg';
// 使用文件上传方式
await removeBgFile(imagePath);
// 或使用Base64方式
// await removeBgBase64(imagePath);
})();
```
---
## 交互式API文档
访问 http://156.225.27.52:8000/docs 可以在浏览器中查看交互式API文档(Swagger UI),支持在线测试接口。
**注意**: 在Swagger UI中测试需要API密钥的接口时,需要先点击右上角的"Authorize"按钮(如果有),或者直接在请求头中添加 `X-API-Key`。
---
## API密钥
当前API密钥:
```
rmbg_sk_live_7K9mP2nQ5rT8vW1xY4zA6bC9dE0fG3hI6jK9mL2nO5pQ8rS1tU4vW7xY0zA3bC6dE9fG
```
**请求头格式**:
```
X-API-Key: rmbg_sk_live_7K9mP2nQ5rT8vW1xY4zA6bC9dE0fG3hI6jK9mL2nO5pQ8rS1tU4vW7xY0zA3bC6dE9fG
```
**安全提示**:
- 请妥善保管API密钥,不要将其暴露在客户端代码中
- 建议通过环境变量或配置文件管理API密钥
- 定期更换API密钥以提高安全性

72
SECURITY.md Normal file
View File

@ -0,0 +1,72 @@
# Security Policy
Security is part of the release contract for yedir-ai. This project handles AI provider keys, OAuth credentials, object-storage URLs, user uploads, generated media, and internal generation workflows, so disclosures should be handled carefully and privately.
## Supported Versions
Security fixes are applied to the active `main` branch and release branches maintained by the project owner. Older experimental branches are not guaranteed to receive patches.
## Reporting a Vulnerability
Do not open a public issue for suspected vulnerabilities.
Report privately to the repository owner or project maintainer with:
- A short description of the issue and affected area.
- Reproduction steps or a minimal proof of concept.
- Potential impact, including whether secrets, user data, uploads, signed URLs, or internal APIs are exposed.
- Relevant logs with secrets, cookies, Authorization headers, OAuth codes, signed URLs, prompts, base64 data, and private user content removed.
Expected handling:
- The maintainer will acknowledge the report when it is received.
- Valid issues will be triaged by impact and exploitability.
- Fixes should include regression coverage or a documented verification path where practical.
- Public disclosure should wait until a fix or mitigation is available.
## Sensitive Data Rules
Never commit or paste:
- `.env.local` or real environment variables.
- API keys, OAuth client secrets, OAuth codes, cookies, session tokens, Authorization headers, or passwords.
- Signed object-storage URLs, raw upload URLs, private bucket paths, or third-party task payloads.
- Full prompts, base64 image/video data, or private generated media unless explicitly sanitized.
Use `.env.example` for placeholders only.
## Logging Rules
High-risk server logs should use `createLogger` from `lib/observability/logger`. Browser logs should use `createClientLogger` and stay silent in production unless explicitly enabled.
Before merging security-sensitive changes, run:
```bash
pnpm check:quality
pnpm audit:security
```
For route-level release smoke tests, also run:
```bash
AUTH_TRUST_HOST=true PORT=3001 pnpm dev
SMOKE_BASE_URL=http://127.0.0.1:3001 pnpm smoke:routes
```
If a file starts logging prompts, URLs, provider responses, upload details, OAuth data, or user content, add it to `scripts/check-log-hygiene.mjs` and ensure `pnpm check:logs` passes.
Dependency audit risk is tracked in `docs/dependency-risk-register.md`. Critical/high advisories in runtime dependencies should be remediated or explicitly accepted before production release.
## Deployment Security Notes
- Configure `AUTH_SECRET` and production `AUTH_URL`.
- Use `AUTH_TRUST_HOST=true` only behind a trusted reverse proxy with validated Host headers.
- Keep database, storage, OAuth, and AI provider credentials in the deployment secret store.
- Prefer short database connect timeouts and bounded connection pools.
- Verify `/settings` remains protected and redirects unauthenticated users to login.
See also:
- `docs/deployment.md`
- `docs/operations.md`
- `docs/release-readiness.md`

View File

@ -0,0 +1,228 @@
# 安全改进总结
**日期**: 2026-05-05
**改进范围**: Phase 1 (安全短板) + Phase 2 (部分工程基础)
---
## 已完成的改进
### Phase 1: 安全短板修复 ✅
#### 1. 修复硬编码管理员密码 (create-admin.ts)
**风险等级**: 🟢 低风险
**改动**:
- 从环境变量 `ADMIN_EMAIL` / `ADMIN_PASSWORD` 读取凭据
- 如未设置则自动生成安全随机密码
- 不再将密码打印到 console(仅在自动生成时打印一次)
**向后兼容**: ✅ 是(未设置环境变量时使用默认邮箱 + 随机密码)
**测试建议**:
```bash
# 测试 1: 使用环境变量
ADMIN_EMAIL=admin@test.com ADMIN_PASSWORD=SecurePass123! node create-admin.ts
# 测试 2: 自动生成密码
node create-admin.ts
```
---
#### 2. 修复 trustHost 安全配置
**风险等级**: 🟡 中等风险
**改动**:
- `app/(auth)/auth.config.ts`: `trustHost` 改为条件启用
- Vercel 环境 (`VERCEL=1`) 自动开启
- 其他环境需显式设置 `AUTH_TRUST_HOST=true`
- `app/(auth)/auth.ts`: 移除重复的 `trustHost: true` 覆盖
**潜在影响**:
- ⚠️ 非 Vercel 部署且未设置 `AUTH_TRUST_HOST=true` 时,NextAuth 会严格校验 Host 头
- 如果反向代理未正确传递 Host 头,可能导致登录失败
**测试建议**:
```bash
# 开发环境测试登录流程
pnpm dev
# 访问 http://localhost:3000/login 测试登录
# 生产环境部署前检查
echo $VERCEL # Vercel 环境应为 "1"
echo $AUTH_TRUST_HOST # 非 Vercel 环境应设为 "true"
```
---
#### 3. 添加 HTTP 安全头
**风险等级**: 🟢 低风险
**改动**: `next.config.ts` 新增 `headers()` 函数
- `X-Content-Type-Options: nosniff`
- `X-Frame-Options: SAMEORIGIN`
- `Referrer-Policy: strict-origin-when-cross-origin`
- `Permissions-Policy: camera=(), microphone=(), geolocation=()`
- `Content-Security-Policy` (开发环境允许 unsafe-eval,生产环境更严格)
**潜在影响**:
- CSP 可能阻止某些第三方脚本(如未在 `connect-src` 白名单中的 API)
- 如有嵌入需求(iframe),需调整 `X-Frame-Options`
**测试建议**:
```bash
# 启动后检查响应头
curl -I http://localhost:3000 | grep -E "X-Content-Type|X-Frame|CSP"
```
---
#### 4. 创建 middleware.ts 接入 proxy.ts
**风险等级**: 🔴 **高风险** - 需重点测试
**改动**: 新建 `/middleware.ts`
```typescript
export { proxy as middleware, config } from "./proxy";
```
**影响**:
- ✅ 限流功能**首次生效**(之前 proxy.ts 存在但未被 Next.js 加载)
- ✅ IP 黑名单检查生效
- ✅ 可疑请求检测生效
- ⚠️ 所有 API 路由现在都会经过中间件检查
**潜在问题**:
1. **限流过严**: 如果用户正常使用触发限流(如画布自动保存频繁),会返回 429
2. **误判可疑请求**: `lib/security/request-monitor.ts` 的规则可能误报
3. **性能影响**: 每个请求都要经过 IP 检查、限流检查、可疑模式匹配
**测试建议**:
```bash
# 1. 检查中间件是否加载
pnpm build
cat .next/server/middleware-manifest.json # 应有内容,不再为空
# 2. 测试限流
# 快速刷新页面 100 次,应触发 429
# 3. 测试正常流程
# 登录、创建画布、上传图片、聊天 - 确保都正常工作
# 4. 检查日志
# 查看是否有 "[Security] Rate limit exceeded" 等日志
```
---
#### 5. 补充 .env.example
**风险等级**: 🟢 无风险(纯文档)
**新增条目**:
- `AUTH_URL` / `NEXTAUTH_URL`
- `AUTH_TRUST_HOST`
- `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET`
- `ADMIN_EMAIL` / `ADMIN_PASSWORD`
- `FAL_KEY`
---
### Phase 2: 工程基础改进 ✅
#### 6. 建立结构化日志系统
**风险等级**: 🟢 低风险
**新增文件**: `lib/logger.ts`
- 提供 `log.info()`, `log.error()`, `log.warn()`, `log.debug()`
- 支持模块化日志:`createLogger("ModuleName")`
- 开发环境可读格式,生产环境 JSON 格式
- 通过 `LOG_LEVEL` 环境变量控制输出级别
**使用示例**:
```typescript
import { createLogger } from "@/lib/logger";
const log = createLogger("MyService");
log.error("Operation failed", error, { userId: "123" });
```
---
#### 7. 修复 service 层错误吞没
**风险等级**: 🟡 中等风险
**改动**:
- `services/canvas/index.ts`: 添加 logger 导入和辅助函数(未全部迁移)
**潜在问题**:
- `handleError(err, context, fallbackMessage)` 参数顺序与之前不同
---
#### 8. 更新安全文档
**风险等级**: 🟢 无风险(纯文档)
**改动**: `lib/security/IMPLEMENTATION_GUIDE.md` 更新至实际状态
---
## 未完成的任务
### Phase 2 剩余
- ❌ 为缺少验证的路由补充 Zod schema (enhance, rmbg, canvas/text-edit 等)
- ❌ 迁移剩余 service 文件的错误处理(canvas 有 31 个 catch 块未完全迁移)
### Phase 3
- ❌ 补充核心单元测试
- ❌ 添加 Dockerfile 支持
---
## 回滚方案
如果部署后出现问题,可以快速回滚:
### 回滚 middleware(最高优先级)
```bash
# 删除 middleware.ts,限流和安全检查将停止工作
rm middleware.ts
pnpm build
```
### 回滚 trustHost
```bash
# 在 auth.config.ts 中改回
trustHost: true,
```
### 回滚其他改动
```bash
git diff HEAD # 查看所有改动
git checkout HEAD -- <file> # 回滚单个文件
```
---
## 部署前检查清单
- [ ] 在开发环境运行 `pnpm dev`,确保无编译错误
- [ ] 测试登录/登出流程
- [ ] 测试聊天、画布、图片生成等核心功能
- [ ] 快速刷新页面,确认限流触发(应看到 429 错误)
- [ ] 检查 `.next/server/middleware-manifest.json` 不为空
- [ ] 设置生产环境变量:
- `AUTH_TRUST_HOST=true`(如非 Vercel)
- `LOG_LEVEL=warn`(生产环境)
- `ADMIN_PASSWORD=<secure-password>`(如需创建管理员)
- [ ] 运行 `pnpm build` 确保生产构建成功
---
## 监控建议
部署后前 24 小时重点监控:
1. **错误率**: 是否有大量 429 (限流) 或 403 (IP 黑名单) 错误
2. **登录成功率**: trustHost 改动可能影响登录
3. **日志输出**: 检查是否有 "[Security]" 前缀的警告
4. **性能**: 中间件增加的延迟(应 <10ms)
---
## 联系与支持
如遇问题,优先检查:
1. `.next/server/middleware-manifest.json` 是否正确生成
2. 浏览器控制台是否有 CSP 违规警告
3. 服务器日志中是否有 "[Security]" 相关错误

View File

@ -0,0 +1,247 @@
# AI 生图平台白底图接口文档
> 用途:AI 生图平台输入 `SKU` 后,直接从素材库获取该 `SKU` 对应的白底图。
> 当前主域名:`https://sc.jiansun.vip`
> 旧域名:`https://3d.yydd.ai`,保留兼容并跳转到新域名
## 1. 基础信息
- **接口基地址**:`https://sc.jiansun.vip/api/external/`
- **鉴权方式**:请求头 `X-API-Key`
- **返回格式**:`application/json`
- **适用范围**:仅适用于 `标准模型` 类型素材
## 2. 接口说明
### 2.1 按 SKU 查询白底图
**请求**
```http
GET /api/external/standard-model-white-background?sku={SKU}
X-API-Key: 5073534d7a59babbc45397a8eac5943b
```
**请求参数**
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `sku` | string | 是 | SKU 名称。接口按大小写不敏感查询。 |
**请求头**
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `X-API-Key` | string | 是 | 外部接口访问密钥 |
## 3. 成功响应
**响应示例**
```json
{
"success": true,
"data": {
"sku": "text001452",
"materialId": "484bd047-c956-48cf-a0f4-401f696aef8d",
"productModel": "TEXT0014",
"whiteBackground": {
"fixed": [
{
"key": "leftFront45",
"label": "左前45°",
"fileName": "左前45°.png",
"fileSize": 68,
"downloadUrl": "https://3d.yydd.ai/api/external/download?token=xxx"
},
{
"key": "rightFront45",
"label": "右前45°",
"fileName": "右前45°.png",
"fileSize": 68,
"downloadUrl": "https://3d.yydd.ai/api/external/download?token=xxx"
}
],
"other": [
{
"key": "other",
"label": "其他图片",
"fileName": "01-other-1.png",
"fileSize": 68,
"downloadUrl": "https://3d.yydd.ai/api/external/download?token=xxx"
}
]
},
"downloadUrls": {
"zip": "https://3d.yydd.ai/api/external/download?token=xxx"
}
}
}
```
### 3.1 字段说明
| 字段 | 类型 | 说明 |
|---|---|---|
| `data.sku` | string | 实际命中的 SKU 名称 |
| `data.materialId` | string | 素材 ID |
| `data.productModel` | string | 所属 SPU / 型号 |
| `data.whiteBackgroundUploaded` | boolean | 是否已有白底图 |
| `data.whiteBackgroundComplete` | boolean | 固定 8 张是否已补齐 |
| `data.whiteBackgroundFixedCount` | number | 当前已上传的固定角度图数量 |
| `data.whiteBackgroundOtherCount` | number | 当前已上传的 other 图片数量 |
| `data.whiteBackgroundCount` | number | 当前白底图总数 |
| `data.missingViews` | array | 当前仍缺少的固定角度图 |
| `data.whiteBackground.fixed` | array | 当前已有的固定角度白底图,按固定顺序返回已存在项 |
| `data.whiteBackground.other` | array | 其他图片,可为空数组 |
| `data.downloadUrls.zip` | string | 当前 SKU 白底图整包 ZIP 下载链接 |
### 3.2 `fixed` 固定图顺序
固定图返回顺序固定为:
1. `leftFront45`:左前45°
2. `rightFront45`:右前45°
3. `leftBack45`:左后45°
4. `rightBack45`:右后45°
5. `front`:正视图
6. `back`:后视图
7. `left`:左视图
8. `right`:右视图
### 3.3 单张图片对象字段
| 字段 | 类型 | 说明 |
|---|---|---|
| `key` | string | 固定图为固定角度 key;其他图固定为 `other` |
| `label` | string | 图片说明 |
| `fileName` | string | 文件名 |
| `fileSize` | number | 文件大小,单位字节 |
| `downloadUrl` | string | 单张图片下载链接 |
## 4. 下载方式
### 4.1 下载单张白底图
直接请求响应里的 `downloadUrl` 即可:
```http
GET https://3d.yydd.ai/api/external/download?token=xxx
```
返回内容为对应图片文件,例如:
- `image/png`
- `image/jpeg`
### 4.2 下载整包白底图 ZIP
直接请求:
```http
GET https://3d.yydd.ai/api/external/download?token=xxx
```
当 token 对应的是白底图 ZIP 时,返回:
- `Content-Type: application/zip`
- 文件名:`{SKU}_白底图.zip`
## 5. 错误响应
### 5.1 SKU 不存在
```json
{
"success": false,
"error": {
"code": "SKU_NOT_FOUND",
"message": "未找到对应的标准模型 SKU"
}
}
```
### 5.2 SKU 数据重复
```json
{
"success": false,
"error": {
"code": "SKU_NOT_UNIQUE",
"message": "该 SKU 存在重复数据,暂无法对外提供白底图"
}
}
```
### 5.3 白底图不存在
```json
{
"success": false,
"error": {
"code": "WHITE_BACKGROUND_NOT_FOUND",
"message": "该 SKU 暂无白底图"
}
}
```
### 5.4 API Key 无效
```json
{
"success": false,
"error": {
"code": "INVALID_API_KEY",
"message": "API Key 无效或缺失"
}
}
```
## 6. 接入建议
- **推荐主流程**:先查接口 → 拿到 `fixed` / `other` → 按需下载单张图
- 如果业务需要一次性拉全量,也可以直接使用 `downloadUrls.zip`
- `sku` 查询支持大小写不敏感,但建议调用方统一按大写传参,便于排查
- 接口不再要求固定 8 张必须补齐;只要该 SKU 已有任意白底图,就会返回当前已有图片
- 下载链接为带 token 的受控链接,建议由服务端调用,不建议直接暴露到前端页面
## 7. cURL 示例
### 7.1 查询白底图
```bash
curl -X GET \
"https://3d.yydd.ai/api/external/standard-model-white-background?sku=TEXT001452" \
-H "X-API-Key: 5073534d7a59babbc45397a8eac5943b"
```
### 7.2 下载单张图片
```bash
curl -L \
"https://3d.yydd.ai/api/external/download?token=xxx" \
-o left-front-45.png
```
### 7.3 下载整包 ZIP
```bash
curl -L \
"https://3d.yydd.ai/api/external/download?token=xxx" \
-o TEXT001452_白底图.zip
```
## 8. 当前联调结论
已验证:
- 旧任务平台接口 `GET /api/external/product-assets` 正常
- 旧下载接口 `GET /api/external/download?token=...` 正常
- 新白底图接口 `GET /api/external/standard-model-white-background` 正常
- 新白底图单图下载正常
如果后续还要继续开放更多素材接口,建议继续复用当前这套:
- `X-API-Key`
- `/api/external/*`
- token 下载链接

504
SPU详情接口文档.md Normal file
View File

@ -0,0 +1,504 @@
# AI 生图平台素材接口文档
> 用途:AI 生图平台统一通过同一条接口,查询 `标准模型白底图` 或 `任务-详情整套预览下载链接`。
> 当前主域名:`https://sc.jiansun.vip`
> 当前资源公网域名:`https://s3.jiansun.vip`
> 旧应用域名:`https://3d.yydd.ai`(仅保留兼容跳转)
## 1. 基础信息
- 接口基地址:`https://sc.jiansun.vip/api/external/`
- 鉴权方式:请求头 `X-API-Key`
- 请求方式:`GET`
- 返回格式:`application/json`
- 主接口路径:`/api/external/standard-model-white-background`
- 默认资源类型:`white-background`
返回统一格式:
```json
{
"success": true,
"data": {}
}
```
失败时:
```json
{
"success": false,
"error": {
"code": "ERROR_CODE",
"message": "错误说明"
}
}
```
## 2. 通用请求规则
### 2.1 请求头
| 参数 | 必填 | 说明 |
|---|---|---|
| `X-API-Key` | 是 | 外部接口访问密钥 |
### 2.2 查询参数
| 参数 | 必填 | 说明 |
|---|---|---|
| `resourceType` | 否 | 资源类型。可选 `white-background` 或 `task-detail`。不传时默认 `white-background` |
| `sku` | 条件必填 | `sku` 和 `spu` 二选一即可 |
| `spu` | 条件必填 | `sku` 和 `spu` 二选一即可 |
补充说明:
- `sku`、`spu` 都按大小写不敏感查询,建议调用方统一传大写。
- 如果 `sku` 和 `spu` 同时传入,接口优先按 `spu` 处理。
- `shopName` 当前与 `categoryName` 含义一致,都是“店铺”。
## 3. 白底图查询
### 3.1 适用场景
- `resourceType=white-background`
- 不传 `resourceType` 时也走这一分支,兼容老调用方
- 支持两种查询方式:
- `sku`:直接拿到该 SKU 的完整白底图信息
- `spu`:先拿到该 SPU 下全部 SKU 清单,再让用户选 SKU
---
### 3.2 按 SKU 查询白底图
**请求**
```http
GET /api/external/standard-model-white-background?resourceType=white-background&sku={SKU}
X-API-Key: your-api-key
```
兼容老调用方式:
```http
GET /api/external/standard-model-white-background?sku={SKU}
X-API-Key: your-api-key
```
**成功响应示例**
```json
{
"success": true,
"data": {
"resourceType": "white-background",
"sku": "TEXT001452",
"materialId": "484bd047-c956-48cf-a0f4-401f696aef8d",
"productModel": "TEXT0014",
"categoryName": "旗舰店",
"shopName": "旗舰店",
"whiteBackgroundUploaded": true,
"whiteBackgroundComplete": false,
"whiteBackgroundFixedCount": 2,
"whiteBackgroundOtherCount": 1,
"whiteBackgroundCount": 3,
"missingViews": [
{
"key": "leftBack45",
"label": "左后45°"
}
],
"whiteBackground": {
"fixed": [
{
"key": "leftFront45",
"label": "左前45°",
"fileName": "左前45°.png",
"fileSize": 123456,
"downloadUrl": "https://sc.jiansun.vip/api/external/download?token=xxx"
},
{
"key": "rightFront45",
"label": "右前45°",
"fileName": "右前45°.png",
"fileSize": 123456,
"downloadUrl": "https://sc.jiansun.vip/api/external/download?token=yyy"
}
],
"other": [
{
"key": "other-1",
"label": "其他图片1",
"fileName": "detail-1.png",
"fileSize": 123456,
"downloadUrl": "https://sc.jiansun.vip/api/external/download?token=zzz"
}
]
},
"downloadUrls": {
"zip": "https://sc.jiansun.vip/api/external/download?token=zip-token"
}
}
}
```
**字段说明**
| 字段 | 说明 |
|---|---|
| `resourceType` | 固定为 `white-background` |
| `sku` | 实际命中的 SKU |
| `materialId` | 标准模型素材 ID |
| `productModel` | 所属 SPU |
| `categoryName` | 店铺 |
| `shopName` | 店铺,当前与 `categoryName` 相同 |
| `whiteBackgroundUploaded` | 是否已有白底图 |
| `whiteBackgroundComplete` | 固定 8 个角度是否已补齐 |
| `whiteBackgroundFixedCount` | 已有固定角度图数量 |
| `whiteBackgroundOtherCount` | 已有 other 图数量 |
| `whiteBackgroundCount` | 白底图总数 |
| `missingViews` | 还缺少的固定角度图 |
| `whiteBackground.fixed` | 固定角度白底图列表 |
| `whiteBackground.other` | 其他白底图列表 |
| `downloadUrls.zip` | 当前 SKU 白底图 ZIP 下载链接 |
固定角度顺序固定为:
1. `leftFront45`
2. `rightFront45`
3. `leftBack45`
4. `rightBack45`
5. `front`
6. `back`
7. `left`
8. `right`
单张图片对象字段:
| 字段 | 说明 |
|---|---|
| `key` | 固定图为固定角度 key,other 图为 `other-*` |
| `label` | 图片说明 |
| `fileName` | 文件名 |
| `fileSize` | 文件大小,单位字节 |
| `downloadUrl` | 单张白底图下载链接 |
---
### 3.3 按 SPU 查询白底图 SKU 清单
**请求**
```http
GET /api/external/standard-model-white-background?resourceType=white-background&spu={SPU}
X-API-Key: your-api-key
```
**成功响应示例**
```json
{
"success": true,
"data": {
"resourceType": "white-background",
"spu": "TEXT0014",
"materialId": "484bd047-c956-48cf-a0f4-401f696aef8d",
"categoryName": "旗舰店",
"shopName": "旗舰店",
"skuCount": 2,
"skuList": [
{
"skuName": "TEXT001452",
"skuId": "sku-id-1",
"hasWhiteBackground": true,
"whiteBackgroundCount": 2,
"previewImage": {
"downloadUrl": "https://sc.jiansun.vip/api/external/download?token=xxx",
"fileName": "右前45°.png",
"fileSize": 123456
}
},
{
"skuName": "TEXT001453",
"skuId": "sku-id-2",
"hasWhiteBackground": false,
"whiteBackgroundCount": 0,
"previewImage": null
}
]
}
}
```
**字段说明**
| 字段 | 说明 |
|---|---|
| `resourceType` | 固定为 `white-background` |
| `spu` | 实际命中的 SPU |
| `materialId` | 标准模型素材 ID |
| `categoryName` | 店铺 |
| `shopName` | 店铺,当前与 `categoryName` 相同 |
| `skuCount` | 该 SPU 下 SKU 数量 |
| `skuList[].skuName` | SKU 名称 |
| `skuList[].skuId` | SKU 记录 ID |
| `skuList[].hasWhiteBackground` | 是否已有任意白底图 |
| `skuList[].whiteBackgroundCount` | 当前固定角度白底图数量 |
| `skuList[].previewImage` | 右前 45° 预览图;如果没有该角度则为 `null` |
说明:
- 这个接口主要给 AI 平台先展示 SKU 列表用。
- 选定 SKU 后,再调用“按 SKU 查询白底图”接口拿完整白底图信息。
## 4. 任务-详情整套查询
### 4.1 适用场景
- `resourceType=task-detail`
- 只返回:
- `materialType='任务-详情'`
- `isFullSet=true`
- `deletedAt=null`
的候选素材
- 返回的是**预览图直链数组**,不是 zip,也不是 token 下载链接
- 返回顺序按 `uploadedAt` 倒序,也就是最新上传的排前面
---
### 4.2 按 SPU 查询任务-详情整套候选
**请求**
```http
GET /api/external/standard-model-white-background?resourceType=task-detail&spu={SPU}
X-API-Key: your-api-key
```
**成功响应示例**
```json
{
"success": true,
"data": {
"resourceType": "task-detail",
"spu": "SPU001",
"sku": null,
"categoryName": "旗舰店",
"shopName": "旗舰店",
"candidateCount": 2,
"candidates": [
{
"materialId": "material-2",
"taskNo": "TASK202601141713525624",
"productModel": "SPU001",
"categoryName": "旗舰店",
"shopName": "旗舰店",
"sceneName": "客厅",
"uploadedAt": "2026-03-18T10:00:00.000Z",
"isFullSet": true,
"downloadUrls": {
"main": [
"https://s3.jiansun.vip/gongxiangpan/preview/main-1.jpg"
],
"sku": [
"https://s3.jiansun.vip/gongxiangpan/preview/sku-1.jpg"
],
"detail": [
"https://s3.jiansun.vip/gongxiangpan/preview/detail-1.jpg"
],
"all": [
"https://s3.jiansun.vip/gongxiangpan/preview/main-1.jpg",
"https://s3.jiansun.vip/gongxiangpan/preview/sku-1.jpg",
"https://s3.jiansun.vip/gongxiangpan/preview/detail-1.jpg"
]
}
}
]
}
}
```
---
### 4.3 按 SKU 查询任务-详情整套候选
**请求**
```http
GET /api/external/standard-model-white-background?resourceType=task-detail&sku={SKU}
X-API-Key: your-api-key
```
**处理逻辑**
- 先通过 `SKU -> 标准模型 SPU`
- 再按这个 SPU 查询所有整套 `任务-详情` 候选
**成功响应示例**
```json
{
"success": true,
"data": {
"resourceType": "task-detail",
"spu": "SPU001",
"sku": "SKU001",
"categoryName": "旗舰店",
"shopName": "旗舰店",
"candidateCount": 1,
"candidates": [
{
"materialId": "material-2",
"taskNo": "TASK202601141713525624",
"productModel": "SPU001",
"categoryName": "旗舰店",
"shopName": "旗舰店",
"sceneName": "客厅",
"uploadedAt": "2026-03-18T10:00:00.000Z",
"isFullSet": true,
"downloadUrls": {
"main": [],
"sku": [],
"detail": [],
"all": []
}
}
]
}
}
```
**候选素材字段说明**
| 字段 | 说明 |
|---|---|
| `materialId` | 任务-详情素材 ID |
| `taskNo` | 任务号 |
| `productModel` | 对应 SPU |
| `categoryName` | 店铺 |
| `shopName` | 店铺,当前与 `categoryName` 相同 |
| `sceneName` | 场景 |
| `uploadedAt` | 上传时间,ISO 8601 格式 |
| `isFullSet` | 是否整套,返回结果中固定为 `true` |
| `downloadUrls.main` | 主图预览直链数组 |
| `downloadUrls.sku` | SKU 图预览直链数组 |
| `downloadUrls.detail` | 详情图预览直链数组 |
| `downloadUrls.all` | `main + sku + detail` 合并后的数组 |
说明:
- `task-detail` 分支返回的是**全部候选**,不是只返回一条。
- 预览链接可以直接打开或下载。
- 历史 `任务-详情` 如果没有标记 `isFullSet=true`,不会被返回。
## 5. 下载说明
### 5.1 白底图
白底图返回的是下载链接:
- 单张图:`whiteBackground.fixed[*].downloadUrl` / `whiteBackground.other[*].downloadUrl`
- 整包 ZIP:`downloadUrls.zip`
调用方式:
```http
GET https://sc.jiansun.vip/api/external/download?token=xxx
```
### 5.2 任务-详情整套
任务-详情返回的是可直接访问的预览图直链数组:
- `downloadUrls.main`
- `downloadUrls.sku`
- `downloadUrls.detail`
- `downloadUrls.all`
这一分支**不返回 zip**,也**不需要再调下载 token 接口**。
## 6. 错误码
### 6.1 通用错误
| HTTP 状态码 | 错误码 | 说明 |
|---|---|---|
| 401 | `INVALID_API_KEY` | API Key 无效或缺失 |
| 400 | `MISSING_PARAM` | 没有传 `sku` 或 `spu` |
| 400 | `RESOURCE_TYPE_INVALID` | `resourceType` 不是 `white-background` 或 `task-detail` |
| 500 | `SERVER_ERROR` | 服务器内部错误 |
### 6.2 白底图分支错误
| HTTP 状态码 | 错误码 | 说明 |
|---|---|---|
| 404 | `SKU_NOT_FOUND` | 未找到对应的标准模型 SKU |
| 409 | `SKU_NOT_UNIQUE` | SKU 重复,暂无法对外提供白底图 |
| 404 | `SPU_NOT_FOUND` | 未找到对应的标准模型 SPU |
| 404 | `NO_SKUS` | 该 SPU 下没有 SKU 数据 |
| 404 | `WHITE_BACKGROUND_NOT_FOUND` | 该 SKU 暂无白底图 |
### 6.3 任务-详情分支错误
| HTTP 状态码 | 错误码 | 说明 |
|---|---|---|
| 404 | `SPU_NOT_FOUND` | 未找到对应的标准模型 SPU |
| 404 | `SKU_NOT_FOUND` | 未找到对应的标准模型 SKU |
| 409 | `SKU_NOT_UNIQUE` | SKU 重复,暂无法对外提供任务-详情整套素材 |
| 404 | `TASK_DETAIL_NOT_FOUND` | 该 SPU 或该 SKU 所属 SPU 下暂无任务-详情素材 |
| 404 | `FULL_SET_NOT_FOUND` | 有任务-详情素材,但没有任何一条被标记为整套 |
## 7. cURL 示例
### 7.1 按 SKU 查询白底图
```bash
curl -X GET \
"https://sc.jiansun.vip/api/external/standard-model-white-background?sku=TEXT001452" \
-H "X-API-Key: your-api-key"
```
### 7.2 按 SPU 查询白底图 SKU 清单
```bash
curl -X GET \
"https://sc.jiansun.vip/api/external/standard-model-white-background?resourceType=white-background&spu=TEXT0014" \
-H "X-API-Key: your-api-key"
```
### 7.3 按 SPU 查询任务-详情整套候选
```bash
curl -X GET \
"https://sc.jiansun.vip/api/external/standard-model-white-background?resourceType=task-detail&spu=SPU001" \
-H "X-API-Key: your-api-key"
```
### 7.4 按 SKU 查询任务-详情整套候选
```bash
curl -X GET \
"https://sc.jiansun.vip/api/external/standard-model-white-background?resourceType=task-detail&sku=SKU001" \
-H "X-API-Key: your-api-key"
```
## 8. 接入建议
- 老的白底图调用方可以不改,继续传 `sku` 即可。
- 如果是 AI 生图平台新流程,建议:
- 白底图:`spu -> sku -> 白底图详情`
- 任务-详情:直接按 `spu` 或 `sku` 查整套候选
- `task-detail` 返回多个候选时,建议调用方按 `uploadedAt` 再做一次前端展示排序或筛选。
- `task-detail` 使用的是预览图直链,不适合再按源文件 zip 的思路处理。
## 9. 当前联调状态
截至 `2026-03-18`,本地代码已完成并验证通过的内容:
- `/api/external/standard-model-white-background` 已支持 `white-background` 和 `task-detail`
- 不传 `resourceType` 时,白底图老调用方式保持兼容
- `task-detail` 已支持 `spu` / `sku` 两种查询
- 返回中已补充 `shopName`
- `任务-详情` 整套字段 `isFullSet` 已接入接口返回逻辑
- 本地构建 `npm run build` 已通过

558
SPU详情接口文档2.0.md Normal file
View File

@ -0,0 +1,558 @@
# AI 生图平台素材接口文档
> 用途:AI 生图平台统一通过同一条接口,查询 `标准模型白底图` 或 `任务-详情整套预览下载链接`
> 当前主域名:`https://sc.jiansun.vip`
> 当前资源主域名:`https://s3.jiansun.vip`
> 备用资源域名:`https://s3.yydd.ai`(备用 / 回切用,非默认对接地址)
> 旧应用域名:`https://3d.yydd.ai`(仅保留兼容跳转)
## 1. 基础信息
- 接口基地址:`https://sc.jiansun.vip/api/external/`
- 鉴权方式:请求头 `X-API-Key`
- 请求方式:`GET`
- 返回格式:`application/json`
- 主接口路径:`/api/external/standard-model-white-background`
- 默认资源类型:`white-background`
### 1.1 当前网络口径
- 接口查询、鉴权和下载 token 入口统一使用 `https://sc.jiansun.vip`
- `task-detail` 分支返回的预览图直链,当前默认资源域名使用 `https://s3.jiansun.vip`
- `https://s3.yydd.ai` 当前只保留为备用资源域名;除非单独通知切换,否则调用方继续以 `https://s3.jiansun.vip` 为准
返回统一格式:
```json
{
"success": true,
"data": {}
}
```
失败时:
```json
{
"success": false,
"error": {
"code": "ERROR_CODE",
"message": "错误说明"
}
}
```
### 1.2 响应诊断头
为便于调用方排查“接口慢”到底是服务端慢还是链路慢,当前接口额外返回以下响应头:
| 响应头 | 说明 |
|---|---|
| `X-Trace-Id` | 本次请求追踪 ID,排查时可提供给素材平台定位服务端日志 |
| `X-Server-Duration-Ms` | 服务端实际处理耗时,单位毫秒 |
| `Server-Timing` | 标准服务端耗时头,内容与上面一致 |
| `X-Resource-Type` | 本次实际走的资源分支 |
| `X-Request-Sku` | 本次实际识别后的 SKU;未传时不返回 |
| `X-Request-Spu` | 本次实际识别后的 SPU;未传时不返回 |
## 2. 通用请求规则
### 2.1 请求头
| 参数 | 必填 | 说明 |
|---|---|---|
| `X-API-Key` | 是 | 外部接口访问密钥 |
### 2.2 查询参数
| 参数 | 必填 | 说明 |
|---|---|---|
| `resourceType` | 否 | 资源类型。可选 `white-background` 或 `task-detail`。不传时默认 `white-background` |
| `sku` | 条件必填 | `sku` 和 `spu` 二选一即可 |
| `spu` | 条件必填 | `sku` 和 `spu` 二选一即可 |
补充说明:
- `sku`、`spu` 都按大小写不敏感查询,建议调用方统一传大写。
- 当前服务端会先把 `sku`、`spu` 标准化为大写后再做精确匹配,建议调用方直接传大写,避免自身重复大小写转换。
- 如果 `sku` 和 `spu` 同时传入,接口优先按 `spu` 处理。
- `shopName` 当前与 `categoryName` 含义一致,都是“店铺”。
## 3. 白底图查询
### 3.1 适用场景
- `resourceType=white-background`
- 不传 `resourceType` 时也走这一分支,兼容老调用方
- 支持两种查询方式:
- `sku`:直接拿到该 SKU 的完整白底图信息
- `spu`:先拿到该 SPU 下全部 SKU 清单,再让用户选 SKU
---
### 3.2 按 SKU 查询白底图
**请求**
```http
GET /api/external/standard-model-white-background?resourceType=white-background&sku={SKU}
X-API-Key: your-api-key
```
兼容老调用方式:
```http
GET /api/external/standard-model-white-background?sku={SKU}
X-API-Key: your-api-key
```
**成功响应示例**
```json
{
"success": true,
"data": {
"resourceType": "white-background",
"sku": "TEXT001452",
"materialId": "484bd047-c956-48cf-a0f4-401f696aef8d",
"productModel": "TEXT0014",
"categoryName": "旗舰店",
"shopName": "旗舰店",
"whiteBackgroundUploaded": true,
"whiteBackgroundComplete": false,
"whiteBackgroundFixedCount": 2,
"whiteBackgroundOtherCount": 1,
"whiteBackgroundCount": 3,
"missingViews": [
{
"key": "leftBack45",
"label": "左后45°"
}
],
"whiteBackground": {
"fixed": [
{
"key": "leftFront45",
"label": "左前45°",
"fileName": "左前45°.png",
"fileSize": 123456,
"downloadUrl": "https://sc.jiansun.vip/api/external/download?token=xxx"
},
{
"key": "rightFront45",
"label": "右前45°",
"fileName": "右前45°.png",
"fileSize": 123456,
"downloadUrl": "https://sc.jiansun.vip/api/external/download?token=yyy"
}
],
"other": [
{
"key": "other-1",
"label": "其他图片1",
"fileName": "detail-1.png",
"fileSize": 123456,
"downloadUrl": "https://sc.jiansun.vip/api/external/download?token=zzz"
}
]
},
"downloadUrls": {
"zip": "https://sc.jiansun.vip/api/external/download?token=zip-token"
}
}
}
```
**字段说明**
| 字段 | 说明 |
|---|---|
| `resourceType` | 固定为 `white-background` |
| `sku` | 实际命中的 SKU |
| `materialId` | 标准模型素材 ID |
| `productModel` | 所属 SPU |
| `categoryName` | 店铺 |
| `shopName` | 店铺,当前与 `categoryName` 相同 |
| `whiteBackgroundUploaded` | 是否已有白底图 |
| `whiteBackgroundComplete` | 固定 8 个角度是否已补齐 |
| `whiteBackgroundFixedCount` | 已有固定角度图数量 |
| `whiteBackgroundOtherCount` | 已有 other 图数量 |
| `whiteBackgroundCount` | 白底图总数 |
| `missingViews` | 还缺少的固定角度图 |
| `whiteBackground.fixed` | 固定角度白底图列表 |
| `whiteBackground.other` | 其他白底图列表 |
| `downloadUrls.zip` | 当前 SKU 白底图 ZIP 下载链接 |
固定角度顺序固定为:
1. `leftFront45`
2. `rightFront45`
3. `leftBack45`
4. `rightBack45`
5. `front`
6. `back`
7. `left`
8. `right`
单张图片对象字段:
| 字段 | 说明 |
|---|---|
| `key` | 固定图为固定角度 key,other 图为 `other-*` |
| `label` | 图片说明 |
| `fileName` | 文件名 |
| `fileSize` | 文件大小,单位字节 |
| `downloadUrl` | 单张白底图下载链接 |
---
### 3.3 按 SPU 查询白底图 SKU 清单
**请求**
```http
GET /api/external/standard-model-white-background?resourceType=white-background&spu={SPU}
X-API-Key: your-api-key
```
**成功响应示例**
```json
{
"success": true,
"data": {
"resourceType": "white-background",
"spu": "TEXT0014",
"materialId": "484bd047-c956-48cf-a0f4-401f696aef8d",
"categoryName": "旗舰店",
"shopName": "旗舰店",
"skuCount": 2,
"skuList": [
{
"skuName": "TEXT001452",
"skuId": "sku-id-1",
"hasWhiteBackground": true,
"whiteBackgroundCount": 2,
"previewImage": {
"downloadUrl": "https://sc.jiansun.vip/api/external/download?token=xxx",
"fileName": "右前45°.png",
"fileSize": 123456
}
},
{
"skuName": "TEXT001453",
"skuId": "sku-id-2",
"hasWhiteBackground": false,
"whiteBackgroundCount": 0,
"previewImage": null
}
]
}
}
```
**字段说明**
| 字段 | 说明 |
|---|---|
| `resourceType` | 固定为 `white-background` |
| `spu` | 实际命中的 SPU |
| `materialId` | 标准模型素材 ID |
| `categoryName` | 店铺 |
| `shopName` | 店铺,当前与 `categoryName` 相同 |
| `skuCount` | 该 SPU 下 SKU 数量 |
| `skuList[].skuName` | SKU 名称 |
| `skuList[].skuId` | SKU 记录 ID |
| `skuList[].hasWhiteBackground` | 是否已有任意白底图 |
| `skuList[].whiteBackgroundCount` | 当前固定角度白底图数量 |
| `skuList[].previewImage` | 右前 45° 预览图;如果没有该角度则为 `null` |
说明:
- 这个接口主要给 AI 平台先展示 SKU 列表用。
- 选定 SKU 后,再调用“按 SKU 查询白底图”接口拿完整白底图信息。
## 4. 任务-详情整套查询
### 4.1 适用场景
- `resourceType=task-detail`
- 按 `任务-详情` 素材类型直接查询,不再要求先存在 `标准模型`
- 只返回:
- `materialType='任务-详情'`
- 同时拥有主图、SKU 图、详情图
- `deletedAt=null`
的候选素材
- 返回的是**预览图直链数组**,不是 zip,也不是 token 下载链接
- 返回顺序按 `uploadedAt` 倒序,也就是最新上传的排前面
- 当前默认返回的资源直链域名是 `https://s3.jiansun.vip`
- `https://s3.yydd.ai` 保留备用说明,但不是当前默认示例地址
---
### 4.2 按 SPU 查询任务-详情整套候选
**请求**
```http
GET /api/external/standard-model-white-background?resourceType=task-detail&spu={SPU}
X-API-Key: your-api-key
```
注意:
- `task-detail` 响应顶层的 `categoryName` / `shopName` 是便捷字段,取本次返回候选列表中的第一条候选素材。
- 如果 AI 平台要展示候选素材的实际店铺,请优先读取 `candidates[].categoryName` 或 `candidates[].shopName`。
- 如果同一个 SPU 下有多条候选素材,且它们店铺不同,请以各自 `candidates[]` 内的字段为准。
**成功响应示例**
```json
{
"success": true,
"data": {
"resourceType": "task-detail",
"spu": "SPU001",
"sku": null,
"categoryName": "旗舰店",
"shopName": "旗舰店",
"candidateCount": 1,
"candidates": [
{
"materialId": "material-2",
"taskNo": "TASK202601141713525624",
"productModel": "SPU001",
"categoryName": "旗舰店",
"shopName": "旗舰店",
"sceneName": "客厅",
"uploadedAt": "2026-03-18T10:00:00.000Z",
"isFullSet": true,
"downloadUrls": {
"main": [
"https://s3.jiansun.vip/gongxiangpan/preview/main-1.jpg"
],
"sku": [
"https://s3.jiansun.vip/gongxiangpan/preview/sku-1.jpg"
],
"detail": [
"https://s3.jiansun.vip/gongxiangpan/preview/detail-1.jpg"
],
"all": [
"https://s3.jiansun.vip/gongxiangpan/preview/main-1.jpg",
"https://s3.jiansun.vip/gongxiangpan/preview/sku-1.jpg",
"https://s3.jiansun.vip/gongxiangpan/preview/detail-1.jpg"
]
}
}
]
}
}
```
---
### 4.3 按 SKU 查询任务-详情整套候选
**请求**
```http
GET /api/external/standard-model-white-background?resourceType=task-detail&sku={SKU}
X-API-Key: your-api-key
```
**处理逻辑**
- 先通过 `SKU -> 标准模型 SPU`
- 再按这个 SPU 查询所有整套 `任务-详情` 候选
- 顶层 `categoryName` / `shopName` 是便捷字段,取本次返回候选列表中的第一条候选素材
- 如果 AI 平台要展示候选素材的实际店铺,请优先读取 `candidates[].categoryName` 或 `candidates[].shopName`
**成功响应示例**
```json
{
"success": true,
"data": {
"resourceType": "task-detail",
"spu": "SPU001",
"sku": "SKU001",
"categoryName": "旗舰店",
"shopName": "旗舰店",
"candidateCount": 1,
"candidates": [
{
"materialId": "material-2",
"taskNo": "TASK202601141713525624",
"productModel": "SPU001",
"categoryName": "旗舰店",
"shopName": "旗舰店",
"sceneName": "客厅",
"uploadedAt": "2026-03-18T10:00:00.000Z",
"isFullSet": true,
"downloadUrls": {
"main": [
"https://s3.jiansun.vip/gongxiangpan/preview/main-1.jpg"
],
"sku": [
"https://s3.jiansun.vip/gongxiangpan/preview/sku-1.jpg"
],
"detail": [
"https://s3.jiansun.vip/gongxiangpan/preview/detail-1.jpg"
],
"all": [
"https://s3.jiansun.vip/gongxiangpan/preview/main-1.jpg",
"https://s3.jiansun.vip/gongxiangpan/preview/sku-1.jpg",
"https://s3.jiansun.vip/gongxiangpan/preview/detail-1.jpg"
]
}
}
]
}
}
```
**候选素材字段说明**
| 字段 | 说明 |
|---|---|
| `materialId` | 任务-详情素材 ID |
| `taskNo` | 任务号 |
| `productModel` | 对应 SPU |
| `categoryName` | 店铺 |
| `shopName` | 店铺,当前与 `categoryName` 相同 |
| `sceneName` | 场景 |
| `uploadedAt` | 上传时间,ISO 8601 格式 |
| `isFullSet` | 是否整套;当前规则为“同时拥有主图、SKU 图、详情图” |
| `downloadUrls.main` | 主图预览直链数组 |
| `downloadUrls.sku` | SKU 图预览直链数组 |
| `downloadUrls.detail` | 详情图预览直链数组 |
| `downloadUrls.all` | `main + sku + detail` 合并后的数组 |
说明:
- `task-detail` 分支返回的是**全部候选**,不是只返回一条。
- 预览链接可以直接打开或下载。
- 历史 `任务-详情` 会按同一套资产规则判断,不再依赖人工勾选。
- 顶层 `categoryName` / `shopName` 是便捷字段;如果要给用户展示候选素材的真实店铺,请优先使用 `candidates[].shopName`。
## 5. 下载说明
### 5.1 白底图
白底图返回的是下载链接:
- 单张图:`whiteBackground.fixed[*].downloadUrl` / `whiteBackground.other[*].downloadUrl`
- 整包 ZIP:`downloadUrls.zip`
调用方式:
```http
GET https://sc.jiansun.vip/api/external/download?token=xxx
```
### 5.2 任务-详情整套
任务-详情返回的是可直接访问的预览图直链数组:
- `downloadUrls.main`
- `downloadUrls.sku`
- `downloadUrls.detail`
- `downloadUrls.all`
这一分支**不返回 zip**,也**不需要再调下载 token 接口**。
补充说明:
- 当前默认回包中的预览图资源域名为 `https://s3.jiansun.vip`
- `https://s3.yydd.ai` 仅保留为备用资源域名说明,不作为当前默认对接地址
## 6. 错误码
### 6.1 通用错误
| HTTP 状态码 | 错误码 | 说明 |
|---|---|---|
| 401 | `INVALID_API_KEY` | API Key 无效或缺失 |
| 400 | `MISSING_PARAM` | 没有传 `sku` 或 `spu` |
| 400 | `RESOURCE_TYPE_INVALID` | `resourceType` 不是 `white-background` 或 `task-detail` |
| 500 | `SERVER_ERROR` | 服务器内部错误 |
### 6.2 白底图分支错误
| HTTP 状态码 | 错误码 | 说明 |
|---|---|---|
| 404 | `SKU_NOT_FOUND` | 未找到对应的标准模型 SKU |
| 409 | `SKU_NOT_UNIQUE` | SKU 重复,暂无法对外提供白底图 |
| 404 | `SPU_NOT_FOUND` | 未找到对应的标准模型 SPU |
| 404 | `NO_SKUS` | 该 SPU 下没有 SKU 数据 |
| 404 | `WHITE_BACKGROUND_NOT_FOUND` | 该 SKU 暂无白底图 |
### 6.3 任务-详情分支错误
| HTTP 状态码 | 错误码 | 说明 |
|---|---|---|
| 404 | `SKU_NOT_FOUND` | 未找到对应的标准模型 SKU |
| 409 | `SKU_NOT_UNIQUE` | SKU 重复,暂无法对外提供任务-详情整套素材 |
| 404 | `TASK_DETAIL_NOT_FOUND` | 该 SPU 下,或该 SKU 所属 SPU 下暂无任务-详情素材 |
| 404 | `FULL_SET_NOT_FOUND` | 有任务-详情素材,但没有任何一条同时拥有主图、SKU 图、详情图 |
## 7. cURL 示例
### 7.1 按 SKU 查询白底图
```bash
curl -X GET \
"https://sc.jiansun.vip/api/external/standard-model-white-background?sku=TEXT001452" \
-H "X-API-Key: your-api-key"
```
### 7.2 按 SPU 查询白底图 SKU 清单
```bash
curl -X GET \
"https://sc.jiansun.vip/api/external/standard-model-white-background?resourceType=white-background&spu=TEXT0014" \
-H "X-API-Key: your-api-key"
```
### 7.3 按 SPU 查询任务-详情整套候选
```bash
curl -X GET \
"https://sc.jiansun.vip/api/external/standard-model-white-background?resourceType=task-detail&spu=SPU001" \
-H "X-API-Key: your-api-key"
```
### 7.4 按 SKU 查询任务-详情整套候选
```bash
curl -X GET \
"https://sc.jiansun.vip/api/external/standard-model-white-background?resourceType=task-detail&sku=SKU001" \
-H "X-API-Key: your-api-key"
```
## 8. 接入建议
- 老的白底图调用方可以不改,继续传 `sku` 即可。
- 如果是 AI 生图平台新流程,建议:
- 白底图:`spu -> sku -> 白底图详情`
- 任务-详情:直接按 `spu` 或 `sku` 查整套候选
- `task-detail` 返回多个候选时,建议调用方按 `uploadedAt` 再做一次前端展示排序或筛选。
- `task-detail` 使用的是预览图直链,不适合再按源文件 zip 的思路处理。
## 9. 当前联调状态
截至 `2026-03-21`,正式环境已部署并验证通过的内容:
- `/api/external/standard-model-white-background` 已支持 `white-background` 和 `task-detail`
- 不传 `resourceType` 时,白底图老调用方式保持兼容
- `task-detail` 已支持 `spu` / `sku` 两种查询
- 返回中已补充 `shopName`
- `任务-详情` 整套字段 `isFullSet` 已改为按主图 / SKU 图 / 详情图自动判定
- 对外文档默认资源域名已统一到 `https://s3.jiansun.vip`,同时保留 `https://s3.yydd.ai` 作为备用说明
- 相关测试已通过:`standard-model-white-background-route`、`task-detail-full-set`、`url-rewrite`
- 构建验证已通过:`npm run build`
- 正式环境健康检查已通过:`/api/health`
- 正式环境真实调用已验证:
- `resourceType=task-detail&spu=CTG0290` 返回 `200`
- `resourceType=task-detail&sku=DNZ025802` 返回 `200`
- 白底图 `spu=CTG0149` 返回 `200`

57
SUPPORT.md Normal file
View File

@ -0,0 +1,57 @@
# Support
This document explains where to ask for help and what information to include.
## Before Asking
Please check:
- `README.md` for setup and project boundaries.
- `docs/deployment.md` for production deployment.
- `docs/operations.md` for quality gates, logging, and smoke tests.
- `docs/release-readiness.md` for release verification.
- Existing GitHub issues.
## Bugs
Use the Bug report issue template when you can provide reproduction steps.
Include:
- What happened and what you expected.
- Route, workflow, or UI area affected.
- Browser, OS, Node, pnpm, and deployment context when relevant.
- Sanitized logs or screenshots.
Do not include secrets, cookies, OAuth codes, Authorization headers, signed URLs, `.env.local` values, private prompts, base64 data, or private user content.
## Feature Requests
Use the Feature request issue template. Keep proposals focused and include:
- The user or operator problem.
- Proposed behavior or API shape.
- Acceptance criteria.
- Relevant alternatives considered.
## Security Issues
Do not open public issues for vulnerabilities, auth bypasses, token leaks, signed URL exposure, private data exposure, or suspicious dependency behavior.
Follow `SECURITY.md` and report privately to the maintainer.
## Deployment and Operations Help
For deployment questions, include sanitized versions of:
- Hosting target.
- Node and pnpm versions.
- Database/storage provider type.
- Relevant command output.
- Whether `pnpm check:quality` and `pnpm smoke:routes` pass.
Never paste real environment variables. Use placeholder names from `.env.example`.
## Maintainer Expectations
The project is maintained as a production-oriented codebase. Issues and discussions that include unsafe secrets, private user data, or unsanitized provider payloads may be edited or closed to protect users and operators.

View File

@ -0,0 +1,2 @@
__pycache__/
archive/

View File

@ -0,0 +1,88 @@
# Is legal the same as legitimate: AI reimplementation and the erosion of copyleft
- **Date:** March 9, 2026
- **Author:** Hong Minhee
- **Source context:** _Hong Minhee on Things_ (English / 日本語 / 朝鮮語 (國漢文) / 한국어 (한글))
- **Archive note:** This copy was normalized from user-provided text for this repository's research/archive context. Site navigation/footer language links were converted into metadata.
Last week, Dan Blanchard, the maintainer of chardet—a Python library for detecting text encodings used by roughly 130 million projects a month—released a new version. Version 7.0 is 48 times faster than its predecessor, supports multiple cores, and was redesigned from the ground up. Anthropic's Claude is listed as a contributor. The license changed from LGPL to MIT.
Blanchard's account is that he never looked at the existing source code directly. He fed only the API and the test suite to Claude and asked it to reimplement the library from scratch. The resulting code shares less than 1.3% similarity with any prior version, as measured by JPlag. His conclusion: this is an independent new work, and he is under no obligation to carry forward the LGPL. Mark Pilgrim, the library's original author, opened a GitHub issue to object. The LGPL requires that modifications be distributed under the same license, and a reimplementation produced with ample exposure to the original codebase cannot, in Pilgrim's view, pass as a clean-room effort.
The dispute drew responses from two prominent figures in the open source world. Armin Ronacher, the creator of Flask, welcomed the relicensing. Salvatore Sanfilippo (antirez), the creator of Redis, published a broader defense of AI reimplementation, grounding it in copyright law and the history of the GNU project. Both conclude, by different routes, that what Blanchard did is legitimate. I respect both writers, and I think both are wrong—or more precisely, both are evading the question that actually matters.
That question is this: does legal mean legitimate? Neither piece answers it. Both move from “this is legally permissible” to “this is therefore fine,” without pausing at the gap between those two claims. Law sets a floor; clearing it does not mean the conduct is right. That gap is where this essay begins.
## The analogy points the wrong way
Antirez builds his case on history. When the GNU project reimplemented the UNIX userspace, it was lawful. So was Linux. Copyright law prohibits copying “protected expressions”—the actual code, its structure, its specific mechanisms—but it does not protect ideas or behavior. AI-assisted reimplementation occupies the same legal ground. Therefore, it is lawful.
The legal analysis is largely correct, and I am not disputing it. The problem lies in what antirez does next: he presents the legal conclusion as if it were also a social one, and uses a historical analogy that, examined more carefully, argues against his own position.
When GNU reimplemented the UNIX userspace, the vector ran from proprietary to free. Stallman was using the limits of copyright law to turn proprietary software into free software. The ethical force of that project did not come from its legal permissibility—it came from the direction it was moving, from the fact that it was expanding the commons. That is why people cheered.
The vector in the chardet case runs the other way. Software protected by a copyleft license—one that guarantees users the right to study, modify, and redistribute derivative works under the same terms—has been reimplemented under a permissive license that carries no such guarantee. This is not a reimplementation that expands the commons. It is one that removes the fencing that protected the commons. Derivative works built on chardet 7.0 are under no obligation to share their source code. That obligation, which applied to a library downloaded 130 million times a month, is now gone.
Antirez does not address this directional difference. He invokes the GNU precedent, but that precedent is a counterexample to his conclusion, not a supporting one.
## Does the GPL work against sharing?
Ronacher's argument is different. He discloses upfront that he has a stake in the outcome: “I personally have a horse in the race here because I too wanted chardet to be under a non-GPL license for many years. So consider me a very biased person in that regard.” He goes on to write that he considers “the GPL to run against that spirit by restricting what can be done with it”—the spirit being that society is better off when we share.
This claim rests on a fundamental misreading of what the GPL does.
Start with what the GPL actually prohibits. It does not prohibit keeping source code private. It imposes no constraint on privately modifying GPL software and using it yourself. The GPL's conditions are triggered only by distribution. If you distribute modified code, or offer it as a networked service, you must make the source available under the same terms. This is not a restriction on sharing. It is a condition placed on sharing: if you share, you must share in kind.
The requirement that improvements be returned to the commons is not a mechanism that suppresses sharing. It is a mechanism that makes sharing recursive and self-reinforcing. The claim that imposing contribution obligations on users of a commons undermines sharing culture does not hold together logically.
The contrast with the MIT license clarifies the point. Under MIT, anyone may take code, improve it, and close it off into a proprietary product. You can receive from the commons without giving back. If Ronacher calls this structure “more share-friendly,” he is using a concept of sharing with a specific directionality built in: sharing flows toward whoever has more capital and more engineers to take advantage of it.
The historical record bears this out. In the 1990s, companies routinely absorbed GPL code into proprietary products—not because they had chosen permissive licenses, but because copyleft enforcement was slack. The strengthening of copyleft mechanisms closed that gap. For individual developers and small projects without the resources to compete on anything but reciprocity, copyleft was what made the exchange approximately fair.
The creator of Flask knows this distinction. If he elides it anyway, the argument is not naïve—it is convenient.
## A self-refuting example
The most interesting moment in Ronacher's piece is not the argument but a detail he mentions in passing: Vercel reimplemented GNU Bash using AI and published it, then got visibly upset when Cloudflare reimplemented Next.js the same way.
Ronacher notes this as an irony and moves on. But the irony cuts deeper than he lets on. Next.js is MIT licensed. Cloudflare's vinext did not violate any license—it did exactly what Ronacher calls a contribution to the culture of openness, applied to a permissively licensed codebase. Vercel's reaction had nothing to do with license infringement; it was purely competitive and territorial. The implicit position is: reimplementing GPL software as MIT is a victory for sharing, but having our own MIT software reimplemented by a competitor is cause for outrage. This is what the claim that permissive licensing is “more share-friendly” than copyleft looks like in practice. The spirit of sharing, it turns out, runs in one direction only: outward from oneself.
Ronacher registers the contradiction and does not stop. “This development plays into my worldview,” he writes. When you present evidence that cuts against your own position, acknowledge it, and then proceed to your original conclusion unchanged, that is a signal that the conclusion preceded the argument.
## Legality and social legitimacy are different registers
Back to the question posed at the start. Is legal the same as legitimate?
Antirez closes his careful legal analysis as though it settles the matter. Ronacher acknowledges that “there is an obvious moral question here, but that isn't necessarily what I'm interested in.” Both pieces treat legal permissibility as a proxy for social legitimacy. But law only says what conduct it will not prevent—it does not certify that conduct as right. Aggressive tax minimization that never crosses into illegality may still be widely regarded as antisocial. A pharmaceutical company that legally acquires a patent on a long-generic drug and raises the price a hundredfold has done something legal, but that does not make it fine. Legality is a necessary condition; it is not a sufficient one.
In the chardet case, the distinction is sharper still. What the LGPL protected was not Blanchard's labor alone. It was a social compact agreed to by everyone who contributed to the library over twelve years. The terms of that compact were: if you take this and build on it, you share back under the same terms. This compact operated as a legal instrument, yes, but it was also the foundation of trust that made contribution rational. The fact that a reimplementation may qualify legally as a new work, and the fact that it breaks faith with the original contributors, are separate questions. If a court eventually rules in Blanchard's favor, that ruling will tell us what the law permits. It will not tell us that the act was right.
Zoë Kooyman, executive director of the FSF, put it plainly: “Refusing to grant others the rights you yourself received as a user is highly antisocial, no matter what method you use.”
## Whose perspective is the default?
Reading this debate, I keep returning to a question about position. From where are these two writers looking at the situation?
Antirez created Redis. Ronacher created Flask. Both are figures at the center of the open source ecosystem, with large audiences and well-established reputations. For them, falling costs of AI reimplementation means something specific: it is easier to reimplement things they want in a different form. Ronacher says explicitly that he had begun reimplementing GNU Readline precisely because of its copyleft terms.
For the people who have spent years contributing to a library like chardet, the same shift in costs means something else entirely: the copyleft protection around their contributions can be removed. The two writers are speaking from the former position to people in the latter, telling them that this was always lawful, that historical precedent supports it, and that the appropriate response is adaptation.
When positional asymmetry of this kind is ignored, and the argument is presented as universal analysis, what you get is not analysis but rationalization. Both writers arrive at conclusions that align precisely with their own interests. Readers should hold that fact in mind.
## What this fight points toward
Bruce Perens, who wrote the original Open Source Definition, told The Register: “The entire economics of software development are dead, gone, over, kaput!” He meant it as an alarm. Antirez, from a similar assessment of the situation, draws the conclusion: adapt. Ronacher says he finds the direction exciting.
None of the three responses addresses the central question. When copyleft becomes technically easier to circumvent, does that make it less necessary, or more?
I think more. What the GPL protected was not the scarcity of code but the freedom of users. The fact that producing code has become cheaper does not make it acceptable to use that code as a vehicle for eroding freedom. If anything, as the friction of reimplementation disappears, so does the friction of stripping copyleft from anything left exposed. The erosion of enforcement capacity is a legal problem. It does not touch the underlying normative judgment.
That judgment is this: those who take from the commons owe something back to the commons. The principle does not change depending on whether a reimplementation takes five years or five days. No court ruling on AI-generated code will alter its social weight.
This is where law and community norms diverge. Law is made slowly, after the fact, reflecting existing power arrangements. The norms that open source communities built over decades did not wait for court approval. People chose the GPL when the law offered them no guarantee of its enforcement, because it expressed the values of the communities they wanted to belong to. Those values do not expire when the law changes.
In previous writing, I argued for a training copyleft (TGPL) as the next step in this line of development. The chardet situation suggests the argument has to go further: to a specification copyleft covering the layer below source code. If source code can now be generated from a specification, the specification is where the essential intellectual content of a GPL project resides. Blanchard's own claim—that he worked only from the test suite and API without reading the source—is, paradoxically, an argument for protecting that test suite and API specification under copyleft terms.
The history of the GPL is the history of licensing tools evolving in response to new forms of exploitation: GPLv2 to GPLv3, then AGPL. What drove each evolution was not a court ruling but a community reaching a value judgment first and then seeking legal instruments to express it. The same sequence is available now. Whatever courts eventually decide about AI reimplementation, the question we need to answer first is not a legal one. It is a social one. Do those who take from the commons owe something back? I think they do. That judgment does not require a verdict.
What makes the pieces by antirez and Ronacher worth reading is not that they are right. It is that they make visible, with unusual clarity, what they are choosing not to see. When legality is used as a substitute for a value judgment, the question that actually matters gets buried in the footnotes of a law it has already outgrown.

View File

@ -0,0 +1,106 @@
# Claude Code Python Porting Workspace
> The primary `src/` tree in this repository is now dedicated to **Python porting work**. The March 31, 2026 Claude Code source exposure is part of the project's background, but the tracked repository is now centered on Python source rather than the exposed TypeScript snapshot.
---
## Porting Status
The main source tree is now Python-first.
- `src/` contains the active Python porting workspace
- `tests/` verifies the current Python workspace
- the exposed snapshot is no longer part of the tracked repository state
The current Python workspace is not yet a complete one-to-one replacement for the original system, but the primary implementation surface is now Python.
## Why this rewrite exists
I originally studied the exposed codebase to understand its harness, tool wiring, and agent workflow. After spending more time with the legal and ethical questions—and after reading the essay linked below—I did not want the exposed snapshot itself to remain the main tracked source tree.
This repository now focuses on Python porting work instead.
## Repository Layout
```text
.
├── src/ # Python porting workspace
│ ├── __init__.py
│ ├── commands.py
│ ├── main.py
│ ├── models.py
│ ├── port_manifest.py
│ ├── query_engine.py
│ ├── task.py
│ └── tools.py
├── tests/ # Python verification
├── assets/omx/ # OmX workflow screenshots
├── 2026-03-09-is-legal-the-same-as-legitimate-ai-reimplementation-and-the-erosion-of-copyleft.md
└── README.md
```
## Python Workspace Overview
The new Python `src/` tree currently provides:
- **`port_manifest.py`** — summarizes the current Python workspace structure
- **`models.py`** — dataclasses for subsystems, modules, and backlog state
- **`commands.py`** — Python-side command port metadata
- **`tools.py`** — Python-side tool port metadata
- **`query_engine.py`** — renders a Python porting summary from the active workspace
- **`main.py`** — a CLI entrypoint for manifest and summary output
## Quickstart
Render the Python porting summary:
```bash
python3 -m src.main summary
```
Print the current Python workspace manifest:
```bash
python3 -m src.main manifest
```
List the current Python modules:
```bash
python3 -m src.main subsystems --limit 16
```
Run verification:
```bash
python3 -m unittest discover -s tests -v
```
## Related Essay
- [*Is legal the same as legitimate: AI reimplementation and the erosion of copyleft*](https://writings.hongminhee.org/2026/03/legal-vs-legitimate/)
The essay is dated **March 9, 2026**, so it should be read as companion analysis that predates the **March 31, 2026** source exposure that motivated this rewrite direction.
## Built with `oh-my-codex`
The restructuring and documentation work on this repository was AI-assisted and orchestrated with Yeachan Heo's [oh-my-codex (OmX)](https://github.com/Yeachan-Heo/oh-my-codex), layered on top of Codex.
- **`$team` mode:** used for coordinated parallel review and architectural feedback
- **`$ralph` mode:** used for persistent execution, verification, and completion discipline
- **Codex-driven workflow:** used to turn the main `src/` tree into a Python-first porting workspace
### OmX workflow screenshots
![OmX workflow screenshot 1](assets/omx/omx-readme-review-1.png)
*Ralph/team orchestration view while the README and essay context were being reviewed in terminal panes.*
![OmX workflow screenshot 2](assets/omx/omx-readme-review-2.png)
*Split-pane review and verification flow during the final README wording pass.*
## Ownership / Affiliation Disclaimer
- This repository does **not** claim ownership of the original Claude Code source material.
- This repository is **not affiliated with, endorsed by, or maintained by Anthropic**.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

View File

@ -0,0 +1,16 @@
"""Python porting workspace for the Claude Code rewrite effort."""
from .commands import PORTED_COMMANDS, build_command_backlog
from .port_manifest import PortManifest, build_port_manifest
from .query_engine import QueryEnginePort
from .tools import PORTED_TOOLS, build_tool_backlog
__all__ = [
'PortManifest',
'QueryEnginePort',
'PORTED_COMMANDS',
'PORTED_TOOLS',
'build_command_backlog',
'build_port_manifest',
'build_tool_backlog',
]

View File

@ -0,0 +1,13 @@
from __future__ import annotations
from .models import PortingBacklog, PortingModule
PORTED_COMMANDS = (
PortingModule('main', 'Expose a Python CLI for manifest and backlog reporting', 'src/main.py', 'implemented'),
PortingModule('summary', 'Render a Markdown overview of the current porting workspace', 'src/query_engine.py', 'implemented'),
PortingModule('subsystems', 'List the current Python modules participating in the rewrite', 'src/port_manifest.py', 'implemented'),
)
def build_command_backlog() -> PortingBacklog:
return PortingBacklog(title='Command surface', modules=list(PORTED_COMMANDS))

View File

@ -0,0 +1,38 @@
from __future__ import annotations
import argparse
from .port_manifest import build_port_manifest
from .query_engine import QueryEnginePort
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description='Python porting workspace for the Claude Code rewrite effort')
subparsers = parser.add_subparsers(dest='command', required=True)
subparsers.add_parser('summary', help='render a Markdown summary of the Python porting workspace')
subparsers.add_parser('manifest', help='print the current Python workspace manifest')
list_parser = subparsers.add_parser('subsystems', help='list the current Python modules in the workspace')
list_parser.add_argument('--limit', type=int, default=16)
return parser
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
manifest = build_port_manifest()
if args.command == 'summary':
print(QueryEnginePort(manifest).render_summary())
return 0
if args.command == 'manifest':
print(manifest.to_markdown())
return 0
if args.command == 'subsystems':
for subsystem in manifest.top_level_modules[: args.limit]:
print(f'{subsystem.name} {subsystem.file_count} {subsystem.notes}')
return 0
parser.error(f'unknown command: {args.command}')
return 2
if __name__ == '__main__':
raise SystemExit(main())

View File

@ -0,0 +1,31 @@
from __future__ import annotations
from dataclasses import dataclass, field
@dataclass(frozen=True)
class Subsystem:
name: str
path: str
file_count: int
notes: str
@dataclass(frozen=True)
class PortingModule:
name: str
responsibility: str
source_hint: str
status: str = 'planned'
@dataclass
class PortingBacklog:
title: str
modules: list[PortingModule] = field(default_factory=list)
def summary_lines(self) -> list[str]:
return [
f'- {module.name} [{module.status}] — {module.responsibility} (from {module.source_hint})'
for module in self.modules
]

View File

@ -0,0 +1,52 @@
from __future__ import annotations
from collections import Counter
from dataclasses import dataclass
from pathlib import Path
from .models import Subsystem
DEFAULT_SRC_ROOT = Path(__file__).resolve().parent
@dataclass(frozen=True)
class PortManifest:
src_root: Path
total_python_files: int
top_level_modules: tuple[Subsystem, ...]
def to_markdown(self) -> str:
lines = [
f'Port root: `{self.src_root}`',
f'Total Python files: **{self.total_python_files}**',
'',
'Top-level Python modules:',
]
for module in self.top_level_modules:
lines.append(f'- `{module.name}` ({module.file_count} files) — {module.notes}')
return '\n'.join(lines)
def build_port_manifest(src_root: Path | None = None) -> PortManifest:
root = src_root or DEFAULT_SRC_ROOT
files = [path for path in root.rglob('*.py') if path.is_file()]
counter = Counter(
path.relative_to(root).parts[0] if len(path.relative_to(root).parts) > 1 else path.name
for path in files
if path.name != '__pycache__'
)
notes = {
'__init__.py': 'package export surface',
'main.py': 'CLI entrypoint',
'port_manifest.py': 'workspace manifest generation',
'query_engine.py': 'port orchestration summary layer',
'commands.py': 'command backlog metadata',
'tools.py': 'tool backlog metadata',
'models.py': 'shared dataclasses',
'task.py': 'task-level planning structures',
}
modules = tuple(
Subsystem(name=name, path=f'src/{name}', file_count=count, notes=notes.get(name, 'Python port support module'))
for name, count in counter.most_common()
)
return PortManifest(src_root=root, total_python_files=len(files), top_level_modules=modules)

View File

@ -0,0 +1,32 @@
from __future__ import annotations
from dataclasses import dataclass
from .commands import build_command_backlog
from .port_manifest import PortManifest, build_port_manifest
from .tools import build_tool_backlog
@dataclass
class QueryEnginePort:
manifest: PortManifest
@classmethod
def from_workspace(cls) -> 'QueryEnginePort':
return cls(manifest=build_port_manifest())
def render_summary(self) -> str:
command_backlog = build_command_backlog()
tool_backlog = build_tool_backlog()
sections = [
'# Python Porting Workspace Summary',
'',
self.manifest.to_markdown(),
'',
f'{command_backlog.title}:',
*command_backlog.summary_lines(),
'',
f'{tool_backlog.title}:',
*tool_backlog.summary_lines(),
]
return '\n'.join(sections)

View File

@ -0,0 +1,10 @@
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class PortingTask:
title: str
detail: str
completed: bool = False

View File

@ -0,0 +1,13 @@
from __future__ import annotations
from .models import PortingBacklog, PortingModule
PORTED_TOOLS = (
PortingModule('port_manifest', 'Inspect the active Python source tree and summarize the current rewrite surface', 'src/port_manifest.py', 'implemented'),
PortingModule('backlog_models', 'Represent subsystem and backlog metadata as Python dataclasses', 'src/models.py', 'implemented'),
PortingModule('query_engine', 'Coordinate Python-facing rewrite summaries and reporting', 'src/query_engine.py', 'implemented'),
)
def build_tool_backlog() -> PortingBacklog:
return PortingBacklog(title='Tool surface', modules=list(PORTED_TOOLS))

View File

@ -0,0 +1,34 @@
from __future__ import annotations
import subprocess
import sys
import unittest
from src.port_manifest import build_port_manifest
from src.query_engine import QueryEnginePort
class PortingWorkspaceTests(unittest.TestCase):
def test_manifest_counts_python_files(self) -> None:
manifest = build_port_manifest()
self.assertGreaterEqual(manifest.total_python_files, 7)
self.assertTrue(manifest.top_level_modules)
def test_query_engine_summary_mentions_workspace(self) -> None:
summary = QueryEnginePort.from_workspace().render_summary()
self.assertIn('Python Porting Workspace Summary', summary)
self.assertIn('Command surface', summary)
self.assertIn('Tool surface', summary)
def test_cli_summary_runs(self) -> None:
result = subprocess.run(
[sys.executable, '-m', 'src.main', 'summary'],
check=True,
capture_output=True,
text=True,
)
self.assertIn('Python Porting Workspace Summary', result.stdout)
if __name__ == '__main__':
unittest.main()

View File

@ -0,0 +1,30 @@
"use client";
import type { ComponentProps, ReactNode } from "react";
import { usePathname } from "next/navigation";
import { TopNavbar } from "@/components/top-navbar";
type AdminLayoutShellProps = ComponentProps<typeof TopNavbar> & {
children: ReactNode;
};
function shouldHideSidebarChrome(pathname: string) {
return pathname === "/settings" || pathname.startsWith("/settings/");
}
export function AdminLayoutShell({
children,
...topNavbarProps
}: AdminLayoutShellProps) {
const pathname = usePathname();
const hideSidebarChrome = shouldHideSidebarChrome(pathname);
return (
<div className="flex min-h-screen flex-col bg-background">
{hideSidebarChrome ? null : <TopNavbar {...topNavbarProps} />}
<div className={hideSidebarChrome ? "flex-1" : "flex-1 pl-16"}>
{children}
</div>
</div>
);
}

57
app/(admin)/layout.tsx Normal file
View File

@ -0,0 +1,57 @@
import { auth } from "@/app/(auth)/auth";
import { getSiteSettings, getNavLinks, getTutorialVideos } from "@/lib/db/queries";
import { AdminLayoutShell } from "./_components/admin-layout-shell";
export default async function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
const [session, siteSettings, navLinks, tutorialVideos] = await Promise.all([
auth(),
getSiteSettings(),
getNavLinks(),
getTutorialVideos(),
]);
const settingsHref =
session?.user?.type === "admin"
? "/settings"
: session?.user?.type === "agent_admin"
? "/settings/agent-workflow"
: null;
return (
<AdminLayoutShell
siteName={siteSettings.siteName}
siteLogo={siteSettings.siteLogo || undefined}
siteLogoDark={siteSettings.siteLogoDark || undefined}
isAdmin={session?.user?.type === "admin" || session?.user?.type === "agent_admin"}
settingsHref={settingsHref}
customLinks={navLinks.map((link) => ({
id: link.id,
name: link.name,
url: link.url,
order: link.order,
toolboxGroup: link.toolboxGroup || 1,
}))}
toolbox1Name={siteSettings.toolbox1Name || "工具箱"}
toolbox1Enabled={siteSettings.toolbox1Enabled ?? true}
toolbox2Name={siteSettings.toolbox2Name || "工具箱2"}
toolbox2Enabled={siteSettings.toolbox2Enabled ?? false}
tutorialVideos={tutorialVideos.map((v) => ({
id: v.id,
title: v.title,
videoUrl: v.videoUrl,
coverUrl: v.coverUrl,
description: v.description,
order: v.order,
}))}
tutorialEnabled={siteSettings.tutorialEnabled ?? false}
rankingEnabled={siteSettings.rankingEnabled ?? true}
productPlanningEnabled={(siteSettings as any).productPlanningEnabled ?? true}
videoCanvasEnabled={(siteSettings as any).videoCanvasEnabled ?? true}
>
{children}
</AdminLayoutShell>
);
}

View File

@ -0,0 +1,706 @@
"use client";
import {
Activity,
AlertTriangle,
Ban,
Clock3,
Globe2,
LogIn,
Plus,
RefreshCw,
Search,
ShieldAlert,
Unlock,
UserCheck,
} from "lucide-react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import useSWR from "swr";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn, fetcher } from "@/lib/utils";
type Severity = "info" | "watch" | "high" | "critical";
type AccessLogEvent = {
id: string;
eventType: string;
ip: string;
userEmail: string | null;
userRole: string | null;
method: string | null;
path: string | null;
routeGroup: string | null;
statusCode: number | null;
outcome: string;
severity: Severity;
reasons: string[] | null;
userAgent: string | null;
createdAt: string;
};
type AccessLogTopIp = {
ip: string;
total: number;
uniqueUsers: number;
failureCount: number;
rateLimitedCount: number;
authFailureCount: number;
loginSuccessCount: number;
suspiciousCount: number;
firstSeen: string;
lastSeen: string;
severity: Severity;
failureRate: number;
reasons: string[];
};
type AccessLogResponse = {
overview: {
totalEvents: number;
uniqueIps: number;
abnormalEvents: number;
abnormalIps: number;
rateLimitedEvents: number;
manualBlockedEvents: number;
loginSuccessEvents: number;
unauthorizedEvents: number;
};
topIps: AccessLogTopIp[];
hourlyStats: Array<{
hour: string;
count: number;
abnormalCount: number;
}>;
pathStats: Array<{
path: string | null;
routeGroup: string | null;
count: number;
abnormalCount: number;
}>;
recentEvents: AccessLogEvent[];
rules: string[];
};
type AccessBlocklistEntry = {
id: string;
ip: string;
reason: string | null;
isActive: boolean;
expiresAt: string | null;
createdAt: string;
updatedAt: string;
createdByEmail: string | null;
};
const severityLabels: Record<Severity, string> = {
info: "正常",
watch: "关注",
high: "异常",
critical: "高危",
};
const severityClassNames: Record<Severity, string> = {
info:
"border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-800/70 dark:bg-emerald-950/45 dark:text-emerald-300",
watch:
"border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-800/70 dark:bg-amber-950/45 dark:text-amber-300",
high:
"border-orange-200 bg-orange-50 text-orange-700 dark:border-orange-800/70 dark:bg-orange-950/45 dark:text-orange-300",
critical:
"border-red-200 bg-red-50 text-red-700 dark:border-red-800/70 dark:bg-red-950/45 dark:text-red-300",
};
function formatNumber(value: number | null | undefined) {
return new Intl.NumberFormat("zh-CN").format(Number(value || 0));
}
function formatPercent(value: number | null | undefined) {
return `${Math.round(Number(value || 0) * 100)}%`;
}
function formatTime(value: string | null | undefined) {
if (!value) return "--";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "--";
return new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
}).format(date);
}
function getStatusClassName(statusCode: number | null) {
if (!statusCode) return "text-muted-foreground";
if (statusCode >= 500) return "text-red-700 dark:text-red-300";
if (statusCode >= 400) return "text-orange-700 dark:text-orange-300";
if (statusCode >= 300) return "text-sky-700 dark:text-sky-300";
return "text-emerald-700 dark:text-emerald-300";
}
function AccessSeverityBadge({ severity }: { severity: Severity }) {
return (
<Badge
className={cn("shrink-0 border px-2 py-0.5", severityClassNames[severity])}
variant="outline"
>
{severityLabels[severity]}
</Badge>
);
}
export function AccessSecurityDashboard() {
const [hours, setHours] = useState("24");
const [severity, setSeverity] = useState("all");
const [query, setQuery] = useState("");
const [blockIp, setBlockIp] = useState("");
const [blockReason, setBlockReason] = useState("");
const [isSavingBlock, setIsSavingBlock] = useState(false);
const accessLogUrl = useMemo(() => {
const params = new URLSearchParams({
hours,
severity,
limit: "160",
});
if (query.trim()) params.set("q", query.trim());
return `/api/admin/access-logs?${params.toString()}`;
}, [hours, query, severity]);
const { data, isLoading, mutate } = useSWR<AccessLogResponse>(
accessLogUrl,
fetcher,
{ refreshInterval: 30_000 }
);
const { data: blocklistData, mutate: mutateBlocklist } = useSWR<{
entries: AccessBlocklistEntry[];
}>("/api/admin/access-blocklist", fetcher);
const activeBlocklistEntries = useMemo(
() => (blocklistData?.entries || []).filter((entry) => entry.isActive),
[blocklistData?.entries]
);
const blockedRules = useMemo(
() => new Set(activeBlocklistEntries.map((entry) => entry.ip.toLowerCase())),
[activeBlocklistEntries]
);
const saveBlockEntry = async (targetIp?: string, targetReason?: string) => {
const ip = String(targetIp || blockIp).trim();
const reason = String(targetReason ?? blockReason).trim();
if (!ip) {
toast.error("请输入要拉黑的 IP");
return;
}
setIsSavingBlock(true);
try {
const response = await fetch("/api/admin/access-blocklist", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ ip, reason }),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(payload?.message || payload?.error || "拉黑失败");
}
toast.success(`${ip} 已加入手动黑名单`);
setBlockIp("");
setBlockReason("");
await Promise.all([mutateBlocklist(), mutate()]);
} catch (error) {
toast.error(error instanceof Error ? error.message : "拉黑失败");
} finally {
setIsSavingBlock(false);
}
};
const updateBlockEntry = async (entry: AccessBlocklistEntry, isActive: boolean) => {
try {
const response = await fetch("/api/admin/access-blocklist", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({
id: entry.id,
isActive,
reason: entry.reason || "",
expiresAt: entry.expiresAt || null,
}),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(payload?.message || payload?.error || "更新失败");
}
toast.success(isActive ? `${entry.ip} 已重新拉黑` : `${entry.ip} 已解除拉黑`);
await Promise.all([mutateBlocklist(), mutate()]);
} catch (error) {
toast.error(error instanceof Error ? error.message : "更新失败");
}
};
const overviewCards = [
{
key: "events",
label: "访问记录",
value: formatNumber(data?.overview.totalEvents),
note: `${hours} 小时窗口`,
icon: Activity,
className:
"border-slate-200 bg-slate-50 text-slate-900 dark:border-slate-700/70 dark:bg-slate-900/70 dark:text-slate-100",
},
{
key: "ips",
label: "独立 IP",
value: formatNumber(data?.overview.uniqueIps),
note: `${formatNumber(data?.overview.abnormalIps)} 个异常标注`,
icon: Globe2,
className:
"border-sky-200 bg-sky-50 text-sky-900 dark:border-sky-800/70 dark:bg-sky-950/45 dark:text-sky-100",
},
{
key: "abnormal",
label: "异常事件",
value: formatNumber(data?.overview.abnormalEvents),
note: `${formatNumber(data?.overview.manualBlockedEvents)} 次手动阻断`,
icon: ShieldAlert,
className:
"border-orange-200 bg-orange-50 text-orange-900 dark:border-orange-800/70 dark:bg-orange-950/45 dark:text-orange-100",
},
{
key: "login",
label: "成功登录",
value: formatNumber(data?.overview.loginSuccessEvents),
note: `${formatNumber(data?.overview.unauthorizedEvents)} 次未授权`,
icon: LogIn,
className:
"border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-800/70 dark:bg-emerald-950/45 dark:text-emerald-100",
},
];
const maxHourlyCount = Math.max(
1,
...(data?.hourlyStats || []).map((item) => item.count)
);
const maxPathCount = Math.max(
1,
...(data?.pathStats || []).map((item) => item.count)
);
return (
<div className="space-y-4">
<Card>
<CardHeader className="pb-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
<div>
<CardTitle className="text-xl">访问安全分析</CardTitle>
<CardDescription>
IP 访问记录、登录来源、异常标注与请求趋势
</CardDescription>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<div className="relative sm:w-[260px]">
<Search className="-translate-y-1/2 absolute left-3 top-1/2 h-4 w-4 text-muted-foreground" />
<Input
className="h-9 pl-9"
onChange={(event) => setQuery(event.target.value)}
placeholder="搜索 IP、路径、用户"
value={query}
/>
</div>
<Select onValueChange={setHours} value={hours}>
<SelectTrigger className="h-9 sm:w-[132px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">近 1 小时</SelectItem>
<SelectItem value="6">近 6 小时</SelectItem>
<SelectItem value="24">近 24 小时</SelectItem>
<SelectItem value="72">近 3 天</SelectItem>
<SelectItem value="168">近 7 天</SelectItem>
</SelectContent>
</Select>
<Select onValueChange={setSeverity} value={severity}>
<SelectTrigger className="h-9 sm:w-[132px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部级别</SelectItem>
<SelectItem value="abnormal">仅异常</SelectItem>
<SelectItem value="watch">关注</SelectItem>
<SelectItem value="high">异常</SelectItem>
<SelectItem value="critical">高危</SelectItem>
</SelectContent>
</Select>
<Button
className="h-9 gap-2"
onClick={() => mutate()}
size="sm"
variant="outline"
>
<RefreshCw className={cn("h-4 w-4", isLoading && "animate-spin")} />
刷新
</Button>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{overviewCards.map((item) => {
const Icon = item.icon;
return (
<div
className={cn("rounded-lg border p-4", item.className)}
key={item.key}
>
<div className="flex items-center justify-between gap-3">
<div className="text-sm text-current/70">{item.label}</div>
<Icon className="h-4 w-4" />
</div>
<div className="mt-3 font-semibold text-3xl tracking-tight">
{item.value}
</div>
<div className="mt-1 text-current/65 text-xs">{item.note}</div>
</div>
);
})}
</div>
<div className="rounded-lg border bg-background p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="flex items-center gap-2 font-medium text-sm">
<Clock3 className="h-4 w-4" />
小时趋势
</div>
<div className="text-muted-foreground text-xs">
橙色表示异常事件占比
</div>
</div>
<div className="flex h-[180px] items-end gap-1">
{(data?.hourlyStats || []).map((item) => {
const height = Math.max(4, (item.count / maxHourlyCount) * 100);
const abnormalHeight =
item.count > 0
? Math.max(0, (item.abnormalCount / item.count) * height)
: 0;
return (
<div
className="group relative flex min-w-[10px] flex-1 items-end overflow-hidden rounded bg-slate-200 dark:bg-slate-800"
key={item.hour}
style={{ height: `${height}%` }}
>
<div
className="w-full bg-orange-400"
style={{ height: `${abnormalHeight}%` }}
/>
<div className="-translate-x-1/2 pointer-events-none absolute bottom-full left-1/2 z-10 mb-2 hidden whitespace-nowrap rounded-md bg-zinc-950 px-2 py-1 text-white text-xs shadow-lg group-hover:block">
{item.hour} · {formatNumber(item.count)} 次 · 异常{" "}
{formatNumber(item.abnormalCount)}
</div>
</div>
);
})}
{(!data?.hourlyStats || data.hourlyStats.length === 0) && (
<div className="flex h-full w-full items-center justify-center rounded-lg border border-dashed text-muted-foreground text-sm">
暂无访问记录
</div>
)}
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-4">
<CardTitle className="text-lg">手动 IP 黑名单</CardTitle>
<CardDescription>
管理员手动加入后,命中 IP 会被阻断并写入访问日志
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 lg:grid-cols-[220px_minmax(240px,1fr)_auto]">
<Input
className="h-9"
onChange={(event) => setBlockIp(event.target.value)}
placeholder="IP 或 IPv4 CIDR"
value={blockIp}
/>
<Input
className="h-9"
onChange={(event) => setBlockReason(event.target.value)}
placeholder="拉黑原因"
value={blockReason}
/>
<Button
className="h-9 gap-2"
disabled={isSavingBlock}
onClick={() => saveBlockEntry()}
size="sm"
>
<Plus className="h-4 w-4" />
加入黑名单
</Button>
</div>
<div className="grid gap-2 md:grid-cols-2 xl:grid-cols-3">
{activeBlocklistEntries.map((entry) => (
<div
className="rounded-lg border border-red-100 bg-red-50/60 px-3 py-3 dark:border-red-900/60 dark:bg-red-950/30"
key={entry.id}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<Ban className="h-4 w-4 text-red-600 dark:text-red-400" />
<span className="truncate font-mono font-semibold text-sm text-red-900 dark:text-red-200">
{entry.ip}
</span>
</div>
<div className="mt-1 line-clamp-2 text-red-800/75 text-xs dark:text-red-300/75">
{entry.reason || "未填写原因"}
</div>
<div className="mt-2 text-red-700/70 text-xs dark:text-red-400/70">
{formatTime(entry.updatedAt)}
</div>
</div>
<Button
className="h-8 shrink-0 gap-1 border-red-200 bg-white text-red-700 hover:bg-red-50 dark:border-red-800 dark:bg-red-950/30 dark:text-red-300 dark:hover:bg-red-950/60"
onClick={() => updateBlockEntry(entry, false)}
size="sm"
variant="outline"
>
<Unlock className="h-3.5 w-3.5" />
解除
</Button>
</div>
</div>
))}
{activeBlocklistEntries.length === 0 ? (
<div className="rounded-lg border border-dashed py-8 text-center text-muted-foreground text-sm md:col-span-2 xl:col-span-3">
暂无手动拉黑 IP
</div>
) : null}
</div>
</CardContent>
</Card>
<div className="grid gap-4 xl:grid-cols-[minmax(0,1.3fr)_minmax(320px,0.7fr)]">
<Card>
<CardHeader className="pb-4">
<CardTitle className="text-lg">异常 IP 标注</CardTitle>
<CardDescription>
按限流、失败率、认证异常、敏感路径和请求量综合排序
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
{(data?.topIps || []).map((item) => (
<div
className="rounded-lg border bg-background px-4 py-3"
key={item.ip}
>
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono font-semibold text-sm">
{item.ip}
</span>
<AccessSeverityBadge severity={item.severity} />
</div>
<div className="mt-2 flex flex-wrap gap-2 text-muted-foreground text-xs">
<span>{formatNumber(item.total)} 次访问</span>
<span>{formatNumber(item.uniqueUsers)} 个用户</span>
<span>失败率 {formatPercent(item.failureRate)}</span>
<span>最近 {formatTime(item.lastSeen)}</span>
</div>
</div>
<div className="flex flex-wrap items-center gap-1 md:justify-end">
{(item.reasons.length > 0 ? item.reasons : ["正常访问"]).map(
(reason) => (
<Badge
className="border-zinc-200 bg-zinc-50 text-zinc-700 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300"
key={reason}
variant="outline"
>
{reason}
</Badge>
)
)}
<Button
className="h-7 gap-1 px-2 text-xs"
disabled={blockedRules.has(item.ip.toLowerCase()) || isSavingBlock}
onClick={() =>
saveBlockEntry(
item.ip,
item.reasons.join("、") || "管理员从异常 IP 排行手动拉黑"
)
}
size="sm"
variant="outline"
>
<Ban className="h-3.5 w-3.5" />
{blockedRules.has(item.ip.toLowerCase()) ? "已拉黑" : "拉黑"}
</Button>
</div>
</div>
</div>
))}
{(!data?.topIps || data.topIps.length === 0) && (
<div className="rounded-lg border border-dashed py-10 text-center text-muted-foreground text-sm">
暂无 IP 聚合数据
</div>
)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-4">
<CardTitle className="text-lg">访问路径</CardTitle>
<CardDescription>按请求数量排序</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{(data?.pathStats || []).map((item) => {
const abnormalRate =
item.count > 0 ? item.abnormalCount / item.count : 0;
return (
<div key={`${item.routeGroup || "page"}-${item.path || "/"}`}>
<div className="flex items-center justify-between gap-3 text-sm">
<span className="min-w-0 truncate font-medium">
{item.path || "/"}
</span>
<span className="shrink-0 text-muted-foreground text-xs">
{formatNumber(item.count)}
</span>
</div>
<div className="mt-2 h-2 overflow-hidden rounded-full bg-zinc-100 dark:bg-zinc-800">
<div
className="h-full rounded-full bg-zinc-900 dark:bg-zinc-200"
style={{
width: `${Math.max(4, (item.count / maxPathCount) * 100)}%`,
}}
/>
<div
className="-mt-2 h-full rounded-full bg-orange-400"
style={{
width: `${Math.round(abnormalRate * 100)}%`,
}}
/>
</div>
</div>
);
})}
{(!data?.pathStats || data.pathStats.length === 0) && (
<div className="rounded-lg border border-dashed py-10 text-center text-muted-foreground text-sm">
暂无路径统计
</div>
)}
</div>
</CardContent>
</Card>
</div>
<Card>
<CardHeader className="pb-4">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle className="text-lg">访问日志列表</CardTitle>
<CardDescription>最近 {formatNumber(data?.recentEvents.length)} 条</CardDescription>
</div>
<div className="flex flex-wrap gap-2">
{(data?.rules || []).map((rule) => (
<Badge
className="border-zinc-200 bg-zinc-50 text-zinc-600 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300"
key={rule}
variant="outline"
>
{rule}
</Badge>
))}
</div>
</div>
</CardHeader>
<CardContent>
<div className="overflow-x-auto rounded-lg border">
<div className="min-w-[980px]">
<div className="grid grid-cols-[138px_132px_86px_minmax(220px,1fr)_140px_120px] border-b bg-zinc-50 px-4 py-2 font-medium text-muted-foreground text-xs dark:bg-zinc-900/70">
<div>时间</div>
<div>IP</div>
<div>状态</div>
<div>路径</div>
<div>用户</div>
<div>标注</div>
</div>
{(data?.recentEvents || []).map((event) => (
<div
className="grid grid-cols-[138px_132px_86px_minmax(220px,1fr)_140px_120px] items-center border-b px-4 py-3 text-sm last:border-b-0"
key={event.id}
>
<div className="text-muted-foreground text-xs">
{formatTime(event.createdAt)}
</div>
<div className="font-mono text-xs">{event.ip}</div>
<div
className={cn(
"font-semibold text-xs",
getStatusClassName(event.statusCode)
)}
>
{event.statusCode || "--"}
</div>
<div className="min-w-0">
<div className="truncate font-medium">
{event.method || "GET"} {event.path || "/"}
</div>
<div className="mt-1 truncate text-muted-foreground text-xs">
{event.reasons?.join("、") || event.outcome}
</div>
</div>
<div className="min-w-0">
<div className="truncate text-xs">
{event.userEmail || "匿名"}
</div>
<div className="mt-1 flex items-center gap-1 text-muted-foreground text-xs">
<UserCheck className="h-3 w-3" />
{event.userRole || "unknown"}
</div>
</div>
<div className="flex items-center gap-2">
{event.severity === "high" || event.severity === "critical" ? (
<AlertTriangle className="h-4 w-4 text-orange-500" />
) : null}
<AccessSeverityBadge severity={event.severity} />
</div>
</div>
))}
{(!data?.recentEvents || data.recentEvents.length === 0) && (
<div className="py-10 text-center text-muted-foreground text-sm">
暂无访问日志
</div>
)}
</div>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,148 @@
"use client";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ADMIN_SETTINGS_NAV_ITEMS } from "../_lib/settings-navigation";
import type { AdminSettingsNavItem } from "../_lib/types";
function SettingsQuickAction({
activeTab,
item,
onTabChange,
stretched = false,
}: {
activeTab: string;
item: AdminSettingsNavItem;
onTabChange: (value: string) => void;
stretched?: boolean;
}) {
const Icon = item.icon;
const className =
item.value === activeTab
? "border-foreground/10 bg-foreground text-background shadow-sm hover:!bg-foreground/90 hover:!text-background dark:border-white/20 dark:bg-white dark:text-black dark:hover:!bg-white/90 dark:hover:!text-black"
: "border-transparent bg-transparent text-muted-foreground hover:!border-border/80 hover:!bg-zinc-100 hover:!text-foreground dark:hover:!bg-zinc-900/50";
const layoutClassName = stretched
? "h-8 w-full min-w-0 justify-center gap-1.5 rounded-md border px-2.5 text-xs"
: "h-8 shrink-0 gap-1.5 rounded-md border px-2.5 text-xs";
if (item.href) {
return (
<Button
asChild
className={`${layoutClassName} ${className}`}
size="sm"
variant="ghost"
>
<Link href={item.href}>
<Icon className="h-3.5 w-3.5" />
<span className={stretched ? "truncate" : ""}>{item.label}</span>
</Link>
</Button>
);
}
return (
<Button
className={`${layoutClassName} ${className}`}
onClick={() => onTabChange(item.value)}
size="sm"
variant="ghost"
>
<Icon className="h-3.5 w-3.5" />
<span className={stretched ? "truncate" : ""}>{item.label}</span>
</Button>
);
}
export function AdminSettingsNavigation({
activeTab,
onTabChange,
}: {
activeTab: string;
onTabChange: (value: string) => void;
}) {
const router = useRouter();
const activeItem = ADMIN_SETTINGS_NAV_ITEMS.find(
(item) => item.value === activeTab
);
const handleSelectChange = (value: string) => {
const nextItem = ADMIN_SETTINGS_NAV_ITEMS.find(
(item) => item.value === value
);
if (nextItem?.href) {
router.push(nextItem.href);
return;
}
onTabChange(value);
};
return (
<nav
aria-label="系统设置导航"
className="sticky top-2 z-30 rounded-lg border bg-background/95 p-2 shadow-sm backdrop-blur supports-[backdrop-filter]:bg-background/85"
>
<div className="md:hidden">
<div className="flex items-center gap-2">
<Select onValueChange={handleSelectChange} value={activeTab}>
<SelectTrigger className="h-8 flex-1 border-border/70 bg-background shadow-none">
<SelectValue placeholder={activeItem?.label || "选择设置项"} />
</SelectTrigger>
<SelectContent>
{ADMIN_SETTINGS_NAV_ITEMS.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
asChild
className="h-8 rounded-md border-border/70 px-2.5 text-xs"
size="sm"
variant="outline"
>
<Link href="/">
<ArrowLeft className="h-3.5 w-3.5" />
返回
</Link>
</Button>
</div>
</div>
<div className="hidden min-w-0 grid-cols-7 gap-1 md:grid xl:grid-cols-[repeat(13,minmax(78px,1fr))_auto]">
{ADMIN_SETTINGS_NAV_ITEMS.map((item) => (
<SettingsQuickAction
activeTab={activeTab}
item={item}
key={item.value}
onTabChange={onTabChange}
stretched
/>
))}
<Button
asChild
className="h-8 min-w-0 rounded-md border-border/70 px-2.5 text-xs"
size="sm"
variant="outline"
>
<Link href="/">
<ArrowLeft className="h-3.5 w-3.5" />
返回工作台
</Link>
</Button>
</div>
</nav>
);
}

View File

@ -0,0 +1,148 @@
"use client";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { LOCAL_AI_QUICK_LINK_GROUPS } from "../_lib/local-ai-sections";
import type { LocalAiOverviewItem, LocalAiQuickLink } from "../_lib/types";
import { SettingStatusBadge } from "./setting-status-badge";
export function LocalAiCommandCenter({
openSectionCount,
providerCount,
modelCount,
overviewItems,
quickLinks,
isSectionExpanded,
onFocusSection,
onSetAllExpanded,
}: {
openSectionCount: number;
providerCount: number;
modelCount: number;
overviewItems: LocalAiOverviewItem[];
quickLinks: LocalAiQuickLink[];
isSectionExpanded: (sectionKey: string) => boolean;
onFocusSection: (sectionKey: string) => void;
onSetAllExpanded: (expanded: boolean) => void;
}) {
return (
<div className="overflow-hidden rounded-lg border bg-background">
<div className="border-b bg-muted/20 px-4 py-3">
<div className="flex flex-col gap-2.5 lg:flex-row lg:items-start lg:justify-between">
<div className="space-y-1">
<div className="font-semibold text-[10px] text-muted-foreground">
SYSTEM INTEGRATIONS
</div>
<h2 className="font-semibold text-lg">
高级能力与外部服务
</h2>
<p className="max-w-3xl text-muted-foreground text-xs leading-5">
把商品策划、图像处理、3D/CAD、实时画布和开放 API
按运维任务重新归类。折叠状态会保留,常用模块可以保持展开。
</p>
</div>
<div className="flex flex-wrap gap-1.5">
<SettingStatusBadge tone="neutral">
已展开 {openSectionCount} 个模块
</SettingStatusBadge>
<SettingStatusBadge tone="enabled">
供应商 {providerCount}
</SettingStatusBadge>
<SettingStatusBadge tone="neutral">
模型 {modelCount}
</SettingStatusBadge>
<Button
className="h-6 rounded-md px-2 text-[11px]"
onClick={() => onSetAllExpanded(true)}
size="sm"
type="button"
variant="outline"
>
全部展开
</Button>
<Button
className="h-6 rounded-md px-2 text-[11px]"
onClick={() => onSetAllExpanded(false)}
size="sm"
type="button"
variant="outline"
>
全部收起
</Button>
</div>
</div>
</div>
<div className="grid gap-px bg-border md:grid-cols-2 xl:grid-cols-4">
{overviewItems.map((item) => {
const Icon = item.icon;
return (
<button
className="group bg-background p-3 text-left transition-colors hover:bg-muted/30"
key={item.key}
onClick={() => onFocusSection(item.key)}
type="button"
>
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md border bg-muted/30">
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
</div>
<div className="min-w-0">
<div className="truncate font-semibold text-xs">
{item.title}
</div>
<div className="mt-0.5 truncate text-[11px] text-muted-foreground">
{item.description}
</div>
</div>
</div>
<SettingStatusBadge tone={item.tone}>
{item.status}
</SettingStatusBadge>
</div>
</button>
);
})}
</div>
<div className="border-t bg-muted/10 px-4 py-3">
<div className="grid gap-3 lg:grid-cols-4">
{LOCAL_AI_QUICK_LINK_GROUPS.map((group) => (
<div className="space-y-1.5" key={group}>
<div className="font-semibold text-[11px] text-muted-foreground">
{group}
</div>
<div className="flex flex-wrap gap-1.5">
{quickLinks
.filter((item) => item.group === group)
.map((item) => (
<button
className={cn(
"inline-flex h-6 items-center gap-1 rounded-md border px-2 text-[11px] transition-colors hover:bg-background",
isSectionExpanded(item.key)
? "border-foreground/20 bg-background text-foreground"
: "border-border bg-transparent text-muted-foreground"
)}
key={item.key}
onClick={() => onFocusSection(item.key)}
type="button"
>
<span
className={cn(
"h-1.5 w-1.5 rounded-full",
item.tone === "enabled" && "bg-emerald-500",
item.tone === "disabled" && "bg-zinc-300",
item.tone === "warning" && "bg-amber-500",
item.tone === "neutral" && "bg-sky-500"
)}
/>
{item.label}
</button>
))}
</div>
</div>
))}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,121 @@
"use client";
import { ArrowDown, ArrowUp } from "lucide-react";
import type { ReactNode } from "react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { cn } from "@/lib/utils";
import type { SettingStatusTone } from "../_lib/types";
import { SettingStatusBadge } from "./setting-status-badge";
export function LocalAiGroupHeading({
eyebrow,
title,
description,
}: {
eyebrow: string;
title: string;
description: string;
}) {
return (
<div className="rounded-lg border bg-muted/20 px-3 py-2.5">
<div className="font-semibold text-[10px] text-muted-foreground uppercase">
{eyebrow}
</div>
<div className="mt-1 flex flex-col gap-1 md:flex-row md:items-end md:justify-between">
<h3 className="font-semibold text-sm text-foreground">{title}</h3>
<p className="max-w-3xl text-muted-foreground text-xs">{description}</p>
</div>
</div>
);
}
export function LocalAiSectionSummary({
items,
}: {
items: Array<{
label: string;
tone?: SettingStatusTone;
}>;
}) {
return (
<div className="flex flex-wrap gap-1.5 pt-1">
{items.map((item) => (
<SettingStatusBadge key={item.label} tone={item.tone || "neutral"}>
{item.label}
</SettingStatusBadge>
))}
</div>
);
}
export function LocalAiSectionToggleButton({
expanded,
onClick,
}: {
expanded: boolean;
onClick: () => void;
}) {
return (
<Button
aria-expanded={expanded}
className="h-7 shrink-0 gap-1 rounded-md px-2 text-[11px]"
onClick={onClick}
size="sm"
type="button"
variant="outline"
>
{expanded ? (
<ArrowUp className="h-3 w-3" />
) : (
<ArrowDown className="h-3 w-3" />
)}
{expanded ? "收起配置" : "展开配置"}
</Button>
);
}
export function LocalAiSectionShell({
id,
title,
description,
summary,
expanded,
onToggle,
children,
}: {
id: string;
title: ReactNode;
description: ReactNode;
summary?: ReactNode;
expanded: boolean;
onToggle: () => void;
children: ReactNode;
}) {
return (
<Card id={id}>
<CardHeader className="flex flex-col gap-3 space-y-0 md:flex-row md:items-start md:justify-between">
<div className="space-y-1">
<CardTitle className="text-base">{title}</CardTitle>
<CardDescription>{description}</CardDescription>
{summary}
</div>
<LocalAiSectionToggleButton expanded={expanded} onClick={onToggle} />
</CardHeader>
<CardContent
className={cn(
"local-ai-config-card-content space-y-4 pt-4",
!expanded && "hidden"
)}
>
{children}
</CardContent>
</Card>
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,754 @@
"use client";
import { Save } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL,
DETAIL_AGENT_LOCAL_MOONSHOT_DISPLAY_NAME,
DETAIL_AGENT_LOCAL_MOONSHOT_MODEL_ID,
} from "@/lib/ai/detail-agent-model-config";
import {
PRODUCT_PLANNING_AI_MODEL_DEFAULT_BASE_URL,
PRODUCT_PLANNING_AI_MODEL_DEFAULT_MODEL_ID,
} from "@/lib/product-planning/model-config";
import type { LocalAiPlanningSectionsProps } from "../../_lib/types";
import {
LocalAiGroupHeading,
LocalAiSectionShell,
LocalAiSectionSummary,
} from "../local-ai-section-shell";
export function LocalAiPlanningSections({
siteSettings,
setSiteSettings,
productPlanningApiConfig,
setProductPlanningApiConfig,
productPlanningAiModelConfig,
setProductPlanningAiModelConfig,
moonshotDetailAgentConfig,
setMoonshotDetailAgentConfig,
isLocalAiSectionExpanded,
toggleLocalAiSection,
handleSaveProductPlanningApiConfig,
handleSaveProductPlanningAiModelConfig,
handleSaveMoonshotDetailAgentConfig,
}: LocalAiPlanningSectionsProps) {
return (
<>
<LocalAiGroupHeading
description="把电商数据源、商品策划专用模型、详情 Agent 和业务数据库放在同一组,减少跨页面查找。"
eyebrow="Planning"
title="商品策划与数据集成"
/>
<LocalAiSectionShell
description={
<>
配置商品策划工具页面使用的淘宝 / 京东
Onebound、问大家和销量接口。保存后商品策划相关接口会优先读取这里的配置,未填写时才回退到环境变量。
</>
}
expanded={isLocalAiSectionExpanded("product-planning-api")}
id="local-ai-section-product-planning-api"
onToggle={() => toggleLocalAiSection("product-planning-api")}
summary={
<LocalAiSectionSummary
items={[
{
label: siteSettings.productPlanningEnabled
? "功能已启用"
: "功能已关闭",
tone: siteSettings.productPlanningEnabled
? "enabled"
: "disabled",
},
{
label: productPlanningApiConfig.oneboundBaseUrl
? "Onebound 已配置"
: "Onebound 待配置",
tone: productPlanningApiConfig.oneboundBaseUrl
? "enabled"
: "warning",
},
{
label: productPlanningApiConfig.planningDbHost
? "回写库已配置"
: "回写库待配置",
tone: productPlanningApiConfig.planningDbHost
? "enabled"
: "neutral",
},
]}
/>
}
title="商品策划工具 API 配置"
>
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="flex-1 space-y-1">
<Label className="text-base">启用商品策划工具</Label>
<div className="text-muted-foreground text-sm">
关闭后左侧导航入口会隐藏,商品策划页面和相关接口也会一并禁用。
</div>
</div>
<Switch
checked={siteSettings.productPlanningEnabled}
onCheckedChange={(checked) =>
setSiteSettings({
...siteSettings,
productPlanningEnabled: checked,
})
}
/>
</div>
<div className="grid gap-6 lg:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="productPlanningOneboundBaseUrl">
Onebound API 地址
</Label>
<Input
id="productPlanningOneboundBaseUrl"
onChange={(e) =>
setProductPlanningApiConfig((prev) => ({
...prev,
oneboundBaseUrl: e.target.value,
}))
}
placeholder="https://api-gw.onebound.cn"
value={productPlanningApiConfig.oneboundBaseUrl}
/>
<div className="text-muted-foreground text-xs">
用于淘宝评价、商品详情、关键词洞察以及京东商品详情。
</div>
</div>
<div className="space-y-2">
<Label htmlFor="productPlanningDetailPlusBaseUrl">
详情增强接口地址(公网)
</Label>
<Input
id="productPlanningDetailPlusBaseUrl"
onChange={(e) =>
setProductPlanningApiConfig((prev) => ({
...prev,
detailPlusBaseUrl: e.target.value,
}))
}
placeholder="http://ltai5tdb2ogyj59fquyedh.yydd.ai:18082/api/public/taobao-detail-plus"
value={productPlanningApiConfig.detailPlusBaseUrl}
/>
<div className="text-muted-foreground text-xs">
竞品分析会优先用这个接口查询商品详情,查不到再走 Onebound 旧接口。
</div>
</div>
<div className="space-y-2">
<Label htmlFor="productPlanningDetailPlusInternalBaseUrl">
详情增强接口地址(内网,可选)
</Label>
<Input
id="productPlanningDetailPlusInternalBaseUrl"
onChange={(e) =>
setProductPlanningApiConfig((prev) => ({
...prev,
detailPlusInternalBaseUrl: e.target.value,
}))
}
placeholder="http://127.0.0.1:4000/api/public/taobao-detail-plus"
value={productPlanningApiConfig.detailPlusInternalBaseUrl}
/>
<div className="text-muted-foreground text-xs">
服务器同机部署时可填内网地址,系统会先尝试内网再尝试公网。
</div>
</div>
<div className="space-y-2 lg:col-span-2">
<Label htmlFor="productPlanningDetailPlusApiKey">
详情增强接口 API Key
</Label>
<Input
id="productPlanningDetailPlusApiKey"
onChange={(e) =>
setProductPlanningApiConfig((prev) => ({
...prev,
detailPlusApiKey: e.target.value,
}))
}
placeholder="留空保存时会保留原 API Key"
type="password"
value={productPlanningApiConfig.detailPlusApiKey}
/>
</div>
<div className="space-y-2">
<Label htmlFor="productPlanningQaApiUrl">问大家接口地址</Label>
<Input
id="productPlanningQaApiUrl"
onChange={(e) =>
setProductPlanningApiConfig((prev) => ({
...prev,
qaApiUrl: e.target.value,
}))
}
placeholder="http://119.91.237.2:5432/taobao_social_feed_aggregate"
value={productPlanningApiConfig.qaApiUrl}
/>
<div className="text-muted-foreground text-xs">
用于商品策划工具中的问大家抓取。
</div>
</div>
<div className="space-y-2">
<Label htmlFor="productPlanningTaobaoApiKey">
淘宝 / 京东 API Key
</Label>
<Input
id="productPlanningTaobaoApiKey"
onChange={(e) =>
setProductPlanningApiConfig((prev) => ({
...prev,
taobaoApiKey: e.target.value,
}))
}
placeholder="请输入 Onebound API Key"
type="password"
value={productPlanningApiConfig.taobaoApiKey}
/>
</div>
<div className="space-y-2">
<Label htmlFor="productPlanningTaobaoApiSecret">
淘宝 / 京东 API Secret
</Label>
<Input
id="productPlanningTaobaoApiSecret"
onChange={(e) =>
setProductPlanningApiConfig((prev) => ({
...prev,
taobaoApiSecret: e.target.value,
}))
}
placeholder="请输入 Onebound API Secret"
type="password"
value={productPlanningApiConfig.taobaoApiSecret}
/>
</div>
<div className="space-y-2">
<Label htmlFor="productPlanningQaToken">问大家 Token</Label>
<Input
id="productPlanningQaToken"
onChange={(e) =>
setProductPlanningApiConfig((prev) => ({
...prev,
qaToken: e.target.value,
}))
}
placeholder="请输入问大家抓取 Token"
type="password"
value={productPlanningApiConfig.qaToken}
/>
</div>
<div className="space-y-2">
<Label htmlFor="productPlanningSaleApiUrl">销量接口地址</Label>
<Input
id="productPlanningSaleApiUrl"
onChange={(e) =>
setProductPlanningApiConfig((prev) => ({
...prev,
saleApiUrl: e.target.value,
}))
}
placeholder="http://119.91.237.2:5432/taobao_item_month_sale"
value={productPlanningApiConfig.saleApiUrl}
/>
<div className="text-muted-foreground text-xs">
用于商品策划工具中的销量查询。
</div>
</div>
<div className="space-y-2">
<Label htmlFor="productPlanningSaleToken">销量 Token</Label>
<Input
id="productPlanningSaleToken"
onChange={(e) =>
setProductPlanningApiConfig((prev) => ({
...prev,
saleToken: e.target.value,
}))
}
placeholder="请输入销量查询 Token"
type="password"
value={productPlanningApiConfig.saleToken}
/>
</div>
<div className="rounded-lg border bg-muted/30 p-4 lg:col-span-2">
<div className="mb-4 flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<div className="font-medium">企划数据库回写配置</div>
<div className="text-muted-foreground text-xs">
旧接口兜底查到的淘宝详情会同步写入新的企划详情表,供后续新接口命中。
</div>
</div>
<div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">SSL</span>
<Switch
checked={productPlanningApiConfig.planningDbSsl}
onCheckedChange={(checked) =>
setProductPlanningApiConfig((prev) => ({
...prev,
planningDbSsl: checked,
}))
}
/>
</div>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="productPlanningDbHost">数据库 Host</Label>
<Input
id="productPlanningDbHost"
onChange={(e) =>
setProductPlanningApiConfig((prev) => ({
...prev,
planningDbHost: e.target.value,
}))
}
placeholder="156.225.27.52"
value={productPlanningApiConfig.planningDbHost}
/>
</div>
<div className="space-y-2">
<Label htmlFor="productPlanningDbPort">数据库端口</Label>
<Input
id="productPlanningDbPort"
onChange={(e) =>
setProductPlanningApiConfig((prev) => ({
...prev,
planningDbPort: e.target.value,
}))
}
placeholder="60023"
value={productPlanningApiConfig.planningDbPort}
/>
</div>
<div className="space-y-2">
<Label htmlFor="productPlanningDbDatabase">数据库名</Label>
<Input
id="productPlanningDbDatabase"
onChange={(e) =>
setProductPlanningApiConfig((prev) => ({
...prev,
planningDbDatabase: e.target.value,
}))
}
placeholder="planning_database"
value={productPlanningApiConfig.planningDbDatabase}
/>
</div>
<div className="space-y-2">
<Label htmlFor="productPlanningDbUser">数据库账号</Label>
<Input
id="productPlanningDbUser"
onChange={(e) =>
setProductPlanningApiConfig((prev) => ({
...prev,
planningDbUser: e.target.value,
}))
}
placeholder="planning_user"
value={productPlanningApiConfig.planningDbUser}
/>
</div>
<div className="space-y-2">
<Label htmlFor="productPlanningDbPassword">数据库密码</Label>
<Input
id="productPlanningDbPassword"
onChange={(e) =>
setProductPlanningApiConfig((prev) => ({
...prev,
planningDbPassword: e.target.value,
}))
}
placeholder="留空保存时会保留原密码"
type="password"
value={productPlanningApiConfig.planningDbPassword}
/>
</div>
<div className="space-y-2">
<Label htmlFor="productPlanningDbCharset">字符集</Label>
<Input
id="productPlanningDbCharset"
onChange={(e) =>
setProductPlanningApiConfig((prev) => ({
...prev,
planningDbCharset: e.target.value,
}))
}
placeholder="utf8mb4"
value={productPlanningApiConfig.planningDbCharset}
/>
</div>
</div>
</div>
</div>
<div className="space-y-1 rounded-lg border border-blue-200 bg-blue-50 p-4 text-blue-800 text-xs">
<p>
• 当前功能状态:
{siteSettings.productPlanningEnabled
? "已启用,左侧导航和接口开放中。"
: "已关闭,入口、页面和接口都会被禁用。"}
</p>
<p>
•
竞品分析会优先读取详情增强接口;未命中时自动走旧接口,并将旧结果回写到企划详情表。
</p>
<p>
•
若某个字段留空,系统会继续尝试读取旧环境变量,避免你切换配置期间功能直接中断。
</p>
</div>
<div className="flex justify-end">
<Button onClick={handleSaveProductPlanningApiConfig}>
<Save className="mr-2 h-4 w-4" />
保存商品策划工具设置
</Button>
</div>
</LocalAiSectionShell>
<LocalAiSectionShell
description={
<>
为商品策划工具单独绑定一套分析模型。开启后,评价分析、问大家分析、竞品图片分析和
SKU 卖点分析会优先走这里配置的 OpenAI
兼容模型,不再依赖站点默认模型。
</>
}
expanded={isLocalAiSectionExpanded("product-planning-ai-model")}
id="local-ai-section-product-planning-ai-model"
onToggle={() => toggleLocalAiSection("product-planning-ai-model")}
summary={
<LocalAiSectionSummary
items={[
{
label: productPlanningAiModelConfig.enabled
? "专用模型已启用"
: "回退默认模型",
tone: productPlanningAiModelConfig.enabled
? "enabled"
: "neutral",
},
{
label: productPlanningAiModelConfig.modelId || "模型 ID 待配置",
tone: productPlanningAiModelConfig.modelId
? "neutral"
: "warning",
},
]}
/>
}
title="商品策划专用 AI 模型"
>
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="flex-1 space-y-1">
<Label className="text-base">启用专用模型</Label>
<div className="text-muted-foreground text-sm">
推荐在商品策划需要固定分析口径时开启。关闭后会自动回退到站点当前可用的默认模型。
</div>
</div>
<Switch
checked={productPlanningAiModelConfig.enabled}
onCheckedChange={(checked) =>
setProductPlanningAiModelConfig((prev) => ({
...prev,
enabled: checked,
}))
}
/>
</div>
<div className="grid gap-6 lg:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="productPlanningAiProviderName">提供商名称</Label>
<Input
id="productPlanningAiProviderName"
onChange={(e) =>
setProductPlanningAiModelConfig((prev) => ({
...prev,
providerName: e.target.value,
}))
}
placeholder="Product Planning Qwen3-VL"
value={productPlanningAiModelConfig.providerName}
/>
</div>
<div className="space-y-2">
<Label htmlFor="productPlanningAiDisplayName">显示名称</Label>
<Input
id="productPlanningAiDisplayName"
onChange={(e) =>
setProductPlanningAiModelConfig((prev) => ({
...prev,
displayName: e.target.value,
}))
}
placeholder="商品策划专用 Qwen3-VL"
value={productPlanningAiModelConfig.displayName}
/>
</div>
<div className="space-y-2">
<Label htmlFor="productPlanningAiBaseUrl">
OpenAI 兼容 API 地址
</Label>
<Input
id="productPlanningAiBaseUrl"
onChange={(e) =>
setProductPlanningAiModelConfig((prev) => ({
...prev,
baseUrl: e.target.value,
}))
}
placeholder={PRODUCT_PLANNING_AI_MODEL_DEFAULT_BASE_URL}
value={productPlanningAiModelConfig.baseUrl}
/>
<div className="text-muted-foreground text-xs">
支持填写服务根地址,例如
`http://103.118.245.6:6081`。系统会自动兼容补全 `/v1` 路径。
</div>
</div>
<div className="space-y-2">
<Label htmlFor="productPlanningAiModelId">模型 ID</Label>
<Input
id="productPlanningAiModelId"
onChange={(e) =>
setProductPlanningAiModelConfig((prev) => ({
...prev,
modelId: e.target.value,
}))
}
placeholder={PRODUCT_PLANNING_AI_MODEL_DEFAULT_MODEL_ID}
value={productPlanningAiModelConfig.modelId}
/>
<div className="text-muted-foreground text-xs">
你的 Qwen3-VL OpenAI 兼容接口若使用默认模型名,可直接保留
`Qwen3-VL-4B-Instruct`。
</div>
</div>
<div className="space-y-2 lg:col-span-2">
<Label htmlFor="productPlanningAiApiKey">API Key</Label>
<Input
id="productPlanningAiApiKey"
onChange={(e) =>
setProductPlanningAiModelConfig((prev) => ({
...prev,
apiKey: e.target.value,
}))
}
placeholder="请输入商品策划专用模型 API Key"
type="password"
value={productPlanningAiModelConfig.apiKey}
/>
</div>
</div>
<div className="space-y-1 rounded-lg border border-blue-200 bg-blue-50 p-4 text-blue-800 text-xs">
<p>
•
开启后,商品策划页的文本分析和图片分析都会优先使用这里配置的独立模型。
</p>
<p>
•
关闭后,系统会继续回退到站点默认文本/视觉模型,不会影响其他页面的模型设置。
</p>
</div>
<div className="flex justify-end">
<Button onClick={handleSaveProductPlanningAiModelConfig}>
<Save className="mr-2 h-4 w-4" />
保存商品策划专用模型
</Button>
</div>
</LocalAiSectionShell>
<LocalAiSectionShell
description={
<>
为 AI详情生成 单独配置 Kimi Code 编程套餐
模型。保存后会自动绑定到“AI详情生成 - Agent专用模型”。
</>
}
expanded={isLocalAiSectionExpanded("moonshot-detail-agent")}
id="local-ai-section-moonshot-detail-agent"
onToggle={() => toggleLocalAiSection("moonshot-detail-agent")}
summary={
<LocalAiSectionSummary
items={[
{
label: moonshotDetailAgentConfig.isEnabled
? "模型已启用"
: "模型已关闭",
tone: moonshotDetailAgentConfig.isEnabled
? "enabled"
: "disabled",
},
{
label: moonshotDetailAgentConfig.modelId || "模型 ID 待配置",
tone: moonshotDetailAgentConfig.modelId ? "neutral" : "warning",
},
]}
/>
}
title={<>Kimi Code 编程套餐配置</>}
>
<div className="space-y-2">
<Label htmlFor="moonshotProviderName">提供商名称</Label>
<Input
id="moonshotProviderName"
onChange={(e) =>
setMoonshotDetailAgentConfig((prev) => ({
...prev,
providerName: e.target.value,
}))
}
placeholder="Kimi Code 编程套餐"
value={moonshotDetailAgentConfig.providerName}
/>
</div>
<div className="space-y-2">
<Label htmlFor="moonshotBaseUrl">编程套餐专用 API 地址</Label>
<Input
id="moonshotBaseUrl"
onChange={(e) =>
setMoonshotDetailAgentConfig((prev) => ({
...prev,
baseUrl: e.target.value,
}))
}
placeholder={DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL}
value={moonshotDetailAgentConfig.baseUrl}
/>
<div className="text-muted-foreground text-xs">
根据 Kimi Code 文档,编程套餐 OpenAI 兼容接口地址为 `
{DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL}`
</div>
</div>
<div className="space-y-2">
<Label htmlFor="moonshotApiKey">
API Key{" "}
{moonshotDetailAgentConfig.providerId ? "(留空则不修改)" : "*"}
</Label>
<Input
id="moonshotApiKey"
onChange={(e) =>
setMoonshotDetailAgentConfig((prev) => ({
...prev,
apiKey: e.target.value,
}))
}
placeholder={
moonshotDetailAgentConfig.providerId
? "留空则不修改当前 Key"
: "请输入 Kimi Code 控制台 API Key"
}
type="password"
value={moonshotDetailAgentConfig.apiKey}
/>
</div>
<div className="space-y-2">
<Label htmlFor="moonshotModelId">Kimi Code API 模型 ID *</Label>
<Input
id="moonshotModelId"
onChange={(e) =>
setMoonshotDetailAgentConfig((prev) => ({
...prev,
modelId: e.target.value,
}))
}
placeholder={DETAIL_AGENT_LOCAL_MOONSHOT_MODEL_ID}
value={moonshotDetailAgentConfig.modelId}
/>
<div className="text-muted-foreground text-xs">
Kimi Code 编程套餐专用接口固定使用
`kimi-for-coding`;保存后系统会自动创建/更新对应模型记录。
</div>
</div>
<div className="space-y-2">
<Label htmlFor="moonshotDisplayName">模型显示名称</Label>
<Input
id="moonshotDisplayName"
onChange={(e) =>
setMoonshotDetailAgentConfig((prev) => ({
...prev,
displayName: e.target.value,
}))
}
placeholder={DETAIL_AGENT_LOCAL_MOONSHOT_DISPLAY_NAME}
value={moonshotDetailAgentConfig.displayName}
/>
</div>
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<Label className="text-sm">启用该详情 Agent 模型</Label>
<div className="text-muted-foreground text-xs">
关闭后仍会保留记录,但不会被 AI详情生成 选作专用 Agent 模型。
</div>
</div>
<div className="flex items-center gap-2">
<Switch
checked={moonshotDetailAgentConfig.isEnabled}
onCheckedChange={(checked) =>
setMoonshotDetailAgentConfig((prev) => ({
...prev,
isEnabled: checked,
}))
}
/>
<span className="min-w-[3rem] font-medium text-muted-foreground text-sm">
{moonshotDetailAgentConfig.isEnabled ? "已开启" : "已关闭"}
</span>
</div>
</div>
<div className="space-y-1 rounded-lg border border-blue-200 bg-blue-50 p-4 text-blue-800 text-xs">
<p>
• 专用于 AI详情生成 的分镜规划与排版复审,不影响普通 Agent 对话。
</p>
<p>
• 系统会按多模态 Agent
模型写入:支持视觉、支持结构化规划、支持协议复审。
</p>
<p>• 保存成功后会自动写入“AI详情生成 - Agent专用模型”。</p>
</div>
<div className="flex justify-end">
<Button onClick={handleSaveMoonshotDetailAgentConfig}>
<Save className="mr-2 h-4 w-4" />
保存 Kimi Code 编程套餐配置
</Button>
</div>
</LocalAiSectionShell>
</>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,30 @@
"use client";
import type { ReactNode } from "react";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import type { SettingStatusTone } from "../_lib/types";
export function SettingStatusBadge({
tone,
children,
}: {
tone: SettingStatusTone;
children: ReactNode;
}) {
return (
<Badge
className={cn(
"h-5 rounded-md border px-1.5 font-medium text-[10px]",
tone === "enabled" &&
"border-emerald-200 bg-emerald-50 text-emerald-700",
tone === "disabled" && "border-zinc-200 bg-zinc-50 text-zinc-500",
tone === "warning" && "border-amber-200 bg-amber-50 text-amber-700",
tone === "neutral" && "border-sky-200 bg-sky-50 text-sky-700"
)}
variant="outline"
>
{children}
</Badge>
);
}

View File

@ -0,0 +1,33 @@
export const LOCAL_AI_EXPANDED_STORAGE_KEY =
"admin-settings:local-ai-expanded-sections";
export const DEFAULT_LOCAL_AI_EXPANDED_SECTIONS: Record<string, boolean> = {
"product-planning-api": true,
"product-planning-ai-model": false,
"moonshot-detail-agent": false,
tripo3d: false,
worldlabs3d: false,
"3d-model-alias": false,
"kittycad-cad": false,
rmbg: false,
enhance: false,
"enhance-pro": false,
"canvas-local-edit": false,
"ai-wash": false,
extend: false,
"watermark-remove": false,
"angle-edit": false,
"layer-split": false,
"third-party-sqlserver": false,
"external-image-api": true,
"realtime-canvas": false,
"canvas-image-task-queue": false,
"batch-process": false,
};
export const LOCAL_AI_QUICK_LINK_GROUPS = [
"商品策划",
"3D/CAD",
"图像处理",
"运行控制",
] as const;

View File

@ -0,0 +1,120 @@
import {
BarChart3,
Bell,
Bot,
HardDrive,
Image as ImageIcon,
Key,
Menu,
MessageSquare,
Scissors,
Settings,
ShieldAlert,
Sparkles,
User,
} from "lucide-react";
import type { AdminSettingsNavGroup } from "./types";
export const ADMIN_SETTINGS_NAV_GROUPS: AdminSettingsNavGroup[] = [
{
title: "运营与数据",
description: "日常运营最常用入口,优先看用户、资产记录和效果数据。",
items: [
{
value: "users",
label: "用户管理",
description: "用户、权限与账号状态",
icon: User,
},
{
value: "images",
label: "生图/视频记录",
description: "生成资产、任务与产物追踪",
icon: ImageIcon,
},
{
value: "analytics",
label: "数据分析",
description: "用量、趋势与运营指标",
icon: BarChart3,
},
{
value: "access-security",
label: "访问安全",
description: "访问 IP、异常标注与登录来源",
icon: ShieldAlert,
},
],
},
{
title: "站点体验",
description: "面向用户可见的站点配置,影响导航、文案、认证和通知体验。",
items: [
{
value: "site",
label: "网站设置",
description: "基础信息、展示与安全策略",
icon: Settings,
},
{
value: "style-pro",
label: "风格化 PRO",
description: "独立高级风格化配置",
icon: Sparkles,
href: "/settings/style-pro",
},
{
value: "nav",
label: "导航设置",
description: "菜单、入口与站内路径",
icon: Menu,
},
{
value: "questions",
label: "快捷短语",
description: "预设问题与提示短语",
icon: MessageSquare,
},
{
value: "auth",
label: "认证设置",
description: "登录、注册与访问控制",
icon: Key,
},
{
value: "notification",
label: "钉钉通知",
description: "告警、审批与业务通知",
icon: Bell,
},
],
},
{
title: "模型与基础设施",
description: "上线前重点检查,集中管理模型、存储和外部服务依赖。",
items: [
{
value: "ai",
label: "AI 模型",
description: "供应商、模型与 Key 池",
icon: Bot,
},
{
value: "storage",
label: "存储配置",
description: "对象存储、迁移与清理",
icon: HardDrive,
},
{
value: "rmbg",
label: "高级能力",
description: "本地 AI、3D/CAD 与图像处理",
icon: Scissors,
},
],
},
];
export const ADMIN_SETTINGS_NAV_ITEMS = ADMIN_SETTINGS_NAV_GROUPS.flatMap(
(group) => group.items
);

View File

@ -0,0 +1,472 @@
import type { LucideIcon } from "lucide-react";
import type { Dispatch, DragEvent, ReactNode, SetStateAction } from "react";
import type { CanvasImageTaskQueueConfig } from "@/lib/canvas/image-task-config";
import type { CanvasLocalEditConfig } from "@/lib/canvas/local-edit-config";
import type { AiWashConfig } from "@/lib/ai-wash/config";
import type {
EnhanceProKreaConfig,
EnhanceProKreaModel,
EnhanceProPreset,
} from "@/lib/enhance-pro/config";
import type { ThreeDModelAliasConfig } from "@/lib/model3d/model-alias-config";
import type { PointsSystemConfig } from "@/lib/points/config";
import type { ProductPlanningApiConfig } from "@/lib/product-planning/config";
import type { ProductPlanningAiModelConfig } from "@/lib/product-planning/model-config";
import type { DEFAULT_LOCAL_AI_EXPANDED_SECTIONS } from "./local-ai-sections";
export type SettingStatusTone = "enabled" | "disabled" | "warning" | "neutral";
export type AdminSettingsNavItem = {
value: string;
label: string;
description: string;
icon: LucideIcon;
href?: string;
};
export type AdminSettingsNavGroup = {
title: string;
description: string;
items: AdminSettingsNavItem[];
};
export type LocalAiOverviewItem = {
key: string;
title: string;
description: string;
status: string;
tone: SettingStatusTone;
icon: LucideIcon;
};
export type LocalAiQuickLinkGroup =
| "商品策划"
| "3D/CAD"
| "图像处理"
| "运行控制";
export type LocalAiQuickLink = {
key: string;
label: string;
group: LocalAiQuickLinkGroup;
tone: SettingStatusTone;
};
export type LocalAiSectionKey = keyof typeof DEFAULT_LOCAL_AI_EXPANDED_SECTIONS;
export type LocalAiSectionControlProps = {
isLocalAiSectionExpanded: (sectionKey: LocalAiSectionKey) => boolean;
toggleLocalAiSection: (sectionKey: LocalAiSectionKey) => void;
};
export type AsyncAction = () => void | Promise<void>;
type BivariantStateSetter<T> = {
bivarianceHack(value: SetStateAction<T>): void;
}["bivarianceHack"];
export type AdminAiProvider = {
id: string;
name: string;
type:
| "alibaba"
| "openai"
| "custom"
| "local"
| "gemini"
| "bltcy-video"
| "grsai-gpt-image"
| "grsai-video"
| "huanke-gemini"
| "qianwen-video"
| "doubao-video"
| "ruxa-video"
| "volcano-vision"
| "volcano-video"
| "alibaba-image"
| "bfl-image"
| "ruxa-image"
| "fal-image"
| "snapgen-image"
| "openai-image"
| "bananaimage"
| "visionary"
| "atlascloud"
| "vip98k"
| "stepfun-image"
| "midjourney"
| "fal-video"
| "zhipu"
| "tripo-3d"
| "worldlabs-3d"
| "kittycad-cad"
| "runway"
| "krea"
| "volcano-multimodal";
baseUrl: string;
apiKey: string;
isEnabled: boolean;
createdAt: string;
};
export type AdminAiModel = {
id: string;
providerId: string;
modelId: string;
displayName: string;
supportsVision: boolean;
supportsImageGen: boolean;
isEnabled: boolean;
isHidden: boolean;
isDefault: boolean;
};
export type AdminProvidersData = {
providers: AdminAiProvider[];
};
export type AdminModelsData = {
models: AdminAiModel[];
};
export type PlanningSiteSettings = {
productPlanningEnabled: boolean;
};
export type MoonshotDetailAgentConfig = {
providerId: string;
modelEntryId: string;
providerName: string;
baseUrl: string;
apiKey: string;
modelId: string;
displayName: string;
isEnabled: boolean;
};
export type LocalAiPlanningSectionsProps = LocalAiSectionControlProps & {
siteSettings: PlanningSiteSettings;
setSiteSettings: BivariantStateSetter<PlanningSiteSettings>;
productPlanningApiConfig: ProductPlanningApiConfig;
setProductPlanningApiConfig: Dispatch<
SetStateAction<ProductPlanningApiConfig>
>;
productPlanningAiModelConfig: ProductPlanningAiModelConfig;
setProductPlanningAiModelConfig: Dispatch<
SetStateAction<ProductPlanningAiModelConfig>
>;
moonshotDetailAgentConfig: MoonshotDetailAgentConfig;
setMoonshotDetailAgentConfig: Dispatch<
SetStateAction<MoonshotDetailAgentConfig>
>;
handleSaveProductPlanningApiConfig: AsyncAction;
handleSaveProductPlanningAiModelConfig: AsyncAction;
handleSaveMoonshotDetailAgentConfig: AsyncAction;
};
export type AiProviderSettingsConfig = {
providerId: string;
name: string;
baseUrl: string;
apiKey: string;
isEnabled: boolean;
};
export type WorldLabsKeyPoolConfig = {
importToken: string;
providerName: string;
baseUrl: string;
isEnabled: boolean;
};
export type LocalAiModel3dSectionsProps = LocalAiSectionControlProps & {
tripo3dConfig: AiProviderSettingsConfig;
setTripo3dConfig: Dispatch<SetStateAction<AiProviderSettingsConfig>>;
tripoProviders: AdminAiProvider[];
filteredTripoProviders: AdminAiProvider[];
enabledTripoProviderCount: number;
tripoProviderSearch: string;
setTripoProviderSearch: Dispatch<SetStateAction<string>>;
tripoProviderBusyId: string | null;
worldlabs3dConfig: AiProviderSettingsConfig;
setWorldlabs3dConfig: Dispatch<SetStateAction<AiProviderSettingsConfig>>;
worldlabsKeyPoolConfig: WorldLabsKeyPoolConfig;
setWorldlabsKeyPoolConfig: Dispatch<SetStateAction<WorldLabsKeyPoolConfig>>;
worldlabsApiKeysDraft: string;
setWorldlabsApiKeysDraft: Dispatch<SetStateAction<string>>;
worldlabsProviders: AdminAiProvider[];
filteredWorldlabsProviders: AdminAiProvider[];
enabledWorldlabsProviderCount: number;
worldlabsProviderSearch: string;
setWorldlabsProviderSearch: Dispatch<SetStateAction<string>>;
worldlabsProviderBusyId: string | null;
threeDModelAliasConfig: ThreeDModelAliasConfig;
setThreeDModelAliasConfig: Dispatch<SetStateAction<ThreeDModelAliasConfig>>;
kittycadCadConfig: AiProviderSettingsConfig;
setKittycadCadConfig: Dispatch<SetStateAction<AiProviderSettingsConfig>>;
isTestingKittycadCad: boolean;
getProviderDefaultBaseUrl: (type?: string) => string;
handleEditTripoPoolProvider: (provider: AdminAiProvider) => void;
handleToggleTripoPoolProvider: (
provider: AdminAiProvider,
checked: boolean
) => void | Promise<void>;
handleDeleteProvider: (id: string) => void | Promise<void>;
handleSaveTripo3dConfig: AsyncAction;
handleAddTripo3dPoolKey: AsyncAction;
handleImportWorldlabs3dPoolKeys: AsyncAction;
handleAddWorldlabs3dPoolKey: AsyncAction;
handleEditWorldlabsPoolProvider: (provider: AdminAiProvider) => void;
handleToggleWorldlabsPoolProvider: (
provider: AdminAiProvider,
checked: boolean
) => void | Promise<void>;
handleSaveWorldlabs3dConfig: AsyncAction;
handleAddThreeDModelAliasEntry: (
group: "tripoModels" | "worldModels"
) => void;
handleThreeDModelAliasEntryChange: (
group: "tripoModels" | "worldModels",
index: number,
field: keyof ThreeDModelAliasConfig["tripoModels"][number],
value: ThreeDModelAliasConfig["tripoModels"][number][keyof ThreeDModelAliasConfig["tripoModels"][number]]
) => void;
handleRemoveThreeDModelAliasEntry: (
group: "tripoModels" | "worldModels",
index: number
) => void;
handleResetThreeDModelAliasConfig: () => void;
handleSaveThreeDModelAliasConfig: AsyncAction;
handleTestKittycadCadConfig: AsyncAction;
handleSaveKittycadCadConfig: AsyncAction;
};
export type EnhanceProKeyRow = {
id: string;
name: string;
maskedKey: string;
index?: number;
};
export type RmbgConfig = {
provider: "vip98k" | "rmbg" | "photoroom" | "aoscdn";
apiUrl: string;
apiKey: string;
photoroomApiKey: string;
aosApiKey: string;
aosApiBaseUrl: string;
aosForegroundType: "auto" | "person" | "object" | "stamp";
aosCrop: boolean;
aosFormat: "png" | "jpg";
};
export type EnhanceConfig = {
provider: "vip98k" | "legacy";
apiUrl: string;
apiKey: string;
type: string;
scaleFactor: string;
useBackupApi: boolean;
backupApiUrl: string;
backupApiKey: string;
backupScale: string;
backupModel: string;
};
export type ExtendConfig = {
provider: "vip98k" | "dashscope";
apiKey: string;
xScale: string;
yScale: string;
};
export type WatermarkRemoveConfig = {
apiKey: string;
};
export type AngleEditConfig = {
enabled: boolean;
falApiKey: string;
loraScale: number;
inferenceSteps: number;
guidanceScale: number;
maxImageSize: number;
timeout: number;
};
export type LayerSplitConfig = {
enabled: boolean;
provider: "replicate";
replicateApiToken: string;
replicateModel: string;
layers: number;
resolution: number;
trueCfgScale: number;
numInferenceSteps: number;
cfgNormalize: boolean;
useEnPrompt: boolean;
negativePrompt: string;
timeoutMs: number;
};
export type LocalAiImagePipelineSectionsProps = LocalAiSectionControlProps & {
modelsData?: AdminModelsData;
rmbgConfig: RmbgConfig;
setRmbgConfig: Dispatch<SetStateAction<RmbgConfig>>;
enhanceConfig: EnhanceConfig;
setEnhanceConfig: Dispatch<SetStateAction<EnhanceConfig>>;
enhanceProConfig: EnhanceProKreaConfig;
setEnhanceProConfig: Dispatch<SetStateAction<EnhanceProKreaConfig>>;
enhanceProApiKeysDraft: string;
setEnhanceProApiKeysDraft: Dispatch<SetStateAction<string>>;
enhanceProApiKeysDraftCount: number;
draftEnhanceProKeyRows: EnhanceProKeyRow[];
savedEnhanceProKeyRows: EnhanceProKeyRow[];
enhanceProPresetEditorMode: "simple" | "advanced";
setEnhanceProPresetEditorMode: Dispatch<
SetStateAction<"simple" | "advanced">
>;
selectedEnhanceProPresetId: string;
setSelectedEnhanceProPresetId: Dispatch<SetStateAction<string>>;
selectedEnhanceProPreset: EnhanceProPreset | null | undefined;
selectedEnhanceProPresetIndex: number;
draggingEnhanceProPresetId: string;
aiWashConfig: AiWashConfig;
setAiWashConfig: Dispatch<SetStateAction<AiWashConfig>>;
aiWashModeIconUploading: boolean;
canvasLocalEditConfig: CanvasLocalEditConfig;
setCanvasLocalEditConfig: Dispatch<SetStateAction<CanvasLocalEditConfig>>;
extendConfig: ExtendConfig;
setExtendConfig: Dispatch<SetStateAction<ExtendConfig>>;
watermarkRemoveConfig: WatermarkRemoveConfig;
setWatermarkRemoveConfig: Dispatch<SetStateAction<WatermarkRemoveConfig>>;
angleEditConfig: AngleEditConfig;
setAngleEditConfig: Dispatch<SetStateAction<AngleEditConfig>>;
pointsSystemConfig: PointsSystemConfig;
updatePointsCostField: (
key: keyof PointsSystemConfig,
value: unknown
) => void;
layerSplitConfig: LayerSplitConfig;
setLayerSplitConfig: Dispatch<SetStateAction<LayerSplitConfig>>;
renderEnhanceProPresetEditor: (
preset: EnhanceProPreset,
index: number,
expanded?: boolean
) => ReactNode;
getEnhanceProPresetSummary: (preset: EnhanceProPreset) => string;
handleSaveRmbgConfig: AsyncAction;
handleSaveEnhanceConfig: AsyncAction;
handleSaveEnhanceProConfig: AsyncAction;
handleAddEnhanceProPoolKey: () => void;
handleDeleteEnhanceProPoolKey: (index: number) => void;
handleToggleEnhanceProModelVisible: (
model: EnhanceProKreaModel,
visible: boolean
) => void;
handleAddEnhanceProPreset: () => void;
handleResetEnhanceProPresets: () => void;
handleEnhanceProPresetDragStart: (
event: DragEvent<HTMLDivElement>,
presetId: string
) => void;
handleEnhanceProPresetDragEnd: () => void;
handleEnhanceProPresetDrop: (
event: DragEvent<HTMLDivElement>,
targetPresetId: string
) => void;
handleMoveEnhanceProPreset: (fromIndex: number, toIndex: number) => void;
handleAiWashModeIconUpload: (file: File) => Promise<void>;
handleSaveAiWashConfig: AsyncAction;
handleSaveCanvasLocalEditConfig: AsyncAction;
handleSaveExtendConfig: AsyncAction;
handleSaveWatermarkRemoveConfig: AsyncAction;
handleSaveAngleEditConfig: AsyncAction;
handleSaveLayerSplitConfig: AsyncAction;
};
export type ThirdPartySqlServerConfig = {
enabled: boolean;
host: string;
port: number;
database: string;
username: string;
password: string;
encrypt: boolean;
trustServerCertificate: boolean;
connectionTimeoutMs: number;
requestTimeoutMs: number;
};
export type RealtimeCanvasProviderType = "bfl" | "replicate" | "fal";
export type RealtimeCanvasConfig = {
enabled: boolean;
providerType: RealtimeCanvasProviderType;
bflApiKey: string;
bflBaseUrl: string;
bflModel: string;
falApiKey: string;
falBaseUrl: string;
falModel: string;
replicateApiToken: string;
replicateModel: string;
fallbackEnabled: boolean;
fallbackProviderType: RealtimeCanvasProviderType;
fallbackBflApiKey: string;
fallbackBflBaseUrl: string;
fallbackBflModel: string;
fallbackFalApiKey: string;
fallbackFalBaseUrl: string;
fallbackFalModel: string;
fallbackReplicateApiToken: string;
fallbackReplicateModel: string;
renderMode: "strict-structure" | "balanced" | "stylize";
throttleInterval: number;
maxBuffering: number;
maxInputResolution: number;
jpegQuality: number;
previewMegapixels: number;
detailMegapixels: number;
outputJpegQuality: number;
steps: number;
scheduleMu: number;
feedback: number;
imageSize: "square" | "square_hd";
};
export type SceneFusionConfig = {
enabled: boolean;
preRmbgEnabled: boolean;
defaultPrompt: string;
maxScenes: number;
modelId: string;
};
export type LocalAiRuntimeSectionsProps = LocalAiSectionControlProps & {
thirdPartySqlServerConfig: ThirdPartySqlServerConfig;
setThirdPartySqlServerConfig: Dispatch<
SetStateAction<ThirdPartySqlServerConfig>
>;
isThirdPartySqlServerFieldsVisible: boolean;
setIsThirdPartySqlServerFieldsVisible: Dispatch<SetStateAction<boolean>>;
isTestingThirdPartySqlServer: boolean;
realtimeCanvasConfig: RealtimeCanvasConfig;
setRealtimeCanvasConfig: Dispatch<SetStateAction<RealtimeCanvasConfig>>;
canvasImageTaskQueueConfig: CanvasImageTaskQueueConfig;
setCanvasImageTaskQueueConfig: Dispatch<
SetStateAction<CanvasImageTaskQueueConfig>
>;
sceneFusionConfig: SceneFusionConfig;
setSceneFusionConfig: Dispatch<SetStateAction<SceneFusionConfig>>;
hasThirdPartySqlServerConfiguredValue: boolean;
modelsData?: AdminModelsData;
providersData?: AdminProvidersData;
DEFAULT_BFL_BASE_URL: string;
DEFAULT_BFL_REALTIME_MODEL: string;
handleSaveThirdPartySqlServerConfig: AsyncAction;
handleTestThirdPartySqlServerConfig: AsyncAction;
handleSaveRealtimeCanvasConfig: AsyncAction;
handleSaveCanvasImageTaskQueueConfig: AsyncAction;
handleSaveSceneFusionConfig: AsyncAction;
};

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,19 @@
import { requireAgentWorkflowAdmin } from "@/lib/middleware/admin";
import { redirect } from "next/navigation";
import { AgentWorkflowSettingsClient } from "./agent-workflow-settings-client";
export const dynamic = "force-dynamic";
export default async function AgentWorkflowSettingsPage() {
const session = await requireAgentWorkflowAdmin().catch(() => null);
if (!session) {
redirect("/");
}
return (
<AgentWorkflowSettingsClient
backHref={session.user.type === "admin" ? "/settings" : "/"}
backLabel={session.user.type === "admin" ? "返回设置" : "返回首页"}
/>
);
}

View File

@ -0,0 +1,19 @@
import { requireAgentWorkflowAdmin } from "@/lib/middleware/admin";
import { redirect } from "next/navigation";
import { PsdTemplatesSettingsClient } from "./psd-templates-settings-client";
export const dynamic = "force-dynamic";
export default async function AgentWorkflowPsdTemplatesPage() {
const session = await requireAgentWorkflowAdmin().catch(() => null);
if (!session) {
redirect("/");
}
return (
<PsdTemplatesSettingsClient
backHref="/settings/agent-workflow"
backLabel="返回风格化工作流配置"
/>
);
}

View File

@ -0,0 +1,22 @@
import { auth } from "@/app/(auth)/auth";
import { requireAdmin } from "@/lib/middleware/admin";
import { redirect } from "next/navigation";
import { AdminSettingsClient } from "./admin-settings-client";
// 强制动态渲染,避免预渲染问题
export const dynamic = "force-dynamic";
export default async function AdminSettingsPage() {
const session = await auth().catch(() => null);
if (session?.user?.type === "agent_admin") {
redirect("/settings/agent-workflow");
}
try {
await requireAdmin();
} catch {
redirect("/");
}
return <AdminSettingsClient />;
}

View File

@ -0,0 +1,16 @@
import { redirect } from "next/navigation";
import { requireAdmin } from "@/lib/middleware/admin";
import { StyleProSettingsClient } from "./style-pro-settings-client";
export const dynamic = "force-dynamic";
export default async function StyleProSettingsPage() {
try {
await requireAdmin();
} catch {
redirect("/");
}
return <StyleProSettingsClient />;
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,15 @@
import { redirect } from "next/navigation";
import { requireAdmin } from "@/lib/middleware/admin";
import { VideoCanvasSettingsClient } from "./video-canvas-settings-client";
export const dynamic = "force-dynamic";
export default async function VideoCanvasSettingsPage() {
try {
await requireAdmin();
} catch {
redirect("/");
}
return <VideoCanvasSettingsClient />;
}

View File

@ -0,0 +1,887 @@
"use client";
import Link from "next/link";
import { ArrowLeft, Plus, Trash2 } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import useSWR from "swr";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import {
isImageGenerationModelDescriptor,
isVideoModelDescriptor,
} from "@/lib/ai/model-classification";
import { fetcher } from "@/lib/utils";
import {
getVideoCanvasFunctionBillingCopy,
type VideoCanvasFunctionConfig,
type VideoCanvasFunctionDefinition,
type VideoCanvasEffectPreset,
type VideoCanvasMotionPreset,
type VideoCanvasRolePreset,
VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
defaultVideoCanvasFunctionConfig,
normalizeVideoCanvasFunctionConfig,
} from "@/lib/video-canvas/config";
type SettingsResponse = {
setting?: {
value?: unknown;
};
};
type AiModelOption = {
id: string;
modelId: string;
displayName: string;
providerName?: string;
providerType?: string;
supportsVision?: boolean;
supportsImageGen?: boolean;
isEnabled?: boolean;
isHidden?: boolean;
isMultimodal?: boolean;
capabilities?: unknown;
};
type AiModelsResponse = {
models?: AiModelOption[];
};
const INHERIT_MODEL_VALUE = "__inherit__";
const functionKindLabels: Record<VideoCanvasFunctionDefinition["kind"], string> = {
audio: "音频",
image: "图片生成",
"image-edit": "图片编辑",
multimodal: "多模态",
utility: "工具",
video: "视频生成",
workflow: "工作流",
};
function getModelOptionValue(model: AiModelOption) {
return String(model.modelId || model.id || "").trim();
}
function getModelOptionLabel(model: AiModelOption) {
const name = model.displayName || model.modelId || model.id;
const id = model.modelId && model.modelId !== name ? ` · ${model.modelId}` : "";
return `${name}${id}`;
}
function isImageModelOption(model: AiModelOption) {
return isImageGenerationModelDescriptor({
providerType: model.providerType,
modelId: model.modelId,
capabilities: model.capabilities,
supportsImageGen: model.supportsImageGen,
});
}
function isVideoModelOption(model: AiModelOption) {
return isVideoModelDescriptor({
providerType: model.providerType,
modelId: model.modelId,
capabilities: model.capabilities,
supportsImageGen: model.supportsImageGen,
});
}
function filterModelsForFunctionKind(
models: AiModelOption[],
kind: VideoCanvasFunctionDefinition["kind"],
) {
const enabled = models.filter((model) => model.isEnabled !== false && getModelOptionValue(model));
let filtered = enabled;
if (kind === "image" || kind === "image-edit") {
filtered = enabled.filter(isImageModelOption);
} else if (kind === "video") {
filtered = enabled.filter(isVideoModelOption);
} else if (kind === "multimodal") {
filtered = enabled.filter(
(model) => model.isMultimodal || model.supportsVision || (!isImageModelOption(model) && !isVideoModelOption(model)),
);
}
return filtered.length ? filtered : enabled;
}
function ModelSelect({
allowInherit = false,
kind,
models,
onChange,
value,
}: {
allowInherit?: boolean;
kind: VideoCanvasFunctionDefinition["kind"];
models: AiModelOption[];
onChange: (value: string) => void;
value: string;
}) {
const filteredModels = filterModelsForFunctionKind(models, kind);
const normalizedValue = value?.trim() || (allowInherit ? INHERIT_MODEL_VALUE : "auto");
const hasCurrent =
normalizedValue === "auto" ||
normalizedValue === INHERIT_MODEL_VALUE ||
filteredModels.some((model) => getModelOptionValue(model) === normalizedValue);
return (
<Select
onValueChange={(nextValue) => {
if (nextValue === INHERIT_MODEL_VALUE) {
onChange("");
return;
}
onChange(nextValue);
}}
value={normalizedValue}
>
<SelectTrigger>
<SelectValue placeholder="选择模型" />
</SelectTrigger>
<SelectContent>
{allowInherit ? (
<SelectItem value={INHERIT_MODEL_VALUE}>继承全局默认模型</SelectItem>
) : (
<SelectItem value="auto">自动选择可用模型</SelectItem>
)}
{!hasCurrent && normalizedValue ? (
<SelectItem value={normalizedValue}>当前保存值:{normalizedValue}</SelectItem>
) : null}
{filteredModels.map((model) => {
const optionValue = getModelOptionValue(model);
return (
<SelectItem key={`${model.id}-${optionValue}`} value={optionValue}>
{getModelOptionLabel(model)}
</SelectItem>
);
})}
{filteredModels.length === 0 ? (
<SelectItem disabled value="__no_models__">
暂无已启用模型
</SelectItem>
) : null}
</SelectContent>
</Select>
);
}
export function VideoCanvasSettingsClient() {
const { data, isLoading, mutate } = useSWR<SettingsResponse>(
`/api/admin/settings?key=${VIDEO_CANVAS_FUNCTION_CONFIG_KEY}`,
fetcher,
);
const { data: aiModelsData } = useSWR<AiModelsResponse>("/api/admin/ai-models", fetcher);
const [config, setConfig] = useState<VideoCanvasFunctionConfig>(defaultVideoCanvasFunctionConfig);
const [saving, setSaving] = useState(false);
const modelOptions = useMemo(
() => (aiModelsData?.models || []).filter((model) => model.isEnabled !== false),
[aiModelsData?.models],
);
useEffect(() => {
if (!data) return;
setConfig(normalizeVideoCanvasFunctionConfig(data.setting?.value));
}, [data]);
const functions = useMemo(
() =>
Object.values(config.functions).sort((a, b) => {
const compare = (left: string, right: string) =>
left === right ? 0 : left < right ? -1 : 1;
return compare(a.kind, b.kind) || compare(a.label, b.label);
}),
[config.functions],
);
const updateRoot = (updates: Partial<VideoCanvasFunctionConfig>) => {
setConfig((current) => ({ ...current, ...updates }));
};
const updateFunction = (
key: string,
updates: Partial<VideoCanvasFunctionDefinition>,
) => {
setConfig((current) => ({
...current,
functions: {
...current.functions,
[key]: {
...current.functions[key],
...updates,
},
},
}));
};
const updateEffectPreset = (
index: number,
updates: Partial<VideoCanvasEffectPreset>,
) => {
setConfig((current) => ({
...current,
effectPresets: current.effectPresets.map((preset, presetIndex) =>
presetIndex === index ? { ...preset, ...updates } : preset,
),
}));
};
const updateMotionPreset = (
index: number,
updates: Partial<VideoCanvasMotionPreset>,
) => {
setConfig((current) => ({
...current,
motionPresets: current.motionPresets.map((preset, presetIndex) =>
presetIndex === index ? { ...preset, ...updates } : preset,
),
}));
};
const addMotionPreset = () => {
const createdAt = Date.now();
setConfig((current) => ({
...current,
motionPresets: [
...current.motionPresets,
{
id: `motion-${createdAt}`,
name: "新运镜预设",
previewUrl: "/video-canvas/effects/multi-angle.webp",
prompt: "镜头平稳移动,保持主体和空间关系清晰。",
},
],
}));
};
const removeMotionPreset = (index: number) => {
setConfig((current) => ({
...current,
motionPresets: current.motionPresets.filter(
(_, presetIndex) => presetIndex !== index,
),
}));
};
const addEffectPreset = () => {
setConfig((current) => ({
...current,
effectPresets: [
...current.effectPresets,
{
id: `effect-${Date.now()}`,
title: "新视频特效",
author: "官方预设",
usageCount: 0,
model: "Seedance 2.0",
thumbnailUrl: "/video-canvas/effects/product-light-sweep.webp",
prompt: "保持参考主体稳定,应用自然、连续的视频特效和镜头运动。",
commercial: true,
},
],
}));
};
const removeEffectPreset = (index: number) => {
setConfig((current) => ({
...current,
effectPresets: current.effectPresets.filter((_, presetIndex) => presetIndex !== index),
}));
};
const updateRolePreset = (
index: number,
updates: Partial<VideoCanvasRolePreset>,
) => {
setConfig((current) => ({
...current,
rolePresets: current.rolePresets.map((preset, presetIndex) =>
presetIndex === index ? { ...preset, ...updates } : preset,
),
}));
};
const addRolePreset = () => {
const createdAt = Date.now();
setConfig((current) => ({
...current,
rolePresets: [
...current.rolePresets,
{
id: `role-${createdAt}`,
assetId: `public-role:role-${createdAt}`,
name: "新角色预设",
accent: "#525252",
description: "管理员配置的公共角色素材。",
prompt: "保持角色身份、五官、发型、服装和体型在所有视频镜头中一致。",
tags: "角色",
compliant: true,
images: {
expression: "",
face: "",
full: "/video-canvas/effects/product-light-sweep.webp",
threeView: "",
},
filters: {
age: "青年",
build: "匀称",
culture: "东亚",
era: "现代",
gender: "中性",
hairColor: "黑发",
species: "人类",
},
},
],
}));
};
const removeRolePreset = (index: number) => {
setConfig((current) => ({
...current,
rolePresets: current.rolePresets.filter((_, presetIndex) => presetIndex !== index),
}));
};
const save = async () => {
setSaving(true);
try {
const response = await fetch("/api/admin/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
key: VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
value: config,
}),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(payload?.message || payload?.error || "保存失败");
}
toast.success("视频画布功能配置已保存");
await mutate();
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
} finally {
setSaving(false);
}
};
const resetToDefault = () => {
setConfig(defaultVideoCanvasFunctionConfig);
toast.info("已恢复默认配置,保存后生效");
};
return (
<main className="settings-workspace mx-auto flex w-full max-w-7xl flex-col gap-4 px-4 py-5 sm:px-5">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<Button asChild className="mb-2" size="sm" variant="outline">
<Link href="/settings">
<ArrowLeft className="mr-1.5 h-3.5 w-3.5" />
返回设置
</Link>
</Button>
<h1 className="text-xl font-semibold">视频画布功能设置</h1>
<p className="mt-1 max-w-3xl text-xs leading-5 text-muted-foreground">
这里集中配置复刻画布里的工具能力、预设提示词和默认模型。积分与扣费统一跟随全站模型/功能配置,不再在视频画布里单独维护一套价格。
</p>
</div>
<div className="flex gap-2">
<Button disabled={saving || isLoading} onClick={resetToDefault} type="button" variant="outline">
恢复默认
</Button>
<Button disabled={saving || isLoading} onClick={save} type="button">
{saving ? "保存中..." : "保存配置"}
</Button>
</div>
</div>
<Card>
<CardHeader className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div>
<CardTitle className="text-xl">全局模型与开关</CardTitle>
<CardDescription>
默认模型从后台已启用模型中选择;保持自动时由各接口按当前可用模型选择。
</CardDescription>
</div>
<div className="flex items-center gap-3">
<Label htmlFor="video-canvas-enabled">启用视频画布功能串联</Label>
<Switch
checked={config.enabled}
id="video-canvas-enabled"
onCheckedChange={(checked) => updateRoot({ enabled: checked })}
/>
</div>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="defaultImageModel">默认图片/编辑模型</Label>
<ModelSelect
kind="image-edit"
models={modelOptions}
onChange={(value) => updateRoot({ defaultImageModel: value || "auto" })}
value={config.defaultImageModel}
/>
</div>
<div className="space-y-2">
<Label htmlFor="defaultVideoModel">默认视频模型</Label>
<ModelSelect
kind="video"
models={modelOptions}
onChange={(value) => updateRoot({ defaultVideoModel: value || "auto" })}
value={config.defaultVideoModel}
/>
</div>
<div className="space-y-2">
<Label htmlFor="defaultMultimodalModel">默认多模态模型</Label>
<ModelSelect
kind="multimodal"
models={modelOptions}
onChange={(value) => updateRoot({ defaultMultimodalModel: value || "auto" })}
value={config.defaultMultimodalModel}
/>
</div>
</CardContent>
</Card>
<section className="space-y-4">
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div>
<h2 className="text-lg font-semibold tracking-tight">视频运镜素材预设</h2>
<p className="mt-1 max-w-3xl text-sm text-muted-foreground">
视频节点“运镜广场”的名称、预览图和插入提示词都从这里读取。点击运镜卡会按竞品规则把提示词追加到用户输入框,允许组合和重复使用。
</p>
</div>
<Button onClick={addMotionPreset} size="sm" type="button" variant="outline">
<Plus className="mr-1.5 h-4 w-4" />
添加运镜
</Button>
</div>
<div className="grid gap-3">
{config.motionPresets.map((preset, index) => (
<details
className="group rounded-lg border border-border bg-card px-4 py-3"
key={`${preset.id}-${index}`}
>
<summary className="flex cursor-pointer list-none items-center gap-3">
<img
alt=""
className="h-14 w-14 shrink-0 rounded-md object-cover"
src={preset.previewUrl}
/>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{preset.name}</div>
<div className="mt-1 truncate text-xs text-muted-foreground">
{preset.prompt || "未设置插入提示词"}
</div>
</div>
<Button
aria-label={`删除运镜 ${preset.name}`}
className="shrink-0"
onClick={(event) => {
event.preventDefault();
removeMotionPreset(index);
}}
size="icon"
title={`删除运镜 ${preset.name}`}
type="button"
variant="ghost"
>
<Trash2 className="h-4 w-4" />
</Button>
</summary>
<div className="mt-4 grid gap-4 border-t border-border pt-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={`motion-name-${index}`}>运镜名称</Label>
<Input
id={`motion-name-${index}`}
onChange={(event) =>
updateMotionPreset(index, { name: event.target.value })
}
value={preset.name}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`motion-preview-${index}`}>预览图地址</Label>
<Input
id={`motion-preview-${index}`}
onChange={(event) =>
updateMotionPreset(index, { previewUrl: event.target.value })
}
value={preset.previewUrl}
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor={`motion-prompt-${index}`}>插入提示词</Label>
<Textarea
id={`motion-prompt-${index}`}
onChange={(event) =>
updateMotionPreset(index, { prompt: event.target.value })
}
value={preset.prompt}
/>
</div>
</div>
</details>
))}
</div>
</section>
<section className="space-y-4">
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div>
<h2 className="text-lg font-semibold tracking-tight">视频特效素材预设</h2>
<p className="mt-1 max-w-3xl text-sm text-muted-foreground">
视频节点“特效”面板、素材节点封面、模型约束和生成提示词都从这里读取。选择特效时会创建结构化素材节点,不会把描述硬写进用户提示词。
</p>
</div>
<Button onClick={addEffectPreset} size="sm" type="button" variant="outline">
<Plus className="mr-1.5 h-4 w-4" />
添加特效
</Button>
</div>
<div className="grid gap-3">
{config.effectPresets.map((preset, index) => (
<details
className="group rounded-lg border border-border bg-card px-4 py-3"
key={`${preset.id}-${index}`}
>
<summary className="flex cursor-pointer list-none items-center gap-3">
<img
alt=""
className="h-12 w-12 shrink-0 rounded-md object-cover"
src={preset.thumbnailUrl}
/>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{preset.title}</div>
<div className="mt-1 truncate text-xs text-muted-foreground">
{preset.author} · {preset.model} · {preset.usageCount.toLocaleString()} 次使用
</div>
</div>
<Button
aria-label={`删除特效 ${preset.title}`}
className="shrink-0"
onClick={(event) => {
event.preventDefault();
removeEffectPreset(index);
}}
size="icon"
title={`删除特效 ${preset.title}`}
type="button"
variant="ghost"
>
<Trash2 className="h-4 w-4" />
</Button>
</summary>
<div className="mt-4 grid gap-4 border-t border-border pt-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={`effect-title-${index}`}>特效名称</Label>
<Input
id={`effect-title-${index}`}
onChange={(event) => updateEffectPreset(index, { title: event.target.value })}
value={preset.title}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`effect-author-${index}`}>作者</Label>
<Input
id={`effect-author-${index}`}
onChange={(event) => updateEffectPreset(index, { author: event.target.value })}
value={preset.author}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`effect-model-${index}`}>适用模型</Label>
<Input
id={`effect-model-${index}`}
onChange={(event) => updateEffectPreset(index, { model: event.target.value })}
value={preset.model}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`effect-count-${index}`}>展示使用次数</Label>
<Input
id={`effect-count-${index}`}
min={0}
onChange={(event) =>
updateEffectPreset(index, {
usageCount: Math.max(0, Number(event.target.value) || 0),
})
}
type="number"
value={preset.usageCount}
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor={`effect-thumbnail-${index}`}>封面地址</Label>
<Input
id={`effect-thumbnail-${index}`}
onChange={(event) =>
updateEffectPreset(index, { thumbnailUrl: event.target.value })
}
value={preset.thumbnailUrl}
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor={`effect-prompt-${index}`}>生成提示词 / 后端规则</Label>
<Textarea
id={`effect-prompt-${index}`}
onChange={(event) => updateEffectPreset(index, { prompt: event.target.value })}
value={preset.prompt}
/>
</div>
<div className="flex items-center gap-3 md:col-span-2">
<Switch
checked={preset.commercial}
id={`effect-commercial-${index}`}
onCheckedChange={(checked) => updateEffectPreset(index, { commercial: checked })}
/>
<Label htmlFor={`effect-commercial-${index}`}>标记为可商用</Label>
</div>
</div>
</details>
))}
</div>
</section>
<section className="space-y-4">
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div>
<h2 className="text-lg font-semibold tracking-tight">视频角色素材预设</h2>
<p className="mt-1 max-w-3xl text-sm text-muted-foreground">
视频节点“角色库”的公共角色、Seedance 合规资产 ID、四类角色图和隐藏生成提示词都从这里读取。角色引用通过画布连线保存,不会改写用户输入框。
</p>
</div>
<Button onClick={addRolePreset} size="sm" type="button" variant="outline">
<Plus className="mr-1.5 h-4 w-4" />
添加角色
</Button>
</div>
<div className="grid gap-3">
{config.rolePresets.map((preset, index) => (
<details
className="group rounded-lg border border-border bg-card px-4 py-3"
key={`${preset.id}-${index}`}
>
<summary className="flex cursor-pointer list-none items-center gap-3">
<img
alt=""
className="h-14 w-11 shrink-0 rounded-md object-cover object-top"
src={preset.images.full}
/>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{preset.name}</div>
<div className="mt-1 truncate text-xs text-muted-foreground">
{preset.assetId} · {preset.tags || "未设置标签"}
</div>
</div>
<Button
aria-label={`删除角色 ${preset.name}`}
className="shrink-0"
onClick={(event) => {
event.preventDefault();
removeRolePreset(index);
}}
size="icon"
title={`删除角色 ${preset.name}`}
type="button"
variant="ghost"
>
<Trash2 className="h-4 w-4" />
</Button>
</summary>
<div className="mt-4 grid gap-4 border-t border-border pt-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={`role-name-${index}`}>角色名称</Label>
<Input
id={`role-name-${index}`}
onChange={(event) => updateRolePreset(index, { name: event.target.value })}
value={preset.name}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`role-asset-${index}`}>合规资产 ID</Label>
<Input
id={`role-asset-${index}`}
onChange={(event) => updateRolePreset(index, { assetId: event.target.value })}
value={preset.assetId}
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor={`role-tags-${index}`}>展示标签</Label>
<Input
id={`role-tags-${index}`}
onChange={(event) => updateRolePreset(index, { tags: event.target.value })}
value={preset.tags}
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor={`role-description-${index}`}>角色说明</Label>
<Textarea
id={`role-description-${index}`}
onChange={(event) => updateRolePreset(index, { description: event.target.value })}
value={preset.description}
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor={`role-prompt-${index}`}>生成提示词 / 身份一致性规则</Label>
<Textarea
id={`role-prompt-${index}`}
onChange={(event) => updateRolePreset(index, { prompt: event.target.value })}
value={preset.prompt}
/>
</div>
{([
["full", "角色立绘"],
["face", "脸部近景"],
["expression", "表情参考"],
["threeView", "三视图"],
] as const).map(([key, label]) => (
<div className="space-y-2" key={key}>
<Label htmlFor={`role-${key}-${index}`}>{label}地址</Label>
<Input
id={`role-${key}-${index}`}
onChange={(event) =>
updateRolePreset(index, {
images: { ...preset.images, [key]: event.target.value },
})
}
value={preset.images[key]}
/>
</div>
))}
{([
["gender", "性别"],
["age", "年龄段"],
["species", "种族"],
["era", "时代"],
["culture", "文化区域"],
["build", "体型"],
["hairColor", "发色"],
] as const).map(([key, label]) => (
<div className="space-y-2" key={key}>
<Label htmlFor={`role-filter-${key}-${index}`}>{label}</Label>
<Input
id={`role-filter-${key}-${index}`}
onChange={(event) =>
updateRolePreset(index, {
filters: { ...preset.filters, [key]: event.target.value },
})
}
value={preset.filters[key]}
/>
</div>
))}
<div className="flex items-center gap-3 md:col-span-2">
<Switch
checked={preset.compliant}
id={`role-compliant-${index}`}
onCheckedChange={(checked) => updateRolePreset(index, { compliant: checked })}
/>
<Label htmlFor={`role-compliant-${index}`}>可在 Seedance2.0 合规素材库中使用</Label>
</div>
</div>
</details>
))}
</div>
</section>
<section className="space-y-4">
<div>
<h2 className="text-lg font-semibold tracking-tight">功能预设提示词</h2>
<p className="mt-1 max-w-3xl text-sm text-muted-foreground">
视频画布运行时会通过 /api/video-canvas/config 读取这里保存的提示词。比如“多机位九宫格”的分镜规则、黑色分割线要求和镜头说明,都由管理员在这里维护,前端不再写死覆盖。
</p>
<p className="mt-2 max-w-3xl text-sm text-muted-foreground">
计费说明只展示来源,不在这里改价;需要调整积分请回到全站积分设置、模型设置或对应功能设置统一修改。
</p>
</div>
<div className="grid gap-4">
{functions.map((item) => (
<Card className={!item.enabled ? "opacity-70" : undefined} key={item.key}>
<CardHeader className="space-y-4">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<CardTitle className="flex flex-wrap items-center gap-2 text-base">
<span>{item.label}</span>
<span className="rounded-full bg-muted px-2 py-0.5 text-xs font-normal text-muted-foreground">
{functionKindLabels[item.kind]}
</span>
<span className="rounded-full bg-muted px-2 py-0.5 text-xs font-normal text-muted-foreground">
{item.route}
</span>
</CardTitle>
<CardDescription className="mt-2">{item.description}</CardDescription>
</div>
<div className="flex items-center gap-3">
<Label htmlFor={`enabled-${item.key}`}>启用</Label>
<Switch
checked={item.enabled}
id={`enabled-${item.key}`}
onCheckedChange={(checked) => updateFunction(item.key, { enabled: checked })}
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-1">
<div className="space-y-2">
<Label htmlFor={`model-${item.key}`}>模型覆盖</Label>
<ModelSelect
allowInherit
kind={item.kind}
models={modelOptions}
onChange={(value) => updateFunction(item.key, { model: value })}
value={item.model || ""}
/>
</div>
<div className="rounded-lg border border-dashed border-border/70 bg-muted/20 px-3 py-2">
<div className="text-xs font-medium text-foreground">
积分来源:{getVideoCanvasFunctionBillingCopy(item).shortLabel}
</div>
<div className="mt-1 text-xs leading-5 text-muted-foreground">
{getVideoCanvasFunctionBillingCopy(item).description}
</div>
</div>
</div>
</CardHeader>
<CardContent>
<Label htmlFor={`prompt-${item.key}`}>管理员可编辑的预设提示词 / 后端规则</Label>
<Textarea
className="mt-2 min-h-28"
id={`prompt-${item.key}`}
onChange={(event) => updateFunction(item.key, { prompt: event.target.value })}
value={item.prompt}
/>
</CardContent>
</Card>
))}
</div>
</section>
</main>
);
}

203
app/(auth)/actions.ts Normal file
View File

@ -0,0 +1,203 @@
"use server";
import { z } from "zod";
import { and, desc, eq } from "drizzle-orm";
import { createUser, getUser, getAuthSettings } from "@/lib/db/queries";
import {
EMAIL_VERIFICATION_PURPOSE_REGISTER,
hashEmailVerificationCode,
} from "@/lib/auth/email-verification";
import { db } from "@/lib/db";
import { emailVerificationCode } from "@/lib/db/schema";
import { signIn } from "./auth";
const authFormSchema = z.object({
email: z.string().email(),
password: z.string().min(6),
});
const registerFormSchema = z
.object({
email: z.string().email(),
password: z.string().min(6),
confirmPassword: z.string().min(6),
verificationCode: z.string().optional(),
})
.refine((data) => data.password === data.confirmPassword, {
path: ["confirmPassword"],
message: "Password mismatch",
});
export type LoginActionState = {
status: "idle" | "in_progress" | "success" | "failed" | "invalid_data";
};
export const login = async (
_: LoginActionState,
formData: FormData
): Promise<LoginActionState> => {
try {
const provider = formData.get("provider") as string;
if (provider === "dingtalk") {
const token = formData.get("token");
console.log("[DingTalk Login] Received token:", token);
if (!token) return { status: "invalid_data" };
try {
const result = await signIn("dingtalk", {
token,
redirect: false,
});
console.log("[DingTalk Login] signIn result:", result);
return { status: "success" };
} catch (signInError) {
console.error("[DingTalk Login] signIn error:", signInError);
throw signInError;
}
}
const validatedData = authFormSchema.parse({
email: formData.get("email"),
password: formData.get("password"),
});
const normalizedEmail = validatedData.email.trim().toLowerCase();
await signIn("credentials", {
email: normalizedEmail,
password: validatedData.password,
redirect: false,
});
return { status: "success" };
} catch (error) {
if (error instanceof z.ZodError) {
return { status: "invalid_data" };
}
return { status: "failed" };
}
};
export type RegisterActionState = {
status:
| "idle"
| "in_progress"
| "success"
| "failed"
| "user_exists"
| "password_mismatch"
| "verification_code_required"
| "verification_code_invalid"
| "verification_code_expired"
| "invalid_data";
};
export const register = async (
_: RegisterActionState,
formData: FormData
): Promise<RegisterActionState> => {
try {
// 检查是否开放注册
const settings = await getAuthSettings();
if (settings.isRegistrationEnabled === false) {
return { status: "failed" };
}
const validatedData = registerFormSchema.parse({
email: formData.get("email"),
password: formData.get("password"),
confirmPassword: formData.get("confirmPassword"),
verificationCode:
typeof formData.get("verificationCode") === "string"
? String(formData.get("verificationCode"))
: undefined,
});
const normalizedEmail = validatedData.email.trim().toLowerCase();
const verificationCodeInput = String(
validatedData.verificationCode || ""
).trim();
if (settings.isEmailVerificationEnabled === true) {
if (!verificationCodeInput) {
return { status: "verification_code_required" };
}
if (!/^\d{6}$/.test(verificationCodeInput)) {
return { status: "verification_code_invalid" };
}
const [latestCode] = await db
.select()
.from(emailVerificationCode)
.where(
and(
eq(emailVerificationCode.email, normalizedEmail),
eq(
emailVerificationCode.purpose,
EMAIL_VERIFICATION_PURPOSE_REGISTER
)
)
)
.orderBy(desc(emailVerificationCode.createdAt))
.limit(1);
if (!latestCode) {
return { status: "verification_code_invalid" };
}
if (new Date(latestCode.expiresAt) <= new Date()) {
await db
.delete(emailVerificationCode)
.where(eq(emailVerificationCode.id, latestCode.id));
return { status: "verification_code_expired" };
}
const expectedHash = hashEmailVerificationCode({
email: normalizedEmail,
purpose: EMAIL_VERIFICATION_PURPOSE_REGISTER,
code: verificationCodeInput,
});
if (latestCode.codeHash !== expectedHash) {
return { status: "verification_code_invalid" };
}
}
const [user] = await getUser(normalizedEmail);
if (user) {
return { status: "user_exists" } as RegisterActionState;
}
await createUser(normalizedEmail, validatedData.password);
await signIn("credentials", {
email: normalizedEmail,
password: validatedData.password,
redirect: false,
});
await db
.delete(emailVerificationCode)
.where(
and(
eq(emailVerificationCode.email, normalizedEmail),
eq(emailVerificationCode.purpose, EMAIL_VERIFICATION_PURPOSE_REGISTER)
)
);
return { status: "success" };
} catch (error) {
if (error instanceof z.ZodError) {
const passwordMismatch = error.issues.some(
(issue) => issue.path?.[0] === "confirmPassword"
);
if (passwordMismatch) {
return { status: "password_mismatch" };
}
return { status: "invalid_data" };
}
return { status: "failed" };
}
};

View File

@ -0,0 +1 @@
export { GET, POST } from "@/app/(auth)/auth";

View File

@ -0,0 +1,41 @@
import { NextResponse } from "next/server";
import { getToken } from "next-auth/jwt";
import { signIn } from "@/app/(auth)/auth";
import { isDevelopmentEnvironment } from "@/lib/constants";
export async function GET(request: Request) {
const requestUrl = new URL(request.url);
const { searchParams } = requestUrl;
const redirectUrl = searchParams.get("redirectUrl") || "/";
const token = await getToken({
req: request,
secret: process.env.AUTH_SECRET,
secureCookie: !isDevelopmentEnvironment,
});
if (token) {
const baseUrl = process.env.AUTH_URL || requestUrl.origin;
return NextResponse.redirect(new URL("/", baseUrl));
}
const baseUrl = process.env.AUTH_URL || requestUrl.origin;
// 规范化 redirectUrl:
// 1. 无论是相对路径还是带 localhost 的绝对地址,
// 2. 最终都转换为以 AUTH_URL/baseUrl 为 origin 的完整 URL,保证端口正确。
let finalRedirectUrl = `${baseUrl}/`;
try {
const candidate = new URL(redirectUrl, baseUrl);
// 只保留路径和查询参数,强制使用外网域名和端口
const normalized = new URL(
`${candidate.pathname}${candidate.search}${candidate.hash}`,
baseUrl,
);
finalRedirectUrl = normalized.toString();
} catch {
// ignore, 使用默认的 baseUrl
}
return signIn("guest", { redirect: true, redirectTo: finalRedirectUrl });
}

54
app/(auth)/auth.config.ts Normal file
View File

@ -0,0 +1,54 @@
import type { NextAuthConfig } from "next-auth";
export function buildAuthConfig(authUrl?: string | null): NextAuthConfig {
const resolvedAuthUrl = authUrl || process.env.AUTH_URL || "";
const isSecure = resolvedAuthUrl.startsWith("https://");
// Use host-only cookies to avoid cross-domain login issues in multi-domain deployments.
const cookieDomain = undefined;
// trustHost 仅在明确配置时开启,避免 host header 注入攻击
// 部署在 Vercel 或受信反向代理后时,设置 AUTH_TRUST_HOST=true
const trustHost =
process.env.AUTH_TRUST_HOST === "true" ||
process.env.VERCEL === "1";
return {
trustHost,
pages: {
signIn: "/login",
newUser: "/",
},
cookies: {
sessionToken: {
name: isSecure ? "__Secure-authjs.session-token" : "authjs.session-token",
options: {
httpOnly: true,
sameSite: "lax",
path: "/",
secure: isSecure,
domain: cookieDomain,
},
},
callbackUrl: {
name: isSecure ? "__Secure-authjs.callback-url" : "authjs.callback-url",
options: {
sameSite: "lax",
path: "/",
secure: isSecure,
domain: cookieDomain,
},
},
csrfToken: {
name: isSecure ? "__Host-authjs.csrf-token" : "authjs.csrf-token",
options: {
httpOnly: true,
sameSite: "lax",
path: "/",
secure: isSecure,
},
},
},
providers: [],
callbacks: {},
};
}

396
app/(auth)/auth.ts Normal file
View File

@ -0,0 +1,396 @@
import { compare } from "bcrypt-ts";
import NextAuth, { type DefaultSession } from "next-auth";
import type { DefaultJWT } from "next-auth/jwt";
import Credentials from "next-auth/providers/credentials";
import Google from "next-auth/providers/google";
import { headers } from "next/headers";
import { DUMMY_PASSWORD } from "@/lib/constants";
import { createGuestUser, getAuthSettings, getUser } from "@/lib/db/queries";
import { buildAuthConfig } from "./auth.config";
import { db } from "@/lib/db";
import { loginToken, user as userTable } from "@/lib/db/schema";
import { eq, and, gt } from "drizzle-orm";
import {
establishSingleActiveSession,
getValidatedSessionUser,
isManagedSessionUserType,
requiresSingleActiveSession,
revokeSingleActiveSession,
} from "@/lib/auth/session-control";
import { recordAccessIpEventFromHeaders } from "@/lib/security/access-log";
export type UserType = "guest" | "regular" | "admin" | "agent_admin";
declare module "next-auth" {
interface Session extends DefaultSession {
user: {
id: string;
type: UserType;
name?: string | null;
sessionKey?: string;
} & DefaultSession["user"];
}
interface User {
id?: string;
email?: string | null;
name?: string | null;
type?: UserType;
}
}
declare module "next-auth/jwt" {
interface JWT extends DefaultJWT {
id: string;
type: UserType;
name?: string | null;
sessionKey?: string;
}
}
async function getOrCreateGoogleUser(email: string, name?: string | null) {
const existingUsers = await getUser(email);
if (existingUsers.length > 0) {
return existingUsers[0];
}
const [createdUser] = await db
.insert(userTable)
.values({
email,
password: "",
name: (name || email.split("@")[0] || "").slice(0, 100),
role: "regular",
isActive: true,
})
.returning();
return createdUser || null;
}
function createBaseProviders(googleClientId?: string, googleClientSecret?: string) {
const providers: any[] = [
Credentials({
credentials: {},
async authorize(credentials: any) {
// 1. DingTalk Token 登录逻辑
if (credentials.token) {
const { token } = credentials;
const now = new Date();
const [validToken] = await db
.select()
.from(loginToken)
.where(and(eq(loginToken.token, token), gt(loginToken.expiresAt, now)))
.limit(1);
if (!validToken) {
return null;
}
const users = await getUser(validToken.email);
if (users.length === 0) {
return null;
}
const user = users[0];
// 检查用户是否被激活
if (!user.isActive) {
return null;
}
await db.delete(loginToken).where(eq(loginToken.id, validToken.id));
const userType: UserType = (user.role as UserType) || "regular";
return { id: user.id, email: user.email, name: user.name, type: userType };
}
// 2. 邮箱密码登录逻辑
const email = String(credentials?.email || "").trim().toLowerCase();
const password = String(credentials?.password || "");
if (!email || !password) {
await compare(password || "", DUMMY_PASSWORD);
return null;
}
const users = await getUser(email);
if (users.length === 0) {
await compare(password, DUMMY_PASSWORD);
return null;
}
const [user] = users;
if (!user.password) {
await compare(password, DUMMY_PASSWORD);
return null;
}
const passwordsMatch = await compare(password, user.password);
if (!passwordsMatch) {
return null;
}
// 检查用户是否被激活
if (!user.isActive) {
return null;
}
// 从数据库读取用户角色,默认为 regular
const userType: UserType = (user.role as UserType) || "regular";
return { id: user.id, email: user.email, name: user.name, type: userType };
},
}),
Credentials({
id: "guest",
credentials: {},
async authorize() {
const [guestUser] = await createGuestUser();
return { ...guestUser, type: "guest" };
},
}),
];
if (googleClientId && googleClientSecret) {
providers.push(
Google({
clientId: googleClientId,
clientSecret: googleClientSecret,
})
);
}
return providers;
}
function normalizeBaseUrl(input?: string | null) {
if (!input) return "";
try {
const parsed = new URL(input);
return `${parsed.protocol}//${parsed.host}`;
} catch {
return "";
}
}
async function resolveRuntimeAuthUrl(settingsAuthUrl?: string | null) {
const fromEnv =
normalizeBaseUrl(process.env.AUTH_URL) ||
normalizeBaseUrl(process.env.NEXTAUTH_URL);
if (fromEnv) return fromEnv;
const fromSettings = normalizeBaseUrl(settingsAuthUrl);
if (fromSettings) return fromSettings;
const requestHeaders = await headers();
const forwardedHost = requestHeaders.get("x-forwarded-host");
const host = requestHeaders.get("host");
const resolvedHost = forwardedHost || host || "";
if (!resolvedHost) return "";
const forwardedProto = requestHeaders.get("x-forwarded-proto");
const protocol = forwardedProto || "https";
return `${protocol}://${resolvedHost}`;
}
const nextAuth = NextAuth(async () => {
const runtimeAuthSettings =
process.env.CANVAS_E2E_LOCAL === "true"
? null
: await getAuthSettings().catch(() => null as any);
const resolvedAuthUrl =
(await resolveRuntimeAuthUrl(runtimeAuthSettings?.authUrl)) || undefined;
const googleClientId =
runtimeAuthSettings?.googleClientId || process.env.GOOGLE_CLIENT_ID || "";
const googleClientSecret =
runtimeAuthSettings?.googleClientSecret || process.env.GOOGLE_CLIENT_SECRET || "";
return {
...buildAuthConfig(resolvedAuthUrl),
providers: createBaseProviders(googleClientId, googleClientSecret),
callbacks: {
async signIn({ account, user, profile }) {
if (account?.provider !== "google") {
return true;
}
const authSettings = await getAuthSettings();
if (!authSettings.isGoogleEnabled) {
return false;
}
const email = (user?.email || (profile as any)?.email || "").trim().toLowerCase();
if (!email) {
return false;
}
const dbUser = await getOrCreateGoogleUser(
email,
user?.name || (profile as any)?.name || null
);
return Boolean(dbUser?.isActive);
},
async jwt({ token, user, account, profile }) {
let resolvedUserId =
typeof token.id === "string" && token.id.length > 0 ? token.id : undefined;
let resolvedUserType =
typeof token.type === "string" && isManagedSessionUserType(token.type)
? token.type
: undefined;
if (user?.id && user?.type) {
token.id = user.id as string;
token.type = user.type;
token.name = user.name;
resolvedUserId = token.id;
resolvedUserType = token.type;
}
if (account?.provider === "google") {
const email = String(
user?.email || (profile as any)?.email || token.email || ""
)
.trim()
.toLowerCase();
if (email) {
const dbUser = await getOrCreateGoogleUser(
email,
user?.name || (profile as any)?.name || null
);
if (dbUser) {
token.id = dbUser.id;
token.type = (dbUser.role as UserType) || "regular";
token.name = dbUser.name;
token.email = dbUser.email;
resolvedUserId = dbUser.id;
resolvedUserType = (dbUser.role as UserType) || "regular";
}
}
}
if (account && resolvedUserId && requiresSingleActiveSession(resolvedUserType)) {
const { sessionKey } = await establishSingleActiveSession({
userId: resolvedUserId,
});
token.sessionKey = sessionKey;
} else if (!requiresSingleActiveSession(resolvedUserType)) {
delete token.sessionKey;
}
if (account && resolvedUserId) {
await recordAccessIpEventFromHeaders({
eventType: "login_success",
userId: resolvedUserId,
userEmail:
typeof token.email === "string"
? token.email
: typeof user?.email === "string"
? user.email
: null,
userRole: resolvedUserType || "regular",
method: "POST",
path: `/api/auth/${account.provider}`,
routeGroup: "auth",
statusCode: 200,
outcome: "login_success",
severity: "info",
reasons: ["登录成功"],
metadata: {
provider: account.provider,
},
}).catch((error) => {
console.warn("[Auth] Failed to record login access event:", error);
});
}
return token;
},
session({ session, token }) {
if (session.user) {
session.user.id = token.id;
session.user.type = token.type || "regular";
session.user.name = token.name as string;
session.user.sessionKey =
typeof token.sessionKey === "string" ? token.sessionKey : undefined;
}
return session;
},
},
};
});
const {
handlers: { GET, POST },
auth: rawAuth,
signIn,
signOut: rawSignOut,
} = nextAuth;
export { GET, POST, signIn };
export async function auth() {
const session = await rawAuth();
if (!session?.user?.id) {
return session;
}
if (!requiresSingleActiveSession(session.user.type)) {
return session;
}
const sessionKey = session.user.sessionKey;
if (!sessionKey) {
return null;
}
try {
const validatedUser = await getValidatedSessionUser({
userId: session.user.id,
sessionKey,
});
if (!validatedUser) {
return null;
}
return {
...session,
user: {
...session.user,
email: validatedUser.email,
name: validatedUser.name ?? session.user.name,
type: validatedUser.role,
sessionKey,
},
};
} catch (error) {
console.error("[Auth] Failed to validate active session:", error);
return null;
}
}
export async function signOut(options?: Parameters<typeof rawSignOut>[0]) {
const session = await auth().catch(() => null);
if (session?.user?.id && requiresSingleActiveSession(session.user.type)) {
await revokeSingleActiveSession({
userId: session.user.id,
sessionKey: session.user.sessionKey,
}).catch((error) => {
console.error("[Auth] Failed to revoke active session during sign out:", error);
});
}
return rawSignOut(options);
}

720
app/(auth)/login/page.tsx Normal file
View File

@ -0,0 +1,720 @@
"use client";
import Link from "next/link";
import { useActionState, useEffect, useState, useRef, useCallback } from "react";
import { useTheme } from "next-themes";
import { Moon, Sun, X } from "lucide-react";
import { signIn } from "next-auth/react"; // Import signIn
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { AuthForm } from "@/components/auth-form";
import { SubmitButton } from "@/components/submit-button";
import { toast } from "@/components/toast";
import { type LoginActionState, login } from "../actions";
import { Button } from "@/components/ui/button";
import { AntigravityBackground } from "@/components/antigravity-background";
import { createClientLogger } from "@/lib/observability/client-logger";
const logger = createClientLogger("auth-login");
export default function Page() {
const [email, setEmail] = useState("");
const [isSuccessful, setIsSuccessful] = useState(false);
const [siteName, setSiteName] = useState("AI Chatbot");
const [siteLogo, setSiteLogo] = useState<string | null>(null);
const [siteLogoDark, setSiteLogoDark] = useState<string | null>(null);
const [isDingTalkEnabled, setIsDingTalkEnabled] = useState(false);
const [isGoogleEnabled, setIsGoogleEnabled] = useState(false);
const [isGoogleConfigured, setIsGoogleConfigured] = useState(false);
const [dingTalkAppKey, setDingTalkAppKey] = useState<string | null>(null);
const [dingTalkOrganizations, setDingTalkOrganizations] = useState<string[]>([]);
const [isRegistrationEnabled, setIsRegistrationEnabled] = useState(true);
const [showDingTalkModal, setShowDingTalkModal] = useState(false);
const [dingTalkIframeSrc, setDingTalkIframeSrc] = useState("");
const [showOrgMenu, setShowOrgMenu] = useState(false);
const { setTheme, resolvedTheme } = useTheme();
const [mounted, setMounted] = useState(false);
const [isHandlingToken, setIsHandlingToken] = useState(false);
const [isCheckingExistingSession, setIsCheckingExistingSession] = useState(true);
// 系统通知状态
const [notification, setNotification] = useState<{ enabled: boolean; title: string; content: string } | null>(null);
const [showNotification, setShowNotification] = useState(false);
const loginRedirectTimerRef = useRef<number | null>(null);
const loginRedirectFallbackTimerRef = useRef<number | null>(null);
const iframeRef = useRef<HTMLIFrameElement>(null);
// 声明 formAction 在前面
const [state, formAction] = useActionState<LoginActionState, FormData>(
login,
{
status: "idle",
}
);
useEffect(() => {
let cancelled = false;
fetch("/api/auth/validate-session", {
cache: "no-store",
credentials: "include",
})
.then((res) => res.json())
.then((data) => {
if (cancelled) return;
if (data?.valid === true && data?.userType !== "guest") {
window.location.replace("/");
return;
}
setIsCheckingExistingSession(false);
})
.catch(() => {
if (!cancelled) {
setIsCheckingExistingSession(false);
}
});
return () => {
cancelled = true;
};
}, []);
// 使用 token 完成钉钉登录
const completeDingTalkLogin = useCallback(async (token: string) => {
logger.debug("[DingTalk] Completing login with token", { hasToken: Boolean(token) });
setIsHandlingToken(true);
try {
// 获取 CSRF token
const csrfRes = await fetch("/api/auth/csrf");
const { csrfToken } = await csrfRes.json();
logger.debug("[DingTalk] CSRF token resolved");
// 手动构造登录请求
const loginRes = await fetch("/api/auth/callback/credentials", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
csrfToken,
token,
callbackUrl: "/",
}),
credentials: "include", // 重要:确保 cookie 被发送和接收
});
// 检查响应类型
const contentType = loginRes.headers.get("content-type");
logger.debug("[DingTalk] Login response received", {
status: loginRes.status,
redirected: loginRes.redirected,
contentType,
});
if (loginRes.redirected || loginRes.status === 302) {
// 登录成功,被重定向
logger.debug("[DingTalk] Login redirected successfully");
// 等待一小段时间确保 cookie 被设置
await new Promise(resolve => setTimeout(resolve, 300));
// 验证 session 是否创建成功
const sessionRes = await fetch("/api/auth/session");
const session = await sessionRes.json();
logger.debug("[DingTalk] Session validation completed", {
hasUser: Boolean(session?.user),
});
if (session && session.user) {
logger.debug("[DingTalk] Session validated, redirecting to home...");
setIsSuccessful(true);
// 使用 replace 而不是 href,避免在历史记录中留下登录页
// 增加短暂延迟确保 cookie 完全同步
setTimeout(() => {
window.location.replace("/");
}, 200);
} else {
logger.error("[DingTalk] Session not created after login");
setIsHandlingToken(false);
toast({ type: "error", description: "登录失败:无法创建会话" });
}
} else if (contentType?.includes("application/json")) {
const result = await loginRes.json();
logger.debug("[DingTalk] Login JSON response received", {
ok: loginRes.ok,
hasUrl: Boolean(result?.url),
hasError: Boolean(result?.error),
});
if (result.url || loginRes.ok) {
// 登录成功
logger.debug("[DingTalk] Login successful");
// 等待一小段时间确保 cookie 被设置
await new Promise(resolve => setTimeout(resolve, 300));
// 验证 session
const sessionRes = await fetch("/api/auth/session");
const session = await sessionRes.json();
logger.debug("[DingTalk] Session validation completed", {
hasUser: Boolean(session?.user),
});
if (session && session.user) {
logger.debug("[DingTalk] Session validated, redirecting...");
setIsSuccessful(true);
// 使用 replace 而不是 href,避免在历史记录中留下登录页
// 增加短暂延迟确保 cookie 完全同步
setTimeout(() => {
window.location.replace(result.url || "/");
}, 200);
} else {
logger.error("[DingTalk] Session not created");
setIsHandlingToken(false);
toast({ type: "error", description: "登录失败:无法创建会话" });
}
} else {
// 登录失败
logger.error("[DingTalk] Login failed", {
hasError: Boolean(result?.error),
});
setIsHandlingToken(false);
toast({ type: "error", description: result.error || "登录失败,请重试" });
}
} else {
// 未知响应
await loginRes.text();
logger.error("[DingTalk] Unexpected response type", {
status: loginRes.status,
contentType,
});
setIsHandlingToken(false);
toast({ type: "error", description: "登录失败:服务器响应异常" });
}
} catch (error) {
logger.error("[DingTalk] Login error:", error);
setIsHandlingToken(false);
toast({ type: "error", description: "登录发生错误:" + (error instanceof Error ? error.message : String(error)) });
}
}, []);
// Chunk 加载错误自动恢复
useEffect(() => {
const handleChunkError = (event: ErrorEvent) => {
const target = event.target as any;
if (target?.src?.includes("/_next/static/chunks/") || event.message?.includes("Failed to load chunk")) {
logger.error("[ChunkError] 检测到chunk加载失败,3秒后自动刷新页面");
// 3秒后自动刷新页面(清除缓存)
setTimeout(() => {
window.location.reload();
}, 3000);
}
};
window.addEventListener("error", handleChunkError, true);
return () => window.removeEventListener("error", handleChunkError, true);
}, []);
// 初始化设置
useEffect(() => {
setMounted(true);
// 获取网站设置(添加时间戳防止缓存)
fetch(`/api/site-settings?scope=auth&t=${Date.now()}`)
.then((res) => res.json())
.then((data) => {
logger.debug("[Login] Site settings loaded", {
hasSiteName: Boolean(data.siteName),
hasSiteLogo: Boolean(data.siteLogo),
hasNotification: Boolean(data.notification),
});
if (data.siteName) setSiteName(data.siteName);
if (data.siteLogo) setSiteLogo(data.siteLogo);
if (data.siteLogoDark) setSiteLogoDark(data.siteLogoDark);
// 处理系统通知
if (data.notification && data.notification.enabled) {
logger.debug("[Login] Setting notification and showing dialog");
setNotification(data.notification);
// 每次访问登录页都显示通知(重要公告)
setShowNotification(true);
} else {
logger.debug("[Login] Notification not enabled or missing");
}
})
.catch((err) => {
logger.error("[Login] Failed to fetch site settings:", err);
});
// 获取认证设置
fetch("/api/auth-settings")
.then((res) => res.json())
.then((data) => {
if (data.isDingTalkEnabled) setIsDingTalkEnabled(true);
if (data.isGoogleEnabled) setIsGoogleEnabled(true);
if (data.isGoogleConfigured) setIsGoogleConfigured(true);
if (data.dingTalkAppKey) setDingTalkAppKey(data.dingTalkAppKey);
const rawOrganizations = Array.isArray(data.organizations)
? data.organizations
: Array.isArray(data.dingTalkOrganizations)
? data.dingTalkOrganizations
: [];
if (rawOrganizations.length > 0) {
setDingTalkOrganizations(
rawOrganizations
.map((org: { name?: string } | string) =>
typeof org === "string" ? org : org.name,
)
.filter((name: string | undefined): name is string => Boolean(name?.trim()))
.map((name: string) => name.trim()),
);
}
if (data.isRegistrationEnabled !== undefined) {
setIsRegistrationEnabled(data.isRegistrationEnabled);
}
})
.catch((error) => {
logger.error("[Login] Failed to fetch auth settings", error);
});
}, []);
const handleGoogleLogin = async () => {
if (!isGoogleEnabled) {
toast({ type: "error", description: "Google 登录未启用" });
return;
}
if (!isGoogleConfigured) {
toast({ type: "error", description: "Google 登录未完成服务器配置" });
return;
}
await signIn("google", { callbackUrl: "/" });
};
// 检查 URL 中是否有钉钉 Token(页面加载时自动登录)
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const token = params.get("dingtalk_token");
if (token) {
completeDingTalkLogin(token);
}
}, [completeDingTalkLogin]);
// 监听来自钉钉回调页面的消息
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
logger.debug("[DingTalk] Received message", {
type: event.data?.type,
hasToken: Boolean(event.data?.token),
});
if (event.data && event.data.type === "dingtalk_login_success") {
setShowDingTalkModal(false);
setDingTalkIframeSrc("");
// 使用返回的 token 完成 Auth.js 登录
if (event.data.token) {
toast({ type: "success", description: "钉钉认证成功,正在登录..." });
completeDingTalkLogin(event.data.token);
} else {
toast({ type: "error", description: "钉钉登录失败:未获取到登录凭证" });
}
} else if (event.data && event.data.type === "dingtalk_login_error") {
setShowDingTalkModal(false);
setDingTalkIframeSrc("");
toast({ type: "error", description: "钉钉登录失败:" + (event.data.message || "未知错误") });
}
};
window.addEventListener("message", handleMessage);
return () => window.removeEventListener("message", handleMessage);
}, [completeDingTalkLogin]);
// 处理登录状态变化
useEffect(() => {
if (loginRedirectTimerRef.current) {
window.clearTimeout(loginRedirectTimerRef.current);
loginRedirectTimerRef.current = null;
}
if (loginRedirectFallbackTimerRef.current) {
window.clearTimeout(loginRedirectFallbackTimerRef.current);
loginRedirectFallbackTimerRef.current = null;
}
if (state.status === "failed") {
setIsSuccessful(false);
setIsHandlingToken(false);
toast({
type: "error",
description: "邮箱或密码不正确",
});
} else if (state.status === "invalid_data") {
setIsSuccessful(false);
setIsHandlingToken(false);
toast({
type: "error",
description: "提交的信息校验失败",
});
} else if (state.status === "success") {
toast({ type: "success", description: "登录成功,正在进入首页" });
setIsSuccessful(true);
loginRedirectTimerRef.current = window.setTimeout(() => {
window.location.replace("/");
}, 120);
loginRedirectFallbackTimerRef.current = window.setTimeout(() => {
window.location.assign("/");
}, 1600);
}
}, [state.status]);
const handleSubmit = (formData: FormData) => {
setIsSuccessful(false);
setEmail(formData.get("email") as string);
formAction(formData);
};
useEffect(() => {
return () => {
if (loginRedirectTimerRef.current) {
window.clearTimeout(loginRedirectTimerRef.current);
}
if (loginRedirectFallbackTimerRef.current) {
window.clearTimeout(loginRedirectFallbackTimerRef.current);
}
};
}, []);
const getSafeLoginCallbackUrl = () => {
if (typeof window === "undefined") return "/";
const callbackUrl = new URLSearchParams(window.location.search).get(
"callbackUrl",
);
if (
callbackUrl &&
callbackUrl.startsWith("/") &&
!callbackUrl.startsWith("//")
) {
return callbackUrl;
}
return "/";
};
const buildDingTalkLoginUrl = (orgName?: string) => {
const params = new URLSearchParams();
if (orgName) params.set("org", orgName);
const callbackUrl = getSafeLoginCallbackUrl();
if (callbackUrl !== "/") {
params.set("callbackUrl", callbackUrl);
}
const query = params.toString();
return `/api/auth/dingtalk/login${query ? `?${query}` : ""}`;
};
// 根据组织名称启动钉钉登录。钉钉授权页禁止第三方 iframe 嵌入,必须使用顶层跳转。
const startDingTalkLoginForOrg = (orgName: string) => {
// 清除 URL 中的 token 参数,避免干扰
if (
typeof window !== "undefined" &&
window.location.search.includes("dingtalk_token")
) {
const newUrl = window.location.pathname;
window.history.replaceState({}, "", newUrl);
}
const loginUrl = buildDingTalkLoginUrl(orgName);
logger.debug("[DingTalk] Redirecting org login", {
hasOrg: Boolean(orgName),
loginUrl,
});
setShowDingTalkModal(true);
setShowOrgMenu(false);
window.location.assign(loginUrl);
};
// 钉钉登录 - 支持多组织
const handleDingTalkLogin = () => {
if (!isDingTalkEnabled) {
toast({ type: "error", description: "钉钉登录未启用" });
return;
}
// 没有配置多组织:回退到单一 appKey 流程
if (dingTalkOrganizations.length === 0) {
if (!dingTalkAppKey) {
toast({ type: "error", description: "钉钉登录未正确配置" });
return;
}
if (
typeof window === "undefined" ||
typeof window.location === "undefined"
) {
toast({ type: "error", description: "当前环境不支持钉钉登录" });
return;
}
// 清除 URL 中的 token 参数,避免干扰
if (window.location.search.includes("dingtalk_token")) {
const newUrl = window.location.pathname;
window.history.replaceState({}, "", newUrl);
}
const loginUrl = buildDingTalkLoginUrl();
logger.debug("[DingTalk] Redirecting OAuth login", { loginUrl });
window.location.assign(loginUrl);
return;
}
// 只有一个组织:直接使用该组织登录
if (dingTalkOrganizations.length === 1) {
startDingTalkLoginForOrg(dingTalkOrganizations[0] as string);
return;
}
// 多组织:直接打开弹窗中的组织列表,避免悬浮菜单被页面层级覆盖
setDingTalkIframeSrc("");
setShowDingTalkModal(true);
setShowOrgMenu(false);
};
// 关闭钉钉登录弹窗
const closeDingTalkModal = () => {
setShowDingTalkModal(false);
setDingTalkIframeSrc("");
};
// 决定显示哪个 LOGO
const displayLogo = mounted
? (resolvedTheme === "dark" && siteLogoDark ? siteLogoDark : siteLogo)
: siteLogo;
const hasThirdPartyLogin =
(isDingTalkEnabled && (Boolean(dingTalkAppKey) || dingTalkOrganizations.length > 0)) ||
(isGoogleEnabled && isGoogleConfigured);
if (isCheckingExistingSession) {
return (
<div className="flex min-h-screen items-center justify-center text-muted-foreground text-sm">
正在检查登录状态...
</div>
);
}
return (
<div className="flex h-dvh w-screen items-start justify-center bg-background pt-12 md:items-center md:pt-0 relative overflow-hidden">
{/* Antigravity 动画背景 */}
<AntigravityBackground />
{/* 主题切换按钮 */}
<div className="absolute top-4 right-4 z-10">
<Button
variant="ghost"
size="icon"
onClick={() => setTheme(resolvedTheme === "dark" ? "light" : "dark")}
>
{mounted && resolvedTheme === "dark" ? (
<Moon className="h-5 w-5" />
) : (
<Sun className="h-5 w-5" />
)}
</Button>
</div>
<div className="flex w-full max-w-md flex-col gap-12 overflow-hidden rounded-2xl relative z-10 pointer-events-auto">
<div className="flex flex-col items-center justify-center gap-2 px-4 text-center sm:px-16 backdrop-blur-sm bg-background/80 rounded-2xl p-6 pointer-events-auto">
{displayLogo ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={displayLogo}
alt={siteName}
className="mb-2 h-12 w-auto object-contain"
/>
) : null}
<h3 className="font-semibold text-xl dark:text-zinc-50">登录 {siteName}</h3>
<p className="text-gray-500 text-sm dark:text-zinc-400">
使用邮箱与密码进入系统
</p>
</div>
<AuthForm action={handleSubmit} defaultEmail={email} className="pointer-events-auto">
<SubmitButton isSuccessful={isSuccessful}>登录</SubmitButton>
{hasThirdPartyLogin && (
<div className="mt-4 w-full relative">
<div className="relative mb-4">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">
或使用第三方登录
</span>
</div>
</div>
<div className="space-y-2">
{isDingTalkEnabled && (dingTalkAppKey || dingTalkOrganizations.length > 0) ? (
<button
type="button"
onClick={handleDingTalkLogin}
className="flex w-full items-center justify-center gap-2 rounded-md border bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:bg-zinc-900 dark:text-zinc-200 dark:hover:bg-zinc-800"
>
<svg style={{ width: "20px", height: "20px" }} viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
<path
d="M512 2C230.2 2 2 230.2 2 512s228.2 510 510 510 510-228.2 510-510S793.3 2 512 2z m235.9 442c-1 4.6-3.6 10.8-7.2 19.1l-0.5 0.5c-21.6 45.8-77.3 135.5-77.3 135.5l-0.5-0.5-16.5 28.3h78.8L574.3 826.8l34-136h-61.8l21.6-90.2c-17.5 4.1-38.1 9.8-62.3 18 0 0-33 19.1-94.8-37.1 0 0-41.7-37.1-17.5-45.8 10.3-4.1 50-8.8 81.4-12.9 42.2-5.7 68.5-8.8 68.5-8.8s-130.3 2.1-161.2-3.1c-30.9-4.6-70.1-56.7-78.3-102 0 0-12.9-24.7 27.8-12.9 40.2 11.8 209.2 45.8 209.2 45.8S321.4 375 307 358.5c-14.4-16.5-42.8-89.6-39.2-134.5 0 0 1.5-11.3 12.9-8.2 0 0 161.8 74.2 272.5 114.4C664.5 371.4 760.8 392 747.9 444z"
fill="#3296FA"
/>
</svg>
钉钉登录
</button>
) : null}
{isGoogleEnabled && isGoogleConfigured ? (
<button
type="button"
onClick={handleGoogleLogin}
className="flex w-full items-center justify-center gap-2 rounded-md border bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:bg-zinc-900 dark:text-zinc-200 dark:hover:bg-zinc-800"
>
<svg width="20" height="20" viewBox="0 0 24 24" aria-hidden="true">
<path fill="#EA4335" d="M12 10.2v3.9h5.5c-.2 1.2-1.4 3.5-5.5 3.5-3.3 0-6-2.7-6-6s2.7-6 6-6c1.9 0 3.2.8 3.9 1.5l2.7-2.6C16.9 2.9 14.6 2 12 2 6.5 2 2 6.5 2 12s4.5 10 10 10c5.8 0 9.6-4.1 9.6-9.8 0-.7-.1-1.2-.2-1.7H12z" />
</svg>
Google 登录
</button>
) : null}
</div>
{/* 多组织下的小型选择框(悬浮在按钮附近) */}
{dingTalkOrganizations.length > 1 && showOrgMenu && (
<div className="absolute left-0 right-0 -top-2 -translate-y-full rounded-md border bg-background shadow-lg z-20 max-h-60 overflow-auto text-sm">
{dingTalkOrganizations.map((org) => (
<button
key={org}
type="button"
onClick={() => startDingTalkLoginForOrg(org)}
className="flex w-full items-center justify-between px-3 py-2 text-left hover:bg-muted"
>
<span>{org}</span>
</button>
))}
</div>
)}
</div>
)}
{isRegistrationEnabled && (
<p className="mt-4 text-center text-gray-600 text-sm dark:text-zinc-400">
还没有账号?
<Link
className="font-semibold text-gray-800 hover:underline dark:text-zinc-200"
href="/register"
>
立即注册
</Link>
,即可免费体验。
</p>
)}
</AuthForm>
</div>
{/* 钉钉登录 iframe 弹窗 / 多组织选择 */}
{showDingTalkModal && (
<div
className="fixed inset-0 bg-black/85 flex items-center justify-center z-50 backdrop-blur-sm"
onClick={(e) => {
if (e.target === e.currentTarget) closeDingTalkModal();
}}
>
<div className="w-[90%] max-w-[500px] h-[720px] bg-white dark:bg-zinc-900 rounded-xl overflow-hidden flex flex-col shadow-2xl">
<div className="flex justify-between items-center p-4 bg-gray-50 dark:bg-zinc-800 border-b dark:border-zinc-700">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
钉钉登录
</h3>
<button
onClick={closeDingTalkModal}
className="p-2 hover:bg-gray-200 dark:hover:bg-zinc-700 rounded-lg transition-colors"
>
<X className="w-5 h-5 text-gray-500 dark:text-gray-400" />
</button>
</div>
{/* 多组织选择 */}
{dingTalkOrganizations.length > 1 && !dingTalkIframeSrc && (
<div className="p-4 space-y-3">
<p className="text-sm text-muted-foreground">
请选择要使用的钉钉组织进行登录:
</p>
<div className="space-y-2">
{dingTalkOrganizations.map((org) => (
<button
key={org}
type="button"
onClick={() => startDingTalkLoginForOrg(org)}
className="flex w-full items-center justify-between rounded-md border bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 dark:bg-zinc-900 dark:text-zinc-200 dark:hover:bg-zinc-800"
>
<span>{org}</span>
</button>
))}
</div>
<p className="text-xs text-muted-foreground">
选择后将跳转到钉钉完成授权。
</p>
</div>
)}
{/* 登录 iframe */}
{dingTalkIframeSrc && (
<iframe
ref={iframeRef}
src={dingTalkIframeSrc}
className="flex-1 w-full border-0"
title="钉钉登录"
/>
)}
<div className="p-4 bg-gray-50 dark:bg-zinc-800 border-t dark:border-zinc-700 flex justify-center">
<button
onClick={closeDingTalkModal}
className="px-4 py-2 bg-gray-200 dark:bg-zinc-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-300 dark:hover:bg-zinc-600 transition-colors"
>
返回登录
</button>
</div>
</div>
</div>
)}
{/* 系统通知弹窗 */}
<Dialog open={showNotification} onOpenChange={setShowNotification}>
<DialogContent className="sm:max-w-[425px] bg-background">
<DialogHeader>
<DialogTitle>{notification?.title || "系统通知"}</DialogTitle>
</DialogHeader>
<div className="whitespace-pre-wrap text-sm text-muted-foreground mt-4 max-h-[60vh] overflow-y-auto">
{notification?.content}
</div>
</DialogContent>
</Dialog>
{/* 登录中 Loading 状态 */}
{isHandlingToken && (
<div className="fixed inset-0 bg-black/50 z-[60] flex items-center justify-center backdrop-blur-sm">
<div className="bg-white dark:bg-zinc-800 p-8 rounded-xl shadow-2xl flex flex-col items-center">
<div className="w-10 h-10 border-4 border-blue-500 border-t-transparent rounded-full animate-spin mb-4"></div>
<p className="text-lg font-medium text-gray-900 dark:text-white">正在完成登录...</p>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,254 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useActionState, useEffect, useRef, useState } from "react";
import { AuthForm } from "@/components/auth-form";
import { SubmitButton } from "@/components/submit-button";
import { toast } from "@/components/toast";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { type RegisterActionState, register } from "../actions";
export default function Page() {
const router = useRouter();
const [email, setEmail] = useState("");
const [isSuccessful, setIsSuccessful] = useState(false);
const [siteName, setSiteName] = useState("AI Chatbot");
const [siteLogo, setSiteLogo] = useState<string | null>(null);
const [isEmailVerificationEnabled, setIsEmailVerificationEnabled] = useState(false);
const [emailVerificationExpiresMinutes, setEmailVerificationExpiresMinutes] = useState(10);
const [isSendingCode, setIsSendingCode] = useState(false);
const [codeCooldown, setCodeCooldown] = useState(0);
const [isCheckingExistingSession, setIsCheckingExistingSession] = useState(true);
const redirectTimerRef = useRef<number | null>(null);
useEffect(() => {
// 获取认证设置并检查是否开放注册
fetch("/api/auth-settings")
.then((res) => res.json())
.then((data) => {
if (data.isRegistrationEnabled === false) {
toast({ type: "error", description: "当前系统未开放注册" });
router.push("/login");
}
setIsEmailVerificationEnabled(data.isEmailVerificationEnabled === true);
const minutes = Number(data.emailVerificationExpiresMinutes) || 10;
setEmailVerificationExpiresMinutes(Math.min(Math.max(minutes, 3), 30));
})
.catch(console.error);
// 获取网站设置(添加时间戳防止缓存)
fetch(`/api/site-settings?scope=branding&t=${Date.now()}`)
.then((res) => res.json())
.then((data) => {
if (data.siteName) {
setSiteName(data.siteName);
}
if (data.siteLogo) {
setSiteLogo(data.siteLogo);
}
})
.catch(console.error);
fetch("/api/auth/validate-session", {
cache: "no-store",
credentials: "include",
})
.then((res) => res.json())
.then((data) => {
if (data?.valid === true && data?.userType !== "guest") {
window.location.replace("/");
return;
}
setIsCheckingExistingSession(false);
})
.catch(() => {
setIsCheckingExistingSession(false);
});
}, []);
useEffect(() => {
if (codeCooldown <= 0) {
return;
}
const timer = window.setInterval(() => {
setCodeCooldown((prev) => (prev > 0 ? prev - 1 : 0));
}, 1000);
return () => window.clearInterval(timer);
}, [codeCooldown]);
const [state, formAction] = useActionState<RegisterActionState, FormData>(
register,
{
status: "idle",
}
);
useEffect(() => {
if (redirectTimerRef.current) {
window.clearTimeout(redirectTimerRef.current);
redirectTimerRef.current = null;
}
if (state.status === "user_exists") {
setIsSuccessful(false);
toast({ type: "error", description: "账号已存在" });
} else if (state.status === "password_mismatch") {
setIsSuccessful(false);
toast({ type: "error", description: "两次输入的密码不一致" });
} else if (state.status === "verification_code_required") {
setIsSuccessful(false);
toast({ type: "error", description: "请先输入邮箱验证码" });
} else if (state.status === "verification_code_invalid") {
setIsSuccessful(false);
toast({ type: "error", description: "邮箱验证码不正确" });
} else if (state.status === "verification_code_expired") {
setIsSuccessful(false);
toast({ type: "error", description: "邮箱验证码已过期,请重新发送" });
} else if (state.status === "failed") {
setIsSuccessful(false);
toast({ type: "error", description: "创建账号失败" });
} else if (state.status === "invalid_data") {
setIsSuccessful(false);
toast({
type: "error",
description: "提交的信息校验失败",
});
} else if (state.status === "success") {
toast({ type: "success", description: "账号创建成功" });
setIsSuccessful(true);
redirectTimerRef.current = window.setTimeout(() => {
window.location.replace("/");
}, 300);
}
}, [state.status]);
useEffect(() => {
return () => {
if (redirectTimerRef.current) {
window.clearTimeout(redirectTimerRef.current);
}
};
}, []);
const handleSubmit = (formData: FormData) => {
setEmail(formData.get("email") as string);
formAction(formData);
};
if (isCheckingExistingSession) {
return (
<div className="flex min-h-screen items-center justify-center text-muted-foreground text-sm">
正在检查登录状态...
</div>
);
}
const handleSendCode = async () => {
const normalizedEmail = email.trim().toLowerCase();
if (!normalizedEmail) {
toast({ type: "error", description: "请先输入邮箱地址" });
return;
}
if (codeCooldown > 0 || isSendingCode) {
return;
}
setIsSendingCode(true);
try {
const response = await fetch("/api/auth/email/send-code", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: normalizedEmail }),
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data?.error || "验证码发送失败");
}
const cooldownSeconds = Number(data?.cooldownSeconds) || 60;
setCodeCooldown(cooldownSeconds);
toast({
type: "success",
description: `验证码已发送,请在 ${emailVerificationExpiresMinutes} 分钟内完成验证`,
});
} catch (error) {
toast({
type: "error",
description: error instanceof Error ? error.message : "验证码发送失败",
});
} finally {
setIsSendingCode(false);
}
};
return (
<div className="flex h-dvh w-screen items-start justify-center bg-background pt-12 md:items-center md:pt-0">
<div className="flex w-full max-w-md flex-col gap-12 overflow-hidden rounded-2xl">
<div className="flex flex-col items-center justify-center gap-2 px-4 text-center sm:px-16">
{siteLogo ? (
<img src={siteLogo} alt={siteName} className="h-12 w-12 mb-2 object-contain" />
) : null}
<h3 className="font-semibold text-xl dark:text-zinc-50">注册 {siteName}</h3>
<p className="text-gray-500 text-sm dark:text-zinc-400">
使用邮箱与密码创建新账号
</p>
</div>
<AuthForm
action={handleSubmit}
defaultEmail={email}
onEmailChange={setEmail}
showConfirmPassword
>
{isEmailVerificationEnabled ? (
<div className="flex flex-col gap-2">
<Label
className="font-normal text-zinc-600 dark:text-zinc-400"
htmlFor="verificationCode"
>
邮箱验证码
</Label>
<div className="flex items-center gap-2">
<Input
className="bg-muted text-md md:text-sm"
id="verificationCode"
name="verificationCode"
placeholder="请输入 6 位验证码"
required
type="text"
/>
<Button
type="button"
variant="outline"
onClick={handleSendCode}
disabled={isSendingCode || codeCooldown > 0}
className="shrink-0"
>
{isSendingCode
? "发送中..."
: codeCooldown > 0
? `${codeCooldown}s`
: "发送验证码"}
</Button>
</div>
</div>
) : null}
<SubmitButton isSuccessful={isSuccessful}>注册</SubmitButton>
<p className="mt-4 text-center text-gray-600 text-sm dark:text-zinc-400">
已经有账号?
<Link
className="font-semibold text-gray-800 hover:underline dark:text-zinc-200"
href="/login"
>
立即登录
</Link>
。
</p>
</AuthForm>
</div>
</div>
);
}

67
app/(chat)/actions.ts Normal file
View File

@ -0,0 +1,67 @@
"use server";
import { generateText, type UIMessage } from "ai";
import { cookies } from "next/headers";
import type { VisibilityType } from "@/components/visibility-selector";
import { titlePrompt } from "@/lib/ai/prompts";
import { myProvider } from "@/lib/ai/providers";
import { getSystemModel } from "@/lib/ai/dynamic-provider";
import {
deleteMessagesByChatIdAfterTimestamp,
getMessageById,
updateChatVisibilityById,
} from "@/lib/db/queries";
import { getTextFromMessage } from "@/lib/utils";
export async function saveChatModelAsCookie(model: string) {
const cookieStore = await cookies();
cookieStore.set("chat-model", model);
}
export async function generateTitleFromUserMessage({
message,
}: {
message: UIMessage;
}) {
const prompt = (getTextFromMessage(message) || "").trim();
const fallbackTitle = prompt.slice(0, 20) || "新对话";
try {
// 优先使用数据库配置的模型,回退到预定义模型
let model = await getSystemModel("title");
if (!model) {
model = myProvider.languageModel("title-model");
}
const { text: title } = await generateText({
model,
system: titlePrompt,
prompt,
});
const normalized = (title || "").trim();
return normalized || fallbackTitle;
} catch (error) {
console.warn("[generateTitleFromUserMessage] fallback title:", error);
return fallbackTitle;
}
}
export async function deleteTrailingMessages({ id }: { id: string }) {
const [message] = await getMessageById({ id });
await deleteMessagesByChatIdAfterTimestamp({
chatId: message.chatId,
timestamp: message.createdAt,
});
}
export async function updateChatVisibility({
chatId,
visibility,
}: {
chatId: string;
visibility: VisibilityType;
}) {
await updateChatVisibilityById({ chatId, visibility });
}

View File

@ -0,0 +1,44 @@
import { existsSync } from "node:fs";
import path from "node:path";
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { auth } from "@/app/(auth)/auth";
const ZOO_MODELING_APP_SRC =
"/vendor/zoo-modeling-app/index.html?v=f2b345db-eng1";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "HiCAD",
};
export const runtime = "nodejs";
export default async function AiCadPage() {
const session = await auth();
if (!session?.user?.id) {
redirect("/login?callbackUrl=%2Fai-cad");
}
const isReady = existsSync(
path.join(process.cwd(), "public/vendor/zoo-modeling-app/index.html")
);
if (isReady) {
redirect(ZOO_MODELING_APP_SRC);
}
return (
<main className="flex h-svh w-full items-center justify-center bg-[#10100e] p-6 text-[#f8f6ef]">
<section className="w-full max-w-xl rounded-lg border border-[#3a352c] bg-[#171510] p-6 shadow-2xl">
<h1 className="font-semibold text-lg">HiCAD 官方前端尚未构建</h1>
<p className="mt-2 text-[#d3cabb] text-sm leading-6">
请运行
<code className="mx-1 rounded bg-white/10 px-1.5 py-0.5">
pnpm zoo-modeling-app:vendor
</code>
生成官方前端,然后刷新本页。
</p>
</section>
</main>
);
}

View File

@ -0,0 +1,112 @@
import { createUIMessageStream, JsonToSseTransformStream } from "ai";
import { differenceInSeconds } from "date-fns";
import { auth } from "@/app/(auth)/auth";
import {
getChatById,
getMessagesByChatId,
getStreamIdsByChatId,
} from "@/lib/db/queries";
import type { Chat } from "@/lib/db/schema";
import { ChatSDKError } from "@/lib/errors";
import type { ChatMessage } from "@/lib/types";
import { getStreamContext } from "../../route";
export async function GET(
_: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id: chatId } = await params;
const streamContext = getStreamContext();
const resumeRequestedAt = new Date();
if (!streamContext) {
return new Response(null, { status: 204 });
}
if (!chatId) {
return new ChatSDKError("bad_request:api").toResponse();
}
const session = await auth();
if (!session?.user) {
return new ChatSDKError("unauthorized:chat").toResponse();
}
let chat: Chat | null;
try {
chat = await getChatById({ id: chatId });
} catch {
return new ChatSDKError("not_found:chat").toResponse();
}
if (!chat) {
return new ChatSDKError("not_found:chat").toResponse();
}
if (chat.visibility === "private" && chat.userId !== session.user.id) {
return new ChatSDKError("forbidden:chat").toResponse();
}
const streamIds = await getStreamIdsByChatId({ chatId });
if (!streamIds.length) {
return new ChatSDKError("not_found:stream").toResponse();
}
const recentStreamId = streamIds.at(-1);
if (!recentStreamId) {
return new ChatSDKError("not_found:stream").toResponse();
}
const emptyDataStream = createUIMessageStream<ChatMessage>({
execute: () => {},
});
const stream = await streamContext.resumableStream(recentStreamId, () =>
emptyDataStream.pipeThrough(new JsonToSseTransformStream())
);
/*
* For when the generation is streaming during SSR
* but the resumable stream has concluded at this point.
*/
if (!stream) {
const messages = await getMessagesByChatId({ id: chatId });
const mostRecentMessage = messages.at(-1);
if (!mostRecentMessage) {
return new Response(emptyDataStream, { status: 200 });
}
if (mostRecentMessage.role !== "assistant") {
return new Response(emptyDataStream, { status: 200 });
}
const messageCreatedAt = new Date(mostRecentMessage.createdAt);
if (differenceInSeconds(resumeRequestedAt, messageCreatedAt) > 15) {
return new Response(emptyDataStream, { status: 200 });
}
const restoredStream = createUIMessageStream<ChatMessage>({
execute: ({ writer }) => {
writer.write({
type: "data-appendMessage",
data: JSON.stringify(mostRecentMessage),
transient: true,
});
},
});
return new Response(
restoredStream.pipeThrough(new JsonToSseTransformStream()),
{ status: 200 }
);
}
return new Response(stream, { status: 200 });
}

View File

@ -0,0 +1,108 @@
import type { PostRequestBody } from "../schema";
export function sanitizeAgentOutputSettingsSection(params: {
message?: string;
userImageParams?: PostRequestBody["imageParams"];
hasUserCustomImageParams: boolean;
}): string | undefined {
const { message, userImageParams, hasUserCustomImageParams } = params;
if (!message?.trim()) return message;
const outputSectionRegex =
/(?:###\s*输出设置|【\s*输出设置\s*】)[\s\S]*?(?=\n(?:###\s*|【\s*[^】]+\s*】)|$)/;
if (!outputSectionRegex.test(message)) return message;
if (!hasUserCustomImageParams) {
return message.replace(
outputSectionRegex,
"【输出设置】\n- 参数:按用户面板当前设置执行(未自动改写)"
);
}
const lines: string[] = [];
const aspectRatio =
typeof userImageParams?.aspectRatio === "string" ? userImageParams.aspectRatio.trim() : "";
const imageSize =
typeof userImageParams?.imageSize === "string" ? userImageParams.imageSize.trim() : "";
const imageCount =
typeof userImageParams?.imageCount === "number" ? userImageParams.imageCount : undefined;
if (aspectRatio && aspectRatio !== "auto") {
lines.push(`- 宽高比:${aspectRatio}(来自用户面板设置)`);
}
if (imageSize && imageSize.toUpperCase() !== "2K") {
lines.push(`- 清晰度档位:${imageSize.toUpperCase()}(来自用户面板设置)`);
}
if (typeof imageCount === "number" && Number.isFinite(imageCount) && imageCount !== 1) {
lines.push(`- 出图数量:${Math.max(1, Math.round(imageCount))} 张(来自用户面板设置)`);
}
lines.push("- 其余参数:保持用户面板当前设置");
return message.replace(outputSectionRegex, `【输出设置】\n${lines.join("\n")}`);
}
export function normalizeAgentReportHeadingLine(line: string): string {
const trimmed = line.trim();
if (!trimmed) return line;
const markdownHeading = trimmed.match(/^#{1,6}\s*(.+?)\s*$/);
const plainTitle = markdownHeading?.[1]?.trim() || trimmed;
const normalizedTitle = plainTitle
.replace(/^[^\u4e00-\u9fa5A-Za-z0-9]+/, "")
.trim();
const knownTitles = [
"意图分析",
"任务规划",
"需求理解",
"图片分析",
"执行计划",
"优化后提示词",
"输出设置",
"脚本规划",
"分镜规划",
"执行说明",
"视频生成完成",
];
if (knownTitles.some((title) => normalizedTitle === title)) {
return `【${normalizedTitle}】`;
}
if (markdownHeading) {
return `【${normalizedTitle || plainTitle}】`;
}
return line;
}
export function stripAgentFriendlyMediaEmbeds(message: string): string {
if (!message) return message;
const withoutInlineMedia = message
.replace(/!\[[^\]]*]\((https?:\/\/[^\s)]+)\)/gi, "")
.replace(/<video[\s\S]*?<\/video>/gi, "")
.replace(/<img[^>]*>/gi, "");
const cleanedLines = withoutInlineMedia
.split("\n")
.filter((line) => {
const trimmed = line.trim();
if (!trimmed) return true;
if (/^https?:\/\/\S+$/i.test(trimmed)) return false;
if (/^\[[^\]]*]\(https?:\/\/[^\s)]+\)$/i.test(trimmed)) return false;
return true;
})
.join("\n");
return cleanedLines.replace(/\n{3,}/g, "\n\n").trim();
}
export function normalizeAgentReportForDisplay(message?: string): string | undefined {
if (!message?.trim()) return message;
const stripped = stripAgentFriendlyMediaEmbeds(message);
if (!stripped) return undefined;
return stripped
.split("\n")
.map((line) => normalizeAgentReportHeadingLine(line))
.join("\n");
}

View File

@ -0,0 +1,540 @@
export type AgentPresetMessageImagePart = {
type: "file";
url: string;
name: string;
mediaType: string;
assetId?: string;
};
export function extractCurrentMessageImageParts(
parts: Array<Record<string, any>>,
): AgentPresetMessageImagePart[] {
return parts
.filter(
(part): part is AgentPresetMessageImagePart =>
part?.type === "file" &&
typeof part?.url === "string" &&
/^https?:\/\//i.test(part.url) &&
typeof part?.mediaType === "string" &&
part.mediaType.startsWith("image/")
)
.map((part) => ({
type: "file",
url: String(part.url).trim(),
name: String(part.name || "图片").trim() || "图片",
mediaType: String(part.mediaType || "image/jpeg").trim() || "image/jpeg",
assetId:
typeof part.assetId === "string" && part.assetId.trim() ? part.assetId.trim() : undefined,
}));
}
export function getSceneUploadedImageBreakdown(params: {
uploadedImageParts: AgentPresetMessageImagePart[];
fallbackPrimaryCount: number;
fallbackIncludeModelImage: boolean;
}) {
const normalizedNames = params.uploadedImageParts.map((part) => String(part.name || "").trim());
const primaryCountFromNames = normalizedNames.filter((name) => name.startsWith("主SKU参考图")).length;
const secondaryCountFromNames = normalizedNames.filter((name) => name.startsWith("搭配SKU参考图")).length;
const hasModelFromNames = normalizedNames.some((name) => name.startsWith("品牌模特图"));
const hasNamedBreakdown =
primaryCountFromNames > 0 || secondaryCountFromNames > 0 || hasModelFromNames;
if (hasNamedBreakdown) {
return {
primaryProductImageCount: primaryCountFromNames,
bundleProductImageCount: secondaryCountFromNames,
includeModelImage: hasModelFromNames,
};
}
const uploadedImageCount = params.uploadedImageParts.length;
const includeModelImage =
params.fallbackIncludeModelImage && uploadedImageCount > params.fallbackPrimaryCount;
const bundleProductImageCount = Math.max(
0,
uploadedImageCount - params.fallbackPrimaryCount - (includeModelImage ? 1 : 0)
);
const primaryProductImageCount = Math.max(
0,
Math.min(
params.fallbackPrimaryCount,
uploadedImageCount - bundleProductImageCount - (includeModelImage ? 1 : 0)
)
);
return {
primaryProductImageCount,
bundleProductImageCount,
includeModelImage,
};
}
export function resolveScenePromptOptimizationImageUrls(params: {
uploadedImageParts: AgentPresetMessageImagePart[];
primaryProductImageCount: number;
bundleProductImageCount: number;
includeModelImage: boolean;
}) {
const namedPrimaryParts = params.uploadedImageParts.filter((part) =>
String(part.name || "").trim().startsWith("主SKU参考图")
);
const namedModelPart = params.uploadedImageParts.find((part) =>
String(part.name || "").trim().startsWith("品牌模特图")
);
if (namedPrimaryParts.length > 0 || namedModelPart) {
return {
primaryImageUrl: namedPrimaryParts[0]?.url || "",
modelImageUrl: namedModelPart?.url || null,
};
}
const primaryImageUrl =
params.uploadedImageParts.slice(0, Math.max(0, params.primaryProductImageCount))[0]?.url || "";
const modelIndex = params.primaryProductImageCount + params.bundleProductImageCount;
return {
primaryImageUrl,
modelImageUrl:
params.includeModelImage && modelIndex < params.uploadedImageParts.length
? params.uploadedImageParts[modelIndex]?.url || null
: null,
};
}
export function normalizeAgentSupplementPrompt(text: string, presetName?: string | null) {
const normalized = String(text || "").trim();
if (!normalized) return "";
const escapedPresetName = String(presetName || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const genericPattern = escapedPresetName
? new RegExp(`^请按「${escapedPresetName}」风格化配置顺序处理这\\d+张图片。?$`)
: /^请按「.+」风格化配置顺序处理这\d+张图片。?$/;
if (genericPattern.test(normalized)) {
return "";
}
return normalized;
}
export function normalizeAgentWorkflowPrompt(text: string) {
return String(text || "")
.split(/\n+/)
.map((line) => line.replace(/\s+/g, " ").trim())
.filter(Boolean)
.join("\n")
.trim();
}
export function parseAgentWorkflowPromptSections(text: string) {
const sectionMap = new Map<string, string[]>();
let activeSection = "__root__";
const normalizedPrompt = normalizeAgentWorkflowPrompt(text);
if (!normalizedPrompt) {
return sectionMap;
}
normalizedPrompt.split("\n").forEach((line) => {
const trimmedLine = line.trim();
if (!trimmedLine) {
return;
}
const headerMatch = trimmedLine.match(/^【([^】]{1,30})】$/);
if (headerMatch) {
activeSection = headerMatch[1].trim();
if (!sectionMap.has(activeSection)) {
sectionMap.set(activeSection, []);
}
return;
}
const currentLines = sectionMap.get(activeSection) || [];
currentLines.push(trimmedLine);
sectionMap.set(activeSection, currentLines);
});
return sectionMap;
}
export function buildAgentPromptSectionText(
title: string,
lines: string[],
options?: { bullet?: boolean }
) {
const normalizedLines = lines
.map((line) => String(line || "").trim())
.filter(Boolean);
if (normalizedLines.length === 0) {
return "";
}
const sectionBody = options?.bullet
? normalizedLines.map((line) => (line.startsWith("- ") ? line : `- ${line}`)).join("\n")
: normalizedLines.join("\n");
return `【${title}】\n${sectionBody}`;
}
export function buildOptimizedAgentSceneWorkflowPrompt(params: {
workflowPrompt: string;
optimizationResult?: {
sceneGoal: string;
environmentSummary: string;
propSummary: string;
cameraLightingSummary: string;
characterSummary: string;
negativeConstraints: string[];
conflicts: string[];
} | null;
}) {
const normalizedWorkflowPrompt = normalizeAgentWorkflowPrompt(params.workflowPrompt);
if (!normalizedWorkflowPrompt) {
return "";
}
const optimizationResult = params.optimizationResult || null;
if (!optimizationResult) {
return normalizedWorkflowPrompt;
}
const sectionMap = parseAgentWorkflowPromptSections(normalizedWorkflowPrompt);
const sceneGoalLines = [optimizationResult.sceneGoal, optimizationResult.environmentSummary].filter(
Boolean
);
const propLines = [optimizationResult.propSummary].filter(Boolean);
const cameraLightingLines = [optimizationResult.cameraLightingSummary].filter(Boolean);
const characterLines = [optimizationResult.characterSummary].filter(Boolean);
const negativeLines = Array.from(
new Set([
...optimizationResult.negativeConstraints,
...optimizationResult.conflicts.map((item) => `冲突校正:${item}`),
])
);
const optimizedSections = [
buildAgentPromptSectionText("项目背景", sectionMap.get("项目背景") || sectionMap.get("__root__") || []),
buildAgentPromptSectionText("主SKU锁定", sectionMap.get("主SKU锁定") || []),
buildAgentPromptSectionText("搭配SKU锁定", sectionMap.get("搭配SKU锁定") || []),
buildAgentPromptSectionText("参考图", sectionMap.get("参考图") || []),
buildAgentPromptSectionText("场景创作总纲", sceneGoalLines),
buildAgentPromptSectionText("陈列与道具", propLines),
buildAgentPromptSectionText("机位与光线", cameraLightingLines),
buildAgentPromptSectionText("人物规则", characterLines),
buildAgentPromptSectionText("负面约束", negativeLines, { bullet: true }),
].filter(Boolean);
return optimizedSections.join("\n\n").trim();
}
const AGENT_PROMPT_STRUCTURAL_LABELS = new Set([
"角色",
"技能",
"目标",
"约束条件",
"流程",
"输出",
]);
const AGENT_PROMPT_LABEL_SEMANTIC_ALIASES = new Map<string, string>([
["角色", "role"],
["技能", "skills"],
["目标", "goal"],
["约束条件", "constraints"],
["流程", "process"],
["输出", "output"],
["人群选择", "audience"],
["人物", "audience"],
["主题风格", "style"],
["整体风格", "style"],
["空间选择", "space"],
["空间布局", "space"],
["主空间", "space"],
["子空间选择", "subspace"],
["子空间", "subspace"],
["硬装方案", "hardDecoration"],
["硬装", "hardDecoration"],
["软装方案", "softDecoration"],
["软装", "softDecoration"],
["光氛选择", "lighting"],
["光源布置", "lighting"],
["灯具", "lighting"],
["机位选择", "camera"],
["拍摄角度", "camera"],
["配饰陈列", "accessories"],
["放在产品内的小件配饰", "accessories"],
["前景", "sceneLayout"],
["中景", "sceneLayout"],
["背景", "sceneLayout"],
]);
const AGENT_SCENE_WORKFLOW_OWNED_SEMANTICS = new Set([
"role",
"skills",
"goal",
"constraints",
"process",
"output",
"audience",
"style",
"space",
"subspace",
"hardDecoration",
"softDecoration",
"lighting",
"camera",
"accessories",
"sceneLayout",
]);
export function normalizePromptLineForCompare(line: string) {
return String(line || "")
.replace(/\s+/g, " ")
.replace(/[::]/g, ":")
.replace(/[。;;,,、!!??]+$/g, "")
.trim()
.toLowerCase();
}
export function parsePromptLineLabel(line: string) {
const normalized = String(line || "").replace(/\s+/g, " ").trim();
if (!normalized) return { label: "", value: "" };
const headerOnlyMatch = normalized.match(/^([^::]{1,20})[::]\s*$/);
if (headerOnlyMatch) {
return {
label: headerOnlyMatch[1].trim(),
value: "",
};
}
const colonMatch = normalized.match(/^([^::]{1,20})[::]\s*(.+)$/);
if (colonMatch) {
return {
label: colonMatch[1].trim(),
value: colonMatch[2].trim(),
};
}
const whitespaceMatch = normalized.match(/^([^\s]{1,20})\s+(.+)$/);
if (whitespaceMatch) {
return {
label: whitespaceMatch[1].trim(),
value: whitespaceMatch[2].trim(),
};
}
return { label: "", value: normalized };
}
export function normalizePromptLabel(label: string) {
return String(label || "")
.replace(/\s+/g, "")
.replace(/[::]/g, "")
.trim();
}
export function getPromptSemanticLabel(label: string) {
const normalized = normalizePromptLabel(label);
return AGENT_PROMPT_LABEL_SEMANTIC_ALIASES.get(normalized) || normalized;
}
export function collectPromptSemanticLabelsFromText(text: string) {
const semanticLabels = new Set<string>();
const normalizedText = String(text || "").replace(/\s+/g, " ").trim();
if (!normalizedText) {
return semanticLabels;
}
for (const [label, semantic] of AGENT_PROMPT_LABEL_SEMANTIC_ALIASES.entries()) {
if (!label) {
continue;
}
if (normalizedText.includes(`${label}:`) || normalizedText.includes(`${label}:`)) {
semanticLabels.add(semantic);
continue;
}
if (normalizedText.includes(`${label} `) || normalizedText.includes(label)) {
semanticLabels.add(semantic);
}
}
return semanticLabels;
}
export function shouldDropSceneSlotSemanticLine(
slotSemanticLabel: string,
workflowSemanticLabels: Set<string>,
) {
if (!slotSemanticLabel || !AGENT_SCENE_WORKFLOW_OWNED_SEMANTICS.has(slotSemanticLabel)) {
return false;
}
if (slotSemanticLabel === "sceneLayout") {
return [
"style",
"space",
"subspace",
"hardDecoration",
"softDecoration",
"accessories",
"camera",
"lighting",
].some((semantic) => workflowSemanticLabels.has(semantic));
}
return workflowSemanticLabels.has(slotSemanticLabel);
}
export function dedupeAgentSlotPromptAgainstWorkflow(params: {
workflowPrompt?: string | null;
slotPrompt?: string | null;
modeType?: "scene_generation" | "detail_replacement" | "sku_replacement" | "generic";
}) {
const dedupedSlotLines: string[] = [];
const seenExactLines = new Set<string>();
const workflowStructuralValues = new Map<string, string>();
const workflowSemanticLabels = new Set<string>();
const rememberLine = (line: string) => {
const normalizedLine = normalizePromptLineForCompare(line);
if (!normalizedLine) {
return;
}
seenExactLines.add(normalizedLine);
};
const normalizedWorkflowLines = normalizeAgentWorkflowPrompt(params.workflowPrompt || "")
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
normalizedWorkflowLines.forEach((line) => {
const parsed = parsePromptLineLabel(line);
if (parsed.label) {
workflowSemanticLabels.add(getPromptSemanticLabel(parsed.label));
}
collectPromptSemanticLabelsFromText(line).forEach((semantic) => {
workflowSemanticLabels.add(semantic);
});
collectPromptSemanticLabelsFromText(parsed.value || "").forEach((semantic) => {
workflowSemanticLabels.add(semantic);
});
if (parsed.label && AGENT_PROMPT_STRUCTURAL_LABELS.has(parsed.label)) {
workflowStructuralValues.set(
parsed.label,
normalizePromptLineForCompare(parsed.value || "")
);
}
rememberLine(line);
});
const normalizedSlotLines = String(params.slotPrompt || "")
.split(/\n+/)
.map((line) => line.replace(/\s+/g, " ").trim())
.filter(Boolean);
let activeSlotSemanticLabel = "";
normalizedSlotLines.forEach((line) => {
const normalizedLine = normalizePromptLineForCompare(line);
if (!normalizedLine || seenExactLines.has(normalizedLine)) {
return;
}
const parsed = parsePromptLineLabel(line);
const slotSemanticLabel = parsed.label ? getPromptSemanticLabel(parsed.label) : "";
if (slotSemanticLabel) {
activeSlotSemanticLabel = slotSemanticLabel;
}
if (
params.modeType === "scene_generation" &&
shouldDropSceneSlotSemanticLine(
slotSemanticLabel || activeSlotSemanticLabel,
workflowSemanticLabels,
)
) {
return;
}
if (parsed.label && AGENT_PROMPT_STRUCTURAL_LABELS.has(parsed.label)) {
const normalizedValue = normalizePromptLineForCompare(parsed.value || "");
const workflowValue = workflowStructuralValues.get(parsed.label);
if (workflowValue && workflowValue === normalizedValue) {
return;
}
}
seenExactLines.add(normalizedLine);
dedupedSlotLines.push(line);
});
return dedupedSlotLines.join("\n").trim();
}
export function buildAgentSlotPrompt(params: {
workflowPrompt?: string | null;
slotPrompt: string;
slotTitle?: string | null;
userSupplementPrompt?: string | null;
includeSlotTitle?: boolean;
modeType?: "scene_generation" | "detail_replacement" | "sku_replacement" | "generic";
}) {
const lines: string[] = [];
const workflowPrompt = normalizeAgentWorkflowPrompt(params.workflowPrompt || "");
if (workflowPrompt) {
lines.push(workflowPrompt);
}
const slotTitle = String(params.slotTitle || "").trim();
if (params.includeSlotTitle !== false && slotTitle) {
lines.push(`【槽位】${slotTitle}`);
}
const dedupedSlotPrompt = dedupeAgentSlotPromptAgainstWorkflow({
workflowPrompt,
slotPrompt: params.slotPrompt,
modeType: params.modeType || "generic",
});
if (dedupedSlotPrompt) {
lines.push(dedupedSlotPrompt);
}
const supplement = String(params.userSupplementPrompt || "").trim();
if (supplement) {
lines.push(`【补充要求】${supplement}`);
}
return lines.join("\n\n").trim();
}
export function buildAgentSceneWorkflowPrompt(params: {
workflowPrompt?: string | null;
slotPrompt?: string | null;
userSupplementPrompt?: string | null;
imageRolePrompt?: string | null;
}) {
const lines: string[] = [];
const workflowPrompt = normalizeAgentWorkflowPrompt(params.workflowPrompt || "");
if (workflowPrompt) {
lines.push(workflowPrompt);
}
const slotPrompt = String(params.slotPrompt || "")
.split(/\n+/)
.map((line) => line.replace(/\s+/g, " ").trim())
.filter(Boolean)
.join("\n")
.trim();
if (slotPrompt) {
lines.push(slotPrompt);
}
let imageRolePrompt = String(params.imageRolePrompt || "").trim();
if (workflowPrompt.includes("品牌模特图:已选") || workflowPrompt.includes("固定模特图")) {
imageRolePrompt = imageRolePrompt
.replace(
/^如当前请求未附带固定模特图,仅在工作流明确要求人物时再安排辅助人物,且人物不能喧宾夺主。$/m,
""
)
.replace(/^当前没有固定模特图,不要凭空生成强人物主体。$/m, "")
.split(/\n+/)
.map((line) => line.trim())
.filter(Boolean)
.join("\n");
}
if (imageRolePrompt) {
lines.push(imageRolePrompt);
}
const supplement = String(params.userSupplementPrompt || "").trim();
if (supplement) {
lines.push(`【补充要求】${supplement}`);
}
return lines.join("\n\n").trim();
}

View File

@ -0,0 +1,105 @@
import { createLogger } from "@/lib/observability/logger";
import { getModelGenerationCreditCost } from "@/lib/ai/image-model-pricing";
import {
consumeUserPoints,
getAiModelById,
getEnabledAiModelsWithProvider,
getPointsSystemConfig,
getUserById,
} from "@/lib/db/queries";
const chatApiLogger = createLogger("chat.billing");
const MODEL_UUID_REGEX =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export function getModelCreditCost(
modelConfig: any,
target: "image" | "video",
options?: {
duration?: number | null;
imageSize?: string | null;
resolution?: string | null;
},
): number {
return getModelGenerationCreditCost(modelConfig, target, options);
}
export async function resolveBillingModelByReference(modelRef: string): Promise<any | undefined> {
if (!modelRef) return undefined;
if (MODEL_UUID_REGEX.test(modelRef)) {
return await getAiModelById({ id: modelRef }).catch(() => undefined);
}
const allModels = await getEnabledAiModelsWithProvider(true, true).catch(() => []);
const matched = allModels.find(
({ model }: any) => model.id === modelRef || model.modelId === modelRef,
);
return matched?.model;
}
export async function consumeGeneratedVideoPoints(params: {
userId: string;
chatId: string;
messageId: string;
modelRefs: Array<string | null | undefined>;
duration?: number | null;
resolution?: string | null;
}) {
try {
const pointsConfig = await getPointsSystemConfig();
if (!pointsConfig.enabled) return;
const userData = await getUserById({ id: params.userId }).catch(() => undefined);
if (userData?.isHighVip) {
chatApiLogger.info(`[Points] Skip video points for VIP user=${params.userId}`);
return;
}
let billingModel: any | undefined;
for (const ref of params.modelRefs) {
if (!ref) continue;
const resolved = await resolveBillingModelByReference(ref);
if (!resolved) continue;
billingModel = resolved;
if (
getModelCreditCost(resolved, "video", {
duration: params.duration,
resolution: params.resolution,
}) > 0
) {
break;
}
}
if (!billingModel) return;
const unitCost = getModelCreditCost(billingModel, "video", {
duration: params.duration,
resolution: params.resolution,
});
if (unitCost <= 0) return;
const consumeResult = await consumeUserPoints({
userId: params.userId,
amount: unitCost,
reason: "video_generation",
modelId: billingModel.id,
chatId: params.chatId,
messageId: params.messageId,
note: `模型(${billingModel.displayName || billingModel.modelId || billingModel.id}) 生视频 1 条${params.duration ? `(${params.duration} 秒)` : ""}${params.resolution ? `(${params.resolution})` : ""}`,
});
if (!consumeResult.ok) {
chatApiLogger.warn(`[Points] Failed to consume video points. user=${params.userId} model=${billingModel.id} cost=${unitCost}`);
} else if (consumeResult.skipped) {
chatApiLogger.info(`[Points] Skipped ${unitCost} video points for VIP user=${params.userId} model=${billingModel.id}`);
} else {
chatApiLogger.info(`[Points] Consumed ${unitCost} points for video. balance=${consumeResult.balance}`);
}
} catch (error) {
chatApiLogger.error("[Points] Failed to consume points after video generation:", error);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,192 @@
import { createLogger } from "@/lib/observability/logger";
import { NextResponse } from "next/server";
import { getRequestErrorMessage } from "@/lib/request-error";
import { generateUUID } from "@/lib/utils";
const chatApiLogger = createLogger("chat.errors");
const DEFAULT_CHAT_PRE_RESPONSE_SOFT_TIMEOUT_MS = 55_000;
export class OperationTimeoutError extends Error {
constructor(operation: string, timeoutMs: number) {
super(`${operation} timed out after ${timeoutMs}ms`);
this.name = "OperationTimeoutError";
}
}
export function getChatStreamErrorMessage(error: unknown): string {
const rawMessage =
error instanceof Error ? String(error.message || "").trim() : String(error || "").trim();
const normalized = rawMessage.toLowerCase();
if (
normalized.includes("token quota is not enough") ||
normalized.includes("pre_consume_token_quota_failed") ||
normalized.includes("quota is not enough")
) {
return "当前对话模型额度不足,请切换其他模型,或联系管理员补充该模型额度。";
}
if (
normalized.includes("timeout") ||
normalized.includes("timed out") ||
normalized.includes("body timeout")
) {
return "当前对话模型响应超时,请重试,或切换到其他可用模型。";
}
if (rawMessage) {
return `当前对话暂时失败:${rawMessage}`;
}
return "当前对话暂时失败,请重试或切换其他模型。";
}
function resolveChatApiFailure(error: unknown) {
const detailMessage = getRequestErrorMessage(error);
const normalized = String(detailMessage || "").toLowerCase();
if (
normalized.includes("payload too large") ||
normalized.includes("request entity too large") ||
normalized.includes("body exceeded") ||
normalized.includes("content length") ||
normalized.includes("413")
) {
return {
code: "payload_too_large:chat",
message: "本次请求内容过大,请减少图片或文字内容后重试。",
status: 413,
category: "payload_too_large",
detailMessage,
};
}
if (
error instanceof OperationTimeoutError ||
normalized.includes("timeout") ||
normalized.includes("timed out") ||
normalized.includes("body timeout") ||
normalized.includes("aborterror") ||
normalized.includes("aborted")
) {
return {
code: "timeout:chat",
message: "当前模型响应超时,请重试,或切换到其他可用模型。",
status: 504,
category: "timeout",
detailMessage,
};
}
if (
normalized.includes("fetch failed") ||
normalized.includes("econnreset") ||
normalized.includes("econnrefused") ||
normalized.includes("enotfound") ||
normalized.includes("etimedout") ||
normalized.includes("und_err") ||
normalized.includes("socket hang up")
) {
return {
code: "server_error:chat",
message: "模型服务连接失败,请稍后重试,或切换其他可用模型。",
status: 502,
category: "upstream_connection",
detailMessage,
};
}
if (
normalized.includes("database") ||
normalized.includes("postgres") ||
normalized.includes("drizzle")
) {
return {
code: "server_error:chat",
message: "服务数据库暂时繁忙,请稍后重试。",
status: 503,
category: "database",
detailMessage,
};
}
return {
code: "server_error:chat",
message: "服务临时繁忙,请稍后重试;如果连续出现,请联系管理员。",
status: 500,
category: "unknown",
detailMessage,
};
}
export function createChatApiFailureResponse(
error: unknown,
vercelId: string | null
) {
const failure = resolveChatApiFailure(error);
const traceId = generateUUID();
chatApiLogger.error("[Chat API] Unhandled request failure:", {
traceId,
vercelId,
category: failure.category,
status: failure.status,
detailMessage: failure.detailMessage,
error,
});
return NextResponse.json(
{
code: failure.code,
message: failure.message,
cause: `trace:${traceId}`,
},
{ status: failure.status }
);
}
export async function withOperationTimeout<T>(
operation: Promise<T>,
operationName: string,
timeoutMs: number
): Promise<T> {
let timer: ReturnType<typeof setTimeout> | null = null;
try {
return await Promise.race([
operation,
new Promise<never>((_, reject) => {
timer = setTimeout(
() => reject(new OperationTimeoutError(operationName, timeoutMs)),
timeoutMs
);
}),
]);
} finally {
if (timer) {
clearTimeout(timer);
}
}
}
export function getRemainingBudgetMs(startedAt: number, budgetMs: number) {
return Math.max(0, budgetMs - (Date.now() - startedAt));
}
export function getBudgetedTimeout(params: {
startedAt: number;
preferredMs: number;
minimumMs?: number;
budgetMs?: number;
}) {
const minimumMs = params.minimumMs ?? 6_000;
const remainingMs = getRemainingBudgetMs(
params.startedAt,
params.budgetMs ?? DEFAULT_CHAT_PRE_RESPONSE_SOFT_TIMEOUT_MS
);
if (remainingMs < minimumMs) {
return null;
}
return Math.min(params.preferredMs, remainingMs);
}

View File

@ -0,0 +1,507 @@
import { createLogger } from "@/lib/observability/logger";
import { isFalGptImage2EditModelId } from "@/lib/ai/providers/fal-image";
import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
import { modelLacksReferenceImageInputSupport } from "@/lib/ai/reference-image-capability";
import type { ChatMessage } from "@/lib/types";
import { convertMessagesWithImages } from "./image-media";
const chatApiLogger = createLogger("chat.imageFailover");
export const IMAGE_FAILOVER_PROVIDER_ALLOWLIST = new Set([
"alibaba-image",
"bfl-image",
"ruxa-image",
"fal-image",
"snapgen-image",
"openai-image",
"bananaimage",
"visionary",
"atlascloud",
"vip98k",
"stepfun-image",
"runway",
"krea",
"grsai-gpt-image",
"volcano-vision",
"gemini",
"huanke-gemini",
]);
const IMAGE_FAILOVER_MODEL_COOLDOWN_MS = 30 * 60 * 1000;
const imageFailoverModelCooldown = new Map<string, { until: number; reason: string }>();
export function resolveImageModelRuntimeId(
model: { defaultParams?: unknown; modelId?: unknown; id?: unknown } | null | undefined,
) {
const defaultParams = model?.defaultParams;
const defaultEndpoint =
typeof defaultParams === "object" &&
defaultParams !== null &&
"endpoint" in defaultParams
? String((defaultParams as Record<string, unknown>).endpoint || "").trim()
: "";
if (defaultEndpoint) {
return defaultEndpoint;
}
const modelId = String(model?.modelId || "").trim();
if (modelId) {
return modelId;
}
return String(model?.id || "").trim();
}
export function normalizeFailoverErrorMessage(error: unknown): string {
if (!error) return "";
if (typeof error === "string") return error;
if (error instanceof Error) return error.message || String(error);
try {
return JSON.stringify(error);
} catch {
return String(error);
}
}
export function shouldCooldownImageFallbackModel(errorMessage: string): boolean {
const msg = errorMessage.toLowerCase();
return (
msg.includes("model not found") ||
msg.includes("model_not_found") ||
msg.includes("no available channel for model") ||
msg.includes("unknown model") ||
msg.includes("invalid model") ||
msg.includes("api key is missing") ||
msg.includes("invalid api key") ||
msg.includes("unauthorized") ||
msg.includes("cannot convert argument to a bytestring") ||
msg.includes("model maintenance") ||
msg.includes("maintenance") ||
msg.includes("维护中") ||
msg.includes("维护") ||
msg.includes("service unavailable") ||
msg.includes("temporarily unavailable") ||
msg.includes("系统繁忙") ||
msg.includes("无可用渠道") ||
/(?:^|\D)(?:429|500|502|503|504)(?:\D|$)/.test(msg)
);
}
export function isImageFallbackModelCoolingDown(modelId: string): boolean {
const snapshot = imageFailoverModelCooldown.get(modelId);
if (!snapshot) return false;
if (snapshot.until <= Date.now()) {
imageFailoverModelCooldown.delete(modelId);
return false;
}
return true;
}
export function setImageFallbackModelCooldown(modelId: string, reason: string): void {
imageFailoverModelCooldown.set(modelId, {
until: Date.now() + IMAGE_FAILOVER_MODEL_COOLDOWN_MS,
reason: reason.slice(0, 500),
});
}
export function isImageFailoverExcludedModel(model: { excludeFromFailover?: boolean } | null | undefined) {
return Boolean(model?.excludeFromFailover);
}
export type ImageFallbackCandidate = {
model: any;
provider: any;
configured: boolean;
sameProvider: boolean;
nameSimilarity: number;
sameName: boolean;
};
const IMAGE_FAILOVER_SAME_NAME_THRESHOLD = 0.5;
function getModelRefKeys(item: { model?: any } | null | undefined) {
return [
String(item?.model?.id || "").trim(),
String(item?.model?.modelId || "").trim(),
].filter(Boolean);
}
function normalizeModelNameForSimilarity(value: unknown) {
return String(value || "")
.trim()
.toLowerCase()
.replace(/[「」『』【】[\]()(){}]/g, " ")
.replace(/[_/|::,,.。+]+/g, "-")
.replace(/\s+/g, " ");
}
function tokenizeModelNameForSimilarity(value: unknown) {
const normalized = normalizeModelNameForSimilarity(value);
const compact = normalized.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, "");
const tokens = new Set(
normalized
.split(/[^a-z0-9\u4e00-\u9fa5]+/g)
.map((item) => item.trim())
.filter((item) => item.length >= 2)
);
if (compact.length >= 2) {
tokens.add(compact);
for (let index = 0; index < compact.length - 1; index += 1) {
tokens.add(compact.slice(index, index + 2));
}
}
return tokens;
}
function scoreModelNameSimilarity(
source: { model?: any } | null | undefined,
candidate: { model?: any } | null | undefined
) {
const sourceNames = [
source?.model?.displayName,
source?.model?.modelId,
].filter(Boolean);
const candidateNames = [
candidate?.model?.displayName,
candidate?.model?.modelId,
].filter(Boolean);
let bestScore = 0;
for (const sourceName of sourceNames) {
const sourceTokens = tokenizeModelNameForSimilarity(sourceName);
if (sourceTokens.size === 0) continue;
for (const candidateName of candidateNames) {
const candidateTokens = tokenizeModelNameForSimilarity(candidateName);
if (candidateTokens.size === 0) continue;
let overlap = 0;
sourceTokens.forEach((token) => {
if (candidateTokens.has(token)) overlap += 1;
});
const score = overlap / Math.max(sourceTokens.size, candidateTokens.size);
bestScore = Math.max(bestScore, score);
}
}
return bestScore;
}
function resolveCurrentImageModelEntry(
allModels: Array<{ model: any; provider: any }>,
refs: string[]
) {
const normalizedRefs = new Set(refs.map((item) => String(item || "").trim()).filter(Boolean));
if (normalizedRefs.size === 0) return null;
return (
allModels.find((item) =>
getModelRefKeys(item).some((key) => normalizedRefs.has(key))
) || null
);
}
function messagePartHasImageInput(part: any) {
if (!part || typeof part !== "object") return false;
if (part.type === "file" || part.type === "image" || part.type === "image_url") {
return true;
}
if (typeof part.url === "string" && /^https?:\/\//i.test(part.url)) {
return true;
}
if (typeof part.image === "string" && part.image.trim()) {
return true;
}
if (typeof part.image_url?.url === "string" && part.image_url.url.trim()) {
return true;
}
return false;
}
function countImageInputPartsInMessage(message: any) {
const countParts = (parts: any[] | undefined) =>
Array.isArray(parts) ? parts.filter(messagePartHasImageInput).length : 0;
const contentCount = countParts(Array.isArray(message?.content) ? message.content : undefined);
if (contentCount > 0) return contentCount;
return countParts(Array.isArray(message?.parts) ? message.parts : undefined);
}
export function countChatMessageImageInputs(messages: ChatMessage[]) {
return messages.reduce(
(count, message) => count + countImageInputPartsInMessage(message),
0
);
}
export function chatMessagesHaveImageInput(messages: ChatMessage[]) {
return countChatMessageImageInputs(messages) > 0;
}
export function getImageFailoverConvertProvider(providerType?: string | null) {
switch (providerType) {
case "bfl-image":
case "ruxa-image":
case "fal-image":
case "snapgen-image":
case "openai-image":
case "bananaimage":
case "visionary":
case "atlascloud":
case "vip98k":
case "stepfun-image":
case "midjourney":
case "runway":
case "krea":
case "gemini":
case "huanke-gemini":
return providerType;
case "grsai-gpt-image":
return "gemini";
case "volcano-vision":
return "volcano";
default:
return "alibaba-image";
}
}
export function buildImageFallbackCandidates(
allModels: Array<{ model: any; provider: any }>,
options: {
currentProviderId?: string;
currentModelId?: string;
currentModelKey?: string;
configuredFallbackIds?: string[];
}
) {
const { currentProviderId, currentModelId, currentModelKey, configuredFallbackIds = [] } = options;
const currentEntry = resolveCurrentImageModelEntry(allModels, [
currentModelId || "",
currentModelKey || "",
]);
const configuredRefs = configuredFallbackIds
.map((item) => String(item || "").trim())
.filter(Boolean);
const byModelRef = new Map<string, { model: any; provider: any }>();
const isEligible = (item: { model: any; provider: any }, sameProvider: boolean) => {
if (
!item?.model?.supportsImageGen ||
!item?.model?.isEnabled ||
item?.model?.isSubModel ||
isImageFailoverExcludedModel(item?.model) ||
item?.model?.modelId === currentModelKey ||
item?.model?.id === currentModelId
) {
return false;
}
const providerType = String(item?.provider?.type || "");
if (!sameProvider && !IMAGE_FAILOVER_PROVIDER_ALLOWLIST.has(providerType)) {
return false;
}
if (isImageFallbackModelCoolingDown(item.model.id)) {
const cooldown = imageFailoverModelCooldown.get(item.model.id);
chatApiLogger.info(`[Chat API] ⏭ Skip configured image fallback on cooldown: ${item.model.displayName} (${providerType}) - ${cooldown?.reason || "cooldown"}`);
return false;
}
return true;
};
allModels.forEach((item) => {
if (!item?.model) return;
byModelRef.set(String(item.model.id), item);
if (item.model.modelId) {
byModelRef.set(String(item.model.modelId), item);
}
});
const candidates: ImageFallbackCandidate[] = [];
const seenModelIds = new Set<string>();
const candidateOrder = new Map<string, number>();
const pushCandidate = (item: { model: any; provider: any } | undefined, configured: boolean) => {
if (!item?.model) return;
const sameProvider = Boolean(currentProviderId && item.provider?.id === currentProviderId);
if (!isEligible(item, sameProvider)) return;
const modelId = String(item.model.id || "");
if (!modelId || seenModelIds.has(modelId)) return;
seenModelIds.add(modelId);
candidateOrder.set(modelId, candidateOrder.size);
const nameSimilarity = scoreModelNameSimilarity(currentEntry, item);
candidates.push({
model: item.model,
provider: item.provider,
configured,
sameProvider,
nameSimilarity,
sameName: nameSimilarity >= IMAGE_FAILOVER_SAME_NAME_THRESHOLD,
});
};
configuredRefs.forEach((modelRef) => {
pushCandidate(byModelRef.get(modelRef), true);
});
if (currentProviderId) {
allModels
.filter((item) => item.provider?.id === currentProviderId)
.forEach((item) => pushCandidate(item, false));
}
if (currentEntry) {
allModels
.filter((item) => scoreModelNameSimilarity(currentEntry, item) >= IMAGE_FAILOVER_SAME_NAME_THRESHOLD)
.forEach((item) => pushCandidate(item, false));
}
return candidates.sort((left, right) => {
if (left.sameName !== right.sameName) return left.sameName ? -1 : 1;
if (left.configured !== right.configured) return left.configured ? -1 : 1;
if (left.sameProvider !== right.sameProvider) return left.sameProvider ? -1 : 1;
if (right.nameSimilarity !== left.nameSimilarity) {
return right.nameSimilarity - left.nameSimilarity;
}
return (
(candidateOrder.get(String(left.model.id || "")) ?? 0) -
(candidateOrder.get(String(right.model.id || "")) ?? 0)
);
});
}
export async function generateImageWithFailoverCandidate(params: {
candidate: ImageFallbackCandidate;
messages: ChatMessage[];
imageParams: any;
fallbackImageParams: any;
}) {
const { candidate, messages, imageParams, fallbackImageParams } = params;
const providerType = String(candidate.provider?.type || "");
const candidateRuntimeModelId = resolveImageModelRuntimeId(candidate.model);
if (
chatMessagesHaveImageInput(messages) &&
modelLacksReferenceImageInputSupport(candidate.model, candidate.provider)
) {
throw new Error(
`备用模型不支持参考图/图生图输入:${candidate.model.displayName || candidate.model.id}`
);
}
if (
providerType === "fal-image" &&
isFalGptImage2EditModelId(candidateRuntimeModelId)
) {
throw new Error("FAL GPT Image 2 Edit 仅支持异步队列调用,已跳过同步故障迁移");
}
const candidateLanguageModel = await getLanguageModelById(
candidate.model.id,
candidateRuntimeModelId
);
if (!candidateLanguageModel || typeof (candidateLanguageModel as any).doGenerate !== "function") {
throw new Error(`备用模型不支持当前生图故障迁移调用:${candidate.model.displayName || candidate.model.id}`);
}
const candidateMessages = await convertMessagesWithImages(
messages,
getImageFailoverConvertProvider(providerType)
);
const effectiveImageParams = imageParams || fallbackImageParams;
const imageInputCount = countChatMessageImageInputs(messages);
chatApiLogger.info("[Chat API] Image failover candidate params:", {
model: candidate.model?.modelId || candidate.model?.id,
providerType,
aspectRatio: effectiveImageParams?.aspectRatio,
imageSize: effectiveImageParams?.imageSize,
imageCount: effectiveImageParams?.imageCount,
hasImageInput: imageInputCount > 0,
imageInputCount,
multiImageInput: imageInputCount > 1,
});
const candidateResult = await (candidateLanguageModel as any).doGenerate({
messages: candidateMessages,
imageParams: effectiveImageParams,
imageGenConfig: candidate.model.imageGenConfig || null,
modelDisplayName: candidate.model.displayName,
overrideModelId: candidateRuntimeModelId || candidate.model.modelId,
});
if (candidateResult?.finishReason === "error") {
throw new Error(candidateResult.text || "图片生成失败");
}
return {
text: candidateResult.text || "",
usedModelId: String(candidate.model.modelId || candidate.model.id || ""),
displayName: String(candidate.model.displayName || candidate.model.modelId || candidate.model.id || ""),
};
}
export function getImageCrossProviderCandidates(
allModels: any[],
options: {
currentModelId: string;
currentModelKey?: string;
currentProviderId?: string;
providerPriority: Record<string, number>;
}
) {
const currentEntry = resolveCurrentImageModelEntry(allModels, [
options.currentModelId,
options.currentModelKey || "",
]);
return allModels
.filter((m) => {
if (
!m?.model?.supportsImageGen ||
!m?.model?.isEnabled ||
m?.model?.isSubModel ||
isImageFailoverExcludedModel(m?.model)
) {
return false;
}
if (!IMAGE_FAILOVER_PROVIDER_ALLOWLIST.has(String(m?.provider?.type || ""))) {
return false;
}
if (m.model.id === options.currentModelId) return false;
if (options.currentProviderId && m.provider.id === options.currentProviderId) return false;
if (isImageFallbackModelCoolingDown(m.model.id)) {
const cooldown = imageFailoverModelCooldown.get(m.model.id);
chatApiLogger.info(`[Chat API] ⏭ Skip image failover model on cooldown: ${m.model.displayName} (${m.provider.type}) - ${cooldown?.reason || "cooldown"}`);
return false;
}
return true;
})
.map((item, index) => {
const nameSimilarity = scoreModelNameSimilarity(currentEntry, item);
return {
...item,
__imageFailoverIndex: index,
__imageFailoverNameSimilarity: nameSimilarity,
__imageFailoverSameName:
nameSimilarity >= IMAGE_FAILOVER_SAME_NAME_THRESHOLD,
};
})
.sort((a, b) => {
if (a.__imageFailoverSameName !== b.__imageFailoverSameName) {
return a.__imageFailoverSameName ? -1 : 1;
}
if (b.__imageFailoverNameSimilarity !== a.__imageFailoverNameSimilarity) {
return b.__imageFailoverNameSimilarity - a.__imageFailoverNameSimilarity;
}
const left = options.providerPriority[a.provider.type as string] ?? 99;
const right = options.providerPriority[b.provider.type as string] ?? 99;
if (left !== right) return left - right;
const sortOrderDiff = (a.model.sortOrder ?? 0) - (b.model.sortOrder ?? 0);
if (sortOrderDiff !== 0) return sortOrderDiff;
return (a.__imageFailoverIndex ?? 0) - (b.__imageFailoverIndex ?? 0);
})
.map((item) => {
const {
__imageFailoverIndex,
__imageFailoverNameSimilarity,
__imageFailoverSameName,
...candidate
} = item;
return candidate;
});
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,173 @@
import { createLogger } from "@/lib/observability/logger";
import {
analyzeOptimizedPromptMerge,
buildOptimizedPromptPreview,
} from "@/lib/ai/runtime/optimized-prompt";
import type { ChatMessage } from "@/lib/types";
const chatApiLogger = createLogger("chat.messagePreparation");
/**
* 将图片 URL 转换为 base64 格式
* 用于将 S3 URL 的图片转换为 base64 发送给 Gemini API
*/
export async function convertImageUrlToBase64(url: string): Promise<string> {
try {
chatApiLogger.info("[Image Convert] Fetching image from:", url);
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch image: ${response.statusText}`);
}
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const base64 = buffer.toString("base64");
// 获取 MIME 类型
const contentType = response.headers.get("content-type") || "image/jpeg";
const dataUrl = `data:${contentType};base64,${base64}`;
chatApiLogger.info("[Image Convert] Converted to base64, size:", base64.length);
return dataUrl;
} catch (error) {
chatApiLogger.error("[Image Convert] Failed:", error);
throw error;
}
}
/**
* 检测消息中是否包含用户标注过的图片(文件名以 annotated- 开头)
*/
export function hasAnnotatedImage(messages: ChatMessage[]): boolean {
for (const message of messages) {
for (const part of message.parts) {
if (part.type === "file" && "name" in part && typeof part.name === "string") {
if (part.name.startsWith("annotated-")) {
return true;
}
}
}
}
return false;
}
/**
* 标注图片的系统提示词(用户不可见,后台自动添加)
*/
export const ANNOTATION_SYSTEM_PROMPT =
"【重要】用户提供的图片上有标注线条(如箭头、圆圈、画笔痕迹或文字说明),这些标注是用户用来指示需要修改的区域或方向。请仔细理解标注的含义,按照标注指示进行编辑,但在生成的新图片中不要保留这些标注线条,输出干净的编辑结果。";
export function applyHiddenPromptToMessages(
messages: ChatMessage[],
hiddenPrompt?: string,
): ChatMessage[] {
if (!hiddenPrompt || !hiddenPrompt.trim()) return messages;
const cloned = messages.map((msg) => ({
...msg,
parts: msg.parts.map((part) => ({ ...part })),
}));
for (let i = cloned.length - 1; i >= 0; i--) {
const msg = cloned[i];
if (msg.role !== "user") continue;
const textPart = msg.parts.find((part) => part.type === "text") as
| { type: "text"; text: string }
| undefined;
if (textPart) {
textPart.text = `${hiddenPrompt}\n\n${textPart.text}`.trim();
} else {
msg.parts.unshift({ type: "text", text: hiddenPrompt });
}
break;
}
return cloned;
}
export function applyOptimizedPromptToLatestUserMessage(
messages: ChatMessage[],
optimizedPrompt?: string,
hiddenPromptPrefix = "",
): ChatMessage[] {
const prompt = optimizedPrompt?.trim();
if (!prompt) return messages;
const cloned = messages.map((msg) => ({
...msg,
parts: msg.parts.map((part) => ({ ...part })),
}));
for (let i = cloned.length - 1; i >= 0; i -= 1) {
const msg = cloned[i];
if (msg.role !== "user") continue;
const textPart = msg.parts.find(
(part): part is { type: "text"; text: string } => part.type === "text",
);
const originalText = textPart?.text?.trim() || "";
const { normalizedOriginal, hasOriginalDemand, hasOriginalInOptimized } =
analyzeOptimizedPromptMerge(originalText, prompt, hiddenPromptPrefix);
const finalPrompt =
hasOriginalDemand && !hasOriginalInOptimized
? [
hiddenPromptPrefix.trim(),
`【核心目标(必须优先满足)】${normalizedOriginal}`,
`【执行优化细节】${prompt}`,
]
.filter(Boolean)
.join("\n\n")
.trim()
: `${hiddenPromptPrefix}${prompt}`.trim();
chatApiLogger.info("[Prompt Merge] Final image prompt prepared:", {
original: normalizedOriginal.substring(0, 120),
optimized: prompt.substring(0, 120),
merged: finalPrompt.substring(0, 180),
forcePreserveOriginal: hasOriginalDemand && !hasOriginalInOptimized,
});
if (textPart) {
textPart.text = finalPrompt;
} else {
msg.parts.unshift({ type: "text", text: finalPrompt });
}
break;
}
return cloned;
}
export function injectOptimizedPromptSection(params: {
message?: string;
originalText: string;
optimizedPrompt?: string;
hiddenPromptPrefix?: string;
}): string | undefined {
const { message, originalText, optimizedPrompt, hiddenPromptPrefix } = params;
if (!message?.trim()) return message;
const preview = buildOptimizedPromptPreview({
originalText,
optimizedPrompt,
hiddenPromptPrefix,
});
if (!preview) return message;
const optimizedPromptSection = `【优化后提示词】\n${preview}`;
const optimizedSectionRegex =
/(?:###\s*优化后提示词|【\s*优化后提示词\s*】)[\s\S]*?(?=\n(?:###\s*|【\s*[^】]+\s*】)|$)/;
if (optimizedSectionRegex.test(message)) {
return message.replace(optimizedSectionRegex, optimizedPromptSection);
}
const executionSectionRegex =
/(?:###\s*执行计划|【\s*执行计划\s*】)[\s\S]*?(?=\n(?:###\s*|【\s*[^】]+\s*】)|$)/;
if (executionSectionRegex.test(message)) {
return message.replace(executionSectionRegex, optimizedPromptSection);
}
const outputSectionRegex = /(?:###\s*输出设置|【\s*输出设置\s*】)/;
if (outputSectionRegex.test(message)) {
return message.replace(outputSectionRegex, `${optimizedPromptSection}\n$&`);
}
return `${message.trim()}\n\n${optimizedPromptSection}`;
}

View File

@ -0,0 +1,43 @@
export function resolveModelDisplayNameByModelId(
models: Array<{ model: any; provider?: any }> | undefined,
modelId: string | undefined,
): string {
if (!modelId || !models?.length) return "";
const match = models.find(
(item) => item.model.modelId === modelId || item.model.id === modelId,
);
return String(match?.model?.displayName || "");
}
export function resolveModelEntryByRuntimeModelId(
models: Array<{ model: any; provider?: any }> | undefined,
modelId: string | undefined,
fallbackRef?: string,
) {
const candidates = [modelId, fallbackRef]
.map((item) => String(item || "").trim())
.filter(Boolean);
if (!candidates.length || !models?.length) return null;
return (
models.find((item) =>
candidates.some(
(candidate) => item.model.modelId === candidate || item.model.id === candidate,
),
) || null
);
}
export function resolveModelRefByRuntimeModelId(
models: Array<{ model: any; provider?: any }> | undefined,
modelId: string | undefined,
fallbackRef?: string,
): string {
const normalizedModelId = String(modelId || "").trim();
if (!normalizedModelId) return fallbackRef || "";
const match = models?.find(
(item) => item.model.modelId === normalizedModelId || item.model.id === normalizedModelId,
);
return String(match?.model?.id || fallbackRef || normalizedModelId || "");
}

View File

@ -0,0 +1,24 @@
export function normalizeAssistantPartsForPersistence(
parts: Array<Record<string, any>> | undefined
) {
const safeParts = Array.isArray(parts) ? parts.filter(Boolean) : [];
return safeParts.filter((part) => {
const partType = String(part?.type || "").trim();
if (!partType) return false;
if (
partType === "data-keepalive" ||
partType === "step-start" ||
partType === "step-finish"
) {
return false;
}
return true;
});
}
export function bufferToArrayBuffer(buffer: Buffer): ArrayBuffer {
return buffer.buffer.slice(
buffer.byteOffset,
buffer.byteOffset + buffer.byteLength
) as ArrayBuffer;
}

View File

@ -0,0 +1,612 @@
import { createLogger } from "@/lib/observability/logger";
import { JsonToSseTransformStream } from "ai";
import { extractImageUrlsFromText } from "@/lib/image-url";
import type {
ResolvedChatMediaInput,
ResolvedImageRef,
ResolvedTargetAssets,
} from "@/lib/ai/context-memory/types";
import type { CanvasViewOperationPlan } from "@/types/canvas/agent-protocol";
import type { CanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command";
import { generateUUID } from "@/lib/utils";
const chatApiLogger = createLogger("chat.stream");
export type StreamWriter = {
write: (chunk: any) => void;
};
export function safeWriteStreamChunk(
writer: StreamWriter,
chunk: any,
label: string
): boolean {
try {
writer.write(chunk);
return true;
} catch (error) {
chatApiLogger.warn(`[Chat API] Stream write skipped (${label})`, error);
return false;
}
}
export function writeAssistantTextChunk(
writer: StreamWriter,
text: string,
label: string,
messageId = generateUUID()
) {
safeWriteStreamChunk(
writer,
{
type: "data-appendMessage",
data: JSON.stringify({
id: messageId,
role: "assistant",
parts: [{ type: "text", text }],
}),
},
label
);
return messageId;
}
export function writeStructuredDataChunk(
writer: StreamWriter,
type: string,
data: unknown,
label: string
) {
safeWriteStreamChunk(
writer,
{
type,
data: JSON.stringify(data),
},
label
);
}
function normalizeStreamIdList(value: unknown) {
return Array.isArray(value)
? value.map((id) => String(id || "").trim()).filter(Boolean)
: [];
}
function normalizeStreamMediaUrls(value: unknown) {
return Array.isArray(value)
? value.map((url) => String(url || "").trim())
: [];
}
function isValidStreamMediaUrl(url: string) {
return /^https?:\/\//i.test(url);
}
export function normalizeCanvasMediaStreamPayload(params: {
imageUrls?: unknown;
videoUrls?: unknown;
mediaItemIds?: unknown;
handledCanvasMediaItemIds?: unknown;
}) {
const rawImageUrls = normalizeStreamMediaUrls(params.imageUrls);
const rawVideoUrls = normalizeStreamMediaUrls(params.videoUrls);
const rawEntries = [
...rawImageUrls.map((url, index) => ({
type: "image" as const,
url,
originalIndex: index,
})),
...rawVideoUrls.map((url, index) => ({
type: "video" as const,
url,
originalIndex: rawImageUrls.length + index,
})),
];
const rawMediaItemIds = normalizeStreamIdList(params.mediaItemIds);
const validEntries = rawEntries.filter((entry) =>
isValidStreamMediaUrl(entry.url)
);
const idByValidIndex = new Map<number, string>();
if (rawMediaItemIds.length === rawEntries.length) {
validEntries.forEach((entry, validIndex) => {
const id = rawMediaItemIds[entry.originalIndex];
if (id) idByValidIndex.set(validIndex, id);
});
} else if (rawMediaItemIds.length === validEntries.length) {
rawMediaItemIds.forEach((id, index) => {
if (id) idByValidIndex.set(index, id);
});
}
const dedupedEntries: Array<{
type: "image" | "video";
url: string;
mediaItemId?: string;
}> = [];
const seenMediaKeys = new Set<string>();
validEntries.forEach((entry, validIndex) => {
const key = `${entry.type}:${entry.url}`;
if (seenMediaKeys.has(key)) return;
seenMediaKeys.add(key);
dedupedEntries.push({
type: entry.type,
url: entry.url,
mediaItemId: idByValidIndex.get(validIndex),
});
});
if (
dedupedEntries.length > 0 &&
dedupedEntries.some((entry) => !entry.mediaItemId) &&
rawMediaItemIds.length === dedupedEntries.length
) {
dedupedEntries.forEach((entry, index) => {
entry.mediaItemId = rawMediaItemIds[index];
});
}
const mediaItemIds = dedupedEntries.every((entry) => entry.mediaItemId)
? dedupedEntries.map((entry) => entry.mediaItemId!)
: [];
return {
imageUrls: dedupedEntries
.filter((entry) => entry.type === "image")
.map((entry) => entry.url),
videoUrls: dedupedEntries
.filter((entry) => entry.type === "video")
.map((entry) => entry.url),
mediaItemIds,
handledCanvasMediaItemIds: normalizeStreamIdList(
params.handledCanvasMediaItemIds
),
};
}
export function writeProtocolDebugChunk(
writer: StreamWriter,
enabled: boolean,
data: unknown,
label: string
) {
if (!enabled) return;
writeStructuredDataChunk(writer, "data-protocolDebug", data, label);
}
function normalizeImageLoadingProviderType(providerType?: string | null): string {
const normalized = String(providerType || "").trim().toLowerCase();
if (normalized === "gemini-image" || normalized === "huanke-gemini") {
return "gemini";
}
if (
normalized === "alibaba-image" ||
normalized === "bfl-image" ||
normalized === "ruxa-image" ||
normalized === "fal-image" ||
normalized === "snapgen-image" ||
normalized === "openai-image" ||
normalized === "bananaimage" ||
normalized === "visionary" ||
normalized === "atlascloud" ||
normalized === "vip98k" ||
normalized === "stepfun-image" ||
normalized === "midjourney" ||
normalized === "runway" ||
normalized === "krea" ||
normalized === "volcano-vision" ||
normalized === "gemini"
) {
return normalized;
}
return "alibaba-image";
}
export function writeImageGenerationStatusChunk(
writer: StreamWriter,
params: {
phase: "queued" | "processing" | "finalizing" | "failed" | "completed";
providerType?: string | null;
modelName?: string | null;
imageCount?: number | null;
queuePosition?: number | null;
sameModelRunningCount?: number | null;
concurrencyLimit?: number | null;
estimatedWaitMs?: number | null;
},
label: string
) {
writeStructuredDataChunk(
writer,
"data-imageGenerationStatus",
{
phase: params.phase,
providerType: normalizeImageLoadingProviderType(params.providerType),
modelName: String(params.modelName || "").trim() || undefined,
imageCount:
Number.isFinite(Number(params.imageCount)) && Number(params.imageCount) > 0
? Number(params.imageCount)
: undefined,
queuePosition:
Number.isFinite(Number(params.queuePosition)) &&
Number(params.queuePosition) >= 0
? Number(params.queuePosition)
: undefined,
sameModelRunningCount:
Number.isFinite(Number(params.sameModelRunningCount)) &&
Number(params.sameModelRunningCount) >= 0
? Number(params.sameModelRunningCount)
: undefined,
concurrencyLimit:
Number.isFinite(Number(params.concurrencyLimit)) &&
Number(params.concurrencyLimit) > 0
? Number(params.concurrencyLimit)
: undefined,
estimatedWaitMs:
Number.isFinite(Number(params.estimatedWaitMs)) &&
Number(params.estimatedWaitMs) >= 0
? Number(params.estimatedWaitMs)
: undefined,
},
label
);
}
export type AgentProgressStreamPayload = {
stage: "planning" | "clarify" | "executing" | "completed" | "failed";
currentStep:
| "lock_target"
| "analyze"
| "select_tool"
| "execute"
| "finalize";
title?: string;
message?: string;
toolName?: string;
capability?: string;
};
export function writeAgentProgressChunk(
writer: StreamWriter,
payload: AgentProgressStreamPayload,
label: string
) {
writeStructuredDataChunk(
writer,
"data-agentProgress",
{
stage: payload.stage,
currentStep: payload.currentStep,
title: String(payload.title || "").trim() || undefined,
message: String(payload.message || "").trim() || undefined,
toolName: String(payload.toolName || "").trim() || undefined,
capability: String(payload.capability || "").trim() || undefined,
},
label
);
}
export function writeCanvasMediaChunk(
writer: StreamWriter,
params: {
messageId: string;
text?: string;
imageUrls?: string[];
videoUrls?: string[];
prompt?: string | null;
sourceImageUrls?: string[] | null;
agentPresetId?: string | null;
agentPresetName?: string | null;
agentCategoryId?: string | null;
agentCategoryName?: string | null;
agentExecutionSnapshotId?: string | null;
mediaItemIds?: string[] | null;
handledCanvasMediaItemIds?: string[] | null;
},
label: string
) {
const inputImageUrls = Array.isArray(params.imageUrls)
? params.imageUrls
: extractImageUrlsFromText(String(params.text || ""));
const normalizedPayload = normalizeCanvasMediaStreamPayload({
imageUrls: inputImageUrls,
videoUrls: params.videoUrls,
mediaItemIds: params.mediaItemIds,
handledCanvasMediaItemIds: params.handledCanvasMediaItemIds,
});
if (
normalizedPayload.imageUrls.length === 0 &&
normalizedPayload.videoUrls.length === 0
) {
return;
}
writeStructuredDataChunk(
writer,
"data-canvasMedia",
{
messageId: params.messageId,
imageUrls: normalizedPayload.imageUrls,
videoUrls: normalizedPayload.videoUrls,
prompt: String(params.prompt || "").trim() || undefined,
sourceImageUrls: Array.isArray(params.sourceImageUrls)
? params.sourceImageUrls
.map((url) => String(url || "").trim())
.filter((url) => /^https?:\/\//i.test(url))
: [],
agentPresetId: String(params.agentPresetId || "").trim() || undefined,
agentPresetName: String(params.agentPresetName || "").trim() || undefined,
agentCategoryId: String(params.agentCategoryId || "").trim() || undefined,
agentCategoryName: String(params.agentCategoryName || "").trim() || undefined,
agentExecutionSnapshotId:
String(params.agentExecutionSnapshotId || "").trim() || undefined,
mediaItemIds: normalizedPayload.mediaItemIds,
handledCanvasMediaItemIds: normalizedPayload.handledCanvasMediaItemIds,
},
label
);
}
export function writeCanvasOperationChunk(
writer: StreamWriter,
params: {
projectId: string;
operationId: string;
operation: string;
status?: "applied" | "failed" | "shadowed";
createdNodeIds: string[];
createdNodeIdsByMediaItemId?: Record<string, string>;
updatedNodeIds?: string[];
createdEdgeIds?: string[];
canvasRevision?: string;
anchorNodeId?: string | null;
handledCanvasMediaItemIds?: string[];
mediaItemIds?: string[];
errorCode?: string;
errorMessage?: string;
fallbackToCanvasMedia?: boolean;
message?: string;
},
label: string
) {
writeStructuredDataChunk(
writer,
"data-canvasOperation",
{
projectId: params.projectId,
operationId: params.operationId,
operation: params.operation,
status: params.status || "applied",
createdNodeIds: params.createdNodeIds || [],
createdNodeIdsByMediaItemId: params.createdNodeIdsByMediaItemId || {},
updatedNodeIds: params.updatedNodeIds || [],
createdEdgeIds: params.createdEdgeIds || [],
canvasRevision: params.canvasRevision,
anchorNodeId: params.anchorNodeId || undefined,
handledCanvasMediaItemIds:
params.status && params.status !== "applied"
? []
: normalizeStreamIdList(params.handledCanvasMediaItemIds),
mediaItemIds: normalizeStreamIdList(params.mediaItemIds),
errorCode: params.errorCode,
errorMessage: params.errorMessage,
fallbackToCanvasMedia: params.fallbackToCanvasMedia,
message: params.message,
},
label
);
}
export function writeCanvasViewOperationChunk(
writer: StreamWriter,
params: CanvasViewOperationPlan,
label: string
) {
writeStructuredDataChunk(
writer,
"data-canvasViewOperation",
{
version: 1,
viewOperationId: params.viewOperationId,
operation: params.operation,
projectId: params.projectId,
nodeIds: params.nodeIds || [],
anchorNodeId: params.anchorNodeId,
zoom: params.zoom,
padding: params.padding,
durationMs: params.durationMs,
persist: false,
center: params.center,
reason: params.reason,
},
label
);
}
export function writeCanvasCapabilityCommandChunk(
writer: StreamWriter,
command: CanvasCapabilityClientCommand,
label: string
) {
writeStructuredDataChunk(
writer,
"data-canvasCapabilityCommand",
command,
label
);
}
type AgentTargetLockRefPreview = {
url: string;
title?: string;
nodeId?: string;
assetId?: string;
source: ResolvedImageRef["source"];
role: ResolvedImageRef["role"];
isSelected?: boolean;
positionLabel?: string;
};
export type AgentTargetLockStreamPayload = {
stage: "locked" | "clarify" | "unresolved";
resolutionMode: ResolvedTargetAssets["resolutionMode"];
confidence: number;
reason: string;
needsClarification: boolean;
clarificationMessage?: string;
targetCount: number;
candidateCount: number;
targets: AgentTargetLockRefPreview[];
candidates: AgentTargetLockRefPreview[];
};
function sortTargetLockRefsForCanvasOrdering(refs: ResolvedImageRef[]) {
return [...refs].sort((left, right) => {
const leftY = Number(left.y ?? 0);
const rightY = Number(right.y ?? 0);
if (Math.abs(leftY - rightY) > 12) {
return leftY - rightY;
}
return Number(left.x ?? 0) - Number(right.x ?? 0);
});
}
function getTargetLockPositionLabel(
ref: ResolvedImageRef,
refs: ResolvedImageRef[]
): string {
if (refs.length <= 1) return "中间";
const xs = refs.map((item) => Number(item.x ?? 0));
const ys = refs.map((item) => Number(item.y ?? 0));
const minX = Math.min(...xs);
const maxX = Math.max(...xs);
const minY = Math.min(...ys);
const maxY = Math.max(...ys);
const spanX = Math.max(1, maxX - minX);
const spanY = Math.max(1, maxY - minY);
const relX = (Number(ref.x ?? 0) - minX) / spanX;
const relY = (Number(ref.y ?? 0) - minY) / spanY;
const horizontal = relX <= 0.33 ? "左" : relX >= 0.66 ? "右" : "中";
const vertical = relY <= 0.33 ? "上" : relY >= 0.66 ? "下" : "中";
if (horizontal === "中" && vertical === "中") return "中间";
return `${vertical}${horizontal}`;
}
function serializeAgentTargetLockRefs(refs: ResolvedImageRef[]) {
const orderedRefs = sortTargetLockRefsForCanvasOrdering(refs).slice(0, 4);
return orderedRefs.map((ref) => ({
url: ref.url,
title: String(ref.title || "").trim() || undefined,
nodeId: ref.nodeId,
assetId: ref.assetId,
source: ref.source,
role: ref.role,
isSelected: ref.isSelected === true,
positionLabel: getTargetLockPositionLabel(ref, orderedRefs),
})) satisfies AgentTargetLockRefPreview[];
}
export function buildAgentTargetLockPayload(params: {
requestTargetResolution: ResolvedTargetAssets;
requestResolvedMediaInput: ResolvedChatMediaInput;
needsClarification: boolean;
clarificationMessage?: string;
}): AgentTargetLockStreamPayload {
const stage = params.needsClarification
? "clarify"
: params.requestTargetResolution.targetRefs.length > 0
? "locked"
: "unresolved";
return {
stage,
resolutionMode: params.requestTargetResolution.resolutionMode,
confidence: Number(params.requestTargetResolution.confidence || 0),
reason: params.requestTargetResolution.reason,
needsClarification: params.needsClarification,
clarificationMessage: params.clarificationMessage?.trim() || undefined,
targetCount: params.requestTargetResolution.targetRefs.length,
candidateCount: params.requestTargetResolution.candidateRefs.length,
targets: serializeAgentTargetLockRefs(params.requestTargetResolution.targetRefs),
candidates: serializeAgentTargetLockRefs(
params.requestTargetResolution.candidateRefs.length > 0
? params.requestTargetResolution.candidateRefs
: params.requestResolvedMediaInput.imageRefs
),
};
}
export function writeAgentTargetLockChunk(
writer: StreamWriter,
payload: AgentTargetLockStreamPayload,
label: string
) {
writeStructuredDataChunk(writer, "data-agentTargetLock", payload, label);
}
export function createSseResponse(stream: ReadableStream) {
return new Response(stream.pipeThrough(new JsonToSseTransformStream()), {
headers: {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
},
});
}
export function startStreamKeepalive(
writer: StreamWriter,
label: string,
intervalMs = 15_000
) {
const timer = setInterval(() => {
safeWriteStreamChunk(
writer,
{
type: "data-keepalive",
data: JSON.stringify({ ts: Date.now(), label }),
},
`${label}:keepalive`
);
}, intervalMs);
return () => clearInterval(timer);
}
export async function runBoundedParallel<T>(
items: T[],
workerCount: number,
worker: (item: T, index: number) => Promise<void>
) {
const maxWorkers = Math.max(1, Math.min(workerCount, items.length));
let cursor = 0;
let firstError: unknown = null;
const workers = Array.from({ length: maxWorkers }, async () => {
while (cursor < items.length) {
const index = cursor;
cursor += 1;
if (firstError) {
return;
}
try {
await worker(items[index]!, index);
} catch (error) {
firstError = firstError || error;
}
}
});
await Promise.all(workers);
if (firstError) {
throw firstError;
}
}

View File

@ -0,0 +1,279 @@
import { createLogger } from "@/lib/observability/logger";
import { after } from "next/server";
import {
getAiModelById,
getEnabledAiModelsWithProvider,
updateMessageById,
} from "@/lib/db/queries";
import {
isImageGenerationModelDescriptor,
isVideoModelDescriptor,
} from "@/lib/ai/model-classification";
import type { StoryboardModelCandidate } from "@/lib/video-agent/types";
const chatApiLogger = createLogger("chat.videoModels");
export type VideoModeType =
| "text-to-video"
| "first-frame"
| "first-last-frame"
| "reference-images";
export function isVideoProviderType(
providerType?: string | null,
model?: {
modelId?: string | null;
capabilities?: unknown;
supportsImageGen?: boolean | null;
},
): boolean {
return isVideoModelDescriptor({
providerType,
modelId: model?.modelId,
capabilities: model?.capabilities,
supportsImageGen: model?.supportsImageGen,
});
}
export function isImageGenerationModelType(
providerType?: string | null,
model?: {
modelId?: string | null;
capabilities?: unknown;
supportsImageGen?: boolean | null;
},
): boolean {
return isImageGenerationModelDescriptor({
providerType,
modelId: model?.modelId,
capabilities: model?.capabilities,
supportsImageGen: model?.supportsImageGen,
});
}
export function inferSupportedVideoModesByModelId(modelId: string): VideoModeType[] {
const normalized = modelId.toLowerCase();
if (
normalized === "gen4.5" ||
normalized === "gen4_turbo" ||
normalized === "gen4_aleph" ||
normalized === "gen3a_turbo" ||
normalized === "veo3" ||
normalized === "veo3.1" ||
normalized === "veo3.1_fast"
) {
return ["text-to-video", "first-frame"];
}
if (normalized.includes("lite-t2v")) {
return ["text-to-video"];
}
if (normalized.includes("lite-i2v")) {
return ["first-frame", "first-last-frame", "reference-images"];
}
if (normalized.includes("pro-fast")) {
return ["text-to-video", "first-frame"];
}
if (normalized.includes("-pro-") || normalized.endsWith("-pro")) {
return ["text-to-video", "first-frame", "first-last-frame"];
}
return ["text-to-video", "first-frame", "first-last-frame", "reference-images"];
}
export function getSupportedVideoModesForModel(model: any): VideoModeType[] {
const capabilityModes = (((model?.capabilities as any)?.supportedModes ?? []) as string[]).filter(
(mode): mode is VideoModeType =>
mode === "text-to-video" ||
mode === "first-frame" ||
mode === "first-last-frame" ||
mode === "reference-images",
);
if (capabilityModes.length > 0) {
return capabilityModes;
}
const modelId = String(model?.modelId || "");
return inferSupportedVideoModesByModelId(modelId);
}
export function scoreVideoSubModelForMode(modelId: string, mode: VideoModeType): number {
const normalized = modelId.toLowerCase();
const isProFast = normalized.includes("pro-fast");
const isLiteT2V = normalized.includes("lite-t2v");
const isLiteI2V = normalized.includes("lite-i2v");
const isPro = (normalized.includes("-pro-") || normalized.endsWith("-pro")) && !isProFast;
const isV15Pro = normalized.includes("seedance-1-5-pro");
switch (mode) {
case "first-last-frame":
if (isV15Pro) return 150;
if (isPro) return 120;
if (isLiteI2V) return 90;
if (isProFast) return -1000;
if (isLiteT2V) return -1000;
return 10;
case "first-frame":
if (isV15Pro) return 140;
if (isProFast) return 130;
if (isPro) return 120;
if (isLiteI2V) return 100;
if (isLiteT2V) return 20;
return 10;
case "reference-images":
if (isV15Pro) return 150;
if (isLiteI2V) return 140;
if (isPro) return 110;
if (isProFast || isLiteT2V) return -1000;
return 10;
case "text-to-video":
default:
if (isV15Pro) return 140;
if (isLiteT2V) return 130;
if (isProFast) return 120;
if (isPro) return 100;
if (isLiteI2V) return 80;
return 10;
}
}
export function selectBestVideoSubModelForMode(
subModels: Array<{ model: any }>,
mode: VideoModeType,
): { model: any } | null {
const compatible = subModels.filter(({ model }) => {
const supportedModes = getSupportedVideoModesForModel(model);
return supportedModes.includes(mode);
});
const candidates = compatible.length > 0 ? compatible : subModels;
if (candidates.length === 0) return null;
return [...candidates].sort((a, b) => {
const scoreA = scoreVideoSubModelForMode(String(a.model?.modelId || ""), mode);
const scoreB = scoreVideoSubModelForMode(String(b.model?.modelId || ""), mode);
return scoreB - scoreA;
})[0];
}
export async function resolveStoryboardModelCandidates(
selectedModelRef: string,
): Promise<StoryboardModelCandidate[]> {
const selectedModel = await getAiModelById({ id: selectedModelRef }).catch(() => undefined);
if (!selectedModel) return [];
const configuredFallbackIds = [
selectedModel.fallbackModelId1,
selectedModel.fallbackModelId2,
].filter((item): item is string => Boolean(String(item || "").trim()));
const allModels = await getEnabledAiModelsWithProvider(true, true).catch(() => []);
const sameProvider = allModels
.filter(
({ model, provider }: any) =>
provider?.id === selectedModel.providerId && model?.id !== selectedModel.id && model?.isEnabled,
)
.map(({ model }: any) => String(model?.id || "").trim())
.filter(Boolean);
const candidateIds = Array.from(new Set([selectedModel.id, ...configuredFallbackIds, ...sameProvider]));
const byModelRef = new Map<string, any>();
allModels
.map(({ model }: any) => model)
.filter(Boolean)
.forEach((model: any) => {
byModelRef.set(String(model.id), model);
if (model.modelId) {
byModelRef.set(String(model.modelId), model);
}
});
return candidateIds
.map((id) => byModelRef.get(id))
.filter(Boolean)
.map((model: any) => ({
id: String(model.id),
modelId: String(model.modelId || "").trim(),
displayName: String(model.displayName || model.modelId || "").trim(),
}))
.filter((item) => item.modelId);
}
export function extractVideoUrlFromText(text: string): string | null {
const normalizedText = String(text || "");
if (!normalizedText) return null;
const htmlMatch = normalizedText.match(/<video[^>]+src="([^"]+)"/i);
if (htmlMatch?.[1]) return htmlMatch[1];
const markdownMatch = normalizedText.match(
/\[[^\]]*]\((https?:\/\/[^)\s]+\.mp4(?:\?[^)\s]*)?)\)/i,
);
if (markdownMatch?.[1]) return markdownMatch[1];
const plainMatch = normalizedText.match(/(https?:\/\/[^\s"'<>]+\.mp4(?:\?[^\s"'<>]+)?)/i);
return plainMatch?.[1] || null;
}
export function isAlreadyArchivedVideoUrl(url: string): boolean {
return /\/uploads\//i.test(url);
}
export function scheduleVideoArchive(params: {
chatId: string;
messageId: string;
text: string;
temporaryVideoUrl: string;
taskId?: string;
metadata?: Record<string, any> | null;
}) {
const { chatId, messageId, temporaryVideoUrl, taskId, metadata } = params;
if (!temporaryVideoUrl || isAlreadyArchivedVideoUrl(temporaryVideoUrl)) {
return;
}
const delayHoursRaw = Number(process.env.VIDEO_ARCHIVE_DELAY_HOURS || 48);
const delayHours = Number.isFinite(delayHoursRaw)
? Math.max(1, Math.min(24 * 30, Math.round(delayHoursRaw)))
: 48;
const archiveAfterAtMs = Date.now() + delayHours * 60 * 60 * 1000;
after(async () => {
try {
const nextMetadata = {
...(metadata || {}),
temporaryVideoUrl,
storageStatus: "temporary",
archiveMode: "delayed_job",
archiveAfterAt: archiveAfterAtMs,
archiveDelayHours: delayHours,
archiveTaskId: taskId || messageId,
updatedAt: Date.now(),
};
await updateMessageById({
messageId,
chatId,
videoStorageStatus: "temporary",
temporaryVideoUrl,
permanentVideoUrl: null,
archivedAt: null,
videoMetadata: nextMetadata as any,
});
chatApiLogger.info("[Chat API] 🕒 已登记延迟归档视频:", {
messageId,
archiveAfterAt: new Date(archiveAfterAtMs).toISOString(),
});
} catch (error) {
chatApiLogger.error("[Chat API] ❌ 登记视频延迟归档失败(保留临时地址):", {
messageId,
temporaryVideoUrl,
error,
});
}
});
}

View File

@ -0,0 +1,36 @@
import { updateMessageContent } from "@/lib/db/queries";
export async function POST(request: Request) {
try {
const { chatId, messageId, newContent } = await request.json();
console.log("[Replace Video URL] Updating message:", messageId);
// 更新消息内容
await updateMessageContent({
messageId,
chatId,
newContent,
});
console.log("[Replace Video URL] ✅ Message updated successfully");
return new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
console.error("[Replace Video URL] Error:", error);
return new Response(
JSON.stringify({
success: false,
error: error instanceof Error ? error.message : "Unknown error"
}),
{
status: 500,
headers: { "Content-Type": "application/json" },
}
);
}
}

12446
app/(chat)/api/chat/route.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,147 @@
import { z } from "zod";
import type { CanvasAgentContext } from "@/types/canvas/agent-protocol";
const textPartSchema = z.object({
type: z.enum(["text"]),
text: z.string().min(1).max(2000),
});
const filePartSchema = z.object({
type: z.enum(["file"]),
mediaType: z.enum(["image/jpeg", "image/png", "image/webp", "image/gif", "image/jpg", "image/bmp", "image/svg+xml"]),
name: z.string().min(1).max(100),
url: z.string().url(),
assetId: z.string().uuid().optional(),
width: z.number().positive().optional(),
height: z.number().positive().optional(),
});
const partSchema = z.union([textPartSchema, filePartSchema]);
// 视频参数 schema
const videoParamsSchema = z.object({
mode: z.enum(["text-to-video", "first-frame", "first-last-frame", "reference-images"]),
resolution: z.enum(["480p", "720p", "1080p"]).optional(),
ratio: z.string().optional(),
duration: z.number().optional(),
generateAudio: z.boolean().optional(),
watermark: z.boolean().optional(),
cameraFixed: z.boolean().optional(),
cameraMove: z.string().optional(),
fps: z.number().optional(),
seed: z.number().optional(),
returnLastFrame: z.boolean().optional(),
}).optional();
const imageSizeValues = ["0.5K", "1K", "2K", "4K", "8K", "10K", "12K"] as const;
function normalizeImageSizeInput(value: unknown) {
if (typeof value !== "string") return undefined;
const normalized = value.trim().toUpperCase();
return imageSizeValues.includes(normalized as (typeof imageSizeValues)[number])
? normalized
: undefined;
}
// 🆕 图片生成参数 schema
const imageParamsSchema = z.object({
aspectRatio: z.string().optional(),
imageSize: z.preprocess(normalizeImageSizeInput, z.enum(imageSizeValues).optional()),
imageCount: z.number().min(1).max(10).optional(),
quality: z.enum(["auto", "low", "medium", "high"]).optional(),
// 阿里生图专用参数
size: z.string().optional(), // 自定义分辨率,如 "1024*1024"
negativePrompt: z.string().optional(), // 反向提示词
promptExtend: z.boolean().optional(), // 提示词智能优化
watermark: z.boolean().optional(), // 水印
seed: z.number().optional(), // 随机种子
n: z.number().min(1).max(10).optional(), // 兼容 n 参数
}).optional();
const contextPayloadSchema = z
.object({
source: z.enum(["chat", "canvas", "realtime-canvas"]).optional(),
projectId: z.string().uuid().optional(),
selectedNodeIds: z.array(z.string().min(1).max(128)).max(20).optional(),
selectedAssetIds: z.array(z.string().uuid()).max(20).optional(),
focusPolicy: z.enum(["auto", "selected", "latest_result", "original", "slot"]).optional(),
canvasAgentContext: z.custom<CanvasAgentContext>().optional(),
canvasImageNodes: z
.array(
z.object({
nodeId: z.string().min(1).max(128),
assetId: z.string().uuid().optional(),
url: z.string().url(),
title: z.string().max(256).optional(),
prompt: z.string().max(2000).optional(),
x: z.number(),
y: z.number(),
width: z.number().optional(),
height: z.number().optional(),
isSelected: z.boolean().optional(),
}),
)
.max(20)
.optional(),
})
.optional();
const agentModeSourceImageSchema = z.object({
url: z.string().url(),
label: z.string().max(255).optional(),
name: z.string().max(255).optional(),
assetId: z.string().min(1).max(128).nullable().optional(),
});
const agentModeReferenceSchema = z.object({
id: z.string().min(1).max(128),
label: z.string().min(1).max(255),
imageUrl: z.string().url(),
candidateId: z.string().min(1).max(128).nullable().optional(),
sortOrder: z.number().min(0).max(999).optional(),
assignedProductIndex: z.number().min(0).max(9).nullable().optional(),
passthrough: z.boolean().nullable().optional(),
rerunReferenceImages: z.array(agentModeSourceImageSchema).max(6).optional(),
});
const agentModePayloadSchema = z
.object({
modeType: z.enum(["detail_replacement", "sku_replacement"]),
replacementStrategy: z.enum(["scene_replace", "product_only"]).optional(),
query: z.string().max(64).optional(),
queryType: z.enum(["sku", "spu", "productId"]).optional(),
selectedCandidateId: z.string().min(1).max(128).nullable().optional(),
references: z.array(agentModeReferenceSchema).max(40),
sourceImages: z
.object({
productImages: z.array(agentModeSourceImageSchema).max(16).optional(),
modelImage: agentModeSourceImageSchema.nullable().optional(),
})
.optional(),
})
.optional();
export const postRequestBodySchema = z.object({
id: z.string().uuid(),
message: z.object({
id: z.string().uuid(),
role: z.enum(["user"]),
parts: z.array(partSchema),
}),
hiddenPrompt: z.string().max(4000).optional(),
workflowPromptOptimizationEnabled: z.boolean().optional(),
autoContext: z.string().max(4000).optional(),
agentPresetId: z.string().uuid().optional(),
// 支持预定义模型名称或数据库中的模型 UUID
selectedChatModel: z.string().min(1).max(128),
selectedVisibilityType: z.enum(["public", "private"]),
composerMode: z.enum(["agent", "image", "video", "detail"]).optional(),
deepThinkingEnabled: z.boolean().optional(),
debugProtocol: z.boolean().optional(),
videoParams: videoParamsSchema, // 视频参数
imageParams: imageParamsSchema, // 🆕 图片生成参数
contextPayload: contextPayloadSchema,
agentModePayload: agentModePayloadSchema,
});
export type PostRequestBody = z.infer<typeof postRequestBodySchema>;

Some files were not shown because too many files have changed in this diff Show More