feat(grok): 增强图片生成功能并支持提供商置顶

- 更新 Grok 图片生成的 WebSocket 协议以适配最新服务端接口
- 扩展资源代理支持至 imagine-public.x.ai 和 grok.com 域名
- 在配置页面为预加载模型提供商添加置顶功能,可设置默认提供商
- 改进图片渲染逻辑,避免流式输出中的重复图片显示
- 更新相关界面文本以更准确描述预加载提供商功能
This commit is contained in:
hex2077 2026-04-06 16:08:13 +08:00
parent ed9282b52e
commit 77f73f0603
8 changed files with 1016 additions and 375 deletions

View file

@ -50,8 +50,11 @@ export class GrokConverter extends BaseConverter {
if (!url || !uuid) return url;
// 检查是否为 assets.grok.com 域名或相对路径
const isGrokAsset = url.includes('assets.grok.com') || (!url.startsWith('http') && !url.startsWith('data:'));
// 检查是否为 Grok 资源域名或相对路径
const isGrokAsset = url.includes('assets.grok.com') ||
url.includes('imagine-public.x.ai') ||
url.includes('grok.com') ||
(!url.startsWith('http') && !url.startsWith('data:'));
if (!isGrokAsset) return url;
@ -73,14 +76,14 @@ export class GrokConverter extends BaseConverter {
}
/**
* 在文本中查找并替换所有 assets.grok.com 资源链接为绝对代理链接
* 在文本中查找并替换所有 Grok 资源链接为绝对代理链接
*/
_processGrokAssetsInText(text, state = null) {
const uuid = state?.uuid || GrokConverter.sharedUuid;
if (!text || !uuid) return text;
// 更宽松的正则匹配 assets.grok.com 的 URL
const grokUrlRegex = /https?:\/\/assets\.grok\.com\/[^\s\)\"\'\>]+/g;
// 匹配 assets.grok.com, imagine-public.x.ai 或 grok.com 的 URL
const grokUrlRegex = /https?:\/\/(assets\.grok\.com|imagine-public\.x\.ai|grok\.com)\/[^\s\)\"\'\>]+/g;
return text.replace(grokUrlRegex, (url) => {
return this._appendSsoToken(url, state);
@ -107,6 +110,7 @@ export class GrokConverter extends BaseConverter {
content_started: false, // 是否已经开始输出正式内容
requestBaseUrl: "",
uuid: null,
seen_images: new Set(), // 用于去重已输出的图片
pending_text_buffer: "" // 用于处理流式输出中被截断的 URL
});
}
@ -358,12 +362,21 @@ export class GrokConverter extends BaseConverter {
if (typeof jsonStr !== 'string') return;
try {
const card = JSON.parse(jsonStr);
const url = card.image?.original;
const url = card.image?.original || card.image_chunk?.imageUrl;
if (this._isPart0(url)) return;
if (url) add(url);
} catch (e) {}
});
continue;
}
if (key === "jsonData" && typeof item === "string") {
try {
const card = JSON.parse(item);
const url = card.image?.original || card.image_chunk?.imageUrl;
if (url) add(url);
} catch (e) {}
continue;
}
walk(item);
}
}
@ -464,8 +477,8 @@ export class GrokConverter extends BaseConverter {
filtered = filtered.replace(/<xai:tool_usage_card[^>]*>.*?<\/xai:tool_usage_card>/gs, "");
filtered = filtered.replace(/<xai:tool_usage_card[^>]*\/>/gs, "");
// 移除其他内部标签
const tagsToFilter = ["rolloutId", "responseId", "isThinking"];
// 移除其他内部标签,包括渲染标签(流式模式下我们通过卡片逻辑单独渲染图片)
const tagsToFilter = ["rolloutId", "responseId", "isThinking", "grok:render"];
for (const tag of tagsToFilter) {
const pattern = new RegExp(`<${tag}[^>]*>.*?<\\/${tag}>|<${tag}[^>]*\\/>`, 'gs');
filtered = filtered.replace(pattern, "");
@ -496,55 +509,44 @@ export class GrokConverter extends BaseConverter {
content = this._filterToken(content, responseId);
content = this._processGrokAssetsInText(content, state);
// 处理 cardAttachmentsJson 中的图片,将其映射到卡片 ID
// 处理 cardMap (已由 grok-core 预先提取映射关系)
const cardMap = new Map();
const modelResponse = grokResponse.modelResponse || {};
if (grokResponse.cardMap && typeof grokResponse.cardMap === 'object') {
for (const [id, data] of Object.entries(grokResponse.cardMap)) {
cardMap.set(id, data);
}
}
// 收集所有的卡片原始数据(可能是 cardAttachmentsJson 中的,或者是单独收集的 cardAttachments 数组)
const allCardSources = [];
if (Array.isArray(modelResponse.cardAttachmentsJson)) allCardSources.push(...modelResponse.cardAttachmentsJson);
if (Array.isArray(grokResponse.cardAttachments)) {
grokResponse.cardAttachments.forEach(card => card.jsonData && allCardSources.push(card.jsonData));
} else if (grokResponse.cardAttachment?.jsonData) {
allCardSources.push(grokResponse.cardAttachment.jsonData);
}
for (const raw of allCardSources) {
try {
const cardData = JSON.parse(raw);
const cardId = cardData.id;
const image = cardData.image || {};
const original = image.original;
const title = image.title || "image";
if (cardId && original) {
cardMap.set(cardId, { title, original });
}
} catch (e) {}
}
const modelResponse = grokResponse.modelResponse || {};
// 替换正文中的 <grok:render> 标签为 Markdown 图片
const renderedCardIds = new Set();
if (content && cardMap.size > 0) {
content = content.replace(/<grok:render[^>]*card_id="([^"]+)"[^>]*>.*?<\/grok:render>/gs, (match, cardId) => {
const item = cardMap.get(cardId);
if (!item) return "";
renderedCardIds.add(cardId);
return this._renderImage(item.original, item.title || "image", state);
});
}
// 收集未在正文中渲染的其他图片并追加
// 收集所有图片并追加(排除已在正文中渲染过的)
const imageUrls = this._collectImages(grokResponse);
if (imageUrls.length > 0) {
// 已通过卡片 ID 渲染过的 URL 记录
const handledUrls = new Set();
for (const item of cardMap.values()) handledUrls.add(item.original);
const renderedUrls = new Set();
for (const cardId of renderedCardIds) {
const item = cardMap.get(cardId);
if (item) renderedUrls.add(item.original);
}
let appendContent = "";
for (const url of imageUrls) {
if (!handledUrls.has(url)) {
if (!renderedUrls.has(url)) {
appendContent += this._renderImage(url, "image", state) + "\n";
renderedUrls.add(url); // 防止重复追加同一张图
}
}
if (appendContent) content += "\n" + appendContent;
if (appendContent) content += (content ? "\n" : "") + appendContent;
}
// 处理视频 (非流式模式)
@ -737,18 +739,16 @@ export class GrokConverter extends BaseConverter {
// 3. 处理模型响应(通常包含完整消息或图片)
if (resp.modelResponse) {
const mr = resp.modelResponse;
/*
if ((state.image_think_active || state.video_think_active) && state.think_opened) {
deltaContent += "\n</think>\n";
state.think_opened = false;
}
*/
state.image_think_active = false;
state.video_think_active = false;
const imageUrls = this._collectImages(mr);
for (const url of imageUrls) {
deltaContent += this._renderImage(url, "image", state) + "\n";
// 检查是否已经在流中输出过
if (!state.seen_images.has(url)) {
deltaContent += this._renderImage(url, "image", state) + "\n";
state.seen_images.add(url);
}
}
if (mr.metadata?.llm_info?.modelHash) {
@ -756,28 +756,6 @@ export class GrokConverter extends BaseConverter {
}
}
// 4. 处理卡片附件
if (resp.cardAttachment) {
const card = resp.cardAttachment;
if (card.jsonData) {
try {
const cardData = JSON.parse(card.jsonData);
let original = cardData.image?.original;
const title = cardData.image?.title || "image";
if (original) {
// 确保是绝对路径
if (!original.startsWith('http')) {
original = `https://assets.grok.com${original.startsWith('/') ? '' : '/'}${original}`;
}
original = this._appendSsoToken(original, state);
deltaContent += `![${title}](${original})\n`;
}
} catch (e) {
// 忽略 JSON 解析错误
}
}
}
// 5. 处理普通 Token 和 思考状态
if (resp.token !== undefined && resp.token !== null) {
const token = resp.token;

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,6 @@
import WebSocket from 'ws';
import logger from '../../utils/logger.js';
import { v4 as uuidv4 } from 'uuid';
import { getProxyConfigForProvider } from '../../utils/proxy-utils.js';
import { MODEL_PROVIDER } from '../../utils/common.js';
@ -11,7 +12,7 @@ export class ImagineWebSocketService {
constructor(config) {
this.config = config;
this.baseUrl = (config.GROK_BASE_URL || 'https://grok.com').replace(/\/$/, '');
this.wsUrl = this.baseUrl.replace(/^http/, 'ws') + '/rpc/imagine/streaming';
this.wsUrl = this.baseUrl.replace(/^http/, 'ws') + '/ws/imagine/listen';
}
/**
@ -30,11 +31,17 @@ export class ImagineWebSocketService {
let ssoToken = token || "";
if (ssoToken.startsWith("sso=")) ssoToken = ssoToken.substring(4);
const cookie = ssoToken ? `sso=${ssoToken}; sso-rw=${ssoToken}` : "";
const cfClearance = this.config.GROK_CF_CLEARANCE;
const cookie = ssoToken ? `sso=${ssoToken}; sso-rw=${ssoToken}${cfClearance ? `; cf_clearance=${cfClearance}` : ""}` : "";
const headers = {
'Cookie': cookie,
'Origin': this.baseUrl,
'Host': 'grok.com',
'Connection': 'Upgrade',
'Pragma': 'no-cache',
'Cache-Control': 'no-cache',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8,ja;q=0.7',
'User-Agent': this.config.GROK_USER_AGENT || 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36',
};
@ -43,7 +50,7 @@ export class ImagineWebSocketService {
const ws = new WebSocket(this.wsUrl, {
headers,
agent,
handshakeTimeout: 15000,
handshakeTimeout: 30000,
rejectUnauthorized: false
});
@ -52,16 +59,49 @@ export class ImagineWebSocketService {
let resolveNext = null;
ws.on('open', () => {
logger.debug(`[Grok WS] Connected. Sending imagine request.`);
ws.send(JSON.stringify({
method: 'imagine',
params: {
prompt,
aspectRatio,
count: n,
enableNsfw
logger.debug(`[Grok WS] Connected. Sending reset and imagine request.`);
// 遵循协议:首先发送重置消息
const resetPayload = {
"type": "conversation.item.create",
"timestamp": Date.now(),
"item": {
"type": "message",
"content": [{ "type": "reset" }]
}
}));
};
ws.send(JSON.stringify(resetPayload));
// 延迟 50ms 发送实际生成请求 (模拟浏览器行为)
setTimeout(() => {
if (ws.readyState !== WebSocket.OPEN) return;
const payload = {
"type": "conversation.item.create",
"timestamp": Date.now(),
"item": {
"type": "message",
"content": [
{
"requestId": uuidv4(),
"text": prompt,
"type": "input_text",
"properties": {
"section_count": 0,
"is_kids_mode": false,
"enable_nsfw": enableNsfw,
"skip_upsampler": false,
"enable_side_by_side": true,
"is_initial": false,
"aspect_ratio": aspectRatio,
"enable_pro": false
}
}
]
}
};
ws.send(JSON.stringify(payload));
}, 50);
});
ws.on('message', (data) => {

View file

@ -64,6 +64,7 @@ export async function handleGetConfig(req, res, currentConfig) {
HOST: currentConfig.HOST,
SERVER_PORT: currentConfig.SERVER_PORT,
MODEL_PROVIDER: currentConfig.MODEL_PROVIDER,
DEFAULT_MODEL_PROVIDERS: currentConfig.DEFAULT_MODEL_PROVIDERS,
SYSTEM_PROMPT_FILE_PATH: currentConfig.SYSTEM_PROMPT_FILE_PATH,
SYSTEM_PROMPT_MODE: currentConfig.SYSTEM_PROMPT_MODE,
PROMPT_LOG_BASE_NAME: currentConfig.PROMPT_LOG_BASE_NAME,

View file

@ -51,12 +51,13 @@ export async function handleGrokAssetsProxy(req, res, config, providerPoolManage
finalTargetUrl = `https://assets.grok.com${targetUrl.startsWith('/') ? '' : '/'}${targetUrl}`;
}
// 验证域名安全,只允许代理 assets.grok.com
// 验证域名安全,允许代理 Grok 相关域名
try {
const parsedTarget = new URL(finalTargetUrl);
if (parsedTarget.hostname !== 'assets.grok.com') {
const allowedHostnames = ['assets.grok.com', 'imagine-public.x.ai', 'grok.com'];
if (!allowedHostnames.includes(parsedTarget.hostname)) {
res.writeHead(403, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Forbidden: Only assets.grok.com is allowed' }));
res.end(JSON.stringify({ error: `Forbidden: Only ${allowedHostnames.join(', ')} are allowed` }));
return;
}
} catch (e) {

View file

@ -54,10 +54,15 @@ function renderProviderTags(container, configs, isRequired) {
const visibleConfigs = configs.filter(c => c.visible !== false);
const escHtml = s => String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
// 如果是预加载模型提供商选择,添加置顶图标
const isModelProviderSelect = container.id === 'modelProvider';
container.innerHTML = visibleConfigs.map(c => `
<button type="button" class="provider-tag" data-value="${escHtml(c.id)}">
<i class="fas ${escHtml(c.icon || 'fa-server')}"></i>
<span>${escHtml(c.name)}</span>
${isModelProviderSelect ? `<span class="tag-pin-icon" title="${t('config.pin') || '设为默认 (置顶)'}"><i class="fas fa-thumbtack"></i></span>` : ''}
</button>
`).join('');
@ -65,6 +70,20 @@ function renderProviderTags(container, configs, isRequired) {
const tags = container.querySelectorAll('.provider-tag');
tags.forEach(tag => {
tag.addEventListener('click', (e) => {
// 如果点击的是置顶图标
if (e.target.closest('.tag-pin-icon')) {
e.preventDefault();
e.stopPropagation();
// 置顶逻辑:将其移动到容器最前面并设为选中
tag.classList.add('selected');
container.prepend(tag);
// 更新视觉样式
updatePinnedStatus(container);
return;
}
e.preventDefault();
const isSelected = tag.classList.contains('selected');
@ -79,10 +98,34 @@ function renderProviderTags(container, configs, isRequired) {
// 切换选中状态
tag.classList.toggle('selected');
// 如果取消选中了当前置顶的,重新计算置顶状态
if (!tag.classList.contains('selected') && isModelProviderSelect) {
updatePinnedStatus(container);
}
});
});
}
/**
* 更新置顶状态的视觉表现
* @param {HTMLElement} container
*/
function updatePinnedStatus(container) {
const tags = container.querySelectorAll('.provider-tag');
tags.forEach((tag, index) => {
// 第一个被选中的即为“置顶”的默认提供商
const isFirstSelected = tag.classList.contains('selected') &&
index === Array.from(tags).findIndex(t => t.classList.contains('selected'));
if (isFirstSelected) {
tag.classList.add('pinned');
} else {
tag.classList.remove('pinned');
}
});
}
/**
* 加载配置
*/
@ -107,21 +150,34 @@ async function loadConfiguration() {
? data.DEFAULT_MODEL_PROVIDERS
: (typeof data.MODEL_PROVIDER === 'string' ? data.MODEL_PROVIDER.split(',') : []);
const tags = modelProviderEl.querySelectorAll('.provider-tag');
const tags = Array.from(modelProviderEl.querySelectorAll('.provider-tag'));
// 按照 providers 数组的顺序重新排列 DOM 中的标签
providers.forEach(id => {
const tag = tags.find(t => t.getAttribute('data-value') === id);
if (tag) {
tag.classList.add('selected');
modelProviderEl.appendChild(tag); // 依次移到末尾实现重排
}
});
// 处理未选中的标签
tags.forEach(tag => {
const value = tag.getAttribute('data-value');
if (providers.includes(value)) {
tag.classList.add('selected');
} else {
if (!providers.includes(value)) {
tag.classList.remove('selected');
modelProviderEl.appendChild(tag); // 移到最后
}
});
// 如果没有任何选中的,默认选中第一个(保持兼容性)
const anySelected = Array.from(tags).some(tag => tag.classList.contains('selected'));
const anySelected = Array.from(modelProviderEl.querySelectorAll('.provider-tag.selected')).length > 0;
if (!anySelected && tags.length > 0) {
tags[0].classList.add('selected');
}
// 更新置顶视觉样式
updatePinnedStatus(modelProviderEl);
}
if (systemPromptEl) systemPromptEl.value = data.systemPrompt || '';

View file

@ -241,9 +241,10 @@ const translations = {
'config.basic.title': '基础设置',
'config.governance.title': '服务治理',
'config.oauth.title': 'OAuth & 令牌',
'config.modelProvider': '模型提供商',
'config.modelProviderHelp': '勾选启动时初始化的模型提供商 (必须至少勾选一个)',
'config.modelProviderRequired': '必须至少勾选一个模型提供商',
'config.modelProvider': '预加载模型提供商',
'config.modelProviderHelp': '选择在服务启动时提前初始化的适配器,以加速首个请求并确保路由可用 (必须至少选择一个)',
'config.modelProviderRequired': '必须至少选择一个预加载提供商',
'config.pin': '设为默认 (置顶)',
'config.optional': '(选填)',
'config.gemini.baseUrl': 'Gemini Base URL',
'config.gemini.baseUrlPlaceholder': 'https://cloudcode-pa.googleapis.com',
@ -1111,9 +1112,10 @@ const translations = {
'config.basic.title': 'Basic Settings',
'config.governance.title': 'Service Governance',
'config.oauth.title': 'OAuth & Tokens',
'config.modelProvider': 'Model Provider',
'config.modelProviderHelp': 'Check model providers to initialize on startup (must select at least one)',
'config.modelProviderRequired': 'At least one model provider must be selected',
'config.modelProvider': 'Pre-initialized Providers',
'config.modelProviderHelp': 'Select provider adapters to pre-initialize on startup for faster first requests and routing availability (must select at least one)',
'config.modelProviderRequired': 'At least one pre-initialized provider must be selected',
'config.pin': 'Set as Default (Pin)',
'config.optional': '(Optional)',
'config.gemini.baseUrl': 'Gemini Base URL',
'config.gemini.baseUrlPlaceholder': 'https://cloudcode-pa.googleapis.com',

View file

@ -362,6 +362,47 @@ input:checked + .toggle-slider:before {
white-space: nowrap;
}
/* 置顶图标样式 */
.tag-pin-icon {
margin-left: 6px;
padding: 2px;
opacity: 0.3;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
justify-content: center;
}
.provider-tag:hover .tag-pin-icon {
opacity: 0.6;
}
.tag-pin-icon:hover {
opacity: 1 !important;
color: #ffc107;
transform: scale(1.2);
}
/* 置顶状态标签样式 */
.provider-tag.pinned {
position: relative;
border-color: #ffc107;
box-shadow: 0 0 10px rgba(255, 193, 7, 0.3);
}
.provider-tag.pinned .tag-pin-icon {
opacity: 1;
color: #ffc107;
}
.provider-tag.pinned i.fa-thumbtack {
transform: rotate(0deg);
}
.provider-tag:not(.pinned) .tag-pin-icon i {
transform: rotate(45deg);
}
/* 高级配置区域 */
.advanced-config-section {