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
|
||||||
backend/sproutclaw-web.exe
|
backend/sproutclaw-web.exe
|
||||||
|
backend/dist/
|
||||||
backend/data/
|
backend/data/
|
||||||
frontend/dist/
|
frontend/dist/
|
||||||
frontend/dist-desktop/
|
frontend/dist-desktop/
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 ""
|
||||||
|
|||||||
@@ -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()})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
2
build.sh
2
build.sh
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
@@ -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>;
|
||||||
}
|
});
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
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 [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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 !== "")
|
||||||
|
|||||||
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;
|
repoRoot?: string;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
piCmd?: string;
|
piCmd?: string;
|
||||||
|
sproutclawCmd?: string;
|
||||||
// Agent 工具
|
// Agent 工具
|
||||||
agentTools?: AgentToolInfo[];
|
agentTools?: AgentToolInfo[];
|
||||||
// 兼容旧字段
|
// 兼容旧字段
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
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