feat: prepare 2.2.0 with UI and execution fixes

Add dark theme, richer Markdown and readable plugin results.
Improve Android input evidence, response integrity and diagnostics.
Unify lifecycle, guidance receipts and evidence sources across clients.
Keep direct action dispatch without an added AI quota approval layer.
Align version metadata at 2.2.0 and bundle Helper 1.2.2.

Validation: 3063 backend tests before the version bump; 44 after.
Frontend: 317 tests and production build passed.
This commit is contained in:
chenmingxuan committed 2026-10-26 00:44:13 +08:00
1 parent 83bcfd54d5
commit fac4ccb760
146 files changed
+5689 -2033

No files matched your search

@@ -71,8 +71,55 @@ describe('markdown-parser.util verification & check lines', () => {
it('renders Chinese report tables and preserves separate lines safely', () => {
const html = renderMarkdownToHtml('## 结论\n第一行\n第二行\n\n| 分类 | 结果 |\n| --- | --- |\n| 网络 | **通过** |');
expect(html).toContain('<h2>结论</h2>');
expect(html).toContain('<div>第一行</div>\n<div>第二行</div>');
expect(html).toContain('<p>第一行<br>第二行</p>');
expect(html).toContain('<th>分类</th>');
expect(html).toContain('<td><strong>通过</strong></td>');
});
it('keeps table alignment, escaped pipes and inline code intact', () => {
const view = document.createElement('div');
view.innerHTML = renderMarkdownToHtml('| 项目 | 值 |\n| :--- | ---: |\n| A\\|B | `**原样**` |');
expect(view.querySelectorAll('th').length).toBe(2);
expect(view.querySelector('td')?.textContent).toBe('A|B');
expect(view.querySelector('td.md-align-right code')?.textContent).toBe('**原样**');
expect(view.querySelector('code strong')).toBeNull();
});
it('renders nested lists, task states, quotes and reference links', () => {
const view = document.createElement('div');
view.innerHTML = renderMarkdownToHtml('#### 计划\n- [x] 完成\n - 子项\n- [/] 执行中\n- [ ] 待办\n- assert@end: **最终断言**\n\n> 引用\n>\n> 3. [文档][docs]\n\n[docs]: https://example.com/a?q=1&x=2');
expect(view.querySelector('h4')?.textContent).toBe('计划');
expect(view.querySelector('li ul li')?.textContent).toBe('子项');
expect(view.querySelectorAll('.md-task-marker').length).toBe(3);
expect(view.querySelector('.md-task-item.progress')?.textContent).toContain('执行中');
expect(view.querySelector('.verify-badge.assert')?.textContent).toBe('assert@end');
expect(view.querySelector('blockquote ol')?.getAttribute('start')).toBe('3');
expect(view.querySelector('a')?.getAttribute('href')).toBe('https://example.com/a?q=1&x=2');
});
it('preserves code whitespace, markup and unfinished streaming fences', () => {
const view = document.createElement('div');
view.innerHTML = renderMarkdownToHtml('~~~xml\r\n<plan>**原样**</plan>\r\n\r\n\r\n code\r\n');
expect(view.querySelector('pre code')?.textContent).toBe('<plan>**原样**</plan>\n\n\n code');
expect(view.querySelector('plan,strong')).toBeNull();
});
it('escapes raw and inline HTML and rejects active link and image protocols', () => {
const view = document.createElement('div');
view.innerHTML = renderMarkdownToHtml('<script>alert(1)</script>\n\n`<img src=x onerror=alert(1)>`\n\n[危险](javascript:alert%281%29) ![危险](data:text/html,attack)\n\n[相对链接](/reports/test)');
expect(view.querySelector('script,img')).toBeNull();
expect(view.querySelector('code')?.textContent).toBe('<img src=x onerror=alert(1)>');
expect(view.querySelectorAll('a').length).toBe(1);
expect(view.querySelector('a')?.getAttribute('href')).toBe('/reports/test');
expect(view.querySelector('a')?.getAttribute('rel')).toBe('noopener noreferrer');
});
it('renders Markdown inside internal wrappers while retaining those tags inside code', () => {
const view = document.createElement('div');
view.innerHTML = renderMarkdownToHtml('<thought>\n## 阶段\n\n- **核对**\n\n```xml\n<plan>字面内容</plan>\n```\n</thought>');
expect(view.querySelector('h2')?.textContent).toBe('阶段');
expect(view.querySelector('li strong')?.textContent).toBe('核对');
expect(view.querySelector('pre code')?.textContent).toBe('<plan>字面内容</plan>');
expect(view.querySelector('thought,plan')).toBeNull();
});
});
@@ -16,6 +16,7 @@
import { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote } from '../core/models/markdown.model';
import { CheckerResult } from '../core/models/stream.model';
import { Marked, Renderer } from 'marked';
/**
* Split text into bold, code, and plain segments
@@ -208,196 +209,89 @@ export function extractCheckerResult(text: string): CheckerResult | null {
return null;
}
/**
* Helper to format inline markdown segments (bold, italic, code, strikethrough)
*/
function formatInlineMarkdown(text: string): string {
if (!text) return '';
return text
// Inline code first (so asterisks inside code aren't formatted)
.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>')
// Bold + Italic
.replace(/\*\*\*([^*]+)\*\*\*/g, '<strong><em>$1</em></strong>')
.replace(/___([^_]+)___/g, '<strong><em>$1</em></strong>')
// Bold
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/__([^_]+)__/g, '<strong>$1</strong>')
// Italic
.replace(/\*([^*]+)\*/g, '<em>$1</em>')
.replace(/\b_([^_]+)_\b/g, '<em>$1</em>')
// Strikethrough
.replace(/~~([^~]+)~~/g, '<del>$1</del>');
function escapeHtml(text: string): string {
return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
/**
* Convert basic markdown text to safe HTML for agent logs/thinking
*/
function safeMarkdownUrl(href: string, image = false): boolean {
try {
const url = new URL(href, 'https://artemis.invalid/');
return ['http:', 'https:', ...(image ? [] : ['mailto:'])].includes(url.protocol);
} catch {
return false;
}
}
const baseRenderer = new Renderer();
const agentTag = /^<\/?(?:thought|short_term_memory|strong_term_memory|reasoning|plan|task_plan|call_tool)\b[^\n>]*>/i;
function agentTagToken(src: string) {
const match = src.match(agentTag);
return match ? { type: 'artemisTag', raw: match[0] } : undefined;
}
// Use one scoped parser for notes, plans, reports and streaming text. HTML from
// models stays literal; Angular also sanitizes every innerHTML binding.
const markdown = new Marked({
gfm: true,
breaks: true,
async: false,
extensions: [
{ name: 'artemisTag', level: 'block', tokenizer: agentTagToken, renderer: () => '' },
{
name: 'artemisTag', level: 'inline', tokenizer: agentTagToken,
start: src => src.search(/<\/?(?:thought|short_term_memory|strong_term_memory|reasoning|plan|task_plan|call_tool)\b/i)
}
],
renderer: {
html({ text }) { return escapeHtml(text); },
code({ text, lang }) {
const language = (lang || '').split(/\s/)[0].replace(/[^a-zA-Z0-9_-]/g, '');
return `<pre class="code-block"><code class="language-${language}">${escapeHtml(text)}</code></pre>\n`;
},
codespan({ text }) { return `<code class="inline-code">${escapeHtml(text)}</code>`; },
link(token) {
if (!safeMarkdownUrl(token.href)) return this.parser.parseInline(token.tokens);
return `<a href="${escapeHtml(token.href)}"${token.title ? ` title="${escapeHtml(token.title)}"` : ''} target="_blank" rel="noopener noreferrer">${this.parser.parseInline(token.tokens)}</a>`;
},
image(token) {
if (!safeMarkdownUrl(token.href, true)) return escapeHtml(token.text);
return `<img src="${escapeHtml(token.href)}" alt="${escapeHtml(token.text)}"${token.title ? ` title="${escapeHtml(token.title)}"` : ''} loading="lazy" />`;
},
table(token) {
return `<div class="md-table-scroll">${baseRenderer.table.call(this, token)}</div>`;
},
tablecell(token) {
const tag = token.header ? 'th' : 'td';
const align = token.align ? ` class="md-align-${token.align}"` : '';
return `<${tag}${align}>${this.parser.parseInline(token.tokens)}</${tag}>`;
},
checkbox({ checked }) {
return `<span class="md-task-marker ${checked ? 'checked' : 'unchecked'}" role="img" aria-label="${checked ? '已完成' : '未完成'}">${checked ? '☑' : '☐'}</span> `;
},
listitem(token) {
let html = baseRenderer.listitem.call(this, token);
if (token.task) return html.replace('<li>', `<li class="md-task-item ${token.checked ? 'checked' : 'unchecked'}">`);
if (/^\[\/\]\s/.test(token.text)) {
return html.replace(/^<li>(<p>)?\[\/\]\s*/, '<li class="md-task-item progress">$1<span class="md-task-marker progress" role="img" aria-label="进行中">◧</span> ');
}
const check = token.text.match(/^(verify|assert)(@end)?\s*:\s*/i);
if (check) {
const kind = check[1].toLowerCase();
html = html.replace(/^<li>/, '<li class="md-verify-item">');
return html.replace(/^(<li[^>]*>(?:<p>)?)(verify|assert)(@end)?\s*:\s*/i,
`$1<span class="verify-badge${kind === 'assert' ? ' assert' : ''}">${kind}${check[2] || ''}</span>`);
}
if (/^finding\s*:/i.test(token.text)) {
return html.replace(/^<li>(<p>)?finding\s*:\s*/i,
'<li class="md-finding-item">$1<span class="finding-badge">finding</span>');
}
return html;
}
}
});
/** Render CommonMark/GFM while preserving ARTEMIS plan status markers. */
export function renderMarkdownToHtml(text: string): string {
if (!text) return '';
// Clean up internal agent XML-like tags (e.g. <thought>, <short_term_memory>, etc.)
const cleanedText = text
.replace(/<\/?(thought|short_term_memory|strong_term_memory|reasoning|plan|task_plan|call_tool)[^\n>]*>?/gi, '')
.replace(/\r\n/g, '\n')
.replace(/\r/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
// Escape HTML first to prevent XSS
const escaped = cleanedText
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
const rawLines = escaped.split('\n');
const result: string[] = [];
let currentListType: 'ul' | 'ol' | null = null;
let inCodeBlock = false;
let codeBlockLang = '';
let codeBlockLines: string[] = [];
const closeList = () => {
if (currentListType) {
result.push(`</${currentListType}>`);
currentListType = null;
}
};
for (let i = 0; i < rawLines.length; i++) {
const rawLine = rawLines[i];
const trimmed = rawLine.trim();
// 1. Code blocks: ```lang ... ```
if (trimmed.startsWith('```')) {
if (inCodeBlock) {
// End code block
const codeContent = codeBlockLines.join('\n');
result.push(`<pre class="code-block"><code class="lang-${codeBlockLang.replace(/[^a-zA-Z0-9_-]/g, '')}">${codeContent}</code></pre>`);
inCodeBlock = false;
codeBlockLines = [];
codeBlockLang = '';
} else {
closeList();
inCodeBlock = true;
codeBlockLang = trimmed.substring(3).trim();
codeBlockLines = [];
}
continue;
}
if (inCodeBlock) {
codeBlockLines.push(rawLine);
continue;
}
// Markdown result tables need real cells to remain readable in reports.
if (trimmed.includes('|') && i + 1 < rawLines.length
&& /^\s*\|?\s*:?-{3,}:?(?:\s*\|\s*:?-{3,}:?)*\s*\|?\s*$/.test(rawLines[i + 1])) {
closeList();
const cells = (line: string) => line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|');
const headings = cells(rawLine);
result.push('<div class="md-table-scroll"><table><thead><tr>'
+ headings.map(cell => `<th>${formatInlineMarkdown(cell.trim())}</th>`).join('')
+ '</tr></thead><tbody>');
i += 2;
while (i < rawLines.length && rawLines[i].trim() && rawLines[i].includes('|')) {
const row = cells(rawLines[i]);
result.push('<tr>' + headings.map((_, index) => `<td>${formatInlineMarkdown((row[index] || '').trim())}</td>`).join('') + '</tr>');
i++;
}
result.push('</tbody></table></div>');
i--;
continue;
}
// 2. Headings: # to ######
const headingMatch = trimmed.match(/^(#{1,6})\s+(.*)$/);
if (headingMatch) {
closeList();
const level = headingMatch[1].length;
const headingContent = formatInlineMarkdown(headingMatch[2]);
result.push(`<h${level}>${headingContent}</h${level}>`);
continue;
}
// 3. Horizontal Rule: ---, ***, ___
if (/^(\-{3,}|\*{3,}|_{3,})$/.test(trimmed)) {
closeList();
result.push('<hr class="md-hr" />');
continue;
}
// 4. Blockquotes: > quote
if (trimmed.startsWith('&gt; ') || trimmed.startsWith('&gt;')) {
closeList();
const quoteContent = formatInlineMarkdown(trimmed.replace(/^&gt;\s?/, ''));
result.push(`<blockquote>${quoteContent}</blockquote>`);
continue;
}
// 5. Unordered List: - item or * item
const ulMatch = rawLine.match(/^(\s*)([\*\-])\s+(.*)$/);
if (ulMatch) {
if (currentListType !== 'ul') {
closeList();
currentListType = 'ul';
result.push('<ul>');
}
const spaces = ulMatch[1].length;
const subLevel = Math.max(0, Math.floor((spaces - 2) / 2));
const indent = subLevel * 12;
const style = indent ? ` style="margin-left: ${indent}px"` : '';
const itemText = ulMatch[3];
const checkMatch = itemText.match(/^(verify|assert)(@end)?\s*:\s*(.*)$/i);
const findingMatch = itemText.match(/^finding\s*:\s*(.*)$/i);
if (checkMatch) {
const kind = checkMatch[1].toLowerCase();
const atEnd = checkMatch[2] ? '@end' : '';
const assertClass = kind === 'assert' ? ' assert' : '';
const itemContent = formatInlineMarkdown(checkMatch[3].trim());
result.push(`<li${style} class="md-verify-item"><span class="verify-badge${assertClass}">${kind}${atEnd}</span>${itemContent}</li>`);
} else if (findingMatch) {
const itemContent = formatInlineMarkdown(findingMatch[1].trim());
result.push(`<li${style} class="md-finding-item"><span class="finding-badge">finding</span>${itemContent}</li>`);
} else {
const itemContent = formatInlineMarkdown(itemText);
result.push(`<li${style}>${itemContent}</li>`);
}
continue;
}
// 6. Ordered List: 1. item
const olMatch = rawLine.match(/^(\s*)(\d+)\.\s+(.*)$/);
if (olMatch) {
if (currentListType !== 'ol') {
closeList();
currentListType = 'ol';
result.push('<ol>');
}
const spaces = olMatch[1].length;
const subLevel = Math.max(0, Math.floor((spaces - 2) / 2));
const indent = subLevel * 12;
const style = indent ? ` style="margin-left: ${indent}px"` : '';
const itemContent = formatInlineMarkdown(olMatch[3]);
result.push(`<li${style}>${itemContent}</li>`);
continue;
}
// 7. Regular paragraph / empty line
closeList();
if (trimmed === '') {
result.push('<div class="md-para-gap"></div>');
} else {
const lineContent = formatInlineMarkdown(rawLine);
result.push(`<div>${lineContent}</div>`);
}
}
if (inCodeBlock) {
const codeContent = codeBlockLines.join('\n');
result.push(`<pre class="code-block"><code class="lang-${codeBlockLang.replace(/[^a-zA-Z0-9_-]/g, '')}">${codeContent}</code></pre>`);
}
closeList();
return result.join('\n');
return markdown.parse(text.replace(/\r\n?/g, '\n'), { async: false });
}
@@ -68,4 +68,37 @@ describe('plugin evidence timeline cards', () => {
expect(getPluginToolView(tool)?.status).toBe('已执行');
expect(getPluginToolView(tool)?.result).toBe('已点击');
});
it('presents CUA window facts without modifying either raw result channel', () => {
const tree = '- Window "Demo"\n - [0] Button "Save"';
const raw = { execution: 'completed', content: [{ type: 'text', text: tree }],
structured_content: { window_title: 'Demo', pid: 12, window_id: 34,
returned_element_count: 0, total_element_count: 20, screenshot_width: 1280,
screenshot_height: 720, snapshot_id: 's00000001', truncated: true, elements_complete: false,
tree_markdown: tree, capture_focus: { target_foreground_after: false } } };
const tool = { name: 'plug_abcdef1234_get_window_state', payload: {
plugin_id: 'cua-driver', effect: 'read', observation: true, result: JSON.stringify(raw) } };
const original = JSON.stringify(tool);
const view = getPluginToolView(tool)!;
expect(view.title).toBe('观察电脑窗口');
expect(view.facts).toContain({ key: '窗口', value: 'Demo' });
expect(view.facts).toContain({ key: '控件', value: '0 / 20' });
expect(view.facts).toContain({ key: '截图', value: '1280 × 720' });
expect(view.facts).toContain({ key: '控件树完整性', value: '未声明完整' });
expect(view.facts).toContain({ key: '采集后焦点', value: '目标窗口不在前台' });
expect(JSON.parse(view.rawResult)).toEqual(raw);
expect(view.result).toBe(tree);
expect(JSON.stringify(tool)).toBe(original);
});
it('shows the actual post-action observation and preserves plain legacy receipts', () => {
const view = getPluginToolView({ name: 'plug_abcdef1234_press_key', payload: {
plugin_id: 'cua-driver', result: { structured_content: {
observation: { window_title: 'After key', window_id: 56 } } } } })!;
expect(view.facts).toContain({ key: '窗口', value: 'After key' });
const legacy = getPluginToolView({ action: 'plug_abcdef1234_get_window_state',
result: '来源:cua-driver\n窗口已采集' })!;
expect(legacy.result).toBe('来源:cua-driver\n窗口已采集');
expect(legacy.rawResult).toBe(legacy.result);
});
});
@@ -5,6 +5,8 @@ export interface PluginToolView {
result: string;
status: string;
args: Array<{ key: string; value: string }>;
facts: Array<{ key: string; value: string }>;
rawResult: string;
}
function objectValue(value: any): Record<string, any> {
@@ -18,7 +20,8 @@ function objectValue(value: any): Record<string, any> {
export function getPluginToolView(tool: any): PluginToolView | null {
if (!tool || typeof tool !== 'object' || tool.type === 'llm_call') return null;
const payload = objectValue(tool.payload);
const result = objectValue(payload['result'] ?? tool.result);
const rawResult = payload['result'] ?? tool.result;
const result = objectValue(rawResult);
const pluginId = payload['plugin_id'] || result['plugin_id'] || tool.plugin_id;
if (!pluginId && !/^plug_[a-f0-9]{10}_/.test(tool.name || tool.action || '')) {
return null;
@@ -29,7 +32,8 @@ export function getPluginToolView(tool: any): PluginToolView | null {
['possible', 'confirmed'].includes(result['side_effect']) ? 'write' : '');
const auxiliary = effect !== 'write';
const titles: Record<string, string> = {
get_app_state: '观察电脑窗口', list_apps: '列出电脑应用',
get_app_state: '观察电脑窗口', get_window_state: '观察电脑窗口',
list_apps: '列出电脑应用', list_windows: '列出电脑窗口',
click: '点击', double_click: '双击', drag: '拖动', scroll: '滚动',
type_text: '输入文本', press_key: '按键', screenshot: '截取屏幕',
save_screenshot: '保存截图', launch_app: '打开应用'
@@ -37,6 +41,30 @@ export function getPluginToolView(tool: any): PluginToolView | null {
const content = Array.isArray(result['content']) ? result['content'] : [];
const text = content.filter((item: any) => item?.type === 'text' && typeof item.text === 'string')
.map((item: any) => item.text).join('\n');
const structured = objectValue(result['structured_content'] ?? result['structuredContent']);
const observation = Object.keys(objectValue(structured['observation'])).length
? objectValue(structured['observation']) : structured;
const facts: PluginToolView['facts'] = [];
const addFact = (key: string, value: any) => {
if (value !== undefined && value !== null && value !== '') facts.push({ key, value: String(value) });
};
addFact('窗口', observation['window_title']);
addFact('进程 PID', observation['pid']);
addFact('窗口 ID', observation['window_id']);
const count = observation['returned_element_count'] ?? observation['element_count'];
addFact('控件', count === undefined ? undefined : observation['total_element_count'] === undefined
? count : `${count} / ${observation['total_element_count']}`);
if (observation['screenshot_width'] && observation['screenshot_height']) {
addFact('截图', `${observation['screenshot_width']} × ${observation['screenshot_height']}`);
}
addFact('快照', observation['snapshot_id']);
if (observation['truncated'] === true) addFact('控件范围', '返回已截断');
if (observation['elements_complete'] === false) addFact('控件树完整性', '未声明完整');
const focus = objectValue(observation['capture_focus']);
if (typeof focus['target_foreground_after'] === 'boolean') {
addFact('采集后焦点', focus['target_foreground_after'] ? '目标窗口在前台' : '目标窗口不在前台');
}
const plainResult = typeof rawResult === 'string' && Object.keys(result).length === 0 ? rawResult : '';
const state = result['execution'];
const status = tool.status === 'running' ? '执行中' :
result['status'] === 'uncertain' || state === 'unknown' ? '结果不确定' :
@@ -48,11 +76,14 @@ export function getPluginToolView(tool: any): PluginToolView | null {
kind: payload['observation'] === true ? '辅助观察' : effect === 'read' ? '辅助读取' :
effect === 'write' ? '插件操作' : '插件调用',
auxiliary,
result: [result['message'], text].filter(Boolean).join('\n') || String(tool.error || ''),
result: [result['message'], text].filter(Boolean).join('\n') || plainResult || String(tool.error || ''),
status,
args: Object.entries(objectValue(payload['args'] ?? tool.args)).map(([key, value]) => ({
key, value: typeof value === 'string' ? value : JSON.stringify(value)
}))
})),
facts,
rawResult: rawResult === undefined || rawResult === null ? '' :
Object.keys(result).length ? JSON.stringify(result, null, 2) : String(rawResult)
};
return view;
}