mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
Fix/usage json entity escaping (#333)
* fix(usage): 修复 JSON 视图实体转义显示 * fix(usage): 解码 OpenAI 工具参数实体
This commit is contained in:
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user