Initial commit - clean repository
This commit is contained in:
commit
53ddb7e585
333
.cursor/rules/ultracite.mdc
Normal file
333
.cursor/rules/ultracite.mdc
Normal 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);
|
||||||
|
}
|
||||||
|
```
|
||||||
40
.env.example
Normal file
40
.env.example
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
# Generate a random secret: https://generate-secret.vercel.app/32 or `openssl rand -base64 32`
|
||||||
|
AUTH_SECRET=****
|
||||||
|
|
||||||
|
# The following keys below are automatically created and
|
||||||
|
# added to your environment when you deploy on Vercel
|
||||||
|
|
||||||
|
# Instructions to create an AI Gateway API key here: https://vercel.com/ai-gateway
|
||||||
|
# API key required for non-Vercel deployments
|
||||||
|
# For Vercel deployments, OIDC tokens are used automatically
|
||||||
|
# https://vercel.com/ai-gateway
|
||||||
|
AI_GATEWAY_API_KEY=****
|
||||||
|
|
||||||
|
|
||||||
|
# Instructions to create a Vercel Blob Store here: https://vercel.com/docs/vercel-blob
|
||||||
|
BLOB_READ_WRITE_TOKEN=****
|
||||||
|
|
||||||
|
# Instructions to create a PostgreSQL database here: https://vercel.com/docs/postgres
|
||||||
|
POSTGRES_URL=****
|
||||||
|
|
||||||
|
|
||||||
|
# Instructions to create a Redis store here:
|
||||||
|
# https://vercel.com/docs/redis
|
||||||
|
REDIS_URL=****
|
||||||
|
|
||||||
|
# DashScope / 通义千问配置
|
||||||
|
# 请求地址(阿里云 DashScope OpenAI 兼容接口)
|
||||||
|
DASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
|
||||||
|
|
||||||
|
# API Key(请在本地 .env.local 和 服务器环境中填写真实值,不要把真实 Key 提交到代码仓库)
|
||||||
|
DASHSCOPE_API_KEY=your-dashscope-api-key
|
||||||
|
|
||||||
|
# 第三方 GPT5 提供方配置(OpenAI 兼容接口)
|
||||||
|
# API 基础地址(例如:https://api.huandutech.com)
|
||||||
|
GTP5_API_URL=https://api.huandutech.com
|
||||||
|
|
||||||
|
# API Key(请在本地 .env.local 和 服务器环境中填写真实值,不要把真实 Key 提交到代码仓库)
|
||||||
|
GTP5_API_KEY=your-gtp5-api-key
|
||||||
|
|
||||||
|
# 模型名称(例如:gpt-5-nano)
|
||||||
|
GTP5_MODEL=gpt-5-nano
|
||||||
25
.github/workflows/lint.yml
vendored
Normal file
25
.github/workflows/lint.yml
vendored
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
name: Lint
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
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
|
||||||
|
- name: Run lint
|
||||||
|
run: pnpm lint
|
||||||
73
.github/workflows/playwright.yml
vendored
Normal file
73
.github/workflows/playwright.yml
vendored
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
name: Playwright Tests
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, master]
|
||||||
|
pull_request:
|
||||||
|
branches: [main, master]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
timeout-minutes: 30
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
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: lts/*
|
||||||
|
|
||||||
|
- name: Install pnpm
|
||||||
|
uses: pnpm/action-setup@v2
|
||||||
|
with:
|
||||||
|
version: latest
|
||||||
|
run_install: false
|
||||||
|
|
||||||
|
- name: Get pnpm store directory
|
||||||
|
id: pnpm-cache
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
|
||||||
|
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-pnpm-store-
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: lts/*
|
||||||
|
cache: "pnpm"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Cache Playwright browsers
|
||||||
|
uses: actions/cache@v3
|
||||||
|
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
|
||||||
48
.gitignore
vendored
Normal file
48
.gitignore
vendored
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
node_modules
|
||||||
|
.pnp
|
||||||
|
.pnp.js
|
||||||
|
.pnpm-store
|
||||||
|
|
||||||
|
# testing
|
||||||
|
coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
# turbo
|
||||||
|
.turbo
|
||||||
|
|
||||||
|
.env
|
||||||
|
.vercel
|
||||||
|
.env*.local
|
||||||
|
|
||||||
|
# Playwright
|
||||||
|
/test-results/
|
||||||
|
/playwright-report/
|
||||||
|
/blob-report/
|
||||||
|
/playwright/*
|
||||||
|
deploy.tar.gz
|
||||||
|
libvips-8.14.5-linux-x64.tar.br
|
||||||
|
.temp-packages/
|
||||||
3
.vscode/extensions.json
vendored
Normal file
3
.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["biomejs.biome"]
|
||||||
|
}
|
||||||
35
.vscode/settings.json
vendored
Normal file
35
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"[jsonc]": {
|
||||||
|
"editor.defaultFormatter": "biomejs.biome"
|
||||||
|
},
|
||||||
|
"[css]": {
|
||||||
|
"editor.defaultFormatter": "biomejs.biome"
|
||||||
|
},
|
||||||
|
"[graphql]": {
|
||||||
|
"editor.defaultFormatter": "biomejs.biome"
|
||||||
|
},
|
||||||
|
"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
160
3001端口检查报告.md
Normal 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
262
AI_MODEL_CONFIG_GUIDE.md
Normal 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
421
AI供应商配置指南.md
Normal 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等)
|
||||||
|
|
||||||
142
FRPC修复完成报告.md
Normal file
142
FRPC修复完成报告.md
Normal 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
153
FRPC穿透配置说明.md
Normal 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
170
FRPC访问问题说明.md
Normal 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 服务端配置
|
||||||
|
|
||||||
62
Google Antigravity Auth Success.html
Normal file
62
Google Antigravity Auth Success.html
Normal file
File diff suppressed because one or more lines are too long
13
LICENSE
Normal file
13
LICENSE
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
Copyright 2024 Vercel, 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
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
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.
|
||||||
71
README.md
Normal file
71
README.md
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
<a href="https://chat.vercel.ai/">
|
||||||
|
<img alt="Next.js 14 and App Router-ready AI chatbot." src="app/(chat)/opengraph-image.png">
|
||||||
|
<h1 align="center">Chat SDK</h1>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
Chat SDK is a free, open-source template built with Next.js and the AI SDK that helps you quickly build powerful chatbot applications.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://chat-sdk.dev"><strong>Read Docs</strong></a> ·
|
||||||
|
<a href="#features"><strong>Features</strong></a> ·
|
||||||
|
<a href="#model-providers"><strong>Model Providers</strong></a> ·
|
||||||
|
<a href="#deploy-your-own"><strong>Deploy Your Own</strong></a> ·
|
||||||
|
<a href="#running-locally"><strong>Running locally</strong></a>
|
||||||
|
</p>
|
||||||
|
<br/>
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- [Next.js](https://nextjs.org) App Router
|
||||||
|
- Advanced routing for seamless navigation and performance
|
||||||
|
- React Server Components (RSCs) and Server Actions for server-side rendering and increased performance
|
||||||
|
- [AI SDK](https://ai-sdk.dev/docs/introduction)
|
||||||
|
- Unified API for generating text, structured objects, and tool calls with LLMs
|
||||||
|
- Hooks for building dynamic chat and generative user interfaces
|
||||||
|
- Supports xAI (default), OpenAI, Fireworks, and other model providers
|
||||||
|
- [shadcn/ui](https://ui.shadcn.com)
|
||||||
|
- Styling with [Tailwind CSS](https://tailwindcss.com)
|
||||||
|
- Component primitives from [Radix UI](https://radix-ui.com) for accessibility and flexibility
|
||||||
|
- Data Persistence
|
||||||
|
- [Neon Serverless Postgres](https://vercel.com/marketplace/neon) for saving chat history and user data
|
||||||
|
- [Vercel Blob](https://vercel.com/storage/blob) for efficient file storage
|
||||||
|
- [Auth.js](https://authjs.dev)
|
||||||
|
- Simple and secure authentication
|
||||||
|
|
||||||
|
## Model Providers
|
||||||
|
|
||||||
|
This template uses the [Vercel AI Gateway](https://vercel.com/docs/ai-gateway) to access multiple AI models through a unified interface. The default configuration includes [xAI](https://x.ai) models (`grok-2-vision-1212`, `grok-3-mini`) routed through the gateway.
|
||||||
|
|
||||||
|
### AI Gateway Authentication
|
||||||
|
|
||||||
|
**For Vercel deployments**: Authentication is handled automatically via OIDC tokens.
|
||||||
|
|
||||||
|
**For non-Vercel deployments**: You need to provide an AI Gateway API key by setting the `AI_GATEWAY_API_KEY` environment variable in your `.env.local` file.
|
||||||
|
|
||||||
|
With the [AI SDK](https://ai-sdk.dev/docs/introduction), you can also switch to direct LLM providers like [OpenAI](https://openai.com), [Anthropic](https://anthropic.com), [Cohere](https://cohere.com/), and [many more](https://ai-sdk.dev/providers/ai-sdk-providers) with just a few lines of code.
|
||||||
|
|
||||||
|
## Deploy Your Own
|
||||||
|
|
||||||
|
You can deploy your own version of the Next.js AI Chatbot to Vercel with one click:
|
||||||
|
|
||||||
|
[](https://vercel.com/templates/next.js/nextjs-ai-chatbot)
|
||||||
|
|
||||||
|
## Running locally
|
||||||
|
|
||||||
|
You will need to use the environment variables [defined in `.env.example`](.env.example) to run Next.js AI Chatbot. It's recommended you use [Vercel Environment Variables](https://vercel.com/docs/projects/environment-variables) for this, but a `.env` file is all that is necessary.
|
||||||
|
|
||||||
|
> Note: You should not commit your `.env` file or it will expose secrets that will allow others to control access to your various AI and authentication provider accounts.
|
||||||
|
|
||||||
|
1. Install Vercel CLI: `npm i -g vercel`
|
||||||
|
2. Link local instance with Vercel and GitHub accounts (creates `.vercel` directory): `vercel link`
|
||||||
|
3. Download your environment variables: `vercel env pull`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
pnpm db:migrate # Setup database or apply latest database changes
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Your app template should now be running on [localhost:3000](http://localhost:3000).
|
||||||
34
app/(admin)/layout.tsx
Normal file
34
app/(admin)/layout.tsx
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import { TopNavbar } from "@/components/top-navbar";
|
||||||
|
import { getSiteSettings, getNavLinks } from "@/lib/db/queries";
|
||||||
|
|
||||||
|
export default async function AdminLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const [session, siteSettings, navLinks] = await Promise.all([
|
||||||
|
auth(),
|
||||||
|
getSiteSettings(),
|
||||||
|
getNavLinks(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col bg-background">
|
||||||
|
<TopNavbar
|
||||||
|
siteName={siteSettings.siteName}
|
||||||
|
siteLogo={siteSettings.siteLogo || undefined}
|
||||||
|
siteLogoDark={siteSettings.siteLogoDark || undefined}
|
||||||
|
isAdmin={session?.user?.type === "admin"}
|
||||||
|
customLinks={navLinks.map((link) => ({
|
||||||
|
id: link.id,
|
||||||
|
name: link.name,
|
||||||
|
url: link.url,
|
||||||
|
order: link.order,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 pt-16">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
2293
app/(admin)/settings/admin-settings-client.tsx
Normal file
2293
app/(admin)/settings/admin-settings-client.tsx
Normal file
File diff suppressed because it is too large
Load Diff
17
app/(admin)/settings/page.tsx
Normal file
17
app/(admin)/settings/page.tsx
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
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() {
|
||||||
|
try {
|
||||||
|
await requireAdmin();
|
||||||
|
} catch {
|
||||||
|
redirect("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
return <AdminSettingsClient />;
|
||||||
|
}
|
||||||
|
|
||||||
110
app/(auth)/actions.ts
Normal file
110
app/(auth)/actions.ts
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { createUser, getUser, getAuthSettings } from "@/lib/db/queries";
|
||||||
|
|
||||||
|
import { signIn } from "./auth";
|
||||||
|
|
||||||
|
const authFormSchema = z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
password: z.string().min(6),
|
||||||
|
});
|
||||||
|
|
||||||
|
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"),
|
||||||
|
});
|
||||||
|
|
||||||
|
await signIn("credentials", {
|
||||||
|
email: validatedData.email,
|
||||||
|
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"
|
||||||
|
| "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 = authFormSchema.parse({
|
||||||
|
email: formData.get("email"),
|
||||||
|
password: formData.get("password"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const [user] = await getUser(validatedData.email);
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
return { status: "user_exists" } as RegisterActionState;
|
||||||
|
}
|
||||||
|
await createUser(validatedData.email, validatedData.password);
|
||||||
|
await signIn("credentials", {
|
||||||
|
email: validatedData.email,
|
||||||
|
password: validatedData.password,
|
||||||
|
redirect: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { status: "success" };
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return { status: "invalid_data" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: "failed" };
|
||||||
|
}
|
||||||
|
};
|
||||||
18
app/(auth)/api/auth/[...nextauth]/route.ts
Normal file
18
app/(auth)/api/auth/[...nextauth]/route.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { GET as AuthGET, POST as AuthPOST } from "@/app/(auth)/auth";
|
||||||
|
|
||||||
|
export const GET = async (req: Request) => {
|
||||||
|
console.log("[Auth API] GET request:", req.url);
|
||||||
|
return AuthGET(req);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const POST = async (req: Request) => {
|
||||||
|
console.log("[Auth API] POST request:", req.url);
|
||||||
|
try {
|
||||||
|
const clone = req.clone();
|
||||||
|
const text = await clone.text();
|
||||||
|
console.log("[Auth API] POST body:", text.substring(0, 500));
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[Auth API] Error reading body:", e);
|
||||||
|
}
|
||||||
|
return AuthPOST(req);
|
||||||
|
}
|
||||||
41
app/(auth)/api/auth/guest/route.ts
Normal file
41
app/(auth)/api/auth/guest/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
49
app/(auth)/auth.config.ts
Normal file
49
app/(auth)/auth.config.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import type { NextAuthConfig } from "next-auth";
|
||||||
|
|
||||||
|
// 检测是否为 HTTPS 环境
|
||||||
|
const isSecure = process.env.AUTH_URL?.startsWith("https://") ?? false;
|
||||||
|
const hostName = process.env.AUTH_URL ? new URL(process.env.AUTH_URL).hostname : undefined;
|
||||||
|
const cookieDomain = hostName && hostName !== "localhost" ? hostName : undefined;
|
||||||
|
|
||||||
|
export const authConfig = {
|
||||||
|
trustHost: true,
|
||||||
|
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: [
|
||||||
|
// added later in auth.ts since it requires bcrypt which is only compatible with Node.js
|
||||||
|
// while this file is also used in non-Node.js environments
|
||||||
|
],
|
||||||
|
callbacks: {},
|
||||||
|
} satisfies NextAuthConfig;
|
||||||
165
app/(auth)/auth.ts
Normal file
165
app/(auth)/auth.ts
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
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 { DUMMY_PASSWORD } from "@/lib/constants";
|
||||||
|
import { createGuestUser, getUser } from "@/lib/db/queries";
|
||||||
|
import { authConfig } from "./auth.config";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { loginToken } from "@/lib/db/schema";
|
||||||
|
import { eq, and, gt } from "drizzle-orm";
|
||||||
|
|
||||||
|
export type UserType = "guest" | "regular" | "admin";
|
||||||
|
|
||||||
|
declare module "next-auth" {
|
||||||
|
interface Session extends DefaultSession {
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
type: UserType;
|
||||||
|
name?: string | null;
|
||||||
|
} & DefaultSession["user"];
|
||||||
|
}
|
||||||
|
|
||||||
|
// biome-ignore lint/nursery/useConsistentTypeDefinitions: "Required"
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const {
|
||||||
|
handlers: { GET, POST },
|
||||||
|
auth,
|
||||||
|
signIn,
|
||||||
|
signOut,
|
||||||
|
} = NextAuth({
|
||||||
|
...authConfig,
|
||||||
|
trustHost: true,
|
||||||
|
providers: [
|
||||||
|
Credentials({
|
||||||
|
credentials: {},
|
||||||
|
async authorize(credentials: any) {
|
||||||
|
console.log("[Auth] Authorize called with credentials:", Object.keys(credentials));
|
||||||
|
|
||||||
|
// 1. DingTalk Token 登录逻辑
|
||||||
|
if (credentials.token) {
|
||||||
|
const { token } = credentials;
|
||||||
|
console.log("[Auth] DingTalk logic triggered with token:", token ? token.substring(0, 8) + "..." : "null");
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const [validToken] = await db
|
||||||
|
.select()
|
||||||
|
.from(loginToken)
|
||||||
|
.where(and(eq(loginToken.token, token), gt(loginToken.expiresAt, now)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
console.log("[Auth] Token lookup result:", validToken ? {
|
||||||
|
email: validToken.email,
|
||||||
|
expiresAt: validToken.expiresAt,
|
||||||
|
isExpired: validToken.expiresAt <= now
|
||||||
|
} : "not found");
|
||||||
|
|
||||||
|
if (!validToken) {
|
||||||
|
console.log("[Auth] Token not found or expired, returning null");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = await getUser(validToken.email);
|
||||||
|
console.log("[Auth] User lookup for", validToken.email, ":", users.length, "users found");
|
||||||
|
|
||||||
|
if (users.length === 0) {
|
||||||
|
console.log("[Auth] No user found for email, returning null");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = users[0];
|
||||||
|
console.log("[Auth] User found:", { id: user.id, email: user.email, isActive: user.isActive, role: user.role });
|
||||||
|
|
||||||
|
// 检查用户是否被激活
|
||||||
|
if (!user.isActive) {
|
||||||
|
console.log("[Auth] User is not active, returning null");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.delete(loginToken).where(eq(loginToken.id, validToken.id));
|
||||||
|
console.log("[Auth] Login token deleted");
|
||||||
|
|
||||||
|
const userType: UserType = (user.role as UserType) || "regular";
|
||||||
|
const returnUser = { id: user.id, email: user.email, name: user.name, type: userType };
|
||||||
|
console.log("[Auth] Returning user object:", returnUser);
|
||||||
|
return returnUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 邮箱密码登录逻辑
|
||||||
|
const { email, password } = credentials;
|
||||||
|
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" };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
callbacks: {
|
||||||
|
jwt({ token, user }) {
|
||||||
|
if (user) {
|
||||||
|
token.id = user.id as string;
|
||||||
|
token.type = user.type;
|
||||||
|
token.name = user.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return token;
|
||||||
|
},
|
||||||
|
session({ session, token }) {
|
||||||
|
if (session.user) {
|
||||||
|
session.user.id = token.id;
|
||||||
|
session.user.type = token.type;
|
||||||
|
session.user.name = token.name as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
return session;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
452
app/(auth)/login/page.tsx
Normal file
452
app/(auth)/login/page.tsx
Normal file
@ -0,0 +1,452 @@
|
|||||||
|
"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";
|
||||||
|
|
||||||
|
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 [dingTalkAppKey, setDingTalkAppKey] = useState<string | null>(null);
|
||||||
|
const [isRegistrationEnabled, setIsRegistrationEnabled] = useState(true);
|
||||||
|
const [showDingTalkModal, setShowDingTalkModal] = useState(false);
|
||||||
|
const [dingTalkIframeSrc, setDingTalkIframeSrc] = useState("");
|
||||||
|
const { setTheme, resolvedTheme } = useTheme();
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
const [isHandlingToken, setIsHandlingToken] = useState(false);
|
||||||
|
|
||||||
|
// 系统通知状态
|
||||||
|
const [notification, setNotification] = useState<{ enabled: boolean; title: string; content: string } | null>(null);
|
||||||
|
const [showNotification, setShowNotification] = useState(false);
|
||||||
|
|
||||||
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||||
|
|
||||||
|
// 声明 formAction 在前面
|
||||||
|
const [state, formAction] = useActionState<LoginActionState, FormData>(
|
||||||
|
login,
|
||||||
|
{
|
||||||
|
status: "idle",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 使用 token 完成钉钉登录
|
||||||
|
const completeDingTalkLogin = useCallback(async (token: string) => {
|
||||||
|
console.log("[DingTalk] Completing login with token:", token.substring(0, 10) + "...");
|
||||||
|
setIsHandlingToken(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 获取 CSRF token
|
||||||
|
const csrfRes = await fetch("/api/auth/csrf");
|
||||||
|
const { csrfToken } = await csrfRes.json();
|
||||||
|
console.log("[DingTalk] Got CSRF token");
|
||||||
|
|
||||||
|
// 手动构造登录请求
|
||||||
|
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 被发送和接收
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("[DingTalk] Login response status:", loginRes.status);
|
||||||
|
console.log("[DingTalk] Login response headers:", Object.fromEntries(loginRes.headers.entries()));
|
||||||
|
|
||||||
|
// 检查响应类型
|
||||||
|
const contentType = loginRes.headers.get("content-type");
|
||||||
|
console.log("[DingTalk] Content-Type:", contentType);
|
||||||
|
|
||||||
|
if (loginRes.redirected || loginRes.status === 302) {
|
||||||
|
// 登录成功,被重定向
|
||||||
|
console.log("[DingTalk] Login successful (redirected to:", loginRes.url, ")");
|
||||||
|
|
||||||
|
// 等待一小段时间确保 cookie 被设置
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 300));
|
||||||
|
|
||||||
|
// 验证 session 是否创建成功
|
||||||
|
const sessionRes = await fetch("/api/auth/session");
|
||||||
|
const session = await sessionRes.json();
|
||||||
|
console.log("[DingTalk] Session after login:", session);
|
||||||
|
|
||||||
|
if (session && session.user) {
|
||||||
|
console.log("[DingTalk] Session validated, redirecting to home...");
|
||||||
|
setIsSuccessful(true);
|
||||||
|
window.location.replace("/");
|
||||||
|
} else {
|
||||||
|
console.error("[DingTalk] Session not created after login");
|
||||||
|
setIsHandlingToken(false);
|
||||||
|
toast({ type: "error", description: "登录失败:无法创建会话" });
|
||||||
|
}
|
||||||
|
} else if (contentType?.includes("application/json")) {
|
||||||
|
const result = await loginRes.json();
|
||||||
|
console.log("[DingTalk] Login result:", result);
|
||||||
|
|
||||||
|
if (result.url || loginRes.ok) {
|
||||||
|
// 登录成功
|
||||||
|
console.log("[DingTalk] Login successful");
|
||||||
|
|
||||||
|
// 等待一小段时间确保 cookie 被设置
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 300));
|
||||||
|
|
||||||
|
// 验证 session
|
||||||
|
const sessionRes = await fetch("/api/auth/session");
|
||||||
|
const session = await sessionRes.json();
|
||||||
|
console.log("[DingTalk] Session after login:", session);
|
||||||
|
|
||||||
|
if (session && session.user) {
|
||||||
|
console.log("[DingTalk] Session validated, redirecting...");
|
||||||
|
setIsSuccessful(true);
|
||||||
|
window.location.replace(result.url || "/");
|
||||||
|
} else {
|
||||||
|
console.error("[DingTalk] Session not created");
|
||||||
|
setIsHandlingToken(false);
|
||||||
|
toast({ type: "error", description: "登录失败:无法创建会话" });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 登录失败
|
||||||
|
console.error("[DingTalk] Login failed:", result);
|
||||||
|
setIsHandlingToken(false);
|
||||||
|
toast({ type: "error", description: result.error || "登录失败,请重试" });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 未知响应
|
||||||
|
const responseText = await loginRes.text();
|
||||||
|
console.error("[DingTalk] Unexpected response type. Body:", responseText.substring(0, 200));
|
||||||
|
setIsHandlingToken(false);
|
||||||
|
toast({ type: "error", description: "登录失败:服务器响应异常" });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.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")) {
|
||||||
|
console.error("[ChunkError] 检测到chunk加载失败,3秒后自动刷新页面");
|
||||||
|
// 3秒后自动刷新页面(清除缓存)
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.reload();
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("error", handleChunkError, true);
|
||||||
|
return () => window.removeEventListener("error", handleChunkError, true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 初始化设置
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
|
||||||
|
// 测试标记 - 确认代码已更新
|
||||||
|
console.log("=".repeat(50));
|
||||||
|
console.log("🔔 登录页面代码版本: 2024-12-10 v3.0");
|
||||||
|
console.log("=".repeat(50));
|
||||||
|
|
||||||
|
// 获取网站设置
|
||||||
|
fetch("/api/site-settings")
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => {
|
||||||
|
console.log("[Login] Site settings data:", data);
|
||||||
|
if (data.siteName) setSiteName(data.siteName);
|
||||||
|
if (data.siteLogo) setSiteLogo(data.siteLogo);
|
||||||
|
if (data.siteLogoDark) setSiteLogoDark(data.siteLogoDark);
|
||||||
|
|
||||||
|
// 处理系统通知
|
||||||
|
console.log("[Login] Notification data:", data.notification);
|
||||||
|
if (data.notification && data.notification.enabled) {
|
||||||
|
console.log("[Login] Setting notification and showing dialog");
|
||||||
|
setNotification(data.notification);
|
||||||
|
// 每次访问登录页都显示通知(重要公告)
|
||||||
|
setShowNotification(true);
|
||||||
|
} else {
|
||||||
|
console.log("[Login] Notification not enabled or missing");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.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.dingTalkAppKey) setDingTalkAppKey(data.dingTalkAppKey);
|
||||||
|
if (data.isRegistrationEnabled !== undefined) setIsRegistrationEnabled(data.isRegistrationEnabled);
|
||||||
|
})
|
||||||
|
.catch(console.error);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 检查 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) => {
|
||||||
|
console.log("[DingTalk] Received message:", event.data);
|
||||||
|
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 (state.status === "failed") {
|
||||||
|
setIsHandlingToken(false);
|
||||||
|
toast({
|
||||||
|
type: "error",
|
||||||
|
description: "邮箱或密码不正确",
|
||||||
|
});
|
||||||
|
} else if (state.status === "invalid_data") {
|
||||||
|
setIsHandlingToken(false);
|
||||||
|
toast({
|
||||||
|
type: "error",
|
||||||
|
description: "提交的信息校验失败",
|
||||||
|
});
|
||||||
|
} else if (state.status === "success") {
|
||||||
|
setIsSuccessful(true);
|
||||||
|
window.location.href = "/";
|
||||||
|
}
|
||||||
|
}, [state.status]);
|
||||||
|
|
||||||
|
const handleSubmit = (formData: FormData) => {
|
||||||
|
setEmail(formData.get("email") as string);
|
||||||
|
formAction(formData);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 钉钉登录 - 严格按照钉钉配置文档实现
|
||||||
|
const handleDingTalkLogin = () => {
|
||||||
|
if (!dingTalkAppKey) {
|
||||||
|
toast({ type: "error", description: "钉钉登录未正确配置" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除 URL 中的 token 参数,避免干扰
|
||||||
|
if (typeof window !== 'undefined' && window.location.search.includes('dingtalk_token')) {
|
||||||
|
const newUrl = window.location.pathname;
|
||||||
|
window.history.replaceState({}, '', newUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建钉钉 OAuth2 授权 URL
|
||||||
|
const param = {
|
||||||
|
redirect_uri: encodeURIComponent(window.location.origin + "/dingtalk-callback.html"),
|
||||||
|
client_id: dingTalkAppKey,
|
||||||
|
scope: "openid",
|
||||||
|
response_type: "code",
|
||||||
|
state: "default",
|
||||||
|
prompt: "consent"
|
||||||
|
};
|
||||||
|
|
||||||
|
const redirectUrl = "https://login.dingtalk.com/oauth2/auth?" +
|
||||||
|
Object.keys(param).map(key => key + "=" + param[key as keyof typeof param]).join("&");
|
||||||
|
|
||||||
|
console.log("[DingTalk] Opening OAuth URL:", redirectUrl);
|
||||||
|
|
||||||
|
// 在 iframe 中打开钉钉登录页面
|
||||||
|
setDingTalkIframeSrc(redirectUrl);
|
||||||
|
setShowDingTalkModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 关闭钉钉登录弹窗
|
||||||
|
const closeDingTalkModal = () => {
|
||||||
|
setShowDingTalkModal(false);
|
||||||
|
setDingTalkIframeSrc("");
|
||||||
|
};
|
||||||
|
|
||||||
|
// 决定显示哪个 LOGO
|
||||||
|
const displayLogo = mounted
|
||||||
|
? (resolvedTheme === "dark" && siteLogoDark ? siteLogoDark : siteLogo)
|
||||||
|
: siteLogo;
|
||||||
|
|
||||||
|
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="h-12 w-auto 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} className="pointer-events-auto">
|
||||||
|
<SubmitButton isSuccessful={isSuccessful}>登录</SubmitButton>
|
||||||
|
|
||||||
|
{isDingTalkEnabled && dingTalkAppKey && (
|
||||||
|
<div className="mt-4 w-full">
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
</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/80 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>
|
||||||
|
<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]">
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
104
app/(auth)/register/page.tsx
Normal file
104
app/(auth)/register/page.tsx
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
import { useActionState, useEffect, useState } from "react";
|
||||||
|
import { AuthForm } from "@/components/auth-form";
|
||||||
|
import { SubmitButton } from "@/components/submit-button";
|
||||||
|
import { toast } from "@/components/toast";
|
||||||
|
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);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 获取认证设置并检查是否开放注册
|
||||||
|
fetch("/api/auth-settings")
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (data.isRegistrationEnabled === false) {
|
||||||
|
toast({ type: "error", description: "当前系统未开放注册" });
|
||||||
|
router.push("/login");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(console.error);
|
||||||
|
|
||||||
|
// 获取网站设置
|
||||||
|
fetch("/api/site-settings")
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (data.siteName) setSiteName(data.siteName);
|
||||||
|
if (data.siteLogo) setSiteLogo(data.siteLogo);
|
||||||
|
})
|
||||||
|
.catch(console.error);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const [state, formAction] = useActionState<RegisterActionState, FormData>(
|
||||||
|
register,
|
||||||
|
{
|
||||||
|
status: "idle",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const { update: updateSession } = useSession();
|
||||||
|
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: router and updateSession are stable refs
|
||||||
|
useEffect(() => {
|
||||||
|
if (state.status === "user_exists") {
|
||||||
|
toast({ type: "error", description: "账号已存在" });
|
||||||
|
} else if (state.status === "failed") {
|
||||||
|
toast({ type: "error", description: "创建账号失败" });
|
||||||
|
} else if (state.status === "invalid_data") {
|
||||||
|
toast({
|
||||||
|
type: "error",
|
||||||
|
description: "提交的信息校验失败",
|
||||||
|
});
|
||||||
|
} else if (state.status === "success") {
|
||||||
|
toast({ type: "success", description: "账号创建成功" });
|
||||||
|
|
||||||
|
setIsSuccessful(true);
|
||||||
|
updateSession();
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
}, [state.status]);
|
||||||
|
|
||||||
|
const handleSubmit = (formData: FormData) => {
|
||||||
|
setEmail(formData.get("email") as string);
|
||||||
|
formAction(formData);
|
||||||
|
};
|
||||||
|
|
||||||
|
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}>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
app/(chat)/actions.ts
Normal file
58
app/(chat)/actions.ts
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
"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;
|
||||||
|
}) {
|
||||||
|
// 优先使用数据库配置的模型,回退到预定义模型
|
||||||
|
let model = await getSystemModel("title");
|
||||||
|
if (!model) {
|
||||||
|
model = myProvider.languageModel("title-model");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { text: title } = await generateText({
|
||||||
|
model,
|
||||||
|
system: titlePrompt,
|
||||||
|
prompt: getTextFromMessage(message),
|
||||||
|
});
|
||||||
|
|
||||||
|
return title;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
113
app/(chat)/api/chat/[id]/stream/route.ts
Normal file
113
app/(chat)/api/chat/[id]/stream/route.ts
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
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>({
|
||||||
|
// biome-ignore lint/suspicious/noEmptyBlockStatements: "Needs to exist"
|
||||||
|
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 });
|
||||||
|
}
|
||||||
36
app/(chat)/api/chat/replace-video-url/route.ts
Normal file
36
app/(chat)/api/chat/replace-video-url/route.ts
Normal 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" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
1468
app/(chat)/api/chat/route.ts
Normal file
1468
app/(chat)/api/chat/route.ts
Normal file
File diff suppressed because it is too large
Load Diff
44
app/(chat)/api/chat/schema.ts
Normal file
44
app/(chat)/api/chat/schema.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
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"]),
|
||||||
|
name: z.string().min(1).max(100),
|
||||||
|
url: z.string().url(),
|
||||||
|
});
|
||||||
|
|
||||||
|
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(),
|
||||||
|
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();
|
||||||
|
|
||||||
|
export const postRequestBodySchema = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
message: z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
role: z.enum(["user"]),
|
||||||
|
parts: z.array(partSchema),
|
||||||
|
}),
|
||||||
|
// 支持预定义模型名称或数据库中的模型 UUID
|
||||||
|
selectedChatModel: z.string().min(1).max(128),
|
||||||
|
selectedVisibilityType: z.enum(["public", "private"]),
|
||||||
|
videoParams: videoParamsSchema, // 🆕 视频参数
|
||||||
|
});
|
||||||
|
|
||||||
|
export type PostRequestBody = z.infer<typeof postRequestBodySchema>;
|
||||||
3
app/(chat)/api/detect-object/route.ts
Normal file
3
app/(chat)/api/detect-object/route.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
// 此文件应该在 app/api/detect-object/route.ts
|
||||||
|
// 已迁移到正确位置
|
||||||
|
|
||||||
126
app/(chat)/api/document/route.ts
Normal file
126
app/(chat)/api/document/route.ts
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import type { ArtifactKind } from "@/components/artifact";
|
||||||
|
import {
|
||||||
|
deleteDocumentsByIdAfterTimestamp,
|
||||||
|
getDocumentsById,
|
||||||
|
saveDocument,
|
||||||
|
} from "@/lib/db/queries";
|
||||||
|
import { ChatSDKError } from "@/lib/errors";
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const id = searchParams.get("id");
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return new ChatSDKError(
|
||||||
|
"bad_request:api",
|
||||||
|
"Parameter id is missing"
|
||||||
|
).toResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
return new ChatSDKError("unauthorized:document").toResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
const documents = await getDocumentsById({ id });
|
||||||
|
|
||||||
|
const [document] = documents;
|
||||||
|
|
||||||
|
if (!document) {
|
||||||
|
return new ChatSDKError("not_found:document").toResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.userId !== session.user.id) {
|
||||||
|
return new ChatSDKError("forbidden:document").toResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json(documents, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const id = searchParams.get("id");
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return new ChatSDKError(
|
||||||
|
"bad_request:api",
|
||||||
|
"Parameter id is required."
|
||||||
|
).toResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
return new ChatSDKError("not_found:document").toResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
content,
|
||||||
|
title,
|
||||||
|
kind,
|
||||||
|
}: { content: string; title: string; kind: ArtifactKind } =
|
||||||
|
await request.json();
|
||||||
|
|
||||||
|
const documents = await getDocumentsById({ id });
|
||||||
|
|
||||||
|
if (documents.length > 0) {
|
||||||
|
const [doc] = documents;
|
||||||
|
|
||||||
|
if (doc.userId !== session.user.id) {
|
||||||
|
return new ChatSDKError("forbidden:document").toResponse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const document = await saveDocument({
|
||||||
|
id,
|
||||||
|
content,
|
||||||
|
title,
|
||||||
|
kind,
|
||||||
|
userId: session.user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return Response.json(document, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: Request) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const id = searchParams.get("id");
|
||||||
|
const timestamp = searchParams.get("timestamp");
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return new ChatSDKError(
|
||||||
|
"bad_request:api",
|
||||||
|
"Parameter id is required."
|
||||||
|
).toResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!timestamp) {
|
||||||
|
return new ChatSDKError(
|
||||||
|
"bad_request:api",
|
||||||
|
"Parameter timestamp is required."
|
||||||
|
).toResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
return new ChatSDKError("unauthorized:document").toResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
const documents = await getDocumentsById({ id });
|
||||||
|
|
||||||
|
const [document] = documents;
|
||||||
|
|
||||||
|
if (document.userId !== session.user.id) {
|
||||||
|
return new ChatSDKError("forbidden:document").toResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
const documentsDeleted = await deleteDocumentsByIdAfterTimestamp({
|
||||||
|
id,
|
||||||
|
timestamp: new Date(timestamp),
|
||||||
|
});
|
||||||
|
|
||||||
|
return Response.json(documentsDeleted, { status: 200 });
|
||||||
|
}
|
||||||
93
app/(chat)/api/files/upload/route.ts
Normal file
93
app/(chat)/api/files/upload/route.ts
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import { uploadFile } from "@/lib/storage/service";
|
||||||
|
|
||||||
|
// Use Blob instead of File since File is not available in Node.js environment
|
||||||
|
const FileSchema = z.object({
|
||||||
|
file: z
|
||||||
|
.instanceof(Blob)
|
||||||
|
.refine((file) => file.size <= 8 * 1024 * 1024, {
|
||||||
|
message: "File size should be less than 8MB",
|
||||||
|
})
|
||||||
|
// Update the file type based on the kind of files you want to accept
|
||||||
|
.refine((file) => ["image/jpeg", "image/png", "image/webp"].includes(file.type), {
|
||||||
|
message: "File type should be JPEG, PNG or WebP",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
console.log("[Upload API] Received upload request");
|
||||||
|
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
console.log("[Upload API] Unauthorized - no session");
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.body === null) {
|
||||||
|
console.log("[Upload API] Request body is empty");
|
||||||
|
return new Response("Request body is empty", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const file = formData.get("file") as Blob;
|
||||||
|
|
||||||
|
console.log("[Upload API] FormData received, file exists:", !!file);
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
console.log("[Upload API] No file in FormData");
|
||||||
|
return NextResponse.json({ error: "No file uploaded" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[Upload API] File info:", {
|
||||||
|
type: file.type,
|
||||||
|
size: file.size,
|
||||||
|
sizeInMB: (file.size / 1024 / 1024).toFixed(2) + "MB"
|
||||||
|
});
|
||||||
|
|
||||||
|
const validatedFile = FileSchema.safeParse({ file });
|
||||||
|
|
||||||
|
if (!validatedFile.success) {
|
||||||
|
const errorMessage = validatedFile.error.errors
|
||||||
|
.map((error) => error.message)
|
||||||
|
.join(", ");
|
||||||
|
|
||||||
|
console.log("[Upload API] Validation failed:", errorMessage);
|
||||||
|
return NextResponse.json({ error: errorMessage }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get filename from formData since Blob doesn't have name property
|
||||||
|
const filename = (formData.get("file") as File).name;
|
||||||
|
console.log("[Upload API] Filename:", filename);
|
||||||
|
|
||||||
|
const fileBuffer = await file.arrayBuffer();
|
||||||
|
console.log("[Upload API] File buffer size:", fileBuffer.byteLength);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 使用统一的存储服务(支持数据库配置的 S3、环境变量 S3 或 Vercel Blob)
|
||||||
|
const data = await uploadFile(filename, fileBuffer);
|
||||||
|
|
||||||
|
console.log("[Upload API] Upload successful:", data.url);
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (_error) {
|
||||||
|
console.error("[Upload API] Upload failed:", _error);
|
||||||
|
return NextResponse.json({
|
||||||
|
error: "Upload failed",
|
||||||
|
details: _error instanceof Error ? _error.message : String(_error)
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
console.error("[Upload API] Request processing failed:", _error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: "Failed to process request",
|
||||||
|
details: _error instanceof Error ? _error.message : String(_error)
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
46
app/(chat)/api/history/route.ts
Normal file
46
app/(chat)/api/history/route.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import { deleteAllChatsByUserId, getChatsByUserId } from "@/lib/db/queries";
|
||||||
|
import { ChatSDKError } from "@/lib/errors";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const { searchParams } = request.nextUrl;
|
||||||
|
|
||||||
|
const limit = Number.parseInt(searchParams.get("limit") || "10", 10);
|
||||||
|
const startingAfter = searchParams.get("starting_after");
|
||||||
|
const endingBefore = searchParams.get("ending_before");
|
||||||
|
|
||||||
|
if (startingAfter && endingBefore) {
|
||||||
|
return new ChatSDKError(
|
||||||
|
"bad_request:api",
|
||||||
|
"Only one of starting_after or ending_before can be provided."
|
||||||
|
).toResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
return new ChatSDKError("unauthorized:chat").toResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
const chats = await getChatsByUserId({
|
||||||
|
id: session.user.id,
|
||||||
|
limit,
|
||||||
|
startingAfter,
|
||||||
|
endingBefore,
|
||||||
|
});
|
||||||
|
|
||||||
|
return Response.json(chats);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE() {
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
return new ChatSDKError("unauthorized:chat").toResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await deleteAllChatsByUserId({ userId: session.user.id });
|
||||||
|
|
||||||
|
return Response.json(result, { status: 200 });
|
||||||
|
}
|
||||||
45
app/(chat)/api/models/route.ts
Normal file
45
app/(chat)/api/models/route.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import { getEnabledAiModelsWithProvider, getDefaultAiModel } from "@/lib/db/queries";
|
||||||
|
import { ChatSDKError } from "@/lib/errors";
|
||||||
|
|
||||||
|
// 获取前端可用的 AI 模型列表(需要登录但不需要管理员权限)
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
return new ChatSDKError("unauthorized:chat").toResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只获取主模型(不包括子模型),子模型由后端根据 videoParams.mode 自动选择
|
||||||
|
const modelsWithProvider = await getEnabledAiModelsWithProvider(false);
|
||||||
|
const defaultModel = await getDefaultAiModel();
|
||||||
|
|
||||||
|
// 转换为前端需要的格式
|
||||||
|
const models = modelsWithProvider.map(({ model, provider }) => ({
|
||||||
|
id: model.id,
|
||||||
|
modelId: model.modelId,
|
||||||
|
displayName: model.displayName,
|
||||||
|
description: model.description,
|
||||||
|
supportsVision: model.supportsVision,
|
||||||
|
supportsImageGen: model.supportsImageGen,
|
||||||
|
isDefault: model.isDefault,
|
||||||
|
providerName: provider.name,
|
||||||
|
providerType: provider.type,
|
||||||
|
isSubModel: model.isSubModel,
|
||||||
|
parentModelId: model.parentModelId,
|
||||||
|
capabilities: model.capabilities, // 🆕 返回子模型的能力配置
|
||||||
|
}));
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
models,
|
||||||
|
defaultModelId: defaultModel?.id ?? models[0]?.id ?? null,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ChatSDKError) {
|
||||||
|
return error.toResponse();
|
||||||
|
}
|
||||||
|
return new ChatSDKError("offline:chat").toResponse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
37
app/(chat)/api/suggestions/route.ts
Normal file
37
app/(chat)/api/suggestions/route.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import { getSuggestionsByDocumentId } from "@/lib/db/queries";
|
||||||
|
import { ChatSDKError } from "@/lib/errors";
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const documentId = searchParams.get("documentId");
|
||||||
|
|
||||||
|
if (!documentId) {
|
||||||
|
return new ChatSDKError(
|
||||||
|
"bad_request:api",
|
||||||
|
"Parameter documentId is required."
|
||||||
|
).toResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
return new ChatSDKError("unauthorized:suggestions").toResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
const suggestions = await getSuggestionsByDocumentId({
|
||||||
|
documentId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [suggestion] = suggestions;
|
||||||
|
|
||||||
|
if (!suggestion) {
|
||||||
|
return Response.json([], { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (suggestion.userId !== session.user.id) {
|
||||||
|
return new ChatSDKError("forbidden:api").toResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json(suggestions, { status: 200 });
|
||||||
|
}
|
||||||
75
app/(chat)/api/vote/route.ts
Normal file
75
app/(chat)/api/vote/route.ts
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import { getChatById, getVotesByChatId, voteMessage } from "@/lib/db/queries";
|
||||||
|
import { ChatSDKError } from "@/lib/errors";
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const chatId = searchParams.get("chatId");
|
||||||
|
|
||||||
|
if (!chatId) {
|
||||||
|
return new ChatSDKError(
|
||||||
|
"bad_request:api",
|
||||||
|
"Parameter chatId is required."
|
||||||
|
).toResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
return new ChatSDKError("unauthorized:vote").toResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
const chat = await getChatById({ id: chatId });
|
||||||
|
|
||||||
|
if (!chat) {
|
||||||
|
return new ChatSDKError("not_found:chat").toResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chat.userId !== session.user.id) {
|
||||||
|
return new ChatSDKError("forbidden:vote").toResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
const votes = await getVotesByChatId({ id: chatId });
|
||||||
|
|
||||||
|
return Response.json(votes, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(request: Request) {
|
||||||
|
const {
|
||||||
|
chatId,
|
||||||
|
messageId,
|
||||||
|
type,
|
||||||
|
}: { chatId: string; messageId: string; type: "up" | "down" } =
|
||||||
|
await request.json();
|
||||||
|
|
||||||
|
if (!chatId || !messageId || !type) {
|
||||||
|
return new ChatSDKError(
|
||||||
|
"bad_request:api",
|
||||||
|
"Parameters chatId, messageId, and type are required."
|
||||||
|
).toResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
return new ChatSDKError("unauthorized:vote").toResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
const chat = await getChatById({ id: chatId });
|
||||||
|
|
||||||
|
if (!chat) {
|
||||||
|
return new ChatSDKError("not_found:vote").toResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chat.userId !== session.user.id) {
|
||||||
|
return new ChatSDKError("forbidden:vote").toResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
await voteMessage({
|
||||||
|
chatId,
|
||||||
|
messageId,
|
||||||
|
type,
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response("Message voted", { status: 200 });
|
||||||
|
}
|
||||||
82
app/(chat)/chat/[id]/page.tsx
Normal file
82
app/(chat)/chat/[id]/page.tsx
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { notFound, redirect } from "next/navigation";
|
||||||
|
import { Suspense } from "react";
|
||||||
|
|
||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import { Chat } from "@/components/chat";
|
||||||
|
import { DataStreamHandler } from "@/components/data-stream-handler";
|
||||||
|
import { DEFAULT_CHAT_MODEL } from "@/lib/ai/models";
|
||||||
|
import { getChatById, getMessagesByChatId } from "@/lib/db/queries";
|
||||||
|
import { convertToUIMessages } from "@/lib/utils";
|
||||||
|
|
||||||
|
export default function Page(props: { params: Promise<{ id: string }> }) {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<div className="flex h-dvh" />}>
|
||||||
|
<ChatPage params={props.params} />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ChatPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
|
const chat = await getChatById({ id });
|
||||||
|
|
||||||
|
if (!chat) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
if (chat.visibility === "private") {
|
||||||
|
if (!session?.user) {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.user.id !== chat.userId) {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const messagesFromDb = await getMessagesByChatId({
|
||||||
|
id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const uiMessages = convertToUIMessages(messagesFromDb);
|
||||||
|
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const chatModelFromCookie = cookieStore.get("chat-model");
|
||||||
|
|
||||||
|
if (!chatModelFromCookie) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Chat
|
||||||
|
autoResume={true}
|
||||||
|
id={chat.id}
|
||||||
|
initialChatModel={DEFAULT_CHAT_MODEL}
|
||||||
|
initialLastContext={chat.lastContext ?? undefined}
|
||||||
|
initialMessages={uiMessages}
|
||||||
|
initialVisibilityType={chat.visibility}
|
||||||
|
isReadonly={session?.user?.id !== chat.userId}
|
||||||
|
userType={session?.user?.type}
|
||||||
|
/>
|
||||||
|
<DataStreamHandler />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Chat
|
||||||
|
autoResume={true}
|
||||||
|
id={chat.id}
|
||||||
|
initialChatModel={chatModelFromCookie.value}
|
||||||
|
initialLastContext={chat.lastContext ?? undefined}
|
||||||
|
initialMessages={uiMessages}
|
||||||
|
initialVisibilityType={chat.visibility}
|
||||||
|
isReadonly={session?.user?.id !== chat.userId}
|
||||||
|
userType={session?.user?.type}
|
||||||
|
/>
|
||||||
|
<DataStreamHandler />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
347
app/(chat)/gallery/page.tsx
Normal file
347
app/(chat)/gallery/page.tsx
Normal file
@ -0,0 +1,347 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||||
|
import { ArrowLeft, Download, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Edit } from "lucide-react";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
|
type GalleryImage = {
|
||||||
|
id: string;
|
||||||
|
messageId: string;
|
||||||
|
url: string; // 原图 URL
|
||||||
|
thumbnailUrl?: string; // 缩略图 URL(可选,兼容旧数据)
|
||||||
|
alt: string;
|
||||||
|
imageSize?: string; // 图片尺寸(如 "2K", "4K")
|
||||||
|
createdAt: string;
|
||||||
|
chatId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Pagination = {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
total: number;
|
||||||
|
totalPages: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function GalleryPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [images, setImages] = useState<GalleryImage[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [pagination, setPagination] = useState<Pagination>({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 24,
|
||||||
|
total: 0,
|
||||||
|
totalPages: 0,
|
||||||
|
});
|
||||||
|
const [selectedImage, setSelectedImage] = useState<GalleryImage | null>(null);
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [pageInput, setPageInput] = useState("");
|
||||||
|
|
||||||
|
const fetchImages = async (page: number) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/gallery?page=${page}&pageSize=24`);
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.images) {
|
||||||
|
setImages(data.images);
|
||||||
|
setPagination(data.pagination);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch images:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchImages(1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleImageClick = (image: GalleryImage) => {
|
||||||
|
setSelectedImage(image);
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownload = async (url: string, alt: string) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url);
|
||||||
|
const blob = await response.blob();
|
||||||
|
const blobUrl = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = blobUrl;
|
||||||
|
link.download = `${alt || "image"}-${Date.now()}.png`;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
window.URL.revokeObjectURL(blobUrl);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Download failed:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (imageUrl: string) => {
|
||||||
|
// 跳转到首页,并将图片URL作为参数传递
|
||||||
|
router.push(`/?editImage=${encodeURIComponent(imageUrl)}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToPage = (page: number) => {
|
||||||
|
if (page >= 1 && page <= pagination.totalPages) {
|
||||||
|
fetchImages(page);
|
||||||
|
setPageInput(""); // 清空输入框
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePageInputSubmit = () => {
|
||||||
|
const pageNum = Number.parseInt(pageInput);
|
||||||
|
if (!Number.isNaN(pageNum) && pageNum >= 1 && pageNum <= pagination.totalPages) {
|
||||||
|
goToPage(pageNum);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto p-6 h-full flex flex-col">
|
||||||
|
<div className="mb-6 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||||
|
<ArrowLeft className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">我的图册</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
共 {pagination.total} 张AI生成的图片
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex-1 flex items-center justify-center">
|
||||||
|
<div className="text-center py-10 text-muted-foreground">
|
||||||
|
<div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full mx-auto mb-4"></div>
|
||||||
|
加载中...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : images.length === 0 ? (
|
||||||
|
<div className="flex-1 flex items-center justify-center">
|
||||||
|
<div className="text-center py-10 text-muted-foreground">
|
||||||
|
暂无生图记录,快去生成一些图片吧!
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
<div className="flex flex-wrap justify-center gap-6 px-4">
|
||||||
|
{images.map((img) => (
|
||||||
|
<div
|
||||||
|
key={img.id}
|
||||||
|
className="group relative cursor-pointer rounded-lg border bg-muted overflow-hidden transition-all hover:shadow-lg hover:scale-105"
|
||||||
|
style={{ width: "200px", height: "270px", flexShrink: 0 }}
|
||||||
|
onClick={() => handleImageClick(img)}
|
||||||
|
>
|
||||||
|
<div className="relative w-full h-full">
|
||||||
|
<Image
|
||||||
|
src={img.thumbnailUrl || img.url}
|
||||||
|
alt={img.alt}
|
||||||
|
width={200}
|
||||||
|
height={270}
|
||||||
|
className="object-cover w-full h-full"
|
||||||
|
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, (max-width: 1024px) 25vw, (max-width: 1280px) 20vw, 200px"
|
||||||
|
quality={75}
|
||||||
|
loading="lazy"
|
||||||
|
placeholder="blur"
|
||||||
|
blurDataURL="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjI3MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMjAwIiBoZWlnaHQ9IjI3MCIgZmlsbD0iI2VlZSIvPjwvc3ZnPg=="
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* 🆕 编辑按钮(悬停时显示) */}
|
||||||
|
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
className="h-8 px-2 bg-black/70 hover:bg-black/90 text-white border-0"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleEdit(img.url);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Edit className="h-3 w-3 mr-1" />
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-2 translate-y-full group-hover:translate-y-0 transition-transform">
|
||||||
|
<p className="text-xs text-white truncate">{img.alt}</p>
|
||||||
|
<p className="text-xs text-white/70 mt-1">
|
||||||
|
{new Date(img.createdAt).toLocaleString("zh-CN", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit"
|
||||||
|
})} • AI生成
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 分页信息 */}
|
||||||
|
<div className="mt-6">
|
||||||
|
{/* 始终显示页码信息 */}
|
||||||
|
<div className="text-center text-sm text-muted-foreground mb-3">
|
||||||
|
第 {pagination.page} 页 / 共 {pagination.totalPages} 页
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 只有多页时才显示翻页按钮 */}
|
||||||
|
{pagination.totalPages > 1 && (
|
||||||
|
<div className="flex flex-col sm:flex-row items-center justify-center gap-3">
|
||||||
|
{/* 左侧:第一页按钮 */}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => goToPage(1)}
|
||||||
|
disabled={pagination.page === 1}
|
||||||
|
title="第一页"
|
||||||
|
>
|
||||||
|
<ChevronsLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* 上一页 */}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => goToPage(pagination.page - 1)}
|
||||||
|
disabled={pagination.page === 1}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
上一页
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* 页码按钮 */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{Array.from({ length: pagination.totalPages }, (_, i) => i + 1)
|
||||||
|
.filter((p) => {
|
||||||
|
// 显示当前页前后1页
|
||||||
|
return (
|
||||||
|
p === 1 ||
|
||||||
|
p === pagination.totalPages ||
|
||||||
|
Math.abs(p - pagination.page) <= 1
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.map((p, idx, arr) => {
|
||||||
|
// 添加省略号
|
||||||
|
const showEllipsis = idx > 0 && p - arr[idx - 1] > 1;
|
||||||
|
return (
|
||||||
|
<div key={p} className="flex items-center">
|
||||||
|
{showEllipsis && (
|
||||||
|
<span className="px-2 text-muted-foreground">...</span>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant={p === pagination.page ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => goToPage(p)}
|
||||||
|
>
|
||||||
|
{p}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 下一页 */}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => goToPage(pagination.page + 1)}
|
||||||
|
disabled={pagination.page === pagination.totalPages}
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* 右侧:最后一页按钮 */}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => goToPage(pagination.totalPages)}
|
||||||
|
disabled={pagination.page === pagination.totalPages}
|
||||||
|
title="最后一页"
|
||||||
|
>
|
||||||
|
<ChevronsRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* 页码输入跳转 */}
|
||||||
|
<div className="flex items-center gap-2 ml-4">
|
||||||
|
<span className="text-sm text-muted-foreground">跳至</span>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={pagination.totalPages}
|
||||||
|
value={pageInput}
|
||||||
|
onChange={(e) => setPageInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
handlePageInputSubmit();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-16 h-8 text-center"
|
||||||
|
placeholder={String(pagination.page)}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-muted-foreground">页</span>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handlePageInputSubmit}
|
||||||
|
disabled={!pageInput}
|
||||||
|
>
|
||||||
|
跳转
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 图片预览对话框 */}
|
||||||
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
<DialogContent className="max-w-4xl max-h-[90vh] p-0">
|
||||||
|
{selectedImage && (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div className="flex-1 relative bg-black/5 flex items-center justify-center p-4">
|
||||||
|
<div className="relative max-w-full max-h-[70vh]">
|
||||||
|
<Image
|
||||||
|
src={selectedImage.url}
|
||||||
|
alt={selectedImage.alt}
|
||||||
|
width={800}
|
||||||
|
height={800}
|
||||||
|
className="object-contain max-h-[70vh] w-auto"
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 border-t bg-background">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-medium">{selectedImage.alt}</p>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
{new Date(selectedImage.createdAt).toLocaleString("zh-CN")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleDownload(selectedImage.url, selectedImage.alt)}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4 mr-2" />
|
||||||
|
下载
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
app/(chat)/layout.tsx
Normal file
56
app/(chat)/layout.tsx
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { cookies } from "next/headers";
|
||||||
|
import Script from "next/script";
|
||||||
|
import { Suspense } from "react";
|
||||||
|
import { AppSidebar } from "@/components/app-sidebar";
|
||||||
|
import { DataStreamProvider } from "@/components/data-stream-provider";
|
||||||
|
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
||||||
|
import { auth } from "../(auth)/auth";
|
||||||
|
import { TopNavbar } from "@/components/top-navbar";
|
||||||
|
import { getSiteSettings, getNavLinks } from "@/lib/db/queries";
|
||||||
|
|
||||||
|
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Script
|
||||||
|
src="https://cdn.jsdelivr.net/pyodide/v0.23.4/full/pyodide.js"
|
||||||
|
strategy="beforeInteractive"
|
||||||
|
/>
|
||||||
|
<DataStreamProvider>
|
||||||
|
<Suspense fallback={<div className="flex h-dvh" />}>
|
||||||
|
<SidebarWrapper>{children}</SidebarWrapper>
|
||||||
|
</Suspense>
|
||||||
|
</DataStreamProvider>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function SidebarWrapper({ children }: { children: React.ReactNode }) {
|
||||||
|
const [session, cookieStore, siteSettings, navLinks] = await Promise.all([
|
||||||
|
auth(),
|
||||||
|
cookies(),
|
||||||
|
getSiteSettings(),
|
||||||
|
getNavLinks(),
|
||||||
|
]);
|
||||||
|
const isCollapsed = cookieStore.get("sidebar_state")?.value !== "true";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarProvider defaultOpen={!isCollapsed}>
|
||||||
|
<TopNavbar
|
||||||
|
siteName={siteSettings.siteName}
|
||||||
|
siteLogo={siteSettings.siteLogo || undefined}
|
||||||
|
siteLogoDark={siteSettings.siteLogoDark || undefined}
|
||||||
|
isAdmin={session?.user?.type === "admin"}
|
||||||
|
customLinks={navLinks.map(link => ({
|
||||||
|
id: link.id,
|
||||||
|
name: link.name,
|
||||||
|
url: link.url,
|
||||||
|
order: link.order
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
<div className="pt-14 flex h-full w-full overflow-hidden"> {/* Add padding-top for fixed navbar */}
|
||||||
|
<AppSidebar user={session?.user} />
|
||||||
|
<SidebarInset className="h-[calc(100vh-56px)]">{children}</SidebarInset> {/* Adjust height */}
|
||||||
|
</div>
|
||||||
|
</SidebarProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
BIN
app/(chat)/opengraph-image.png
Normal file
BIN
app/(chat)/opengraph-image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 162 KiB |
56
app/(chat)/page.tsx
Normal file
56
app/(chat)/page.tsx
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { Suspense } from "react";
|
||||||
|
import { Chat } from "@/components/chat";
|
||||||
|
import { DataStreamHandler } from "@/components/data-stream-handler";
|
||||||
|
import { generateUUID } from "@/lib/utils";
|
||||||
|
import { auth } from "../(auth)/auth";
|
||||||
|
import { getDefaultAiModel, getEnabledAiModelsWithProvider } from "@/lib/db/queries";
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<div className="flex h-dvh" />}>
|
||||||
|
<NewChatPage />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function NewChatPage() {
|
||||||
|
const id = generateUUID();
|
||||||
|
|
||||||
|
const [session, cookieStore] = await Promise.all([auth(), cookies()]);
|
||||||
|
const modelIdFromCookie = cookieStore.get("chat-model");
|
||||||
|
|
||||||
|
// 如果没有保存的模型 ID,从数据库获取默认模型
|
||||||
|
let initialModelId = modelIdFromCookie?.value;
|
||||||
|
|
||||||
|
if (!initialModelId) {
|
||||||
|
// 尝试获取默认模型
|
||||||
|
const defaultModel = await getDefaultAiModel();
|
||||||
|
|
||||||
|
if (defaultModel) {
|
||||||
|
initialModelId = defaultModel.id;
|
||||||
|
} else {
|
||||||
|
// 如果没有设置默认模型,获取第一个启用的模型
|
||||||
|
const enabledModels = await getEnabledAiModelsWithProvider();
|
||||||
|
if (enabledModels.length > 0) {
|
||||||
|
initialModelId = enabledModels[0].model.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Chat
|
||||||
|
autoResume={false}
|
||||||
|
id={id}
|
||||||
|
initialChatModel={initialModelId || ""}
|
||||||
|
initialMessages={[]}
|
||||||
|
initialVisibilityType="private"
|
||||||
|
isReadonly={false}
|
||||||
|
key={id}
|
||||||
|
userType={session?.user?.type}
|
||||||
|
/>
|
||||||
|
<DataStreamHandler />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
BIN
app/(chat)/twitter-image.png
Normal file
BIN
app/(chat)/twitter-image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 51 KiB |
149
app/api/admin/ai-models/route.ts
Normal file
149
app/api/admin/ai-models/route.ts
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { requireAdmin } from "@/lib/middleware/admin";
|
||||||
|
import {
|
||||||
|
getAllAiModels,
|
||||||
|
getAiModelById,
|
||||||
|
getAiModelsByProviderId,
|
||||||
|
createAiModel,
|
||||||
|
updateAiModel,
|
||||||
|
deleteAiModel,
|
||||||
|
getAiProviderById,
|
||||||
|
} from "@/lib/db/queries";
|
||||||
|
import { ChatSDKError } from "@/lib/errors";
|
||||||
|
|
||||||
|
const createModelSchema = z.object({
|
||||||
|
providerId: z.string().uuid(),
|
||||||
|
modelId: z.string().min(1).max(128),
|
||||||
|
displayName: z.string().min(1).max(128),
|
||||||
|
description: z.string().optional(),
|
||||||
|
supportsVision: z.boolean().optional(),
|
||||||
|
supportsImageGen: z.boolean().optional(),
|
||||||
|
isEnabled: z.boolean().optional(),
|
||||||
|
isDefault: z.boolean().optional(),
|
||||||
|
sortOrder: z.number().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateModelSchema = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
providerId: z.string().uuid().optional(), // 允许修改提供商
|
||||||
|
modelId: z.string().min(1).max(128).optional(),
|
||||||
|
displayName: z.string().min(1).max(128).optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
supportsVision: z.boolean().optional(),
|
||||||
|
supportsImageGen: z.boolean().optional(),
|
||||||
|
isEnabled: z.boolean().optional(),
|
||||||
|
isDefault: z.boolean().optional(),
|
||||||
|
sortOrder: z.number().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
await requireAdmin();
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const id = searchParams.get("id");
|
||||||
|
const providerId = searchParams.get("providerId");
|
||||||
|
|
||||||
|
if (id) {
|
||||||
|
const model = await getAiModelById({ id });
|
||||||
|
if (!model) {
|
||||||
|
return new ChatSDKError("bad_request:api", "Model not found").toResponse();
|
||||||
|
}
|
||||||
|
return Response.json({ model });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (providerId) {
|
||||||
|
const models = await getAiModelsByProviderId({ providerId });
|
||||||
|
return Response.json({ models });
|
||||||
|
}
|
||||||
|
|
||||||
|
const models = await getAllAiModels();
|
||||||
|
return Response.json({ models });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ChatSDKError) {
|
||||||
|
return error.toResponse();
|
||||||
|
}
|
||||||
|
return new ChatSDKError("offline:admin").toResponse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
await requireAdmin();
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const data = createModelSchema.parse(body);
|
||||||
|
|
||||||
|
// 验证 provider 是否存在
|
||||||
|
const provider = await getAiProviderById({ id: data.providerId });
|
||||||
|
if (!provider) {
|
||||||
|
return new ChatSDKError("bad_request:api", "Provider not found").toResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
const model = await createAiModel(data);
|
||||||
|
|
||||||
|
return Response.json({ model: model[0] });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ChatSDKError) {
|
||||||
|
return error.toResponse();
|
||||||
|
}
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return new ChatSDKError("bad_request:api", error.message).toResponse();
|
||||||
|
}
|
||||||
|
return new ChatSDKError("offline:admin").toResponse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: Request) {
|
||||||
|
try {
|
||||||
|
await requireAdmin();
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
console.log("[Admin API] Update model request body:", JSON.stringify(body)); // Debug log
|
||||||
|
const data = updateModelSchema.parse(body);
|
||||||
|
console.log("[Admin API] Validation passed, updating model..."); // Debug log
|
||||||
|
|
||||||
|
const model = await updateAiModel(data);
|
||||||
|
|
||||||
|
if (model.length === 0) {
|
||||||
|
console.error("[Admin API] Model not found:", data.id); // Debug log
|
||||||
|
return new ChatSDKError("bad_request:api", "Model not found").toResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[Admin API] Model updated successfully:", model[0].id); // Debug log
|
||||||
|
return Response.json({ model: model[0] });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Admin API] Update model error:", error); // Debug log
|
||||||
|
if (error instanceof ChatSDKError) {
|
||||||
|
return error.toResponse();
|
||||||
|
}
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
console.error("[Admin API] Validation error details:", error.errors); // Debug log
|
||||||
|
return new ChatSDKError("bad_request:api", error.message).toResponse();
|
||||||
|
}
|
||||||
|
return new ChatSDKError("offline:admin").toResponse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: Request) {
|
||||||
|
try {
|
||||||
|
await requireAdmin();
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const id = searchParams.get("id");
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return new ChatSDKError("bad_request:api", "Model ID is required").toResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteAiModel({ id });
|
||||||
|
|
||||||
|
return Response.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ChatSDKError) {
|
||||||
|
return error.toResponse();
|
||||||
|
}
|
||||||
|
return new ChatSDKError("offline:admin").toResponse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
145
app/api/admin/ai-providers/route.ts
Normal file
145
app/api/admin/ai-providers/route.ts
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { requireAdmin } from "@/lib/middleware/admin";
|
||||||
|
import {
|
||||||
|
getAllAiProviders,
|
||||||
|
getAiProviderById,
|
||||||
|
createAiProvider,
|
||||||
|
updateAiProvider,
|
||||||
|
deleteAiProvider,
|
||||||
|
} from "@/lib/db/queries";
|
||||||
|
import { ChatSDKError } from "@/lib/errors";
|
||||||
|
|
||||||
|
const createProviderSchema = z.object({
|
||||||
|
name: z.string().min(1).max(128),
|
||||||
|
type: z.enum(["alibaba", "openai", "custom", "local", "gemini", "qianwen-video", "volcano-vision", "volcano-video"]),
|
||||||
|
baseUrl: z.string().min(1), // 改为简单的字符串验证,支持 localhost
|
||||||
|
apiKey: z.string().min(1),
|
||||||
|
isEnabled: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateProviderSchema = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
name: z.string().min(1).max(128).optional(),
|
||||||
|
type: z.enum(["alibaba", "openai", "custom", "local", "gemini", "qianwen-video", "volcano-vision", "volcano-video"]).optional(),
|
||||||
|
baseUrl: z.string().min(1).optional(), // 改为简单的字符串验证,支持 localhost
|
||||||
|
apiKey: z.string().min(1).optional(),
|
||||||
|
isEnabled: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
await requireAdmin();
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const id = searchParams.get("id");
|
||||||
|
|
||||||
|
if (id) {
|
||||||
|
const provider = await getAiProviderById({ id });
|
||||||
|
if (!provider) {
|
||||||
|
return new ChatSDKError("bad_request:api", "Provider not found").toResponse();
|
||||||
|
}
|
||||||
|
// 隐藏 API Key 的部分内容
|
||||||
|
return Response.json({
|
||||||
|
provider: {
|
||||||
|
...provider,
|
||||||
|
apiKey: provider.apiKey.slice(0, 8) + "****" + provider.apiKey.slice(-4),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const providers = await getAllAiProviders();
|
||||||
|
// 隐藏 API Key 的部分内容
|
||||||
|
return Response.json({
|
||||||
|
providers: providers.map((p) => ({
|
||||||
|
...p,
|
||||||
|
apiKey: p.apiKey.slice(0, 8) + "****" + p.apiKey.slice(-4),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ChatSDKError) {
|
||||||
|
return error.toResponse();
|
||||||
|
}
|
||||||
|
return new ChatSDKError("offline:admin").toResponse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const session = await requireAdmin();
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
console.log("[Admin API] Create provider request body:", JSON.stringify(body));
|
||||||
|
|
||||||
|
const data = createProviderSchema.parse(body);
|
||||||
|
console.log("[Admin API] Validation passed, creating provider...");
|
||||||
|
|
||||||
|
const provider = await createAiProvider({
|
||||||
|
...data,
|
||||||
|
updatedBy: session.user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("[Admin API] Provider created successfully:", provider[0].id);
|
||||||
|
return Response.json({ provider: provider[0] });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Admin API] Create provider error:", error);
|
||||||
|
if (error instanceof ChatSDKError) {
|
||||||
|
return error.toResponse();
|
||||||
|
}
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
console.error("[Admin API] Validation error details:", error.errors);
|
||||||
|
return new ChatSDKError("bad_request:api", JSON.stringify(error.errors)).toResponse();
|
||||||
|
}
|
||||||
|
return new ChatSDKError("offline:admin").toResponse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: Request) {
|
||||||
|
try {
|
||||||
|
const session = await requireAdmin();
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const data = updateProviderSchema.parse(body);
|
||||||
|
|
||||||
|
const provider = await updateAiProvider({
|
||||||
|
...data,
|
||||||
|
updatedBy: session.user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (provider.length === 0) {
|
||||||
|
return new ChatSDKError("bad_request:api", "Provider not found").toResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json({ provider: provider[0] });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ChatSDKError) {
|
||||||
|
return error.toResponse();
|
||||||
|
}
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return new ChatSDKError("bad_request:api", error.message).toResponse();
|
||||||
|
}
|
||||||
|
return new ChatSDKError("offline:admin").toResponse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: Request) {
|
||||||
|
try {
|
||||||
|
await requireAdmin();
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const id = searchParams.get("id");
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return new ChatSDKError("bad_request:api", "Provider ID is required").toResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteAiProvider({ id });
|
||||||
|
|
||||||
|
return Response.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ChatSDKError) {
|
||||||
|
return error.toResponse();
|
||||||
|
}
|
||||||
|
return new ChatSDKError("offline:admin").toResponse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
185
app/api/admin/images/analytics/route.ts
Normal file
185
app/api/admin/images/analytics/route.ts
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { generatedImage, user } from "@/lib/db/schema";
|
||||||
|
import { eq, sql, desc, count, and, gte, lt } from "drizzle-orm";
|
||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否为管理员
|
||||||
|
const currentUser = await db.query.user.findFirst({
|
||||||
|
where: eq(user.id, session.user.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (currentUser?.role !== "admin") {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取查询参数
|
||||||
|
const searchParams = request.nextUrl.searchParams;
|
||||||
|
const month = searchParams.get("month"); // 格式: YYYY-MM
|
||||||
|
const selectedDate = searchParams.get("date"); // 格式: YYYY-MM-DD,用于高峰时段筛选
|
||||||
|
|
||||||
|
// 1. 总体统计
|
||||||
|
const totalStats = await db
|
||||||
|
.select({
|
||||||
|
totalImages: sql<number>`count(*)::int`,
|
||||||
|
totalUsers: sql<number>`count(distinct ${generatedImage.userId})::int`,
|
||||||
|
})
|
||||||
|
.from(generatedImage);
|
||||||
|
|
||||||
|
// 2. 按用户统计(Top 20)
|
||||||
|
const userStats = await db
|
||||||
|
.select({
|
||||||
|
userId: generatedImage.userId,
|
||||||
|
userName: user.name,
|
||||||
|
userEmail: user.email,
|
||||||
|
imageCount: sql<number>`count(*)::int`,
|
||||||
|
})
|
||||||
|
.from(generatedImage)
|
||||||
|
.leftJoin(user, eq(generatedImage.userId, user.id))
|
||||||
|
.groupBy(generatedImage.userId, user.name, user.email)
|
||||||
|
.orderBy(desc(sql`count(*)`))
|
||||||
|
.limit(20);
|
||||||
|
|
||||||
|
// 3. 按日期统计(最近30天)
|
||||||
|
const dailyStats = await db
|
||||||
|
.select({
|
||||||
|
date: sql<string>`date(${generatedImage.createdAt})::text`,
|
||||||
|
count: sql<number>`count(*)::int`,
|
||||||
|
})
|
||||||
|
.from(generatedImage)
|
||||||
|
.where(sql`${generatedImage.createdAt} >= now() - interval '30 days'`)
|
||||||
|
.groupBy(sql`date(${generatedImage.createdAt})`)
|
||||||
|
.orderBy(sql`date(${generatedImage.createdAt})`);
|
||||||
|
|
||||||
|
// 4. 按图片尺寸统计
|
||||||
|
const sizeStats = await db
|
||||||
|
.select({
|
||||||
|
imageSize: generatedImage.imageSize,
|
||||||
|
count: sql<number>`count(*)::int`,
|
||||||
|
})
|
||||||
|
.from(generatedImage)
|
||||||
|
.groupBy(generatedImage.imageSize)
|
||||||
|
.orderBy(desc(sql`count(*)`));
|
||||||
|
|
||||||
|
// 5. 按模型统计
|
||||||
|
const modelStats = await db
|
||||||
|
.select({
|
||||||
|
modelId: generatedImage.modelId,
|
||||||
|
count: sql<number>`count(*)::int`,
|
||||||
|
})
|
||||||
|
.from(generatedImage)
|
||||||
|
.groupBy(generatedImage.modelId)
|
||||||
|
.orderBy(desc(sql`count(*)`))
|
||||||
|
.limit(10);
|
||||||
|
|
||||||
|
// 6. 按月份统计
|
||||||
|
const monthlyStats = await db
|
||||||
|
.select({
|
||||||
|
month: sql<string>`to_char(${generatedImage.createdAt}, 'YYYY-MM')`,
|
||||||
|
count: sql<number>`count(*)::int`,
|
||||||
|
})
|
||||||
|
.from(generatedImage)
|
||||||
|
.groupBy(sql`to_char(${generatedImage.createdAt}, 'YYYY-MM')`)
|
||||||
|
.orderBy(sql`to_char(${generatedImage.createdAt}, 'YYYY-MM')`);
|
||||||
|
|
||||||
|
// 7. 按小时统计(用于分析高峰时段,支持按日期筛选)
|
||||||
|
let hourlyWhereClause = undefined;
|
||||||
|
if (selectedDate) {
|
||||||
|
const dateStart = new Date(selectedDate);
|
||||||
|
const dateEnd = new Date(selectedDate);
|
||||||
|
dateEnd.setDate(dateEnd.getDate() + 1);
|
||||||
|
hourlyWhereClause = and(
|
||||||
|
gte(generatedImage.createdAt, dateStart),
|
||||||
|
lt(generatedImage.createdAt, dateEnd)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hourlyStats = await db
|
||||||
|
.select({
|
||||||
|
hour: sql<number>`extract(hour from ${generatedImage.createdAt})::int`,
|
||||||
|
count: sql<number>`count(*)::int`,
|
||||||
|
})
|
||||||
|
.from(generatedImage)
|
||||||
|
.where(hourlyWhereClause)
|
||||||
|
.groupBy(sql`extract(hour from ${generatedImage.createdAt})`)
|
||||||
|
.orderBy(sql`extract(hour from ${generatedImage.createdAt})`);
|
||||||
|
|
||||||
|
// 8. 今日统计
|
||||||
|
const todayStats = await db
|
||||||
|
.select({
|
||||||
|
count: sql<number>`count(*)::int`,
|
||||||
|
users: sql<number>`count(distinct ${generatedImage.userId})::int`,
|
||||||
|
})
|
||||||
|
.from(generatedImage)
|
||||||
|
.where(sql`date(${generatedImage.createdAt}) = current_date`);
|
||||||
|
|
||||||
|
// 9. 本周统计
|
||||||
|
const weekStats = await db
|
||||||
|
.select({
|
||||||
|
count: sql<number>`count(*)::int`,
|
||||||
|
users: sql<number>`count(distinct ${generatedImage.userId})::int`,
|
||||||
|
})
|
||||||
|
.from(generatedImage)
|
||||||
|
.where(sql`${generatedImage.createdAt} >= date_trunc('week', current_date)`);
|
||||||
|
|
||||||
|
// 10. 指定月份统计(支持选择月份)
|
||||||
|
let monthStart: Date;
|
||||||
|
let monthEnd: Date;
|
||||||
|
|
||||||
|
if (month) {
|
||||||
|
// 解析指定月份
|
||||||
|
const [year, mon] = month.split("-").map(Number);
|
||||||
|
monthStart = new Date(year, mon - 1, 1);
|
||||||
|
monthEnd = new Date(year, mon, 1); // 下个月第一天
|
||||||
|
} else {
|
||||||
|
// 默认当前月
|
||||||
|
const now = new Date();
|
||||||
|
monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
|
monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const monthStats = await db
|
||||||
|
.select({
|
||||||
|
count: sql<number>`count(*)::int`,
|
||||||
|
users: sql<number>`count(distinct ${generatedImage.userId})::int`,
|
||||||
|
})
|
||||||
|
.from(generatedImage)
|
||||||
|
.where(and(
|
||||||
|
gte(generatedImage.createdAt, monthStart),
|
||||||
|
lt(generatedImage.createdAt, monthEnd)
|
||||||
|
));
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
overview: {
|
||||||
|
totalImages: totalStats[0]?.totalImages || 0,
|
||||||
|
totalUsers: totalStats[0]?.totalUsers || 0,
|
||||||
|
todayImages: todayStats[0]?.count || 0,
|
||||||
|
todayUsers: todayStats[0]?.users || 0,
|
||||||
|
weekImages: weekStats[0]?.count || 0,
|
||||||
|
weekUsers: weekStats[0]?.users || 0,
|
||||||
|
monthImages: monthStats[0]?.count || 0,
|
||||||
|
monthUsers: monthStats[0]?.users || 0,
|
||||||
|
selectedMonth: month || `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, "0")}`,
|
||||||
|
selectedDate: selectedDate || null,
|
||||||
|
},
|
||||||
|
userStats,
|
||||||
|
dailyStats,
|
||||||
|
sizeStats,
|
||||||
|
modelStats,
|
||||||
|
monthlyStats,
|
||||||
|
hourlyStats,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Admin Images Analytics API] Error:", error);
|
||||||
|
return NextResponse.json({ error: "Failed to fetch analytics" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
111
app/api/admin/images/route.ts
Normal file
111
app/api/admin/images/route.ts
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { generatedImage, user } from "@/lib/db/schema";
|
||||||
|
import { eq, desc, sql, and, gte, lte, like, or } from "drizzle-orm";
|
||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否为管理员
|
||||||
|
const currentUser = await db.query.user.findFirst({
|
||||||
|
where: eq(user.id, session.user.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (currentUser?.role !== "admin") {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取查询参数
|
||||||
|
const searchParams = request.nextUrl.searchParams;
|
||||||
|
const page = Number.parseInt(searchParams.get("page") || "1");
|
||||||
|
const pageSize = Number.parseInt(searchParams.get("pageSize") || "200");
|
||||||
|
const offset = (page - 1) * pageSize;
|
||||||
|
const search = searchParams.get("search") || "";
|
||||||
|
const startDate = searchParams.get("startDate");
|
||||||
|
const endDate = searchParams.get("endDate");
|
||||||
|
|
||||||
|
// 构建查询条件
|
||||||
|
const conditions = [];
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
// 搜索用户名或邮箱
|
||||||
|
const matchingUsers = await db
|
||||||
|
.select({ id: user.id })
|
||||||
|
.from(user)
|
||||||
|
.where(or(
|
||||||
|
like(user.name, `%${search}%`),
|
||||||
|
like(user.email, `%${search}%`)
|
||||||
|
));
|
||||||
|
|
||||||
|
if (matchingUsers.length > 0) {
|
||||||
|
const userIds = matchingUsers.map(u => u.id);
|
||||||
|
conditions.push(sql`${generatedImage.userId} IN (${sql.join(userIds.map(id => sql`${id}`), sql`, `)})`);
|
||||||
|
} else {
|
||||||
|
// 没有匹配的用户,返回空结果
|
||||||
|
return NextResponse.json({
|
||||||
|
images: [],
|
||||||
|
pagination: { page, pageSize, total: 0, totalPages: 0 }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startDate) {
|
||||||
|
conditions.push(gte(generatedImage.createdAt, new Date(startDate)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endDate) {
|
||||||
|
const end = new Date(endDate);
|
||||||
|
end.setHours(23, 59, 59, 999);
|
||||||
|
conditions.push(lte(generatedImage.createdAt, end));
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
|
||||||
|
|
||||||
|
// 查询总数
|
||||||
|
const countResult = await db
|
||||||
|
.select({ count: sql<number>`count(*)::int` })
|
||||||
|
.from(generatedImage)
|
||||||
|
.where(whereClause);
|
||||||
|
|
||||||
|
const total = countResult[0]?.count || 0;
|
||||||
|
|
||||||
|
// 查询图片列表(包含用户信息)
|
||||||
|
const images = await db
|
||||||
|
.select({
|
||||||
|
id: generatedImage.id,
|
||||||
|
userId: generatedImage.userId,
|
||||||
|
userName: user.name,
|
||||||
|
userEmail: user.email,
|
||||||
|
thumbnailUrl: generatedImage.thumbnailUrl,
|
||||||
|
originalUrl: generatedImage.originalUrl,
|
||||||
|
imageSize: generatedImage.imageSize,
|
||||||
|
prompt: generatedImage.prompt,
|
||||||
|
modelId: generatedImage.modelId,
|
||||||
|
createdAt: generatedImage.createdAt,
|
||||||
|
chatId: generatedImage.chatId,
|
||||||
|
})
|
||||||
|
.from(generatedImage)
|
||||||
|
.leftJoin(user, eq(generatedImage.userId, user.id))
|
||||||
|
.where(whereClause)
|
||||||
|
.orderBy(desc(generatedImage.createdAt))
|
||||||
|
.limit(pageSize)
|
||||||
|
.offset(offset);
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(total / pageSize);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
images,
|
||||||
|
pagination: { page, pageSize, total, totalPages }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Admin Images API] Error:", error);
|
||||||
|
return NextResponse.json({ error: "Failed to fetch images" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
61
app/api/admin/settings/route.ts
Normal file
61
app/api/admin/settings/route.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { requireAdmin } from "@/lib/middleware/admin";
|
||||||
|
import {
|
||||||
|
getAllSystemSettings,
|
||||||
|
getSystemSetting,
|
||||||
|
upsertSystemSetting,
|
||||||
|
} from "@/lib/db/queries";
|
||||||
|
import { ChatSDKError } from "@/lib/errors";
|
||||||
|
|
||||||
|
const settingSchema = z.object({
|
||||||
|
key: z.string(),
|
||||||
|
value: z.any(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
await requireAdmin();
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const key = searchParams.get("key");
|
||||||
|
|
||||||
|
if (key) {
|
||||||
|
const setting = await getSystemSetting({ key });
|
||||||
|
return Response.json({ setting });
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = await getAllSystemSettings();
|
||||||
|
return Response.json({ settings });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ChatSDKError) {
|
||||||
|
return error.toResponse();
|
||||||
|
}
|
||||||
|
return new ChatSDKError("offline:admin").toResponse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const session = await requireAdmin();
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { key, value } = settingSchema.parse(body);
|
||||||
|
|
||||||
|
const setting = await upsertSystemSetting({
|
||||||
|
key,
|
||||||
|
value,
|
||||||
|
updatedBy: session.user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return Response.json({ setting: setting[0] });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ChatSDKError) {
|
||||||
|
return error.toResponse();
|
||||||
|
}
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return new ChatSDKError("bad_request:api", error.message).toResponse();
|
||||||
|
}
|
||||||
|
return new ChatSDKError("offline:admin").toResponse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
153
app/api/admin/storage/route.ts
Normal file
153
app/api/admin/storage/route.ts
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { requireAdmin } from "@/lib/middleware/admin";
|
||||||
|
import {
|
||||||
|
getAllStorageConfigs,
|
||||||
|
getStorageConfigById,
|
||||||
|
createStorageConfig,
|
||||||
|
updateStorageConfig,
|
||||||
|
deleteStorageConfig,
|
||||||
|
} from "@/lib/db/queries";
|
||||||
|
import { ChatSDKError } from "@/lib/errors";
|
||||||
|
|
||||||
|
const createStorageSchema = z.object({
|
||||||
|
name: z.string().min(1).max(128),
|
||||||
|
type: z.enum(["s3", "local"]),
|
||||||
|
endpoint: z.string().optional(),
|
||||||
|
region: z.string().max(64).optional(),
|
||||||
|
bucket: z.string().max(128).optional(),
|
||||||
|
accessKeyId: z.string().optional(),
|
||||||
|
secretAccessKey: z.string().optional(),
|
||||||
|
publicUrl: z.string().optional(),
|
||||||
|
isDefault: z.boolean().optional(),
|
||||||
|
isEnabled: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateStorageSchema = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
name: z.string().min(1).max(128).optional(),
|
||||||
|
type: z.enum(["s3", "local"]).optional(),
|
||||||
|
endpoint: z.string().optional(),
|
||||||
|
region: z.string().max(64).optional(),
|
||||||
|
bucket: z.string().max(128).optional(),
|
||||||
|
accessKeyId: z.string().optional(),
|
||||||
|
secretAccessKey: z.string().optional(),
|
||||||
|
publicUrl: z.string().optional(),
|
||||||
|
isDefault: z.boolean().optional(),
|
||||||
|
isEnabled: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
await requireAdmin();
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const id = searchParams.get("id");
|
||||||
|
|
||||||
|
if (id) {
|
||||||
|
const config = await getStorageConfigById({ id });
|
||||||
|
if (!config) {
|
||||||
|
return new ChatSDKError("bad_request:api", "Storage config not found").toResponse();
|
||||||
|
}
|
||||||
|
// 隐藏敏感信息
|
||||||
|
return Response.json({
|
||||||
|
config: {
|
||||||
|
...config,
|
||||||
|
accessKeyId: config.accessKeyId
|
||||||
|
? config.accessKeyId.slice(0, 4) + "****"
|
||||||
|
: null,
|
||||||
|
secretAccessKey: config.secretAccessKey ? "****" : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const configs = await getAllStorageConfigs();
|
||||||
|
// 隐藏敏感信息
|
||||||
|
return Response.json({
|
||||||
|
configs: configs.map((c) => ({
|
||||||
|
...c,
|
||||||
|
accessKeyId: c.accessKeyId ? c.accessKeyId.slice(0, 4) + "****" : null,
|
||||||
|
secretAccessKey: c.secretAccessKey ? "****" : null,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ChatSDKError) {
|
||||||
|
return error.toResponse();
|
||||||
|
}
|
||||||
|
return new ChatSDKError("offline:admin").toResponse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const session = await requireAdmin();
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const data = createStorageSchema.parse(body);
|
||||||
|
|
||||||
|
const config = await createStorageConfig({
|
||||||
|
...data,
|
||||||
|
updatedBy: session.user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return Response.json({ config: config[0] });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ChatSDKError) {
|
||||||
|
return error.toResponse();
|
||||||
|
}
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return new ChatSDKError("bad_request:api", error.message).toResponse();
|
||||||
|
}
|
||||||
|
return new ChatSDKError("offline:admin").toResponse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: Request) {
|
||||||
|
try {
|
||||||
|
const session = await requireAdmin();
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const data = updateStorageSchema.parse(body);
|
||||||
|
|
||||||
|
const config = await updateStorageConfig({
|
||||||
|
...data,
|
||||||
|
updatedBy: session.user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (config.length === 0) {
|
||||||
|
return new ChatSDKError("bad_request:api", "Storage config not found").toResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json({ config: config[0] });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ChatSDKError) {
|
||||||
|
return error.toResponse();
|
||||||
|
}
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return new ChatSDKError("bad_request:api", error.message).toResponse();
|
||||||
|
}
|
||||||
|
return new ChatSDKError("offline:admin").toResponse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: Request) {
|
||||||
|
try {
|
||||||
|
await requireAdmin();
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const id = searchParams.get("id");
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return new ChatSDKError("bad_request:api", "Storage config ID is required").toResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteStorageConfig({ id });
|
||||||
|
|
||||||
|
return Response.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ChatSDKError) {
|
||||||
|
return error.toResponse();
|
||||||
|
}
|
||||||
|
return new ChatSDKError("offline:admin").toResponse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
66
app/api/admin/tools/route.ts
Normal file
66
app/api/admin/tools/route.ts
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { requireAdmin } from "@/lib/middleware/admin";
|
||||||
|
import {
|
||||||
|
getAllToolConfigs,
|
||||||
|
getToolConfig,
|
||||||
|
upsertToolConfig,
|
||||||
|
} from "@/lib/db/queries";
|
||||||
|
import { ChatSDKError } from "@/lib/errors";
|
||||||
|
|
||||||
|
const toolConfigSchema = z.object({
|
||||||
|
toolName: z.string(),
|
||||||
|
isEnabled: z.boolean(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
config: z.any().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
await requireAdmin();
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const toolName = searchParams.get("toolName");
|
||||||
|
|
||||||
|
if (toolName) {
|
||||||
|
const config = await getToolConfig({ toolName });
|
||||||
|
return Response.json({ config });
|
||||||
|
}
|
||||||
|
|
||||||
|
const configs = await getAllToolConfigs();
|
||||||
|
return Response.json({ configs });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ChatSDKError) {
|
||||||
|
return error.toResponse();
|
||||||
|
}
|
||||||
|
return new ChatSDKError("offline:admin").toResponse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const session = await requireAdmin();
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { toolName, isEnabled, description, config } =
|
||||||
|
toolConfigSchema.parse(body);
|
||||||
|
|
||||||
|
const result = await upsertToolConfig({
|
||||||
|
toolName,
|
||||||
|
isEnabled,
|
||||||
|
description,
|
||||||
|
config,
|
||||||
|
updatedBy: session.user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return Response.json({ config: result[0] });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ChatSDKError) {
|
||||||
|
return error.toResponse();
|
||||||
|
}
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return new ChatSDKError("bad_request:api", error.message).toResponse();
|
||||||
|
}
|
||||||
|
return new ChatSDKError("offline:admin").toResponse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
92
app/api/admin/users/route.ts
Normal file
92
app/api/admin/users/route.ts
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { requireAdmin } from "@/lib/middleware/admin";
|
||||||
|
import {
|
||||||
|
getAllUsers,
|
||||||
|
getUserById,
|
||||||
|
updateUser,
|
||||||
|
deleteUserById,
|
||||||
|
} from "@/lib/db/queries";
|
||||||
|
import { ChatSDKError } from "@/lib/errors";
|
||||||
|
|
||||||
|
const updateUserSchema = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
role: z.enum(["guest", "regular", "admin"]).optional(),
|
||||||
|
isActive: z.boolean().optional(),
|
||||||
|
email: z.string().email().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
await requireAdmin();
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const page = Number.parseInt(searchParams.get("page") || "1", 10);
|
||||||
|
const limit = Number.parseInt(searchParams.get("limit") || "10", 10);
|
||||||
|
const search = searchParams.get("search") || "";
|
||||||
|
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
const { users, total } = await getAllUsers({ limit, offset, search });
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
users,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
totalPages: Math.ceil(total / limit)
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ChatSDKError) {
|
||||||
|
return error.toResponse();
|
||||||
|
}
|
||||||
|
return new ChatSDKError("offline:admin").toResponse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(request: Request) {
|
||||||
|
try {
|
||||||
|
await requireAdmin();
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { id, role, isActive, email } = updateUserSchema.parse(body);
|
||||||
|
|
||||||
|
const result = await updateUser({ id, role, isActive, email });
|
||||||
|
|
||||||
|
if (result.length === 0) {
|
||||||
|
return new ChatSDKError("bad_request:api", "User not found").toResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json({ user: result[0] });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ChatSDKError) {
|
||||||
|
return error.toResponse();
|
||||||
|
}
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return new ChatSDKError("bad_request:api", error.message).toResponse();
|
||||||
|
}
|
||||||
|
return new ChatSDKError("offline:admin").toResponse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: Request) {
|
||||||
|
try {
|
||||||
|
await requireAdmin();
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const id = searchParams.get("id");
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return new ChatSDKError("bad_request:api", "User ID is required").toResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteUserById({ id });
|
||||||
|
|
||||||
|
return Response.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ChatSDKError) {
|
||||||
|
return error.toResponse();
|
||||||
|
}
|
||||||
|
return new ChatSDKError("offline:admin").toResponse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
205
app/api/admin/volcano-video-models/route.ts
Normal file
205
app/api/admin/volcano-video-models/route.ts
Normal file
@ -0,0 +1,205 @@
|
|||||||
|
/**
|
||||||
|
* 火山引擎视频模型快速配置API
|
||||||
|
* 用于批量添加预设模型
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { auth } from '@/app/(auth)/auth';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { aiModel } from '@/lib/db/schema';
|
||||||
|
import { VOLCANO_VIDEO_MODEL_PRESETS } from '@/lib/ai/volcano-video-models';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取火山引擎视频模型预设列表
|
||||||
|
*/
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
if (!session?.user || session.user.type !== 'admin') {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
presets: VOLCANO_VIDEO_MODEL_PRESETS,
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[Volcano Video Models API] GET error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error.message || 'Failed to fetch model presets' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量添加火山引擎视频模型
|
||||||
|
* POST body: {
|
||||||
|
* providerId: string,
|
||||||
|
* modelIds?: string[], // 可选:只添加指定的模型ID
|
||||||
|
* models?: Array<{modelId, displayName, description}> // 可选:自定义模型配置
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
if (!session?.user || session.user.type !== 'admin') {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { providerId, modelIds, models: customModels } = body;
|
||||||
|
|
||||||
|
if (!providerId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Provider ID is required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🆕 如果提供了自定义模型配置(通常只有主模型),需要补充所有子模型
|
||||||
|
let modelsToAdd;
|
||||||
|
if (customModels && Array.isArray(customModels)) {
|
||||||
|
// 检查是否只传了主模型
|
||||||
|
const hasMasterOnly = customModels.length === 1 && customModels[0].modelId === 'volcano-video-master';
|
||||||
|
|
||||||
|
if (hasMasterOnly) {
|
||||||
|
// 🎯 只传了主模型,需要补充所有子模型
|
||||||
|
const masterModelConfig = customModels[0];
|
||||||
|
const masterPreset = VOLCANO_VIDEO_MODEL_PRESETS.find(p => p.modelId === 'volcano-video-master');
|
||||||
|
|
||||||
|
// 获取主模型
|
||||||
|
const masterModel = {
|
||||||
|
...masterPreset,
|
||||||
|
displayName: masterModelConfig.displayName || masterPreset?.displayName || '火山引擎视频',
|
||||||
|
description: masterModelConfig.description || masterPreset?.description || '',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取所有子模型(使用预设配置)
|
||||||
|
const subModels = VOLCANO_VIDEO_MODEL_PRESETS.filter(p => p.isSubModel);
|
||||||
|
|
||||||
|
// 组合:主模型 + 所有子模型
|
||||||
|
modelsToAdd = [masterModel, ...subModels];
|
||||||
|
|
||||||
|
console.log(`[Volcano Video Models] ℹ️ 添加主模型和 ${subModels.length} 个子模型`);
|
||||||
|
} else {
|
||||||
|
// 使用自定义配置,但需要从预设中获取完整的capabilities和defaultParams
|
||||||
|
modelsToAdd = customModels.map(customModel => {
|
||||||
|
let preset = VOLCANO_VIDEO_MODEL_PRESETS.find(p => p.modelId === customModel.modelId);
|
||||||
|
|
||||||
|
if (!preset) {
|
||||||
|
const baseModelId = customModel.modelId.split('-').slice(0, 4).join('-');
|
||||||
|
preset = VOLCANO_VIDEO_MODEL_PRESETS.find(p => p.modelId.startsWith(baseModelId));
|
||||||
|
|
||||||
|
if (preset) {
|
||||||
|
console.log(`[Volcano Video Models] ⚠️ 模型ID "${customModel.modelId}" 未找到精确匹配,使用相似配置: ${preset.modelId}`);
|
||||||
|
} else {
|
||||||
|
console.warn(`[Volcano Video Models] ⚠️ 模型ID "${customModel.modelId}" 未找到预设配置,将使用默认配置`);
|
||||||
|
preset = VOLCANO_VIDEO_MODEL_PRESETS[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...preset,
|
||||||
|
modelId: customModel.modelId,
|
||||||
|
displayName: customModel.displayName || preset?.displayName || customModel.modelId,
|
||||||
|
description: customModel.description || preset?.description || '',
|
||||||
|
};
|
||||||
|
}).filter(Boolean);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 使用预设模型,可以通过modelIds过滤
|
||||||
|
const selectedModelIds = modelIds || VOLCANO_VIDEO_MODEL_PRESETS.map(p => p.modelId);
|
||||||
|
modelsToAdd = VOLCANO_VIDEO_MODEL_PRESETS.filter(p => selectedModelIds.includes(p.modelId));
|
||||||
|
}
|
||||||
|
|
||||||
|
const addedModels = [];
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
|
for (const preset of modelsToAdd) {
|
||||||
|
if (!preset) {
|
||||||
|
errors.push({ modelId: 'unknown', error: 'Invalid model configuration' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 检查模型是否已存在
|
||||||
|
const existing = await db.query.aiModel.findFirst({
|
||||||
|
where: eq(aiModel.modelId, preset.modelId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
errors.push({ modelId: preset.modelId, error: 'Model already exists' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加模型
|
||||||
|
const [newModel] = await db.insert(aiModel).values({
|
||||||
|
providerId,
|
||||||
|
modelId: preset.modelId,
|
||||||
|
displayName: preset.displayName,
|
||||||
|
description: preset.description,
|
||||||
|
supportsVision: preset.supportsVision,
|
||||||
|
supportsImageGen: preset.supportsImageGen,
|
||||||
|
isEnabled: true,
|
||||||
|
isDefault: false,
|
||||||
|
sortOrder: preset.sortOrder,
|
||||||
|
capabilities: preset.capabilities,
|
||||||
|
defaultParams: preset.defaultParams,
|
||||||
|
isSubModel: preset.isSubModel || false, // 🆕 保存子模型标记
|
||||||
|
parentModelId: preset.parentModelId ? null : null, // 🆕 父模型ID(第一次添加时为null,稍后通过UPDATE关联)
|
||||||
|
}).returning();
|
||||||
|
|
||||||
|
addedModels.push(newModel);
|
||||||
|
console.log(`[Volcano Video Models API] ✅ Added model: ${preset.displayName}`);
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`[Volcano Video Models API] ❌ Failed to add ${preset.modelId}:`, error);
|
||||||
|
errors.push({ modelId: preset.modelId, error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🆕 更新子模型的parentModelId,关联到主模型
|
||||||
|
try {
|
||||||
|
// ✅ 查找主模型(isSubModel=false的那个),支持自定义modelId
|
||||||
|
const masterModel = addedModels.find(m => !m.isSubModel);
|
||||||
|
if (masterModel) {
|
||||||
|
// ✅ 查找所有子模型(isSubModel=true)
|
||||||
|
const subModels = addedModels.filter(m => m.isSubModel);
|
||||||
|
|
||||||
|
if (subModels.length > 0) {
|
||||||
|
for (const subModel of subModels) {
|
||||||
|
await db.update(aiModel)
|
||||||
|
.set({ parentModelId: masterModel.id })
|
||||||
|
.where(eq(aiModel.id, subModel.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Volcano Video Models API] ✅ 已关联 ${subModels.length} 个子模型到主模型 (${masterModel.displayName})`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn('[Volcano Video Models API] ⚠️ 未找到主模型,跳过子模型关联');
|
||||||
|
}
|
||||||
|
} catch (updateError) {
|
||||||
|
console.error('[Volcano Video Models API] ⚠️ 更新parentModelId失败:', updateError);
|
||||||
|
// 不影响主流程,仅记录错误
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
addedCount: addedModels.length,
|
||||||
|
addedModels,
|
||||||
|
errors: errors.length > 0 ? errors : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[Volcano Video Models API] POST error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error.message || 'Failed to add models' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
115
app/api/auth-settings/route.ts
Normal file
115
app/api/auth-settings/route.ts
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { authSettings } from "@/lib/db/schema";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
|
||||||
|
// GET /api/auth-settings - 获取认证配置
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
const isAdmin = session?.user?.type === "admin";
|
||||||
|
|
||||||
|
const [settings] = await db.select().from(authSettings).limit(1);
|
||||||
|
|
||||||
|
// 如果没有配置记录,返回默认值
|
||||||
|
if (!settings) {
|
||||||
|
return NextResponse.json({
|
||||||
|
dingTalkAppKey: "",
|
||||||
|
dingTalkAppSecret: "", // 即使是空的,非管理员也不应该看到这个字段名,但因为是空字符串,问题不大
|
||||||
|
isDingTalkEnabled: false,
|
||||||
|
isRegistrationEnabled: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAdmin) {
|
||||||
|
return NextResponse.json(settings);
|
||||||
|
} else {
|
||||||
|
// 非管理员,脱敏返回
|
||||||
|
return NextResponse.json({
|
||||||
|
dingTalkAppKey: settings.dingTalkAppKey,
|
||||||
|
isDingTalkEnabled: settings.isDingTalkEnabled,
|
||||||
|
isRegistrationEnabled: settings.isRegistrationEnabled,
|
||||||
|
// 不返回 dingTalkAppSecret
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Auth Settings API] Get error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to fetch auth settings" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateSchema = z.object({
|
||||||
|
dingTalkAppKey: z.string().optional(),
|
||||||
|
dingTalkAppSecret: z.string().optional(),
|
||||||
|
isDingTalkEnabled: z.boolean().optional(),
|
||||||
|
isRegistrationEnabled: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/auth-settings - 更新认证配置(仅管理员)
|
||||||
|
export async function PUT(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.user.type !== "admin") {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const data = updateSchema.parse(body);
|
||||||
|
|
||||||
|
// 查找现有配置
|
||||||
|
const [existing] = await db.select().from(authSettings).limit(1);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
// 更新现有配置
|
||||||
|
const [updated] = await db
|
||||||
|
.update(authSettings)
|
||||||
|
.set({
|
||||||
|
...data,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
updatedBy: session.user.id,
|
||||||
|
})
|
||||||
|
.where(eq(authSettings.id, existing.id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return NextResponse.json(updated);
|
||||||
|
} else {
|
||||||
|
// 创建新配置
|
||||||
|
const [created] = await db
|
||||||
|
.insert(authSettings)
|
||||||
|
.values({
|
||||||
|
dingTalkAppKey: data.dingTalkAppKey,
|
||||||
|
dingTalkAppSecret: data.dingTalkAppSecret,
|
||||||
|
isDingTalkEnabled: data.isDingTalkEnabled || false,
|
||||||
|
isRegistrationEnabled: data.isRegistrationEnabled ?? true,
|
||||||
|
updatedBy: session.user.id,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return NextResponse.json(created);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Auth Settings API] Update error:", error);
|
||||||
|
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid request data", details: error.errors },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to update auth settings" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
239
app/api/auth/dingtalk/callback/route.ts
Normal file
239
app/api/auth/dingtalk/callback/route.ts
Normal file
@ -0,0 +1,239 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { getAuthSettings, getUser } from "@/lib/db/queries";
|
||||||
|
import { loginToken, user } from "@/lib/db/schema";
|
||||||
|
import { generateUUID } from "@/lib/utils";
|
||||||
|
import { signIn } from "@/app/(auth)/auth";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const code = searchParams.get("code");
|
||||||
|
const state = searchParams.get("state");
|
||||||
|
|
||||||
|
console.log("[DingTalk Callback] Received code:", code?.substring(0, 10) + "...", "state:", state);
|
||||||
|
|
||||||
|
if (!code) {
|
||||||
|
return NextResponse.json({ success: false, error: "Missing code" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = await getAuthSettings();
|
||||||
|
if (!settings.isDingTalkEnabled || !settings.dingTalkAppKey || !settings.dingTalkAppSecret) {
|
||||||
|
return NextResponse.json({ success: false, error: "DingTalk login is not configured" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 获取 userAccessToken
|
||||||
|
console.log("[DingTalk Callback] Getting user access token...");
|
||||||
|
const tokenResponse = await fetch("https://api.dingtalk.com/v1.0/oauth2/userAccessToken", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
clientId: settings.dingTalkAppKey,
|
||||||
|
clientSecret: settings.dingTalkAppSecret,
|
||||||
|
code,
|
||||||
|
grantType: "authorization_code",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const tokenData = await tokenResponse.json();
|
||||||
|
console.log("[DingTalk Callback] Token response:", tokenData.accessToken ? "success" : "failed");
|
||||||
|
|
||||||
|
if (!tokenData.accessToken) {
|
||||||
|
console.error("[DingTalk Callback] Token Error:", tokenData);
|
||||||
|
return NextResponse.json({ success: false, error: "Failed to get access token: " + (tokenData.message || JSON.stringify(tokenData)) }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 获取用户 unionId
|
||||||
|
console.log("[DingTalk Callback] Getting user unionId...");
|
||||||
|
const userMeResponse = await fetch("https://api.dingtalk.com/v1.0/contact/users/me", {
|
||||||
|
headers: {
|
||||||
|
"x-acs-dingtalk-access-token": tokenData.accessToken,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const userMeData = await userMeResponse.json();
|
||||||
|
console.log("[DingTalk Callback] User me response:", userMeData.unionId ? "success" : "failed");
|
||||||
|
|
||||||
|
if (!userMeData.unionId) {
|
||||||
|
console.error("[DingTalk Callback] User Me Error:", userMeData);
|
||||||
|
return NextResponse.json({ success: false, error: "Failed to get user unionId" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 获取应用访问令牌 (用于获取更详细的用户信息)
|
||||||
|
console.log("[DingTalk Callback] Getting app access token...");
|
||||||
|
const appTokenResponse = await fetch(`https://oapi.dingtalk.com/gettoken?appkey=${settings.dingTalkAppKey}&appsecret=${settings.dingTalkAppSecret}`);
|
||||||
|
const appTokenData = await appTokenResponse.json();
|
||||||
|
|
||||||
|
if (!appTokenData.access_token) {
|
||||||
|
console.error("[DingTalk Callback] App Token Error:", appTokenData);
|
||||||
|
return NextResponse.json({ success: false, error: "Failed to get app access token" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 根据 unionId 获取 userId
|
||||||
|
console.log("[DingTalk Callback] Getting userId by unionId...");
|
||||||
|
const userIdResponse = await fetch(`https://oapi.dingtalk.com/topapi/user/getbyunionid?access_token=${appTokenData.access_token}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ unionid: userMeData.unionId }),
|
||||||
|
});
|
||||||
|
const userIdData = await userIdResponse.json();
|
||||||
|
console.log("[DingTalk Callback] UserId response:", userIdData);
|
||||||
|
|
||||||
|
let userInfo: any = {
|
||||||
|
unionId: userMeData.unionId,
|
||||||
|
nick: userMeData.nick,
|
||||||
|
avatarUrl: userMeData.avatarUrl,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 如果能获取到 userId,则获取更详细的用户信息
|
||||||
|
if (userIdData.result && userIdData.result.userid) {
|
||||||
|
console.log("[DingTalk Callback] Getting detailed user info...");
|
||||||
|
const detailResponse = await fetch(`https://oapi.dingtalk.com/topapi/v2/user/get?access_token=${appTokenData.access_token}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ userid: userIdData.result.userid }),
|
||||||
|
});
|
||||||
|
const detailData = await detailResponse.json();
|
||||||
|
|
||||||
|
if (detailData.result) {
|
||||||
|
// 获取部门名称
|
||||||
|
let departmentNames: string[] = [];
|
||||||
|
const deptIdList = detailData.result.dept_id_list || [];
|
||||||
|
|
||||||
|
if (deptIdList.length > 0) {
|
||||||
|
console.log("[DingTalk Callback] Getting department info for dept_ids:", deptIdList);
|
||||||
|
try {
|
||||||
|
// 获取每个部门的详细信息
|
||||||
|
for (const deptId of deptIdList) {
|
||||||
|
const deptResponse = await fetch(`https://oapi.dingtalk.com/topapi/v2/department/get?access_token=${appTokenData.access_token}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ dept_id: deptId }),
|
||||||
|
});
|
||||||
|
const deptData = await deptResponse.json();
|
||||||
|
if (deptData.result && deptData.result.name) {
|
||||||
|
departmentNames.push(deptData.result.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log("[DingTalk Callback] Department names:", departmentNames);
|
||||||
|
} catch (deptError) {
|
||||||
|
console.error("[DingTalk Callback] Failed to get department info:", deptError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
userInfo = {
|
||||||
|
...userInfo,
|
||||||
|
userId: userIdData.result.userid,
|
||||||
|
name: detailData.result.name,
|
||||||
|
email: detailData.result.email,
|
||||||
|
mobile: detailData.result.mobile,
|
||||||
|
avatar: detailData.result.avatar,
|
||||||
|
jobNumber: detailData.result.job_number,
|
||||||
|
title: detailData.result.title, // 职位
|
||||||
|
deptIdList: deptIdList,
|
||||||
|
departmentNames: departmentNames, // 部门名称列表
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[DingTalk Callback] Final user info:", {
|
||||||
|
...userInfo,
|
||||||
|
mobile: "***",
|
||||||
|
departments: userInfo.departmentNames?.join(", ") || "无部门信息"
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. 查找或创建用户
|
||||||
|
// 使用 unionId 作为唯一标识,email 可能为空
|
||||||
|
const email = userInfo.email || `dingtalk_${userInfo.unionId}@dingtalk.local`;
|
||||||
|
console.log("[DingTalk Callback] Checking user:", email);
|
||||||
|
|
||||||
|
// 检查用户是否存在(直接使用 Drizzle 查询,不通过 getUser)
|
||||||
|
let existingUsers;
|
||||||
|
try {
|
||||||
|
existingUsers = await db
|
||||||
|
.select()
|
||||||
|
.from(user)
|
||||||
|
.where(eq(user.email, email));
|
||||||
|
console.log("[DingTalk Callback] User found:", existingUsers.length > 0);
|
||||||
|
if (existingUsers.length > 0) {
|
||||||
|
console.log("[DingTalk Callback] User details:", {
|
||||||
|
id: existingUsers[0].id,
|
||||||
|
email: existingUsers[0].email,
|
||||||
|
name: existingUsers[0].name,
|
||||||
|
isActive: existingUsers[0].isActive
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[DingTalk Callback] Error querying user:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingUsers.length === 0) {
|
||||||
|
// 创建新用户
|
||||||
|
console.log("[DingTalk Callback] Creating new user:", email, "with name:", userInfo.name || userInfo.nick);
|
||||||
|
try {
|
||||||
|
await db.insert(user).values({
|
||||||
|
email,
|
||||||
|
password: "", // 钉钉用户无密码
|
||||||
|
name: userInfo.name || userInfo.nick, // 保存真实姓名或昵称
|
||||||
|
role: "regular",
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
console.log("[DingTalk Callback] User created successfully");
|
||||||
|
// 重新查询用户
|
||||||
|
existingUsers = await db
|
||||||
|
.select()
|
||||||
|
.from(user)
|
||||||
|
.where(eq(user.email, email));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[DingTalk Callback] Error creating user:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 更新现有用户的姓名(如果数据库中为空但钉钉有数据)
|
||||||
|
const existingUser = existingUsers[0];
|
||||||
|
console.log("[DingTalk Callback] Existing user found, current name:", existingUser.name);
|
||||||
|
if (!existingUser.name && (userInfo.name || userInfo.nick)) {
|
||||||
|
console.log("[DingTalk Callback] Updating user name for:", email, "to:", userInfo.name || userInfo.nick);
|
||||||
|
try {
|
||||||
|
await db.update(user)
|
||||||
|
.set({ name: userInfo.name || userInfo.nick })
|
||||||
|
.where(eq(user.email, email));
|
||||||
|
console.log("[DingTalk Callback] User name updated successfully");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[DingTalk Callback] Error updating user name:", error);
|
||||||
|
// 不抛出错误,继续登录流程
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 生成临时登录令牌
|
||||||
|
const token = generateUUID();
|
||||||
|
await db.insert(loginToken).values({
|
||||||
|
token,
|
||||||
|
email,
|
||||||
|
expiresAt: new Date(Date.now() + 5 * 60 * 1000), // 5分钟有效期
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("[DingTalk Callback] Login successful for:", email);
|
||||||
|
|
||||||
|
// 7. 返回成功响应(给 iframe 中的回调页面)
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: "登录成功",
|
||||||
|
user: {
|
||||||
|
email,
|
||||||
|
name: userInfo.name || userInfo.nick,
|
||||||
|
avatar: userInfo.avatar || userInfo.avatarUrl,
|
||||||
|
title: userInfo.title,
|
||||||
|
departments: userInfo.departmentNames || [],
|
||||||
|
},
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[DingTalk Callback] Error:", error);
|
||||||
|
return NextResponse.json({ success: false, error: "Internal server error: " + (error as Error).message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
29
app/api/auth/dingtalk/login/route.ts
Normal file
29
app/api/auth/dingtalk/login/route.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getAuthSettings } from "@/lib/db/queries";
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const settings = await getAuthSettings();
|
||||||
|
|
||||||
|
if (!settings.isDingTalkEnabled || !settings.dingTalkAppKey) {
|
||||||
|
return NextResponse.json({ error: "DingTalk login is not enabled" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 动态构建回调地址
|
||||||
|
const host = request.headers.get("host");
|
||||||
|
const protocol = request.headers.get("x-forwarded-proto") || "http";
|
||||||
|
const baseUrl = `${protocol}://${host}`;
|
||||||
|
const redirectUri = `${baseUrl}/dingtalk-callback.html`;
|
||||||
|
|
||||||
|
const state = Math.random().toString(36).substring(7);
|
||||||
|
|
||||||
|
// 钉钉 OAuth 2.0 授权地址
|
||||||
|
const url = `https://login.dingtalk.com/oauth2/auth?` +
|
||||||
|
`redirect_uri=${encodeURIComponent(redirectUri)}&` +
|
||||||
|
`response_type=code&` +
|
||||||
|
`client_id=${settings.dingTalkAppKey}&` +
|
||||||
|
`scope=openid&` +
|
||||||
|
`state=${state}&` +
|
||||||
|
`prompt=consent`;
|
||||||
|
|
||||||
|
return NextResponse.redirect(url);
|
||||||
|
}
|
||||||
47
app/api/auth/logout/route.ts
Normal file
47
app/api/auth/logout/route.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
const isSecure = process.env.AUTH_URL?.startsWith("https://") ?? false;
|
||||||
|
const domain = process.env.AUTH_URL ? new URL(process.env.AUTH_URL).hostname : undefined;
|
||||||
|
|
||||||
|
// 创建清除 cookie 的响应
|
||||||
|
const response = NextResponse.json({ success: true });
|
||||||
|
|
||||||
|
// 清除所有认证相关的 cookie
|
||||||
|
const cookiesToClear = [
|
||||||
|
"__Secure-authjs.session-token",
|
||||||
|
"__Secure-authjs.callback-url",
|
||||||
|
"__Host-authjs.csrf-token",
|
||||||
|
"authjs.session-token",
|
||||||
|
"authjs.callback-url",
|
||||||
|
"authjs.csrf-token",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const cookieName of cookiesToClear) {
|
||||||
|
// 清除不带 domain 的 cookie
|
||||||
|
response.cookies.set(cookieName, "", {
|
||||||
|
expires: new Date(0),
|
||||||
|
path: "/",
|
||||||
|
secure: isSecure,
|
||||||
|
httpOnly: cookieName.includes("session-token") || cookieName.includes("csrf-token"),
|
||||||
|
sameSite: "lax",
|
||||||
|
});
|
||||||
|
|
||||||
|
// 如果有 domain,也清除带 domain 的 cookie
|
||||||
|
if (domain && domain !== "localhost" && !cookieName.startsWith("__Host-")) {
|
||||||
|
response.cookies.set(cookieName, "", {
|
||||||
|
expires: new Date(0),
|
||||||
|
path: "/",
|
||||||
|
domain: domain,
|
||||||
|
secure: isSecure,
|
||||||
|
httpOnly: cookieName.includes("session-token") || cookieName.includes("csrf-token"),
|
||||||
|
sameSite: "lax",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[Logout API] Cleared all auth cookies");
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
34
app/api/detect-object/route.ts
Normal file
34
app/api/detect-object/route.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const maxDuration = 10;
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
|
||||||
|
// 转发到 Florence-2 后端(本地服务)
|
||||||
|
const response = await fetch("http://localhost:8002/api/detect-object", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Florence API proxy error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
data: null,
|
||||||
|
error: `代理错误: ${error instanceof Error ? error.message : "未知错误"}`,
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
76
app/api/gallery/route.ts
Normal file
76
app/api/gallery/route.ts
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { generatedImage } from "@/lib/db/schema";
|
||||||
|
import { eq, desc, sql } from "drizzle-orm";
|
||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = session.user.id;
|
||||||
|
|
||||||
|
// 获取分页参数
|
||||||
|
const searchParams = request.nextUrl.searchParams;
|
||||||
|
const page = Number.parseInt(searchParams.get("page") || "1");
|
||||||
|
const pageSize = Number.parseInt(searchParams.get("pageSize") || "24");
|
||||||
|
const offset = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
// 查询总数
|
||||||
|
const countResult = await db
|
||||||
|
.select({ count: sql<number>`count(*)::int` })
|
||||||
|
.from(generatedImage)
|
||||||
|
.where(eq(generatedImage.userId, userId));
|
||||||
|
|
||||||
|
const total = countResult[0]?.count || 0;
|
||||||
|
|
||||||
|
// 查询当前用户的所有生成图片(分页)
|
||||||
|
const images = await db
|
||||||
|
.select({
|
||||||
|
id: generatedImage.id,
|
||||||
|
messageId: generatedImage.messageId,
|
||||||
|
url: generatedImage.originalUrl, // 使用原图 URL
|
||||||
|
thumbnailUrl: generatedImage.thumbnailUrl,
|
||||||
|
alt: generatedImage.prompt, // 使用 prompt 作为 alt
|
||||||
|
imageSize: generatedImage.imageSize,
|
||||||
|
createdAt: generatedImage.createdAt,
|
||||||
|
chatId: generatedImage.chatId,
|
||||||
|
})
|
||||||
|
.from(generatedImage)
|
||||||
|
.where(eq(generatedImage.userId, userId))
|
||||||
|
.orderBy(desc(generatedImage.createdAt))
|
||||||
|
.limit(pageSize)
|
||||||
|
.offset(offset);
|
||||||
|
|
||||||
|
// 格式化返回数据
|
||||||
|
const formattedImages = images.map((img) => ({
|
||||||
|
id: img.id,
|
||||||
|
messageId: img.messageId,
|
||||||
|
url: img.url,
|
||||||
|
thumbnailUrl: img.thumbnailUrl,
|
||||||
|
alt: img.alt || "AI生成的图片",
|
||||||
|
imageSize: img.imageSize,
|
||||||
|
createdAt: img.createdAt,
|
||||||
|
chatId: img.chatId,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(total / pageSize);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
images: formattedImages,
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
totalPages,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Gallery API] Error:", error);
|
||||||
|
return NextResponse.json({ error: "Failed to fetch gallery" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
112
app/api/jobs/archive-videos/route.ts
Normal file
112
app/api/jobs/archive-videos/route.ts
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* 视频归档定时任务触发API
|
||||||
|
* 用于 Cron Job 定时调用,执行视频归档任务
|
||||||
|
*
|
||||||
|
* 使用方法:
|
||||||
|
* 1. 配置环境变量 CRON_SECRET
|
||||||
|
* 2. 在 Vercel Cron 或外部 Cron 服务中配置定时触发
|
||||||
|
* 3. 每小时执行一次:0 * * * *
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { runVideoArchiveJob } from "@/lib/jobs/video-archive-job";
|
||||||
|
import { getVideosToArchive } from "@/lib/jobs/video-archive-job";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证请求授权(防止未授权调用)
|
||||||
|
*/
|
||||||
|
function verifyAuthToken(request: Request): boolean {
|
||||||
|
const authHeader = request.headers.get("authorization");
|
||||||
|
const token = authHeader?.replace("Bearer ", "");
|
||||||
|
|
||||||
|
// 与环境变量中的密钥比对
|
||||||
|
const cronSecret = process.env.CRON_SECRET;
|
||||||
|
|
||||||
|
// 如果未配置 CRON_SECRET,则允许所有请求(开发环境)
|
||||||
|
if (!cronSecret) {
|
||||||
|
console.warn("[VideoArchiveAPI] ⚠️ CRON_SECRET 未配置,允许所有请求");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return token === cronSecret;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/jobs/archive-videos
|
||||||
|
* 执行视频归档任务
|
||||||
|
*/
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
// 1. 验证授权
|
||||||
|
if (!verifyAuthToken(request)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Unauthorized" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 执行归档任务
|
||||||
|
const result = await runVideoArchiveJob();
|
||||||
|
|
||||||
|
// 3. 返回成功响应
|
||||||
|
return NextResponse.json({
|
||||||
|
success: result.success,
|
||||||
|
message: result.message,
|
||||||
|
stats: {
|
||||||
|
total: result.total,
|
||||||
|
succeeded: result.succeeded,
|
||||||
|
failed: result.failed,
|
||||||
|
},
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("[VideoArchiveAPI] ❌ Job execution failed:", error);
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: error.message,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/jobs/archive-videos
|
||||||
|
* 查询待归档视频统计
|
||||||
|
*/
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
// 验证授权
|
||||||
|
if (!verifyAuthToken(request)) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询待归档视频
|
||||||
|
const videos = await getVideosToArchive();
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
pendingCount: videos.length,
|
||||||
|
videos: videos.map((v) => ({
|
||||||
|
id: v.id,
|
||||||
|
createdAt: v.createdAt,
|
||||||
|
temporaryVideoUrl: v.temporaryVideoUrl,
|
||||||
|
})),
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("[VideoArchiveAPI] ❌ Query failed:", error);
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: error.message,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
161
app/api/nav-links/route.ts
Normal file
161
app/api/nav-links/route.ts
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { navLinks } from "@/lib/db/schema";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
|
||||||
|
// GET /api/nav-links - 获取所有导航链接 (公开)
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const links = await db
|
||||||
|
.select()
|
||||||
|
.from(navLinks)
|
||||||
|
.where(eq(navLinks.isEnabled, true))
|
||||||
|
.orderBy(navLinks.order);
|
||||||
|
|
||||||
|
return NextResponse.json({ links });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Nav Links API] Get error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to fetch nav links" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createSchema = z.object({
|
||||||
|
name: z.string().min(1).max(64),
|
||||||
|
url: z.string().url(),
|
||||||
|
order: z.number().int().default(0),
|
||||||
|
isEnabled: z.boolean().default(true),
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/nav-links - 创建导航链接(仅管理员)
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.user.type !== "admin") {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const data = createSchema.parse(body);
|
||||||
|
|
||||||
|
const [created] = await db
|
||||||
|
.insert(navLinks)
|
||||||
|
.values({
|
||||||
|
...data,
|
||||||
|
createdBy: session.user.id,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return NextResponse.json(created);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Nav Links API] Create error:", error);
|
||||||
|
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid request data", details: error.errors },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to create nav link" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateSchema = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
name: z.string().min(1).max(64).optional(),
|
||||||
|
url: z.string().url().optional(),
|
||||||
|
order: z.number().int().optional(),
|
||||||
|
isEnabled: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/nav-links - 更新导航链接(仅管理员)
|
||||||
|
export async function PUT(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.user.type !== "admin") {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { id, ...data } = updateSchema.parse(body);
|
||||||
|
|
||||||
|
const [updated] = await db
|
||||||
|
.update(navLinks)
|
||||||
|
.set({
|
||||||
|
...data,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(navLinks.id, id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
return NextResponse.json({ error: "Nav link not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(updated);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Nav Links API] Update error:", error);
|
||||||
|
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid request data", details: error.errors },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to update nav link" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/nav-links - 删除导航链接(仅管理员)
|
||||||
|
export async function DELETE(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.user.type !== "admin") {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const id = searchParams.get("id");
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return NextResponse.json({ error: "ID is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.delete(navLinks).where(eq(navLinks.id, id));
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Nav Links API] Delete error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to delete nav link" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
127
app/api/site-settings/route.ts
Normal file
127
app/api/site-settings/route.ts
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { siteSettings, systemSettings } from "@/lib/db/schema";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
|
||||||
|
// GET /api/site-settings - 获取网站设置 (公开)
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const [settings] = await db.select().from(siteSettings).limit(1);
|
||||||
|
|
||||||
|
// 获取系统通知
|
||||||
|
const [notificationSetting] = await db
|
||||||
|
.select()
|
||||||
|
.from(systemSettings)
|
||||||
|
.where(eq(systemSettings.key, "login_notification"))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const notification = notificationSetting?.value || { enabled: false, title: "", content: "" };
|
||||||
|
|
||||||
|
// 如果没有设置记录,返回默认值
|
||||||
|
const baseSettings = settings || {
|
||||||
|
siteName: "AI Chatbot",
|
||||||
|
siteLogo: null,
|
||||||
|
siteLogoDark: null,
|
||||||
|
favicon: null,
|
||||||
|
showAutoModel: true,
|
||||||
|
usageTimeEnabled: false,
|
||||||
|
usageTimeStart: "08:00",
|
||||||
|
usageTimeEnd: "20:00",
|
||||||
|
usageTimeMessage: "由壹点累了也是需要休息的",
|
||||||
|
};
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
...baseSettings,
|
||||||
|
notification,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Site Settings API] Get error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to fetch site settings" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateSchema = z.object({
|
||||||
|
siteName: z.string().min(1).max(128).optional(),
|
||||||
|
siteLogo: z.string().nullable().optional(),
|
||||||
|
siteLogoDark: z.string().nullable().optional(),
|
||||||
|
favicon: z.string().nullable().optional(),
|
||||||
|
showAutoModel: z.boolean().optional(),
|
||||||
|
usageTimeEnabled: z.boolean().optional(),
|
||||||
|
usageTimeStart: z.string().length(5).optional(),
|
||||||
|
usageTimeEnd: z.string().length(5).optional(),
|
||||||
|
usageTimeMessage: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/site-settings - 更新网站设置(仅管理员)
|
||||||
|
export async function PUT(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.user.type !== "admin") {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const data = updateSchema.parse(body);
|
||||||
|
|
||||||
|
// 查找现有设置
|
||||||
|
const [existing] = await db.select().from(siteSettings).limit(1);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
// 更新现有设置
|
||||||
|
const [updated] = await db
|
||||||
|
.update(siteSettings)
|
||||||
|
.set({
|
||||||
|
...data,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
updatedBy: session.user.id,
|
||||||
|
})
|
||||||
|
.where(eq(siteSettings.id, existing.id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return NextResponse.json(updated);
|
||||||
|
} else {
|
||||||
|
// 创建新设置
|
||||||
|
const [created] = await db
|
||||||
|
.insert(siteSettings)
|
||||||
|
.values({
|
||||||
|
siteName: data.siteName || "AI Chatbot",
|
||||||
|
siteLogo: data.siteLogo,
|
||||||
|
siteLogoDark: data.siteLogoDark,
|
||||||
|
favicon: data.favicon,
|
||||||
|
showAutoModel: data.showAutoModel ?? true,
|
||||||
|
usageTimeEnabled: data.usageTimeEnabled ?? false,
|
||||||
|
usageTimeStart: data.usageTimeStart ?? "08:00",
|
||||||
|
usageTimeEnd: data.usageTimeEnd ?? "20:00",
|
||||||
|
usageTimeMessage: data.usageTimeMessage ?? "由壹点累了也是需要休息的",
|
||||||
|
updatedBy: session.user.id,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return NextResponse.json(created);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Site Settings API] Update error:", error);
|
||||||
|
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid request data", details: error.errors },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to update site settings" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
95
app/api/starter-questions/route.ts
Normal file
95
app/api/starter-questions/route.ts
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { systemSettings } from "@/lib/db/schema";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
|
||||||
|
const SETTING_KEY = "starter_questions";
|
||||||
|
const DEFAULT_QUESTIONS = [
|
||||||
|
// AI图片编辑
|
||||||
|
"帮我优化这张产品图片,突出产品特点和卖点",
|
||||||
|
"请美化这张商品图片,提升视觉吸引力",
|
||||||
|
"帮我调整产品图片的色调和构图",
|
||||||
|
// 市场分析
|
||||||
|
"分析一下当前电商市场的热门趋势和消费者偏好",
|
||||||
|
"帮我分析竞品的产品策略和定价",
|
||||||
|
"请分析目标用户群体的消费习惯",
|
||||||
|
// 文案优化
|
||||||
|
"优化这段产品描述文案,让它更有吸引力和转化力",
|
||||||
|
"帮我改写产品标题,提升点击率",
|
||||||
|
"请优化商品详情页的文案结构",
|
||||||
|
// 卖点优化
|
||||||
|
"提炼这个产品的核心卖点,突出差异化优势",
|
||||||
|
"帮我找出产品的独特价值主张",
|
||||||
|
"请分析产品的竞争优势并提炼卖点",
|
||||||
|
// 视频生成
|
||||||
|
"帮我生成一个产品宣传视频的创意脚本",
|
||||||
|
"请为这个产品设计视频营销方案",
|
||||||
|
"帮我写一个产品演示视频的脚本",
|
||||||
|
];
|
||||||
|
|
||||||
|
// GET: 公开
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const [setting] = await db
|
||||||
|
.select()
|
||||||
|
.from(systemSettings)
|
||||||
|
.where(eq(systemSettings.key, SETTING_KEY))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
let questions = DEFAULT_QUESTIONS;
|
||||||
|
if (setting && Array.isArray(setting.value)) {
|
||||||
|
questions = setting.value as string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(questions);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Starter Questions API] Get error:", error);
|
||||||
|
return NextResponse.json(DEFAULT_QUESTIONS); // 出错时返回默认值
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT: 管理员
|
||||||
|
export async function PUT(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (session?.user?.type !== "admin") {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
if (!Array.isArray(body)) {
|
||||||
|
return NextResponse.json({ error: "Invalid data format" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查找是否存在
|
||||||
|
const [existing] = await db
|
||||||
|
.select()
|
||||||
|
.from(systemSettings)
|
||||||
|
.where(eq(systemSettings.key, SETTING_KEY))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await db
|
||||||
|
.update(systemSettings)
|
||||||
|
.set({
|
||||||
|
value: body,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
updatedBy: session.user.id,
|
||||||
|
})
|
||||||
|
.where(eq(systemSettings.id, existing.id));
|
||||||
|
} else {
|
||||||
|
await db.insert(systemSettings).values({
|
||||||
|
key: SETTING_KEY,
|
||||||
|
value: body,
|
||||||
|
updatedBy: session.user.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Starter Questions API] Update error:", error);
|
||||||
|
return NextResponse.json({ error: "Failed to update" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
40
app/api/user/image-stats/route.ts
Normal file
40
app/api/user/image-stats/route.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { generatedImage } from "@/lib/db/schema";
|
||||||
|
import { eq, sql } from "drizzle-orm";
|
||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
|
||||||
|
// 缓存配置:每天中午12点更新(约12小时缓存)
|
||||||
|
export const revalidate = 43200; // 12小时,单位秒
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = session.user.id;
|
||||||
|
|
||||||
|
// 查询该用户的生图总数
|
||||||
|
const result = await db
|
||||||
|
.select({ count: sql<number>`count(*)::int` })
|
||||||
|
.from(generatedImage)
|
||||||
|
.where(eq(generatedImage.userId, userId));
|
||||||
|
|
||||||
|
const totalImages = result[0]?.count ?? 0;
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
totalImages,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[User Image Stats API] Error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to fetch image stats" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
BIN
app/favicon.ico
Normal file
BIN
app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
328
app/globals.css
Normal file
328
app/globals.css
Normal file
@ -0,0 +1,328 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@import "katex/dist/katex.min.css";
|
||||||
|
|
||||||
|
/* include utility classes in streamdown */
|
||||||
|
@source '../node_modules/streamdown/dist/index.js';
|
||||||
|
|
||||||
|
/* custom variant for setting dark mode programmatically */
|
||||||
|
@custom-variant dark (&:is(.dark, .dark *));
|
||||||
|
|
||||||
|
/* include plugins */
|
||||||
|
@plugin "tailwindcss-animate";
|
||||||
|
@plugin "@tailwindcss/typography";
|
||||||
|
|
||||||
|
/* define design tokens (light mode) */
|
||||||
|
:root {
|
||||||
|
--background: hsl(0 0% 100%);
|
||||||
|
--foreground: hsl(240 10% 3.9%);
|
||||||
|
--card: hsl(0 0% 100%);
|
||||||
|
--card-foreground: hsl(240 10% 3.9%);
|
||||||
|
--popover: hsl(0 0% 100%);
|
||||||
|
--popover-foreground: hsl(240 10% 3.9%);
|
||||||
|
--primary: hsl(240 5.9% 10%);
|
||||||
|
--primary-foreground: hsl(0 0% 98%);
|
||||||
|
--secondary: hsl(240 4.8% 95.9%);
|
||||||
|
--secondary-foreground: hsl(240 5.9% 10%);
|
||||||
|
--muted: hsl(240 4.8% 95.9%);
|
||||||
|
--muted-foreground: hsl(240 3.8% 46.1%);
|
||||||
|
--accent: hsl(240 4.8% 95.9%);
|
||||||
|
--accent-foreground: hsl(240 5.9% 10%);
|
||||||
|
--destructive: hsl(0 84.2% 60.2%);
|
||||||
|
--destructive-foreground: hsl(0 0% 98%);
|
||||||
|
--border: hsl(240 5.9% 90%);
|
||||||
|
--input: hsl(240 5.9% 90%);
|
||||||
|
--ring: hsl(240 10% 3.9%);
|
||||||
|
--chart-1: hsl(12 76% 61%);
|
||||||
|
--chart-2: hsl(173 58% 39%);
|
||||||
|
--chart-3: hsl(197 37% 24%);
|
||||||
|
--chart-4: hsl(43 74% 66%);
|
||||||
|
--chart-5: hsl(27 87% 67%);
|
||||||
|
--sidebar-background: hsl(0 0% 98%);
|
||||||
|
--sidebar-foreground: hsl(240 5.3% 26.1%);
|
||||||
|
--sidebar-primary: hsl(240 5.9% 10%);
|
||||||
|
--sidebar-primary-foreground: hsl(0 0% 98%);
|
||||||
|
--sidebar-accent: hsl(240 4.8% 95.9%);
|
||||||
|
--sidebar-accent-foreground: hsl(240 5.9% 10%);
|
||||||
|
--sidebar-border: hsl(220 13% 91%);
|
||||||
|
--sidebar-ring: hsl(217.2 91.2% 59.8%);
|
||||||
|
/* border radius unit */
|
||||||
|
--radius: 0.5rem;
|
||||||
|
--sidebar: hsl(0 0% 98%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* define design tokens (dark mode) */
|
||||||
|
.dark {
|
||||||
|
--background: hsl(240 10% 3.9%);
|
||||||
|
--foreground: hsl(0 0% 98%);
|
||||||
|
--card: hsl(240 10% 3.9%);
|
||||||
|
--card-foreground: hsl(0 0% 98%);
|
||||||
|
--popover: hsl(240 10% 3.9%);
|
||||||
|
--popover-foreground: hsl(0 0% 98%);
|
||||||
|
--primary: hsl(0 0% 98%);
|
||||||
|
--primary-foreground: hsl(240 5.9% 10%);
|
||||||
|
--secondary: hsl(240 3.7% 15.9%);
|
||||||
|
--secondary-foreground: hsl(0 0% 98%);
|
||||||
|
--muted: hsl(240 3.7% 15.9%);
|
||||||
|
--muted-foreground: hsl(240 5% 64.9%);
|
||||||
|
--accent: hsl(240 3.7% 15.9%);
|
||||||
|
--accent-foreground: hsl(0 0% 98%);
|
||||||
|
--destructive: hsl(0 62.8% 30.6%);
|
||||||
|
--destructive-foreground: hsl(0 0% 98%);
|
||||||
|
--border: hsl(240 3.7% 15.9%);
|
||||||
|
--input: hsl(240 3.7% 15.9%);
|
||||||
|
--ring: hsl(240 4.9% 83.9%);
|
||||||
|
--chart-1: hsl(220 70% 50%);
|
||||||
|
--chart-2: hsl(160 60% 45%);
|
||||||
|
--chart-3: hsl(30 80% 55%);
|
||||||
|
--chart-4: hsl(280 65% 60%);
|
||||||
|
--chart-5: hsl(340 75% 55%);
|
||||||
|
--sidebar-background: hsl(240 5.9% 10%);
|
||||||
|
--sidebar-foreground: hsl(240 4.8% 95.9%);
|
||||||
|
--sidebar-primary: hsl(224.3 76.3% 48%);
|
||||||
|
--sidebar-primary-foreground: hsl(0 0% 100%);
|
||||||
|
--sidebar-accent: hsl(240 3.7% 15.9%);
|
||||||
|
--sidebar-accent-foreground: hsl(240 4.8% 95.9%);
|
||||||
|
--sidebar-border: hsl(240 3.7% 15.9%);
|
||||||
|
--sidebar-ring: hsl(217.2 91.2% 59.8%);
|
||||||
|
--sidebar: hsl(240 5.9% 10%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* define theme */
|
||||||
|
@theme {
|
||||||
|
--font-sans: var(--font-geist);
|
||||||
|
--font-mono: var(--font-geist-mono);
|
||||||
|
|
||||||
|
--breakpoint-toast-mobile: 600px;
|
||||||
|
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-destructive-foreground: var(--destructive-foreground);
|
||||||
|
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
|
||||||
|
--color-sidebar: var(--sidebar-background);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
The default border color has changed to `currentcolor` in Tailwind CSS v4,
|
||||||
|
so we've added these compatibility styles to make sure everything still
|
||||||
|
looks the same as it did with Tailwind CSS v3.
|
||||||
|
|
||||||
|
If we ever want to remove these styles, we need to add an explicit border
|
||||||
|
color utility to any element that depends on these defaults.
|
||||||
|
*/
|
||||||
|
@layer base {
|
||||||
|
*,
|
||||||
|
::after,
|
||||||
|
::before,
|
||||||
|
::backdrop,
|
||||||
|
::file-selector-button {
|
||||||
|
border-color: var(--color-gray-200, currentcolor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility text-balance {
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility -webkit-overflow-scrolling-touch {
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility touch-pan-y {
|
||||||
|
touch-action: pan-y;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility overscroll-behavior-contain {
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
:root {
|
||||||
|
--foreground-rgb: 0, 0, 0;
|
||||||
|
--background-start-rgb: 214, 219, 220;
|
||||||
|
--background-end-rgb: 255, 255, 255;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--foreground-rgb: 255, 255, 255;
|
||||||
|
--background-start-rgb: 0, 0, 0;
|
||||||
|
--background-end-rgb: 0, 0, 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
overflow-x: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton {
|
||||||
|
* {
|
||||||
|
pointer-events: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
*[class^="text-"] {
|
||||||
|
color: transparent;
|
||||||
|
@apply rounded-md bg-foreground/20 select-none animate-pulse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-bg {
|
||||||
|
@apply bg-foreground/10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-div {
|
||||||
|
@apply bg-foreground/20 animate-pulse;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-editor,
|
||||||
|
.cm-gutters {
|
||||||
|
@apply bg-background! dark:bg-zinc-800! outline-hidden! selection:bg-zinc-900!;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ͼo.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground,
|
||||||
|
.ͼo.cm-selectionBackground,
|
||||||
|
.ͼo.cm-content::selection {
|
||||||
|
@apply bg-zinc-200! dark:bg-zinc-900!;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-activeLine,
|
||||||
|
.cm-activeLineGutter {
|
||||||
|
@apply bg-transparent!;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-activeLine {
|
||||||
|
@apply rounded-r-sm!;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-lineNumbers {
|
||||||
|
@apply min-w-7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-foldGutter {
|
||||||
|
@apply min-w-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-lineNumbers .cm-activeLineGutter {
|
||||||
|
@apply rounded-l-sm!;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-highlight {
|
||||||
|
@apply bg-blue-200 hover:bg-blue-300 dark:hover:bg-blue-400/50 dark:text-blue-50 dark:bg-blue-500/40;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* minimal scrollbar styling */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: --alpha(var(--muted-foreground) / 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-corner {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* firefox scrollbar styling */
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--border) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* AI 思考动画 */
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% {
|
||||||
|
background-position: -200% 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: 200% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
134
app/layout.tsx
Normal file
134
app/layout.tsx
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
|
import { Toaster } from "sonner";
|
||||||
|
import { ThemeProvider } from "@/components/theme-provider";
|
||||||
|
|
||||||
|
import "./globals.css";
|
||||||
|
import { SessionProvider } from "next-auth/react";
|
||||||
|
|
||||||
|
import { getSiteSettings } from "@/lib/db/queries";
|
||||||
|
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const settings = await getSiteSettings();
|
||||||
|
|
||||||
|
return {
|
||||||
|
metadataBase: settings.siteUrl ? new URL(settings.siteUrl) : undefined,
|
||||||
|
title: {
|
||||||
|
template: `%s | ${settings.siteName}`,
|
||||||
|
default: settings.siteName,
|
||||||
|
},
|
||||||
|
description: settings.siteDescription || settings.siteName,
|
||||||
|
icons: {
|
||||||
|
icon: settings.siteFavicon || "/favicon.ico",
|
||||||
|
apple: settings.siteFavicon || "/favicon.ico",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const viewport = {
|
||||||
|
maximumScale: 1, // Disable auto-zoom on mobile Safari
|
||||||
|
};
|
||||||
|
|
||||||
|
const geist = Geist({
|
||||||
|
subsets: ["latin"],
|
||||||
|
display: "swap",
|
||||||
|
variable: "--font-geist",
|
||||||
|
});
|
||||||
|
|
||||||
|
const geistMono = Geist_Mono({
|
||||||
|
subsets: ["latin"],
|
||||||
|
display: "swap",
|
||||||
|
variable: "--font-geist-mono",
|
||||||
|
});
|
||||||
|
|
||||||
|
const LIGHT_THEME_COLOR = "hsl(0 0% 100%)";
|
||||||
|
const DARK_THEME_COLOR = "hsl(240deg 10% 3.92%)";
|
||||||
|
const THEME_COLOR_SCRIPT = `\
|
||||||
|
(function() {
|
||||||
|
var html = document.documentElement;
|
||||||
|
var meta = document.querySelector('meta[name="theme-color"]');
|
||||||
|
if (!meta) {
|
||||||
|
meta = document.createElement('meta');
|
||||||
|
meta.setAttribute('name', 'theme-color');
|
||||||
|
document.head.appendChild(meta);
|
||||||
|
}
|
||||||
|
function updateThemeColor() {
|
||||||
|
var isDark = html.classList.contains('dark');
|
||||||
|
meta.setAttribute('content', isDark ? '${DARK_THEME_COLOR}' : '${LIGHT_THEME_COLOR}');
|
||||||
|
}
|
||||||
|
var observer = new MutationObserver(updateThemeColor);
|
||||||
|
observer.observe(html, { attributes: true, attributeFilter: ['class'] });
|
||||||
|
updateThemeColor();
|
||||||
|
})();`;
|
||||||
|
|
||||||
|
const CHUNK_ERROR_RECOVERY_SCRIPT = `\
|
||||||
|
(function() {
|
||||||
|
var hasReloaded = sessionStorage.getItem('chunk-reload-attempted');
|
||||||
|
|
||||||
|
window.addEventListener('error', function(event) {
|
||||||
|
var isChunkError = event.message && (
|
||||||
|
event.message.includes('Failed to load chunk') ||
|
||||||
|
event.message.includes('Loading chunk') ||
|
||||||
|
event.message.includes('/_next/static/chunks/')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isChunkError && !hasReloaded) {
|
||||||
|
console.warn('[ChunkError] 检测到chunk加载失败,自动刷新页面...');
|
||||||
|
sessionStorage.setItem('chunk-reload-attempted', 'true');
|
||||||
|
setTimeout(function() {
|
||||||
|
window.location.reload();
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
// 页面加载成功后清除标记
|
||||||
|
window.addEventListener('load', function() {
|
||||||
|
setTimeout(function() {
|
||||||
|
sessionStorage.removeItem('chunk-reload-attempted');
|
||||||
|
}, 2000);
|
||||||
|
});
|
||||||
|
})();`;
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html
|
||||||
|
className={`${geist.variable} ${geistMono.variable}`}
|
||||||
|
// `next-themes` injects an extra classname to the body element to avoid
|
||||||
|
// visual flicker before hydration. Hence the `suppressHydrationWarning`
|
||||||
|
// prop is necessary to avoid the React hydration mismatch warning.
|
||||||
|
// https://github.com/pacocoursey/next-themes?tab=readme-ov-file#with-app
|
||||||
|
lang="zh-CN"
|
||||||
|
suppressHydrationWarning
|
||||||
|
>
|
||||||
|
<head>
|
||||||
|
<script
|
||||||
|
// biome-ignore lint/security/noDangerouslySetInnerHtml: "Required"
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: THEME_COLOR_SCRIPT,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<script
|
||||||
|
// biome-ignore lint/security/noDangerouslySetInnerHtml: "Required"
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: CHUNK_ERROR_RECOVERY_SCRIPT,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body className="antialiased">
|
||||||
|
<ThemeProvider
|
||||||
|
attribute="class"
|
||||||
|
defaultTheme="system"
|
||||||
|
disableTransitionOnChange
|
||||||
|
enableSystem
|
||||||
|
>
|
||||||
|
<Toaster position="top-center" />
|
||||||
|
<SessionProvider>{children}</SessionProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
artifacts/actions.ts
Normal file
8
artifacts/actions.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { getSuggestionsByDocumentId } from "@/lib/db/queries";
|
||||||
|
|
||||||
|
export async function getSuggestions({ documentId }: { documentId: string }) {
|
||||||
|
const suggestions = await getSuggestionsByDocumentId({ documentId });
|
||||||
|
return suggestions ?? [];
|
||||||
|
}
|
||||||
280
artifacts/code/client.tsx
Normal file
280
artifacts/code/client.tsx
Normal file
@ -0,0 +1,280 @@
|
|||||||
|
import { toast } from "sonner";
|
||||||
|
import { CodeEditor } from "@/components/code-editor";
|
||||||
|
import {
|
||||||
|
Console,
|
||||||
|
type ConsoleOutput,
|
||||||
|
type ConsoleOutputContent,
|
||||||
|
} from "@/components/console";
|
||||||
|
import { Artifact } from "@/components/create-artifact";
|
||||||
|
import {
|
||||||
|
CopyIcon,
|
||||||
|
LogsIcon,
|
||||||
|
MessageIcon,
|
||||||
|
PlayIcon,
|
||||||
|
RedoIcon,
|
||||||
|
UndoIcon,
|
||||||
|
} from "@/components/icons";
|
||||||
|
import { generateUUID } from "@/lib/utils";
|
||||||
|
|
||||||
|
const OUTPUT_HANDLERS = {
|
||||||
|
matplotlib: `
|
||||||
|
import io
|
||||||
|
import base64
|
||||||
|
from matplotlib import pyplot as plt
|
||||||
|
|
||||||
|
# Clear any existing plots
|
||||||
|
plt.clf()
|
||||||
|
plt.close('all')
|
||||||
|
|
||||||
|
# Switch to agg backend
|
||||||
|
plt.switch_backend('agg')
|
||||||
|
|
||||||
|
def setup_matplotlib_output():
|
||||||
|
def custom_show():
|
||||||
|
if plt.gcf().get_size_inches().prod() * plt.gcf().dpi ** 2 > 25_000_000:
|
||||||
|
print("Warning: Plot size too large, reducing quality")
|
||||||
|
plt.gcf().set_dpi(100)
|
||||||
|
|
||||||
|
png_buf = io.BytesIO()
|
||||||
|
plt.savefig(png_buf, format='png')
|
||||||
|
png_buf.seek(0)
|
||||||
|
png_base64 = base64.b64encode(png_buf.read()).decode('utf-8')
|
||||||
|
print(f'data:image/png;base64,{png_base64}')
|
||||||
|
png_buf.close()
|
||||||
|
|
||||||
|
plt.clf()
|
||||||
|
plt.close('all')
|
||||||
|
|
||||||
|
plt.show = custom_show
|
||||||
|
`,
|
||||||
|
basic: `
|
||||||
|
# Basic output capture setup
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
|
||||||
|
function detectRequiredHandlers(code: string): string[] {
|
||||||
|
const handlers: string[] = ["basic"];
|
||||||
|
|
||||||
|
if (code.includes("matplotlib") || code.includes("plt.")) {
|
||||||
|
handlers.push("matplotlib");
|
||||||
|
}
|
||||||
|
|
||||||
|
return handlers;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Metadata = {
|
||||||
|
outputs: ConsoleOutput[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const codeArtifact = new Artifact<"code", Metadata>({
|
||||||
|
kind: "code",
|
||||||
|
description:
|
||||||
|
"Useful for code generation; Code execution is only available for python code.",
|
||||||
|
initialize: ({ setMetadata }) => {
|
||||||
|
setMetadata({
|
||||||
|
outputs: [],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onStreamPart: ({ streamPart, setArtifact }) => {
|
||||||
|
if (streamPart.type === "data-codeDelta") {
|
||||||
|
setArtifact((draftArtifact) => ({
|
||||||
|
...draftArtifact,
|
||||||
|
content: streamPart.data,
|
||||||
|
isVisible:
|
||||||
|
draftArtifact.status === "streaming" &&
|
||||||
|
draftArtifact.content.length > 300 &&
|
||||||
|
draftArtifact.content.length < 310
|
||||||
|
? true
|
||||||
|
: draftArtifact.isVisible,
|
||||||
|
status: "streaming",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
content: ({ metadata, setMetadata, ...props }) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="px-1">
|
||||||
|
<CodeEditor {...props} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{metadata?.outputs && (
|
||||||
|
<Console
|
||||||
|
consoleOutputs={metadata.outputs}
|
||||||
|
setConsoleOutputs={() => {
|
||||||
|
setMetadata({
|
||||||
|
...metadata,
|
||||||
|
outputs: [],
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
icon: <PlayIcon size={18} />,
|
||||||
|
label: "Run",
|
||||||
|
description: "Execute code",
|
||||||
|
onClick: async ({ content, setMetadata }) => {
|
||||||
|
const runId = generateUUID();
|
||||||
|
const outputContent: ConsoleOutputContent[] = [];
|
||||||
|
|
||||||
|
setMetadata((metadata) => ({
|
||||||
|
...metadata,
|
||||||
|
outputs: [
|
||||||
|
...metadata.outputs,
|
||||||
|
{
|
||||||
|
id: runId,
|
||||||
|
contents: [],
|
||||||
|
status: "in_progress",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
// @ts-expect-error - loadPyodide is not defined
|
||||||
|
const currentPyodideInstance = await globalThis.loadPyodide({
|
||||||
|
indexURL: "https://cdn.jsdelivr.net/pyodide/v0.23.4/full/",
|
||||||
|
});
|
||||||
|
|
||||||
|
currentPyodideInstance.setStdout({
|
||||||
|
batched: (output: string) => {
|
||||||
|
outputContent.push({
|
||||||
|
type: output.startsWith("data:image/png;base64")
|
||||||
|
? "image"
|
||||||
|
: "text",
|
||||||
|
value: output,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await currentPyodideInstance.loadPackagesFromImports(content, {
|
||||||
|
messageCallback: (message: string) => {
|
||||||
|
setMetadata((metadata) => ({
|
||||||
|
...metadata,
|
||||||
|
outputs: [
|
||||||
|
...metadata.outputs.filter((output) => output.id !== runId),
|
||||||
|
{
|
||||||
|
id: runId,
|
||||||
|
contents: [{ type: "text", value: message }],
|
||||||
|
status: "loading_packages",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const requiredHandlers = detectRequiredHandlers(content);
|
||||||
|
for (const handler of requiredHandlers) {
|
||||||
|
if (OUTPUT_HANDLERS[handler as keyof typeof OUTPUT_HANDLERS]) {
|
||||||
|
await currentPyodideInstance.runPythonAsync(
|
||||||
|
OUTPUT_HANDLERS[handler as keyof typeof OUTPUT_HANDLERS]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (handler === "matplotlib") {
|
||||||
|
await currentPyodideInstance.runPythonAsync(
|
||||||
|
"setup_matplotlib_output()"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await currentPyodideInstance.runPythonAsync(content);
|
||||||
|
|
||||||
|
setMetadata((metadata) => ({
|
||||||
|
...metadata,
|
||||||
|
outputs: [
|
||||||
|
...metadata.outputs.filter((output) => output.id !== runId),
|
||||||
|
{
|
||||||
|
id: runId,
|
||||||
|
contents: outputContent,
|
||||||
|
status: "completed",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
} catch (error: any) {
|
||||||
|
setMetadata((metadata) => ({
|
||||||
|
...metadata,
|
||||||
|
outputs: [
|
||||||
|
...metadata.outputs.filter((output) => output.id !== runId),
|
||||||
|
{
|
||||||
|
id: runId,
|
||||||
|
contents: [{ type: "text", value: error.message }],
|
||||||
|
status: "failed",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <UndoIcon size={18} />,
|
||||||
|
description: "View Previous version",
|
||||||
|
onClick: ({ handleVersionChange }) => {
|
||||||
|
handleVersionChange("prev");
|
||||||
|
},
|
||||||
|
isDisabled: ({ currentVersionIndex }) => {
|
||||||
|
if (currentVersionIndex === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <RedoIcon size={18} />,
|
||||||
|
description: "View Next version",
|
||||||
|
onClick: ({ handleVersionChange }) => {
|
||||||
|
handleVersionChange("next");
|
||||||
|
},
|
||||||
|
isDisabled: ({ isCurrentVersion }) => {
|
||||||
|
if (isCurrentVersion) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <CopyIcon size={18} />,
|
||||||
|
description: "Copy code to clipboard",
|
||||||
|
onClick: ({ content }) => {
|
||||||
|
navigator.clipboard.writeText(content);
|
||||||
|
toast.success("Copied to clipboard!");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
toolbar: [
|
||||||
|
{
|
||||||
|
icon: <MessageIcon />,
|
||||||
|
description: "Add comments",
|
||||||
|
onClick: ({ sendMessage }) => {
|
||||||
|
sendMessage({
|
||||||
|
role: "user",
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "Add comments to the code snippet for understanding",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <LogsIcon />,
|
||||||
|
description: "Add logs",
|
||||||
|
onClick: ({ sendMessage }) => {
|
||||||
|
sendMessage({
|
||||||
|
role: "user",
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "Add logs to the code snippet for debugging",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
82
artifacts/code/server.ts
Normal file
82
artifacts/code/server.ts
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import { streamObject } from "ai";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { codePrompt, updateDocumentPrompt } from "@/lib/ai/prompts";
|
||||||
|
import { myProvider } from "@/lib/ai/providers";
|
||||||
|
import { getSystemModel } from "@/lib/ai/dynamic-provider";
|
||||||
|
import { createDocumentHandler } from "@/lib/artifacts/server";
|
||||||
|
|
||||||
|
export const codeDocumentHandler = createDocumentHandler<"code">({
|
||||||
|
kind: "code",
|
||||||
|
onCreateDocument: async ({ title, dataStream }) => {
|
||||||
|
let draftContent = "";
|
||||||
|
|
||||||
|
// 优先使用数据库配置的模型
|
||||||
|
const model = (await getSystemModel("artifact")) ?? myProvider.languageModel("artifact-model");
|
||||||
|
|
||||||
|
const { fullStream } = streamObject({
|
||||||
|
model,
|
||||||
|
system: codePrompt,
|
||||||
|
prompt: title,
|
||||||
|
schema: z.object({
|
||||||
|
code: z.string(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
for await (const delta of fullStream) {
|
||||||
|
const { type } = delta;
|
||||||
|
|
||||||
|
if (type === "object") {
|
||||||
|
const { object } = delta;
|
||||||
|
const { code } = object;
|
||||||
|
|
||||||
|
if (code) {
|
||||||
|
dataStream.write({
|
||||||
|
type: "data-codeDelta",
|
||||||
|
data: code ?? "",
|
||||||
|
transient: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
draftContent = code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return draftContent;
|
||||||
|
},
|
||||||
|
onUpdateDocument: async ({ document, description, dataStream }) => {
|
||||||
|
let draftContent = "";
|
||||||
|
|
||||||
|
// 优先使用数据库配置的模型
|
||||||
|
const model = (await getSystemModel("artifact")) ?? myProvider.languageModel("artifact-model");
|
||||||
|
|
||||||
|
const { fullStream } = streamObject({
|
||||||
|
model,
|
||||||
|
system: updateDocumentPrompt(document.content, "code"),
|
||||||
|
prompt: description,
|
||||||
|
schema: z.object({
|
||||||
|
code: z.string(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
for await (const delta of fullStream) {
|
||||||
|
const { type } = delta;
|
||||||
|
|
||||||
|
if (type === "object") {
|
||||||
|
const { object } = delta;
|
||||||
|
const { code } = object;
|
||||||
|
|
||||||
|
if (code) {
|
||||||
|
dataStream.write({
|
||||||
|
type: "data-codeDelta",
|
||||||
|
data: code ?? "",
|
||||||
|
transient: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
draftContent = code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return draftContent;
|
||||||
|
},
|
||||||
|
});
|
||||||
76
artifacts/image/client.tsx
Normal file
76
artifacts/image/client.tsx
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import { toast } from "sonner";
|
||||||
|
import { Artifact } from "@/components/create-artifact";
|
||||||
|
import { CopyIcon, RedoIcon, UndoIcon } from "@/components/icons";
|
||||||
|
import { ImageEditor } from "@/components/image-editor";
|
||||||
|
|
||||||
|
export const imageArtifact = new Artifact({
|
||||||
|
kind: "image",
|
||||||
|
description: "Useful for image generation",
|
||||||
|
onStreamPart: ({ streamPart, setArtifact }) => {
|
||||||
|
if (streamPart.type === "data-imageDelta") {
|
||||||
|
setArtifact((draftArtifact) => ({
|
||||||
|
...draftArtifact,
|
||||||
|
content: streamPart.data,
|
||||||
|
isVisible: true,
|
||||||
|
status: "streaming",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
content: ImageEditor,
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
icon: <UndoIcon size={18} />,
|
||||||
|
description: "View Previous version",
|
||||||
|
onClick: ({ handleVersionChange }) => {
|
||||||
|
handleVersionChange("prev");
|
||||||
|
},
|
||||||
|
isDisabled: ({ currentVersionIndex }) => {
|
||||||
|
if (currentVersionIndex === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <RedoIcon size={18} />,
|
||||||
|
description: "View Next version",
|
||||||
|
onClick: ({ handleVersionChange }) => {
|
||||||
|
handleVersionChange("next");
|
||||||
|
},
|
||||||
|
isDisabled: ({ isCurrentVersion }) => {
|
||||||
|
if (isCurrentVersion) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <CopyIcon size={18} />,
|
||||||
|
description: "Copy image to clipboard",
|
||||||
|
onClick: ({ content }) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.src = `data:image/png;base64,${content}`;
|
||||||
|
|
||||||
|
img.onload = () => {
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = img.width;
|
||||||
|
canvas.height = img.height;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
ctx?.drawImage(img, 0, 0);
|
||||||
|
canvas.toBlob((blob) => {
|
||||||
|
if (blob) {
|
||||||
|
navigator.clipboard.write([
|
||||||
|
new ClipboardItem({ "image/png": blob }),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}, "image/png");
|
||||||
|
};
|
||||||
|
|
||||||
|
toast.success("Copied image to clipboard!");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
toolbar: [],
|
||||||
|
});
|
||||||
115
artifacts/sheet/client.tsx
Normal file
115
artifacts/sheet/client.tsx
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
import { parse, unparse } from "papaparse";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Artifact } from "@/components/create-artifact";
|
||||||
|
import {
|
||||||
|
CopyIcon,
|
||||||
|
LineChartIcon,
|
||||||
|
RedoIcon,
|
||||||
|
SparklesIcon,
|
||||||
|
UndoIcon,
|
||||||
|
} from "@/components/icons";
|
||||||
|
import { SpreadsheetEditor } from "@/components/sheet-editor";
|
||||||
|
|
||||||
|
type Metadata = any;
|
||||||
|
|
||||||
|
export const sheetArtifact = new Artifact<"sheet", Metadata>({
|
||||||
|
kind: "sheet",
|
||||||
|
description: "Useful for working with spreadsheets",
|
||||||
|
initialize: () => null,
|
||||||
|
onStreamPart: ({ setArtifact, streamPart }) => {
|
||||||
|
if (streamPart.type === "data-sheetDelta") {
|
||||||
|
setArtifact((draftArtifact) => ({
|
||||||
|
...draftArtifact,
|
||||||
|
content: streamPart.data,
|
||||||
|
isVisible: true,
|
||||||
|
status: "streaming",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
content: ({ content, currentVersionIndex, onSaveContent, status }) => {
|
||||||
|
return (
|
||||||
|
<SpreadsheetEditor
|
||||||
|
content={content}
|
||||||
|
currentVersionIndex={currentVersionIndex}
|
||||||
|
isCurrentVersion={true}
|
||||||
|
saveContent={onSaveContent}
|
||||||
|
status={status}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
icon: <UndoIcon size={18} />,
|
||||||
|
description: "View Previous version",
|
||||||
|
onClick: ({ handleVersionChange }) => {
|
||||||
|
handleVersionChange("prev");
|
||||||
|
},
|
||||||
|
isDisabled: ({ currentVersionIndex }) => {
|
||||||
|
if (currentVersionIndex === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <RedoIcon size={18} />,
|
||||||
|
description: "View Next version",
|
||||||
|
onClick: ({ handleVersionChange }) => {
|
||||||
|
handleVersionChange("next");
|
||||||
|
},
|
||||||
|
isDisabled: ({ isCurrentVersion }) => {
|
||||||
|
if (isCurrentVersion) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <CopyIcon />,
|
||||||
|
description: "Copy as .csv",
|
||||||
|
onClick: ({ content }) => {
|
||||||
|
const parsed = parse<string[]>(content, { skipEmptyLines: true });
|
||||||
|
|
||||||
|
const nonEmptyRows = parsed.data.filter((row) =>
|
||||||
|
row.some((cell) => cell.trim() !== "")
|
||||||
|
);
|
||||||
|
|
||||||
|
const cleanedCsv = unparse(nonEmptyRows);
|
||||||
|
|
||||||
|
navigator.clipboard.writeText(cleanedCsv);
|
||||||
|
toast.success("Copied csv to clipboard!");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
toolbar: [
|
||||||
|
{
|
||||||
|
description: "Format and clean data",
|
||||||
|
icon: <SparklesIcon />,
|
||||||
|
onClick: ({ sendMessage }) => {
|
||||||
|
sendMessage({
|
||||||
|
role: "user",
|
||||||
|
parts: [
|
||||||
|
{ type: "text", text: "Can you please format and clean the data?" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Analyze and visualize data",
|
||||||
|
icon: <LineChartIcon />,
|
||||||
|
onClick: ({ sendMessage }) => {
|
||||||
|
sendMessage({
|
||||||
|
role: "user",
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "Can you please analyze and visualize the data by creating a new code artifact in python?",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
88
artifacts/sheet/server.ts
Normal file
88
artifacts/sheet/server.ts
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import { streamObject } from "ai";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { sheetPrompt, updateDocumentPrompt } from "@/lib/ai/prompts";
|
||||||
|
import { myProvider } from "@/lib/ai/providers";
|
||||||
|
import { getSystemModel } from "@/lib/ai/dynamic-provider";
|
||||||
|
import { createDocumentHandler } from "@/lib/artifacts/server";
|
||||||
|
|
||||||
|
export const sheetDocumentHandler = createDocumentHandler<"sheet">({
|
||||||
|
kind: "sheet",
|
||||||
|
onCreateDocument: async ({ title, dataStream }) => {
|
||||||
|
let draftContent = "";
|
||||||
|
|
||||||
|
// 优先使用数据库配置的模型
|
||||||
|
const model = (await getSystemModel("artifact")) ?? myProvider.languageModel("artifact-model");
|
||||||
|
|
||||||
|
const { fullStream } = streamObject({
|
||||||
|
model,
|
||||||
|
system: sheetPrompt,
|
||||||
|
prompt: title,
|
||||||
|
schema: z.object({
|
||||||
|
csv: z.string().describe("CSV data"),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
for await (const delta of fullStream) {
|
||||||
|
const { type } = delta;
|
||||||
|
|
||||||
|
if (type === "object") {
|
||||||
|
const { object } = delta;
|
||||||
|
const { csv } = object;
|
||||||
|
|
||||||
|
if (csv) {
|
||||||
|
dataStream.write({
|
||||||
|
type: "data-sheetDelta",
|
||||||
|
data: csv,
|
||||||
|
transient: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
draftContent = csv;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dataStream.write({
|
||||||
|
type: "data-sheetDelta",
|
||||||
|
data: draftContent,
|
||||||
|
transient: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return draftContent;
|
||||||
|
},
|
||||||
|
onUpdateDocument: async ({ document, description, dataStream }) => {
|
||||||
|
let draftContent = "";
|
||||||
|
|
||||||
|
// 优先使用数据库配置的模型
|
||||||
|
const model = (await getSystemModel("artifact")) ?? myProvider.languageModel("artifact-model");
|
||||||
|
|
||||||
|
const { fullStream } = streamObject({
|
||||||
|
model,
|
||||||
|
system: updateDocumentPrompt(document.content, "sheet"),
|
||||||
|
prompt: description,
|
||||||
|
schema: z.object({
|
||||||
|
csv: z.string(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
for await (const delta of fullStream) {
|
||||||
|
const { type } = delta;
|
||||||
|
|
||||||
|
if (type === "object") {
|
||||||
|
const { object } = delta;
|
||||||
|
const { csv } = object;
|
||||||
|
|
||||||
|
if (csv) {
|
||||||
|
dataStream.write({
|
||||||
|
type: "data-sheetDelta",
|
||||||
|
data: csv,
|
||||||
|
transient: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
draftContent = csv;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return draftContent;
|
||||||
|
},
|
||||||
|
});
|
||||||
179
artifacts/text/client.tsx
Normal file
179
artifacts/text/client.tsx
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
import { toast } from "sonner";
|
||||||
|
import { Artifact } from "@/components/create-artifact";
|
||||||
|
import { DiffView } from "@/components/diffview";
|
||||||
|
import { DocumentSkeleton } from "@/components/document-skeleton";
|
||||||
|
import {
|
||||||
|
ClockRewind,
|
||||||
|
CopyIcon,
|
||||||
|
MessageIcon,
|
||||||
|
PenIcon,
|
||||||
|
RedoIcon,
|
||||||
|
UndoIcon,
|
||||||
|
} from "@/components/icons";
|
||||||
|
import { Editor } from "@/components/text-editor";
|
||||||
|
import type { Suggestion } from "@/lib/db/schema";
|
||||||
|
import { getSuggestions } from "../actions";
|
||||||
|
|
||||||
|
type TextArtifactMetadata = {
|
||||||
|
suggestions: Suggestion[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const textArtifact = new Artifact<"text", TextArtifactMetadata>({
|
||||||
|
kind: "text",
|
||||||
|
description: "Useful for text content, like drafting essays and emails.",
|
||||||
|
initialize: async ({ documentId, setMetadata }) => {
|
||||||
|
const suggestions = await getSuggestions({ documentId });
|
||||||
|
|
||||||
|
setMetadata({
|
||||||
|
suggestions,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onStreamPart: ({ streamPart, setMetadata, setArtifact }) => {
|
||||||
|
if (streamPart.type === "data-suggestion") {
|
||||||
|
setMetadata((metadata) => {
|
||||||
|
return {
|
||||||
|
suggestions: [...metadata.suggestions, streamPart.data],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (streamPart.type === "data-textDelta") {
|
||||||
|
setArtifact((draftArtifact) => {
|
||||||
|
return {
|
||||||
|
...draftArtifact,
|
||||||
|
content: draftArtifact.content + streamPart.data,
|
||||||
|
isVisible:
|
||||||
|
draftArtifact.status === "streaming" &&
|
||||||
|
draftArtifact.content.length > 400 &&
|
||||||
|
draftArtifact.content.length < 450
|
||||||
|
? true
|
||||||
|
: draftArtifact.isVisible,
|
||||||
|
status: "streaming",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
content: ({
|
||||||
|
mode,
|
||||||
|
status,
|
||||||
|
content,
|
||||||
|
isCurrentVersion,
|
||||||
|
currentVersionIndex,
|
||||||
|
onSaveContent,
|
||||||
|
getDocumentContentById,
|
||||||
|
isLoading,
|
||||||
|
metadata,
|
||||||
|
}) => {
|
||||||
|
if (isLoading) {
|
||||||
|
return <DocumentSkeleton artifactKind="text" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "diff") {
|
||||||
|
const oldContent = getDocumentContentById(currentVersionIndex - 1);
|
||||||
|
const newContent = getDocumentContentById(currentVersionIndex);
|
||||||
|
|
||||||
|
return <DiffView newContent={newContent} oldContent={oldContent} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-row px-4 py-8 md:p-20">
|
||||||
|
<Editor
|
||||||
|
content={content}
|
||||||
|
currentVersionIndex={currentVersionIndex}
|
||||||
|
isCurrentVersion={isCurrentVersion}
|
||||||
|
onSaveContent={onSaveContent}
|
||||||
|
status={status}
|
||||||
|
suggestions={metadata ? metadata.suggestions : []}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{metadata?.suggestions && metadata.suggestions.length > 0 ? (
|
||||||
|
<div className="h-dvh w-12 shrink-0 md:hidden" />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
icon: <ClockRewind size={18} />,
|
||||||
|
description: "View changes",
|
||||||
|
onClick: ({ handleVersionChange }) => {
|
||||||
|
handleVersionChange("toggle");
|
||||||
|
},
|
||||||
|
isDisabled: ({ currentVersionIndex }) => {
|
||||||
|
if (currentVersionIndex === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <UndoIcon size={18} />,
|
||||||
|
description: "View Previous version",
|
||||||
|
onClick: ({ handleVersionChange }) => {
|
||||||
|
handleVersionChange("prev");
|
||||||
|
},
|
||||||
|
isDisabled: ({ currentVersionIndex }) => {
|
||||||
|
if (currentVersionIndex === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <RedoIcon size={18} />,
|
||||||
|
description: "View Next version",
|
||||||
|
onClick: ({ handleVersionChange }) => {
|
||||||
|
handleVersionChange("next");
|
||||||
|
},
|
||||||
|
isDisabled: ({ isCurrentVersion }) => {
|
||||||
|
if (isCurrentVersion) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <CopyIcon size={18} />,
|
||||||
|
description: "Copy to clipboard",
|
||||||
|
onClick: ({ content }) => {
|
||||||
|
navigator.clipboard.writeText(content);
|
||||||
|
toast.success("Copied to clipboard!");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
toolbar: [
|
||||||
|
{
|
||||||
|
icon: <PenIcon />,
|
||||||
|
description: "Add final polish",
|
||||||
|
onClick: ({ sendMessage }) => {
|
||||||
|
sendMessage({
|
||||||
|
role: "user",
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "Please add final polish and check for grammar, add section titles for better structure, and ensure everything reads smoothly.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <MessageIcon />,
|
||||||
|
description: "Request suggestions",
|
||||||
|
onClick: ({ sendMessage }) => {
|
||||||
|
sendMessage({
|
||||||
|
role: "user",
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "Please add suggestions you have that could improve the writing.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
80
artifacts/text/server.ts
Normal file
80
artifacts/text/server.ts
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import { smoothStream, streamText } from "ai";
|
||||||
|
import { updateDocumentPrompt } from "@/lib/ai/prompts";
|
||||||
|
import { myProvider } from "@/lib/ai/providers";
|
||||||
|
import { getSystemModel } from "@/lib/ai/dynamic-provider";
|
||||||
|
import { createDocumentHandler } from "@/lib/artifacts/server";
|
||||||
|
|
||||||
|
export const textDocumentHandler = createDocumentHandler<"text">({
|
||||||
|
kind: "text",
|
||||||
|
onCreateDocument: async ({ title, dataStream }) => {
|
||||||
|
let draftContent = "";
|
||||||
|
|
||||||
|
// 优先使用数据库配置的模型
|
||||||
|
const model = (await getSystemModel("artifact")) ?? myProvider.languageModel("artifact-model");
|
||||||
|
|
||||||
|
const { fullStream } = streamText({
|
||||||
|
model,
|
||||||
|
system:
|
||||||
|
"Write about the given topic. Markdown is supported. Use headings wherever appropriate.",
|
||||||
|
experimental_transform: smoothStream({ chunking: "word" }),
|
||||||
|
prompt: title,
|
||||||
|
});
|
||||||
|
|
||||||
|
for await (const delta of fullStream) {
|
||||||
|
const { type } = delta;
|
||||||
|
|
||||||
|
if (type === "text-delta") {
|
||||||
|
const { text } = delta;
|
||||||
|
|
||||||
|
draftContent += text;
|
||||||
|
|
||||||
|
dataStream.write({
|
||||||
|
type: "data-textDelta",
|
||||||
|
data: text,
|
||||||
|
transient: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return draftContent;
|
||||||
|
},
|
||||||
|
onUpdateDocument: async ({ document, description, dataStream }) => {
|
||||||
|
let draftContent = "";
|
||||||
|
|
||||||
|
// 优先使用数据库配置的模型
|
||||||
|
const model = (await getSystemModel("artifact")) ?? myProvider.languageModel("artifact-model");
|
||||||
|
|
||||||
|
const { fullStream } = streamText({
|
||||||
|
model,
|
||||||
|
system: updateDocumentPrompt(document.content, "text"),
|
||||||
|
experimental_transform: smoothStream({ chunking: "word" }),
|
||||||
|
prompt: description,
|
||||||
|
providerOptions: {
|
||||||
|
openai: {
|
||||||
|
prediction: {
|
||||||
|
type: "content",
|
||||||
|
content: document.content,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for await (const delta of fullStream) {
|
||||||
|
const { type } = delta;
|
||||||
|
|
||||||
|
if (type === "text-delta") {
|
||||||
|
const { text } = delta;
|
||||||
|
|
||||||
|
draftContent += text;
|
||||||
|
|
||||||
|
dataStream.write({
|
||||||
|
type: "data-textDelta",
|
||||||
|
data: text,
|
||||||
|
transient: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return draftContent;
|
||||||
|
},
|
||||||
|
});
|
||||||
296
auto-deploy.sh
Executable file
296
auto-deploy.sh
Executable file
@ -0,0 +1,296 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# 自动部署脚本 - 包含密码
|
||||||
|
# 使用方法: ./auto-deploy.sh
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# 服务器配置
|
||||||
|
SERVER="156.225.27.52"
|
||||||
|
PORT="5204"
|
||||||
|
USER="root"
|
||||||
|
PASSWORD="Jsjj@8888"
|
||||||
|
REMOTE_DIR="/var/www/ai-chatbot"
|
||||||
|
|
||||||
|
# 颜色输出
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
echo -e "${GREEN}开始自动部署到服务器...${NC}"
|
||||||
|
|
||||||
|
# 检查是否安装了 sshpass
|
||||||
|
if ! command -v sshpass &> /dev/null; then
|
||||||
|
echo -e "${YELLOW}安装 sshpass...${NC}"
|
||||||
|
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||||
|
# macOS
|
||||||
|
if ! command -v brew &> /dev/null; then
|
||||||
|
echo -e "${RED}请先安装 Homebrew: /bin/bash -c \"\$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\"${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
brew install hudochenkov/sshpass/sshpass
|
||||||
|
else
|
||||||
|
# Linux
|
||||||
|
sudo apt-get update && sudo apt-get install -y sshpass
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# SSH 连接函数
|
||||||
|
ssh_cmd() {
|
||||||
|
sshpass -p "$PASSWORD" ssh -o StrictHostKeyChecking=no -p $PORT $USER@$SERVER "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
scp_cmd() {
|
||||||
|
sshpass -p "$PASSWORD" scp -o StrictHostKeyChecking=no -P $PORT "$1" $USER@$SERVER:"$2"
|
||||||
|
}
|
||||||
|
|
||||||
|
scp_dir() {
|
||||||
|
sshpass -p "$PASSWORD" scp -o StrictHostKeyChecking=no -P $PORT -r "$1" $USER@$SERVER:"$2"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo -e "${GREEN}步骤 1: 检查服务器连接...${NC}"
|
||||||
|
ssh_cmd "echo '连接成功' && uname -a"
|
||||||
|
|
||||||
|
echo -e "${GREEN}步骤 2: 检查并安装必要软件...${NC}"
|
||||||
|
ssh_cmd "
|
||||||
|
# 检查 Node.js
|
||||||
|
if ! command -v node &> /dev/null; then
|
||||||
|
echo '安装 Node.js...'
|
||||||
|
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash
|
||||||
|
export NVM_DIR=\"\$HOME/.nvm\"
|
||||||
|
[ -s \"\$NVM_DIR/nvm.sh\" ] && . \"\$NVM_DIR/nvm.sh\"
|
||||||
|
nvm install 20
|
||||||
|
nvm use 20
|
||||||
|
nvm alias default 20
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 检查 pnpm
|
||||||
|
if ! command -v pnpm &> /dev/null; then
|
||||||
|
echo '安装 pnpm...'
|
||||||
|
npm install -g pnpm
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 检查 PM2
|
||||||
|
if ! command -v pm2 &> /dev/null; then
|
||||||
|
echo '安装 PM2...'
|
||||||
|
npm install -g pm2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 检查 PostgreSQL
|
||||||
|
if ! command -v psql &> /dev/null; then
|
||||||
|
echo '安装 PostgreSQL...'
|
||||||
|
apt-get update -y
|
||||||
|
apt-get install -y postgresql postgresql-contrib
|
||||||
|
systemctl start postgresql
|
||||||
|
systemctl enable postgresql
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 检查 Nginx
|
||||||
|
if ! command -v nginx &> /dev/null; then
|
||||||
|
echo '安装 Nginx...'
|
||||||
|
apt-get install -y nginx
|
||||||
|
systemctl start nginx
|
||||||
|
systemctl enable nginx
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 创建项目目录
|
||||||
|
mkdir -p $REMOTE_DIR
|
||||||
|
mkdir -p /var/log/pm2
|
||||||
|
mkdir -p /root/backups/ai-chatbot
|
||||||
|
"
|
||||||
|
|
||||||
|
echo -e "${GREEN}步骤 3: 配置数据库...${NC}"
|
||||||
|
ssh_cmd "
|
||||||
|
# 检查并创建/更新数据库用户
|
||||||
|
echo '配置数据库用户...'
|
||||||
|
sudo -u postgres psql << 'PSQL'
|
||||||
|
-- 删除已存在的用户(如果存在)
|
||||||
|
DROP USER IF EXISTS chatbot_user;
|
||||||
|
|
||||||
|
-- 创建新用户
|
||||||
|
CREATE USER chatbot_user WITH PASSWORD 'ChatBot@2024!Secure';
|
||||||
|
|
||||||
|
-- 检查数据库是否存在
|
||||||
|
SELECT 1 FROM pg_database WHERE datname='ai_chatbot'\\gexec
|
||||||
|
|
||||||
|
-- 如果不存在则创建
|
||||||
|
DO \\$\\$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT FROM pg_database WHERE datname = 'ai_chatbot') THEN
|
||||||
|
CREATE DATABASE ai_chatbot;
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
\\$\\$;
|
||||||
|
|
||||||
|
-- 授予权限
|
||||||
|
GRANT ALL PRIVILEGES ON DATABASE ai_chatbot TO chatbot_user;
|
||||||
|
|
||||||
|
-- 连接到数据库并授予schema权限
|
||||||
|
\\c ai_chatbot
|
||||||
|
GRANT ALL ON SCHEMA public TO chatbot_user;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO chatbot_user;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO chatbot_user;
|
||||||
|
|
||||||
|
\\q
|
||||||
|
PSQL
|
||||||
|
echo '数据库配置完成'
|
||||||
|
"
|
||||||
|
|
||||||
|
echo -e "${GREEN}步骤 4: 上传项目文件...${NC}"
|
||||||
|
# 创建临时部署包
|
||||||
|
echo "准备部署文件..."
|
||||||
|
tar --exclude='node_modules' \
|
||||||
|
--exclude='.next' \
|
||||||
|
--exclude='.git' \
|
||||||
|
--exclude='.env.local' \
|
||||||
|
--exclude='*.log' \
|
||||||
|
--exclude='deploy.tar.gz' \
|
||||||
|
-czf /tmp/deploy.tar.gz .
|
||||||
|
|
||||||
|
# 上传部署包
|
||||||
|
scp_cmd "/tmp/deploy.tar.gz" "/tmp/"
|
||||||
|
|
||||||
|
# 在服务器上解压
|
||||||
|
ssh_cmd "
|
||||||
|
cd $REMOTE_DIR
|
||||||
|
tar -xzf /tmp/deploy.tar.gz
|
||||||
|
rm /tmp/deploy.tar.gz
|
||||||
|
"
|
||||||
|
|
||||||
|
echo -e "${GREEN}步骤 5: 配置环境变量...${NC}"
|
||||||
|
ssh_cmd "
|
||||||
|
cd $REMOTE_DIR
|
||||||
|
|
||||||
|
# 生成 AUTH_SECRET
|
||||||
|
AUTH_SECRET=\$(openssl rand -base64 32)
|
||||||
|
|
||||||
|
# 创建 .env.local 文件
|
||||||
|
cat > .env.local << EOF
|
||||||
|
POSTGRES_URL=postgresql://chatbot_user:ChatBot@2024!Secure@localhost:5432/ai_chatbot
|
||||||
|
AUTH_SECRET=\$AUTH_SECRET
|
||||||
|
AUTH_URL=http://$SERVER
|
||||||
|
NODE_ENV=production
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo '环境变量文件已创建'
|
||||||
|
"
|
||||||
|
|
||||||
|
echo -e "${GREEN}步骤 6: 安装依赖和构建...${NC}"
|
||||||
|
ssh_cmd "
|
||||||
|
cd $REMOTE_DIR
|
||||||
|
export NVM_DIR=\"\$HOME/.nvm\"
|
||||||
|
[ -s \"\$NVM_DIR/nvm.sh\" ] && . \"\$NVM_DIR/nvm.sh\"
|
||||||
|
|
||||||
|
echo '安装依赖...'
|
||||||
|
pnpm install --production=false
|
||||||
|
|
||||||
|
echo '运行数据库迁移...'
|
||||||
|
pnpm db:migrate || echo '迁移可能已存在,继续...'
|
||||||
|
|
||||||
|
echo '构建项目...'
|
||||||
|
pnpm build
|
||||||
|
"
|
||||||
|
|
||||||
|
echo -e "${GREEN}步骤 7: 配置 PM2...${NC}"
|
||||||
|
# 上传 PM2 配置文件
|
||||||
|
scp_cmd "ecosystem.config.js" "$REMOTE_DIR/"
|
||||||
|
|
||||||
|
ssh_cmd "
|
||||||
|
cd $REMOTE_DIR
|
||||||
|
export NVM_DIR=\"\$HOME/.nvm\"
|
||||||
|
[ -s \"\$NVM_DIR/nvm.sh\" ] && . \"\$NVM_DIR/nvm.sh\"
|
||||||
|
|
||||||
|
# 停止旧进程(如果存在)
|
||||||
|
pm2 delete ai-chatbot 2>/dev/null || true
|
||||||
|
|
||||||
|
# 启动应用
|
||||||
|
pm2 start ecosystem.config.js
|
||||||
|
pm2 save
|
||||||
|
|
||||||
|
# 设置开机自启
|
||||||
|
pm2 startup systemd -u root --hp /root 2>/dev/null || echo 'PM2 startup 已配置'
|
||||||
|
"
|
||||||
|
|
||||||
|
echo -e "${GREEN}步骤 8: 配置 Nginx...${NC}"
|
||||||
|
ssh_cmd "
|
||||||
|
# 创建 Nginx 配置
|
||||||
|
cat > /etc/nginx/sites-available/ai-chatbot << 'NGINX'
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name $SERVER;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://localhost:3000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade \$http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host \$host;
|
||||||
|
proxy_set_header X-Real-IP \$remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||||
|
proxy_cache_bypass \$http_upgrade;
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
proxy_connect_timeout 75s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NGINX
|
||||||
|
|
||||||
|
# 启用站点
|
||||||
|
ln -sf /etc/nginx/sites-available/ai-chatbot /etc/nginx/sites-enabled/
|
||||||
|
|
||||||
|
# 删除默认配置(如果存在)
|
||||||
|
rm -f /etc/nginx/sites-enabled/default
|
||||||
|
|
||||||
|
# 测试配置
|
||||||
|
nginx -t
|
||||||
|
|
||||||
|
# 重启 Nginx
|
||||||
|
systemctl restart nginx
|
||||||
|
"
|
||||||
|
|
||||||
|
echo -e "${GREEN}步骤 9: 配置防火墙...${NC}"
|
||||||
|
ssh_cmd "
|
||||||
|
ufw --force enable
|
||||||
|
ufw allow 80/tcp
|
||||||
|
ufw allow 443/tcp
|
||||||
|
ufw allow 5204/tcp
|
||||||
|
ufw allow 3000/tcp
|
||||||
|
"
|
||||||
|
|
||||||
|
echo -e "${GREEN}步骤 10: 检查部署状态...${NC}"
|
||||||
|
ssh_cmd "
|
||||||
|
echo '=== PM2 状态 ==='
|
||||||
|
pm2 status
|
||||||
|
|
||||||
|
echo ''
|
||||||
|
echo '=== 应用日志(最后20行)==='
|
||||||
|
pm2 logs ai-chatbot --lines 20 --nostream
|
||||||
|
|
||||||
|
echo ''
|
||||||
|
echo '=== Nginx 状态 ==='
|
||||||
|
systemctl status nginx --no-pager -l
|
||||||
|
|
||||||
|
echo ''
|
||||||
|
echo '=== 端口监听 ==='
|
||||||
|
netstat -tulpn | grep -E ':(80|3000|5432)' || ss -tulpn | grep -E ':(80|3000|5432)'
|
||||||
|
"
|
||||||
|
|
||||||
|
# 清理本地临时文件
|
||||||
|
rm -f /tmp/deploy.tar.gz
|
||||||
|
|
||||||
|
echo -e "${GREEN}========================================${NC}"
|
||||||
|
echo -e "${GREEN}部署完成!${NC}"
|
||||||
|
echo -e "${GREEN}========================================${NC}"
|
||||||
|
echo -e "${YELLOW}访问地址: http://$SERVER${NC}"
|
||||||
|
echo -e "${YELLOW}管理员设置: http://$SERVER/admin/settings${NC}"
|
||||||
|
echo -e "${YELLOW}查看日志: ssh -p $PORT $USER@$SERVER 'pm2 logs ai-chatbot'${NC}"
|
||||||
|
echo -e "${YELLOW}查看状态: ssh -p $PORT $USER@$SERVER 'pm2 status'${NC}"
|
||||||
|
echo ""
|
||||||
|
echo -e "${YELLOW}数据库信息:${NC}"
|
||||||
|
echo -e " 数据库名: ai_chatbot"
|
||||||
|
echo -e " 用户名: chatbot_user"
|
||||||
|
echo -e " 密码: ChatBot@2024!Secure"
|
||||||
|
echo ""
|
||||||
|
echo -e "${YELLOW}注意: 请尽快修改数据库密码和服务器密码!${NC}"
|
||||||
|
|
||||||
39
auto_deploy.exp
Executable file
39
auto_deploy.exp
Executable file
@ -0,0 +1,39 @@
|
|||||||
|
#!/usr/bin/expect
|
||||||
|
|
||||||
|
set timeout -1
|
||||||
|
set host "156.225.27.52"
|
||||||
|
set port "5204"
|
||||||
|
set user "root"
|
||||||
|
set password "Jsjj@8888"
|
||||||
|
set local_script "remote_deploy_dreamomni2.sh"
|
||||||
|
set remote_script "/root/remote_deploy_dreamomni2.sh"
|
||||||
|
|
||||||
|
puts "Starting deployment to $host..."
|
||||||
|
|
||||||
|
# Step 1: Upload the deployment script
|
||||||
|
puts "Uploading deployment script..."
|
||||||
|
spawn scp -P $port $local_script $user@$host:$remote_script
|
||||||
|
expect {
|
||||||
|
"Are you sure you want to continue connecting" {
|
||||||
|
send "yes\r"
|
||||||
|
exp_continue
|
||||||
|
}
|
||||||
|
"password:" {
|
||||||
|
send "$password\r"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect eof
|
||||||
|
|
||||||
|
# Step 2: Execute the script remotely
|
||||||
|
puts "Executing deployment script on remote server..."
|
||||||
|
spawn ssh -p $port $user@$host "bash $remote_script"
|
||||||
|
expect {
|
||||||
|
"password:" {
|
||||||
|
send "$password\r"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Keep the session open to show output until the remote command finishes
|
||||||
|
expect eof
|
||||||
|
|
||||||
|
puts "Deployment process finished."
|
||||||
2
backend-florence/app/__init__.py
Normal file
2
backend-florence/app/__init__.py
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
# Florence-2 物体检测 API
|
||||||
|
|
||||||
78
backend-florence/app/main.py
Normal file
78
backend-florence/app/main.py
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from pydantic import BaseModel
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
from app.services.qwen_service import get_qwen_service
|
||||||
|
|
||||||
|
app = FastAPI(title="Qwen2-VL 物体识别 API")
|
||||||
|
|
||||||
|
# CORS 配置
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["https://ai.yydd.ai", "http://localhost:3000"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# 获取 Qwen 服务(全局单例)
|
||||||
|
qwen_service = get_qwen_service()
|
||||||
|
|
||||||
|
class DetectRequest(BaseModel):
|
||||||
|
imageUrl: str
|
||||||
|
clickX: int
|
||||||
|
clickY: int
|
||||||
|
imageIndex: int
|
||||||
|
imageWidth: int
|
||||||
|
imageHeight: int
|
||||||
|
|
||||||
|
class DetectResponse(BaseModel):
|
||||||
|
success: bool
|
||||||
|
data: dict | None
|
||||||
|
error: str | None
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
async def startup_event():
|
||||||
|
"""启动时加载模型"""
|
||||||
|
print("🚀 正在加载 Qwen2-VL-2B 模型...")
|
||||||
|
await qwen_service.load_model()
|
||||||
|
print("✅ 模型加载完成!")
|
||||||
|
|
||||||
|
@app.post("/api/detect-object", response_model=DetectResponse)
|
||||||
|
async def detect_object(request: DetectRequest):
|
||||||
|
"""检测图片中点击位置的物体"""
|
||||||
|
try:
|
||||||
|
result = await qwen_service.detect_object_at_point(
|
||||||
|
image_url=request.imageUrl,
|
||||||
|
click_x=request.clickX,
|
||||||
|
click_y=request.clickY,
|
||||||
|
image_index=request.imageIndex,
|
||||||
|
image_width=request.imageWidth,
|
||||||
|
image_height=request.imageHeight
|
||||||
|
)
|
||||||
|
|
||||||
|
return DetectResponse(
|
||||||
|
success=True,
|
||||||
|
data=result,
|
||||||
|
error=None
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ 检测失败: {str(e)}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
return DetectResponse(
|
||||||
|
success=False,
|
||||||
|
data=None,
|
||||||
|
error=f"物体识别失败: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health_check():
|
||||||
|
"""健康检查"""
|
||||||
|
return {"status": "ok", "model_loaded": qwen_service.is_loaded}
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=8002)
|
||||||
|
|
||||||
2
backend-florence/app/services/__init__.py
Normal file
2
backend-florence/app/services/__init__.py
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
# Services
|
||||||
|
|
||||||
613
backend-florence/app/services/florence_service.py
Normal file
613
backend-florence/app/services/florence_service.py
Normal file
@ -0,0 +1,613 @@
|
|||||||
|
import torch
|
||||||
|
from transformers import AutoProcessor, AutoModelForCausalLM, MarianMTModel, MarianTokenizer
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
import requests
|
||||||
|
from io import BytesIO
|
||||||
|
import asyncio
|
||||||
|
import re
|
||||||
|
|
||||||
|
class FlorenceService:
|
||||||
|
def __init__(self):
|
||||||
|
self.model = None
|
||||||
|
self.processor = None
|
||||||
|
self.translator_model = None
|
||||||
|
self.translator_tokenizer = None
|
||||||
|
self.is_loaded = False
|
||||||
|
|
||||||
|
# 🚀 自动检测并使用 GPU(如果可用)
|
||||||
|
self.device = "cuda" if torch.cuda.is_available() else "cpu"
|
||||||
|
print(f"🎯 使用设备: {self.device}")
|
||||||
|
|
||||||
|
if self.device == "cuda":
|
||||||
|
print(f"🚀 GPU 加速已启用: {torch.cuda.get_device_name(0)}")
|
||||||
|
print(f"💾 GPU 显存: {torch.cuda.get_device_properties(0).total_memory / 1024**3:.1f} GB")
|
||||||
|
print(f"📊 GPU 数量: {torch.cuda.device_count()}")
|
||||||
|
|
||||||
|
async def load_model(self):
|
||||||
|
"""异步加载模型"""
|
||||||
|
if self.is_loaded:
|
||||||
|
return
|
||||||
|
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
await loop.run_in_executor(None, self._load_model_sync)
|
||||||
|
|
||||||
|
def _load_model_sync(self):
|
||||||
|
"""同步加载模型(在线程池中执行)"""
|
||||||
|
print("📦 加载 Florence-2 模型...")
|
||||||
|
self.model = AutoModelForCausalLM.from_pretrained(
|
||||||
|
"microsoft/Florence-2-base",
|
||||||
|
trust_remote_code=True
|
||||||
|
).to(self.device)
|
||||||
|
|
||||||
|
self.processor = AutoProcessor.from_pretrained(
|
||||||
|
"microsoft/Florence-2-base",
|
||||||
|
trust_remote_code=True
|
||||||
|
)
|
||||||
|
|
||||||
|
print("✅ Florence-2 模型加载完成")
|
||||||
|
|
||||||
|
# 加载英中翻译模型
|
||||||
|
print("📦 加载英中翻译模型...")
|
||||||
|
translator_name = "Helsinki-NLP/opus-mt-en-zh"
|
||||||
|
self.translator_tokenizer = MarianTokenizer.from_pretrained(translator_name)
|
||||||
|
self.translator_model = MarianMTModel.from_pretrained(translator_name).to(self.device)
|
||||||
|
|
||||||
|
self.is_loaded = True
|
||||||
|
print("✅ 翻译模型加载完成")
|
||||||
|
|
||||||
|
async def detect_object_at_point(
|
||||||
|
self,
|
||||||
|
image_url: str,
|
||||||
|
click_x: int,
|
||||||
|
click_y: int,
|
||||||
|
image_index: int,
|
||||||
|
image_width: int,
|
||||||
|
image_height: int
|
||||||
|
) -> dict:
|
||||||
|
"""检测点击位置的物体(使用大区域裁剪 + 详细描述)"""
|
||||||
|
|
||||||
|
# 1. 下载完整图片
|
||||||
|
print(f"📥 下载图片: {image_url}")
|
||||||
|
response = requests.get(image_url, timeout=10)
|
||||||
|
image = Image.open(BytesIO(response.content)).convert("RGB")
|
||||||
|
actual_width, actual_height = image.size
|
||||||
|
|
||||||
|
print(f"📊 图片信息:")
|
||||||
|
print(f" - 原始尺寸: {actual_width}x{actual_height}")
|
||||||
|
print(f" - 传入尺寸: {image_width}x{image_height}")
|
||||||
|
print(f" - 点击坐标: ({click_x}, {click_y})")
|
||||||
|
|
||||||
|
# 2. 在图片上绘制标注点,让模型能"看到"用户的标注
|
||||||
|
marked_image = image.copy()
|
||||||
|
draw = ImageDraw.Draw(marked_image)
|
||||||
|
|
||||||
|
# 绘制醒目的标注标记(红色圆圈 + 十字)
|
||||||
|
marker_size = 30
|
||||||
|
# 外圈(红色)
|
||||||
|
draw.ellipse(
|
||||||
|
[click_x - marker_size, click_y - marker_size,
|
||||||
|
click_x + marker_size, click_y + marker_size],
|
||||||
|
outline=(255, 0, 0),
|
||||||
|
width=5
|
||||||
|
)
|
||||||
|
# 十字线(红色)
|
||||||
|
draw.line(
|
||||||
|
[click_x - marker_size - 10, click_y, click_x + marker_size + 10, click_y],
|
||||||
|
fill=(255, 0, 0),
|
||||||
|
width=5
|
||||||
|
)
|
||||||
|
draw.line(
|
||||||
|
[click_x, click_y - marker_size - 10, click_x, click_y + marker_size + 10],
|
||||||
|
fill=(255, 0, 0),
|
||||||
|
width=5
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"✏️ 已在图片上绘制标注点: ({click_x}, {click_y})")
|
||||||
|
|
||||||
|
# 3. 使用标记后的完整图片进行物体检测
|
||||||
|
print("🔍 正在检测标记后的完整图片...")
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
detected_object = await loop.run_in_executor(
|
||||||
|
None,
|
||||||
|
self._detect_object_at_point_sync,
|
||||||
|
marked_image,
|
||||||
|
click_x,
|
||||||
|
click_y
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. 翻译成中文(如果是英文)
|
||||||
|
description_cn = await self._translate_to_chinese(detected_object)
|
||||||
|
|
||||||
|
# 4. 格式化返回结果(无图标,纯文本)
|
||||||
|
formatted_text = f"图{image_index}: {description_cn}"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"description": description_cn,
|
||||||
|
"descriptionEn": detected_object,
|
||||||
|
"confidence": 0.95, # Florence-2 不返回置信度,固定值
|
||||||
|
"imageIndex": image_index,
|
||||||
|
"formattedText": formatted_text
|
||||||
|
}
|
||||||
|
|
||||||
|
def _detect_object_at_point_sync(self, image: Image.Image, click_x: int, click_y: int) -> str:
|
||||||
|
"""智能裁剪策略:聚焦在点击位置的具体物品,而不是整个人物"""
|
||||||
|
|
||||||
|
# 步骤1:使用物体检测找到点击位置的物体边界框
|
||||||
|
print("🔍 步骤1: 检测所有物体...")
|
||||||
|
bbox = self._find_object_bbox(image, click_x, click_y)
|
||||||
|
|
||||||
|
if bbox:
|
||||||
|
x1, y1, x2, y2 = bbox
|
||||||
|
bbox_width = x2 - x1
|
||||||
|
bbox_height = y2 - y1
|
||||||
|
|
||||||
|
print(f"📦 检测到物体边界框: ({x1:.0f}, {y1:.0f}) -> ({x2:.0f}, {y2:.0f})")
|
||||||
|
print(f" 尺寸: {bbox_width:.0f}x{bbox_height:.0f}")
|
||||||
|
|
||||||
|
# 判断:如果是大物体(人物),使用点击点周围的固定区域
|
||||||
|
# 如果是小物体,使用完整边界框
|
||||||
|
if bbox_width > 400 or bbox_height > 600:
|
||||||
|
# 大物体(可能是人物):以点击点为中心裁剪固定区域
|
||||||
|
print(" → 检测到大物体(人物),聚焦点击位置")
|
||||||
|
crop_size = 300
|
||||||
|
x1 = max(0, click_x - crop_size // 2)
|
||||||
|
y1 = max(0, click_y - crop_size // 2)
|
||||||
|
x2 = min(image.width, click_x + crop_size // 2)
|
||||||
|
y2 = min(image.height, click_y + crop_size // 2)
|
||||||
|
|
||||||
|
print(f"✂️ 聚焦裁剪: ({x1}, {y1}) -> ({x2}, {y2}), 尺寸: {x2-x1}x{y2-y1}")
|
||||||
|
else:
|
||||||
|
# 小物体:使用完整边界框+扩展
|
||||||
|
print(" → 检测到小物体,使用完整边界框")
|
||||||
|
padding = 0.15
|
||||||
|
pad_x = int(bbox_width * padding)
|
||||||
|
pad_y = int(bbox_height * padding)
|
||||||
|
|
||||||
|
x1 = max(0, int(x1 - pad_x))
|
||||||
|
y1 = max(0, int(y1 - pad_y))
|
||||||
|
x2 = min(image.width, int(x2 + pad_x))
|
||||||
|
y2 = min(image.height, int(y2 + pad_y))
|
||||||
|
|
||||||
|
print(f"✂️ 扩展边界框: ({x1}, {y1}) -> ({x2}, {y2})")
|
||||||
|
|
||||||
|
cropped_region = image.crop((x1, y1, x2, y2))
|
||||||
|
else:
|
||||||
|
# 如果没有检测到物体,使用标注点周围的中等区域
|
||||||
|
print("⚠️ 未检测到物体边界框,使用标注点周围区域")
|
||||||
|
crop_size = 300
|
||||||
|
x1 = max(0, click_x - crop_size // 2)
|
||||||
|
y1 = max(0, click_y - crop_size // 2)
|
||||||
|
x2 = min(image.width, click_x + crop_size // 2)
|
||||||
|
y2 = min(image.height, click_y + crop_size // 2)
|
||||||
|
|
||||||
|
print(f"✂️ 裁剪区域: ({x1}, {y1}) -> ({x2}, {y2})")
|
||||||
|
cropped_region = image.crop((x1, y1, x2, y2))
|
||||||
|
|
||||||
|
# 步骤2:对裁剪区域进行详细描述
|
||||||
|
print("🔍 步骤2: 生成详细描述...")
|
||||||
|
description = self._describe_region(cropped_region)
|
||||||
|
|
||||||
|
print(f"✅ 最终识别: {description}")
|
||||||
|
return description
|
||||||
|
|
||||||
|
def _find_object_bbox(self, image: Image.Image, click_x: int, click_y: int):
|
||||||
|
"""使用物体检测找到点击位置的物体边界框"""
|
||||||
|
import time
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
prompt = "<OD>"
|
||||||
|
|
||||||
|
inputs = self.processor(
|
||||||
|
text=prompt,
|
||||||
|
images=image,
|
||||||
|
return_tensors="pt"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 显式移动所有tensor到GPU
|
||||||
|
inputs = {k: v.to(self.device) for k, v in inputs.items()}
|
||||||
|
|
||||||
|
print(f" → 推理设备: {next(self.model.parameters()).device}")
|
||||||
|
|
||||||
|
with torch.no_grad():
|
||||||
|
generated_ids = self.model.generate(
|
||||||
|
input_ids=inputs["input_ids"],
|
||||||
|
pixel_values=inputs["pixel_values"],
|
||||||
|
max_new_tokens=1024,
|
||||||
|
num_beams=3,
|
||||||
|
do_sample=False
|
||||||
|
)
|
||||||
|
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
print(f" → 物体检测耗时: {elapsed:.2f}秒")
|
||||||
|
|
||||||
|
generated_text = self.processor.batch_decode(
|
||||||
|
generated_ids,
|
||||||
|
skip_special_tokens=False
|
||||||
|
)[0]
|
||||||
|
|
||||||
|
parsed_results = self.processor.post_process_generation(
|
||||||
|
generated_text,
|
||||||
|
task="<OD>",
|
||||||
|
image_size=(image.width, image.height)
|
||||||
|
)
|
||||||
|
|
||||||
|
if parsed_results and '<OD>' in parsed_results:
|
||||||
|
od_result = parsed_results['<OD>']
|
||||||
|
bboxes = od_result.get('bboxes', [])
|
||||||
|
labels = od_result.get('labels', [])
|
||||||
|
|
||||||
|
print(f" 检测到 {len(bboxes)} 个物体")
|
||||||
|
|
||||||
|
# 找到包含点击点的边界框
|
||||||
|
for i, (bbox, label) in enumerate(zip(bboxes, labels)):
|
||||||
|
x1, y1, x2, y2 = bbox
|
||||||
|
if x1 <= click_x <= x2 and y1 <= click_y <= y2:
|
||||||
|
print(f" ✓ 找到匹配物体 {i+1}: {label}")
|
||||||
|
return bbox
|
||||||
|
|
||||||
|
# 如果没有包含的,找最近的
|
||||||
|
print(" 未找到包含点击点的物体,寻找最近的...")
|
||||||
|
min_distance = float('inf')
|
||||||
|
closest_bbox = None
|
||||||
|
|
||||||
|
for bbox in bboxes:
|
||||||
|
x1, y1, x2, y2 = bbox
|
||||||
|
center_x = (x1 + x2) / 2
|
||||||
|
center_y = (y1 + y2) / 2
|
||||||
|
distance = ((center_x - click_x) ** 2 + (center_y - click_y) ** 2) ** 0.5
|
||||||
|
|
||||||
|
if distance < min_distance:
|
||||||
|
min_distance = distance
|
||||||
|
closest_bbox = bbox
|
||||||
|
|
||||||
|
if closest_bbox:
|
||||||
|
print(f" ✓ 最近物体距离: {min_distance:.0f}px")
|
||||||
|
return closest_bbox
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _describe_region(self, region_image: Image.Image) -> str:
|
||||||
|
"""对区域图像进行详细描述"""
|
||||||
|
import time
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
prompt = "<MORE_DETAILED_CAPTION>"
|
||||||
|
|
||||||
|
inputs = self.processor(
|
||||||
|
text=prompt,
|
||||||
|
images=region_image,
|
||||||
|
return_tensors="pt"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 显式移动所有tensor到GPU
|
||||||
|
inputs = {k: v.to(self.device) for k, v in inputs.items()}
|
||||||
|
|
||||||
|
with torch.no_grad():
|
||||||
|
generated_ids = self.model.generate(
|
||||||
|
input_ids=inputs["input_ids"],
|
||||||
|
pixel_values=inputs["pixel_values"],
|
||||||
|
max_new_tokens=50,
|
||||||
|
num_beams=3,
|
||||||
|
do_sample=False
|
||||||
|
)
|
||||||
|
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
print(f" → 描述生成耗时: {elapsed:.2f}秒")
|
||||||
|
|
||||||
|
generated_text = self.processor.batch_decode(
|
||||||
|
generated_ids,
|
||||||
|
skip_special_tokens=True
|
||||||
|
)[0]
|
||||||
|
|
||||||
|
print(f" 原始描述: {generated_text}")
|
||||||
|
|
||||||
|
# 提取核心物品名称
|
||||||
|
description = self._extract_item_name(generated_text)
|
||||||
|
|
||||||
|
return description
|
||||||
|
|
||||||
|
def _extract_item_name(self, description: str) -> str:
|
||||||
|
"""从详细描述中提取核心物品名称"""
|
||||||
|
# 移除常见的前缀短语(按长度排序,先匹配长的)
|
||||||
|
remove_phrases = [
|
||||||
|
"the image is a promotional poster ",
|
||||||
|
"the image is a portrait ",
|
||||||
|
"this image shows ",
|
||||||
|
"the image shows ",
|
||||||
|
"the image is a ",
|
||||||
|
"the image is ",
|
||||||
|
"a close-up of ",
|
||||||
|
"a close up of ",
|
||||||
|
"close-up of ",
|
||||||
|
"close up of ",
|
||||||
|
"an image of ",
|
||||||
|
"a picture of ",
|
||||||
|
"a photo of ",
|
||||||
|
"there is a ",
|
||||||
|
"there is ",
|
||||||
|
"this is a ",
|
||||||
|
"this is ",
|
||||||
|
"it is a ",
|
||||||
|
"it is ",
|
||||||
|
]
|
||||||
|
|
||||||
|
description_lower = description.lower().strip()
|
||||||
|
|
||||||
|
# 持续删除前缀,直到没有匹配的
|
||||||
|
changed = True
|
||||||
|
while changed:
|
||||||
|
changed = False
|
||||||
|
for phrase in remove_phrases:
|
||||||
|
if description_lower.startswith(phrase):
|
||||||
|
description_lower = description_lower[len(phrase):].strip()
|
||||||
|
changed = True
|
||||||
|
break
|
||||||
|
|
||||||
|
# 取前4-5个词作为物品名称
|
||||||
|
words = description_lower.split()
|
||||||
|
|
||||||
|
if len(words) >= 4:
|
||||||
|
item_name = " ".join(words[:4])
|
||||||
|
elif len(words) >= 2:
|
||||||
|
item_name = " ".join(words[:2])
|
||||||
|
else:
|
||||||
|
item_name = description_lower
|
||||||
|
|
||||||
|
return item_name.strip()
|
||||||
|
|
||||||
|
def _detect_with_context(self, image: Image.Image) -> str:
|
||||||
|
"""使用大区域上下文识别主要物体(在线程池中执行)"""
|
||||||
|
# 使用 <MORE_DETAILED_CAPTION> 生成详细描述,包含颜色、材质等
|
||||||
|
prompt = "<MORE_DETAILED_CAPTION>"
|
||||||
|
|
||||||
|
print(f"🎯 使用 prompt: {prompt}")
|
||||||
|
|
||||||
|
inputs = self.processor(
|
||||||
|
text=prompt,
|
||||||
|
images=image,
|
||||||
|
return_tensors="pt"
|
||||||
|
).to(self.device)
|
||||||
|
|
||||||
|
# 生成描述
|
||||||
|
with torch.no_grad():
|
||||||
|
generated_ids = self.model.generate(
|
||||||
|
input_ids=inputs["input_ids"],
|
||||||
|
pixel_values=inputs["pixel_values"],
|
||||||
|
max_new_tokens=40, # 增加长度以获取更多细节
|
||||||
|
num_beams=3,
|
||||||
|
do_sample=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# 解码结果
|
||||||
|
generated_text = self.processor.batch_decode(
|
||||||
|
generated_ids,
|
||||||
|
skip_special_tokens=False
|
||||||
|
)[0]
|
||||||
|
|
||||||
|
# 提取描述(去除提示词和特殊标记)
|
||||||
|
description = generated_text.replace(prompt, "").strip()
|
||||||
|
description = description.replace("</s>", "").replace("<s>", "").strip()
|
||||||
|
description = " ".join(description.split())
|
||||||
|
|
||||||
|
# 🎯 提取核心信息(颜色+物体)
|
||||||
|
description = self._extract_core_description(description)
|
||||||
|
|
||||||
|
print(f"✅ 识别结果(带上下文): {description}")
|
||||||
|
return description
|
||||||
|
|
||||||
|
def _detect_region_sync(self, image: Image.Image, bbox: tuple) -> str:
|
||||||
|
"""使用区域标注识别完整图片中的指定区域(在线程池中执行)"""
|
||||||
|
# 使用 <REGION_TO_CAPTION> 任务,识别完整图片中的指定区域
|
||||||
|
# bbox 格式:(x1, y1, x2, y2),归一化到 [0, 1000]
|
||||||
|
x1, y1, x2, y2 = bbox
|
||||||
|
prompt = f"<REGION_TO_CAPTION><loc_{x1}><loc_{y1}><loc_{x2}><loc_{y2}>"
|
||||||
|
|
||||||
|
print(f"🎯 使用 prompt: {prompt}")
|
||||||
|
|
||||||
|
inputs = self.processor(
|
||||||
|
text=prompt,
|
||||||
|
images=image,
|
||||||
|
return_tensors="pt"
|
||||||
|
).to(self.device)
|
||||||
|
|
||||||
|
# 生成描述
|
||||||
|
with torch.no_grad():
|
||||||
|
generated_ids = self.model.generate(
|
||||||
|
input_ids=inputs["input_ids"],
|
||||||
|
pixel_values=inputs["pixel_values"],
|
||||||
|
max_new_tokens=30,
|
||||||
|
num_beams=3,
|
||||||
|
do_sample=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# 解码结果
|
||||||
|
generated_text = self.processor.batch_decode(
|
||||||
|
generated_ids,
|
||||||
|
skip_special_tokens=False
|
||||||
|
)[0]
|
||||||
|
|
||||||
|
# 提取描述(去除提示词和特殊标记)
|
||||||
|
# Florence-2 返回格式: <REGION_TO_CAPTION>description</s>
|
||||||
|
description = generated_text.replace("<REGION_TO_CAPTION>", "").strip()
|
||||||
|
|
||||||
|
# 清理特殊标记和位置标记
|
||||||
|
description = re.sub(r'<loc_\d+>', '', description)
|
||||||
|
description = description.replace("</s>", "").replace("<s>", "").strip()
|
||||||
|
description = " ".join(description.split())
|
||||||
|
|
||||||
|
# 🎯 提取核心信息(颜色+物体)
|
||||||
|
description = self._extract_core_description(description)
|
||||||
|
|
||||||
|
print(f"✅ 区域识别结果: {description}")
|
||||||
|
return description
|
||||||
|
|
||||||
|
def _detect_sync(self, image: Image.Image) -> str:
|
||||||
|
"""同步推理(在线程池中执行)- 已弃用,使用 _detect_region_sync"""
|
||||||
|
# 使用 <MORE_DETAILED_CAPTION> 生成更详细的描述,包含颜色和细节
|
||||||
|
prompt = "<MORE_DETAILED_CAPTION>"
|
||||||
|
|
||||||
|
inputs = self.processor(
|
||||||
|
text=prompt,
|
||||||
|
images=image,
|
||||||
|
return_tensors="pt"
|
||||||
|
).to(self.device)
|
||||||
|
|
||||||
|
# 生成描述
|
||||||
|
with torch.no_grad():
|
||||||
|
generated_ids = self.model.generate(
|
||||||
|
input_ids=inputs["input_ids"],
|
||||||
|
pixel_values=inputs["pixel_values"],
|
||||||
|
max_new_tokens=30, # 增加长度以获取更多细节
|
||||||
|
num_beams=3,
|
||||||
|
do_sample=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# 解码结果
|
||||||
|
generated_text = self.processor.batch_decode(
|
||||||
|
generated_ids,
|
||||||
|
skip_special_tokens=False
|
||||||
|
)[0]
|
||||||
|
|
||||||
|
# 提取描述(去除提示词和特殊标记)
|
||||||
|
description = generated_text.replace(prompt, "").strip()
|
||||||
|
|
||||||
|
# 清理特殊标记
|
||||||
|
description = description.replace("</s>", "").replace("<s>", "").strip()
|
||||||
|
# 清理多余的空格
|
||||||
|
description = " ".join(description.split())
|
||||||
|
|
||||||
|
# 🎯 提取核心信息(颜色+物体)
|
||||||
|
description = self._extract_core_description(description)
|
||||||
|
|
||||||
|
print(f"✅ 识别结果: {description}")
|
||||||
|
return description
|
||||||
|
|
||||||
|
def _extract_core_description(self, text: str) -> str:
|
||||||
|
"""提取核心描述(只保留主要物体名称)"""
|
||||||
|
# 移除所有介绍性和描述性短语(包括摄影术语)
|
||||||
|
remove_phrases = [
|
||||||
|
"a close up of ", # 特写镜头(优先匹配)
|
||||||
|
"a close-up of ",
|
||||||
|
"close up of ",
|
||||||
|
"close-up of ",
|
||||||
|
"the image shows ",
|
||||||
|
"the photo shows ",
|
||||||
|
"this is a ",
|
||||||
|
"this is an ",
|
||||||
|
"this is ",
|
||||||
|
"there is a ",
|
||||||
|
"there is an ",
|
||||||
|
"there is ",
|
||||||
|
"there are ",
|
||||||
|
"a picture of ",
|
||||||
|
"an image of ",
|
||||||
|
"it shows ",
|
||||||
|
"shows ",
|
||||||
|
"depicts ",
|
||||||
|
"featuring ",
|
||||||
|
"with ",
|
||||||
|
" on top of ",
|
||||||
|
" on a ",
|
||||||
|
" on the ",
|
||||||
|
" in a ",
|
||||||
|
" in the ",
|
||||||
|
" sitting on ",
|
||||||
|
" lying on ",
|
||||||
|
" placed on ",
|
||||||
|
" next to ",
|
||||||
|
" beside ",
|
||||||
|
]
|
||||||
|
|
||||||
|
result = text.lower()
|
||||||
|
|
||||||
|
# 先移除介绍性短语(按长度降序,优先匹配长短语)
|
||||||
|
for phrase in sorted(remove_phrases, key=len, reverse=True):
|
||||||
|
result = result.replace(phrase, " ")
|
||||||
|
|
||||||
|
# 只保留第一个逗号或句号之前的内容
|
||||||
|
for sep in [". ", ", ", " and ", " with "]:
|
||||||
|
if sep in result:
|
||||||
|
result = result.split(sep)[0]
|
||||||
|
break
|
||||||
|
|
||||||
|
# 移除冠词
|
||||||
|
result = result.replace(" a ", " ").replace(" an ", " ").replace(" the ", " ")
|
||||||
|
|
||||||
|
# 清理多余空格
|
||||||
|
result = " ".join(result.split()).strip()
|
||||||
|
|
||||||
|
# 限制长度(最多3-4个词)
|
||||||
|
words = result.split()
|
||||||
|
if len(words) > 4:
|
||||||
|
result = " ".join(words[:4])
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def _translate_to_chinese(self, text: str) -> str:
|
||||||
|
"""使用关键词映射进行准确翻译(替代MarianMT)"""
|
||||||
|
if not text or not text.strip():
|
||||||
|
return text
|
||||||
|
|
||||||
|
# 如果已经是中文,直接返回
|
||||||
|
if any('\u4e00' <= char <= '\u9fff' for char in text):
|
||||||
|
return text
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 使用关键词映射
|
||||||
|
translated = self._translate_with_keywords(text)
|
||||||
|
print(f"🌐 翻译: {text} -> {translated}")
|
||||||
|
return translated
|
||||||
|
except Exception as e:
|
||||||
|
print(f"⚠️ 翻译失败: {e},返回原文")
|
||||||
|
return text
|
||||||
|
|
||||||
|
def _translate_with_keywords(self, text: str) -> str:
|
||||||
|
"""使用关键词映射进行翻译"""
|
||||||
|
keyword_map = {
|
||||||
|
# 家具
|
||||||
|
"chair": "椅子", "sofa": "沙发", "couch": "沙发",
|
||||||
|
"bean bag": "懒人沙发", "beanbag": "懒人沙发",
|
||||||
|
"table": "桌子", "desk": "书桌", "bed": "床",
|
||||||
|
|
||||||
|
# 织物
|
||||||
|
"towel": "毛巾", "pillow": "枕头", "cushion": "靠垫",
|
||||||
|
"blanket": "毯子", "throw": "抱枕",
|
||||||
|
"rug": "地毯", "carpet": "地毯", "mat": "垫子",
|
||||||
|
"fabric": "织物", "knit": "针织", "knitted": "针织",
|
||||||
|
"fluffy": "毛绒", "fuzzy": "毛茸茸",
|
||||||
|
|
||||||
|
# 颜色
|
||||||
|
"green": "绿色", "red": "红色", "blue": "蓝色",
|
||||||
|
"yellow": "黄色", "white": "白色", "black": "黑色",
|
||||||
|
"brown": "棕色", "pink": "粉色", "beige": "米色",
|
||||||
|
"gray": "灰色", "grey": "灰色",
|
||||||
|
|
||||||
|
# 物品
|
||||||
|
"book": "书", "books": "书籍", "magazine": "杂志",
|
||||||
|
"stack": "堆叠", "pile": "堆", "bottle": "瓶子",
|
||||||
|
"plant": "植物", "vase": "花瓶",
|
||||||
|
|
||||||
|
# 衣物
|
||||||
|
"sweater": "毛衣", "shirt": "衬衫",
|
||||||
|
"jacket": "夹克", "coat": "外套",
|
||||||
|
"dress": "连衣裙", "scarf": "围巾",
|
||||||
|
|
||||||
|
# 形状
|
||||||
|
"heart": "心形", "round": "圆形",
|
||||||
|
"square": "方形",
|
||||||
|
|
||||||
|
# 状态
|
||||||
|
"lying": "放置", "sitting": "放置",
|
||||||
|
"resting": "靠着", "open": "打开",
|
||||||
|
"with": "带有", "and": "和",
|
||||||
|
}
|
||||||
|
|
||||||
|
words = text.lower().split()
|
||||||
|
translated_words = []
|
||||||
|
|
||||||
|
for word in words:
|
||||||
|
clean_word = word.strip('.,!?;:')
|
||||||
|
translated_words.append(keyword_map.get(clean_word, word))
|
||||||
|
|
||||||
|
return " ".join(translated_words)
|
||||||
|
|
||||||
297
backend-florence/app/services/qwen_service.py
Normal file
297
backend-florence/app/services/qwen_service.py
Normal file
@ -0,0 +1,297 @@
|
|||||||
|
import torch
|
||||||
|
from transformers import Qwen2VLForConditionalGeneration, AutoProcessor
|
||||||
|
from qwen_vl_utils import process_vision_info
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
import requests
|
||||||
|
from io import BytesIO
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
import os
|
||||||
|
|
||||||
|
class QwenVLService:
|
||||||
|
def __init__(self):
|
||||||
|
self.model = None
|
||||||
|
self.processor = None
|
||||||
|
self.is_loaded = False
|
||||||
|
|
||||||
|
# 自动检测并使用 GPU
|
||||||
|
self.device = "cuda" if torch.cuda.is_available() else "cpu"
|
||||||
|
print(f"🎯 使用设备: {self.device}")
|
||||||
|
|
||||||
|
if self.device == "cuda":
|
||||||
|
print(f"🚀 GPU 加速已启用: {torch.cuda.get_device_name(0)}")
|
||||||
|
print(f"💾 GPU 显存: {torch.cuda.get_device_properties(0).total_memory / 1024**3:.1f} GB")
|
||||||
|
print(f"📊 GPU 数量: {torch.cuda.device_count()}")
|
||||||
|
|
||||||
|
async def load_model(self):
|
||||||
|
"""异步加载模型"""
|
||||||
|
if self.is_loaded:
|
||||||
|
return
|
||||||
|
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
await loop.run_in_executor(None, self._load_model_sync)
|
||||||
|
|
||||||
|
def _load_model_sync(self):
|
||||||
|
"""同步加载模型(在线程池中执行)"""
|
||||||
|
print("🚀 正在加载 Qwen2-VL-2B 模型...")
|
||||||
|
|
||||||
|
# 直接使用 HuggingFace(通过 HF_ENDPOINT 环境变量使用镜像)
|
||||||
|
model_path = "Qwen/Qwen2-VL-2B-Instruct"
|
||||||
|
|
||||||
|
print(f"📦 从 HuggingFace 下载模型: {model_path}")
|
||||||
|
print(f"🌐 使用镜像: {os.environ.get('HF_ENDPOINT', 'https://huggingface.co')}")
|
||||||
|
print(f"💾 缓存目录: {os.environ.get('HF_HOME', './models')}")
|
||||||
|
|
||||||
|
# 加载模型(会自动从 HF_ENDPOINT 下载)
|
||||||
|
print(f"🔄 正在下载并加载模型到 {self.device}...")
|
||||||
|
self.model = Qwen2VLForConditionalGeneration.from_pretrained(
|
||||||
|
model_path,
|
||||||
|
torch_dtype=torch.float16 if self.device == "cuda" else torch.float32,
|
||||||
|
device_map="auto" if self.device == "cuda" else None,
|
||||||
|
trust_remote_code=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.device == "cpu":
|
||||||
|
self.model = self.model.to(self.device)
|
||||||
|
|
||||||
|
# 加载处理器
|
||||||
|
print("🔄 正在加载处理器...")
|
||||||
|
self.processor = AutoProcessor.from_pretrained(
|
||||||
|
model_path,
|
||||||
|
trust_remote_code=True
|
||||||
|
)
|
||||||
|
|
||||||
|
self.is_loaded = True
|
||||||
|
print("✅ Qwen2-VL-2B 模型加载完成!")
|
||||||
|
|
||||||
|
async def detect_object_at_point(
|
||||||
|
self,
|
||||||
|
image_url: str,
|
||||||
|
click_x: int,
|
||||||
|
click_y: int,
|
||||||
|
image_index: int,
|
||||||
|
image_width: int,
|
||||||
|
image_height: int
|
||||||
|
) -> dict:
|
||||||
|
"""使用Qwen2-VL检测点击位置的物体"""
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
# 1. 下载图片
|
||||||
|
print(f"📥 下载图片: {image_url}")
|
||||||
|
response = requests.get(image_url, timeout=10)
|
||||||
|
image = Image.open(BytesIO(response.content)).convert("RGB")
|
||||||
|
actual_width, actual_height = image.size
|
||||||
|
|
||||||
|
print(f"📊 原始图片: {actual_width}x{actual_height}")
|
||||||
|
|
||||||
|
# 缩放大图以加快推理速度(保持宽高比)
|
||||||
|
max_size = 768 # 降低到768以加快推理(从1024)
|
||||||
|
if max(actual_width, actual_height) > max_size:
|
||||||
|
scale = max_size / max(actual_width, actual_height)
|
||||||
|
new_width = int(actual_width * scale)
|
||||||
|
new_height = int(actual_height * scale)
|
||||||
|
|
||||||
|
# 同步缩放坐标
|
||||||
|
click_x = int(click_x * scale)
|
||||||
|
click_y = int(click_y * scale)
|
||||||
|
|
||||||
|
image = image.resize((new_width, new_height), Image.LANCZOS)
|
||||||
|
print(f"⚡ 已缩放至: {new_width}x{new_height} (加速推理)")
|
||||||
|
|
||||||
|
print(f"📍 标注坐标: ({click_x}, {click_y})")
|
||||||
|
|
||||||
|
# 2. 在图片上绘制标注点
|
||||||
|
marked_image = image.copy()
|
||||||
|
draw = ImageDraw.Draw(marked_image)
|
||||||
|
|
||||||
|
# 绘制超级明显的红色标记(放大3倍)
|
||||||
|
marker_size = 50 # 从30增加到50
|
||||||
|
line_width = 10 # 从6增加到10
|
||||||
|
|
||||||
|
# 绘制3层嵌套圆圈,更明显
|
||||||
|
for i in range(3):
|
||||||
|
size = marker_size - i * 10
|
||||||
|
draw.ellipse(
|
||||||
|
[click_x - size, click_y - size,
|
||||||
|
click_x + size, click_y + size],
|
||||||
|
outline=(255, 0, 0),
|
||||||
|
width=line_width
|
||||||
|
)
|
||||||
|
|
||||||
|
# 绘制十字线
|
||||||
|
draw.line(
|
||||||
|
[click_x - marker_size - 20, click_y, click_x + marker_size + 20, click_y],
|
||||||
|
fill=(255, 0, 0),
|
||||||
|
width=line_width
|
||||||
|
)
|
||||||
|
draw.line(
|
||||||
|
[click_x, click_y - marker_size - 20, click_x, click_y + marker_size + 20],
|
||||||
|
fill=(255, 0, 0),
|
||||||
|
width=line_width
|
||||||
|
)
|
||||||
|
|
||||||
|
# 在圆心绘制实心点
|
||||||
|
center_size = 8
|
||||||
|
draw.ellipse(
|
||||||
|
[click_x - center_size, click_y - center_size,
|
||||||
|
click_x + center_size, click_y + center_size],
|
||||||
|
fill=(255, 0, 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"✏️ 已在图片上绘制标注点")
|
||||||
|
|
||||||
|
# 3. 构建对话式问答prompt
|
||||||
|
messages = [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "image",
|
||||||
|
"image": marked_image,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": "看图中红色圆圈的正中心位置,那里有一个物体。只用2-4个词描述这个被圆圈中心点标记的物体(格式:颜色+材质/物品,如:白色沙发、红色毛衣)。只说这一个物体,不要其他。"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
# 4. 准备输入
|
||||||
|
text = self.processor.apply_chat_template(
|
||||||
|
messages,
|
||||||
|
tokenize=False,
|
||||||
|
add_generation_prompt=True
|
||||||
|
)
|
||||||
|
|
||||||
|
image_inputs, video_inputs = process_vision_info(messages)
|
||||||
|
|
||||||
|
inputs = self.processor(
|
||||||
|
text=[text],
|
||||||
|
images=image_inputs,
|
||||||
|
videos=video_inputs,
|
||||||
|
padding=True,
|
||||||
|
return_tensors="pt",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 移动到设备
|
||||||
|
inputs = inputs.to(self.device)
|
||||||
|
|
||||||
|
# 5. 生成回答
|
||||||
|
print("🔍 正在识别物体...")
|
||||||
|
inference_start = time.time()
|
||||||
|
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
description = await loop.run_in_executor(
|
||||||
|
None,
|
||||||
|
self._generate_sync,
|
||||||
|
inputs
|
||||||
|
)
|
||||||
|
|
||||||
|
inference_time = time.time() - inference_start
|
||||||
|
print(f"⚡ 推理耗时: {inference_time:.2f}秒")
|
||||||
|
|
||||||
|
# 6. 清理结果
|
||||||
|
description = self._clean_description(description)
|
||||||
|
|
||||||
|
# 7. 格式化返回结果
|
||||||
|
formatted_text = f"图{image_index}: {description}"
|
||||||
|
|
||||||
|
total_time = time.time() - start_time
|
||||||
|
print(f"✅ 识别完成: {description} (总耗时: {total_time:.2f}秒)")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"description": description,
|
||||||
|
"descriptionEn": "", # Qwen2-VL直接输出中文
|
||||||
|
"confidence": 0.95,
|
||||||
|
"imageIndex": image_index,
|
||||||
|
"formattedText": formatted_text
|
||||||
|
}
|
||||||
|
|
||||||
|
def _generate_sync(self, inputs):
|
||||||
|
"""同步生成(在线程池中执行)"""
|
||||||
|
with torch.no_grad():
|
||||||
|
generated_ids = self.model.generate(
|
||||||
|
**inputs,
|
||||||
|
max_new_tokens=20, # 进一步减少生成长度加速(30→20)
|
||||||
|
do_sample=False,
|
||||||
|
temperature=0.0,
|
||||||
|
use_cache=True # 启用KV缓存加速
|
||||||
|
)
|
||||||
|
|
||||||
|
# 只解码生成的部分
|
||||||
|
generated_ids_trimmed = [
|
||||||
|
out_ids[len(in_ids):]
|
||||||
|
for in_ids, out_ids in zip(inputs.input_ids, generated_ids)
|
||||||
|
]
|
||||||
|
|
||||||
|
output_text = self.processor.batch_decode(
|
||||||
|
generated_ids_trimmed,
|
||||||
|
skip_special_tokens=True,
|
||||||
|
clean_up_tokenization_spaces=False
|
||||||
|
)[0]
|
||||||
|
|
||||||
|
return output_text
|
||||||
|
|
||||||
|
def _clean_description(self, text: str) -> str:
|
||||||
|
"""清理描述文本,移除不需要的前缀和格式"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
text = text.strip()
|
||||||
|
|
||||||
|
# 移除常见的前缀(Qwen2-VL可能生成的)
|
||||||
|
prefixes_to_remove = [
|
||||||
|
r"红色圆圈标记中心位置的物体是\s*",
|
||||||
|
r"红色圆圈标记中心位置的\s*",
|
||||||
|
r"标记中心位置的物体是\s*",
|
||||||
|
r"红色圆圈标记的物体是\s*",
|
||||||
|
r"红色圆圈标记的是\s*",
|
||||||
|
r"红色圆圈标记[::]\s*",
|
||||||
|
r"红色标记的物体是\s*",
|
||||||
|
r"红色标记[::]\s*",
|
||||||
|
r"标记的物体是\s*",
|
||||||
|
r"标记位置的物体是\s*",
|
||||||
|
r"标记位置[::]\s*",
|
||||||
|
r"标记的位置[::]\s*",
|
||||||
|
r"标记处[::]\s*",
|
||||||
|
r"标记中心位置的\s*",
|
||||||
|
r"中心位置的物体是\s*",
|
||||||
|
r"中心位置的\s*",
|
||||||
|
r"物体是\s*",
|
||||||
|
r"图片中[,,、]?\s*",
|
||||||
|
r"这是\s*",
|
||||||
|
r"这个是\s*",
|
||||||
|
r"标记的是\s*",
|
||||||
|
]
|
||||||
|
|
||||||
|
for prefix in prefixes_to_remove:
|
||||||
|
text = re.sub(f"^{prefix}", "", text, flags=re.IGNORECASE)
|
||||||
|
|
||||||
|
# 移除开头的标点符号
|
||||||
|
text = re.sub(r"^[::,,、。\s]+", "", text)
|
||||||
|
|
||||||
|
# 如果包含多个物品(用顿号、逗号分隔),只取第一个
|
||||||
|
if '、' in text:
|
||||||
|
text = text.split('、')[0]
|
||||||
|
elif ',' in text:
|
||||||
|
text = text.split(',')[0]
|
||||||
|
elif ',' in text:
|
||||||
|
text = text.split(',')[0]
|
||||||
|
|
||||||
|
# 限制长度(最多20个字,避免截断)
|
||||||
|
if len(text) > 20:
|
||||||
|
text = text[:20]
|
||||||
|
|
||||||
|
return text.strip()
|
||||||
|
|
||||||
|
# 全局单例
|
||||||
|
_qwen_service = None
|
||||||
|
|
||||||
|
def get_qwen_service() -> QwenVLService:
|
||||||
|
"""获取全局Qwen服务实例"""
|
||||||
|
global _qwen_service
|
||||||
|
if _qwen_service is None:
|
||||||
|
_qwen_service = QwenVLService()
|
||||||
|
return _qwen_service
|
||||||
|
|
||||||
23
backend-florence/ecosystem.config.js
Normal file
23
backend-florence/ecosystem.config.js
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
module.exports = {
|
||||||
|
apps: [{
|
||||||
|
name: 'qwen-vl-api',
|
||||||
|
script: '/var/www/ai-chatbot-backend/venv/bin/python',
|
||||||
|
args: '-m uvicorn app.main:app --host 0.0.0.0 --port 8002',
|
||||||
|
cwd: '/var/www/ai-chatbot-backend',
|
||||||
|
instances: 1,
|
||||||
|
exec_mode: 'fork',
|
||||||
|
max_memory_restart: '6G', // Qwen2-VL-2B需要更多内存
|
||||||
|
env: {
|
||||||
|
PORT: 8002,
|
||||||
|
CUDA_VISIBLE_DEVICES: '0', // 使用第一张 GPU
|
||||||
|
PYTHONPATH: '/var/www/ai-chatbot-backend',
|
||||||
|
HF_ENDPOINT: 'https://hf-mirror.com',
|
||||||
|
TRANSFORMERS_CACHE: '/var/www/ai-chatbot-backend/models',
|
||||||
|
HF_HOME: '/var/www/ai-chatbot-backend/models'
|
||||||
|
},
|
||||||
|
error_file: '/var/www/ai-chatbot-backend/logs/error.log',
|
||||||
|
out_file: '/var/www/ai-chatbot-backend/logs/output.log',
|
||||||
|
time: true
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
18
backend-florence/requirements.txt
Normal file
18
backend-florence/requirements.txt
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
fastapi==0.115.6
|
||||||
|
uvicorn[standard]==0.34.0
|
||||||
|
python-multipart==0.0.20
|
||||||
|
pydantic==2.10.5
|
||||||
|
# PyTorch with CUDA 11.8 support
|
||||||
|
torch==2.5.1
|
||||||
|
torchvision==0.20.1
|
||||||
|
# Transformers and Qwen2-VL
|
||||||
|
transformers==4.47.1
|
||||||
|
qwen-vl-utils==0.0.8
|
||||||
|
accelerate==1.2.1
|
||||||
|
# ModelScope for faster model download in China
|
||||||
|
modelscope==1.22.0
|
||||||
|
# Image and video processing
|
||||||
|
Pillow==11.0.0
|
||||||
|
av==14.0.1
|
||||||
|
# HTTP requests
|
||||||
|
requests==2.32.3
|
||||||
51
biome.jsonc
Normal file
51
biome.jsonc
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
|
||||||
|
"extends": ["ultracite"],
|
||||||
|
"files": {
|
||||||
|
"includes": [
|
||||||
|
"**/*",
|
||||||
|
"!components/ui",
|
||||||
|
"!lib/utils.ts",
|
||||||
|
"!hooks/use-mobile.ts"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"linter": {
|
||||||
|
"rules": {
|
||||||
|
"suspicious": {
|
||||||
|
/* Needs more work to fix */
|
||||||
|
"noExplicitAny": "off",
|
||||||
|
|
||||||
|
/* Allow for Tailwind @ rules */
|
||||||
|
"noUnknownAtRules": "off",
|
||||||
|
|
||||||
|
/* Allowing console for debugging */
|
||||||
|
"noConsole": "off",
|
||||||
|
|
||||||
|
/* Needed for generateUUID() */
|
||||||
|
"noBitwiseOperators": "off"
|
||||||
|
},
|
||||||
|
"style": {
|
||||||
|
/* Allowing magic numbers */
|
||||||
|
"noMagicNumbers": "off",
|
||||||
|
|
||||||
|
/* Needs more work to fix */
|
||||||
|
"noNestedTernary": "off"
|
||||||
|
},
|
||||||
|
"nursery": {
|
||||||
|
/* Too many false positives */
|
||||||
|
"noUnnecessaryConditions": "off"
|
||||||
|
},
|
||||||
|
"complexity": {
|
||||||
|
/* Needs more work to fix */
|
||||||
|
"noExcessiveCognitiveComplexity": "off",
|
||||||
|
|
||||||
|
/* This one has false positives. It's a bit... iffy 😉 */
|
||||||
|
"useSimplifiedLogicExpression": "off"
|
||||||
|
},
|
||||||
|
"a11y": {
|
||||||
|
/* Needs more work to fix */
|
||||||
|
"noSvgWithoutTitle": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
102
check-gpu.sh
Normal file
102
check-gpu.sh
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
echo "🔍 开始检测服务器 GPU 配置..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 1. 检查 NVIDIA 驱动
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
echo "1️⃣ 检查 NVIDIA 驱动"
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
if command -v nvidia-smi &> /dev/null; then
|
||||||
|
nvidia-smi --query-gpu=name,driver_version,memory.total --format=csv
|
||||||
|
echo ""
|
||||||
|
echo "✅ NVIDIA 驱动已安装"
|
||||||
|
else
|
||||||
|
echo "❌ 未检测到 NVIDIA 驱动"
|
||||||
|
echo " 请先安装驱动: https://www.nvidia.com/Download/index.aspx"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. 检查 CUDA
|
||||||
|
echo ""
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
echo "2️⃣ 检查 CUDA 版本"
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
if command -v nvcc &> /dev/null; then
|
||||||
|
nvcc --version | grep release
|
||||||
|
echo "✅ CUDA 工具包已安装"
|
||||||
|
else
|
||||||
|
echo "⚠️ 未检测到 CUDA 工具包(可选)"
|
||||||
|
echo " nvidia-smi 显示的 CUDA 版本可以直接使用"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 3. 检查 Python 和 PyTorch
|
||||||
|
echo ""
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
echo "3️⃣ 检查 PyTorch GPU 支持"
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
if command -v python3 &> /dev/null; then
|
||||||
|
python3 << 'EOF'
|
||||||
|
import sys
|
||||||
|
|
||||||
|
try:
|
||||||
|
import torch
|
||||||
|
print(f"PyTorch 版本: {torch.__version__}")
|
||||||
|
print(f"CUDA 可用: {torch.cuda.is_available()}")
|
||||||
|
|
||||||
|
if torch.cuda.is_available():
|
||||||
|
print(f"CUDA 版本: {torch.version.cuda}")
|
||||||
|
print(f"cuDNN 版本: {torch.backends.cudnn.version()}")
|
||||||
|
print(f"GPU 数量: {torch.cuda.device_count()}")
|
||||||
|
print(f"GPU 名称: {torch.cuda.get_device_name(0)}")
|
||||||
|
print(f"GPU 显存: {torch.cuda.get_device_properties(0).total_memory / 1024**3:.1f} GB")
|
||||||
|
print("")
|
||||||
|
print("✅ PyTorch GPU 支持已启用")
|
||||||
|
else:
|
||||||
|
print("")
|
||||||
|
print("❌ PyTorch 未启用 GPU 支持")
|
||||||
|
print(" 需要安装 CUDA 版本的 PyTorch:")
|
||||||
|
print(" pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
except ImportError:
|
||||||
|
print("❌ PyTorch 未安装")
|
||||||
|
print(" 请先安装: pip install torch torchvision")
|
||||||
|
sys.exit(1)
|
||||||
|
EOF
|
||||||
|
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
echo ""
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
echo "🎉 GPU 配置检测完成!"
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
echo ""
|
||||||
|
echo "📊 性能预估:"
|
||||||
|
echo " • 推理速度: 0.1-0.3 秒/次"
|
||||||
|
echo " • 比 CPU 快 3-5 倍"
|
||||||
|
echo " • 用户体验: ⭐⭐⭐⭐⭐"
|
||||||
|
echo ""
|
||||||
|
echo "🚀 可以开始部署 Florence-2 API 了!"
|
||||||
|
else
|
||||||
|
echo ""
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
echo "⚠️ 需要安装 CUDA 版本的 PyTorch"
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
echo ""
|
||||||
|
echo "请运行以下命令:"
|
||||||
|
echo ""
|
||||||
|
echo " # 查看 CUDA 版本"
|
||||||
|
echo " nvidia-smi | grep 'CUDA Version'"
|
||||||
|
echo ""
|
||||||
|
echo " # 如果是 CUDA 11.8"
|
||||||
|
echo " pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118"
|
||||||
|
echo ""
|
||||||
|
echo " # 如果是 CUDA 12.1"
|
||||||
|
echo " pip install torch torchvision --index-url https://download.pytorch.org/whl/cu121"
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
|
else
|
||||||
|
echo "❌ Python3 未安装"
|
||||||
|
fi
|
||||||
|
|
||||||
19
check_server.exp
Normal file
19
check_server.exp
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
#!/usr/bin/expect
|
||||||
|
set timeout 30
|
||||||
|
set host "156.225.27.52"
|
||||||
|
set port "5204"
|
||||||
|
set user "root"
|
||||||
|
set password "Jsjj@8888"
|
||||||
|
|
||||||
|
spawn ssh -o StrictHostKeyChecking=no -p $port $user@$host "echo 'Connected successfully' && nvidia-smi"
|
||||||
|
expect {
|
||||||
|
"password:" {
|
||||||
|
send "$password\r"
|
||||||
|
exp_continue
|
||||||
|
}
|
||||||
|
"yes/no" {
|
||||||
|
send "yes\r"
|
||||||
|
exp_continue
|
||||||
|
}
|
||||||
|
eof
|
||||||
|
}
|
||||||
38
check_status.exp
Executable file
38
check_status.exp
Executable file
@ -0,0 +1,38 @@
|
|||||||
|
#!/usr/bin/expect
|
||||||
|
|
||||||
|
set timeout -1
|
||||||
|
set host "156.225.27.52"
|
||||||
|
set port "5204"
|
||||||
|
set user "root"
|
||||||
|
set password "Jsjj@8888"
|
||||||
|
|
||||||
|
spawn ssh -p $port $user@$host "
|
||||||
|
echo '--- GPU Status ---'
|
||||||
|
nvidia-smi --query-gpu=name,memory.total,memory.free --format=csv,noheader
|
||||||
|
|
||||||
|
echo '--- Conda Environment ---'
|
||||||
|
source ~/miniconda3/bin/activate
|
||||||
|
conda env list | grep dreamomni2
|
||||||
|
|
||||||
|
echo '--- Installed Packages (Top 5) ---'
|
||||||
|
conda activate dreamomni2
|
||||||
|
pip list | grep -E 'torch|diffusers|transformers|accelerate|modelscope'
|
||||||
|
|
||||||
|
echo '--- Model Files ---'
|
||||||
|
ls -lh ~/ai-projects/DreamOmni2/checkpoints/DreamOmni2 | head -n 5
|
||||||
|
|
||||||
|
echo '--- Code Files ---'
|
||||||
|
ls -F ~/ai-projects/DreamOmni2
|
||||||
|
"
|
||||||
|
|
||||||
|
expect {
|
||||||
|
"password:" {
|
||||||
|
send "$password\r"
|
||||||
|
exp_continue
|
||||||
|
}
|
||||||
|
"yes/no" {
|
||||||
|
send "yes\r"
|
||||||
|
exp_continue
|
||||||
|
}
|
||||||
|
eof
|
||||||
|
}
|
||||||
93
complete-deploy.sh
Executable file
93
complete-deploy.sh
Executable file
@ -0,0 +1,93 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# 完成部署脚本
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SERVER="156.225.27.52"
|
||||||
|
PORT="5204"
|
||||||
|
USER="root"
|
||||||
|
PASSWORD="Jsjj@8888"
|
||||||
|
|
||||||
|
ssh_cmd() {
|
||||||
|
sshpass -p "$PASSWORD" ssh -o StrictHostKeyChecking=no -p $PORT $USER@$SERVER "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "完成部署..."
|
||||||
|
|
||||||
|
# 运行迁移和构建
|
||||||
|
echo "运行数据库迁移和构建..."
|
||||||
|
ssh_cmd "
|
||||||
|
cd /var/www/ai-chatbot
|
||||||
|
export NVM_DIR=\"\$HOME/.nvm\"
|
||||||
|
[ -s \"\$NVM_DIR/nvm.sh\" ] && . \"\$NVM_DIR/nvm.sh\"
|
||||||
|
|
||||||
|
echo '运行数据库迁移...'
|
||||||
|
pnpm db:migrate || echo '迁移完成或已存在'
|
||||||
|
|
||||||
|
echo '构建项目...'
|
||||||
|
pnpm build
|
||||||
|
"
|
||||||
|
|
||||||
|
# 启动应用
|
||||||
|
echo "启动应用..."
|
||||||
|
ssh_cmd "
|
||||||
|
cd /var/www/ai-chatbot
|
||||||
|
export NVM_DIR=\"\$HOME/.nvm\"
|
||||||
|
[ -s \"\$NVM_DIR/nvm.sh\" ] && . \"\$NVM_DIR/nvm.sh\"
|
||||||
|
|
||||||
|
# 停止旧进程
|
||||||
|
pm2 delete ai-chatbot 2>/dev/null || true
|
||||||
|
|
||||||
|
# 启动应用
|
||||||
|
pm2 start ecosystem.config.js
|
||||||
|
pm2 save
|
||||||
|
"
|
||||||
|
|
||||||
|
# 配置 Nginx
|
||||||
|
echo "配置 Nginx..."
|
||||||
|
ssh_cmd "
|
||||||
|
cat > /etc/nginx/sites-available/ai-chatbot << 'NGINX'
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name $SERVER;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://localhost:3000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade \$http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host \$host;
|
||||||
|
proxy_set_header X-Real-IP \$remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||||
|
proxy_cache_bypass \$http_upgrade;
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
proxy_connect_timeout 75s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NGINX
|
||||||
|
|
||||||
|
ln -sf /etc/nginx/sites-available/ai-chatbot /etc/nginx/sites-enabled/
|
||||||
|
rm -f /etc/nginx/sites-enabled/default
|
||||||
|
nginx -t
|
||||||
|
systemctl restart nginx
|
||||||
|
"
|
||||||
|
|
||||||
|
# 检查状态
|
||||||
|
echo "检查部署状态..."
|
||||||
|
ssh_cmd "
|
||||||
|
echo '=== PM2 状态 ==='
|
||||||
|
pm2 status
|
||||||
|
|
||||||
|
echo ''
|
||||||
|
echo '=== 应用日志(最后10行)==='
|
||||||
|
pm2 logs ai-chatbot --lines 10 --nostream || echo '暂无日志'
|
||||||
|
|
||||||
|
echo ''
|
||||||
|
echo '=== Nginx 状态 ==='
|
||||||
|
systemctl status nginx --no-pager -l | head -10
|
||||||
|
"
|
||||||
|
|
||||||
|
echo "部署完成!"
|
||||||
|
echo "访问地址: http://$SERVER"
|
||||||
|
|
||||||
20
components.json
Normal file
20
components.json
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "default",
|
||||||
|
"rsc": true,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "app/globals.css",
|
||||||
|
"baseColor": "zinc",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
}
|
||||||
|
}
|
||||||
254
components/antigravity-background.tsx
Normal file
254
components/antigravity-background.tsx
Normal file
@ -0,0 +1,254 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
import Matter from "matter-js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Antigravity Background Component
|
||||||
|
* 使用 Matter.js 实现完整的 Google Antigravity 物理效果
|
||||||
|
*/
|
||||||
|
export function AntigravityBackground() {
|
||||||
|
const sceneRef = useRef<HTMLDivElement>(null);
|
||||||
|
const engineRef = useRef<Matter.Engine | null>(null);
|
||||||
|
const renderRef = useRef<Matter.Render | null>(null);
|
||||||
|
const mouseConstraintRef = useRef<Matter.MouseConstraint | null>(null);
|
||||||
|
const { resolvedTheme } = useTheme();
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mounted || !sceneRef.current) return;
|
||||||
|
|
||||||
|
// 清理旧实例
|
||||||
|
if (engineRef.current) {
|
||||||
|
Matter.Engine.clear(engineRef.current);
|
||||||
|
}
|
||||||
|
if (renderRef.current) {
|
||||||
|
Matter.Render.stop(renderRef.current);
|
||||||
|
renderRef.current.canvas.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建物理引擎
|
||||||
|
const engine = Matter.Engine.create({
|
||||||
|
gravity: { x: 0, y: 1, scale: 0.001 },
|
||||||
|
});
|
||||||
|
engineRef.current = engine;
|
||||||
|
|
||||||
|
// 创建渲染器
|
||||||
|
const render = Matter.Render.create({
|
||||||
|
element: sceneRef.current,
|
||||||
|
engine: engine,
|
||||||
|
options: {
|
||||||
|
width: window.innerWidth,
|
||||||
|
height: window.innerHeight,
|
||||||
|
wireframes: false,
|
||||||
|
background: "transparent",
|
||||||
|
pixelRatio: window.devicePixelRatio,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
renderRef.current = render;
|
||||||
|
|
||||||
|
// 创建墙壁(边界)
|
||||||
|
const wallOptions: Matter.IBodyDefinition = {
|
||||||
|
isStatic: true,
|
||||||
|
render: {
|
||||||
|
fillStyle: "transparent",
|
||||||
|
strokeStyle: "transparent",
|
||||||
|
},
|
||||||
|
friction: 0.3,
|
||||||
|
restitution: 0.6,
|
||||||
|
};
|
||||||
|
|
||||||
|
const walls = [
|
||||||
|
// 底部
|
||||||
|
Matter.Bodies.rectangle(
|
||||||
|
window.innerWidth / 2,
|
||||||
|
window.innerHeight + 25,
|
||||||
|
window.innerWidth,
|
||||||
|
50,
|
||||||
|
wallOptions
|
||||||
|
),
|
||||||
|
// 顶部
|
||||||
|
Matter.Bodies.rectangle(
|
||||||
|
window.innerWidth / 2,
|
||||||
|
-25,
|
||||||
|
window.innerWidth,
|
||||||
|
50,
|
||||||
|
wallOptions
|
||||||
|
),
|
||||||
|
// 左侧
|
||||||
|
Matter.Bodies.rectangle(
|
||||||
|
-25,
|
||||||
|
window.innerHeight / 2,
|
||||||
|
50,
|
||||||
|
window.innerHeight,
|
||||||
|
wallOptions
|
||||||
|
),
|
||||||
|
// 右侧
|
||||||
|
Matter.Bodies.rectangle(
|
||||||
|
window.innerWidth + 25,
|
||||||
|
window.innerHeight / 2,
|
||||||
|
50,
|
||||||
|
window.innerHeight,
|
||||||
|
wallOptions
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
Matter.Composite.add(engine.world, walls);
|
||||||
|
|
||||||
|
// 创建可拖动的物体
|
||||||
|
const emojis = [
|
||||||
|
{ emoji: "🤖", x: 200, y: 100, size: 40, color: "#667eea" },
|
||||||
|
{ emoji: "💬", x: 400, y: 150, size: 35, color: "#764ba2" },
|
||||||
|
{ emoji: "✨", x: 600, y: 200, size: 38, color: "#f093fb" },
|
||||||
|
{ emoji: "🚀", x: 800, y: 120, size: 36, color: "#4facfe" },
|
||||||
|
{ emoji: "🎨", x: 300, y: 400, size: 34, color: "#43e97b" },
|
||||||
|
{ emoji: "⚡", x: 700, y: 350, size: 37, color: "#fa709a" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const bodies = emojis.map((item) => {
|
||||||
|
const body = Matter.Bodies.circle(item.x, item.y, item.size / 2, {
|
||||||
|
restitution: 0.8,
|
||||||
|
friction: 0.05,
|
||||||
|
density: 0.001,
|
||||||
|
render: {
|
||||||
|
fillStyle: item.color + "40",
|
||||||
|
strokeStyle: item.color + "80",
|
||||||
|
lineWidth: 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 存储 emoji 信息到 body 上
|
||||||
|
(body as any).emoji = item.emoji;
|
||||||
|
(body as any).size = item.size;
|
||||||
|
|
||||||
|
return body;
|
||||||
|
});
|
||||||
|
|
||||||
|
Matter.Composite.add(engine.world, bodies);
|
||||||
|
|
||||||
|
// 添加鼠标控制
|
||||||
|
const mouse = Matter.Mouse.create(render.canvas);
|
||||||
|
const mouseConstraint = Matter.MouseConstraint.create(engine, {
|
||||||
|
mouse: mouse,
|
||||||
|
constraint: {
|
||||||
|
stiffness: 0.2,
|
||||||
|
render: {
|
||||||
|
visible: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
mouseConstraintRef.current = mouseConstraint;
|
||||||
|
|
||||||
|
Matter.Composite.add(engine.world, mouseConstraint);
|
||||||
|
|
||||||
|
// 保持鼠标与渲染器同步
|
||||||
|
render.mouse = mouse;
|
||||||
|
|
||||||
|
// 自定义渲染函数(绘制 emoji)
|
||||||
|
Matter.Events.on(render, "afterRender", () => {
|
||||||
|
const context = render.context;
|
||||||
|
|
||||||
|
bodies.forEach((body) => {
|
||||||
|
const emoji = (body as any).emoji;
|
||||||
|
const size = (body as any).size;
|
||||||
|
|
||||||
|
if (!emoji) return;
|
||||||
|
|
||||||
|
context.save();
|
||||||
|
context.translate(body.position.x, body.position.y);
|
||||||
|
context.rotate(body.angle);
|
||||||
|
|
||||||
|
// 绘制半透明背景圆
|
||||||
|
context.beginPath();
|
||||||
|
context.arc(0, 0, size / 2, 0, Math.PI * 2);
|
||||||
|
context.fillStyle = body.render.fillStyle || "#667eea40";
|
||||||
|
context.fill();
|
||||||
|
|
||||||
|
// 绘制 emoji
|
||||||
|
context.font = `${size * 0.8}px Arial`;
|
||||||
|
context.textAlign = "center";
|
||||||
|
context.textBaseline = "middle";
|
||||||
|
context.fillText(emoji, 0, 0);
|
||||||
|
|
||||||
|
context.restore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 启动引擎和渲染器
|
||||||
|
const runner = Matter.Runner.create();
|
||||||
|
Matter.Runner.run(runner, engine);
|
||||||
|
Matter.Render.run(render);
|
||||||
|
|
||||||
|
// 窗口大小调整
|
||||||
|
const handleResize = () => {
|
||||||
|
render.canvas.width = window.innerWidth;
|
||||||
|
render.canvas.height = window.innerHeight;
|
||||||
|
render.options.width = window.innerWidth;
|
||||||
|
render.options.height = window.innerHeight;
|
||||||
|
|
||||||
|
// 更新墙壁位置
|
||||||
|
Matter.Body.setPosition(walls[0], {
|
||||||
|
x: window.innerWidth / 2,
|
||||||
|
y: window.innerHeight + 25,
|
||||||
|
});
|
||||||
|
Matter.Body.setPosition(walls[1], {
|
||||||
|
x: window.innerWidth / 2,
|
||||||
|
y: -25,
|
||||||
|
});
|
||||||
|
Matter.Body.setPosition(walls[2], {
|
||||||
|
x: -25,
|
||||||
|
y: window.innerHeight / 2,
|
||||||
|
});
|
||||||
|
Matter.Body.setPosition(walls[3], {
|
||||||
|
x: window.innerWidth + 25,
|
||||||
|
y: window.innerHeight / 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 更新墙壁尺寸
|
||||||
|
Matter.Body.scale(walls[0], window.innerWidth / walls[0].bounds.max.x, 1);
|
||||||
|
Matter.Body.scale(walls[1], window.innerWidth / walls[1].bounds.max.x, 1);
|
||||||
|
Matter.Body.scale(walls[2], 1, window.innerHeight / walls[2].bounds.max.y);
|
||||||
|
Matter.Body.scale(walls[3], 1, window.innerHeight / walls[3].bounds.max.y);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("resize", handleResize);
|
||||||
|
|
||||||
|
// 清理函数
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("resize", handleResize);
|
||||||
|
|
||||||
|
if (mouseConstraintRef.current) {
|
||||||
|
Matter.Composite.remove(engine.world, mouseConstraintRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (renderRef.current) {
|
||||||
|
Matter.Render.stop(renderRef.current);
|
||||||
|
renderRef.current.canvas.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (engineRef.current) {
|
||||||
|
Matter.Engine.clear(engineRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
Matter.Runner.stop(runner);
|
||||||
|
};
|
||||||
|
}, [mounted, resolvedTheme]);
|
||||||
|
|
||||||
|
if (!mounted) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={sceneRef}
|
||||||
|
className="fixed inset-0 z-0"
|
||||||
|
style={{
|
||||||
|
filter: resolvedTheme === "dark" ? "brightness(0.8)" : "brightness(1)",
|
||||||
|
transition: "filter 0.5s ease-in-out",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
136
components/app-sidebar.tsx
Normal file
136
components/app-sidebar.tsx
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import type { User } from "next-auth";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useSWRConfig } from "swr";
|
||||||
|
import { unstable_serialize } from "swr/infinite";
|
||||||
|
import { PlusIcon, TrashIcon } from "@/components/icons";
|
||||||
|
import {
|
||||||
|
getChatHistoryPaginationKey,
|
||||||
|
SidebarHistory,
|
||||||
|
} from "@/components/sidebar-history";
|
||||||
|
import { SidebarUserNav } from "@/components/sidebar-user-nav";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Sidebar,
|
||||||
|
SidebarContent,
|
||||||
|
SidebarFooter,
|
||||||
|
SidebarHeader,
|
||||||
|
SidebarMenu,
|
||||||
|
useSidebar,
|
||||||
|
} from "@/components/ui/sidebar";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "./ui/alert-dialog";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||||
|
|
||||||
|
export function AppSidebar({ user }: { user: User | undefined }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { setOpenMobile } = useSidebar();
|
||||||
|
const { mutate } = useSWRConfig();
|
||||||
|
const [showDeleteAllDialog, setShowDeleteAllDialog] = useState(false);
|
||||||
|
|
||||||
|
const handleDeleteAll = () => {
|
||||||
|
const deletePromise = fetch("/api/history", {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.promise(deletePromise, {
|
||||||
|
loading: "正在删除所有会话...",
|
||||||
|
success: () => {
|
||||||
|
mutate(unstable_serialize(getChatHistoryPaginationKey));
|
||||||
|
router.push("/");
|
||||||
|
setShowDeleteAllDialog(false);
|
||||||
|
return "所有会话已删除";
|
||||||
|
},
|
||||||
|
error: "删除会话失败,请稍后重试",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Sidebar className="group-data-[side=left]:border-r-0 !top-14 !h-[calc(100svh-3.5rem)]">
|
||||||
|
<SidebarHeader>
|
||||||
|
<SidebarMenu>
|
||||||
|
<div className="flex flex-row items-center justify-between w-full">
|
||||||
|
{/* Logo removed */}
|
||||||
|
<div className="flex-1" />
|
||||||
|
<div className="flex flex-row gap-1">
|
||||||
|
{user && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
className="h-8 p-1 md:h-fit md:p-2"
|
||||||
|
onClick={() => setShowDeleteAllDialog(true)}
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
<TrashIcon />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent align="end" side="bottom" className="hidden md:block">
|
||||||
|
删除所有会话
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
className="h-8 p-1 md:h-fit md:p-2"
|
||||||
|
onClick={() => {
|
||||||
|
setOpenMobile(false);
|
||||||
|
router.push("/");
|
||||||
|
router.refresh();
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
<PlusIcon />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent align="end" side="bottom" className="hidden md:block">
|
||||||
|
新建会话
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarHeader>
|
||||||
|
<SidebarContent>
|
||||||
|
<SidebarHistory user={user} />
|
||||||
|
</SidebarContent>
|
||||||
|
<SidebarFooter>{user && <SidebarUserNav user={user} />}</SidebarFooter>
|
||||||
|
</Sidebar>
|
||||||
|
|
||||||
|
<AlertDialog
|
||||||
|
onOpenChange={setShowDeleteAllDialog}
|
||||||
|
open={showDeleteAllDialog}
|
||||||
|
>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>确定删除全部会话?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
操作不可撤销,所有会话记录将从服务器永久移除。
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleDeleteAll}>
|
||||||
|
删除所有
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
107
components/artifact-actions.tsx
Normal file
107
components/artifact-actions.tsx
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
import { type Dispatch, memo, type SetStateAction, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { artifactDefinitions, type UIArtifact } from "./artifact";
|
||||||
|
import type { ArtifactActionContext } from "./create-artifact";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||||
|
|
||||||
|
type ArtifactActionsProps = {
|
||||||
|
artifact: UIArtifact;
|
||||||
|
handleVersionChange: (type: "next" | "prev" | "toggle" | "latest") => void;
|
||||||
|
currentVersionIndex: number;
|
||||||
|
isCurrentVersion: boolean;
|
||||||
|
mode: "edit" | "diff";
|
||||||
|
metadata: any;
|
||||||
|
setMetadata: Dispatch<SetStateAction<any>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function PureArtifactActions({
|
||||||
|
artifact,
|
||||||
|
handleVersionChange,
|
||||||
|
currentVersionIndex,
|
||||||
|
isCurrentVersion,
|
||||||
|
mode,
|
||||||
|
metadata,
|
||||||
|
setMetadata,
|
||||||
|
}: ArtifactActionsProps) {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const artifactDefinition = artifactDefinitions.find(
|
||||||
|
(definition) => definition.kind === artifact.kind
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!artifactDefinition) {
|
||||||
|
throw new Error("Artifact definition not found!");
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionContext: ArtifactActionContext = {
|
||||||
|
content: artifact.content,
|
||||||
|
handleVersionChange,
|
||||||
|
currentVersionIndex,
|
||||||
|
isCurrentVersion,
|
||||||
|
mode,
|
||||||
|
metadata,
|
||||||
|
setMetadata,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-row gap-1">
|
||||||
|
{artifactDefinition.actions.map((action) => (
|
||||||
|
<Tooltip key={action.description}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
className={cn("h-fit dark:hover:bg-zinc-700", {
|
||||||
|
"p-2": !action.label,
|
||||||
|
"px-2 py-1.5": action.label,
|
||||||
|
})}
|
||||||
|
disabled={
|
||||||
|
isLoading || artifact.status === "streaming"
|
||||||
|
? true
|
||||||
|
: action.isDisabled
|
||||||
|
? action.isDisabled(actionContext)
|
||||||
|
: false
|
||||||
|
}
|
||||||
|
onClick={async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.resolve(action.onClick(actionContext));
|
||||||
|
} catch (_error) {
|
||||||
|
toast.error("Failed to execute action");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{action.icon}
|
||||||
|
{action.label}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{action.description}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ArtifactActions = memo(
|
||||||
|
PureArtifactActions,
|
||||||
|
(prevProps, nextProps) => {
|
||||||
|
if (prevProps.artifact.status !== nextProps.artifact.status) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (prevProps.currentVersionIndex !== nextProps.currentVersionIndex) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (prevProps.isCurrentVersion !== nextProps.isCurrentVersion) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (prevProps.artifact.content !== nextProps.artifact.content) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
);
|
||||||
30
components/artifact-close-button.tsx
Normal file
30
components/artifact-close-button.tsx
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { memo } from "react";
|
||||||
|
import { initialArtifactData, useArtifact } from "@/hooks/use-artifact";
|
||||||
|
import { CrossIcon } from "./icons";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
|
|
||||||
|
function PureArtifactCloseButton() {
|
||||||
|
const { setArtifact } = useArtifact();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
className="h-fit p-2 dark:hover:bg-zinc-700"
|
||||||
|
data-testid="artifact-close-button"
|
||||||
|
onClick={() => {
|
||||||
|
setArtifact((currentArtifact) =>
|
||||||
|
currentArtifact.status === "streaming"
|
||||||
|
? {
|
||||||
|
...currentArtifact,
|
||||||
|
isVisible: false,
|
||||||
|
}
|
||||||
|
: { ...initialArtifactData, status: "idle" }
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
<CrossIcon size={18} />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ArtifactCloseButton = memo(PureArtifactCloseButton, () => true);
|
||||||
110
components/artifact-messages.tsx
Normal file
110
components/artifact-messages.tsx
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
import type { UseChatHelpers } from "@ai-sdk/react";
|
||||||
|
import equal from "fast-deep-equal";
|
||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import { memo } from "react";
|
||||||
|
import { useMessages } from "@/hooks/use-messages";
|
||||||
|
import type { Vote } from "@/lib/db/schema";
|
||||||
|
import type { ChatMessage } from "@/lib/types";
|
||||||
|
import type { UIArtifact } from "./artifact";
|
||||||
|
import { PreviewMessage, ThinkingMessage } from "./message";
|
||||||
|
|
||||||
|
type ArtifactMessagesProps = {
|
||||||
|
chatId: string;
|
||||||
|
status: UseChatHelpers<ChatMessage>["status"];
|
||||||
|
votes: Vote[] | undefined;
|
||||||
|
messages: ChatMessage[];
|
||||||
|
setMessages: UseChatHelpers<ChatMessage>["setMessages"];
|
||||||
|
regenerate: UseChatHelpers<ChatMessage>["regenerate"];
|
||||||
|
sendMessage: UseChatHelpers<ChatMessage>["sendMessage"];
|
||||||
|
isReadonly: boolean;
|
||||||
|
artifactStatus: UIArtifact["status"];
|
||||||
|
};
|
||||||
|
|
||||||
|
function PureArtifactMessages({
|
||||||
|
chatId,
|
||||||
|
status,
|
||||||
|
votes,
|
||||||
|
messages,
|
||||||
|
setMessages,
|
||||||
|
regenerate,
|
||||||
|
sendMessage,
|
||||||
|
isReadonly,
|
||||||
|
}: ArtifactMessagesProps) {
|
||||||
|
const {
|
||||||
|
containerRef: messagesContainerRef,
|
||||||
|
endRef: messagesEndRef,
|
||||||
|
onViewportEnter,
|
||||||
|
onViewportLeave,
|
||||||
|
hasSentMessage,
|
||||||
|
} = useMessages({
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex h-full flex-col items-center gap-4 overflow-y-scroll px-4 pt-20"
|
||||||
|
ref={messagesContainerRef}
|
||||||
|
>
|
||||||
|
{messages.map((message, index) => (
|
||||||
|
<PreviewMessage
|
||||||
|
chatId={chatId}
|
||||||
|
isLoading={status === "streaming" && index === messages.length - 1}
|
||||||
|
isReadonly={isReadonly}
|
||||||
|
key={message.id}
|
||||||
|
message={message}
|
||||||
|
regenerate={regenerate}
|
||||||
|
sendMessage={sendMessage}
|
||||||
|
requiresScrollPadding={
|
||||||
|
hasSentMessage && index === messages.length - 1
|
||||||
|
}
|
||||||
|
setMessages={setMessages}
|
||||||
|
vote={
|
||||||
|
votes
|
||||||
|
? votes.find((vote) => vote.messageId === message.id)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{status === "submitted" && <ThinkingMessage key="thinking" />}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
className="min-h-[24px] min-w-[24px] shrink-0"
|
||||||
|
onViewportEnter={onViewportEnter}
|
||||||
|
onViewportLeave={onViewportLeave}
|
||||||
|
ref={messagesEndRef}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function areEqual(
|
||||||
|
prevProps: ArtifactMessagesProps,
|
||||||
|
nextProps: ArtifactMessagesProps
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
prevProps.artifactStatus === "streaming" &&
|
||||||
|
nextProps.artifactStatus === "streaming"
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prevProps.status !== nextProps.status) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (prevProps.status && nextProps.status) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (prevProps.messages.length !== nextProps.messages.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!equal(prevProps.votes, nextProps.votes)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ArtifactMessages = memo(PureArtifactMessages, areEqual);
|
||||||
530
components/artifact.tsx
Normal file
530
components/artifact.tsx
Normal file
@ -0,0 +1,530 @@
|
|||||||
|
import type { UseChatHelpers } from "@ai-sdk/react";
|
||||||
|
import { formatDistance } from "date-fns";
|
||||||
|
import equal from "fast-deep-equal";
|
||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import {
|
||||||
|
type Dispatch,
|
||||||
|
memo,
|
||||||
|
type SetStateAction,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import useSWR, { useSWRConfig } from "swr";
|
||||||
|
import { useDebounceCallback, useWindowSize } from "usehooks-ts";
|
||||||
|
import { codeArtifact } from "@/artifacts/code/client";
|
||||||
|
import { imageArtifact } from "@/artifacts/image/client";
|
||||||
|
import { sheetArtifact } from "@/artifacts/sheet/client";
|
||||||
|
import { textArtifact } from "@/artifacts/text/client";
|
||||||
|
import { useArtifact } from "@/hooks/use-artifact";
|
||||||
|
import type { Document, Vote } from "@/lib/db/schema";
|
||||||
|
import type { Attachment, ChatMessage } from "@/lib/types";
|
||||||
|
import { fetcher } from "@/lib/utils";
|
||||||
|
import { ArtifactActions } from "./artifact-actions";
|
||||||
|
import { ArtifactCloseButton } from "./artifact-close-button";
|
||||||
|
import { ArtifactMessages } from "./artifact-messages";
|
||||||
|
import { MultimodalInput } from "./multimodal-input";
|
||||||
|
import { Toolbar } from "./toolbar";
|
||||||
|
import { useSidebar } from "./ui/sidebar";
|
||||||
|
import { VersionFooter } from "./version-footer";
|
||||||
|
import type { VisibilityType } from "./visibility-selector";
|
||||||
|
|
||||||
|
export const artifactDefinitions = [
|
||||||
|
textArtifact,
|
||||||
|
codeArtifact,
|
||||||
|
imageArtifact,
|
||||||
|
sheetArtifact,
|
||||||
|
];
|
||||||
|
export type ArtifactKind = (typeof artifactDefinitions)[number]["kind"];
|
||||||
|
|
||||||
|
export type UIArtifact = {
|
||||||
|
title: string;
|
||||||
|
documentId: string;
|
||||||
|
kind: ArtifactKind;
|
||||||
|
content: string;
|
||||||
|
isVisible: boolean;
|
||||||
|
status: "streaming" | "idle";
|
||||||
|
boundingBox: {
|
||||||
|
top: number;
|
||||||
|
left: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
function PureArtifact({
|
||||||
|
chatId,
|
||||||
|
input,
|
||||||
|
setInput,
|
||||||
|
status,
|
||||||
|
stop,
|
||||||
|
attachments,
|
||||||
|
setAttachments,
|
||||||
|
sendMessage,
|
||||||
|
messages,
|
||||||
|
setMessages,
|
||||||
|
regenerate,
|
||||||
|
votes,
|
||||||
|
isReadonly,
|
||||||
|
selectedVisibilityType,
|
||||||
|
selectedModelId,
|
||||||
|
}: {
|
||||||
|
chatId: string;
|
||||||
|
input: string;
|
||||||
|
setInput: Dispatch<SetStateAction<string>>;
|
||||||
|
status: UseChatHelpers<ChatMessage>["status"];
|
||||||
|
stop: UseChatHelpers<ChatMessage>["stop"];
|
||||||
|
attachments: Attachment[];
|
||||||
|
setAttachments: Dispatch<SetStateAction<Attachment[]>>;
|
||||||
|
messages: ChatMessage[];
|
||||||
|
setMessages: UseChatHelpers<ChatMessage>["setMessages"];
|
||||||
|
votes: Vote[] | undefined;
|
||||||
|
sendMessage: UseChatHelpers<ChatMessage>["sendMessage"];
|
||||||
|
regenerate: UseChatHelpers<ChatMessage>["regenerate"];
|
||||||
|
isReadonly: boolean;
|
||||||
|
selectedVisibilityType: VisibilityType;
|
||||||
|
selectedModelId: string;
|
||||||
|
}) {
|
||||||
|
const { artifact, setArtifact, metadata, setMetadata } = useArtifact();
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: documents,
|
||||||
|
isLoading: isDocumentsFetching,
|
||||||
|
mutate: mutateDocuments,
|
||||||
|
} = useSWR<Document[]>(
|
||||||
|
artifact.documentId !== "init" && artifact.status !== "streaming"
|
||||||
|
? `/api/document?id=${artifact.documentId}`
|
||||||
|
: null,
|
||||||
|
fetcher
|
||||||
|
);
|
||||||
|
|
||||||
|
const [mode, setMode] = useState<"edit" | "diff">("edit");
|
||||||
|
const [document, setDocument] = useState<Document | null>(null);
|
||||||
|
const [currentVersionIndex, setCurrentVersionIndex] = useState(-1);
|
||||||
|
|
||||||
|
const { open: isSidebarOpen } = useSidebar();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (documents && documents.length > 0) {
|
||||||
|
const mostRecentDocument = documents.at(-1);
|
||||||
|
|
||||||
|
if (mostRecentDocument) {
|
||||||
|
setDocument(mostRecentDocument);
|
||||||
|
setCurrentVersionIndex(documents.length - 1);
|
||||||
|
setArtifact((currentArtifact) => ({
|
||||||
|
...currentArtifact,
|
||||||
|
content: mostRecentDocument.content ?? "",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [documents, setArtifact]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
mutateDocuments();
|
||||||
|
}, [mutateDocuments]);
|
||||||
|
|
||||||
|
const { mutate } = useSWRConfig();
|
||||||
|
const [isContentDirty, setIsContentDirty] = useState(false);
|
||||||
|
|
||||||
|
const handleContentChange = useCallback(
|
||||||
|
(updatedContent: string) => {
|
||||||
|
if (!artifact) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
mutate<Document[]>(
|
||||||
|
`/api/document?id=${artifact.documentId}`,
|
||||||
|
async (currentDocuments) => {
|
||||||
|
if (!currentDocuments) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentDocument = currentDocuments.at(-1);
|
||||||
|
|
||||||
|
if (!currentDocument || !currentDocument.content) {
|
||||||
|
setIsContentDirty(false);
|
||||||
|
return currentDocuments;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentDocument.content !== updatedContent) {
|
||||||
|
await fetch(`/api/document?id=${artifact.documentId}`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: artifact.title,
|
||||||
|
content: updatedContent,
|
||||||
|
kind: artifact.kind,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsContentDirty(false);
|
||||||
|
|
||||||
|
const newDocument = {
|
||||||
|
...currentDocument,
|
||||||
|
content: updatedContent,
|
||||||
|
createdAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
return [...currentDocuments, newDocument];
|
||||||
|
}
|
||||||
|
return currentDocuments;
|
||||||
|
},
|
||||||
|
{ revalidate: false }
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[artifact, mutate]
|
||||||
|
);
|
||||||
|
|
||||||
|
const debouncedHandleContentChange = useDebounceCallback(
|
||||||
|
handleContentChange,
|
||||||
|
2000
|
||||||
|
);
|
||||||
|
|
||||||
|
const saveContent = useCallback(
|
||||||
|
(updatedContent: string, debounce: boolean) => {
|
||||||
|
if (document && updatedContent !== document.content) {
|
||||||
|
setIsContentDirty(true);
|
||||||
|
|
||||||
|
if (debounce) {
|
||||||
|
debouncedHandleContentChange(updatedContent);
|
||||||
|
} else {
|
||||||
|
handleContentChange(updatedContent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[document, debouncedHandleContentChange, handleContentChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
function getDocumentContentById(index: number) {
|
||||||
|
if (!documents) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (!documents[index]) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return documents[index].content ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleVersionChange = (type: "next" | "prev" | "toggle" | "latest") => {
|
||||||
|
if (!documents) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "latest") {
|
||||||
|
setCurrentVersionIndex(documents.length - 1);
|
||||||
|
setMode("edit");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "toggle") {
|
||||||
|
setMode((currentMode) => (currentMode === "edit" ? "diff" : "edit"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "prev") {
|
||||||
|
if (currentVersionIndex > 0) {
|
||||||
|
setCurrentVersionIndex((index) => index - 1);
|
||||||
|
}
|
||||||
|
} else if (type === "next" && currentVersionIndex < documents.length - 1) {
|
||||||
|
setCurrentVersionIndex((index) => index + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const [isToolbarVisible, setIsToolbarVisible] = useState(false);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* NOTE: if there are no documents, or if
|
||||||
|
* the documents are being fetched, then
|
||||||
|
* we mark it as the current version.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const isCurrentVersion =
|
||||||
|
documents && documents.length > 0
|
||||||
|
? currentVersionIndex === documents.length - 1
|
||||||
|
: true;
|
||||||
|
|
||||||
|
const { width: windowWidth, height: windowHeight } = useWindowSize();
|
||||||
|
const isMobile = windowWidth ? windowWidth < 768 : false;
|
||||||
|
|
||||||
|
const artifactDefinition = artifactDefinitions.find(
|
||||||
|
(definition) => definition.kind === artifact.kind
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!artifactDefinition) {
|
||||||
|
throw new Error("Artifact definition not found!");
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (artifact.documentId !== "init" && artifactDefinition.initialize) {
|
||||||
|
artifactDefinition.initialize({
|
||||||
|
documentId: artifact.documentId,
|
||||||
|
setMetadata,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [artifact.documentId, artifactDefinition, setMetadata]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{artifact.isVisible && (
|
||||||
|
<motion.div
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
className="fixed top-0 left-0 z-50 flex h-dvh w-dvw flex-row bg-transparent"
|
||||||
|
data-testid="artifact"
|
||||||
|
exit={{ opacity: 0, transition: { delay: 0.4 } }}
|
||||||
|
initial={{ opacity: 1 }}
|
||||||
|
>
|
||||||
|
{!isMobile && (
|
||||||
|
<motion.div
|
||||||
|
animate={{ width: windowWidth, right: 0 }}
|
||||||
|
className="fixed h-dvh bg-background"
|
||||||
|
exit={{
|
||||||
|
width: isSidebarOpen ? windowWidth - 256 : windowWidth,
|
||||||
|
right: 0,
|
||||||
|
}}
|
||||||
|
initial={{
|
||||||
|
width: isSidebarOpen ? windowWidth - 256 : windowWidth,
|
||||||
|
right: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isMobile && (
|
||||||
|
<motion.div
|
||||||
|
animate={{
|
||||||
|
opacity: 1,
|
||||||
|
x: 0,
|
||||||
|
scale: 1,
|
||||||
|
transition: {
|
||||||
|
delay: 0.1,
|
||||||
|
type: "spring",
|
||||||
|
stiffness: 300,
|
||||||
|
damping: 30,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
className="relative h-dvh w-[400px] shrink-0 bg-muted dark:bg-background"
|
||||||
|
exit={{
|
||||||
|
opacity: 0,
|
||||||
|
x: 0,
|
||||||
|
scale: 1,
|
||||||
|
transition: { duration: 0 },
|
||||||
|
}}
|
||||||
|
initial={{ opacity: 0, x: 10, scale: 1 }}
|
||||||
|
>
|
||||||
|
<AnimatePresence>
|
||||||
|
{!isCurrentVersion && (
|
||||||
|
<motion.div
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
className="absolute top-0 left-0 z-50 h-dvh w-[400px] bg-zinc-900/50"
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
<div className="flex h-full flex-col items-center justify-between">
|
||||||
|
<ArtifactMessages
|
||||||
|
artifactStatus={artifact.status}
|
||||||
|
chatId={chatId}
|
||||||
|
isReadonly={isReadonly}
|
||||||
|
messages={messages}
|
||||||
|
regenerate={regenerate}
|
||||||
|
sendMessage={sendMessage}
|
||||||
|
setMessages={setMessages}
|
||||||
|
status={status}
|
||||||
|
votes={votes}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="relative flex w-full flex-row items-end gap-2 px-4 pb-4">
|
||||||
|
<MultimodalInput
|
||||||
|
attachments={attachments}
|
||||||
|
chatId={chatId}
|
||||||
|
className="bg-background dark:bg-muted"
|
||||||
|
input={input}
|
||||||
|
messages={messages}
|
||||||
|
selectedModelId={selectedModelId}
|
||||||
|
selectedVisibilityType={selectedVisibilityType}
|
||||||
|
sendMessage={sendMessage}
|
||||||
|
setAttachments={setAttachments}
|
||||||
|
setInput={setInput}
|
||||||
|
setMessages={setMessages}
|
||||||
|
status={status}
|
||||||
|
stop={stop}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
animate={
|
||||||
|
isMobile
|
||||||
|
? {
|
||||||
|
opacity: 1,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
height: windowHeight,
|
||||||
|
width: windowWidth ? windowWidth : "calc(100dvw)",
|
||||||
|
borderRadius: 0,
|
||||||
|
transition: {
|
||||||
|
delay: 0,
|
||||||
|
type: "spring",
|
||||||
|
stiffness: 300,
|
||||||
|
damping: 30,
|
||||||
|
duration: 0.8,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
opacity: 1,
|
||||||
|
x: 400,
|
||||||
|
y: 0,
|
||||||
|
height: windowHeight,
|
||||||
|
width: windowWidth
|
||||||
|
? windowWidth - 400
|
||||||
|
: "calc(100dvw-400px)",
|
||||||
|
borderRadius: 0,
|
||||||
|
transition: {
|
||||||
|
delay: 0,
|
||||||
|
type: "spring",
|
||||||
|
stiffness: 300,
|
||||||
|
damping: 30,
|
||||||
|
duration: 0.8,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
className="fixed flex h-dvh flex-col overflow-y-scroll border-zinc-200 bg-background md:border-l dark:border-zinc-700 dark:bg-muted"
|
||||||
|
exit={{
|
||||||
|
opacity: 0,
|
||||||
|
scale: 0.5,
|
||||||
|
transition: {
|
||||||
|
delay: 0.1,
|
||||||
|
type: "spring",
|
||||||
|
stiffness: 600,
|
||||||
|
damping: 30,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
initial={
|
||||||
|
isMobile
|
||||||
|
? {
|
||||||
|
opacity: 1,
|
||||||
|
x: artifact.boundingBox.left,
|
||||||
|
y: artifact.boundingBox.top,
|
||||||
|
height: artifact.boundingBox.height,
|
||||||
|
width: artifact.boundingBox.width,
|
||||||
|
borderRadius: 50,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
opacity: 1,
|
||||||
|
x: artifact.boundingBox.left,
|
||||||
|
y: artifact.boundingBox.top,
|
||||||
|
height: artifact.boundingBox.height,
|
||||||
|
width: artifact.boundingBox.width,
|
||||||
|
borderRadius: 50,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex flex-row items-start justify-between p-2">
|
||||||
|
<div className="flex flex-row items-start gap-4">
|
||||||
|
<ArtifactCloseButton />
|
||||||
|
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="font-medium">{artifact.title}</div>
|
||||||
|
|
||||||
|
{isContentDirty ? (
|
||||||
|
<div className="text-muted-foreground text-sm">
|
||||||
|
Saving changes...
|
||||||
|
</div>
|
||||||
|
) : document ? (
|
||||||
|
<div className="text-muted-foreground text-sm">
|
||||||
|
{`Updated ${formatDistance(
|
||||||
|
new Date(document.createdAt),
|
||||||
|
new Date(),
|
||||||
|
{
|
||||||
|
addSuffix: true,
|
||||||
|
}
|
||||||
|
)}`}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-2 h-3 w-32 animate-pulse rounded-md bg-muted-foreground/20" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ArtifactActions
|
||||||
|
artifact={artifact}
|
||||||
|
currentVersionIndex={currentVersionIndex}
|
||||||
|
handleVersionChange={handleVersionChange}
|
||||||
|
isCurrentVersion={isCurrentVersion}
|
||||||
|
metadata={metadata}
|
||||||
|
mode={mode}
|
||||||
|
setMetadata={setMetadata}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-full max-w-full! items-center overflow-y-scroll bg-background dark:bg-muted">
|
||||||
|
<artifactDefinition.content
|
||||||
|
content={
|
||||||
|
isCurrentVersion
|
||||||
|
? artifact.content
|
||||||
|
: getDocumentContentById(currentVersionIndex)
|
||||||
|
}
|
||||||
|
currentVersionIndex={currentVersionIndex}
|
||||||
|
getDocumentContentById={getDocumentContentById}
|
||||||
|
isCurrentVersion={isCurrentVersion}
|
||||||
|
isInline={false}
|
||||||
|
isLoading={isDocumentsFetching && !artifact.content}
|
||||||
|
metadata={metadata}
|
||||||
|
mode={mode}
|
||||||
|
onSaveContent={saveContent}
|
||||||
|
setMetadata={setMetadata}
|
||||||
|
status={artifact.status}
|
||||||
|
suggestions={[]}
|
||||||
|
title={artifact.title}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{isCurrentVersion && (
|
||||||
|
<Toolbar
|
||||||
|
artifactKind={artifact.kind}
|
||||||
|
isToolbarVisible={isToolbarVisible}
|
||||||
|
sendMessage={sendMessage}
|
||||||
|
setIsToolbarVisible={setIsToolbarVisible}
|
||||||
|
setMessages={setMessages}
|
||||||
|
status={status}
|
||||||
|
stop={stop}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{!isCurrentVersion && (
|
||||||
|
<VersionFooter
|
||||||
|
currentVersionIndex={currentVersionIndex}
|
||||||
|
documents={documents}
|
||||||
|
handleVersionChange={handleVersionChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Artifact = memo(PureArtifact, (prevProps, nextProps) => {
|
||||||
|
if (prevProps.status !== nextProps.status) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!equal(prevProps.votes, nextProps.votes)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (prevProps.input !== nextProps.input) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!equal(prevProps.messages, nextProps.messages.length)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (prevProps.selectedVisibilityType !== nextProps.selectedVisibilityType) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
62
components/auth-form.tsx
Normal file
62
components/auth-form.tsx
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import Form from "next/form";
|
||||||
|
|
||||||
|
import { Input } from "./ui/input";
|
||||||
|
import { Label } from "./ui/label";
|
||||||
|
|
||||||
|
export function AuthForm({
|
||||||
|
action,
|
||||||
|
children,
|
||||||
|
defaultEmail = "",
|
||||||
|
className = "",
|
||||||
|
}: {
|
||||||
|
action: NonNullable<
|
||||||
|
string | ((formData: FormData) => void | Promise<void>) | undefined
|
||||||
|
>;
|
||||||
|
children: React.ReactNode;
|
||||||
|
defaultEmail?: string;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Form action={action} className={`flex flex-col gap-4 px-4 sm:px-16 ${className}`}>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label
|
||||||
|
className="font-normal text-zinc-600 dark:text-zinc-400"
|
||||||
|
htmlFor="email"
|
||||||
|
>
|
||||||
|
邮箱地址
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
autoComplete="email"
|
||||||
|
autoFocus
|
||||||
|
className="bg-muted text-md md:text-sm"
|
||||||
|
defaultValue={defaultEmail}
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
placeholder="user@example.com"
|
||||||
|
required
|
||||||
|
type="email"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label
|
||||||
|
className="font-normal text-zinc-600 dark:text-zinc-400"
|
||||||
|
htmlFor="password"
|
||||||
|
>
|
||||||
|
登录密码
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
className="bg-muted text-md md:text-sm"
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
required
|
||||||
|
type="password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{children}
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
components/chat-header.tsx
Normal file
61
components/chat-header.tsx
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { memo } from "react";
|
||||||
|
import { useWindowSize } from "usehooks-ts";
|
||||||
|
import { SidebarToggle } from "@/components/sidebar-toggle";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { PlusIcon } from "@/components/icons";
|
||||||
|
import { useSidebar } from "@/components/ui/sidebar";
|
||||||
|
import { VisibilitySelector, type VisibilityType } from "@/components/visibility-selector";
|
||||||
|
|
||||||
|
function PureChatHeader({
|
||||||
|
chatId,
|
||||||
|
selectedVisibilityType,
|
||||||
|
isReadonly,
|
||||||
|
}: {
|
||||||
|
chatId: string;
|
||||||
|
selectedVisibilityType: VisibilityType;
|
||||||
|
isReadonly: boolean;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { open } = useSidebar();
|
||||||
|
|
||||||
|
const { width: windowWidth } = useWindowSize();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="sticky top-0 flex items-center gap-2 bg-background px-2 py-1.5 md:px-2">
|
||||||
|
<SidebarToggle />
|
||||||
|
|
||||||
|
{(!open || windowWidth < 768) && (
|
||||||
|
<Button
|
||||||
|
className="order-2 ml-auto h-8 px-2 md:order-1 md:ml-0 md:h-fit md:px-2"
|
||||||
|
onClick={() => {
|
||||||
|
router.push("/");
|
||||||
|
router.refresh();
|
||||||
|
}}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
<PlusIcon />
|
||||||
|
<span className="md:sr-only">新建会话</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isReadonly && (
|
||||||
|
<VisibilitySelector
|
||||||
|
chatId={chatId}
|
||||||
|
className="order-1 md:order-2"
|
||||||
|
selectedVisibilityType={selectedVisibilityType}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ChatHeader = memo(PureChatHeader, (prevProps, nextProps) => {
|
||||||
|
return (
|
||||||
|
prevProps.chatId === nextProps.chatId &&
|
||||||
|
prevProps.selectedVisibilityType === nextProps.selectedVisibilityType &&
|
||||||
|
prevProps.isReadonly === nextProps.isReadonly
|
||||||
|
);
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user