Fix/usage json entity escaping (#333)

* fix(usage): 修复 JSON 视图实体转义显示

* fix(usage): 解码 OpenAI 工具参数实体
This commit is contained in:
RWDai
2026-04-25 16:41:25 +08:00
committed by GitHub
parent bb7fe9fe37
commit b8205b5a09
3 changed files with 126 additions and 7 deletions

View File

@@ -180,6 +180,8 @@ const escapeHtml = (str: string): string => {
.replace(/"/g, '"')
}
const jsonStringLiteral = (value: string): string => JSON.stringify(value)
const parseJsonToLines = (data: unknown): JsonLine[] => {
const result: JsonLine[] = []
let lineNumber = 1
@@ -222,7 +224,7 @@ const parseJsonToLines = (data: unknown): JsonLine[] => {
id: result.length,
lineNumber: lineNumber++,
indent,
html: keyPrefix + getTokenHtml(`"${escapeHtml(value)}"`, 'string') + comma,
html: keyPrefix + getTokenHtml(jsonStringLiteral(value), 'string') + comma,
canFold: false,
blockId: '',
})
@@ -294,7 +296,7 @@ const parseJsonToLines = (data: unknown): JsonLine[] => {
})
keys.forEach((key, i) => {
const keyHtml = getTokenHtml(`"${escapeHtml(key)}"`, 'key') + getTokenHtml(': ', 'punctuation')
const keyHtml = getTokenHtml(jsonStringLiteral(key), 'key') + getTokenHtml(': ', 'punctuation')
processValue(obj[key], indent + 1, i === keys.length - 1, keyHtml)
})

View File

@@ -109,4 +109,55 @@ describe('Conversation stream compatibility', () => {
content: 'Hello from legacy alias',
})
})
it('renders HTML-entity encoded OpenAI tool arguments as formatted JSON', () => {
const requestBody = {
model: 'gpt-5.4',
messages: [
{ role: 'user', content: 'Call a tool' },
],
}
const responseBody = {
id: 'chatcmpl_tool_123',
object: 'chat.completion',
model: 'gpt-5.4',
choices: [
{
index: 0,
message: {
role: 'assistant',
content: null,
tool_calls: [
{
id: 'call_123',
type: 'function',
function: {
name: 'skill',
arguments: '{"name":"hai-ai","user_message":"A & B < C > D 'ok'"}',
},
},
],
},
finish_reason: 'tool_calls',
},
],
}
const rendered = renderResponse(responseBody, requestBody, 'openai:chat')
expect(rendered.error).toBeUndefined()
const firstBlock = rendered.blocks[0]
if (!firstBlock || firstBlock.type !== 'message') {
throw new Error('expected first render block to be message')
}
expect(firstBlock.content[0]).toMatchObject({
type: 'tool_use',
toolName: 'skill',
input: JSON.stringify({
name: 'hai-ai',
user_message: "A & B < C > D 'ok'",
}, null, 2),
})
})
})

View File

@@ -32,6 +32,63 @@ import {
/** Raw JSON object from API (loosely typed) */
type RawObject = Record<string, unknown>
type JsonParseResult =
| { ok: true; value: unknown }
| { ok: false }
const HTML_ENTITY_MAP: Record<string, string> = {
amp: '&',
apos: "'",
gt: '>',
lt: '<',
nbsp: '\u00A0',
quot: '"',
}
const parseJsonString = (input: string): JsonParseResult => {
try {
return { ok: true, value: JSON.parse(input) as unknown }
} catch {
return { ok: false }
}
}
const decodeHtmlEntityToken = (entity: string): string => {
const normalized = entity.toLowerCase()
const named = HTML_ENTITY_MAP[normalized]
if (named !== undefined) {
return named
}
if (normalized.startsWith('#x')) {
const codePoint = Number.parseInt(normalized.slice(2), 16)
if (Number.isFinite(codePoint) && codePoint >= 0 && codePoint <= 0x10FFFF) {
return String.fromCodePoint(codePoint)
}
}
if (normalized.startsWith('#')) {
const codePoint = Number.parseInt(normalized.slice(1), 10)
if (Number.isFinite(codePoint) && codePoint >= 0 && codePoint <= 0x10FFFF) {
return String.fromCodePoint(codePoint)
}
}
return `&${entity};`
}
const decodeHtmlEntities = (input: string): string => {
let decoded = input
for (let pass = 0; pass < 3; pass += 1) {
const next = decoded.replace(/&(#x[0-9a-f]+|#\d+|[a-z][a-z0-9]+);/gi, (_match, entity: string) => decodeHtmlEntityToken(entity))
if (next === decoded) {
break
}
decoded = next
}
return decoded
}
/**
* OpenAI API 格式解析器
*/
@@ -1153,12 +1210,21 @@ export class OpenAIParser implements ApiFormatParser {
*/
private formatJson(input: unknown): string {
if (typeof input === 'string') {
try {
const parsed = JSON.parse(input) as unknown
return JSON.stringify(parsed, null, 2)
} catch {
return input
const parsed = parseJsonString(input)
if (parsed.ok) {
return JSON.stringify(parsed.value, null, 2)
}
const decoded = decodeHtmlEntities(input)
if (decoded !== input) {
const parsedDecoded = parseJsonString(decoded)
if (parsedDecoded.ok) {
return JSON.stringify(parsedDecoded.value, null, 2)
}
return decoded
}
return input
}
return JSON.stringify(input, null, 2)
}