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:
boos
2026-06-22 11:32:57 +08:00
parent d9c78ecc73
commit e0a5f57793
2305 changed files with 449355 additions and 0 deletions
@@ -0,0 +1,279 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Alert, App, Button, Image as AntImage, Spin, Tooltip, Typography, theme } from 'antd';
import { Check, Copy, Download } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import {
copyChatImage,
getDefaultImageFilename,
saveChatImage,
} from '@/lib/chatImageActions';
type ChatImageNodeData = {
type: 'image' | 'img';
src?: string;
alt?: string | null;
title?: string | null;
raw?: string;
attrs?:
| Record<string, string | boolean>
| [string, string][]
| Array<{ name: string; value: string | boolean }>;
loading?: boolean;
};
type MarkdownImageProps = {
node: ChatImageNodeData;
};
type HtmlImageProps = {
src?: string;
alt?: string;
title?: string;
};
type ChatImageNodeProps = MarkdownImageProps | HtmlImageProps;
function isMarkdownImageProps(props: ChatImageNodeProps): props is MarkdownImageProps {
return 'node' in props;
}
function getNodeAttr(node: ChatImageNodeData, name: string) {
const attrs = node.attrs;
if (!attrs) return undefined;
if (Array.isArray(attrs)) {
for (const attr of attrs) {
if (Array.isArray(attr)) {
const [attrName, value] = attr;
if (attrName === name) return value;
continue;
}
if (attr.name === name && typeof attr.value === 'string') {
return attr.value;
}
}
return undefined;
}
const value = attrs[name];
return typeof value === 'string' ? value : undefined;
}
function normalizeImageProps(props: ChatImageNodeProps) {
if (isMarkdownImageProps(props)) {
const src = props.node.src ?? getNodeAttr(props.node, 'src') ?? '';
const alt = props.node.alt ?? getNodeAttr(props.node, 'alt') ?? '';
const title = props.node.title ?? getNodeAttr(props.node, 'title') ?? alt;
return {
src,
alt,
title,
raw: props.node.raw ?? '',
loading: Boolean(props.node.loading),
};
}
return {
src: props.src ?? '',
alt: props.alt ?? '',
title: props.title ?? props.alt ?? '',
raw: props.src ?? '',
loading: false,
};
}
export function ChatImageNode(props: ChatImageNodeProps) {
const { token } = theme.useToken();
const { message } = App.useApp();
const { t } = useTranslation();
const { src, alt, title, raw, loading } = normalizeImageProps(props);
const [status, setStatus] = useState<'loading' | 'loaded' | 'error'>(() => (
src || loading ? 'loading' : 'error'
));
const [copying, setCopying] = useState(false);
const [saving, setSaving] = useState(false);
const [copied, setCopied] = useState(false);
useEffect(() => {
setStatus(src || loading ? 'loading' : 'error');
setCopied(false);
}, [src, loading]);
useEffect(() => {
if (!copied) return;
const timer = window.setTimeout(() => setCopied(false), 1600);
return () => window.clearTimeout(timer);
}, [copied]);
const filename = useMemo(() => getDefaultImageFilename(src, alt), [alt, src]);
const sourceLabel = src || raw || t('chat.imageMissingSource', '图片链接为空');
const altText = alt || title || t('chat.imagePreview', '图片预览');
const handleCopy = useCallback(async () => {
if (!src || status !== 'loaded') return;
setCopying(true);
try {
await copyChatImage(src);
setCopied(true);
message.success(t('chat.imageCopySuccess', '图片已复制'));
} catch (error) {
console.error('copy chat image failed:', error);
message.error(t('chat.imageCopyFailed', '复制图片失败'));
} finally {
setCopying(false);
}
}, [message, src, status, t]);
const handleSave = useCallback(async () => {
if (!src || status !== 'loaded') return;
setSaving(true);
try {
const saved = await saveChatImage(src, filename);
if (saved) {
message.success(t('chat.imageSaveSuccess', '图片已保存'));
}
} catch (error) {
console.error('save chat image failed:', error);
message.error(t('chat.imageSaveFailed', '保存图片失败'));
} finally {
setSaving(false);
}
}, [filename, message, src, status, t]);
const shellStyle: React.CSSProperties = {
position: 'relative',
display: 'inline-block',
maxWidth: '100%',
verticalAlign: 'top',
margin: '6px 0',
};
const loadingStyle: React.CSSProperties = {
minWidth: 220,
minHeight: 132,
maxWidth: '100%',
border: `1px solid ${token.colorBorderSecondary}`,
borderRadius: token.borderRadius,
background: token.colorFillQuaternary,
color: token.colorTextSecondary,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
padding: 16,
};
if (status === 'error') {
return (
<span style={shellStyle}>
<Alert
type="error"
showIcon
message={t('chat.imageLoadFailed', '图片加载失败')}
description={(
<Typography.Text
copyable={src ? { text: src } : false}
style={{
display: 'block',
maxWidth: 520,
maxHeight: 120,
overflow: 'auto',
wordBreak: 'break-all',
}}
>
{sourceLabel}
</Typography.Text>
)}
style={{ maxWidth: 560, textAlign: 'left' }}
/>
</span>
);
}
return (
<span style={shellStyle}>
{status === 'loading' && (
<span style={loadingStyle}>
<Spin size="small" />
<Typography.Text type="secondary">
{t('chat.imageLoading', '图片加载中...')}
</Typography.Text>
</span>
)}
{status === 'loading' && src && (
<img
src={src}
alt={altText}
onLoad={() => setStatus('loaded')}
onError={() => setStatus('error')}
style={{
position: 'absolute',
width: 1,
height: 1,
opacity: 0,
pointerEvents: 'none',
}}
/>
)}
{status === 'loaded' && (
<>
<AntImage
src={src}
alt={altText}
title={title || undefined}
style={{
display: 'block',
maxWidth: '100%',
maxHeight: 520,
borderRadius: token.borderRadius,
objectFit: 'contain',
}}
preview={{ mask: { blur: true }, scaleStep: 0.5 }}
onError={() => setStatus('error')}
/>
<span
onClick={(event) => event.stopPropagation()}
style={{
position: 'absolute',
top: 8,
right: 8,
display: 'inline-flex',
gap: 4,
padding: 3,
borderRadius: token.borderRadiusSM,
background: token.colorBgElevated,
boxShadow: token.boxShadowSecondary,
border: `1px solid ${token.colorBorderSecondary}`,
}}
>
<Tooltip title={copied ? t('common.copied') : t('chat.copyImage', '复制图片')}>
<Button
type="text"
size="small"
aria-label={t('chat.copyImage', '复制图片')}
icon={copied ? <Check size={14} style={{ color: token.colorSuccess }} /> : <Copy size={14} />}
loading={copying}
onClick={handleCopy}
style={{ width: 26, height: 26, padding: 0 }}
/>
</Tooltip>
<Tooltip title={t('chat.saveImage', '保存图片')}>
<Button
type="text"
size="small"
aria-label={t('chat.saveImage', '保存图片')}
icon={<Download size={14} />}
loading={saving}
onClick={handleSave}
style={{ width: 26, height: 26, padding: 0 }}
/>
</Tooltip>
</span>
</>
)}
</span>
);
}