fix: stabilize v0.92.0 release build

This commit is contained in:
coso
2026-03-20 23:53:44 +08:00
parent 7242707293
commit 1ae16a699f
18 changed files with 801 additions and 101 deletions
+1
View File
@@ -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>
@@ -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":
+30 -5
View File
@@ -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
}
+5 -1
View File
@@ -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",
});
}
+25 -1
View File
@@ -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,
};
}
+4
View File
@@ -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;
}