Merge teacher reply cleanup with continuous conversations
This commit is contained in:
@@ -1,4 +1,3 @@
|
||||
import type { TeacherDiscussion, TeacherDiscussionContent } from './teacher-discussion';
|
||||
import type { PublicUsage } from './coding-conversation-contracts';
|
||||
|
||||
/** Only retained for locating conversations created by older clients. */
|
||||
@@ -67,19 +66,6 @@ export type TeacherRequestStatus =
|
||||
| 'failed'
|
||||
| 'cancelled'
|
||||
| 'interrupted';
|
||||
export interface TeacherDiscussionContext {
|
||||
toolId: string;
|
||||
revision: number;
|
||||
focusId?: string;
|
||||
transition?: 'structure';
|
||||
}
|
||||
export interface TeacherDiscussionAction {
|
||||
projectId?: string;
|
||||
toolId: string;
|
||||
revision: number;
|
||||
action: 'enter' | 'pause' | 'finish' | 'resume' | 'keep-idea' | 'aside-idea' | 'first-idea' | 'back-ideas';
|
||||
itemId?: string;
|
||||
}
|
||||
export interface TeacherRequest {
|
||||
/** Frozen when this turn is accepted; history can span projects and releases. */
|
||||
projectId?: string;
|
||||
@@ -87,11 +73,14 @@ export interface TeacherRequest {
|
||||
teacherVersion?: number;
|
||||
runtimeThreadId?: string;
|
||||
origin?: { projectId: string; topicId: string; requestId: string };
|
||||
presentation?: 'discussion-v1';
|
||||
discussionContext?: TeacherDiscussionContext;
|
||||
discussionSnapshot?: TeacherDiscussionContent;
|
||||
/** discussion-v1 is retained only to identify historical replies. */
|
||||
presentation?: 'reply-v1' | 'discussion-v1';
|
||||
/** Archived legacy data; never parsed into interactive component state. */
|
||||
discussionContext?: unknown;
|
||||
discussionSnapshot?: unknown;
|
||||
discussionError?: string;
|
||||
/** Original answer retained when discussion parsing fails; never fed back as context. */
|
||||
replyParseError?: string;
|
||||
/** Original answer retained when reply parsing fails; never fed back as context. */
|
||||
unparsedResponse?: string;
|
||||
toolActivity?: TeacherToolActivity[];
|
||||
|
||||
@@ -127,10 +116,12 @@ export interface TeacherTopic {
|
||||
agentId: string;
|
||||
runtimeThreadId?: string;
|
||||
segmentTurns: number;
|
||||
discussions: Record<string, TeacherDiscussion>;
|
||||
/** Archived per-project component data; never selected or updated by new turns. */
|
||||
discussions?: Record<string, unknown>;
|
||||
seenThrough?: string;
|
||||
};
|
||||
discussion?: TeacherDiscussion;
|
||||
/** Archived legacy data; new requests do not create or update it. */
|
||||
discussion?: unknown;
|
||||
role?: LegacyConsultationRole;
|
||||
revision: number;
|
||||
schemaVersion: 1;
|
||||
@@ -152,8 +143,6 @@ export interface TeacherTopicList {
|
||||
}
|
||||
export interface TeacherSend {
|
||||
projectId?: string;
|
||||
presentation?: 'discussion-v1';
|
||||
discussion?: TeacherDiscussionContext;
|
||||
intent?: TeacherRequestIntent;
|
||||
sourceConversationId?: string;
|
||||
requestId: string;
|
||||
|
||||
@@ -1,305 +0,0 @@
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/** 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); }
|
||||
}
|
||||
198
shared/teacher-reply.ts
Normal file
198
shared/teacher-reply.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
export interface TeacherReply {
|
||||
reply: string;
|
||||
quickReplies: string[];
|
||||
parseError?: string;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
/** 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<string, unknown>; keys: Set<string>; duplicate: boolean; toolStart?: number } {
|
||||
const values = new Map<string, unknown>(), keys = new Set<string>();
|
||||
let duplicate = false, index = raw.indexOf('{') + 1;
|
||||
let toolStart: 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;
|
||||
const next = valueEnd(raw, index);
|
||||
if (next === undefined) 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 };
|
||||
}
|
||||
|
||||
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;
|
||||
// "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[] {
|
||||
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);
|
||||
const stop = objectEnd !== undefined && objectEnd <= end ? objectEnd : end;
|
||||
const content = raw.slice(index, stop);
|
||||
if (isEnvelope(content)) 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;
|
||||
}
|
||||
|
||||
function projectReply(envelope: Record<string, unknown>): 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 };
|
||||
}
|
||||
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 };
|
||||
}
|
||||
const text = raw.trim();
|
||||
if (!text) return { reply: INVALID_REPLY, quickReplies: [], parseError: INVALID_FORMAT };
|
||||
const found = candidates(text);
|
||||
if (!found.length) return { reply: text, quickReplies: [] };
|
||||
const candidate = found[0];
|
||||
let result: TeacherReply;
|
||||
const 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<string, unknown>);
|
||||
} catch {
|
||||
result = projectReply(Object.fromEntries(recovered.values));
|
||||
result.parseError = INVALID_FORMAT;
|
||||
}
|
||||
if (candidate.incomplete || recovered.duplicate || found.length > 1) result.parseError = INVALID_FORMAT;
|
||||
// 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;
|
||||
result.reply = [text.slice(0, candidate.start).trim(), result.reply, brokenTail ? '' : suffix]
|
||||
.filter(part => part.length > 0).join('\n\n');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
Reference in New Issue
Block a user