diff --git a/app/components/ImageGenerateCom.vue b/app/components/ImageGenerateCom.vue new file mode 100644 index 0000000..31ddcf1 --- /dev/null +++ b/app/components/ImageGenerateCom.vue @@ -0,0 +1,65 @@ + + + diff --git a/app/pages/index.vue b/app/pages/index.vue index 16a6d81..0190c2a 100644 --- a/app/pages/index.vue +++ b/app/pages/index.vue @@ -1,14 +1,11 @@ diff --git a/app/services/image_service.ts b/app/services/image_service.ts new file mode 100644 index 0000000..c1483fc --- /dev/null +++ b/app/services/image_service.ts @@ -0,0 +1,19 @@ +import type { + ICommonResponse, + IImageGenerateData, + IImageGenerateRequest +} from "#shared/types"; + +export class ImageService { + public static basePath = "/api/images"; + + public static GenerateImage(request: IImageGenerateRequest) { + return $fetch>( + `${ImageService.basePath}/generate`, + { + method: "POST", + body: request + } + ); + } +} diff --git a/app/services/index.ts b/app/services/index.ts index b23ced4..8c70732 100644 --- a/app/services/index.ts +++ b/app/services/index.ts @@ -1 +1,2 @@ +export * from "./image_service"; export * from "./user_service"; diff --git a/server/api/auth/ready.get.ts b/server/api/auth/ready.get.ts index 09b53c9..3e3a43c 100644 --- a/server/api/auth/ready.get.ts +++ b/server/api/auth/ready.get.ts @@ -1,4 +1,5 @@ import type { IUserReadyData } from "#shared/types"; +import { isUnauthorizedError } from "~~/server/utils"; export default defineEventHandler(async (event) => { try { @@ -17,20 +18,3 @@ export default defineEventHandler(async (event) => { return createUpstreamErrorResponse(error, "环境准备失败"); } }); - -/** 鉴权失败时清理本地 cookie,避免继续携带失效的 NewAPI 登录态 */ -const isUnauthorizedError = (error: unknown): boolean => { - const fetchError = error as { - response?: { - status?: number; - }; - status?: number; - statusCode?: number; - }; - - return ( - fetchError.statusCode === 401 || - fetchError.status === 401 || - fetchError.response?.status === 401 - ); -}; diff --git a/server/api/images/generate.post.ts b/server/api/images/generate.post.ts new file mode 100644 index 0000000..39a9098 --- /dev/null +++ b/server/api/images/generate.post.ts @@ -0,0 +1,44 @@ +import { consola } from "consola"; + +import type { + IImageGenerateData, + IImageGenerateRequest +} from "#shared/types/openai"; +import { isUnauthorizedError } from "~~/server/utils"; + +export default defineEventHandler(async (event) => { + const requestBody = await readBody | null>( + event + ); + + // 只接受 JSON 对象,避免数组、字符串等无效 body 被用于生成请求。 + if ( + requestBody !== null && + (typeof requestBody !== "object" || Array.isArray(requestBody)) + ) { + return createErrorResponse(400, "请求体必须是 JSON 对象"); + } + + const prompt = requestBody?.prompt?.trim(); + if (!prompt) { + return createErrorResponse(400, "请输入图片描述"); + } + + try { + const apiKey = await getAiArtStudioTokenKey(event); + const result = await askImg({ + apiKey, + prompt + }); + + consola.info("图片生成成功", { prompt, imageUrl: result.imageUrl }); + return createSuccessResponse(result, "图片生成成功"); + } catch (error) { + if (isUnauthorizedError(error)) { + clearNewApiAuthCookies(event); + return createErrorResponse(401, "未登录"); + } + + return createUpstreamErrorResponse(error, "图片生成失败"); + } +}); diff --git a/server/utils/createApiResponse.ts b/server/utils/createApiResponse.ts index 1f719d1..eb4d631 100644 --- a/server/utils/createApiResponse.ts +++ b/server/utils/createApiResponse.ts @@ -71,3 +71,20 @@ export const createUpstreamErrorResponse = ( errorData ); }; + +/** 判断上游或本地鉴权错误是否为 401,用于统一清理失效登录态 */ +export const isUnauthorizedError = (error: unknown): boolean => { + const fetchError = error as { + response?: { + status?: number; + }; + status?: number; + statusCode?: number; + }; + + return ( + fetchError.statusCode === 401 || + fetchError.status === 401 || + fetchError.response?.status === 401 + ); +}; diff --git a/server/utils/index.ts b/server/utils/index.ts index f433a37..f72fe27 100644 --- a/server/utils/index.ts +++ b/server/utils/index.ts @@ -2,3 +2,4 @@ export * from "./createApiResponse"; export * from "./fetch"; export * from "./newApiAuthCookies"; export * from "./newApiTokens"; +export * from "./openai"; diff --git a/server/utils/newApiTokens.ts b/server/utils/newApiTokens.ts index 1d04f54..c914d83 100644 --- a/server/utils/newApiTokens.ts +++ b/server/utils/newApiTokens.ts @@ -41,6 +41,11 @@ interface INewApiWrappedResponse { success?: boolean; } +interface INewApiTokenKeyData { + /** NewAPI 返回的完整 Token Key,仅允许服务端内部使用 */ + key: string; +} + const AI_ART_STUDIO_TOKEN_PAYLOAD = { remain_quota: 0, remain_amount: 0, @@ -58,8 +63,8 @@ const AI_ART_STUDIO_TOKEN_PAYLOAD = { export const ensureAiArtStudioToken = async ( event: H3Event ): Promise => { - const hasToken = await hasActiveAiArtStudioToken(event); - if (hasToken) { + const token = await findActiveAiArtStudioToken(event); + if (token) { return { ready: true, created: false @@ -74,8 +79,45 @@ export const ensureAiArtStudioToken = async ( }; }; +/** 确保 AIArtStudio Token 可用,并获取完整 key,完整 key 不允许透出到前端 */ +export const getAiArtStudioTokenKey = async ( + event: H3Event +): Promise => { + let token = await findActiveAiArtStudioToken(event); + + if (!token) { + await createAiArtStudioToken(event); + token = await findActiveAiArtStudioToken(event); + } + + if (!token) { + throw createError({ + statusCode: 502, + statusMessage: "环境初始化失败" + }); + } + + const response = await newApiAuthedFetch< + INewApiWrappedResponse + >(event, `/api/token/${token.id}/key`, { + method: "POST" + }); + const data = unwrapNewApiResponse(response, "环境读取失败"); + + if (typeof data.key !== "string" || !data.key) { + throw createError({ + statusCode: 502, + statusMessage: "环境读取失败" + }); + } + + return data.key; +}; + /** 分页读取 Token 列表,只认名称为 AIArtStudio 且状态启用的未删除 Token */ -const hasActiveAiArtStudioToken = async (event: H3Event): Promise => { +const findActiveAiArtStudioToken = async ( + event: H3Event +): Promise => { let page = 1; let total = Number.POSITIVE_INFINITY; @@ -87,15 +129,16 @@ const hasActiveAiArtStudioToken = async (event: H3Event): Promise => { }); const data = unwrapNewApiResponse(response, "环境检查失败"); - if (data.items.some(isActiveAiArtStudioToken)) { - return true; + const token = data.items.find(isActiveAiArtStudioToken); + if (token) { + return token; } total = data.total; page += 1; } - return false; + return null; }; /** 创建默认 AIArtStudio Token,参数固定,避免 handler 内重复维护 */ diff --git a/server/utils/openai.ts b/server/utils/openai.ts index 02be53b..c33c294 100644 --- a/server/utils/openai.ts +++ b/server/utils/openai.ts @@ -1,6 +1,22 @@ // server/utils/openai.ts import OpenAI from "openai"; -import type { BaseOptions } from "#shared/types/openai"; +import type { BaseOptions, IImageGenerateData } from "#shared/types/openai"; + +const IMAGE_GENERATION_URL = "https://api.qflink.xyz/v1/images/generations"; +const IMAGE_GENERATION_MODEL = "gpt-image-2"; +const IMAGE_GENERATION_SIZE = "1024x1024"; + +interface IImageGenerationResponse { + /** 上游创建时间 */ + created?: number; + /** 上游图片生成结果列表 */ + data?: Array<{ + /** 上游返回的修订提示词,可能为空 */ + revised_prompt?: string; + /** 生成图片地址 */ + url?: string; + }>; +} /** 通用 AI 调用函数(支持文本 / 图文 / 多模态) */ export const askAI = async ({ @@ -84,6 +100,38 @@ export const askVision = async ( return res.output_text; }; +/** 调用图片生成接口,完整 API Key 只在服务端使用 */ +export const askImg = async ({ + apiKey, + prompt +}: { + apiKey: string; + prompt: string; +}): Promise => { + const result = await $fetch(IMAGE_GENERATION_URL, { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json" + }, + body: { + model: IMAGE_GENERATION_MODEL, + prompt, + size: IMAGE_GENERATION_SIZE + } + }); + + const image = result.data?.[0]; + if (!image?.url) { + throw new Error("图片生成失败"); + } + + return { + imageUrl: image.url, + revisedPrompt: image.revised_prompt + }; +}; + /** 调用流式接口 */ export const askStream = async ( options: BaseOptions & { diff --git a/shared/types/index.ts b/shared/types/index.ts index 2382c8f..b5ae07d 100644 --- a/shared/types/index.ts +++ b/shared/types/index.ts @@ -17,4 +17,5 @@ export type ISuccessResponse = ICommonResponse & { code: 0 }; /** 语义化的错误响应类型 */ export type IErrorResponse = ICommonResponse & { code: number }; +export * from "./openai"; export * from "./user"; diff --git a/shared/types/openai.ts b/shared/types/openai.ts index cfb123f..3a57d57 100644 --- a/shared/types/openai.ts +++ b/shared/types/openai.ts @@ -6,3 +6,21 @@ export type BaseOptions = { /** 自定义 baseURL */ baseURL?: string; }; + +/** + * 图片生成请求体。 + */ +export interface IImageGenerateRequest { + /** 图片提示词 */ + prompt?: string; +} + +/** + * 图片生成成功后返回给前端的数据。 + */ +export interface IImageGenerateData { + /** 生成图片的访问地址 */ + imageUrl: string; + /** 上游返回的修订提示词,可能为空 */ + revisedPrompt?: string; +}