/** Content comes from the teacher; rendering, lifecycle, and type locking belong to the app. */ export interface TeacherIdeaItem { id: string; text: string; parentId?: string; state: 'kept' | 'suggested' | 'aside'; } export interface TeacherStructureNode { id: string; label: string; relation?: string; parentId?: string; } export interface TeacherFlowNode { id: string; label: string; kind: 'event' | 'condition' | 'outcome'; } export interface TeacherFlowEdge { id: string; from: string; to: string; label?: string; } export interface TeacherComparisonColumn { id: string; label: string; } export interface TeacherComparisonRow { id: string; label: string; cells: Array<{ columnId: string; text: string }>; } export type TeacherDiscussionContent = | { kind: 'ideas'; title: string; items: TeacherIdeaItem[]; firstItemId?: string } | { kind: 'structure'; title: string; nodes: TeacherStructureNode[] } | { kind: 'flow'; title: string; nodes: TeacherFlowNode[]; edges: TeacherFlowEdge[] } | { kind: 'comparison'; title: string; columns: TeacherComparisonColumn[]; rows: TeacherComparisonRow[] }; export interface TeacherDiscussion { id: string; revision: number; status: 'offered' | 'active' | 'paused' | 'finished'; content: TeacherDiscussionContent; previousIdeas?: Extract; previousStructure?: Extract; } export interface TeacherDiscussionReply { reply: string; quickReplies: string[]; tool?: TeacherDiscussionContent | null; toolError?: string; } const MAX_REPLY_LENGTH = 12000; const MAX_INPUT_BYTES = 64000; const INVALID_REPLY = '这次回复没有整理完整,请再试一次。'; const INVALID_TOOL = '这次整理没有完成,先保留原来的内容。'; const dangerousKeys = new Set(['__proto__', 'prototype', 'constructor']); function invalid(): never { // Validation errors contain no untrusted model content. throw new Error('老师组件内容格式无效或超过长度限制。'); } function record(value: unknown): Record { if (!value || typeof value !== 'object' || Array.isArray(value)) return invalid(); const prototype = Object.getPrototypeOf(value); if (prototype !== Object.prototype && prototype !== null) return invalid(); for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(value))) { if (dangerousKeys.has(key) || !('value' in descriptor)) return invalid(); } return value as Record; } function text(value: unknown, max: number, allowEmpty = false): string { if (typeof value !== 'string' || value.length > max || (!allowEmpty && !value.trim())) return invalid(); return value.trim(); } function id(value: unknown): string { if (typeof value !== 'string' || !/^[A-Za-z0-9_-]{1,64}$/.test(value) || dangerousKeys.has(value)) return invalid(); return value; } function array(value: unknown, min: number, max: number): unknown[] { if (!Array.isArray(value) || value.length < min || value.length > max) return invalid(); // Do not invoke accessor elements, overridden map methods, or custom iterators. const projected: unknown[] = []; for (let index = 0; index < value.length; index++) { const descriptor = Object.getOwnPropertyDescriptor(value, index); if (!descriptor || !('value' in descriptor)) return invalid(); projected.push(descriptor.value); } return projected; } function uniqueIds(values: T[]): Set { const ids = new Set(values.map(value => value.id)); if (ids.size !== values.length) return invalid(); return ids; } function optionalParent(value: Record): { parentId?: string } { return value.parentId === undefined ? {} : { parentId: id(value.parentId) }; } function validateParents(values: Array<{ id: string; parentId?: string }>): void { const ids = uniqueIds(values); const parents = new Map(values.map(value => [value.id, value.parentId])); for (const value of values) { const path = new Set([value.id]); let parent = value.parentId; while (parent !== undefined) { if (!ids.has(parent) || path.has(parent)) return invalid(); path.add(parent); parent = parents.get(parent); } } } /** Validate and project only the supported data fields. No HTML, style, or executable actions are copied. */ export function parseTeacherDiscussionContent(value: unknown): TeacherDiscussionContent { const content = record(value); const title = text(content.title, 120); switch (content.kind) { case 'ideas': { const items: TeacherIdeaItem[] = array(content.items, 1, 24).map(raw => { const item = record(raw); if (item.state !== 'kept' && item.state !== 'suggested' && item.state !== 'aside') return invalid(); return { id: id(item.id), text: text(item.text, 600), ...optionalParent(item), state: item.state }; }); validateParents(items); if (content.firstItemId !== undefined) { const firstItemId = id(content.firstItemId); if (!items.some(item => item.id === firstItemId && item.state === 'kept')) return invalid(); return { kind: 'ideas', title, items, firstItemId }; } return { kind: 'ideas', title, items }; } case 'structure': { const nodes: TeacherStructureNode[] = array(content.nodes, 1, 24).map(raw => { const node = record(raw); return { id: id(node.id), label: text(node.label, 600), ...optionalParent(node), ...(node.relation === undefined ? {} : { relation: text(node.relation, 120) }), }; }); validateParents(nodes); return { kind: 'structure', title, nodes }; } case 'flow': { const nodes: TeacherFlowNode[] = array(content.nodes, 1, 24).map(raw => { const node = record(raw); if (node.kind !== 'event' && node.kind !== 'condition' && node.kind !== 'outcome') return invalid(); return { id: id(node.id), label: text(node.label, 600), kind: node.kind }; }); const nodeIds = uniqueIds(nodes); const edges: TeacherFlowEdge[] = array(content.edges, 0, 40).map(raw => { const edge = record(raw); const from = id(edge.from), to = id(edge.to); if (!nodeIds.has(from) || !nodeIds.has(to)) return invalid(); return { id: id(edge.id), from, to, ...(edge.label === undefined ? {} : { label: text(edge.label, 160) }), }; }); uniqueIds(edges); // Explicit cycles are valid: a game loop or failed attempt may return to an earlier event. return { kind: 'flow', title, nodes, edges }; } case 'comparison': { const columns: TeacherComparisonColumn[] = array(content.columns, 2, 4).map(raw => { const column = record(raw); return { id: id(column.id), label: text(column.label, 120) }; }); const columnIds = uniqueIds(columns); const rows: TeacherComparisonRow[] = array(content.rows, 1, 12).map(raw => { const row = record(raw); const cells = array(row.cells, columns.length, columns.length).map(rawCell => { const cell = record(rawCell); const columnId = id(cell.columnId); if (!columnIds.has(columnId)) return invalid(); return { columnId, text: text(cell.text, 600) }; }); if (new Set(cells.map(cell => cell.columnId)).size !== columns.length) return invalid(); const byColumn = new Map(cells.map(cell => [cell.columnId, cell])); return { id: id(row.id), label: text(row.label, 160), cells: columns.map(column => byColumn.get(column.id)!), }; }); uniqueIds(rows); return { kind: 'comparison', title, columns, rows }; } default: return invalid(); } } function quickReplies(value: unknown): string[] { if (value === undefined) return []; try { return [...new Set(array(value, 0, 3).map(reply => text(reply, 120)))]; } catch { return []; } } function stringToken(raw: string, start: number): { value: string; end: number } | undefined { for (let cursor = start + 1; cursor < raw.length; cursor++) { if (raw[cursor] === '\\') { cursor++; continue; } if (raw[cursor] !== '"') continue; try { const value: unknown = JSON.parse(raw.slice(start, cursor + 1)); if (typeof value === 'string') return { value, end: cursor + 1 }; } catch { return undefined; } } return undefined; } /** Inspect only top-level fields, including when a later value was truncated. */ function findTopLevelField(raw: string, names: readonly string[]): number | undefined { if (!raw.trimStart().startsWith('{')) return undefined; let depth = 0, expectingKey = false; for (let cursor = 0; cursor < raw.length; cursor++) { const character = raw[cursor]; if (character === '"') { const token = stringToken(raw, cursor); if (!token) return undefined; if (depth === 1 && expectingKey && names.includes(token.value)) { let valueStart = token.end; while (/\s/.test(raw[valueStart] ?? '') && valueStart < raw.length) valueStart++; if (raw[valueStart] !== ':') return undefined; valueStart++; while (/\s/.test(raw[valueStart] ?? '') && valueStart < raw.length) valueStart++; return valueStart; } if (depth === 1) expectingKey = false; cursor = token.end - 1; } else if (character === '{' || character === '[') { depth++; if (depth === 1) expectingKey = character === '{'; } else if (character === '}' || character === ']') { depth--; } else if (character === ',' && depth === 1) { expectingKey = true; } } return undefined; } /** Recover a complete reply string, never nested tool data or a partial string. */ function recoverReply(raw: string): string | undefined { const start = findTopLevelField(raw, ['reply']); if (start === undefined || raw[start] !== '"') return undefined; const value = stringToken(raw, start)?.value; return value?.trim() && value.length <= MAX_REPLY_LENGTH ? value.trim() : undefined; } function invalidReply(candidate: string): TeacherDiscussionReply { return { reply: recoverReply(candidate) ?? INVALID_REPLY, quickReplies: [], toolError: INVALID_TOOL }; } /** Parse final model output. Callers keep the existing tool whenever toolError is present. */ export function parseTeacherDiscussionReply(raw: string): TeacherDiscussionReply { const trimmed = raw.trim(); // A Markdown link, JSON example or code fence is not a discussion envelope. // Only our explicit fence or top-level protocol fields select this parser. const firstFence = /(?:^|\n)[ \t]*```([^\r\n]*)[ \t]*(?:\r?\n|$)/.exec(trimmed); const language = firstFence?.[1].trim().toLowerCase(); const explicitFence = language === 'makelore-teacher' || language === 'makelore-teacher-discussion'; const opening = firstFence && (explicitFence || language === 'json' || language === '') ? firstFence : null; const unfencedEnvelope = !firstFence && !trimmed.startsWith('{') ? /(?:^|\n)[ \t]*(\{\s*"(?:reply|quickReplies|tool)"\s*:)/.exec(trimmed) : null; let candidate = unfencedEnvelope ? trimmed.slice(unfencedEnvelope.index).trimStart() : trimmed; let validFence = true; if (opening) { const remainder = trimmed.slice(opening.index + opening[0].length); const closing = /\r?\n```[ \t]*$/.exec(remainder); candidate = closing ? remainder.slice(0, closing.index) : remainder; validFence = !!closing && !/(?:^|\n)[ \t]*```/.test(candidate); } if (!explicitFence && findTopLevelField(candidate, ['reply', 'quickReplies', 'tool']) === undefined) { return { reply: trimmed.slice(0, MAX_REPLY_LENGTH) || INVALID_REPLY, quickReplies: [] }; } if (new TextEncoder().encode(trimmed).length > MAX_INPUT_BYTES) return invalidReply(candidate.slice(0, MAX_INPUT_BYTES)); if (!validFence) return invalidReply(candidate); try { const envelope = record(JSON.parse(candidate)); const reply = text(envelope.reply, MAX_REPLY_LENGTH, true); const result: TeacherDiscussionReply = { reply, quickReplies: quickReplies(envelope.quickReplies) }; if (envelope.tool === null) result.tool = null; else if (envelope.tool !== undefined) { try { result.tool = parseTeacherDiscussionContent(envelope.tool); } catch { result.toolError = INVALID_TOOL; } } if (!reply && !result.tool) result.reply = INVALID_REPLY; return result; } catch { return invalidReply(candidate); } }