Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/

1 change: 1 addition & 0 deletions app/Data/Admin/AdminAiModelTestSnapshot.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
20 changes: 16 additions & 4 deletions app/Http/Controllers/Admin/AiModelController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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') {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
18 changes: 17 additions & 1 deletion app/Http/Controllers/Admin/AiVisibilityAnalyticsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
]);
Expand Down
32 changes: 30 additions & 2 deletions app/Http/Controllers/Admin/KnowledgeBaseController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down Expand Up @@ -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'));
}

Expand Down
7 changes: 7 additions & 0 deletions app/Providers/AppServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
});
Expand Down
11 changes: 11 additions & 0 deletions app/Services/Admin/AdminAiModelTestPreparationService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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');
}
}
90 changes: 90 additions & 0 deletions app/Services/GeoFlow/KnowledgeChunkSyncService.php
Original file line number Diff line number Diff line change
Expand Up @@ -2368,6 +2368,10 @@ private function requestEmbeddingVectors(
);
}

if ($this->isVolcengineMultimodalEmbeddingMetadata($embeddingMetadata)) {
return $this->requestVolcengineMultimodalEmbeddings($inputs, $embeddingMetadata);
}

return $this->requestOpenAiCompatibleEmbeddings($inputs, $embeddingMetadata);
}

Expand Down Expand Up @@ -2435,6 +2439,79 @@ private function requestOpenAiCompatibleEmbeddings(
);
}

/**
* 直连火山方舟多模态 embedding endpoint。
*
* 该 endpoint(/embeddings/multimodal-embedding-v1)与通用 /embeddings 不兼容,
* 请求/响应采用 OpenAI 兼容结构但 input 必须是 [{"type":"text","text":...}] 形式。
*
* 请求通过统一安全出站网关校验并固定目标地址。
*
* @param list<string> $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)));
Expand Down Expand Up @@ -2489,6 +2566,19 @@ private function isGeminiEmbeddingMetadata(array $embeddingMetadata): bool
|| OpenAiRuntimeProvider::isGeminiProviderUrl((string) ($embeddingMetadata['api_url'] ?? ''));
}

/**
* @param array<string, mixed> $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);
Expand Down
Loading