mirror of
https://github.com/kuaifan/dootask.git
synced 2025-12-12 11:19:56 +00:00
feat: 完善AI助手功能
This commit is contained in:
parent
69c66053b7
commit
ecb52c76b9
@ -3,12 +3,8 @@
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Module\AI;
|
||||
use App\Module\Base;
|
||||
use App\Module\Ihttp;
|
||||
use App\Models\WebSocketDialog;
|
||||
use App\Models\WebSocketDialogMsg;
|
||||
use App\Models\WebSocketDialogUser;
|
||||
use Request;
|
||||
|
||||
/**
|
||||
@ -18,133 +14,6 @@ use Request;
|
||||
*/
|
||||
class AssistantController extends AbstractController
|
||||
{
|
||||
/**
|
||||
* @api {post} api/assistant/dialog/prompt 生成对话提示词
|
||||
*
|
||||
* @apiDescription 需要token身份,整理当前会话的系统提示词与上下文信息
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup assistant
|
||||
* @apiName dialog__prompt
|
||||
*
|
||||
* @apiParam {Number} dialog_id 对话ID
|
||||
* @apiParam {String} [content] 消息需求描述(用于提示词整理,可为空)
|
||||
* @apiParam {String} [draft] 当前草稿内容(HTML 格式)
|
||||
* @apiParam {Number} [quote_id] 引用消息ID
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
* @apiSuccess {Object} data 返回数据
|
||||
* @apiSuccess {String} data.system_prompt AI 使用的系统提示词
|
||||
* @apiSuccess {String} data.context_prompt AI 使用的上下文提示词
|
||||
*/
|
||||
public function dialog__prompt()
|
||||
{
|
||||
$user = User::auth();
|
||||
$user->checkChatInformation();
|
||||
//
|
||||
$dialog_id = intval(Request::input('dialog_id'));
|
||||
$content = trim(Request::input('content', ''));
|
||||
if ($dialog_id <= 0) {
|
||||
return Base::retError('参数错误');
|
||||
}
|
||||
|
||||
$dialog = WebSocketDialog::checkDialog($dialog_id);
|
||||
|
||||
// 基本信息
|
||||
$context = [
|
||||
'dialog_name' => $dialog->name ?: '',
|
||||
'dialog_type' => $dialog->type ?: '',
|
||||
'group_type' => $dialog->group_type ?: '',
|
||||
];
|
||||
|
||||
// 当前草稿
|
||||
$draft = Request::input('draft', '');
|
||||
if (is_string($draft) && trim($draft) !== '') {
|
||||
$context['current_draft'] = Base::html2markdown($draft);
|
||||
}
|
||||
|
||||
// 引用消息
|
||||
$quote_id = intval(Request::input('quote_id'));
|
||||
if ($quote_id > 0) {
|
||||
$quote = WebSocketDialogMsg::whereDialogId($dialog_id)
|
||||
->whereId($quote_id)
|
||||
->with('user')
|
||||
->first();
|
||||
if ($quote) {
|
||||
$context['quote_summary'] = WebSocketDialogMsg::previewMsg($quote);
|
||||
$context['quote_user'] = $quote->user->nickname ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
// 成员列表
|
||||
$members = WebSocketDialogUser::whereDialogId($dialog_id)
|
||||
->join('users', 'users.userid', '=', 'web_socket_dialog_users.userid')
|
||||
->orderBy('web_socket_dialog_users.id')
|
||||
->limit(10)
|
||||
->pluck('users.nickname')
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
if (!empty($members)) {
|
||||
$context['members'] = $members;
|
||||
}
|
||||
|
||||
// 最近消息
|
||||
$recentMessagesQuery = WebSocketDialogMsg::whereDialogId($dialog_id)
|
||||
->orderByDesc('id')
|
||||
->with('user');
|
||||
|
||||
$recentMessages = (clone $recentMessagesQuery)->take(15)->get();
|
||||
if ($recentMessages->isNotEmpty()) {
|
||||
$formatRecentMessages = function ($messages) {
|
||||
return $messages->reverse()->map(function ($msg) {
|
||||
return [
|
||||
'sender' => $msg->user->nickname ?? ('用户' . $msg->userid),
|
||||
'summary' => $msg->extractMessageContent(500),
|
||||
];
|
||||
})->filter(function ($item) {
|
||||
return !empty($item['summary']);
|
||||
})->values();
|
||||
};
|
||||
|
||||
$formattedRecentMessages = $formatRecentMessages($recentMessages);
|
||||
$summaryLength = $formattedRecentMessages->sum(function ($item) {
|
||||
return mb_strlen($item['summary']);
|
||||
});
|
||||
|
||||
if ($summaryLength < 500 && $recentMessages->count() === 15) {
|
||||
$lastMessageId = optional($recentMessages->last())->id;
|
||||
$additionalMessages = collect();
|
||||
if ($lastMessageId) {
|
||||
$additionalMessages = (clone $recentMessagesQuery)
|
||||
->where('id', '<', $lastMessageId)
|
||||
->take(10)
|
||||
->get();
|
||||
}
|
||||
if ($additionalMessages->isNotEmpty()) {
|
||||
$recentMessages = $recentMessages->concat($additionalMessages);
|
||||
$formattedRecentMessages = $formatRecentMessages($recentMessages);
|
||||
}
|
||||
}
|
||||
|
||||
if ($formattedRecentMessages->isNotEmpty()) {
|
||||
$context['recent_messages'] = $formattedRecentMessages->all();
|
||||
}
|
||||
}
|
||||
|
||||
// 生成消息
|
||||
$systemPrompt = AI::messageSystemPrompt();
|
||||
$contextPrompt = AI::buildMessageContextPrompt($context);
|
||||
if ($content) {
|
||||
$contextPrompt .= "\n\n请根据以上信息,结合提示词生成一条待发送的消息:";
|
||||
}
|
||||
|
||||
return Base::retSuccess('success', [
|
||||
'system_prompt' => $systemPrompt,
|
||||
'context_prompt' => $contextPrompt,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/assistant/auth 生成授权码
|
||||
*
|
||||
|
||||
@ -26,7 +26,7 @@
|
||||
<template v-if="response.status === 'error'">
|
||||
<span class="ai-assistant-output-error">{{ response.error || $L('发送失败') }}</span>
|
||||
</template>
|
||||
<template v-else-if="response.text">
|
||||
<template v-else-if="response.rawOutput">
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
@ -44,9 +44,9 @@
|
||||
</div>
|
||||
<div v-if="response.prompt" class="ai-assistant-output-question">{{ response.prompt }}</div>
|
||||
<DialogMarkdown
|
||||
v-if="response.text"
|
||||
v-if="response.rawOutput"
|
||||
class="ai-assistant-output-markdown no-dark-content"
|
||||
:text="response.text"/>
|
||||
:text="response.displayOutput || response.rawOutput"/>
|
||||
<div v-else class="ai-assistant-output-placeholder">
|
||||
{{ response.status === 'error' ? (response.error || $L('发送失败')) : $L('等待 AI 回复...') }}
|
||||
</div>
|
||||
@ -95,7 +95,7 @@
|
||||
<script>
|
||||
import emitter from "../store/events";
|
||||
import {SSEClient} from "../utils";
|
||||
import {AIBotList, AIModelNames} from "../utils/ai";
|
||||
import {AIBotMap, AIModelNames} from "../utils/ai";
|
||||
import DialogMarkdown from "../pages/manage/components/DialogMarkdown.vue";
|
||||
|
||||
export default {
|
||||
@ -132,8 +132,11 @@ export default {
|
||||
inputRows: this.defaultInputRows,
|
||||
inputAutosize: this.defaultInputAutosize,
|
||||
inputMaxlength: this.defaultInputMaxlength,
|
||||
inputOnOk: null,
|
||||
inputOnBeforeSend: null,
|
||||
|
||||
// 回调钩子
|
||||
applyHook: null,
|
||||
beforeSendHook: null,
|
||||
renderHook: null,
|
||||
|
||||
// 模型选择
|
||||
inputModel: '',
|
||||
@ -147,7 +150,7 @@ export default {
|
||||
responses: [],
|
||||
responseSeed: 1,
|
||||
maxResponses: 5,
|
||||
activeStreams: [],
|
||||
activeSSEClients: [],
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
@ -156,7 +159,7 @@ export default {
|
||||
},
|
||||
beforeDestroy() {
|
||||
emitter.off('openAIAssistant', this.onOpenAIAssistant);
|
||||
this.clearActiveStreams();
|
||||
this.clearActiveSSEClients();
|
||||
},
|
||||
computed: {
|
||||
selectedModelOption({modelMap, inputModel}) {
|
||||
@ -182,12 +185,13 @@ export default {
|
||||
this.inputRows = params.rows || this.defaultInputRows;
|
||||
this.inputAutosize = params.autosize || this.defaultInputAutosize;
|
||||
this.inputMaxlength = params.maxlength || this.defaultInputMaxlength;
|
||||
this.inputOnOk = params.onOk || null;
|
||||
this.inputOnBeforeSend = params.onBeforeSend || null;
|
||||
this.applyHook = params.onApply || null;
|
||||
this.beforeSendHook = params.onBeforeSend || null;
|
||||
this.renderHook = params.onRender || null;
|
||||
}
|
||||
this.responses = [];
|
||||
this.showModal = true;
|
||||
this.clearActiveStreams();
|
||||
this.clearActiveSSEClients();
|
||||
},
|
||||
|
||||
/**
|
||||
@ -243,10 +247,6 @@ export default {
|
||||
normalizeModelOptions(data) {
|
||||
const groups = [];
|
||||
const map = {};
|
||||
const labelMap = AIBotList.reduce((acc, bot) => {
|
||||
acc[bot.value] = bot.label;
|
||||
return acc;
|
||||
}, {});
|
||||
if ($A.isJson(data)) {
|
||||
Object.keys(data).forEach(key => {
|
||||
const match = key.match(/^(.*?)_models$/);
|
||||
@ -260,7 +260,7 @@ export default {
|
||||
return;
|
||||
}
|
||||
const defaultModel = data[`${type}_model`] || '';
|
||||
const label = labelMap[type] || type;
|
||||
const label = AIBotMap[type] || type;
|
||||
const options = list.slice(0, 5);
|
||||
if (defaultModel) {
|
||||
const defaultOption = list.find(option => option.value === defaultModel);
|
||||
@ -285,7 +285,7 @@ export default {
|
||||
groups.push(group);
|
||||
});
|
||||
}
|
||||
const order = AIBotList.map(bot => bot.value);
|
||||
const order = Object.keys(AIBotMap);
|
||||
groups.sort((a, b) => {
|
||||
const indexA = order.indexOf(a.type);
|
||||
const indexB = order.indexOf(b.type);
|
||||
@ -350,12 +350,8 @@ export default {
|
||||
this.loadIng++;
|
||||
let responseEntry = null;
|
||||
try {
|
||||
const preparedPayload = await this.buildPayloadData({
|
||||
prompt: rawValue,
|
||||
model_type: modelOption.type,
|
||||
model_name: modelOption.value,
|
||||
}) || {};
|
||||
const context = this.buildContextMessages(preparedPayload);
|
||||
const baseContext = this.collectBaseContext(rawValue);
|
||||
const context = await this.buildPayloadData(baseContext);
|
||||
|
||||
responseEntry = this.createResponseEntry({
|
||||
modelOption,
|
||||
@ -385,32 +381,32 @@ export default {
|
||||
/**
|
||||
* 构建最终发送的数据
|
||||
*/
|
||||
async buildPayloadData(data) {
|
||||
if (typeof this.inputOnBeforeSend !== 'function') {
|
||||
return data;
|
||||
async buildPayloadData(context) {
|
||||
const baseContext = this.normalizeContextEntries(context);
|
||||
if (typeof this.beforeSendHook !== 'function') {
|
||||
return baseContext;
|
||||
}
|
||||
try {
|
||||
const result = this.inputOnBeforeSend(data);
|
||||
if (result && typeof result.then === 'function') {
|
||||
const resolved = await result;
|
||||
if ($A.isJson(resolved)) {
|
||||
return resolved;
|
||||
}
|
||||
} else if ($A.isJson(result)) {
|
||||
return result;
|
||||
const clonedContext = baseContext.map(entry => entry.slice());
|
||||
const result = this.beforeSendHook(clonedContext);
|
||||
const resolved = result && typeof result.then === 'function'
|
||||
? await result
|
||||
: result;
|
||||
const prepared = this.normalizeContextEntries(resolved);
|
||||
if (prepared.length) {
|
||||
return prepared;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[AIAssistant] onBeforeSend error:', e);
|
||||
}
|
||||
return data;
|
||||
return baseContext;
|
||||
},
|
||||
|
||||
/**
|
||||
* 组装上下文
|
||||
* 汇总当前会话的基础上下文
|
||||
*/
|
||||
buildContextMessages({prompt, system_prompt, context_prompt}) {
|
||||
const context = [];
|
||||
const pushContext = (role, value) => {
|
||||
collectBaseContext(prompt) {
|
||||
const pushEntry = (context, role, value) => {
|
||||
if (typeof value === 'undefined' || value === null) {
|
||||
return;
|
||||
}
|
||||
@ -418,33 +414,58 @@ export default {
|
||||
if (!content) {
|
||||
return;
|
||||
}
|
||||
const lastEntry = context[context.length - 1];
|
||||
if (lastEntry && lastEntry[0] === role) {
|
||||
lastEntry[1] = lastEntry[1] ? `${lastEntry[1]}\n${content}` : content;
|
||||
return;
|
||||
}
|
||||
context.push([role, content]);
|
||||
};
|
||||
if (system_prompt) {
|
||||
pushContext('system', String(system_prompt));
|
||||
}
|
||||
if (context_prompt) {
|
||||
pushContext('human', String(context_prompt));
|
||||
}
|
||||
const context = [];
|
||||
this.responses.forEach(item => {
|
||||
if (item.prompt) {
|
||||
pushContext('human', item.prompt);
|
||||
pushEntry(context, 'human', item.prompt);
|
||||
}
|
||||
if (item.text) {
|
||||
pushContext('assistant', item.text);
|
||||
if (item.rawOutput) {
|
||||
pushEntry(context, 'assistant', item.rawOutput);
|
||||
}
|
||||
});
|
||||
if (prompt && prompt.trim()) {
|
||||
pushContext('human', prompt.trim());
|
||||
if (prompt && String(prompt).trim()) {
|
||||
pushEntry(context, 'human', prompt);
|
||||
}
|
||||
return context;
|
||||
},
|
||||
|
||||
/**
|
||||
* 归一化上下文结构
|
||||
*/
|
||||
normalizeContextEntries(context) {
|
||||
if (!Array.isArray(context)) {
|
||||
return [];
|
||||
}
|
||||
const normalized = [];
|
||||
context.forEach(entry => {
|
||||
if (!Array.isArray(entry) || entry.length < 2) {
|
||||
return;
|
||||
}
|
||||
const [role, value] = entry;
|
||||
const roleName = typeof role === 'string' ? role.trim() : '';
|
||||
const content = typeof value === 'string'
|
||||
? value.trim()
|
||||
: String(value ?? '').trim();
|
||||
if (!roleName || !content) {
|
||||
return;
|
||||
}
|
||||
const last = normalized[normalized.length - 1];
|
||||
const canMergeWithLast = last
|
||||
&& last[0] === roleName
|
||||
&& typeof last[1] === 'string'
|
||||
&& last[1].slice(-4) === '++++';
|
||||
if (canMergeWithLast) {
|
||||
const previousContent = last[1].slice(0, -4);
|
||||
last[1] = previousContent ? `${previousContent}\n${content}` : content;
|
||||
return;
|
||||
}
|
||||
normalized.push([roleName, content]);
|
||||
});
|
||||
return normalized;
|
||||
},
|
||||
|
||||
/**
|
||||
* 请求 stream_key
|
||||
*/
|
||||
@ -474,7 +495,7 @@ export default {
|
||||
throw new Error('获取 stream_key 失败');
|
||||
}
|
||||
const sse = new SSEClient($A.mainUrl(`ai/invoke/stream/${streamKey}`));
|
||||
this.registerStream(sse);
|
||||
this.registerSSEClient(sse);
|
||||
sse.subscribe(['append', 'replace', 'done'], (type, event) => {
|
||||
switch (type) {
|
||||
case 'append':
|
||||
@ -482,10 +503,10 @@ export default {
|
||||
this.handleStreamChunk(responseEntry, type, event);
|
||||
break;
|
||||
case 'done':
|
||||
if (responseEntry && responseEntry.status !== 'error' && responseEntry.text) {
|
||||
if (responseEntry && responseEntry.status !== 'error' && responseEntry.rawOutput) {
|
||||
responseEntry.status = 'completed';
|
||||
}
|
||||
this.releaseStream(sse);
|
||||
this.releaseSSEClient(sse);
|
||||
break;
|
||||
}
|
||||
});
|
||||
@ -502,10 +523,11 @@ export default {
|
||||
const payload = this.parseStreamPayload(event);
|
||||
const chunk = this.resolveStreamContent(payload);
|
||||
if (type === 'replace') {
|
||||
responseEntry.text = chunk;
|
||||
responseEntry.rawOutput = chunk;
|
||||
} else {
|
||||
responseEntry.text += chunk;
|
||||
responseEntry.rawOutput += chunk;
|
||||
}
|
||||
this.updateResponseDisplayOutput(responseEntry);
|
||||
responseEntry.status = 'streaming';
|
||||
this.scrollResponsesToBottom();
|
||||
},
|
||||
@ -540,29 +562,38 @@ export default {
|
||||
return '';
|
||||
},
|
||||
|
||||
registerStream(sse) {
|
||||
/**
|
||||
* 将 SSE 客户端加入活跃列表,方便后续清理
|
||||
*/
|
||||
registerSSEClient(sse) {
|
||||
if (!sse) {
|
||||
return;
|
||||
}
|
||||
this.activeStreams.push(sse);
|
||||
this.activeSSEClients.push(sse);
|
||||
},
|
||||
|
||||
releaseStream(sse) {
|
||||
const index = this.activeStreams.indexOf(sse);
|
||||
/**
|
||||
* 从活跃列表移除 SSE 客户端并执行注销
|
||||
*/
|
||||
releaseSSEClient(sse) {
|
||||
const index = this.activeSSEClients.indexOf(sse);
|
||||
if (index > -1) {
|
||||
this.activeStreams.splice(index, 1);
|
||||
this.activeSSEClients.splice(index, 1);
|
||||
}
|
||||
sse.unsunscribe();
|
||||
},
|
||||
|
||||
clearActiveStreams() {
|
||||
this.activeStreams.forEach(sse => {
|
||||
/**
|
||||
* 关闭所有活跃的 SSE 连接
|
||||
*/
|
||||
clearActiveSSEClients() {
|
||||
this.activeSSEClients.forEach(sse => {
|
||||
try {
|
||||
sse.unsunscribe();
|
||||
} catch (e) {
|
||||
}
|
||||
});
|
||||
this.activeStreams = [];
|
||||
this.activeSSEClients = [];
|
||||
},
|
||||
|
||||
/**
|
||||
@ -576,7 +607,8 @@ export default {
|
||||
modelLabel: modelOption.label,
|
||||
type: modelOption.type,
|
||||
prompt: prompt.trim(),
|
||||
text: '',
|
||||
rawOutput: '',
|
||||
displayOutput: '',
|
||||
status: 'waiting',
|
||||
error: '',
|
||||
applyLoading: false,
|
||||
@ -603,23 +635,18 @@ export default {
|
||||
if (!response || response.applyLoading) {
|
||||
return;
|
||||
}
|
||||
if (!response.text) {
|
||||
if (!response.rawOutput) {
|
||||
$A.messageWarning('暂无可用内容');
|
||||
return;
|
||||
}
|
||||
if (typeof this.inputOnOk !== 'function') {
|
||||
if (typeof this.applyHook !== 'function') {
|
||||
this.closeAssistant();
|
||||
return;
|
||||
}
|
||||
response.applyLoading = true;
|
||||
const payload = {
|
||||
model: response.model,
|
||||
type: response.type,
|
||||
content: response.prompt,
|
||||
aiContent: response.text,
|
||||
};
|
||||
const payload = this.buildResponsePayload(response);
|
||||
try {
|
||||
const result = this.inputOnOk(payload);
|
||||
const result = this.applyHook(payload);
|
||||
if (result && typeof result.then === 'function') {
|
||||
result.then(() => {
|
||||
this.closeAssistant();
|
||||
@ -638,6 +665,52 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 构造发送给外部回调的统一数据结构
|
||||
*/
|
||||
buildResponsePayload(response) {
|
||||
if (!response) {
|
||||
return {
|
||||
model: '',
|
||||
type: '',
|
||||
prompt: '',
|
||||
rawOutput: '',
|
||||
};
|
||||
}
|
||||
return {
|
||||
model: response.model,
|
||||
type: response.type,
|
||||
prompt: response.prompt,
|
||||
rawOutput: response.rawOutput,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* 根据 onRender 回调生成展示文本
|
||||
*/
|
||||
updateResponseDisplayOutput(response) {
|
||||
if (!response) {
|
||||
return;
|
||||
}
|
||||
if (typeof this.renderHook !== 'function') {
|
||||
response.displayOutput = response.rawOutput;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const payload = this.buildResponsePayload(response);
|
||||
const result = this.renderHook(payload);
|
||||
if (result && typeof result.then === 'function') {
|
||||
console.warn('[AIAssistant] onRender should be synchronous');
|
||||
response.displayOutput = response.rawOutput;
|
||||
return;
|
||||
}
|
||||
response.displayOutput = typeof result === 'string' ? result : response.rawOutput;
|
||||
} catch (e) {
|
||||
console.warn('[AIAssistant] onRender error:', e);
|
||||
response.displayOutput = response.rawOutput;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 关闭弹窗
|
||||
*/
|
||||
@ -648,7 +721,7 @@ export default {
|
||||
this.closing = true;
|
||||
this.showModal = false;
|
||||
this.responses = [];
|
||||
this.clearActiveStreams();
|
||||
this.clearActiveSSEClients();
|
||||
setTimeout(() => {
|
||||
this.closing = false;
|
||||
}, 300);
|
||||
|
||||
30
resources/assets/js/functions/common.js
vendored
30
resources/assets/js/functions/common.js
vendored
@ -515,18 +515,30 @@ const timezone = require("dayjs/plugin/timezone");
|
||||
* 获取对象
|
||||
* @param obj
|
||||
* @param keys
|
||||
* @param defaultValue
|
||||
* @returns {string|*}
|
||||
*/
|
||||
getObject(obj, keys) {
|
||||
let object = obj;
|
||||
if (this.count(obj) === 0 || this.count(keys) === 0) {
|
||||
return "";
|
||||
getObject(obj, keys, defaultValue = undefined) {
|
||||
let keyArray;
|
||||
if (typeof keys === 'string') {
|
||||
keyArray = keys.replace(/,/g, "|").replace(/\./g, "|").split("|");
|
||||
} else if (Array.isArray(keys)) {
|
||||
keyArray = keys;
|
||||
} else {
|
||||
return defaultValue;
|
||||
}
|
||||
let array = keys.replace(/,/g, "|").replace(/\./g, "|").split("|");
|
||||
array.some(key => {
|
||||
object = typeof object[key] === "undefined" ? "" : object[key];
|
||||
})
|
||||
return object;
|
||||
let result = obj;
|
||||
for (let i = 0; i < keyArray.length; i++) {
|
||||
let key = keyArray[i];
|
||||
if (result == null) {
|
||||
return defaultValue;
|
||||
}
|
||||
if (typeof key === 'string' && /^\d+$/.test(key)) {
|
||||
key = parseInt(key, 10);
|
||||
}
|
||||
result = result[key];
|
||||
}
|
||||
return result === undefined ? defaultValue : result;
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@ -457,6 +457,7 @@ import SearchBox from "../components/SearchBox.vue";
|
||||
import AIAssistant from "../components/AIAssistant.vue";
|
||||
import transformEmojiToHtml from "../utils/emoji";
|
||||
import {languageName} from "../language";
|
||||
import {PROJECT_AI_SYSTEM_PROMPT} from "../utils/ai";
|
||||
import Draggable from 'vuedraggable'
|
||||
|
||||
export default {
|
||||
@ -1066,78 +1067,201 @@ export default {
|
||||
},
|
||||
|
||||
onProjectAI() {
|
||||
let canceled = false;
|
||||
$A.modalInput({
|
||||
title: 'AI 生成',
|
||||
placeholder: '请简要描述项目目标、范围或关键里程碑,AI 将生成名称和任务列表',
|
||||
inputProps: {
|
||||
type: 'textarea',
|
||||
rows: 2,
|
||||
autosize: {minRows: 2, maxRows: 6},
|
||||
maxlength: 500,
|
||||
},
|
||||
onCancel: () => {
|
||||
canceled = true;
|
||||
},
|
||||
onOk: (value) => {
|
||||
if (!value) {
|
||||
return '请输入项目需求';
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
if (canceled) {
|
||||
reject();
|
||||
return;
|
||||
}
|
||||
const parseColumns = (cols) => {
|
||||
if (Array.isArray(cols)) {
|
||||
return cols;
|
||||
}
|
||||
if (typeof cols === 'string') {
|
||||
return cols.split(/[\n\r,,;;|]/).map(item => item.trim()).filter(item => item);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
const templateExamples = this.columns
|
||||
.filter((item, index) => index > 0 && item && item.columns && String(item.columns).trim() !== '')
|
||||
.slice(0, 6)
|
||||
.map(item => ({
|
||||
name: item.name,
|
||||
columns: parseColumns(item.columns)
|
||||
}));
|
||||
emitter.emit('openAIAssistant', {
|
||||
placeholder: this.$L('请简要描述项目目标、范围或关键里程碑,AI 将生成名称和任务列表'),
|
||||
onBeforeSend: this.handleProjectAIBeforeSend,
|
||||
onRender: this.handleProjectAIRender,
|
||||
onApply: this.handleProjectAIApply,
|
||||
});
|
||||
},
|
||||
|
||||
this.$store.dispatch("call", {
|
||||
url: 'project/ai/generate',
|
||||
data: {
|
||||
content: value,
|
||||
current_name: this.addData.name || '',
|
||||
current_columns: this.addData.columns || '',
|
||||
template_examples: templateExamples,
|
||||
},
|
||||
timeout: 45 * 1000,
|
||||
}).then(({data}) => {
|
||||
if (canceled) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const columns = Array.isArray(data.columns) ? data.columns : parseColumns(data.columns);
|
||||
this.$set(this.addData, 'name', data.name || '');
|
||||
this.$set(this.addData, 'columns', columns.length > 0 ? columns.join(',') : '');
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.projectName) {
|
||||
this.$refs.projectName.focus();
|
||||
}
|
||||
});
|
||||
resolve();
|
||||
}).catch(({msg}) => {
|
||||
if (canceled) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
reject(msg);
|
||||
});
|
||||
});
|
||||
buildProjectAIContextData() {
|
||||
const prompts = [];
|
||||
const currentName = (this.addData.name || '').trim();
|
||||
const currentColumns = this.normalizeAIColumns(this.addData.columns);
|
||||
|
||||
if (currentName || currentColumns.length > 0) {
|
||||
prompts.push('## 当前项目草稿');
|
||||
if (currentName) {
|
||||
prompts.push(`已有名称:${currentName}`);
|
||||
}
|
||||
})
|
||||
if (currentColumns.length > 0) {
|
||||
prompts.push(`现有任务列表:${currentColumns.join('、')}`);
|
||||
}
|
||||
prompts.push('请在此基础上进行优化和补充。');
|
||||
}
|
||||
|
||||
const rawTemplates = Array.isArray(this.columns) ? this.columns : [];
|
||||
const templateExamples = rawTemplates
|
||||
.filter((item, index) => index > 0 && item)
|
||||
.map(item => {
|
||||
const columns = this.normalizeAIColumns(item.columns);
|
||||
if (columns.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
name: (item.name || '').trim(),
|
||||
columns,
|
||||
};
|
||||
})
|
||||
.filter(Boolean)
|
||||
.slice(0, 6);
|
||||
|
||||
if (templateExamples.length > 0) {
|
||||
prompts.push('## 常用模板示例');
|
||||
templateExamples.forEach(example => {
|
||||
const namePrefix = example.name ? `${example.name}:` : '';
|
||||
prompts.push(`- ${namePrefix}${example.columns.join('、')}`);
|
||||
});
|
||||
prompts.push('可以借鉴以上结构,但要结合用户需求生成更贴合的方案。');
|
||||
}
|
||||
|
||||
return prompts.join('\n').trim();
|
||||
},
|
||||
|
||||
handleProjectAIBeforeSend(context = []) {
|
||||
const prepared = [
|
||||
['system', PROJECT_AI_SYSTEM_PROMPT]
|
||||
];
|
||||
const contextPrompt = this.buildProjectAIContextData();
|
||||
if (contextPrompt) {
|
||||
let assistantContext = [
|
||||
'以下是可用的上下文,请据此生成项目:',
|
||||
contextPrompt,
|
||||
].join('\n');
|
||||
if ($A.getObject(context, [0,0]) === 'human') {
|
||||
assistantContext += "\n----\n请根据以上信息,结合以下用户输入的内容生成项目名称和任务列表:++++";
|
||||
}
|
||||
prepared.push(['human', assistantContext]);
|
||||
}
|
||||
if (context.length > 0) {
|
||||
prepared.push(...context);
|
||||
}
|
||||
return prepared;
|
||||
},
|
||||
|
||||
handleProjectAIApply({rawOutput}) {
|
||||
if (!rawOutput) {
|
||||
$A.messageWarning('AI 未生成内容');
|
||||
return;
|
||||
}
|
||||
const parsed = this.parseProjectAIContent(rawOutput);
|
||||
if (!parsed) {
|
||||
$A.modalError('AI 内容解析失败,请重试');
|
||||
return;
|
||||
}
|
||||
if (parsed.name) {
|
||||
this.$set(this.addData, 'name', parsed.name);
|
||||
}
|
||||
if (parsed.columns.length > 0) {
|
||||
this.$set(this.addData, 'columns', parsed.columns.join(','));
|
||||
}
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.projectName) {
|
||||
this.$refs.projectName.focus();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
normalizeAIColumns(value) {
|
||||
if (!value) {
|
||||
return [];
|
||||
}
|
||||
const normalize = (item) => {
|
||||
if (!item) {
|
||||
return '';
|
||||
}
|
||||
if (typeof item === 'string') {
|
||||
return item.trim();
|
||||
}
|
||||
if (typeof item === 'object') {
|
||||
const text = item.name || item.title || item.label || item.value || '';
|
||||
return typeof text === 'string' ? text.trim() : '';
|
||||
}
|
||||
return String(item).trim();
|
||||
};
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(normalize).filter(Boolean);
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value.split(/[\n\r,,;;|]/).map(item => item.trim()).filter(Boolean);
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
if (Array.isArray(value.columns)) {
|
||||
return this.normalizeAIColumns(value.columns);
|
||||
}
|
||||
if (typeof value.columns === 'string') {
|
||||
return this.normalizeAIColumns(value.columns);
|
||||
}
|
||||
}
|
||||
return [];
|
||||
},
|
||||
|
||||
normalizeAIJsonContent(content) {
|
||||
if (!content) {
|
||||
return null;
|
||||
}
|
||||
const raw = String(content).trim();
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const candidates = [raw];
|
||||
const block = raw.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
||||
if (block && block[1]) {
|
||||
candidates.push(block[1].trim());
|
||||
}
|
||||
const start = raw.indexOf('{');
|
||||
const end = raw.lastIndexOf('}');
|
||||
if (start !== -1 && end !== -1 && end > start) {
|
||||
candidates.push(raw.slice(start, end + 1));
|
||||
}
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(candidate);
|
||||
} catch (e) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
parseProjectAIContent(content) {
|
||||
const payload = this.normalizeAIJsonContent(content);
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return null;
|
||||
}
|
||||
const nameSource = [payload.name, payload.title, payload.project_name].find(item => typeof item === 'string' && item.trim());
|
||||
const columnsSource = payload.columns || payload.lists || payload.stages || payload.columns_list;
|
||||
const columns = this.normalizeAIColumns(columnsSource);
|
||||
if (!nameSource && columns.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
name: nameSource ? nameSource.trim() : '',
|
||||
columns,
|
||||
};
|
||||
},
|
||||
|
||||
handleProjectAIRender({rawOutput}) {
|
||||
if (!rawOutput) {
|
||||
return '';
|
||||
}
|
||||
const parsed = this.parseProjectAIContent(rawOutput);
|
||||
if (!parsed) {
|
||||
return rawOutput;
|
||||
}
|
||||
const blocks = [];
|
||||
if (parsed.name) {
|
||||
blocks.push(`## ${parsed.name}`);
|
||||
}
|
||||
if (parsed.columns.length > 0) {
|
||||
const lines = parsed.columns.map((column, index) => `${index + 1}. ${column}`);
|
||||
blocks.push(lines.join('\n'));
|
||||
}
|
||||
return blocks.join('\n\n').trim() || rawOutput;
|
||||
},
|
||||
|
||||
onAddProject() {
|
||||
|
||||
@ -343,6 +343,7 @@ import longpress from "../../../../directives/longpress";
|
||||
import {inputLoadAdd, inputLoadIsLast, inputLoadRemove} from "./one";
|
||||
import {languageList, languageName} from "../../../../language";
|
||||
import {isMarkdownFormat, MarkdownConver} from "../../../../utils/markdown";
|
||||
import {MESSAGE_AI_SYSTEM_PROMPT} from "../../../../utils/ai";
|
||||
import emitter from "../../../../store/events";
|
||||
import historyMixin from "./history";
|
||||
|
||||
@ -1909,42 +1910,208 @@ export default {
|
||||
}
|
||||
emitter.emit('openAIAssistant', {
|
||||
placeholder: this.$L('请简要描述消息的主题、语气或要点,AI 将生成完整消息'),
|
||||
onBeforeSend: async (sendData) => {
|
||||
if (!sendData) {
|
||||
return sendData;
|
||||
}
|
||||
try {
|
||||
const {data: promptData} = await this.$store.dispatch('call', {
|
||||
url: 'assistant/dialog/prompt',
|
||||
data: {
|
||||
dialog_id: this.dialogId,
|
||||
content: sendData.prompt,
|
||||
draft: this.value || '',
|
||||
quote_id: this.quoteData?.id || 0,
|
||||
},
|
||||
});
|
||||
if ($A.isJson(promptData)) {
|
||||
Object.assign(sendData, promptData);
|
||||
}
|
||||
return sendData;
|
||||
} catch (error) {
|
||||
const msg = error?.msg || 'AI 提示生成失败';
|
||||
$A.modalError(msg);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
onOk: ({aiContent}) => {
|
||||
if (!aiContent) {
|
||||
$A.messageWarning('AI 未生成内容');
|
||||
return;
|
||||
}
|
||||
const html = MarkdownConver(aiContent);
|
||||
this.$emit('input', html);
|
||||
this.$nextTick(() => this.focus());
|
||||
},
|
||||
onBeforeSend: this.handleMessageAIBeforeSend,
|
||||
onRender: this.handleMessageAIRender,
|
||||
onApply: this.handleMessageAIApply,
|
||||
});
|
||||
},
|
||||
|
||||
handleMessageAIBeforeSend(context = []) {
|
||||
const prepared = [
|
||||
['system', MESSAGE_AI_SYSTEM_PROMPT]
|
||||
];
|
||||
let assistantContext = this.buildMessageAssistantContext();
|
||||
if (assistantContext) {
|
||||
if ($A.getObject(context, [0,0]) === 'human') {
|
||||
assistantContext += "\n----\n请根据以上信息,结合以下用户输入的内容生成消息:++++";
|
||||
}
|
||||
prepared.push(['human', assistantContext]);
|
||||
}
|
||||
if (context.length > 0) {
|
||||
prepared.push(...context);
|
||||
}
|
||||
return prepared;
|
||||
},
|
||||
|
||||
handleMessageAIRender({rawOutput}) {
|
||||
return rawOutput || '';
|
||||
},
|
||||
|
||||
handleMessageAIApply({rawOutput}) {
|
||||
if (!rawOutput) {
|
||||
$A.messageWarning('AI 未生成内容');
|
||||
return;
|
||||
}
|
||||
const html = MarkdownConver(rawOutput);
|
||||
this.$emit('input', html);
|
||||
this.$nextTick(() => this.focus());
|
||||
},
|
||||
|
||||
buildMessageAssistantContext() {
|
||||
const sections = [];
|
||||
const infoLines = [];
|
||||
if (this.dialogData?.name) {
|
||||
infoLines.push(`名称:${this.cutText(this.dialogData.name, 60)}`);
|
||||
}
|
||||
if (this.dialogData?.type) {
|
||||
const typeMap = {group: this.$L('群聊'), user: this.$L('单聊')};
|
||||
infoLines.push(`类型:${typeMap[this.dialogData.type] || this.dialogData.type}`);
|
||||
}
|
||||
if (this.dialogData?.group_type) {
|
||||
infoLines.push(`分类:${this.cutText(this.dialogData.group_type, 60)}`);
|
||||
}
|
||||
if (infoLines.length) {
|
||||
sections.push('## 会话信息');
|
||||
sections.push(...infoLines);
|
||||
}
|
||||
|
||||
const memberNames = this.collectDialogMemberNames();
|
||||
if (memberNames.length) {
|
||||
sections.push('## 会话成员');
|
||||
sections.push(memberNames.join(','));
|
||||
}
|
||||
|
||||
const recentMessages = this.collectRecentMessages();
|
||||
if (recentMessages.length) {
|
||||
sections.push('## 最近消息');
|
||||
recentMessages.forEach(({sender, summary}) => {
|
||||
if (summary) {
|
||||
const name = sender || this.$L('成员');
|
||||
sections.push(`- ${name}:${summary}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (this.quoteData) {
|
||||
const quoteSummary = this.getMessageSummaryText(this.quoteData);
|
||||
if (quoteSummary) {
|
||||
sections.push('## 引用消息');
|
||||
const quoteUser = this.resolveUserNickname(this.quoteData.userid);
|
||||
sections.push(quoteUser ? `${quoteUser}:${quoteSummary}` : quoteSummary);
|
||||
}
|
||||
}
|
||||
|
||||
const draftText = this.extractPlainText(this.value);
|
||||
if (draftText) {
|
||||
sections.push('## 当前草稿');
|
||||
sections.push(this.cutText(draftText, 200));
|
||||
}
|
||||
|
||||
return sections.join('\n');
|
||||
},
|
||||
|
||||
collectDialogMemberNames(limit = 10) {
|
||||
if (!this.dialogId) {
|
||||
return [];
|
||||
}
|
||||
const result = [];
|
||||
const seen = new Set();
|
||||
const pushName = (name) => {
|
||||
const clean = this.cutText((name || '').trim(), 30);
|
||||
if (!clean || seen.has(clean)) {
|
||||
return;
|
||||
}
|
||||
seen.add(clean);
|
||||
result.push(clean);
|
||||
};
|
||||
|
||||
if (this.dialogData?.dialog_user) {
|
||||
pushName(this.dialogData.dialog_user.nickname || this.dialogData.dialog_user.name);
|
||||
}
|
||||
|
||||
const messages = this.dialogMsgs.filter(item => item.dialog_id == this.dialogId);
|
||||
for (let i = messages.length - 1; i >= 0 && result.length < limit; i--) {
|
||||
pushName(this.resolveUserNickname(messages[i].userid));
|
||||
}
|
||||
|
||||
const currentUserId = this.$store?.state?.userInfo?.userid;
|
||||
if (currentUserId) {
|
||||
pushName(this.resolveUserNickname(currentUserId));
|
||||
}
|
||||
|
||||
return result.slice(0, limit);
|
||||
},
|
||||
|
||||
collectRecentMessages(limit = 15) {
|
||||
if (!this.dialogId) {
|
||||
return [];
|
||||
}
|
||||
const messages = this.dialogMsgs.filter(item => item.dialog_id == this.dialogId);
|
||||
if (messages.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const sorted = messages.slice().sort((a, b) => a.id - b.id);
|
||||
const result = [];
|
||||
for (let i = sorted.length - 1; i >= 0 && result.length < limit; i--) {
|
||||
const msg = sorted[i];
|
||||
const summary = this.getMessageSummaryText(msg);
|
||||
if (!summary) {
|
||||
continue;
|
||||
}
|
||||
result.unshift({
|
||||
sender: this.resolveUserNickname(msg.userid) || this.$L('成员'),
|
||||
summary,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
getMessageSummaryText(message) {
|
||||
if (!message) {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
const preview = $A.getMsgSimpleDesc(message);
|
||||
const plain = this.extractPlainText(preview || '');
|
||||
return this.cutText(plain, 160);
|
||||
} catch (error) {
|
||||
return '';
|
||||
}
|
||||
},
|
||||
|
||||
extractPlainText(content) {
|
||||
if (!content) {
|
||||
return '';
|
||||
}
|
||||
const value = typeof content === 'string' ? content : JSON.stringify(content);
|
||||
if (typeof window === 'undefined' || !window.document) {
|
||||
return value.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = value;
|
||||
return (div.textContent || div.innerText || '').replace(/\s+/g, ' ').trim();
|
||||
},
|
||||
|
||||
cutText(text, limit = 60) {
|
||||
const value = (text || '').trim();
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
const units = Array.from(value);
|
||||
if (units.length <= limit) {
|
||||
return value;
|
||||
}
|
||||
return units.slice(0, limit).join('') + '…';
|
||||
},
|
||||
|
||||
resolveUserNickname(userid) {
|
||||
if (!userid) {
|
||||
return '';
|
||||
}
|
||||
const currentUser = this.$store?.state?.userInfo;
|
||||
if (currentUser && currentUser.userid == userid) {
|
||||
return currentUser.nickname || currentUser.username || currentUser.name || '';
|
||||
}
|
||||
const cached = this.cacheUserBasic.find(user => user.userid == userid);
|
||||
if (cached) {
|
||||
return cached.nickname || cached.name || cached.username || '';
|
||||
}
|
||||
if (this.dialogData?.dialog_user && this.dialogData.dialog_user.userid == userid) {
|
||||
return this.dialogData.dialog_user.nickname || this.dialogData.dialog_user.name || '';
|
||||
}
|
||||
return '';
|
||||
},
|
||||
|
||||
onFullInput() {
|
||||
if (this.disabled) {
|
||||
return
|
||||
|
||||
@ -195,10 +195,13 @@
|
||||
|
||||
<script>
|
||||
import {mapState} from "vuex";
|
||||
import emitter from "../../../store/events";
|
||||
import UserSelect from "../../../components/UserSelect.vue";
|
||||
import TaskExistTips from "./TaskExistTips.vue";
|
||||
import TEditorTask from "../../../components/TEditorTask.vue";
|
||||
import nostyle from "../../../components/VMEditor/engine/nostyle";
|
||||
import {MarkdownConver} from "../../../utils/markdown";
|
||||
import {TASK_AI_SYSTEM_PROMPT} from "../../../utils/ai";
|
||||
|
||||
export default {
|
||||
name: "TaskAdd",
|
||||
@ -625,97 +628,261 @@ export default {
|
||||
},
|
||||
|
||||
onAI() {
|
||||
let canceled = false;
|
||||
$A.modalInput({
|
||||
title: 'AI 生成',
|
||||
placeholder: '请简要描述任务目标、背景或预期交付,AI 将生成标题、详细说明和子任务',
|
||||
inputProps: {
|
||||
type: 'textarea',
|
||||
rows: 2,
|
||||
autosize: { minRows: 2, maxRows: 6 },
|
||||
maxlength: 500,
|
||||
},
|
||||
onCancel: () => {
|
||||
canceled = true;
|
||||
},
|
||||
onOk: (value) => {
|
||||
if (!value) {
|
||||
return `请输入任务需求`
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
if (canceled) {
|
||||
reject();
|
||||
return;
|
||||
}
|
||||
// 获取当前任务模板信息
|
||||
const currentTemplate = this.templateActiveID ?
|
||||
this.taskTemplateList.find(item => item.id === this.templateActiveID) : null;
|
||||
|
||||
this.$store.dispatch("call", {
|
||||
url: 'project/task/ai_generate',
|
||||
data: {
|
||||
content: value,
|
||||
// 当前已有的标题和内容作为参考
|
||||
current_title: this.addData.name || '',
|
||||
current_content: this.addData.content || '',
|
||||
// 当前选中的任务模板信息
|
||||
template_name: currentTemplate ? currentTemplate.name : '',
|
||||
template_content: currentTemplate ? currentTemplate.content : '',
|
||||
// 其他上下文信息
|
||||
has_owner: this.addData.owner && this.addData.owner.length > 0,
|
||||
has_time_plan: this.addData.times && this.addData.times.length > 0,
|
||||
priority_level: this.addData.p_name || ''
|
||||
},
|
||||
timeout: 60 * 1000,
|
||||
}).then(({data}) => {
|
||||
if (canceled) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
this.addData.name = data.title;
|
||||
this.$refs.editorTaskRef.setContent(data.content, {format: 'raw'});
|
||||
if (Array.isArray(data.subtasks) && data.subtasks.length > 0) {
|
||||
const normalized = data.subtasks
|
||||
.map(item => {
|
||||
if (typeof item === 'string') {
|
||||
return item.trim();
|
||||
}
|
||||
if (item && typeof item === 'object') {
|
||||
const name = item.title || item.name || '';
|
||||
return typeof name === 'string' ? name.trim() : '';
|
||||
}
|
||||
return '';
|
||||
})
|
||||
.filter(item => item !== '');
|
||||
emitter.emit('openAIAssistant', {
|
||||
placeholder: this.$L('请简要描述任务目标、背景或预期交付,AI 将生成标题、详细说明和子任务'),
|
||||
onBeforeSend: this.handleTaskAIBeforeSend,
|
||||
onRender: this.handleTaskAIRender,
|
||||
onApply: this.handleTaskAIApply,
|
||||
});
|
||||
},
|
||||
|
||||
const unique = Array.from(new Set(normalized)).slice(0, 8);
|
||||
|
||||
if (unique.length > 0) {
|
||||
const mainOwner = Array.isArray(this.addData.owner) && this.addData.owner.length > 0
|
||||
? [this.addData.owner[0]]
|
||||
: (this.userId ? [this.userId] : []);
|
||||
|
||||
const subtasks = unique.map(name => ({
|
||||
name,
|
||||
owner: [...mainOwner],
|
||||
times: [],
|
||||
}));
|
||||
|
||||
this.$set(this.addData, 'subtasks', subtasks);
|
||||
this.advanced = true;
|
||||
}
|
||||
}
|
||||
resolve();
|
||||
}).catch(({msg}) => {
|
||||
if (canceled) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
reject(msg);
|
||||
});
|
||||
})
|
||||
buildTaskAIContextData() {
|
||||
const prompts = [];
|
||||
const plainText = (value, limit = 600) => {
|
||||
if (!value || typeof value !== 'string') {
|
||||
return '';
|
||||
}
|
||||
})
|
||||
return value
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/ /gi, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.slice(0, limit)
|
||||
.trim();
|
||||
};
|
||||
|
||||
const currentTitle = (this.addData.name || '').trim();
|
||||
const currentContent = plainText(this.addData.content, 600);
|
||||
if (currentTitle || currentContent) {
|
||||
prompts.push('## 当前任务信息');
|
||||
if (currentTitle) {
|
||||
prompts.push(`当前标题:${currentTitle}`);
|
||||
}
|
||||
if (currentContent) {
|
||||
prompts.push(`当前内容:${currentContent}`);
|
||||
}
|
||||
prompts.push('请在此基础上优化改进,而不是完全重写。');
|
||||
}
|
||||
|
||||
const currentTemplate = this.templateActiveID
|
||||
? this.taskTemplateList.find(item => item.id === this.templateActiveID)
|
||||
: null;
|
||||
if (currentTemplate) {
|
||||
const templateName = (currentTemplate.name || currentTemplate.title || '').trim();
|
||||
const templateContent = plainText(nostyle(currentTemplate.content, {sanitize: false}), 800);
|
||||
prompts.push('## 任务模板要求');
|
||||
if (templateName) {
|
||||
prompts.push(`模板名称:${templateName}`);
|
||||
}
|
||||
if (templateContent) {
|
||||
prompts.push(`模板内容结构:${templateContent}`);
|
||||
}
|
||||
prompts.push('请严格按照此模板的结构和格式要求生成内容。');
|
||||
}
|
||||
|
||||
const statusInfo = [];
|
||||
if (Array.isArray(this.addData.owner) && this.addData.owner.length > 0) {
|
||||
statusInfo.push('已设置负责人');
|
||||
}
|
||||
if (Array.isArray(this.addData.times) && this.addData.times.length > 0) {
|
||||
statusInfo.push('已设置计划时间');
|
||||
}
|
||||
const priorityName = (this.addData.p_name || '').trim();
|
||||
if (priorityName) {
|
||||
statusInfo.push(`优先级:${priorityName}`);
|
||||
}
|
||||
if (statusInfo.length > 0) {
|
||||
prompts.push('## 任务状态');
|
||||
prompts.push(statusInfo.join(','));
|
||||
prompts.push('请在任务描述中体现相应的要求和约束。');
|
||||
}
|
||||
|
||||
const projectInfo = this.cacheProjects.find(({id}) => id == this.addData.project_id);
|
||||
const columnInfo = this.cacheColumns.find(({id}) => id == this.addData.column_id);
|
||||
if ((projectInfo && projectInfo.name) || (columnInfo && columnInfo.name)) {
|
||||
prompts.push('## 所属项目');
|
||||
if (projectInfo && projectInfo.name) {
|
||||
prompts.push(`项目:${projectInfo.name}`);
|
||||
}
|
||||
if (columnInfo && columnInfo.name) {
|
||||
prompts.push(`任务列表:${columnInfo.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
const subtasks = (this.addData.subtasks || [])
|
||||
.map(item => (item && item.name ? item.name.trim() : ''))
|
||||
.filter(Boolean)
|
||||
.slice(0, 8);
|
||||
if (subtasks.length > 0) {
|
||||
prompts.push('## 当前子任务');
|
||||
subtasks.forEach((name, index) => {
|
||||
prompts.push(`${index + 1}. ${name}`);
|
||||
});
|
||||
}
|
||||
|
||||
return prompts.join('\n').trim();
|
||||
},
|
||||
|
||||
handleTaskAIBeforeSend(context = []) {
|
||||
const prepared = [
|
||||
['system', TASK_AI_SYSTEM_PROMPT]
|
||||
];
|
||||
const contextPrompt = this.buildTaskAIContextData();
|
||||
if (contextPrompt) {
|
||||
let assistantContext = [
|
||||
'以下是已有的上下文信息,可辅助你理解:',
|
||||
contextPrompt,
|
||||
].join('\n');
|
||||
if ($A.getObject(context, [0,0]) === 'human') {
|
||||
assistantContext += "\n----\n请根据以上信息,结合以下用户输入的内容生成项目任务:++++";
|
||||
}
|
||||
prepared.push(['human', assistantContext]);
|
||||
}
|
||||
if (context.length > 0) {
|
||||
prepared.push(...context);
|
||||
}
|
||||
return prepared;
|
||||
},
|
||||
|
||||
handleTaskAIApply({rawOutput}) {
|
||||
if (!rawOutput) {
|
||||
$A.messageWarning('AI 未生成内容');
|
||||
return;
|
||||
}
|
||||
const parsed = this.parseTaskAIContent(rawOutput);
|
||||
if (!parsed) {
|
||||
$A.modalError('AI 内容解析失败,请重试');
|
||||
return;
|
||||
}
|
||||
if (parsed.title) {
|
||||
this.addData.name = parsed.title;
|
||||
this.$nextTick(() => {
|
||||
this.$refs.input && this.$refs.input.focus();
|
||||
});
|
||||
}
|
||||
if (parsed.description && this.$refs.editorTaskRef) {
|
||||
const html = MarkdownConver(parsed.description);
|
||||
this.$refs.editorTaskRef.setContent(html, {format: 'raw'});
|
||||
}
|
||||
if (parsed.subtasks.length > 0) {
|
||||
const mainOwner = Array.isArray(this.addData.owner) && this.addData.owner.length > 0
|
||||
? [this.addData.owner[0]]
|
||||
: (this.userId ? [this.userId] : []);
|
||||
const subtasks = parsed.subtasks.map(name => ({
|
||||
name,
|
||||
owner: [...mainOwner],
|
||||
times: [],
|
||||
}));
|
||||
this.$set(this.addData, 'subtasks', subtasks);
|
||||
this.advanced = true;
|
||||
}
|
||||
},
|
||||
|
||||
parseTaskAIContent(content) {
|
||||
const payload = this.normalizeAIJsonContent(content);
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return null;
|
||||
}
|
||||
const title = this.pickFirstString([payload.title, payload.name, payload.task_title]);
|
||||
const description = this.pickFirstString([
|
||||
payload.description_markdown,
|
||||
payload.description,
|
||||
payload.content_markdown,
|
||||
payload.content,
|
||||
payload.body,
|
||||
payload.detail,
|
||||
]);
|
||||
const subtasks = this.normalizeAISubtasks(payload.subtasks || payload.tasks || payload.checklist || payload.steps);
|
||||
if (!title && !description && subtasks.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
subtasks,
|
||||
};
|
||||
},
|
||||
|
||||
normalizeAIJsonContent(content) {
|
||||
if (!content) {
|
||||
return null;
|
||||
}
|
||||
const raw = String(content).trim();
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const candidates = [raw];
|
||||
const block = raw.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
||||
if (block && block[1]) {
|
||||
candidates.push(block[1].trim());
|
||||
}
|
||||
const start = raw.indexOf('{');
|
||||
const end = raw.lastIndexOf('}');
|
||||
if (start !== -1 && end !== -1 && end > start) {
|
||||
candidates.push(raw.slice(start, end + 1));
|
||||
}
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(candidate);
|
||||
} catch (e) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
normalizeAISubtasks(value) {
|
||||
let raw = [];
|
||||
if (Array.isArray(value)) {
|
||||
raw = value.map(item => {
|
||||
if (typeof item === 'string') {
|
||||
return item;
|
||||
}
|
||||
if (item && typeof item === 'object') {
|
||||
return item.title || item.name || item.task || item.content || '';
|
||||
}
|
||||
return '';
|
||||
});
|
||||
} else if (typeof value === 'string') {
|
||||
raw = value.split(/[\n\r;;]+/);
|
||||
}
|
||||
const cleaned = raw
|
||||
.map(item => String(item || '').replace(/^[\d\.-]*\s*/, '').replace(/^[•*\-]\s*/, '').trim())
|
||||
.filter(Boolean);
|
||||
return Array.from(new Set(cleaned)).slice(0, 8);
|
||||
},
|
||||
|
||||
pickFirstString(list = []) {
|
||||
for (const item of list) {
|
||||
if (typeof item === 'string' && item.trim()) {
|
||||
return item.trim();
|
||||
}
|
||||
}
|
||||
return '';
|
||||
},
|
||||
|
||||
handleTaskAIRender({rawOutput}) {
|
||||
if (!rawOutput) {
|
||||
return '';
|
||||
}
|
||||
const parsed = this.parseTaskAIContent(rawOutput);
|
||||
if (!parsed) {
|
||||
return rawOutput;
|
||||
}
|
||||
const blocks = [];
|
||||
if (parsed.title) {
|
||||
blocks.push(`## ${parsed.title}`);
|
||||
}
|
||||
if (parsed.description) {
|
||||
blocks.push(parsed.description);
|
||||
}
|
||||
if (parsed.subtasks.length > 0) {
|
||||
const list = parsed.subtasks.map((name, index) => `${index + 1}. ${name}`);
|
||||
blocks.push(list.join('\n'));
|
||||
}
|
||||
return blocks.join('\n\n').trim() || rawOutput;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
163
resources/assets/js/utils/ai.js
vendored
163
resources/assets/js/utils/ai.js
vendored
@ -11,71 +11,19 @@ const AIModelNames = (str) => {
|
||||
}, []).filter(item => item.value);
|
||||
}
|
||||
|
||||
const AIBotList = [
|
||||
{
|
||||
value: "openai",
|
||||
label: "ChatGPT",
|
||||
tags: [],
|
||||
src: $A.mainUrl('images/avatar/default_openai.png'),
|
||||
desc: $A.L('我是一个人工智能助手,为用户提供问题解答和指导。我没有具体的身份,只是一个程序。您有什么问题可以问我哦?')
|
||||
},
|
||||
{
|
||||
value: "claude",
|
||||
label: "Claude",
|
||||
tags: [],
|
||||
src: $A.mainUrl('images/avatar/default_claude.png'),
|
||||
desc: $A.L('我是Claude,一个由Anthropic公司创造出来的AI助手机器人。我的工作是帮助人类,与人对话并给出解答。')
|
||||
},
|
||||
{
|
||||
value: "deepseek",
|
||||
label: "DeepSeek",
|
||||
tags: [],
|
||||
src: $A.mainUrl('images/avatar/default_deepseek.png'),
|
||||
desc: $A.L('DeepSeek大语言模型算法是北京深度求索人工智能基础技术研究有限公司推出的深度合成服务算法。')
|
||||
},
|
||||
{
|
||||
value: "gemini",
|
||||
label: "Gemini",
|
||||
tags: [],
|
||||
src: $A.mainUrl('images/avatar/default_gemini.png'),
|
||||
desc: `${$A.L('我是由Google开发的生成式人工智能聊天机器人。')}${$A.L('它基于同名的Gemini系列大型语言模型。')}${$A.L('是应对OpenAI公司开发的ChatGPT聊天机器人的崛起而开发的。')}`
|
||||
},
|
||||
{
|
||||
value: "grok",
|
||||
label: "Grok",
|
||||
tags: [],
|
||||
src: $A.mainUrl('images/avatar/default_grok.png'),
|
||||
desc: $A.L('Grok是由xAI开发的生成式人工智能聊天机器人,旨在通过实时回答用户问题来提供帮助。')
|
||||
},
|
||||
{
|
||||
value: "ollama",
|
||||
label: "Ollama",
|
||||
tags: [],
|
||||
src: $A.mainUrl('images/avatar/default_ollama.png'),
|
||||
desc: $A.L('Ollama 是一个轻量级、可扩展的框架,旨在让用户能够在本地机器上构建和运行大型语言模型。')
|
||||
},
|
||||
{
|
||||
value: "zhipu",
|
||||
label: "智谱清言",
|
||||
tags: [],
|
||||
src: $A.mainUrl('images/avatar/default_zhipu.png'),
|
||||
desc: `${$A.L('我是智谱清言,是智谱 AI 公司于2023训练的语言模型。')}${$A.L('我的任务是针对用户的问题和要求提供适当的答复和支持。')}`
|
||||
},
|
||||
{
|
||||
value: "qianwen",
|
||||
label: "通义千问",
|
||||
tags: [],
|
||||
src: $A.mainUrl('avatar/%E9%80%9A%E4%B9%89%E5%8D%83%E9%97%AE.png'),
|
||||
desc: $A.L('我是达摩院自主研发的超大规模语言模型,能够回答问题、创作文字,还能表达观点、撰写代码。')
|
||||
},
|
||||
{
|
||||
value: "wenxin",
|
||||
label: "文心一言",
|
||||
tags: [],
|
||||
src: $A.mainUrl('avatar/%E6%96%87%E5%BF%83.png'),
|
||||
desc: $A.L('我是文心一言,英文名是ERNIE Bot。我能够与人对话互动,回答问题,协助创作,高效便捷地帮助人们获取信息、知识和灵感。')
|
||||
},
|
||||
]
|
||||
const AIBotList = []
|
||||
|
||||
const AIBotMap = {
|
||||
openai: "ChatGPT",
|
||||
claude: "Claude",
|
||||
deepseek: "DeepSeek",
|
||||
gemini: "Gemini",
|
||||
grok: "Grok",
|
||||
ollama: "Ollama",
|
||||
zhipu: "智谱清言",
|
||||
qianwen: "通义千问",
|
||||
wenxin: "文心一言",
|
||||
}
|
||||
|
||||
const AISystemConfig = {
|
||||
fields: [
|
||||
@ -269,5 +217,88 @@ const AISystemConfig = {
|
||||
}
|
||||
}
|
||||
|
||||
const MESSAGE_AI_SYSTEM_PROMPT = `你是一名专业的沟通助手,协助用户编写得体、清晰且具行动指向的即时消息。
|
||||
|
||||
export {AIModelNames, AIBotList, AISystemConfig}
|
||||
写作要求:
|
||||
1. 根据用户提供的需求与上下文生成完整消息,语气需符合业务沟通场景,保持真诚、礼貌且高效
|
||||
2. 默认使用简洁的短段落,可使用 Markdown 基础格式(加粗、列表、引用)增强结构,但不要输出代码块或 JSON
|
||||
3. 如果上下文包含引用信息或草稿,请在消息中自然呼应相关要点
|
||||
4. 如无特别说明,将消息长度控制在 60-180 字;若需更短或更长,遵循用户描述
|
||||
5. 如需提出行动或问题,请明确表达,避免含糊
|
||||
|
||||
输出规范:
|
||||
- 仅返回可直接发送的消息内容
|
||||
- 禁止在内容前后添加额外说明、标签或引导语`;
|
||||
|
||||
const TASK_AI_SYSTEM_PROMPT = `你是一个专业的任务管理专家,擅长将想法和需求转化为清晰、可执行的项目任务。
|
||||
|
||||
任务生成要求:
|
||||
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格式
|
||||
- 可以包含任务背景、具体要求、验收标准等部分
|
||||
- 根据任务性质灵活组织内容结构
|
||||
- 仅在确有必要时生成子任务,并确保每个子任务都是独立、可执行、便于追踪的动作
|
||||
- 若用户明确要求简洁或简单,保持描述紧凑,避免添加冗余段落或重复信息
|
||||
|
||||
上下文信息处理指南:
|
||||
- 如果已有标题和内容,优先考虑优化改进而非完全重写
|
||||
- 如果使用了任务模板,严格按照模板的结构和格式要求生成
|
||||
- 如果已设置负责人或时间计划,在任务描述中体现相关要求
|
||||
- 根据优先级等级调整任务的紧急程度和详细程度
|
||||
|
||||
注意事项:
|
||||
- 标题要体现任务的核心动作和目标
|
||||
- 描述要包含足够的细节让执行者理解任务
|
||||
- 如果涉及技术开发,要明确技术要求和实现方案
|
||||
- 如果涉及设计,要说明设计要求和期望效果
|
||||
- 如果涉及测试,要明确测试范围和验收标准`;
|
||||
|
||||
const PROJECT_AI_SYSTEM_PROMPT = `你是一名资深的项目规划顾问,帮助团队快速搭建符合需求的项目。
|
||||
|
||||
生成要求:
|
||||
1. 产出一个简洁、有辨识度的项目名称(不超过18个汉字或36个字符)
|
||||
2. 给出 3 - 8 个项目任务列表,用于看板列或阶段分组
|
||||
3. 任务列表名称保持 4 - 12 个字符,聚焦阶段或责任划分,避免冗长描述
|
||||
4. 结合用户描述的业务特征,必要时可包含里程碑或交付节点
|
||||
5. 尽量参考上下文提供的现有内容或模板,不要与之完全重复
|
||||
|
||||
输出格式:
|
||||
必须严格返回 JSON,禁止携带额外说明或 Markdown 代码块,结构如下:
|
||||
{
|
||||
"name": "项目名称",
|
||||
"columns": ["列表1", "列表2", "列表3"]
|
||||
}
|
||||
|
||||
校验标准:
|
||||
- 列表名称应当互不重复且语义明确
|
||||
- 若上下文包含已有名称或列表,请在此基础上迭代优化`;
|
||||
|
||||
export {
|
||||
AIModelNames,
|
||||
AIBotList,
|
||||
AIBotMap,
|
||||
AISystemConfig,
|
||||
MESSAGE_AI_SYSTEM_PROMPT,
|
||||
TASK_AI_SYSTEM_PROMPT,
|
||||
PROJECT_AI_SYSTEM_PROMPT,
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user