feat: add OpenClaw commands and improve workbench functionality

- Add OpenClaw command registration in runner.rs
- Improve MarkdownRenderer with tests
- Enhance video canvas and sidebar components
- Update workbench controller and quick actions
- Add video theme panel renderers
- Update OpenClaw pages and types
This commit is contained in:
coso
2026-03-10 11:34:55 +08:00
parent daaa2352b6
commit dce4753488
34 changed files with 2799 additions and 462 deletions
+8 -3
View File
@@ -1,3 +1,5 @@
const WINDOWS_STACK_SIZE_BYTES: usize = 10 * 1024 * 1024;
fn main() {
configure_windows_stack_size();
@@ -16,10 +18,13 @@ fn main() {
}
fn configure_windows_stack_size() {
#[cfg(target_os = "windows")]
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {
return;
}
match std::env::var("CARGO_CFG_TARGET_ENV").as_deref() {
Ok("msvc") => println!("cargo:rustc-link-arg=/STACK:8388608"),
Ok("gnu") => println!("cargo:rustc-link-arg=-Wl,--stack,8388608"),
Ok("msvc") => println!("cargo:rustc-link-arg=/STACK:{WINDOWS_STACK_SIZE_BYTES}"),
Ok("gnu") => println!("cargo:rustc-link-arg=-Wl,--stack,{WINDOWS_STACK_SIZE_BYTES}"),
_ => {}
}
}
+187 -75
View File
@@ -9,19 +9,11 @@ use regex::Regex;
use serde::{Deserialize, Serialize};
const JSON_RECURSION_LIMIT: usize = 50;
/// 从工具结果中提取文本内容
///
/// 使用 serde_json 来处理,避免直接依赖 rmcp 类型
fn push_non_empty(target: &mut Vec<String>, value: Option<&str>) {
let Some(raw) = value else {
return;
};
let trimmed = raw.trim();
if !trimmed.is_empty() {
target.push(trimmed.to_string());
}
}
const JSON_TRAVERSAL_NODE_LIMIT: usize = 4_096;
const TOOL_RESULT_MAX_TEXT_PARTS: usize = 256;
const TOOL_RESULT_MAX_OUTPUT_CHARS: usize = 16_000;
const TOOL_RESULT_MAX_IMAGES: usize = 12;
const TOOL_RESULT_TRUNCATED_NOTICE: &str = "\n\n[event_converter] 工具输出已截断";
fn enhance_execution_error_text(raw: &str) -> String {
if !raw.contains("Execution error: No such file or directory (os error 2)") {
@@ -48,48 +40,119 @@ fn dedupe_preserve_order(items: Vec<String>) -> Vec<String> {
deduped
}
fn collect_tool_result_text(value: &serde_json::Value, target: &mut Vec<String>) {
collect_tool_result_text_with_depth(value, target, 0);
#[derive(Debug, Default)]
struct TextCollectState {
collected_chars: usize,
truncated: bool,
}
fn collect_tool_result_text_with_depth(
value: &serde_json::Value,
fn truncate_chars(text: &str, max_chars: usize) -> (String, bool) {
if max_chars == 0 {
return (String::new(), !text.is_empty());
}
let mut char_count = 0usize;
for (idx, _) in text.char_indices() {
if char_count == max_chars {
return (text[..idx].to_string(), true);
}
char_count += 1;
}
(text.to_string(), false)
}
fn push_non_empty_limited(
target: &mut Vec<String>,
depth: usize,
value: Option<&str>,
state: &mut TextCollectState,
) {
if depth >= JSON_RECURSION_LIMIT {
let Some(raw) = value else {
return;
};
let trimmed = raw.trim();
if trimmed.is_empty() {
return;
}
if target.len() >= TOOL_RESULT_MAX_TEXT_PARTS
|| state.collected_chars >= TOOL_RESULT_MAX_OUTPUT_CHARS
{
state.truncated = true;
return;
}
match value {
serde_json::Value::String(text) => push_non_empty(target, Some(text)),
serde_json::Value::Array(items) => {
for item in items {
collect_tool_result_text_with_depth(item, target, depth + 1);
}
}
serde_json::Value::Object(obj) => {
if let Some(content) = obj.get("content") {
collect_tool_result_text_with_depth(content, target, depth + 1);
}
if let Some(value) = obj.get("value") {
collect_tool_result_text_with_depth(value, target, depth + 1);
}
for key in ["text", "output", "stdout", "stderr", "message", "error"] {
push_non_empty(target, obj.get(key).and_then(|v| v.as_str()));
}
}
_ => {}
let remaining = TOOL_RESULT_MAX_OUTPUT_CHARS.saturating_sub(state.collected_chars);
let (snippet, was_truncated) = truncate_chars(trimmed, remaining);
if snippet.is_empty() {
state.truncated = true;
return;
}
state.collected_chars += snippet.chars().count();
state.truncated |= was_truncated;
target.push(snippet);
}
fn collect_tool_result_text(value: &serde_json::Value, target: &mut Vec<String>) -> bool {
let mut stack = vec![(value, 0usize)];
let mut visited_nodes = 0usize;
let mut state = TextCollectState::default();
while let Some((current, depth)) = stack.pop() {
visited_nodes += 1;
if visited_nodes > JSON_TRAVERSAL_NODE_LIMIT {
state.truncated = true;
break;
}
if depth >= JSON_RECURSION_LIMIT {
state.truncated = true;
continue;
}
match current {
serde_json::Value::String(text) => {
push_non_empty_limited(target, Some(text), &mut state);
}
serde_json::Value::Array(items) => {
for item in items.iter().rev() {
stack.push((item, depth + 1));
}
}
serde_json::Value::Object(obj) => {
for key in ["text", "output", "stdout", "stderr", "message", "error"] {
push_non_empty_limited(
target,
obj.get(key).and_then(|v| v.as_str()),
&mut state,
);
}
if let Some(value) = obj.get("value") {
stack.push((value, depth + 1));
}
if let Some(content) = obj.get("content") {
stack.push((content, depth + 1));
}
}
_ => {}
}
}
state.truncated
}
fn extract_tool_result_text<T: serde::Serialize>(result: &T) -> String {
if let Ok(json) = serde_json::to_value(result) {
let mut parts = Vec::new();
collect_tool_result_text(&json, &mut parts);
let traversal_truncated = collect_tool_result_text(&json, &mut parts);
let deduped = dedupe_preserve_order(parts);
if !deduped.is_empty() {
return maybe_filter_web_content(&deduped.join("\n"));
let filtered = maybe_filter_web_content(&deduped.join("\n"));
let (mut limited, output_truncated) =
truncate_chars(&filtered, TOOL_RESULT_MAX_OUTPUT_CHARS);
if traversal_truncated || output_truncated {
limited.push_str(TOOL_RESULT_TRUNCATED_NOTICE);
}
return limited;
}
}
String::new()
@@ -256,51 +319,68 @@ fn collect_tool_result_images(
value: &serde_json::Value,
target: &mut Vec<TauriToolImage>,
seen_sources: &mut std::collections::HashSet<String>,
) {
collect_tool_result_images_with_depth(value, target, seen_sources, 0);
}
) -> bool {
let mut stack = vec![(value, 0usize)];
let mut visited_nodes = 0usize;
let mut truncated = false;
fn collect_tool_result_images_with_depth(
value: &serde_json::Value,
target: &mut Vec<TauriToolImage>,
seen_sources: &mut std::collections::HashSet<String>,
depth: usize,
) {
if depth >= JSON_RECURSION_LIMIT {
return;
}
while let Some((current, depth)) = stack.pop() {
visited_nodes += 1;
if visited_nodes > JSON_TRAVERSAL_NODE_LIMIT {
truncated = true;
break;
}
if depth >= JSON_RECURSION_LIMIT {
truncated = true;
continue;
}
if target.len() >= TOOL_RESULT_MAX_IMAGES {
truncated = true;
break;
}
match value {
serde_json::Value::String(text) => {
for data_url in extract_data_urls_from_text(text) {
push_tool_image_if_new(
target,
seen_sources,
build_tool_image_from_data_url(&data_url, "data_url"),
);
}
}
serde_json::Value::Array(items) => {
for item in items {
collect_tool_result_images_with_depth(item, target, seen_sources, depth + 1);
}
}
serde_json::Value::Object(obj) => {
for key in ["image_url", "url", "data"] {
if let Some(serde_json::Value::String(raw)) = obj.get(key) {
match current {
serde_json::Value::String(text) => {
for data_url in extract_data_urls_from_text(text) {
if target.len() >= TOOL_RESULT_MAX_IMAGES {
truncated = true;
break;
}
push_tool_image_if_new(
target,
seen_sources,
build_tool_image_from_data_url(raw, "tool_payload"),
build_tool_image_from_data_url(&data_url, "data_url"),
);
}
}
for nested in obj.values() {
collect_tool_result_images_with_depth(nested, target, seen_sources, depth + 1);
serde_json::Value::Array(items) => {
for item in items.iter().rev() {
stack.push((item, depth + 1));
}
}
serde_json::Value::Object(obj) => {
for key in ["image_url", "url", "data"] {
if target.len() >= TOOL_RESULT_MAX_IMAGES {
truncated = true;
break;
}
if let Some(serde_json::Value::String(raw)) = obj.get(key) {
push_tool_image_if_new(
target,
seen_sources,
build_tool_image_from_data_url(raw, "tool_payload"),
);
}
}
for nested in obj.values() {
stack.push((nested, depth + 1));
}
}
_ => {}
}
_ => {}
}
truncated
}
fn extract_tool_result_data<T: serde::Serialize>(result: &T) -> ExtractedToolResult {
@@ -317,7 +397,7 @@ fn extract_tool_result_data<T: serde::Serialize>(result: &T) -> ExtractedToolRes
}
if let Ok(json) = serde_json::to_value(result) {
collect_tool_result_images(&json, &mut images, &mut seen_sources);
let _ = collect_tool_result_images(&json, &mut images, &mut seen_sources);
}
ExtractedToolResult { output, images }
@@ -911,4 +991,36 @@ mod tests {
let text = extract_tool_result_text(&nested);
assert_eq!(text, "");
}
#[test]
fn test_extract_tool_result_text_should_truncate_large_payload() {
let payload = serde_json::json!({
"content": [
{
"type": "text",
"text": "A".repeat(TOOL_RESULT_MAX_OUTPUT_CHARS + 128)
}
]
});
let text = extract_tool_result_text(&payload);
assert!(text.contains("[event_converter] 工具输出已截断"));
assert!(text.chars().count() <= TOOL_RESULT_MAX_OUTPUT_CHARS + 64);
}
#[test]
fn test_extract_tool_result_data_should_limit_image_count() {
let payload = serde_json::json!({
"images": (0..(TOOL_RESULT_MAX_IMAGES + 4))
.map(|index| {
serde_json::json!({
"data": format!("data:image/png;base64,image{index}")
})
})
.collect::<Vec<_>>()
});
let extracted = extract_tool_result_data(&payload);
assert_eq!(extracted.images.len(), TOOL_RESULT_MAX_IMAGES);
}
}
+3
View File
@@ -983,6 +983,7 @@ pub fn run() {
commands::config_cmd::download_update,
// OpenClaw commands
commands::openclaw_cmd::openclaw_check_installed,
commands::openclaw_cmd::openclaw_get_environment_status,
commands::openclaw_cmd::openclaw_check_node_version,
commands::openclaw_cmd::openclaw_check_git_available,
commands::openclaw_cmd::openclaw_get_node_download_url,
@@ -990,7 +991,9 @@ pub fn run() {
commands::openclaw_cmd::openclaw_get_command_preview,
commands::openclaw_cmd::openclaw_get_progress_logs,
commands::openclaw_cmd::openclaw_install,
commands::openclaw_cmd::openclaw_install_dependency,
commands::openclaw_cmd::openclaw_uninstall,
commands::openclaw_cmd::openclaw_cleanup_temp_artifacts,
commands::openclaw_cmd::openclaw_start_gateway,
commands::openclaw_cmd::openclaw_stop_gateway,
commands::openclaw_cmd::openclaw_restart_gateway,
+30 -2
View File
@@ -2,8 +2,8 @@ use crate::commands::api_key_provider_cmd::ApiKeyProviderServiceState;
use crate::database::DbConnection;
use crate::services::openclaw_service::{
openclaw_install_event_name, ActionResult, BinaryAvailabilityStatus, BinaryInstallStatus,
ChannelInfo, CommandPreview, GatewayStatusInfo, HealthInfo, InstallProgressEvent,
NodeCheckResult, OpenClawServiceState, SyncModelEntry,
ChannelInfo, CommandPreview, EnvironmentStatus, GatewayStatusInfo, HealthInfo,
InstallProgressEvent, NodeCheckResult, OpenClawServiceState, SyncModelEntry,
};
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, State};
@@ -25,6 +25,14 @@ pub async fn openclaw_check_installed(
service.check_installed().await
}
#[tauri::command]
pub async fn openclaw_get_environment_status(
service: State<'_, OpenClawServiceState>,
) -> Result<EnvironmentStatus, String> {
let service = service.0.lock().await;
service.get_environment_status().await
}
#[tauri::command]
pub async fn openclaw_check_node_version(
service: State<'_, OpenClawServiceState>,
@@ -78,6 +86,26 @@ pub async fn openclaw_install(
service.install(&app).await
}
#[tauri::command]
pub async fn openclaw_install_dependency(
app: AppHandle,
service: State<'_, OpenClawServiceState>,
kind: String,
) -> Result<ActionResult, String> {
let mut service = service.0.lock().await;
service.clear_progress_logs();
service.install_dependency(&app, &kind).await
}
#[tauri::command]
pub async fn openclaw_cleanup_temp_artifacts(
app: AppHandle,
service: State<'_, OpenClawServiceState>,
) -> Result<ActionResult, String> {
let mut service = service.0.lock().await;
service.cleanup_temp_artifacts(Some(&app)).await
}
#[tauri::command]
pub async fn openclaw_uninstall(
app: AppHandle,
+36
View File
@@ -1249,6 +1249,17 @@ pub async fn handle_command(
Ok(serde_json::to_value(service.check_installed().await?)?)
}
"openclaw_get_environment_status" => {
let app_handle = state
.app_handle
.as_ref()
.ok_or_else(|| "Dev Bridge 未持有 AppHandle".to_string())?;
let service =
app_handle.state::<crate::services::openclaw_service::OpenClawServiceState>();
let service = service.0.lock().await;
Ok(serde_json::to_value(service.get_environment_status().await?)?)
}
"openclaw_check_node_version" => {
let app_handle = state
.app_handle
@@ -1313,6 +1324,20 @@ pub async fn handle_command(
Ok(serde_json::to_value(service.install(app_handle).await?)?)
}
"openclaw_install_dependency" => {
let app_handle = state
.app_handle
.as_ref()
.ok_or_else(|| "Dev Bridge 未持有 AppHandle".to_string())?;
let args = args.unwrap_or_default();
let kind = get_string_arg(&args, "kind", "kind")?;
let service =
app_handle.state::<crate::services::openclaw_service::OpenClawServiceState>();
let mut service = service.0.lock().await;
service.clear_progress_logs();
Ok(serde_json::to_value(service.install_dependency(app_handle, &kind).await?)?)
}
"openclaw_uninstall" => {
let app_handle = state
.app_handle
@@ -1324,6 +1349,17 @@ pub async fn handle_command(
Ok(serde_json::to_value(service.uninstall(app_handle).await?)?)
}
"openclaw_cleanup_temp_artifacts" => {
let app_handle = state
.app_handle
.as_ref()
.ok_or_else(|| "Dev Bridge 未持有 AppHandle".to_string())?;
let service =
app_handle.state::<crate::services::openclaw_service::OpenClawServiceState>();
let mut service = service.0.lock().await;
Ok(serde_json::to_value(service.cleanup_temp_artifacts(Some(app_handle)).await?)?)
}
"openclaw_start_gateway" => {
let app_handle = state
.app_handle
File diff suppressed because it is too large Load Diff
+6
View File
@@ -539,6 +539,12 @@ function AppContent() {
initialUserPrompt={
(pageParams as AgentPageParams).initialUserPrompt
}
initialSessionName={
(pageParams as AgentPageParams).initialSessionName
}
entryBannerMessage={
(pageParams as AgentPageParams).entryBannerMessage
}
theme={(pageParams as AgentPageParams).theme}
lockTheme={(pageParams as AgentPageParams).lockTheme}
fromResources={(pageParams as AgentPageParams).fromResources}
@@ -0,0 +1,108 @@
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 { MarkdownRenderer } from "./MarkdownRenderer";
vi.mock("react-syntax-highlighter", () => ({
Prism: ({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) => (
<pre data-testid="syntax-highlighter" className={className}>
{children}
</pre>
),
}));
vi.mock("react-syntax-highlighter/dist/esm/styles/prism", () => ({
oneDark: {},
}));
vi.mock("./ArtifactPlaceholder", () => ({
ArtifactPlaceholder: ({ language }: { language: string }) => (
<div data-testid="artifact-placeholder">{language}</div>
),
}));
vi.mock("./A2UITaskCard", () => ({
A2UITaskCard: () => <div data-testid="a2ui-task-card" />,
A2UITaskLoadingCard: () => <div data-testid="a2ui-task-loading-card" />,
}));
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 render(content: string, isStreaming = false): HTMLDivElement {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(
<MarkdownRenderer content={content} isStreaming={isStreaming} />,
);
});
mountedRoots.push({ container, root });
return container;
}
describe("MarkdownRenderer", () => {
it("非流式时应保留 raw html 渲染能力", () => {
const content = [
"前置文本",
"",
'<div class="rendered-html">原始 HTML</div>',
"",
"后置文本",
].join("\n");
const container = render(content, false);
expect(container.querySelector(".rendered-html")).not.toBeNull();
expect(container.textContent).toContain("原始 HTML");
});
it("大段流式输出时应跳过 raw html 重解析", () => {
const content = [
"A".repeat(2_200),
"",
'<div class="rendered-html">原始 HTML</div>',
"",
"结尾文本",
].join("\n");
const container = render(content, true);
expect(container.querySelector(".rendered-html")).toBeNull();
expect(container.textContent).toContain("结尾文本");
});
});
@@ -14,6 +14,8 @@ import { CHAT_A2UI_TASK_CARD_PRESET } from "@/components/content-creator/a2ui/ta
import { ArtifactPlaceholder } from "./ArtifactPlaceholder";
import { A2UITaskCard, A2UITaskLoadingCard } from "./A2UITaskCard";
const STREAMING_LIGHT_RENDER_THRESHOLD = 2_000;
// Custom styles for markdown content to match Cherry Studio
const MarkdownContainer = styled.div`
font-size: 15px;
@@ -245,6 +247,19 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = memo(
isStreaming = false,
}) => {
const [copied, setCopied] = React.useState<string | null>(null);
const useLightweightStreamingRender =
isStreaming && content.length >= STREAMING_LIGHT_RENDER_THRESHOLD;
const remarkPlugins = React.useMemo(
() =>
useLightweightStreamingRender ? [remarkGfm] : [remarkGfm, remarkMath],
[useLightweightStreamingRender],
);
const rehypePlugins = React.useMemo(
() => (useLightweightStreamingRender ? [] : [rehypeRaw, rehypeKatex]),
[useLightweightStreamingRender],
);
const handleCopy = (code: string) => {
navigator.clipboard.writeText(code);
@@ -353,8 +368,9 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = memo(
{/* 如果还有其他内容,渲染 markdown */}
{!hasOnlyPlaceholders && processedContent.text.trim() && (
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeRaw, rehypeKatex]}
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
skipHtml={useLightweightStreamingRender}
components={{
// 使用 pre 组件来处理代码块,以便更好地控制 a2ui 的渲染
pre({ children, ...props }: any) {
@@ -415,6 +431,14 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = memo(
);
}
if (useLightweightStreamingRender) {
return (
<pre {...props}>
<code className={className}>{codeContent}</code>
</pre>
);
}
// Block code - 完整显示
const isCopied = copied === codeContent;
@@ -1484,27 +1484,66 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) {
}
}, [filterSessionsByWorkspace, workspaceId]);
const createFreshSession = useCallback(
async (sessionName?: string): Promise<string | null> => {
try {
const resolvedWorkspaceId = getRequiredWorkspaceId();
const newSessionId = await createAsterSession(
resolvedWorkspaceId,
undefined,
sessionName,
executionStrategy,
);
const now = new Date();
setMessages([]);
setPendingActions([]);
setSessionId(newSessionId);
setTopics((prev) => [
{
id: newSessionId,
title: sessionName?.trim() || "新话题",
createdAt: now,
updatedAt: now,
messagesCount: 0,
executionStrategy,
},
...prev.filter((topic) => topic.id !== newSessionId),
]);
currentAssistantMsgIdRef.current = null;
currentStreamingSessionIdRef.current = null;
hydratedSessionRef.current = newSessionId;
skipAutoRestoreRef.current = false;
restoredWorkspaceRef.current = resolvedWorkspaceId;
saveTransient(getScopedSessionKey(), newSessionId);
savePersisted(getScopedPersistedSessionKey(), newSessionId);
saveTransient(getScopedMessagesKey(), []);
void loadTopics();
return newSessionId;
} catch (error) {
console.error("[AsterChat] 创建新话题失败:", error);
toast.error(`创建新话题失败: ${error}`);
return null;
}
},
[
executionStrategy,
getRequiredWorkspaceId,
getScopedMessagesKey,
getScopedPersistedSessionKey,
getScopedSessionKey,
loadTopics,
],
);
// 确保有会话
const ensureSession = useCallback(async (): Promise<string | null> => {
if (sessionId) return sessionId;
try {
const resolvedWorkspaceId = getRequiredWorkspaceId();
const newSessionId = await createAsterSession(
resolvedWorkspaceId,
undefined,
undefined,
executionStrategy,
);
setSessionId(newSessionId);
skipAutoRestoreRef.current = false;
return newSessionId;
} catch (error) {
console.error("[AsterChat] 创建会话失败:", error);
toast.error(`创建会话失败: ${error}`);
return null;
}
}, [executionStrategy, getRequiredWorkspaceId, sessionId]);
return createFreshSession();
}, [createFreshSession, sessionId]);
// 辅助函数:追加文本到 contentParts
const appendTextToParts = (
@@ -2192,7 +2231,13 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) {
if (unlisten) unlisten();
}
},
[ensureSession, executionStrategy, getRequiredWorkspaceId, onWriteFile, systemPrompt],
[
ensureSession,
executionStrategy,
getRequiredWorkspaceId,
onWriteFile,
systemPrompt,
],
);
// 停止发送
@@ -2906,6 +2951,8 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) {
topics,
sessionId,
createFreshSession,
ensureSession,
switchTopic,
deleteTopic,
renameTopic,
+23
View File
@@ -1973,3 +1973,26 @@ describe("AgentChatPage 自动引导", () => {
);
});
});
describe("AgentChatPage 视频主题工作台", () => {
it("视频主题工作台不应渲染底部通用输入条,也不应自动发送首条请求", async () => {
mockUseThemeContextWorkspace.mockReturnValue(
createMockThemeContextWorkspaceState({
enabled: true,
}),
);
const container = renderPage({
projectId: "project-video",
contentId: "content-video",
theme: "video",
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();
});
});
+96 -4
View File
@@ -18,7 +18,7 @@ import {
} from "react";
import { toast } from "sonner";
import styled from "styled-components";
import { PanelLeftOpen } from "lucide-react";
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";
@@ -227,6 +227,28 @@ const ChatContainerInner = styled.div`
overflow: hidden;
`;
const EntryBanner = styled.div`
display: flex;
align-items: center;
gap: 8px;
margin: 8px 12px 0;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid hsl(var(--primary) / 0.18);
background: hsl(var(--primary) / 0.08);
color: hsl(var(--foreground));
font-size: 13px;
`;
const EntryBannerClose = styled.button`
margin-left: auto;
border: none;
background: transparent;
color: hsl(var(--muted-foreground));
cursor: pointer;
font-size: 13px;
`;
const ChatContent = styled.div`
display: flex;
flex-direction: column;
@@ -1542,6 +1564,8 @@ export function AgentChatPage({
hideInlineStepProgress = false,
onWorkflowProgressChange,
initialUserPrompt,
initialSessionName,
entryBannerMessage,
onInitialUserPromptConsumed,
newChatAt,
onRecommendationClick: _onRecommendationClick,
@@ -1565,6 +1589,8 @@ export function AgentChatPage({
snapshot: WorkflowProgressSnapshot | null,
) => void;
initialUserPrompt?: string;
initialSessionName?: string;
entryBannerMessage?: string;
onInitialUserPromptConsumed?: () => void;
newChatAt?: number;
onRecommendationClick?: (shortLabel: string, fullPrompt: string) => void;
@@ -1577,6 +1603,9 @@ export function AgentChatPage({
useState(false);
const [input, setInput] = useState("");
const [selectedText, setSelectedText] = useState("");
const [entryBannerVisible, setEntryBannerVisible] = useState(
Boolean(entryBannerMessage),
);
const [chatToolPreferences, setChatToolPreferences] =
useState<ChatToolPreferences>(() => loadChatToolPreferences());
@@ -1609,6 +1638,10 @@ export function AgentChatPage({
setCreationMode(initialCreationMode);
}, [initialCreationMode]);
useEffect(() => {
setEntryBannerVisible(Boolean(entryBannerMessage));
}, [entryBannerMessage]);
useEffect(() => {
saveChatToolPreferences(chatToolPreferences);
}, [chatToolPreferences]);
@@ -2124,6 +2157,7 @@ export function AgentChatPage({
triggerAIGuide,
topics,
sessionId,
createFreshSession,
switchTopic: originalSwitchTopic,
deleteTopic,
renameTopic,
@@ -3988,7 +4022,27 @@ export function AgentChatPage({
setActiveTheme(normalizeInitialTheme(initialTheme));
setCreationMode(initialCreationMode ?? "guided");
}
const toastId = initialSessionName
? "openclaw-agent-handoff"
: "agent-new-chat";
void (async () => {
const newSessionId = await createFreshSession(initialSessionName);
if (newSessionId) {
toast.success(
initialSessionName
? `已创建新话题:${initialSessionName}`
: "已创建新话题",
{ id: toastId },
);
} else {
toast.error("创建新话题失败,请重试。", { id: toastId });
}
})();
}, [
createFreshSession,
initialSessionName,
newChatAt,
clearMessages,
externalProjectId,
@@ -4832,6 +4886,10 @@ export function AgentChatPage({
// 当从项目进入且有 contentId 时,自动启动创作引导
useEffect(() => {
if (mappedTheme === "video") {
return;
}
// 条件:
// - 有 contentId(从项目创建内容进入)
// - 没有消息(messages.length === 0)
@@ -4941,8 +4999,10 @@ export function AgentChatPage({
useEffect(() => {
const pendingInitialPrompt = (initialUserPrompt || "").trim();
if (
mappedTheme === "video" ||
!pendingInitialPrompt ||
contentId ||
!sessionId ||
messages.length > 0 ||
isSending
) {
@@ -4969,8 +5029,10 @@ export function AgentChatPage({
handleSend,
initialUserPrompt,
isSending,
mappedTheme,
messages.length,
onInitialUserPromptConsumed,
sessionId,
]);
// 当 contentId 变化时重置引导状态
@@ -5025,14 +5087,19 @@ export function AgentChatPage({
// 主题工作台始终使用聊天布局与浮层输入,不走旧 EmptyState 输入流程
const showChatLayout = hasMessages || isThemeWorkbench;
const shouldHideThemeWorkbenchInputForTheme =
isThemeWorkbench && mappedTheme === "video";
const shouldShowThemeWorkbenchSidebarForTheme = mappedTheme !== "video";
const showThemeWorkbenchSidebar =
showChatPanel &&
showSidebar &&
isThemeWorkbench &&
shouldShowThemeWorkbenchSidebarForTheme &&
(!enableThemeWorkbenchPanelCollapse || !themeWorkbenchSidebarCollapsed);
const showThemeWorkbenchLeftExpandButton =
showChatPanel &&
showSidebar &&
shouldShowThemeWorkbenchSidebarForTheme &&
enableThemeWorkbenchPanelCollapse &&
themeWorkbenchSidebarCollapsed;
const handleThemeWorkbenchDeleteTopic = useCallback(() => {}, []);
@@ -5437,6 +5504,19 @@ export function AgentChatPage({
() => (
<ChatContainer>
<ChatContainerInner>
{entryBannerVisible && entryBannerMessage ? (
<EntryBanner>
<Info className="h-4 w-4 shrink-0" />
<span>{entryBannerMessage}</span>
<EntryBannerClose
type="button"
onClick={() => setEntryBannerVisible(false)}
aria-label="关闭入口提示"
>
关闭
</EntryBannerClose>
</EntryBanner>
) : null}
{!hideInlineStepProgress &&
isContentCreationMode &&
hasMessages &&
@@ -5582,7 +5662,10 @@ export function AgentChatPage({
</button>
</div>
)}
{!contextWorkspace.enabled ? inputbarNode : null}
{!contextWorkspace.enabled &&
!shouldHideThemeWorkbenchInputForTheme
? inputbarNode
: null}
</>
)}
</ChatContainerInner>
@@ -5598,6 +5681,8 @@ export function AgentChatPage({
currentStepIndex,
deleteMessage,
dismissWorkspacePathError,
entryBannerMessage,
entryBannerVisible,
editMessage,
executionStrategy,
generalCanvasState.content,
@@ -5632,6 +5717,7 @@ export function AgentChatPage({
setWorkspaceHealthError,
shouldCollapseCodeBlocks,
selectedText,
setEntryBannerVisible,
showChatLayout,
handleRunStyleAudit,
handleRunStyleRewrite,
@@ -5643,6 +5729,7 @@ export function AgentChatPage({
workspaceHealthError,
workspacePathMissing,
resolvedCanvasState,
shouldHideThemeWorkbenchInputForTheme,
],
);
@@ -5875,7 +5962,9 @@ export function AgentChatPage({
<ThemeWorkbenchLayoutShell
$bottomInset={
isThemeWorkbench && showChatLayout
isThemeWorkbench &&
showChatLayout &&
!shouldHideThemeWorkbenchInputForTheme
? canvasContent
? themeWorkbenchRunState === "auto_running"
? "24px"
@@ -5896,7 +5985,9 @@ export function AgentChatPage({
canvasContent={canvasContent}
/>
</ThemeWorkbenchLayoutShell>
{isThemeWorkbench && showChatLayout ? (
{isThemeWorkbench &&
showChatLayout &&
!shouldHideThemeWorkbenchInputForTheme ? (
<ThemeWorkbenchInputOverlay
$hasPendingA2UIForm={Boolean(pendingA2UIForm)}
>
@@ -5930,6 +6021,7 @@ export function AgentChatPage({
onBackToProjectManagement,
pendingA2UIForm,
projectId,
shouldHideThemeWorkbenchInputForTheme,
showChatLayout,
showChatPanel,
showNovelNavbarControls,
@@ -2,9 +2,6 @@ import React, { memo, useEffect, useMemo, useState } from "react";
import styled from "styled-components";
import { toast } from "sonner";
import {
ChevronLeft,
ChevronRight,
LayoutGrid,
PanelLeftClose,
PanelLeftOpen,
} from "lucide-react";
@@ -151,60 +148,6 @@ const WorkspaceFrame = styled.div`
overflow: hidden;
`;
const TopicPanel = styled.div<{ $collapsed: boolean }>`
position: relative;
width: ${({ $collapsed }) => ($collapsed ? "0px" : "90px")};
min-width: ${({ $collapsed }) => ($collapsed ? "0px" : "90px")};
height: 100%;
border-left: ${({ $collapsed }) =>
$collapsed ? "none" : "1px solid hsl(var(--border))"};
background: hsl(var(--background));
overflow: visible;
transition:
width 0.2s ease,
min-width 0.2s ease;
`;
const TopicPanelHandle = styled.button`
position: absolute;
left: -14px;
top: 50%;
transform: translateY(-50%);
width: 28px;
height: 50px;
border: 1px solid hsl(var(--border));
border-right: none;
border-radius: 12px 0 0 12px;
background: hsl(var(--background));
color: hsl(var(--muted-foreground));
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 6;
&:hover {
color: hsl(var(--foreground));
}
`;
const MainAction = styled.button`
position: absolute;
top: 10px;
right: 10px;
z-index: 5;
width: 20px;
height: 20px;
border-radius: 6px;
border: none;
background: transparent;
color: hsl(var(--muted-foreground));
display: inline-flex;
align-items: center;
justify-content: center;
cursor: default;
`;
export const VideoCanvas: React.FC<VideoCanvasProps> = memo(
({
state,
@@ -214,7 +157,6 @@ export const VideoCanvas: React.FC<VideoCanvasProps> = memo(
onClose: _onClose,
}) => {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [topicPanelCollapsed, setTopicPanelCollapsed] = useState(false);
const [providers, setProviders] = useState<VideoProviderOption[]>([]);
useEffect(() => {
@@ -373,30 +315,12 @@ export const VideoCanvas: React.FC<VideoCanvasProps> = memo(
<WorkspaceFrame>
<MainContainer>
<MainAction>
<LayoutGrid size={12} />
</MainAction>
<VideoWorkspace
state={state}
projectId={projectId}
onStateChange={onStateChange}
/>
</MainContainer>
<TopicPanel $collapsed={topicPanelCollapsed}>
<TopicPanelHandle
type="button"
title={topicPanelCollapsed ? "展开右侧栏" : "收起右侧栏"}
onClick={() =>
setTopicPanelCollapsed((previous) => !previous)
}
>
{topicPanelCollapsed ? (
<ChevronLeft size={12} />
) : (
<ChevronRight size={12} />
)}
</TopicPanelHandle>
</TopicPanel>
</WorkspaceFrame>
</Body>
</Root>
@@ -246,6 +246,24 @@ const UploadPrompt = styled.div`
}
`;
const UploadActionButton = styled.button`
margin-top: 8px;
height: 30px;
border-radius: 999px;
border: 1px solid hsl(var(--border));
background: hsl(var(--background));
color: hsl(var(--foreground));
padding: 0 12px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
&:hover {
border-color: hsl(var(--primary));
color: hsl(var(--primary));
}
`;
const PreviewBox = styled.div`
width: 100%;
min-height: 116px;
@@ -291,6 +309,24 @@ const RemovePreviewButton = styled.button`
cursor: pointer;
`;
const ReplacePreviewButton = styled.button`
position: absolute;
top: 8px;
left: 8px;
height: 24px;
border: none;
border-radius: 999px;
background: hsl(var(--background) / 0.92);
color: hsl(var(--foreground));
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 10px;
font-size: 11px;
font-weight: 600;
cursor: pointer;
`;
const RatioGrid = styled.div`
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
@@ -759,6 +795,12 @@ export const VideoSidebar: React.FC<VideoSidebarProps> = memo(
onStateChange({ ...state, endImage: value });
};
const openFramePicker = (field: FrameImageField) => {
const inputRef =
field === "startImage" ? startFileInputRef : endFileInputRef;
inputRef.current?.click();
};
const handleUploadFiles = async (
field: FrameImageField,
files: FileList | null,
@@ -863,7 +905,6 @@ export const VideoSidebar: React.FC<VideoSidebarProps> = memo(
<SectionTitle>{frame.title}</SectionTitle>
<ImageUploadArea
$dragging={draggingArea === frame.area}
onClick={() => inputRef.current?.click()}
onDragOver={(event) => {
event.preventDefault();
setDraggingArea(frame.area);
@@ -890,6 +931,15 @@ export const VideoSidebar: React.FC<VideoSidebarProps> = memo(
{previewImage ? (
<PreviewBox>
<img src={previewImage} alt={`${frame.title}预览`} />
<ReplacePreviewButton
type="button"
onClick={(event) => {
event.stopPropagation();
openFramePicker(frame.field);
}}
>
更换
</ReplacePreviewButton>
<RemovePreviewButton
type="button"
onClick={(event) => {
@@ -897,15 +947,24 @@ export const VideoSidebar: React.FC<VideoSidebarProps> = memo(
setFrameImage(frame.field, undefined);
}}
>
<X size={14} />
</RemovePreviewButton>
<ReplaceHint>点击或拖拽替换图片</ReplaceHint>
<X size={14} />
</RemovePreviewButton>
<ReplaceHint>拖拽上传可替换图片</ReplaceHint>
</PreviewBox>
) : (
<UploadPrompt>
<ImagePlus size={18} />
<div>添加图片</div>
<div>点击或拖拽上传</div>
<div>拖拽上传参考图</div>
<UploadActionButton
type="button"
onClick={(event) => {
event.stopPropagation();
openFramePicker(frame.field);
}}
>
选择图片
</UploadActionButton>
</UploadPrompt>
)}
</ImageUploadArea>
+248 -54
View File
@@ -1,52 +1,222 @@
import { Download, ExternalLink, Link2, Loader2, Package } from "lucide-react";
import type { ReactNode } from "react";
import {
Download,
ExternalLink,
GitBranch,
Loader2,
Package,
RefreshCw,
TerminalSquare,
Trash2,
} from "lucide-react";
import type {
OpenClawDependencyStatus,
OpenClawEnvironmentStatus,
} from "@/lib/api/openclaw";
import { OpenClawMark } from "./OpenClawMark";
interface OpenClawInstallPageProps {
binaryPath?: string | null;
nodeStatusText: string;
gitStatusText: string;
environmentStatus: OpenClawEnvironmentStatus | null;
busy: boolean;
installing: boolean;
installingNode: boolean;
installingGit: boolean;
cleaningTemp: boolean;
onInstall: () => void;
onInstallNode: () => void;
onInstallGit: () => void;
onRefresh: () => void;
onCleanupTemp: () => void;
onOpenDocs: () => void;
onDownloadNode: () => void;
onDownloadGit: () => void;
}
function resolveStatusTone(status: OpenClawDependencyStatus["status"]): string {
switch (status) {
case "ok":
return "text-emerald-600";
case "version_low":
return "text-amber-600";
case "missing":
return "text-rose-600";
default:
return "text-muted-foreground";
}
}
function resolveStatusLabel(
status: OpenClawDependencyStatus["status"],
): string {
switch (status) {
case "ok":
return "已就绪";
case "version_low":
return "版本过低";
case "missing":
return "未检测到";
default:
return status;
}
}
function DependencyCard({
title,
icon,
status,
busy,
primaryLabel,
onPrimaryAction,
secondaryLabel,
onSecondaryAction,
}: {
title: string;
icon: ReactNode;
status: OpenClawDependencyStatus | null;
busy: boolean;
primaryLabel?: string;
onPrimaryAction?: () => void;
secondaryLabel?: string;
onSecondaryAction?: () => void;
}) {
const resolvedStatus = status || {
status: "unknown",
version: null,
path: null,
message: "尚未检测",
autoInstallSupported: false,
};
const pathText = resolvedStatus.path || "当前未检测到路径";
return (
<section className="rounded-2xl border bg-card p-5 shadow-sm">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 text-sm font-medium">
{icon}
{title}
</div>
<span
className={`text-xs font-medium ${resolveStatusTone(resolvedStatus.status)}`}
>
{resolveStatusLabel(resolvedStatus.status)}
</span>
</div>
<p className="mt-3 text-sm leading-6 text-muted-foreground">
{resolvedStatus.message}
</p>
<div className="mt-3 rounded-xl bg-muted/50 px-3 py-2 text-xs leading-6 text-muted-foreground">
<div>版本:{resolvedStatus.version || "未检测到"}</div>
<div className="break-all">路径:{pathText}</div>
</div>
{(primaryLabel || secondaryLabel) && (
<div className="mt-4 flex flex-wrap gap-2">
{primaryLabel && onPrimaryAction ? (
<button
type="button"
onClick={onPrimaryAction}
disabled={busy}
className="inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-xs hover:bg-muted disabled:opacity-60"
>
{busy ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Download className="h-3.5 w-3.5" />
)}
{primaryLabel}
</button>
) : null}
{secondaryLabel && onSecondaryAction ? (
<button
type="button"
onClick={onSecondaryAction}
className="inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-xs hover:bg-muted"
>
<ExternalLink className="h-3.5 w-3.5" />
{secondaryLabel}
</button>
) : null}
</div>
)}
</section>
);
}
export function OpenClawInstallPage({
binaryPath,
nodeStatusText,
gitStatusText,
environmentStatus,
busy,
installing,
installingNode,
installingGit,
cleaningTemp,
onInstall,
onInstallNode,
onInstallGit,
onRefresh,
onCleanupTemp,
onOpenDocs,
onDownloadNode,
onDownloadGit,
}: OpenClawInstallPageProps) {
const nodeReady = environmentStatus?.node.status === "ok";
const gitReady = environmentStatus?.git.status === "ok";
const openclawReady = environmentStatus?.openclaw.status === "ok";
const installLabel = openclawReady
? "重新安装 OpenClaw"
: nodeReady && gitReady
? "安装 OpenClaw"
: "一键修复环境并安装 OpenClaw";
return (
<div className="flex min-h-full flex-col items-center justify-center px-6 py-10">
<div className="w-full max-w-4xl space-y-8">
<div className="w-full max-w-5xl space-y-8">
<div className="flex flex-col items-center text-center">
<OpenClawMark size="lg" />
<h1 className="mt-6 text-4xl font-semibold tracking-tight">
OpenClaw 未安装
OpenClaw 环境安装
</h1>
<p className="mt-3 max-w-2xl text-base leading-7 text-muted-foreground">
先完成本地安装后,才可以继续配置模型、启动 Gateway,并进入 Dashboard。
{environmentStatus?.summary ||
"正在检查 Node.js、Git 与 OpenClaw 环境,稍后会给出一键修复建议。"}
</p>
<div className="mt-6 flex flex-wrap items-center justify-center gap-3">
<button
type="button"
onClick={onInstall}
disabled={installing}
className="inline-flex min-w-[168px] items-center justify-center gap-2 rounded-lg bg-primary px-5 py-2.5 text-sm text-primary-foreground disabled:opacity-60"
disabled={busy}
className="inline-flex min-w-[220px] items-center justify-center gap-2 rounded-lg bg-primary px-5 py-2.5 text-sm text-primary-foreground disabled:opacity-60"
>
{installing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
安装 OpenClaw
{installLabel}
</button>
<button
type="button"
onClick={onRefresh}
disabled={busy}
className="inline-flex min-w-[132px] items-center justify-center gap-2 rounded-lg border px-5 py-2.5 text-sm hover:bg-muted disabled:opacity-60"
>
<RefreshCw className="h-4 w-4" />
重新检测
</button>
<button
type="button"
onClick={onCleanupTemp}
disabled={busy}
className="inline-flex min-w-[132px] items-center justify-center gap-2 rounded-lg border px-5 py-2.5 text-sm hover:bg-muted disabled:opacity-60"
>
{cleaningTemp ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
清理临时文件
</button>
<button
type="button"
@@ -60,50 +230,74 @@ export function OpenClawInstallPage({
</div>
<div className="grid gap-4 md:grid-cols-3">
<div className="rounded-2xl border bg-card p-5 shadow-sm">
<div className="flex items-center gap-2 text-sm font-medium">
<Package className="h-4 w-4" />
OpenClaw
</div>
<p className="mt-3 break-all text-sm leading-6 text-muted-foreground">
{binaryPath || "当前未检测到可执行文件。"}
</p>
</div>
<DependencyCard
title="Node.js"
icon={<TerminalSquare className="h-4 w-4" />}
status={environmentStatus?.node ?? null}
busy={busy && installingNode}
primaryLabel={
environmentStatus?.node.autoInstallSupported &&
environmentStatus?.node.status !== "ok"
? "一键安装 Node.js"
: undefined
}
onPrimaryAction={onInstallNode}
secondaryLabel="手动下载 Node.js"
onSecondaryAction={onDownloadNode}
/>
<div className="rounded-2xl border bg-card p-5 shadow-sm">
<div className="flex items-center gap-2 text-sm font-medium">
<Download className="h-4 w-4" />
Node.js
</div>
<p className="mt-3 text-sm leading-6 text-muted-foreground">
{nodeStatusText}
</p>
<button
type="button"
onClick={onDownloadNode}
className="mt-4 text-xs text-primary hover:underline"
>
下载 Node.js
</button>
</div>
<DependencyCard
title="Git"
icon={<GitBranch className="h-4 w-4" />}
status={environmentStatus?.git ?? null}
busy={busy && installingGit}
primaryLabel={
environmentStatus?.git.autoInstallSupported &&
environmentStatus?.git.status !== "ok"
? "一键安装 Git"
: undefined
}
onPrimaryAction={onInstallGit}
secondaryLabel="手动下载 Git"
onSecondaryAction={onDownloadGit}
/>
<div className="rounded-2xl border bg-card p-5 shadow-sm">
<div className="flex items-center gap-2 text-sm font-medium">
<Link2 className="h-4 w-4" />
Git
</div>
<p className="mt-3 text-sm leading-6 text-muted-foreground">
{gitStatusText}
</p>
<button
type="button"
onClick={onDownloadGit}
className="mt-4 text-xs text-primary hover:underline"
>
下载 Git
</button>
</div>
<DependencyCard
title="OpenClaw"
icon={<Package className="h-4 w-4" />}
status={environmentStatus?.openclaw ?? null}
busy={busy && installing}
primaryLabel={!openclawReady ? "安装 OpenClaw" : undefined}
onPrimaryAction={!openclawReady ? onInstall : undefined}
/>
</div>
<section className="rounded-2xl border bg-card p-5 shadow-sm">
<div className="text-sm font-medium">当前安装策略</div>
<div className="mt-3 text-sm leading-7 text-muted-foreground">
<p>- 优先复用系统里已满足要求的 Node.js / Git,避免重复安装。</p>
<p>
-
缺失依赖时,优先尝试应用内一键安装;若当前平台不支持,则自动降级到手动下载引导。
</p>
<p>- 安装完成后会自动重新检测环境,并继续执行 OpenClaw 安装。</p>
</div>
{environmentStatus?.tempArtifacts?.length ? (
<div className="mt-4 rounded-xl bg-muted/50 px-4 py-3 text-xs leading-6 text-muted-foreground">
<div className="font-medium text-foreground">
可清理的临时文件
</div>
<div className="mt-2 space-y-1">
{environmentStatus.tempArtifacts.map((item) => (
<div key={item} className="break-all">
{item}
</div>
))}
</div>
</div>
) : null}
</section>
</div>
</div>
);
+206 -63
View File
@@ -17,6 +17,7 @@ import {
type OpenClawBinaryAvailabilityStatus,
type OpenClawBinaryInstallStatus,
type OpenClawChannelInfo,
type OpenClawEnvironmentStatus,
type OpenClawGatewayStatus,
type OpenClawHealthInfo,
type OpenClawInstallProgressEvent,
@@ -56,6 +57,7 @@ const SUPPORTED_PROVIDER_TYPES = new Set([
const progressSubpageByAction: Record<OpenClawOperationKind, OpenClawSubpage> =
{
install: "installing",
repair: "installing",
uninstall: "uninstalling",
restart: "restarting",
};
@@ -138,6 +140,8 @@ function openClawOperationLabel(kind: OpenClawOperationKind | null): string {
switch (kind) {
case "install":
return "安装";
case "repair":
return "修复环境";
case "uninstall":
return "卸载";
case "restart":
@@ -299,6 +303,8 @@ export function OpenClawPage({ pageParams, onNavigate }: OpenClawPageProps) {
const [statusResolved, setStatusResolved] = useState(false);
const [installedStatus, setInstalledStatus] =
useState<OpenClawBinaryInstallStatus | null>(null);
const [environmentStatus, setEnvironmentStatus] =
useState<OpenClawEnvironmentStatus | null>(null);
const [nodeStatus, setNodeStatus] = useState<OpenClawNodeCheckResult | null>(
null,
);
@@ -315,9 +321,14 @@ export function OpenClawPage({ pageParams, onNavigate }: OpenClawPageProps) {
const [starting, setStarting] = useState(false);
const [stopping, setStopping] = useState(false);
const [checkingHealth, setCheckingHealth] = useState(false);
const [cleaningTemp, setCleaningTemp] = useState(false);
const [handingOffToAgent, setHandingOffToAgent] = useState(false);
const [operationState, setOperationState] = useState<OpenClawOperationState>({
kind: null,
target: null,
running: false,
title: null,
description: null,
message: null,
returnSubpage: "install",
});
@@ -521,14 +532,24 @@ export function OpenClawPage({ pageParams, onNavigate }: OpenClawPageProps) {
const refreshAll = useCallback(async () => {
try {
const [installedResult, nodeResult, gitResult] = await Promise.all([
openclawApi.checkInstalled(),
openclawApi.checkNodeVersion(),
openclawApi.checkGitAvailable(),
]);
setInstalledStatus(installedResult);
setNodeStatus(nodeResult);
setGitStatus(gitResult);
const environment = await openclawApi.getEnvironmentStatus();
setEnvironmentStatus(environment);
setInstalledStatus({
installed: environment.openclaw.status === "ok",
path: environment.openclaw.path,
});
setNodeStatus({
status:
environment.node.status === "missing"
? "not_found"
: environment.node.status,
version: environment.node.version,
path: environment.node.path,
});
setGitStatus({
available: environment.git.status === "ok",
path: environment.git.path,
});
await Promise.all([
refreshGatewayRuntime(),
refreshDashboardWindowState(),
@@ -645,18 +666,36 @@ export function OpenClawPage({ pageParams, onNavigate }: OpenClawPageProps) {
);
const runProgressOperation = useCallback(
async (
kind: OpenClawOperationKind,
action: () => Promise<{ success: boolean; message: string }>,
successSubpage: OpenClawSubpage,
returnSubpage: OpenClawSubpage,
initialLogs: OpenClawInstallProgressEvent[] = [],
onSuccess?: () => void,
) => {
async (options: {
kind: OpenClawOperationKind;
target?: OpenClawOperationState["target"];
title?: string;
description?: string;
action: () => Promise<{ success: boolean; message: string }>;
successSubpage: OpenClawSubpage;
returnSubpage: OpenClawSubpage;
initialLogs?: OpenClawInstallProgressEvent[];
onSuccess?: () => void;
}) => {
const {
kind,
target = "environment",
title = null,
description = null,
action,
successSubpage,
returnSubpage,
initialLogs = [],
onSuccess,
} = options;
setInstallLogs(initialLogs);
setOperationState({
kind,
target,
running: true,
title,
description,
message: null,
returnSubpage,
});
@@ -667,7 +706,10 @@ export function OpenClawPage({ pageParams, onNavigate }: OpenClawPageProps) {
const result = await action();
setOperationState({
kind,
target,
running: false,
title,
description,
message: result.message,
returnSubpage,
});
@@ -686,7 +728,10 @@ export function OpenClawPage({ pageParams, onNavigate }: OpenClawPageProps) {
const message = error instanceof Error ? error.message : String(error);
setOperationState({
kind,
target,
running: false,
title,
description,
message,
returnSubpage,
});
@@ -698,28 +743,22 @@ export function OpenClawPage({ pageParams, onNavigate }: OpenClawPageProps) {
);
const handleInstall = useCallback(async () => {
const preview = await openclawApi
.getCommandPreview("install")
.catch(() => null);
await runProgressOperation(
"install",
() => openclawApi.install(),
"runtime",
"install",
preview
? [
{ level: "info", message: preview.title },
...preview.command
.split("\n")
.map((line) => ({ level: "info" as const, message: line })),
]
: [
{
level: "info",
message: "已发送安装请求,正在等待后端返回安装命令...",
},
],
);
await runProgressOperation({
kind: "install",
target: "openclaw",
title: "正在修复环境并安装 OpenClaw",
description:
"ProxyCast 会先自动检查并修复 Node.js / Git,再继续安装 OpenClaw。",
action: () => openclawApi.install(),
successSubpage: "runtime",
returnSubpage: "install",
initialLogs: [
{
level: "info",
message: "已发送安装请求,正在检查并修复 OpenClaw 运行环境...",
},
],
});
}, [runProgressOperation]);
const handleUninstall = useCallback(async () => {
@@ -732,12 +771,13 @@ export function OpenClawPage({ pageParams, onNavigate }: OpenClawPageProps) {
.getCommandPreview("uninstall")
.catch(() => null);
await runProgressOperation(
"uninstall",
() => openclawApi.uninstall(),
"install",
installed ? "configure" : "install",
preview
await runProgressOperation({
kind: "uninstall",
target: "openclaw",
action: () => openclawApi.uninstall(),
successSubpage: "install",
returnSubpage: installed ? "configure" : "install",
initialLogs: preview
? [
{ level: "info", message: preview.title },
...preview.command
@@ -750,11 +790,11 @@ export function OpenClawPage({ pageParams, onNavigate }: OpenClawPageProps) {
message: "已发送卸载请求,正在等待后端返回卸载命令...",
},
],
() => {
onSuccess: () => {
clearLastSynced();
setSelectedModelId("");
},
);
});
}, [
clearLastSynced,
closeDashboardWindowSilently,
@@ -769,12 +809,13 @@ export function OpenClawPage({ pageParams, onNavigate }: OpenClawPageProps) {
.getCommandPreview("restart", gatewayPort)
.catch(() => null);
await runProgressOperation(
"restart",
() => openclawApi.restartGateway(),
"runtime",
"runtime",
preview
await runProgressOperation({
kind: "restart",
target: "openclaw",
action: () => openclawApi.restartGateway(),
successSubpage: "runtime",
returnSubpage: "runtime",
initialLogs: preview
? [
{ level: "info", message: preview.title },
...preview.command
@@ -787,9 +828,45 @@ export function OpenClawPage({ pageParams, onNavigate }: OpenClawPageProps) {
message: "已发送重启请求,正在停止并重新拉起 Gateway...",
},
],
);
});
}, [closeDashboardWindowSilently, gatewayPort, runProgressOperation]);
const handleInstallNode = useCallback(async () => {
await runProgressOperation({
kind: "repair",
target: "node",
title: "正在安装 Node.js 环境",
description: "ProxyCast 会优先尝试应用内一键安装或修复 Node.js。",
action: () => openclawApi.installDependency("node"),
successSubpage: "install",
returnSubpage: "install",
initialLogs: [
{
level: "info",
message: "已发送 Node.js 修复请求,正在准备安装流程...",
},
],
});
}, [runProgressOperation]);
const handleInstallGit = useCallback(async () => {
await runProgressOperation({
kind: "repair",
target: "git",
title: "正在安装 Git 环境",
description: "ProxyCast 会优先尝试应用内一键安装或修复 Git。",
action: () => openclawApi.installDependency("git"),
successSubpage: "install",
returnSubpage: "install",
initialLogs: [
{
level: "info",
message: "已发送 Git 修复请求,正在准备安装流程...",
},
],
});
}, [runProgressOperation]);
const handleSync = useCallback(async () => {
await syncProviderConfig();
}, [syncProviderConfig]);
@@ -890,6 +967,23 @@ export function OpenClawPage({ pageParams, onNavigate }: OpenClawPageProps) {
}
}, []);
const handleCleanupTempArtifacts = useCallback(async () => {
setCleaningTemp(true);
try {
const result = await openclawApi.cleanupTempArtifacts();
if (result.success) {
toast.success(result.message);
} else {
toast.warning(result.message);
}
await refreshAll();
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
} finally {
setCleaningTemp(false);
}
}, [refreshAll]);
const handleCopyPath = useCallback(async () => {
const path = installedStatus?.path;
if (!path) {
@@ -1051,10 +1145,16 @@ export function OpenClawPage({ pageParams, onNavigate }: OpenClawPageProps) {
return;
}
setHandingOffToAgent(true);
toast.info("正在创建新话题并转交给 AI...", {
id: "openclaw-agent-handoff",
});
const project = await getOrCreateDefaultProject().catch((error) => {
toast.error(
error instanceof Error ? error.message : "创建默认项目失败。",
);
setHandingOffToAgent(false);
return null;
});
@@ -1065,10 +1165,13 @@ export function OpenClawPage({ pageParams, onNavigate }: OpenClawPageProps) {
onNavigate?.("agent", {
projectId: project.id,
initialUserPrompt: prompt,
initialSessionName: "OpenClaw 修复",
entryBannerMessage: "已从 OpenClaw 故障诊断进入,诊断请求已自动发送。",
newChatAt: Date.now(),
theme: "general",
lockTheme: false,
});
setHandingOffToAgent(false);
}, [onNavigate, openClawRepairPrompt]);
if (!statusResolved && !operationState.running) {
@@ -1090,11 +1193,29 @@ export function OpenClawPage({ pageParams, onNavigate }: OpenClawPageProps) {
if (currentSubpage === "install") {
return (
<OpenClawInstallPage
binaryPath={installedStatus?.path}
nodeStatusText={formatNodeStatus(nodeStatus)}
gitStatusText={formatBinaryStatus(gitStatus, "可用", "未检测到 Git")}
installing={operationState.running && operationState.kind === "install"}
environmentStatus={environmentStatus}
busy={operationState.running}
installing={
operationState.running &&
operationState.kind === "install" &&
operationState.target === "openclaw"
}
installingNode={
operationState.running &&
operationState.kind === "repair" &&
operationState.target === "node"
}
installingGit={
operationState.running &&
operationState.kind === "repair" &&
operationState.target === "git"
}
cleaningTemp={cleaningTemp}
onInstall={() => void handleInstall()}
onInstallNode={() => void handleInstallNode()}
onInstallGit={() => void handleInstallGit()}
onRefresh={() => void refreshAll()}
onCleanupTemp={() => void handleCleanupTempArtifacts()}
onOpenDocs={() => void openUrl(OPENCLAW_DOCS_URL)}
onDownloadNode={() =>
void openclawApi
@@ -1120,13 +1241,17 @@ export function OpenClawPage({ pageParams, onNavigate }: OpenClawPageProps) {
return (
<OpenClawProgressPage
kind={
progressActionBySubpage[currentSubpage] ??
operationState.kind ??
progressActionBySubpage[currentSubpage] ??
"install"
}
title={operationState.title}
description={operationState.description}
handingOffToAgent={handingOffToAgent}
running={
operationState.running &&
operationState.kind === progressActionBySubpage[currentSubpage]
currentSubpage ===
progressSubpageByAction[operationState.kind ?? "install"]
}
message={operationState.message}
logs={installLogs}
@@ -1143,11 +1268,29 @@ export function OpenClawPage({ pageParams, onNavigate }: OpenClawPageProps) {
if (!installed) {
return (
<OpenClawInstallPage
binaryPath={installedStatus?.path}
nodeStatusText={formatNodeStatus(nodeStatus)}
gitStatusText={formatBinaryStatus(gitStatus, "可用", "未检测到 Git")}
installing={operationState.running && operationState.kind === "install"}
environmentStatus={environmentStatus}
busy={operationState.running}
installing={
operationState.running &&
operationState.kind === "install" &&
operationState.target === "openclaw"
}
installingNode={
operationState.running &&
operationState.kind === "repair" &&
operationState.target === "node"
}
installingGit={
operationState.running &&
operationState.kind === "repair" &&
operationState.target === "git"
}
cleaningTemp={cleaningTemp}
onInstall={() => void handleInstall()}
onInstallNode={() => void handleInstallNode()}
onInstallGit={() => void handleInstallGit()}
onRefresh={() => void refreshAll()}
onCleanupTemp={() => void handleCleanupTempArtifacts()}
onOpenDocs={() => void openUrl(OPENCLAW_DOCS_URL)}
onDownloadNode={() =>
void openclawApi
@@ -5,7 +5,10 @@ import { OpenClawMark } from "./OpenClawMark";
interface OpenClawProgressPageProps {
kind: OpenClawOperationKind;
title?: string | null;
description?: string | null;
running: boolean;
handingOffToAgent?: boolean;
message: string | null;
logs: OpenClawInstallProgressEvent[];
repairPrompt: string;
@@ -17,20 +20,25 @@ interface OpenClawProgressPageProps {
}
const titleMap: Record<OpenClawOperationKind, string> = {
install: "正在安装 OpenClaw",
install: "正在修复环境并安装 OpenClaw",
uninstall: "正在卸载 OpenClaw",
restart: "正在重启 Gateway",
repair: "正在修复 OpenClaw 环境",
};
const descriptionMap: Record<OpenClawOperationKind, string> = {
install: "正在安装 OpenClaw,请保持当前页面并等待安装完成。",
install: "正在准备 Node.js、Git 和 OpenClaw 环境,请保持当前页面并等待完成。",
uninstall: "正在卸载 OpenClaw,请等待进度完成后返回安装页。",
restart: "正在重启 Gateway,完成后将返回运行页。",
repair: "正在修复 OpenClaw 依赖环境,请保持当前页面并等待完成。",
};
export function OpenClawProgressPage({
kind,
title,
description,
running,
handingOffToAgent = false,
message,
logs,
repairPrompt,
@@ -46,10 +54,10 @@ export function OpenClawProgressPage({
<div className="flex flex-col items-center text-center">
<OpenClawMark size="lg" />
<h1 className="mt-6 text-4xl font-semibold tracking-tight">
{titleMap[kind]}
{title || titleMap[kind]}
</h1>
<p className="mt-3 max-w-2xl text-base leading-7 text-muted-foreground">
{descriptionMap[kind]}
{description || descriptionMap[kind]}
</p>
</div>
@@ -92,11 +100,15 @@ export function OpenClawProgressPage({
<button
type="button"
onClick={onAskAgentFix}
disabled={!repairPrompt.trim()}
disabled={!repairPrompt.trim() || handingOffToAgent}
className="inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm hover:bg-muted disabled:opacity-60"
>
<Bot className="h-4 w-4" />
交给 AI 修复
{handingOffToAgent ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Bot className="h-4 w-4" />
)}
{handingOffToAgent ? "转交中..." : "交给 AI 修复"}
</button>
<button
type="button"
+8 -1
View File
@@ -2,7 +2,11 @@ import type { OpenClawSubpage } from "@/types/page";
export type { OpenClawSubpage };
export type OpenClawOperationKind = "install" | "uninstall" | "restart";
export type OpenClawOperationKind =
| "install"
| "uninstall"
| "restart"
| "repair";
export type OpenClawScene = "setup" | "sync" | "dashboard";
export interface OpenClawLastSynced {
@@ -12,7 +16,10 @@ export interface OpenClawLastSynced {
export interface OpenClawOperationState {
kind: OpenClawOperationKind | null;
target: "openclaw" | "node" | "git" | "cleanup" | "environment" | null;
running: boolean;
title: string | null;
description: string | null;
message: string | null;
returnSubpage: OpenClawSubpage;
}
@@ -95,14 +95,14 @@ vi.mock("@/features/themes/video", () => ({
theme: "video",
capabilities: {
workspaceKind: "video-canvas",
showWorkspaceRightRailInWorkspace: false,
},
navigation: {
defaultView: "create",
items: [
{ key: "create", label: "创作" },
{ key: "material", label: "素材" },
{ key: "template", label: "排版" },
{ key: "publish", label: "发布" },
{ key: "publish", label: "任务" },
{ key: "settings", label: "设置" },
],
},
@@ -122,8 +122,7 @@ vi.mock("@/features/themes/video", () => ({
),
panelRenderers: {
material: () => <div>Material Panel</div>,
template: () => <div>Template Panel</div>,
publish: () => <div>Publish Panel</div>,
publish: () => <div>Task Panel</div>,
settings: () => <div>Settings Panel</div>,
},
},
@@ -1184,7 +1183,30 @@ describe("WorkbenchPage 左侧栏模式行为", () => {
expect(
container.querySelector("[data-testid='workbench-right-rail-expanded']"),
).not.toBeNull();
expect(container.textContent).toContain("短视频 · 引导模式");
expect(container.textContent).toContain("视频制作 · 引导模式");
expect(findInputByPlaceholder(container, "搜索文稿...")).toBeNull();
});
it("视频主题在作业模式不应渲染右侧视频助手栏", async () => {
mockListProjects.mockResolvedValueOnce([
createWorkspaceProjectFixture({
id: "video-project-2",
name: "视频项目B",
workspaceType: "video",
rootPath: "/tmp/workspace/video-project-2",
}),
]);
const { container } = renderPage({
theme: "video",
viewMode: "workspace",
projectId: "video-project-2",
});
await flushEffects();
expect(
container.querySelector("[data-testid='workbench-right-rail-expanded']"),
).toBeNull();
});
it("切换到非创作视图时左侧显示紧凑提示并可返回创作视图", async () => {
+6 -1
View File
@@ -138,6 +138,9 @@ export function WorkbenchPage({
const selectedContentCreationType = selectedContentId
? contentCreationTypes[selectedContentId]
: undefined;
const shouldHideVideoSidebarInWorkspace =
themeModule.capabilities.workspaceKind === "video-canvas" &&
workspaceMode === "workspace";
return (
<div className="flex flex-col h-full min-h-0">
@@ -159,7 +162,9 @@ export function WorkbenchPage({
}
leftSidebar={
<WorkbenchLeftSidebar
shouldRender={shouldRenderLeftSidebar}
shouldRender={
shouldRenderLeftSidebar && !shouldHideVideoSidebarInWorkspace
}
leftSidebarCollapsed={leftSidebarCollapsed}
theme={theme as ProjectType}
projectsLoading={projectsLoading}
@@ -18,6 +18,7 @@ import type {
NovelQuickCreateOptions,
NovelQuickCreateResult,
OpenProjectWritingOptions,
ThemeWorkspaceView,
} from "@/features/themes/types";
import type { CreationMode } from "@/components/content-creator/types";
import type { WorkflowProgressSnapshot } from "@/components/agent/chat";
@@ -197,8 +198,9 @@ export function useWorkbenchController({
const isAgentChatWorkspace =
themeModule.capabilities.workspaceKind === "agent-chat";
const shouldRenderWorkspaceRightRailInWorkspace =
themeModule.capabilities.workspaceKind === "video-canvas" ||
(isAgentChatWorkspace && !PrimaryWorkspaceRenderer);
themeModule.capabilities.showWorkspaceRightRailInWorkspace ??
(themeModule.capabilities.workspaceKind === "video-canvas" ||
(isAgentChatWorkspace && !PrimaryWorkspaceRenderer));
const {
projects,
@@ -508,6 +510,17 @@ export function useWorkbenchController({
activeWorkspaceView,
panelRenderers,
});
const workspaceViewLabels = useMemo(
() =>
themeModule.navigation.items.reduce<Partial<Record<ThemeWorkspaceView, string>>>(
(labels, item) => {
labels[item.key] = item.label;
return labels;
},
{},
),
[themeModule.navigation.items],
);
const { nonCreateQuickActions } = useWorkbenchQuickActions({
workspaceMode,
@@ -515,6 +528,7 @@ export function useWorkbenchController({
hasWorkflowWorkspaceView,
hasPublishWorkspaceView,
hasSettingsWorkspaceView,
workspaceViewLabels,
selectedContentId,
onSwitchWorkspaceView: handleSwitchWorkspaceView,
onQuickSaveCurrent: handleQuickSaveCurrent,
@@ -45,6 +45,12 @@ function createHarnessProps(
hasWorkflowWorkspaceView: true,
hasPublishWorkspaceView: true,
hasSettingsWorkspaceView: true,
workspaceViewLabels: {
create: "创作",
workflow: "流程",
publish: "发布",
settings: "设置",
},
selectedContentId: "content-1",
onSwitchWorkspaceView: vi.fn(),
onQuickSaveCurrent: vi.fn(),
@@ -143,4 +149,20 @@ describe("useWorkbenchQuickActions", () => {
expect(labels).toContain("前往发布视图");
expect(labels).not.toContain("快速保存当前文稿");
});
it("应优先使用主题导航中的视图标签", () => {
const { container } = renderHarness({
activeWorkspaceView: "material",
workspaceViewLabels: {
create: "创作",
publish: "任务",
settings: "设置",
},
});
const labels =
container.querySelector("[data-testid='action-labels']")?.textContent ?? "";
expect(labels).toContain("返回创作视图");
expect(labels).toContain("前往任务视图");
});
});
@@ -10,6 +10,7 @@ export interface UseWorkbenchQuickActionsParams {
hasWorkflowWorkspaceView: boolean;
hasPublishWorkspaceView: boolean;
hasSettingsWorkspaceView: boolean;
workspaceViewLabels?: Partial<Record<ThemeWorkspaceView, string>>;
selectedContentId: string | null;
onSwitchWorkspaceView: (view: ThemeWorkspaceView) => void;
onQuickSaveCurrent: () => Promise<void> | void;
@@ -21,6 +22,7 @@ export function useWorkbenchQuickActions({
hasWorkflowWorkspaceView,
hasPublishWorkspaceView,
hasSettingsWorkspaceView,
workspaceViewLabels,
selectedContentId,
onSwitchWorkspaceView,
onQuickSaveCurrent,
@@ -33,7 +35,7 @@ export function useWorkbenchQuickActions({
const actions: WorkbenchQuickAction[] = [
{
key: "to-create",
label: "返回创作视图",
label: `返回${workspaceViewLabels?.create ?? "创作"}视图`,
icon: Bot,
onClick: () => onSwitchWorkspaceView("create"),
},
@@ -42,7 +44,7 @@ export function useWorkbenchQuickActions({
if (hasWorkflowWorkspaceView && activeWorkspaceView !== "workflow") {
actions.push({
key: "to-workflow",
label: "前往流程视图",
label: `前往${workspaceViewLabels?.workflow ?? "流程"}视图`,
icon: Sparkles,
onClick: () => onSwitchWorkspaceView("workflow"),
});
@@ -51,7 +53,7 @@ export function useWorkbenchQuickActions({
if (hasPublishWorkspaceView && activeWorkspaceView !== "publish") {
actions.push({
key: "to-publish",
label: "前往发布视图",
label: `前往${workspaceViewLabels?.publish ?? "发布"}视图`,
icon: Send,
onClick: () => onSwitchWorkspaceView("publish"),
});
@@ -60,7 +62,7 @@ export function useWorkbenchQuickActions({
if (hasSettingsWorkspaceView && activeWorkspaceView !== "settings") {
actions.push({
key: "to-settings",
label: "前往设置视图",
label: `前往${workspaceViewLabels?.settings ?? "设置"}视图`,
icon: Wrench,
onClick: () => onSwitchWorkspaceView("settings"),
});
@@ -86,6 +88,7 @@ export function useWorkbenchQuickActions({
onQuickSaveCurrent,
onSwitchWorkspaceView,
selectedContentId,
workspaceViewLabels,
workspaceMode,
]);
@@ -47,6 +47,26 @@ describe("WorkbenchRightRail", () => {
expect(container.querySelector("[data-testid='workbench-right-rail-expanded']")).not.toBeNull();
});
it("视频主题右侧栏不再默认显示项目风格策略", () => {
const { container } = mountHarness(
WorkbenchRightRail,
{
shouldRender: true,
isCreateWorkspaceView: true,
projectId: "project-1",
theme: "video",
onBackToCreateView: vi.fn(),
onCreateContentFromPrompt: vi.fn(),
},
mountedRoots,
);
expect(container.textContent).toContain("视频助手");
expect(container.textContent).not.toContain("风格策略");
expect(container.textContent).not.toContain("编辑项目风格");
expect(container.textContent).not.toContain("生成的素材输出将保存在此处");
});
it("存在评审状态时应切换为评审专家团面板,关闭后恢复能力面板", () => {
const closeSpy = vi.fn();
@@ -85,6 +85,7 @@ export function WorkbenchRightRail({
sections={capabilitySections}
heading={railHeading}
subheading={railSubheading}
theme={theme}
onCollapse={() => setCollapsed(true)}
projectId={projectId}
onCreateContentFromPrompt={onCreateContentFromPrompt}
@@ -8,6 +8,7 @@ import {
} from "./workbenchRightRailExpandedChrome";
import { useWorkbenchRightRailCapabilityController } from "./useWorkbenchRightRailCapabilityController";
import type { WorkbenchRightRailCapabilitySection } from "./workbenchRightRailTypes";
import type { WorkspaceTheme } from "@/types/page";
export function WorkbenchRightRailExpandedPanel({
onCollapse,
@@ -22,6 +23,7 @@ export function WorkbenchRightRailExpandedPanel({
sections,
heading,
subheading,
theme,
}: {
onCollapse: () => void;
projectId?: string | null;
@@ -35,6 +37,7 @@ export function WorkbenchRightRailExpandedPanel({
sections: WorkbenchRightRailCapabilitySection[];
heading?: string | null;
subheading?: string | null;
theme?: WorkspaceTheme;
}) {
const controller = useWorkbenchRightRailCapabilityController({
projectId,
@@ -56,19 +59,23 @@ export function WorkbenchRightRailExpandedPanel({
<div className="flex flex-1 min-h-0 flex-col gap-4 overflow-y-auto px-3 py-3">
<WorkbenchRightRailHeadingCard
eyebrow={theme === "video" ? "视频助手" : undefined}
heading={heading}
subheading={subheading}
/>
<WorkbenchRightRailStyleGuideCard
projectId={projectId}
onOpen={() => {
controller.setStyleGuideSourceEntryId(null);
controller.handleStyleGuideDialogOpenChange(true);
}}
/>
{theme === "video" ? null : (
<WorkbenchRightRailStyleGuideCard
projectId={projectId}
onOpen={() => {
controller.setStyleGuideSourceEntryId(null);
controller.handleStyleGuideDialogOpenChange(true);
}}
/>
)}
<WorkbenchRightRailActionSections
sections={sections}
controller={controller}
theme={theme}
/>
</div>
@@ -1,5 +1,6 @@
import { Sparkles } from "lucide-react";
import { cn } from "@/lib/utils";
import type { WorkspaceTheme } from "@/types/page";
import { GeneratedOutputsPanel } from "./workbenchRightRailGeneratedOutputs";
import {
GenerateBgmPanel,
@@ -266,10 +267,15 @@ function renderExpandedActionPanel(
export function WorkbenchRightRailActionSections({
sections,
controller,
theme,
}: {
sections: WorkbenchRightRailCapabilitySection[];
controller: WorkbenchRightRailCapabilityController;
theme?: WorkspaceTheme;
}) {
const shouldRenderGeneratedOutputs =
theme !== "video" || controller.generatedOutputs.length > 0;
return (
<>
{sections.map((section) => {
@@ -323,7 +329,9 @@ export function WorkbenchRightRailActionSections({
);
})}
<GeneratedOutputsPanel items={controller.generatedOutputs} />
{shouldRenderGeneratedOutputs ? (
<GeneratedOutputsPanel items={controller.generatedOutputs} />
) : null}
</>
);
}
@@ -44,9 +44,11 @@ export function WorkbenchRightRailCollapseBar({
}
export function WorkbenchRightRailHeadingCard({
eyebrow,
heading,
subheading,
}: {
eyebrow?: string;
heading?: string | null;
subheading?: string | null;
}) {
@@ -57,7 +59,7 @@ export function WorkbenchRightRailHeadingCard({
return (
<div className="rounded-xl border border-border/70 bg-muted/35 px-3 py-2">
<div className="text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
独立右栏
{eyebrow ?? "独立右栏"}
</div>
<div className="mt-1 text-sm font-medium text-foreground">{heading}</div>
{subheading ? (
@@ -36,21 +36,21 @@ const VIDEO_CAPABILITY_SECTIONS_BY_CREATION_MODE: Record<
guided: [
{
key: "video-script",
title: "脚本策划",
title: "前期准备",
tone: "violet",
items: [
{ key: "search-material", label: "搜灵感", icon: Search, tone: "violet" },
{ key: "generate-title", label: "起标题", icon: Type, tone: "violet" },
{ key: "generate-storyboard", label: "拆分镜", icon: Film, tone: "violet" },
{ key: "search-material", label: "找参考", icon: Search, tone: "violet" },
{ key: "generate-title", label: "开场钩子", icon: Type, tone: "violet" },
{ key: "generate-storyboard", label: "分镜草案", icon: Film, tone: "violet" },
],
},
{
key: "video-visual",
title: "画面制作",
title: "画面生成",
tone: "blue",
items: [
{ key: "generate-video-assets", label: "做素材", icon: Clapperboard, tone: "blue" },
{ key: "generate-ai-video", label: "生成视频", icon: Video, tone: "blue" },
{ key: "generate-video-assets", label: "补素材", icon: Clapperboard, tone: "blue" },
{ key: "generate-ai-video", label: "开始生成", icon: Video, tone: "blue" },
],
},
{
@@ -67,11 +67,11 @@ const VIDEO_CAPABILITY_SECTIONS_BY_CREATION_MODE: Record<
fast: [
{
key: "video-fast-plan",
title: "快速成稿",
title: "快速准备",
tone: "violet",
items: [
{ key: "generate-title", label: "起标题", icon: Type, tone: "violet" },
{ key: "generate-storyboard", label: "生成分镜", icon: Film, tone: "violet" },
{ key: "generate-title", label: "开场钩子", icon: Type, tone: "violet" },
{ key: "generate-storyboard", label: "分镜草案", icon: Film, tone: "violet" },
],
},
{
@@ -79,7 +79,7 @@ const VIDEO_CAPABILITY_SECTIONS_BY_CREATION_MODE: Record<
title: "快速出片",
tone: "blue",
items: [
{ key: "generate-ai-video", label: "生成视频", icon: Video, tone: "blue" },
{ key: "generate-ai-video", label: "开始生成", icon: Video, tone: "blue" },
{ key: "generate-video-assets", label: "补素材", icon: Clapperboard, tone: "blue" },
],
},
@@ -96,21 +96,21 @@ const VIDEO_CAPABILITY_SECTIONS_BY_CREATION_MODE: Record<
hybrid: [
{
key: "video-hybrid-plan",
title: "协同策划",
title: "协同准备",
tone: "violet",
items: [
{ key: "search-material", label: "搜灵感", icon: Search, tone: "violet" },
{ key: "generate-title", label: "起标题", icon: Type, tone: "violet" },
{ key: "generate-storyboard", label: "生成分镜", icon: Film, tone: "violet" },
{ key: "search-material", label: "找参考", icon: Search, tone: "violet" },
{ key: "generate-title", label: "开场钩子", icon: Type, tone: "violet" },
{ key: "generate-storyboard", label: "分镜草案", icon: Film, tone: "violet" },
],
},
{
key: "video-hybrid-visual",
title: "画面协作",
title: "画面补全",
tone: "blue",
items: [
{ key: "generate-video-assets", label: "做素材", icon: Clapperboard, tone: "blue" },
{ key: "generate-ai-video", label: "生成视频", icon: Video, tone: "blue" },
{ key: "generate-video-assets", label: "补素材", icon: Clapperboard, tone: "blue" },
{ key: "generate-ai-video", label: "开始生成", icon: Video, tone: "blue" },
],
},
{
@@ -130,8 +130,8 @@ const VIDEO_CAPABILITY_SECTIONS_BY_CREATION_MODE: Record<
title: "结构搭建",
tone: "violet",
items: [
{ key: "generate-title", label: "起标题", icon: Type, tone: "violet" },
{ key: "generate-storyboard", label: "生成分镜", icon: Film, tone: "violet" },
{ key: "generate-title", label: "开场钩子", icon: Type, tone: "violet" },
{ key: "generate-storyboard", label: "分镜草案", icon: Film, tone: "violet" },
],
},
{
@@ -139,8 +139,8 @@ const VIDEO_CAPABILITY_SECTIONS_BY_CREATION_MODE: Record<
title: "执行制作",
tone: "blue",
items: [
{ key: "generate-video-assets", label: "视频素材", icon: Clapperboard, tone: "blue" },
{ key: "generate-ai-video", label: "生成视频", icon: Video, tone: "blue" },
{ key: "generate-video-assets", label: "补素材", icon: Clapperboard, tone: "blue" },
{ key: "generate-ai-video", label: "开始生成", icon: Video, tone: "blue" },
],
},
{
@@ -167,7 +167,7 @@ const VIDEO_CAPABILITY_SECTIONS_BY_TYPE: Record<
tone: "violet",
items: [
{ key: "search-material", label: "找选题", icon: Search, tone: "violet" },
{ key: "generate-title", label: "起标题", icon: Type, tone: "violet" },
{ key: "generate-title", label: "开场钩子", icon: Type, tone: "violet" },
{ key: "generate-storyboard", label: "拆口播分镜", icon: Film, tone: "violet" },
],
},
@@ -198,7 +198,7 @@ const VIDEO_CAPABILITY_SECTIONS_BY_TYPE: Record<
title: "剧情脚本",
tone: "violet",
items: [
{ key: "generate-title", label: "故事标题", icon: Type, tone: "violet" },
{ key: "generate-title", label: "故事卖点", icon: Type, tone: "violet" },
{ key: "generate-storyboard", label: "剧情分镜", icon: Film, tone: "violet" },
],
},
@@ -229,7 +229,7 @@ const VIDEO_CAPABILITY_SECTIONS_BY_TYPE: Record<
tone: "violet",
items: [
{ key: "search-material", label: "查资料", icon: FileSearch, tone: "violet" },
{ key: "generate-title", label: "起标题", icon: Type, tone: "violet" },
{ key: "generate-title", label: "测评钩子", icon: Type, tone: "violet" },
{ key: "generate-storyboard", label: "对比分镜", icon: Film, tone: "violet" },
],
},
@@ -260,7 +260,7 @@ const VIDEO_CAPABILITY_SECTIONS_BY_TYPE: Record<
tone: "violet",
items: [
{ key: "search-material", label: "找亮点", icon: Search, tone: "violet" },
{ key: "generate-title", label: "店铺标题", icon: Type, tone: "violet" },
{ key: "generate-title", label: "探店钩子", icon: Type, tone: "violet" },
{ key: "generate-storyboard", label: "动线分镜", icon: Film, tone: "violet" },
],
},
@@ -292,7 +292,7 @@ const VIDEO_CAPABILITY_SECTIONS_BY_TYPE: Record<
tone: "violet",
items: [
{ key: "search-material", label: "查步骤", icon: Search, tone: "violet" },
{ key: "generate-title", label: "教程标题", icon: Type, tone: "violet" },
{ key: "generate-title", label: "教程开场", icon: Type, tone: "violet" },
{ key: "generate-storyboard", label: "步骤分镜", icon: Film, tone: "violet" },
],
},
@@ -358,9 +358,9 @@ export function getVideoWorkbenchRightRailHeading(
): string {
const normalizedType = creationType?.trim();
if (normalizedType) {
return `短视频 · ${normalizedType}`;
return `视频制作 · ${normalizedType}`;
}
return `短视频 · ${VIDEO_CREATION_MODE_LABELS[creationMode]}`;
return `视频制作 · ${VIDEO_CREATION_MODE_LABELS[creationMode]}`;
}
export function getVideoWorkbenchRightRailSubheading(
+1
View File
@@ -40,6 +40,7 @@ export interface OpenProjectWritingOptions {
export interface ThemeCapabilities {
workspaceKind: ThemeWorkspaceKind;
workspaceNotice?: ThemeWorkspaceNotice;
showWorkspaceRightRailInWorkspace?: boolean;
}
export interface ThemeWorkspaceRendererProps {
+4 -9
View File
@@ -2,25 +2,22 @@ import type { ThemeModule } from "@/features/themes/types";
import { VideoThemeWorkspace } from "@/features/themes/video/VideoThemeWorkspace";
import {
DefaultMaterialPanel,
DefaultPublishPanel,
DefaultSettingsPanel,
DefaultStylePanel,
DefaultTemplatePanel,
} from "@/features/themes/shared/panelRenderers";
import { VideoTasksPanel } from "@/features/themes/video/panelRenderers";
export const videoThemeModule: ThemeModule = {
theme: "video",
capabilities: {
workspaceKind: "video-canvas",
showWorkspaceRightRailInWorkspace: false,
},
navigation: {
defaultView: "create",
items: [
{ key: "create", label: "创作" },
{ key: "material", label: "素材" },
{ key: "template", label: "排版" },
{ key: "style", label: "风格" },
{ key: "publish", label: "发布" },
{ key: "publish", label: "任务" },
{ key: "settings", label: "设置" },
],
},
@@ -28,9 +25,7 @@ export const videoThemeModule: ThemeModule = {
workspaceRenderer: VideoThemeWorkspace,
panelRenderers: {
material: DefaultMaterialPanel,
template: DefaultTemplatePanel,
style: DefaultStylePanel,
publish: DefaultPublishPanel,
publish: VideoTasksPanel,
settings: DefaultSettingsPanel,
},
};
@@ -0,0 +1,322 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { ExternalLink, RefreshCw, Square } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import type { ThemeWorkspaceRendererProps } from "@/features/themes/types";
import {
videoGenerationApi,
type VideoGenerationTask,
type VideoTaskStatus,
} from "@/lib/api/videoGeneration";
const STATUS_META: Record<
VideoTaskStatus,
{ label: string; className: string }
> = {
pending: {
label: "排队中",
className: "border-amber-200 bg-amber-50 text-amber-700",
},
processing: {
label: "生成中",
className: "border-blue-200 bg-blue-50 text-blue-700",
},
success: {
label: "已完成",
className: "border-emerald-200 bg-emerald-50 text-emerald-700",
},
error: {
label: "失败",
className: "border-red-200 bg-red-50 text-red-700",
},
cancelled: {
label: "已取消",
className: "border-slate-200 bg-slate-100 text-slate-700",
},
};
function formatTime(timestamp?: number): string {
if (!timestamp) {
return "—";
}
return new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
}).format(timestamp);
}
function summarizeTasks(tasks: VideoGenerationTask[]) {
return tasks.reduce(
(summary, task) => {
summary.total += 1;
if (task.status === "pending" || task.status === "processing") {
summary.running += 1;
} else if (task.status === "success") {
summary.success += 1;
} else if (task.status === "error") {
summary.error += 1;
}
return summary;
},
{ total: 0, running: 0, success: 0, error: 0 },
);
}
export function VideoTasksPanel({
projectId,
}: ThemeWorkspaceRendererProps) {
const [tasks, setTasks] = useState<VideoGenerationTask[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [cancellingTaskId, setCancellingTaskId] = useState<string | null>(null);
const loadTasks = useCallback(
async (options?: { silent?: boolean }) => {
if (!projectId) {
setTasks([]);
setLoading(false);
return;
}
const silent = options?.silent ?? false;
if (silent) {
setRefreshing(true);
} else {
setLoading(true);
}
try {
const taskList = await videoGenerationApi.listTasks(projectId, {
limit: 50,
});
setTasks(
[...taskList].sort((left, right) => right.createdAt - left.createdAt),
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
toast.error(`加载视频任务失败:${message}`);
} finally {
setLoading(false);
setRefreshing(false);
}
},
[projectId],
);
useEffect(() => {
void loadTasks();
}, [loadTasks]);
const hasRunningTask = useMemo(
() =>
tasks.some(
(task) => task.status === "pending" || task.status === "processing",
),
[tasks],
);
useEffect(() => {
if (!hasRunningTask) {
return;
}
const timer = window.setInterval(() => {
void loadTasks({ silent: true });
}, 3000);
return () => {
window.clearInterval(timer);
};
}, [hasRunningTask, loadTasks]);
const summary = useMemo(() => summarizeTasks(tasks), [tasks]);
const handleCancelTask = useCallback(
async (taskId: string) => {
setCancellingTaskId(taskId);
try {
await videoGenerationApi.cancelTask(taskId);
toast.success("任务已取消");
await loadTasks({ silent: true });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
toast.error(`取消任务失败:${message}`);
} finally {
setCancellingTaskId(null);
}
},
[loadTasks],
);
if (!projectId) {
return null;
}
return (
<div className="flex-1 min-h-0 overflow-y-auto p-4">
<div className="mx-auto flex max-w-5xl flex-col gap-4">
<Card>
<CardHeader className="flex flex-row items-start justify-between space-y-0">
<div className="space-y-1">
<CardTitle className="text-xl">视频任务</CardTitle>
<CardDescription>
集中查看当前项目的视频生成进度、结果与异常状态。
</CardDescription>
</div>
<Button
variant="outline"
size="sm"
onClick={() => {
void loadTasks({ silent: true });
}}
disabled={refreshing}
>
<RefreshCw
className={`mr-2 h-4 w-4 ${refreshing ? "animate-spin" : ""}`}
/>
刷新
</Button>
</CardHeader>
<CardContent className="grid gap-3 sm:grid-cols-4">
<div className="rounded-xl border bg-slate-50 p-3">
<div className="text-xs text-muted-foreground">总任务</div>
<div className="mt-1 text-2xl font-semibold">{summary.total}</div>
</div>
<div className="rounded-xl border bg-blue-50 p-3">
<div className="text-xs text-muted-foreground">进行中</div>
<div className="mt-1 text-2xl font-semibold">{summary.running}</div>
</div>
<div className="rounded-xl border bg-emerald-50 p-3">
<div className="text-xs text-muted-foreground">已完成</div>
<div className="mt-1 text-2xl font-semibold">{summary.success}</div>
</div>
<div className="rounded-xl border bg-red-50 p-3">
<div className="text-xs text-muted-foreground">失败</div>
<div className="mt-1 text-2xl font-semibold">{summary.error}</div>
</div>
</CardContent>
</Card>
{loading ? (
<Card>
<CardContent className="py-10 text-sm text-muted-foreground">
正在加载视频任务...
</CardContent>
</Card>
) : tasks.length === 0 ? (
<Card>
<CardContent className="py-10 text-center">
<div className="text-base font-medium text-foreground">
暂无视频任务
</div>
<div className="mt-2 text-sm text-muted-foreground">
请先回到「创作」视图提交视频生成,结果会自动沉淀到这里。
</div>
</CardContent>
</Card>
) : (
tasks.map((task) => {
const meta = STATUS_META[task.status];
const canCancel =
task.status === "pending" || task.status === "processing";
return (
<Card key={task.id} data-testid={`video-task-${task.id}`}>
<CardHeader className="gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<Badge
variant="outline"
className={meta.className}
>
{meta.label}
</Badge>
<span className="text-sm font-medium text-foreground">
{task.providerId} · {task.model}
</span>
</div>
<CardDescription className="max-w-3xl whitespace-pre-wrap break-words text-sm leading-6 text-foreground">
{task.prompt || "未提供提示词"}
</CardDescription>
</div>
<div className="flex flex-wrap items-center gap-2">
{task.resultUrl ? (
<a
href={task.resultUrl}
target="_blank"
rel="noreferrer"
className="inline-flex"
>
<Button variant="outline" size="sm">
<ExternalLink className="mr-2 h-4 w-4" />
查看结果
</Button>
</a>
) : null}
{canCancel ? (
<Button
variant="outline"
size="sm"
onClick={() => {
void handleCancelTask(task.id);
}}
disabled={cancellingTaskId === task.id}
>
<Square className="mr-2 h-3.5 w-3.5" />
取消任务
</Button>
) : null}
</div>
</CardHeader>
<CardContent className="grid gap-3 text-sm text-muted-foreground sm:grid-cols-4">
<div>
<div className="text-xs">创建时间</div>
<div className="mt-1 text-foreground">
{formatTime(task.createdAt)}
</div>
</div>
<div>
<div className="text-xs">更新时间</div>
<div className="mt-1 text-foreground">
{formatTime(task.updatedAt)}
</div>
</div>
<div>
<div className="text-xs">完成时间</div>
<div className="mt-1 text-foreground">
{formatTime(task.finishedAt)}
</div>
</div>
<div>
<div className="text-xs">任务 ID</div>
<div className="mt-1 break-all font-mono text-xs text-foreground">
{task.id}
</div>
</div>
{task.errorMessage ? (
<div className="sm:col-span-4">
<div className="text-xs text-red-600">失败原因</div>
<div className="mt-1 whitespace-pre-wrap break-words text-red-600">
{task.errorMessage}
</div>
</div>
) : null}
</CardContent>
</Card>
);
})
)}
</div>
</div>
);
}
+34
View File
@@ -22,6 +22,23 @@ export interface OpenClawNodeCheckResult {
path?: string | null;
}
export interface OpenClawDependencyStatus {
status: "ok" | "missing" | "version_low" | string;
version?: string | null;
path?: string | null;
message: string;
autoInstallSupported: boolean;
}
export interface OpenClawEnvironmentStatus {
node: OpenClawDependencyStatus;
git: OpenClawDependencyStatus;
openclaw: OpenClawDependencyStatus;
recommendedAction: string;
summary: string;
tempArtifacts: string[];
}
export interface OpenClawActionResult {
success: boolean;
message: string;
@@ -74,6 +91,10 @@ export async function openclawCheckInstalled(): Promise<OpenClawBinaryInstallSta
return safeInvoke("openclaw_check_installed");
}
export async function openclawGetEnvironmentStatus(): Promise<OpenClawEnvironmentStatus> {
return safeInvoke("openclaw_get_environment_status");
}
export async function openclawCheckNodeVersion(): Promise<OpenClawNodeCheckResult> {
return safeInvoke("openclaw_check_node_version");
}
@@ -94,6 +115,12 @@ export async function openclawInstall(): Promise<OpenClawActionResult> {
return safeInvoke("openclaw_install");
}
export async function openclawInstallDependency(
kind: "node" | "git",
): Promise<OpenClawActionResult> {
return safeInvoke("openclaw_install_dependency", { kind });
}
export async function openclawGetCommandPreview(
operation: "install" | "uninstall" | "start" | "stop" | "restart",
port?: number,
@@ -105,6 +132,10 @@ export async function openclawUninstall(): Promise<OpenClawActionResult> {
return safeInvoke("openclaw_uninstall");
}
export async function openclawCleanupTempArtifacts(): Promise<OpenClawActionResult> {
return safeInvoke("openclaw_cleanup_temp_artifacts");
}
export async function openclawStartGateway(
port?: number,
): Promise<OpenClawActionResult> {
@@ -158,13 +189,16 @@ export async function openclawGetProgressLogs(): Promise<
export const openclawApi = {
checkInstalled: openclawCheckInstalled,
getEnvironmentStatus: openclawGetEnvironmentStatus,
checkNodeVersion: openclawCheckNodeVersion,
checkGitAvailable: openclawCheckGitAvailable,
getNodeDownloadUrl: openclawGetNodeDownloadUrl,
getGitDownloadUrl: openclawGetGitDownloadUrl,
install: openclawInstall,
installDependency: openclawInstallDependency,
getCommandPreview: openclawGetCommandPreview,
uninstall: openclawUninstall,
cleanupTempArtifacts: openclawCleanupTempArtifacts,
startGateway: openclawStartGateway,
stopGateway: openclawStopGateway,
restartGateway: openclawRestartGateway,
+4
View File
@@ -137,6 +137,10 @@ export interface AgentPageParams {
contentId?: string;
/** 进入 Agent 时自动发送的首条用户消息 */
initialUserPrompt?: string;
/** 进入 Agent 时优先创建的话题名称 */
initialSessionName?: string;
/** 一次性入口提示文案 */
entryBannerMessage?: string;
/** 首屏主题(用于左侧导航直达创作主题) */
theme?: string;
/** 是否锁定主题(锁定后不在首屏显示主题切换) */