diff --git a/packages/@buildingai/constants/src/shared/auth.ts b/packages/@buildingai/constants/src/shared/auth.ts index 3ec7103c7..5a55aa235 100644 --- a/packages/@buildingai/constants/src/shared/auth.ts +++ b/packages/@buildingai/constants/src/shared/auth.ts @@ -13,5 +13,7 @@ export const LOGIN_TYPE = { PHONE: 2, /** 微信登录 */ WECHAT: 3, + /** Google登录 */ + GOOGLE: 5, } as const; export type LoginType = (typeof LOGIN_TYPE)[keyof typeof LOGIN_TYPE]; diff --git a/packages/@buildingai/db/src/entities/user.entity.ts b/packages/@buildingai/db/src/entities/user.entity.ts index 36fc676c6..27da8fb0c 100644 --- a/packages/@buildingai/db/src/entities/user.entity.ts +++ b/packages/@buildingai/db/src/entities/user.entity.ts @@ -37,6 +37,12 @@ export class User extends SoftDeleteBaseEntity { @Column({ nullable: true, unique: true, comment: "用户unionid" }) unionid: string; + /** + * Google OAuth openid + */ + @Column({ nullable: true, comment: "Google OAuth openid" }) + googleOpenid: string; + @Column({ nullable: true }) userNo: string; /** diff --git a/packages/@buildingai/db/src/seeds/data/menu.json b/packages/@buildingai/db/src/seeds/data/menu.json index 9d85f4580..96de2c652 100644 --- a/packages/@buildingai/db/src/seeds/data/menu.json +++ b/packages/@buildingai/db/src/seeds/data/menu.json @@ -386,6 +386,18 @@ "isHidden": 0, "type": 2, "sourceType": 1 + }, + { + "name": "Google登录", + "code": "channel-google", + "path": "google", + "icon": "google", + "component": "/console/channel/google/index", + "permissionCode": "google-config:get-config", + "sort": 1, + "isHidden": 0, + "type": 2, + "sourceType": 1 } ] }, diff --git a/packages/@buildingai/utils/src/is.ts b/packages/@buildingai/utils/src/is.ts index 85db84824..18e1e1787 100644 --- a/packages/@buildingai/utils/src/is.ts +++ b/packages/@buildingai/utils/src/is.ts @@ -110,3 +110,9 @@ export function isAsyncGenerator( ): value is AsyncGenerator { return value != null && typeof value === "object" && Symbol.asyncIterator in value; } + +export function getFrontendBaseUrl() { + return isDevelopment() + ? (process.env.CLIENT_URL || "http://localhost:4091").replace(/\/$/, "") + : (process.env.APP_DOMAIN || "http://localhost:4090").replace(/\/$/, ""); +} diff --git a/packages/@buildingai/web/services/src/console/user.ts b/packages/@buildingai/web/services/src/console/user.ts index 149cd98cb..e65a76f17 100644 --- a/packages/@buildingai/web/services/src/console/user.ts +++ b/packages/@buildingai/web/services/src/console/user.ts @@ -369,6 +369,37 @@ export function useUpdateWxOaConfigMutation( }); } +/** Google登录配置(接口返回) */ +export type GoogleConfigResponse = { + clientId: string; + clientSecret: string; + enabled: boolean; +}; + +/** 更新Google登录配置 DTO */ +export type UpdateGoogleConfigDto = { + clientId?: string; + clientSecret?: string; + enabled?: boolean; +}; + +export function useGoogleConfigQuery(options?: QueryOptionsUtil) { + return useQuery({ + queryKey: ["channel", "google-config"], + queryFn: () => consoleHttpClient.get("/google-config"), + ...options, + }); +} + +export function useUpdateGoogleConfigMutation( + options?: MutationOptionsUtil<{ success: boolean }, UpdateGoogleConfigDto>, +) { + return useMutation<{ success: boolean }, Error, UpdateGoogleConfigDto>({ + mutationFn: (dto) => consoleHttpClient.patch<{ success: boolean }>("/google-config", dto), + ...options, + }); +} + // User subscription types export type UserSubscriptionLevel = { id: string; diff --git a/packages/@buildingai/web/ui/src/layouts/extension/console/_components/app-navbar.tsx b/packages/@buildingai/web/ui/src/layouts/extension/console/_components/app-navbar.tsx index 52e303f59..75fd0d966 100644 --- a/packages/@buildingai/web/ui/src/layouts/extension/console/_components/app-navbar.tsx +++ b/packages/@buildingai/web/ui/src/layouts/extension/console/_components/app-navbar.tsx @@ -1,4 +1,5 @@ import { ReloadWindow } from "@buildingai/ui/components/reload-windows"; +import { useI18n } from "@buildingai/i18n"; import { Breadcrumb, BreadcrumbItem, @@ -61,6 +62,7 @@ function findBreadcrumbTrail( } const AppNavbar = ({ menus }: { menus: ExtensionMenuItem[] }) => { + const { t } = useI18n(); const { state } = useSidebar(); const location = useLocation(); const navigate = useNavigate(); @@ -78,7 +80,7 @@ const AppNavbar = ({ menus }: { menus: ExtensionMenuItem[] }) => { -

{state === "expanded" ? "收起侧边栏" : "展开侧边栏"}

+

{state === "expanded" ? t("common.collapseSidebar") : t("common.expandSidebar")}

diff --git a/packages/@buildingai/web/ui/src/layouts/extension/console/_components/console-logo.tsx b/packages/@buildingai/web/ui/src/layouts/extension/console/_components/console-logo.tsx index 85b94042b..0d5b6cec2 100644 --- a/packages/@buildingai/web/ui/src/layouts/extension/console/_components/console-logo.tsx +++ b/packages/@buildingai/web/ui/src/layouts/extension/console/_components/console-logo.tsx @@ -1,4 +1,5 @@ import { useExtensionDetailQuery } from "@buildingai/services/console"; +import { useI18n } from "@buildingai/i18n"; import { useConfigStore } from "@buildingai/stores"; import { Avatar, AvatarFallback, AvatarImage } from "@buildingai/ui/components/ui/avatar"; import { @@ -15,6 +16,7 @@ export interface ConsoleLogoProps { } export function ConsoleLogo({ identifier }: ConsoleLogoProps) { + const { t } = useI18n(); const { websiteConfig } = useConfigStore((state) => state.config); const { data: extension, isLoading } = useExtensionDetailQuery(identifier || "", { enabled: !!identifier, @@ -54,7 +56,7 @@ export function ConsoleLogo({ identifier }: ConsoleLogoProps) { {extension?.name || websiteConfig?.webinfo.name} - {extension?.description || "插件管理 · 工作台"} + {extension?.description || t("extension.pluginManagementWorkspace")} diff --git a/packages/@buildingai/web/ui/src/layouts/extension/console/_components/nav-user.tsx b/packages/@buildingai/web/ui/src/layouts/extension/console/_components/nav-user.tsx index 0b377c02d..4b6f95e10 100644 --- a/packages/@buildingai/web/ui/src/layouts/extension/console/_components/nav-user.tsx +++ b/packages/@buildingai/web/ui/src/layouts/extension/console/_components/nav-user.tsx @@ -1,4 +1,5 @@ import { useAuthStore } from "@buildingai/stores"; +import { useI18n } from "@buildingai/i18n"; import { Avatar, AvatarFallback, AvatarImage } from "@buildingai/ui/components/ui/avatar"; import { DropdownMenu, @@ -18,6 +19,7 @@ import { ChevronsUpDown, LogOut, User } from "lucide-react"; import { useLocation, useNavigate } from "react-router-dom"; export function NavUser() { + const { t } = useI18n(); const { isMobile } = useSidebar(); const { userInfo } = useAuthStore((state) => state.auth); const { logout, isLogin } = useAuthStore((state) => state.authActions); @@ -48,13 +50,13 @@ export function NavUser() {
- {userInfo?.nickname || "未登录"} + {userInfo?.nickname || t("common.notLoggedIn")} {isLogin() ? isEnabled(userInfo?.isRoot) - ? "超级管理员" - : userInfo?.role?.name || "未设置角色" - : "请先登录后使用"} + ? t("common.superAdmin") + : userInfo?.role?.name || t("common.noRole") + : t("common.pleaseLoginFirst")}
@@ -69,8 +71,10 @@ export function NavUser() { { await confirm({ - title: "退出确认", - description: "确定要退出登录吗?", + title: t("common.logoutConfirm"), + description: t("common.logoutConfirmDesc"), + confirmText: t("common.action.confirm"), + cancelText: t("common.action.cancel"), }); await logout(); const redirect = encodeURIComponent(location.pathname + location.search); @@ -81,7 +85,7 @@ export function NavUser() { }} > - 退出登录 + {t("common.logout")} diff --git a/packages/@buildingai/web/ui/src/layouts/styles/default/_components/default-nav-main.tsx b/packages/@buildingai/web/ui/src/layouts/styles/default/_components/default-nav-main.tsx index de5943f8e..96dcf6bfa 100644 --- a/packages/@buildingai/web/ui/src/layouts/styles/default/_components/default-nav-main.tsx +++ b/packages/@buildingai/web/ui/src/layouts/styles/default/_components/default-nav-main.tsx @@ -1,5 +1,6 @@ "use client"; +import { useI18n } from "@buildingai/i18n"; import { type ConversationRecord, useConversationsQuery, @@ -444,6 +445,7 @@ function ChatHistoryMenuItem({ isActive: boolean; onOpenDialog: () => void; }) { + const { t } = useI18n(); const { pathname } = useLocation(); const { state } = useSidebar(); const { isLogin } = useAuthStore((state) => state.authActions); @@ -452,7 +454,7 @@ function ChatHistoryMenuItem({ return ( - {item.title} + {t(`sidebar.${item.id}`) || item.title} @@ -509,9 +511,9 @@ function ChatHistoryMenuItem({ - 查看全部 + {t("chat.viewAll")} - 查看全部 + {t("chat.viewAll")} path === pathname; + const { t } = useI18n(); return ( - + {item.icon && } - {item.title} + {t(`sidebar.${item.id}`)} @@ -570,11 +573,12 @@ function CollapsibleMenuItem({ item, isActive }: { item: NavItem; isActive: bool */ function LinkMenuItem({ item, isActive }: { item: NavItem; isActive: boolean }) { const isExternal = item.target === "_blank"; + const { t } = useI18n(); const content = ( <> {item.icon && } - {item.title} + {t(`sidebar.${item.id}`) || item.title} {item.action} {isExternal && ( @@ -585,7 +589,7 @@ function LinkMenuItem({ item, isActive }: { item: NavItem; isActive: boolean }) return ( state.authActions); @@ -712,7 +717,6 @@ export function DefaultNavMain({ const renderMenuItem = (item: NavItem) => { const hasItems = item.items && item.items.length > 0; const isChatHistory = item.id === "menu_history_fixed"; - if (isChatHistory) { return ( + {conversations.map((conversation) => ( ) { + const { t } = useI18n(); const navigate = useNavigate(); const { userInfo } = useAuthStore((state) => state.auth); const { data: menuConfig, isLoading: isMenuLoading } = useDecorateMenuQuery(); @@ -115,7 +117,7 @@ export function DefaultAppSidebar({ ...props }: React.ComponentProps conversationsData?.items?.map((conversation) => ({ id: `conversation-${conversation.id}`, - title: conversation.title || "新对话", + title: conversation.title || t("chat.newConversation"), path: `/c/${conversation.id}`, })) || [], [conversationsData], @@ -182,7 +184,7 @@ export function DefaultAppSidebar({ ...props }: React.ComponentProps - 工作台 + {t("common.workspace")}
diff --git a/packages/api/src/common/modules/auth/services/google-oauth.service.ts b/packages/api/src/common/modules/auth/services/google-oauth.service.ts new file mode 100644 index 000000000..631367e63 --- /dev/null +++ b/packages/api/src/common/modules/auth/services/google-oauth.service.ts @@ -0,0 +1,182 @@ +import { BooleanNumber, UserTerminal, UserTerminalType } from "@buildingai/constants"; +import { checkUserLoginPlayground, LoginUserPlayground } from "@buildingai/db"; +import { HttpErrorFactory } from "@buildingai/errors"; +import { Injectable } from "@nestjs/common"; +import { getFrontendBaseUrl } from "@buildingai/utils"; +import { GoogleConfigService } from "../../../../modules/channel/services/google-config.service"; +import { UserService } from "../../../../modules/user/services/user.service"; +import { UserTokenService } from "./user-token.service"; + +/** + * Google OAuth 2.0 Service + * + * Implements the OAuth 2.0 authorization code flow for Google authentication: + * 1. Build authorization URL + * 2. Exchange authorization code for access token + * 3. Fetch user info from Google + * 4. Find or create user and return app token + */ +@Injectable() +export class GoogleOAuthService { + private readonly TOKEN_URL = "https://oauth2.googleapis.com/token"; + private readonly USER_INFO_URL = "https://www.googleapis.com/oauth2/v2/userinfo"; + private readonly AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"; + private readonly SCOPES = "openid email profile"; + + constructor( + private readonly configService: GoogleConfigService, + private readonly userService: UserService, + private readonly userTokenService: UserTokenService, + ) { } + + /** + * Step 1: Build Google OAuth authorization URL + * + * @param state Random state string for CSRF protection + * @returns Authorization URL to redirect user to Google + * @throws Error if Google OAuth is not properly configured + */ + async getAuthorizationUrl(state: string): Promise { + const config = await this.configService.getConfig(); + + if (!config.clientId || !config.clientSecret) { + throw HttpErrorFactory.unauthorized("Google OAuth is not configured. Please configure clientId and clientSecret."); + } + + const redirectUri = `${getFrontendBaseUrl()}/api/auth/google-callback`; + const params = new URLSearchParams({ + client_id: config.clientId, + redirect_uri: redirectUri, + response_type: "code", + scope: this.SCOPES, + state, + }); + + return `${this.AUTH_URL}?${params}`; + } + + /** + * Step 2: Exchange authorization code for access token + * + * @param code Authorization code from Google callback + * @param redirectUri Redirect URI used in the authorization request + * @returns Access token response + * @throws Error if token exchange fails + */ + private async exchangeCodeForToken( + code: string, + redirectUri: string, + ): Promise<{ access_token: string }> { + const config = await this.configService.getConfig(); + + const response = await fetch(this.TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + code, + client_id: config.clientId, + client_secret: config.clientSecret, + redirect_uri: redirectUri, + grant_type: "authorization_code", + }), + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => response.statusText); + throw HttpErrorFactory.unauthorized(`Google token exchange failed: ${response.status} - ${errorText}`); + } + + return response.json() as Promise<{ access_token: string }>; + } + + /** + * Step 3: Fetch user info from Google + * + * @param accessToken Google access token + * @returns Google user info + * @throws Error if user info fetch fails + */ + private async fetchUserInfo(accessToken: string): Promise<{ + id: string; + email?: string; + name?: string; + picture?: string; + }> { + const response = await fetch(this.USER_INFO_URL, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => response.statusText); + throw HttpErrorFactory.unauthorized(`Google user info fetch failed: ${response.status} - ${errorText}`); + } + + return response.json() as Promise<{ id: string; email?: string; name?: string; picture?: string }>; + } + + /** + * Step 4: Handle callback - find or create user, return token + * + * @param code Authorization code from Google callback + * @param redirectUri Redirect URI used in the authorization request + * @param terminal Login terminal type + * @param ipAddress Client IP address (optional) + * @param userAgent Client user agent (optional) + * @returns Token result with user info + */ + async handleCallback( + code: string, + state: string, + redirectUri: string, + terminal: UserTerminalType = UserTerminal.PC, + ipAddress?: string, + userAgent?: string, + ) { + // Validate state parameter for CSRF protection + if (!state || state.trim() === "") { + throw HttpErrorFactory.unauthorized("Invalid state parameter: CSRF validation failed"); + } + + // Exchange code for access token + const { access_token } = await this.exchangeCodeForToken(code, redirectUri); + + // Fetch user info from Google + const userInfo = await this.fetchUserInfo(access_token); + const googleOpenid = userInfo.id; + + // Find or create user by Google OpenID + let user = await this.userService.findByGoogleOpenid(googleOpenid); + + if (!user) { + user = await this.userService.createByGoogle({ + googleOpenid, + email: userInfo.email, + nickname: userInfo.name, + avatar: userInfo.picture, + }); + } + + // Build login playground payload + const payload: LoginUserPlayground = checkUserLoginPlayground({ + id: user.id, + username: user.username, + isRoot: user.isRoot ?? BooleanNumber.NO, + terminal, + }); + + // Create app token + const tokenResult = await this.userTokenService.createToken( + user.id, + payload, + terminal, + ipAddress, + userAgent, + ); + + return { + token: tokenResult.token, + expiresAt: tokenResult.expiresAt, + user, + }; + } +} diff --git a/packages/api/src/modules/ai/agents/agents.module.ts b/packages/api/src/modules/ai/agents/agents.module.ts index ae0ac0912..33d00f799 100644 --- a/packages/api/src/modules/ai/agents/agents.module.ts +++ b/packages/api/src/modules/ai/agents/agents.module.ts @@ -78,7 +78,7 @@ import { AgentsService } from "./services/agents.service"; forwardRef(() => AiDatasetsModule), AiMemoryModule, ConfigModule, - UserModule, + forwardRef(() => UserModule), ], controllers: [ AgentsConsoleController, diff --git a/packages/api/src/modules/ai/chat/ai-chat.module.ts b/packages/api/src/modules/ai/chat/ai-chat.module.ts index 340ee0216..7c9f3d56d 100644 --- a/packages/api/src/modules/ai/chat/ai-chat.module.ts +++ b/packages/api/src/modules/ai/chat/ai-chat.module.ts @@ -19,7 +19,7 @@ import { Secret, UserSubscription, } from "@buildingai/db/entities"; -import { Module } from "@nestjs/common"; +import { forwardRef, Module } from "@nestjs/common"; import { UserModule } from "../../user/user.module"; import { AiMcpServerService } from "../mcp/services/ai-mcp-server.service"; @@ -51,7 +51,7 @@ import { ChatConfigService } from "./services/chat-config.service"; @Module({ imports: [ AiMemoryModule, - UserModule, + forwardRef(() => UserModule), TypeOrmModule.forFeature([ AiModel, AiProvider, diff --git a/packages/api/src/modules/app.module.ts b/packages/api/src/modules/app.module.ts index 89e89253e..b505fbff6 100644 --- a/packages/api/src/modules/app.module.ts +++ b/packages/api/src/modules/app.module.ts @@ -30,7 +30,7 @@ import { ExtensionCoreModule } from "@modules/extension/extension.module"; import { HealthModule } from "@modules/health/health.module"; import { MembershipModule } from "@modules/membership/membership.module"; import { NotificationModule } from "@modules/notification/notification.module"; -import { DynamicModule, Module } from "@nestjs/common"; +import { DynamicModule, forwardRef, Module } from "@nestjs/common"; import { ConfigModule } from "@nestjs/config"; import { APP_GUARD } from "@nestjs/core"; import { ServeStaticModule } from "@nestjs/serve-static"; @@ -97,7 +97,7 @@ export class AppModule { DatabaseModule, GuardsModule, BillingModule, - AuthModule, + forwardRef(() => AuthModule), CDKModule, // ChannelModule, AiModule, @@ -118,7 +118,7 @@ export class AppModule { UploadModule, AnalyseModule, SecretModule, - UserModule, + forwardRef(() => UserModule), CloudStorageModule, ScheduleModule, SmsModule, diff --git a/packages/api/src/modules/auth/auth.module.ts b/packages/api/src/modules/auth/auth.module.ts index 84fbf8d19..13e527753 100644 --- a/packages/api/src/modules/auth/auth.module.ts +++ b/packages/api/src/modules/auth/auth.module.ts @@ -14,14 +14,17 @@ import { import { AuthService } from "@common/modules/auth/services/auth.service"; import { ExtensionFeatureService } from "@common/modules/auth/services/extension-feature.service"; import { ExtensionFeatureScanService } from "@common/modules/auth/services/extension-feature-scan.service"; +import { GoogleOAuthService } from "@common/modules/auth/services/google-oauth.service"; import { RolePermissionService } from "@common/modules/auth/services/role-permission.service"; import { UserTokenService } from "@common/modules/auth/services/user-token.service"; import { SmsModule } from "@common/modules/sms/sms.module"; import { WechatOaService } from "@common/modules/wechat/services/wechatoa.service"; import { ChannelModule } from "@modules/channel/channel.module"; +import { UserModule } from "@modules/user/user.module"; import { Module } from "@nestjs/common"; import { ConfigModule, ConfigService } from "@nestjs/config"; import { JwtModule } from "@nestjs/jwt"; +import { forwardRef } from "@nestjs/common"; import type { StringValue } from "ms"; import { AuthWebController } from "./controller/web/auth.controller"; @@ -47,6 +50,7 @@ import { AuthWebController } from "./controller/web/auth.controller"; DepartmentUserIndex, ]), ChannelModule, + forwardRef(() => UserModule), JwtModule.registerAsync({ imports: [ConfigModule], inject: [ConfigService], @@ -63,6 +67,7 @@ import { AuthWebController } from "./controller/web/auth.controller"; AuthService, ExtensionFeatureScanService, ExtensionFeatureService, + GoogleOAuthService, RolePermissionService, UserTokenService, WechatOaService, @@ -71,6 +76,7 @@ import { AuthWebController } from "./controller/web/auth.controller"; AuthService, ExtensionFeatureScanService, ExtensionFeatureService, + GoogleOAuthService, JwtModule, RolePermissionService, UserTokenService, diff --git a/packages/api/src/modules/auth/controller/web/auth.controller.ts b/packages/api/src/modules/auth/controller/web/auth.controller.ts index 879c25dcb..112424a75 100644 --- a/packages/api/src/modules/auth/controller/web/auth.controller.ts +++ b/packages/api/src/modules/auth/controller/web/auth.controller.ts @@ -10,18 +10,20 @@ import { Playground } from "@buildingai/decorators/playground.decorator"; import { Public } from "@buildingai/decorators/public.decorator"; import { DictService } from "@buildingai/dict"; import { HttpErrorFactory } from "@buildingai/errors"; -import { isDevelopment } from "@buildingai/utils"; +import { getFrontendBaseUrl } from "@buildingai/utils"; import { WebController } from "@common/decorators"; import { ChangePasswordDto } from "@common/modules/auth/dto/change-password.dto"; import { LoginDto } from "@common/modules/auth/dto/login.dto"; import { RegisterDto } from "@common/modules/auth/dto/register.dto"; import { SendSmsCodeDto, SmsLoginDto } from "@common/modules/auth/dto/sms.dto"; import { AuthService } from "@common/modules/auth/services/auth.service"; +import { GoogleOAuthService } from "@common/modules/auth/services/google-oauth.service"; import { SmsService } from "@common/modules/sms/services/sms.service"; import { WechatOaService } from "@common/modules/wechat/services/wechatoa.service"; import { type LoginSettingsConfig } from "@modules/user/dto/login-settings.dto"; import { Body, Get, Headers, Param, Post, Query, Req, Res } from "@nestjs/common"; import type { Request, Response } from "express"; +import crypto from "node:crypto"; const OAUTH_SESSION_PREFIX = "oauth:session:"; @@ -38,6 +40,7 @@ export class AuthWebController extends BaseController { private smsService: SmsService, private dictService: DictService, private cacheService: CacheService, + private googleOAuthService: GoogleOAuthService, ) { super(); } @@ -338,22 +341,6 @@ export class AuthWebController extends BaseController { return { token: data.token, user: data.user }; } - private getFrontendBaseUrl() { - return isDevelopment() - ? "http://localhost:4091" - : (process.env.APP_DOMAIN || "http://localhost:4090").replace(/\/$/, ""); - } - - private redirectOAuthCallback(res: Response, error?: string, redirectState?: string) { - const baseUrl = this.getFrontendBaseUrl(); - if (error) { - const params = new URLSearchParams({ error }); - if (redirectState) params.set("redirect", redirectState); - return res.redirect(`${baseUrl}/login?${params.toString()}`); - } - return res.redirect(`${baseUrl}/login`); - } - private async getLoginSettings() { return await this.dictService.get( "login_settings", @@ -439,4 +426,46 @@ export class AuthWebController extends BaseController { res.setHeader("Content-Type", "text/html; charset=utf-8"); return res.send(html); } + + /** + * Redirect user to Google consent screen + * + * @param res Express response object + * @returns Redirects to Google OAuth authorization URL + */ + @Public() + @Get("google") + async googleAuth(@Res() res: Response) { + const state = crypto.randomUUID(); + const url = await this.googleOAuthService.getAuthorizationUrl(state); + return res.redirect(url); + } + + /** + * Google OAuth callback - Google redirects here with code + * + * @param code Authorization code from Google + * @param state State parameter for CSRF validation + * @param error Error parameter if user denied consent + * @param res Express response object + * @returns Redirects to frontend with token + */ + @Public() + @Get("google-callback") + async googleCallback( + @Query("code") code: string, + @Query("state") state: string, + @Query("error") error: string, + @Res() res: Response, + ) { + const redirectUri = `${getFrontendBaseUrl()}/api/auth/google-callback`; + + if (error || !code) { + return res.redirect(`${getFrontendBaseUrl()}/login?error=${error || "missing_code"}`); + } + + const { token } = await this.googleOAuthService.handleCallback(code, state, redirectUri); + console.log("Google OAuth callback - token:", token); + return res.redirect(`${getFrontendBaseUrl()}/login/oauth-callback?provider=google&token=${token}`); + } } diff --git a/packages/api/src/modules/channel/channel.module.ts b/packages/api/src/modules/channel/channel.module.ts index 26dee6677..d66cdd41a 100644 --- a/packages/api/src/modules/channel/channel.module.ts +++ b/packages/api/src/modules/channel/channel.module.ts @@ -2,14 +2,16 @@ import { TypeOrmModule } from "@buildingai/db/@nestjs/typeorm"; import { Dict } from "@buildingai/db/entities"; import { DictCacheService } from "@buildingai/dict"; import { DictService } from "@buildingai/dict"; +import { GoogleConfigConsoleController } from "@modules/channel/controller/console/google-config.controller"; +import { GoogleConfigService } from "@modules/channel/services/google-config.service"; import { WxOaConfigConsoleController } from "@modules/channel/controller/console/wxoaconfig.controller"; import { WxOaConfigService } from "@modules/channel/services/wxoaconfig.service"; import { Module } from "@nestjs/common"; @Module({ imports: [TypeOrmModule.forFeature([Dict])], - controllers: [WxOaConfigConsoleController], - providers: [WxOaConfigService, DictService, DictCacheService], - exports: [WxOaConfigService], + controllers: [WxOaConfigConsoleController, GoogleConfigConsoleController], + providers: [WxOaConfigService, GoogleConfigService, DictService, DictCacheService], + exports: [WxOaConfigService, GoogleConfigService], }) export class ChannelModule {} diff --git a/packages/api/src/modules/channel/controller/console/google-config.controller.ts b/packages/api/src/modules/channel/controller/console/google-config.controller.ts new file mode 100644 index 000000000..eb5d869fb --- /dev/null +++ b/packages/api/src/modules/channel/controller/console/google-config.controller.ts @@ -0,0 +1,38 @@ +import { BaseController } from "@buildingai/base"; +import { Permissions } from "@common/decorators"; +import { ConsoleController } from "@common/decorators/controller.decorator"; +import { UpdateGoogleConfigDto } from "@modules/channel/dto/google-config.dto"; +import { GoogleConfigService } from "@modules/channel/services/google-config.service"; +import { Body, Get, Patch } from "@nestjs/common"; + +@ConsoleController("google-config", "Google登录配置") +export class GoogleConfigConsoleController extends BaseController { + constructor(private readonly googleConfigService: GoogleConfigService) { + super(); + } + /** + * 获取Google登录配置 + * @returns Google登录配置 + */ + @Get() + @Permissions({ + code: "google-config:get-config", + name: "获取Google登录配置", + }) + getConfig() { + return this.googleConfigService.getConfig(); + } + /** + * 更新Google登录配置 + * @param updateGoogleConfigDto 更新配置DTO + * @returns 更新后的配置 + */ + @Patch() + @Permissions({ + code: "google-config:update-config", + name: "更新Google登录配置", + }) + update(@Body() updateGoogleConfigDto: UpdateGoogleConfigDto) { + return this.googleConfigService.updateConfig(updateGoogleConfigDto); + } +} diff --git a/packages/api/src/modules/channel/dto/google-config.dto.ts b/packages/api/src/modules/channel/dto/google-config.dto.ts new file mode 100644 index 000000000..36af27487 --- /dev/null +++ b/packages/api/src/modules/channel/dto/google-config.dto.ts @@ -0,0 +1,15 @@ +import { IsString, IsOptional, IsBoolean } from 'class-validator'; + +export class UpdateGoogleConfigDto { + @IsString() + @IsOptional() + clientId?: string; + + @IsString() + @IsOptional() + clientSecret?: string; + + @IsBoolean() + @IsOptional() + enabled?: boolean; +} diff --git a/packages/api/src/modules/channel/services/google-config.service.ts b/packages/api/src/modules/channel/services/google-config.service.ts new file mode 100644 index 000000000..64c977115 --- /dev/null +++ b/packages/api/src/modules/channel/services/google-config.service.ts @@ -0,0 +1,74 @@ +import { BaseService } from "@buildingai/base"; +import { InjectRepository } from "@buildingai/db/@nestjs/typeorm"; +import { Dict } from "@buildingai/db/entities"; +import { Repository } from "@buildingai/db/typeorm"; +import { DictService } from "@buildingai/dict"; +import { UpdateGoogleConfigDto } from "@modules/channel/dto/google-config.dto"; +import { Injectable } from "@nestjs/common"; +import { EventEmitter2 } from "@nestjs/event-emitter"; + +export const GOOGLE_EVENTS = { + REFRESH: "google.access_token.refresh", +} as const; + +export type GoogleConfig = { + clientId: string; + clientSecret: string; + enabled: boolean; +}; + +@Injectable() +export class GoogleConfigService extends BaseService { + private readonly GROUP = "googleConfig"; + + constructor( + private readonly dictService: DictService, + @InjectRepository(Dict) repository: Repository, + private readonly eventEmitter: EventEmitter2, + ) { + super(repository); + } + + async getConfig(): Promise { + const defaultConfig: GoogleConfig = { + clientId: "", + clientSecret: "", + enabled: false, + }; + + try { + const items = await this.dictService.findAll({ + where: { group: this.GROUP }, + }); + + if (items.length === 0) { + return defaultConfig; + } + + return { + clientId: items.find((i) => i.key === "clientId")?.value || "", + clientSecret: items.find((i) => i.key === "clientSecret")?.value || "", + enabled: items.find((i) => i.key === "enabled")?.value === "true", + }; + } catch (error) { + this.logger.error(`获取 ${this.GROUP} 配置失败: ${error.message}`); + return defaultConfig; + } + } + + async updateConfig(dto: UpdateGoogleConfigDto): Promise { + try { + const entries = Object.entries(dto).filter(([, v]) => v !== undefined); + for (const [key, value] of entries) { + await this.dictService.set(key, String(value), { + group: this.GROUP, + description: `配置 - ${key}`, + }); + } + this.eventEmitter.emit(GOOGLE_EVENTS.REFRESH); + } catch (error) { + this.logger.error(`更新 ${this.GROUP} 配置失败: ${error.message}`); + throw error; + } + } +} diff --git a/packages/api/src/modules/extension/extension.module.ts b/packages/api/src/modules/extension/extension.module.ts index b23dc5970..7b0773515 100644 --- a/packages/api/src/modules/extension/extension.module.ts +++ b/packages/api/src/modules/extension/extension.module.ts @@ -10,7 +10,7 @@ import { Extension } from "@buildingai/db/entities"; import { DataSource } from "@buildingai/db/typeorm"; import { TerminalLogger } from "@buildingai/logger"; import { ExtensionFeatureScanService } from "@common/modules/auth/services/extension-feature-scan.service"; -import { DynamicModule, Logger, Module, OnModuleInit } from "@nestjs/common"; +import { DynamicModule, forwardRef, Logger, Module, OnModuleInit } from "@nestjs/common"; import { ModuleRef } from "@nestjs/core"; import { AuthModule } from "../auth/auth.module"; @@ -51,7 +51,7 @@ export class ExtensionCoreModule implements OnModuleInit { return { module: ExtensionCoreModule, imports: [ - AuthModule, + forwardRef(() => AuthModule), Pm2Module, UploadModule, TypeOrmModule.forFeature([Extension]), diff --git a/packages/api/src/modules/system/system.module.ts b/packages/api/src/modules/system/system.module.ts index 80644ab97..701fc67d5 100644 --- a/packages/api/src/modules/system/system.module.ts +++ b/packages/api/src/modules/system/system.module.ts @@ -30,7 +30,7 @@ import { WebsiteService } from "./services/website.service"; */ @Module({ imports: [ - AuthModule, + forwardRef(() => AuthModule), CloudStorageModule, TypeOrmModule.forFeature([ Dict, diff --git a/packages/api/src/modules/upload/upload.module.ts b/packages/api/src/modules/upload/upload.module.ts index ae1e67fd9..8e6ad44a6 100644 --- a/packages/api/src/modules/upload/upload.module.ts +++ b/packages/api/src/modules/upload/upload.module.ts @@ -4,7 +4,7 @@ import { TypeOrmModule } from "@buildingai/db/@nestjs/typeorm"; import { File } from "@buildingai/db/entities"; import { SystemModule } from "@modules/system/system.module"; import { UserModule } from "@modules/user/user.module"; -import { Module } from "@nestjs/common"; +import { forwardRef, Module } from "@nestjs/common"; import { MulterModule } from "@nestjs/platform-express"; import { memoryStorage } from "multer"; @@ -26,7 +26,7 @@ import { UploadService } from "./services/upload.service"; storage: memoryStorage(), }), RedisModule, - UserModule, + forwardRef(() => UserModule), ], controllers: [UploadController], providers: [UploadService], diff --git a/packages/api/src/modules/user/services/user.service.ts b/packages/api/src/modules/user/services/user.service.ts index 57f340f8c..389b20861 100644 --- a/packages/api/src/modules/user/services/user.service.ts +++ b/packages/api/src/modules/user/services/user.service.ts @@ -868,6 +868,41 @@ export class UserService extends BaseService { return result; } + /** + * 根据 Google OpenID 查找用户 + * + * @param googleOpenid Google OpenID + * @returns 用户或 null + */ + async findByGoogleOpenid(googleOpenid: string): Promise { + return this.userRepository.findOne({ where: { googleOpenid } }); + } + + /** + * 通过 Google 创建用户 + * + * @param data 用户数据 + * @returns 创建的用户 + */ + async createByGoogle(data: { + googleOpenid: string; + email?: string; + nickname?: string; + avatar?: string; + }): Promise { + const user = this.userRepository.create({ + googleOpenid: data.googleOpenid, + email: data.email, + nickname: data.nickname || data.email?.split("@")[0] || "Google User", + username: `google_${data.googleOpenid.slice(0, 8)}`, + avatar: data.avatar, + password: "GOOGLE_AUTH_CREDENTIALS", + source: UserCreateSource.GOOGLE, + status: 1, + }); + return await this.userRepository.save(user); + } + /** * 获取用户当前最高会员等级ID * diff --git a/packages/api/src/modules/user/user.module.ts b/packages/api/src/modules/user/user.module.ts index 7fbcbf577..ce53166b7 100644 --- a/packages/api/src/modules/user/user.module.ts +++ b/packages/api/src/modules/user/user.module.ts @@ -43,7 +43,7 @@ import { UserCapacityService } from "./services/user-capacity.service"; Department, DepartmentUserIndex, ]), - AuthModule, + forwardRef(() => AuthModule), SmsModule, MenuModule, RoleModule, diff --git a/packages/client/src/components/agreement-dialog.tsx b/packages/client/src/components/agreement-dialog.tsx index 4db512221..8c64467b6 100644 --- a/packages/client/src/components/agreement-dialog.tsx +++ b/packages/client/src/components/agreement-dialog.tsx @@ -1,4 +1,5 @@ import { useAgreementConfigQuery } from "@buildingai/services/web"; +import { useI18n } from "@buildingai/i18n"; import { EditorContentRenderer } from "@buildingai/ui/components/editor"; import { Dialog, @@ -17,6 +18,7 @@ type AgreementDialogProps = { }; const AgreementDialog = ({ open, onOpenChange, type }: AgreementDialogProps) => { + const { t } = useI18n(); const { data, isLoading } = useAgreementConfigQuery(); const agreement = data?.agreement; @@ -38,7 +40,7 @@ const AgreementDialog = ({ open, onOpenChange, type }: AgreementDialogProps) =>
{isLoading && ( -

正在加载协议内容…

+

{t("agreement.loadingContent")}

)}

{currentTitle}

@@ -48,7 +50,7 @@ const AgreementDialog = ({ open, onOpenChange, type }: AgreementDialogProps) => {!isLoading && !currentContent && (

- 暂未配置{isService ? "用户协议" : "隐私政策"}内容,请联系管理员在网站配置中补充。 + {isService ? t("agreement.notConfiguredService") : t("agreement.notConfiguredPrivacy")}

)}
diff --git a/packages/client/src/components/ask-assistant-ui/chat.tsx b/packages/client/src/components/ask-assistant-ui/chat.tsx index c3db70146..4328c634b 100644 --- a/packages/client/src/components/ask-assistant-ui/chat.tsx +++ b/packages/client/src/components/ask-assistant-ui/chat.tsx @@ -5,6 +5,7 @@ import { InfiniteScrollTopScrollButton, useInfiniteScrollTopContext, } from "@buildingai/ui/components/infinite-scroll-top"; +import { useI18n } from "@buildingai/i18n"; import { Button } from "@buildingai/ui/components/ui/button"; import { SidebarTrigger } from "@buildingai/ui/components/ui/sidebar"; import { cn } from "@buildingai/ui/lib/utils"; @@ -44,6 +45,7 @@ const ChatHeader = memo(function ChatHeader({ onSelectModel: (id: string) => void; onShare?: () => void; }) { + const { t } = useI18n(); const [modelSelectorOpen, setModelSelectorOpen] = useState(false); return ( @@ -70,7 +72,7 @@ const ChatHeader = memo(function ChatHeader({ {onShare && ( )} @@ -101,6 +103,7 @@ const WelcomeMessage = memo(function WelcomeMessage({ welcomeTitle?: string; welcomeDescription?: string; }) { + const { t } = useI18n(); const descContent = useMemo( () => parseWelcomeDescription(welcomeDescription), [welcomeDescription], @@ -109,7 +112,7 @@ const WelcomeMessage = memo(function WelcomeMessage({ return (
-

{welcomeTitle || "欢迎使用 AI 助手"}

+

{welcomeTitle || t("common.askAssistant.welcomeToAIAssistant")}

{descContent?.type === "rich" ? (
{descContent.value}

) : ( -

开始对话,或者选择一个推荐问题

+

{t("common.askAssistant.startConversationOrChoose")}

)}
@@ -127,10 +130,11 @@ const WelcomeMessage = memo(function WelcomeMessage({ }); const LoadingIndicator = memo(function LoadingIndicator() { + const { t } = useI18n(); return (
-

加载中...

+

{t("common.askAssistant.loading")}

); @@ -178,6 +182,7 @@ const InputArea = memo(function InputArea({ hasMessages: boolean; footerText?: string; }) { + const { t } = useI18n(); const { suggestions, status, textareaRef, isLoading, onSend, onStop, selectedModelId } = useAssistantContext(); const { id } = useParams<{ id: string }>(); @@ -186,7 +191,7 @@ const InputArea = memo(function InputArea({ const handleSubmit = useCallback( async (message: PromptInputMessage, _event: FormEvent) => { if (!selectedModelId) { - toast.warning("请先选择模型"); + toast.warning(t("common.askAssistant.pleaseSelectModelFirst")); throw new Error("No model selected"); } const text = message.text?.trim(); @@ -227,7 +232,7 @@ const InputArea = memo(function InputArea({ />
- {footerText || "内容由 AI 生成,请仔细甄别"} + {footerText || t("common.askAssistant.contentGeneratedByAI")}
); diff --git a/packages/client/src/components/ask-assistant-ui/components/input/prompt-input.tsx b/packages/client/src/components/ask-assistant-ui/components/input/prompt-input.tsx index 6eb1cd64e..c7c6d406d 100644 --- a/packages/client/src/components/ask-assistant-ui/components/input/prompt-input.tsx +++ b/packages/client/src/components/ask-assistant-ui/components/input/prompt-input.tsx @@ -1,3 +1,4 @@ +import { useI18n } from "@buildingai/i18n"; import { useMcpServerQuickMenuQuery, useMcpServersAllQuery } from "@buildingai/services/web"; import { PromptInputAttachment as AIPromptInputAttachment, @@ -153,6 +154,7 @@ const PromptInputInner = memo( children, }: PromptInputProps) => { const context = useContext(AssistantContext); + const { t } = useI18n(); const models = modelsProp ?? context?.models ?? []; const selectedModelId = selectedModelIdProp ?? context?.selectedModelId ?? ""; const selectedMcpServerIds = selectedMcpServerIdsProp ?? context?.selectedMcpServerIds ?? []; @@ -223,7 +225,7 @@ const PromptInputInner = memo( if (invalidFiles.length > 0) { const typeText = unsupportedTypeLabels.join("、"); - toast.error(`当前模型不支持${typeText}类型`); + toast.error(t("ai:askAssistant.promptInput.modelNotSupportType", { type: typeText })); } // Always prevent default to take full control of file handling @@ -257,7 +259,7 @@ const PromptInputInner = memo( items.unshift({ id: "thinking", icon: , - label: "思考", + label: t("ai:askAssistant.promptInput.thinking"), featureKey: "thinking", }); } @@ -345,14 +347,14 @@ const PromptInputInner = memo( -

更多操作

+

{t("ai:askAssistant.promptInput.moreActions")}

{showFile && ( - {hasImageSupport ? "选择照片和文件" : "选择文件"} + {hasImageSupport ? t("ai:askAssistant.promptInput.selectPhotosAndFiles") : t("ai:askAssistant.promptInput.selectFiles")} )} {showFeatureItems.map((item) => ( @@ -367,7 +369,7 @@ const PromptInputInner = memo( {showExploreApps && ( - 全部应用 + {t("ai:askAssistant.promptInput.allApps")} )} @@ -420,7 +422,7 @@ const PromptInputInner = memo(
-

语音输入

+

{t("ai:askAssistant.promptInput.voiceInput")}

) : null} diff --git a/packages/client/src/components/ask-assistant-ui/components/input/voice-input.tsx b/packages/client/src/components/ask-assistant-ui/components/input/voice-input.tsx index d9da9122a..4725ae8cd 100644 --- a/packages/client/src/components/ask-assistant-ui/components/input/voice-input.tsx +++ b/packages/client/src/components/ask-assistant-ui/components/input/voice-input.tsx @@ -1,5 +1,6 @@ "use client"; +import { useI18n } from "@buildingai/i18n"; import { Button } from "@buildingai/ui/components/ui/button"; import { Spinner } from "@buildingai/ui/components/ui/spinner"; import { cn } from "@buildingai/ui/lib/utils"; @@ -212,6 +213,7 @@ const VoiceInputInner = memo(function VoiceInputInner({ onTranscriptReceived, className, }: VoiceInputProps) { + const { t } = useI18n(); const [isRecording, setIsRecording] = useState(false); const [isProcessing, setIsProcessing] = useState(false); const [stream, setStream] = useState(null); @@ -352,7 +354,7 @@ const VoiceInputInner = memo(function VoiceInputInner({ style={{ width: "100%", height: "100%", imageRendering: "crisp-edges" }} /> ) : ( - 连接中… + {t("ai:askAssistant.voiceInput.connecting")} )} @@ -371,7 +373,7 @@ const VoiceInputInner = memo(function VoiceInputInner({ className="size-7 rounded-full" onClick={handleConfirm} disabled={isProcessing} - aria-label="确认" + aria-label={t("ai:askAssistant.voiceInput.confirm")} loading={isProcessing} > @@ -389,7 +391,7 @@ const VoiceInputInner = memo(function VoiceInputInner({ disabled={disabled} onClick={startRecording} loading={isProcessing} - aria-label="语音输入" + aria-label={t("ai:askAssistant.voiceInput.voiceInput")} > diff --git a/packages/client/src/components/ask-assistant-ui/components/mcp-selector.tsx b/packages/client/src/components/ask-assistant-ui/components/mcp-selector.tsx index 2e4a5220b..ac36683aa 100644 --- a/packages/client/src/components/ask-assistant-ui/components/mcp-selector.tsx +++ b/packages/client/src/components/ask-assistant-ui/components/mcp-selector.tsx @@ -1,4 +1,5 @@ import { useSettingsDialog } from "@buildingai/hooks"; +import { useI18n } from "@buildingai/i18n"; import type { McpServer, McpServerType } from "@buildingai/services/web"; import { PromptInputButton as AIPromptInputButton } from "@buildingai/ui/components/ai-elements/prompt-input"; import SvgIcons from "@buildingai/ui/components/svg-icons"; @@ -33,6 +34,7 @@ export interface McpSelectorProps { export const McpSelector = memo( ({ mcpServers, selectedMcpServerIds, onSelectionChange }: McpSelectorProps) => { + const { t } = useI18n(); const [open, setOpen] = useState(false); const [filterType, setFilterType] = useState("all"); @@ -88,7 +90,7 @@ export const McpSelector = memo( )} ) : ( - 工具 + {t("common.askAssistant.tools")} )} @@ -97,7 +99,7 @@ export const McpSelector = memo(
- +
- 全部({mcpServers.length}) + {t("common.askAssistant.all")}({mcpServers.length}) - 系统({systemServers.length}) + {t("common.askAssistant.system")}({systemServers.length}) - 我的({userServers.length}) + {t("common.askAssistant.mine")}({userServers.length})
- 未找到 MCP 服务 + {t("common.askAssistant.noMcpServicesFound")} {(filterType === "all" || filterType === "system") && systemServers.length > 0 && ( <> {systemServers.map((server) => { const isSelected = selectedMcpServerIds.includes(server.id); @@ -167,7 +169,7 @@ export const McpSelector = memo( {server.tools && server.tools.length > 0 && ( - {server.tools.length} 工具 + {t("common.askAssistant.toolsCount", { count: server.tools.length })} )} @@ -179,7 +181,7 @@ export const McpSelector = memo( )} {(filterType === "all" || filterType === "user") && userServers.length > 0 && ( {userServers.map((server) => { const isSelected = selectedMcpServerIds.includes(server.id); diff --git a/packages/client/src/components/ask-assistant-ui/components/message/file-parse-queue.tsx b/packages/client/src/components/ask-assistant-ui/components/message/file-parse-queue.tsx index e5d0e61a2..120636883 100644 --- a/packages/client/src/components/ask-assistant-ui/components/message/file-parse-queue.tsx +++ b/packages/client/src/components/ask-assistant-ui/components/message/file-parse-queue.tsx @@ -8,6 +8,7 @@ import { TaskTrigger, } from "@buildingai/ui/components/ai-elements/task"; import { cn } from "@buildingai/ui/lib/utils"; +import { useI18n } from "@buildingai/i18n"; import type { UIMessage } from "ai"; import { ChevronDownIcon, FileSearchCornerIcon, FileTextIcon, LoaderIcon } from "lucide-react"; @@ -81,6 +82,7 @@ const toMetadataParts = (parts?: UIMessage["parts"]): FileParseMetadataPart[] => (parts ?? []).filter(isFileParseMetadataPart); export const FileParseQueue = ({ messageId, parts, isStreaming }: FileParseQueueProps) => { + const { t } = useI18n(); const progressParts = toProgressParts(parts); const metadataParts = toMetadataParts(parts); const latestMetadata = metadataParts.at(-1)?.data; @@ -106,7 +108,7 @@ export const FileParseQueue = ({ messageId, parts, isStreaming }: FileParseQueue }; const progressPercentage = getProgressPercentage(latestProgress); - const title = progressPercentage !== undefined ? `文件解析完成` : "文件解析中"; + const title = progressPercentage !== undefined ? t("action.fileParsingComplete") : t("action.fileParsing"); const Icon = hasCompleted ? FileSearchCornerIcon : LoaderIcon; return ( @@ -145,7 +147,7 @@ export const FileParseQueue = ({ messageId, parts, isStreaming }: FileParseQueue {progressParts.length > 0 && progressParts.map((part, index) => { const progress = part.data; - const message = progress?.message?.trim() || progress?.stage || "处理中"; + const message = progress?.message?.trim() || progress?.stage || t("action.processing"); const percentage = getProgressPercentage(progress); // Remove percentage from message if it's already in the format diff --git a/packages/client/src/components/ask-assistant-ui/components/message/message-actions.tsx b/packages/client/src/components/ask-assistant-ui/components/message/message-actions.tsx index 7f641ed02..47df9cada 100644 --- a/packages/client/src/components/ask-assistant-ui/components/message/message-actions.tsx +++ b/packages/client/src/components/ask-assistant-ui/components/message/message-actions.tsx @@ -14,6 +14,7 @@ import { } from "lucide-react"; import { memo, type ReactNode, useCallback, useEffect, useRef, useState } from "react"; +import { useI18n } from "@buildingai/i18n"; import { MessageContext } from "./message-context"; import { MessageUsage, type MessageUsageProps } from "./message-usage"; @@ -55,6 +56,7 @@ export const MessageActions = memo(function MessageActions({ onSpeak, extraActions, }: MessageActionsProps) { + const { t } = useI18n(); const [isCopied, setIsCopied] = useState(false); const [isTtsLoading, setIsTtsLoading] = useState(false); const [isTtsPlaying, setIsTtsPlaying] = useState(false); @@ -120,18 +122,18 @@ export const MessageActions = memo(function MessageActions({ return ( {onRetry && ( - + )} {hasValidMessageId && onLikeChange && !disliked && ( - + )} {hasValidMessageId && onDislikeChange && !liked && ( { if (disliked) { await handleDislike(); @@ -140,19 +142,19 @@ export const MessageActions = memo(function MessageActions({ onShowFeedbackCard?.(true); } }} - tooltip="不喜欢" + tooltip={t("action.dislike")} > )} - + {isCopied ? : } {onSpeak && content.trim() && ( {isTtsLoading ? ( diff --git a/packages/client/src/components/ask-assistant-ui/components/message/message-context.tsx b/packages/client/src/components/ask-assistant-ui/components/message/message-context.tsx index d2ff2abd1..c6b7456ed 100644 --- a/packages/client/src/components/ask-assistant-ui/components/message/message-context.tsx +++ b/packages/client/src/components/ask-assistant-ui/components/message/message-context.tsx @@ -6,6 +6,7 @@ import { } from "@buildingai/ui/components/ui/dialog"; import { ScrollArea } from "@buildingai/ui/components/ui/scroll-area"; import { Tooltip, TooltipContent, TooltipTrigger } from "@buildingai/ui/components/ui/tooltip"; +import { useI18n } from "@buildingai/i18n"; import { ListChecks } from "lucide-react"; import { memo, useState } from "react"; @@ -14,12 +15,13 @@ export interface MessageContextProps { } const roleLabel: Record = { - system: "系统", - user: "用户", - assistant: "助手", + system: "roles.system", + user: "roles.user", + assistant: "roles.assistant", }; export const MessageContext = memo(function MessageContext({ messages }: MessageContextProps) { + const { t } = useI18n(); const [open, setOpen] = useState(false); if (!messages?.length) return null; @@ -37,26 +39,26 @@ export const MessageContext = memo(function MessageContext({ messages }: Message -

查看对话上下文

+

{t("action.viewConversationContext")}

- 对话上下文 + {t("message.conversationContext")}

- 发送给模型的完整上下文(含 system、截断历史与当前轮) + {t("message.fullContextSentToModel")}

{messages.map((msg, i) => (
- {roleLabel[msg.role] ?? msg.role} + {t(`message.${roleLabel[msg.role]}`) ?? msg.role}
-                    {msg.content || "(无文本)"}
+                    {msg.content || t("action.noContent")}
                   
))} diff --git a/packages/client/src/components/ask-assistant-ui/components/message/message-feedback.tsx b/packages/client/src/components/ask-assistant-ui/components/message/message-feedback.tsx index d5c70277a..702a1c406 100644 --- a/packages/client/src/components/ask-assistant-ui/components/message/message-feedback.tsx +++ b/packages/client/src/components/ask-assistant-ui/components/message/message-feedback.tsx @@ -10,31 +10,32 @@ import { } from "@buildingai/ui/components/ui/dialog"; import { Textarea } from "@buildingai/ui/components/ui/textarea"; import { cn } from "@buildingai/ui/lib/utils"; +import { useI18n } from "@buildingai/i18n"; import { XIcon } from "lucide-react"; import { memo, useState } from "react"; const DISLIKE_REASONS = [ - "回答不准确", - "回答不完整", - "回答不相关", - "回答有偏见", - "回答格式不佳", - "代码不正确", - "不应该使用记忆", - "不喜欢此人物", - "不喜欢这种风格", - "与事实不符", - "未完全遵循指令", - "其他", + "feedbackReasonInaccurate", + "feedbackReasonIncomplete", + "feedbackReasonIrrelevant", + "feedbackReasonBiased", + "feedbackReasonPoorFormat", + "feedbackReasonCodeIncorrect", + "feedbackReasonShouldNotUseMemory", + "feedbackReasonDislikePersona", + "feedbackReasonDislikeStyle", + "feedbackReasonFactuallyIncorrect", + "feedbackReasonInstructionNotFollowed", + "feedbackReasonOther", ]; const FEEDBACK_CARD_REASONS = [ - "代码不正确", - "与事实不符", - "回答不准确", - "回答不完整", - "回答不相关", - "未完全遵循指令", + "feedbackReasonCodeIncorrect", + "feedbackReasonFactuallyIncorrect", + "feedbackReasonInaccurate", + "feedbackReasonIncomplete", + "feedbackReasonIrrelevant", + "feedbackReasonInstructionNotFollowed", ]; export interface FeedbackCardProps { @@ -48,6 +49,7 @@ export const FeedbackCard = memo(function FeedbackCard({ onMore, onClose, }: FeedbackCardProps) { + const { t } = useI18n(); return (
-

请与我们分享更多信息:

+

{t("message.feedbackTitle")}

@@ -86,7 +88,7 @@ export const FeedbackCard = memo(function FeedbackCard({ className="h-auto px-3 py-1.5 text-xs" onClick={() => onSelectReason(reason)} > - {reason} + {t(`message.${reason}`)} ))}
@@ -117,6 +119,7 @@ export const MessageFeedback = memo(function MessageFeedback({ onSubmit, onCancel, }: MessageFeedbackProps) { + const { t } = useI18n(); const [selectedReasons, setSelectedReasons] = useState([]); const [customReason, setCustomReason] = useState(""); @@ -130,12 +133,13 @@ export const MessageFeedback = memo(function MessageFeedback({ }; const handleSubmit = async () => { - const reasons = selectedReasons.filter((r) => r !== "其他"); + const reasons = selectedReasons.filter((r) => r !== "message.feedbackReasonOther"); + const translatedReasons = reasons.map((r) => t(r)); let finalReason: string | undefined; - if (reasons.length > 0 && customReason.trim()) { - finalReason = `${reasons.join("、")};${customReason}`; - } else if (reasons.length > 0) { - finalReason = reasons.join("、"); + if (translatedReasons.length > 0 && customReason.trim()) { + finalReason = `${translatedReasons.join("、")};${customReason}`; + } else if (translatedReasons.length > 0) { + finalReason = translatedReasons.join("、"); } else if (customReason.trim()) { finalReason = customReason.trim(); } @@ -164,8 +168,8 @@ export const MessageFeedback = memo(function MessageFeedback({ - 分享反馈 - 请选择您不喜欢这条消息的原因(可选) + {t("message.feedbackTitle")} + {t("message.feedbackDescription")}
@@ -182,14 +186,14 @@ export const MessageFeedback = memo(function MessageFeedback({ )} onClick={() => handleReasonToggle(reason)} > - {reason} + {t(`message.${reason}`)} ); })}