mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
fix: stabilize v0.92.0 release build
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
### ✨ 主要更新
|
||||
|
||||
- **Team Workspace 正式成型**:Agent 聊天页新增 Team Workspace 主工作台、建议栏、Dock 与 Home Shell,围绕多代理协作视图重组交互结构
|
||||
- **Team 配置与发布稳定性收尾**:补齐 Team Selector 自定义 Team 配置链路、当前 Team 展示与相关测试,并修复通知、Provider Runtime 与前端类型兼容问题,确保 `v0.92.0` 可稳定构建发布
|
||||
- **运行态与工具可视化增强**:`ToolCallDisplay`、Harness 状态面板、Runtime Strip、执行日志与子代理时间线继续增强,工具调用与运行态反馈更完整
|
||||
- **Aster Agent 运行时继续收口**:Rust 侧补齐 session store、subagent control、agent tools inventory / execution、runtime queue 及命令桥接,统一现役 Agent Runtime 路径
|
||||
- **治理与测试基建升级**:新增 `pr-gate`、本地校验脚本、命令契约检查、workspace smoke 与治理报告更新,发布前自检链路更清晰
|
||||
|
||||
@@ -9267,6 +9267,9 @@ export function AgentChatWorkspace({
|
||||
compatSubagentRuntime={compatSubagentRuntime}
|
||||
environment={harnessEnvironment}
|
||||
childSubagentSessions={childSubagentSessions}
|
||||
selectedTeamLabel={selectedTeamLabel}
|
||||
selectedTeamSummary={selectedTeamSummary}
|
||||
selectedTeamRoles={selectedTeam?.roles}
|
||||
toolInventory={toolInventory}
|
||||
toolInventoryLoading={toolInventoryLoading}
|
||||
toolInventoryError={toolInventoryError}
|
||||
@@ -9290,6 +9293,9 @@ export function AgentChatWorkspace({
|
||||
isThemeWorkbench,
|
||||
refreshToolInventory,
|
||||
compatSubagentRuntime,
|
||||
selectedTeam?.roles,
|
||||
selectedTeamLabel,
|
||||
selectedTeamSummary,
|
||||
toolInventory,
|
||||
toolInventoryError,
|
||||
toolInventoryLoading,
|
||||
@@ -9789,6 +9795,9 @@ export function AgentChatWorkspace({
|
||||
compatSubagentRuntime={compatSubagentRuntime}
|
||||
environment={harnessEnvironment}
|
||||
childSubagentSessions={childSubagentSessions}
|
||||
selectedTeamLabel={selectedTeamLabel}
|
||||
selectedTeamSummary={selectedTeamSummary}
|
||||
selectedTeamRoles={selectedTeam?.roles}
|
||||
toolInventory={toolInventory}
|
||||
toolInventoryLoading={toolInventoryLoading}
|
||||
toolInventoryError={toolInventoryError}
|
||||
@@ -9807,6 +9816,9 @@ export function AgentChatWorkspace({
|
||||
variant="embedded"
|
||||
isSending={isSending}
|
||||
runtimeStatusTitle={activeRuntimeStatusTitle}
|
||||
selectedTeamLabel={selectedTeamLabel}
|
||||
selectedTeamSummary={selectedTeamSummary}
|
||||
selectedTeamRoleCount={selectedTeam?.roles.length || 0}
|
||||
/>
|
||||
}
|
||||
onOpenSubagentSession={handleOpenSubagentSession}
|
||||
@@ -9832,6 +9844,9 @@ export function AgentChatWorkspace({
|
||||
mappedTheme,
|
||||
refreshToolInventory,
|
||||
compatSubagentRuntime,
|
||||
selectedTeam?.roles,
|
||||
selectedTeamLabel,
|
||||
selectedTeamSummary,
|
||||
toolInventory,
|
||||
toolInventoryError,
|
||||
toolInventoryLoading,
|
||||
|
||||
@@ -19,6 +19,9 @@ interface AgentRuntimeStripProps {
|
||||
variant?: "standalone" | "embedded";
|
||||
isSending?: boolean;
|
||||
runtimeStatusTitle?: string | null;
|
||||
selectedTeamLabel?: string | null;
|
||||
selectedTeamSummary?: string | null;
|
||||
selectedTeamRoleCount?: number;
|
||||
}
|
||||
|
||||
const THEME_LABELS: Record<string, string> = {
|
||||
@@ -48,9 +51,14 @@ export const AgentRuntimeStrip: React.FC<AgentRuntimeStripProps> = ({
|
||||
variant = "standalone",
|
||||
isSending = false,
|
||||
runtimeStatusTitle = null,
|
||||
selectedTeamLabel = null,
|
||||
selectedTeamSummary = null,
|
||||
selectedTeamRoleCount = 0,
|
||||
}) => {
|
||||
const themeLabel =
|
||||
THEME_LABELS[activeTheme?.trim().toLowerCase() || ""] || "通用对话";
|
||||
const hasSelectedTeam =
|
||||
Boolean(selectedTeamLabel?.trim()) || selectedTeamRoleCount > 0;
|
||||
|
||||
const capabilities = useMemo<CapabilityItem[]>(
|
||||
() => [
|
||||
@@ -190,6 +198,13 @@ export const AgentRuntimeStrip: React.FC<AgentRuntimeStripProps> = ({
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
<div className="text-sm font-medium text-foreground">通用 Agent</div>
|
||||
<Badge variant="outline">{themeLabel}</Badge>
|
||||
{toolPreferences.subagent ? (
|
||||
<Badge variant={hasSelectedTeam ? "secondary" : "outline"}>
|
||||
{hasSelectedTeam
|
||||
? `Team · ${selectedTeamLabel || `${selectedTeamRoleCount} 角色`}`
|
||||
: "Team mode"}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mb-3 flex flex-wrap gap-2">
|
||||
{capabilities.map((item) => (
|
||||
@@ -206,6 +221,19 @@ export const AgentRuntimeStrip: React.FC<AgentRuntimeStripProps> = ({
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{toolPreferences.subagent ? (
|
||||
<div className="mb-3 rounded-xl border border-border/70 bg-background/80 px-3 py-2 text-xs text-muted-foreground">
|
||||
<span className="font-medium text-foreground">当前 Team</span>
|
||||
<span>
|
||||
{" "}
|
||||
·{" "}
|
||||
{selectedTeamSummary?.trim() ||
|
||||
(hasSelectedTeam
|
||||
? `已配置 ${selectedTeamRoleCount} 个角色,运行时可按需委派。`
|
||||
: "已开启 Team mode,本轮可选择或自定义 Team。")}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{statusItems.map((item) => (
|
||||
<Badge key={item.key} variant={item.tone || "outline"}>
|
||||
|
||||
@@ -144,7 +144,7 @@ function createToolInventory(): AgentRuntimeToolInventory {
|
||||
{
|
||||
name: "write",
|
||||
profiles: ["core"],
|
||||
capabilities: ["filesystem"],
|
||||
capabilities: ["workspace_io"],
|
||||
lifecycle: "current",
|
||||
source: "aster_builtin",
|
||||
permission_plane: "parameter_restricted",
|
||||
@@ -159,7 +159,7 @@ function createToolInventory(): AgentRuntimeToolInventory {
|
||||
{
|
||||
name: "tool_search",
|
||||
profiles: ["core"],
|
||||
capabilities: ["discovery"],
|
||||
capabilities: ["web_search"],
|
||||
lifecycle: "current",
|
||||
source: "lime_injected",
|
||||
permission_plane: "session_allowlist",
|
||||
@@ -400,6 +400,31 @@ describe("HarnessStatusPanel", () => {
|
||||
expect(document.body.textContent).toContain("等待首个模型事件");
|
||||
});
|
||||
|
||||
it("存在 selectedTeam 时应在工作台展示当前 Team 配置", () => {
|
||||
renderPanel({
|
||||
selectedTeamLabel: "前端联调团队",
|
||||
selectedTeamSummary: "分析、实现、验证三段式推进。",
|
||||
selectedTeamRoles: [
|
||||
{
|
||||
id: "explorer",
|
||||
label: "分析",
|
||||
summary: "负责定位问题、澄清范围。",
|
||||
profileId: "code-explorer",
|
||||
roleKey: "explorer",
|
||||
skillIds: ["repo-exploration", "source-grounding"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(document.body.textContent).toContain("当前 Team");
|
||||
expect(document.body.textContent).toContain("当前 Team 配置");
|
||||
expect(document.body.textContent).toContain("前端联调团队");
|
||||
expect(document.body.textContent).toContain("分析、实现、验证三段式推进。");
|
||||
expect(document.body.textContent).toContain("画像 code-explorer");
|
||||
expect(document.body.textContent).toContain("Role explorer");
|
||||
expect(document.body.textContent).toContain("repo-exploration");
|
||||
});
|
||||
|
||||
it("存在真实 child session 时应优先展示 Team 会话摘要,并将旧 scheduler 降级为兼容轨迹", () => {
|
||||
renderPanel({
|
||||
childSubagentSessions: [
|
||||
|
||||
@@ -75,6 +75,7 @@ import {
|
||||
summarizeSearchQuerySemantics,
|
||||
} from "../utils/searchQueryGrouping";
|
||||
import type { CompatSubagentRuntimeSnapshot } from "../utils/compatSubagentRuntime";
|
||||
import type { TeamRoleDefinition } from "../utils/teamDefinitions";
|
||||
|
||||
interface HarnessEnvironmentSummary {
|
||||
skillsCount: number;
|
||||
@@ -113,6 +114,9 @@ interface HarnessStatusPanelProps {
|
||||
description?: string;
|
||||
toggleLabel?: string;
|
||||
leadContent?: ReactNode;
|
||||
selectedTeamLabel?: string | null;
|
||||
selectedTeamSummary?: string | null;
|
||||
selectedTeamRoles?: TeamRoleDefinition[] | null;
|
||||
}
|
||||
|
||||
interface PreviewDialogState {
|
||||
@@ -135,6 +139,7 @@ type FileDisplayMode = "timeline" | "grouped";
|
||||
type ToolInventoryFilterValue = "all" | "runtime" | "persisted" | "default";
|
||||
|
||||
type HarnessSectionKey =
|
||||
| "team_config"
|
||||
| "runtime"
|
||||
| "inventory"
|
||||
| "approvals"
|
||||
@@ -1336,6 +1341,9 @@ export function HarnessStatusPanel({
|
||||
description = "展示最近文件活动、工具输出、审批与上下文装载情况。",
|
||||
toggleLabel = "详情",
|
||||
leadContent,
|
||||
selectedTeamLabel = null,
|
||||
selectedTeamSummary = null,
|
||||
selectedTeamRoles = [],
|
||||
}: HarnessStatusPanelProps) {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const isDialogLayout = layout === "dialog";
|
||||
@@ -1391,6 +1399,9 @@ export function HarnessStatusPanel({
|
||||
[childSubagentSessions],
|
||||
);
|
||||
const hasCompatSchedulerSignals = compatSubagentRuntime.hasSignals;
|
||||
const hasSelectedTeamConfig = Boolean(selectedTeamLabel?.trim()) ||
|
||||
Boolean(selectedTeamSummary?.trim()) ||
|
||||
(selectedTeamRoles?.length ?? 0) > 0;
|
||||
|
||||
const fileFilterOptions = useMemo(
|
||||
() =>
|
||||
@@ -1529,6 +1540,10 @@ export function HarnessStatusPanel({
|
||||
const availableSections = useMemo(() => {
|
||||
const sections: HarnessSectionNavItem[] = [];
|
||||
|
||||
if (hasSelectedTeamConfig) {
|
||||
sections.push({ key: "team_config", label: "当前 Team" });
|
||||
}
|
||||
|
||||
if (harnessState.runtimeStatus) {
|
||||
sections.push({ key: "runtime", label: "当前阶段" });
|
||||
}
|
||||
@@ -1581,6 +1596,7 @@ export function HarnessStatusPanel({
|
||||
harnessState.plan.phase,
|
||||
harnessState.recentFileEvents.length,
|
||||
harnessState.runtimeStatus,
|
||||
hasSelectedTeamConfig,
|
||||
hasCompatSchedulerSignals,
|
||||
realTeamSummary.total,
|
||||
]);
|
||||
@@ -1599,6 +1615,22 @@ export function HarnessStatusPanel({
|
||||
});
|
||||
}
|
||||
|
||||
if (hasSelectedTeamConfig) {
|
||||
cards.push({
|
||||
sectionKey: "team_config",
|
||||
title: "当前 Team",
|
||||
value:
|
||||
selectedTeamLabel?.trim() ||
|
||||
`${selectedTeamRoles?.length || 0} 个角色`,
|
||||
hint:
|
||||
selectedTeamSummary?.trim() ||
|
||||
((selectedTeamRoles?.length || 0) > 0
|
||||
? `已配置 ${selectedTeamRoles?.length || 0} 个角色`
|
||||
: "当前回合已启用 Team 配置"),
|
||||
icon: Workflow,
|
||||
});
|
||||
}
|
||||
|
||||
if (harnessState.activeFileWrites.length > 0) {
|
||||
cards.push({
|
||||
sectionKey: "writes",
|
||||
@@ -1695,6 +1727,7 @@ export function HarnessStatusPanel({
|
||||
environment.contextEnabled,
|
||||
environment.contextItemsCount,
|
||||
hasToolInventorySection,
|
||||
hasSelectedTeamConfig,
|
||||
harnessState.activeFileWrites,
|
||||
harnessState.pendingApprovals.length,
|
||||
harnessState.plan.items,
|
||||
@@ -1708,6 +1741,9 @@ export function HarnessStatusPanel({
|
||||
realTeamSummary.running,
|
||||
realTeamSummary.settled,
|
||||
realTeamSummary.total,
|
||||
selectedTeamLabel,
|
||||
selectedTeamRoles?.length,
|
||||
selectedTeamSummary,
|
||||
toolInventory,
|
||||
toolInventoryError,
|
||||
toolInventoryLoading,
|
||||
@@ -2048,6 +2084,78 @@ export function HarnessStatusPanel({
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{hasSelectedTeamConfig ? (
|
||||
<Section
|
||||
sectionKey="team_config"
|
||||
title="当前 Team 配置"
|
||||
badge={
|
||||
selectedTeamRoles && selectedTeamRoles.length > 0
|
||||
? `${selectedTeamRoles.length} 个角色`
|
||||
: undefined
|
||||
}
|
||||
registerRef={registerSectionRef}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-xl border border-sky-200/80 bg-sky-50/50 p-3">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
|
||||
<Workflow className="h-4 w-4 text-sky-600" />
|
||||
<span>{selectedTeamLabel || "当前已启用 Team"}</span>
|
||||
</div>
|
||||
{selectedTeamSummary ? (
|
||||
<div className="mt-2 text-sm text-muted-foreground">
|
||||
{selectedTeamSummary}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2 text-sm text-muted-foreground">
|
||||
当前回合会优先参考所选 Team 的角色分工来决定是否委派子代理。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedTeamRoles && selectedTeamRoles.length > 0 ? (
|
||||
<div className="grid gap-2 lg:grid-cols-2">
|
||||
{selectedTeamRoles.map((role, index) => (
|
||||
<div
|
||||
key={`${role.id || role.label}-${index}`}
|
||||
className="rounded-xl border border-border bg-background p-3"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{role.label}
|
||||
</div>
|
||||
{role.profileId ? (
|
||||
<Badge variant="outline">
|
||||
画像 {role.profileId}
|
||||
</Badge>
|
||||
) : null}
|
||||
{role.roleKey ? (
|
||||
<Badge variant="outline">
|
||||
Role {role.roleKey}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-2 text-xs leading-5 text-muted-foreground">
|
||||
{role.summary}
|
||||
</div>
|
||||
{role.skillIds && role.skillIds.length > 0 ? (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{role.skillIds.map((skillId) => (
|
||||
<Badge
|
||||
key={`${role.id || role.label}-${skillId}`}
|
||||
variant="secondary"
|
||||
>
|
||||
{skillId}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Section>
|
||||
) : null}
|
||||
{harnessState.runtimeStatus ? (
|
||||
<Section
|
||||
sectionKey="runtime"
|
||||
|
||||
@@ -30,9 +30,7 @@ interface InputbarOverlayShellProps {
|
||||
onFileSelect: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
const SecondaryControlsRow = styled.div.attrs({
|
||||
"data-testid": "inputbar-secondary-controls",
|
||||
})`
|
||||
const SecondaryControlsRow = styled.div`
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
bottom: calc(100% + 8px);
|
||||
@@ -78,7 +76,7 @@ export const InputbarOverlayShell: React.FC<InputbarOverlayShellProps> = ({
|
||||
/>
|
||||
) : null}
|
||||
{taskFiles.length > 0 || overlayAccessory ? (
|
||||
<SecondaryControlsRow>
|
||||
<SecondaryControlsRow data-testid="inputbar-secondary-controls">
|
||||
<TaskFilesPanel
|
||||
files={taskFiles}
|
||||
selectedFileId={selectedFileId}
|
||||
|
||||
@@ -10,9 +10,7 @@ interface TaskFilesPanelProps {
|
||||
onFileClick?: (file: TaskFile) => void;
|
||||
}
|
||||
|
||||
const Area = styled.div.attrs({
|
||||
"data-testid": "task-files-panel-area",
|
||||
})`
|
||||
const Area = styled.div`
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
@@ -80,7 +78,7 @@ export function TaskFilesPanel({
|
||||
}
|
||||
|
||||
return (
|
||||
<Area>
|
||||
<Area data-testid="task-files-panel-area">
|
||||
<Wrapper>
|
||||
<TaskFileList
|
||||
files={files}
|
||||
|
||||
@@ -6,11 +6,7 @@ import React, {
|
||||
useState,
|
||||
} from "react";
|
||||
import { Users } from "lucide-react";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { scheduleIdleModulePreload } from "./scheduleIdleModulePreload";
|
||||
import type { TeamDefinition } from "../../../utils/teamDefinitions";
|
||||
@@ -54,51 +50,57 @@ export const TeamSelector: React.FC<TeamSelectorProps> = ({
|
||||
return `Team · ${selectedTeam.label.trim()}`;
|
||||
}, [selectedTeam?.label, triggerLabel]);
|
||||
|
||||
const selectedRoleCount = selectedTeam?.roles.length || 0;
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="team-selector-trigger"
|
||||
className={cn(
|
||||
"inline-flex h-8 items-center gap-1.5 rounded-full border px-3 text-xs font-medium shadow-none transition-colors",
|
||||
selectedTeam
|
||||
? "border-sky-300 bg-sky-50 text-sky-700 hover:border-sky-300 hover:bg-sky-50 hover:text-sky-700"
|
||||
: "border-slate-200/80 bg-white text-slate-600 hover:border-slate-300 hover:bg-white hover:text-slate-900",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Users className="h-3.5 w-3.5" />
|
||||
<span className="max-w-[180px] truncate">{resolvedLabel}</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="overflow-hidden rounded-[22px] border border-slate-200/80 bg-white p-0 shadow-xl shadow-slate-950/8 opacity-100"
|
||||
side="top"
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="team-selector-trigger"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen(true)}
|
||||
className={cn(
|
||||
"inline-flex h-8 items-center gap-1.5 rounded-full border px-3 text-xs font-medium shadow-none transition-colors",
|
||||
selectedTeam
|
||||
? "border-sky-300 bg-sky-50 text-sky-700 hover:border-sky-300 hover:bg-sky-50 hover:text-sky-700"
|
||||
: "border-slate-200/80 bg-white text-slate-600 hover:border-slate-300 hover:bg-white hover:text-slate-900",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{open ? (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="px-4 py-7 text-center text-sm text-slate-500">
|
||||
加载中...
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<TeamSelectorPanel
|
||||
activeTheme={activeTheme}
|
||||
input={input}
|
||||
selectedTeam={selectedTeam}
|
||||
onSelectTeam={(team) => {
|
||||
onSelectTeam(team);
|
||||
setOpen(false);
|
||||
}}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
</Suspense>
|
||||
<Users className="h-3.5 w-3.5" />
|
||||
<span className="max-w-[180px] truncate">{resolvedLabel}</span>
|
||||
{selectedRoleCount > 0 ? (
|
||||
<span className="rounded-full border border-current/15 bg-white/70 px-1.5 py-0.5 text-[10px] font-semibold leading-none">
|
||||
{selectedRoleCount}
|
||||
</span>
|
||||
) : null}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</button>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-6xl overflow-hidden border-slate-200/80 bg-white p-0">
|
||||
{open ? (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="px-4 py-7 text-center text-sm text-slate-500">
|
||||
加载中...
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<TeamSelectorPanel
|
||||
activeTheme={activeTheme}
|
||||
input={input}
|
||||
selectedTeam={selectedTeam}
|
||||
onSelectTeam={(team) => {
|
||||
onSelectTeam(team);
|
||||
setOpen(false);
|
||||
}}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
</Suspense>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { act, type ComponentProps } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { TeamSelectorPanel } from "./TeamSelectorPanel";
|
||||
import {
|
||||
createTeamDefinitionFromPreset,
|
||||
type TeamDefinition,
|
||||
} from "../../../utils/teamDefinitions";
|
||||
|
||||
const { mockToast } = vi.hoisted(() => ({
|
||||
mockToast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: mockToast,
|
||||
}));
|
||||
|
||||
const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = [];
|
||||
|
||||
function renderPanel(
|
||||
props?: Partial<ComponentProps<typeof TeamSelectorPanel>>,
|
||||
) {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
const defaultProps: ComponentProps<typeof TeamSelectorPanel> = {
|
||||
onSelectTeam: vi.fn(),
|
||||
};
|
||||
|
||||
act(() => {
|
||||
root.render(<TeamSelectorPanel {...defaultProps} {...props} />);
|
||||
});
|
||||
|
||||
mountedRoots.push({ root, container });
|
||||
return { container };
|
||||
}
|
||||
|
||||
async function flushEffects() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
function setInputValue(
|
||||
element: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement | null,
|
||||
value: string,
|
||||
) {
|
||||
if (!element) {
|
||||
throw new Error("未找到目标输入元素");
|
||||
}
|
||||
|
||||
act(() => {
|
||||
const prototype =
|
||||
element instanceof HTMLTextAreaElement
|
||||
? HTMLTextAreaElement.prototype
|
||||
: element instanceof HTMLSelectElement
|
||||
? HTMLSelectElement.prototype
|
||||
: HTMLInputElement.prototype;
|
||||
const descriptor = Object.getOwnPropertyDescriptor(prototype, "value");
|
||||
descriptor?.set?.call(element, value);
|
||||
element.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
element.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
describe("TeamSelectorPanel", () => {
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
}
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (mountedRoots.length > 0) {
|
||||
const mounted = mountedRoots.pop();
|
||||
if (!mounted) {
|
||||
break;
|
||||
}
|
||||
act(() => {
|
||||
mounted.root.unmount();
|
||||
});
|
||||
mounted.container.remove();
|
||||
}
|
||||
localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("应保存自定义 Team 的 profileId、roleKey 与 skillIds", async () => {
|
||||
const onSelectTeam = vi.fn();
|
||||
const selectedTeam = createTeamDefinitionFromPreset(
|
||||
"code-triage-team",
|
||||
) as TeamDefinition;
|
||||
const { container } = renderPanel({
|
||||
selectedTeam,
|
||||
onSelectTeam,
|
||||
});
|
||||
|
||||
await flushEffects();
|
||||
|
||||
const createButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.includes("新建自定义 Team"),
|
||||
);
|
||||
|
||||
expect(createButton).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
createButton?.click();
|
||||
});
|
||||
|
||||
await flushEffects();
|
||||
|
||||
setInputValue(
|
||||
container.querySelector(
|
||||
'[data-testid="team-role-profile-select-0"]',
|
||||
) as HTMLSelectElement | null,
|
||||
"research-analyst",
|
||||
);
|
||||
setInputValue(
|
||||
container.querySelector(
|
||||
'[data-testid="team-role-role-key-input-0"]',
|
||||
) as HTMLInputElement | null,
|
||||
"research-lead",
|
||||
);
|
||||
setInputValue(
|
||||
container.querySelector(
|
||||
'[data-testid="team-role-skill-ids-input-0"]',
|
||||
) as HTMLInputElement | null,
|
||||
"source-grounding, structured-writing",
|
||||
);
|
||||
|
||||
const saveButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.includes("保存 Team"),
|
||||
);
|
||||
|
||||
expect(saveButton).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
saveButton?.click();
|
||||
});
|
||||
|
||||
const savedTeam = onSelectTeam.mock.calls[0]?.[0] as TeamDefinition | undefined;
|
||||
|
||||
expect(savedTeam).toBeTruthy();
|
||||
expect(savedTeam?.source).toBe("custom");
|
||||
expect(savedTeam?.roles[0]?.profileId).toBe("research-analyst");
|
||||
expect(savedTeam?.roles[0]?.roleKey).toBe("research-lead");
|
||||
expect(savedTeam?.roles[0]?.skillIds).toEqual([
|
||||
"source-grounding",
|
||||
"structured-writing",
|
||||
]);
|
||||
expect(mockToast.success).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,12 @@ import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
BUILTIN_TEAM_PROFILE_OPTIONS,
|
||||
BUILTIN_TEAM_SKILL_OPTIONS,
|
||||
getBuiltinTeamProfileOption,
|
||||
getBuiltinTeamSkillOption,
|
||||
} from "../../../utils/teamPresets";
|
||||
import {
|
||||
buildTeamDefinitionSummary,
|
||||
cloneTeamDefinitionAsCustom,
|
||||
@@ -105,6 +111,13 @@ function matchTeamQuery(team: TeamDefinition, query: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function parseSkillIdsInput(value: string): string[] {
|
||||
return value
|
||||
.split(/[\n,,]/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function TeamCard({
|
||||
team,
|
||||
selected,
|
||||
@@ -186,6 +199,31 @@ function TeamCard({
|
||||
{role.label}
|
||||
</span>
|
||||
<span> · {role.summary}</span>
|
||||
{role.profileId || role.roleKey || role.skillIds?.length ? (
|
||||
<div className="mt-1 flex flex-wrap gap-1.5">
|
||||
{role.profileId ? (
|
||||
<span className="rounded-full border border-slate-200 bg-slate-50 px-2 py-0.5 text-[11px] text-slate-500">
|
||||
画像 ·{" "}
|
||||
{getBuiltinTeamProfileOption(role.profileId)?.label ||
|
||||
role.profileId}
|
||||
</span>
|
||||
) : null}
|
||||
{role.roleKey ? (
|
||||
<span className="rounded-full border border-slate-200 bg-slate-50 px-2 py-0.5 text-[11px] text-slate-500">
|
||||
Role · {role.roleKey}
|
||||
</span>
|
||||
) : null}
|
||||
{role.skillIds?.map((skillId) => (
|
||||
<span
|
||||
key={`${team.id}-${role.id}-${skillId}`}
|
||||
className="rounded-full border border-slate-200 bg-slate-50 px-2 py-0.5 text-[11px] text-slate-500"
|
||||
>
|
||||
技能 ·{" "}
|
||||
{getBuiltinTeamSkillOption(skillId)?.label || skillId}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -294,6 +332,36 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
|
||||
|
||||
const currentSelectionSummary = buildTeamDefinitionSummary(selectedTeam);
|
||||
|
||||
const updateDraftRole = (
|
||||
roleIndex: number,
|
||||
updater: (role: TeamRoleDefinition) => TeamRoleDefinition,
|
||||
) => {
|
||||
setDraft((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
roles: current.roles.map((item, index) =>
|
||||
index === roleIndex ? updater(item) : item,
|
||||
),
|
||||
}
|
||||
: current,
|
||||
);
|
||||
};
|
||||
|
||||
const toggleDraftRoleSkill = (roleIndex: number, skillId: string) => {
|
||||
updateDraftRole(roleIndex, (role) => {
|
||||
const currentSkillIds = role.skillIds || [];
|
||||
const nextSkillIds = currentSkillIds.includes(skillId)
|
||||
? currentSkillIds.filter((item) => item !== skillId)
|
||||
: [...currentSkillIds, skillId];
|
||||
|
||||
return {
|
||||
...role,
|
||||
skillIds: nextSkillIds,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const handleStartCreate = (base?: TeamDefinition | null) => {
|
||||
setDraft(base ? buildDraftFromTeam(cloneTeamDefinitionAsCustom(base)) : createBlankDraft(activeTheme));
|
||||
};
|
||||
@@ -639,6 +707,7 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
|
||||
id: `role-${current.roles.length + 1}`,
|
||||
label: "",
|
||||
summary: "",
|
||||
skillIds: [],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -657,6 +726,15 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
|
||||
key={`${role.id}-${index}`}
|
||||
className="rounded-2xl border border-slate-200 bg-white p-3"
|
||||
>
|
||||
{(() => {
|
||||
const selectedProfile = getBuiltinTeamProfileOption(
|
||||
role.profileId,
|
||||
);
|
||||
const resolvedSkillIds = role.skillIds || [];
|
||||
const suggestedSkillIds = selectedProfile?.skillIds || [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<div className="text-xs font-medium text-slate-500">
|
||||
角色 {index + 1}
|
||||
@@ -685,21 +763,10 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
|
||||
<Input
|
||||
value={role.label}
|
||||
onChange={(event) =>
|
||||
setDraft((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
roles: current.roles.map((item, roleIndex) =>
|
||||
roleIndex === index
|
||||
? {
|
||||
...item,
|
||||
label: event.target.value,
|
||||
}
|
||||
: item,
|
||||
),
|
||||
}
|
||||
: current,
|
||||
)
|
||||
updateDraftRole(index, (item) => ({
|
||||
...item,
|
||||
label: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="角色名称,例如:分析"
|
||||
className="border-slate-200 bg-white"
|
||||
@@ -707,26 +774,144 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
|
||||
<Textarea
|
||||
value={role.summary}
|
||||
onChange={(event) =>
|
||||
setDraft((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
roles: current.roles.map((item, roleIndex) =>
|
||||
roleIndex === index
|
||||
? {
|
||||
...item,
|
||||
summary: event.target.value,
|
||||
}
|
||||
: item,
|
||||
),
|
||||
}
|
||||
: current,
|
||||
)
|
||||
updateDraftRole(index, (item) => ({
|
||||
...item,
|
||||
summary: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="说明这个角色负责什么。"
|
||||
className="min-h-[76px] border-slate-200 bg-white"
|
||||
/>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<label className="text-xs font-medium text-slate-600">
|
||||
内置画像
|
||||
</label>
|
||||
<select
|
||||
value={role.profileId || ""}
|
||||
onChange={(event) => {
|
||||
const nextProfileId =
|
||||
event.target.value.trim() || undefined;
|
||||
const nextProfile =
|
||||
getBuiltinTeamProfileOption(nextProfileId);
|
||||
updateDraftRole(index, (item) => ({
|
||||
...item,
|
||||
profileId: nextProfileId,
|
||||
roleKey:
|
||||
item.roleKey?.trim() ||
|
||||
nextProfile?.roleKey ||
|
||||
"",
|
||||
skillIds:
|
||||
item.skillIds && item.skillIds.length > 0
|
||||
? item.skillIds
|
||||
: nextProfile?.skillIds
|
||||
? [...nextProfile.skillIds]
|
||||
: [],
|
||||
}));
|
||||
}}
|
||||
data-testid={`team-role-profile-select-${index}`}
|
||||
className="h-10 rounded-xl border border-slate-200 bg-white px-3 text-sm text-slate-700 outline-none transition focus:border-slate-300"
|
||||
>
|
||||
<option value="">不指定内置画像</option>
|
||||
{BUILTIN_TEAM_PROFILE_OPTIONS.map((option) => (
|
||||
<option key={option.id} value={option.id}>
|
||||
{option.label} · {option.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="rounded-xl border border-slate-200 bg-slate-50 px-3 py-2 text-[11px] leading-5 text-slate-500">
|
||||
{selectedProfile ? (
|
||||
<>
|
||||
<span className="font-medium text-slate-700">
|
||||
{selectedProfile.label}
|
||||
</span>
|
||||
<span> · {selectedProfile.description}</span>
|
||||
</>
|
||||
) : (
|
||||
"可选内置 subagent profile,用于对齐 Codex 风格的角色画像。"
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<label className="text-xs font-medium text-slate-600">
|
||||
roleKey
|
||||
</label>
|
||||
<Input
|
||||
value={role.roleKey || ""}
|
||||
onChange={(event) =>
|
||||
updateDraftRole(index, (item) => ({
|
||||
...item,
|
||||
roleKey: event.target.value,
|
||||
}))
|
||||
}
|
||||
data-testid={`team-role-role-key-input-${index}`}
|
||||
placeholder="例如:explorer / executor / reviewer"
|
||||
className="border-slate-200 bg-white"
|
||||
/>
|
||||
<div className="rounded-xl border border-slate-200 bg-slate-50 px-3 py-2 text-[11px] leading-5 text-slate-500">
|
||||
用于运行时和工作台标记角色职责;建议与所选画像保持一致。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<label className="text-xs font-medium text-slate-600">
|
||||
skills
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{BUILTIN_TEAM_SKILL_OPTIONS.map((option) => {
|
||||
const active = resolvedSkillIds.includes(
|
||||
option.id,
|
||||
);
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
"rounded-full border px-2.5 py-1 text-[11px] transition-colors",
|
||||
active
|
||||
? "border-sky-300 bg-sky-50 text-sky-700"
|
||||
: suggestedSkillIds.includes(option.id)
|
||||
? "border-emerald-200 bg-emerald-50 text-emerald-700"
|
||||
: "border-slate-200 bg-white text-slate-500 hover:border-slate-300 hover:text-slate-700",
|
||||
)}
|
||||
onClick={() =>
|
||||
toggleDraftRoleSkill(index, option.id)
|
||||
}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Input
|
||||
value={resolvedSkillIds.join(", ")}
|
||||
onChange={(event) =>
|
||||
updateDraftRole(index, (item) => ({
|
||||
...item,
|
||||
skillIds: parseSkillIdsInput(event.target.value),
|
||||
}))
|
||||
}
|
||||
data-testid={`team-role-skill-ids-input-${index}`}
|
||||
placeholder="多个 skill id 用逗号分隔,例如:source-grounding, structured-writing"
|
||||
className="border-slate-200 bg-white"
|
||||
/>
|
||||
<div className="rounded-xl border border-slate-200 bg-slate-50 px-3 py-2 text-[11px] leading-5 text-slate-500">
|
||||
{selectedProfile && suggestedSkillIds.length > 0 ? (
|
||||
<>
|
||||
推荐技能:
|
||||
{suggestedSkillIds.join("、")}
|
||||
</>
|
||||
) : (
|
||||
"skillIds 会透传给运行时,用于约束子代理的技能集。"
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
+2
-1
@@ -30,7 +30,8 @@ describe("useThemeWorkbenchInputState", () => {
|
||||
root.render(React.createElement(TestComponent));
|
||||
});
|
||||
|
||||
expect(state?.shouldShowA2UISubmissionNotice).toBe(false);
|
||||
expect(state).not.toBeNull();
|
||||
expect(state!.shouldShowA2UISubmissionNotice).toBe(false);
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
|
||||
@@ -91,7 +91,7 @@ function normalizeLiveActivityText(
|
||||
}
|
||||
|
||||
function appendLiveActivityDraft(previous: string | undefined, chunk: string) {
|
||||
return normalizeLiveActivityText(`${previous ?? ""}${chunk}`);
|
||||
return normalizeLiveActivityText(`${previous ?? ""}${chunk}`) ?? undefined;
|
||||
}
|
||||
|
||||
function buildActivityEntry(params: {
|
||||
|
||||
@@ -15,6 +15,108 @@ export interface TeamPresetOption {
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface BuiltinTeamSkillOption {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface BuiltinTeamProfileOption {
|
||||
id: string;
|
||||
label: string;
|
||||
roleKey: string;
|
||||
description: string;
|
||||
theme: string;
|
||||
skillIds: string[];
|
||||
}
|
||||
|
||||
export const BUILTIN_TEAM_SKILL_OPTIONS: BuiltinTeamSkillOption[] = [
|
||||
{
|
||||
id: "repo-exploration",
|
||||
label: "仓库探索",
|
||||
description: "聚焦代码阅读、边界识别与事实收敛。",
|
||||
},
|
||||
{
|
||||
id: "bounded-implementation",
|
||||
label: "边界实现",
|
||||
description: "强调只改授权范围,避免和其他子代理冲突。",
|
||||
},
|
||||
{
|
||||
id: "verification-report",
|
||||
label: "验证汇报",
|
||||
description: "突出验证步骤、回归结果、风险与缺口。",
|
||||
},
|
||||
{
|
||||
id: "source-grounding",
|
||||
label: "事实收敛",
|
||||
description: "明确区分事实、推断与待验证项。",
|
||||
},
|
||||
{
|
||||
id: "structured-writing",
|
||||
label: "结构写作",
|
||||
description: "输出面向开发者、可直接执行的结构化内容。",
|
||||
},
|
||||
];
|
||||
|
||||
export const BUILTIN_TEAM_PROFILE_OPTIONS: BuiltinTeamProfileOption[] = [
|
||||
{
|
||||
id: "code-explorer",
|
||||
label: "代码分析员",
|
||||
roleKey: "explorer",
|
||||
description: "负责阅读代码、收敛问题、定位影响面与事实证据。",
|
||||
theme: "engineering",
|
||||
skillIds: ["repo-exploration", "source-grounding"],
|
||||
},
|
||||
{
|
||||
id: "code-executor",
|
||||
label: "代码执行员",
|
||||
roleKey: "executor",
|
||||
description: "负责在清晰边界内实现改动,并回报改动与验证结果。",
|
||||
theme: "engineering",
|
||||
skillIds: ["bounded-implementation", "verification-report"],
|
||||
},
|
||||
{
|
||||
id: "code-verifier",
|
||||
label: "代码验证员",
|
||||
roleKey: "verifier",
|
||||
description: "负责复核结果、补充测试与列出风险。",
|
||||
theme: "engineering",
|
||||
skillIds: ["verification-report", "source-grounding"],
|
||||
},
|
||||
{
|
||||
id: "research-analyst",
|
||||
label: "研究分析员",
|
||||
roleKey: "researcher",
|
||||
description: "负责多源材料整理、证据归并与结论提炼。",
|
||||
theme: "research",
|
||||
skillIds: ["source-grounding", "structured-writing"],
|
||||
},
|
||||
{
|
||||
id: "doc-writer",
|
||||
label: "文档起草员",
|
||||
roleKey: "writer",
|
||||
description: "负责把分析结果转成方案、说明、PRD 或团队文档。",
|
||||
theme: "documentation",
|
||||
skillIds: ["structured-writing"],
|
||||
},
|
||||
{
|
||||
id: "content-ideator",
|
||||
label: "内容策划员",
|
||||
roleKey: "ideator",
|
||||
description: "负责生成创意方向、候选结构与选题角度。",
|
||||
theme: "content",
|
||||
skillIds: ["structured-writing"],
|
||||
},
|
||||
{
|
||||
id: "content-reviewer",
|
||||
label: "内容复核员",
|
||||
roleKey: "reviewer",
|
||||
description: "负责复核内容一致性、可读性与发布风险。",
|
||||
theme: "content",
|
||||
skillIds: ["verification-report", "structured-writing"],
|
||||
},
|
||||
];
|
||||
|
||||
export const TEAM_PRESET_OPTIONS: TeamPresetOption[] = [
|
||||
{
|
||||
id: "code-triage-team",
|
||||
@@ -129,6 +231,28 @@ export function getTeamPresetOption(
|
||||
return TEAM_PRESET_OPTIONS.find((option) => option.id === presetId.trim());
|
||||
}
|
||||
|
||||
export function getBuiltinTeamSkillOption(
|
||||
skillId?: string | null,
|
||||
): BuiltinTeamSkillOption | undefined {
|
||||
if (!skillId) {
|
||||
return undefined;
|
||||
}
|
||||
return BUILTIN_TEAM_SKILL_OPTIONS.find(
|
||||
(option) => option.id === skillId.trim(),
|
||||
);
|
||||
}
|
||||
|
||||
export function getBuiltinTeamProfileOption(
|
||||
profileId?: string | null,
|
||||
): BuiltinTeamProfileOption | undefined {
|
||||
if (!profileId) {
|
||||
return undefined;
|
||||
}
|
||||
return BUILTIN_TEAM_PROFILE_OPTIONS.find(
|
||||
(option) => option.id === profileId.trim(),
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveDefaultTeamPresetId(theme?: string | null): string {
|
||||
switch (theme?.trim().toLowerCase()) {
|
||||
case "knowledge":
|
||||
|
||||
@@ -8,6 +8,31 @@ import {
|
||||
const CUSTOM_TEAM_STORAGE_KEY = "lime.chat.custom_teams.v1";
|
||||
const TEAM_SELECTION_STORAGE_KEY_PREFIX = "lime.chat.team_selection.v1";
|
||||
|
||||
function normalizeCustomTeamList(
|
||||
teams: Array<Partial<TeamDefinition>> | TeamDefinition[],
|
||||
): TeamDefinition[] {
|
||||
const uniqueTeams = new Map<string, TeamDefinition>();
|
||||
|
||||
for (const team of teams) {
|
||||
const normalized = normalizeTeamDefinition(team);
|
||||
if (!normalized || normalized.source !== "custom") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = uniqueTeams.get(normalized.id);
|
||||
if (
|
||||
!existing ||
|
||||
(normalized.updatedAt || 0) >= (existing.updatedAt || 0)
|
||||
) {
|
||||
uniqueTeams.set(normalized.id, normalized);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(uniqueTeams.values()).sort(
|
||||
(left, right) => (right.updatedAt || 0) - (left.updatedAt || 0),
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeThemeScope(theme?: string | null): string {
|
||||
const normalized = theme?.trim().toLowerCase();
|
||||
return normalized || "general";
|
||||
@@ -24,10 +49,7 @@ export function loadCustomTeams(): TeamDefinition[] {
|
||||
return [];
|
||||
}
|
||||
const parsed = JSON.parse(raw) as Array<Partial<TeamDefinition>>;
|
||||
return parsed
|
||||
.map((team) => normalizeTeamDefinition(team))
|
||||
.filter((team): team is TeamDefinition => Boolean(team))
|
||||
.filter((team) => team.source === "custom");
|
||||
return normalizeCustomTeamList(parsed);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
@@ -35,7 +57,10 @@ export function loadCustomTeams(): TeamDefinition[] {
|
||||
|
||||
export function saveCustomTeams(teams: TeamDefinition[]): void {
|
||||
try {
|
||||
localStorage.setItem(CUSTOM_TEAM_STORAGE_KEY, JSON.stringify(teams));
|
||||
localStorage.setItem(
|
||||
CUSTOM_TEAM_STORAGE_KEY,
|
||||
JSON.stringify(normalizeCustomTeamList(teams)),
|
||||
);
|
||||
} catch {
|
||||
// ignore persistence errors
|
||||
}
|
||||
|
||||
@@ -9,5 +9,9 @@ export interface ShowNotificationRequest {
|
||||
export async function showSystemNotification(
|
||||
request: ShowNotificationRequest,
|
||||
): Promise<void> {
|
||||
await notificationService.show(request.title, request.body, "info");
|
||||
await notificationService.notify({
|
||||
title: request.title,
|
||||
body: request.body,
|
||||
type: "info",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -85,6 +85,11 @@ export async function checkAndReloadGeminiCredentials(
|
||||
export async function getQwenCredentials(): Promise<QwenCredentialStatus> {
|
||||
const credential = await safeInvoke<{
|
||||
loaded: boolean;
|
||||
has_access_token?: boolean;
|
||||
has_refresh_token?: boolean;
|
||||
is_valid?: boolean;
|
||||
expiry_info?: string | null;
|
||||
creds_path?: string | null;
|
||||
credentials_path?: string | null;
|
||||
status_message?: string | null;
|
||||
extra?: Record<string, unknown> | null;
|
||||
@@ -92,9 +97,28 @@ export async function getQwenCredentials(): Promise<QwenCredentialStatus> {
|
||||
provider: "qwen",
|
||||
});
|
||||
const extra = credential.extra ?? {};
|
||||
const parsedExpiryDate = (() => {
|
||||
if (!credential.expiry_info) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const numericExpiry = Number(credential.expiry_info);
|
||||
if (Number.isFinite(numericExpiry)) {
|
||||
return numericExpiry;
|
||||
}
|
||||
|
||||
const timestamp = Date.parse(credential.expiry_info);
|
||||
return Number.isNaN(timestamp) ? null : timestamp;
|
||||
})();
|
||||
|
||||
return {
|
||||
loaded: credential.loaded,
|
||||
has_access_token: credential.has_access_token ?? false,
|
||||
has_refresh_token: credential.has_refresh_token ?? false,
|
||||
expiry_date: parsedExpiryDate,
|
||||
is_valid: credential.is_valid ?? false,
|
||||
creds_path:
|
||||
credential.creds_path ?? credential.credentials_path ?? "",
|
||||
user_id:
|
||||
typeof extra.user_id === "string"
|
||||
? extra.user_id
|
||||
@@ -107,7 +131,7 @@ export async function getQwenCredentials(): Promise<QwenCredentialStatus> {
|
||||
: typeof extra.nickName === "string"
|
||||
? extra.nickName
|
||||
: undefined,
|
||||
token_path: credential.credentials_path ?? undefined,
|
||||
token_path: credential.creds_path ?? credential.credentials_path ?? undefined,
|
||||
status_message: credential.status_message ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -106,6 +106,10 @@ export interface QwenCredentialStatus {
|
||||
expiry_date: number | null;
|
||||
is_valid: boolean;
|
||||
creds_path: string;
|
||||
user_id?: string;
|
||||
nick_name?: string;
|
||||
token_path?: string;
|
||||
status_message?: string;
|
||||
}
|
||||
|
||||
export interface OpenAICustomStatus {
|
||||
|
||||
@@ -78,10 +78,7 @@ describe("legacy tool permission guard", () => {
|
||||
const files = collectRustFiles(root);
|
||||
|
||||
for (const filePath of files) {
|
||||
const relativePath = relative(REPO_ROOT, filePath).replaceAll(
|
||||
"\\",
|
||||
"/",
|
||||
);
|
||||
const relativePath = relative(REPO_ROOT, filePath).replace(/\\/g, "/");
|
||||
if (EXCLUDED_RUST_FILES.has(relativePath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user