Release v0.90.0

This commit is contained in:
coso
2026-03-18 10:39:02 +08:00
parent 157e400c97
commit 8c2cf9f97b
225 changed files with 11935 additions and 7890 deletions
+134 -77
View File
@@ -46,6 +46,7 @@ import { ConnectConfirmDialog } from "./components/connect";
import { showRegistryLoadError } from "./lib/utils/connectError";
import { useDeepLink } from "./hooks/useDeepLink";
import { useRelayRegistry } from "./hooks/useRelayRegistry";
import { useGlobalTrayModelSync } from "./hooks/useGlobalTrayModelSync";
import { ComponentDebugProvider } from "./contexts/ComponentDebugContext";
import { SoundProvider } from "./contexts/SoundProvider";
import { ComponentDebugOverlay } from "./components/dev";
@@ -68,6 +69,7 @@ import {
import { toast } from "sonner";
import { recordWorkspaceRepair } from "@/lib/workspaceHealthTelemetry";
import { buildHomeAgentParams } from "@/lib/workspace/navigation";
import { hasTauriInvokeCapability } from "@/lib/tauri-runtime";
const AppContainer = styled.div`
display: flex;
@@ -115,12 +117,7 @@ const THEME_WORKSPACE_PAGES: ThemeWorkspacePage[] = [
];
function isTauriDesktopEnvironment(): boolean {
if (typeof window === "undefined") {
return false;
}
const tauri = (window as any).__TAURI__;
return !!(tauri?.core?.invoke || tauri?.invoke);
return hasTauriInvokeCapability();
}
function isWindowsNavigatorPlatform(): boolean {
@@ -150,6 +147,11 @@ function AppContent() {
projectName: string;
} | null>(null);
useGlobalTrayModelSync({
currentPage,
pageParams,
});
const resolveWorkspacePage = useCallback(
(workspaceTheme?: WorkspaceTheme): ThemeWorkspacePage => {
if (workspaceTheme) {
@@ -447,124 +449,165 @@ function AppContent() {
);
};
const renderAllPages = () => {
return (
<>
const renderCurrentPage = () => {
if (currentPage === "image-gen") {
return (
<div
style={{
flex: 1,
minHeight: 0,
display: currentPage === "image-gen" ? "flex" : "none",
display: "flex",
flexDirection: "column",
}}
>
<ImageGenPage onNavigate={handleNavigate} />
</div>
);
}
if (currentPage === "automation") {
return (
<div
style={{
flex: 1,
minHeight: 0,
display: currentPage === "automation" ? "flex" : "none",
display: "flex",
flexDirection: "column",
}}
>
<AutomationPage onNavigate={handleNavigate} />
</div>
);
}
if (currentPage === "agent") {
return (
<div
style={{
flex: 1,
minHeight: 0,
display: currentPage === "agent" ? "flex" : "none",
display: "flex",
flexDirection: "column",
}}
>
{currentPage === "agent" ? (
<AgentChatPage
key={`${(pageParams as AgentPageParams).projectId || ""}:${(pageParams as AgentPageParams).contentId || ""}:${(pageParams as AgentPageParams).theme || ""}:${(pageParams as AgentPageParams).lockTheme ? "1" : "0"}:${(pageParams as AgentPageParams).agentEntry || "claw"}:${(pageParams as AgentPageParams).immersiveHome ? "immersive" : "standard"}:${(pageParams as AgentPageParams).newChatAt ?? 0}`}
onNavigate={handleNavigate}
projectId={(pageParams as AgentPageParams).projectId}
contentId={(pageParams as AgentPageParams).contentId}
initialUserPrompt={
(pageParams as AgentPageParams).initialUserPrompt
}
initialSessionName={
(pageParams as AgentPageParams).initialSessionName
}
entryBannerMessage={
(pageParams as AgentPageParams).entryBannerMessage
}
theme={(pageParams as AgentPageParams).theme}
lockTheme={(pageParams as AgentPageParams).lockTheme}
fromResources={(pageParams as AgentPageParams).fromResources}
agentEntry={(pageParams as AgentPageParams).agentEntry}
showChatPanel={
(pageParams as AgentPageParams).agentEntry !== "new-task" &&
!(pageParams as AgentPageParams).immersiveHome
}
newChatAt={(pageParams as AgentPageParams).newChatAt}
onHasMessagesChange={setAgentHasMessages}
/>
) : null}
</div>
{renderThemeWorkspaces()}
<div
style={{
flex: 1,
minHeight: 0,
display: currentPage === "terminal" ? "flex" : "none",
flexDirection: "column",
}}
>
<TerminalWorkspace
<AgentChatPage
key={`${(pageParams as AgentPageParams).projectId || ""}:${(pageParams as AgentPageParams).contentId || ""}:${(pageParams as AgentPageParams).theme || ""}:${(pageParams as AgentPageParams).lockTheme ? "1" : "0"}:${(pageParams as AgentPageParams).agentEntry || "claw"}:${(pageParams as AgentPageParams).immersiveHome ? "immersive" : "standard"}:${(pageParams as AgentPageParams).newChatAt ?? 0}`}
onNavigate={handleNavigate}
isActive={currentPage === "terminal"}
projectId={(pageParams as AgentPageParams).projectId}
contentId={(pageParams as AgentPageParams).contentId}
initialUserPrompt={
(pageParams as AgentPageParams).initialUserPrompt
}
initialSessionName={
(pageParams as AgentPageParams).initialSessionName
}
entryBannerMessage={
(pageParams as AgentPageParams).entryBannerMessage
}
theme={(pageParams as AgentPageParams).theme}
lockTheme={(pageParams as AgentPageParams).lockTheme}
fromResources={(pageParams as AgentPageParams).fromResources}
agentEntry={(pageParams as AgentPageParams).agentEntry}
showChatPanel={
(pageParams as AgentPageParams).agentEntry !== "new-task" &&
!(pageParams as AgentPageParams).immersiveHome
}
newChatAt={(pageParams as AgentPageParams).newChatAt}
onHasMessagesChange={setAgentHasMessages}
/>
</div>
);
}
<FullscreenWrapper $isActive={currentPage === "sysinfo"}>
<SysinfoView />
</FullscreenWrapper>
<FullscreenWrapper $isActive={currentPage === "files"}>
<FileBrowserView />
</FullscreenWrapper>
<FullscreenWrapper $isActive={currentPage === "web"}>
<WebView />
</FullscreenWrapper>
if (isThemeWorkspacePage(currentPage)) {
return renderThemeWorkspaces();
}
if (currentPage === "terminal") {
return (
<div
style={{
flex: 1,
minHeight: 0,
display: currentPage === "resources" ? "flex" : "none",
display: "flex",
flexDirection: "column",
}}
>
<TerminalWorkspace onNavigate={handleNavigate} isActive />
</div>
);
}
if (currentPage === "sysinfo") {
return (
<FullscreenWrapper $isActive={true}>
<SysinfoView />
</FullscreenWrapper>
);
}
if (currentPage === "files") {
return (
<FullscreenWrapper $isActive={true}>
<FileBrowserView />
</FullscreenWrapper>
);
}
if (currentPage === "web") {
return (
<FullscreenWrapper $isActive={true}>
<WebView />
</FullscreenWrapper>
);
}
if (currentPage === "resources") {
return (
<div
style={{
flex: 1,
minHeight: 0,
display: "flex",
flexDirection: "column",
}}
>
<ResourcesPage onNavigate={handleNavigate} />
</div>
);
}
<PageWrapper $isActive={currentPage === "tools"}>
if (currentPage === "tools") {
return (
<PageWrapper $isActive={true}>
<ToolsPage onNavigate={handleNavigate} />
</PageWrapper>
);
}
<PageWrapper $isActive={currentPage === "browser-runtime"}>
<BrowserRuntimeWorkspace active={currentPage === "browser-runtime"} />
if (currentPage === "browser-runtime") {
return (
<PageWrapper $isActive={true}>
<BrowserRuntimeWorkspace active={true} />
</PageWrapper>
);
}
<PageWrapper $isActive={currentPage === "plugins"}>
if (currentPage === "plugins") {
return (
<PageWrapper $isActive={true}>
<PluginsPage onNavigate={handleNavigate} />
</PageWrapper>
);
}
if (currentPage === "style") {
return (
<div
style={{
flex: 1,
minHeight: 0,
display: currentPage === "style" ? "flex" : "none",
display: "flex",
flexDirection: "column",
}}
>
@@ -573,12 +616,16 @@ function AppContent() {
pageParams={pageParams as StylePageParams}
/>
</div>
);
}
if (currentPage === "memory") {
return (
<div
style={{
flex: 1,
minHeight: 0,
display: currentPage === "memory" ? "flex" : "none",
display: "flex",
flexDirection: "column",
}}
>
@@ -587,28 +634,36 @@ function AppContent() {
pageParams={pageParams as MemoryPageParams}
/>
</div>
);
}
if (currentPage === "openclaw") {
return (
<div
style={{
flex: 1,
minHeight: 0,
overflowY: "auto",
display: currentPage === "openclaw" ? "flex" : "none",
display: "flex",
flexDirection: "column",
}}
>
<OpenClawPage
onNavigate={handleNavigate}
pageParams={pageParams as OpenClawPageParams}
isActive={currentPage === "openclaw"}
isActive={true}
/>
</div>
);
}
if (currentPage === "settings") {
return (
<div
style={{
flex: 1,
minHeight: 0,
display: currentPage === "settings" ? "flex" : "none",
display: "flex",
flexDirection: "column",
}}
>
@@ -617,8 +672,10 @@ function AppContent() {
initialTab={(pageParams as SettingsPageParams).tab}
/>
</div>
</>
);
);
}
return null;
};
const handleOnboardingComplete = useCallback(() => {
@@ -669,7 +726,7 @@ function AppContent() {
/>
)}
<MainContent $withSidebarGap={shouldAddMainContentGap}>
{renderAllPages()}
{renderCurrentPage()}
</MainContent>
<RecentImageInsertFloating onNavigate={handleNavigate} />
+277 -110
View File
@@ -4,13 +4,15 @@
* 参考成熟产品的信息架构:用户区、搜索、主导航、助手分组、底部快捷入口
*/
import { useState, useEffect, useMemo } from "react";
import { useState, useEffect, useMemo, type ReactElement } from "react";
import styled from "styled-components";
import {
Image,
Moon,
Sun,
Search,
PanelLeftClose,
PanelLeftOpen,
PenTool,
Video,
Music,
@@ -18,7 +20,6 @@ import {
Lightbulb,
CalendarRange,
FileType,
ChevronDown,
Activity,
LucideIcon,
} from "lucide-react";
@@ -49,6 +50,12 @@ import {
DEFAULT_ENABLED_CONTENT_THEME_IDS,
resolveEnabledContentThemes,
} from "@/lib/contentCreator/themeDefaults";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
interface AppSidebarProps {
currentPage: Page;
@@ -58,25 +65,67 @@ interface AppSidebarProps {
type SidebarNavItem = SidebarNavItemDefinition;
const Container = styled.aside`
const APP_SIDEBAR_COLLAPSED_STORAGE_KEY = "lime.app-sidebar.collapsed";
const SIDEBAR_PLUGIN_IDLE_TIMEOUT_MS = 1200;
const SIDEBAR_PLUGIN_FALLBACK_DELAY_MS = 180;
function scheduleSidebarPluginLoad(task: () => void): () => void {
if (typeof window === "undefined") {
return () => undefined;
}
if (typeof window.requestIdleCallback === "function") {
const idleId = window.requestIdleCallback(() => task(), {
timeout: SIDEBAR_PLUGIN_IDLE_TIMEOUT_MS,
});
return () => {
if (typeof window.cancelIdleCallback === "function") {
window.cancelIdleCallback(idleId);
}
};
}
const timeoutId = window.setTimeout(task, SIDEBAR_PLUGIN_FALLBACK_DELAY_MS);
return () => {
window.clearTimeout(timeoutId);
};
}
const Container = styled.aside<{ $collapsed?: boolean }>`
display: flex;
flex-direction: column;
width: 248px;
min-width: 248px;
width: ${({ $collapsed }) => ($collapsed ? "72px" : "248px")};
min-width: ${({ $collapsed }) => ($collapsed ? "72px" : "248px")};
height: 100vh;
padding: 12px 10px;
padding: ${({ $collapsed }) => ($collapsed ? "12px 6px" : "12px 10px")};
background-color: hsl(var(--card));
border-right: 1px solid hsl(var(--border));
transition:
width 180ms ease,
min-width 180ms ease,
padding 180ms ease;
`;
const HeaderArea = styled.div`
const HeaderArea = styled.div<{ $collapsed?: boolean }>`
display: flex;
flex-direction: column;
gap: 10px;
gap: ${({ $collapsed }) => ($collapsed ? "8px" : "10px")};
margin-bottom: 12px;
`;
const UserButton = styled.button`
const HeaderTopRow = styled.div<{ $collapsed?: boolean }>`
display: flex;
align-items: center;
gap: 8px;
${({ $collapsed }) =>
$collapsed
? `
flex-direction: column;
`
: ""}
`;
const UserButton = styled.button<{ $collapsed?: boolean }>`
display: flex;
align-items: center;
gap: 10px;
@@ -84,9 +133,10 @@ const UserButton = styled.button`
border: none;
background: transparent;
border-radius: 10px;
padding: 8px 10px;
padding: ${({ $collapsed }) => ($collapsed ? "8px" : "8px 10px")};
cursor: pointer;
color: hsl(var(--foreground));
justify-content: ${({ $collapsed }) => ($collapsed ? "center" : "flex-start")};
&:hover {
background: hsl(var(--muted) / 0.55);
@@ -107,7 +157,7 @@ const Avatar = styled.div`
}
`;
const UserName = styled.div`
const UserName = styled.div<{ $collapsed?: boolean }>`
flex: 1;
font-size: 14px;
font-weight: 600;
@@ -115,9 +165,10 @@ const UserName = styled.div`
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: ${({ $collapsed }) => ($collapsed ? "none" : "block")};
`;
const SearchButton = styled.button`
const SearchButton = styled.button<{ $collapsed?: boolean }>`
display: flex;
align-items: center;
gap: 8px;
@@ -127,8 +178,9 @@ const SearchButton = styled.button`
border: 1px solid hsl(var(--border));
background: hsl(var(--background));
color: hsl(var(--muted-foreground));
padding: 0 10px;
padding: ${({ $collapsed }) => ($collapsed ? "0" : "0 10px")};
cursor: pointer;
justify-content: ${({ $collapsed }) => ($collapsed ? "center" : "flex-start")};
&:hover {
border-color: hsl(var(--primary) / 0.35);
@@ -137,6 +189,7 @@ const SearchButton = styled.button`
span {
font-size: 13px;
display: ${({ $collapsed }) => ($collapsed ? "none" : "inline")};
}
`;
@@ -161,36 +214,38 @@ const MenuScroll = styled.div`
}
`;
const Section = styled.div`
const Section = styled.div<{ $collapsed?: boolean }>`
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 14px;
`;
const SectionTitle = styled.div`
const SectionTitle = styled.div<{ $collapsed?: boolean }>`
padding: 0 10px;
font-size: 12px;
font-weight: 500;
color: hsl(var(--muted-foreground));
opacity: 0.9;
display: ${({ $collapsed }) => ($collapsed ? "none" : "block")};
`;
const NavButton = styled.button<{ $active?: boolean }>`
const NavButton = styled.button<{ $active?: boolean; $collapsed?: boolean }>`
display: flex;
align-items: center;
gap: 10px;
gap: ${({ $collapsed }) => ($collapsed ? "0" : "10px")};
width: 100%;
height: 38px;
border: none;
border-radius: 10px;
padding: 0 10px;
padding: ${({ $collapsed }) => ($collapsed ? "0" : "0 10px")};
background: ${({ $active }) =>
$active ? "hsl(var(--accent))" : "transparent"};
color: ${({ $active }) =>
$active ? "hsl(var(--foreground))" : "hsl(var(--muted-foreground))"};
cursor: pointer;
transition: all 0.18s ease;
justify-content: ${({ $collapsed }) => ($collapsed ? "center" : "flex-start")};
&:hover {
background: hsl(var(--accent));
@@ -205,7 +260,7 @@ const NavButton = styled.button<{ $active?: boolean }>`
}
`;
const NavLabel = styled.span`
const NavLabel = styled.span<{ $collapsed?: boolean }>`
flex: 1;
text-align: left;
font-size: 14px;
@@ -213,9 +268,10 @@ const NavLabel = styled.span`
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: ${({ $collapsed }) => ($collapsed ? "none" : "inline")};
`;
const FooterArea = styled.div`
const FooterArea = styled.div<{ $collapsed?: boolean }>`
margin-top: auto;
padding-top: 10px;
border-top: 1px solid hsl(var(--border));
@@ -224,10 +280,10 @@ const FooterArea = styled.div`
gap: 8px;
`;
const ActionRow = styled.div`
const ActionRow = styled.div<{ $collapsed?: boolean }>`
display: flex;
align-items: center;
justify-content: flex-end;
justify-content: ${({ $collapsed }) => ($collapsed ? "center" : "space-between")};
padding: 0 2px;
`;
@@ -336,6 +392,15 @@ export function AppSidebar({
currentPageParams,
onNavigate,
}: AppSidebarProps) {
const [collapsed, setCollapsed] = useState<boolean>(() => {
if (typeof window === "undefined") {
return false;
}
return (
window.localStorage.getItem(APP_SIDEBAR_COLLAPSED_STORAGE_KEY) === "true"
);
});
const [theme, setTheme] = useState<"light" | "dark">(() => {
if (typeof window !== "undefined") {
return document.documentElement.classList.contains("dark")
@@ -408,16 +473,36 @@ export function AppSidebar({
}, [enabledThemes]);
useEffect(() => {
const loadSidebarPlugins = async () => {
let cancelled = false;
const loadSidebarPlugins = async (forceRefresh = false) => {
try {
const plugins = await getPluginsForSurface("sidebar");
setSidebarPlugins(plugins);
const plugins = await getPluginsForSurface("sidebar", { forceRefresh });
if (!cancelled) {
setSidebarPlugins(plugins);
}
} catch (error) {
console.error("加载侧边栏插件失败:", error);
if (!cancelled) {
console.error("加载侧边栏插件失败:", error);
}
}
};
loadSidebarPlugins();
if (refreshTrigger > 0) {
void loadSidebarPlugins(true);
return () => {
cancelled = true;
};
}
const cancelScheduledLoad = scheduleSidebarPluginLoad(() => {
void loadSidebarPlugins();
});
return () => {
cancelled = true;
cancelScheduledLoad();
};
}, [refreshTrigger]);
useEffect(() => {
@@ -449,6 +534,17 @@ export function AppSidebar({
localStorage.setItem("theme", theme);
}, [theme]);
useEffect(() => {
if (typeof window === "undefined") {
return;
}
window.localStorage.setItem(
APP_SIDEBAR_COLLAPSED_STORAGE_KEY,
collapsed ? "true" : "false",
);
}, [collapsed]);
useEffect(() => {
if (isThemeWorkspacePage(currentPage)) {
setActiveThemeKey(currentPage);
@@ -510,93 +606,164 @@ export function AppSidebar({
onNavigate(item.page, params);
};
const maybeWrapWithTooltip = (node: ReactElement, label: string) => {
if (!collapsed) {
return node;
}
return (
<Tooltip key={node.key ?? label}>
<TooltipTrigger asChild>{node}</TooltipTrigger>
<TooltipContent side="right">{label}</TooltipContent>
</Tooltip>
);
};
return (
<Container>
<HeaderArea>
<UserButton onClick={() => onNavigate("agent", buildHomeAgentParams())}>
<Avatar>
<img src="/logo.png" alt="Lime" />
</Avatar>
<UserName>Lime</UserName>
<ChevronDown size={14} />
</UserButton>
<SearchButton
onClick={() => onNavigate("agent", buildHomeAgentParams())}
>
<Search size={14} />
<span>搜索任务</span>
</SearchButton>
</HeaderArea>
<MenuScroll>
<Section>
{filteredMainMenuItems.map((item) => (
<NavButton
key={item.id}
$active={isActive(item)}
onClick={() => handleNavigate(item)}
>
<item.icon />
<NavLabel>{item.label}</NavLabel>
</NavButton>
))}
</Section>
<Section>
<SectionTitle>创作主题</SectionTitle>
{filteredThemeMenuItems.map((item) => (
<NavButton
key={item.id}
$active={isActive(item)}
onClick={() => handleNavigate(item)}
>
<item.icon />
<NavLabel>{item.label}</NavLabel>
</NavButton>
))}
</Section>
{assistantItems.length > 0 && (
<Section>
<SectionTitle>助手</SectionTitle>
{assistantItems.map((item) => (
<NavButton
key={item.id}
$active={isActive(item)}
onClick={() => handleNavigate(item)}
<TooltipProvider>
<Container $collapsed={collapsed}>
<HeaderArea $collapsed={collapsed}>
<HeaderTopRow $collapsed={collapsed}>
{maybeWrapWithTooltip(
<UserButton
$collapsed={collapsed}
onClick={() => onNavigate("agent", buildHomeAgentParams())}
title="返回 Lime 首页"
>
<item.icon />
<NavLabel>{item.label}</NavLabel>
</NavButton>
))}
</Section>
)}
</MenuScroll>
<Avatar>
<img src="/logo.png" alt="Lime" />
</Avatar>
<UserName $collapsed={collapsed}>Lime</UserName>
</UserButton>,
"Lime 首页",
)}
<FooterArea>
<Section>
{filteredFooterMenuItems.map((item) => (
<NavButton
key={item.id}
$active={isActive(item)}
onClick={() => handleNavigate(item)}
{maybeWrapWithTooltip(
<IconActionButton
onClick={() => setCollapsed((value) => !value)}
title={collapsed ? "展开导航栏" : "折叠导航栏"}
aria-label={collapsed ? "展开导航栏" : "折叠导航栏"}
>
{collapsed ? <PanelLeftOpen /> : <PanelLeftClose />}
</IconActionButton>,
collapsed ? "展开导航栏" : "折叠导航栏",
)}
</HeaderTopRow>
{maybeWrapWithTooltip(
<SearchButton
$collapsed={collapsed}
onClick={() => onNavigate("agent", buildHomeAgentParams())}
title="搜索任务"
aria-label="搜索任务"
>
<item.icon />
<NavLabel>{item.label}</NavLabel>
</NavButton>
))}
</Section>
<Search size={14} />
<span>搜索任务</span>
</SearchButton>,
"搜索任务",
)}
</HeaderArea>
<ActionRow>
<IconActionButton
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
title={theme === "dark" ? "深色模式" : "浅色模式"}
>
{theme === "dark" ? <Moon /> : <Sun />}
</IconActionButton>
</ActionRow>
</FooterArea>
</Container>
<MenuScroll>
<Section $collapsed={collapsed}>
{filteredMainMenuItems.map((item) =>
maybeWrapWithTooltip(
<NavButton
key={item.id}
$active={isActive(item)}
$collapsed={collapsed}
onClick={() => handleNavigate(item)}
title={item.label}
aria-label={item.label}
>
<item.icon />
<NavLabel $collapsed={collapsed}>{item.label}</NavLabel>
</NavButton>,
item.label,
),
)}
</Section>
<Section $collapsed={collapsed}>
<SectionTitle $collapsed={collapsed}>创作主题</SectionTitle>
{filteredThemeMenuItems.map((item) =>
maybeWrapWithTooltip(
<NavButton
key={item.id}
$active={isActive(item)}
$collapsed={collapsed}
onClick={() => handleNavigate(item)}
title={item.label}
aria-label={item.label}
>
<item.icon />
<NavLabel $collapsed={collapsed}>{item.label}</NavLabel>
</NavButton>,
item.label,
),
)}
</Section>
{assistantItems.length > 0 && (
<Section $collapsed={collapsed}>
<SectionTitle $collapsed={collapsed}>助手</SectionTitle>
{assistantItems.map((item) =>
maybeWrapWithTooltip(
<NavButton
key={item.id}
$active={isActive(item)}
$collapsed={collapsed}
onClick={() => handleNavigate(item)}
title={item.label}
aria-label={item.label}
>
<item.icon />
<NavLabel $collapsed={collapsed}>{item.label}</NavLabel>
</NavButton>,
item.label,
),
)}
</Section>
)}
</MenuScroll>
<FooterArea $collapsed={collapsed}>
<Section $collapsed={collapsed}>
{filteredFooterMenuItems.map((item) =>
maybeWrapWithTooltip(
<NavButton
key={item.id}
$active={isActive(item)}
$collapsed={collapsed}
onClick={() => handleNavigate(item)}
title={item.label}
aria-label={item.label}
>
<item.icon />
<NavLabel $collapsed={collapsed}>{item.label}</NavLabel>
</NavButton>,
item.label,
),
)}
</Section>
<ActionRow $collapsed={collapsed}>
{!collapsed ? <div /> : null}
{maybeWrapWithTooltip(
<IconActionButton
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
title={theme === "dark" ? "深色模式" : "浅色模式"}
aria-label={
theme === "dark" ? "切换到浅色模式" : "切换到深色模式"
}
>
{theme === "dark" ? <Moon /> : <Sun />}
</IconActionButton>,
theme === "dark" ? "切换到浅色模式" : "切换到深色模式",
)}
</ActionRow>
</FooterArea>
</Container>
</TooltipProvider>
);
}
+30 -4
View File
@@ -87,7 +87,7 @@ const AmbientOrb = styled.div<{
const Stage = styled.div`
position: relative;
z-index: 1;
width: min(720px, calc(100vw - 40px));
width: min(860px, calc(100vw - 40px));
display: flex;
flex-direction: column;
align-items: center;
@@ -128,7 +128,15 @@ const Logo = styled.img`
}
`;
const Slogan = styled.p`
const CopyBlock = styled.div`
margin-top: 12px;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
`;
const Slogan = styled.h1`
margin: 22px 0 0;
max-width: 18em;
font-size: clamp(28px, 4vw, 38px);
@@ -138,12 +146,27 @@ const Slogan = styled.p`
color: hsl(var(--foreground));
text-wrap: balance;
text-shadow: 0 10px 30px rgba(15, 23, 42, 0.08);
@media (max-width: 640px) {
margin-top: 16px;
}
`;
const Subtitle = styled.p`
margin: 0;
max-width: min(33em, calc(100vw - 56px));
font-size: clamp(15px, 2vw, 18px);
line-height: 1.72;
font-weight: 500;
letter-spacing: -0.02em;
color: hsl(var(--muted-foreground));
text-wrap: balance;
`;
const ProgressTrack = styled.div`
position: relative;
overflow: hidden;
margin-top: 28px;
margin-top: 30px;
width: min(320px, 72vw);
height: 8px;
border-radius: 999px;
@@ -227,7 +250,10 @@ export function SplashScreen({
<LogoGlow />
<Logo src="/logo-splash.png" alt="Lime" />
</LogoStack>
<Slogan>青柠一下,灵感即来。</Slogan>
<CopyBlock>
<Slogan>青柠一下,灵感即来</Slogan>
<Subtitle>从一句想法,到成稿、成图、成片、成事</Subtitle>
</CopyBlock>
<ProgressTrack aria-hidden>
<ProgressBar />
</ProgressTrack>
+2 -1
View File
@@ -8,6 +8,7 @@
import { useState } from "react";
import { AlertTriangle, X } from "lucide-react";
import styled from "styled-components";
import { hasTauriRuntimeMarkers } from "@/lib/tauri-runtime";
const WarningBanner = styled.div`
position: fixed;
@@ -82,7 +83,7 @@ export function WebModeWarning() {
const [visible, setVisible] = useState(true);
// Check if running in Tauri
const isTauri = typeof window !== "undefined" && "__TAURI__" in window;
const isTauri = hasTauriRuntimeMarkers();
// Only show in web mode (not Tauri)
if (isTauri || !visible) {
+11 -11
View File
@@ -9,19 +9,19 @@ AI Agent 相关组件,包括聊天页面和技能面板。
## 文件索引
| 文件 | 说明 |
|------|------|
| `index.ts` | 模块导出入口 |
| `AgentChatPage.tsx` | Agent 聊天页面(旧版,已迁移到 chat/) |
| `AgentSkillsPanel.tsx` | Agent 技能面板 |
| 文件 | 说明 |
| ---------------------- | -------------------------------------- |
| `index.ts` | 模块导出入口 |
| `AgentChatPage.tsx` | Agent 聊天页面(旧版,已迁移到 chat/) |
| `AgentSkillsPanel.tsx` | Agent 技能面板 |
### chat/
AI Agent 聊天模块,详见 [chat/README.md](./chat/README.md)
| 文件 | 说明 |
|------|------|
| `index.tsx` | AgentChatPage 主组件 |
| `types.ts` | 类型定义 |
| `components/` | 子组件(Navbar、Sidebar、MessageList 等) |
| `hooks/` | Hooks(useAgentChat) |
| 文件 | 说明 |
| ------------- | -------------------------------------------------------- |
| `index.tsx` | AgentChatPage 主组件 |
| `types.ts` | 类型定义 |
| `components/` | 子组件(Navbar、Sidebar、MessageList 等) |
| `hooks/` | Hooks(`useAgentChatUnified -> useAsterAgentChat` 唯一主链,旧 compat Hook 已删除) |
+22 -18
View File
@@ -9,45 +9,49 @@ AI Agent 聊天页面,支持通用对话和内容创作两种模式。集成
## 文件索引
| 文件 | 说明 |
|------|------|
| `index.tsx` | AgentChatPage 主组件,集成布局过渡和工作流 |
| `types.ts` | 类型定义(Message、Provider 配置等) |
| `utils/canvasWorkbenchDiff.ts` | 画布工作台的文本 diff 计算工具 |
| 文件 | 说明 |
| ------------------------------ | ------------------------------------------ |
| `index.tsx` | AgentChatPage 主组件,集成布局过渡和工作流 |
| `types.ts` | 类型定义(Message、Provider 配置等) |
| `utils/canvasWorkbenchDiff.ts` | 画布工作台的文本 diff 计算工具 |
### components/
| 文件 | 说明 |
|------|------|
| `ChatNavbar.tsx` | 顶部导航栏(模型选择、设置等) |
| `ChatSidebar.tsx` | 侧边栏(任务列表) |
| `ChatSettings.tsx` | 设置面板 |
| `MessageList.tsx` | 消息列表组件 |
| `Inputbar.tsx` | 输入栏组件 |
| `EmptyState.tsx` | 空状态引导(主题选择、模式选择) |
| 文件 | 说明 |
| --------------------------- | ---------------------------------------------------------------------------- |
| `ChatNavbar.tsx` | 顶部导航栏(模型选择、设置等) |
| `ChatSidebar.tsx` | 侧边栏(任务列表) |
| `ChatSettings.tsx` | 设置面板 |
| `MessageList.tsx` | 消息列表组件 |
| `Inputbar.tsx` | 输入栏组件 |
| `EmptyState.tsx` | 空状态引导(主题选择、模式选择) |
| `CanvasWorkbenchLayout.tsx` | 画布响应式工作台,宽屏侧栏与窄屏底部面板均支持产物、文件树、变更、预览与下载 |
### hooks/
| 文件 | 说明 |
|------|------|
| `useAgentChat.ts` | 聊天状态管理 Hook |
| 文件 | 说明 |
| ---------------------- | ---------------------------------------------------------------- |
| `useAsterAgentChat.ts` | 现役 Aster 聊天主 Hook |
| `index.ts` | `useAgentChatUnified` 统一入口与 Hook 导出,旧 `useAgentChat` 已删除 |
## 核心功能
### 1. 通用对话
- 多轮对话上下文
- 流式响应
- Markdown 渲染
- 代码高亮
### 2. 内容创作模式
- 6 种创作主题(知识探索、计划规划、社媒内容、图文海报、办公文档、短视频)
- 4 种创作模式(引导/快速/混合/框架)
- 步骤进度条(仅内容创作主题)
- 布局过渡(对话 ↔ 对话+画布)
### 3. 画布内工作台
- 宽屏使用右侧内嵌工作台,窄屏自动切换为底部工作台,均支持 `产物 / 全部文件 / 变更 / 预览`
- 支持画布面板折叠/展开
- 支持复制路径、系统打开、定位与文本下载
@@ -61,9 +65,9 @@ AI Agent 聊天页面,支持通用对话和内容创作两种模式。集成
## 使用示例
```tsx
import { AgentChatPage } from '@/components/agent/chat'
import { AgentChatPage } from "@/components/agent/chat";
function App() {
return <AgentChatPage onNavigate={(page) => console.log(page)} />
return <AgentChatPage onNavigate={(page) => console.log(page)} />;
}
```
@@ -5,15 +5,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const {
mockInitAsterAgent,
mockSendAsterMessageStream,
mockCreateAsterSession,
mockListAsterSessions,
mockGetAsterSession,
mockRenameAsterSession,
mockDeleteAsterSession,
mockStopAsterSession,
mockConfirmAsterAction,
mockSubmitAsterElicitationResponse,
mockCreateAgentRuntimeSession,
mockListAgentRuntimeSessions,
mockGetAgentRuntimeSession,
mockUpdateAgentRuntimeSession,
mockParseStreamEvent,
mockSafeListen,
mockToast,
@@ -24,15 +19,10 @@ const {
mockEmitProviderDataChanged,
} = vi.hoisted(() => ({
mockInitAsterAgent: vi.fn(),
mockSendAsterMessageStream: vi.fn(),
mockCreateAsterSession: vi.fn(),
mockListAsterSessions: vi.fn(),
mockGetAsterSession: vi.fn(),
mockRenameAsterSession: vi.fn(),
mockDeleteAsterSession: vi.fn(),
mockStopAsterSession: vi.fn(),
mockConfirmAsterAction: vi.fn(),
mockSubmitAsterElicitationResponse: vi.fn(),
mockCreateAgentRuntimeSession: vi.fn(),
mockListAgentRuntimeSessions: vi.fn(),
mockGetAgentRuntimeSession: vi.fn(),
mockUpdateAgentRuntimeSession: vi.fn(),
mockParseStreamEvent: vi.fn((payload: unknown) => payload),
mockSafeListen: vi.fn(),
mockToast: {
@@ -50,15 +40,10 @@ const {
vi.mock("@/lib/api/agentRuntime", () => ({
initAsterAgent: mockInitAsterAgent,
sendAsterMessageStream: mockSendAsterMessageStream,
createAsterSession: mockCreateAsterSession,
listAsterSessions: mockListAsterSessions,
getAsterSession: mockGetAsterSession,
renameAsterSession: mockRenameAsterSession,
deleteAsterSession: mockDeleteAsterSession,
stopAsterSession: mockStopAsterSession,
confirmAsterAction: mockConfirmAsterAction,
submitAsterElicitationResponse: mockSubmitAsterElicitationResponse,
createAgentRuntimeSession: mockCreateAgentRuntimeSession,
listAgentRuntimeSessions: mockListAgentRuntimeSessions,
getAgentRuntimeSession: mockGetAgentRuntimeSession,
updateAgentRuntimeSession: mockUpdateAgentRuntimeSession,
}));
vi.mock("@/lib/api/agentStream", () => ({
@@ -256,37 +241,39 @@ beforeEach(() => {
sessionStorage.clear();
mockInitAsterAgent.mockResolvedValue(undefined);
mockSendAsterMessageStream.mockResolvedValue(undefined);
mockCreateAsterSession.mockResolvedValue("created-session");
mockRenameAsterSession.mockResolvedValue(undefined);
mockDeleteAsterSession.mockResolvedValue(undefined);
mockStopAsterSession.mockResolvedValue(undefined);
mockConfirmAsterAction.mockResolvedValue(undefined);
mockSubmitAsterElicitationResponse.mockResolvedValue(undefined);
mockCreateAgentRuntimeSession.mockResolvedValue("created-session");
mockUpdateAgentRuntimeSession.mockResolvedValue(undefined);
mockSafeListen.mockResolvedValue(() => {});
mockProviderPoolGetOverview.mockResolvedValue([]);
mockApiKeyProvidersGetProviders.mockResolvedValue([]);
mockEmitProviderDataChanged.mockImplementation(() => {});
const createdAt = Math.floor(Date.now() / 1000);
mockListAsterSessions.mockResolvedValue([
mockListAgentRuntimeSessions.mockResolvedValue([
{
id: "topic-a",
name: "话题 A",
created_at: createdAt,
updated_at: createdAt,
messages_count: 0,
},
{
id: "topic-b",
name: "话题 B",
created_at: createdAt,
updated_at: createdAt,
messages_count: 0,
},
]);
mockGetAsterSession.mockImplementation(async (topicId: string) => ({
mockGetAgentRuntimeSession.mockImplementation(async (topicId: string) => ({
id: topicId,
created_at: createdAt,
updated_at: createdAt,
messages: [],
execution_strategy: "react",
turns: [],
items: [],
queued_turns: [],
}));
mockUseConfiguredProviders.mockReturnValue({
@@ -118,6 +118,22 @@ export const ChatNavbar: React.FC<ChatNavbarProps> = ({
const browserAssistTitle = resolveBrowserAssistTitle(
browserAssistAttentionLevel,
);
const groupClassName = cn(
toolbarGroupClassName,
isWorkspaceCompact && "rounded-[18px] p-1",
);
const dividerClassName = cn(
toolbarDividerClassName,
isWorkspaceCompact && "mx-1 h-5",
);
const embeddedButtonClassName = cn(
toolbarEmbeddedButtonClassName,
isWorkspaceCompact && "h-8 rounded-[18px] px-3",
);
const ghostIconButtonClassName = cn(
toolbarGhostIconButtonClassName,
isWorkspaceCompact && "h-8 w-8 rounded-[18px]",
);
const showStatusTools = showBrowserAssistEntry || showHarnessToggle;
const showNavigationTools =
!isWorkspaceCompact &&
@@ -127,17 +143,20 @@ export const ChatNavbar: React.FC<ChatNavbarProps> = ({
const showWorkspaceTools = showHistoryToggle || showCanvasToggle || Boolean(novelCanvasControls);
const showProjectSelector = !isWorkspaceCompact;
const showCompactSettingsButton = isWorkspaceCompact && Boolean(onToggleSettings);
const compactProjectSelectorClassName = isWorkspaceCompact
? "min-w-[184px] max-w-[248px]"
: "min-w-[196px] max-w-[280px]";
return (
<Navbar>
<Navbar $compact={isWorkspaceCompact}>
<div className="flex items-center gap-2">
{showNavigationTools ? (
<div className={toolbarGroupClassName}>
<div className={groupClassName}>
{onBackHome && (
<Button
variant="ghost"
size="icon"
className={toolbarGhostIconButtonClassName}
className={ghostIconButtonClassName}
onClick={onBackHome}
title="返回新建任务"
aria-label="返回新建任务"
@@ -146,13 +165,13 @@ export const ChatNavbar: React.FC<ChatNavbarProps> = ({
</Button>
)}
{onBackHome && (onBackToResources || onBackToProjectManagement) ? (
<div className={toolbarDividerClassName} aria-hidden="true" />
<div className={dividerClassName} aria-hidden="true" />
) : null}
{onBackToResources && (
<Button
variant="ghost"
size="sm"
className={cn(toolbarEmbeddedButtonClassName, toolbarTextButtonClassName)}
className={cn(embeddedButtonClassName, toolbarTextButtonClassName)}
onClick={onBackToResources}
>
<FolderOpen size={16} className="mr-0.5" />
@@ -160,13 +179,13 @@ export const ChatNavbar: React.FC<ChatNavbarProps> = ({
</Button>
)}
{onBackToResources && onBackToProjectManagement ? (
<div className={toolbarDividerClassName} aria-hidden="true" />
<div className={dividerClassName} aria-hidden="true" />
) : null}
{onBackToProjectManagement && (
<Button
variant="ghost"
size="sm"
className={cn(toolbarEmbeddedButtonClassName, toolbarTextButtonClassName)}
className={cn(embeddedButtonClassName, toolbarTextButtonClassName)}
onClick={onBackToProjectManagement}
>
项目管理
@@ -176,12 +195,12 @@ export const ChatNavbar: React.FC<ChatNavbarProps> = ({
) : null}
{showWorkspaceTools ? (
<div className={toolbarGroupClassName}>
<div className={groupClassName}>
{showHistoryToggle && (
<Button
variant="ghost"
size="icon"
className={toolbarGhostIconButtonClassName}
className={ghostIconButtonClassName}
onClick={onToggleHistory}
aria-label="切换历史"
title="切换历史"
@@ -190,13 +209,13 @@ export const ChatNavbar: React.FC<ChatNavbarProps> = ({
</Button>
)}
{showHistoryToggle && (showCanvasToggle || novelCanvasControls) ? (
<div className={toolbarDividerClassName} aria-hidden="true" />
<div className={dividerClassName} aria-hidden="true" />
) : null}
{showCanvasToggle ? (
<Button
variant="ghost"
size="icon"
className={toolbarGhostIconButtonClassName}
className={ghostIconButtonClassName}
onClick={onToggleCanvas}
aria-label={isCanvasOpen ? "折叠画布" : "展开画布"}
title={isCanvasOpen ? "折叠画布" : "展开画布"}
@@ -209,14 +228,14 @@ export const ChatNavbar: React.FC<ChatNavbarProps> = ({
</Button>
) : null}
{showCanvasToggle && novelCanvasControls ? (
<div className={toolbarDividerClassName} aria-hidden="true" />
<div className={dividerClassName} aria-hidden="true" />
) : null}
{novelCanvasControls ? (
<>
<Button
variant="ghost"
size="icon"
className={toolbarGhostIconButtonClassName}
className={ghostIconButtonClassName}
onClick={novelCanvasControls.onToggleChapterList}
title={
novelCanvasControls.chapterListCollapsed
@@ -233,7 +252,7 @@ export const ChatNavbar: React.FC<ChatNavbarProps> = ({
<Button
variant="ghost"
size="icon"
className={toolbarGhostIconButtonClassName}
className={ghostIconButtonClassName}
onClick={novelCanvasControls.onAddChapter}
title="新建章节"
>
@@ -242,7 +261,7 @@ export const ChatNavbar: React.FC<ChatNavbarProps> = ({
<Button
variant="ghost"
size="icon"
className={toolbarGhostIconButtonClassName}
className={ghostIconButtonClassName}
onClick={novelCanvasControls.onCloseCanvas}
title="关闭画布"
>
@@ -258,7 +277,7 @@ export const ChatNavbar: React.FC<ChatNavbarProps> = ({
<div className="flex items-center gap-2">
{showProjectSelector ? (
<div className={toolbarGroupClassName}>
<div className={groupClassName}>
<ProjectSelector
value={projectId}
onChange={(nextProjectId) => onProjectChange?.(nextProjectId)}
@@ -269,13 +288,13 @@ export const ChatNavbar: React.FC<ChatNavbarProps> = ({
enableManagement={workspaceType === "general"}
density="compact"
chrome="embedded"
className="min-w-[196px] max-w-[280px]"
className={compactProjectSelectorClassName}
/>
<div className={toolbarDividerClassName} aria-hidden="true" />
<div className={dividerClassName} aria-hidden="true" />
<Button
variant="ghost"
size="icon"
className={toolbarGhostIconButtonClassName}
className={ghostIconButtonClassName}
onClick={onToggleSettings}
aria-label="打开设置"
title="打开设置"
@@ -286,11 +305,11 @@ export const ChatNavbar: React.FC<ChatNavbarProps> = ({
) : null}
{showCompactSettingsButton ? (
<div className={toolbarGroupClassName}>
<div className={groupClassName}>
<Button
variant="ghost"
size="icon"
className={toolbarGhostIconButtonClassName}
className={ghostIconButtonClassName}
onClick={onToggleSettings}
aria-label="打开设置"
title="打开设置"
@@ -301,14 +320,14 @@ export const ChatNavbar: React.FC<ChatNavbarProps> = ({
) : null}
{showStatusTools ? (
<div className={toolbarGroupClassName}>
<div className={groupClassName}>
{showBrowserAssistEntry ? (
<Button
type="button"
variant="ghost"
size="sm"
className={cn(
toolbarEmbeddedButtonClassName,
embeddedButtonClassName,
toolbarTextButtonClassName,
browserAssistActive && "bg-slate-100 text-slate-900",
browserAssistAttentionLevel === "warning" &&
@@ -342,7 +361,7 @@ export const ChatNavbar: React.FC<ChatNavbarProps> = ({
) : null}
{showBrowserAssistEntry && showHarnessToggle ? (
<div className={toolbarDividerClassName} aria-hidden="true" />
<div className={dividerClassName} aria-hidden="true" />
) : null}
{showHarnessToggle ? (
@@ -351,7 +370,7 @@ export const ChatNavbar: React.FC<ChatNavbarProps> = ({
variant="ghost"
size="sm"
className={cn(
toolbarEmbeddedButtonClassName,
embeddedButtonClassName,
toolbarTextButtonClassName,
harnessPanelVisible && "bg-slate-100 text-slate-900",
harnessAttentionLevel === "warning" &&
@@ -1,4 +1,5 @@
import React, { useState, useEffect, useMemo } from "react";
import styled, { keyframes } from "styled-components";
import {
Lightbulb,
ImageIcon,
@@ -10,12 +11,14 @@ import {
Globe,
Music,
ListChecks,
Settings2,
Workflow,
} from "lucide-react";
import { getConfig } from "@/lib/api/appConfig";
import type { CreationMode, EntryTaskSlotValues, EntryTaskType } from "./types";
import { CREATION_MODE_CONFIG } from "./constants";
import { Button } from "@/components/ui/button";
import { ProjectSelector } from "@/components/projects/ProjectSelector";
import { toast } from "sonner";
import {
composeEntryPrompt,
@@ -56,6 +59,77 @@ import capabilityBrowserAssistPlaceholder from "@/assets/claw-home/capability-br
const SOCIAL_ARTICLE_SKILL_KEY = "social_post_with_cover";
const backgroundOrbDrift = keyframes`
0%, 100% {
transform: translate3d(0, 0, 0) scale(1);
opacity: 0.92;
}
50% {
transform: translate3d(18px, -14px, 0) scale(1.05);
opacity: 1;
}
`;
const backgroundOrbPulse = keyframes`
0%, 100% {
transform: translate3d(0, 0, 0) scale(1);
opacity: 0.9;
}
50% {
transform: translate3d(-16px, 18px, 0) scale(1.08);
opacity: 1;
}
`;
const contentReveal = keyframes`
from {
opacity: 0;
transform: translateY(18px) scale(0.992);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
`;
const PageContainer = styled.div.attrs({
className: EMPTY_STATE_PAGE_CONTAINER_CLASSNAME,
})`
isolation: isolate;
`;
const BackgroundOrbLeft = styled.div.attrs({
className: EMPTY_STATE_BACKGROUND_ORB_LEFT_CLASSNAME,
})`
animation: ${backgroundOrbDrift} 18s ease-in-out infinite;
will-change: transform, opacity;
@media (prefers-reduced-motion: reduce) {
animation: none;
}
`;
const BackgroundOrbRight = styled.div.attrs({
className: EMPTY_STATE_BACKGROUND_ORB_RIGHT_CLASSNAME,
})`
animation: ${backgroundOrbPulse} 22s ease-in-out infinite;
will-change: transform, opacity;
@media (prefers-reduced-motion: reduce) {
animation: none;
}
`;
const ContentWrapper = styled.div.attrs({
className: EMPTY_STATE_CONTENT_WRAPPER_CLASSNAME,
})`
animation: ${contentReveal} 560ms cubic-bezier(0.22, 1, 0.36, 1) both;
@media (prefers-reduced-motion: reduce) {
animation: none;
}
`;
interface EmptyStateProps {
input: string;
setInput: (value: string) => void;
@@ -112,6 +186,12 @@ interface EmptyStateProps {
onLaunchBrowserAssist?: () => void | Promise<void>;
/** 浏览器协助启动中 */
browserAssistLoading?: boolean;
/** 当前项目 ID */
projectId?: string | null;
/** 项目切换 */
onProjectChange?: (projectId: string) => void;
/** 打开设置 */
onOpenSettings?: () => void;
}
const ENTRY_THEME_ID = "social-media";
@@ -180,12 +260,15 @@ const THEME_WORKBENCH_COPY: Record<
{
title: string;
description: string;
supportingDescription?: string;
}
> = {
general: {
title: "Claw 工作台",
title: "青柠一下,灵感即来",
description:
"围绕一个目标持续对话、检索网页、补充素材,并把结果沉淀到右侧画布,而不是只发一条一次性提问。",
"从一句想法,到成稿、成图、成片、成事。",
supportingDescription:
"Claw 工作台会围绕一个目标持续对话、检索网页、补充素材,并把结果沉淀到右侧画布,而不是只停留在一次性提问。",
},
"social-media": {
title: "社媒内容工作台",
@@ -273,6 +356,9 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
onRefreshSkills,
onLaunchBrowserAssist,
browserAssistLoading = false,
projectId = null,
onProjectChange,
onOpenSettings,
}) => {
const { activeSkill, setActiveSkill, clearActiveSkill, wrapTextWithSkill } =
useActiveSkill();
@@ -988,23 +1074,68 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
/>
);
const headerControls =
onProjectChange ? (
<div className="flex w-full justify-start sm:w-auto sm:justify-end">
<div className="inline-flex max-w-full items-center rounded-[24px] border border-white/85 bg-white/84 p-1 shadow-sm shadow-slate-950/5 backdrop-blur-sm">
<ProjectSelector
value={projectId ?? null}
onChange={onProjectChange}
workspaceType={activeTheme}
placeholder="选择项目"
dropdownSide="bottom"
dropdownAlign="end"
enableManagement={activeTheme === "general"}
density="compact"
chrome="embedded"
className="min-w-[180px] max-w-[260px]"
/>
{onOpenSettings ? (
<>
<div
className="mx-1 h-6 w-px shrink-0 bg-slate-200/80"
aria-hidden="true"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 rounded-[18px] text-slate-500 hover:bg-slate-100 hover:text-slate-900"
onClick={onOpenSettings}
aria-label="打开设置"
title="打开设置"
>
<Settings2 size={18} />
</Button>
</>
) : null}
</div>
</div>
) : null;
return (
<div className={EMPTY_STATE_PAGE_CONTAINER_CLASSNAME}>
<div className={EMPTY_STATE_BACKGROUND_ORB_LEFT_CLASSNAME} />
<div className={EMPTY_STATE_BACKGROUND_ORB_RIGHT_CLASSNAME} />
<div className={EMPTY_STATE_CONTENT_WRAPPER_CLASSNAME}>
<PageContainer>
<BackgroundOrbLeft />
<BackgroundOrbRight />
<ContentWrapper>
<EmptyStateHero
eyebrow="CLAW WORKSPACE"
eyebrow={
activeTheme === "general"
? "CLAW WORKSPACE · Claw 工作台"
: "CLAW WORKSPACE"
}
title={workbenchCopy.title}
description={workbenchCopy.description}
supportingDescription={workbenchCopy.supportingDescription}
badges={workspaceBadges}
cards={workspaceCards}
features={workspaceFeatures}
prioritySlot={composerPanel}
supportingSlot={quickActionsPanel}
themeTabs={themeTabs}
headerControls={headerControls}
/>
</div>
</div>
</ContentWrapper>
</PageContainer>
);
};
@@ -1,5 +1,5 @@
import React, { useRef } from "react";
import styled from "styled-components";
import styled, { keyframes } from "styled-components";
import {
ArrowRight,
BrainCircuit,
@@ -56,6 +56,47 @@ import {
getEmptyStateIconToolButtonClassName,
} from "./emptyStateSurfaceTokens";
const composerReveal = keyframes`
from {
opacity: 0;
transform: translateY(16px);
}
to {
opacity: 1;
transform: translateY(0);
}
`;
const composerAura = keyframes`
0%, 100% {
transform: translate3d(0, 0, 0) scale(1);
opacity: 0.78;
}
50% {
transform: translate3d(22px, -16px, 0) scale(1.08);
opacity: 1;
}
`;
const composerSheen = keyframes`
0% {
transform: translateX(-150%);
}
15%,
100% {
transform: translateX(170%);
}
`;
const buttonGlow = keyframes`
0%, 100% {
box-shadow: 0 14px 28px -18px rgba(15, 23, 42, 0.28);
}
50% {
box-shadow: 0 18px 34px -18px rgba(15, 23, 42, 0.36);
}
`;
const InputCard = styled.div`
width: 100%;
position: relative;
@@ -72,6 +113,51 @@ const InputCard = styled.div`
overflow: visible;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
backdrop-filter: blur(14px);
animation: ${composerReveal} 600ms cubic-bezier(0.22, 1, 0.36, 1) both;
&::before {
content: "";
position: absolute;
left: -8%;
top: -18%;
width: 240px;
height: 240px;
border-radius: 999px;
background: radial-gradient(
circle,
rgba(16, 185, 129, 0.1) 0%,
rgba(16, 185, 129, 0.04) 42%,
transparent 72%
);
filter: blur(26px);
animation: ${composerAura} 14s ease-in-out infinite;
pointer-events: none;
}
&::after {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: -32%;
width: 26%;
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0.34) 48%,
rgba(255, 255, 255, 0) 100%
);
opacity: 0.55;
transform: translateX(-150%);
animation: ${composerSheen} 10s ease-in-out infinite;
pointer-events: none;
mix-blend-mode: screen;
}
> * {
position: relative;
z-index: 1;
}
&:hover {
box-shadow:
@@ -86,6 +172,15 @@ const InputCard = styled.div`
0 0 0 3px rgba(226, 232, 240, 0.78),
0 20px 36px -24px rgba(15, 23, 42, 0.12);
}
@media (prefers-reduced-motion: reduce) {
animation: none;
&::before,
&::after {
animation: none;
}
}
`;
const StyledTextarea = styled(Textarea)`
@@ -152,6 +247,58 @@ const ToolbarRight = styled.div`
}
`;
const LaunchButton = styled(Button).attrs({
className: EMPTY_STATE_PRIMARY_ACTION_BUTTON_CLASSNAME,
})`
position: relative;
overflow: hidden;
background: linear-gradient(
135deg,
rgba(15, 23, 42, 0.96) 0%,
rgba(71, 85, 105, 0.98) 100%
);
box-shadow: 0 14px 28px -18px rgba(15, 23, 42, 0.28);
animation: ${buttonGlow} 3.2s ease-in-out infinite;
transition:
transform 180ms ease,
box-shadow 180ms ease,
filter 180ms ease;
&::before {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(
110deg,
rgba(255, 255, 255, 0) 24%,
rgba(255, 255, 255, 0.18) 48%,
rgba(255, 255, 255, 0) 72%
);
transform: translateX(-130%);
animation: ${composerSheen} 5.8s ease-in-out infinite;
pointer-events: none;
}
&:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 18px 34px -18px rgba(15, 23, 42, 0.36);
filter: brightness(1.02);
}
&:disabled {
opacity: 0.62;
animation: none;
}
@media (prefers-reduced-motion: reduce) {
animation: none;
&::before {
animation: none;
}
}
`;
const ColorDot = styled.div<{ $color: string }>`
width: 16px;
height: 16px;
@@ -845,17 +992,16 @@ export function EmptyStateComposerPanel({
</ToolLoginLeft>
<ToolbarRight>
<Button
<LaunchButton
size="sm"
onClick={onSend}
disabled={
!input.trim() && !isEntryTheme && pendingImagesCount === 0
}
className={EMPTY_STATE_PRIMARY_ACTION_BUTTON_CLASSNAME}
>
开始生成
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</LaunchButton>
</ToolbarRight>
</Toolbar>
</InputCard>
@@ -1,4 +1,5 @@
import type { ReactNode } from "react";
import styled, { keyframes } from "styled-components";
import {
EMPTY_STATE_BADGE_BASE_CLASSNAME,
EMPTY_STATE_BADGE_TONE_CLASSNAMES,
@@ -7,6 +8,281 @@ import {
EMPTY_STATE_META_PILL_CLASSNAME,
} from "./emptyStateSurfaceTokens";
const heroReveal = keyframes`
from {
opacity: 0;
transform: translateY(20px) scale(0.994);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
`;
const orbFloat = keyframes`
0%, 100% {
transform: translate3d(0, 0, 0) scale(1);
}
50% {
transform: translate3d(16px, -12px, 0) scale(1.06);
}
`;
const cardReveal = keyframes`
from {
opacity: 0;
transform: translateY(18px);
}
to {
opacity: 1;
transform: translateY(0);
}
`;
const HeroSection = styled.section`
position: relative;
overflow: visible;
animation: ${heroReveal} 620ms cubic-bezier(0.22, 1, 0.36, 1) both;
@media (prefers-reduced-motion: reduce) {
animation: none;
}
`;
const HeroOrbLeft = styled.div`
pointer-events: none;
position: absolute;
left: -6rem;
top: -5.4rem;
height: 14rem;
width: 14rem;
border-radius: 999px;
background: rgba(167, 243, 208, 0.24);
filter: blur(48px);
animation: ${orbFloat} 16s ease-in-out infinite;
@media (prefers-reduced-motion: reduce) {
animation: none;
}
`;
const HeroOrbRight = styled.div`
pointer-events: none;
position: absolute;
right: -4rem;
top: -1.2rem;
height: 12rem;
width: 12rem;
border-radius: 999px;
background: rgba(186, 230, 253, 0.26);
filter: blur(42px);
animation: ${orbFloat} 19s ease-in-out infinite reverse;
@media (prefers-reduced-motion: reduce) {
animation: none;
}
`;
const HeroOrbBottom = styled.div`
pointer-events: none;
position: absolute;
bottom: -5.4rem;
left: 33%;
height: 11rem;
width: 11rem;
border-radius: 999px;
background: rgba(253, 230, 138, 0.16);
filter: blur(44px);
animation: ${orbFloat} 17s ease-in-out infinite;
@media (prefers-reduced-motion: reduce) {
animation: none;
}
`;
const HeroContent = styled.div`
position: relative;
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0.875rem;
@media (min-width: 1024px) {
gap: 0.875rem;
padding: 1rem;
}
@media (max-height: 940px) {
gap: 0.625rem;
}
`;
const LeadBlock = styled.div`
min-width: 0;
animation: ${cardReveal} 520ms cubic-bezier(0.22, 1, 0.36, 1) both;
@media (prefers-reduced-motion: reduce) {
animation: none;
}
`;
const LeadTopRow = styled.div`
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
`;
const IntroGrid = styled.div`
display: grid;
gap: 0.75rem;
align-items: start;
@media (min-width: 1240px) {
grid-template-columns: minmax(0, 1.08fr) minmax(380px, 0.92fr);
gap: 0.875rem;
}
`;
const LeadTextGroup = styled.div`
display: flex;
width: 100%;
flex-direction: column;
gap: 0.625rem;
`;
const LeadSupportingText = styled.p`
margin: 0;
max-width: 48rem;
font-size: 12px;
line-height: 1.65;
color: rgb(100 116 139);
@media (min-width: 768px) {
font-size: 13px;
}
`;
const PriorityShell = styled.div<{ $delay: number }>`
animation: ${cardReveal} 560ms cubic-bezier(0.22, 1, 0.36, 1) both;
animation-delay: ${({ $delay }) => `${$delay}ms`};
@media (prefers-reduced-motion: reduce) {
animation: none;
}
`;
const SupportingShell = styled.div<{ $delay: number }>`
animation: ${cardReveal} 560ms cubic-bezier(0.22, 1, 0.36, 1) both;
animation-delay: ${({ $delay }) => `${$delay}ms`};
@media (prefers-reduced-motion: reduce) {
animation: none;
}
`;
const CardsShell = styled.div`
display: grid;
gap: 0.75rem;
grid-template-columns: repeat(1, minmax(0, 1fr));
@media (min-width: 768px) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@media (max-height: 940px) {
gap: 0.625rem;
}
`;
const HeroCard = styled.article.attrs({
className: EMPTY_STATE_CARD_SURFACE_CLASSNAME,
})<{ $index: number }>`
position: relative;
transition:
transform 220ms ease,
box-shadow 220ms ease,
border-color 220ms ease;
animation: ${cardReveal} 520ms cubic-bezier(0.22, 1, 0.36, 1) both;
animation-delay: ${({ $index }) => `${160 + $index * 70}ms`};
&::after {
content: "";
position: absolute;
left: 1rem;
right: 1rem;
bottom: 0.9rem;
height: 1px;
background: linear-gradient(
90deg,
rgba(16, 185, 129, 0) 0%,
rgba(16, 185, 129, 0.28) 32%,
rgba(56, 189, 248, 0.22) 70%,
rgba(56, 189, 248, 0) 100%
);
opacity: 0.7;
}
&:hover {
transform: translateY(-4px);
border-color: rgba(203, 213, 225, 0.96);
box-shadow: 0 18px 34px -28px rgba(15, 23, 42, 0.18);
}
@media (prefers-reduced-motion: reduce) {
animation: none;
transition: none;
}
@media (max-width: 1180px), (max-height: 940px) {
padding: 0.875rem;
.card-icon {
height: 2rem;
width: 2rem;
border-radius: 1rem;
}
.card-content {
margin-top: 0.625rem;
}
.card-title {
font-size: 0.95rem;
line-height: 1.3;
}
.card-value {
margin-top: 0.125rem;
}
.card-description {
-webkit-line-clamp: 2;
font-size: 11px;
line-height: 1.45;
}
.card-preview {
margin-top: 0.75rem;
}
.card-preview img {
height: 68px;
}
}
`;
const FeaturePanel = styled.div`
order: 5;
animation: ${cardReveal} 620ms cubic-bezier(0.22, 1, 0.36, 1) both;
animation-delay: 260ms;
@media (prefers-reduced-motion: reduce) {
animation: none;
}
`;
export interface EmptyStateHeroBadge {
key: string;
label: string;
@@ -36,122 +312,137 @@ interface EmptyStateHeroProps {
eyebrow: string;
title: string;
description: string;
supportingDescription?: string;
badges: EmptyStateHeroBadge[];
cards: EmptyStateHeroCard[];
features?: EmptyStateHeroFeature[];
prioritySlot?: ReactNode;
supportingSlot?: ReactNode;
themeTabs?: ReactNode;
headerControls?: ReactNode;
}
export function EmptyStateHero({
eyebrow,
title,
description,
supportingDescription,
badges,
cards,
features = [],
prioritySlot,
supportingSlot,
themeTabs,
headerControls,
}: EmptyStateHeroProps) {
return (
<section className="relative overflow-hidden rounded-[32px] border border-emerald-200/70 bg-[linear-gradient(135deg,rgba(244,251,248,0.98)_0%,rgba(248,250,252,0.985)_46%,rgba(241,247,255,0.96)_100%)] shadow-sm shadow-slate-950/5">
<div className="pointer-events-none absolute inset-x-0 top-0 h-px bg-white/90" />
<div className="pointer-events-none absolute -left-24 top-[-86px] h-56 w-56 rounded-full bg-emerald-200/24 blur-3xl" />
<div className="pointer-events-none absolute right-[-64px] top-[-18px] h-48 w-48 rounded-full bg-sky-200/26 blur-3xl" />
<div className="pointer-events-none absolute bottom-[-86px] left-1/3 h-44 w-44 rounded-full bg-amber-200/16 blur-3xl" />
<div className="relative space-y-3.5 p-4 lg:space-y-4 lg:p-5">
<div className="mx-auto flex max-w-[48rem] flex-col items-center gap-2.5 text-center">
<div className="inline-flex items-center rounded-full border border-emerald-200/80 bg-white/88 px-3 py-1 text-[10px] font-semibold tracking-[0.14em] text-emerald-700 shadow-sm shadow-slate-950/5">
{eyebrow}
</div>
<HeroSection>
<HeroOrbLeft />
<HeroOrbRight />
<HeroOrbBottom />
<HeroContent>
<IntroGrid>
<LeadBlock className="flex w-full min-w-0 flex-col gap-3 rounded-[28px] border border-white/80 bg-white/56 px-4 py-4 text-left shadow-sm shadow-slate-950/5 backdrop-blur-sm md:px-5 md:py-[18px]">
<LeadTopRow>
<div className="inline-flex w-fit items-center rounded-full border border-emerald-200/80 bg-white/92 px-3 py-1 text-[10px] font-semibold tracking-[0.14em] text-emerald-700 shadow-sm shadow-slate-950/5">
{eyebrow}
</div>
{headerControls}
</LeadTopRow>
<div className="space-y-2">
<h1 className="max-w-[19ch] text-[28px] font-semibold tracking-tight text-slate-900 md:text-[32px]">
{title}
</h1>
<p className="max-w-[44rem] text-[13px] leading-7 text-slate-600 md:text-sm">
{description}
</p>
</div>
<LeadTextGroup>
<h1 className="max-w-[14ch] text-[28px] font-semibold leading-[1.05] tracking-tight text-slate-900 md:text-[38px]">
{title}
</h1>
<p className="max-w-[40rem] text-[14px] font-medium leading-7 text-slate-700 md:text-[15px]">
{description}
</p>
{supportingDescription ? (
<LeadSupportingText>{supportingDescription}</LeadSupportingText>
) : null}
</LeadTextGroup>
{badges.length > 0 ? (
<div className="flex max-w-[46rem] flex-wrap justify-center gap-2">
{badges.map((badge) => (
<span
key={badge.key}
className={`${EMPTY_STATE_BADGE_BASE_CLASSNAME} ${
EMPTY_STATE_BADGE_TONE_CLASSNAMES[badge.tone || "slate"]
}`}
>
{badge.label}
</span>
))}
</div>
) : null}
{badges.length > 0 ? (
<div className="flex max-w-[44rem] flex-wrap gap-2">
{badges.map((badge) => (
<span
key={badge.key}
className={`${EMPTY_STATE_BADGE_BASE_CLASSNAME} ${
EMPTY_STATE_BADGE_TONE_CLASSNAMES[badge.tone || "slate"]
}`}
>
{badge.label}
</span>
))}
</div>
) : null}
{themeTabs ? (
<div className="flex w-full justify-center">{themeTabs}</div>
) : null}
</div>
{themeTabs ? <div className="flex w-full">{themeTabs}</div> : null}
</LeadBlock>
<CardsShell>
{cards.map((card, index) => (
<HeroCard key={card.key} $index={index}>
<div className="flex items-start justify-between gap-3">
<div
className={`card-icon flex h-9 w-9 items-center justify-center rounded-2xl border ${
EMPTY_STATE_ICON_TONE_CLASSNAMES[card.tone || "slate"]
}`}
>
{card.icon}
</div>
<span className={EMPTY_STATE_META_PILL_CLASSNAME}>
{card.eyebrow}
</span>
</div>
<div className="card-content mt-2.5 space-y-1">
<div className="card-title text-sm font-semibold text-slate-900">
{card.title}
</div>
<div className="card-value line-clamp-1 text-[11px] font-medium text-slate-500">
{card.value}
</div>
<p className="card-description line-clamp-3 text-[12px] leading-5 text-slate-500">
{card.description}
</p>
</div>
{card.imageSrc ? (
<div className="card-preview mt-3 overflow-hidden rounded-[18px] border border-slate-200/70 bg-slate-50">
<img
src={card.imageSrc}
alt={card.imageAlt || card.title}
className="h-[74px] w-full object-cover md:h-[82px] xl:h-[74px] 2xl:h-[86px]"
/>
</div>
) : null}
{card.action ? (
<div className="mt-2.5">{card.action}</div>
) : null}
</HeroCard>
))}
</CardsShell>
</IntroGrid>
{prioritySlot ? (
<div className="mx-auto w-full max-w-[1020px]">{prioritySlot}</div>
<PriorityShell $delay={120} className="mx-auto w-full max-w-[1120px]">
{prioritySlot}
</PriorityShell>
) : null}
{supportingSlot ? (
<div className="mx-auto w-full max-w-[1020px]">{supportingSlot}</div>
<SupportingShell
$delay={180}
className="mx-auto w-full max-w-[1120px]"
>
{supportingSlot}
</SupportingShell>
) : null}
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
{cards.map((card) => (
<article
key={card.key}
className={EMPTY_STATE_CARD_SURFACE_CLASSNAME}
>
<div className="flex items-start justify-between gap-3">
<div
className={`flex h-9 w-9 items-center justify-center rounded-2xl border ${
EMPTY_STATE_ICON_TONE_CLASSNAMES[card.tone || "slate"]
}`}
>
{card.icon}
</div>
<span className={EMPTY_STATE_META_PILL_CLASSNAME}>
{card.eyebrow}
</span>
</div>
<div className="mt-2.5 space-y-1">
<div className="text-sm font-semibold text-slate-900">
{card.title}
</div>
<div className="line-clamp-1 text-[11px] font-medium text-slate-500">
{card.value}
</div>
<p className="line-clamp-3 text-[12px] leading-5 text-slate-500">
{card.description}
</p>
</div>
{card.imageSrc ? (
<div className="mt-3 overflow-hidden rounded-[18px] border border-slate-200/70 bg-slate-50">
<img
src={card.imageSrc}
alt={card.imageAlt || card.title}
className="h-[78px] w-full object-cover md:h-[88px] xl:h-[78px] 2xl:h-[92px]"
/>
</div>
) : null}
{card.action ? <div className="mt-2.5">{card.action}</div> : null}
</article>
))}
</div>
{features.length > 0 ? (
<div className="hidden rounded-[22px] border border-white/85 bg-white/72 px-4 py-3.5 shadow-sm shadow-slate-950/5 backdrop-blur-sm md:block">
<FeaturePanel className="hidden rounded-[22px] border border-white/85 bg-white/72 px-4 py-3.5 shadow-sm shadow-slate-950/5 backdrop-blur-sm md:block">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{features.map((feature) => (
<div
@@ -168,10 +459,10 @@ export function EmptyStateHero({
</div>
))}
</div>
</div>
</FeaturePanel>
) : null}
</div>
</section>
</HeroContent>
</HeroSection>
);
}
@@ -1,3 +1,4 @@
import styled, { keyframes } from "styled-components";
import {
EMPTY_STATE_META_PILL_CLASSNAME,
EMPTY_STATE_PANEL_CLASSNAME,
@@ -6,6 +7,96 @@ import {
EMPTY_STATE_RECOMMENDATION_CARD_CLASSNAME,
} from "./emptyStateSurfaceTokens";
const sectionReveal = keyframes`
from {
opacity: 0;
transform: translateY(16px);
}
to {
opacity: 1;
transform: translateY(0);
}
`;
const itemReveal = keyframes`
from {
opacity: 0;
transform: translateY(14px);
}
to {
opacity: 1;
transform: translateY(0);
}
`;
const QuickActionsPanel = styled.section<{ $embedded: boolean }>`
position: relative;
overflow: hidden;
animation: ${sectionReveal} 580ms cubic-bezier(0.22, 1, 0.36, 1) both;
animation-delay: 200ms;
&::after {
content: "";
position: absolute;
inset: 0 auto auto 1.1rem;
width: 8rem;
height: 1px;
background: linear-gradient(
90deg,
rgba(16, 185, 129, 0.55) 0%,
rgba(56, 189, 248, 0.24) 100%
);
opacity: 0.55;
}
@media (prefers-reduced-motion: reduce) {
animation: none;
}
`;
const PresetButton = styled.button.attrs({
className: EMPTY_STATE_PRESET_BUTTON_CLASSNAME,
})<{ $index: number }>`
animation: ${itemReveal} 460ms cubic-bezier(0.22, 1, 0.36, 1) both;
animation-delay: ${({ $index }) => `${160 + $index * 40}ms`};
transition:
transform 180ms ease,
box-shadow 180ms ease,
border-color 180ms ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 14px 24px -20px rgba(15, 23, 42, 0.18);
}
@media (prefers-reduced-motion: reduce) {
animation: none;
transition: none;
}
`;
const RecommendationCard = styled.button.attrs({
className: EMPTY_STATE_RECOMMENDATION_CARD_CLASSNAME,
})<{ $index: number }>`
animation: ${itemReveal} 520ms cubic-bezier(0.22, 1, 0.36, 1) both;
animation-delay: ${({ $index }) => `${220 + $index * 70}ms`};
transition:
transform 200ms ease,
box-shadow 200ms ease,
border-color 200ms ease;
&:hover {
transform: translateY(-3px);
border-color: rgba(203, 213, 225, 0.98);
box-shadow: 0 16px 28px -22px rgba(15, 23, 42, 0.18);
}
@media (prefers-reduced-motion: reduce) {
animation: none;
transition: none;
}
`;
export interface EmptyStateQuickActionItem {
key: string;
title: string;
@@ -47,8 +138,11 @@ export function EmptyStateQuickActions({
}
return (
<section
className={embedded ? EMPTY_STATE_PANEL_EMBEDDED_CLASSNAME : EMPTY_STATE_PANEL_CLASSNAME}
<QuickActionsPanel
$embedded={embedded}
className={
embedded ? EMPTY_STATE_PANEL_EMBEDDED_CLASSNAME : EMPTY_STATE_PANEL_CLASSNAME
}
>
<div className="flex flex-col gap-1 md:flex-row md:items-end md:justify-between">
<div>
@@ -61,12 +155,12 @@ export function EmptyStateQuickActions({
{presets.length > 0 ? (
<div className="mt-2.5 flex flex-wrap gap-2">
{presets.map((preset) => (
<button
{presets.map((preset, index) => (
<PresetButton
key={preset.key}
$index={index}
type="button"
onClick={() => onPresetAction?.(preset)}
className={EMPTY_STATE_PRESET_BUTTON_CLASSNAME}
>
{preset.icon ? (
<span aria-hidden="true" className="text-base leading-none">
@@ -74,7 +168,7 @@ export function EmptyStateQuickActions({
</span>
) : null}
<span>{preset.label}</span>
</button>
</PresetButton>
))}
</div>
) : null}
@@ -89,12 +183,12 @@ export function EmptyStateQuickActions({
) : null}
<div className="mt-2.5 grid gap-2 md:grid-cols-2">
{items.map((item) => (
<button
{items.map((item, index) => (
<RecommendationCard
key={item.key}
$index={index}
type="button"
onClick={() => onAction(item)}
className={EMPTY_STATE_RECOMMENDATION_CARD_CLASSNAME}
>
<div className="flex w-full items-start justify-between gap-3">
<span className={EMPTY_STATE_META_PILL_CLASSNAME}>
@@ -112,10 +206,10 @@ export function EmptyStateQuickActions({
{item.description}
</p>
</div>
</button>
</RecommendationCard>
))}
</div>
</section>
</QuickActionsPanel>
);
}
@@ -44,7 +44,7 @@ export const EMPTY_STATE_RECOMMENDATION_CARD_CLASSNAME =
"group flex min-w-0 flex-col items-start gap-2 rounded-[20px] border border-slate-200/80 bg-[linear-gradient(180deg,rgba(255,255,255,0.97)_0%,rgba(248,250,252,0.9)_100%)] px-3.5 py-3.5 text-left shadow-sm shadow-slate-950/5 transition-colors hover:border-slate-300 hover:bg-white";
export const EMPTY_STATE_PAGE_CONTAINER_CLASSNAME =
"relative flex flex-1 flex-col items-stretch justify-start overflow-y-auto bg-[linear-gradient(135deg,rgba(244,250,255,0.94)_0%,rgba(248,250,252,0.98)_44%,rgba(244,250,247,0.96)_100%)] px-4 pb-6 pt-[clamp(10px,1.4vw,16px)]";
"relative flex flex-1 flex-col items-stretch justify-start overflow-y-auto bg-[linear-gradient(135deg,rgba(244,250,255,0.94)_0%,rgba(248,250,252,0.98)_44%,rgba(244,250,247,0.96)_100%)] px-3 pb-5 pt-[clamp(8px,1.1vw,12px)] md:px-4";
export const EMPTY_STATE_BACKGROUND_ORB_LEFT_CLASSNAME =
"pointer-events-none absolute left-[-4%] top-[-14%] h-[640px] w-[640px] rounded-full bg-[radial-gradient(circle,rgba(16,185,129,0.09)_0%,transparent_70%)]";
@@ -53,10 +53,10 @@ export const EMPTY_STATE_BACKGROUND_ORB_RIGHT_CLASSNAME =
"pointer-events-none absolute right-[-10%] top-[10%] h-[520px] w-[520px] rounded-full bg-[radial-gradient(circle,rgba(56,189,248,0.1)_0%,transparent_72%)]";
export const EMPTY_STATE_CONTENT_WRAPPER_CLASSNAME =
"relative z-[1] mx-auto flex w-full max-w-[1160px] flex-col items-stretch gap-3 pt-1";
"relative z-[1] mx-auto flex w-full max-w-[1240px] flex-col items-stretch gap-2.5";
export const EMPTY_STATE_THEME_TABS_CONTAINER_CLASSNAME =
"flex w-full max-w-[780px] flex-nowrap justify-start gap-1.5 overflow-x-auto overflow-y-hidden rounded-[20px] border border-slate-200/80 bg-slate-50/80 p-[5px] shadow-[0_10px_24px_-22px_rgba(15,23,42,0.18)] backdrop-blur-sm [scrollbar-width:none] md:justify-center [&::-webkit-scrollbar]:hidden";
"flex w-full max-w-full flex-nowrap justify-start gap-1.5 overflow-x-auto overflow-y-hidden rounded-[20px] border border-slate-200/80 bg-slate-50/80 p-[5px] shadow-[0_10px_24px_-22px_rgba(15,23,42,0.18)] backdrop-blur-sm [scrollbar-width:none] [&::-webkit-scrollbar]:hidden";
export const EMPTY_STATE_SELECT_TRIGGER_CLASSNAME =
"h-8 rounded-full border-slate-200/80 bg-white/92 px-3 text-xs text-slate-700 shadow-none transition-colors hover:border-slate-300 hover:bg-white focus:ring-1 focus:ring-slate-200";
@@ -26,6 +26,7 @@ export interface AgentRuntimeTurnRequest {
sessionId: string;
eventName: string;
workspaceId: string;
turnId?: string;
images?: ImageInput[];
providerConfig?: AsterProviderConfig;
executionStrategy?: AsterExecutionStrategy;
@@ -107,6 +108,7 @@ export const defaultAgentRuntimeAdapter: AgentRuntimeAdapter = {
session_id: request.sessionId,
event_name: request.eventName,
workspace_id: request.workspaceId,
turn_id: request.turnId,
images: request.images,
turn_config: {
provider_config: request.providerConfig,
+1 -2
View File
@@ -30,8 +30,7 @@ export function useAgentChatUnified(options: UseAgentChatUnifiedOptions) {
return useAsterAgentChat(options);
}
// 重新导出原有 hooks,便于直接使用
export { useAgentChat } from "./useAgentChat";
// 旧 useAgentChat 已删除,避免新代码继续沿 compat 路径扩展。
export { useAsterAgentChat } from "./useAsterAgentChat";
export { useThemeContextWorkspace } from "./useThemeContextWorkspace";
export { useTopicBranchBoard } from "./useTopicBranchBoard";
@@ -1,449 +0,0 @@
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const {
mockStartAgentProcess,
mockStopAgentProcess,
mockGetAgentProcessStatus,
mockCreateAgentSession,
mockSendAsterMessageStream,
mockListAgentSessions,
mockDeleteAgentSession,
mockGetAgentSessionMessages,
mockRenameAgentSession,
mockGenerateAgentTitle,
mockParseStreamEvent,
mockConfirmAsterAction,
mockSubmitAsterElicitationResponse,
mockStopAsterSession,
mockSafeListen,
mockGetProviderConfig,
} = vi.hoisted(() => ({
mockStartAgentProcess: vi.fn(),
mockStopAgentProcess: vi.fn(),
mockGetAgentProcessStatus: vi.fn(),
mockCreateAgentSession: vi.fn(),
mockSendAsterMessageStream: vi.fn(),
mockListAgentSessions: vi.fn(),
mockDeleteAgentSession: vi.fn(),
mockGetAgentSessionMessages: vi.fn(),
mockRenameAgentSession: vi.fn(),
mockGenerateAgentTitle: vi.fn(),
mockParseStreamEvent: vi.fn((payload: unknown) => payload),
mockConfirmAsterAction: vi.fn(),
mockSubmitAsterElicitationResponse: vi.fn(),
mockStopAsterSession: vi.fn(),
mockSafeListen: vi.fn(),
mockGetProviderConfig: vi.fn(),
}));
vi.mock("@/lib/api/agentRuntime", () => ({
startAgentProcess: mockStartAgentProcess,
stopAgentProcess: mockStopAgentProcess,
getAgentProcessStatus: mockGetAgentProcessStatus,
createAgentSession: mockCreateAgentSession,
sendAsterMessageStream: mockSendAsterMessageStream,
listAgentSessions: mockListAgentSessions,
deleteAgentSession: mockDeleteAgentSession,
getAgentSessionMessages: mockGetAgentSessionMessages,
renameAgentSession: mockRenameAgentSession,
generateAgentTitle: mockGenerateAgentTitle,
confirmAsterAction: mockConfirmAsterAction,
submitAsterElicitationResponse: mockSubmitAsterElicitationResponse,
stopAsterSession: mockStopAsterSession,
}));
vi.mock("@/lib/api/agentStream", () => ({
parseStreamEvent: mockParseStreamEvent,
}));
vi.mock("@/lib/dev-bridge", () => ({
safeListen: mockSafeListen,
}));
vi.mock("@/lib/artifact/hooks/useArtifactParser", () => ({
useArtifactParser: () => ({
startParsing: vi.fn(),
appendChunk: vi.fn(),
finalizeParsing: vi.fn(),
reset: vi.fn(),
}),
}));
vi.mock("./skillCommand", () => ({
parseSkillSlashCommand: vi.fn(() => null),
tryExecuteSlashSkillCommand: vi.fn(async () => false),
}));
vi.mock("../utils/sessionRecovery", () => ({
isValidSessionId: vi.fn(() => true),
resolveRestorableSessionId: vi.fn(() => null),
}));
vi.mock("../types", () => {
const providerConfig = {
claude: { models: ["claude-sonnet-4-5", "claude-opus-4"] },
gemini: { models: ["gemini-2.5-pro", "gemini-2.5-flash"] },
deepseek: { models: ["deepseek-reasoner", "deepseek-chat"] },
};
return {
PROVIDER_CONFIG: providerConfig,
getProviderConfig: mockGetProviderConfig,
};
});
import { useAgentChat } from "./useAgentChat";
interface HookHarness {
getValue: () => ReturnType<typeof useAgentChat>;
unmount: () => void;
}
function mountHook(workspaceId = "ws-test"): HookHarness {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
let hookValue: ReturnType<typeof useAgentChat> | null = null;
function TestComponent() {
hookValue = useAgentChat({ workspaceId });
return null;
}
act(() => {
root.render(<TestComponent />);
});
return {
getValue: () => {
if (!hookValue) {
throw new Error("hook 尚未初始化");
}
return hookValue;
},
unmount: () => {
act(() => {
root.unmount();
});
container.remove();
},
};
}
async function flushEffects() {
await act(async () => {
await Promise.resolve();
});
}
function seedSession(
workspaceId: string,
sessionId: string,
messages: Array<Record<string, unknown>>,
) {
sessionStorage.setItem(
`agent_curr_sessionId_${workspaceId}`,
JSON.stringify(sessionId),
);
sessionStorage.setItem(
`agent_messages_${workspaceId}`,
JSON.stringify(messages),
);
}
beforeEach(() => {
(
globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
).IS_REACT_ACT_ENVIRONMENT = true;
vi.clearAllMocks();
localStorage.clear();
sessionStorage.clear();
mockStartAgentProcess.mockResolvedValue(undefined);
mockStopAgentProcess.mockResolvedValue(undefined);
mockGetAgentProcessStatus.mockResolvedValue({ running: false });
mockCreateAgentSession.mockResolvedValue({ session_id: "session-created" });
mockSendAsterMessageStream.mockResolvedValue(undefined);
mockListAgentSessions.mockResolvedValue([]);
mockDeleteAgentSession.mockResolvedValue(undefined);
mockGetAgentSessionMessages.mockResolvedValue([]);
mockRenameAgentSession.mockResolvedValue(undefined);
mockGenerateAgentTitle.mockResolvedValue("新话题");
mockConfirmAsterAction.mockResolvedValue(undefined);
mockSubmitAsterElicitationResponse.mockResolvedValue(undefined);
mockStopAsterSession.mockResolvedValue(undefined);
mockParseStreamEvent.mockImplementation((payload: unknown) => payload);
mockSafeListen.mockResolvedValue(() => {});
mockGetProviderConfig.mockResolvedValue({
claude: { models: ["claude-sonnet-4-5", "claude-opus-4"] },
gemini: { models: ["gemini-2.5-pro", "gemini-2.5-flash"] },
deepseek: { models: ["deepseek-reasoner", "deepseek-chat"] },
});
});
afterEach(() => {
localStorage.clear();
sessionStorage.clear();
});
describe("useAgentChat 权限响应", () => {
it("ask_user 提交应透传统一的 elicitation_context metadata", async () => {
const workspaceId = "ws-native-action-meta";
seedSession(workspaceId, "session-native-action-meta", [
{
id: "assistant-1",
role: "assistant",
content: "请选择执行模式",
timestamp: new Date().toISOString(),
actionRequests: [
{
requestId: "req-native-ask-1",
actionType: "ask_user",
prompt: "请选择执行模式",
questions: [{ question: "你希望如何执行?" }],
status: "pending",
},
],
},
]);
const harness = mountHook(workspaceId);
try {
await flushEffects();
await act(async () => {
await harness.getValue().handlePermissionResponse({
requestId: "req-native-ask-1",
confirmed: true,
actionType: "ask_user",
response: '{"answer":"自动执行"}',
});
});
expect(mockSubmitAsterElicitationResponse).toHaveBeenCalledWith(
"session-native-action-meta",
"req-native-ask-1",
{ answer: "自动执行" },
{
elicitation_context: {
source: "action_required",
mode: "runtime_protocol",
form_id: "req-native-ask-1",
action_type: "ask_user",
field_count: 1,
prompt: "请选择执行模式",
entries: [
{
fieldId: "req-native-ask-1_answer",
fieldKey: "answer",
label: "你希望如何执行?",
value: "自动执行",
summary: "自动执行",
},
],
},
},
);
} finally {
harness.unmount();
}
});
});
describe("useAgentChat 偏好持久化", () => {
it("应将旧全局偏好迁移到当前工作区", async () => {
localStorage.setItem("agent_pref_provider", JSON.stringify("gemini"));
localStorage.setItem("agent_pref_model", JSON.stringify("gemini-2.5-pro"));
const workspaceId = "ws-native-migrate";
const harness = mountHook(workspaceId);
try {
await flushEffects();
const value = harness.getValue();
expect(value.providerType).toBe("gemini");
expect(value.model).toBe("gemini-2.5-pro");
expect(
JSON.parse(
localStorage.getItem(`agent_pref_provider_${workspaceId}`) || "null",
),
).toBe("gemini");
expect(
JSON.parse(
localStorage.getItem(`agent_pref_model_${workspaceId}`) || "null",
),
).toBe("gemini-2.5-pro");
expect(
JSON.parse(
localStorage.getItem(`agent_pref_migrated_${workspaceId}`) || "false",
),
).toBe(true);
} finally {
harness.unmount();
}
});
it("应优先使用工作区偏好而不是旧全局偏好", async () => {
localStorage.setItem("agent_pref_provider", JSON.stringify("claude"));
localStorage.setItem("agent_pref_model", JSON.stringify("claude-opus-4"));
localStorage.setItem(
"agent_pref_provider_ws-native-scoped",
JSON.stringify("deepseek"),
);
localStorage.setItem(
"agent_pref_model_ws-native-scoped",
JSON.stringify("deepseek-reasoner"),
);
const harness = mountHook("ws-native-scoped");
try {
await flushEffects();
const value = harness.getValue();
expect(value.providerType).toBe("deepseek");
expect(value.model).toBe("deepseek-reasoner");
} finally {
harness.unmount();
}
});
it("无工作区时应保留全局模型偏好(切主题不丢失)", async () => {
const firstMount = mountHook("");
try {
await flushEffects();
act(() => {
firstMount.getValue().setProviderType("gemini");
firstMount.getValue().setModel("gemini-2.5-pro");
});
await flushEffects();
} finally {
firstMount.unmount();
}
const secondMount = mountHook("");
try {
await flushEffects();
const value = secondMount.getValue();
expect(value.providerType).toBe("gemini");
expect(value.model).toBe("gemini-2.5-pro");
expect(
JSON.parse(
localStorage.getItem("agent_pref_provider_global") || "null",
),
).toBe("gemini");
expect(
JSON.parse(localStorage.getItem("agent_pref_model_global") || "null"),
).toBe("gemini-2.5-pro");
} finally {
secondMount.unmount();
}
});
});
describe("useAgentChat 会话创建", () => {
it("创建新会话时不应默认注入已安装 skills", async () => {
const harness = mountHook("ws-session-create");
try {
await flushEffects();
await act(async () => {
await harness.getValue().sendMessage("普通对话消息", [], false, false);
});
expect(mockCreateAgentSession).toHaveBeenCalledTimes(1);
expect(mockCreateAgentSession).toHaveBeenCalledWith(
"claude",
"ws-session-create",
"claude-sonnet-4-5",
undefined,
);
expect(mockCreateAgentSession.mock.calls[0]).toHaveLength(4);
} finally {
harness.unmount();
}
});
it("旧链路收到带 Lime 元数据块的 tool_end error 时应清洗错误文本", async () => {
const harness = mountHook("ws-native-tool-error");
let streamHandler: ((event: { payload: unknown }) => void) | null = null;
mockSafeListen.mockImplementationOnce(async (_eventName, handler) => {
streamHandler = handler as (event: { payload: unknown }) => void;
return () => {
streamHandler = null;
};
});
try {
await flushEffects();
await act(async () => {
await harness.getValue().sendMessage("测试旧链路", [], false, false);
});
act(() => {
streamHandler?.({
payload: {
type: "tool_start",
tool_id: "native-tool-1",
tool_name: "browser_navigate",
arguments: JSON.stringify({
url: "https://example.com",
}),
},
});
});
act(() => {
streamHandler?.({
payload: {
type: "tool_end",
tool_id: "native-tool-1",
result: {
success: true,
output: "",
error: [
"CDP 会话已断开,请重试",
"",
"[Lime 工具元数据开始]",
JSON.stringify({
reported_success: false,
exit_code: 1,
}),
"[Lime 工具元数据结束]",
].join("\n"),
},
},
});
});
const assistantMessage = [...harness.getValue().messages]
.reverse()
.find((message) => message.role === "assistant");
const toolCall = assistantMessage?.toolCalls?.find(
(item) => item.id === "native-tool-1",
);
expect(toolCall?.status).toBe("failed");
expect(toolCall?.result?.error).toBe("CDP 会话已断开,请重试");
expect(toolCall?.result?.error).not.toContain("Lime 工具元数据");
expect(toolCall?.result?.metadata).toMatchObject({
reported_success: false,
exit_code: 1,
});
} finally {
harness.unmount();
}
});
});
File diff suppressed because it is too large Load Diff
@@ -11,6 +11,7 @@ import type {
AsterExecutionStrategy,
QueuedTurnSnapshot,
} from "@/lib/api/agentRuntime";
import { logAgentDebug } from "@/lib/agentDebug";
import { normalizeQueuedTurnSnapshots } from "@/lib/api/queuedTurn";
import {
isAsterSessionNotFoundError,
@@ -42,7 +43,7 @@ interface UseAgentSessionOptions {
runtime: AgentRuntimeAdapter;
workspaceId: string;
disableSessionRestore: boolean;
isInitialized: boolean;
preserveRestoredMessages: boolean;
executionStrategy: AsterExecutionStrategy;
providerTypeRef: MutableRefObject<string>;
modelRef: MutableRefObject<string>;
@@ -73,7 +74,7 @@ export function useAgentSession(options: UseAgentSessionOptions) {
runtime,
workspaceId,
disableSessionRestore,
isInitialized,
preserveRestoredMessages,
executionStrategy,
providerTypeRef,
modelRef,
@@ -277,10 +278,6 @@ export function useAgentSession(options: UseAgentSessionOptions) {
useEffect(() => {
let cancelled = false;
if (!isInitialized) {
return;
}
if (!workspaceId?.trim()) {
setTopics([]);
setTopicsReady(true);
@@ -288,6 +285,10 @@ export function useAgentSession(options: UseAgentSessionOptions) {
}
setTopicsReady(false);
const startedAt = Date.now();
logAgentDebug("useAgentSession", "listSessions.start", {
workspaceId,
});
runtime
.listSessions()
.then((sessions) => {
@@ -296,6 +297,12 @@ export function useAgentSession(options: UseAgentSessionOptions) {
}
const topicList =
filterSessionsByWorkspace(sessions).map(mapSessionToTopic);
logAgentDebug("useAgentSession", "listSessions.success", {
durationMs: Date.now() - startedAt,
sessionsCount: sessions.length,
topicsCount: topicList.length,
workspaceId,
});
setTopics(topicList);
})
.catch((error) => {
@@ -303,6 +310,16 @@ export function useAgentSession(options: UseAgentSessionOptions) {
return;
}
console.error("[AsterChat] 加载话题失败:", error);
logAgentDebug(
"useAgentSession",
"listSessions.error",
{
durationMs: Date.now() - startedAt,
error,
workspaceId,
},
{ level: "error" },
);
})
.finally(() => {
if (!cancelled) {
@@ -313,7 +330,7 @@ export function useAgentSession(options: UseAgentSessionOptions) {
return () => {
cancelled = true;
};
}, [filterSessionsByWorkspace, isInitialized, runtime, workspaceId]);
}, [filterSessionsByWorkspace, runtime, workspaceId]);
const loadTopics = useCallback(async () => {
if (!workspaceId?.trim()) {
@@ -323,13 +340,33 @@ export function useAgentSession(options: UseAgentSessionOptions) {
}
setTopicsReady(false);
const startedAt = Date.now();
logAgentDebug("useAgentSession", "loadTopics.start", {
workspaceId,
});
try {
const sessions = await runtime.listSessions();
const topicList =
filterSessionsByWorkspace(sessions).map(mapSessionToTopic);
logAgentDebug("useAgentSession", "loadTopics.success", {
durationMs: Date.now() - startedAt,
sessionsCount: sessions.length,
topicsCount: topicList.length,
workspaceId,
});
setTopics(topicList);
} catch (error) {
console.error("[AsterChat] 加载话题失败:", error);
logAgentDebug(
"useAgentSession",
"loadTopics.error",
{
durationMs: Date.now() - startedAt,
error,
workspaceId,
},
{ level: "error" },
);
} finally {
setTopicsReady(true);
}
@@ -344,6 +381,11 @@ export function useAgentSession(options: UseAgentSessionOptions) {
}
try {
logAgentDebug("useAgentSession", "createFreshSession.start", {
executionStrategy,
sessionName: sessionName?.trim() || null,
workspaceId: resolvedWorkspaceId,
});
const newSessionId = await runtime.createSession(
resolvedWorkspaceId,
sessionName,
@@ -392,9 +434,24 @@ export function useAgentSession(options: UseAgentSessionOptions) {
saveTransient(scopedKeys.currentTurnKey, null);
void loadTopics();
logAgentDebug("useAgentSession", "createFreshSession.success", {
newSessionId,
sessionName: sessionName?.trim() || null,
workspaceId: resolvedWorkspaceId,
});
return newSessionId;
} catch (error) {
console.error("[AsterChat] 创建新任务失败:", error);
logAgentDebug(
"useAgentSession",
"createFreshSession.error",
{
error,
sessionName: sessionName?.trim() || null,
workspaceId: resolvedWorkspaceId,
},
{ level: "error" },
);
toast.error(`创建新任务失败: ${error}`);
return null;
}
@@ -449,11 +506,7 @@ export function useAgentSession(options: UseAgentSessionOptions) {
toast.success(toastMessage);
}
},
[
resetPendingActions,
resetStreamingRefs,
scopedKeys,
],
[resetPendingActions, resetStreamingRefs, scopedKeys],
);
const deleteMessage = useCallback((id: string) => {
@@ -513,10 +566,26 @@ export function useAgentSession(options: UseAgentSessionOptions) {
skipAutoRestoreRef.current = false;
try {
const startedAt = Date.now();
logAgentDebug("useAgentSession", "switchTopic.start", {
currentSessionId,
messagesCount: messages.length,
topicId,
workspaceId,
});
const detail = await runtime.getSession(topicId);
const topicPreference = loadSessionModelPreference(topicId);
applySessionDetail(topicId, detail, { syncSessionId: true });
logAgentDebug("useAgentSession", "switchTopic.success", {
durationMs: Date.now() - startedAt,
itemsCount: detail.items?.length ?? 0,
messagesCount: detail.messages.length,
queuedTurnsCount: detail.queued_turns?.length ?? 0,
topicId,
turnsCount: detail.turns?.length ?? 0,
workspaceId,
});
if (topicPreference) {
applySessionModelPreference(topicId, topicPreference);
@@ -524,6 +593,16 @@ export function useAgentSession(options: UseAgentSessionOptions) {
} catch (error) {
console.error("[AsterChat] 切换话题失败:", error);
console.error("[AsterChat] 错误详情:", JSON.stringify(error, null, 2));
logAgentDebug(
"useAgentSession",
"switchTopic.error",
{
error,
topicId,
workspaceId,
},
{ level: "error" },
);
if (isAsterSessionNotFoundError(error)) {
setMessages([]);
setThreadTurns([]);
@@ -561,6 +640,7 @@ export function useAgentSession(options: UseAgentSessionOptions) {
runtime,
scopedKeys,
sessionIdRef,
workspaceId,
],
);
@@ -589,7 +669,6 @@ export function useAgentSession(options: UseAgentSessionOptions) {
useEffect(() => {
const resolvedWorkspaceId = workspaceId?.trim();
if (!resolvedWorkspaceId) return;
if (!isInitialized) return;
if (disableSessionRestore) return;
if (!topicsReady) return;
if (skipAutoRestoreRef.current) return;
@@ -611,16 +690,41 @@ export function useAgentSession(options: UseAgentSessionOptions) {
})),
});
if (!targetSessionId) {
logAgentDebug(
"useAgentSession",
"autoRestore.skipWithoutTarget",
{
candidateSessionId: scopedCandidate,
topicsCount: topics.length,
workspaceId: resolvedWorkspaceId,
},
{ throttleMs: 1000 },
);
return;
}
logAgentDebug("useAgentSession", "autoRestore.start", {
candidateSessionId: scopedCandidate,
targetSessionId,
topicsCount: topics.length,
workspaceId: resolvedWorkspaceId,
});
switchTopic(targetSessionId).catch((error) => {
console.warn("[AsterChat] 自动恢复会话失败:", error);
logAgentDebug(
"useAgentSession",
"autoRestore.error",
{
error,
targetSessionId,
workspaceId: resolvedWorkspaceId,
},
{ level: "warn" },
);
saveTransient(scopedKeys.currentSessionKey, null);
savePersisted(scopedKeys.persistedSessionKey, null);
});
}, [
isInitialized,
disableSessionRestore,
sessionId,
scopedKeys,
@@ -641,6 +745,16 @@ export function useAgentSession(options: UseAgentSessionOptions) {
if (!topicsReady) return;
if (topics.length > 0 && !topics.some((topic) => topic.id === sessionId)) {
logAgentDebug(
"useAgentSession",
"hydrateSession.sessionMissingFromTopics",
{
sessionId,
topicsCount: topics.length,
workspaceId,
},
{ level: "warn" },
);
setSessionId(null);
setMessages([]);
setThreadTurns([]);
@@ -661,18 +775,41 @@ export function useAgentSession(options: UseAgentSessionOptions) {
return;
}
if (preserveRestoredMessages && messages.length > 0) {
hydratedSessionRef.current = sessionId;
return;
}
if (hydratedSessionRef.current === sessionId) {
return;
}
hydratedSessionRef.current = sessionId;
logAgentDebug("useAgentSession", "hydrateSession.start", {
messagesCount: messages.length,
sessionId,
threadItemsCount: threadItems.length,
threadTurnsCount: threadTurns.length,
workspaceId,
});
switchTopic(sessionId).catch((error) => {
console.warn("[AsterChat] 会话水合失败:", error);
logAgentDebug(
"useAgentSession",
"hydrateSession.error",
{
error,
sessionId,
workspaceId,
},
{ level: "warn" },
);
hydratedSessionRef.current = null;
});
}, [
messages.length,
preserveRestoredMessages,
scopedKeys,
sessionId,
switchTopic,
@@ -680,6 +817,49 @@ export function useAgentSession(options: UseAgentSessionOptions) {
threadTurns.length,
topics,
topicsReady,
workspaceId,
]);
useEffect(() => {
logAgentDebug(
"useAgentSession",
"stateSnapshot",
{
currentTurnId: currentTurnId ?? null,
messagesCount: messages.length,
queuedTurnsCount: queuedTurns.length,
sessionId: sessionId ?? null,
threadItemsCount: threadItems.length,
threadTurnsCount: threadTurns.length,
topicsCount: topics.length,
topicsReady,
workspaceId,
},
{
dedupeKey: JSON.stringify({
currentTurnId: currentTurnId ?? null,
messagesCount: messages.length,
queuedTurnsCount: queuedTurns.length,
sessionId: sessionId ?? null,
threadItemsCount: threadItems.length,
threadTurnsCount: threadTurns.length,
topicsCount: topics.length,
topicsReady,
workspaceId,
}),
throttleMs: 800,
},
);
}, [
currentTurnId,
messages.length,
queuedTurns.length,
sessionId,
threadItems.length,
threadTurns.length,
topics.length,
topicsReady,
workspaceId,
]);
const deleteTopic = useCallback(
@@ -779,9 +959,11 @@ export function useAgentSession(options: UseAgentSessionOptions) {
return topic;
}
const { updatedAt, ...restSnapshot } = snapshot;
const nextTopic = {
...topic,
...snapshot,
...restSnapshot,
...(updatedAt ? { updatedAt } : {}),
};
const unchanged =
@@ -138,6 +138,8 @@ export function useAgentStream(options: UseAgentStreamOptions) {
assistantMsgId: string;
eventName: string;
sessionId: string;
optimisticTurnId?: string;
optimisticItemId?: string;
} | null>(null);
useEffect(() => {
@@ -156,6 +158,8 @@ export function useAgentStream(options: UseAgentStreamOptions) {
assistantMsgId: string;
eventName: string;
sessionId: string;
optimisticTurnId?: string;
optimisticItemId?: string;
} | null,
) => {
activeStreamRef.current = nextActive;
@@ -331,6 +335,8 @@ export function useAgentStream(options: UseAgentStreamOptions) {
activeStreamRef.current?.assistantMsgId || assistantMsgId,
eventName: skillEventName,
sessionId: sessionIdForStop,
optimisticTurnId: activeStreamRef.current?.optimisticTurnId,
optimisticItemId: activeStreamRef.current?.optimisticItemId,
});
},
isExecutionCancelled: () =>
@@ -358,8 +364,8 @@ export function useAgentStream(options: UseAgentStreamOptions) {
};
let streamActivated = false;
const optimisticStartedAt = assistantMsg.timestamp.toISOString();
const optimisticTurnId = `local-turn:${assistantMsgId}`;
const optimisticItemId = `local-item:${assistantMsgId}:turn-summary`;
const optimisticTurnId = crypto.randomUUID();
const optimisticItemId = `turn-summary:${optimisticTurnId}`;
const optimisticThreadId =
sessionIdRef.current || `local-thread:${assistantMsgId}`;
const toolLogIdByToolId = new Map<string, string>();
@@ -483,6 +489,8 @@ export function useAgentStream(options: UseAgentStreamOptions) {
assistantMsgId,
eventName,
sessionId: activeSessionId,
optimisticTurnId,
optimisticItemId,
});
setMessages((prev) =>
prev.map((msg) =>
@@ -615,6 +623,7 @@ export function useAgentStream(options: UseAgentStreamOptions) {
sessionId: activeSessionId,
eventName,
workspaceId: resolvedWorkspaceId,
turnId: optimisticTurnId,
images: imagesToSend,
providerConfig,
executionStrategy: effectiveExecutionStrategy,
@@ -716,11 +725,19 @@ export function useAgentStream(options: UseAgentStreamOptions) {
setQueuedTurns([]);
if (activeStream?.assistantMsgId) {
const optimisticTurnId = `local-turn:${activeStream.assistantMsgId}`;
const optimisticItemId = `${`local-item:${activeStream.assistantMsgId}`}:turn-summary`;
setThreadItems((prev) => removeThreadItemState(prev, optimisticItemId));
setThreadTurns((prev) => removeThreadTurnState(prev, optimisticTurnId));
setCurrentTurnId((prev) => (prev === optimisticTurnId ? null : prev));
if (activeStream.optimisticItemId) {
setThreadItems((prev) =>
removeThreadItemState(prev, activeStream.optimisticItemId!),
);
}
if (activeStream.optimisticTurnId) {
setThreadTurns((prev) =>
removeThreadTurnState(prev, activeStream.optimisticTurnId!),
);
setCurrentTurnId((prev) =>
prev === activeStream.optimisticTurnId ? null : prev,
);
}
setMessages((prev) =>
prev.map((msg) =>
msg.id === activeStream.assistantMsgId
@@ -23,6 +23,7 @@ interface UseAgentToolsOptions {
runtime: AgentRuntimeAdapter;
sessionIdRef: MutableRefObject<string | null>;
currentStreamingSessionIdRef: MutableRefObject<string | null>;
messages: Message[];
setMessages: Dispatch<SetStateAction<Message[]>>;
setThreadItems: Dispatch<SetStateAction<AgentThreadItem[]>>;
}
@@ -32,6 +33,7 @@ export function useAgentTools(options: UseAgentToolsOptions) {
runtime,
sessionIdRef,
currentStreamingSessionIdRef,
messages,
setMessages,
setThreadItems,
} = options;
@@ -53,7 +55,12 @@ export function useAgentTools(options: UseAgentToolsOptions) {
const pendingAction = pendingActions.find(
(item) => item.requestId === response.requestId,
);
const actionType = response.actionType || pendingAction?.actionType;
const persistedAction =
pendingAction ||
messages
.flatMap((message) => message.actionRequests || [])
.find((item) => item.requestId === response.requestId);
const actionType = response.actionType || persistedAction?.actionType;
if (!actionType) {
throw new Error("缺少 actionType,无法提交确认");
}
@@ -62,7 +69,7 @@ export function useAgentTools(options: UseAgentToolsOptions) {
typeof response.response === "string" ? response.response.trim() : "";
let submittedUserData: unknown = response.userData;
let effectiveRequestId = response.requestId;
let metadataAction = pendingAction;
let metadataAction = persistedAction;
const acknowledgedRequestIds = new Set<string>([response.requestId]);
if (actionType === "elicitation" || actionType === "ask_user") {
@@ -94,13 +101,13 @@ export function useAgentTools(options: UseAgentToolsOptions) {
submittedUserData = userData;
if (pendingAction?.isFallback) {
const fallbackPromptKey = resolveActionPromptKey(pendingAction);
if (persistedAction?.isFallback) {
const fallbackPromptKey = resolveActionPromptKey(persistedAction);
if (fallbackPromptKey) {
const resolvedAction = pendingActions.find((item) => {
if (item.requestId === pendingAction.requestId) return false;
if (item.requestId === persistedAction.requestId) return false;
if (item.isFallback) return false;
if (item.actionType !== pendingAction.actionType) return false;
if (item.actionType !== persistedAction.actionType) return false;
return resolveActionPromptKey(item) === fallbackPromptKey;
});
@@ -108,12 +115,12 @@ export function useAgentTools(options: UseAgentToolsOptions) {
queuedFallbackResponsesRef.current.set(fallbackPromptKey, {
...response,
actionType,
requestId: pendingAction.requestId,
requestId: persistedAction.requestId,
userData,
});
setPendingActions((prev) =>
prev.map((item) =>
item.requestId === pendingAction.requestId
item.requestId === persistedAction.requestId
? {
...item,
status: "queued",
@@ -127,7 +134,7 @@ export function useAgentTools(options: UseAgentToolsOptions) {
prev.map((msg) => ({
...msg,
actionRequests: msg.actionRequests?.map((item) =>
item.requestId === pendingAction.requestId
item.requestId === persistedAction.requestId
? {
...item,
status: "queued" as const,
@@ -138,7 +145,7 @@ export function useAgentTools(options: UseAgentToolsOptions) {
),
contentParts: msg.contentParts?.map((part) =>
part.type === "action_required" &&
part.actionRequired.requestId === pendingAction.requestId
part.actionRequired.requestId === persistedAction.requestId
? {
...part,
actionRequired: {
@@ -249,6 +256,7 @@ export function useAgentTools(options: UseAgentToolsOptions) {
},
[
currentStreamingSessionIdRef,
messages,
pendingActions,
runtime,
sessionIdRef,
@@ -6,11 +6,11 @@ import type { WriteArtifactContext } from "../types";
const {
mockInitAsterAgent,
mockSubmitAgentRuntimeTurn,
mockCreateAsterSession,
mockListAsterSessions,
mockGetAsterSession,
mockCreateAgentRuntimeSession,
mockListAgentRuntimeSessions,
mockGetAgentRuntimeSession,
mockUpdateAgentRuntimeSession,
mockDeleteAsterSession,
mockDeleteAgentRuntimeSession,
mockInterruptAgentRuntimeTurn,
mockRemoveAgentRuntimeQueuedTurn,
mockRespondAgentRuntimeAction,
@@ -22,11 +22,11 @@ const {
} = vi.hoisted(() => ({
mockInitAsterAgent: vi.fn(),
mockSubmitAgentRuntimeTurn: vi.fn(),
mockCreateAsterSession: vi.fn(),
mockListAsterSessions: vi.fn(),
mockGetAsterSession: vi.fn(),
mockCreateAgentRuntimeSession: vi.fn(),
mockListAgentRuntimeSessions: vi.fn(),
mockGetAgentRuntimeSession: vi.fn(),
mockUpdateAgentRuntimeSession: vi.fn(),
mockDeleteAsterSession: vi.fn(),
mockDeleteAgentRuntimeSession: vi.fn(),
mockInterruptAgentRuntimeTurn: vi.fn(),
mockRemoveAgentRuntimeQueuedTurn: vi.fn(),
mockRespondAgentRuntimeAction: vi.fn(),
@@ -44,20 +44,14 @@ const {
mockTryExecuteSlashSkillCommand: vi.fn(async () => false),
}));
const mockSendAsterMessageStream = mockSubmitAgentRuntimeTurn;
vi.mock("@/lib/api/agentRuntime", () => ({
initAsterAgent: mockInitAsterAgent,
createAsterSession: mockCreateAsterSession,
listAsterSessions: mockListAsterSessions,
getAsterSession: mockGetAsterSession,
deleteAsterSession: mockDeleteAsterSession,
submitAgentRuntimeTurn: mockSubmitAgentRuntimeTurn,
createAgentRuntimeSession: mockCreateAsterSession,
listAgentRuntimeSessions: mockListAsterSessions,
getAgentRuntimeSession: mockGetAsterSession,
createAgentRuntimeSession: mockCreateAgentRuntimeSession,
listAgentRuntimeSessions: mockListAgentRuntimeSessions,
getAgentRuntimeSession: mockGetAgentRuntimeSession,
updateAgentRuntimeSession: mockUpdateAgentRuntimeSession,
deleteAgentRuntimeSession: mockDeleteAsterSession,
deleteAgentRuntimeSession: mockDeleteAgentRuntimeSession,
interruptAgentRuntimeTurn: mockInterruptAgentRuntimeTurn,
removeAgentRuntimeQueuedTurn: mockRemoveAgentRuntimeQueuedTurn,
respondAgentRuntimeAction: mockRespondAgentRuntimeAction,
@@ -84,6 +78,7 @@ import { useAsterAgentChat } from "./useAsterAgentChat";
interface HookHarness {
getValue: () => ReturnType<typeof useAsterAgentChat>;
getRenderCount: () => number;
unmount: () => void;
}
@@ -102,8 +97,10 @@ function mountHook(
const root = createRoot(container);
let hookValue: ReturnType<typeof useAsterAgentChat> | null = null;
let renderCount = 0;
function TestComponent() {
renderCount += 1;
hookValue = useAsterAgentChat({
workspaceId,
onWriteFile: currentOptions.onWriteFile,
@@ -122,6 +119,7 @@ function mountHook(
}
return hookValue;
},
getRenderCount: () => renderCount,
unmount: () => {
act(() => {
root.unmount();
@@ -168,14 +166,14 @@ beforeEach(() => {
mockInitAsterAgent.mockResolvedValue(undefined);
mockSubmitAgentRuntimeTurn.mockResolvedValue(undefined);
mockCreateAsterSession.mockResolvedValue("created-session");
mockListAsterSessions.mockResolvedValue([]);
mockGetAsterSession.mockResolvedValue({
mockCreateAgentRuntimeSession.mockResolvedValue("created-session");
mockListAgentRuntimeSessions.mockResolvedValue([]);
mockGetAgentRuntimeSession.mockResolvedValue({
id: "session-from-api",
messages: [],
});
mockUpdateAgentRuntimeSession.mockResolvedValue(undefined);
mockDeleteAsterSession.mockResolvedValue(undefined);
mockDeleteAgentRuntimeSession.mockResolvedValue(undefined);
mockInterruptAgentRuntimeTurn.mockResolvedValue(undefined);
mockRemoveAgentRuntimeQueuedTurn.mockResolvedValue(true);
mockRespondAgentRuntimeAction.mockResolvedValue(undefined);
@@ -190,6 +188,21 @@ afterEach(() => {
});
describe("useAsterAgentChat 首页新会话", () => {
it("无工作区时不应主动初始化 Agent", async () => {
const harness = mountHook("");
try {
await flushEffects();
expect(mockInitAsterAgent).not.toHaveBeenCalled();
expect(mockListAgentRuntimeSessions).not.toHaveBeenCalled();
expect(harness.getValue().processStatus.running).toBe(false);
expect(harness.getValue().topics).toEqual([]);
} finally {
harness.unmount();
}
});
it("clearMessages 后重新进入同工作区不应恢复旧话题", async () => {
const workspaceId = "ws-home-clear";
const sessionId = "session-home-clear";
@@ -229,9 +242,85 @@ describe("useAsterAgentChat 首页新会话", () => {
harness.unmount();
}
});
it("加载话题不应依赖预先初始化 Agent", async () => {
const workspaceId = "ws-topic-lazy-init";
const sessionId = "session-topic-lazy-init";
mockListAgentRuntimeSessions.mockResolvedValue([
{
id: sessionId,
name: "任务 C",
created_at: 1700000020,
updated_at: 1700000021,
messages_count: 0,
},
]);
const harness = mountHook(workspaceId);
try {
await flushEffects();
await flushEffects();
expect(mockInitAsterAgent).not.toHaveBeenCalled();
expect(mockListAgentRuntimeSessions).toHaveBeenCalledTimes(1);
expect(harness.getValue().topics.map((topic) => topic.id)).toEqual([
sessionId,
]);
} finally {
harness.unmount();
}
});
});
describe("useAsterAgentChat 任务快照", () => {
it("空会话快照稳定后不应继续自发重渲染", async () => {
const workspaceId = "ws-task-stable";
const sessionId = "session-task-stable";
sessionStorage.setItem(
`aster_curr_sessionId_${workspaceId}`,
JSON.stringify(sessionId),
);
mockListAgentRuntimeSessions.mockResolvedValue([
{
id: sessionId,
name: "任务稳定性",
created_at: 1700000100,
updated_at: 1700000101,
messages_count: 0,
},
]);
mockGetAgentRuntimeSession.mockResolvedValue({
id: sessionId,
messages: [],
turns: [],
items: [],
queued_turns: [],
});
const harness = mountHook(workspaceId);
try {
await flushEffects();
await flushEffects();
const topic = harness
.getValue()
.topics.find((item) => item.id === sessionId);
expect(topic).toBeTruthy();
expect(topic?.updatedAt.getTime()).toBe(1700000101 * 1000);
const settledRenderCount = harness.getRenderCount();
await flushEffects();
await flushEffects();
expect(harness.getRenderCount()).toBe(settledRenderCount);
} finally {
harness.unmount();
}
});
it("应将当前任务的真实摘要与状态回写到任务列表", async () => {
const workspaceId = "ws-task-snapshot";
const sessionId = "session-task-snapshot";
@@ -250,7 +339,7 @@ describe("useAsterAgentChat 任务快照", () => {
},
]),
);
mockListAsterSessions.mockResolvedValue([
mockListAgentRuntimeSessions.mockResolvedValue([
{
id: sessionId,
name: "任务 A",
@@ -285,7 +374,7 @@ describe("useAsterAgentChat 任务快照", () => {
`aster_curr_sessionId_${workspaceId}`,
JSON.stringify(sessionId),
);
mockListAsterSessions.mockResolvedValue([
mockListAgentRuntimeSessions.mockResolvedValue([
{
id: sessionId,
name: "任务 B",
@@ -294,7 +383,7 @@ describe("useAsterAgentChat 任务快照", () => {
messages_count: 0,
},
]);
mockGetAsterSession.mockResolvedValue({
mockGetAgentRuntimeSession.mockResolvedValue({
id: sessionId,
messages: [],
turns: [],
@@ -430,7 +519,7 @@ describe("useAsterAgentChat.confirmAction", () => {
describe("useAsterAgentChat queue hydration", () => {
it("切换话题时应恢复后端返回的排队项", async () => {
mockListAsterSessions.mockResolvedValue([
mockListAgentRuntimeSessions.mockResolvedValue([
{
id: "session-queue",
name: "带队列的话题",
@@ -438,7 +527,7 @@ describe("useAsterAgentChat queue hydration", () => {
updated_at: 2,
},
]);
mockGetAsterSession.mockResolvedValue({
mockGetAgentRuntimeSession.mockResolvedValue({
id: "session-queue",
messages: [],
turns: [],
@@ -502,12 +591,15 @@ describe("useAsterAgentChat thread timeline", () => {
.sendMessage("帮我先开始处理", [], false, false, false, "react");
});
expect(harness.getValue().currentTurnId).toMatch(/^local-turn:/);
const optimisticTurnId = harness.getValue().currentTurnId;
expect(optimisticTurnId).toBeTruthy();
expect(harness.getValue().turns).toHaveLength(1);
expect(harness.getValue().turns[0]?.id).toMatch(/^local-turn:/);
expect(harness.getValue().turns[0]?.id).toBe(optimisticTurnId);
expect(harness.getValue().turns[0]?.status).toBe("running");
expect(harness.getValue().threadItems).toHaveLength(1);
expect(harness.getValue().threadItems[0]?.id).toMatch(/^local-item:/);
expect(harness.getValue().threadItems[0]?.id).toBe(
`turn-summary:${optimisticTurnId}`,
);
expect(harness.getValue().threadItems[0]?.type).toBe("turn_summary");
expect(harness.getValue().threadItems[0]?.status).toBe("in_progress");
@@ -871,7 +963,7 @@ describe("useAsterAgentChat slash skill 执行链路", () => {
"/social_post_with_cover 写一篇春季新品文案",
);
expect(mockTryExecuteSlashSkillCommand).toHaveBeenCalledTimes(1);
expect(mockSendAsterMessageStream).not.toHaveBeenCalled();
expect(mockSubmitAgentRuntimeTurn).not.toHaveBeenCalled();
} finally {
harness.unmount();
}
@@ -903,7 +995,7 @@ describe("useAsterAgentChat slash skill 执行链路", () => {
});
expect(mockTryExecuteSlashSkillCommand).toHaveBeenCalledTimes(1);
expect(mockSendAsterMessageStream).toHaveBeenCalledTimes(1);
expect(mockSubmitAgentRuntimeTurn).toHaveBeenCalledTimes(1);
} finally {
harness.unmount();
}
@@ -2211,7 +2303,7 @@ describe("useAsterAgentChat 偏好持久化", () => {
const now = Math.floor(Date.now() / 1000);
seedSession(workspaceId, staleSessionId);
mockListAsterSessions.mockResolvedValue([
mockListAgentRuntimeSessions.mockResolvedValue([
{
id: activeSessionId,
name: "可用会话",
@@ -2220,7 +2312,7 @@ describe("useAsterAgentChat 偏好持久化", () => {
messages_count: 1,
},
]);
mockGetAsterSession.mockResolvedValue({
mockGetAgentRuntimeSession.mockResolvedValue({
id: activeSessionId,
created_at: now - 10,
updated_at: now,
@@ -2235,7 +2327,7 @@ describe("useAsterAgentChat 偏好持久化", () => {
await flushEffects();
expect(
mockGetAsterSession.mock.calls.some(
mockGetAgentRuntimeSession.mock.calls.some(
([sessionId]) => sessionId === staleSessionId,
),
).toBe(false);
@@ -2249,7 +2341,7 @@ describe("useAsterAgentChat 偏好持久化", () => {
const workspaceId = "ws-filter-current";
const createdAt = Math.floor(Date.now() / 1000);
mockListAsterSessions.mockResolvedValue([
mockListAgentRuntimeSessions.mockResolvedValue([
{
id: "topic-current",
name: "当前项目话题",
@@ -2298,7 +2390,7 @@ describe("useAsterAgentChat 偏好持久化", () => {
const workspaceId = "ws-topic-memory";
const createdAt = Math.floor(Date.now() / 1000);
mockListAsterSessions.mockResolvedValue([
mockListAgentRuntimeSessions.mockResolvedValue([
{
id: "topic-a",
name: "话题 A",
@@ -2312,7 +2404,7 @@ describe("useAsterAgentChat 偏好持久化", () => {
messages_count: 0,
},
]);
mockGetAsterSession.mockImplementation(async (topicId: string) => ({
mockGetAgentRuntimeSession.mockImplementation(async (topicId: string) => ({
id: topicId,
messages: [],
execution_strategy: "react",
@@ -2379,7 +2471,7 @@ describe("useAsterAgentChat 偏好持久化", () => {
const workspaceId = "ws-topic-memory-immediate";
const createdAt = Math.floor(Date.now() / 1000);
mockListAsterSessions.mockResolvedValue([
mockListAgentRuntimeSessions.mockResolvedValue([
{
id: "topic-a",
name: "话题 A",
@@ -2393,7 +2485,7 @@ describe("useAsterAgentChat 偏好持久化", () => {
messages_count: 0,
},
]);
mockGetAsterSession.mockImplementation(async (topicId: string) => ({
mockGetAgentRuntimeSession.mockImplementation(async (topicId: string) => ({
id: topicId,
messages: [],
execution_strategy: "react",
@@ -2447,7 +2539,7 @@ describe("useAsterAgentChat 偏好持久化", () => {
it("切换话题时应保留工具调用历史并恢复 elicitation 回答文本", async () => {
const workspaceId = "ws-history-hydrate";
const now = Math.floor(Date.now() / 1000);
mockGetAsterSession.mockResolvedValue({
mockGetAgentRuntimeSession.mockResolvedValue({
id: "topic-history",
execution_strategy: "react",
messages: [
@@ -2516,7 +2608,7 @@ describe("useAsterAgentChat 偏好持久化", () => {
it("切换话题时应恢复 input_image 历史消息", async () => {
const workspaceId = "ws-history-image";
const now = Math.floor(Date.now() / 1000);
mockGetAsterSession.mockResolvedValue({
mockGetAgentRuntimeSession.mockResolvedValue({
id: "topic-image",
execution_strategy: "react",
messages: [
@@ -2574,7 +2666,7 @@ describe("useAsterAgentChat 偏好持久化", () => {
it("切换话题时应将仅含 tool_response 协议的空白 user 消息归一为 assistant 轨迹", async () => {
const workspaceId = "ws-history-empty-user-tool-response";
const now = Math.floor(Date.now() / 1000);
mockGetAsterSession.mockResolvedValue({
mockGetAgentRuntimeSession.mockResolvedValue({
id: "topic-empty-user",
execution_strategy: "react",
messages: [
@@ -2649,7 +2741,7 @@ describe("useAsterAgentChat 偏好持久化", () => {
it("切换话题时应从 tool_response 输出中提取图片并写入工具结果", async () => {
const workspaceId = "ws-history-tool-image";
const now = Math.floor(Date.now() / 1000);
mockGetAsterSession.mockResolvedValue({
mockGetAgentRuntimeSession.mockResolvedValue({
id: "topic-tool-image",
execution_strategy: "react",
messages: [
@@ -2702,7 +2794,7 @@ describe("useAsterAgentChat 偏好持久化", () => {
it("切换话题时应清洗 tool_response error 中的 Lime 元数据块", async () => {
const workspaceId = "ws-history-tool-error-metadata";
const now = Math.floor(Date.now() / 1000);
mockGetAsterSession.mockResolvedValue({
mockGetAgentRuntimeSession.mockResolvedValue({
id: "topic-tool-error-metadata",
execution_strategy: "react",
messages: [
@@ -2766,7 +2858,7 @@ describe("useAsterAgentChat 偏好持久化", () => {
it("切换话题时应合并同一工具调用的 running/completed 轨迹为一条", async () => {
const workspaceId = "ws-history-tool-dedupe";
const now = Math.floor(Date.now() / 1000);
mockGetAsterSession.mockResolvedValue({
mockGetAgentRuntimeSession.mockResolvedValue({
id: "topic-tool-dedupe",
execution_strategy: "react",
messages: [
@@ -2823,7 +2915,7 @@ describe("useAsterAgentChat 偏好持久化", () => {
it("切换话题时应合并连续 assistant 历史片段", async () => {
const workspaceId = "ws-history-merge";
const now = Math.floor(Date.now() / 1000);
mockGetAsterSession.mockResolvedValue({
mockGetAgentRuntimeSession.mockResolvedValue({
id: "topic-merge",
execution_strategy: "react",
messages: [
@@ -2882,7 +2974,7 @@ describe("useAsterAgentChat 偏好持久化", () => {
it("切换话题时应去重相邻重复历史消息", async () => {
const workspaceId = "ws-history-adjacent-dedupe";
const now = Math.floor(Date.now() / 1000);
mockGetAsterSession.mockResolvedValue({
mockGetAgentRuntimeSession.mockResolvedValue({
id: "topic-adjacent-dedupe",
execution_strategy: "react",
messages: [
@@ -3044,7 +3136,7 @@ describe("useAsterAgentChat 兼容接口", () => {
it("renameTopic 应调用后端并刷新话题标题", async () => {
const createdAt = Math.floor(Date.now() / 1000);
mockListAsterSessions
mockListAgentRuntimeSessions
.mockResolvedValue([
{
id: "topic-1",
@@ -3098,8 +3190,8 @@ describe("useAsterAgentChat 兼容接口", () => {
},
];
mockListAsterSessions.mockImplementation(async () => currentSessions);
mockDeleteAsterSession.mockImplementation(async () => {
mockListAgentRuntimeSessions.mockImplementation(async () => currentSessions);
mockDeleteAgentRuntimeSession.mockImplementation(async () => {
currentSessions = [];
});
@@ -3113,8 +3205,8 @@ describe("useAsterAgentChat 兼容接口", () => {
await harness.getValue().deleteTopic("topic-1");
});
expect(mockDeleteAsterSession).toHaveBeenCalledTimes(1);
expect(mockDeleteAsterSession).toHaveBeenCalledWith("topic-1");
expect(mockDeleteAgentRuntimeSession).toHaveBeenCalledTimes(1);
expect(mockDeleteAgentRuntimeSession).toHaveBeenCalledWith("topic-1");
const deletedTopic = harness
.getValue()
@@ -7,7 +7,11 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { AsterExecutionStrategy } from "@/lib/api/agentRuntime";
import { defaultAgentRuntimeAdapter } from "./agentRuntimeAdapter";
import { logAgentDebug } from "@/lib/agentDebug";
import {
defaultAgentRuntimeAdapter,
type AgentRuntimeAdapter,
} from "./agentRuntimeAdapter";
import { useAgentContext } from "./useAgentContext";
import { useAgentSession } from "./useAgentSession";
import { useAgentTools } from "./useAgentTools";
@@ -20,15 +24,27 @@ import {
export type { Topic } from "./agentChatShared";
export function useAsterAgentChat(options: UseAsterAgentChatOptions) {
const { systemPrompt, onWriteFile, workspaceId, disableSessionRestore = false } =
options;
const runtime = defaultAgentRuntimeAdapter;
type UseAsterAgentChatRuntimeOptions = UseAsterAgentChatOptions & {
runtimeAdapter?: AgentRuntimeAdapter;
preserveRestoredMessages?: boolean;
};
export function useAsterAgentChat(options: UseAsterAgentChatRuntimeOptions) {
const {
systemPrompt,
onWriteFile,
workspaceId,
disableSessionRestore = false,
runtimeAdapter,
preserveRestoredMessages = false,
} = options;
const runtime = runtimeAdapter ?? defaultAgentRuntimeAdapter;
const [isInitialized, setIsInitialized] = useState(false);
const sessionIdRef = useRef<string | null>(null);
const currentAssistantMsgIdRef = useRef<string | null>(null);
const currentStreamingSessionIdRef = useRef<string | null>(null);
const lastTopicSnapshotKeyRef = useRef<string | null>(null);
const sendMessageRef = useRef<SendMessageFn | null>(null);
const resetPendingActionsRef = useRef<(() => void) | null>(null);
const topicsUpdaterRef = useRef<
@@ -52,7 +68,7 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) {
runtime,
workspaceId,
disableSessionRestore,
isInitialized,
preserveRestoredMessages,
executionStrategy: context.executionStrategy,
providerTypeRef: context.providerTypeRef,
modelRef: context.modelRef,
@@ -71,6 +87,7 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) {
runtime,
sessionIdRef,
currentStreamingSessionIdRef,
messages: session.messages,
setMessages: session.setMessages,
setThreadItems: session.setThreadItems,
});
@@ -103,23 +120,60 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) {
sendMessageRef.current = stream.sendMessage;
topicsUpdaterRef.current = session.updateTopicExecutionStrategy;
const hasActiveTopic = Boolean(
session.sessionId &&
session.topics.some((topic) => topic.id === session.sessionId),
);
useEffect(() => {
logAgentDebug(
"useAsterAgentChat",
"stateSnapshot",
{
hasActiveTopic,
isSending: stream.isSending,
messagesCount: session.messages.length,
pendingActionsCount: tools.pendingActions.length,
queuedTurnsCount: session.queuedTurns.length,
sessionId: session.sessionId ?? null,
threadTurnsCount: session.threadTurns.length,
topicsCount: session.topics.length,
workspaceId,
workspacePathMissing: context.workspacePathMissing,
},
{
dedupeKey: JSON.stringify({
hasActiveTopic,
isSending: stream.isSending,
messagesCount: session.messages.length,
pendingActionsCount: tools.pendingActions.length,
queuedTurnsCount: session.queuedTurns.length,
sessionId: session.sessionId ?? null,
threadTurnsCount: session.threadTurns.length,
topicsCount: session.topics.length,
workspaceId,
workspacePathMissing: context.workspacePathMissing,
}),
throttleMs: 800,
},
);
}, [
context.workspacePathMissing,
hasActiveTopic,
session.messages.length,
session.queuedTurns.length,
session.sessionId,
session.threadTurns.length,
session.topics.length,
stream.isSending,
tools.pendingActions.length,
workspaceId,
]);
useEffect(() => {
tools.warnedKeysRef.current.clear();
}, [tools.warnedKeysRef, workspaceId]);
useEffect(() => {
const init = async () => {
try {
await runtime.init();
setIsInitialized(true);
console.log("[AsterChat] Agent 初始化成功");
} catch (err) {
console.error("[AsterChat] 初始化失败:", err);
}
};
init();
}, [runtime]);
useEffect(() => {
const refreshSessionDetail = session.refreshSessionDetail;
const activeSessionId = session.sessionId;
@@ -131,7 +185,8 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) {
}
const hasRecoveredQueueWork =
queuedTurnCount > 0 || threadTurns.some((turn) => turn.status === "running");
queuedTurnCount > 0 ||
threadTurns.some((turn) => turn.status === "running");
if (!hasRecoveredQueueWork) {
return;
}
@@ -159,14 +214,20 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) {
const pendingActionCount = tools.pendingActions.length;
const workspacePathMissing = context.workspacePathMissing;
if (!activeSessionId) {
return;
}
const hasActiveTopic = session.topics.some(
(topic) => topic.id === activeSessionId,
);
if (!hasActiveTopic) {
if (!activeSessionId || !hasActiveTopic) {
if (activeSessionId && !hasActiveTopic) {
logAgentDebug(
"useAsterAgentChat",
"topicSnapshot.skipWithoutActiveTopic",
{
activeSessionId,
topicsCount: session.topics.length,
workspaceId,
},
{ level: "warn", throttleMs: 1000 },
);
}
lastTopicSnapshotKeyRef.current = null;
return;
}
@@ -178,20 +239,61 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) {
workspaceError: Boolean(workspacePathMissing),
});
const snapshotKey = JSON.stringify({
sessionId: activeSessionId,
updatedAt: snapshot.updatedAt?.getTime() ?? null,
messagesCount: snapshot.messagesCount,
status: snapshot.status,
statusReason: snapshot.statusReason ?? null,
lastPreview: snapshot.lastPreview,
hasUnread: snapshot.hasUnread,
});
if (lastTopicSnapshotKeyRef.current === snapshotKey) {
logAgentDebug(
"useAsterAgentChat",
"topicSnapshot.skipDuplicate",
{
activeSessionId,
snapshotKey,
},
{ throttleMs: 1200 },
);
return;
}
lastTopicSnapshotKeyRef.current = snapshotKey;
logAgentDebug("useAsterAgentChat", "topicSnapshot.apply", {
activeSessionId,
hasUnread: snapshot.hasUnread,
messagesCount: snapshot.messagesCount,
status: snapshot.status,
statusReason: snapshot.statusReason ?? null,
updatedAt: snapshot.updatedAt?.toISOString() ?? null,
});
updateTopicSnapshot(activeSessionId, snapshot);
}, [
hasActiveTopic,
session.sessionId,
session.messages,
session.queuedTurns.length,
session.topics,
session.topics.length,
session.updateTopicSnapshot,
stream.isSending,
tools.pendingActions.length,
context.workspacePathMissing,
workspaceId,
]);
const handleStartProcess = async () => {
// Aster 不需要显式启动独立进程,初始化在 effect 中完成。
try {
await runtime.init();
setIsInitialized(true);
console.log("[AsterChat] Agent 初始化成功");
} catch (err) {
setIsInitialized(false);
console.error("[AsterChat] 初始化失败:", err);
}
};
const handleStopProcess = async () => {
@@ -0,0 +1,149 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const {
getOverview,
getProviders,
getModelRegistry,
getAllAliasConfigs,
syncTrayModelShortcuts,
} = vi.hoisted(() => ({
getOverview: vi.fn(),
getProviders: vi.fn(),
getModelRegistry: vi.fn(),
getAllAliasConfigs: vi.fn(),
syncTrayModelShortcuts: vi.fn(),
}));
vi.mock("@/lib/api/providerPool", () => ({
providerPoolApi: {
getOverview,
},
}));
vi.mock("@/lib/api/apiKeyProvider", () => ({
apiKeyProviderApi: {
getProviders,
},
}));
vi.mock("@/lib/api/modelRegistry", () => ({
modelRegistryApi: {
getModelRegistry,
getAllAliasConfigs,
},
}));
vi.mock("@/lib/api/tray", () => ({
TRAY_MODEL_SELECTED_EVENT: "tray-model-selected",
trayApi: {
syncTrayModelShortcuts,
},
}));
vi.mock("@/lib/constants/providerMappings", () => ({
getAliasConfigKey: (provider: string) => provider,
getProviderLabel: (provider: string) => `label:${provider}`,
getRegistryIdFromType: (provider: string) => provider,
isAliasProvider: () => false,
}));
vi.mock("../utils/modelThemePolicy", () => ({
filterModelsByTheme: (_theme: string | undefined, models: unknown[]) => ({
models,
}),
}));
vi.mock("../utils/providerModelCompatibility", () => ({
getProviderModelCompatibilityIssue: () => null,
}));
import {
buildTrayPayload,
invalidateTrayPayloadCache,
syncTrayModelShortcutsState,
} from "./useTrayModelShortcuts";
describe("buildTrayPayload", () => {
beforeEach(() => {
vi.clearAllMocks();
invalidateTrayPayloadCache();
getOverview.mockResolvedValue([
{
provider_type: "deepseek",
credentials: [{ credential_type: "deepseek" }],
},
]);
getProviders.mockResolvedValue([
{
id: "deepseek",
name: "DeepSeek",
type: "deepseek",
enabled: true,
api_key_count: 1,
custom_models: [],
},
]);
getModelRegistry.mockResolvedValue([
{
id: "deepseek-chat",
display_name: "DeepSeek Chat",
provider_id: "deepseek",
provider_name: "DeepSeek",
is_latest: true,
release_date: "2026-01-01",
},
]);
getAllAliasConfigs.mockResolvedValue({});
syncTrayModelShortcuts.mockResolvedValue(undefined);
});
it("相同签名的 payload 会复用缓存,避免重复拉取数据", async () => {
const first = await buildTrayPayload("deepseek", "deepseek-chat", "general");
const second = await buildTrayPayload(
"deepseek",
"deepseek-chat",
"general",
);
expect(second).toEqual(first);
expect(getOverview).toHaveBeenCalledTimes(1);
expect(getProviders).toHaveBeenCalledTimes(1);
expect(getModelRegistry).toHaveBeenCalledTimes(1);
expect(getAllAliasConfigs).toHaveBeenCalledTimes(1);
});
it("强制刷新会绕过缓存重新拉取数据", async () => {
await buildTrayPayload("deepseek", "deepseek-chat", "general");
await buildTrayPayload("deepseek", "deepseek-chat", "general", {
forceRefresh: true,
});
expect(getOverview).toHaveBeenCalledTimes(2);
expect(getProviders).toHaveBeenCalledTimes(2);
expect(getModelRegistry).toHaveBeenCalledTimes(2);
expect(getAllAliasConfigs).toHaveBeenCalledTimes(2);
});
it("相同 payload 重复同步时应跳过重复托盘写入", async () => {
await syncTrayModelShortcutsState("deepseek", "deepseek-chat", "general");
await syncTrayModelShortcutsState("deepseek", "deepseek-chat", "general");
expect(syncTrayModelShortcuts).toHaveBeenCalledTimes(1);
});
it("首次同步失败时不应缓存成功指纹,后续重试仍应继续同步", async () => {
syncTrayModelShortcuts
.mockRejectedValueOnce(new Error("tray unavailable"))
.mockResolvedValueOnce(undefined);
await expect(
syncTrayModelShortcutsState("deepseek", "deepseek-chat", "general"),
).rejects.toThrow("tray unavailable");
await expect(
syncTrayModelShortcutsState("deepseek", "deepseek-chat", "general"),
).resolves.toBeUndefined();
expect(syncTrayModelShortcuts).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,632 @@
import { useEffect, useRef } from "react";
import { safeListen } from "@/lib/dev-bridge";
import {
TRAY_MODEL_SELECTED_EVENT,
type SyncTrayModelShortcutsPayload,
type TrayModelSelectedPayload,
type TrayQuickModelGroup,
} from "@/lib/api/tray";
import {
apiKeyProviderApi,
type ProviderWithKeysDisplay,
} from "@/lib/api/apiKeyProvider";
import {
providerPoolApi,
type ProviderPoolOverview,
} from "@/lib/api/providerPool";
import { modelRegistryApi } from "@/lib/api/modelRegistry";
import { trayApi } from "@/lib/api/tray";
import {
getAliasConfigKey,
getProviderLabel,
getRegistryIdFromType,
isAliasProvider,
} from "@/lib/constants/providerMappings";
import type {
EnhancedModelMetadata,
ProviderAliasConfig,
} from "@/lib/types/modelRegistry";
import { filterModelsByTheme } from "../utils/modelThemePolicy";
import { getProviderModelCompatibilityIssue } from "../utils/providerModelCompatibility";
interface UseTrayModelShortcutsOptions {
providerType: string;
setProviderType: (type: string) => void;
model: string;
setModel: (model: string) => void;
activeTheme?: string;
}
interface ConfiguredProvider {
key: string;
label: string;
registryId: string;
fallbackRegistryId?: string;
type: string;
providerId?: string;
customModels?: string[];
}
const MAX_TRAY_MODELS_PER_PROVIDER = 8;
const TRAY_PAYLOAD_CACHE_TTL_MS = 3_000;
interface TrayPayloadCacheEntry {
signature: string;
expiresAt: number;
payload: SyncTrayModelShortcutsPayload;
}
interface TrayPayloadInFlight {
signature: string;
promise: Promise<SyncTrayModelShortcutsPayload>;
}
let trayPayloadCache: TrayPayloadCacheEntry | null = null;
let trayPayloadInFlight: TrayPayloadInFlight | null = null;
let lastSyncedTrayPayloadFingerprint: string | null = null;
const THEME_LABEL_MAP: Record<string, string> = {
general: "通用对话",
"social-media": "社媒内容",
poster: "图文海报",
knowledge: "知识探索",
planning: "计划规划",
document: "办公文档",
video: "短视频",
music: "歌词曲谱",
novel: "小说创作",
};
function sortModels(models: EnhancedModelMetadata[]): EnhancedModelMetadata[] {
return [...models].sort((a, b) => {
if (a.is_latest && !b.is_latest) return -1;
if (!a.is_latest && b.is_latest) return 1;
if (a.release_date && b.release_date) {
return b.release_date.localeCompare(a.release_date);
}
if (a.release_date && !b.release_date) return -1;
if (!a.release_date && b.release_date) return 1;
return a.display_name.localeCompare(b.display_name);
});
}
function convertCustomModelsToMetadata(
models: string[],
providerId: string,
providerName: string,
): EnhancedModelMetadata[] {
const now = Date.now() / 1000;
return models.map((modelName) => ({
id: modelName,
display_name: modelName,
provider_id: providerId,
provider_name: providerName,
family: null,
tier: "pro",
capabilities: {
vision: false,
tools: true,
streaming: true,
json_mode: true,
function_calling: true,
reasoning: modelName.includes("thinking"),
},
pricing: null,
limits: {
context_length: null,
max_output_tokens: null,
requests_per_minute: null,
tokens_per_minute: null,
},
status: "active",
release_date: null,
is_latest: false,
description: `自定义模型: ${modelName}`,
source: "custom",
created_at: now,
updated_at: now,
}));
}
function convertAliasModelsToMetadata(
models: string[],
aliasConfig: ProviderAliasConfig,
providerId: string,
providerName: string,
): EnhancedModelMetadata[] {
const now = Date.now() / 1000;
return models.map((modelName) => {
const aliasInfo = aliasConfig.aliases[modelName];
return {
id: modelName,
display_name: modelName,
provider_id: providerId,
provider_name: providerName,
family: aliasInfo?.provider || null,
tier: "pro" as const,
capabilities: {
vision: false,
tools: true,
streaming: true,
json_mode: true,
function_calling: true,
reasoning: modelName.includes("thinking"),
},
pricing: null,
limits: {
context_length: null,
max_output_tokens: null,
requests_per_minute: null,
tokens_per_minute: null,
},
status: "active" as const,
release_date: null,
is_latest: false,
description: aliasInfo?.description || aliasInfo?.actual || modelName,
source: "custom" as const,
created_at: now,
updated_at: now,
};
});
}
function buildConfiguredProviders(
oauthCredentials: ProviderPoolOverview[],
apiKeyProviders: ProviderWithKeysDisplay[],
): ConfiguredProvider[] {
const providerMap = new Map<string, ConfiguredProvider>();
oauthCredentials.forEach((overview) => {
if (overview.credentials.length === 0) {
return;
}
const key = overview.provider_type;
if (providerMap.has(key)) {
return;
}
providerMap.set(key, {
key,
label: getProviderLabel(key),
registryId: getRegistryIdFromType(key),
type: key,
});
});
apiKeyProviders
.filter((provider) => provider.api_key_count > 0 && provider.enabled)
.forEach((provider) => {
let key = provider.id;
let label = provider.name;
if (providerMap.has(key)) {
key = `${provider.id}_api_key`;
label = `${provider.name} API Key`;
}
if (providerMap.has(key)) {
return;
}
providerMap.set(key, {
key,
label,
registryId: provider.id,
fallbackRegistryId: getRegistryIdFromType(provider.type),
type: provider.type,
providerId: provider.id,
customModels: provider.custom_models,
});
});
return Array.from(providerMap.values());
}
function dedupeModels(models: EnhancedModelMetadata[]): EnhancedModelMetadata[] {
const seen = new Set<string>();
const result: EnhancedModelMetadata[] = [];
models.forEach((model) => {
const normalized = model.id.trim().toLowerCase();
if (!normalized || seen.has(normalized)) {
return;
}
seen.add(normalized);
result.push(model);
});
return result;
}
function getTrayPayloadSignature(
providerType: string,
model: string,
activeTheme?: string,
): string {
return [providerType.trim(), model.trim(), activeTheme?.trim() || ""].join(
"|",
);
}
export function invalidateTrayPayloadCache(): void {
trayPayloadCache = null;
trayPayloadInFlight = null;
lastSyncedTrayPayloadFingerprint = null;
}
function getTrayPayloadFingerprint(
payload: SyncTrayModelShortcutsPayload,
): string {
return JSON.stringify(payload);
}
function resolveProviderModels(
provider: ConfiguredProvider,
registryModels: EnhancedModelMetadata[],
aliasConfigs: Record<string, ProviderAliasConfig>,
): EnhancedModelMetadata[] {
const combined: EnhancedModelMetadata[] = [];
if (provider.customModels?.length) {
combined.push(
...convertCustomModelsToMetadata(
provider.customModels,
provider.key,
provider.label,
),
);
}
if (isAliasProvider(provider.key)) {
const aliasConfig = aliasConfigs[getAliasConfigKey(provider.key)];
if (aliasConfig) {
combined.push(
...convertAliasModelsToMetadata(
aliasConfig.models,
aliasConfig,
provider.key,
provider.label,
),
);
}
return dedupeModels(combined);
}
const registryMatches = sortModels(
registryModels.filter((item) => item.provider_id === provider.registryId),
);
if (registryMatches.length > 0) {
combined.push(...registryMatches);
return dedupeModels(combined);
}
if (provider.fallbackRegistryId) {
combined.push(
...sortModels(
registryModels.filter(
(item) => item.provider_id === provider.fallbackRegistryId,
),
),
);
}
return dedupeModels(combined);
}
function resolveThemeLabel(theme?: string): string {
const normalizedTheme = theme?.trim().toLowerCase() || "";
return THEME_LABEL_MAP[normalizedTheme] || "";
}
function buildQuickModelGroups(
providers: ConfiguredProvider[],
registryModels: EnhancedModelMetadata[],
aliasConfigs: Record<string, ProviderAliasConfig>,
providerType: string,
model: string,
activeTheme?: string,
): TrayQuickModelGroup[] {
const groups: TrayQuickModelGroup[] = [];
const currentProviderKey = providerType.trim();
const currentModel = model.trim();
providers.forEach((provider) => {
const resolvedModels = resolveProviderModels(
provider,
registryModels,
aliasConfigs,
);
const filteredModels = filterModelsByTheme(activeTheme, resolvedModels).models;
const compatibleModels = filteredModels
.filter(
(item) =>
!getProviderModelCompatibilityIssue({
providerType: provider.key,
configuredProviderType: provider.type,
model: item.id,
}),
)
.map((item) => item.id);
const prioritizedModels = compatibleModels.filter(Boolean);
if (
provider.key === currentProviderKey &&
currentModel &&
!prioritizedModels.includes(currentModel)
) {
prioritizedModels.unshift(currentModel);
}
const uniqueModels = Array.from(new Set(prioritizedModels)).slice(
0,
MAX_TRAY_MODELS_PER_PROVIDER,
);
if (uniqueModels.length === 0) {
return;
}
groups.push({
provider_type: provider.key,
provider_label: provider.label,
models: uniqueModels.map((item) => ({
provider_type: provider.key,
provider_label: provider.label,
model: item,
})),
});
});
if (
currentProviderKey &&
currentModel &&
!groups.some((group) => group.provider_type === currentProviderKey)
) {
groups.unshift({
provider_type: currentProviderKey,
provider_label: getProviderLabel(currentProviderKey),
models: [
{
provider_type: currentProviderKey,
provider_label: getProviderLabel(currentProviderKey),
model: currentModel,
},
],
});
}
return groups;
}
async function loadTraySource<T>(
loader: () => Promise<T>,
fallbackValue: T,
label: string,
): Promise<T> {
try {
return await loader();
} catch (error) {
console.warn(`[TrayModelShortcuts] 加载${label}失败:`, error);
return fallbackValue;
}
}
export async function buildTrayPayload(
providerType: string,
model: string,
activeTheme?: string,
options?: {
forceRefresh?: boolean;
},
): Promise<SyncTrayModelShortcutsPayload> {
const signature = getTrayPayloadSignature(providerType, model, activeTheme);
const now = Date.now();
const forceRefresh = options?.forceRefresh ?? false;
if (
!forceRefresh &&
trayPayloadCache &&
trayPayloadCache.signature === signature &&
trayPayloadCache.expiresAt > now
) {
return trayPayloadCache.payload;
}
if (
!forceRefresh &&
trayPayloadInFlight &&
trayPayloadInFlight.signature === signature
) {
return trayPayloadInFlight.promise;
}
const payloadPromise = (async () => {
const sourceOptions = forceRefresh ? { forceRefresh: true } : undefined;
const [oauthCredentials, apiKeyProviders, registryModels, aliasConfigs] =
await Promise.all([
loadTraySource(
() => providerPoolApi.getOverview(sourceOptions),
[] as ProviderPoolOverview[],
"OAuth Provider 概览",
),
loadTraySource(
() => apiKeyProviderApi.getProviders(sourceOptions),
[] as ProviderWithKeysDisplay[],
"API Key Provider 列表",
),
loadTraySource(
() => modelRegistryApi.getModelRegistry(sourceOptions),
[] as EnhancedModelMetadata[],
"模型注册表",
),
loadTraySource(
() => modelRegistryApi.getAllAliasConfigs(sourceOptions),
{} as Record<string, ProviderAliasConfig>,
"别名模型配置",
),
]);
const providers = buildConfiguredProviders(oauthCredentials, apiKeyProviders);
const currentProvider =
providers.find((item) => item.key === providerType) || null;
return {
current_model_provider_type: providerType,
current_model_provider_label:
currentProvider?.label || getProviderLabel(providerType),
current_model: model,
current_theme_label: resolveThemeLabel(activeTheme),
quick_model_groups: buildQuickModelGroups(
providers,
registryModels,
aliasConfigs,
providerType,
model,
activeTheme,
),
};
})();
trayPayloadInFlight = {
signature,
promise: payloadPromise,
};
try {
const payload = await payloadPromise;
if (trayPayloadInFlight?.promise === payloadPromise) {
trayPayloadCache = {
signature,
expiresAt: Date.now() + TRAY_PAYLOAD_CACHE_TTL_MS,
payload,
};
}
return payload;
} finally {
if (trayPayloadInFlight?.promise === payloadPromise) {
trayPayloadInFlight = null;
}
}
}
export async function syncTrayModelShortcutsState(
providerType: string,
model: string,
activeTheme?: string,
options?: {
forceRefresh?: boolean;
},
): Promise<void> {
const payload = await buildTrayPayload(
providerType,
model,
activeTheme,
options,
);
const fingerprint = getTrayPayloadFingerprint(payload);
if (!options?.forceRefresh && fingerprint === lastSyncedTrayPayloadFingerprint) {
return;
}
await trayApi.syncTrayModelShortcuts(payload);
lastSyncedTrayPayloadFingerprint = fingerprint;
}
export function useTrayModelShortcuts({
providerType,
setProviderType,
model,
setModel,
activeTheme,
}: UseTrayModelShortcutsOptions) {
const lastSyncedSignatureRef = useRef<string>("");
useEffect(() => {
const normalizedProviderType = providerType.trim();
const normalizedModel = model.trim();
const normalizedTheme = activeTheme?.trim() || "";
if (!normalizedProviderType || !normalizedModel) {
return;
}
const signature = [
normalizedProviderType,
normalizedModel,
normalizedTheme,
].join("|");
if (signature === lastSyncedSignatureRef.current) {
return;
}
lastSyncedSignatureRef.current = signature;
let cancelled = false;
void syncTrayModelShortcutsState(
normalizedProviderType,
normalizedModel,
normalizedTheme || undefined,
).catch((error) => {
if (!cancelled) {
console.warn("[TrayModelShortcuts] 同步托盘模型状态失败:", error);
}
});
return () => {
cancelled = true;
};
}, [activeTheme, model, providerType]);
useEffect(() => {
let cancelled = false;
let dispose: (() => void) | null = null;
safeListen<TrayModelSelectedPayload>(
TRAY_MODEL_SELECTED_EVENT,
(event) => {
if (cancelled) {
return;
}
const nextProviderType = event.payload?.providerType?.trim() || "";
const nextModel = event.payload?.model?.trim() || "";
if (!nextModel) {
return;
}
if (nextProviderType && nextProviderType !== providerType) {
setProviderType(nextProviderType);
}
if (nextModel !== model) {
setModel(nextModel);
}
},
)
.then((unlisten) => {
if (cancelled) {
void unlisten();
return;
}
dispose = unlisten;
})
.catch((error) => {
console.warn("[TrayModelShortcuts] 监听托盘模型切换失败:", error);
});
return () => {
cancelled = true;
if (dispose) {
dispose();
}
};
}, [model, providerType, setModel, setProviderType]);
}
+2 -2
View File
@@ -3605,7 +3605,7 @@ describe("AgentChatPage 自动引导", () => {
gate_key: "write_mode",
status: "running",
source: "chat",
source_ref: "aster_agent_chat_stream",
source_ref: "agent_runtime_submit_turn",
started_at: "2026-03-06T04:00:00.000Z",
},
],
@@ -3622,7 +3622,7 @@ describe("AgentChatPage 自动引导", () => {
await flushEffects(12);
expect(mockSkillExecutionGetDetail).not.toHaveBeenCalledWith(
"aster_agent_chat_stream",
"agent_runtime_submit_turn",
);
});
+416 -91
View File
@@ -58,11 +58,10 @@ import type { TopicBranchStatus } from "./hooks/useTopicBranchBoard";
import { useSessionFiles } from "./hooks/useSessionFiles";
import { useContentSync, type SyncStatus } from "./hooks/useContentSync";
import { getDefaultGuidePromptByTheme } from "./utils/defaultGuidePrompt";
import { useTrayModelShortcuts } from "./hooks/useTrayModelShortcuts";
import { ChatNavbar } from "./components/ChatNavbar";
import { ChatSidebar } from "./components/ChatSidebar";
import {
ThemeWorkbenchSidebar,
} from "./components/ThemeWorkbenchSidebar";
import { ThemeWorkbenchSidebar } from "./components/ThemeWorkbenchSidebar";
import type { ThemeWorkbenchCreationTaskEvent } from "./components/themeWorkbenchWorkflowData";
import { AgentRuntimeStrip } from "./components/AgentRuntimeStrip";
import { HarnessStatusPanel } from "./components/HarnessStatusPanel";
@@ -158,6 +157,7 @@ import {
type ProjectMemory,
type Character,
} from "@/lib/api/memory";
import { logAgentDebug } from "@/lib/agentDebug";
import { browserExecuteAction, launchBrowserSession } from "@/lib/webview-api";
import type { Page, PageParams } from "@/types/page";
import { SettingsTabs } from "@/types/settings";
@@ -166,7 +166,7 @@ import {
buildClawAgentParams,
buildHomeAgentParams,
} from "@/lib/workspace/navigation";
import { useConfiguredProviders } from "@/hooks/useConfiguredProviders";
import { loadConfiguredProviders } from "@/hooks/useConfiguredProviders";
import { useSubAgentScheduler } from "@/hooks/useSubAgentScheduler";
import {
executionRunGet,
@@ -182,7 +182,7 @@ import { recordWorkspaceRepair } from "@/lib/workspaceHealthTelemetry";
import { listMaterials, uploadMaterial } from "@/lib/api/materials";
import { setStoredResourceProjectId } from "@/lib/resourceProjectSelection";
import { resolveProviderModelCompatibility } from "./utils/providerModelCompatibility";
import { useProviderModels } from "@/hooks/useProviderModels";
import { loadProviderModels } from "@/hooks/useProviderModels";
import {
isReasoningModel,
resolveBaseModelOnThinkingOff,
@@ -614,21 +614,33 @@ function mergeMessageArtifactsIntoStore(
]);
}
const PageContainer = styled.div`
const PageContainer = styled.div<{ $compact?: boolean }>`
display: flex;
height: 100%;
width: 100%;
position: relative;
min-height: 0;
gap: 14px;
padding: 14px;
gap: ${({ $compact }) => ($compact ? "8px" : "14px")};
padding: ${({ $compact }) => ($compact ? "8px" : "14px")};
box-sizing: border-box;
overflow: hidden;
isolation: isolate;
background:
radial-gradient(circle at 14% 18%, rgba(56, 189, 248, 0.1), transparent 30%),
radial-gradient(circle at 86% 14%, rgba(16, 185, 129, 0.08), transparent 28%),
radial-gradient(circle at 72% 84%, rgba(245, 158, 11, 0.06), transparent 24%),
radial-gradient(
circle at 14% 18%,
rgba(56, 189, 248, 0.1),
transparent 30%
),
radial-gradient(
circle at 86% 14%,
rgba(16, 185, 129, 0.08),
transparent 28%
),
radial-gradient(
circle at 72% 84%,
rgba(245, 158, 11, 0.06),
transparent 24%
),
linear-gradient(
180deg,
rgba(248, 250, 252, 0.98) 0%,
@@ -642,7 +654,7 @@ const PageContainer = styled.div`
}
`;
const MainArea = styled.div`
const MainArea = styled.div<{ $compact?: boolean }>`
display: flex;
flex-direction: column;
flex: 1;
@@ -651,14 +663,13 @@ const MainArea = styled.div`
overflow: hidden;
position: relative;
border: 1px solid rgba(226, 232, 240, 0.88);
border-radius: 32px;
background:
linear-gradient(
180deg,
rgba(255, 255, 255, 0.96) 0%,
rgba(248, 250, 252, 0.94) 56%,
rgba(248, 250, 252, 0.88) 100%
);
border-radius: ${({ $compact }) => ($compact ? "24px" : "32px")};
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.96) 0%,
rgba(248, 250, 252, 0.94) 56%,
rgba(248, 250, 252, 0.88) 100%
);
box-shadow:
0 24px 72px -36px rgba(15, 23, 42, 0.18),
0 16px 28px -24px rgba(15, 23, 42, 0.1),
@@ -743,13 +754,12 @@ const ChatContainerInner = styled.div`
min-height: 0;
height: 100%;
overflow: hidden;
background:
linear-gradient(
180deg,
rgba(248, 250, 252, 0.78) 0%,
rgba(255, 255, 255, 0.12) 18%,
rgba(255, 255, 255, 0) 100%
);
background: linear-gradient(
180deg,
rgba(248, 250, 252, 0.78) 0%,
rgba(255, 255, 255, 0.12) 18%,
rgba(255, 255, 255, 0) 100%
);
`;
const EntryBanner = styled.div`
@@ -760,12 +770,11 @@ const EntryBanner = styled.div`
padding: 10px 12px;
border-radius: 18px;
border: 1px solid rgba(191, 219, 254, 0.9);
background:
linear-gradient(
180deg,
rgba(239, 246, 255, 0.96) 0%,
rgba(248, 250, 252, 0.92) 100%
);
background: linear-gradient(
180deg,
rgba(239, 246, 255, 0.96) 0%,
rgba(248, 250, 252, 0.92) 100%
);
color: #0f172a;
font-size: 13px;
box-shadow: 0 10px 22px -20px rgba(15, 23, 42, 0.16);
@@ -780,12 +789,12 @@ const EntryBannerClose = styled.button`
font-size: 13px;
`;
const ChatContent = styled.div`
const ChatContent = styled.div<{ $compact?: boolean }>`
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
padding: 0 10px 10px;
padding: ${({ $compact }) => ($compact ? "0 6px 6px" : "0 10px 10px")};
overflow: hidden;
height: 100%;
position: relative;
@@ -916,12 +925,11 @@ const ThemeWorkbenchLeftExpandButton = styled.button`
height: 78px;
border: 1px solid rgba(226, 232, 240, 0.92);
border-radius: 14px;
background:
linear-gradient(
180deg,
rgba(255, 255, 255, 0.94) 0%,
rgba(248, 250, 252, 0.9) 100%
);
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.94) 0%,
rgba(248, 250, 252, 0.9) 100%
);
color: #64748b;
display: inline-flex;
align-items: center;
@@ -933,12 +941,11 @@ const ThemeWorkbenchLeftExpandButton = styled.button`
&:hover {
color: #0f172a;
border-color: rgba(148, 163, 184, 0.84);
background:
linear-gradient(
180deg,
rgba(255, 255, 255, 0.98) 0%,
rgba(241, 245, 249, 0.92) 100%
);
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.98) 0%,
rgba(241, 245, 249, 0.92) 100%
);
}
`;
@@ -2456,6 +2463,39 @@ export function AgentChatPage({
externalProjectId ??
(shouldResetToFreshHomeContext ? undefined : internalProjectId) ??
undefined;
const pageMountedAtRef = useRef(Date.now());
useEffect(() => {
const mountedAt = pageMountedAtRef.current;
logAgentDebug("AgentChatPage", "mount", {
agentEntry,
contentId: contentId ?? null,
externalProjectId: externalProjectId ?? null,
initialCreationMode: initialCreationMode ?? null,
initialTheme: initialTheme ?? null,
lockTheme,
});
return () => {
logAgentDebug(
"AgentChatPage",
"unmount",
{
contentId: contentId ?? null,
externalProjectId: externalProjectId ?? null,
lifetimeMs: Date.now() - mountedAt,
},
{ consoleOnly: true },
);
};
}, [
agentEntry,
contentId,
externalProjectId,
initialCreationMode,
initialTheme,
lockTheme,
]);
// 画布状态(支持多种画布类型)
const [canvasState, setCanvasState] = useState<CanvasStateUnion | null>(
@@ -2603,7 +2643,7 @@ export function AgentChatPage({
} | null>(null);
const isResolvingTopicProjectRef = useRef(false);
// 文件写入回调 ref(用于传递给 useAgentChat)
// 文件写入回调 ref(用于传递给统一聊天主链 Hook)
const handleWriteFileRef =
useRef<
(
@@ -2748,23 +2788,47 @@ export function AgentChatPage({
_onNavigate?.("settings", { tab: SettingsTabs.Skills });
}, [_onNavigate]);
const loadSkills = useCallback(async (): Promise<Skill[]> => {
setSkillsLoading(true);
try {
const loadedSkills = await skillsApi.getAll("lime");
setSkills(loadedSkills);
return loadedSkills;
} catch (error) {
console.warn("[AgentChatPage] 加载 skills 失败:", error);
setSkills([]);
return [];
} finally {
setSkillsLoading(false);
}
}, []);
const loadSkills = useCallback(
async (includeRemote = false): Promise<Skill[]> => {
const startedAt = Date.now();
logAgentDebug("AgentChatPage", "loadSkills.start", {
includeRemote,
});
setSkillsLoading(true);
try {
const loadedSkills = includeRemote
? await skillsApi.getAll("lime")
: await skillsApi.getLocal("lime");
logAgentDebug("AgentChatPage", "loadSkills.success", {
durationMs: Date.now() - startedAt,
includeRemote,
skillsCount: loadedSkills.length,
});
setSkills(loadedSkills);
return loadedSkills;
} catch (error) {
console.warn("[AgentChatPage] 加载 skills 失败:", error);
logAgentDebug(
"AgentChatPage",
"loadSkills.error",
{
durationMs: Date.now() - startedAt,
error,
includeRemote,
},
{ level: "warn" },
);
setSkills([]);
return [];
} finally {
setSkillsLoading(false);
}
},
[],
);
const handleRefreshSkills = useCallback(async () => {
await loadSkills();
await loadSkills(true);
}, [loadSkills]);
// 加载项目、Memory 和内容
@@ -2772,6 +2836,13 @@ export function AgentChatPage({
let cancelled = false;
const loadData = async () => {
const startedAt = Date.now();
logAgentDebug("AgentChatPage", "loadData.start", {
contentId: contentId ?? null,
lockTheme,
projectId: projectId ?? null,
});
if (contentId) {
setIsInitialContentLoading(true);
setInitialContentLoadError(null);
@@ -2784,6 +2855,10 @@ export function AgentChatPage({
if (cancelled) {
return;
}
logAgentDebug("AgentChatPage", "loadData.noProject", {
contentId: contentId ?? null,
durationMs: Date.now() - startedAt,
});
setProject(null);
setProjectMemory(null);
setIsInitialContentLoading(false);
@@ -2796,6 +2871,16 @@ export function AgentChatPage({
if (cancelled) {
return;
}
logAgentDebug(
"AgentChatPage",
"loadData.projectMissing",
{
contentId: contentId ?? null,
durationMs: Date.now() - startedAt,
projectId,
},
{ level: "warn" },
);
setProject(null);
setProjectMemory(null);
if (contentId) {
@@ -2810,6 +2895,12 @@ export function AgentChatPage({
setProject(p);
const theme = projectTypeToTheme(p.workspaceType);
logAgentDebug("AgentChatPage", "loadData.projectLoaded", {
durationMs: Date.now() - startedAt,
projectId: p.id,
theme,
workspaceType: p.workspaceType,
});
if (!lockTheme || !initialTheme) {
setActiveTheme(theme);
}
@@ -2819,8 +2910,19 @@ export function AgentChatPage({
return;
}
setProjectMemory(memory);
logAgentDebug("AgentChatPage", "loadData.memoryLoaded", {
charactersCount: memory?.characters?.length ?? 0,
durationMs: Date.now() - startedAt,
hasOutline: Boolean(memory?.outline?.length),
hasStyleGuide: Boolean(memory?.style_guide),
projectId,
});
if (!contentId) {
logAgentDebug("AgentChatPage", "loadData.projectOnlyComplete", {
durationMs: Date.now() - startedAt,
projectId,
});
return;
}
@@ -2830,10 +2932,27 @@ export function AgentChatPage({
}
if (!content) {
logAgentDebug(
"AgentChatPage",
"loadData.contentMissing",
{
contentId,
durationMs: Date.now() - startedAt,
projectId,
},
{ level: "warn" },
);
setInitialContentLoadError("文稿不存在或读取失败");
return;
}
logAgentDebug("AgentChatPage", "loadData.contentLoaded", {
bodyLength: content.body?.length ?? 0,
contentId: content.id,
durationMs: Date.now() - startedAt,
projectId,
});
contentMetadataRef.current = content.metadata || {};
const canvasTheme = (
lockTheme && initialTheme
@@ -2865,8 +2984,23 @@ export function AgentChatPage({
"[AgentChatPage] 读取主题工作台版本状态失败,降级为 metadata 解析:",
error,
);
logAgentDebug(
"AgentChatPage",
"loadData.documentStateError",
{
contentId: content.id,
durationMs: Date.now() - startedAt,
error,
},
{ level: "warn" },
);
return null;
});
logAgentDebug("AgentChatPage", "loadData.documentStateLoaded", {
contentId: content.id,
durationMs: Date.now() - startedAt,
hasBackendDocumentState: Boolean(backendDocumentState),
});
const backendApplied = backendDocumentState
? applyBackendThemeWorkbenchDocumentState(
initialState,
@@ -2913,8 +3047,25 @@ export function AgentChatPage({
};
setCanvasState(initialState);
setLayoutMode("canvas");
logAgentDebug("AgentChatPage", "loadData.complete", {
contentId: content.id,
durationMs: Date.now() - startedAt,
initialStateType: initialState.type,
projectId,
});
} catch (error) {
console.error("[AgentChatPage] 加载项目或文稿失败:", error);
logAgentDebug(
"AgentChatPage",
"loadData.error",
{
contentId: contentId ?? null,
durationMs: Date.now() - startedAt,
error,
projectId: projectId ?? null,
},
{ level: "error" },
);
if (!cancelled && contentId) {
setInitialContentLoadError("文稿加载失败,请稍后重试");
}
@@ -2957,6 +3108,10 @@ export function AgentChatPage({
const normalizedId = normalizeProjectId(projectId);
if (!normalizedId) return;
const startedAt = Date.now();
logAgentDebug("AgentChatPage", "workspaceCheck.start", {
projectId: normalizedId,
});
ensureWorkspaceReady(normalizedId)
.then(({ repaired, rootPath }) => {
if (repaired) {
@@ -2967,10 +3122,26 @@ export function AgentChatPage({
});
console.info("[AgentChatPage] workspace 目录已自动修复:", rootPath);
}
logAgentDebug("AgentChatPage", "workspaceCheck.success", {
durationMs: Date.now() - startedAt,
projectId: normalizedId,
repaired,
rootPath,
});
})
.catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err);
console.warn("[AgentChatPage] workspace 目录检查失败:", message);
logAgentDebug(
"AgentChatPage",
"workspaceCheck.error",
{
durationMs: Date.now() - startedAt,
error: err,
projectId: normalizedId,
},
{ level: "warn" },
);
setWorkspaceHealthError(true);
});
}, [projectId]);
@@ -3107,6 +3278,59 @@ export function AgentChatPage({
workspaceId: projectId ?? "",
disableSessionRestore: isNewTaskEntry,
});
useEffect(() => {
logAgentDebug(
"AgentChatPage",
"stateSnapshot",
{
activeTheme,
contentId: contentId ?? null,
initialContentLoadError: initialContentLoadError ?? null,
isInitialContentLoading,
isSending,
layoutMode,
messagesCount: messages.length,
projectId: projectId ?? null,
sessionId: sessionId ?? null,
skillsCount: skills.length,
skillsLoading,
topicsCount: topics.length,
workspaceHealthError,
},
{
dedupeKey: JSON.stringify({
activeTheme,
contentId: contentId ?? null,
initialContentLoadError: initialContentLoadError ?? null,
isInitialContentLoading,
isSending,
layoutMode,
messagesCount: messages.length,
projectId: projectId ?? null,
sessionId: sessionId ?? null,
skillsCount: skills.length,
skillsLoading,
topicsCount: topics.length,
workspaceHealthError,
}),
throttleMs: 800,
},
);
}, [
activeTheme,
contentId,
initialContentLoadError,
isInitialContentLoading,
isSending,
layoutMode,
messages.length,
projectId,
sessionId,
skills.length,
skillsLoading,
topics.length,
workspaceHealthError,
]);
const settledLiveArtifact = useMemo(
() =>
settleLiveArtifactAfterStreamStops(liveArtifact, {
@@ -3174,7 +3398,6 @@ export function AgentChatPage({
activeTheme === "general" &&
layoutMode !== "chat" &&
currentCanvasArtifact?.type === "browser_assist";
const { providers: configuredProviders } = useConfiguredProviders();
const subAgentRuntime = useSubAgentScheduler(sessionId);
const syntheticSubagentItems = useMemo(
() =>
@@ -3211,14 +3434,19 @@ export function AgentChatPage({
const [harnessPanelVisible, setHarnessPanelVisible] = useState(() =>
loadPersistedBoolean(HARNESS_PANEL_VISIBILITY_KEY, false),
);
const selectedProvider = useMemo(
() => configuredProviders.find((provider) => provider.key === providerType),
[configuredProviders, providerType],
);
const { models: providerModels } = useProviderModels(selectedProvider, {
returnFullMetadata: true,
});
const thinkingVariantWarnedRef = useRef<Set<string>>(new Set());
const resolveSendProviderContext = useCallback(async () => {
const configuredProviders = await loadConfiguredProviders();
const selectedProvider =
configuredProviders.find((provider) => provider.key === providerType) ||
null;
const providerModels = await loadProviderModels(selectedProvider);
return {
selectedProvider,
providerModels,
};
}, [providerType]);
useEffect(() => {
onSessionChange?.(sessionId ?? null);
@@ -3362,7 +3590,7 @@ export function AgentChatPage({
}, []);
useEffect(() => {
void loadSkills();
void loadSkills(false);
}, [loadSkills]);
// 主题工作台模式:同步 skills 状态到 store
@@ -4744,23 +4972,54 @@ export function AgentChatPage({
const runTopicSwitch = useCallback(
async (topicId: string) => {
console.log("[AgentChatPage] switchTopic 包装函数被调用:", topicId);
const startedAt = Date.now();
logAgentDebug("AgentChatPage", "runTopicSwitch.start", {
currentProjectId: projectId ?? null,
topicId,
});
resetTopicLocalState();
console.log("[AgentChatPage] 调用 originalSwitchTopic");
await originalSwitchTopic(topicId);
console.log("[AgentChatPage] originalSwitchTopic 完成");
try {
await originalSwitchTopic(topicId);
logAgentDebug("AgentChatPage", "runTopicSwitch.success", {
durationMs: Date.now() - startedAt,
topicId,
});
} catch (error) {
logAgentDebug(
"AgentChatPage",
"runTopicSwitch.error",
{
durationMs: Date.now() - startedAt,
error,
topicId,
},
{ level: "error" },
);
throw error;
}
},
[originalSwitchTopic, resetTopicLocalState],
[originalSwitchTopic, projectId, resetTopicLocalState],
);
const switchTopic = useCallback(
async (topicId: string) => {
if (isResolvingTopicProjectRef.current) {
logAgentDebug(
"AgentChatPage",
"switchTopic.skipWhileResolving",
{ topicId },
{ level: "warn", throttleMs: 1000 },
);
return;
}
isResolvingTopicProjectRef.current = true;
try {
logAgentDebug("AgentChatPage", "switchTopic.start", {
currentProjectId: projectId ?? null,
externalProjectId: externalProjectId ?? null,
topicId,
});
const decision = await resolveTopicSwitchProject({
lockedProjectId: externalProjectId ?? null,
topicBoundProjectId: loadPersistedProjectId(
@@ -4786,6 +5045,12 @@ export function AgentChatPage({
: null;
},
});
logAgentDebug("AgentChatPage", "switchTopic.decision", {
createdDefault: decision.createdDefault,
decisionStatus: decision.status,
projectId: decision.status === "ready" ? decision.projectId : null,
topicId,
});
if (decision.status === "blocked") {
toast.error("该任务绑定了其他项目,请先切换到对应项目");
@@ -4807,6 +5072,11 @@ export function AgentChatPage({
const currentProjectId = normalizeProjectId(projectId);
if (currentProjectId !== targetProjectId) {
pendingTopicSwitchRef.current = { topicId, targetProjectId };
logAgentDebug("AgentChatPage", "switchTopic.deferUntilProjectReady", {
currentProjectId,
targetProjectId,
topicId,
});
setInternalProjectId(targetProjectId);
return;
}
@@ -4814,6 +5084,16 @@ export function AgentChatPage({
await runTopicSwitch(topicId);
} catch (error) {
console.error("[AgentChatPage] 解析任务项目失败:", error);
logAgentDebug(
"AgentChatPage",
"switchTopic.error",
{
error,
projectId: projectId ?? null,
topicId,
},
{ level: "error" },
);
toast.error("切换任务失败,请稍后重试");
} finally {
isResolvingTopicProjectRef.current = false;
@@ -4822,6 +5102,14 @@ export function AgentChatPage({
[externalProjectId, projectId, runTopicSwitch],
);
useTrayModelShortcuts({
providerType,
setProviderType,
model,
setModel,
activeTheme: mappedTheme,
});
useEffect(() => {
const pending = pendingTopicSwitchRef.current;
if (!pending) {
@@ -4834,8 +5122,22 @@ export function AgentChatPage({
}
pendingTopicSwitchRef.current = null;
logAgentDebug("AgentChatPage", "switchTopic.resumePending", {
projectId: currentProjectId,
topicId: pending.topicId,
});
runTopicSwitch(pending.topicId).catch((error) => {
console.error("[AgentChatPage] 执行待切换任务失败:", error);
logAgentDebug(
"AgentChatPage",
"switchTopic.resumePendingError",
{
error,
projectId: currentProjectId,
topicId: pending.topicId,
},
{ level: "error" },
);
toast.error("加载任务失败,请重试");
});
}, [projectId, runTopicSwitch]);
@@ -5240,6 +5542,8 @@ export function AgentChatPage({
setMentionedCharacters([]); // 清空引用的角色
try {
const { selectedProvider, providerModels } =
await resolveSendProviderContext();
const memoryParams = {
scope: "aster" as const,
workspaceId: projectId,
@@ -5415,10 +5719,9 @@ export function AgentChatPage({
model,
_onNavigate,
projectId,
providerModels,
providerType,
resolveSendProviderContext,
runtimeStyleMessagePrompt,
selectedProvider?.type,
sendMessage,
sessionId,
setModel,
@@ -7403,7 +7706,7 @@ export function AgentChatPage({
],
);
// 更新 ref,供 useAgentChat 使用
// 更新 ref,供统一聊天主链 Hook 使用
useEffect(() => {
handleWriteFileRef.current = handleWriteFile;
}, [handleWriteFile]);
@@ -8973,6 +9276,10 @@ export function AgentChatPage({
const shouldRenderInlineA2UI = isContentCreationMode;
const shouldShowClawEmptyState =
!isNewTaskEntry && !showChatLayout && !isThemeWorkbench && !sessionId;
const isWorkspaceCompactChrome = topBarChrome === "workspace-compact";
const shouldRenderBrandedEmptyState =
!showChatLayout && !shouldShowClawEmptyState;
const shouldRenderTopBar = !hideTopBar && !shouldRenderBrandedEmptyState;
// 聊天区域内容
const chatContent = useMemo(
@@ -9016,7 +9323,7 @@ export function AgentChatPage({
/>
) : null}
{showChatLayout ? (
<ChatContent>
<ChatContent $compact={isWorkspaceCompactChrome}>
<>
{contextWorkspace.enabled ? (
<MessageViewport>
@@ -9176,6 +9483,13 @@ export function AgentChatPage({
onRefreshSkills={handleRefreshSkills}
onLaunchBrowserAssist={handleOpenBrowserAssistInCanvas}
browserAssistLoading={browserAssistLaunching}
projectId={projectId ?? null}
onProjectChange={handleProjectChange}
onOpenSettings={() => {
_onNavigate?.("settings", {
tab: SettingsTabs.Appearance,
});
}}
/>
)}
@@ -9216,6 +9530,7 @@ export function AgentChatPage({
</ChatContainer>
),
[
_onNavigate,
activeTheme,
artifacts.length,
browserAssistLaunching,
@@ -9241,6 +9556,7 @@ export function AgentChatPage({
handleManageProviders,
handleNavigateToSkillSettings,
handleOpenBrowserAssistInCanvas,
handleProjectChange,
handleRefreshSkills,
handlePermissionResponseWithBrowserPreflight,
handleSelectWorkspaceDirectory,
@@ -9250,6 +9566,7 @@ export function AgentChatPage({
input,
inputbarNode,
isContentCreationMode,
isWorkspaceCompactChrome,
isThemeWorkbench,
lockTheme,
displayMessages,
@@ -9340,8 +9657,8 @@ export function AgentChatPage({
const mainAreaNode = useMemo(
() => (
<MainArea>
{!hideTopBar && (
<MainArea $compact={isWorkspaceCompactChrome}>
{shouldRenderTopBar && (
<>
<ChatNavbar
isRunning={isSending}
@@ -9397,7 +9714,9 @@ export function AgentChatPage({
}
/>
{!isThemeWorkbench && contentId && syncStatus !== "idle" && (
{!isThemeWorkbench &&
contentId &&
syncStatus !== "idle" &&
(() => {
const notice = resolveContentSyncNotice(syncStatus);
const NoticeIcon = notice.Icon;
@@ -9405,13 +9724,18 @@ export function AgentChatPage({
return (
<ContentSyncNotice $status={syncStatus}>
<NoticeIcon
className={notice.animated ? "h-3.5 w-3.5 animate-spin" : "h-3.5 w-3.5"}
className={
notice.animated
? "h-3.5 w-3.5 animate-spin"
: "h-3.5 w-3.5"
}
/>
<ContentSyncNoticeText>{notice.label}</ContentSyncNoticeText>
<ContentSyncNoticeText>
{notice.label}
</ContentSyncNoticeText>
</ContentSyncNotice>
);
})()
)}
})()}
</>
)}
@@ -9479,10 +9803,10 @@ export function AgentChatPage({
handleToggleCanvas,
handleToggleSidebar,
hideHistoryToggle,
hideTopBar,
inputbarNode,
isSending,
isNewTaskEntry,
isWorkspaceCompactChrome,
isThemeWorkbench,
chatMode,
generalWorkbenchDialog,
@@ -9500,6 +9824,7 @@ export function AgentChatPage({
showChatPanel,
showHarnessToggle,
showNovelNavbarControls,
shouldRenderTopBar,
syncStatus,
themeWorkbenchHarnessDialog,
themeWorkbenchRunState,
@@ -9509,10 +9834,10 @@ export function AgentChatPage({
// ========== 渲染逻辑 ==========
// 所有主题统一使用 useAgentChat 的状态和渲染逻辑
// 所有主题统一使用 useAgentChatUnified / useAsterAgentChat 的状态和渲染逻辑
// General 主题与其他主题的区别仅在于不显示步骤进度条
return (
<PageContainer>
<PageContainer $compact={isWorkspaceCompactChrome}>
{isThemeWorkbench ? (
themeWorkbenchSidebarNode
) : showChatPanel && showSidebar ? (
+5 -4
View File
@@ -1,13 +1,14 @@
import styled from "styled-components";
import { ScrollArea } from "@/components/ui/scroll-area";
export const Navbar = styled.div`
export const Navbar = styled.div<{ $compact?: boolean }>`
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 16px 10px;
min-height: 64px;
gap: ${({ $compact }) => ($compact ? "8px" : "12px")};
padding: ${({ $compact }) =>
$compact ? "7px 10px 7px" : "12px 16px 10px"};
min-height: ${({ $compact }) => ($compact ? "50px" : "64px")};
border-bottom: 1px solid rgba(226, 232, 240, 0.88);
background:
linear-gradient(
@@ -13,7 +13,7 @@ describe("streamDiagnostics", () => {
});
it("开始流后应写入基础上下文", () => {
const reporter = createStreamDiagnosticsReporter("useAgentChat");
const reporter = createStreamDiagnosticsReporter("useAsterAgentChat");
reporter.start({
sessionId: "session-1",
eventName: "agent_stream_1",
@@ -24,7 +24,7 @@ describe("streamDiagnostics", () => {
expect(updateCrashContextMock).toHaveBeenCalledTimes(1);
expect(updateCrashContextMock.mock.calls[0]?.[0]).toMatchObject({
agent_stream_diag: expect.objectContaining({
component: "useAgentChat",
component: "useAsterAgentChat",
sessionId: "session-1",
eventName: "agent_stream_1",
assistantMessageId: "assistant-1",
@@ -34,7 +34,7 @@ describe("streamDiagnostics", () => {
});
it("遇到关键事件应立即刷新上下文", () => {
const reporter = createStreamDiagnosticsReporter("useAgentChat");
const reporter = createStreamDiagnosticsReporter("useAsterAgentChat");
reporter.start({
sessionId: "session-1",
eventName: "agent_stream_1",
@@ -72,7 +72,7 @@ describe("streamDiagnostics", () => {
});
it("tool_end 缺少 output 时不应抛错", () => {
const reporter = createStreamDiagnosticsReporter("useAgentChat");
const reporter = createStreamDiagnosticsReporter("useAsterAgentChat");
reporter.start({
sessionId: "session-1",
eventName: "agent_stream_1",
@@ -94,7 +94,7 @@ describe("streamDiagnostics", () => {
});
it("解析失败时应记录 invalid 事件", () => {
const reporter = createStreamDiagnosticsReporter("useAgentChat");
const reporter = createStreamDiagnosticsReporter("useAsterAgentChat");
reporter.start({
sessionId: "session-1",
eventName: "agent_stream_1",
@@ -63,7 +63,6 @@ const Content = styled.div`
display: flex;
flex: 1;
min-height: 0;
gap: 16px;
`;
const ChapterList = styled.div`
@@ -72,8 +71,9 @@ const ChapterList = styled.div`
display: flex;
flex-direction: column;
background: hsl(var(--background));
border-radius: 12px;
border-radius: 12px 0 0 12px;
border: 1px solid hsl(var(--border));
border-right: none;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
`;
@@ -162,13 +162,14 @@ const ChapterMeta = styled.div`
margin-top: 6px;
`;
const EditorArea = styled.div`
const EditorArea = styled.div<{ $withSidebar: boolean }>`
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
background: hsl(var(--background));
border-radius: 12px;
border-radius: ${({ $withSidebar }) =>
$withSidebar ? "0 12px 12px 0" : "12px"};
border: 1px solid hsl(var(--border));
overflow: hidden;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
@@ -588,7 +589,7 @@ export const NovelCanvas: React.FC<NovelCanvasProps> = memo(
</ChapterList>
)}
<EditorArea>
<EditorArea $withSidebar={!isChapterListCollapsed}>
{!useExternalToolbar && isChapterListCollapsed && (
<div className="absolute bottom-2 left-2 z-10 flex items-center gap-1 rounded-md border bg-background/90 p-1 shadow-sm">
<Button
@@ -123,7 +123,6 @@ const Body = styled.div`
flex: 1;
min-height: 0;
width: 100%;
gap: 12px;
@media (max-width: 1100px) {
flex-direction: column;
@@ -135,9 +134,13 @@ const SidebarContainer = styled.div<{ $collapsed: boolean }>`
flex-shrink: 0;
height: 100%;
min-height: 0;
background: hsl(var(--background) / 0.72);
border-radius: 28px;
border: 1px solid hsl(var(--border) / 0.7);
background: transparent;
border-radius: ${({ $collapsed }) =>
$collapsed ? "0" : "28px 0 0 28px"};
border: 1px solid
${({ $collapsed }) =>
$collapsed ? "transparent" : "hsl(var(--border) / 0.7)"};
border-right: none;
box-shadow:
0 16px 40px hsl(215 40% 10% / 0.06),
inset 0 1px 0 hsl(0 0% 100% / 0.72);
@@ -154,24 +157,30 @@ const SidebarContainer = styled.div<{ $collapsed: boolean }>`
width: 100%;
height: auto;
max-height: ${({ $collapsed }) => ($collapsed ? "0px" : "780px")};
border-radius: ${({ $collapsed }) => ($collapsed ? "0" : "28px")};
border-right: 1px solid
${({ $collapsed }) =>
$collapsed ? "transparent" : "hsl(var(--border) / 0.7)"};
}
`;
const Splitter = styled.div`
width: 18px;
display: flex;
justify-content: center;
align-items: flex-start;
position: relative;
width: 0;
flex-shrink: 0;
z-index: 2;
@media (max-width: 1100px) {
width: 100%;
justify-content: flex-start;
height: 0;
}
`;
const SplitterButton = styled.button`
margin-top: 12px;
position: absolute;
top: 14px;
left: 0;
transform: translateX(-50%);
width: 32px;
height: 48px;
border-radius: 999px;
@@ -198,7 +207,9 @@ const SplitterButton = styled.button`
}
@media (max-width: 1100px) {
margin-top: 0;
top: 0;
left: 18px;
transform: translateY(-50%);
width: 48px;
height: 32px;
}
@@ -208,11 +219,7 @@ const MainContainer = styled.div`
flex: 1;
height: 100%;
min-height: 0;
background: linear-gradient(
180deg,
hsl(var(--background) / 0.94),
hsl(200 48% 98% / 0.94)
);
background: transparent;
overflow: hidden;
position: relative;
`;
@@ -221,13 +228,17 @@ const WorkspaceFrame = styled.div`
flex: 1;
min-height: 0;
display: flex;
border-radius: 32px;
border-radius: 0 32px 32px 0;
border: 1px solid hsl(var(--border) / 0.75);
background: hsl(var(--background) / 0.84);
background: transparent;
box-shadow:
0 20px 48px hsl(215 32% 12% / 0.07),
inset 0 1px 0 hsl(0 0% 100% / 0.7);
overflow: hidden;
@media (max-width: 1100px) {
border-radius: 32px;
}
`;
export const VideoCanvas: React.FC<VideoCanvasProps> = memo(
@@ -30,11 +30,7 @@ const SidebarWrapper = styled.div`
display: flex;
flex-direction: column;
gap: 14px;
background: linear-gradient(
180deg,
hsl(var(--background) / 0.84),
hsl(201 42% 98% / 0.72)
);
background: transparent;
@media (max-width: 1100px) {
padding: 16px;
@@ -14,7 +14,8 @@ const {
}));
vi.mock("@/hooks/useConfiguredProviders", () => ({
useConfiguredProviders: () => mockUseConfiguredProviders(),
useConfiguredProviders: (...args: unknown[]) =>
mockUseConfiguredProviders(...args),
}));
vi.mock("@/hooks/useProviderModels", () => ({
@@ -110,14 +111,39 @@ afterEach(() => {
});
describe("ModelSelector", () => {
it("应在 codex 不兼容模型被选中时自动回退到兼容模型", () => {
const setModel = vi.fn();
it("关闭状态下应延后加载模型选择数据", () => {
renderModelSelector();
renderModelSelector({
expect(mockUseConfiguredProviders).toHaveBeenCalledWith({
autoLoad: false,
});
expect(mockUseProviderModels).toHaveBeenCalledWith(
expect.objectContaining({ key: "custom-codex" }),
expect.objectContaining({
returnFullMetadata: true,
autoLoad: false,
}),
);
});
it("打开选择器后应加载数据并回退到兼容模型", () => {
const setModel = vi.fn();
const { container } = renderModelSelector({
model: "gpt-5.3-codex",
setModel,
});
const trigger = container.querySelector(
'button[role="combobox"]',
) as HTMLButtonElement | null;
if (!trigger) {
throw new Error("未找到模型选择触发器");
}
act(() => {
trigger.click();
});
expect(setModel).toHaveBeenCalledWith("gpt-5.2-codex");
});
});
+57 -9
View File
@@ -20,9 +20,10 @@ import { useConfiguredProviders } from "@/hooks/useConfiguredProviders";
import { useProviderModels } from "@/hooks/useProviderModels";
import { filterModelsByTheme } from "@/components/agent/chat/utils/modelThemePolicy";
import { getProviderModelCompatibilityIssue } from "@/components/agent/chat/utils/providerModelCompatibility";
import { getProviderLabel } from "@/lib/constants/providerMappings";
const compactTriggerClassName =
"h-8 w-8 rounded-full border-slate-200/80 bg-white/92 p-0 text-slate-500 shadow-none transition-colors hover:border-slate-300 hover:bg-white hover:text-slate-700";
"h-8 min-w-[104px] max-w-[168px] justify-start gap-1.5 rounded-full border-slate-200/80 bg-white/92 px-2.5 text-slate-600 shadow-none transition-colors hover:border-slate-300 hover:bg-white hover:text-slate-800";
const defaultTriggerClassName =
"h-9 w-full min-w-0 justify-start gap-2 rounded-full border-slate-200/80 bg-white/92 px-3 font-normal text-slate-700 shadow-none transition-colors hover:border-slate-300 hover:bg-white";
@@ -68,12 +69,38 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
disabled = false,
}) => {
const [open, setOpen] = useState(false);
const [backgroundProviderLoadReady, setBackgroundProviderLoadReady] =
useState(false);
const hasInitialized = useRef(false);
const modelRef = useRef(model);
modelRef.current = model;
const shouldLoadProviders =
open ||
backgroundProviderLoadReady ||
!providerType.trim() ||
!model.trim();
const shouldLoadModels = open || !providerType.trim() || !model.trim();
useEffect(() => {
if (open || backgroundProviderLoadReady) {
return;
}
let cancelled = false;
const timerId = window.setTimeout(() => {
if (!cancelled) {
setBackgroundProviderLoadReady(true);
}
}, 0);
return () => {
cancelled = true;
window.clearTimeout(timerId);
};
}, [backgroundProviderLoadReady, open]);
const { providers: configuredProviders, loading: providersLoading } =
useConfiguredProviders();
useConfiguredProviders({ autoLoad: shouldLoadProviders });
const selectedProvider = useMemo(() => {
return configuredProviders.find(
@@ -83,7 +110,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
const { models: providerModels, loading: modelsLoading } = useProviderModels(
selectedProvider,
{ returnFullMetadata: true },
{ returnFullMetadata: true, autoLoad: shouldLoadModels },
);
const filteredResult = useMemo(() => {
@@ -121,6 +148,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
useEffect(() => {
if (hasInitialized.current) return;
if (!shouldLoadProviders) return;
if (providersLoading) return;
if (configuredProviders.length === 0) return;
@@ -129,9 +157,16 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
if (!providerType.trim()) {
setProviderType(configuredProviders[0].key);
}
}, [configuredProviders, providerType, providersLoading, setProviderType]);
}, [
configuredProviders,
providerType,
providersLoading,
setProviderType,
shouldLoadProviders,
]);
useEffect(() => {
if (!shouldLoadModels) return;
if (!selectedProvider) return;
if (modelsLoading) return;
@@ -142,7 +177,13 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
) {
setModel(currentModels[0]);
}
}, [currentModels, modelsLoading, selectedProvider, setModel]);
}, [
currentModels,
modelsLoading,
selectedProvider,
setModel,
shouldLoadModels,
]);
useEffect(() => {
if (!import.meta.env.DEV) return;
@@ -173,11 +214,15 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
setOpen(false);
}, [disabled, open]);
const selectedProviderLabel = selectedProvider?.label || providerType;
const selectedProviderLabel =
selectedProvider?.label || getProviderLabel(providerType || "lime-hub");
const compactProviderType =
selectedProvider?.key || providerType || "lime-hub";
const compactProviderLabel =
selectedProvider?.label || providerType || "Lime Hub";
selectedProvider?.label ||
getProviderLabel(providerType || "lime-hub") ||
"Lime Hub";
const compactModelLabel = model || "切换模型";
const normalizedTheme = (activeTheme || "").toLowerCase();
const activeThemeLabel =
THEME_LABEL_MAP[normalizedTheme] || activeTheme || "当前主题";
@@ -187,7 +232,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
!filteredResult.usedFallback &&
filteredResult.filteredOutCount > 0;
const showNoProviderGuide =
!providersLoading && configuredProviders.length === 0;
shouldLoadProviders && !providersLoading && configuredProviders.length === 0;
if (showNoProviderGuide) {
return (
@@ -241,7 +286,6 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
{compactTrigger ? (
<Button
variant="outline"
size="icon"
role="combobox"
aria-expanded={open}
disabled={disabled}
@@ -256,6 +300,10 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
fallbackText={compactProviderLabel}
size={15}
/>
<span className="min-w-0 flex-1 truncate text-xs font-medium">
{compactModelLabel}
</span>
<ChevronDown className="h-3 w-3 shrink-0 text-slate-400 opacity-80" />
</Button>
) : (
<Button
@@ -9,6 +9,23 @@ import {
type UserProfile,
} from "../constants";
function resolveNeedsOnboardingState(): boolean {
if (typeof window === "undefined") {
return false;
}
try {
const isComplete =
localStorage.getItem(STORAGE_KEYS.ONBOARDING_COMPLETE) === "true";
const version = localStorage.getItem(STORAGE_KEYS.ONBOARDING_VERSION);
return !isComplete || version !== ONBOARDING_VERSION;
} catch (error) {
console.warn("[Onboarding] 读取引导状态失败,默认继续进入主应用:", error);
return false;
}
}
/**
* 引导状态 Hook
*
@@ -16,16 +33,12 @@ import {
*/
export function useOnboardingState() {
// null 表示正在检测中
const [needsOnboarding, setNeedsOnboarding] = useState<boolean | null>(null);
const [needsOnboarding, setNeedsOnboarding] = useState<boolean | null>(() =>
resolveNeedsOnboardingState(),
);
useEffect(() => {
// 检测是否需要显示引导
const isComplete =
localStorage.getItem(STORAGE_KEYS.ONBOARDING_COMPLETE) === "true";
const version = localStorage.getItem(STORAGE_KEYS.ONBOARDING_VERSION);
// 首次启动或版本升级时显示引导
setNeedsOnboarding(!isComplete || version !== ONBOARDING_VERSION);
setNeedsOnboarding(resolveNeedsOnboardingState());
}, []);
/**
@@ -6,6 +6,7 @@ import { useState, useEffect } from "react";
import styled from "styled-components";
import { Keyboard, CheckCircle2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { safeListen } from "@/lib/dev-bridge";
const Container = styled.div`
padding: 32px 24px;
@@ -111,15 +112,13 @@ export function VoiceShortcutTestStep({
const setupListener = async () => {
try {
const { listen } = await import("@tauri-apps/api/event");
// 监听录音开始事件(快捷键按下)
const unlistenStart = await listen("voice-start-recording", () => {
const unlistenStart = await safeListen("voice-start-recording", () => {
setIsPressed(true);
});
// 监听录音停止事件(快捷键释放)
const unlistenStop = await listen("voice-stop-recording", () => {
const unlistenStop = await safeListen("voice-stop-recording", () => {
setIsPressed(false);
setTestSuccess(true);
// 取消录音(因为这只是测试)
@@ -16,6 +16,7 @@ import type {
} from "@/lib/api/openclaw";
import { cn } from "@/lib/utils";
import type { OpenClawLastSynced } from "./types";
import { compactPathLabel } from "./pathDisplay";
import {
openClawDangerButtonClassName,
openClawInputClassName,
@@ -93,6 +94,9 @@ export function OpenClawConfigurePage({
const healthText = healthInfo
? `${healthInfo.status}${healthInfo.version ? ` · ${healthInfo.version}` : ""}`
: "尚未执行健康检查";
const installPathLabel = installPath
? compactPathLabel(installPath, 76)
: "未检测到安装路径";
return (
<div className="space-y-4">
@@ -184,8 +188,11 @@ export function OpenClawConfigurePage({
OpenClaw 安装路径
</div>
<div className="mt-2 flex items-center gap-2 text-sm leading-6 text-slate-600">
<span className="break-all">
{installPath || "未检测到安装路径"}
<span
className="min-w-0 flex-1 truncate"
title={installPath || undefined}
>
{installPathLabel}
</span>
<button
type="button"
@@ -1,5 +1,11 @@
import { Cpu, Package, TerminalSquare } from "lucide-react";
import { useMemo, useState } from "react";
import { ChevronDown, Cpu, Package, TerminalSquare } from "lucide-react";
import type { OpenClawRuntimeCandidate } from "@/lib/api/openclaw";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Select,
SelectContent,
@@ -8,6 +14,7 @@ import {
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { openClawSubPanelClassName } from "./openclawStyles";
import { compactPathLabel } from "./pathDisplay";
const AUTO_RUNTIME_VALUE = "__auto_runtime__";
@@ -61,6 +68,23 @@ function formatOpenClawStatus(candidate: OpenClawRuntimeCandidate | null): strin
return "当前运行时尚未安装 OpenClaw";
}
function buildEnvironmentDetails(
candidate: OpenClawRuntimeCandidate | null,
): Array<{ label: string; value: string }> {
if (!candidate) {
return [];
}
return [
{ label: "Node.js 可执行文件", value: candidate.nodePath },
{ label: "运行时 bin 目录", value: candidate.binDir },
{ label: "npm 命令", value: candidate.npmPath || "" },
{ label: "npm 全局前缀", value: candidate.npmGlobalPrefix || "" },
{ label: "OpenClaw 命令", value: candidate.openclawPath || "" },
{ label: "OpenClaw 包路径", value: candidate.openclawPackagePath || "" },
].filter((item) => item.value.trim().length > 0);
}
export function OpenClawExecutionEnvironmentCard({
candidates,
preferredRuntimeId,
@@ -69,6 +93,7 @@ export function OpenClawExecutionEnvironmentCard({
className,
onChange,
}: OpenClawExecutionEnvironmentCardProps) {
const [detailsOpen, setDetailsOpen] = useState(false);
const currentCandidate =
(preferredRuntimeId
? candidates.find((candidate) => candidate.id === preferredRuntimeId)
@@ -79,6 +104,10 @@ export function OpenClawExecutionEnvironmentCard({
null;
const selectValue = preferredRuntimeId || AUTO_RUNTIME_VALUE;
const showSelector = candidates.length > 0;
const environmentDetails = useMemo(
() => buildEnvironmentDetails(currentCandidate),
[currentCandidate],
);
return (
<div className={cn(openClawSubPanelClassName, className)}>
@@ -110,8 +139,13 @@ export function OpenClawExecutionEnvironmentCard({
<div className="truncate text-sm font-medium text-slate-900">
{formatRuntimeSelectionLabel(currentCandidate, preferredRuntimeId)}
</div>
<div className="mt-1 truncate text-xs text-slate-500">
{currentCandidate?.binDir || "由 Lime 自动选择最合适的运行时"}
<div
className="mt-1 truncate text-xs text-slate-500"
title={currentCandidate?.binDir || undefined}
>
{currentCandidate?.binDir
? compactPathLabel(currentCandidate.binDir, 56)
: "由 Lime 自动选择最合适的运行时"}
</div>
</div>
</SelectTrigger>
@@ -144,8 +178,11 @@ export function OpenClawExecutionEnvironmentCard({
? "已检测到 OpenClaw 包"
: "当前运行时未安装 OpenClaw"}
</div>
<div className="mt-1 truncate text-[11px] text-slate-400">
{candidate.binDir}
<div
className="mt-1 truncate text-[11px] text-slate-400"
title={candidate.binDir}
>
{compactPathLabel(candidate.binDir, 56)}
</div>
</div>
</SelectItem>
@@ -164,8 +201,13 @@ export function OpenClawExecutionEnvironmentCard({
<div className="mt-2 text-sm font-medium text-slate-900">
{currentCandidate?.nodeVersion || "未识别"}
</div>
<div className="mt-1 break-all text-[11px] leading-5 text-slate-500">
{currentCandidate?.nodePath || "未检测到"}
<div
className="mt-1 truncate text-[11px] leading-5 text-slate-500"
title={currentCandidate?.nodePath || undefined}
>
{currentCandidate?.nodePath
? compactPathLabel(currentCandidate.nodePath, 46)
: "未检测到"}
</div>
</div>
@@ -177,10 +219,19 @@ export function OpenClawExecutionEnvironmentCard({
<div className="mt-2 text-sm font-medium text-slate-900">
{formatOpenClawStatus(currentCandidate)}
</div>
<div className="mt-1 break-all text-[11px] leading-5 text-slate-500">
{currentCandidate?.openclawPath ||
<div
className="mt-1 truncate text-[11px] leading-5 text-slate-500"
title={
currentCandidate?.openclawPath ||
currentCandidate?.openclawPackagePath ||
"切换到该运行时后可直接安装 OpenClaw。"}
undefined
}
>
{currentCandidate?.openclawPath
? compactPathLabel(currentCandidate.openclawPath, 46)
: currentCandidate?.openclawPackagePath
? compactPathLabel(currentCandidate.openclawPackagePath, 46)
: "切换到该运行时后可直接安装 OpenClaw。"}
</div>
</div>
@@ -192,13 +243,73 @@ export function OpenClawExecutionEnvironmentCard({
<div className="mt-2 text-sm font-medium text-slate-900">
{currentCandidate?.source || "未识别"}
</div>
<div className="mt-1 break-all text-[11px] leading-5 text-slate-500">
{currentCandidate?.npmGlobalPrefix ||
currentCandidate?.npmPath ||
"当前未检测到 npm 全局前缀"}
<div
className="mt-1 truncate text-[11px] leading-5 text-slate-500"
title={currentCandidate?.npmGlobalPrefix || currentCandidate?.npmPath || undefined}
>
{currentCandidate?.npmGlobalPrefix
? compactPathLabel(currentCandidate.npmGlobalPrefix, 46)
: currentCandidate?.npmPath
? compactPathLabel(currentCandidate.npmPath, 46)
: "当前未检测到 npm 全局前缀"}
</div>
</div>
</div>
{environmentDetails.length > 0 ? (
<Collapsible
open={detailsOpen}
onOpenChange={setDetailsOpen}
className="mt-4"
>
<div className="overflow-hidden rounded-2xl border border-slate-200/80 bg-white/80">
<div className="flex flex-col gap-3 px-4 py-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<div className="text-sm font-medium text-slate-900">
环境详情
</div>
<p className="mt-1 text-xs leading-5 text-slate-500">
默认只保留运行时摘要,排查路径与前缀冲突时再展开完整信息。
</p>
</div>
<CollapsibleTrigger asChild>
<button
type="button"
className="inline-flex shrink-0 items-center gap-2 rounded-xl border border-slate-200 bg-white px-3 py-2 text-xs font-medium text-slate-700 transition hover:bg-slate-50"
>
{detailsOpen ? "收起详情" : "查看详情"}
<ChevronDown
className={cn(
"h-4 w-4 transition-transform duration-200",
detailsOpen ? "rotate-180" : "rotate-0",
)}
/>
</button>
</CollapsibleTrigger>
</div>
<CollapsibleContent>
<div className="border-t border-slate-200/80 px-4 py-4">
<div className="grid gap-3 md:grid-cols-2">
{environmentDetails.map((item) => (
<div
key={item.label}
className="rounded-xl border border-slate-200/70 bg-slate-50/80 px-3 py-3"
>
<div className="text-[11px] font-medium text-slate-500">
{item.label}
</div>
<div className="mt-1 break-all text-xs leading-6 text-slate-700">
{item.value}
</div>
</div>
))}
</div>
</div>
</CollapsibleContent>
</div>
</Collapsible>
) : null}
</div>
);
}
@@ -97,6 +97,18 @@ function renderPage(
return container;
}
function findButton(container: HTMLElement, text: string): HTMLButtonElement {
const button = Array.from(container.querySelectorAll("button")).find((item) =>
item.textContent?.includes(text),
);
if (!button) {
throw new Error(`未找到按钮:${text}`);
}
return button as HTMLButtonElement;
}
beforeEach(() => {
(
globalThis as typeof globalThis & {
@@ -260,9 +272,81 @@ describe("OpenClawInstallPage", () => {
);
expect(container.textContent).toContain("检测诊断");
expect(container.textContent).toContain("C:/Program Files/nodejs/npm.cmd");
expect(container.textContent).toContain("C:/Users/demo/AppData/Roaming/npm");
expect(container.textContent).toContain("~/AppData/Roaming/npm");
act(() => {
findButton(container, "查看详细诊断").click();
});
expect(container.textContent).toContain("收起详细诊断");
expect(container.textContent).toContain(
"C:/Users/demo/AppData/Roaming/npm/openclaw.cmd",
);
});
it("长路径应在执行环境与诊断摘要中压缩显示,并通过折叠入口展开详情", () => {
const runtimeNodePath =
"/Users/demo/Library/Application Support/lime/runtime/node/versions/very-long-version/bin/node";
const diagnosticsNpmPath =
"/Users/demo/Library/Application Support/lime/runtime/npm/prefix/very-long-segment/bin/npm";
const container = renderPage({
environmentStatus: buildEnvironmentStatus({
diagnostics: {
npmPath: diagnosticsNpmPath,
npmGlobalPrefix:
"/Users/demo/Library/Application Support/lime/runtime/npm/prefix/very-long-segment",
openclawPackagePath:
"/Users/demo/Library/Application Support/lime/runtime/openclaw/node_modules/@qingchencloud/openclaw-zh/package.json",
whereCandidates: [],
supplementalSearchDirs: [],
supplementalCommandCandidates: [],
},
}),
runtimeCandidates: [
{
id: "runtime-demo",
source: "nvm",
binDir:
"/Users/demo/Library/Application Support/lime/runtime/node/versions/very-long-version/bin",
nodePath: runtimeNodePath,
nodeVersion: "22.12.0",
npmPath:
"/Users/demo/Library/Application Support/lime/runtime/node/versions/very-long-version/bin/npm",
npmGlobalPrefix:
"/Users/demo/Library/Application Support/lime/runtime/npm/prefix/very-long-segment",
openclawPath:
"/Users/demo/Library/Application Support/lime/runtime/node/versions/very-long-version/bin/openclaw",
openclawVersion: "2026.3.18",
openclawPackagePath:
"/Users/demo/Library/Application Support/lime/runtime/openclaw/node_modules/@qingchencloud/openclaw-zh/package.json",
isActive: true,
isPreferred: false,
},
],
});
const titledElements = Array.from(container.querySelectorAll("[title]"));
const runtimeSummary = titledElements.find(
(item) => item.getAttribute("title") === runtimeNodePath,
);
const diagnosticsSummary = titledElements.find(
(item) => item.getAttribute("title") === diagnosticsNpmPath,
);
expect(runtimeSummary?.textContent).toContain("...");
expect(runtimeSummary?.textContent).not.toBe(runtimeNodePath);
expect(diagnosticsSummary?.textContent).toContain("...");
expect(diagnosticsSummary?.textContent).not.toBe(diagnosticsNpmPath);
act(() => {
findButton(container, "查看详情").click();
});
expect(container.textContent).toContain("收起详情");
act(() => {
findButton(container, "查看详细诊断").click();
});
expect(container.textContent).toContain("收起详细诊断");
});
});
+150 -63
View File
@@ -1,5 +1,6 @@
import type { ReactNode } from "react";
import { useState, type ReactNode } from "react";
import {
ChevronDown,
Download,
ExternalLink,
GitBranch,
@@ -14,9 +15,15 @@ import type {
OpenClawEnvironmentStatus,
OpenClawRuntimeCandidate,
} from "@/lib/api/openclaw";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import type { DesktopPlatform } from "@/lib/crashDiagnostic";
import { cn } from "@/lib/utils";
import { OpenClawExecutionEnvironmentCard } from "./OpenClawExecutionEnvironmentCard";
import { compactPathLabel } from "./pathDisplay";
import {
openClawPanelClassName,
openClawPrimaryButtonClassName,
@@ -128,7 +135,16 @@ function DependencyCard({
<div className="mt-3 rounded-xl border border-slate-200/80 bg-white px-3 py-2 text-xs leading-6 text-slate-500">
<div>版本:{resolvedStatus.version || "未检测到"}</div>
<div className="break-all">路径:{pathText}</div>
<div className="min-w-0">
<span>路径:</span>
<span className="inline-block max-w-full truncate align-bottom">
<span title={resolvedStatus.path || undefined}>
{resolvedStatus.path
? compactPathLabel(resolvedStatus.path, 54)
: pathText}
</span>
</span>
</div>
</div>
{(primaryLabel || secondaryLabel) && (
@@ -191,6 +207,7 @@ export function OpenClawInstallPage({
onDownloadGit,
onSelectPreferredRuntime,
}: OpenClawInstallPageProps) {
const [diagnosticsOpen, setDiagnosticsOpen] = useState(false);
const nodeReady = environmentStatus?.node.status === "ok";
const gitReady = environmentStatus?.git.status === "ok";
const openclawReady = environmentStatus?.openclaw.status === "ok";
@@ -238,6 +255,46 @@ export function OpenClawInstallPage({
const openclawStatusLabel = environmentStatus
? resolveStatusLabel(environmentStatus.openclaw.status)
: "未检测";
const diagnosticHighlights = [
{
key: "npm-command",
title: "npm 命令",
value: diagnostics?.npmPath || null,
fallback: "未检测到",
},
{
key: "npm-prefix",
title: "npm 全局前缀",
value: diagnostics?.npmGlobalPrefix || null,
fallback: "未检测到",
},
{
key: "openclaw-package",
title: "OpenClaw 包路径",
value: diagnostics?.openclawPackagePath || null,
fallback: "未检测到",
},
];
const diagnosticLists = [
{
key: "where-candidates",
title: "`where openclaw` 命中",
values: diagnostics?.whereCandidates || [],
emptyText: "未命中",
},
{
key: "supplemental-search-dirs",
title: "补充搜索目录",
values: diagnostics?.supplementalSearchDirs || [],
emptyText: "无",
},
{
key: "supplemental-command-candidates",
title: "补充目录中的 OpenClaw 命中",
values: diagnostics?.supplementalCommandCandidates || [],
emptyText: "未命中",
},
];
return (
<div className="space-y-4">
@@ -426,73 +483,103 @@ export function OpenClawInstallPage({
{hasDiagnostics ? (
<section className={openClawPanelClassName}>
<div className="text-sm font-medium text-slate-900">检测诊断</div>
<div className="mt-3 grid gap-3 text-xs leading-6 text-slate-500 md:grid-cols-2">
<div className="rounded-[22px] border border-slate-200/80 bg-slate-50/80 px-4 py-3">
<div className="font-medium text-slate-900">npm 命令</div>
<div className="mt-1 break-all">
{diagnostics?.npmPath || "未检测到"}
<Collapsible open={diagnosticsOpen} onOpenChange={setDiagnosticsOpen}>
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div className="max-w-2xl">
<div className="text-sm font-medium text-slate-900">
检测诊断
</div>
<p className="mt-2 text-sm leading-6 text-slate-500">
主页面先展示关键探测结果,完整命中路径与搜索目录按需展开,避免把安装页变成长日志面板。
</p>
</div>
<CollapsibleTrigger asChild>
<button
type="button"
className="inline-flex shrink-0 items-center gap-2 rounded-xl border border-slate-200 bg-white px-3 py-2 text-xs font-medium text-slate-700 transition hover:bg-slate-50"
>
{diagnosticsOpen ? "收起详细诊断" : "查看详细诊断"}
<ChevronDown
className={cn(
"h-4 w-4 transition-transform duration-200",
diagnosticsOpen ? "rotate-180" : "rotate-0",
)}
/>
</button>
</CollapsibleTrigger>
</div>
<div className="rounded-[22px] border border-slate-200/80 bg-slate-50/80 px-4 py-3">
<div className="font-medium text-slate-900">npm 全局前缀</div>
<div className="mt-1 break-all">
{diagnostics?.npmGlobalPrefix || "未检测到"}
</div>
<div className="mt-4 grid gap-3 md:grid-cols-3">
{diagnosticHighlights.map((item) => (
<div
key={item.key}
className="rounded-[22px] border border-slate-200/80 bg-slate-50/80 px-4 py-3"
>
<div className="text-[11px] font-medium text-slate-500">
{item.title}
</div>
<div
className="mt-2 truncate text-sm font-medium text-slate-900"
title={item.value || undefined}
>
{item.value
? compactPathLabel(item.value, 60)
: item.fallback}
</div>
</div>
))}
</div>
<div className="rounded-[22px] border border-slate-200/80 bg-slate-50/80 px-4 py-3 md:col-span-2">
<div className="font-medium text-slate-900">OpenClaw 包路径</div>
<div className="mt-1 break-all">
{diagnostics?.openclawPackagePath || "未检测到"}
</div>
</div>
<div className="rounded-[22px] border border-slate-200/80 bg-slate-50/80 px-4 py-3">
<div className="font-medium text-slate-900">
`where openclaw` 命中
</div>
<div className="mt-1 space-y-1">
{diagnostics?.whereCandidates?.length ? (
diagnostics.whereCandidates.map((item) => (
<div key={item} className="break-all">
{item}
<CollapsibleContent>
<div className="mt-4 border-t border-slate-200/80 pt-4">
<div className="grid gap-3 text-xs leading-6 text-slate-500 md:grid-cols-2">
{diagnosticHighlights.map((item) => (
<div
key={`${item.key}-detail`}
className={cn(
"rounded-[22px] border border-slate-200/80 bg-slate-50/80 px-4 py-3",
item.key === "openclaw-package" ? "md:col-span-2" : "",
)}
>
<div className="font-medium text-slate-900">
{item.title}
</div>
<div className="mt-1 break-all">
{item.value || item.fallback}
</div>
</div>
))
) : (
<div>未命中</div>
)}
</div>
</div>
<div className="rounded-[22px] border border-slate-200/80 bg-slate-50/80 px-4 py-3">
<div className="font-medium text-slate-900">补充搜索目录</div>
<div className="mt-1 space-y-1">
{diagnostics?.supplementalSearchDirs?.length ? (
diagnostics.supplementalSearchDirs.map((item) => (
<div key={item} className="break-all">
{item}
))}
{diagnosticLists.map((item) => (
<div
key={item.key}
className={cn(
"rounded-[22px] border border-slate-200/80 bg-slate-50/80 px-4 py-3",
item.key === "supplemental-command-candidates"
? "md:col-span-2"
: "",
)}
>
<div className="font-medium text-slate-900">
{item.title}
</div>
<div className="mt-1 space-y-1">
{item.values.length > 0 ? (
item.values.map((entry) => (
<div key={entry} className="break-all">
{entry}
</div>
))
) : (
<div>{item.emptyText}</div>
)}
</div>
</div>
))
) : (
<div>无</div>
)}
))}
</div>
</div>
</div>
<div className="rounded-[22px] border border-slate-200/80 bg-slate-50/80 px-4 py-3 md:col-span-2">
<div className="font-medium text-slate-900">
补充目录中的 OpenClaw 命中
</div>
<div className="mt-1 space-y-1">
{diagnostics?.supplementalCommandCandidates?.length ? (
diagnostics.supplementalCommandCandidates.map((item) => (
<div key={item} className="break-all">
{item}
</div>
))
) : (
<div>未命中</div>
)}
</div>
</div>
</div>
</CollapsibleContent>
</Collapsible>
</section>
) : null}
</div>
+105 -3
View File
@@ -34,6 +34,7 @@ const {
mockRuntimePageRender,
mockRefreshDashboardUrl,
mockRefreshDashboardWindowState,
mockHandleOpenDashboardWindow,
mockCheckHealth,
mockGetChannels,
} = vi.hoisted(() => ({
@@ -59,6 +60,7 @@ const {
mockRuntimePageRender: vi.fn(),
mockRefreshDashboardUrl: vi.fn(),
mockRefreshDashboardWindowState: vi.fn(),
mockHandleOpenDashboardWindow: vi.fn(),
mockCheckHealth: vi.fn(),
mockGetChannels: vi.fn(),
}));
@@ -131,7 +133,7 @@ vi.mock("./useOpenClawDashboardWindow", () => ({
dashboardWindowOpen: false,
refreshDashboardUrl: mockRefreshDashboardUrl,
refreshDashboardWindowState: mockRefreshDashboardWindowState,
handleOpenDashboardWindow: vi.fn(),
handleOpenDashboardWindow: mockHandleOpenDashboardWindow,
handleOpenDashboardExternal: vi.fn(),
closeDashboardWindowSilently: vi.fn(),
}),
@@ -283,8 +285,8 @@ function buildEnvironmentStatus(options?: {
};
}
function renderPage() {
return renderIntoDom(<OpenClawPage isActive />, mountedRoots);
function renderPage(props?: Partial<React.ComponentProps<typeof OpenClawPage>>) {
return renderIntoDom(<OpenClawPage isActive {...props} />, mountedRoots);
}
function findButton(container: HTMLElement, text: string): HTMLButtonElement {
@@ -605,6 +607,106 @@ describe("OpenClawPage", () => {
);
});
it("命令待刷新时仍应允许进入安装页重新检测", async () => {
mockGetEnvironmentStatus.mockResolvedValue(
buildEnvironmentStatus({
nodeStatus: "ok",
gitStatus: "ok",
openclawStatus: "needs_reload",
openclawVersion: "2026.3.13-zh.1",
openclawPath: "/Users/demo/.nvm/versions/node/v23.4.0",
summary: "已检测到 OpenClaw 包,但命令尚未生效。",
}),
);
mockGetStatus.mockResolvedValue({ status: "running", port: 18790 });
const mounted = renderPage({
pageParams: { subpage: "install" },
onNavigate: vi.fn(),
});
await waitForInstallPage(mounted.container);
await flushEffects();
expect(
mounted.container.querySelector('[data-testid="openclaw-install-page"]'),
).toBeTruthy();
});
it("工作台概览中的安装路径摘要应压缩显示长路径", async () => {
const longInstallPath =
"/Users/demo/Library/Application Support/lime/runtime/openclaw/node_modules/@qingchencloud/openclaw-zh/package.json";
mockGetEnvironmentStatus.mockResolvedValue(
buildEnvironmentStatus({
nodeStatus: "ok",
gitStatus: "ok",
openclawStatus: "ok",
openclawVersion: "2026.3.18",
openclawPath: longInstallPath,
summary: "OpenClaw 已安装。",
}),
);
const mounted = renderPage();
await flushEffects();
await waitForCondition(
() => mounted.container.textContent?.includes("OpenClaw 工作台") ?? false,
40,
"OpenClaw 工作台未在预期时间内渲染",
);
const compactedEntries = Array.from(
mounted.container.querySelectorAll("[title]"),
).filter((item) => item.getAttribute("title") === longInstallPath);
expect(compactedEntries.length).toBeGreaterThan(0);
expect(
compactedEntries.some(
(item) =>
item.textContent !== longInstallPath &&
item.textContent?.includes("..."),
),
).toBe(true);
});
it("工作台摘要中的桌面面板快捷按钮应直接触发打开动作", async () => {
mockGetEnvironmentStatus.mockResolvedValue(
buildEnvironmentStatus({
nodeStatus: "ok",
gitStatus: "ok",
openclawStatus: "ok",
openclawVersion: "2026.3.13",
openclawPath:
"/Users/demo/.nvm/versions/node/v23.4.0/bin/openclaw",
summary: "OpenClaw 已安装。",
}),
);
mockGetStatus.mockResolvedValue({ status: "running", port: 18790 });
const mounted = renderPage();
await flushEffects();
await waitForCondition(
() => !!mounted.container.querySelector('[data-testid="openclaw-runtime-page"]'),
40,
"OpenClaw 运行页未在预期时间内渲染",
);
await act(async () => {
// 切换到快捷操作 tab
findButton(mounted.container, "快捷操作").click();
await flushEffects();
});
await act(async () => {
findButton(mounted.container, "打开桌面面板").click();
await flushEffects();
});
expect(mockHandleOpenDashboardWindow).toHaveBeenCalledTimes(1);
});
it("智能升级前会自动切到检测到 OpenClaw 的执行环境", async () => {
mockGetEnvironmentStatus.mockResolvedValue(
buildEnvironmentStatus({
+266 -250
View File
@@ -12,6 +12,7 @@ import {
type LucideIcon,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import type { ConfiguredProvider } from "@/hooks/useConfiguredProviders";
import { useProviderModels } from "@/hooks/useProviderModels";
import { useApiKeyProvider } from "@/hooks/useApiKeyProvider";
@@ -64,6 +65,7 @@ import {
} from "./types";
import { useOpenClawStore } from "./useOpenClawStore";
import { openUrl } from "./openUrl";
import { compactPathLabel } from "./pathDisplay";
import { useOpenClawDashboardWindow } from "./useOpenClawDashboardWindow";
import {
openClawPanelClassName,
@@ -465,11 +467,15 @@ function renderBlockedPage(
function resolveOpenClawSubpage(
candidate: OpenClawSubpage,
installed: boolean,
workflowReady: boolean,
gatewayRunning: boolean,
gatewayStarting: boolean,
operationState: OpenClawOperationState,
options?: {
allowInstallPage?: boolean;
},
): OpenClawSubpage {
const allowInstallPage = options?.allowInstallPage ?? false;
if (operationState.running && operationState.kind) {
return progressSubpageByAction[operationState.kind];
}
@@ -482,15 +488,15 @@ function resolveOpenClawSubpage(
return candidate;
}
if (!installed) {
if (!workflowReady) {
return "install";
}
if (
candidate === "install" ||
candidate === "installing" ||
candidate === "updating"
) {
if (candidate === "install") {
return allowInstallPage ? "install" : "runtime";
}
if (candidate === "installing" || candidate === "updating") {
return "runtime";
}
@@ -562,6 +568,7 @@ export function OpenClawPage({
const [fallbackSubpage, setFallbackSubpage] =
useState<LocalOpenClawSubpage>("install");
const hasManualFallbackNavigationRef = useRef(false);
const [statusResolved, setStatusResolved] = useState(false);
const [installedStatus, setInstalledStatus] =
useState<OpenClawBinaryInstallStatus | null>(null);
@@ -859,6 +866,7 @@ export function OpenClawPage({
const requestedOrFallbackSubpage =
requestedSubpage ?? (onNavigate ? defaultSubpage : fallbackSubpage);
const allowInstallPage = environmentStatus?.openclaw.status === "needs_reload";
const currentSubpage = useMemo(
() =>
resolveOpenClawSubpage(
@@ -867,10 +875,12 @@ export function OpenClawPage({
gatewayRunning,
gatewayStarting,
operationState,
{ allowInstallPage },
),
[
gatewayRunning,
gatewayStarting,
allowInstallPage,
openclawWorkflowReady,
operationState,
requestedOrFallbackSubpage,
@@ -921,6 +931,7 @@ export function OpenClawPage({
if (onNavigate) {
onNavigate("openclaw", { subpage });
} else {
hasManualFallbackNavigationRef.current = true;
setFallbackSubpage(subpage);
}
},
@@ -1216,12 +1227,21 @@ export function OpenClawPage({
return;
}
const preserveInstallPage =
allowInstallPage &&
hasManualFallbackNavigationRef.current &&
fallbackSubpage === "install";
const resolvedSubpage = !openclawWorkflowReady ? "install" : "runtime";
if (!onNavigate && fallbackSubpage !== resolvedSubpage) {
if (
!onNavigate &&
!preserveInstallPage &&
fallbackSubpage !== resolvedSubpage
) {
setFallbackSubpage(resolvedSubpage);
}
}, [
allowInstallPage,
fallbackSubpage,
gatewayRunning,
gatewayStarting,
@@ -2315,12 +2335,12 @@ export function OpenClawPage({
if (installed) {
return { label: "已安装", tone: "done" };
}
if (softInstalled) {
return { label: "可继续", tone: "active" };
}
if (environmentStatus?.openclaw.status === "needs_reload") {
return { label: "待刷新", tone: "active" };
}
if (softInstalled) {
return { label: "可继续", tone: "active" };
}
return { label: "待安装", tone: "idle" };
case "sync":
if (!openclawWorkflowReady) {
@@ -2455,6 +2475,7 @@ export function OpenClawPage({
title: string;
value: string;
description: string;
descriptionTitle?: string;
icon: LucideIcon;
iconClassName: string;
valueClassName?: string;
@@ -2471,11 +2492,22 @@ export function OpenClawPage({
: operationState.running
? "处理中"
: "待安装",
description:
descriptionTitle:
environmentStatus?.openclaw.path ||
currentRuntimeCandidate?.openclawPath ||
currentRuntimeCandidate?.openclawPackagePath ||
"等待检测安装路径",
undefined,
description:
environmentStatus?.openclaw.path ||
currentRuntimeCandidate?.openclawPath ||
currentRuntimeCandidate?.openclawPackagePath
? compactPathLabel(
environmentStatus?.openclaw.path ||
currentRuntimeCandidate?.openclawPath ||
currentRuntimeCandidate?.openclawPackagePath,
54,
)
: "等待检测安装路径",
icon: Wrench,
iconClassName: "border-slate-200 bg-slate-100 text-slate-700",
},
@@ -2808,192 +2840,58 @@ export function OpenClawPage({
<div className="flex-1 overflow-auto">
<div className="mx-auto flex min-h-full w-full max-w-[1480px] flex-col gap-6 px-4 py-5 lg:px-6 lg:py-6">
<section className="relative overflow-hidden rounded-[30px] border border-amber-200/70 bg-[linear-gradient(135deg,rgba(249,248,244,0.98)_0%,rgba(248,250,252,0.98)_46%,rgba(243,248,247,0.96)_100%)] shadow-sm shadow-slate-950/5">
<div className="pointer-events-none absolute -left-20 top-[-72px] h-56 w-56 rounded-full bg-amber-200/30 blur-3xl" />
<div className="pointer-events-none absolute right-[-76px] top-[-24px] h-56 w-56 rounded-full bg-sky-200/24 blur-3xl" />
<div className="relative flex flex-col gap-6 p-6 lg:p-8">
<div className="flex flex-col gap-6 xl:flex-row xl:items-start xl:justify-between">
<div className="max-w-3xl space-y-4">
<div className="flex items-center gap-4">
<OpenClawMark size="md" className="shadow-red-500/10" />
<div>
<span className="inline-flex items-center rounded-full border border-amber-200 bg-white/85 px-3 py-1 text-xs font-semibold tracking-[0.16em] text-amber-700 shadow-sm">
OPENCLAW WORKSPACE
</span>
</div>
</div>
<div className="space-y-2">
<h1 className="text-3xl font-semibold tracking-tight text-slate-900">
OpenClaw 工作台
</h1>
<p className="max-w-2xl text-sm leading-6 text-slate-600">
{pageDescription}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Badge className="rounded-full border border-white/90 bg-white/90 px-3 py-1 text-slate-700 shadow-sm hover:bg-white">
{currentSubpageLabel}
</Badge>
<Badge
variant="outline"
className="rounded-full border-slate-200 bg-white/75 px-3 py-1 text-slate-600"
>
{installed
? "环境已安装"
: softInstalled
? "环境可继续"
: "环境待安装"}
</Badge>
<Badge
variant="outline"
className="rounded-full border-slate-200 bg-white/75 px-3 py-1 text-slate-600"
>
已安装 {installedVersion || "未检测到版本"}
</Badge>
<Badge
variant="outline"
className={cn(
"rounded-full bg-white/75 px-3 py-1",
versionMismatch
? "border-amber-200 text-amber-700"
: "border-slate-200 text-slate-600",
)}
>
运行中{" "}
{gatewayRunning
? runningVersion || "待校验"
: gatewayStarting
? "启动中"
: "未启动"}
</Badge>
<Badge
variant="outline"
className="rounded-full border-slate-200 bg-white/75 px-3 py-1 text-slate-600"
>
Gateway {gatewayRunning ? "运行中" : gatewayStatus}
</Badge>
{versionMismatch ? (
<Badge className="rounded-full border border-amber-200 bg-amber-50 px-3 py-1 text-amber-700 shadow-none hover:bg-amber-50">
需重启 Gateway 才会切到新版本
</Badge>
) : null}
{updateRuntimeRequiresSwitch &&
recommendedUpdateRuntimeCandidate ? (
<Badge
variant="outline"
className="rounded-full border-sky-200 bg-sky-50 px-3 py-1 text-sky-700"
>
升级将自动切到{" "}
{formatRuntimeCandidateLabel(
recommendedUpdateRuntimeCandidate,
)}
</Badge>
) : null}
{updateInfo?.hasUpdate ? (
<Badge
variant="outline"
className="rounded-full border-amber-200 bg-amber-50 px-3 py-1 text-amber-700"
>
可升级至 {updateInfo.latestVersion || "新版本"}
</Badge>
) : null}
{updateInfo?.hasUpdate ? (
<button
type="button"
onClick={() => void handlePerformUpdate()}
disabled={operationState.running || switchingRuntime}
className={cn(
openClawPrimaryButtonClassName,
"h-8 rounded-full px-3 text-xs shadow-sm",
)}
>
{updating ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<ArrowUpCircle className="h-3.5 w-3.5" />
)}
智能升级
</button>
) : null}
<div className="space-y-4">
<div className="flex items-center gap-4">
<OpenClawMark size="md" className="shadow-red-500/10" />
<div>
<span className="inline-flex items-center rounded-full border border-amber-200 bg-white/85 px-3 py-1 text-xs font-semibold tracking-[0.16em] text-amber-700 shadow-sm">
OPENCLAW WORKSPACE
</span>
</div>
</div>
<div className="w-full max-w-[360px] rounded-[24px] border border-white/90 bg-white/88 p-5 shadow-sm">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold text-slate-800">
当前摘要
</p>
<p className="mt-1 text-sm leading-6 text-slate-500">
安装、模型同步、Gateway 和 Dashboard 状态会在这里持续汇总。
</p>
</div>
{operationState.running ? (
<span className="inline-flex items-center rounded-full border border-amber-200 bg-amber-50 px-2.5 py-1 text-xs font-medium text-amber-700">
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
处理中
</span>
) : (
<span className="inline-flex items-center rounded-full border border-slate-200 bg-white px-2.5 py-1 text-xs font-medium text-slate-600">
已就绪
</span>
)}
</div>
<div className="space-y-2">
<h1 className="text-3xl font-semibold tracking-tight text-slate-900">
OpenClaw 工作台
</h1>
<p className="max-w-2xl text-sm leading-6 text-slate-600">
{pageDescription}
</p>
</div>
<div className="mt-4 flex flex-wrap items-center gap-2 text-xs text-slate-500">
<span className="rounded-full bg-slate-100 px-2.5 py-1">
{compatibleProviders.length} 个 Provider
</span>
<span className="rounded-full bg-slate-100 px-2.5 py-1">
{channels.length} 个通道
</span>
<span className="rounded-full bg-slate-100 px-2.5 py-1">
端口 {gatewayPort}
</span>
</div>
<div className="mt-4 rounded-[18px] border border-slate-200/80 bg-slate-50/80 px-4 py-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<p className="text-xs font-semibold tracking-[0.12em] text-slate-500">
VERSION STATUS
</p>
<p
className={cn(
"mt-1 text-sm font-semibold",
versionMismatch
? "text-amber-700"
: "text-slate-800",
)}
>
{versionStatusSummary.label}
</p>
</div>
{versionMismatch && canRestartGateway ? (
<button
type="button"
onClick={() => void handleRestart()}
disabled={operationState.running}
className={cn(
openClawSecondaryButtonClassName,
"px-3 py-2 text-xs",
)}
>
<RefreshCw className="h-3.5 w-3.5" />
立即重启生效
</button>
) : null}
</div>
<p className="mt-2 text-xs leading-5 text-slate-500">
{versionStatusSummary.description}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Badge className="rounded-full border border-white/90 bg-white/90 px-3 py-1 text-slate-700 shadow-sm hover:bg-white">
{currentSubpageLabel}
</Badge>
<Badge
variant="outline"
className="rounded-full border-slate-200 bg-white/75 px-3 py-1 text-slate-600"
>
{installed
? "环境已安装"
: softInstalled
? "环境可继续"
: "环境待安装"}
</Badge>
<Badge
variant="outline"
className="rounded-full border-slate-200 bg-white/75 px-3 py-1 text-slate-600"
>
Gateway {gatewayRunning ? "运行中" : gatewayStatus}
</Badge>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<Tabs defaultValue="overview" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="overview">概览</TabsTrigger>
<TabsTrigger value="actions">快捷操作</TabsTrigger>
<TabsTrigger value="version">版本状态</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="mt-4">
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{summaryCards.map((card) => {
const CardIcon = card.icon;
return (
@@ -3002,11 +2900,14 @@ export function OpenClawPage({
className="rounded-[22px] border border-white/90 bg-white/85 p-4 shadow-sm"
>
<div className="flex items-start justify-between gap-3">
<div>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-slate-800">
{card.title}
</p>
<p className="mt-1 text-xs leading-5 text-slate-500">
<p
className="mt-1 truncate text-xs leading-5 text-slate-500"
title={card.descriptionTitle ?? card.description}
>
{card.description}
</p>
</div>
@@ -3030,7 +2931,171 @@ export function OpenClawPage({
</div>
);
})}
</div>
</div>
</TabsContent>
<TabsContent value="actions" className="mt-4">
<div className="flex flex-wrap gap-3">
<button
type="button"
onClick={() => {
if (gatewayRunning) {
void handleOpenDashboardWindow();
return;
}
navigateSubpage(
openclawWorkflowReady ? "runtime" : "install",
);
}}
disabled={gatewayRunning ? dashboardWindowBusy : false}
className={cn(
openClawPrimaryButtonClassName,
"px-4 py-2.5 text-sm shadow-sm",
)}
>
{gatewayRunning && dashboardWindowBusy ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<MonitorSmartphone className="h-4 w-4" />
)}
{gatewayRunning
? dashboardWindowOpen
? "聚焦桌面面板"
: "打开桌面面板"
: openclawWorkflowReady
? "前往运行页"
: "进入安装环境"}
</button>
{(environmentStatus?.openclaw.status === "needs_reload" ||
!installed) && (
<button
type="button"
onClick={() => {
navigateSubpage("install");
void refreshAll();
}}
disabled={operationState.running || switchingRuntime}
className={cn(
openClawSecondaryButtonClassName,
"px-4 py-2.5 text-sm",
)}
>
<RefreshCw className="h-4 w-4" />
重新检测环境
</button>
)}
{updateInfo?.hasUpdate ? (
<button
type="button"
onClick={() => void handlePerformUpdate()}
disabled={operationState.running || switchingRuntime}
className={cn(
openClawPrimaryButtonClassName,
"px-4 py-2.5 text-sm shadow-sm",
)}
>
{updating ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<ArrowUpCircle className="h-4 w-4" />
)}
智能升级
</button>
) : null}
<button
type="button"
onClick={() => void refreshAll()}
className={cn(
openClawSecondaryButtonClassName,
"px-4 py-2.5 text-sm",
)}
>
<Sparkles className="h-4 w-4" />
刷新状态
</button>
<button
type="button"
onClick={() => void openUrl(OPENCLAW_DOCS_URL)}
className={cn(
openClawSecondaryButtonClassName,
"px-4 py-2.5 text-sm",
)}
>
查看文档
</button>
</div>
</TabsContent>
<TabsContent value="version" className="mt-4">
<div className="rounded-[24px] border border-white/90 bg-white/85 p-5 shadow-sm">
<div className="rounded-[18px] border border-slate-200/80 bg-slate-50/80 px-4 py-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<p className="text-xs font-semibold tracking-[0.12em] text-slate-500">
VERSION STATUS
</p>
<p
className={cn(
"mt-1 text-sm font-semibold",
versionMismatch
? "text-amber-700"
: "text-slate-800",
)}
>
{versionStatusSummary.label}
</p>
</div>
{versionMismatch && canRestartGateway ? (
<button
type="button"
onClick={() => void handleRestart()}
disabled={operationState.running}
className={cn(
openClawSecondaryButtonClassName,
"px-3 py-2 text-xs",
)}
>
<RefreshCw className="h-3.5 w-3.5" />
立即重启生效
</button>
) : null}
</div>
<p className="mt-2 text-xs leading-5 text-slate-500">
{versionStatusSummary.description}
</p>
</div>
<div className="mt-4 flex flex-wrap gap-2">
{versionMismatch ? (
<Badge className="rounded-full border border-amber-200 bg-amber-50 px-3 py-1 text-amber-700 shadow-none hover:bg-amber-50">
需重启 Gateway 才会切到新版本
</Badge>
) : null}
{updateInfo?.hasUpdate ? (
<Badge
variant="outline"
className="rounded-full border-amber-200 bg-amber-50 px-3 py-1 text-amber-700"
>
可升级至 {updateInfo.latestVersion || "新版本"}
</Badge>
) : null}
{updateRuntimeRequiresSwitch &&
recommendedUpdateRuntimeCandidate ? (
<Badge
variant="outline"
className="rounded-full border-sky-200 bg-sky-50 px-3 py-1 text-sky-700"
>
升级将自动切到{" "}
{formatRuntimeCandidateLabel(
recommendedUpdateRuntimeCandidate,
)}
</Badge>
) : null}
</div>
</div>
</TabsContent>
</Tabs>
</div>
</section>
@@ -3052,8 +3117,13 @@ export function OpenClawPage({
<div className="text-xs font-medium text-slate-500">
安装路径
</div>
<div className="mt-2 break-all text-sm leading-6 text-slate-700">
{installedStatus?.path || "尚未检测到安装路径"}
<div
className="mt-2 truncate text-sm leading-6 text-slate-700"
title={installedStatus?.path || undefined}
>
{installedStatus?.path
? compactPathLabel(installedStatus.path, 52)
: "尚未检测到安装路径"}
</div>
</div>
<div className={openClawSubPanelClassName}>
@@ -3066,60 +3136,6 @@ export function OpenClawPage({
{selectedModelId.trim() || "未选择模型"}
</div>
</div>
<div className={openClawSubPanelClassName}>
<div className="text-xs font-medium text-slate-500">
Dashboard
</div>
<div className="mt-2 text-sm leading-6 text-slate-700">
{dashboardWindowOpen
? "桌面面板已打开"
: dashboardUrl
? "访问地址已生成"
: "尚未生成访问地址"}
</div>
</div>
</div>
<div className="mt-4 flex flex-wrap gap-2">
{updateInfo?.hasUpdate ? (
<button
type="button"
onClick={() => void handlePerformUpdate()}
disabled={operationState.running || switchingRuntime}
className={cn(
openClawPrimaryButtonClassName,
"px-3 py-2 text-xs",
)}
>
{updating ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<ArrowUpCircle className="h-3.5 w-3.5" />
)}
智能升级到 {updateInfo.latestVersion || "最新版本"}
</button>
) : null}
<button
type="button"
onClick={() => void refreshAll()}
className={cn(
openClawSecondaryButtonClassName,
"px-3 py-2 text-xs",
)}
>
<Sparkles className="h-3.5 w-3.5" />
刷新状态
</button>
<button
type="button"
onClick={() => void openUrl(OPENCLAW_DOCS_URL)}
className={cn(
openClawSecondaryButtonClassName,
"px-3 py-2 text-xs",
)}
>
查看文档
</button>
</div>
</section>
</aside>
+39
View File
@@ -0,0 +1,39 @@
const POSIX_HOME_PATTERN = /^\/(?:Users|home)\/[^/]+/;
const WINDOWS_HOME_PATTERN = /^[A-Za-z]:[\\/]+Users[\\/]+[^\\/]+/;
function normalizeDisplayPath(path: string): string {
const trimmed = path.trim();
if (!trimmed) {
return trimmed;
}
if (POSIX_HOME_PATTERN.test(trimmed)) {
return trimmed.replace(POSIX_HOME_PATTERN, "~");
}
if (WINDOWS_HOME_PATTERN.test(trimmed)) {
return trimmed.replace(WINDOWS_HOME_PATTERN, "~").replace(/\\/g, "/");
}
return trimmed;
}
export function compactPathLabel(
path: string | null | undefined,
maxLength = 52,
): string {
if (!path) {
return "未检测到";
}
const normalized = normalizeDisplayPath(path);
if (normalized.length <= maxLength) {
return normalized;
}
const visibleLength = Math.max(maxLength - 3, 12);
const headLength = Math.max(10, Math.ceil(visibleLength * 0.58));
const tailLength = Math.max(6, visibleLength - headLength);
return `${normalized.slice(0, headLength)}...${normalized.slice(-tailLength)}`;
}
+4 -2
View File
@@ -6,6 +6,7 @@ import {
type VersionInfo,
} from "@/lib/api/appUpdate";
import { safeListen } from "@/lib/dev-bridge";
import { notifyPluginUIChanged } from "@/lib/api/pluginUI";
import {
cancelPluginTask,
disablePlugin,
@@ -838,8 +839,7 @@ export function PluginManager({ onNavigate }: PluginManagerProps = {}) {
const handleInstallSuccess = useCallback(() => {
fetchData();
toast.success("插件安装成功");
// 触发侧边栏刷新事件
window.dispatchEvent(new CustomEvent("plugin-changed"));
notifyPluginUIChanged();
}, [fetchData]);
const handleTogglePlugin = async (name: string, currentEnabled: boolean) => {
@@ -850,6 +850,7 @@ export function PluginManager({ onNavigate }: PluginManagerProps = {}) {
await enablePlugin(name);
}
await fetchData();
notifyPluginUIChanged();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
@@ -859,6 +860,7 @@ export function PluginManager({ onNavigate }: PluginManagerProps = {}) {
try {
await reloadPlugins();
await fetchData();
notifyPluginUIChanged();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
@@ -9,6 +9,7 @@ import { useState } from "react";
import { AlertTriangle, Loader2, Trash2 } from "lucide-react";
import { Modal, ModalBody, ModalFooter } from "@/components/Modal";
import { Button } from "@/components/ui/button";
import { notifyPluginUIChanged } from "@/lib/api/pluginUI";
import { uninstallPlugin } from "@/lib/api/plugins";
/** 已安装插件信息 */
@@ -56,8 +57,7 @@ export function PluginUninstallDialog({
try {
await uninstallPlugin(plugin.id);
onSuccess();
// 触发侧边栏刷新事件
window.dispatchEvent(new CustomEvent("plugin-changed"));
notifyPluginUIChanged();
handleClose();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
@@ -19,7 +19,6 @@ const SidebarContainer = styled.aside`
min-width: 240px;
height: 100%;
background: hsl(var(--card));
border-right: 1px solid hsl(var(--border));
overflow-y: auto;
padding: 16px 8px;
+23 -22
View File
@@ -10,8 +10,8 @@ import type { UnlistenFn } from "@tauri-apps/api/event";
import { toast } from "sonner";
import { requireDefaultProjectId } from "@/lib/api/project";
import {
createAgentSession,
sendAsterMessageStream,
createAgentRuntimeSession,
submitAgentRuntimeTurn,
} from "@/lib/api/agentRuntime";
import type { ChatMessage, MessageImage, UseSmartInputReturn } from "./types";
import { parseStreamEvent, type StreamEvent } from "@/lib/api/agentStream";
@@ -97,13 +97,11 @@ export function useSmartInput(): UseSmartInputReturn {
workspaceIdRef.current = workspaceId;
}
const response = await createAgentSession(
DEFAULT_SMART_INPUT_PROVIDER,
const createdSessionId = await createAgentRuntimeSession(
workspaceId,
DEFAULT_SMART_INPUT_MODEL,
);
sessionIdRef.current = response.session_id;
return response.session_id;
sessionIdRef.current = createdSessionId;
return createdSessionId;
} catch (err) {
console.error("创建会话失败:", err);
return null;
@@ -241,23 +239,26 @@ export function useSmartInput(): UseSmartInputReturn {
}
// 发送流式请求(使用 Aster Agent)
await sendAsterMessageStream(
await submitAgentRuntimeTurn({
message,
sessionId,
eventName,
workspaceId,
images.length > 0
? images.map((img) => ({
data: img.data,
media_type: img.mediaType,
}))
: undefined,
{
provider_id: DEFAULT_SMART_INPUT_PROVIDER,
provider_name: DEFAULT_SMART_INPUT_PROVIDER,
model_name: DEFAULT_SMART_INPUT_MODEL,
session_id: sessionId,
event_name: eventName,
workspace_id: workspaceId,
images:
images.length > 0
? images.map((img) => ({
data: img.data,
media_type: img.mediaType,
}))
: undefined,
turn_config: {
provider_config: {
provider_id: DEFAULT_SMART_INPUT_PROVIDER,
provider_name: DEFAULT_SMART_INPUT_PROVIDER,
model_name: DEFAULT_SMART_INPUT_MODEL,
},
},
);
});
} catch (err) {
console.error("发送消息失败:", err);
const errorMsg = err instanceof Error ? err.message : "发送失败";
@@ -53,7 +53,7 @@ export const TerminalAIPanel: React.FC<TerminalAIPanelProps> = ({
// 加载技能列表
useEffect(() => {
skillsApi
.getAll("lime")
.getLocal("lime")
.then(setSkills)
.catch((err) => console.error("加载技能列表失败:", err));
}, []);
+32 -34
View File
@@ -12,10 +12,10 @@ import { toast } from "sonner";
import { safeListen } from "@/lib/dev-bridge";
import type { UnlistenFn } from "@tauri-apps/api/event";
import {
createAgentRuntimeSession,
startAgentProcess,
getAgentProcessStatus,
createAgentSession,
sendAsterMessageStream,
submitAgentRuntimeTurn,
sendTerminalCommandResponse,
sendTermScrollbackResponse,
type TerminalCommandRequest,
@@ -48,6 +48,15 @@ const DEFAULT_CONFIG: TerminalAIConfig = {
autoExecute: false, // 默认需要手动批准
};
const TERMINAL_AI_SYSTEM_PROMPT = `你是一个终端助手,帮助用户解决命令行相关的问题。
你可以:
- 解释命令的用法和参数
- 帮助调试错误信息
- 建议更好的命令或脚本
- 解答 shell 脚本相关问题
请用简洁清晰的语言回答,必要时提供代码示例。`;
/**
* 加载持久化数据
*/
@@ -171,33 +180,19 @@ export function useTerminalAI(
if (sessionId) return sessionId;
try {
// 构建系统提示词
const systemPrompt = `你是一个终端助手,帮助用户解决命令行相关的问题。
你可以:
- 解释命令的用法和参数
- 帮助调试错误信息
- 建议更好的命令或脚本
- 解答 shell 脚本相关问题
请用简洁清晰的语言回答,必要时提供代码示例。`;
const resolvedWorkspaceId = await ensureWorkspaceId();
const response = await createAgentSession(
providerId,
const createdSessionId = await createAgentRuntimeSession(
resolvedWorkspaceId,
modelId,
systemPrompt,
undefined,
);
setSessionId(response.session_id);
return response.session_id;
setSessionId(createdSessionId);
return createdSessionId;
} catch (error) {
console.error("[useTerminalAI] 创建会话失败:", error);
toast.error("创建 AI 会话失败");
return null;
}
}, [sessionId, providerId, modelId, ensureWorkspaceId]);
}, [sessionId, ensureWorkspaceId]);
/**
* 获取终端上下文
@@ -435,20 +430,23 @@ export function useTerminalAI(
const resolvedWorkspaceId = await ensureWorkspaceId();
await sendAsterMessageStream(
messageContent,
activeSessionId,
eventName,
resolvedWorkspaceId,
imagesToSend,
providerId
? {
provider_id: providerId,
provider_name: providerId,
model_name: modelId || "claude-sonnet-4-20250514",
}
: undefined,
);
await submitAgentRuntimeTurn({
message: messageContent,
session_id: activeSessionId,
event_name: eventName,
workspace_id: resolvedWorkspaceId,
images: imagesToSend,
turn_config: {
provider_config: providerId
? {
provider_id: providerId,
provider_name: providerId,
model_name: modelId || "claude-sonnet-4-20250514",
}
: undefined,
system_prompt: TERMINAL_AI_SYSTEM_PROMPT,
},
});
} catch (error) {
console.error("[useTerminalAI] 发送消息失败:", error);
toast.error(`发送失败: ${error}`);
+1 -1
View File
@@ -11,7 +11,7 @@
## useUnifiedChat
统一的对话逻辑 Hook,替代原有分散的 `useAgentChat` 和 `useChat`。
统一的对话逻辑 Hook,统一收口 Agent / General / Creator 三类对话入口。
### 使用示例
+14 -1
View File
@@ -26,6 +26,10 @@ export interface UseAliasConfigResult {
error: string | null;
}
interface UseAliasConfigOptions {
autoLoad?: boolean;
}
// ============================================================================
// Hook 实现
// ============================================================================
@@ -51,7 +55,9 @@ export interface UseAliasConfigResult {
*/
export function useAliasConfig(
selectedProvider: ConfiguredProvider | undefined | null,
options: UseAliasConfigOptions = {},
): UseAliasConfigResult {
const { autoLoad = true } = options;
const [aliasConfig, setAliasConfig] = useState<ProviderAliasConfig | null>(
null,
);
@@ -59,6 +65,13 @@ export function useAliasConfig(
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!autoLoad) {
setAliasConfig(null);
setLoading(false);
setError(null);
return;
}
// 如果没有选中 Provider,清空配置
if (!selectedProvider) {
setAliasConfig(null);
@@ -97,7 +110,7 @@ export function useAliasConfig(
});
// 只依赖 key 变化,避免 selectedProvider 对象引用变化导致不必要的重新加载
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedProvider?.key]);
}, [autoLoad, selectedProvider?.key]);
return {
aliasConfig,
+17 -3
View File
@@ -110,6 +110,10 @@ export interface UseApiKeyProviderReturn {
providersByGroup: Map<ProviderGroup, ProviderWithKeysDisplay[]>;
}
interface UseApiKeyProviderOptions {
autoLoad?: boolean;
}
// ============================================================================
// UI 状态键
// ============================================================================
@@ -163,7 +167,10 @@ function providerDebugLog(...args: unknown[]): void {
// Hook 实现
// ============================================================================
export function useApiKeyProvider(): UseApiKeyProviderReturn {
export function useApiKeyProvider(
options: UseApiKeyProviderOptions = {},
): UseApiKeyProviderReturn {
const { autoLoad = true } = options;
// ===== 状态 =====
const [providers, setProviders] = useState<ProviderWithKeysDisplay[]>([]);
const [selectedProviderId, setSelectedProviderId] = useState<string | null>(
@@ -211,7 +218,9 @@ export function useApiKeyProvider(): UseApiKeyProviderReturn {
setLoading(true);
}
setError(null);
providerCacheState.inFlight = apiKeyProviderApi.getProviders();
providerCacheState.inFlight = apiKeyProviderApi.getProviders(
force ? { forceRefresh: true } : undefined,
);
const data = await providerCacheState.inFlight;
providerDebugLog("[useApiKeyProvider] 获取到", data.length, "个 Provider");
@@ -265,6 +274,11 @@ export function useApiKeyProvider(): UseApiKeyProviderReturn {
// ===== 初始化 =====
useEffect(() => {
if (!autoLoad) {
setLoading(false);
return;
}
if (!applyCachedProviders()) {
void fetchProviders(true);
}
@@ -284,7 +298,7 @@ export function useApiKeyProvider(): UseApiKeyProviderReturn {
return () => {
unsubscribe();
};
}, [applyCachedProviders, fetchProviders, loadUiState]);
}, [applyCachedProviders, autoLoad, fetchProviders, loadUiState]);
// ===== 保存折叠状态 =====
const saveCollapsedGroups = useCallback(
+94 -65
View File
@@ -5,6 +5,12 @@
*/
import { useMemo } from "react";
import { apiKeyProviderApi } from "@/lib/api/apiKeyProvider";
import {
providerPoolApi,
type ProviderPoolOverview,
} from "@/lib/api/providerPool";
import type { ProviderWithKeysDisplay } from "@/lib/api/apiKeyProvider";
import { useProviderPool } from "./useProviderPool";
import { useApiKeyProvider } from "./useApiKeyProvider";
import {
@@ -45,6 +51,84 @@ export interface UseConfiguredProvidersResult {
loading: boolean;
}
export interface UseConfiguredProvidersOptions {
autoLoad?: boolean;
}
interface LoadConfiguredProvidersOptions {
forceRefresh?: boolean;
}
export function buildConfiguredProviders(
oauthCredentials: ProviderPoolOverview[],
apiKeyProviders: ProviderWithKeysDisplay[],
): ConfiguredProvider[] {
const safeOauthCredentials = Array.isArray(oauthCredentials)
? oauthCredentials
: [];
const safeApiKeyProviders = Array.isArray(apiKeyProviders)
? apiKeyProviders
: [];
const providerMap = new Map<string, ConfiguredProvider>();
safeOauthCredentials.forEach((overview) => {
if (overview.credentials.length > 0) {
const key = overview.provider_type;
const firstCredential = overview.credentials[0];
const credentialType = firstCredential.credential_type || key;
if (!providerMap.has(key)) {
providerMap.set(key, {
key,
label: getProviderLabel(key),
registryId: getRegistryIdFromType(key),
type: key,
credentialType,
});
}
}
});
safeApiKeyProviders
.filter((p) => p.api_key_count > 0 && p.enabled)
.forEach((provider) => {
let key = provider.id;
let label = provider.name;
if (providerMap.has(key)) {
key = `${provider.id}_api_key`;
label = `${provider.name} API Key`;
}
if (!providerMap.has(key)) {
providerMap.set(key, {
key,
label,
registryId: provider.id,
fallbackRegistryId: getRegistryIdFromType(provider.type),
type: provider.type,
credentialType: `${provider.type}_key`,
providerId: provider.id,
customModels: provider.custom_models,
});
}
});
return Array.from(providerMap.values());
}
export async function loadConfiguredProviders(
options: LoadConfiguredProvidersOptions = {},
): Promise<ConfiguredProvider[]> {
const sourceOptions = options.forceRefresh ? { forceRefresh: true } : undefined;
const [oauthCredentials, apiKeyProviders] = await Promise.all([
providerPoolApi.getOverview(sourceOptions),
apiKeyProviderApi.getProviders(sourceOptions),
]);
return buildConfiguredProviders(oauthCredentials, apiKeyProviders);
}
// ============================================================================
// Hook 实现
// ============================================================================
@@ -72,76 +156,21 @@ export interface UseConfiguredProvidersResult {
* );
* ```
*/
export function useConfiguredProviders(): UseConfiguredProvidersResult {
export function useConfiguredProviders(
options: UseConfiguredProvidersOptions = {},
): UseConfiguredProvidersResult {
const { autoLoad = true } = options;
// 获取凭证池数据
const { overview: oauthCredentials, loading: oauthLoading } =
useProviderPool();
useProviderPool({ autoLoad });
const { providers: apiKeyProviders, loading: apiKeyLoading } =
useApiKeyProvider();
useApiKeyProvider({ autoLoad });
// 计算已配置的 Provider 列表
const providers = useMemo(() => {
const safeOauthCredentials = Array.isArray(oauthCredentials)
? oauthCredentials
: [];
const safeApiKeyProviders = Array.isArray(apiKeyProviders)
? apiKeyProviders
: [];
const providerMap = new Map<string, ConfiguredProvider>();
// 1. 从 OAuth 凭证提取 Provider
safeOauthCredentials.forEach((overview) => {
if (overview.credentials.length > 0) {
const key = overview.provider_type;
const firstCredential = overview.credentials[0];
const credentialType = firstCredential.credential_type || key;
if (!providerMap.has(key)) {
providerMap.set(key, {
key,
label: getProviderLabel(key),
registryId: getRegistryIdFromType(key),
type: key,
credentialType,
});
}
}
});
// 2. 从 API Key Provider 提取
// 使用 provider.id 作为 key,确保每个 Provider 单独显示
// 特殊处理:如果与 OAuth 凭证冲突,使用带后缀的 key
safeApiKeyProviders
.filter((p) => p.api_key_count > 0 && p.enabled)
.forEach((provider) => {
let key = provider.id;
let label = provider.name;
// 如果 key 与 OAuth 凭证冲突,添加 "_api_key" 后缀
// 例如:Gemini OAuth 的 key 是 "gemini",Gemini API Key 的 key 变成 "gemini_api_key"
if (providerMap.has(key)) {
key = `${provider.id}_api_key`;
label = `${provider.name} API Key`;
}
if (!providerMap.has(key)) {
// 优先使用 provider.id 作为 registryId(适用于系统预设的 Provider,如 deepseek, moonshot)
// 如果模型注册表中没有该 id 的模型,则回退到使用 type 映射(适用于自定义 Provider)
providerMap.set(key, {
key,
label,
registryId: provider.id,
fallbackRegistryId: getRegistryIdFromType(provider.type),
type: provider.type,
credentialType: `${provider.type}_key`,
providerId: provider.id,
customModels: provider.custom_models,
});
}
});
return Array.from(providerMap.values());
}, [oauthCredentials, apiKeyProviders]);
const providers = useMemo(
() => buildConfiguredProviders(oauthCredentials, apiKeyProviders),
[oauthCredentials, apiKeyProviders],
);
return {
providers,
+14 -10
View File
@@ -9,7 +9,6 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { safeInvoke, safeListen } from "@/lib/dev-bridge";
import type { UnlistenFn } from "@tauri-apps/api/event";
import { onOpenUrl } from "@tauri-apps/plugin-deep-link";
import {
showDeepLinkError,
showApiKeySaveError,
@@ -375,17 +374,22 @@ export function useDeepLink(): UseDeepLinkReturn {
const setupListener = async () => {
try {
// 使用 @tauri-apps/plugin-deep-link 监听 Deep Link URL
// 监听插件派发的 Deep Link URL 事件
// _Requirements: 5.1_
unlistenDeepLink = await onOpenUrl(async (urls) => {
if (!mounted) return;
unlistenDeepLink = await safeListen<string[]>(
"deep-link://new-url",
async (event) => {
if (!mounted) return;
console.log("[useDeepLink] 收到 Deep Link URL:", urls);
const urls = Array.isArray(event.payload) ? event.payload : [];
console.log("[useDeepLink] 收到 Deep Link URL:", urls);
for (const url of urls) {
if (!url.startsWith("lime://connect")) {
continue;
}
for (const url of urls) {
if (url.startsWith("lime://connect")) {
try {
// 调用后端处理 Deep Link
const result = await safeInvoke<DeepLinkResult>(
"handle_deep_link",
{ url },
@@ -401,8 +405,8 @@ export function useDeepLink(): UseDeepLinkReturn {
}
}
}
}
});
},
);
// 监听后端发送的 deep-link-connect 事件(兼容旧逻辑)
const unlisten = await safeListen<DeepLinkResult>(
+298
View File
@@ -0,0 +1,298 @@
import { useEffect, useRef } from "react";
import { safeListen } from "@/lib/dev-bridge";
import { subscribeProviderDataChanged } from "@/lib/providerDataEvents";
import {
DEFAULT_AGENT_MODEL,
DEFAULT_AGENT_PROVIDER,
GLOBAL_MODEL_PREF_KEY,
GLOBAL_PROVIDER_PREF_KEY,
getAgentPreferenceKeys,
loadPersistedString,
savePersisted,
} from "@/components/agent/chat/hooks/agentChatStorage";
import {
invalidateTrayPayloadCache,
syncTrayModelShortcutsState,
} from "@/components/agent/chat/hooks/useTrayModelShortcuts";
import {
TRAY_MODEL_SELECTED_EVENT,
type TrayModelSelectedPayload,
} from "@/lib/api/tray";
import {
getThemeByWorkspacePage,
isThemeWorkspacePage,
type AgentPageParams,
type Page,
type PageParams,
type ProjectDetailPageParams,
} from "@/types/page";
const LAST_PROJECT_ID_KEY = "agent_last_project_id";
function normalizeProjectId(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function loadPersistedProjectId(key: string): string | null {
if (typeof window === "undefined") {
return null;
}
try {
const stored = window.localStorage.getItem(key);
if (!stored) {
return null;
}
try {
const parsed = JSON.parse(stored);
return normalizeProjectId(typeof parsed === "string" ? parsed : stored);
} catch {
return normalizeProjectId(stored);
}
} catch {
return null;
}
}
function savePersistedProjectId(projectId: string): void {
const normalized = normalizeProjectId(projectId);
if (!normalized || typeof window === "undefined") {
return;
}
try {
window.localStorage.setItem(
LAST_PROJECT_ID_KEY,
JSON.stringify(normalized),
);
} catch {
// ignore write errors
}
}
function resolveActiveProjectId(page: Page, pageParams?: PageParams): string | null {
if (page === "agent") {
return normalizeProjectId((pageParams as AgentPageParams | undefined)?.projectId);
}
if (isThemeWorkspacePage(page)) {
return normalizeProjectId(
(pageParams as { projectId?: string } | undefined)?.projectId,
);
}
if (page === "project-detail") {
return normalizeProjectId(
(pageParams as ProjectDetailPageParams | undefined)?.projectId,
);
}
return null;
}
function resolveActiveTheme(page: Page, pageParams?: PageParams): string | undefined {
if (isThemeWorkspacePage(page)) {
return getThemeByWorkspacePage(page);
}
if (page === "agent") {
const theme = (pageParams as AgentPageParams | undefined)?.theme;
return typeof theme === "string" && theme.trim() ? theme : undefined;
}
if (page === "project-detail") {
return (pageParams as ProjectDetailPageParams | undefined)?.workspaceTheme;
}
return undefined;
}
function resolvePersistedModelPreference(projectId: string | null): {
providerType: string;
model: string;
} {
const { providerKey, modelKey } = getAgentPreferenceKeys(projectId);
const providerType =
loadPersistedString(providerKey) ||
loadPersistedString(GLOBAL_PROVIDER_PREF_KEY) ||
DEFAULT_AGENT_PROVIDER;
const model =
loadPersistedString(modelKey) ||
loadPersistedString(GLOBAL_MODEL_PREF_KEY) ||
DEFAULT_AGENT_MODEL;
return {
providerType,
model,
};
}
function resolveTrayProjectId(page: Page, pageParams?: PageParams): string | null {
return (
loadPersistedProjectId(LAST_PROJECT_ID_KEY) ||
resolveActiveProjectId(page, pageParams)
);
}
interface UseGlobalTrayModelSyncOptions {
currentPage: Page;
pageParams?: PageParams;
}
export function useGlobalTrayModelSync({
currentPage,
pageParams,
}: UseGlobalTrayModelSyncOptions) {
const currentPageRef = useRef(currentPage);
const pageParamsRef = useRef<PageParams | undefined>(pageParams);
currentPageRef.current = currentPage;
pageParamsRef.current = pageParams;
useEffect(() => {
let cancelled = false;
const retryTimerIds: number[] = [];
const idleCallbackIds: number[] = [];
const scheduleInitialSync = (task: () => void) => {
if (import.meta.env.DEV) {
if (typeof window.requestIdleCallback === "function") {
idleCallbackIds.push(
window.requestIdleCallback(
() => {
if (!cancelled) {
task();
}
},
{ timeout: 1500 },
),
);
return;
}
retryTimerIds.push(
window.setTimeout(() => {
if (!cancelled) {
task();
}
}, 120),
);
return;
}
task();
};
const sync = async (
override?: {
projectId?: string | null;
providerType?: string;
model?: string;
theme?: string;
},
options?: {
forceRefresh?: boolean;
},
) => {
const projectId =
override?.projectId ??
resolveTrayProjectId(currentPageRef.current, pageParamsRef.current);
const theme =
override?.theme ??
resolveActiveTheme(currentPageRef.current, pageParamsRef.current);
const preference = resolvePersistedModelPreference(projectId);
const providerType = override?.providerType || preference.providerType;
const model = override?.model || preference.model;
if (projectId) {
savePersistedProjectId(projectId);
}
try {
await syncTrayModelShortcutsState(providerType, model, theme, options);
} catch (error) {
if (!cancelled) {
console.warn("[GlobalTrayModelSync] 同步托盘模型状态失败:", error);
}
}
};
scheduleInitialSync(() => {
void sync();
});
retryTimerIds.push(window.setTimeout(() => void sync(), 900));
retryTimerIds.push(window.setTimeout(() => void sync(), 2600));
const unsubscribe = subscribeProviderDataChanged(() => {
invalidateTrayPayloadCache();
void sync(undefined, { forceRefresh: true });
});
const handleFocus = () => {
invalidateTrayPayloadCache();
void sync(undefined, { forceRefresh: true });
};
window.addEventListener("focus", handleFocus);
let dispose: (() => void) | null = null;
safeListen<TrayModelSelectedPayload>(
TRAY_MODEL_SELECTED_EVENT,
(event) => {
if (cancelled) {
return;
}
const providerType = event.payload?.providerType?.trim() || "";
const model = event.payload?.model?.trim() || "";
if (!providerType || !model) {
return;
}
const projectId =
resolveTrayProjectId(currentPageRef.current, pageParamsRef.current);
const { providerKey, modelKey } = getAgentPreferenceKeys(projectId);
savePersisted(providerKey, providerType);
savePersisted(modelKey, model);
if (projectId) {
savePersistedProjectId(projectId);
}
void sync({
projectId,
providerType,
model,
});
},
)
.then((unlisten) => {
if (cancelled) {
void unlisten();
return;
}
dispose = unlisten;
})
.catch((error) => {
console.warn("[GlobalTrayModelSync] 监听托盘模型切换失败:", error);
});
return () => {
cancelled = true;
retryTimerIds.forEach((timerId) => window.clearTimeout(timerId));
idleCallbackIds.forEach((callbackId) => {
if (typeof window.cancelIdleCallback === "function") {
window.cancelIdleCallback(callbackId);
}
});
unsubscribe();
window.removeEventListener("focus", handleFocus);
if (dispose) {
dispose();
}
};
}, [currentPage]);
}
+48 -47
View File
@@ -12,7 +12,7 @@
*/
import { useState, useEffect, useCallback } from "react";
import { listen, UnlistenFn } from "@tauri-apps/api/event";
import type { UnlistenFn } from "@tauri-apps/api/event";
import {
mcpApi,
McpServerInfo,
@@ -24,6 +24,7 @@ import {
McpResourceContent,
McpServerCapabilities,
} from "@/lib/api/mcp";
import { safeListen } from "@/lib/dev-bridge";
// ============================================================================
// 事件 Payload 类型
@@ -254,55 +255,55 @@ export function useMcp(): UseMcpReturn {
};
const setupListeners = async () => {
// 监听服务器启动事件
const unlistenStarted = await listen<McpServerStartedPayload>(
"mcp:server_started",
(event) => {
console.log("[useMcp] 服务器已启动:", event.payload.server_name);
refreshServers();
refreshTools();
},
);
unlisteners.push(unlistenStarted);
try {
const unlistenStarted = await safeListen<McpServerStartedPayload>(
"mcp:server_started",
(event) => {
console.log("[useMcp] 服务器已启动:", event.payload.server_name);
refreshServers();
refreshTools();
},
);
unlisteners.push(unlistenStarted);
// 监听服务器停止事件
const unlistenStopped = await listen<McpServerStoppedPayload>(
"mcp:server_stopped",
(event) => {
console.log("[useMcp] 服务器已停止:", event.payload.server_name);
refreshServers();
refreshTools();
},
);
unlisteners.push(unlistenStopped);
const unlistenStopped = await safeListen<McpServerStoppedPayload>(
"mcp:server_stopped",
(event) => {
console.log("[useMcp] 服务器已停止:", event.payload.server_name);
refreshServers();
refreshTools();
},
);
unlisteners.push(unlistenStopped);
// 监听服务器错误事件
const unlistenError = await listen<McpServerErrorPayload>(
"mcp:server_error",
(event) => {
console.error(
"[useMcp] 服务器错误:",
event.payload.server_name,
event.payload.error,
);
if (mounted) {
setError(`${event.payload.server_name}: ${event.payload.error}`);
}
},
);
unlisteners.push(unlistenError);
const unlistenError = await safeListen<McpServerErrorPayload>(
"mcp:server_error",
(event) => {
console.error(
"[useMcp] 服务器错误:",
event.payload.server_name,
event.payload.error,
);
if (mounted) {
setError(`${event.payload.server_name}: ${event.payload.error}`);
}
},
);
unlisteners.push(unlistenError);
// 监听工具列表更新事件
const unlistenTools = await listen<McpToolsUpdatedPayload>(
"mcp:tools_updated",
(event) => {
console.log("[useMcp] 工具列表已更新:", event.payload.tools.length);
if (mounted) {
setTools(event.payload.tools);
}
},
);
unlisteners.push(unlistenTools);
const unlistenTools = await safeListen<McpToolsUpdatedPayload>(
"mcp:tools_updated",
(event) => {
console.log("[useMcp] 工具列表已更新:", event.payload.tools.length);
if (mounted) {
setTools(event.payload.tools);
}
},
);
unlisteners.push(unlistenTools);
} catch (error) {
console.error("[useMcp] 注册 MCP 事件监听失败:", error);
}
};
init();
+6 -4
View File
@@ -210,7 +210,7 @@ export function useModelRegistry(
const [lastSyncAt, setLastSyncAt] = useState<number | null>(null);
// 加载模型数据(带重试机制)
const loadModels = useCallback(async () => {
const loadModels = useCallback(async (forceRefresh = false) => {
setLoading(true);
setError(null);
@@ -220,7 +220,9 @@ export function useModelRegistry(
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const [models, prefs, syncState] = await Promise.all([
modelRegistryApi.getModelRegistry(),
modelRegistryApi.getModelRegistry(
forceRefresh ? { forceRefresh: true } : undefined,
),
modelRegistryApi.getModelPreferences(),
modelRegistryApi.getModelSyncState(),
]);
@@ -258,7 +260,7 @@ export function useModelRegistry(
try {
const count = await modelRegistryApi.refreshModelRegistry();
console.log(`[ModelRegistry] 刷新完成,加载了 ${count} 个模型`);
await loadModels();
await loadModels(true);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
@@ -384,7 +386,7 @@ export function useModelRegistry(
// 自动加载
useEffect(() => {
if (autoLoad) {
loadModels();
void loadModels();
}
}, [autoLoad, loadModels]);
+184 -112
View File
@@ -6,9 +6,13 @@
import { useMemo, useState, useEffect } from "react";
import { invoke } from "@tauri-apps/api/core";
import { modelRegistryApi } from "@/lib/api/modelRegistry";
import { useModelRegistry } from "./useModelRegistry";
import { useAliasConfig } from "./useAliasConfig";
import { isAliasProvider } from "@/lib/constants/providerMappings";
import {
getAliasConfigKey,
isAliasProvider,
} from "@/lib/constants/providerMappings";
import type { ConfiguredProvider } from "./useConfiguredProviders";
import type {
EnhancedModelMetadata,
@@ -55,6 +59,10 @@ interface FetchModelsResult {
should_prompt_error?: boolean;
}
interface LoadProviderModelsOptions {
forceRefresh?: boolean;
}
// ============================================================================
// 工具函数
// ============================================================================
@@ -168,6 +176,165 @@ function convertAliasModelsToMetadata(
});
}
function buildLocalProviderModels(
selectedProvider: ConfiguredProvider | undefined | null,
registryModels: EnhancedModelMetadata[],
aliasConfig: ProviderAliasConfig | null,
): {
modelIds: string[];
models: EnhancedModelMetadata[];
hasLocalModels: boolean;
} {
if (!selectedProvider) {
return { modelIds: [], models: [], hasLocalModels: false };
}
let allModels: EnhancedModelMetadata[] = [];
let allModelIds: string[] = [];
if (selectedProvider.customModels && selectedProvider.customModels.length > 0) {
const customModels = convertCustomModelsToMetadata(
selectedProvider.customModels,
selectedProvider.key,
selectedProvider.label,
);
allModels = [...customModels];
allModelIds = [...selectedProvider.customModels];
}
const findModelIndexById = (modelId: string): number => {
const targetId = modelId.toLowerCase();
return allModels.findIndex((model) => model.id.toLowerCase() === targetId);
};
if (isAliasProvider(selectedProvider.key) && aliasConfig) {
const aliasModels = convertAliasModelsToMetadata(
aliasConfig.models,
aliasConfig,
selectedProvider.key,
selectedProvider.label,
);
const newAliasModels = aliasModels.filter(
(model) =>
!allModelIds.some(
(existingModelId) =>
existingModelId.toLowerCase() === model.id.toLowerCase(),
),
);
allModels = [...allModels, ...newAliasModels];
allModelIds = [...allModelIds, ...newAliasModels.map((model) => model.id)];
}
const registryFilteredModels = registryModels.filter(
(model) => model.provider_id === selectedProvider.registryId,
);
const sortedRegistryModels = sortModels(registryFilteredModels);
for (const registryModel of sortedRegistryModels) {
const existingIndex = findModelIndexById(registryModel.id);
if (existingIndex >= 0) {
allModels[existingIndex] = registryModel;
continue;
}
allModels.push(registryModel);
allModelIds.push(registryModel.id);
}
const hasLocalModels = Boolean(
sortedRegistryModels.length > 0 ||
(isAliasProvider(selectedProvider.key) &&
aliasConfig &&
aliasConfig.models.length > 0),
);
return {
modelIds: allModelIds,
models: allModels,
hasLocalModels,
};
}
async function fetchProviderModelsFromApi(
selectedProvider: ConfiguredProvider,
registryModels: EnhancedModelMetadata[],
): Promise<EnhancedModelMetadata[]> {
try {
const result = await invoke<FetchModelsResult>("fetch_provider_models_auto", {
providerId: selectedProvider.key,
});
if (result && result.models && result.models.length > 0) {
return result.models;
}
} catch {
// ignore and fall back below
}
if (selectedProvider.fallbackRegistryId) {
const fallbackModels = registryModels.filter(
(model) => model.provider_id === selectedProvider.fallbackRegistryId,
);
if (fallbackModels.length > 0) {
return sortModels(fallbackModels);
}
}
return [];
}
export async function loadProviderModels(
selectedProvider: ConfiguredProvider | undefined | null,
options: LoadProviderModelsOptions = {},
): Promise<EnhancedModelMetadata[]> {
if (!selectedProvider) {
return [];
}
const sourceOptions = options.forceRefresh ? { forceRefresh: true } : undefined;
const aliasConfigPromise = isAliasProvider(selectedProvider.key)
? modelRegistryApi.getProviderAliasConfig(
getAliasConfigKey(selectedProvider.key),
sourceOptions,
)
: Promise.resolve(null);
const [registryModels, aliasConfig] = await Promise.all([
modelRegistryApi.getModelRegistry(sourceOptions),
aliasConfigPromise,
]);
const localResult = buildLocalProviderModels(
selectedProvider,
registryModels,
aliasConfig,
);
if (localResult.hasLocalModels || localResult.models.length > 0) {
return localResult.models;
}
if (isAliasProvider(selectedProvider.key)) {
return localResult.models;
}
const apiModels = await fetchProviderModelsFromApi(selectedProvider, registryModels);
if (apiModels.length === 0) {
return localResult.models;
}
const customModels = selectedProvider.customModels || [];
const customModelMetadata =
customModels.length > 0
? convertCustomModelsToMetadata(
customModels,
selectedProvider.key,
selectedProvider.label,
)
: [];
return [...customModelMetadata, ...apiModels];
}
// ============================================================================
// Hook 实现
// ============================================================================
@@ -209,7 +376,7 @@ export function useProviderModels(
// 获取别名配置
const { aliasConfig, loading: aliasLoading } =
useAliasConfig(selectedProvider);
useAliasConfig(selectedProvider, { autoLoad });
// API 获取的模型缓存
const [apiModels, setApiModels] = useState<EnhancedModelMetadata[]>([]);
@@ -217,89 +384,10 @@ export function useProviderModels(
const [apiError, setApiError] = useState<string | null>(null);
// 计算本地模型列表
const localResult = useMemo(() => {
if (!selectedProvider) {
return { modelIds: [], models: [], hasLocalModels: false };
}
// 收集所有模型
let allModels: EnhancedModelMetadata[] = [];
let allModelIds: string[] = [];
// 1. 首先添加自定义模型(排在最前面)
if (
selectedProvider.customModels &&
selectedProvider.customModels.length > 0
) {
const customModels = convertCustomModelsToMetadata(
selectedProvider.customModels,
selectedProvider.key,
selectedProvider.label,
);
allModels = [...customModels];
allModelIds = [...selectedProvider.customModels];
}
const findModelIndexById = (modelId: string): number => {
const targetId = modelId.toLowerCase();
return allModels.findIndex(
(model) => model.id.toLowerCase() === targetId,
);
};
// 2. 对于别名 Provider,添加别名配置中的模型
if (isAliasProvider(selectedProvider.key) && aliasConfig) {
const aliasModels = convertAliasModelsToMetadata(
aliasConfig.models,
aliasConfig,
selectedProvider.key,
selectedProvider.label,
);
// 过滤掉已存在的模型(避免重复)
const newAliasModels = aliasModels.filter(
(m) =>
!allModelIds.some(
(existingModelId) =>
existingModelId.toLowerCase() === m.id.toLowerCase(),
),
);
allModels = [...allModels, ...newAliasModels];
allModelIds = [...allModelIds, ...newAliasModels.map((m) => m.id)];
}
// 3. 从模型注册表获取模型
const registryFilteredModels = registryModels.filter(
(m) => m.provider_id === selectedProvider.registryId,
);
// 排序注册表模型,并用其覆盖同 ID 的别名/自定义占位元数据
const sortedRegistryModels = sortModels(registryFilteredModels);
for (const registryModel of sortedRegistryModels) {
const existingIndex = findModelIndexById(registryModel.id);
if (existingIndex >= 0) {
allModels[existingIndex] = registryModel;
continue;
}
allModels.push(registryModel);
allModelIds.push(registryModel.id);
}
// 判断是否有本地模型(不包括自定义模型)
const hasLocalModels =
sortedRegistryModels.length > 0 ||
(isAliasProvider(selectedProvider.key) &&
aliasConfig &&
aliasConfig.models.length > 0);
return {
modelIds: allModelIds,
models: allModels,
hasLocalModels,
};
}, [selectedProvider, registryModels, aliasConfig]);
const localResult = useMemo(
() => buildLocalProviderModels(selectedProvider, registryModels, aliasConfig),
[selectedProvider, registryModels, aliasConfig],
);
// 当本地没有模型时,从 API 获取
useEffect(() => {
@@ -308,6 +396,13 @@ export function useProviderModels(
return;
}
if (!autoLoad) {
setApiModels([]);
setApiLoading(false);
setApiError(null);
return;
}
// 如果是别名 Provider,不从 API 获取
if (isAliasProvider(selectedProvider.key)) {
return;
@@ -330,36 +425,12 @@ export function useProviderModels(
setApiError(null);
try {
const result = await invoke<FetchModelsResult>(
"fetch_provider_models_auto",
{ providerId: selectedProvider.key },
setApiModels(
await fetchProviderModelsFromApi(selectedProvider, registryModels),
);
if (result && result.models && result.models.length > 0) {
setApiModels(result.models);
} else {
// API 没有返回模型,尝试 fallback
if (selectedProvider.fallbackRegistryId) {
const fallbackModels = registryModels.filter(
(m) => m.provider_id === selectedProvider.fallbackRegistryId,
);
if (fallbackModels.length > 0) {
setApiModels(sortModels(fallbackModels));
}
}
}
} catch (err) {
setApiError(err instanceof Error ? err.message : String(err));
// API 失败,尝试 fallback
if (selectedProvider.fallbackRegistryId) {
const fallbackModels = registryModels.filter(
(m) => m.provider_id === selectedProvider.fallbackRegistryId,
);
if (fallbackModels.length > 0) {
setApiModels(sortModels(fallbackModels));
}
}
setApiModels([]);
} finally {
setApiLoading(false);
}
@@ -368,6 +439,7 @@ export function useProviderModels(
fetchFromApi();
}, [
selectedProvider,
autoLoad,
localResult.hasLocalModels,
registryLoading,
aliasLoading,
+21 -9
View File
@@ -14,18 +14,25 @@ import {
subscribeProviderDataChanged,
} from "@/lib/providerDataEvents";
export function useProviderPool() {
interface UseProviderPoolOptions {
autoLoad?: boolean;
}
export function useProviderPool(options: UseProviderPoolOptions = {}) {
const { autoLoad = true } = options;
const [overview, setOverview] = useState<ProviderPoolOverview[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [checkingHealth, setCheckingHealth] = useState<string | null>(null);
const [refreshingToken, setRefreshingToken] = useState<string | null>(null);
const fetchOverview = useCallback(async () => {
const fetchOverview = useCallback(async (forceRefresh = false) => {
try {
setLoading(true);
setError(null);
const data = await providerPoolApi.getOverview();
const data = await providerPoolApi.getOverview(
forceRefresh ? { forceRefresh: true } : undefined,
);
setOverview(data);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
@@ -35,29 +42,34 @@ export function useProviderPool() {
}, []);
const refreshAndNotify = useCallback(async () => {
await fetchOverview();
await fetchOverview(true);
emitProviderDataChanged("provider_pool");
}, [fetchOverview]);
useEffect(() => {
fetchOverview();
if (!autoLoad) {
setLoading(false);
return;
}
void fetchOverview();
if (typeof window === "undefined" || typeof document === "undefined") {
return;
}
const handleFocus = () => {
void fetchOverview();
void fetchOverview(true);
};
const handleVisibilityChange = () => {
if (!document.hidden) {
void fetchOverview();
void fetchOverview(true);
}
};
const unsubscribe = subscribeProviderDataChanged(() => {
void fetchOverview();
void fetchOverview(true);
});
window.addEventListener("focus", handleFocus);
@@ -68,7 +80,7 @@ export function useProviderPool() {
document.removeEventListener("visibilitychange", handleVisibilityChange);
unsubscribe();
};
}, [fetchOverview]);
}, [autoLoad, fetchOverview]);
// Add Kiro OAuth credential
const addKiroOAuth = async (credsFilePath: string, name?: string) => {
+142
View File
@@ -0,0 +1,142 @@
import { useEffect } from "react";
import { act } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { AppType, Skill, SkillRepo } from "@/lib/api/skills";
import {
cleanupMountedRoots,
flushEffects,
mountHarness,
setupReactActEnvironment,
type MountedRoot,
} from "@/components/workspace/hooks/testUtils";
import { useSkills } from "./useSkills";
const {
mockGetLocal,
mockGetAll,
mockGetRepos,
mockRefreshCache,
} = vi.hoisted(() => ({
mockGetLocal: vi.fn(),
mockGetAll: vi.fn(),
mockGetRepos: vi.fn(),
mockRefreshCache: vi.fn(),
}));
vi.mock("@/lib/api/skills", async () => {
const actual =
await vi.importActual<typeof import("@/lib/api/skills")>(
"@/lib/api/skills",
);
return {
...actual,
skillsApi: {
...actual.skillsApi,
getLocal: (...args: unknown[]) => mockGetLocal(...args),
getAll: (...args: unknown[]) => mockGetAll(...args),
getRepos: (...args: unknown[]) => mockGetRepos(...args),
refreshCache: (...args: unknown[]) => mockRefreshCache(...args),
},
};
});
type HookValue = ReturnType<typeof useSkills>;
interface HarnessProps {
app?: AppType;
onReady: (value: HookValue) => void;
}
function HookHarness({ app = "lime", onReady }: HarnessProps) {
const value = useSkills(app);
useEffect(() => {
onReady(value);
}, [onReady, value]);
return null;
}
function createSkill(overrides: Partial<Skill> = {}): Skill {
return {
key: "local:test-skill",
name: "测试技能",
description: "测试用技能",
directory: "test-skill",
installed: true,
sourceKind: "other",
...overrides,
};
}
const mountedRoots: MountedRoot[] = [];
describe("useSkills", () => {
let latestValue: HookValue | null = null;
beforeEach(() => {
setupReactActEnvironment();
latestValue = null;
vi.clearAllMocks();
mockGetLocal.mockResolvedValue([]);
mockGetAll.mockResolvedValue([]);
mockGetRepos.mockResolvedValue([] satisfies SkillRepo[]);
mockRefreshCache.mockResolvedValue(true);
});
afterEach(() => {
cleanupMountedRoots(mountedRoots);
});
async function renderHook(app: AppType) {
mountHarness(HookHarness, {
app,
onReady: (value) => {
latestValue = value;
},
}, mountedRoots);
await flushEffects(6);
}
function getLatestValue(): HookValue {
expect(latestValue).not.toBeNull();
return latestValue as HookValue;
}
it("首次挂载时只加载本地技能和仓库信息", async () => {
const localSkill = createSkill();
mockGetLocal.mockResolvedValue([localSkill]);
await renderHook("lime");
expect(mockGetLocal).toHaveBeenCalledWith("lime");
expect(mockGetRepos).toHaveBeenCalledTimes(1);
expect(mockGetAll).not.toHaveBeenCalled();
expect(getLatestValue().skills).toEqual([localSkill]);
expect(getLatestValue().remoteLoading).toBe(false);
});
it("显式刷新时才清缓存并拉取远程技能目录", async () => {
const remoteSkill = createSkill({
key: "owner/repo:test-skill",
installed: false,
catalogSource: "remote",
repoOwner: "owner",
repoName: "repo",
repoBranch: "main",
});
mockGetAll.mockResolvedValue([remoteSkill]);
await renderHook("codex");
await act(async () => {
await getLatestValue().refresh();
});
await flushEffects(4);
expect(mockRefreshCache).toHaveBeenCalledTimes(1);
expect(mockGetAll).toHaveBeenCalledWith("codex", { refreshRemote: true });
expect(getLatestValue().skills).toEqual([remoteSkill]);
});
});
+13 -23
View File
@@ -39,6 +39,7 @@ export function useSkills(app: AppType = "lime") {
async (refreshRemote = false) => {
try {
setLoading(true);
setRemoteLoading(true);
setError(null);
const data = await skillsApi.getAll(app, { refreshRemote });
setSkills(data);
@@ -46,6 +47,7 @@ export function useSkills(app: AppType = "lime") {
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setRemoteLoading(false);
setLoading(false);
}
},
@@ -72,29 +74,17 @@ export function useSkills(app: AppType = "lime") {
}
initializedRef.current = true;
// 阶段 1:快速拿本地+内置技能(同步接口,不走网络)
skillsApi.getLocal(app).then((localData) => {
setSkills(localData);
setLoading(false);
// 阶段 2:后台拉取全部(含远程仓库)
setRemoteLoading(true);
skillsApi
.getAll(app, { refreshRemote: false })
.then((allData) => {
setSkills(allData);
updateCache(allData);
})
.catch(() => {
// 远程失败,保留本地数据
updateCache(localData);
})
.finally(() => {
setRemoteLoading(false);
});
}).catch((e) => {
setError(e instanceof Error ? e.message : String(e));
setLoading(false);
});
skillsApi
.getLocal(app)
.then((localData) => {
setSkills(localData);
updateCache(localData);
setLoading(false);
})
.catch((e) => {
setError(e instanceof Error ? e.message : String(e));
setLoading(false);
});
fetchRepos();
}, [app, isCacheFresh, fetchAllSkills, fetchRepos, updateCache]);
-1
View File
@@ -800,7 +800,6 @@
"参数": "Parameters",
"执行日志": "Execution Log",
"执行结果": "Execution Result",
"// === components\\agent\\chat\\hooks\\useAgentChat.ts ===": "",
"加载模型配置失败": "Failed to load model configuration",
"使用默认配置": "Using default configuration",
"模型": "Model",
-1
View File
@@ -774,7 +774,6 @@
"参数": "参数",
"执行日志": "执行日志",
"执行结果": "执行结果",
"// === components\\agent\\chat\\hooks\\useAgentChat.ts ===": "",
"加载模型配置失败": "加载模型配置失败",
"使用默认配置": "使用默认配置",
"模型": "模型",
+77
View File
@@ -0,0 +1,77 @@
import { act } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
cleanupMountedRoots,
flushEffects,
mountHarness,
setupReactActEnvironment,
type MountedRoot,
} from "@/components/workspace/hooks/testUtils";
const { mockGetConfig, mockHasTauriInvokeCapability, mockReplaceTextInDOM } =
vi.hoisted(() => ({
mockGetConfig: vi.fn(),
mockHasTauriInvokeCapability: vi.fn(),
mockReplaceTextInDOM: vi.fn(),
}));
vi.mock("@/lib/api/appConfig", () => ({
getConfig: (...args: unknown[]) => mockGetConfig(...args),
}));
vi.mock("@/lib/tauri-runtime", () => ({
hasTauriInvokeCapability: (...args: unknown[]) =>
mockHasTauriInvokeCapability(...args),
}));
vi.mock("./dom-replacer", () => ({
replaceTextInDOM: (...args: unknown[]) => mockReplaceTextInDOM(...args),
}));
import { withI18nPatch } from "./withI18nPatch";
const mountedRoots: MountedRoot[] = [];
function DemoComponent() {
return <div>应用已就绪</div>;
}
describe("withI18nPatch", () => {
beforeEach(() => {
setupReactActEnvironment();
vi.clearAllMocks();
vi.useFakeTimers();
vi.stubGlobal(
"requestAnimationFrame",
((callback: (time: number) => void) =>
window.setTimeout(() => callback(0), 0)) as typeof requestAnimationFrame,
);
mockHasTauriInvokeCapability.mockReturnValue(true);
});
afterEach(() => {
cleanupMountedRoots(mountedRoots);
vi.unstubAllGlobals();
vi.useRealTimers();
});
it("配置读取超时后回退默认语言并继续渲染", async () => {
mockGetConfig.mockImplementation(
() => new Promise(() => undefined) as Promise<unknown>,
);
const PatchedComponent = withI18nPatch(DemoComponent);
const mounted = mountHarness(PatchedComponent, {}, mountedRoots);
await flushEffects(2);
expect(mounted.container.textContent).toBe("");
await act(async () => {
await vi.advanceTimersByTimeAsync(2600);
});
await flushEffects(4);
expect(mounted.container.textContent).toContain("应用已就绪");
expect(mockReplaceTextInDOM).toHaveBeenCalledWith("zh");
});
});
+55 -27
View File
@@ -19,14 +19,15 @@ import { getConfig, type Config } from "@/lib/api/appConfig";
import { I18nPatchProvider } from "./I18nPatchProvider";
import { Language } from "./text-map";
import { replaceTextInDOM } from "./dom-replacer";
import { hasTauriInvokeCapability } from "@/lib/tauri-runtime";
const CONFIG_LOAD_TIMEOUT_MS = 2500;
/**
* 检查是否在 Tauri 环境中运行
*/
function isTauriEnvironment(): boolean {
if (typeof window === "undefined") return false;
const w = window as any;
return !!(w.__TAURI__?.core?.invoke || w.__TAURI__?.invoke);
return hasTauriInvokeCapability();
}
interface WithI18nPatchOptions {
@@ -52,39 +53,66 @@ export function withI18nPatch<P extends object>(
const [isReady, setIsReady] = useState(false);
useEffect(() => {
let cancelled = false;
let timeoutId: number | null = null;
const applyConfig = (nextConfig: Config) => {
if (cancelled) {
return;
}
setConfig(nextConfig);
replaceTextInDOM((nextConfig.language || "zh") as Language);
requestAnimationFrame(() => {
if (!cancelled) {
setIsReady(true);
}
});
};
const fallbackToDefault = (reason: string, error?: unknown) => {
if (error) {
console.error(`[i18n] ${reason}:`, error);
} else {
console.warn(`[i18n] ${reason}`);
}
applyConfig({ language: "zh" } as Config);
};
// 如果不在 Tauri 环境,使用默认配置
if (!isTauriEnvironment()) {
console.warn("[i18n] Not in Tauri environment, using default language");
const defaultConfig = { language: "zh" } as Config;
setConfig(defaultConfig);
replaceTextInDOM("zh");
requestAnimationFrame(() => {
setIsReady(true);
});
return;
fallbackToDefault("Not in Tauri environment, using default language");
return () => {
cancelled = true;
};
}
timeoutId = window.setTimeout(() => {
fallbackToDefault("Config load timed out, using default language");
}, CONFIG_LOAD_TIMEOUT_MS);
getConfig()
.then((c) => {
setConfig(c);
// Apply initial patch immediately (synchronous)
const lang = (c.language || "zh") as Language;
replaceTextInDOM(lang);
// Fade in after patch is complete
requestAnimationFrame(() => {
setIsReady(true);
});
if (timeoutId !== null) {
window.clearTimeout(timeoutId);
timeoutId = null;
}
applyConfig(c);
})
.catch((err) => {
console.error("[i18n] Failed to load config:", err);
// Use default language on error
const defaultConfig = { language: "zh" } as Config;
setConfig(defaultConfig);
replaceTextInDOM("zh");
requestAnimationFrame(() => {
setIsReady(true);
});
if (timeoutId !== null) {
window.clearTimeout(timeoutId);
timeoutId = null;
}
fallbackToDefault("Failed to load config", err);
});
return () => {
cancelled = true;
if (timeoutId !== null) {
window.clearTimeout(timeoutId);
}
};
}, []);
if (!config) {
+174
View File
@@ -0,0 +1,174 @@
import { reportFrontendDebugLog } from "@/lib/api/frontendDebug";
export const AGENT_DEBUG_FLAG_KEY = "lime:agent-debug";
type AgentDebugLevel = "debug" | "info" | "warn" | "error";
interface AgentDebugOptions {
level?: AgentDebugLevel;
throttleMs?: number;
dedupeKey?: string;
consoleOnly?: boolean;
}
type SerializableDebugValue =
| null
| boolean
| number
| string
| SerializableDebugValue[]
| { [key: string]: SerializableDebugValue };
const logThrottleMap = new Map<string, number>();
function readAgentDebugFlag(): string | null {
if (typeof window === "undefined") {
return null;
}
try {
return window.localStorage.getItem(AGENT_DEBUG_FLAG_KEY);
} catch {
return null;
}
}
export function isAgentDebugEnabled(): boolean {
const flag = readAgentDebugFlag();
if (flag === "0") {
return false;
}
if (flag === "1") {
return true;
}
if (import.meta.env.MODE === "test") {
return false;
}
return Boolean(import.meta.env.DEV);
}
function sanitizeDebugValue(
value: unknown,
depth = 0,
): SerializableDebugValue | undefined {
if (value == null) {
return null;
}
if (
typeof value === "boolean" ||
typeof value === "number" ||
typeof value === "string"
) {
if (typeof value === "string" && value.length > 300) {
return `${value.slice(0, 300)}...`;
}
return value;
}
if (value instanceof Date) {
return value.toISOString();
}
if (value instanceof Error) {
return {
name: value.name,
message: value.message,
stack: value.stack
? value.stack.split("\n").slice(0, 4).join(" | ")
: null,
};
}
if (depth >= 3) {
return String(value);
}
if (Array.isArray(value)) {
return value
.slice(0, 12)
.map((item) => sanitizeDebugValue(item, depth + 1) ?? null);
}
if (typeof value === "object") {
const entries = Object.entries(value as Record<string, unknown>)
.sort(([left], [right]) => left.localeCompare(right))
.slice(0, 24);
const result: Record<string, SerializableDebugValue> = {};
for (const [key, item] of entries) {
const sanitized = sanitizeDebugValue(item, depth + 1);
if (sanitized !== undefined) {
result[key] = sanitized;
}
}
return result;
}
return String(value);
}
function getConsoleMethod(level: AgentDebugLevel): typeof console.log {
if (level === "error") {
return console.error;
}
if (level === "warn") {
return console.warn;
}
if (level === "debug") {
return console.debug;
}
return console.info;
}
function shouldEmitLog(key: string, throttleMs: number): boolean {
if (throttleMs <= 0) {
return true;
}
const now = Date.now();
const lastLoggedAt = logThrottleMap.get(key) ?? 0;
if (now - lastLoggedAt < throttleMs) {
return false;
}
logThrottleMap.set(key, now);
return true;
}
export function logAgentDebug(
component: string,
phase: string,
context?: Record<string, unknown>,
options: AgentDebugOptions = {},
): void {
if (!isAgentDebugEnabled()) {
return;
}
const level = options.level ?? "info";
const sanitizedContext = sanitizeDebugValue(context ?? {}) as
| Record<string, SerializableDebugValue>
| undefined;
const dedupeKey =
options.dedupeKey ??
`${component}:${phase}:${JSON.stringify(sanitizedContext ?? {})}`;
if (!shouldEmitLog(dedupeKey, options.throttleMs ?? 0)) {
return;
}
getConsoleMethod(level)(
`[AgentDebug] ${component}.${phase}`,
sanitizedContext ?? {},
);
if (options.consoleOnly) {
return;
}
void reportFrontendDebugLog({
level,
category: "agent",
message: `${component}.${phase}`,
context: sanitizedContext ?? {},
}).catch(() => {
// 调试日志不上抛,避免影响主流程
});
}
+167 -78
View File
@@ -8,12 +8,15 @@ vi.mock("@/lib/dev-bridge", () => ({
safeInvoke: mockSafeInvoke,
}));
import * as AgentApi from "./agent";
import {
createAgentRuntimeSession,
deleteAgentRuntimeSession,
getAsterAgentStatus,
generateAgentRuntimeSessionTitle,
getAgentRuntimeSession,
interruptAgentRuntimeTurn,
listAgentRuntimeSessions,
respondAgentRuntimeAction,
sendAsterMessageStream,
submitAgentRuntimeTurn,
updateAgentRuntimeSession,
} from "./agentRuntime";
@@ -23,68 +26,21 @@ describe("Agent API 治理护栏", () => {
vi.clearAllMocks();
});
it("sendAsterMessageStream 应走统一 helper 并透传现役字段", async () => {
mockSafeInvoke.mockResolvedValueOnce(undefined);
it("createAgentRuntimeSession 应走统一 runtime create 命令", async () => {
mockSafeInvoke.mockResolvedValueOnce("session-created");
await sendAsterMessageStream(
"hello",
"session-2",
"event-2",
"workspace-2",
[{ data: "base64", media_type: "image/jpeg" }],
await expect(
createAgentRuntimeSession("workspace-2", "新会话", "auto"),
).resolves.toBe("session-created");
expect(mockSafeInvoke).toHaveBeenCalledWith(
"agent_runtime_create_session",
{
provider_id: "provider-2",
provider_name: "Provider 2",
model_name: "model-2",
},
"auto",
true,
{
enabled: true,
fast_mode_enabled: false,
continuation_length: 256,
sensitivity: 0.4,
},
"system prompt",
"project-2",
{
harness: {
theme: "social-media",
gate_key: "write_mode",
},
workspaceId: "workspace-2",
name: "新会话",
executionStrategy: "auto",
},
);
expect(mockSafeInvoke).toHaveBeenCalledWith("aster_agent_chat_stream", {
request: {
message: "hello",
session_id: "session-2",
event_name: "event-2",
images: [{ data: "base64", media_type: "image/jpeg" }],
provider_config: {
provider_id: "provider-2",
provider_name: "Provider 2",
model_name: "model-2",
},
project_id: "project-2",
workspace_id: "workspace-2",
execution_strategy: "auto",
web_search: true,
auto_continue: {
enabled: true,
fast_mode_enabled: false,
continuation_length: 256,
sensitivity: 0.4,
},
system_prompt: "system prompt",
metadata: {
harness: {
theme: "social-media",
gate_key: "write_mode",
},
},
},
});
});
it("getAsterAgentStatus 应返回现役状态结构", async () => {
@@ -187,20 +143,23 @@ describe("Agent API 治理护栏", () => {
request_id: "req-runtime",
action_type: "ask_user",
confirmed: true,
response: "{\"answer\":\"A\"}",
response: '{"answer":"A"}',
user_data: { answer: "A" },
});
expect(mockSafeInvoke).toHaveBeenCalledWith("agent_runtime_respond_action", {
request: {
session_id: "session-runtime",
request_id: "req-runtime",
action_type: "ask_user",
confirmed: true,
response: "{\"answer\":\"A\"}",
user_data: { answer: "A" },
expect(mockSafeInvoke).toHaveBeenCalledWith(
"agent_runtime_respond_action",
{
request: {
session_id: "session-runtime",
request_id: "req-runtime",
action_type: "ask_user",
confirmed: true,
response: '{"answer":"A"}',
user_data: { answer: "A" },
},
},
});
);
});
it("interruptAgentRuntimeTurn 与 updateAgentRuntimeSession 应走统一 runtime 命令", async () => {
@@ -239,13 +198,143 @@ describe("Agent API 治理护栏", () => {
);
});
it("agent 门面只暴露现役 API", () => {
expect("sendAsterMessageStream" in AgentApi).toBe(true);
expect("getAsterAgentStatus" in AgentApi).toBe(true);
expect("submitAgentRuntimeTurn" in AgentApi).toBe(true);
expect("respondAgentRuntimeAction" in AgentApi).toBe(true);
expect("createasterSession" in AgentApi).toBe(false);
expect("sendAgentMessage" in AgentApi).toBe(false);
expect("getasterAgentStatus" in AgentApi).toBe(false);
it("listAgentRuntimeSessions 应返回现役 runtime 会话列表", async () => {
mockSafeInvoke.mockResolvedValueOnce([
{
id: "session-runtime-1",
name: "Runtime Session",
model: "claude-sonnet-4-20250514",
created_at: 1710000000,
updated_at: 1710000123,
messages_count: 3,
execution_strategy: "auto",
workspace_id: "workspace-1",
working_dir: "/tmp/workspace-1",
},
]);
await expect(listAgentRuntimeSessions()).resolves.toEqual([
{
id: "session-runtime-1",
name: "Runtime Session",
model: "claude-sonnet-4-20250514",
created_at: 1710000000,
updated_at: 1710000123,
messages_count: 3,
workspace_id: "workspace-1",
working_dir: "/tmp/workspace-1",
execution_strategy: "auto",
},
]);
expect(mockSafeInvoke).toHaveBeenCalledWith("agent_runtime_list_sessions");
});
it("getAgentRuntimeSession 应返回现役 runtime 详情并归一 queued_turns", async () => {
mockSafeInvoke.mockResolvedValueOnce({
id: "session-runtime-2",
name: "Runtime Detail",
model: "gpt-5.4",
created_at: 1710001000,
updated_at: 1710002000,
workspace_id: "workspace-2",
working_dir: "/tmp/workspace-2",
execution_strategy: "react",
queued_turns: [
{
queued_turn_id: "queued-1",
message_text: "排队中的任务",
message_preview: "排队中的任务",
created_at: 1710001500,
image_count: 0,
position: 2,
},
],
messages: [
{
role: "user",
content: [{ type: "text", text: "hello" }],
timestamp: 1710001000,
},
{
role: "assistant",
content: [{ type: "text", text: "world" }],
timestamp: 1710002000,
},
],
});
await expect(getAgentRuntimeSession("session-runtime-2")).resolves.toEqual({
id: "session-runtime-2",
name: "Runtime Detail",
model: "gpt-5.4",
created_at: 1710001000,
updated_at: 1710002000,
workspace_id: "workspace-2",
working_dir: "/tmp/workspace-2",
execution_strategy: "react",
queued_turns: [
{
queued_turn_id: "queued-1",
message_text: "排队中的任务",
message_preview: "排队中的任务",
created_at: 1710001500,
image_count: 0,
position: 2,
},
],
messages: [
{
role: "user",
content: [{ type: "text", text: "hello" }],
timestamp: 1710001000,
},
{
role: "assistant",
content: [{ type: "text", text: "world" }],
timestamp: 1710002000,
},
],
});
expect(mockSafeInvoke).toHaveBeenCalledWith("agent_runtime_get_session", {
sessionId: "session-runtime-2",
});
});
it("deleteAgentRuntimeSession / updateAgentRuntimeSession / generateAgentRuntimeSessionTitle 应走现役命令", async () => {
mockSafeInvoke
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce("新的智能标题");
await deleteAgentRuntimeSession("session-runtime-3");
await updateAgentRuntimeSession({
session_id: "session-runtime-3",
name: "重命名后的标题",
});
await expect(
generateAgentRuntimeSessionTitle("session-runtime-3"),
).resolves.toBe("新的智能标题");
expect(mockSafeInvoke).toHaveBeenNthCalledWith(
1,
"agent_runtime_delete_session",
{
sessionId: "session-runtime-3",
},
);
expect(mockSafeInvoke).toHaveBeenNthCalledWith(
2,
"agent_runtime_update_session",
{
request: {
session_id: "session-runtime-3",
name: "重命名后的标题",
},
},
);
expect(mockSafeInvoke).toHaveBeenNthCalledWith(3, "agent_generate_title", {
sessionId: "session-runtime-3",
});
});
});
-12
View File
@@ -1,12 +0,0 @@
/**
* Agent API 现役门面
*
* 新代码请优先直接使用:
* - `agentRuntime.ts`:现役运行时 API
* - `agentStream.ts`:流式事件与 UI 类型
*
* 历史 `agentCompat.ts` 已删除,避免新代码和 AI 自动补全继续误用旧 API。
*/
export * from "./agentRuntime";
export * from "./agentStream";
+66 -374
View File
@@ -5,6 +5,7 @@
*/
import { safeInvoke } from "@/lib/dev-bridge";
import { logAgentDebug } from "@/lib/agentDebug";
import type {
AgentThreadItem,
AgentThreadTurn,
@@ -26,36 +27,8 @@ export interface AgentProcessStatus {
port?: number;
}
/**
* 创建会话响应
*/
export interface CreateSessionResponse {
session_id: string;
credential_name: string;
credential_uuid: string;
provider_type: string;
model?: string;
execution_strategy?: AsterExecutionStrategy;
}
export type AsterExecutionStrategy = "react" | "code_orchestrated" | "auto";
/**
* 会话信息
*/
export interface SessionInfo {
session_id: string;
provider_type: string;
model?: string;
title?: string;
created_at: string;
last_activity: string;
messages_count: number;
workspace_id?: string;
working_dir?: string;
execution_strategy?: AsterExecutionStrategy;
}
/**
* 图片输入
*/
@@ -64,15 +37,6 @@ export interface ImageInput {
media_type: string;
}
/**
* Skill 信息
*/
export interface SkillInfo {
name: string;
description?: string;
path?: string;
}
const requireWorkspaceId = (
workspaceId?: string,
fallbackWorkspaceId?: string,
@@ -123,8 +87,11 @@ export interface AsterSessionInfo {
name?: string;
created_at: number;
updated_at: number;
model?: string;
messages_count?: number;
execution_strategy?: AsterExecutionStrategy;
workspace_id?: string;
working_dir?: string;
}
/**
@@ -133,6 +100,7 @@ export interface AsterSessionInfo {
export interface TauriMessageContent {
type: string;
text?: string;
image_url?: { url: string; detail?: string } | string;
id?: string;
action_type?: string;
data?: unknown;
@@ -154,6 +122,9 @@ export interface AsterSessionDetail {
name?: string;
created_at: number;
updated_at: number;
model?: string;
workspace_id?: string;
working_dir?: string;
execution_strategy?: AsterExecutionStrategy;
messages: Array<{
id?: string;
@@ -181,6 +152,7 @@ export interface AgentRuntimeSubmitTurnRequest {
session_id: string;
event_name: string;
workspace_id: string;
turn_id?: string;
images?: ImageInput[];
turn_config?: AgentTurnConfigSnapshot;
queue_if_busy?: boolean;
@@ -213,58 +185,6 @@ export interface AgentRuntimeUpdateSessionRequest {
execution_strategy?: AsterExecutionStrategy;
}
interface InvokeAsterChatStreamOptions {
message: string;
sessionId: string;
eventName: string;
workspaceId: string;
images?: ImageInput[];
providerConfig?: AsterProviderConfig;
executionStrategy?: AsterExecutionStrategy;
webSearch?: boolean;
searchMode?: AgentSearchMode;
autoContinue?: AutoContinueRequestPayload;
systemPrompt?: string;
projectId?: string;
metadata?: Record<string, unknown>;
}
const invokeAsterChatStream = async ({
message,
sessionId,
eventName,
workspaceId,
images,
providerConfig,
executionStrategy,
webSearch,
searchMode,
autoContinue,
systemPrompt,
projectId,
metadata,
}: InvokeAsterChatStreamOptions): Promise<void> => {
const resolvedWorkspaceId = requireWorkspaceId(workspaceId, projectId);
return await safeInvoke("aster_agent_chat_stream", {
request: {
message,
session_id: sessionId,
event_name: eventName,
images,
provider_config: providerConfig,
project_id: projectId,
workspace_id: resolvedWorkspaceId,
execution_strategy: executionStrategy,
web_search: webSearch,
search_mode: searchMode,
auto_continue: autoContinue,
system_prompt: systemPrompt,
metadata,
},
});
};
export async function submitAgentRuntimeTurn(
request: AgentRuntimeSubmitTurnRequest,
): Promise<void> {
@@ -302,7 +222,58 @@ export async function createAgentRuntimeSession(
}
export async function listAgentRuntimeSessions(): Promise<AsterSessionInfo[]> {
return await safeInvoke("agent_runtime_list_sessions");
const startedAt = Date.now();
let settled = false;
const slowTimer: ReturnType<typeof setTimeout> | null =
typeof window !== "undefined"
? window.setTimeout(() => {
if (settled) {
return;
}
logAgentDebug(
"AgentApi",
"runtimeListSessions.slow",
{
elapsedMs: Date.now() - startedAt,
},
{
dedupeKey: "runtimeListSessions.slow",
level: "warn",
throttleMs: 1000,
},
);
}, 1000)
: null;
logAgentDebug("AgentApi", "runtimeListSessions.start");
try {
const sessions = await safeInvoke<AsterSessionInfo[]>(
"agent_runtime_list_sessions",
);
settled = true;
logAgentDebug("AgentApi", "runtimeListSessions.success", {
durationMs: Date.now() - startedAt,
sessionsCount: sessions.length,
});
return sessions;
} catch (error) {
settled = true;
logAgentDebug(
"AgentApi",
"runtimeListSessions.error",
{
durationMs: Date.now() - startedAt,
error,
},
{ level: "error" },
);
throw error;
} finally {
if (slowTimer !== null) {
clearTimeout(slowTimer);
}
}
}
export async function getAgentRuntimeSession(
@@ -351,114 +322,13 @@ export async function getAgentProcessStatus(): Promise<AgentProcessStatus> {
}
/**
* 创建 Agent 会话
* 生成会话智能标题
*
* 现役 runtime 命名入口。
*/
export async function createAgentSession(
providerType: string,
workspaceId: string,
model?: string,
systemPrompt?: string,
skills?: SkillInfo[],
executionStrategy?: AsterExecutionStrategy,
): Promise<CreateSessionResponse> {
const resolvedWorkspaceId = requireWorkspaceId(workspaceId);
return await safeInvoke("agent_create_session", {
providerType,
model,
systemPrompt,
skills,
workspaceId: resolvedWorkspaceId,
executionStrategy,
});
}
/**
* 获取会话列表
*/
export async function listAgentSessions(): Promise<SessionInfo[]> {
return await safeInvoke("agent_list_sessions");
}
/**
* 获取会话详情
*/
export async function getAgentSession(sessionId: string): Promise<SessionInfo> {
return await safeInvoke("agent_get_session", {
sessionId,
});
}
/**
* 删除会话
*/
export async function deleteAgentSession(sessionId: string): Promise<void> {
return await safeInvoke("agent_delete_session", {
sessionId,
});
}
/**
* Agent 消息内容类型
*/
export type AgentMessageContent =
| string
| Array<
| { type: "text"; text: string }
| { type: "image_url"; image_url: { url: string; detail?: string } }
>;
/**
* 工具调用
*/
export interface AgentToolCall {
id: string;
type: string;
function: {
name: string;
arguments: string;
};
}
/**
* Agent 消息
*/
export interface AgentMessage {
role: string;
content: AgentMessageContent;
timestamp: string;
tool_calls?: AgentToolCall[];
tool_call_id?: string;
}
/**
* 获取会话消息列表
*/
export async function getAgentSessionMessages(
export async function generateAgentRuntimeSessionTitle(
sessionId: string,
): Promise<AgentMessage[]> {
return await safeInvoke("agent_get_session_messages", {
sessionId,
});
}
/**
* 重命名会话(更新标题)
*/
export async function renameAgentSession(
sessionId: string,
title: string,
): Promise<void> {
return await safeInvoke("agent_rename_session", {
sessionId,
title,
});
}
/**
* 生成智能标题
*/
export async function generateAgentTitle(sessionId: string): Promise<string> {
): Promise<string> {
return await safeInvoke("agent_generate_title", {
sessionId,
});
@@ -491,155 +361,6 @@ export async function configureAsterProvider(
});
}
/**
* 发送消息到 Aster Agent (流式响应)
*
* 通过 Tauri 事件接收响应流
*/
export async function sendAsterMessageStream(
message: string,
sessionId: string,
eventName: string,
workspaceId: string,
images?: ImageInput[],
providerConfig?: AsterProviderConfig,
executionStrategy?: AsterExecutionStrategy,
webSearch?: boolean,
autoContinue?: AutoContinueRequestPayload,
systemPrompt?: string,
projectId?: string,
metadata?: Record<string, unknown>,
): Promise<void> {
return await invokeAsterChatStream({
message,
sessionId,
eventName,
workspaceId,
images,
providerConfig,
executionStrategy,
webSearch,
autoContinue,
systemPrompt,
projectId,
metadata,
});
}
/**
* 停止 Aster Agent 会话
*/
export async function stopAsterSession(sessionId: string): Promise<boolean> {
return await safeInvoke("aster_agent_stop", { sessionId });
}
/**
* 创建 Aster 会话
*/
export async function createAsterSession(
workspaceId: string,
workingDir?: string,
name?: string,
executionStrategy?: AsterExecutionStrategy,
): Promise<string> {
const resolvedWorkspaceId = requireWorkspaceId(workspaceId);
return await safeInvoke("aster_session_create", {
workingDir,
workspaceId: resolvedWorkspaceId,
name,
executionStrategy,
});
}
/**
* 获取 Aster 会话列表
*/
export async function listAsterSessions(): Promise<AsterSessionInfo[]> {
return await safeInvoke("aster_session_list");
}
/**
* 获取 Aster 会话详情
*/
export async function getAsterSession(
sessionId: string,
): Promise<AsterSessionDetail> {
const detail = await safeInvoke("aster_session_get", { sessionId });
return {
...(detail as AsterSessionDetail),
queued_turns: normalizeQueuedTurnSnapshots(
(detail as AsterSessionDetail | null | undefined)?.queued_turns,
),
};
}
/**
* 重命名 Aster 会话
*/
export async function renameAsterSession(
sessionId: string,
name: string,
): Promise<void> {
return await safeInvoke("aster_session_rename", { sessionId, name });
}
/**
* 设置 Aster 会话执行策略
*/
export async function setAsterSessionExecutionStrategy(
sessionId: string,
executionStrategy: AsterExecutionStrategy,
): Promise<void> {
return await safeInvoke("aster_session_set_execution_strategy", {
sessionId,
executionStrategy,
});
}
/**
* 删除 Aster 会话
*/
export async function deleteAsterSession(sessionId: string): Promise<void> {
return await safeInvoke("aster_session_delete", { sessionId });
}
/**
* 确认 Aster Agent 权限请求
*/
export async function confirmAsterAction(
requestId: string,
confirmed: boolean,
response?: string,
): Promise<void> {
return await safeInvoke("aster_agent_confirm", {
request: {
request_id: requestId,
confirmed,
response,
},
});
}
/**
* 提交 Aster Agent elicitation 响应
*/
export async function submitAsterElicitationResponse(
sessionId: string,
requestId: string,
userData: unknown,
metadata?: Record<string, unknown>,
): Promise<void> {
return await safeInvoke("aster_agent_submit_elicitation_response", {
sessionId,
request: {
request_id: requestId,
user_data: userData,
metadata,
},
});
}
/**
* 终端命令请求(从后端发送到前端)
*/
@@ -745,32 +466,3 @@ export async function sendTermScrollbackResponse(
error: response.error,
});
}
/**
* 权限确认响应
*/
export interface PermissionResponse {
/** 请求 ID */
requestId: string;
/** 是否确认 */
confirmed: boolean;
/** 响应内容(用户输入或选择的答案) */
response?: string;
}
/**
* 发送权限确认响应到后端
*
* 当用户确认或拒绝权限请求后,调用此函数将结果发送给 Agent
*/
export async function sendPermissionResponse(
response: PermissionResponse,
): Promise<void> {
return await safeInvoke("aster_agent_confirm", {
request: {
request_id: response.requestId,
confirmed: response.confirmed,
response: response.response,
},
});
}
+96 -1
View File
@@ -1,6 +1,9 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { safeInvoke } from "@/lib/dev-bridge";
import { apiKeyProviderApi } from "./apiKeyProvider";
import {
apiKeyProviderApi,
invalidateApiKeyProviderCache,
} from "./apiKeyProvider";
vi.mock("@/lib/dev-bridge", () => ({
safeInvoke: vi.fn(),
@@ -9,6 +12,7 @@ vi.mock("@/lib/dev-bridge", () => ({
describe("apiKeyProvider API", () => {
beforeEach(() => {
vi.clearAllMocks();
invalidateApiKeyProviderCache();
});
it("应代理现役 provider 命令", async () => {
@@ -36,4 +40,95 @@ describe("apiKeyProvider API", () => {
expect("migrateLegacyCredentials" in apiKeyProviderApi).toBe(false);
expect("deleteLegacyCredential" in apiKeyProviderApi).toBe(false);
});
it("getProviders 应缓存并复用同一轮读取结果", async () => {
vi.mocked(safeInvoke).mockResolvedValueOnce([
{
id: "openai",
name: "OpenAI",
type: "openai",
enabled: true,
api_key_count: 1,
api_keys: [{ id: "key-1", provider_id: "openai", enabled: true }],
},
]);
const [first, second] = await Promise.all([
apiKeyProviderApi.getProviders(),
apiKeyProviderApi.getProviders(),
]);
expect(vi.mocked(safeInvoke)).toHaveBeenCalledTimes(1);
expect(first).toEqual(second);
expect(first).not.toBe(second);
});
it("forceRefresh 应绕过 Provider 缓存", async () => {
vi.mocked(safeInvoke)
.mockResolvedValueOnce([
{
id: "openai",
name: "OpenAI",
type: "openai",
enabled: true,
api_key_count: 1,
api_keys: [],
},
])
.mockResolvedValueOnce([
{
id: "deepseek",
name: "DeepSeek",
type: "deepseek",
enabled: true,
api_key_count: 2,
api_keys: [],
},
]);
await expect(apiKeyProviderApi.getProviders()).resolves.toEqual([
expect.objectContaining({ id: "openai" }),
]);
await expect(
apiKeyProviderApi.getProviders({ forceRefresh: true }),
).resolves.toEqual([expect.objectContaining({ id: "deepseek" })]);
expect(vi.mocked(safeInvoke)).toHaveBeenCalledTimes(2);
});
it("写操作成功后应失效缓存", async () => {
vi.mocked(safeInvoke)
.mockResolvedValueOnce([
{
id: "openai",
name: "OpenAI",
type: "openai",
enabled: true,
api_key_count: 1,
api_keys: [],
},
])
.mockResolvedValueOnce({ id: "key-2" })
.mockResolvedValueOnce([
{
id: "openai",
name: "OpenAI",
type: "openai",
enabled: true,
api_key_count: 2,
api_keys: [{ id: "key-2", provider_id: "openai", enabled: true }],
},
]);
await apiKeyProviderApi.getProviders();
await apiKeyProviderApi.addApiKey({
provider_id: "openai",
api_key: "sk-test",
});
await expect(apiKeyProviderApi.getProviders()).resolves.toEqual([
expect.objectContaining({ api_key_count: 2 }),
]);
expect(vi.mocked(safeInvoke)).toHaveBeenCalledTimes(3);
});
});
+86 -11
View File
@@ -9,6 +9,65 @@
import { safeInvoke } from "@/lib/dev-bridge";
interface ProviderQueryOptions {
forceRefresh?: boolean;
}
let providersCache: ProviderWithKeysDisplay[] | null = null;
let providersLoadingPromise: Promise<ProviderWithKeysDisplay[]> | null = null;
function cloneProviderList(
providers: ProviderWithKeysDisplay[],
): ProviderWithKeysDisplay[] {
return providers.map((provider) => ({
...provider,
api_keys: Array.isArray(provider.api_keys)
? provider.api_keys.map((apiKey) => ({ ...apiKey }))
: [],
custom_models: Array.isArray(provider.custom_models)
? [...provider.custom_models]
: [],
}));
}
export function invalidateApiKeyProviderCache(): void {
providersCache = null;
providersLoadingPromise = null;
}
async function loadProviders(
options: ProviderQueryOptions = {},
): Promise<ProviderWithKeysDisplay[]> {
if (options.forceRefresh) {
invalidateApiKeyProviderCache();
}
if (providersCache) {
return cloneProviderList(providersCache);
}
if (!providersLoadingPromise) {
providersLoadingPromise = safeInvoke<ProviderWithKeysDisplay[]>(
"get_api_key_providers",
)
.then((providers) => {
providersCache = cloneProviderList(providers);
return providersCache;
})
.finally(() => {
providersLoadingPromise = null;
});
}
return cloneProviderList(await providersLoadingPromise);
}
async function invalidateAfterMutation<T>(promise: Promise<T>): Promise<T> {
const result = await promise;
invalidateApiKeyProviderCache();
return result;
}
// ============================================================================
// 请求类型
// ============================================================================
@@ -156,8 +215,10 @@ export const apiKeyProviderApi = {
/**
* 获取所有 API Key Provider(包含 API Keys)
*/
async getProviders(): Promise<ProviderWithKeysDisplay[]> {
return safeInvoke("get_api_key_providers");
async getProviders(
options: ProviderQueryOptions = {},
): Promise<ProviderWithKeysDisplay[]> {
return loadProviders(options);
},
/**
@@ -173,7 +234,9 @@ export const apiKeyProviderApi = {
async addCustomProvider(
request: AddCustomProviderRequest,
): Promise<ProviderDisplay> {
return safeInvoke("add_custom_api_key_provider", { request });
return invalidateAfterMutation(
safeInvoke("add_custom_api_key_provider", { request }),
);
},
/**
@@ -183,35 +246,41 @@ export const apiKeyProviderApi = {
id: string,
request: UpdateProviderRequest,
): Promise<ProviderDisplay> {
return safeInvoke("update_api_key_provider", { id, request });
return invalidateAfterMutation(
safeInvoke("update_api_key_provider", { id, request }),
);
},
/**
* 删除自定义 Provider
*/
async deleteCustomProvider(id: string): Promise<boolean> {
return safeInvoke("delete_custom_api_key_provider", { id });
return invalidateAfterMutation(
safeInvoke("delete_custom_api_key_provider", { id }),
);
},
/**
* 添加 API Key
*/
async addApiKey(request: AddApiKeyRequest): Promise<ApiKeyDisplay> {
return safeInvoke("add_api_key", { request });
return invalidateAfterMutation(safeInvoke("add_api_key", { request }));
},
/**
* 删除 API Key
*/
async deleteApiKey(keyId: string): Promise<boolean> {
return safeInvoke("delete_api_key", { keyId });
return invalidateAfterMutation(safeInvoke("delete_api_key", { keyId }));
},
/**
* 切换 API Key 启用状态
*/
async toggleApiKey(keyId: string, enabled: boolean): Promise<ApiKeyDisplay> {
return safeInvoke("toggle_api_key", { keyId, enabled });
return invalidateAfterMutation(
safeInvoke("toggle_api_key", { keyId, enabled }),
);
},
/**
@@ -221,7 +290,9 @@ export const apiKeyProviderApi = {
keyId: string,
alias?: string,
): Promise<ApiKeyDisplay> {
return safeInvoke("update_api_key_alias", { keyId, alias });
return invalidateAfterMutation(
safeInvoke("update_api_key_alias", { keyId, alias }),
);
},
/**
@@ -264,7 +335,9 @@ export const apiKeyProviderApi = {
* **Validates: Requirements 8.4**
*/
async updateSortOrders(sortOrders: [string, number][]): Promise<void> {
return safeInvoke("update_provider_sort_orders", { sortOrders });
return invalidateAfterMutation(
safeInvoke("update_provider_sort_orders", { sortOrders }),
);
},
/**
@@ -278,7 +351,9 @@ export const apiKeyProviderApi = {
* 导入 Provider 配置
*/
async importConfig(configJson: string): Promise<ImportResult> {
return safeInvoke("import_api_key_providers", { configJson });
return invalidateAfterMutation(
safeInvoke("import_api_key_providers", { configJson }),
);
},
// ============================================================================
+81
View File
@@ -3,6 +3,7 @@ import { safeInvoke } from "@/lib/dev-bridge";
import {
getConfig,
getDefaultProvider,
invalidateAppConfigCache,
getEnvironmentPreview,
saveConfig,
setDefaultProvider,
@@ -16,6 +17,8 @@ vi.mock("@/lib/dev-bridge", () => ({
describe("appConfig API", () => {
beforeEach(() => {
vi.clearAllMocks();
window.localStorage.clear();
invalidateAppConfigCache();
});
it("应代理读取配置命令", async () => {
@@ -47,4 +50,82 @@ describe("appConfig API", () => {
updateProviderEnvVars("openai", "https://example.com", "key"),
).resolves.toBeUndefined();
});
it("getConfig 应缓存并复用同一轮读取结果", async () => {
vi.mocked(safeInvoke).mockResolvedValueOnce({
default_provider: "claude",
navigation: { enabled_items: ["agent"] },
});
const [first, second] = await Promise.all([getConfig(), getConfig()]);
expect(vi.mocked(safeInvoke)).toHaveBeenCalledTimes(1);
expect(first).toEqual(
expect.objectContaining({ default_provider: "claude" }),
);
expect(second).toEqual(
expect.objectContaining({ default_provider: "claude" }),
);
expect(first).not.toBe(second);
});
it("saveConfig 后后续 getConfig 应直接命中新缓存", async () => {
vi.mocked(safeInvoke).mockResolvedValueOnce(undefined);
const nextConfig = {
default_provider: "kiro",
navigation: { enabled_items: ["agent", "tools"] },
} as never;
await expect(saveConfig(nextConfig)).resolves.toBeUndefined();
await expect(getConfig()).resolves.toEqual(
expect.objectContaining({
default_provider: "kiro",
navigation: { enabled_items: ["agent", "tools"] },
}),
);
expect(vi.mocked(safeInvoke)).toHaveBeenCalledTimes(1);
});
it("setDefaultProvider 应更新已缓存配置中的 default_provider", async () => {
vi.mocked(safeInvoke)
.mockResolvedValueOnce({
default_provider: "claude",
navigation: { enabled_items: ["agent"] },
})
.mockResolvedValueOnce("gemini");
await getConfig();
await expect(setDefaultProvider("gemini")).resolves.toBe("gemini");
await expect(getConfig()).resolves.toEqual(
expect.objectContaining({ default_provider: "gemini" }),
);
expect(vi.mocked(safeInvoke)).toHaveBeenCalledTimes(2);
});
it("updateProviderEnvVars 后应失效缓存并触发下一次重新读取", async () => {
vi.mocked(safeInvoke)
.mockResolvedValueOnce({
default_provider: "claude",
navigation: { enabled_items: ["agent"] },
})
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce({
default_provider: "openai",
navigation: { enabled_items: ["agent", "tools"] },
});
await getConfig();
await updateProviderEnvVars("openai", "https://example.com", "key");
await expect(getConfig()).resolves.toEqual(
expect.objectContaining({
default_provider: "openai",
navigation: { enabled_items: ["agent", "tools"] },
}),
);
expect(vi.mocked(safeInvoke)).toHaveBeenCalledTimes(3);
});
});
+100 -5
View File
@@ -1,6 +1,12 @@
import { safeInvoke } from "@/lib/dev-bridge";
import type { Config, EnvironmentPreview } from "./appConfigTypes";
const APP_CONFIG_CHANGE_STAMP_KEY = "lime.app-config.changed-at";
let configCache: Config | null = null;
let configLoadingPromise: Promise<Config> | null = null;
let configCacheStamp: string | null = null;
export type {
Config,
CrashReportingConfig,
@@ -24,12 +30,88 @@ export type {
VoiceConfig,
} from "./appConfigTypes";
export async function getConfig(): Promise<Config> {
return safeInvoke("get_config");
interface GetConfigOptions {
forceRefresh?: boolean;
}
function cloneConfig(config: Config): Config {
if (typeof structuredClone === "function") {
return structuredClone(config);
}
return JSON.parse(JSON.stringify(config)) as Config;
}
function readAppConfigChangeStamp(): string | null {
if (typeof window === "undefined") {
return null;
}
try {
return window.localStorage.getItem(APP_CONFIG_CHANGE_STAMP_KEY);
} catch {
return null;
}
}
function markAppConfigChanged(): string | null {
const nextStamp = String(Date.now());
if (typeof window !== "undefined") {
try {
window.localStorage.setItem(APP_CONFIG_CHANGE_STAMP_KEY, nextStamp);
} catch {
// ignore
}
}
return nextStamp;
}
function invalidateConfigCache(): void {
configCache = null;
configLoadingPromise = null;
configCacheStamp = null;
}
export function invalidateAppConfigCache(): void {
invalidateConfigCache();
}
export async function getConfig(
options: GetConfigOptions = {},
): Promise<Config> {
if (options.forceRefresh) {
invalidateConfigCache();
}
const currentStamp = readAppConfigChangeStamp();
if (configCache && configCacheStamp !== currentStamp) {
invalidateConfigCache();
}
if (configCache) {
return cloneConfig(configCache);
}
if (!configLoadingPromise) {
configLoadingPromise = safeInvoke<Config>("get_config")
.then((config) => {
configCache = cloneConfig(config);
configCacheStamp = readAppConfigChangeStamp();
return configCache;
})
.finally(() => {
configLoadingPromise = null;
});
}
return cloneConfig(await configLoadingPromise);
}
export async function saveConfig(config: Config): Promise<void> {
return safeInvoke("save_config", { config });
await safeInvoke("save_config", { config });
configCache = cloneConfig(config);
configCacheStamp = markAppConfigChanged();
}
export async function getEnvironmentPreview(): Promise<EnvironmentPreview> {
@@ -41,7 +123,18 @@ export async function getDefaultProvider(): Promise<string> {
}
export async function setDefaultProvider(provider: string): Promise<string> {
return safeInvoke("set_default_provider", { provider });
const nextProvider = await safeInvoke<string>("set_default_provider", {
provider,
});
if (configCache) {
configCache = {
...cloneConfig(configCache),
default_provider: nextProvider,
};
}
configCacheStamp = markAppConfigChanged();
return nextProvider;
}
export async function updateProviderEnvVars(
@@ -49,9 +142,11 @@ export async function updateProviderEnvVars(
apiHost: string,
apiKey?: string,
): Promise<void> {
return safeInvoke("update_provider_env_vars", {
await safeInvoke("update_provider_env_vars", {
providerType,
apiHost,
apiKey: apiKey || null,
});
invalidateConfigCache();
configCacheStamp = markAppConfigChanged();
}
+24
View File
@@ -0,0 +1,24 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { safeInvoke } from "@/lib/dev-bridge";
import { reportFrontendDebugLog } from "./frontendDebug";
vi.mock("@/lib/dev-bridge", () => ({
safeInvoke: vi.fn(),
}));
describe("frontendDebug API", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("应代理前端调试日志上报命令", async () => {
vi.mocked(safeInvoke).mockResolvedValueOnce(undefined);
await expect(
reportFrontendDebugLog({
message: "AgentChatPage.loadData.start",
category: "agent",
}),
).resolves.toBeUndefined();
});
});
+14
View File
@@ -0,0 +1,14 @@
import { safeInvoke } from "@/lib/dev-bridge";
export interface FrontendDebugLogReport {
message: string;
level?: "debug" | "info" | "warn" | "error";
category?: string;
context?: unknown;
}
export async function reportFrontendDebugLog(
report: FrontendDebugLogReport,
): Promise<void> {
await safeInvoke("report_frontend_debug_log", { report });
}
+59 -1
View File
@@ -5,6 +5,7 @@
*/
import { safeInvoke } from "@/lib/dev-bridge";
import { logAgentDebug } from "@/lib/agentDebug";
// ==================== 类型定义 ====================
@@ -261,7 +262,64 @@ export async function deleteOutlineNode(id: string): Promise<boolean> {
export async function getProjectMemory(
projectId: string,
): Promise<ProjectMemory> {
return safeInvoke<ProjectMemory>("project_memory_get", { projectId });
const startedAt = Date.now();
let settled = false;
const slowTimer: ReturnType<typeof setTimeout> | null =
typeof window !== "undefined"
? window.setTimeout(() => {
if (settled) {
return;
}
logAgentDebug(
"AgentApi",
"projectMemoryGet.slow",
{
elapsedMs: Date.now() - startedAt,
projectId,
},
{
dedupeKey: `projectMemoryGet.slow:${projectId}`,
level: "warn",
throttleMs: 1000,
},
);
}, 1000)
: null;
logAgentDebug("AgentApi", "projectMemoryGet.start", { projectId });
try {
const memory = await safeInvoke<ProjectMemory>("project_memory_get", {
projectId,
});
settled = true;
logAgentDebug("AgentApi", "projectMemoryGet.success", {
charactersCount: memory.characters.length,
durationMs: Date.now() - startedAt,
hasStyleGuide: Boolean(memory.style_guide),
hasWorldBuilding: Boolean(memory.world_building),
outlineCount: memory.outline.length,
projectId,
});
return memory;
} catch (error) {
settled = true;
logAgentDebug(
"AgentApi",
"projectMemoryGet.error",
{
durationMs: Date.now() - startedAt,
error,
projectId,
},
{ level: "error" },
);
throw error;
} finally {
if (slowTimer !== null) {
clearTimeout(slowTimer);
}
}
}
// ==================== 辅助函数 ====================
+95
View File
@@ -0,0 +1,95 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { safeInvoke } from "@/lib/dev-bridge";
import {
getAllAliasConfigs,
getModelRegistry,
getProviderAliasConfig,
invalidateModelRegistryCache,
refreshModelRegistry,
} from "./modelRegistry";
vi.mock("@/lib/dev-bridge", () => ({
safeInvoke: vi.fn(),
}));
describe("modelRegistry API", () => {
beforeEach(() => {
vi.clearAllMocks();
invalidateModelRegistryCache();
});
it("getModelRegistry 应缓存并复用同一轮读取结果", async () => {
vi.mocked(safeInvoke).mockResolvedValueOnce([
{
id: "gpt-4.1",
display_name: "GPT-4.1",
provider_id: "openai",
provider_name: "OpenAI",
},
]);
const [first, second] = await Promise.all([
getModelRegistry(),
getModelRegistry(),
]);
expect(vi.mocked(safeInvoke)).toHaveBeenCalledTimes(1);
expect(first).toEqual(second);
expect(first).not.toBe(second);
});
it("getProviderAliasConfig 应复用已加载的全量别名配置", async () => {
vi.mocked(safeInvoke).mockResolvedValueOnce({
kiro: {
models: ["kimi-k2"],
aliases: {
"kimi-k2": {
actual: "kimi-k2",
},
},
},
});
await expect(getAllAliasConfigs()).resolves.toEqual(
expect.objectContaining({
kiro: expect.objectContaining({
models: ["kimi-k2"],
}),
}),
);
await expect(getProviderAliasConfig("kiro")).resolves.toEqual(
expect.objectContaining({ models: ["kimi-k2"] }),
);
expect(vi.mocked(safeInvoke)).toHaveBeenCalledTimes(1);
});
it("refreshModelRegistry 后应失效缓存并触发下一次重新读取", async () => {
vi.mocked(safeInvoke)
.mockResolvedValueOnce([
{
id: "gpt-4.1",
display_name: "GPT-4.1",
provider_id: "openai",
provider_name: "OpenAI",
},
])
.mockResolvedValueOnce(285)
.mockResolvedValueOnce([
{
id: "gpt-5",
display_name: "GPT-5",
provider_id: "openai",
provider_name: "OpenAI",
},
]);
await getModelRegistry();
await expect(refreshModelRegistry()).resolves.toBe(285);
await expect(getModelRegistry()).resolves.toEqual([
expect.objectContaining({ id: "gpt-5" }),
]);
expect(vi.mocked(safeInvoke)).toHaveBeenCalledTimes(3);
});
});
+142 -6
View File
@@ -13,11 +13,75 @@ import type {
UserModelPreference,
} from "@/lib/types/modelRegistry";
interface ModelRegistryQueryOptions {
forceRefresh?: boolean;
}
let modelRegistryCache: EnhancedModelMetadata[] | null = null;
let modelRegistryLoadingPromise: Promise<EnhancedModelMetadata[]> | null = null;
let allAliasConfigsCache: Record<string, ProviderAliasConfig> | null = null;
let allAliasConfigsLoadingPromise: Promise<
Record<string, ProviderAliasConfig>
> | null = null;
const providerAliasConfigCache = new Map<string, ProviderAliasConfig | null>();
const providerAliasConfigLoadingPromises = new Map<
string,
Promise<ProviderAliasConfig | null>
>();
function cloneValue<T>(value: T): T {
if (typeof structuredClone === "function") {
return structuredClone(value);
}
return JSON.parse(JSON.stringify(value)) as T;
}
function normalizeProviderKey(provider: string): string {
return provider.trim();
}
function invalidateAliasConfigCache(): void {
allAliasConfigsCache = null;
allAliasConfigsLoadingPromise = null;
providerAliasConfigCache.clear();
providerAliasConfigLoadingPromises.clear();
}
export function invalidateModelRegistryCache(): void {
modelRegistryCache = null;
modelRegistryLoadingPromise = null;
invalidateAliasConfigCache();
}
/**
* 获取所有模型
*/
export async function getModelRegistry(): Promise<EnhancedModelMetadata[]> {
return safeInvoke("get_model_registry");
export async function getModelRegistry(
options: ModelRegistryQueryOptions = {},
): Promise<EnhancedModelMetadata[]> {
if (options.forceRefresh) {
modelRegistryCache = null;
modelRegistryLoadingPromise = null;
}
if (modelRegistryCache) {
return cloneValue(modelRegistryCache);
}
if (!modelRegistryLoadingPromise) {
modelRegistryLoadingPromise = safeInvoke<EnhancedModelMetadata[]>(
"get_model_registry",
)
.then((models) => {
modelRegistryCache = cloneValue(models);
return modelRegistryCache;
})
.finally(() => {
modelRegistryLoadingPromise = null;
});
}
return cloneValue(await modelRegistryLoadingPromise);
}
/**
@@ -32,7 +96,9 @@ export async function getModelRegistryProviderIds(): Promise<string[]> {
* @returns 加载的模型数量
*/
export async function refreshModelRegistry(): Promise<number> {
return safeInvoke("refresh_model_registry");
const count = await safeInvoke<number>("refresh_model_registry");
invalidateModelRegistryCache();
return count;
}
/**
@@ -113,8 +179,47 @@ export async function getModelsByTier(
*/
export async function getProviderAliasConfig(
provider: string,
options: ModelRegistryQueryOptions = {},
): Promise<ProviderAliasConfig | null> {
return safeInvoke("get_provider_alias_config", { provider });
const normalizedProvider = normalizeProviderKey(provider);
if (!normalizedProvider) {
return null;
}
if (options.forceRefresh) {
invalidateAliasConfigCache();
}
if (allAliasConfigsCache) {
return cloneValue(allAliasConfigsCache[normalizedProvider] ?? null);
}
if (providerAliasConfigCache.has(normalizedProvider)) {
return cloneValue(providerAliasConfigCache.get(normalizedProvider) ?? null);
}
const existingPromise = providerAliasConfigLoadingPromises.get(
normalizedProvider,
);
if (existingPromise) {
return cloneValue(await existingPromise);
}
const loadingPromise = safeInvoke<ProviderAliasConfig | null>(
"get_provider_alias_config",
{ provider: normalizedProvider },
)
.then((config) => {
const snapshot = config ? cloneValue(config) : null;
providerAliasConfigCache.set(normalizedProvider, snapshot);
return snapshot;
})
.finally(() => {
providerAliasConfigLoadingPromises.delete(normalizedProvider);
});
providerAliasConfigLoadingPromises.set(normalizedProvider, loadingPromise);
return cloneValue(await loadingPromise);
}
/**
@@ -123,7 +228,38 @@ export async function getProviderAliasConfig(
export async function getAllAliasConfigs(): Promise<
Record<string, ProviderAliasConfig>
> {
return safeInvoke("get_all_alias_configs");
return getAllAliasConfigsCached();
}
async function getAllAliasConfigsCached(
options: ModelRegistryQueryOptions = {},
): Promise<Record<string, ProviderAliasConfig>> {
if (options.forceRefresh) {
invalidateAliasConfigCache();
}
if (allAliasConfigsCache) {
return cloneValue(allAliasConfigsCache);
}
if (!allAliasConfigsLoadingPromise) {
allAliasConfigsLoadingPromise = safeInvoke<Record<string, ProviderAliasConfig>>(
"get_all_alias_configs",
)
.then((configs) => {
allAliasConfigsCache = cloneValue(configs);
providerAliasConfigCache.clear();
Object.entries(allAliasConfigsCache).forEach(([key, value]) => {
providerAliasConfigCache.set(key, cloneValue(value));
});
return allAliasConfigsCache;
})
.finally(() => {
allAliasConfigsLoadingPromise = null;
});
}
return cloneValue(await allAliasConfigsLoadingPromise);
}
/**
@@ -142,5 +278,5 @@ export const modelRegistryApi = {
getModelsForProvider,
getModelsByTier,
getProviderAliasConfig,
getAllAliasConfigs,
getAllAliasConfigs: getAllAliasConfigsCached,
};
+122
View File
@@ -0,0 +1,122 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { mockSafeInvoke } = vi.hoisted(() => ({
mockSafeInvoke: vi.fn(),
}));
vi.mock("@/lib/dev-bridge", () => ({
safeInvoke: mockSafeInvoke,
}));
import {
getPluginsForSurface,
getPluginsWithUI,
invalidatePluginUICache,
notifyPluginUIChanged,
type PluginUIInfo,
} from "./pluginUI";
const BASE_PLUGINS: PluginUIInfo[] = [
{
pluginId: "plugin.sidebar",
name: "Sidebar Plugin",
description: "show in sidebar",
icon: "PanelLeftOpen",
surfaces: ["sidebar", "tools"],
},
{
pluginId: "plugin.tools",
name: "Tools Plugin",
description: "show in tools",
icon: "Wrench",
surfaces: ["tools"],
},
];
describe("pluginUI API", () => {
beforeEach(() => {
vi.clearAllMocks();
invalidatePluginUICache();
window.localStorage.clear();
});
afterEach(() => {
invalidatePluginUICache();
window.localStorage.clear();
});
it("应缓存插件目录并复用同一轮请求", async () => {
mockSafeInvoke.mockResolvedValueOnce(BASE_PLUGINS);
const [first, second] = await Promise.all([
getPluginsWithUI(),
getPluginsWithUI(),
]);
expect(mockSafeInvoke).toHaveBeenCalledTimes(1);
expect(first).toEqual(BASE_PLUGINS);
expect(second).toEqual(BASE_PLUGINS);
expect(first).not.toBe(second);
});
it("getPluginsForSurface 应按 surface 过滤结果", async () => {
mockSafeInvoke.mockResolvedValueOnce(BASE_PLUGINS);
await expect(getPluginsForSurface("sidebar")).resolves.toEqual([
BASE_PLUGINS[0],
]);
});
it("forceRefresh 应绕过缓存重新请求", async () => {
mockSafeInvoke
.mockResolvedValueOnce(BASE_PLUGINS)
.mockResolvedValueOnce([
{
...BASE_PLUGINS[0],
name: "Sidebar Plugin v2",
},
]);
await expect(getPluginsWithUI()).resolves.toEqual(BASE_PLUGINS);
await expect(getPluginsWithUI({ forceRefresh: true })).resolves.toEqual([
{
...BASE_PLUGINS[0],
name: "Sidebar Plugin v2",
},
]);
expect(mockSafeInvoke).toHaveBeenCalledTimes(2);
});
it("notifyPluginUIChanged 应清空缓存并广播变更", async () => {
mockSafeInvoke
.mockResolvedValueOnce(BASE_PLUGINS)
.mockResolvedValueOnce([
{
...BASE_PLUGINS[0],
name: "Sidebar Plugin refreshed",
},
]);
const changedListener = vi.fn();
window.addEventListener("plugin-changed", changedListener);
try {
await getPluginsWithUI();
notifyPluginUIChanged();
await expect(getPluginsWithUI()).resolves.toEqual([
{
...BASE_PLUGINS[0],
name: "Sidebar Plugin refreshed",
},
]);
expect(changedListener).toHaveBeenCalledTimes(1);
expect(window.localStorage.getItem("plugin-changed")).toBeTruthy();
expect(mockSafeInvoke).toHaveBeenCalledTimes(2);
} finally {
window.removeEventListener("plugin-changed", changedListener);
}
});
});
+83 -3
View File
@@ -9,6 +9,12 @@
import { safeInvoke } from "@/lib/dev-bridge";
const PLUGIN_UI_CHANGE_EVENT = "plugin-changed";
let pluginsWithUICache: PluginUIInfo[] | null = null;
let pluginsWithUILoadingPromise: Promise<PluginUIInfo[]> | null = null;
let pluginsWithUICacheStamp: string | null = null;
/**
* 插件 UI 信息
*
@@ -27,6 +33,51 @@ export interface PluginUIInfo {
surfaces: string[];
}
interface PluginUIQueryOptions {
forceRefresh?: boolean;
}
function clonePluginList(plugins: PluginUIInfo[]): PluginUIInfo[] {
return plugins.map((plugin) => ({
...plugin,
surfaces: [...plugin.surfaces],
}));
}
function readPluginUIChangeStamp(): string | null {
if (typeof window === "undefined") {
return null;
}
try {
return window.localStorage.getItem(PLUGIN_UI_CHANGE_EVENT);
} catch {
return null;
}
}
export function invalidatePluginUICache(): void {
pluginsWithUICache = null;
pluginsWithUILoadingPromise = null;
pluginsWithUICacheStamp = null;
}
export function notifyPluginUIChanged(): void {
invalidatePluginUICache();
if (typeof window === "undefined") {
return;
}
try {
window.localStorage.setItem(PLUGIN_UI_CHANGE_EVENT, String(Date.now()));
} catch {
// ignore
}
window.dispatchEvent(new CustomEvent(PLUGIN_UI_CHANGE_EVENT));
}
/**
* 获取带有 UI 配置的已安装插件列表
*
@@ -35,8 +86,36 @@ export interface PluginUIInfo {
*
* @returns 带有 UI 配置的插件列表
*/
export async function getPluginsWithUI(): Promise<PluginUIInfo[]> {
return safeInvoke<PluginUIInfo[]>("get_plugins_with_ui");
export async function getPluginsWithUI(
options: PluginUIQueryOptions = {},
): Promise<PluginUIInfo[]> {
if (options.forceRefresh) {
invalidatePluginUICache();
}
const currentStamp = readPluginUIChangeStamp();
if (pluginsWithUICache && pluginsWithUICacheStamp !== currentStamp) {
invalidatePluginUICache();
}
if (pluginsWithUICache) {
return clonePluginList(pluginsWithUICache);
}
if (!pluginsWithUILoadingPromise) {
pluginsWithUILoadingPromise = safeInvoke<PluginUIInfo[]>("get_plugins_with_ui")
.then((plugins) => {
const snapshot = clonePluginList(plugins);
pluginsWithUICache = snapshot;
pluginsWithUICacheStamp = readPluginUIChangeStamp();
return snapshot;
})
.finally(() => {
pluginsWithUILoadingPromise = null;
});
}
return clonePluginList(await pluginsWithUILoadingPromise);
}
/**
@@ -49,7 +128,8 @@ export async function getPluginsWithUI(): Promise<PluginUIInfo[]> {
*/
export async function getPluginsForSurface(
surface: string,
options: PluginUIQueryOptions = {},
): Promise<PluginUIInfo[]> {
const plugins = await getPluginsWithUI();
const plugins = await getPluginsWithUI(options);
return plugins.filter((plugin) => plugin.surfaces.includes(surface));
}
+159
View File
@@ -0,0 +1,159 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { safeInvoke } from "@/lib/dev-bridge";
import {
invalidateProviderPoolOverviewCache,
providerPoolApi,
} from "./providerPool";
vi.mock("@/lib/dev-bridge", () => ({
safeInvoke: vi.fn(),
}));
describe("providerPool API", () => {
beforeEach(() => {
vi.clearAllMocks();
invalidateProviderPoolOverviewCache();
});
it("getOverview 应缓存并复用同一轮读取结果", async () => {
vi.mocked(safeInvoke).mockResolvedValueOnce([
{
provider_type: "kiro",
stats: {
total: 1,
healthy: 1,
unhealthy: 0,
disabled: 0,
total_usage: 0,
total_errors: 0,
},
credentials: [
{
uuid: "cred-1",
provider_type: "kiro",
credential_type: "kiro_oauth",
display_credential: "kiro",
is_healthy: true,
is_disabled: false,
check_health: true,
not_supported_models: [],
usage_count: 0,
error_count: 0,
created_at: "",
updated_at: "",
source: "manual",
},
],
},
]);
const [first, second] = await Promise.all([
providerPoolApi.getOverview(),
providerPoolApi.getOverview(),
]);
expect(vi.mocked(safeInvoke)).toHaveBeenCalledTimes(1);
expect(first).toEqual(second);
expect(first).not.toBe(second);
});
it("forceRefresh 应绕过概览缓存", async () => {
vi.mocked(safeInvoke)
.mockResolvedValueOnce([
{
provider_type: "kiro",
stats: {
total: 1,
healthy: 1,
unhealthy: 0,
disabled: 0,
total_usage: 0,
total_errors: 0,
},
credentials: [],
},
])
.mockResolvedValueOnce([
{
provider_type: "gemini",
stats: {
total: 2,
healthy: 2,
unhealthy: 0,
disabled: 0,
total_usage: 0,
total_errors: 0,
},
credentials: [],
},
]);
await expect(providerPoolApi.getOverview()).resolves.toEqual([
expect.objectContaining({ provider_type: "kiro" }),
]);
await expect(
providerPoolApi.getOverview({ forceRefresh: true }),
).resolves.toEqual([expect.objectContaining({ provider_type: "gemini" })]);
expect(vi.mocked(safeInvoke)).toHaveBeenCalledTimes(2);
});
it("写操作成功后应失效概览缓存", async () => {
vi.mocked(safeInvoke)
.mockResolvedValueOnce([
{
provider_type: "openai",
stats: {
total: 0,
healthy: 0,
unhealthy: 0,
disabled: 0,
total_usage: 0,
total_errors: 0,
},
credentials: [],
},
])
.mockResolvedValueOnce({ uuid: "cred-2" })
.mockResolvedValueOnce([
{
provider_type: "openai",
stats: {
total: 1,
healthy: 1,
unhealthy: 0,
disabled: 0,
total_usage: 0,
total_errors: 0,
},
credentials: [
{
uuid: "cred-2",
provider_type: "openai",
credential_type: "openai_key",
display_credential: "sk-***",
is_healthy: true,
is_disabled: false,
check_health: true,
not_supported_models: [],
usage_count: 0,
error_count: 0,
created_at: "",
updated_at: "",
source: "manual",
},
],
},
]);
await providerPoolApi.getOverview();
await providerPoolApi.addOpenAIKey("sk-test");
await expect(providerPoolApi.getOverview()).resolves.toEqual([
expect.objectContaining({
stats: expect.objectContaining({ total: 1 }),
}),
]);
expect(vi.mocked(safeInvoke)).toHaveBeenCalledTimes(3);
});
});
+184 -62
View File
@@ -1,5 +1,65 @@
import { safeInvoke } from "@/lib/dev-bridge";
interface ProviderPoolQueryOptions {
forceRefresh?: boolean;
}
let overviewCache: ProviderPoolOverview[] | null = null;
let overviewLoadingPromise: Promise<ProviderPoolOverview[]> | null = null;
function cloneOverview(
overview: ProviderPoolOverview[],
): ProviderPoolOverview[] {
return overview.map((pool) => ({
...pool,
stats: { ...pool.stats },
credentials: pool.credentials.map((credential) => ({
...credential,
not_supported_models: [...credential.not_supported_models],
})),
}));
}
export function invalidateProviderPoolOverviewCache(): void {
overviewCache = null;
overviewLoadingPromise = null;
}
async function loadOverview(
options: ProviderPoolQueryOptions = {},
): Promise<ProviderPoolOverview[]> {
if (options.forceRefresh) {
invalidateProviderPoolOverviewCache();
}
if (overviewCache) {
return cloneOverview(overviewCache);
}
if (!overviewLoadingPromise) {
overviewLoadingPromise = safeInvoke<ProviderPoolOverview[]>(
"get_provider_pool_overview",
)
.then((overview) => {
overviewCache = cloneOverview(overview);
return overviewCache;
})
.finally(() => {
overviewLoadingPromise = null;
});
}
return cloneOverview(await overviewLoadingPromise);
}
async function invalidateOverviewAfterMutation<T>(
promise: Promise<T>,
): Promise<T> {
const result = await promise;
invalidateProviderPoolOverviewCache();
return result;
}
// Provider types supported by the pool
export type PoolProviderType =
| "kiro"
@@ -201,8 +261,10 @@ export interface UpdateCredentialRequest {
export const providerPoolApi = {
// Get overview of all provider pools
async getOverview(): Promise<ProviderPoolOverview[]> {
return safeInvoke("get_provider_pool_overview");
async getOverview(
options: ProviderPoolQueryOptions = {},
): Promise<ProviderPoolOverview[]> {
return loadOverview(options);
},
// Get credentials for a specific provider type
@@ -216,7 +278,9 @@ export const providerPoolApi = {
async addCredential(
request: AddCredentialRequest,
): Promise<ProviderCredential> {
return safeInvoke("add_provider_pool_credential", { request });
return invalidateOverviewAfterMutation(
safeInvoke("add_provider_pool_credential", { request }),
);
},
// Update a credential
@@ -224,7 +288,9 @@ export const providerPoolApi = {
uuid: string,
request: UpdateCredentialRequest,
): Promise<ProviderCredential> {
return safeInvoke("update_provider_pool_credential", { uuid, request });
return invalidateOverviewAfterMutation(
safeInvoke("update_provider_pool_credential", { uuid, request }),
);
},
// Delete a credential
@@ -232,10 +298,12 @@ export const providerPoolApi = {
uuid: string,
providerType?: PoolProviderType,
): Promise<boolean> {
return safeInvoke("delete_provider_pool_credential", {
uuid,
providerType,
});
return invalidateOverviewAfterMutation(
safeInvoke("delete_provider_pool_credential", {
uuid,
providerType,
}),
);
},
// Toggle credential enabled/disabled
@@ -243,29 +311,39 @@ export const providerPoolApi = {
uuid: string,
isDisabled: boolean,
): Promise<ProviderCredential> {
return safeInvoke("toggle_provider_pool_credential", { uuid, isDisabled });
return invalidateOverviewAfterMutation(
safeInvoke("toggle_provider_pool_credential", { uuid, isDisabled }),
);
},
// Reset credential counters
async resetCredential(uuid: string): Promise<void> {
return safeInvoke("reset_provider_pool_credential", { uuid });
return invalidateOverviewAfterMutation(
safeInvoke("reset_provider_pool_credential", { uuid }),
);
},
// Reset health status for all credentials of a type
async resetHealth(providerType: PoolProviderType): Promise<number> {
return safeInvoke("reset_provider_pool_health", { providerType });
return invalidateOverviewAfterMutation(
safeInvoke("reset_provider_pool_health", { providerType }),
);
},
// Check health of a single credential
async checkCredentialHealth(uuid: string): Promise<HealthCheckResult> {
return safeInvoke("check_provider_pool_credential_health", { uuid });
return invalidateOverviewAfterMutation(
safeInvoke("check_provider_pool_credential_health", { uuid }),
);
},
// Check health of all credentials of a type
async checkTypeHealth(
providerType: PoolProviderType,
): Promise<HealthCheckResult[]> {
return safeInvoke("check_provider_pool_type_health", { providerType });
return invalidateOverviewAfterMutation(
safeInvoke("check_provider_pool_type_health", { providerType }),
);
},
// Provider-specific add methods
@@ -273,7 +351,9 @@ export const providerPoolApi = {
credsFilePath: string,
name?: string,
): Promise<ProviderCredential> {
return safeInvoke("add_kiro_oauth_credential", { credsFilePath, name });
return invalidateOverviewAfterMutation(
safeInvoke("add_kiro_oauth_credential", { credsFilePath, name }),
);
},
// 从 JSON 内容添加 Kiro 凭证(直接粘贴 JSON)
@@ -281,7 +361,9 @@ export const providerPoolApi = {
jsonContent: string,
name?: string,
): Promise<ProviderCredential> {
return safeInvoke("add_kiro_from_json", { jsonContent, name });
return invalidateOverviewAfterMutation(
safeInvoke("add_kiro_from_json", { jsonContent, name }),
);
},
async addGeminiOAuth(
@@ -289,11 +371,13 @@ export const providerPoolApi = {
projectId?: string,
name?: string,
): Promise<ProviderCredential> {
return safeInvoke("add_gemini_oauth_credential", {
credsFilePath,
projectId,
name,
});
return invalidateOverviewAfterMutation(
safeInvoke("add_gemini_oauth_credential", {
credsFilePath,
projectId,
name,
}),
);
},
async addOpenAIKey(
@@ -301,7 +385,9 @@ export const providerPoolApi = {
baseUrl?: string,
name?: string,
): Promise<ProviderCredential> {
return safeInvoke("add_openai_key_credential", { apiKey, baseUrl, name });
return invalidateOverviewAfterMutation(
safeInvoke("add_openai_key_credential", { apiKey, baseUrl, name }),
);
},
async addClaudeKey(
@@ -309,7 +395,9 @@ export const providerPoolApi = {
baseUrl?: string,
name?: string,
): Promise<ProviderCredential> {
return safeInvoke("add_claude_key_credential", { apiKey, baseUrl, name });
return invalidateOverviewAfterMutation(
safeInvoke("add_claude_key_credential", { apiKey, baseUrl, name }),
);
},
async addGeminiApiKey(
@@ -318,12 +406,14 @@ export const providerPoolApi = {
excludedModels?: string[],
name?: string,
): Promise<ProviderCredential> {
return safeInvoke("add_gemini_api_key_credential", {
apiKey,
baseUrl,
excludedModels,
name,
});
return invalidateOverviewAfterMutation(
safeInvoke("add_gemini_api_key_credential", {
apiKey,
baseUrl,
excludedModels,
name,
}),
);
},
async addAntigravityOAuth(
@@ -331,11 +421,13 @@ export const providerPoolApi = {
projectId?: string,
name?: string,
): Promise<ProviderCredential> {
return safeInvoke("add_antigravity_oauth_credential", {
credsFilePath,
projectId,
name,
});
return invalidateOverviewAfterMutation(
safeInvoke("add_antigravity_oauth_credential", {
credsFilePath,
projectId,
name,
}),
);
},
async addCodexOAuth(
@@ -343,18 +435,22 @@ export const providerPoolApi = {
apiBaseUrl?: string,
name?: string,
): Promise<ProviderCredential> {
return safeInvoke("add_codex_oauth_credential", {
credsFilePath,
apiBaseUrl,
name,
});
return invalidateOverviewAfterMutation(
safeInvoke("add_codex_oauth_credential", {
credsFilePath,
apiBaseUrl,
name,
}),
);
},
async addClaudeOAuth(
credsFilePath: string,
name?: string,
): Promise<ProviderCredential> {
return safeInvoke("add_claude_oauth_credential", { credsFilePath, name });
return invalidateOverviewAfterMutation(
safeInvoke("add_claude_oauth_credential", { credsFilePath, name }),
);
},
// Antigravity OAuth 登录(打开浏览器授权)
@@ -362,10 +458,12 @@ export const providerPoolApi = {
name?: string,
skipProjectIdFetch?: boolean,
): Promise<ProviderCredential> {
return safeInvoke("start_antigravity_oauth_login", {
name,
skipProjectIdFetch,
});
return invalidateOverviewAfterMutation(
safeInvoke("start_antigravity_oauth_login", {
name,
skipProjectIdFetch,
}),
);
},
// 获取 Antigravity OAuth 授权 URL 并等待回调(不自动打开浏览器)
@@ -375,26 +473,34 @@ export const providerPoolApi = {
name?: string,
skipProjectIdFetch?: boolean,
): Promise<ProviderCredential> {
return safeInvoke("get_antigravity_auth_url_and_wait", {
name,
skipProjectIdFetch,
});
return invalidateOverviewAfterMutation(
safeInvoke("get_antigravity_auth_url_and_wait", {
name,
skipProjectIdFetch,
}),
);
},
// Codex OAuth 登录(打开浏览器授权)
async startCodexOAuthLogin(name?: string): Promise<ProviderCredential> {
return safeInvoke("start_codex_oauth_login", { name });
return invalidateOverviewAfterMutation(
safeInvoke("start_codex_oauth_login", { name }),
);
},
// 获取 Codex OAuth 授权 URL 并等待回调(不自动打开浏览器)
// 服务器会在后台等待回调,成功后返回凭证
async getCodexAuthUrlAndWait(name?: string): Promise<ProviderCredential> {
return safeInvoke("get_codex_auth_url_and_wait", { name });
return invalidateOverviewAfterMutation(
safeInvoke("get_codex_auth_url_and_wait", { name }),
);
},
// Claude OAuth 登录(打开浏览器授权)
async startClaudeOAuthLogin(name?: string): Promise<ProviderCredential> {
return safeInvoke("start_claude_oauth_login", { name });
return invalidateOverviewAfterMutation(
safeInvoke("start_claude_oauth_login", { name }),
);
},
// 获取 Claude OAuth 授权 URL 并等待回调(不自动打开浏览器)
@@ -402,7 +508,9 @@ export const providerPoolApi = {
async getClaudeOAuthAuthUrlAndWait(
name?: string,
): Promise<ProviderCredential> {
return safeInvoke("get_claude_oauth_auth_url_and_wait", { name });
return invalidateOverviewAfterMutation(
safeInvoke("get_claude_oauth_auth_url_and_wait", { name }),
);
},
// Claude Cookie 自动授权(使用 sessionKey 自动完成 OAuth 流程)
@@ -412,22 +520,28 @@ export const providerPoolApi = {
isSetupToken?: boolean,
name?: string,
): Promise<ProviderCredential> {
return safeInvoke("claude_oauth_with_cookie", {
sessionKey,
isSetupToken,
name,
});
return invalidateOverviewAfterMutation(
safeInvoke("claude_oauth_with_cookie", {
sessionKey,
isSetupToken,
name,
}),
);
},
// Gemini OAuth 登录(打开浏览器授权)
async startGeminiOAuthLogin(name?: string): Promise<ProviderCredential> {
return safeInvoke("start_gemini_oauth_login", { name });
return invalidateOverviewAfterMutation(
safeInvoke("start_gemini_oauth_login", { name }),
);
},
// 获取 Gemini OAuth 授权 URL 并等待回调(不自动打开浏览器)
// 服务器会在后台等待回调,成功后返回凭证
async getGeminiAuthUrlAndWait(name?: string): Promise<ProviderCredential> {
return safeInvoke("get_gemini_auth_url_and_wait", { name });
return invalidateOverviewAfterMutation(
safeInvoke("get_gemini_auth_url_and_wait", { name }),
);
},
// 用 Gemini 授权码交换 token
@@ -436,7 +550,9 @@ export const providerPoolApi = {
sessionId?: string,
name?: string,
): Promise<ProviderCredential> {
return safeInvoke("exchange_gemini_code", { code, sessionId, name });
return invalidateOverviewAfterMutation(
safeInvoke("exchange_gemini_code", { code, sessionId, name }),
);
},
// ============ Kiro Builder ID 登录 ============
@@ -460,7 +576,9 @@ export const providerPoolApi = {
// 从 Builder ID 授权结果添加 Kiro 凭证
async addKiroFromBuilderIdAuth(name?: string): Promise<ProviderCredential> {
return safeInvoke("add_kiro_from_builder_id_auth", { name });
return invalidateOverviewAfterMutation(
safeInvoke("add_kiro_from_builder_id_auth", { name }),
);
},
// ============ Kiro Social Auth 登录 (Google/GitHub) ============
@@ -492,7 +610,9 @@ export const providerPoolApi = {
// OAuth token management
async refreshCredentialToken(uuid: string): Promise<string> {
return safeInvoke("refresh_pool_credential_token", { uuid });
return invalidateOverviewAfterMutation(
safeInvoke("refresh_pool_credential_token", { uuid }),
);
},
async getCredentialOAuthStatus(uuid: string): Promise<OAuthStatus> {
@@ -501,7 +621,9 @@ export const providerPoolApi = {
// Migration API
async migratePrivateConfig(config: unknown): Promise<MigrationResult> {
return safeInvoke("migrate_private_config_to_pool", { config });
return invalidateOverviewAfterMutation(
safeInvoke("migrate_private_config_to_pool", { config }),
);
},
// 获取单个凭证的健康状态
+44
View File
@@ -0,0 +1,44 @@
import { safeInvoke } from "@/lib/dev-bridge";
export const TRAY_MODEL_SELECTED_EVENT = "tray-model-selected";
export interface TrayQuickModelItem {
provider_type: string;
provider_label: string;
model: string;
}
export interface TrayQuickModelGroup {
provider_type: string;
provider_label: string;
models: TrayQuickModelItem[];
}
export interface TrayModelSelectedPayload {
providerType: string;
model: string;
}
export interface SyncTrayModelShortcutsPayload {
current_model_provider_type: string;
current_model_provider_label: string;
current_model: string;
current_theme_label: string;
quick_model_groups: TrayQuickModelGroup[];
}
export async function syncTrayModelShortcuts(
payload: SyncTrayModelShortcutsPayload,
): Promise<void> {
await safeInvoke("sync_tray_model_shortcuts", {
currentModelProviderType: payload.current_model_provider_type,
currentModelProviderLabel: payload.current_model_provider_label,
currentModel: payload.current_model,
currentThemeLabel: payload.current_theme_label,
quickModelGroups: payload.quick_model_groups,
});
}
export const trayApi = {
syncTrayModelShortcuts,
};
+8 -2
View File
@@ -5,6 +5,11 @@
* 通过 HTTP 与运行中的 Tauri 后端通信。
*/
import {
hasTauriInvokeCapability,
hasTauriRuntimeMarkers,
} from "@/lib/tauri-runtime";
const BRIDGE_URL = "http://127.0.0.1:3030/invoke";
const BRIDGE_HEALTH_URL = "http://127.0.0.1:3030/health";
@@ -79,7 +84,8 @@ export function isDevBridgeAvailable(): boolean {
// 检查是否在浏览器环境(非 Tauri webview)
const isBrowser =
typeof window !== "undefined" &&
!(window as any).__TAURI__ &&
!hasTauriRuntimeMarkers() &&
!hasTauriInvokeCapability() &&
// 进一步检查是否在开发模式
(import.meta.env.DEV ||
location.hostname === "localhost" ||
@@ -156,7 +162,7 @@ export interface BridgeStatus {
* 获取当前桥接状态
*/
export function getBridgeStatus(): BridgeStatus {
const hasTauri = typeof window !== "undefined" && (window as any).__TAURI__;
const hasTauri = hasTauriInvokeCapability() || hasTauriRuntimeMarkers();
const devAvailable = isDevBridgeAvailable();
return {
@@ -7,7 +7,6 @@ describe("mockPriorityCommands", () => {
expect(
shouldPreferMockInBrowser("execution_run_get_theme_workbench_state"),
).toBe(true);
expect(shouldPreferMockInBrowser("aster_agent_chat_stream")).toBe(true);
expect(shouldPreferMockInBrowser("get_hint_routes")).toBe(true);
expect(shouldPreferMockInBrowser("content_workflow_get_by_content")).toBe(true);
});
@@ -25,7 +25,6 @@ const mockPriorityCommands = new Set<string>([
"session_files_read_file",
"session_files_delete_file",
"execution_run_get_theme_workbench_state",
"aster_agent_chat_stream",
"agent_runtime_submit_turn",
"agent_runtime_interrupt_turn",
"agent_runtime_create_session",
+54
View File
@@ -39,6 +39,7 @@ import {
clearInvokeTraceBuffer,
getInvokeErrorBuffer,
getInvokeTraceBuffer,
safeListen,
safeInvoke,
} from "./safeInvoke";
import { shouldPreferMockInBrowser } from "./mockPriorityCommands";
@@ -51,6 +52,7 @@ describe("safeInvoke", () => {
clearInvokeErrorBuffer();
clearInvokeTraceBuffer();
delete (window as any).__TAURI__;
delete (window as any).__TAURI_INTERNALS__;
});
it("浏览器开发模式下优先走 HTTP bridge", async () => {
@@ -118,4 +120,56 @@ describe("safeInvoke", () => {
"[workspace_list] Failed to fetch",
);
});
it("事件 internals 已就绪时 safeListen 走原生 event API", async () => {
const unlisten = vi.fn();
(window as any).__TAURI_INTERNALS__ = {
invoke: vi.fn(),
transformCallback: vi.fn(),
};
mocks.baseListen.mockResolvedValueOnce(unlisten);
await expect(safeListen("config-changed", vi.fn())).resolves.toBe(unlisten);
expect(mocks.baseListen).toHaveBeenCalledWith(
"config-changed",
expect.any(Function),
);
});
it("Tauri 运行时存在但事件桥缺失时 safeListen 返回空清理函数", async () => {
vi.useFakeTimers();
(window as any).__TAURI__ = {
core: {
invoke: vi.fn(),
},
};
const promise = safeListen("config-changed", vi.fn());
await vi.advanceTimersByTimeAsync(3000);
const unlisten = await promise;
expect(typeof unlisten).toBe("function");
expect(mocks.baseListen).not.toHaveBeenCalled();
vi.useRealTimers();
});
it("事件桥调用异常时 safeListen 降级为空清理函数", async () => {
(window as any).__TAURI_INTERNALS__ = {
invoke: vi.fn(),
transformCallback: vi.fn(),
};
mocks.baseListen.mockRejectedValueOnce(
new TypeError(
"Cannot read properties of undefined (reading 'transformCallback')",
),
);
const unlisten = await safeListen("plugin-task-event", vi.fn());
expect(typeof unlisten).toBe("function");
expect(mocks.baseListen).toHaveBeenCalledWith(
"plugin-task-event",
expect.any(Function),
);
});
});
+130 -14
View File
@@ -17,6 +17,13 @@ import {
normalizeDevBridgeError,
} from "./http-client";
import { shouldPreferMockInBrowser } from "./mockPriorityCommands";
import {
getTauriGlobal,
hasTauriEventCapability,
hasTauriEventListenerCapability,
hasTauriInvokeCapability,
hasTauriRuntimeMarkers,
} from "@/lib/tauri-runtime";
export interface InvokeErrorBufferEntry {
timestamp: string;
@@ -41,6 +48,7 @@ const INVOKE_ERROR_BUFFER_LIMIT = 120;
const INVOKE_TRACE_BUFFER_KEY = "lime_invoke_trace_buffer_v1";
const INVOKE_TRACE_BUFFER_LIMIT = 240;
const INVOKE_ERROR_TEXT_LIMIT = 800;
const USER_TIMING_PREFIX = "lime:safeInvoke";
const SECRET_PATTERNS: Array<[RegExp, string]> = [
[/\bBearer\s+[A-Za-z0-9._-]+\b/gi, "Bearer ***"],
@@ -102,6 +110,63 @@ function toErrorMessage(error: unknown): string {
);
}
function supportsUserTiming(): boolean {
return (
typeof performance !== "undefined" &&
typeof performance.mark === "function" &&
typeof performance.measure === "function"
);
}
function sanitizeTimingLabel(input: string): string {
const normalized = input.replace(/[^a-zA-Z0-9:_-]+/g, "_").slice(0, 120);
return normalized || "invoke";
}
function startInvokeTiming(command: string): string | null {
if (!supportsUserTiming()) {
return null;
}
const timingId = `${USER_TIMING_PREFIX}:${sanitizeTimingLabel(command)}:${Date.now()}:${Math.random()
.toString(36)
.slice(2, 8)}`;
try {
performance.mark(`${timingId}:start`);
return timingId;
} catch {
return null;
}
}
function finishInvokeTiming(
timingId: string | null,
command: string,
transport: InvokeTraceBufferEntry["transport"],
status: InvokeTraceBufferEntry["status"],
): void {
if (!timingId || !supportsUserTiming()) {
return;
}
const startMark = `${timingId}:start`;
const endMark = `${timingId}:end`;
const measureName = `${USER_TIMING_PREFIX}:${sanitizeTimingLabel(command)}:${transport}:${status}`;
try {
performance.mark(endMark);
performance.measure(measureName, startMark, endMark);
} catch {
// ignore
} finally {
try {
performance.clearMarks(startMark);
performance.clearMarks(endMark);
} catch {
// ignore
}
}
}
function readInvokeErrorBuffer(): InvokeErrorBufferEntry[] {
if (typeof window === "undefined") {
return [];
@@ -279,6 +344,7 @@ export async function safeInvoke<T = any>(
args?: Record<string, unknown>,
): Promise<T> {
const startedAt = Date.now();
const timingId = startInvokeTiming(cmd);
// 1. 优先使用 Tauri IPC (生产环境或 Tauri webview 可用时)
if (
@@ -291,10 +357,12 @@ export async function safeInvoke<T = any>(
args,
)) as T;
recordInvokeTrace(cmd, args, "tauri-ipc", "success", startedAt);
finishInvokeTiming(timingId, cmd, "tauri-ipc", "success");
return result;
} catch (error) {
recordInvokeError(cmd, args, error, "tauri-ipc");
recordInvokeTrace(cmd, args, "tauri-ipc", "error", startedAt, error);
finishInvokeTiming(timingId, cmd, "tauri-ipc", "error");
throw error;
}
}
@@ -304,10 +372,28 @@ export async function safeInvoke<T = any>(
try {
const result = (await (window as any).__TAURI__.invoke(cmd, args)) as T;
recordInvokeTrace(cmd, args, "tauri-legacy", "success", startedAt);
finishInvokeTiming(timingId, cmd, "tauri-legacy", "success");
return result;
} catch (error) {
recordInvokeError(cmd, args, error, "tauri-legacy");
recordInvokeTrace(cmd, args, "tauri-legacy", "error", startedAt, error);
finishInvokeTiming(timingId, cmd, "tauri-legacy", "error");
throw error;
}
}
// Tauri IPC 尚未就绪时不再轮询等待,直接 fall through 到后续通道。
// 避免首屏并发大量 safeInvoke 时全部阻塞在 waitForTauriCapability 上。
if (hasTauriInvokeCapability()) {
try {
const result = (await baseInvoke(cmd, args)) as T;
recordInvokeTrace(cmd, args, "tauri-ipc", "success", startedAt);
finishInvokeTiming(timingId, cmd, "tauri-ipc", "success");
return result;
} catch (error) {
recordInvokeError(cmd, args, error, "tauri-ipc");
recordInvokeTrace(cmd, args, "tauri-ipc", "error", startedAt, error);
finishInvokeTiming(timingId, cmd, "tauri-ipc", "error");
throw error;
}
}
@@ -317,6 +403,7 @@ export async function safeInvoke<T = any>(
try {
const result = (await baseInvoke(cmd, args)) as T;
recordInvokeTrace(cmd, args, "fallback-invoke", "success", startedAt);
finishInvokeTiming(timingId, cmd, "fallback-invoke", "success");
return result;
} catch (error) {
recordInvokeError(cmd, args, error, "fallback-invoke");
@@ -328,6 +415,7 @@ export async function safeInvoke<T = any>(
startedAt,
error,
);
finishInvokeTiming(timingId, cmd, "fallback-invoke", "error");
throw error;
}
}
@@ -337,6 +425,7 @@ export async function safeInvoke<T = any>(
try {
const result = await invokeViaHttp(cmd, args);
recordInvokeTrace(cmd, args, "http-bridge", "success", startedAt);
finishInvokeTiming(timingId, cmd, "http-bridge", "success");
return result as T;
} catch (error) {
const normalizedError = normalizeDevBridgeError(cmd, error);
@@ -353,6 +442,7 @@ export async function safeInvoke<T = any>(
try {
const result = (await baseInvoke(cmd, args)) as T;
recordInvokeTrace(cmd, args, "fallback-invoke", "success", startedAt);
finishInvokeTiming(timingId, cmd, "fallback-invoke", "success");
return result;
} catch (fallbackError) {
recordInvokeError(cmd, args, fallbackError, "fallback-invoke");
@@ -364,6 +454,7 @@ export async function safeInvoke<T = any>(
startedAt,
fallbackError,
);
finishInvokeTiming(timingId, cmd, "fallback-invoke", "error");
throw normalizedError;
}
}
@@ -373,10 +464,12 @@ export async function safeInvoke<T = any>(
try {
const result = (await baseInvoke(cmd, args)) as T;
recordInvokeTrace(cmd, args, "fallback-invoke", "success", startedAt);
finishInvokeTiming(timingId, cmd, "fallback-invoke", "success");
return result;
} catch (error) {
recordInvokeError(cmd, args, error, "fallback-invoke");
recordInvokeTrace(cmd, args, "fallback-invoke", "error", startedAt, error);
finishInvokeTiming(timingId, cmd, "fallback-invoke", "error");
throw error;
}
}
@@ -389,22 +482,29 @@ export async function safeListen<T = any>(
event: string,
handler: (event: { payload: T }) => void,
): Promise<UnlistenFn> {
// 1. 优先使用 Tauri event API
if (
typeof window !== "undefined" &&
(window as any).__TAURI__?.event?.listen
) {
return (window as any).__TAURI__.event.listen(event, handler);
// 同步检查即可,不轮询等待,避免首屏并发监听全部阻塞
if (hasTauriEventListenerCapability()) {
try {
return await baseListen(event, handler);
} catch (error) {
if (hasTauriRuntimeMarkers()) {
console.warn(`[safeListen] Tauri 事件桥调用失败,跳过监听: ${event}`, error);
return () => {};
}
throw error;
}
}
if (hasTauriRuntimeMarkers()) {
console.warn(`[safeListen] Tauri 事件桥未就绪,跳过监听: ${event}`);
return () => {};
}
// 2. Fallback 到 mock(Vite alias 会替换 @tauri-apps 导入)
return baseListen(event, handler);
}
export function hasNativeTauriEventSupport(): boolean {
return Boolean(
typeof window !== "undefined" && (window as any).__TAURI__?.event?.listen,
);
return hasTauriEventListenerCapability();
}
/**
@@ -415,12 +515,28 @@ export async function safeEmit(
event: string,
payload?: unknown,
): Promise<void> {
// 1. 优先使用 Tauri event API
if (typeof window !== "undefined" && (window as any).__TAURI__?.event?.emit) {
return (window as any).__TAURI__.event.emit(event, payload);
const tauriGlobal = getTauriGlobal() as
| {
event?: {
emit?: (event: string, payload?: unknown) => Promise<void>;
};
}
| null;
if (typeof tauriGlobal?.event?.emit === "function") {
return tauriGlobal.event.emit(event, payload);
}
// 同步检查,不轮询
if (hasTauriEventCapability()) {
return baseEmit(event, payload);
}
if (hasTauriRuntimeMarkers()) {
console.warn(`[safeEmit] Tauri 事件桥未就绪,跳过发送: ${event}`);
return;
}
// 2. Fallback 到 mock
return baseEmit(event, payload);
}
+133
View File
@@ -0,0 +1,133 @@
{
"runtimeGatewayCommands": [
"agent_start_process",
"agent_stop_process",
"agent_get_process_status",
"agent_generate_title",
"agent_terminal_command_response",
"agent_term_scrollback_response",
"aster_agent_init",
"aster_agent_status",
"aster_agent_configure_provider",
"aster_agent_configure_from_pool",
"aster_agent_reset",
"agent_runtime_submit_turn",
"agent_runtime_interrupt_turn",
"agent_runtime_remove_queued_turn",
"agent_runtime_respond_action",
"agent_runtime_create_session",
"agent_runtime_list_sessions",
"agent_runtime_get_session",
"agent_runtime_update_session",
"agent_runtime_delete_session"
],
"deprecatedCommandReplacements": {
"get_agent_process_status": "agent_get_process_status",
"list_agent_sessions": "agent_runtime_list_sessions",
"agent_chat_stream": "agent_runtime_submit_turn",
"agent_create_session": "agent_runtime_create_session",
"agent_list_sessions": "agent_runtime_list_sessions",
"agent_get_session": "agent_runtime_get_session",
"agent_get_session_messages": "agent_runtime_get_session",
"agent_delete_session": "agent_runtime_delete_session",
"agent_rename_session": "agent_runtime_update_session",
"aster_agent_chat_stream": "agent_runtime_submit_turn",
"aster_agent_stop": "agent_runtime_interrupt_turn",
"aster_agent_confirm": "agent_runtime_respond_action",
"aster_agent_submit_elicitation_response": "agent_runtime_respond_action",
"aster_session_create": "agent_runtime_create_session",
"aster_session_list": "agent_runtime_list_sessions",
"aster_session_get": "agent_runtime_get_session",
"aster_session_rename": "agent_runtime_update_session",
"aster_session_set_execution_strategy": "agent_runtime_update_session",
"aster_session_delete": "agent_runtime_delete_session"
},
"deprecatedHelperReplacements": {
"createAgentSession": "createAgentRuntimeSession",
"listAgentSessions": "listAgentRuntimeSessions",
"getAgentSession": "getAgentRuntimeSession",
"getAgentSessionMessages": "getAgentRuntimeSession",
"renameAgentSession": "updateAgentRuntimeSession",
"deleteAgentSession": "deleteAgentRuntimeSession",
"generateAgentTitle": "generateAgentRuntimeSessionTitle",
"sendAsterMessageStream": "submitAgentRuntimeTurn",
"confirmAsterAction": "respondAgentRuntimeAction",
"submitAsterElicitationResponse": "respondAgentRuntimeAction",
"stopAsterSession": "interruptAgentRuntimeTurn",
"createAsterSession": "createAgentRuntimeSession",
"listAsterSessions": "listAgentRuntimeSessions",
"getAsterSession": "getAgentRuntimeSession",
"deleteAsterSession": "deleteAgentRuntimeSession",
"renameAsterSession": "updateAgentRuntimeSession"
},
"legacyCommandSurfaceMonitors": [
{
"id": "agent-create-session-compat-command",
"classification": "compat",
"description": "agent_create_session compat 命令前端边界",
"commands": ["agent_create_session"],
"allowedPaths": ["src/lib/api/agentRuntime.ts"]
},
{
"id": "agent-session-message-legacy-command",
"classification": "deprecated",
"description": "旧 agent session message 命令前端边界",
"commands": ["agent_get_session_messages"],
"allowedPaths": ["src/lib/api/agentRuntime.ts"]
},
{
"id": "agent-session-compat-commands",
"classification": "deprecated",
"description": "旧 agent session compat 命令前端边界",
"commands": [
"agent_list_sessions",
"agent_get_session",
"agent_delete_session",
"agent_rename_session"
],
"allowedPaths": []
}
],
"legacyHelperSurfaceMonitors": [
{
"id": "agent-legacy-session-api-helpers",
"classification": "deprecated",
"description": "旧 Agent session compat helper 直连回流",
"helpers": [
"createAgentSession",
"listAgentSessions",
"getAgentSession",
"getAgentSessionMessages",
"renameAgentSession",
"deleteAgentSession",
"generateAgentTitle"
],
"allowedPaths": ["src/lib/api/agentRuntime.ts"]
},
{
"id": "agent-legacy-stream-action-helpers",
"classification": "deprecated",
"description": "旧 Aster stream/action helper 直连回流",
"helpers": [
"sendAsterMessageStream",
"confirmAsterAction",
"submitAsterElicitationResponse",
"stopAsterSession"
],
"allowedPaths": ["src/lib/api/agentRuntime.ts"]
},
{
"id": "aster-session-helper-direct-usage",
"classification": "deprecated",
"description": "前端 direct Aster session helper 回流",
"helpers": [
"createAsterSession",
"listAsterSessions",
"getAsterSession",
"deleteAsterSession",
"renameAsterSession"
],
"allowedPaths": ["src/lib/api/agentRuntime.ts"]
}
]
}
+128
View File
@@ -0,0 +1,128 @@
import {
emit as realEmit,
emitTo as realEmitTo,
listen as realListen,
once as realOnce,
TauriEvent,
} from "../../node_modules/@tauri-apps/api/event.js";
import type {
Event,
EventCallback,
EventName,
EventTarget,
Options,
UnlistenFn,
} from "../../node_modules/@tauri-apps/api/event.js";
import {
hasTauriEventListenerCapability,
hasTauriRuntimeMarkers,
waitForTauriCapability,
waitForTauriEventListenerCapability,
} from "@/lib/tauri-runtime";
const NOOP_UNLISTEN: UnlistenFn = () => {};
export type {
Event,
EventCallback,
EventName,
EventTarget,
Options,
UnlistenFn,
};
export { TauriEvent };
export async function listen<T>(
event: EventName,
handler: EventCallback<T>,
options?: Options,
): Promise<UnlistenFn> {
const nativeListenReady = hasTauriEventListenerCapability()
? true
: await waitForTauriEventListenerCapability();
if (nativeListenReady) {
try {
return await realListen(event, handler, options);
} catch (error) {
if (hasTauriRuntimeMarkers()) {
console.warn(`[tauri-event] 原生事件监听失败,跳过监听: ${event}`, error);
return NOOP_UNLISTEN;
}
throw error;
}
}
if (hasTauriRuntimeMarkers()) {
console.warn(`[tauri-event] Tauri 事件桥未就绪,跳过监听: ${event}`);
return NOOP_UNLISTEN;
}
try {
return await realListen(event, handler, options);
} catch (error) {
console.warn(`[tauri-event] 事件监听不可用,跳过监听: ${event}`, error);
return NOOP_UNLISTEN;
}
}
export async function once<T>(
event: EventName,
handler: EventCallback<T>,
options?: Options,
): Promise<UnlistenFn> {
if (!hasTauriRuntimeMarkers()) {
return realOnce(event, handler, options);
}
let resolvedUnlisten: UnlistenFn = NOOP_UNLISTEN;
const unlisten = await listen<T>(
event,
(eventData) => {
resolvedUnlisten();
handler(eventData);
},
options,
);
resolvedUnlisten = unlisten;
return unlisten;
}
export async function emit<T>(event: string, payload?: T): Promise<void> {
if (await waitForTauriCapability("event")) {
return realEmit(event, payload);
}
if (hasTauriRuntimeMarkers()) {
console.warn(`[tauri-event] Tauri 事件桥未就绪,跳过发送: ${event}`);
return;
}
try {
return await realEmit(event, payload);
} catch (error) {
console.warn(`[tauri-event] 事件发送不可用,跳过发送: ${event}`, error);
}
}
export async function emitTo<T>(
target: EventTarget | string,
event: string,
payload?: T,
): Promise<void> {
if (await waitForTauriCapability("event")) {
return realEmitTo(target, event, payload);
}
if (hasTauriRuntimeMarkers()) {
console.warn(`[tauri-event] Tauri 事件桥未就绪,跳过定向发送: ${event}`);
return;
}
try {
return await realEmitTo(target, event, payload);
} catch (error) {
console.warn(`[tauri-event] 定向事件发送不可用,跳过发送: ${event}`, error);
}
}
+11
View File
@@ -76,4 +76,15 @@ describe("tauri-mock/core invoke", () => {
}),
);
});
it("旧 Agent 命令别名应直接报废弃错误,不再静默返回 mock 成功结果", async () => {
mocks.isDevBridgeAvailable.mockReturnValue(false);
await expect(invoke("list_agent_sessions")).rejects.toThrow(
"命令 list_agent_sessions 已废弃,请迁移到 agent_runtime_list_sessions",
);
await expect(invoke("get_agent_process_status")).rejects.toThrow(
"命令 get_agent_process_status 已废弃,请迁移到 agent_get_process_status",
);
});
});
+15 -23
View File
@@ -10,6 +10,7 @@ import {
isDevBridgeAvailable,
normalizeDevBridgeError,
} from "../dev-bridge/http-client";
import agentCommandCatalog from "../governance/agentCommandCatalog.json";
import { shouldPreferMockInBrowser } from "../dev-bridge/mockPriorityCommands";
// 模拟的命令处理器
@@ -22,6 +23,18 @@ const createDeprecatedCommandMock =
);
};
const deprecatedAgentCommandReplacements =
agentCommandCatalog.deprecatedCommandReplacements as Record<string, string>;
const deprecatedAgentCommandMocks = Object.fromEntries(
Object.entries(deprecatedAgentCommandReplacements).map(
([command, replacement]) => [
command,
createDeprecatedCommandMock(command, replacement),
],
),
) as Record<string, () => never>;
type MockBrowserProfileRecord = {
id: string;
profile_key: string;
@@ -1508,24 +1521,10 @@ const defaultMocks: Record<string, any> = {
}),
// Agent 相关
list_agent_sessions: () => [],
agent_list_sessions: () => [],
get_agent_process_status: () => ({ running: false }),
...deprecatedAgentCommandMocks,
agent_get_process_status: () => ({ running: false }),
agent_start_process: () => ({ success: true }),
agent_stop_process: () => ({ success: true }),
agent_create_session: () => ({ session_id: "mock-session-id" }),
agent_send_message: createDeprecatedCommandMock(
"agent_send_message",
"aster_agent_chat_stream",
),
agent_get_session: () => ({ session: null }),
agent_delete_session: () => ({ success: true }),
agent_get_session_messages: () => [],
agent_chat_stream: createDeprecatedCommandMock(
"agent_chat_stream",
"aster_agent_chat_stream",
),
agent_terminal_command_response: () => ({}),
agent_term_scrollback_response: () => ({}),
@@ -1543,14 +1542,6 @@ const defaultMocks: Record<string, any> = {
initialized: true,
provider_configured: true,
}),
aster_agent_chat_stream: () => ({}),
aster_agent_stop: () => true,
aster_session_create: () => "mock-aster-session",
aster_session_set_execution_strategy: () => ({}),
aster_session_list: () => [],
aster_session_get: () => ({ id: "mock", messages: [] }),
aster_agent_confirm: () => ({}),
aster_agent_submit_elicitation_response: () => ({}),
agent_runtime_submit_turn: () => ({}),
agent_runtime_interrupt_turn: () => true,
agent_runtime_create_session: () => "mock-aster-session",
@@ -1845,6 +1836,7 @@ const defaultMocks: Record<string, any> = {
get_models_by_tier: () => [],
get_provider_alias_config: () => ({ alias: {} }),
get_all_alias_configs: () => ({}),
sync_tray_model_shortcuts: () => ({}),
// Orchestrator 相关
init_orchestrator: () => ({}),
+133
View File
@@ -0,0 +1,133 @@
const DEV_READY_TIMEOUT_MS = 2500;
const PROD_READY_TIMEOUT_MS = 800;
const READY_POLL_INTERVAL_MS = 25;
type TauriInternals = {
invoke?: unknown;
transformCallback?: unknown;
};
function getWindowObject(): (Window & typeof globalThis) | null {
return typeof window === "undefined" ? null : window;
}
export function getTauriGlobal(): Record<string, unknown> | null {
const currentWindow = getWindowObject() as
| ((Window & typeof globalThis) & { __TAURI__?: Record<string, unknown> })
| null;
return currentWindow?.__TAURI__ ?? null;
}
export function getTauriInternals(): TauriInternals | null {
const currentWindow = getWindowObject() as
| ((Window & typeof globalThis) & {
__TAURI_INTERNALS__?: TauriInternals;
})
| null;
return currentWindow?.__TAURI_INTERNALS__ ?? null;
}
export function hasTauriRuntimeMarkers(): boolean {
const currentWindow = getWindowObject();
if (!currentWindow) {
return false;
}
return Boolean(getTauriGlobal()) || "__TAURI_INTERNALS__" in currentWindow;
}
export function hasTauriInvokeCapability(): boolean {
const tauriGlobal = getTauriGlobal() as
| {
core?: { invoke?: unknown };
invoke?: unknown;
}
| null;
const internals = getTauriInternals();
return (
typeof tauriGlobal?.core?.invoke === "function" ||
typeof tauriGlobal?.invoke === "function" ||
typeof internals?.invoke === "function"
);
}
export function hasTauriEventCapability(): boolean {
const tauriGlobal = getTauriGlobal() as
| {
event?: {
listen?: unknown;
emit?: unknown;
};
}
| null;
const internals = getTauriInternals();
return (
typeof tauriGlobal?.event?.listen === "function" ||
(typeof internals?.invoke === "function" &&
typeof internals?.transformCallback === "function")
);
}
export function hasTauriEventListenerCapability(): boolean {
const internals = getTauriInternals();
return (
typeof internals?.invoke === "function" &&
typeof internals?.transformCallback === "function"
);
}
function getReadyTimeoutMs(): number {
return import.meta.env.DEV ? DEV_READY_TIMEOUT_MS : PROD_READY_TIMEOUT_MS;
}
async function waitForCapability(
hasCapability: () => boolean,
timeoutMs = getReadyTimeoutMs(),
): Promise<boolean> {
if (hasCapability()) {
return true;
}
if (!hasTauriRuntimeMarkers()) {
return false;
}
const currentWindow = getWindowObject();
if (!currentWindow) {
return false;
}
const deadline = Date.now() + Math.max(0, timeoutMs);
while (Date.now() < deadline) {
await new Promise<void>((resolve) => {
currentWindow.setTimeout(resolve, READY_POLL_INTERVAL_MS);
});
if (hasCapability()) {
return true;
}
}
return hasCapability();
}
export async function waitForTauriCapability(
capability: "invoke" | "event",
timeoutMs = getReadyTimeoutMs(),
): Promise<boolean> {
const hasCapability =
capability === "event"
? hasTauriEventCapability
: hasTauriInvokeCapability;
return waitForCapability(hasCapability, timeoutMs);
}
export async function waitForTauriEventListenerCapability(
timeoutMs = getReadyTimeoutMs(),
): Promise<boolean> {
return waitForCapability(hasTauriEventListenerCapability, timeoutMs);
}
+1 -19
View File
@@ -8,7 +8,6 @@
*/
import { safeInvoke } from "@/lib/dev-bridge";
import { Webview } from "@tauri-apps/api/webview";
/**
* Webview 面板信息
@@ -914,24 +913,7 @@ export async function getBrowserActionAuditLogs(
export async function closeWebviewPanel(panelId: string): Promise<boolean> {
console.log("[webview-api] 尝试关闭 webview:", panelId);
// 方法 1: 尝试使用 Tauri JavaScript API 直接关闭
try {
const webview = await Webview.getByLabel(panelId);
if (webview) {
console.log("[webview-api] 找到 webview,尝试关闭");
await webview.close();
console.log("[webview-api] Tauri API 关闭成功");
// 也调用后端清理状态
await safeInvoke<boolean>("close_webview_panel", {
panelId,
}).catch(() => {});
return true;
}
} catch (e) {
console.warn("[webview-api] Tauri API 关闭失败:", e);
}
// 方法 2: 使用后端命令关闭
// 统一走后端命令,避免前端在启动期提前加载 Tauri webview/window/event 链路。
try {
const result = await safeInvoke<boolean>("close_webview_panel", {
panelId,
+3 -4
View File
@@ -19,6 +19,7 @@ import {
import { getCurrentWindow } from "@tauri-apps/api/window";
import { sendScreenshotChat } from "@/lib/api/screenshotChat";
import { useVoiceSound } from "@/hooks/useVoiceSound";
import { safeListen } from "@/lib/dev-bridge";
import "./smart-input.css";
// Lime Logo组件
@@ -211,8 +212,7 @@ export function SmartInputPage() {
(async () => {
try {
const { listen } = await import("@tauri-apps/api/event");
unlisten = await listen("voice-start-recording", () => {
unlisten = await safeListen("voice-start-recording", () => {
console.log("[语音输入] 收到开始录音事件");
startVoiceMode();
});
@@ -405,8 +405,7 @@ export function SmartInputPage() {
const setupStopListener = async () => {
try {
const { listen } = await import("@tauri-apps/api/event");
const unlisten = await listen("voice-stop-recording", async () => {
const unlisten = await safeListen("voice-stop-recording", async () => {
console.log("[语音输入] 收到停止录音事件");
// 播放停止录音音效
-246
View File
@@ -1,246 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const {
mockConfirmAsterAction,
mockCreateAsterSession,
mockDeleteAsterSession,
mockGetAsterSession,
mockInitAsterAgent,
mockListAsterSessions,
mockParseStreamEvent,
mockRequireDefaultProjectId,
mockResolveRestorableSessionId,
mockSafeListen,
mockSendAsterMessageStream,
mockStopAsterSession,
mockSubmitAsterElicitationResponse,
} = vi.hoisted(() => ({
mockConfirmAsterAction: vi.fn(),
mockCreateAsterSession: vi.fn(),
mockDeleteAsterSession: vi.fn(),
mockGetAsterSession: vi.fn(),
mockInitAsterAgent: vi.fn(),
mockListAsterSessions: vi.fn(),
mockParseStreamEvent: vi.fn((payload: unknown) => payload),
mockRequireDefaultProjectId: vi.fn(),
mockResolveRestorableSessionId: vi.fn(() => null),
mockSafeListen: vi.fn(),
mockSendAsterMessageStream: vi.fn(),
mockStopAsterSession: vi.fn(),
mockSubmitAsterElicitationResponse: vi.fn(),
}));
vi.mock("@/lib/api/agentRuntime", () => ({
confirmAsterAction: mockConfirmAsterAction,
createAsterSession: mockCreateAsterSession,
deleteAsterSession: mockDeleteAsterSession,
getAsterSession: mockGetAsterSession,
initAsterAgent: mockInitAsterAgent,
listAsterSessions: mockListAsterSessions,
sendAsterMessageStream: mockSendAsterMessageStream,
stopAsterSession: mockStopAsterSession,
submitAsterElicitationResponse: mockSubmitAsterElicitationResponse,
}));
vi.mock("@/lib/api/agentStream", () => ({
parseStreamEvent: mockParseStreamEvent,
}));
vi.mock("@/lib/api/project", () => ({
requireDefaultProjectId: mockRequireDefaultProjectId,
}));
vi.mock("@/lib/asterSessionRecovery", () => ({
isAsterSessionNotFoundError: vi.fn(() => false),
resolveRestorableSessionId: mockResolveRestorableSessionId,
}));
vi.mock("@/lib/dev-bridge", () => ({
safeListen: mockSafeListen,
}));
import { useAgentStore } from "./agentStore";
function resetStore() {
useAgentStore.getState()._cleanup();
useAgentStore.setState({
currentSessionId: null,
sessions: [],
messages: [],
isStreaming: false,
currentAssistantMsgId: null,
pendingActions: [],
isInitialized: false,
});
}
beforeEach(() => {
vi.clearAllMocks();
mockConfirmAsterAction.mockResolvedValue(undefined);
mockCreateAsterSession.mockResolvedValue("session-created");
mockDeleteAsterSession.mockResolvedValue(undefined);
mockGetAsterSession.mockResolvedValue({ messages: [] });
mockInitAsterAgent.mockResolvedValue(undefined);
mockListAsterSessions.mockResolvedValue([]);
mockParseStreamEvent.mockImplementation((payload: unknown) => payload);
mockRequireDefaultProjectId.mockResolvedValue("workspace-test");
mockSafeListen.mockResolvedValue(() => {});
mockSendAsterMessageStream.mockResolvedValue(undefined);
mockStopAsterSession.mockResolvedValue(undefined);
mockSubmitAsterElicitationResponse.mockResolvedValue(undefined);
resetStore();
});
afterEach(() => {
resetStore();
});
describe("useAgentStore 权限响应", () => {
it("ask_user 经 action_required 进入 store 后应保留 questions 并透传 metadata", async () => {
useAgentStore.setState({
currentSessionId: "session-store-ask",
currentAssistantMsgId: "assistant-store-ask",
});
useAgentStore.getState()._handleEvent({
type: "action_required",
request_id: "req-store-ask-1",
action_type: "ask_user",
prompt: "请补充任务信息",
questions: [
{ question: "这次主要面向谁?" },
{ question: "这次最想达成什么?" },
],
});
expect(useAgentStore.getState().pendingActions).toMatchObject([
{
requestId: "req-store-ask-1",
actionType: "ask_user",
prompt: "请补充任务信息",
questions: [
{ question: "这次主要面向谁?" },
{ question: "这次最想达成什么?" },
],
},
]);
await useAgentStore.getState().confirmAction({
requestId: "req-store-ask-1",
confirmed: true,
userData: {
question_1: "客户",
question_2: "提高转化",
},
});
expect(mockSubmitAsterElicitationResponse).toHaveBeenCalledWith(
"session-store-ask",
"req-store-ask-1",
{
question_1: "客户",
question_2: "提高转化",
},
{
elicitation_context: {
source: "action_required",
mode: "runtime_protocol",
form_id: "req-store-ask-1",
action_type: "ask_user",
field_count: 2,
prompt: "请补充任务信息",
entries: [
{
fieldId: "req-store-ask-1_question_1",
fieldKey: "question_1",
label: "这次主要面向谁?",
value: "客户",
summary: "客户",
},
{
fieldId: "req-store-ask-1_question_2",
fieldKey: "question_2",
label: "这次最想达成什么?",
value: "提高转化",
summary: "提高转化",
},
],
},
},
);
expect(useAgentStore.getState().pendingActions).toHaveLength(0);
});
it("elicitation schema 提交应透传 schema 派生的结构化 metadata", async () => {
useAgentStore.setState({
currentSessionId: "session-store-elicitation",
currentAssistantMsgId: "assistant-store-elicitation",
});
useAgentStore.getState()._handleEvent({
type: "action_required",
request_id: "req-store-elicitation-1",
action_type: "elicitation",
prompt: "请确认本次任务配置",
requested_schema: {
type: "object",
properties: {
audience: {
type: "string",
title: "目标受众",
},
include_cta: {
type: "boolean",
title: "加入行动号召",
},
},
},
});
await useAgentStore.getState().confirmAction({
requestId: "req-store-elicitation-1",
confirmed: true,
userData: {
audience: "潜在客户",
include_cta: true,
},
});
expect(mockSubmitAsterElicitationResponse).toHaveBeenCalledWith(
"session-store-elicitation",
"req-store-elicitation-1",
{
audience: "潜在客户",
include_cta: true,
},
{
elicitation_context: {
source: "action_required",
mode: "runtime_protocol",
form_id: "req-store-elicitation-1",
action_type: "elicitation",
field_count: 2,
prompt: "请确认本次任务配置",
entries: [
{
fieldId: "req-store-elicitation-1_audience",
fieldKey: "audience",
label: "目标受众",
value: "潜在客户",
summary: "潜在客户",
},
{
fieldId: "req-store-elicitation-1_include_cta",
fieldKey: "include_cta",
label: "加入行动号召",
value: true,
summary: "是",
},
],
},
},
);
});
});
-767
View File
@@ -1,767 +0,0 @@
/**
* Aster Agent Zustand Store
*
* 基于 Aster 框架的 Agent 状态管理
* 参考 Claude-Cowork 的设计模式
*
* @deprecated 当前仓库已迁移到 `useAgentChat` / `useAsterAgentChat` 主链路;
* 请不要在新代码中继续依赖这个遗留 store。
*/
import { create } from "zustand";
import type { UnlistenFn } from "@tauri-apps/api/event";
import { safeListen } from "@/lib/dev-bridge";
import {
confirmAsterAction,
createAsterSession,
deleteAsterSession,
getAsterSession,
initAsterAgent,
listAsterSessions,
sendAsterMessageStream,
stopAsterSession,
submitAsterElicitationResponse,
type AsterSessionDetail,
} from "@/lib/api/agentRuntime";
import {
parseStreamEvent,
type StreamEvent,
type ToolExecutionResult,
type TokenUsage,
} from "@/lib/api/agentStream";
import { requireDefaultProjectId } from "@/lib/api/project";
import {
isAsterSessionNotFoundError,
resolveRestorableSessionId,
} from "@/lib/asterSessionRecovery";
import {
buildActionRequestSubmissionContext,
type ActionRequestSubmissionContext,
} from "@/components/agent/chat/utils/actionRequestA2UI";
import type { ActionRequired as ChatActionRequired } from "@/components/agent/chat/types";
// ============ 类型定义 ============
/** 消息图片 */
export interface MessageImage {
data: string;
mediaType: string;
}
/** 工具调用结果 */
export type ToolResult = ToolExecutionResult;
export type { TokenUsage } from "@/lib/api/agentStream";
/** 工具调用状态 */
export interface ToolCallState {
id: string;
name: string;
arguments?: string;
status: "pending" | "running" | "completed" | "failed";
result?: ToolResult;
startTime?: Date;
endTime?: Date;
}
/** 内容片段类型 */
export type ContentPart =
| { type: "text"; text: string }
| { type: "thinking"; 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;
toolCalls?: ToolCallState[];
usage?: TokenUsage;
contentParts?: ContentPart[];
}
/** 会话信息 */
export interface SessionInfo {
id: string;
name?: string;
createdAt: Date;
updatedAt: Date;
messagesCount: number;
}
/** 权限确认请求 */
export interface ActionRequired {
requestId: string;
actionType:
| "tool_confirmation"
| "ask_user"
| "elicitation"
| "permission_request";
toolName?: string;
arguments?: Record<string, unknown>;
prompt?: string;
questions?: ChatActionRequired["questions"];
requestedSchema?: Record<string, unknown>;
options?: Array<{
label: string;
description?: string;
}>;
timestamp: Date;
}
/** 确认响应 */
export interface ConfirmResponse {
requestId: string;
confirmed: boolean;
response?: string;
actionType?: ActionRequired["actionType"];
userData?: unknown;
}
function toChatActionRequired(
action: ActionRequired | undefined,
): ChatActionRequired | null {
if (
!action ||
(action.actionType !== "ask_user" && action.actionType !== "elicitation")
) {
return null;
}
return {
requestId: action.requestId,
actionType: action.actionType,
prompt: action.prompt,
questions: action.questions,
requestedSchema: action.requestedSchema,
};
}
function resolveLegacyStoreSubmissionMetadata(
action: ActionRequired | undefined,
userData: unknown,
): ActionRequestSubmissionContext["requestMetadata"] | undefined {
const normalizedAction = toChatActionRequired(action);
if (!normalizedAction) {
return undefined;
}
return buildActionRequestSubmissionContext(normalizedAction, userData)
?.requestMetadata;
}
// ============ Tauri 事件类型 ============
/** Tauri Agent 事件 */
export type TauriAgentEvent = StreamEvent;
// ============ Store 状态类型 ============
interface AgentState {
currentSessionId: string | null;
sessions: SessionInfo[];
messages: Message[];
isStreaming: boolean;
currentAssistantMsgId: string | null;
pendingActions: ActionRequired[];
isInitialized: boolean;
initialize: () => Promise<void>;
sendMessage: (content: string, images?: MessageImage[]) => Promise<void>;
stopStreaming: () => Promise<void>;
confirmAction: (response: ConfirmResponse) => Promise<void>;
switchSession: (sessionId: string) => Promise<void>;
createSession: (name?: string) => Promise<string>;
deleteSession: (sessionId: string) => Promise<void>;
clearMessages: () => void;
loadSessions: () => Promise<void>;
_handleEvent: (event: StreamEvent) => void;
_cleanup: () => void;
}
// ============ Store 实现 ============
let eventUnlisten: UnlistenFn | null = null;
const resolveDefaultWorkspaceId = async (): Promise<string> => {
return requireDefaultProjectId("未找到默认工作区,请先创建并设为默认工作区");
};
const toRuntimeImages = (images?: MessageImage[]) =>
images?.map((image) => ({
data: image.data,
media_type: image.mediaType,
}));
const extractSessionMessageText = (
content: AsterSessionDetail["messages"][number]["content"],
): string => {
const segments = content
.map((block) => {
if (typeof block.text === "string" && block.text.trim()) {
return block.text.trim();
}
if (typeof block.output === "string" && block.output.trim()) {
return block.output.trim();
}
if (typeof block.error === "string" && block.error.trim()) {
return `错误: ${block.error.trim()}`;
}
return "";
})
.filter(Boolean);
return segments.join("\n\n");
};
export const useAgentStore = create<AgentState>((set, get) => ({
currentSessionId: null,
sessions: [],
messages: [],
isStreaming: false,
currentAssistantMsgId: null,
pendingActions: [],
isInitialized: false,
initialize: async () => {
try {
await initAsterAgent();
set({ isInitialized: true });
console.log("[AgentStore] Agent 初始化成功");
await get().loadSessions();
} catch (error) {
console.error("[AgentStore] Agent 初始化失败:", error);
throw error;
}
},
sendMessage: async (content: string, images?: MessageImage[]) => {
const state = get();
if (!state.isInitialized) {
await state.initialize();
}
let sessionId = state.currentSessionId;
if (!sessionId) {
sessionId = await state.createSession();
}
const userMsg: Message = {
id: crypto.randomUUID(),
role: "user",
content,
images,
timestamp: new Date(),
};
const assistantMsgId = crypto.randomUUID();
const assistantMsg: Message = {
id: assistantMsgId,
role: "assistant",
content: "",
timestamp: new Date(),
isThinking: true,
thinkingContent: "思考中...",
contentParts: [],
};
set((currentState) => ({
messages: [...currentState.messages, userMsg, assistantMsg],
isStreaming: true,
currentAssistantMsgId: assistantMsgId,
}));
const eventName = `aster_stream_${assistantMsgId}`;
try {
const workspaceId = await resolveDefaultWorkspaceId();
get()._cleanup();
eventUnlisten = await safeListen<unknown>(eventName, (event) => {
const parsedEvent = parseStreamEvent(event.payload);
if (!parsedEvent) {
return;
}
get()._handleEvent(parsedEvent);
});
await sendAsterMessageStream(
content,
sessionId,
eventName,
workspaceId,
toRuntimeImages(images),
);
} catch (error) {
console.error("[AgentStore] 发送消息失败:", error);
set((currentState) => ({
messages: currentState.messages.map((message) =>
message.id === assistantMsgId
? {
...message,
isThinking: false,
content: `错误: ${error}`,
}
: message,
),
isStreaming: false,
currentAssistantMsgId: null,
}));
get()._cleanup();
throw error;
}
},
stopStreaming: async () => {
const state = get();
if (!state.currentSessionId) return;
try {
await stopAsterSession(state.currentSessionId);
} catch (error) {
console.error("[AgentStore] 停止失败:", error);
}
set((currentState) => ({
messages: currentState.messages.map((message) =>
message.id === currentState.currentAssistantMsgId
? {
...message,
isThinking: false,
content: message.content || "(已停止生成)",
}
: message,
),
isStreaming: false,
currentAssistantMsgId: null,
}));
get()._cleanup();
},
confirmAction: async (response: ConfirmResponse) => {
try {
const state = get();
const actionType =
response.actionType ||
state.pendingActions.find(
(item) => item.requestId === response.requestId,
)?.actionType;
const pendingAction = state.pendingActions.find(
(item) => item.requestId === response.requestId,
);
if (actionType === "elicitation" || actionType === "ask_user") {
if (!state.currentSessionId) {
throw new Error("缺少会话 ID,无法提交 elicitation 响应");
}
let userData: unknown;
if (!response.confirmed) {
userData = "";
} else if (response.userData !== undefined) {
userData = response.userData;
} else if (response.response !== undefined) {
const rawResponse = response.response.trim();
if (!rawResponse) {
userData = "";
} else {
try {
userData = JSON.parse(rawResponse);
} catch {
userData = rawResponse;
}
}
} else {
userData = "";
}
await submitAsterElicitationResponse(
state.currentSessionId,
response.requestId,
userData,
resolveLegacyStoreSubmissionMetadata(pendingAction, userData),
);
} else {
await confirmAsterAction(
response.requestId,
response.confirmed,
response.response,
);
}
set((currentState) => ({
pendingActions: currentState.pendingActions.filter(
(item) => item.requestId !== response.requestId,
),
}));
} catch (error) {
console.error("[AgentStore] 确认失败:", error);
throw error;
}
},
switchSession: async (sessionId: string) => {
try {
const detail = await getAsterSession(sessionId);
const messages: Message[] = detail.messages.map((message, index) => ({
id: `${sessionId}-${index}`,
role: message.role === "user" ? "user" : "assistant",
content: extractSessionMessageText(message.content),
timestamp: new Date(message.timestamp),
}));
set({
currentSessionId: sessionId,
messages,
pendingActions: [],
isStreaming: false,
currentAssistantMsgId: null,
});
} catch (error) {
console.error("[AgentStore] 切换会话失败:", error);
if (isAsterSessionNotFoundError(error)) {
try {
const sessions = await listAsterSessions();
const recoveredSessionId = resolveRestorableSessionId({
candidateSessionId: null,
sessions: sessions.map((item) => ({
id: item.id,
createdAt: item.created_at,
updatedAt: item.updated_at,
})),
});
if (recoveredSessionId && recoveredSessionId !== sessionId) {
await get().switchSession(recoveredSessionId);
return;
}
await get().createSession();
set({
messages: [],
pendingActions: [],
isStreaming: false,
currentAssistantMsgId: null,
});
return;
} catch (recoveryError) {
console.error("[AgentStore] 恢复会话失败:", recoveryError);
}
}
throw error;
}
},
createSession: async (name?: string) => {
try {
const workspaceId = await resolveDefaultWorkspaceId();
const sessionId = await createAsterSession(workspaceId, undefined, name);
set((currentState) => ({
currentSessionId: sessionId,
messages: [],
pendingActions: [],
isStreaming: false,
currentAssistantMsgId: null,
sessions: [
{
id: sessionId,
name,
createdAt: new Date(),
updatedAt: new Date(),
messagesCount: 0,
},
...currentState.sessions,
],
}));
return sessionId;
} catch (error) {
console.error("[AgentStore] 创建会话失败:", error);
throw error;
}
},
deleteSession: async (sessionId: string) => {
await deleteAsterSession(sessionId);
set((currentState) => ({
sessions: currentState.sessions.filter(
(session) => session.id !== sessionId,
),
...(currentState.currentSessionId === sessionId
? {
currentSessionId: null,
messages: [],
pendingActions: [],
isStreaming: false,
currentAssistantMsgId: null,
}
: {}),
}));
},
clearMessages: () => {
set({
messages: [],
currentSessionId: null,
pendingActions: [],
isStreaming: false,
currentAssistantMsgId: null,
});
},
loadSessions: async () => {
try {
const sessions = await listAsterSessions();
set({
sessions: sessions.map((session) => ({
id: session.id,
name: session.name,
createdAt: new Date(session.created_at),
updatedAt: new Date(session.updated_at),
messagesCount: session.messages_count || 0,
})),
});
} catch (error) {
console.error("[AgentStore] 加载会话列表失败:", error);
}
},
_handleEvent: (event: StreamEvent) => {
const state = get();
const msgId = state.currentAssistantMsgId;
if (!msgId) return;
console.log("[AgentStore] 收到事件:", event.type, event);
switch (event.type) {
case "text_delta":
set((currentState) => ({
messages: currentState.messages.map((message) => {
if (message.id !== msgId) return message;
const nextContent = message.content + event.text;
const nextParts = [...(message.contentParts || [])];
const lastPart = nextParts[nextParts.length - 1];
if (lastPart && lastPart.type === "text") {
nextParts[nextParts.length - 1] = {
type: "text",
text: lastPart.text + event.text,
};
} else {
nextParts.push({ type: "text", text: event.text });
}
return {
...message,
content: nextContent,
isThinking: false,
thinkingContent: undefined,
contentParts: nextParts,
};
}),
}));
break;
case "thinking_delta":
set((currentState) => ({
messages: currentState.messages.map((message) => {
if (message.id !== msgId) return message;
const nextParts = [...(message.contentParts || [])];
const lastPart = nextParts[nextParts.length - 1];
if (lastPart && lastPart.type === "thinking") {
nextParts[nextParts.length - 1] = {
type: "thinking",
text: lastPart.text + event.text,
};
} else {
nextParts.push({ type: "thinking", text: event.text });
}
return {
...message,
thinkingContent: (message.thinkingContent || "") + event.text,
contentParts: nextParts,
};
}),
}));
break;
case "tool_start": {
const newToolCall: ToolCallState = {
id: event.tool_id,
name: event.tool_name,
arguments: event.arguments,
status: "running",
startTime: new Date(),
};
set((currentState) => ({
messages: currentState.messages.map((message) => {
if (message.id !== msgId) return message;
if (
message.toolCalls?.find(
(toolCall) => toolCall.id === event.tool_id,
)
) {
return message;
}
return {
...message,
toolCalls: [...(message.toolCalls || []), newToolCall],
contentParts: [
...(message.contentParts || []),
{ type: "tool_use", toolCall: newToolCall },
],
};
}),
}));
break;
}
case "tool_end":
set((currentState) => ({
messages: currentState.messages.map((message) => {
if (message.id !== msgId) return message;
const nextToolCalls = (message.toolCalls || []).map((toolCall) => {
if (toolCall.id !== event.tool_id) {
return toolCall;
}
const nextStatus: "completed" | "failed" = event.result.success
? "completed"
: "failed";
const updatedToolCall: ToolCallState = {
...toolCall,
status: nextStatus,
result: event.result,
endTime: new Date(),
};
return updatedToolCall;
});
const nextContentParts = (message.contentParts || []).map(
(part) => {
if (
part.type !== "tool_use" ||
part.toolCall.id !== event.tool_id
) {
return part;
}
const nextStatus: "completed" | "failed" = event.result.success
? "completed"
: "failed";
const updatedToolCall: ToolCallState = {
...part.toolCall,
status: nextStatus,
result: event.result,
endTime: new Date(),
};
return {
...part,
toolCall: updatedToolCall,
};
},
);
return {
...message,
toolCalls: nextToolCalls,
contentParts: nextContentParts,
};
}),
}));
break;
case "action_required":
set((currentState) => ({
pendingActions: [
...currentState.pendingActions,
{
requestId: event.request_id,
actionType: event.action_type as ActionRequired["actionType"],
toolName: event.tool_name,
arguments: event.arguments,
prompt: event.prompt || event.questions?.[0]?.question,
questions: event.questions,
requestedSchema: event.requested_schema,
options: event.questions?.[0]?.options,
timestamp: new Date(),
},
],
}));
break;
case "done":
console.log("[AgentStore] done 事件,等待 final_done...");
break;
case "final_done":
set((currentState) => ({
messages: currentState.messages.map((message) =>
message.id === msgId
? {
...message,
isThinking: false,
usage: event.usage,
}
: message,
),
isStreaming: false,
currentAssistantMsgId: null,
}));
get()._cleanup();
break;
case "error":
set((currentState) => ({
messages: currentState.messages.map((message) =>
message.id === msgId
? {
...message,
isThinking: false,
content: message.content || `错误: ${event.message}`,
}
: message,
),
isStreaming: false,
currentAssistantMsgId: null,
}));
get()._cleanup();
break;
case "warning":
console.warn("[AgentStore] warning:", event.code, event.message);
break;
}
},
_cleanup: () => {
const cleanup = eventUnlisten;
eventUnlisten = null;
cleanup?.();
},
}));
export const useAgentMessages = () => useAgentStore((state) => state.messages);
export const useAgentStreaming = () =>
useAgentStore((state) => state.isStreaming);
export const useAgentSessions = () => useAgentStore((state) => state.sessions);
export const usePendingActions = () =>
useAgentStore((state) => state.pendingActions);
-24
View File
@@ -1,24 +0,0 @@
/**
* Stores 导出
*
* @deprecated `agentStore` 属于遗留导出,请不要在新代码中继续依赖。
*/
// Aster Agent Store
export {
useAgentStore,
useAgentMessages,
useAgentStreaming,
useAgentSessions,
usePendingActions,
type Message,
type MessageImage,
type ToolResult,
type ToolCallState,
type TokenUsage,
type ContentPart,
type SessionInfo,
type ActionRequired,
type ConfirmResponse,
type TauriAgentEvent,
} from "./agentStore";