@@ -0,0 +1,65 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { ImageService } from "~/services";
|
||||||
|
|
||||||
|
const prompt = ref("");
|
||||||
|
const imageUrl = ref("");
|
||||||
|
const errorMessage = ref("");
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
const generate = async () => {
|
||||||
|
const text = prompt.value.trim();
|
||||||
|
if (!text) {
|
||||||
|
errorMessage.value = "请输入图片描述";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true;
|
||||||
|
errorMessage.value = "";
|
||||||
|
imageUrl.value = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await ImageService.GenerateImage({
|
||||||
|
prompt: text
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.code === 0 && res.data?.imageUrl) {
|
||||||
|
imageUrl.value = res.data.imageUrl;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
errorMessage.value = res.msg || "图片生成失败";
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = getErrorMessage(error, "图片生成失败");
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="w-full max-w-xl space-y-3">
|
||||||
|
<el-input
|
||||||
|
v-model="prompt"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
placeholder="请输入图片描述"
|
||||||
|
:disabled="loading"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<el-button type="primary" :loading="loading" @click="generate">
|
||||||
|
立即生成
|
||||||
|
</el-button>
|
||||||
|
|
||||||
|
<p v-if="errorMessage" class="text-sm text-red-500">
|
||||||
|
{{ errorMessage }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<img
|
||||||
|
v-if="imageUrl"
|
||||||
|
:src="imageUrl"
|
||||||
|
alt="生成图片"
|
||||||
|
class="block max-w-full rounded border"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
+3
-6
@@ -1,14 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useUserStore } from "~/stores";
|
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: "index-view"
|
layout: "index-view"
|
||||||
});
|
});
|
||||||
|
|
||||||
const userStore = useUserStore();
|
|
||||||
const { isOnline } = storeToRefs(userStore);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>123</div>
|
<div class="py-2">
|
||||||
|
<ImageGenerateCom />
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -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<ICommonResponse<IImageGenerateData>>(
|
||||||
|
`${ImageService.basePath}/generate`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: request
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1 +1,2 @@
|
|||||||
|
export * from "./image_service";
|
||||||
export * from "./user_service";
|
export * from "./user_service";
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { IUserReadyData } from "#shared/types";
|
import type { IUserReadyData } from "#shared/types";
|
||||||
|
import { isUnauthorizedError } from "~~/server/utils";
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
@@ -17,20 +18,3 @@ export default defineEventHandler(async (event) => {
|
|||||||
return createUpstreamErrorResponse(error, "环境准备失败");
|
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
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -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<Partial<IImageGenerateRequest> | 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<IImageGenerateData>(result, "图片生成成功");
|
||||||
|
} catch (error) {
|
||||||
|
if (isUnauthorizedError(error)) {
|
||||||
|
clearNewApiAuthCookies(event);
|
||||||
|
return createErrorResponse(401, "未登录");
|
||||||
|
}
|
||||||
|
|
||||||
|
return createUpstreamErrorResponse(error, "图片生成失败");
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -71,3 +71,20 @@ export const createUpstreamErrorResponse = (
|
|||||||
errorData
|
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
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -2,3 +2,4 @@ export * from "./createApiResponse";
|
|||||||
export * from "./fetch";
|
export * from "./fetch";
|
||||||
export * from "./newApiAuthCookies";
|
export * from "./newApiAuthCookies";
|
||||||
export * from "./newApiTokens";
|
export * from "./newApiTokens";
|
||||||
|
export * from "./openai";
|
||||||
|
|||||||
@@ -41,6 +41,11 @@ interface INewApiWrappedResponse<T> {
|
|||||||
success?: boolean;
|
success?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface INewApiTokenKeyData {
|
||||||
|
/** NewAPI 返回的完整 Token Key,仅允许服务端内部使用 */
|
||||||
|
key: string;
|
||||||
|
}
|
||||||
|
|
||||||
const AI_ART_STUDIO_TOKEN_PAYLOAD = {
|
const AI_ART_STUDIO_TOKEN_PAYLOAD = {
|
||||||
remain_quota: 0,
|
remain_quota: 0,
|
||||||
remain_amount: 0,
|
remain_amount: 0,
|
||||||
@@ -58,8 +63,8 @@ const AI_ART_STUDIO_TOKEN_PAYLOAD = {
|
|||||||
export const ensureAiArtStudioToken = async (
|
export const ensureAiArtStudioToken = async (
|
||||||
event: H3Event
|
event: H3Event
|
||||||
): Promise<IUserReadyData> => {
|
): Promise<IUserReadyData> => {
|
||||||
const hasToken = await hasActiveAiArtStudioToken(event);
|
const token = await findActiveAiArtStudioToken(event);
|
||||||
if (hasToken) {
|
if (token) {
|
||||||
return {
|
return {
|
||||||
ready: true,
|
ready: true,
|
||||||
created: false
|
created: false
|
||||||
@@ -74,8 +79,45 @@ export const ensureAiArtStudioToken = async (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 确保 AIArtStudio Token 可用,并获取完整 key,完整 key 不允许透出到前端 */
|
||||||
|
export const getAiArtStudioTokenKey = async (
|
||||||
|
event: H3Event
|
||||||
|
): Promise<string> => {
|
||||||
|
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<INewApiTokenKeyData>
|
||||||
|
>(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 */
|
/** 分页读取 Token 列表,只认名称为 AIArtStudio 且状态启用的未删除 Token */
|
||||||
const hasActiveAiArtStudioToken = async (event: H3Event): Promise<boolean> => {
|
const findActiveAiArtStudioToken = async (
|
||||||
|
event: H3Event
|
||||||
|
): Promise<INewApiTokenItem | null> => {
|
||||||
let page = 1;
|
let page = 1;
|
||||||
let total = Number.POSITIVE_INFINITY;
|
let total = Number.POSITIVE_INFINITY;
|
||||||
|
|
||||||
@@ -87,15 +129,16 @@ const hasActiveAiArtStudioToken = async (event: H3Event): Promise<boolean> => {
|
|||||||
});
|
});
|
||||||
const data = unwrapNewApiResponse(response, "环境检查失败");
|
const data = unwrapNewApiResponse(response, "环境检查失败");
|
||||||
|
|
||||||
if (data.items.some(isActiveAiArtStudioToken)) {
|
const token = data.items.find(isActiveAiArtStudioToken);
|
||||||
return true;
|
if (token) {
|
||||||
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
total = data.total;
|
total = data.total;
|
||||||
page += 1;
|
page += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 创建默认 AIArtStudio Token,参数固定,避免 handler 内重复维护 */
|
/** 创建默认 AIArtStudio Token,参数固定,避免 handler 内重复维护 */
|
||||||
|
|||||||
+49
-1
@@ -1,6 +1,22 @@
|
|||||||
// server/utils/openai.ts
|
// server/utils/openai.ts
|
||||||
import OpenAI from "openai";
|
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 调用函数(支持文本 / 图文 / 多模态) */
|
/** 通用 AI 调用函数(支持文本 / 图文 / 多模态) */
|
||||||
export const askAI = async ({
|
export const askAI = async ({
|
||||||
@@ -84,6 +100,38 @@ export const askVision = async (
|
|||||||
return res.output_text;
|
return res.output_text;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 调用图片生成接口,完整 API Key 只在服务端使用 */
|
||||||
|
export const askImg = async ({
|
||||||
|
apiKey,
|
||||||
|
prompt
|
||||||
|
}: {
|
||||||
|
apiKey: string;
|
||||||
|
prompt: string;
|
||||||
|
}): Promise<IImageGenerateData> => {
|
||||||
|
const result = await $fetch<IImageGenerationResponse>(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 (
|
export const askStream = async (
|
||||||
options: BaseOptions & {
|
options: BaseOptions & {
|
||||||
|
|||||||
@@ -17,4 +17,5 @@ export type ISuccessResponse<T = any> = ICommonResponse<T> & { code: 0 };
|
|||||||
/** 语义化的错误响应类型 */
|
/** 语义化的错误响应类型 */
|
||||||
export type IErrorResponse = ICommonResponse<null> & { code: number };
|
export type IErrorResponse = ICommonResponse<null> & { code: number };
|
||||||
|
|
||||||
|
export * from "./openai";
|
||||||
export * from "./user";
|
export * from "./user";
|
||||||
|
|||||||
@@ -6,3 +6,21 @@ export type BaseOptions = {
|
|||||||
/** 自定义 baseURL */
|
/** 自定义 baseURL */
|
||||||
baseURL?: string;
|
baseURL?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图片生成请求体。
|
||||||
|
*/
|
||||||
|
export interface IImageGenerateRequest {
|
||||||
|
/** 图片提示词 */
|
||||||
|
prompt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图片生成成功后返回给前端的数据。
|
||||||
|
*/
|
||||||
|
export interface IImageGenerateData {
|
||||||
|
/** 生成图片的访问地址 */
|
||||||
|
imageUrl: string;
|
||||||
|
/** 上游返回的修订提示词,可能为空 */
|
||||||
|
revisedPrompt?: string;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user