export interface TeacherReply { reply: string; quickReplies: string[]; parseError?: string; /** No unambiguous, complete prose field could be recovered. */ incomplete?: boolean; /** Prose escaping was repaired; retain the original bytes for diagnosis. */ recovered?: boolean; } // Resource limits, not writing instructions. Ordinary answers below the transport // limit are preserved in full, including Markdown and long suggested questions. export const MAX_TEACHER_REPLY_BYTES = 1024 * 1024; export const MAX_TEACHER_QUICK_REPLIES = 128; export const MAX_TEACHER_QUICK_REPLY_BYTES = 64 * 1024; const INVALID_REPLY = '这次回复没有完整解析,请重试。'; const INVALID_FORMAT = '回复格式不完整或无效。'; const RESOURCE_LIMIT = '回复超过可处理的数据大小。'; const protocolKeys = new Set(['reply', 'quickReplies', 'intro', 'questions', 'tool']); const own = (value: object, key: string) => Object.prototype.hasOwnProperty.call(value, key); function withinBytes(value: string, max: number): boolean { return value.length <= max && new TextEncoder().encode(value).length <= max; } function stringEnd(raw: string, start: number): number | undefined { for (let index = start + 1; index < raw.length; index++) { if (raw[index] === '\\') { index++; continue; } if (raw[index] === '"') return index + 1; } return undefined; } /** A quote alone is not a field boundary: it may be punctuation in the prose. */ function fieldBoundary(raw: string, end: number): boolean { let index = end; while (/\s/.test(raw[index] ?? '') && index < raw.length) index++; if (raw[index] === '}') { while (index < raw.length && (raw[index] === '}' || /\s/.test(raw[index]))) index++; return index === raw.length; } if (raw[index++] !== ',') return false; while (/\s/.test(raw[index] ?? '') && index < raw.length) index++; if (index === raw.length) return true; // Complete value, missing later fields. if (raw[index] !== '"') return false; const keyEnd = stringEnd(raw, index); if (keyEnd === undefined) return false; try { JSON.parse(raw.slice(index, keyEnd)); } catch { return false; } index = keyEnd; while (/\s/.test(raw[index] ?? '') && index < raw.length) index++; return raw[index] === ':'; } /** Find one complete value without interpreting nested legacy tool data. */ function valueEnd(raw: string, start: number): number | undefined { if (raw[start] === '"') return stringEnd(raw, start); if (raw[start] !== '{' && raw[start] !== '[') { let end = start; while (end < raw.length && !/[,}\]\s]/.test(raw[end])) end++; return end > start ? end : undefined; } const stack = [raw[start]]; for (let index = start + 1; index < raw.length; index++) { const character = raw[index]; if (character === '"') { const end = stringEnd(raw, index); if (end === undefined) return undefined; index = end - 1; } else if (character === '{' || character === '[') stack.push(character); else if (character === '}' || character === ']') { if (stack.pop() !== (character === '}' ? '{' : '[')) return undefined; if (!stack.length) return index + 1; } } return undefined; } /** Recover only complete top-level fields; nested reply-looking data stays inert. */ function fields(raw: string): { values: Map; keys: Set; duplicate: boolean; toolStart?: number; proseStart?: number } { const values = new Map(), keys = new Set(); let duplicate = false, index = raw.indexOf('{') + 1; let toolStart: number | undefined; let proseStart: number | undefined; const whitespace = () => { while (index < raw.length && /\s/.test(raw[index])) index++; }; while (index > 0 && index < raw.length) { whitespace(); if (raw[index] !== '"') break; const end = stringEnd(raw, index); if (end === undefined) break; let key: string; try { key = JSON.parse(raw.slice(index, end)); } catch { break; } index = end; whitespace(); if (raw[index++] !== ':') break; duplicate ||= keys.has(key); keys.add(key); whitespace(); if (key === 'tool') toolStart = index; if ((key === 'reply' || key === 'intro') && proseStart === undefined) proseStart = index; const next = valueEnd(raw, index); if (next === undefined || !fieldBoundary(raw, next)) break; if ((protocolKeys.has(key) || key === 'kind') && key !== 'tool' && !values.has(key)) { try { values.set(key, JSON.parse(raw.slice(index, next))); } catch { break; } } index = next; whitespace(); if (raw[index++] !== ',') break; } return { values, keys, duplicate, toolStart, proseStart }; } /** Repair only prose escaping, never fabricate missing text or consume other fields. */ function repairProse(raw: string, start: number | undefined): string | undefined { if (start === undefined || raw[start] !== '"') return undefined; for (let index = start + 1; index < raw.length; index++) { if (raw[index] === '\\') { index++; continue; } if (raw[index] !== '"' || !fieldBoundary(raw, index + 1)) continue; // Stop at the first field boundary even if later auxiliary data is broken. // Searching past it could swallow tool data into a fabricated prose field. let encoded = '"'; for (let cursor = start + 1; cursor < index; cursor++) { const character = raw[cursor]; if (character === '\\') { encoded += character + raw[++cursor]; continue; } encoded += character === '"' || character.charCodeAt(0) < 32 ? JSON.stringify(character).slice(1, -1) : character; } encoded += '"'; if (encoded === raw.slice(start, index + 1)) return undefined; const repaired = raw.slice(0, start) + encoded + raw.slice(index + 1); try { JSON.parse(repaired); return repaired; } catch { return undefined; } } return undefined; } interface Candidate { start: number; end: number; content: string; incomplete?: boolean } function isEnvelope(raw: string): boolean { const scanned = fields(raw); if (scanned.keys.has('reply') || scanned.keys.has('quickReplies') || (scanned.keys.has('intro') && scanned.keys.has('questions'))) return true; if (scanned.keys.has('intro')) { const repaired = repairProse(raw, scanned.proseStart); if (repaired) { const envelope = JSON.parse(repaired) as Record; if (own(envelope, 'intro') && own(envelope, 'questions')) return true; } } // "questions" and "tool" also occur in ordinary API/code examples. A tool // alone identifies the retired reply protocol only through its legacy kind. if (scanned.toolStart !== undefined && raw[scanned.toolStart] === '{') { const kind = fields(raw.slice(scanned.toolStart)).values.get('kind'); if (typeof kind === 'string' && ['ideas', 'structure', 'flow', 'comparison'].includes(kind)) return true; } return /^\{\s*(?:reply|quickReplies)\s*:/.test(raw); } /** Markdown examples stay prose unless a top-level reply envelope identifies them. */ function candidates(raw: string): Candidate[] { if (raw.startsWith('{') && isEnvelope(raw)) { const end = valueEnd(raw, 0); try { if (end === undefined) throw new Error(); JSON.parse(raw.slice(0, end)); } catch { // Literal newlines/code fences within damaged prose belong to this envelope. return [{ start: 0, end: raw.length, content: raw }]; } } const result: Candidate[] = []; const inspectText = (start: number, end: number) => { for (let index = start; index < end; index++) { if (raw[index] === '`') { let fenceEnd = index + 1; while (raw[fenceEnd] === '`') fenceEnd++; const close = raw.indexOf(raw.slice(index, fenceEnd), fenceEnd); index = close >= 0 && close < end ? close + fenceEnd - index - 1 : end; } else if (raw[index] === '[') { const endOfArray = valueEnd(raw, index); if (endOfArray !== undefined && endOfArray <= end) { try { if (Array.isArray(JSON.parse(raw.slice(index, endOfArray)))) index = endOfArray - 1; } catch { /* A Markdown link is not a JSON array. */ } } } else if (raw[index] === '{') { const objectEnd = valueEnd(raw, index); let stop = objectEnd !== undefined && objectEnd <= end ? objectEnd : end; let content = raw.slice(index, stop); if (isEnvelope(content)) { // A stray quote can also fool bracket scanning. Keep the received tail // with the malformed envelope instead of appending it as normal prose. try { JSON.parse(content); } catch { stop = end; content = raw.slice(index, stop); } result.push({ start: index, end: stop, content }); } index = stop - 1; } } }; const opening = /(?:^|\n)[ \t]*(`{3,}|~{3,})([^\r\n]*)\r?\n/g; let processed = 0, match: RegExpExecArray | null; while ((match = opening.exec(raw))) { inspectText(processed, match.index); const bodyStart = opening.lastIndex; const marker = match[1][0], size = match[1].length; const closing = new RegExp('(?:^|\\n)[ \\t]*' + marker + '{' + size + ',}[ \\t]*(?=\\r?\\n|$)', 'g'); closing.lastIndex = bodyStart; const close = closing.exec(raw); const body = raw.slice(bodyStart, close?.index ?? raw.length).trim(); const language = match[2].trim().toLowerCase(); const explicit = ['makelore-teacher', 'makelore-teacher-reply', 'makelore-teacher-discussion'].includes(language); if (explicit || ((language === 'json' || language === '') && body.startsWith('{') && isEnvelope(body))) { result.push({ start: match.index, end: close ? closing.lastIndex : raw.length, content: body, incomplete: !close }); } processed = close ? closing.lastIndex : raw.length; opening.lastIndex = processed; } inspectText(processed, raw.length); return result; } /** Historical diagnostics must be identifiable as our envelope, not arbitrary raw text. */ export function isTeacherReplyEnvelope(raw: string): boolean { return withinBytes(raw, MAX_TEACHER_REPLY_BYTES) && candidates(raw.trim()).length > 0; } /** Preview received prose only; final validation still belongs to parseTeacherReply. */ export function previewTeacherReply(raw: string): string { if (!withinBytes(raw, MAX_TEACHER_REPLY_BYTES)) return ''; const text = raw.trimStart(); const found = candidates(text); if (!found.length) { // Wait for enough of an opening JSON/fence to identify the reply envelope. if (/^\{/.test(text)) { try { JSON.parse(text); } catch { return ''; } } if (/^(`{1,3}|~{1,3})([^\n]*)$/.test(text) || /^(?:```|~~~)(?:json|makelore-teacher(?:-reply|-discussion)?)?\s*\n\s*\{?[^:]*$/i.test(text)) return ''; return text; } if (found.length !== 1) return ''; const candidate = found[0]; const scanned = fields(candidate.content); if (scanned.duplicate || scanned.proseStart === undefined) return ''; const start = scanned.proseStart; if (candidate.content[start] !== '"') return ''; let end = start + 1; while (end < candidate.content.length) { const char = candidate.content[end]; if (char === '"') break; if (char === '\\') { const size = candidate.content[end + 1] === 'u' ? 6 : 2; if (end + size > candidate.content.length) break; end += size; } else end++; } try { // Incomplete escapes remain buffered, never displayed as raw protocol text. const prose: string = JSON.parse('"' + candidate.content.slice(start + 1, end) + '"'); return [text.slice(0, candidate.start).trim(), prose.replace(/[\uD800-\uDBFF]$/, '')] .filter(Boolean).join('\n\n'); } catch { return ''; } } function projectReply(envelope: Record): TeacherReply { const reply = own(envelope, 'reply') ? envelope.reply : envelope.intro; const suggestions = own(envelope, 'quickReplies') ? envelope.quickReplies : envelope.questions; let parseError: string | undefined; let quickReplies: string[] = []; if (suggestions !== undefined) { if (!Array.isArray(suggestions) || suggestions.some(item => typeof item !== 'string' || !item.trim())) { parseError = INVALID_FORMAT; } else if (suggestions.length > MAX_TEACHER_QUICK_REPLIES || suggestions.some(item => !withinBytes(item, MAX_TEACHER_QUICK_REPLY_BYTES))) { parseError = RESOURCE_LIMIT; } else quickReplies = suggestions; } if (typeof reply !== 'string' || (!reply.trim() && !quickReplies.length)) { return { reply: INVALID_REPLY, quickReplies: [], parseError: INVALID_FORMAT, incomplete: true }; } return { reply, quickReplies, ...(parseError ? { parseError } : {}) }; } /** Only text and suggested replies survive; legacy tool payloads are never restored. */ export function parseTeacherReply(raw: string): TeacherReply { if (!withinBytes(raw, MAX_TEACHER_REPLY_BYTES)) { return { reply: INVALID_REPLY, quickReplies: [], parseError: RESOURCE_LIMIT, incomplete: true }; } const text = raw.trim(); if (!text) return { reply: INVALID_REPLY, quickReplies: [], parseError: INVALID_FORMAT, incomplete: true }; const found = candidates(text); if (!found.length) return { reply: text, quickReplies: [] }; const candidate = found[0]; let result: TeacherReply; let recovered = fields(candidate.content); try { const envelope: unknown = JSON.parse(candidate.content); if (!envelope || typeof envelope !== 'object' || Array.isArray(envelope)) throw new Error(); result = projectReply(envelope as Record); } catch { const repaired = repairProse(candidate.content, recovered.proseStart); if (repaired) { recovered = fields(repaired); result = { ...projectReply(JSON.parse(repaired)), recovered: true }; } else { result = projectReply(Object.fromEntries(recovered.values)); result.parseError = INVALID_FORMAT; } } if (candidate.incomplete) result.parseError = INVALID_FORMAT; if (recovered.duplicate || found.length > 1) { result = { reply: INVALID_REPLY, quickReplies: [], parseError: INVALID_FORMAT, incomplete: true }; } // Multiple envelopes are ambiguous; never append their JSON as visible prose. if (found.length === 1) { const suffix = text.slice(candidate.end).trim(); const brokenTail = /^[,\s]*"(?:reply|quickReplies|intro|questions|tool)"\s*:/.test(suffix) || (suffix.length > 0 && /^[}\],\s]+$/.test(suffix)); if (brokenTail) result.parseError = INVALID_FORMAT; if (!result.incomplete) result.reply = [text.slice(0, candidate.start).trim(), result.reply, brokenTail ? '' : suffix] .filter(part => part.length > 0).join('\n\n'); } return result; }