mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
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:
+8
-3
@@ -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}"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -40,6 +40,7 @@ export interface OpenProjectWritingOptions {
|
||||
export interface ThemeCapabilities {
|
||||
workspaceKind: ThemeWorkspaceKind;
|
||||
workspaceNotice?: ThemeWorkspaceNotice;
|
||||
showWorkspaceRightRailInWorkspace?: boolean;
|
||||
}
|
||||
|
||||
export interface ThemeWorkspaceRendererProps {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -137,6 +137,10 @@ export interface AgentPageParams {
|
||||
contentId?: string;
|
||||
/** 进入 Agent 时自动发送的首条用户消息 */
|
||||
initialUserPrompt?: string;
|
||||
/** 进入 Agent 时优先创建的话题名称 */
|
||||
initialSessionName?: string;
|
||||
/** 一次性入口提示文案 */
|
||||
entryBannerMessage?: string;
|
||||
/** 首屏主题(用于左侧导航直达创作主题) */
|
||||
theme?: string;
|
||||
/** 是否锁定主题(锁定后不在首屏显示主题切换) */
|
||||
|
||||
Reference in New Issue
Block a user