This commit is contained in:
2026-06-24 16:33:09 +08:00
12 changed files with 315 additions and 100 deletions

View File

@ -0,0 +1,16 @@
using QYZH.InteractiveMagazine.Models.Dto;
namespace QYZH.InteractiveMagazine.IService;
/// <summary>
/// AI聊天服务接口
/// </summary>
public interface IAiChatService
{
/// <summary>
/// 发送消息进行AI对话
/// </summary>
/// <param name="input">聊天输入</param>
/// <returns>AI返回的提示词内容</returns>
Task<AiChatOutput> ChatAsync(AiChatInput input);
}

View File

@ -0,0 +1,23 @@
namespace QYZH.InteractiveMagazine.Models.Dto;
/// <summary>
/// AI聊天输入
/// </summary>
public class AiChatInput
{
/// <summary>
/// 用户需求文本
/// </summary>
public string Message { get; set; } = string.Empty;
}
/// <summary>
/// AI聊天输出
/// </summary>
public class AiChatOutput
{
/// <summary>
/// AI返回的内容Markdown格式提示词
/// </summary>
public string Content { get; set; } = string.Empty;
}

View File

@ -19,11 +19,6 @@ namespace QYZH.InteractiveMagazine.Models.Dto.Journal
/// </summary>
public long JournalPageId { get; set; }
/// <summary>
/// 学科Id
/// </summary>
public long SubjectId { get; set; }
/// <summary>
/// 题号
/// </summary>
@ -36,33 +31,10 @@ namespace QYZH.InteractiveMagazine.Models.Dto.Journal
public string ShowType => Type.GetDescription();
private string _task;
/// <summary>
/// 问题
/// </summary>
public string Task
{
get { return _task.AddDomain(DomainHelper.OssDomain); }
set { _task = value; }
}
/// <summary>
/// 答案列表
/// </summary>
public List<JournalTaskAnswerOutput> Answers { get; set; }
private string _imageUrl;
/// <summary>
/// 图片地址
/// </summary>
public string ImageUrl
{
get { return DomainHelper.OssFullUrl(_imageUrl); }
set { _imageUrl = value; }
}
public List<string> ImageUrls { get; set; }
/// <summary>
/// 积分
/// </summary>
@ -98,55 +70,10 @@ namespace QYZH.InteractiveMagazine.Models.Dto.Journal
/// </summary>
public int AnswerTime { get; set; }
private string _audioUrl;
/// <summary>
/// 音频地址
/// Prompt配置
/// </summary>
public string AudioUrl
{
get { return DomainHelper.OssFullUrl(_audioUrl); }
set { _audioUrl = value; }
}
private string _videoUrl;
/// <summary>
/// 视频地址
/// </summary>
public string VideoUrl
{
get { return DomainHelper.OssFullUrl(_videoUrl); }
set { _videoUrl = value; }
}
private string _pointsUrl;
/// <summary>
/// 点位数据地址
/// </summary>
public string PointsUrl
{
get { return DomainHelper.OssFullUrl(_pointsUrl); }
set { _pointsUrl = value; }
}
/// <summary>
/// 音频开始时间
/// </summary>
public long AudioStartTime { get; set; }
/// <summary>
/// 音频结束时间
/// </summary>
public long AudioEndTime { get; set; }
/// <summary>
/// 知识点
/// </summary>
public string KnowledgePointIds { get; set; }
/// <summary>
/// 年级
/// </summary>
public int Grade { get; set; }
public string Prompt { get; set; }
}
/// <summary>

View File

@ -1,4 +1,4 @@
using QYZH.InteractiveMagazine.Models.Enum;
using QYZH.InteractiveMagazine.Models.Enum;
using QYZH.InteractiveMagazine.Models.Enum;
using System;
using System.Collections.Generic;
@ -67,18 +67,9 @@ namespace QYZH.InteractiveMagazine.Models.Dto.Journal
/// </summary>
public int AnswerTime { get; set; }
/// <summary>
/// Desc:基础Prompt配置ID为空则使用IsDefault=1的模板
/// Default:
/// Nullable:True
/// Prompt配置
/// </summary>
public string BasePrompt { get; set; }
/// <summary>
/// Desc:个性化Prompt追加到基础Prompt后面可为空
/// Default:
/// Nullable:True
/// </summary>
public string CustomPrompt { get; set; }
public string Prompt { get; set; }
}

View File

@ -122,18 +122,11 @@ namespace QYZH.InteractiveMagazine.Models.Entity
public bool NeedAiProcess { get; set; }
/// <summary>
/// Desc:基础Prompt配置ID为空则使用IsDefault=1的模板
/// Desc:Prompt配置
/// Default:
/// Nullable:True
/// </summary>
public string BasePrompt { get; set; }
/// <summary>
/// Desc:个性化Prompt追加到基础Prompt后面可为空
/// Default:
/// Nullable:True
/// </summary>
public string CustomPrompt { get; set; }
public string Prompt { get; set; }
/// <summary>
/// Desc:作答参考时间

View File

@ -16,5 +16,5 @@ public enum JournalPageTaskTypeEnum
/// 问答题
/// </summary>
[Description("主观题")]
TaskAnswer = 4
TaskAnswer = 0
}

View File

@ -0,0 +1,37 @@
namespace QYZH.InteractiveMagazine.Models.Settings;
/// <summary>
/// AI聊天配置
/// </summary>
public class AiChatSettings
{
/// <summary>
/// API密钥
/// </summary>
public string? ApiKey { get; set; }
/// <summary>
/// API基础地址OpenAI兼容
/// </summary>
public string? BaseUrl { get; set; }
/// <summary>
/// 模型名称
/// </summary>
public string? Model { get; set; }
/// <summary>
/// 请求超时时间(秒)
/// </summary>
public int TimeoutSeconds { get; set; } = 300;
/// <summary>
/// 最大生成Token数
/// </summary>
public int MaxTokens { get; set; } = 2000;
/// <summary>
/// 采样温度0-2值越低输出越确定
/// </summary>
public double Temperature { get; set; } = 0.5;
}

View File

@ -0,0 +1,154 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using QYZH.InteractiveMagazine.IService;
using QYZH.InteractiveMagazine.Models.Common;
using QYZH.InteractiveMagazine.Models.Dto;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace QYZH.InteractiveMagazine.Service;
/// <summary>
/// AI聊天服务实现OpenAI API规范
/// </summary>
public class AiChatService(
IHttpClientFactory httpClientFactory,
IConfiguration configuration,
ILogger<AiChatService> logger) : IAiChatService
{
/// <summary>
/// 预设系统提示词
/// </summary>
private const string SystemPrompt = """
# AI /
**/便 AI **
****
---
##
1. ****
2. **** ````````
3. **** + +
4. ****
5. **** Markdown 使`#` `##` `###``-`/
---
##
1. ****
- /
-
- //
-
-
2. ****
- `## ` +
- `## `
- `## `
- `## `/
- `## `
3. ****
-
- 1. 2. 3.
-
4. ****
---
##
- AI 使/ prompt
- 使 Markdown `#`
-
---
**** Markdown
""";
/// <summary>
/// AI聊天根据用户需求生成提示词
/// </summary>
public async Task<AiChatOutput> ChatAsync(AiChatInput input)
{
if (string.IsNullOrWhiteSpace(input.Message))
{
throw new BusinessException("消息内容不能为空", 400);
}
var apiKey = configuration["AiChat:ApiKey"];
var baseUrl = configuration["AiChat:BaseUrl"];
var model = configuration["AiChat:Model"];
var timeoutSeconds = configuration.GetValue<int>("AiChat:TimeoutSeconds");
var maxTokens = configuration.GetValue<int>("AiChat:MaxTokens");
var temperature = configuration.GetValue<double>("AiChat:Temperature");
if (string.IsNullOrWhiteSpace(apiKey) || string.IsNullOrWhiteSpace(baseUrl) || string.IsNullOrWhiteSpace(model))
{
throw new BusinessException("AI聊天服务配置不完整请检查 AiChat 配置节", 500);
}
logger.LogInformation("开始调用AI聊天服务消息长度{Length}", input.Message.Length);
// 构建 OpenAI Chat Completions 请求体
var requestBody = new
{
model = model,
messages = new[]
{
new { role = "system", content = SystemPrompt },
new { role = "user", content = input.Message }
},
max_tokens = maxTokens,
temperature = temperature
};
var jsonContent = JsonSerializer.Serialize(requestBody, new JsonSerializerOptions
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
});
// 创建 HttpClient 并发起请求
var client = httpClientFactory.CreateClient();
client.Timeout = TimeSpan.FromSeconds(timeoutSeconds > 0 ? timeoutSeconds : 300);
var request = new HttpRequestMessage(HttpMethod.Post, $"{baseUrl.TrimEnd('/')}/chat/completions");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
request.Content = new StringContent(jsonContent, Encoding.UTF8, "application/json");
var response = await client.SendAsync(request);
var responseContent = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
logger.LogError("AI聊天服务调用失败状态码{StatusCode},响应:{Response}", response.StatusCode, responseContent);
throw new BusinessException($"AI服务调用失败{response.StatusCode}", 500);
}
// 解析 OpenAI 响应
using var doc = JsonDocument.Parse(responseContent);
var choices = doc.RootElement.GetProperty("choices");
if (choices.GetArrayLength() == 0)
{
throw new BusinessException("AI未返回有效内容", 500);
}
var content = choices[0]
.GetProperty("message")
.GetProperty("content")
.GetString();
logger.LogInformation("AI聊天服务调用成功返回内容长度{Length}", content?.Length ?? 0);
return new AiChatOutput
{
Content = content ?? string.Empty
};
}
}

View File

@ -1,4 +1,4 @@
using Mapster;
using Mapster;
using QYZH.InteractiveMagazine.Common.Extensions;
using QYZH.InteractiveMagazine.Infrastructure.OSS;
using QYZH.InteractiveMagazine.IService;
@ -66,6 +66,7 @@ public class JournalPageTaskService(BaseRepository<Journal> journalRepository, O
task.Expression = input.Expression;
task.Persuasiveness = input.Persuasiveness;
task.AnswerTime = input.AnswerTime;
task.Prompt = input.Prompt;
var no = input.No.Split('-').Select(int.Parse).ToArray();
@ -95,7 +96,6 @@ public class JournalPageTaskService(BaseRepository<Journal> journalRepository, O
JournalPageId = task.JournalPageId,
No = task.No,
Type = task.Type,
Task = task.Task,
Points = task.Points,
GrowthPoint = task.GrowthPoint ?? 0,
Comprehension = task.Comprehension,
@ -103,6 +103,7 @@ public class JournalPageTaskService(BaseRepository<Journal> journalRepository, O
Expression = task.Expression,
Persuasiveness = task.Persuasiveness,
AnswerTime = task.AnswerTime,
Prompt = task.Prompt
};
// 查询答案列表

View File

@ -18,6 +18,7 @@ namespace QYZH.InteractiveMagazine.Service;
public class JournalService(BaseRepository<JournalPage> JournalPageRepository,
BaseRepository<JournalPageTask> JournalPageTaskRepository,
BaseRepository<JournalPageTaskAnswer> JournalPageTaskAnswerRepository,
BaseRepository<JournalCatalog> JournalCatalogRepository,
BaseRepository<DotFile> dotFileRepository,
BaseRepository<DotFileDetail> dotFileDetailRepository, OssService ossService, ILogger<JournalService> logger) : BaseRepository<Journal>, IJournalService
@ -210,6 +211,20 @@ public class JournalService(BaseRepository<JournalPage> JournalPageRepository,
await base.DeleteAsync(d => ids.Contains(d.Id));
await JournalPageRepository.DeleteAsync(d => ids.Contains(d.JournalId));
await JournalCatalogRepository.DeleteAsync(d => ids.Contains(d.JournalId));
// 先查询要删除的 JournalPageTask Id 列表
var taskIds = await JournalPageTaskRepository.Queryable()
.Where(d => ids.Contains(d.JournalId))
.Select(d => d.Id)
.ToListAsync();
// 删除 JournalPageTaskAnswer
if (taskIds.Any())
{
await JournalPageTaskAnswerRepository.DeleteAsync(d => taskIds.Contains(d.JournalPageTaskId));
}
// 再删除 JournalPageTask
await JournalPageTaskRepository.DeleteAsync(d => ids.Contains(d.JournalId));
return true;
});

View File

@ -0,0 +1,50 @@
using Microsoft.AspNetCore.Mvc;
using QYZH.InteractiveMagazine.IService;
using QYZH.InteractiveMagazine.Models.Common;
using QYZH.InteractiveMagazine.Models.Dto;
using QYZH.InteractiveMagazine.Models.Enum;
namespace QYZH.InteractiveMagazine.WebApi.Controllers;
/// <summary>
/// AI聊天控制器
/// </summary>
[Route("api/[controller]")]
[ApiController]
[ApiExplorerSettings(GroupName = nameof(ApiVersionEnum.Platform))]
public class AiChatController : BaseController
{
private readonly IAiChatService _aiChatService;
private readonly ILogger<AiChatController> _logger;
public AiChatController(IAiChatService aiChatService, ILogger<AiChatController> logger)
{
_aiChatService = aiChatService;
_logger = logger;
}
/// <summary>
/// AI聊天根据需求生成提示词
/// </summary>
/// <param name="input">用户需求消息</param>
/// <returns>AI生成的提示词Markdown格式</returns>
[HttpPost]
public async Task<BaseResponse<AiChatOutput>> ChatAsync([FromBody] AiChatInput input)
{
try
{
var result = await _aiChatService.ChatAsync(input);
return Success(result, "AI聊天成功");
}
catch (BusinessException ex)
{
_logger.LogWarning(ex, "AI聊天业务异常: {Message}", ex.Message);
return BaseResponse<AiChatOutput>.Fail(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "AI聊天系统异常参数{Input}", input);
return BaseResponse<AiChatOutput>.Fail("AI聊天失败请稍后重试");
}
}
}

View File

@ -47,6 +47,14 @@
]
},
"AllowedHosts": "*",
"AiChat": {
"ApiKey": "Ollama",
"BaseUrl": "http://172.16.10.130:11434/v1/",
"Model": "qwen2.5vl:7b",
"TimeoutSeconds": 300,
"MaxTokens": 2000,
"Temperature": 0.5
},
"AliyunOSSConfigs": {
"AccessKeyID": "LTAI5tEBXGewpHSLiSxyx6Bf",
"AccessKeySecret": "w29b8wkw6XQVL8GWXgp3ZesgYeDKvf",