Each task has a lock in its header (task["privacy"]); the Gates window sets the default for new tasks, and turning the default off pins the tasks it covered. mcp_server hands every tool result to ai_privacy.guard last, after run pictures and the user's messages were added, so a private task's image content never leaves; a call tied to no task is private while the default is on or a private task is unfinished. The card in the AI app shows no pictures for it, begin_task tells the AI, and turning it on or off is a user step and a decision. A test calls every tool with a picture in its result and checks none gets out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
216 lines
10 KiB
HTML
216 lines
10 KiB
HTML
<!doctype html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Anomalous</title>
|
|
<!-- Anomalous's card in the AI app's conversation (api/mcp_apps.py). Everything the AI or the task
|
|
says is drawn with textContent; pictures are inline thumbnails; nothing is loaded from elsewhere. -->
|
|
<style>
|
|
:root { --bg: #ffffff; --soft: #f4f4f6; --line: #e2e2e8; --text: #1d1d22; --dim: #6b6b75; --accent: #7c5cdb; --ok: #2f9e6e; --warn: #c98a04; --bad: #d64545; }
|
|
:root.dark { --bg: #1d1d22; --soft: #26262d; --line: #36363f; --text: #ececf1; --dim: #9a9aa6; --accent: #b39eef; --ok: #52b98d; --warn: #fbbf24; --bad: #f87171; }
|
|
* { box-sizing: border-box; }
|
|
body { margin: 0; padding: 14px 16px; background: var(--bg); color: var(--text); font: 13.5px/1.55 system-ui, -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif; }
|
|
.head { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
|
|
.brand { color: var(--accent); font-weight: 600; font-size: 12px; }
|
|
.title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; }
|
|
.state { padding: 1px 8px; border-radius: 999px; background: var(--soft); color: var(--dim); font-size: 11.5px; }
|
|
.state.live { color: var(--ok); }
|
|
.steps { display: flex; flex-direction: column; gap: 4px; margin: 0 0 10px; padding: 0; list-style: none; }
|
|
.steps li { display: flex; gap: 8px; color: var(--dim); font-size: 12.5px; }
|
|
.steps li::before { content: "•"; color: var(--ok); }
|
|
.steps li.running::before { color: var(--accent); }
|
|
.steps li.asking::before, .steps li.allowed::before { color: var(--warn); }
|
|
.steps li.bad::before { color: var(--bad); }
|
|
.steps li span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
.pictures { display: grid; grid-template-columns: repeat(auto-fill, minmax(96px, 1fr)); gap: 6px; margin-bottom: 10px; }
|
|
.pictures img { width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: 8px; background: var(--soft); }
|
|
.box { margin-bottom: 10px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 10px; background: var(--soft); }
|
|
.box.warn { border-color: var(--warn); }
|
|
.question { margin-bottom: 8px; font-weight: 600; }
|
|
.options { display: flex; flex-wrap: wrap; gap: 6px; }
|
|
button { padding: 6px 12px; border: 1px solid var(--line); border-radius: 8px; background: var(--bg); color: var(--text); font: inherit; font-size: 12.5px; cursor: pointer; }
|
|
button:hover:not(:disabled) { border-color: var(--accent); }
|
|
button:disabled { opacity: 0.5; cursor: default; }
|
|
button.primary { border-color: var(--accent); background: var(--accent); color: #fff; }
|
|
input { flex: 1; min-width: 0; padding: 6px 10px; border: 1px solid var(--line); border-radius: 8px; background: var(--bg); color: var(--text); font: inherit; }
|
|
.row { display: flex; gap: 6px; }
|
|
.foot { display: flex; align-items: center; justify-content: space-between; gap: 8px; color: var(--dim); font-size: 12px; }
|
|
.link { padding: 0; border: 0; background: none; color: var(--accent); }
|
|
.note { margin-bottom: 8px; color: var(--dim); font-size: 12px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="card"><div class="note" id="loading"></div></div>
|
|
<script>
|
|
(() => {
|
|
let zh = /^zh/i.test(navigator.language || '');
|
|
const words = {
|
|
loading: ['Reading the task from Anomalous…', '正在从 AMB 读取任务…'],
|
|
live: ['Working', '进行中'], done: ['Done', '已完成'],
|
|
asking: ['The AI waits for your OK in Anomalous', 'AI 在等你在 AMB 里点头'],
|
|
askingHow: ['Allowing is only done in Anomalous\'s AI workbench, so the AI can never allow itself.', '为了安全,允许只能在 AMB 的 AI 工作台里点,AI 自己没法替你点。'],
|
|
openAmb: ['Open in Anomalous', '在 AMB 里看'], send: ['Send', '发送'], refresh: ['Refresh', '刷新'],
|
|
picked: ['I picked in the card: ', '我在卡片里选了:'], typed: ['My answer in the card: ', '我在卡片里回答:'],
|
|
youAnswered: ['You answered: ', '你回答了:'], none: ['No task to show yet.', '还没有任务可看。'],
|
|
private: ['Privacy mode: the pictures are only shown in Anomalous.', '隐私模式:图只在 AMB 里看。'],
|
|
};
|
|
const t = key => words[key][zh ? 1 : 0];
|
|
const card = document.getElementById('card');
|
|
document.getElementById('loading').textContent = t('loading');
|
|
|
|
// JSON-RPC with the AI app (MCP Apps): requests wait for their answer by id.
|
|
let nextId = 1;
|
|
const waiting = new Map();
|
|
const send = (message) => window.parent.postMessage({ jsonrpc: '2.0', ...message }, '*');
|
|
const request = (method, params) => new Promise((resolve, reject) => {
|
|
const id = nextId++;
|
|
waiting.set(id, { resolve, reject });
|
|
send({ id, method, params });
|
|
});
|
|
const notify = (method, params) => send({ method, params });
|
|
const resize = () => notify('ui/notifications/size-changed', { width: document.body.scrollWidth, height: document.body.scrollHeight });
|
|
|
|
let target = {}; // { panel } or { task }: what the card shows
|
|
let openUrl = '';
|
|
let polls = 0;
|
|
let timer = null;
|
|
|
|
function el(tag, className, text) {
|
|
const node = document.createElement(tag);
|
|
if (className) node.className = className;
|
|
if (text !== undefined) node.textContent = String(text);
|
|
return node;
|
|
}
|
|
|
|
async function callTool(name, args) {
|
|
const result = await request('tools/call', { name, arguments: args });
|
|
if (result?.isError) throw new Error(result.content?.[0]?.text || 'error');
|
|
return result?.structuredContent || {};
|
|
}
|
|
|
|
async function answer(question, choice, text, label) {
|
|
card.querySelectorAll('button, input').forEach((node) => { node.disabled = true; });
|
|
try {
|
|
await callTool('app_answer', { panel: question.panel, block: question.block, ...(choice ? { choice: [choice] } : { text }) });
|
|
// Also said in the conversation, so the AI goes on even when it is not waiting.
|
|
await request('ui/message', { role: 'user', content: [{ type: 'text', text: (choice ? t('picked') : t('typed')) + label }] }).catch(() => {});
|
|
} catch (error) {
|
|
card.append(el('div', 'note', error.message));
|
|
}
|
|
await load();
|
|
}
|
|
|
|
function questionBox(question) {
|
|
const box = el('div', 'box');
|
|
box.append(el('div', 'question', question.question));
|
|
if (question.type === 'input') {
|
|
const row = el('div', 'row');
|
|
const field = el('input');
|
|
const go = el('button', 'primary', t('send'));
|
|
go.onclick = () => field.value.trim() && answer(question, null, field.value.trim(), field.value.trim());
|
|
field.onkeydown = (event) => { if (event.key === 'Enter') go.click(); };
|
|
row.append(field, go);
|
|
box.append(row);
|
|
} else {
|
|
const options = el('div', 'options');
|
|
for (const option of question.options) {
|
|
const pick = el('button', '', option.label);
|
|
pick.onclick = () => answer(question, option.id, null, option.label);
|
|
options.append(pick);
|
|
}
|
|
box.append(options);
|
|
}
|
|
return box;
|
|
}
|
|
|
|
function draw(view) {
|
|
openUrl = view.open_url || openUrl;
|
|
const head = el('div', 'head');
|
|
head.append(el('span', 'brand', 'Anomalous'), el('span', 'title', view.title || ''),
|
|
el('span', `state${view.ended ? '' : ' live'}`, t(view.ended ? 'done' : 'live')));
|
|
const steps = el('ul', 'steps');
|
|
for (const step of view.steps || []) {
|
|
const line = el('li', `${step.state || ''}${step.ok === false ? ' bad' : ''}`);
|
|
line.append(el('span', '', step.text || step.tool));
|
|
steps.append(line);
|
|
}
|
|
const parts = [head, steps];
|
|
if (view.pictures?.length) {
|
|
const pictures = el('div', 'pictures');
|
|
for (const picture of view.pictures) {
|
|
const image = el('img');
|
|
image.alt = picture.name;
|
|
image.title = picture.name;
|
|
image.src = picture.src; // a data: thumbnail from Anomalous
|
|
image.onload = resize;
|
|
pictures.append(image);
|
|
}
|
|
parts.push(pictures);
|
|
}
|
|
if (view.private) parts.push(el('div', 'note', t('private')));
|
|
for (const item of view.answered || []) parts.push(el('div', 'note', `${t('youAnswered')}${item.question ? `${item.question} ` : ''}${item.answer}`));
|
|
for (const question of view.questions || []) parts.push(questionBox(question));
|
|
if (view.asking?.length) {
|
|
const box = el('div', 'box warn');
|
|
box.append(el('div', 'question', t('asking')), el('div', 'note', t('askingHow')));
|
|
parts.push(box);
|
|
}
|
|
const foot = el('div', 'foot');
|
|
const open = el('button', 'link', t('openAmb'));
|
|
open.onclick = () => openUrl && request('ui/open-link', { url: openUrl }).catch(() => {});
|
|
const again = el('button', 'link', t('refresh'));
|
|
again.onclick = () => { polls = 0; load(); };
|
|
foot.append(open, again);
|
|
parts.push(foot);
|
|
card.replaceChildren(...parts);
|
|
resize();
|
|
// Follows the task for a few minutes while it goes on; Refresh starts again.
|
|
clearTimeout(timer);
|
|
if (!view.ended && polls++ < 36) timer = setTimeout(load, 5000);
|
|
}
|
|
|
|
async function load() {
|
|
try {
|
|
draw(await callTool('app_task_view', target));
|
|
} catch (_) {
|
|
card.replaceChildren(el('div', 'note', t('none')));
|
|
resize();
|
|
}
|
|
}
|
|
|
|
window.addEventListener('message', (event) => {
|
|
if (event.source !== window.parent) return;
|
|
const message = event.data;
|
|
if (!message || message.jsonrpc !== '2.0') return;
|
|
if (message.id !== undefined && waiting.has(message.id)) {
|
|
const { resolve, reject } = waiting.get(message.id);
|
|
waiting.delete(message.id);
|
|
if (message.error) reject(new Error(message.error.message));
|
|
else resolve(message.result);
|
|
return;
|
|
}
|
|
if (message.method === 'ui/notifications/tool-result') {
|
|
const data = message.params?.structuredContent || {};
|
|
target = data.panel ? { panel: String(data.panel) } : data.task ? { task: String(data.task) } : {};
|
|
polls = 0;
|
|
load();
|
|
} else if (message.method === 'ui/notifications/host-context-changed') {
|
|
document.documentElement.classList.toggle('dark', message.params?.theme === 'dark');
|
|
}
|
|
});
|
|
|
|
request('ui/initialize', { appInfo: { name: 'anomalous-task-card', version: '1.0.0' }, appCapabilities: {}, protocolVersion: '2026-01-26' })
|
|
.then((result) => {
|
|
const context = result?.hostContext || {};
|
|
if (context.locale) zh = /^zh/i.test(context.locale);
|
|
document.documentElement.classList.toggle('dark', context.theme === 'dark');
|
|
notify('ui/notifications/initialized', {});
|
|
resize();
|
|
})
|
|
.catch(() => {});
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|