feat: release v0.83.2 with full pending changes

This commit is contained in:
coso
2026-03-10 19:17:54 +08:00
parent e5d67a0d15
commit bfacf764b7
49 changed files with 2496 additions and 237 deletions
+5 -1
View File
@@ -564,7 +564,10 @@ function AppContent() {
flexDirection: "column",
}}
>
<TerminalWorkspace onNavigate={handleNavigate} />
<TerminalWorkspace
onNavigate={handleNavigate}
isActive={currentPage === "terminal"}
/>
</div>
<FullscreenWrapper $isActive={currentPage === "sysinfo"}>
@@ -637,6 +640,7 @@ function AppContent() {
<OpenClawPage
onNavigate={handleNavigate}
pageParams={pageParams as OpenClawPageParams}
isActive={currentPage === "openclaw"}
/>
</div>
@@ -57,6 +57,7 @@ afterEach(() => {
});
mounted.container.remove();
}
vi.useRealTimers();
vi.clearAllMocks();
});
@@ -75,6 +76,28 @@ function render(content: string, isStreaming = false): HTMLDivElement {
return container;
}
function renderHarness(content: string, isStreaming = false) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
const rerender = (nextContent: string, nextIsStreaming = isStreaming) => {
act(() => {
root.render(
<MarkdownRenderer
content={nextContent}
isStreaming={nextIsStreaming}
/>,
);
});
};
rerender(content, isStreaming);
mountedRoots.push({ container, root });
return { container, rerender };
}
describe("MarkdownRenderer", () => {
it("非流式时应保留 raw html 渲染能力", () => {
const content = [
@@ -105,4 +128,21 @@ describe("MarkdownRenderer", () => {
expect(container.querySelector(".rendered-html")).toBeNull();
expect(container.textContent).toContain("结尾文本");
});
it("流式结束后应立即恢复完整 raw html 渲染", () => {
vi.useFakeTimers();
const content = [
"A".repeat(2_200),
"",
'<div class="rendered-html">原始 HTML</div>',
"",
"结尾文本",
].join("\n");
const { container, rerender } = renderHarness(content, true);
expect(container.querySelector(".rendered-html")).toBeNull();
rerender(content, false);
expect(container.querySelector(".rendered-html")).not.toBeNull();
});
});
@@ -11,10 +11,13 @@ import { Copy, Check } from "lucide-react";
import { parseA2UIJson } from "@/components/content-creator/a2ui/parser";
import type { A2UIFormData } from "@/components/content-creator/a2ui/types";
import { CHAT_A2UI_TASK_CARD_PRESET } from "@/components/content-creator/a2ui/taskCardPresets";
import { useDebouncedValue } from "@/lib/artifact/hooks/useDebouncedValue";
import { ArtifactPlaceholder } from "./ArtifactPlaceholder";
import { A2UITaskCard, A2UITaskLoadingCard } from "./A2UITaskCard";
const STREAMING_LIGHT_RENDER_THRESHOLD = 2_000;
const STREAMING_LIGHT_RENDER_DEBOUNCE_MS = 48;
const STREAMING_STANDARD_RENDER_DEBOUNCE_MS = 24;
// Custom styles for markdown content to match Cherry Studio
const MarkdownContainer = styled.div`
@@ -249,6 +252,13 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = memo(
const [copied, setCopied] = React.useState<string | null>(null);
const useLightweightStreamingRender =
isStreaming && content.length >= STREAMING_LIGHT_RENDER_THRESHOLD;
const debouncedStreamingContent = useDebouncedValue(
content,
useLightweightStreamingRender
? STREAMING_LIGHT_RENDER_DEBOUNCE_MS
: STREAMING_STANDARD_RENDER_DEBOUNCE_MS,
);
const renderContent = isStreaming ? debouncedStreamingContent : content;
const remarkPlugins = React.useMemo(
() =>
@@ -272,12 +282,12 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = memo(
// 匹配 markdown 图片语法中的 base64 data URL
const base64ImageRegex =
/!\[([^\]]*)\]\((data:image\/[^;]+;base64,[^)]+)\)/g;
let result = content;
let result = renderContent;
const images: { alt: string; src: string; placeholder: string }[] = [];
let match;
let index = 0;
while ((match = base64ImageRegex.exec(content)) !== null) {
while ((match = base64ImageRegex.exec(renderContent)) !== null) {
const placeholder = `__BASE64_IMAGE_${index}__`;
images.push({
alt: match[1] || "Generated Image",
@@ -289,7 +299,7 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = memo(
}
return { text: result, images };
}, [content]);
}, [renderContent]);
// 渲染 base64 图片
const renderBase64Images = () => {
@@ -0,0 +1,150 @@
import React from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { StreamingRenderer } from "./StreamingRenderer";
import type { ContentPart } from "../types";
const parseAIResponseMock = vi.fn();
vi.mock("@/components/content-creator/a2ui/parser", () => ({
parseAIResponse: (...args: unknown[]) => parseAIResponseMock(...args),
}));
vi.mock("@/lib/artifact/hooks/useDebouncedValue", () => ({
useDebouncedValue: <T,>(value: T) => value,
}));
vi.mock("./MarkdownRenderer", () => ({
MarkdownRenderer: ({ content }: { content: string }) => (
<div data-testid="markdown-renderer">{content}</div>
),
}));
vi.mock("./A2UITaskCard", () => ({
A2UITaskCard: () => <div data-testid="a2ui-card" />,
A2UITaskLoadingCard: () => <div data-testid="a2ui-loading-card" />,
}));
vi.mock("./ToolCallDisplay", () => ({
ToolCallList: () => <div data-testid="tool-call-list" />,
ToolCallItem: () => <div data-testid="tool-call-item" />,
}));
vi.mock("./DecisionPanel", () => ({
DecisionPanel: () => <div data-testid="decision-panel" />,
}));
interface MountedHarness {
container: HTMLDivElement;
root: Root;
}
const mountedRoots: MountedHarness[] = [];
beforeEach(() => {
(
globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
).IS_REACT_ACT_ENVIRONMENT = true;
parseAIResponseMock.mockImplementation((content: string) => ({
parts: content.trim() ? [{ type: "text", content: content.trim() }] : [],
hasA2UI: false,
hasWriteFile: false,
hasPending: false,
}));
});
afterEach(() => {
while (mountedRoots.length > 0) {
const mounted = mountedRoots.pop();
if (!mounted) break;
act(() => {
mounted.root.unmount();
});
mounted.container.remove();
}
vi.clearAllMocks();
});
function renderHarness(props: {
content: string;
isStreaming?: boolean;
contentParts?: ContentPart[];
}) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
const rerender = (nextProps: typeof props) => {
act(() => {
root.render(<StreamingRenderer {...nextProps} />);
});
};
rerender(props);
mountedRoots.push({ container, root });
return { container, rerender };
}
describe("StreamingRenderer", () => {
it("纯文本内容应短路跳过结构化解析", () => {
renderHarness({
content: "这是普通文本输出,不包含结构化标签。",
isStreaming: true,
});
expect(parseAIResponseMock).not.toHaveBeenCalled();
});
it("交错内容重复渲染时应复用已缓存解析结果", () => {
const structuredText = '<write_file path="demo.md">hello</write_file>';
parseAIResponseMock.mockImplementation((content: string) => {
if (content === structuredText) {
return {
parts: [
{
type: "write_file",
content: "hello",
filePath: "demo.md",
},
],
hasA2UI: false,
hasWriteFile: true,
hasPending: false,
};
}
return {
parts: content.trim()
? [{ type: "text", content: content.trim() }]
: [],
hasA2UI: false,
hasWriteFile: false,
hasPending: false,
};
});
const contentParts: ContentPart[] = [
{ type: "text", text: structuredText },
{ type: "text", text: "普通文本" },
];
const { rerender } = renderHarness({
content: structuredText,
contentParts,
isStreaming: true,
});
expect(parseAIResponseMock).toHaveBeenCalledTimes(1);
rerender({
content: structuredText,
contentParts: [...contentParts],
isStreaming: true,
});
expect(parseAIResponseMock).toHaveBeenCalledTimes(1);
});
});
@@ -8,16 +8,25 @@
import React, { memo, useMemo, useState, useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
import { ChevronDown, Lightbulb, FileText } from "lucide-react";
import { useDebouncedValue } from "@/lib/artifact/hooks/useDebouncedValue";
import { MarkdownRenderer } from "./MarkdownRenderer";
import { A2UITaskCard, A2UITaskLoadingCard } from "./A2UITaskCard";
import { ToolCallList, ToolCallItem } from "./ToolCallDisplay";
import { DecisionPanel } from "./DecisionPanel";
import { parseAIResponse } from "@/components/content-creator/a2ui/parser";
import type { A2UIFormData } from "@/components/content-creator/a2ui/types";
import type {
A2UIFormData,
ParseResult,
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 { ContentPart, ActionRequired, ConfirmResponse } from "../types";
const STRUCTURED_CONTENT_HINT_RE = /<a2ui|```\s*a2ui|<write_file|<document/i;
const STRUCTURED_PARSE_CACHE_LIMIT = 64;
const STREAMING_STRUCTURED_PARSE_DEBOUNCE_MS = 48;
// ============ 思考内容组件 ============
interface ThinkingBlockProps {
@@ -65,6 +74,64 @@ const StreamingCursor: React.FC = () => (
/>
);
const EMPTY_PARSE_RESULT: ParseResult = {
parts: [],
hasA2UI: false,
hasWriteFile: false,
hasPending: false,
};
function hasStructuredContentHint(text: string): boolean {
return STRUCTURED_CONTENT_HINT_RE.test(text);
}
function createPlainTextParts(text: string): ParsedMessageContent[] {
const trimmed = text.trim();
return trimmed ? [{ type: "text", content: trimmed }] : [];
}
function parseStructuredContent(
text: string,
isStreaming: boolean,
): ParseResult {
if (!text.trim()) {
return EMPTY_PARSE_RESULT;
}
if (!hasStructuredContentHint(text)) {
return {
parts: createPlainTextParts(text),
hasA2UI: false,
hasWriteFile: false,
hasPending: false,
};
}
return parseAIResponse(text, isStreaming);
}
function getCachedStructuredParse(
cacheRef: React.MutableRefObject<Map<string, ParseResult>>,
text: string,
isStreaming: boolean,
): ParseResult {
const key = `${isStreaming ? "stream" : "static"}:${text}`;
const cached = cacheRef.current.get(key);
if (cached) {
return cached;
}
const parsed = parseStructuredContent(text, isStreaming);
if (cacheRef.current.size >= STRUCTURED_PARSE_CACHE_LIMIT) {
const oldestKey = cacheRef.current.keys().next().value;
if (oldestKey) {
cacheRef.current.delete(oldestKey);
}
}
cacheRef.current.set(key, parsed);
return parsed;
}
// ============ 流式文本组件(逐字符动画) ============
interface StreamingTextProps {
@@ -113,6 +180,7 @@ const StreamingText: React.FC<StreamingTextProps> = memo(
const displayIndexRef = useRef(0);
const animationRef = useRef<number | null>(null);
const prevTextRef = useRef("");
const parseCacheRef = useRef<Map<string, ParseResult>>(new Map());
useEffect(() => {
// 如果不是流式输出,直接显示完整文本
@@ -198,12 +266,27 @@ const StreamingText: React.FC<StreamingTextProps> = memo(
const shouldShowCursor =
isStreaming && showCursor && displayIndexRef.current < text.length;
const containsStructuredContent = useMemo(
() => hasStructuredContentHint(displayText),
[displayText],
);
const debouncedStructuredText = useDebouncedValue(
displayText,
isStreaming && containsStructuredContent
? STREAMING_STRUCTURED_PARSE_DEBOUNCE_MS
: 0,
);
const parsedSourceText =
isStreaming && containsStructuredContent
? debouncedStructuredText
: displayText;
// 使用 parseAIResponse 解析内容,以正确处理 a2ui 代码块
// 这比依赖 MarkdownRenderer 的 pre 组件更可靠
const parsedContent = useMemo(
() => parseAIResponse(displayText, isStreaming),
[displayText, isStreaming],
() =>
getCachedStructuredParse(parseCacheRef, parsedSourceText, isStreaming),
[parsedSourceText, isStreaming],
);
// 渲染解析后的内容
@@ -390,16 +473,38 @@ export const StreamingRenderer: React.FC<StreamingRendererProps> = memo(
}) => {
// 判断是否使用交错显示模式
const useInterleavedMode = contentParts && contentParts.length > 0;
const parseCacheRef = useRef<Map<string, ParseResult>>(new Map());
// 解析思考内容(仅在非交错模式下使用)
const { visibleText, thinkingText } = useMemo(
() => parseThinkingContent(content),
[content],
);
const containsStructuredContent = useMemo(
() => hasStructuredContentHint(visibleText),
[visibleText],
);
const debouncedVisibleText = useDebouncedValue(
visibleText,
isStreaming && containsStructuredContent
? STREAMING_STRUCTURED_PARSE_DEBOUNCE_MS
: 0,
);
const parsedVisibleText =
isStreaming && containsStructuredContent
? debouncedVisibleText
: visibleText;
// 解析 A2UI 和 write_file 内容
const parsedContent = useMemo(() => {
const result = parseAIResponse(visibleText, isStreaming);
if (useInterleavedMode) {
return EMPTY_PARSE_RESULT;
}
const result = getCachedStructuredParse(
parseCacheRef,
parsedVisibleText,
isStreaming,
);
// 添加调试日志
if (result.hasWriteFile) {
console.log(
@@ -410,7 +515,7 @@ export const StreamingRenderer: React.FC<StreamingRendererProps> = memo(
);
}
return result;
}, [visibleText, isStreaming]);
}, [parsedVisibleText, isStreaming, useInterleavedMode]);
// 处理文件写入 - 使用 ref 来追踪已处理的内容
const processedWriteFilesRef = useRef<Set<string>>(new Set());
@@ -475,7 +580,11 @@ export const StreamingRenderer: React.FC<StreamingRendererProps> = memo(
if (!partText) return null;
// 解析 write_file 标签
const partParsed = parseAIResponse(partText, isStreaming);
const partParsed = getCachedStructuredParse(
parseCacheRef,
partText,
isStreaming,
);
const isLastPart = index === contentParts.length - 1;
// 添加调试日志
@@ -44,6 +44,7 @@ import {
isValidSessionId,
resolveRestorableSessionId,
} from "../utils/sessionRecovery";
import { createStreamDiagnosticsReporter } from "../utils/streamDiagnostics";
/** 话题(会话)信息 */
export interface Topic {
@@ -269,6 +270,9 @@ interface UseAgentChatOptions {
export function useAgentChat(options: UseAgentChatOptions) {
const { systemPrompt, onWriteFile, workspaceId } = options;
const streamDiagnosticsRef = useRef(
createStreamDiagnosticsReporter("useAgentChat"),
);
const getRequiredWorkspaceId = (): string => {
const resolvedWorkspaceId = workspaceId?.trim();
@@ -686,7 +690,12 @@ export function useAgentChat(options: UseAgentChatOptions) {
allProxycastSkills
.filter((skill) => skill.installed)
.forEach((skill) => {
const skillName = (skill.directory || skill.key || skill.name || "").trim();
const skillName = (
skill.directory ||
skill.key ||
skill.name ||
""
).trim();
if (!skillName) return;
detailsByName.set(skillName, {
name: skillName,
@@ -984,13 +993,21 @@ export function useAgentChat(options: UseAgentChatOptions) {
console.log(
`[AgentChat] 设置事件监听器: ${eventName}, sessionId: ${activeSessionId}`,
);
streamDiagnosticsRef.current.start({
sessionId: activeSessionId,
eventName,
assistantMessageId: assistantMsgId,
source: "sendMessage",
});
unlisten = await safeListen<StreamEvent>(eventName, (event) => {
console.log("[AgentChat] 收到事件:", eventName, event.payload);
const data = parseStreamEvent(event.payload);
if (!data) {
console.warn("[AgentChat] 解析事件失败:", event.payload);
streamDiagnosticsRef.current.recordInvalidEvent(event.payload);
return;
}
streamDiagnosticsRef.current.record(data);
console.log("[AgentChat] 解析后数据:", data);
switch (data.type) {
@@ -1085,6 +1102,7 @@ export function useAgentChat(options: UseAgentChatOptions) {
unlistenRef.current = null;
currentAssistantMsgIdRef.current = null;
currentStreamingSessionIdRef.current = null;
streamDiagnosticsRef.current.markDone();
if (unlisten) {
unlisten();
unlisten = null;
@@ -1133,6 +1151,7 @@ export function useAgentChat(options: UseAgentChatOptions) {
unlistenRef.current = null;
currentAssistantMsgIdRef.current = null;
currentStreamingSessionIdRef.current = null;
streamDiagnosticsRef.current.markError(data.message);
if (unlisten) {
unlisten();
unlisten = null;
@@ -1354,6 +1373,7 @@ export function useAgentChat(options: UseAgentChatOptions) {
);
} catch (error) {
console.error("[AgentChat] Send failed:", error);
streamDiagnosticsRef.current.markError(String(error));
toast.error(`发送失败: ${error}`, {
id: `send-error-${Date.now()}`,
duration: 8000,
@@ -1505,7 +1525,8 @@ export function useAgentChat(options: UseAgentChatOptions) {
})
// 过滤仅包含工具协议的空白 assistant 消息,避免历史里出现无意义气泡
.filter(
(msg) => !(msg.role === "assistant" && msg.content.trim().length === 0),
(msg) =>
!(msg.role === "assistant" && msg.content.trim().length === 0),
);
if (restoreRequestVersion !== sessionResetVersionRef.current) {
@@ -1773,6 +1794,12 @@ export function useAgentChat(options: UseAgentChatOptions) {
console.log(
`[AgentChat] triggerAIGuide 设置事件监听器: ${eventName}, sessionId: ${activeSessionId}`,
);
streamDiagnosticsRef.current.start({
sessionId: activeSessionId,
eventName,
assistantMessageId: assistantMsgId,
source: "triggerAIGuide",
});
unlisten = await safeListen<StreamEvent>(eventName, (event) => {
console.log(
"[AgentChat] triggerAIGuide 收到事件:",
@@ -1785,8 +1812,10 @@ export function useAgentChat(options: UseAgentChatOptions) {
"[AgentChat] triggerAIGuide 解析事件失败:",
event.payload,
);
streamDiagnosticsRef.current.recordInvalidEvent(event.payload);
return;
}
streamDiagnosticsRef.current.record(data);
switch (data.type) {
case "text_delta":
@@ -1858,6 +1887,7 @@ export function useAgentChat(options: UseAgentChatOptions) {
unlistenRef.current = null;
currentAssistantMsgIdRef.current = null;
currentStreamingSessionIdRef.current = null;
streamDiagnosticsRef.current.markDone();
if (unlisten) {
unlisten();
unlisten = null;
@@ -1888,6 +1918,7 @@ export function useAgentChat(options: UseAgentChatOptions) {
unlistenRef.current = null;
currentAssistantMsgIdRef.current = null;
currentStreamingSessionIdRef.current = null;
streamDiagnosticsRef.current.markError(data.message);
if (unlisten) {
unlisten();
unlisten = null;
@@ -2051,6 +2082,7 @@ export function useAgentChat(options: UseAgentChatOptions) {
);
} catch (error) {
console.error("[AgentChat] triggerAIGuide failed:", error);
streamDiagnosticsRef.current.markError(String(error));
toast.error(`启动引导失败: ${error}`, {
id: `guide-error-${Date.now()}`,
duration: 8000,
+38
View File
@@ -1972,6 +1972,44 @@ describe("AgentChatPage 自动引导", () => {
"exec-map-1",
);
});
it("主题工作台不应把聊天命令 source_ref 当成 Skill 详情去加载", async () => {
mockIsContentCreationTheme.mockReturnValue(true);
mockUseThemeContextWorkspace.mockReturnValue(
createMockThemeContextWorkspaceState({
enabled: true,
}),
);
mockExecutionRunGetThemeWorkbenchState.mockResolvedValue({
run_state: "auto_running",
current_gate_key: "write_mode",
queue_items: [
{
run_id: "run-chat-command",
title: "执行主题工作台编排",
gate_key: "write_mode",
status: "running",
source: "chat",
source_ref: "aster_agent_chat_stream",
started_at: "2026-03-06T04:00:00.000Z",
},
],
latest_terminal: null,
updated_at: "2026-03-06T04:00:02.000Z",
});
renderPage({
projectId: "project-theme-chat-command",
contentId: "content-theme-chat-command",
theme: "social-media",
lockTheme: true,
});
await flushEffects(12);
expect(mockSkillExecutionGetDetail).not.toHaveBeenCalledWith(
"aster_agent_chat_stream",
);
});
});
describe("AgentChatPage 视频主题工作台", () => {
+45 -7
View File
@@ -108,17 +108,19 @@ import type { Page, PageParams } from "@/types/page";
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 { LatestRunStatusBadge } from "@/components/execution/LatestRunStatusBadge";
import {
executionRunGet,
executionRunGetThemeWorkbenchState,
type AgentRun,
type ThemeWorkbenchRunTodoItem,
type ThemeWorkbenchRunTerminalItem,
type ThemeWorkbenchRunState as BackendThemeWorkbenchRunState,
} from "@/lib/api/executionRun";
import { setActiveContentTarget } from "@/lib/activeContentTarget";
import { recordWorkspaceRepair } from "@/lib/workspaceHealthTelemetry";
import { useConfiguredProviders } from "@/hooks/useConfiguredProviders";
import { resolveProviderModelCompatibility } from "./utils/providerModelCompatibility";
import { useProviderModels } from "@/hooks/useProviderModels";
import {
isReasoningModel,
@@ -812,7 +814,7 @@ function resolveThemeWorkbenchQueueItemTitle(
item: ThemeWorkbenchRunTodoItem,
skillDetailMap: Record<string, SkillDetailInfo | null>,
): string {
const sourceRef = item.source_ref?.trim();
const sourceRef = resolveThemeWorkbenchSkillSourceRef(item);
if (sourceRef) {
return resolveThemeWorkbenchPrimaryTaskTitle(
sourceRef,
@@ -823,6 +825,19 @@ function resolveThemeWorkbenchQueueItemTitle(
}
const THEME_WORKBENCH_ACTIVE_RUN_MAX_AGE_MS = 45 * 1000;
function resolveThemeWorkbenchSkillSourceRef(
item:
| ThemeWorkbenchRunTodoItem
| ThemeWorkbenchRunTerminalItem
| { source?: string | null; source_ref?: string | null },
): string | null {
if ((item.source || "").trim() !== "skill") {
return null;
}
const sourceRef = item.source_ref?.trim();
return sourceRef || null;
}
interface PersistedThemeWorkbenchDocument {
versions: DocumentVersion[];
currentVersionId: string;
@@ -1361,7 +1376,7 @@ function buildThemeWorkbenchWorkflowSteps(
if (queueItems.length > 0) {
if (queueItems.length === 1) {
const item = queueItems[0];
const sourceRef = item.source_ref?.trim();
const sourceRef = resolveThemeWorkbenchSkillSourceRef(item);
const workflowSteps = sourceRef
? skillDetailMap[sourceRef]?.workflow_steps || []
: [];
@@ -2582,13 +2597,14 @@ export function AgentChatPage({
}
});
(themeWorkbenchBackendRunState?.queue_items || []).forEach((item) => {
const sourceRef = item.source_ref?.trim();
const sourceRef = resolveThemeWorkbenchSkillSourceRef(item);
if (sourceRef) {
requiredSkillNames.add(sourceRef);
}
});
const terminalSourceRef =
themeWorkbenchBackendRunState?.latest_terminal?.source_ref?.trim();
const terminalSourceRef = resolveThemeWorkbenchSkillSourceRef(
themeWorkbenchBackendRunState?.latest_terminal || {},
);
if (terminalSourceRef) {
requiredSkillNames.add(terminalSourceRef);
}
@@ -2597,7 +2613,7 @@ export function AgentChatPage({
}, [
isThemeWorkbench,
messages,
themeWorkbenchBackendRunState?.latest_terminal?.source_ref,
themeWorkbenchBackendRunState?.latest_terminal,
themeWorkbenchBackendRunState?.queue_items,
]);
@@ -3625,6 +3641,21 @@ export function AgentChatPage({
}
}
const compatibilityResult = resolveProviderModelCompatibility({
providerType,
configuredProviderType: selectedProvider?.type,
model: effectiveModel,
});
if (compatibilityResult.changed) {
effectiveModel = compatibilityResult.model;
if (model !== compatibilityResult.model) {
setModel(compatibilityResult.model);
}
if (compatibilityResult.reason) {
toast.warning(compatibilityResult.reason);
}
}
if (autoContinuePayload) {
await sendMessage(
text,
@@ -3672,6 +3703,7 @@ export function AgentChatPage({
providerModels,
providerType,
runtimeStyleMessagePrompt,
selectedProvider?.type,
sendMessage,
sessionId,
setModel,
@@ -4026,6 +4058,11 @@ export function AgentChatPage({
const toastId = initialSessionName
? "openclaw-agent-handoff"
: "agent-new-chat";
const canCreateFreshSession = Boolean(projectId?.trim());
if (!canCreateFreshSession) {
return;
}
void (async () => {
const newSessionId = await createFreshSession(initialSessionName);
@@ -4048,6 +4085,7 @@ export function AgentChatPage({
externalProjectId,
initialTheme,
initialCreationMode,
projectId,
]);
const handleBackHome = useCallback(() => {
@@ -0,0 +1,78 @@
import { describe, expect, it } from "vitest";
import {
filterProviderModelsByCompatibility,
getProviderModelCompatibilityIssue,
resolveProviderModelCompatibility,
} from "./providerModelCompatibility";
describe("resolveProviderModelCompatibility", () => {
it("应将 codex provider 的 gpt-5.3-codex 自动降级到 gpt-5.2-codex", () => {
const result = resolveProviderModelCompatibility({
providerType: "custom-123",
configuredProviderType: "codex",
model: "gpt-5.3-codex",
});
expect(result.changed).toBe(true);
expect(result.model).toBe("gpt-5.2-codex");
expect(result.reason).toContain("gpt-5.2-codex");
});
it("非 codex provider 不应调整模型", () => {
const result = resolveProviderModelCompatibility({
providerType: "anthropic",
configuredProviderType: "anthropic",
model: "gpt-5.3-codex",
});
expect(result.changed).toBe(false);
expect(result.model).toBe("gpt-5.3-codex");
});
it("codex provider 的其他模型不应调整", () => {
const result = resolveProviderModelCompatibility({
providerType: "codex",
configuredProviderType: "codex",
model: "gpt-5.2-codex",
});
expect(result.changed).toBe(false);
expect(result.model).toBe("gpt-5.2-codex");
});
it("应返回不兼容模型的 UI 提示信息", () => {
const issue = getProviderModelCompatibilityIssue({
providerType: "custom-123",
configuredProviderType: "codex",
model: "gpt-5.3-codex",
});
expect(issue).toEqual({
code: "codex_chatgpt_account_unsupported",
message: "当前 Codex 登录态不支持该模型",
suggestedModel: "gpt-5.2-codex",
});
});
it("应过滤不兼容模型并保留兼容模型", () => {
const result = filterProviderModelsByCompatibility(
{
providerType: "custom-123",
configuredProviderType: "codex",
},
["gpt-5.3-codex", "gpt-5.2-codex", "gpt-5.2"],
);
expect(result.compatibleModels).toEqual(["gpt-5.2-codex", "gpt-5.2"]);
expect(result.incompatibleModels).toEqual([
{
model: "gpt-5.3-codex",
issue: {
code: "codex_chatgpt_account_unsupported",
message: "当前 Codex 登录态不支持该模型",
suggestedModel: "gpt-5.2-codex",
},
},
]);
});
});
@@ -0,0 +1,105 @@
export interface ProviderModelCompatibilityInput {
providerType: string;
configuredProviderType?: string | null;
model: string;
}
export interface ProviderModelCompatibilityResult {
model: string;
changed: boolean;
reason?: string;
}
export interface ProviderModelCompatibilityIssue {
code: "codex_chatgpt_account_unsupported";
message: string;
suggestedModel?: string;
}
function normalize(value?: string | null): string {
return (value || "").trim().toLowerCase();
}
export function getProviderModelCompatibilityIssue({
providerType,
configuredProviderType,
model,
}: ProviderModelCompatibilityInput): ProviderModelCompatibilityIssue | null {
const normalizedProviderType = normalize(providerType);
const normalizedConfiguredType = normalize(configuredProviderType);
const normalizedModel = normalize(model);
const isCodexProvider =
normalizedProviderType === "codex" || normalizedConfiguredType === "codex";
if (isCodexProvider && normalizedModel === "gpt-5.3-codex") {
return {
code: "codex_chatgpt_account_unsupported",
message: "当前 Codex 登录态不支持该模型",
suggestedModel: "gpt-5.2-codex",
};
}
return null;
}
export function resolveProviderModelCompatibility({
providerType,
configuredProviderType,
model,
}: ProviderModelCompatibilityInput): ProviderModelCompatibilityResult {
const issue = getProviderModelCompatibilityIssue({
providerType,
configuredProviderType,
model,
});
if (issue?.suggestedModel) {
return {
model: issue.suggestedModel,
changed: true,
reason: `当前 Codex 登录态与 ${model} 兼容性不足,已自动切换到 ${issue.suggestedModel}。`,
};
}
return {
model,
changed: false,
};
}
export function filterProviderModelsByCompatibility(
input: Pick<
ProviderModelCompatibilityInput,
"providerType" | "configuredProviderType"
>,
models: string[],
): {
compatibleModels: string[];
incompatibleModels: Array<{
model: string;
issue: ProviderModelCompatibilityIssue;
}>;
} {
const compatibleModels: string[] = [];
const incompatibleModels: Array<{
model: string;
issue: ProviderModelCompatibilityIssue;
}> = [];
models.forEach((model) => {
const issue = getProviderModelCompatibilityIssue({
...input,
model,
});
if (issue) {
incompatibleModels.push({ model, issue });
} else {
compatibleModels.push(model);
}
});
return {
compatibleModels,
incompatibleModels,
};
}
@@ -0,0 +1,92 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createStreamDiagnosticsReporter } from "./streamDiagnostics";
const updateCrashContextMock = vi.fn();
vi.mock("@/lib/crashReporting", () => ({
updateCrashContext: (context: unknown) => updateCrashContextMock(context),
}));
describe("streamDiagnostics", () => {
beforeEach(() => {
updateCrashContextMock.mockClear();
});
it("开始流后应写入基础上下文", () => {
const reporter = createStreamDiagnosticsReporter("useAgentChat");
reporter.start({
sessionId: "session-1",
eventName: "agent_stream_1",
assistantMessageId: "assistant-1",
source: "sendMessage",
});
expect(updateCrashContextMock).toHaveBeenCalledTimes(1);
expect(updateCrashContextMock.mock.calls[0]?.[0]).toMatchObject({
agent_stream_diag: expect.objectContaining({
component: "useAgentChat",
sessionId: "session-1",
eventName: "agent_stream_1",
assistantMessageId: "assistant-1",
state: "streaming",
}),
});
});
it("遇到关键事件应立即刷新上下文", () => {
const reporter = createStreamDiagnosticsReporter("useAgentChat");
reporter.start({
sessionId: "session-1",
eventName: "agent_stream_1",
assistantMessageId: "assistant-1",
source: "sendMessage",
});
reporter.record({
type: "tool_start",
tool_id: "tool-1",
tool_name: "WebSearch",
});
reporter.record({
type: "tool_end",
tool_id: "tool-1",
result: {
success: true,
output: "ok",
},
});
reporter.record({
type: "final_done",
});
const lastCall = updateCrashContextMock.mock.calls.at(-1)?.[0] as {
agent_stream_diag: Record<string, unknown>;
};
expect(lastCall.agent_stream_diag).toMatchObject({
state: "done",
toolStartCount: 1,
toolEndCount: 1,
finalDoneCount: 1,
lastToolId: "tool-1",
});
});
it("解析失败时应记录 invalid 事件", () => {
const reporter = createStreamDiagnosticsReporter("useAgentChat");
reporter.start({
sessionId: "session-1",
eventName: "agent_stream_1",
assistantMessageId: "assistant-1",
source: "sendMessage",
});
reporter.recordInvalidEvent({ foo: "bar" });
const snapshot = reporter.getSnapshot();
expect(snapshot).toMatchObject({
invalidEventCount: 1,
lastEventType: "invalid",
state: "streaming",
});
});
});
@@ -0,0 +1,218 @@
import type { StreamEvent } from "@/lib/api/agent";
import { updateCrashContext } from "@/lib/crashReporting";
const EVENT_PUBLISH_INTERVAL = 20;
const PREVIEW_MAX_CHARS = 240;
export interface StreamDiagnosticsStartPayload {
sessionId: string;
eventName: string;
assistantMessageId: string;
source: string;
}
export interface StreamDiagnosticsSnapshot {
source: string;
sessionId: string;
eventName: string;
assistantMessageId: string;
state: "streaming" | "done" | "error";
startedAt: string;
lastEventAt: string;
totalEvents: number;
invalidEventCount: number;
textDeltaCount: number;
thinkingDeltaCount: number;
toolStartCount: number;
toolEndCount: number;
actionRequiredCount: number;
contextTraceCount: number;
warningCount: number;
doneCount: number;
finalDoneCount: number;
errorCount: number;
maxTextDeltaChars: number;
maxToolOutputChars: number;
maxContextTraceSteps: number;
lastEventType?: string;
lastToolName?: string;
lastToolId?: string;
lastWarningCode?: string;
lastErrorMessage?: string;
}
function nowIso(): string {
return new Date().toISOString();
}
function truncatePreview(value: string | undefined): string | undefined {
if (!value) return value;
if (value.length <= PREVIEW_MAX_CHARS) {
return value;
}
return `${value.slice(0, PREVIEW_MAX_CHARS)}...`;
}
export function createStreamDiagnosticsReporter(componentName: string) {
let snapshot: StreamDiagnosticsSnapshot | null = null;
let lastPublishedTotalEvents = 0;
const publish = (force = false) => {
if (!snapshot) return;
if (
!force &&
snapshot.totalEvents - lastPublishedTotalEvents < EVENT_PUBLISH_INTERVAL
) {
return;
}
lastPublishedTotalEvents = snapshot.totalEvents;
updateCrashContext({
agent_stream_diag: {
component: componentName,
...snapshot,
},
});
};
return {
start(payload: StreamDiagnosticsStartPayload) {
const startedAt = nowIso();
snapshot = {
source: payload.source,
sessionId: payload.sessionId,
eventName: payload.eventName,
assistantMessageId: payload.assistantMessageId,
state: "streaming",
startedAt,
lastEventAt: startedAt,
totalEvents: 0,
invalidEventCount: 0,
textDeltaCount: 0,
thinkingDeltaCount: 0,
toolStartCount: 0,
toolEndCount: 0,
actionRequiredCount: 0,
contextTraceCount: 0,
warningCount: 0,
doneCount: 0,
finalDoneCount: 0,
errorCount: 0,
maxTextDeltaChars: 0,
maxToolOutputChars: 0,
maxContextTraceSteps: 0,
};
lastPublishedTotalEvents = 0;
publish(true);
},
recordInvalidEvent(payload: unknown) {
if (!snapshot) return;
snapshot.totalEvents += 1;
snapshot.invalidEventCount += 1;
snapshot.lastEventAt = nowIso();
snapshot.lastEventType = "invalid";
try {
snapshot.lastErrorMessage = truncatePreview(JSON.stringify(payload));
} catch {
snapshot.lastErrorMessage = "[unserializable_payload]";
}
publish(true);
},
record(event: StreamEvent) {
if (!snapshot) return;
snapshot.totalEvents += 1;
snapshot.lastEventAt = nowIso();
snapshot.lastEventType = event.type;
switch (event.type) {
case "text_delta": {
snapshot.textDeltaCount += 1;
snapshot.maxTextDeltaChars = Math.max(
snapshot.maxTextDeltaChars,
event.text.length,
);
break;
}
case "thinking_delta": {
snapshot.thinkingDeltaCount += 1;
break;
}
case "tool_start": {
snapshot.toolStartCount += 1;
snapshot.lastToolId = event.tool_id;
snapshot.lastToolName = truncatePreview(event.tool_name);
break;
}
case "tool_end": {
snapshot.toolEndCount += 1;
snapshot.lastToolId = event.tool_id;
snapshot.maxToolOutputChars = Math.max(
snapshot.maxToolOutputChars,
event.result.output.length,
);
break;
}
case "action_required": {
snapshot.actionRequiredCount += 1;
break;
}
case "context_trace": {
snapshot.contextTraceCount += 1;
snapshot.maxContextTraceSteps = Math.max(
snapshot.maxContextTraceSteps,
event.steps.length,
);
break;
}
case "warning": {
snapshot.warningCount += 1;
snapshot.lastWarningCode = event.code;
snapshot.lastErrorMessage = truncatePreview(event.message);
publish(true);
return;
}
case "done": {
snapshot.doneCount += 1;
publish(true);
return;
}
case "final_done": {
snapshot.finalDoneCount += 1;
snapshot.state = "done";
publish(true);
return;
}
case "error": {
snapshot.errorCount += 1;
snapshot.state = "error";
snapshot.lastErrorMessage = truncatePreview(event.message);
publish(true);
return;
}
}
publish(false);
},
markError(message: string) {
if (!snapshot) return;
snapshot.state = "error";
snapshot.lastEventAt = nowIso();
snapshot.lastEventType = "error";
snapshot.lastErrorMessage = truncatePreview(message);
publish(true);
},
markDone() {
if (!snapshot) return;
snapshot.state = "done";
snapshot.lastEventAt = nowIso();
publish(true);
},
getSnapshot(): StreamDiagnosticsSnapshot | null {
return snapshot ? { ...snapshot } : null;
},
};
}
@@ -17,6 +17,7 @@ import {
import { useProviderModels } from "@/hooks/useProviderModels";
import { useGeneralChatStore } from "../store/useGeneralChatStore";
import type { ProviderConfig } from "../types";
import { filterProviderModelsByCompatibility } from "@/components/agent/chat/utils/providerModelCompatibility";
// ============================================================================
// 类型定义
@@ -115,6 +116,19 @@ export function useProvider(): UseProviderResult {
error: modelsError,
} = useProviderModels(selectedProvider);
const compatibleModelIds = useMemo(() => {
if (!selectedProvider) {
return availableModelIds;
}
return filterProviderModelsByCompatibility(
{
providerType: selectedProvider.key,
configuredProviderType: selectedProvider.type,
},
availableModelIds,
).compatibleModels;
}, [availableModelIds, selectedProvider]);
// 计算加载状态
// 注意:只有在加载 Provider 列表时才显示加载状态
// 模型加载是次要的,不应该阻塞整个界面
@@ -178,23 +192,23 @@ export function useProvider(): UseProviderResult {
// 如果没有选中的模型,且有可用的模型,自动选择第一个
if (
!selectedModelId &&
availableModelIds.length > 0 &&
compatibleModelIds.length > 0 &&
!modelInitializedRef.current
) {
modelInitializedRef.current = true;
setSelectedModel(availableModelIds[0]);
setSelectedModel(compatibleModelIds[0]);
return;
}
// 如果选中的模型不在列表中,重新选择
if (selectedModelId && !availableModelIds.includes(selectedModelId)) {
if (availableModelIds.length > 0) {
setSelectedModel(availableModelIds[0]);
if (selectedModelId && !compatibleModelIds.includes(selectedModelId)) {
if (compatibleModelIds.length > 0) {
setSelectedModel(compatibleModelIds[0]);
} else {
setSelectedModel(null);
}
}
}, [modelsLoading, availableModelIds, selectedModelId, setSelectedModel]);
}, [modelsLoading, compatibleModelIds, selectedModelId, setSelectedModel]);
// ========== 操作方法 ==========
@@ -218,11 +232,11 @@ export function useProvider(): UseProviderResult {
*/
const selectModel = useCallback(
(modelId: string) => {
if (availableModelIds.includes(modelId)) {
if (compatibleModelIds.includes(modelId)) {
setSelectedModel(modelId);
}
},
[availableModelIds, setSelectedModel],
[compatibleModelIds, setSelectedModel],
);
/**
@@ -278,7 +292,7 @@ export function useProvider(): UseProviderResult {
providers,
selectedProvider,
selectedModelId,
availableModelIds,
availableModelIds: compatibleModelIds,
isLoading,
error,
hasAvailableProvider,
@@ -0,0 +1,123 @@
import React from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const {
mockUseConfiguredProviders,
mockUseProviderModels,
mockFilterModelsByTheme,
} = vi.hoisted(() => ({
mockUseConfiguredProviders: vi.fn(),
mockUseProviderModels: vi.fn(),
mockFilterModelsByTheme: vi.fn(),
}));
vi.mock("@/hooks/useConfiguredProviders", () => ({
useConfiguredProviders: () => mockUseConfiguredProviders(),
}));
vi.mock("@/hooks/useProviderModels", () => ({
useProviderModels: (...args: unknown[]) => mockUseProviderModels(...args),
}));
vi.mock("@/components/agent/chat/utils/modelThemePolicy", () => ({
filterModelsByTheme: (...args: unknown[]) => mockFilterModelsByTheme(...args),
}));
import { ModelSelector } from "./ModelSelector";
interface MountedRoot {
root: Root;
container: HTMLDivElement;
}
const mountedRoots: MountedRoot[] = [];
function renderModelSelector(
props: Partial<React.ComponentProps<typeof ModelSelector>> = {},
) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
const mergedProps: React.ComponentProps<typeof ModelSelector> = {
providerType: "custom-codex",
setProviderType: vi.fn(),
model: "gpt-5.3-codex",
setModel: vi.fn(),
activeTheme: "general",
...props,
};
act(() => {
root.render(<ModelSelector {...mergedProps} />);
});
mountedRoots.push({ root, container });
return { container, props: mergedProps };
}
beforeEach(() => {
(
globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
).IS_REACT_ACT_ENVIRONMENT = true;
vi.clearAllMocks();
mockUseConfiguredProviders.mockReturnValue({
providers: [
{
key: "custom-codex",
label: "Codex Custom",
registryId: "custom-codex",
fallbackRegistryId: "codex",
type: "codex",
},
],
loading: false,
});
mockUseProviderModels.mockReturnValue({
modelIds: ["gpt-5.3-codex", "gpt-5.2-codex"],
models: [
{ id: "gpt-5.3-codex" },
{ id: "gpt-5.2-codex" },
],
loading: false,
error: null,
});
mockFilterModelsByTheme.mockImplementation((_theme, models) => ({
models,
usedFallback: false,
filteredOutCount: 0,
policyName: "none",
}));
});
afterEach(() => {
while (mountedRoots.length > 0) {
const mounted = mountedRoots.pop();
if (!mounted) break;
act(() => {
mounted.root.unmount();
});
mounted.container.remove();
}
});
describe("ModelSelector", () => {
it("应在 codex 不兼容模型被选中时自动回退到兼容模型", () => {
const setModel = vi.fn();
renderModelSelector({
model: "gpt-5.3-codex",
setModel,
});
expect(setModel).toHaveBeenCalledWith("gpt-5.2-codex");
});
});
+58 -11
View File
@@ -19,6 +19,7 @@ import { ProviderIcon } from "@/icons/providers";
import { useConfiguredProviders } from "@/hooks/useConfiguredProviders";
import { useProviderModels } from "@/hooks/useProviderModels";
import { filterModelsByTheme } from "@/components/agent/chat/utils/modelThemePolicy";
import { getProviderModelCompatibilityIssue } from "@/components/agent/chat/utils/providerModelCompatibility";
const THEME_LABEL_MAP: Record<string, string> = {
general: "通用对话",
@@ -80,9 +81,34 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
return filterModelsByTheme(activeTheme, providerModels);
}, [activeTheme, providerModels]);
const currentModels = useMemo(() => {
return filteredResult.models.map((item) => item.id);
}, [filteredResult.models]);
const modelOptions = useMemo(
() =>
filteredResult.models.map((item) => {
const compatibilityIssue = getProviderModelCompatibilityIssue({
providerType,
configuredProviderType: selectedProvider?.type,
model: item.id,
});
return {
id: item.id,
compatibilityIssue,
};
}),
[filteredResult.models, providerType, selectedProvider?.type],
);
const currentModels = useMemo(
() =>
modelOptions
.filter((item) => !item.compatibilityIssue)
.map((item) => item.id),
[modelOptions],
);
const incompatibleModelCount = useMemo(
() => modelOptions.filter((item) => item.compatibilityIssue).length,
[modelOptions],
);
useEffect(() => {
if (hasInitialized.current) return;
@@ -309,27 +335,39 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
{activeThemeLabel} 未匹配到主题模型,已展示全部模型
</div>
)}
{incompatibleModelCount > 0 && (
<div className="text-[11px] text-amber-600 px-2 pb-1">
已隐藏 {incompatibleModelCount} 个当前登录态不兼容的模型
</div>
)}
<ScrollArea className="flex-1">
<div className="space-y-1 p-1">
{currentModels.length === 0 ? (
{modelOptions.length === 0 ? (
<div className="text-xs text-muted-foreground p-2">
暂无可用模型
</div>
) : (
currentModels.map((currentModelItem) => (
modelOptions.map((currentModelItem) => (
<button
key={currentModelItem}
key={currentModelItem.id}
disabled={Boolean(currentModelItem.compatibilityIssue)}
onClick={() => {
setModel(currentModelItem);
if (currentModelItem.compatibilityIssue) {
return;
}
setModel(currentModelItem.id);
setOpen(false);
}}
className={cn(
"flex items-center justify-between w-full px-2 py-1.5 text-sm rounded-md transition-colors text-left group",
model === currentModelItem
currentModelItem.compatibilityIssue
? "cursor-not-allowed opacity-60 text-muted-foreground"
: model === currentModelItem.id
? "bg-accent text-accent-foreground"
: "hover:bg-muted text-muted-foreground hover:text-foreground",
)}
title={currentModelItem.compatibilityIssue?.message}
>
<span className="flex items-center gap-2 min-w-0">
{selectedProvider && (
@@ -339,11 +377,20 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
size={15}
/>
)}
<span className="truncate">{currentModelItem}</span>
<span className="min-w-0 flex flex-col">
<span className="truncate">{currentModelItem.id}</span>
{currentModelItem.compatibilityIssue ? (
<span className="truncate text-[11px] text-amber-600">
{currentModelItem.compatibilityIssue.message}
</span>
) : null}
</span>
</span>
{model === currentModelItem && (
{currentModelItem.compatibilityIssue ? (
<AlertCircle size={14} className="text-amber-500" />
) : model === currentModelItem.id ? (
<Check size={14} className="text-primary" />
)}
) : null}
</button>
))
)}
@@ -0,0 +1,129 @@
import React from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const {
mockUseConfiguredProviders,
mockUseProviderModels,
} = vi.hoisted(() => ({
mockUseConfiguredProviders: vi.fn(),
mockUseProviderModels: vi.fn(),
}));
vi.mock("@/hooks/useConfiguredProviders", () => ({
useConfiguredProviders: () => mockUseConfiguredProviders(),
}));
vi.mock("@/hooks/useProviderModels", () => ({
useProviderModels: (...args: unknown[]) => mockUseProviderModels(...args),
}));
import { ProviderModelSelector } from "./ProviderModelSelector";
interface MountedRoot {
root: Root;
container: HTMLDivElement;
}
const mountedRoots: MountedRoot[] = [];
function renderSelector(
props: Partial<React.ComponentProps<typeof ProviderModelSelector>> = {},
) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
const mergedProps: React.ComponentProps<typeof ProviderModelSelector> = {
onSelect: vi.fn(),
initialProviderId: "custom-codex",
...props,
};
act(() => {
root.render(<ProviderModelSelector {...mergedProps} />);
});
mountedRoots.push({ root, container });
return { container };
}
beforeEach(() => {
(
globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
).IS_REACT_ACT_ENVIRONMENT = true;
vi.clearAllMocks();
mockUseConfiguredProviders.mockReturnValue({
providers: [
{
key: "custom-codex",
label: "Codex Custom",
registryId: "custom-codex",
fallbackRegistryId: "codex",
type: "codex",
},
],
loading: false,
});
mockUseProviderModels.mockReturnValue({
models: [
{
id: "gpt-5.3-codex",
display_name: "GPT-5.3 Codex",
capabilities: {
vision: true,
tools: true,
streaming: true,
json_mode: true,
function_calling: true,
reasoning: true,
},
is_latest: true,
},
{
id: "gpt-5.2-codex",
display_name: "GPT-5.2 Codex",
capabilities: {
vision: true,
tools: true,
streaming: true,
json_mode: true,
function_calling: true,
reasoning: true,
},
is_latest: false,
},
],
loading: false,
error: null,
});
});
afterEach(() => {
while (mountedRoots.length > 0) {
const mounted = mountedRoots.pop();
if (!mounted) break;
act(() => {
mounted.root.unmount();
});
mounted.container.remove();
}
});
describe("ProviderModelSelector", () => {
it("应隐藏 codex 不兼容模型并展示兼容提示", () => {
const { container } = renderSelector();
expect(container.textContent).toContain(
"已隐藏 1 个当前登录态不兼容的模型",
);
expect(container.textContent).not.toContain("GPT-5.3 Codex");
expect(container.textContent).toContain("GPT-5.2 Codex");
});
});
@@ -22,6 +22,7 @@ import {
import { useProviderModels } from "@/hooks/useProviderModels";
import { getProviderLabel } from "@/lib/constants/providerMappings";
import type { EnhancedModelMetadata } from "@/lib/types/modelRegistry";
import { getProviderModelCompatibilityIssue } from "@/components/agent/chat/utils/providerModelCompatibility";
// ============================================================================
// 类型定义
@@ -187,6 +188,24 @@ export const ProviderModelSelector: React.FC<ProviderModelSelectorProps> = ({
error: modelsError,
} = useProviderModels(selectedProvider, { returnFullMetadata: true });
const compatibleModels = useMemo(
() =>
filteredModels.filter(
(model) =>
!getProviderModelCompatibilityIssue({
providerType: selectedProvider?.key || "",
configuredProviderType: selectedProvider?.type,
model: model.id,
}),
),
[filteredModels, selectedProvider?.key, selectedProvider?.type],
);
const incompatibleModelCount = useMemo(
() => filteredModels.length - compatibleModels.length,
[compatibleModels.length, filteredModels.length],
);
// 默认选中第一个 Provider
useEffect(() => {
if (!selectedProviderId && configuredProviders.length > 0) {
@@ -279,19 +298,26 @@ export const ProviderModelSelector: React.FC<ProviderModelSelectorProps> = ({
<AlertCircle className="h-8 w-8 mb-2" />
<p className="text-sm">{modelsError}</p>
</div>
) : filteredModels.length === 0 ? (
) : compatibleModels.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
<p className="text-sm">暂无模型数据</p>
</div>
) : (
filteredModels.map((model) => (
<>
{incompatibleModelCount > 0 ? (
<div className="px-1 py-1 text-xs text-amber-600">
已隐藏 {incompatibleModelCount} 个当前登录态不兼容的模型
</div>
) : null}
{compatibleModels.map((model) => (
<ModelItem
key={model.id}
model={model}
isSelected={selectedModelId === model.id}
onClick={() => handleSelectModel(model)}
/>
))
))}
</>
)}
</div>
</div>
+27 -4
View File
@@ -269,9 +269,14 @@ function resolveOpenClawSubpage(
interface OpenClawPageProps {
pageParams?: OpenClawPageParams;
onNavigate?: (page: Page, params?: PageParams) => void;
isActive?: boolean;
}
export function OpenClawPage({ pageParams, onNavigate }: OpenClawPageProps) {
export function OpenClawPage({
pageParams,
onNavigate,
isActive = false,
}: OpenClawPageProps) {
const {
providers,
loading: providersLoading,
@@ -562,8 +567,12 @@ export function OpenClawPage({ pageParams, onNavigate }: OpenClawPageProps) {
}, [refreshDashboardWindowState, refreshGatewayRuntime]);
useEffect(() => {
if (!isActive) {
return;
}
void refreshAll();
}, [refreshAll]);
}, [isActive, refreshAll]);
useEffect(() => {
if (!statusResolved || requestedSubpage || operationState.running) {
@@ -587,6 +596,10 @@ export function OpenClawPage({ pageParams, onNavigate }: OpenClawPageProps) {
]);
useEffect(() => {
if (!isActive) {
return;
}
if (gatewayStatus !== "running" && gatewayStatus !== "starting") {
return;
}
@@ -598,13 +611,23 @@ export function OpenClawPage({ pageParams, onNavigate }: OpenClawPageProps) {
}, 5000);
return () => window.clearInterval(timer);
}, [gatewayStatus, refreshGatewayRuntime]);
}, [gatewayStatus, isActive, refreshGatewayRuntime]);
useEffect(() => {
if (!isActive) {
return;
}
if (currentSubpage === "dashboard" && gatewayRunning && !dashboardUrl) {
void refreshDashboardUrl({ silent: true, showLoading: true });
}
}, [currentSubpage, dashboardUrl, gatewayRunning, refreshDashboardUrl]);
}, [
currentSubpage,
dashboardUrl,
gatewayRunning,
isActive,
refreshDashboardUrl,
]);
const syncProviderConfig = useCallback(
async ({ showSuccessToast = true, trackLoading = true } = {}) => {
@@ -46,6 +46,7 @@ import {
telegramChannelProbe,
} from "@/hooks/useTauri";
import { useConfiguredProviders } from "@/hooks/useConfiguredProviders";
import { filterProviderModelsByCompatibility, getProviderModelCompatibilityIssue } from "@/components/agent/chat/utils/providerModelCompatibility";
import { ChannelLogTailPanel } from "./ChannelLogTailPanel";
// ============================================================================
@@ -108,6 +109,25 @@ function DefaultModelSelect({
}) {
const { providers, loading: providersLoading } = useConfiguredProviders();
// 已保存的值如果不兼容,自动回退到 "未指定"
useEffect(() => {
if (!value || providersLoading) return;
const slashIdx = value.indexOf("/");
if (slashIdx < 0) return;
const providerKey = value.slice(0, slashIdx);
const modelName = value.slice(slashIdx + 1);
const provider = providers.find((p) => p.key === providerKey);
if (!provider) return;
const issue = getProviderModelCompatibilityIssue({
providerType: provider.type,
configuredProviderType: provider.type,
model: modelName,
});
if (issue) {
onChange(undefined);
}
}, [value, providers, providersLoading, onChange]);
return (
<div>
<label className="block text-sm font-medium mb-1.5">默认模型</label>
@@ -118,15 +138,23 @@ function DefaultModelSelect({
>
<option value="">未指定(使用全局默认)</option>
{providersLoading && <option disabled>加载中...</option>}
{providers.map((p) => (
<optgroup key={p.key} label={p.label}>
{p.customModels?.map((m) => (
<option key={`${p.key}/${m}`} value={`${p.key}/${m}`}>
{m}
</option>
))}
</optgroup>
))}
{providers.map((p) => {
const models = p.customModels ?? [];
const { compatibleModels } = filterProviderModelsByCompatibility(
{ providerType: p.type, configuredProviderType: p.type },
models,
);
if (compatibleModels.length === 0) return null;
return (
<optgroup key={p.key} label={p.label}>
{compatibleModels.map((m) => (
<option key={`${p.key}/${m}`} value={`${p.key}/${m}`}>
{m}
</option>
))}
</optgroup>
);
})}
</select>
<p className="text-xs text-muted-foreground mt-1">
为此渠道指定默认使用的 AI 模型
+22 -2
View File
@@ -16,7 +16,7 @@
* - AI 面板可控制活动终端
*/
import { useState, useCallback, useRef } from "react";
import { useState, useCallback, useRef, useEffect } from "react";
import styled from "styled-components";
import { TerminalPanel } from "./TerminalPanel";
import {
@@ -210,12 +210,17 @@ const CloseIcon = () => (
interface TerminalWorkspaceProps {
/** 页面导航回调 */
onNavigate: (page: Page) => void;
/** 当前页面是否已激活 */
isActive: boolean;
}
/**
* 终端工作区组件
*/
export function TerminalWorkspace({ onNavigate }: TerminalWorkspaceProps) {
export function TerminalWorkspace({
onNavigate,
isActive,
}: TerminalWorkspaceProps) {
// 面板状态管理 - 初始包含主终端
const [panels, setPanels] = useState<SidePanel[]>([
{ id: "main-terminal", type: "terminal", title: "Terminal" },
@@ -223,6 +228,7 @@ export function TerminalWorkspace({ onNavigate }: TerminalWorkspaceProps) {
// AI 面板状态
const [showAIPanel, setShowAIPanel] = useState(false);
const [hasActivated, setHasActivated] = useState(isActive);
// 活动终端面板 ID(用于 AI 控制)
const [activeTerminalPanelId, setActiveTerminalPanelId] =
@@ -234,6 +240,12 @@ export function TerminalWorkspace({ onNavigate }: TerminalWorkspaceProps) {
// 连接编辑器模态窗口状态
const [isConnectionsEditorOpen, setIsConnectionsEditorOpen] = useState(false);
useEffect(() => {
if (isActive) {
setHasActivated(true);
}
}, [isActive]);
// 获取活动终端的会话 ID
const getActiveTerminalSessionId = useCallback((): string | null => {
const activePanel = panels.find(
@@ -346,6 +358,14 @@ export function TerminalWorkspace({ onNavigate }: TerminalWorkspaceProps) {
const renderPanelContent = (panel: SidePanel) => {
switch (panel.type) {
case "terminal":
if (!hasActivated) {
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
终端将在首次打开该页面时初始化
</div>
);
}
return (
<TerminalPanel
panelId={panel.id}
@@ -0,0 +1,107 @@
import React from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const {
mockUseConfiguredProviders,
mockUseProviderModels,
} = vi.hoisted(() => ({
mockUseConfiguredProviders: vi.fn(),
mockUseProviderModels: vi.fn(),
}));
vi.mock("@/hooks/useConfiguredProviders", () => ({
useConfiguredProviders: () => mockUseConfiguredProviders(),
}));
vi.mock("@/hooks/useProviderModels", () => ({
useProviderModels: (...args: unknown[]) => mockUseProviderModels(...args),
}));
import { TerminalAIModeSelector } from "./TerminalAIModeSelector";
interface MountedRoot {
root: Root;
container: HTMLDivElement;
}
const mountedRoots: MountedRoot[] = [];
function renderSelector(
props: Partial<React.ComponentProps<typeof TerminalAIModeSelector>> = {},
) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
const mergedProps: React.ComponentProps<typeof TerminalAIModeSelector> = {
providerId: "custom-codex",
onProviderChange: vi.fn(),
modelId: "gpt-5.3-codex",
onModelChange: vi.fn(),
...props,
};
act(() => {
root.render(<TerminalAIModeSelector {...mergedProps} />);
});
mountedRoots.push({ root, container });
return { container, props: mergedProps };
}
beforeEach(() => {
(
globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
).IS_REACT_ACT_ENVIRONMENT = true;
vi.clearAllMocks();
mockUseConfiguredProviders.mockReturnValue({
providers: [
{
key: "custom-codex",
label: "Codex Custom",
registryId: "custom-codex",
fallbackRegistryId: "codex",
type: "codex",
credentialType: "codex_oauth",
},
],
loading: false,
});
mockUseProviderModels.mockReturnValue({
modelIds: ["gpt-5.3-codex", "gpt-5.2-codex"],
models: [],
loading: false,
error: null,
});
});
afterEach(() => {
while (mountedRoots.length > 0) {
const mounted = mountedRoots.pop();
if (!mounted) break;
act(() => {
mounted.root.unmount();
});
mounted.container.remove();
}
});
describe("TerminalAIModeSelector", () => {
it("应在 codex 不兼容模型被选中时自动回退到兼容模型", () => {
const onModelChange = vi.fn();
renderSelector({
modelId: "gpt-5.3-codex",
onModelChange,
});
expect(onModelChange).toHaveBeenCalledWith("gpt-5.2-codex");
});
});
@@ -18,6 +18,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import { useConfiguredProviders } from "@/hooks/useConfiguredProviders";
import { useProviderModels } from "@/hooks/useProviderModels";
import { isAliasProvider } from "@/lib/constants/providerMappings";
import { filterProviderModelsByCompatibility } from "@/components/agent/chat/utils/providerModelCompatibility";
// ============================================================================
// 常量
@@ -229,6 +230,20 @@ export const TerminalAIModeSelector: React.FC<TerminalAIModeSelectorProps> = ({
return hookModels;
}, [selectedProvider, hookModels]);
const compatibilityResult = useMemo(() => {
if (!selectedProvider) {
return { compatibleModels: [], incompatibleModels: [] };
}
return filterProviderModelsByCompatibility(
{
providerType: selectedProvider.key,
configuredProviderType: selectedProvider.type,
},
currentModels,
);
}, [currentModels, selectedProvider]);
// 自动选择第一个模型
useEffect(() => {
// 等待模型加载完成
@@ -240,10 +255,19 @@ export const TerminalAIModeSelector: React.FC<TerminalAIModeSelectorProps> = ({
return;
}
if (currentModels.length > 0 && !currentModels.includes(modelId)) {
onModelChange(currentModels[0]);
if (
compatibilityResult.compatibleModels.length > 0 &&
!compatibilityResult.compatibleModels.includes(modelId)
) {
onModelChange(compatibilityResult.compatibleModels[0]);
}
}, [currentModels, modelId, onModelChange, selectedProvider, modelsLoading]);
}, [
compatibilityResult.compatibleModels,
modelId,
onModelChange,
selectedProvider,
modelsLoading,
]);
// 初始化 Provider
useEffect(() => {
@@ -308,12 +332,17 @@ export const TerminalAIModeSelector: React.FC<TerminalAIModeSelectorProps> = ({
<div className="text-xs font-semibold text-zinc-400 px-2 py-1 mb-1">
Models
</div>
{compatibilityResult.incompatibleModels.length > 0 ? (
<div className="px-2 pb-1 text-[11px] text-amber-400">
已隐藏 {compatibilityResult.incompatibleModels.length} 个当前登录态不兼容的模型
</div>
) : null}
<ScrollArea className="flex-1">
<div className="space-y-0.5 p-1">
{currentModels.length === 0 ? (
{compatibilityResult.compatibleModels.length === 0 ? (
<div className="text-xs text-zinc-500 p-2">暂无可用模型</div>
) : (
currentModels.map((m) => (
compatibilityResult.compatibleModels.map((m) => (
<button
key={m}
onClick={() => {
+16 -2
View File
@@ -22,6 +22,7 @@ import { cn } from "@/lib/utils";
import { useConfiguredProviders } from "@/hooks/useConfiguredProviders";
import { useProviderModels } from "@/hooks/useProviderModels";
import { getProviderLabel } from "@/lib/constants/providerMappings";
import { getProviderModelCompatibilityIssue } from "@/components/agent/chat/utils/providerModelCompatibility";
export interface PolishModelSelectorProps {
/** 当前选中的模型 ID */
@@ -65,6 +66,19 @@ export const PolishModelSelector: React.FC<PolishModelSelectorProps> = ({
{ returnFullMetadata: true },
);
const compatibleModels = useMemo(
() =>
currentModels.filter(
(model) =>
!getProviderModelCompatibilityIssue({
providerType: selectedProvider?.key || "",
configuredProviderType: selectedProvider?.type,
model: model.id,
}),
),
[currentModels, selectedProvider?.key, selectedProvider?.type],
);
// 点击外部关闭
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
@@ -200,12 +214,12 @@ export const PolishModelSelector: React.FC<PolishModelSelectorProps> = ({
<div className="flex items-center justify-center h-full">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
) : currentModels.length === 0 ? (
) : compatibleModels.length === 0 ? (
<div className="flex items-center justify-center h-full text-xs text-muted-foreground">
暂无可用模型
</div>
) : (
currentModels.map((model) => {
compatibleModels.map((model) => {
const isSelected = value === model.id;
// 简化模型名称显示
const displayName =
+23 -18
View File
@@ -141,6 +141,9 @@ export function WorkbenchPage({
const shouldHideVideoSidebarInWorkspace =
themeModule.capabilities.workspaceKind === "video-canvas" &&
workspaceMode === "workspace";
const shouldHideVideoRightRailInWorkspace =
themeModule.capabilities.workspaceKind === "video-canvas" &&
workspaceMode === "workspace";
return (
<div className="flex flex-col h-full min-h-0">
@@ -229,24 +232,26 @@ export function WorkbenchPage({
/>
}
rightRail={
<WorkbenchRightRail
shouldRender={shouldRenderWorkspaceRightRail}
isCreateWorkspaceView={isCreateWorkspaceView}
projectId={selectedProjectId}
theme={theme}
creationMode={selectedContentCreationMode}
creationType={selectedContentCreationType}
initialStyleGuideDialogOpen={pendingStyleGuideDialogOpen}
onInitialStyleGuideDialogConsumed={() =>
setPendingStyleGuideDialogOpen(false)
}
initialStyleGuideSourceEntryId={pendingStyleGuideSourceEntryId}
onInitialStyleGuideSourceEntryConsumed={() =>
setPendingStyleGuideSourceEntryId(null)
}
onBackToCreateView={() => handleSwitchWorkspaceView("create")}
onCreateContentFromPrompt={handleCreateContentFromWorkspacePrompt}
/>
shouldHideVideoRightRailInWorkspace ? null : (
<WorkbenchRightRail
shouldRender={shouldRenderWorkspaceRightRail}
isCreateWorkspaceView={isCreateWorkspaceView}
projectId={selectedProjectId}
theme={theme}
creationMode={selectedContentCreationMode}
creationType={selectedContentCreationType}
initialStyleGuideDialogOpen={pendingStyleGuideDialogOpen}
onInitialStyleGuideDialogConsumed={() =>
setPendingStyleGuideDialogOpen(false)
}
initialStyleGuideSourceEntryId={pendingStyleGuideSourceEntryId}
onInitialStyleGuideSourceEntryConsumed={() =>
setPendingStyleGuideSourceEntryId(null)
}
onBackToCreateView={() => handleSwitchWorkspaceView("create")}
onCreateContentFromPrompt={handleCreateContentFromWorkspacePrompt}
/>
)
}
/>
+9 -9
View File
@@ -6,13 +6,13 @@
*/
import { useState, useEffect, useCallback, useMemo } from "react";
import { invoke } from "@tauri-apps/api/core";
import type {
Project,
CreateProjectRequest,
ProjectUpdate,
ProjectFilter,
} from "@/types/project";
import { safeInvoke } from "@/lib/dev-bridge";
import { recordWorkspaceRepair } from "@/lib/workspaceHealthTelemetry";
interface WorkspaceEnsureResult {
@@ -73,12 +73,12 @@ export function useProjects(): UseProjectsReturn {
setError(null);
const [list, defaultProj] = await Promise.all([
invoke<Project[]>("workspace_list"),
invoke<Project | null>("workspace_get_default"),
safeInvoke<Project[]>("workspace_list"),
safeInvoke<Project | null>("workspace_get_default"),
]);
if (defaultProj?.id) {
const ensureResult = await invoke<WorkspaceEnsureResult>(
const ensureResult = await safeInvoke<WorkspaceEnsureResult>(
"workspace_ensure_ready",
{ id: defaultProj.id },
);
@@ -139,11 +139,11 @@ export function useProjects(): UseProjectsReturn {
/** 创建项目 */
const create = useCallback(
async (request: CreateProjectRequest): Promise<Project> => {
const rootPath = await invoke<string>("workspace_resolve_project_path", {
const rootPath = await safeInvoke<string>("workspace_resolve_project_path", {
name: request.name,
});
const project = await invoke<Project>("workspace_create", {
const project = await safeInvoke<Project>("workspace_create", {
request: {
name: request.name,
rootPath,
@@ -159,7 +159,7 @@ export function useProjects(): UseProjectsReturn {
/** 更新项目 */
const update = useCallback(
async (id: string, updateData: ProjectUpdate): Promise<Project> => {
const project = await invoke<Project>("workspace_update", {
const project = await safeInvoke<Project>("workspace_update", {
id,
request: updateData,
});
@@ -172,7 +172,7 @@ export function useProjects(): UseProjectsReturn {
/** 删除项目 */
const remove = useCallback(
async (id: string): Promise<boolean> => {
const result = await invoke<boolean>("workspace_delete", { id });
const result = await safeInvoke<boolean>("workspace_delete", { id });
await refresh();
return result;
},
@@ -181,7 +181,7 @@ export function useProjects(): UseProjectsReturn {
/** 获取或创建默认项目 */
const getOrCreateDefault = useCallback(async (): Promise<Project> => {
const project = await invoke<Project>("get_or_create_default_project");
const project = await safeInvoke<Project>("get_or_create_default_project");
await refresh();
return project;
}, [refresh]);