mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
Release v0.90.0
This commit is contained in:
+134
-77
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 已删除) |
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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("收起详细诊断");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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));
|
||||
}, []);
|
||||
|
||||
@@ -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
@@ -11,7 +11,7 @@
|
||||
|
||||
## useUnifiedChat
|
||||
|
||||
统一的对话逻辑 Hook,替代原有分散的 `useAgentChat` 和 `useChat`。
|
||||
统一的对话逻辑 Hook,统一收口 Agent / General / Creator 三类对话入口。
|
||||
|
||||
### 使用示例
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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>(
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
@@ -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]);
|
||||
|
||||
@@ -800,7 +800,6 @@
|
||||
"参数": "Parameters",
|
||||
"执行日志": "Execution Log",
|
||||
"执行结果": "Execution Result",
|
||||
"// === components\\agent\\chat\\hooks\\useAgentChat.ts ===": "",
|
||||
"加载模型配置失败": "Failed to load model configuration",
|
||||
"使用默认配置": "Using default configuration",
|
||||
"模型": "Model",
|
||||
|
||||
@@ -774,7 +774,6 @@
|
||||
"参数": "参数",
|
||||
"执行日志": "执行日志",
|
||||
"执行结果": "执行结果",
|
||||
"// === components\\agent\\chat\\hooks\\useAgentChat.ts ===": "",
|
||||
"加载模型配置失败": "加载模型配置失败",
|
||||
"使用默认配置": "使用默认配置",
|
||||
"模型": "模型",
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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",
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
/**
|
||||
* Agent API 现役门面
|
||||
*
|
||||
* 新代码请优先直接使用:
|
||||
* - `agentRuntime.ts`:现役运行时 API
|
||||
* - `agentStream.ts`:流式事件与 UI 类型
|
||||
*
|
||||
* 历史 `agentCompat.ts` 已删除,避免新代码和 AI 自动补全继续误用旧 API。
|
||||
*/
|
||||
|
||||
export * from "./agentRuntime";
|
||||
export * from "./agentStream";
|
||||
+66
-374
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }),
|
||||
);
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 辅助函数 ====================
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 }),
|
||||
);
|
||||
},
|
||||
|
||||
// 获取单个凭证的健康状态
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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: () => ({}),
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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("[语音输入] 收到停止录音事件");
|
||||
|
||||
// 播放停止录音音效
|
||||
|
||||
@@ -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: "是",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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";
|
||||
Reference in New Issue
Block a user