feat(grok): 增强图片生成功能并支持提供商置顶
- 更新 Grok 图片生成的 WebSocket 协议以适配最新服务端接口 - 扩展资源代理支持至 imagine-public.x.ai 和 grok.com 域名 - 在配置页面为预加载模型提供商添加置顶功能,可设置默认提供商 - 改进图片渲染逻辑,避免流式输出中的重复图片显示 - 更新相关界面文本以更准确描述预加载提供商功能
This commit is contained in:
parent
ed9282b52e
commit
77f73f0603
8 changed files with 1016 additions and 375 deletions
|
|
@ -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 += `\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
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -54,10 +54,15 @@ function renderProviderTags(container, configs, isRequired) {
|
|||
const visibleConfigs = configs.filter(c => c.visible !== false);
|
||||
|
||||
const escHtml = s => String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
||||
|
||||
// 如果是预加载模型提供商选择,添加置顶图标
|
||||
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 || '';
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue