$user, 'file' => new \CURLFile($image->path(), 'image/jpeg', 'video-screenshot.jpg'), ], $deadline); if (!is_string($upload['id'] ?? null) || !preg_match('/\A[a-zA-Z0-9_-]{1,128}\z/D', $upload['id'])) { throw new \DomainException('截图上传分析服务失败,请稍后重试'); } // An upload failure can never become a text-only success. Both possible // model calls use this same private image and anonymous request-local user. $payload = [ 'inputs' => (object) [], 'query' => TongueScreenshotAnalysis::prompt(), 'response_mode' => 'blocking', 'user' => $user, 'auto_generate_name' => false, 'files' => [['type' => 'image', 'transfer_method' => 'local_file', 'upload_file_id' => $upload['id']]], ]; $response = self::post($settings['base'] . '/chat-messages', $settings, json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR), $deadline); $answer = self::answer($response); try { $analysis = TongueScreenshotAnalysis::parse($answer); } catch (\DomainException $error) { if ($deadline - microtime(true) < self::MIN_CORRECTION_SECONDS) throw $error; // Only parser rejection permits one correction. Never replay the // rejected answer, reset the budget, upload again or change models. $payload['query'] = TongueScreenshotAnalysis::prompt() . "\n" . self::CORRECTION_PROMPT; $response = self::post($settings['base'] . '/chat-messages', $settings, json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR), $deadline); $analysis = TongueScreenshotAnalysis::parse(self::answer($response)); } return ['analysis' => $analysis, 'model' => $model, 'model_label' => $settings['label']]; } private static function answer(array $response): string { if (!is_string($response['answer'] ?? null) || strlen($response['answer']) > 32768) { throw new \DomainException('截图分析未返回有效结果,请重新截图后重试'); } return $response['answer']; } private static function settings(array $config, string $model): array { if (!in_array($model, ['qwen', 'openai'], true)) throw new \DomainException('截图分析模型无效'); if (empty($config['enable'])) throw new \DomainException('AI 截图分析功能未启用'); // The selected profile owns both the image upload and chat. No cross-model fallback. $profile = $config['models'][$model] ?? []; $base = rtrim(trim((string) ($config['base_url'] ?? '')), '/'); $key = trim((string) ($profile['api_key'] ?? '')); $parts = parse_url($base); if (!is_array($parts) || !in_array(strtolower($parts['scheme'] ?? ''), ['https', 'http'], true) || empty($parts['host']) || isset($parts['user']) || isset($parts['pass']) || isset($parts['query']) || isset($parts['fragment']) || preg_match('/[\x00-\x20\x7f]/', $base) || $key === '' || preg_match('/[\x00-\x1f\x7f]/', (string) ($profile['api_key'] ?? '')) || str_ends_with(strtolower($parts['path'] ?? ''), '/chat/completions')) { throw new \DomainException('截图分析服务配置不完整'); } if (str_ends_with($base, '/chat-messages')) $base = substr($base, 0, -strlen('/chat-messages')); elseif (!str_ends_with($base, '/v1')) $base .= '/v1'; $caFile = (string) ($config['required_attachment_upload']['ca_file'] ?? ''); if ($caFile !== '' && (!is_file($caFile) || !is_readable($caFile))) { // Match the established attachment transport on other hosts: an // optional stale CA path falls back to the system trust store. // CURLOPT_SSL_VERIFYPEER/VERIFYHOST remain enabled below. $caFile = ''; } $label = $model === 'qwen' ? '千问' : 'GPT'; return ['base' => $base, 'key' => $key, 'ca_file' => $caFile, 'label' => $label]; } /** Exactly one bounded POST; redirects and ambient proxies must not receive credentials/images. */ private static function post(string $url, array $settings, $payload, float $deadline): array { $milliseconds = (int) floor(($deadline - microtime(true)) * 1000); if ($milliseconds < 1) throw new \DomainException('截图分析超时,请稍后重试'); $curl = curl_init(); if ($curl === false) throw new \DomainException('截图分析服务暂不可用'); $body = ''; $tooLarge = false; try { $headers = ['Authorization: Bearer ' . $settings['key'], 'Accept: application/json', 'Expect:']; if (is_string($payload)) $headers[] = 'Content-Type: application/json'; $options = [ CURLOPT_URL => $url, CURLOPT_POST => true, CURLOPT_POSTFIELDS => $payload, CURLOPT_HTTPHEADER => $headers, CURLOPT_RETURNTRANSFER => false, CURLOPT_FOLLOWLOCATION => false, CURLOPT_MAXREDIRS => 0, CURLOPT_PROXY => '', CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_TIMEOUT_MS => $milliseconds, CURLOPT_CONNECTTIMEOUT_MS => min(8000, $milliseconds), CURLOPT_NOSIGNAL => true, CURLOPT_WRITEFUNCTION => static function ($handle, string $chunk) use (&$body, &$tooLarge): int { if (strlen($body) + strlen($chunk) > self::MAX_RESPONSE_BYTES) { $tooLarge = true; return 0; } $body .= $chunk; return strlen($chunk); }, ]; if ($settings['ca_file'] !== '') $options[CURLOPT_CAINFO] = $settings['ca_file']; if (!curl_setopt_array($curl, $options)) throw new \DomainException('截图分析服务暂不可用'); $ok = curl_exec($curl); $status = (int) curl_getinfo($curl, CURLINFO_HTTP_CODE); if (curl_errno($curl) === CURLE_OPERATION_TIMEDOUT) throw new \DomainException('截图分析超时,请稍后重试'); if ($ok === false || $tooLarge || $status < 200 || $status >= 300) { // Deliberately discard upstream bodies/errors; they can contain image/model data or secrets. if ($ok !== false && !$tooLarge && $status === 400) { $failure = json_decode($body, true); if (is_array($failure) && ($failure['code'] ?? '') === 'invalid_param' && is_string($failure['message'] ?? null) && preg_match('/\[models\].{0,200}(?:status code 404|404 page not found)/is', $failure['message'])) { throw new \DomainException($settings['label'] . ' 模型接口配置异常,请管理员检查 Dify 中的模型 API 地址。'); } } throw new \DomainException('截图分析服务暂不可用,请稍后重试'); } try { $decoded = json_decode($body, true, 24, JSON_THROW_ON_ERROR); } catch (\Throwable $error) { throw new \DomainException('截图分析服务返回格式无效'); } if (!is_array($decoded)) throw new \DomainException('截图分析服务返回格式无效'); return $decoded; } finally { curl_close($curl); } } }