diff --git a/.gitignore b/.gitignore index 543a364..5e2bb67 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ backend/sproutclaw-web backend/sproutclaw-web.exe +backend/dist/ backend/data/ frontend/dist/ frontend/dist-desktop/ diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 96af49d..47458ad 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -27,16 +27,16 @@ func main() { defer database.Close() log.Printf("[sproutclaw-web] db: %s", database.DbPath) - // pi CLI RPC client + // SproutClaw CLI RPC client piClient, err := rpc.NewClient(cfg.PiCmd, cfg.PiArgs, cfg.RepoRoot, cfg.AgentDir, func(code int) { if code != 0 { - log.Printf("[sproutclaw-web] pi CLI exited with code %d, shutting down", code) + log.Printf("[sproutclaw-web] SproutClaw CLI exited with code %d, shutting down", code) os.Exit(1) } os.Exit(0) }) if err != nil { - log.Fatalf("pi client: %v", err) + log.Fatalf("SproutClaw client: %v", err) } defer piClient.Stop() diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index b535b63..7751345 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -8,12 +8,14 @@ import ( "strings" ) +const ConfigDirName = ".sproutclaw" + type Config struct { Port int AgentDir string - RepoRoot string // pi 仓库根目录(pi CLI 子进程的工作目录) + RepoRoot string // SproutClaw 仓库根目录(CLI 子进程的工作目录) DataDir string // sproutclaw-web 自己的数据目录(SQLite 等) - PiCmd string // pi CLI 启动命令(完整路径) + PiCmd string // SproutClaw CLI 启动命令(完整路径) PiArgs []string // 派生路径(从 AgentDir 推导) @@ -35,13 +37,16 @@ func New() *Config { agentDir := flag.String("agent-dir", "", "SproutClaw agent directory (e.g. /path/to/.sproutclaw/agent)") repoRoot := flag.String("repo-root", "", "SproutClaw repo root (CLI cwd); defaults to two levels above agent-dir") dataDir := flag.String("data-dir", "", "sproutclaw-web data dir for SQLite (defaults to ./data)") - piCmd := flag.String("pi-cmd", "", "pi CLI command (may include args, e.g. \"node /path/tsx.mjs /path/cli.ts\")") + sproutclawCmd := flag.String("sproutclaw-cmd", "", "SproutClaw CLI command (alias for --pi-cmd)") + piCmd := flag.String("pi-cmd", "", "SproutClaw CLI command (may include args, e.g. \"node /path/tsx.mjs /path/cli.ts\")") frontendDist := flag.String("frontend-dist", "", "path to frontend/dist (defaults to ../frontend/dist relative to binary)") flag.Parse() if *agentDir == "" { - cwd, _ := os.Getwd() - *agentDir = filepath.Join(cwd, ".sproutclaw", "agent") + *agentDir = resolveDefaultAgentDir() + } + if abs, err := filepath.Abs(*agentDir); err == nil { + *agentDir = abs } cfg := &Config{ @@ -50,9 +55,11 @@ func New() *Config { RepoRoot: *repoRoot, DataDir: *dataDir, } - // repoRoot defaults to the directory two levels above agentDir (.sproutclaw/agent -> repo root) if cfg.RepoRoot == "" { - cfg.RepoRoot = filepath.Dir(filepath.Dir(*agentDir)) + cfg.RepoRoot = inferRepoRoot(*agentDir) + } + if abs, err := filepath.Abs(cfg.RepoRoot); err == nil { + cfg.RepoRoot = abs } // dataDir defaults to ./data relative to cwd (run-backend.bat cd's into backend/) if cfg.DataDir == "" { @@ -62,10 +69,14 @@ func New() *Config { if abs, err := filepath.Abs(cfg.DataDir); err == nil { cfg.DataDir = abs } - // --pi-cmd may include arguments separated by spaces, e.g. - // "node /path/to/tsx/dist/cli.mjs /path/to/pi/cli.ts" + // --pi-cmd / --sproutclaw-cmd may include arguments separated by spaces, e.g. + // "node /path/to/tsx/dist/cli.mjs /path/to/cli.ts" // Split into executable + args so exec.Command receives them correctly. - if parts := strings.Fields(*piCmd); len(parts) > 0 { + cmdLine := strings.TrimSpace(*sproutclawCmd) + if cmdLine == "" { + cmdLine = strings.TrimSpace(*piCmd) + } + if parts := strings.Fields(cmdLine); len(parts) > 0 { cfg.PiCmd = parts[0] cfg.PiArgs = parts[1:] } @@ -107,3 +118,32 @@ func Platform() string { func Arch() string { return runtime.GOARCH } + +func resolveDefaultAgentDir() string { + if env := strings.TrimSpace(os.Getenv("SPROUTCLAW_AGENT_DIR")); env != "" { + return env + } + cwd, _ := os.Getwd() + candidates := []string{ + filepath.Join(cwd, ConfigDirName, "agent"), + filepath.Join(cwd, "..", "sproutclaw", ConfigDirName, "agent"), + filepath.Join(cwd, "..", "sproutclaw", ".pi", "agent"), + } + for _, candidate := range candidates { + if st, err := os.Stat(candidate); err == nil && st.IsDir() { + return candidate + } + } + return filepath.Join(cwd, ConfigDirName, "agent") +} + +func inferRepoRoot(agentDir string) string { + agentDir = filepath.Clean(agentDir) + if filepath.Base(agentDir) == "agent" { + configDir := filepath.Base(filepath.Dir(agentDir)) + if configDir == ConfigDirName || configDir == ".pi" { + return filepath.Dir(filepath.Dir(agentDir)) + } + } + return filepath.Dir(filepath.Dir(agentDir)) +} diff --git a/backend/internal/handlers/settings.go b/backend/internal/handlers/settings.go index 6006d94..12fda59 100644 --- a/backend/internal/handlers/settings.go +++ b/backend/internal/handlers/settings.go @@ -225,25 +225,26 @@ func handleEnvironment(cfg *config.Config) gin.HandlerFunc { } c.JSON(http.StatusOK, gin.H{ - "goVersion": runtime.Version(), - "platform": runtime.GOOS, - "arch": runtime.GOARCH, - "osRelease": osRelease, - "hostname": hostname, - "numCPU": runtime.NumCPU(), - "totalMemMb": totalMemMb, - "freeMemMb": freeMemMb, - "pid": os.Getpid(), - "uptime": uptimeSec, - "execPath": exe, - "goroutines": runtime.NumGoroutine(), - "goHeapMb": goHeapMb, - "cwd": cwd, - "port": cfg.Port, - "agentDir": cfg.AgentDir, - "dataDir": cfg.DataDir, - "repoRoot": cfg.RepoRoot, - "piCmd": piCmd, + "goVersion": runtime.Version(), + "platform": runtime.GOOS, + "arch": runtime.GOARCH, + "osRelease": osRelease, + "hostname": hostname, + "numCPU": runtime.NumCPU(), + "totalMemMb": totalMemMb, + "freeMemMb": freeMemMb, + "pid": os.Getpid(), + "uptime": uptimeSec, + "execPath": exe, + "goroutines": runtime.NumGoroutine(), + "goHeapMb": goHeapMb, + "cwd": cwd, + "port": cfg.Port, + "agentDir": cfg.AgentDir, + "dataDir": cfg.DataDir, + "repoRoot": cfg.RepoRoot, + "piCmd": piCmd, + "sproutclawCmd": piCmd, "memUsagePct": func() string { if totalMemMb == 0 { return "" diff --git a/backend/internal/handlers/terminal.go b/backend/internal/handlers/terminal.go index f9a8769..e8eef81 100644 --- a/backend/internal/handlers/terminal.go +++ b/backend/internal/handlers/terminal.go @@ -37,7 +37,7 @@ func handleTerminalWS(cfg *config.Config) gin.HandlerFunc { } defer conn.Close() - if err := services.ServeWebTerminal(conn, cfg.RepoRoot); err != nil { + if err := services.ServeWebTerminal(conn, cfg.RepoRoot, cfg.AgentDir); err != nil { _ = conn.WriteJSON(gin.H{"type": "error", "error": err.Error()}) } } diff --git a/backend/internal/rpc/client.go b/backend/internal/rpc/client.go index affb88a..e931616 100644 --- a/backend/internal/rpc/client.go +++ b/backend/internal/rpc/client.go @@ -73,16 +73,19 @@ type Client struct { // with the exit code when the subprocess terminates. func NewClient(piCmd string, piArgs []string, repoRoot, agentDir string, onExit func(int)) (*Client, error) { if piCmd == "" { - return nil, fmt.Errorf("pi CLI command not specified (use --pi-cmd)") + return nil, fmt.Errorf("SproutClaw CLI command not specified (use --pi-cmd or --sproutclaw-cmd)") } - // pi enters RPC mode via "--mode rpc" + // SproutClaw enters RPC mode via "--mode rpc" args := append(append([]string{}, piArgs...), "--mode", "rpc") cmd := exec.Command(piCmd, args...) cmd.Dir = repoRoot - cmd.Env = append(os.Environ(), fmt.Sprintf("SPROUTCLAW_CODING_AGENT_DIR=%s", agentDir)) + cmd.Env = append(os.Environ(), + fmt.Sprintf("SPROUTCLAW_CODING_AGENT_DIR=%s", agentDir), + fmt.Sprintf("PI_CODING_AGENT_DIR=%s", agentDir), + ) - log.Printf("[sproutclaw-web] launching pi RPC: %s %s (cwd=%s)", piCmd, strings.Join(args, " "), repoRoot) + log.Printf("[sproutclaw-web] launching SproutClaw RPC: %s %s (cwd=%s agent-dir=%s)", piCmd, strings.Join(args, " "), repoRoot, agentDir) stdin, err := cmd.StdinPipe() if err != nil { @@ -115,7 +118,7 @@ func NewClient(piCmd string, piArgs []string, repoRoot, agentDir string, onExit if cmd.ProcessState != nil { code = cmd.ProcessState.ExitCode() } - log.Printf("[sproutclaw-web] pi RPC exited, code=%d", code) + log.Printf("[sproutclaw-web] SproutClaw RPC exited, code=%d", code) onExit(code) }() diff --git a/backend/internal/services/web_terminal.go b/backend/internal/services/web_terminal.go index 632c0ee..8f7474f 100644 --- a/backend/internal/services/web_terminal.go +++ b/backend/internal/services/web_terminal.go @@ -21,7 +21,7 @@ type terminalWSMessage struct { } // ServeWebTerminal attaches a PTY shell session to the websocket connection. -func ServeWebTerminal(conn *websocket.Conn, workDir string) error { +func ServeWebTerminal(conn *websocket.Conn, workDir, agentDir string) error { abs, err := filepath.Abs(workDir) if err != nil { return fmt.Errorf("解析目录失败: %w", err) @@ -34,15 +34,21 @@ func ServeWebTerminal(conn *websocket.Conn, workDir string) error { return fmt.Errorf("不是有效目录: %s", abs) } + envInject := map[string]string{ + "TERM": "xterm-256color", + "COLORTERM": "truecolor", + } + if agentDir != "" { + envInject["SPROUTCLAW_CODING_AGENT_DIR"] = agentDir + envInject["PI_CODING_AGENT_DIR"] = agentDir + } + ptmx, err := crosspty.Start(crosspty.CommandConfig{ - Argv: defaultShellArgv(), - Dir: abs, - Env: os.Environ(), - EnvInject: map[string]string{ - "TERM": "xterm-256color", - "COLORTERM": "truecolor", - }, - Size: crosspty.TermSize{Rows: 24, Cols: 80}, + Argv: defaultShellArgv(), + Dir: abs, + Env: os.Environ(), + EnvInject: envInject, + Size: crosspty.TermSize{Rows: 24, Cols: 80}, }) if err != nil { if err == crosspty.ErrConPTYNotSupported { diff --git a/build.sh b/build.sh index 47be694..2e8e137 100755 --- a/build.sh +++ b/build.sh @@ -6,6 +6,6 @@ OUT="$SCRIPT_DIR/backend/dist/sproutclaw-web-backend" echo "[build] compiling Go backend -> $OUT" cd "$SCRIPT_DIR/backend" -/usr/local/go/bin/go build -o "$OUT" ./cmd/server +go build -o "$OUT" ./cmd/server chmod +x "$OUT" echo "[build] done: $OUT" diff --git a/frontend/src/components/chat/AssistantTurnBody.module.css b/frontend/src/components/chat/AssistantTurnBody.module.css index 4779a5e..c646bdf 100644 --- a/frontend/src/components/chat/AssistantTurnBody.module.css +++ b/frontend/src/components/chat/AssistantTurnBody.module.css @@ -1,35 +1,31 @@ .turn { display: flex; flex-direction: column; - gap: 8px; + gap: 3px; } -.text { - line-height: 1.5; +.textSegment { + composes: textSegment from "./agentSegment.module.css"; } -.thinking { - line-height: 1.45; - font-style: italic; - color: #6b7280; +.textBody { + composes: textBody from "./agentSegment.module.css"; +} + +.text:global(.markdown-body) { + font-size: 14px; + line-height: 1.4; + background: transparent; +} + +.streamingPlain { + composes: streamingPlain from "./agentSegment.module.css"; } .thinkingHidden { margin: 0; font-style: italic; color: #9ca3af; - font-size: 0.92em; -} - -.streaming::after { - content: "▊"; - animation: blink 0.8s step-end infinite; - color: #8b6fd4; - margin-left: 1px; -} - -@keyframes blink { - 50% { - opacity: 0; - } -} + font-size: 12px; + line-height: 1.4; +} \ No newline at end of file diff --git a/frontend/src/components/chat/AssistantTurnBody.tsx b/frontend/src/components/chat/AssistantTurnBody.tsx index 1de5745..b930c6d 100644 --- a/frontend/src/components/chat/AssistantTurnBody.tsx +++ b/frontend/src/components/chat/AssistantTurnBody.tsx @@ -1,5 +1,7 @@ +import { memo, useMemo } from "react"; import type { AssistantBlock } from "../../types/message"; import { renderMarkdown } from "../../utils/markdown"; +import { ThinkingBlock } from "./ThinkingBlock"; import styles from "./AssistantTurnBody.module.css"; interface AssistantTurnBodyProps { @@ -8,7 +10,30 @@ interface AssistantTurnBodyProps { showThinking?: boolean; } -export function AssistantTurnBody({ +interface TextBlockContentProps { + content: string; + streaming: boolean; +} + +const TextBlockContent = memo(function TextBlockContent({ content, streaming }: TextBlockContentProps) { + const html = useMemo(() => (streaming ? "" : renderMarkdown(content)), [content, streaming]); + + return ( +
+ {streaming ? ( +
{content}
+ ) : ( +
+ )} +
+ ); +}); + +export const AssistantTurnBody = memo(function AssistantTurnBody({ blocks, streaming = false, showThinking = true, @@ -27,22 +52,22 @@ export function AssistantTurnBody({ ); } return ( -
); } return ( -
); })}
); -} +}); \ No newline at end of file diff --git a/frontend/src/components/chat/MessageBubble.tsx b/frontend/src/components/chat/MessageBubble.tsx index f8865b2..7d47c6a 100644 --- a/frontend/src/components/chat/MessageBubble.tsx +++ b/frontend/src/components/chat/MessageBubble.tsx @@ -1,7 +1,6 @@ -import { useState } from "react"; +import { memo, useState } from "react"; import type { ChatMessage } from "../../types/message"; import { useAvatars } from "../../context/AvatarContext"; -import { useChatContext } from "../../context/ChatContext"; import { downloadTextFile, messageMarkdownFilename, @@ -20,6 +19,7 @@ interface MessageBubbleProps { message: ChatMessage; agentGutter?: "avatar" | "spacer" | "none"; toolsExpanded?: boolean; + showThinkingAndTools?: boolean; } function ChatAvatar({ @@ -102,13 +102,13 @@ function AssistantActions({ content }: { content: string }) { ); } -export function MessageBubble({ +export const MessageBubble = memo(function MessageBubble({ message, agentGutter = "none", toolsExpanded = false, + showThinkingAndTools = true, }: MessageBubbleProps) { const { userAvatarUrl, agentAvatarUrl } = useAvatars(); - const { showThinkingAndTools } = useChatContext(); const gutter = agentGutter === "avatar" || agentGutter === "spacer" ? ( @@ -116,7 +116,7 @@ export function MessageBubble({ if (message.role === "tool_call") { return ( -
+
{gutter}
@@ -211,4 +211,4 @@ export function MessageBubble({ } return
{message.content}
; -} +}); \ No newline at end of file diff --git a/frontend/src/components/chat/MessageList.module.css b/frontend/src/components/chat/MessageList.module.css index 7e42cf2..c8f11ad 100644 --- a/frontend/src/components/chat/MessageList.module.css +++ b/frontend/src/components/chat/MessageList.module.css @@ -1,10 +1,10 @@ .chat { flex: 1; overflow-y: auto; - padding: 12px 16px 10px; + padding: 8px 12px 6px; display: flex; flex-direction: column; - gap: 6px; + gap: 3px; min-height: 0; } @@ -50,9 +50,9 @@ } .msg { - padding: 10px 12px; + padding: 8px 10px; line-height: 1.4; - font-size: 16px; + font-size: 14px; word-wrap: break-word; white-space: pre-wrap; } @@ -110,10 +110,10 @@ align-self: flex-start; display: flex; align-items: flex-start; - gap: 10px; + gap: 8px; width: 100%; max-width: 100%; - margin: 0 0 2px; + margin: 0; } .assistantAvatar { @@ -139,22 +139,25 @@ flex: 1; min-width: 0; max-width: 100%; - padding: 10px 12px; - background: #fff; - border: 1px solid #e8ecf0; - border-radius: 14px 14px 14px 4px; - box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04); + padding: 0; + background: transparent; + border: none; + border-radius: 0; + box-shadow: none; color: #1a1a1a; - line-height: 1.46; + line-height: 1.4; white-space: normal; + display: flex; + flex-direction: column; + gap: 3px; } .assistantToolbar { display: flex; justify-content: flex-end; gap: 4px; - margin: -2px -2px 6px 0; - min-height: 22px; + margin: 0 0 1px; + min-height: 20px; opacity: 0; transition: opacity 0.15s ease; } @@ -192,6 +195,9 @@ .assistantBody { min-width: 0; + display: flex; + flex-direction: column; + gap: 3px; } .system { @@ -211,6 +217,35 @@ background: transparent; border: none; box-shadow: none; + font-size: 14px; + line-height: 1.4; + white-space: normal; +} + +.toolCallRow { + margin: 0; +} + +.toolCallRow + .toolCallRow { + margin-top: -3px; +} + +.toolCallRow:has(+ .toolCallRow) :global(.tool-call-block) { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + box-shadow: none; +} + +.toolCallRow + .toolCallRow :global(.tool-call-block) { + border-top: none; + border-top-left-radius: 0; + border-top-right-radius: 0; + box-shadow: none; +} + +.assistantRow:not(.toolCallRow) + .toolCallRow, +.toolCallRow + .assistantRow:not(.toolCallRow) { + margin-top: -2px; } .thinking { @@ -250,129 +285,12 @@ margin-left: 1px; } -.assistantRow + .assistantRow { - margin-top: 2px; +.assistantRow + .assistantRow:not(.toolCallRow) { + margin-top: 0; } -.assistant :global(h1), -.assistant :global(h2), -.assistant :global(h3), -.assistant :global(h4), -.assistant :global(h5), -.assistant :global(h6) { - margin: 0.78em 0 0.3em; - font-weight: 600; - line-height: 1.25; -} - -.assistant :global(h1) { font-size: 1.18em; } -.assistant :global(h2) { font-size: 1.1em; } -.assistant :global(h3) { font-size: 1.03em; } -.assistant :global(h4) { font-size: 1em; } - -.assistant > :global(:first-child) { margin-top: 0; } -.assistant > :global(:last-child) { margin-bottom: 0; } - -.assistant :global(p) { - margin: 0 0 0.46em; -} - -.assistant :global(p + p) { - margin-top: 0.3em; -} - -.assistant :global(strong) { font-weight: 600; } -.assistant :global(em) { font-style: italic; } - -.assistant :global(a) { - color: #2563eb; - text-decoration: none; -} - -.assistant :global(a:hover) { text-decoration: underline; } - -.assistant :global(ul), -.assistant :global(ol) { - margin: 0.32em 0 0.48em; - padding-left: 1.18em; -} - -.assistant :global(li) { - margin: 0.08em 0; - padding-left: 0.04em; -} - -.assistant :global(li > p) { - margin: 0; -} - -.assistant :global(blockquote) { - margin: 0.36em 0; - padding: 3px 9px; - border-left: 2px solid #e5e5e5; - color: #666; -} - -.assistant :global(hr) { - border: none; - border-top: 1px solid #eee; - margin: 0.58em 0; -} - -.assistant :global(table) { - border-collapse: collapse; - margin: 0.42em 0; - font-size: 13px; - width: 100%; -} - -.assistant :global(th), -.assistant :global(td) { - border: 1px solid #e5e5e5; - padding: 4px 7px; - text-align: left; -} - -.assistant :global(th) { - background: #fafafa; - font-weight: 600; -} - -.assistant :global(code:not(.hljs)) { - font-size: 0.9em; - background: #f5f5f5; - padding: 1px 5px; - border-radius: 4px; - color: #d63384; - word-break: break-word; -} - -.assistant :global(pre.assistant-pre) { - margin: 0.5em 0 0.6em; - padding: 0; - background: transparent; - border: 1px solid #e8edf2; - border-radius: 8px; - overflow-x: auto; - line-height: 1.48; -} - -.assistant :global(pre.assistant-pre code.hljs) { - display: block; - padding: 10px 12px; - background: none; - border: none; - color: inherit; - font-size: 13px; - line-height: 1.48; - word-break: normal; - tab-size: 2; -} - -.assistant :global(img) { - max-width: 100%; - border-radius: 8px; - margin: 0.42em 0; +.assistant :global(.markdown-body) { + min-width: 0; } @media (min-width: 769px) { @@ -384,12 +302,13 @@ @media (max-width: 768px) { .chat { - padding: 10px 12px 8px; + padding: 6px 10px 4px; + gap: 3px; } .msg { - padding: 11px 12px; - font-size: 16px; + padding: 8px 10px; + font-size: 14px; } .userRow { diff --git a/frontend/src/components/chat/MessageList.tsx b/frontend/src/components/chat/MessageList.tsx index ca7553e..9999758 100644 --- a/frontend/src/components/chat/MessageList.tsx +++ b/frontend/src/components/chat/MessageList.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useMemo, useRef } from "react"; import { useChatContext } from "../../context/ChatContext"; import type { MessageRole } from "../../types/message"; import { MessageBubble } from "./MessageBubble"; @@ -20,13 +20,22 @@ function isAgentSideRole(role: MessageRole): boolean { export function MessageList() { const { messages, toolsExpanded, showThinkingAndTools } = useChatContext(); - const visibleMessages = showThinkingAndTools - ? messages - : messages.filter((m) => m.role !== "thinking" && m.role !== "tool_call"); + const visibleMessages = useMemo( + () => + showThinkingAndTools + ? messages + : messages.filter((m) => m.role !== "thinking" && m.role !== "tool_call"), + [messages, showThinkingAndTools], + ); const chatRef = useRef(null); const stickToBottomRef = useRef(true); const prevMessageCountRef = useRef(0); + const lastMessage = messages[messages.length - 1]; + const scrollKey = lastMessage + ? `${messages.length}:${lastMessage.id}:${lastMessage.content.length}:${lastMessage.streaming ? 1 : 0}` + : String(messages.length); + useEffect(() => { const el = chatRef.current; if (!el) return; @@ -47,8 +56,8 @@ export function MessageList() { const messageCount = messages.length; prevMessageCountRef.current = messageCount; - const lastMessage = messages[messageCount - 1]; - const userJustSent = messageCount > prevCount && lastMessage?.role === "user"; + const last = messages[messageCount - 1]; + const userJustSent = messageCount > prevCount && last?.role === "user"; if (userJustSent || prevCount === 0) { stickToBottomRef.current = true; @@ -59,7 +68,7 @@ export function MessageList() { requestAnimationFrame(() => { if (chatRef.current) scrollToBottom(chatRef.current); }); - }, [messages]); + }, [scrollKey, messages.length]); if (messages.length === 0) { return ( @@ -91,9 +100,10 @@ export function MessageList() { message={msg} agentGutter={agentGutter} toolsExpanded={toolsExpanded} + showThinkingAndTools={showThinkingAndTools} /> ); })}
); -} +} \ No newline at end of file diff --git a/frontend/src/components/chat/ThinkingBlock.module.css b/frontend/src/components/chat/ThinkingBlock.module.css index 6a48cad..a847d02 100644 --- a/frontend/src/components/chat/ThinkingBlock.module.css +++ b/frontend/src/components/chat/ThinkingBlock.module.css @@ -1,69 +1,28 @@ .block { - margin: 0; + composes: segment from "./agentSegment.module.css"; } .summary { - list-style: none; - cursor: pointer; - display: flex; - align-items: center; - gap: 6px; - padding: 6px 10px; - user-select: none; - font-weight: 500; - color: #7c6a9a; -} - -.summary::-webkit-details-marker { - display: none; -} - -.summary::before { - content: ""; - width: 0; - height: 0; - border-left: 5px solid #a894c6; - border-top: 3.5px solid transparent; - border-bottom: 3.5px solid transparent; - flex-shrink: 0; - transform: rotate(0deg); - transition: transform 0.15s ease; -} - -.block[open] > .summary::before { - transform: rotate(90deg); + composes: summary from "./agentSegment.module.css"; } .summaryLabel { - flex-shrink: 0; - font-size: inherit; - letter-spacing: 0.02em; + composes: summaryTitle from "./agentSegment.module.css"; } .summaryText { - min-width: 0; - flex: 1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + composes: summaryPreview from "./agentSegment.module.css"; font-style: italic; - color: #8b7aa8; } .detail { - margin: 0; - padding: 6px 10px 8px; - border-top: 1px solid #ece6f5; + composes: detail from "./agentSegment.module.css"; } .body { - margin: 0; - white-space: pre-wrap; - word-break: break-word; - font-size: inherit; - line-height: inherit; + composes: body from "./agentSegment.module.css"; font-style: italic; - color: #6f5f8d; + color: #6b7280; } .streaming::after { @@ -77,4 +36,4 @@ 50% { opacity: 0; } -} +} \ No newline at end of file diff --git a/frontend/src/components/chat/ThinkingBlock.tsx b/frontend/src/components/chat/ThinkingBlock.tsx index 31bcf4c..89180ac 100644 --- a/frontend/src/components/chat/ThinkingBlock.tsx +++ b/frontend/src/components/chat/ThinkingBlock.tsx @@ -10,7 +10,7 @@ export function ThinkingBlock({ content, streaming }: ThinkingBlockProps) { const summary = deriveThinkingSummary(content); return ( -
+
思考 {summary} diff --git a/frontend/src/components/chat/ToolCallBlock.module.css b/frontend/src/components/chat/ToolCallBlock.module.css index 068af1b..6b4421a 100644 --- a/frontend/src/components/chat/ToolCallBlock.module.css +++ b/frontend/src/components/chat/ToolCallBlock.module.css @@ -1,97 +1,138 @@ .block { - margin: 0; - border-radius: 8px; - border: 1px solid #e5e7eb; - background: #f8fafc; - overflow: hidden; -} - -.pending { - border-color: #dbeafe; - background: #f0f7ff; -} - -.success { - border-color: #bbf7d0; - background: #f0fdf4; -} - -.error { - border-color: #fecaca; - background: #fef2f2; + composes: segment from "./agentSegment.module.css"; } .summary { - list-style: none; - cursor: pointer; - display: flex; - align-items: center; + composes: summary from "./agentSegment.module.css"; justify-content: space-between; - gap: 8px; - padding: 8px 10px; - user-select: none; -} - -.summary::-webkit-details-marker { - display: none; } .toolName { - font-weight: 600; - color: #374151; - font-size: 0.92em; + composes: summaryTitleGrow from "./agentSegment.module.css"; } .status { flex-shrink: 0; - font-size: 11px; - color: #6b7280; + font-size: 10px; + font-weight: 500; + line-height: 1.2; + padding: 1px 5px; + border-radius: 999px; + background: #f6f8fa; + color: #656d76; } .pending .status { - color: #2563eb; + color: #0969da; + background: #ddf4ff; +} + +.pending .status::after { + content: ""; + display: inline-block; + width: 4px; + height: 4px; + margin-left: 3px; + border-radius: 50%; + background: currentColor; + animation: pulse 1.2s ease-in-out infinite; + vertical-align: middle; } .success .status { - color: #15803d; + color: #1a7f37; + background: #dafbe1; } .error .status { - color: #b91c1c; + color: #cf222e; + background: #ffebe9; } .detail { - margin: 0; - padding: 8px 10px 10px; - border-top: 1px solid rgba(0, 0, 0, 0.06); + composes: detail from "./agentSegment.module.css"; } .args { - margin: 0 0 6px; - font-size: 0.85em; - color: #6b7280; - font-family: var(--font-mono, ui-monospace, monospace); + margin: 4px 0 0; + padding: 4px 6px; + font-size: 11px; + color: #656d76; + background: #f6f8fa; + border: 1px solid #e8ecf0; + border-radius: 5px; + font-family: var(--fontStack-monospace, ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace); word-break: break-all; + line-height: 1.35; } -.detailPre { - margin: 0; - padding: 0; +.markdownBody:global(.markdown-body) { + font-size: 13px; + line-height: 1.4; + background: transparent; + padding: 4px 0 0; +} + +.markdownBody:global(.markdown-body) :global(p), +.markdownBody:global(.markdown-body) :global(ul), +.markdownBody:global(.markdown-body) :global(ol), +.markdownBody:global(.markdown-body) :global(blockquote), +.markdownBody:global(.markdown-body) :global(table), +.markdownBody:global(.markdown-body) :global(pre) { + margin-top: 0; + margin-bottom: 0.4em; +} + +.markdownBody:global(.markdown-body) :global(p:last-child), +.markdownBody:global(.markdown-body) :global(pre:last-child) { + margin-bottom: 0; +} + +.markdownBody:global(.markdown-body) :global(pre) { + padding: 6px 8px; + font-size: 11px; +} + +.diffPre, +.plainPre { + margin: 4px 0 0; + padding: 6px 8px; white-space: pre-wrap; word-break: break-word; - font-size: inherit; - line-height: inherit; - font-family: var(--font-mono, ui-monospace, monospace); + font-size: 11px; + line-height: 1.35; + font-family: var(--fontStack-monospace, ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace); + background: #f6f8fa; + border: 1px solid #e8ecf0; + border-radius: 5px; + overflow-x: auto; } -.detailPre :global(.diffAdd) { - color: #15803d; +.diffPre :global(.diffAdd) { + color: #1a7f37; } -.detailPre :global(.diffDel) { - color: #b91c1c; +.diffPre :global(.diffDel) { + color: #cf222e; } -.detailPre :global(.diffMeta) { - color: #6b7280; +.diffPre :global(.diffMeta) { + color: #656d76; } + +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: 0.35; + } +} + +@media (max-width: 768px) { + .detail { + max-height: min(180px, 28vh); + } +} \ No newline at end of file diff --git a/frontend/src/components/chat/ToolCallBlock.tsx b/frontend/src/components/chat/ToolCallBlock.tsx index 87c49be..29de99a 100644 --- a/frontend/src/components/chat/ToolCallBlock.tsx +++ b/frontend/src/components/chat/ToolCallBlock.tsx @@ -1,4 +1,6 @@ +import { memo, useMemo } from "react"; import { deriveToolCallSummary, formatToolBodyHtml } from "../../utils/toolCall"; +import { renderMarkdown } from "../../utils/markdown"; import styles from "./ToolCallBlock.module.css"; interface ToolCallBlockProps { @@ -31,34 +33,66 @@ function parseToolCallContent(content: string): { return { title, body, status }; } -export function ToolCallBlock({ content, forceOpen }: ToolCallBlockProps) { - const { title, body, status } = parseToolCallContent(content); +interface ToolBodyProps { + body: string; + fallback: string; + hasDiff: boolean; + status: "pending" | "success" | "error"; +} + +const ToolBody = memo(function ToolBody({ body, fallback, hasDiff, status }: ToolBodyProps) { + const text = body || fallback; + const html = useMemo(() => { + if (!text.trim() || hasDiff || status === "pending") return ""; + return renderMarkdown(text); + }, [text, hasDiff, status]); + + if (!text.trim()) return null; + + if (hasDiff) { + return ( +
+		);
+	}
+
+	if (status === "pending") {
+		return 
{text}
; + } + + return ( +
+ ); +}); + +export const ToolCallBlock = memo(function ToolCallBlock({ content, forceOpen }: ToolCallBlockProps) { + const { title, body, status } = useMemo(() => parseToolCallContent(content), [content]); const hasDiff = body.includes("\n+") || body.includes("\n-") || body.startsWith("+") || body.startsWith("-"); const statusLabel = status === "success" ? "完成" : status === "error" ? "失败" : "执行中"; + const toolName = title.split("(")[0]?.trim() || deriveToolCallSummary(content); + const hasDetail = Boolean(body || (title.includes("(") && title !== toolName) || content !== title); return ( -
+
- {title.split("(")[0] || deriveToolCallSummary(content)} + + {toolName} + {statusLabel} - {(body || content !== title) && ( + {hasDetail ? (
{title.includes("(") ?
{title}
: null} - {hasDiff ? ( -
-					) : (
-						
{body || content}
- )} +
- )} + ) : null}
); -} +}); \ No newline at end of file diff --git a/frontend/src/components/chat/agentSegment.module.css b/frontend/src/components/chat/agentSegment.module.css new file mode 100644 index 0000000..2762ed5 --- /dev/null +++ b/frontend/src/components/chat/agentSegment.module.css @@ -0,0 +1,114 @@ +.segment { + margin: 0; + border-radius: 10px 10px 10px 3px; + border: 1px solid #e8ecf0; + background: #fff; + overflow: hidden; + font-size: 14px; + line-height: 1.4; + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04); + color: #1a1a1a; +} + +.summary { + list-style: none; + cursor: pointer; + display: flex; + align-items: center; + gap: 5px; + padding: 6px 10px; + min-height: 0; + user-select: none; + font-size: 13px; + line-height: 1.4; +} + +.summary::-webkit-details-marker { + display: none; +} + +.summary::before { + content: ""; + flex-shrink: 0; + width: 0; + height: 0; + border-top: 3.5px solid transparent; + border-bottom: 3.5px solid transparent; + border-left: 4px solid #818b98; + transition: transform 0.15s ease; +} + +.segment[open] > .summary::before { + transform: rotate(90deg); +} + +.summaryTitle { + flex-shrink: 0; + font-weight: 500; + color: #57606a; +} + +.summaryPreview { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: #656d76; +} + +.summaryTitleGrow { + flex: 1; + min-width: 0; + font-weight: 500; + color: #57606a; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.detail { + margin: 0; + padding: 0 10px 6px; + border-top: 1px solid #e8ecf0; + max-height: min(200px, 30vh); + overflow: auto; +} + +.body { + margin: 0; + padding: 4px 0 0; + white-space: pre-wrap; + word-break: break-word; + font-size: 13px; + line-height: 1.4; + color: #374151; +} + +.bodyItalic { + font-style: italic; + color: #6b7280; +} + +.textSegment { + composes: segment; + padding: 6px 10px; +} + +.textBody { + margin: 0; + font-size: 14px; + line-height: 1.4; + white-space: pre-wrap; + word-break: break-word; +} + +.textBody:global(.markdown-body) { + font-size: 14px; + line-height: 1.4; + background: transparent; +} + +.streamingPlain { + composes: textBody; +} \ No newline at end of file diff --git a/frontend/src/context/ChatContext.tsx b/frontend/src/context/ChatContext.tsx index 15c8164..a7c7f34 100644 --- a/frontend/src/context/ChatContext.tsx +++ b/frontend/src/context/ChatContext.tsx @@ -464,9 +464,11 @@ export function ChatProvider({ children }: { children: ReactNode }) { const [toolsExpanded, setToolsExpanded] = useState(false); const [showThinkingAndTools, setShowThinkingAndTools] = useState(() => { try { - return localStorage.getItem(SHOW_THINKING_TOOLS_KEY) === "true"; + const stored = localStorage.getItem(SHOW_THINKING_TOOLS_KEY); + if (stored === null) return true; + return stored === "true"; } catch { - return false; + return true; } }); const [isEphemeralSession, setIsEphemeralSession] = useState(false); @@ -484,6 +486,10 @@ export function ChatProvider({ children }: { children: ReactNode }) { const compactionReloadPendingRef = useRef(false); const activeBashMsgIdRef = useRef(null); const sessionDashboardRaf = useRef(null); + const pendingAssistantBlocksRef = useRef(null); + const messageUpdateRafRef = useRef(null); + const pendingBashOutputRef = useRef(null); + const bashUpdateRafRef = useRef(null); const backendSessionPathRef = useRef(backendSessionPath); const activeSessionPathRef = useRef(activeSessionPath); const isStreamingRef = useRef(isStreaming); @@ -498,12 +504,91 @@ export function ChatProvider({ children }: { children: ReactNode }) { isStreamingRef.current = isStreaming; }, [isStreaming]); - const setMessages = useCallback((next: SetStateAction) => { - setRawMessages((prev) => { - const resolved = typeof next === "function" ? next(prev) : next; - return normalizeChatMessages(resolved); - }); - }, []); + const setMessages = useCallback( + (next: SetStateAction, options?: { skipNormalize?: boolean }) => { + setRawMessages((prev) => { + const resolved = typeof next === "function" ? next(prev) : next; + return options?.skipNormalize ? resolved : normalizeChatMessages(resolved); + }); + }, + [], + ); + + const flushPendingStreamUpdates = useCallback(() => { + if (messageUpdateRafRef.current != null) { + cancelAnimationFrame(messageUpdateRafRef.current); + messageUpdateRafRef.current = null; + } + if (bashUpdateRafRef.current != null) { + cancelAnimationFrame(bashUpdateRafRef.current); + bashUpdateRafRef.current = null; + } + + const blocks = pendingAssistantBlocksRef.current; + const bashOutput = pendingBashOutputRef.current; + pendingAssistantBlocksRef.current = null; + pendingBashOutputRef.current = null; + + if (blocks) { + setMessages( + (prev) => applyAssistantMessageUpdate(prev, blocks, streamingAssistantId), + { skipNormalize: true }, + ); + } + if (bashOutput != null) { + const bashId = activeBashMsgIdRef.current; + if (bashId) { + setMessages( + (prev) => + prev.map((msg) => + msg.id === bashId ? { ...msg, content: bashOutput, streaming: true } : msg, + ), + { skipNormalize: true }, + ); + } + } + }, [setMessages]); + + const scheduleAssistantMessageUpdate = useCallback( + (blocks: AssistantBlock[]) => { + pendingAssistantBlocksRef.current = blocks; + if (messageUpdateRafRef.current != null) return; + messageUpdateRafRef.current = requestAnimationFrame(() => { + messageUpdateRafRef.current = null; + const pending = pendingAssistantBlocksRef.current; + if (!pending) return; + pendingAssistantBlocksRef.current = null; + setMessages( + (prev) => applyAssistantMessageUpdate(prev, pending, streamingAssistantId), + { skipNormalize: true }, + ); + }); + }, + [setMessages], + ); + + const scheduleBashOutputUpdate = useCallback( + (output: string) => { + pendingBashOutputRef.current = output; + if (bashUpdateRafRef.current != null) return; + bashUpdateRafRef.current = requestAnimationFrame(() => { + bashUpdateRafRef.current = null; + const pending = pendingBashOutputRef.current; + if (pending == null) return; + pendingBashOutputRef.current = null; + const bashId = activeBashMsgIdRef.current; + if (!bashId) return; + setMessages( + (prev) => + prev.map((msg) => + msg.id === bashId ? { ...msg, content: pending, streaming: true } : msg, + ), + { skipNormalize: true }, + ); + }); + }, + [setMessages], + ); const availableThinkingLevels = useMemo( () => getAvailableThinkingLevels(sessionState?.model), @@ -1482,6 +1567,7 @@ export function ChatProvider({ children }: { children: ReactNode }) { break; case "agent_end": + flushPendingStreamUpdates(); setIsStreaming(false); setConnected(true); setMessages((prev) => finalizeStreamingTurn(prev)); @@ -1537,14 +1623,13 @@ export function ChatProvider({ children }: { children: ReactNode }) { } else if (hasTextBlock(blocks)) { updateRunPhase("writing"); } - setMessages((prev) => - applyAssistantMessageUpdate(prev, blocks, streamingAssistantId), - ); + scheduleAssistantMessageUpdate(blocks); } break; } case "message_end": { + flushPendingStreamUpdates(); const m = ev.message; if (m.role === "assistant") { const blocks = extractAssistantBlocks(m.content); @@ -1574,25 +1659,28 @@ export function ChatProvider({ children }: { children: ReactNode }) { const tid = ev.toolCallId ? String(ev.toolCallId) : undefined; updateRunPhase("tool", ev.toolName); const label = `🔧 ${ev.toolName}(${JSON.stringify(ev.args ?? {})})`; - setMessages((prev) => { - const existingIdx = tid - ? prev.findIndex((msg) => msg.toolCallId === tid) - : -1; - if (existingIdx >= 0) { - return prev.map((msg, i) => - i === existingIdx ? { ...msg, content: label } : msg, - ); - } - return [ - ...prev, - { - id: tid ? `tool-${tid}` : nextMessageId(), - role: "tool_call" as const, - content: label, - toolCallId: tid, - }, - ]; - }); + setMessages( + (prev) => { + const existingIdx = tid + ? prev.findIndex((msg) => msg.toolCallId === tid) + : -1; + if (existingIdx >= 0) { + return prev.map((msg, i) => + i === existingIdx ? { ...msg, content: label } : msg, + ); + } + return [ + ...prev, + { + id: tid ? `tool-${tid}` : nextMessageId(), + role: "tool_call" as const, + content: label, + toolCallId: tid, + }, + ]; + }, + { skipNormalize: true }, + ); break; } @@ -1602,17 +1690,20 @@ export function ChatProvider({ children }: { children: ReactNode }) { const tid = ev.toolCallId ? String(ev.toolCallId) : undefined; const isError = ev.type === "tool_execution_end" ? ev.isError : undefined; const full = buildToolCallUpdateText(ev, isError); - setMessages((prev) => { - let idx = tid ? prev.findIndex((msg) => msg.toolCallId === tid) : -1; - if (idx < 0) { - const toolCalls = prev.filter((m) => m.role === "tool_call"); - if (toolCalls.length > 0) { - idx = prev.findIndex((m) => m.id === toolCalls[toolCalls.length - 1].id); + setMessages( + (prev) => { + let idx = tid ? prev.findIndex((msg) => msg.toolCallId === tid) : -1; + if (idx < 0) { + const toolCalls = prev.filter((m) => m.role === "tool_call"); + if (toolCalls.length > 0) { + idx = prev.findIndex((m) => m.id === toolCalls[toolCalls.length - 1].id); + } } - } - if (idx < 0) return prev; - return prev.map((msg, i) => (i === idx ? { ...msg, content: full } : msg)); - }); + if (idx < 0) return prev; + return prev.map((msg, i) => (i === idx ? { ...msg, content: full } : msg)); + }, + { skipNormalize: true }, + ); break; } @@ -1692,9 +1783,7 @@ export function ChatProvider({ children }: { children: ReactNode }) { const bashId = activeBashMsgIdRef.current; if (!bashId) break; const output = typeof ev.partialOutput === "string" ? ev.partialOutput : ""; - setMessages((prev) => - prev.map((msg) => (msg.id === bashId ? { ...msg, content: output, streaming: true } : msg)), - ); + scheduleBashOutputUpdate(output); break; } } @@ -1707,6 +1796,9 @@ export function ChatProvider({ children }: { children: ReactNode }) { addSystemMessage, resetStreamingState, updateRunPhase, + scheduleAssistantMessageUpdate, + scheduleBashOutputUpdate, + flushPendingStreamUpdates, ], ); @@ -1839,53 +1931,105 @@ export function ChatProvider({ children }: { children: ReactNode }) { }; }, []); - const value: ChatContextValue = { - connected, - isStreaming, - messages, - sessions, - activeSessionPath, - headerTitle, - headerMeta, - sidebarOpen, - sessionState, - availableModels, - currentModelKey, - thinkingLevel, - availableThinkingLevels, - isApplyingModelSettings, - pendingSteering, - pendingFollowUp, - retryState, - isCompacting, - runPhase, - extensionWidgets, - extensionStatuses, - extensionDialog, - toolsExpanded, - showThinkingAndTools, - toggleSidebar: () => setSidebarOpen((o) => !o), - closeSidebar: () => setSidebarOpen(false), - loadSessions, - loadSession, - deleteSession: deleteSessionHandler, - renameSession: renameSessionHandler, - toggleSessionPin: toggleSessionPinHandler, - newSession, - newTemporarySession, - isEphemeralSession, - sendMessage, - runBashCommand, - abortStream, - abortRetry, - toggleToolsExpanded, - toggleShowThinkingAndTools, - respondExtensionDialog, - dismissExtensionDialog, - applyModelSelection, - applyThinkingSelection, - addSystemMessage, - }; + const toggleSidebar = useCallback(() => setSidebarOpen((o) => !o), []); + const closeSidebar = useCallback(() => setSidebarOpen(false), []); + + const value = useMemo( + () => ({ + connected, + isStreaming, + messages, + sessions, + activeSessionPath, + headerTitle, + headerMeta, + sidebarOpen, + sessionState, + availableModels, + currentModelKey, + thinkingLevel, + availableThinkingLevels, + isApplyingModelSettings, + pendingSteering, + pendingFollowUp, + retryState, + isCompacting, + runPhase, + extensionWidgets, + extensionStatuses, + extensionDialog, + toolsExpanded, + showThinkingAndTools, + toggleSidebar, + closeSidebar, + loadSessions, + loadSession, + deleteSession: deleteSessionHandler, + renameSession: renameSessionHandler, + toggleSessionPin: toggleSessionPinHandler, + newSession, + newTemporarySession, + isEphemeralSession, + sendMessage, + runBashCommand, + abortStream, + abortRetry, + toggleToolsExpanded, + toggleShowThinkingAndTools, + respondExtensionDialog, + dismissExtensionDialog, + applyModelSelection, + applyThinkingSelection, + addSystemMessage, + }), + [ + connected, + isStreaming, + messages, + sessions, + activeSessionPath, + headerTitle, + headerMeta, + sidebarOpen, + sessionState, + availableModels, + currentModelKey, + thinkingLevel, + availableThinkingLevels, + isApplyingModelSettings, + pendingSteering, + pendingFollowUp, + retryState, + isCompacting, + runPhase, + extensionWidgets, + extensionStatuses, + extensionDialog, + toolsExpanded, + showThinkingAndTools, + toggleSidebar, + closeSidebar, + loadSessions, + loadSession, + deleteSessionHandler, + renameSessionHandler, + toggleSessionPinHandler, + newSession, + newTemporarySession, + isEphemeralSession, + sendMessage, + runBashCommand, + abortStream, + abortRetry, + toggleToolsExpanded, + toggleShowThinkingAndTools, + respondExtensionDialog, + dismissExtensionDialog, + applyModelSelection, + applyThinkingSelection, + addSystemMessage, + ], + ); return {children}; } diff --git a/frontend/src/routes/SettingsPage.module.css b/frontend/src/routes/SettingsPage.module.css index ce61b33..b49283a 100644 --- a/frontend/src/routes/SettingsPage.module.css +++ b/frontend/src/routes/SettingsPage.module.css @@ -208,28 +208,6 @@ padding: 24px 28px; } -.markdownBody :global(pre.assistant-pre) { - margin: 0.5em 0 0.6em; - padding: 0; - background: transparent; - border: 1px solid #d0d7de; - border-radius: 6px; - overflow-x: auto; - line-height: 1.45; -} - -.markdownBody :global(pre.assistant-pre code.hljs) { - display: block; - padding: 16px; - background: #f6f8fa; - border: none; - color: inherit; - font-size: 13px; - line-height: 1.45; - word-break: normal; - tab-size: 2; -} - .primaryBtn, .secondaryBtn, .linkBtn { diff --git a/frontend/src/routes/SettingsPage.tsx b/frontend/src/routes/SettingsPage.tsx index b7c233d..5171a3e 100644 --- a/frontend/src/routes/SettingsPage.tsx +++ b/frontend/src/routes/SettingsPage.tsx @@ -100,7 +100,7 @@ function extensionCategory(extension: ExtensionInfo): "local" | "npm" { } if (extension.source?.startsWith("npm:")) return "npm"; const pathHint = `${extension.path || ""} ${extension.location || ""}`.replace(/\\/g, "/"); - if (pathHint.includes("/.sproutclaw/agent/npm/node_modules/")) return "npm"; + if (pathHint.includes("/agent/npm/node_modules/")) return "npm"; return "local"; } @@ -1366,7 +1366,7 @@ export function SettingsPage() {

- 开启后在对话中显示 AI 的思考过程和工具调用详情,默认关闭。 + 开启后在对话中显示 AI 的思考过程和工具调用;思考内容默认折叠,点击可展开。

@@ -1464,7 +1464,7 @@ export function SettingsPage() { ["数据目录", envInfo.dataDir], ["项目根目录", envInfo.repoRoot], ["工作目录", envInfo.cwd], - ["Pi 命令", envInfo.piCmd], + ["SproutClaw 命令", envInfo.sproutclawCmd || envInfo.piCmd], ] as [string, string | undefined][] ) .filter(([, v]) => v !== undefined && v !== "") diff --git a/frontend/src/styles/markdown-body.css b/frontend/src/styles/markdown-body.css new file mode 100644 index 0000000..6bbfd00 --- /dev/null +++ b/frontend/src/styles/markdown-body.css @@ -0,0 +1,22 @@ +@import "github-markdown-css/github-markdown.css"; + +.markdown-body pre code.hljs { + display: block; + padding: 0; + overflow: visible; + line-height: inherit; + word-break: normal; + word-wrap: normal; + tab-size: 2; + background: transparent; + font-family: var( + --fontStack-monospace, + ui-monospace, + SFMono-Regular, + SF Mono, + Menlo, + Consolas, + Liberation Mono, + monospace + ) !important; +} diff --git a/frontend/src/types/events.ts b/frontend/src/types/events.ts index e4bbf15..545c148 100644 --- a/frontend/src/types/events.ts +++ b/frontend/src/types/events.ts @@ -258,6 +258,7 @@ export interface EnvironmentInfo { repoRoot?: string; cwd?: string; piCmd?: string; + sproutclawCmd?: string; // Agent 工具 agentTools?: AgentToolInfo[]; // 兼容旧字段 diff --git a/frontend/src/utils/markdown.ts b/frontend/src/utils/markdown.ts index 370cfda..5bfbfb2 100644 --- a/frontend/src/utils/markdown.ts +++ b/frontend/src/utils/markdown.ts @@ -9,6 +9,7 @@ import css from "highlight.js/lib/languages/css"; import xml from "highlight.js/lib/languages/xml"; import { Marked, type Tokens } from "marked"; import "highlight.js/styles/github.min.css"; +import "../styles/markdown-body.css"; hljs.registerLanguage("javascript", javascript); hljs.registerLanguage("typescript", typescript); @@ -50,7 +51,7 @@ function renderAssistantCodeBlock(token: Tokens.Code): string { const safeLangSlug = /^[a-z][a-z0-9_-]*$/i.test(langSlug) ? langSlug : ""; const codeClass = safeLangSlug ? `hljs language-${safeLangSlug}` : "hljs"; - return `
${innerHtml}
\n`; + return `
${innerHtml}
\n`; } const marked = new Marked({ @@ -61,7 +62,18 @@ const marked = new Marked({ }, }); +const MARKDOWN_CACHE_MAX = 200; +const markdownCache = new Map(); + export function renderMarkdown(text: string): string { if (!text) return ""; - return marked.parse(text) as string; + const cached = markdownCache.get(text); + if (cached !== undefined) return cached; + const html = marked.parse(text) as string; + if (markdownCache.size >= MARKDOWN_CACHE_MAX) { + const oldest = markdownCache.keys().next().value; + if (oldest !== undefined) markdownCache.delete(oldest); + } + markdownCache.set(text, html); + return html; } diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index ed1de01..0a880fe 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -83,7 +83,11 @@ export default defineConfig(({ mode }) => { if (id.includes("node_modules/@xterm")) { return "xterm-vendor"; } - if (id.includes("node_modules/highlight.js") || id.includes("node_modules/marked")) { + if ( + id.includes("node_modules/highlight.js") || + id.includes("node_modules/marked") || + id.includes("node_modules/github-markdown-css") + ) { return "markdown-vendor"; } }, diff --git a/run-backend.bat b/run-backend.bat index 9779bd3..c57b86d 100644 --- a/run-backend.bat +++ b/run-backend.bat @@ -6,7 +6,7 @@ rem ------------------------------------------------------- cd /d "%~dp0backend" echo Starting Go backend on port %BACKEND_PORT%... -go run ./cmd/server --port %BACKEND_PORT% --agent-dir "%SPROUTCLAW_DIR%\.sproutclaw\agent" --pi-cmd "node %SPROUTCLAW_DIR%\packages\coding-agent\src\cli.ts" +go run ./cmd/server --port %BACKEND_PORT% --agent-dir "%SPROUTCLAW_DIR%\.sproutclaw\agent" --repo-root "%SPROUTCLAW_DIR%" --pi-cmd "node %SPROUTCLAW_DIR%\packages\coding-agent\dist\cli.js" echo. pause >nul diff --git a/run-backend.sh b/run-backend.sh new file mode 100755 index 0000000..3e588c6 --- /dev/null +++ b/run-backend.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SPROUTCLAW_DIR="${SPROUTCLAW_DIR:-$(cd "$SCRIPT_DIR/../sproutclaw" && pwd)}" +BACKEND_PORT="${BACKEND_PORT:-19133}" +AGENT_DIR="${SPROUTCLAW_AGENT_DIR:-$SPROUTCLAW_DIR/.sproutclaw/agent}" +PI_CMD="${SPROUTCLAW_CMD:-node $SPROUTCLAW_DIR/packages/coding-agent/dist/cli.js}" + +cd "$SCRIPT_DIR/backend" +echo "[run-backend] port=$BACKEND_PORT agent-dir=$AGENT_DIR" +exec go run ./cmd/server \ + --port "$BACKEND_PORT" \ + --agent-dir "$AGENT_DIR" \ + --repo-root "$SPROUTCLAW_DIR" \ + --pi-cmd "$PI_CMD" \ + --frontend-dist "$SCRIPT_DIR/frontend/dist" \ + --data-dir "$SCRIPT_DIR/backend/data"