135 文件 19,211 处自动修复:空格→Tab 缩进、括号间距、 函数声明空格、前置自增、尾逗号等纯格式化改动。 零逻辑变更,php -l + token 级对比验证通过。 新增 phpcs.xml.dist / phpstan.neon.dist 项目配置。 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
653 lines
17 KiB
PHP
653 lines
17 KiB
PHP
<?php
|
|
/**
|
|
* Chat Service
|
|
*
|
|
* 处理 AI 对话和流式输出
|
|
*
|
|
* @package WPMind
|
|
* @subpackage API\Services
|
|
* @since 3.7.0
|
|
*/
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace WPMind\API\Services;
|
|
|
|
use WP_Error;
|
|
|
|
/**
|
|
* Chat Service
|
|
*
|
|
* @since 3.7.0
|
|
*/
|
|
class ChatService extends AbstractService {
|
|
|
|
/**
|
|
* AI 对话(核心实现)
|
|
*
|
|
* @param string|array $messages 消息
|
|
* @param array $options 选项
|
|
* @return array|WP_Error
|
|
*/
|
|
public function chat( $messages, array $options = [] ) {
|
|
$defaults = [
|
|
'context' => '',
|
|
'system' => '',
|
|
'max_tokens' => 1000,
|
|
'temperature' => 0.7,
|
|
'model' => 'auto',
|
|
'provider' => 'auto',
|
|
'json_mode' => false,
|
|
'cache_ttl' => 0,
|
|
'tools' => [],
|
|
'tool_choice' => 'auto',
|
|
'failover_providers' => [],
|
|
];
|
|
$options = wp_parse_args( $options, $defaults );
|
|
|
|
$context = $options['context'];
|
|
|
|
$normalized_messages = $this->normalize_messages( $messages, $options );
|
|
|
|
$args = [
|
|
'messages' => $normalized_messages,
|
|
'max_tokens' => $options['max_tokens'],
|
|
'temperature' => $options['temperature'],
|
|
'json_mode' => $options['json_mode'],
|
|
'tools' => $options['tools'],
|
|
'tool_choice' => $options['tool_choice'],
|
|
];
|
|
|
|
$args = apply_filters( 'wpmind_chat_args', $args, $context, $messages );
|
|
|
|
$original_model = $options['model'];
|
|
$model_is_auto = ( $original_model === 'auto' );
|
|
|
|
if ( $model_is_auto ) {
|
|
$model = $this->get_default_model();
|
|
} else {
|
|
$model = $original_model;
|
|
}
|
|
$model = apply_filters( 'wpmind_select_model', $model, $context, get_current_user_id() );
|
|
|
|
$provider = $this->resolve_provider( $options['provider'], $context );
|
|
$failover_chain = $this->get_failover_chain( $provider );
|
|
|
|
// Filter failover chain to only supported providers (e.g. vision-capable).
|
|
if ( ! empty( $options['failover_providers'] ) ) {
|
|
$allowed = $options['failover_providers'];
|
|
$failover_chain = array_values(
|
|
array_filter(
|
|
$failover_chain,
|
|
function ( $p ) use ( $allowed ) {
|
|
return in_array( $p, $allowed, true );
|
|
}
|
|
)
|
|
);
|
|
|
|
if ( empty( $failover_chain ) ) {
|
|
return new WP_Error(
|
|
'wpmind_no_supported_provider',
|
|
__( '没有可用的服务商支持此请求类型', 'wpmind' )
|
|
);
|
|
}
|
|
}
|
|
|
|
if ( ! empty( $failover_chain ) && $failover_chain[0] !== $provider ) {
|
|
do_action( 'wpmind_provider_failover', $provider, $failover_chain[0], $context );
|
|
}
|
|
|
|
$cache_key = $this->generate_cache_key( 'chat', $args, $provider, $model );
|
|
$cache_lookup = $this->get_cached_value( $cache_key, (int) $options['cache_ttl'] );
|
|
if ( $cache_lookup['hit'] ) {
|
|
return $cache_lookup['value'];
|
|
}
|
|
|
|
do_action( 'wpmind_before_request', 'chat', $args, $context );
|
|
|
|
$result = null;
|
|
$last_error = null;
|
|
$tried_providers = [];
|
|
$failover_count = count( $failover_chain );
|
|
|
|
foreach ( $failover_chain as $index => $try_provider ) {
|
|
$tried_providers[] = $try_provider;
|
|
$is_last_provider = ( $index === $failover_count - 1 );
|
|
$max_retries = $is_last_provider ? 3 : 1;
|
|
|
|
if ( $model_is_auto ) {
|
|
$try_model = $this->get_current_model( $try_provider );
|
|
} else {
|
|
$try_model = $model;
|
|
$endpoints = $this->get_endpoints();
|
|
$provider_models = $endpoints[ $try_provider ]['models'] ?? [];
|
|
if ( ! empty( $provider_models ) && ! in_array( $try_model, $provider_models, true ) ) {
|
|
$try_model = $this->get_current_model( $try_provider );
|
|
}
|
|
}
|
|
|
|
for ( $attempt = 0; $attempt <= $max_retries; $attempt++ ) {
|
|
$result = $this->execute_chat_request( $args, $try_provider, $try_model );
|
|
|
|
if ( ! is_wp_error( $result ) ) {
|
|
if ( $try_provider !== $provider ) {
|
|
$result['failover'] = [
|
|
'original_provider' => $provider,
|
|
'actual_provider' => $try_provider,
|
|
'tried_providers' => $tried_providers,
|
|
];
|
|
}
|
|
|
|
if ( $try_model !== $model ) {
|
|
$result['model_fallback'] = true;
|
|
$result['original_model'] = $model;
|
|
}
|
|
break 2;
|
|
}
|
|
|
|
$last_error = $result;
|
|
|
|
$error_code = $result->get_error_code();
|
|
if ( in_array( $error_code, [ 'wpmind_api_key_missing', 'wpmind_provider_not_found' ], true ) ) {
|
|
break;
|
|
}
|
|
|
|
$error_data = $result->get_error_data();
|
|
$status = is_array( $error_data ) && isset( $error_data['status'] ) ? (int) $error_data['status'] : 0;
|
|
|
|
if ( $status > 0 && ! \WPMind\ErrorHandler::should_retry( $status ) ) {
|
|
break;
|
|
}
|
|
|
|
if ( $attempt < $max_retries ) {
|
|
$delay_ms = \WPMind\ErrorHandler::get_retry_delay( $attempt + 1 );
|
|
do_action( 'wpmind_retry', $try_provider, $attempt + 1, $status );
|
|
sleep( (int) ( $delay_ms / 1000 ) );
|
|
}
|
|
}
|
|
}
|
|
|
|
if ( is_wp_error( $result ) ) {
|
|
do_action( 'wpmind_error', $result, 'chat', $args );
|
|
return $result;
|
|
}
|
|
|
|
$result = apply_filters( 'wpmind_chat_response', $result, $args, $context );
|
|
|
|
$usage = $result['usage'] ?? [];
|
|
do_action( 'wpmind_after_request', 'chat', $result, $args, $usage );
|
|
|
|
if ( ! is_wp_error( $result ) ) {
|
|
$this->set_cached_value(
|
|
$cache_key,
|
|
$result,
|
|
(int) $options['cache_ttl'],
|
|
[
|
|
'type' => 'chat',
|
|
'context' => $context,
|
|
'provider' => $provider,
|
|
'model' => $model,
|
|
]
|
|
);
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* 流式输出
|
|
*
|
|
* @since 2.6.0
|
|
* @param array|string $messages 消息
|
|
* @param callable $callback 回调函数
|
|
* @param array $options 选项
|
|
* @return bool|WP_Error
|
|
*/
|
|
public function stream( $messages, callable $callback, array $options = [] ) {
|
|
$defaults = [
|
|
'context' => '',
|
|
'system' => '',
|
|
'max_tokens' => 2000,
|
|
'temperature' => 0.7,
|
|
'model' => 'auto',
|
|
'provider' => 'auto',
|
|
];
|
|
$options = wp_parse_args( $options, $defaults );
|
|
|
|
$context = $options['context'];
|
|
$normalized_messages = $this->normalize_messages( $messages, $options );
|
|
|
|
$original_model = $options['model'];
|
|
$model_is_auto = ( $original_model === 'auto' );
|
|
|
|
$provider = $this->resolve_provider( $options['provider'], $context );
|
|
$failover_chain = $this->get_failover_chain( $provider );
|
|
|
|
if ( ! empty( $failover_chain ) && $failover_chain[0] !== $provider ) {
|
|
do_action( 'wpmind_provider_failover', $provider, $failover_chain[0], $context );
|
|
}
|
|
|
|
do_action( 'wpmind_before_request', 'stream', compact( 'messages', 'options' ), $context );
|
|
|
|
$endpoints = $this->get_endpoints();
|
|
$last_error = null;
|
|
|
|
foreach ( $failover_chain as $index => $try_provider ) {
|
|
if ( ! isset( $endpoints[ $try_provider ] ) ) {
|
|
$last_error = new WP_Error(
|
|
'wpmind_provider_not_found',
|
|
sprintf( __( '服务商 %s 未配置', 'wpmind' ), $try_provider )
|
|
);
|
|
continue;
|
|
}
|
|
|
|
$endpoint = $endpoints[ $try_provider ];
|
|
$api_key = $endpoint['api_key'] ?? '';
|
|
|
|
if ( empty( $api_key ) ) {
|
|
$last_error = new WP_Error(
|
|
'wpmind_api_key_missing',
|
|
sprintf( __( '服务商 %s 未配置 API Key', 'wpmind' ), $try_provider )
|
|
);
|
|
continue;
|
|
}
|
|
|
|
$model = $model_is_auto
|
|
? $this->get_current_model( $try_provider )
|
|
: $original_model;
|
|
|
|
if ( $model === 'auto' || $model === 'default' ) {
|
|
$model = $endpoint['models'][0] ?? 'gpt-3.5-turbo';
|
|
}
|
|
|
|
$base_url = $endpoint['custom_base_url'] ?? $endpoint['base_url'] ?? '';
|
|
$api_url = trailingslashit( $base_url ) . 'chat/completions';
|
|
|
|
$request_body = [
|
|
'model' => $model,
|
|
'messages' => $normalized_messages,
|
|
'max_tokens' => $options['max_tokens'],
|
|
'temperature' => $options['temperature'],
|
|
'stream' => true,
|
|
];
|
|
|
|
$stream_context_options = [
|
|
'http' => [
|
|
'method' => 'POST',
|
|
'header' => [
|
|
'Content-Type: application/json',
|
|
'Authorization: Bearer ' . $api_key,
|
|
],
|
|
'content' => wp_json_encode( $request_body ),
|
|
'timeout' => 120,
|
|
],
|
|
'ssl' => [
|
|
'verify_peer' => true,
|
|
],
|
|
];
|
|
|
|
$start_time = microtime( true );
|
|
|
|
// 检查 allow_url_fopen 是否启用
|
|
if ( ! ini_get( 'allow_url_fopen' ) ) {
|
|
$last_error = new WP_Error(
|
|
'wpmind_stream_unsupported',
|
|
__( '流式输出需要 PHP allow_url_fopen 配置启用', 'wpmind' )
|
|
);
|
|
continue;
|
|
}
|
|
|
|
$stream_ctx = stream_context_create( $stream_context_options );
|
|
$stream = @fopen( $api_url, 'r', false, $stream_ctx );
|
|
|
|
if ( ! $stream ) {
|
|
$latency_ms = (int) ( ( microtime( true ) - $start_time ) * 1000 );
|
|
$this->record_result( $try_provider, false, $latency_ms );
|
|
|
|
$last_error = new WP_Error(
|
|
'wpmind_stream_failed',
|
|
sprintf( __( '服务商 %s 无法建立流式连接', 'wpmind' ), $try_provider )
|
|
);
|
|
continue;
|
|
}
|
|
|
|
$full_content = '';
|
|
|
|
while ( ! feof( $stream ) ) {
|
|
$line = fgets( $stream );
|
|
if ( empty( $line ) ) {
|
|
continue;
|
|
}
|
|
|
|
$line = trim( $line );
|
|
if ( strpos( $line, 'data: ' ) !== 0 ) {
|
|
continue;
|
|
}
|
|
|
|
$data = substr( $line, 6 );
|
|
if ( $data === '[DONE]' ) {
|
|
break;
|
|
}
|
|
|
|
$json = json_decode( $data, true );
|
|
if ( ! $json ) {
|
|
continue;
|
|
}
|
|
|
|
$delta = $json['choices'][0]['delta']['content'] ?? '';
|
|
if ( ! empty( $delta ) ) {
|
|
$full_content .= $delta;
|
|
call_user_func( $callback, $delta, $json );
|
|
}
|
|
}
|
|
|
|
fclose( $stream );
|
|
|
|
$latency_ms = (int) ( ( microtime( true ) - $start_time ) * 1000 );
|
|
$this->record_result( $try_provider, true, $latency_ms );
|
|
|
|
do_action( 'wpmind_after_request', 'stream', [ 'content' => $full_content ], compact( 'messages', 'options' ), [] );
|
|
|
|
return true;
|
|
}
|
|
|
|
if ( $last_error ) {
|
|
do_action( 'wpmind_error', $last_error, 'stream', compact( 'messages', 'options' ) );
|
|
return $last_error;
|
|
}
|
|
|
|
return new WP_Error( 'wpmind_stream_failed', __( '无法建立流式连接', 'wpmind' ) );
|
|
}
|
|
|
|
/**
|
|
* 标准化消息格式
|
|
*
|
|
* @param array|string $messages 原始消息
|
|
* @param array $options 选项
|
|
* @return array
|
|
*/
|
|
public function normalize_messages( $messages, array $options ): array {
|
|
if ( is_string( $messages ) ) {
|
|
$normalized = [];
|
|
|
|
if ( ! empty( $options['system'] ) ) {
|
|
$normalized[] = [
|
|
'role' => 'system',
|
|
'content' => $options['system'],
|
|
];
|
|
}
|
|
|
|
$normalized[] = [
|
|
'role' => 'user',
|
|
'content' => $messages,
|
|
];
|
|
|
|
return $normalized;
|
|
}
|
|
|
|
return $messages;
|
|
}
|
|
|
|
/**
|
|
* 判断是否应该使用 SDK 执行请求
|
|
*
|
|
* @since 3.6.0
|
|
* @param string $provider 服务商 ID
|
|
* @param array $args 请求参数
|
|
* @return bool
|
|
*/
|
|
private function should_use_sdk( string $provider, array $args ): bool {
|
|
if ( ! class_exists( '\\WPMind\\SDK\\SDKAdapter' ) ) {
|
|
return false;
|
|
}
|
|
|
|
if ( ! class_exists( 'WordPress\\AiClient\\AiClient' ) ) {
|
|
return false;
|
|
}
|
|
|
|
if ( ! get_option( 'wpmind_sdk_enabled', true ) ) {
|
|
return false;
|
|
}
|
|
|
|
if ( ! empty( $args['tools'] ) ) {
|
|
return false;
|
|
}
|
|
|
|
$sdk_providers = apply_filters( 'wpmind_sdk_providers', [ 'anthropic', 'google' ] );
|
|
return in_array( $provider, $sdk_providers, true );
|
|
}
|
|
|
|
/**
|
|
* 执行 Chat 请求(路由方法)
|
|
*
|
|
* @since 3.6.0
|
|
* @param array $args 请求参数
|
|
* @param string $provider 服务商
|
|
* @param string $model 模型
|
|
* @return array|WP_Error
|
|
*/
|
|
private function execute_chat_request( array $args, string $provider, string $model ) {
|
|
if ( $this->should_use_sdk( $provider, $args ) ) {
|
|
$sdk = new \WPMind\SDK\SDKAdapter();
|
|
$start_time = microtime( true );
|
|
$result = $sdk->chat( $args, $provider, $model );
|
|
$latency_ms = (int) ( ( microtime( true ) - $start_time ) * 1000 );
|
|
|
|
if ( ! is_wp_error( $result ) ) {
|
|
$this->record_result( $provider, true, $latency_ms );
|
|
return $result;
|
|
}
|
|
|
|
$error_code = $result->get_error_code();
|
|
|
|
if ( $error_code === 'wpmind_sdk_invalid_args' || $error_code === 'wpmind_sdk_unavailable' ) {
|
|
do_action( 'wpmind_sdk_fallback', $provider, $error_code, $result->get_error_message() );
|
|
} else {
|
|
$this->record_result( $provider, false, $latency_ms );
|
|
return $result;
|
|
}
|
|
}
|
|
|
|
return $this->execute_chat_request_native( $args, $provider, $model );
|
|
}
|
|
|
|
/**
|
|
* 通过原生 HTTP 执行 chat 请求
|
|
*
|
|
* @since 3.6.0
|
|
* @param array $args 请求参数
|
|
* @param string $provider 服务商
|
|
* @param string $model 模型
|
|
* @return array|WP_Error
|
|
*/
|
|
private function execute_chat_request_native( array $args, string $provider, string $model ) {
|
|
$endpoints = $this->get_endpoints();
|
|
|
|
if ( ! isset( $endpoints[ $provider ] ) ) {
|
|
return new WP_Error(
|
|
'wpmind_provider_not_found',
|
|
sprintf( __( '服务商 %s 未配置', 'wpmind' ), $provider )
|
|
);
|
|
}
|
|
|
|
$endpoint = $endpoints[ $provider ];
|
|
$api_key = $endpoint['api_key'] ?? '';
|
|
|
|
if ( empty( $api_key ) ) {
|
|
return new WP_Error(
|
|
'wpmind_api_key_missing',
|
|
sprintf( __( '服务商 %s 未配置 API Key', 'wpmind' ), $provider )
|
|
);
|
|
}
|
|
|
|
if ( $model === 'auto' || empty( $model ) || $model === 'default' ) {
|
|
$model = $endpoint['models'][0] ?? 'gpt-3.5-turbo';
|
|
}
|
|
|
|
$request_body = [
|
|
'model' => $model,
|
|
'messages' => $args['messages'],
|
|
'max_tokens' => $args['max_tokens'],
|
|
'temperature' => $args['temperature'],
|
|
];
|
|
|
|
if ( ! empty( $args['json_mode'] ) ) {
|
|
$request_body['response_format'] = [ 'type' => 'json_object' ];
|
|
}
|
|
|
|
if ( ! empty( $args['tools'] ) ) {
|
|
$request_body['tools'] = $args['tools'];
|
|
if ( ! empty( $args['tool_choice'] ) && $args['tool_choice'] !== 'auto' ) {
|
|
$request_body['tool_choice'] = $args['tool_choice'];
|
|
}
|
|
}
|
|
|
|
$base_url = $endpoint['custom_base_url'] ?? $endpoint['base_url'] ?? '';
|
|
$api_url = trailingslashit( $base_url ) . 'chat/completions';
|
|
|
|
$start_time = microtime( true );
|
|
|
|
$response = wp_remote_post(
|
|
$api_url,
|
|
[
|
|
'headers' => [
|
|
'Authorization' => 'Bearer ' . $api_key,
|
|
'Content-Type' => 'application/json',
|
|
],
|
|
'body' => wp_json_encode( $request_body ),
|
|
'timeout' => 60,
|
|
]
|
|
);
|
|
|
|
$latency_ms = (int) ( ( microtime( true ) - $start_time ) * 1000 );
|
|
|
|
if ( is_wp_error( $response ) ) {
|
|
$this->record_result( $provider, false, $latency_ms );
|
|
|
|
return new WP_Error(
|
|
'wpmind_request_failed',
|
|
sprintf( __( '请求失败: %s', 'wpmind' ), $response->get_error_message() )
|
|
);
|
|
}
|
|
|
|
$status_code = wp_remote_retrieve_response_code( $response );
|
|
$body = wp_remote_retrieve_body( $response );
|
|
$data = json_decode( $body, true );
|
|
|
|
if ( $status_code !== 200 ) {
|
|
$this->record_result( $provider, false, $latency_ms );
|
|
|
|
$error_message = $data['error']['message'] ?? __( '未知错误', 'wpmind' );
|
|
return new WP_Error(
|
|
'wpmind_api_error',
|
|
sprintf( __( 'API 错误 (%1$d): %2$s', 'wpmind' ), $status_code, $error_message ),
|
|
[
|
|
'status' => $status_code,
|
|
'body' => substr( (string) $body, 0, 500 ),
|
|
]
|
|
);
|
|
}
|
|
|
|
if ( ! is_array( $data ) ) {
|
|
$this->record_result( $provider, false, $latency_ms );
|
|
|
|
return new WP_Error(
|
|
'wpmind_invalid_response',
|
|
__( '服务商返回了无效的响应格式', 'wpmind' ),
|
|
[
|
|
'status' => $status_code,
|
|
'body' => substr( (string) $body, 0, 500 ),
|
|
]
|
|
);
|
|
}
|
|
|
|
$this->record_result( $provider, true, $latency_ms );
|
|
|
|
return $this->parse_chat_response( $data, $provider, $model );
|
|
}
|
|
|
|
/**
|
|
* 解析 Chat 响应
|
|
*
|
|
* @param array $response 原始响应
|
|
* @param string $provider 服务商
|
|
* @param string $model 模型
|
|
* @return array
|
|
*/
|
|
public function parse_chat_response( array $response, string $provider, string $model ): array {
|
|
$content = '';
|
|
$tool_calls = [];
|
|
$finish_reason = '';
|
|
$usage = [
|
|
'prompt_tokens' => 0,
|
|
'completion_tokens' => 0,
|
|
'total_tokens' => 0,
|
|
];
|
|
|
|
$message = $response['choices'][0]['message'] ?? [];
|
|
$finish_reason = $response['choices'][0]['finish_reason'] ?? '';
|
|
|
|
if ( isset( $message['content'] ) ) {
|
|
$content = $message['content'];
|
|
} elseif ( isset( $response['content'][0]['text'] ) ) {
|
|
$content = $response['content'][0]['text'];
|
|
}
|
|
|
|
if ( isset( $message['tool_calls'] ) && is_array( $message['tool_calls'] ) ) {
|
|
foreach ( $message['tool_calls'] as $call ) {
|
|
$tool_calls[] = [
|
|
'id' => $call['id'] ?? '',
|
|
'type' => $call['type'] ?? 'function',
|
|
'function' => [
|
|
'name' => $call['function']['name'] ?? '',
|
|
'arguments' => $call['function']['arguments'] ?? '{}',
|
|
],
|
|
];
|
|
}
|
|
}
|
|
|
|
if ( isset( $response['usage'] ) ) {
|
|
$usage = [
|
|
'prompt_tokens' => $response['usage']['prompt_tokens'] ?? 0,
|
|
'completion_tokens' => $response['usage']['completion_tokens'] ?? 0,
|
|
'total_tokens' => $response['usage']['total_tokens'] ?? 0,
|
|
];
|
|
}
|
|
|
|
$result = [
|
|
'content' => $content,
|
|
'provider' => $provider,
|
|
'model' => $model,
|
|
'usage' => $usage,
|
|
'finish_reason' => $finish_reason,
|
|
];
|
|
|
|
if ( ! empty( $tool_calls ) ) {
|
|
$result['tool_calls'] = $tool_calls;
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* 默认响应过滤器
|
|
*
|
|
* @param array $response 响应
|
|
* @param array $args 参数
|
|
* @param string $context 上下文
|
|
* @return array
|
|
*/
|
|
public function filter_chat_response( array $response, array $args, string $context ): array {
|
|
return $response;
|
|
}
|
|
|
|
/**
|
|
* 公共访问器:获取当前模型(供 Facade 的 get_status() 使用)
|
|
*
|
|
* @param string $provider 服务商
|
|
* @return string
|
|
*/
|
|
public function get_current_model_public( string $provider ): string {
|
|
return $this->get_current_model( $provider );
|
|
}
|
|
}
|