diff --git a/package.json b/package.json index d0cf5579e9..66e8e44cb1 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "desktop:prepare-dist": "tsx scripts/electronWorkflow/moveNextStandalone.ts", "dev": "next dev --turbopack -p 3010", "dev:desktop": "next dev --turbopack -p 3015", + "dev:mobile": "next dev --turbopack -p 3018", "docs:i18n": "lobe-i18n md && npm run lint:md && npm run lint:mdx && prettier -c --write locales/**/*", "docs:seo": "lobe-seo && npm run lint:mdx", "e2e": "playwright test", diff --git a/src/app/(backend)/trpc/mobile/[trpc]/route.ts b/src/app/(backend)/trpc/mobile/[trpc]/route.ts new file mode 100644 index 0000000000..3cc78a95cd --- /dev/null +++ b/src/app/(backend)/trpc/mobile/[trpc]/route.ts @@ -0,0 +1,31 @@ +import { fetchRequestHandler } from '@trpc/server/adapters/fetch'; +import type { NextRequest } from 'next/server'; + +import { pino } from '@/libs/logger'; +import { createLambdaContext } from '@/libs/trpc/lambda/context'; +import { mobileRouter } from '@/server/routers/mobile'; + +const handler = (req: NextRequest) => + fetchRequestHandler({ + /** + * @link https://trpc.io/docs/v11/context + */ + createContext: () => createLambdaContext(req), + + endpoint: '/trpc/mobile', + + onError: ({ error, path, type }) => { + pino.info(`Error in tRPC handler (mobile) on path: ${path}, type: ${type}`); + console.error(error); + }, + + req, + responseMeta({ ctx }) { + const headers = ctx?.resHeaders; + + return { headers }; + }, + router: mobileRouter, + }); + +export { handler as GET, handler as POST }; diff --git a/src/app/[variants]/oauth/consent/[uid]/components/OAuthApplicationLogo.tsx b/src/app/[variants]/oauth/consent/[uid]/components/OAuthApplicationLogo.tsx index 9ad5df1d66..fdf7760f12 100644 --- a/src/app/[variants]/oauth/consent/[uid]/components/OAuthApplicationLogo.tsx +++ b/src/app/[variants]/oauth/consent/[uid]/components/OAuthApplicationLogo.tsx @@ -11,11 +11,20 @@ const useStyles = createStyles(({ css, token }) => ({ connector: css` width: 40px; height: 40px; + + @media (max-width: 768px) { + width: 32px; + height: 32px; + } `, connectorLine: css` width: 32px; height: 1px; background-color: ${token.colorBorderSecondary}; + + @media (max-width: 768px) { + width: 24px; + } `, icon: css` overflow: hidden; @@ -29,6 +38,12 @@ const useStyles = createStyles(({ css, token }) => ({ border-radius: 16px; background-color: ${token.colorBgElevated}; + + @media (max-width: 768px) { + width: 48px; + height: 48px; + border-radius: 12px; + } `, lobeIcon: css` overflow: hidden; @@ -41,6 +56,11 @@ const useStyles = createStyles(({ css, token }) => ({ border-radius: 50%; background-color: ${token.colorBgElevated}; + + @media (max-width: 768px) { + width: 48px; + height: 48px; + } `, })); @@ -55,13 +75,27 @@ const OAuthApplicationLogo = memo( const { styles, theme } = useStyles(); return isFirstParty ? ( - {clientDisplayName} + {clientDisplayName} ) : (
{logoUrl ? ( - {clientDisplayName} + {clientDisplayName} ) : ( )} @@ -72,7 +106,11 @@ const OAuthApplicationLogo = memo(
- +
); diff --git a/src/features/ModelSelect/index.tsx b/src/features/ModelSelect/index.tsx index 89b258d07a..cb87541185 100644 --- a/src/features/ModelSelect/index.tsx +++ b/src/features/ModelSelect/index.tsx @@ -46,7 +46,6 @@ const ModelSelect = memo(({ value, onChange, showAbility = tru provider: provider.id, value: `${provider.id}/${model.id}`, })); - if (enabledList.length === 1) { const provider = enabledList[0]; diff --git a/src/libs/oidc-provider/config.ts b/src/libs/oidc-provider/config.ts index 88250f261b..9848f64119 100644 --- a/src/libs/oidc-provider/config.ts +++ b/src/libs/oidc-provider/config.ts @@ -35,6 +35,21 @@ export const defaultClients: ClientMetadata[] = [ // 标记为公共客户端客户端,无密钥 token_endpoint_auth_method: 'none', }, + { + application_type: 'native', // 移动端使用 native 类型 + client_id: 'lobehub-mobile', + client_name: 'LobeHub Mobile', + // 支持授权码流程和刷新令牌 + grant_types: ['authorization_code', 'refresh_token'], + logo_uri: 'https://hub-apac-1.lobeobjects.space/docs/73f69adfa1b802a0e250f6ff9d62f70b.png', + // 移动端不需要 post_logout_redirect_uris,因为注销通常在应用内处理 + post_logout_redirect_uris: [], + // 移动端使用自定义 URL Scheme + redirect_uris: ['com.lobehub.app://auth/callback'], + response_types: ['code'], + // 公共客户端,无密钥 + token_endpoint_auth_method: 'none', + }, ]; /** diff --git a/src/libs/oidc-provider/jwt.ts b/src/libs/oidc-provider/jwt.ts index 21b9f58397..c4d2d98b54 100644 --- a/src/libs/oidc-provider/jwt.ts +++ b/src/libs/oidc-provider/jwt.ts @@ -106,7 +106,8 @@ export const validateOIDCJWT = async (token: string) => { // 提取用户信息 const userId = payload.sub; - const clientId = payload.aud; + const clientId = payload.client_id; + const aud = payload.aud; if (!userId) { throw new TRPCError({ @@ -119,7 +120,7 @@ export const validateOIDCJWT = async (token: string) => { clientId, payload, tokenData: { - aud: clientId, + aud: aud, client_id: clientId, exp: payload.exp, iat: payload.iat, diff --git a/src/libs/oidc-provider/provider.ts b/src/libs/oidc-provider/provider.ts index 8a3307a9fb..20aa0be682 100644 --- a/src/libs/oidc-provider/provider.ts +++ b/src/libs/oidc-provider/provider.ts @@ -7,6 +7,7 @@ import { serverDBEnv } from '@/config/db'; import { UserModel } from '@/database/models/user'; import { appEnv } from '@/envs/app'; import { getJWKS } from '@/libs/oidc-provider/jwt'; +import { normalizeLocale } from '@/locales/resources'; import { DrizzleAdapter } from './adapter'; import { defaultClaims, defaultClients, defaultScopes } from './config'; @@ -76,6 +77,9 @@ export const createOIDCProvider = async (db: LobeChatDatabase): Promise API_AUDIENCE, enabled: true, + getResourceServerInfo: (ctx, resourceIndicator) => { logProvider('getResourceServerInfo called with indicator: %s', resourceIndicator); // <-- 添加这行日志 if (resourceIndicator === API_AUDIENCE) { @@ -107,6 +112,8 @@ export const createOIDCProvider = async (db: LobeChatDatabase): Promise true, }, revocation: { enabled: true }, rpInitiatedLogout: { enabled: true }, @@ -195,7 +202,25 @@ export const createOIDCProvider = async (db: LobeChatDatabase): Promise 添加日志 <--- logProvider('interactions.url function called'); logProvider('Interaction details: %O', interaction); - const interactionUrl = `/oauth/consent/${interaction.uid}`; + + // 读取 OIDC 请求中的 ui_locales 参数(空格分隔的语言优先级) + // https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest + const uiLocalesRaw = (interaction.params?.ui_locales || ctx.oidc?.params?.ui_locales) as + | string + | undefined; + + let query = ''; + if (uiLocalesRaw) { + // 取第一个优先语言,规范化到站点支持的标签 + const first = uiLocalesRaw.split(/[\s,]+/).find(Boolean); + const hl = normalizeLocale(first); + query = `?hl=${encodeURIComponent(hl)}`; + logProvider('Detected ui_locales=%s -> using hl=%s', uiLocalesRaw, hl); + } else { + logProvider('No ui_locales provided in authorization request'); + } + + const interactionUrl = `/oauth/consent/${interaction.uid}${query}`; logProvider('Generated interaction URL: %s', interactionUrl); // ---> 添加日志结束 <--- return interactionUrl; diff --git a/src/middleware.ts b/src/middleware.ts index cec4dbd5a2..46cd8c3aff 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -143,7 +143,32 @@ const defaultMiddleware = (request: NextRequest) => { url.pathname = nextPathname; - return NextResponse.rewrite(url, { status: 200 }); + // build rewrite response first + const rewrite = NextResponse.rewrite(url, { status: 200 }); + + // If locale explicitly provided via query (?hl=), persist it in cookie when user has no prior preference + if (explicitlyLocale) { + const existingLocale = request.cookies.get(LOBE_LOCALE_COOKIE)?.value as Locales | undefined; + if (!existingLocale) { + rewrite.cookies.set(LOBE_LOCALE_COOKIE, explicitlyLocale, { + // 90 days is a balanced persistence for locale preference + maxAge: 60 * 60 * 24 * 90, + + path: '/', + sameSite: 'lax', + secure: process.env.NODE_ENV === 'production', + }); + logDefault('Persisted explicit locale to cookie (no prior cookie): %s', explicitlyLocale); + } else { + logDefault( + 'Locale cookie exists (%s), skip overwrite with %s', + existingLocale, + explicitlyLocale, + ); + } + } + + return rewrite; }; const isPublicRoute = createRouteMatcher([ @@ -158,6 +183,8 @@ const isPublicRoute = createRouteMatcher([ '/login', '/signup', // oauth + // Make only the consent view public (GET page), not other oauth paths + '/oauth/consent/(.*)', '/oidc/handoff', '/oidc/token', ]); @@ -212,6 +239,11 @@ const nextAuthMiddleware = NextAuth.auth((req) => { logNextAuth('Request a protected route, redirecting to sign-in page'); const nextLoginUrl = new URL('/next-auth/signin', req.nextUrl.origin); nextLoginUrl.searchParams.set('callbackUrl', req.nextUrl.href); + const hl = req.nextUrl.searchParams.get('hl'); + if (hl) { + nextLoginUrl.searchParams.set('hl', hl); + logNextAuth('Preserving locale to sign-in: hl=%s', hl); + } return Response.redirect(nextLoginUrl); } logNextAuth('Request a free route but not login, allow visit without auth header'); diff --git a/src/server/routers/mobile/index.ts b/src/server/routers/mobile/index.ts new file mode 100644 index 0000000000..f1067275b6 --- /dev/null +++ b/src/server/routers/mobile/index.ts @@ -0,0 +1,30 @@ +/** + * This file contains the root router of Lobe Chat tRPC-backend for Mobile App + * Only includes routers that are actually used by the mobile client + */ +import { publicProcedure, router } from '@/libs/trpc/lambda'; + +import { agentRouter } from '../lambda/agent'; +import { aiChatRouter } from '../lambda/aiChat'; +import { aiModelRouter } from '../lambda/aiModel'; +import { aiProviderRouter } from '../lambda/aiProvider'; +import { marketRouter } from '../lambda/market'; +import { messageRouter } from '../lambda/message'; +import { sessionRouter } from '../lambda/session'; +import { sessionGroupRouter } from '../lambda/sessionGroup'; +import { topicRouter } from '../lambda/topic'; + +export const mobileRouter = router({ + agent: agentRouter, + aiChat: aiChatRouter, + aiModel: aiModelRouter, + aiProvider: aiProviderRouter, + healthcheck: publicProcedure.query(() => "i'm live!"), + market: marketRouter, + message: messageRouter, + session: sessionRouter, + sessionGroup: sessionGroupRouter, + topic: topicRouter, +}); + +export type MobileRouter = typeof mobileRouter; diff --git a/tsconfig.json b/tsconfig.json index ce4395dcf1..0df184ce79 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -31,6 +31,15 @@ } ] }, - "exclude": ["node_modules", "public/sw.js", "apps/desktop", "tmp", "temp", ".temp", "e2e"], + "exclude": [ + "node_modules", + "public/sw.js", + "apps/desktop", + "apps/mobile", + "tmp", + "temp", + ".temp", + "e2e" + ], "include": ["**/*.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "next-env.d.ts"] } diff --git a/vitest.config.mts b/vitest.config.mts index 7904175b7e..89f4fa6999 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -45,6 +45,7 @@ export default defineConfig({ '**/dist/**', '**/build/**', '**/apps/desktop/**', + '**/apps/mobile/**', '**/packages/**', '**/e2e/**', ],