feat: 增强聊天 UI 与终端配置
- 重构消息列表、思考块与工具调用块样式 - ChatContext 支持 agent 分段与 markdown 渲染优化 - 后端增加 PI 命令配置与 Web 终端改进 - 新增 run-backend.sh 启动脚本,忽略 backend/dist 构建产物
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
backend/sproutclaw-web
|
||||
backend/sproutclaw-web.exe
|
||||
backend/dist/
|
||||
backend/data/
|
||||
frontend/dist/
|
||||
frontend/dist-desktop/
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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()})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}()
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
2
build.sh
2
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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 (
|
||||
<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,
|
||||
streaming = false,
|
||||
showThinking = true,
|
||||
@@ -27,22 +52,22 @@ export function AssistantTurnBody({
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
<ThinkingBlock
|
||||
key={`thinking-${index}`}
|
||||
className={`${styles.thinking} ${streaming ? styles.streaming : ""}`}
|
||||
dangerouslySetInnerHTML={{ __html: renderMarkdown(block.content) }}
|
||||
content={block.content}
|
||||
streaming={streaming}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
<TextBlockContent
|
||||
key={`text-${index}`}
|
||||
className={`${styles.text} ${streaming ? styles.streaming : ""}`}
|
||||
dangerouslySetInnerHTML={{ __html: renderMarkdown(block.content) }}
|
||||
content={block.content}
|
||||
streaming={streaming}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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" ? (
|
||||
<AgentSideGutter mode={agentGutter} avatarUrl={agentAvatarUrl} />
|
||||
@@ -116,7 +116,7 @@ export function MessageBubble({
|
||||
|
||||
if (message.role === "tool_call") {
|
||||
return (
|
||||
<div className={styles.assistantRow}>
|
||||
<div className={`${styles.assistantRow} ${styles.toolCallRow}`}>
|
||||
{gutter}
|
||||
<div className={`${styles.msg} ${styles.toolCall}`}>
|
||||
<ToolCallBlock content={message.content} forceOpen={toolsExpanded} />
|
||||
@@ -211,4 +211,4 @@ export function MessageBubble({
|
||||
}
|
||||
|
||||
return <div className={classNames.join(" ")}>{message.content}</div>;
|
||||
}
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<HTMLDivElement>(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,6 +100,7 @@ export function MessageList() {
|
||||
message={msg}
|
||||
agentGutter={agentGutter}
|
||||
toolsExpanded={toolsExpanded}
|
||||
showThinkingAndTools={showThinkingAndTools}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -10,7 +10,7 @@ export function ThinkingBlock({ content, streaming }: ThinkingBlockProps) {
|
||||
const summary = deriveThinkingSummary(content);
|
||||
|
||||
return (
|
||||
<details className={styles.block}>
|
||||
<details className={`thinking-block ${styles.block}`}>
|
||||
<summary className={styles.summary}>
|
||||
<span className={styles.summaryLabel}>思考</span>
|
||||
<span className={styles.summaryText}>{summary}</span>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
<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 =
|
||||
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 (
|
||||
<details
|
||||
className={`${styles.block} ${styles[status]}`}
|
||||
open={forceOpen || status === "pending"}
|
||||
>
|
||||
<details className={`tool-call-block ${styles.block} ${styles[status]}`} open={forceOpen}>
|
||||
<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>
|
||||
</summary>
|
||||
{(body || content !== title) && (
|
||||
{hasDetail ? (
|
||||
<div className={styles.detail}>
|
||||
{title.includes("(") ? <div className={styles.args}>{title}</div> : null}
|
||||
{hasDiff ? (
|
||||
<pre
|
||||
className={styles.detailPre}
|
||||
dangerouslySetInnerHTML={{ __html: formatToolBodyHtml(body || content) }}
|
||||
/>
|
||||
) : (
|
||||
<pre className={styles.detailPre}>{body || content}</pre>
|
||||
)}
|
||||
<ToolBody body={body} fallback={content} hasDiff={hasDiff} status={status} />
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</details>
|
||||
);
|
||||
}
|
||||
});
|
||||
114
frontend/src/components/chat/agentSegment.module.css
Normal file
114
frontend/src/components/chat/agentSegment.module.css
Normal 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;
|
||||
}
|
||||
@@ -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<string | 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 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<ChatMessage[]>) => {
|
||||
setRawMessages((prev) => {
|
||||
const resolved = typeof next === "function" ? next(prev) : next;
|
||||
return normalizeChatMessages(resolved);
|
||||
});
|
||||
}, []);
|
||||
const setMessages = useCallback(
|
||||
(next: SetStateAction<ChatMessage[]>, 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<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,
|
||||
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 <ChatContext.Provider value={value}>{children}</ChatContext.Provider>;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
</span>
|
||||
</label>
|
||||
<p className={styles.fieldHint}>
|
||||
开启后在对话中显示 AI 的思考过程和工具调用详情,默认关闭。
|
||||
开启后在对话中显示 AI 的思考过程和工具调用;思考内容默认折叠,点击可展开。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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 !== "")
|
||||
|
||||
22
frontend/src/styles/markdown-body.css
Normal file
22
frontend/src/styles/markdown-body.css
Normal 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;
|
||||
}
|
||||
@@ -258,6 +258,7 @@ export interface EnvironmentInfo {
|
||||
repoRoot?: string;
|
||||
cwd?: string;
|
||||
piCmd?: string;
|
||||
sproutclawCmd?: string;
|
||||
// Agent 工具
|
||||
agentTools?: AgentToolInfo[];
|
||||
// 兼容旧字段
|
||||
|
||||
@@ -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 `<pre class="assistant-pre"><code class="${codeClass}">${innerHtml}</code></pre>\n`;
|
||||
return `<pre><code class="${codeClass}">${innerHtml}</code></pre>\n`;
|
||||
}
|
||||
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
18
run-backend.sh
Executable file
18
run-backend.sh
Executable 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"
|
||||
Reference in New Issue
Block a user