diff --git a/soloncode-cli/src/main/java/org/noear/solon/codecli/portal/desktop/DesktopStreamHub.java b/soloncode-cli/src/main/java/org/noear/solon/codecli/portal/desktop/DesktopStreamHub.java new file mode 100644 index 0000000000000000000000000000000000000000..af158149ce411e524a3fe031898eac01144f4f16 --- /dev/null +++ b/soloncode-cli/src/main/java/org/noear/solon/codecli/portal/desktop/DesktopStreamHub.java @@ -0,0 +1,174 @@ +/* + * Copyright 2017-2026 noear.org and authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + */ +package org.noear.solon.codecli.portal.desktop; + +import org.noear.snack4.ONode; +import org.noear.solon.net.websocket.WebSocket; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 桌面端流式消息中转站。 + * + *

Agent 运行不再绑定首次建立的 WebSocket。每个会话保留一个有界增量缓冲区, + * 新连接可以从最后确认的 sequence 继续回放,再无缝接收实时消息。

+ */ +final class DesktopStreamHub { + private static final Logger LOG = LoggerFactory.getLogger(DesktopStreamHub.class); + private static final int MAX_REPLAY_MESSAGES = 4096; + private static final long COMPLETED_TTL_MILLIS = 5 * 60 * 1000L; + + private final Map streams = new ConcurrentHashMap<>(); + + void begin(String sessionId, WebSocket socket) { + cleanupExpired(); + StreamState next = new StreamState(sessionId); + next.addSubscriber(socket); + streams.put(sessionId, next); + } + + boolean attach(String sessionId, WebSocket socket, long afterSequence) { + cleanupExpired(); + StreamState state = streams.get(sessionId); + if (state == null) { + return false; + } + return state.attachAndReplay(socket, Math.max(0L, afterSequence)); + } + + void subscribe(String sessionId, WebSocket socket) { + StreamState state = streams.get(sessionId); + if (state == null) { + state = new StreamState(sessionId); + streams.put(sessionId, state); + } + state.addSubscriber(socket); + } + + void detach(WebSocket socket) { + if (socket == null) { + return; + } + for (StreamState state : streams.values()) { + state.removeSubscriber(socket); + } + } + + boolean emit(String sessionId, String json) { + if (json == null || json.isEmpty()) { + return false; + } + StreamState state = streams.get(sessionId); + if (state == null) { + LOG.debug("[DesktopStreamHub] Ignore chunk without stream state: {}", sessionId); + return false; + } + state.emit(json); + return true; + } + + private void cleanupExpired() { + long now = System.currentTimeMillis(); + streams.entrySet().removeIf(entry -> entry.getValue().isExpired(now)); + } + + private static final class StreamState { + private final String sessionId; + private final Set subscribers = new HashSet<>(); + private final ArrayDeque replay = new ArrayDeque<>(); + private long nextSequence = 1L; + private long completedAt; + + private StreamState(String sessionId) { + this.sessionId = sessionId; + } + + synchronized void addSubscriber(WebSocket socket) { + if (socket != null) { + subscribers.add(socket); + } + } + + synchronized boolean attachAndReplay(WebSocket socket, long afterSequence) { + if (socket == null) { + return false; + } + ReplayMessage oldest = replay.peekFirst(); + if (oldest != null && oldest.sequence > afterSequence + 1L) { + LOG.warn("[DesktopStreamHub] Replay gap for session {}: requested after {}, oldest is {}", + sessionId, afterSequence, oldest.sequence); + return false; + } + subscribers.add(socket); + for (ReplayMessage message : replay) { + if (message.sequence > afterSequence && !send(socket, message.json)) { + subscribers.remove(socket); + return false; + } + } + return true; + } + + synchronized void removeSubscriber(WebSocket socket) { + subscribers.remove(socket); + } + + synchronized void emit(String json) { + long sequence = nextSequence++; + ONode node = ONode.ofJson(json).set("sequence", sequence); + String sequencedJson = node.toJson(); + replay.addLast(new ReplayMessage(sequence, sequencedJson)); + while (replay.size() > MAX_REPLAY_MESSAGES) { + replay.removeFirst(); + } + + String type = node.get("type") == null ? null : node.get("type").getString(); + if ("done".equalsIgnoreCase(type) || "error".equalsIgnoreCase(type)) { + completedAt = System.currentTimeMillis(); + } + + List failed = new ArrayList<>(); + for (WebSocket socket : subscribers) { + if (!send(socket, sequencedJson)) { + failed.add(socket); + } + } + subscribers.removeAll(failed); + } + + synchronized boolean isExpired(long now) { + return completedAt > 0L && now - completedAt >= COMPLETED_TTL_MILLIS; + } + + private boolean send(WebSocket socket, String json) { + try { + socket.send(json); + return true; + } catch (Throwable error) { + LOG.debug("[DesktopStreamHub] Send failed for session {}: {}", sessionId, error.getMessage()); + return false; + } + } + } + + private static final class ReplayMessage { + private final long sequence; + private final String json; + + private ReplayMessage(long sequence, String json) { + this.sequence = sequence; + this.json = json; + } + } +} diff --git a/soloncode-cli/src/main/java/org/noear/solon/codecli/portal/desktop/WsController.java b/soloncode-cli/src/main/java/org/noear/solon/codecli/portal/desktop/WsController.java index f8508d383675de80d8a04d0b7c0b87a4dbe9d77c..6854d8ce049ab7495419c64850c4d0c12ffe46ab 100644 --- a/soloncode-cli/src/main/java/org/noear/solon/codecli/portal/desktop/WsController.java +++ b/soloncode-cli/src/main/java/org/noear/solon/codecli/portal/desktop/WsController.java @@ -4,6 +4,7 @@ import org.noear.snack4.ONode; import org.noear.solon.ai.chat.ChatConfig; import org.noear.solon.ai.chat.ChatModel; import org.noear.solon.ai.harness.HarnessEngine; +import org.noear.solon.ai.talents.mount.MountDir; import org.noear.solon.annotation.*; import org.noear.solon.codecli.config.AgentFlags; import org.noear.solon.codecli.config.AgentSettings; @@ -52,6 +53,33 @@ public class WsController { return Result.succeed(data); } + /** + * 重新扫描桌面端指定的挂载池,使新创建的 Skill/Agent 立即进入运行时。 + */ + @Post + @Mapping("/desktop/settings/mounts/refresh") + public Result mountsRefresh(@Param("alias") String alias) { + if (Assert.isEmpty(alias)) { + return Result.failure("alias is required"); + } + + MountDir mountDir = engine.getMount(alias); + if (mountDir == null) { + return Result.failure("挂载池不存在: " + alias); + } + if (!mountDir.isEnabled()) { + return Result.failure("挂载池未启用: " + alias); + } + + try { + engine.refreshMount(alias); + return Result.succeed("刷新成功"); + } catch (Exception e) { + LOG.warn("[Desktop] Failed to refresh mount {}: {}", alias, e.getMessage()); + return Result.failure("刷新挂载池失败"); + } + } + /** * 通过 ModelsAdapterManager 从远程 API 获取可用模型列表 diff --git a/soloncode-cli/src/main/java/org/noear/solon/codecli/portal/desktop/WsGate.java b/soloncode-cli/src/main/java/org/noear/solon/codecli/portal/desktop/WsGate.java index d2bed06aeed75681fbbadd8a8eaefb5e0d0af120..e8d71e31b92d5c6d744a97bf45e9c99c205d7b59 100644 --- a/soloncode-cli/src/main/java/org/noear/solon/codecli/portal/desktop/WsGate.java +++ b/soloncode-cli/src/main/java/org/noear/solon/codecli/portal/desktop/WsGate.java @@ -76,6 +76,7 @@ public class WsGate extends SimpleWebSocketListener { private final HarnessEngine engine; private final AgentSettings agentSettings; + private final DesktopStreamHub streamHub = new DesktopStreamHub(); public WsGate(HarnessEngine engine, AgentSettings agentSettings) { this.engine = engine; @@ -105,6 +106,22 @@ public class WsGate extends SimpleWebSocketListener { AgentSession session = engine.getSession(sessionId); session.attrs().putIfAbsent(HarnessEngine.ATTR_CWD, sessionCwd); } + + if ("1".equals(socket.param("resume"))) { + long afterSequence = parseSequence(socket.param("afterSequence")); + if (!streamHub.attach(sessionId, socket, afterSequence)) { + socket.send(new ONode().set("type", "error") + .set("sessionId", sessionId) + .set("text", "会话流已失效,无法恢复连接") + .toJson()); + socket.close(); + } + } + } + + @Override + public void onClose(WebSocket socket) { + streamHub.detach(socket); } @Override @@ -153,16 +170,22 @@ public class WsGate extends SimpleWebSocketListener { interruptModelName = engine.getMainModel().getConfig().getNameOrModel(); } - socket.send(new ONode().set("type", "reason") + String interruptReason = new ONode().set("type", "reason") .set("sessionId", session.getSessionId()) .set("text", "[Task interrupted]") - .toJson()); + .toJson(); + if (!streamHub.emit(sessionId, interruptReason)) { + socket.send(interruptReason); + } - socket.send(new ONode().set("type", "done") + String interruptDone = new ONode().set("type", "done") .set("sessionId", session.getSessionId()) .set("modelName", interruptModelName) .set("totalTokens", 0) - .set("elapsedMs", 0).toJson()); + .set("elapsedMs", 0).toJson(); + if (!streamHub.emit(sessionId, interruptDone)) { + socket.send(interruptDone); + } return; } @@ -314,6 +337,7 @@ public class WsGate extends SimpleWebSocketListener { String finalCwd = cwd; AtomicBoolean terminalSent = new AtomicBoolean(false); + streamHub.begin(finalSessionId, socket); Disposable disposable = engine.prompt(prompt) .session(session) .options(o -> { @@ -329,7 +353,7 @@ public class WsGate extends SimpleWebSocketListener { // ReActChunk 需要优先处理 metrics 收集(无论 hasContent 状态) String msg = null; if (chunk instanceof ReActChunk) { - onReActChunk((ReActChunk) chunk, finalSessionId, socket, terminalSent); + onReActChunk((ReActChunk) chunk, finalSessionId, terminalSent); return; } else if (chunk instanceof ReasonChunk) { msg = onReasonChunk((ReasonChunk) chunk, finalSessionId); @@ -342,12 +366,12 @@ public class WsGate extends SimpleWebSocketListener { } if (Assert.isNotEmpty(msg)) { - socket.send(msg); + streamHub.emit(finalSessionId, msg); } }) - .doOnComplete(() -> sendDoneIfNeeded(socket, terminalSent, finalSessionId, + .doOnComplete(() -> sendDoneIfNeeded(terminalSent, finalSessionId, chatModel.getConfig().getNameOrModel(), 0, 0)) - .doOnError(err -> sendErrorIfNeeded(socket, terminalSent, finalSessionId, err)) + .doOnError(err -> sendErrorIfNeeded(terminalSent, finalSessionId, err)) .subscribe(); Disposable old = (Disposable) session.attrs().put("disposable", disposable); @@ -361,24 +385,24 @@ public class WsGate extends SimpleWebSocketListener { } } - private void onReActChunk(ReActChunk chunk, String finalSessionId, WebSocket socket, + private void onReActChunk(ReActChunk chunk, String finalSessionId, AtomicBoolean terminalSent) { ReActTrace trace = chunk.getTrace(); Long start_time = trace.getOriginalPrompt().attrAs("start_time"); long elapsed = start_time != null ? System.currentTimeMillis() - start_time : 0; long totalTokens = trace.getMetrics() != null ? trace.getMetrics().getTotalTokens() : 0; - sendDoneIfNeeded(socket, terminalSent, finalSessionId, + sendDoneIfNeeded(terminalSent, finalSessionId, trace.getOptions().getChatModel().getNameOrModel(), totalTokens, elapsed); } - private void sendDoneIfNeeded(WebSocket socket, AtomicBoolean terminalSent, String sessionId, + private void sendDoneIfNeeded(AtomicBoolean terminalSent, String sessionId, String modelName, long totalTokens, long elapsedMs) { if (!terminalSent.compareAndSet(false, true)) { return; } - socket.send(new ONode().set("type", "done") + streamHub.emit(sessionId, new ONode().set("type", "done") .set("sessionId", sessionId) .set("modelName", modelName) .set("totalTokens", totalTokens) @@ -386,19 +410,30 @@ public class WsGate extends SimpleWebSocketListener { .toJson()); } - private void sendErrorIfNeeded(WebSocket socket, AtomicBoolean terminalSent, String sessionId, + private void sendErrorIfNeeded(AtomicBoolean terminalSent, String sessionId, Throwable error) { if (!terminalSent.compareAndSet(false, true)) { return; } String errorMessage = error.getMessage() != null ? error.getMessage() : error.getClass().getSimpleName(); - socket.send(new ONode().set("type", "error") + streamHub.emit(sessionId, new ONode().set("type", "error") .set("sessionId", sessionId) .set("text", errorMessage) .toJson()); } + private long parseSequence(String value) { + if (value == null || value.isEmpty()) { + return 0L; + } + try { + return Long.parseLong(value); + } catch (NumberFormatException ignored) { + return 0L; + } + } + private String onReasonChunk(ReasonChunk chunk, String finalSessionId) { if (!chunk.isToolCalls() && chunk.getMessage() != null) { String content = chunk.getMessage().getContent(); @@ -538,6 +573,7 @@ public class WsGate extends SimpleWebSocketListener { applyReasoningEffort(hitlPrompt, reasoningEffort); AtomicBoolean terminalSent = new AtomicBoolean(false); + streamHub.subscribe(sessionId, socket); Disposable disposable = engine.prompt(hitlPrompt) .session(session) .options(o -> { @@ -551,7 +587,7 @@ public class WsGate extends SimpleWebSocketListener { .doFinally(signal -> session.attrs().remove("disposable")) .doOnNext(chunk -> { if (chunk instanceof ReActChunk) { - onReActChunk((ReActChunk) chunk, sessionId, socket, terminalSent); + onReActChunk((ReActChunk) chunk, sessionId, terminalSent); return; } String msg = null; @@ -565,12 +601,12 @@ public class WsGate extends SimpleWebSocketListener { msg = onThoughtChunk((ThoughtChunk) chunk, sessionId); } if (Assert.isNotEmpty(msg)) { - socket.send(msg); + streamHub.emit(sessionId, msg); } }) - .doOnComplete(() -> sendDoneIfNeeded(socket, terminalSent, sessionId, + .doOnComplete(() -> sendDoneIfNeeded(terminalSent, sessionId, chatModel.getConfig().getNameOrModel(), 0, 0)) - .doOnError(err -> sendErrorIfNeeded(socket, terminalSent, sessionId, err)) + .doOnError(err -> sendErrorIfNeeded(terminalSent, sessionId, err)) .subscribe(); session.attrs().put("disposable", disposable); @@ -822,6 +858,7 @@ public class WsGate extends SimpleWebSocketListener { Prompt prompt = Prompt.of(input).attrPut("start_time", System.currentTimeMillis()); applyReasoningEffort(prompt, reasoningEffort); AtomicBoolean terminalSent = new AtomicBoolean(false); + streamHub.begin(finalSessionId, socket); Disposable disposable = engine.prompt(prompt) .session(session) .options(o -> { @@ -835,7 +872,7 @@ public class WsGate extends SimpleWebSocketListener { .doFinally(signal -> session.attrs().remove("disposable")) .doOnNext(chunk -> { if (chunk instanceof ReActChunk) { - onReActChunk((ReActChunk) chunk, finalSessionId, socket, terminalSent); + onReActChunk((ReActChunk) chunk, finalSessionId, terminalSent); return; } String msg = null; @@ -849,12 +886,12 @@ public class WsGate extends SimpleWebSocketListener { msg = onThoughtChunk((ThoughtChunk) chunk, finalSessionId); } if (Assert.isNotEmpty(msg)) { - socket.send(msg); + streamHub.emit(finalSessionId, msg); } }) - .doOnComplete(() -> sendDoneIfNeeded(socket, terminalSent, finalSessionId, + .doOnComplete(() -> sendDoneIfNeeded(terminalSent, finalSessionId, chatModel.getConfig().getNameOrModel(), 0, 0)) - .doOnError(err -> sendErrorIfNeeded(socket, terminalSent, finalSessionId, err)) + .doOnError(err -> sendErrorIfNeeded(terminalSent, finalSessionId, err)) .subscribe(); Disposable old = (Disposable) session.attrs().put("disposable", disposable); diff --git a/soloncode-desktop/package.json b/soloncode-desktop/package.json index b3f940941c9c9c269d4b5edc9821f06ed9ec49b9..5e475e2e5445a57d45db325a38c2cf5e9f6e1fbd 100644 --- a/soloncode-desktop/package.json +++ b/soloncode-desktop/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "vite --port 5173", "build": "tsc --noEmit && vite build", + "test": "node --experimental-strip-types --test tests/*.test.ts", "preview": "vite preview", "tauri": "tauri", "tauri:dev": "tauri dev", diff --git a/soloncode-desktop/src-tauri/src/lib.rs b/soloncode-desktop/src-tauri/src/lib.rs index db1fd0a04d80dd1c0c3f7fbe73dc771ec4018bbc..58cbde01cad670d279a20ac137047ad63507c763 100644 --- a/soloncode-desktop/src-tauri/src/lib.rs +++ b/soloncode-desktop/src-tauri/src/lib.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize}; use std::fs; use std::io::{Read as IoRead, Write as IoWrite}; use base64::Engine; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::process::{Child, Command}; use std::sync::Mutex; use std::net::TcpStream; @@ -787,9 +787,13 @@ fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<(), String> { let entry = entry.map_err(|e| format!("读取条目失败: {}", e))?; let src_path = entry.path(); let dst_path = dst.join(entry.file_name()); - if src_path.is_dir() { + let file_type = entry.file_type().map_err(|e| format!("读取条目类型失败: {}", e))?; + if file_type.is_symlink() { + return Err("不支持复制包含符号链接的目录".to_string()); + } + if file_type.is_dir() { copy_dir_recursive(&src_path, &dst_path)?; - } else { + } else if file_type.is_file() { fs::copy(&src_path, &dst_path).map_err(|e| format!("复制文件失败: {}", e))?; } } @@ -2061,6 +2065,183 @@ fn validate_resource_name(name: &str) -> Result { Ok(trimmed.to_string()) } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct ManagedResourceResult { + name: String, + path: String, +} + +fn managed_resource_marker(kind: &str) -> Result<&'static str, String> { + match kind { + "skill" => Ok("SKILL.md"), + "agent" => Ok("AGENT.md"), + _ => Err("不支持的资源类型".to_string()), + } +} + +fn validate_managed_resource_path(resource_path: &str, kind: &str) -> Result<(PathBuf, &'static str), String> { + let marker = managed_resource_marker(kind)?; + let source = Path::new(resource_path); + if !source.is_absolute() { + return Err("资源路径必须是绝对路径".to_string()); + } + let metadata = fs::symlink_metadata(source).map_err(|_| "资源目录不存在".to_string())?; + if metadata.file_type().is_symlink() { + return Err("不支持修改符号链接资源".to_string()); + } + if !metadata.is_dir() { + return Err("资源路径不是目录".to_string()); + } + let canonical = fs::canonicalize(source).map_err(|e| format!("无法解析资源路径: {}", e))?; + let marker_path = canonical.join(marker); + let marker_metadata = fs::symlink_metadata(&marker_path) + .map_err(|_| format!("资源目录缺少 {}", marker))?; + if !marker_metadata.is_file() || marker_metadata.file_type().is_symlink() { + return Err(format!("{} 必须是普通文件", marker)); + } + Ok((canonical, marker)) +} + +fn replace_frontmatter_name(content: &str, new_name: &str) -> String { + let newline = if content.contains("\r\n") { "\r\n" } else { "\n" }; + let trailing_newline = content.ends_with('\n'); + let mut lines: Vec = content.lines().map(str::to_string).collect(); + if lines.first().is_some_and(|line| line.trim() == "---") { + let end = lines.iter().enumerate().skip(1) + .find_map(|(index, line)| (line.trim() == "---").then_some(index)); + if let Some(end_index) = end { + if let Some(name_index) = (1..end_index).find(|index| lines[*index].trim_start().starts_with("name:")) { + lines[name_index] = format!("name: {}", new_name); + } else { + lines.insert(1, format!("name: {}", new_name)); + } + } else { + lines.insert(0, format!("---{newline}name: {new_name}{newline}---")); + } + } else { + lines.insert(0, format!("---{newline}name: {new_name}{newline}---")); + } + let mut result = lines.join(newline); + if trailing_newline || content.is_empty() { + result.push_str(newline); + } + result +} + +fn update_managed_resource_name(resource_dir: &Path, marker: &str, new_name: &str) -> Result<(), String> { + let marker_path = resource_dir.join(marker); + let content = fs::read_to_string(&marker_path).map_err(|e| format!("读取 {} 失败: {}", marker, e))?; + let updated = replace_frontmatter_name(&content, new_name); + fs::write(&marker_path, updated).map_err(|e| format!("更新 {} 失败: {}", marker, e)) +} + +fn copy_resource_name(source: &Path) -> Result { + let raw = source.file_name().and_then(|value| value.to_str()).unwrap_or("resource"); + let mut base: String = raw.chars() + .map(|ch| if ch.is_alphanumeric() || ch == '-' || ch == '_' { ch } else { '-' }) + .take(44) + .collect(); + base = base.trim_matches('-').to_string(); + if base.is_empty() { + base = "resource".to_string(); + } + validate_resource_name(&base) +} + +#[tauri::command] +fn rename_managed_resource(resource_path: &str, kind: &str, new_name: &str) -> Result { + let name = validate_resource_name(new_name)?; + let (source, marker) = validate_managed_resource_path(resource_path, kind)?; + let parent = source.parent().ok_or("无法获取资源父目录")?; + let target = parent.join(&name); + if source == target { + return Ok(ManagedResourceResult { name, path: source.to_string_lossy().to_string() }); + } + if target.exists() { + return Err("同名资源已存在".to_string()); + } + + fs::rename(&source, &target).map_err(|e| format!("重命名资源目录失败: {}", e))?; + if let Err(error) = update_managed_resource_name(&target, marker, &name) { + let _ = fs::rename(&target, &source); + return Err(error); + } + Ok(ManagedResourceResult { name, path: target.to_string_lossy().to_string() }) +} + +#[tauri::command] +fn copy_managed_resource(resource_path: &str, kind: &str) -> Result { + let (source, marker) = validate_managed_resource_path(resource_path, kind)?; + let parent = source.parent().ok_or("无法获取资源父目录")?; + let base = copy_resource_name(&source)?; + let mut index = 1_u32; + let (name, target) = loop { + let suffix = if index == 1 { "-copy".to_string() } else { format!("-copy-{}", index) }; + let candidate = format!("{}{}", base, suffix); + let target = parent.join(&candidate); + if !target.exists() { + break (candidate, target); + } + index = index.checked_add(1).ok_or("副本数量过多")?; + }; + + if let Err(error) = copy_dir_recursive(&source, &target) { + let _ = fs::remove_dir_all(&target); + return Err(error); + } + if let Err(error) = update_managed_resource_name(&target, marker, &name) { + let _ = fs::remove_dir_all(&target); + return Err(error); + } + Ok(ManagedResourceResult { name, path: target.to_string_lossy().to_string() }) +} + +#[tauri::command] +fn delete_managed_resource(resource_path: &str, kind: &str) -> Result<(), String> { + let (source, _) = validate_managed_resource_path(resource_path, kind)?; + fs::remove_dir_all(source).map_err(|e| format!("删除资源失败: {}", e)) +} + +#[cfg(test)] +mod managed_resource_tests { + use super::{copy_managed_resource, delete_managed_resource, rename_managed_resource}; + use std::fs; + use std::time::{SystemTime, UNIX_EPOCH}; + + #[test] + fn renames_copies_and_deletes_only_valid_resources() { + let unique = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos(); + let root = std::env::temp_dir().join(format!("soloncode-managed-resource-{unique}")); + let source = root.join("demo"); + fs::create_dir_all(&source).unwrap(); + fs::write(source.join("SKILL.md"), "---\nname: demo\ndescription: test\n---\n\n# Demo\n").unwrap(); + + let renamed = rename_managed_resource(source.to_str().unwrap(), "skill", "renamed").unwrap(); + let renamed_path = root.join("renamed"); + assert_eq!(renamed.path, fs::canonicalize(&renamed_path).unwrap().to_string_lossy()); + assert!(fs::read_to_string(renamed_path.join("SKILL.md")).unwrap().contains("name: renamed")); + + let copied = copy_managed_resource(renamed_path.to_str().unwrap(), "skill").unwrap(); + let copied_path = root.join("renamed-copy"); + assert_eq!(copied.path, fs::canonicalize(&copied_path).unwrap().to_string_lossy()); + assert!(fs::read_to_string(copied_path.join("SKILL.md")).unwrap().contains("name: renamed-copy")); + + delete_managed_resource(copied_path.to_str().unwrap(), "skill").unwrap(); + assert!(!copied_path.exists()); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn rejects_directories_without_expected_marker() { + let unique = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos(); + let root = std::env::temp_dir().join(format!("soloncode-invalid-resource-{unique}")); + fs::create_dir_all(&root).unwrap(); + assert!(delete_managed_resource(root.to_str().unwrap(), "agent").is_err()); + fs::remove_dir_all(root).unwrap(); + } +} + /// 创建新 Skill(在 ~/.soloncode/skills/{name}/SKILL.md 生成模板) #[tauri::command] fn create_skill(name: String, description: String, content: Option) -> Result { @@ -2208,6 +2389,9 @@ pub fn run() { list_skills, toggle_skill, create_skill, + rename_managed_resource, + copy_managed_resource, + delete_managed_resource, list_agents, toggle_agent, create_agent diff --git a/soloncode-desktop/src/App.css b/soloncode-desktop/src/App.css index 4d78cacc8007d07a83cc5443c4352b0431ed8fdd..b643f633ca96bf0bf0f7ddb59ce1ff6231daccc9 100644 --- a/soloncode-desktop/src/App.css +++ b/soloncode-desktop/src/App.css @@ -443,7 +443,7 @@ select option { background: var(--cb-vscode-input-background); color: var(--cb-vscode-foreground); font-size: 13px; - z-index: 9999; + z-index: 10001; pointer-events: none; animation: fadeInOut 5s ease-in-out forwards; border: 1px solid var(--cb-vscode-panel-border); diff --git a/soloncode-desktop/src/App.tsx b/soloncode-desktop/src/App.tsx index 6a1c1f95e2c41d23607434424e4a52e9b65ebabd..c103e17d79cc3b34edabcc90007ab0b577429e5a 100644 --- a/soloncode-desktop/src/App.tsx +++ b/soloncode-desktop/src/App.tsx @@ -8,7 +8,12 @@ import { ExplorerPanel } from './components/sidebar/ExplorerPanel'; import { GitPanel } from './components/sidebar/GitPanel'; import { ExtensionsPanel } from './components/sidebar/ExtensionsPanel'; import { SessionsPanel, type Session, type Project } from './components/sidebar/SessionsPanel'; -import { AutomationPanel } from './components/sidebar/AutomationPanel'; +import { + AutomationDetail, + AutomationPanel, + type AutomationModelOption, + type AutomationUpdateInput, +} from './components/sidebar/AutomationPanel'; import { SkillsPanel } from './components/sidebar/SkillsPanel'; import { AgentsPanel } from './components/sidebar/AgentsPanel'; import { SettingsPanel, type Settings } from './components/sidebar/SettingsPanel'; @@ -26,7 +31,22 @@ import { useBackend } from './hooks/useBackend'; import { useGit } from './hooks/useGit'; import { useFileManager } from './hooks/useFileManager'; import { useSessions } from './hooks/useSessions'; -import { UNLINKED_PROJECT, addAutomation, saveMessage, db, type DbAutomation } from './db'; +import { + UNLINKED_PROJECT, + addAutomation, + addAutomationRun, + deleteAutomation, + getAllAutomations, + getAutomation, + updateAutomation, + updateAutomationRun, + saveMessage, + db, + type DbAutomation, + type DbAutomationRun, +} from './db'; +import { cronMatchesDate, getCronMinuteKey, getCronValidationError, getLatestCronRun } from './utils/cron'; +import type { GeneratedAutomationPlan } from './utils/automationPlan'; import { useWorkspace } from './hooks/useWorkspace'; import type { Conversation, Plugin, Theme } from './types'; import type { GitFileStatus } from './services/gitService'; @@ -233,9 +253,11 @@ function hasMeaningfulReviewChange(file: GitFileStatus, stats: { additions: numb return file.status === 'deleted'; } -function createPromptTitle(prompt: string): string { - const oneLine = prompt.trim().replace(/\s+/g, ' '); - return oneLine.length > 28 ? `${oneLine.slice(0, 28)}...` : oneLine; +function getAutomationRunError(error: unknown): string { + const message = error instanceof Error + ? error.message + : (typeof error === 'string' ? error : '自动化运行失败'); + return message.trim().replace(/\s+/g, ' ').slice(0, 300) || '自动化运行失败'; } function App() { @@ -248,8 +270,15 @@ function App() { const [promptCreation, setPromptCreation] = useState(null); const [aiCreateRefreshKey, setAiCreateRefreshKey] = useState(0); const [automationRefreshKey, setAutomationRefreshKey] = useState(0); + const [selectedAutomation, setSelectedAutomation] = useState(null); + const [runningAutomationId, setRunningAutomationId] = useState(null); + const runningAutomationIdRef = useRef(null); + const scheduledAutomationQueueRef = useRef([]); + const scheduledAutomationQueueProcessingRef = useRef(false); + const [scheduledAutomationQueueVersion, setScheduledAutomationQueueVersion] = useState(0); const [automationPrompt, setAutomationPrompt] = useState<{ runId: string; + sessionId: string; prompt: string; modelId: string; modelName: string; @@ -259,6 +288,8 @@ function App() { const [sessionRunStates, setSessionRunStates] = useState>({}); const [sessionReviewFiles, setSessionReviewFiles] = useState>({}); const resolvedSessionIdsRef = useRef>({}); + const automationRunBySessionRef = useRef>({}); + const lastAutomationScheduleCheckRef = useRef(null); const sessionReviewBaselinesRef = useRef>({}); const sessionReviewBaselinePromisesRef = useRef>>({}); const [currentTheme, setCurrentTheme] = useState(() => { @@ -268,6 +299,22 @@ function App() { return theme; }); + const automationModels = useMemo(() => { + const options = new Map(); + for (const provider of settings.providers) { + if (!provider.enabled) continue; + if (provider.availableModels?.length) { + for (const model of provider.availableModels) { + const id = `${provider.id}__${model.id}`; + options.set(id, { id, name: model.id, label: `${model.id} · ${provider.name}` }); + } + } else if (provider.model) { + options.set(provider.id, { id: provider.id, name: provider.model, label: `${provider.model} · ${provider.name}` }); + } + } + return Array.from(options.values()); + }, [settings.providers]); + const toggleTheme = useCallback(() => { setCurrentTheme(prev => { const next = prev === 'dark' ? 'light' : 'dark'; @@ -361,12 +408,17 @@ function App() { const { backendPort, backendPortRef, backendStatus, startBackend, reconnectBackend } = useBackend(); const { openFiles, activeFilePath, activeFile, setActiveFilePath, - handleFileSelect, handleFileClose, handleContentChange, + handleFileSelect: handleFileSelectInternal, handleFileClose, handleContentChange, handleFileSave, handleSaveCurrentFile, clearEditorState, setOpenFiles, } = useFileManager(null, () => { setPanelState(prev => ({ ...prev, editorVisible: false })); }); + const handleFileSelect = useCallback(async (path: string) => { + setSelectedAutomation(null); + await handleFileSelectInternal(path); + }, [handleFileSelectInternal]); + const { sessions, currentSessionId, setCurrentSessionId, currentConversation, handleNewSession, handleDeleteSession, handleUpdateSessionTitle, @@ -376,6 +428,14 @@ function App() { } = useSessions(null, { onSessionIdResolved: (oldId, newId) => { resolvedSessionIdsRef.current[oldId] = newId; + const automationRunId = automationRunBySessionRef.current[oldId]; + if (automationRunId) { + automationRunBySessionRef.current[newId] = automationRunId; + delete automationRunBySessionRef.current[oldId]; + void updateAutomationRun(automationRunId, { sessionId: newId }).catch(err => { + console.error('[App] 更新自动化运行记录会话失败:', err); + }); + } setSessionRunStates(prev => { const state = prev[oldId]; if (!state) return prev; @@ -422,8 +482,8 @@ function App() { const chatWorkspacePath = useMemo(() => { if (currentConversation.workspacePath === UNLINKED_PROJECT) return null; - return currentConversation.workspacePath || activeProjectPath; - }, [activeProjectPath, currentConversation.workspacePath]); + return currentConversation.workspacePath || null; + }, [currentConversation.workspacePath]); useEffect(() => { setChatWorkspacePath(chatWorkspacePath); @@ -630,6 +690,7 @@ function App() { ], }); if (selectedPath && typeof selectedPath === 'string') { + setSelectedAutomation(null); const file = await fileService.openFile(selectedPath); setOpenFiles(prev => prev.some(f => f.path === selectedPath) ? prev : [...prev, file]); setActiveFilePath(selectedPath); @@ -662,7 +723,7 @@ function App() { }, [activeProjectPath, handleFileSelect]); const captureReviewBaseline = useCallback(async (sessionId: string, workspacePath?: string | null) => { - const cwd = workspacePath && workspacePath !== UNLINKED_PROJECT ? workspacePath : activeProjectPath; + const cwd = workspacePath && workspacePath !== UNLINKED_PROJECT ? workspacePath : null; if (!cwd) { delete sessionReviewBaselinesRef.current[sessionId]; delete sessionReviewBaselinePromisesRef.current[sessionId]; @@ -686,10 +747,10 @@ function App() { delete sessionReviewBaselinePromisesRef.current[sessionId]; } } - }, [activeProjectPath]); + }, []); const captureSessionReviewFiles = useCallback(async (sessionId: string, workspacePath?: string | null) => { - const cwd = workspacePath && workspacePath !== UNLINKED_PROJECT ? workspacePath : activeProjectPath; + const cwd = workspacePath && workspacePath !== UNLINKED_PROJECT ? workspacePath : null; if (!cwd) { setSessionReviewFiles(prev => { const next = { ...prev }; @@ -761,6 +822,11 @@ function App() { toastTimer.current = setTimeout(() => setToast(null), 5000); }, []); + const handleSkillInstalled = useCallback((skillName: string) => { + setAiCreateRefreshKey(current => current + 1); + showToast(`Skill "${skillName}" 安装成功,列表已刷新`); + }, [showToast]); + useEffect(() => { if (!backendPort || !settings.autoCheckUpdates || autoUpdateCheckedRef.current) return; @@ -811,13 +877,18 @@ function App() { const handleSelectSession = useCallback((id: string) => { setCurrentSessionId(id); const session = sessions.find(s => s.id === id); + setChatWorkspacePath(session?.workspacePath && session.workspacePath !== UNLINKED_PROJECT + ? session.workspacePath + : null); if (session?.workspacePath && session.workspacePath !== UNLINKED_PROJECT && session.workspacePath !== activeProjectPath) { void handleSetActiveProject(session.workspacePath); } }, [sessions, activeProjectPath, handleSetActiveProject, setCurrentSessionId]); const handleCreateSessionInProject = useCallback((projectId?: string) => { - setNewSessionFromProject(true); + const linked = Boolean(projectId && projectId !== UNLINKED_PROJECT); + setNewSessionFromProject(linked); + setChatWorkspacePath(linked ? projectId! : null); if (projectId && projectId !== UNLINKED_PROJECT && projectId !== activeProjectPath) { void handleSetActiveProject(projectId); } @@ -864,17 +935,15 @@ function App() { }, []); const handleStartPromptCreation = useCallback((type: PromptCreationType) => { - if (type === 'automation' && !activeProjectPath) { - showToast('请先选择一个项目'); - return; - } setPanelState(prev => ({ ...prev, chatVisible: true })); setSidebarCollapsed(false); setAutomationPrompt(null); - setNewSessionFromProject(!!activeProjectPath); + setNewSessionFromProject(false); + setChatWorkspacePath(null); const label = type === 'skill' ? 'Skill' : type === 'agent' ? 'Agent' : '自动化'; - const sessionId = handleNewSession(type === 'automation' ? activeProjectPath! : undefined, `创建 ${label}`); + const projectId = type === 'automation' ? UNLINKED_PROJECT : undefined; + const sessionId = handleNewSession(projectId, `创建 ${label}`); if (!sessionId) { showToast(`无法进入${label}创建模式`); return; @@ -884,14 +953,16 @@ function App() { id: `${type}-${Date.now()}`, sessionId, type, + projectId, template: type === 'skill' ? settings.skillPrompt : type === 'agent' ? settings.agentPrompt : undefined, }); - }, [activeProjectPath, handleNewSession, settings.agentPrompt, settings.skillPrompt]); + }, [handleNewSession, settings.agentPrompt, settings.skillPrompt]); - const handleAiCreateComplete = useCallback(async (info: { type: 'skill' | 'agent'; name: string; error?: string }) => { + const handleAiCreateComplete = useCallback(async (info: { type: PromptCreationType; name: string; error?: string }) => { setPromptCreation(null); if (info.error) { - showToast(`${info.type === 'skill' ? 'Skill' : 'Agent'} "${info.name}" 创建失败`); + if (info.type === 'automation') showToast(`自动化创建失败:${info.error}`); + else showToast(`${info.type === 'skill' ? 'Skill' : 'Agent'} "${info.name}" 创建失败`); return; } try { @@ -902,7 +973,9 @@ function App() { const agents = await invoke>('list_agents'); setSettings(prev => ({ ...prev, agents: agents.map(a => ({ ...a, source: 'discovered' as const })) })); } - showToast(`${info.type === 'skill' ? 'Skill' : 'Agent'} "${info.name}" 已创建`); + if (info.type !== 'automation') { + showToast(`${info.type === 'skill' ? 'Skill' : 'Agent'} "${info.name}" 已创建`); + } } catch (err) { console.error('[App] AI 创建完成刷新失败:', err); } finally { @@ -910,73 +983,349 @@ function App() { } }, []); - const handleCreateAutomationFromPrompt = useCallback(async (rawPrompt: string, options: SendOptions) => { - const prompt = rawPrompt.trim(); + const handleCreateAutomationFromPrompt = useCallback(async (plan: GeneratedAutomationPlan, options: SendOptions) => { const creation = promptCreation?.type === 'automation' ? promptCreation : null; - const project = activeProjectPath - ? projects.find(item => item.id === activeProjectPath) - : undefined; - if (!creation || !activeProjectPath || !project) { - showToast('当前项目不可用,请重新进入自动化创建模式'); - return; - } - if (!prompt || prompt.length > 10000) { - showToast(prompt ? '自动化提示词不能超过 10000 个字符' : '请输入自动化提示词'); - return; + if (!creation) { + throw new Error('自动化创建上下文已失效,请重新进入创建模式'); } + const projectId = creation.projectId || UNLINKED_PROJECT; + const project = projectId === UNLINKED_PROJECT + ? { id: UNLINKED_PROJECT, name: '未关联项目' } + : projects.find(item => item.id === projectId); + if (!project) throw new Error('创建自动化时选择的项目已不可用'); if (!options.model || !options.modelName) { - showToast('请先选择一个可用模型'); - return; + throw new Error('请先选择一个可用模型'); } const now = new Date().toISOString(); try { await addAutomation({ - title: createPromptTitle(prompt), - prompt, - projectId: activeProjectPath, + title: plan.title, + prompt: plan.prompt, + projectId: project.id, projectName: project.name, modelId: options.model, modelName: options.modelName, reasoningEffort: options.reasoningEffort, + scheduleEnabled: plan.scheduleEnabled, + cron: plan.cron, createdAt: now, updatedAt: now, runCount: 0, }); setPromptCreation(null); setAutomationRefreshKey(current => current + 1); - if (creation.sessionId.startsWith('temp-')) handleDeleteSession(creation.sessionId); + const creationSessionId = resolvedSessionIdsRef.current[creation.sessionId] || creation.sessionId; + handleDeleteSession(creationSessionId); setActiveActivity('automation'); - showToast('自动化已创建'); + showToast(plan.scheduleEnabled ? '定时自动化已创建' : '自动化已创建'); } catch (err) { console.error('[App] 创建自动化失败:', err); - showToast('创建自动化失败'); + throw err; } - }, [activeProjectPath, handleDeleteSession, projects, promptCreation]); + }, [handleDeleteSession, projects, promptCreation]); - const handleRunAutomation = useCallback(async (automation: DbAutomation) => { - const project = projects.find(item => item.id === automation.projectId); - if (!project) throw new Error(`项目已不在列表中:${automation.projectName}`); + const handleRunAutomation = useCallback(async (automation: DbAutomation, automationRunId?: number) => { + const unlinked = automation.projectId === UNLINKED_PROJECT; + const project = unlinked ? null : projects.find(item => item.id === automation.projectId); + if (!unlinked && !project) throw new Error(`项目已不在列表中:${automation.projectName}`); - await handleSetActiveProject(automation.projectId); + if (unlinked) setChatWorkspacePath(null); + else await handleSetActiveProject(automation.projectId); setPanelState(prev => ({ ...prev, chatVisible: true })); setSidebarCollapsed(false); - setNewSessionFromProject(true); + setNewSessionFromProject(!unlinked); setPromptCreation(null); const sessionId = handleNewSession(automation.projectId, automation.title); if (!sessionId) throw new Error('无法创建自动化会话'); + if (automationRunId) { + automationRunBySessionRef.current[sessionId] = automationRunId; + } setAutomationPrompt({ runId: `${automation.id || 'automation'}-${Date.now()}`, + sessionId, prompt: automation.prompt, modelId: automation.modelId, modelName: automation.modelName, reasoningEffort: automation.reasoningEffort, }); setActiveActivity('sessions'); + return sessionId; }, [handleNewSession, handleSetActiveProject, projects]); + const handleSelectAutomation = useCallback((automation: DbAutomation) => { + setSelectedAutomation(automation); + setPanelState(prev => ({ ...prev, editorVisible: true })); + }, []); + + const handleAutomationDeleted = useCallback((automationId: number) => { + if (selectedAutomation?.id !== automationId) return; + setSelectedAutomation(null); + if (openFiles.length === 0) { + setPanelState(prev => ({ ...prev, editorVisible: false })); + } + }, [openFiles.length, selectedAutomation?.id]); + + const handleRunAutomationFromDetail = useCallback(async ( + automation: DbAutomation, + trigger: 'manual' | 'scheduled' = 'manual', + ): Promise => { + if (!automation.id || runningAutomationIdRef.current !== null) return false; + runningAutomationIdRef.current = automation.id; + setRunningAutomationId(automation.id); + const startedAt = new Date().toISOString(); + let automationRunId: number; + + try { + automationRunId = await addAutomationRun({ + automationId: automation.id, + status: 'running', + trigger, + projectId: automation.projectId, + projectName: automation.projectName, + modelId: automation.modelId, + modelName: automation.modelName, + reasoningEffort: automation.reasoningEffort, + startedAt, + }); + } catch (err) { + runningAutomationIdRef.current = null; + setRunningAutomationId(null); + console.error('[App] 保存自动化运行记录失败:', err); + showToast('无法保存运行记录,自动化未启动'); + return false; + } + + try { + const sessionId = await handleRunAutomation(automation, automationRunId); + try { + await updateAutomationRun(automationRunId, { sessionId }); + } catch (err) { + console.error('[App] 关联自动化运行会话失败:', err); + } + + const lastRunAt = startedAt; + const updated = { + ...automation, + lastRunAt, + runCount: automation.runCount + 1, + updatedAt: lastRunAt, + }; + try { + await updateAutomation(automation.id, { + lastRunAt, + runCount: updated.runCount, + updatedAt: lastRunAt, + }); + } catch (err) { + console.error('[App] 更新自动化运行统计失败:', err); + } + setSelectedAutomation(current => current?.id === updated.id ? updated : current); + setAutomationRefreshKey(current => current + 1); + if (trigger === 'scheduled') showToast(`定时任务 "${automation.title}" 已启动`); + return true; + } catch (err) { + for (const [mappedSessionId, mappedRunId] of Object.entries(automationRunBySessionRef.current)) { + if (mappedRunId === automationRunId) delete automationRunBySessionRef.current[mappedSessionId]; + } + const completedAt = new Date().toISOString(); + await updateAutomationRun(automationRunId, { + status: 'error', + completedAt, + error: getAutomationRunError(err), + }).catch(updateErr => { + console.error('[App] 更新自动化失败记录失败:', updateErr); + }); + setAutomationRefreshKey(current => current + 1); + runningAutomationIdRef.current = null; + setRunningAutomationId(null); + console.error('[App] 运行自动化失败:', err); + showToast(err instanceof Error ? err.message : '运行自动化失败'); + return false; + } + }, [handleRunAutomation, showToast]); + + const handleSaveAutomation = useCallback(async ( + automation: DbAutomation, + updates: AutomationUpdateInput, + ): Promise => { + if (!automation.id || runningAutomationIdRef.current === automation.id) return false; + const title = updates.title.trim(); + const prompt = updates.prompt.trim(); + const cron = updates.cron.trim().replace(/\s+/g, ' '); + if (!title || title.length > 100) { + showToast('任务名称应为 1-100 个字符'); + return false; + } + if (!prompt || prompt.length > 10000) { + showToast('任务提示词应为 1-10000 个字符'); + return false; + } + const cronError = getCronValidationError(cron); + if (cronError) { + showToast(cronError); + return false; + } + const project = updates.projectId === UNLINKED_PROJECT + ? { id: UNLINKED_PROJECT, name: '未关联项目' } + : projects.find(item => item.id === updates.projectId); + const model = automationModels.find(item => item.id === updates.modelId); + if (!project || !model) { + showToast(!project ? '请选择一个可用项目' : '请选择一个可用模型'); + return false; + } + + const updatedAt = new Date().toISOString(); + const nextAutomation: DbAutomation = { + ...automation, + ...updates, + title, + prompt, + cron, + projectName: project.name, + modelName: model.name, + updatedAt, + }; + try { + await updateAutomation(automation.id, { + title, + prompt, + projectId: project.id, + projectName: project.name, + modelId: model.id, + modelName: model.name, + reasoningEffort: updates.reasoningEffort, + scheduleEnabled: updates.scheduleEnabled, + cron, + updatedAt, + }); + if (!updates.scheduleEnabled) { + scheduledAutomationQueueRef.current = scheduledAutomationQueueRef.current.filter(id => id !== automation.id); + setScheduledAutomationQueueVersion(current => current + 1); + } + setSelectedAutomation(nextAutomation); + setAutomationRefreshKey(current => current + 1); + showToast('自动化配置已保存'); + return true; + } catch (error) { + console.error('[App] 保存自动化配置失败:', error); + showToast('保存自动化配置失败'); + return false; + } + }, [automationModels, projects, showToast]); + + useEffect(() => { + let checking = false; + const checkSchedules = async () => { + if (checking) return; + checking = true; + try { + const now = new Date(); + const previousCheck = lastAutomationScheduleCheckRef.current; + lastAutomationScheduleCheckRef.current = now; + const automations = await getAllAutomations(); + let queued = false; + for (const automation of automations) { + if (!automation.id || !automation.scheduleEnabled) continue; + let scheduledFor: Date | null = null; + try { + const cron = automation.cron || '0 9 * * *'; + scheduledFor = previousCheck + ? getLatestCronRun(cron, previousCheck, now) + : (cronMatchesDate(cron, now) ? new Date(Math.floor(now.getTime() / 60_000) * 60_000) : null); + } catch (error) { + console.error(`[App] 自动化 ${automation.id} 的 Cron 无效:`, error); + continue; + } + if (!scheduledFor) continue; + const minuteKey = getCronMinuteKey(scheduledFor); + if (automation.lastScheduledAt === minuteKey) continue; + await updateAutomation(automation.id, { lastScheduledAt: minuteKey }); + if (!scheduledAutomationQueueRef.current.includes(automation.id)) { + scheduledAutomationQueueRef.current.push(automation.id); + queued = true; + } + } + if (queued) { + setAutomationRefreshKey(current => current + 1); + setScheduledAutomationQueueVersion(current => current + 1); + } + } catch (error) { + console.error('[App] 检查自动化定时任务失败:', error); + } finally { + checking = false; + } + }; + + void checkSchedules(); + const timer = window.setInterval(() => { void checkSchedules(); }, 30_000); + const handleFocus = () => { void checkSchedules(); }; + window.addEventListener('focus', handleFocus); + return () => { + window.clearInterval(timer); + window.removeEventListener('focus', handleFocus); + }; + }, []); + + useEffect(() => { + if (runningAutomationIdRef.current !== null || scheduledAutomationQueueProcessingRef.current) return; + const automationId = scheduledAutomationQueueRef.current.shift(); + if (automationId === undefined) return; + scheduledAutomationQueueProcessingRef.current = true; + void (async () => { + try { + const automation = await getAutomation(automationId); + if (automation?.scheduleEnabled) { + await handleRunAutomationFromDetail(automation, 'scheduled'); + } + } catch (error) { + console.error('[App] 启动排队的自动化任务失败:', error); + } finally { + scheduledAutomationQueueProcessingRef.current = false; + setScheduledAutomationQueueVersion(current => current + 1); + } + })(); + }, [handleRunAutomationFromDetail, runningAutomationId, scheduledAutomationQueueVersion]); + + const handleDeleteAutomationFromDetail = useCallback(async (automation: DbAutomation) => { + if (!automation.id || runningAutomationId !== null) return; + try { + await deleteAutomation(automation.id); + setSelectedAutomation(null); + if (openFiles.length === 0) { + setPanelState(prev => ({ ...prev, editorVisible: false })); + } + setAutomationRefreshKey(current => current + 1); + showToast(`自动化任务 "${automation.title}" 已删除`); + } catch (err) { + console.error('[App] 删除自动化失败:', err); + showToast('删除自动化失败'); + } + }, [openFiles.length, runningAutomationId]); + + const handleCloseAutomationDetail = useCallback(() => { + setSelectedAutomation(null); + if (openFiles.length === 0) { + setPanelState(prev => ({ ...prev, editorVisible: false })); + } + }, [openFiles.length]); + + const handleOpenAutomationRun = useCallback((run: DbAutomationRun) => { + if (!run.sessionId) return; + const sessionId = resolvedSessionIdsRef.current[run.sessionId] || run.sessionId; + if (!sessions.some(session => session.id === sessionId)) { + showToast('该运行记录关联的对话已不存在'); + return; + } + handleCloseAutomationDetail(); + setPanelState(prev => ({ ...prev, chatVisible: true })); + setSidebarCollapsed(false); + setActiveActivity('sessions'); + handleSelectSession(sessionId); + }, [handleCloseAutomationDetail, handleSelectSession, sessions, showToast]); + // 渲染侧边栏内容 const renderSidebarContent = () => { if (sidebarCollapsed) return null; @@ -986,7 +1335,7 @@ function App() { @@ -1013,8 +1362,10 @@ function App() { handleStartPromptCreation('automation')} - onRunAutomation={handleRunAutomation} + onSelectAutomation={handleSelectAutomation} + onAutomationDeleted={handleAutomationDeleted} /> ); case 'skills': @@ -1035,9 +1386,25 @@ function App() { if (!panelState.editorVisible) return null; return (
- Loading editor...
}> - { handleFileClose(path); setDiffFiles(prev => { const next = { ...prev }; delete next[path]; return next; }); }} onContentChange={handleContentChange} onFileSave={handleFileSave} theme={currentTheme} editorTheme={settings.editorTheme} fontSize={settings.fontSize} tabSize={settings.tabSize} autoSave={settings.autoSave} formatOnSave={settings.formatOnSave} diffLines={diffLines} diffFiles={diffFiles} /> - + {selectedAutomation ? ( + { void handleRunAutomationFromDetail(automation); }} + onOpenRun={handleOpenAutomationRun} + onSave={handleSaveAutomation} + onDelete={automation => { void handleDeleteAutomationFromDetail(automation); }} + onClose={handleCloseAutomationDetail} + /> + ) : ( + Loading editor...}> + { handleFileClose(path); setDiffFiles(prev => { const next = { ...prev }; delete next[path]; return next; }); }} onContentChange={handleContentChange} onFileSave={handleFileSave} theme={currentTheme} editorTheme={settings.editorTheme} fontSize={settings.fontSize} tabSize={settings.tabSize} autoSave={settings.autoSave} formatOnSave={settings.formatOnSave} diffLines={diffLines} diffFiles={diffFiles} /> + + )} {bothVisible &&
startResize('editor', e)} />}
); @@ -1050,12 +1417,14 @@ function App() { { - setNewSessionFromProject(!!activeProjectPath); - return handleNewSession(activeProjectPath || UNLINKED_PROJECT, title); + setNewSessionFromProject(false); + setChatWorkspacePath(null); + return handleNewSession(UNLINKED_PROJECT, title); }} sessions={sessions} sessionRunStates={sessionRunStates} maxSteps={settings.maxSteps} onSelectSession={handleSelectSession} providers={settings.providers} agents={settings.agents} activeProviderId={settings.activeProviderId} onActiveProviderChange={(providerId: string) => { setSettings(prev => { const updated = { ...prev, activeProviderId: providerId }; settingsService.save(updated); return updated; }); }} - activeFileName={activeFile?.name} activeFilePath={activeFilePath || undefined} + activeFileName={chatWorkspacePath ? activeFile?.name : undefined} + activeFilePath={chatWorkspacePath ? (activeFilePath || undefined) : undefined} onFileSelect={handleChatFileSelect} reviewFiles={currentSessionId ? (sessionReviewFiles[currentSessionId] || []) : []} onReviewFileSelect={handleDiffFileSelect} @@ -1069,8 +1438,30 @@ function App() { setAutomationPrompt(current => current?.runId === runId ? null : current); }} newSessionFromProject={newSessionFromProject} - onSessionRunStateChange={(sessionId, status) => { + onSessionRunStateChange={(sessionId, status, error) => { setSessionRunStates(prev => ({ ...prev, [sessionId]: status })); + if (status === 'completed' || status === 'error') { + const resolvedSessionId = resolvedSessionIdsRef.current[sessionId] || sessionId; + const automationRunId = automationRunBySessionRef.current[sessionId] + || automationRunBySessionRef.current[resolvedSessionId]; + if (automationRunId) { + for (const [mappedSessionId, mappedRunId] of Object.entries(automationRunBySessionRef.current)) { + if (mappedRunId === automationRunId) delete automationRunBySessionRef.current[mappedSessionId]; + } + const completedAt = new Date().toISOString(); + void updateAutomationRun(automationRunId, { + status, + completedAt, + error: status === 'error' ? getAutomationRunError(error || '会话执行失败') : undefined, + }).then(() => { + setAutomationRefreshKey(current => current + 1); + }).catch(err => { + console.error('[App] 完成自动化运行记录失败:', err); + }); + runningAutomationIdRef.current = null; + setRunningAutomationId(null); + } + } if (status === 'running') { setSessionReviewFiles(prev => { const next = { ...prev }; @@ -1225,7 +1616,7 @@ function App() { onReconnect={() => reconnectBackend((updater) => setSettings(updater))} /> {toast &&
{toast}
} - setSettingsVisible(false)} backendPort={backendPort} workspacePath={activeProjectPath} sessionId={currentSessionId} /> + setSettingsVisible(false)} onSkillInstalled={handleSkillInstalled} backendPort={backendPort} workspacePath={activeProjectPath} sessionId={currentSessionId} /> ); diff --git a/soloncode-desktop/src/components/ChatHeader.css b/soloncode-desktop/src/components/ChatHeader.css index 36a37a6b06e7f4d2340f2c5edd57eba8d735db42..8bc42c7bb80dc4f7bfbc7b7badf1d1bb0a3e1052 100644 --- a/soloncode-desktop/src/components/ChatHeader.css +++ b/soloncode-desktop/src/components/ChatHeader.css @@ -127,19 +127,13 @@ white-space: nowrap; } -.chat-task-section { - margin-top: 12px; - padding-top: 10px; - border-top: 1px solid var(--cb-vscode-panel-border); -} - .chat-review-section { margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--cb-vscode-panel-border); } -.chat-task-heading { +.chat-section-heading { display: flex; align-items: center; justify-content: space-between; @@ -149,150 +143,6 @@ font-weight: 600; } -.chat-task-list { - max-height: 340px; - overflow-y: auto; - display: flex; - flex-direction: column; - gap: 10px; - padding-right: 2px; -} - -.chat-result-card-shell { - width: 100%; - border: 1px solid var(--cb-vscode-panel-border); - border-radius: 8px; - background-color: var(--cb-vscode-input-background); - color: var(--cb-text-primary); - display: flex; - flex-direction: column; - gap: 10px; - padding: 10px; - text-align: left; - cursor: pointer; - transition: border-color 0.16s, background-color 0.16s, box-shadow 0.16s; -} - -.chat-result-card-shell:hover { - border-color: rgba(14, 99, 156, 0.48); - background-color: var(--cb-vscode-list-hoverBackground); -} - -.chat-result-card-shell.selected { - border-color: var(--cb-accent); - box-shadow: 0 0 0 1px rgba(14, 99, 156, 0.18); -} - -.chat-task-card-header { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 10px; - min-width: 0; -} - -.chat-task-card-title { - min-width: 0; - color: var(--cb-text-primary); - font-size: 13px; - font-weight: 600; - line-height: 1.35; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.chat-task-tag { - flex: 0 0 auto; - border: 1px solid transparent; - border-radius: 4px; - padding: 2px 7px; - font-size: 12px; - line-height: 1.25; -} - -.chat-task-tag.info { - color: var(--cb-text-secondary); - background-color: var(--cb-text-secondary); - background-color: rgba(144, 147, 153, 0.12); - border-color: rgba(144, 147, 153, 0.24); -} - -.chat-task-tag.primary { - color: #409eff; - background-color: rgba(64, 158, 255, 0.12); - border-color: rgba(64, 158, 255, 0.24); -} - -.chat-task-tag.success { - color: #67c23a; - background-color: rgba(103, 194, 58, 0.12); - border-color: rgba(103, 194, 58, 0.24); -} - -.chat-task-tag.danger { - color: #f56c6c; - background-color: rgba(245, 108, 108, 0.12); - border-color: rgba(245, 108, 108, 0.24); -} - -.chat-task-card-footer { - display: flex; - flex-direction: column; - gap: 6px; -} - -.chat-task-card-meta { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - color: var(--cb-text-secondary); - font-size: 12px; -} - -.chat-task-card-meta span { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.chat-task-progress { - width: 100%; - height: 6px; - border-radius: 999px; - overflow: hidden; - background-color: rgba(144, 147, 153, 0.16); -} - -.chat-task-progress span { - display: block; - height: 100%; - border-radius: inherit; - background-color: #909399; - transition: width 0.2s ease; -} - -.chat-task-progress.primary span { - background-color: #409eff; -} - -.chat-task-progress.success span { - background-color: #67c23a; -} - -.chat-task-progress.danger span { - background-color: #f56c6c; -} - -.chat-task-empty { - padding: 18px 8px; - color: var(--cb-text-secondary); - font-size: 12px; - text-align: center; -} - .chat-review-file-list { display: flex; flex-direction: column; diff --git a/soloncode-desktop/src/components/ChatHeader.tsx b/soloncode-desktop/src/components/ChatHeader.tsx index bd53b99c4a3227f9d49f4c9563d121b7266a9637..5a1911b3558cfed058f090fb69a235becf3b244a 100644 --- a/soloncode-desktop/src/components/ChatHeader.tsx +++ b/soloncode-desktop/src/components/ChatHeader.tsx @@ -1,15 +1,7 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { Icon } from './common/Icon'; import './ChatHeader.css'; -export interface ChatHeaderTask { - id: string; - title: string; - status: 'pending' | 'in_progress' | 'done'; - group?: string; - line?: number; -} - export interface ChatReviewFile { path: string; status: 'modified' | 'added' | 'deleted' | 'untracked'; @@ -25,9 +17,6 @@ interface ChatHeaderProps { startedAt?: string; totalTokens?: number; totalConversations?: number; - tasks?: ChatHeaderTask[]; - activeTaskId?: string; - onSelectTask?: (id: string) => void; reviewFiles?: ChatReviewFile[]; onReviewFileSelect?: (path: string) => void; openInfoSignal?: number; @@ -64,16 +53,6 @@ function formatRelativeTime(value?: string) { return new Date(value).toLocaleDateString('zh-CN'); } -function getTaskStatusMeta(status?: ChatHeaderTask['status']) { - if (status === 'in_progress') { - return { label: '进行中', tone: 'primary', progress: 50 }; - } - if (status === 'done') { - return { label: '已完成', tone: 'success', progress: 100 }; - } - return { label: '待处理', tone: 'info', progress: 0 }; -} - function getFileName(path: string) { return path.split(/[\\/]/).pop() || path; } @@ -93,9 +72,6 @@ export function ChatHeader({ startedAt, totalTokens = 0, totalConversations = 0, - tasks = [], - activeTaskId, - onSelectTask, reviewFiles = [], onReviewFileSelect, openInfoSignal, @@ -126,19 +102,6 @@ export function ChatHeader({ if (openInfoSignal > 0) setInfoOpen(true); }, [openInfoSignal]); - const sortedTasks = useMemo(() => { - return [...tasks].sort((a, b) => { - if (typeof a.line === 'number' && typeof b.line === 'number') return a.line - b.line; - return 0; - }); - }, [tasks]); - - const handleSelectTask = (id: string) => { - if (!onSelectTask) return; - onSelectTask?.(id); - setInfoOpen(false); - }; - return (
@@ -181,7 +144,7 @@ export function ChatHeader({
{reviewFiles.length > 0 && (
-
+
审查文件 {formatNumber(reviewFiles.length)}
@@ -205,46 +168,6 @@ export function ChatHeader({
)} -
-
- 任务列表 - {formatNumber(sortedTasks.length)} -
-
- {sortedTasks.length === 0 && ( -
暂无任务
- )} - {sortedTasks.map((task, index) => { - const statusMeta = getTaskStatusMeta(task.status); - const isActiveTask = activeTaskId === task.id; - const progress = statusMeta.progress; - return ( - - ); - })} -
-
)} diff --git a/soloncode-desktop/src/components/ChatMessages.tsx b/soloncode-desktop/src/components/ChatMessages.tsx index 95ac26dd370b6f8c262e6629f16db943d8705805..bbbd67632c94e1a3ff50c6229980574203c9938d 100644 --- a/soloncode-desktop/src/components/ChatMessages.tsx +++ b/soloncode-desktop/src/components/ChatMessages.tsx @@ -8,6 +8,7 @@ import { ThinkBlock } from './ThinkBlock'; import { ActionBlock } from './ActionBlock'; import { ActionGroupBlock } from './ActionGroupBlock'; import type { Message, Theme, ContentItem } from '../types'; +import { isTodoToolName } from '../utils/todoTools'; import './ChatMessages.css'; interface ChatMessagesProps { @@ -474,6 +475,14 @@ export const ChatMessages = forwardRef( ({ messages, isLoading, thinkingElapsedSeconds = 0, theme, projectName, onDeleteMessage, onHitlAction, onFileSelect }, ref) => { const virtuosoRef = useRef(null); const autoFollowRef = useRef(true); + const visibleMessages = useMemo(() => { + return messages.reduce((result, message) => { + const contents = message.contents.filter(item => !isTodoToolName(item.toolName)); + if (contents.length === 0) return result; + result.push(contents.length === message.contents.length ? message : { ...message, contents }); + return result; + }, []); + }, [messages]); useImperativeHandle(ref, () => ({ scrollToBottom() { @@ -482,20 +491,20 @@ export const ChatMessages = forwardRef( } })); - const showThinkingRow = isLoading && messages[messages.length - 1]?.role !== 'ASSISTANT'; + const showThinkingRow = isLoading && visibleMessages[visibleMessages.length - 1]?.role !== 'ASSISTANT'; const itemContent = useCallback((index: number) => { - if (showThinkingRow && index === messages.length) { + if (showThinkingRow && index === visibleMessages.length) { return ; } - const message = messages[index]; - const isStreamingMessage = isLoading && index === messages.length - 1 && message?.role === 'ASSISTANT'; + const message = visibleMessages[index]; + const isStreamingMessage = isLoading && index === visibleMessages.length - 1 && message?.role === 'ASSISTANT'; return ( ); - }, [messages, isLoading, showThinkingRow, thinkingElapsedSeconds, theme, onDeleteMessage, onHitlAction, onFileSelect]); + }, [visibleMessages, isLoading, showThinkingRow, thinkingElapsedSeconds, theme, onDeleteMessage, onHitlAction, onFileSelect]); - if (messages.length === 0 && !isLoading) { + if (visibleMessages.length === 0 && !isLoading) { return (
@@ -511,14 +520,14 @@ export const ChatMessages = forwardRef( autoFollowRef.current && isAtBottom ? 'auto' : false} atBottomStateChange={(atBottom) => { autoFollowRef.current = atBottom; }} - initialTopMostItemIndex={Math.max(0, messages.length - 1)} - computeItemKey={(index) => showThinkingRow && index === messages.length ? 'thinking' : (messages[index]?.id ?? index)} + initialTopMostItemIndex={Math.max(0, visibleMessages.length - 1)} + computeItemKey={(index) => showThinkingRow && index === visibleMessages.length ? 'thinking' : (visibleMessages[index]?.id ?? index)} style={{ height: '100%' }} /> {false && ( diff --git a/soloncode-desktop/src/components/ChatTaskList.css b/soloncode-desktop/src/components/ChatTaskList.css new file mode 100644 index 0000000000000000000000000000000000000000..59ba5d20a26a5395cead9a68fa0f1994bc4f0cf1 --- /dev/null +++ b/soloncode-desktop/src/components/ChatTaskList.css @@ -0,0 +1,233 @@ +.chat-task-panel { + position: relative; + z-index: 20; + width: 100%; + max-width: var(--input-max-width, 60%); + margin: 0 auto; + padding: 0 10px 4px; + box-sizing: border-box; +} + +.chat-task-trigger { + width: 100%; + min-height: 38px; + display: flex; + align-items: center; + gap: 9px; + padding: 7px 11px; + border: 1px solid var(--cb-vscode-panel-border); + border-radius: 10px; + background: color-mix(in srgb, var(--cb-vscode-input-background) 82%, var(--cb-vscode-editor-background)); + color: var(--cb-text-primary); + cursor: pointer; + text-align: left; + transition: border-color 0.16s, background-color 0.16s; +} + +.chat-task-trigger:hover, +.chat-task-trigger:focus-visible { + border-color: color-mix(in srgb, var(--cb-accent) 52%, var(--cb-vscode-panel-border)); + background: var(--cb-vscode-list-hoverBackground); + outline: none; +} + +.chat-task-panel.expanded .chat-task-trigger { + border-radius: 0 0 10px 10px; + border-color: color-mix(in srgb, var(--cb-accent) 42%, var(--cb-vscode-panel-border)); +} + +.chat-task-trigger-icon { + width: 22px; + height: 22px; + flex: 0 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 7px; + color: var(--cb-accent); + background: color-mix(in srgb, var(--cb-accent) 13%, transparent); +} + +.chat-task-trigger-main { + min-width: 0; + flex: 1; + display: flex; + align-items: baseline; + gap: 8px; +} + +.chat-task-trigger-title { + flex: 0 0 auto; + font-size: 12px; + font-weight: 650; +} + +.chat-task-trigger-summary { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--cb-text-secondary); + font-size: 11px; +} + +.chat-task-trigger-progress { + width: clamp(48px, 12%, 84px); + height: 4px; + flex: 0 0 auto; + overflow: hidden; + border-radius: 999px; + background: color-mix(in srgb, var(--cb-text-secondary) 18%, transparent); +} + +.chat-task-trigger-progress > span { + display: block; + height: 100%; + border-radius: inherit; + background: #67c23a; + transition: width 0.2s ease; +} + +.chat-task-content { + position: absolute; + right: 10px; + bottom: calc(100% - 1px); + left: 10px; + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--cb-accent) 42%, var(--cb-vscode-panel-border)); + border-bottom: 0; + border-radius: 10px 10px 0 0; + background: color-mix(in srgb, var(--cb-vscode-input-background) 88%, var(--cb-vscode-editor-background)); + box-shadow: 0 -10px 24px rgba(0, 0, 0, 0.2); +} + +.chat-task-items { + max-height: min(260px, 32vh); + margin: 0; + padding: 5px; + overflow-y: auto; + list-style: none; +} + +.chat-task-item { + min-height: 40px; + display: grid; + grid-template-columns: 22px minmax(0, 1fr) auto; + align-items: center; + gap: 9px; + padding: 6px 7px; + border-radius: 7px; +} + +.chat-task-item + .chat-task-item { + border-top: 1px solid color-mix(in srgb, var(--cb-vscode-panel-border) 72%, transparent); + border-radius: 0; +} + +.chat-task-item:hover { + background: var(--cb-vscode-list-hoverBackground); +} + +.chat-task-index { + width: 20px; + height: 20px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid var(--cb-vscode-panel-border); + border-radius: 50%; + color: var(--cb-text-secondary); + font-size: 10px; + line-height: 1; +} + +.chat-task-item.primary .chat-task-index { + border-color: rgba(64, 158, 255, 0.45); + color: #409eff; + background: rgba(64, 158, 255, 0.1); +} + +.chat-task-item.success .chat-task-index { + border-color: rgba(103, 194, 58, 0.4); + color: #67c23a; + background: rgba(103, 194, 58, 0.1); +} + +.chat-task-item-main { + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.chat-task-item-title, +.chat-task-item-meta { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chat-task-item-title { + color: var(--cb-text-primary); + font-size: 12px; + font-weight: 550; +} + +.chat-task-item.success .chat-task-item-title { + color: var(--cb-text-secondary); + text-decoration: line-through; +} + +.chat-task-item-meta { + color: var(--cb-text-secondary); + font-size: 10px; +} + +.chat-task-status { + flex: 0 0 auto; + padding: 2px 6px; + border: 1px solid transparent; + border-radius: 999px; + font-size: 10px; + line-height: 1.25; +} + +.chat-task-status.info { + color: var(--cb-text-secondary); + border-color: rgba(144, 147, 153, 0.24); + background: rgba(144, 147, 153, 0.1); +} + +.chat-task-status.primary { + color: #409eff; + border-color: rgba(64, 158, 255, 0.24); + background: rgba(64, 158, 255, 0.1); +} + +.chat-task-status.success { + color: #67c23a; + border-color: rgba(103, 194, 58, 0.24); + background: rgba(103, 194, 58, 0.1); +} + +.empty-center-container > .chat-task-panel { + max-width: 100%; + padding-right: 0; + padding-left: 0; +} + +.empty-center-container > .chat-task-panel .chat-task-content { + right: 0; + left: 0; +} + +@media (max-width: 720px) { + .chat-task-trigger-progress { + display: none; + } + + .chat-task-status { + display: none; + } +} diff --git a/soloncode-desktop/src/components/ChatTaskList.tsx b/soloncode-desktop/src/components/ChatTaskList.tsx new file mode 100644 index 0000000000000000000000000000000000000000..691161d94100b55ed978d697d059c7e3f46854bf --- /dev/null +++ b/soloncode-desktop/src/components/ChatTaskList.tsx @@ -0,0 +1,88 @@ +import { useId, useMemo, useState } from 'react'; +import { Icon } from './common/Icon'; +import './ChatTaskList.css'; + +export interface ChatTask { + id: string; + title: string; + status: 'pending' | 'in_progress' | 'done'; + group?: string; + line?: number; +} + +interface ChatTaskListProps { + tasks: ChatTask[]; +} + +function getTaskStatusMeta(status: ChatTask['status']) { + if (status === 'in_progress') return { label: '进行中', tone: 'primary' }; + if (status === 'done') return { label: '已完成', tone: 'success' }; + return { label: '待处理', tone: 'info' }; +} + +export function ChatTaskList({ tasks }: ChatTaskListProps) { + const [expanded, setExpanded] = useState(false); + const contentId = useId(); + const sortedTasks = useMemo(() => { + return [...tasks].sort((a, b) => { + if (typeof a.line === 'number' && typeof b.line === 'number') return a.line - b.line; + return 0; + }); + }, [tasks]); + const completedCount = sortedTasks.filter(task => task.status === 'done').length; + const inProgressCount = sortedTasks.filter(task => task.status === 'in_progress').length; + const progress = sortedTasks.length > 0 ? Math.round((completedCount / sortedTasks.length) * 100) : 0; + + if (sortedTasks.length === 0) return null; + + return ( +
+ {expanded && ( +
+
    + {sortedTasks.map((task, index) => { + const status = getTaskStatusMeta(task.status); + return ( +
  1. + {index + 1} + + {task.title} + {(task.group || typeof task.line === 'number') && ( + + {task.group || '当前任务清单'} + {typeof task.line === 'number' && ` · 第 ${task.line} 行`} + + )} + + {status.label} +
  2. + ); + })} +
+
+ )} + +
+ ); +} diff --git a/soloncode-desktop/src/components/ChatView.tsx b/soloncode-desktop/src/components/ChatView.tsx index c1cf76ac70baac806401d0aafe88c6da1d443edb..d496e11e0598b43dc559c718b78e16abe9a01511 100644 --- a/soloncode-desktop/src/components/ChatView.tsx +++ b/soloncode-desktop/src/components/ChatView.tsx @@ -3,12 +3,20 @@ import { invoke } from '@tauri-apps/api/core'; import type { Message, Conversation, Theme, Plugin, ContentType, ContentItem } from '../types'; import { normalizeProviderType, type ModelProvider } from '../services/settingsService'; import { fileService } from '../services/fileService'; -import { saveMessage, getMessagesByConversation } from '../db'; -import { ChatHeader, type ChatHeaderTask, type ChatReviewFile } from './ChatHeader'; +import { saveMessage, updateMessage, getMessagesByConversation } from '../db'; +import { ChatHeader, type ChatReviewFile } from './ChatHeader'; +import { ChatTaskList, type ChatTask } from './ChatTaskList'; import { ChatMessages } from './ChatMessages'; import { ChatInput, type ChatAgentOption, type SendOptions, type ChatMode, type ReasoningEffort } from './ChatInput'; import { Icon } from './common/Icon'; import type { Session } from './sidebar/SessionsPanel'; +import { + buildAutomationPlanningPrompt, + parseGeneratedAutomationPlan, + type GeneratedAutomationPlan, +} from '../utils/automationPlan'; +import { isTodoToolName } from '../utils/todoTools'; +import { withRetry } from '../utils/retry'; import '../views/ChatPage.css'; export type PromptCreationType = 'skill' | 'agent' | 'automation'; @@ -17,6 +25,7 @@ export interface PromptCreationMode { id: string; sessionId: string; type: PromptCreationType; + projectId?: string; template?: string; } @@ -30,7 +39,7 @@ const promptCreationCopy: Record void; onReviewFileDiscard?: (path: string) => void; promptCreation?: PromptCreationMode | null; - onCreateAutomationFromPrompt?: (prompt: string, options: SendOptions) => Promise; + onCreateAutomationFromPrompt?: (plan: GeneratedAutomationPlan, options: SendOptions) => Promise; automationPrompt?: { runId: string; + sessionId: string; prompt: string; modelId: string; modelName: string; reasoningEffort: ReasoningEffort; } | null; onAutomationPromptConsumed?: (runId: string) => void; - onAiCreateComplete?: (info: { type: 'skill' | 'agent'; name: string; error?: string }) => void; + onAiCreateComplete?: (info: { type: PromptCreationType; name: string; error?: string }) => void; newSessionFromProject?: boolean; - onSessionRunStateChange?: (sessionId: string, status: 'running' | 'completed' | 'error') => void; + onSessionRunStateChange?: (sessionId: string, status: 'running' | 'completed' | 'error', error?: string) => void; onSessionMessageSaved?: (sessionId: string, count?: number) => void; } // 全局 WebSocket 连接管理器(每次请求独立连接�? const STREAM_BATCH_INTERVAL_MS = 16; const STREAM_BATCH_CHARS = 24; +const WS_CONNECT_RETRY_DELAYS_MS = [300, 700, 1500]; +const WS_RECONNECT_DELAYS_MS = [500, 1000, 2000, 4000, 8000]; class WebSocketManager { private static instance: WebSocketManager | null = null; private activeWs = new Map(); private intentionallyClosedWs = new WeakSet(); - private messageCallback: ((data: any) => void) | null = null; - private statusCallback: ((sessionId: string, status: 'running' | 'completed' | 'error') => void) | null = null; + private lastSequenceBySession = new Map(); + private reconnectAttempts = new Map(); + private reconnectTimers = new Map>(); + private terminalSessions = new Set(); + private messageCallback: ((data: any) => void | Promise) | null = null; + private statusCallback: ((sessionId: string, status: 'running' | 'completed' | 'error', error?: string) => void) | null = null; private backendPort: number | null = null; private workspacePath: string | null = null; @@ -214,7 +230,7 @@ class WebSocketManager { this.workspacePath = path; } - private getWebSocketUrl(sessionId?: string): string { + private getWebSocketUrl(sessionId?: string, resume = false): string { const host = this.backendPort ? `localhost:${this.backendPort}` : (import.meta.env.VITE_WS_HOST || 'localhost:4808'); @@ -226,16 +242,21 @@ class WebSocketManager { if (this.workspacePath) { params.set('X-Session-Cwd', this.workspacePath); } + if (resume && sessionId) { + params.set('resume', '1'); + params.set('afterSequence', String(this.lastSequenceBySession.get(sessionId) || 0)); + } const query = params.toString(); return `${protocol}://${host}/desktop/ws${query ? '?' + query : ''}`; } /** 每次请求创建独立 WebSocket 连接 */ - private createConnection(sessionId?: string): Promise { + private createConnection(sessionId?: string, resume = false, manageSession = false): Promise { return new Promise((resolve, reject) => { - const wsUrl = this.getWebSocketUrl(sessionId); + const wsUrl = this.getWebSocketUrl(sessionId, resume); console.log('[WS] Connecting to:', wsUrl); const ws = new WebSocket(wsUrl); + if (sessionId && manageSession) this.bindSessionSocket(sessionId, ws); const onOpen = () => { cleanup(); @@ -262,11 +283,25 @@ class WebSocketManager { }); } - registerCallback(callback: (data: any) => void) { + private async createConnectionWithRetry(sessionId: string): Promise { + let lastError: unknown; + for (let attempt = 0; attempt <= WS_CONNECT_RETRY_DELAYS_MS.length; attempt++) { + try { + return await this.createConnection(sessionId); + } catch (error) { + lastError = error; + if (attempt >= WS_CONNECT_RETRY_DELAYS_MS.length) break; + await new Promise(resolve => setTimeout(resolve, WS_CONNECT_RETRY_DELAYS_MS[attempt])); + } + } + throw lastError instanceof Error ? lastError : new Error('WebSocket connection failed'); + } + + registerCallback(callback: (data: any) => void | Promise) { this.messageCallback = callback; } - registerStatusCallback(callback: (sessionId: string, status: 'running' | 'completed' | 'error') => void) { + registerStatusCallback(callback: (sessionId: string, status: 'running' | 'completed' | 'error', error?: string) => void) { this.statusCallback = callback; } @@ -276,71 +311,137 @@ class WebSocketManager { async sendMessage(request: any): Promise { const sessionId = request.sessionId?.toString() || ''; - const ws = await this.createConnection(sessionId); - let terminalReceived = false; + if (!sessionId) throw new Error('Session ID is required'); + this.closeSession(sessionId); + this.terminalSessions.delete(sessionId); + this.lastSequenceBySession.set(sessionId, 0); + this.reconnectAttempts.set(sessionId, 0); + const ws = await this.createConnectionWithRetry(sessionId); if (sessionId) { - this.closeSession(sessionId); this.activeWs.set(sessionId, ws); this.statusCallback?.(sessionId, 'running'); } - ws.onmessage = (event) => { + this.bindSessionSocket(sessionId, ws); + + // sessionId 已通过 URL 参数传递,从 body 中移除。 + const payload = { ...request }; + delete payload.sessionId; + try { + ws.send(JSON.stringify(payload)); + } catch (error) { + this.terminalSessions.add(sessionId); + this.closeSession(sessionId); + throw error; + } + } + + private bindSessionSocket(sessionId: string, ws: WebSocket) { + ws.onmessage = (event) => this.handleSessionMessage(sessionId, ws, event); + ws.onclose = () => { + if (this.activeWs.get(sessionId) === ws) { + this.activeWs.delete(sessionId); + } + if (!this.terminalSessions.has(sessionId) && !this.intentionallyClosedWs.has(ws)) { + this.scheduleReconnect(sessionId); + } + }; + } + + private handleSessionMessage(sessionId: string, ws: WebSocket, event: MessageEvent) { + try { + const data = event.data; + if (typeof data !== 'string') throw new Error('Unsupported WebSocket frame type'); + if (data.trim() === '[DONE]') { + this.finishSession(sessionId, ws, { type: 'done', sessionId }); + return; + } + const parsed: unknown = JSON.parse(data); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('Invalid WebSocket message'); + const msg = parsed as Record; + if (typeof msg.type !== 'string') throw new Error('WebSocket message type is required'); + if (msg.text !== undefined && typeof msg.text !== 'string') throw new Error('Invalid WebSocket message text'); + if (msg.sessionId !== undefined && typeof msg.sessionId !== 'string' && typeof msg.sessionId !== 'number') { + throw new Error('Invalid WebSocket session ID'); + } + + const msgSessionId = (msg.sessionId || sessionId).toString(); + msg.sessionId = msgSessionId; + this.reconnectAttempts.set(msgSessionId, 0); + if (typeof msg.sequence === 'number' && Number.isSafeInteger(msg.sequence) && msg.sequence > 0) { + const previousSequence = this.lastSequenceBySession.get(msgSessionId) || 0; + if (msg.sequence <= previousSequence) return; + this.lastSequenceBySession.set(msgSessionId, msg.sequence); + } + + const messageType = msg.type.toLowerCase(); + if (messageType === 'done' || messageType === 'error') { + this.finishSession(msgSessionId, ws, msg); + return; + } + this.dispatchMessage(msg); + } catch (error) { + console.warn('[WS] Failed to parse message:', error); + } + } + + private finishSession(sessionId: string, ws: WebSocket, message: Record) { + this.terminalSessions.add(sessionId); + this.clearReconnectTimer(sessionId); + const messageType = String(message.type || '').toLowerCase(); + if (messageType === 'done') this.statusCallback?.(sessionId, 'completed'); + else this.statusCallback?.(sessionId, 'error', message.text || '会话执行失败'); + this.dispatchMessage(message); + if (ws.readyState === WebSocket.OPEN) ws.close(); + } + + private scheduleReconnect(sessionId: string) { + if (this.reconnectTimers.has(sessionId) || this.terminalSessions.has(sessionId)) return; + const attempt = this.reconnectAttempts.get(sessionId) || 0; + if (attempt >= WS_RECONNECT_DELAYS_MS.length) { + this.terminalSessions.add(sessionId); + const text = '连接恢复失败,请重试'; + this.statusCallback?.(sessionId, 'error', text); + this.dispatchMessage({ type: 'error', sessionId, text }); + return; + } + + const timer = setTimeout(async () => { + this.reconnectTimers.delete(sessionId); + if (this.terminalSessions.has(sessionId)) return; + this.reconnectAttempts.set(sessionId, attempt + 1); try { - const data = event.data; - if (typeof data !== 'string') { - throw new Error('Unsupported WebSocket frame type'); - } - if (data.trim() === '[DONE]') { - terminalReceived = true; - if (sessionId) this.statusCallback?.(sessionId, 'completed'); - this.messageCallback?.({ type: 'done', sessionId }); + const ws = await this.createConnection(sessionId, true, true); + if (this.terminalSessions.has(sessionId)) { + this.intentionallyClosedWs.add(ws); ws.close(); return; } - const parsed: unknown = JSON.parse(data); - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { - throw new Error('Invalid WebSocket message'); - } - const msg = parsed as Record; - if (typeof msg.type !== 'string') { - throw new Error('WebSocket message type is required'); - } - if (msg.text !== undefined && typeof msg.text !== 'string') { - throw new Error('Invalid WebSocket message text'); - } - if (msg.sessionId !== undefined && typeof msg.sessionId !== 'string' && typeof msg.sessionId !== 'number') { - throw new Error('Invalid WebSocket session ID'); - } - const msgSessionId = (msg.sessionId || sessionId || '').toString(); - if (msgSessionId && !msg.sessionId) msg.sessionId = msgSessionId; - const messageType = typeof msg.type === 'string' ? msg.type.toLowerCase() : ''; - if (messageType === 'done' || messageType === 'error') terminalReceived = true; - if (msgSessionId && messageType === 'done') this.statusCallback?.(msgSessionId, 'completed'); - if (msgSessionId && messageType === 'error') this.statusCallback?.(msgSessionId, 'error'); - this.messageCallback?.(msg); - if (terminalReceived) ws.close(); - } catch (e) { - console.warn('[WS] Failed to parse message:', e); + this.activeWs.set(sessionId, ws); + this.statusCallback?.(sessionId, 'running'); + } catch (error) { + console.warn(`[WS] Reconnect attempt ${attempt + 1} failed:`, error); + this.scheduleReconnect(sessionId); } - }; + }, WS_RECONNECT_DELAYS_MS[attempt]); + this.reconnectTimers.set(sessionId, timer); + } - ws.onclose = () => { - if (sessionId && this.activeWs.get(sessionId) === ws) { - this.activeWs.delete(sessionId); - } - if (sessionId && !terminalReceived && !this.intentionallyClosedWs.has(ws)) { - this.statusCallback?.(sessionId, 'error'); - this.messageCallback?.({ - type: 'error', - sessionId, - text: 'WebSocket 连接已中断,请重试', - }); + private dispatchMessage(message: Record) { + try { + const result = this.messageCallback?.(message); + if (result) { + void result.catch(error => console.error('[WS] Message handler failed:', error)); } - }; + } catch (error) { + console.error('[WS] Message handler failed:', error); + } + } - // sessionId 已通过 URL 参数传递,�?body 中移�? - delete request.sessionId; - ws.send(JSON.stringify(request)); + private clearReconnectTimer(sessionId: string) { + const timer = this.reconnectTimers.get(sessionId); + if (timer) clearTimeout(timer); + this.reconnectTimers.delete(sessionId); } /** 取消当前请求:关闭连�?*/ @@ -349,7 +450,19 @@ class WebSocketManager { } cancelSession(sessionId: string) { - this.closeSession(sessionId); + this.terminalSessions.add(sessionId); + this.clearReconnectTimer(sessionId); + const ws = this.activeWs.get(sessionId); + if (ws?.readyState === WebSocket.OPEN) { + try { + ws.send(JSON.stringify({ input: '[(sec)interrupt]' })); + } catch { + // Closing the socket below is still sufficient to stop client-side streaming. + } + setTimeout(() => this.closeSession(sessionId), 50); + } else { + this.closeSession(sessionId); + } } getSessionSocket(sessionId: string): WebSocket | null { @@ -372,6 +485,7 @@ class WebSocketManager { } private closeActive() { + for (const sessionId of this.reconnectTimers.keys()) this.clearReconnectTimer(sessionId); for (const ws of this.activeWs.values()) { this.intentionallyClosedWs.add(ws); ws.close(); @@ -380,6 +494,7 @@ class WebSocketManager { } private closeSession(sessionId: string) { + this.clearReconnectTimer(sessionId); const ws = this.activeWs.get(sessionId); if (ws) { this.intentionallyClosedWs.add(ws); @@ -414,17 +529,8 @@ function estimateMessageTokens(messages: Message[]) { return Math.ceil(text.length / 4); } -function normalizeTodoToolName(toolName?: string) { - return (toolName || '').toLowerCase().replace(/[_\s-]/g, ''); -} - -function isTodoTool(toolName?: string) { - const name = normalizeTodoToolName(toolName); - return name === 'todoread' || name === 'todowrite'; -} - -function parseTodoMarkdown(raw: string): ChatHeaderTask[] { - const tasks: ChatHeaderTask[] = []; +function parseTodoMarkdown(raw: string): ChatTask[] { + const tasks: ChatTask[] = []; let currentGroup = ''; String(raw || '').split(/\r?\n/).forEach((line, index) => { const heading = line.match(/^\s*##\s+(.+)$/); @@ -435,7 +541,7 @@ function parseTodoMarkdown(raw: string): ChatHeaderTask[] { const match = line.match(/^\s*-\s*\[([ xX/])\]\s+(.+)$/); if (!match) return; const statusChar = match[1]; - const status: ChatHeaderTask['status'] = statusChar === ' ' + const status: ChatTask['status'] = statusChar === ' ' ? 'pending' : (statusChar === '/' ? 'in_progress' : 'done'); const title = match[2].trim(); @@ -451,14 +557,14 @@ function parseTodoMarkdown(raw: string): ChatHeaderTask[] { } function getTodoMarkdownFromContent(item: ContentItem) { - if (!isTodoTool(item.toolName)) return null; + if (!isTodoToolName(item.toolName)) return null; const todosArg = item.args?.todos; if (typeof todosArg === 'string' && todosArg.trim()) return todosArg; return item.text || ''; } -function extractLatestTodoTasks(messages: Message[]): ChatHeaderTask[] { - let latest: ChatHeaderTask[] | null = null; +function extractLatestTodoTasks(messages: Message[]): ChatTask[] { + let latest: ChatTask[] | null = null; for (const message of messages) { for (const item of message.contents || []) { const markdown = getTodoMarkdownFromContent(item); @@ -469,7 +575,7 @@ function extractLatestTodoTasks(messages: Message[]): ChatHeaderTask[] { return latest || []; } -function mapTodoApiItems(items: any[]): ChatHeaderTask[] { +function mapTodoApiItems(items: any[]): ChatTask[] { return (Array.isArray(items) ? items : []).map((item, index) => { const status = item.status === 'done' ? 'done' @@ -482,7 +588,7 @@ function mapTodoApiItems(items: any[]): ChatHeaderTask[] { status, group: item.group || '', line, - } satisfies ChatHeaderTask; + } satisfies ChatTask; }); } @@ -662,7 +768,7 @@ export function ChatView({ currentConversation, plugins, workspacePath, projectN const [chatMode, setChatMode] = useState('default'); const [reviewInfoSignal, setReviewInfoSignal] = useState(0); const [thinkingElapsedSeconds, setThinkingElapsedSeconds] = useState(0); - const [sessionTodoTasks, setSessionTodoTasks] = useState>({}); + const [sessionTodoTasks, setSessionTodoTasks] = useState>({}); const chatMessagesRef = useRef<{ scrollToBottom: () => void } | null>(null); const sessionIdRef = useRef(''); const conversationIdRef = useRef(''); @@ -670,7 +776,7 @@ export function ChatView({ currentConversation, plugins, workspacePath, projectN const streamingSessionIdRef = useRef(null); const thinkingStartedAtBySessionRef = useRef(new Map()); const aiCreateRef = useRef< - { type: 'skill'; name: string } | { type: 'agent' } | null + { type: 'skill'; name: string } | { type: 'agent' } | { type: 'automation'; options: SendOptions } | null >(null); const automationPromptSentRef = useRef(null); const onUpdateSessionTitleRef = useRef(onUpdateSessionTitle); @@ -681,6 +787,10 @@ export function ChatView({ currentConversation, plugins, workspacePath, projectN onSessionRunStateChangeRef.current = onSessionRunStateChange; const onSessionMessageSavedRef = useRef(onSessionMessageSaved); onSessionMessageSavedRef.current = onSessionMessageSaved; + const onCreateAutomationFromPromptRef = useRef(onCreateAutomationFromPrompt); + onCreateAutomationFromPromptRef.current = onCreateAutomationFromPrompt; + const onAiCreateCompleteRef = useRef(onAiCreateComplete); + onAiCreateCompleteRef.current = onAiCreateComplete; const workspacePathRef = useRef(workspacePath); workspacePathRef.current = workspacePath; @@ -819,6 +929,7 @@ export function ChatView({ currentConversation, plugins, workspacePath, projectN item.forceNewSegment = false; } buildLiveMessages(item.sessionId, segments); + scheduleAssistantPersistence(item.sessionId); return; } if (item.type === 'ACTION') { @@ -829,6 +940,7 @@ export function ChatView({ currentConversation, plugins, workspacePath, projectN item.forceNewSegment = false; } buildLiveMessages(item.sessionId, segments); + scheduleAssistantPersistence(item.sessionId); return; } if (last && last.type === 'TEXT' && !item.forceNewSegment) { @@ -839,6 +951,7 @@ export function ChatView({ currentConversation, plugins, workspacePath, projectN item.forceNewSegment = false; } buildLiveMessages(item.sessionId, segments); + scheduleAssistantPersistence(item.sessionId); } function hasPendingQueuedChars(sessionId: string) { @@ -936,16 +1049,22 @@ export function ChatView({ currentConversation, plugins, workspacePath, projectN thinkingStartedAtBySessionRef.current.delete(sessionId); } - const pendingPersistRef = useRef<{ + type PendingPersist = { sessionId: string; userMessage: { timestamp: string; contents: string }; messageText: string; - } | null>(null); - const pendingPersistBySessionRef = useRef(new Map()); + userMessageId?: number; + }; + type AssistantDraftPersistence = { + messageId?: number; + lastContents?: string; + metadata?: Message['metadata']; + timer?: ReturnType; + writeChain: Promise; + }; + const pendingPersistRef = useRef(null); + const pendingPersistBySessionRef = useRef(new Map()); + const assistantDraftsBySessionRef = useRef(new Map()); // 当前 assistant 消息 ID const assistantMsgIdRef = useRef(0); @@ -966,7 +1085,7 @@ export function ChatView({ currentConversation, plugins, workspacePath, projectN isStreamingRef.current = false; streamingSessionIdRef.current = null; if (timedOutSessionId) { - onSessionRunStateChangeRef.current?.(timedOutSessionId, 'error'); + onSessionRunStateChangeRef.current?.(timedOutSessionId, 'error', '等待响应超时'); } }, 120000); }, []); @@ -978,7 +1097,7 @@ export function ChatView({ currentConversation, plugins, workspacePath, projectN } }, []); const updateSessionTodosFromTool = useCallback((sessionId: string, toolName?: string, text?: string, args?: Record) => { - if (!sessionId || !isTodoTool(toolName)) return; + if (!sessionId || !isTodoToolName(toolName)) return; const todosArg = args?.todos; const markdown = typeof todosArg === 'string' && todosArg.trim() ? todosArg : (text || ''); setSessionTodoTasks(prev => ({ @@ -1029,6 +1148,70 @@ export function ChatView({ currentConversation, plugins, workspacePath, projectN .filter(item => item.text.length > 0); } + function getAssistantDraftState(sessionId: string) { + let state = assistantDraftsBySessionRef.current.get(sessionId); + if (!state) { + state = { writeChain: Promise.resolve() }; + assistantDraftsBySessionRef.current.set(sessionId, state); + } + return state; + } + + async function writeAssistantSnapshot(sessionId: string, state: AssistantDraftPersistence) { + const segments = isSessionVisible(sessionId) + ? accumulatedContentRef.current + : (backgroundContentBySessionRef.current.get(sessionId) || []); + const items = buildContentItems(segments); + if (items.length === 0) return; + const contents = JSON.stringify({ items, metadata: state.metadata }); + if (contents === state.lastContents) return; + + if (state.messageId) { + await withRetry(() => updateMessage(state.messageId!, { + contents, + })); + } else { + state.messageId = await withRetry(() => saveMessage({ + conversationId: sessionId, + role: 'ASSISTANT', + timestamp: new Date().toLocaleTimeString(), + contents, + workspacePath: workspacePathRef.current, + })); + onSessionMessageSavedRef.current?.(sessionId, 1); + } + state.lastContents = contents; + } + + function scheduleAssistantPersistence(sessionId: string) { + const state = getAssistantDraftState(sessionId); + if (state.timer) return; + const delay = state.messageId ? 200 : 0; + state.timer = setTimeout(() => { + state.timer = undefined; + state.writeChain = state.writeChain + .then(() => writeAssistantSnapshot(sessionId, state!)) + .catch(error => console.error('[ChatView] 增量保存助手消息失败:', error)); + }, delay); + } + + async function flushAssistantPersistence(sessionId: string, metadata?: Message['metadata']) { + const state = getAssistantDraftState(sessionId); + state.metadata = metadata || state.metadata; + if (state.timer) { + clearTimeout(state.timer); + state.timer = undefined; + } + state.writeChain = state.writeChain.then(() => writeAssistantSnapshot(sessionId, state)); + try { + await state.writeChain; + } catch (error) { + console.error('[ChatView] 保存助手消息最终状态失败:', error); + } finally { + assistantDraftsBySessionRef.current.delete(sessionId); + } + } + // 注册消息回调(只注册一次,通过 ref 获取当前 sessionId�? useEffect(() => { const wsManager = WebSocketManager.getInstance(); @@ -1045,14 +1228,16 @@ export function ChatView({ currentConversation, plugins, workspacePath, projectN pendingPersistRef.current = null; } - await saveMessage({ - conversationId: pending.sessionId, - role: 'USER', - timestamp: pending.userMessage.timestamp, - contents: pending.userMessage.contents, - workspacePath: workspacePathRef.current, - }); - onSessionMessageSavedRef.current?.(pending.sessionId, 1); + if (!pending.userMessageId) { + pending.userMessageId = await withRetry(() => saveMessage({ + conversationId: pending.sessionId, + role: 'USER', + timestamp: pending.userMessage.timestamp, + contents: pending.userMessage.contents, + workspacePath: workspacePathRef.current, + })); + onSessionMessageSavedRef.current?.(pending.sessionId, 1); + } return { sessionId: pending.sessionId, @@ -1072,20 +1257,11 @@ export function ChatView({ currentConversation, plugins, workspacePath, projectN const pending = await flushPendingUserMessage(msgSessionId); const backgroundSegments = backgroundContentBySessionRef.current.get(msgSessionId) || []; const contentItems = buildContentItems(backgroundSegments); - if (contentItems.length > 0) { - await saveMessage({ - conversationId: pending?.sessionId || msgSessionId, - role: 'ASSISTANT', - timestamp: new Date().toLocaleTimeString(), - contents: JSON.stringify({ items: contentItems, metadata: { - modelName: data.modelName, - totalTokens: data.totalTokens, - elapsedMs: data.elapsedMs, - } }), - workspacePath: workspacePathRef.current, - }); - onSessionMessageSavedRef.current?.(pending?.sessionId || msgSessionId, 1); - } + await flushAssistantPersistence(msgSessionId, contentItems.length > 0 ? { + modelName: data.modelName, + totalTokens: data.totalTokens, + elapsedMs: data.elapsedMs, + } : undefined); if (pending?.wasNew && onUpdateSessionTitleRef.current) { onUpdateSessionTitleRef.current(pending.sessionId, pending.title); } @@ -1123,15 +1299,9 @@ export function ChatView({ currentConversation, plugins, workspacePath, projectN return [...filtered, finalMsg]; }); - // 保存助手消息(用 temp ID,后�?reassignMessages 会统一转换�? - await saveMessage({ - conversationId: pending?.sessionId || msgSessionId, - role: 'ASSISTANT', - timestamp: finalMsg.timestamp, - contents: JSON.stringify({ items: contentItems, metadata: finalMsg.metadata }), - workspacePath: workspacePathRef.current, - }); - onSessionMessageSavedRef.current?.(pending?.sessionId || msgSessionId, 1); + await flushAssistantPersistence(msgSessionId, finalMsg.metadata); + } else { + await flushAssistantPersistence(msgSessionId); } // 所有消息保存后,触发会话持久化(reassignMessages 会把 temp ID 转为 real ID�? @@ -1153,8 +1323,8 @@ export function ChatView({ currentConversation, plugins, workspacePath, projectN if (type === 'skill') { const { name } = creation; await invoke('create_skill', { name, description: '', content: aiContent }); - onAiCreateComplete?.({ type, name }); - } else { + onAiCreateCompleteRef.current?.({ type, name }); + } else if (type === 'agent') { const generatedName = extractGeneratedAgentName(aiContent); if (!generatedName) { throw new Error('AI 未生成有效的 Agent 名称'); @@ -1162,16 +1332,20 @@ export function ChatView({ currentConversation, plugins, workspacePath, projectN const name = await createUniqueAgentName(generatedName); const content = applyGeneratedAgentName(aiContent, name); await invoke('create_agent', { name, description: '', content }); - onAiCreateComplete?.({ type, name }); + onAiCreateCompleteRef.current?.({ type, name }); + } else { + const plan = parseGeneratedAutomationPlan(aiContent); + if (!onCreateAutomationFromPromptRef.current) throw new Error('自动化保存处理器不可用'); + await onCreateAutomationFromPromptRef.current(plan, creation.options); } } catch (err) { console.error('[ChatView] AI 创建自动保存失败:', err); - const displayName = type === 'skill' ? creation.name : '自动命名 Agent'; - onAiCreateComplete?.({ type, name: displayName, error: String(err) }); + const displayName = type === 'skill' ? creation.name : type === 'agent' ? '自动命名 Agent' : '自动化'; + onAiCreateCompleteRef.current?.({ type, name: displayName, error: String(err) }); } } else { - const displayName = type === 'skill' ? creation.name : '自动命名 Agent'; - onAiCreateComplete?.({ type, name: displayName, error: 'AI 未返回可保存的内容' }); + const displayName = type === 'skill' ? creation.name : type === 'agent' ? '自动命名 Agent' : '自动化'; + onAiCreateCompleteRef.current?.({ type, name: displayName, error: 'AI 未返回可保存的内容' }); } aiCreateRef.current = null; } @@ -1191,15 +1365,28 @@ export function ChatView({ currentConversation, plugins, workspacePath, projectN } if (data.type === 'error') { + if (aiCreateRef.current) { + const creation = aiCreateRef.current; + aiCreateRef.current = null; + const displayName = creation.type === 'skill' + ? creation.name + : (creation.type === 'agent' ? '自动命名 Agent' : '自动化'); + onAiCreateCompleteRef.current?.({ + type: creation.type, + name: displayName, + error: data.text || '生成请求失败', + }); + } if (!isCurrentSession) { const pending = await flushPendingUserMessage(msgSessionId); - await saveMessage({ + await flushAssistantPersistence(msgSessionId); + await withRetry(() => saveMessage({ conversationId: pending?.sessionId || msgSessionId, role: 'ERROR', timestamp: new Date().toLocaleTimeString(), contents: JSON.stringify([{ type: 'ERROR', text: data.text || '未知错误' }]), workspacePath: workspacePathRef.current, - }); + })); onSessionMessageSavedRef.current?.(pending?.sessionId || msgSessionId, 1); if (pending?.wasNew && onUpdateSessionTitleRef.current) { onUpdateSessionTitleRef.current(pending.sessionId, pending.title); @@ -1217,6 +1404,7 @@ export function ChatView({ currentConversation, plugins, workspacePath, projectN // 即使出错也要持久化用户消�? const pending = await flushPendingUserMessage(msgSessionId); + await flushAssistantPersistence(msgSessionId); const errorText = data.text || '未知错误'; const errorMsg: Message = { @@ -1227,13 +1415,13 @@ export function ChatView({ currentConversation, plugins, workspacePath, projectN }; setMessages(prev => [...prev, errorMsg]); - await saveMessage({ + await withRetry(() => saveMessage({ conversationId: pending?.sessionId || msgSessionId, role: 'ERROR', timestamp: errorMsg.timestamp, contents: JSON.stringify(errorMsg.contents), workspacePath: workspacePathRef.current, - }); + })); onSessionMessageSavedRef.current?.(pending?.sessionId || msgSessionId, 1); // 所有消息保存后,触发会话持久化 @@ -1289,7 +1477,7 @@ export function ChatView({ currentConversation, plugins, workspacePath, projectN ) as ContentType; let text = filterEmptyTags(data.text || ''); if (rawType === 'COMMAND') text += '\n'; - if (type === 'ACTION' && isTodoTool(data.toolName)) { + if (type === 'ACTION' && isTodoToolName(data.toolName)) { updateSessionTodosFromTool(msgSessionId, data.toolName, text, data.args); } @@ -1360,6 +1548,7 @@ export function ChatView({ currentConversation, plugins, workspacePath, projectN } break; } + scheduleAssistantPersistence(msgSessionId); // 实时更新显示(RAF 节流,合并多�?chunk�? if (isCurrentSession) { @@ -1368,8 +1557,8 @@ export function ChatView({ currentConversation, plugins, workspacePath, projectN }; wsManager.registerCallback(handleMessage); - wsManager.registerStatusCallback((sessionId, status) => { - onSessionRunStateChangeRef.current?.(sessionId, status); + wsManager.registerStatusCallback((sessionId, status, error) => { + onSessionRunStateChangeRef.current?.(sessionId, status, error); }); return () => { @@ -1402,7 +1591,7 @@ export function ChatView({ currentConversation, plugins, workspacePath, projectN } if (contextParts.length > 0) { - fullMessage = `${contextParts.join('\n\n')}\n\n${messageText}`; + fullMessage = `${contextParts.join('\n\n')}\n\n${fullMessage}`; } // 拼接文本附件内容(图片通过 attachments 字段单独发送) @@ -1478,6 +1667,16 @@ export function ChatView({ currentConversation, plugins, workspacePath, projectN try { const wsManager = WebSocketManager.getInstance(); + pendingPersist.userMessageId = await withRetry(() => saveMessage({ + conversationId: pendingPersist.sessionId, + role: 'USER', + timestamp: pendingPersist.userMessage.timestamp, + contents: pendingPersist.userMessage.contents, + workspacePath, + })); + onSessionMessageSavedRef.current?.(pendingPersist.sessionId, 1); + getAssistantDraftState(pendingPersist.sessionId); + // 开启会话时注册模型到后�? // options.model 格式: "providerId" �?"providerId__modelId" const sepIdx = options.model.indexOf('__'); @@ -1543,14 +1742,17 @@ export function ChatView({ currentConversation, plugins, workspacePath, projectN if (pending) { pendingPersistBySessionRef.current.delete(pending.sessionId); if (pendingPersistRef.current?.sessionId === pending.sessionId) pendingPersistRef.current = null; - await saveMessage({ - conversationId: pending.sessionId, - role: 'USER', - timestamp: pending.userMessage.timestamp, - contents: pending.userMessage.contents, - workspacePath, - }); - onSessionMessageSaved?.(pending.sessionId, 1); + if (!pending.userMessageId) { + pending.userMessageId = await withRetry(() => saveMessage({ + conversationId: pending.sessionId, + role: 'USER', + timestamp: pending.userMessage.timestamp, + contents: pending.userMessage.contents, + workspacePath, + })); + onSessionMessageSaved?.(pending.sessionId, 1); + } + await flushAssistantPersistence(pending.sessionId); const errorMessage: Message = { id: Date.now() + 1, @@ -1560,13 +1762,13 @@ export function ChatView({ currentConversation, plugins, workspacePath, projectN }; setMessages(prev => [...prev, errorMessage]); - await saveMessage({ + await withRetry(() => saveMessage({ conversationId: pending.sessionId, role: 'ERROR', timestamp: errorMessage.timestamp, contents: JSON.stringify(errorMessage.contents), workspacePath, - }); + })); onSessionMessageSaved?.(pending.sessionId, 1); // 触发会话持久化(reassignMessages 会处�?temp→real�? @@ -1584,12 +1786,20 @@ export function ChatView({ currentConversation, plugins, workspacePath, projectN if (aiCreateRef.current) { const creation = aiCreateRef.current; aiCreateRef.current = null; - onAiCreateComplete?.({ + const displayName = creation.type === 'skill' + ? creation.name + : (creation.type === 'agent' ? '自动命名 Agent' : '自动化'); + onAiCreateCompleteRef.current?.({ type: creation.type, - name: creation.type === 'skill' ? creation.name : '自动命名 Agent', + name: displayName, error: '生成请求失败', }); } + onSessionRunStateChangeRef.current?.( + sessionId!, + 'error', + error instanceof Error ? error.message : '命令发送失败', + ); } }, [currentConversation, onAiCreateComplete, onNewSession, onUpdateSessionTitle, workspacePath, providers, activeFilePath, maxSteps]); @@ -1597,7 +1807,7 @@ export function ChatView({ currentConversation, plugins, workspacePath, projectN useEffect(() => { if (!automationPrompt || automationPromptSentRef.current === automationPrompt.runId) return; const conversationId = currentConversation.id?.toString(); - if (!conversationId) return; + if (!conversationId || conversationId !== automationPrompt.sessionId) return; automationPromptSentRef.current = automationPrompt.runId; void sendMessage(automationPrompt.prompt, { @@ -1620,7 +1830,8 @@ export function ChatView({ currentConversation, plugins, workspacePath, projectN } if (activeCreation.type === 'automation') { - void onCreateAutomationFromPrompt?.(message, options); + aiCreateRef.current = { type: 'automation', options }; + void sendMessage(message, options, buildAutomationPlanningPrompt(message)); return; } @@ -1697,8 +1908,11 @@ export function ChatView({ currentConversation, plugins, workspacePath, projectN const id = currentConversationId?.toString(); if (!id || id.startsWith('temp-') || id.startsWith('pending-') || !backendPort) return; let cancelled = false; - fetch(`http://localhost:${backendPort}/web/chat/todos?sessionId=${encodeURIComponent(id)}`) - .then(resp => resp.ok ? resp.json() : null) + withRetry(async () => { + const resp = await fetch(`http://localhost:${backendPort}/web/chat/todos?sessionId=${encodeURIComponent(id)}`); + if (!resp.ok) throw new Error(`Todo request failed: ${resp.status}`); + return resp.json(); + }, [300, 700, 1500]) .then(res => { if (cancelled) return; const items = res?.data?.items || []; @@ -1737,7 +1951,7 @@ export function ChatView({ currentConversation, plugins, workspacePath, projectN } }, []); - const handleStop = useCallback(() => { + const handleStop = useCallback(async () => { const stoppedSessionId = sessionIdRef.current; WebSocketManager.getInstance().cancelSession(stoppedSessionId); clearLoadingTimer(); @@ -1764,6 +1978,17 @@ export function ChatView({ currentConversation, plugins, workspacePath, projectN }); } + await flushAssistantPersistence(stoppedSessionId); + const pending = pendingPersistBySessionRef.current.get(stoppedSessionId); + if (pending) { + pendingPersistBySessionRef.current.delete(stoppedSessionId); + if (pendingPersistRef.current?.sessionId === stoppedSessionId) pendingPersistRef.current = null; + if (pending.sessionId.startsWith('temp-') && onUpdateSessionTitleRef.current) { + const title = pending.messageText.trim().slice(0, 20) + (pending.messageText.trim().length > 20 ? '...' : ''); + void onUpdateSessionTitleRef.current(pending.sessionId, title); + } + } + // 重置累积�? accumulatedContentRef.current = []; clearLiveSession(stoppedSessionId); @@ -1843,7 +2068,6 @@ export function ChatView({ currentConversation, plugins, workspacePath, projectN startedAt={headerStartedAt} totalTokens={headerTotalTokens} totalConversations={totalConversationCount} - tasks={currentTodoTasks} reviewFiles={reviewFiles} onReviewFileSelect={onReviewFileSelect} openInfoSignal={reviewInfoSignal} @@ -1862,6 +2086,7 @@ export function ChatView({ currentConversation, plugins, workspacePath, projectN {showReviewFiles && ( )} +
) : ( @@ -1869,6 +2094,7 @@ export function ChatView({ currentConversation, plugins, workspacePath, projectN {showReviewFiles && ( )} + )} diff --git a/soloncode-desktop/src/components/common/ConfirmDialog.css b/soloncode-desktop/src/components/common/ConfirmDialog.css index f1f8476d8f9be8f67393e3d1f267ba641f4e728e..ca56cabfb984f1b71e8b4c97bb7e989105c64c22 100644 --- a/soloncode-desktop/src/components/common/ConfirmDialog.css +++ b/soloncode-desktop/src/components/common/ConfirmDialog.css @@ -50,6 +50,36 @@ margin-bottom: 20px; } +.confirm-input-field { + display: flex; + flex-direction: column; + gap: 6px; + margin: -6px 0 20px; + color: var(--cb-text-secondary, #858699); + font-size: 11px; +} + +.confirm-input-field input { + height: 30px; + padding: 0 8px; + border: 1px solid var(--cb-vscode-input-border, var(--cb-vscode-widget-border, #474747)); + border-radius: 4px; + outline: none; + background: var(--cb-vscode-input-background, #3c3c3c); + color: var(--cb-vscode-input-foreground, var(--cb-text-primary, #cccccc)); + font: inherit; + font-size: 13px; +} + +.confirm-input-field input:focus { + border-color: var(--cb-accent); +} + +.confirm-input-field small { + color: var(--cb-vscode-errorForeground, #f14c4c); + font-size: 10px; +} + .confirm-actions { display: flex; justify-content: flex-end; @@ -71,6 +101,11 @@ background: var(--cb-vscode-list-hoverBackground, #2a2d2e); } +.confirm-btn:disabled { + opacity: 0.45; + cursor: default; +} + .confirm-btn.danger { background: #c74e39; border-color: #c74e39; diff --git a/soloncode-desktop/src/components/common/ConfirmDialog.tsx b/soloncode-desktop/src/components/common/ConfirmDialog.tsx index 59d0eb831b25009053764b751507477075bf9844..6bbb2a1d7e811ad389f848389b95238e99298463 100644 --- a/soloncode-desktop/src/components/common/ConfirmDialog.tsx +++ b/soloncode-desktop/src/components/common/ConfirmDialog.tsx @@ -10,6 +10,11 @@ interface ConfirmDialogProps { confirmLabel?: string; cancelLabel?: string; danger?: boolean; + inputLabel?: string; + inputValue?: string; + inputError?: string; + confirmDisabled?: boolean; + onInputChange?: (value: string) => void; onConfirm: () => void; onCancel: () => void; } @@ -20,25 +25,52 @@ export function ConfirmDialog({ confirmLabel = '确认', cancelLabel = '取消', danger = false, + inputLabel, + inputValue, + inputError, + confirmDisabled = false, + onInputChange, onConfirm, onCancel, }: ConfirmDialogProps) { const confirmRef = useRef(null); + const inputRef = useRef(null); + + useEffect(() => { + if (inputValue !== undefined) { + inputRef.current?.focus(); + inputRef.current?.select(); + } else { + confirmRef.current?.focus(); + } + }, []); useEffect(() => { - confirmRef.current?.focus(); const handleKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onCancel(); + if (e.key === 'Enter' && inputValue !== undefined && !confirmDisabled) onConfirm(); }; document.addEventListener('keydown', handleKey); return () => document.removeEventListener('keydown', handleKey); - }, [onCancel]); + }, [confirmDisabled, inputValue, onCancel, onConfirm]); return (
e.stopPropagation()}>
{title}
{message}
+ {inputValue !== undefined && ( + + )}
+
+ + {error &&
{error}
} + +
+ {automations.length === 0 && ( +
+ + 暂无自动化任务 + 点击右上角新增,通过提示词创建任务 +
+ )} + + {automations.map(automation => { + const projectAvailable = automation.projectId === UNLINKED_PROJECT + || projects.some(project => project.id === automation.projectId); + const selected = automation.id === selectedAutomationId; + return ( +
onSelectAutomation(automation)} + onKeyDown={event => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onSelectAutomation(automation); + } + }} + > + +
+ {automation.title} + + + {automation.scheduleEnabled ? automation.cron : automation.projectName} · {automation.runCount > 0 ? `已运行 ${automation.runCount} 次` : '未运行'} + +
+ +
+ ); + })} +
+
+ ); +} + +export function AutomationDetail({ + automation, + projects, + models, + running = false, + runDisabled = false, + runRefreshKey = 0, + onRun, + onOpenRun, + onSave, + onDelete, + onClose, +}: AutomationDetailProps) { + const [runs, setRuns] = useState([]); + const [runsLoading, setRunsLoading] = useState(false); + const [draft, setDraft] = useState(() => createAutomationDraft(automation)); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(''); + + useEffect(() => { + setDraft(createAutomationDraft(automation)); + setSaveError(''); + }, [automation.id, automation.updatedAt]); + + const dirty = automationDraftChanged(automation, draft); + const projectAvailable = draft.projectId === UNLINKED_PROJECT + || projects.some(project => project.id === draft.projectId); + const modelAvailable = models.some(model => model.id === draft.modelId); + const cronError = useMemo(() => getCronValidationError(draft.cron), [draft.cron]); + const nextCronRun = useMemo(() => { + if (!draft.scheduleEnabled || cronError) return null; + try { + return getNextCronRun(draft.cron); + } catch { + return null; + } + }, [cronError, draft.cron, draft.scheduleEnabled]); + const modelOptions = useMemo(() => { + if (models.some(model => model.id === draft.modelId)) return models; + return [{ id: automation.modelId, name: automation.modelName, label: `${automation.modelName}(当前不可用)` }, ...models]; + }, [automation.modelId, automation.modelName, draft.modelId, models]); + const validationError = !draft.title.trim() + ? '任务名称不能为空' + : !draft.prompt.trim() + ? '任务提示词不能为空' + : !projectAvailable + ? '请选择一个可用项目' + : !modelAvailable + ? '请选择一个可用模型' + : cronError; + + const handleSave = useCallback(async () => { + if (!dirty || validationError || saving || running) return; + const normalized: AutomationUpdateInput = { + ...draft, + title: draft.title.trim(), + prompt: draft.prompt.trim(), + cron: draft.cron.trim().replace(/\s+/g, ' '), + }; + setSaving(true); + setSaveError(''); + try { + const saved = await onSave(automation, normalized); + if (!saved) setSaveError('保存失败,请检查配置后重试'); + } catch (error) { + console.error('[AutomationDetail] 保存自动化失败:', error); + setSaveError('保存失败,请稍后重试'); + } finally { + setSaving(false); + } + }, [automation, dirty, draft, onSave, running, saving, validationError]); + + useEffect(() => { + let cancelled = false; + if (!automation.id) { + setRuns([]); + return () => { cancelled = true; }; + } + + setRunsLoading(true); + getAutomationRuns(automation.id) + .then(items => { + if (!cancelled) setRuns(items); + }) + .catch(err => { + console.error('[AutomationDetail] 加载运行记录失败:', err); + if (!cancelled) setRuns([]); + }) + .finally(() => { + if (!cancelled) setRunsLoading(false); + }); + + return () => { cancelled = true; }; + }, [automation.id, runRefreshKey]); + + return ( +
+
+
+ + 自动化详情 +
+ +
+ +
+
+
+ + setDraft(current => ({ ...current, title: event.target.value }))} + /> +

{projectAvailable ? '已关联项目,可以运行' : '关联项目已不可用'}

+
+ + {!projectAvailable ? '项目缺失' : draft.scheduleEnabled ? '定时已开启' : '定时关闭'} + +
+ +
+

任务提示词

+