- 新增 Grok 资源代理接口,将 assets.grok.com 的资源通过本地代理访问 - 在请求处理中注入 requestBaseUrl 配置,供转换器生成正确的代理链接 - 统一各提供商核心服务中删除 _requestBaseUrl 字段的逻辑 - 优化 Grok 用量显示逻辑,支持按 token 或 query 显示剩余额度 - 更新 UI 管理器,允许 /api/grok/assets 接口免认证访问 - 改进 Grok 转换器,在流式输出中智能处理被截断的 URL
796 lines
28 KiB
JavaScript
796 lines
28 KiB
JavaScript
/**
|
||
* Grok转换器
|
||
* 处理Grok协议与其他协议之间的转换
|
||
*/
|
||
|
||
import { v4 as uuidv4 } from 'uuid';
|
||
import logger from '../../utils/logger.js';
|
||
import { BaseConverter } from '../BaseConverter.js';
|
||
import { MODEL_PROTOCOL_PREFIX } from '../../utils/common.js';
|
||
|
||
/**
|
||
* Grok转换器类
|
||
* 实现Grok协议到其他协议的转换
|
||
*/
|
||
export class GrokConverter extends BaseConverter {
|
||
// 静态属性,确保所有实例共享最新的认证和基础 URL 配置
|
||
static sharedSsoToken = null;
|
||
static sharedRequestBaseUrl = "";
|
||
|
||
constructor() {
|
||
super('grok');
|
||
// 用于跟踪每个请求的状态
|
||
this.requestStates = new Map();
|
||
}
|
||
|
||
/**
|
||
* 设置 Grok SSO token
|
||
*/
|
||
setSsoToken(token) {
|
||
if (!token) return;
|
||
|
||
// 如果 token 包含 sso= 前缀,则去掉它
|
||
let processedToken = token;
|
||
if (processedToken.startsWith("sso=")) {
|
||
processedToken = processedToken.substring(4);
|
||
}
|
||
GrokConverter.sharedSsoToken = processedToken;
|
||
}
|
||
|
||
/**
|
||
* 设置请求的基础 URL
|
||
*/
|
||
setRequestBaseUrl(baseUrl) {
|
||
if (baseUrl) {
|
||
GrokConverter.sharedRequestBaseUrl = baseUrl;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 为 assets.grok.com 域名的资源 URL 添加 sso 参数,并转换为本地代理 URL
|
||
*/
|
||
_appendSsoToken(url, state = null) {
|
||
const ssoToken = state?.ssoToken || GrokConverter.sharedSsoToken;
|
||
const requestBaseUrl = state?.requestBaseUrl || GrokConverter.sharedRequestBaseUrl;
|
||
|
||
if (!url || !ssoToken) return url;
|
||
|
||
// 检查是否为 assets.grok.com 域名或相对路径
|
||
const isGrokAsset = url.includes('assets.grok.com') || (!url.startsWith('http') && !url.startsWith('data:'));
|
||
|
||
if (!isGrokAsset) return url;
|
||
|
||
// 构造完整的原始 URL
|
||
let originalUrl = url;
|
||
if (!url.startsWith('http')) {
|
||
originalUrl = `https://assets.grok.com${url.startsWith('/') ? '' : '/'}${url}`;
|
||
}
|
||
|
||
// 返回本地代理接口 URL
|
||
const proxyPath = `/api/grok/assets?url=${encodeURIComponent(originalUrl)}&sso=${encodeURIComponent(ssoToken)}`;
|
||
if (requestBaseUrl) {
|
||
return `${requestBaseUrl}${proxyPath}`;
|
||
}
|
||
return proxyPath;
|
||
}
|
||
|
||
/**
|
||
* 在文本中查找并替换所有 assets.grok.com 的资源链接为绝对代理链接
|
||
*/
|
||
_processGrokAssetsInText(text, state = null) {
|
||
const ssoToken = state?.ssoToken || GrokConverter.sharedSsoToken;
|
||
if (!text || !ssoToken) return text;
|
||
|
||
// 更宽松的正则匹配 assets.grok.com 的 URL
|
||
const grokUrlRegex = /https?:\/\/assets\.grok\.com\/[^\s\)\"\'\>]+/g;
|
||
|
||
return text.replace(grokUrlRegex, (url) => {
|
||
return this._appendSsoToken(url, state);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 获取或初始化请求状态
|
||
*/
|
||
_getState(requestId) {
|
||
if (!this.requestStates.has(requestId)) {
|
||
this.requestStates.set(requestId, {
|
||
think_opened: false,
|
||
image_think_active: false,
|
||
video_think_active: false,
|
||
role_sent: false,
|
||
tool_buffer: "",
|
||
last_is_thinking: false,
|
||
fingerprint: "",
|
||
content_buffer: "", // 用于缓存内容以解析工具调用
|
||
has_tool_call: false,
|
||
rollout_id: "",
|
||
in_tool_call: false, // 是否处于 <tool_call> 块内
|
||
ssoToken: null,
|
||
requestBaseUrl: "",
|
||
pending_text_buffer: "" // 用于处理流式输出中被截断的 URL
|
||
});
|
||
}
|
||
return this.requestStates.get(requestId);
|
||
}
|
||
|
||
/**
|
||
* 构建工具系统提示词 (build_tool_prompt)
|
||
*/
|
||
buildToolPrompt(tools, toolChoice = "auto", parallelToolCalls = true) {
|
||
if (!tools || tools.length === 0 || toolChoice === "none") {
|
||
return "";
|
||
}
|
||
|
||
const lines = [
|
||
"# Available Tools",
|
||
"",
|
||
"You have access to the following tools. To call a tool, output a <tool_call> block with a JSON object containing \"name\" and \"arguments\".",
|
||
"",
|
||
"Format:",
|
||
"<tool_call>",
|
||
'{"name": "function_name", "arguments": {"param": "value"}}',
|
||
"</tool_call>",
|
||
"",
|
||
];
|
||
|
||
if (parallelToolCalls) {
|
||
lines.push("You may make multiple tool calls in a single response by using multiple <tool_call> blocks.");
|
||
lines.push("");
|
||
}
|
||
|
||
lines.push("## Tool Definitions");
|
||
lines.push("");
|
||
for (const tool of tools) {
|
||
if (tool.type !== "function") continue;
|
||
const func = tool.function || {};
|
||
lines.push(`### ${func.name}`);
|
||
if (func.description) lines.push(func.description);
|
||
if (func.parameters) lines.push(`Parameters: ${JSON.stringify(func.parameters)}`);
|
||
lines.push("");
|
||
}
|
||
|
||
if (toolChoice === "required") {
|
||
lines.push("IMPORTANT: You MUST call at least one tool in your response. Do not respond with only text.");
|
||
} else if (typeof toolChoice === 'object' && toolChoice.function?.name) {
|
||
lines.push(`IMPORTANT: You MUST call the tool "${toolChoice.function.name}" in your response.`);
|
||
} else {
|
||
lines.push("Decide whether to call a tool based on the user's request. If you don't need a tool, respond normally with text only.");
|
||
}
|
||
|
||
lines.push("");
|
||
lines.push("When you call a tool, you may include text before or after the <tool_call> blocks, but the tool call blocks must be valid JSON.");
|
||
|
||
return lines.join("\n");
|
||
}
|
||
|
||
/**
|
||
* 格式化工具历史 (format_tool_history)
|
||
*/
|
||
formatToolHistory(messages) {
|
||
const result = [];
|
||
for (const msg of messages) {
|
||
const role = msg.role;
|
||
const content = msg.content;
|
||
const toolCalls = msg.tool_calls;
|
||
|
||
if (role === "assistant" && toolCalls && toolCalls.length > 0) {
|
||
const parts = [];
|
||
if (content) parts.push(typeof content === 'string' ? content : JSON.stringify(content));
|
||
for (const tc of toolCalls) {
|
||
const func = tc.function || {};
|
||
parts.push(`<tool_call>{"name":"${func.name}","arguments":${func.arguments || "{}"}}</tool_call>`);
|
||
}
|
||
result.push({ role: "assistant", content: parts.join("\n") });
|
||
} else if (role === "tool") {
|
||
const toolName = msg.name || "unknown";
|
||
const callId = msg.tool_call_id || "";
|
||
const contentStr = typeof content === 'string' ? content : JSON.stringify(content);
|
||
result.push({
|
||
role: "user",
|
||
content: `tool (${toolName}, ${callId}): ${contentStr}`
|
||
});
|
||
} else {
|
||
result.push(msg);
|
||
}
|
||
}
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* 解析工具调用 (parse_tool_calls)
|
||
*/
|
||
parseToolCalls(content) {
|
||
if (!content) return { text: content, toolCalls: null };
|
||
|
||
const toolCallRegex = /<tool_call>\s*(.*?)\s*<\/tool_call>/gs;
|
||
const matches = [...content.matchAll(toolCallRegex)];
|
||
|
||
if (matches.length === 0) return { text: content, toolCalls: null };
|
||
|
||
const toolCalls = [];
|
||
for (const match of matches) {
|
||
try {
|
||
const parsed = JSON.parse(match[1].trim());
|
||
if (parsed.name) {
|
||
let args = parsed.arguments || {};
|
||
const argumentsStr = typeof args === 'string' ? args : JSON.stringify(args);
|
||
|
||
toolCalls.push({
|
||
id: `call_${uuidv4().replace(/-/g, '').slice(0, 24)}`,
|
||
type: "function",
|
||
function: {
|
||
name: parsed.name,
|
||
arguments: argumentsStr
|
||
}
|
||
});
|
||
}
|
||
} catch (e) {
|
||
// 忽略解析失败的块
|
||
}
|
||
}
|
||
|
||
if (toolCalls.length === 0) return { text: content, toolCalls: null };
|
||
|
||
// 提取文本内容
|
||
let text = content;
|
||
for (const match of matches) {
|
||
text = text.replace(match[0], "");
|
||
}
|
||
text = text.trim() || null;
|
||
|
||
return { text, toolCalls };
|
||
}
|
||
|
||
/**
|
||
* 转换请求
|
||
*/
|
||
convertRequest(data, targetProtocol) {
|
||
return data;
|
||
}
|
||
|
||
/**
|
||
* 转换响应
|
||
*/
|
||
convertResponse(data, targetProtocol, model) {
|
||
switch (targetProtocol) {
|
||
case MODEL_PROTOCOL_PREFIX.OPENAI:
|
||
return this.toOpenAIResponse(data, model);
|
||
default:
|
||
return data;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 转换流式响应块
|
||
*/
|
||
convertStreamChunk(chunk, targetProtocol, model) {
|
||
switch (targetProtocol) {
|
||
case MODEL_PROTOCOL_PREFIX.OPENAI:
|
||
return this.toOpenAIStreamChunk(chunk, model);
|
||
default:
|
||
return chunk;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 转换模型列表
|
||
*/
|
||
convertModelList(data, targetProtocol) {
|
||
return data;
|
||
}
|
||
|
||
/**
|
||
* 构建工具覆盖配置 (build_tool_overrides)
|
||
*/
|
||
buildToolOverrides(tools) {
|
||
if (!tools || !Array.isArray(tools)) {
|
||
return {};
|
||
}
|
||
|
||
const toolOverrides = {};
|
||
for (const tool of tools) {
|
||
if (tool.type !== "function") continue;
|
||
const func = tool.function || {};
|
||
const name = func.name;
|
||
if (!name) continue;
|
||
|
||
toolOverrides[name] = {
|
||
"enabled": true,
|
||
"description": func.description || "",
|
||
"parameters": func.parameters || {}
|
||
};
|
||
}
|
||
|
||
return toolOverrides;
|
||
}
|
||
|
||
/**
|
||
* 递归收集响应中的图片 URL
|
||
*/
|
||
_collectImages(obj) {
|
||
const urls = [];
|
||
const seen = new Set();
|
||
|
||
const add = (url) => {
|
||
if (!url || seen.has(url)) return;
|
||
seen.add(url);
|
||
urls.push(url);
|
||
};
|
||
|
||
const walk = (value) => {
|
||
if (value && typeof value === 'object') {
|
||
if (Array.isArray(value)) {
|
||
value.forEach(walk);
|
||
} else {
|
||
for (const [key, item] of Object.entries(value)) {
|
||
if (key === "generatedImageUrls" || key === "imageUrls" || key === "imageURLs") {
|
||
if (Array.isArray(item)) {
|
||
item.forEach(url => typeof url === 'string' && add(url));
|
||
} else if (typeof item === 'string') {
|
||
add(item);
|
||
}
|
||
continue;
|
||
}
|
||
walk(item);
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
walk(obj);
|
||
return urls;
|
||
}
|
||
|
||
/**
|
||
* 渲染图片为 Markdown
|
||
*/
|
||
_renderImage(url, imageId = "image") {
|
||
let finalUrl = url;
|
||
if (!url.startsWith('http')) {
|
||
finalUrl = `https://assets.grok.com${url.startsWith('/') ? '' : '/'}${url}`;
|
||
}
|
||
finalUrl = this._appendSsoToken(finalUrl);
|
||
return ``;
|
||
}
|
||
|
||
/**
|
||
* 渲染视频为 Markdown/HTML (render_video)
|
||
*/
|
||
_renderVideo(videoUrl, thumbnailImageUrl = "") {
|
||
let finalVideoUrl = videoUrl;
|
||
if (!videoUrl.startsWith('http')) {
|
||
finalVideoUrl = `https://assets.grok.com${videoUrl.startsWith('/') ? '' : '/'}${videoUrl}`;
|
||
}
|
||
finalVideoUrl = this._appendSsoToken(finalVideoUrl);
|
||
|
||
let finalThumbUrl = thumbnailImageUrl;
|
||
if (thumbnailImageUrl && !thumbnailImageUrl.startsWith('http')) {
|
||
finalThumbUrl = `https://assets.grok.com${thumbnailImageUrl.startsWith('/') ? '' : '/'}${thumbnailImageUrl}`;
|
||
}
|
||
finalThumbUrl = this._appendSsoToken(finalThumbUrl);
|
||
|
||
const defaultThumb = this._appendSsoToken('https://assets.grok.com/favicon.ico');
|
||
return `\n[](${finalVideoUrl})\n[Play Video](${finalVideoUrl})\n`;
|
||
}
|
||
|
||
/**
|
||
* 提取工具卡片文本 (extract_tool_text)
|
||
*/
|
||
_extractToolText(raw, rolloutId = "") {
|
||
if (!raw) return "";
|
||
|
||
const nameMatch = raw.match(/<xai:tool_name>(.*?)<\/xai:tool_name>/s);
|
||
const argsMatch = raw.match(/<xai:tool_args>(.*?)<\/xai:tool_args>/s);
|
||
|
||
let name = nameMatch ? nameMatch[1].replace(/<!\[CDATA\[(.*?)\]\]>/gs, "$1").trim() : "";
|
||
let args = argsMatch ? argsMatch[1].replace(/<!\[CDATA\[(.*?)\]\]>/gs, "$1").trim() : "";
|
||
|
||
let payload = null;
|
||
if (args) {
|
||
try {
|
||
payload = JSON.parse(args);
|
||
} catch (e) {
|
||
payload = null;
|
||
}
|
||
}
|
||
|
||
let label = name;
|
||
let text = args;
|
||
const prefix = rolloutId ? `[${rolloutId}]` : "";
|
||
|
||
if (name === "web_search") {
|
||
label = `${prefix}[WebSearch]`;
|
||
if (payload && typeof payload === 'object') {
|
||
text = payload.query || payload.q || "";
|
||
}
|
||
} else if (name === "search_images") {
|
||
label = `${prefix}[SearchImage]`;
|
||
if (payload && typeof payload === 'object') {
|
||
text = payload.image_description || payload.description || payload.query || "";
|
||
}
|
||
} else if (name === "chatroom_send") {
|
||
label = `${prefix}[AgentThink]`;
|
||
if (payload && typeof payload === 'object') {
|
||
text = payload.message || "";
|
||
}
|
||
}
|
||
|
||
if (label && text) return `${label} ${text}`.trim();
|
||
if (label) return label;
|
||
if (text) return text;
|
||
return raw.replace(/<[^>]+>/g, "").trim();
|
||
}
|
||
|
||
/**
|
||
* 过滤特殊标签
|
||
*/
|
||
_filterToken(token, requestId = "") {
|
||
if (!token) return token;
|
||
|
||
let filtered = token;
|
||
|
||
// 移除 xai:tool_usage_card 及其内容,不显示工具调用的过程输出
|
||
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"];
|
||
for (const tag of tagsToFilter) {
|
||
const pattern = new RegExp(`<${tag}[^>]*>.*?<\\/${tag}>|<${tag}[^>]*\\/>`, 'gs');
|
||
filtered = filtered.replace(pattern, "");
|
||
}
|
||
|
||
return filtered;
|
||
}
|
||
|
||
/**
|
||
* Grok响应 -> OpenAI响应
|
||
*/
|
||
toOpenAIResponse(grokResponse, model) {
|
||
if (!grokResponse) return null;
|
||
|
||
const responseId = grokResponse.responseId || `chatcmpl-${uuidv4()}`;
|
||
let content = grokResponse.message || "";
|
||
const modelHash = grokResponse.llmInfo?.modelHash || "";
|
||
|
||
const state = this._getState(this._formatResponseId(responseId));
|
||
if (grokResponse._ssoToken) {
|
||
let processedToken = grokResponse._ssoToken;
|
||
if (processedToken.startsWith("sso=")) {
|
||
processedToken = processedToken.substring(4);
|
||
}
|
||
state.ssoToken = processedToken;
|
||
}
|
||
if (grokResponse._requestBaseUrl) {
|
||
state.requestBaseUrl = grokResponse._requestBaseUrl;
|
||
}
|
||
|
||
// 过滤内容并处理其中的 Grok 资源链接
|
||
content = this._filterToken(content, responseId);
|
||
content = this._processGrokAssetsInText(content, state);
|
||
|
||
// 收集图片并追加
|
||
const imageUrls = this._collectImages(grokResponse);
|
||
if (imageUrls.length > 0) {
|
||
content += "\n";
|
||
for (const url of imageUrls) {
|
||
content += this._renderImage(url, "image", state) + "\n";
|
||
}
|
||
}
|
||
|
||
// 处理视频 (非流式模式)
|
||
if (grokResponse.finalVideoUrl) {
|
||
content += this._renderVideo(grokResponse.finalVideoUrl, grokResponse.finalThumbnailUrl, state);
|
||
}
|
||
|
||
// 解析工具调用
|
||
const { text, toolCalls } = this.parseToolCalls(content);
|
||
|
||
const result = {
|
||
id: responseId,
|
||
object: "chat.completion",
|
||
created: Math.floor(Date.now() / 1000),
|
||
model: model,
|
||
system_fingerprint: modelHash,
|
||
choices: [{
|
||
index: 0,
|
||
message: {
|
||
role: "assistant",
|
||
content: text,
|
||
},
|
||
finish_reason: toolCalls ? "tool_calls" : "stop",
|
||
}],
|
||
usage: {
|
||
prompt_tokens: 0,
|
||
completion_tokens: 0,
|
||
total_tokens: 0,
|
||
},
|
||
};
|
||
|
||
if (toolCalls) {
|
||
result.choices[0].message.tool_calls = toolCalls;
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
_formatResponseId(id) {
|
||
if (!id) return `chatcmpl-${uuidv4()}`;
|
||
if (id.startsWith('chatcmpl-')) return id;
|
||
return `chatcmpl-${id}`;
|
||
}
|
||
|
||
/**
|
||
* Grok流式响应块 -> OpenAI流式响应块
|
||
*/
|
||
toOpenAIStreamChunk(grokChunk, model) {
|
||
if (!grokChunk || !grokChunk.result || !grokChunk.result.response) {
|
||
return null;
|
||
}
|
||
|
||
const resp = grokChunk.result.response;
|
||
const rawResponseId = resp.responseId || "";
|
||
const responseId = this._formatResponseId(rawResponseId);
|
||
const state = this._getState(responseId);
|
||
|
||
// 从响应块中同步 token 和基础 URL
|
||
if (resp._ssoToken) {
|
||
let processedToken = resp._ssoToken;
|
||
if (processedToken.startsWith("sso=")) {
|
||
processedToken = processedToken.substring(4);
|
||
}
|
||
state.ssoToken = processedToken;
|
||
}
|
||
if (resp._requestBaseUrl) {
|
||
state.requestBaseUrl = resp._requestBaseUrl;
|
||
}
|
||
|
||
if (resp.llmInfo?.modelHash && !state.fingerprint) {
|
||
state.fingerprint = resp.llmInfo.modelHash;
|
||
}
|
||
if (resp.rolloutId) {
|
||
state.rollout_id = String(resp.rolloutId);
|
||
}
|
||
|
||
const chunks = [];
|
||
|
||
// 0. 发送角色信息(仅第一次)
|
||
if (!state.role_sent) {
|
||
chunks.push({
|
||
id: responseId,
|
||
object: "chat.completion.chunk",
|
||
created: Math.floor(Date.now() / 1000),
|
||
model: model,
|
||
system_fingerprint: state.fingerprint,
|
||
choices: [{
|
||
index: 0,
|
||
delta: { role: "assistant", content: "" },
|
||
finish_reason: null
|
||
}]
|
||
});
|
||
state.role_sent = true;
|
||
}
|
||
|
||
// 处理结束标志
|
||
if (resp.isDone) {
|
||
let finalContent = "";
|
||
// 处理剩余的缓冲区
|
||
if (state.pending_text_buffer) {
|
||
finalContent += this._processGrokAssetsInText(state.pending_text_buffer, state);
|
||
state.pending_text_buffer = "";
|
||
}
|
||
|
||
// 处理 buffer 中的工具调用
|
||
const { text, toolCalls } = this.parseToolCalls(state.content_buffer);
|
||
|
||
if (toolCalls) {
|
||
chunks.push({
|
||
id: responseId,
|
||
object: "chat.completion.chunk",
|
||
created: Math.floor(Date.now() / 1000),
|
||
model: model,
|
||
system_fingerprint: state.fingerprint,
|
||
choices: [{
|
||
index: 0,
|
||
delta: {
|
||
content: (finalContent + (text || "")).trim() || null,
|
||
tool_calls: toolCalls
|
||
},
|
||
finish_reason: "tool_calls"
|
||
}]
|
||
});
|
||
} else {
|
||
chunks.push({
|
||
id: responseId,
|
||
object: "chat.completion.chunk",
|
||
created: Math.floor(Date.now() / 1000),
|
||
model: model,
|
||
system_fingerprint: state.fingerprint,
|
||
choices: [{
|
||
index: 0,
|
||
delta: { content: finalContent || null },
|
||
finish_reason: "stop"
|
||
}]
|
||
});
|
||
}
|
||
|
||
// 清理状态
|
||
this.requestStates.delete(responseId);
|
||
return chunks;
|
||
}
|
||
|
||
let deltaContent = "";
|
||
let deltaReasoning = "";
|
||
|
||
// 1. 处理图片生成进度
|
||
if (resp.streamingImageGenerationResponse) {
|
||
const img = resp.streamingImageGenerationResponse;
|
||
state.image_think_active = true;
|
||
/*
|
||
if (!state.think_opened) {
|
||
deltaReasoning += "<think>\n";
|
||
state.think_opened = true;
|
||
}
|
||
*/
|
||
const idx = (img.imageIndex || 0) + 1;
|
||
const progress = img.progress || 0;
|
||
deltaReasoning += `正在生成第${idx}张图片中,当前进度${progress}%\n`;
|
||
}
|
||
|
||
// 2. 处理视频生成进度 (VideoStreamProcessor)
|
||
if (resp.streamingVideoGenerationResponse) {
|
||
const vid = resp.streamingVideoGenerationResponse;
|
||
state.video_think_active = true;
|
||
/*
|
||
if (!state.think_opened) {
|
||
deltaReasoning += "<think>\n";
|
||
state.think_opened = true;
|
||
}
|
||
*/
|
||
const progress = vid.progress || 0;
|
||
deltaReasoning += `正在生成视频中,当前进度${progress}%\n`;
|
||
|
||
if (progress === 100 && vid.videoUrl) {
|
||
/*
|
||
if (state.think_opened) {
|
||
deltaContent += "\n</think>\n";
|
||
state.think_opened = false;
|
||
}
|
||
*/
|
||
state.video_think_active = false;
|
||
deltaContent += this._renderVideo(vid.videoUrl, vid.thumbnailImageUrl, state);
|
||
}
|
||
}
|
||
|
||
// 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 (mr.metadata?.llm_info?.modelHash) {
|
||
state.fingerprint = mr.metadata.llm_info.modelHash;
|
||
}
|
||
}
|
||
|
||
// 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;
|
||
const filtered = this._filterToken(token, responseId);
|
||
const isThinking = !!resp.isThinking;
|
||
const inThink = isThinking || state.image_think_active || state.video_think_active;
|
||
|
||
if (inThink) {
|
||
deltaReasoning += filtered;
|
||
} else {
|
||
// 将新 token 加入待处理缓冲区,解决 URL 被截断的问题
|
||
state.pending_text_buffer += filtered;
|
||
|
||
let outputFromBuffer = "";
|
||
|
||
// 启发式逻辑:检查缓冲区是否包含完整的 URL
|
||
if (state.pending_text_buffer.includes("https://assets.grok.com")) {
|
||
const lastUrlIndex = state.pending_text_buffer.lastIndexOf("https://assets.grok.com");
|
||
const textAfterUrl = state.pending_text_buffer.slice(lastUrlIndex);
|
||
|
||
// 检查 URL 是否结束(空格、右括号、引号、换行、大于号等)
|
||
const terminatorMatch = textAfterUrl.match(/[\s\)\"\'\>\n]/);
|
||
if (terminatorMatch) {
|
||
// URL 已结束,可以安全地处理并输出缓冲区
|
||
outputFromBuffer = this._processGrokAssetsInText(state.pending_text_buffer, state);
|
||
state.pending_text_buffer = "";
|
||
} else if (state.pending_text_buffer.length > 1000) {
|
||
// 缓冲区过长,强制处理输出,避免过度延迟
|
||
outputFromBuffer = this._processGrokAssetsInText(state.pending_text_buffer, state);
|
||
state.pending_text_buffer = "";
|
||
}
|
||
} else {
|
||
// 不包含 Grok URL,直接输出
|
||
outputFromBuffer = state.pending_text_buffer;
|
||
state.pending_text_buffer = "";
|
||
}
|
||
|
||
if (outputFromBuffer) {
|
||
// 工具调用抑制逻辑:不向客户端输出 <tool_call> 块及其内容
|
||
let outputToken = outputFromBuffer;
|
||
|
||
// 简单的状态切换检测
|
||
if (outputToken.includes('<tool_call>')) {
|
||
state.in_tool_call = true;
|
||
state.has_tool_call = true;
|
||
// 移除标签之后的部分(如果有)
|
||
outputToken = outputToken.split('<tool_call>')[0];
|
||
} else if (state.in_tool_call && outputToken.includes('</tool_call>')) {
|
||
state.in_tool_call = false;
|
||
// 只保留标签之后的部分
|
||
outputToken = outputToken.split('</tool_call>')[1] || "";
|
||
} else if (state.in_tool_call) {
|
||
// 处于块内,完全抑制
|
||
outputToken = "";
|
||
}
|
||
|
||
deltaContent += outputToken;
|
||
}
|
||
|
||
// 将内容加入 buffer 用于最终解析工具调用
|
||
state.content_buffer += filtered;
|
||
}
|
||
state.last_is_thinking = isThinking;
|
||
}
|
||
|
||
if (deltaContent || deltaReasoning) {
|
||
const delta = {};
|
||
if (deltaContent) delta.content = deltaContent;
|
||
if (deltaReasoning) delta.reasoning_content = deltaReasoning;
|
||
|
||
chunks.push({
|
||
id: responseId,
|
||
object: "chat.completion.chunk",
|
||
created: Math.floor(Date.now() / 1000),
|
||
model: model,
|
||
system_fingerprint: state.fingerprint,
|
||
choices: [{
|
||
index: 0,
|
||
delta: delta,
|
||
finish_reason: null
|
||
}]
|
||
});
|
||
}
|
||
|
||
return chunks.length > 0 ? chunks : null;
|
||
}
|
||
}
|