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:
coso
2026-03-12 00:34:23 +08:00
co-authored by Claude Opus 4.6
parent bfacf764b7
commit 2aa83ee7fc
268 changed files with 21727 additions and 6006 deletions
+5 -27
View File
@@ -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",
}}
+2 -1
View File
@@ -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"),
},
{
+8 -10
View File
@@ -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();
}
+27 -12
View File
@@ -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);
+109 -2
View File
@@ -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();
});
});
+226 -31
View File
@@ -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,
+2 -2
View File
@@ -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,
);
+22 -18
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 }> = {
+8 -140
View File
@@ -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";
+24 -24
View File
@@ -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 -15
View File
@@ -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,
+6 -9
View File
@@ -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 -2
View File
@@ -1,8 +1,7 @@
/**
* @file Hooks 导出入口
* @description 导出通用对话相关的 Hooks
* @description 遗留通用对话 Hooks 兼容导出
* @module components/chat/hooks
*/
export { useChat } from "./useChat";
export { useStreaming } from "./useStreaming";
+99 -105
View File
@@ -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,
+12 -57
View File
@@ -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 或统一对话链路。",
);
},
[],
);
+3 -4
View File
@@ -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";
+6
View File
@@ -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[] = [
{
+2 -3
View File
@@ -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 -1
View File
@@ -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";
+40 -21
View File
@@ -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) => {
+14 -5
View File
@@ -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
+12
View File
@@ -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 -4
View File
@@ -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";
+26 -53
View File
@@ -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]);
/**
* 重新生成消息
+22 -83
View File
@@ -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 -36
View File
@@ -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}>
+19 -17
View File
@@ -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 }}>
+20 -20
View File
@@ -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) => {
+5 -10
View File
@@ -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}`,
+2 -4
View File
@@ -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" />
+2 -2
View File
@@ -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" />
+27 -23
View File
@@ -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;
+142 -80
View File
@@ -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 =
+18 -9
View File
@@ -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` 展示仓库缓存中的远程技能,点击刷新时才同步最新仓库列表
- 搜索和安装状态筛选会先作用于全量技能,再按分组渲染结果
## 更新提醒
任何文件变更后,请更新此文档和相关的上级文档。
+37 -1
View File
@@ -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);
});
+70 -40
View File
@@ -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 {
+235
View File
@@ -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('点击"刷新"同步已启用仓库');
});
});
+91 -37
View File
@@ -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