Initialize LineUp app server
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="theme-color" content="#f7f4ee">
|
||||
<title>LineUp</title>
|
||||
<script defer src="/vendor/marked/marked.umd.js"></script>
|
||||
<script defer src="/vendor/dompurify/purify.min.js"></script>
|
||||
<script defer src="/chat/interaction-kernel.js"></script>
|
||||
<style>
|
||||
:root { --paper:#f7f4ee; --surface:#fffdf9; --ink:#25231f; --muted:#77736b; --line:#e8e2d8; --accent:#47736b; --accent-soft:#e1eee9; --agent:#f0eee8; --danger:#b64c48; }
|
||||
* { box-sizing:border-box; } body { margin:0; min-height:100vh; font-family:ui-rounded,"SF Pro Rounded",-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; color:var(--ink); background:radial-gradient(circle at 20% 0,#fff 0,transparent 32%),var(--paper); }
|
||||
button,input { font:inherit; } button { cursor:pointer; }
|
||||
#login { width:min(360px,calc(100% - 32px)); margin:14vh auto; padding:32px; border:1px solid var(--line); border-radius:22px; background:rgba(255,253,249,.9); box-shadow:0 20px 55px rgba(58,48,35,.11); text-align:center; }
|
||||
#login h1 { font-family:ui-serif,Georgia,serif; font-size:32px; letter-spacing:-1px; margin:0 0 7px; } #login p { margin:0 0 24px; color:var(--muted); }
|
||||
#login input { width:100%; margin:0 0 10px; padding:12px 14px; border:1px solid var(--line); border-radius:10px; outline:none; background:#fff; } #login input:focus { border-color:var(--accent); box-shadow:0 0 0 3px rgba(71,115,107,.12); }
|
||||
.primary { border:0; border-radius:10px; padding:12px 18px; color:#fff; background:var(--accent); font-weight:700; } .primary:hover { background:#345b54; } .error { min-height:19px; margin-top:9px; color:var(--danger); font-size:13px; }
|
||||
#chat { display:none; width:min(900px,100%); min-height:100vh; margin:auto; background:rgba(255,253,249,.93); border-inline:1px solid var(--line); flex-direction:column; }
|
||||
#chat-header { display:flex; align-items:center; justify-content:space-between; gap:16px; min-height:72px; padding:14px 24px; border-bottom:1px solid var(--line); background:rgba(255,253,249,.86); backdrop-filter:blur(12px); position:sticky; top:0; z-index:2; }
|
||||
.agent-title { display:flex; align-items:center; gap:11px; }.avatar { display:grid; place-items:center; width:38px; height:38px; border-radius:13px; color:#fff; background:linear-gradient(145deg,#5f9085,#315a54); box-shadow:inset 0 1px rgba(255,255,255,.3); }.agent-title h1 { margin:0; font-size:15px; letter-spacing:.1px; }.presence { display:flex; align-items:center; gap:5px; margin-top:3px; color:var(--muted); font-size:12px; }.presence-dot { width:7px; height:7px; border-radius:50%; background:#aaa; }.presence.online .presence-dot { background:#43a976; box-shadow:0 0 0 3px rgba(67,169,118,.13); }.presence.thinking .presence-dot { background:#d68d36; animation:pulse 1.2s ease-in-out infinite; }.presence.offline .presence-dot { background:#aaa; }.text-button { padding:7px 9px; border:0; background:transparent; color:var(--muted); font-size:13px; }.text-button:hover { color:var(--danger); }
|
||||
#messages { display:flex; flex:1; flex-direction:column; gap:16px; overflow-y:auto; padding:28px clamp(16px,5vw,56px); scroll-behavior:smooth; }.system-message { align-self:center; max-width:100%; color:var(--muted); font-size:12px; text-align:center; }.message-row { display:flex; gap:9px; max-width:100%; }.message-row.user { justify-content:flex-end; }.message-row.agent { align-items:flex-start; }.message-avatar { flex:0 0 25px; display:grid; place-items:center; width:25px; height:25px; margin-top:4px; border-radius:9px; background:var(--accent-soft); color:var(--accent); font-size:13px; }.message { max-width:min(690px,calc(100% - 34px)); padding:12px 15px; border:1px solid transparent; border-radius:16px; line-height:1.65; font-size:15px; overflow-wrap:anywhere; }.user .message { color:#fff; background:var(--accent); border-bottom-right-radius:5px; }.agent .message { background:var(--agent); border-bottom-left-radius:5px; }.message-meta { display:flex; align-items:center; justify-content:flex-end; gap:5px; margin-top:7px; color:inherit; font-size:11px; opacity:.65; }.delivery.failed { color:#ffe3e1; opacity:1; }.message.thinking { display:flex; align-items:center; gap:7px; min-height:43px; color:#5e5b55; font-size:13px; }.thinking-dots { display:flex; gap:3px; }.thinking-dots i { width:5px; height:5px; border-radius:50%; background:var(--accent); animation:bounce 1s infinite; }.thinking-dots i:nth-child(2) { animation-delay:.16s; }.thinking-dots i:nth-child(3) { animation-delay:.32s; }
|
||||
.markdown { color:var(--ink); }.markdown>:first-child { margin-top:0; }.markdown>:last-child { margin-bottom:0; }.markdown h1,.markdown h2,.markdown h3,.markdown h4 { margin:1.05em 0 .42em; line-height:1.28; font-family:ui-serif,Georgia,serif; letter-spacing:-.2px; }.markdown h1 { font-size:1.48em; }.markdown h2 { font-size:1.28em; }.markdown h3 { font-size:1.1em; }.markdown p,.markdown ul,.markdown ol,.markdown blockquote { margin:.65em 0; }.markdown ul,.markdown ol { padding-left:1.35em; }.markdown li+li { margin-top:.2em; }.markdown blockquote { padding:.1em 0 .1em .9em; color:#615c54; border-left:3px solid #b9cfc8; }.markdown a { color:#236a60; text-decoration-color:#8bb8ad; text-underline-offset:2px; }.markdown code { padding:.13em .35em; border-radius:5px; background:rgba(43,57,52,.09); font-family:"SFMono-Regular",Consolas,monospace; font-size:.87em; }.markdown pre { margin:.8em 0; padding:13px; overflow:auto; border:1px solid #dedbd3; border-radius:10px; background:#292b29; color:#f5f1e9; }.markdown pre code { padding:0; background:none; color:inherit; }.markdown table { display:block; max-width:100%; overflow:auto; border-collapse:collapse; font-size:.92em; }.markdown th,.markdown td { padding:7px 9px; border:1px solid #dcd7ce; text-align:left; }.markdown th { background:rgba(255,255,255,.55); }.markdown img { display:block; max-width:100%; height:auto; border-radius:9px; }.markdown hr { border:0; border-top:1px solid #d8d2c8; margin:1em 0; }
|
||||
.long-reply { padding:0; overflow:hidden; }.long-reply>summary { padding:12px 15px; color:#37675e; cursor:pointer; font-weight:650; list-style:none; }.long-reply>summary::-webkit-details-marker { display:none; }.long-reply>summary::after { content:"⌄"; float:right; transition:transform .18s; }.long-reply[open]>summary::after { transform:rotate(180deg); }.long-reply .markdown { padding:0 15px 13px; border-top:1px solid rgba(116,104,88,.14); }.code-block { margin:.8em 0; border-radius:10px; overflow:hidden; background:#292b29; }.code-block>summary { padding:7px 10px; color:#c9d4cf; font:12px "SFMono-Regular",Consolas,monospace; cursor:pointer; list-style:none; border-bottom:1px solid rgba(255,255,255,.1); }.code-block>summary::-webkit-details-marker { display:none; }.code-block>summary::before { content:"⌘"; margin-right:6px; color:#89b6aa; }.code-block pre { margin:0; border:0; border-radius:0; }.copy-code { float:right; border:0; padding:0; color:#b5c7c0; background:transparent; font-size:12px; }.copy-code:hover { color:#fff; }
|
||||
.card { margin-top:8px; padding:13px; border:1px solid #dfe5de; border-radius:12px; background:#f8faf7; }.card-title { margin-bottom:7px; font-weight:700; }.card-body { color:#5e5b55; font-size:13px; }.card-actions { display:flex; flex-wrap:wrap; gap:7px; margin-top:11px; }.card-actions button { padding:7px 12px; border:1px solid #7aa499; border-radius:8px; color:#315f57; background:#fff; font-size:13px; }.card-actions button:hover { color:#fff; background:var(--accent); }.progress-bar { height:6px; overflow:hidden; border-radius:20px; background:#d9e2dd; }.progress-fill { height:100%; border-radius:inherit; background:var(--accent); transition:width .35s; }
|
||||
.surface { width:min(690px,calc(100vw - 82px)); overflow:hidden; border:1px solid #d6dfd9; border-radius:14px; background:#fff; box-shadow:0 8px 25px rgba(49,66,57,.08); }.surface-header { display:flex; align-items:center; justify-content:space-between; gap:8px; min-height:38px; padding:8px 11px; border-bottom:1px solid #e4ebe6; color:#50645d; background:#f6faf7; font-size:12px; }.surface-header strong { color:#365e55; }.surface-frame { display:block; width:100%; min-height:120px; border:0; background:#fff; }.surface-error { padding:14px; color:var(--danger); font-size:13px; }.surface-close { border:0; color:#64726d; background:transparent; font-size:16px; }.surface-close:hover { color:var(--danger); }
|
||||
#input-area { display:flex; align-items:flex-end; gap:9px; padding:14px clamp(16px,5vw,56px) max(16px,env(safe-area-inset-bottom)); border-top:1px solid var(--line); background:rgba(255,253,249,.9); }.input-shell { display:flex; flex:1; align-items:center; min-height:43px; padding:0 4px 0 13px; border:1px solid #dcd6cb; border-radius:13px; background:#fff; transition:.15s; }.input-shell:focus-within { border-color:#78a99d; box-shadow:0 0 0 3px rgba(71,115,107,.12); }.input-shell input { flex:1; min-width:0; border:0; outline:0; background:transparent; color:var(--ink); }.send { width:43px; height:35px; border:0; border-radius:10px; color:#fff; background:var(--accent); font-size:16px; }.send:disabled { cursor:not-allowed; opacity:.45; }@keyframes pulse { 50% { transform:scale(.68); opacity:.55; } }@keyframes bounce { 50% { transform:translateY(-4px); opacity:.45; } }
|
||||
@media (max-width:620px) { #chat { border:0; }.message { max-width:calc(100% - 30px); }.message-row { width:100%; } #chat-header { padding-inline:16px; } #messages { padding-top:20px; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main id="login">
|
||||
<h1>LineUp</h1><p>你的 AI 搭档</p>
|
||||
<input id="phone" type="text" autocomplete="tel" placeholder="手机号" value="13800138000">
|
||||
<input id="code" type="text" inputmode="numeric" placeholder="验证码" value="123456">
|
||||
<button class="primary" type="button" onclick="login()">进入对话</button>
|
||||
<div id="loginError" class="error" aria-live="polite"></div>
|
||||
</main>
|
||||
<main id="chat">
|
||||
<header id="chat-header">
|
||||
<div class="agent-title"><div class="avatar" aria-hidden="true">✦</div><div><h1>AI Agent</h1><div id="presence" class="presence offline"><span class="presence-dot"></span><span id="status">正在连接…</span></div></div></div>
|
||||
<button class="text-button" type="button" onclick="logout()">退出</button>
|
||||
</header>
|
||||
<section id="messages" aria-live="polite" aria-label="对话消息"></section>
|
||||
<form id="input-area" onsubmit="sendMsg(); return false;"><div class="input-shell"><input id="msgInput" autocomplete="off" placeholder="问问你的 AI 搭档…"></div><button id="sendBtn" class="send" type="submit" title="发送">↑</button></form>
|
||||
</main>
|
||||
<script>
|
||||
const API = '';
|
||||
let state = { uid:'', token:'', agentUID:'agent_hermes_main', channelID:'agent_default_channel', channelType:2, lastSeq:0, active:true };
|
||||
let polling = false, healthTimer = null, thinkingEl = null;
|
||||
let itemFactory = null, rendererRegistry = null;
|
||||
const surfaces = new Map();
|
||||
const MAX_SURFACE_BYTES = 160 * 1024;
|
||||
|
||||
function timeLabel(date = new Date()) { return `${String(date.getHours()).padStart(2,'0')}:${String(date.getMinutes()).padStart(2,'0')}`; }
|
||||
function scrollToLatest() { const el = document.getElementById('messages'); el.scrollTop = el.scrollHeight; }
|
||||
function setPresence(kind, label) { const el = document.getElementById('presence'); el.className = `presence ${kind}`; document.getElementById('status').textContent = label; }
|
||||
function showLoginError(message) { document.getElementById('loginError').textContent = message; }
|
||||
|
||||
async function login() {
|
||||
const phone = document.getElementById('phone').value.trim(), code = document.getElementById('code').value.trim();
|
||||
if (!phone || !code) return showLoginError('请填写手机号和验证码');
|
||||
try {
|
||||
const response = await fetch(`${API}/login`, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({phone, code}) });
|
||||
const data = await response.json(); if (!response.ok) return showLoginError(data.error || '登录失败');
|
||||
state = { ...state, uid:data.uid, token:data.token, agentUID:data.agent_uid || state.agentUID, channelID:data.channel_id || state.channelID, channelType:data.channel_type || state.channelType, lastSeq:0, active:true };
|
||||
document.getElementById('login').style.display = 'none'; document.getElementById('chat').style.display = 'flex';
|
||||
addSystemMsg('已进入与 AI Agent 的私密对话'); setPresence('offline','正在确认 Agent 状态…'); document.getElementById('msgInput').focus();
|
||||
pollMessages(); pollAgentHealth(); healthTimer = window.setInterval(pollAgentHealth, 10000);
|
||||
} catch (error) { showLoginError(`网络错误:${error.message}`); }
|
||||
}
|
||||
function logout() { state.active = false; window.clearInterval(healthTimer); healthTimer = null; surfaces.forEach(surface => surface.row.remove()); surfaces.clear(); document.getElementById('chat').style.display = 'none'; document.getElementById('login').style.display = 'block'; document.getElementById('messages').replaceChildren(); thinkingEl = null; state = { uid:'', token:'', agentUID:'agent_hermes_main', channelID:'agent_default_channel', channelType:2, lastSeq:0, active:true }; }
|
||||
|
||||
async function pollAgentHealth() {
|
||||
if (!state.uid || !state.active) return;
|
||||
try { const response = await fetch(`${API}/agents/${encodeURIComponent(state.agentUID)}/health`); const health = await response.json(); if (!state.active) return;
|
||||
if (thinkingEl) setPresence('thinking','正在思考…'); else if (health.online) setPresence('online','在线'); else setPresence('offline', health.known ? '暂时离线' : '等待 Agent 上线');
|
||||
} catch { if (state.active) setPresence('offline','状态不可用'); }
|
||||
}
|
||||
|
||||
async function sendMsg() {
|
||||
const input = document.getElementById('msgInput'), button = document.getElementById('sendBtn'), text = input.value.trim(); if (!text || !state.uid) return;
|
||||
input.value = ''; button.disabled = true; const message = addMessage(text, 'user'); setDelivery(message, '发送中');
|
||||
try { const response = await fetch(`${API}/messages/send`, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({from_uid:state.uid, channel_id:state.channelID, channel_type:state.channelType, payload:text})}); const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error || '发送失败'); if (data.message_seq) state.lastSeq = Math.max(state.lastSeq, data.message_seq); setDelivery(message, '已发送');
|
||||
} catch (error) { setDelivery(message, '发送失败', true); addSystemMsg(`消息未送达:${error.message}`); } finally { button.disabled = false; input.focus(); }
|
||||
}
|
||||
|
||||
async function pollMessages() {
|
||||
if (polling) return; polling = true;
|
||||
while (state.active && document.getElementById('chat').style.display !== 'none') {
|
||||
// WuKongIM returns start_message_seq inclusively. Continue from the
|
||||
// next unconsumed sequence after the local cursor.
|
||||
try { const startSeq = state.lastSeq === 0 ? 0 : state.lastSeq + 1; const response = await fetch(`${API}/messages/sync`, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({channel_id:state.channelID, channel_type:state.channelType, login_uid:state.uid, start_message_seq:startSeq, limit:50})}); const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error || '同步失败'); for (const message of (data.messages || [])) { if (message.message_seq > state.lastSeq) state.lastSeq = message.message_seq; if (message.from_uid !== state.uid) renderAgentMessage(base64Decode(message.payload)); }
|
||||
} catch { if (state.active) setPresence('offline','消息同步中断,正在重试'); }
|
||||
await sleep(1500);
|
||||
} polling = false;
|
||||
}
|
||||
|
||||
function addMessage(text, role) {
|
||||
const row = document.createElement('article'); row.className = `message-row ${role}`;
|
||||
if (role === 'agent') { const avatar = document.createElement('div'); avatar.className = 'message-avatar'; avatar.textContent = '✦'; row.append(avatar); }
|
||||
const bubble = document.createElement('div'); bubble.className = 'message';
|
||||
if (role === 'agent') appendMarkdown(bubble, text); else bubble.textContent = text;
|
||||
const meta = document.createElement('div'); meta.className = 'message-meta'; meta.textContent = timeLabel(); bubble.append(meta); row.append(bubble); document.getElementById('messages').append(row); scrollToLatest(); return row;
|
||||
}
|
||||
function setDelivery(row, text, failed = false) { const meta = row.querySelector('.message-meta'); meta.textContent = `${timeLabel()} · ${text}`; meta.classList.toggle('delivery', true); meta.classList.toggle('failed', failed); }
|
||||
function addSystemMsg(text) { const item = document.createElement('div'); item.className = 'system-message'; item.textContent = text; document.getElementById('messages').append(item); scrollToLatest(); }
|
||||
function showThinking(detail = '') { if (thinkingEl) thinkingEl.remove(); const row = document.createElement('article'); row.className = 'message-row agent'; row.id = 'thinkingIndicator'; const avatar = document.createElement('div'); avatar.className = 'message-avatar'; avatar.textContent = '✦'; const bubble = document.createElement('div'); bubble.className = 'message thinking'; bubble.innerHTML = '<span class="thinking-dots"><i></i><i></i><i></i></span><span></span>'; bubble.querySelector('span:last-child').textContent = detail || '正在思考'; row.append(avatar,bubble); document.getElementById('messages').append(row); thinkingEl = row; setPresence('thinking','正在思考…'); scrollToLatest(); }
|
||||
function hideThinking() { if (thinkingEl) thinkingEl.remove(); thinkingEl = null; }
|
||||
|
||||
function appendMarkdown(container, source) {
|
||||
const markdown = document.createElement('div'); markdown.className = 'markdown';
|
||||
const parsed = window.marked ? marked.parse(String(source), {gfm:true, breaks:true}) : escapeHtml(source).replace(/\n/g,'<br>');
|
||||
markdown.innerHTML = window.DOMPurify ? DOMPurify.sanitize(parsed, {USE_PROFILES:{html:true}, FORBID_TAGS:['style','form','input','button']}) : parsed;
|
||||
markdown.querySelectorAll('a').forEach(link => { link.target = '_blank'; link.rel = 'noopener noreferrer'; });
|
||||
markdown.querySelectorAll('pre').forEach(pre => {
|
||||
const code = pre.querySelector('code'); const language = [...(code?.classList || [])].find(name => name.startsWith('language-'))?.slice(9) || '代码'; const disclosure = document.createElement('details'); disclosure.className = 'code-block'; disclosure.open = true; const summary = document.createElement('summary'); summary.textContent = language; const copy = document.createElement('button'); copy.className = 'copy-code'; copy.type = 'button'; copy.textContent = '复制'; copy.onclick = async event => { event.preventDefault(); try { await navigator.clipboard.writeText(code?.textContent || ''); copy.textContent = '已复制'; setTimeout(() => copy.textContent = '复制', 1400); } catch { copy.textContent = '请手动复制'; } }; summary.append(copy); pre.replaceWith(disclosure); disclosure.append(summary,pre);
|
||||
});
|
||||
if (String(source).length > 1200 || markdown.querySelectorAll('pre,table,blockquote').length > 2) { const details = document.createElement('details'); details.className = 'long-reply'; const summary = document.createElement('summary'); summary.textContent = '展开完整回复'; details.append(summary,markdown); container.append(details); } else container.append(markdown);
|
||||
}
|
||||
|
||||
// UI Surfaces are intentionally not normal DOM content. Agent-supplied
|
||||
// HTML/CSS/JS runs only in a unique-origin sandbox without network access;
|
||||
// it can emit validated user events but cannot read this page, its tokens,
|
||||
// or invoke a client capability by itself.
|
||||
function builtInSurface(requested) {
|
||||
if (!requested || requested.id !== 'com.lineup.task.dashboard') return null;
|
||||
return {
|
||||
id: 'com.lineup.task.dashboard', name: '任务执行面板', version: '1.0.0',
|
||||
html: '<main class="task"><header><div><p class="eyebrow">LINEUP TASK</p><h1 id="title">任务处理中</h1></div><span id="phase" class="phase">运行中</span></header><p id="summary" class="summary"></p><div class="progress"><i id="progress"></i></div><p id="percent" class="percent"></p><ol id="steps" class="steps"></ol><footer><button id="cancel" class="cancel" type="button">取消任务</button></footer></main>',
|
||||
css: '.task{padding:18px;font-family:ui-sans-serif,system-ui,sans-serif;color:#20312d}.task header{display:flex;justify-content:space-between;gap:12px;align-items:flex-start}.eyebrow{margin:0 0 3px;color:#668078;font-size:10px;font-weight:700;letter-spacing:.12em}.task h1{margin:0;font-size:19px;line-height:1.25}.phase{padding:4px 8px;border-radius:99px;background:#e2eee9;color:#35675a;font-size:12px;white-space:nowrap}.summary{margin:12px 0;color:#5e6965;font-size:14px;line-height:1.55}.progress{height:7px;overflow:hidden;border-radius:8px;background:#e4ece8}.progress i{display:block;height:100%;border-radius:inherit;background:#4b8474;transition:width .25s}.percent{margin:6px 0 13px;color:#60716b;font-size:12px;text-align:right}.steps{display:grid;gap:8px;margin:0;padding:0;list-style:none}.steps li{display:flex;gap:9px;align-items:flex-start;padding:9px;border-radius:9px;background:#f4f7f5;font-size:13px}.steps b{display:block;color:#30423d}.steps small{display:block;margin-top:2px;color:#718078}.mark{width:18px;height:18px;border-radius:50%;text-align:center;line-height:18px;background:#d9e8e1;color:#397363;font-size:11px}.mark.running{background:#f8e9ca;color:#9a6824}.mark.failed{background:#f6d8d6;color:#a64743}.task footer{display:flex;justify-content:flex-end;margin-top:15px}.cancel{padding:7px 11px;border:1px solid #cf827e;border-radius:8px;background:#fff;color:#a74743;font-size:13px}',
|
||||
js: "const symbols={completed:'✓',running:'…',failed:'!',pending:'○',cancelled:'–'};function renderTask(state){document.querySelector('#title').textContent=state.title||'任务处理中';document.querySelector('#summary').textContent=state.summary||'';const phase=state.phase||'running';document.querySelector('#phase').textContent={running:'运行中',waiting_input:'等待确认',completed:'已完成',failed:'失败',cancelled:'已取消'}[phase]||phase;const percent=Math.max(0,Math.min(100,Number(state.percent)||0));document.querySelector('#progress').style.width=percent+'%';document.querySelector('#percent').textContent=percent+'%';const steps=document.querySelector('#steps');steps.replaceChildren(...(Array.isArray(state.steps)?state.steps:[]).map(step=>{const li=document.createElement('li'),mark=document.createElement('span'),body=document.createElement('div'),label=document.createElement('b'),detail=document.createElement('small'),status=step.status||'pending';mark.className='mark '+status;mark.textContent=symbols[status]||'○';label.textContent=step.title||'未命名步骤';detail.textContent=step.detail||'';body.append(label,detail);li.append(mark,body);return li;}));document.querySelector('#cancel').hidden=state.cancellable===false||['completed','failed','cancelled'].includes(phase);}LineUpSurface.onState(renderTask);document.querySelector('#cancel').onclick=()=>LineUpSurface.event('cancel',{});"
|
||||
};
|
||||
}
|
||||
function surfaceDocument(app) {
|
||||
const title = escapeHtml(String(app.name || app.id || 'LineUp Surface'));
|
||||
const html = typeof app.html === 'string' ? app.html : '<main>此界面没有内容。</main>';
|
||||
const css = typeof app.css === 'string' ? app.css : '';
|
||||
const javascript = typeof app.js === 'string' ? app.js : '';
|
||||
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; connect-src 'none'; img-src data: blob:; style-src 'unsafe-inline'; script-src 'unsafe-inline'"><title>${title}</title><style>html,body{margin:0;min-height:100%;font-family:system-ui,-apple-system,sans-serif;color:#25231f;background:#fff}button,input,select,textarea{font:inherit}${css}</style></head><body>${html}<script>const LineUpSurface={event:(event,data={})=>parent.postMessage({namespace:'lineup.surface.v1',type:'event',event,data},'*'),ready:()=>parent.postMessage({namespace:'lineup.surface.v1',type:'ready'},'*'),resize:(height)=>parent.postMessage({namespace:'lineup.surface.v1',type:'resize',height},'*'),onState:(handler)=>addEventListener('message',e=>{const d=e.data||{};if(d.namespace==='lineup.surface.v1'&&d.type==='state')handler(d.state||{})})};${javascript};LineUpSurface.ready();<\/script></body></html>`;
|
||||
}
|
||||
function isPlainObject(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); }
|
||||
function jsonSize(value) { try { return new TextEncoder().encode(JSON.stringify(value)).length; } catch { return Number.POSITIVE_INFINITY; } }
|
||||
async function sendProtocol(type, payload, conversationID) {
|
||||
const envelope = { v:1, id:`msg_${crypto.randomUUID ? crypto.randomUUID() : Date.now()}`, type, conversation_id:conversationID || `lineup:${state.channelID}:${state.uid}`, sender:{kind:'human',id:state.uid}, target:{kind:'agent',id:state.agentUID}, timestamp:new Date().toISOString(), payload };
|
||||
const response = await fetch(`${API}/messages/send`, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({from_uid:state.uid, channel_id:state.channelID, channel_type:state.channelType, payload:JSON.stringify(envelope)})});
|
||||
if (!response.ok) { const body = await response.json().catch(() => ({})); throw new Error(body.error || '协议消息发送失败'); }
|
||||
}
|
||||
function renderSurface(envelope) {
|
||||
const payload = envelope.payload || {}, requestedApp = payload.app, app = builtInSurface(requestedApp) || requestedApp, instanceID = String(payload.instance_id || '');
|
||||
if (!instanceID || !/^[A-Za-z0-9._:-]{1,128}$/.test(instanceID) || !isPlainObject(app) || typeof app.id !== 'string' || typeof app.version !== 'string' || jsonSize(app) > MAX_SURFACE_BYTES) { addSystemMsg('Agent 请求的扩展界面无效或超过安全大小限制。'); return; }
|
||||
closeSurface(instanceID, false);
|
||||
const row = document.createElement('article'); row.className = 'message-row agent'; const avatar = document.createElement('div'); avatar.className = 'message-avatar'; avatar.textContent = '✦'; const shell = document.createElement('section'); shell.className = 'surface'; shell.setAttribute('aria-label', `${app.name || app.id} 扩展界面`);
|
||||
const header = document.createElement('div'); header.className = 'surface-header'; const name = document.createElement('strong'); name.textContent = String(app.name || app.id); const close = document.createElement('button'); close.type = 'button'; close.className = 'surface-close'; close.title = '关闭界面'; close.textContent = '×'; close.onclick = () => closeSurface(instanceID, true); header.append(name,close);
|
||||
const initialState = isPlainObject(payload.state) ? payload.state : {}; const frame = document.createElement('iframe'); frame.className = 'surface-frame'; frame.title = String(app.name || app.id); frame.sandbox = 'allow-scripts'; frame.referrerPolicy = 'no-referrer'; frame.srcdoc = surfaceDocument(app); shell.append(header,frame); row.append(avatar,shell); document.getElementById('messages').append(row); surfaces.set(instanceID, {row,frame,conversationID:envelope.conversation_id,state:initialState});
|
||||
frame.addEventListener('load', () => frame.contentWindow?.postMessage({namespace:'lineup.surface.v1',type:'state',state:initialState}, '*')); scrollToLatest();
|
||||
}
|
||||
function patchSurface(envelope) { const payload = envelope.payload || {}, surface = surfaces.get(String(payload.instance_id || '')); if (!surface) return addSystemMsg('收到一个尚未打开的扩展界面更新。'); if (!isPlainObject(payload.state) || jsonSize(payload.state) > 32 * 1024) return; surface.state = payload.state; surface.frame.contentWindow?.postMessage({namespace:'lineup.surface.v1',type:'state',state:surface.state}, '*'); }
|
||||
function closeSurface(instanceID, notifyAgent) { const surface = surfaces.get(instanceID); if (!surface) return; surface.row.remove(); surfaces.delete(instanceID); if (notifyAgent) sendProtocol('lineup.v1.ui.event', {instance_id:instanceID,event:'close',data:{}}, surface.conversationID).catch(error => addSystemMsg(`无法通知 Agent 关闭界面:${error.message}`)); }
|
||||
window.addEventListener('message', event => { const data = event.data; if (!isPlainObject(data) || data.namespace !== 'lineup.surface.v1') return; const entry = [...surfaces.entries()].find(([, surface]) => surface.frame.contentWindow === event.source); if (!entry) return; const [instanceID,surface] = entry;
|
||||
if (data.type === 'ready') { surface.frame.contentWindow?.postMessage({namespace:'lineup.surface.v1',type:'state',state:surface.state}, '*'); return; }
|
||||
if (data.type === 'resize') { const height = Number(data.height); if (Number.isFinite(height)) surface.frame.style.height = `${Math.max(120,Math.min(680,height))}px`; return; }
|
||||
if (data.type !== 'event' || typeof data.event !== 'string' || !/^[a-z][a-z0-9._:-]{0,63}$/i.test(data.event) || (data.data !== undefined && !isPlainObject(data.data)) || jsonSize(data.data || {}) > 16 * 1024) return;
|
||||
sendProtocol('lineup.v1.ui.event', {instance_id:instanceID,event:data.event,data:data.data || {}}, surface.conversationID).catch(error => addSystemMsg(`扩展界面事件未送达:${error.message}`));
|
||||
});
|
||||
function renderAppCall(envelope) { const payload = envelope.payload || {}, callID = String(payload.call_id || ''), capability = String(payload.capability || ''); if (!callID || !capability) return addSystemMsg('收到无效的 App 能力调用。'); const known = new Set(['app.open_url','clipboard.write']); const respond = (status, result = {}, error = '') => sendProtocol('lineup.v1.app.result',{call_id:callID,status,result,error},envelope.conversation_id).catch(err => addSystemMsg(`能力调用结果未送达:${err.message}`)); if (!known.has(capability)) { addSystemMsg(`此客户端不支持 Agent 请求的能力:${capability}`); respond('unsupported',{},'capability_not_registered'); return; }
|
||||
const row = document.createElement('article'); row.className='message-row agent'; const avatar=document.createElement('div'); avatar.className='message-avatar'; avatar.textContent='✦'; const bubble=document.createElement('div'); bubble.className='message'; const card=document.createElement('div'); card.className='card'; const title=document.createElement('div'); title.className='card-title'; title.textContent=`应用请求:${capability}`; const body=document.createElement('div'); body.className='card-body'; body.textContent=String(payload.reason || 'Agent 请求使用一项本机能力。请确认后再执行。'); const actions=document.createElement('div'); actions.className='card-actions'; const allow=document.createElement('button'); allow.textContent='允许'; const reject=document.createElement('button'); reject.textContent='拒绝'; reject.onclick=()=>{row.remove();respond('rejected',{},'user_rejected');}; allow.onclick=async()=>{ try { const args=isPlainObject(payload.arguments)?payload.arguments:{}; if (capability==='app.open_url') { const url=new URL(String(args.url || '')); if (!['https:','http:'].includes(url.protocol)) throw new Error('仅允许 HTTP(S) 链接'); window.open(url.href,'_blank','noopener,noreferrer'); respond('completed',{opened:true}); } else { await navigator.clipboard.writeText(String(args.text || '')); respond('completed',{written:true}); } row.remove(); } catch(error) { respond('failed',{},String(error.message || error)); } }; actions.append(allow,reject); card.append(title,body,actions); bubble.append(card); row.append(avatar,bubble); document.getElementById('messages').append(row); scrollToLatest(); }
|
||||
|
||||
function renderAgentMessage(payload) {
|
||||
if (!itemFactory || !rendererRegistry) { addSystemMsg('交互渲染层尚未就绪,请稍候。'); return; }
|
||||
rendererRegistry.render(itemFactory.decode(payload));
|
||||
}
|
||||
function renderCard(obj) {
|
||||
const row = document.createElement('article'); row.className = 'message-row agent'; const avatar = document.createElement('div'); avatar.className = 'message-avatar'; avatar.textContent = '✦'; const bubble = document.createElement('div'); bubble.className = 'message'; if (obj.text) appendMarkdown(bubble,String(obj.text)); const card = document.createElement('div'); card.className = 'card';
|
||||
const labels = {choice:['🔀 ','请选择'],confirm:['❓ ','请确认'],progress:['⏳ ','处理中'],result:['✓ ','结果'],error:['⚠ ','错误']}; const label = labels[obj.type] || ['','Agent 消息']; const title = document.createElement('div'); title.className = 'card-title'; title.textContent = label[0] + String(obj.title || label[1]); card.append(title);
|
||||
if (obj.type === 'progress') { const bar = document.createElement('div'); bar.className = 'progress-bar'; const fill = document.createElement('div'); fill.className = 'progress-fill'; fill.style.width = `${Math.max(0,Math.min(100,Number(obj.percent) || 0))}%`; bar.append(fill); card.append(bar); const label = document.createElement('div'); label.className = 'card-body'; label.style.marginTop='7px'; label.textContent = String(obj.status || `${obj.percent || 0}%`); card.append(label); }
|
||||
else if (obj.type === 'choice' || obj.type === 'confirm') { if (obj.description) { const body = document.createElement('div'); body.className='card-body'; body.textContent=String(obj.description); card.append(body); } const actions = document.createElement('div'); actions.className='card-actions'; const options = obj.type === 'confirm' ? ['确认','取消'] : (obj.options || []); options.forEach(option => { const button = document.createElement('button'); button.type='button'; button.textContent=String(option); button.onclick=() => sendChoiceResponse(String(option)); actions.append(button); }); card.append(actions); }
|
||||
else if (obj.content) { const body = document.createElement('div'); body.className='card-body'; obj.type === 'result' ? appendMarkdown(body,String(obj.content)) : body.textContent=String(obj.content); card.append(body); }
|
||||
bubble.append(card); const meta = document.createElement('div'); meta.className='message-meta'; meta.textContent=timeLabel(); bubble.append(meta); row.append(avatar,bubble); document.getElementById('messages').append(row); scrollToLatest();
|
||||
}
|
||||
async function sendChoiceResponse(option) { const input = document.getElementById('msgInput'); input.value = option; await sendMsg(); }
|
||||
function installRendererRegistry() {
|
||||
if (!window.LineUp) throw new Error('LineUp Interaction Kernel 未加载');
|
||||
itemFactory = new LineUp.ConversationItemFactory(); rendererRegistry = new LineUp.RendererRegistry();
|
||||
rendererRegistry
|
||||
.register('markdown', item => { hideThinking(); addMessage(item.data.markdown, 'agent'); })
|
||||
.register('agent-status', item => { const {status,detail} = item.data; if (status === 'thinking') showThinking(detail); else if (status === 'idle') { hideThinking(); setPresence('online','在线'); } else { hideThinking(); setPresence(status === 'failed' ? 'offline' : 'online', detail || status || '在线'); } })
|
||||
.register('progress', item => renderCard({type:'progress', ...item.data}))
|
||||
.register('agent-error', item => { hideThinking(); addSystemMsg(`Agent 错误:${item.data.message}`); })
|
||||
.register('legacy-card', item => renderCard(item.data))
|
||||
.register('ui.open', item => renderSurface(item.envelope))
|
||||
.register('ui.patch', item => patchSurface(item.envelope))
|
||||
.register('ui.close', item => closeSurface(String(item.data.instance_id || ''), false))
|
||||
.register('app-call', item => renderAppCall(item.envelope))
|
||||
.register('protocol-fallback', item => addSystemMsg(item.data.reason === 'unsupported_type' ? `当前客户端暂不支持 Agent 内容:${item.data.type}` : '收到无法安全展示的 Agent 内容。'));
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', () => { try { installRendererRegistry(); } catch (error) { showLoginError(`初始化交互层失败:${error.message}`); } });
|
||||
function base64Decode(value) { try { return decodeURIComponent(Array.from(atob(value), c => `%${(`00${c.charCodeAt(0).toString(16)}`).slice(-2)}`).join('')); } catch { try { return atob(value); } catch { return value; } } }
|
||||
function escapeHtml(value) { return String(value).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); } function sleep(ms) { return new Promise(resolve => setTimeout(resolve,ms)); }
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* 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 };
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { ConversationItemFactory, RendererRegistry } = require('./interaction-kernel.js');
|
||||
|
||||
test('decodes plain agent output as a markdown conversation item', () => {
|
||||
const item = new ConversationItemFactory().decode('# 标题\n\n- 一项');
|
||||
assert.equal(item.kind, 'markdown');
|
||||
assert.equal(item.data.markdown, '# 标题\n\n- 一项');
|
||||
});
|
||||
|
||||
test('decodes a v1 surface lifecycle message without treating it as a card', () => {
|
||||
const item = new ConversationItemFactory().decode(JSON.stringify({
|
||||
v: 1, type: 'lineup.v1.ui.open', conversation_id: 'conv-1',
|
||||
payload: { instance_id: 'task.01', app: { id: 'com.lineup.task', version: '1.0.0' }, state: { phase: 'running' } },
|
||||
}));
|
||||
assert.equal(item.kind, 'ui.open');
|
||||
assert.equal(item.data.instance_id, 'task.01');
|
||||
});
|
||||
|
||||
test('uses a safe fallback for an unsupported protocol message', () => {
|
||||
const item = new ConversationItemFactory().decode(JSON.stringify({ v: 1, type: 'lineup.v1.secret.execute', payload: {} }));
|
||||
assert.equal(item.kind, 'protocol-fallback');
|
||||
assert.equal(item.data.reason, 'unsupported_type');
|
||||
});
|
||||
|
||||
test('routes only validated conversation items through registered renderers', () => {
|
||||
const registry = new RendererRegistry().register('markdown', item => item.data.markdown).register('protocol-fallback', () => 'fallback');
|
||||
assert.equal(registry.render(new ConversationItemFactory().decode('hello')), 'hello');
|
||||
});
|
||||
Reference in New Issue
Block a user