diff --git a/.env.example b/.env.example index edcec4c73..a08e42dbf 100644 --- a/.env.example +++ b/.env.example @@ -150,6 +150,15 @@ AI_QUALITY_OPTIMIZATION_QUEUE_REPLICAS=2 GEOFLOW_DEBUG_KNOWLEDGE_QUERY_EMBEDDING=false # 私网出站仅允许精确 host:port(逗号分隔);留空时全部拒绝,不支持通配符或路径。 GEOFLOW_OUTBOUND_PRIVATE_TARGETS= +# 出站 HTTP 代理:仅当 GEOFLOW_OUTBOUND_PROXY 与 GEOFLOW_OUTBOUND_PROXY_HOSTS 同时配置才生效。 +# 留空时所有 GEOFlow 出站保持直连(默认行为,AI/搜索/更新器走各自原有路径)。 +# 典型用法:需要绕过目标站点 WAF(jshh.com、Cloudflare 高防)时为特定 host 启用住宅代理。 +# 示例住宅代理 URL: http://user:pass@resi.example.com:8231 +# 示例 SOCKS5: socks5://user:pass@resi.example.com:1080 +# host 支持精确(jshh.com)、后缀(*.jshh.com)、通配(* 或 . 三选一匹配所有公网 host)。 +# 私网/内网目标(private_targets)永远走直连,不受代理配置影响。 +GEOFLOW_OUTBOUND_PROXY= +GEOFLOW_OUTBOUND_PROXY_HOSTS= GEOFLOW_OUTBOUND_JSON_MAX_BYTES=4194304 GEOFLOW_OUTBOUND_AI_MAX_BYTES=8388608 GEOFLOW_OUTBOUND_IMPORT_MAX_BYTES=5242880 diff --git a/.gitignore b/.gitignore index e28e95c54..66657caf9 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,18 @@ __pycache__/ Homestead.json Homestead.yaml Thumbs.db + +# AI tool caches (per-machine) +.agents/ +.boost/ +.claude/ +.codex/ +.cursor/ +.gemini/ +.bak/ +.dockerignore.local + +_route_refs.txt + +.untracked-stash/ + diff --git a/app/Data/Admin/AdminAiModelTestSnapshot.php b/app/Data/Admin/AdminAiModelTestSnapshot.php index d9b4e201b..0f73886a9 100644 --- a/app/Data/Admin/AdminAiModelTestSnapshot.php +++ b/app/Data/Admin/AdminAiModelTestSnapshot.php @@ -27,6 +27,7 @@ public function __construct( public readonly ?int $maxTokens, public readonly bool $gemini, public readonly bool $usesOpenAiResponses, + public readonly bool $volcengineMultimodal, public readonly bool $preparedAsSuperAdmin, public readonly ?AiUsageReservation $reservation, private readonly string $encryptedApiKey, diff --git a/app/Http/Controllers/Admin/AiModelController.php b/app/Http/Controllers/Admin/AiModelController.php index 90455603d..6d48206c0 100644 --- a/app/Http/Controllers/Admin/AiModelController.php +++ b/app/Http/Controllers/Admin/AiModelController.php @@ -399,6 +399,7 @@ public function testConnection(Request $request, int $modelId): JsonResponse $modelName = trim($snapshot->providerModelId); $isGemini = $snapshot->gemini; $usesOpenAiResponses = $snapshot->usesOpenAiResponses; + $isVolcengineMultimodal = $snapshot->volcengineMultimodal; if ($endpoint === '') { return $this->modelTestResponse( @@ -515,7 +516,7 @@ public function testConnection(Request $request, int $modelId): JsonResponse ? $request->withHeaders(['x-goog-api-key' => $apiKey]) : $request->withToken($apiKey); - $testPayload = $this->buildTestPayload($modelName, $modelType, $isGemini, $usesOpenAiResponses); + $testPayload = $this->buildTestPayload($modelName, $modelType, $isGemini, $usesOpenAiResponses, $isVolcengineMultimodal); $response = $this->safeHttp->post( $request, $endpoint, @@ -555,7 +556,7 @@ function () use (&$outboundAttempted, $usageSession, $model, $testPayload): void ); } - if (! $this->isValidTestResponse($json, $modelType, $isGemini, $usesOpenAiResponses)) { + if (! $this->isValidTestResponse($json, $modelType, $isGemini, $usesOpenAiResponses, $isVolcengineMultimodal)) { $usageSession->discarded('direct.p1', 'ai_provider_response_invalid', $json['usage'] ?? null); if ($reservation instanceof AiUsageReservation) { $this->recordModelTestAttempt($reservation); @@ -961,7 +962,8 @@ private function buildTestPayload( string $modelName, string $modelType, bool $isGemini = false, - bool $usesOpenAiResponses = false + bool $usesOpenAiResponses = false, + bool $isVolcengineMultimodal = false ): array { if ($isGemini) { if ($modelType === 'embedding') { @@ -1005,6 +1007,15 @@ private function buildTestPayload( ]; } + if ($modelType === 'embedding' && $isVolcengineMultimodal) { + return [ + 'model' => $modelName, + 'input' => [ + ['type' => 'text', 'text' => 'GEOFlow embedding connection test'], + ], + ]; + } + if ($modelType === 'embedding') { return [ 'model' => $modelName, @@ -1034,7 +1045,8 @@ private function isValidTestResponse( mixed $json, string $modelType, bool $isGemini = false, - bool $usesOpenAiResponses = false + bool $usesOpenAiResponses = false, + bool $isVolcengineMultimodal = false ): bool { if (! is_array($json)) { return false; diff --git a/app/Http/Controllers/Admin/AiVisibilityAnalyticsController.php b/app/Http/Controllers/Admin/AiVisibilityAnalyticsController.php index 7090a66a8..e4f6ff500 100644 --- a/app/Http/Controllers/Admin/AiVisibilityAnalyticsController.php +++ b/app/Http/Controllers/Admin/AiVisibilityAnalyticsController.php @@ -179,13 +179,29 @@ private function keywordLibraries(): Collection $keywords = Keyword::query()->where('keyword', '!=', '')->orderBy('id')->limit(1000) ->get(['id', 'library_id', 'keyword'])->groupBy('library_id'); + $recentlySampled = Schema::hasTable('ai_visibility_runs') + ? AiVisibilityRun::query() + ->whereIn('provider_type', AiVisibilityRun::SAMPLE_PROVIDERS) + ->whereNotNull('keyword') + ->where('keyword', '!=', '') + ->where('created_at', '>=', now()->subDays(7)) + ->distinct() + ->pluck('keyword') + ->mapWithKeys(static fn (string $keyword): array => [$keyword => true]) + ->all() + : []; + return KeywordLibrary::query()->whereIn('id', $keywords->keys())->orderBy('id') ->get(['id', 'name']) ->map(static fn (KeywordLibrary $library): array => [ 'id' => $library->id, 'name' => $library->name, 'keywords' => $keywords->get($library->id, collect()) - ->map(static fn (Keyword $keyword): array => ['id' => $keyword->id, 'keyword' => (string) $keyword->keyword]) + ->map(static fn (Keyword $keyword): array => [ + 'id' => $keyword->id, + 'keyword' => (string) $keyword->keyword, + 'recently_sampled' => isset($recentlySampled[(string) $keyword->keyword]), + ]) ->values()->all(), ])->values(); } diff --git a/app/Http/Controllers/Admin/EnterpriseKnowledgeController.php b/app/Http/Controllers/Admin/EnterpriseKnowledgeController.php index 73008a247..2b0f15c8b 100644 --- a/app/Http/Controllers/Admin/EnterpriseKnowledgeController.php +++ b/app/Http/Controllers/Admin/EnterpriseKnowledgeController.php @@ -67,7 +67,7 @@ public function store(Request $request): RedirectResponse 'description' => ['nullable', 'string', 'max:1000'], 'content' => ['nullable', 'string'], 'enterprise_files' => ['nullable', 'array', 'max:10'], - 'enterprise_files.*' => ['file', File::types(['txt', 'md', 'markdown', 'docx'])->max(8 * 1024)], + 'enterprise_files.*' => ['file', File::types(['txt', 'md', 'markdown', 'docx', 'pdf', 'ppt', 'pptx'])->max(8 * 1024)], ], [ 'enterprise_files.max' => __('admin.enterprise_knowledge.error.files_limit'), ]); diff --git a/app/Http/Controllers/Admin/KnowledgeBaseController.php b/app/Http/Controllers/Admin/KnowledgeBaseController.php index 2dd9fa8dd..1c18d5e3d 100644 --- a/app/Http/Controllers/Admin/KnowledgeBaseController.php +++ b/app/Http/Controllers/Admin/KnowledgeBaseController.php @@ -566,9 +566,9 @@ private function validateKnowledgeImportForm(Request $request): array 'risk_level' => ['nullable', 'in:low,medium,high'], 'review_status' => ['nullable', 'in:unreviewed,reviewed'], 'import_action' => ['nullable', 'in:save,save_and_chunk'], - 'knowledge_file' => ['nullable', File::types(['txt', 'md', 'docx'])->max(8 * 1024)], + 'knowledge_file' => ['nullable', File::types(['txt', 'md', 'docx', 'pdf', 'ppt', 'pptx'])->max(8 * 1024)], 'knowledge_files' => ['nullable', 'array', 'max:10'], - 'knowledge_files.*' => ['file', File::types(['txt', 'md', 'docx'])->max(8 * 1024)], + 'knowledge_files.*' => ['file', File::types(['txt', 'md', 'docx', 'pdf', 'ppt', 'pptx'])->max(8 * 1024)], ], [ 'knowledge_file.mimes' => __('admin.knowledge_bases.error.file_type_invalid'), 'knowledge_file.max' => __('admin.knowledge_bases.error.file_too_large'), @@ -1106,6 +1106,34 @@ private function parseUploadedKnowledgeFile(string $absolutePath, string $origin ]; } + if ($extension === 'pdf') { + $content = app(\App\Services\GeoFlow\KnowledgeSourceParser::class)->extractPdfContent($absolutePath); + if ($content === '') { + throw new \RuntimeException(__('admin.knowledge_bases.error.file_type_invalid')); + } + + return [ + 'content' => $content, + 'file_type' => 'pdf', + ]; + } + + if ($extension === 'pptx') { + $content = app(\App\Services\GeoFlow\KnowledgeSourceParser::class)->extractPptxContent($absolutePath); + if ($content === '') { + throw new \RuntimeException(__('admin.knowledge_bases.error.file_type_invalid')); + } + + return [ + 'content' => $content, + 'file_type' => 'presentation', + ]; + } + + if ($extension === 'ppt') { + throw new \RuntimeException(__('admin.knowledge_bases.error.ppt_legacy_not_supported')); + } + throw new \RuntimeException(__('admin.knowledge_bases.error.file_type_invalid')); } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 5100db249..7a1faf6d3 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -31,6 +31,7 @@ use App\Services\GeoFlow\TaskMonitoringQueryService; use App\Services\Outbound\FinalOutboundSecurityPolicy; use App\Services\Outbound\LaravelPinnedOutboundTransport; +use App\Services\Outbound\OutboundProxyPolicy; use App\Services\Outbound\SafeOutboundHttpClient; use App\Services\Outbound\SecureHttpFactory; use App\Services\Outbound\SystemHostResolver; @@ -71,6 +72,12 @@ public function register(): void $this->app->bind(AgentClient::class, UnixSocketAgentClient::class); $this->app->bind(AiModelWriteLock::class, DatabaseAiModelWriteLock::class); $this->app->singleton(FinalOutboundSecurityPolicy::class); + $this->app->singleton(OutboundProxyPolicy::class, function (): OutboundProxyPolicy { + return OutboundProxyPolicy::fromConfig( + config('geoflow.outbound_proxy_url'), + (string) config('geoflow.outbound_proxy_hosts', ''), + ); + }); $this->app->bind(OutboundTransport::class, function () use ($fixedContextCapability): LaravelPinnedOutboundTransport { return new LaravelPinnedOutboundTransport($fixedContextCapability); }); diff --git a/app/Services/Admin/AdminAiModelTestPreparationService.php b/app/Services/Admin/AdminAiModelTestPreparationService.php index 5d6d6ea1d..a1096ae92 100644 --- a/app/Services/Admin/AdminAiModelTestPreparationService.php +++ b/app/Services/Admin/AdminAiModelTestPreparationService.php @@ -57,6 +57,11 @@ public function prepare(Admin $authenticatedActor, int $modelId): AdminAiModelTe providerModelId: (string) $lockedModel->model_id, maxTokens: $lockedModel->max_tokens === null ? null : (int) $lockedModel->max_tokens, gemini: OpenAiRuntimeProvider::isGeminiProviderUrl($endpoint), + volcengineMultimodal: $modelType === 'embedding' + && OpenAiRuntimeProvider::isVolcengineMultimodalEmbedding( + (string) $lockedModel->api_url, + (string) $lockedModel->model_id, + ), usesOpenAiResponses: $modelType === 'chat' && OpenAiRuntimeProvider::resolveChatDriver( (string) $lockedModel->api_url, @@ -114,6 +119,7 @@ public function prepareSystemBinding( providerModelId: (string) $lockedModel->model_id, maxTokens: $lockedModel->max_tokens === null ? null : (int) $lockedModel->max_tokens, gemini: false, + volcengineMultimodal: false, usesOpenAiResponses: $bindingType === 'ark', preparedAsSuperAdmin: true, reservation: $reservation, @@ -342,6 +348,11 @@ private function resolveEndpoint(AiModel $model, string $modelType): string return rtrim($baseUrl, '/').'/responses'; } + if ($modelType === 'embedding' + && OpenAiRuntimeProvider::isVolcengineMultimodalEmbedding((string) $model->api_url, (string) $model->model_id)) { + return rtrim($baseUrl, '/').OpenAiRuntimeProvider::volcengineMultimodalEmbeddingPath(); + } + return rtrim($baseUrl, '/').($modelType === 'embedding' ? '/embeddings' : '/chat/completions'); } } diff --git a/app/Services/GeoFlow/KnowledgeChunkSyncService.php b/app/Services/GeoFlow/KnowledgeChunkSyncService.php index f7a7f15f9..95c482d25 100644 --- a/app/Services/GeoFlow/KnowledgeChunkSyncService.php +++ b/app/Services/GeoFlow/KnowledgeChunkSyncService.php @@ -2368,6 +2368,10 @@ private function requestEmbeddingVectors( ); } + if ($this->isVolcengineMultimodalEmbeddingMetadata($embeddingMetadata)) { + return $this->requestVolcengineMultimodalEmbeddings($inputs, $embeddingMetadata); + } + return $this->requestOpenAiCompatibleEmbeddings($inputs, $embeddingMetadata); } @@ -2435,6 +2439,79 @@ private function requestOpenAiCompatibleEmbeddings( ); } + /** + * 直连火山方舟多模态 embedding endpoint。 + * + * 该 endpoint(/embeddings/multimodal-embedding-v1)与通用 /embeddings 不兼容, + * 请求/响应采用 OpenAI 兼容结构但 input 必须是 [{"type":"text","text":...}] 形式。 + * + * 请求通过统一安全出站网关校验并固定目标地址。 + * + * @param list $inputs + * @param array{model_id:int,model_name:string,provider:string,api_url:string,api_key:string,driver:string} $embeddingMetadata + */ + private function requestVolcengineMultimodalEmbeddings( + array $inputs, + array $embeddingMetadata, + ): KnowledgeEmbeddingProviderResult { + $endpoint = rtrim((string) $embeddingMetadata['api_url'], '/') + .OpenAiRuntimeProvider::volcengineMultimodalEmbeddingPath(); + + $payload = [ + 'model' => (string) $embeddingMetadata['model_name'], + 'input' => array_map( + static fn (string $text): array => ['type' => 'text', 'text' => $text], + $inputs, + ), + ]; + + $request = $this->http->acceptJson() + ->asJson() + ->withToken((string) $embeddingMetadata['api_key']) + ->connectTimeout(8) + ->timeout(45); + $response = $this->safeHttp->post($request, $endpoint, $payload, (int) config('geoflow.outbound_ai_max_bytes', 8 * 1024 * 1024)); + + if (! $response->successful()) { + $error = data_get($response->json(), 'error.message'); + $message = is_string($error) && $this->isEmbeddingBatchSizeError($error) + ? 'Embedding provider rejected batch size.' + : 'Embedding provider request failed.'; + + throw new \RuntimeException(sprintf( + 'HTTP request returned status code %d: %s', + $response->status(), + $message, + ), 0, $response->toException()); + } + + $data = $response->json(); + $rows = is_array($data) ? ($data['data'] ?? []) : []; + if (! is_array($rows)) { + return new KnowledgeEmbeddingProviderResult([], is_array($data) ? ($data['usage'] ?? null) : null); + } + + $embeddings = []; + foreach ($rows as $position => $row) { + if (! is_array($row)) { + continue; + } + + $index = $position; + if (array_key_exists('index', $row) && is_numeric($row['index'])) { + $index = max(0, (int) $row['index']); + } + + $embeddings[$index] = $row['embedding'] ?? null; + } + ksort($embeddings); + + return new KnowledgeEmbeddingProviderResult( + $embeddings, + is_array($data) ? ($data['usage'] ?? null) : null, + ); + } + private function embeddingBatchSize(): int { return max(1, min(64, (int) config('geoflow.embedding_batch_size', 1))); @@ -2489,6 +2566,19 @@ private function isGeminiEmbeddingMetadata(array $embeddingMetadata): bool || OpenAiRuntimeProvider::isGeminiProviderUrl((string) ($embeddingMetadata['api_url'] ?? '')); } + /** + * @param array $embeddingMetadata + */ + private function isVolcengineMultimodalEmbeddingMetadata(array $embeddingMetadata): bool + { + if ((string) ($embeddingMetadata['driver'] ?? '') === 'volcengine-multimodal') { + return true; + } + + return OpenAiRuntimeProvider::isVolcengineProviderUrl((string) ($embeddingMetadata['api_url'] ?? '')) + && OpenAiRuntimeProvider::isVolcengineMultimodalEmbeddingModel((string) ($embeddingMetadata['model_name'] ?? '')); + } + private function normalizeGeminiEmbeddingSegment(string $value): string { return trim(preg_replace('/\s+/u', ' ', $value) ?: $value); diff --git a/app/Services/GeoFlow/KnowledgeSourceParser.php b/app/Services/GeoFlow/KnowledgeSourceParser.php index 6ae791b0b..ce06decf2 100644 --- a/app/Services/GeoFlow/KnowledgeSourceParser.php +++ b/app/Services/GeoFlow/KnowledgeSourceParser.php @@ -5,6 +5,7 @@ use Illuminate\Http\Request; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Storage; +use Smalot\PdfParser\Parser as SmalotPdfParser; final class KnowledgeSourceParser { @@ -14,6 +15,12 @@ final class KnowledgeSourceParser private const MAX_DOCX_COMPRESSION_RATIO = 100; + private const MAX_PPTX_XML_BYTES = 16 * 1024 * 1024; + + private const MAX_PPTX_COMPRESSION_RATIO = 100; + + private const MAX_PDF_BYTES = 32 * 1024 * 1024; + public function storeUploadedKnowledgeFile(UploadedFile $file, string $relativeDirectory = 'uploads/knowledge'): string { $extension = strtolower($file->getClientOriginalExtension() ?: 'txt'); @@ -191,7 +198,7 @@ public function resolveKnowledgeFileType(string $requestedType, string $manualCo $fileType = (string) ($parsedFiles[0]['file_type'] ?? 'markdown'); - return in_array($fileType, ['markdown', 'word', 'text'], true) ? $fileType : 'markdown'; + return in_array($fileType, ['markdown', 'word', 'text', 'pdf', 'presentation'], true) ? $fileType : 'markdown'; } /** @@ -249,6 +256,34 @@ public function parseUploadedKnowledgeFile(string $absolutePath, string $origina ]; } + if ($extension === 'pdf') { + $content = $this->extractPdfContent($absolutePath); + if ($content === '') { + throw new \RuntimeException(__('admin.knowledge_bases.error.file_type_invalid')); + } + + return [ + 'content' => $content, + 'file_type' => 'pdf', + ]; + } + + if ($extension === 'pptx') { + $content = $this->extractPptxContent($absolutePath); + if ($content === '') { + throw new \RuntimeException(__('admin.knowledge_bases.error.file_type_invalid')); + } + + return [ + 'content' => $content, + 'file_type' => 'presentation', + ]; + } + + if ($extension === 'ppt') { + throw new \RuntimeException(__('admin.knowledge_bases.error.ppt_legacy_not_supported')); + } + throw new \RuntimeException(__('admin.knowledge_bases.error.file_type_invalid')); } @@ -434,6 +469,138 @@ public function extractDocxContent(string $absolutePath): string return is_string($content) ? $this->normalizeKnowledgeText($content) : ''; } + + public function extractPdfContent(string $absolutePath): string + { + if (@filesize($absolutePath) > self::MAX_PDF_BYTES) { + throw new \RuntimeException(__('admin.knowledge_bases.error.file_too_large')); + } + if (! class_exists(SmalotPdfParser::class)) { + return ''; + } + + try { + $parser = new SmalotPdfParser; + $document = $parser->parseFile($absolutePath); + $text = $document->getText(); + if (! is_string($text) || $text === '') { + return ''; + } + + if (strlen($text) > self::MAX_KNOWLEDGE_BYTES) { + throw new \RuntimeException(__('admin.knowledge_bases.error.content_too_large')); + } + + return $this->normalizeKnowledgeText($this->convertUploadedTextToUtf8($text)); + } catch (\Throwable $exception) { + return ''; + } + } + + public function extractPptxContent(string $absolutePath): string + { + if (! class_exists('ZipArchive') || ! class_exists('XMLReader')) { + return ''; + } + + $zip = new \ZipArchive; + if ($zip->open($absolutePath) !== true) { + return ''; + } + + $slideNames = []; + for ($i = 0; $i < $zip->numFiles; $i++) { + $name = $zip->getNameIndex($i); + if (is_string($name) && preg_match('#^ppt/slides/slide\d+\.xml$#', $name)) { + $slideNames[] = $name; + } + } + sort($slideNames, SORT_NATURAL); + + if ($slideNames === []) { + $zip->close(); + return ''; + } + + $presentationNamespace = 'http://schemas.openxmlformats.org/drawingml/2006/main'; + $textOutput = tmpfile(); + if (! is_resource($textOutput)) { + $zip->close(); + return ''; + } + + $slideNumber = 0; + $contentBytes = 0; + foreach ($slideNames as $slideName) { + $stat = $zip->statName($slideName); + $uncompressedSize = is_array($stat) ? max(0, (int) ($stat['size'] ?? 0)) : 0; + $compressedSize = is_array($stat) ? max(1, (int) ($stat['comp_size'] ?? 0)) : 1; + if ($uncompressedSize > self::MAX_PPTX_XML_BYTES || ($uncompressedSize / $compressedSize) > self::MAX_PPTX_COMPRESSION_RATIO) { + fclose($textOutput); + $zip->close(); + throw new \RuntimeException(__('admin.knowledge_bases.error.pptx_expansion_too_large')); + } + + $source = $zip->getStream($slideName); + if (! is_resource($source)) { + continue; + } + + $temporary = tmpfile(); + if (! is_resource($temporary)) { + fclose($source); + fclose($textOutput); + $zip->close(); + return ''; + } + + $copiedBytes = stream_copy_to_stream($source, $temporary, self::MAX_PPTX_XML_BYTES + 1); + fclose($source); + if (! is_int($copiedBytes) || $copiedBytes > self::MAX_PPTX_XML_BYTES) { + fclose($temporary); + fclose($textOutput); + $zip->close(); + throw new \RuntimeException(__('admin.knowledge_bases.error.pptx_expansion_too_large')); + } + + $metadata = stream_get_meta_data($temporary); + $temporaryPath = (string) ($metadata['uri'] ?? ''); + $reader = new \XMLReader; + if ($temporaryPath !== '' && @$reader->open($temporaryPath, null, LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING)) { + $slideNumber++; + fwrite($textOutput, "\n\n# Slide " . $slideNumber . "\n\n"); + while ($reader->read()) { + if ( + $reader->nodeType === \XMLReader::ELEMENT + && $reader->localName === 't' + && $reader->namespaceURI === $presentationNamespace + ) { + $value = trim($reader->readString()); + if ($value !== '') { + $contentBytes += strlen($value) + 1; + if ($contentBytes > self::MAX_KNOWLEDGE_BYTES) { + $reader->close(); + fclose($temporary); + fclose($textOutput); + $zip->close(); + throw new \RuntimeException(__('admin.knowledge_bases.error.content_too_large')); + } + fwrite($textOutput, $value . "\n"); + } + } + } + $reader->close(); + } + fclose($temporary); + } + $zip->close(); + rewind($textOutput); + $content = stream_get_contents($textOutput, self::MAX_KNOWLEDGE_BYTES + 1); + fclose($textOutput); + + return is_string($content) ? $this->normalizeKnowledgeText($content) : ''; + } + private function assertContentSize(string $content): void { if (strlen($content) > self::MAX_KNOWLEDGE_BYTES) { diff --git a/app/Services/Outbound/LaravelPinnedOutboundTransport.php b/app/Services/Outbound/LaravelPinnedOutboundTransport.php index 1dce868e8..708163622 100644 --- a/app/Services/Outbound/LaravelPinnedOutboundTransport.php +++ b/app/Services/Outbound/LaravelPinnedOutboundTransport.php @@ -29,7 +29,7 @@ public function send( 'allow_redirects' => false, 'decode_content' => false, 'http_errors' => false, - 'proxy' => '', + 'proxy' => $target->proxyUrl ?? '', 'stream' => false, 'verify' => true, 'force_ip_resolve' => str_contains($target->selectedIp, ':') ? 'v6' : 'v4', diff --git a/app/Services/Outbound/OutboundProxyPolicy.php b/app/Services/Outbound/OutboundProxyPolicy.php new file mode 100644 index 000000000..5943d2b5f --- /dev/null +++ b/app/Services/Outbound/OutboundProxyPolicy.php @@ -0,0 +1,68 @@ + $hostPatterns + */ + public function __construct( + public ?string $proxyUrl, + public array $hostPatterns, + ) {} + + public static function fromConfig(?string $proxyUrl, string $hostList): self + { + $proxyUrl = $proxyUrl !== null ? trim($proxyUrl) : ''; + $patterns = array_values(array_filter( + array_map('trim', explode(',', $hostList)), + static fn (string $p): bool => $p !== '' + )); + + return new self($proxyUrl === '' ? null : $proxyUrl, $patterns); + } + + public function isEnabled(): bool + { + return $this->proxyUrl !== null && $this->hostPatterns !== []; + } + + public function appliesTo(string $host): bool + { + if (! $this->isEnabled()) { + return false; + } + $host = strtolower(trim($host)); + if ($host === '') { + return false; + } + foreach ($this->hostPatterns as $pattern) { + $needle = strtolower(trim($pattern)); + if ($needle === '' || $needle === '*' || $needle === '.') { + return true; + } + if (str_starts_with($needle, '*.')) { + $suffix = substr($needle, 1); + if ($suffix !== '' && strlen($host) > strlen($suffix) && str_ends_with($host, $suffix)) { + return true; + } + + continue; + } + if ($needle === $host) { + return true; + } + } + + return false; + } +} diff --git a/app/Services/Outbound/ResolvedOutboundTarget.php b/app/Services/Outbound/ResolvedOutboundTarget.php index 4d07c818f..59bd3a790 100644 --- a/app/Services/Outbound/ResolvedOutboundTarget.php +++ b/app/Services/Outbound/ResolvedOutboundTarget.php @@ -14,5 +14,6 @@ public function __construct( public int $port, public array $addresses, public string $selectedIp, + public ?string $proxyUrl = null, ) {} } diff --git a/app/Services/Outbound/SafeOutboundHttpClient.php b/app/Services/Outbound/SafeOutboundHttpClient.php index 99573f80c..4e6bab4cf 100644 --- a/app/Services/Outbound/SafeOutboundHttpClient.php +++ b/app/Services/Outbound/SafeOutboundHttpClient.php @@ -59,6 +59,7 @@ final class SafeOutboundHttpClient public function __construct( private readonly HostResolver $resolver, private readonly OutboundTransport $transport, + private readonly OutboundProxyPolicy $proxyPolicy = new OutboundProxyPolicy(null, []), ) {} /** @param array $query */ @@ -116,6 +117,7 @@ public function send( while (true) { $target = $this->resolveTarget($currentUrl); + $target = $this->applyProxyPolicy($target); $crossOrigin = $previousTarget instanceof ResolvedOutboundTarget && ! $this->sameOrigin($previousTarget, $target); if ($crossOrigin) { $currentRequest = CrossOriginRequestSanitizer::pendingRequest($currentRequest, $method, $target->url); @@ -166,6 +168,23 @@ public function send( } } + private function applyProxyPolicy(ResolvedOutboundTarget $target): ResolvedOutboundTarget + { + if ($target->proxyUrl !== null || ! $this->proxyPolicy->appliesTo($target->host)) { + return $target; + } + + return new ResolvedOutboundTarget( + $target->url, + $target->scheme, + $target->host, + $target->port, + $target->addresses, + $target->selectedIp, + (string) $this->proxyPolicy->proxyUrl, + ); + } + public function resolveTarget(string $url): ResolvedOutboundTarget { [$normalizedUrl, $scheme, $host, $port] = $this->normalizeUrl($url); diff --git a/app/Support/GeoFlow/OpenAiRuntimeProvider.php b/app/Support/GeoFlow/OpenAiRuntimeProvider.php index e9d6c67c1..d5315b065 100644 --- a/app/Support/GeoFlow/OpenAiRuntimeProvider.php +++ b/app/Support/GeoFlow/OpenAiRuntimeProvider.php @@ -112,6 +112,10 @@ public static function resolveEmbeddingDriver(string $apiUrl, string $modelId = return 'gemini'; } + if (self::isVolcengineProviderUrl($apiUrl) && self::isVolcengineMultimodalEmbeddingModel($modelId)) { + return 'volcengine-multimodal'; + } + $host = strtolower((string) (parse_url(trim($apiUrl), PHP_URL_HOST) ?? '')); return $host === 'api.openai.com' ? 'openai' : 'openai-compatible'; @@ -127,6 +131,56 @@ public static function isGeminiProviderUrl(string $apiUrl): bool return $host === 'generativelanguage.googleapis.com'; } + /** + * 判断 URL 是否指向火山方舟 (Volcengine ARK) API 服务。 + */ + public static function isVolcengineProviderUrl(string $apiUrl): bool + { + $host = strtolower((string) (parse_url(trim($apiUrl), PHP_URL_HOST) ?? '')); + + return $host !== '' && str_ends_with($host, '.volces.com'); + } + + /** + * 判断模型 ID 是否属于火山方舟多模态 embedding 系列。 + * + * 火山方舟多模态 embedding(如 doubao-embedding-vision-*)走独立 endpoint, + * 不能复用通用 /v3/embeddings 接口。 + */ + public static function isVolcengineMultimodalEmbeddingModel(string $modelId): bool + { + $needle = strtolower(trim($modelId)); + if ($needle === '') { + return false; + } + if (str_contains($needle, 'vision')) { + return true; + } + + return str_starts_with($needle, 'ep-'); + } + + /** + * 火山方舟多模态 embedding API 子路径,独立于通用 /embeddings。 + * + * 参考:https://www.volcengine.com/docs/82379/1366569 + */ + public static function volcengineMultimodalEmbeddingPath(): string + { + return '/embeddings/multimodal-embedding-v1'; + } + + /** + * 综合判断 URL + 模型是否属于火山方舟多模态 embedding。 + * + * 仅当 URL 指向 *.volces.com 且模型 id 包含 "vision" 时返回 true。 + */ + public static function isVolcengineMultimodalEmbedding(string $apiUrl, string $modelId): bool + { + return self::isVolcengineProviderUrl($apiUrl) + && self::isVolcengineMultimodalEmbeddingModel($modelId); + } + /** * Gemini 原生 Chat/Embedding API 共用 v1beta base,不使用 OpenAI compatibility 子路径。 */ diff --git a/composer.json b/composer.json index f190edbbc..742677cc3 100644 --- a/composer.json +++ b/composer.json @@ -13,7 +13,8 @@ "laravel/horizon": "^5.45", "laravel/reverb": "^1.0", "laravel/sanctum": "^4.3", - "laravel/tinker": "^2.10.1" + "laravel/tinker": "^2.10.1", + "smalot/pdfparser": "^2.12" }, "require-dev": { "fakerphp/faker": "^1.23", diff --git a/composer.lock b/composer.lock index 21bec22aa..154e7031f 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,11 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], + "content-hash": "fd791f4005dc2d8a7061adee81d530ef", + + "content-hash": "8427f7b21ba65e63d42577f0328c27c6", + "packages": [ { "name": "aws/aws-crt-php", @@ -4871,6 +4875,57 @@ ], "time": "2024-06-11T12:45:25+00:00" }, + { + "name": "smalot/pdfparser", + "version": "v2.12.5", + "source": { + "type": "git", + "url": "https://github.com/smalot/pdfparser.git", + "reference": "2cfa0d92bd557875c9f52a75fde0e8392302a354" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/smalot/pdfparser/zipball/2cfa0d92bd557875c9f52a75fde0e8392302a354", + "reference": "2cfa0d92bd557875c9f52a75fde0e8392302a354", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "ext-zlib": "*", + "php": ">=7.1", + "symfony/polyfill-mbstring": "^1.18" + }, + "type": "library", + "autoload": { + "psr-0": { + "Smalot\\PdfParser\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0" + ], + "authors": [ + { + "name": "Sebastien MALOT", + "email": "sebastien@malot.fr" + } + ], + "description": "Pdf parser library. Can read and extract information from pdf file.", + "homepage": "https://www.pdfparser.org", + "keywords": [ + "extract", + "parse", + "parser", + "pdf", + "text" + ], + "support": { + "issues": "https://github.com/smalot/pdfparser/issues", + "source": "https://github.com/smalot/pdfparser/tree/v2.12.5" + }, + "time": "2026-04-17T11:37:58+00:00" + }, { "name": "symfony/clock", "version": "v7.4.8", @@ -10252,5 +10307,5 @@ "platform-overrides": { "php": "8.3.0" }, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/config/geoflow.php b/config/geoflow.php index c074c82a5..39c9a30f8 100644 --- a/config/geoflow.php +++ b/config/geoflow.php @@ -273,6 +273,11 @@ static function (string $hostname) use ($hostedRootDomains): bool { ], // 统一出站安全网关:仅此处列出的精确 host:port 可连接私网地址;不支持通配符或路径。 'outbound_private_targets' => array_values(array_filter(array_map('trim', explode(',', (string) env('GEOFLOW_OUTBOUND_PRIVATE_TARGETS', ''))), static fn (string $target): bool => $target !== '')), + // 出口代理:仅当 GEOFLOW_OUTBOUND_PROXY 与 GEOFLOW_OUTBOUND_PROXY_HOSTS 同时配置时才生效; + // host 支持精确(jshh.com)、后缀(*.jshh.com)、通配(* / . 三选一代表全部)。 + // 仅 GEOFlow 公网出站会走代理;私网目标由 outbound_private_targets 控制,永远直连。 + 'outbound_proxy_url' => env('GEOFLOW_OUTBOUND_PROXY'), + 'outbound_proxy_hosts' => (string) env('GEOFLOW_OUTBOUND_PROXY_HOSTS', ''), 'outbound_json_max_bytes' => max(1, (int) env('GEOFLOW_OUTBOUND_JSON_MAX_BYTES', 4 * 1024 * 1024)), 'outbound_ai_max_bytes' => max(1, (int) env('GEOFLOW_OUTBOUND_AI_MAX_BYTES', 8 * 1024 * 1024)), 'outbound_import_max_bytes' => max(1, (int) env('GEOFLOW_OUTBOUND_IMPORT_MAX_BYTES', 5 * 1024 * 1024)), diff --git a/lang/en/admin.php b/lang/en/admin.php index b7d64e2a0..9046d4420 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -3048,6 +3048,13 @@ 'queued' => 'Queued collection for :count keywords.', 'empty' => 'Select at least one keyword.', 'empty_library' => 'Keyword library is empty. Add keywords first.', + 'empty' => 'Select at least one keyword.', + 'empty_library' => 'Keyword library is empty. Add keywords first.', + 'recently_sampled' => 'Sampled', + 'recently_sampled_hint' => 'Already collected in the last 7 days', + 'selection_counter' => 'Selected :count/:cap', + 'select_all_hint_overflow' => 'Library exceeds :cap entries — the first :cap eligible keywords are selected, recently sampled ones are skipped', + 'select_all_clear' => 'Clear selection', ], 'detect_button' => 'AI auto-detect competitors', 'detect_queued' => 'Queued AI competitor detection. New competitors will be added automatically.', @@ -3996,7 +4003,7 @@ 'source_files_title' => 'Upload Documents', 'source_files_desc' => 'Batch upload files. They will be cleaned and merged in file order.', 'dropzone_title' => 'Click to choose files or drag them here', - 'dropzone_desc' => 'TXT, MD, and DOCX are supported. Save legacy DOC files as DOCX first.', + 'dropzone_desc' => 'TXT, MD, DOCX, PDF, and PPTX are supported. Save legacy DOC/PPT files as DOCX/PPTX first.', 'upload_limits' => 'Up to 10 files, with an 8MB per-file and combined limit', 'content_counter' => ':count chars', 'file_status_ready' => 'Ready', @@ -4013,7 +4020,7 @@ 'pipeline_vector' => 'Chunk and Vectorize', 'pipeline_vector_desc' => 'Save the source and build chunks for later vector writing.', 'import_rules_title' => 'Import Rules', - 'import_rule_formats' => 'TXT, MD, and DOCX are supported. Legacy DOC is not parsed directly.', + 'import_rule_formats' => 'TXT, MD, DOCX, PDF, and PPTX are supported. Legacy DOC/PPT is not parsed directly.', 'import_rule_merge' => 'Multiple sources receive source headings for review and traceability.', 'import_rule_server' => 'If uploads are blocked by deployment limits, adjust PHP/Nginx upload settings.', 'import_submit_only' => 'Submit', @@ -4034,6 +4041,8 @@ 'imported_multi_file_name_with_first' => ':name and :count source files', 'format_help' => 'Supported file formats:', 'format_txt' => 'TXT - plain text file', + 'format_pdf' => 'PDF - PDF document', + 'format_pptx' => 'PPTX - PowerPoint presentation', 'format_md' => 'MD - Markdown file', 'format_docx' => 'DOCX - Word document with automatic body extraction', 'format_doc' => 'DOC - legacy Word document; please save as DOCX before uploading', @@ -4082,8 +4091,10 @@ 'total_files_too_large' => 'The combined upload size cannot exceed 8MB.', 'content_too_large' => 'Knowledge base content cannot exceed 8MB.', 'docx_expansion_too_large' => 'The expanded DOCX content is too large or has an unsafe compression ratio.', + 'pptx_expansion_too_large' => 'The expanded PPTX content is too large or has an unsafe compression ratio.', + 'ppt_legacy_not_supported' => 'Legacy PPT binary format is not supported. Save the file as PPTX before uploading.', 'files_limit' => 'Upload up to 10 knowledge documents at a time.', - 'file_type_invalid' => 'Unsupported file type. Upload TXT, MD, or DOCX files.', + 'file_type_invalid' => 'Unsupported file type. Upload TXT, MD, DOCX, PDF, or PPTX files.', 'in_use' => 'This knowledge base is referenced by :count tasks. Remove the references before deleting it.', 'system_delete_forbidden' => 'This knowledge base powers the AI Workspace and cannot be deleted. You may edit it or restore a revision from its detail page.', 'not_found' => 'Knowledge base not found', @@ -4155,7 +4166,7 @@ 'upload_title' => 'Upload Company Materials', 'upload_desc' => 'Supports TXT, Markdown, and DOCX. Up to 10 files, with an 8MB per-file and combined limit.', 'dropzone_title' => 'Click to select files or drag them here', - 'dropzone_desc' => 'Supports TXT, MD, and DOCX. Save legacy DOC files as DOCX first.', + 'dropzone_desc' => 'Supports TXT, MD, DOCX, PDF, and PPTX. Save legacy DOC/PPT files as DOCX/PPTX first.', 'drop_fallback' => 'This browser cannot assign dropped files directly. Please click the upload area to select files.', 'content_title' => 'Supplemental Text', 'content_desc' => 'Paste website copy, product docs, sales materials, FAQ, or banned phrases.', diff --git a/lang/pt_BR/admin.php b/lang/pt_BR/admin.php index 1dfc3a96a..75da00eb4 100644 --- a/lang/pt_BR/admin.php +++ b/lang/pt_BR/admin.php @@ -3799,7 +3799,7 @@ 'source_files_title' => 'Enviar Documentos', 'source_files_desc' => 'Envie arquivos em lote. Eles serão limpos e mesclados na ordem enviada.', 'dropzone_title' => 'Clique para escolher ou arraste arquivos aqui', - 'dropzone_desc' => 'TXT, MD e DOCX são suportados. Salve DOC antigo como DOCX antes.', + 'dropzone_desc' => 'TXT, MD, DOCX, PDF e PPTX são suportados. Salve DOC/PPT antigos como DOCX/PPTX antes.', 'upload_limits' => 'Até 10 arquivos, com limite de 8MB por arquivo e no total', 'content_counter' => ':count caracteres', 'file_status_ready' => 'Pronto', @@ -3816,7 +3816,7 @@ 'pipeline_vector' => 'Fragmentar e Vetorizar', 'pipeline_vector_desc' => 'Salva o conteúdo e cria fragmentos para vetores futuros.', 'import_rules_title' => 'Regras de Importação', - 'import_rule_formats' => 'TXT, MD e DOCX são suportados. DOC antigo não é analisado diretamente.', + 'import_rule_formats' => 'TXT, MD, DOCX, PDF e PPTX são suportados. DOC/PPT antigos não são analisados diretamente.', 'import_rule_merge' => 'Várias fontes recebem títulos para revisão e rastreabilidade.', 'import_rule_server' => 'Se o servidor bloquear uploads, ajuste os limites de PHP/Nginx.', 'import_submit_only' => 'Enviar', @@ -3837,6 +3837,8 @@ 'imported_multi_file_name_with_first' => ':name e :count fontes', 'format_help' => 'Formatos suportados:', 'format_txt' => 'TXT', + 'format_pdf' => 'PDF', + 'format_pptx' => 'PPTX', 'format_md' => 'MD - Markdown', 'format_docx' => 'DOCX - Word', 'confirm_delete' => 'Excluir base ":name"?', @@ -3848,6 +3850,8 @@ 'total_files_too_large' => 'O tamanho total dos arquivos não pode exceder 8MB.', 'content_too_large' => 'O conteúdo da base não pode exceder 8MB.', 'docx_expansion_too_large' => 'O conteúdo expandido do DOCX é muito grande ou tem uma taxa de compressão insegura.', + 'pptx_expansion_too_large' => 'O conteúdo expandido do PPTX é muito grande ou tem uma taxa de compressão insegura.', + 'ppt_legacy_not_supported' => 'O formato PPT legado não é suportado. Salve o arquivo como PPTX antes de enviar.', 'files_limit' => 'Envie no máximo 10 documentos por vez', 'file_type_invalid' => 'Tipo não suportado', 'in_use' => 'Base referenciada por :count tarefas', @@ -3931,7 +3935,7 @@ 'upload_title' => 'Enviar Materiais da Empresa', 'upload_desc' => 'Suporta TXT, Markdown e DOCX. Até 10 arquivos, com limite de 8MB por arquivo e no total.', 'dropzone_title' => 'Clique para selecionar ou arraste arquivos aqui', - 'dropzone_desc' => 'Suporta TXT, MD e DOCX. Salve arquivos DOC antigos como DOCX primeiro.', + 'dropzone_desc' => 'Suporta TXT, MD, DOCX, PDF e PPTX. Salve arquivos DOC/PPT antigos como DOCX/PPTX primeiro.', 'drop_fallback' => 'Este navegador não permite atribuir arquivos arrastados diretamente. Clique na área de upload para selecionar.', 'content_title' => 'Texto Complementar', 'content_desc' => 'Cole textos do site, documentos de produto, materiais comerciais, FAQ ou expressões proibidas.', diff --git a/lang/zh_CN/admin.php b/lang/zh_CN/admin.php index ad64b81eb..74ff035b3 100644 --- a/lang/zh_CN/admin.php +++ b/lang/zh_CN/admin.php @@ -3096,6 +3096,13 @@ 'queued' => '已派发 :count 个关键词的采集任务,完成后数据自动入库。', 'empty' => '请至少勾选一个关键词。', 'empty_library' => '关键词库为空,请先到「内容资产 → 关键词库」添加关键词。', + 'empty' => '请至少勾选一个关键词。', + 'empty_library' => '关键词库为空,请先到「内容资产 → 关键词库」添加关键词。', + 'recently_sampled' => '最近已采', + 'recently_sampled_hint' => '近 7 天已发起过采集的关键词', + 'selection_counter' => '已选 :count/:cap 个', + 'select_all_hint_overflow' => '本库超过 :cap 个 关键词,已自动按列表顺序取前 :cap 个', + 'select_all_clear' => '取消选择', ], 'detect_button' => 'AI 自动识别竞品', 'detect_queued' => '已派发 AI 竞品识别任务,完成后新竞品自动加入名单(标注 AI)。', @@ -4056,7 +4063,7 @@ 'source_files_title' => '上传文档', 'source_files_desc' => '支持批量上传,系统会按文件顺序清洗并合并。', 'dropzone_title' => '点击选择或拖拽文件到这里', - 'dropzone_desc' => '支持 TXT、MD、DOCX,旧版 DOC 请先另存为 DOCX。', + 'dropzone_desc' => '支持 TXT、MD、DOCX、PDF、PPT、PPTX;旧版 PPT/DOC 请先另存为新版格式。', 'upload_limits' => '最多 10 个文件,单文件及合计均不超过 8MB', 'content_counter' => ':count 字', 'file_status_ready' => '待合并', @@ -4073,7 +4080,7 @@ 'pipeline_vector' => '切片向量化', 'pipeline_vector_desc' => '保存后同步生成知识片段,后续可写入向量。', 'import_rules_title' => '导入规则', - 'import_rule_formats' => '支持 TXT、MD、DOCX;暂不直接解析旧版 DOC。', + 'import_rule_formats' => '支持 TXT、MD、DOCX、PDF、PPTX;暂不直接解析旧版 DOC/PPT。', 'import_rule_merge' => '多来源会自动添加来源标题,方便后续查看和追溯。', 'import_rule_server' => '若部署环境限制上传大小,请同步调整 PHP/Nginx 的上传限制。', 'import_submit_only' => '提交', @@ -4094,6 +4101,8 @@ 'imported_multi_file_name_with_first' => ':name 等 :count 份资料', 'format_help' => '支持的文件格式:', 'format_txt' => 'TXT - 纯文本文件', + 'format_pdf' => 'PDF - PDF 文档', + 'format_pptx' => 'PPTX - PowerPoint 演示文稿', 'format_md' => 'MD - Markdown文件', 'format_docx' => 'DOCX - Word文档,支持自动提取正文', 'format_doc' => 'DOC - 旧版 Word 文档,请先另存为 DOCX 后上传', @@ -4142,8 +4151,10 @@ 'total_files_too_large' => '本次上传文件合计不能超过 8MB', 'content_too_large' => '知识库正文不能超过 8MB', 'docx_expansion_too_large' => 'DOCX 解压后的正文过大或压缩比异常,已停止解析', + 'pptx_expansion_too_large' => 'PPTX 解压后的正文过大或压缩比异常,已停止解析', + 'ppt_legacy_not_supported' => '旧版 PPT 二进制格式暂不支持解析,请先另存为 PPTX 再上传', 'files_limit' => '一次最多上传 10 个知识文档', - 'file_type_invalid' => '不支持的文件格式,请上传 TXT、MD 或 DOCX 文件', + 'file_type_invalid' => '不支持的文件格式,请上传 TXT、MD、DOCX、PDF、PPTX 文件', 'in_use' => '该知识库正在被 :count 个任务引用,请先解除引用后再删除', 'system_delete_forbidden' => '该知识库是 AI 工作台的系统知识,不能删除;可以在详情页编辑或恢复修订。', 'not_found' => '知识库不存在', @@ -4213,9 +4224,9 @@ 'description' => '说明', 'description_placeholder' => '记录资料范围、业务线或适用任务', 'upload_title' => '上传企业资料', - 'upload_desc' => '支持 TXT、Markdown、DOCX,最多 10 个文件,单文件及合计均不超过 8MB。', + 'upload_desc' => '支持 TXT、Markdown、DOCX、PDF、PPTX,最多 10 个文件,单文件及合计均不超过 8MB。', 'dropzone_title' => '点击选择或拖拽文件到这里', - 'dropzone_desc' => '支持 TXT、MD、DOCX;旧版 DOC 请先另存为 DOCX。', + 'dropzone_desc' => '支持 TXT、MD、DOCX、PDF、PPTX;旧版 PPT/DOC 请先另存为新版格式。', 'drop_fallback' => '当前浏览器不支持直接拖拽写入文件,请点击上传区域选择文件。', 'content_title' => '补充文本', 'content_desc' => '可以粘贴官网、产品手册、销售资料、FAQ 或禁用表述。', diff --git a/resources/views/admin/analytics/_ai-visibility-collect.blade.php b/resources/views/admin/analytics/_ai-visibility-collect.blade.php index 24abb06af..987c57644 100644 --- a/resources/views/admin/analytics/_ai-visibility-collect.blade.php +++ b/resources/views/admin/analytics/_ai-visibility-collect.blade.php @@ -1,5 +1,6 @@ @php $aiVisibilityLibraries = $keywordLibraries ?? collect(); + $collectSelectionCap = 50; @endphp
@@ -13,23 +14,44 @@ @csrf
@foreach ($aiVisibilityLibraries as $library) + @php + $libraryContainerId = 'ai-visibility-library-'.$library['id']; + $libraryKeywordCount = count($library['keywords']); + $libraryHasMoreThanCap = $libraryKeywordCount > $collectSelectionCap; + @endphp
-
- {{ $library['name'] }}({{ count($library['keywords']) }}) - +
+ {{ $library['name'] }}({{ $libraryKeywordCount }}) +
+ {{ __('admin.analytics.ai_visibility.collect.selection_counter', ['count' => 0, 'cap' => $collectSelectionCap]) }} + +
-
+
@foreach ($library['keywords'] as $item) -
@endforeach
-
+
+ {{ __('admin.analytics.ai_visibility.collect.selection_counter', ['count' => 0, 'cap' => $collectSelectionCap]) }} @@ -39,13 +61,74 @@
+ })(); + \ No newline at end of file diff --git a/resources/views/admin/enterprise-knowledge/create.blade.php b/resources/views/admin/enterprise-knowledge/create.blade.php index ce2be31eb..95e7b2e1a 100644 --- a/resources/views/admin/enterprise-knowledge/create.blade.php +++ b/resources/views/admin/enterprise-knowledge/create.blade.php @@ -83,7 +83,7 @@ {{ __('admin.enterprise_knowledge.dropzone_title') }} {{ __('admin.enterprise_knowledge.dropzone_desc') }} - + @error('enterprise_files') diff --git a/resources/views/admin/knowledge-bases/form.blade.php b/resources/views/admin/knowledge-bases/form.blade.php index 9de40830f..d36042f35 100644 --- a/resources/views/admin/knowledge-bases/form.blade.php +++ b/resources/views/admin/knowledge-bases/form.blade.php @@ -295,7 +295,7 @@ class="space-y-6"

{{ __('admin.knowledge_bases.source_files_desc') }}

- +