mirror of
https://github.com/kuaifan/dootask.git
synced 2025-12-10 18:02:55 +00:00
1116 lines
42 KiB
PHP
1116 lines
42 KiB
PHP
<?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']
|
||
]);
|
||
}
|
||
}
|