feat(desktop): embed BoosAPI Desktop client as a subproject
Vendor the rebranded BoosAPI Desktop client (Tauri + Vue) into metapi as a monorepo subdirectory under desktop/. Source only — node_modules, dist, Cargo target and the upstream .git history are excluded. desktop/ keeps its own independent Cargo workspace (desktop/src-tauri with crates/*); it is not part of the backend boosapi crate, so a `cargo check` at the metapi root still compiles only the backend. Build the desktop app from desktop/src-tauri per desktop/README.md. Note: app icons are still placeholder assets pending the final BoosAPI logo. The updater signing keys live outside the repo and are not included.
This commit is contained in:
@@ -0,0 +1,229 @@
|
||||
import React, { useMemo, useState, useEffect } from 'react';
|
||||
import { Collapse, Tag } from 'antd';
|
||||
import { CheckCircle, Loader, XCircle } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { NodeComponentProps } from 'markstream-react';
|
||||
|
||||
const MCP_NODE_TEXT_CHAR_LIMIT = 20_000;
|
||||
const MCP_NODE_TRUNCATED_NOTICE = 'MCP output truncated for rendering';
|
||||
|
||||
function safeGetAttr(attrs: any, key: string): string | undefined {
|
||||
if (!attrs) return undefined;
|
||||
|
||||
if (Array.isArray(attrs)) {
|
||||
for (const item of attrs) {
|
||||
if (Array.isArray(item)) {
|
||||
const [k, v] = item;
|
||||
if (k === key || k === `data-${key}`) {
|
||||
if (v == null) return undefined;
|
||||
return typeof v === 'object' ? JSON.stringify(v) : String(v);
|
||||
}
|
||||
} else if (item && typeof item === 'object' && 'name' in item) {
|
||||
if (item.name === key || item.name === `data-${key}`) {
|
||||
const v = item.value;
|
||||
if (v == null) return undefined;
|
||||
return typeof v === 'object' ? JSON.stringify(v) : String(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof attrs === 'object') {
|
||||
for (const k of [key, `data-${key}`]) {
|
||||
const v = attrs[k];
|
||||
if (v == null) continue;
|
||||
return typeof v === 'object' ? JSON.stringify(v) : String(v);
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function appendBoundedText(current: string, next: string, limit: number): { text: string; truncated: boolean } {
|
||||
if (!next) return { text: current, truncated: false };
|
||||
|
||||
let text = current;
|
||||
let remaining = limit - Array.from(current).length;
|
||||
if (remaining <= 0) {
|
||||
return { text, truncated: true };
|
||||
}
|
||||
|
||||
for (const char of next) {
|
||||
if (remaining <= 0) {
|
||||
return { text, truncated: true };
|
||||
}
|
||||
text += char;
|
||||
remaining -= 1;
|
||||
}
|
||||
|
||||
return { text, truncated: false };
|
||||
}
|
||||
|
||||
function extractText(children: any[] | undefined, limit = MCP_NODE_TEXT_CHAR_LIMIT): { text: string; truncated: boolean } {
|
||||
if (!children || children.length === 0) return { text: '', truncated: false };
|
||||
|
||||
let text = '';
|
||||
for (const child of children) {
|
||||
let next = '';
|
||||
if (typeof child === 'string') {
|
||||
next = child;
|
||||
} else if (child?.content != null) {
|
||||
next = typeof child.content === 'object' ? JSON.stringify(child.content) : String(child.content);
|
||||
} else if (child?.children) {
|
||||
const nested = extractText(child.children, limit - Array.from(text).length);
|
||||
text += nested.text;
|
||||
if (nested.truncated) {
|
||||
return { text, truncated: true };
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const appended = appendBoundedText(text, next, limit);
|
||||
text = appended.text;
|
||||
if (appended.truncated) {
|
||||
return { text, truncated: true };
|
||||
}
|
||||
}
|
||||
|
||||
return { text, truncated: false };
|
||||
}
|
||||
|
||||
export function McpContainerNode(props: NodeComponentProps<any>) {
|
||||
const { node, ctx, renderNode, indexKey } = props;
|
||||
|
||||
if (node.name !== 'mcp') {
|
||||
return (
|
||||
<div className={`vmr-container vmr-container-${node.name ?? 'unknown'}`}>
|
||||
{Array.isArray(node.children) && ctx && renderNode
|
||||
? node.children.map((child: any, i: number) => (
|
||||
<React.Fragment key={`${String(indexKey ?? 'vmr-container')}-${i}`}>
|
||||
{renderNode(child, `${String(indexKey ?? 'vmr-container')}-${i}`, ctx)}
|
||||
</React.Fragment>
|
||||
))
|
||||
: null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <McpToolCard node={node} />;
|
||||
}
|
||||
|
||||
const monoStyle: React.CSSProperties = {
|
||||
display: 'block',
|
||||
fontSize: 12,
|
||||
maxHeight: 300,
|
||||
overflow: 'auto',
|
||||
whiteSpace: 'pre-wrap',
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
||||
padding: '4px 0',
|
||||
};
|
||||
|
||||
function McpToolCard({ node }: { node: any }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const serverName = safeGetAttr(node.attrs, 'name') ?? 'MCP';
|
||||
const toolName = safeGetAttr(node.attrs, 'tool') ?? 'unknown';
|
||||
const rawArgs = safeGetAttr(node.attrs, 'arguments');
|
||||
const isLoading = Boolean(node.loading);
|
||||
|
||||
const [activeKey, setActiveKey] = useState<string[]>(isLoading ? ['1'] : []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) {
|
||||
setActiveKey(['1']);
|
||||
} else {
|
||||
setActiveKey([]);
|
||||
}
|
||||
}, [isLoading]);
|
||||
|
||||
const resultText = useMemo(() => {
|
||||
if (isLoading) return '';
|
||||
const result = extractText(node.children);
|
||||
if (!result.truncated) return result.text;
|
||||
return `${result.text}\n\n[${MCP_NODE_TRUNCATED_NOTICE}]`;
|
||||
}, [isLoading, node.children]);
|
||||
|
||||
const isError = useMemo(() => {
|
||||
if (isLoading) return false;
|
||||
const trimmed = resultText.trim();
|
||||
return (
|
||||
trimmed.startsWith('Error:') ||
|
||||
trimmed.startsWith('Error executing tool:') ||
|
||||
trimmed.startsWith('错误')
|
||||
);
|
||||
}, [isLoading, resultText]);
|
||||
|
||||
const status = isLoading ? 'running' : isError ? 'error' : 'success';
|
||||
|
||||
const statusIcon = useMemo(() => {
|
||||
if (status === 'running') return <Loader size={12} className="animate-spin" />;
|
||||
if (status === 'error') return <XCircle size={12} />;
|
||||
return <CheckCircle size={12} />;
|
||||
}, [status]);
|
||||
|
||||
const statusColor = status === 'running' ? 'processing' : status === 'error' ? 'error' : 'success';
|
||||
const statusLabel = status === 'running'
|
||||
? t('chat.tool.running')
|
||||
: status === 'error'
|
||||
? t('chat.tool.error')
|
||||
: t('chat.tool.success');
|
||||
|
||||
const decodedArgs = useMemo(() => {
|
||||
if (!rawArgs) return null;
|
||||
try {
|
||||
const decoded = rawArgs.includes('%') ? decodeURIComponent(rawArgs) : rawArgs;
|
||||
return JSON.stringify(JSON.parse(decoded), null, 2);
|
||||
} catch {
|
||||
return rawArgs;
|
||||
}
|
||||
}, [rawArgs]);
|
||||
|
||||
const headerLabel = (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||
<Tag icon={statusIcon} color={statusColor} style={{ margin: 0 }}>{statusLabel}</Tag>
|
||||
<Tag style={{ margin: 0 }}>{serverName}</Tag>
|
||||
<span style={{ fontSize: 13 }}>{toolName}</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
const hasContent = Boolean(decodedArgs) || Boolean(!isLoading && resultText);
|
||||
|
||||
return (
|
||||
<span style={{ display: 'block', margin: '8px 0' }}>
|
||||
<Collapse
|
||||
size="small"
|
||||
activeKey={activeKey}
|
||||
onChange={(keys) => setActiveKey(keys as string[])}
|
||||
items={hasContent ? [{
|
||||
key: '1',
|
||||
label: headerLabel,
|
||||
children: (
|
||||
<>
|
||||
{decodedArgs && (
|
||||
<div style={{ marginBottom: resultText ? 8 : 0 }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--ant-color-text-secondary)', marginBottom: 4 }}>
|
||||
{t('chat.tool.input')}
|
||||
</div>
|
||||
<span style={{ ...monoStyle, maxHeight: 200 }}>{decodedArgs}</span>
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && resultText && (
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--ant-color-text-secondary)', marginBottom: 4 }}>
|
||||
{t('chat.tool.output')}
|
||||
</div>
|
||||
<span style={monoStyle}>{resultText}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
}] : [{
|
||||
key: '1',
|
||||
label: headerLabel,
|
||||
children: null,
|
||||
}]}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user