mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
feat: release v0.84.0 with full pending changes
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+5
-27
@@ -10,7 +10,7 @@
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import styled from "styled-components";
|
||||
import { safeInvoke } from "@/lib/dev-bridge";
|
||||
import { getWindowsStartupDiagnostics } from "@/lib/api/serverRuntime";
|
||||
import { withI18nPatch } from "./i18n/withI18nPatch";
|
||||
import { SplashScreen } from "./components/SplashScreen";
|
||||
import { AppSidebar } from "./components/AppSidebar";
|
||||
@@ -30,6 +30,7 @@ import { WorkbenchPage } from "./components/workspace";
|
||||
import {
|
||||
ProjectType,
|
||||
createProject,
|
||||
ensureDefaultWorkspaceReady,
|
||||
isUserProjectType,
|
||||
resolveProjectRootPath,
|
||||
} from "./lib/api/project";
|
||||
@@ -112,22 +113,6 @@ const THEME_WORKSPACE_PAGES: ThemeWorkspacePage[] = [
|
||||
"workspace-novel",
|
||||
];
|
||||
|
||||
interface WindowsStartupDiagnostics {
|
||||
platform: string;
|
||||
app_data_dir?: string | null;
|
||||
legacy_proxycast_dir?: string | null;
|
||||
db_path?: string | null;
|
||||
webview2_version?: string | null;
|
||||
checks: Array<{
|
||||
key: string;
|
||||
status: string;
|
||||
message: string;
|
||||
detail?: string | null;
|
||||
}>;
|
||||
has_blocking_issues: boolean;
|
||||
has_warnings: boolean;
|
||||
summary_message?: string | null;
|
||||
}
|
||||
|
||||
function isTauriDesktopEnvironment(): boolean {
|
||||
if (typeof window === "undefined") {
|
||||
@@ -386,9 +371,7 @@ function AppContent() {
|
||||
return;
|
||||
}
|
||||
|
||||
void safeInvoke<WindowsStartupDiagnostics>(
|
||||
"get_windows_startup_diagnostics",
|
||||
)
|
||||
void getWindowsStartupDiagnostics()
|
||||
.then((diagnostics) => {
|
||||
if (!diagnostics.summary_message) {
|
||||
return;
|
||||
@@ -415,13 +398,7 @@ function AppContent() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void safeInvoke<{
|
||||
workspaceId: string;
|
||||
rootPath: string;
|
||||
created: boolean;
|
||||
repaired: boolean;
|
||||
relocated?: boolean;
|
||||
} | null>("workspace_ensure_default_ready")
|
||||
void ensureDefaultWorkspaceReady()
|
||||
.then((result) => {
|
||||
if (result?.repaired) {
|
||||
recordWorkspaceRepair({
|
||||
@@ -633,6 +610,7 @@ function AppContent() {
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflowY: "auto",
|
||||
display: currentPage === "openclaw" ? "flex" : "none",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
|
||||
@@ -43,7 +43,7 @@ import {
|
||||
PageParams,
|
||||
ThemeWorkspacePage,
|
||||
} from "@/types/page";
|
||||
import { getConfig } from "@/hooks/useTauri";
|
||||
import { getConfig } from "@/lib/api/appConfig";
|
||||
import {
|
||||
buildHomeAgentParams,
|
||||
buildWorkspaceResetParams,
|
||||
@@ -306,6 +306,7 @@ const THEME_MENU_ITEMS: SidebarNavItem[] = [
|
||||
label: "短视频",
|
||||
icon: Video,
|
||||
page: getThemeWorkspacePage("video"),
|
||||
params: { workspaceViewMode: "workspace" },
|
||||
isActive: (currentPage) => currentPage === getThemeWorkspacePage("video"),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -18,8 +18,6 @@ import {
|
||||
getEnvVariables,
|
||||
getTokenFileHash,
|
||||
checkAndReloadCredentials,
|
||||
KiroCredentialStatus,
|
||||
EnvVariable,
|
||||
// Gemini
|
||||
getGeminiCredentials,
|
||||
reloadGeminiCredentials,
|
||||
@@ -27,7 +25,6 @@ import {
|
||||
getGeminiEnvVariables,
|
||||
getGeminiTokenFileHash,
|
||||
checkAndReloadGeminiCredentials,
|
||||
GeminiCredentialStatus,
|
||||
// Qwen
|
||||
getQwenCredentials,
|
||||
reloadQwenCredentials,
|
||||
@@ -35,18 +32,19 @@ import {
|
||||
getQwenEnvVariables,
|
||||
getQwenTokenFileHash,
|
||||
checkAndReloadQwenCredentials,
|
||||
QwenCredentialStatus,
|
||||
// OpenAI/Claude Custom
|
||||
getOpenAICustomStatus,
|
||||
setOpenAICustomConfig,
|
||||
getClaudeCustomStatus,
|
||||
setClaudeCustomConfig,
|
||||
OpenAICustomStatus,
|
||||
ClaudeCustomStatus,
|
||||
// Default Provider
|
||||
getDefaultProvider,
|
||||
setDefaultProvider,
|
||||
} from "@/hooks/useTauri";
|
||||
type ClaudeCustomStatus,
|
||||
type EnvVariable,
|
||||
type GeminiCredentialStatus,
|
||||
type KiroCredentialStatus,
|
||||
type OpenAICustomStatus,
|
||||
type QwenCredentialStatus,
|
||||
} from "@/lib/api/providerRuntime";
|
||||
import { getDefaultProvider, setDefaultProvider } from "@/lib/api/appConfig";
|
||||
import { useProviderState } from "@/hooks/useProviderState";
|
||||
import { useFileMonitoring } from "@/hooks/useFileMonitoring";
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ const {
|
||||
mockEmitProviderDataChanged: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/agent", () => ({
|
||||
vi.mock("@/lib/api/agentRuntime", () => ({
|
||||
initAsterAgent: mockInitAsterAgent,
|
||||
sendAsterMessageStream: mockSendAsterMessageStream,
|
||||
createAsterSession: mockCreateAsterSession,
|
||||
@@ -59,6 +59,9 @@ vi.mock("@/lib/api/agent", () => ({
|
||||
stopAsterSession: mockStopAsterSession,
|
||||
confirmAsterAction: mockConfirmAsterAction,
|
||||
submitAsterElicitationResponse: mockSubmitAsterElicitationResponse,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/agentStream", () => ({
|
||||
parseStreamEvent: mockParseStreamEvent,
|
||||
}));
|
||||
|
||||
@@ -141,7 +144,10 @@ function createModel(id: string, providerId: string) {
|
||||
};
|
||||
}
|
||||
|
||||
function mount(workspaceId: string, options: MountOptions = {}): HTMLDivElement {
|
||||
function mount(
|
||||
workspaceId: string,
|
||||
options: MountOptions = {},
|
||||
): HTMLDivElement {
|
||||
const { onManageProviders } = options;
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
@@ -218,12 +224,14 @@ function findButtonByText(
|
||||
options: { excludeCombobox?: boolean } = {},
|
||||
): HTMLButtonElement {
|
||||
const { excludeCombobox = false } = options;
|
||||
const target = Array.from(document.querySelectorAll("button")).find((node) => {
|
||||
if (excludeCombobox && node.getAttribute("role") === "combobox") {
|
||||
return false;
|
||||
}
|
||||
return node.textContent?.includes(text);
|
||||
});
|
||||
const target = Array.from(document.querySelectorAll("button")).find(
|
||||
(node) => {
|
||||
if (excludeCombobox && node.getAttribute("role") === "combobox") {
|
||||
return false;
|
||||
}
|
||||
return node.textContent?.includes(text);
|
||||
},
|
||||
);
|
||||
if (!target) {
|
||||
throw new Error(`未找到按钮文本: ${text}`);
|
||||
}
|
||||
@@ -294,22 +302,30 @@ beforeEach(() => {
|
||||
loading: false,
|
||||
});
|
||||
|
||||
mockUseProviderModels.mockImplementation((selectedProvider: { key: string } | null) => {
|
||||
const key = selectedProvider?.key;
|
||||
const models =
|
||||
key === "gemini"
|
||||
? [createModel("gemini-2.5-pro", "gemini"), createModel("gemini-2.5-flash", "gemini")]
|
||||
: key === "deepseek"
|
||||
? [createModel("deepseek-chat", "deepseek"), createModel("deepseek-reasoner", "deepseek")]
|
||||
: [];
|
||||
mockUseProviderModels.mockImplementation(
|
||||
(selectedProvider: { key: string } | null) => {
|
||||
const key = selectedProvider?.key;
|
||||
const models =
|
||||
key === "gemini"
|
||||
? [
|
||||
createModel("gemini-2.5-pro", "gemini"),
|
||||
createModel("gemini-2.5-flash", "gemini"),
|
||||
]
|
||||
: key === "deepseek"
|
||||
? [
|
||||
createModel("deepseek-chat", "deepseek"),
|
||||
createModel("deepseek-reasoner", "deepseek"),
|
||||
]
|
||||
: [];
|
||||
|
||||
return {
|
||||
modelIds: models.map((item) => item.id),
|
||||
models,
|
||||
loading: false,
|
||||
error: null,
|
||||
};
|
||||
});
|
||||
return {
|
||||
modelIds: models.map((item) => item.id),
|
||||
models,
|
||||
loading: false,
|
||||
error: null,
|
||||
};
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -385,9 +401,8 @@ describe("ChatModelSelector + useAsterAgentChat 集成", () => {
|
||||
|
||||
expect(
|
||||
JSON.parse(
|
||||
localStorage.getItem(
|
||||
`agent_topic_model_pref_${workspaceId}_topic-a`,
|
||||
) || "null",
|
||||
localStorage.getItem(`agent_topic_model_pref_${workspaceId}_topic-a`) ||
|
||||
"null",
|
||||
),
|
||||
).toEqual({
|
||||
providerType: "gemini",
|
||||
@@ -395,9 +410,8 @@ describe("ChatModelSelector + useAsterAgentChat 集成", () => {
|
||||
});
|
||||
expect(
|
||||
JSON.parse(
|
||||
localStorage.getItem(
|
||||
`agent_topic_model_pref_${workspaceId}_topic-b`,
|
||||
) || "null",
|
||||
localStorage.getItem(`agent_topic_model_pref_${workspaceId}_topic-b`) ||
|
||||
"null",
|
||||
),
|
||||
).toEqual({
|
||||
providerType: "deepseek",
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import React, { useState } from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ChatNavbar } from "./ChatNavbar";
|
||||
|
||||
vi.mock("@/components/projects/ProjectSelector", () => ({
|
||||
ProjectSelector: () => <div data-testid="project-selector" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/button", () => ({
|
||||
Button: ({
|
||||
children,
|
||||
onClick,
|
||||
disabled,
|
||||
...rest
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
[key: string]: unknown;
|
||||
}) => (
|
||||
<button type="button" onClick={onClick} disabled={disabled} {...rest}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
interface MountedHarness {
|
||||
container: HTMLDivElement;
|
||||
root: Root;
|
||||
}
|
||||
|
||||
const mountedRoots: MountedHarness[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
}
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (mountedRoots.length > 0) {
|
||||
const mounted = mountedRoots.pop();
|
||||
if (!mounted) break;
|
||||
act(() => {
|
||||
mounted.root.unmount();
|
||||
});
|
||||
mounted.container.remove();
|
||||
}
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function mount(node: React.ReactNode) {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(node);
|
||||
});
|
||||
|
||||
mountedRoots.push({ container, root });
|
||||
return container;
|
||||
}
|
||||
|
||||
function renderChatNavbar(
|
||||
props?: Partial<React.ComponentProps<typeof ChatNavbar>>,
|
||||
) {
|
||||
const defaultProps: React.ComponentProps<typeof ChatNavbar> = {
|
||||
isRunning: false,
|
||||
onToggleHistory: vi.fn(),
|
||||
onToggleFullscreen: vi.fn(),
|
||||
};
|
||||
|
||||
return mount(<ChatNavbar {...defaultProps} {...props} />);
|
||||
}
|
||||
|
||||
describe("ChatNavbar", () => {
|
||||
it("有 Harness 信号时应渲染顶栏切换按钮", () => {
|
||||
const onToggleHarnessPanel = vi.fn();
|
||||
const container = renderChatNavbar({
|
||||
showHarnessToggle: true,
|
||||
harnessPanelVisible: false,
|
||||
harnessPendingCount: 2,
|
||||
onToggleHarnessPanel,
|
||||
});
|
||||
|
||||
const button = container.querySelector(
|
||||
'button[aria-label="展开 Harness 面板"]',
|
||||
) as HTMLButtonElement | null;
|
||||
|
||||
expect(button).not.toBeNull();
|
||||
expect(button?.textContent).toContain("Harness");
|
||||
expect(button?.textContent).toContain("2");
|
||||
|
||||
act(() => {
|
||||
button?.click();
|
||||
});
|
||||
|
||||
expect(onToggleHarnessPanel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("点击顶栏按钮后应切换 Harness 面板显隐", () => {
|
||||
function HarnessToggleHarness() {
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ChatNavbar
|
||||
isRunning={false}
|
||||
onToggleHistory={() => {}}
|
||||
onToggleFullscreen={() => {}}
|
||||
showHarnessToggle
|
||||
harnessPanelVisible={visible}
|
||||
onToggleHarnessPanel={() => setVisible((current) => !current)}
|
||||
/>
|
||||
{visible ? (
|
||||
<div data-testid="harness-panel">Harness Panel</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const container = mount(<HarnessToggleHarness />);
|
||||
const expandButton = container.querySelector(
|
||||
'button[aria-label="展开 Harness 面板"]',
|
||||
) as HTMLButtonElement | null;
|
||||
|
||||
expect(container.querySelector('[data-testid="harness-panel"]')).toBeNull();
|
||||
|
||||
act(() => {
|
||||
expandButton?.click();
|
||||
});
|
||||
|
||||
expect(
|
||||
container.querySelector('[data-testid="harness-panel"]'),
|
||||
).not.toBeNull();
|
||||
|
||||
const collapseButton = container.querySelector(
|
||||
'button[aria-label="收起 Harness 面板"]',
|
||||
) as HTMLButtonElement | null;
|
||||
|
||||
act(() => {
|
||||
collapseButton?.click();
|
||||
});
|
||||
|
||||
expect(container.querySelector('[data-testid="harness-panel"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,19 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Box,
|
||||
ChevronDown,
|
||||
FolderOpen,
|
||||
Home,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
Plus,
|
||||
Settings2,
|
||||
Sparkles,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ProjectSelector } from "@/components/projects/ProjectSelector";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Navbar } from "../styles";
|
||||
|
||||
interface ChatNavbarProps {
|
||||
@@ -25,6 +28,11 @@ interface ChatNavbarProps {
|
||||
projectId?: string | null;
|
||||
onProjectChange?: (projectId: string) => void;
|
||||
workspaceType?: string;
|
||||
showHarnessToggle?: boolean;
|
||||
harnessPanelVisible?: boolean;
|
||||
onToggleHarnessPanel?: () => void;
|
||||
harnessPendingCount?: number;
|
||||
harnessAttentionLevel?: "idle" | "active" | "warning";
|
||||
novelCanvasControls?: {
|
||||
chapterListCollapsed: boolean;
|
||||
onToggleChapterList: () => void;
|
||||
@@ -45,6 +53,11 @@ export const ChatNavbar: React.FC<ChatNavbarProps> = ({
|
||||
projectId = null,
|
||||
onProjectChange,
|
||||
workspaceType,
|
||||
showHarnessToggle = false,
|
||||
harnessPanelVisible = false,
|
||||
onToggleHarnessPanel,
|
||||
harnessPendingCount = 0,
|
||||
harnessAttentionLevel = "idle",
|
||||
novelCanvasControls = null,
|
||||
}) => {
|
||||
return (
|
||||
@@ -147,6 +160,42 @@ export const ChatNavbar: React.FC<ChatNavbarProps> = ({
|
||||
className="h-8 text-xs min-w-[160px] max-w-[220px]"
|
||||
/>
|
||||
|
||||
{showHarnessToggle ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant={harnessPanelVisible ? "secondary" : "outline"}
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-8 gap-1.5 px-3 text-xs",
|
||||
harnessAttentionLevel === "warning" &&
|
||||
!harnessPanelVisible &&
|
||||
"border-amber-300 text-amber-700 hover:text-amber-800",
|
||||
)}
|
||||
onClick={onToggleHarnessPanel}
|
||||
aria-label={
|
||||
harnessPanelVisible ? "收起 Harness 面板" : "展开 Harness 面板"
|
||||
}
|
||||
aria-expanded={harnessPanelVisible}
|
||||
title={
|
||||
harnessPanelVisible ? "收起 Harness 面板" : "展开 Harness 面板"
|
||||
}
|
||||
>
|
||||
<Sparkles size={14} />
|
||||
<span>Harness</span>
|
||||
{harnessPendingCount > 0 ? (
|
||||
<span className="rounded-full bg-destructive px-1.5 py-0.5 text-[10px] font-medium leading-none text-destructive-foreground">
|
||||
{harnessPendingCount > 99 ? "99+" : harnessPendingCount}
|
||||
</span>
|
||||
) : null}
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-3.5 w-3.5 transition-transform",
|
||||
harnessPanelVisible && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
Music,
|
||||
Code2,
|
||||
} from "lucide-react";
|
||||
import { getConfig } from "@/hooks/useTauri";
|
||||
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";
|
||||
@@ -543,8 +543,10 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
const [enabledThemes, setEnabledThemes] = useState<string[]>(
|
||||
DEFAULT_ENABLED_THEMES,
|
||||
);
|
||||
const [appendSelectedTextToRecommendation, setAppendSelectedTextToRecommendation] =
|
||||
useState(true);
|
||||
const [
|
||||
appendSelectedTextToRecommendation,
|
||||
setAppendSelectedTextToRecommendation,
|
||||
] = useState(true);
|
||||
|
||||
// 加载配置
|
||||
useEffect(() => {
|
||||
@@ -555,8 +557,8 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
setEnabledThemes(loadedConfig.content_creator.enabled_themes);
|
||||
}
|
||||
setAppendSelectedTextToRecommendation(
|
||||
loadedConfig.chat_appearance?.append_selected_text_to_recommendation ??
|
||||
true,
|
||||
loadedConfig.chat_appearance
|
||||
?.append_selected_text_to_recommendation ?? true,
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("加载主题配置失败:", e);
|
||||
@@ -618,10 +620,7 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
if (wrappedByActiveSkill !== text) {
|
||||
return wrappedByActiveSkill;
|
||||
}
|
||||
if (
|
||||
activeTheme === "social-media" &&
|
||||
!text.trimStart().startsWith("/")
|
||||
) {
|
||||
if (activeTheme === "social-media" && !text.trimStart().startsWith("/")) {
|
||||
return `/${SOCIAL_ARTICLE_SKILL_KEY} ${text}`.trim();
|
||||
}
|
||||
return text;
|
||||
@@ -854,7 +853,8 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
<ContentWrapper>
|
||||
<Header>
|
||||
<MainTitle>
|
||||
{themeHeadline.lead}<span>{themeHeadline.focus}</span>
|
||||
{themeHeadline.lead}
|
||||
<span>{themeHeadline.focus}</span>
|
||||
</MainTitle>
|
||||
</Header>
|
||||
|
||||
@@ -1260,9 +1260,7 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
? "border-yellow-500 text-yellow-600 bg-yellow-50 dark:bg-yellow-950/30"
|
||||
: ""
|
||||
}`}
|
||||
onClick={() =>
|
||||
onThinkingEnabledChange?.(!thinkingEnabled)
|
||||
}
|
||||
onClick={() => onThinkingEnabledChange?.(!thinkingEnabled)}
|
||||
aria-pressed={thinkingEnabled}
|
||||
title={thinkingEnabled ? "关闭深度思考" : "开启深度思考"}
|
||||
>
|
||||
@@ -1279,9 +1277,7 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
? "border-blue-500 text-blue-600 bg-blue-50 dark:bg-blue-950/30"
|
||||
: ""
|
||||
}`}
|
||||
onClick={() =>
|
||||
onWebSearchEnabledChange?.(!webSearchEnabled)
|
||||
}
|
||||
onClick={() => onWebSearchEnabledChange?.(!webSearchEnabled)}
|
||||
aria-pressed={webSearchEnabled}
|
||||
title={webSearchEnabled ? "关闭联网搜索" : "开启联网搜索"}
|
||||
>
|
||||
@@ -1347,7 +1343,9 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
{selectedTextPreview && (
|
||||
<div className="w-full max-w-[800px] text-xs text-muted-foreground bg-muted/30 border border-border/70 rounded-lg px-3 py-2">
|
||||
已检测到选中内容,点击推荐会自动附带上下文:
|
||||
<span className="ml-1 text-foreground">“{selectedTextPreview}”</span>
|
||||
<span className="ml-1 text-foreground">
|
||||
“{selectedTextPreview}”
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="w-full max-w-[800px] flex flex-wrap gap-3 justify-center">
|
||||
|
||||
@@ -0,0 +1,514 @@
|
||||
import { act, type ComponentProps } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { HarnessStatusPanel } from "./HarnessStatusPanel";
|
||||
import type { HarnessSessionState } from "../utils/harnessState";
|
||||
|
||||
const { mockToast } = vi.hoisted(() => ({
|
||||
mockToast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: mockToast,
|
||||
}));
|
||||
|
||||
interface RenderResult {
|
||||
container: HTMLDivElement;
|
||||
root: Root;
|
||||
}
|
||||
|
||||
const mountedRoots: RenderResult[] = [];
|
||||
let originalClipboard: Clipboard | undefined;
|
||||
|
||||
function createHarnessState(
|
||||
overrides: Partial<HarnessSessionState> = {},
|
||||
): HarnessSessionState {
|
||||
return {
|
||||
pendingApprovals: [],
|
||||
latestContextTrace: [],
|
||||
plan: {
|
||||
phase: "idle",
|
||||
items: [],
|
||||
},
|
||||
activity: {
|
||||
planning: 0,
|
||||
filesystem: 1,
|
||||
execution: 0,
|
||||
web: 0,
|
||||
skills: 0,
|
||||
delegation: 0,
|
||||
},
|
||||
delegatedTasks: [],
|
||||
outputSignals: [],
|
||||
recentFileEvents: [],
|
||||
hasSignals: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderPanel(
|
||||
overrides: Partial<ComponentProps<typeof HarnessStatusPanel>> = {},
|
||||
): RenderResult {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<HarnessStatusPanel
|
||||
harnessState={createHarnessState()}
|
||||
subAgentRuntime={{
|
||||
isRunning: false,
|
||||
progress: null,
|
||||
events: [],
|
||||
result: null,
|
||||
error: null,
|
||||
}}
|
||||
environment={{
|
||||
skillsCount: 2,
|
||||
skillNames: ["read_file", "write_todos"],
|
||||
memorySignals: ["风格"],
|
||||
contextItemsCount: 2,
|
||||
activeContextCount: 1,
|
||||
contextItemNames: ["需求.md"],
|
||||
contextEnabled: true,
|
||||
}}
|
||||
{...overrides}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const rendered = { container, root };
|
||||
mountedRoots.push(rendered);
|
||||
return rendered;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
}
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
originalClipboard = navigator.clipboard;
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
configurable: true,
|
||||
value: {
|
||||
writeText: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (mountedRoots.length > 0) {
|
||||
const mounted = mountedRoots.pop();
|
||||
if (!mounted) {
|
||||
break;
|
||||
}
|
||||
act(() => {
|
||||
mounted.root.unmount();
|
||||
});
|
||||
mounted.container.remove();
|
||||
}
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
configurable: true,
|
||||
value: originalClipboard,
|
||||
});
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("HarnessStatusPanel", () => {
|
||||
it("摘要卡和快速导航应支持跳转到对应区块", () => {
|
||||
const scrollIntoViewMock = vi.fn();
|
||||
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
|
||||
HTMLElement.prototype.scrollIntoView = scrollIntoViewMock;
|
||||
|
||||
renderPanel({
|
||||
harnessState: createHarnessState({
|
||||
pendingApprovals: [
|
||||
{
|
||||
requestId: "approval-1",
|
||||
actionType: "tool_confirmation",
|
||||
prompt: "确认写入",
|
||||
},
|
||||
],
|
||||
recentFileEvents: [
|
||||
{
|
||||
id: "event-nav-1",
|
||||
toolCallId: "tool-nav-1",
|
||||
path: "/tmp/workspace/nav.md",
|
||||
displayName: "nav.md",
|
||||
kind: "document",
|
||||
action: "write",
|
||||
sourceToolName: "Write",
|
||||
timestamp: new Date("2026-03-11T12:00:00.000Z"),
|
||||
preview: "导航预览",
|
||||
clickable: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const summaryJumpButton = document.body.querySelector(
|
||||
'button[aria-label="跳转到待审批"]',
|
||||
) as HTMLButtonElement | null;
|
||||
|
||||
act(() => {
|
||||
summaryJumpButton?.click();
|
||||
});
|
||||
|
||||
expect(scrollIntoViewMock).toHaveBeenCalled();
|
||||
|
||||
HTMLElement.prototype.scrollIntoView = originalScrollIntoView;
|
||||
});
|
||||
|
||||
it("应渲染最近文件活动区块", () => {
|
||||
renderPanel({
|
||||
harnessState: createHarnessState({
|
||||
recentFileEvents: [
|
||||
{
|
||||
id: "event-1",
|
||||
toolCallId: "tool-1",
|
||||
path: "/tmp/workspace/draft.md",
|
||||
displayName: "draft.md",
|
||||
kind: "document",
|
||||
action: "write",
|
||||
sourceToolName: "Write",
|
||||
timestamp: new Date("2026-03-11T12:00:00.000Z"),
|
||||
preview: "# 草稿\n这是预览",
|
||||
clickable: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(document.body.textContent).toContain("最近文件活动");
|
||||
expect(document.body.textContent).toContain("draft.md");
|
||||
expect(document.body.textContent).toContain("写入");
|
||||
expect(document.body.textContent).toContain("这是预览");
|
||||
});
|
||||
|
||||
it("点击文件活动后应加载并展示预览内容", async () => {
|
||||
const onLoadFilePreview = vi.fn().mockResolvedValue({
|
||||
path: "/tmp/workspace/draft.md",
|
||||
content: "# 标题\n正文内容",
|
||||
isBinary: false,
|
||||
size: 18,
|
||||
error: null,
|
||||
});
|
||||
const onOpenFile = vi.fn();
|
||||
|
||||
renderPanel({
|
||||
harnessState: createHarnessState({
|
||||
recentFileEvents: [
|
||||
{
|
||||
id: "event-2",
|
||||
toolCallId: "tool-2",
|
||||
path: "/tmp/workspace/draft.md",
|
||||
displayName: "draft.md",
|
||||
kind: "document",
|
||||
action: "read",
|
||||
sourceToolName: "Read",
|
||||
timestamp: new Date("2026-03-11T12:01:00.000Z"),
|
||||
preview: "摘要预览",
|
||||
clickable: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
onLoadFilePreview,
|
||||
onOpenFile,
|
||||
});
|
||||
|
||||
const trigger = document.body.querySelector(
|
||||
'button[aria-label="查看文件活动:draft.md"]',
|
||||
) as HTMLButtonElement | null;
|
||||
|
||||
await act(async () => {
|
||||
trigger?.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(onLoadFilePreview).toHaveBeenCalledWith("/tmp/workspace/draft.md");
|
||||
expect(document.body.textContent).toContain("# 标题");
|
||||
expect(document.body.textContent).toContain("正文内容");
|
||||
|
||||
const openInChatButton = Array.from(
|
||||
document.body.querySelectorAll("button"),
|
||||
).find((button) => button.textContent?.includes("在会话中打开"));
|
||||
|
||||
act(() => {
|
||||
openInChatButton?.dispatchEvent(
|
||||
new MouseEvent("click", { bubbles: true }),
|
||||
);
|
||||
});
|
||||
|
||||
expect(onOpenFile).toHaveBeenCalledWith(
|
||||
"/tmp/workspace/draft.md",
|
||||
"# 标题\n正文内容",
|
||||
);
|
||||
});
|
||||
|
||||
it("应支持按类型筛选最近文件活动", () => {
|
||||
renderPanel({
|
||||
harnessState: createHarnessState({
|
||||
recentFileEvents: [
|
||||
{
|
||||
id: "event-filter-doc",
|
||||
toolCallId: "tool-filter-doc",
|
||||
path: "/tmp/workspace/spec.md",
|
||||
displayName: "spec.md",
|
||||
kind: "document",
|
||||
action: "write",
|
||||
sourceToolName: "Write",
|
||||
timestamp: new Date("2026-03-11T12:10:00.000Z"),
|
||||
preview: "需求说明",
|
||||
clickable: true,
|
||||
},
|
||||
{
|
||||
id: "event-filter-code",
|
||||
toolCallId: "tool-filter-code",
|
||||
path: "/tmp/workspace/app.ts",
|
||||
displayName: "app.ts",
|
||||
kind: "code",
|
||||
action: "edit",
|
||||
sourceToolName: "Edit",
|
||||
timestamp: new Date("2026-03-11T12:11:00.000Z"),
|
||||
preview: "const app = true;",
|
||||
clickable: true,
|
||||
},
|
||||
{
|
||||
id: "event-filter-log",
|
||||
toolCallId: "tool-filter-log",
|
||||
path: "/tmp/workspace/run.log",
|
||||
displayName: "run.log",
|
||||
kind: "log",
|
||||
action: "persist",
|
||||
sourceToolName: "Execute",
|
||||
timestamp: new Date("2026-03-11T12:12:00.000Z"),
|
||||
preview: "执行完成",
|
||||
clickable: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const codeFilterButton = document.body.querySelector(
|
||||
'button[aria-label="文件活动筛选:代码"]',
|
||||
) as HTMLButtonElement | null;
|
||||
|
||||
act(() => {
|
||||
codeFilterButton?.click();
|
||||
});
|
||||
|
||||
const fileSection = document.body.querySelector(
|
||||
'[data-harness-section="files"]',
|
||||
) as HTMLElement | null;
|
||||
|
||||
expect(fileSection?.textContent).toContain("app.ts");
|
||||
expect(fileSection?.textContent).not.toContain("spec.md");
|
||||
expect(fileSection?.textContent).not.toContain("run.log");
|
||||
expect(fileSection?.textContent).toContain("1 / 3 条");
|
||||
});
|
||||
|
||||
it("应支持按文件聚合最近文件活动", () => {
|
||||
renderPanel({
|
||||
harnessState: createHarnessState({
|
||||
recentFileEvents: [
|
||||
{
|
||||
id: "event-group-1",
|
||||
toolCallId: "tool-group-1",
|
||||
path: "/tmp/workspace/draft.md",
|
||||
displayName: "draft.md",
|
||||
kind: "document",
|
||||
action: "write",
|
||||
sourceToolName: "Write",
|
||||
timestamp: new Date("2026-03-11T12:20:00.000Z"),
|
||||
preview: "第一版",
|
||||
clickable: true,
|
||||
},
|
||||
{
|
||||
id: "event-group-2",
|
||||
toolCallId: "tool-group-2",
|
||||
path: "/tmp/workspace/draft.md",
|
||||
displayName: "draft.md",
|
||||
kind: "document",
|
||||
action: "edit",
|
||||
sourceToolName: "Edit",
|
||||
timestamp: new Date("2026-03-11T12:21:00.000Z"),
|
||||
preview: "第二版",
|
||||
clickable: true,
|
||||
},
|
||||
{
|
||||
id: "event-group-3",
|
||||
toolCallId: "tool-group-3",
|
||||
path: "/tmp/workspace/notes.md",
|
||||
displayName: "notes.md",
|
||||
kind: "document",
|
||||
action: "read",
|
||||
sourceToolName: "Read",
|
||||
timestamp: new Date("2026-03-11T12:22:00.000Z"),
|
||||
preview: "笔记",
|
||||
clickable: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const groupedViewButton = document.body.querySelector(
|
||||
'button[aria-label="文件视图:按文件"]',
|
||||
) as HTMLButtonElement | null;
|
||||
|
||||
act(() => {
|
||||
groupedViewButton?.click();
|
||||
});
|
||||
|
||||
const fileSection = document.body.querySelector(
|
||||
'[data-harness-section="files"]',
|
||||
) as HTMLElement | null;
|
||||
const groupedCards = document.body.querySelectorAll(
|
||||
'button[aria-label^="查看聚合文件活动:"]',
|
||||
);
|
||||
|
||||
expect(groupedCards).toHaveLength(2);
|
||||
expect(fileSection?.textContent).toContain("2 个文件 / 3 条");
|
||||
expect(fileSection?.textContent).toContain("draft.md");
|
||||
expect(fileSection?.textContent).toContain("2 次活动");
|
||||
expect(fileSection?.textContent).toContain("写入 1");
|
||||
expect(fileSection?.textContent).toContain("编辑 1");
|
||||
});
|
||||
|
||||
it("应支持按类型筛选工具输出", () => {
|
||||
renderPanel({
|
||||
harnessState: createHarnessState({
|
||||
outputSignals: [
|
||||
{
|
||||
id: "signal-path",
|
||||
toolCallId: "tool-path",
|
||||
toolName: "read_file",
|
||||
title: "读取结果",
|
||||
summary: "返回了输出文件",
|
||||
outputFile: "/tmp/workspace/output.txt",
|
||||
},
|
||||
{
|
||||
id: "signal-offload",
|
||||
toolCallId: "tool-offload",
|
||||
toolName: "write_file",
|
||||
title: "大结果转存",
|
||||
summary: "内容已转存",
|
||||
offloadFile: "/tmp/workspace/offload/result.md",
|
||||
offloaded: true,
|
||||
},
|
||||
{
|
||||
id: "signal-summary",
|
||||
toolCallId: "tool-summary",
|
||||
toolName: "execute",
|
||||
title: "执行摘要",
|
||||
summary: "仅保留摘要",
|
||||
preview: "最后 10 行输出",
|
||||
},
|
||||
{
|
||||
id: "signal-truncated",
|
||||
toolCallId: "tool-truncated",
|
||||
toolName: "execute",
|
||||
title: "截断输出",
|
||||
summary: "输出过长已截断",
|
||||
truncated: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const summaryFilterButton = document.body.querySelector(
|
||||
'button[aria-label="工具输出筛选:仅摘要"]',
|
||||
) as HTMLButtonElement | null;
|
||||
|
||||
act(() => {
|
||||
summaryFilterButton?.click();
|
||||
});
|
||||
|
||||
const outputSection = document.body.querySelector(
|
||||
'[data-harness-section="outputs"]',
|
||||
) as HTMLElement | null;
|
||||
|
||||
expect(outputSection?.textContent).toContain("执行摘要");
|
||||
expect(outputSection?.textContent).not.toContain("读取结果");
|
||||
expect(outputSection?.textContent).not.toContain("大结果转存");
|
||||
expect(outputSection?.textContent).not.toContain("截断输出");
|
||||
expect(outputSection?.textContent).toContain("1 / 4 条");
|
||||
});
|
||||
|
||||
it("预览弹窗应支持复制路径和系统文件操作", async () => {
|
||||
const onLoadFilePreview = vi.fn().mockResolvedValue({
|
||||
path: "/tmp/workspace/draft.md",
|
||||
content: "# 标题\n正文内容",
|
||||
isBinary: false,
|
||||
size: 18,
|
||||
error: null,
|
||||
});
|
||||
const onRevealPath = vi.fn().mockResolvedValue(undefined);
|
||||
const onOpenPath = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
renderPanel({
|
||||
harnessState: createHarnessState({
|
||||
recentFileEvents: [
|
||||
{
|
||||
id: "event-3",
|
||||
toolCallId: "tool-3",
|
||||
path: "/tmp/workspace/draft.md",
|
||||
displayName: "draft.md",
|
||||
kind: "document",
|
||||
action: "read",
|
||||
sourceToolName: "Read",
|
||||
timestamp: new Date("2026-03-11T12:02:00.000Z"),
|
||||
preview: "摘要预览",
|
||||
clickable: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
onLoadFilePreview,
|
||||
onRevealPath,
|
||||
onOpenPath,
|
||||
});
|
||||
|
||||
const trigger = document.body.querySelector(
|
||||
'button[aria-label="查看文件活动:draft.md"]',
|
||||
) as HTMLButtonElement | null;
|
||||
|
||||
await act(async () => {
|
||||
trigger?.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const copyPathButton = Array.from(
|
||||
document.body.querySelectorAll("button"),
|
||||
).find((button) => button.textContent?.includes("复制路径"));
|
||||
const revealButton = Array.from(document.body.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.includes("定位文件"),
|
||||
);
|
||||
const openPathButton = Array.from(
|
||||
document.body.querySelectorAll("button"),
|
||||
).find((button) => button.textContent?.includes("系统打开"));
|
||||
|
||||
await act(async () => {
|
||||
copyPathButton?.dispatchEvent(
|
||||
new MouseEvent("click", { bubbles: true }),
|
||||
);
|
||||
revealButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
openPathButton?.dispatchEvent(
|
||||
new MouseEvent("click", { bubbles: true }),
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
|
||||
"/tmp/workspace/draft.md",
|
||||
);
|
||||
expect(onRevealPath).toHaveBeenCalledWith("/tmp/workspace/draft.md");
|
||||
expect(onOpenPath).toHaveBeenCalledWith("/tmp/workspace/draft.md");
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -20,7 +20,7 @@ import type {
|
||||
ParsedMessageContent,
|
||||
} from "@/components/content-creator/a2ui/types";
|
||||
import { CHAT_A2UI_TASK_CARD_PRESET } from "@/components/content-creator/a2ui/taskCardPresets";
|
||||
import type { ToolCallState } from "@/lib/api/agent";
|
||||
import type { ToolCallState } from "@/lib/api/agentStream";
|
||||
import type { ContentPart, ActionRequired, ConfirmResponse } from "../types";
|
||||
|
||||
const STRUCTURED_CONTENT_HINT_RE = /<a2ui|```\s*a2ui|<write_file|<document/i;
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import React from "react";
|
||||
import styled from "styled-components";
|
||||
import { Coins } from "lucide-react";
|
||||
import type { TokenUsage } from "@/lib/api/agent";
|
||||
import type { TokenUsage } from "@/lib/api/agentStream";
|
||||
|
||||
const UsageContainer = styled.div`
|
||||
display: inline-flex;
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import type { ToolCallState } from "@/lib/api/agent";
|
||||
import type { ToolCallState } from "@/lib/api/agentStream";
|
||||
import { ToolCallDisplay } from "./ToolCallDisplay";
|
||||
|
||||
interface MountedHarness {
|
||||
@@ -37,7 +37,13 @@ function render(toolCall: ToolCallState): HTMLDivElement {
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(<ToolCallDisplay toolCall={toolCall} />);
|
||||
root.render(
|
||||
<ToolCallDisplay
|
||||
toolCall={toolCall}
|
||||
defaultExpanded
|
||||
isMessageStreaming
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
mountedRoots.push({ container, root });
|
||||
@@ -55,7 +61,9 @@ describe("ToolCallDisplay", () => {
|
||||
result: {
|
||||
success: true,
|
||||
output: "图片已生成",
|
||||
images: [{ src: "data:image/png;base64,aGVsbG8=", mimeType: "image/png" }],
|
||||
images: [
|
||||
{ src: "data:image/png;base64,aGVsbG8=", mimeType: "image/png" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -77,7 +85,9 @@ describe("ToolCallDisplay", () => {
|
||||
result: {
|
||||
success: true,
|
||||
output: "图片已生成",
|
||||
images: [{ src: "data:image/png;base64,aGVsbG8=", mimeType: "image/png" }],
|
||||
images: [
|
||||
{ src: "data:image/png;base64,aGVsbG8=", mimeType: "image/png" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -96,4 +106,65 @@ describe("ToolCallDisplay", () => {
|
||||
) as HTMLImageElement | null;
|
||||
expect(enlargedImage).not.toBeNull();
|
||||
});
|
||||
|
||||
it("工具结果包含 metadata 时应渲染执行摘要", () => {
|
||||
const toolCall: ToolCallState = {
|
||||
id: "tool-meta-1",
|
||||
name: "Bash",
|
||||
status: "failed",
|
||||
startTime: new Date(),
|
||||
endTime: new Date(),
|
||||
result: {
|
||||
success: false,
|
||||
output: "命令执行失败",
|
||||
metadata: {
|
||||
exit_code: 1,
|
||||
stdout_length: 120,
|
||||
stderr_length: 32,
|
||||
sandboxed: true,
|
||||
output_file: "/tmp/aster_tasks/task-1.log",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const container = render(toolCall);
|
||||
expect(container.textContent).toContain("退出码 1");
|
||||
expect(container.textContent).toContain("stdout 120");
|
||||
expect(container.textContent).toContain("已隔离执行");
|
||||
expect(container.textContent).toContain(
|
||||
"输出文件: /tmp/aster_tasks/task-1.log",
|
||||
);
|
||||
});
|
||||
|
||||
it("工具结果完成 offload 转存时应显示转存摘要与文件路径", () => {
|
||||
const toolCall: ToolCallState = {
|
||||
id: "tool-offload-1",
|
||||
name: "Write",
|
||||
status: "completed",
|
||||
startTime: new Date(),
|
||||
endTime: new Date(),
|
||||
result: {
|
||||
success: true,
|
||||
output:
|
||||
"preview line\n\n[ProxyCast Offload] 完整输出已转存到文件:/tmp/proxycast/harness/tool-io/results/tool-offload-1.json",
|
||||
metadata: {
|
||||
proxycast_offloaded: true,
|
||||
offload_file:
|
||||
"/tmp/proxycast/harness/tool-io/results/tool-offload-1.json",
|
||||
offload_original_chars: 18234,
|
||||
offload_original_tokens: 4521,
|
||||
offload_trigger: "token_limit_before_evict",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const container = render(toolCall);
|
||||
expect(container.textContent).toContain("完整输出已转存");
|
||||
expect(container.textContent).toContain("原始 18234 字符");
|
||||
expect(container.textContent).toContain("约 4521 tokens");
|
||||
expect(container.textContent).toContain("token 阈值触发");
|
||||
expect(container.textContent).toContain(
|
||||
"转存文件: /tmp/proxycast/harness/tool-io/results/tool-offload-1.json",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
ExternalLink,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ToolCallState, ToolResultImage } from "@/lib/api/agent";
|
||||
import type { ToolCallState, ToolResultImage } from "@/lib/api/agentStream";
|
||||
import { MarkdownRenderer } from "./MarkdownRenderer";
|
||||
|
||||
// ============ 类型定义 ============
|
||||
@@ -283,9 +283,7 @@ const snakeToTitleCase = (str: string): string => {
|
||||
.join(" ");
|
||||
};
|
||||
|
||||
const normalizeToolResultImages = (
|
||||
rawImages: unknown,
|
||||
): ToolResultImage[] => {
|
||||
const normalizeToolResultImages = (rawImages: unknown): ToolResultImage[] => {
|
||||
if (!Array.isArray(rawImages)) return [];
|
||||
const normalized: ToolResultImage[] = [];
|
||||
for (const item of rawImages) {
|
||||
@@ -308,6 +306,19 @@ const normalizeToolResultImages = (
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const normalizeToolResultMetadata = (
|
||||
rawMetadata: unknown,
|
||||
): Record<string, unknown> | undefined => {
|
||||
if (
|
||||
!rawMetadata ||
|
||||
typeof rawMetadata !== "object" ||
|
||||
Array.isArray(rawMetadata)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return Object.fromEntries(Object.entries(rawMetadata));
|
||||
};
|
||||
|
||||
// ============ 可展开面板组件 ============
|
||||
|
||||
interface ExpandablePanelProps {
|
||||
@@ -610,6 +621,82 @@ export const ToolCallDisplay: React.FC<ToolCallDisplayProps> = ({
|
||||
() => normalizeToolResultImages(toolCall.result?.images),
|
||||
[toolCall.result?.images],
|
||||
);
|
||||
const resultMetadata = useMemo(
|
||||
() => normalizeToolResultMetadata(toolCall.result?.metadata),
|
||||
[toolCall.result?.metadata],
|
||||
);
|
||||
const resultMetaItems = useMemo(() => {
|
||||
if (!resultMetadata) return [];
|
||||
|
||||
const items: string[] = [];
|
||||
if (resultMetadata.proxycast_offloaded === true) {
|
||||
items.push("完整输出已转存");
|
||||
}
|
||||
if (typeof resultMetadata.exit_code === "number") {
|
||||
items.push(`退出码 ${resultMetadata.exit_code}`);
|
||||
}
|
||||
if (typeof resultMetadata.stdout_length === "number") {
|
||||
items.push(`stdout ${resultMetadata.stdout_length}`);
|
||||
}
|
||||
if (typeof resultMetadata.stderr_length === "number") {
|
||||
items.push(`stderr ${resultMetadata.stderr_length}`);
|
||||
}
|
||||
if (typeof resultMetadata.sandboxed === "boolean") {
|
||||
items.push(resultMetadata.sandboxed ? "已隔离执行" : "普通执行");
|
||||
}
|
||||
if (resultMetadata.output_truncated === true) {
|
||||
items.push("输出已截断");
|
||||
}
|
||||
if (typeof resultMetadata.offload_original_chars === "number") {
|
||||
items.push(`原始 ${resultMetadata.offload_original_chars} 字符`);
|
||||
}
|
||||
if (typeof resultMetadata.offload_original_tokens === "number") {
|
||||
items.push(`约 ${resultMetadata.offload_original_tokens} tokens`);
|
||||
}
|
||||
if (typeof resultMetadata.offload_trigger === "string") {
|
||||
const triggerLabel =
|
||||
resultMetadata.offload_trigger === "history_context_pressure"
|
||||
? "上下文压力触发"
|
||||
: resultMetadata.offload_trigger === "token_limit_before_evict"
|
||||
? "token 阈值触发"
|
||||
: resultMetadata.offload_trigger === "payload_bytes"
|
||||
? "字节阈值触发"
|
||||
: resultMetadata.offload_trigger === "payload_chars"
|
||||
? "字符阈值触发"
|
||||
: resultMetadata.offload_trigger;
|
||||
items.push(triggerLabel);
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [resultMetadata]);
|
||||
const resultPath = useMemo(() => {
|
||||
if (!resultMetadata) return undefined;
|
||||
if (
|
||||
typeof resultMetadata.offload_file === "string" &&
|
||||
resultMetadata.offload_file.trim()
|
||||
) {
|
||||
return {
|
||||
label: "转存文件",
|
||||
value: resultMetadata.offload_file.trim(),
|
||||
};
|
||||
}
|
||||
if (
|
||||
typeof resultMetadata.output_file === "string" &&
|
||||
resultMetadata.output_file.trim()
|
||||
) {
|
||||
return {
|
||||
label: "输出文件",
|
||||
value: resultMetadata.output_file.trim(),
|
||||
};
|
||||
}
|
||||
if (typeof resultMetadata.path === "string" && resultMetadata.path.trim()) {
|
||||
return {
|
||||
label: "产物路径",
|
||||
value: resultMetadata.path.trim(),
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}, [resultMetadata]);
|
||||
const hasResultImages = resultImages.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -735,6 +822,23 @@ export const ToolCallDisplay: React.FC<ToolCallDisplayProps> = ({
|
||||
>
|
||||
Output
|
||||
</div>
|
||||
{resultMetaItems.length > 0 ? (
|
||||
<div className="mb-2 flex flex-wrap gap-2">
|
||||
{resultMetaItems.map((item) => (
|
||||
<span
|
||||
key={item}
|
||||
className="rounded-full bg-[var(--surface-secondary)] px-2 py-1 text-[11px] text-[var(--ink-600)]"
|
||||
>
|
||||
{item}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{resultPath ? (
|
||||
<div className="mb-2 break-all text-[11px] text-[var(--ink-600)]">
|
||||
{resultPath.label}: {resultPath.value}
|
||||
</div>
|
||||
) : null}
|
||||
<pre
|
||||
className={cn(
|
||||
"whitespace-pre-wrap font-mono text-xs break-all max-h-40 overflow-y-auto",
|
||||
|
||||
@@ -4,19 +4,23 @@ import type { UnlistenFn } from "@tauri-apps/api/event";
|
||||
import type { Message } from "../types";
|
||||
import { tryExecuteSlashSkillCommand } from "./skillCommand";
|
||||
|
||||
const { mockSafeListen, mockParseStreamEvent, mockListExecutableSkills, mockExecuteSkill } =
|
||||
vi.hoisted(() => ({
|
||||
mockSafeListen: vi.fn(),
|
||||
mockParseStreamEvent: vi.fn((payload: unknown) => payload),
|
||||
mockListExecutableSkills: vi.fn(),
|
||||
mockExecuteSkill: vi.fn(),
|
||||
}));
|
||||
const {
|
||||
mockSafeListen,
|
||||
mockParseStreamEvent,
|
||||
mockListExecutableSkills,
|
||||
mockExecuteSkill,
|
||||
} = vi.hoisted(() => ({
|
||||
mockSafeListen: vi.fn(),
|
||||
mockParseStreamEvent: vi.fn((payload: unknown) => payload),
|
||||
mockListExecutableSkills: vi.fn(),
|
||||
mockExecuteSkill: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/dev-bridge", () => ({
|
||||
safeListen: mockSafeListen,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/agent", () => ({
|
||||
vi.mock("@/lib/api/agentStream", () => ({
|
||||
parseStreamEvent: mockParseStreamEvent,
|
||||
}));
|
||||
|
||||
@@ -84,7 +88,11 @@ describe("tryExecuteSlashSkillCommand 社媒主链路", () => {
|
||||
});
|
||||
|
||||
mockExecuteSkill.mockImplementation(async () => {
|
||||
const emitWriteToolStart = (toolId: string, path: string, content: string) => {
|
||||
const emitWriteToolStart = (
|
||||
toolId: string,
|
||||
path: string,
|
||||
content: string,
|
||||
) => {
|
||||
streamHandler?.({
|
||||
payload: {
|
||||
type: "tool_start",
|
||||
@@ -106,12 +114,12 @@ describe("tryExecuteSlashSkillCommand 社媒主链路", () => {
|
||||
emitWriteToolStart(
|
||||
"tool-cover",
|
||||
"social-posts/demo.cover.json",
|
||||
"{\"cover_url\":\"https://example.com/cover.png\",\"status\":\"成功\"}",
|
||||
'{"cover_url":"https://example.com/cover.png","status":"成功"}',
|
||||
);
|
||||
emitWriteToolStart(
|
||||
"tool-pack",
|
||||
"social-posts/demo.publish-pack.json",
|
||||
"{\"article_path\":\"social-posts/demo.md\",\"cover_meta_path\":\"social-posts/demo.cover.json\"}",
|
||||
'{"article_path":"social-posts/demo.md","cover_meta_path":"social-posts/demo.cover.json"}',
|
||||
);
|
||||
streamHandler?.({ payload: { type: "final_done" } });
|
||||
|
||||
@@ -241,7 +249,9 @@ describe("tryExecuteSlashSkillCommand 社媒主链路", () => {
|
||||
expect(onWriteFile).toHaveBeenCalledTimes(1);
|
||||
const [contentArg, filePathArg] = onWriteFile.mock.calls[0];
|
||||
expect(contentArg).toBe("# 标题\n\n正文内容");
|
||||
expect(filePathArg).toMatch(/^social-posts\/\d{8}-\d{6}-[a-z0-9-]+-[a-z0-9]{3,6}\.md$/);
|
||||
expect(filePathArg).toMatch(
|
||||
/^social-posts\/\d{8}-\d{6}-[a-z0-9-]+-[a-z0-9]{3,6}\.md$/,
|
||||
);
|
||||
});
|
||||
|
||||
it("非社媒技能在无 write_file 时不应触发兜底写入", async () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import type { UnlistenFn } from "@tauri-apps/api/event";
|
||||
import { safeListen } from "@/lib/dev-bridge";
|
||||
import { parseStreamEvent, type StreamEvent } from "@/lib/api/agent";
|
||||
import { parseStreamEvent, type StreamEvent } from "@/lib/api/agentStream";
|
||||
import {
|
||||
skillExecutionApi,
|
||||
type ExecutableSkillInfo,
|
||||
@@ -62,12 +62,16 @@ function buildSocialPostSlug(seed: string): string {
|
||||
return normalized || "post";
|
||||
}
|
||||
|
||||
function buildSocialPostFallbackPath(seed: string, assistantMsgId: string): string {
|
||||
function buildSocialPostFallbackPath(
|
||||
seed: string,
|
||||
assistantMsgId: string,
|
||||
): string {
|
||||
const now = new Date();
|
||||
const format2 = (value: number) => String(value).padStart(2, "0");
|
||||
const timestamp = `${now.getFullYear()}${format2(now.getMonth() + 1)}${format2(now.getDate())}-${format2(now.getHours())}${format2(now.getMinutes())}${format2(now.getSeconds())}`;
|
||||
const slug = buildSocialPostSlug(seed);
|
||||
const suffix = assistantMsgId.replace(/[^a-zA-Z0-9]/g, "").slice(0, 6) || "run";
|
||||
const suffix =
|
||||
assistantMsgId.replace(/[^a-zA-Z0-9]/g, "").slice(0, 6) || "run";
|
||||
return `social-posts/${timestamp}-${slug}-${suffix.toLowerCase()}.md`;
|
||||
}
|
||||
|
||||
@@ -610,8 +614,8 @@ ${failureText}`
|
||||
: shouldForceResultOutput
|
||||
? result.output || "Skill 执行完成"
|
||||
: hasStreamedContent
|
||||
? accumulatedContent
|
||||
: result.output || "Skill 执行完成";
|
||||
? accumulatedContent
|
||||
: result.output || "Skill 执行完成";
|
||||
|
||||
if (failure) {
|
||||
console.warn(
|
||||
|
||||
@@ -7,7 +7,7 @@ const {
|
||||
mockStopAgentProcess,
|
||||
mockGetAgentProcessStatus,
|
||||
mockCreateAgentSession,
|
||||
mockSendAgentMessageStream,
|
||||
mockSendAsterMessageStream,
|
||||
mockListAgentSessions,
|
||||
mockDeleteAgentSession,
|
||||
mockGetAgentSessionMessages,
|
||||
@@ -24,7 +24,7 @@ const {
|
||||
mockStopAgentProcess: vi.fn(),
|
||||
mockGetAgentProcessStatus: vi.fn(),
|
||||
mockCreateAgentSession: vi.fn(),
|
||||
mockSendAgentMessageStream: vi.fn(),
|
||||
mockSendAsterMessageStream: vi.fn(),
|
||||
mockListAgentSessions: vi.fn(),
|
||||
mockDeleteAgentSession: vi.fn(),
|
||||
mockGetAgentSessionMessages: vi.fn(),
|
||||
@@ -38,23 +38,26 @@ const {
|
||||
mockGetProviderConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/agent", () => ({
|
||||
vi.mock("@/lib/api/agentRuntime", () => ({
|
||||
startAgentProcess: mockStartAgentProcess,
|
||||
stopAgentProcess: mockStopAgentProcess,
|
||||
getAgentProcessStatus: mockGetAgentProcessStatus,
|
||||
createAgentSession: mockCreateAgentSession,
|
||||
sendAgentMessageStream: mockSendAgentMessageStream,
|
||||
sendAsterMessageStream: mockSendAsterMessageStream,
|
||||
listAgentSessions: mockListAgentSessions,
|
||||
deleteAgentSession: mockDeleteAgentSession,
|
||||
getAgentSessionMessages: mockGetAgentSessionMessages,
|
||||
renameAgentSession: mockRenameAgentSession,
|
||||
generateAgentTitle: mockGenerateAgentTitle,
|
||||
parseStreamEvent: mockParseStreamEvent,
|
||||
confirmAsterAction: mockConfirmAsterAction,
|
||||
submitAsterElicitationResponse: mockSubmitAsterElicitationResponse,
|
||||
stopAsterSession: mockStopAsterSession,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/agentStream", () => ({
|
||||
parseStreamEvent: mockParseStreamEvent,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/dev-bridge", () => ({
|
||||
safeListen: mockSafeListen,
|
||||
}));
|
||||
@@ -151,7 +154,7 @@ beforeEach(() => {
|
||||
mockStopAgentProcess.mockResolvedValue(undefined);
|
||||
mockGetAgentProcessStatus.mockResolvedValue({ running: false });
|
||||
mockCreateAgentSession.mockResolvedValue({ session_id: "session-created" });
|
||||
mockSendAgentMessageStream.mockResolvedValue(undefined);
|
||||
mockSendAsterMessageStream.mockResolvedValue(undefined);
|
||||
mockListAgentSessions.mockResolvedValue([]);
|
||||
mockDeleteAgentSession.mockResolvedValue(undefined);
|
||||
mockGetAgentSessionMessages.mockResolvedValue([]);
|
||||
@@ -201,8 +204,7 @@ describe("useAgentChat 偏好持久化", () => {
|
||||
).toBe("gemini-2.5-pro");
|
||||
expect(
|
||||
JSON.parse(
|
||||
localStorage.getItem(`agent_pref_migrated_${workspaceId}`) ||
|
||||
"false",
|
||||
localStorage.getItem(`agent_pref_migrated_${workspaceId}`) || "false",
|
||||
),
|
||||
).toBe(true);
|
||||
} finally {
|
||||
@@ -255,12 +257,14 @@ describe("useAgentChat 偏好持久化", () => {
|
||||
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",
|
||||
);
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -7,21 +7,20 @@ import {
|
||||
stopAgentProcess,
|
||||
getAgentProcessStatus,
|
||||
createAgentSession,
|
||||
sendAgentMessageStream,
|
||||
sendAsterMessageStream,
|
||||
listAgentSessions,
|
||||
deleteAgentSession,
|
||||
getAgentSessionMessages,
|
||||
renameAgentSession,
|
||||
generateAgentTitle,
|
||||
parseStreamEvent,
|
||||
confirmAsterAction,
|
||||
submitAsterElicitationResponse,
|
||||
stopAsterSession,
|
||||
type AgentProcessStatus,
|
||||
type SessionInfo,
|
||||
type SkillInfo,
|
||||
type StreamEvent,
|
||||
} from "@/lib/api/agent";
|
||||
} from "@/lib/api/agentRuntime";
|
||||
import { parseStreamEvent, type StreamEvent } from "@/lib/api/agentStream";
|
||||
import { skillsApi } from "@/lib/api/skills";
|
||||
import { A2UIFormAPI } from "@/lib/api/a2uiForm";
|
||||
import type { A2UIFormData } from "@/components/content-creator/a2ui/types";
|
||||
@@ -788,14 +787,22 @@ export function useAgentChat(options: UseAgentChatOptions) {
|
||||
currentStreamingSessionIdRef.current = activeSessionId;
|
||||
|
||||
try {
|
||||
await sendAgentMessageStream(
|
||||
await sendAsterMessageStream(
|
||||
message,
|
||||
activeSessionId,
|
||||
eventName,
|
||||
resolvedWorkspaceId,
|
||||
activeSessionId,
|
||||
modelName,
|
||||
images,
|
||||
providerType,
|
||||
providerType
|
||||
? {
|
||||
provider_id: providerType,
|
||||
provider_name: providerType,
|
||||
model_name: modelName || "claude-sonnet-4-20250514",
|
||||
}
|
||||
: undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
projectId,
|
||||
);
|
||||
@@ -823,14 +830,22 @@ export function useAgentChat(options: UseAgentChatOptions) {
|
||||
freshSessionId,
|
||||
workspaceId: resolvedWorkspaceId,
|
||||
});
|
||||
await sendAgentMessageStream(
|
||||
await sendAsterMessageStream(
|
||||
message,
|
||||
freshSessionId,
|
||||
eventName,
|
||||
resolvedWorkspaceId,
|
||||
freshSessionId,
|
||||
modelName,
|
||||
images,
|
||||
providerType,
|
||||
providerType
|
||||
? {
|
||||
provider_id: providerType,
|
||||
provider_name: providerType,
|
||||
model_name: modelName || "claude-sonnet-4-20250514",
|
||||
}
|
||||
: undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
projectId,
|
||||
);
|
||||
|
||||
@@ -37,11 +37,13 @@ const {
|
||||
info: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
},
|
||||
mockParseSkillSlashCommand: vi.fn((): { skillName: string; userInput: string } | null => null),
|
||||
mockParseSkillSlashCommand: vi.fn(
|
||||
(): { skillName: string; userInput: string } | null => null,
|
||||
),
|
||||
mockTryExecuteSlashSkillCommand: vi.fn(async () => false),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/agent", () => ({
|
||||
vi.mock("@/lib/api/agentRuntime", () => ({
|
||||
initAsterAgent: mockInitAsterAgent,
|
||||
sendAsterMessageStream: mockSendAsterMessageStream,
|
||||
createAsterSession: mockCreateAsterSession,
|
||||
@@ -52,6 +54,9 @@ vi.mock("@/lib/api/agent", () => ({
|
||||
stopAsterSession: mockStopAsterSession,
|
||||
confirmAsterAction: mockConfirmAsterAction,
|
||||
submitAsterElicitationResponse: mockSubmitAsterElicitationResponse,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/agentStream", () => ({
|
||||
parseStreamEvent: mockParseStreamEvent,
|
||||
}));
|
||||
|
||||
@@ -182,9 +187,15 @@ describe("useAsterAgentChat 首页新会话", () => {
|
||||
|
||||
expect(harness.getValue().sessionId).toBeNull();
|
||||
expect(harness.getValue().messages).toEqual([]);
|
||||
expect(sessionStorage.getItem(`aster_curr_sessionId_${workspaceId}`)).toBe("null");
|
||||
expect(sessionStorage.getItem(`aster_messages_${workspaceId}`)).toBe("[]");
|
||||
expect(localStorage.getItem(`aster_last_sessionId_${workspaceId}`)).toBe("null");
|
||||
expect(
|
||||
sessionStorage.getItem(`aster_curr_sessionId_${workspaceId}`),
|
||||
).toBe("null");
|
||||
expect(sessionStorage.getItem(`aster_messages_${workspaceId}`)).toBe(
|
||||
"[]",
|
||||
);
|
||||
expect(localStorage.getItem(`aster_last_sessionId_${workspaceId}`)).toBe(
|
||||
"null",
|
||||
);
|
||||
} finally {
|
||||
harness.unmount();
|
||||
}
|
||||
@@ -300,14 +311,16 @@ describe("useAsterAgentChat slash skill 执行链路", () => {
|
||||
try {
|
||||
await flushEffects();
|
||||
await act(async () => {
|
||||
await harness.getValue().sendMessage(
|
||||
"/social_post_with_cover 写一篇春季新品文案",
|
||||
[],
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
"react",
|
||||
);
|
||||
await harness
|
||||
.getValue()
|
||||
.sendMessage(
|
||||
"/social_post_with_cover 写一篇春季新品文案",
|
||||
[],
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
"react",
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockParseSkillSlashCommand).toHaveBeenCalledWith(
|
||||
@@ -333,14 +346,16 @@ describe("useAsterAgentChat slash skill 执行链路", () => {
|
||||
try {
|
||||
await flushEffects();
|
||||
await act(async () => {
|
||||
await harness.getValue().sendMessage(
|
||||
"/social_post_with_cover 写一篇春季新品文案",
|
||||
[],
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
"react",
|
||||
);
|
||||
await harness
|
||||
.getValue()
|
||||
.sendMessage(
|
||||
"/social_post_with_cover 写一篇春季新品文案",
|
||||
[],
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
"react",
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockTryExecuteSlashSkillCommand).toHaveBeenCalledTimes(1);
|
||||
@@ -801,6 +816,84 @@ describe("useAsterAgentChat action_required 渲染链路", () => {
|
||||
harness.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("收到带 ProxyCast 元数据块的 tool_end 后应清洗输出并恢复失败态 metadata", async () => {
|
||||
const workspaceId = "ws-tool-metadata-block";
|
||||
seedSession(workspaceId, "session-tool-metadata-block");
|
||||
const harness = mountHook(workspaceId);
|
||||
|
||||
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, false, "react");
|
||||
});
|
||||
|
||||
act(() => {
|
||||
streamHandler?.({
|
||||
payload: {
|
||||
type: "tool_start",
|
||||
tool_id: "tool-meta-1",
|
||||
tool_name: "SubAgentTask",
|
||||
arguments: JSON.stringify({
|
||||
prompt: "检查 harness 缺口",
|
||||
}),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
act(() => {
|
||||
streamHandler?.({
|
||||
payload: {
|
||||
type: "tool_end",
|
||||
tool_id: "tool-meta-1",
|
||||
result: {
|
||||
success: true,
|
||||
output: [
|
||||
"子任务执行失败,需要人工接管",
|
||||
"",
|
||||
"[ProxyCast 工具元数据开始]",
|
||||
JSON.stringify({
|
||||
reported_success: false,
|
||||
role: "planner",
|
||||
failed_count: 1,
|
||||
}),
|
||||
"[ProxyCast 工具元数据结束]",
|
||||
].join("\n"),
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const assistantMessage = [...harness.getValue().messages]
|
||||
.reverse()
|
||||
.find((msg) => msg.role === "assistant");
|
||||
const toolCall = assistantMessage?.toolCalls?.find(
|
||||
(item) => item.id === "tool-meta-1",
|
||||
);
|
||||
|
||||
expect(toolCall?.status).toBe("failed");
|
||||
expect(toolCall?.result?.output).toBe("子任务执行失败,需要人工接管");
|
||||
expect(toolCall?.result?.output).not.toContain("ProxyCast 工具元数据");
|
||||
expect(toolCall?.result?.metadata).toMatchObject({
|
||||
reported_success: false,
|
||||
role: "planner",
|
||||
failed_count: 1,
|
||||
});
|
||||
} finally {
|
||||
harness.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("useAsterAgentChat 偏好持久化", () => {
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { safeListen } from "@/lib/dev-bridge";
|
||||
import type { UnlistenFn } from "@tauri-apps/api/event";
|
||||
import {
|
||||
@@ -22,14 +21,17 @@ import {
|
||||
stopAsterSession,
|
||||
confirmAsterAction,
|
||||
submitAsterElicitationResponse,
|
||||
parseStreamEvent,
|
||||
type StreamEvent,
|
||||
type ContextTraceStep,
|
||||
type AsterSessionInfo,
|
||||
type AsterExecutionStrategy,
|
||||
type AutoContinueRequestPayload,
|
||||
} from "@/lib/api/agentRuntime";
|
||||
import { updateProject } from "@/lib/api/project";
|
||||
import {
|
||||
parseStreamEvent,
|
||||
type StreamEvent,
|
||||
type ContextTraceStep,
|
||||
type ToolResultImage,
|
||||
} from "@/lib/api/agent";
|
||||
} from "@/lib/api/agentStream";
|
||||
import {
|
||||
isAsterSessionNotFoundError,
|
||||
resolveRestorableSessionId,
|
||||
@@ -98,6 +100,8 @@ const normalizeActionType = (
|
||||
};
|
||||
|
||||
const WORKSPACE_PATH_AUTO_CREATED_WARNING_CODE = "workspace_path_auto_created";
|
||||
const PROXYCAST_TOOL_METADATA_BEGIN = "[ProxyCast 工具元数据开始]";
|
||||
const PROXYCAST_TOOL_METADATA_END = "[ProxyCast 工具元数据结束]";
|
||||
|
||||
const isWorkspacePathErrorMessage = (message: string): boolean => {
|
||||
return (
|
||||
@@ -488,6 +492,126 @@ const normalizeToolResultImages = (
|
||||
return normalized.length > 0 ? normalized : undefined;
|
||||
};
|
||||
|
||||
const parseToolResultMetadataRecord = (
|
||||
value: unknown,
|
||||
): Record<string, unknown> | null => {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
return Object.fromEntries(Object.entries(value));
|
||||
};
|
||||
|
||||
const extractProxycastToolMetadataBlock = (
|
||||
text?: string,
|
||||
): { text: string; metadata?: Record<string, unknown> } => {
|
||||
if (!text) {
|
||||
return { text: "" };
|
||||
}
|
||||
|
||||
const beginIndex = text.lastIndexOf(PROXYCAST_TOOL_METADATA_BEGIN);
|
||||
const endIndex = text.lastIndexOf(PROXYCAST_TOOL_METADATA_END);
|
||||
if (beginIndex < 0 || endIndex < beginIndex) {
|
||||
return { text };
|
||||
}
|
||||
|
||||
const metadataRaw = text
|
||||
.slice(beginIndex + PROXYCAST_TOOL_METADATA_BEGIN.length, endIndex)
|
||||
.trim();
|
||||
const parsedMetadata = (() => {
|
||||
if (!metadataRaw) return undefined;
|
||||
try {
|
||||
const parsed = JSON.parse(metadataRaw);
|
||||
return parseToolResultMetadataRecord(parsed) || undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
})();
|
||||
|
||||
const cleaned = text.slice(0, beginIndex).replace(/\s+$/, "");
|
||||
return {
|
||||
text: cleaned,
|
||||
metadata: parsedMetadata,
|
||||
};
|
||||
};
|
||||
|
||||
const parseProxycastExecutionSummary = (
|
||||
text?: string,
|
||||
): Record<string, unknown> | undefined => {
|
||||
if (!text) return undefined;
|
||||
const marker = "[ProxyCast 执行摘要]";
|
||||
const markerIndex = text.lastIndexOf(marker);
|
||||
if (markerIndex < 0) return undefined;
|
||||
|
||||
const raw = text.slice(markerIndex + marker.length).trim();
|
||||
if (!raw) return undefined;
|
||||
|
||||
const metadata: Record<string, unknown> = {};
|
||||
for (const line of raw.split("\n")) {
|
||||
const separatorIndex = line.indexOf(":");
|
||||
if (separatorIndex <= 0) continue;
|
||||
const key = line.slice(0, separatorIndex).trim();
|
||||
const rawValue = line.slice(separatorIndex + 1).trim();
|
||||
if (!key || !rawValue) continue;
|
||||
|
||||
if (rawValue === "true" || rawValue === "false") {
|
||||
metadata[key] = rawValue === "true";
|
||||
continue;
|
||||
}
|
||||
|
||||
const numericValue = Number(rawValue);
|
||||
if (!Number.isNaN(numericValue) && rawValue === String(numericValue)) {
|
||||
metadata[key] = numericValue;
|
||||
continue;
|
||||
}
|
||||
|
||||
metadata[key] = rawValue;
|
||||
}
|
||||
|
||||
return Object.keys(metadata).length > 0 ? metadata : undefined;
|
||||
};
|
||||
|
||||
const normalizeToolResultMetadata = (
|
||||
value: unknown,
|
||||
fallbackText?: string,
|
||||
): Record<string, unknown> | undefined => {
|
||||
const direct = parseToolResultMetadataRecord(value);
|
||||
const fromBlock = extractProxycastToolMetadataBlock(fallbackText).metadata;
|
||||
const fromSummary = parseProxycastExecutionSummary(fallbackText);
|
||||
|
||||
if (!direct && !fromBlock && !fromSummary) return undefined;
|
||||
return {
|
||||
...(direct || {}),
|
||||
...(fromBlock || {}),
|
||||
...(fromSummary || {}),
|
||||
};
|
||||
};
|
||||
|
||||
const isToolResultSuccessful = (
|
||||
result:
|
||||
| {
|
||||
success?: boolean;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
| null
|
||||
| undefined,
|
||||
): boolean => {
|
||||
if (!result) return false;
|
||||
|
||||
const metadata = result.metadata;
|
||||
if (metadata?.reported_success === false) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
typeof metadata?.exit_code === "number" &&
|
||||
Number.isFinite(metadata.exit_code) &&
|
||||
metadata.exit_code !== 0
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return result.success !== false;
|
||||
};
|
||||
|
||||
const normalizeHistoryPartType = (value: unknown): string => {
|
||||
if (typeof value !== "string") return "";
|
||||
return value
|
||||
@@ -1496,8 +1620,6 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) {
|
||||
);
|
||||
|
||||
const now = new Date();
|
||||
setMessages([]);
|
||||
setPendingActions([]);
|
||||
setSessionId(newSessionId);
|
||||
setTopics((prev) => [
|
||||
{
|
||||
@@ -1889,7 +2011,22 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) {
|
||||
}
|
||||
|
||||
case "tool_end": {
|
||||
const isSuccess = data.result.success;
|
||||
const normalizedOutput = extractProxycastToolMetadataBlock(
|
||||
data.result?.output,
|
||||
);
|
||||
const normalizedResult = {
|
||||
...data.result,
|
||||
output: normalizedOutput.text,
|
||||
images: normalizeToolResultImages(
|
||||
data.result?.images,
|
||||
normalizedOutput.text,
|
||||
),
|
||||
metadata: normalizeToolResultMetadata(
|
||||
data.result?.metadata,
|
||||
data.result?.output,
|
||||
),
|
||||
};
|
||||
const isSuccess = isToolResultSuccessful(normalizedResult);
|
||||
const eventType = isSuccess ? "tool_complete" : "tool_error";
|
||||
const startedAt = toolStartedAtByToolId.get(data.tool_id);
|
||||
const toolName = toolNameByToolId.get(data.tool_id) || "未知工具";
|
||||
@@ -1898,10 +2035,9 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) {
|
||||
? Date.now() - startedAt
|
||||
: undefined;
|
||||
const toolLogId = toolLogIdByToolId.get(data.tool_id);
|
||||
const outputText =
|
||||
typeof data.result?.output === "string"
|
||||
? truncateForLog(data.result.output, 120)
|
||||
: "";
|
||||
const outputText = normalizedResult.output
|
||||
? truncateForLog(normalizedResult.output, 120)
|
||||
: "";
|
||||
|
||||
if (toolLogId) {
|
||||
activityLogger.updateLog(toolLogId, {
|
||||
@@ -1932,18 +2068,11 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) {
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) => {
|
||||
if (msg.id !== assistantMsgId) return msg;
|
||||
const normalizedResult = {
|
||||
...data.result,
|
||||
images: normalizeToolResultImages(
|
||||
data.result?.images,
|
||||
data.result?.output,
|
||||
),
|
||||
};
|
||||
const updatedToolCalls = (msg.toolCalls || []).map((tc) =>
|
||||
tc.id === data.tool_id
|
||||
? {
|
||||
...tc,
|
||||
status: data.result.success
|
||||
status: isSuccess
|
||||
? ("completed" as const)
|
||||
: ("failed" as const),
|
||||
result: normalizedResult,
|
||||
@@ -1961,7 +2090,7 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) {
|
||||
...part,
|
||||
toolCall: {
|
||||
...part.toolCall,
|
||||
status: data.result.success
|
||||
status: isSuccess
|
||||
? ("completed" as const)
|
||||
: ("failed" as const),
|
||||
result: normalizedResult,
|
||||
@@ -2280,10 +2409,7 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) {
|
||||
workspacePathMissing;
|
||||
setWorkspacePathMissing(null);
|
||||
try {
|
||||
await invoke("workspace_update", {
|
||||
id: workspaceId,
|
||||
request: { rootPath: newPath },
|
||||
});
|
||||
await updateProject(workspaceId, { rootPath: newPath });
|
||||
await sendMessage(retryContent, retryImages, false, false, true);
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
@@ -2624,13 +2750,29 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) {
|
||||
|
||||
if (partType === "tool_response") {
|
||||
if (!part.id || typeof part.id !== "string") continue;
|
||||
const success = part.success !== false;
|
||||
const toolName = resolveHistoryToolName(
|
||||
part.id,
|
||||
historyToolNameById,
|
||||
);
|
||||
const outputText =
|
||||
const rawOutputText =
|
||||
typeof part.output === "string" ? part.output : "";
|
||||
const normalizedOutput =
|
||||
extractProxycastToolMetadataBlock(rawOutputText);
|
||||
const normalizedResult = {
|
||||
success: part.success !== false,
|
||||
output: normalizedOutput.text,
|
||||
error:
|
||||
typeof part.error === "string" ? part.error : undefined,
|
||||
images: normalizeToolResultImages(
|
||||
part.images,
|
||||
normalizedOutput.text,
|
||||
),
|
||||
metadata: normalizeToolResultMetadata(
|
||||
part.metadata,
|
||||
rawOutputText,
|
||||
),
|
||||
};
|
||||
const success = isToolResultSuccessful(normalizedResult);
|
||||
const toolCall = {
|
||||
id: part.id,
|
||||
name: toolName,
|
||||
@@ -2640,11 +2782,8 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) {
|
||||
startTime: messageTimestamp,
|
||||
endTime: messageTimestamp,
|
||||
result: {
|
||||
...normalizedResult,
|
||||
success,
|
||||
output: outputText,
|
||||
error:
|
||||
typeof part.error === "string" ? part.error : undefined,
|
||||
images: normalizeToolResultImages(part.images, outputText),
|
||||
},
|
||||
};
|
||||
toolCalls.push(toolCall);
|
||||
|
||||
@@ -11,6 +11,7 @@ const {
|
||||
mockGetOrCreateDefaultProject,
|
||||
mockGetContent,
|
||||
mockGetThemeWorkbenchDocumentState,
|
||||
mockEnsureWorkspaceReady,
|
||||
mockUpdateContent,
|
||||
mockGetProjectMemory,
|
||||
mockToast,
|
||||
@@ -34,6 +35,7 @@ const {
|
||||
mockGetOrCreateDefaultProject: vi.fn(),
|
||||
mockGetContent: vi.fn(),
|
||||
mockGetThemeWorkbenchDocumentState: vi.fn(),
|
||||
mockEnsureWorkspaceReady: vi.fn(),
|
||||
mockUpdateContent: vi.fn(),
|
||||
mockGetProjectMemory: vi.fn(),
|
||||
mockToast: {
|
||||
@@ -240,8 +242,14 @@ vi.mock("@/components/content-creator/canvas/CanvasFactory", () => ({
|
||||
CanvasFactory: () => <div data-testid="canvas-factory" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/general-chat/canvas", () => ({
|
||||
vi.mock("@/components/general-chat/bridge", () => ({
|
||||
CanvasPanel: () => <div data-testid="general-canvas" />,
|
||||
DEFAULT_CANVAS_STATE: {
|
||||
isOpen: false,
|
||||
contentType: null,
|
||||
content: "",
|
||||
isEditing: false,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/artifact", () => ({
|
||||
@@ -304,6 +312,7 @@ vi.mock("@/lib/api/project", () => ({
|
||||
getOrCreateDefaultProject: mockGetOrCreateDefaultProject,
|
||||
getContent: mockGetContent,
|
||||
getThemeWorkbenchDocumentState: mockGetThemeWorkbenchDocumentState,
|
||||
ensureWorkspaceReady: mockEnsureWorkspaceReady,
|
||||
updateContent: mockUpdateContent,
|
||||
}));
|
||||
|
||||
@@ -487,6 +496,16 @@ beforeEach(() => {
|
||||
mockGetOrCreateDefaultProject.mockResolvedValue(null);
|
||||
mockGetContent.mockResolvedValue(null);
|
||||
mockGetThemeWorkbenchDocumentState.mockResolvedValue(null);
|
||||
mockEnsureWorkspaceReady.mockResolvedValue({
|
||||
workspaceId: "workspace-test",
|
||||
rootPath: "/tmp/workspace-test",
|
||||
existed: true,
|
||||
created: false,
|
||||
repaired: false,
|
||||
relocated: false,
|
||||
previousRootPath: null,
|
||||
warning: null,
|
||||
});
|
||||
mockUpdateContent.mockResolvedValue(undefined);
|
||||
mockGetProjectMemory.mockResolvedValue(null);
|
||||
mockExecutionRunGetThemeWorkbenchState.mockResolvedValue({
|
||||
@@ -2029,8 +2048,96 @@ describe("AgentChatPage 视频主题工作台", () => {
|
||||
await flushEffects(10);
|
||||
|
||||
expect(container.querySelector('[data-testid="inputbar"]')).toBeNull();
|
||||
expect(container.querySelector('[data-testid="theme-workbench-sidebar"]')).toBeNull();
|
||||
expect(
|
||||
container.querySelector('[data-testid="theme-workbench-sidebar"]'),
|
||||
).toBeNull();
|
||||
expect(sharedTriggerAIGuideMock).not.toHaveBeenCalled();
|
||||
expect(sharedSendMessageMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AgentChatPage 海报主题工作台", () => {
|
||||
it("海报主题工作台不应渲染底部通用输入条、左侧上下文栏,也不应自动发起请求", async () => {
|
||||
mockUseThemeContextWorkspace.mockReturnValue(
|
||||
createMockThemeContextWorkspaceState({
|
||||
enabled: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const container = renderPage({
|
||||
projectId: "project-poster",
|
||||
contentId: "content-poster",
|
||||
theme: "poster",
|
||||
lockTheme: true,
|
||||
});
|
||||
await flushEffects(10);
|
||||
|
||||
expect(container.querySelector('[data-testid="inputbar"]')).toBeNull();
|
||||
expect(
|
||||
container.querySelector('[data-testid="theme-workbench-sidebar"]'),
|
||||
).toBeNull();
|
||||
expect(sharedTriggerAIGuideMock).not.toHaveBeenCalled();
|
||||
expect(sharedSendMessageMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AgentChatPage 小说主题工作台", () => {
|
||||
it("小说主题工作台普通进入时不应自动发起请求", async () => {
|
||||
mockIsContentCreationTheme.mockReturnValue(true);
|
||||
mockUseThemeContextWorkspace.mockReturnValue(
|
||||
createMockThemeContextWorkspaceState({
|
||||
enabled: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const container = renderPage({
|
||||
projectId: "project-novel",
|
||||
contentId: "content-novel",
|
||||
theme: "novel",
|
||||
lockTheme: true,
|
||||
});
|
||||
await flushEffects(10);
|
||||
|
||||
expect(
|
||||
container.querySelector('[data-testid="theme-workbench-sidebar"]'),
|
||||
).not.toBeNull();
|
||||
expect(sharedTriggerAIGuideMock).not.toHaveBeenCalled();
|
||||
expect(sharedSendMessageMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("小说主题工作台带初始意图时仍应自动发送首条请求", async () => {
|
||||
mockIsContentCreationTheme.mockReturnValue(true);
|
||||
mockUseThemeContextWorkspace.mockReturnValue(
|
||||
createMockThemeContextWorkspaceState({
|
||||
enabled: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const initialUserPrompt = "请基于当前设定生成第一章开篇。";
|
||||
const onInitialUserPromptConsumed = vi.fn();
|
||||
|
||||
renderPage({
|
||||
projectId: "project-novel-intent",
|
||||
contentId: "content-novel-intent",
|
||||
theme: "novel",
|
||||
lockTheme: true,
|
||||
initialUserPrompt,
|
||||
onInitialUserPromptConsumed,
|
||||
});
|
||||
await flushEffects(12);
|
||||
|
||||
expect(sharedSendMessageMock).toHaveBeenCalledWith(
|
||||
initialUserPrompt,
|
||||
[],
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
undefined,
|
||||
"mock-model",
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
expect(onInitialUserPromptConsumed).toHaveBeenCalledTimes(1);
|
||||
expect(sharedTriggerAIGuideMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,8 +20,8 @@ import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import { Info, PanelLeftOpen } from "lucide-react";
|
||||
import { open as openDialog } from "@tauri-apps/plugin-dialog";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { safeListen } from "@/lib/dev-bridge";
|
||||
import { readFilePreview } from "@/lib/api/fileBrowser";
|
||||
import { uploadImageToSession, importDocument } from "@/lib/api/session-files";
|
||||
import {
|
||||
useAgentChatUnified,
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
ThemeWorkbenchSidebar,
|
||||
type ThemeWorkbenchCreationTaskEvent,
|
||||
} from "./components/ThemeWorkbenchSidebar";
|
||||
import { HarnessStatusPanel } from "./components/HarnessStatusPanel";
|
||||
import { MessageList } from "./components/MessageList";
|
||||
import { Inputbar } from "./components/Inputbar";
|
||||
import { RuntimeStyleControlBar } from "./components/RuntimeStyleControlBar";
|
||||
@@ -65,11 +66,11 @@ import type {
|
||||
TextStylizeRunPayload,
|
||||
} from "@/components/content-creator/canvas/document/types";
|
||||
import { parseAIResponse } from "@/components/content-creator/a2ui/parser";
|
||||
import { CanvasPanel as GeneralCanvasPanel } from "@/components/general-chat/canvas";
|
||||
import { CanvasPanel as GeneralCanvasPanel } from "@/components/general-chat/bridge";
|
||||
import {
|
||||
type CanvasState as GeneralCanvasState,
|
||||
DEFAULT_CANVAS_STATE,
|
||||
} from "@/components/general-chat/types";
|
||||
} from "@/components/general-chat/bridge";
|
||||
import {
|
||||
artifactsAtom,
|
||||
selectedArtifactAtom,
|
||||
@@ -94,6 +95,8 @@ import {
|
||||
getOrCreateDefaultProject,
|
||||
getContent,
|
||||
getThemeWorkbenchDocumentState,
|
||||
ensureWorkspaceReady,
|
||||
updateProject as updateProjectById,
|
||||
updateContent,
|
||||
type Project,
|
||||
type ProjectType,
|
||||
@@ -109,6 +112,7 @@ import { SettingsTabs } from "@/types/settings";
|
||||
import { skillsApi, type Skill } from "@/lib/api/skills";
|
||||
import { buildHomeAgentParams } from "@/lib/workspace/navigation";
|
||||
import { useConfiguredProviders } from "@/hooks/useConfiguredProviders";
|
||||
import { useSubAgentScheduler } from "@/hooks/useSubAgentScheduler";
|
||||
import { LatestRunStatusBadge } from "@/components/execution/LatestRunStatusBadge";
|
||||
import {
|
||||
executionRunGet,
|
||||
@@ -131,10 +135,8 @@ import {
|
||||
loadRememberedBaseModel,
|
||||
saveRememberedBaseModel,
|
||||
} from "@/lib/model/thinkingBaseModelMemory";
|
||||
import type {
|
||||
AutoContinueRequestPayload,
|
||||
ToolCallState,
|
||||
} from "@/lib/api/agent";
|
||||
import type { AutoContinueRequestPayload } from "@/lib/api/agentRuntime";
|
||||
import type { ToolCallState } from "@/lib/api/agentStream";
|
||||
import {
|
||||
skillExecutionApi,
|
||||
type SkillDetailInfo,
|
||||
@@ -159,6 +161,7 @@ import {
|
||||
saveChatToolPreferences,
|
||||
type ChatToolPreferences,
|
||||
} from "./utils/chatToolPreferences";
|
||||
import { deriveHarnessSessionState } from "./utils/harnessState";
|
||||
import {
|
||||
resolveCanvasTaskFileTarget,
|
||||
shouldDeferCanvasSyncWhileEditing,
|
||||
@@ -187,6 +190,20 @@ const SUPPORTED_ENTRY_THEMES: ThemeType[] = [
|
||||
"novel",
|
||||
];
|
||||
|
||||
interface HarnessFilePreviewResult {
|
||||
path: string;
|
||||
content: string | null;
|
||||
isBinary: boolean;
|
||||
size: number;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
function extractFileNameFromPath(path: string): string {
|
||||
const normalized = path.replace(/\\/g, "/");
|
||||
const segments = normalized.split("/");
|
||||
return segments[segments.length - 1] || path;
|
||||
}
|
||||
|
||||
function normalizeInitialTheme(value?: string): ThemeType {
|
||||
if (!value) return "general";
|
||||
if (SUPPORTED_ENTRY_THEMES.includes(value as ThemeType)) {
|
||||
@@ -1455,6 +1472,32 @@ function savePersistedProjectId(key: string, projectId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function loadPersistedBoolean(key: string, fallback = false): boolean {
|
||||
try {
|
||||
const stored = localStorage.getItem(key);
|
||||
if (stored == null) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(stored);
|
||||
return typeof parsed === "boolean" ? parsed : fallback;
|
||||
} catch {
|
||||
return stored === "true";
|
||||
}
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function savePersistedBoolean(key: string, value: boolean) {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
} catch {
|
||||
// ignore write errors
|
||||
}
|
||||
}
|
||||
|
||||
export interface WorkflowProgressSnapshot {
|
||||
steps: Array<{
|
||||
id: string;
|
||||
@@ -1468,6 +1511,8 @@ export interface WorkflowProgressSnapshot {
|
||||
* 判断画布状态是否为空
|
||||
* 用于决定是否自动触发 AI 引导
|
||||
*/
|
||||
const HARNESS_PANEL_VISIBILITY_KEY = "proxycast.chat.harness-panel.visible.v1";
|
||||
|
||||
function isCanvasStateEmpty(state: CanvasStateUnion | null): boolean {
|
||||
if (!state) return true;
|
||||
|
||||
@@ -1859,14 +1904,6 @@ export function AgentChatPage({
|
||||
setLayoutMode("chat-canvas");
|
||||
}, [artifacts.length, activeTheme]);
|
||||
|
||||
// 加载技能列表
|
||||
useEffect(() => {
|
||||
skillsApi
|
||||
.getAll("proxycast")
|
||||
.then(setSkills)
|
||||
.catch((err) => console.error("加载技能列表失败:", err));
|
||||
}, []);
|
||||
|
||||
// 跳转到设置页安装技能
|
||||
const handleNavigateToSkillSettings = useCallback(() => {
|
||||
_onNavigate?.("settings", { tab: SettingsTabs.Skills });
|
||||
@@ -2062,12 +2099,7 @@ export function AgentChatPage({
|
||||
const normalizedId = normalizeProjectId(projectId);
|
||||
if (!normalizedId) return;
|
||||
|
||||
invoke<{ created: boolean; repaired: boolean; rootPath: string }>(
|
||||
"workspace_ensure_ready",
|
||||
{
|
||||
id: normalizedId,
|
||||
},
|
||||
)
|
||||
ensureWorkspaceReady(normalizedId)
|
||||
.then(({ repaired, rootPath }) => {
|
||||
if (repaired) {
|
||||
recordWorkspaceRepair({
|
||||
@@ -2169,6 +2201,7 @@ export function AgentChatPage({
|
||||
deleteMessage,
|
||||
editMessage,
|
||||
handlePermissionResponse,
|
||||
pendingActions,
|
||||
triggerAIGuide,
|
||||
topics,
|
||||
sessionId,
|
||||
@@ -2188,6 +2221,14 @@ export function AgentChatPage({
|
||||
workspaceId: projectId ?? "",
|
||||
});
|
||||
const { providers: configuredProviders } = useConfiguredProviders();
|
||||
const subAgentRuntime = useSubAgentScheduler(sessionId);
|
||||
const harnessState = useMemo(
|
||||
() => deriveHarnessSessionState(messages, pendingActions),
|
||||
[messages, pendingActions],
|
||||
);
|
||||
const [harnessPanelVisible, setHarnessPanelVisible] = useState(() =>
|
||||
loadPersistedBoolean(HARNESS_PANEL_VISIBILITY_KEY, false),
|
||||
);
|
||||
const selectedProvider = useMemo(
|
||||
() => configuredProviders.find((provider) => provider.key === providerType),
|
||||
[configuredProviders, providerType],
|
||||
@@ -2201,6 +2242,10 @@ export function AgentChatPage({
|
||||
onSessionChange?.(sessionId ?? null);
|
||||
}, [onSessionChange, sessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
savePersistedBoolean(HARNESS_PANEL_VISIBILITY_KEY, harnessPanelVisible);
|
||||
}, [harnessPanelVisible]);
|
||||
|
||||
const contextWorkspace = useThemeContextWorkspace({
|
||||
projectId,
|
||||
activeTheme,
|
||||
@@ -2208,7 +2253,63 @@ export function AgentChatPage({
|
||||
providerType,
|
||||
model,
|
||||
});
|
||||
const installedSkills = useMemo(
|
||||
() => skills.filter((skill) => skill.installed),
|
||||
[skills],
|
||||
);
|
||||
const harnessPendingCount = harnessState.pendingApprovals.length;
|
||||
const showHarnessToggle =
|
||||
harnessPanelVisible || harnessState.hasSignals || subAgentRuntime.isRunning;
|
||||
const harnessAttentionLevel =
|
||||
harnessPendingCount > 0 ? "warning" : showHarnessToggle ? "active" : "idle";
|
||||
const visibleContextItems = useMemo(() => {
|
||||
const activeItems = contextWorkspace.sidebarContextItems.filter(
|
||||
(item) => item.active,
|
||||
);
|
||||
return activeItems.length > 0
|
||||
? activeItems
|
||||
: contextWorkspace.sidebarContextItems;
|
||||
}, [contextWorkspace.sidebarContextItems]);
|
||||
const harnessEnvironment = useMemo(
|
||||
() => ({
|
||||
skillsCount: installedSkills.length,
|
||||
skillNames: installedSkills
|
||||
.map((skill) => skill.name || skill.key)
|
||||
.filter((name) => !!name.trim())
|
||||
.slice(0, 4),
|
||||
memorySignals: [
|
||||
projectMemory?.characters.length ? "角色" : null,
|
||||
projectMemory?.world_building ? "世界观" : null,
|
||||
projectMemory?.style_guide ? "风格" : null,
|
||||
projectMemory?.outline.length ? "大纲" : null,
|
||||
].filter((item): item is string => item !== null),
|
||||
contextItemsCount: contextWorkspace.sidebarContextItems.length,
|
||||
activeContextCount: contextWorkspace.sidebarContextItems.filter(
|
||||
(item) => item.active,
|
||||
).length,
|
||||
contextItemNames: visibleContextItems
|
||||
.map((item) => item.name)
|
||||
.filter((name) => !!name.trim())
|
||||
.slice(0, 4),
|
||||
contextEnabled: contextWorkspace.enabled,
|
||||
}),
|
||||
[
|
||||
contextWorkspace.enabled,
|
||||
contextWorkspace.sidebarContextItems,
|
||||
installedSkills,
|
||||
projectMemory?.characters.length,
|
||||
projectMemory?.outline.length,
|
||||
projectMemory?.style_guide,
|
||||
projectMemory?.world_building,
|
||||
visibleContextItems,
|
||||
],
|
||||
);
|
||||
const isThemeWorkbench = contextWorkspace.enabled;
|
||||
const shouldUseCompactThemeWorkbench =
|
||||
isThemeWorkbench && (mappedTheme === "video" || mappedTheme === "poster");
|
||||
const shouldSkipThemeWorkbenchAutoGuideWithoutPrompt =
|
||||
isThemeWorkbench &&
|
||||
(shouldUseCompactThemeWorkbench || mappedTheme === "novel");
|
||||
const enableThemeWorkbenchPanelCollapse =
|
||||
isThemeWorkbench && mappedTheme === "social-media";
|
||||
|
||||
@@ -4796,6 +4897,72 @@ export function AgentChatPage({
|
||||
[activeTheme, isThemeWorkbench, mappedTheme, upsertNovelCanvasState],
|
||||
);
|
||||
|
||||
const handleHarnessLoadFilePreview = useCallback(
|
||||
async (path: string): Promise<HarnessFilePreviewResult> => {
|
||||
const normalizedPath = path.trim();
|
||||
const createFallbackResult = (
|
||||
overrides: Partial<HarnessFilePreviewResult> = {},
|
||||
): HarnessFilePreviewResult => ({
|
||||
path: normalizedPath,
|
||||
content: null,
|
||||
isBinary: false,
|
||||
size: 0,
|
||||
error: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
if (!normalizedPath) {
|
||||
return createFallbackResult({ error: "文件路径为空" });
|
||||
}
|
||||
|
||||
const fileName = extractFileNameFromPath(normalizedPath);
|
||||
const candidateNames = [...new Set([normalizedPath, fileName])];
|
||||
|
||||
const matchedTaskFile = taskFiles.find((file) =>
|
||||
candidateNames.includes(file.name),
|
||||
);
|
||||
if (matchedTaskFile) {
|
||||
const content = matchedTaskFile.content ?? "";
|
||||
return createFallbackResult({
|
||||
path: matchedTaskFile.name,
|
||||
content,
|
||||
size: content.length,
|
||||
});
|
||||
}
|
||||
|
||||
const matchedSessionFile = sessionFiles.find((file) =>
|
||||
candidateNames.includes(file.name),
|
||||
);
|
||||
if (matchedSessionFile) {
|
||||
const content = await readSessionFile(matchedSessionFile.name);
|
||||
if (content !== null) {
|
||||
return createFallbackResult({
|
||||
path: matchedSessionFile.name,
|
||||
content,
|
||||
size: content.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await readFilePreview(normalizedPath, 64 * 1024);
|
||||
|
||||
return createFallbackResult({
|
||||
path: result.path || normalizedPath,
|
||||
content: result.content ?? null,
|
||||
isBinary: result.isBinary ?? false,
|
||||
size: result.size ?? 0,
|
||||
error: result.error ?? null,
|
||||
});
|
||||
} catch (error) {
|
||||
return createFallbackResult({
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
},
|
||||
[readSessionFile, sessionFiles, taskFiles],
|
||||
);
|
||||
|
||||
// 处理代码块点击 - 在画布中显示代码(General 主题专用)
|
||||
const handleCodeBlockClick = useCallback(
|
||||
(language: string, code: string) => {
|
||||
@@ -4924,7 +5091,7 @@ export function AgentChatPage({
|
||||
|
||||
// 当从项目进入且有 contentId 时,自动启动创作引导
|
||||
useEffect(() => {
|
||||
if (mappedTheme === "video") {
|
||||
if (shouldUseCompactThemeWorkbench) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -4981,6 +5148,9 @@ export function AgentChatPage({
|
||||
}
|
||||
|
||||
if (isThemeWorkbench) {
|
||||
if (shouldSkipThemeWorkbenchAutoGuideWithoutPrompt) {
|
||||
return;
|
||||
}
|
||||
hasTriggeredGuide.current = true;
|
||||
console.log("[AgentChatPage] 主题工作台:触发 AI 引导,创建后端工作流");
|
||||
// 同步创建后端工作流(不阻塞触发)
|
||||
@@ -5031,13 +5201,15 @@ export function AgentChatPage({
|
||||
handleSend,
|
||||
chatToolPreferences,
|
||||
onInitialUserPromptConsumed,
|
||||
shouldUseCompactThemeWorkbench,
|
||||
shouldSkipThemeWorkbenchAutoGuideWithoutPrompt,
|
||||
]);
|
||||
|
||||
// 通用聊天场景:若带有 initialUserPrompt,则自动新建并发送首条消息
|
||||
useEffect(() => {
|
||||
const pendingInitialPrompt = (initialUserPrompt || "").trim();
|
||||
if (
|
||||
mappedTheme === "video" ||
|
||||
shouldUseCompactThemeWorkbench ||
|
||||
!pendingInitialPrompt ||
|
||||
contentId ||
|
||||
!sessionId ||
|
||||
@@ -5067,10 +5239,10 @@ export function AgentChatPage({
|
||||
handleSend,
|
||||
initialUserPrompt,
|
||||
isSending,
|
||||
mappedTheme,
|
||||
messages.length,
|
||||
onInitialUserPromptConsumed,
|
||||
sessionId,
|
||||
shouldUseCompactThemeWorkbench,
|
||||
]);
|
||||
|
||||
// 当 contentId 变化时重置引导状态
|
||||
@@ -5125,9 +5297,9 @@ export function AgentChatPage({
|
||||
|
||||
// 主题工作台始终使用聊天布局与浮层输入,不走旧 EmptyState 输入流程
|
||||
const showChatLayout = hasMessages || isThemeWorkbench;
|
||||
const shouldHideThemeWorkbenchInputForTheme =
|
||||
isThemeWorkbench && mappedTheme === "video";
|
||||
const shouldShowThemeWorkbenchSidebarForTheme = mappedTheme !== "video";
|
||||
const shouldHideThemeWorkbenchInputForTheme = shouldUseCompactThemeWorkbench;
|
||||
const shouldShowThemeWorkbenchSidebarForTheme =
|
||||
!shouldUseCompactThemeWorkbench;
|
||||
const showThemeWorkbenchSidebar =
|
||||
showChatPanel &&
|
||||
showSidebar &&
|
||||
@@ -5307,10 +5479,7 @@ export function AgentChatPage({
|
||||
} else if (projectId) {
|
||||
// 主动健康检查发现问题:只更新路径,不需要重试
|
||||
try {
|
||||
await invoke("workspace_update", {
|
||||
id: projectId,
|
||||
request: { rootPath: newPath },
|
||||
});
|
||||
await updateProjectById(projectId, { rootPath: newPath });
|
||||
setWorkspaceHealthError(false);
|
||||
toast.success("工作区目录已更新");
|
||||
} catch (err) {
|
||||
@@ -5579,6 +5748,16 @@ export function AgentChatPage({
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{harnessPanelVisible ? (
|
||||
<HarnessStatusPanel
|
||||
harnessState={harnessState}
|
||||
subAgentRuntime={subAgentRuntime}
|
||||
environment={harnessEnvironment}
|
||||
onLoadFilePreview={handleHarnessLoadFilePreview}
|
||||
onOpenFile={handleFileClick}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{showChatLayout ? (
|
||||
<ChatContent>
|
||||
{contextWorkspace.enabled ? (
|
||||
@@ -5728,13 +5907,17 @@ export function AgentChatPage({
|
||||
handleA2UISubmit,
|
||||
handleCodeBlockClick,
|
||||
handleFileClick,
|
||||
handleHarnessLoadFilePreview,
|
||||
handleManageProviders,
|
||||
handleNavigateToSkillSettings,
|
||||
handlePermissionResponse,
|
||||
handleSelectWorkspaceDirectory,
|
||||
handleSend,
|
||||
handleWriteFile,
|
||||
harnessEnvironment,
|
||||
harnessPanelVisible,
|
||||
hasMessages,
|
||||
harnessState,
|
||||
hideInlineStepProgress,
|
||||
input,
|
||||
inputbarNode,
|
||||
@@ -5762,6 +5945,7 @@ export function AgentChatPage({
|
||||
mappedTheme,
|
||||
runtimeStyleSelection,
|
||||
styleActionsDisabled,
|
||||
subAgentRuntime,
|
||||
skills,
|
||||
steps,
|
||||
workspaceHealthError,
|
||||
@@ -5942,6 +6126,13 @@ export function AgentChatPage({
|
||||
onProjectChange={handleProjectChange}
|
||||
workspaceType={activeTheme}
|
||||
onBackHome={handleBackHome}
|
||||
showHarnessToggle={showHarnessToggle}
|
||||
harnessPanelVisible={harnessPanelVisible}
|
||||
onToggleHarnessPanel={() =>
|
||||
setHarnessPanelVisible((current) => !current)
|
||||
}
|
||||
harnessPendingCount={harnessPendingCount}
|
||||
harnessAttentionLevel={harnessAttentionLevel}
|
||||
onToggleSettings={() => {
|
||||
_onNavigate?.("settings", {
|
||||
tab: SettingsTabs.ChatAppearance,
|
||||
@@ -6054,6 +6245,9 @@ export function AgentChatPage({
|
||||
inputbarNode,
|
||||
isSending,
|
||||
isThemeWorkbench,
|
||||
harnessAttentionLevel,
|
||||
harnessPanelVisible,
|
||||
harnessPendingCount,
|
||||
layoutMode,
|
||||
novelChapterListCollapsed,
|
||||
onBackToProjectManagement,
|
||||
@@ -6062,6 +6256,7 @@ export function AgentChatPage({
|
||||
shouldHideThemeWorkbenchInputForTheme,
|
||||
showChatLayout,
|
||||
showChatPanel,
|
||||
showHarnessToggle,
|
||||
showNovelNavbarControls,
|
||||
syncStatus,
|
||||
themeWorkbenchRunState,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ToolCallState, TokenUsage } from "@/lib/api/agent";
|
||||
import type { ContextTraceStep } from "@/lib/api/agent";
|
||||
import type { ToolCallState, TokenUsage } from "@/lib/api/agentStream";
|
||||
import type { ContextTraceStep } from "@/lib/api/agentStream";
|
||||
import { safeInvoke } from "@/lib/dev-bridge";
|
||||
|
||||
export interface MessageImage {
|
||||
|
||||
@@ -0,0 +1,525 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ActionRequired, Message } from "../types";
|
||||
import { deriveHarnessSessionState } from "./harnessState";
|
||||
|
||||
const BASE_TIME = new Date("2026-03-11T12:00:00.000Z");
|
||||
|
||||
function asLegacyDate(value: string): Date {
|
||||
return value as unknown as Date;
|
||||
}
|
||||
|
||||
function createAssistantMessage(overrides: Partial<Message> = {}): Message {
|
||||
return {
|
||||
id: "assistant-1",
|
||||
role: "assistant",
|
||||
content: "",
|
||||
timestamp: BASE_TIME,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("deriveHarnessSessionState", () => {
|
||||
it("应从 TodoWrite 参数提取结构化 Todo", () => {
|
||||
const state = deriveHarnessSessionState(
|
||||
[
|
||||
createAssistantMessage({
|
||||
toolCalls: [
|
||||
{
|
||||
id: "todo-1",
|
||||
name: "TodoWrite",
|
||||
arguments: JSON.stringify({
|
||||
todos: [
|
||||
{ id: "a", content: "梳理主链", status: "in_progress" },
|
||||
{ id: "b", content: "实现面板", status: "pending" },
|
||||
],
|
||||
}),
|
||||
status: "completed",
|
||||
startTime: BASE_TIME,
|
||||
endTime: new Date(BASE_TIME.getTime() + 1000),
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
expect(state.plan.phase).toBe("planning");
|
||||
expect(state.plan.items).toEqual([
|
||||
{ id: "a", content: "梳理主链", status: "in_progress" },
|
||||
{ id: "b", content: "实现面板", status: "pending" },
|
||||
]);
|
||||
expect(state.activity.planning).toBe(1);
|
||||
});
|
||||
|
||||
it("应在 ExitPlanMode 完成后标记规划完成", () => {
|
||||
const state = deriveHarnessSessionState(
|
||||
[
|
||||
createAssistantMessage({
|
||||
toolCalls: [
|
||||
{
|
||||
id: "todo-1",
|
||||
name: "TodoWrite",
|
||||
arguments: JSON.stringify({
|
||||
todos: [{ id: "a", content: "完成实现", status: "completed" }],
|
||||
}),
|
||||
status: "completed",
|
||||
startTime: BASE_TIME,
|
||||
endTime: new Date(BASE_TIME.getTime() + 1000),
|
||||
},
|
||||
{
|
||||
id: "plan-exit",
|
||||
name: "ExitPlanMode",
|
||||
status: "completed",
|
||||
startTime: new Date(BASE_TIME.getTime() + 2000),
|
||||
endTime: new Date(BASE_TIME.getTime() + 3000),
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
expect(state.plan.phase).toBe("ready");
|
||||
expect(state.plan.items).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("应提取待审批数和最新 context trace", () => {
|
||||
const pendingApprovals: ActionRequired[] = [
|
||||
{
|
||||
requestId: "req-1",
|
||||
actionType: "tool_confirmation",
|
||||
prompt: "是否允许写文件?",
|
||||
},
|
||||
];
|
||||
|
||||
const state = deriveHarnessSessionState(
|
||||
[
|
||||
createAssistantMessage({
|
||||
id: "assistant-2",
|
||||
contextTrace: [
|
||||
{ stage: "workspace", detail: "加载 AGENTS.md" },
|
||||
{ stage: "memory", detail: "注入项目记忆" },
|
||||
],
|
||||
}),
|
||||
],
|
||||
pendingApprovals,
|
||||
);
|
||||
|
||||
expect(state.pendingApprovals).toHaveLength(1);
|
||||
expect(state.latestContextTrace).toHaveLength(2);
|
||||
expect(state.latestContextTrace[0]?.stage).toBe("workspace");
|
||||
});
|
||||
|
||||
it("应兼容未提供待审批数组的旧调用方", () => {
|
||||
const state = deriveHarnessSessionState(
|
||||
[
|
||||
createAssistantMessage({
|
||||
contextTrace: [{ stage: "workspace", detail: "恢复旧会话" }],
|
||||
}),
|
||||
],
|
||||
undefined as unknown as ActionRequired[],
|
||||
);
|
||||
|
||||
expect(state.pendingApprovals).toEqual([]);
|
||||
expect(state.latestContextTrace).toHaveLength(1);
|
||||
expect(state.hasSignals).toBe(true);
|
||||
});
|
||||
|
||||
it("应识别 SubAgentTask 和关键工具活动", () => {
|
||||
const state = deriveHarnessSessionState(
|
||||
[
|
||||
createAssistantMessage({
|
||||
toolCalls: [
|
||||
{
|
||||
id: "read-1",
|
||||
name: "Read",
|
||||
status: "completed",
|
||||
startTime: BASE_TIME,
|
||||
endTime: new Date(BASE_TIME.getTime() + 1000),
|
||||
},
|
||||
{
|
||||
id: "sub-1",
|
||||
name: "SubAgentTask",
|
||||
arguments: JSON.stringify({
|
||||
description: "调研 legacy chat",
|
||||
role: "explorer",
|
||||
taskType: "explore",
|
||||
}),
|
||||
status: "running",
|
||||
result: {
|
||||
success: true,
|
||||
output: "正在执行",
|
||||
},
|
||||
startTime: new Date(BASE_TIME.getTime() + 2000),
|
||||
},
|
||||
{
|
||||
id: "web-1",
|
||||
name: "WebSearch",
|
||||
status: "completed",
|
||||
startTime: new Date(BASE_TIME.getTime() + 3000),
|
||||
endTime: new Date(BASE_TIME.getTime() + 4000),
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
expect(state.activity.filesystem).toBe(1);
|
||||
expect(state.activity.delegation).toBe(1);
|
||||
expect(state.activity.web).toBe(1);
|
||||
expect(state.delegatedTasks).toHaveLength(1);
|
||||
expect(state.delegatedTasks[0]?.title).toBe("调研 legacy chat");
|
||||
expect(state.delegatedTasks[0]?.role).toBe("explorer");
|
||||
});
|
||||
|
||||
it("应兼容历史缓存中的字符串时间戳", () => {
|
||||
const state = deriveHarnessSessionState(
|
||||
[
|
||||
createAssistantMessage({
|
||||
toolCalls: [
|
||||
{
|
||||
id: "plan-enter",
|
||||
name: "EnterPlanMode",
|
||||
status: "completed",
|
||||
startTime: asLegacyDate("2026-03-11T12:00:00.000Z"),
|
||||
endTime: asLegacyDate("2026-03-11T12:00:01.000Z"),
|
||||
},
|
||||
{
|
||||
id: "todo-legacy",
|
||||
name: "TodoWrite",
|
||||
arguments: JSON.stringify({
|
||||
todos: [
|
||||
{ id: "legacy-1", content: "修复短视频崩溃", status: "done" },
|
||||
],
|
||||
}),
|
||||
status: "completed",
|
||||
startTime: asLegacyDate("2026-03-11T12:00:02.000Z"),
|
||||
endTime: asLegacyDate("2026-03-11T12:00:03.000Z"),
|
||||
},
|
||||
{
|
||||
id: "sub-legacy",
|
||||
name: "SubAgentTask",
|
||||
arguments: JSON.stringify({
|
||||
description: "检查历史会话",
|
||||
role: "diagnose",
|
||||
}),
|
||||
status: "completed",
|
||||
startTime: asLegacyDate("2026-03-11T12:00:04.000Z"),
|
||||
endTime: asLegacyDate("2026-03-11T12:00:05.000Z"),
|
||||
result: {
|
||||
success: true,
|
||||
output: "已完成",
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
expect(state.plan.phase).toBe("planning");
|
||||
expect(state.plan.items).toEqual([
|
||||
{ id: "legacy-1", content: "修复短视频崩溃", status: "completed" },
|
||||
]);
|
||||
expect(state.delegatedTasks[0]?.startedAt).toBeInstanceOf(Date);
|
||||
expect(state.activity.planning).toBe(2);
|
||||
expect(state.activity.delegation).toBe(1);
|
||||
});
|
||||
|
||||
it("应识别归一化后的 Harness 工具别名", () => {
|
||||
const state = deriveHarnessSessionState(
|
||||
[
|
||||
createAssistantMessage({
|
||||
toolCalls: [
|
||||
{
|
||||
id: "todo-2",
|
||||
name: "Write_Todos",
|
||||
arguments: JSON.stringify({
|
||||
items: [{ content: "补充事件隔离", status: "running" }],
|
||||
}),
|
||||
status: "completed",
|
||||
startTime: BASE_TIME,
|
||||
endTime: new Date(BASE_TIME.getTime() + 1000),
|
||||
},
|
||||
{
|
||||
id: "fs-2",
|
||||
name: "list_directory",
|
||||
status: "completed",
|
||||
startTime: new Date(BASE_TIME.getTime() + 2000),
|
||||
endTime: new Date(BASE_TIME.getTime() + 3000),
|
||||
},
|
||||
{
|
||||
id: "skill-2",
|
||||
name: "three_stage_workflow",
|
||||
status: "completed",
|
||||
startTime: new Date(BASE_TIME.getTime() + 4000),
|
||||
endTime: new Date(BASE_TIME.getTime() + 5000),
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
expect(state.plan.items).toEqual([
|
||||
{ id: "todo-1", content: "补充事件隔离", status: "in_progress" },
|
||||
]);
|
||||
expect(state.activity.planning).toBe(1);
|
||||
expect(state.activity.filesystem).toBe(1);
|
||||
expect(state.activity.skills).toBe(1);
|
||||
});
|
||||
|
||||
it("应提取任务输出文件与命令执行摘要信号", () => {
|
||||
const state = deriveHarnessSessionState(
|
||||
[
|
||||
createAssistantMessage({
|
||||
toolCalls: [
|
||||
{
|
||||
id: "task-output-1",
|
||||
name: "TaskOutput",
|
||||
status: "completed",
|
||||
startTime: BASE_TIME,
|
||||
endTime: new Date(BASE_TIME.getTime() + 1000),
|
||||
result: {
|
||||
success: true,
|
||||
output: [
|
||||
"=== 任务 task-1 ===",
|
||||
"状态: completed",
|
||||
"输出文件: /tmp/aster_tasks/task-1.log",
|
||||
].join("\n"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "bash-1",
|
||||
name: "Bash",
|
||||
status: "completed",
|
||||
startTime: new Date(BASE_TIME.getTime() + 2000),
|
||||
endTime: new Date(BASE_TIME.getTime() + 3000),
|
||||
result: {
|
||||
success: true,
|
||||
output: [
|
||||
"done",
|
||||
"[ProxyCast 执行摘要]",
|
||||
"exit_code: 1",
|
||||
"stdout_length: 120",
|
||||
"stderr_length: 32",
|
||||
"sandboxed: true",
|
||||
"output_truncated: true",
|
||||
].join("\n"),
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const taskSignal = state.outputSignals.find(
|
||||
(signal) => signal.toolName === "TaskOutput",
|
||||
);
|
||||
const bashSignal = state.outputSignals.find(
|
||||
(signal) => signal.toolName === "Bash",
|
||||
);
|
||||
|
||||
expect(taskSignal?.outputFile).toBe("/tmp/aster_tasks/task-1.log");
|
||||
expect(taskSignal?.title).toBe("任务输出已落盘");
|
||||
expect(bashSignal?.exitCode).toBe(1);
|
||||
expect(bashSignal?.stdoutLength).toBe(120);
|
||||
expect(bashSignal?.stderrLength).toBe(32);
|
||||
expect(bashSignal?.sandboxed).toBe(true);
|
||||
expect(bashSignal?.truncated).toBe(true);
|
||||
});
|
||||
|
||||
it("应识别工具输出 offload 转存信号", () => {
|
||||
const state = deriveHarnessSessionState(
|
||||
[
|
||||
createAssistantMessage({
|
||||
toolCalls: [
|
||||
{
|
||||
id: "tool-offload-1",
|
||||
name: "Write",
|
||||
status: "completed",
|
||||
startTime: BASE_TIME,
|
||||
endTime: new Date(BASE_TIME.getTime() + 1000),
|
||||
result: {
|
||||
success: true,
|
||||
output:
|
||||
"preview line 1\n\n[ProxyCast Offload] 完整输出已转存到文件:/tmp/proxycast/harness/tool-io/results/tool-offload-1.json",
|
||||
metadata: {
|
||||
proxycast_offloaded: true,
|
||||
offload_file:
|
||||
"/tmp/proxycast/harness/tool-io/results/tool-offload-1.json",
|
||||
offload_original_chars: 18234,
|
||||
offload_original_tokens: 4521,
|
||||
offload_trigger: "history_context_pressure",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const signal = state.outputSignals.find(
|
||||
(item) => item.toolCallId === "tool-offload-1",
|
||||
);
|
||||
|
||||
expect(signal?.title).toBe("工具输出已转存");
|
||||
expect(signal?.offloadFile).toBe(
|
||||
"/tmp/proxycast/harness/tool-io/results/tool-offload-1.json",
|
||||
);
|
||||
expect(signal?.offloaded).toBe(true);
|
||||
expect(signal?.offloadOriginalChars).toBe(18234);
|
||||
expect(signal?.offloadOriginalTokens).toBe(4521);
|
||||
expect(signal?.offloadTrigger).toBe("history_context_pressure");
|
||||
expect(signal?.summary).toContain("完整输出已转存");
|
||||
expect(signal?.summary).toContain("约 4521 tokens");
|
||||
expect(signal?.summary).toContain("上下文压力触发");
|
||||
});
|
||||
|
||||
it("应提取最近文件活动并保留文本预览", () => {
|
||||
const state = deriveHarnessSessionState(
|
||||
[
|
||||
createAssistantMessage({
|
||||
toolCalls: [
|
||||
{
|
||||
id: "write-1",
|
||||
name: "Write",
|
||||
arguments: JSON.stringify({
|
||||
path: "/tmp/workspace/plan.md",
|
||||
content: "# 规划\n- 第一步",
|
||||
}),
|
||||
status: "completed",
|
||||
startTime: BASE_TIME,
|
||||
endTime: new Date(BASE_TIME.getTime() + 1000),
|
||||
result: {
|
||||
success: true,
|
||||
output: "已写入 /tmp/workspace/plan.md",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "read-1",
|
||||
name: "Read",
|
||||
arguments: JSON.stringify({
|
||||
path: "/tmp/workspace/plan.md",
|
||||
}),
|
||||
status: "completed",
|
||||
startTime: new Date(BASE_TIME.getTime() + 2000),
|
||||
endTime: new Date(BASE_TIME.getTime() + 3000),
|
||||
result: {
|
||||
success: true,
|
||||
output: "# 规划\n- 第一步\n- 第二步",
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
expect(state.recentFileEvents).toHaveLength(2);
|
||||
expect(state.recentFileEvents[0]).toMatchObject({
|
||||
action: "read",
|
||||
path: "/tmp/workspace/plan.md",
|
||||
displayName: "plan.md",
|
||||
kind: "document",
|
||||
});
|
||||
expect(state.recentFileEvents[0]?.preview).toContain("# 规划");
|
||||
expect(state.recentFileEvents[0]?.content).toContain("- 第二步");
|
||||
expect(state.recentFileEvents[1]).toMatchObject({
|
||||
action: "write",
|
||||
path: "/tmp/workspace/plan.md",
|
||||
});
|
||||
expect(state.recentFileEvents[1]?.content).toContain("# 规划");
|
||||
});
|
||||
|
||||
it("应从输出信号提取可点击文件事件", () => {
|
||||
const state = deriveHarnessSessionState(
|
||||
[
|
||||
createAssistantMessage({
|
||||
toolCalls: [
|
||||
{
|
||||
id: "bash-offload-1",
|
||||
name: "Bash",
|
||||
status: "completed",
|
||||
startTime: BASE_TIME,
|
||||
endTime: new Date(BASE_TIME.getTime() + 1000),
|
||||
result: {
|
||||
success: true,
|
||||
output: [
|
||||
"stdout preview line",
|
||||
"输出文件: /tmp/proxycast/tasks/run-1.log",
|
||||
"[ProxyCast Offload] 完整输出已转存到文件:/tmp/proxycast/harness/results/run-1.json",
|
||||
].join("\n"),
|
||||
metadata: {
|
||||
proxycast_offloaded: true,
|
||||
output_file: "/tmp/proxycast/tasks/run-1.log",
|
||||
offload_file: "/tmp/proxycast/harness/results/run-1.json",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
expect(state.recentFileEvents).toHaveLength(2);
|
||||
const offloadEvent = state.recentFileEvents.find(
|
||||
(event) => event.action === "offload",
|
||||
);
|
||||
const outputEvent = state.recentFileEvents.find(
|
||||
(event) => event.path === "/tmp/proxycast/tasks/run-1.log",
|
||||
);
|
||||
|
||||
expect(offloadEvent).toMatchObject({
|
||||
action: "offload",
|
||||
path: "/tmp/proxycast/harness/results/run-1.json",
|
||||
kind: "offload",
|
||||
clickable: true,
|
||||
});
|
||||
expect(offloadEvent?.preview).toContain("stdout preview line");
|
||||
expect(outputEvent).toMatchObject({
|
||||
action: "persist",
|
||||
path: "/tmp/proxycast/tasks/run-1.log",
|
||||
kind: "log",
|
||||
});
|
||||
});
|
||||
|
||||
it("最近文件活动应只保留最新 5 条", () => {
|
||||
const toolCalls = Array.from({ length: 6 }, (_, index) => ({
|
||||
id: `read-${index + 1}`,
|
||||
name: "Read",
|
||||
arguments: JSON.stringify({
|
||||
path: `/tmp/workspace/file-${index + 1}.txt`,
|
||||
}),
|
||||
status: "completed" as const,
|
||||
startTime: new Date(BASE_TIME.getTime() + index * 1000),
|
||||
endTime: new Date(BASE_TIME.getTime() + index * 1000 + 500),
|
||||
result: {
|
||||
success: true,
|
||||
output: `file-${index + 1}`,
|
||||
},
|
||||
}));
|
||||
|
||||
const state = deriveHarnessSessionState(
|
||||
[
|
||||
createAssistantMessage({
|
||||
toolCalls,
|
||||
}),
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
expect(state.recentFileEvents).toHaveLength(5);
|
||||
expect(state.recentFileEvents.map((item) => item.displayName)).toEqual([
|
||||
"file-6.txt",
|
||||
"file-5.txt",
|
||||
"file-4.txt",
|
||||
"file-3.txt",
|
||||
"file-2.txt",
|
||||
]);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
import type { StreamEvent } from "@/lib/api/agent";
|
||||
import type { StreamEvent } from "@/lib/api/agentStream";
|
||||
import { updateCrashContext } from "@/lib/crashReporting";
|
||||
|
||||
const EVENT_PUBLISH_INTERVAL = 20;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ThemeType } from "@/components/content-creator/types";
|
||||
import type { CanvasStateUnion } from "@/components/content-creator/canvas/canvasUtils";
|
||||
import { scriptStateToText } from "@/components/content-creator/canvas/script";
|
||||
import type { CanvasState as GeneralCanvasState } from "@/components/general-chat/types";
|
||||
import type { CanvasState as GeneralCanvasState } from "@/components/general-chat/bridge";
|
||||
import type { TaskFile } from "../components/TaskFiles";
|
||||
import { getSupportedFilenames } from "./workflowMapping";
|
||||
|
||||
@@ -67,7 +67,9 @@ export function extractStyleActionContent(context: StyleActionContext): string {
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveStyleActionFileName(context: StyleActionContext): string {
|
||||
export function resolveStyleActionFileName(
|
||||
context: StyleActionContext,
|
||||
): string {
|
||||
const selectedFile = context.taskFiles.find(
|
||||
(file) => file.id === context.selectedFileId,
|
||||
);
|
||||
|
||||
@@ -11,28 +11,32 @@ import * as Select from "@radix-ui/react-select";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { LogsTab } from "./LogsTab";
|
||||
import { ProviderIcon } from "@/icons/providers";
|
||||
import { reloadCredentials } from "@/lib/api/providerRuntime";
|
||||
import { revealPathInFinder } from "@/lib/api/fileSystem";
|
||||
import {
|
||||
startServer,
|
||||
stopServer,
|
||||
getServerStatus,
|
||||
getServerDiagnostics,
|
||||
getConfig,
|
||||
saveConfig,
|
||||
reloadCredentials,
|
||||
getNetworkInfo,
|
||||
testApi,
|
||||
exportSupportBundle,
|
||||
revealInFinder,
|
||||
ServerStatus,
|
||||
ServerDiagnostics,
|
||||
SupportBundleExportResult,
|
||||
Config,
|
||||
TestResult,
|
||||
type NetworkInfo,
|
||||
type TestResult,
|
||||
} from "@/lib/api/serverTools";
|
||||
import {
|
||||
getConfig,
|
||||
getDefaultProvider,
|
||||
saveConfig,
|
||||
setDefaultProvider,
|
||||
updateProviderEnvVars,
|
||||
getNetworkInfo,
|
||||
NetworkInfo,
|
||||
} from "@/hooks/useTauri";
|
||||
type Config,
|
||||
} from "@/lib/api/appConfig";
|
||||
import {
|
||||
exportSupportBundle,
|
||||
getServerDiagnostics,
|
||||
getServerStatus,
|
||||
startServer,
|
||||
stopServer,
|
||||
type ServerDiagnostics,
|
||||
type ServerStatus,
|
||||
type SupportBundleExportResult,
|
||||
} from "@/lib/api/serverRuntime";
|
||||
import { providerPoolApi, ProviderPoolOverview } from "@/lib/api/providerPool";
|
||||
import {
|
||||
apiKeyProviderApi,
|
||||
@@ -275,7 +279,7 @@ export function ApiServerPage({ hideHeader = false }: ApiServerPageProps) {
|
||||
const handleRevealSupportBundle = async () => {
|
||||
if (!supportBundleResult?.bundle_path) return;
|
||||
try {
|
||||
await revealInFinder(supportBundleResult.bundle_path);
|
||||
await revealPathInFinder(supportBundleResult.bundle_path);
|
||||
} catch (e: unknown) {
|
||||
const errMsg = e instanceof Error ? e.message : String(e);
|
||||
setMessage({ type: "error", text: `打开支持包目录失败: ${errMsg}` });
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Trash2, Download } from "lucide-react";
|
||||
import { getLogs, clearLogs, LogEntry } from "@/hooks/useTauri";
|
||||
import { clearLogs, getLogs, type LogEntry } from "@/lib/api/logs";
|
||||
|
||||
export function LogsTab() {
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { Cpu, RefreshCw, Copy, Check, Search } from "lucide-react";
|
||||
import { getAvailableModels, ModelInfo } from "@/hooks/useTauri";
|
||||
import { getAvailableModels, type ModelInfo } from "@/lib/api/modelCatalog";
|
||||
|
||||
// 根据 provider_id 获取分组配置
|
||||
const PROVIDER_GROUPS: Record<string, { name: string; color: string }> = {
|
||||
|
||||
@@ -1,152 +1,20 @@
|
||||
/**
|
||||
* @file 通用对话页面
|
||||
* @description ProxyCast 核心功能 - 通用对话页面
|
||||
* @description 旧版通用对话页面兼容包装层
|
||||
* @module components/chat/ChatPage
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useEffect, memo } from "react";
|
||||
import styled from "styled-components";
|
||||
import { MessageList, InputBar, ThemeSelector, EmptyState } from "./components";
|
||||
import { useChat } from "./hooks";
|
||||
import { ThemeType } from "./types";
|
||||
|
||||
const PageContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background-color: hsl(var(--background));
|
||||
`;
|
||||
|
||||
const ChatArea = styled.div`
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
`;
|
||||
|
||||
const ErrorBanner = styled.div`
|
||||
padding: 12px 16px;
|
||||
margin: 0 16px;
|
||||
border-radius: 8px;
|
||||
background: hsl(var(--destructive) / 0.1);
|
||||
color: hsl(var(--destructive));
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
`;
|
||||
import React, { memo } from "react";
|
||||
import { GeneralChatPage } from "@/components/general-chat";
|
||||
|
||||
/**
|
||||
* 通用对话页面
|
||||
*
|
||||
* ProxyCast 的核心功能,提供:
|
||||
* - 即时对话,打开即用
|
||||
* - Markdown 渲染和代码高亮
|
||||
* - 流式响应
|
||||
* - 主题选择入口
|
||||
* 该组件仅保留兼容入口职责,实际实现统一委托给
|
||||
* `components/general-chat/GeneralChatPage`,避免旧页面继续维护独立状态机。
|
||||
*
|
||||
* @deprecated 遗留通用聊天页面。禁止新增依赖,请优先使用现役聊天入口。
|
||||
*/
|
||||
export const ChatPage: React.FC = memo(() => {
|
||||
const {
|
||||
messages,
|
||||
isGenerating,
|
||||
error,
|
||||
sendMessage,
|
||||
clearMessages: _clearMessages,
|
||||
retryLastMessage,
|
||||
stopGeneration,
|
||||
} = useChat();
|
||||
|
||||
const [currentTheme, setCurrentTheme] = useState<ThemeType>("general");
|
||||
const [selectedText, setSelectedText] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const handleSelectionChange = () => {
|
||||
const rawSelection = window.getSelection()?.toString() || "";
|
||||
const normalized = rawSelection.trim().replace(/\s+/g, " ");
|
||||
const clipped =
|
||||
normalized.length > 500
|
||||
? `${normalized.slice(0, 500).trim()}…`
|
||||
: normalized;
|
||||
|
||||
setSelectedText((prev) => (prev === clipped ? prev : clipped));
|
||||
};
|
||||
|
||||
document.addEventListener("selectionchange", handleSelectionChange);
|
||||
return () => {
|
||||
document.removeEventListener("selectionchange", handleSelectionChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const hasMessages = messages.length > 0;
|
||||
|
||||
// 处理建议点击
|
||||
const handleSuggestionClick = useCallback(
|
||||
(prompt: string) => {
|
||||
sendMessage(prompt);
|
||||
},
|
||||
[sendMessage],
|
||||
);
|
||||
|
||||
// 处理删除消息
|
||||
const handleDeleteMessage = useCallback((id: string) => {
|
||||
// TODO: 实现单条消息删除
|
||||
console.log("删除消息:", id);
|
||||
}, []);
|
||||
|
||||
// 处理重试消息
|
||||
const handleRetryMessage = useCallback(
|
||||
(_id: string) => {
|
||||
retryLastMessage();
|
||||
},
|
||||
[retryLastMessage],
|
||||
);
|
||||
|
||||
// 处理主题变更
|
||||
const handleThemeChange = useCallback((theme: ThemeType) => {
|
||||
setCurrentTheme(theme);
|
||||
// TODO: 切换到创作模式
|
||||
console.log("切换主题:", theme);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<ChatArea>
|
||||
{error && <ErrorBanner>⚠️ {error}</ErrorBanner>}
|
||||
|
||||
{hasMessages ? (
|
||||
<MessageList
|
||||
messages={messages}
|
||||
isGenerating={isGenerating}
|
||||
onDeleteMessage={handleDeleteMessage}
|
||||
onRetryMessage={handleRetryMessage}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
onSuggestionClick={handleSuggestionClick}
|
||||
activeTheme={currentTheme}
|
||||
selectedText={selectedText}
|
||||
/>
|
||||
)}
|
||||
</ChatArea>
|
||||
|
||||
<InputBar
|
||||
onSend={sendMessage}
|
||||
isGenerating={isGenerating}
|
||||
onStop={stopGeneration}
|
||||
placeholder={
|
||||
currentTheme === "general"
|
||||
? "输入消息,按 Enter 发送..."
|
||||
: `开始${currentTheme}创作...`
|
||||
}
|
||||
/>
|
||||
|
||||
{!hasMessages && (
|
||||
<ThemeSelector
|
||||
currentTheme={currentTheme}
|
||||
onThemeChange={handleThemeChange}
|
||||
/>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
});
|
||||
export const ChatPage: React.FC = memo(() => <GeneralChatPage />);
|
||||
|
||||
ChatPage.displayName = "ChatPage";
|
||||
|
||||
@@ -4,50 +4,50 @@
|
||||
|
||||
## 架构说明
|
||||
|
||||
通用对话模块 - ProxyCast 的核心功能。提供即时对话能力,支持 Markdown 渲染、代码高亮、流式响应。
|
||||
该目录现阶段仅保留 **兼容入口**,用于承接历史 `components/chat` 依赖。
|
||||
现役通用对话实现已经迁移到 `src/components/general-chat/`,不要再在这里新增业务逻辑。
|
||||
|
||||
## 功能特性
|
||||
## 当前定位
|
||||
|
||||
- **即时对话**:打开即用,无需选择主题
|
||||
- **Markdown 渲染**:支持标题、列表、粗体、斜体、链接等
|
||||
- **代码高亮**:支持 12+ 种编程语言语法高亮
|
||||
- **一键复制**:代码块支持一键复制
|
||||
- **流式响应**:打字机效果,实时显示 AI 回复
|
||||
- **主题选择**:底部提供创作主题入口
|
||||
- **兼容包装**:保留旧导入路径,避免一次性打爆历史调用方
|
||||
- **单一事实源**:真实会话、消息、流式状态统一以 `general-chat` Store 和后端 compat 命令为准
|
||||
- **禁止扩散**:该目录下文件只能做委托、适配、废弃标记,不再维护独立状态机
|
||||
|
||||
## 文件索引
|
||||
|
||||
- `index.ts` - 模块导出入口
|
||||
- `ChatPage.tsx` - 通用对话主页面
|
||||
- `ChatPage.tsx` - `GeneralChatPage` 的兼容包装层
|
||||
- `types.ts` - 类型定义
|
||||
|
||||
### components/
|
||||
|
||||
- `CodeBlock.tsx` - 代码块组件(语法高亮 + 复制)
|
||||
- `MessageItem.tsx` - 单条消息组件
|
||||
- `MessageList.tsx` - 消息列表组件
|
||||
- `InputBar.tsx` - 输入栏组件
|
||||
- `ThemeSelector.tsx` - 主题选择器
|
||||
- `ModeSelector.tsx` - 创作模式选择器
|
||||
- `EmptyState.tsx` - 空状态欢迎界面
|
||||
- `index.ts` - 组件导出入口
|
||||
- `*.tsx` - 历史 UI 资产源码,仅保留参考和兼容排障价值
|
||||
- `index.ts` - 空壳兼容入口,不再导出旧组件
|
||||
|
||||
### hooks/
|
||||
### hooks/(兼容层)
|
||||
|
||||
- `useChat.ts` - 对话状态管理 Hook
|
||||
- `useStreaming.ts` - 流式响应处理 Hook
|
||||
- `useChat.ts` - 委托到 `general-chat` Store 的兼容 Hook
|
||||
- `useStreaming.ts` - 历史遗留流式 Hook,已停止维护,不再从模块根入口导出
|
||||
- `index.ts` - Hooks 导出入口
|
||||
|
||||
## 使用示例
|
||||
## 推荐用法
|
||||
|
||||
```tsx
|
||||
import { ChatPage } from '@/components/chat'
|
||||
import { useUnifiedChat } from "@/hooks/useUnifiedChat";
|
||||
|
||||
function App() {
|
||||
return <ChatPage />
|
||||
function Example() {
|
||||
const chat = useUnifiedChat({ mode: "general" });
|
||||
|
||||
return <button onClick={() => void chat.sendMessage("你好")}>发送</button>;
|
||||
}
|
||||
```
|
||||
|
||||
- 页面层不要新增 `ChatPage` / `GeneralChatPage` 依赖,请走现有工作台或路由入口。
|
||||
- 新的对话逻辑请优先基于 `@/hooks/useUnifiedChat`。
|
||||
- `@/components/chat` 模块根入口现仅保留 `ChatPage`、基础消息类型和 `useChat` 兼容导出。
|
||||
- `@/components/chat/components` 已不再导出任何组件,避免旧 UI 资产继续扩散。
|
||||
- 如必须兼容旧代码,`@/components/chat` 仍可继续导入,但应尽快迁移到统一对话链路。
|
||||
|
||||
## 更新提醒
|
||||
|
||||
任何文件变更后,请更新此文档和相关的上级文档。
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
Lightbulb,
|
||||
} from "lucide-react";
|
||||
import { ProjectSelector } from "@/components/projects/ProjectSelector";
|
||||
import { getConfig } from "@/hooks/useTauri";
|
||||
import { getConfig } from "@/lib/api/appConfig";
|
||||
import type { ThemeType } from "../types";
|
||||
import {
|
||||
buildRecommendationPrompt,
|
||||
@@ -180,8 +180,10 @@ export const EmptyState: React.FC<EmptyStateProps> = memo(
|
||||
const [localProjectId, setLocalProjectId] = useState<string | null>(
|
||||
selectedProjectId || null,
|
||||
);
|
||||
const [appendSelectedTextToRecommendation, setAppendSelectedTextToRecommendation] =
|
||||
useState(true);
|
||||
const [
|
||||
appendSelectedTextToRecommendation,
|
||||
setAppendSelectedTextToRecommendation,
|
||||
] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const loadConfigPreferences = async () => {
|
||||
@@ -227,18 +229,16 @@ export const EmptyState: React.FC<EmptyStateProps> = memo(
|
||||
selectedText: recommendationSelectedText,
|
||||
});
|
||||
|
||||
return recommendationTuples
|
||||
.slice(0, 4)
|
||||
.map(([title, prompt], index) => ({
|
||||
icon: SUGGESTION_ICONS[index % SUGGESTION_ICONS.length],
|
||||
title,
|
||||
desc: prompt,
|
||||
prompt: buildRecommendationPrompt(
|
||||
prompt,
|
||||
selectedText,
|
||||
appendSelectedTextToRecommendation,
|
||||
),
|
||||
}));
|
||||
return recommendationTuples.slice(0, 4).map(([title, prompt], index) => ({
|
||||
icon: SUGGESTION_ICONS[index % SUGGESTION_ICONS.length],
|
||||
title,
|
||||
desc: prompt,
|
||||
prompt: buildRecommendationPrompt(
|
||||
prompt,
|
||||
selectedText,
|
||||
appendSelectedTextToRecommendation,
|
||||
),
|
||||
}));
|
||||
}, [
|
||||
recommendationTheme,
|
||||
recommendationSelectedText,
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
/**
|
||||
* @file 组件导出入口
|
||||
* @description 导出通用对话相关的组件
|
||||
* @description 遗留通用对话组件兼容占位入口
|
||||
* @module components/chat/components
|
||||
*/
|
||||
|
||||
export { CodeBlock } from "./CodeBlock";
|
||||
export { MessageItem } from "./MessageItem";
|
||||
export { MessageList } from "./MessageList";
|
||||
export { InputBar } from "./InputBar";
|
||||
export { ThemeSelector } from "./ThemeSelector";
|
||||
export { ModeSelector } from "./ModeSelector";
|
||||
export type { CreationMode } from "./ModeSelector";
|
||||
export { EmptyState } from "./EmptyState";
|
||||
// 该目录下组件已降级为历史 UI 资产:
|
||||
// - 不再从 barrel 导出
|
||||
// - 不作为正式复用入口
|
||||
// - 仅保留源码,供历史兼容排障参考
|
||||
export {};
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
/**
|
||||
* @file Hooks 导出入口
|
||||
* @description 导出通用对话相关的 Hooks
|
||||
* @description 遗留通用对话 Hooks 兼容导出
|
||||
* @module components/chat/hooks
|
||||
*/
|
||||
|
||||
export { useChat } from "./useChat";
|
||||
export { useStreaming } from "./useStreaming";
|
||||
|
||||
@@ -1,145 +1,139 @@
|
||||
/**
|
||||
* @file useChat Hook
|
||||
* @description 通用对话状态管理 Hook
|
||||
* @description 遗留通用对话兼容 Hook
|
||||
* @module components/chat/hooks/useChat
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import {
|
||||
useGeneralChatStore,
|
||||
type GeneralChatState,
|
||||
} from "@/components/general-chat/store/useGeneralChatStore";
|
||||
import type { Message as GeneralChatMessage } from "@/components/general-chat/bridge";
|
||||
import { Message, ChatState, ChatActions } from "../types";
|
||||
import { useStreaming } from "./useStreaming";
|
||||
|
||||
const EMPTY_MESSAGES: GeneralChatMessage[] = [];
|
||||
|
||||
const getMessageContent = (message: GeneralChatMessage): string => {
|
||||
if (message.content.trim()) {
|
||||
return message.content;
|
||||
}
|
||||
|
||||
const textContent = message.blocks
|
||||
.filter((block) => block.type === "text")
|
||||
.map((block) => block.content)
|
||||
.join("\n")
|
||||
.trim();
|
||||
|
||||
return textContent;
|
||||
};
|
||||
|
||||
const toLegacyMessage = (message: GeneralChatMessage): Message => ({
|
||||
id: message.id,
|
||||
role: message.role,
|
||||
content: getMessageContent(message),
|
||||
timestamp: message.createdAt,
|
||||
metadata: message.metadata
|
||||
? {
|
||||
model: message.metadata.model,
|
||||
tokens: message.metadata.tokens,
|
||||
duration: message.metadata.duration,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const selectCurrentMessages = (
|
||||
state: GeneralChatState,
|
||||
): GeneralChatMessage[] => {
|
||||
if (!state.currentSessionId) {
|
||||
return EMPTY_MESSAGES;
|
||||
}
|
||||
|
||||
return state.messages[state.currentSessionId] || EMPTY_MESSAGES;
|
||||
};
|
||||
|
||||
/**
|
||||
* 生成唯一 ID
|
||||
*/
|
||||
function generateId(): string {
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用对话状态管理 Hook
|
||||
* 遗留通用对话兼容 Hook
|
||||
*
|
||||
* 提供消息管理、发送、重试、停止等功能
|
||||
* 兼容旧 `components/chat` 调用方,但内部已完全委托给
|
||||
* `general-chat` Store,避免继续维护第二套聊天状态机。
|
||||
*
|
||||
* @returns 对话状态和操作方法
|
||||
* @deprecated 遗留聊天 Hook。禁止新增依赖,请优先使用 @/hooks/useUnifiedChat 或现役聊天入口。
|
||||
*/
|
||||
export function useChat(): ChatState & ChatActions {
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const currentAiMessageIdRef = useRef<string | null>(null);
|
||||
const currentMessages = useGeneralChatStore(selectCurrentMessages);
|
||||
const isGenerating = useGeneralChatStore(
|
||||
(state) => state.streaming.isStreaming,
|
||||
);
|
||||
const sendMessageInStore = useGeneralChatStore((state) => state.sendMessage);
|
||||
const stopGenerationInStore = useGeneralChatStore(
|
||||
(state) => state.stopGeneration,
|
||||
);
|
||||
const retryMessageInStore = useGeneralChatStore(
|
||||
(state) => state.retryMessage,
|
||||
);
|
||||
const createSessionInStore = useGeneralChatStore(
|
||||
(state) => state.createSession,
|
||||
);
|
||||
|
||||
const { streamChat } = useStreaming();
|
||||
const messages = useMemo(
|
||||
() => currentMessages.map(toLegacyMessage),
|
||||
[currentMessages],
|
||||
);
|
||||
|
||||
const error = useMemo(() => {
|
||||
const latestMessage = currentMessages[currentMessages.length - 1];
|
||||
if (latestMessage?.status !== "error") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return latestMessage.error?.message || null;
|
||||
}, [currentMessages]);
|
||||
|
||||
/**
|
||||
* 发送消息
|
||||
*/
|
||||
const sendMessage = useCallback(
|
||||
async (content: string) => {
|
||||
if (!content.trim() || isGenerating) return;
|
||||
|
||||
setError(null);
|
||||
|
||||
// 添加用户消息
|
||||
const userMessage: Message = {
|
||||
id: generateId(),
|
||||
role: "user",
|
||||
content: content.trim(),
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
// 创建 AI 消息占位
|
||||
const aiMessage: Message = {
|
||||
id: generateId(),
|
||||
role: "assistant",
|
||||
content: "",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
currentAiMessageIdRef.current = aiMessage.id;
|
||||
setMessages((prev) => [...prev, userMessage, aiMessage]);
|
||||
setIsGenerating(true);
|
||||
|
||||
// 创建 AbortController
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
await streamChat(
|
||||
[...messages, userMessage],
|
||||
(chunk) => {
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
msg.id === aiMessage.id
|
||||
? { ...msg, content: msg.content + chunk }
|
||||
: msg,
|
||||
),
|
||||
);
|
||||
},
|
||||
abortControllerRef.current.signal,
|
||||
);
|
||||
|
||||
// 更新元数据
|
||||
const duration = Date.now() - startTime;
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
msg.id === aiMessage.id
|
||||
? { ...msg, metadata: { ...msg.metadata, duration } }
|
||||
: msg,
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name !== "AbortError") {
|
||||
setError(err.message);
|
||||
// 移除空的 AI 消息
|
||||
setMessages((prev) => prev.filter((msg) => msg.id !== aiMessage.id));
|
||||
}
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
abortControllerRef.current = null;
|
||||
currentAiMessageIdRef.current = null;
|
||||
}
|
||||
await sendMessageInStore(content);
|
||||
},
|
||||
[messages, isGenerating, streamChat],
|
||||
[sendMessageInStore],
|
||||
);
|
||||
|
||||
/**
|
||||
* 清空消息
|
||||
* 清空消息(兼容语义:新建空白会话,而非删除已有历史)
|
||||
*/
|
||||
const clearMessages = useCallback(() => {
|
||||
setMessages([]);
|
||||
setError(null);
|
||||
}, []);
|
||||
stopGenerationInStore();
|
||||
void createSessionInStore().catch((createSessionError) => {
|
||||
console.error("兼容 clearMessages 创建新会话失败:", createSessionError);
|
||||
});
|
||||
}, [createSessionInStore, stopGenerationInStore]);
|
||||
|
||||
/**
|
||||
* 重试最后一条消息
|
||||
* 重试最后一条错误消息
|
||||
*/
|
||||
const retryLastMessage = useCallback(async () => {
|
||||
const lastUserMessage = [...messages]
|
||||
const lastErrorAssistantMessage = [...currentMessages]
|
||||
.reverse()
|
||||
.find((m) => m.role === "user");
|
||||
if (lastUserMessage) {
|
||||
// 移除最后一条 AI 消息
|
||||
setMessages((prev) => {
|
||||
const lastAiIndex = [...prev]
|
||||
.reverse()
|
||||
.findIndex((m: Message) => m.role === "assistant");
|
||||
if (lastAiIndex > -1) {
|
||||
return prev.slice(0, prev.length - 1 - lastAiIndex);
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
await sendMessage(lastUserMessage.content);
|
||||
.find(
|
||||
(message) => message.role === "assistant" && message.status === "error",
|
||||
);
|
||||
|
||||
if (!lastErrorAssistantMessage) {
|
||||
return;
|
||||
}
|
||||
}, [messages, sendMessage]);
|
||||
|
||||
await retryMessageInStore(lastErrorAssistantMessage.id);
|
||||
}, [currentMessages, retryMessageInStore]);
|
||||
|
||||
/**
|
||||
* 停止生成
|
||||
*/
|
||||
const stopGeneration = useCallback(() => {
|
||||
abortControllerRef.current?.abort();
|
||||
}, []);
|
||||
stopGenerationInStore();
|
||||
}, [stopGenerationInStore]);
|
||||
|
||||
return {
|
||||
messages,
|
||||
|
||||
@@ -1,22 +1,12 @@
|
||||
/**
|
||||
* @file useStreaming Hook
|
||||
* @description 流式响应处理 Hook
|
||||
* @description 遗留流式兼容 Hook
|
||||
* @module components/chat/hooks/useStreaming
|
||||
*/
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { safeInvoke, safeListen } from "@/lib/dev-bridge";
|
||||
import type { UnlistenFn } from "@tauri-apps/api/event";
|
||||
import { Message } from "../types";
|
||||
|
||||
/**
|
||||
* 流式响应事件数据
|
||||
*/
|
||||
interface StreamChunkEvent {
|
||||
content: string;
|
||||
done: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 流式对话选项
|
||||
*/
|
||||
@@ -26,11 +16,13 @@ interface StreamChatOptions {
|
||||
}
|
||||
|
||||
/**
|
||||
* 流式响应处理 Hook
|
||||
* 遗留流式兼容 Hook
|
||||
*
|
||||
* 提供与后端的流式通信能力
|
||||
* 该 Hook 曾依赖已废弃的 `agent_chat_stream` 命令。
|
||||
* 为避免继续扩散旧链路,这里只保留兼容 API 形状,并显式提示调用方迁移。
|
||||
*
|
||||
* @returns 流式对话方法
|
||||
* @deprecated 禁止新增依赖,请迁移到 `@/components/general-chat` 或统一对话链路。
|
||||
*/
|
||||
export function useStreaming() {
|
||||
/**
|
||||
@@ -43,51 +35,14 @@ export function useStreaming() {
|
||||
*/
|
||||
const streamChat = useCallback(
|
||||
async (
|
||||
messages: Message[],
|
||||
onChunk: (chunk: string) => void,
|
||||
signal?: AbortSignal,
|
||||
options?: StreamChatOptions,
|
||||
_messages: Message[],
|
||||
_onChunk: (chunk: string) => void,
|
||||
_signal?: AbortSignal,
|
||||
_options?: StreamChatOptions,
|
||||
): Promise<void> => {
|
||||
let unlisten: UnlistenFn | null = null;
|
||||
let isAborted = false;
|
||||
|
||||
// 监听中断信号
|
||||
if (signal) {
|
||||
signal.addEventListener("abort", () => {
|
||||
isAborted = true;
|
||||
unlisten?.();
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// 监听流式响应事件
|
||||
unlisten = await safeListen<StreamChunkEvent>(
|
||||
"chat-stream-chunk",
|
||||
(event) => {
|
||||
if (isAborted) return;
|
||||
if (event.payload.content) {
|
||||
onChunk(event.payload.content);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// 调用 Tauri 命令开始流式对话
|
||||
// 注意:这里使用现有的 agent_chat_stream 命令
|
||||
// 如果需要独立的通用对话命令,可以后续添加
|
||||
await safeInvoke("agent_chat_stream", {
|
||||
messages: messages.map((m) => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
})),
|
||||
projectId: options?.projectId,
|
||||
});
|
||||
} catch (err) {
|
||||
if (!isAborted) {
|
||||
throw err;
|
||||
}
|
||||
} finally {
|
||||
unlisten?.();
|
||||
}
|
||||
throw new Error(
|
||||
"components/chat/hooks/useStreaming 已停止维护,请迁移到 general-chat 或统一对话链路。",
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
/**
|
||||
* @file 通用对话模块入口
|
||||
* @description 导出通用对话相关的组件和类型
|
||||
* @description 遗留通用对话兼容入口
|
||||
* @module components/chat
|
||||
*/
|
||||
|
||||
export { ChatPage } from "./ChatPage";
|
||||
export * from "./types";
|
||||
export * from "./hooks";
|
||||
export * from "./components";
|
||||
export { useChat } from "./hooks/useChat";
|
||||
export type { MessageRole, Message, ChatState, ChatActions } from "./types";
|
||||
|
||||
@@ -60,6 +60,8 @@ export interface ChatActions {
|
||||
|
||||
/**
|
||||
* 主题类型
|
||||
*
|
||||
* @deprecated 仅供遗留 `components/chat` UI 资产使用,请改用现役内容创作或统一对话主题类型。
|
||||
*/
|
||||
export type ThemeType =
|
||||
| "general" // 通用对话(默认)
|
||||
@@ -76,6 +78,8 @@ export type ThemeType =
|
||||
|
||||
/**
|
||||
* 主题配置
|
||||
*
|
||||
* @deprecated 仅供遗留 `components/chat` UI 资产使用。
|
||||
*/
|
||||
export interface ThemeConfig {
|
||||
id: ThemeType;
|
||||
@@ -86,6 +90,8 @@ export interface ThemeConfig {
|
||||
|
||||
/**
|
||||
* 主题配置列表
|
||||
*
|
||||
* @deprecated 仅供遗留 `components/chat` UI 资产使用。
|
||||
*/
|
||||
export const THEME_CONFIGS: ThemeConfig[] = [
|
||||
{
|
||||
|
||||
@@ -121,8 +121,7 @@ export function LiveConfigModal({ appType, onClose }: LiveConfigModalProps) {
|
||||
<div className="p-4 rounded-lg bg-muted/30 text-sm text-muted-foreground border border-dashed">
|
||||
<p className="mb-2">暂无环境变量配置</p>
|
||||
<p className="text-xs">
|
||||
💡 提示:切换 Claude 供应商后,ProxyCast 会自动将配置写入
|
||||
shell 配置文件
|
||||
💡 提示:这里展示的是兼容外部客户端的 Shell 写入结果;运行时统一环境请以系统设置中的“环境变量”页为准。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -140,7 +139,7 @@ export function LiveConfigModal({ appType, onClose }: LiveConfigModalProps) {
|
||||
<div className="p-4 border-t bg-muted/30">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{appType === "claude" && claudeConfig ? (
|
||||
<>配置方式:配置文件 + Shell 环境变量(需重启终端生效)</>
|
||||
<>兼容输出:配置文件 + Shell 环境变量;运行时主入口已统一到系统设置的“环境变量”页</>
|
||||
) : (
|
||||
<>
|
||||
配置文件路径:{" "}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useMemo, useCallback, useEffect } from "react";
|
||||
import { X, ExternalLink, Wand2, Eye, EyeOff, Database } from "lucide-react";
|
||||
import { Provider, AppType } from "@/lib/api/switch";
|
||||
import { getConfig } from "@/hooks/useTauri";
|
||||
import { getConfig } from "@/lib/api/appConfig";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ProviderIcon } from "@/icons/providers";
|
||||
import {
|
||||
|
||||
@@ -8,10 +8,13 @@ import React, {
|
||||
} from "react";
|
||||
import styled from "styled-components";
|
||||
import { Video } from "lucide-react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { toast } from "sonner";
|
||||
import { VideoCanvasState } from "./types";
|
||||
import { PromptInput } from "./PromptInput";
|
||||
import {
|
||||
importMaterialFromUrl,
|
||||
type ImportMaterialFromUrlRequest,
|
||||
} from "@/lib/api/materials";
|
||||
import {
|
||||
videoGenerationApi,
|
||||
type VideoGenerationTask,
|
||||
@@ -151,15 +154,6 @@ const TaskPrompt = styled.div`
|
||||
-webkit-line-clamp: 2;
|
||||
`;
|
||||
|
||||
interface ImportMaterialFromUrlRequest {
|
||||
projectId: string;
|
||||
name: string;
|
||||
type: "video" | "image";
|
||||
url: string;
|
||||
tags?: string[];
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface WorkspaceTask extends VideoGenerationTask {
|
||||
resourceMaterialId?: string;
|
||||
resourceSavedAt?: number;
|
||||
@@ -315,12 +309,7 @@ export const VideoWorkspace: React.FC<VideoWorkspaceProps> = memo(
|
||||
tags: [VIDEO_TASK_TAG],
|
||||
description: `视频生成自动入库(服务:${task.providerId},模型:${task.model})`,
|
||||
};
|
||||
const savedMaterial = await invoke<{ id: string }>(
|
||||
"import_material_from_url",
|
||||
{
|
||||
req: request,
|
||||
},
|
||||
);
|
||||
const savedMaterial = await importMaterialFromUrl(request);
|
||||
|
||||
setTasks((previous) =>
|
||||
previous.map((item) =>
|
||||
@@ -485,9 +474,7 @@ export const VideoWorkspace: React.FC<VideoWorkspaceProps> = memo(
|
||||
? "视频生成首帧参考图(自动上传)"
|
||||
: "视频生成尾帧参考图(自动上传)",
|
||||
};
|
||||
const material = await invoke<{ id: string }>("import_material_from_url", {
|
||||
req: request,
|
||||
});
|
||||
const material = await importMaterialFromUrl(request);
|
||||
|
||||
const materialUrl = `material://${material.id}`;
|
||||
materialRefCache.current.set(normalizedUrl, materialUrl);
|
||||
@@ -496,77 +483,82 @@ export const VideoWorkspace: React.FC<VideoWorkspaceProps> = memo(
|
||||
[projectId],
|
||||
);
|
||||
|
||||
const handleGenerate = useCallback(async (textOverride?: string) => {
|
||||
if (!projectId) {
|
||||
toast.error("请先选择项目后再生成视频");
|
||||
return;
|
||||
}
|
||||
if (!state.providerId) {
|
||||
toast.error("请选择视频服务");
|
||||
return;
|
||||
}
|
||||
if (!state.model) {
|
||||
toast.error("请选择视频模型");
|
||||
return;
|
||||
}
|
||||
const promptText = textOverride || state.prompt.trim();
|
||||
if (!promptText) {
|
||||
toast.error("请输入视频描述");
|
||||
return;
|
||||
}
|
||||
const providerNormalized = state.providerId.trim().toLowerCase();
|
||||
const supportedProvider =
|
||||
providerNormalized.includes("doubao") ||
|
||||
providerNormalized.includes("volc") ||
|
||||
providerNormalized.includes("dashscope") ||
|
||||
providerNormalized.includes("alibaba") ||
|
||||
providerNormalized.includes("qwen");
|
||||
if (!supportedProvider) {
|
||||
toast.error("当前仅支持火山或阿里兼容视频服务");
|
||||
return;
|
||||
}
|
||||
const handleGenerate = useCallback(
|
||||
async (textOverride?: string) => {
|
||||
if (!projectId) {
|
||||
toast.error("请先选择项目后再生成视频");
|
||||
return;
|
||||
}
|
||||
if (!state.providerId) {
|
||||
toast.error("请选择视频服务");
|
||||
return;
|
||||
}
|
||||
if (!state.model) {
|
||||
toast.error("请选择视频模型");
|
||||
return;
|
||||
}
|
||||
const promptText = textOverride || state.prompt.trim();
|
||||
if (!promptText) {
|
||||
toast.error("请输入视频描述");
|
||||
return;
|
||||
}
|
||||
const providerNormalized = state.providerId.trim().toLowerCase();
|
||||
const supportedProvider =
|
||||
providerNormalized.includes("doubao") ||
|
||||
providerNormalized.includes("volc") ||
|
||||
providerNormalized.includes("dashscope") ||
|
||||
providerNormalized.includes("alibaba") ||
|
||||
providerNormalized.includes("qwen");
|
||||
if (!supportedProvider) {
|
||||
toast.error("当前仅支持火山或阿里兼容视频服务");
|
||||
return;
|
||||
}
|
||||
|
||||
onStateChange({
|
||||
...state,
|
||||
status: "generating",
|
||||
errorMessage: undefined,
|
||||
});
|
||||
try {
|
||||
const [resolvedStartImageUrl, resolvedEndImageUrl] = await Promise.all([
|
||||
ensureReferenceImageUrl(state.startImage, "start"),
|
||||
ensureReferenceImageUrl(state.endImage, "end"),
|
||||
]);
|
||||
|
||||
const created = await videoGenerationApi.createTask({
|
||||
projectId,
|
||||
providerId: state.providerId,
|
||||
model: state.model,
|
||||
prompt: promptText,
|
||||
aspectRatio: state.aspectRatio,
|
||||
resolution: state.resolution,
|
||||
duration: state.duration,
|
||||
imageUrl: resolvedStartImageUrl,
|
||||
endImageUrl: resolvedEndImageUrl,
|
||||
seed: state.seed,
|
||||
generateAudio: state.generateAudio,
|
||||
cameraFixed: state.cameraFixed,
|
||||
});
|
||||
|
||||
setTasks((previous) => {
|
||||
const merged = mergeTaskList(previous, [created]);
|
||||
return merged;
|
||||
});
|
||||
toast.success("视频任务已提交,正在生成");
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
onStateChange({
|
||||
...state,
|
||||
status: "error",
|
||||
errorMessage: message,
|
||||
status: "generating",
|
||||
errorMessage: undefined,
|
||||
});
|
||||
toast.error(message);
|
||||
}
|
||||
}, [ensureReferenceImageUrl, onStateChange, projectId, state]);
|
||||
try {
|
||||
const [resolvedStartImageUrl, resolvedEndImageUrl] =
|
||||
await Promise.all([
|
||||
ensureReferenceImageUrl(state.startImage, "start"),
|
||||
ensureReferenceImageUrl(state.endImage, "end"),
|
||||
]);
|
||||
|
||||
const created = await videoGenerationApi.createTask({
|
||||
projectId,
|
||||
providerId: state.providerId,
|
||||
model: state.model,
|
||||
prompt: promptText,
|
||||
aspectRatio: state.aspectRatio,
|
||||
resolution: state.resolution,
|
||||
duration: state.duration,
|
||||
imageUrl: resolvedStartImageUrl,
|
||||
endImageUrl: resolvedEndImageUrl,
|
||||
seed: state.seed,
|
||||
generateAudio: state.generateAudio,
|
||||
cameraFixed: state.cameraFixed,
|
||||
});
|
||||
|
||||
setTasks((previous) => {
|
||||
const merged = mergeTaskList(previous, [created]);
|
||||
return merged;
|
||||
});
|
||||
toast.success("视频任务已提交,正在生成");
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
onStateChange({
|
||||
...state,
|
||||
status: "error",
|
||||
errorMessage: message,
|
||||
});
|
||||
toast.error(message);
|
||||
}
|
||||
},
|
||||
[ensureReferenceImageUrl, onStateChange, projectId, state],
|
||||
);
|
||||
|
||||
const isGenerated = tasks.length > 0 || state.status !== "idle";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @file GeneralChatPage.tsx
|
||||
* @description 通用对话主页面 - 三栏布局
|
||||
* @description 通用对话主页面 - 三栏布局(旧 general-chat 兼容入口)
|
||||
* @module components/general-chat/GeneralChatPage
|
||||
*
|
||||
* @requirements 3.1, 3.5, 9.4
|
||||
@@ -11,6 +11,7 @@ import { ChatPanel } from "./chat/ChatPanel";
|
||||
import { CanvasPanel } from "./canvas/CanvasPanel";
|
||||
import { ErrorBoundary } from "./chat/ErrorBoundary";
|
||||
import { useGeneralChatStore } from "./store/useGeneralChatStore";
|
||||
import { useStreaming } from "./hooks/useStreaming";
|
||||
import type { CanvasState, GeneralChatPageProps } from "./types";
|
||||
import { DEFAULT_CANVAS_STATE } from "./types";
|
||||
|
||||
@@ -21,41 +22,59 @@ import { DEFAULT_CANVAS_STATE } from "./types";
|
||||
* - 左侧:会话列表(复用 ChatSidebar)
|
||||
* - 中间:聊天区域
|
||||
* - 右侧:画布面板(可折叠)
|
||||
*
|
||||
* @deprecated 该页面仅用于兼容旧版 general-chat 链路,新功能请优先接入统一对话入口。
|
||||
*/
|
||||
export const GeneralChatPage: React.FC<GeneralChatPageProps> = ({
|
||||
initialSessionId,
|
||||
onNavigate,
|
||||
}) => {
|
||||
const { currentSessionId, selectSession, sessions, createSession } =
|
||||
const { currentSessionId, selectSession, createSession, hydrateSessions } =
|
||||
useGeneralChatStore();
|
||||
|
||||
// 画布状态
|
||||
const [canvasState, setCanvasState] =
|
||||
useState<CanvasState>(DEFAULT_CANVAS_STATE);
|
||||
|
||||
// 使用 ref 防止重复创建会话
|
||||
// 使用 ref 防止 StrictMode 下重复初始化
|
||||
const hydratedRef = useRef(false);
|
||||
const sessionCreatedRef = useRef(false);
|
||||
|
||||
// 初始化:如果有初始会话 ID,选择它;否则如果没有会话,创建一个
|
||||
// 接入现役 Aster 流式事件,避免发送后只停留在占位消息。
|
||||
useStreaming({ sessionId: currentSessionId });
|
||||
|
||||
// 初始化:先从后端 hydrate 会话,再决定是否创建默认会话。
|
||||
useEffect(() => {
|
||||
if (initialSessionId) {
|
||||
selectSession(initialSessionId);
|
||||
} else if (
|
||||
sessions.length === 0 &&
|
||||
!currentSessionId &&
|
||||
!sessionCreatedRef.current
|
||||
) {
|
||||
// 如果没有会话,创建一个新会话(只创建一次)
|
||||
sessionCreatedRef.current = true;
|
||||
createSession();
|
||||
if (hydratedRef.current) {
|
||||
return;
|
||||
}
|
||||
}, [
|
||||
initialSessionId,
|
||||
selectSession,
|
||||
sessions.length,
|
||||
currentSessionId,
|
||||
createSession,
|
||||
]);
|
||||
|
||||
hydratedRef.current = true;
|
||||
let cancelled = false;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const hydratedSessionId = await hydrateSessions(initialSessionId);
|
||||
|
||||
if (!cancelled && !hydratedSessionId && !sessionCreatedRef.current) {
|
||||
sessionCreatedRef.current = true;
|
||||
await createSession();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[GeneralChatPage] 初始化会话失败:", error);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [initialSessionId, hydrateSessions, createSession]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialSessionId && initialSessionId !== currentSessionId) {
|
||||
selectSession(initialSessionId);
|
||||
}
|
||||
}, [initialSessionId, currentSessionId, selectSession]);
|
||||
|
||||
// 打开画布
|
||||
const handleOpenCanvas = useCallback((state: CanvasState) => {
|
||||
|
||||
@@ -7,7 +7,12 @@
|
||||
通用对话功能模块,提供简洁高效的 AI 对话体验。
|
||||
采用三栏布局架构:左侧会话列表 + 中间聊天区域 + 右侧画布面板。
|
||||
|
||||
> 治理说明:`GeneralChatPage` 仍是现役页面容器,但已不鼓励业务代码继续直接 import 页面入口;
|
||||
> 新逻辑优先走统一对话链路(如 `@/hooks/useUnifiedChat`)或现有工作台/路由接入。
|
||||
> 如必须跨模块复用 `general-chat` 的少量能力,请优先走 `bridge.ts`,不要直接深导入内部目录。
|
||||
|
||||
**多模态支持**: 完整支持图片上传、显示和处理,包括:
|
||||
|
||||
- 图片上传(拖拽、点击上传)
|
||||
- 图片预览和下载
|
||||
- 支持 JPEG、PNG、GIF、WebP 格式
|
||||
@@ -26,7 +31,8 @@
|
||||
|
||||
## 文件索引
|
||||
|
||||
- `index.tsx` - 主入口导出
|
||||
- `index.tsx` - 兼容根入口,仅导出 `GeneralChatPage`
|
||||
- `bridge.ts` - 对外桥接层,仅暴露少量跨模块允许复用的稳定能力
|
||||
- `GeneralChatPage.tsx` - 页面容器(三栏布局)
|
||||
- `types.ts` - 核心类型定义
|
||||
- Session、Message、ContentBlock 等数据类型
|
||||
@@ -71,15 +77,18 @@
|
||||
- `CanvasToolbar.tsx` - 画布工具栏
|
||||
|
||||
- `store/` - 状态管理
|
||||
- `useGeneralChatStore.ts` - 主 Store
|
||||
- `useGeneralChatStore.ts` - 主 Store(只消费 compat API 网关与现役 `agentRuntime`,不再直连 `general_chat_*` / `aster_*` 命令)
|
||||
- `sessionSlice.ts` - 会话状态切片
|
||||
- `messageSlice.ts` - 消息状态切片
|
||||
- `uiSlice.ts` - UI 状态切片
|
||||
|
||||
- `src/lib/api/generalChatCompat.ts` - general-chat compat API 网关(前端唯一允许直连 `general_chat_*` 命令的地方)
|
||||
- `src/lib/api/agentRuntime.ts` - Aster 运行时 API(general-chat 发送/停止流式响应统一走这里)
|
||||
|
||||
- `hooks/` - 自定义 Hooks
|
||||
- `useChat.ts` - 对话逻辑 Hook
|
||||
- `useStreaming.ts` - 流式响应 Hook
|
||||
- `useSession.ts` - 会话管理 Hook
|
||||
- `useChat.ts` - 旧对话逻辑 Hook(兼容层,不再从 barrel/root 导出)
|
||||
- `useStreaming.ts` - 旧流式响应 Hook(兼容层,不再从 barrel/root 导出)
|
||||
- `useSession.ts` - 旧会话管理 Hook(兼容层,不再从 barrel/root 导出)
|
||||
- `useCanvas.ts` - 画布控制 Hook
|
||||
- `useProvider.ts` - Provider 选择逻辑 Hook(复用 ProviderPool 系统)
|
||||
- 自动选择可用 Provider
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* @file bridge.ts
|
||||
* @description general-chat 对外桥接层
|
||||
* @module components/general-chat/bridge
|
||||
*
|
||||
* 仅用于其他模块在治理过渡期按需复用少量稳定能力,
|
||||
* 避免继续直接深挖 `general-chat` 内部实现目录。
|
||||
*/
|
||||
|
||||
export { CanvasPanel } from "./canvas";
|
||||
export { DEFAULT_CANVAS_STATE } from "./types";
|
||||
export type { CanvasState, Message } from "./types";
|
||||
@@ -1,11 +1,8 @@
|
||||
/**
|
||||
* @file index.ts
|
||||
* @description Hooks 导出
|
||||
* @description Hooks 导出(仅保留现役稳定入口)
|
||||
* @module components/general-chat/hooks
|
||||
*/
|
||||
|
||||
export { useStreaming } from "./useStreaming";
|
||||
export { useChat } from "./useChat";
|
||||
export { useSession } from "./useSession";
|
||||
export { useProvider } from "./useProvider";
|
||||
export type { UseProviderResult } from "./useProvider";
|
||||
|
||||
@@ -9,21 +9,9 @@
|
||||
*/
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useGeneralChatStore } from "../store/useGeneralChatStore";
|
||||
import type { Message, ProviderConfig } from "../types";
|
||||
|
||||
/**
|
||||
* 发送消息请求参数
|
||||
*/
|
||||
interface SendMessageRequest {
|
||||
session_id: string;
|
||||
content: string;
|
||||
event_name: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* useChat Hook 配置
|
||||
*/
|
||||
@@ -40,11 +28,16 @@ interface UseChatOptions {
|
||||
|
||||
/**
|
||||
* 聊天逻辑 Hook
|
||||
*
|
||||
* @deprecated general-chat 的旧聊天 Hook。禁止新增依赖,请优先使用 @/hooks/useUnifiedChat 或现役聊天入口。
|
||||
*/
|
||||
export const useChat = (options: UseChatOptions) => {
|
||||
const { sessionId, providerConfig, onMessageSent, onError } = options;
|
||||
const { sessionId, onMessageSent, onError } = options;
|
||||
|
||||
const { startStreaming } = useGeneralChatStore();
|
||||
const sendMessageInStore = useGeneralChatStore((state) => state.sendMessage);
|
||||
const stopGenerationInStore = useGeneralChatStore(
|
||||
(state) => state.stopGeneration,
|
||||
);
|
||||
|
||||
/**
|
||||
* 发送消息
|
||||
@@ -56,65 +49,45 @@ export const useChat = (options: UseChatOptions) => {
|
||||
}
|
||||
|
||||
try {
|
||||
// 构建事件名称
|
||||
const eventName = `general-chat-stream-${sessionId}`;
|
||||
|
||||
// 调用 Tauri 命令发送消息
|
||||
const request: SendMessageRequest = {
|
||||
session_id: sessionId,
|
||||
content: content.trim(),
|
||||
event_name: eventName,
|
||||
provider: providerConfig?.providerName,
|
||||
model: providerConfig?.modelName,
|
||||
};
|
||||
|
||||
const messageId = await invoke<string>("general_chat_send_message", {
|
||||
request,
|
||||
});
|
||||
|
||||
startStreaming(messageId);
|
||||
await sendMessageInStore(content.trim());
|
||||
|
||||
// 消息发送成功
|
||||
if (onMessageSent) {
|
||||
const message: Message = {
|
||||
id: messageId,
|
||||
sessionId,
|
||||
role: "assistant",
|
||||
content: "",
|
||||
blocks: [],
|
||||
status: "streaming",
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
onMessageSent(message);
|
||||
const { messages } = useGeneralChatStore.getState();
|
||||
const latestAssistantMessage = [...(messages[sessionId] || [])]
|
||||
.reverse()
|
||||
.find((message) => message.role === "assistant");
|
||||
|
||||
if (latestAssistantMessage) {
|
||||
onMessageSent(latestAssistantMessage);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// 停止流式状态
|
||||
const { stopGeneration } = useGeneralChatStore.getState();
|
||||
stopGeneration();
|
||||
stopGenerationInStore();
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
onError?.(errorMessage);
|
||||
}
|
||||
},
|
||||
[sessionId, providerConfig, startStreaming, onMessageSent, onError],
|
||||
[
|
||||
sessionId,
|
||||
sendMessageInStore,
|
||||
stopGenerationInStore,
|
||||
onMessageSent,
|
||||
onError,
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* 停止生成
|
||||
*/
|
||||
const stopGeneration = useCallback(async () => {
|
||||
if (!sessionId) return;
|
||||
|
||||
try {
|
||||
await invoke("general_chat_stop_generation", {
|
||||
sessionId,
|
||||
});
|
||||
const { stopGeneration: stopGen } = useGeneralChatStore.getState();
|
||||
stopGen();
|
||||
stopGenerationInStore();
|
||||
} catch (error) {
|
||||
console.error("停止生成失败:", error);
|
||||
}
|
||||
}, [sessionId]);
|
||||
}, [stopGenerationInStore]);
|
||||
|
||||
/**
|
||||
* 重新生成消息
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @file useSession.ts
|
||||
* @description 会话管理 Hook
|
||||
* @description 会话管理 Hook(旧 general-chat 兼容实现)
|
||||
* @module components/general-chat/hooks/useSession
|
||||
*
|
||||
* 封装会话加载、切换、自动标题生成等逻辑
|
||||
@@ -9,40 +9,7 @@
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useGeneralChatStore } from "../store/useGeneralChatStore";
|
||||
import type { Session } from "../types";
|
||||
|
||||
/**
|
||||
* 后端会话数据结构
|
||||
*/
|
||||
interface BackendSession {
|
||||
id: string;
|
||||
name: string;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 后端会话详情数据结构
|
||||
*/
|
||||
interface BackendSessionDetail {
|
||||
session: BackendSession;
|
||||
messages: unknown[];
|
||||
message_count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换后端会话为前端格式
|
||||
*/
|
||||
const convertSession = (backend: BackendSession): Session => ({
|
||||
id: backend.id,
|
||||
name: backend.name,
|
||||
createdAt: backend.created_at,
|
||||
updatedAt: backend.updated_at,
|
||||
messageCount: 0,
|
||||
});
|
||||
|
||||
/**
|
||||
* useSession Hook 配置
|
||||
@@ -56,6 +23,8 @@ interface UseSessionOptions {
|
||||
|
||||
/**
|
||||
* 会话管理 Hook
|
||||
*
|
||||
* @deprecated 该 Hook 仍停留在 general-chat compat 会话链路,仅用于兼容旧版 general-chat 页面。
|
||||
*/
|
||||
export const useSession = (options: UseSessionOptions = {}) => {
|
||||
const { autoLoad = true, onSessionChange } = options;
|
||||
@@ -63,11 +32,11 @@ export const useSession = (options: UseSessionOptions = {}) => {
|
||||
const {
|
||||
sessions,
|
||||
currentSessionId,
|
||||
setSessions,
|
||||
hydrateSessions,
|
||||
selectSession,
|
||||
createSession: createNewSession,
|
||||
deleteSession: _removeSession,
|
||||
updateSession,
|
||||
deleteSession: removeSession,
|
||||
renameSession: renameSessionInStore,
|
||||
} = useGeneralChatStore();
|
||||
|
||||
/**
|
||||
@@ -75,15 +44,11 @@ export const useSession = (options: UseSessionOptions = {}) => {
|
||||
*/
|
||||
const loadSessions = useCallback(async () => {
|
||||
try {
|
||||
const backendSessions = await invoke<BackendSession[]>(
|
||||
"general_chat_list_sessions",
|
||||
);
|
||||
const frontendSessions = backendSessions.map(convertSession);
|
||||
setSessions(frontendSessions);
|
||||
await hydrateSessions();
|
||||
} catch (error) {
|
||||
console.error("加载会话列表失败:", error);
|
||||
}
|
||||
}, [setSessions]);
|
||||
}, [hydrateSessions]);
|
||||
|
||||
/**
|
||||
* 创建新会话
|
||||
@@ -91,15 +56,10 @@ export const useSession = (options: UseSessionOptions = {}) => {
|
||||
const createSession = useCallback(
|
||||
async (name?: string): Promise<string | null> => {
|
||||
try {
|
||||
const _session = await invoke<BackendSession>(
|
||||
"general_chat_create_session",
|
||||
{
|
||||
name: name || undefined,
|
||||
metadata: undefined,
|
||||
},
|
||||
);
|
||||
// 使用 store 的 createSession 方法,它会自动添加会话并设置为当前会话
|
||||
const sessionId = await createNewSession();
|
||||
if (name?.trim()) {
|
||||
await renameSessionInStore(sessionId, name.trim());
|
||||
}
|
||||
onSessionChange?.(sessionId);
|
||||
return sessionId;
|
||||
} catch (error) {
|
||||
@@ -107,7 +67,7 @@ export const useSession = (options: UseSessionOptions = {}) => {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[createNewSession, onSessionChange],
|
||||
[createNewSession, renameSessionInStore, onSessionChange],
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -116,26 +76,13 @@ export const useSession = (options: UseSessionOptions = {}) => {
|
||||
const switchSession = useCallback(
|
||||
async (sessionId: string) => {
|
||||
try {
|
||||
// 加载会话详情
|
||||
const detail = await invoke<BackendSessionDetail>(
|
||||
"general_chat_get_session",
|
||||
{
|
||||
sessionId,
|
||||
messageLimit: 50,
|
||||
},
|
||||
);
|
||||
|
||||
// 更新会话消息数量
|
||||
updateSession(sessionId, { messageCount: detail.message_count });
|
||||
|
||||
// 切换当前会话
|
||||
selectSession(sessionId);
|
||||
onSessionChange?.(sessionId);
|
||||
} catch (error) {
|
||||
console.error("切换会话失败:", error);
|
||||
}
|
||||
},
|
||||
[selectSession, updateSession, onSessionChange],
|
||||
[selectSession, onSessionChange],
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -144,9 +91,7 @@ export const useSession = (options: UseSessionOptions = {}) => {
|
||||
const deleteSession = useCallback(
|
||||
async (sessionId: string) => {
|
||||
try {
|
||||
await invoke("general_chat_delete_session", { sessionId });
|
||||
// 使用 store 的 deleteSession 方法,它会自动处理会话切换逻辑
|
||||
await useGeneralChatStore.getState().deleteSession(sessionId);
|
||||
await removeSession(sessionId);
|
||||
|
||||
// 获取新的当前会话 ID 并触发回调
|
||||
const newCurrentId = useGeneralChatStore.getState().currentSessionId;
|
||||
@@ -155,7 +100,7 @@ export const useSession = (options: UseSessionOptions = {}) => {
|
||||
console.error("删除会话失败:", error);
|
||||
}
|
||||
},
|
||||
[onSessionChange],
|
||||
[removeSession, onSessionChange],
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -164,13 +109,12 @@ export const useSession = (options: UseSessionOptions = {}) => {
|
||||
const renameSession = useCallback(
|
||||
async (sessionId: string, name: string) => {
|
||||
try {
|
||||
await invoke("general_chat_rename_session", { sessionId, name });
|
||||
updateSession(sessionId, { name });
|
||||
await renameSessionInStore(sessionId, name);
|
||||
} catch (error) {
|
||||
console.error("重命名会话失败:", error);
|
||||
}
|
||||
},
|
||||
[updateSession],
|
||||
[renameSessionInStore],
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -180,15 +124,10 @@ export const useSession = (options: UseSessionOptions = {}) => {
|
||||
const generateTitle = useCallback(
|
||||
async (sessionId: string, firstMessage: string) => {
|
||||
try {
|
||||
// 调用后端命令生成标题
|
||||
const title = await invoke<string>("general_chat_generate_title", {
|
||||
request: {
|
||||
session_id: sessionId,
|
||||
first_message: firstMessage,
|
||||
},
|
||||
});
|
||||
// 更新本地状态
|
||||
updateSession(sessionId, { name: title });
|
||||
await renameSession(
|
||||
sessionId,
|
||||
firstMessage.slice(0, 20).trim() || "新话题",
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("生成标题失败:", error);
|
||||
// 失败时使用简单截取作为 fallback
|
||||
@@ -197,7 +136,7 @@ export const useSession = (options: UseSessionOptions = {}) => {
|
||||
await renameSession(sessionId, fallbackTitle);
|
||||
}
|
||||
},
|
||||
[renameSession, updateSession],
|
||||
[renameSession],
|
||||
);
|
||||
|
||||
// 自动加载会话列表
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @file useStreaming.ts
|
||||
* @description 流式响应处理 Hook
|
||||
* @description 流式响应处理 Hook(旧 general-chat 兼容实现)
|
||||
* @module components/general-chat/hooks/useStreaming
|
||||
*
|
||||
* 处理 Tauri 事件监听和流式内容累积
|
||||
@@ -9,13 +9,15 @@
|
||||
*/
|
||||
|
||||
import { useEffect, useCallback, useRef } from "react";
|
||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||
import type { UnlistenFn } from "@tauri-apps/api/event";
|
||||
import { parseStreamEvent } from "@/lib/api/agentStream";
|
||||
import { safeListen } from "@/lib/dev-bridge";
|
||||
import { useGeneralChatStore } from "../store/useGeneralChatStore";
|
||||
|
||||
/**
|
||||
* 流式事件类型
|
||||
* 旧版流式事件类型
|
||||
*/
|
||||
interface StreamEvent {
|
||||
interface LegacyStreamEvent {
|
||||
type: "start" | "delta" | "done" | "error";
|
||||
message_id?: string;
|
||||
content?: string;
|
||||
@@ -44,6 +46,8 @@ interface UseStreamingOptions {
|
||||
* 流式响应处理 Hook
|
||||
*
|
||||
* 监听 Tauri 事件,处理流式响应
|
||||
*
|
||||
* @deprecated 该 Hook 依赖 `start/delta/done` 旧事件协议,仅用于兼容旧版 general-chat 页面。
|
||||
*/
|
||||
export const useStreaming = (options: UseStreamingOptions) => {
|
||||
const {
|
||||
@@ -62,39 +66,103 @@ export const useStreaming = (options: UseStreamingOptions) => {
|
||||
|
||||
// 处理流式事件
|
||||
const handleStreamEvent = useCallback(
|
||||
(event: { payload: StreamEvent }) => {
|
||||
const { type, message_id, content, message } = event.payload;
|
||||
(event: { payload: unknown }) => {
|
||||
const payload = event.payload;
|
||||
const legacyEvent =
|
||||
payload && typeof payload === "object"
|
||||
? (payload as LegacyStreamEvent)
|
||||
: null;
|
||||
|
||||
switch (type) {
|
||||
case "start":
|
||||
contentRef.current = "";
|
||||
startStreaming(message_id || "");
|
||||
onStart?.(message_id || "");
|
||||
if (legacyEvent?.type === "start") {
|
||||
contentRef.current = "";
|
||||
startStreaming(legacyEvent.message_id || "");
|
||||
onStart?.(legacyEvent.message_id || "");
|
||||
return;
|
||||
}
|
||||
|
||||
if (legacyEvent?.type === "delta") {
|
||||
if (legacyEvent.content) {
|
||||
contentRef.current += legacyEvent.content;
|
||||
appendStreamingContent(legacyEvent.content);
|
||||
onDelta?.(legacyEvent.content);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (legacyEvent?.type === "done" && !("usage" in legacyEvent)) {
|
||||
const { finalizeMessage } = useGeneralChatStore.getState();
|
||||
void finalizeMessage();
|
||||
onDone?.(legacyEvent.message_id || "", contentRef.current);
|
||||
contentRef.current = "";
|
||||
return;
|
||||
}
|
||||
|
||||
if (legacyEvent?.type === "error") {
|
||||
const {
|
||||
streaming,
|
||||
setMessageError,
|
||||
stopGeneration: stopGen,
|
||||
} = useGeneralChatStore.getState();
|
||||
if (streaming.currentMessageId) {
|
||||
setMessageError(
|
||||
streaming.currentMessageId,
|
||||
legacyEvent.message || "未知错误",
|
||||
);
|
||||
} else {
|
||||
stopGen();
|
||||
}
|
||||
onError?.(legacyEvent.message || "未知错误");
|
||||
contentRef.current = "";
|
||||
return;
|
||||
}
|
||||
|
||||
const streamEvent = parseStreamEvent(payload);
|
||||
if (!streamEvent) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (streamEvent.type) {
|
||||
case "text_delta":
|
||||
contentRef.current += streamEvent.text;
|
||||
appendStreamingContent(streamEvent.text);
|
||||
onDelta?.(streamEvent.text);
|
||||
break;
|
||||
|
||||
case "delta":
|
||||
if (content) {
|
||||
contentRef.current += content;
|
||||
appendStreamingContent(content);
|
||||
onDelta?.(content);
|
||||
}
|
||||
case "done":
|
||||
// Aster 的 done 只代表一轮响应结束,工具循环可能继续。
|
||||
break;
|
||||
|
||||
case "done": {
|
||||
const { finalizeMessage } = useGeneralChatStore.getState();
|
||||
finalizeMessage();
|
||||
onDone?.(message_id || "", contentRef.current);
|
||||
case "final_done": {
|
||||
const { finalizeMessage, streaming } = useGeneralChatStore.getState();
|
||||
const messageId = streaming.currentMessageId || "";
|
||||
void finalizeMessage();
|
||||
onDone?.(messageId, contentRef.current);
|
||||
contentRef.current = "";
|
||||
break;
|
||||
}
|
||||
|
||||
case "error": {
|
||||
const { stopGeneration: stopGen } = useGeneralChatStore.getState();
|
||||
stopGen();
|
||||
onError?.(message || "未知错误");
|
||||
const {
|
||||
streaming,
|
||||
setMessageError,
|
||||
stopGeneration: stopGen,
|
||||
} = useGeneralChatStore.getState();
|
||||
if (streaming.currentMessageId) {
|
||||
setMessageError(streaming.currentMessageId, streamEvent.message);
|
||||
} else {
|
||||
stopGen();
|
||||
}
|
||||
onError?.(streamEvent.message);
|
||||
contentRef.current = "";
|
||||
break;
|
||||
}
|
||||
|
||||
case "warning":
|
||||
console.warn("[GeneralChat] 流式告警:", streamEvent.message);
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
[startStreaming, appendStreamingContent, onStart, onDelta, onDone, onError],
|
||||
@@ -112,10 +180,7 @@ export const useStreaming = (options: UseStreamingOptions) => {
|
||||
|
||||
// 设置新的监听器
|
||||
const eventKey = `${eventName}-${sessionId}`;
|
||||
unlistenRef.current = await listen<StreamEvent>(
|
||||
eventKey,
|
||||
handleStreamEvent,
|
||||
);
|
||||
unlistenRef.current = await safeListen(eventKey, handleStreamEvent);
|
||||
};
|
||||
|
||||
setupListener();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @file index.tsx
|
||||
* @description 通用对话功能主入口导出
|
||||
* @description 通用对话兼容根入口导出
|
||||
* @module components/general-chat
|
||||
* @requires ./GeneralChatPage
|
||||
* @exports GeneralChatPage - 通用对话页面组件
|
||||
@@ -8,38 +8,3 @@
|
||||
|
||||
// 主页面组件导出
|
||||
export { default as GeneralChatPage } from "./GeneralChatPage";
|
||||
|
||||
// 类型导出(不触发 react-refresh 警告)
|
||||
export type {
|
||||
Session,
|
||||
Message,
|
||||
MessageRole,
|
||||
MessageStatus,
|
||||
ContentBlock,
|
||||
ContentBlockType,
|
||||
CanvasState,
|
||||
CanvasContentType,
|
||||
UIState,
|
||||
StreamingState,
|
||||
ProviderConfig,
|
||||
MessageMetadata,
|
||||
GeneralChatPageProps,
|
||||
ChatPanelProps,
|
||||
MessageItemProps,
|
||||
InputBarProps,
|
||||
CanvasPanelProps,
|
||||
} from "./types";
|
||||
|
||||
// 子模块组件导出
|
||||
export {
|
||||
ChatPanel,
|
||||
MessageList,
|
||||
MessageItem,
|
||||
UserMessage,
|
||||
AssistantMessage,
|
||||
CodeBlock,
|
||||
ErrorBoundary,
|
||||
} from "./chat";
|
||||
export { CanvasPanel, CodePreview, MarkdownPreview } from "./canvas";
|
||||
export { useGeneralChatStore } from "./store";
|
||||
export { useStreaming, useChat, useSession } from "./hooks";
|
||||
|
||||
@@ -11,9 +11,25 @@
|
||||
|
||||
import { create } from "zustand";
|
||||
import { persist, createJSONStorage } from "zustand/middleware";
|
||||
import {
|
||||
createGeneralChatCompatSession,
|
||||
deleteGeneralChatCompatSession,
|
||||
getGeneralChatCompatMessages,
|
||||
getGeneralChatCompatSession,
|
||||
listGeneralChatCompatSessions,
|
||||
renameGeneralChatCompatSession,
|
||||
type GeneralChatCompatMessageRecord,
|
||||
type GeneralChatCompatSessionRecord,
|
||||
} from "@/lib/api/generalChatCompat";
|
||||
import {
|
||||
sendAsterMessageStream,
|
||||
stopAsterSession,
|
||||
} from "@/lib/api/agentRuntime";
|
||||
import { requireDefaultProjectId } from "@/lib/api/project";
|
||||
import type {
|
||||
Session,
|
||||
Message,
|
||||
ContentBlock,
|
||||
UIState,
|
||||
StreamingState,
|
||||
CanvasState,
|
||||
@@ -143,6 +159,10 @@ export interface GeneralChatState {
|
||||
deleteSession: (id: string) => Promise<void>;
|
||||
/** 重命名会话 */
|
||||
renameSession: (id: string, name: string) => Promise<void>;
|
||||
/** 从后端同步会话列表 */
|
||||
hydrateSessions: (
|
||||
preferredSessionId?: string | null,
|
||||
) => Promise<string | null>;
|
||||
/** 设置会话列表 */
|
||||
setSessions: (sessions: Session[]) => void;
|
||||
/** 更新单个会话 */
|
||||
@@ -322,6 +342,197 @@ const generateId = (): string => {
|
||||
*/
|
||||
const now = (): number => Date.now();
|
||||
|
||||
interface SessionSnapshot {
|
||||
session: Session;
|
||||
messages: Message[];
|
||||
pagination: PaginationState;
|
||||
}
|
||||
|
||||
interface AsterImagePayload {
|
||||
data: string;
|
||||
media_type: string;
|
||||
}
|
||||
|
||||
const sortSessions = (sessions: Session[]): Session[] =>
|
||||
[...sessions].sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
|
||||
const upsertSession = (sessions: Session[], session: Session): Session[] =>
|
||||
sortSessions([session, ...sessions.filter((item) => item.id !== session.id)]);
|
||||
|
||||
const toTextBlocks = (content: string): ContentBlock[] => [
|
||||
{
|
||||
type: "text",
|
||||
content,
|
||||
},
|
||||
];
|
||||
|
||||
const normalizeMetadata = (
|
||||
metadata: Record<string, unknown> | null | undefined,
|
||||
): MessageMetadata | undefined => {
|
||||
if (!metadata) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
model: typeof metadata.model === "string" ? metadata.model : undefined,
|
||||
tokens: typeof metadata.tokens === "number" ? metadata.tokens : undefined,
|
||||
duration:
|
||||
typeof metadata.duration === "number" ? metadata.duration : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const convertCompatSession = (
|
||||
backend: GeneralChatCompatSessionRecord,
|
||||
messageCount = 0,
|
||||
): Session => ({
|
||||
id: backend.id,
|
||||
name: backend.name,
|
||||
createdAt: backend.created_at,
|
||||
updatedAt: backend.updated_at,
|
||||
messageCount,
|
||||
});
|
||||
|
||||
const convertCompatMessage = (
|
||||
message: GeneralChatCompatMessageRecord,
|
||||
): Message => {
|
||||
const blocks =
|
||||
message.blocks?.map((block) => ({
|
||||
type: block.type as ContentBlock["type"],
|
||||
content: block.content,
|
||||
language: block.language,
|
||||
filename: block.filename,
|
||||
mimeType: block.mime_type,
|
||||
})) || toTextBlocks(message.content);
|
||||
|
||||
return {
|
||||
id: message.id,
|
||||
sessionId: message.session_id,
|
||||
role: message.role as Message["role"],
|
||||
content: message.content,
|
||||
blocks,
|
||||
status: message.status as Message["status"],
|
||||
createdAt: message.created_at,
|
||||
metadata: normalizeMetadata(message.metadata),
|
||||
};
|
||||
};
|
||||
|
||||
const buildPaginationState = (
|
||||
messages: Message[],
|
||||
messageCount: number,
|
||||
overrides?: Partial<PaginationState>,
|
||||
): PaginationState => ({
|
||||
...DEFAULT_PAGINATION_STATE,
|
||||
hasMoreMessages: messageCount > messages.length,
|
||||
oldestMessageId: messages[0]?.id || null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const generateFallbackSessionTitle = (content: string): string => {
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed) {
|
||||
return "新对话";
|
||||
}
|
||||
|
||||
const chars = Array.from(trimmed);
|
||||
return chars.length > 20 ? `${chars.slice(0, 17).join("")}...` : trimmed;
|
||||
};
|
||||
|
||||
const loadGeneralChatSessionSnapshot = async (
|
||||
sessionId: string,
|
||||
): Promise<SessionSnapshot> => {
|
||||
const detail = await getGeneralChatCompatSession(
|
||||
sessionId,
|
||||
DEFAULT_PAGINATION_STATE.pageSize,
|
||||
);
|
||||
const messages = detail.messages.map(convertCompatMessage);
|
||||
|
||||
return {
|
||||
session: convertCompatSession(detail.session, detail.message_count),
|
||||
messages,
|
||||
pagination: buildPaginationState(messages, detail.message_count),
|
||||
};
|
||||
};
|
||||
|
||||
const buildAsterMessageToSend = (
|
||||
content: string,
|
||||
theme: GeneralChatState["contentTheme"],
|
||||
mode: GeneralChatState["contentCreationMode"],
|
||||
): string => {
|
||||
const normalizedContent = content.trim() || "请分析这张图片";
|
||||
|
||||
if (theme === "general") {
|
||||
return normalizedContent;
|
||||
}
|
||||
|
||||
const systemInstruction = getContentCreationInstruction(theme, mode);
|
||||
return `${systemInstruction}\n\n---\n\n用户请求:${normalizedContent}`;
|
||||
};
|
||||
|
||||
const resolveDefaultWorkspaceId = async (): Promise<string> => {
|
||||
return requireDefaultProjectId("未找到默认工作区,请先创建并设为默认工作区");
|
||||
};
|
||||
|
||||
const invokeGeneralChatAsterStream = async ({
|
||||
sessionId,
|
||||
message,
|
||||
images,
|
||||
webSearch,
|
||||
}: {
|
||||
sessionId: string;
|
||||
message: string;
|
||||
images?: AsterImagePayload[];
|
||||
webSearch?: boolean;
|
||||
}) => {
|
||||
const workspaceId = await resolveDefaultWorkspaceId();
|
||||
|
||||
await sendAsterMessageStream(
|
||||
message,
|
||||
sessionId,
|
||||
`general-chat-stream-${sessionId}`,
|
||||
workspaceId,
|
||||
images,
|
||||
undefined,
|
||||
undefined,
|
||||
webSearch,
|
||||
);
|
||||
};
|
||||
|
||||
const extractImagePayloadFromDataUrl = (
|
||||
value: string,
|
||||
): AsterImagePayload | null => {
|
||||
const matched = value.match(/^data:([^;]+);base64,(.+)$/);
|
||||
if (!matched) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [, mediaType, data] = matched;
|
||||
return {
|
||||
data,
|
||||
media_type: mediaType,
|
||||
};
|
||||
};
|
||||
|
||||
const extractRetryPayloadFromMessage = (
|
||||
message: Message,
|
||||
): { content: string; images?: AsterImagePayload[] } => {
|
||||
const textContent = message.blocks
|
||||
.filter((block) => block.type === "text")
|
||||
.map((block) => block.content)
|
||||
.join("\n")
|
||||
.trim();
|
||||
|
||||
const imagePayload = message.blocks
|
||||
.filter((block) => block.type === "image")
|
||||
.map((block) => extractImagePayloadFromDataUrl(block.content))
|
||||
.filter((block): block is AsterImagePayload => block !== null);
|
||||
|
||||
return {
|
||||
content:
|
||||
textContent || (message.content === "[图片]" ? "" : message.content),
|
||||
images: imagePayload.length > 0 ? imagePayload : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Store 实现
|
||||
// ============================================================================
|
||||
@@ -337,46 +548,90 @@ export const useGeneralChatStore = create<GeneralChatState>()(
|
||||
(set, get) => ({
|
||||
...initialState,
|
||||
|
||||
hydrateSessions: async (preferredSessionId?: string | null) => {
|
||||
const backendSessions = await listGeneralChatCompatSessions();
|
||||
const sessions = sortSessions(
|
||||
backendSessions.map((session) => convertCompatSession(session)),
|
||||
);
|
||||
const currentSessionId = get().currentSessionId;
|
||||
const nextSessionId =
|
||||
[preferredSessionId, currentSessionId]
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.find((value) =>
|
||||
sessions.some((session) => session.id === value),
|
||||
) ||
|
||||
sessions[0]?.id ||
|
||||
null;
|
||||
|
||||
set((state) => ({
|
||||
sessions,
|
||||
currentSessionId: nextSessionId,
|
||||
messages: Object.fromEntries(
|
||||
Object.entries(state.messages).filter(([sessionId]) =>
|
||||
sessions.some((session) => session.id === sessionId),
|
||||
),
|
||||
),
|
||||
}));
|
||||
|
||||
if (nextSessionId) {
|
||||
get().selectSession(nextSessionId);
|
||||
}
|
||||
|
||||
return nextSessionId;
|
||||
},
|
||||
|
||||
// ========== 会话操作实现 ==========
|
||||
|
||||
createSession: async () => {
|
||||
const id = generateId();
|
||||
const timestamp = now();
|
||||
const newSession: Session = {
|
||||
id,
|
||||
name: "新对话",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
messageCount: 0,
|
||||
};
|
||||
const backendSession = await createGeneralChatCompatSession();
|
||||
const newSession = convertCompatSession(backendSession);
|
||||
const paginationState = buildPaginationState([], 0, {
|
||||
hasMoreMessages: false,
|
||||
});
|
||||
|
||||
set((state) => ({
|
||||
sessions: [newSession, ...state.sessions],
|
||||
currentSessionId: id,
|
||||
sessions: upsertSession(state.sessions, newSession),
|
||||
currentSessionId: newSession.id,
|
||||
messages: {
|
||||
...state.messages,
|
||||
[id]: [],
|
||||
[newSession.id]: [],
|
||||
},
|
||||
pagination: {
|
||||
...state.pagination,
|
||||
[newSession.id]: paginationState,
|
||||
},
|
||||
}));
|
||||
|
||||
// TODO: 调用 Tauri 命令持久化到数据库
|
||||
// await invoke('general_chat_create_session', { name: newSession.name });
|
||||
|
||||
return id;
|
||||
return newSession.id;
|
||||
},
|
||||
|
||||
selectSession: (id: string) => {
|
||||
const { sessions } = get();
|
||||
const sessionExists = sessions.some((s) => s.id === id);
|
||||
set({ currentSessionId: id });
|
||||
|
||||
if (sessionExists) {
|
||||
set({ currentSessionId: id });
|
||||
// TODO: 如果消息未加载,调用 Tauri 命令加载消息
|
||||
}
|
||||
void (async () => {
|
||||
try {
|
||||
const snapshot = await loadGeneralChatSessionSnapshot(id);
|
||||
|
||||
set((state) => ({
|
||||
sessions: upsertSession(state.sessions, snapshot.session),
|
||||
messages: {
|
||||
...state.messages,
|
||||
[id]: snapshot.messages,
|
||||
},
|
||||
pagination: {
|
||||
...state.pagination,
|
||||
[id]: snapshot.pagination,
|
||||
},
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("加载会话详情失败:", error);
|
||||
}
|
||||
})();
|
||||
},
|
||||
|
||||
deleteSession: async (id: string) => {
|
||||
const { sessions, currentSessionId, messages } = get();
|
||||
await deleteGeneralChatCompatSession(id);
|
||||
|
||||
// 从列表中移除会话
|
||||
const newSessions = sessions.filter((s) => s.id !== id);
|
||||
@@ -399,29 +654,37 @@ export const useGeneralChatStore = create<GeneralChatState>()(
|
||||
messages: newMessages,
|
||||
});
|
||||
|
||||
// TODO: 调用 Tauri 命令从数据库删除
|
||||
// await invoke('general_chat_delete_session', { sessionId: id });
|
||||
if (newCurrentId && !(newCurrentId in newMessages)) {
|
||||
get().selectSession(newCurrentId);
|
||||
}
|
||||
},
|
||||
|
||||
renameSession: async (id: string, name: string) => {
|
||||
await renameGeneralChatCompatSession(id, name);
|
||||
|
||||
set((state) => ({
|
||||
sessions: state.sessions.map((s) =>
|
||||
s.id === id ? { ...s, name, updatedAt: now() } : s,
|
||||
sessions: sortSessions(
|
||||
state.sessions.map((session) =>
|
||||
session.id === id
|
||||
? { ...session, name, updatedAt: now() }
|
||||
: session,
|
||||
),
|
||||
),
|
||||
}));
|
||||
|
||||
// TODO: 调用 Tauri 命令持久化到数据库
|
||||
// await invoke('general_chat_rename_session', { sessionId: id, name });
|
||||
},
|
||||
|
||||
setSessions: (sessions: Session[]) => {
|
||||
set({ sessions });
|
||||
set({ sessions: sortSessions(sessions) });
|
||||
},
|
||||
|
||||
updateSession: (id: string, updates: Partial<Session>) => {
|
||||
set((state) => ({
|
||||
sessions: state.sessions.map((s) =>
|
||||
s.id === id ? { ...s, ...updates, updatedAt: now() } : s,
|
||||
sessions: sortSessions(
|
||||
state.sessions.map((session) =>
|
||||
session.id === id
|
||||
? { ...session, ...updates, updatedAt: now() }
|
||||
: session,
|
||||
),
|
||||
),
|
||||
}));
|
||||
},
|
||||
@@ -433,17 +696,16 @@ export const useGeneralChatStore = create<GeneralChatState>()(
|
||||
images?: File[],
|
||||
webSearch?: boolean,
|
||||
) => {
|
||||
const { currentSessionId, messages } = get();
|
||||
const { messages } = get();
|
||||
|
||||
// 验证:空白消息且无图片不发送
|
||||
if (!content.trim() && (!images || images.length === 0)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证:必须有当前会话
|
||||
let currentSessionId = get().currentSessionId;
|
||||
if (!currentSessionId) {
|
||||
console.warn("No current session selected");
|
||||
return;
|
||||
currentSessionId = await get().createSession();
|
||||
}
|
||||
|
||||
const messageId = generateId();
|
||||
@@ -515,7 +777,7 @@ export const useGeneralChatStore = create<GeneralChatState>()(
|
||||
|
||||
// 更新会话的消息数量和更新时间
|
||||
get().updateSession(currentSessionId, {
|
||||
messageCount: currentMessages.length + 1,
|
||||
messageCount: currentMessages.length + 2,
|
||||
});
|
||||
|
||||
// 创建 AI 响应消息占位符
|
||||
@@ -591,30 +853,19 @@ export const useGeneralChatStore = create<GeneralChatState>()(
|
||||
}
|
||||
|
||||
try {
|
||||
// 调用 Tauri 命令发送消息并开始流式响应
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
|
||||
// 获取内容创作状态
|
||||
const { contentTheme, contentCreationMode } = get();
|
||||
const messageToSend = buildAsterMessageToSend(
|
||||
content,
|
||||
contentTheme,
|
||||
contentCreationMode,
|
||||
);
|
||||
|
||||
// 根据主题生成系统指令前缀
|
||||
let messageToSend = content.trim() || "请分析这张图片";
|
||||
|
||||
// 如果不是通用主题,注入系统指令
|
||||
if (contentTheme !== "general") {
|
||||
const systemInstruction = getContentCreationInstruction(
|
||||
contentTheme,
|
||||
contentCreationMode,
|
||||
);
|
||||
messageToSend = `${systemInstruction}\n\n---\n\n用户请求:${messageToSend}`;
|
||||
}
|
||||
|
||||
await invoke("aster_agent_chat_stream", {
|
||||
await invokeGeneralChatAsterStream({
|
||||
sessionId: currentSessionId,
|
||||
message: messageToSend,
|
||||
eventName: `general-chat-stream-${currentSessionId}`,
|
||||
images: imageData,
|
||||
web_search: webSearch,
|
||||
webSearch,
|
||||
});
|
||||
|
||||
// 如果启用了工作流,执行 Action 阶段
|
||||
@@ -699,8 +950,11 @@ export const useGeneralChatStore = create<GeneralChatState>()(
|
||||
set({ streaming: { ...DEFAULT_STREAMING_STATE } });
|
||||
}
|
||||
|
||||
// TODO: 调用 Tauri 命令停止生成
|
||||
// await invoke('general_chat_stop_generation', { sessionId: currentSessionId });
|
||||
if (currentSessionId) {
|
||||
void stopAsterSession(currentSessionId).catch((error) => {
|
||||
console.error("停止生成失败:", error);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
appendStreamingContent: (content: string) => {
|
||||
@@ -763,6 +1017,9 @@ export const useGeneralChatStore = create<GeneralChatState>()(
|
||||
},
|
||||
streaming: { ...DEFAULT_STREAMING_STATE },
|
||||
}));
|
||||
get().updateSession(currentSessionId, {
|
||||
messageCount: updatedMessages.length,
|
||||
});
|
||||
|
||||
// 自动生成会话标题:当这是第一轮对话完成时(2条消息:用户+助手)
|
||||
const currentSession = sessions.find((s) => s.id === currentSessionId);
|
||||
@@ -772,26 +1029,15 @@ export const useGeneralChatStore = create<GeneralChatState>()(
|
||||
(m) => m.role === "user",
|
||||
);
|
||||
if (firstUserMessage) {
|
||||
const nextTitle = generateFallbackSessionTitle(
|
||||
firstUserMessage.content,
|
||||
);
|
||||
|
||||
try {
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
const title = await invoke<string>(
|
||||
"general_chat_generate_title",
|
||||
{
|
||||
request: {
|
||||
session_id: currentSessionId,
|
||||
first_message: firstUserMessage.content,
|
||||
},
|
||||
},
|
||||
);
|
||||
// 更新本地会话标题
|
||||
get().updateSession(currentSessionId, { name: title });
|
||||
await get().renameSession(currentSessionId, nextTitle);
|
||||
} catch (error) {
|
||||
console.warn("自动生成标题失败:", error);
|
||||
// 失败时使用简单截取
|
||||
const fallbackTitle =
|
||||
firstUserMessage.content.slice(0, 20) +
|
||||
(firstUserMessage.content.length > 20 ? "..." : "");
|
||||
get().updateSession(currentSessionId, { name: fallbackTitle });
|
||||
console.warn("自动更新标题失败:", error);
|
||||
get().updateSession(currentSessionId, { name: nextTitle });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -855,13 +1101,29 @@ export const useGeneralChatStore = create<GeneralChatState>()(
|
||||
},
|
||||
|
||||
startStreaming: (messageId: string) => {
|
||||
set({
|
||||
const { currentSessionId, messages } = get();
|
||||
const updatedMessages = currentSessionId
|
||||
? (messages[currentSessionId] || []).map((message) =>
|
||||
message.id === messageId
|
||||
? { ...message, status: "streaming" as const }
|
||||
: message,
|
||||
)
|
||||
: null;
|
||||
|
||||
set((state) => ({
|
||||
messages:
|
||||
currentSessionId && updatedMessages
|
||||
? {
|
||||
...state.messages,
|
||||
[currentSessionId]: updatedMessages,
|
||||
}
|
||||
: state.messages,
|
||||
streaming: {
|
||||
isStreaming: true,
|
||||
currentMessageId: messageId,
|
||||
partialContent: "",
|
||||
},
|
||||
});
|
||||
}));
|
||||
},
|
||||
|
||||
setMessageError: (messageId: string, error: ErrorInfo | string) => {
|
||||
@@ -916,6 +1178,8 @@ export const useGeneralChatStore = create<GeneralChatState>()(
|
||||
|
||||
if (!userMessage) return;
|
||||
|
||||
const retryPayload = extractRetryPayloadFromMessage(userMessage);
|
||||
|
||||
// 清除错误状态,将消息状态改为 pending
|
||||
const updatedMessages = currentMessages.map((m) =>
|
||||
m.id === messageId
|
||||
@@ -924,6 +1188,7 @@ export const useGeneralChatStore = create<GeneralChatState>()(
|
||||
status: "pending" as const,
|
||||
error: undefined,
|
||||
content: "",
|
||||
blocks: [],
|
||||
}
|
||||
: m,
|
||||
);
|
||||
@@ -935,12 +1200,24 @@ export const useGeneralChatStore = create<GeneralChatState>()(
|
||||
},
|
||||
}));
|
||||
|
||||
// TODO: 调用 Tauri 命令重新发送消息
|
||||
// await invoke('general_chat_send_message', {
|
||||
// sessionId: currentSessionId,
|
||||
// content: userMessage.content,
|
||||
// eventName: `chat-stream-${currentSessionId}`,
|
||||
// });
|
||||
get().startStreaming(messageId);
|
||||
|
||||
try {
|
||||
const { contentTheme, contentCreationMode } = get();
|
||||
const messageToSend = buildAsterMessageToSend(
|
||||
retryPayload.content,
|
||||
contentTheme,
|
||||
contentCreationMode,
|
||||
);
|
||||
|
||||
await invokeGeneralChatAsterStream({
|
||||
sessionId: currentSessionId,
|
||||
message: messageToSend,
|
||||
images: retryPayload.images,
|
||||
});
|
||||
} catch (error) {
|
||||
get().setMessageError(messageId, error as string);
|
||||
}
|
||||
},
|
||||
|
||||
clearMessageError: (messageId: string) => {
|
||||
@@ -996,54 +1273,14 @@ export const useGeneralChatStore = create<GeneralChatState>()(
|
||||
currentMessages.length > 0 ? currentMessages[0] : null;
|
||||
const beforeId = oldestMessage?.id || null;
|
||||
|
||||
// 调用 Tauri 命令获取更多消息
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
const olderMessages = await invoke<
|
||||
Array<{
|
||||
id: string;
|
||||
session_id: string;
|
||||
role: string;
|
||||
content: string;
|
||||
blocks: Array<{
|
||||
type: string;
|
||||
content: string;
|
||||
language?: string;
|
||||
filename?: string;
|
||||
mime_type?: string;
|
||||
}> | null;
|
||||
status: string;
|
||||
created_at: number;
|
||||
metadata: Record<string, unknown> | null;
|
||||
}>
|
||||
>("general_chat_get_messages", {
|
||||
const olderMessages = await getGeneralChatCompatMessages(
|
||||
sessionId,
|
||||
limit: currentPagination.pageSize,
|
||||
currentPagination.pageSize,
|
||||
beforeId,
|
||||
});
|
||||
);
|
||||
|
||||
// 转换后端消息格式为前端格式
|
||||
const convertedMessages: Message[] = olderMessages.map((msg) => ({
|
||||
id: msg.id,
|
||||
sessionId: msg.session_id,
|
||||
role: msg.role as Message["role"],
|
||||
content: msg.content,
|
||||
blocks: msg.blocks?.map((b) => ({
|
||||
type: b.type as Message["blocks"][0]["type"],
|
||||
content: b.content,
|
||||
language: b.language,
|
||||
filename: b.filename,
|
||||
mimeType: b.mime_type,
|
||||
})) || [{ type: "text" as const, content: msg.content }],
|
||||
status: msg.status as Message["status"],
|
||||
createdAt: msg.created_at,
|
||||
metadata: msg.metadata
|
||||
? {
|
||||
model: msg.metadata.model as string | undefined,
|
||||
tokens: msg.metadata.tokens as number | undefined,
|
||||
duration: msg.metadata.duration as number | undefined,
|
||||
}
|
||||
: undefined,
|
||||
}));
|
||||
const convertedMessages = olderMessages.map(convertCompatMessage);
|
||||
|
||||
// 判断是否还有更多消息
|
||||
const hasMore =
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState, type KeyboardEvent } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { open as openExternal } from "@tauri-apps/plugin-shell";
|
||||
import {
|
||||
Search,
|
||||
@@ -38,6 +37,7 @@ import {
|
||||
getCanvasImageInsertHistory,
|
||||
type CanvasImageInsertHistoryEntry,
|
||||
} from "@/lib/canvasImageInsertHistory";
|
||||
import { importMaterialFromUrl } from "@/lib/api/materials";
|
||||
import type { Page, PageParams } from "@/types/page";
|
||||
|
||||
export interface ImageSearchTabProps {
|
||||
@@ -716,7 +716,9 @@ export function ImageSearchTab({ projectId, onNavigate }: ImageSearchTabProps) {
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = onCanvasImageInsertAck((ack: CanvasImageInsertAck) => {
|
||||
const pendingMeta = pendingInsertRequestMetaRef.current.get(ack.requestId);
|
||||
const pendingMeta = pendingInsertRequestMetaRef.current.get(
|
||||
ack.requestId,
|
||||
);
|
||||
if (!pendingMeta) {
|
||||
return;
|
||||
}
|
||||
@@ -724,7 +726,9 @@ export function ImageSearchTab({ projectId, onNavigate }: ImageSearchTabProps) {
|
||||
|
||||
if (ack.success) {
|
||||
const canvasLabel = CANVAS_DISPLAY_NAME[ack.canvasType] || "目标画布";
|
||||
const locationLabel = ack.locationLabel ? ` · ${ack.locationLabel}` : "";
|
||||
const locationLabel = ack.locationLabel
|
||||
? ` · ${ack.locationLabel}`
|
||||
: "";
|
||||
toast.success(`已插入到${canvasLabel}${locationLabel}`);
|
||||
|
||||
const nextHistory = addCanvasImageInsertHistory({
|
||||
@@ -777,14 +781,12 @@ export function ImageSearchTab({ projectId, onNavigate }: ImageSearchTabProps) {
|
||||
|
||||
setSavingId(imageUrl);
|
||||
try {
|
||||
await invoke("import_material_from_url", {
|
||||
req: {
|
||||
projectId,
|
||||
name: imageName,
|
||||
type: "image",
|
||||
url: imageUrl,
|
||||
tags: [provider],
|
||||
},
|
||||
await importMaterialFromUrl({
|
||||
projectId,
|
||||
name: imageName,
|
||||
type: "image",
|
||||
url: imageUrl,
|
||||
tags: [provider],
|
||||
});
|
||||
toast.success("已保存到图片库");
|
||||
} catch (error) {
|
||||
@@ -960,7 +962,8 @@ export function ImageSearchTab({ projectId, onNavigate }: ImageSearchTabProps) {
|
||||
<RecentInsertHeader>最近插入记录(可一键定位)</RecentInsertHeader>
|
||||
<RecentInsertList>
|
||||
{recentInsertHistory.map((entry) => {
|
||||
const canvasLabel = CANVAS_DISPLAY_NAME[entry.canvasType] || "画布";
|
||||
const canvasLabel =
|
||||
CANVAS_DISPLAY_NAME[entry.canvasType] || "画布";
|
||||
const locationLabel = entry.locationLabel || "已插入";
|
||||
return (
|
||||
<RecentInsertItem key={entry.requestId}>
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { ImagePlus, Loader2, Upload, Trash2 } from "lucide-react";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { toast } from "sonner";
|
||||
import styled, { keyframes } from "styled-components";
|
||||
import { uploadMaterial } from "@/lib/api/materials";
|
||||
|
||||
export interface LocalImageTabProps {
|
||||
/** 目标项目 ID */
|
||||
@@ -54,7 +54,7 @@ const DropZone = styled.div<{ $dragging?: boolean }>`
|
||||
min-height: 280px;
|
||||
border: 2px dashed
|
||||
${({ $dragging }) =>
|
||||
$dragging ? "hsl(var(--primary))" : "hsl(var(--border))"};
|
||||
$dragging ? "hsl(var(--primary))" : "hsl(var(--border))"};
|
||||
border-radius: 20px;
|
||||
padding: 48px;
|
||||
text-align: center;
|
||||
@@ -66,9 +66,7 @@ const DropZone = styled.div<{ $dragging?: boolean }>`
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
background: ${({ $dragging }) =>
|
||||
$dragging
|
||||
? "hsl(var(--primary) / 0.06)"
|
||||
: "hsl(var(--card) / 0.3)"};
|
||||
$dragging ? "hsl(var(--primary) / 0.06)" : "hsl(var(--card) / 0.3)"};
|
||||
|
||||
&:hover {
|
||||
border-color: hsl(var(--primary) / 0.5);
|
||||
@@ -168,9 +166,9 @@ const ActionButton = styled.button<{ $primary?: boolean; $danger?: boolean }>`
|
||||
&:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: ${({ $primary }) =>
|
||||
$primary
|
||||
? "0 6px 20px hsl(var(--primary) / 0.3)"
|
||||
: "0 4px 12px hsl(var(--background) / 0.3)"};
|
||||
$primary
|
||||
? "0 6px 20px hsl(var(--primary) / 0.3)"
|
||||
: "0 4px 12px hsl(var(--background) / 0.3)"};
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
@@ -243,14 +241,12 @@ export function LocalImageTab({ projectId }: LocalImageTabProps) {
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await invoke("upload_material", {
|
||||
req: {
|
||||
project_id: projectId,
|
||||
name: selectedPath.split("/").pop() || "本地图片",
|
||||
type: "image",
|
||||
file_path: selectedPath,
|
||||
tags: ["local"],
|
||||
},
|
||||
await uploadMaterial({
|
||||
projectId,
|
||||
name: selectedPath.split("/").pop() || "本地图片",
|
||||
type: "image",
|
||||
filePath: selectedPath,
|
||||
tags: ["local"],
|
||||
});
|
||||
toast.success("已保存到图片库");
|
||||
|
||||
@@ -277,7 +273,13 @@ export function LocalImageTab({ projectId }: LocalImageTabProps) {
|
||||
<NoProjectIcon>
|
||||
<Upload size={28} />
|
||||
</NoProjectIcon>
|
||||
<div style={{ fontSize: 15, fontWeight: 500, color: "hsl(var(--foreground) / 0.7)" }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 15,
|
||||
fontWeight: 500,
|
||||
color: "hsl(var(--foreground) / 0.7)",
|
||||
}}
|
||||
>
|
||||
请先选择项目
|
||||
</div>
|
||||
<div style={{ fontSize: 13 }}>
|
||||
|
||||
@@ -5,9 +5,12 @@
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useApiKeyProvider } from "@/hooks/useApiKeyProvider";
|
||||
import { apiKeyProviderApi } from "@/lib/api/apiKeyProvider";
|
||||
import {
|
||||
importMaterialFromUrl,
|
||||
type ImportMaterialFromUrlRequest,
|
||||
} from "@/lib/api/materials";
|
||||
import { getImageModelsForProvider } from "@/lib/imageGeneration";
|
||||
import { isDebugFlagEnabled } from "@/lib/perfDebug";
|
||||
import { setStoredResourceProjectId } from "@/lib/resourceProjectSelection";
|
||||
@@ -38,15 +41,6 @@ interface EndpointRequestOptions {
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
interface ImportMaterialFromUrlRequest {
|
||||
projectId: string;
|
||||
name: string;
|
||||
type: "image";
|
||||
url: string;
|
||||
tags?: string[];
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface BackfillImagesResult {
|
||||
total: number;
|
||||
saved: number;
|
||||
@@ -1284,7 +1278,10 @@ async function requestImageFromFalQueue(
|
||||
});
|
||||
|
||||
const submitRaw = await submitResponse.text();
|
||||
const submitPayload = tryParseJson(submitRaw) as Record<string, unknown> | null;
|
||||
const submitPayload = tryParseJson(submitRaw) as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null;
|
||||
|
||||
if (!submitResponse.ok) {
|
||||
throw new Error(
|
||||
@@ -1330,7 +1327,10 @@ async function requestImageFromFalQueue(
|
||||
},
|
||||
});
|
||||
const statusRaw = await statusResponse.text();
|
||||
const statusPayload = tryParseJson(statusRaw) as Record<string, unknown> | null;
|
||||
const statusPayload = tryParseJson(statusRaw) as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null;
|
||||
|
||||
if (!statusResponse.ok) {
|
||||
throw new Error(
|
||||
@@ -1369,9 +1369,10 @@ async function requestImageFromFalQueue(
|
||||
},
|
||||
});
|
||||
const pollingRaw = await pollingResponse.text();
|
||||
const pollingPayload = tryParseJson(pollingRaw) as
|
||||
| Record<string, unknown>
|
||||
| null;
|
||||
const pollingPayload = tryParseJson(pollingRaw) as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null;
|
||||
|
||||
if (pollingResponse.ok) {
|
||||
const imageUrl = pollingPayload
|
||||
@@ -1601,7 +1602,9 @@ export function useImageGen(options: UseImageGenOptions = {}) {
|
||||
}
|
||||
|
||||
const preferredProvider = preferredProviderId
|
||||
? availableProviders.find((provider) => provider.id === preferredProviderId)
|
||||
? availableProviders.find(
|
||||
(provider) => provider.id === preferredProviderId,
|
||||
)
|
||||
: null;
|
||||
const nextProvider = preferredProvider ?? availableProviders[0];
|
||||
|
||||
@@ -1650,10 +1653,7 @@ export function useImageGen(options: UseImageGenOptions = {}) {
|
||||
|
||||
setResourceSavingCount((count) => count + 1);
|
||||
try {
|
||||
const savedMaterial = await invoke<{ id: string }>(
|
||||
"import_material_from_url",
|
||||
{ req: request },
|
||||
);
|
||||
const savedMaterial = await importMaterialFromUrl(request);
|
||||
|
||||
const savedAt = Date.now();
|
||||
setImages((prev) => {
|
||||
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { getConfig, getLogs, getPersistedLogsTail } from "@/hooks/useTauri";
|
||||
import { getConfig } from "@/lib/api/appConfig";
|
||||
import { getLogs, getPersistedLogsTail } from "@/lib/api/logs";
|
||||
import {
|
||||
buildCrashDiagnosticPayload,
|
||||
clearCrashDiagnosticHistory,
|
||||
@@ -19,9 +20,9 @@ import {
|
||||
normalizeCrashReportingConfig,
|
||||
openCrashDiagnosticDownloadDirectory,
|
||||
} from "@/lib/crashDiagnostic";
|
||||
import { getProjectByRootPath, updateProject } from "@/lib/api/project";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ClipboardPermissionGuideCard } from "@/components/settings-v2/system/shared/ClipboardPermissionGuideCard";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { open as openDialog } from "@tauri-apps/plugin-dialog";
|
||||
|
||||
interface CrashRecoveryPanelProps {
|
||||
@@ -197,10 +198,7 @@ export function CrashRecoveryPanel({
|
||||
if (!newPath) {
|
||||
return;
|
||||
}
|
||||
const workspace = await invoke<{ id: string } | null>(
|
||||
"workspace_get_by_path",
|
||||
{ rootPath: oldWorkspacePath },
|
||||
);
|
||||
const workspace = await getProjectByRootPath(oldWorkspacePath);
|
||||
if (!workspace) {
|
||||
setMessage({
|
||||
type: "error",
|
||||
@@ -208,10 +206,7 @@ export function CrashRecoveryPanel({
|
||||
});
|
||||
return;
|
||||
}
|
||||
await invoke("workspace_update", {
|
||||
id: workspace.id,
|
||||
request: { rootPath: newPath },
|
||||
});
|
||||
await updateProject(workspace.id, { rootPath: newPath });
|
||||
setMessage({
|
||||
type: "success",
|
||||
text: `Workspace 路径已更新为:${newPath}`,
|
||||
|
||||
@@ -33,13 +33,11 @@ import {
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { MemoryPageParams, Page, PageParams } from "@/types/page";
|
||||
import { SettingsTabs } from "@/types/settings";
|
||||
import { getConfig, saveConfig, type Config } from "@/lib/api/appConfig";
|
||||
import {
|
||||
getConfig,
|
||||
getMemoryOverview as getContextMemoryOverview,
|
||||
saveConfig,
|
||||
type Config,
|
||||
type MemoryConfig as TauriMemoryConfig,
|
||||
} from "@/hooks/useTauri";
|
||||
} from "@/lib/api/memoryRuntime";
|
||||
import {
|
||||
createCharacter,
|
||||
createOutlineNode,
|
||||
|
||||
@@ -87,7 +87,7 @@ export function OpenClawConfigurePage({
|
||||
: "尚未执行健康检查";
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full flex-col items-center justify-center px-6 py-10">
|
||||
<div className="flex min-h-0 flex-col items-center px-6 py-10">
|
||||
<div className="w-full max-w-[760px] space-y-6">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<OpenClawMark size="lg" />
|
||||
|
||||
@@ -170,7 +170,7 @@ export function OpenClawInstallPage({
|
||||
: "一键修复环境并安装 OpenClaw";
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full flex-col items-center justify-center px-6 py-10">
|
||||
<div className="flex min-h-0 flex-col items-center px-6 py-10">
|
||||
<div className="w-full max-w-5xl space-y-8">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<OpenClawMark size="lg" />
|
||||
|
||||
@@ -215,7 +215,7 @@ function renderBlockedPage(
|
||||
onAction: () => void,
|
||||
) {
|
||||
return (
|
||||
<div className="flex min-h-full flex-col items-center justify-center px-6 py-10">
|
||||
<div className="flex min-h-0 flex-col items-center px-6 py-10">
|
||||
<section className="w-full max-w-2xl rounded-2xl border bg-card p-8 text-center shadow-sm">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
||||
<p className="mt-3 text-sm leading-7 text-muted-foreground">
|
||||
@@ -1199,7 +1199,7 @@ export function OpenClawPage({
|
||||
|
||||
if (!statusResolved && !operationState.running) {
|
||||
return (
|
||||
<div className="flex min-h-full flex-col items-center justify-center px-6 py-10">
|
||||
<div className="flex min-h-0 flex-col items-center px-6 py-10">
|
||||
<div className="flex w-full max-w-xl flex-col items-center rounded-2xl border bg-card px-8 py-10 text-center shadow-sm">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<h1 className="mt-5 text-2xl font-semibold tracking-tight">
|
||||
|
||||
@@ -49,7 +49,7 @@ export function OpenClawProgressPage({
|
||||
onAskAgentFix,
|
||||
}: OpenClawProgressPageProps) {
|
||||
return (
|
||||
<div className="flex min-h-full flex-col items-center justify-center px-6 py-10">
|
||||
<div className="flex min-h-0 flex-col items-center px-6 py-10">
|
||||
<div className="w-full max-w-3xl space-y-8">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<OpenClawMark size="lg" />
|
||||
|
||||
@@ -92,7 +92,7 @@ export function OpenClawRuntimePage({
|
||||
: "尚未执行健康检查";
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full flex-col items-center justify-center px-6 py-10">
|
||||
<div className="flex min-h-0 flex-col items-center px-6 py-10">
|
||||
<div className="w-full max-w-[760px] space-y-6">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<OpenClawMark size="lg" />
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
import React, { useEffect, useState, useCallback, useMemo } from "react";
|
||||
import { safeInvoke, safeListen } from "@/lib/dev-bridge";
|
||||
import { safeListen } from "@/lib/dev-bridge";
|
||||
import {
|
||||
cancelPluginTask,
|
||||
disablePlugin,
|
||||
enablePlugin,
|
||||
getPluginQueueStats,
|
||||
getPluginStatus,
|
||||
getPlugins,
|
||||
getPluginTask,
|
||||
listInstalledPlugins,
|
||||
listPluginTasks,
|
||||
reloadPlugins,
|
||||
unloadPlugin,
|
||||
} from "@/lib/api/plugins";
|
||||
import {
|
||||
Puzzle,
|
||||
RefreshCw,
|
||||
@@ -577,13 +590,11 @@ export function PluginManager() {
|
||||
const fetchRuntimeData = useCallback(async () => {
|
||||
try {
|
||||
const [taskList, queueStatsList] = await Promise.all([
|
||||
safeInvoke<PluginTaskRecord[]>("list_plugin_tasks", {
|
||||
listPluginTasks<PluginTaskRecord>({
|
||||
taskState: taskFilter === "all" ? null : taskFilter,
|
||||
limit: 300,
|
||||
}).catch(() => []),
|
||||
safeInvoke<PluginQueueStats[]>("get_plugin_queue_stats").catch(
|
||||
() => [],
|
||||
),
|
||||
getPluginQueueStats<PluginQueueStats>().catch(() => []),
|
||||
]);
|
||||
|
||||
const groupedTasks = taskList.reduce<Record<string, PluginTaskRecord[]>>(
|
||||
@@ -615,9 +626,9 @@ export function PluginManager() {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [serviceStatus, pluginList, installedList] = await Promise.all([
|
||||
safeInvoke<PluginServiceStatus>("get_plugin_status"),
|
||||
safeInvoke<PluginInfo[]>("get_plugins"),
|
||||
safeInvoke<InstalledPlugin[]>("list_installed_plugins").catch(() => []),
|
||||
getPluginStatus<PluginServiceStatus>(),
|
||||
getPlugins<PluginInfo>(),
|
||||
listInstalledPlugins<InstalledPlugin>().catch(() => []),
|
||||
]);
|
||||
setStatus(serviceStatus);
|
||||
setPlugins(pluginList);
|
||||
@@ -742,9 +753,9 @@ export function PluginManager() {
|
||||
const handleTogglePlugin = async (name: string, currentEnabled: boolean) => {
|
||||
try {
|
||||
if (currentEnabled) {
|
||||
await safeInvoke("disable_plugin", { name });
|
||||
await disablePlugin(name);
|
||||
} else {
|
||||
await safeInvoke("enable_plugin", { name });
|
||||
await enablePlugin(name);
|
||||
}
|
||||
await fetchData();
|
||||
} catch (err) {
|
||||
@@ -754,7 +765,7 @@ export function PluginManager() {
|
||||
|
||||
const handleReloadPlugins = async () => {
|
||||
try {
|
||||
await safeInvoke("reload_plugins");
|
||||
await reloadPlugins();
|
||||
await fetchData();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
@@ -764,9 +775,7 @@ export function PluginManager() {
|
||||
const handleCancelTask = async (taskId: string) => {
|
||||
try {
|
||||
setCancellingTaskId(taskId);
|
||||
const cancelled = await safeInvoke<boolean>("cancel_plugin_task", {
|
||||
taskId,
|
||||
});
|
||||
const cancelled = await cancelPluginTask(taskId);
|
||||
if (cancelled) {
|
||||
toast.success("任务取消请求已发送");
|
||||
} else {
|
||||
@@ -784,12 +793,7 @@ export function PluginManager() {
|
||||
const handleLoadTaskDetail = async (taskId: string) => {
|
||||
try {
|
||||
setDetailLoadingTaskId(taskId);
|
||||
const detail = await safeInvoke<PluginTaskRecord | null>(
|
||||
"get_plugin_task",
|
||||
{
|
||||
taskId,
|
||||
},
|
||||
);
|
||||
const detail = await getPluginTask<PluginTaskRecord>(taskId);
|
||||
if (!detail) {
|
||||
toast.warning("任务详情不存在,可能已被清理");
|
||||
return;
|
||||
@@ -855,7 +859,7 @@ export function PluginManager() {
|
||||
|
||||
const handleUnloadPlugin = async (name: string) => {
|
||||
try {
|
||||
await safeInvoke("unload_plugin", { name });
|
||||
await unloadPlugin(name);
|
||||
await fetchData();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
@@ -1487,10 +1491,10 @@ export function PluginManager() {
|
||||
onToggleEnabled={async () => {
|
||||
try {
|
||||
if (plugin.enabled) {
|
||||
await safeInvoke("disable_plugin", { name: plugin.id });
|
||||
await disablePlugin(plugin.id);
|
||||
toast.success("插件已禁用");
|
||||
} else {
|
||||
await safeInvoke("enable_plugin", { name: plugin.id });
|
||||
await enablePlugin(plugin.id);
|
||||
toast.success("插件已启用");
|
||||
}
|
||||
fetchData();
|
||||
|
||||
@@ -6,10 +6,10 @@
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { safeInvoke } from "@/lib/dev-bridge";
|
||||
import { AlertTriangle, Loader2, Trash2 } from "lucide-react";
|
||||
import { Modal, ModalBody, ModalFooter } from "@/components/Modal";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { uninstallPlugin } from "@/lib/api/plugins";
|
||||
|
||||
/** 已安装插件信息 */
|
||||
interface InstalledPlugin {
|
||||
@@ -54,7 +54,7 @@ export function PluginUninstallDialog({
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await safeInvoke<boolean>("uninstall_plugin", { pluginId: plugin.id });
|
||||
await uninstallPlugin(plugin.id);
|
||||
onSuccess();
|
||||
// 触发侧边栏刷新事件
|
||||
window.dispatchEvent(new CustomEvent("plugin-changed"));
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TYPE_CONFIGS, type UserType } from "@/lib/api/project";
|
||||
import { getConfig } from "@/hooks/useTauri";
|
||||
import { getConfig } from "@/lib/api/appConfig";
|
||||
|
||||
export type ProjectFilter = "all" | UserType | "favorites" | "archived";
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
} from "lucide-react";
|
||||
import type { AmpConfig, AmpModelMapping } from "@/hooks/useTauri";
|
||||
import type { AmpConfig, AmpModelMapping } from "@/lib/api/providerRuntime";
|
||||
|
||||
interface AmpConfigSectionProps {
|
||||
config: AmpConfig;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { Plus, Trash2, FolderOpen, LogIn, RefreshCw } from "lucide-react";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import type { CredentialEntry } from "@/hooks/useTauri";
|
||||
import type { CredentialEntry } from "@/lib/api/providerRuntime";
|
||||
|
||||
interface CodexSectionProps {
|
||||
entries: CredentialEntry[];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { Plus, Trash2, Key, Globe, Ban, Eye, EyeOff } from "lucide-react";
|
||||
import type { GeminiApiKeyEntry } from "@/hooks/useTauri";
|
||||
import type { GeminiApiKeyEntry } from "@/lib/api/providerRuntime";
|
||||
|
||||
interface GeminiApiKeySectionProps {
|
||||
entries: GeminiApiKeyEntry[] | undefined;
|
||||
|
||||
@@ -8,7 +8,10 @@ import {
|
||||
EyeOff,
|
||||
ArrowRight,
|
||||
} from "lucide-react";
|
||||
import type { VertexApiKeyEntry, VertexModelAlias } from "@/hooks/useTauri";
|
||||
import type {
|
||||
VertexApiKeyEntry,
|
||||
VertexModelAlias,
|
||||
} from "@/lib/api/providerRuntime";
|
||||
|
||||
interface VertexAISectionProps {
|
||||
entries: VertexApiKeyEntry[] | undefined;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import {
|
||||
ArrowUp,
|
||||
@@ -53,6 +52,8 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { useProjects } from "@/hooks/useProjects";
|
||||
import { openPathWithDefaultApp } from "@/lib/api/fileSystem";
|
||||
import { listMaterials } from "@/lib/api/materials";
|
||||
import {
|
||||
getStoredResourceProjectId,
|
||||
onResourceProjectChange,
|
||||
@@ -194,7 +195,9 @@ const matchResourceCategory = (
|
||||
if (category === "document") {
|
||||
if (item.kind === "document") return true;
|
||||
if (item.kind !== "file") return false;
|
||||
return !isImageResource(item) && !isAudioResource(item) && !isVideoResource(item);
|
||||
return (
|
||||
!isImageResource(item) && !isAudioResource(item) && !isVideoResource(item)
|
||||
);
|
||||
}
|
||||
if (category === "image") return isImageResource(item);
|
||||
if (category === "audio") return isAudioResource(item);
|
||||
@@ -244,7 +247,9 @@ const sortResources = (
|
||||
field: "updatedAt" | "createdAt" | "name",
|
||||
direction: "asc" | "desc",
|
||||
): ResourceItem[] => {
|
||||
return [...resources].sort((a, b) => compareBySortField(a, b, field, direction));
|
||||
return [...resources].sort((a, b) =>
|
||||
compareBySortField(a, b, field, direction),
|
||||
);
|
||||
};
|
||||
|
||||
export function ResourcesPage({ onNavigate }: ResourcesPageProps) {
|
||||
@@ -310,10 +315,14 @@ export function ResourcesPage({ onNavigate }: ResourcesPageProps) {
|
||||
const categoryCounts = useMemo(
|
||||
() => ({
|
||||
all: items.length,
|
||||
document: items.filter((item) => matchResourceCategory(item, "document")).length,
|
||||
image: items.filter((item) => matchResourceCategory(item, "image")).length,
|
||||
audio: items.filter((item) => matchResourceCategory(item, "audio")).length,
|
||||
video: items.filter((item) => matchResourceCategory(item, "video")).length,
|
||||
document: items.filter((item) => matchResourceCategory(item, "document"))
|
||||
.length,
|
||||
image: items.filter((item) => matchResourceCategory(item, "image"))
|
||||
.length,
|
||||
audio: items.filter((item) => matchResourceCategory(item, "audio"))
|
||||
.length,
|
||||
video: items.filter((item) => matchResourceCategory(item, "video"))
|
||||
.length,
|
||||
}),
|
||||
[items],
|
||||
);
|
||||
@@ -389,7 +398,9 @@ export function ResourcesPage({ onNavigate }: ResourcesPageProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!availableProjects.some((project) => project.id === detail.projectId)) {
|
||||
if (
|
||||
!availableProjects.some((project) => project.id === detail.projectId)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -432,10 +443,8 @@ export function ResourcesPage({ onNavigate }: ResourcesPageProps) {
|
||||
const results = await Promise.all(
|
||||
candidateProjects.map(async (project) => {
|
||||
try {
|
||||
const materials = await invoke<unknown[]>("list_materials", {
|
||||
projectId: project.id,
|
||||
project_id: project.id,
|
||||
filter: { type: viewCategory },
|
||||
const materials = await listMaterials(project.id, {
|
||||
type: viewCategory,
|
||||
});
|
||||
return {
|
||||
projectId: project.id,
|
||||
@@ -525,48 +534,53 @@ export function ResourcesPage({ onNavigate }: ResourcesPageProps) {
|
||||
}
|
||||
|
||||
try {
|
||||
await invoke("open_with_default_app", { path: item.filePath });
|
||||
await openPathWithDefaultApp(item.filePath);
|
||||
} catch (invokeError) {
|
||||
toast.error(
|
||||
invokeError instanceof Error ? invokeError.message : String(invokeError),
|
||||
invokeError instanceof Error
|
||||
? invokeError.message
|
||||
: String(invokeError),
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleOpenDocument = useCallback(async (item: ResourceItem) => {
|
||||
if (onNavigate) {
|
||||
onNavigate("agent", {
|
||||
projectId: item.projectId,
|
||||
contentId: item.id,
|
||||
lockTheme: true,
|
||||
fromResources: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setPreviewOpen(true);
|
||||
setPreviewLoading(true);
|
||||
setPreviewTitle(item.name);
|
||||
setPreviewContent("");
|
||||
|
||||
try {
|
||||
const detail = await fetchDocumentDetail(item.id);
|
||||
if (!detail) {
|
||||
setPreviewContent("文档不存在或已被删除。");
|
||||
const handleOpenDocument = useCallback(
|
||||
async (item: ResourceItem) => {
|
||||
if (onNavigate) {
|
||||
onNavigate("agent", {
|
||||
projectId: item.projectId,
|
||||
contentId: item.id,
|
||||
lockTheme: true,
|
||||
fromResources: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setPreviewTitle(detail.title);
|
||||
setPreviewContent(detail.body || "");
|
||||
} catch (detailError) {
|
||||
setPreviewContent(
|
||||
detailError instanceof Error
|
||||
? `读取失败:${detailError.message}`
|
||||
: `读取失败:${String(detailError)}`,
|
||||
);
|
||||
} finally {
|
||||
setPreviewLoading(false);
|
||||
}
|
||||
}, [onNavigate]);
|
||||
|
||||
setPreviewOpen(true);
|
||||
setPreviewLoading(true);
|
||||
setPreviewTitle(item.name);
|
||||
setPreviewContent("");
|
||||
|
||||
try {
|
||||
const detail = await fetchDocumentDetail(item.id);
|
||||
if (!detail) {
|
||||
setPreviewContent("文档不存在或已被删除。");
|
||||
return;
|
||||
}
|
||||
setPreviewTitle(detail.title);
|
||||
setPreviewContent(detail.body || "");
|
||||
} catch (detailError) {
|
||||
setPreviewContent(
|
||||
detailError instanceof Error
|
||||
? `读取失败:${detailError.message}`
|
||||
: `读取失败:${String(detailError)}`,
|
||||
);
|
||||
} finally {
|
||||
setPreviewLoading(false);
|
||||
}
|
||||
},
|
||||
[onNavigate],
|
||||
);
|
||||
|
||||
const handleOpenResource = useCallback(
|
||||
async (item: ResourceItem) => {
|
||||
@@ -618,7 +632,9 @@ export function ResourcesPage({ onNavigate }: ResourcesPageProps) {
|
||||
key: "upload-folder",
|
||||
label: "上传文件夹",
|
||||
action: () => {
|
||||
toast.info("当前版本暂不支持文件夹上传,可先创建文件夹后逐个上传文件");
|
||||
toast.info(
|
||||
"当前版本暂不支持文件夹上传,可先创建文件夹后逐个上传文件",
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -642,7 +658,9 @@ export function ResourcesPage({ onNavigate }: ResourcesPageProps) {
|
||||
<aside className="border-b bg-muted/20 p-3 lg:border-b-0 lg:border-r">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="px-2 text-xs font-medium text-muted-foreground">资源</p>
|
||||
<p className="px-2 text-xs font-medium text-muted-foreground">
|
||||
资源
|
||||
</p>
|
||||
<div className="mt-1 space-y-1">
|
||||
{resourceCategoryItems.map(({ key, label, icon: Icon }) => (
|
||||
<button
|
||||
@@ -650,7 +668,8 @@ export function ResourcesPage({ onNavigate }: ResourcesPageProps) {
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between rounded-lg px-2 py-2 text-sm transition-colors hover:bg-muted",
|
||||
viewCategory === key && "bg-background text-foreground shadow-sm",
|
||||
viewCategory === key &&
|
||||
"bg-background text-foreground shadow-sm",
|
||||
)}
|
||||
onClick={() => setViewCategory(key)}
|
||||
>
|
||||
@@ -658,7 +677,9 @@ export function ResourcesPage({ onNavigate }: ResourcesPageProps) {
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{categoryCounts[key]}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{categoryCounts[key]}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -668,13 +689,17 @@ export function ResourcesPage({ onNavigate }: ResourcesPageProps) {
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between px-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">库</p>
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
库
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => toast.info("资源库来源于项目,请在项目模块中创建")}
|
||||
onClick={() =>
|
||||
toast.info("资源库来源于项目,请在项目模块中创建")
|
||||
}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -683,7 +708,9 @@ export function ResourcesPage({ onNavigate }: ResourcesPageProps) {
|
||||
<button
|
||||
type="button"
|
||||
className="mt-2 w-full rounded-lg border border-dashed bg-background px-3 py-2 text-left text-xs text-muted-foreground hover:border-primary/40 hover:text-foreground"
|
||||
onClick={() => toast.info("资源库来源于项目,请在项目模块中创建")}
|
||||
onClick={() =>
|
||||
toast.info("资源库来源于项目,请在项目模块中创建")
|
||||
}
|
||||
>
|
||||
+ 新建资源库
|
||||
</button>
|
||||
@@ -706,7 +733,9 @@ export function ResourcesPage({ onNavigate }: ResourcesPageProps) {
|
||||
)}
|
||||
onClick={() => setProjectId(project.id)}
|
||||
>
|
||||
<div className="truncate font-medium">{project.name}</div>
|
||||
<div className="truncate font-medium">
|
||||
{project.name}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{project.id.slice(0, 8)}
|
||||
</div>
|
||||
@@ -724,9 +753,12 @@ export function ResourcesPage({ onNavigate }: ResourcesPageProps) {
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold tracking-tight">
|
||||
{currentFolder?.name ?? resourceCategoryLabelMap[viewCategory]}
|
||||
{currentFolder?.name ??
|
||||
resourceCategoryLabelMap[viewCategory]}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{headingDescription}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{headingDescription}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -778,7 +810,9 @@ export function ResourcesPage({ onNavigate }: ResourcesPageProps) {
|
||||
value={searchQuery}
|
||||
onChange={(event) => setSearchQuery(event.target.value)}
|
||||
placeholder={
|
||||
isFolderMode ? "按名称、描述或标签搜索" : "搜索当前分类资源"
|
||||
isFolderMode
|
||||
? "按名称、描述或标签搜索"
|
||||
: "搜索当前分类资源"
|
||||
}
|
||||
className="pl-8"
|
||||
/>
|
||||
@@ -809,8 +843,14 @@ export function ResourcesPage({ onNavigate }: ResourcesPageProps) {
|
||||
{sortDirection === "asc" ? "升序" : "降序"}
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" onClick={refresh} disabled={!projectId || loading}>
|
||||
<RefreshCw className={cn("mr-2 h-4 w-4", loading && "animate-spin")} />
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={refresh}
|
||||
disabled={!projectId || loading}
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn("mr-2 h-4 w-4", loading && "animate-spin")}
|
||||
/>
|
||||
刷新
|
||||
</Button>
|
||||
|
||||
@@ -841,7 +881,8 @@ export function ResourcesPage({ onNavigate }: ResourcesPageProps) {
|
||||
key={folder.id}
|
||||
className={cn(
|
||||
"rounded px-2 py-1 hover:bg-muted",
|
||||
currentFolderId === folder.id && "bg-muted text-foreground",
|
||||
currentFolderId === folder.id &&
|
||||
"bg-muted text-foreground",
|
||||
)}
|
||||
onClick={() => setCurrentFolderId(folder.id)}
|
||||
type="button"
|
||||
@@ -852,21 +893,27 @@ export function ResourcesPage({ onNavigate }: ResourcesPageProps) {
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-3 rounded-md bg-muted/50 px-3 py-2 text-sm text-muted-foreground">
|
||||
当前为「{resourceCategoryLabelMap[viewCategory]}」分类视图,展示整个资源库内该分类内容
|
||||
当前为「{resourceCategoryLabelMap[viewCategory]}
|
||||
」分类视图,展示整个资源库内该分类内容
|
||||
</div>
|
||||
)}
|
||||
{crossProjectMediaHint && (
|
||||
<div className="mt-2 flex items-center justify-between gap-3 rounded-md border border-amber-300/70 bg-amber-50 px-3 py-2 text-sm text-amber-900">
|
||||
<span className="truncate">
|
||||
当前资源库暂无{mediaCategoryLabelMap[crossProjectMediaHint.category]},检测到「
|
||||
{crossProjectMediaHint.projectName}」包含 {crossProjectMediaHint.count} 个
|
||||
当前资源库暂无
|
||||
{mediaCategoryLabelMap[crossProjectMediaHint.category]}
|
||||
,检测到「
|
||||
{crossProjectMediaHint.projectName}」包含{" "}
|
||||
{crossProjectMediaHint.count} 个
|
||||
{mediaCategoryLabelMap[crossProjectMediaHint.category]}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setProjectId(crossProjectMediaHint.projectId)}
|
||||
onClick={() =>
|
||||
setProjectId(crossProjectMediaHint.projectId)
|
||||
}
|
||||
>
|
||||
切换查看
|
||||
</Button>
|
||||
@@ -891,7 +938,9 @@ export function ResourcesPage({ onNavigate }: ResourcesPageProps) {
|
||||
</div>
|
||||
) : showEmptyState ? (
|
||||
<div className="flex min-h-[420px] flex-col items-center justify-center rounded-xl border bg-muted/10 px-6 py-12 text-center">
|
||||
<h3 className="text-3xl font-semibold tracking-tight">把文件或文件夹拖到这里</h3>
|
||||
<h3 className="text-3xl font-semibold tracking-tight">
|
||||
把文件或文件夹拖到这里
|
||||
</h3>
|
||||
<p className="mt-2 text-lg text-muted-foreground">或者</p>
|
||||
<div className="mt-8 grid w-full max-w-3xl gap-4 sm:grid-cols-3">
|
||||
{emptyActions.map((item) => (
|
||||
@@ -915,7 +964,9 @@ export function ResourcesPage({ onNavigate }: ResourcesPageProps) {
|
||||
<TableHead className="w-[120px]">类型</TableHead>
|
||||
<TableHead className="w-[120px]">来源</TableHead>
|
||||
<TableHead className="w-[220px]">更新时间</TableHead>
|
||||
<TableHead className="w-[80px] text-right">操作</TableHead>
|
||||
<TableHead className="w-[80px] text-right">
|
||||
操作
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -940,12 +991,18 @@ export function ResourcesPage({ onNavigate }: ResourcesPageProps) {
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={item.kind === "folder" ? "default" : "outline"}>
|
||||
<Badge
|
||||
variant={
|
||||
item.kind === "folder" ? "default" : "outline"
|
||||
}
|
||||
>
|
||||
{kindLabelMap[item.kind]}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">{sourceLabelMap[item.sourceType]}</Badge>
|
||||
<Badge variant="secondary">
|
||||
{sourceLabelMap[item.sourceType]}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{formatTime(item.updatedAt)}
|
||||
@@ -963,7 +1020,9 @@ export function ResourcesPage({ onNavigate }: ResourcesPageProps) {
|
||||
void handleOpenResource(item);
|
||||
}}
|
||||
>
|
||||
{item.kind === "folder" ? "进入文件夹" : "打开"}
|
||||
{item.kind === "folder"
|
||||
? "进入文件夹"
|
||||
: "打开"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
@@ -973,16 +1032,17 @@ export function ResourcesPage({ onNavigate }: ResourcesPageProps) {
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
重命名
|
||||
</DropdownMenuItem>
|
||||
{item.sourceType === "content" && item.parentId && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
void moveToRoot(item.id);
|
||||
}}
|
||||
>
|
||||
<ArrowUp className="mr-2 h-4 w-4" />
|
||||
移动到根目录
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{item.sourceType === "content" &&
|
||||
item.parentId && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
void moveToRoot(item.id);
|
||||
}}
|
||||
>
|
||||
<ArrowUp className="mr-2 h-4 w-4" />
|
||||
移动到根目录
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => {
|
||||
@@ -1014,7 +1074,9 @@ export function ResourcesPage({ onNavigate }: ResourcesPageProps) {
|
||||
</DialogHeader>
|
||||
<ScrollArea className="max-h-[60vh] rounded border p-3">
|
||||
{previewLoading ? (
|
||||
<div className="text-sm text-muted-foreground">加载文档内容中...</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
加载文档内容中...
|
||||
</div>
|
||||
) : (
|
||||
<pre className="whitespace-pre-wrap break-words text-sm">
|
||||
{previewContent || "暂无内容"}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import {
|
||||
createContent,
|
||||
deleteContent,
|
||||
@@ -7,28 +6,15 @@ import {
|
||||
updateContent,
|
||||
type ContentListItem,
|
||||
} from "@/lib/api/project";
|
||||
import type { MaterialType } from "@/types/material";
|
||||
import {
|
||||
deleteMaterial,
|
||||
listMaterials,
|
||||
updateMaterial,
|
||||
uploadMaterial,
|
||||
} from "@/lib/api/materials";
|
||||
import type { Material, MaterialType } from "@/types/material";
|
||||
import type { ResourceItem, ResourceMetadata } from "./types";
|
||||
|
||||
type RawMaterial = {
|
||||
id: string;
|
||||
name?: string;
|
||||
type?: string;
|
||||
material_type?: string;
|
||||
projectId?: string;
|
||||
project_id?: string;
|
||||
filePath?: string;
|
||||
file_path?: string;
|
||||
fileSize?: number;
|
||||
file_size?: number;
|
||||
mimeType?: string;
|
||||
mime_type?: string;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
createdAt?: number;
|
||||
created_at?: number;
|
||||
};
|
||||
|
||||
const IMAGE_EXTENSIONS = new Set([
|
||||
"jpg",
|
||||
"jpeg",
|
||||
@@ -133,7 +119,10 @@ const normalizeMaterialType = (
|
||||
fileName: string,
|
||||
): MaterialType => {
|
||||
const normalizedRaw = rawMaterialType?.toLowerCase().trim();
|
||||
if (normalizedRaw && KNOWN_MATERIAL_TYPES.has(normalizedRaw as MaterialType)) {
|
||||
if (
|
||||
normalizedRaw &&
|
||||
KNOWN_MATERIAL_TYPES.has(normalizedRaw as MaterialType)
|
||||
) {
|
||||
return normalizedRaw as MaterialType;
|
||||
}
|
||||
|
||||
@@ -175,19 +164,19 @@ const mapContentToResource = (item: ContentListItem): ResourceItem | null => {
|
||||
};
|
||||
|
||||
const mapMaterialToResource = (
|
||||
item: RawMaterial,
|
||||
item: Material,
|
||||
fallbackProjectId: string,
|
||||
): ResourceItem => {
|
||||
const name = item.name ?? "未命名文件";
|
||||
const filePath = item.filePath ?? item.file_path;
|
||||
const mimeType = item.mimeType ?? item.mime_type;
|
||||
const filePath = item.filePath;
|
||||
const mimeType = item.mimeType;
|
||||
const materialType = normalizeMaterialType(
|
||||
(item.type ?? item.material_type)?.toString(),
|
||||
item.type?.toString(),
|
||||
mimeType,
|
||||
filePath,
|
||||
name,
|
||||
);
|
||||
const projectId = (item.projectId ?? item.project_id ?? fallbackProjectId).toString();
|
||||
const projectId = (item.projectId || fallbackProjectId).toString();
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
@@ -196,9 +185,9 @@ const mapMaterialToResource = (
|
||||
kind: "file",
|
||||
sourceType: "material",
|
||||
parentId: null,
|
||||
createdAt: toTimestampMs(item.createdAt ?? item.created_at),
|
||||
updatedAt: toTimestampMs(item.createdAt ?? item.created_at),
|
||||
size: item.fileSize ?? item.file_size,
|
||||
createdAt: toTimestampMs(item.createdAt),
|
||||
updatedAt: toTimestampMs(item.createdAt),
|
||||
size: item.fileSize,
|
||||
fileType: materialType,
|
||||
mimeType,
|
||||
filePath,
|
||||
@@ -244,11 +233,7 @@ export const fetchProjectResources = async (
|
||||
sort_by: "updated_at",
|
||||
sort_order: "desc",
|
||||
}),
|
||||
invoke<RawMaterial[]>("list_materials", {
|
||||
projectId,
|
||||
project_id: projectId,
|
||||
filter: null,
|
||||
}),
|
||||
listMaterials(projectId),
|
||||
]);
|
||||
|
||||
const contentResources = contents
|
||||
@@ -299,19 +284,18 @@ export const renameResource = async (
|
||||
name: string,
|
||||
): Promise<void> => {
|
||||
if (item.sourceType === "material") {
|
||||
await invoke("update_material", {
|
||||
id: item.id,
|
||||
update: { name },
|
||||
});
|
||||
await updateMaterial(item.id, { name });
|
||||
return;
|
||||
}
|
||||
|
||||
await updateContent(item.id, { title: name });
|
||||
};
|
||||
|
||||
export const deleteSingleResource = async (item: ResourceItem): Promise<void> => {
|
||||
export const deleteSingleResource = async (
|
||||
item: ResourceItem,
|
||||
): Promise<void> => {
|
||||
if (item.sourceType === "material") {
|
||||
await invoke("delete_material", { id: item.id });
|
||||
await deleteMaterial(item.id);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -341,14 +325,12 @@ export const uploadFileResource = async (
|
||||
projectId: string,
|
||||
filePath: string,
|
||||
): Promise<void> => {
|
||||
await invoke("upload_material", {
|
||||
req: {
|
||||
projectId,
|
||||
name: extractFileName(filePath),
|
||||
type: inferMaterialType(filePath),
|
||||
filePath,
|
||||
tags: [],
|
||||
},
|
||||
await uploadMaterial({
|
||||
projectId,
|
||||
name: extractFileName(filePath),
|
||||
type: inferMaterialType(filePath),
|
||||
filePath,
|
||||
tags: [],
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ import { ProviderPoolPage } from "@/components/provider-pool";
|
||||
import { ApiServerPage } from "@/components/api-server/ApiServerPage";
|
||||
import { McpPanel } from "@/components/mcp";
|
||||
import { ChannelsSettings } from "../system/channels";
|
||||
import { EnvironmentSettings } from "../system/environment";
|
||||
import { WebSearchSettings } from "../system/web-search";
|
||||
import { ChromeRelaySettings } from "../system/chrome-relay";
|
||||
|
||||
@@ -244,6 +245,14 @@ function renderSettingsContent(tab: SettingsTabs): ReactNode {
|
||||
</>
|
||||
);
|
||||
|
||||
case SettingsTabs.Environment:
|
||||
return (
|
||||
<>
|
||||
<SettingHeader title="环境变量" />
|
||||
<EnvironmentSettings />
|
||||
</>
|
||||
);
|
||||
|
||||
case SettingsTabs.ChromeRelay:
|
||||
return (
|
||||
<>
|
||||
@@ -321,6 +330,7 @@ const WIDE_CONTENT_TABS = new Set<SettingsTabs>([
|
||||
SettingsTabs.ApiServer,
|
||||
SettingsTabs.McpServer,
|
||||
SettingsTabs.Channels,
|
||||
SettingsTabs.Environment,
|
||||
SettingsTabs.ExecutionTracker,
|
||||
]);
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getConfig, saveConfig, Config } from "@/hooks/useTauri";
|
||||
import { getConfig, saveConfig, type Config } from "@/lib/api/appConfig";
|
||||
|
||||
interface UserProfile {
|
||||
/** 用户头像 URL */
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
type DailyUsage,
|
||||
type ModelUsage,
|
||||
type UsageStatsResponse,
|
||||
} from "@/hooks/useTauri";
|
||||
} from "@/lib/api/usageStats";
|
||||
|
||||
export function StatsSettings() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getConfig, saveConfig, Config } from "@/hooks/useTauri";
|
||||
import { getConfig, saveConfig, type Config } from "@/lib/api/appConfig";
|
||||
|
||||
interface AssistantProfile {
|
||||
id: string;
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useApiKeyProvider } from "@/hooks/useApiKeyProvider";
|
||||
import { getConfig, saveConfig, Config } from "@/hooks/useTauri";
|
||||
import { getConfig, saveConfig, type Config } from "@/lib/api/appConfig";
|
||||
import {
|
||||
buildPersistedMediaGenerationPreference,
|
||||
hasMediaGenerationPreferenceOverride,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Film, CheckCircle2, AlertCircle } from "lucide-react";
|
||||
import { useApiKeyProvider } from "@/hooks/useApiKeyProvider";
|
||||
import { getConfig, saveConfig, type Config } from "@/hooks/useTauri";
|
||||
import { getConfig, saveConfig, type Config } from "@/lib/api/appConfig";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
buildPersistedMediaGenerationPreference,
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useApiKeyProvider } from "@/hooks/useApiKeyProvider";
|
||||
import { getConfig, saveConfig, Config } from "@/hooks/useTauri";
|
||||
import { getConfig, saveConfig, type Config } from "@/lib/api/appConfig";
|
||||
import {
|
||||
buildPersistedMediaGenerationPreference,
|
||||
getTtsModelsForProvider,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import styled from "styled-components";
|
||||
import { Moon, Sun, Monitor, Volume2, RotateCcw } from "lucide-react";
|
||||
import { getConfig, saveConfig, Config } from "@/hooks/useTauri";
|
||||
import { getConfig, saveConfig, type Config } from "@/lib/api/appConfig";
|
||||
import { useOnboardingState } from "@/components/onboarding";
|
||||
import {
|
||||
LanguageSelector,
|
||||
@@ -85,9 +85,12 @@ const ThemeButton = styled.button<{ $active: boolean }>`
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
background: ${({ $active }) => ($active ? "hsl(var(--background))" : "transparent")};
|
||||
color: ${({ $active }) => ($active ? "hsl(var(--foreground))" : "hsl(var(--muted-foreground))")};
|
||||
box-shadow: ${({ $active }) => ($active ? "0 1px 3px rgba(0,0,0,0.1)" : "none")};
|
||||
background: ${({ $active }) =>
|
||||
$active ? "hsl(var(--background))" : "transparent"};
|
||||
color: ${({ $active }) =>
|
||||
$active ? "hsl(var(--foreground))" : "hsl(var(--muted-foreground))"};
|
||||
box-shadow: ${({ $active }) =>
|
||||
$active ? "0 1px 3px rgba(0,0,0,0.1)" : "none"};
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
@@ -102,143 +105,150 @@ const ThemeButton = styled.button<{ $active: boolean }>`
|
||||
`;
|
||||
|
||||
export function AppearanceSettings() {
|
||||
const [theme, setTheme] = useState<Theme>("system");
|
||||
const [language, setLanguageState] = useState<Language>("zh");
|
||||
const [config, setConfig] = useState<Config | null>(null);
|
||||
const [theme, setTheme] = useState<Theme>("system");
|
||||
const [language, setLanguageState] = useState<Language>("zh");
|
||||
const [config, setConfig] = useState<Config | null>(null);
|
||||
|
||||
const { setLanguage: setI18nLanguage } = useI18nPatch();
|
||||
const { soundEnabled, setSoundEnabled, playToolcallSound } = useSoundContext();
|
||||
const { resetOnboarding } = useOnboardingState();
|
||||
const { setLanguage: setI18nLanguage } = useI18nPatch();
|
||||
const { soundEnabled, setSoundEnabled, playToolcallSound } =
|
||||
useSoundContext();
|
||||
const { resetOnboarding } = useOnboardingState();
|
||||
|
||||
useEffect(() => {
|
||||
const savedTheme = localStorage.getItem("theme") as Theme | null;
|
||||
if (savedTheme) {
|
||||
setTheme(savedTheme);
|
||||
}
|
||||
loadConfig();
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const savedTheme = localStorage.getItem("theme") as Theme | null;
|
||||
if (savedTheme) {
|
||||
setTheme(savedTheme);
|
||||
}
|
||||
loadConfig();
|
||||
}, []);
|
||||
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
const c = await getConfig();
|
||||
setConfig(c);
|
||||
setLanguageState((c.language || "zh") as Language);
|
||||
} catch (e) {
|
||||
console.error("加载配置失败:", e);
|
||||
}
|
||||
};
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
const c = await getConfig();
|
||||
setConfig(c);
|
||||
setLanguageState((c.language || "zh") as Language);
|
||||
} catch (e) {
|
||||
console.error("加载配置失败:", e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleThemeChange = (newTheme: Theme) => {
|
||||
setTheme(newTheme);
|
||||
localStorage.setItem("theme", newTheme);
|
||||
const root = document.documentElement;
|
||||
if (newTheme === "system") {
|
||||
const systemDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
root.classList.toggle("dark", systemDark);
|
||||
} else {
|
||||
root.classList.toggle("dark", newTheme === "dark");
|
||||
}
|
||||
};
|
||||
const handleThemeChange = (newTheme: Theme) => {
|
||||
setTheme(newTheme);
|
||||
localStorage.setItem("theme", newTheme);
|
||||
const root = document.documentElement;
|
||||
if (newTheme === "system") {
|
||||
const systemDark = window.matchMedia(
|
||||
"(prefers-color-scheme: dark)",
|
||||
).matches;
|
||||
root.classList.toggle("dark", systemDark);
|
||||
} else {
|
||||
root.classList.toggle("dark", newTheme === "dark");
|
||||
}
|
||||
};
|
||||
|
||||
const handleLanguageChange = async (newLanguage: Language) => {
|
||||
if (!config) return;
|
||||
try {
|
||||
const newConfig = { ...config, language: newLanguage };
|
||||
await saveConfig(newConfig);
|
||||
setConfig(newConfig);
|
||||
setLanguageState(newLanguage);
|
||||
setI18nLanguage(newLanguage);
|
||||
} catch (err) {
|
||||
console.error("保存语言设置失败:", err);
|
||||
}
|
||||
};
|
||||
const handleLanguageChange = async (newLanguage: Language) => {
|
||||
if (!config) return;
|
||||
try {
|
||||
const newConfig = { ...config, language: newLanguage };
|
||||
await saveConfig(newConfig);
|
||||
setConfig(newConfig);
|
||||
setLanguageState(newLanguage);
|
||||
setI18nLanguage(newLanguage);
|
||||
} catch (err) {
|
||||
console.error("保存语言设置失败:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetOnboarding = useCallback(() => {
|
||||
resetOnboarding();
|
||||
window.location.reload();
|
||||
}, [resetOnboarding]);
|
||||
const handleResetOnboarding = useCallback(() => {
|
||||
resetOnboarding();
|
||||
window.location.reload();
|
||||
}, [resetOnboarding]);
|
||||
|
||||
const themeOptions = [
|
||||
{ id: "light" as Theme, label: "浅色", icon: Sun },
|
||||
{ id: "dark" as Theme, label: "深色", icon: Moon },
|
||||
{ id: "system" as Theme, label: "系统", icon: Monitor },
|
||||
];
|
||||
const themeOptions = [
|
||||
{ id: "light" as Theme, label: "浅色", icon: Sun },
|
||||
{ id: "dark" as Theme, label: "深色", icon: Moon },
|
||||
{ id: "system" as Theme, label: "系统", icon: Monitor },
|
||||
];
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Section>
|
||||
<SectionTitle>基础外观</SectionTitle>
|
||||
return (
|
||||
<Container>
|
||||
<Section>
|
||||
<SectionTitle>基础外观</SectionTitle>
|
||||
|
||||
<SettingItem>
|
||||
<SettingInfo>
|
||||
<SettingLabel>主题模式</SettingLabel>
|
||||
<SettingDescription>选择应用的主题颜色体系</SettingDescription>
|
||||
</SettingInfo>
|
||||
<ThemeButtonGroup>
|
||||
{themeOptions.map((option) => (
|
||||
<ThemeButton
|
||||
key={option.id}
|
||||
$active={theme === option.id}
|
||||
onClick={() => handleThemeChange(option.id)}
|
||||
>
|
||||
<option.icon />
|
||||
{option.label}
|
||||
</ThemeButton>
|
||||
))}
|
||||
</ThemeButtonGroup>
|
||||
</SettingItem>
|
||||
<SettingItem>
|
||||
<SettingInfo>
|
||||
<SettingLabel>主题模式</SettingLabel>
|
||||
<SettingDescription>选择应用的主题颜色体系</SettingDescription>
|
||||
</SettingInfo>
|
||||
<ThemeButtonGroup>
|
||||
{themeOptions.map((option) => (
|
||||
<ThemeButton
|
||||
key={option.id}
|
||||
$active={theme === option.id}
|
||||
onClick={() => handleThemeChange(option.id)}
|
||||
>
|
||||
<option.icon />
|
||||
{option.label}
|
||||
</ThemeButton>
|
||||
))}
|
||||
</ThemeButtonGroup>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem>
|
||||
<SettingInfo>
|
||||
<SettingLabel>语言</SettingLabel>
|
||||
<SettingDescription>选择应用的显示语言</SettingDescription>
|
||||
</SettingInfo>
|
||||
<LanguageSelector
|
||||
currentLanguage={language}
|
||||
onLanguageChange={handleLanguageChange}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem>
|
||||
<SettingInfo>
|
||||
<SettingLabel>语言</SettingLabel>
|
||||
<SettingDescription>选择应用的显示语言</SettingDescription>
|
||||
</SettingInfo>
|
||||
<LanguageSelector
|
||||
currentLanguage={language}
|
||||
onLanguageChange={handleLanguageChange}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem>
|
||||
<SettingInfo>
|
||||
<SettingLabel>
|
||||
<Volume2 className="h-4 w-4" />
|
||||
提示音效
|
||||
</SettingLabel>
|
||||
<SettingDescription>在工具调用和消息生成时播放提示音</SettingDescription>
|
||||
</SettingInfo>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={soundEnabled}
|
||||
onChange={(e) => {
|
||||
setSoundEnabled(e.target.checked);
|
||||
if (e.target.checked) {
|
||||
playToolcallSound();
|
||||
}
|
||||
}}
|
||||
className="w-4 h-4 rounded border-gray-300"
|
||||
/>
|
||||
</SettingItem>
|
||||
</Section>
|
||||
<SettingItem>
|
||||
<SettingInfo>
|
||||
<SettingLabel>
|
||||
<Volume2 className="h-4 w-4" />
|
||||
提示音效
|
||||
</SettingLabel>
|
||||
<SettingDescription>
|
||||
在工具调用和消息生成时播放提示音
|
||||
</SettingDescription>
|
||||
</SettingInfo>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={soundEnabled}
|
||||
onChange={(e) => {
|
||||
setSoundEnabled(e.target.checked);
|
||||
if (e.target.checked) {
|
||||
playToolcallSound();
|
||||
}
|
||||
}}
|
||||
className="w-4 h-4 rounded border-gray-300"
|
||||
/>
|
||||
</SettingItem>
|
||||
</Section>
|
||||
|
||||
<Section>
|
||||
<SectionTitle>初始化</SectionTitle>
|
||||
<SettingItem>
|
||||
<SettingInfo>
|
||||
<SettingLabel>重置向导设置</SettingLabel>
|
||||
<SettingDescription>遇到问题或想重新选择启动选项时,可重新运行初始化向导</SettingDescription>
|
||||
</SettingInfo>
|
||||
<button
|
||||
onClick={handleResetOnboarding}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded border border-input bg-background hover:bg-accent hover:text-accent-foreground text-sm font-medium transition-colors"
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
重新运行引导
|
||||
</button>
|
||||
</SettingItem>
|
||||
</Section>
|
||||
</Container>
|
||||
);
|
||||
<Section>
|
||||
<SectionTitle>初始化</SectionTitle>
|
||||
<SettingItem>
|
||||
<SettingInfo>
|
||||
<SettingLabel>重置向导设置</SettingLabel>
|
||||
<SettingDescription>
|
||||
遇到问题或想重新选择启动选项时,可重新运行初始化向导
|
||||
</SettingDescription>
|
||||
</SettingInfo>
|
||||
<button
|
||||
onClick={handleResetOnboarding}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded border border-input bg-background hover:bg-accent hover:text-accent-foreground text-sm font-medium transition-colors"
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
重新运行引导
|
||||
</button>
|
||||
</SettingItem>
|
||||
</Section>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default AppearanceSettings;
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useState, useEffect } from "react";
|
||||
import styled from "styled-components";
|
||||
import { Palette } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getConfig, saveConfig, Config } from "@/hooks/useTauri";
|
||||
import { getConfig, saveConfig, type Config } from "@/lib/api/appConfig";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
const ALL_CONTENT_THEMES = [
|
||||
@@ -41,11 +41,7 @@ const ALL_NAV_ITEMS = [
|
||||
{ id: "tools", label: "工具箱" },
|
||||
] as const;
|
||||
|
||||
const DEFAULT_ENABLED_NAV_ITEMS = [
|
||||
"home-general",
|
||||
"video",
|
||||
"image-gen",
|
||||
];
|
||||
const DEFAULT_ENABLED_NAV_ITEMS = ["home-general", "video", "image-gen"];
|
||||
|
||||
const ALL_NAV_ITEM_ID_SET = new Set<string>(
|
||||
ALL_NAV_ITEMS.map((item) => item.id),
|
||||
@@ -196,8 +192,10 @@ export function ChatAppearanceSettings() {
|
||||
const [enabledNavItems, setEnabledNavItems] = useState<string[]>(
|
||||
DEFAULT_ENABLED_NAV_ITEMS,
|
||||
);
|
||||
const [appendSelectedTextToRecommendation, setAppendSelectedTextToRecommendation] =
|
||||
useState(true);
|
||||
const [
|
||||
appendSelectedTextToRecommendation,
|
||||
setAppendSelectedTextToRecommendation,
|
||||
] = useState(true);
|
||||
const [config, setConfig] = useState<Config | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -300,7 +298,9 @@ export function ChatAppearanceSettings() {
|
||||
</SettingIcon>
|
||||
<SettingInfo>
|
||||
<SettingLabel>创作模式卡片</SettingLabel>
|
||||
<SettingDescription>选择您希望在创建新项目时可以使用的快捷内容创作模板,它们会在新对话页面展现。</SettingDescription>
|
||||
<SettingDescription>
|
||||
选择您希望在创建新项目时可以使用的快捷内容创作模板,它们会在新对话页面展现。
|
||||
</SettingDescription>
|
||||
</SettingInfo>
|
||||
</SettingHeader>
|
||||
|
||||
@@ -330,7 +330,9 @@ export function ChatAppearanceSettings() {
|
||||
</SettingIcon>
|
||||
<SettingInfo>
|
||||
<SettingLabel>左侧边栏导航</SettingLabel>
|
||||
<SettingDescription>定制主视图左侧边栏启用的常驻导航图标入口,最少须保留一个。</SettingDescription>
|
||||
<SettingDescription>
|
||||
定制主视图左侧边栏启用的常驻导航图标入口,最少须保留一个。
|
||||
</SettingDescription>
|
||||
</SettingInfo>
|
||||
</SettingHeader>
|
||||
|
||||
@@ -360,19 +362,22 @@ export function ChatAppearanceSettings() {
|
||||
</SettingIcon>
|
||||
<SettingInfo>
|
||||
<SettingLabel>推荐自动附带选中内容</SettingLabel>
|
||||
<SettingDescription>开启后,点击推荐提示词会自动追加当前编辑器选中文本作为上下文。</SettingDescription>
|
||||
<SettingDescription>
|
||||
开启后,点击推荐提示词会自动追加当前编辑器选中文本作为上下文。
|
||||
</SettingDescription>
|
||||
</SettingInfo>
|
||||
</SettingHeader>
|
||||
|
||||
<ToggleRow>
|
||||
<ToggleInfo>建议开启:更贴合当前段落;关闭可避免附加额外上下文。</ToggleInfo>
|
||||
<ToggleInfo>
|
||||
建议开启:更贴合当前段落;关闭可避免附加额外上下文。
|
||||
</ToggleInfo>
|
||||
<Switch
|
||||
checked={appendSelectedTextToRecommendation}
|
||||
onCheckedChange={handleRecommendationSelectionToggle}
|
||||
/>
|
||||
</ToggleRow>
|
||||
</SettingItem>
|
||||
|
||||
</Section>
|
||||
</Container>
|
||||
);
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import styled from "styled-components";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { getExperimentalConfig } from "@/hooks/useTauri";
|
||||
import { getExperimentalConfig } from "@/lib/api/experimentalFeatures";
|
||||
import {
|
||||
getVoiceInputConfig,
|
||||
type VoiceInputConfig,
|
||||
|
||||
@@ -2,22 +2,20 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Brain, Loader2, RefreshCw } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
getConfig,
|
||||
getMemoryAutoIndex,
|
||||
getMemoryEffectiveSources,
|
||||
getMemoryOverview as getContextMemoryOverview,
|
||||
saveConfig,
|
||||
toggleMemoryAuto,
|
||||
updateMemoryAutoNote,
|
||||
type AutoMemoryIndexResponse,
|
||||
type Config,
|
||||
type EffectiveMemorySourcesResponse,
|
||||
type MemoryAutoConfig,
|
||||
type MemoryConfig,
|
||||
type MemoryProfileConfig,
|
||||
type MemoryResolveConfig,
|
||||
type MemorySourcesConfig,
|
||||
} from "@/hooks/useTauri";
|
||||
getMemoryOverview as getContextMemoryOverview,
|
||||
} from "@/lib/api/memoryRuntime";
|
||||
import { getConfig, saveConfig, type Config } from "@/lib/api/appConfig";
|
||||
import { getUnifiedMemoryStats } from "@/lib/api/unifiedMemory";
|
||||
import { getProjectMemory } from "@/lib/api/memory";
|
||||
import {
|
||||
@@ -148,7 +146,9 @@ function MultiSelectSection({
|
||||
<div className="rounded-lg border p-4 space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">{title}</h3>
|
||||
{subtitle && <p className="text-xs text-muted-foreground">{subtitle}</p>}
|
||||
{subtitle && (
|
||||
<p className="text-xs text-muted-foreground">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -208,13 +208,14 @@ export function MemorySettings() {
|
||||
const currentProjectId = targetProjectId ?? projectId;
|
||||
setLoadingLayerMetrics(true);
|
||||
try {
|
||||
const [unifiedStats, contextOverview, projectMemory] = await Promise.all([
|
||||
getUnifiedMemoryStats(),
|
||||
getContextMemoryOverview(200).catch(() => null),
|
||||
currentProjectId
|
||||
? getProjectMemory(currentProjectId).catch(() => null)
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
const [unifiedStats, contextOverview, projectMemory] =
|
||||
await Promise.all([
|
||||
getUnifiedMemoryStats(),
|
||||
getContextMemoryOverview(200).catch(() => null),
|
||||
currentProjectId
|
||||
? getProjectMemory(currentProjectId).catch(() => null)
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
|
||||
setLayerMetrics(
|
||||
buildLayerMetrics({
|
||||
@@ -381,7 +382,10 @@ export function MemorySettings() {
|
||||
|
||||
setSavingAutoNote(true);
|
||||
try {
|
||||
const index = await updateMemoryAutoNote(note, autoTopic.trim() || undefined);
|
||||
const index = await updateMemoryAutoNote(
|
||||
note,
|
||||
autoTopic.trim() || undefined,
|
||||
);
|
||||
setAutoIndex(index);
|
||||
setAutoNote("");
|
||||
setMessage("已写入自动记忆");
|
||||
@@ -457,7 +461,9 @@ export function MemorySettings() {
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border p-4 space-y-3">
|
||||
<h3 className="text-sm font-medium">以下哪个选项最能形容你现在的状态?</h3>
|
||||
<h3 className="text-sm font-medium">
|
||||
以下哪个选项最能形容你现在的状态?
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{STATUS_OPTIONS.map((option) => {
|
||||
const selected = profile.current_status === option;
|
||||
@@ -513,7 +519,9 @@ export function MemorySettings() {
|
||||
disabled={loadingLayerMetrics}
|
||||
className="inline-flex items-center gap-1 rounded border px-2 py-1 text-[11px] hover:bg-muted disabled:opacity-60"
|
||||
>
|
||||
<RefreshCw className={cn("h-3 w-3", loadingLayerMetrics && "animate-spin")} />
|
||||
<RefreshCw
|
||||
className={cn("h-3 w-3", loadingLayerMetrics && "animate-spin")}
|
||||
/>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
@@ -566,7 +574,9 @@ export function MemorySettings() {
|
||||
disabled={loadingSourceState}
|
||||
className="inline-flex items-center gap-1 rounded border px-2 py-1 text-[11px] hover:bg-muted disabled:opacity-60"
|
||||
>
|
||||
<RefreshCw className={cn("h-3 w-3", loadingSourceState && "animate-spin")} />
|
||||
<RefreshCw
|
||||
className={cn("h-3 w-3", loadingSourceState && "animate-spin")}
|
||||
/>
|
||||
刷新来源
|
||||
</button>
|
||||
</div>
|
||||
@@ -611,7 +621,9 @@ export function MemorySettings() {
|
||||
</label>
|
||||
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">项目本地私有文件</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
项目本地私有文件
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={sourcesConfig.project_local_memory_path || ""}
|
||||
@@ -805,7 +817,9 @@ export function MemorySettings() {
|
||||
</label>
|
||||
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">自动记忆根目录</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
自动记忆根目录
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={autoConfig.root_dir || ""}
|
||||
@@ -859,7 +873,9 @@ export function MemorySettings() {
|
||||
{autoIndex.preview_lines.join("\n")}
|
||||
</pre>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">暂无自动记忆入口内容</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
暂无自动记忆入口内容
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -877,7 +893,10 @@ export function MemorySettings() {
|
||||
{effectiveSources ? (
|
||||
<div className="space-y-2">
|
||||
{effectiveSources.sources.map((source) => (
|
||||
<div key={`${source.kind}-${source.path}`} className="rounded border bg-background/60 px-3 py-2">
|
||||
<div
|
||||
key={`${source.kind}-${source.path}`}
|
||||
className="rounded border bg-background/60 px-3 py-2"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-medium">{source.kind}</span>
|
||||
<span
|
||||
@@ -888,10 +907,16 @@ export function MemorySettings() {
|
||||
: "text-muted-foreground border-muted",
|
||||
)}
|
||||
>
|
||||
{source.loaded ? "已加载" : source.exists ? "存在未命中" : "未发现"}
|
||||
{source.loaded
|
||||
? "已加载"
|
||||
: source.exists
|
||||
? "存在未命中"
|
||||
: "未发现"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground break-all">{source.path}</p>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground break-all">
|
||||
{source.path}
|
||||
</p>
|
||||
{source.preview && (
|
||||
<p className="mt-1 text-[11px] leading-relaxed text-muted-foreground line-clamp-2">
|
||||
{source.preview}
|
||||
@@ -906,7 +931,9 @@ export function MemorySettings() {
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">正在加载来源命中结果...</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
正在加载来源命中结果...
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
Plug,
|
||||
Route,
|
||||
Search,
|
||||
Variable,
|
||||
Monitor,
|
||||
ShieldCheck,
|
||||
HeartPulse,
|
||||
@@ -173,6 +174,11 @@ export function useSettingsCategory(): CategoryGroup[] {
|
||||
label: t("settings.tab.webSearch", "网络搜索"),
|
||||
icon: Search,
|
||||
},
|
||||
{
|
||||
key: SettingsTabs.Environment,
|
||||
label: t("settings.tab.environment", "环境变量"),
|
||||
icon: Variable,
|
||||
},
|
||||
{
|
||||
key: SettingsTabs.ChromeRelay,
|
||||
label: t("settings.tab.chromeRelay", "Chrome Relay"),
|
||||
|
||||
@@ -6,23 +6,14 @@ import {
|
||||
AlertCircle,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { safeInvoke } from "@/lib/dev-bridge";
|
||||
import {
|
||||
checkForUpdates,
|
||||
downloadUpdate,
|
||||
type DownloadUpdateResult,
|
||||
type VersionInfo,
|
||||
} from "@/lib/api/appUpdate";
|
||||
import { ProviderIcon } from "@/icons/providers";
|
||||
|
||||
interface VersionInfo {
|
||||
current: string;
|
||||
latest?: string;
|
||||
hasUpdate: boolean;
|
||||
downloadUrl?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface DownloadResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
filePath?: string;
|
||||
}
|
||||
|
||||
const FALLBACK_TAGS_URL = "https://github.com/aiclientproxy/proxycast/tags";
|
||||
|
||||
const CREATIVE_THEMES = [
|
||||
@@ -62,9 +53,8 @@ export function AboutSection() {
|
||||
});
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [downloadResult, setDownloadResult] = useState<DownloadResult | null>(
|
||||
null,
|
||||
);
|
||||
const [downloadResult, setDownloadResult] =
|
||||
useState<DownloadUpdateResult | null>(null);
|
||||
const manualDownloadUrl = versionInfo.downloadUrl || FALLBACK_TAGS_URL;
|
||||
|
||||
// 加载当前版本号(从后端获取,确保与 Cargo.toml 同步)
|
||||
@@ -72,7 +62,7 @@ export function AboutSection() {
|
||||
const loadCurrentVersion = async () => {
|
||||
try {
|
||||
// check_for_updates 会返回当前版本号
|
||||
const result = await safeInvoke<VersionInfo>("check_for_updates");
|
||||
const result = await checkForUpdates();
|
||||
setVersionInfo((prev) => ({
|
||||
...prev,
|
||||
current: result.current,
|
||||
@@ -93,7 +83,7 @@ export function AboutSection() {
|
||||
setChecking(true);
|
||||
setDownloadResult(null);
|
||||
try {
|
||||
const result = await safeInvoke<VersionInfo>("check_for_updates");
|
||||
const result = await checkForUpdates();
|
||||
setVersionInfo({
|
||||
...result,
|
||||
downloadUrl: result.downloadUrl || FALLBACK_TAGS_URL,
|
||||
@@ -114,7 +104,7 @@ export function AboutSection() {
|
||||
setDownloading(true);
|
||||
setDownloadResult(null);
|
||||
try {
|
||||
const result = await safeInvoke<DownloadResult>("download_update");
|
||||
const result = await downloadUpdate();
|
||||
setDownloadResult(result);
|
||||
|
||||
if (result.success) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Copy, Pause, Play, Trash2 } from "lucide-react";
|
||||
import { clearLogs, getPersistedLogsTail, type LogEntry } from "@/hooks/useTauri";
|
||||
import { clearLogs, getPersistedLogsTail, type LogEntry } from "@/lib/api/logs";
|
||||
import {
|
||||
type ChannelLogPreset,
|
||||
buildChannelLogRegex,
|
||||
@@ -152,7 +152,11 @@ export function ChannelLogTailPanel() {
|
||||
onClick={() => setPaused((v) => !v)}
|
||||
className="inline-flex items-center gap-1 rounded-md border px-3 py-1.5 text-xs transition-colors hover:bg-muted"
|
||||
>
|
||||
{paused ? <Play className="h-3.5 w-3.5" /> : <Pause className="h-3.5 w-3.5" />}
|
||||
{paused ? (
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Pause className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{paused ? "继续" : "暂停"}
|
||||
</button>
|
||||
<button
|
||||
@@ -179,7 +183,9 @@ export function ChannelLogTailPanel() {
|
||||
<span className="text-xs text-muted-foreground">过滤模式</span>
|
||||
<select
|
||||
value={preset}
|
||||
onChange={(event) => setPreset(event.target.value as ChannelLogPreset)}
|
||||
onChange={(event) =>
|
||||
setPreset(event.target.value as ChannelLogPreset)
|
||||
}
|
||||
className="h-9 w-full rounded-md border bg-background px-3 text-sm"
|
||||
>
|
||||
<option value="all">全部</option>
|
||||
@@ -208,7 +214,9 @@ export function ChannelLogTailPanel() {
|
||||
onChange={(event) => setAutoScroll(event.target.checked)}
|
||||
className="h-4 w-4 rounded border"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">自动滚动到底部</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
自动滚动到底部
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
@@ -227,9 +235,13 @@ export function ChannelLogTailPanel() {
|
||||
|
||||
{(error || regexError || copyTip) && (
|
||||
<div className="space-y-1">
|
||||
{error && <p className="text-xs text-destructive">拉取日志失败: {error}</p>}
|
||||
{error && (
|
||||
<p className="text-xs text-destructive">拉取日志失败: {error}</p>
|
||||
)}
|
||||
{regexError && <p className="text-xs text-amber-600">{regexError}</p>}
|
||||
{copyTip && <p className="text-xs text-muted-foreground">{copyTip}</p>}
|
||||
{copyTip && (
|
||||
<p className="text-xs text-muted-foreground">{copyTip}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -245,9 +257,17 @@ export function ChannelLogTailPanel() {
|
||||
</p>
|
||||
) : (
|
||||
filteredLogs.map((entry, index) => (
|
||||
<div key={`${entry.timestamp}-${index}`} className="py-0.5 break-all">
|
||||
<span className="text-muted-foreground">[{formatTime(entry.timestamp)}]</span>{" "}
|
||||
<span className="text-blue-500">[{entry.level.toUpperCase()}]</span> {entry.message}
|
||||
<div
|
||||
key={`${entry.timestamp}-${index}`}
|
||||
className="py-0.5 break-all"
|
||||
>
|
||||
<span className="text-muted-foreground">
|
||||
[{formatTime(entry.timestamp)}]
|
||||
</span>{" "}
|
||||
<span className="text-blue-500">
|
||||
[{entry.level.toUpperCase()}]
|
||||
</span>{" "}
|
||||
{entry.message}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { LogEntry } from "@/hooks/useTauri";
|
||||
import type { LogEntry } from "@/lib/api/logs";
|
||||
import {
|
||||
buildChannelLogRegex,
|
||||
filterChannelLogs,
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import type { LogEntry } from "@/hooks/useTauri";
|
||||
import type { LogEntry } from "@/lib/api/logs";
|
||||
|
||||
export type ChannelLogPreset = "all" | "telegram" | "rpc" | "feishu" | "custom";
|
||||
|
||||
const PRESET_PATTERNS: Record<Exclude<ChannelLogPreset, "all" | "custom">, string> = {
|
||||
const PRESET_PATTERNS: Record<
|
||||
Exclude<ChannelLogPreset, "all" | "custom">,
|
||||
string
|
||||
> = {
|
||||
telegram: "TelegramGateway",
|
||||
rpc: "\\bRPC\\b|agent\\.run|cron\\.run",
|
||||
feishu: "FeishuGateway",
|
||||
@@ -21,7 +24,8 @@ export function buildChannelLogRegex(
|
||||
return { regex: null, error: null };
|
||||
}
|
||||
|
||||
const pattern = preset === "custom" ? customPattern.trim() : PRESET_PATTERNS[preset];
|
||||
const pattern =
|
||||
preset === "custom" ? customPattern.trim() : PRESET_PATTERNS[preset];
|
||||
if (!pattern) {
|
||||
return { regex: null, error: null };
|
||||
}
|
||||
@@ -33,10 +37,12 @@ export function buildChannelLogRegex(
|
||||
}
|
||||
}
|
||||
|
||||
export function filterChannelLogs(entries: LogEntry[], regex: RegExp | null): LogEntry[] {
|
||||
export function filterChannelLogs(
|
||||
entries: LogEntry[],
|
||||
regex: RegExp | null,
|
||||
): LogEntry[] {
|
||||
if (!regex) {
|
||||
return entries;
|
||||
}
|
||||
return entries.filter((entry) => regex.test(entry.message));
|
||||
}
|
||||
|
||||
|
||||
@@ -20,15 +20,13 @@ import {
|
||||
Network,
|
||||
ScrollText,
|
||||
} from "lucide-react";
|
||||
import { getConfig, saveConfig, type Config } from "@/lib/api/appConfig";
|
||||
import {
|
||||
getConfig,
|
||||
saveConfig,
|
||||
Config,
|
||||
ChannelsConfig,
|
||||
GatewayConfig,
|
||||
TelegramBotConfig,
|
||||
DiscordBotConfig,
|
||||
FeishuBotConfig,
|
||||
discordChannelProbe,
|
||||
feishuChannelProbe,
|
||||
gatewayChannelStart,
|
||||
gatewayChannelStatus,
|
||||
gatewayChannelStop,
|
||||
gatewayTunnelCreate,
|
||||
gatewayTunnelDetectCloudflared,
|
||||
gatewayTunnelInstallCloudflared,
|
||||
@@ -38,15 +36,18 @@ import {
|
||||
gatewayTunnelStatus,
|
||||
gatewayTunnelStop,
|
||||
gatewayTunnelSyncWebhookUrl,
|
||||
gatewayChannelStart,
|
||||
gatewayChannelStatus,
|
||||
gatewayChannelStop,
|
||||
discordChannelProbe,
|
||||
feishuChannelProbe,
|
||||
telegramChannelProbe,
|
||||
} from "@/hooks/useTauri";
|
||||
type ChannelsConfig,
|
||||
type DiscordBotConfig,
|
||||
type FeishuBotConfig,
|
||||
type GatewayConfig,
|
||||
type TelegramBotConfig,
|
||||
} from "@/lib/api/channelsRuntime";
|
||||
import { useConfiguredProviders } from "@/hooks/useConfiguredProviders";
|
||||
import { filterProviderModelsByCompatibility, getProviderModelCompatibilityIssue } from "@/components/agent/chat/utils/providerModelCompatibility";
|
||||
import {
|
||||
filterProviderModelsByCompatibility,
|
||||
getProviderModelCompatibilityIssue,
|
||||
} from "@/components/agent/chat/utils/providerModelCompatibility";
|
||||
import { ChannelLogTailPanel } from "./ChannelLogTailPanel";
|
||||
|
||||
// ============================================================================
|
||||
@@ -54,7 +55,12 @@ import { ChannelLogTailPanel } from "./ChannelLogTailPanel";
|
||||
// ============================================================================
|
||||
|
||||
const DEFAULT_CHANNELS: ChannelsConfig = {
|
||||
telegram: { enabled: false, bot_token: "", allowed_user_ids: [], default_model: undefined },
|
||||
telegram: {
|
||||
enabled: false,
|
||||
bot_token: "",
|
||||
allowed_user_ids: [],
|
||||
default_model: undefined,
|
||||
},
|
||||
discord: {
|
||||
enabled: false,
|
||||
bot_token: "",
|
||||
@@ -240,7 +246,9 @@ function StringListInput({
|
||||
type="text"
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), addItem())}
|
||||
onKeyDown={(e) =>
|
||||
e.key === "Enter" && (e.preventDefault(), addItem())
|
||||
}
|
||||
placeholder={placeholder}
|
||||
className="flex-1 px-3 py-2 rounded-lg border bg-background text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none"
|
||||
/>
|
||||
@@ -315,11 +323,15 @@ function GatewayTunnelPanel({
|
||||
const cloudflare = tunnel.cloudflare ?? {};
|
||||
const [busyAction, setBusyAction] = useState<string | null>(null);
|
||||
const [output, setOutput] = useState("");
|
||||
const [feishuAccountId, setFeishuAccountId] = useState(defaultFeishuAccountId ?? "default");
|
||||
const [feishuAccountId, setFeishuAccountId] = useState(
|
||||
defaultFeishuAccountId ?? "default",
|
||||
);
|
||||
|
||||
const busy = busyAction !== null;
|
||||
|
||||
const patchTunnel = (patch: Partial<NonNullable<GatewayConfig["tunnel"]>>) => {
|
||||
const patchTunnel = (
|
||||
patch: Partial<NonNullable<GatewayConfig["tunnel"]>>,
|
||||
) => {
|
||||
onChange({
|
||||
...config,
|
||||
tunnel: {
|
||||
@@ -330,7 +342,9 @@ function GatewayTunnelPanel({
|
||||
};
|
||||
|
||||
const patchCloudflare = (
|
||||
patch: Partial<NonNullable<NonNullable<GatewayConfig["tunnel"]>["cloudflare"]>>,
|
||||
patch: Partial<
|
||||
NonNullable<NonNullable<GatewayConfig["tunnel"]>["cloudflare"]>
|
||||
>,
|
||||
) => {
|
||||
patchTunnel({
|
||||
cloudflare: {
|
||||
@@ -340,7 +354,10 @@ function GatewayTunnelPanel({
|
||||
});
|
||||
};
|
||||
|
||||
const runAction = async (action: string, executor: () => Promise<unknown>) => {
|
||||
const runAction = async (
|
||||
action: string,
|
||||
executor: () => Promise<unknown>,
|
||||
) => {
|
||||
setBusyAction(action);
|
||||
try {
|
||||
const result = await executor();
|
||||
@@ -407,10 +424,14 @@ function GatewayTunnelPanel({
|
||||
</select>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">cloudflared 二进制(可选)</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
cloudflared 二进制(可选)
|
||||
</span>
|
||||
<input
|
||||
value={tunnel.binary_path || ""}
|
||||
onChange={(event) => patchTunnel({ binary_path: event.target.value || undefined })}
|
||||
onChange={(event) =>
|
||||
patchTunnel({ binary_path: event.target.value || undefined })
|
||||
}
|
||||
placeholder="默认使用 PATH 中 cloudflared"
|
||||
className="h-9 w-full rounded-md border bg-background px-3 text-sm"
|
||||
/>
|
||||
@@ -422,7 +443,9 @@ function GatewayTunnelPanel({
|
||||
<span className="text-xs text-muted-foreground">本地 Host</span>
|
||||
<input
|
||||
value={tunnel.local_host || "127.0.0.1"}
|
||||
onChange={(event) => patchTunnel({ local_host: event.target.value })}
|
||||
onChange={(event) =>
|
||||
patchTunnel({ local_host: event.target.value })
|
||||
}
|
||||
placeholder="127.0.0.1"
|
||||
className="h-9 w-full rounded-md border bg-background px-3 text-sm"
|
||||
/>
|
||||
@@ -444,11 +467,15 @@ function GatewayTunnelPanel({
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">公网基础 URL(可选)</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
公网基础 URL(可选)
|
||||
</span>
|
||||
<input
|
||||
value={tunnel.public_base_url || ""}
|
||||
onChange={(event) =>
|
||||
patchTunnel({ public_base_url: event.target.value.trim() || undefined })
|
||||
patchTunnel({
|
||||
public_base_url: event.target.value.trim() || undefined,
|
||||
})
|
||||
}
|
||||
placeholder="https://bot.example.com"
|
||||
className="h-9 w-full rounded-md border bg-background px-3 text-sm"
|
||||
@@ -461,7 +488,9 @@ function GatewayTunnelPanel({
|
||||
<span className="text-xs text-muted-foreground">Tunnel Name</span>
|
||||
<input
|
||||
value={cloudflare.tunnel_name || ""}
|
||||
onChange={(event) => patchCloudflare({ tunnel_name: event.target.value || undefined })}
|
||||
onChange={(event) =>
|
||||
patchCloudflare({ tunnel_name: event.target.value || undefined })
|
||||
}
|
||||
placeholder="proxycast-gateway"
|
||||
className="h-9 w-full rounded-md border bg-background px-3 text-sm"
|
||||
/>
|
||||
@@ -470,7 +499,9 @@ function GatewayTunnelPanel({
|
||||
<span className="text-xs text-muted-foreground">Tunnel ID</span>
|
||||
<input
|
||||
value={cloudflare.tunnel_id || ""}
|
||||
onChange={(event) => patchCloudflare({ tunnel_id: event.target.value || undefined })}
|
||||
onChange={(event) =>
|
||||
patchCloudflare({ tunnel_id: event.target.value || undefined })
|
||||
}
|
||||
placeholder="uuid"
|
||||
className="h-9 w-full rounded-md border bg-background px-3 text-sm"
|
||||
/>
|
||||
@@ -479,7 +510,9 @@ function GatewayTunnelPanel({
|
||||
<span className="text-xs text-muted-foreground">DNS Name</span>
|
||||
<input
|
||||
value={cloudflare.dns_name || ""}
|
||||
onChange={(event) => patchCloudflare({ dns_name: event.target.value || undefined })}
|
||||
onChange={(event) =>
|
||||
patchCloudflare({ dns_name: event.target.value || undefined })
|
||||
}
|
||||
placeholder="bot.example.com"
|
||||
className="h-9 w-full rounded-md border bg-background px-3 text-sm"
|
||||
/>
|
||||
@@ -494,11 +527,15 @@ function GatewayTunnelPanel({
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5">Credentials File(可选)</label>
|
||||
<label className="block text-sm font-medium mb-1.5">
|
||||
Credentials File(可选)
|
||||
</label>
|
||||
<input
|
||||
value={cloudflare.credentials_file || ""}
|
||||
onChange={(event) =>
|
||||
patchCloudflare({ credentials_file: event.target.value || undefined })
|
||||
patchCloudflare({
|
||||
credentials_file: event.target.value || undefined,
|
||||
})
|
||||
}
|
||||
placeholder="~/.cloudflared/<tunnel-id>.json"
|
||||
className="w-full px-3 py-2 rounded-lg border bg-background text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none"
|
||||
@@ -507,7 +544,9 @@ function GatewayTunnelPanel({
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">同步回调账号 ID(Feishu)</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
同步回调账号 ID(Feishu)
|
||||
</span>
|
||||
<input
|
||||
value={feishuAccountId}
|
||||
onChange={(event) => setFeishuAccountId(event.target.value)}
|
||||
@@ -521,7 +560,11 @@ function GatewayTunnelPanel({
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => void runAction("detect_cloudflared", async () => gatewayTunnelDetectCloudflared())}
|
||||
onClick={() =>
|
||||
void runAction("detect_cloudflared", async () =>
|
||||
gatewayTunnelDetectCloudflared(),
|
||||
)
|
||||
}
|
||||
className="rounded-md border px-3 py-1.5 text-xs transition-colors disabled:opacity-50"
|
||||
>
|
||||
检测 cloudflared
|
||||
@@ -544,7 +587,9 @@ function GatewayTunnelPanel({
|
||||
return;
|
||||
}
|
||||
void runAction("install_cloudflared", async () => {
|
||||
const install = await gatewayTunnelInstallCloudflared({ confirm: true });
|
||||
const install = await gatewayTunnelInstallCloudflared({
|
||||
confirm: true,
|
||||
});
|
||||
const detect = await gatewayTunnelDetectCloudflared();
|
||||
return { install, detect };
|
||||
});
|
||||
@@ -556,7 +601,9 @@ function GatewayTunnelPanel({
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => void runAction("probe", async () => gatewayTunnelProbe())}
|
||||
onClick={() =>
|
||||
void runAction("probe", async () => gatewayTunnelProbe())
|
||||
}
|
||||
className="rounded-md border px-3 py-1.5 text-xs transition-colors disabled:opacity-50"
|
||||
>
|
||||
探测
|
||||
@@ -580,7 +627,9 @@ function GatewayTunnelPanel({
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => void runAction("start", async () => gatewayTunnelStart())}
|
||||
onClick={() =>
|
||||
void runAction("start", async () => gatewayTunnelStart())
|
||||
}
|
||||
className="rounded-md border px-3 py-1.5 text-xs transition-colors disabled:opacity-50"
|
||||
>
|
||||
启动
|
||||
@@ -588,7 +637,9 @@ function GatewayTunnelPanel({
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => void runAction("stop", async () => gatewayTunnelStop())}
|
||||
onClick={() =>
|
||||
void runAction("stop", async () => gatewayTunnelStop())
|
||||
}
|
||||
className="rounded-md border px-3 py-1.5 text-xs transition-colors disabled:opacity-50"
|
||||
>
|
||||
停止
|
||||
@@ -596,7 +647,9 @@ function GatewayTunnelPanel({
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => void runAction("restart", async () => gatewayTunnelRestart())}
|
||||
onClick={() =>
|
||||
void runAction("restart", async () => gatewayTunnelRestart())
|
||||
}
|
||||
className="rounded-md border px-3 py-1.5 text-xs transition-colors disabled:opacity-50"
|
||||
>
|
||||
重启
|
||||
@@ -604,7 +657,9 @@ function GatewayTunnelPanel({
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => void runAction("status", async () => gatewayTunnelStatus())}
|
||||
onClick={() =>
|
||||
void runAction("status", async () => gatewayTunnelStatus())
|
||||
}
|
||||
className="rounded-md border px-3 py-1.5 text-xs transition-colors disabled:opacity-50"
|
||||
>
|
||||
查询状态
|
||||
@@ -628,7 +683,9 @@ function GatewayTunnelPanel({
|
||||
</div>
|
||||
|
||||
{busyAction && (
|
||||
<div className="text-xs text-muted-foreground">正在执行:{busyAction}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
正在执行:{busyAction}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
@@ -657,7 +714,9 @@ function TelegramForm({
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">启用 Telegram Bot</h3>
|
||||
<p className="text-xs text-muted-foreground">开启后可通过 Telegram Bot 与 AI 对话</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
开启后可通过 Telegram Bot 与 AI 对话
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -821,7 +880,8 @@ function TelegramGatewayDebugPanel() {
|
||||
onClick={() =>
|
||||
void runAction(
|
||||
"probe",
|
||||
async () => telegramChannelProbe({ accountId: resolveAccountId() }),
|
||||
async () =>
|
||||
telegramChannelProbe({ accountId: resolveAccountId() }),
|
||||
"Telegram 探测完成",
|
||||
)
|
||||
}
|
||||
@@ -855,7 +915,10 @@ function TelegramGatewayDebugPanel() {
|
||||
void runAction(
|
||||
"stop",
|
||||
async () =>
|
||||
gatewayChannelStop({ channel: "telegram", accountId: resolveAccountId() }),
|
||||
gatewayChannelStop({
|
||||
channel: "telegram",
|
||||
accountId: resolveAccountId(),
|
||||
}),
|
||||
"Gateway 已停止",
|
||||
)
|
||||
}
|
||||
@@ -889,7 +952,9 @@ function TelegramGatewayDebugPanel() {
|
||||
accountId: resolveAccountId(),
|
||||
pollTimeoutSecs: resolvePollTimeoutSecs(),
|
||||
});
|
||||
const statusResult = await gatewayChannelStatus({ channel: "telegram" });
|
||||
const statusResult = await gatewayChannelStatus({
|
||||
channel: "telegram",
|
||||
});
|
||||
return {
|
||||
stop: stopResult,
|
||||
start: startResult,
|
||||
@@ -907,7 +972,9 @@ function TelegramGatewayDebugPanel() {
|
||||
</div>
|
||||
|
||||
{busyAction && (
|
||||
<div className="text-xs text-muted-foreground">正在执行:{busyAction}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
正在执行:{busyAction}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
@@ -1079,7 +1146,9 @@ function FeishuGatewayDebugPanel() {
|
||||
channel: "feishu",
|
||||
accountId: resolveAccountId(),
|
||||
});
|
||||
const statusResult = await gatewayChannelStatus({ channel: "feishu" });
|
||||
const statusResult = await gatewayChannelStatus({
|
||||
channel: "feishu",
|
||||
});
|
||||
return {
|
||||
stop: stopResult,
|
||||
start: startResult,
|
||||
@@ -1097,7 +1166,9 @@ function FeishuGatewayDebugPanel() {
|
||||
</div>
|
||||
|
||||
{busyAction && (
|
||||
<div className="text-xs text-muted-foreground">正在执行:{busyAction}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
正在执行:{busyAction}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
@@ -1200,7 +1271,8 @@ function DiscordGatewayDebugPanel() {
|
||||
onClick={() =>
|
||||
void runAction(
|
||||
"probe",
|
||||
async () => discordChannelProbe({ accountId: resolveAccountId() }),
|
||||
async () =>
|
||||
discordChannelProbe({ accountId: resolveAccountId() }),
|
||||
"Discord 探测完成",
|
||||
)
|
||||
}
|
||||
@@ -1269,7 +1341,9 @@ function DiscordGatewayDebugPanel() {
|
||||
channel: "discord",
|
||||
accountId: resolveAccountId(),
|
||||
});
|
||||
const statusResult = await gatewayChannelStatus({ channel: "discord" });
|
||||
const statusResult = await gatewayChannelStatus({
|
||||
channel: "discord",
|
||||
});
|
||||
return {
|
||||
stop: stopResult,
|
||||
start: startResult,
|
||||
@@ -1287,7 +1361,9 @@ function DiscordGatewayDebugPanel() {
|
||||
</div>
|
||||
|
||||
{busyAction && (
|
||||
<div className="text-xs text-muted-foreground">正在执行:{busyAction}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
正在执行:{busyAction}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
@@ -1311,7 +1387,10 @@ function DiscordForm({
|
||||
config: DiscordBotConfig;
|
||||
onChange: (c: DiscordBotConfig) => void;
|
||||
}) {
|
||||
const accountIds = useMemo(() => Object.keys(config.accounts ?? {}), [config.accounts]);
|
||||
const accountIds = useMemo(
|
||||
() => Object.keys(config.accounts ?? {}),
|
||||
[config.accounts],
|
||||
);
|
||||
const [activeAccountId, setActiveAccountId] = useState(
|
||||
config.default_account || accountIds[0] || "default",
|
||||
);
|
||||
@@ -1332,9 +1411,9 @@ function DiscordForm({
|
||||
|
||||
const patchAccount = (
|
||||
accountId: string,
|
||||
updater: (current: NonNullable<DiscordBotConfig["accounts"]>[string]) => NonNullable<
|
||||
DiscordBotConfig["accounts"]
|
||||
>[string],
|
||||
updater: (
|
||||
current: NonNullable<DiscordBotConfig["accounts"]>[string],
|
||||
) => NonNullable<DiscordBotConfig["accounts"]>[string],
|
||||
) => {
|
||||
const baseAccounts = config.accounts ?? {};
|
||||
const current = baseAccounts[accountId] ?? { enabled: true };
|
||||
@@ -1375,7 +1454,9 @@ function DiscordForm({
|
||||
patch({
|
||||
accounts: baseAccounts,
|
||||
default_account:
|
||||
config.default_account === accountId ? (nextIds[0] || "default") : config.default_account,
|
||||
config.default_account === accountId
|
||||
? nextIds[0] || "default"
|
||||
: config.default_account,
|
||||
});
|
||||
setActiveAccountId(nextIds[0] || "default");
|
||||
};
|
||||
@@ -1395,7 +1476,9 @@ function DiscordForm({
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">启用 Discord Bot</h3>
|
||||
<p className="text-xs text-muted-foreground">开启后可通过 Discord Bot 与 AI 对话</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
开启后可通过 Discord Bot 与 AI 对话
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1448,7 +1531,9 @@ function DiscordForm({
|
||||
<input
|
||||
type="text"
|
||||
value={config.default_account || ""}
|
||||
onChange={(e) => patch({ default_account: e.target.value || undefined })}
|
||||
onChange={(e) =>
|
||||
patch({ default_account: e.target.value || undefined })
|
||||
}
|
||||
placeholder="default"
|
||||
className="w-full px-3 py-2 rounded-lg border bg-background text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none"
|
||||
/>
|
||||
@@ -1517,7 +1602,9 @@ function DiscordForm({
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs mb-1 text-muted-foreground">账号名称</label>
|
||||
<label className="block text-xs mb-1 text-muted-foreground">
|
||||
账号名称
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={currentAccount.name || ""}
|
||||
@@ -1532,7 +1619,9 @@ function DiscordForm({
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs mb-1 text-muted-foreground">账号默认模型</label>
|
||||
<label className="block text-xs mb-1 text-muted-foreground">
|
||||
账号默认模型
|
||||
</label>
|
||||
<DefaultModelSelect
|
||||
value={currentAccount.default_model}
|
||||
onChange={(v) =>
|
||||
@@ -1572,7 +1661,9 @@ function DiscordForm({
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">未配置账号,将使用全局 Bot Token 运行。</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
未配置账号,将使用全局 Bot Token 运行。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1632,7 +1723,9 @@ function DiscordForm({
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5">Reply To Mode</label>
|
||||
<label className="block text-sm font-medium mb-1.5">
|
||||
Reply To Mode
|
||||
</label>
|
||||
<select
|
||||
value={replyToMode}
|
||||
onChange={(e) => patch({ reply_to_mode: e.target.value })}
|
||||
@@ -1784,7 +1877,9 @@ function FeishuForm({
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">启用飞书 Bot</h3>
|
||||
<p className="text-xs text-muted-foreground">开启后可通过飞书 Bot 与 AI 对话</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
开启后可通过飞书 Bot 与 AI 对话
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1823,13 +1918,17 @@ function FeishuForm({
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5">
|
||||
Verification Token <span className="text-muted-foreground font-normal">(可选)</span>
|
||||
Verification Token{" "}
|
||||
<span className="text-muted-foreground font-normal">(可选)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={config.verification_token || ""}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, verification_token: e.target.value || undefined })
|
||||
onChange({
|
||||
...config,
|
||||
verification_token: e.target.value || undefined,
|
||||
})
|
||||
}
|
||||
placeholder="事件订阅验证 Token"
|
||||
className="w-full px-3 py-2 rounded-lg border bg-background text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none"
|
||||
@@ -1860,7 +1959,8 @@ function FeishuForm({
|
||||
</select>
|
||||
{connectionMode === "websocket" && (
|
||||
<p className="text-xs text-amber-600 mt-1">
|
||||
当前版本 WebSocket 模式尚未实装,请使用 Webhook 模式接入飞书事件回调。
|
||||
当前版本 WebSocket 模式尚未实装,请使用 Webhook
|
||||
模式接入飞书事件回调。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -1868,7 +1968,9 @@ function FeishuForm({
|
||||
{connectionMode === "webhook" && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5">Webhook Host</label>
|
||||
<label className="block text-sm font-medium mb-1.5">
|
||||
Webhook Host
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={config.webhook_host || "127.0.0.1"}
|
||||
@@ -1883,7 +1985,9 @@ function FeishuForm({
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5">Webhook Port</label>
|
||||
<label className="block text-sm font-medium mb-1.5">
|
||||
Webhook Port
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
@@ -1893,7 +1997,8 @@ function FeishuForm({
|
||||
const value = Number.parseInt(e.target.value, 10);
|
||||
onChange({
|
||||
...config,
|
||||
webhook_port: Number.isFinite(value) && value > 0 ? value : undefined,
|
||||
webhook_port:
|
||||
Number.isFinite(value) && value > 0 ? value : undefined,
|
||||
});
|
||||
}}
|
||||
placeholder="3000"
|
||||
@@ -1901,7 +2006,9 @@ function FeishuForm({
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5">Webhook Path</label>
|
||||
<label className="block text-sm font-medium mb-1.5">
|
||||
Webhook Path
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={config.webhook_path || "/feishu/default"}
|
||||
@@ -1978,9 +2085,15 @@ function FeishuForm({
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5">回复引用模式</label>
|
||||
<label className="block text-sm font-medium mb-1.5">
|
||||
回复引用模式
|
||||
</label>
|
||||
<select
|
||||
value={replyToMode === "first" || replyToMode === "all" ? replyToMode : "off"}
|
||||
value={
|
||||
replyToMode === "first" || replyToMode === "all"
|
||||
? replyToMode
|
||||
: "off"
|
||||
}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...config,
|
||||
@@ -2014,14 +2127,18 @@ export interface ChannelsSettingsProps {
|
||||
|
||||
export function ChannelsSettings({ className }: ChannelsSettingsProps) {
|
||||
const [activeTab, setActiveTab] = useState<TabKey>("telegram");
|
||||
const [activeSubPage, setActiveSubPage] = useState<ChannelSubPage>("overview");
|
||||
const [activeSubPage, setActiveSubPage] =
|
||||
useState<ChannelSubPage>("overview");
|
||||
const [activeDebugTab, setActiveDebugTab] = useState<DebugTabKey>("telegram");
|
||||
const [config, setConfig] = useState<Config | null>(null);
|
||||
const [channels, setChannels] = useState<ChannelsConfig>(DEFAULT_CHANNELS);
|
||||
const [gateway, setGateway] = useState<GatewayConfig>(DEFAULT_GATEWAY);
|
||||
const [initialJson, setInitialJson] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
|
||||
const [message, setMessage] = useState<{
|
||||
type: "success" | "error";
|
||||
text: string;
|
||||
} | null>(null);
|
||||
|
||||
const isDirty = useMemo(
|
||||
() => JSON.stringify({ channels, gateway }) !== initialJson,
|
||||
@@ -2074,7 +2191,10 @@ export function ChannelsSettings({ className }: ChannelsSettingsProps) {
|
||||
|
||||
const handleCancel = () => {
|
||||
if (initialJson) {
|
||||
const snapshot = JSON.parse(initialJson) as { channels: ChannelsConfig; gateway: GatewayConfig };
|
||||
const snapshot = JSON.parse(initialJson) as {
|
||||
channels: ChannelsConfig;
|
||||
gateway: GatewayConfig;
|
||||
};
|
||||
setChannels(snapshot.channels);
|
||||
setGateway(snapshot.gateway);
|
||||
}
|
||||
@@ -2193,7 +2313,9 @@ export function ChannelsSettings({ className }: ChannelsSettingsProps) {
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">{page.label}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{page.description}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{page.description}
|
||||
</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -2216,7 +2338,10 @@ export function ChannelsSettings({ className }: ChannelsSettingsProps) {
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
{channelOverview.map((item) => (
|
||||
<div key={item.key} className="rounded-lg border bg-muted/20 p-4">
|
||||
<div
|
||||
key={item.key}
|
||||
className="rounded-lg border bg-muted/20 p-4"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium">{item.label}</h3>
|
||||
<span
|
||||
@@ -2343,7 +2468,9 @@ export function ChannelsSettings({ className }: ChannelsSettingsProps) {
|
||||
<GatewayTunnelPanel
|
||||
config={gateway}
|
||||
onChange={setGateway}
|
||||
defaultFeishuAccountId={channels.feishu.default_account || "default"}
|
||||
defaultFeishuAccountId={
|
||||
channels.feishu.default_account || "default"
|
||||
}
|
||||
onReloadConfig={loadConfig}
|
||||
/>
|
||||
</div>
|
||||
@@ -2428,7 +2555,11 @@ export function ChannelsSettings({ className }: ChannelsSettingsProps) {
|
||||
disabled={saving}
|
||||
className="flex items-center gap-1.5 px-4 py-1.5 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Save className="h-3.5 w-3.5" />}
|
||||
{saving ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
)}
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ExternalLink, Globe, RefreshCw } from "lucide-react";
|
||||
import { getConfig } from "@/hooks/useTauri";
|
||||
import { getConfig } from "@/lib/api/appConfig";
|
||||
import {
|
||||
browserExecuteAction,
|
||||
chromeBridgeExecuteCommand,
|
||||
@@ -41,21 +41,24 @@ const BACKEND_LABELS: Record<BrowserBackendType, string> = {
|
||||
export function ChromeRelaySettings() {
|
||||
const [testEngine, setTestEngine] = useState<SearchEngine>("google");
|
||||
const [openingEngine, setOpeningEngine] = useState<SearchEngine | null>(null);
|
||||
const [closingProfileKey, setClosingProfileKey] = useState<string | null>(null);
|
||||
const [closingProfileKey, setClosingProfileKey] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [refreshingSessions, setRefreshingSessions] = useState(false);
|
||||
const [refreshingBridge, setRefreshingBridge] = useState(false);
|
||||
const [refreshingBackends, setRefreshingBackends] = useState(false);
|
||||
const [savingBackendPolicy, setSavingBackendPolicy] = useState(false);
|
||||
const [testingBackend, setTestingBackend] = useState<BrowserBackendType | null>(null);
|
||||
const [testingBackend, setTestingBackend] =
|
||||
useState<BrowserBackendType | null>(null);
|
||||
const [testingBridgeEngine, setTestingBridgeEngine] =
|
||||
useState<SearchEngine | null>(null);
|
||||
const [sessions, setSessions] = useState<ChromeProfileSessionInfo[]>([]);
|
||||
const [bridgeEndpoint, setBridgeEndpoint] =
|
||||
useState<ChromeBridgeEndpointInfo | null>(null);
|
||||
const [bridgeStatus, setBridgeStatus] = useState<ChromeBridgeStatusSnapshot | null>(
|
||||
null,
|
||||
);
|
||||
const [backendPolicy, setBackendPolicy] = useState<BrowserBackendPolicy | null>(null);
|
||||
const [bridgeStatus, setBridgeStatus] =
|
||||
useState<ChromeBridgeStatusSnapshot | null>(null);
|
||||
const [backendPolicy, setBackendPolicy] =
|
||||
useState<BrowserBackendPolicy | null>(null);
|
||||
const [draftBackendPolicy, setDraftBackendPolicy] =
|
||||
useState<BrowserBackendPolicy | null>(null);
|
||||
const [backendsStatus, setBackendsStatus] =
|
||||
@@ -106,8 +109,11 @@ export function ChromeRelaySettings() {
|
||||
|
||||
const hasBackendPolicyChanges = useMemo(() => {
|
||||
if (!backendPolicy || !draftBackendPolicy) return false;
|
||||
if (backendPolicy.auto_fallback !== draftBackendPolicy.auto_fallback) return true;
|
||||
return backendPolicy.priority.join(",") !== draftBackendPolicy.priority.join(",");
|
||||
if (backendPolicy.auto_fallback !== draftBackendPolicy.auto_fallback)
|
||||
return true;
|
||||
return (
|
||||
backendPolicy.priority.join(",") !== draftBackendPolicy.priority.join(",")
|
||||
);
|
||||
}, [backendPolicy, draftBackendPolicy]);
|
||||
|
||||
const hasObserverConnected = (bridgeStatus?.observer_count ?? 0) > 0;
|
||||
@@ -188,7 +194,9 @@ export function ChromeRelaySettings() {
|
||||
const key = getProfileKey(engine);
|
||||
const observers = bridgeStatus?.observers || [];
|
||||
return (
|
||||
observers.find((observer) => observer.profile_key === key) || observers.at(0) || null
|
||||
observers.find((observer) => observer.profile_key === key) ||
|
||||
observers.at(0) ||
|
||||
null
|
||||
);
|
||||
};
|
||||
|
||||
@@ -303,7 +311,10 @@ export function ChromeRelaySettings() {
|
||||
}
|
||||
};
|
||||
|
||||
const updateBackendPriority = (index: number, backend: BrowserBackendType) => {
|
||||
const updateBackendPriority = (
|
||||
index: number,
|
||||
backend: BrowserBackendType,
|
||||
) => {
|
||||
setDraftBackendPolicy((prev) => {
|
||||
if (!prev) return prev;
|
||||
const next = [...prev.priority];
|
||||
@@ -382,7 +393,10 @@ export function ChromeRelaySettings() {
|
||||
text: `${BACKEND_LABELS[backend]} 测试成功`,
|
||||
});
|
||||
setTimeout(() => setMessage(null), 2500);
|
||||
await Promise.all([refreshBridgeStatus(true), refreshBackendStatus(true)]);
|
||||
await Promise.all([
|
||||
refreshBridgeStatus(true),
|
||||
refreshBackendStatus(true),
|
||||
]);
|
||||
} catch (error) {
|
||||
setMessage({
|
||||
type: "error",
|
||||
@@ -417,7 +431,8 @@ export function ChromeRelaySettings() {
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Profile 会话 {backendsStatus?.running_profile_count ?? 0} | CDP 可用{" "}
|
||||
{backendsStatus?.cdp_alive_profile_count ?? 0} | 扩展 observer{" "}
|
||||
{bridgeStatus?.observer_count ?? 0} | control {bridgeStatus?.control_count ?? 0}
|
||||
{bridgeStatus?.observer_count ?? 0} | control{" "}
|
||||
{bridgeStatus?.control_count ?? 0}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
@@ -426,7 +441,9 @@ export function ChromeRelaySettings() {
|
||||
void refreshBridgeStatus(false);
|
||||
void refreshBackendStatus(false);
|
||||
}}
|
||||
disabled={refreshingSessions || refreshingBridge || refreshingBackends}
|
||||
disabled={
|
||||
refreshingSessions || refreshingBridge || refreshingBackends
|
||||
}
|
||||
className="inline-flex items-center gap-2 rounded-md border px-3 py-2 text-sm hover:bg-muted disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw
|
||||
@@ -449,8 +466,8 @@ export function ChromeRelaySettings() {
|
||||
const session = getSessionByEngine("google");
|
||||
return session ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
会话运行中 | PID {session.pid} | 来源 {session.browser_source} | 调试端口{" "}
|
||||
{session.remote_debugging_port}
|
||||
会话运行中 | PID {session.pid} | 来源 {session.browser_source} |
|
||||
调试端口 {session.remote_debugging_port}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">当前无运行中的会话</p>
|
||||
@@ -469,10 +486,15 @@ export function ChromeRelaySettings() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => closeSession("google")}
|
||||
disabled={!getSessionByEngine("google") || closingProfileKey === GOOGLE_PROFILE_KEY}
|
||||
disabled={
|
||||
!getSessionByEngine("google") ||
|
||||
closingProfileKey === GOOGLE_PROFILE_KEY
|
||||
}
|
||||
className="inline-flex items-center gap-2 rounded-md border px-3 py-2 text-sm hover:bg-muted disabled:opacity-50"
|
||||
>
|
||||
{closingProfileKey === GOOGLE_PROFILE_KEY ? "关闭中..." : "关闭会话"}
|
||||
{closingProfileKey === GOOGLE_PROFILE_KEY
|
||||
? "关闭中..."
|
||||
: "关闭会话"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -486,8 +508,8 @@ export function ChromeRelaySettings() {
|
||||
const session = getSessionByEngine("xiaohongshu");
|
||||
return session ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
会话运行中 | PID {session.pid} | 来源 {session.browser_source} | 调试端口{" "}
|
||||
{session.remote_debugging_port}
|
||||
会话运行中 | PID {session.pid} | 来源 {session.browser_source} |
|
||||
调试端口 {session.remote_debugging_port}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">当前无运行中的会话</p>
|
||||
@@ -512,7 +534,9 @@ export function ChromeRelaySettings() {
|
||||
}
|
||||
className="inline-flex items-center gap-2 rounded-md border px-3 py-2 text-sm hover:bg-muted disabled:opacity-50"
|
||||
>
|
||||
{closingProfileKey === XIAOHONGSHU_PROFILE_KEY ? "关闭中..." : "关闭会话"}
|
||||
{closingProfileKey === XIAOHONGSHU_PROFILE_KEY
|
||||
? "关闭中..."
|
||||
: "关闭会话"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -520,11 +544,15 @@ export function ChromeRelaySettings() {
|
||||
<div className="rounded-lg border p-5 space-y-3">
|
||||
<h3 className="text-sm font-medium">浏览器后端编排策略</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
统一调度 Aster 协议适配、扩展桥接和 CDP 直连。支持按优先级执行并在失败时自动回退。
|
||||
统一调度 Aster 协议适配、扩展桥接和 CDP
|
||||
直连。支持按优先级执行并在失败时自动回退。
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="relay-test-engine" className="text-xs text-muted-foreground">
|
||||
<label
|
||||
htmlFor="relay-test-engine"
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
后端测试默认引擎
|
||||
</label>
|
||||
<select
|
||||
@@ -540,12 +568,20 @@ export function ChromeRelaySettings() {
|
||||
|
||||
<div className="space-y-2">
|
||||
{[0, 1, 2].map((idx) => (
|
||||
<div className="flex items-center gap-2" key={`backend-priority-${idx}`}>
|
||||
<div
|
||||
className="flex items-center gap-2"
|
||||
key={`backend-priority-${idx}`}
|
||||
>
|
||||
<label className="text-xs text-muted-foreground w-16">{`优先级 ${idx + 1}`}</label>
|
||||
<select
|
||||
value={draftBackendPolicy?.priority[idx] || BACKEND_OPTIONS[idx]}
|
||||
value={
|
||||
draftBackendPolicy?.priority[idx] || BACKEND_OPTIONS[idx]
|
||||
}
|
||||
onChange={(e) =>
|
||||
updateBackendPriority(idx, e.target.value as BrowserBackendType)
|
||||
updateBackendPriority(
|
||||
idx,
|
||||
e.target.value as BrowserBackendType,
|
||||
)
|
||||
}
|
||||
className="h-9 rounded-md border bg-background px-3 text-sm outline-none focus:ring-2 focus:ring-primary/20"
|
||||
>
|
||||
@@ -599,14 +635,17 @@ export function ChromeRelaySettings() {
|
||||
<div className="rounded-md border bg-muted/30 p-3 space-y-1 text-xs">
|
||||
{(backendsStatus?.backends || []).map((item) => (
|
||||
<p key={`backend-status-${item.backend}`}>
|
||||
{BACKEND_LABELS[item.backend]}: {item.available ? "可用" : "不可用"}
|
||||
{BACKEND_LABELS[item.backend]}:{" "}
|
||||
{item.available ? "可用" : "不可用"}
|
||||
{item.reason ? ` | ${item.reason}` : ""}
|
||||
</p>
|
||||
))}
|
||||
<p>
|
||||
Aster native-host: {backendsStatus?.aster_native_host_configured ? "已配置" : "未配置"}
|
||||
Aster native-host:{" "}
|
||||
{backendsStatus?.aster_native_host_configured ? "已配置" : "未配置"}
|
||||
{" | "}
|
||||
平台支持: {backendsStatus?.aster_native_host_supported ? "是" : "否"}
|
||||
平台支持:{" "}
|
||||
{backendsStatus?.aster_native_host_supported ? "是" : "否"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -625,7 +664,9 @@ export function ChromeRelaySettings() {
|
||||
disabled={refreshingBackends}
|
||||
className="inline-flex items-center gap-2 rounded-md border px-3 py-2 text-sm hover:bg-muted disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`h-3.5 w-3.5 ${refreshingBackends ? "animate-spin" : ""}`} />
|
||||
<RefreshCw
|
||||
className={`h-3.5 w-3.5 ${refreshingBackends ? "animate-spin" : ""}`}
|
||||
/>
|
||||
刷新后端状态
|
||||
</button>
|
||||
</div>
|
||||
@@ -634,17 +675,23 @@ export function ChromeRelaySettings() {
|
||||
<div className="rounded-lg border p-5 space-y-3">
|
||||
<h3 className="text-sm font-medium">Chrome 扩展桥接</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
该能力用于让浏览器扩展回传页面信息并接收控制命令。请在对应 Chrome Profile 安装扩展并连接 observer 地址。
|
||||
该能力用于让浏览器扩展回传页面信息并接收控制命令。请在对应 Chrome
|
||||
Profile 安装扩展并连接 observer 地址。
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
服务状态: {bridgeEndpoint?.server_running ? "运行中" : "未运行"} | observer 连接:{" "}
|
||||
{bridgeStatus?.observer_count ?? 0} | control 连接: {bridgeStatus?.control_count ?? 0} |
|
||||
待处理命令: {bridgeStatus?.pending_command_count ?? 0}
|
||||
服务状态: {bridgeEndpoint?.server_running ? "运行中" : "未运行"} |
|
||||
observer 连接: {bridgeStatus?.observer_count ?? 0} | control 连接:{" "}
|
||||
{bridgeStatus?.control_count ?? 0} | 待处理命令:{" "}
|
||||
{bridgeStatus?.pending_command_count ?? 0}
|
||||
</p>
|
||||
{bridgeEndpoint && (
|
||||
<div className="rounded-md border bg-muted/30 p-3 space-y-1 text-xs">
|
||||
<p className="break-all">Observer WS: {bridgeEndpoint.observer_ws_url}</p>
|
||||
<p className="break-all">Control WS: {bridgeEndpoint.control_ws_url}</p>
|
||||
<p className="break-all">
|
||||
Observer WS: {bridgeEndpoint.observer_ws_url}
|
||||
</p>
|
||||
<p className="break-all">
|
||||
Control WS: {bridgeEndpoint.control_ws_url}
|
||||
</p>
|
||||
<p className="break-all">Bridge Key: {bridgeEndpoint.bridge_key}</p>
|
||||
<div className="pt-2 flex items-center gap-2">
|
||||
<button
|
||||
@@ -655,8 +702,13 @@ export function ChromeRelaySettings() {
|
||||
bridgeKey: bridgeEndpoint.bridge_key,
|
||||
profileKey: "search_google",
|
||||
};
|
||||
navigator.clipboard.writeText(JSON.stringify(config, null, 2));
|
||||
setMessage({ type: "success", text: "Google 配置已复制到剪贴板" });
|
||||
navigator.clipboard.writeText(
|
||||
JSON.stringify(config, null, 2),
|
||||
);
|
||||
setMessage({
|
||||
type: "success",
|
||||
text: "Google 配置已复制到剪贴板",
|
||||
});
|
||||
setTimeout(() => setMessage(null), 2000);
|
||||
}}
|
||||
className="inline-flex items-center gap-1 rounded-md border px-2.5 py-1.5 text-xs hover:bg-muted"
|
||||
@@ -671,8 +723,13 @@ export function ChromeRelaySettings() {
|
||||
bridgeKey: bridgeEndpoint.bridge_key,
|
||||
profileKey: "search_xiaohongshu",
|
||||
};
|
||||
navigator.clipboard.writeText(JSON.stringify(config, null, 2));
|
||||
setMessage({ type: "success", text: "小红书配置已复制到剪贴板" });
|
||||
navigator.clipboard.writeText(
|
||||
JSON.stringify(config, null, 2),
|
||||
);
|
||||
setMessage({
|
||||
type: "success",
|
||||
text: "小红书配置已复制到剪贴板",
|
||||
});
|
||||
setTimeout(() => setMessage(null), 2000);
|
||||
}}
|
||||
className="inline-flex items-center gap-1 rounded-md border px-2.5 py-1.5 text-xs hover:bg-muted"
|
||||
@@ -684,7 +741,8 @@ export function ChromeRelaySettings() {
|
||||
)}
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
<p>
|
||||
Google observer: {(() => {
|
||||
Google observer:{" "}
|
||||
{(() => {
|
||||
const observer = getObserverByEngine("google");
|
||||
return observer
|
||||
? `${observer.client_id} | 最近页面: ${observer.last_page_info?.title || "无"}`
|
||||
@@ -692,7 +750,8 @@ export function ChromeRelaySettings() {
|
||||
})()}
|
||||
</p>
|
||||
<p>
|
||||
小红书 observer: {(() => {
|
||||
小红书 observer:{" "}
|
||||
{(() => {
|
||||
const observer = getObserverByEngine("xiaohongshu");
|
||||
return observer
|
||||
? `${observer.client_id} | 最近页面: ${observer.last_page_info?.title || "无"}`
|
||||
@@ -703,29 +762,30 @@ export function ChromeRelaySettings() {
|
||||
{!hasObserverConnected && (
|
||||
<div className="rounded-md border border-amber-400/40 bg-amber-50 px-3 py-2 text-xs text-amber-800">
|
||||
未检测到扩展 observer 连接。请在对应 Chrome Profile 安装并打开
|
||||
Proxycast Browser Bridge 扩展,在扩展弹窗填写 Observer WS 与 Bridge Key 后再测试。
|
||||
Proxycast Browser Bridge 扩展,在扩展弹窗填写 Observer WS 与 Bridge
|
||||
Key 后再测试。
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => testBridgeCommand("google")}
|
||||
disabled={
|
||||
testingBridgeEngine === "google"
|
||||
}
|
||||
disabled={testingBridgeEngine === "google"}
|
||||
className="inline-flex items-center gap-2 rounded-md border px-3 py-2 text-sm hover:bg-muted disabled:opacity-50"
|
||||
>
|
||||
{testingBridgeEngine === "google" ? "测试中..." : "测试 Google 扩展"}
|
||||
{testingBridgeEngine === "google"
|
||||
? "测试中..."
|
||||
: "测试 Google 扩展"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => testBridgeCommand("xiaohongshu")}
|
||||
disabled={
|
||||
testingBridgeEngine === "xiaohongshu"
|
||||
}
|
||||
disabled={testingBridgeEngine === "xiaohongshu"}
|
||||
className="inline-flex items-center gap-2 rounded-md border px-3 py-2 text-sm hover:bg-muted disabled:opacity-50"
|
||||
>
|
||||
{testingBridgeEngine === "xiaohongshu" ? "测试中..." : "测试小红书扩展"}
|
||||
{testingBridgeEngine === "xiaohongshu"
|
||||
? "测试中..."
|
||||
: "测试小红书扩展"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -733,7 +793,9 @@ export function ChromeRelaySettings() {
|
||||
disabled={refreshingBridge}
|
||||
className="inline-flex items-center gap-2 rounded-md border px-3 py-2 text-sm hover:bg-muted disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`h-3.5 w-3.5 ${refreshingBridge ? "animate-spin" : ""}`} />
|
||||
<RefreshCw
|
||||
className={`h-3.5 w-3.5 ${refreshingBridge ? "animate-spin" : ""}`}
|
||||
/>
|
||||
刷新扩展状态
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -6,14 +6,13 @@ import { useCallback, useState } from "react";
|
||||
import { Bug, Code2, Eye, Trash2 } from "lucide-react";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useComponentDebug } from "@/contexts/ComponentDebugContext";
|
||||
import { getConfig } from "@/lib/api/appConfig";
|
||||
import { getLogs, getPersistedLogsTail } from "@/lib/api/logs";
|
||||
import {
|
||||
getConfig,
|
||||
getLogs,
|
||||
getLogStorageDiagnostics,
|
||||
getPersistedLogsTail,
|
||||
getServerDiagnostics,
|
||||
getWindowsStartupDiagnostics,
|
||||
} from "@/hooks/useTauri";
|
||||
} from "@/lib/api/serverRuntime";
|
||||
import {
|
||||
buildCrashDiagnosticPayload,
|
||||
clearCrashDiagnosticHistory,
|
||||
|
||||
@@ -0,0 +1,508 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Save,
|
||||
ShieldAlert,
|
||||
Terminal,
|
||||
Trash2,
|
||||
Variable,
|
||||
Eye,
|
||||
EyeOff,
|
||||
} from "lucide-react";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
getConfig,
|
||||
getEnvironmentPreview,
|
||||
saveConfig,
|
||||
type Config,
|
||||
type EnvironmentConfig,
|
||||
type EnvironmentPreview,
|
||||
type EnvironmentVariableOverride,
|
||||
} from "@/lib/api/appConfig";
|
||||
|
||||
function normalizeEnvironmentConfig(config: Config | null): EnvironmentConfig {
|
||||
return {
|
||||
shell_import: {
|
||||
enabled: config?.environment?.shell_import?.enabled ?? false,
|
||||
timeout_ms: config?.environment?.shell_import?.timeout_ms ?? 1500,
|
||||
},
|
||||
variables: [...(config?.environment?.variables ?? [])],
|
||||
};
|
||||
}
|
||||
|
||||
function createEmptyVariable(): EnvironmentVariableOverride {
|
||||
return {
|
||||
key: "",
|
||||
value: "",
|
||||
enabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
function formatSourceLabel(source: string): string {
|
||||
switch (source) {
|
||||
case "override":
|
||||
return "环境变量覆盖";
|
||||
case "shell_import":
|
||||
return "Shell 环境导入";
|
||||
case "web_search":
|
||||
return "网络搜索配置";
|
||||
default:
|
||||
return source;
|
||||
}
|
||||
}
|
||||
|
||||
export function EnvironmentSettings() {
|
||||
const [config, setConfig] = useState<Config | null>(null);
|
||||
const [preview, setPreview] = useState<EnvironmentPreview | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshingPreview, setRefreshingPreview] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [showValues, setShowValues] = useState(false);
|
||||
const [message, setMessage] = useState<{
|
||||
type: "success" | "error";
|
||||
text: string;
|
||||
} | null>(null);
|
||||
|
||||
const environment = useMemo(
|
||||
() => normalizeEnvironmentConfig(config),
|
||||
[config],
|
||||
);
|
||||
|
||||
const loadPageData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const [nextConfig, nextPreview] = await Promise.all([
|
||||
getConfig(),
|
||||
getEnvironmentPreview(),
|
||||
]);
|
||||
setConfig(nextConfig);
|
||||
setPreview(nextPreview);
|
||||
} catch (error) {
|
||||
setMessage({
|
||||
type: "error",
|
||||
text: error instanceof Error ? error.message : "加载环境变量配置失败",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refreshPreview = useCallback(async () => {
|
||||
setRefreshingPreview(true);
|
||||
try {
|
||||
const nextPreview = await getEnvironmentPreview();
|
||||
setPreview(nextPreview);
|
||||
} catch (error) {
|
||||
setMessage({
|
||||
type: "error",
|
||||
text: error instanceof Error ? error.message : "刷新环境预览失败",
|
||||
});
|
||||
} finally {
|
||||
setRefreshingPreview(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadPageData();
|
||||
}, [loadPageData]);
|
||||
|
||||
const updateEnvironment = useCallback(
|
||||
(updater: (prev: EnvironmentConfig) => EnvironmentConfig) => {
|
||||
setConfig((prev) => {
|
||||
const baseConfig = prev ?? ({} as Config);
|
||||
const nextEnvironment = updater(normalizeEnvironmentConfig(prev));
|
||||
return {
|
||||
...baseConfig,
|
||||
environment: nextEnvironment,
|
||||
};
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
await saveConfig({
|
||||
...config,
|
||||
environment,
|
||||
});
|
||||
const [nextConfig, nextPreview] = await Promise.all([
|
||||
getConfig(),
|
||||
getEnvironmentPreview(),
|
||||
]);
|
||||
setConfig(nextConfig);
|
||||
setPreview(nextPreview);
|
||||
setMessage({
|
||||
type: "success",
|
||||
text: "环境变量配置已保存并应用到当前运行时",
|
||||
});
|
||||
} catch (error) {
|
||||
setMessage({
|
||||
type: "error",
|
||||
text: error instanceof Error ? error.message : "保存环境变量配置失败",
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [config, environment]);
|
||||
|
||||
const updateVariable = useCallback(
|
||||
(index: number, patch: Partial<EnvironmentVariableOverride>) => {
|
||||
updateEnvironment((prev) => ({
|
||||
...prev,
|
||||
variables: prev.variables.map((item, currentIndex) =>
|
||||
currentIndex === index ? { ...item, ...patch } : item,
|
||||
),
|
||||
}));
|
||||
},
|
||||
[updateEnvironment],
|
||||
);
|
||||
|
||||
const addVariable = useCallback(() => {
|
||||
updateEnvironment((prev) => ({
|
||||
...prev,
|
||||
variables: [...prev.variables, createEmptyVariable()],
|
||||
}));
|
||||
}, [updateEnvironment]);
|
||||
|
||||
const removeVariable = useCallback(
|
||||
(index: number) => {
|
||||
updateEnvironment((prev) => ({
|
||||
...prev,
|
||||
variables: prev.variables.filter(
|
||||
(_, currentIndex) => currentIndex !== index,
|
||||
),
|
||||
}));
|
||||
},
|
||||
[updateEnvironment],
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16 text-muted-foreground">
|
||||
<RefreshCw className="mr-2 h-5 w-5 animate-spin" />
|
||||
正在加载环境变量配置...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-xl border bg-card p-4">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Variable className="h-5 w-5 text-primary" />
|
||||
<h3 className="text-base font-semibold">统一环境变量控制</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
按 Shell
|
||||
环境导入与显式环境变量覆盖统一管理运行时环境。模型、搜索和
|
||||
OpenClaw 等模块只消费这里的结果,不再各自维护独立环境逻辑。
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
兼容说明:旧入口仍可编辑,但底层已统一写入同一份环境配置。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void refreshPreview()}
|
||||
disabled={refreshingPreview}
|
||||
className="inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm hover:bg-muted disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${refreshingPreview ? "animate-spin" : ""}`}
|
||||
/>
|
||||
刷新预览
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleSave()}
|
||||
disabled={saving}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-primary px-3 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
{saving ? "保存中..." : "保存并应用"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message ? (
|
||||
<div
|
||||
className={`rounded-xl border px-4 py-3 text-sm ${
|
||||
message.type === "success"
|
||||
? "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-300"
|
||||
: "border-destructive/30 bg-destructive/10 text-destructive"
|
||||
}`}
|
||||
>
|
||||
{message.text}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section className="rounded-xl border bg-card">
|
||||
<div className="border-b px-5 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal className="h-5 w-5 text-primary" />
|
||||
<h4 className="font-semibold">Shell 环境导入</h4>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
可选地从登录 Shell 读取 PATH、代理等环境变量,再与显式覆盖项合并。
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-4 px-5 py-4 lg:grid-cols-[1fr_220px]">
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="font-medium">启用 Shell 环境导入</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
适合需要继承登录 Shell 中 PATH、代理、Node
|
||||
版本管理器配置的场景。
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={environment.shell_import.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
updateEnvironment((prev) => ({
|
||||
...prev,
|
||||
shell_import: {
|
||||
...prev.shell_import,
|
||||
enabled: checked,
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border p-4">
|
||||
<label className="mb-2 block text-sm font-medium">
|
||||
导入超时(ms)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={100}
|
||||
max={30000}
|
||||
step={100}
|
||||
value={environment.shell_import.timeout_ms}
|
||||
onChange={(event) =>
|
||||
updateEnvironment((prev) => ({
|
||||
...prev,
|
||||
shell_import: {
|
||||
...prev.shell_import,
|
||||
timeout_ms: Math.min(
|
||||
30000,
|
||||
Math.max(
|
||||
100,
|
||||
Number.parseInt(event.target.value, 10) || 1500,
|
||||
),
|
||||
),
|
||||
},
|
||||
}))
|
||||
}
|
||||
className="w-full rounded-lg border bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
超时后会回退为仅使用显式覆盖,不阻塞整体运行。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{preview ? (
|
||||
<div className="border-t px-5 py-4 text-sm">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">当前状态:</span>
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-xs ${
|
||||
preview.shellImport.status === "ok"
|
||||
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-950/50 dark:text-emerald-300"
|
||||
: preview.shellImport.status === "disabled"
|
||||
? "bg-muted text-muted-foreground"
|
||||
: "bg-amber-100 text-amber-700 dark:bg-amber-950/50 dark:text-amber-300"
|
||||
}`}
|
||||
>
|
||||
{preview.shellImport.status}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{preview.shellImport.message}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border bg-card">
|
||||
<div className="border-b px-5 py-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Variable className="h-5 w-5 text-primary" />
|
||||
<h4 className="font-semibold">环境变量覆盖</h4>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
显式设置或覆盖运行时环境变量。这里的配置优先级高于 Shell
|
||||
导入与旧入口兼容逻辑。
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addVariable}
|
||||
className="inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm hover:bg-muted"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
添加变量
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3 px-5 py-4">
|
||||
{environment.variables.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||
暂无显式环境变量覆盖,当前仅使用 Shell
|
||||
导入与业务模块推导的运行时变量。
|
||||
</div>
|
||||
) : (
|
||||
environment.variables.map((entry, index) => (
|
||||
<div
|
||||
key={`${entry.key}-${index}`}
|
||||
className="grid gap-3 rounded-lg border p-4 lg:grid-cols-[140px_1fr_160px_56px]"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<label className="block text-xs font-medium text-muted-foreground">
|
||||
变量名
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={entry.key}
|
||||
onChange={(event) =>
|
||||
updateVariable(index, { key: event.target.value })
|
||||
}
|
||||
placeholder="例如 OPENAI_BASE_URL"
|
||||
className="w-full rounded-lg border bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="block text-xs font-medium text-muted-foreground">
|
||||
变量值
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={entry.value}
|
||||
onChange={(event) =>
|
||||
updateVariable(index, { value: event.target.value })
|
||||
}
|
||||
placeholder="变量值"
|
||||
className="w-full rounded-lg border bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end justify-between gap-3 lg:justify-start">
|
||||
<div className="space-y-2">
|
||||
<label className="block text-xs font-medium text-muted-foreground">
|
||||
启用
|
||||
</label>
|
||||
<Switch
|
||||
checked={entry.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
updateVariable(index, { enabled: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-end justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeVariable(index)}
|
||||
className="rounded-lg border p-2 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
title="删除变量"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border bg-card">
|
||||
<div className="border-b px-5 py-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldAlert className="h-5 w-5 text-primary" />
|
||||
<h4 className="font-semibold">生效预览</h4>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
展示当前统一环境层最终提供给运行时的关键变量,以及它们的来源。
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowValues((prev) => !prev)}
|
||||
className="inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm hover:bg-muted"
|
||||
>
|
||||
{showValues ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
{showValues ? "隐藏值" : "显示值"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-5 py-4">
|
||||
{!preview || preview.entries.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||
暂无可预览的环境变量。
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-lg border">
|
||||
<div className="grid grid-cols-[220px_1fr_160px] border-b bg-muted/40 px-4 py-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
<span>变量</span>
|
||||
<span>当前值</span>
|
||||
<span>来源</span>
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
{preview.entries.map((entry) => (
|
||||
<div
|
||||
key={entry.key}
|
||||
className="grid grid-cols-[220px_1fr_160px] items-start gap-3 px-4 py-3 text-sm"
|
||||
>
|
||||
<div className="font-mono font-medium">{entry.key}</div>
|
||||
<div className="space-y-1">
|
||||
<div className="font-mono break-all text-muted-foreground">
|
||||
{showValues || !entry.sensitive
|
||||
? entry.value
|
||||
: entry.maskedValue}
|
||||
</div>
|
||||
{entry.overriddenSources.length > 0 ? (
|
||||
<div className="text-xs text-amber-600 dark:text-amber-300">
|
||||
已覆盖来源:
|
||||
{entry.overriddenSources
|
||||
.map(formatSourceLabel)
|
||||
.join("、")}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
<span className="inline-flex rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
|
||||
{entry.sourceLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,29 +11,15 @@
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { safeInvoke } from "@/lib/dev-bridge";
|
||||
import { Bug } from "lucide-react";
|
||||
|
||||
interface UpdateCheckConfig {
|
||||
enabled: boolean;
|
||||
check_interval_hours: number;
|
||||
show_notification: boolean;
|
||||
last_check_timestamp: number;
|
||||
skipped_version: string | null;
|
||||
remind_later_until: number | null;
|
||||
}
|
||||
|
||||
interface UpdateNotificationMetrics {
|
||||
shown_count: number;
|
||||
update_now_count: number;
|
||||
remind_later_count: number;
|
||||
skip_version_count: number;
|
||||
dismiss_count: number;
|
||||
update_now_rate: number;
|
||||
remind_later_rate: number;
|
||||
skip_version_rate: number;
|
||||
dismiss_rate: number;
|
||||
}
|
||||
import {
|
||||
getUpdateCheckSettings,
|
||||
getUpdateNotificationMetrics,
|
||||
setUpdateCheckSettings,
|
||||
testUpdateWindow,
|
||||
type UpdateCheckConfig,
|
||||
type UpdateNotificationMetrics,
|
||||
} from "@/lib/api/appUpdate";
|
||||
|
||||
/**
|
||||
* 更新检查设置组件
|
||||
@@ -67,15 +53,11 @@ export function UpdateCheckSettings() {
|
||||
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
const configResult = await safeInvoke<UpdateCheckConfig>(
|
||||
"get_update_check_settings",
|
||||
);
|
||||
const configResult = await getUpdateCheckSettings();
|
||||
setSettings(configResult);
|
||||
|
||||
try {
|
||||
const metricsResult = await safeInvoke<UpdateNotificationMetrics>(
|
||||
"get_update_notification_metrics",
|
||||
);
|
||||
const metricsResult = await getUpdateNotificationMetrics();
|
||||
setMetrics(metricsResult);
|
||||
} catch (metricsError) {
|
||||
console.error("加载更新提醒指标失败:", metricsError);
|
||||
@@ -89,7 +71,7 @@ export function UpdateCheckSettings() {
|
||||
|
||||
const saveSettings = async (newSettings: UpdateCheckConfig) => {
|
||||
try {
|
||||
await safeInvoke("set_update_check_settings", { settings: newSettings });
|
||||
await setUpdateCheckSettings(newSettings);
|
||||
setSettings(newSettings);
|
||||
} catch (error) {
|
||||
console.error("保存更新检查设置失败:", error);
|
||||
@@ -234,12 +216,14 @@ export function UpdateCheckSettings() {
|
||||
<div className="p-3 rounded-lg border bg-muted/20 space-y-1">
|
||||
<div className="text-sm font-medium">提醒转化指标</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
展示 {metrics.shown_count} 次,立即更新 {metrics.update_now_count} 次(
|
||||
展示 {metrics.shown_count} 次,立即更新 {metrics.update_now_count}{" "}
|
||||
次(
|
||||
{metrics.update_now_rate}%)
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
稍后 {metrics.remind_later_count} 次({metrics.remind_later_rate}%),跳过{" "}
|
||||
{metrics.skip_version_count} 次({metrics.skip_version_rate}%)
|
||||
稍后 {metrics.remind_later_count} 次({metrics.remind_later_rate}
|
||||
%),跳过 {metrics.skip_version_count} 次(
|
||||
{metrics.skip_version_rate}%)
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
关闭 {metrics.dismiss_count} 次({metrics.dismiss_rate}%)
|
||||
@@ -251,7 +235,7 @@ export function UpdateCheckSettings() {
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await safeInvoke("test_update_window");
|
||||
await testUpdateWindow();
|
||||
} catch (error) {
|
||||
console.error("测试更新弹窗失败:", error);
|
||||
}
|
||||
|
||||
@@ -7,25 +7,35 @@
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { FlaskConical, Camera, AlertTriangle, RefreshCw, Bug, Wrench } from "lucide-react";
|
||||
import {
|
||||
FlaskConical,
|
||||
Camera,
|
||||
AlertTriangle,
|
||||
RefreshCw,
|
||||
Bug,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
getExperimentalConfig,
|
||||
saveExperimentalConfig,
|
||||
validateShortcut,
|
||||
updateScreenshotShortcut,
|
||||
ExperimentalFeatures,
|
||||
getConfig,
|
||||
saveConfig,
|
||||
type Config,
|
||||
getLogs,
|
||||
getLogStorageDiagnostics,
|
||||
getPersistedLogsTail,
|
||||
getServerDiagnostics,
|
||||
getWindowsStartupDiagnostics,
|
||||
type CrashReportingConfig,
|
||||
type ToolCallingConfig,
|
||||
} from "@/hooks/useTauri";
|
||||
} from "@/lib/api/appConfig";
|
||||
import {
|
||||
getExperimentalConfig,
|
||||
saveExperimentalConfig,
|
||||
updateScreenshotShortcut,
|
||||
validateShortcut,
|
||||
type ExperimentalFeatures,
|
||||
} from "@/lib/api/experimentalFeatures";
|
||||
import { getLogs, getPersistedLogsTail } from "@/lib/api/logs";
|
||||
import {
|
||||
getLogStorageDiagnostics,
|
||||
getServerDiagnostics,
|
||||
getWindowsStartupDiagnostics,
|
||||
} from "@/lib/api/serverRuntime";
|
||||
import { ShortcutSettings } from "@/components/smart-input/ShortcutSettings";
|
||||
import { UpdateCheckSettings } from "./UpdateCheckSettings";
|
||||
import { VoiceSettings } from "@/components/voice";
|
||||
@@ -86,17 +96,16 @@ export function ExperimentalSettings() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [experimentalConfig, voiceInputConfig, fullConfig] = await Promise.all([
|
||||
getExperimentalConfig(),
|
||||
getVoiceInputConfig(),
|
||||
getConfig(),
|
||||
]);
|
||||
const [experimentalConfig, voiceInputConfig, fullConfig] =
|
||||
await Promise.all([
|
||||
getExperimentalConfig(),
|
||||
getVoiceInputConfig(),
|
||||
getConfig(),
|
||||
]);
|
||||
setConfig(experimentalConfig);
|
||||
setToolCallingConfig(normalizeToolCallingConfig(fullConfig.tool_calling));
|
||||
setVoiceConfig(voiceInputConfig);
|
||||
setCrashConfig(
|
||||
normalizeCrashReportingConfig(fullConfig.crash_reporting),
|
||||
);
|
||||
setCrashConfig(normalizeCrashReportingConfig(fullConfig.crash_reporting));
|
||||
} catch (err) {
|
||||
console.error("加载实验室配置失败:", err);
|
||||
setError(err instanceof Error ? err.message : "加载配置失败");
|
||||
@@ -241,7 +250,8 @@ export function ExperimentalSettings() {
|
||||
console.error("保存 Tool Calling 配置失败:", err);
|
||||
setMessage({
|
||||
type: "error",
|
||||
text: err instanceof Error ? err.message : "保存 Tool Calling 配置失败",
|
||||
text:
|
||||
err instanceof Error ? err.message : "保存 Tool Calling 配置失败",
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
@@ -768,7 +778,9 @@ export function ExperimentalSettings() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-muted-foreground">采样率 (0-1)</label>
|
||||
<label className="text-xs text-muted-foreground">
|
||||
采样率 (0-1)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ToolCallingConfig } from "@/hooks/useTauri";
|
||||
import type { ToolCallingConfig } from "@/lib/api/appConfig";
|
||||
|
||||
export const DEFAULT_TOOL_CALLING_CONFIG: ToolCallingConfig = {
|
||||
enabled: true,
|
||||
@@ -12,7 +12,8 @@ export function normalizeToolCallingConfig(
|
||||
return {
|
||||
enabled: config?.enabled ?? DEFAULT_TOOL_CALLING_CONFIG.enabled,
|
||||
dynamic_filtering:
|
||||
config?.dynamic_filtering ?? DEFAULT_TOOL_CALLING_CONFIG.dynamic_filtering,
|
||||
config?.dynamic_filtering ??
|
||||
DEFAULT_TOOL_CALLING_CONFIG.dynamic_filtering,
|
||||
native_input_examples:
|
||||
config?.native_input_examples ??
|
||||
DEFAULT_TOOL_CALLING_CONFIG.native_input_examples,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Eye, EyeOff, Copy, Check, RefreshCw } from "lucide-react";
|
||||
import { getConfig, saveConfig, Config } from "@/hooks/useTauri";
|
||||
import { getConfig, saveConfig, type Config } from "@/lib/api/appConfig";
|
||||
|
||||
const DEFAULT_RESPONSE_CACHE_SETTINGS = {
|
||||
enabled: true,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Globe, Image as ImageIcon, RefreshCw } from "lucide-react";
|
||||
import { open } from "@tauri-apps/plugin-shell";
|
||||
import { getConfig, saveConfig, type Config } from "@/hooks/useTauri";
|
||||
import { getConfig, saveConfig, type Config } from "@/lib/api/appConfig";
|
||||
|
||||
type SearchEngine = "google" | "xiaohongshu";
|
||||
type WebSearchProvider =
|
||||
|
||||
@@ -5,22 +5,24 @@
|
||||
## 架构说明
|
||||
|
||||
Skills 组件模块提供 Skill 管理和执行的 UI 界面,包括:
|
||||
- Skill 列表展示和管理
|
||||
|
||||
- Built-in / Local / Remote Skills 分组展示与管理
|
||||
- Skill 仓库管理
|
||||
- Skill 执行对话框
|
||||
- Workflow 执行进度展示
|
||||
|
||||
## 文件索引
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `index.ts` | 模块导出入口 |
|
||||
| `SkillsPage.tsx` | Skills 主页面,展示 Skill 列表 |
|
||||
| `SkillCard.tsx` | Skill 卡片组件,展示单个 Skill 信息和操作 |
|
||||
| `SkillCard.test.ts` | SkillCard 组件测试 |
|
||||
| `RepoManagerPanel.tsx` | Skill 仓库管理面板 |
|
||||
| 文件 | 说明 |
|
||||
| -------------------------- | ------------------------------------------ |
|
||||
| `index.ts` | 模块导出入口 |
|
||||
| `SkillsPage.tsx` | Skills 主页面,展示 Skill 列表 |
|
||||
| `SkillsPage.test.tsx` | Skills 主页面分组与筛选测试 |
|
||||
| `SkillCard.tsx` | Skill 卡片组件,展示单个 Skill 信息和操作 |
|
||||
| `SkillCard.test.ts` | SkillCard 组件测试 |
|
||||
| `RepoManagerPanel.tsx` | Skill 仓库管理面板 |
|
||||
| `SkillExecutionDialog.tsx` | Skill 执行对话框,显示详情、输入表单和进度 |
|
||||
| `WorkflowProgress.tsx` | Workflow 进度展示组件 |
|
||||
| `WorkflowProgress.tsx` | Workflow 进度展示组件 |
|
||||
|
||||
## 组件依赖关系
|
||||
|
||||
@@ -32,6 +34,13 @@ SkillsPage
|
||||
└── WorkflowProgress (执行进度)
|
||||
```
|
||||
|
||||
## 当前行为
|
||||
|
||||
- `Built-in Skills` 展示 ProxyCast 随应用提供的内置技能,默认可用,不显示安装/卸载入口
|
||||
- `Local Skills` 直接读取本地目录中的技能,优先展示,不依赖远程网络
|
||||
- `Remote Skills` 展示仓库缓存中的远程技能,点击刷新时才同步最新仓库列表
|
||||
- 搜索和安装状态筛选会先作用于全量技能,再按分组渲染结果
|
||||
|
||||
## 更新提醒
|
||||
|
||||
任何文件变更后,请更新此文档和相关的上级文档。
|
||||
|
||||
@@ -11,6 +11,7 @@ import { describe, expect, it } from "vitest";
|
||||
import { test } from "@fast-check/vitest";
|
||||
import * as fc from "fast-check";
|
||||
import {
|
||||
canManageSkillInstallation,
|
||||
canViewLocalSkillContent,
|
||||
getSkillSource,
|
||||
type SkillSource,
|
||||
@@ -27,6 +28,7 @@ function createSkill(overrides: Partial<Skill> = {}): Skill {
|
||||
description: "A test skill",
|
||||
directory: "test-skill",
|
||||
installed: false,
|
||||
sourceKind: "other",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -36,6 +38,7 @@ describe("getSkillSource", () => {
|
||||
* Property 4: Source Classification Logic
|
||||
*
|
||||
* *For any* Skill object, the source classification SHALL return:
|
||||
* - "builtin" if sourceKind="builtin"
|
||||
* - "official" if repoOwner="proxycast" AND repoName="skills"
|
||||
* - "community" if repoOwner and repoName are present but not proxycast/skills
|
||||
* - "local" if repoOwner or repoName is missing
|
||||
@@ -129,16 +132,27 @@ describe("getSkillSource", () => {
|
||||
repoName: repoName ?? undefined,
|
||||
});
|
||||
const source = getSkillSource(skill);
|
||||
expect(["official", "community", "local"]).toContain(source);
|
||||
expect(["builtin", "official", "community", "local"]).toContain(source);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canViewLocalSkillContent", () => {
|
||||
it("内置且已安装 skill 应可查看内容", () => {
|
||||
const skill = createSkill({
|
||||
installed: true,
|
||||
sourceKind: "builtin",
|
||||
repoOwner: undefined,
|
||||
repoName: undefined,
|
||||
});
|
||||
expect(canViewLocalSkillContent(skill)).toBe(true);
|
||||
});
|
||||
|
||||
it("本地且已安装 skill 应可查看内容", () => {
|
||||
const skill = createSkill({
|
||||
installed: true,
|
||||
sourceKind: "other",
|
||||
repoOwner: undefined,
|
||||
repoName: undefined,
|
||||
});
|
||||
@@ -148,6 +162,7 @@ describe("canViewLocalSkillContent", () => {
|
||||
it("本地但未安装 skill 不可查看内容", () => {
|
||||
const skill = createSkill({
|
||||
installed: false,
|
||||
sourceKind: "other",
|
||||
repoOwner: undefined,
|
||||
repoName: undefined,
|
||||
});
|
||||
@@ -157,9 +172,30 @@ describe("canViewLocalSkillContent", () => {
|
||||
it("非本地已安装 skill 不可查看内容", () => {
|
||||
const skill = createSkill({
|
||||
installed: true,
|
||||
sourceKind: "other",
|
||||
repoOwner: "proxycast",
|
||||
repoName: "skills",
|
||||
});
|
||||
expect(canViewLocalSkillContent(skill)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canManageSkillInstallation", () => {
|
||||
it("内置 skill 不应显示安装或卸载入口", () => {
|
||||
const skill = createSkill({ sourceKind: "builtin" });
|
||||
expect(canManageSkillInstallation(skill)).toBe(false);
|
||||
});
|
||||
|
||||
it("其他 skill 应保留安装或卸载入口", () => {
|
||||
const skill = createSkill({ sourceKind: "other" });
|
||||
expect(canManageSkillInstallation(skill)).toBe(true);
|
||||
});
|
||||
});
|
||||
it("内置技能应优先返回 'builtin'", () => {
|
||||
const skill = createSkill({
|
||||
sourceKind: "builtin",
|
||||
repoOwner: "proxycast",
|
||||
repoName: "skills",
|
||||
});
|
||||
expect(getSkillSource(skill)).toBe("builtin" as SkillSource);
|
||||
});
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
*
|
||||
* 功能:
|
||||
* - 显示 Skill 基本信息(名称、描述、来源)
|
||||
* - 安装/卸载操作按钮
|
||||
* - 安装/卸载操作按钮(非内置)
|
||||
* - 执行按钮(仅已安装的 Skill 显示)
|
||||
* - 查看内容按钮(仅本地且已安装的 Skill 显示)
|
||||
* - 查看内容按钮(内置或本地且已安装的 Skill 显示)
|
||||
* - GitHub 链接按钮
|
||||
*
|
||||
* @module components/skills
|
||||
@@ -25,11 +25,12 @@ import type { Skill } from "@/lib/api/skills";
|
||||
|
||||
/**
|
||||
* Skill 来源类型
|
||||
* - builtin: ProxyCast 内置技能
|
||||
* - official: 来自 proxycast/skills 官方仓库
|
||||
* - community: 来自其他 GitHub 仓库
|
||||
* - local: 本地安装,无仓库信息
|
||||
*/
|
||||
export type SkillSource = "official" | "community" | "local";
|
||||
export type SkillSource = "builtin" | "official" | "community" | "local";
|
||||
|
||||
/**
|
||||
* 判断 Skill 的来源类型
|
||||
@@ -38,12 +39,16 @@ export type SkillSource = "official" | "community" | "local";
|
||||
* @returns SkillSource - 来源类型
|
||||
*
|
||||
* 分类规则:
|
||||
* - "builtin": sourceKind="builtin"
|
||||
* - "official": repoOwner="proxycast" AND repoName="skills"
|
||||
* - "community": repoOwner 和 repoName 存在但不是 proxycast/skills
|
||||
* - "local": repoOwner 或 repoName 缺失
|
||||
*/
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function getSkillSource(skill: Skill): SkillSource {
|
||||
if (skill.sourceKind === "builtin") {
|
||||
return "builtin";
|
||||
}
|
||||
if (!skill.repoOwner || !skill.repoName) {
|
||||
return "local";
|
||||
}
|
||||
@@ -56,14 +61,28 @@ export function getSkillSource(skill: Skill): SkillSource {
|
||||
/**
|
||||
* 是否可查看本地 Skill 内容
|
||||
*
|
||||
* 仅本地且已安装的 Skill 支持查看 SKILL.md。
|
||||
* 仅内置或本地且已安装的 Skill 支持查看 SKILL.md。
|
||||
*
|
||||
* @param skill - Skill 对象
|
||||
* @returns 是否显示查看内容入口
|
||||
*/
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function canViewLocalSkillContent(skill: Skill): boolean {
|
||||
return skill.installed && getSkillSource(skill) === "local";
|
||||
const source = getSkillSource(skill);
|
||||
return skill.installed && (source === "builtin" || source === "local");
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否允许用户安装或卸载 Skill
|
||||
*
|
||||
* 内置 Skill 默认可用,不提供安装/卸载入口。
|
||||
*
|
||||
* @param skill - Skill 对象
|
||||
* @returns 是否显示安装/卸载操作
|
||||
*/
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function canManageSkillInstallation(skill: Skill): boolean {
|
||||
return skill.sourceKind !== "builtin";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -71,6 +90,11 @@ export function canViewLocalSkillContent(skill: Skill): boolean {
|
||||
*/
|
||||
const sourceConfig: Record<SkillSource, { label: string; className: string }> =
|
||||
{
|
||||
builtin: {
|
||||
label: "内置",
|
||||
className:
|
||||
"bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400",
|
||||
},
|
||||
official: {
|
||||
label: "官方",
|
||||
className:
|
||||
@@ -119,9 +143,9 @@ interface SkillCardProps {
|
||||
* Skill 卡片组件
|
||||
*
|
||||
* 展示单个 Skill 的信息和操作按钮,包括:
|
||||
* - 安装/卸载按钮
|
||||
* - 安装/卸载按钮(非内置)
|
||||
* - 执行按钮(仅已安装的 Skill 显示)
|
||||
* - 查看内容按钮(仅本地且已安装的 Skill 显示)
|
||||
* - 查看内容按钮(仅内置或本地且已安装的 Skill 显示)
|
||||
* - GitHub 链接按钮
|
||||
*
|
||||
* @param props - 组件属性
|
||||
@@ -137,8 +161,10 @@ export function SkillCard({
|
||||
onViewContent,
|
||||
installing,
|
||||
}: SkillCardProps) {
|
||||
const canManageInstallation = canManageSkillInstallation(skill);
|
||||
|
||||
const handleAction = () => {
|
||||
if (installing) return;
|
||||
if (installing || !canManageInstallation) return;
|
||||
if (skill.installed) {
|
||||
onUninstall(skill.directory);
|
||||
} else {
|
||||
@@ -169,7 +195,9 @@ export function SkillCard({
|
||||
};
|
||||
|
||||
const source = getSkillSource(skill);
|
||||
const showViewContent = Boolean(onViewContent && canViewLocalSkillContent(skill));
|
||||
const showViewContent = Boolean(
|
||||
onViewContent && canViewLocalSkillContent(skill),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4 hover:shadow-md transition-shadow">
|
||||
@@ -197,36 +225,38 @@ export function SkillCard({
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleAction}
|
||||
disabled={installing}
|
||||
className={`flex-1 flex items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
|
||||
skill.installed
|
||||
? "border border-red-500 text-red-500 hover:bg-red-50 dark:hover:bg-red-950/30"
|
||||
: "bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
} disabled:opacity-50 disabled:cursor-not-allowed`}
|
||||
>
|
||||
{installing ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{skill.installed ? "卸载中..." : "安装中..."}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{skill.installed ? (
|
||||
<>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
卸载
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="h-4 w-4" />
|
||||
安装
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{canManageInstallation && (
|
||||
<button
|
||||
onClick={handleAction}
|
||||
disabled={installing}
|
||||
className={`flex-1 flex items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
|
||||
skill.installed
|
||||
? "border border-red-500 text-red-500 hover:bg-red-50 dark:hover:bg-red-950/30"
|
||||
: "bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
} disabled:opacity-50 disabled:cursor-not-allowed`}
|
||||
>
|
||||
{installing ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{skill.installed ? "卸载中..." : "安装中..."}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{skill.installed ? (
|
||||
<>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
卸载
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="h-4 w-4" />
|
||||
安装
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 执行按钮 - 仅已安装的 Skill 显示 */}
|
||||
{skill.installed && onExecute && (
|
||||
@@ -241,7 +271,7 @@ export function SkillCard({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 查看内容按钮 - 仅本地且已安装的 Skill 显示 */}
|
||||
{/* 查看内容按钮 - 仅内置或本地且已安装的 Skill 显示 */}
|
||||
{showViewContent && (
|
||||
<button
|
||||
onClick={handleViewContent}
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { act, type ComponentProps } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/components/preview/MarkdownPreview", () => ({
|
||||
MarkdownPreview: ({
|
||||
content,
|
||||
}: {
|
||||
content: string;
|
||||
}) => <div data-testid="markdown-preview">{content}</div>,
|
||||
}));
|
||||
|
||||
import { SkillContentDialog } from "./SkillContentDialog";
|
||||
|
||||
interface RenderResult {
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
SkillsPage,
|
||||
} from "./SkillsPage";
|
||||
import {
|
||||
filterSkillsByQueryAndStatus,
|
||||
groupSkillsBySourceKind,
|
||||
} from "./skillsUtils";
|
||||
import type { Skill } from "@/lib/api/skills";
|
||||
|
||||
const mockUseSkills = vi.fn();
|
||||
|
||||
vi.mock("@/hooks/useSkills", () => ({
|
||||
useSkills: (...args: unknown[]) => mockUseSkills(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./SkillContentDialog", () => ({
|
||||
SkillContentDialog: () => null,
|
||||
}));
|
||||
|
||||
interface RenderResult {
|
||||
container: HTMLDivElement;
|
||||
root: Root;
|
||||
}
|
||||
|
||||
const mountedRoots: RenderResult[] = [];
|
||||
|
||||
function createSkill(overrides: Partial<Skill> = {}): Skill {
|
||||
return {
|
||||
key: "skill:test",
|
||||
name: "Test Skill",
|
||||
description: "A test skill",
|
||||
directory: "test-skill",
|
||||
installed: true,
|
||||
sourceKind: "other",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderSkillsPage(): RenderResult {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(<SkillsPage hideHeader />);
|
||||
});
|
||||
|
||||
const rendered = { container, root };
|
||||
mountedRoots.push(rendered);
|
||||
return rendered;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
}
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
mockUseSkills.mockReturnValue({
|
||||
skills: [],
|
||||
repos: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: vi.fn(),
|
||||
install: vi.fn(),
|
||||
uninstall: vi.fn(),
|
||||
addRepo: vi.fn(),
|
||||
removeRepo: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockUseSkills.mockReset();
|
||||
|
||||
while (mountedRoots.length > 0) {
|
||||
const mounted = mountedRoots.pop();
|
||||
if (!mounted) {
|
||||
break;
|
||||
}
|
||||
act(() => {
|
||||
mounted.root.unmount();
|
||||
});
|
||||
mounted.container.remove();
|
||||
}
|
||||
});
|
||||
|
||||
describe("filterSkillsByQueryAndStatus", () => {
|
||||
it("应同时按搜索词和安装状态过滤技能", () => {
|
||||
const skills = [
|
||||
createSkill({ name: "Video Skill", installed: true }),
|
||||
createSkill({
|
||||
key: "skill:draft",
|
||||
name: "Draft Writer",
|
||||
directory: "draft-writer",
|
||||
installed: false,
|
||||
}),
|
||||
];
|
||||
|
||||
const result = filterSkillsByQueryAndStatus(skills, "draft", "uninstalled");
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.name).toBe("Draft Writer");
|
||||
});
|
||||
});
|
||||
|
||||
describe("groupSkillsBySourceKind", () => {
|
||||
it("应将技能分为内置、本地和远程三组", () => {
|
||||
const sections = groupSkillsBySourceKind([
|
||||
createSkill({
|
||||
key: "builtin:video_generate",
|
||||
name: "Video Generate",
|
||||
directory: "video_generate",
|
||||
sourceKind: "builtin",
|
||||
}),
|
||||
createSkill({
|
||||
key: "local:custom",
|
||||
name: "Custom Skill",
|
||||
directory: "custom-skill",
|
||||
sourceKind: "other",
|
||||
}),
|
||||
createSkill({
|
||||
key: "repo:remote",
|
||||
name: "Remote Skill",
|
||||
directory: "remote-skill",
|
||||
sourceKind: "other",
|
||||
repoOwner: "proxycast",
|
||||
repoName: "skills",
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(sections[0]?.key).toBe("builtin");
|
||||
expect(sections[0]?.skills).toHaveLength(1);
|
||||
expect(sections[1]?.key).toBe("local");
|
||||
expect(sections[1]?.skills).toHaveLength(1);
|
||||
expect(sections[2]?.key).toBe("remote");
|
||||
expect(sections[2]?.skills).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SkillsPage", () => {
|
||||
it("应按 Built-in / Local / Remote Skills 分组渲染,并隐藏内置技能卸载入口", () => {
|
||||
mockUseSkills.mockReturnValue({
|
||||
skills: [
|
||||
createSkill({
|
||||
key: "builtin:video_generate",
|
||||
name: "Video Generate",
|
||||
directory: "video_generate",
|
||||
sourceKind: "builtin",
|
||||
}),
|
||||
createSkill({
|
||||
key: "local:custom",
|
||||
name: "Local Skill",
|
||||
directory: "local-skill",
|
||||
sourceKind: "other",
|
||||
installed: false,
|
||||
}),
|
||||
createSkill({
|
||||
key: "repo:custom",
|
||||
name: "Remote Skill",
|
||||
directory: "remote-skill",
|
||||
repoOwner: "proxycast",
|
||||
repoName: "skills",
|
||||
sourceKind: "other",
|
||||
}),
|
||||
],
|
||||
repos: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: vi.fn(),
|
||||
install: vi.fn(),
|
||||
uninstall: vi.fn(),
|
||||
addRepo: vi.fn(),
|
||||
removeRepo: vi.fn(),
|
||||
});
|
||||
|
||||
const { container } = renderSkillsPage();
|
||||
const text = container.textContent ?? "";
|
||||
|
||||
expect(text).toContain("BUILT-IN SKILLS");
|
||||
expect(text).toContain("LOCAL SKILLS");
|
||||
expect(text).toContain("REMOTE SKILLS");
|
||||
expect(text.indexOf("BUILT-IN SKILLS")).toBeLessThan(
|
||||
text.indexOf("LOCAL SKILLS"),
|
||||
);
|
||||
expect(text.indexOf("LOCAL SKILLS")).toBeLessThan(
|
||||
text.indexOf("REMOTE SKILLS"),
|
||||
);
|
||||
|
||||
const buttonTexts = Array.from(container.querySelectorAll("button")).map(
|
||||
(button) => button.textContent?.trim() ?? "",
|
||||
);
|
||||
const uninstallButtons = buttonTexts.filter((textContent) =>
|
||||
textContent.includes("卸载"),
|
||||
);
|
||||
|
||||
expect(uninstallButtons).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("远程缓存为空时仍应显示远程分组和刷新提示", () => {
|
||||
mockUseSkills.mockReturnValue({
|
||||
skills: [
|
||||
createSkill({
|
||||
key: "builtin:video_generate",
|
||||
name: "Video Generate",
|
||||
directory: "video_generate",
|
||||
sourceKind: "builtin",
|
||||
}),
|
||||
createSkill({
|
||||
key: "local:custom",
|
||||
name: "Local Skill",
|
||||
directory: "local-skill",
|
||||
sourceKind: "other",
|
||||
}),
|
||||
],
|
||||
repos: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: vi.fn(),
|
||||
install: vi.fn(),
|
||||
uninstall: vi.fn(),
|
||||
addRepo: vi.fn(),
|
||||
removeRepo: vi.fn(),
|
||||
});
|
||||
|
||||
const { container } = renderSkillsPage();
|
||||
const text = container.textContent ?? "";
|
||||
|
||||
expect(text).toContain("REMOTE SKILLS");
|
||||
expect(text).toContain("暂无远程缓存");
|
||||
expect(text).toContain('点击"刷新"同步已启用仓库');
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,10 @@ import { SkillCard } from "./SkillCard";
|
||||
import { RepoManagerPanel } from "./RepoManagerPanel";
|
||||
import { SkillExecutionDialog } from "./SkillExecutionDialog";
|
||||
import { SkillContentDialog } from "./SkillContentDialog";
|
||||
import {
|
||||
filterSkillsByQueryAndStatus,
|
||||
groupSkillsBySourceKind,
|
||||
} from "./skillsUtils";
|
||||
import { HelpTip } from "@/components/HelpTip";
|
||||
import { skillsApi, type AppType, type Skill } from "@/lib/api/skills";
|
||||
|
||||
@@ -46,6 +50,7 @@ export const SkillsPage = forwardRef<SkillsPageRef, SkillsPageProps>(
|
||||
skills,
|
||||
repos,
|
||||
loading,
|
||||
remoteLoading,
|
||||
error,
|
||||
refresh,
|
||||
install,
|
||||
@@ -125,7 +130,10 @@ export const SkillsPage = forwardRef<SkillsPageRef, SkillsPageProps>(
|
||||
setContentLoading(true);
|
||||
|
||||
try {
|
||||
const content = await skillsApi.getLocalSkillContent(skill.directory, app);
|
||||
const content = await skillsApi.getLocalSkillContent(
|
||||
skill.directory,
|
||||
app,
|
||||
);
|
||||
if (requestId !== contentRequestIdRef.current) {
|
||||
return;
|
||||
}
|
||||
@@ -158,18 +166,23 @@ export const SkillsPage = forwardRef<SkillsPageRef, SkillsPageProps>(
|
||||
}
|
||||
};
|
||||
|
||||
const filteredSkills = skills.filter((skill) => {
|
||||
const matchesSearch =
|
||||
skill.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
skill.description.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
|
||||
const matchesFilter =
|
||||
filterStatus === "all" ||
|
||||
(filterStatus === "installed" && skill.installed) ||
|
||||
(filterStatus === "uninstalled" && !skill.installed);
|
||||
|
||||
return matchesSearch && matchesFilter;
|
||||
});
|
||||
const filteredSkills = filterSkillsByQueryAndStatus(
|
||||
skills,
|
||||
searchQuery,
|
||||
filterStatus,
|
||||
);
|
||||
const groupedSkillSections = groupSkillsBySourceKind(filteredSkills);
|
||||
const isFiltering = searchQuery.trim().length > 0 || filterStatus !== "all";
|
||||
const hasVisibleSkills = groupedSkillSections.some(
|
||||
(section) => section.skills.length > 0,
|
||||
);
|
||||
const skillSections = groupedSkillSections.filter(
|
||||
(section) =>
|
||||
section.skills.length > 0 ||
|
||||
loading ||
|
||||
remoteLoading ||
|
||||
(!isFiltering && section.key === "remote"),
|
||||
);
|
||||
|
||||
const installedCount = skills.filter((s) => s.installed).length;
|
||||
const uninstalledCount = skills.length - installedCount;
|
||||
@@ -179,9 +192,9 @@ export const SkillsPage = forwardRef<SkillsPageRef, SkillsPageProps>(
|
||||
{!hideHeader && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">Skills</h2>
|
||||
<h2 className="text-2xl font-bold">技能</h2>
|
||||
<p className="text-muted-foreground">
|
||||
浏览和安装 Claude Code Skills
|
||||
管理技能可用性和 API 能力注入。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -230,9 +243,9 @@ export const SkillsPage = forwardRef<SkillsPageRef, SkillsPageProps>(
|
||||
|
||||
<HelpTip title="什么是 Skills?" variant="green">
|
||||
<ul className="list-disc list-inside space-y-1 text-sm text-green-700 dark:text-green-400">
|
||||
<li>Skills 是 ProxyCast 的扩展功能包,提供特定领域的专业能力</li>
|
||||
<li>安装后 AI 助手可以自动发现并调用这些 Skills</li>
|
||||
<li>可通过"仓库管理"添加自定义 Skills 仓库</li>
|
||||
<li>Built-in Skills 为应用内置技能,默认可用且不可卸载</li>
|
||||
<li>Local Skills 直接从本地目录加载,不依赖远程仓库</li>
|
||||
<li>Remote Skills 使用缓存展示,点击"刷新"才同步远程仓库</li>
|
||||
</ul>
|
||||
</HelpTip>
|
||||
|
||||
@@ -248,7 +261,7 @@ export const SkillsPage = forwardRef<SkillsPageRef, SkillsPageProps>(
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索 skills..."
|
||||
placeholder="搜索技能..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full rounded-lg border bg-background pl-10 pr-4 py-2 text-sm"
|
||||
@@ -291,27 +304,68 @@ export const SkillsPage = forwardRef<SkillsPageRef, SkillsPageProps>(
|
||||
</div>
|
||||
|
||||
{/* Skills 列表 */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<RefreshCw className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : filteredSkills.length === 0 ? (
|
||||
{!hasVisibleSkills && !loading && isFiltering ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||
<p>没有找到 skills</p>
|
||||
<p>没有找到匹配的技能</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredSkills.map((skill) => (
|
||||
<SkillCard
|
||||
key={skill.key}
|
||||
skill={skill}
|
||||
onInstall={handleInstall}
|
||||
onUninstall={handleUninstall}
|
||||
onExecute={handleExecute}
|
||||
onViewContent={handleViewContent}
|
||||
installing={installingSkills.has(skill.directory)}
|
||||
/>
|
||||
))}
|
||||
<div className="space-y-4">
|
||||
{skillSections.map((section) => {
|
||||
const isSectionLoading =
|
||||
section.key === "remote" ? remoteLoading || loading : loading;
|
||||
return (
|
||||
<details
|
||||
key={section.key}
|
||||
open={section.key !== "builtin"}
|
||||
className="group rounded-lg border"
|
||||
>
|
||||
<summary className="flex cursor-pointer list-none items-center justify-between gap-4 px-4 py-3 [&::-webkit-details-marker]:hidden">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold tracking-[0.24em] text-muted-foreground">
|
||||
{section.title}
|
||||
</span>
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
|
||||
{section.skills.length}
|
||||
</span>
|
||||
{isSectionLoading && (
|
||||
<RefreshCw className="h-3 w-3 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground transition-transform group-open:rotate-90">
|
||||
▶
|
||||
</span>
|
||||
</summary>
|
||||
<div className="px-4 pb-4">
|
||||
<p className="mb-3 text-sm text-muted-foreground">
|
||||
{section.description}
|
||||
</p>
|
||||
{section.skills.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed bg-muted/20 px-4 py-5 text-sm text-muted-foreground">
|
||||
{isSectionLoading
|
||||
? "正在加载..."
|
||||
: section.key === "remote"
|
||||
? '暂无远程缓存,点击"刷新"同步已启用仓库。'
|
||||
: "暂无技能。"}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{section.skills.map((skill) => (
|
||||
<SkillCard
|
||||
key={skill.key}
|
||||
skill={skill}
|
||||
onInstall={handleInstall}
|
||||
onUninstall={handleUninstall}
|
||||
onExecute={handleExecute}
|
||||
onViewContent={handleViewContent}
|
||||
installing={installingSkills.has(skill.directory)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user