添加完整的Outlook MCP集成,包括邮件和日历功能,通过AuthZ模式进行认证和权限管理, 支持邮箱连接、断开、状态检查和数据同步等功能。 fix(config): 统一配置文件路径从.nanobot到.beaver 将配置文件路径从/root/.nanobot统一更改为/root/.beaver,更新Dockerfile中的环境变量定义, 确保所有组件使用一致的配置目录结构。 feat(agent): 添加代理删除功能和助手身份提示 为代理注册表添加delete_agent方法,实现代理的动态删除功能;同时添加海狸助手身份提示, 确保AI助手在交互中保持一致的身份认知。 feat(engine): 增强引擎循环并添加意图决策快照 扩展AgentLoop类,添加intent_agent_decision参数用于意图驱动的代理决策,并在会话中记录 决策快照,便于后续分析和调试。 feat(authz): 扩展认证客户端功能 为AuthzClient添加设置权限、用户注册、后端注册和Outlook设置管理等新方法,增强系统 的认证和授权能力。
223 lines
8.9 KiB
TypeScript
223 lines
8.9 KiB
TypeScript
'use client';
|
|
|
|
import React from 'react';
|
|
import { FileText, GitBranch, Loader2 } from 'lucide-react';
|
|
import ReactMarkdown from 'react-markdown';
|
|
import remarkGfm from 'remark-gfm';
|
|
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
import type { SkillFileContent, SkillFileInfo, SkillVersionRef } from '@/types';
|
|
|
|
type SkillDetailViewProps = {
|
|
title: string;
|
|
summary?: string | null;
|
|
badges?: React.ReactNode;
|
|
actions?: React.ReactNode;
|
|
currentVersion: string;
|
|
versions: SkillVersionRef[];
|
|
files: SkillFileInfo[];
|
|
content: string;
|
|
selectedFile?: SkillFileContent | null;
|
|
loadingFile?: boolean;
|
|
loadingVersion?: boolean;
|
|
onSelectVersion: (version: string) => void;
|
|
onOpenFile: (filePath: string) => void;
|
|
labels: {
|
|
overview: string;
|
|
files: string;
|
|
versions: string;
|
|
noReadme: string;
|
|
noFiles: string;
|
|
selectFile: string;
|
|
binaryFile: string;
|
|
current: string;
|
|
size: string;
|
|
};
|
|
};
|
|
|
|
export function SkillDetailView({
|
|
title,
|
|
summary,
|
|
badges,
|
|
actions,
|
|
currentVersion,
|
|
versions,
|
|
files,
|
|
content,
|
|
selectedFile,
|
|
loadingFile,
|
|
loadingVersion,
|
|
onSelectVersion,
|
|
onOpenFile,
|
|
labels,
|
|
}: SkillDetailViewProps) {
|
|
const readme = stripFrontmatter(content || '');
|
|
return (
|
|
<div className="rounded-lg border border-border bg-white text-black [--background:0_0%_100%] [--card:0_0%_100%] [--card-foreground:0_0%_0%] [--foreground:0_0%_0%] [--muted-foreground:0_0%_0%] [--popover:0_0%_100%] [--popover-foreground:0_0%_0%] [--secondary-foreground:0_0%_0%]">
|
|
<div className="border-b border-border p-5">
|
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
|
<div className="min-w-0">
|
|
<div className="mb-2 flex flex-wrap items-center gap-2">
|
|
{badges}
|
|
<Badge variant="outline">v{currentVersion || '-'}</Badge>
|
|
{loadingVersion && <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />}
|
|
</div>
|
|
<h2 className="break-words text-2xl font-semibold tracking-normal">{title}</h2>
|
|
{summary && <p className="mt-2 max-w-4xl text-sm leading-6 text-muted-foreground">{summary}</p>}
|
|
</div>
|
|
{actions}
|
|
</div>
|
|
</div>
|
|
|
|
<Tabs defaultValue="overview" className="p-5">
|
|
<TabsList>
|
|
<TabsTrigger value="overview">{labels.overview}</TabsTrigger>
|
|
<TabsTrigger value="files">{labels.files}</TabsTrigger>
|
|
<TabsTrigger value="versions">{labels.versions}</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<TabsContent value="overview" className="mt-5">
|
|
{readme.trim() ? (
|
|
<MarkdownPreview content={readme} />
|
|
) : (
|
|
<EmptyPanel icon={<FileText className="h-7 w-7" />} text={labels.noReadme} />
|
|
)}
|
|
</TabsContent>
|
|
|
|
<TabsContent value="files" className="mt-5">
|
|
<div className="grid gap-4 lg:grid-cols-[320px_minmax(0,1fr)]">
|
|
<div className="min-h-[420px] rounded-lg border border-border">
|
|
{files.length === 0 ? (
|
|
<EmptyPanel icon={<FileText className="h-7 w-7" />} text={labels.noFiles} />
|
|
) : (
|
|
<div className="max-h-[620px] overflow-auto p-2">
|
|
{files.map((file) => (
|
|
<button
|
|
key={file.filePath}
|
|
type="button"
|
|
className={`flex w-full items-center justify-between gap-3 rounded-md px-3 py-2 text-left text-sm transition hover:bg-muted ${
|
|
selectedFile?.filePath === file.filePath ? 'bg-muted' : ''
|
|
}`}
|
|
onClick={() => onOpenFile(file.filePath)}
|
|
>
|
|
<span className="min-w-0 break-all font-mono text-xs">{file.filePath}</span>
|
|
<span className="shrink-0 text-xs text-muted-foreground">{formatBytes(file.fileSize)}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="min-h-[420px] rounded-lg border border-border bg-muted/20 p-4">
|
|
{loadingFile ? (
|
|
<div className="flex h-[360px] items-center justify-center">
|
|
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
|
</div>
|
|
) : selectedFile ? (
|
|
<FilePreview file={selectedFile} labels={labels} />
|
|
) : (
|
|
<EmptyPanel icon={<FileText className="h-7 w-7" />} text={labels.selectFile} />
|
|
)}
|
|
</div>
|
|
</div>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="versions" className="mt-5">
|
|
<div className="space-y-2">
|
|
{versions.map((version) => (
|
|
<button
|
|
key={version.version}
|
|
type="button"
|
|
className={`flex w-full items-center justify-between gap-4 rounded-lg border px-4 py-3 text-left transition hover:bg-muted ${
|
|
version.version === currentVersion ? 'border-primary bg-muted' : 'border-border'
|
|
}`}
|
|
onClick={() => onSelectVersion(version.version)}
|
|
>
|
|
<div className="min-w-0">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<GitBranch className="h-4 w-4 text-muted-foreground" />
|
|
<span className="font-mono text-sm">{version.version}</span>
|
|
{version.version === currentVersion && <Badge variant="secondary">{labels.current}</Badge>}
|
|
{version.status && <Badge variant="outline">{version.status}</Badge>}
|
|
</div>
|
|
{version.changeReason && (
|
|
<p className="mt-1 truncate text-xs text-muted-foreground">{version.changeReason}</p>
|
|
)}
|
|
</div>
|
|
<div className="shrink-0 text-right text-xs text-muted-foreground">
|
|
{version.publishedAt || version.createdAt || ''}
|
|
{typeof version.totalSize === 'number' && (
|
|
<div>
|
|
{labels.size}: {formatBytes(version.totalSize)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function FilePreview({ file, labels }: { file: SkillFileContent; labels: SkillDetailViewProps['labels'] }) {
|
|
const content = file.content || '';
|
|
return (
|
|
<div className="space-y-3">
|
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
<div className="break-all font-mono text-sm font-medium">{file.filePath}</div>
|
|
<Badge variant="outline">
|
|
{labels.size}: {formatBytes(file.fileSize)}
|
|
</Badge>
|
|
</div>
|
|
{file.isBinary ? (
|
|
<EmptyPanel icon={<FileText className="h-7 w-7" />} text={labels.binaryFile} />
|
|
) : isMarkdown(file.filePath, file.contentType) ? (
|
|
<MarkdownPreview content={stripFrontmatter(content)} />
|
|
) : (
|
|
<pre className="max-h-[560px] overflow-auto rounded-md border border-border bg-background p-4 text-xs leading-5">
|
|
{content}
|
|
</pre>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MarkdownPreview({ content }: { content: string }) {
|
|
return (
|
|
<div className="prose prose-sm max-w-none text-black prose-a:text-black prose-blockquote:text-black prose-code:rounded prose-code:bg-muted prose-code:px-1 prose-code:py-0.5 prose-code:text-black prose-headings:text-black prose-headings:tracking-normal prose-li:text-black prose-p:text-black prose-pre:border prose-pre:border-border prose-pre:bg-muted prose-pre:text-black prose-strong:text-black prose-table:text-black prose-td:text-black prose-th:text-black [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
|
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function EmptyPanel({ icon, text }: { icon: React.ReactNode; text: string }) {
|
|
return (
|
|
<div className="flex min-h-[220px] flex-col items-center justify-center text-center text-muted-foreground">
|
|
<div className="mb-3 opacity-40">{icon}</div>
|
|
<p className="text-sm font-medium">{text}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function stripFrontmatter(value: string): string {
|
|
if (!value.startsWith('---')) return value;
|
|
const marker = value.indexOf('\n---', 3);
|
|
if (marker < 0) return value;
|
|
const after = value.indexOf('\n', marker + 4);
|
|
return after >= 0 ? value.slice(after + 1) : '';
|
|
}
|
|
|
|
function isMarkdown(filePath: string, contentType?: string | null): boolean {
|
|
return filePath.toLowerCase().endsWith('.md') || (contentType || '').includes('markdown');
|
|
}
|
|
|
|
function formatBytes(value: number | undefined): string {
|
|
const size = Number(value || 0);
|
|
if (size < 1024) return `${size} B`;
|
|
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
|
|
return `${(size / 1024 / 1024).toFixed(1)} MB`;
|
|
}
|