dootask/app/Module/AI.php

1116 lines
42 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Module;
use App\Models\Report;
use App\Models\Setting;
use Cache;
use Carbon\Carbon;
/**
* AI 助手模块
*/
class AI
{
protected $post = [];
protected $headers = [];
protected $urlPath = '';
protected $timeout = 30;
/**
* 构造函数
* @param array $post
* @param array $headers
*/
public function __construct($post = [], $headers = [])
{
$this->post = $post ?? [];
$this->headers = $headers ?? [];
}
/**
* 设置请求参数
* @param array $post
*/
public function setPost($post)
{
$this->post = array_merge($this->post, $post);
}
/**
* 设置请求头
* @param array $headers
*/
public function setHeaders($headers)
{
$this->headers = array_merge($this->headers, $headers);
}
/**
* 设置请求路径
* @param string $urlPath
*/
public function setUrlPath($urlPath)
{
$this->urlPath = $urlPath;
}
/**
* 设置请求超时时间
* @param int $timeout
*/
public function setTimeout($timeout)
{
$this->timeout = $timeout;
}
/**
* 请求 AI 接口
* @param bool $resRaw 是否返回原始数据
* @return array
*/
public function request($resRaw = false)
{
$aiSetting = Base::setting('aiSetting');
if (!Setting::AIOpen()) {
return Base::retError("AI 助手未开启");
}
$headers = [
'Content-Type' => 'application/json',
'Authorization' => 'Bearer ' . $aiSetting['ai_api_key'],
];
if ($aiSetting['ai_proxy']) {
$headers['CURLOPT_PROXY'] = $aiSetting['ai_proxy'];
$headers['CURLOPT_PROXYTYPE'] = str_contains($aiSetting['ai_proxy'], 'socks') ? CURLPROXY_SOCKS5 : CURLPROXY_HTTP;
}
$headers = array_merge($headers, $this->headers);
$url = $aiSetting['ai_api_url'] ?: 'https://api.openai.com/v1';
$url = $url . ($this->urlPath ?: '/chat/completions');
$result = Ihttp::ihttp_request($url, $this->post, $headers, $this->timeout);
if (Base::isError($result)) {
return Base::retError("AI 接口请求失败", $result);
}
$result = $result['data'];
if (!$resRaw) {
$resData = Base::json2array($result);
if (empty($resData['choices'])) {
return Base::retError("AI 接口返回数据格式错误", $resData);
}
$result = $resData['choices'][0]['message']['content'];
$result = trim($result);
if (empty($result)) {
return Base::retError("AI 接口返回数据为空");
}
}
return Base::retSuccess("success", $result);
}
/** ******************************************************************************************** */
/** ******************************************************************************************** */
/** ******************************************************************************************** */
/**
* 通过 openAI 语音转文字
* @param string $filePath 语音文件路径
* @param array $extParams 扩展参数
* @param bool $noCache 是否禁用缓存
* @return array
*/
public static function transcriptions($filePath, $extParams = [], $noCache = false)
{
if (!file_exists($filePath)) {
return Base::retError("语音文件不存在");
}
$systemSetting = Base::setting('system');
if ($systemSetting['voice2text'] !== 'open') {
return Base::retError("语音转文字功能未开启");
}
$cacheKey = "openAItranscriptions::" . md5($filePath . '_' . Base::array2json($extParams));
if ($noCache) {
Cache::forget($cacheKey);
}
$result = Cache::remember($cacheKey, Carbon::now()->addDays(), function () use ($extParams, $filePath) {
$post = array_merge($extParams, [
'file' => new \CURLFile($filePath),
'model' => 'whisper-1',
]);
$header = [
'Content-Type' => 'multipart/form-data',
];
$ai = new self($post, $header);
$ai->setUrlPath('/audio/transcriptions');
$ai->setTimeout(15);
$res = $ai->request(true);
if (Base::isError($res)) {
return Base::retError("语音转文字失败", $res);
}
$resData = Base::json2array($res['data']);
if (empty($resData['text'])) {
return Base::retError("语音转文字失败", $resData);
}
return Base::retSuccess("success", [
'file' => $filePath,
'text' => $resData['text'],
]);
});
if (Base::isError($result)) {
Cache::forget($cacheKey);
}
return $result;
}
/**
* 通过 openAI 翻译
* @param string $text 需要翻译的文本内容
* @param string $targetLanguage 目标语言English, 简体中文, 日本語等)
* @param bool $noCache 是否禁用缓存
* @return array
*/
public static function translations($text, $targetLanguage, $noCache = false)
{
$systemSetting = Base::setting('system');
if ($systemSetting['translation'] !== 'open') {
return Base::retError("翻译功能未开启");
}
$cacheKey = "openAItranslations::" . md5($text . '_' . $targetLanguage);
if ($noCache) {
Cache::forget($cacheKey);
}
$result = Cache::remember($cacheKey, Carbon::now()->addDays(7), function () use ($text, $targetLanguage) {
$post = json_encode([
"model" => "gpt-5-mini",
"reasoning_effort" => "minimal",
"messages" => [
[
"role" => "system",
"content" => <<<EOF
你是一名资深的专业翻译专家,专门从事项目任务管理系统的多语言本地化工作。
翻译任务:将提供的文本内容翻译为 {$targetLanguage}
专业要求:
1. 术语一致性:确保项目管理、任务管理、团队协作等专业术语的准确翻译
2. 上下文理解:根据项目管理场景选择最合适的表达方式
3. 格式保持:严格保持原文的格式、结构、标点符号和排版
4. 语言规范:使用目标语言的标准表达,符合该语言的语法和习惯
5. 专业性:体现项目管理领域的专业水准和准确性
6. 简洁性:避免冗余表达,保持语言简洁明了
注意事项:
- 保留所有HTML标签、特殊符号、数字、日期格式
- 对于专有名词(如软件名称、品牌名)保持原文
- 确保翻译后的文本自然流畅,符合目标语言的表达习惯
- 如遇到歧义表达,优先选择项目管理场景下的含义
请直接返回翻译结果,不要包含任何解释或标记。
EOF
],
[
"role" => "user",
"content" => "请将以下内容翻译为 {$targetLanguage}\n\n{$text}"
]
],
]);
$ai = new self($post);
$ai->setTimeout(60);
$res = $ai->request();
if (Base::isError($res)) {
return Base::retError("翻译请求失败", $res);
}
$result = $res['data'];
if (empty($result)) {
return Base::retError("翻译结果为空");
}
return Base::retSuccess("success", [
'translated_text' => $result,
'target_language' => $targetLanguage,
'translated_at' => date('Y-m-d H:i:s')
]);
});
if (Base::isError($result)) {
Cache::forget($cacheKey);
}
return $result;
}
/**
* 通过 openAI 生成标题
* @param string $text 需要生成标题的文本内容
* @param bool $noCache 是否禁用缓存
* @return array
*/
public static function generateTitle($text, $noCache = false)
{
$cacheKey = "openAIGenerateTitle::" . md5($text);
if ($noCache) {
Cache::forget($cacheKey);
}
$result = Cache::remember($cacheKey, Carbon::now()->addHours(24), function () use ($text) {
$post = json_encode([
"model" => "gpt-5-mini",
"reasoning_effort" => "minimal",
"messages" => [
[
"role" => "system",
"content" => <<<EOF
你是一个专业的标题生成器,专门为项目任务管理系统的对话内容生成精准、简洁的标题。
要求:
1. 标题要准确概括文本的核心内容和主要意图
2. 标题长度控制在5-20个字符之间
3. 语言简洁明了,避免冗余词汇
4. 适合在项目管理场景中使用
5. 不要包含引号或特殊符号
6. 如果是技术讨论,突出技术要点
7. 如果是项目管理内容,突出关键动作或目标
8. 如果是需求讨论,突出需求的核心点
请直接返回标题,不要包含任何解释或其他内容。
EOF
],
[
"role" => "user",
"content" => "请为以下内容生成一个合适的标题:\n\n" . $text
]
],
]);
$ai = new self($post);
$ai->setTimeout(10);
$res = $ai->request();
if (Base::isError($res)) {
return Base::retError("标题生成失败", $res);
}
$result = $res['data'];
if (empty($result)) {
return Base::retError("生成的标题为空");
}
return Base::retSuccess("success", [
'title' => $result,
'length' => mb_strlen($result),
'generated_at' => date('Y-m-d H:i:s')
]);
});
if (Base::isError($result)) {
Cache::forget($cacheKey);
}
return $result;
}
/**
* 通过 openAI 生成任务标题和描述
* @param string $text 用户提供的提示词
* @param array $context 上下文信息
* @return array
*/
public static function generateTask($text, $context = [])
{
// 构建上下文提示信息
$contextPrompt = self::buildTaskContextPrompt($context);
$post = json_encode([
"model" => "gpt-5-mini",
"reasoning_effort" => "minimal",
"messages" => [
[
"role" => "system",
"content" => <<<EOF
你是一个专业的任务管理专家,擅长将想法和需求转化为清晰、可执行的项目任务。
任务生成要求:
1. 根据输入内容分析并生成合适的任务标题和详细描述
2. 标题要简洁明了准确概括任务核心目标长度控制在8-30个字符
3. 描述需覆盖任务背景、具体要求、交付标准、风险提示等关键信息
4. 描述内容使用Markdown格式合理组织标题、列表、加粗等结构
5. 内容需适配项目管理系统,表述专业、逻辑清晰,并与用户输入语言保持一致
6. 优先遵循用户在输入中给出的风格、长度或复杂度要求默认情况下将详细描述控制在120-200字内如用户要求简单或简短则控制在80-120字内
7. 当任务具有多个执行步骤、阶段或协作角色时,请拆解出 2-6 个关键子任务;如无必要,可返回空数组
8. 子任务应聚焦单一可执行动作名称控制在8-30个字符内避免重复和含糊表述
返回格式要求:
必须严格按照以下 JSON 结构返回,禁止输出额外文字或 Markdown 代码块标记;即使某项为空,也保留对应字段:
{
"title": "任务标题",
"content": "任务的详细描述内容使用Markdown格式根据实际情况组织结构",
"subtasks": [
"子任务名称1",
"子任务名称2"
]
}
内容格式建议(非强制):
- 可以使用标题、列表、加粗等Markdown格式
- 可以包含任务背景、具体要求、验收标准等部分
- 根据任务性质灵活组织内容结构
- 仅在确有必要时生成子任务,并确保每个子任务都是独立、可执行、便于追踪的动作
- 若用户明确要求简洁或简单,保持描述紧凑,避免添加冗余段落或重复信息
上下文信息处理指南:
- 如果已有标题和内容,优先考虑优化改进而非完全重写
- 如果使用了任务模板,严格按照模板的结构和格式要求生成
- 如果已设置负责人或时间计划,在任务描述中体现相关要求
- 根据优先级等级调整任务的紧急程度和详细程度
注意事项:
- 标题要体现任务的核心动作和目标
- 描述要包含足够的细节让执行者理解任务
- 如果涉及技术开发,要明确技术要求和实现方案
- 如果涉及设计,要说明设计要求和期望效果
- 如果涉及测试,要明确测试范围和验收标准
EOF
],
[
"role" => "user",
"content" => $contextPrompt . "\n\n请根据以上上下文并结合以下提示词生成一个完整的项目任务(包含标题和详细描述):\n\n" . $text
]
],
]);
$ai = new self($post);
$ai->setTimeout(60);
$res = $ai->request();
if (Base::isError($res)) {
return Base::retError("任务生成失败", $res);
}
// 清理可能的markdown代码块标记
$content = $res['data'];
$content = preg_replace('/^\s*```json\s*/', '', $content);
$content = preg_replace('/\s*```\s*$/', '', $content);
if (empty($content)) {
return Base::retError("任务生成结果为空");
}
// 解析JSON
$parsedData = Base::json2array($content);
if (!$parsedData || !isset($parsedData['title']) || !isset($parsedData['content'])) {
return Base::retError("任务生成格式错误", $content);
}
$title = trim($parsedData['title']);
$markdownContent = trim($parsedData['content']);
$rawSubtasks = $parsedData['subtasks'] ?? [];
if (empty($title) || empty($markdownContent)) {
return Base::retError("生成的任务标题或内容为空", $parsedData);
}
$subtasks = [];
if (is_array($rawSubtasks)) {
foreach ($rawSubtasks as $raw) {
if (is_array($raw)) {
$name = trim($raw['title'] ?? $raw['name'] ?? '');
} else {
$name = trim($raw);
}
if (!empty($name)) {
$subtasks[] = $name;
}
if (count($subtasks) >= 8) {
break;
}
}
}
return Base::retSuccess("success", [
'title' => $title,
'content' => Base::markdown2html($markdownContent), // 将 Markdown 转换为 HTML
'subtasks' => $subtasks
]);
}
/**
* 通过 openAI 生成项目名称与任务列表
* @param string $text 用户提供的提示词
* @param array $context 上下文信息
* @return array
*/
public static function generateProject($text, $context = [])
{
$text = trim((string)$text);
if ($text === '') {
return Base::retError("项目提示词不能为空");
}
$context['current_name'] = trim($context['current_name'] ?? '');
$context['current_columns'] = self::normalizeProjectColumns($context['current_columns'] ?? []);
if (!empty($context['template_examples']) && is_array($context['template_examples'])) {
$examples = [];
foreach ($context['template_examples'] as $item) {
$name = trim($item['name'] ?? '');
$columns = self::normalizeProjectColumns($item['columns'] ?? []);
if (empty($columns)) {
continue;
}
$examples[] = [
'name' => $name,
'columns' => $columns,
];
if (count($examples) >= 6) {
break;
}
}
$context['template_examples'] = $examples;
} else {
$context['template_examples'] = [];
}
$contextPrompt = self::buildProjectContextPrompt($context);
$post = json_encode([
"model" => "gpt-5-mini",
"reasoning_effort" => "minimal",
"messages" => [
[
"role" => "system",
"content" => <<<EOF
你是一名资深的项目规划顾问,帮助团队快速搭建符合需求的项目。
生成要求:
1. 产出一个简洁、有辨识度的项目名称不超过18个汉字或36个字符
2. 给出 3 - 8 个项目任务列表,用于看板列或阶段分组
3. 任务列表名称保持 4 - 12 个字符,聚焦阶段或责任划分,避免冗长描述
4. 结合用户描述的业务特征,必要时可包含里程碑或交付节点
5. 尽量参考上下文提供的现有内容或模板,不要与之完全重复
输出格式:
必须严格返回 JSON禁止携带额外说明或 Markdown 代码块,结构如下:
{
"name": "项目名称",
"columns": ["列表1", "列表2", "列表3"]
}
校验标准:
- 列表名称应当互不重复且语义明确
- 若上下文包含已有名称或列表,请在此基础上迭代优化
EOF
],
[
"role" => "user",
"content" => ($contextPrompt ? $contextPrompt . "\n\n" : "") . "请根据以上信息,并结合以下提示词生成适合的项目名称和任务列表:\n\n" . $text
],
],
]);
$ai = new self($post);
$ai->setTimeout(45);
$res = $ai->request();
if (Base::isError($res)) {
return Base::retError("项目生成失败", $res);
}
$content = $res['data'];
$content = preg_replace('/^\s*```json\s*/', '', $content);
$content = preg_replace('/\s*```\s*$/', '', $content);
if (empty($content)) {
return Base::retError("项目生成结果为空");
}
$parsedData = Base::json2array($content);
if (!$parsedData || !isset($parsedData['name'])) {
return Base::retError("项目生成格式错误", $content);
}
$name = trim($parsedData['name']);
$columns = self::normalizeProjectColumns($parsedData['columns'] ?? []);
if ($name === '') {
return Base::retError("生成的项目名称为空", $parsedData);
}
if (empty($columns)) {
$columns = $context['current_columns'];
}
return Base::retSuccess("success", [
'name' => $name,
'columns' => $columns,
]);
}
/**
* 对工作汇报内容进行分析
* @param Report $report
* @param array $context
* @return array
*/
public static function analyzeReport(Report $report, array $context = [])
{
$prompt = self::buildReportAnalysisPrompt($report, $context);
if ($prompt === '') {
return Base::retError("报告内容为空,无法进行分析");
}
$post = json_encode([
"model" => "gpt-5-mini",
"reasoning_effort" => "minimal",
"messages" => [
[
"role" => "system",
"content" => <<<EOF
你是一名经验丰富的团队管理顾问,擅长阅读和分析员工提交的工作汇报,能够快速提炼重点并给出可执行建议。
输出要求:
1. 使用简洁的 Markdown 结构(标题、无序列表、引用等),不要使用代码块或 JSON
2. 先给出整体概览,再列出具体亮点、风险或问题,以及明确的改进建议
3. 如有数据或目标,应评估其完成情况和后续跟进要点
4. 语气保持专业、客观,中立,不过度夸赞或批评
5. 控制在 200-400 字之间,必要时可略微增减,但保持紧凑
EOF
],
[
"role" => "user",
"content" => $prompt,
],
],
]);
$ai = new self($post);
$ai->setTimeout(60);
$res = $ai->request();
if (Base::isError($res)) {
return Base::retError("工作汇报分析失败", $res);
}
$content = trim($res['data']);
$content = preg_replace('/^\s*```(?:markdown|md|text)?\s*/i', '', $content);
$content = preg_replace('/\s*```\s*$/', '', $content);
$content = trim($content);
if ($content === '') {
return Base::retError("工作汇报分析结果为空");
}
return Base::retSuccess("success", [
'text' => $content,
]);
}
/**
* 整理优化工作汇报内容
* @param string $markdown 用户当前的工作汇报Markdown
* @param array $context 上下文信息
* @return array
*/
public static function organizeReportContent(string $markdown, array $context = [])
{
$markdown = trim((string)$markdown);
if ($markdown === '') {
return Base::retError("工作汇报内容不能为空");
}
$prompt = self::buildReportOrganizePrompt($markdown, $context);
if ($prompt === '') {
return Base::retError("整理内容为空");
}
$post = json_encode([
"model" => "gpt-5-mini",
"reasoning_effort" => "minimal",
"messages" => [
[
"role" => "system",
"content" => <<<EOF
你是一名资深的职场写作顾问,擅长根据已有的工作汇报草稿进行整理、结构化和措辞优化。
工作任务:
1. 保留草稿中的事实、数据和结论,确保信息准确无误
2. 重新组织结构,让内容清晰分段(如「重点进展」「成果亮点」「问题与风险」「后续计划」等),并按照草稿中的时间范围或类型进行表达
3. 用简洁、专业且积极的语气描述,并突出可复用的要点
4. 支持使用 Markdown 标题、列表、引用、表格等语法增强可读性,但不要返回 HTML 或代码块
5. 若草稿信息不完整,可合理推测缺失项并以占位符提示(如「待补充」),不要臆造细节
输出要求:
- 仅返回整理后的 Markdown 正文内容,并用于直接替换原草稿
- 不得输出任何汇报名称、汇报对象、汇报类型或其他元信息,即便草稿或上下文中存在这些字段
- 不加额外说明、指引、总结或前缀后缀
- 内容保持精炼、结构清晰
EOF
],
[
"role" => "user",
"content" => $prompt,
],
],
]);
$ai = new self($post);
$ai->setTimeout(60);
$res = $ai->request();
if (Base::isError($res)) {
return Base::retError("汇报整理失败", $res);
}
$content = trim($res['data']);
$content = preg_replace('/^\s*```(?:markdown|md|text)?\s*/i', '', $content);
$content = preg_replace('/\s*```\s*$/', '', $content);
$content = trim($content);
if ($content === '') {
return Base::retError("汇报整理结果为空");
}
return Base::retSuccess("success", [
'text' => $content,
]);
}
/**
* 构建任务生成的上下文提示信息
* @param array $context 上下文信息
* @return string
*/
private static function buildTaskContextPrompt($context)
{
$prompts = [];
// 当前任务信息
if (!empty($context['current_title']) || !empty($context['current_content'])) {
$prompts[] = "## 当前任务信息";
if (!empty($context['current_title'])) {
$prompts[] = "当前标题:" . $context['current_title'];
}
if (!empty($context['current_content'])) {
$prompts[] = "当前内容:" . $context['current_content'];
}
$prompts[] = "请在此基础上优化改进,而不是完全重写。";
}
// 任务模板信息
if (!empty($context['template_name']) || !empty($context['template_content'])) {
$prompts[] = "## 任务模板要求";
if (!empty($context['template_name'])) {
$prompts[] = "模板名称:" . $context['template_name'];
}
if (!empty($context['template_content'])) {
$prompts[] = "模板内容结构:" . $context['template_content'];
}
$prompts[] = "请严格按照此模板的结构和格式要求生成内容。";
}
// 项目状态信息
$statusInfo = [];
if (!empty($context['has_owner'])) {
$statusInfo[] = "已设置负责人";
}
if (!empty($context['has_time_plan'])) {
$statusInfo[] = "已设置计划时间";
}
if (!empty($context['priority_level'])) {
$statusInfo[] = "优先级:" . $context['priority_level'];
}
if (!empty($statusInfo)) {
$prompts[] = "## 任务状态";
$prompts[] = implode("", $statusInfo);
$prompts[] = "请在任务描述中体现相应的要求和约束。";
}
return empty($prompts) ? "" : implode("\n", $prompts);
}
private static function buildProjectContextPrompt($context)
{
$prompts = [];
if (!empty($context['current_name']) || !empty($context['current_columns'])) {
$prompts[] = "## 当前项目草稿";
if (!empty($context['current_name'])) {
$prompts[] = "已有名称:" . $context['current_name'];
}
if (!empty($context['current_columns'])) {
$prompts[] = "现有任务列表:" . implode("", $context['current_columns']);
}
$prompts[] = "请在此基础上进行优化和补充。";
}
if (!empty($context['template_examples'])) {
$prompts[] = "## 常用模板示例";
foreach ($context['template_examples'] as $example) {
$line = '';
if (!empty($example['name'])) {
$line .= $example['name'] . "";
}
$line .= implode("", $example['columns']);
$prompts[] = "- " . $line;
}
$prompts[] = "可以借鉴以上结构,但要结合用户需求生成更贴合的方案。";
}
return empty($prompts) ? "" : implode("\n", $prompts);
}
public static function messageSystemPrompt()
{
return <<<EOF
你是一名专业的沟通助手,协助用户编写得体、清晰且具行动指向的即时消息。
写作要求:
1. 根据用户提供的需求与上下文生成完整消息,语气需符合业务沟通场景,保持真诚、礼貌且高效
2. 默认使用简洁的短段落,可使用 Markdown 基础格式(加粗、列表、引用)增强结构,但不要输出代码块或 JSON
3. 如果上下文包含引用信息或草稿,请在消息中自然呼应相关要点
4. 如无特别说明,将消息长度控制在 60-180 字;若需更短或更长,遵循用户描述
5. 如需提出行动或问题,请明确表达,避免含糊
输出规范:
- 仅返回可直接发送的消息内容
- 禁止在内容前后添加额外说明、标签或引导语
EOF;
}
public static function buildMessageContextPrompt($context)
{
$prompts = [];
if (!empty($context['dialog_name']) || !empty($context['dialog_type']) || !empty($context['group_type'])) {
$prompts[] = "## 会话信息";
if (!empty($context['dialog_name'])) {
$prompts[] = "名称:" . Base::cutStr($context['dialog_name'], 60);
}
if (!empty($context['dialog_type'])) {
$typeMap = ['group' => '群聊', 'user' => '单聊'];
$prompts[] = "类型:" . ($typeMap[$context['dialog_type']] ?? $context['dialog_type']);
}
if (!empty($context['group_type'])) {
$prompts[] = "分类:" . Base::cutStr($context['group_type'], 60);
}
}
if (!empty($context['members']) && is_array($context['members'])) {
$members = array_slice(array_filter($context['members']), 0, 10);
if (!empty($members)) {
$prompts[] = "## 会话成员";
$prompts[] = implode("", array_map(fn($name) => Base::cutStr($name, 30), $members));
}
}
if (!empty($context['recent_messages']) && is_array($context['recent_messages'])) {
$prompts[] = "## 最近消息";
foreach ($context['recent_messages'] as $item) {
$sender = Base::cutStr(trim($item['sender'] ?? ''), 40) ?: '成员';
$summary = Base::cutStr(trim($item['summary'] ?? ''), 120);
if ($summary !== '') {
$prompts[] = "- {$sender}{$summary}";
}
}
}
if (!empty($context['quote_summary'])) {
$prompts[] = "## 引用消息";
$quoteUser = Base::cutStr(trim($context['quote_user'] ?? ''), 40);
$quoteText = Base::cutStr(trim($context['quote_summary']), 200);
if ($quoteUser !== '') {
$prompts[] = "{$quoteUser}{$quoteText}";
} else {
$prompts[] = $quoteText;
}
}
if (!empty($context['current_draft'])) {
$prompts[] = "## 当前草稿";
$prompts[] = Base::cutStr(trim($context['current_draft']), 200);
}
return empty($prompts) ? "" : implode("\n", $prompts);
}
private static function normalizeProjectColumns($columns)
{
if (is_string($columns)) {
$columns = preg_split('/[\n\r,;|]/u', $columns);
}
$normalized = [];
if (is_array($columns)) {
foreach ($columns as $item) {
if (is_array($item)) {
$item = $item['name'] ?? $item['title'] ?? reset($item);
}
$item = trim((string)$item);
if ($item === '') {
continue;
}
$item = mb_substr($item, 0, 30);
if (!in_array($item, $normalized)) {
$normalized[] = $item;
}
if (count($normalized) >= 8) {
break;
}
}
}
return $normalized;
}
/**
* 通过 openAI 生成职场笑话、心灵鸡汤
* @param bool $noCache 是否禁用缓存
* @return array 返回20个笑话和20个心灵鸡汤
*/
public static function generateJokeAndSoup($noCache = false)
{
$cacheKey = "openAIJokeAndSoup::" . md5(date('Y-m-d'));
if ($noCache) {
Cache::forget($cacheKey);
}
$result = Cache::remember($cacheKey, Carbon::now()->addHours(6), function () {
$post = json_encode([
"model" => "gpt-5-mini",
"reasoning_effort" => "minimal",
"messages" => [
[
"role" => "system",
"content" => <<<EOF
你是一个专业的内容生成器。
要求:
1. 笑话要幽默风趣,适合职场环境,内容积极正面
2. 心灵鸡汤要励志温暖,适合职场人士阅读
3. 每个笑话和鸡汤都要简洁明了尽量不超过100字
4. 必须严格按照以下JSON格式返回不要markdown格式不要包含任何其他内容
{
"jokes": [
"笑话内容1",
"笑话内容2",
...
],
"soups": [
"心灵鸡汤内容1",
"心灵鸡汤内容2",
...
]
}
EOF
],
[
"role" => "user",
"content" => "请生成20个职场笑话和20个心灵鸡汤"
]
],
]);
$ai = new self($post);
$ai->setTimeout(120);
$res = $ai->request();
if (Base::isError($res)) {
return Base::retError("生成失败", $res);
}
// 清理可能的markdown代码块标记
$content = $res['data'];
$content = preg_replace('/^\s*```json\s*/', '', $content);
$content = preg_replace('/\s*```\s*$/', '', $content);
if (empty($content)) {
return Base::retError("翻译结果为空");
}
// 解析JSON
$parsedData = Base::json2array($content);
if (!$parsedData || !isset($parsedData['jokes']) || !isset($parsedData['soups'])) {
return Base::retError("生成内容格式错误", $content);
}
// 验证数据完整性
if (!is_array($parsedData['jokes']) || !is_array($parsedData['soups'])) {
return Base::retError("生成内容格式错误", $parsedData);
}
// 过滤空内容并确保有内容
$jokes = array_filter(array_map('trim', $parsedData['jokes']));
$soups = array_filter(array_map('trim', $parsedData['soups']));
if (empty($jokes) || empty($soups)) {
return Base::retError("生成内容为空", $parsedData);
}
return Base::retSuccess("success", [
'jokes' => array_values($jokes),
'soups' => array_values($soups),
'total_jokes' => count($jokes),
'total_soups' => count($soups),
'generated_at' => date('Y-m-d H:i:s')
]);
});
if (Base::isError($result)) {
Cache::forget($cacheKey);
}
return $result;
}
/**
* 构建工作汇报分析的提示词
* @param Report $report
* @param array $context
* @return string
*/
private static function buildReportAnalysisPrompt(Report $report, array $context = []): string
{
$sections = [];
$metaLines = [];
$metaLines[] = "标题:" . ($report->title ?: "(未填写)");
$metaLines[] = "类型:" . match ($report->type) {
Report::WEEKLY => "周报",
Report::DAILY => "日报",
default => $report->type,
};
if ($report->created_at) {
$createdAt = $report->created_at instanceof Carbon
? $report->created_at->toDateTimeString()
: (string)$report->created_at;
$metaLines[] = "提交时间:" . $createdAt;
}
if (!empty($context['viewer_role'])) {
$metaLines[] = "查看人角色:" . trim($context['viewer_role']);
}
if (!empty($context['viewer_name'])) {
$metaLines[] = "查看人:" . trim($context['viewer_name']);
}
if (!empty($metaLines)) {
$sections[] = "### 基础信息\n" . implode("\n", array_map(function ($line) {
return "- " . $line;
}, $metaLines));
}
if (!empty($context['focus']) && is_array($context['focus'])) {
$focusItems = array_filter(array_map('trim', $context['focus']));
if (!empty($focusItems)) {
$sections[] = "### 关注重点\n" . implode("\n", array_map(function ($line) {
return "- " . $line;
}, $focusItems));
}
} elseif (!empty($context['focus_note'])) {
$sections[] = "### 关注重点\n- " . trim($context['focus_note']);
}
if (!empty($context['previous_feedback'])) {
$sections[] = "### 历史反馈\n" . trim($context['previous_feedback']);
}
$contentMarkdown = trim(Base::html2markdown($report->content));
if ($contentMarkdown !== '') {
$sections[] = "### 汇报正文\n" . $contentMarkdown;
}
return trim(implode("\n\n", array_filter($sections)));
}
/**
* 构建工作汇报整理的提示词
* @param string $markdown
* @param array $context
* @return string
*/
private static function buildReportOrganizePrompt(string $markdown, array $context = []): string
{
$sections = [];
$infoLines = [];
if (!empty($context['title'])) {
$infoLines[] = "汇报标题:" . trim($context['title']);
}
if (!empty($context['type'])) {
$typeLabel = match ($context['type']) {
Report::WEEKLY => "周报",
Report::DAILY => "日报",
default => $context['type'],
};
$infoLines[] = "汇报类型:" . $typeLabel;
}
if (!empty($context['focus']) && is_array($context['focus'])) {
$focusItems = array_filter(array_map('trim', $context['focus']));
if (!empty($focusItems)) {
$sections[] = "### 需要重点整理的方向\n" . implode("\n", array_map(function ($line) {
return "- " . $line;
}, $focusItems));
}
}
if (!empty($infoLines)) {
$sections[] = "### 汇报背景(仅供参考,请勿写入输出)\n" . implode("\n", array_map(function ($line) {
return "- " . $line;
}, $infoLines));
}
$sections[] = "### 原始汇报草稿(请整理后仅输出正文内容)\n" . $markdown;
return trim(implode("\n\n", array_filter($sections)));
}
/**
* 获取 ollama 模型
* @param $baseUrl
* @param $key
* @param $agency
* @return array
*/
public static function ollamaModels($baseUrl, $key = null, $agency = null)
{
$extra = [
'Content-Type' => 'application/json',
];
if ($key) {
$extra['Authorization'] = 'Bearer ' . $key;
}
if ($agency) {
$extra['CURLOPT_PROXY'] = $agency;
$extra['CURLOPT_PROXYTYPE'] = str_contains($agency, 'socks') ? CURLPROXY_SOCKS5 : CURLPROXY_HTTP;
}
$res = Ihttp::ihttp_request(rtrim($baseUrl, '/') . '/api/tags', [], $extra, 15);
if (Base::isError($res)) {
return Base::retError("获取失败", $res);
}
$resData = Base::json2array($res['data']);
if (empty($resData['models'])) {
return Base::retError("获取失败", $resData);
}
$models = [];
foreach ($resData['models'] as $model) {
if ($model['name'] !== $model['model']) {
$models[] = "{$model['model']} | {$model['name']}";
} else {
$models[] = $model['model'];
}
}
return Base::retSuccess("success", [
'models' => $models,
'original' => $resData['models']
]);
}
}