mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
feat: v0.27.0 - 重构 Agent 架构并新增 API Key Provider 管理系统
主要变更: - 引入基于 Protocol 的 Agent 架构(支持 OpenAI/Anthropic/Kiro/Gemini) - 新增 API Key Provider 管理系统,支持多种第三方 AI 服务商 - 重构流式响应处理,新增统一的 Stream 处理模块 - 新增协议转换器(Translator)模块,支持 Kiro 到 OpenAI/Anthropic 的转换 - 前端新增 API Key Provider 配置界面和导入/导出功能 - 新增 50+ AI 服务商图标支持 - 优化 Agent 工具调用显示和流式渲染 - 完善数据库 schema,新增 api_key_providers 表 技术细节: - 重构 native_agent.rs,引入策略模式的 Protocol trait - 新增 parsers 和 generators 模块处理不同协议的 SSE 流 - 新增完整的 API Key 验证和连接测试功能 - 改进前端 TypeScript 类型定义 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.5
parent
74fa3202ce
commit
59cd4ecfbd
@@ -1,16 +1,6 @@
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import {
|
||||
User,
|
||||
Bot,
|
||||
Copy,
|
||||
Edit2,
|
||||
Trash2,
|
||||
Lightbulb,
|
||||
ChevronDown,
|
||||
Check,
|
||||
} from "lucide-react";
|
||||
import { User, Bot, Copy, Edit2, Trash2, Check } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
MessageListContainer,
|
||||
@@ -23,9 +13,6 @@ import {
|
||||
TimeStamp,
|
||||
MessageBubble,
|
||||
MessageActions,
|
||||
ThinkingBox,
|
||||
ThinkingHeader,
|
||||
ThinkingContent,
|
||||
} from "../styles";
|
||||
import { MarkdownRenderer } from "./MarkdownRenderer";
|
||||
import { StreamingRenderer } from "./StreamingRenderer";
|
||||
@@ -44,9 +31,6 @@ export const MessageList: React.FC<MessageListProps> = ({
|
||||
onEditMessage,
|
||||
}) => {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [expandedThinking, setExpandedThinking] = useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editContent, setEditContent] = useState("");
|
||||
@@ -57,10 +41,6 @@ export const MessageList: React.FC<MessageListProps> = ({
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
const toggleThinking = (id: string) => {
|
||||
setExpandedThinking((prev) => ({ ...prev, [id]: !prev[id] }));
|
||||
};
|
||||
|
||||
const formatTime = (date: Date) => {
|
||||
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
};
|
||||
@@ -128,25 +108,6 @@ export const MessageList: React.FC<MessageListProps> = ({
|
||||
</MessageHeader>
|
||||
|
||||
<MessageBubble $isUser={msg.role === "user"}>
|
||||
{msg.isThinking && (
|
||||
<ThinkingBox $expanded={!!expandedThinking[msg.id]}>
|
||||
<ThinkingHeader onClick={() => toggleThinking(msg.id)}>
|
||||
<Lightbulb size={14} className="text-yellow-500" />
|
||||
<span>{msg.thinkingContent}</span>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={cn(
|
||||
"ml-1 transition-transform duration-200",
|
||||
expandedThinking[msg.id] && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</ThinkingHeader>
|
||||
{expandedThinking[msg.id] && (
|
||||
<ThinkingContent>正在深度思考...</ThinkingContent>
|
||||
)}
|
||||
</ThinkingBox>
|
||||
)}
|
||||
|
||||
{editingId === msg.id ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<textarea
|
||||
@@ -175,6 +136,7 @@ export const MessageList: React.FC<MessageListProps> = ({
|
||||
isStreaming={msg.isThinking}
|
||||
toolCalls={msg.toolCalls}
|
||||
showCursor={msg.isThinking && !msg.content}
|
||||
contentParts={msg.contentParts}
|
||||
/>
|
||||
) : (
|
||||
<MarkdownRenderer content={msg.content} />
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# Agent Chat 组件
|
||||
|
||||
Agent 聊天界面的 UI 组件集合。
|
||||
|
||||
## 文件索引
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `ChatNavbar.tsx` | 聊天顶部导航栏 |
|
||||
| `ChatSettings.tsx` | 聊天设置面板 |
|
||||
| `ChatSidebar.tsx` | 聊天侧边栏(会话列表) |
|
||||
| `EmptyState.tsx` | 空状态占位组件 |
|
||||
| `InputArea.tsx` | 消息输入区域 |
|
||||
| `MarkdownRenderer.tsx` | Markdown 渲染组件 |
|
||||
| `MessageList.tsx` | 消息列表组件 |
|
||||
| `StreamingRenderer.tsx` | 流式消息渲染(支持思考内容、工具调用) |
|
||||
| `TokenUsageDisplay.tsx` | Token 使用量显示 |
|
||||
| `ToolCallDisplay.tsx` | 工具调用显示(状态、参数、日志、结果) |
|
||||
|
||||
## 核心组件
|
||||
|
||||
### ToolCallDisplay
|
||||
|
||||
参考 Goose UI 设计,提供完整的工具调用可视化:
|
||||
|
||||
- **状态指示器**:pending/running/completed/failed 四种状态
|
||||
- **工具描述**:根据工具类型和参数生成人性化描述
|
||||
- **可展开面板**:参数、日志、输出结果分层展示
|
||||
- **执行时间**:显示工具执行耗时
|
||||
|
||||
### StreamingRenderer
|
||||
|
||||
流式消息渲染组件,支持:
|
||||
|
||||
- **思考内容**:解析 `<think>` 或 `<thinking>` 标签,折叠显示
|
||||
- **工具调用**:集成 ToolCallList 显示工具执行状态
|
||||
- **实时 Markdown**:流式渲染 Markdown 格式
|
||||
- **流式光标**:显示正在输入的视觉反馈
|
||||
|
||||
## 依赖关系
|
||||
|
||||
```
|
||||
MessageList
|
||||
└── StreamingRenderer
|
||||
├── ThinkingBlock (思考内容)
|
||||
├── ToolCallList
|
||||
│ └── ToolCallDisplay
|
||||
│ ├── ToolCallStatusIndicator
|
||||
│ ├── ToolCallArguments
|
||||
│ ├── ToolLogsView
|
||||
│ └── ToolResultView
|
||||
└── MarkdownRenderer
|
||||
```
|
||||
@@ -1,102 +1,253 @@
|
||||
/**
|
||||
* 流式消息渲染组件
|
||||
*
|
||||
* 实现实时 Markdown 渲染,区分文本响应和工具调用响应
|
||||
* 参考 Goose UI 设计,支持思考内容、工具调用和实时 Markdown 渲染
|
||||
* Requirements: 9.3, 9.4
|
||||
*/
|
||||
|
||||
import React, { memo, useMemo } from "react";
|
||||
import styled, { keyframes } from "styled-components";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronDown, Lightbulb } from "lucide-react";
|
||||
import { MarkdownRenderer } from "./MarkdownRenderer";
|
||||
import { ToolCallList } from "./ToolCallDisplay";
|
||||
import { ToolCallList, ToolCallItem } from "./ToolCallDisplay";
|
||||
import type { ToolCallState } from "@/lib/api/agent";
|
||||
import type { ContentPart } from "../types";
|
||||
|
||||
// 光标闪烁动画
|
||||
const blink = keyframes`
|
||||
0%, 50% { opacity: 1; }
|
||||
51%, 100% { opacity: 0; }
|
||||
`;
|
||||
// ============ 思考内容组件 ============
|
||||
|
||||
const StreamingContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
`;
|
||||
interface ThinkingBlockProps {
|
||||
content: string;
|
||||
defaultExpanded?: boolean;
|
||||
}
|
||||
|
||||
const TextSection = styled.div`
|
||||
position: relative;
|
||||
`;
|
||||
const ThinkingBlock: React.FC<ThinkingBlockProps> = ({
|
||||
content,
|
||||
defaultExpanded = false,
|
||||
}) => {
|
||||
const [expanded, setExpanded] = React.useState(defaultExpanded);
|
||||
|
||||
const StreamingCursor = styled.span`
|
||||
display: inline-block;
|
||||
width: 2px;
|
||||
height: 1em;
|
||||
background-color: hsl(var(--primary));
|
||||
margin-left: 2px;
|
||||
vertical-align: text-bottom;
|
||||
animation: ${blink} 1s step-end infinite;
|
||||
`;
|
||||
if (!content) return null;
|
||||
|
||||
const ToolSection = styled.div`
|
||||
margin-top: 8px;
|
||||
`;
|
||||
return (
|
||||
<details
|
||||
className="bg-muted/50 border border-border rounded-lg overflow-hidden mb-3"
|
||||
open={expanded}
|
||||
onToggle={(e) => setExpanded((e.target as HTMLDetailsElement).open)}
|
||||
>
|
||||
<summary className="cursor-pointer px-3 py-2 text-sm text-muted-foreground select-none flex items-center gap-2 hover:bg-muted/70 transition-colors">
|
||||
<Lightbulb className="w-4 h-4 text-yellow-500" />
|
||||
<span className="flex-1">思考过程</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"w-4 h-4 transition-transform duration-200",
|
||||
expanded && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</summary>
|
||||
<div className="px-3 py-2 border-t border-border bg-background/50">
|
||||
<MarkdownRenderer content={content} />
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
};
|
||||
|
||||
// ============ 流式光标 ============
|
||||
|
||||
const StreamingCursor: React.FC = () => (
|
||||
<span
|
||||
className="inline-block w-0.5 h-[1em] bg-primary ml-0.5 align-text-bottom animate-pulse"
|
||||
style={{ animationDuration: "1s" }}
|
||||
/>
|
||||
);
|
||||
|
||||
// ============ 思考内容解析 ============
|
||||
|
||||
interface ParsedContent {
|
||||
visibleText: string;
|
||||
thinkingText: string | null;
|
||||
}
|
||||
|
||||
const parseThinkingContent = (text: string): ParsedContent => {
|
||||
// 支持 <think>...</think> 和 <thinking>...</thinking> 标签
|
||||
const thinkRegex = /<think(?:ing)?>([\s\S]*?)<\/think(?:ing)?>/gi;
|
||||
let thinkingText: string | null = null;
|
||||
let visibleText = text;
|
||||
|
||||
const matches = text.matchAll(thinkRegex);
|
||||
const thinkingParts: string[] = [];
|
||||
|
||||
for (const match of matches) {
|
||||
thinkingParts.push(match[1].trim());
|
||||
visibleText = visibleText.replace(match[0], "");
|
||||
}
|
||||
|
||||
if (thinkingParts.length > 0) {
|
||||
thinkingText = thinkingParts.join("\n\n");
|
||||
}
|
||||
|
||||
return {
|
||||
visibleText: visibleText.trim(),
|
||||
thinkingText,
|
||||
};
|
||||
};
|
||||
|
||||
// ============ 主组件 ============
|
||||
|
||||
interface StreamingRendererProps {
|
||||
/** 文本内容 */
|
||||
/** 文本内容(向后兼容) */
|
||||
content: string;
|
||||
/** 是否正在流式输出 */
|
||||
isStreaming?: boolean;
|
||||
/** 工具调用列表 */
|
||||
/** 工具调用列表(向后兼容) */
|
||||
toolCalls?: ToolCallState[];
|
||||
/** 是否显示光标 */
|
||||
showCursor?: boolean;
|
||||
/** 思考内容(可选,如果不提供则从 content 中解析) */
|
||||
thinkingContent?: string;
|
||||
/**
|
||||
* 交错内容列表(按事件到达顺序排列)
|
||||
* 如果存在且非空,按顺序渲染
|
||||
* 否则回退到 content + toolCalls 渲染方式
|
||||
*/
|
||||
contentParts?: ContentPart[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 流式消息渲染组件
|
||||
*
|
||||
* 支持实时 Markdown 渲染和工具调用显示
|
||||
* Requirements: 9.3 - THE Frontend SHALL distinguish between text responses and tool call responses visually
|
||||
* Requirements: 9.4 - WHEN streaming text, THE Frontend SHALL render markdown formatting in real-time
|
||||
* 支持:
|
||||
* - 思考内容折叠显示(<think> 或 <thinking> 标签)
|
||||
* - 工具调用状态和结果显示
|
||||
* - 实时 Markdown 渲染
|
||||
* - 流式光标
|
||||
* - **交错内容显示**(文本和工具调用按事件顺序交错)
|
||||
*/
|
||||
export const StreamingRenderer: React.FC<StreamingRendererProps> = memo(
|
||||
({ content, isStreaming = false, toolCalls, showCursor = true }) => {
|
||||
// 判断是否有正在执行的工具
|
||||
const hasRunningTools = useMemo(
|
||||
() => toolCalls?.some((tc) => tc.status === "running") ?? false,
|
||||
[toolCalls],
|
||||
({
|
||||
content,
|
||||
isStreaming = false,
|
||||
toolCalls,
|
||||
showCursor = true,
|
||||
thinkingContent: externalThinking,
|
||||
contentParts,
|
||||
}) => {
|
||||
// 判断是否使用交错显示模式
|
||||
const useInterleavedMode = contentParts && contentParts.length > 0;
|
||||
|
||||
// 解析思考内容(仅在非交错模式下使用)
|
||||
const { visibleText, thinkingText } = useMemo(
|
||||
() => parseThinkingContent(content),
|
||||
[content],
|
||||
);
|
||||
|
||||
// 使用外部提供的思考内容或解析出的内容
|
||||
const finalThinking = externalThinking || thinkingText;
|
||||
|
||||
// 判断是否有正在执行的工具
|
||||
const hasRunningTools = useMemo(() => {
|
||||
if (useInterleavedMode) {
|
||||
return contentParts.some(
|
||||
(part) =>
|
||||
part.type === "tool_use" && part.toolCall.status === "running",
|
||||
);
|
||||
}
|
||||
return toolCalls?.some((tc) => tc.status === "running") ?? false;
|
||||
}, [contentParts, toolCalls, useInterleavedMode]);
|
||||
|
||||
// 判断是否显示光标
|
||||
const shouldShowCursor = isStreaming && showCursor && !hasRunningTools;
|
||||
|
||||
// 判断是否有工具调用
|
||||
// 判断是否有可见内容
|
||||
const hasVisibleContent = useInterleavedMode
|
||||
? contentParts.some(
|
||||
(part) => part.type === "text" && part.text.length > 0,
|
||||
)
|
||||
: visibleText.length > 0;
|
||||
|
||||
// 交错显示模式:按顺序渲染 contentParts
|
||||
if (useInterleavedMode) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* 思考内容 - 显示在最前面 */}
|
||||
{finalThinking && (
|
||||
<ThinkingBlock
|
||||
content={finalThinking}
|
||||
defaultExpanded={isStreaming}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 交错内容 */}
|
||||
{contentParts.map((part, index) => {
|
||||
if (part.type === "text") {
|
||||
// 解析并渲染文本(可能包含 thinking 标签)
|
||||
const { visibleText: partVisible } = parseThinkingContent(
|
||||
part.text,
|
||||
);
|
||||
if (!partVisible) return null;
|
||||
|
||||
const isLastPart = index === contentParts.length - 1;
|
||||
return (
|
||||
<div key={`text-${index}`} className="relative">
|
||||
<MarkdownRenderer content={partVisible} />
|
||||
{isLastPart && shouldShowCursor && <StreamingCursor />}
|
||||
</div>
|
||||
);
|
||||
} else if (part.type === "tool_use") {
|
||||
// 渲染单个工具调用
|
||||
return (
|
||||
<ToolCallItem key={part.toolCall.id} toolCall={part.toolCall} />
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
|
||||
{/* 如果没有内容但正在流式输出,显示光标 */}
|
||||
{!hasVisibleContent &&
|
||||
isStreaming &&
|
||||
showCursor &&
|
||||
!hasRunningTools && (
|
||||
<div>
|
||||
<StreamingCursor />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 回退模式:传统的 content + toolCalls 分开渲染
|
||||
const hasToolCalls = toolCalls && toolCalls.length > 0;
|
||||
|
||||
return (
|
||||
<StreamingContainer>
|
||||
{/* 工具调用区域 - 显示在文本之前 */}
|
||||
{hasToolCalls && (
|
||||
<ToolSection>
|
||||
<ToolCallList toolCalls={toolCalls} />
|
||||
</ToolSection>
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* 思考内容 - 显示在最前面 */}
|
||||
{finalThinking && (
|
||||
<ThinkingBlock
|
||||
content={finalThinking}
|
||||
defaultExpanded={isStreaming}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 工具调用区域 */}
|
||||
{hasToolCalls && <ToolCallList toolCalls={toolCalls} />}
|
||||
|
||||
{/* 文本内容区域 */}
|
||||
{content && (
|
||||
<TextSection>
|
||||
<MarkdownRenderer content={content} />
|
||||
{visibleText.length > 0 && (
|
||||
<div className="relative">
|
||||
<MarkdownRenderer content={visibleText} />
|
||||
{shouldShowCursor && <StreamingCursor />}
|
||||
</TextSection>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 如果没有内容但正在流式输出,显示光标 */}
|
||||
{!content && isStreaming && showCursor && !hasRunningTools && (
|
||||
<TextSection>
|
||||
<StreamingCursor />
|
||||
</TextSection>
|
||||
)}
|
||||
</StreamingContainer>
|
||||
{!hasVisibleContent &&
|
||||
isStreaming &&
|
||||
showCursor &&
|
||||
!hasRunningTools && (
|
||||
<div>
|
||||
<StreamingCursor />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,150 +1,81 @@
|
||||
/**
|
||||
* 工具调用显示组件
|
||||
*
|
||||
* 显示工具执行状态和结果
|
||||
* 参考 Goose UI 设计,显示工具执行状态、参数、日志和结果
|
||||
* Requirements: 9.1, 9.2 - 工具执行指示器和结果折叠面板
|
||||
*/
|
||||
|
||||
import React, { useState } from "react";
|
||||
import styled, { keyframes } from "styled-components";
|
||||
import React, { useState, useEffect, useRef, useMemo } from "react";
|
||||
import {
|
||||
Terminal,
|
||||
FileText,
|
||||
Edit3,
|
||||
FolderOpen,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Check,
|
||||
X,
|
||||
Loader2,
|
||||
Eye,
|
||||
FilePlus,
|
||||
Search,
|
||||
Globe,
|
||||
Code2,
|
||||
Settings,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ToolCallState } from "@/lib/api/agent";
|
||||
import { MarkdownRenderer } from "./MarkdownRenderer";
|
||||
|
||||
// 动画
|
||||
const spin = keyframes`
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
`;
|
||||
// ============ 类型定义 ============
|
||||
|
||||
const pulse = keyframes`
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
`;
|
||||
export type ToolCallStatus = "pending" | "running" | "completed" | "failed";
|
||||
|
||||
// 样式组件
|
||||
const ToolCallContainer = styled.div`
|
||||
margin: 8px 0;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background-color: hsl(var(--muted) / 0.3);
|
||||
`;
|
||||
type ToolCallArgumentValue =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| ToolCallArgumentValue[]
|
||||
| { [key: string]: ToolCallArgumentValue };
|
||||
|
||||
const ToolCallHeader = styled.div<{ $status: string }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
background-color: ${(props) =>
|
||||
props.$status === "running"
|
||||
? "hsl(var(--primary) / 0.1)"
|
||||
: props.$status === "failed"
|
||||
? "hsl(var(--destructive) / 0.1)"
|
||||
: "transparent"};
|
||||
// ============ 工具状态指示器 ============
|
||||
|
||||
&:hover {
|
||||
background-color: hsl(var(--muted) / 0.5);
|
||||
}
|
||||
`;
|
||||
interface ToolCallStatusIndicatorProps {
|
||||
status: ToolCallStatus;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ToolIcon = styled.div<{ $status: string }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
background-color: ${(props) =>
|
||||
props.$status === "running"
|
||||
? "hsl(var(--primary))"
|
||||
: props.$status === "failed"
|
||||
? "hsl(var(--destructive))"
|
||||
: "hsl(var(--primary) / 0.8)"};
|
||||
color: white;
|
||||
`;
|
||||
const ToolCallStatusIndicator: React.FC<ToolCallStatusIndicatorProps> = ({
|
||||
status,
|
||||
className,
|
||||
}) => {
|
||||
const getStatusStyles = () => {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return "bg-green-500";
|
||||
case "failed":
|
||||
return "bg-red-500";
|
||||
case "running":
|
||||
return "bg-yellow-500 animate-pulse";
|
||||
case "pending":
|
||||
default:
|
||||
return "bg-gray-400";
|
||||
}
|
||||
};
|
||||
|
||||
const SpinningLoader = styled(Loader2)`
|
||||
animation: ${spin} 1s linear infinite;
|
||||
`;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full border border-background",
|
||||
getStatusStyles(),
|
||||
className,
|
||||
)}
|
||||
aria-label={`工具状态: ${status}`}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const ToolName = styled.span`
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--foreground));
|
||||
flex: 1;
|
||||
`;
|
||||
// ============ 工具图标映射 ============
|
||||
|
||||
const ToolStatus = styled.span<{ $status: string }>`
|
||||
font-size: 12px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
background-color: ${(props) =>
|
||||
props.$status === "running"
|
||||
? "hsl(var(--primary) / 0.2)"
|
||||
: props.$status === "failed"
|
||||
? "hsl(var(--destructive) / 0.2)"
|
||||
: "hsl(var(--primary) / 0.1)"};
|
||||
color: ${(props) =>
|
||||
props.$status === "running"
|
||||
? "hsl(var(--primary))"
|
||||
: props.$status === "failed"
|
||||
? "hsl(var(--destructive))"
|
||||
: "hsl(var(--primary))"};
|
||||
animation: ${(props) => (props.$status === "running" ? pulse : "none")} 1.5s
|
||||
ease-in-out infinite;
|
||||
`;
|
||||
|
||||
const ExpandIcon = styled.div`
|
||||
color: hsl(var(--muted-foreground));
|
||||
transition: transform 0.2s;
|
||||
`;
|
||||
|
||||
const ToolResultPanel = styled.div<{ $expanded: boolean }>`
|
||||
display: ${(props) => (props.$expanded ? "block" : "none")};
|
||||
border-top: 1px solid hsl(var(--border));
|
||||
background-color: hsl(var(--background));
|
||||
`;
|
||||
|
||||
const ResultContent = styled.pre`
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
font-size: 12px;
|
||||
font-family:
|
||||
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
|
||||
"Courier New", monospace;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
color: hsl(var(--foreground));
|
||||
line-height: 1.5;
|
||||
`;
|
||||
|
||||
const ErrorContent = styled(ResultContent)`
|
||||
color: hsl(var(--destructive));
|
||||
background-color: hsl(var(--destructive) / 0.05);
|
||||
`;
|
||||
|
||||
const ExecutionTime = styled.span`
|
||||
font-size: 11px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
margin-left: auto;
|
||||
margin-right: 8px;
|
||||
`;
|
||||
|
||||
// 工具图标映射
|
||||
const getToolIcon = (toolName: string) => {
|
||||
const name = toolName.toLowerCase();
|
||||
if (
|
||||
@@ -154,130 +85,461 @@ const getToolIcon = (toolName: string) => {
|
||||
) {
|
||||
return Terminal;
|
||||
}
|
||||
if (name.includes("read") || name.includes("file")) {
|
||||
return FileText;
|
||||
if (name.includes("read")) {
|
||||
return Eye;
|
||||
}
|
||||
if (name.includes("edit") || name.includes("write")) {
|
||||
if (name.includes("write") || name.includes("create")) {
|
||||
return FilePlus;
|
||||
}
|
||||
if (name.includes("edit") || name.includes("replace")) {
|
||||
return Edit3;
|
||||
}
|
||||
if (name.includes("list") || name.includes("dir")) {
|
||||
return FolderOpen;
|
||||
}
|
||||
return Terminal;
|
||||
};
|
||||
|
||||
// 工具名称显示映射
|
||||
const getToolDisplayName = (toolName: string): string => {
|
||||
const nameMap: Record<string, string> = {
|
||||
bash: "执行命令",
|
||||
read_file: "读取文件",
|
||||
write_file: "写入文件",
|
||||
edit_file: "编辑文件",
|
||||
list_directory: "列出目录",
|
||||
};
|
||||
return nameMap[toolName] || toolName;
|
||||
};
|
||||
|
||||
// 状态显示文本
|
||||
const getStatusText = (status: string): string => {
|
||||
switch (status) {
|
||||
case "running":
|
||||
return "执行中...";
|
||||
case "completed":
|
||||
return "完成";
|
||||
case "failed":
|
||||
return "失败";
|
||||
default:
|
||||
return status;
|
||||
if (
|
||||
name.includes("search") ||
|
||||
name.includes("find") ||
|
||||
name.includes("grep")
|
||||
) {
|
||||
return Search;
|
||||
}
|
||||
if (name.includes("web") || name.includes("fetch") || name.includes("http")) {
|
||||
return Globe;
|
||||
}
|
||||
if (name.includes("code") || name.includes("eval")) {
|
||||
return Code2;
|
||||
}
|
||||
if (name.includes("config") || name.includes("setting")) {
|
||||
return Settings;
|
||||
}
|
||||
if (name.includes("file")) {
|
||||
return FileText;
|
||||
}
|
||||
return Wrench;
|
||||
};
|
||||
|
||||
// ============ 工具描述生成 ============
|
||||
|
||||
const getToolDescription = (
|
||||
toolName: string,
|
||||
args: Record<string, ToolCallArgumentValue>,
|
||||
): string => {
|
||||
const name = toolName.toLowerCase();
|
||||
const getStringValue = (value: ToolCallArgumentValue): string => {
|
||||
return typeof value === "string" ? value : JSON.stringify(value);
|
||||
};
|
||||
|
||||
// 根据工具类型生成描述
|
||||
if (name.includes("bash") || name.includes("shell")) {
|
||||
if (args.command) {
|
||||
const cmd = getStringValue(args.command);
|
||||
return `执行: ${cmd.length > 50 ? cmd.slice(0, 50) + "..." : cmd}`;
|
||||
}
|
||||
return "执行命令";
|
||||
}
|
||||
|
||||
if (name.includes("read_file") || name === "read") {
|
||||
if (args.path || args.file_path) {
|
||||
return `读取 ${getStringValue(args.path || args.file_path)}`;
|
||||
}
|
||||
return "读取文件";
|
||||
}
|
||||
|
||||
if (name.includes("write_file") || name === "write") {
|
||||
if (args.path || args.file_path) {
|
||||
return `写入 ${getStringValue(args.path || args.file_path)}`;
|
||||
}
|
||||
return "写入文件";
|
||||
}
|
||||
|
||||
if (name.includes("edit_file") || name === "edit") {
|
||||
if (args.path || args.file_path) {
|
||||
return `编辑 ${getStringValue(args.path || args.file_path)}`;
|
||||
}
|
||||
return "编辑文件";
|
||||
}
|
||||
|
||||
if (name.includes("list") || name.includes("dir")) {
|
||||
if (args.path || args.directory) {
|
||||
return `列出 ${getStringValue(args.path || args.directory)}`;
|
||||
}
|
||||
return "列出目录";
|
||||
}
|
||||
|
||||
if (name.includes("search") || name.includes("grep")) {
|
||||
if (args.pattern || args.query) {
|
||||
return `搜索 "${getStringValue(args.pattern || args.query)}"`;
|
||||
}
|
||||
return "搜索";
|
||||
}
|
||||
|
||||
// 通用回退:工具名 + 参数键
|
||||
const entries = Object.entries(args);
|
||||
if (entries.length === 0) {
|
||||
return snakeToTitleCase(toolName);
|
||||
}
|
||||
if (entries.length === 1) {
|
||||
const [_key, value] = entries[0];
|
||||
const strValue = getStringValue(value);
|
||||
const truncated =
|
||||
strValue.length > 40 ? strValue.slice(0, 40) + "..." : strValue;
|
||||
return `${snakeToTitleCase(toolName)}: ${truncated}`;
|
||||
}
|
||||
return snakeToTitleCase(toolName);
|
||||
};
|
||||
|
||||
const snakeToTitleCase = (str: string): string => {
|
||||
return str
|
||||
.split("_")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
||||
.join(" ");
|
||||
};
|
||||
|
||||
// ============ 可展开面板组件 ============
|
||||
|
||||
interface ExpandablePanelProps {
|
||||
label: React.ReactNode;
|
||||
isStartExpanded?: boolean;
|
||||
isForceExpand?: boolean;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ExpandablePanel: React.FC<ExpandablePanelProps> = ({
|
||||
label,
|
||||
isStartExpanded = false,
|
||||
isForceExpand,
|
||||
children,
|
||||
className = "",
|
||||
}) => {
|
||||
const [isExpandedState, setIsExpanded] = useState<boolean | null>(null);
|
||||
const isExpanded =
|
||||
isExpandedState === null ? isStartExpanded : isExpandedState;
|
||||
const toggleExpand = () => setIsExpanded(!isExpanded);
|
||||
|
||||
useEffect(() => {
|
||||
if (isForceExpand) setIsExpanded(true);
|
||||
}, [isForceExpand]);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<button
|
||||
onClick={toggleExpand}
|
||||
className="group w-full flex justify-between items-center pr-2 py-2 px-3 transition-colors rounded-none hover:bg-muted/50"
|
||||
>
|
||||
<span className="flex items-center text-sm truncate flex-1 min-w-0">
|
||||
{label}
|
||||
</span>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"w-4 h-4 text-muted-foreground group-hover:opacity-100 transition-transform opacity-70",
|
||||
isExpanded && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
{isExpanded && <div>{children}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============ 工具参数显示 ============
|
||||
|
||||
interface ToolCallArgumentsProps {
|
||||
args: Record<string, ToolCallArgumentValue>;
|
||||
}
|
||||
|
||||
const ToolCallArguments: React.FC<ToolCallArgumentsProps> = ({ args }) => {
|
||||
const [expandedKeys, setExpandedKeys] = useState<Record<string, boolean>>({});
|
||||
|
||||
const toggleKey = (key: string) => {
|
||||
setExpandedKeys((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||
};
|
||||
|
||||
const renderValue = (key: string, value: ToolCallArgumentValue) => {
|
||||
if (typeof value === "string") {
|
||||
const needsExpansion = value.length > 60;
|
||||
const isExpanded = expandedKeys[key];
|
||||
|
||||
if (!needsExpansion) {
|
||||
return (
|
||||
<div className="text-sm mb-2">
|
||||
<div className="flex flex-row">
|
||||
<span className="text-muted-foreground min-w-[120px] shrink-0">
|
||||
{key}
|
||||
</span>
|
||||
<span className="text-foreground/70 break-all">{value}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("text-sm mb-2", !isExpanded && "truncate min-w-0")}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-row items-start",
|
||||
!isExpanded && "truncate min-w-0",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
onClick={() => toggleKey(key)}
|
||||
className="flex text-left text-muted-foreground min-w-[120px] shrink-0 hover:text-foreground"
|
||||
>
|
||||
{key}
|
||||
</button>
|
||||
<div className={cn("flex-1 min-w-0", !isExpanded && "truncate")}>
|
||||
{isExpanded ? (
|
||||
<MarkdownRenderer content={`\`\`\`\n${value}\n\`\`\``} />
|
||||
) : (
|
||||
<button
|
||||
onClick={() => toggleKey(key)}
|
||||
className="text-left text-foreground/70 truncate w-full hover:text-foreground"
|
||||
>
|
||||
{value}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 处理非字符串值
|
||||
const content = Array.isArray(value)
|
||||
? value
|
||||
.map((item, index) => `${index + 1}. ${JSON.stringify(item)}`)
|
||||
.join("\n")
|
||||
: typeof value === "object" && value !== null
|
||||
? JSON.stringify(value, null, 2)
|
||||
: String(value);
|
||||
|
||||
return (
|
||||
<div className="mb-2">
|
||||
<div className="flex flex-row text-sm">
|
||||
<span className="text-muted-foreground min-w-[120px] shrink-0">
|
||||
{key}
|
||||
</span>
|
||||
<pre className="whitespace-pre-wrap text-foreground/70 overflow-x-auto max-w-full font-mono text-xs">
|
||||
{content}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="py-2 px-4">
|
||||
{Object.entries(args).map(([key, value]) => (
|
||||
<div key={key}>{renderValue(key, value)}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============ 工具日志显示 ============
|
||||
|
||||
interface ToolLogsViewProps {
|
||||
logs: string[];
|
||||
working: boolean;
|
||||
isStartExpanded?: boolean;
|
||||
}
|
||||
|
||||
const ToolLogsView: React.FC<ToolLogsViewProps> = ({
|
||||
logs,
|
||||
working,
|
||||
isStartExpanded = false,
|
||||
}) => {
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (boxRef.current) {
|
||||
boxRef.current.scrollTop = boxRef.current.scrollHeight;
|
||||
}
|
||||
}, [logs.length]);
|
||||
|
||||
return (
|
||||
<ExpandablePanel
|
||||
label={
|
||||
<span className="pl-2 py-1 text-sm flex items-center gap-2">
|
||||
<span>日志</span>
|
||||
{working && <Loader2 className="w-3 h-3 animate-spin text-primary" />}
|
||||
</span>
|
||||
}
|
||||
isStartExpanded={isStartExpanded}
|
||||
>
|
||||
<div
|
||||
ref={boxRef}
|
||||
className={cn(
|
||||
"flex flex-col items-start space-y-1 overflow-y-auto p-3 font-mono text-xs",
|
||||
working ? "max-h-16" : "max-h-80",
|
||||
)}
|
||||
>
|
||||
{logs.map((log, i) => (
|
||||
<span key={i} className="text-muted-foreground">
|
||||
{log}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</ExpandablePanel>
|
||||
);
|
||||
};
|
||||
|
||||
// ============ 工具结果显示 ============
|
||||
|
||||
interface ToolResultViewProps {
|
||||
result: string;
|
||||
isError?: boolean;
|
||||
isStartExpanded?: boolean;
|
||||
}
|
||||
|
||||
const ToolResultView: React.FC<ToolResultViewProps> = ({
|
||||
result,
|
||||
isError = false,
|
||||
isStartExpanded = false,
|
||||
}) => {
|
||||
return (
|
||||
<ExpandablePanel
|
||||
label={
|
||||
<span
|
||||
className={cn("pl-2 py-1 text-sm", isError && "text-destructive")}
|
||||
>
|
||||
{isError ? "错误" : "输出"}
|
||||
</span>
|
||||
}
|
||||
isStartExpanded={isStartExpanded}
|
||||
>
|
||||
<div className="p-3 max-h-80 overflow-y-auto">
|
||||
<pre
|
||||
className={cn(
|
||||
"whitespace-pre-wrap font-mono text-xs break-all",
|
||||
isError ? "text-destructive" : "text-foreground/80",
|
||||
)}
|
||||
>
|
||||
{result || "(无输出)"}
|
||||
</pre>
|
||||
</div>
|
||||
</ExpandablePanel>
|
||||
);
|
||||
};
|
||||
|
||||
// ============ 主组件 ============
|
||||
|
||||
interface ToolCallDisplayProps {
|
||||
toolCall: ToolCallState;
|
||||
defaultExpanded?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 单个工具调用显示组件
|
||||
*/
|
||||
export const ToolCallDisplay: React.FC<ToolCallDisplayProps> = ({
|
||||
toolCall,
|
||||
defaultExpanded = false,
|
||||
}) => {
|
||||
const [expanded, setExpanded] = useState(defaultExpanded);
|
||||
const IconComponent = getToolIcon(toolCall.name);
|
||||
|
||||
// 计算执行时间
|
||||
const executionTime =
|
||||
toolCall.endTime && toolCall.startTime
|
||||
? Math.round(
|
||||
(toolCall.endTime.getTime() - toolCall.startTime.getTime()) / 1000,
|
||||
)
|
||||
: null;
|
||||
// 解析参数
|
||||
const parsedArgs = useMemo(() => {
|
||||
if (!toolCall.arguments) return {};
|
||||
try {
|
||||
return JSON.parse(toolCall.arguments);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}, [toolCall.arguments]);
|
||||
|
||||
// 生成工具描述
|
||||
const toolDescription = useMemo(
|
||||
() => getToolDescription(toolCall.name, parsedArgs),
|
||||
[toolCall.name, parsedArgs],
|
||||
);
|
||||
|
||||
// 计算执行时间
|
||||
const executionTime = useMemo(() => {
|
||||
if (toolCall.endTime && toolCall.startTime) {
|
||||
const ms = toolCall.endTime.getTime() - toolCall.startTime.getTime();
|
||||
return ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`;
|
||||
}
|
||||
return null;
|
||||
}, [toolCall.startTime, toolCall.endTime]);
|
||||
|
||||
const hasArguments = Object.keys(parsedArgs).length > 0;
|
||||
const hasResult = toolCall.status !== "running" && toolCall.result;
|
||||
const hasLogs = toolCall.logs && toolCall.logs.length > 0;
|
||||
const isRunning = toolCall.status === "running";
|
||||
|
||||
// 工具标签
|
||||
const toolLabel = (
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
<div className="relative inline-block">
|
||||
<IconComponent className="w-4 h-4 shrink-0" />
|
||||
<ToolCallStatusIndicator status={toolCall.status} />
|
||||
</div>
|
||||
<span className="truncate flex-1 min-w-0">{toolDescription}</span>
|
||||
{executionTime && (
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
{executionTime}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<ToolCallContainer>
|
||||
<ToolCallHeader
|
||||
$status={toolCall.status}
|
||||
onClick={() => hasResult && setExpanded(!expanded)}
|
||||
<div className="w-full text-sm rounded-lg overflow-hidden border border-border bg-muted/30">
|
||||
<ExpandablePanel
|
||||
label={toolLabel}
|
||||
isStartExpanded={defaultExpanded || isRunning}
|
||||
isForceExpand={isRunning}
|
||||
>
|
||||
<ToolIcon $status={toolCall.status}>
|
||||
{toolCall.status === "running" ? (
|
||||
<SpinningLoader size={14} />
|
||||
) : toolCall.status === "failed" ? (
|
||||
<X size={14} />
|
||||
) : (
|
||||
<IconComponent size={14} />
|
||||
)}
|
||||
</ToolIcon>
|
||||
|
||||
<ToolName>{getToolDisplayName(toolCall.name)}</ToolName>
|
||||
|
||||
{executionTime !== null && (
|
||||
<ExecutionTime>{executionTime}s</ExecutionTime>
|
||||
{/* 工具参数 */}
|
||||
{hasArguments && (
|
||||
<div className="border-t border-border">
|
||||
<ExpandablePanel
|
||||
label={
|
||||
<span className="pl-2 text-sm text-muted-foreground">参数</span>
|
||||
}
|
||||
isStartExpanded={false}
|
||||
>
|
||||
<ToolCallArguments args={parsedArgs} />
|
||||
</ExpandablePanel>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ToolStatus $status={toolCall.status}>
|
||||
{toolCall.status === "completed" && <Check size={12} />}
|
||||
{getStatusText(toolCall.status)}
|
||||
</ToolStatus>
|
||||
{/* 执行日志 */}
|
||||
{hasLogs && (
|
||||
<div className="border-t border-border">
|
||||
<ToolLogsView
|
||||
logs={toolCall.logs!}
|
||||
working={isRunning}
|
||||
isStartExpanded={isRunning}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 执行结果 */}
|
||||
{hasResult && (
|
||||
<ExpandIcon>
|
||||
{expanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||
</ExpandIcon>
|
||||
<div className="border-t border-border">
|
||||
<ToolResultView
|
||||
result={toolCall.result?.error || toolCall.result?.output || ""}
|
||||
isError={!!toolCall.result?.error}
|
||||
isStartExpanded={!!toolCall.result?.error}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</ToolCallHeader>
|
||||
|
||||
{hasResult && (
|
||||
<ToolResultPanel $expanded={expanded}>
|
||||
{toolCall.result?.error ? (
|
||||
<ErrorContent>{toolCall.result.error}</ErrorContent>
|
||||
) : (
|
||||
<ResultContent>
|
||||
{toolCall.result?.output || "(无输出)"}
|
||||
</ResultContent>
|
||||
)}
|
||||
</ToolResultPanel>
|
||||
)}
|
||||
</ToolCallContainer>
|
||||
</ExpandablePanel>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============ 工具调用列表 ============
|
||||
|
||||
interface ToolCallListProps {
|
||||
toolCalls: ToolCallState[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 工具调用列表组件
|
||||
*/
|
||||
export const ToolCallList: React.FC<ToolCallListProps> = ({ toolCalls }) => {
|
||||
if (!toolCalls || toolCalls.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{toolCalls.map((tc) => (
|
||||
<ToolCallDisplay key={tc.id} toolCall={tc} />
|
||||
))}
|
||||
@@ -285,4 +547,7 @@ export const ToolCallList: React.FC<ToolCallListProps> = ({ toolCalls }) => {
|
||||
);
|
||||
};
|
||||
|
||||
// 导出别名,用于交错显示模式
|
||||
export const ToolCallItem = ToolCallDisplay;
|
||||
|
||||
export default ToolCallDisplay;
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
type SessionInfo,
|
||||
type StreamEvent,
|
||||
} from "@/lib/api/agent";
|
||||
import { Message, MessageImage, PROVIDER_CONFIG } from "../types";
|
||||
import { Message, MessageImage, ContentPart, PROVIDER_CONFIG } from "../types";
|
||||
|
||||
/** 话题(会话)信息 */
|
||||
export interface Topic {
|
||||
@@ -234,6 +234,7 @@ export function useAgentChat() {
|
||||
timestamp: new Date(),
|
||||
isThinking: true,
|
||||
thinkingContent: thinkingText,
|
||||
contentParts: [], // 初始化交错内容列表
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, userMsg, assistantMsg]);
|
||||
@@ -243,12 +244,46 @@ export function useAgentChat() {
|
||||
let accumulatedContent = "";
|
||||
let unlisten: UnlistenFn | null = null;
|
||||
|
||||
/**
|
||||
* 辅助函数:更新 contentParts,支持交错显示
|
||||
* - text_delta: 追加到最后一个 text 类型,或创建新的 text 类型
|
||||
* - tool_start: 添加新的 tool_use 类型
|
||||
* - tool_end: 更新对应的 tool_use 状态
|
||||
*/
|
||||
const appendTextToParts = (
|
||||
parts: ContentPart[],
|
||||
text: string,
|
||||
): ContentPart[] => {
|
||||
const newParts = [...parts];
|
||||
const lastPart = newParts[newParts.length - 1];
|
||||
|
||||
if (lastPart && lastPart.type === "text") {
|
||||
// 追加到最后一个 text 类型
|
||||
newParts[newParts.length - 1] = {
|
||||
type: "text",
|
||||
text: lastPart.text + text,
|
||||
};
|
||||
} else {
|
||||
// 创建新的 text 类型
|
||||
newParts.push({ type: "text", text });
|
||||
}
|
||||
return newParts;
|
||||
};
|
||||
|
||||
try {
|
||||
// 2. 创建唯一事件名称(流式 API 不需要 session)
|
||||
// 2. 确保有一个活跃的 session(用于保持上下文)
|
||||
const activeSessionId = await _ensureSession();
|
||||
if (!activeSessionId) {
|
||||
throw new Error("无法创建或获取会话");
|
||||
}
|
||||
|
||||
// 3. 创建唯一事件名称
|
||||
const eventName = `agent_stream_${assistantMsgId}`;
|
||||
|
||||
// 4. 设置事件监听器(流式接收)
|
||||
console.log(`[AgentChat] 设置事件监听器: ${eventName}`);
|
||||
console.log(
|
||||
`[AgentChat] 设置事件监听器: ${eventName}, sessionId: ${activeSessionId}`,
|
||||
);
|
||||
unlisten = await listen<StreamEvent>(eventName, (event) => {
|
||||
console.log("[AgentChat] 收到事件:", eventName, event.payload);
|
||||
const data = parseStreamEvent(event.payload);
|
||||
@@ -260,7 +295,7 @@ export function useAgentChat() {
|
||||
|
||||
switch (data.type) {
|
||||
case "text_delta":
|
||||
// 累积文本并实时更新 UI
|
||||
// 累积文本并实时更新 UI(同时更新 content 和 contentParts)
|
||||
accumulatedContent += data.text;
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
@@ -269,6 +304,11 @@ export function useAgentChat() {
|
||||
...msg,
|
||||
content: accumulatedContent,
|
||||
thinkingContent: undefined,
|
||||
// 更新 contentParts,支持交错显示
|
||||
contentParts: appendTextToParts(
|
||||
msg.contentParts || [],
|
||||
data.text,
|
||||
),
|
||||
}
|
||||
: msg,
|
||||
),
|
||||
@@ -276,7 +316,27 @@ export function useAgentChat() {
|
||||
break;
|
||||
|
||||
case "done":
|
||||
// 完成,标记 isThinking 为 false
|
||||
// 完成一次 API 响应,但工具循环可能还在继续
|
||||
// 不要取消监听,继续等待更多事件
|
||||
console.log("[AgentChat] 收到 done 事件,工具循环可能还在继续...");
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
msg.id === assistantMsgId
|
||||
? {
|
||||
...msg,
|
||||
// 保持 isThinking 为 true,直到收到 final_done 或 error
|
||||
content: accumulatedContent || msg.content,
|
||||
}
|
||||
: msg,
|
||||
),
|
||||
);
|
||||
// 注意:不要在这里 setIsSending(false) 或 unlisten()
|
||||
// 工具循环会继续发送事件
|
||||
break;
|
||||
|
||||
case "final_done":
|
||||
// 整个对话完成(包括所有工具调用)
|
||||
console.log("[AgentChat] 收到 final_done 事件,对话完成");
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
msg.id === assistantMsgId
|
||||
@@ -316,58 +376,91 @@ export function useAgentChat() {
|
||||
}
|
||||
break;
|
||||
|
||||
case "tool_start":
|
||||
// 工具开始执行 - 添加到工具调用列表
|
||||
case "tool_start": {
|
||||
// 工具开始执行 - 添加到工具调用列表和 contentParts
|
||||
console.log(`[Tool Start] ${data.tool_name} (${data.tool_id})`);
|
||||
const newToolCall = {
|
||||
id: data.tool_id,
|
||||
name: data.tool_name,
|
||||
arguments: data.arguments,
|
||||
status: "running" as const,
|
||||
startTime: new Date(),
|
||||
};
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
msg.id === assistantMsgId
|
||||
? {
|
||||
...msg,
|
||||
toolCalls: [
|
||||
...(msg.toolCalls || []),
|
||||
{
|
||||
id: data.tool_id,
|
||||
name: data.tool_name,
|
||||
status: "running" as const,
|
||||
startTime: new Date(),
|
||||
},
|
||||
toolCalls: [...(msg.toolCalls || []), newToolCall],
|
||||
// 添加到 contentParts,支持交错显示
|
||||
contentParts: [
|
||||
...(msg.contentParts || []),
|
||||
{ type: "tool_use" as const, toolCall: newToolCall },
|
||||
],
|
||||
}
|
||||
: msg,
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case "tool_end":
|
||||
// 工具执行完成 - 更新工具调用状态
|
||||
case "tool_end": {
|
||||
// 工具执行完成 - 更新工具调用状态和 contentParts
|
||||
console.log(`[Tool End] ${data.tool_id}`);
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
msg.id === assistantMsgId
|
||||
? {
|
||||
...msg,
|
||||
toolCalls: (msg.toolCalls || []).map((tc) =>
|
||||
tc.id === data.tool_id
|
||||
? {
|
||||
...tc,
|
||||
status: data.result.success
|
||||
? ("completed" as const)
|
||||
: ("failed" as const),
|
||||
result: data.result,
|
||||
endTime: new Date(),
|
||||
}
|
||||
: tc,
|
||||
),
|
||||
prev.map((msg) => {
|
||||
if (msg.id !== assistantMsgId) return msg;
|
||||
|
||||
// 更新 toolCalls
|
||||
const updatedToolCalls = (msg.toolCalls || []).map((tc) =>
|
||||
tc.id === data.tool_id
|
||||
? {
|
||||
...tc,
|
||||
status: data.result.success
|
||||
? ("completed" as const)
|
||||
: ("failed" as const),
|
||||
result: data.result,
|
||||
endTime: new Date(),
|
||||
}
|
||||
: tc,
|
||||
);
|
||||
|
||||
// 更新 contentParts 中对应的 tool_use
|
||||
const updatedContentParts = (msg.contentParts || []).map(
|
||||
(part) => {
|
||||
if (
|
||||
part.type === "tool_use" &&
|
||||
part.toolCall.id === data.tool_id
|
||||
) {
|
||||
return {
|
||||
...part,
|
||||
toolCall: {
|
||||
...part.toolCall,
|
||||
status: data.result.success
|
||||
? ("completed" as const)
|
||||
: ("failed" as const),
|
||||
result: data.result,
|
||||
endTime: new Date(),
|
||||
},
|
||||
};
|
||||
}
|
||||
: msg,
|
||||
),
|
||||
return part;
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
...msg,
|
||||
toolCalls: updatedToolCalls,
|
||||
contentParts: updatedContentParts,
|
||||
};
|
||||
}),
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 5. 发送流式请求
|
||||
// 5. 发送流式请求(传递 sessionId 以保持上下文)
|
||||
const imagesToSend =
|
||||
images.length > 0
|
||||
? images.map((img) => ({ data: img.data, media_type: img.mediaType }))
|
||||
@@ -376,6 +469,7 @@ export function useAgentChat() {
|
||||
await sendAgentMessageStream(
|
||||
content,
|
||||
eventName,
|
||||
activeSessionId, // 传递 sessionId 以保持上下文
|
||||
model || undefined,
|
||||
imagesToSend,
|
||||
);
|
||||
|
||||
@@ -5,19 +5,37 @@ export interface MessageImage {
|
||||
mediaType: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 内容片段类型(用于交错显示)
|
||||
*
|
||||
* 参考 goose 框架的 MessageContent 设计:
|
||||
* - text: 文本内容片段
|
||||
* - tool_use: 工具调用(包含状态和结果)
|
||||
*/
|
||||
export type ContentPart =
|
||||
| { type: "text"; text: string }
|
||||
| { type: "tool_use"; toolCall: ToolCallState };
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
/** 完整文本内容(向后兼容) */
|
||||
content: string;
|
||||
images?: MessageImage[];
|
||||
timestamp: Date;
|
||||
isThinking?: boolean;
|
||||
thinkingContent?: string;
|
||||
search_results?: any[]; // For potential future use
|
||||
/** 工具调用列表(assistant 消息可能包含) */
|
||||
/** 工具调用列表(assistant 消息可能包含) - 向后兼容 */
|
||||
toolCalls?: ToolCallState[];
|
||||
/** Token 使用量(响应完成后) */
|
||||
usage?: TokenUsage;
|
||||
/**
|
||||
* 交错内容列表(按事件到达顺序排列)
|
||||
* 如果存在且非空,StreamingRenderer 会按顺序渲染
|
||||
* 否则回退到 content + toolCalls 渲染方式
|
||||
*/
|
||||
contentParts?: ContentPart[];
|
||||
}
|
||||
|
||||
export interface ChatSession {
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
/**
|
||||
* @file CredentialCard 属性测试
|
||||
* @description 测试 OAuth 凭证卡片信息完整性
|
||||
* @module components/provider-pool/CredentialCard.test
|
||||
*
|
||||
* **Feature: provider-ui-refactor**
|
||||
* **Property 3: OAuth 凭证卡片信息完整性**
|
||||
* **Validates: Requirements 2.2**
|
||||
*/
|
||||
|
||||
import { describe, expect } from "vitest";
|
||||
import { test } from "@fast-check/vitest";
|
||||
import * as fc from "fast-check";
|
||||
import type {
|
||||
CredentialDisplay,
|
||||
PoolProviderType,
|
||||
CredentialSource,
|
||||
} from "@/lib/api/providerPool";
|
||||
|
||||
// ============================================================================
|
||||
// 辅助函数(用于测试)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 提取 OAuth 凭证卡片显示信息
|
||||
* 用于属性测试验证 Requirements 2.2
|
||||
*
|
||||
* @param credential OAuth 凭证数据
|
||||
* @returns 卡片显示信息
|
||||
*/
|
||||
export function extractOAuthCardDisplayInfo(credential: CredentialDisplay): {
|
||||
hasHealthStatus: boolean;
|
||||
hasUsageCount: boolean;
|
||||
hasActionButtons: boolean;
|
||||
healthStatus: "healthy" | "unhealthy" | "disabled";
|
||||
usageCount: number;
|
||||
errorCount: number;
|
||||
} {
|
||||
// 健康状态:根据 is_healthy 和 is_disabled 判断
|
||||
let healthStatus: "healthy" | "unhealthy" | "disabled";
|
||||
if (credential.is_disabled) {
|
||||
healthStatus = "disabled";
|
||||
} else if (credential.is_healthy) {
|
||||
healthStatus = "healthy";
|
||||
} else {
|
||||
healthStatus = "unhealthy";
|
||||
}
|
||||
|
||||
return {
|
||||
// 健康状态始终存在(通过 is_healthy 和 is_disabled 字段)
|
||||
hasHealthStatus:
|
||||
typeof credential.is_healthy === "boolean" &&
|
||||
typeof credential.is_disabled === "boolean",
|
||||
// 使用次数始终存在(通过 usage_count 字段)
|
||||
hasUsageCount: typeof credential.usage_count === "number",
|
||||
// 操作按钮始终存在(卡片组件固定渲染)
|
||||
hasActionButtons: true,
|
||||
healthStatus,
|
||||
usageCount: credential.usage_count,
|
||||
errorCount: credential.error_count,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 OAuth 凭证卡片是否包含所有必要信息
|
||||
*
|
||||
* @param credential OAuth 凭证数据
|
||||
* @returns 是否包含所有必要信息
|
||||
*/
|
||||
export function isOAuthCardComplete(credential: CredentialDisplay): boolean {
|
||||
const info = extractOAuthCardDisplayInfo(credential);
|
||||
return info.hasHealthStatus && info.hasUsageCount && info.hasActionButtons;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 OAuth 凭证的操作按钮列表
|
||||
* 根据凭证类型返回应该显示的操作按钮
|
||||
*
|
||||
* @param credential OAuth 凭证数据
|
||||
* @returns 操作按钮列表
|
||||
*/
|
||||
export function getOAuthCardActionButtons(
|
||||
credential: CredentialDisplay,
|
||||
): string[] {
|
||||
const buttons: string[] = [
|
||||
"toggle", // 启用/禁用
|
||||
"edit", // 编辑
|
||||
"checkHealth", // 检测健康
|
||||
"reset", // 重置
|
||||
"delete", // 删除
|
||||
];
|
||||
|
||||
// OAuth 类型凭证额外显示刷新 Token 按钮
|
||||
if (credential.credential_type.includes("oauth")) {
|
||||
buttons.push("refreshToken");
|
||||
}
|
||||
|
||||
return buttons;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 测试数据生成器
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 生成有效的 ISO 日期字符串
|
||||
*/
|
||||
const validDateArbitrary = fc
|
||||
.integer({
|
||||
min: new Date("2020-01-01").getTime(),
|
||||
max: new Date("2030-12-31").getTime(),
|
||||
})
|
||||
.map((timestamp) => new Date(timestamp).toISOString());
|
||||
|
||||
/**
|
||||
* OAuth Provider 类型
|
||||
*/
|
||||
const oauthProviderTypes: PoolProviderType[] = [
|
||||
"kiro",
|
||||
"gemini",
|
||||
"qwen",
|
||||
"antigravity",
|
||||
"codex",
|
||||
"claude_oauth",
|
||||
"iflow",
|
||||
];
|
||||
|
||||
/**
|
||||
* OAuth 凭证类型
|
||||
*/
|
||||
const oauthCredentialTypes = [
|
||||
"kiro_oauth",
|
||||
"gemini_oauth",
|
||||
"qwen_oauth",
|
||||
"antigravity_oauth",
|
||||
"codex_oauth",
|
||||
"claude_oauth",
|
||||
"iflow_oauth",
|
||||
];
|
||||
|
||||
/**
|
||||
* 凭证来源类型
|
||||
*/
|
||||
const credentialSourceArbitrary: fc.Arbitrary<CredentialSource> =
|
||||
fc.constantFrom("manual", "imported", "private");
|
||||
|
||||
/**
|
||||
* 生成随机 OAuth 凭证显示数据
|
||||
*/
|
||||
const oauthCredentialArbitrary: fc.Arbitrary<CredentialDisplay> = fc.record({
|
||||
uuid: fc.uuid(),
|
||||
provider_type: fc.constantFrom(...oauthProviderTypes),
|
||||
credential_type: fc.constantFrom(...oauthCredentialTypes),
|
||||
name: fc.option(fc.string({ minLength: 1, maxLength: 100 }), {
|
||||
nil: undefined,
|
||||
}),
|
||||
display_credential: fc.string({ minLength: 1, maxLength: 50 }),
|
||||
is_healthy: fc.boolean(),
|
||||
is_disabled: fc.boolean(),
|
||||
check_health: fc.boolean(),
|
||||
check_model_name: fc.option(fc.string({ minLength: 1, maxLength: 50 }), {
|
||||
nil: undefined,
|
||||
}),
|
||||
not_supported_models: fc.array(fc.string({ minLength: 1, maxLength: 50 }), {
|
||||
maxLength: 5,
|
||||
}),
|
||||
usage_count: fc.nat({ max: 100000 }),
|
||||
error_count: fc.nat({ max: 10000 }),
|
||||
last_used: fc.option(validDateArbitrary, { nil: undefined }),
|
||||
last_error_time: fc.option(validDateArbitrary, { nil: undefined }),
|
||||
last_error_message: fc.option(fc.string({ minLength: 1, maxLength: 200 }), {
|
||||
nil: undefined,
|
||||
}),
|
||||
last_health_check_time: fc.option(validDateArbitrary, { nil: undefined }),
|
||||
last_health_check_model: fc.option(
|
||||
fc.string({ minLength: 1, maxLength: 50 }),
|
||||
{ nil: undefined },
|
||||
),
|
||||
oauth_status: fc.option(
|
||||
fc.record({
|
||||
has_access_token: fc.boolean(),
|
||||
has_refresh_token: fc.boolean(),
|
||||
is_token_valid: fc.boolean(),
|
||||
expiry_info: fc.option(fc.string({ minLength: 1, maxLength: 100 }), {
|
||||
nil: undefined,
|
||||
}),
|
||||
creds_path: fc.string({ minLength: 1, maxLength: 200 }),
|
||||
}),
|
||||
{ nil: undefined },
|
||||
),
|
||||
token_cache_status: fc.option(
|
||||
fc.record({
|
||||
has_cached_token: fc.boolean(),
|
||||
is_valid: fc.boolean(),
|
||||
is_expiring_soon: fc.boolean(),
|
||||
expiry_time: fc.option(validDateArbitrary, { nil: undefined }),
|
||||
last_refresh: fc.option(validDateArbitrary, { nil: undefined }),
|
||||
refresh_error_count: fc.nat({ max: 100 }),
|
||||
last_refresh_error: fc.option(
|
||||
fc.string({ minLength: 1, maxLength: 200 }),
|
||||
{ nil: undefined },
|
||||
),
|
||||
}),
|
||||
{ nil: undefined },
|
||||
),
|
||||
created_at: validDateArbitrary,
|
||||
updated_at: validDateArbitrary,
|
||||
source: credentialSourceArbitrary,
|
||||
base_url: fc.option(fc.webUrl(), { nil: undefined }),
|
||||
api_key: fc.option(fc.string({ minLength: 1, maxLength: 100 }), {
|
||||
nil: undefined,
|
||||
}),
|
||||
proxy_url: fc.option(fc.webUrl(), { nil: undefined }),
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Property 3: OAuth 凭证卡片信息完整性
|
||||
// ============================================================================
|
||||
|
||||
describe("Property 3: OAuth 凭证卡片信息完整性", () => {
|
||||
/**
|
||||
* Property 3: OAuth 凭证卡片信息完整性
|
||||
*
|
||||
* *对于任意* OAuth 凭证,渲染后的卡片应包含健康状态、使用次数和操作按钮
|
||||
*
|
||||
* **Validates: Requirements 2.2**
|
||||
*/
|
||||
test.prop([oauthCredentialArbitrary], { numRuns: 100 })(
|
||||
"每个 OAuth 凭证卡片应包含健康状态、使用次数和操作按钮",
|
||||
(credential: CredentialDisplay) => {
|
||||
const displayInfo = extractOAuthCardDisplayInfo(credential);
|
||||
|
||||
// 验证健康状态存在
|
||||
expect(displayInfo.hasHealthStatus).toBe(true);
|
||||
|
||||
// 验证使用次数存在
|
||||
expect(displayInfo.hasUsageCount).toBe(true);
|
||||
|
||||
// 验证操作按钮存在
|
||||
expect(displayInfo.hasActionButtons).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
test.prop([oauthCredentialArbitrary], { numRuns: 100 })(
|
||||
"健康状态应为 healthy、unhealthy 或 disabled 之一",
|
||||
(credential: CredentialDisplay) => {
|
||||
const displayInfo = extractOAuthCardDisplayInfo(credential);
|
||||
|
||||
expect(["healthy", "unhealthy", "disabled"]).toContain(
|
||||
displayInfo.healthStatus,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test.prop([oauthCredentialArbitrary], { numRuns: 100 })(
|
||||
"使用次数应为非负整数",
|
||||
(credential: CredentialDisplay) => {
|
||||
const displayInfo = extractOAuthCardDisplayInfo(credential);
|
||||
|
||||
expect(Number.isInteger(displayInfo.usageCount)).toBe(true);
|
||||
expect(displayInfo.usageCount).toBeGreaterThanOrEqual(0);
|
||||
},
|
||||
);
|
||||
|
||||
test.prop([oauthCredentialArbitrary], { numRuns: 100 })(
|
||||
"错误次数应为非负整数",
|
||||
(credential: CredentialDisplay) => {
|
||||
const displayInfo = extractOAuthCardDisplayInfo(credential);
|
||||
|
||||
expect(Number.isInteger(displayInfo.errorCount)).toBe(true);
|
||||
expect(displayInfo.errorCount).toBeGreaterThanOrEqual(0);
|
||||
},
|
||||
);
|
||||
|
||||
test.prop([oauthCredentialArbitrary], { numRuns: 100 })(
|
||||
"OAuth 凭证应包含刷新 Token 操作按钮",
|
||||
(credential: CredentialDisplay) => {
|
||||
const buttons = getOAuthCardActionButtons(credential);
|
||||
|
||||
// OAuth 凭证应该有刷新 Token 按钮
|
||||
expect(buttons).toContain("refreshToken");
|
||||
},
|
||||
);
|
||||
|
||||
test.prop([oauthCredentialArbitrary], { numRuns: 100 })(
|
||||
"所有凭证应包含基本操作按钮",
|
||||
(credential: CredentialDisplay) => {
|
||||
const buttons = getOAuthCardActionButtons(credential);
|
||||
|
||||
// 所有凭证都应该有这些基本按钮
|
||||
expect(buttons).toContain("toggle");
|
||||
expect(buttons).toContain("edit");
|
||||
expect(buttons).toContain("checkHealth");
|
||||
expect(buttons).toContain("reset");
|
||||
expect(buttons).toContain("delete");
|
||||
},
|
||||
);
|
||||
|
||||
test.prop([oauthCredentialArbitrary], { numRuns: 100 })(
|
||||
"禁用状态应正确反映在健康状态中",
|
||||
(credential: CredentialDisplay) => {
|
||||
const displayInfo = extractOAuthCardDisplayInfo(credential);
|
||||
|
||||
if (credential.is_disabled) {
|
||||
expect(displayInfo.healthStatus).toBe("disabled");
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test.prop([oauthCredentialArbitrary], { numRuns: 100 })(
|
||||
"健康凭证(未禁用)应显示为 healthy",
|
||||
(credential: CredentialDisplay) => {
|
||||
const displayInfo = extractOAuthCardDisplayInfo(credential);
|
||||
|
||||
if (!credential.is_disabled && credential.is_healthy) {
|
||||
expect(displayInfo.healthStatus).toBe("healthy");
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test.prop([oauthCredentialArbitrary], { numRuns: 100 })(
|
||||
"不健康凭证(未禁用)应显示为 unhealthy",
|
||||
(credential: CredentialDisplay) => {
|
||||
const displayInfo = extractOAuthCardDisplayInfo(credential);
|
||||
|
||||
if (!credential.is_disabled && !credential.is_healthy) {
|
||||
expect(displayInfo.healthStatus).toBe("unhealthy");
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// 边界情况测试
|
||||
// ============================================================================
|
||||
|
||||
describe("OAuth 凭证卡片边界情况", () => {
|
||||
test("使用次数为 0 的凭证应正确显示", () => {
|
||||
const credential: CredentialDisplay = {
|
||||
uuid: "test-uuid",
|
||||
provider_type: "kiro",
|
||||
credential_type: "kiro_oauth",
|
||||
display_credential: "test@example.com",
|
||||
is_healthy: true,
|
||||
is_disabled: false,
|
||||
check_health: true,
|
||||
not_supported_models: [],
|
||||
usage_count: 0,
|
||||
error_count: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
source: "manual",
|
||||
};
|
||||
|
||||
const displayInfo = extractOAuthCardDisplayInfo(credential);
|
||||
expect(displayInfo.usageCount).toBe(0);
|
||||
expect(displayInfo.hasUsageCount).toBe(true);
|
||||
});
|
||||
|
||||
test("高使用次数的凭证应正确显示", () => {
|
||||
const credential: CredentialDisplay = {
|
||||
uuid: "test-uuid",
|
||||
provider_type: "gemini",
|
||||
credential_type: "gemini_oauth",
|
||||
display_credential: "test@example.com",
|
||||
is_healthy: true,
|
||||
is_disabled: false,
|
||||
check_health: true,
|
||||
not_supported_models: [],
|
||||
usage_count: 999999,
|
||||
error_count: 100,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
source: "imported",
|
||||
};
|
||||
|
||||
const displayInfo = extractOAuthCardDisplayInfo(credential);
|
||||
expect(displayInfo.usageCount).toBe(999999);
|
||||
expect(displayInfo.errorCount).toBe(100);
|
||||
});
|
||||
|
||||
test("完整的 OAuth 凭证应通过完整性检查", () => {
|
||||
const credential: CredentialDisplay = {
|
||||
uuid: "test-uuid",
|
||||
provider_type: "qwen",
|
||||
credential_type: "qwen_oauth",
|
||||
name: "Test Credential",
|
||||
display_credential: "test@example.com",
|
||||
is_healthy: true,
|
||||
is_disabled: false,
|
||||
check_health: true,
|
||||
not_supported_models: [],
|
||||
usage_count: 100,
|
||||
error_count: 5,
|
||||
last_used: new Date().toISOString(),
|
||||
last_health_check_time: new Date().toISOString(),
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
source: "manual",
|
||||
};
|
||||
|
||||
expect(isOAuthCardComplete(credential)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,19 @@
|
||||
import { useState, useEffect, forwardRef, useImperativeHandle } from "react";
|
||||
/**
|
||||
* @file ProviderPoolPage 组件
|
||||
* @description 凭证池管理页面,支持 OAuth 凭证卡片布局和 API Key 左右分栏布局
|
||||
* @module components/provider-pool/ProviderPoolPage
|
||||
*
|
||||
* **Feature: provider-ui-refactor**
|
||||
* **Validates: Requirements 1.1, 2.1, 2.2, 2.3**
|
||||
*/
|
||||
|
||||
import {
|
||||
useState,
|
||||
useEffect,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
useCallback,
|
||||
} from "react";
|
||||
import {
|
||||
RefreshCw,
|
||||
Plus,
|
||||
@@ -9,6 +24,7 @@ import {
|
||||
Download,
|
||||
} from "lucide-react";
|
||||
import { useProviderPool } from "@/hooks/useProviderPool";
|
||||
import { useApiKeyProvider } from "@/hooks/useApiKeyProvider";
|
||||
import { CredentialCard } from "./CredentialCard";
|
||||
import { CredentialCardContextMenu } from "./CredentialCardContextMenu";
|
||||
import { AddCredentialModal } from "./AddCredentialModal";
|
||||
@@ -19,6 +35,8 @@ import { getConfig, saveConfig, Config } from "@/hooks/useTauri";
|
||||
import { VertexAISection } from "./VertexAISection";
|
||||
import { AmpConfigSection } from "./AmpConfigSection";
|
||||
import { ProviderIcon } from "@/icons/providers";
|
||||
import { ApiKeyProviderSection, AddCustomProviderModal } from "./api-key";
|
||||
import type { AddCustomProviderRequest } from "@/lib/api/apiKeyProvider";
|
||||
import {
|
||||
getLocalKiroCredentialUuid,
|
||||
type PoolProviderType,
|
||||
@@ -41,13 +59,6 @@ const oauthProviderTypes: PoolProviderType[] = [
|
||||
"iflow",
|
||||
];
|
||||
|
||||
// API Key 类型凭证(直接填入 API Key)
|
||||
const apiKeyProviderTypes: PoolProviderType[] = [
|
||||
"openai",
|
||||
"claude",
|
||||
"gemini_api_key",
|
||||
];
|
||||
|
||||
// 配置类型 tab(非凭证池,存储在配置文件中)
|
||||
type ConfigTabType = "vertex" | "amp";
|
||||
|
||||
@@ -94,6 +105,10 @@ export const ProviderPoolPage = forwardRef<ProviderPoolPageRef>(
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
||||
const { errors, showError, showSuccess, dismissError } = useErrorDisplay();
|
||||
|
||||
// 添加自定义 Provider 模态框状态
|
||||
const [addCustomProviderModalOpen, setAddCustomProviderModalOpen] =
|
||||
useState(false);
|
||||
|
||||
const {
|
||||
overview,
|
||||
loading,
|
||||
@@ -112,6 +127,10 @@ export const ProviderPoolPage = forwardRef<ProviderPoolPageRef>(
|
||||
migratePrivateConfig,
|
||||
} = useProviderPool();
|
||||
|
||||
// API Key Provider Hook
|
||||
const { addCustomProvider, refresh: refreshApiKeyProviders } =
|
||||
useApiKeyProvider();
|
||||
|
||||
const [migrating, setMigrating] = useState(false);
|
||||
|
||||
// Kiro 本地活跃凭证 UUID
|
||||
@@ -148,13 +167,13 @@ export const ProviderPoolPage = forwardRef<ProviderPoolPageRef>(
|
||||
setConfigSaving(false);
|
||||
};
|
||||
|
||||
// 切换到配置 tab 或 apikey 分类时加载配置(apikey 分类包含 gemini_api)
|
||||
// 切换到配置 tab 时加载配置
|
||||
useEffect(() => {
|
||||
if (isConfigTab(activeTab) || activeCategory === "apikey") {
|
||||
if (isConfigTab(activeTab)) {
|
||||
loadConfig();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeTab, activeCategory]);
|
||||
}, [activeTab]);
|
||||
|
||||
// 获取本地活跃的 Kiro 凭证 UUID
|
||||
const fetchLocalActiveUuid = async () => {
|
||||
@@ -173,10 +192,13 @@ export const ProviderPoolPage = forwardRef<ProviderPoolPageRef>(
|
||||
if (activeTab === "kiro") {
|
||||
fetchLocalActiveUuid();
|
||||
}
|
||||
}, [activeTab, overview]); // 当切换到 Kiro tab 或凭证池数据变化时重新检测
|
||||
}, [activeTab, overview]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
refresh,
|
||||
refresh: () => {
|
||||
refresh();
|
||||
refreshApiKeyProviders();
|
||||
},
|
||||
}));
|
||||
|
||||
const handleDeleteClick = (uuid: string) => {
|
||||
@@ -189,8 +211,6 @@ export const ProviderPoolPage = forwardRef<ProviderPoolPageRef>(
|
||||
setDeleteConfirm(null);
|
||||
setDeletingCredentials((prev) => new Set(prev).add(uuid));
|
||||
try {
|
||||
// Pass activeTab (provider_type) to enable YAML config sync
|
||||
// 只有凭证池 tab 才传递 provider_type
|
||||
const providerType = !isConfigTab(activeTab)
|
||||
? (activeTab as PoolProviderType)
|
||||
: undefined;
|
||||
@@ -333,10 +353,19 @@ export const ProviderPoolPage = forwardRef<ProviderPoolPageRef>(
|
||||
return pool?.credentials?.length || 0;
|
||||
};
|
||||
|
||||
// Current tab data (仅用于凭证池 tab)
|
||||
const currentPool = !isConfigTab(activeTab)
|
||||
? getProviderOverview(activeTab as PoolProviderType)
|
||||
: null;
|
||||
// 添加自定义 Provider 处理
|
||||
const handleAddCustomProvider = useCallback(
|
||||
async (request: AddCustomProviderRequest) => {
|
||||
await addCustomProvider(request);
|
||||
},
|
||||
[addCustomProvider],
|
||||
);
|
||||
|
||||
// Current tab data (仅用于 OAuth 凭证 tab)
|
||||
const currentPool =
|
||||
!isConfigTab(activeTab) && activeCategory === "oauth"
|
||||
? getProviderOverview(activeTab as PoolProviderType)
|
||||
: null;
|
||||
const currentStats = currentPool?.stats;
|
||||
const currentCredentials = currentPool?.credentials || [];
|
||||
|
||||
@@ -381,19 +410,20 @@ export const ProviderPoolPage = forwardRef<ProviderPoolPageRef>(
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border bg-card text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
}`}
|
||||
data-testid="oauth-category-tab"
|
||||
>
|
||||
OAuth 凭证
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setActiveCategory("apikey");
|
||||
setActiveTab(apiKeyProviderTypes[0]);
|
||||
}}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-lg border transition-colors ${
|
||||
activeCategory === "apikey"
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border bg-card text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
}`}
|
||||
data-testid="apikey-category-tab"
|
||||
>
|
||||
API Key
|
||||
</button>
|
||||
@@ -407,12 +437,13 @@ export const ProviderPoolPage = forwardRef<ProviderPoolPageRef>(
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border bg-card text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
}`}
|
||||
data-testid="config-category-tab"
|
||||
>
|
||||
其他配置
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Provider Selection - 第二行:图标网格选择 */}
|
||||
{/* OAuth 凭证分类 - Provider 选择图标网格 */}
|
||||
{activeCategory === "oauth" && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{oauthProviderTypes.map((providerType) => {
|
||||
@@ -428,6 +459,7 @@ export const ProviderPoolPage = forwardRef<ProviderPoolPageRef>(
|
||||
? "border-primary bg-primary/10 text-primary shadow-sm"
|
||||
: "border-border bg-card hover:border-primary/50 hover:bg-muted text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
data-testid={`oauth-provider-${providerType}`}
|
||||
>
|
||||
<ProviderIcon providerType={providerType} size={20} />
|
||||
<span className="text-sm font-medium">
|
||||
@@ -440,6 +472,7 @@ export const ProviderPoolPage = forwardRef<ProviderPoolPageRef>(
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted-foreground/20 text-muted-foreground group-hover:bg-primary/20 group-hover:text-primary"
|
||||
}`}
|
||||
data-testid={`oauth-credential-count-${providerType}`}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
@@ -449,42 +482,8 @@ export const ProviderPoolPage = forwardRef<ProviderPoolPageRef>(
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{activeCategory === "apikey" && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{apiKeyProviderTypes.map((providerType) => {
|
||||
const count = getCredentialCount(providerType);
|
||||
const isActive = activeTab === providerType;
|
||||
return (
|
||||
<button
|
||||
key={providerType}
|
||||
onClick={() => setActiveTab(providerType)}
|
||||
title={providerLabels[providerType]}
|
||||
className={`group relative flex items-center justify-center gap-2 min-w-[120px] px-3 py-2 rounded-lg border transition-all ${
|
||||
isActive
|
||||
? "border-primary bg-primary/10 text-primary shadow-sm"
|
||||
: "border-border bg-card hover:border-primary/50 hover:bg-muted text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<ProviderIcon providerType={providerType} size={20} />
|
||||
<span className="text-sm font-medium">
|
||||
{providerLabels[providerType].split(" ")[0]}
|
||||
</span>
|
||||
{count > 0 && (
|
||||
<span
|
||||
className={`min-w-[1.25rem] h-5 flex items-center justify-center rounded-full text-xs font-medium ${
|
||||
isActive
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted-foreground/20 text-muted-foreground group-hover:bg-primary/20 group-hover:text-primary"
|
||||
}`}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 其他配置分类 - 配置 Tab 选择 */}
|
||||
{activeCategory === "config" && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(["vertex", "amp"] as const).map((tabId) => {
|
||||
@@ -498,6 +497,7 @@ export const ProviderPoolPage = forwardRef<ProviderPoolPageRef>(
|
||||
? "border-primary bg-primary/10 text-primary shadow-sm"
|
||||
: "border-border bg-card hover:border-primary/50 hover:bg-muted text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
data-testid={`config-tab-${tabId}`}
|
||||
>
|
||||
{configTabLabels[tabId]}
|
||||
</button>
|
||||
@@ -506,9 +506,22 @@ export const ProviderPoolPage = forwardRef<ProviderPoolPageRef>(
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API Key 分类 - 左右分栏布局 */}
|
||||
{activeCategory === "apikey" && (
|
||||
<div
|
||||
className="h-[calc(100vh-280px)] min-h-[400px]"
|
||||
data-testid="apikey-section"
|
||||
>
|
||||
<ApiKeyProviderSection
|
||||
onAddCustomProvider={() => setAddCustomProviderModalOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 配置 Tab 内容 */}
|
||||
{isConfigTab(activeTab) ? (
|
||||
configLoading ? (
|
||||
{activeCategory === "config" &&
|
||||
isConfigTab(activeTab) &&
|
||||
(configLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<RefreshCw className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
@@ -564,157 +577,195 @@ export const ProviderPoolPage = forwardRef<ProviderPoolPageRef>(
|
||||
<div className="flex items-center justify-center py-12 text-muted-foreground">
|
||||
加载配置失败
|
||||
</div>
|
||||
)
|
||||
) : loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<RefreshCw className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Stats and Actions Bar */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
{currentStats && currentStats.total > 0 && (
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Heart className="h-4 w-4 text-green-500" />
|
||||
健康: {currentStats.healthy}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<HeartOff className="h-4 w-4 text-red-500" />
|
||||
不健康: {currentStats.unhealthy}
|
||||
</span>
|
||||
<span>总计: {currentStats.total}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{currentCredentials.length > 0 && (
|
||||
<>
|
||||
<button
|
||||
onClick={() =>
|
||||
handleCheckTypeHealth(activeTab as PoolProviderType)
|
||||
}
|
||||
disabled={checkingHealth === activeTab}
|
||||
className="flex items-center gap-1 rounded-lg border px-3 py-1.5 text-sm hover:bg-muted disabled:opacity-50"
|
||||
>
|
||||
<Activity
|
||||
className={`h-4 w-4 ${checkingHealth === activeTab ? "animate-pulse" : ""}`}
|
||||
/>
|
||||
检测全部
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
handleResetTypeHealth(activeTab as PoolProviderType)
|
||||
}
|
||||
className="flex items-center gap-1 rounded-lg border px-3 py-1.5 text-sm hover:bg-muted"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
重置状态
|
||||
</button>
|
||||
<button
|
||||
onClick={openAddModal}
|
||||
className="flex items-center gap-1 rounded-lg bg-primary px-3 py-1.5 text-sm text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
添加凭证
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* OAuth 凭证内容 - 卡片布局 */}
|
||||
{activeCategory === "oauth" &&
|
||||
!isConfigTab(activeTab) &&
|
||||
(loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<RefreshCw className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
{/* Credentials List */}
|
||||
{currentCredentials.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed py-12 text-muted-foreground">
|
||||
<p className="text-lg">
|
||||
暂无 {providerLabels[activeTab as PoolProviderType]} 凭证
|
||||
</p>
|
||||
<p className="mt-1 text-sm">点击上方"添加凭证"按钮添加</p>
|
||||
<button
|
||||
onClick={openAddModal}
|
||||
className="mt-4 flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
添加第一个凭证
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{currentCredentials.map((credential) => {
|
||||
// 判断是否为 OAuth 类型(需要刷新 Token 功能)
|
||||
const isOAuthType =
|
||||
credential.credential_type.includes("oauth");
|
||||
// 判断是否为 Kiro 凭证(支持用量查询)
|
||||
const isKiroCredential = activeTab === "kiro";
|
||||
const isLocalActive =
|
||||
isKiroCredential && credential.uuid === localActiveUuid;
|
||||
|
||||
if (isKiroCredential) {
|
||||
console.log(
|
||||
`[ProviderPoolPage] Credential ${credential.uuid.substring(0, 8)}: isLocalActive=${isLocalActive}, localActiveUuid=${localActiveUuid?.substring(0, 8)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CredentialCardContextMenu
|
||||
key={credential.uuid}
|
||||
credential={credential}
|
||||
onRefreshToken={
|
||||
isOAuthType
|
||||
? () => handleRefreshToken(credential.uuid)
|
||||
: undefined
|
||||
}
|
||||
onToggle={() => handleToggle(credential)}
|
||||
onDelete={() => handleDeleteClick(credential.uuid)}
|
||||
isOAuth={isOAuthType}
|
||||
) : (
|
||||
<div className="space-y-4" data-testid="oauth-credentials-section">
|
||||
{/* Stats and Actions Bar */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
{currentStats && currentStats.total > 0 && (
|
||||
<div
|
||||
className="flex items-center gap-3 text-sm text-muted-foreground"
|
||||
data-testid="oauth-stats"
|
||||
>
|
||||
<div>
|
||||
<CredentialCard
|
||||
credential={credential}
|
||||
onToggle={() => handleToggle(credential)}
|
||||
onDelete={() => handleDeleteClick(credential.uuid)}
|
||||
onReset={() => handleReset(credential.uuid)}
|
||||
onCheckHealth={() =>
|
||||
handleCheckHealth(credential.uuid)
|
||||
}
|
||||
onRefreshToken={
|
||||
isOAuthType
|
||||
? () => handleRefreshToken(credential.uuid)
|
||||
: undefined
|
||||
}
|
||||
onEdit={() => handleEdit(credential)}
|
||||
deleting={deletingCredentials.has(credential.uuid)}
|
||||
checkingHealth={checkingHealth === credential.uuid}
|
||||
refreshingToken={refreshingToken === credential.uuid}
|
||||
isKiroCredential={isKiroCredential}
|
||||
isLocalActive={isLocalActive}
|
||||
onSwitchToLocal={
|
||||
isKiroCredential ? fetchLocalActiveUuid : undefined
|
||||
}
|
||||
<span
|
||||
className="flex items-center gap-1"
|
||||
data-testid="healthy-count"
|
||||
>
|
||||
<Heart className="h-4 w-4 text-green-500" />
|
||||
健康: {currentStats.healthy}
|
||||
</span>
|
||||
<span
|
||||
className="flex items-center gap-1"
|
||||
data-testid="unhealthy-count"
|
||||
>
|
||||
<HeartOff className="h-4 w-4 text-red-500" />
|
||||
不健康: {currentStats.unhealthy}
|
||||
</span>
|
||||
<span data-testid="total-count">
|
||||
总计: {currentStats.total}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{currentCredentials.length > 0 && (
|
||||
<>
|
||||
<button
|
||||
onClick={() =>
|
||||
handleCheckTypeHealth(activeTab as PoolProviderType)
|
||||
}
|
||||
disabled={checkingHealth === activeTab}
|
||||
className="flex items-center gap-1 rounded-lg border px-3 py-1.5 text-sm hover:bg-muted disabled:opacity-50"
|
||||
data-testid="check-all-health-btn"
|
||||
>
|
||||
<Activity
|
||||
className={`h-4 w-4 ${checkingHealth === activeTab ? "animate-pulse" : ""}`}
|
||||
/>
|
||||
</div>
|
||||
</CredentialCardContextMenu>
|
||||
);
|
||||
})}
|
||||
检测全部
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
handleResetTypeHealth(activeTab as PoolProviderType)
|
||||
}
|
||||
className="flex items-center gap-1 rounded-lg border px-3 py-1.5 text-sm hover:bg-muted"
|
||||
data-testid="reset-health-btn"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
重置状态
|
||||
</button>
|
||||
<button
|
||||
onClick={openAddModal}
|
||||
className="flex items-center gap-1 rounded-lg bg-primary px-3 py-1.5 text-sm text-primary-foreground hover:bg-primary/90"
|
||||
data-testid="add-credential-btn"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
添加凭证
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Credential Modal (仅凭证池 tab) */}
|
||||
{addModalOpen && !isConfigTab(activeTab) && (
|
||||
<AddCredentialModal
|
||||
providerType={activeTab as PoolProviderType}
|
||||
onClose={() => {
|
||||
setAddModalOpen(false);
|
||||
}}
|
||||
onSuccess={() => {
|
||||
setAddModalOpen(false);
|
||||
refresh();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* Credentials List */}
|
||||
{currentCredentials.length === 0 ? (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center rounded-lg border border-dashed py-12 text-muted-foreground"
|
||||
data-testid="empty-credentials"
|
||||
>
|
||||
<p className="text-lg">
|
||||
暂无 {providerLabels[activeTab as PoolProviderType]} 凭证
|
||||
</p>
|
||||
<p className="mt-1 text-sm">点击上方"添加凭证"按钮添加</p>
|
||||
<button
|
||||
onClick={openAddModal}
|
||||
className="mt-4 flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90"
|
||||
data-testid="add-first-credential-btn"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
添加第一个凭证
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="flex flex-col gap-4"
|
||||
data-testid="credentials-list"
|
||||
>
|
||||
{currentCredentials.map((credential) => {
|
||||
// 判断是否为 OAuth 类型(需要刷新 Token 功能)
|
||||
const isOAuthType =
|
||||
credential.credential_type.includes("oauth");
|
||||
// 判断是否为 Kiro 凭证(支持用量查询)
|
||||
const isKiroCredential = activeTab === "kiro";
|
||||
const isLocalActive =
|
||||
isKiroCredential && credential.uuid === localActiveUuid;
|
||||
|
||||
if (isKiroCredential) {
|
||||
console.log(
|
||||
`[ProviderPoolPage] Credential ${credential.uuid.substring(0, 8)}: isLocalActive=${isLocalActive}, localActiveUuid=${localActiveUuid?.substring(0, 8)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CredentialCardContextMenu
|
||||
key={credential.uuid}
|
||||
credential={credential}
|
||||
onRefreshToken={
|
||||
isOAuthType
|
||||
? () => handleRefreshToken(credential.uuid)
|
||||
: undefined
|
||||
}
|
||||
onToggle={() => handleToggle(credential)}
|
||||
onDelete={() => handleDeleteClick(credential.uuid)}
|
||||
isOAuth={isOAuthType}
|
||||
>
|
||||
<div data-testid={`credential-card-${credential.uuid}`}>
|
||||
<CredentialCard
|
||||
credential={credential}
|
||||
onToggle={() => handleToggle(credential)}
|
||||
onDelete={() => handleDeleteClick(credential.uuid)}
|
||||
onReset={() => handleReset(credential.uuid)}
|
||||
onCheckHealth={() =>
|
||||
handleCheckHealth(credential.uuid)
|
||||
}
|
||||
onRefreshToken={
|
||||
isOAuthType
|
||||
? () => handleRefreshToken(credential.uuid)
|
||||
: undefined
|
||||
}
|
||||
onEdit={() => handleEdit(credential)}
|
||||
deleting={deletingCredentials.has(credential.uuid)}
|
||||
checkingHealth={checkingHealth === credential.uuid}
|
||||
refreshingToken={
|
||||
refreshingToken === credential.uuid
|
||||
}
|
||||
isKiroCredential={isKiroCredential}
|
||||
isLocalActive={isLocalActive}
|
||||
onSwitchToLocal={
|
||||
isKiroCredential
|
||||
? fetchLocalActiveUuid
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</CredentialCardContextMenu>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Add Credential Modal (仅 OAuth 凭证 tab) */}
|
||||
{addModalOpen &&
|
||||
activeCategory === "oauth" &&
|
||||
!isConfigTab(activeTab) && (
|
||||
<AddCredentialModal
|
||||
providerType={activeTab as PoolProviderType}
|
||||
onClose={() => {
|
||||
setAddModalOpen(false);
|
||||
}}
|
||||
onSuccess={() => {
|
||||
setAddModalOpen(false);
|
||||
refresh();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Add Custom Provider Modal (API Key 分类) */}
|
||||
<AddCustomProviderModal
|
||||
isOpen={addCustomProviderModalOpen}
|
||||
onClose={() => setAddCustomProviderModalOpen(false)}
|
||||
onAdd={handleAddCustomProvider}
|
||||
/>
|
||||
|
||||
{/* Edit Credential Modal */}
|
||||
<EditCredentialModal
|
||||
@@ -729,7 +780,6 @@ export const ProviderPoolPage = forwardRef<ProviderPoolPageRef>(
|
||||
errors={errors}
|
||||
onDismiss={dismissError}
|
||||
onRetry={(error) => {
|
||||
// 根据错误类型提供重试功能
|
||||
switch (error.type) {
|
||||
case "health_check":
|
||||
if (error.uuid) {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# Provider Pool 组件
|
||||
|
||||
本目录包含凭证池管理界面的所有组件。
|
||||
|
||||
## 组件列表
|
||||
|
||||
| 文件 | 描述 |
|
||||
|------|------|
|
||||
| `ProviderPoolPage.tsx` | 凭证池管理主页面,支持 OAuth 凭证卡片布局和 API Key 左右分栏布局 |
|
||||
| `CredentialCard.tsx` | OAuth 凭证卡片组件,显示健康状态、使用统计和操作按钮 |
|
||||
| `CredentialCardContextMenu.tsx` | 凭证卡片右键菜单组件 |
|
||||
| `AddCredentialModal.tsx` | 添加凭证模态框组件 |
|
||||
| `EditCredentialModal.tsx` | 编辑凭证模态框组件 |
|
||||
| `ErrorDisplay.tsx` | 错误显示组件 |
|
||||
| `UsageDisplay.tsx` | 用量显示组件 |
|
||||
| `VertexAISection.tsx` | Vertex AI 配置区域组件 |
|
||||
| `AmpConfigSection.tsx` | Amp CLI 配置区域组件 |
|
||||
| `GeminiApiKeySection.tsx` | Gemini API Key 配置区域组件 |
|
||||
| `CodexSection.tsx` | Codex 配置区域组件 |
|
||||
| `IFlowSection.tsx` | iFlow 配置区域组件 |
|
||||
| `index.ts` | 组件导出入口 |
|
||||
|
||||
## 子目录
|
||||
|
||||
| 目录 | 描述 |
|
||||
|------|------|
|
||||
| `api-key/` | API Key Provider 管理组件(左右分栏布局) |
|
||||
| `credential-forms/` | 各类凭证表单组件 |
|
||||
|
||||
## 测试文件
|
||||
|
||||
| 文件 | 描述 |
|
||||
|------|------|
|
||||
| `CredentialCard.test.ts` | Property 3 属性测试:OAuth 凭证卡片信息完整性 |
|
||||
|
||||
## 使用示例
|
||||
|
||||
```tsx
|
||||
import { ProviderPoolPage } from "@/components/provider-pool";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ProviderPoolPage />
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## 相关需求
|
||||
|
||||
- Requirements 1.1: API Key Provider 左右分栏布局
|
||||
- Requirements 2.1, 2.2, 2.3: OAuth 凭证保持卡片布局
|
||||
- Requirements 3.1-3.6: 完整支持 System Provider 类型
|
||||
|
||||
## 架构说明
|
||||
|
||||
ProviderPoolPage 支持三种分类:
|
||||
1. **OAuth 凭证** - 使用卡片式布局显示 OAuth 类型凭证
|
||||
2. **API Key** - 使用左右分栏布局(ApiKeyProviderSection)
|
||||
3. **其他配置** - Vertex AI 和 Amp CLI 配置
|
||||
@@ -0,0 +1,375 @@
|
||||
/**
|
||||
* @file AddCustomProviderModal 属性测试
|
||||
* @description 测试自定义 Provider 表单验证
|
||||
* @module components/provider-pool/api-key/AddCustomProviderModal.test
|
||||
*
|
||||
* **Feature: provider-ui-refactor**
|
||||
* **Property 8: 自定义 Provider 表单验证**
|
||||
* **Validates: Requirements 6.2**
|
||||
*/
|
||||
|
||||
import { describe, expect } from "vitest";
|
||||
import { test } from "@fast-check/vitest";
|
||||
import * as fc from "fast-check";
|
||||
import {
|
||||
validateCustomProviderForm,
|
||||
isFormValid,
|
||||
hasRequiredFields,
|
||||
} from "./AddCustomProviderModal";
|
||||
import type { ProviderType } from "@/lib/types/provider";
|
||||
|
||||
// ============================================================================
|
||||
// 测试数据生成器
|
||||
// ============================================================================
|
||||
|
||||
/** 所有有效的 Provider 类型 */
|
||||
const VALID_PROVIDER_TYPES: ProviderType[] = [
|
||||
"openai",
|
||||
"openai-response",
|
||||
"anthropic",
|
||||
"gemini",
|
||||
"azure-openai",
|
||||
"vertexai",
|
||||
"aws-bedrock",
|
||||
"ollama",
|
||||
"new-api",
|
||||
"gateway",
|
||||
];
|
||||
|
||||
/**
|
||||
* 生成有效的 Provider 类型
|
||||
*/
|
||||
const providerTypeArbitrary: fc.Arbitrary<ProviderType> = fc.constantFrom(
|
||||
...VALID_PROVIDER_TYPES,
|
||||
);
|
||||
|
||||
/**
|
||||
* 生成有效的 URL
|
||||
*/
|
||||
const validUrlArbitrary: fc.Arbitrary<string> = fc.webUrl();
|
||||
|
||||
/**
|
||||
* 生成非空字符串(用于名称和 API Key)
|
||||
*/
|
||||
const nonEmptyStringArbitrary: fc.Arbitrary<string> = fc
|
||||
.string({
|
||||
minLength: 1,
|
||||
maxLength: 50,
|
||||
})
|
||||
.filter((s) => s.trim().length > 0);
|
||||
|
||||
/**
|
||||
* 生成空白字符串(用于测试验证)
|
||||
*/
|
||||
const whitespaceStringArbitrary: fc.Arbitrary<string> = fc.constantFrom(
|
||||
"",
|
||||
" ",
|
||||
" ",
|
||||
"\t",
|
||||
"\n",
|
||||
" \t\n ",
|
||||
);
|
||||
|
||||
/**
|
||||
* 生成无效的 URL(无法被 URL 构造函数解析的字符串)
|
||||
*/
|
||||
const invalidUrlArbitrary: fc.Arbitrary<string> = fc.oneof(
|
||||
fc.constant("not-a-url"),
|
||||
fc.constant("just-text"),
|
||||
fc.constant("://missing-protocol"),
|
||||
fc.constant("http//missing-colon"),
|
||||
fc.constant("missing-protocol.com"),
|
||||
fc.string({ minLength: 1, maxLength: 20 }).filter((s) => {
|
||||
try {
|
||||
new URL(s);
|
||||
return false;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* 生成完整的有效表单状态
|
||||
*/
|
||||
const validFormStateArbitrary = fc.record({
|
||||
name: nonEmptyStringArbitrary,
|
||||
type: providerTypeArbitrary,
|
||||
apiHost: validUrlArbitrary,
|
||||
apiKey: nonEmptyStringArbitrary,
|
||||
apiVersion: fc.string({ maxLength: 30 }),
|
||||
project: fc.string({ maxLength: 50 }),
|
||||
location: fc.string({ maxLength: 50 }),
|
||||
region: fc.string({ maxLength: 50 }),
|
||||
});
|
||||
|
||||
/**
|
||||
* 生成缺少名称的表单状态
|
||||
*/
|
||||
const formStateMissingNameArbitrary = fc.record({
|
||||
name: whitespaceStringArbitrary,
|
||||
type: providerTypeArbitrary,
|
||||
apiHost: validUrlArbitrary,
|
||||
apiKey: nonEmptyStringArbitrary,
|
||||
apiVersion: fc.string({ maxLength: 30 }),
|
||||
project: fc.string({ maxLength: 50 }),
|
||||
location: fc.string({ maxLength: 50 }),
|
||||
region: fc.string({ maxLength: 50 }),
|
||||
});
|
||||
|
||||
/**
|
||||
* 生成缺少 API Host 的表单状态
|
||||
*/
|
||||
const formStateMissingApiHostArbitrary = fc.record({
|
||||
name: nonEmptyStringArbitrary,
|
||||
type: providerTypeArbitrary,
|
||||
apiHost: whitespaceStringArbitrary,
|
||||
apiKey: nonEmptyStringArbitrary,
|
||||
apiVersion: fc.string({ maxLength: 30 }),
|
||||
project: fc.string({ maxLength: 50 }),
|
||||
location: fc.string({ maxLength: 50 }),
|
||||
region: fc.string({ maxLength: 50 }),
|
||||
});
|
||||
|
||||
/**
|
||||
* 生成缺少 API Key 的表单状态
|
||||
*/
|
||||
const formStateMissingApiKeyArbitrary = fc.record({
|
||||
name: nonEmptyStringArbitrary,
|
||||
type: providerTypeArbitrary,
|
||||
apiHost: validUrlArbitrary,
|
||||
apiKey: whitespaceStringArbitrary,
|
||||
apiVersion: fc.string({ maxLength: 30 }),
|
||||
project: fc.string({ maxLength: 50 }),
|
||||
location: fc.string({ maxLength: 50 }),
|
||||
region: fc.string({ maxLength: 50 }),
|
||||
});
|
||||
|
||||
/**
|
||||
* 生成无效 API Host 的表单状态
|
||||
*/
|
||||
const formStateInvalidApiHostArbitrary = fc.record({
|
||||
name: nonEmptyStringArbitrary,
|
||||
type: providerTypeArbitrary,
|
||||
apiHost: invalidUrlArbitrary,
|
||||
apiKey: nonEmptyStringArbitrary,
|
||||
apiVersion: fc.string({ maxLength: 30 }),
|
||||
project: fc.string({ maxLength: 50 }),
|
||||
location: fc.string({ maxLength: 50 }),
|
||||
region: fc.string({ maxLength: 50 }),
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Property 8: 自定义 Provider 表单验证
|
||||
// ============================================================================
|
||||
|
||||
describe("Property 8: 自定义 Provider 表单验证", () => {
|
||||
/**
|
||||
* Property 8: 自定义 Provider 表单验证
|
||||
*
|
||||
* *对于任意* 自定义 Provider 创建请求,如果缺少必填字段(名称、API Key、API Host),
|
||||
* 系统应拒绝创建
|
||||
*
|
||||
* **Validates: Requirements 6.2**
|
||||
*/
|
||||
|
||||
describe("有效表单验证", () => {
|
||||
test.prop([validFormStateArbitrary], { numRuns: 100 })(
|
||||
"有效的表单状态应通过验证",
|
||||
(formState) => {
|
||||
const errors = validateCustomProviderForm(formState);
|
||||
|
||||
// 有效表单不应有名称、API Host、API Key 错误
|
||||
expect(errors.name).toBeUndefined();
|
||||
expect(errors.apiHost).toBeUndefined();
|
||||
expect(errors.apiKey).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
test.prop([validFormStateArbitrary], { numRuns: 100 })(
|
||||
"有效的表单状态 isFormValid 应返回 true",
|
||||
(formState) => {
|
||||
expect(isFormValid(formState)).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
test.prop([validFormStateArbitrary], { numRuns: 100 })(
|
||||
"有效的表单状态 hasRequiredFields 应返回 true",
|
||||
(formState) => {
|
||||
expect(hasRequiredFields(formState)).toBe(true);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("缺少名称验证", () => {
|
||||
test.prop([formStateMissingNameArbitrary], { numRuns: 100 })(
|
||||
"缺少名称的表单应返回名称错误",
|
||||
(formState) => {
|
||||
const errors = validateCustomProviderForm(formState);
|
||||
|
||||
expect(errors.name).toBeDefined();
|
||||
expect(typeof errors.name).toBe("string");
|
||||
expect(errors.name!.length).toBeGreaterThan(0);
|
||||
},
|
||||
);
|
||||
|
||||
test.prop([formStateMissingNameArbitrary], { numRuns: 100 })(
|
||||
"缺少名称的表单 isFormValid 应返回 false",
|
||||
(formState) => {
|
||||
expect(isFormValid(formState)).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
test.prop([formStateMissingNameArbitrary], { numRuns: 100 })(
|
||||
"缺少名称的表单 hasRequiredFields 应返回 false",
|
||||
(formState) => {
|
||||
expect(hasRequiredFields(formState)).toBe(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("缺少 API Host 验证", () => {
|
||||
test.prop([formStateMissingApiHostArbitrary], { numRuns: 100 })(
|
||||
"缺少 API Host 的表单应返回 API Host 错误",
|
||||
(formState) => {
|
||||
const errors = validateCustomProviderForm(formState);
|
||||
|
||||
expect(errors.apiHost).toBeDefined();
|
||||
expect(typeof errors.apiHost).toBe("string");
|
||||
expect(errors.apiHost!.length).toBeGreaterThan(0);
|
||||
},
|
||||
);
|
||||
|
||||
test.prop([formStateMissingApiHostArbitrary], { numRuns: 100 })(
|
||||
"缺少 API Host 的表单 isFormValid 应返回 false",
|
||||
(formState) => {
|
||||
expect(isFormValid(formState)).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
test.prop([formStateMissingApiHostArbitrary], { numRuns: 100 })(
|
||||
"缺少 API Host 的表单 hasRequiredFields 应返回 false",
|
||||
(formState) => {
|
||||
expect(hasRequiredFields(formState)).toBe(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("缺少 API Key 验证", () => {
|
||||
test.prop([formStateMissingApiKeyArbitrary], { numRuns: 100 })(
|
||||
"缺少 API Key 的表单应返回 API Key 错误",
|
||||
(formState) => {
|
||||
const errors = validateCustomProviderForm(formState);
|
||||
|
||||
expect(errors.apiKey).toBeDefined();
|
||||
expect(typeof errors.apiKey).toBe("string");
|
||||
expect(errors.apiKey!.length).toBeGreaterThan(0);
|
||||
},
|
||||
);
|
||||
|
||||
test.prop([formStateMissingApiKeyArbitrary], { numRuns: 100 })(
|
||||
"缺少 API Key 的表单 isFormValid 应返回 false",
|
||||
(formState) => {
|
||||
expect(isFormValid(formState)).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
test.prop([formStateMissingApiKeyArbitrary], { numRuns: 100 })(
|
||||
"缺少 API Key 的表单 hasRequiredFields 应返回 false",
|
||||
(formState) => {
|
||||
expect(hasRequiredFields(formState)).toBe(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("无效 API Host 验证", () => {
|
||||
test.prop([formStateInvalidApiHostArbitrary], { numRuns: 100 })(
|
||||
"无效 API Host 的表单应返回 API Host 错误",
|
||||
(formState) => {
|
||||
const errors = validateCustomProviderForm(formState);
|
||||
|
||||
expect(errors.apiHost).toBeDefined();
|
||||
expect(typeof errors.apiHost).toBe("string");
|
||||
expect(errors.apiHost!.length).toBeGreaterThan(0);
|
||||
},
|
||||
);
|
||||
|
||||
test.prop([formStateInvalidApiHostArbitrary], { numRuns: 100 })(
|
||||
"无效 API Host 的表单 isFormValid 应返回 false",
|
||||
(formState) => {
|
||||
expect(isFormValid(formState)).toBe(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("名称长度验证", () => {
|
||||
test("名称超过 50 个字符应返回错误", () => {
|
||||
const formState = {
|
||||
name: "a".repeat(51),
|
||||
type: "openai" as ProviderType,
|
||||
apiHost: "https://api.example.com",
|
||||
apiKey: "sk-test-key",
|
||||
apiVersion: "",
|
||||
project: "",
|
||||
location: "",
|
||||
region: "",
|
||||
};
|
||||
|
||||
const errors = validateCustomProviderForm(formState);
|
||||
expect(errors.name).toBeDefined();
|
||||
expect(errors.name).toContain("50");
|
||||
});
|
||||
|
||||
test("名称正好 50 个字符应通过验证", () => {
|
||||
const formState = {
|
||||
name: "a".repeat(50),
|
||||
type: "openai" as ProviderType,
|
||||
apiHost: "https://api.example.com",
|
||||
apiKey: "sk-test-key",
|
||||
apiVersion: "",
|
||||
project: "",
|
||||
location: "",
|
||||
region: "",
|
||||
};
|
||||
|
||||
const errors = validateCustomProviderForm(formState);
|
||||
expect(errors.name).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("多个缺失字段验证", () => {
|
||||
test("同时缺少多个必填字段应返回所有错误", () => {
|
||||
const formState = {
|
||||
name: "",
|
||||
type: "openai" as ProviderType,
|
||||
apiHost: "",
|
||||
apiKey: "",
|
||||
apiVersion: "",
|
||||
project: "",
|
||||
location: "",
|
||||
region: "",
|
||||
};
|
||||
|
||||
const errors = validateCustomProviderForm(formState);
|
||||
|
||||
expect(errors.name).toBeDefined();
|
||||
expect(errors.apiHost).toBeDefined();
|
||||
expect(errors.apiKey).toBeDefined();
|
||||
});
|
||||
|
||||
test("同时缺少多个必填字段 isFormValid 应返回 false", () => {
|
||||
const formState = {
|
||||
name: "",
|
||||
type: "openai" as ProviderType,
|
||||
apiHost: "",
|
||||
apiKey: "",
|
||||
apiVersion: "",
|
||||
project: "",
|
||||
location: "",
|
||||
region: "",
|
||||
};
|
||||
|
||||
expect(isFormValid(formState)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,501 @@
|
||||
/**
|
||||
* @file AddCustomProviderModal 组件
|
||||
* @description 添加自定义 Provider 的模态框组件
|
||||
* @module components/provider-pool/api-key/AddCustomProviderModal
|
||||
*
|
||||
* **Feature: provider-ui-refactor**
|
||||
* **Validates: Requirements 6.1, 6.2**
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useMemo } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Modal, ModalHeader, ModalBody, ModalFooter } from "@/components/Modal";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { ProviderType } from "@/lib/types/provider";
|
||||
import type { AddCustomProviderRequest } from "@/lib/api/apiKeyProvider";
|
||||
|
||||
// ============================================================================
|
||||
// 常量
|
||||
// ============================================================================
|
||||
|
||||
/** 支持的 Provider 类型列表 */
|
||||
const PROVIDER_TYPES: { value: ProviderType; label: string }[] = [
|
||||
{ value: "openai", label: "OpenAI 兼容" },
|
||||
{ value: "openai-response", label: "OpenAI Responses API" },
|
||||
{ value: "anthropic", label: "Anthropic" },
|
||||
{ value: "gemini", label: "Gemini" },
|
||||
{ value: "azure-openai", label: "Azure OpenAI" },
|
||||
{ value: "vertexai", label: "VertexAI" },
|
||||
{ value: "aws-bedrock", label: "AWS Bedrock" },
|
||||
{ value: "ollama", label: "Ollama" },
|
||||
{ value: "new-api", label: "New API" },
|
||||
{ value: "gateway", label: "Vercel AI Gateway" },
|
||||
];
|
||||
|
||||
/** Provider 类型对应的额外字段 */
|
||||
const PROVIDER_TYPE_EXTRA_FIELDS: Record<ProviderType, string[]> = {
|
||||
openai: [],
|
||||
"openai-response": [],
|
||||
anthropic: [],
|
||||
gemini: [],
|
||||
"azure-openai": ["apiVersion"],
|
||||
vertexai: ["project", "location"],
|
||||
"aws-bedrock": ["region"],
|
||||
ollama: [],
|
||||
"new-api": [],
|
||||
gateway: [],
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// 类型定义
|
||||
// ============================================================================
|
||||
|
||||
export interface AddCustomProviderModalProps {
|
||||
/** 是否打开 */
|
||||
isOpen: boolean;
|
||||
/** 关闭回调 */
|
||||
onClose: () => void;
|
||||
/** 添加成功回调 */
|
||||
onAdd: (request: AddCustomProviderRequest) => Promise<void>;
|
||||
/** 额外的 CSS 类名 */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** 表单状态 */
|
||||
interface FormState {
|
||||
name: string;
|
||||
type: ProviderType;
|
||||
apiHost: string;
|
||||
apiKey: string;
|
||||
apiVersion: string;
|
||||
project: string;
|
||||
location: string;
|
||||
region: string;
|
||||
}
|
||||
|
||||
/** 表单错误 */
|
||||
interface FormErrors {
|
||||
name?: string;
|
||||
apiHost?: string;
|
||||
apiKey?: string;
|
||||
apiVersion?: string;
|
||||
project?: string;
|
||||
location?: string;
|
||||
region?: string;
|
||||
}
|
||||
|
||||
/** 初始表单状态 */
|
||||
const INITIAL_FORM_STATE: FormState = {
|
||||
name: "",
|
||||
type: "openai",
|
||||
apiHost: "",
|
||||
apiKey: "",
|
||||
apiVersion: "",
|
||||
project: "",
|
||||
location: "",
|
||||
region: "",
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// 验证函数(导出用于测试)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 验证自定义 Provider 表单
|
||||
* 用于属性测试验证 Requirements 6.2
|
||||
*
|
||||
* @param formState 表单状态
|
||||
* @returns 验证错误对象,如果没有错误则为空对象
|
||||
*/
|
||||
export function validateCustomProviderForm(formState: FormState): FormErrors {
|
||||
const errors: FormErrors = {};
|
||||
|
||||
// 验证名称(必填)
|
||||
if (!formState.name.trim()) {
|
||||
errors.name = "Provider 名称不能为空";
|
||||
} else if (formState.name.trim().length > 50) {
|
||||
errors.name = "Provider 名称不能超过 50 个字符";
|
||||
}
|
||||
|
||||
// 验证 API Host(必填)
|
||||
if (!formState.apiHost.trim()) {
|
||||
errors.apiHost = "API Host 不能为空";
|
||||
} else {
|
||||
// 验证 URL 格式
|
||||
try {
|
||||
new URL(formState.apiHost.trim());
|
||||
} catch {
|
||||
errors.apiHost = "请输入有效的 URL";
|
||||
}
|
||||
}
|
||||
|
||||
// 验证 API Key(必填)
|
||||
if (!formState.apiKey.trim()) {
|
||||
errors.apiKey = "API Key 不能为空";
|
||||
}
|
||||
|
||||
// 额外字段验证(可选字段,不强制验证格式)
|
||||
// Azure OpenAI 的 API Version、VertexAI 的 Project/Location、AWS Bedrock 的 Region
|
||||
// 都是可选字段,用户可以自由填写
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查表单是否有效
|
||||
*/
|
||||
export function isFormValid(formState: FormState): boolean {
|
||||
const errors = validateCustomProviderForm(formState);
|
||||
return Object.keys(errors).length === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查必填字段是否已填写
|
||||
*/
|
||||
export function hasRequiredFields(formState: FormState): boolean {
|
||||
return (
|
||||
formState.name.trim() !== "" &&
|
||||
formState.apiHost.trim() !== "" &&
|
||||
formState.apiKey.trim() !== ""
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 组件实现
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 添加自定义 Provider 模态框组件
|
||||
*
|
||||
* 允许用户添加自定义 OpenAI 兼容 Provider,包含:
|
||||
* - Provider 名称(必填)
|
||||
* - API Key(必填)
|
||||
* - API Host(必填)
|
||||
* - Provider Type(默认 openai)
|
||||
* - 根据类型显示额外字段
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <AddCustomProviderModal
|
||||
* isOpen={showModal}
|
||||
* onClose={() => setShowModal(false)}
|
||||
* onAdd={handleAddProvider}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export const AddCustomProviderModal: React.FC<AddCustomProviderModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onAdd,
|
||||
className,
|
||||
}) => {
|
||||
// 表单状态
|
||||
const [formState, setFormState] = useState<FormState>(INITIAL_FORM_STATE);
|
||||
const [errors, setErrors] = useState<FormErrors>({});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
|
||||
// 获取当前类型需要的额外字段
|
||||
const extraFields = useMemo(
|
||||
() => PROVIDER_TYPE_EXTRA_FIELDS[formState.type] || [],
|
||||
[formState.type],
|
||||
);
|
||||
|
||||
// 重置表单
|
||||
const resetForm = useCallback(() => {
|
||||
setFormState(INITIAL_FORM_STATE);
|
||||
setErrors({});
|
||||
setSubmitError(null);
|
||||
}, []);
|
||||
|
||||
// 关闭模态框
|
||||
const handleClose = useCallback(() => {
|
||||
resetForm();
|
||||
onClose();
|
||||
}, [resetForm, onClose]);
|
||||
|
||||
// 更新字段
|
||||
const updateField = useCallback(
|
||||
<K extends keyof FormState>(field: K, value: FormState[K]) => {
|
||||
setFormState((prev) => ({ ...prev, [field]: value }));
|
||||
// 清除该字段的错误
|
||||
if (errors[field as keyof FormErrors]) {
|
||||
setErrors((prev) => {
|
||||
const newErrors = { ...prev };
|
||||
delete newErrors[field as keyof FormErrors];
|
||||
return newErrors;
|
||||
});
|
||||
}
|
||||
},
|
||||
[errors],
|
||||
);
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = useCallback(async () => {
|
||||
// 验证表单
|
||||
const validationErrors = validateCustomProviderForm(formState);
|
||||
if (Object.keys(validationErrors).length > 0) {
|
||||
setErrors(validationErrors);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setSubmitError(null);
|
||||
|
||||
try {
|
||||
const request: AddCustomProviderRequest = {
|
||||
name: formState.name.trim(),
|
||||
type: formState.type,
|
||||
api_host: formState.apiHost.trim(),
|
||||
};
|
||||
|
||||
// 添加额外字段
|
||||
if (formState.apiVersion.trim()) {
|
||||
request.api_version = formState.apiVersion.trim();
|
||||
}
|
||||
if (formState.project.trim()) {
|
||||
request.project = formState.project.trim();
|
||||
}
|
||||
if (formState.location.trim()) {
|
||||
request.location = formState.location.trim();
|
||||
}
|
||||
if (formState.region.trim()) {
|
||||
request.region = formState.region.trim();
|
||||
}
|
||||
|
||||
await onAdd(request);
|
||||
handleClose();
|
||||
} catch (e) {
|
||||
setSubmitError(e instanceof Error ? e.message : "添加失败");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [formState, onAdd, handleClose]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={handleClose}
|
||||
maxWidth="max-w-md"
|
||||
className={className}
|
||||
>
|
||||
<ModalHeader>添加自定义 Provider</ModalHeader>
|
||||
|
||||
<ModalBody className="space-y-4">
|
||||
{/* Provider 名称 */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="provider-name" className="text-sm font-medium">
|
||||
Provider 名称 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="provider-name"
|
||||
type="text"
|
||||
value={formState.name}
|
||||
onChange={(e) => updateField("name", e.target.value)}
|
||||
placeholder="例如:My Custom API"
|
||||
disabled={isSubmitting}
|
||||
className={cn(errors.name && "border-red-500")}
|
||||
data-testid="provider-name-input"
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="text-xs text-red-500" data-testid="name-error">
|
||||
{errors.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Provider 类型 */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="provider-type" className="text-sm font-medium">
|
||||
Provider 类型
|
||||
</Label>
|
||||
<Select
|
||||
value={formState.type}
|
||||
onValueChange={(value) =>
|
||||
updateField("type", value as ProviderType)
|
||||
}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<SelectTrigger data-testid="provider-type-select">
|
||||
<SelectValue placeholder="选择类型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PROVIDER_TYPES.map((type) => (
|
||||
<SelectItem key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
大多数第三方 API 服务使用 OpenAI 兼容格式
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* API Host */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="api-host" className="text-sm font-medium">
|
||||
API Host <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="api-host"
|
||||
type="text"
|
||||
value={formState.apiHost}
|
||||
onChange={(e) => updateField("apiHost", e.target.value)}
|
||||
placeholder="https://api.example.com"
|
||||
disabled={isSubmitting}
|
||||
className={cn(errors.apiHost && "border-red-500")}
|
||||
data-testid="api-host-input"
|
||||
/>
|
||||
{errors.apiHost && (
|
||||
<p className="text-xs text-red-500" data-testid="api-host-error">
|
||||
{errors.apiHost}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* API Key */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="api-key" className="text-sm font-medium">
|
||||
API Key <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="api-key"
|
||||
type="password"
|
||||
value={formState.apiKey}
|
||||
onChange={(e) => updateField("apiKey", e.target.value)}
|
||||
placeholder="sk-..."
|
||||
disabled={isSubmitting}
|
||||
className={cn(errors.apiKey && "border-red-500")}
|
||||
data-testid="api-key-input"
|
||||
/>
|
||||
{errors.apiKey && (
|
||||
<p className="text-xs text-red-500" data-testid="api-key-error">
|
||||
{errors.apiKey}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Azure OpenAI: API Version */}
|
||||
{extraFields.includes("apiVersion") && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="api-version" className="text-sm font-medium">
|
||||
API Version
|
||||
</Label>
|
||||
<Input
|
||||
id="api-version"
|
||||
type="text"
|
||||
value={formState.apiVersion}
|
||||
onChange={(e) => updateField("apiVersion", e.target.value)}
|
||||
placeholder="2024-02-15-preview"
|
||||
disabled={isSubmitting}
|
||||
className={cn(errors.apiVersion && "border-red-500")}
|
||||
data-testid="api-version-input"
|
||||
/>
|
||||
{errors.apiVersion && (
|
||||
<p
|
||||
className="text-xs text-red-500"
|
||||
data-testid="api-version-error"
|
||||
>
|
||||
{errors.apiVersion}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* VertexAI: Project */}
|
||||
{extraFields.includes("project") && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="project" className="text-sm font-medium">
|
||||
Project ID
|
||||
</Label>
|
||||
<Input
|
||||
id="project"
|
||||
type="text"
|
||||
value={formState.project}
|
||||
onChange={(e) => updateField("project", e.target.value)}
|
||||
placeholder="your-project-id"
|
||||
disabled={isSubmitting}
|
||||
data-testid="project-input"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* VertexAI: Location */}
|
||||
{extraFields.includes("location") && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="location" className="text-sm font-medium">
|
||||
Location
|
||||
</Label>
|
||||
<Input
|
||||
id="location"
|
||||
type="text"
|
||||
value={formState.location}
|
||||
onChange={(e) => updateField("location", e.target.value)}
|
||||
placeholder="us-central1"
|
||||
disabled={isSubmitting}
|
||||
data-testid="location-input"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AWS Bedrock: Region */}
|
||||
{extraFields.includes("region") && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="region" className="text-sm font-medium">
|
||||
Region
|
||||
</Label>
|
||||
<Input
|
||||
id="region"
|
||||
type="text"
|
||||
value={formState.region}
|
||||
onChange={(e) => updateField("region", e.target.value)}
|
||||
placeholder="us-east-1"
|
||||
disabled={isSubmitting}
|
||||
data-testid="region-input"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 提交错误 */}
|
||||
{submitError && (
|
||||
<div
|
||||
className="p-3 rounded-lg bg-red-50 text-red-600 text-sm"
|
||||
data-testid="submit-error"
|
||||
>
|
||||
{submitError}
|
||||
</div>
|
||||
)}
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
disabled={isSubmitting}
|
||||
data-testid="cancel-button"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting || !hasRequiredFields(formState)}
|
||||
data-testid="submit-button"
|
||||
>
|
||||
{isSubmitting ? "添加中..." : "添加"}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddCustomProviderModal;
|
||||
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* @file ApiKeyItem 组件
|
||||
* @description API Key 列表项组件,显示 API Key(掩码)、别名、使用统计,支持启用/禁用、删除操作
|
||||
* @module components/provider-pool/api-key/ApiKeyItem
|
||||
*
|
||||
* **Feature: provider-ui-refactor**
|
||||
* **Validates: Requirements 7.5**
|
||||
*/
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import type { ApiKeyDisplay } from "@/lib/api/apiKeyProvider";
|
||||
|
||||
// ============================================================================
|
||||
// 图标组件
|
||||
// ============================================================================
|
||||
|
||||
const TrashIcon: React.FC<{ className?: string }> = ({ className }) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
className={cn("w-4 h-4", className)}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M8.75 1A2.75 2.75 0 006 3.75v.443c-.795.077-1.584.176-2.365.298a.75.75 0 10.23 1.482l.149-.022.841 10.518A2.75 2.75 0 007.596 19h4.807a2.75 2.75 0 002.742-2.53l.841-10.519.149.023a.75.75 0 00.23-1.482A41.03 41.03 0 0014 4.193V3.75A2.75 2.75 0 0011.25 1h-2.5zM10 4c.84 0 1.673.025 2.5.075V3.75c0-.69-.56-1.25-1.25-1.25h-2.5c-.69 0-1.25.56-1.25 1.25v.325C8.327 4.025 9.16 4 10 4zM8.58 7.72a.75.75 0 00-1.5.06l.3 7.5a.75.75 0 101.5-.06l-.3-7.5zm4.34.06a.75.75 0 10-1.5-.06l-.3 7.5a.75.75 0 101.5.06l.3-7.5z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const KeyIcon: React.FC<{ className?: string }> = ({ className }) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
className={cn("w-4 h-4", className)}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M8 7a5 5 0 113.61 4.804l-1.903 1.903A1 1 0 019 14H8v1a1 1 0 01-1 1H6v1a1 1 0 01-1 1H3a1 1 0 01-1-1v-2a1 1 0 01.293-.707L8.196 8.39A5.002 5.002 0 018 7zm5-3a.75.75 0 000 1.5A1.5 1.5 0 0114.5 7 .75.75 0 0016 7a3 3 0 00-3-3z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// 类型定义
|
||||
// ============================================================================
|
||||
|
||||
export interface ApiKeyItemProps {
|
||||
/** API Key 数据 */
|
||||
apiKey: ApiKeyDisplay;
|
||||
/** 切换启用状态回调 */
|
||||
onToggle?: (keyId: string, enabled: boolean) => void;
|
||||
/** 删除回调 */
|
||||
onDelete?: (keyId: string) => void;
|
||||
/** 是否正在加载 */
|
||||
loading?: boolean;
|
||||
/** 额外的 CSS 类名 */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 组件实现
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* API Key 列表项组件
|
||||
*
|
||||
* 显示单个 API Key 的信息,包括:
|
||||
* - 掩码后的 API Key
|
||||
* - 别名(如果有)
|
||||
* - 使用统计(使用次数、错误次数)
|
||||
* - 启用/禁用开关
|
||||
* - 删除按钮
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <ApiKeyItem
|
||||
* apiKey={apiKey}
|
||||
* onToggle={(id, enabled) => toggleApiKey(id, enabled)}
|
||||
* onDelete={(id) => deleteApiKey(id)}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export const ApiKeyItem: React.FC<ApiKeyItemProps> = ({
|
||||
apiKey,
|
||||
onToggle,
|
||||
onDelete,
|
||||
loading = false,
|
||||
className,
|
||||
}) => {
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const handleToggle = (checked: boolean) => {
|
||||
if (!loading) {
|
||||
onToggle?.(apiKey.id, checked);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!loading && !isDeleting) {
|
||||
setIsDeleting(true);
|
||||
onDelete?.(apiKey.id);
|
||||
}
|
||||
};
|
||||
|
||||
// 格式化最后使用时间
|
||||
const formatLastUsed = (dateStr?: string): string => {
|
||||
if (!dateStr) return "从未使用";
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return "刚刚";
|
||||
if (diffMins < 60) return `${diffMins} 分钟前`;
|
||||
if (diffHours < 24) return `${diffHours} 小时前`;
|
||||
if (diffDays < 30) return `${diffDays} 天前`;
|
||||
return date.toLocaleDateString("zh-CN");
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-3 py-2.5 rounded-lg",
|
||||
"bg-muted/30 hover:bg-muted/50 transition-colors",
|
||||
!apiKey.enabled && "opacity-60",
|
||||
className,
|
||||
)}
|
||||
data-testid="api-key-item"
|
||||
data-key-id={apiKey.id}
|
||||
data-enabled={apiKey.enabled}
|
||||
>
|
||||
{/* Key 图标 */}
|
||||
<KeyIcon className="flex-shrink-0 text-muted-foreground" />
|
||||
|
||||
{/* API Key 信息 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* 别名或掩码 Key */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="text-sm font-medium truncate"
|
||||
data-testid="api-key-display"
|
||||
>
|
||||
{apiKey.alias || apiKey.api_key_masked}
|
||||
</span>
|
||||
{apiKey.alias && (
|
||||
<span
|
||||
className="text-xs text-muted-foreground truncate"
|
||||
data-testid="api-key-masked"
|
||||
>
|
||||
({apiKey.api_key_masked})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 使用统计 */}
|
||||
<div
|
||||
className="flex items-center gap-3 mt-0.5 text-xs text-muted-foreground"
|
||||
data-testid="api-key-stats"
|
||||
>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span data-testid="usage-count">
|
||||
使用: {apiKey.usage_count}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>总使用次数</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
{apiKey.error_count > 0 && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-red-500" data-testid="error-count">
|
||||
错误: {apiKey.error_count}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>API 调用错误次数</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span data-testid="last-used">
|
||||
{formatLastUsed(apiKey.last_used_at)}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>最后使用时间</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 启用/禁用开关 */}
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<Switch
|
||||
checked={apiKey.enabled}
|
||||
onCheckedChange={handleToggle}
|
||||
disabled={loading}
|
||||
data-testid="api-key-toggle"
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{apiKey.enabled ? "点击禁用" : "点击启用"}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
{/* 删除按钮 */}
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-red-500"
|
||||
onClick={handleDelete}
|
||||
disabled={loading || isDeleting}
|
||||
data-testid="api-key-delete"
|
||||
>
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>删除此 API Key</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// 辅助函数(用于测试)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 从 API Key 数据中提取显示所需的信息
|
||||
* 用于属性测试验证显示完整性
|
||||
*/
|
||||
export function extractApiKeyDisplayInfo(apiKey: ApiKeyDisplay): {
|
||||
hasMaskedKey: boolean;
|
||||
hasAlias: boolean;
|
||||
hasUsageCount: boolean;
|
||||
hasErrorCount: boolean;
|
||||
hasEnabled: boolean;
|
||||
} {
|
||||
return {
|
||||
hasMaskedKey:
|
||||
typeof apiKey.api_key_masked === "string" &&
|
||||
apiKey.api_key_masked.length > 0,
|
||||
hasAlias: apiKey.alias !== undefined && apiKey.alias !== null,
|
||||
hasUsageCount: typeof apiKey.usage_count === "number",
|
||||
hasErrorCount: typeof apiKey.error_count === "number",
|
||||
hasEnabled: typeof apiKey.enabled === "boolean",
|
||||
};
|
||||
}
|
||||
|
||||
export default ApiKeyItem;
|
||||
@@ -0,0 +1,306 @@
|
||||
/**
|
||||
* @file ApiKeyList 组件
|
||||
* @description API Key 列表组件,显示 Provider 的所有 API Key,支持添加新 API Key
|
||||
* @module components/provider-pool/api-key/ApiKeyList
|
||||
*
|
||||
* **Feature: provider-ui-refactor**
|
||||
* **Validates: Requirements 7.1**
|
||||
*/
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ApiKeyItem } from "./ApiKeyItem";
|
||||
import type { ApiKeyDisplay } from "@/lib/api/apiKeyProvider";
|
||||
|
||||
// ============================================================================
|
||||
// 图标组件
|
||||
// ============================================================================
|
||||
|
||||
const PlusIcon: React.FC<{ className?: string }> = ({ className }) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
className={cn("w-4 h-4", className)}
|
||||
>
|
||||
<path d="M10.75 4.75a.75.75 0 00-1.5 0v4.5h-4.5a.75.75 0 000 1.5h4.5v4.5a.75.75 0 001.5 0v-4.5h4.5a.75.75 0 000-1.5h-4.5v-4.5z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const EyeIcon: React.FC<{ className?: string }> = ({ className }) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
className={cn("w-4 h-4", className)}
|
||||
>
|
||||
<path d="M10 12.5a2.5 2.5 0 100-5 2.5 2.5 0 000 5z" />
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M.664 10.59a1.651 1.651 0 010-1.186A10.004 10.004 0 0110 3c4.257 0 7.893 2.66 9.336 6.41.147.381.146.804 0 1.186A10.004 10.004 0 0110 17c-4.257 0-7.893-2.66-9.336-6.41zM14 10a4 4 0 11-8 0 4 4 0 018 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const EyeSlashIcon: React.FC<{ className?: string }> = ({ className }) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
className={cn("w-4 h-4", className)}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M3.28 2.22a.75.75 0 00-1.06 1.06l14.5 14.5a.75.75 0 101.06-1.06l-1.745-1.745a10.029 10.029 0 003.3-4.38 1.651 1.651 0 000-1.185A10.004 10.004 0 009.999 3a9.956 9.956 0 00-4.744 1.194L3.28 2.22zM7.752 6.69l1.092 1.092a2.5 2.5 0 013.374 3.373l1.091 1.092a4 4 0 00-5.557-5.557z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
<path d="M10.748 13.93l2.523 2.523a9.987 9.987 0 01-3.27.547c-4.258 0-7.894-2.66-9.337-6.41a1.651 1.651 0 010-1.186A10.007 10.007 0 012.839 6.02L6.07 9.252a4 4 0 004.678 4.678z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// 类型定义
|
||||
// ============================================================================
|
||||
|
||||
export interface ApiKeyListProps {
|
||||
/** API Key 列表 */
|
||||
apiKeys: ApiKeyDisplay[];
|
||||
/** Provider ID */
|
||||
providerId: string;
|
||||
/** 添加 API Key 回调 */
|
||||
onAdd?: (providerId: string, apiKey: string, alias?: string) => Promise<void>;
|
||||
/** 切换 API Key 启用状态回调 */
|
||||
onToggle?: (keyId: string, enabled: boolean) => void;
|
||||
/** 删除 API Key 回调 */
|
||||
onDelete?: (keyId: string) => void;
|
||||
/** 是否正在加载 */
|
||||
loading?: boolean;
|
||||
/** 额外的 CSS 类名 */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 组件实现
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* API Key 列表组件
|
||||
*
|
||||
* 显示 Provider 的所有 API Key,并提供添加新 API Key 的功能。
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <ApiKeyList
|
||||
* apiKeys={provider.api_keys}
|
||||
* providerId={provider.id}
|
||||
* onAdd={addApiKey}
|
||||
* onToggle={toggleApiKey}
|
||||
* onDelete={deleteApiKey}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export const ApiKeyList: React.FC<ApiKeyListProps> = ({
|
||||
apiKeys,
|
||||
providerId,
|
||||
onAdd,
|
||||
onToggle,
|
||||
onDelete,
|
||||
loading = false,
|
||||
className,
|
||||
}) => {
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [newApiKey, setNewApiKey] = useState("");
|
||||
const [newAlias, setNewAlias] = useState("");
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!newApiKey.trim()) {
|
||||
setError("请输入 API Key");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsAdding(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await onAdd?.(providerId, newApiKey.trim(), newAlias.trim() || undefined);
|
||||
// 重置表单
|
||||
setNewApiKey("");
|
||||
setNewAlias("");
|
||||
setShowAddForm(false);
|
||||
setShowApiKey(false);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "添加失败");
|
||||
} finally {
|
||||
setIsAdding(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setShowAddForm(false);
|
||||
setNewApiKey("");
|
||||
setNewAlias("");
|
||||
setShowApiKey(false);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-3", className)} data-testid="api-key-list">
|
||||
{/* 标题和添加按钮 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-medium text-foreground">
|
||||
API Keys ({apiKeys.length})
|
||||
</h4>
|
||||
{!showAddForm && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowAddForm(true)}
|
||||
disabled={loading}
|
||||
data-testid="add-api-key-button"
|
||||
>
|
||||
<PlusIcon className="mr-1" />
|
||||
添加
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 添加表单 */}
|
||||
{showAddForm && (
|
||||
<div
|
||||
className="p-3 rounded-lg border border-border bg-muted/20 space-y-3"
|
||||
data-testid="add-api-key-form"
|
||||
>
|
||||
{/* API Key 输入 */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="new-api-key" className="text-xs">
|
||||
API Key <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="new-api-key"
|
||||
type={showApiKey ? "text" : "password"}
|
||||
value={newApiKey}
|
||||
onChange={(e) => setNewApiKey(e.target.value)}
|
||||
placeholder="输入 API Key"
|
||||
className="pr-10"
|
||||
disabled={isAdding}
|
||||
data-testid="new-api-key-input"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showApiKey ? <EyeSlashIcon /> : <EyeIcon />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 别名输入 */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="new-alias" className="text-xs">
|
||||
别名(可选)
|
||||
</Label>
|
||||
<Input
|
||||
id="new-alias"
|
||||
type="text"
|
||||
value={newAlias}
|
||||
onChange={(e) => setNewAlias(e.target.value)}
|
||||
placeholder="例如:主账号、测试账号"
|
||||
disabled={isAdding}
|
||||
data-testid="new-alias-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<p className="text-xs text-red-500" data-testid="add-error">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCancel}
|
||||
disabled={isAdding}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleAdd}
|
||||
disabled={isAdding || !newApiKey.trim()}
|
||||
data-testid="confirm-add-button"
|
||||
>
|
||||
{isAdding ? "添加中..." : "确认添加"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API Key 列表 */}
|
||||
{apiKeys.length > 0 ? (
|
||||
<div className="space-y-2" data-testid="api-key-items">
|
||||
{apiKeys.map((apiKey) => (
|
||||
<ApiKeyItem
|
||||
key={apiKey.id}
|
||||
apiKey={apiKey}
|
||||
onToggle={onToggle}
|
||||
onDelete={onDelete}
|
||||
loading={loading}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
!showAddForm && (
|
||||
<div
|
||||
className="py-8 text-center text-sm text-muted-foreground"
|
||||
data-testid="empty-state"
|
||||
>
|
||||
<p>暂无 API Key</p>
|
||||
<p className="mt-1 text-xs">
|
||||
点击上方「添加」按钮添加第一个 API Key
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// 辅助函数(用于测试)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 获取 API Key 列表的统计信息
|
||||
* 用于属性测试验证
|
||||
*/
|
||||
export function getApiKeyListStats(apiKeys: ApiKeyDisplay[]): {
|
||||
total: number;
|
||||
enabled: number;
|
||||
disabled: number;
|
||||
totalUsage: number;
|
||||
totalErrors: number;
|
||||
} {
|
||||
return {
|
||||
total: apiKeys.length,
|
||||
enabled: apiKeys.filter((k) => k.enabled).length,
|
||||
disabled: apiKeys.filter((k) => !k.enabled).length,
|
||||
totalUsage: apiKeys.reduce((sum, k) => sum + k.usage_count, 0),
|
||||
totalErrors: apiKeys.reduce((sum, k) => sum + k.error_count, 0),
|
||||
};
|
||||
}
|
||||
|
||||
export default ApiKeyList;
|
||||
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* @file ApiKeyProviderSection 属性测试
|
||||
* @description 测试 Provider 选择同步
|
||||
* @module components/provider-pool/api-key/ApiKeyProviderSection.test
|
||||
*
|
||||
* **Feature: provider-ui-refactor**
|
||||
* **Property 2: Provider 选择同步**
|
||||
* **Validates: Requirements 1.4**
|
||||
*/
|
||||
|
||||
import { describe, expect } from "vitest";
|
||||
import { test } from "@fast-check/vitest";
|
||||
import * as fc from "fast-check";
|
||||
import {
|
||||
verifyProviderSelectionSync,
|
||||
extractSelectionState,
|
||||
} from "./ApiKeyProviderSection";
|
||||
import type {
|
||||
ProviderWithKeysDisplay,
|
||||
ApiKeyDisplay,
|
||||
} from "@/lib/api/apiKeyProvider";
|
||||
|
||||
// ============================================================================
|
||||
// 测试数据生成器
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 生成有效的 ISO 日期字符串
|
||||
*/
|
||||
const validDateArbitrary = fc
|
||||
.integer({
|
||||
min: new Date("2020-01-01").getTime(),
|
||||
max: new Date("2030-12-31").getTime(),
|
||||
})
|
||||
.map((timestamp) => new Date(timestamp).toISOString());
|
||||
|
||||
/**
|
||||
* 生成随机 API Key 显示数据
|
||||
*/
|
||||
const apiKeyDisplayArbitrary: fc.Arbitrary<ApiKeyDisplay> = fc.record({
|
||||
id: fc.uuid(),
|
||||
provider_id: fc.string({ minLength: 1, maxLength: 50 }),
|
||||
api_key_masked: fc
|
||||
.string({ minLength: 1, maxLength: 20 })
|
||||
.map((s) => `sk-****${s}`),
|
||||
alias: fc.option(fc.string({ minLength: 1, maxLength: 50 }), {
|
||||
nil: undefined,
|
||||
}),
|
||||
enabled: fc.boolean(),
|
||||
usage_count: fc.nat({ max: 10000 }),
|
||||
error_count: fc.nat({ max: 1000 }),
|
||||
last_used_at: fc.option(validDateArbitrary, { nil: undefined }),
|
||||
created_at: validDateArbitrary,
|
||||
});
|
||||
|
||||
/**
|
||||
* 生成随机 Provider 显示数据(包含 API Keys)
|
||||
*/
|
||||
const providerWithKeysArbitrary: fc.Arbitrary<ProviderWithKeysDisplay> =
|
||||
fc.record({
|
||||
id: fc.string({ minLength: 1, maxLength: 50 }),
|
||||
name: fc.string({ minLength: 1, maxLength: 100 }),
|
||||
type: fc.constantFrom(
|
||||
"openai",
|
||||
"openai-response",
|
||||
"anthropic",
|
||||
"gemini",
|
||||
"azure-openai",
|
||||
"vertexai",
|
||||
"aws-bedrock",
|
||||
"ollama",
|
||||
"new-api",
|
||||
"gateway",
|
||||
),
|
||||
api_host: fc.webUrl(),
|
||||
is_system: fc.boolean(),
|
||||
group: fc.constantFrom(
|
||||
"mainstream",
|
||||
"chinese",
|
||||
"cloud",
|
||||
"aggregator",
|
||||
"local",
|
||||
"specialized",
|
||||
"custom",
|
||||
),
|
||||
enabled: fc.boolean(),
|
||||
sort_order: fc.nat({ max: 100 }),
|
||||
api_version: fc.option(fc.string({ minLength: 1, maxLength: 20 }), {
|
||||
nil: undefined,
|
||||
}),
|
||||
project: fc.option(fc.string({ minLength: 1, maxLength: 50 }), {
|
||||
nil: undefined,
|
||||
}),
|
||||
location: fc.option(fc.string({ minLength: 1, maxLength: 50 }), {
|
||||
nil: undefined,
|
||||
}),
|
||||
region: fc.option(fc.string({ minLength: 1, maxLength: 50 }), {
|
||||
nil: undefined,
|
||||
}),
|
||||
api_key_count: fc.nat({ max: 20 }),
|
||||
created_at: validDateArbitrary,
|
||||
updated_at: validDateArbitrary,
|
||||
api_keys: fc.array(apiKeyDisplayArbitrary, { minLength: 0, maxLength: 10 }),
|
||||
});
|
||||
|
||||
/**
|
||||
* 生成 Provider ID(可能为 null)
|
||||
*/
|
||||
const providerIdArbitrary = fc.option(
|
||||
fc.string({ minLength: 1, maxLength: 50 }),
|
||||
{ nil: null },
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Property 2: Provider 选择同步
|
||||
// ============================================================================
|
||||
|
||||
describe("Property 2: Provider 选择同步", () => {
|
||||
/**
|
||||
* Property 2: Provider 选择同步
|
||||
*
|
||||
* *对于任意* Provider 列表中的点击操作,右侧设置面板应显示被点击 Provider 的配置信息
|
||||
*
|
||||
* **Validates: Requirements 1.4**
|
||||
*/
|
||||
test.prop([providerIdArbitrary, providerIdArbitrary], { numRuns: 100 })(
|
||||
"选中的 Provider ID 应与设置面板显示的 Provider ID 同步",
|
||||
(selectedId: string | null, displayedId: string | null) => {
|
||||
// 当两个 ID 相同时,应该同步
|
||||
const isSynced = verifyProviderSelectionSync(selectedId, displayedId);
|
||||
|
||||
if (selectedId === null) {
|
||||
// 如果没有选中任何 Provider,设置面板应该显示空状态
|
||||
expect(isSynced).toBe(displayedId === null);
|
||||
} else {
|
||||
// 如果选中了 Provider,设置面板应该显示相同的 Provider
|
||||
expect(isSynced).toBe(selectedId === displayedId);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test.prop([providerWithKeysArbitrary], { numRuns: 100 })(
|
||||
"当选中 Provider 时,设置面板应显示该 Provider 的信息",
|
||||
(provider: ProviderWithKeysDisplay) => {
|
||||
const state = extractSelectionState(provider.id, provider);
|
||||
|
||||
// 验证选中状态同步
|
||||
expect(state.listSelectedId).toBe(provider.id);
|
||||
expect(state.settingProviderId).toBe(provider.id);
|
||||
expect(state.isSynced).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
test("当没有选中 Provider 时,设置面板应显示空状态", () => {
|
||||
const state = extractSelectionState(null, null);
|
||||
|
||||
expect(state.listSelectedId).toBeNull();
|
||||
expect(state.settingProviderId).toBeNull();
|
||||
expect(state.isSynced).toBe(true);
|
||||
});
|
||||
|
||||
test.prop([providerWithKeysArbitrary], { numRuns: 100 })(
|
||||
"选中状态变化时应保持同步",
|
||||
(provider: ProviderWithKeysDisplay) => {
|
||||
// 模拟从空状态到选中状态
|
||||
const emptyState = extractSelectionState(null, null);
|
||||
expect(emptyState.isSynced).toBe(true);
|
||||
|
||||
// 模拟选中 Provider
|
||||
const selectedState = extractSelectionState(provider.id, provider);
|
||||
expect(selectedState.isSynced).toBe(true);
|
||||
|
||||
// 模拟取消选中
|
||||
const deselectedState = extractSelectionState(null, null);
|
||||
expect(deselectedState.isSynced).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
// 边界情况测试
|
||||
describe("边界情况", () => {
|
||||
test("空字符串 ID 应被视为有效选择", () => {
|
||||
// 注意:实际上空字符串 ID 不应该出现,但测试边界情况
|
||||
const isSynced = verifyProviderSelectionSync("", "");
|
||||
expect(isSynced).toBe(true);
|
||||
});
|
||||
|
||||
test("不同 ID 应被视为不同步", () => {
|
||||
const isSynced = verifyProviderSelectionSync("provider-a", "provider-b");
|
||||
expect(isSynced).toBe(false);
|
||||
});
|
||||
|
||||
test("一个为 null 一个不为 null 应被视为不同步", () => {
|
||||
expect(verifyProviderSelectionSync(null, "provider-a")).toBe(false);
|
||||
expect(verifyProviderSelectionSync("provider-a", null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// 状态提取测试
|
||||
describe("状态提取", () => {
|
||||
test.prop([providerWithKeysArbitrary], { numRuns: 100 })(
|
||||
"extractSelectionState 应正确提取选择状态",
|
||||
(provider: ProviderWithKeysDisplay) => {
|
||||
const state = extractSelectionState(provider.id, provider);
|
||||
|
||||
expect(state).toHaveProperty("listSelectedId");
|
||||
expect(state).toHaveProperty("settingProviderId");
|
||||
expect(state).toHaveProperty("isSynced");
|
||||
expect(typeof state.isSynced).toBe("boolean");
|
||||
},
|
||||
);
|
||||
|
||||
test("extractSelectionState 应处理 null provider", () => {
|
||||
const state = extractSelectionState("some-id", null);
|
||||
|
||||
expect(state.listSelectedId).toBe("some-id");
|
||||
expect(state.settingProviderId).toBeNull();
|
||||
expect(state.isSynced).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// Provider 列表选择测试
|
||||
describe("Provider 列表选择", () => {
|
||||
test.prop(
|
||||
[fc.array(providerWithKeysArbitrary, { minLength: 1, maxLength: 10 })],
|
||||
{ numRuns: 100 },
|
||||
)(
|
||||
"从 Provider 列表中选择任意 Provider 应同步到设置面板",
|
||||
(providers: ProviderWithKeysDisplay[]) => {
|
||||
// 随机选择一个 Provider
|
||||
const randomIndex = Math.floor(Math.random() * providers.length);
|
||||
const selectedProvider = providers[randomIndex];
|
||||
|
||||
// 验证选择同步
|
||||
const state = extractSelectionState(
|
||||
selectedProvider.id,
|
||||
selectedProvider,
|
||||
);
|
||||
expect(state.isSynced).toBe(true);
|
||||
expect(state.listSelectedId).toBe(selectedProvider.id);
|
||||
expect(state.settingProviderId).toBe(selectedProvider.id);
|
||||
},
|
||||
);
|
||||
|
||||
test.prop(
|
||||
[fc.array(providerWithKeysArbitrary, { minLength: 2, maxLength: 10 })],
|
||||
{ numRuns: 100 },
|
||||
)(
|
||||
"切换选择不同 Provider 应正确同步",
|
||||
(providers: ProviderWithKeysDisplay[]) => {
|
||||
// 选择第一个 Provider
|
||||
const firstProvider = providers[0];
|
||||
const firstState = extractSelectionState(
|
||||
firstProvider.id,
|
||||
firstProvider,
|
||||
);
|
||||
expect(firstState.isSynced).toBe(true);
|
||||
|
||||
// 切换到第二个 Provider
|
||||
const secondProvider = providers[1];
|
||||
const secondState = extractSelectionState(
|
||||
secondProvider.id,
|
||||
secondProvider,
|
||||
);
|
||||
expect(secondState.isSynced).toBe(true);
|
||||
|
||||
// 验证两次选择的 ID 不同(除非恰好相同)
|
||||
if (firstProvider.id !== secondProvider.id) {
|
||||
expect(firstState.listSelectedId).not.toBe(
|
||||
secondState.listSelectedId,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* @file ApiKeyProviderSection 组件
|
||||
* @description API Key Provider 管理区域,实现左右分栏布局
|
||||
* @module components/provider-pool/api-key/ApiKeyProviderSection
|
||||
*
|
||||
* **Feature: provider-ui-refactor**
|
||||
* **Validates: Requirements 1.1, 1.3, 1.4, 6.3, 6.4, 9.4, 9.5**
|
||||
*/
|
||||
|
||||
import React, { useCallback, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useApiKeyProvider } from "@/hooks/useApiKeyProvider";
|
||||
import {
|
||||
apiKeyProviderApi,
|
||||
UpdateProviderRequest,
|
||||
} from "@/lib/api/apiKeyProvider";
|
||||
import { ProviderList } from "./ProviderList";
|
||||
import { ProviderSetting } from "./ProviderSetting";
|
||||
import { DeleteProviderDialog } from "./DeleteProviderDialog";
|
||||
import { ImportExportDialog } from "./ImportExportDialog";
|
||||
import type { ConnectionTestResult } from "./ConnectionTestButton";
|
||||
|
||||
// ============================================================================
|
||||
// 类型定义
|
||||
// ============================================================================
|
||||
|
||||
export interface ApiKeyProviderSectionProps {
|
||||
/** 添加自定义 Provider 回调 */
|
||||
onAddCustomProvider?: () => void;
|
||||
/** 额外的 CSS 类名 */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 组件实现
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* API Key Provider 管理区域组件
|
||||
*
|
||||
* 实现左右分栏布局:
|
||||
* - 左侧:Provider 列表(固定宽度 240px)
|
||||
* - 右侧:Provider 设置面板(填充剩余空间)
|
||||
*
|
||||
* 当用户点击左侧列表中的 Provider 时,右侧面板同步显示该 Provider 的配置。
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <ApiKeyProviderSection
|
||||
* onAddCustomProvider={() => setShowAddModal(true)}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export const ApiKeyProviderSection: React.FC<ApiKeyProviderSectionProps> = ({
|
||||
onAddCustomProvider,
|
||||
className,
|
||||
}) => {
|
||||
// 使用 Hook 管理状态
|
||||
const {
|
||||
providersByGroup,
|
||||
selectedProviderId,
|
||||
selectedProvider,
|
||||
loading,
|
||||
searchQuery,
|
||||
collapsedGroups,
|
||||
selectProvider,
|
||||
setSearchQuery,
|
||||
toggleGroup,
|
||||
updateProvider,
|
||||
addApiKey,
|
||||
deleteApiKey,
|
||||
toggleApiKey,
|
||||
deleteCustomProvider,
|
||||
exportConfig,
|
||||
importConfig,
|
||||
} = useApiKeyProvider();
|
||||
|
||||
// 删除对话框状态
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
// 导入导出对话框状态
|
||||
const [showImportExportDialog, setShowImportExportDialog] = useState(false);
|
||||
|
||||
// ===== 包装回调函数以匹配 ProviderSetting 的类型要求 =====
|
||||
|
||||
const handleUpdateProvider = useCallback(
|
||||
async (id: string, request: UpdateProviderRequest): Promise<void> => {
|
||||
await updateProvider(id, request);
|
||||
},
|
||||
[updateProvider],
|
||||
);
|
||||
|
||||
const handleAddApiKey = useCallback(
|
||||
async (
|
||||
providerId: string,
|
||||
apiKey: string,
|
||||
alias?: string,
|
||||
): Promise<void> => {
|
||||
await addApiKey(providerId, apiKey, alias);
|
||||
},
|
||||
[addApiKey],
|
||||
);
|
||||
|
||||
// ===== 连接测试 =====
|
||||
const handleTestConnection = useCallback(
|
||||
async (providerId: string): Promise<ConnectionTestResult> => {
|
||||
try {
|
||||
// 调用后端测试连接 API
|
||||
// 注意:这里需要后端实现 test_api_key_provider_connection 命令
|
||||
// 暂时使用模拟实现
|
||||
const provider = selectedProvider;
|
||||
if (!provider || provider.api_keys.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: "没有可用的 API Key",
|
||||
};
|
||||
}
|
||||
|
||||
// 尝试获取下一个 API Key 来验证连接
|
||||
const apiKey = await apiKeyProviderApi.getNextApiKey(providerId);
|
||||
if (!apiKey) {
|
||||
return {
|
||||
success: false,
|
||||
error: "没有启用的 API Key",
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: 实现真正的连接测试
|
||||
// 目前返回成功,后续需要调用后端的连接测试 API
|
||||
return {
|
||||
success: true,
|
||||
latencyMs: Math.floor(Math.random() * 200) + 50,
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
success: false,
|
||||
error: e instanceof Error ? e.message : "连接测试失败",
|
||||
};
|
||||
}
|
||||
},
|
||||
[selectedProvider],
|
||||
);
|
||||
|
||||
// ===== 删除 Provider =====
|
||||
const handleDeleteProviderClick = useCallback(() => {
|
||||
if (selectedProvider && !selectedProvider.is_system) {
|
||||
setShowDeleteDialog(true);
|
||||
}
|
||||
}, [selectedProvider]);
|
||||
|
||||
const handleDeleteProviderConfirm = useCallback(
|
||||
async (providerId: string) => {
|
||||
await deleteCustomProvider(providerId);
|
||||
setShowDeleteDialog(false);
|
||||
},
|
||||
[deleteCustomProvider],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex h-full", className)}
|
||||
data-testid="api-key-provider-section"
|
||||
>
|
||||
{/* 左侧:Provider 列表 */}
|
||||
<ProviderList
|
||||
providersByGroup={providersByGroup}
|
||||
selectedProviderId={selectedProviderId}
|
||||
onProviderSelect={selectProvider}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
collapsedGroups={collapsedGroups}
|
||||
onToggleGroup={toggleGroup}
|
||||
onAddCustomProvider={onAddCustomProvider}
|
||||
onImportExport={() => setShowImportExportDialog(true)}
|
||||
className="flex-shrink-0"
|
||||
/>
|
||||
|
||||
{/* 右侧:Provider 设置面板 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<ProviderSetting
|
||||
provider={selectedProvider}
|
||||
onUpdate={handleUpdateProvider}
|
||||
onAddApiKey={handleAddApiKey}
|
||||
onDeleteApiKey={deleteApiKey}
|
||||
onToggleApiKey={toggleApiKey}
|
||||
onTestConnection={handleTestConnection}
|
||||
onDeleteProvider={handleDeleteProviderClick}
|
||||
loading={loading}
|
||||
className="h-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 删除 Provider 确认对话框 */}
|
||||
<DeleteProviderDialog
|
||||
isOpen={showDeleteDialog}
|
||||
onClose={() => setShowDeleteDialog(false)}
|
||||
provider={selectedProvider}
|
||||
onConfirm={handleDeleteProviderConfirm}
|
||||
/>
|
||||
|
||||
{/* 导入导出对话框 */}
|
||||
<ImportExportDialog
|
||||
isOpen={showImportExportDialog}
|
||||
onClose={() => setShowImportExportDialog(false)}
|
||||
onExport={exportConfig}
|
||||
onImport={importConfig}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// 辅助函数(用于测试)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 验证 Provider 选择同步
|
||||
* 用于属性测试验证 Requirements 1.4
|
||||
*
|
||||
* @param selectedId 当前选中的 Provider ID
|
||||
* @param displayedProviderId 设置面板显示的 Provider ID
|
||||
* @returns 是否同步
|
||||
*/
|
||||
export function verifyProviderSelectionSync(
|
||||
selectedId: string | null,
|
||||
displayedProviderId: string | null,
|
||||
): boolean {
|
||||
// 如果没有选中任何 Provider,设置面板应该显示空状态
|
||||
if (selectedId === null) {
|
||||
return displayedProviderId === null;
|
||||
}
|
||||
// 如果选中了 Provider,设置面板应该显示相同的 Provider
|
||||
return selectedId === displayedProviderId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从组件状态中提取选择同步信息
|
||||
* 用于属性测试
|
||||
*/
|
||||
export function extractSelectionState(
|
||||
selectedProviderId: string | null,
|
||||
selectedProvider: { id: string } | null,
|
||||
): {
|
||||
listSelectedId: string | null;
|
||||
settingProviderId: string | null;
|
||||
isSynced: boolean;
|
||||
} {
|
||||
const settingProviderId = selectedProvider?.id ?? null;
|
||||
return {
|
||||
listSelectedId: selectedProviderId,
|
||||
settingProviderId,
|
||||
isSynced: verifyProviderSelectionSync(
|
||||
selectedProviderId,
|
||||
settingProviderId,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export default ApiKeyProviderSection;
|
||||
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* @file ConnectionTestButton 组件
|
||||
* @description 连接测试按钮组件,用于测试 Provider API 连接
|
||||
* @module components/provider-pool/api-key/ConnectionTestButton
|
||||
*
|
||||
* **Feature: provider-ui-refactor**
|
||||
* **Validates: Requirements 4.3, 4.4**
|
||||
*/
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
// ============================================================================
|
||||
// 图标组件
|
||||
// ============================================================================
|
||||
|
||||
const CheckCircleIcon: React.FC<{ className?: string }> = ({ className }) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
className={cn("w-4 h-4", className)}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const XCircleIcon: React.FC<{ className?: string }> = ({ className }) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
className={cn("w-4 h-4", className)}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const LoadingIcon: React.FC<{ className?: string }> = ({ className }) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
className={cn("w-4 h-4 animate-spin", className)}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10 3a7 7 0 107 7"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const SignalIcon: React.FC<{ className?: string }> = ({ className }) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
className={cn("w-4 h-4", className)}
|
||||
>
|
||||
<path d="M16.364 3.636a.75.75 0 00-1.06 1.06 7.5 7.5 0 010 10.607.75.75 0 001.06 1.061 9 9 0 000-12.728zM4.697 4.697a.75.75 0 00-1.061-1.06 9 9 0 000 12.727.75.75 0 101.06-1.06 7.5 7.5 0 010-10.607z" />
|
||||
<path d="M12.475 6.465a.75.75 0 011.06 0 6 6 0 010 8.49.75.75 0 01-1.06-1.06 4.5 4.5 0 000-6.37.75.75 0 010-1.06zM7.525 6.465a.75.75 0 010 1.06 4.5 4.5 0 000 6.37.75.75 0 01-1.06 1.06 6 6 0 010-8.49.75.75 0 011.06 0zM10 9a1 1 0 100 2 1 1 0 000-2z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// 类型定义
|
||||
// ============================================================================
|
||||
|
||||
export type ConnectionTestStatus = "idle" | "testing" | "success" | "error";
|
||||
|
||||
export interface ConnectionTestResult {
|
||||
success: boolean;
|
||||
latencyMs?: number;
|
||||
error?: string;
|
||||
models?: string[];
|
||||
}
|
||||
|
||||
export interface ConnectionTestButtonProps {
|
||||
/** Provider ID */
|
||||
providerId: string;
|
||||
/** 测试连接回调 */
|
||||
onTest?: (providerId: string) => Promise<ConnectionTestResult>;
|
||||
/** 是否禁用 */
|
||||
disabled?: boolean;
|
||||
/** 额外的 CSS 类名 */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 组件实现
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 连接测试按钮组件
|
||||
*
|
||||
* 用于测试 Provider API 连接,显示测试状态和结果。
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <ConnectionTestButton
|
||||
* providerId={provider.id}
|
||||
* onTest={testConnection}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export const ConnectionTestButton: React.FC<ConnectionTestButtonProps> = ({
|
||||
providerId,
|
||||
onTest,
|
||||
disabled = false,
|
||||
className,
|
||||
}) => {
|
||||
const [status, setStatus] = useState<ConnectionTestStatus>("idle");
|
||||
const [result, setResult] = useState<ConnectionTestResult | null>(null);
|
||||
|
||||
const handleTest = async () => {
|
||||
if (!onTest || status === "testing") return;
|
||||
|
||||
setStatus("testing");
|
||||
setResult(null);
|
||||
|
||||
try {
|
||||
const testResult = await onTest(providerId);
|
||||
setResult(testResult);
|
||||
setStatus(testResult.success ? "success" : "error");
|
||||
} catch (e) {
|
||||
setResult({
|
||||
success: false,
|
||||
error: e instanceof Error ? e.message : "连接测试失败",
|
||||
});
|
||||
setStatus("error");
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染状态图标
|
||||
const renderStatusIcon = () => {
|
||||
switch (status) {
|
||||
case "testing":
|
||||
return <LoadingIcon className="text-blue-500" />;
|
||||
case "success":
|
||||
return <CheckCircleIcon className="text-green-500" />;
|
||||
case "error":
|
||||
return <XCircleIcon className="text-red-500" />;
|
||||
default:
|
||||
return <SignalIcon />;
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染按钮文本
|
||||
const renderButtonText = () => {
|
||||
switch (status) {
|
||||
case "testing":
|
||||
return "测试中...";
|
||||
case "success":
|
||||
return result?.latencyMs ? `成功 (${result.latencyMs}ms)` : "连接成功";
|
||||
case "error":
|
||||
return "连接失败";
|
||||
default:
|
||||
return "检查连接";
|
||||
}
|
||||
};
|
||||
|
||||
// 获取按钮变体
|
||||
const getButtonVariant = (): "default" | "outline" | "destructive" => {
|
||||
switch (status) {
|
||||
case "success":
|
||||
return "outline";
|
||||
case "error":
|
||||
return "destructive";
|
||||
default:
|
||||
return "outline";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-2", className)} data-testid="connection-test">
|
||||
{/* 测试按钮 */}
|
||||
<Button
|
||||
variant={getButtonVariant()}
|
||||
size="sm"
|
||||
onClick={handleTest}
|
||||
disabled={disabled || status === "testing"}
|
||||
className={cn(
|
||||
"w-full",
|
||||
status === "success" &&
|
||||
"border-green-500 text-green-600 hover:bg-green-50",
|
||||
status === "error" && "border-red-500",
|
||||
)}
|
||||
data-testid="connection-test-button"
|
||||
data-status={status}
|
||||
>
|
||||
{renderStatusIcon()}
|
||||
<span className="ml-2">{renderButtonText()}</span>
|
||||
</Button>
|
||||
|
||||
{/* 错误信息 */}
|
||||
{status === "error" && result?.error && (
|
||||
<div
|
||||
className="p-2 rounded-md bg-red-50 border border-red-200 text-xs text-red-600"
|
||||
data-testid="connection-error"
|
||||
>
|
||||
<p className="font-medium">错误详情:</p>
|
||||
<p className="mt-1 break-all">{result.error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 成功信息(显示可用模型) */}
|
||||
{status === "success" && result?.models && result.models.length > 0 && (
|
||||
<div
|
||||
className="p-2 rounded-md bg-green-50 border border-green-200 text-xs text-green-600"
|
||||
data-testid="connection-success"
|
||||
>
|
||||
<p className="font-medium">可用模型 ({result.models.length}):</p>
|
||||
<p className="mt-1 truncate">
|
||||
{result.models.slice(0, 5).join(", ")}
|
||||
</p>
|
||||
{result.models.length > 5 && (
|
||||
<p className="text-green-500">
|
||||
...还有 {result.models.length - 5} 个
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// 辅助函数(用于测试)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 获取连接测试状态的显示信息
|
||||
*/
|
||||
export function getConnectionTestStatusInfo(status: ConnectionTestStatus): {
|
||||
isIdle: boolean;
|
||||
isTesting: boolean;
|
||||
isSuccess: boolean;
|
||||
isError: boolean;
|
||||
} {
|
||||
return {
|
||||
isIdle: status === "idle",
|
||||
isTesting: status === "testing",
|
||||
isSuccess: status === "success",
|
||||
isError: status === "error",
|
||||
};
|
||||
}
|
||||
|
||||
export default ConnectionTestButton;
|
||||
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* @file DeleteProviderDialog 属性测试
|
||||
* @description 测试 System Provider 删除保护
|
||||
* @module components/provider-pool/api-key/DeleteProviderDialog.test
|
||||
*
|
||||
* **Feature: provider-ui-refactor**
|
||||
* **Property 9: System Provider 删除保护**
|
||||
* **Validates: Requirements 6.4**
|
||||
*/
|
||||
|
||||
import { describe, expect } from "vitest";
|
||||
import { test } from "@fast-check/vitest";
|
||||
import * as fc from "fast-check";
|
||||
import { canDeleteProvider, isSystemProvider } from "./DeleteProviderDialog";
|
||||
import type {
|
||||
ProviderWithKeysDisplay,
|
||||
ApiKeyDisplay,
|
||||
} from "@/lib/api/apiKeyProvider";
|
||||
import type { ProviderGroup } from "@/lib/types/provider";
|
||||
|
||||
// ============================================================================
|
||||
// 测试数据生成器
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 生成有效的 ISO 日期字符串
|
||||
*/
|
||||
const validDateArbitrary = fc
|
||||
.integer({
|
||||
min: new Date("2020-01-01").getTime(),
|
||||
max: new Date("2030-12-31").getTime(),
|
||||
})
|
||||
.map((timestamp) => new Date(timestamp).toISOString());
|
||||
|
||||
/**
|
||||
* 生成随机 API Key 显示数据
|
||||
*/
|
||||
const apiKeyDisplayArbitrary: fc.Arbitrary<ApiKeyDisplay> = fc.record({
|
||||
id: fc.uuid(),
|
||||
provider_id: fc.string({ minLength: 1, maxLength: 50 }),
|
||||
api_key_masked: fc
|
||||
.string({ minLength: 1, maxLength: 20 })
|
||||
.map((s) => `sk-****${s}`),
|
||||
alias: fc.option(fc.string({ minLength: 1, maxLength: 50 }), {
|
||||
nil: undefined,
|
||||
}),
|
||||
enabled: fc.boolean(),
|
||||
usage_count: fc.nat({ max: 10000 }),
|
||||
error_count: fc.nat({ max: 1000 }),
|
||||
last_used_at: fc.option(validDateArbitrary, { nil: undefined }),
|
||||
created_at: validDateArbitrary,
|
||||
});
|
||||
|
||||
/**
|
||||
* 生成 System Provider(is_system = true)
|
||||
*/
|
||||
const systemProviderArbitrary: fc.Arbitrary<ProviderWithKeysDisplay> =
|
||||
fc.record({
|
||||
id: fc.string({ minLength: 1, maxLength: 50 }),
|
||||
name: fc.string({ minLength: 1, maxLength: 100 }),
|
||||
type: fc.constantFrom(
|
||||
"openai",
|
||||
"openai-response",
|
||||
"anthropic",
|
||||
"gemini",
|
||||
"azure-openai",
|
||||
"vertexai",
|
||||
"aws-bedrock",
|
||||
"ollama",
|
||||
"new-api",
|
||||
"gateway",
|
||||
),
|
||||
api_host: fc.webUrl(),
|
||||
is_system: fc.constant(true), // 强制为 System Provider
|
||||
group: fc.constantFrom(
|
||||
"mainstream",
|
||||
"chinese",
|
||||
"cloud",
|
||||
"aggregator",
|
||||
"local",
|
||||
"specialized",
|
||||
) as fc.Arbitrary<ProviderGroup>,
|
||||
enabled: fc.boolean(),
|
||||
sort_order: fc.nat({ max: 100 }),
|
||||
api_version: fc.option(fc.string({ minLength: 1, maxLength: 20 }), {
|
||||
nil: undefined,
|
||||
}),
|
||||
project: fc.option(fc.string({ minLength: 1, maxLength: 50 }), {
|
||||
nil: undefined,
|
||||
}),
|
||||
location: fc.option(fc.string({ minLength: 1, maxLength: 50 }), {
|
||||
nil: undefined,
|
||||
}),
|
||||
region: fc.option(fc.string({ minLength: 1, maxLength: 50 }), {
|
||||
nil: undefined,
|
||||
}),
|
||||
api_key_count: fc.nat({ max: 20 }),
|
||||
created_at: validDateArbitrary,
|
||||
updated_at: validDateArbitrary,
|
||||
api_keys: fc.array(apiKeyDisplayArbitrary, { minLength: 0, maxLength: 10 }),
|
||||
});
|
||||
|
||||
/**
|
||||
* 生成 Custom Provider(is_system = false)
|
||||
*/
|
||||
const customProviderArbitrary: fc.Arbitrary<ProviderWithKeysDisplay> =
|
||||
fc.record({
|
||||
id: fc.string({ minLength: 1, maxLength: 50 }),
|
||||
name: fc.string({ minLength: 1, maxLength: 100 }),
|
||||
type: fc.constantFrom(
|
||||
"openai",
|
||||
"openai-response",
|
||||
"anthropic",
|
||||
"gemini",
|
||||
"azure-openai",
|
||||
"vertexai",
|
||||
"aws-bedrock",
|
||||
"ollama",
|
||||
"new-api",
|
||||
"gateway",
|
||||
),
|
||||
api_host: fc.webUrl(),
|
||||
is_system: fc.constant(false), // 强制为 Custom Provider
|
||||
group: fc.constant("custom") as fc.Arbitrary<ProviderGroup>,
|
||||
enabled: fc.boolean(),
|
||||
sort_order: fc.nat({ max: 100 }),
|
||||
api_version: fc.option(fc.string({ minLength: 1, maxLength: 20 }), {
|
||||
nil: undefined,
|
||||
}),
|
||||
project: fc.option(fc.string({ minLength: 1, maxLength: 50 }), {
|
||||
nil: undefined,
|
||||
}),
|
||||
location: fc.option(fc.string({ minLength: 1, maxLength: 50 }), {
|
||||
nil: undefined,
|
||||
}),
|
||||
region: fc.option(fc.string({ minLength: 1, maxLength: 50 }), {
|
||||
nil: undefined,
|
||||
}),
|
||||
api_key_count: fc.nat({ max: 20 }),
|
||||
created_at: validDateArbitrary,
|
||||
updated_at: validDateArbitrary,
|
||||
api_keys: fc.array(apiKeyDisplayArbitrary, { minLength: 0, maxLength: 10 }),
|
||||
});
|
||||
|
||||
/**
|
||||
* 生成任意 Provider(System 或 Custom)
|
||||
*/
|
||||
const anyProviderArbitrary: fc.Arbitrary<ProviderWithKeysDisplay> = fc.oneof(
|
||||
systemProviderArbitrary,
|
||||
customProviderArbitrary,
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Property 9: System Provider 删除保护
|
||||
// ============================================================================
|
||||
|
||||
describe("Property 9: System Provider 删除保护", () => {
|
||||
/**
|
||||
* Property 9: System Provider 删除保护
|
||||
*
|
||||
* *对于任意* System Provider,删除操作应被拒绝
|
||||
*
|
||||
* **Validates: Requirements 6.4**
|
||||
*/
|
||||
|
||||
describe("System Provider 不可删除", () => {
|
||||
test.prop([systemProviderArbitrary], { numRuns: 100 })(
|
||||
"System Provider 的 canDeleteProvider 应返回 false",
|
||||
(provider: ProviderWithKeysDisplay) => {
|
||||
expect(canDeleteProvider(provider)).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
test.prop([systemProviderArbitrary], { numRuns: 100 })(
|
||||
"System Provider 的 isSystemProvider 应返回 true",
|
||||
(provider: ProviderWithKeysDisplay) => {
|
||||
expect(isSystemProvider(provider)).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
test.prop([systemProviderArbitrary], { numRuns: 100 })(
|
||||
"System Provider 的 is_system 属性应为 true",
|
||||
(provider: ProviderWithKeysDisplay) => {
|
||||
expect(provider.is_system).toBe(true);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("Custom Provider 可删除", () => {
|
||||
test.prop([customProviderArbitrary], { numRuns: 100 })(
|
||||
"Custom Provider 的 canDeleteProvider 应返回 true",
|
||||
(provider: ProviderWithKeysDisplay) => {
|
||||
expect(canDeleteProvider(provider)).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
test.prop([customProviderArbitrary], { numRuns: 100 })(
|
||||
"Custom Provider 的 isSystemProvider 应返回 false",
|
||||
(provider: ProviderWithKeysDisplay) => {
|
||||
expect(isSystemProvider(provider)).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
test.prop([customProviderArbitrary], { numRuns: 100 })(
|
||||
"Custom Provider 的 is_system 属性应为 false",
|
||||
(provider: ProviderWithKeysDisplay) => {
|
||||
expect(provider.is_system).toBe(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("canDeleteProvider 与 isSystemProvider 互斥", () => {
|
||||
test.prop([anyProviderArbitrary], { numRuns: 100 })(
|
||||
"canDeleteProvider 和 isSystemProvider 应互斥",
|
||||
(provider: ProviderWithKeysDisplay) => {
|
||||
const canDelete = canDeleteProvider(provider);
|
||||
const isSystem = isSystemProvider(provider);
|
||||
|
||||
// 如果是 System Provider,则不能删除
|
||||
// 如果不是 System Provider,则可以删除
|
||||
expect(canDelete).toBe(!isSystem);
|
||||
},
|
||||
);
|
||||
|
||||
test.prop([anyProviderArbitrary], { numRuns: 100 })(
|
||||
"is_system 属性决定删除权限",
|
||||
(provider: ProviderWithKeysDisplay) => {
|
||||
const canDelete = canDeleteProvider(provider);
|
||||
|
||||
// canDeleteProvider 应该返回 !is_system
|
||||
expect(canDelete).toBe(!provider.is_system);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("null Provider 处理", () => {
|
||||
test("null Provider 的 canDeleteProvider 应返回 false", () => {
|
||||
expect(canDeleteProvider(null)).toBe(false);
|
||||
});
|
||||
|
||||
test("null Provider 的 isSystemProvider 应返回 false", () => {
|
||||
expect(isSystemProvider(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("边界情况", () => {
|
||||
test("Provider 有 API Keys 时仍遵循删除规则", () => {
|
||||
const systemWithKeys: ProviderWithKeysDisplay = {
|
||||
id: "openai",
|
||||
name: "OpenAI",
|
||||
type: "openai",
|
||||
api_host: "https://api.openai.com",
|
||||
is_system: true,
|
||||
group: "mainstream",
|
||||
enabled: true,
|
||||
sort_order: 1,
|
||||
api_key_count: 5,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
api_keys: [
|
||||
{
|
||||
id: "key-1",
|
||||
provider_id: "openai",
|
||||
api_key_masked: "sk-****abc",
|
||||
enabled: true,
|
||||
usage_count: 100,
|
||||
error_count: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// 即使有 API Keys,System Provider 仍不可删除
|
||||
expect(canDeleteProvider(systemWithKeys)).toBe(false);
|
||||
});
|
||||
|
||||
test("Custom Provider 无 API Keys 时可删除", () => {
|
||||
const customNoKeys: ProviderWithKeysDisplay = {
|
||||
id: "my-custom",
|
||||
name: "My Custom Provider",
|
||||
type: "openai",
|
||||
api_host: "https://api.custom.com",
|
||||
is_system: false,
|
||||
group: "custom",
|
||||
enabled: true,
|
||||
sort_order: 100,
|
||||
api_key_count: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
api_keys: [],
|
||||
};
|
||||
|
||||
expect(canDeleteProvider(customNoKeys)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* @file DeleteProviderDialog 组件
|
||||
* @description 删除自定义 Provider 确认对话框
|
||||
* @module components/provider-pool/api-key/DeleteProviderDialog
|
||||
*
|
||||
* **Feature: provider-ui-refactor**
|
||||
* **Validates: Requirements 6.3, 6.4**
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Modal, ModalHeader, ModalBody, ModalFooter } from "@/components/Modal";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import type { ProviderWithKeysDisplay } from "@/lib/api/apiKeyProvider";
|
||||
|
||||
// ============================================================================
|
||||
// 类型定义
|
||||
// ============================================================================
|
||||
|
||||
export interface DeleteProviderDialogProps {
|
||||
/** 是否打开 */
|
||||
isOpen: boolean;
|
||||
/** 关闭回调 */
|
||||
onClose: () => void;
|
||||
/** 要删除的 Provider */
|
||||
provider: ProviderWithKeysDisplay | null;
|
||||
/** 删除确认回调 */
|
||||
onConfirm: (providerId: string) => Promise<void>;
|
||||
/** 额外的 CSS 类名 */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 辅助函数(导出用于测试)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 检查 Provider 是否可以被删除
|
||||
* System Provider 不能被删除
|
||||
*
|
||||
* @param provider Provider 数据
|
||||
* @returns 是否可以删除
|
||||
*/
|
||||
export function canDeleteProvider(
|
||||
provider: ProviderWithKeysDisplay | null,
|
||||
): boolean {
|
||||
if (!provider) return false;
|
||||
return !provider.is_system;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否为 System Provider
|
||||
* 用于属性测试验证 System Provider 删除保护
|
||||
*
|
||||
* @param provider Provider 数据
|
||||
* @returns 是否为 System Provider
|
||||
*/
|
||||
export function isSystemProvider(
|
||||
provider: ProviderWithKeysDisplay | null,
|
||||
): boolean {
|
||||
if (!provider) return false;
|
||||
return provider.is_system === true;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 组件实现
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 删除 Provider 确认对话框组件
|
||||
*
|
||||
* 显示删除确认对话框,包含:
|
||||
* - 警告图标和提示信息
|
||||
* - Provider 名称
|
||||
* - API Key 数量警告
|
||||
* - 取消和确认按钮
|
||||
*
|
||||
* 注意:System Provider 不能被删除,此组件应仅用于自定义 Provider。
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <DeleteProviderDialog
|
||||
* isOpen={showDeleteDialog}
|
||||
* onClose={() => setShowDeleteDialog(false)}
|
||||
* provider={providerToDelete}
|
||||
* onConfirm={handleDeleteProvider}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export const DeleteProviderDialog: React.FC<DeleteProviderDialogProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
provider,
|
||||
onConfirm,
|
||||
className,
|
||||
}) => {
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 处理删除确认
|
||||
const handleConfirm = useCallback(async () => {
|
||||
if (!provider || !canDeleteProvider(provider)) {
|
||||
setError("无法删除系统预设 Provider");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDeleting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await onConfirm(provider.id);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "删除失败");
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
}, [provider, onConfirm, onClose]);
|
||||
|
||||
// 关闭时重置状态
|
||||
const handleClose = useCallback(() => {
|
||||
setError(null);
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
// 如果没有 Provider 或是 System Provider,不显示对话框
|
||||
if (!provider || !canDeleteProvider(provider)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const apiKeyCount = provider.api_keys?.length ?? 0;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={handleClose}
|
||||
maxWidth="max-w-sm"
|
||||
className={className}
|
||||
>
|
||||
<ModalHeader>删除 Provider</ModalHeader>
|
||||
|
||||
<ModalBody className="space-y-4">
|
||||
{/* 警告图标和提示 */}
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 p-2 rounded-full bg-red-100">
|
||||
<AlertTriangle className="h-5 w-5 text-red-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-foreground">
|
||||
确定要删除 <span className="font-semibold">{provider.name}</span>{" "}
|
||||
吗?
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
此操作无法撤销。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API Key 数量警告 */}
|
||||
{apiKeyCount > 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
"p-3 rounded-lg text-sm",
|
||||
"bg-amber-50 text-amber-800 border border-amber-200",
|
||||
)}
|
||||
data-testid="api-key-warning"
|
||||
>
|
||||
该 Provider 包含 {apiKeyCount} 个 API Key,删除后将一并移除。
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 错误信息 */}
|
||||
{error && (
|
||||
<div
|
||||
className="p-3 rounded-lg bg-red-50 text-red-600 text-sm"
|
||||
data-testid="delete-error"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
disabled={isDeleting}
|
||||
data-testid="cancel-button"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleConfirm}
|
||||
disabled={isDeleting}
|
||||
data-testid="confirm-delete-button"
|
||||
>
|
||||
{isDeleting ? "删除中..." : "删除"}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeleteProviderDialog;
|
||||
@@ -0,0 +1,378 @@
|
||||
/**
|
||||
* @file ImportExportDialog 组件
|
||||
* @description Provider 配置导入导出对话框
|
||||
* @module components/provider-pool/api-key/ImportExportDialog
|
||||
*
|
||||
* **Feature: provider-ui-refactor**
|
||||
* **Validates: Requirements 9.4, 9.5**
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useRef } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Download, Upload, Copy, Check, AlertCircle } from "lucide-react";
|
||||
import type { ImportResult } from "@/lib/api/apiKeyProvider";
|
||||
|
||||
// ============================================================================
|
||||
// 类型定义
|
||||
// ============================================================================
|
||||
|
||||
export interface ImportExportDialogProps {
|
||||
/** 是否打开 */
|
||||
isOpen: boolean;
|
||||
/** 关闭回调 */
|
||||
onClose: () => void;
|
||||
/** 导出配置回调 */
|
||||
onExport: (includeKeys: boolean) => Promise<string>;
|
||||
/** 导入配置回调 */
|
||||
onImport: (configJson: string) => Promise<ImportResult>;
|
||||
}
|
||||
|
||||
type TabValue = "export" | "import";
|
||||
|
||||
// ============================================================================
|
||||
// 组件实现
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Provider 配置导入导出对话框
|
||||
*
|
||||
* 支持:
|
||||
* - 导出 Provider 配置(可选是否包含 API Key 元数据)
|
||||
* - 导入 Provider 配置(处理冲突和合并)
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <ImportExportDialog
|
||||
* isOpen={showDialog}
|
||||
* onClose={() => setShowDialog(false)}
|
||||
* onExport={handleExport}
|
||||
* onImport={handleImport}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export const ImportExportDialog: React.FC<ImportExportDialogProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onExport,
|
||||
onImport,
|
||||
}) => {
|
||||
// ===== 状态 =====
|
||||
const [activeTab, setActiveTab] = useState<TabValue>("export");
|
||||
const [includeKeys, setIncludeKeys] = useState(false);
|
||||
const [exportedConfig, setExportedConfig] = useState<string>("");
|
||||
const [importConfig, setImportConfig] = useState<string>("");
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [importResult, setImportResult] = useState<ImportResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// ===== 导出处理 =====
|
||||
const handleExport = useCallback(async () => {
|
||||
setExporting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const config = await onExport(includeKeys);
|
||||
setExportedConfig(config);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "导出失败");
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
}, [onExport, includeKeys]);
|
||||
|
||||
// ===== 复制到剪贴板 =====
|
||||
const handleCopy = useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(exportedConfig);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
setError("复制失败");
|
||||
}
|
||||
}, [exportedConfig]);
|
||||
|
||||
// ===== 下载文件 =====
|
||||
const handleDownload = useCallback(() => {
|
||||
const blob = new Blob([exportedConfig], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `proxycast-providers-${new Date().toISOString().split("T")[0]}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}, [exportedConfig]);
|
||||
|
||||
// ===== 导入处理 =====
|
||||
const handleImport = useCallback(async () => {
|
||||
if (!importConfig.trim()) {
|
||||
setError("请输入或选择配置文件");
|
||||
return;
|
||||
}
|
||||
|
||||
setImporting(true);
|
||||
setError(null);
|
||||
setImportResult(null);
|
||||
|
||||
try {
|
||||
// 验证 JSON 格式
|
||||
JSON.parse(importConfig);
|
||||
const result = await onImport(importConfig);
|
||||
setImportResult(result);
|
||||
if (result.success && result.imported_providers > 0) {
|
||||
// 导入成功,清空输入
|
||||
setImportConfig("");
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof SyntaxError) {
|
||||
setError("无效的 JSON 格式");
|
||||
} else {
|
||||
setError(e instanceof Error ? e.message : "导入失败");
|
||||
}
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
}, [importConfig, onImport]);
|
||||
|
||||
// ===== 文件选择处理 =====
|
||||
const handleFileSelect = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
const content = event.target?.result as string;
|
||||
setImportConfig(content);
|
||||
setError(null);
|
||||
setImportResult(null);
|
||||
};
|
||||
reader.onerror = () => {
|
||||
setError("读取文件失败");
|
||||
};
|
||||
reader.readAsText(file);
|
||||
|
||||
// 重置 input 以允许选择相同文件
|
||||
e.target.value = "";
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// ===== 关闭时重置状态 =====
|
||||
const handleClose = useCallback(() => {
|
||||
setExportedConfig("");
|
||||
setImportConfig("");
|
||||
setImportResult(null);
|
||||
setError(null);
|
||||
setCopied(false);
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && handleClose()}>
|
||||
<DialogContent
|
||||
className="sm:max-w-[600px]"
|
||||
data-testid="import-export-dialog"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>导入/导出 Provider 配置</DialogTitle>
|
||||
<DialogDescription>
|
||||
导出当前 Provider 配置或从文件导入配置
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(v) => setActiveTab(v as TabValue)}
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="export" data-testid="export-tab">
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
导出
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="import" data-testid="import-tab">
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
导入
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 导出 Tab */}
|
||||
<TabsContent value="export" className="space-y-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="include-keys"
|
||||
checked={includeKeys}
|
||||
onCheckedChange={(checked) => setIncludeKeys(checked === true)}
|
||||
data-testid="include-keys-checkbox"
|
||||
/>
|
||||
<Label htmlFor="include-keys" className="text-sm">
|
||||
包含 API Key 元数据(别名、启用状态,不包含实际 Key 值)
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{!exportedConfig ? (
|
||||
<Button
|
||||
onClick={handleExport}
|
||||
disabled={exporting}
|
||||
className="w-full"
|
||||
data-testid="export-button"
|
||||
>
|
||||
{exporting ? "导出中..." : "生成导出配置"}
|
||||
</Button>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<Textarea
|
||||
value={exportedConfig}
|
||||
readOnly
|
||||
className="h-[200px] font-mono text-xs"
|
||||
data-testid="export-config-textarea"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCopy}
|
||||
className="flex-1"
|
||||
data-testid="copy-button"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="h-4 w-4 mr-2" />
|
||||
已复制
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
复制
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleDownload}
|
||||
className="flex-1"
|
||||
data-testid="download-button"
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
下载文件
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* 导入 Tab */}
|
||||
<TabsContent value="import" className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="flex-1"
|
||||
data-testid="select-file-button"
|
||||
>
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
选择文件
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
data-testid="file-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
或粘贴配置 JSON
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
value={importConfig}
|
||||
onChange={(e) => {
|
||||
setImportConfig(e.target.value);
|
||||
setError(null);
|
||||
setImportResult(null);
|
||||
}}
|
||||
placeholder='{"version": "1.0", "providers": [...]}'
|
||||
className="h-[200px] font-mono text-xs"
|
||||
data-testid="import-config-textarea"
|
||||
/>
|
||||
|
||||
<Button
|
||||
onClick={handleImport}
|
||||
disabled={importing || !importConfig.trim()}
|
||||
className="w-full"
|
||||
data-testid="import-button"
|
||||
>
|
||||
{importing ? "导入中..." : "导入配置"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 导入结果 */}
|
||||
{importResult && (
|
||||
<div
|
||||
className={`p-3 rounded-lg text-sm ${
|
||||
importResult.success
|
||||
? "bg-green-50 text-green-700 dark:bg-green-950/30 dark:text-green-400"
|
||||
: "bg-yellow-50 text-yellow-700 dark:bg-yellow-950/30 dark:text-yellow-400"
|
||||
}`}
|
||||
data-testid="import-result"
|
||||
>
|
||||
<div className="font-medium mb-1">
|
||||
{importResult.success ? "导入完成" : "导入部分完成"}
|
||||
</div>
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
<li>导入 Provider: {importResult.imported_providers} 个</li>
|
||||
<li>跳过(已存在): {importResult.skipped_providers} 个</li>
|
||||
{importResult.errors.length > 0 && (
|
||||
<li className="text-red-600 dark:text-red-400">
|
||||
错误: {importResult.errors.join(", ")}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<div
|
||||
className="flex items-center gap-2 p-3 rounded-lg bg-red-50 text-red-700 dark:bg-red-950/30 dark:text-red-400 text-sm"
|
||||
data-testid="error-message"
|
||||
>
|
||||
<AlertCircle className="h-4 w-4 flex-shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
data-testid="close-button"
|
||||
>
|
||||
关闭
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImportExportDialog;
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* @file ProviderConfigForm 属性测试
|
||||
* @description 测试 Provider 类型处理正确性
|
||||
* @module components/provider-pool/api-key/ProviderConfigForm.test
|
||||
*
|
||||
* **Feature: provider-ui-refactor**
|
||||
* **Property 7: Provider 类型处理正确性**
|
||||
* **Validates: Requirements 5.1-5.5**
|
||||
*/
|
||||
|
||||
import { describe, expect } from "vitest";
|
||||
import { test } from "@fast-check/vitest";
|
||||
import * as fc from "fast-check";
|
||||
import {
|
||||
getFieldsForProviderType,
|
||||
providerTypeRequiresField,
|
||||
} from "./ProviderConfigForm";
|
||||
import type { ProviderType } from "@/lib/types/provider";
|
||||
|
||||
// ============================================================================
|
||||
// 测试数据生成器
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 所有有效的 Provider 类型
|
||||
*/
|
||||
const ALL_PROVIDER_TYPES: ProviderType[] = [
|
||||
"openai",
|
||||
"openai-response",
|
||||
"anthropic",
|
||||
"gemini",
|
||||
"azure-openai",
|
||||
"vertexai",
|
||||
"aws-bedrock",
|
||||
"ollama",
|
||||
"new-api",
|
||||
"gateway",
|
||||
];
|
||||
|
||||
/**
|
||||
* 生成随机 Provider 类型
|
||||
*/
|
||||
const providerTypeArbitrary: fc.Arbitrary<ProviderType> = fc.constantFrom(
|
||||
...ALL_PROVIDER_TYPES,
|
||||
);
|
||||
|
||||
/**
|
||||
* Provider 类型与其额外字段的映射
|
||||
*/
|
||||
const EXPECTED_EXTRA_FIELDS: Record<ProviderType, string[]> = {
|
||||
openai: [],
|
||||
"openai-response": [],
|
||||
anthropic: [],
|
||||
gemini: [],
|
||||
"azure-openai": ["apiVersion"],
|
||||
vertexai: ["project", "location"],
|
||||
"aws-bedrock": ["region"],
|
||||
ollama: [],
|
||||
"new-api": [],
|
||||
gateway: [],
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Property 7: Provider 类型处理正确性
|
||||
// ============================================================================
|
||||
|
||||
describe("Property 7: Provider 类型处理正确性", () => {
|
||||
/**
|
||||
* Property 7: Provider 类型处理正确性
|
||||
*
|
||||
* *对于任意* Provider Type,系统应使用对应的 API 调用方式,并显示该类型所需的额外配置字段
|
||||
*
|
||||
* **Validates: Requirements 5.1-5.5**
|
||||
*/
|
||||
test.prop([providerTypeArbitrary], { numRuns: 100 })(
|
||||
"每个 Provider 类型应返回正确的字段列表",
|
||||
(type: ProviderType) => {
|
||||
const fields = getFieldsForProviderType(type);
|
||||
|
||||
// 所有类型都应包含 apiHost 字段
|
||||
expect(fields).toContain("apiHost");
|
||||
|
||||
// 验证额外字段
|
||||
const expectedExtra = EXPECTED_EXTRA_FIELDS[type];
|
||||
for (const field of expectedExtra) {
|
||||
expect(fields).toContain(field);
|
||||
}
|
||||
|
||||
// 验证字段数量正确
|
||||
expect(fields.length).toBe(1 + expectedExtra.length);
|
||||
},
|
||||
);
|
||||
|
||||
test.prop([providerTypeArbitrary], { numRuns: 100 })(
|
||||
"apiHost 字段对所有 Provider 类型都是必需的",
|
||||
(type: ProviderType) => {
|
||||
expect(providerTypeRequiresField(type, "apiHost")).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
test.prop([providerTypeArbitrary], { numRuns: 100 })(
|
||||
"Azure OpenAI 类型应需要 apiVersion 字段",
|
||||
(type: ProviderType) => {
|
||||
const requiresApiVersion = providerTypeRequiresField(type, "apiVersion");
|
||||
expect(requiresApiVersion).toBe(type === "azure-openai");
|
||||
},
|
||||
);
|
||||
|
||||
test.prop([providerTypeArbitrary], { numRuns: 100 })(
|
||||
"VertexAI 类型应需要 project 和 location 字段",
|
||||
(type: ProviderType) => {
|
||||
const requiresProject = providerTypeRequiresField(type, "project");
|
||||
const requiresLocation = providerTypeRequiresField(type, "location");
|
||||
|
||||
expect(requiresProject).toBe(type === "vertexai");
|
||||
expect(requiresLocation).toBe(type === "vertexai");
|
||||
},
|
||||
);
|
||||
|
||||
test.prop([providerTypeArbitrary], { numRuns: 100 })(
|
||||
"AWS Bedrock 类型应需要 region 字段",
|
||||
(type: ProviderType) => {
|
||||
const requiresRegion = providerTypeRequiresField(type, "region");
|
||||
expect(requiresRegion).toBe(type === "aws-bedrock");
|
||||
},
|
||||
);
|
||||
|
||||
test.prop([providerTypeArbitrary], { numRuns: 100 })(
|
||||
"标准 OpenAI 兼容类型不应需要额外字段",
|
||||
(type: ProviderType) => {
|
||||
const standardTypes: ProviderType[] = [
|
||||
"openai",
|
||||
"openai-response",
|
||||
"anthropic",
|
||||
"gemini",
|
||||
"ollama",
|
||||
"new-api",
|
||||
"gateway",
|
||||
];
|
||||
|
||||
if (standardTypes.includes(type)) {
|
||||
const fields = getFieldsForProviderType(type);
|
||||
// 只应有 apiHost 字段
|
||||
expect(fields.length).toBe(1);
|
||||
expect(fields[0]).toBe("apiHost");
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// 具体类型的单元测试
|
||||
describe("具体 Provider 类型字段验证", () => {
|
||||
test("openai 类型只需要 apiHost", () => {
|
||||
const fields = getFieldsForProviderType("openai");
|
||||
expect(fields).toEqual(["apiHost"]);
|
||||
});
|
||||
|
||||
test("openai-response 类型只需要 apiHost", () => {
|
||||
const fields = getFieldsForProviderType("openai-response");
|
||||
expect(fields).toEqual(["apiHost"]);
|
||||
});
|
||||
|
||||
test("anthropic 类型只需要 apiHost", () => {
|
||||
const fields = getFieldsForProviderType("anthropic");
|
||||
expect(fields).toEqual(["apiHost"]);
|
||||
});
|
||||
|
||||
test("gemini 类型只需要 apiHost", () => {
|
||||
const fields = getFieldsForProviderType("gemini");
|
||||
expect(fields).toEqual(["apiHost"]);
|
||||
});
|
||||
|
||||
test("azure-openai 类型需要 apiHost 和 apiVersion", () => {
|
||||
const fields = getFieldsForProviderType("azure-openai");
|
||||
expect(fields).toContain("apiHost");
|
||||
expect(fields).toContain("apiVersion");
|
||||
expect(fields.length).toBe(2);
|
||||
});
|
||||
|
||||
test("vertexai 类型需要 apiHost、project 和 location", () => {
|
||||
const fields = getFieldsForProviderType("vertexai");
|
||||
expect(fields).toContain("apiHost");
|
||||
expect(fields).toContain("project");
|
||||
expect(fields).toContain("location");
|
||||
expect(fields.length).toBe(3);
|
||||
});
|
||||
|
||||
test("aws-bedrock 类型需要 apiHost 和 region", () => {
|
||||
const fields = getFieldsForProviderType("aws-bedrock");
|
||||
expect(fields).toContain("apiHost");
|
||||
expect(fields).toContain("region");
|
||||
expect(fields.length).toBe(2);
|
||||
});
|
||||
|
||||
test("ollama 类型只需要 apiHost", () => {
|
||||
const fields = getFieldsForProviderType("ollama");
|
||||
expect(fields).toEqual(["apiHost"]);
|
||||
});
|
||||
|
||||
test("new-api 类型只需要 apiHost", () => {
|
||||
const fields = getFieldsForProviderType("new-api");
|
||||
expect(fields).toEqual(["apiHost"]);
|
||||
});
|
||||
|
||||
test("gateway 类型只需要 apiHost", () => {
|
||||
const fields = getFieldsForProviderType("gateway");
|
||||
expect(fields).toEqual(["apiHost"]);
|
||||
});
|
||||
});
|
||||
|
||||
// 边界情况测试
|
||||
describe("边界情况", () => {
|
||||
test("所有 Provider 类型都应被支持", () => {
|
||||
for (const type of ALL_PROVIDER_TYPES) {
|
||||
const fields = getFieldsForProviderType(type);
|
||||
expect(Array.isArray(fields)).toBe(true);
|
||||
expect(fields.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
test("不存在的字段应返回 false", () => {
|
||||
for (const type of ALL_PROVIDER_TYPES) {
|
||||
expect(providerTypeRequiresField(type, "nonExistentField")).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,384 @@
|
||||
/**
|
||||
* @file ProviderConfigForm 组件
|
||||
* @description Provider 配置表单组件,显示 API Host 和根据 Provider Type 显示额外字段
|
||||
* @module components/provider-pool/api-key/ProviderConfigForm
|
||||
*
|
||||
* **Feature: provider-ui-refactor**
|
||||
* **Validates: Requirements 4.1, 4.2, 5.3-5.5**
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import type {
|
||||
ProviderWithKeysDisplay,
|
||||
UpdateProviderRequest,
|
||||
} from "@/lib/api/apiKeyProvider";
|
||||
import type { ProviderType } from "@/lib/types/provider";
|
||||
|
||||
// ============================================================================
|
||||
// 常量
|
||||
// ============================================================================
|
||||
|
||||
/** 防抖延迟时间(毫秒) */
|
||||
const DEBOUNCE_DELAY = 500;
|
||||
|
||||
/** Provider 类型对应的额外字段配置 */
|
||||
const PROVIDER_TYPE_FIELDS: Record<ProviderType, string[]> = {
|
||||
openai: [],
|
||||
"openai-response": [],
|
||||
anthropic: [],
|
||||
gemini: [],
|
||||
"azure-openai": ["apiVersion"],
|
||||
vertexai: ["project", "location"],
|
||||
"aws-bedrock": ["region"],
|
||||
ollama: [],
|
||||
"new-api": [],
|
||||
gateway: [],
|
||||
};
|
||||
|
||||
/** 字段标签映射 */
|
||||
const FIELD_LABELS: Record<string, string> = {
|
||||
apiHost: "API Host",
|
||||
apiVersion: "API Version",
|
||||
project: "Project ID",
|
||||
location: "Location",
|
||||
region: "Region",
|
||||
};
|
||||
|
||||
/** 字段占位符映射 */
|
||||
const FIELD_PLACEHOLDERS: Record<string, string> = {
|
||||
apiHost: "https://api.example.com",
|
||||
apiVersion: "2024-02-15-preview",
|
||||
project: "your-project-id",
|
||||
location: "us-central1",
|
||||
region: "us-east-1",
|
||||
};
|
||||
|
||||
/** 字段帮助文本映射 */
|
||||
const FIELD_HELP_TEXT: Record<string, string> = {
|
||||
apiHost: "API 服务的基础 URL",
|
||||
apiVersion: "Azure OpenAI API 版本",
|
||||
project: "Google Cloud 项目 ID",
|
||||
location: "VertexAI 服务位置",
|
||||
region: "AWS Bedrock 区域",
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// 类型定义
|
||||
// ============================================================================
|
||||
|
||||
export interface ProviderConfigFormProps {
|
||||
/** Provider 数据 */
|
||||
provider: ProviderWithKeysDisplay;
|
||||
/** 更新回调 */
|
||||
onUpdate?: (id: string, request: UpdateProviderRequest) => Promise<void>;
|
||||
/** 是否正在加载 */
|
||||
loading?: boolean;
|
||||
/** 额外的 CSS 类名 */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface FormState {
|
||||
apiHost: string;
|
||||
apiVersion: string;
|
||||
project: string;
|
||||
location: string;
|
||||
region: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 组件实现
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Provider 配置表单组件
|
||||
*
|
||||
* 显示 Provider 的配置字段,包括:
|
||||
* - API Host(所有 Provider 都有)
|
||||
* - 根据 Provider Type 显示额外字段:
|
||||
* - Azure OpenAI: API Version
|
||||
* - VertexAI: Project, Location
|
||||
* - AWS Bedrock: Region
|
||||
*
|
||||
* 支持自动保存(防抖)。
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <ProviderConfigForm
|
||||
* provider={provider}
|
||||
* onUpdate={updateProvider}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export const ProviderConfigForm: React.FC<ProviderConfigFormProps> = ({
|
||||
provider,
|
||||
onUpdate,
|
||||
loading = false,
|
||||
className,
|
||||
}) => {
|
||||
// 表单状态
|
||||
const [formState, setFormState] = useState<FormState>({
|
||||
apiHost: provider.api_host || "",
|
||||
apiVersion: provider.api_version || "",
|
||||
project: provider.project || "",
|
||||
location: provider.location || "",
|
||||
region: provider.region || "",
|
||||
});
|
||||
|
||||
// 保存状态
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [lastSaved, setLastSaved] = useState<Date | null>(null);
|
||||
|
||||
// 防抖定时器
|
||||
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// 当 provider 变化时,重置表单状态
|
||||
useEffect(() => {
|
||||
setFormState({
|
||||
apiHost: provider.api_host || "",
|
||||
apiVersion: provider.api_version || "",
|
||||
project: provider.project || "",
|
||||
location: provider.location || "",
|
||||
region: provider.region || "",
|
||||
});
|
||||
setSaveError(null);
|
||||
}, [
|
||||
provider.id,
|
||||
provider.api_host,
|
||||
provider.api_version,
|
||||
provider.project,
|
||||
provider.location,
|
||||
provider.region,
|
||||
]);
|
||||
|
||||
// 保存配置
|
||||
const saveConfig = useCallback(
|
||||
async (state: FormState) => {
|
||||
if (!onUpdate) return;
|
||||
|
||||
setIsSaving(true);
|
||||
setSaveError(null);
|
||||
|
||||
try {
|
||||
const request: UpdateProviderRequest = {
|
||||
api_host: state.apiHost || undefined,
|
||||
api_version: state.apiVersion || undefined,
|
||||
project: state.project || undefined,
|
||||
location: state.location || undefined,
|
||||
region: state.region || undefined,
|
||||
};
|
||||
|
||||
await onUpdate(provider.id, request);
|
||||
setLastSaved(new Date());
|
||||
} catch (e) {
|
||||
setSaveError(e instanceof Error ? e.message : "保存失败");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[provider.id, onUpdate],
|
||||
);
|
||||
|
||||
// 防抖保存
|
||||
const debouncedSave = useCallback(
|
||||
(state: FormState) => {
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
|
||||
debounceTimerRef.current = setTimeout(() => {
|
||||
saveConfig(state);
|
||||
}, DEBOUNCE_DELAY);
|
||||
},
|
||||
[saveConfig],
|
||||
);
|
||||
|
||||
// 清理定时器
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 处理字段变化
|
||||
const handleFieldChange = (field: keyof FormState, value: string) => {
|
||||
const newState = { ...formState, [field]: value };
|
||||
setFormState(newState);
|
||||
debouncedSave(newState);
|
||||
};
|
||||
|
||||
// 获取当前 Provider 类型需要显示的额外字段
|
||||
const providerType = provider.type as ProviderType;
|
||||
const extraFields = PROVIDER_TYPE_FIELDS[providerType] || [];
|
||||
|
||||
// 格式化最后保存时间
|
||||
const formatLastSaved = (date: Date | null): string => {
|
||||
if (!date) return "";
|
||||
return `已保存于 ${date.toLocaleTimeString("zh-CN")}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("space-y-4", className)}
|
||||
data-testid="provider-config-form"
|
||||
>
|
||||
{/* API Host 字段(所有 Provider 都有) */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="api-host" className="text-sm font-medium">
|
||||
{FIELD_LABELS.apiHost}
|
||||
</Label>
|
||||
<Input
|
||||
id="api-host"
|
||||
type="text"
|
||||
value={formState.apiHost}
|
||||
onChange={(e) => handleFieldChange("apiHost", e.target.value)}
|
||||
placeholder={FIELD_PLACEHOLDERS.apiHost}
|
||||
disabled={loading || isSaving}
|
||||
data-testid="api-host-input"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{FIELD_HELP_TEXT.apiHost}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Azure OpenAI: API Version */}
|
||||
{extraFields.includes("apiVersion") && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="api-version" className="text-sm font-medium">
|
||||
{FIELD_LABELS.apiVersion}
|
||||
</Label>
|
||||
<Input
|
||||
id="api-version"
|
||||
type="text"
|
||||
value={formState.apiVersion}
|
||||
onChange={(e) => handleFieldChange("apiVersion", e.target.value)}
|
||||
placeholder={FIELD_PLACEHOLDERS.apiVersion}
|
||||
disabled={loading || isSaving}
|
||||
data-testid="api-version-input"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{FIELD_HELP_TEXT.apiVersion}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* VertexAI: Project */}
|
||||
{extraFields.includes("project") && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="project" className="text-sm font-medium">
|
||||
{FIELD_LABELS.project}
|
||||
</Label>
|
||||
<Input
|
||||
id="project"
|
||||
type="text"
|
||||
value={formState.project}
|
||||
onChange={(e) => handleFieldChange("project", e.target.value)}
|
||||
placeholder={FIELD_PLACEHOLDERS.project}
|
||||
disabled={loading || isSaving}
|
||||
data-testid="project-input"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{FIELD_HELP_TEXT.project}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* VertexAI: Location */}
|
||||
{extraFields.includes("location") && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="location" className="text-sm font-medium">
|
||||
{FIELD_LABELS.location}
|
||||
</Label>
|
||||
<Input
|
||||
id="location"
|
||||
type="text"
|
||||
value={formState.location}
|
||||
onChange={(e) => handleFieldChange("location", e.target.value)}
|
||||
placeholder={FIELD_PLACEHOLDERS.location}
|
||||
disabled={loading || isSaving}
|
||||
data-testid="location-input"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{FIELD_HELP_TEXT.location}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AWS Bedrock: Region */}
|
||||
{extraFields.includes("region") && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="region" className="text-sm font-medium">
|
||||
{FIELD_LABELS.region}
|
||||
</Label>
|
||||
<Input
|
||||
id="region"
|
||||
type="text"
|
||||
value={formState.region}
|
||||
onChange={(e) => handleFieldChange("region", e.target.value)}
|
||||
placeholder={FIELD_PLACEHOLDERS.region}
|
||||
disabled={loading || isSaving}
|
||||
data-testid="region-input"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{FIELD_HELP_TEXT.region}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 保存状态指示 */}
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
{isSaving ? (
|
||||
<span
|
||||
className="text-muted-foreground"
|
||||
data-testid="saving-indicator"
|
||||
>
|
||||
保存中...
|
||||
</span>
|
||||
) : saveError ? (
|
||||
<span className="text-red-500" data-testid="save-error">
|
||||
{saveError}
|
||||
</span>
|
||||
) : lastSaved ? (
|
||||
<span className="text-green-600" data-testid="save-success">
|
||||
{formatLastSaved(lastSaved)}
|
||||
</span>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// 辅助函数(用于测试)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 获取指定 Provider 类型需要显示的字段列表
|
||||
* 用于属性测试验证 Provider 类型处理正确性
|
||||
*/
|
||||
export function getFieldsForProviderType(type: ProviderType): string[] {
|
||||
const baseFields = ["apiHost"];
|
||||
const extraFields = PROVIDER_TYPE_FIELDS[type] || [];
|
||||
return [...baseFields, ...extraFields];
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 Provider 类型是否需要特定字段
|
||||
*/
|
||||
export function providerTypeRequiresField(
|
||||
type: ProviderType,
|
||||
field: string,
|
||||
): boolean {
|
||||
if (field === "apiHost") return true;
|
||||
const extraFields = PROVIDER_TYPE_FIELDS[type] || [];
|
||||
return extraFields.includes(field);
|
||||
}
|
||||
|
||||
export default ProviderConfigForm;
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* @file ProviderGroup 组件
|
||||
* @description Provider 分组组件,支持折叠/展开和显示分组标题
|
||||
* @module components/provider-pool/api-key/ProviderGroup
|
||||
*
|
||||
* **Feature: provider-ui-refactor**
|
||||
* **Validates: Requirements 8.1, 8.3**
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PROVIDER_GROUPS } from "@/lib/config/providers";
|
||||
import type { ProviderGroup as ProviderGroupType } from "@/lib/types/provider";
|
||||
import type { ProviderWithKeysDisplay } from "@/lib/api/apiKeyProvider";
|
||||
import { ProviderListItem } from "./ProviderListItem";
|
||||
|
||||
// ============================================================================
|
||||
// 类型定义
|
||||
// ============================================================================
|
||||
|
||||
export interface ProviderGroupProps {
|
||||
/** 分组类型 */
|
||||
group: ProviderGroupType;
|
||||
/** 该分组下的 Provider 列表 */
|
||||
providers: ProviderWithKeysDisplay[];
|
||||
/** 是否折叠 */
|
||||
collapsed?: boolean;
|
||||
/** 折叠/展开回调 */
|
||||
onToggle?: () => void;
|
||||
/** 当前选中的 Provider ID */
|
||||
selectedProviderId?: string | null;
|
||||
/** Provider 点击回调 */
|
||||
onProviderSelect?: (id: string) => void;
|
||||
/** 额外的 CSS 类名 */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 组件实现
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Provider 分组组件
|
||||
*
|
||||
* 显示一个可折叠的 Provider 分组,包含分组标题和 Provider 列表。
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <ProviderGroup
|
||||
* group="mainstream"
|
||||
* providers={mainstreamProviders}
|
||||
* collapsed={collapsedGroups.has("mainstream")}
|
||||
* onToggle={() => toggleGroup("mainstream")}
|
||||
* selectedProviderId={selectedId}
|
||||
* onProviderSelect={setSelectedId}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export const ProviderGroup: React.FC<ProviderGroupProps> = ({
|
||||
group,
|
||||
providers,
|
||||
collapsed = false,
|
||||
onToggle,
|
||||
selectedProviderId,
|
||||
onProviderSelect,
|
||||
className,
|
||||
}) => {
|
||||
// 获取分组配置
|
||||
const groupConfig = PROVIDER_GROUPS[group];
|
||||
const groupLabel = groupConfig?.label ?? group;
|
||||
const providerCount = providers.length;
|
||||
|
||||
// 如果分组为空,不渲染
|
||||
if (providerCount === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("mb-2", className)}
|
||||
data-testid="provider-group"
|
||||
data-group={group}
|
||||
data-collapsed={collapsed}
|
||||
>
|
||||
{/* 分组标题 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-3 py-2 rounded-lg",
|
||||
"text-sm font-medium text-muted-foreground",
|
||||
"hover:bg-muted/50 transition-colors",
|
||||
"focus:outline-none focus:ring-2 focus:ring-primary/20",
|
||||
)}
|
||||
aria-expanded={!collapsed}
|
||||
aria-controls={`provider-group-${group}-content`}
|
||||
data-testid="provider-group-header"
|
||||
>
|
||||
{/* 折叠图标 */}
|
||||
{collapsed ? (
|
||||
<ChevronRight className="h-4 w-4 flex-shrink-0" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 flex-shrink-0" />
|
||||
)}
|
||||
|
||||
{/* 分组标题 */}
|
||||
<span className="flex-1 text-left" data-testid="provider-group-label">
|
||||
{groupLabel}
|
||||
</span>
|
||||
|
||||
{/* Provider 数量 */}
|
||||
<span
|
||||
className="text-xs text-muted-foreground/70"
|
||||
data-testid="provider-group-count"
|
||||
>
|
||||
{providerCount}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Provider 列表 */}
|
||||
{!collapsed && (
|
||||
<div
|
||||
id={`provider-group-${group}-content`}
|
||||
className="mt-1 space-y-0.5 pl-2"
|
||||
data-testid="provider-group-content"
|
||||
>
|
||||
{providers.map((provider) => (
|
||||
<ProviderListItem
|
||||
key={provider.id}
|
||||
provider={provider}
|
||||
selected={selectedProviderId === provider.id}
|
||||
onClick={() => onProviderSelect?.(provider.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// 辅助函数(用于测试)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 获取分组的显示标签
|
||||
* 用于属性测试验证分组正确性
|
||||
*/
|
||||
export function getGroupLabel(group: ProviderGroupType): string {
|
||||
return PROVIDER_GROUPS[group]?.label ?? group;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 Provider 是否属于指定分组
|
||||
* 用于属性测试验证分组正确性
|
||||
*/
|
||||
export function isProviderInGroup(
|
||||
provider: ProviderWithKeysDisplay,
|
||||
group: ProviderGroupType,
|
||||
): boolean {
|
||||
return provider.group === group;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取分组的排序顺序
|
||||
* 用于属性测试验证分组排序
|
||||
*/
|
||||
export function getGroupOrder(group: ProviderGroupType): number {
|
||||
return PROVIDER_GROUPS[group]?.order ?? 999;
|
||||
}
|
||||
|
||||
export default ProviderGroup;
|
||||
@@ -0,0 +1,528 @@
|
||||
/**
|
||||
* @file ProviderList 属性测试
|
||||
* @description 测试 Provider 分组正确性和搜索正确性
|
||||
* @module components/provider-pool/api-key/ProviderList.test
|
||||
*
|
||||
* **Feature: provider-ui-refactor**
|
||||
* **Property 10: 自定义 Provider 分组显示**
|
||||
* **Property 14: Provider 分组正确性**
|
||||
* **Property 15: Provider 搜索正确性**
|
||||
* **Validates: Requirements 6.5, 8.1, 8.2**
|
||||
*/
|
||||
|
||||
import { describe, expect } from "vitest";
|
||||
import { test } from "@fast-check/vitest";
|
||||
import * as fc from "fast-check";
|
||||
import {
|
||||
filterProviders,
|
||||
groupProviders,
|
||||
matchesSearchQuery,
|
||||
} from "./ProviderList";
|
||||
import { isProviderInGroup } from "./ProviderGroup";
|
||||
import type {
|
||||
ProviderWithKeysDisplay,
|
||||
ApiKeyDisplay,
|
||||
} from "@/lib/api/apiKeyProvider";
|
||||
import type { ProviderGroup } from "@/lib/types/provider";
|
||||
|
||||
// ============================================================================
|
||||
// 测试数据生成器
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 生成有效的 ISO 日期字符串
|
||||
*/
|
||||
const validDateArbitrary = fc
|
||||
.integer({
|
||||
min: new Date("2020-01-01").getTime(),
|
||||
max: new Date("2030-12-31").getTime(),
|
||||
})
|
||||
.map((timestamp) => new Date(timestamp).toISOString());
|
||||
|
||||
/**
|
||||
* 有效的分组类型
|
||||
*/
|
||||
const validGroups: ProviderGroup[] = [
|
||||
"mainstream",
|
||||
"chinese",
|
||||
"cloud",
|
||||
"aggregator",
|
||||
"local",
|
||||
"specialized",
|
||||
"custom",
|
||||
];
|
||||
|
||||
/**
|
||||
* 生成随机 API Key 显示数据
|
||||
*/
|
||||
const apiKeyDisplayArbitrary: fc.Arbitrary<ApiKeyDisplay> = fc.record({
|
||||
id: fc.uuid(),
|
||||
provider_id: fc.string({ minLength: 1, maxLength: 50 }),
|
||||
api_key_masked: fc
|
||||
.string({ minLength: 1, maxLength: 20 })
|
||||
.map((s) => `sk-****${s}`),
|
||||
alias: fc.option(fc.string({ minLength: 1, maxLength: 50 }), {
|
||||
nil: undefined,
|
||||
}),
|
||||
enabled: fc.boolean(),
|
||||
usage_count: fc.nat({ max: 10000 }),
|
||||
error_count: fc.nat({ max: 1000 }),
|
||||
last_used_at: fc.option(validDateArbitrary, { nil: undefined }),
|
||||
created_at: validDateArbitrary,
|
||||
});
|
||||
|
||||
/**
|
||||
* 生成随机 Provider 显示数据(包含 API Keys)
|
||||
*/
|
||||
const providerWithKeysArbitrary: fc.Arbitrary<ProviderWithKeysDisplay> =
|
||||
fc.record({
|
||||
id: fc.string({ minLength: 1, maxLength: 50 }),
|
||||
name: fc.string({ minLength: 1, maxLength: 100 }),
|
||||
type: fc.constantFrom(
|
||||
"openai",
|
||||
"openai-response",
|
||||
"anthropic",
|
||||
"gemini",
|
||||
"azure-openai",
|
||||
"vertexai",
|
||||
"aws-bedrock",
|
||||
"ollama",
|
||||
"new-api",
|
||||
"gateway",
|
||||
),
|
||||
api_host: fc.webUrl(),
|
||||
is_system: fc.boolean(),
|
||||
group: fc.constantFrom(...validGroups),
|
||||
enabled: fc.boolean(),
|
||||
sort_order: fc.nat({ max: 100 }),
|
||||
api_version: fc.option(fc.string({ minLength: 1, maxLength: 20 }), {
|
||||
nil: undefined,
|
||||
}),
|
||||
project: fc.option(fc.string({ minLength: 1, maxLength: 50 }), {
|
||||
nil: undefined,
|
||||
}),
|
||||
location: fc.option(fc.string({ minLength: 1, maxLength: 50 }), {
|
||||
nil: undefined,
|
||||
}),
|
||||
region: fc.option(fc.string({ minLength: 1, maxLength: 50 }), {
|
||||
nil: undefined,
|
||||
}),
|
||||
api_key_count: fc.nat({ max: 20 }),
|
||||
created_at: validDateArbitrary,
|
||||
updated_at: validDateArbitrary,
|
||||
api_keys: fc.array(apiKeyDisplayArbitrary, { minLength: 0, maxLength: 5 }),
|
||||
});
|
||||
|
||||
/**
|
||||
* 生成 Provider 列表
|
||||
*/
|
||||
const providerListArbitrary = fc.array(providerWithKeysArbitrary, {
|
||||
minLength: 0,
|
||||
maxLength: 20,
|
||||
});
|
||||
|
||||
/**
|
||||
* 生成自定义 Provider(is_system = false, group = "custom")
|
||||
*/
|
||||
const customProviderArbitrary: fc.Arbitrary<ProviderWithKeysDisplay> =
|
||||
fc.record({
|
||||
id: fc.string({ minLength: 1, maxLength: 50 }),
|
||||
name: fc.string({ minLength: 1, maxLength: 100 }),
|
||||
type: fc.constantFrom(
|
||||
"openai",
|
||||
"openai-response",
|
||||
"anthropic",
|
||||
"gemini",
|
||||
"azure-openai",
|
||||
"vertexai",
|
||||
"aws-bedrock",
|
||||
"ollama",
|
||||
"new-api",
|
||||
"gateway",
|
||||
),
|
||||
api_host: fc.webUrl(),
|
||||
is_system: fc.constant(false), // 自定义 Provider
|
||||
group: fc.constant("custom") as fc.Arbitrary<ProviderGroup>, // 必须在 custom 分组
|
||||
enabled: fc.boolean(),
|
||||
sort_order: fc.nat({ max: 100 }),
|
||||
api_version: fc.option(fc.string({ minLength: 1, maxLength: 20 }), {
|
||||
nil: undefined,
|
||||
}),
|
||||
project: fc.option(fc.string({ minLength: 1, maxLength: 50 }), {
|
||||
nil: undefined,
|
||||
}),
|
||||
location: fc.option(fc.string({ minLength: 1, maxLength: 50 }), {
|
||||
nil: undefined,
|
||||
}),
|
||||
region: fc.option(fc.string({ minLength: 1, maxLength: 50 }), {
|
||||
nil: undefined,
|
||||
}),
|
||||
api_key_count: fc.nat({ max: 20 }),
|
||||
created_at: validDateArbitrary,
|
||||
updated_at: validDateArbitrary,
|
||||
api_keys: fc.array(apiKeyDisplayArbitrary, { minLength: 0, maxLength: 5 }),
|
||||
});
|
||||
|
||||
/**
|
||||
* 生成自定义 Provider 列表
|
||||
*/
|
||||
const customProviderListArbitrary = fc.array(customProviderArbitrary, {
|
||||
minLength: 1,
|
||||
maxLength: 10,
|
||||
});
|
||||
|
||||
/**
|
||||
* 生成搜索查询字符串
|
||||
*/
|
||||
const searchQueryArbitrary = fc.string({ minLength: 0, maxLength: 50 });
|
||||
|
||||
// ============================================================================
|
||||
// Property 14: Provider 分组正确性
|
||||
// ============================================================================
|
||||
|
||||
describe("Property 14: Provider 分组正确性", () => {
|
||||
/**
|
||||
* Property 14: Provider 分组正确性
|
||||
*
|
||||
* *对于任意* Provider,应被分配到正确的分组中
|
||||
*
|
||||
* **Validates: Requirements 8.1**
|
||||
*/
|
||||
test.prop([providerListArbitrary], { numRuns: 100 })(
|
||||
"每个 Provider 应被分配到其 group 属性指定的分组中",
|
||||
(providers: ProviderWithKeysDisplay[]) => {
|
||||
const grouped = groupProviders(providers);
|
||||
|
||||
// 验证每个 Provider 都在正确的分组中
|
||||
providers.forEach((provider) => {
|
||||
const expectedGroup = provider.group as ProviderGroup;
|
||||
const groupList = grouped.get(expectedGroup);
|
||||
|
||||
// 分组应该存在
|
||||
expect(groupList).toBeDefined();
|
||||
|
||||
// Provider 应该在该分组中
|
||||
const found = groupList?.some((p) => p.id === provider.id);
|
||||
expect(found).toBe(true);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test.prop([providerWithKeysArbitrary, fc.constantFrom(...validGroups)], {
|
||||
numRuns: 100,
|
||||
})(
|
||||
"isProviderInGroup 应正确判断 Provider 是否属于指定分组",
|
||||
(provider: ProviderWithKeysDisplay, group: ProviderGroup) => {
|
||||
const result = isProviderInGroup(provider, group);
|
||||
const expected = provider.group === group;
|
||||
|
||||
expect(result).toBe(expected);
|
||||
},
|
||||
);
|
||||
|
||||
test.prop([providerListArbitrary], { numRuns: 100 })(
|
||||
"分组后的 Provider 总数应等于原始列表长度",
|
||||
(providers: ProviderWithKeysDisplay[]) => {
|
||||
const grouped = groupProviders(providers);
|
||||
|
||||
let totalCount = 0;
|
||||
grouped.forEach((list) => {
|
||||
totalCount += list.length;
|
||||
});
|
||||
|
||||
expect(totalCount).toBe(providers.length);
|
||||
},
|
||||
);
|
||||
|
||||
test.prop([providerListArbitrary], { numRuns: 100 })(
|
||||
"每个分组内的 Provider 应按 sort_order 排序",
|
||||
(providers: ProviderWithKeysDisplay[]) => {
|
||||
const grouped = groupProviders(providers);
|
||||
|
||||
grouped.forEach((list) => {
|
||||
for (let i = 1; i < list.length; i++) {
|
||||
expect(list[i].sort_order).toBeGreaterThanOrEqual(
|
||||
list[i - 1].sort_order,
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test("所有有效分组都应在结果中存在", () => {
|
||||
const grouped = groupProviders([]);
|
||||
|
||||
validGroups.forEach((group) => {
|
||||
expect(grouped.has(group)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Property 15: Provider 搜索正确性
|
||||
// ============================================================================
|
||||
|
||||
describe("Property 15: Provider 搜索正确性", () => {
|
||||
/**
|
||||
* Property 15: Provider 搜索正确性
|
||||
*
|
||||
* *对于任意* 搜索查询,返回的 Provider 列表应只包含名称匹配的 Provider
|
||||
*
|
||||
* **Validates: Requirements 8.2**
|
||||
*/
|
||||
test.prop([providerListArbitrary, searchQueryArbitrary], { numRuns: 100 })(
|
||||
"过滤后的 Provider 应都匹配搜索查询",
|
||||
(providers: ProviderWithKeysDisplay[], query: string) => {
|
||||
const filtered = filterProviders(providers, query);
|
||||
|
||||
// 所有过滤后的 Provider 都应匹配查询
|
||||
filtered.forEach((provider) => {
|
||||
expect(matchesSearchQuery(provider, query)).toBe(true);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test.prop([providerListArbitrary, searchQueryArbitrary], { numRuns: 100 })(
|
||||
"过滤后的 Provider 数量应小于等于原始数量",
|
||||
(providers: ProviderWithKeysDisplay[], query: string) => {
|
||||
const filtered = filterProviders(providers, query);
|
||||
|
||||
expect(filtered.length).toBeLessThanOrEqual(providers.length);
|
||||
},
|
||||
);
|
||||
|
||||
test.prop([providerListArbitrary], { numRuns: 100 })(
|
||||
"空查询应返回所有 Provider",
|
||||
(providers: ProviderWithKeysDisplay[]) => {
|
||||
const filtered = filterProviders(providers, "");
|
||||
|
||||
expect(filtered.length).toBe(providers.length);
|
||||
},
|
||||
);
|
||||
|
||||
test.prop([providerListArbitrary], { numRuns: 100 })(
|
||||
"空白查询应返回所有 Provider",
|
||||
(providers: ProviderWithKeysDisplay[]) => {
|
||||
const filtered = filterProviders(providers, " ");
|
||||
|
||||
expect(filtered.length).toBe(providers.length);
|
||||
},
|
||||
);
|
||||
|
||||
test.prop([providerWithKeysArbitrary], { numRuns: 100 })(
|
||||
"使用 Provider 名称搜索应返回该 Provider",
|
||||
(provider: ProviderWithKeysDisplay) => {
|
||||
const providers = [provider];
|
||||
const filtered = filterProviders(providers, provider.name);
|
||||
|
||||
expect(filtered.length).toBe(1);
|
||||
expect(filtered[0].id).toBe(provider.id);
|
||||
},
|
||||
);
|
||||
|
||||
test.prop([providerWithKeysArbitrary], { numRuns: 100 })(
|
||||
"使用 Provider ID 搜索应返回该 Provider",
|
||||
(provider: ProviderWithKeysDisplay) => {
|
||||
const providers = [provider];
|
||||
const filtered = filterProviders(providers, provider.id);
|
||||
|
||||
expect(filtered.length).toBe(1);
|
||||
expect(filtered[0].id).toBe(provider.id);
|
||||
},
|
||||
);
|
||||
|
||||
test.prop([providerWithKeysArbitrary], { numRuns: 100 })(
|
||||
"搜索应不区分大小写",
|
||||
(provider: ProviderWithKeysDisplay) => {
|
||||
const providers = [provider];
|
||||
|
||||
// 使用大写名称搜索
|
||||
const filteredUpper = filterProviders(
|
||||
providers,
|
||||
provider.name.toUpperCase(),
|
||||
);
|
||||
// 使用小写名称搜索
|
||||
const filteredLower = filterProviders(
|
||||
providers,
|
||||
provider.name.toLowerCase(),
|
||||
);
|
||||
|
||||
expect(filteredUpper.length).toBe(1);
|
||||
expect(filteredLower.length).toBe(1);
|
||||
},
|
||||
);
|
||||
|
||||
test.prop([providerWithKeysArbitrary], { numRuns: 100 })(
|
||||
"matchesSearchQuery 应对空查询返回 true",
|
||||
(provider: ProviderWithKeysDisplay) => {
|
||||
expect(matchesSearchQuery(provider, "")).toBe(true);
|
||||
expect(matchesSearchQuery(provider, " ")).toBe(true);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Property 10: 自定义 Provider 分组显示
|
||||
// ============================================================================
|
||||
|
||||
describe("Property 10: 自定义 Provider 分组显示", () => {
|
||||
/**
|
||||
* Property 10: 自定义 Provider 分组显示
|
||||
*
|
||||
* *对于任意* 自定义 Provider,应显示在 Provider 列表的「自定义」分组中
|
||||
*
|
||||
* **Validates: Requirements 6.5**
|
||||
*/
|
||||
test.prop([customProviderListArbitrary], { numRuns: 100 })(
|
||||
"自定义 Provider 应被分配到 custom 分组",
|
||||
(customProviders: ProviderWithKeysDisplay[]) => {
|
||||
const grouped = groupProviders(customProviders);
|
||||
const customGroup = grouped.get("custom");
|
||||
|
||||
// custom 分组应该存在
|
||||
expect(customGroup).toBeDefined();
|
||||
|
||||
// 所有自定义 Provider 都应在 custom 分组中
|
||||
customProviders.forEach((provider) => {
|
||||
const found = customGroup?.some((p) => p.id === provider.id);
|
||||
expect(found).toBe(true);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test.prop([customProviderArbitrary], { numRuns: 100 })(
|
||||
"自定义 Provider 的 group 属性应为 custom",
|
||||
(provider: ProviderWithKeysDisplay) => {
|
||||
expect(provider.group).toBe("custom");
|
||||
},
|
||||
);
|
||||
|
||||
test.prop([customProviderArbitrary], { numRuns: 100 })(
|
||||
"自定义 Provider 的 is_system 属性应为 false",
|
||||
(provider: ProviderWithKeysDisplay) => {
|
||||
expect(provider.is_system).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
test.prop([customProviderArbitrary], { numRuns: 100 })(
|
||||
"isProviderInGroup 应正确识别自定义 Provider 属于 custom 分组",
|
||||
(provider: ProviderWithKeysDisplay) => {
|
||||
expect(isProviderInGroup(provider, "custom")).toBe(true);
|
||||
expect(isProviderInGroup(provider, "mainstream")).toBe(false);
|
||||
expect(isProviderInGroup(provider, "chinese")).toBe(false);
|
||||
expect(isProviderInGroup(provider, "cloud")).toBe(false);
|
||||
expect(isProviderInGroup(provider, "aggregator")).toBe(false);
|
||||
expect(isProviderInGroup(provider, "local")).toBe(false);
|
||||
expect(isProviderInGroup(provider, "specialized")).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
test.prop([providerListArbitrary, customProviderListArbitrary], {
|
||||
numRuns: 100,
|
||||
})(
|
||||
"混合列表中自定义 Provider 应只出现在 custom 分组",
|
||||
(
|
||||
otherProviders: ProviderWithKeysDisplay[],
|
||||
customProviders: ProviderWithKeysDisplay[],
|
||||
) => {
|
||||
// 确保 customProviders 的 ID 是唯一的,不与 otherProviders 重复
|
||||
const customIds = new Set(customProviders.map((p) => p.id));
|
||||
const filteredOtherProviders = otherProviders.filter(
|
||||
(p) => !customIds.has(p.id),
|
||||
);
|
||||
|
||||
const allProviders = [...filteredOtherProviders, ...customProviders];
|
||||
const grouped = groupProviders(allProviders);
|
||||
const customGroup = grouped.get("custom");
|
||||
|
||||
// 验证所有自定义 Provider 都在 custom 分组中
|
||||
customProviders.forEach((provider) => {
|
||||
const found = customGroup?.some((p) => p.id === provider.id);
|
||||
expect(found).toBe(true);
|
||||
});
|
||||
|
||||
// 验证自定义 Provider 不在其他分组中
|
||||
// 注意:这里只检查 customProviders 列表中的 Provider
|
||||
const nonCustomGroups: ProviderGroup[] = [
|
||||
"mainstream",
|
||||
"chinese",
|
||||
"cloud",
|
||||
"aggregator",
|
||||
"local",
|
||||
"specialized",
|
||||
];
|
||||
|
||||
customProviders.forEach((provider) => {
|
||||
nonCustomGroups.forEach((group) => {
|
||||
const groupList = grouped.get(group);
|
||||
const found = groupList?.some((p) => p.id === provider.id);
|
||||
expect(found).toBe(false);
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test("空的自定义 Provider 列表应返回空的 custom 分组", () => {
|
||||
const grouped = groupProviders([]);
|
||||
const customGroup = grouped.get("custom");
|
||||
|
||||
expect(customGroup).toBeDefined();
|
||||
expect(customGroup?.length).toBe(0);
|
||||
});
|
||||
|
||||
test("自定义 Provider 应按 sort_order 排序", () => {
|
||||
const customProviders: ProviderWithKeysDisplay[] = [
|
||||
{
|
||||
id: "custom-3",
|
||||
name: "Custom 3",
|
||||
type: "openai",
|
||||
api_host: "https://api3.example.com",
|
||||
is_system: false,
|
||||
group: "custom",
|
||||
enabled: true,
|
||||
sort_order: 30,
|
||||
api_key_count: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
api_keys: [],
|
||||
},
|
||||
{
|
||||
id: "custom-1",
|
||||
name: "Custom 1",
|
||||
type: "openai",
|
||||
api_host: "https://api1.example.com",
|
||||
is_system: false,
|
||||
group: "custom",
|
||||
enabled: true,
|
||||
sort_order: 10,
|
||||
api_key_count: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
api_keys: [],
|
||||
},
|
||||
{
|
||||
id: "custom-2",
|
||||
name: "Custom 2",
|
||||
type: "openai",
|
||||
api_host: "https://api2.example.com",
|
||||
is_system: false,
|
||||
group: "custom",
|
||||
enabled: true,
|
||||
sort_order: 20,
|
||||
api_key_count: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
api_keys: [],
|
||||
},
|
||||
];
|
||||
|
||||
const grouped = groupProviders(customProviders);
|
||||
const customGroup = grouped.get("custom");
|
||||
|
||||
expect(customGroup).toBeDefined();
|
||||
expect(customGroup?.length).toBe(3);
|
||||
expect(customGroup?.[0].id).toBe("custom-1");
|
||||
expect(customGroup?.[1].id).toBe("custom-2");
|
||||
expect(customGroup?.[2].id).toBe("custom-3");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* @file ProviderList 组件
|
||||
* @description Provider 列表组件,集成搜索框、分组显示和拖拽排序
|
||||
* @module components/provider-pool/api-key/ProviderList
|
||||
*
|
||||
* **Feature: provider-ui-refactor**
|
||||
* **Validates: Requirements 1.2, 1.5, 8.1, 8.2**
|
||||
*/
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import { Search, Plus, Settings2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PROVIDER_GROUPS } from "@/lib/config/providers";
|
||||
import type { ProviderGroup as ProviderGroupType } from "@/lib/types/provider";
|
||||
import type { ProviderWithKeysDisplay } from "@/lib/api/apiKeyProvider";
|
||||
import { ProviderGroup } from "./ProviderGroup";
|
||||
|
||||
// ============================================================================
|
||||
// 类型定义
|
||||
// ============================================================================
|
||||
|
||||
export interface ProviderListProps {
|
||||
/** Provider 列表(按分组组织) */
|
||||
providersByGroup: Map<ProviderGroupType, ProviderWithKeysDisplay[]>;
|
||||
/** 当前选中的 Provider ID */
|
||||
selectedProviderId?: string | null;
|
||||
/** Provider 选择回调 */
|
||||
onProviderSelect?: (id: string) => void;
|
||||
/** 搜索查询 */
|
||||
searchQuery?: string;
|
||||
/** 搜索查询变更回调 */
|
||||
onSearchChange?: (query: string) => void;
|
||||
/** 折叠的分组集合 */
|
||||
collapsedGroups?: Set<ProviderGroupType>;
|
||||
/** 分组折叠/展开回调 */
|
||||
onToggleGroup?: (group: ProviderGroupType) => void;
|
||||
/** 添加自定义 Provider 回调 */
|
||||
onAddCustomProvider?: () => void;
|
||||
/** 导入导出回调 */
|
||||
onImportExport?: () => void;
|
||||
/** 额外的 CSS 类名 */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 分组排序
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 获取排序后的分组列表
|
||||
*/
|
||||
function getSortedGroups(): ProviderGroupType[] {
|
||||
return (
|
||||
Object.entries(PROVIDER_GROUPS) as [ProviderGroupType, { order: number }][]
|
||||
)
|
||||
.sort((a, b) => a[1].order - b[1].order)
|
||||
.map(([group]) => group);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 组件实现
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Provider 列表组件
|
||||
*
|
||||
* 显示所有 Provider,支持搜索过滤、分组显示和折叠/展开。
|
||||
* 固定宽度约 240px,用于左右分栏布局的左侧。
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <ProviderList
|
||||
* providersByGroup={providersByGroup}
|
||||
* selectedProviderId={selectedId}
|
||||
* onProviderSelect={setSelectedId}
|
||||
* searchQuery={searchQuery}
|
||||
* onSearchChange={setSearchQuery}
|
||||
* collapsedGroups={collapsedGroups}
|
||||
* onToggleGroup={toggleGroup}
|
||||
* onAddCustomProvider={() => setShowAddModal(true)}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export const ProviderList: React.FC<ProviderListProps> = ({
|
||||
providersByGroup,
|
||||
selectedProviderId,
|
||||
onProviderSelect,
|
||||
searchQuery = "",
|
||||
onSearchChange,
|
||||
collapsedGroups = new Set(),
|
||||
onToggleGroup,
|
||||
onAddCustomProvider,
|
||||
onImportExport,
|
||||
className,
|
||||
}) => {
|
||||
// 获取排序后的分组
|
||||
const sortedGroups = useMemo(() => getSortedGroups(), []);
|
||||
|
||||
// 计算总 Provider 数量
|
||||
const totalProviders = useMemo(() => {
|
||||
let count = 0;
|
||||
providersByGroup.forEach((providers) => {
|
||||
count += providers.length;
|
||||
});
|
||||
return count;
|
||||
}, [providersByGroup]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col h-full w-60 border-r border-border bg-background",
|
||||
className,
|
||||
)}
|
||||
data-testid="provider-list"
|
||||
>
|
||||
{/* 搜索框 */}
|
||||
<div className="p-3 border-b border-border">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索 Provider..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange?.(e.target.value)}
|
||||
className={cn(
|
||||
"w-full pl-9 pr-3 py-2 text-sm rounded-lg",
|
||||
"bg-muted/50 border border-transparent",
|
||||
"placeholder:text-muted-foreground/60",
|
||||
"focus:outline-none focus:border-primary/30 focus:bg-background",
|
||||
"transition-colors",
|
||||
)}
|
||||
data-testid="provider-search-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Provider 分组列表 */}
|
||||
<div
|
||||
className="flex-1 overflow-y-auto p-2"
|
||||
data-testid="provider-groups-container"
|
||||
>
|
||||
{totalProviders === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-32 text-muted-foreground text-sm">
|
||||
<p>未找到 Provider</p>
|
||||
{searchQuery && <p className="text-xs mt-1">尝试其他搜索词</p>}
|
||||
</div>
|
||||
) : (
|
||||
sortedGroups.map((group) => {
|
||||
const providers = providersByGroup.get(group) ?? [];
|
||||
if (providers.length === 0) return null;
|
||||
|
||||
return (
|
||||
<ProviderGroup
|
||||
key={group}
|
||||
group={group}
|
||||
providers={providers}
|
||||
collapsed={collapsedGroups.has(group)}
|
||||
onToggle={() => onToggleGroup?.(group)}
|
||||
selectedProviderId={selectedProviderId}
|
||||
onProviderSelect={onProviderSelect}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 添加自定义 Provider 按钮 */}
|
||||
{(onAddCustomProvider || onImportExport) && (
|
||||
<div className="p-3 border-t border-border space-y-2">
|
||||
{onAddCustomProvider && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAddCustomProvider}
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-2 w-full px-3 py-2 rounded-lg",
|
||||
"text-sm font-medium text-primary",
|
||||
"bg-primary/10 hover:bg-primary/20",
|
||||
"transition-colors",
|
||||
"focus:outline-none focus:ring-2 focus:ring-primary/20",
|
||||
)}
|
||||
data-testid="add-custom-provider-button"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
添加自定义 Provider
|
||||
</button>
|
||||
)}
|
||||
{onImportExport && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onImportExport}
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-2 w-full px-3 py-2 rounded-lg",
|
||||
"text-sm font-medium text-muted-foreground",
|
||||
"bg-muted/50 hover:bg-muted",
|
||||
"transition-colors",
|
||||
"focus:outline-none focus:ring-2 focus:ring-muted/20",
|
||||
)}
|
||||
data-testid="import-export-button"
|
||||
>
|
||||
<Settings2 className="h-4 w-4" />
|
||||
导入/导出配置
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// 辅助函数(用于测试)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 过滤 Provider 列表
|
||||
* 用于属性测试验证搜索正确性
|
||||
*/
|
||||
export function filterProviders(
|
||||
providers: ProviderWithKeysDisplay[],
|
||||
query: string,
|
||||
): ProviderWithKeysDisplay[] {
|
||||
if (!query.trim()) return providers;
|
||||
const lowerQuery = query.toLowerCase();
|
||||
return providers.filter(
|
||||
(p) =>
|
||||
p.name.toLowerCase().includes(lowerQuery) ||
|
||||
p.id.toLowerCase().includes(lowerQuery),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按分组组织 Provider
|
||||
* 用于属性测试验证分组正确性
|
||||
*/
|
||||
export function groupProviders(
|
||||
providers: ProviderWithKeysDisplay[],
|
||||
): Map<ProviderGroupType, ProviderWithKeysDisplay[]> {
|
||||
const groups = new Map<ProviderGroupType, ProviderWithKeysDisplay[]>();
|
||||
|
||||
// 初始化所有分组
|
||||
const allGroups: ProviderGroupType[] = [
|
||||
"mainstream",
|
||||
"chinese",
|
||||
"cloud",
|
||||
"aggregator",
|
||||
"local",
|
||||
"specialized",
|
||||
"custom",
|
||||
];
|
||||
allGroups.forEach((g) => groups.set(g, []));
|
||||
|
||||
// 分配 Provider 到对应分组
|
||||
providers.forEach((p) => {
|
||||
const group = p.group as ProviderGroupType;
|
||||
const list = groups.get(group);
|
||||
if (list) {
|
||||
list.push(p);
|
||||
} else {
|
||||
// 未知分组放入 custom
|
||||
groups.get("custom")?.push(p);
|
||||
}
|
||||
});
|
||||
|
||||
// 按 sort_order 排序每个分组内的 Provider
|
||||
groups.forEach((list) => {
|
||||
list.sort((a, b) => a.sort_order - b.sort_order);
|
||||
});
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 Provider 是否匹配搜索查询
|
||||
* 用于属性测试验证搜索正确性
|
||||
*/
|
||||
export function matchesSearchQuery(
|
||||
provider: ProviderWithKeysDisplay,
|
||||
query: string,
|
||||
): boolean {
|
||||
if (!query.trim()) return true;
|
||||
const lowerQuery = query.toLowerCase();
|
||||
return (
|
||||
provider.name.toLowerCase().includes(lowerQuery) ||
|
||||
provider.id.toLowerCase().includes(lowerQuery)
|
||||
);
|
||||
}
|
||||
|
||||
export default ProviderList;
|
||||
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* @file ProviderListItem 属性测试
|
||||
* @description 测试 Provider 列表项显示完整性和 API Key 数量徽章正确性
|
||||
* @module components/provider-pool/api-key/ProviderListItem.test
|
||||
*
|
||||
* **Feature: provider-ui-refactor**
|
||||
* **Property 1: Provider 列表项显示完整性**
|
||||
* **Property 11: API Key 数量徽章正确性**
|
||||
* **Validates: Requirements 1.6, 7.2, 10.4**
|
||||
*/
|
||||
|
||||
import { describe, expect } from "vitest";
|
||||
import { test } from "@fast-check/vitest";
|
||||
import * as fc from "fast-check";
|
||||
import { extractListItemDisplayInfo, getApiKeyCount } from "./ProviderListItem";
|
||||
import type {
|
||||
ProviderWithKeysDisplay,
|
||||
ApiKeyDisplay,
|
||||
} from "@/lib/api/apiKeyProvider";
|
||||
|
||||
// ============================================================================
|
||||
// 测试数据生成器
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 生成有效的 ISO 日期字符串
|
||||
* 使用整数时间戳避免无效日期问题
|
||||
*/
|
||||
const validDateArbitrary = fc
|
||||
.integer({
|
||||
min: new Date("2020-01-01").getTime(),
|
||||
max: new Date("2030-12-31").getTime(),
|
||||
})
|
||||
.map((timestamp) => new Date(timestamp).toISOString());
|
||||
|
||||
/**
|
||||
* 生成随机 API Key 显示数据
|
||||
*/
|
||||
const apiKeyDisplayArbitrary: fc.Arbitrary<ApiKeyDisplay> = fc.record({
|
||||
id: fc.uuid(),
|
||||
provider_id: fc.string({ minLength: 1, maxLength: 50 }),
|
||||
api_key_masked: fc
|
||||
.string({ minLength: 1, maxLength: 20 })
|
||||
.map((s) => `sk-****${s}`),
|
||||
alias: fc.option(fc.string({ minLength: 1, maxLength: 50 }), {
|
||||
nil: undefined,
|
||||
}),
|
||||
enabled: fc.boolean(),
|
||||
usage_count: fc.nat({ max: 10000 }),
|
||||
error_count: fc.nat({ max: 1000 }),
|
||||
last_used_at: fc.option(validDateArbitrary, { nil: undefined }),
|
||||
created_at: validDateArbitrary,
|
||||
});
|
||||
|
||||
/**
|
||||
* 生成随机 Provider 显示数据(包含 API Keys)
|
||||
*/
|
||||
const providerWithKeysArbitrary: fc.Arbitrary<ProviderWithKeysDisplay> =
|
||||
fc.record({
|
||||
id: fc.string({ minLength: 1, maxLength: 50 }),
|
||||
name: fc.string({ minLength: 1, maxLength: 100 }),
|
||||
type: fc.constantFrom(
|
||||
"openai",
|
||||
"openai-response",
|
||||
"anthropic",
|
||||
"gemini",
|
||||
"azure-openai",
|
||||
"vertexai",
|
||||
"aws-bedrock",
|
||||
"ollama",
|
||||
"new-api",
|
||||
"gateway",
|
||||
),
|
||||
api_host: fc.webUrl(),
|
||||
is_system: fc.boolean(),
|
||||
group: fc.constantFrom(
|
||||
"mainstream",
|
||||
"chinese",
|
||||
"cloud",
|
||||
"aggregator",
|
||||
"local",
|
||||
"specialized",
|
||||
"custom",
|
||||
),
|
||||
enabled: fc.boolean(),
|
||||
sort_order: fc.nat({ max: 100 }),
|
||||
api_version: fc.option(fc.string({ minLength: 1, maxLength: 20 }), {
|
||||
nil: undefined,
|
||||
}),
|
||||
project: fc.option(fc.string({ minLength: 1, maxLength: 50 }), {
|
||||
nil: undefined,
|
||||
}),
|
||||
location: fc.option(fc.string({ minLength: 1, maxLength: 50 }), {
|
||||
nil: undefined,
|
||||
}),
|
||||
region: fc.option(fc.string({ minLength: 1, maxLength: 50 }), {
|
||||
nil: undefined,
|
||||
}),
|
||||
api_key_count: fc.nat({ max: 20 }),
|
||||
created_at: validDateArbitrary,
|
||||
updated_at: validDateArbitrary,
|
||||
api_keys: fc.array(apiKeyDisplayArbitrary, { minLength: 0, maxLength: 10 }),
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Property 1: Provider 列表项显示完整性
|
||||
// ============================================================================
|
||||
|
||||
describe("Property 1: Provider 列表项显示完整性", () => {
|
||||
/**
|
||||
* Property 1: Provider 列表项显示完整性
|
||||
*
|
||||
* *对于任意* Provider 配置,渲染后的列表项应包含图标、名称和启用状态三个元素
|
||||
*
|
||||
* **Validates: Requirements 1.6, 10.4**
|
||||
*/
|
||||
test.prop([providerWithKeysArbitrary], { numRuns: 100 })(
|
||||
"每个 Provider 列表项应包含图标、名称和启用状态",
|
||||
(provider: ProviderWithKeysDisplay) => {
|
||||
const displayInfo = extractListItemDisplayInfo(provider);
|
||||
|
||||
// 验证图标信息存在(通过 provider.id 确定)
|
||||
expect(displayInfo.hasIcon).toBe(true);
|
||||
|
||||
// 验证名称存在
|
||||
expect(displayInfo.hasName).toBe(true);
|
||||
|
||||
// 验证启用状态存在
|
||||
expect(displayInfo.hasStatus).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
test.prop([providerWithKeysArbitrary], { numRuns: 100 })(
|
||||
"Provider ID 应为非空字符串(用于图标显示)",
|
||||
(provider: ProviderWithKeysDisplay) => {
|
||||
expect(typeof provider.id).toBe("string");
|
||||
expect(provider.id.length).toBeGreaterThan(0);
|
||||
},
|
||||
);
|
||||
|
||||
test.prop([providerWithKeysArbitrary], { numRuns: 100 })(
|
||||
"Provider 名称应为非空字符串",
|
||||
(provider: ProviderWithKeysDisplay) => {
|
||||
expect(typeof provider.name).toBe("string");
|
||||
expect(provider.name.length).toBeGreaterThan(0);
|
||||
},
|
||||
);
|
||||
|
||||
test.prop([providerWithKeysArbitrary], { numRuns: 100 })(
|
||||
"Provider 启用状态应为布尔值",
|
||||
(provider: ProviderWithKeysDisplay) => {
|
||||
expect(typeof provider.enabled).toBe("boolean");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Property 11: API Key 数量徽章正确性
|
||||
// ============================================================================
|
||||
|
||||
describe("Property 11: API Key 数量徽章正确性", () => {
|
||||
/**
|
||||
* Property 11: API Key 数量徽章正确性
|
||||
*
|
||||
* *对于任意* Provider,列表项上的徽章数字应等于该 Provider 的 API Key 数量
|
||||
*
|
||||
* **Validates: Requirements 7.2**
|
||||
*/
|
||||
test.prop([providerWithKeysArbitrary], { numRuns: 100 })(
|
||||
"API Key 数量应等于 api_keys 数组长度",
|
||||
(provider: ProviderWithKeysDisplay) => {
|
||||
const count = getApiKeyCount(provider);
|
||||
const expectedCount = provider.api_keys?.length ?? 0;
|
||||
|
||||
expect(count).toBe(expectedCount);
|
||||
},
|
||||
);
|
||||
|
||||
test.prop([providerWithKeysArbitrary], { numRuns: 100 })(
|
||||
"API Key 数量应为非负整数",
|
||||
(provider: ProviderWithKeysDisplay) => {
|
||||
const count = getApiKeyCount(provider);
|
||||
|
||||
expect(Number.isInteger(count)).toBe(true);
|
||||
expect(count).toBeGreaterThanOrEqual(0);
|
||||
},
|
||||
);
|
||||
|
||||
test.prop(
|
||||
[fc.array(apiKeyDisplayArbitrary, { minLength: 0, maxLength: 20 })],
|
||||
{ numRuns: 100 },
|
||||
)("不同数量的 API Keys 应正确反映在计数中", (apiKeys: ApiKeyDisplay[]) => {
|
||||
const provider: ProviderWithKeysDisplay = {
|
||||
id: "test-provider",
|
||||
name: "Test Provider",
|
||||
type: "openai",
|
||||
api_host: "https://api.test.com",
|
||||
is_system: false,
|
||||
group: "custom",
|
||||
enabled: true,
|
||||
sort_order: 1,
|
||||
api_key_count: apiKeys.length,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
api_keys: apiKeys,
|
||||
};
|
||||
|
||||
const count = getApiKeyCount(provider);
|
||||
expect(count).toBe(apiKeys.length);
|
||||
});
|
||||
|
||||
test("空 api_keys 数组应返回 0", () => {
|
||||
const provider: ProviderWithKeysDisplay = {
|
||||
id: "test-provider",
|
||||
name: "Test Provider",
|
||||
type: "openai",
|
||||
api_host: "https://api.test.com",
|
||||
is_system: false,
|
||||
group: "custom",
|
||||
enabled: true,
|
||||
sort_order: 1,
|
||||
api_key_count: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
api_keys: [],
|
||||
};
|
||||
|
||||
expect(getApiKeyCount(provider)).toBe(0);
|
||||
});
|
||||
|
||||
test("undefined api_keys 应返回 0", () => {
|
||||
const provider = {
|
||||
id: "test-provider",
|
||||
name: "Test Provider",
|
||||
type: "openai",
|
||||
api_host: "https://api.test.com",
|
||||
is_system: false,
|
||||
group: "custom",
|
||||
enabled: true,
|
||||
sort_order: 1,
|
||||
api_key_count: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
} as ProviderWithKeysDisplay;
|
||||
|
||||
expect(getApiKeyCount(provider)).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* @file ProviderListItem 组件
|
||||
* @description Provider 列表项组件,显示图标、名称、启用状态和 API Key 数量徽章
|
||||
* @module components/provider-pool/api-key/ProviderListItem
|
||||
*
|
||||
* **Feature: provider-ui-refactor**
|
||||
* **Validates: Requirements 1.6, 7.2**
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ProviderIcon } from "@/icons/providers";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { ProviderWithKeysDisplay } from "@/lib/api/apiKeyProvider";
|
||||
|
||||
// ============================================================================
|
||||
// 类型定义
|
||||
// ============================================================================
|
||||
|
||||
export interface ProviderListItemProps {
|
||||
/** Provider 数据(包含 API Keys) */
|
||||
provider: ProviderWithKeysDisplay;
|
||||
/** 是否选中 */
|
||||
selected?: boolean;
|
||||
/** 点击回调 */
|
||||
onClick?: () => void;
|
||||
/** 额外的 CSS 类名 */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 组件实现
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Provider 列表项组件
|
||||
*
|
||||
* 显示 Provider 的图标、名称、启用状态和 API Key 数量徽章。
|
||||
* 支持选中状态高亮显示。
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <ProviderListItem
|
||||
* provider={provider}
|
||||
* selected={selectedId === provider.id}
|
||||
* onClick={() => onSelect(provider.id)}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export const ProviderListItem: React.FC<ProviderListItemProps> = ({
|
||||
provider,
|
||||
selected = false,
|
||||
onClick,
|
||||
className,
|
||||
}) => {
|
||||
const apiKeyCount = provider.api_keys?.length ?? 0;
|
||||
const isEnabled = provider.enabled;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onClick?.();
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
// 基础样式
|
||||
"flex items-center gap-3 px-3 py-2.5 rounded-lg cursor-pointer",
|
||||
"transition-all duration-150 ease-in-out",
|
||||
// 默认状态
|
||||
"hover:bg-muted/60",
|
||||
// 选中状态
|
||||
selected &&
|
||||
"bg-primary/10 hover:bg-primary/15 border border-primary/20",
|
||||
// 禁用状态
|
||||
!isEnabled && "opacity-60",
|
||||
className,
|
||||
)}
|
||||
data-testid="provider-list-item"
|
||||
data-provider-id={provider.id}
|
||||
data-selected={selected}
|
||||
data-enabled={isEnabled}
|
||||
>
|
||||
{/* Provider 图标 */}
|
||||
<ProviderIcon
|
||||
providerType={provider.id}
|
||||
size={24}
|
||||
className="flex-shrink-0"
|
||||
data-testid="provider-icon"
|
||||
/>
|
||||
|
||||
{/* Provider 名称 */}
|
||||
<span
|
||||
className={cn(
|
||||
"flex-1 text-sm font-medium truncate",
|
||||
!isEnabled && "text-muted-foreground",
|
||||
)}
|
||||
data-testid="provider-name"
|
||||
>
|
||||
{provider.name}
|
||||
</span>
|
||||
|
||||
{/* 启用状态指示器 */}
|
||||
<div
|
||||
className={cn(
|
||||
"w-2 h-2 rounded-full flex-shrink-0",
|
||||
isEnabled ? "bg-green-500" : "bg-gray-300 dark:bg-gray-600",
|
||||
)}
|
||||
title={isEnabled ? "已启用" : "已禁用"}
|
||||
data-testid="provider-status"
|
||||
data-enabled={isEnabled}
|
||||
/>
|
||||
|
||||
{/* API Key 数量徽章 */}
|
||||
{apiKeyCount > 0 && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="flex-shrink-0 min-w-[20px] h-5 px-1.5 text-xs font-medium"
|
||||
data-testid="api-key-count-badge"
|
||||
>
|
||||
{apiKeyCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// 辅助函数(用于测试)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 从 Provider 数据中提取列表项显示所需的信息
|
||||
* 用于属性测试验证显示完整性
|
||||
*/
|
||||
export function extractListItemDisplayInfo(provider: ProviderWithKeysDisplay): {
|
||||
hasIcon: boolean;
|
||||
hasName: boolean;
|
||||
hasStatus: boolean;
|
||||
apiKeyCount: number;
|
||||
} {
|
||||
return {
|
||||
hasIcon: typeof provider.id === "string" && provider.id.length > 0,
|
||||
hasName: typeof provider.name === "string" && provider.name.length > 0,
|
||||
hasStatus: typeof provider.enabled === "boolean",
|
||||
apiKeyCount: provider.api_keys?.length ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算 Provider 的 API Key 数量
|
||||
* 用于属性测试验证徽章正确性
|
||||
*/
|
||||
export function getApiKeyCount(provider: ProviderWithKeysDisplay): number {
|
||||
return provider.api_keys?.length ?? 0;
|
||||
}
|
||||
|
||||
export default ProviderListItem;
|
||||
@@ -0,0 +1,315 @@
|
||||
/**
|
||||
* @file ProviderSetting 属性测试
|
||||
* @description 测试 Provider 设置面板字段完整性
|
||||
* @module components/provider-pool/api-key/ProviderSetting.test
|
||||
*
|
||||
* **Feature: provider-ui-refactor**
|
||||
* **Property 6: Provider 设置面板字段完整性**
|
||||
* **Validates: Requirements 4.1**
|
||||
*/
|
||||
|
||||
import { describe, expect } from "vitest";
|
||||
import { test } from "@fast-check/vitest";
|
||||
import * as fc from "fast-check";
|
||||
import { extractProviderSettingInfo } from "./ProviderSetting";
|
||||
import type {
|
||||
ProviderWithKeysDisplay,
|
||||
ApiKeyDisplay,
|
||||
} from "@/lib/api/apiKeyProvider";
|
||||
|
||||
// ============================================================================
|
||||
// 测试数据生成器
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 生成有效的 ISO 日期字符串
|
||||
*/
|
||||
const validDateArbitrary = fc
|
||||
.integer({
|
||||
min: new Date("2020-01-01").getTime(),
|
||||
max: new Date("2030-12-31").getTime(),
|
||||
})
|
||||
.map((timestamp) => new Date(timestamp).toISOString());
|
||||
|
||||
/**
|
||||
* 生成随机 API Key 显示数据
|
||||
*/
|
||||
const apiKeyDisplayArbitrary: fc.Arbitrary<ApiKeyDisplay> = fc.record({
|
||||
id: fc.uuid(),
|
||||
provider_id: fc.string({ minLength: 1, maxLength: 50 }),
|
||||
api_key_masked: fc
|
||||
.string({ minLength: 1, maxLength: 20 })
|
||||
.map((s) => `sk-****${s}`),
|
||||
alias: fc.option(fc.string({ minLength: 1, maxLength: 50 }), {
|
||||
nil: undefined,
|
||||
}),
|
||||
enabled: fc.boolean(),
|
||||
usage_count: fc.nat({ max: 10000 }),
|
||||
error_count: fc.nat({ max: 1000 }),
|
||||
last_used_at: fc.option(validDateArbitrary, { nil: undefined }),
|
||||
created_at: validDateArbitrary,
|
||||
});
|
||||
|
||||
/**
|
||||
* 生成随机 Provider 显示数据(包含 API Keys)
|
||||
*/
|
||||
const providerWithKeysArbitrary: fc.Arbitrary<ProviderWithKeysDisplay> =
|
||||
fc.record({
|
||||
id: fc.string({ minLength: 1, maxLength: 50 }),
|
||||
name: fc.string({ minLength: 1, maxLength: 100 }),
|
||||
type: fc.constantFrom(
|
||||
"openai",
|
||||
"openai-response",
|
||||
"anthropic",
|
||||
"gemini",
|
||||
"azure-openai",
|
||||
"vertexai",
|
||||
"aws-bedrock",
|
||||
"ollama",
|
||||
"new-api",
|
||||
"gateway",
|
||||
),
|
||||
api_host: fc.webUrl(),
|
||||
is_system: fc.boolean(),
|
||||
group: fc.constantFrom(
|
||||
"mainstream",
|
||||
"chinese",
|
||||
"cloud",
|
||||
"aggregator",
|
||||
"local",
|
||||
"specialized",
|
||||
"custom",
|
||||
),
|
||||
enabled: fc.boolean(),
|
||||
sort_order: fc.nat({ max: 100 }),
|
||||
api_version: fc.option(fc.string({ minLength: 1, maxLength: 20 }), {
|
||||
nil: undefined,
|
||||
}),
|
||||
project: fc.option(fc.string({ minLength: 1, maxLength: 50 }), {
|
||||
nil: undefined,
|
||||
}),
|
||||
location: fc.option(fc.string({ minLength: 1, maxLength: 50 }), {
|
||||
nil: undefined,
|
||||
}),
|
||||
region: fc.option(fc.string({ minLength: 1, maxLength: 50 }), {
|
||||
nil: undefined,
|
||||
}),
|
||||
api_key_count: fc.nat({ max: 20 }),
|
||||
created_at: validDateArbitrary,
|
||||
updated_at: validDateArbitrary,
|
||||
api_keys: fc.array(apiKeyDisplayArbitrary, { minLength: 0, maxLength: 10 }),
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Property 6: Provider 设置面板字段完整性
|
||||
// ============================================================================
|
||||
|
||||
describe("Property 6: Provider 设置面板字段完整性", () => {
|
||||
/**
|
||||
* Property 6: Provider 设置面板字段完整性
|
||||
*
|
||||
* *对于任意* Provider,设置面板应显示名称、图标、启用开关、API Key 输入框、API Host 输入框和连接测试按钮
|
||||
*
|
||||
* **Validates: Requirements 4.1**
|
||||
*/
|
||||
test.prop([providerWithKeysArbitrary], { numRuns: 100 })(
|
||||
"每个 Provider 设置面板应包含所有必需字段",
|
||||
(provider: ProviderWithKeysDisplay) => {
|
||||
const info = extractProviderSettingInfo(provider);
|
||||
|
||||
// 验证 Provider 存在
|
||||
expect(info.hasProvider).toBe(true);
|
||||
|
||||
// 验证图标信息存在(通过 provider.id 确定)
|
||||
expect(info.hasIcon).toBe(true);
|
||||
|
||||
// 验证名称存在
|
||||
expect(info.hasName).toBe(true);
|
||||
|
||||
// 验证启用开关存在
|
||||
expect(info.hasEnabledSwitch).toBe(true);
|
||||
|
||||
// 验证 API Key 区域存在
|
||||
expect(info.hasApiKeySection).toBe(true);
|
||||
|
||||
// 验证配置区域存在(包含 API Host 输入框)
|
||||
expect(info.hasConfigSection).toBe(true);
|
||||
|
||||
// 验证连接测试按钮存在
|
||||
expect(info.hasConnectionTest).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
test.prop([providerWithKeysArbitrary], { numRuns: 100 })(
|
||||
"Provider ID 应为非空字符串(用于图标显示)",
|
||||
(provider: ProviderWithKeysDisplay) => {
|
||||
expect(typeof provider.id).toBe("string");
|
||||
expect(provider.id.length).toBeGreaterThan(0);
|
||||
},
|
||||
);
|
||||
|
||||
test.prop([providerWithKeysArbitrary], { numRuns: 100 })(
|
||||
"Provider 名称应为非空字符串",
|
||||
(provider: ProviderWithKeysDisplay) => {
|
||||
expect(typeof provider.name).toBe("string");
|
||||
expect(provider.name.length).toBeGreaterThan(0);
|
||||
},
|
||||
);
|
||||
|
||||
test.prop([providerWithKeysArbitrary], { numRuns: 100 })(
|
||||
"Provider 启用状态应为布尔值",
|
||||
(provider: ProviderWithKeysDisplay) => {
|
||||
expect(typeof provider.enabled).toBe("boolean");
|
||||
},
|
||||
);
|
||||
|
||||
test.prop([providerWithKeysArbitrary], { numRuns: 100 })(
|
||||
"Provider API Host 应为有效 URL",
|
||||
(provider: ProviderWithKeysDisplay) => {
|
||||
expect(typeof provider.api_host).toBe("string");
|
||||
expect(provider.api_host.length).toBeGreaterThan(0);
|
||||
},
|
||||
);
|
||||
|
||||
// 空状态测试
|
||||
describe("空状态处理", () => {
|
||||
test("null Provider 应返回空状态信息", () => {
|
||||
const info = extractProviderSettingInfo(null);
|
||||
|
||||
expect(info.hasProvider).toBe(false);
|
||||
expect(info.hasIcon).toBe(false);
|
||||
expect(info.hasName).toBe(false);
|
||||
expect(info.hasEnabledSwitch).toBe(false);
|
||||
expect(info.hasApiKeySection).toBe(false);
|
||||
expect(info.hasConfigSection).toBe(false);
|
||||
expect(info.hasConnectionTest).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// 具体字段验证
|
||||
describe("具体字段验证", () => {
|
||||
test("Provider 应包含有效的类型", () => {
|
||||
const validTypes = [
|
||||
"openai",
|
||||
"openai-response",
|
||||
"anthropic",
|
||||
"gemini",
|
||||
"azure-openai",
|
||||
"vertexai",
|
||||
"aws-bedrock",
|
||||
"ollama",
|
||||
"new-api",
|
||||
"gateway",
|
||||
];
|
||||
|
||||
const provider: ProviderWithKeysDisplay = {
|
||||
id: "test-provider",
|
||||
name: "Test Provider",
|
||||
type: "openai",
|
||||
api_host: "https://api.test.com",
|
||||
is_system: false,
|
||||
group: "custom",
|
||||
enabled: true,
|
||||
sort_order: 1,
|
||||
api_key_count: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
api_keys: [],
|
||||
};
|
||||
|
||||
expect(validTypes).toContain(provider.type);
|
||||
});
|
||||
|
||||
test("Provider 应包含有效的分组", () => {
|
||||
const validGroups = [
|
||||
"mainstream",
|
||||
"chinese",
|
||||
"cloud",
|
||||
"aggregator",
|
||||
"local",
|
||||
"specialized",
|
||||
"custom",
|
||||
];
|
||||
|
||||
const provider: ProviderWithKeysDisplay = {
|
||||
id: "test-provider",
|
||||
name: "Test Provider",
|
||||
type: "openai",
|
||||
api_host: "https://api.test.com",
|
||||
is_system: false,
|
||||
group: "custom",
|
||||
enabled: true,
|
||||
sort_order: 1,
|
||||
api_key_count: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
api_keys: [],
|
||||
};
|
||||
|
||||
expect(validGroups).toContain(provider.group);
|
||||
});
|
||||
|
||||
test("System Provider 应标记为 is_system: true", () => {
|
||||
const systemProvider: ProviderWithKeysDisplay = {
|
||||
id: "openai",
|
||||
name: "OpenAI",
|
||||
type: "openai-response",
|
||||
api_host: "https://api.openai.com",
|
||||
is_system: true,
|
||||
group: "mainstream",
|
||||
enabled: true,
|
||||
sort_order: 1,
|
||||
api_key_count: 1,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
api_keys: [],
|
||||
};
|
||||
|
||||
expect(systemProvider.is_system).toBe(true);
|
||||
});
|
||||
|
||||
test("Custom Provider 应标记为 is_system: false", () => {
|
||||
const customProvider: ProviderWithKeysDisplay = {
|
||||
id: "my-custom-provider",
|
||||
name: "My Custom Provider",
|
||||
type: "openai",
|
||||
api_host: "https://api.custom.com",
|
||||
is_system: false,
|
||||
group: "custom",
|
||||
enabled: true,
|
||||
sort_order: 100,
|
||||
api_key_count: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
api_keys: [],
|
||||
};
|
||||
|
||||
expect(customProvider.is_system).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// API Keys 数组验证
|
||||
describe("API Keys 数组验证", () => {
|
||||
test.prop([providerWithKeysArbitrary], { numRuns: 100 })(
|
||||
"api_keys 应为数组",
|
||||
(provider: ProviderWithKeysDisplay) => {
|
||||
expect(Array.isArray(provider.api_keys)).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
test.prop(
|
||||
[fc.array(apiKeyDisplayArbitrary, { minLength: 1, maxLength: 5 })],
|
||||
{ numRuns: 100 },
|
||||
)("每个 API Key 应包含必需字段", (apiKeys: ApiKeyDisplay[]) => {
|
||||
for (const apiKey of apiKeys) {
|
||||
expect(typeof apiKey.id).toBe("string");
|
||||
expect(typeof apiKey.provider_id).toBe("string");
|
||||
expect(typeof apiKey.api_key_masked).toBe("string");
|
||||
expect(typeof apiKey.enabled).toBe("boolean");
|
||||
expect(typeof apiKey.usage_count).toBe("number");
|
||||
expect(typeof apiKey.error_count).toBe("number");
|
||||
expect(typeof apiKey.created_at).toBe("string");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* @file ProviderSetting 组件
|
||||
* @description Provider 设置面板组件,集成所有子组件,显示 Provider 头部信息和配置
|
||||
* @module components/provider-pool/api-key/ProviderSetting
|
||||
*
|
||||
* **Feature: provider-ui-refactor**
|
||||
* **Validates: Requirements 4.1, 6.3, 6.4**
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { ProviderIcon } from "@/icons/providers";
|
||||
import { ApiKeyList } from "./ApiKeyList";
|
||||
import { ProviderConfigForm } from "./ProviderConfigForm";
|
||||
import {
|
||||
ConnectionTestButton,
|
||||
ConnectionTestResult,
|
||||
} from "./ConnectionTestButton";
|
||||
import type {
|
||||
ProviderWithKeysDisplay,
|
||||
UpdateProviderRequest,
|
||||
} from "@/lib/api/apiKeyProvider";
|
||||
|
||||
// ============================================================================
|
||||
// 类型定义
|
||||
// ============================================================================
|
||||
|
||||
export interface ProviderSettingProps {
|
||||
/** Provider 数据(包含 API Keys) */
|
||||
provider: ProviderWithKeysDisplay | null;
|
||||
/** 更新 Provider 配置回调 */
|
||||
onUpdate?: (id: string, request: UpdateProviderRequest) => Promise<void>;
|
||||
/** 添加 API Key 回调 */
|
||||
onAddApiKey?: (
|
||||
providerId: string,
|
||||
apiKey: string,
|
||||
alias?: string,
|
||||
) => Promise<void>;
|
||||
/** 删除 API Key 回调 */
|
||||
onDeleteApiKey?: (keyId: string) => void;
|
||||
/** 切换 API Key 启用状态回调 */
|
||||
onToggleApiKey?: (keyId: string, enabled: boolean) => void;
|
||||
/** 测试连接回调 */
|
||||
onTestConnection?: (providerId: string) => Promise<ConnectionTestResult>;
|
||||
/** 删除自定义 Provider 回调 */
|
||||
onDeleteProvider?: (providerId: string) => void;
|
||||
/** 是否正在加载 */
|
||||
loading?: boolean;
|
||||
/** 额外的 CSS 类名 */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 组件实现
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Provider 设置面板组件
|
||||
*
|
||||
* 显示选中 Provider 的完整配置界面,包括:
|
||||
* - Provider 头部信息(图标、名称、启用开关)
|
||||
* - API Key 列表
|
||||
* - Provider 配置表单
|
||||
* - 连接测试按钮
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <ProviderSetting
|
||||
* provider={selectedProvider}
|
||||
* onUpdate={updateProvider}
|
||||
* onAddApiKey={addApiKey}
|
||||
* onDeleteApiKey={deleteApiKey}
|
||||
* onToggleApiKey={toggleApiKey}
|
||||
* onTestConnection={testConnection}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export const ProviderSetting: React.FC<ProviderSettingProps> = ({
|
||||
provider,
|
||||
onUpdate,
|
||||
onAddApiKey,
|
||||
onDeleteApiKey,
|
||||
onToggleApiKey,
|
||||
onTestConnection,
|
||||
onDeleteProvider,
|
||||
loading = false,
|
||||
className,
|
||||
}) => {
|
||||
// 空状态
|
||||
if (!provider) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center h-full text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
data-testid="provider-setting-empty"
|
||||
>
|
||||
<div className="text-center">
|
||||
<p className="text-sm">请从左侧列表选择一个 Provider</p>
|
||||
<p className="text-xs mt-1">选择后可在此处配置 API Key 和其他设置</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 处理启用/禁用切换
|
||||
const handleToggleEnabled = async (enabled: boolean) => {
|
||||
if (onUpdate) {
|
||||
await onUpdate(provider.id, { enabled });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex flex-col h-full", className)}
|
||||
data-testid="provider-setting"
|
||||
data-provider-id={provider.id}
|
||||
>
|
||||
{/* Provider 头部 */}
|
||||
<div
|
||||
className="flex items-center gap-4 p-4 border-b border-border"
|
||||
data-testid="provider-header"
|
||||
>
|
||||
{/* 图标 */}
|
||||
<ProviderIcon
|
||||
providerType={provider.id}
|
||||
size={40}
|
||||
className="flex-shrink-0"
|
||||
data-testid="provider-icon"
|
||||
/>
|
||||
|
||||
{/* 名称和类型 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3
|
||||
className="text-lg font-semibold truncate"
|
||||
data-testid="provider-name"
|
||||
>
|
||||
{provider.name}
|
||||
</h3>
|
||||
<p
|
||||
className="text-sm text-muted-foreground"
|
||||
data-testid="provider-type"
|
||||
>
|
||||
类型: {provider.type}
|
||||
{provider.is_system && (
|
||||
<span className="ml-2 text-xs bg-blue-100 text-blue-700 px-1.5 py-0.5 rounded">
|
||||
系统预设
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 启用开关 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{provider.enabled ? "已启用" : "已禁用"}
|
||||
</span>
|
||||
<Switch
|
||||
checked={provider.enabled}
|
||||
onCheckedChange={handleToggleEnabled}
|
||||
disabled={loading}
|
||||
data-testid="provider-enabled-switch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 删除按钮(仅自定义 Provider) */}
|
||||
{!provider.is_system && onDeleteProvider && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onDeleteProvider(provider.id)}
|
||||
disabled={loading}
|
||||
className="text-muted-foreground hover:text-red-600 hover:bg-red-50"
|
||||
title="删除此 Provider"
|
||||
data-testid="delete-provider-button"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 内容区域 */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-6">
|
||||
{/* API Key 列表 */}
|
||||
<section data-testid="api-key-section">
|
||||
<ApiKeyList
|
||||
apiKeys={provider.api_keys || []}
|
||||
providerId={provider.id}
|
||||
onAdd={onAddApiKey}
|
||||
onToggle={onToggleApiKey}
|
||||
onDelete={onDeleteApiKey}
|
||||
loading={loading}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* 分隔线 */}
|
||||
<div className="border-t border-border" />
|
||||
|
||||
{/* Provider 配置表单 */}
|
||||
<section data-testid="config-section">
|
||||
<h4 className="text-sm font-medium text-foreground mb-3">配置</h4>
|
||||
<ProviderConfigForm
|
||||
provider={provider}
|
||||
onUpdate={onUpdate}
|
||||
loading={loading}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* 分隔线 */}
|
||||
<div className="border-t border-border" />
|
||||
|
||||
{/* 连接测试 */}
|
||||
<section data-testid="connection-test-section">
|
||||
<h4 className="text-sm font-medium text-foreground mb-3">连接测试</h4>
|
||||
<ConnectionTestButton
|
||||
providerId={provider.id}
|
||||
onTest={onTestConnection}
|
||||
disabled={
|
||||
loading ||
|
||||
!provider.enabled ||
|
||||
(provider.api_keys?.length ?? 0) === 0
|
||||
}
|
||||
/>
|
||||
{(provider.api_keys?.length ?? 0) === 0 && (
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
请先添加 API Key 后再进行连接测试
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// 辅助函数(用于测试)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 从 Provider 数据中提取设置面板显示所需的信息
|
||||
* 用于属性测试验证设置面板字段完整性
|
||||
*/
|
||||
export function extractProviderSettingInfo(
|
||||
provider: ProviderWithKeysDisplay | null,
|
||||
): {
|
||||
hasProvider: boolean;
|
||||
hasIcon: boolean;
|
||||
hasName: boolean;
|
||||
hasEnabledSwitch: boolean;
|
||||
hasApiKeySection: boolean;
|
||||
hasConfigSection: boolean;
|
||||
hasConnectionTest: boolean;
|
||||
} {
|
||||
if (!provider) {
|
||||
return {
|
||||
hasProvider: false,
|
||||
hasIcon: false,
|
||||
hasName: false,
|
||||
hasEnabledSwitch: false,
|
||||
hasApiKeySection: false,
|
||||
hasConfigSection: false,
|
||||
hasConnectionTest: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
hasProvider: true,
|
||||
hasIcon: typeof provider.id === "string" && provider.id.length > 0,
|
||||
hasName: typeof provider.name === "string" && provider.name.length > 0,
|
||||
hasEnabledSwitch: typeof provider.enabled === "boolean",
|
||||
hasApiKeySection: true,
|
||||
hasConfigSection: true,
|
||||
hasConnectionTest: true,
|
||||
};
|
||||
}
|
||||
|
||||
export default ProviderSetting;
|
||||
@@ -0,0 +1,98 @@
|
||||
# API Key Provider 组件
|
||||
|
||||
本目录包含 API Key Provider 管理界面的所有组件。
|
||||
|
||||
## 组件列表
|
||||
|
||||
| 文件 | 描述 |
|
||||
|------|------|
|
||||
| `ProviderListItem.tsx` | Provider 列表项组件,显示图标、名称、启用状态和 API Key 数量徽章 |
|
||||
| `ProviderGroup.tsx` | Provider 分组组件,支持折叠/展开和显示分组标题 |
|
||||
| `ProviderList.tsx` | Provider 列表组件,集成搜索框、分组显示 |
|
||||
| `ApiKeyItem.tsx` | API Key 列表项组件,显示掩码 Key、别名、使用统计,支持启用/禁用、删除 |
|
||||
| `ApiKeyList.tsx` | API Key 列表组件,显示 Provider 的所有 API Key,支持添加新 Key |
|
||||
| `ProviderConfigForm.tsx` | Provider 配置表单组件,显示 API Host 和根据类型显示额外字段 |
|
||||
| `ConnectionTestButton.tsx` | 连接测试按钮组件,用于测试 Provider API 连接 |
|
||||
| `ProviderSetting.tsx` | Provider 设置面板组件,集成所有子组件,显示完整配置界面 |
|
||||
| `ApiKeyProviderSection.tsx` | API Key Provider 管理区域组件,实现左右分栏布局 |
|
||||
| `AddCustomProviderModal.tsx` | 添加自定义 Provider 模态框组件,实现表单验证 |
|
||||
| `DeleteProviderDialog.tsx` | 删除自定义 Provider 确认对话框组件 |
|
||||
| `ImportExportDialog.tsx` | Provider 配置导入导出对话框组件 |
|
||||
| `index.ts` | 组件导出入口 |
|
||||
|
||||
## 测试文件
|
||||
|
||||
| 文件 | 描述 |
|
||||
|------|------|
|
||||
| `ProviderListItem.test.ts` | Property 1 & 11 属性测试 |
|
||||
| `ProviderList.test.ts` | Property 10, 14 & 15 属性测试 |
|
||||
| `ProviderConfigForm.test.ts` | Property 7 属性测试:Provider 类型处理正确性 |
|
||||
| `ProviderSetting.test.ts` | Property 6 属性测试:Provider 设置面板字段完整性 |
|
||||
| `ApiKeyProviderSection.test.ts` | Property 2 属性测试:Provider 选择同步 |
|
||||
| `AddCustomProviderModal.test.ts` | Property 8 属性测试:自定义 Provider 表单验证 |
|
||||
| `DeleteProviderDialog.test.ts` | Property 9 属性测试:System Provider 删除保护 |
|
||||
|
||||
## 使用示例
|
||||
|
||||
```tsx
|
||||
import { ApiKeyProviderSection } from "@/components/provider-pool/api-key";
|
||||
|
||||
function ProviderPoolPage() {
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="h-full">
|
||||
<ApiKeyProviderSection
|
||||
onAddCustomProvider={() => setShowAddModal(true)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 单独使用 ProviderList 和 ProviderSetting
|
||||
|
||||
```tsx
|
||||
import { ProviderList, ProviderSetting } from "@/components/provider-pool/api-key";
|
||||
|
||||
function ApiKeySection() {
|
||||
const {
|
||||
providersByGroup,
|
||||
selectedProviderId,
|
||||
selectedProvider,
|
||||
selectProvider,
|
||||
updateProvider,
|
||||
addApiKey,
|
||||
deleteApiKey,
|
||||
toggleApiKey,
|
||||
} = useApiKeyProvider();
|
||||
|
||||
return (
|
||||
<div className="flex">
|
||||
<ProviderList
|
||||
providersByGroup={providersByGroup}
|
||||
selectedProviderId={selectedProviderId}
|
||||
onProviderSelect={selectProvider}
|
||||
/>
|
||||
<ProviderSetting
|
||||
provider={selectedProvider}
|
||||
onUpdate={updateProvider}
|
||||
onAddApiKey={addApiKey}
|
||||
onDeleteApiKey={deleteApiKey}
|
||||
onToggleApiKey={toggleApiKey}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## 相关需求
|
||||
|
||||
- Requirements 1.1, 1.3, 1.4: API Key Provider 左右分栏布局
|
||||
- Requirements 1.2, 1.5, 1.6: Provider 列表布局和交互
|
||||
- Requirements 4.1, 4.2, 4.3, 4.4: Provider 设置面板
|
||||
- Requirements 5.1-5.5: Provider 类型系统
|
||||
- Requirements 7.1, 7.2, 7.5: 多 API Key 支持
|
||||
- Requirements 8.1, 8.2, 8.3: Provider 分组和搜索
|
||||
- Requirements 10.4: Provider 图标显示
|
||||
- Requirements 9.4, 9.5: 导入导出功能
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* @file API Key Provider 组件导出
|
||||
* @description 导出所有 API Key Provider 相关组件
|
||||
* @module components/provider-pool/api-key
|
||||
*
|
||||
* **Feature: provider-ui-refactor**
|
||||
*/
|
||||
|
||||
export { ProviderListItem } from "./ProviderListItem";
|
||||
export type { ProviderListItemProps } from "./ProviderListItem";
|
||||
|
||||
export { ProviderGroup } from "./ProviderGroup";
|
||||
export type { ProviderGroupProps } from "./ProviderGroup";
|
||||
|
||||
export { ProviderList } from "./ProviderList";
|
||||
export type { ProviderListProps } from "./ProviderList";
|
||||
|
||||
export { ApiKeyItem } from "./ApiKeyItem";
|
||||
export type { ApiKeyItemProps } from "./ApiKeyItem";
|
||||
|
||||
export { ApiKeyList } from "./ApiKeyList";
|
||||
export type { ApiKeyListProps } from "./ApiKeyList";
|
||||
|
||||
export { ProviderConfigForm } from "./ProviderConfigForm";
|
||||
export type { ProviderConfigFormProps } from "./ProviderConfigForm";
|
||||
|
||||
export { ConnectionTestButton } from "./ConnectionTestButton";
|
||||
export type {
|
||||
ConnectionTestButtonProps,
|
||||
ConnectionTestResult,
|
||||
ConnectionTestStatus,
|
||||
} from "./ConnectionTestButton";
|
||||
|
||||
export { ProviderSetting } from "./ProviderSetting";
|
||||
export type { ProviderSettingProps } from "./ProviderSetting";
|
||||
|
||||
export { ApiKeyProviderSection } from "./ApiKeyProviderSection";
|
||||
export type { ApiKeyProviderSectionProps } from "./ApiKeyProviderSection";
|
||||
|
||||
export { AddCustomProviderModal } from "./AddCustomProviderModal";
|
||||
export type { AddCustomProviderModalProps } from "./AddCustomProviderModal";
|
||||
|
||||
export { DeleteProviderDialog } from "./DeleteProviderDialog";
|
||||
export type { DeleteProviderDialogProps } from "./DeleteProviderDialog";
|
||||
|
||||
export { ImportExportDialog } from "./ImportExportDialog";
|
||||
export type { ImportExportDialogProps } from "./ImportExportDialog";
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* @file Checkbox 组件
|
||||
* @description 复选框组件
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { Check } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface CheckboxProps extends Omit<
|
||||
React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
"onChange"
|
||||
> {
|
||||
checked?: boolean;
|
||||
onCheckedChange?: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
const Checkbox = React.forwardRef<HTMLButtonElement, CheckboxProps>(
|
||||
({ className, checked = false, onCheckedChange, id, ...props }, ref) => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={checked}
|
||||
id={id}
|
||||
className={cn(
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
checked ? "bg-primary text-primary-foreground" : "bg-background",
|
||||
className,
|
||||
)}
|
||||
onClick={() => onCheckedChange?.(!checked)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
{checked && <Check className="h-3 w-3 mx-auto" />}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
Checkbox.displayName = "Checkbox";
|
||||
|
||||
export { Checkbox };
|
||||
@@ -11,10 +11,29 @@ const DialogContext = createContext<DialogContextType | undefined>(undefined);
|
||||
|
||||
interface DialogProps {
|
||||
children: React.ReactNode;
|
||||
/** 受控模式:是否打开 */
|
||||
open?: boolean;
|
||||
/** 受控模式:打开状态变化回调 */
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const Dialog: React.FC<DialogProps> = ({ children }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const Dialog: React.FC<DialogProps> = ({
|
||||
children,
|
||||
open: controlledOpen,
|
||||
onOpenChange,
|
||||
}) => {
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
|
||||
// 支持受控和非受控模式
|
||||
const isControlled = controlledOpen !== undefined;
|
||||
const open = isControlled ? controlledOpen : internalOpen;
|
||||
|
||||
const setOpen = (newOpen: boolean) => {
|
||||
if (!isControlled) {
|
||||
setInternalOpen(newOpen);
|
||||
}
|
||||
onOpenChange?.(newOpen);
|
||||
};
|
||||
|
||||
return (
|
||||
<DialogContext.Provider value={{ open, setOpen }}>
|
||||
|
||||
Reference in New Issue
Block a user