Merge branch 'ai-hub' into ai-hub-dark
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.Channel;
|
||||
|
||||
/// <summary>
|
||||
/// AI应用快捷配置DTO
|
||||
/// </summary>
|
||||
public class AiAppShortcutDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 应用ID
|
||||
/// </summary>
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 应用名称
|
||||
/// </summary>
|
||||
public string Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 应用终结点
|
||||
/// </summary>
|
||||
public string Endpoint { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 额外URL
|
||||
/// </summary>
|
||||
public string? ExtraUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 应用Key
|
||||
/// </summary>
|
||||
public string ApiKey { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 排序
|
||||
/// </summary>
|
||||
public int OrderNum { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建时间
|
||||
/// </summary>
|
||||
public DateTime CreationTime { get; set; }
|
||||
}
|
||||
@@ -83,4 +83,14 @@ public interface IChannelService
|
||||
Task DeleteModelAsync(Guid id);
|
||||
|
||||
#endregion
|
||||
|
||||
#region AI应用快捷配置
|
||||
|
||||
/// <summary>
|
||||
/// 获取AI应用快捷配置列表
|
||||
/// </summary>
|
||||
/// <returns>快捷配置列表</returns>
|
||||
Task<List<AiAppShortcutDto>> GetAppShortcutListAsync();
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -19,13 +19,16 @@ public class ChannelService : ApplicationService, IChannelService
|
||||
{
|
||||
private readonly ISqlSugarRepository<AiAppAggregateRoot, Guid> _appRepository;
|
||||
private readonly ISqlSugarRepository<AiModelEntity, Guid> _modelRepository;
|
||||
private readonly ISqlSugarRepository<AiAppShortcutAggregateRoot, Guid> _appShortcutRepository;
|
||||
|
||||
public ChannelService(
|
||||
ISqlSugarRepository<AiAppAggregateRoot, Guid> appRepository,
|
||||
ISqlSugarRepository<AiModelEntity, Guid> modelRepository)
|
||||
ISqlSugarRepository<AiModelEntity, Guid> modelRepository,
|
||||
ISqlSugarRepository<AiAppShortcutAggregateRoot, Guid> appShortcutRepository)
|
||||
{
|
||||
_appRepository = appRepository;
|
||||
_modelRepository = modelRepository;
|
||||
_appShortcutRepository = appShortcutRepository;
|
||||
}
|
||||
|
||||
#region AI应用管理
|
||||
@@ -239,4 +242,22 @@ public class ChannelService : ApplicationService, IChannelService
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region AI应用快捷配置
|
||||
|
||||
/// <summary>
|
||||
/// 获取AI应用快捷配置列表
|
||||
/// </summary>
|
||||
[HttpGet("channel/app-shortcut")]
|
||||
public async Task<List<AiAppShortcutDto>> GetAppShortcutListAsync()
|
||||
{
|
||||
var entities = await _appShortcutRepository._DbQueryable
|
||||
.OrderBy(x => x.OrderNum)
|
||||
.OrderByDescending(x => x.CreationTime)
|
||||
.ToListAsync();
|
||||
|
||||
return entities.Adapt<List<AiAppShortcutDto>>();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -109,41 +109,107 @@ public static class GeminiGenerateContentAcquirer
|
||||
|
||||
/// <summary>
|
||||
/// 获取图片 base64(包含 data:image 前缀)
|
||||
/// 从最后一个 part 开始查找 inlineData,找不到再从最后一个 part 开始查找 text
|
||||
/// Step 1: 递归遍历整个 JSON 查找最后一个 base64
|
||||
/// Step 2: 从 text 中查找 markdown 图片
|
||||
/// </summary>
|
||||
public static string GetImagePrefixBase64(JsonElement response)
|
||||
{
|
||||
var parts = response.GetPath("candidates", 0, "content", "parts");
|
||||
if (!parts.HasValue || parts.Value.ValueKind != JsonValueKind.Array)
|
||||
// Step 1: 递归遍历整个 JSON 查找最后一个 base64
|
||||
string? lastBase64 = null;
|
||||
string? lastMimeType = null;
|
||||
CollectLastBase64(response, ref lastBase64, ref lastMimeType);
|
||||
|
||||
if (!string.IsNullOrEmpty(lastBase64))
|
||||
{
|
||||
return string.Empty;
|
||||
var mimeType = lastMimeType ?? "image/png";
|
||||
return $"data:{mimeType};base64,{lastBase64}";
|
||||
}
|
||||
|
||||
var partsArray = parts.Value.EnumerateArray().ToList();
|
||||
if (partsArray.Count == 0)
|
||||
// Step 2: 从 text 中查找 markdown 图片
|
||||
return FindMarkdownImageInResponse(response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 递归遍历 JSON 查找最后一个 base64
|
||||
/// </summary>
|
||||
private static void CollectLastBase64(JsonElement element, ref string? lastBase64, ref string? lastMimeType, int minLength = 100)
|
||||
{
|
||||
switch (element.ValueKind)
|
||||
{
|
||||
return string.Empty;
|
||||
case JsonValueKind.Object:
|
||||
string? currentMimeType = null;
|
||||
string? currentData = null;
|
||||
|
||||
foreach (var prop in element.EnumerateObject())
|
||||
{
|
||||
var name = prop.Name.ToLowerInvariant();
|
||||
|
||||
// 记录 mimeType / mime_type
|
||||
if (name is "mimetype" or "mime_type" && prop.Value.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
currentMimeType = prop.Value.GetString();
|
||||
}
|
||||
// 记录 data 字段(检查是否像 base64)
|
||||
else if (name == "data" && prop.Value.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var val = prop.Value.GetString();
|
||||
if (!string.IsNullOrEmpty(val) && val.Length >= minLength && LooksLikeBase64(val))
|
||||
{
|
||||
currentData = val;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// 递归处理其他属性
|
||||
CollectLastBase64(prop.Value, ref lastBase64, ref lastMimeType, minLength);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果当前对象有 data,更新为"最后一个"
|
||||
if (currentData != null)
|
||||
{
|
||||
lastBase64 = currentData;
|
||||
lastMimeType = currentMimeType;
|
||||
}
|
||||
break;
|
||||
|
||||
case JsonValueKind.Array:
|
||||
foreach (var item in element.EnumerateArray())
|
||||
{
|
||||
CollectLastBase64(item, ref lastBase64, ref lastMimeType, minLength);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 检查字符串是否像 base64
|
||||
/// </summary>
|
||||
private static bool LooksLikeBase64(string str)
|
||||
{
|
||||
// 常见图片 base64 开头: JPEG(/9j/), PNG(iVBOR), GIF(R0lGO), WebP(UklGR)
|
||||
if (str.StartsWith("/9j/") || str.StartsWith("iVBOR") ||
|
||||
str.StartsWith("R0lGO") || str.StartsWith("UklGR"))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Step 1: 从最后一个 part 开始查找 inlineData
|
||||
for (int i = partsArray.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var inlineBase64 = partsArray[i].GetPath("inlineData", "data").GetString();
|
||||
if (!string.IsNullOrEmpty(inlineBase64))
|
||||
{
|
||||
var mimeType = partsArray[i].GetPath("inlineData", "mimeType").GetString() ?? "image/png";
|
||||
return $"data:{mimeType};base64,{inlineBase64}";
|
||||
}
|
||||
}
|
||||
// 检查前100个字符是否都是 base64 合法字符
|
||||
return str.Take(100).All(c => char.IsLetterOrDigit(c) || c == '+' || c == '/' || c == '=');
|
||||
}
|
||||
|
||||
// Step 2: 从最后一个 part 开始查找 text 中的 markdown 图片
|
||||
for (int i = partsArray.Count - 1; i >= 0; i--)
|
||||
/// <summary>
|
||||
/// 递归查找 text 字段中的 markdown 图片
|
||||
/// </summary>
|
||||
private static string FindMarkdownImageInResponse(JsonElement element)
|
||||
{
|
||||
var allTexts = new List<string>();
|
||||
CollectTextFields(element, allTexts);
|
||||
|
||||
// 从最后一个 text 开始查找
|
||||
for (int i = allTexts.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var text = partsArray[i].GetPath("text").GetString();
|
||||
if (string.IsNullOrEmpty(text))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var text = allTexts[i];
|
||||
|
||||
// markdown 图片格式: 
|
||||
var startMarker = "(data:image/";
|
||||
@@ -163,4 +229,38 @@ public static class GeminiGenerateContentAcquirer
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 递归收集所有 text 字段
|
||||
/// </summary>
|
||||
private static void CollectTextFields(JsonElement element, List<string> texts)
|
||||
{
|
||||
switch (element.ValueKind)
|
||||
{
|
||||
case JsonValueKind.Object:
|
||||
foreach (var prop in element.EnumerateObject())
|
||||
{
|
||||
if (prop.Name == "text" && prop.Value.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var val = prop.Value.GetString();
|
||||
if (!string.IsNullOrEmpty(val))
|
||||
{
|
||||
texts.Add(val);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
CollectTextFields(prop.Value, texts);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case JsonValueKind.Array:
|
||||
foreach (var item in element.EnumerateArray())
|
||||
{
|
||||
CollectTextFields(item, texts);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using SqlSugar;
|
||||
using Volo.Abp.Domain.Entities.Auditing;
|
||||
using Yi.Framework.Core.Data;
|
||||
|
||||
namespace Yi.Framework.AiHub.Domain.Entities.Model;
|
||||
|
||||
/// <summary>
|
||||
/// AI应用快捷配置表
|
||||
/// </summary>
|
||||
[SugarTable("Ai_App_Shortcut")]
|
||||
public class AiAppShortcutAggregateRoot : FullAuditedAggregateRoot<Guid>, IOrderNum
|
||||
{
|
||||
/// <summary>
|
||||
/// 应用名称
|
||||
/// </summary>
|
||||
public string Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 应用终结点
|
||||
/// </summary>
|
||||
public string Endpoint { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 额外url
|
||||
/// </summary>
|
||||
public string? ExtraUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 应用key
|
||||
/// </summary>
|
||||
public string ApiKey { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 排序
|
||||
/// </summary>
|
||||
public int OrderNum { get; set; }
|
||||
}
|
||||
@@ -1076,11 +1076,19 @@ public class AiGateWayManager : DomainService
|
||||
LazyServiceProvider.GetRequiredKeyedService<IGeminiGenerateContentService>(modelDescribe.HandlerName);
|
||||
var data = await chatService.GenerateContentAsync(modelDescribe, request, cancellationToken);
|
||||
|
||||
// 检查是否被大模型内容安全策略拦截
|
||||
var rawResponse = data.GetRawText();
|
||||
if (rawResponse.Contains("policies.google.com/terms/generative-ai/use-policy"))
|
||||
{
|
||||
_logger.LogWarning($"图片生成被内容安全策略拦截,模型:【{modelId}】,请求信息:【{request}】");
|
||||
throw new UserFriendlyException("您的提示词涉及敏感信息,已被大模型拦截,请调整提示词后再试!");
|
||||
}
|
||||
|
||||
//解析json,获取base64字符串
|
||||
var imagePrefixBase64 = GeminiGenerateContentAcquirer.GetImagePrefixBase64(data);
|
||||
if (string.IsNullOrWhiteSpace(imagePrefixBase64))
|
||||
{
|
||||
_logger.LogError($"图片生成解析失败,模型id:,请求信息:【{request}】,请求响应信息:{imagePrefixBase64}");
|
||||
_logger.LogError($"图片生成解析失败,模型:【{modelId}】,请求信息:【{request}】,请求响应信息:【{data}】");
|
||||
throw new UserFriendlyException("大模型没有返回图片,请调整提示词或稍后再试");
|
||||
}
|
||||
|
||||
|
||||
@@ -235,7 +235,7 @@ namespace Yi.Framework.Rbac.Domain.Managers
|
||||
if (UserConst.Admin.Equals(dto.User.UserName))
|
||||
{
|
||||
AddToClaim(claims, TokenTypeConst.Permission, UserConst.AdminPermissionCode);
|
||||
AddToClaim(claims, TokenTypeConst.Roles, UserConst.AdminRolesCode);
|
||||
AddToClaim(claims, ClaimTypes.Role, UserConst.AdminRolesCode);
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
@@ -280,7 +281,7 @@ namespace Yi.Abp.Web
|
||||
{
|
||||
options.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
RoleClaimType = "Roles",
|
||||
RoleClaimType = ClaimTypes.Role,
|
||||
ClockSkew = TimeSpan.Zero,
|
||||
ValidateIssuerSigningKey = true,
|
||||
ValidIssuer = jwtOptions.Issuer,
|
||||
|
||||
60
Yi.Ai.Vue3/.build/plugins/git-hash.ts
Normal file
60
Yi.Ai.Vue3/.build/plugins/git-hash.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { Plugin } from 'vite';
|
||||
import { execSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
|
||||
/**
|
||||
* 获取 Git 提交哈希值插件
|
||||
* Git 仓库在上一级目录
|
||||
*/
|
||||
export default function gitHashPlugin(): Plugin {
|
||||
let gitHash = 'unknown';
|
||||
let gitBranch = 'unknown';
|
||||
let gitDate = 'unknown';
|
||||
|
||||
try {
|
||||
// Git 仓库在上一级目录
|
||||
const execOptions = { cwd: path.resolve(__dirname, '../../..'), encoding: 'utf-8' as BufferEncoding };
|
||||
|
||||
// 获取完整的 commit hash
|
||||
gitHash = execSync('git rev-parse HEAD', execOptions)
|
||||
.toString()
|
||||
.trim();
|
||||
|
||||
// 获取短 hash (前7位)
|
||||
const shortHash = gitHash.substring(0, 7);
|
||||
|
||||
// 获取分支名
|
||||
gitBranch = execSync('git rev-parse --abbrev-ref HEAD', execOptions)
|
||||
.toString()
|
||||
.trim();
|
||||
|
||||
// 获取提交时间
|
||||
gitDate = execSync('git log -1 --format=%cd --date=iso', execOptions)
|
||||
.toString()
|
||||
.trim();
|
||||
|
||||
console.log(`\n📦 Git Info:`);
|
||||
console.log(` Branch: ${gitBranch}`);
|
||||
console.log(` Commit: ${shortHash}`);
|
||||
console.log(` Date: ${gitDate}\n`);
|
||||
|
||||
gitHash = shortHash; // 使用短 hash
|
||||
} catch (error: any) {
|
||||
console.warn('⚠️ 无法获取 Git 信息:', error?.message || error);
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'vite-plugin-git-hash',
|
||||
config() {
|
||||
// 在 config 钩子中返回配置
|
||||
return {
|
||||
define: {
|
||||
__GIT_HASH__: JSON.stringify(gitHash),
|
||||
__GIT_BRANCH__: JSON.stringify(gitBranch),
|
||||
__GIT_DATE__: JSON.stringify(gitDate),
|
||||
__BUILD_TIME__: JSON.stringify(new Date().toISOString()),
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -10,12 +10,14 @@ import Components from 'unplugin-vue-components/vite';
|
||||
import viteCompression from 'vite-plugin-compression';
|
||||
|
||||
import envTyped from 'vite-plugin-env-typed';
|
||||
import gitHashPlugin from './git-hash';
|
||||
import createSvgIcon from './svg-icon';
|
||||
|
||||
const root = path.resolve(__dirname, '../../');
|
||||
|
||||
function plugins({ mode, command }: ConfigEnv): PluginOption[] {
|
||||
return [
|
||||
gitHashPlugin(),
|
||||
UnoCSS(),
|
||||
envTyped({
|
||||
mode,
|
||||
|
||||
@@ -112,7 +112,7 @@
|
||||
<body>
|
||||
<!-- 加载动画容器 -->
|
||||
<div id="yixinai-loader" class="loader-container">
|
||||
<div class="loader-title">意心Ai 3.2</div>
|
||||
<div class="loader-title">意心Ai 3.3</div>
|
||||
<div class="loader-subtitle">海外地址,仅首次访问预计加载约10秒,无需梯子</div>
|
||||
<div class="loader-logo">
|
||||
<div class="pulse-box"></div>
|
||||
|
||||
@@ -26,6 +26,17 @@ for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
|
||||
app.use(store);
|
||||
|
||||
// 输出构建信息
|
||||
console.log(
|
||||
`%c 意心AI 3.3 %c Build Info `,
|
||||
'background:#35495e; padding: 4px; border-radius: 3px 0 0 3px; color: #fff',
|
||||
'background:#41b883; padding: 4px; border-radius: 0 3px 3px 0; color: #fff',
|
||||
);
|
||||
// console.log(`🔹 Git Branch: ${__GIT_BRANCH__}`);
|
||||
console.log(`🔹 Git Commit: ${__GIT_HASH__}`);
|
||||
// console.log(`🔹 Commit Date: ${__GIT_DATE__}`);
|
||||
// console.log(`🔹 Build Time: ${__BUILD_TIME__}`);
|
||||
|
||||
// 挂载 Vue 应用
|
||||
// mount 完成说明应用初始化完毕,此时手动通知 loading 动画结束
|
||||
app.mount('#app');
|
||||
|
||||
@@ -130,6 +130,49 @@ function handleRemove(file: UploadFile) {
|
||||
fileList.value.splice(index, 1);
|
||||
}
|
||||
|
||||
// Handle paste event for reference images
|
||||
function handlePaste(event: ClipboardEvent) {
|
||||
const items = event.clipboardData?.items;
|
||||
if (!items) return;
|
||||
|
||||
for (const item of items) {
|
||||
if (item.type.startsWith('image/')) {
|
||||
event.preventDefault();
|
||||
|
||||
if (fileList.value.length >= 2) {
|
||||
ElMessage.warning('最多只能上传2张参考图');
|
||||
return;
|
||||
}
|
||||
|
||||
const file = item.getAsFile();
|
||||
if (!file) return;
|
||||
|
||||
// Check file size
|
||||
const isLt5M = file.size / 1024 / 1024 < 5;
|
||||
if (!isLt5M) {
|
||||
ElMessage.error('图片大小不能超过 5MB!');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create object URL for preview
|
||||
const url = URL.createObjectURL(file);
|
||||
const filename = `pasted-image-${Date.now()}.${file.type.split('/')[1] || 'png'}`;
|
||||
|
||||
const uploadFile: UploadUserFile = {
|
||||
name: filename,
|
||||
url,
|
||||
raw: file,
|
||||
uid: Date.now(),
|
||||
status: 'ready',
|
||||
};
|
||||
|
||||
fileList.value.push(uploadFile);
|
||||
ElMessage.success('已粘贴图片');
|
||||
break; // Only handle the first image
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function fileToBase64(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
@@ -356,10 +399,14 @@ defineExpose({
|
||||
onMounted(() => {
|
||||
fetchTokens();
|
||||
fetchModels();
|
||||
// Add paste event listener for reference images
|
||||
document.addEventListener('paste', handlePaste);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
stopPolling();
|
||||
// Remove paste event listener
|
||||
document.removeEventListener('paste', handlePaste);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -474,7 +521,7 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</el-upload>
|
||||
<div class="text-xs text-gray-400 mt-2 flex justify-between items-center flex-wrap gap-2">
|
||||
<span>最多2张,< 5MB (支持 JPG/PNG/WEBP)</span>
|
||||
<span>最多2张,< 5MB (支持 JPG/PNG/WEBP,可粘贴)</span>
|
||||
<el-checkbox v-model="compressImage" label="压缩图片" size="small" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
11
Yi.Ai.Vue3/types/global.d.ts
vendored
11
Yi.Ai.Vue3/types/global.d.ts
vendored
@@ -1 +1,12 @@
|
||||
declare module "virtual:svg-icons-register";
|
||||
|
||||
// Git 构建信息
|
||||
declare const __GIT_HASH__: string;
|
||||
declare const __GIT_BRANCH__: string;
|
||||
declare const __GIT_DATE__: string;
|
||||
declare const __BUILD_TIME__: string;
|
||||
|
||||
// 全局加载器方法
|
||||
interface Window {
|
||||
__hideAppLoader?: () => void;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user