Files
app-server/assets/chat/interaction-kernel.js
2026-08-07 13:36:56 +08:00

76 lines
3.8 KiB
JavaScript

/*
* Platform-neutral LineUp presentation kernel.
*
* It converts untrusted transport payloads into a small, validated set of
* ConversationItems. UI hosts register trusted renderers for those items;
* neither a renderer nor a UI Surface needs to parse IM payloads directly.
*/
(function exposeLineUpKernel(root, factory) {
const api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
root.LineUp = Object.assign(root.LineUp || {}, api);
})(typeof globalThis === 'undefined' ? window : globalThis, function createKernel() {
const PROTOCOL_VERSION = 1;
const MAX_PAYLOAD_BYTES = 192 * 1024;
const UI_TYPES = new Set(['lineup.v1.ui.open', 'lineup.v1.ui.patch', 'lineup.v1.ui.close']);
function isObject(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); }
function byteLength(value) { return new TextEncoder().encode(String(value)).length; }
function text(value) { return typeof value === 'string' ? value : ''; }
function item(kind, data, envelope) {
return Object.freeze({ kind, data: Object.freeze(data), envelope: envelope || null, receivedAt: Date.now() });
}
class ConversationItemFactory {
decode(rawPayload) {
const raw = String(rawPayload ?? '');
if (byteLength(raw) > MAX_PAYLOAD_BYTES) return item('protocol-fallback', { reason: 'payload_too_large' });
let value;
try { value = JSON.parse(raw); } catch { return item('markdown', { markdown: raw, source: 'plain' }); }
if (!isObject(value) || typeof value.type !== 'string') return item('markdown', { markdown: raw, source: 'plain' });
if (value.v !== PROTOCOL_VERSION || !value.type.startsWith('lineup.v1.')) return this.legacy(value);
return this.envelope(value);
}
envelope(envelope) {
const payload = isObject(envelope.payload) ? envelope.payload : {};
switch (envelope.type) {
case 'lineup.v1.text':
return item('markdown', { markdown: text(payload.text), source: 'agent' }, envelope);
case 'lineup.v1.agent.status':
return item('agent-status', { status: text(payload.status) || 'unknown', detail: text(payload.detail) }, envelope);
case 'lineup.v1.agent.progress':
return item('progress', { title: text(payload.title) || '处理中', percent: Number(payload.percent) || 0, status: text(payload.status) }, envelope);
case 'lineup.v1.error':
return item('agent-error', { code: text(payload.code), message: text(payload.message) || '未知错误' }, envelope);
case 'lineup.v1.app.call':
return item('app-call', payload, envelope);
default:
if (UI_TYPES.has(envelope.type)) return item(envelope.type.slice('lineup.v1.'.length), payload, envelope);
return item('protocol-fallback', { reason: 'unsupported_type', type: envelope.type }, envelope);
}
}
legacy(value) {
if (value.v === undefined && typeof value.type === 'string') return item('legacy-card', value);
return item('protocol-fallback', { reason: 'unsupported_version', version: value.v, type: value.type });
}
}
class RendererRegistry {
constructor() { this.renderers = new Map(); }
register(kind, renderer) {
if (typeof kind !== 'string' || typeof renderer !== 'function') throw new TypeError('Renderer registration requires a kind and a function');
this.renderers.set(kind, renderer);
return this;
}
render(conversationItem, context) {
const renderer = this.renderers.get(conversationItem.kind) || this.renderers.get('protocol-fallback');
if (!renderer) throw new Error(`No renderer registered for ${conversationItem.kind}`);
return renderer(conversationItem, context);
}
}
return { PROTOCOL_VERSION, MAX_PAYLOAD_BYTES, ConversationItemFactory, RendererRegistry, isObject };
});