301 lines
12 KiB
TypeScript
301 lines
12 KiB
TypeScript
/** 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<TeacherDiscussionContent, { kind: 'ideas' }>;
|
|
previousStructure?: Extract<TeacherDiscussionContent, { kind: 'structure' }>;
|
|
}
|
|
|
|
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<string, unknown> {
|
|
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<string, unknown>;
|
|
}
|
|
|
|
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<T extends { id: string }>(values: T[]): Set<string> {
|
|
const ids = new Set(values.map(value => value.id));
|
|
if (ids.size !== values.length) return invalid();
|
|
return ids;
|
|
}
|
|
|
|
function optionalParent(value: Record<string, unknown>): { 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<string>([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;
|
|
}
|
|
|
|
/** Recover only a complete top-level JSON string field, never nested tool data or a partial string. */
|
|
function recoverReply(raw: string): string | 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 && token.value === 'reply') {
|
|
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++;
|
|
if (raw[valueStart] !== '"') return undefined;
|
|
const value = stringToken(raw, valueStart)?.value;
|
|
if (value?.trim() && value.length <= MAX_REPLY_LENGTH) return value.trim();
|
|
return undefined;
|
|
}
|
|
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;
|
|
}
|
|
|
|
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();
|
|
// JSON-labelled fences are transport, including truncated fences. Ordinary programming fences remain text.
|
|
const fence = /(?:^|\n)[ \t]*```(?:json|makelore-teacher(?:-discussion)?)[ \t]*(?:\r?\n|$)/i.exec(trimmed);
|
|
const bareFence = /^```[ \t]*\r?\n(?=\s*[{[])/.exec(trimmed);
|
|
const opening = fence ?? bareFence;
|
|
const unfencedEnvelope = /\{\s*"(?:reply|quickReplies|tool)"\s*:/.exec(trimmed);
|
|
const startsJson = /^[{[]/.test(trimmed);
|
|
const looksStructured = !!opening || !!unfencedEnvelope || startsJson || /^```(?:json|makelore-teacher)/i.test(trimmed);
|
|
if (!looksStructured) {
|
|
return { reply: trimmed.slice(0, MAX_REPLY_LENGTH) || INVALID_REPLY, quickReplies: [] };
|
|
}
|
|
|
|
let candidate = !opening && !startsJson && unfencedEnvelope ? trimmed.slice(unfencedEnvelope.index) : 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 (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); }
|
|
}
|