feat: 增强聊天 UI 与终端配置

- 重构消息列表、思考块与工具调用块样式
- ChatContext 支持 agent 分段与 markdown 渲染优化
- 后端增加 PI 命令配置与 Web 终端改进
- 新增 run-backend.sh 启动脚本,忽略 backend/dist 构建产物
This commit is contained in:
2026-06-26 13:21:15 +08:00
parent bd2251e89a
commit 33717d742b
27 changed files with 803 additions and 475 deletions

1
.gitignore vendored
View File

@@ -1,5 +1,6 @@
backend/sproutclaw-web backend/sproutclaw-web
backend/sproutclaw-web.exe backend/sproutclaw-web.exe
backend/dist/
backend/data/ backend/data/
frontend/dist/ frontend/dist/
frontend/dist-desktop/ frontend/dist-desktop/

View File

@@ -27,16 +27,16 @@ func main() {
defer database.Close() defer database.Close()
log.Printf("[sproutclaw-web] db: %s", database.DbPath) 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) { piClient, err := rpc.NewClient(cfg.PiCmd, cfg.PiArgs, cfg.RepoRoot, cfg.AgentDir, func(code int) {
if code != 0 { 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(1)
} }
os.Exit(0) os.Exit(0)
}) })
if err != nil { if err != nil {
log.Fatalf("pi client: %v", err) log.Fatalf("SproutClaw client: %v", err)
} }
defer piClient.Stop() defer piClient.Stop()

View File

@@ -8,12 +8,14 @@ import (
"strings" "strings"
) )
const ConfigDirName = ".sproutclaw"
type Config struct { type Config struct {
Port int Port int
AgentDir string AgentDir string
RepoRoot string // pi 仓库根目录(pi CLI 子进程的工作目录) RepoRoot string // SproutClaw 仓库根目录CLI 子进程的工作目录)
DataDir string // sproutclaw-web 自己的数据目录SQLite 等) DataDir string // sproutclaw-web 自己的数据目录SQLite 等)
PiCmd string // pi CLI 启动命令(完整路径) PiCmd string // SproutClaw CLI 启动命令(完整路径)
PiArgs []string PiArgs []string
// 派生路径(从 AgentDir 推导) // 派生路径(从 AgentDir 推导)
@@ -35,13 +37,16 @@ func New() *Config {
agentDir := flag.String("agent-dir", "", "SproutClaw agent directory (e.g. /path/to/.sproutclaw/agent)") 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") 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)") 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)") frontendDist := flag.String("frontend-dist", "", "path to frontend/dist (defaults to ../frontend/dist relative to binary)")
flag.Parse() flag.Parse()
if *agentDir == "" { if *agentDir == "" {
cwd, _ := os.Getwd() *agentDir = resolveDefaultAgentDir()
*agentDir = filepath.Join(cwd, ".sproutclaw", "agent") }
if abs, err := filepath.Abs(*agentDir); err == nil {
*agentDir = abs
} }
cfg := &Config{ cfg := &Config{
@@ -50,9 +55,11 @@ func New() *Config {
RepoRoot: *repoRoot, RepoRoot: *repoRoot,
DataDir: *dataDir, DataDir: *dataDir,
} }
// repoRoot defaults to the directory two levels above agentDir (.sproutclaw/agent -> repo root)
if cfg.RepoRoot == "" { 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/) // dataDir defaults to ./data relative to cwd (run-backend.bat cd's into backend/)
if cfg.DataDir == "" { if cfg.DataDir == "" {
@@ -62,10 +69,14 @@ func New() *Config {
if abs, err := filepath.Abs(cfg.DataDir); err == nil { if abs, err := filepath.Abs(cfg.DataDir); err == nil {
cfg.DataDir = abs cfg.DataDir = abs
} }
// --pi-cmd may include arguments separated by spaces, e.g. // --pi-cmd / --sproutclaw-cmd may include arguments separated by spaces, e.g.
// "node /path/to/tsx/dist/cli.mjs /path/to/pi/cli.ts" // "node /path/to/tsx/dist/cli.mjs /path/to/cli.ts"
// Split into executable + args so exec.Command receives them correctly. // 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.PiCmd = parts[0]
cfg.PiArgs = parts[1:] cfg.PiArgs = parts[1:]
} }
@@ -107,3 +118,32 @@ func Platform() string {
func Arch() string { func Arch() string {
return runtime.GOARCH 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))
}

View File

@@ -225,25 +225,26 @@ func handleEnvironment(cfg *config.Config) gin.HandlerFunc {
} }
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"goVersion": runtime.Version(), "goVersion": runtime.Version(),
"platform": runtime.GOOS, "platform": runtime.GOOS,
"arch": runtime.GOARCH, "arch": runtime.GOARCH,
"osRelease": osRelease, "osRelease": osRelease,
"hostname": hostname, "hostname": hostname,
"numCPU": runtime.NumCPU(), "numCPU": runtime.NumCPU(),
"totalMemMb": totalMemMb, "totalMemMb": totalMemMb,
"freeMemMb": freeMemMb, "freeMemMb": freeMemMb,
"pid": os.Getpid(), "pid": os.Getpid(),
"uptime": uptimeSec, "uptime": uptimeSec,
"execPath": exe, "execPath": exe,
"goroutines": runtime.NumGoroutine(), "goroutines": runtime.NumGoroutine(),
"goHeapMb": goHeapMb, "goHeapMb": goHeapMb,
"cwd": cwd, "cwd": cwd,
"port": cfg.Port, "port": cfg.Port,
"agentDir": cfg.AgentDir, "agentDir": cfg.AgentDir,
"dataDir": cfg.DataDir, "dataDir": cfg.DataDir,
"repoRoot": cfg.RepoRoot, "repoRoot": cfg.RepoRoot,
"piCmd": piCmd, "piCmd": piCmd,
"sproutclawCmd": piCmd,
"memUsagePct": func() string { "memUsagePct": func() string {
if totalMemMb == 0 { if totalMemMb == 0 {
return "" return ""

View File

@@ -37,7 +37,7 @@ func handleTerminalWS(cfg *config.Config) gin.HandlerFunc {
} }
defer conn.Close() 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()}) _ = conn.WriteJSON(gin.H{"type": "error", "error": err.Error()})
} }
} }

View File

@@ -73,16 +73,19 @@ type Client struct {
// with the exit code when the subprocess terminates. // with the exit code when the subprocess terminates.
func NewClient(piCmd string, piArgs []string, repoRoot, agentDir string, onExit func(int)) (*Client, error) { func NewClient(piCmd string, piArgs []string, repoRoot, agentDir string, onExit func(int)) (*Client, error) {
if piCmd == "" { 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") args := append(append([]string{}, piArgs...), "--mode", "rpc")
cmd := exec.Command(piCmd, args...) cmd := exec.Command(piCmd, args...)
cmd.Dir = repoRoot 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() stdin, err := cmd.StdinPipe()
if err != nil { if err != nil {
@@ -115,7 +118,7 @@ func NewClient(piCmd string, piArgs []string, repoRoot, agentDir string, onExit
if cmd.ProcessState != nil { if cmd.ProcessState != nil {
code = cmd.ProcessState.ExitCode() 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) onExit(code)
}() }()

View File

@@ -21,7 +21,7 @@ type terminalWSMessage struct {
} }
// ServeWebTerminal attaches a PTY shell session to the websocket connection. // 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) abs, err := filepath.Abs(workDir)
if err != nil { if err != nil {
return fmt.Errorf("解析目录失败: %w", err) return fmt.Errorf("解析目录失败: %w", err)
@@ -34,15 +34,21 @@ func ServeWebTerminal(conn *websocket.Conn, workDir string) error {
return fmt.Errorf("不是有效目录: %s", abs) 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{ ptmx, err := crosspty.Start(crosspty.CommandConfig{
Argv: defaultShellArgv(), Argv: defaultShellArgv(),
Dir: abs, Dir: abs,
Env: os.Environ(), Env: os.Environ(),
EnvInject: map[string]string{ EnvInject: envInject,
"TERM": "xterm-256color", Size: crosspty.TermSize{Rows: 24, Cols: 80},
"COLORTERM": "truecolor",
},
Size: crosspty.TermSize{Rows: 24, Cols: 80},
}) })
if err != nil { if err != nil {
if err == crosspty.ErrConPTYNotSupported { if err == crosspty.ErrConPTYNotSupported {

View File

@@ -6,6 +6,6 @@ OUT="$SCRIPT_DIR/backend/dist/sproutclaw-web-backend"
echo "[build] compiling Go backend -> $OUT" echo "[build] compiling Go backend -> $OUT"
cd "$SCRIPT_DIR/backend" cd "$SCRIPT_DIR/backend"
/usr/local/go/bin/go build -o "$OUT" ./cmd/server go build -o "$OUT" ./cmd/server
chmod +x "$OUT" chmod +x "$OUT"
echo "[build] done: $OUT" echo "[build] done: $OUT"

View File

@@ -1,35 +1,31 @@
.turn { .turn {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 3px;
} }
.text { .textSegment {
line-height: 1.5; composes: textSegment from "./agentSegment.module.css";
} }
.thinking { .textBody {
line-height: 1.45; composes: textBody from "./agentSegment.module.css";
font-style: italic; }
color: #6b7280;
.text:global(.markdown-body) {
font-size: 14px;
line-height: 1.4;
background: transparent;
}
.streamingPlain {
composes: streamingPlain from "./agentSegment.module.css";
} }
.thinkingHidden { .thinkingHidden {
margin: 0; margin: 0;
font-style: italic; font-style: italic;
color: #9ca3af; color: #9ca3af;
font-size: 0.92em; font-size: 12px;
} line-height: 1.4;
}
.streaming::after {
content: "▊";
animation: blink 0.8s step-end infinite;
color: #8b6fd4;
margin-left: 1px;
}
@keyframes blink {
50% {
opacity: 0;
}
}

View File

@@ -1,5 +1,7 @@
import { memo, useMemo } from "react";
import type { AssistantBlock } from "../../types/message"; import type { AssistantBlock } from "../../types/message";
import { renderMarkdown } from "../../utils/markdown"; import { renderMarkdown } from "../../utils/markdown";
import { ThinkingBlock } from "./ThinkingBlock";
import styles from "./AssistantTurnBody.module.css"; import styles from "./AssistantTurnBody.module.css";
interface AssistantTurnBodyProps { interface AssistantTurnBodyProps {
@@ -8,7 +10,30 @@ interface AssistantTurnBodyProps {
showThinking?: boolean; 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 (
<div className={styles.textSegment}>
{streaming ? (
<div className={styles.streamingPlain}>{content}</div>
) : (
<div
className={`markdown-body ${styles.text}`}
data-theme="light"
dangerouslySetInnerHTML={{ __html: html }}
/>
)}
</div>
);
});
export const AssistantTurnBody = memo(function AssistantTurnBody({
blocks, blocks,
streaming = false, streaming = false,
showThinking = true, showThinking = true,
@@ -27,22 +52,22 @@ export function AssistantTurnBody({
); );
} }
return ( return (
<div <ThinkingBlock
key={`thinking-${index}`} key={`thinking-${index}`}
className={`${styles.thinking} ${streaming ? styles.streaming : ""}`} content={block.content}
dangerouslySetInnerHTML={{ __html: renderMarkdown(block.content) }} streaming={streaming}
/> />
); );
} }
return ( return (
<div <TextBlockContent
key={`text-${index}`} key={`text-${index}`}
className={`${styles.text} ${streaming ? styles.streaming : ""}`} content={block.content}
dangerouslySetInnerHTML={{ __html: renderMarkdown(block.content) }} streaming={streaming}
/> />
); );
})} })}
</div> </div>
); );
} });

View File

@@ -1,7 +1,6 @@
import { useState } from "react"; import { memo, useState } from "react";
import type { ChatMessage } from "../../types/message"; import type { ChatMessage } from "../../types/message";
import { useAvatars } from "../../context/AvatarContext"; import { useAvatars } from "../../context/AvatarContext";
import { useChatContext } from "../../context/ChatContext";
import { import {
downloadTextFile, downloadTextFile,
messageMarkdownFilename, messageMarkdownFilename,
@@ -20,6 +19,7 @@ interface MessageBubbleProps {
message: ChatMessage; message: ChatMessage;
agentGutter?: "avatar" | "spacer" | "none"; agentGutter?: "avatar" | "spacer" | "none";
toolsExpanded?: boolean; toolsExpanded?: boolean;
showThinkingAndTools?: boolean;
} }
function ChatAvatar({ function ChatAvatar({
@@ -102,13 +102,13 @@ function AssistantActions({ content }: { content: string }) {
); );
} }
export function MessageBubble({ export const MessageBubble = memo(function MessageBubble({
message, message,
agentGutter = "none", agentGutter = "none",
toolsExpanded = false, toolsExpanded = false,
showThinkingAndTools = true,
}: MessageBubbleProps) { }: MessageBubbleProps) {
const { userAvatarUrl, agentAvatarUrl } = useAvatars(); const { userAvatarUrl, agentAvatarUrl } = useAvatars();
const { showThinkingAndTools } = useChatContext();
const gutter = const gutter =
agentGutter === "avatar" || agentGutter === "spacer" ? ( agentGutter === "avatar" || agentGutter === "spacer" ? (
<AgentSideGutter mode={agentGutter} avatarUrl={agentAvatarUrl} /> <AgentSideGutter mode={agentGutter} avatarUrl={agentAvatarUrl} />
@@ -116,7 +116,7 @@ export function MessageBubble({
if (message.role === "tool_call") { if (message.role === "tool_call") {
return ( return (
<div className={styles.assistantRow}> <div className={`${styles.assistantRow} ${styles.toolCallRow}`}>
{gutter} {gutter}
<div className={`${styles.msg} ${styles.toolCall}`}> <div className={`${styles.msg} ${styles.toolCall}`}>
<ToolCallBlock content={message.content} forceOpen={toolsExpanded} /> <ToolCallBlock content={message.content} forceOpen={toolsExpanded} />
@@ -211,4 +211,4 @@ export function MessageBubble({
} }
return <div className={classNames.join(" ")}>{message.content}</div>; return <div className={classNames.join(" ")}>{message.content}</div>;
} });

View File

@@ -1,10 +1,10 @@
.chat { .chat {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
padding: 12px 16px 10px; padding: 8px 12px 6px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 6px; gap: 3px;
min-height: 0; min-height: 0;
} }
@@ -50,9 +50,9 @@
} }
.msg { .msg {
padding: 10px 12px; padding: 8px 10px;
line-height: 1.4; line-height: 1.4;
font-size: 16px; font-size: 14px;
word-wrap: break-word; word-wrap: break-word;
white-space: pre-wrap; white-space: pre-wrap;
} }
@@ -110,10 +110,10 @@
align-self: flex-start; align-self: flex-start;
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
gap: 10px; gap: 8px;
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
margin: 0 0 2px; margin: 0;
} }
.assistantAvatar { .assistantAvatar {
@@ -139,22 +139,25 @@
flex: 1; flex: 1;
min-width: 0; min-width: 0;
max-width: 100%; max-width: 100%;
padding: 10px 12px; padding: 0;
background: #fff; background: transparent;
border: 1px solid #e8ecf0; border: none;
border-radius: 14px 14px 14px 4px; border-radius: 0;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04); box-shadow: none;
color: #1a1a1a; color: #1a1a1a;
line-height: 1.46; line-height: 1.4;
white-space: normal; white-space: normal;
display: flex;
flex-direction: column;
gap: 3px;
} }
.assistantToolbar { .assistantToolbar {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
gap: 4px; gap: 4px;
margin: -2px -2px 6px 0; margin: 0 0 1px;
min-height: 22px; min-height: 20px;
opacity: 0; opacity: 0;
transition: opacity 0.15s ease; transition: opacity 0.15s ease;
} }
@@ -192,6 +195,9 @@
.assistantBody { .assistantBody {
min-width: 0; min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
} }
.system { .system {
@@ -211,6 +217,35 @@
background: transparent; background: transparent;
border: none; border: none;
box-shadow: 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 { .thinking {
@@ -250,129 +285,12 @@
margin-left: 1px; margin-left: 1px;
} }
.assistantRow + .assistantRow { .assistantRow + .assistantRow:not(.toolCallRow) {
margin-top: 2px; margin-top: 0;
} }
.assistant :global(h1), .assistant :global(.markdown-body) {
.assistant :global(h2), min-width: 0;
.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;
} }
@media (min-width: 769px) { @media (min-width: 769px) {
@@ -384,12 +302,13 @@
@media (max-width: 768px) { @media (max-width: 768px) {
.chat { .chat {
padding: 10px 12px 8px; padding: 6px 10px 4px;
gap: 3px;
} }
.msg { .msg {
padding: 11px 12px; padding: 8px 10px;
font-size: 16px; font-size: 14px;
} }
.userRow { .userRow {

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef } from "react"; import { useEffect, useMemo, useRef } from "react";
import { useChatContext } from "../../context/ChatContext"; import { useChatContext } from "../../context/ChatContext";
import type { MessageRole } from "../../types/message"; import type { MessageRole } from "../../types/message";
import { MessageBubble } from "./MessageBubble"; import { MessageBubble } from "./MessageBubble";
@@ -20,13 +20,22 @@ function isAgentSideRole(role: MessageRole): boolean {
export function MessageList() { export function MessageList() {
const { messages, toolsExpanded, showThinkingAndTools } = useChatContext(); const { messages, toolsExpanded, showThinkingAndTools } = useChatContext();
const visibleMessages = showThinkingAndTools const visibleMessages = useMemo(
? messages () =>
: messages.filter((m) => m.role !== "thinking" && m.role !== "tool_call"); showThinkingAndTools
? messages
: messages.filter((m) => m.role !== "thinking" && m.role !== "tool_call"),
[messages, showThinkingAndTools],
);
const chatRef = useRef<HTMLDivElement>(null); const chatRef = useRef<HTMLDivElement>(null);
const stickToBottomRef = useRef(true); const stickToBottomRef = useRef(true);
const prevMessageCountRef = useRef(0); 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(() => { useEffect(() => {
const el = chatRef.current; const el = chatRef.current;
if (!el) return; if (!el) return;
@@ -47,8 +56,8 @@ export function MessageList() {
const messageCount = messages.length; const messageCount = messages.length;
prevMessageCountRef.current = messageCount; prevMessageCountRef.current = messageCount;
const lastMessage = messages[messageCount - 1]; const last = messages[messageCount - 1];
const userJustSent = messageCount > prevCount && lastMessage?.role === "user"; const userJustSent = messageCount > prevCount && last?.role === "user";
if (userJustSent || prevCount === 0) { if (userJustSent || prevCount === 0) {
stickToBottomRef.current = true; stickToBottomRef.current = true;
@@ -59,7 +68,7 @@ export function MessageList() {
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (chatRef.current) scrollToBottom(chatRef.current); if (chatRef.current) scrollToBottom(chatRef.current);
}); });
}, [messages]); }, [scrollKey, messages.length]);
if (messages.length === 0) { if (messages.length === 0) {
return ( return (
@@ -91,9 +100,10 @@ export function MessageList() {
message={msg} message={msg}
agentGutter={agentGutter} agentGutter={agentGutter}
toolsExpanded={toolsExpanded} toolsExpanded={toolsExpanded}
showThinkingAndTools={showThinkingAndTools}
/> />
); );
})} })}
</div> </div>
); );
} }

View File

@@ -1,69 +1,28 @@
.block { .block {
margin: 0; composes: segment from "./agentSegment.module.css";
} }
.summary { .summary {
list-style: none; composes: summary from "./agentSegment.module.css";
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);
} }
.summaryLabel { .summaryLabel {
flex-shrink: 0; composes: summaryTitle from "./agentSegment.module.css";
font-size: inherit;
letter-spacing: 0.02em;
} }
.summaryText { .summaryText {
min-width: 0; composes: summaryPreview from "./agentSegment.module.css";
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-style: italic; font-style: italic;
color: #8b7aa8;
} }
.detail { .detail {
margin: 0; composes: detail from "./agentSegment.module.css";
padding: 6px 10px 8px;
border-top: 1px solid #ece6f5;
} }
.body { .body {
margin: 0; composes: body from "./agentSegment.module.css";
white-space: pre-wrap;
word-break: break-word;
font-size: inherit;
line-height: inherit;
font-style: italic; font-style: italic;
color: #6f5f8d; color: #6b7280;
} }
.streaming::after { .streaming::after {
@@ -77,4 +36,4 @@
50% { 50% {
opacity: 0; opacity: 0;
} }
} }

View File

@@ -10,7 +10,7 @@ export function ThinkingBlock({ content, streaming }: ThinkingBlockProps) {
const summary = deriveThinkingSummary(content); const summary = deriveThinkingSummary(content);
return ( return (
<details className={styles.block}> <details className={`thinking-block ${styles.block}`}>
<summary className={styles.summary}> <summary className={styles.summary}>
<span className={styles.summaryLabel}></span> <span className={styles.summaryLabel}></span>
<span className={styles.summaryText}>{summary}</span> <span className={styles.summaryText}>{summary}</span>

View File

@@ -1,97 +1,138 @@
.block { .block {
margin: 0; composes: segment from "./agentSegment.module.css";
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;
} }
.summary { .summary {
list-style: none; composes: summary from "./agentSegment.module.css";
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 8px;
padding: 8px 10px;
user-select: none;
}
.summary::-webkit-details-marker {
display: none;
} }
.toolName { .toolName {
font-weight: 600; composes: summaryTitleGrow from "./agentSegment.module.css";
color: #374151;
font-size: 0.92em;
} }
.status { .status {
flex-shrink: 0; flex-shrink: 0;
font-size: 11px; font-size: 10px;
color: #6b7280; font-weight: 500;
line-height: 1.2;
padding: 1px 5px;
border-radius: 999px;
background: #f6f8fa;
color: #656d76;
} }
.pending .status { .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 { .success .status {
color: #15803d; color: #1a7f37;
background: #dafbe1;
} }
.error .status { .error .status {
color: #b91c1c; color: #cf222e;
background: #ffebe9;
} }
.detail { .detail {
margin: 0; composes: detail from "./agentSegment.module.css";
padding: 8px 10px 10px;
border-top: 1px solid rgba(0, 0, 0, 0.06);
} }
.args { .args {
margin: 0 0 6px; margin: 4px 0 0;
font-size: 0.85em; padding: 4px 6px;
color: #6b7280; font-size: 11px;
font-family: var(--font-mono, ui-monospace, monospace); 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; word-break: break-all;
line-height: 1.35;
} }
.detailPre { .markdownBody:global(.markdown-body) {
margin: 0; font-size: 13px;
padding: 0; 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; white-space: pre-wrap;
word-break: break-word; word-break: break-word;
font-size: inherit; font-size: 11px;
line-height: inherit; line-height: 1.35;
font-family: var(--font-mono, ui-monospace, monospace); 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) { .diffPre :global(.diffAdd) {
color: #15803d; color: #1a7f37;
} }
.detailPre :global(.diffDel) { .diffPre :global(.diffDel) {
color: #b91c1c; color: #cf222e;
} }
.detailPre :global(.diffMeta) { .diffPre :global(.diffMeta) {
color: #6b7280; color: #656d76;
} }
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.35;
}
}
@media (max-width: 768px) {
.detail {
max-height: min(180px, 28vh);
}
}

View File

@@ -1,4 +1,6 @@
import { memo, useMemo } from "react";
import { deriveToolCallSummary, formatToolBodyHtml } from "../../utils/toolCall"; import { deriveToolCallSummary, formatToolBodyHtml } from "../../utils/toolCall";
import { renderMarkdown } from "../../utils/markdown";
import styles from "./ToolCallBlock.module.css"; import styles from "./ToolCallBlock.module.css";
interface ToolCallBlockProps { interface ToolCallBlockProps {
@@ -31,34 +33,66 @@ function parseToolCallContent(content: string): {
return { title, body, status }; return { title, body, status };
} }
export function ToolCallBlock({ content, forceOpen }: ToolCallBlockProps) { interface ToolBodyProps {
const { title, body, status } = parseToolCallContent(content); 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 (
<pre
className={styles.diffPre}
dangerouslySetInnerHTML={{ __html: formatToolBodyHtml(text) }}
/>
);
}
if (status === "pending") {
return <pre className={styles.plainPre}>{text}</pre>;
}
return (
<div
className={`markdown-body ${styles.markdownBody}`}
data-theme="light"
dangerouslySetInnerHTML={{ __html: html }}
/>
);
});
export const ToolCallBlock = memo(function ToolCallBlock({ content, forceOpen }: ToolCallBlockProps) {
const { title, body, status } = useMemo(() => parseToolCallContent(content), [content]);
const hasDiff = const hasDiff =
body.includes("\n+") || body.includes("\n-") || body.startsWith("+") || body.startsWith("-"); body.includes("\n+") || body.includes("\n-") || body.startsWith("+") || body.startsWith("-");
const statusLabel = status === "success" ? "完成" : status === "error" ? "失败" : "执行中"; 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 ( return (
<details <details className={`tool-call-block ${styles.block} ${styles[status]}`} open={forceOpen}>
className={`${styles.block} ${styles[status]}`}
open={forceOpen || status === "pending"}
>
<summary className={styles.summary}> <summary className={styles.summary}>
<span className={styles.toolName}>{title.split("(")[0] || deriveToolCallSummary(content)}</span> <span className={styles.toolName} title={title}>
{toolName}
</span>
<span className={styles.status}>{statusLabel}</span> <span className={styles.status}>{statusLabel}</span>
</summary> </summary>
{(body || content !== title) && ( {hasDetail ? (
<div className={styles.detail}> <div className={styles.detail}>
{title.includes("(") ? <div className={styles.args}>{title}</div> : null} {title.includes("(") ? <div className={styles.args}>{title}</div> : null}
{hasDiff ? ( <ToolBody body={body} fallback={content} hasDiff={hasDiff} status={status} />
<pre
className={styles.detailPre}
dangerouslySetInnerHTML={{ __html: formatToolBodyHtml(body || content) }}
/>
) : (
<pre className={styles.detailPre}>{body || content}</pre>
)}
</div> </div>
)} ) : null}
</details> </details>
); );
} });

View File

@@ -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;
}

View File

@@ -464,9 +464,11 @@ export function ChatProvider({ children }: { children: ReactNode }) {
const [toolsExpanded, setToolsExpanded] = useState(false); const [toolsExpanded, setToolsExpanded] = useState(false);
const [showThinkingAndTools, setShowThinkingAndTools] = useState(() => { const [showThinkingAndTools, setShowThinkingAndTools] = useState(() => {
try { 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 { } catch {
return false; return true;
} }
}); });
const [isEphemeralSession, setIsEphemeralSession] = useState(false); const [isEphemeralSession, setIsEphemeralSession] = useState(false);
@@ -484,6 +486,10 @@ export function ChatProvider({ children }: { children: ReactNode }) {
const compactionReloadPendingRef = useRef(false); const compactionReloadPendingRef = useRef(false);
const activeBashMsgIdRef = useRef<string | null>(null); const activeBashMsgIdRef = useRef<string | null>(null);
const sessionDashboardRaf = useRef<number | null>(null); const sessionDashboardRaf = useRef<number | null>(null);
const pendingAssistantBlocksRef = useRef<AssistantBlock[] | null>(null);
const messageUpdateRafRef = useRef<number | null>(null);
const pendingBashOutputRef = useRef<string | null>(null);
const bashUpdateRafRef = useRef<number | null>(null);
const backendSessionPathRef = useRef(backendSessionPath); const backendSessionPathRef = useRef(backendSessionPath);
const activeSessionPathRef = useRef(activeSessionPath); const activeSessionPathRef = useRef(activeSessionPath);
const isStreamingRef = useRef(isStreaming); const isStreamingRef = useRef(isStreaming);
@@ -498,12 +504,91 @@ export function ChatProvider({ children }: { children: ReactNode }) {
isStreamingRef.current = isStreaming; isStreamingRef.current = isStreaming;
}, [isStreaming]); }, [isStreaming]);
const setMessages = useCallback((next: SetStateAction<ChatMessage[]>) => { const setMessages = useCallback(
setRawMessages((prev) => { (next: SetStateAction<ChatMessage[]>, options?: { skipNormalize?: boolean }) => {
const resolved = typeof next === "function" ? next(prev) : next; setRawMessages((prev) => {
return normalizeChatMessages(resolved); 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( const availableThinkingLevels = useMemo(
() => getAvailableThinkingLevels(sessionState?.model), () => getAvailableThinkingLevels(sessionState?.model),
@@ -1482,6 +1567,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
break; break;
case "agent_end": case "agent_end":
flushPendingStreamUpdates();
setIsStreaming(false); setIsStreaming(false);
setConnected(true); setConnected(true);
setMessages((prev) => finalizeStreamingTurn(prev)); setMessages((prev) => finalizeStreamingTurn(prev));
@@ -1537,14 +1623,13 @@ export function ChatProvider({ children }: { children: ReactNode }) {
} else if (hasTextBlock(blocks)) { } else if (hasTextBlock(blocks)) {
updateRunPhase("writing"); updateRunPhase("writing");
} }
setMessages((prev) => scheduleAssistantMessageUpdate(blocks);
applyAssistantMessageUpdate(prev, blocks, streamingAssistantId),
);
} }
break; break;
} }
case "message_end": { case "message_end": {
flushPendingStreamUpdates();
const m = ev.message; const m = ev.message;
if (m.role === "assistant") { if (m.role === "assistant") {
const blocks = extractAssistantBlocks(m.content); const blocks = extractAssistantBlocks(m.content);
@@ -1574,25 +1659,28 @@ export function ChatProvider({ children }: { children: ReactNode }) {
const tid = ev.toolCallId ? String(ev.toolCallId) : undefined; const tid = ev.toolCallId ? String(ev.toolCallId) : undefined;
updateRunPhase("tool", ev.toolName); updateRunPhase("tool", ev.toolName);
const label = `🔧 ${ev.toolName}(${JSON.stringify(ev.args ?? {})})`; const label = `🔧 ${ev.toolName}(${JSON.stringify(ev.args ?? {})})`;
setMessages((prev) => { setMessages(
const existingIdx = tid (prev) => {
? prev.findIndex((msg) => msg.toolCallId === tid) const existingIdx = tid
: -1; ? prev.findIndex((msg) => msg.toolCallId === tid)
if (existingIdx >= 0) { : -1;
return prev.map((msg, i) => if (existingIdx >= 0) {
i === existingIdx ? { ...msg, content: label } : msg, return prev.map((msg, i) =>
); i === existingIdx ? { ...msg, content: label } : msg,
} );
return [ }
...prev, return [
{ ...prev,
id: tid ? `tool-${tid}` : nextMessageId(), {
role: "tool_call" as const, id: tid ? `tool-${tid}` : nextMessageId(),
content: label, role: "tool_call" as const,
toolCallId: tid, content: label,
}, toolCallId: tid,
]; },
}); ];
},
{ skipNormalize: true },
);
break; break;
} }
@@ -1602,17 +1690,20 @@ export function ChatProvider({ children }: { children: ReactNode }) {
const tid = ev.toolCallId ? String(ev.toolCallId) : undefined; const tid = ev.toolCallId ? String(ev.toolCallId) : undefined;
const isError = ev.type === "tool_execution_end" ? ev.isError : undefined; const isError = ev.type === "tool_execution_end" ? ev.isError : undefined;
const full = buildToolCallUpdateText(ev, isError); const full = buildToolCallUpdateText(ev, isError);
setMessages((prev) => { setMessages(
let idx = tid ? prev.findIndex((msg) => msg.toolCallId === tid) : -1; (prev) => {
if (idx < 0) { let idx = tid ? prev.findIndex((msg) => msg.toolCallId === tid) : -1;
const toolCalls = prev.filter((m) => m.role === "tool_call"); if (idx < 0) {
if (toolCalls.length > 0) { const toolCalls = prev.filter((m) => m.role === "tool_call");
idx = prev.findIndex((m) => m.id === toolCalls[toolCalls.length - 1].id); if (toolCalls.length > 0) {
idx = prev.findIndex((m) => m.id === toolCalls[toolCalls.length - 1].id);
}
} }
} if (idx < 0) return prev;
if (idx < 0) return prev; return prev.map((msg, i) => (i === idx ? { ...msg, content: full } : msg));
return prev.map((msg, i) => (i === idx ? { ...msg, content: full } : msg)); },
}); { skipNormalize: true },
);
break; break;
} }
@@ -1692,9 +1783,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
const bashId = activeBashMsgIdRef.current; const bashId = activeBashMsgIdRef.current;
if (!bashId) break; if (!bashId) break;
const output = typeof ev.partialOutput === "string" ? ev.partialOutput : ""; const output = typeof ev.partialOutput === "string" ? ev.partialOutput : "";
setMessages((prev) => scheduleBashOutputUpdate(output);
prev.map((msg) => (msg.id === bashId ? { ...msg, content: output, streaming: true } : msg)),
);
break; break;
} }
} }
@@ -1707,6 +1796,9 @@ export function ChatProvider({ children }: { children: ReactNode }) {
addSystemMessage, addSystemMessage,
resetStreamingState, resetStreamingState,
updateRunPhase, updateRunPhase,
scheduleAssistantMessageUpdate,
scheduleBashOutputUpdate,
flushPendingStreamUpdates,
], ],
); );
@@ -1839,53 +1931,105 @@ export function ChatProvider({ children }: { children: ReactNode }) {
}; };
}, []); }, []);
const value: ChatContextValue = { const toggleSidebar = useCallback(() => setSidebarOpen((o) => !o), []);
connected, const closeSidebar = useCallback(() => setSidebarOpen(false), []);
isStreaming,
messages, const value = useMemo<ChatContextValue>(
sessions, () => ({
activeSessionPath, connected,
headerTitle, isStreaming,
headerMeta, messages,
sidebarOpen, sessions,
sessionState, activeSessionPath,
availableModels, headerTitle,
currentModelKey, headerMeta,
thinkingLevel, sidebarOpen,
availableThinkingLevels, sessionState,
isApplyingModelSettings, availableModels,
pendingSteering, currentModelKey,
pendingFollowUp, thinkingLevel,
retryState, availableThinkingLevels,
isCompacting, isApplyingModelSettings,
runPhase, pendingSteering,
extensionWidgets, pendingFollowUp,
extensionStatuses, retryState,
extensionDialog, isCompacting,
toolsExpanded, runPhase,
showThinkingAndTools, extensionWidgets,
toggleSidebar: () => setSidebarOpen((o) => !o), extensionStatuses,
closeSidebar: () => setSidebarOpen(false), extensionDialog,
loadSessions, toolsExpanded,
loadSession, showThinkingAndTools,
deleteSession: deleteSessionHandler, toggleSidebar,
renameSession: renameSessionHandler, closeSidebar,
toggleSessionPin: toggleSessionPinHandler, loadSessions,
newSession, loadSession,
newTemporarySession, deleteSession: deleteSessionHandler,
isEphemeralSession, renameSession: renameSessionHandler,
sendMessage, toggleSessionPin: toggleSessionPinHandler,
runBashCommand, newSession,
abortStream, newTemporarySession,
abortRetry, isEphemeralSession,
toggleToolsExpanded, sendMessage,
toggleShowThinkingAndTools, runBashCommand,
respondExtensionDialog, abortStream,
dismissExtensionDialog, abortRetry,
applyModelSelection, toggleToolsExpanded,
applyThinkingSelection, toggleShowThinkingAndTools,
addSystemMessage, 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 <ChatContext.Provider value={value}>{children}</ChatContext.Provider>; return <ChatContext.Provider value={value}>{children}</ChatContext.Provider>;
} }

View File

@@ -208,28 +208,6 @@
padding: 24px 28px; 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, .primaryBtn,
.secondaryBtn, .secondaryBtn,
.linkBtn { .linkBtn {

View File

@@ -100,7 +100,7 @@ function extensionCategory(extension: ExtensionInfo): "local" | "npm" {
} }
if (extension.source?.startsWith("npm:")) return "npm"; if (extension.source?.startsWith("npm:")) return "npm";
const pathHint = `${extension.path || ""} ${extension.location || ""}`.replace(/\\/g, "/"); 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"; return "local";
} }
@@ -1366,7 +1366,7 @@ export function SettingsPage() {
</span> </span>
</label> </label>
<p className={styles.fieldHint}> <p className={styles.fieldHint}>
AI AI
</p> </p>
</div> </div>
</div> </div>
@@ -1464,7 +1464,7 @@ export function SettingsPage() {
["数据目录", envInfo.dataDir], ["数据目录", envInfo.dataDir],
["项目根目录", envInfo.repoRoot], ["项目根目录", envInfo.repoRoot],
["工作目录", envInfo.cwd], ["工作目录", envInfo.cwd],
["Pi 命令", envInfo.piCmd], ["SproutClaw 命令", envInfo.sproutclawCmd || envInfo.piCmd],
] as [string, string | undefined][] ] as [string, string | undefined][]
) )
.filter(([, v]) => v !== undefined && v !== "") .filter(([, v]) => v !== undefined && v !== "")

View File

@@ -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;
}

View File

@@ -258,6 +258,7 @@ export interface EnvironmentInfo {
repoRoot?: string; repoRoot?: string;
cwd?: string; cwd?: string;
piCmd?: string; piCmd?: string;
sproutclawCmd?: string;
// Agent 工具 // Agent 工具
agentTools?: AgentToolInfo[]; agentTools?: AgentToolInfo[];
// 兼容旧字段 // 兼容旧字段

View File

@@ -9,6 +9,7 @@ import css from "highlight.js/lib/languages/css";
import xml from "highlight.js/lib/languages/xml"; import xml from "highlight.js/lib/languages/xml";
import { Marked, type Tokens } from "marked"; import { Marked, type Tokens } from "marked";
import "highlight.js/styles/github.min.css"; import "highlight.js/styles/github.min.css";
import "../styles/markdown-body.css";
hljs.registerLanguage("javascript", javascript); hljs.registerLanguage("javascript", javascript);
hljs.registerLanguage("typescript", typescript); 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 safeLangSlug = /^[a-z][a-z0-9_-]*$/i.test(langSlug) ? langSlug : "";
const codeClass = safeLangSlug ? `hljs language-${safeLangSlug}` : "hljs"; const codeClass = safeLangSlug ? `hljs language-${safeLangSlug}` : "hljs";
return `<pre class="assistant-pre"><code class="${codeClass}">${innerHtml}</code></pre>\n`; return `<pre><code class="${codeClass}">${innerHtml}</code></pre>\n`;
} }
const marked = new Marked({ const marked = new Marked({
@@ -61,7 +62,18 @@ const marked = new Marked({
}, },
}); });
const MARKDOWN_CACHE_MAX = 200;
const markdownCache = new Map<string, string>();
export function renderMarkdown(text: string): string { export function renderMarkdown(text: string): string {
if (!text) return ""; 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;
} }

View File

@@ -83,7 +83,11 @@ export default defineConfig(({ mode }) => {
if (id.includes("node_modules/@xterm")) { if (id.includes("node_modules/@xterm")) {
return "xterm-vendor"; 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"; return "markdown-vendor";
} }
}, },

View File

@@ -6,7 +6,7 @@ rem -------------------------------------------------------
cd /d "%~dp0backend" cd /d "%~dp0backend"
echo Starting Go backend on port %BACKEND_PORT%... 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. echo.
pause >nul pause >nul

18
run-backend.sh Executable file
View File

@@ -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"