mirror of
https://github.com/crmeb/CRMEB.git
synced 2026-10-28 13:31:04 +00:00
feat(aiagent): 将模型未配置引导改为对话内普通消息展示
后端新增 sendModelGuide 兜底方法,模型调用失败时以普通 assistant 消息推送配置指引并落库,替代原错误弹窗。前端移除空态引导卡片相关逻辑,新增站内链接路由跳转委托,优化对话页交互体验。
This commit is contained in:
parent
3989c0f570
commit
856ab527fd
@ -47,8 +47,7 @@ class AgentChatService
|
||||
*/
|
||||
public function options(): array
|
||||
{
|
||||
$modelService = app()->make(AiModelServices::class);
|
||||
$models = $modelService->getAvailableModels();
|
||||
$models = app()->make(AiModelServices::class)->getAvailableModels();
|
||||
// 系统默认唯一:存在登记的默认模型时,一号通项退化为普通选项,不再标记默认
|
||||
$hasRegisteredDefault = (bool) array_filter($models, fn(array $model) => (int)$model['is_default'] === 1);
|
||||
array_unshift($models, [
|
||||
@ -63,8 +62,6 @@ class AgentChatService
|
||||
]);
|
||||
return [
|
||||
'models' => $models,
|
||||
// 内置一号通凭证状态:前端据此识别「仅剩一号通且未配置」场景,在对话框直接引导去配置模型
|
||||
'yihaotong_configured' => $modelService->isYihaotongConfigured(),
|
||||
'mcp_available' => $this->mcp->available(),
|
||||
'mcp_servers' => $this->mcp->serverOptions(),
|
||||
'skills' => array_values(array_filter(
|
||||
@ -426,12 +423,63 @@ class AgentChatService
|
||||
$this->agents->expirePendingConfirms($conversationId);
|
||||
|
||||
$send(['event' => 'start', 'conversation_id' => $conversationId]);
|
||||
$context = $this->prepareContext($conversationId, $serverIds, $skillKeys, $modelId, [], [], $adminId);
|
||||
$this->streamLoop($conversationId, $context, $send, [
|
||||
'model_id' => $modelId,
|
||||
'server_ids' => $serverIds,
|
||||
'skill_keys' => $skillKeys,
|
||||
]);
|
||||
try {
|
||||
$context = $this->prepareContext($conversationId, $serverIds, $skillKeys, $modelId, [], [], $adminId);
|
||||
$this->streamLoop($conversationId, $context, $send, [
|
||||
'model_id' => $modelId,
|
||||
'server_ids' => $serverIds,
|
||||
'skill_keys' => $skillKeys,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
// 模型解析/调用失败(未配置、停用、一号通凭证缺失、上游密钥无效等):
|
||||
// 以普通对话消息输出配置指引并落库,前端按正常回复渲染,不再依赖错误弹窗
|
||||
$this->sendModelGuide($conversationId, $send, $e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 模型不可用兜底:以普通 assistant 消息输出配置指引(先落库再流式推送),前端按正常回复渲染。
|
||||
*
|
||||
* 覆盖模型未配置/停用、一号通凭证缺失、上游接口报错(如密钥无效)等场景;
|
||||
* 消息落库后在历史会话中同样可见,替代原先一闪而过的错误弹窗。
|
||||
* @param int $conversationId 会话 ID
|
||||
* @param callable $send SSE 事件推送回调
|
||||
* @param \Throwable $e 失败异常
|
||||
*/
|
||||
private function sendModelGuide(int $conversationId, callable $send, \Throwable $e): void
|
||||
{
|
||||
// 与流式错误白名单同一口径:仅 RuntimeException 的消息可直出,其余异常统一脱敏文案
|
||||
$error = $e instanceof \RuntimeException ? $e->getMessage() : '服务异常,请稍后重试';
|
||||
$content = $this->buildModelGuide($error);
|
||||
$this->agents->addMessage($conversationId, 'assistant', $content);
|
||||
$send(['event' => 'delta', 'text' => $content]);
|
||||
$send(['event' => 'done', 'tool_calls' => [], 'skills' => []]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按错误内容归类生成配置指引文案(含后台配置页地址),以 Markdown 随对话下发
|
||||
* @param string $error 脱敏后的错误消息
|
||||
* @return string
|
||||
*/
|
||||
private function buildModelGuide(string $error): string
|
||||
{
|
||||
// 一号通凭证缺失或一号通接口报错:引导完成一号通配置,或改用第三方模型
|
||||
if (str_contains($error, '一号通')) {
|
||||
return "AI 模型暂不可用:{$error}\n\n"
|
||||
. "可选择以下任一方式配置后再试:\n"
|
||||
. "- 使用一号通 AI:前往[系统设置 → 一号通账户](/admin/setting/yihaotong/index)完成一号通登录;\n"
|
||||
. '- 使用第三方模型:前往[第三方模型配置](/admin/plugin/mcp/config)完成配置。';
|
||||
}
|
||||
// 第三方模型上游报错(如 Invalid API Key):引导检查登记模型的密钥与地址配置
|
||||
if (str_contains($error, 'AI接口错误')) {
|
||||
return "AI 模型调用失败:{$error}\n\n"
|
||||
. '请前往[系统设置 → 模型管理](/admin/setting/ai_model/index)检查所选模型的接口地址与密钥配置,或更换其他可用模型后重试。';
|
||||
}
|
||||
// 模型不存在/停用等(错误文案本身已指向模型管理):补充具体配置页地址
|
||||
if (str_contains($error, '模型管理')) {
|
||||
return "{$error}\n\n模型管理入口:[系统设置 → 模型管理](/admin/setting/ai_model/index)。";
|
||||
}
|
||||
return $error;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -886,15 +934,20 @@ class AgentChatService
|
||||
$this->agents->updateConfirmStatus($messageId, $action === 'approve' ? 'approved' : 'rejected');
|
||||
// 以确认时快照重建上下文(连接器/Skill/模型),执行参数以快照为准,不重新让模型生成;
|
||||
// 确认前已加载的 Skill 一并预载,模型无需重复调用加载工具
|
||||
$context = $this->prepareContext(
|
||||
$conversationId,
|
||||
array_map('intval', (array)($payload['server_ids'] ?? [])),
|
||||
array_map('strval', (array)($payload['skill_keys'] ?? [])),
|
||||
(int)($payload['model_id'] ?? 0),
|
||||
array_map('strval', (array)($payload['skill_loaded'] ?? [])),
|
||||
[],
|
||||
$adminId
|
||||
);
|
||||
try {
|
||||
$context = $this->prepareContext(
|
||||
$conversationId,
|
||||
array_map('intval', (array)($payload['server_ids'] ?? [])),
|
||||
array_map('strval', (array)($payload['skill_keys'] ?? [])),
|
||||
(int)($payload['model_id'] ?? 0),
|
||||
array_map('strval', (array)($payload['skill_loaded'] ?? [])),
|
||||
[],
|
||||
$adminId
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
$this->sendModelGuide($conversationId, $send, $e);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($interaction === 'ask_user') {
|
||||
$toolResult = $action === 'approve'
|
||||
@ -909,15 +962,19 @@ class AgentChatService
|
||||
);
|
||||
$send(['event' => 'tool', 'name' => $toolName, 'arguments' => $arguments, 'result' => $toolResult]);
|
||||
$context['workingMessage'] .= $this->buildToolResultContext($toolName, $toolResult);
|
||||
$this->streamLoop($conversationId, $context, $send, [
|
||||
'model_id' => (int)($payload['model_id'] ?? 0),
|
||||
'server_ids' => (array)($payload['server_ids'] ?? []),
|
||||
'skill_keys' => (array)($payload['skill_keys'] ?? []),
|
||||
], $action === 'approve' ? '用户已作答,正在继续处理' : '用户已跳过该问题,正在继续处理', [[
|
||||
'name' => $toolName,
|
||||
'arguments' => $arguments,
|
||||
'result' => $toolResult,
|
||||
]]);
|
||||
try {
|
||||
$this->streamLoop($conversationId, $context, $send, [
|
||||
'model_id' => (int)($payload['model_id'] ?? 0),
|
||||
'server_ids' => (array)($payload['server_ids'] ?? []),
|
||||
'skill_keys' => (array)($payload['skill_keys'] ?? []),
|
||||
], $action === 'approve' ? '用户已作答,正在继续处理' : '用户已跳过该问题,正在继续处理', [[
|
||||
'name' => $toolName,
|
||||
'arguments' => $arguments,
|
||||
'result' => $toolResult,
|
||||
]]);
|
||||
} catch (\Throwable $e) {
|
||||
$this->sendModelGuide($conversationId, $send, $e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@ -943,15 +1000,19 @@ class AgentChatService
|
||||
);
|
||||
$send(['event' => 'tool', 'name' => $toolName, 'arguments' => $arguments, 'result' => $toolResult]);
|
||||
$context['workingMessage'] .= $this->buildToolResultContext($toolName, $toolResult);
|
||||
$this->streamLoop($conversationId, $context, $send, [
|
||||
'model_id' => (int)($payload['model_id'] ?? 0),
|
||||
'server_ids' => (array)($payload['server_ids'] ?? []),
|
||||
'skill_keys' => (array)($payload['skill_keys'] ?? []),
|
||||
], '用户已确认,正在执行操作', [[
|
||||
'name' => $toolName,
|
||||
'arguments' => $arguments,
|
||||
'result' => $toolResult,
|
||||
]]);
|
||||
try {
|
||||
$this->streamLoop($conversationId, $context, $send, [
|
||||
'model_id' => (int)($payload['model_id'] ?? 0),
|
||||
'server_ids' => (array)($payload['server_ids'] ?? []),
|
||||
'skill_keys' => (array)($payload['skill_keys'] ?? []),
|
||||
], '用户已确认,正在执行操作', [[
|
||||
'name' => $toolName,
|
||||
'arguments' => $arguments,
|
||||
'result' => $toolResult,
|
||||
]]);
|
||||
} catch (\Throwable $e) {
|
||||
$this->sendModelGuide($conversationId, $send, $e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -29,19 +29,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref="messageBox" class="message-list">
|
||||
<div ref="messageBox" class="message-list" @click="onMessageBoxClick">
|
||||
<div v-if="!messages.length" class="empty-chat">
|
||||
<template v-if="modelMissing">
|
||||
<div class="empty-title">AI 模型尚未配置</div>
|
||||
<div class="empty-tip">还没有可用的 AI 模型,请先完成模型配置再开始对话</div>
|
||||
<el-button type="primary" class="empty-guide-btn" v-db-click @click="openManage('model')"
|
||||
>去配置模型</el-button
|
||||
>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="empty-title">今天帮你做些什么?</div>
|
||||
<div class="empty-tip">点击输入框右下角模型名称可切换 AI 模型</div>
|
||||
</template>
|
||||
<div class="empty-title">今天帮你做些什么?</div>
|
||||
<div class="empty-tip">点击输入框右下角模型名称可切换 AI 模型</div>
|
||||
</div>
|
||||
|
||||
<template v-for="(message, index) in messages">
|
||||
@ -78,15 +69,6 @@
|
||||
@resolve="resolveConfirm"
|
||||
/>
|
||||
|
||||
<!-- 模型配置引导卡片:模型未配置/停用时在对话区直接提示去向,替代一闪而过的 toast -->
|
||||
<div v-else-if="message.modelGuide" :key="'g' + index" class="model-guide">
|
||||
<div class="model-guide-title">AI 模型不可用</div>
|
||||
<div class="model-guide-text">{{ message.text || '当前没有可用的 AI 模型,请先完成模型配置后再开始对话' }}</div>
|
||||
<el-button type="primary" size="small" v-db-click @click="openModelGuide(message)">{{
|
||||
message.target === 'yihaotong' ? '去配置一号通' : '去配置模型'
|
||||
}}</el-button>
|
||||
</div>
|
||||
|
||||
<!-- AI 回复:通栏排版 -->
|
||||
<div v-else :key="'a' + index" class="assistant-row">
|
||||
<!-- 系统过程(深度思考):思维链、调用过的 Skill 与工具痕迹,默认折叠,展开才可见 -->
|
||||
@ -218,15 +200,6 @@ const modelId = ref(0);
|
||||
const serverIds = ref([]);
|
||||
const skillKeys = ref([]);
|
||||
const options = ref({ models: [] });
|
||||
/** options 是否已加载完成(未加载完不判定模型缺失,避免空态引导闪现) */
|
||||
const optionsLoaded = ref(false);
|
||||
/** 完全没有可用模型:无启用的登记模型,且内置一号通未配置凭证 */
|
||||
const modelMissing = computed(() => {
|
||||
if (!optionsLoaded.value) return false;
|
||||
const models = options.value.models || [];
|
||||
const hasCustom = models.some((model) => model.id > 0);
|
||||
return !hasCustom && !options.value.yihaotong_configured;
|
||||
});
|
||||
|
||||
/** 快捷场景标签:点击仅回填输入框(icon 为全局注册的 @element-plus/icons-vue 组件名) */
|
||||
const topics = [
|
||||
@ -260,7 +233,6 @@ onMounted(() => {
|
||||
function loadOptions(applyDefault = false) {
|
||||
aiAgentOptionsApi().then((res) => {
|
||||
options.value = { ...options.value, ...(res.data || {}) };
|
||||
optionsLoaded.value = true;
|
||||
// 首次加载时选中"默认模型"(is_default 全局唯一:登记默认或一号通)
|
||||
if (applyDefault) {
|
||||
const def = (options.value.models || []).find((model) => model.is_default === 1);
|
||||
@ -300,9 +272,9 @@ function newConversation() {
|
||||
function selectConversation(item, force = false) {
|
||||
// 从管理遮盖层点击会话记录时,关闭遮盖层展示对话内容
|
||||
activeManage.value = '';
|
||||
if (!force && conversationId.value === item.id) return Promise.resolve();
|
||||
if (!force && conversationId.value === item.id) return;
|
||||
conversationId.value = item.id;
|
||||
return aiAgentMessagesApi(item.id).then((res) => {
|
||||
aiAgentMessagesApi(item.id).then((res) => {
|
||||
if (conversationId.value !== item.id) return;
|
||||
// 过滤中间过程消息(tool 角色与带 tool_name 的 assistant 工具调用记录),仅展示问答内容与交互卡片
|
||||
messages.value = (res.data || [])
|
||||
@ -379,11 +351,6 @@ function sendMessage(question) {
|
||||
const fromInput = typeof question !== 'string';
|
||||
const content = (fromInput ? chatInput.value : question).trim();
|
||||
if (!content || chatting.value) return;
|
||||
// 模型未配置:不发起请求,直接在对话区给出配置引导(输入内容保留)
|
||||
if (modelMissing.value) {
|
||||
pushModelGuide();
|
||||
return;
|
||||
}
|
||||
messages.value.push({ role: 'user', content });
|
||||
if (fromInput) {
|
||||
chatInput.value = '';
|
||||
@ -418,11 +385,6 @@ function startStream(content) {
|
||||
streamController = null;
|
||||
if (e?.name === 'AbortError') {
|
||||
finishAborted(reply);
|
||||
} else if (isModelConfigError(e?.message)) {
|
||||
// 模型未配置/停用:移除空占位,在对话区插入配置引导卡片(用户消息已落库,无需回读)
|
||||
removeReply(reply);
|
||||
pushModelGuide(e?.message || '');
|
||||
loadConversations();
|
||||
} else {
|
||||
ElMessage.error(e.message || '对话失败');
|
||||
finishReply(reply);
|
||||
@ -448,39 +410,6 @@ function finishAborted(reply) {
|
||||
finishReply(reply);
|
||||
}
|
||||
|
||||
/** 是否为模型配置类错误:后端固定文案均指向「模型管理」或一号通凭证缺失 */
|
||||
function isModelConfigError(msg) {
|
||||
return typeof msg === 'string' && (msg.includes('模型管理') || msg.includes('一号通凭证'));
|
||||
}
|
||||
|
||||
/** 在对话区插入模型配置引导卡片(已有引导卡不重复插入),text 为后端错误说明 */
|
||||
function pushModelGuide(text = '') {
|
||||
const list = messages.value;
|
||||
if (!list.some((message) => message.modelGuide)) {
|
||||
list.push({
|
||||
role: 'assistant',
|
||||
modelGuide: true,
|
||||
text,
|
||||
// 一号通凭证缺失引导去「一号通账户」页,其余模型问题统一去「模型管理」页
|
||||
target: text.includes('一号通凭证') ? 'yihaotong' : 'model',
|
||||
});
|
||||
}
|
||||
scrollToBottom();
|
||||
}
|
||||
|
||||
/** 移除流式回复占位消息并复位对话状态(引导卡场景占位无内容,不适用 finishReply 的移除条件) */
|
||||
function removeReply(reply) {
|
||||
const list = messages.value;
|
||||
const index = list.indexOf(reply);
|
||||
if (index > -1) list.splice(index, 1);
|
||||
chatting.value = false;
|
||||
}
|
||||
|
||||
/** 引导卡跳转:模型管理 / 一号通账户(均为系统设置内置页面) */
|
||||
function openModelGuide(message) {
|
||||
router.push(setting.routePre + (message.target === 'yihaotong' ? '/setting/yihaotong/index' : '/setting/ai_model/index'));
|
||||
}
|
||||
|
||||
/** 是否为最后一条 AI 回复(重新生成仅对最后一条回复开放) */
|
||||
function isLastReply(message) {
|
||||
const list = messages.value;
|
||||
@ -538,14 +467,6 @@ function resolveConfirm(message, action, extra = {}) {
|
||||
if (e?.name === 'AbortError') {
|
||||
// 手动终止:卡片决定已提交,保持当前状态防止重复执行,仅停止后续输出
|
||||
finishAborted(reply);
|
||||
} else if (isModelConfigError(e?.message)) {
|
||||
// 模型未配置/停用:移除空占位,回读服务端记录(工具执行可能已落库)后插入配置引导卡片
|
||||
removeReply(reply);
|
||||
const targetId = conversationId.value;
|
||||
Promise.resolve(targetId ? selectConversation({ id: targetId }, true) : null).then(() => {
|
||||
if (conversationId.value === targetId) pushModelGuide(e?.message || '');
|
||||
});
|
||||
loadConversations();
|
||||
} else {
|
||||
ElMessage.error(e.message || '操作失败');
|
||||
finishReply(reply);
|
||||
@ -649,6 +570,19 @@ function scrollToBottom() {
|
||||
if (box) box.scrollTop = box.scrollHeight;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 消息内链接点击委托:AI 回复 Markdown 渲染的站内链接(/admin 开头)转路由跳转,
|
||||
* 避免原生 <a> 导航整页刷新;外链与无 href 的锚点保持默认行为
|
||||
*/
|
||||
function onMessageBoxClick(e) {
|
||||
const anchor = e.target.closest('a');
|
||||
if (!anchor) return;
|
||||
const href = anchor.getAttribute('href') || '';
|
||||
if (!href.startsWith('/admin')) return;
|
||||
e.preventDefault();
|
||||
router.push(href);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@ -738,29 +672,6 @@ function scrollToBottom() {
|
||||
color: #a8abb2;
|
||||
font-size: 13px;
|
||||
}
|
||||
.empty-guide-btn {
|
||||
margin-top: 20px;
|
||||
}
|
||||
/* 模型配置引导卡片:对话区内的居中提示条 */
|
||||
.model-guide {
|
||||
margin-bottom: 30px;
|
||||
padding: 16px 20px;
|
||||
text-align: center;
|
||||
background: var(--prev-bg-color);
|
||||
border: 1px solid var(--prev-border-color-lighter);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.model-guide-title {
|
||||
color: var(--prev-color-text-regular);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.model-guide-text {
|
||||
margin: 6px 0 12px;
|
||||
color: var(--prev-color-text-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
/* 用户消息:右侧浅灰气泡 */
|
||||
.user-row {
|
||||
display: flex;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user