mirror of
https://github.com/crmeb/CRMEB.git
synced 2026-10-28 21:40:50 +00:00
后端新增 sendModelGuide 兜底方法,模型调用失败时以普通 assistant 消息推送配置指引并落库,替代原错误弹窗。前端移除空态引导卡片相关逻辑,新增站内链接路由跳转委托,优化对话页交互体验。
2610 lines
133 KiB
PHP
2610 lines
133 KiB
PHP
<?php
|
||
declare(strict_types=1);
|
||
|
||
namespace modules\aiagent\service;
|
||
|
||
use app\services\ai\AiModelServices;
|
||
use crmeb\services\ai\AiProtocol;
|
||
use think\facade\Log;
|
||
|
||
/** 执行带 Skill 上下文和 MCP 工具循环的 AI 对话(会话直接挂载,不依赖具体 Agent)。 */
|
||
class AgentChatService
|
||
{
|
||
/** 单次对话允许的最大工具调用轮数(需容纳多步确认:问询/执行可多轮交替) */
|
||
private const MAX_STEPS = 6;
|
||
|
||
/** 用户消息长度上限(字符):服务端兜底校验,防止超长消息撑爆上下文与会话存储 */
|
||
private const MAX_MESSAGE_LENGTH = 8000;
|
||
|
||
/** Skill 按需加载虚拟工具名:上下文仅注入 Skill 目录(名称+说明),模型判断相关后调用它加载全文规则 */
|
||
private const SKILL_LOAD_TOOL = 'crmeb_skill_load';
|
||
|
||
/** 邮件发送工具名(内置 MCP):无人值守场景的收件地址受 guardUnattendedEmail 约束 */
|
||
private const EMAIL_TOOL = 'crmeb_email_send';
|
||
|
||
/** @var AiAgentServices Agent 会话服务 */
|
||
private AiAgentServices $agents;
|
||
|
||
/** @var McpToolService MCP 工具调用服务 */
|
||
private McpToolService $mcp;
|
||
|
||
/** @var SkillRepository Skill 仓库服务 */
|
||
private SkillRepository $skills;
|
||
|
||
public function __construct(
|
||
AiAgentServices $agents,
|
||
McpToolService $mcp,
|
||
SkillRepository $skills
|
||
) {
|
||
$this->agents = $agents;
|
||
$this->mcp = $mcp;
|
||
$this->skills = $skills;
|
||
}
|
||
|
||
/**
|
||
* 对话页选项:模型(首位为内置一号通模型)、连接器(MCP 服务粒度)、启用中的 Skill
|
||
* @return array
|
||
*/
|
||
public function options(): array
|
||
{
|
||
$models = app()->make(AiModelServices::class)->getAvailableModels();
|
||
// 系统默认唯一:存在登记的默认模型时,一号通项退化为普通选项,不再标记默认
|
||
$hasRegisteredDefault = (bool) array_filter($models, fn(array $model) => (int)$model['is_default'] === 1);
|
||
array_unshift($models, [
|
||
'id' => 0,
|
||
'name' => $hasRegisteredDefault ? '一号通AI' : '一号通AI(系统内置)',
|
||
'protocol' => AiModelServices::DEFAULT_PROTOCOL,
|
||
'protocol_name' => AiProtocol::name(AiModelServices::DEFAULT_PROTOCOL),
|
||
'provider' => '',
|
||
'model' => '',
|
||
'supports_tools' => 1,
|
||
'is_default' => $hasRegisteredDefault ? 0 : 1,
|
||
]);
|
||
return [
|
||
'models' => $models,
|
||
'mcp_available' => $this->mcp->available(),
|
||
'mcp_servers' => $this->mcp->serverOptions(),
|
||
'skills' => array_values(array_filter(
|
||
$this->skills->all(),
|
||
fn(array $skill) => (int)$skill['status'] === 1
|
||
)),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 发送对话消息(工具调用循环)
|
||
* @param int $adminId 管理员 ID
|
||
* @param int $conversationId 会话 ID(0 表示新建会话)
|
||
* @param string $message 用户消息
|
||
* @param int $modelId 模型 ID(0 = 内置一号通默认)
|
||
* @param array $serverIds 选中的连接器(MCP Server)ID 列表
|
||
* @param array $skillKeys 选中的 Skill 标识列表
|
||
* @param array $runtimeOptions 运行时选项(自动化任务无人值守使用):
|
||
* unattended=bool 无人值守模式;autonomy=int 自主级别(1只读/3全自动);
|
||
* max_steps=int 工具调用轮数上限;deadline=int 软超时时间戳(超过后强制最终答复)
|
||
* @return array
|
||
*/
|
||
public function chat(
|
||
int $adminId,
|
||
int $conversationId,
|
||
string $message,
|
||
int $modelId = 0,
|
||
array $serverIds = [],
|
||
array $skillKeys = [],
|
||
array $runtimeOptions = []
|
||
): array {
|
||
$message = trim($message);
|
||
if ($message === '') {
|
||
throw new \RuntimeException('请输入对话内容');
|
||
}
|
||
if (mb_strlen($message) > self::MAX_MESSAGE_LENGTH) {
|
||
throw new \RuntimeException('对话内容不能超过 ' . self::MAX_MESSAGE_LENGTH . ' 字');
|
||
}
|
||
$unattended = !empty($runtimeOptions['unattended']);
|
||
$maxSteps = max(1, (int)($runtimeOptions['max_steps'] ?? self::MAX_STEPS));
|
||
$deadline = (int)($runtimeOptions['deadline'] ?? 0);
|
||
|
||
if ($conversationId > 0) {
|
||
$this->agents->assertConversation($conversationId, $adminId);
|
||
} else {
|
||
$conversationId = $this->agents->createConversation(0, $adminId, $message);
|
||
}
|
||
$this->agents->addMessage($conversationId, 'user', $message);
|
||
// 新用户消息到达:旧的待确认操作自动过期,避免旧确认卡与新上下文错位
|
||
$this->agents->expirePendingConfirms($conversationId);
|
||
|
||
$context = $this->prepareContext($conversationId, $serverIds, $skillKeys, $modelId, [], $runtimeOptions, $adminId);
|
||
$toolTrace = [];
|
||
$attempted = [];
|
||
// 上游 200 空响应重试计数:透明重试 1 次,仍为空才报错
|
||
$emptyRetries = 0;
|
||
// 残缺工具调用 JSON 修复计数:输出被截断导致 JSON 无法解析时注入系统提示重试,限 1 次
|
||
$repairRetries = 0;
|
||
$lastModel = '';
|
||
$lastUsage = [];
|
||
|
||
for ($step = 0; $step <= $maxSteps; $step++) {
|
||
// 软超时:超过 deadline 后不再发起新的工具轮次,转入循环外的强制最终答复
|
||
if ($deadline > 0 && $step > 0 && time() >= $deadline) {
|
||
break;
|
||
}
|
||
$result = $context['driver']->chat($context['workingMessage'], ['prompts' => $context['prompts'], 'stream' => 0]);
|
||
$content = trim((string)($result['content'] ?? ''));
|
||
$reasoning = trim((string)($result['reasoning'] ?? ''));
|
||
$lastModel = (string)($result['model'] ?? '');
|
||
$lastUsage = (array)($result['usage'] ?? []);
|
||
$toolCall = $this->resolveToolCall($content, $reasoning);
|
||
if ($content === '' && !$toolCall) {
|
||
// 上游请求成功但零正文:有思考无正文多为推理模型思考耗尽 max_tokens,给出可定位的指引;
|
||
// 零正文零思考多为网关/上游瞬时异常,透明重试一次,仍为空才报错
|
||
if ($reasoning !== '') {
|
||
throw new \RuntimeException('AI 模型仅返回了思考过程、未产出正文,常见原因是推理模型耗尽 max_tokens,请在「AI Agent → 模型管理」调大输出上限或更换模型');
|
||
}
|
||
if ($emptyRetries < 1) {
|
||
$emptyRetries++;
|
||
continue;
|
||
}
|
||
Log::error('[AgentChat] 上游空响应', [
|
||
'message_len' => strlen($context['workingMessage']),
|
||
'prompts_len' => array_sum(array_map('strlen', $context['prompts'])),
|
||
]);
|
||
throw new \RuntimeException('AI 模型未返回有效内容,请稍后重试或检查所选模型配置');
|
||
}
|
||
|
||
if (!$toolCall) {
|
||
// 剥离前先识别原始输出是否为工具调用 JSON(含起点标记但解析失败/未闭合,多为被 max_tokens 截断)
|
||
$brokenToolCall = str_contains($content, '{"tool_call"') || str_contains($content, '<|FunctionCallBegin|>');
|
||
$content = $this->stripToolCallJson($content);
|
||
$thinkReasoning = $this->extractThinkBlocks($content);
|
||
if ($reasoning === '') {
|
||
$reasoning = $thinkReasoning;
|
||
}
|
||
if ($content === '') {
|
||
// 模型只输出了无法解析的工具调用 JSON:先注入系统提示要求重新输出(限一次自愈),
|
||
// 仍失败再优雅兜底,避免整个请求报错
|
||
if ($brokenToolCall && $repairRetries < 1) {
|
||
$repairRetries++;
|
||
$context['workingMessage'] .= "\n\n系统提示:你上一条回复输出的工具调用 JSON 不完整或无法解析(常见原因是输出被长度截断)。请重新输出:需要继续操作时,只输出一个完整且精简的工具调用 JSON(display 从简,不要输出其他叙述);已有信息足够时,直接输出包含完整结果的最终答复。禁止重复输出残缺内容。";
|
||
continue;
|
||
}
|
||
if ($toolTrace !== []) {
|
||
Log::error('[AgentChat] 模型仅输出残缺工具调用 JSON,已兜底', ['raw_head' => mb_substr((string)($result['content'] ?? ''), 0, 500)]);
|
||
$content = '抱歉,本次未能基于工具结果生成有效答复,请重试或调整提问方式。';
|
||
} else {
|
||
Log::error('[AgentChat] 模型仅输出无法解析的内容', ['raw_head' => mb_substr((string)($result['content'] ?? ''), 0, 500)]);
|
||
throw new \RuntimeException('AI 模型未返回有效内容,请检查所选模型配置');
|
||
}
|
||
}
|
||
// 正文提问兜底:模型未走 crmeb_ask_user 而以正文直接向用户提问时,
|
||
// 由服务端把该回复强制转为问询卡片,确保用户端弹出输入框(不依赖模型自觉)。
|
||
// 提前剥离追问 JSON 防止混入卡片问题,结果暂存供最终答复复用,避免标签丢失
|
||
$earlyFollowups = $this->extractFollowups($content);
|
||
if ($content === '') {
|
||
// 整轮输出被剥离后只剩追问 JSON 等空内容:兜底话术,避免发出空答复
|
||
$content = '抱歉,本次未能生成有效答复,请重试或调整提问方式。';
|
||
}
|
||
if ($context['definitions'] !== [] && $this->looksLikeQuestionToUser($content)) {
|
||
$confirm = $this->forceAskUserCard($conversationId, $content, $context, [
|
||
'model_id' => $modelId,
|
||
'server_ids' => $serverIds,
|
||
'skill_keys' => $skillKeys,
|
||
]);
|
||
return [
|
||
'conversation_id' => $conversationId,
|
||
'confirm' => [
|
||
'message_id' => $confirm['message_id'],
|
||
'tool_name' => McpToolService::ASK_USER_TOOL,
|
||
'arguments' => $confirm['arguments'],
|
||
'interaction' => 'ask_user',
|
||
'display' => $confirm['display'],
|
||
],
|
||
'content' => '',
|
||
'reasoning' => $reasoning,
|
||
'model' => $lastModel,
|
||
'usage' => $lastUsage,
|
||
'tool_calls' => $toolTrace,
|
||
'skills' => array_keys($context['skillFiles']),
|
||
];
|
||
}
|
||
$grounded = $this->ensureGroundedAnswer($context, $toolTrace, [
|
||
'content' => $content,
|
||
'reasoning' => $reasoning,
|
||
'model' => $lastModel,
|
||
'usage' => $lastUsage,
|
||
]);
|
||
$content = $grounded['content'];
|
||
$reasoning = $grounded['reasoning'];
|
||
$lastModel = $grounded['model'];
|
||
$lastUsage = $grounded['usage'];
|
||
$followups = $this->extractFollowups($content);
|
||
if ($followups === []) {
|
||
$followups = $earlyFollowups;
|
||
}
|
||
$this->agents->addMessage($conversationId, 'assistant', $content, '', [
|
||
'skills' => $context['skillsUsed'],
|
||
'model' => $lastModel,
|
||
'reasoning' => $reasoning,
|
||
'followups' => $followups,
|
||
'tool_calls' => $this->toolTraceNames($toolTrace),
|
||
]);
|
||
return [
|
||
'conversation_id' => $conversationId,
|
||
'content' => $content,
|
||
'reasoning' => $reasoning,
|
||
'model' => $lastModel,
|
||
'usage' => $lastUsage,
|
||
'tool_calls' => $toolTrace,
|
||
'skills' => array_keys($context['skillFiles']),
|
||
'followups' => $followups,
|
||
];
|
||
}
|
||
|
||
if ($step >= $maxSteps) {
|
||
break;
|
||
}
|
||
|
||
$toolName = $toolCall['name'];
|
||
$arguments = $toolCall['arguments'];
|
||
// 相同调用(工具名+参数)只允许尝试一次,防止模型对无效工具原样重试烧完配额
|
||
$signature = $toolName . '|' . json_encode($arguments, JSON_UNESCAPED_UNICODE);
|
||
if (isset($attempted[$signature])) {
|
||
$context['workingMessage'] .= "\n\n系统提示:工具 {$toolName} 的相同调用已经执行过,结果已在上文。请勿重复调用,直接依据已有结果给出最终答复。";
|
||
break;
|
||
}
|
||
$attempted[$signature] = true;
|
||
$this->agents->addMessage(
|
||
$conversationId,
|
||
'assistant',
|
||
$this->canonicalToolCall($toolName, $arguments),
|
||
$toolName,
|
||
['arguments' => $arguments]
|
||
);
|
||
// 需用户交互的工具:暂停执行,等待用户在页面上确认/作答(confirm 消息承载快照与恢复上下文);
|
||
// 无人值守模式没有可交互的用户:需确认写工具已在工具清单过滤(只读)或授权直执(全自动),全部豁免确认
|
||
$interaction = $unattended ? null : $this->resolveInteraction($toolName, $context['definitions']);
|
||
if ($interaction !== null) {
|
||
$display = $this->buildCardDisplay($toolCall, $context['definitions']);
|
||
$confirmMessageId = $this->agents->addMessage(
|
||
$conversationId,
|
||
'confirm',
|
||
$this->canonicalToolCall($toolName, $arguments),
|
||
$toolName,
|
||
[
|
||
'arguments' => $arguments,
|
||
'status' => 'pending',
|
||
'interaction' => $interaction,
|
||
'display' => $display,
|
||
'model_id' => $modelId,
|
||
'server_ids' => $serverIds,
|
||
'skill_keys' => $skillKeys,
|
||
'skill_loaded' => array_keys($context['skillFiles']),
|
||
'allowed_tools' => $context['allowedTools'],
|
||
]
|
||
);
|
||
return [
|
||
'conversation_id' => $conversationId,
|
||
'confirm' => ['message_id' => $confirmMessageId, 'tool_name' => $toolName, 'arguments' => $arguments, 'interaction' => $interaction, 'display' => $display],
|
||
'content' => '',
|
||
'reasoning' => '',
|
||
'model' => $lastModel,
|
||
'usage' => $lastUsage,
|
||
'tool_calls' => $toolTrace,
|
||
'skills' => array_keys($context['skillFiles']),
|
||
];
|
||
}
|
||
if (in_array($toolName, $context['allowedTools'], true)) {
|
||
try {
|
||
// 无人值守邮件收件校验:仅允许任务通知邮箱与系统白名单
|
||
$this->guardUnattendedEmail($toolName, $arguments, $context);
|
||
// 本地虚拟工具(Skill 按需加载/自动化任务管理):拦截在 MCP 分发之前
|
||
$toolResult = match (true) {
|
||
$toolName === self::SKILL_LOAD_TOOL => $this->loadSkillForContext($context, (string)($arguments['key'] ?? ''), (string)($arguments['file'] ?? '')),
|
||
$toolName === McpToolService::TASK_CREATE_TOOL => $this->createTaskForContext((int)$context['admin_id'], $arguments),
|
||
$toolName === McpToolService::TASK_LIST_TOOL => $this->listTasksForContext((int)$context['admin_id'], $arguments),
|
||
default => $this->mcp->call($toolName, $arguments, $context['allowedTools']),
|
||
};
|
||
} catch (\Throwable $e) {
|
||
$toolResult = [
|
||
'error' => true,
|
||
'message' => $e->getMessage(),
|
||
];
|
||
}
|
||
} else {
|
||
// 幻觉工具名:附上可用清单引导模型纠正,而不是让它反复猜测
|
||
$toolResult = [
|
||
'error' => true,
|
||
'message' => "工具 {$toolName} 不在本对话可用工具清单中,禁止调用。",
|
||
'valid_tools' => $context['allowedTools'],
|
||
];
|
||
}
|
||
$this->agents->addMessage(
|
||
$conversationId,
|
||
'tool',
|
||
json_encode($toolResult, JSON_UNESCAPED_UNICODE),
|
||
$toolName,
|
||
['arguments' => $arguments, 'result' => $toolResult]
|
||
);
|
||
$toolTrace[] = ['name' => $toolName, 'arguments' => $arguments, 'result' => $toolResult];
|
||
$context['workingMessage'] .= $this->buildToolResultContext($toolName, $toolResult);
|
||
}
|
||
|
||
$finalPrompts = $context['prompts'];
|
||
$finalPrompts[] = '工具调用阶段已结束。上文已经包含工具返回结果,请直接严格依据结果给出最终答复,不要再输出工具调用 JSON,也不要声称未收到工具结果。';
|
||
$result = $context['driver']->chat($context['workingMessage'], ['prompts' => $finalPrompts, 'stream' => 0]);
|
||
$content = $this->stripToolCallJson(trim((string)($result['content'] ?? '')));
|
||
$reasoning = trim((string)($result['reasoning'] ?? ''));
|
||
$thinkReasoning = $this->extractThinkBlocks($content);
|
||
if ($reasoning === '') {
|
||
$reasoning = $thinkReasoning;
|
||
}
|
||
if ($content === '') {
|
||
// 强制答复轮仍只输出工具 JSON:已有工具结果时优雅兜底
|
||
if ($toolTrace !== []) {
|
||
$content = '抱歉,本次未能基于工具结果生成有效答复,请重试或调整提问方式。';
|
||
} else {
|
||
throw new \RuntimeException('AI 模型未返回有效内容,请检查所选模型配置');
|
||
}
|
||
}
|
||
$grounded = $this->ensureGroundedAnswer($context, $toolTrace, [
|
||
'content' => $content,
|
||
'reasoning' => $reasoning,
|
||
'model' => (string)($result['model'] ?? $lastModel),
|
||
'usage' => (array)($result['usage'] ?? $lastUsage),
|
||
]);
|
||
$content = $grounded['content'];
|
||
$reasoning = $grounded['reasoning'];
|
||
$model = $grounded['model'];
|
||
$followups = $this->extractFollowups($content);
|
||
$this->agents->addMessage($conversationId, 'assistant', $content, '', [
|
||
'skills' => array_keys($context['skillFiles']),
|
||
'model' => $model,
|
||
'reasoning' => $reasoning,
|
||
'followups' => $followups,
|
||
'tool_calls' => $this->toolTraceNames($toolTrace),
|
||
]);
|
||
|
||
return [
|
||
'conversation_id' => $conversationId,
|
||
'content' => $content,
|
||
'reasoning' => $reasoning,
|
||
'model' => $model,
|
||
'usage' => $grounded['usage'],
|
||
'tool_calls' => $toolTrace,
|
||
'skills' => $context['skillsUsed'],
|
||
'followups' => $followups,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 流式对话(SSE):思维链与正文逐块推送,工具调用轮次间同样流式
|
||
*
|
||
* 事件约定(由调用方以回调形式下发到客户端):
|
||
* - {event: start, conversation_id} 会话就绪
|
||
* - {event: skills, skills} 本轮挂载的 Skill 清单(工具循环开始即下发)
|
||
* - {event: reasoning, text} 思维链增量
|
||
* - {event: delta, text} 正文增量
|
||
* - {event: tool, name, arguments, result} 一次工具调用及其结果
|
||
* - {event: done, tool_calls, skills} 对话结束
|
||
*
|
||
* @param int $adminId 管理员 ID
|
||
* @param int $conversationId 会话 ID(0 表示新建会话)
|
||
* @param string $message 用户消息
|
||
* @param int $modelId 模型 ID(0 = 内置一号通默认)
|
||
* @param array $serverIds 选中的连接器(MCP Server)ID 列表
|
||
* @param array $skillKeys 选中的 Skill 标识列表
|
||
* @param callable $send 事件推送回调 function(array $payload): void
|
||
*/
|
||
public function chatStream(
|
||
int $adminId,
|
||
int $conversationId,
|
||
string $message,
|
||
int $modelId = 0,
|
||
array $serverIds = [],
|
||
array $skillKeys = [],
|
||
?callable $send = null
|
||
): void {
|
||
$message = trim($message);
|
||
if ($message === '') {
|
||
throw new \RuntimeException('请输入对话内容');
|
||
}
|
||
if (mb_strlen($message) > self::MAX_MESSAGE_LENGTH) {
|
||
throw new \RuntimeException('对话内容不能超过 ' . self::MAX_MESSAGE_LENGTH . ' 字');
|
||
}
|
||
$send = $send ?: static function (array $payload): void {
|
||
};
|
||
|
||
if ($conversationId > 0) {
|
||
$this->agents->assertConversation($conversationId, $adminId);
|
||
} else {
|
||
$conversationId = $this->agents->createConversation(0, $adminId, $message);
|
||
}
|
||
$this->agents->addMessage($conversationId, 'user', $message);
|
||
// 新用户消息到达:旧的待确认操作自动过期,避免旧确认卡与新上下文错位
|
||
$this->agents->expirePendingConfirms($conversationId);
|
||
|
||
$send(['event' => 'start', 'conversation_id' => $conversationId]);
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* 流式工具调用循环:从模型流式读取到解析工具调用、执行(含确认拦截)直至最终答复。
|
||
*
|
||
* @param int $conversationId 会话 ID
|
||
* @param array $context prepareContext 构建的对话上下文
|
||
* @param callable $send SSE 事件推送回调
|
||
* @param array $confirmContext 确认快照上下文(model_id/server_ids/skill_keys),拦截需确认工具时落库
|
||
* @param string $openingTrace 起始过程提示文案
|
||
* @param array $initialToolTrace 确认续跑前已执行的工具结果
|
||
*/
|
||
private function streamLoop(
|
||
int $conversationId,
|
||
array $context,
|
||
callable $send,
|
||
array $confirmContext,
|
||
string $openingTrace = '正在分析你的问题',
|
||
array $initialToolTrace = []
|
||
): void {
|
||
// confirm_stream 在进入循环前已经按用户确认执行过一个工具。
|
||
// 该结果必须带入,后续模型偶发空响应时仍能给出确定性结果,不能误报模型配置失败。
|
||
$toolTrace = $initialToolTrace;
|
||
$attempted = [];
|
||
// 叙述中断续跑计数:最多注入 2 次系统提示要求模型继续,防止死循环
|
||
$nudges = 0;
|
||
// 上游 200 空响应重试计数:透明重试 1 次,仍为空才报错
|
||
$emptyRetries = 0;
|
||
// 残缺工具调用 JSON 修复计数:输出被截断导致 JSON 无法解析时注入系统提示重试,限 1 次
|
||
$repairRetries = 0;
|
||
$skillsUsed = $context['skillsUsed'];
|
||
// 部分模型(如普通 Chat 模型)不会返回 reasoning_content;补充可审计的处理过程,
|
||
// 既保证页面有实时反馈,也不伪造或暴露模型内部思维链。
|
||
$processTrace = [];
|
||
// 手动停止兜底:前端终止会断开 SSE 连接,正常完成路径的落库不再执行。
|
||
// 必须先关闭「客户端断开即终止脚本」的默认行为(默认 Off 时 FPM 在 echo 瞬间直接杀脚本,
|
||
// 下方 connection_aborted 检测根本不会执行),改为由输出出口主动检测:
|
||
// 断开时先落库部分回复再 exit;若代理层迟迟未上报断开,脚本正常跑完并完整落库,同样不丢记录。
|
||
@ignore_user_abort(true);
|
||
// lastAcc 实时累积已推送给前端的可见正文/思维链,避免「手动停止后回复记录丢失」。
|
||
$lastAcc = ['content' => '', 'reasoning' => ''];
|
||
$responsePersisted = false;
|
||
$savePartialReply = function () use ($conversationId, &$skillsUsed, &$toolTrace, &$processTrace, &$lastAcc, &$responsePersisted): void {
|
||
$content = trim($lastAcc['content']);
|
||
if ($responsePersisted || $content === '') {
|
||
return;
|
||
}
|
||
$this->agents->addMessage($conversationId, 'assistant', $content, '', [
|
||
'skills' => $skillsUsed,
|
||
'reasoning' => $this->mergeReasoning($processTrace, trim($lastAcc['reasoning'])),
|
||
'followups' => [],
|
||
'tool_calls' => $this->toolTraceNames($toolTrace),
|
||
]);
|
||
};
|
||
$send = function (array $payload) use ($send, $savePartialReply): void {
|
||
$send($payload);
|
||
if (connection_aborted()) {
|
||
$savePartialReply();
|
||
exit;
|
||
}
|
||
};
|
||
// 已加载的 Skill(key+中文名)一开始即下发(显式选择的或确认续跑预载的);按需加载的随加载动态更新
|
||
$send(['event' => 'skills', 'skills' => $skillsUsed]);
|
||
$trace = function (string $text) use (&$processTrace, $send): void {
|
||
$processTrace[] = $text;
|
||
$send(['event' => 'reasoning', 'text' => $text . "\n"]);
|
||
};
|
||
$trace($openingTrace);
|
||
|
||
// 流式读取一轮:思考自然语言安全透传,正文完整累积后再区分最终答复与工具调用。
|
||
$streamOnce = function (array $prompts) use (&$context, $send, &$lastAcc): array {
|
||
$acc = ['content' => '', 'rawContent' => '', 'reasoning' => '', 'suppressed' => false];
|
||
$filter = $this->newStreamFilter();
|
||
$reasoningFilter = $this->newReasoningFilter();
|
||
$privateCallFilter = $this->newPrivateCallFilter();
|
||
$onReasoning = function (string $text) use (&$acc, &$reasoningFilter, &$privateCallFilter, &$lastAcc, $send): void {
|
||
if ($text === '') {
|
||
return;
|
||
}
|
||
$acc['reasoning'] .= $text;
|
||
// 同步到断开兜底容器(手动停止时按此落库部分回复)
|
||
$lastAcc['reasoning'] .= $text;
|
||
$this->filterPrivateCallChunk($privateCallFilter, $text, function (string $safeText) use (&$reasoningFilter, $send): void {
|
||
$this->filterReasoningChunk($reasoningFilter, $safeText, static function (string $visibleText) use ($send): void {
|
||
$send(['event' => 'reasoning', 'text' => $visibleText]);
|
||
});
|
||
});
|
||
};
|
||
$onContent = function (string $text) use (&$acc, &$lastAcc, $send): void {
|
||
if ($text === '') {
|
||
return;
|
||
}
|
||
$acc['content'] .= $text;
|
||
// 同步到断开兜底容器(手动停止时按此落库部分回复)
|
||
$lastAcc['content'] .= $text;
|
||
$send(['event' => 'delta', 'text' => $text]);
|
||
};
|
||
AiProtocol::chatStream(
|
||
$context['driver'],
|
||
$context['workingMessage'],
|
||
function (array $delta) use ($onReasoning, $onContent, &$filter, &$acc): void {
|
||
$text = (string)($delta['text'] ?? '');
|
||
if (($delta['type'] ?? 'content') === 'reasoning') {
|
||
$onReasoning($text);
|
||
return;
|
||
}
|
||
// rawContent 始终保留,避免纯 JSON 被展示过滤器抑制后无法执行。
|
||
$acc['rawContent'] .= $text;
|
||
$this->filterStreamChunk($filter, $text, $onReasoning, $onContent);
|
||
},
|
||
['prompts' => $prompts]
|
||
);
|
||
// 流结束:冲刷跨块截断的残留缓冲
|
||
$this->flushStreamFilter($filter, $onReasoning, $onContent);
|
||
$this->flushPrivateCallFilter($privateCallFilter, function (string $safeText) use (&$reasoningFilter, $send): void {
|
||
$this->filterReasoningChunk($reasoningFilter, $safeText, static function (string $visibleText) use ($send): void {
|
||
$send(['event' => 'reasoning', 'text' => $visibleText]);
|
||
});
|
||
});
|
||
$this->flushReasoningFilter($reasoningFilter, static function (string $safeText) use ($send): void {
|
||
$send(['event' => 'reasoning', 'text' => $safeText]);
|
||
});
|
||
$acc['suppressed'] = (bool)($filter['blocked'] ?? false);
|
||
return $acc;
|
||
};
|
||
|
||
for ($step = 0; $step <= self::MAX_STEPS; $step++) {
|
||
$acc = $streamOnce($context['prompts']);
|
||
$content = trim((string)$acc['rawContent']);
|
||
$inlineReasoning = $this->extractThinkBlocks($content);
|
||
$reasoning = trim((string)$acc['reasoning']);
|
||
if ($reasoning === '') {
|
||
$reasoning = $inlineReasoning;
|
||
}
|
||
$toolCall = $this->resolveToolCall($content, $reasoning);
|
||
if ($content === '' && !$toolCall) {
|
||
// 部分 OpenAI 兼容网关会偶发返回 HTTP 200 的空 SSE 流,但同一请求的
|
||
// 非流式响应正常。先透明重试一次,再降级为非流式请求取回实际答复。
|
||
if ($emptyRetries < 1) {
|
||
$emptyRetries++;
|
||
$trace('模型本次未返回内容,正在重试');
|
||
continue;
|
||
}
|
||
$fallback = $context['driver']->chat($context['workingMessage'], [
|
||
'prompts' => $context['prompts'],
|
||
'stream' => 0,
|
||
]);
|
||
$content = trim((string)($fallback['content'] ?? ''));
|
||
$fallbackReasoning = trim((string)($fallback['reasoning'] ?? ''));
|
||
if ($fallbackReasoning !== '') {
|
||
$reasoning = $fallbackReasoning;
|
||
}
|
||
$toolCall = $this->resolveToolCall($content, $reasoning);
|
||
if ($content === '' && !$toolCall) {
|
||
Log::error('[AgentChat] 上游空响应', [
|
||
'message_len' => strlen($context['workingMessage']),
|
||
'prompts_len' => array_sum(array_map('strlen', $context['prompts'])),
|
||
'has_reasoning' => $reasoning !== '',
|
||
]);
|
||
throw new \RuntimeException($reasoning !== ''
|
||
? 'AI 模型仅返回了思考过程、未产出正文,常见原因是推理模型耗尽 max_tokens,请在「AI Agent → 模型管理」调大输出上限或更换模型'
|
||
: 'AI 模型未返回有效内容,请稍后重试或检查所选模型配置');
|
||
}
|
||
$trace('流式响应为空,已切换为非流式响应继续处理');
|
||
}
|
||
|
||
if (!$toolCall) {
|
||
// 剥离前先识别原始输出是否为工具调用 JSON(含起点标记但解析失败/未闭合,多为被 max_tokens 截断)
|
||
$brokenToolCall = str_contains($content, '{"tool_call"') || str_contains($content, '<|FunctionCallBegin|>');
|
||
$content = $this->stripToolCallJson($content);
|
||
if ($content === '') {
|
||
// 模型只输出了无法解析的工具调用 JSON:先注入系统提示要求重新输出(限一次自愈),
|
||
// 仍失败再优雅兜底,避免整个请求报错
|
||
if ($brokenToolCall && $repairRetries < 1) {
|
||
$repairRetries++;
|
||
$trace('模型输出的工具调用 JSON 不完整(可能被输出长度截断),正在要求模型重新输出');
|
||
$context['workingMessage'] .= "\n\n系统提示:你上一条回复输出的工具调用 JSON 不完整或无法解析(常见原因是输出被长度截断)。请重新输出:需要继续操作时,只输出一个完整且精简的工具调用 JSON(display 从简,不要输出其他叙述);已有信息足够时,直接输出包含完整结果的最终答复。禁止重复输出残缺内容。";
|
||
continue;
|
||
}
|
||
if ($toolTrace !== []) {
|
||
Log::error('[AgentChat] 模型仅输出残缺工具调用 JSON,已兜底', ['raw_head' => mb_substr((string)$acc['rawContent'], 0, 500)]);
|
||
$content = '抱歉,本次未能基于工具结果生成有效答复,请重试或调整提问方式。';
|
||
} else {
|
||
Log::error('[AgentChat] 模型仅输出无法解析的内容', ['raw_head' => mb_substr((string)$acc['rawContent'], 0, 500)]);
|
||
throw new \RuntimeException('AI 模型未返回有效内容,请检查所选模型配置');
|
||
}
|
||
}
|
||
// 正文提问兜底:模型未走 crmeb_ask_user 而以正文直接向用户提问时,
|
||
// 由服务端把该回复强制转为问询卡片,确保用户端弹出输入框(不依赖模型自觉)。
|
||
// 提前剥离追问 JSON 防止混入卡片问题,结果暂存供最终答复复用,避免标签丢失
|
||
$earlyFollowups = $this->extractFollowups($content);
|
||
if ($content === '') {
|
||
// 整轮输出被剥离后只剩追问 JSON 等空内容:兜底话术,避免发出空答复
|
||
$content = '抱歉,本次未能生成有效答复,请重试或调整提问方式。';
|
||
}
|
||
if ($context['definitions'] !== [] && $this->looksLikeQuestionToUser($content)) {
|
||
$confirm = $this->forceAskUserCard($conversationId, $content, $context, $confirmContext);
|
||
$responsePersisted = true;
|
||
$trace('检测到模型以正文直接提问,已转为卡片提问');
|
||
$send([
|
||
'event' => 'confirm',
|
||
'message_id' => $confirm['message_id'],
|
||
'tool_name' => McpToolService::ASK_USER_TOOL,
|
||
'arguments' => $confirm['arguments'],
|
||
'interaction' => 'ask_user',
|
||
'display' => $confirm['display'],
|
||
]);
|
||
$send(['event' => 'done', 'tool_calls' => $toolTrace, 'skills' => $skillsUsed]);
|
||
return;
|
||
}
|
||
// 叙述中断检测:模型输出了「接下来我将…」式计划后未给出工具调用就停笔,
|
||
// 直接结束会让用户看到"说了要继续却突然没了"。注入系统提示要求继续,有限重试。
|
||
if ($context['definitions'] !== [] && $nudges < 2 && $this->looksLikeUnfinishedNarration($content)) {
|
||
$nudges++;
|
||
$context['workingMessage'] .= "\n\n系统提示:你上一条回复只输出了计划性叙述(如「接下来我将……」),没有输出工具调用 JSON,流程已中断。请立即继续:"
|
||
. "还需要查询或操作数据时,只输出一个工具调用 JSON(格式见工具清单说明);信息已足够时,直接输出包含完整结果的最终答复。"
|
||
. "禁止再输出任何计划性叙述而不采取实际动作。";
|
||
$trace('检测到回复在计划叙述后中断,正在让模型继续');
|
||
continue;
|
||
}
|
||
$grounded = $this->ensureGroundedAnswer($context, $toolTrace, [
|
||
'content' => $content,
|
||
'reasoning' => $reasoning,
|
||
'model' => '',
|
||
'usage' => [],
|
||
]);
|
||
$content = $grounded['content'];
|
||
$reasoning = $this->mergeReasoning($processTrace, $grounded['reasoning']);
|
||
$followups = $this->extractFollowups($content);
|
||
if ($followups === []) {
|
||
$followups = $earlyFollowups ?? [];
|
||
}
|
||
// 先落库最终答复再推送校正内容:即使推送时客户端断开(手动停止),记录也已保存
|
||
$this->agents->addMessage($conversationId, 'assistant', $content, '', [
|
||
'skills' => $skillsUsed,
|
||
'reasoning' => $reasoning,
|
||
'followups' => $followups,
|
||
'tool_calls' => $this->toolTraceNames($toolTrace),
|
||
]);
|
||
$responsePersisted = true;
|
||
$this->syncStreamFinalAnswer($send, $acc, $content);
|
||
if ($followups !== []) {
|
||
$send(['event' => 'followups', 'questions' => $followups]);
|
||
}
|
||
$send(['event' => 'done', 'tool_calls' => $toolTrace, 'skills' => $skillsUsed]);
|
||
return;
|
||
}
|
||
|
||
if ($step >= self::MAX_STEPS) {
|
||
break;
|
||
}
|
||
|
||
$toolName = $toolCall['name'];
|
||
$arguments = $toolCall['arguments'];
|
||
$trace("已识别需要实时数据,准备调用工具:{$toolName}");
|
||
// 相同调用(工具名+参数)只允许尝试一次,防止模型对无效工具原样重试烧完配额
|
||
$signature = $toolName . '|' . json_encode($arguments, JSON_UNESCAPED_UNICODE);
|
||
if (isset($attempted[$signature])) {
|
||
$context['workingMessage'] .= "\n\n系统提示:工具 {$toolName} 的相同调用已经执行过,结果已在上文。请勿重复调用,直接依据已有结果给出最终答复。";
|
||
break;
|
||
}
|
||
$attempted[$signature] = true;
|
||
$this->agents->addMessage(
|
||
$conversationId,
|
||
'assistant',
|
||
$this->canonicalToolCall($toolName, $arguments),
|
||
$toolName,
|
||
['arguments' => $arguments]
|
||
);
|
||
// 需用户交互的工具:暂停执行,推送交互事件等待用户确认/作答(confirm 消息承载快照与恢复上下文)
|
||
$interaction = $this->resolveInteraction($toolName, $context['definitions']);
|
||
if ($interaction !== null) {
|
||
$display = $this->buildCardDisplay($toolCall, $context['definitions']);
|
||
$confirmMessageId = $this->agents->addMessage(
|
||
$conversationId,
|
||
'confirm',
|
||
$this->canonicalToolCall($toolName, $arguments),
|
||
$toolName,
|
||
[
|
||
'arguments' => $arguments,
|
||
'status' => 'pending',
|
||
'interaction' => $interaction,
|
||
'display' => $display,
|
||
'model_id' => (int)($confirmContext['model_id'] ?? 0),
|
||
'server_ids' => (array)($confirmContext['server_ids'] ?? []),
|
||
'skill_keys' => (array)($confirmContext['skill_keys'] ?? []),
|
||
'skill_loaded' => array_keys($context['skillFiles']),
|
||
'allowed_tools' => $context['allowedTools'],
|
||
]
|
||
);
|
||
$responsePersisted = true;
|
||
$trace($interaction === 'ask_user' ? "需要用户补充信息,已发起提问:{$toolName}" : "工具 {$toolName} 涉及数据修改,已暂停等待用户确认");
|
||
$send([
|
||
'event' => 'confirm',
|
||
'message_id' => $confirmMessageId,
|
||
'tool_name' => $toolName,
|
||
'arguments' => $arguments,
|
||
'interaction' => $interaction,
|
||
'display' => $display,
|
||
]);
|
||
$send(['event' => 'done', 'tool_calls' => $toolTrace, 'skills' => $skillsUsed]);
|
||
return;
|
||
}
|
||
if (in_array($toolName, $context['allowedTools'], true)) {
|
||
try {
|
||
// 无人值守邮件收件校验:仅允许任务通知邮箱与系统白名单
|
||
$this->guardUnattendedEmail($toolName, $arguments, $context);
|
||
// 本地虚拟工具(Skill 按需加载/自动化任务管理):拦截在 MCP 分发之前
|
||
if ($toolName === self::SKILL_LOAD_TOOL) {
|
||
$toolResult = $this->loadSkillForContext($context, (string)($arguments['key'] ?? ''), (string)($arguments['file'] ?? ''));
|
||
if (empty($toolResult['error'])) {
|
||
// loadSkillForContext 已更新清单;附件加载不改变 Skill 清单。
|
||
$skillsUsed = $context['skillsUsed'];
|
||
$send(['event' => 'skills', 'skills' => $skillsUsed]);
|
||
$skillName = (string)($toolResult['skill']['name'] ?? ($arguments['key'] ?? ''));
|
||
if (!empty($toolResult['file'])) {
|
||
$trace("已加载 Skill [{$skillName}] 的附属资料:{$toolResult['file']}");
|
||
} else {
|
||
$trace("检测到问题匹配 Skill 领域,规则 [{$skillName}] 已加载");
|
||
}
|
||
}
|
||
} elseif ($toolName === McpToolService::TASK_CREATE_TOOL) {
|
||
$toolResult = $this->createTaskForContext((int)($context['admin_id'] ?? 0), $arguments);
|
||
$trace(empty($toolResult['error']) ? "自动化任务 [{$toolResult['name']}] 已创建并启用" : '自动化任务创建失败');
|
||
} elseif ($toolName === McpToolService::TASK_LIST_TOOL) {
|
||
$toolResult = $this->listTasksForContext((int)($context['admin_id'] ?? 0), $arguments);
|
||
$trace('已查询自动化任务清单');
|
||
} else {
|
||
$toolResult = $this->mcp->call($toolName, $arguments, $context['allowedTools']);
|
||
}
|
||
} catch (\Throwable $e) {
|
||
$toolResult = ['error' => true, 'message' => $e->getMessage()];
|
||
}
|
||
} else {
|
||
// 幻觉工具名:附上可用清单引导模型纠正,而不是让它反复猜测
|
||
$toolResult = [
|
||
'error' => true,
|
||
'message' => "工具 {$toolName} 不在本对话可用工具清单中,禁止调用。",
|
||
'valid_tools' => $context['allowedTools'],
|
||
];
|
||
}
|
||
$this->agents->addMessage(
|
||
$conversationId,
|
||
'tool',
|
||
json_encode($toolResult, JSON_UNESCAPED_UNICODE),
|
||
$toolName,
|
||
['arguments' => $arguments, 'result' => $toolResult]
|
||
);
|
||
$toolTrace[] = ['name' => $toolName, 'arguments' => $arguments, 'result' => $toolResult];
|
||
$trace(
|
||
is_array($toolResult) && empty($toolResult['error'])
|
||
? "工具 {$toolName} 已返回结果,正在整理回答"
|
||
: "工具 {$toolName} 调用失败,正在根据已有信息继续处理"
|
||
);
|
||
// 已输出的文字只是本轮工具调用前言,前端收到 tool 后会清掉并等待工具结果后的正式答复。
|
||
// 工具调用前的正文片段,前端会随 tool 事件转入思考区展示:后端同步并入断开兜底的 reasoning,
|
||
// 避免断开落库时这些前言碎片混入正文
|
||
$lastAcc['reasoning'] .= ($lastAcc['reasoning'] !== '' && $lastAcc['content'] !== '' ? "\n" : '') . $lastAcc['content'];
|
||
$lastAcc['content'] = '';
|
||
$send(['event' => 'tool', 'name' => $toolName, 'arguments' => $arguments, 'result' => $toolResult]);
|
||
$context['workingMessage'] .= $this->buildToolResultContext($toolName, $toolResult);
|
||
}
|
||
|
||
// 工具轮次达上限:强制输出最终答复
|
||
$finalPrompts = $context['prompts'];
|
||
$finalPrompts[] = '工具调用阶段已结束。上文已经包含工具返回结果,请直接严格依据结果给出最终答复,不要再输出工具调用 JSON,也不要声称未收到工具结果。';
|
||
$trace('正在基于工具结果生成最终答复');
|
||
$acc = $streamOnce($finalPrompts);
|
||
$content = trim((string)$acc['rawContent']);
|
||
$inlineReasoning = $this->extractThinkBlocks($content);
|
||
$reasoning = trim((string)$acc['reasoning']);
|
||
if ($reasoning === '') {
|
||
$reasoning = $inlineReasoning;
|
||
}
|
||
$content = $this->stripToolCallJson($content);
|
||
if ($content === '') {
|
||
// 强制答复轮仍只输出工具 JSON:已有工具结果时优雅兜底
|
||
if ($toolTrace !== []) {
|
||
$content = '抱歉,本次未能基于工具结果生成有效答复,请重试或调整提问方式。';
|
||
} else {
|
||
throw new \RuntimeException('AI 模型未返回有效内容,请检查所选模型配置');
|
||
}
|
||
}
|
||
$grounded = $this->ensureGroundedAnswer($context, $toolTrace, [
|
||
'content' => $content,
|
||
'reasoning' => $reasoning,
|
||
'model' => '',
|
||
'usage' => [],
|
||
]);
|
||
$content = $grounded['content'];
|
||
$reasoning = $this->mergeReasoning($processTrace, $grounded['reasoning']);
|
||
$followups = $this->extractFollowups($content);
|
||
$this->agents->addMessage($conversationId, 'assistant', $content, '', [
|
||
'skills' => $skillsUsed,
|
||
'reasoning' => $reasoning,
|
||
'followups' => $followups,
|
||
'tool_calls' => $this->toolTraceNames($toolTrace),
|
||
]);
|
||
$responsePersisted = true;
|
||
$this->syncStreamFinalAnswer($send, $acc, $content);
|
||
if ($followups !== []) {
|
||
$send(['event' => 'followups', 'questions' => $followups]);
|
||
}
|
||
$send(['event' => 'done', 'tool_calls' => $toolTrace, 'skills' => $skillsUsed]);
|
||
}
|
||
|
||
/**
|
||
* 流式处理用户对交互卡片的决定(SSE,事件类型与 chatStream 一致)。
|
||
*
|
||
* 按 interaction 类型分流:
|
||
* - execute:approve=按 confirm 消息中的参数快照执行工具,结果作为权威事实继续工具调用循环直至最终答复;
|
||
* reject=不执行工具、不调用模型,直接落库固定话术(确定性强,节省一次模型调用)
|
||
* - ask_user:approve=把用户提交的答案(answers/custom)作为工具结果回给模型继续循环;
|
||
* reject=以「用户未回答」工具结果回给模型继续循环,模型可自行调整
|
||
* 循环中若再次命中需交互工具,会再次推送 confirm 事件(多步确认链式进行)
|
||
*
|
||
* @param int $adminId 管理员 ID
|
||
* @param int $conversationId 会话 ID
|
||
* @param int $messageId confirm 消息 ID
|
||
* @param string $action approve=确认/作答 / reject=取消/跳过
|
||
* @param array $answers ask_user 场景用户选中的选项 value 列表
|
||
* @param string $custom ask_user 场景用户自定义输入文本
|
||
* @param callable|null $send SSE 事件推送回调
|
||
*/
|
||
public function confirmStream(
|
||
int $adminId,
|
||
int $conversationId,
|
||
int $messageId,
|
||
string $action,
|
||
array $answers = [],
|
||
string $custom = '',
|
||
?callable $send = null
|
||
): void {
|
||
$send = $send ?: static function (array $payload): void {
|
||
};
|
||
$message = $this->agents->getPendingConfirm($conversationId, $messageId, $adminId);
|
||
$payload = $message['tool_payload'];
|
||
$toolName = (string)$message['tool_name'];
|
||
$arguments = is_array($payload['arguments'] ?? null) ? $payload['arguments'] : [];
|
||
$interaction = (string)($payload['interaction'] ?? 'execute');
|
||
|
||
// 执行类卡片被拒绝:固定话术,不调用模型
|
||
if ($interaction !== 'ask_user' && $action !== 'approve') {
|
||
$this->agents->updateConfirmStatus($messageId, 'rejected');
|
||
$content = '好的,已取消执行「' . $toolName . '」操作,系统未做任何修改。如需继续,请告诉我调整后的要求。';
|
||
$this->agents->addMessage($conversationId, 'assistant', $content);
|
||
$send(['event' => 'delta', 'text' => $content]);
|
||
$send(['event' => 'done', 'tool_calls' => [], 'skills' => []]);
|
||
return;
|
||
}
|
||
|
||
$this->agents->updateConfirmStatus($messageId, $action === 'approve' ? 'approved' : 'rejected');
|
||
// 以确认时快照重建上下文(连接器/Skill/模型),执行参数以快照为准,不重新让模型生成;
|
||
// 确认前已加载的 Skill 一并预载,模型无需重复调用加载工具
|
||
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'
|
||
? $this->buildAskUserAnswer($arguments, $answers, $custom)
|
||
: ['status' => 'skipped', 'message' => '用户未回答该问题,请基于已有信息继续,不要重复追问同一问题'];
|
||
$this->agents->addMessage(
|
||
$conversationId,
|
||
'tool',
|
||
json_encode($toolResult, JSON_UNESCAPED_UNICODE),
|
||
$toolName,
|
||
['arguments' => $arguments, 'result' => $toolResult]
|
||
);
|
||
$send(['event' => 'tool', 'name' => $toolName, 'arguments' => $arguments, 'result' => $toolResult]);
|
||
$context['workingMessage'] .= $this->buildToolResultContext($toolName, $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;
|
||
}
|
||
|
||
// 执行类卡片确认:按快照执行工具(本地虚拟工具在 MCP 分发之前拦截)
|
||
$allowed = is_array($payload['allowed_tools'] ?? null) && $payload['allowed_tools'] !== []
|
||
? $payload['allowed_tools']
|
||
: $context['allowedTools'];
|
||
try {
|
||
$toolResult = match (true) {
|
||
$toolName === McpToolService::TASK_CREATE_TOOL => $this->createTaskForContext($adminId, $arguments),
|
||
$toolName === McpToolService::TASK_LIST_TOOL => $this->listTasksForContext($adminId, $arguments),
|
||
default => $this->mcp->call($toolName, $arguments, $allowed),
|
||
};
|
||
} catch (\Throwable $e) {
|
||
$toolResult = ['error' => true, 'message' => $e->getMessage()];
|
||
}
|
||
$this->agents->addMessage(
|
||
$conversationId,
|
||
'tool',
|
||
json_encode($toolResult, JSON_UNESCAPED_UNICODE),
|
||
$toolName,
|
||
['arguments' => $arguments, 'result' => $toolResult]
|
||
);
|
||
$send(['event' => 'tool', 'name' => $toolName, 'arguments' => $arguments, 'result' => $toolResult]);
|
||
$context['workingMessage'] .= $this->buildToolResultContext($toolName, $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);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 把用户对问询卡片的作答规范化为工具结果(选项 value 映射回 value+label,附自定义输入)
|
||
* @param array $arguments 问询工具入参(含 options 定义)
|
||
* @param array $answers 用户选中的选项 value 列表
|
||
* @param string $custom 用户自定义输入文本
|
||
* @return array
|
||
*/
|
||
private function buildAskUserAnswer(array $arguments, array $answers, string $custom): array
|
||
{
|
||
$options = [];
|
||
foreach ((array)($arguments['options'] ?? []) as $option) {
|
||
if (is_array($option)) {
|
||
$value = trim((string)($option['value'] ?? ''));
|
||
$label = trim((string)($option['label'] ?? ''));
|
||
} else {
|
||
$value = trim((string)$option);
|
||
$label = $value;
|
||
}
|
||
if ($value !== '') {
|
||
$options[$value] = ['value' => $value, 'label' => $label !== '' ? $label : $value];
|
||
}
|
||
}
|
||
$selected = [];
|
||
foreach ($answers as $answer) {
|
||
$value = trim((string)$answer);
|
||
if (isset($options[$value])) {
|
||
$selected[] = $options[$value];
|
||
}
|
||
}
|
||
$custom = mb_substr(trim($custom), 0, 500);
|
||
$summary = [];
|
||
foreach ($selected as $item) {
|
||
$summary[] = $item['label'];
|
||
}
|
||
if ($custom !== '') {
|
||
$summary[] = '自定义:' . $custom;
|
||
}
|
||
return [
|
||
'status' => 'answered',
|
||
'selected' => $selected,
|
||
'custom' => $custom,
|
||
'message' => '用户已作答' . ($summary !== [] ? ':' . implode(';', $summary) : '(未选择任何选项)')
|
||
. '。请依据该结果继续,不要重复提问',
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 工具需要的用户交互类型(依据本次对话挂载的工具定义标记)
|
||
* @param string $toolName 工具名
|
||
* @param array $definitions 本次对话挂载的工具定义
|
||
* @return string|null execute=确认后执行 / ask_user=用户作答后回传 / null=无需交互直接执行
|
||
*/
|
||
private function resolveInteraction(string $toolName, array $definitions): ?string
|
||
{
|
||
foreach ($definitions as $definition) {
|
||
if (($definition['name'] ?? '') === $toolName) {
|
||
if (empty($definition['requires_confirm'])) {
|
||
return null;
|
||
}
|
||
$interaction = (string)($definition['interaction'] ?? 'execute');
|
||
return $interaction !== '' ? $interaction : 'execute';
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* 交互卡片的中文展示信息:优先使用模型输出的 display(中文标题+参数说明),缺失时降级为原始字段
|
||
* @param array $toolCall parseToolCall 解析结果
|
||
* @return array{title:string,params:array}
|
||
*/
|
||
private function buildCardDisplay(array $toolCall, array $definitions = []): array
|
||
{
|
||
$display = is_array($toolCall['display'] ?? null) ? $toolCall['display'] : [];
|
||
$title = trim((string)($display['title'] ?? ''));
|
||
$params = is_array($display['params'] ?? null) ? $display['params'] : [];
|
||
// 模型未输出中文说明时,用工具定义自带的中文元数据兜底(描述→标题、入参 Schema 说明→参数标签),
|
||
// 避免确认卡降级直出工具名与英文字段名
|
||
if ($title === '' || $params === []) {
|
||
$definition = $this->findDefinitionByName((string)($toolCall['name'] ?? ''), $definitions);
|
||
if ($definition !== null) {
|
||
if ($title === '') {
|
||
$title = $this->displayTitleFromDescription((string)($definition['description'] ?? ''));
|
||
}
|
||
if ($params === []) {
|
||
$params = $this->displayParamsFromSchema(
|
||
is_array($toolCall['arguments'] ?? null) ? $toolCall['arguments'] : [],
|
||
is_array($definition['inputSchema'] ?? null) ? $definition['inputSchema'] : []
|
||
);
|
||
}
|
||
}
|
||
}
|
||
if ($params === []) {
|
||
// 最终降级:参数名直出
|
||
$params = is_array($toolCall['arguments'] ?? null) ? $toolCall['arguments'] : [];
|
||
}
|
||
return ['title' => $title, 'params' => $params];
|
||
}
|
||
|
||
/**
|
||
* 按名称在工具定义清单中查找定义
|
||
* @param string $name 工具名
|
||
* @param array $definitions 工具定义清单
|
||
* @return array|null
|
||
*/
|
||
private function findDefinitionByName(string $name, array $definitions): ?array
|
||
{
|
||
foreach ($definitions as $definition) {
|
||
if (($definition['name'] ?? '') === $name) {
|
||
return $definition;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* 从工具中文描述提炼确认卡标题(取首个句号/分号/换行前主干句子;首句仍超长时按逗号取主干,截断兜底)
|
||
* 如「修改系统设置(系统配置)中指定配置项的值;需传入…」→「修改系统设置(系统配置)中指定配置项的值」
|
||
* 如「修改第三方驱动的配置项值,支持短信/邮件/…。需传入…」→「修改第三方驱动的配置项值」
|
||
* @param string $description 工具中文描述
|
||
* @return string
|
||
*/
|
||
private function displayTitleFromDescription(string $description): string
|
||
{
|
||
$main = preg_split('/[。;;\n]/u', trim($description))[0] ?? '';
|
||
$main = trim((string)$main);
|
||
if (mb_strlen($main) > 30) {
|
||
$main = trim((string)(preg_split('/[,,]/u', $main)[0] ?? $main));
|
||
}
|
||
if (mb_strlen($main) > 30) {
|
||
$main = mb_substr($main, 0, 30) . '…';
|
||
}
|
||
return $main;
|
||
}
|
||
|
||
/**
|
||
* 用入参 Schema 的中文 description 作为确认卡参数标签(取首个逗号/括号/分号/冒号前的主干),无说明时退回字段名;
|
||
* 标签超长截断兜底,避免整段枚举说明(如「驱动类型:sms=短信 email=邮件…」)直接上卡
|
||
* 如「配置变量名,如 site_url」→ 标签「配置变量名」;「驱动启停状态:1=启用 0=禁用」→ 标签「驱动启停状态」
|
||
* @param array $arguments 工具入参
|
||
* @param array $schema 工具 inputSchema
|
||
* @return array<string,string> 参数标签 => 取值
|
||
*/
|
||
private function displayParamsFromSchema(array $arguments, array $schema): array
|
||
{
|
||
$properties = is_array($schema['properties'] ?? null) ? $schema['properties'] : [];
|
||
$params = [];
|
||
foreach ($arguments as $name => $value) {
|
||
$label = (string)$name;
|
||
$description = is_array($properties[$name] ?? null) ? trim((string)($properties[$name]['description'] ?? '')) : '';
|
||
if ($description !== '') {
|
||
$main = preg_split('/[,,((;;::]/u', $description)[0] ?? '';
|
||
$main = trim((string)$main);
|
||
if ($main !== '') {
|
||
$label = $main;
|
||
}
|
||
}
|
||
if (mb_strlen($label) > 12) {
|
||
$label = mb_substr($label, 0, 12) . '…';
|
||
}
|
||
$params[$label] = $this->displayValueOf((string)$name, $value);
|
||
}
|
||
return $params;
|
||
}
|
||
|
||
/**
|
||
* 确认卡参数取值展示:对象序列化为 JSON(嵌套的敏感键同样打码),敏感字段值不明文展示
|
||
* @param string $name 字段名
|
||
* @param mixed $value 字段值
|
||
* @return string
|
||
*/
|
||
private function displayValueOf(string $name, $value): string
|
||
{
|
||
if (is_array($value)) {
|
||
array_walk_recursive($value, function (&$v, $k) {
|
||
if (is_scalar($v) && self::isSensitiveField((string)$k)) {
|
||
$v = $this->maskSensitiveValue((string)$v);
|
||
}
|
||
});
|
||
return json_encode($value, JSON_UNESCAPED_UNICODE);
|
||
}
|
||
if (self::isSensitiveField($name)) {
|
||
return $this->maskSensitiveValue((string)$value);
|
||
}
|
||
return (string)$value;
|
||
}
|
||
|
||
/**
|
||
* 判断字段名是否为敏感字段(密码/授权码/密钥/令牌类),确认卡展示时需打码
|
||
* @param string $name 字段名
|
||
* @return bool
|
||
*/
|
||
private static function isSensitiveField(string $name): bool
|
||
{
|
||
return (bool)preg_match('/password|passwd|secret|auth[_-]?code|token|app[_-]?key|api[_-]?key|access[_-]?key/i', $name);
|
||
}
|
||
|
||
/**
|
||
* 敏感值打码:保留末 4 位便于用户核对,其余以 * 号遮蔽
|
||
* @param string $value 原始值
|
||
* @return string
|
||
*/
|
||
private function maskSensitiveValue(string $value): string
|
||
{
|
||
if ($value === '') {
|
||
return '';
|
||
}
|
||
return mb_strlen($value) <= 4 ? '******' : '******' . mb_substr($value, -4);
|
||
}
|
||
|
||
/**
|
||
* 创建思考过程过滤器:自然语言实时透传,工具调用 JSON 暂存并隐藏。
|
||
* @return array<string, mixed>
|
||
*/
|
||
private function newReasoningFilter(): array
|
||
{
|
||
return [
|
||
'pending' => '',
|
||
'inToolCall' => false,
|
||
'depth' => 0,
|
||
'inString' => false,
|
||
'escape' => false,
|
||
];
|
||
}
|
||
|
||
/** @return array{pending:string,inPrivateCall:bool} */
|
||
private function newPrivateCallFilter(): array
|
||
{
|
||
return ['pending' => '', 'inPrivateCall' => false];
|
||
}
|
||
|
||
/**
|
||
* 隐藏一号通可能输出的私有函数调用区块,兼容开始/结束标记跨数据块。
|
||
* @param array{pending:string,inPrivateCall:bool} $state
|
||
*/
|
||
private function filterPrivateCallChunk(array &$state, string $text, callable $emit): void
|
||
{
|
||
$begin = '<|FunctionCallBegin|>';
|
||
$end = '<|FunctionCallEnd|>';
|
||
$state['pending'] .= $text;
|
||
|
||
while ($state['pending'] !== '') {
|
||
if (!$state['inPrivateCall']) {
|
||
$position = strpos($state['pending'], $begin);
|
||
if ($position !== false) {
|
||
if ($position > 0) {
|
||
$emit(substr($state['pending'], 0, $position));
|
||
}
|
||
$state['pending'] = substr($state['pending'], $position + strlen($begin));
|
||
$state['inPrivateCall'] = true;
|
||
continue;
|
||
}
|
||
$holdLength = $this->reasoningMarkerSuffixLength($state['pending'], $begin);
|
||
$emitLength = strlen($state['pending']) - $holdLength;
|
||
if ($emitLength > 0) {
|
||
$emit(substr($state['pending'], 0, $emitLength));
|
||
}
|
||
$state['pending'] = $holdLength > 0 ? substr($state['pending'], -$holdLength) : '';
|
||
return;
|
||
}
|
||
|
||
$position = strpos($state['pending'], $end);
|
||
if ($position !== false) {
|
||
$state['pending'] = substr($state['pending'], $position + strlen($end));
|
||
$state['inPrivateCall'] = false;
|
||
continue;
|
||
}
|
||
// 私有调用区块内的内容全部丢弃,仅保留可能构成结束标记的后缀。
|
||
$holdLength = $this->reasoningMarkerSuffixLength($state['pending'], $end);
|
||
$state['pending'] = $holdLength > 0 ? substr($state['pending'], -$holdLength) : '';
|
||
return;
|
||
}
|
||
}
|
||
|
||
/** @param array{pending:string,inPrivateCall:bool} $state */
|
||
private function flushPrivateCallFilter(array &$state, callable $emit): void
|
||
{
|
||
if (!$state['inPrivateCall'] && $state['pending'] !== '') {
|
||
$emit($state['pending']);
|
||
}
|
||
$state['pending'] = '';
|
||
}
|
||
|
||
/**
|
||
* 实时过滤 reasoning 中的 {"tool_call":...},支持标记和 JSON 跨数据块。
|
||
* @param array<string, mixed> $state
|
||
* @param callable $emit function(string $safeText): void
|
||
*/
|
||
private function filterReasoningChunk(array &$state, string $text, callable $emit): void
|
||
{
|
||
$marker = '{"tool_call"';
|
||
$state['pending'] .= $text;
|
||
|
||
while ($state['pending'] !== '') {
|
||
if (!$state['inToolCall']) {
|
||
$position = strpos($state['pending'], $marker);
|
||
if ($position !== false) {
|
||
if ($position > 0) {
|
||
$emit(substr($state['pending'], 0, $position));
|
||
}
|
||
$state['pending'] = substr($state['pending'], $position);
|
||
$state['inToolCall'] = true;
|
||
$state['depth'] = 0;
|
||
$state['inString'] = false;
|
||
$state['escape'] = false;
|
||
continue;
|
||
}
|
||
|
||
$holdLength = $this->reasoningMarkerSuffixLength($state['pending'], $marker);
|
||
$emitLength = strlen($state['pending']) - $holdLength;
|
||
if ($emitLength > 0) {
|
||
$emit(substr($state['pending'], 0, $emitLength));
|
||
}
|
||
$state['pending'] = $holdLength > 0 ? substr($state['pending'], -$holdLength) : '';
|
||
return;
|
||
}
|
||
|
||
$buffer = $state['pending'];
|
||
$length = strlen($buffer);
|
||
for ($index = 0; $index < $length; $index++) {
|
||
$char = $buffer[$index];
|
||
if ($state['inString']) {
|
||
if ($state['escape']) {
|
||
$state['escape'] = false;
|
||
} elseif ($char === '\\') {
|
||
$state['escape'] = true;
|
||
} elseif ($char === '"') {
|
||
$state['inString'] = false;
|
||
}
|
||
continue;
|
||
}
|
||
if ($char === '"') {
|
||
$state['inString'] = true;
|
||
} elseif ($char === '{') {
|
||
$state['depth']++;
|
||
} elseif ($char === '}') {
|
||
$state['depth']--;
|
||
if ($state['depth'] === 0) {
|
||
$state['pending'] = substr($buffer, $index + 1);
|
||
$state['inToolCall'] = false;
|
||
$state['inString'] = false;
|
||
$state['escape'] = false;
|
||
continue 2;
|
||
}
|
||
}
|
||
}
|
||
// 工具 JSON 尚未闭合,状态已记录扫描进度;已处理内容无需继续保留。
|
||
$state['pending'] = '';
|
||
return;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 计算文本末尾与标记前缀的最长部分匹配长度(用于跨块截断的标签缓冲)
|
||
* @param string $text 文本内容
|
||
* @param string $marker 完整标记(如 think 闭合标签)
|
||
* @return int 匹配长度,最长为标记长度减一,无匹配返回 0
|
||
*/
|
||
private function reasoningMarkerSuffixLength(string $text, string $marker): int
|
||
{
|
||
$maximum = min(strlen($text), strlen($marker) - 1);
|
||
for ($length = $maximum; $length > 0; $length--) {
|
||
if (substr($text, -$length) === substr($marker, 0, $length)) {
|
||
return $length;
|
||
}
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
/** @param array<string, mixed> $state */
|
||
private function flushReasoningFilter(array &$state, callable $emit): void
|
||
{
|
||
// 未进入工具 JSON 时,pending 只是可能匹配标记的短前缀,应正常输出。
|
||
if (!$state['inToolCall'] && $state['pending'] !== '') {
|
||
$emit((string)$state['pending']);
|
||
}
|
||
$state['pending'] = '';
|
||
}
|
||
|
||
/**
|
||
* 创建流式内容过滤器状态
|
||
*
|
||
* @return array 过滤器状态(引用传递给 filterStreamChunk / flushStreamFilter)
|
||
*/
|
||
private function newStreamFilter(): array
|
||
{
|
||
return [
|
||
'inThink' => false, // 当前是否位于 <think> 标签内部
|
||
'pending' => '', // 跨块截断的标签前缀缓冲
|
||
'held' => '', // 抑制判定前暂存的正文
|
||
'decided' => false, // 是否已完成「是否疑似工具调用 JSON」判定
|
||
'blocked' => false, // 判定为疑似工具调用 JSON,整轮流式正文全部抑制
|
||
// 推荐追问 JSON 位于回答末尾,不能在最终提取前透传到前端。
|
||
// 用独立缓冲兼容 {"followups" 标记与 JSON 对象跨 SSE 数据块的情况。
|
||
'followupPending' => '',
|
||
'inFollowup' => false,
|
||
'followupDepth' => 0,
|
||
'followupInString' => false,
|
||
'followupEscape' => false,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 过滤器逐块处理正文增量
|
||
*
|
||
* 1. 推理模型可能不返回标准 reasoning 字段,而是把思维链以 <think>...</think>
|
||
* (含 </think_xxx> 等变体闭合标签)内嵌在正文流中:标签内文本改道为 reasoning 增量;
|
||
* 2. 工具调用 JSON(首段非空白字符为 { 或 ```)在流式阶段无法与正文区分,
|
||
* 一旦命中即整轮抑制正文外发,待流结束后由调用方统一判定(执行工具或补发正文)。
|
||
*
|
||
* @param array $state 过滤器状态
|
||
* @param string $text 本次到达的正文增量(标签可能跨块截断)
|
||
* @param callable $onReasoning function(string $text): void 思维链增量出站
|
||
* @param callable $onContent function(string $text): void 正文增量出站
|
||
*/
|
||
private function filterStreamChunk(array &$state, string $text, callable $onReasoning, callable $onContent): void
|
||
{
|
||
$buffer = ($state['pending'] ?? '') . $text;
|
||
$state['pending'] = '';
|
||
|
||
while ($buffer !== '') {
|
||
if ($state['inThink']) {
|
||
// 思维链模式:扫描闭合标签(兼容 </think_xxx> 变体)
|
||
$matched = preg_match('~</think[^>]*>~i', $buffer, $m, PREG_OFFSET_CAPTURE) ? $m[0] : null;
|
||
if ($matched === null) {
|
||
$hold = $this->streamHoldSuffix($buffer);
|
||
$emit = $hold === '' ? $buffer : substr($buffer, 0, strlen($buffer) - strlen($hold));
|
||
if ($emit !== '') {
|
||
$onReasoning($emit);
|
||
}
|
||
$state['pending'] = $hold;
|
||
return;
|
||
}
|
||
$pos = (int)$matched[1];
|
||
if ($pos > 0) {
|
||
$onReasoning(substr($buffer, 0, $pos));
|
||
}
|
||
$buffer = substr($buffer, $pos + strlen((string)$matched[0]));
|
||
$state['inThink'] = false;
|
||
continue;
|
||
}
|
||
|
||
// 正文模式:扫描 <think> 开标签
|
||
$matched = preg_match('~<think[^>]*>~i', $buffer, $m, PREG_OFFSET_CAPTURE) ? $m[0] : null;
|
||
// 部分兼容网关会漏掉开标签、只输出 </think_xxx>。闭合标记本身不能进入最终答复。
|
||
$closing = preg_match('~</think[^>]*>~i', $buffer, $close, PREG_OFFSET_CAPTURE) ? $close[0] : null;
|
||
if ($closing !== null && ($matched === null || (int)$closing[1] < (int)$matched[1])) {
|
||
$pos = (int)$closing[1];
|
||
if ($pos > 0) {
|
||
$this->emitStreamContent($state, substr($buffer, 0, $pos), $onContent);
|
||
}
|
||
$buffer = substr($buffer, $pos + strlen((string)$closing[0]));
|
||
continue;
|
||
}
|
||
if ($matched === null) {
|
||
$hold = $this->streamHoldSuffix($buffer);
|
||
$emit = $hold === '' ? $buffer : substr($buffer, 0, strlen($buffer) - strlen($hold));
|
||
if ($emit !== '') {
|
||
$this->emitStreamContent($state, $emit, $onContent);
|
||
}
|
||
$state['pending'] = $hold;
|
||
return;
|
||
}
|
||
$pos = (int)$matched[1];
|
||
if ($pos > 0) {
|
||
$this->emitStreamContent($state, substr($buffer, 0, $pos), $onContent);
|
||
}
|
||
$buffer = substr($buffer, $pos + strlen((string)$matched[0]));
|
||
$state['inThink'] = true;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 流结束后冲刷过滤器残留缓冲(跨块截断的未闭合标签前缀)
|
||
* @param array $state 过滤器状态
|
||
* @param callable $onReasoning function(string $text): void
|
||
* @param callable $onContent function(string $text): void
|
||
*/
|
||
private function flushStreamFilter(array &$state, callable $onReasoning, callable $onContent): void
|
||
{
|
||
$pending = (string)($state['pending'] ?? '');
|
||
if ($pending === '') {
|
||
$this->flushFollowupStreamContent($state, $onContent);
|
||
return;
|
||
}
|
||
$state['pending'] = '';
|
||
if ($state['inThink']) {
|
||
$onReasoning($pending);
|
||
} else {
|
||
$this->emitStreamContent($state, $pending, $onContent);
|
||
}
|
||
$this->flushFollowupStreamContent($state, $onContent);
|
||
}
|
||
|
||
/**
|
||
* 过滤器正文出站:完成「疑似工具调用 JSON」抑制判定
|
||
* @param array $state 过滤器状态
|
||
* @param string $text 待出站正文
|
||
* @param callable $onContent function(string $text): void
|
||
*/
|
||
private function emitStreamContent(array &$state, string $text, callable $onContent): void
|
||
{
|
||
if ($text === '') {
|
||
return;
|
||
}
|
||
if (!($state['decided'])) {
|
||
// 判定前全部暂存,直到首段非空白内容可判定走向
|
||
$state['held'] .= $text;
|
||
$trimmed = ltrim((string)$state['held']);
|
||
if ($trimmed === '') {
|
||
return;
|
||
}
|
||
$state['decided'] = true;
|
||
$first = $trimmed[0];
|
||
if ($first === '{' || str_starts_with($trimmed, '```')) {
|
||
// 疑似工具调用 JSON:整轮抑制正文外发
|
||
$state['blocked'] = true;
|
||
return;
|
||
}
|
||
$this->emitFollowupSafeContent($state, (string)$state['held'], $onContent);
|
||
$state['held'] = '';
|
||
return;
|
||
}
|
||
if (!($state['blocked'])) {
|
||
$this->emitFollowupSafeContent($state, $text, $onContent);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 输出正文时拦截模型附在尾部的推荐追问 JSON。
|
||
* 原始内容仍由调用方累计,流结束后 extractFollowups() 会解析并作为独立 SSE 事件发送。
|
||
*/
|
||
private function emitFollowupSafeContent(array &$state, string $text, callable $onContent): void
|
||
{
|
||
$marker = '{"followups"';
|
||
$buffer = (string)($state['followupPending'] ?? '') . $text;
|
||
$state['followupPending'] = '';
|
||
|
||
while ($buffer !== '') {
|
||
if (!empty($state['inFollowup'])) {
|
||
$length = strlen($buffer);
|
||
for ($index = 0; $index < $length; $index++) {
|
||
$char = $buffer[$index];
|
||
if (!empty($state['followupInString'])) {
|
||
if (!empty($state['followupEscape'])) {
|
||
$state['followupEscape'] = false;
|
||
} elseif ($char === '\\') {
|
||
$state['followupEscape'] = true;
|
||
} elseif ($char === '"') {
|
||
$state['followupInString'] = false;
|
||
}
|
||
continue;
|
||
}
|
||
if ($char === '"') {
|
||
$state['followupInString'] = true;
|
||
} elseif ($char === '{') {
|
||
$state['followupDepth']++;
|
||
} elseif ($char === '}') {
|
||
$state['followupDepth']--;
|
||
if ($state['followupDepth'] === 0) {
|
||
$state['inFollowup'] = false;
|
||
$state['followupInString'] = false;
|
||
$state['followupEscape'] = false;
|
||
$buffer = substr($buffer, $index + 1);
|
||
continue 2;
|
||
}
|
||
}
|
||
}
|
||
// 追问对象尚未结束:整段继续抑制,等待下一个 SSE 数据块。
|
||
return;
|
||
}
|
||
|
||
$position = strpos($buffer, $marker);
|
||
if ($position !== false) {
|
||
if ($position > 0) {
|
||
$onContent(substr($buffer, 0, $position));
|
||
}
|
||
$state['inFollowup'] = true;
|
||
$state['followupDepth'] = 0;
|
||
$state['followupInString'] = false;
|
||
$state['followupEscape'] = false;
|
||
$buffer = substr($buffer, $position);
|
||
continue;
|
||
}
|
||
|
||
// 标记可能被 SSE 块切开,暂存可构成标记前缀的尾部字符。
|
||
$keepLength = $this->followupMarkerSuffixLength($buffer, $marker);
|
||
if ($keepLength > 0) {
|
||
$onContent(substr($buffer, 0, -$keepLength));
|
||
$state['followupPending'] = substr($buffer, -$keepLength);
|
||
} else {
|
||
$onContent($buffer);
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
|
||
/** 计算文本尾部与追问标记前缀的最长匹配,供跨 SSE 块缓冲。 */
|
||
private function followupMarkerSuffixLength(string $text, string $marker): int
|
||
{
|
||
$maximum = min(strlen($text), strlen($marker) - 1);
|
||
for ($length = $maximum; $length > 0; $length--) {
|
||
if (substr($text, -$length) === substr($marker, 0, $length)) {
|
||
return $length;
|
||
}
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
/** 流结束时仅补发普通正文残留;未闭合的追问 JSON 一律不透传。 */
|
||
private function flushFollowupStreamContent(array &$state, callable $onContent): void
|
||
{
|
||
if (empty($state['inFollowup']) && ($state['followupPending'] ?? '') !== '') {
|
||
$onContent((string)$state['followupPending']);
|
||
}
|
||
$state['followupPending'] = '';
|
||
}
|
||
|
||
/**
|
||
* 保留缓冲区尾部可能构成标签的前缀(自最后一个未闭合的 < 起)
|
||
* @param string $buffer 当前缓冲
|
||
* @return string 需要留待下一块拼接的后缀,无需保留时返回空串
|
||
*/
|
||
private function streamHoldSuffix(string $buffer): string
|
||
{
|
||
$lastLt = strrpos($buffer, '<');
|
||
if ($lastLt === false) {
|
||
return '';
|
||
}
|
||
$suffix = substr($buffer, $lastLt);
|
||
// 已闭合(含 >)说明不是跨块截断的标签前缀
|
||
if (strpos($suffix, '>') !== false) {
|
||
return '';
|
||
}
|
||
return $suffix;
|
||
}
|
||
|
||
/**
|
||
* 准备对话上下文:连接器/Skill 挂载、提示词与转录、模型驱动
|
||
* @param int $conversationId 会话 ID
|
||
* @param array $serverIds 连接器 ID 列表
|
||
* @param array $skillKeys Skill 标识列表
|
||
* @param int $modelId 模型 ID
|
||
* @param array $preloadSkillKeys 确认续跑场景预载的 Skill 标识(仅收窄到目录内,避免模型重复加载)
|
||
* @param array $runtimeOptions 运行时选项(unattended/autonomy,见 chat() 注释)
|
||
* @return array{definitions:array,allowedTools:array,skillCatalog:array,skillFiles:array,skillsUsed:array,prompts:array,workingMessage:string,driver:\crmeb\services\ai\AiInterface}
|
||
*/
|
||
private function prepareContext(int $conversationId, array $serverIds, array $skillKeys, int $modelId, array $preloadSkillKeys = [], array $runtimeOptions = [], int $adminId = 0): array
|
||
{
|
||
$unattended = !empty($runtimeOptions['unattended']);
|
||
$autonomy = (int)($runtimeOptions['autonomy'] ?? 1);
|
||
// 未指定连接器时自动挂载全部可用项(内置工具 + 启用中的远程 Server)
|
||
if ($serverIds === []) {
|
||
$serverIds = array_map('intval', array_column($this->mcp->serverOptions(), 'id'));
|
||
}
|
||
$skillFiles = [];
|
||
$catalog = [];
|
||
if ($skillKeys === []) {
|
||
// 未指定 Skill 时不做全文注入:仅下发目录(标识|名称|说明),模型判断问题相关后
|
||
// 经 crmeb_skill_load 按需加载全文规则,避免全部 Skill 全文占用 token
|
||
$catalog = array_map(fn(array $skill) => [
|
||
'key' => $skill['key'],
|
||
'name' => $skill['name'],
|
||
'description' => $skill['description'],
|
||
], array_values(array_filter(
|
||
$this->skills->all(),
|
||
fn(array $skill) => (int)$skill['status'] === 1
|
||
)));
|
||
// 确认续跑场景:恢复确认前已加载的 Skill(仅收窄到目录内)
|
||
$skillFiles = $this->skills->load(array_values(array_intersect(
|
||
array_map('strval', $preloadSkillKeys),
|
||
array_column($catalog, 'key')
|
||
)));
|
||
} else {
|
||
// 显式选择的 Skill 语义为「确定要用」:直接全文注入,无需二次加载
|
||
$skillFiles = $this->skills->load($skillKeys);
|
||
}
|
||
$definitions = $this->mcp->definitionsForServers($serverIds, $unattended, $autonomy);
|
||
if ($catalog !== []) {
|
||
$definitions[] = $this->skillLoadDefinition();
|
||
}
|
||
// 已加载 Skill 的展示清单(key + 中文名),随 skills 事件下发前端标签展示
|
||
$skillNames = array_column($this->skills->all(), 'name', 'key');
|
||
$skillsUsed = [];
|
||
foreach (array_keys($skillFiles) as $key) {
|
||
$skillsUsed[] = ['key' => $key, 'name' => (string)($skillNames[$key] ?? $key)];
|
||
}
|
||
$recent = $this->agents->recentMessages($conversationId, 50);
|
||
if (!in_array('user', array_column($recent, 'role'), true)) {
|
||
$latestUser = $this->agents->latestUserMessage($conversationId);
|
||
if ($latestUser !== null) {
|
||
array_unshift($recent, $latestUser);
|
||
}
|
||
}
|
||
return [
|
||
'admin_id' => $adminId,
|
||
'definitions' => $definitions,
|
||
'allowedTools' => array_column($definitions, 'name'),
|
||
'skillCatalog' => $catalog,
|
||
'skillFiles' => $skillFiles,
|
||
'skillsUsed' => $skillsUsed,
|
||
'unattended' => $unattended,
|
||
// 任务登记的通知邮箱:无人值守邮件发送仅允许此处与系统收件白名单
|
||
'notify_email' => (string)($runtimeOptions['notify_email'] ?? ''),
|
||
// 本轮已加载的 Skill 附属资料(key:file => true),用于附件重复加载幂等
|
||
'skillAttachmentFiles' => [],
|
||
'prompts' => $this->buildPrompts($definitions, $skillFiles, $catalog, $unattended),
|
||
'workingMessage' => $this->buildTranscript($recent),
|
||
'driver' => app()->make(AiModelServices::class)->getChatHandler($modelId),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 无人值守邮件收件校验:自动化任务执行时,邮件仅允许发往任务登记的通知邮箱
|
||
* 与系统「邮件收件白名单」(aiagent_email_recipients,逗号/分号/换行分隔)中的地址,
|
||
* 防止模型把工具查询到的业务数据发往任意外部邮箱;两者都未配置时禁止发送。
|
||
* 人工对话场景不走本校验(发送前已有确认卡供用户核对收件人)。
|
||
* @param string $toolName 工具名(builtin.crmeb_email_send)
|
||
* @param array $arguments 工具入参
|
||
* @param array $context 对话上下文(unattended/notify_email)
|
||
* @throws \RuntimeException 收件地址不在允许范围时抛出
|
||
*/
|
||
private function guardUnattendedEmail(string $toolName, array $arguments, array $context): void
|
||
{
|
||
if (empty($context['unattended'])) {
|
||
return;
|
||
}
|
||
if ($toolName !== self::EMAIL_TOOL && !str_ends_with($toolName, '.' . self::EMAIL_TOOL)) {
|
||
return;
|
||
}
|
||
$to = strtolower(trim((string)($arguments['to'] ?? '')));
|
||
$taskEmail = strtolower(trim((string)($context['notify_email'] ?? '')));
|
||
$allowed = $taskEmail !== '' ? [$taskEmail] : [];
|
||
$whitelist = preg_split('/[,;\n\r]+/', (string) sys_config('aiagent_email_recipients', '')) ?: [];
|
||
foreach ($whitelist as $item) {
|
||
$item = strtolower(trim((string)$item));
|
||
if ($item !== '') {
|
||
$allowed[] = $item;
|
||
}
|
||
}
|
||
if ($to === '' || !in_array($to, array_unique($allowed), true)) {
|
||
throw new \RuntimeException($taskEmail !== ''
|
||
? "无人值守任务仅允许把邮件发送到任务登记的通知邮箱({$taskEmail})或系统邮件收件白名单中的地址"
|
||
: '无人值守任务未登记通知邮箱且系统未配置邮件收件白名单,禁止发送邮件;请在任务配置中登记收件邮箱');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 组装系统提示词(AGENTS.md 兜底规则 + Skill 规则 + 可用工具说明)
|
||
* @param array $definitions 本次对话挂载的工具定义
|
||
* @param array $skillFiles 本次全文注入的 Skill(key => 正文)
|
||
* @param array $catalog 按需加载的 Skill 目录(key/name/description),非空时注入目录提示
|
||
* @param bool $unattended 无人值守模式(自动化任务执行),注入执行者角色提示并禁用提问类指引
|
||
* @return array<int, string>
|
||
*/
|
||
private function buildPrompts(array $definitions, array $skillFiles, array $catalog = [], bool $unattended = false): array
|
||
{
|
||
$prompts = [];
|
||
// 当前时间锚点:模型自身无法获取日期(无人值守下更无上下文可推断),
|
||
// 不注入会导致邮件落款等时间表述照抄 Skill 文档示例日期(如 2026-06-01)
|
||
$prompts[] = '当前系统时间:' . date('Y-m-d H:i') . ',凡涉及「今天/当前/生成时间」等时间表述一律以此为准,禁止照抄任何文档示例日期。';
|
||
if ($unattended) {
|
||
// 无人值守执行者角色提示:先于其他规则注入,明确任务边界与交付纪律,
|
||
// 避免模型把任务指令当作需求咨询来回答(输出方案说明而非实际执行)
|
||
$prompts[] = '运行模式(最高优先级):你是无人值守的定时任务执行器,调度器只负责按频率唤起你,'
|
||
. '任务指令中关于执行频率/时间的描述(如「每10分钟执行一次」)由调度器兑现,与你无关——本次调用只需完成一个周期的实际工作后立即结束。'
|
||
. '禁止向用户提问或等待确认(无人应答,ask_user 类工具不可用),信息不足时按任务上下文与合理默认值继续执行;'
|
||
. '必须优先调用工具完成实际业务动作(查询数据、执行操作),禁止只输出方案说明、实现建议或「系统能力不足」类答复;'
|
||
. '任务要求产出数据类结果(统计/报表/清单)时,必须先调用数据查询类工具取得真实数据,再基于查询结果交付,禁止用占位描述或虚构数据充当结果;'
|
||
. '任务涉及结果送达(发送邮件/推送通知)时,完成业务动作后必须调用对应交付工具(如 builtin.crmeb_email_send / builtin.crmeb_qywechat_send)'
|
||
. '把最终结果完整发送出去,收件目标以任务描述为准。';
|
||
}
|
||
// 兜底规则:恒定最先注入 .crmeb/AGENTS.md(随代码发布,不可在后台删停)
|
||
$agentsRule = $this->loadAgentsRule();
|
||
if ($agentsRule !== '') {
|
||
$prompts[] = "以下是本系统的智能体基础规则(AGENTS.md),请始终遵循:\n{$agentsRule}";
|
||
}
|
||
foreach ($skillFiles as $key => $content) {
|
||
$prompts[] = "以下是当前启用的 Skill [{$key}],请遵循其中的工作规则:\n{$content}";
|
||
}
|
||
if ($catalog !== []) {
|
||
$lines = [];
|
||
foreach ($catalog as $skill) {
|
||
$lines[] = '- ' . $skill['key'] . ' | ' . $skill['name'] . ' | ' . $skill['description'];
|
||
}
|
||
$prompts[] = "以下是可按需加载的 Skill 目录(格式:标识 | 名称 | 说明):\n" . implode("\n", $lines) . "\n"
|
||
. '当判断当前问题涉及某个 Skill 的领域时,先调用工具 ' . self::SKILL_LOAD_TOOL
|
||
. ' 加载其完整工作规则({"tool_call":{"name":"' . self::SKILL_LOAD_TOOL . '","arguments":{"key":"目录中的标识"}}}),拿到规则后再继续;'
|
||
. '部分 Skill 附带资料附件:加载结果中的 attachments 列出可用附件,需要更完整资料时在 arguments 中加 file 加载,同一附件只需加载一次;'
|
||
. '问题与所有 Skill 均无关时禁止加载;同一 Skill 只需加载一次。';
|
||
}
|
||
// 输出纪律:非推理模型常把思维链混进正文输出,正文中的独白前端无法与最终答复区分,故强约束
|
||
$prompts[] = '输出纪律(最高优先级):回复正文只允许包含面向用户的最终答复。'
|
||
. '一切内部推敲、犹豫、自我质疑与纠错(如「不对」「再看看」「等一下」之类的自言自语)必须在你内部完成,'
|
||
. '绝对禁止写入正文;也不要以过程性叙述(如「让我看看」「我需要先确认」)开场,'
|
||
. '正文第一句就必须是正式回答或工具调用 JSON。';
|
||
$prompts[] = '回答完成后,如存在用户可能想继续追问的问题,在回答正文之后另起一行输出一行 JSON:'
|
||
. '{"followups":["问题一","问题二","问题三"]};'
|
||
. '问题必须与本次回答主题相关、口语化、简短(不超过30字)、可直接作为新提问发送;最多 3 个;'
|
||
. '无合适的追问时不要输出该 JSON。除该 JSON 外不要输出任何多余内容。';
|
||
if ($definitions) {
|
||
$prompts[] = "你可以调用以下 MCP 工具:\n"
|
||
. json_encode($definitions, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)
|
||
. "\n需要调用工具时,只输出一个 JSON 对象,不要使用 Markdown:"
|
||
. '{"tool_call":{"name":"工具名","arguments":{}}}'
|
||
. ';调用会修改数据的工具(requires_confirm:true)时必须附带 display 字段,用中文向用户展示操作标题与参数说明,'
|
||
. '格式:{"tool_call":{"name":"工具名","arguments":{},"display":{"title":"中文操作标题","params":{"中文参数名":"中文取值说明"}}}},'
|
||
. 'title 与 params 禁止出现工具名、字段名、枚举值等技术词汇;'
|
||
. 'params 每项取值必须是 20 字以内的中文摘要,正文/内容类参数只写内容概括(如「平台用户统计汇总报表 HTML 正文」),'
|
||
. '禁止把正文全文、HTML 源码或长文本原文放进 params(确认卡会基于 arguments 自动提供内容渲染预览)。'
|
||
. ($unattended
|
||
? '本次为无人值守执行,ask_user 类提问工具不可用,信息不足时按合理默认值继续执行,禁止在正文中向用户提问。'
|
||
: '需要用户补充信息(如时间范围、筛选条件)、做出选择或在执行前拍板时,必须调用 crmeb_ask_user 工具发起卡片提问'
|
||
. '(自由填写用 type:text,系统会弹出输入框/选择卡,用户提交后答案回传),'
|
||
. '禁止在正文中直接向用户提问并等待回复——纯文本提问不会弹出输入框。')
|
||
. '每次只输出一个调用,禁止在一条回复中连续输出多个调用 JSON。'
|
||
. '拿到工具结果后,再继续回答或调用下一个工具。'
|
||
. '工具名必须与上述清单完全一致,禁止调用未列出的工具;同一调用失败后禁止原样重复,应换用清单中的其他工具或直接回答。'
|
||
. '除工具调用 JSON 外,回答必须直接给出结论,禁止输出内心独白、推理过程或自我纠错文本。'
|
||
. '禁止输出 <|FunctionCallBegin|>、<|FunctionCallEnd|> 等任何平台私有函数调用标记,统一使用上述 JSON 格式。';
|
||
} else {
|
||
$prompts[] = '当前对话没有挂载任何工具,请直接依据已有知识回答;'
|
||
. '禁止输出 <|FunctionCallBegin|>、<|FunctionCallEnd|> 等任何函数调用标记。'
|
||
. '禁止在回复中输出思考过程、内部推理或复述会话记录原文,直接给出最终答复。';
|
||
}
|
||
return $prompts;
|
||
}
|
||
|
||
/**
|
||
* Skill 按需加载虚拟工具定义:目录非空时追加到工具清单,模型按目录判断后调用
|
||
* 支持 file 参数加载 Skill 目录内的附属资料文件(如完整索引、文档清单)
|
||
* @return array{name:string,description:string,inputSchema:array}
|
||
*/
|
||
private function skillLoadDefinition(): array
|
||
{
|
||
return [
|
||
'name' => self::SKILL_LOAD_TOOL,
|
||
'description' => '加载指定 Skill 的完整工作规则(规则以工具结果返回,仅本对话生效)。'
|
||
. '仅当问题涉及 Skill 目录中某项领域时调用;一次只加载一个,同一 Skill 只需加载一次。'
|
||
. '部分 Skill 附带资料附件:加载结果的 attachments 列出可用附件,'
|
||
. '需要更完整的资料(完整清单、官方文档链接等)时,再在 arguments 中传 file 加载对应附件。',
|
||
'inputSchema' => [
|
||
'type' => 'object',
|
||
'required' => ['key'],
|
||
'properties' => [
|
||
'key' => ['type' => 'string', 'description' => 'Skill 标识,必须取自 Skill 目录中的标识'],
|
||
'file' => ['type' => 'string', 'description' => '可选:附属资料文件名,必须取自该 Skill 加载结果 attachments 中的 file;不传时加载 Skill 工作规则本身'],
|
||
],
|
||
],
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 执行 Skill 按需加载(本地虚拟工具,拦截在 MCP 分发之前)
|
||
* 不传 file 时加载 Skill 工作规则全文(写入 context.skillFiles 随 prompts 生效);
|
||
* 传 file 时加载 Skill 目录内的附属资料文件(内容仅随工具结果进入上下文,同附件幂等)
|
||
* @param array $context 对话上下文(skillFiles/skillAttachmentFiles 以引用方式更新)
|
||
* @param string $key 模型传入的 Skill 标识
|
||
* @param string $file 可选附属资料文件名(取自 attachments 列表)
|
||
* @return array 工具结果(含 skill 元信息、内容与可用附件清单)
|
||
*/
|
||
private function loadSkillForContext(array &$context, string $key, string $file = ''): array
|
||
{
|
||
$key = trim($key);
|
||
$meta = null;
|
||
foreach ($context['skillCatalog'] as $skill) {
|
||
if ($skill['key'] === $key) {
|
||
$meta = $skill;
|
||
break;
|
||
}
|
||
}
|
||
if ($meta === null) {
|
||
return [
|
||
'error' => true,
|
||
'message' => "Skill {$key} 不在本对话的 Skill 目录中,禁止调用。",
|
||
'valid_skills' => array_column($context['skillCatalog'], 'key'),
|
||
];
|
||
}
|
||
// 附件加载:文件名必须取自 Skill 目录附属资料,加载成功仅随工具结果进入上下文
|
||
$file = trim($file);
|
||
if ($file !== '') {
|
||
$attachmentKey = $key . ':' . $file;
|
||
if (!empty($context['skillAttachmentFiles'][$attachmentKey])) {
|
||
$loaded = $this->skills->loadAttachment($key, $file);
|
||
if ($loaded !== null) {
|
||
return ['skill' => $meta, 'file' => $file, 'already_loaded' => true, 'content' => $loaded['content']];
|
||
}
|
||
}
|
||
$loaded = $this->skills->loadAttachment($key, $file);
|
||
if ($loaded === null) {
|
||
return [
|
||
'error' => true,
|
||
'message' => "Skill {$key} 不存在附属资料 {$file},可用附件以加载 Skill 时返回的 attachments 列表为准。",
|
||
'attachments' => $this->skills->attachments($key),
|
||
];
|
||
}
|
||
$context['skillAttachmentFiles'][$attachmentKey] = true;
|
||
return ['skill' => $meta, 'file' => $file, 'content' => $loaded['content']];
|
||
}
|
||
if (isset($context['skillFiles'][$key])) {
|
||
// 重复加载幂等:直接返回已加载内容,不重复写上下文
|
||
return ['skill' => $meta, 'already_loaded' => true, 'content' => $context['skillFiles'][$key], 'attachments' => $this->skills->attachments($key)];
|
||
}
|
||
$loaded = $this->skills->load([$key]);
|
||
if (!isset($loaded[$key])) {
|
||
return ['error' => true, 'message' => "Skill {$key} 已停用或不存在,请改用目录中的其他能力或直接回答"];
|
||
}
|
||
$context['skillFiles'][$key] = $loaded[$key];
|
||
$context['skillsUsed'][] = ['key' => $key, 'name' => (string)$meta['name']];
|
||
$context['prompts'] = $this->buildPrompts(
|
||
$context['definitions'], $context['skillFiles'], $context['skillCatalog'], (bool)$context['unattended']
|
||
);
|
||
return ['skill' => $meta, 'content' => $loaded[$key], 'attachments' => $this->skills->attachments($key)];
|
||
}
|
||
|
||
/**
|
||
* 创建自动化任务(本地虚拟工具,拦截在 MCP 分发之前,处理人工对话场景)
|
||
* 由模型把用户的周期性诉求落成任务定义:默认只读自主级别、创建即启用;
|
||
* server_ids 留空,无人值守执行时 prepareContext 自动挂载全部可用连接器
|
||
* @param int $adminId 管理员 ID(任务归属)
|
||
* @param array $arguments 模型入参(name/instruction/schedule_type/schedule_value/notify_channel/notify_email)
|
||
* @return array 工具结果
|
||
*/
|
||
private function createTaskForContext(int $adminId, array $arguments): array
|
||
{
|
||
$channel = (string)($arguments['notify_channel'] ?? 'is_email');
|
||
if (!in_array($channel, ['is_email', 'is_ent_wechat'], true)) {
|
||
$channel = 'is_email';
|
||
}
|
||
$email = trim((string)($arguments['notify_email'] ?? ''));
|
||
if ($channel === 'is_email' && $email === '') {
|
||
return ['error' => true, 'message' => '推送渠道为邮件时必须提供收件邮箱,请先向用户询问后再创建任务'];
|
||
}
|
||
$tasks = app()->make(AgentTaskServices::class);
|
||
$taskId = $tasks->save($adminId, [
|
||
'name' => (string)($arguments['name'] ?? ''),
|
||
'instruction' => (string)($arguments['instruction'] ?? ''),
|
||
'autonomy' => AgentTaskServices::AUTONOMY_READONLY,
|
||
'schedule_type' => (int)($arguments['schedule_type'] ?? AgentTaskServices::SCHEDULE_DAILY),
|
||
'schedule_value' => (string)($arguments['schedule_value'] ?? ''),
|
||
'notify_channels' => [$channel],
|
||
'notify_email' => $email,
|
||
'status' => 1,
|
||
]);
|
||
$task = $tasks->read($taskId, $adminId);
|
||
return [
|
||
'success' => true,
|
||
'task_id' => $taskId,
|
||
'name' => (string)$task['name'],
|
||
'schedule' => $this->scheduleText((int)$task['schedule_type'], (string)$task['schedule_value']),
|
||
'next_run_text' => (int)$task['next_run_time'] > 0 ? date('Y-m-d H:i', (int)$task['next_run_time']) : '',
|
||
'message' => '自动化任务已创建并启用,到期由调度器自动执行',
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 查询当前管理员的自动化任务清单(本地虚拟工具,只读直执行)
|
||
* @param int $adminId 管理员 ID
|
||
* @param array $arguments 模型入参(page 可选)
|
||
* @return array 工具结果
|
||
*/
|
||
private function listTasksForContext(int $adminId, array $arguments): array
|
||
{
|
||
$result = app()->make(AgentTaskServices::class)->index($adminId, max(1, (int)($arguments['page'] ?? 1)), 20);
|
||
$list = array_map(fn(array $task): array => [
|
||
'id' => (int)$task['id'],
|
||
'name' => (string)$task['name'],
|
||
'instruction' => mb_substr((string)$task['instruction'], 0, 80),
|
||
'schedule' => $this->scheduleText((int)$task['schedule_type'], (string)$task['schedule_value']),
|
||
'status' => (int)$task['status'] === 1 ? '启用' : '停用',
|
||
'next_run_text' => (int)$task['next_run_time'] > 0 ? date('Y-m-d H:i', (int)$task['next_run_time']) : '—',
|
||
'last_run_text' => (int)$task['last_run_time'] > 0 ? date('Y-m-d H:i', (int)$task['last_run_time']) : '—',
|
||
], $result['list']);
|
||
return ['count' => $result['count'], 'list' => $list];
|
||
}
|
||
|
||
/**
|
||
* 调度类型与值转中文描述(供模型向用户转述)
|
||
* @param int $type 调度类型
|
||
* @param string $value 调度值
|
||
* @return string
|
||
*/
|
||
private function scheduleText(int $type, string $value): string
|
||
{
|
||
$weekNames = ['1' => '一', '2' => '二', '3' => '三', '4' => '四', '5' => '五', '6' => '六', '7' => '日'];
|
||
return match ($type) {
|
||
AgentTaskServices::SCHEDULE_MINUTES => "每隔 {$value} 分钟",
|
||
AgentTaskServices::SCHEDULE_DAILY => "每天 {$value}",
|
||
AgentTaskServices::SCHEDULE_WEEKLY => (isset($weekNames[$value[0] ?? '']) ? "每周{$weekNames[$value[0]]} " . substr($value, 2) : "每周调度 {$value}"),
|
||
AgentTaskServices::SCHEDULE_MONTHLY => "每月 " . str_replace('|', ' 日 ', $value) . ' 执行',
|
||
AgentTaskServices::SCHEDULE_YEARLY => "每年 " . str_replace('|', ' 月 ', substr($value, 0, strrpos($value, '|'))) . ' 日 ' . substr($value, strrpos($value, '|') + 1) . ' 执行',
|
||
default => "调度 {$value}",
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 读取 .crmeb/AGENTS.md 兜底规则(文件缺失时返回空串,不阻断对话)
|
||
* @return string
|
||
*/
|
||
private function loadAgentsRule(): string
|
||
{
|
||
$file = dirname(__DIR__) . DIRECTORY_SEPARATOR . '.crmeb' . DIRECTORY_SEPARATOR . 'AGENTS.md';
|
||
if (!is_file($file)) {
|
||
return '';
|
||
}
|
||
return $this->limitText((string)file_get_contents($file), 16000);
|
||
}
|
||
|
||
/**
|
||
* 将历史消息构建为纯文本会话转录(作为非对话接口的上下文注入)
|
||
* @param array $messages 历史消息列表
|
||
* @return string 会话转录文本
|
||
*/
|
||
private function buildTranscript(array $messages): string
|
||
{
|
||
$labels = ['user' => '用户', 'assistant' => '助手', 'tool' => '工具'];
|
||
// 历史消息按角色限长:当前轮工具结果由 workingMessage 追加提供全文,历史工具结果只保留摘要,
|
||
// 避免大结果(如全量配置清单)常驻转录把请求上下文撑爆、触发上游空响应
|
||
$limits = ['user' => 4000, 'assistant' => 4000, 'tool' => 600];
|
||
$messages = array_values(array_filter($messages, static fn(array $message) => ($message['role'] ?? '') !== 'confirm'));
|
||
$lastUser = -1;
|
||
foreach ($messages as $index => $message) {
|
||
if (($message['role'] ?? '') === 'user') {
|
||
$lastUser = $index;
|
||
}
|
||
}
|
||
if ($lastUser >= 0) {
|
||
// 历史只保留可见问答;当前轮保留工具调用及结果,且始终锚定原问题。
|
||
$previous = array_values(array_filter(array_slice($messages, 0, $lastUser), static fn(array $message) =>
|
||
in_array($message['role'] ?? '', ['user', 'assistant'], true) && empty($message['tool_name'])
|
||
));
|
||
$current = array_slice($messages, $lastUser);
|
||
if (count($current) > 16) {
|
||
$current = array_merge([$current[0]], array_slice($current, -15));
|
||
}
|
||
$messages = array_merge(array_slice($previous, -8), $current);
|
||
} else {
|
||
$messages = array_slice($messages, -16);
|
||
}
|
||
$lines = ['以下是当前会话记录,请回答最后一条用户消息:'];
|
||
foreach ($messages as $message) {
|
||
$role = $labels[$message['role']] ?? $message['role'];
|
||
$limit = !empty($message['tool_name']) && $message['role'] === 'assistant'
|
||
? 600 : ($limits[$message['role']] ?? 4000);
|
||
$content = $this->limitText((string)$message['content'], $limit);
|
||
$lines[] = "{$role}: {$content}";
|
||
}
|
||
return implode("\n\n", $lines);
|
||
}
|
||
|
||
/**
|
||
* 同时兼容普通模型与推理模型的工具调用位置。
|
||
* 标准情况下优先解析正文;仅当正文为空时,才从 reasoning 中兜底提取,
|
||
* 避免把推理里用于举例的 JSON 误执行。
|
||
*
|
||
* @return array{name:string,arguments:array}|null
|
||
*/
|
||
private function resolveToolCall(string $content, string $reasoning): ?array
|
||
{
|
||
$toolCall = $this->parseToolCall($content);
|
||
if ($toolCall || trim($content) !== '') {
|
||
return $toolCall;
|
||
}
|
||
return $this->parseToolCall($reasoning);
|
||
}
|
||
|
||
/** @param array<string, mixed> $arguments */
|
||
private function canonicalToolCall(string $toolName, array $arguments): string
|
||
{
|
||
return (string)json_encode([
|
||
'tool_call' => [
|
||
'name' => $toolName,
|
||
'arguments' => $arguments,
|
||
],
|
||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||
}
|
||
|
||
/**
|
||
* 将工具结果作为权威事实追加到当轮上下文,并显式提供可核对的数据摘要。
|
||
* 这段文本同时适用于一号通和 OpenAI 兼容的自定义模型。
|
||
* Skill 附属资料特殊处理:内容为纯 Markdown,直接注入(不走 JSON 转义)并放宽截断上限,避免长资料被截断。
|
||
*
|
||
* @param mixed $toolResult
|
||
*/
|
||
private function buildToolResultContext(string $toolName, $toolResult): string
|
||
{
|
||
if ($toolName === self::SKILL_LOAD_TOOL
|
||
&& is_array($toolResult)
|
||
&& empty($toolResult['error'])
|
||
&& empty($toolResult['file'])) {
|
||
$key = (string)($toolResult['skill']['key'] ?? '');
|
||
$attachments = (string)json_encode($toolResult['attachments'] ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||
return "\n\n[系统提供的权威工具结果]\nSkill [{$key}] 已加载到本轮系统规则中。"
|
||
. "\n可用附属资料:{$attachments}\n[/系统提供的权威工具结果]\n"
|
||
. '请遵循已加载规则,按需加载附属资料或继续回答。';
|
||
}
|
||
// Skill 附属资料加载成功:原文即权威内容
|
||
if ($toolName === self::SKILL_LOAD_TOOL
|
||
&& is_array($toolResult)
|
||
&& empty($toolResult['error'])
|
||
&& !empty($toolResult['file'])) {
|
||
return "\n\n[系统提供的权威工具结果]\nSkill 附属资料:{$toolResult['file']}(已加载全文,引用时以原文为准)\n"
|
||
. "内容:\n"
|
||
. $this->limitText((string)($toolResult['content'] ?? ''), 32000)
|
||
. "\n[/系统提供的权威工具结果]\n"
|
||
. '如需其他资料可继续输出工具调用 JSON,否则直接给出最终答复。';
|
||
}
|
||
$encoded = (string)json_encode($toolResult, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||
$facts = [];
|
||
if (is_array($toolResult) && empty($toolResult['error'])) {
|
||
$facts[] = '工具调用状态:成功';
|
||
if (isset($toolResult['count']) && is_numeric($toolResult['count'])) {
|
||
$facts[] = 'count=' . (int)$toolResult['count'];
|
||
}
|
||
if (isset($toolResult['list']) && is_array($toolResult['list'])) {
|
||
$facts[] = 'list实际条数=' . count($toolResult['list']);
|
||
}
|
||
} else {
|
||
$facts[] = '工具调用状态:失败';
|
||
}
|
||
|
||
return "\n\n[系统提供的权威工具结果]\n工具:{$toolName}\n"
|
||
. implode(';', $facts)
|
||
. "\nJSON:\n"
|
||
. $this->limitText($encoded, 16000)
|
||
. "\n[/系统提供的权威工具结果]\n"
|
||
. '必须以该结果为准:非空列表不得回答为空或未收到结果。'
|
||
. '如需其他工具可继续输出工具调用 JSON,否则直接给出最终答复。';
|
||
}
|
||
|
||
/**
|
||
* 工具已返回非空数据但模型声称为空时,自动进行一次纠正;仍不一致则直接返回权威结果。
|
||
*
|
||
* @param array $context
|
||
* @param array $toolTrace
|
||
* @param array{content:string,reasoning:string,model:string,usage:array} $answer
|
||
* @return array{content:string,reasoning:string,model:string,usage:array}
|
||
*/
|
||
private function ensureGroundedAnswer(array $context, array $toolTrace, array $answer): array
|
||
{
|
||
if (!$this->toolTraceHasData($toolTrace) || !$this->claimsNoData((string)$answer['content'])) {
|
||
return $answer;
|
||
}
|
||
|
||
$prompts = $context['prompts'];
|
||
$prompts[] = '你上一次的结论与工具返回事实冲突:工具明确返回了非空数据。'
|
||
. '请重新读取用户消息下方最后一个“系统提供的权威工具结果”,准确列出结果并直接回答。'
|
||
. '禁止回答未查到、列表为空、没有收到工具结果,也禁止再次调用工具。';
|
||
try {
|
||
$corrected = $context['driver']->chat($context['workingMessage'], [
|
||
'prompts' => $prompts,
|
||
'stream' => 0,
|
||
]);
|
||
} catch (\Throwable $e) {
|
||
return [
|
||
'content' => $this->authoritativeToolFallback($toolTrace),
|
||
'reasoning' => '',
|
||
'model' => (string)$answer['model'],
|
||
'usage' => (array)$answer['usage'],
|
||
];
|
||
}
|
||
$content = trim((string)($corrected['content'] ?? ''));
|
||
$reasoning = trim((string)($corrected['reasoning'] ?? ''));
|
||
$inlineReasoning = $this->extractThinkBlocks($content);
|
||
if ($reasoning === '') {
|
||
$reasoning = $inlineReasoning;
|
||
}
|
||
$content = $this->stripToolCallJson($content);
|
||
if ($content !== '' && !$this->claimsNoData($content)) {
|
||
return [
|
||
'content' => $content,
|
||
'reasoning' => $reasoning,
|
||
'model' => (string)($corrected['model'] ?? $answer['model']),
|
||
'usage' => (array)($corrected['usage'] ?? $answer['usage']),
|
||
];
|
||
}
|
||
|
||
return [
|
||
'content' => $this->authoritativeToolFallback($toolTrace),
|
||
'reasoning' => '',
|
||
'model' => (string)$answer['model'],
|
||
'usage' => (array)$answer['usage'],
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 判断本轮工具调用轨迹中是否存在返回有效数据的工具结果
|
||
* @param array $toolTrace 工具调用轨迹(name/result 等)
|
||
* @return bool 存在无错误且含 count>0 / 非空 list / 非空 data 的结果时为 true
|
||
*/
|
||
private function toolTraceHasData(array $toolTrace): bool
|
||
{
|
||
foreach ($toolTrace as $trace) {
|
||
$result = $trace['result'] ?? null;
|
||
if (!is_array($result) || !empty($result['error'])) {
|
||
continue;
|
||
}
|
||
if (isset($result['count']) && is_numeric($result['count']) && (int)$result['count'] > 0) {
|
||
return true;
|
||
}
|
||
if (isset($result['list']) && is_array($result['list']) && $result['list'] !== []) {
|
||
return true;
|
||
}
|
||
if (isset($result['data']) && is_array($result['data']) && $result['data'] !== []) {
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* 判断模型正文是否声称「未查到数据/没有数据」之类的空结果表述(事实校验用)
|
||
* @param string $content 模型生成的正文
|
||
* @return bool 命中空结果表述时为 true
|
||
*/
|
||
private function claimsNoData(string $content): bool
|
||
{
|
||
return (bool)preg_match(
|
||
'/未查到|未查询到|没有(?:收到|获取到).*工具|工具.*(?:未返回|没有返回)|(?:列表|数据|记录)(?:为|是)?空|暂无(?:相关)?(?:数据|记录)|没有(?:任何)?(?:用户|商品|订单|记录|数据)/u',
|
||
$content
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 生成事实校正兜底回复:模型声称无数据但工具实际有数据时,直接下发系统确认的工具结果
|
||
* @param array $toolTrace 工具调用轨迹(name/result 等)
|
||
* @return string 兜底回复文本
|
||
*/
|
||
private function authoritativeToolFallback(array $toolTrace): string
|
||
{
|
||
$results = [];
|
||
foreach ($toolTrace as $trace) {
|
||
if (!isset($trace['result']) || !is_array($trace['result']) || !empty($trace['result']['error'])) {
|
||
continue;
|
||
}
|
||
$results[] = [
|
||
'tool' => (string)($trace['name'] ?? ''),
|
||
'result' => $trace['result'],
|
||
];
|
||
}
|
||
$json = (string)json_encode($results, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
|
||
return "工具已成功返回数据,但模型生成的总结与结果不一致。以下为系统确认的工具结果:\n"
|
||
. $this->limitText($json, 12000);
|
||
}
|
||
|
||
/**
|
||
* 正文已经在本轮流式下发时不做额外操作;仅在被过滤、清洗或事实校正后与已输出文本不一致时替换。
|
||
* @param array{content:string,rawContent:string,reasoning:string,suppressed:bool} $acc
|
||
*/
|
||
private function syncStreamFinalAnswer(callable $send, array $acc, string $content): void
|
||
{
|
||
$streamed = trim((string)($acc['content'] ?? ''));
|
||
if ($streamed === $content) {
|
||
return;
|
||
}
|
||
$send([
|
||
'event' => 'replace',
|
||
'content' => $content,
|
||
// 被纠正轮的 reasoning 不再补发,避免与已经实时展示的思考过程重复。
|
||
'reasoning' => '',
|
||
]);
|
||
}
|
||
|
||
/** @param array<int, string> $processTrace */
|
||
private function mergeReasoning(array $processTrace, string $modelReasoning): string
|
||
{
|
||
$parts = array_filter(array_merge($processTrace, [trim($modelReasoning)]));
|
||
return implode("\n", $parts);
|
||
}
|
||
|
||
/**
|
||
* 判断最终答复是否为「在正文中直接向用户提问/索要信息」:纯文本提问不会弹出输入框,
|
||
* 命中时由服务端强制转为 crmeb_ask_user 问询卡片。判定宁缺勿滥,避免误伤带数据的完整答复。
|
||
* @param string $content 剥离工具调用 JSON 与追问 JSON 后的最终答复正文
|
||
* @return bool
|
||
*/
|
||
private function looksLikeQuestionToUser(string $content): bool
|
||
{
|
||
$content = trim($content);
|
||
$length = mb_strlen($content);
|
||
// 完整答复通常携带数据与结论、长度明显更长;超长内容不转换
|
||
if ($length === 0 || $length > 300) {
|
||
return false;
|
||
}
|
||
// 明确的索取指令(可无问号,如「请补充结束日期」「请直接回复…」)
|
||
if (preg_match(
|
||
'~请(?:直接)?(?:补充|提供|回复|输入|告知)|麻烦(?:你|您)?(?:补充|提供|告知)|请告诉我|我(?:仍|还)?需要你(?:提供|补充|确认)~u',
|
||
$content
|
||
)) {
|
||
return true;
|
||
}
|
||
// 疑问句兜底(如「请问您希望分析哪个时间段的数据?」):短回复 + 问号
|
||
if (mb_strpos($content, '?') === false && strpos($content, '?') === false) {
|
||
return false;
|
||
}
|
||
// 含表格、标题、代码块等数据特征视为完整答复,不转换
|
||
return !preg_match('~(^|\n)\s*(?:\||#|```)|\n\d+[.、]\s~', $content);
|
||
}
|
||
|
||
/**
|
||
* 把模型以正文直接提问的回复强制转为 ask_user 问询卡片:落库 confirm 消息
|
||
* (question 取回复正文、type=text 自由填写),用户提交答案后经 confirmStream 续跑工具循环
|
||
* @param int $conversationId 会话 ID
|
||
* @param string $question 模型回复正文(作为卡片问题)
|
||
* @param array $context 对话上下文(取 allowedTools 与已加载 Skill 快照)
|
||
* @param array $confirmContext 确认快照上下文(model_id/server_ids/skill_keys)
|
||
* @return array{message_id:int,arguments:array,display:array}
|
||
*/
|
||
private function forceAskUserCard(int $conversationId, string $question, array $context, array $confirmContext): array
|
||
{
|
||
$arguments = ['question' => $question, 'type' => 'text', 'allow_custom' => true];
|
||
$display = ['title' => 'AI 需要你补充信息', 'params' => []];
|
||
$confirmMessageId = $this->agents->addMessage(
|
||
$conversationId,
|
||
'confirm',
|
||
$this->canonicalToolCall(McpToolService::ASK_USER_TOOL, $arguments),
|
||
McpToolService::ASK_USER_TOOL,
|
||
[
|
||
'arguments' => $arguments,
|
||
'status' => 'pending',
|
||
'interaction' => 'ask_user',
|
||
'display' => $display,
|
||
'model_id' => (int)($confirmContext['model_id'] ?? 0),
|
||
'server_ids' => (array)($confirmContext['server_ids'] ?? []),
|
||
'skill_keys' => (array)($confirmContext['skill_keys'] ?? []),
|
||
'skill_loaded' => array_keys($context['skillFiles']),
|
||
'allowed_tools' => $context['allowedTools'],
|
||
]
|
||
);
|
||
return ['message_id' => $confirmMessageId, 'arguments' => $arguments, 'display' => $display];
|
||
}
|
||
|
||
/**
|
||
* 判断回复是否为「计划性叙述后中断」:只说了接下来要做什么,没有交付实际内容。
|
||
* 特征:文本较短(真正的最终答复会携带数据与结论,长度明显更长)且以未来动作叙述收尾。
|
||
* 仅用于挂载了工具的对话轮次做继续提示,判定宁缺勿滥,避免把正常答复误判为中断。
|
||
* @param string $content 剥离工具调用 JSON 后的正文
|
||
* @return bool
|
||
*/
|
||
private function looksLikeUnfinishedNarration(string $content): bool
|
||
{
|
||
$length = mb_strlen(trim($content));
|
||
if ($length === 0 || $length > 300) {
|
||
return false;
|
||
}
|
||
return (bool)preg_match(
|
||
'~(接下来|下面|现在|马上)(我将|我会|我将要|为你|开始)|让我来|正在为你|正在查询|正在整理|我将逐一|逐一核查|开始查询|开始分析~u',
|
||
$content
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 提取工具调用轨迹中的工具名清单(用于持久化到 assistant 消息 payload,供前端历史回显「调用 xxx」标签;
|
||
* 只存名称不存参数与结果,避免消息体膨胀,完整轨迹仅随 SSE done 事件下发)
|
||
* @param array $toolTrace 工具调用轨迹(name/arguments/result)
|
||
* @return array<int, array{name: string}>
|
||
*/
|
||
private function toolTraceNames(array $toolTrace): array
|
||
{
|
||
return array_values(array_map(
|
||
static fn(array $item): array => ['name' => (string)($item['name'] ?? '')],
|
||
$toolTrace
|
||
));
|
||
}
|
||
|
||
/**
|
||
* 解析模型回复中的工具调用意图
|
||
*
|
||
* 兼容格式:
|
||
* 1. 纯 JSON / Markdown 包裹:{"tool_call":{"name":...,"arguments":{...}}}
|
||
* 2. 混入正文或多个 JSON 连排(花括号深度扫描提取首个完整对象)
|
||
* 3. 一号通平台私有标记:<|FunctionCallBegin|>[{"name":...,"arguments":{...}}]<|FunctionCallEnd|>
|
||
* @return array{name:string,arguments:array}|null
|
||
*/
|
||
private function parseToolCall(string $content): ?array
|
||
{
|
||
$raw = trim($content);
|
||
// 依次尝试:整体原文 → Markdown 代码块 → 正文内首个完整工具调用对象
|
||
$candidates = [$raw];
|
||
if (preg_match('/```(?:json)?\s*(\{.*?\})\s*```/si', $raw, $match)) {
|
||
$candidates[] = $match[1];
|
||
}
|
||
$extracted = $this->extractFirstToolCallObject($raw);
|
||
if ($extracted !== null) {
|
||
$candidates[] = $extracted;
|
||
}
|
||
foreach ($candidates as $candidate) {
|
||
$payload = json_decode($candidate, true);
|
||
$call = is_array($payload) ? ($payload['tool_call'] ?? null) : null;
|
||
if (is_array($call) && trim((string)($call['name'] ?? '')) !== '') {
|
||
return [
|
||
'name' => trim((string)$call['name']),
|
||
'arguments' => is_array($call['arguments'] ?? null) ? $call['arguments'] : [],
|
||
'display' => is_array($call['display'] ?? null) ? $call['display'] : [],
|
||
];
|
||
}
|
||
}
|
||
// 一号通平台私有函数调用标记:<|FunctionCallBegin|>{...}|[...]<|FunctionCallEnd|>
|
||
if (preg_match('/<\|FunctionCallBegin\|>\s*(\{.*?\}|\[.*?\])\s*<\|FunctionCallEnd\|>/s', $raw, $match)) {
|
||
$decoded = json_decode($match[1], true);
|
||
// 数组形式取首个调用
|
||
if (isset($decoded[0]) && is_array($decoded[0])) {
|
||
$decoded = $decoded[0];
|
||
}
|
||
if (is_array($decoded) && trim((string)($decoded['name'] ?? '')) !== '') {
|
||
return [
|
||
'name' => trim((string)$decoded['name']),
|
||
'arguments' => is_array($decoded['arguments'] ?? null) ? $decoded['arguments'] : [],
|
||
'display' => is_array($decoded['display'] ?? null) ? $decoded['display'] : [],
|
||
];
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* 从文本中提取首个完整的 {"tool_call":{...}} JSON 对象(花括号深度扫描,忽略字符串内括号)
|
||
*
|
||
* 模型可能将多个工具调用 JSON 连排输出(如 {..}{..}{..}),正则贪婪/懒惰匹配均易截取错误,
|
||
* 此处以首个 {"tool_call" 为起点按深度配对提取,保证取到第一个完整对象。
|
||
* @param string $text 待扫描文本
|
||
* @return string|null 完整 JSON 对象文本,未找到时返回 null
|
||
*/
|
||
private function extractFirstToolCallObject(string $text): ?string
|
||
{
|
||
$start = strpos($text, '{"tool_call"');
|
||
if ($start === false) {
|
||
return null;
|
||
}
|
||
$depth = 0;
|
||
$inString = false;
|
||
$escape = false;
|
||
$length = strlen($text);
|
||
for ($i = $start; $i < $length; $i++) {
|
||
$char = $text[$i];
|
||
if ($inString) {
|
||
if ($escape) {
|
||
$escape = false;
|
||
} elseif ($char === '\\') {
|
||
$escape = true;
|
||
} elseif ($char === '"') {
|
||
$inString = false;
|
||
}
|
||
continue;
|
||
}
|
||
if ($char === '"') {
|
||
$inString = true;
|
||
} elseif ($char === '{') {
|
||
$depth++;
|
||
} elseif ($char === '}') {
|
||
$depth--;
|
||
if ($depth === 0) {
|
||
return substr($text, $start, $i - $start + 1);
|
||
}
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* 从最终答复中提取并剥离推荐追问 JSON({"followups":[...]},支持标记跨多次扫描)
|
||
* @param string $content 模型最终答复(引用传递,剥离后为纯正文)
|
||
* @return array<int, string> 推荐问题列表(最多 3 个),无有效内容时返回空数组
|
||
*/
|
||
private function extractFollowups(string &$content): array
|
||
{
|
||
$marker = '{"followups"';
|
||
$questions = [];
|
||
// 模型可能复读输出多段 followups JSON(如连答两遍各带一段):逐段剥离,防止残留原文展示给用户
|
||
while (($start = strpos($content, $marker)) !== false) {
|
||
// 与工具调用对象同规则的花括号深度扫描,取完整 JSON 对象
|
||
$depth = 0;
|
||
$inString = false;
|
||
$escape = false;
|
||
$length = strlen($content);
|
||
$object = null;
|
||
for ($i = $start; $i < $length; $i++) {
|
||
$char = $content[$i];
|
||
if ($inString) {
|
||
if ($escape) {
|
||
$escape = false;
|
||
} elseif ($char === '\\') {
|
||
$escape = true;
|
||
} elseif ($char === '"') {
|
||
$inString = false;
|
||
}
|
||
continue;
|
||
}
|
||
if ($char === '"') {
|
||
$inString = true;
|
||
} elseif ($char === '{') {
|
||
$depth++;
|
||
} elseif ($char === '}') {
|
||
$depth--;
|
||
if ($depth === 0) {
|
||
$object = substr($content, $start, $i - $start + 1);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
if ($object === null) {
|
||
// 未闭合的 followups JSON(多为输出被截断):必然出现在答复末尾,从起点截断防泄漏
|
||
$content = trim(substr($content, 0, $start));
|
||
break;
|
||
}
|
||
$content = trim(substr($content, 0, $start) . substr($content, $start + strlen($object)));
|
||
$decoded = json_decode($object, true);
|
||
foreach ((array)($decoded['followups'] ?? []) as $question) {
|
||
$question = trim((string)$question);
|
||
if ($question !== '' && mb_strlen($question) <= 100) {
|
||
$questions[] = $question;
|
||
}
|
||
}
|
||
}
|
||
return array_slice(array_values(array_unique($questions)), 0, 3);
|
||
}
|
||
|
||
/**
|
||
* 清理最终答复:剥离平台私有函数调用标记与残留的工具调用 JSON(含连排多个),避免原样展示给用户
|
||
* @param string $content 模型原始回复
|
||
* @return string
|
||
*/
|
||
private function stripToolCallJson(string $content): string
|
||
{
|
||
$content = preg_replace('/<\|FunctionCallBegin\|>.*?(?:<\|FunctionCallEnd\|>|$)/s', '', $content) ?? '';
|
||
// Markdown 代码块包裹的工具调用
|
||
$content = preg_replace('/```(?:json)?\s*\{[^`]*"tool_call"[^`]*\}\s*```/si', '', $content) ?? '';
|
||
// 正文内散落的工具调用 JSON(连排多个时逐个剥离)
|
||
while (($pos = strpos($content, '{"tool_call"')) !== false) {
|
||
$object = $this->extractFirstToolCallObject(substr($content, $pos));
|
||
if ($object === null) {
|
||
// 未闭合的工具调用 JSON(多为输出被 max_tokens 截断):无法配对但必然不是答复内容,
|
||
// 从起点整体剥离,避免原文泄漏给用户、也避免截断 JSON 随转录进入下轮被模型复读
|
||
$content = substr($content, 0, $pos);
|
||
break;
|
||
}
|
||
$content = substr($content, 0, $pos) . substr($content, $pos + strlen($object));
|
||
}
|
||
return trim((string)$content);
|
||
}
|
||
|
||
/**
|
||
* 从模型回复中提取 <think>...</think> 内嵌思维链(兼容 </think_xxx> 变体闭合标签)
|
||
* @param string $content 模型原始回复(引用传递,提取后为剔除思维链的正文)
|
||
* @return string 提取出的思维链文本,无内嵌思维链时返回空串
|
||
*/
|
||
private function extractThinkBlocks(string &$content): string
|
||
{
|
||
$reasoning = '';
|
||
$content = preg_replace_callback(
|
||
'~<think[^>]*>(.*?)</think[^>]*>~si',
|
||
function (array $m) use (&$reasoning): string {
|
||
$reasoning .= $m[1];
|
||
return '';
|
||
},
|
||
$content
|
||
);
|
||
// 某些模型会输出非标准的“仅闭合”标记:正文前半段实际上是思维链,
|
||
// 例如:思维链...</think_never_used_xxx>最终答复。把标记前内容归入 reasoning,
|
||
// 避免内部过程和标签混入最终回答。
|
||
if (preg_match('~\\A\\s*(.*?)</think[^>]*>~si', (string)$content, $match)) {
|
||
$reasoning .= $match[1];
|
||
$content = substr((string)$content, strlen($match[0]));
|
||
}
|
||
// 清理正文中仍可能残留的孤立闭合标记(包括跨流分片组合后的情况)。
|
||
$content = preg_replace('~</think[^>]*>~si', '', (string)$content) ?? '';
|
||
// 未闭合的 <think> 标签:其后内容全部视为思维链
|
||
if (preg_match('~<think[^>]*>~i', (string)$content)) {
|
||
$content = preg_replace_callback(
|
||
'~<think[^>]*>(.*)$~si',
|
||
function (array $m) use (&$reasoning): string {
|
||
$reasoning .= $m[1];
|
||
return '';
|
||
},
|
||
(string)$content
|
||
);
|
||
}
|
||
$content = trim((string)$content);
|
||
return trim($reasoning);
|
||
}
|
||
|
||
/**
|
||
* 文本超长时按字节截断并追加省略号
|
||
* @param string $text 原始文本
|
||
* @param int $length 最大字节长度
|
||
* @return string 截断后的文本
|
||
*/
|
||
private function limitText(string $text, int $length): string
|
||
{
|
||
return strlen($text) > $length ? mb_strcut($text, 0, $length, 'UTF-8') . '…' : $text;
|
||
}
|
||
}
|