-
-
Notifications
You must be signed in to change notification settings - Fork 1.6k
feat(stage-ui): character settings page #847
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
Co-authored-by: Neko Ayaka <[email protected]> Co-authored-by: Lovehsigure_520 <[email protected]>
… chat functionality
…add character routes
…er routes with session management
…prove character route responses
…, and delete functionalities
…d enhance Button component with new 'ghost' variant
…r and chat schemas, and update related services and components
… foreign key constraint with characters table
…d character_i18n tables for soft delete functionality
Summary of ChangesHello @luoling8192, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! This pull request significantly enhances the application's architecture by introducing a new, dedicated API server. This server centralizes authentication and provides robust CRUD functionalities for managing AI characters and their associated data models. The existing web application has been updated to seamlessly integrate with these new backend services, offering users a streamlined experience for authentication and character configuration. This foundational work paves the way for a more scalable and feature-rich platform. Highlights
Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here. You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension. Footnotes
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Code Review
This pull request introduces a new API server for the application, complete with database schemas, services, authentication, and API routes. It also adds corresponding frontend components for user authentication and character management. The changes are extensive and well-structured, using dependency injection, service-oriented architecture, and clear separation of concerns. However, there are several critical and high-severity issues that need to be addressed, particularly in the Docker configuration, dependency versions, database connection handling, and security settings. There are also opportunities to improve data integrity in the database schema and reduce code duplication between the frontend and backend. One security-related comment has been updated to reference a rule regarding environment variable validation on startup.
| timeout: 5s | ||
| retries: 10 | ||
| volumes: | ||
| - ${serviceName}_data:/var/lib/postgresql/data |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The volume for the db service is defined as ${serviceName}_data, but the named volume at the root of the file is db_data. The serviceName variable is not defined in this file's context, so this will result in a volume named _data being created, and the db_data volume will be unused. This will cause the database to lose all its data if the container is removed and recreated.
- db_data:/var/lib/postgresql/data| "drizzle-orm": "^0.44.7", | ||
| "drizzle-valibot": "catalog:", | ||
| "hono": "^4.10.7", | ||
| "injeca": "catalog:", | ||
| "postgres": "^3.4.7", | ||
| "tsx": "^4.21.0", | ||
| "valibot": "catalog:" | ||
| }, | ||
| "devDependencies": { | ||
| "@better-auth/cli": "^1.4.5", | ||
| "drizzle-kit": "^0.31.7" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The versions specified for drizzle-orm (^0.44.7) and drizzle-kit (^0.31.7) do not appear to exist in the public npm registry. This will likely cause the pnpm install command to fail for anyone without access to a private registry where these versions might be hosted. Please verify that these versions are correct. If they are typos, they should be corrected to valid, publicly available versions.
| auth = { | ||
| $Infer: { | ||
| Session: { | ||
| user: {}, | ||
| session: {}, | ||
| }, | ||
| }, | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| build: ({ dependsOn }) => { | ||
| const dbInstance = createDrizzle(dependsOn.env.DATABASE_URL, schema) | ||
| dbInstance.execute('SELECT 1') | ||
| .then(() => logger.log('Connected to database')) | ||
| .catch((err) => { | ||
| logger.withError(err).error('Failed to connect to database') | ||
| exit(1) | ||
| }) | ||
| return dbInstance | ||
| }, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The database connection check is performed asynchronously with .then() and .catch(), but the build function for the services:db provider is synchronous and returns immediately. This creates a race condition where the application might start handling requests before the database connection is confirmed to be healthy. The build function should be async and await the connection check to ensure the database is ready before the service is considered built.
build: async ({ dependsOn }) => {
const dbInstance = createDrizzle(dependsOn.env.DATABASE_URL, schema)
try {
await dbInstance.execute('SELECT 1')
logger.log('Connected to database')
} catch (err) {
logger.withError(err).error('Failed to connect to database')
exit(1)
}
return dbInstance
},| updatedAt: timestamp('updated_at') | ||
| .$onUpdate(() => /* @__PURE__ */ new Date()) | ||
| .notNull(), | ||
| ipAddress: text('ip_address'), | ||
| userAgent: text('user_agent'), | ||
| userId: text('user_id') | ||
| .notNull() | ||
| .references(() => user.id, { onDelete: 'cascade' }), | ||
| }, | ||
| table => [index('session_userId_idx').on(table.userId)], | ||
| ) | ||
|
|
||
| export const account = pgTable( | ||
| 'account', | ||
| { | ||
| id: text('id').primaryKey(), | ||
| accountId: text('account_id').notNull(), | ||
| providerId: text('provider_id').notNull(), | ||
| userId: text('user_id') | ||
| .notNull() | ||
| .references(() => user.id, { onDelete: 'cascade' }), | ||
| accessToken: text('access_token'), | ||
| refreshToken: text('refresh_token'), | ||
| idToken: text('id_token'), | ||
| accessTokenExpiresAt: timestamp('access_token_expires_at'), | ||
| refreshTokenExpiresAt: timestamp('refresh_token_expires_at'), | ||
| scope: text('scope'), | ||
| password: text('password'), | ||
| createdAt: timestamp('created_at').defaultNow().notNull(), | ||
| updatedAt: timestamp('updated_at') | ||
| .$onUpdate(() => /* @__PURE__ */ new Date()) | ||
| .notNull(), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The updatedAt columns in the session and account tables are defined as NOT NULL and have an $onUpdate hook, but they are missing a .defaultNow() call. This means that on INSERT, if a value for updatedAt is not explicitly provided, the database will try to insert NULL, which violates the NOT NULL constraint. You should add .defaultNow() to these columns for consistency with other tables and to prevent insertion errors.
| }, | ||
|
|
||
| baseURL: process.env.API_SERVER_URL || 'http://localhost:3000', | ||
| trustedOrigins: ['*'], |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Setting trustedOrigins: ['*'] disables CSRF protection provided by better-auth by allowing any origin. This is a significant security risk. While the CORS middleware in app.ts is configured correctly with getTrustedOrigin, this better-auth setting should also be made secure. You should use a specific list of trusted origins, preferably from an environment variable, and ensure a fallback is provided to prevent issues from undefined values.
| trustedOrigins: ['*'], | |
| trustedOrigins: [process.env.WEB_URL || 'http://localhost:5173'], |
References
- Validate environment variables on startup to ensure all required variables are present, preventing runtime errors from undefined values.
| updatedAt: timestamp('updated_at').defaultNow().notNull(), | ||
| deletedAt: timestamp('deleted_at'), | ||
| }, | ||
| ) | ||
|
|
||
| export type Character = InferSelectModel<typeof character> | ||
| export type NewCharacter = InferInsertModel<typeof character> | ||
|
|
||
| export const avatarModel = pgTable( | ||
| 'avatar_model', | ||
| { | ||
| id: text('id').primaryKey().$defaultFn(() => nanoid()), | ||
| characterId: text('character_id').notNull().references(() => character.id, { onDelete: 'cascade' }), | ||
| name: text('name').notNull(), | ||
| type: text('type').notNull().$type<keyof AvatarModelConfig>(), | ||
|
|
||
| description: text('description').notNull(), | ||
|
|
||
| config: jsonb('config').notNull().$type<AvatarModelConfig[keyof AvatarModelConfig]>(), | ||
| createdAt: timestamp('created_at').defaultNow().notNull(), | ||
| updatedAt: timestamp('updated_at').defaultNow().notNull(), | ||
| deletedAt: timestamp('deleted_at'), | ||
| }, | ||
| ) | ||
|
|
||
| export type AvatarModel = InferSelectModel<typeof avatarModel> | ||
| export type NewAvatarModel = InferInsertModel<typeof avatarModel> | ||
|
|
||
| export const characterCapabilities = pgTable( | ||
| 'character_capabilities', | ||
| { | ||
| id: text('id').primaryKey().$defaultFn(() => nanoid()), | ||
| characterId: text('character_id').notNull().references(() => character.id, { onDelete: 'cascade' }), | ||
|
|
||
| type: text('type').notNull().$type<keyof CharacterCapabilityConfig>(), | ||
|
|
||
| config: jsonb('config').notNull().$type<CharacterCapabilityConfig[keyof CharacterCapabilityConfig]>(), | ||
| }, | ||
| ) | ||
|
|
||
| export type CharacterCapability = InferSelectModel<typeof characterCapabilities> | ||
| export type NewCharacterCapability = InferInsertModel<typeof characterCapabilities> | ||
|
|
||
| export const characterI18n = pgTable( | ||
| 'character_i18n', | ||
| { | ||
| id: text('id').primaryKey().$defaultFn(() => nanoid()), | ||
| characterId: text('character_id').notNull().references(() => character.id, { onDelete: 'cascade' }), | ||
|
|
||
| language: text('language').notNull(), | ||
|
|
||
| name: text('name').notNull(), | ||
| description: text('description').notNull(), | ||
| tags: text('tags').array().notNull(), | ||
|
|
||
| // TODO: Implement the system prompt | ||
| // systemPrompt: text('system_prompt').notNull(), | ||
| // TODO: Implement the personality | ||
| // personality: text('personality').notNull(), | ||
|
|
||
| // TODO: Implement the initial memories | ||
| // initialMemories: text('initial_memories').array().notNull(), | ||
|
|
||
| // TODO: greetings? | ||
| // TODO: notes? | ||
| // TODO: metadata? | ||
|
|
||
| createdAt: timestamp('created_at').defaultNow().notNull(), | ||
| updatedAt: timestamp('updated_at').defaultNow().notNull(), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The updatedAt columns in the character, avatarModel, and characterI18n tables are missing the $onUpdate hook. While the service layer currently handles updating this field manually, it's best practice to define this behavior at the schema level for consistency and to prevent accidental omissions in the future. Please add .$onUpdate(() => new Date()) to these columns.
| replyToMessageId: text('reply_message_id'), | ||
| forwardFromMessageId: text('forward_from_message_id'), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The replyToMessageId and forwardFromMessageId columns in the messages table are defined as text but seem to reference another message's ID. For data integrity, these should be defined as foreign keys referencing messages.id. This would prevent dangling references where a message refers to a reply/forward that no longer exists.
| replyToMessageId: text('reply_message_id'), | |
| forwardFromMessageId: text('forward_from_message_id'), | |
| replyToMessageId: text('reply_message_id').references(() => messages.id), | |
| forwardFromMessageId: text('forward_from_message_id').references(() => messages.id), |
| import type { InferOutput } from 'valibot' | ||
|
|
||
| import { array, date, literal, number, object, optional, pipe, string, transform, union } from 'valibot' | ||
|
|
||
| // --- Enums & Configs --- | ||
|
|
||
| export const AvatarModelConfigSchema = object({ | ||
| vrm: optional(object({ | ||
| urls: array(string()), | ||
| })), | ||
| live2d: optional(object({ | ||
| urls: array(string()), | ||
| })), | ||
| }) | ||
|
|
||
| export const CharacterCapabilityConfigSchema = object({ | ||
| apiKey: string(), | ||
| apiBaseUrl: string(), | ||
| llm: optional(object({ | ||
| temperature: number(), | ||
| model: string(), | ||
| })), | ||
| tts: optional(object({ | ||
| ssml: string(), | ||
| voiceId: string(), | ||
| speed: number(), | ||
| pitch: number(), | ||
| })), | ||
| vlm: optional(object({ | ||
| image: string(), | ||
| })), | ||
| asr: optional(object({ | ||
| audio: string(), | ||
| })), | ||
| }) | ||
|
|
||
| const CharacterCapabilityTypeSchema = union([ | ||
| literal('llm'), | ||
| literal('tts'), | ||
| literal('vlm'), | ||
| literal('asr'), | ||
| ]) | ||
|
|
||
| const AvatarModelTypeSchema = union([ | ||
| literal('vrm'), | ||
| literal('live2d'), | ||
| ]) | ||
|
|
||
| const PromptTypeSchema = union([ | ||
| literal('system'), | ||
| literal('personality'), | ||
| literal('greetings'), | ||
| ]) | ||
|
|
||
| const DateSchema = pipe( | ||
| union([string(), date()]), | ||
| transform(v => new Date(v)), | ||
| ) | ||
|
|
||
| // --- Base Entities (mimicking database tables) --- | ||
|
|
||
| export const CharacterBaseSchema = object({ | ||
| id: string(), | ||
| version: string(), | ||
| coverUrl: string(), | ||
| creatorId: string(), | ||
| ownerId: string(), | ||
| characterId: string(), | ||
| createdAt: DateSchema, | ||
| updatedAt: DateSchema, | ||
| }) | ||
|
|
||
| export const CharacterCapabilitySchema = object({ | ||
| id: string(), | ||
| characterId: string(), | ||
| type: CharacterCapabilityTypeSchema, | ||
| config: CharacterCapabilityConfigSchema, | ||
| }) | ||
|
|
||
| export const AvatarModelSchema = object({ | ||
| id: string(), | ||
| characterId: string(), | ||
| name: string(), | ||
| type: AvatarModelTypeSchema, | ||
| description: string(), | ||
| config: AvatarModelConfigSchema, | ||
| createdAt: DateSchema, | ||
| updatedAt: DateSchema, | ||
| }) | ||
|
|
||
| export const CharacterI18nSchema = object({ | ||
| id: string(), | ||
| characterId: string(), | ||
| language: string(), | ||
| name: string(), | ||
| description: string(), | ||
| tags: array(string()), | ||
| createdAt: DateSchema, | ||
| updatedAt: DateSchema, | ||
| }) | ||
|
|
||
| export const CharacterPromptSchema = object({ | ||
| id: string(), | ||
| characterId: string(), | ||
| language: string(), | ||
| type: PromptTypeSchema, | ||
| content: string(), | ||
| }) | ||
|
|
||
| // --- Aggregated Character (with relations) --- | ||
|
|
||
| export const CharacterWithRelationsSchema = object({ | ||
| ...CharacterBaseSchema.entries, | ||
| capabilities: array(CharacterCapabilitySchema), | ||
| avatarModels: array(AvatarModelSchema), | ||
| i18n: array(CharacterI18nSchema), | ||
| prompts: array(CharacterPromptSchema), | ||
| }) | ||
|
|
||
| // --- API Request Schemas --- | ||
|
|
||
| export const CreateCharacterSchema = object({ | ||
| character: object({ | ||
| version: string(), | ||
| coverUrl: string(), | ||
| characterId: string(), | ||
| // creatorId & ownerId are handled by server | ||
| }), | ||
| capabilities: optional(array(object({ | ||
| type: CharacterCapabilityTypeSchema, | ||
| config: CharacterCapabilityConfigSchema, | ||
| }))), | ||
| avatarModels: optional(array(object({ | ||
| name: string(), | ||
| type: AvatarModelTypeSchema, | ||
| description: string(), | ||
| config: AvatarModelConfigSchema, | ||
| }))), | ||
| i18n: optional(array(object({ | ||
| language: string(), | ||
| name: string(), | ||
| description: string(), | ||
| tags: array(string()), | ||
| }))), | ||
| prompts: optional(array(object({ | ||
| language: string(), | ||
| type: PromptTypeSchema, | ||
| content: string(), | ||
| }))), | ||
| }) | ||
|
|
||
| export const UpdateCharacterSchema = object({ | ||
| version: optional(string()), | ||
| coverUrl: optional(string()), | ||
| characterId: optional(string()), | ||
| }) | ||
|
|
||
| // --- Type Exports --- | ||
|
|
||
| export type Character = InferOutput<typeof CharacterWithRelationsSchema> | ||
| export type CharacterBase = InferOutput<typeof CharacterBaseSchema> | ||
| export type CharacterCapability = InferOutput<typeof CharacterCapabilitySchema> | ||
| export type AvatarModel = InferOutput<typeof AvatarModelSchema> | ||
| export type CharacterI18n = InferOutput<typeof CharacterI18nSchema> | ||
| export type CharacterPrompt = InferOutput<typeof CharacterPromptSchema> | ||
|
|
||
| export type CreateCharacterPayload = InferOutput<typeof CreateCharacterSchema> | ||
| export type UpdateCharacterPayload = InferOutput<typeof UpdateCharacterSchema> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This file duplicates a significant amount of schema and type definitions that also exist on the backend in apps/server/src/api/characters.schema.ts. This code duplication can lead to inconsistencies and maintenance overhead. Consider creating a shared package (e.g., packages/shared-types) within the monorepo to house these common types so they can be imported by both the frontend and backend.
Co-authored-by: Neko Ayaka <[email protected]> Co-authored-by: Lovehsigure_520 <[email protected]>
⏳ Approval required for deploying to Cloudflare Workers (Preview) for stage-web.
Hey, @nekomeowww, @sumimakito, @luoling8192, @LemonNekoGH, kindly take some time to review and approve this deployment when you are available. Thank you! 🙏 |
depends #848