Просмотр исходного кода

update: add knowledge to frontend

guantao 4 дней назад
Родитель
Сommit
dc145f35a1

+ 3 - 1
.claude/settings.local.json

@@ -11,7 +11,9 @@
       "Bash(timeout 60 python:*)",
       "Bash(timeout 240 python:*)",
       "Bash(curl:*)",
-      "Bash(tree:*)"
+      "Bash(tree:*)",
+      "Bash(xargs grep:*)",
+      "Bash(npm run:*)"
     ],
     "deny": [],
     "ask": []

+ 144 - 0
agent/trace/examples_api.py

@@ -0,0 +1,144 @@
+"""
+Examples API - 提供 examples 项目列表和 prompt 读取接口
+"""
+
+import os
+from typing import List, Optional
+from pathlib import Path
+from fastapi import APIRouter, HTTPException
+from pydantic import BaseModel
+
+
+router = APIRouter(prefix="/api/examples", tags=["examples"])
+
+
+class ExampleProject(BaseModel):
+    """Example 项目信息"""
+    name: str
+    path: str
+    has_prompt: bool
+
+
+class ExampleListResponse(BaseModel):
+    """Example 列表响应"""
+    projects: List[ExampleProject]
+
+
+class PromptResponse(BaseModel):
+    """Prompt 响应"""
+    system_prompt: str
+    user_prompt: str
+    model: Optional[str] = None
+    temperature: Optional[float] = None
+
+
+# 配置 examples 目录路径
+EXAMPLES_DIR = Path("examples")
+
+
+@router.get("", response_model=ExampleListResponse)
+async def list_examples():
+    """
+    列出所有 example 项目
+
+    扫描 examples 目录,返回所有子目录及其 prompt 文件状态
+    """
+    if not EXAMPLES_DIR.exists():
+        return ExampleListResponse(projects=[])
+
+    projects = []
+    for item in EXAMPLES_DIR.iterdir():
+        if item.is_dir():
+            # 检查是否有 prompt 文件
+            prompt_file = item / "production.prompt"
+            has_prompt = prompt_file.exists()
+
+            projects.append(ExampleProject(
+                name=item.name,
+                path=str(item),
+                has_prompt=has_prompt
+            ))
+
+    # 按名称排序
+    projects.sort(key=lambda x: x.name)
+
+    return ExampleListResponse(projects=projects)
+
+
+@router.get("/{project_name}/prompt", response_model=PromptResponse)
+async def get_example_prompt(project_name: str):
+    """
+    获取指定 example 项目的 prompt
+
+    读取 production.prompt 文件,解析 frontmatter 和内容
+    """
+    project_path = EXAMPLES_DIR / project_name
+    if not project_path.exists() or not project_path.is_dir():
+        raise HTTPException(status_code=404, detail=f"Project not found: {project_name}")
+
+    prompt_file = project_path / "production.prompt"
+    if not prompt_file.exists():
+        raise HTTPException(status_code=404, detail=f"Prompt file not found for project: {project_name}")
+
+    try:
+        content = prompt_file.read_text(encoding="utf-8")
+
+        # 解析 frontmatter 和内容
+        system_prompt = ""
+        user_prompt = ""
+        model = None
+        temperature = None
+
+        # 检查是否有 frontmatter
+        if content.startswith("---"):
+            parts = content.split("---", 2)
+            if len(parts) >= 3:
+                frontmatter = parts[1].strip()
+                body = parts[2].strip()
+
+                # 解析 frontmatter
+                for line in frontmatter.split("\n"):
+                    if ":" in line:
+                        key, value = line.split(":", 1)
+                        key = key.strip()
+                        value = value.strip()
+                        if key == "model":
+                            model = value
+                        elif key == "temperature":
+                            try:
+                                temperature = float(value)
+                            except ValueError:
+                                pass
+            else:
+                body = content
+        else:
+            body = content
+
+        # 解析 $system$ 和 $user$ 部分
+        if "$system$" in body:
+            parts = body.split("$system$", 1)
+            if len(parts) > 1:
+                rest = parts[1]
+                if "$user$" in rest:
+                    system_part, user_part = rest.split("$user$", 1)
+                    system_prompt = system_part.strip()
+                    user_prompt = user_part.strip()
+                else:
+                    system_prompt = rest.strip()
+        elif "$user$" in body:
+            parts = body.split("$user$", 1)
+            if len(parts) > 1:
+                user_prompt = parts[1].strip()
+        else:
+            # 没有标记,全部作为 user_prompt
+            user_prompt = body.strip()
+
+        return PromptResponse(
+            system_prompt=system_prompt,
+            user_prompt=user_prompt,
+            model=model,
+            temperature=temperature
+        )
+
+    except Exception as e:
+        raise HTTPException(status_code=500, detail=f"Failed to read prompt file: {str(e)}")

+ 102 - 0
agent/trace/logs_websocket.py

@@ -0,0 +1,102 @@
+"""
+Logs WebSocket - 实时推送后端日志到前端
+"""
+
+import asyncio
+import logging
+from typing import Set
+from fastapi import APIRouter, WebSocket, WebSocketDisconnect
+from datetime import datetime
+
+
+router = APIRouter(prefix="/api/logs", tags=["logs"])
+
+
+# 存储所有连接的WebSocket客户端
+_clients: Set[WebSocket] = set()
+
+
+class WebSocketLogHandler(logging.Handler):
+    """自定义日志处理器,将日志推送到WebSocket客户端"""
+
+    def emit(self, record: logging.LogRecord):
+        """发送日志记录到所有连接的客户端"""
+        try:
+            log_entry = self.format(record)
+            # 构造日志消息
+            message = {
+                "timestamp": datetime.now().isoformat(),
+                "level": record.levelname,
+                "name": record.name,
+                "message": log_entry,
+            }
+            # 异步发送到所有客户端
+            asyncio.create_task(broadcast_log(message))
+        except Exception:
+            self.handleError(record)
+
+
+async def broadcast_log(message: dict):
+    """广播日志消息到所有连接的客户端"""
+    disconnected = set()
+    for client in _clients:
+        try:
+            await client.send_json(message)
+        except Exception:
+            disconnected.add(client)
+
+    # 移除断开连接的客户端
+    for client in disconnected:
+        _clients.discard(client)
+
+
+@router.websocket("/watch")
+async def logs_websocket(websocket: WebSocket):
+    """
+    日志WebSocket端点
+
+    客户端连接后,实时接收后端日志
+    """
+    await websocket.accept()
+    _clients.add(websocket)
+
+    try:
+        # 发送欢迎消息
+        await websocket.send_json({
+            "timestamp": datetime.now().isoformat(),
+            "level": "INFO",
+            "name": "logs_websocket",
+            "message": "Connected to logs stream",
+        })
+
+        # 保持连接,等待客户端断开
+        while True:
+            # 接收客户端消息(用于保持连接)
+            await websocket.receive_text()
+    except WebSocketDisconnect:
+        pass
+    finally:
+        _clients.discard(websocket)
+
+
+def setup_websocket_logging(level=logging.INFO):
+    """
+    设置WebSocket日志处理器
+
+    将根日志器的日志推送到WebSocket客户端
+    """
+    handler = WebSocketLogHandler()
+    handler.setLevel(level)
+
+    # 设置日志格式
+    formatter = logging.Formatter(
+        "%(asctime)s [%(levelname)s] %(name)s: %(message)s",
+        datefmt="%Y-%m-%d %H:%M:%S"
+    )
+    handler.setFormatter(formatter)
+
+    # 添加到根日志器
+    root_logger = logging.getLogger()
+    root_logger.addHandler(handler)
+
+    return handler

+ 11 - 0
api_server.py

@@ -19,6 +19,8 @@ from agent.trace import FileSystemTraceStore
 from agent.trace.api import router as api_router, set_trace_store as set_api_trace_store
 from agent.trace.run_api import router as run_router, experiences_router, set_runner
 from agent.trace.websocket import router as ws_router, set_trace_store as set_ws_trace_store
+from agent.trace.examples_api import router as examples_router
+from agent.trace.logs_websocket import router as logs_router, setup_websocket_logging
 
 
 # ===== 日志配置 =====
@@ -29,6 +31,9 @@ logging.basicConfig(
 )
 logger = logging.getLogger(__name__)
 
+# 设置WebSocket日志推送
+setup_websocket_logging(level=logging.INFO)
+
 
 # ===== FastAPI 应用 =====
 
@@ -75,6 +80,9 @@ set_runner(runner)
 
 # ===== 注册路由 =====
 
+# Examples API(GET /api/examples)
+app.include_router(examples_router)
+
 # Trace 执行 API(POST + GET /running,需配置 Runner)
 # 注意:run_router 必须在 api_router 之前注册,否则 GET /running 会被 /{trace_id} 捕获
 app.include_router(run_router)
@@ -88,6 +96,9 @@ app.include_router(api_router)
 # Trace WebSocket(实时推送)
 app.include_router(ws_router)
 
+# Logs WebSocket(日志推送)
+app.include_router(logs_router)
+
 @app.websocket("/ws_ping")
 async def ws_ping(websocket: WebSocket):
     await websocket.accept()

BIN
frontend/images/image.png


+ 16 - 0
frontend/react-template/src/App.tsx

@@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
 import { TopBar } from "./components/TopBar/TopBar";
 import { MainContent } from "./components/MainContent/MainContent";
 import { DetailPanel } from "./components/DetailPanel/DetailPanel";
+import { Terminal } from "./components/Terminal/Terminal";
 import type { Goal } from "./types/goal";
 import type { Edge, Message } from "./types/message";
 import { useFlowChartData } from "./components/FlowChart/hooks/useFlowChartData";
@@ -14,6 +15,7 @@ function App() {
   const [selectedEdge, setSelectedEdge] = useState<Edge | null>(null);
   const [rightWidth, setRightWidth] = useState(360);
   const [isDragging, setIsDragging] = useState(false);
+  const [showTerminal, setShowTerminal] = useState(false);
   const [refreshTrigger, setRefreshTrigger] = useState(0);
   const [messageRefreshTrigger, setMessageRefreshTrigger] = useState(0);
   const bodyRef = useRef<HTMLDivElement | null>(null);
@@ -126,6 +128,20 @@ function App() {
           </>
         )}
       </div>
+      {showTerminal && (
+        <div className="app-terminal-float">
+          <Terminal onClose={() => setShowTerminal(false)} />
+        </div>
+      )}
+      {!showTerminal && (
+        <button
+          className="app-terminal-toggle"
+          onClick={() => setShowTerminal(true)}
+          title="打开控制台"
+        >
+          ▶ 控制台
+        </button>
+      )}
     </div>
   );
 }

+ 11 - 0
frontend/react-template/src/api/traceApi.ts

@@ -53,4 +53,15 @@ export const traceApi = {
   getExperiences() {
     return request<string>("/api/experiences");
   },
+  fetchExamples() {
+    return request<{ projects: Array<{ name: string; path: string; has_prompt: boolean }> }>("/api/examples");
+  },
+  fetchExamplePrompt(projectName: string) {
+    return request<{
+      system_prompt: string;
+      user_prompt: string;
+      model?: string;
+      temperature?: number;
+    }>(`/api/examples/${projectName}/prompt`);
+  },
 };

+ 96 - 1
frontend/react-template/src/components/DetailPanel/DetailPanel.module.css

@@ -129,4 +129,99 @@
   padding-left: var(--space-md);
 }
 
-/* Removed old styles */
+.knowledgeList {
+  display: flex;
+  flex-direction: column;
+  gap: var(--space-md);
+  margin-top: var(--space-sm);
+}
+
+.knowledgeItem {
+  background: var(--bg-surface-hover);
+  border: 1px solid var(--border-light);
+  border-radius: var(--radius-lg);
+  padding: var(--space-md);
+  transition: all var(--transition-fast);
+}
+
+.knowledgeItem:hover {
+  border-color: var(--color-primary-light);
+  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
+}
+
+.knowledgeHeader {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: var(--space-sm);
+}
+
+.knowledgeId {
+  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+  font-size: 12px;
+  color: var(--text-tertiary);
+  font-weight: 500;
+}
+
+.knowledgeMetrics {
+  display: flex;
+  gap: var(--space-md);
+  font-size: 13px;
+}
+
+.metricScore {
+  color: #f59e0b; /* Amber 500 */
+  font-weight: 500;
+}
+
+.metricQuality {
+  color: #8b5cf6; /* Purple 500 */
+  font-weight: 500;
+}
+
+.metricHelpful {
+  color: #10b981; /* Emerald 500 */
+  font-weight: 500;
+}
+
+.metricHarmful {
+  color: #ef4444; /* Red 500 */
+  font-weight: 500;
+}
+
+.knowledgeTags {
+  display: flex;
+  flex-wrap: wrap;
+  gap: var(--space-xs);
+  margin-bottom: var(--space-sm);
+}
+
+.tag {
+  font-size: 11px;
+  font-weight: 600;
+  text-transform: uppercase;
+  padding: 2px 8px;
+  background: var(--color-primary-light);
+  color: var(--color-primary);
+  border-radius: var(--radius-full);
+  letter-spacing: 0.02em;
+}
+
+.knowledgeScenario {
+  font-size: 13px;
+  color: var(--text-secondary);
+  line-height: 1.5;
+  margin-bottom: var(--space-sm);
+  padding-bottom: var(--space-sm);
+  border-bottom: 1px dashed var(--border-light);
+}
+
+.knowledgeContent {
+  font-size: 14px;
+  color: var(--text-primary);
+  line-height: 1.6;
+}
+
+.knowledgeContent p {
+  margin: 0;
+}

+ 114 - 21
frontend/react-template/src/components/DetailPanel/DetailPanel.tsx

@@ -47,6 +47,57 @@ export const DetailPanel = ({ node, edge, messages = [], onClose }: DetailPanelP
   const isMessageNode = (node: Goal | Message): node is Message =>
     "message_id" in node || "role" in node || "content" in node || "goal_id" in node || "tokens" in node;
 
+  const renderKnowledge = (knowledge: Goal["knowledge"]) => {
+    if (!knowledge || knowledge.length === 0) return null;
+
+    return (
+      <div className={styles.knowledgeList}>
+        {knowledge.map((item) => (
+          <div
+            key={item.id}
+            className={styles.knowledgeItem}
+          >
+            <div className={styles.knowledgeHeader}>
+              <span className={styles.knowledgeId}>{item.id}</span>
+              <div className={styles.knowledgeMetrics}>
+                {item.score !== undefined && (
+                  <span className={styles.metricScore}>⭐ {item.score}</span>
+                )}
+                {item.quality_score !== undefined && (
+                  <span className={styles.metricQuality}>✨ {item.quality_score.toFixed(1)}</span>
+                )}
+                {item.metrics?.helpful !== undefined && (
+                  <span className={styles.metricHelpful}>👍 {item.metrics.helpful}</span>
+                )}
+                {item.metrics?.harmful !== undefined && (
+                  <span className={styles.metricHarmful}>👎 {item.metrics.harmful}</span>
+                )}
+              </div>
+            </div>
+            {item.tags?.type && item.tags.type.length > 0 && (
+              <div className={styles.knowledgeTags}>
+                {item.tags.type.map((tag) => (
+                  <span
+                    key={tag}
+                    className={styles.tag}
+                  >
+                    {tag}
+                  </span>
+                ))}
+              </div>
+            )}
+            <div className={styles.knowledgeScenario}>
+              <strong>场景:</strong> {item.scenario}
+            </div>
+            <div className={styles.knowledgeContent}>
+              <ReactMarkdown>{item.content}</ReactMarkdown>
+            </div>
+          </div>
+        ))}
+      </div>
+    );
+  };
+
   return (
     <aside className={styles.panel}>
       <div className={styles.header}>
@@ -67,27 +118,69 @@ export const DetailPanel = ({ node, edge, messages = [], onClose }: DetailPanelP
               <div className={styles.label}>ID</div>
               <div className={styles.value}>{isMessageNode(node) ? node.message_id || node.id : node.id}</div>
             </div>
-            <div className={styles.section}>
-              <div className={styles.label}>目标描述</div>
-              <div className={styles.value}>{node.description}</div>
-            </div>
-            {isGoal(node) && node.reason && (
-              <div className={styles.section}>
-                <div className={styles.label}>创建理由</div>
-                <div className={styles.value}>{node.reason}</div>
-              </div>
-            )}
-            {isGoal(node) && node.summary && (
-              <div className={styles.section}>
-                <div className={styles.label}>总结</div>
-                <div className={styles.value}>{node.summary}</div>
-              </div>
-            )}
-            {isGoal(node) && (
-              <div className={styles.section}>
-                <div className={styles.label}>状态</div>
-                <div className={styles.value}>{node.status}</div>
-              </div>
+
+            {isGoal(node) ? (
+              <>
+                <div className={styles.section}>
+                  <div className={styles.label}>目标描述</div>
+                  <div className={styles.value}>{node.description}</div>
+                </div>
+                {node.reason && (
+                  <div className={styles.section}>
+                    <div className={styles.label}>创建理由</div>
+                    <div className={styles.value}>{node.reason}</div>
+                  </div>
+                )}
+                {node.summary && (
+                  <div className={styles.section}>
+                    <div className={styles.label}>总结</div>
+                    <div className={styles.value}>{node.summary}</div>
+                  </div>
+                )}
+                <div className={styles.section}>
+                  <div className={styles.label}>状态</div>
+                  <div className={styles.value}>{node.status}</div>
+                </div>
+                {node.knowledge && node.knowledge.length > 0 && (
+                  <div className={styles.section}>
+                    <div className={styles.sectionTitle}>相关知识</div>
+                    {renderKnowledge(node.knowledge)}
+                  </div>
+                )}
+              </>
+            ) : (
+              <>
+                {node.description && (
+                  <div className={styles.section}>
+                    <div className={styles.label}>描述</div>
+                    <div className={styles.value}>{node.description}</div>
+                  </div>
+                )}
+                {node.role && (
+                  <div className={styles.section}>
+                    <div className={styles.label}>角色</div>
+                    <div className={styles.value}>{node.role}</div>
+                  </div>
+                )}
+                {node.content && (
+                  <div className={styles.section}>
+                    <div className={styles.label}>内容</div>
+                    <div className={styles.value}>{renderMessageContent(node.content)}</div>
+                  </div>
+                )}
+                {node.goal_id && (
+                  <div className={styles.section}>
+                    <div className={styles.label}>所属目标</div>
+                    <div className={styles.value}>{node.goal_id}</div>
+                  </div>
+                )}
+                {node.tokens !== undefined && node.tokens !== null && (
+                  <div className={styles.section}>
+                    <div className={styles.label}>Token数</div>
+                    <div className={styles.value}>{node.tokens}</div>
+                  </div>
+                )}
+              </>
             )}
           </>
         )}

+ 3 - 0
frontend/react-template/src/components/FlowChart/hooks/useFlowChartData.ts

@@ -139,6 +139,9 @@ export const useFlowChartData = (traceId: string | null, refreshTrigger?: number
               if (existing.agent_call_mode && !merged.agent_call_mode) {
                 merged.agent_call_mode = existing.agent_call_mode;
               }
+              if (existing.knowledge && !merged.knowledge) {
+                merged.knowledge = existing.knowledge;
+              }
               return merged;
             });
             return buildSubGoals(mergedFlat);

+ 159 - 0
frontend/react-template/src/components/Terminal/Terminal.module.css

@@ -0,0 +1,159 @@
+.terminal {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+  background: #1e1e1e;
+  color: #d4d4d4;
+  font-family: "Consolas", "Monaco", "Courier New", monospace;
+  font-size: 13px;
+}
+
+.header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 8px 12px;
+  background: #2d2d2d;
+  border-bottom: 1px solid #3e3e3e;
+  flex-shrink: 0;
+}
+
+.title {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  font-weight: 600;
+  color: #cccccc;
+}
+
+.icon {
+  color: #4ec9b0;
+}
+
+.status {
+  font-size: 10px;
+  margin-left: 4px;
+}
+
+.connected {
+  color: #4ec9b0;
+}
+
+.disconnected {
+  color: #f48771;
+}
+
+.actions {
+  display: flex;
+  gap: 8px;
+}
+
+.button {
+  padding: 4px 12px;
+  background: #3e3e3e;
+  border: 1px solid #555;
+  color: #cccccc;
+  border-radius: 4px;
+  cursor: pointer;
+  font-size: 12px;
+  transition: all 0.2s;
+}
+
+.button:hover {
+  background: #505050;
+  border-color: #666;
+}
+
+.button.active {
+  background: #0e639c;
+  border-color: #1177bb;
+}
+
+.closeButton {
+  padding: 2px 8px;
+  background: transparent;
+  border: none;
+  color: #cccccc;
+  font-size: 20px;
+  cursor: pointer;
+  transition: color 0.2s;
+}
+
+.closeButton:hover {
+  color: #f48771;
+}
+
+.content {
+  flex: 1;
+  overflow-y: auto;
+  padding: 8px;
+  line-height: 1.5;
+}
+
+.empty {
+  color: #858585;
+  text-align: center;
+  padding: 20px;
+}
+
+.logEntry {
+  display: flex;
+  gap: 8px;
+  padding: 2px 0;
+  word-break: break-word;
+}
+
+.timestamp {
+  color: #858585;
+  flex-shrink: 0;
+}
+
+.level {
+  font-weight: 600;
+  flex-shrink: 0;
+  min-width: 60px;
+}
+
+.name {
+  color: #4ec9b0;
+  flex-shrink: 0;
+}
+
+.message {
+  color: #d4d4d4;
+  flex: 1;
+}
+
+.logEntry.error .level {
+  color: #f48771;
+}
+
+.logEntry.warning .level {
+  color: #dcdcaa;
+}
+
+.logEntry.info .level {
+  color: #4fc1ff;
+}
+
+.logEntry.debug .level {
+  color: #b5cea8;
+}
+
+/* 滚动条样式 */
+.content::-webkit-scrollbar {
+  width: 10px;
+}
+
+.content::-webkit-scrollbar-track {
+  background: #1e1e1e;
+}
+
+.content::-webkit-scrollbar-thumb {
+  background: #424242;
+  border-radius: 5px;
+}
+
+.content::-webkit-scrollbar-thumb:hover {
+  background: #4e4e4e;
+}

+ 138 - 0
frontend/react-template/src/components/Terminal/Terminal.tsx

@@ -0,0 +1,138 @@
+import { useEffect, useRef, useState } from "react";
+import type { FC } from "react";
+import styles from "./Terminal.module.css";
+
+interface LogEntry {
+  timestamp: string;
+  level: string;
+  name: string;
+  message: string;
+}
+
+interface TerminalProps {
+  onClose: () => void;
+}
+
+export const Terminal: FC<TerminalProps> = ({ onClose }) => {
+  const [logs, setLogs] = useState<LogEntry[]>([]);
+  const [isConnected, setIsConnected] = useState(false);
+  const wsRef = useRef<WebSocket | null>(null);
+  const logsEndRef = useRef<HTMLDivElement>(null);
+  const [autoScroll, setAutoScroll] = useState(true);
+
+  useEffect(() => {
+    // 连接WebSocket
+    const ws = new WebSocket("ws://localhost:8000/api/logs/watch");
+    wsRef.current = ws;
+
+    ws.onopen = () => {
+      setIsConnected(true);
+      console.log("Terminal WebSocket connected");
+    };
+
+    ws.onmessage = (event) => {
+      try {
+        const logEntry: LogEntry = JSON.parse(event.data);
+        setLogs((prev) => [...prev, logEntry]);
+      } catch (error) {
+        console.error("Failed to parse log entry:", error);
+      }
+    };
+
+    ws.onerror = (error) => {
+      console.error("Terminal WebSocket error:", error);
+      setIsConnected(false);
+    };
+
+    ws.onclose = () => {
+      setIsConnected(false);
+      console.log("Terminal WebSocket disconnected");
+    };
+
+    return () => {
+      ws.close();
+    };
+  }, []);
+
+  useEffect(() => {
+    if (autoScroll && logsEndRef.current) {
+      logsEndRef.current.scrollIntoView({ behavior: "smooth" });
+    }
+  }, [logs, autoScroll]);
+
+  const handleClear = () => {
+    setLogs([]);
+  };
+
+  const getLevelColor = (level: string) => {
+    switch (level) {
+      case "ERROR":
+        return styles.error;
+      case "WARNING":
+        return styles.warning;
+      case "INFO":
+        return styles.info;
+      case "DEBUG":
+        return styles.debug;
+      default:
+        return "";
+    }
+  };
+
+  return (
+    <div className={styles.terminal}>
+      <div className={styles.header}>
+        <div className={styles.title}>
+          <span className={styles.icon}>▶</span>
+          <span>控制台输出</span>
+          <span className={`${styles.status} ${isConnected ? styles.connected : styles.disconnected}`}>
+            {isConnected ? "●" : "○"}
+          </span>
+        </div>
+        <div className={styles.actions}>
+          <button
+            className={styles.button}
+            onClick={handleClear}
+            title="清空日志"
+          >
+            清空
+          </button>
+          <button
+            className={`${styles.button} ${autoScroll ? styles.active : ""}`}
+            onClick={() => setAutoScroll(!autoScroll)}
+            title="自动滚动"
+          >
+            {autoScroll ? "🔒" : "🔓"}
+          </button>
+          <button
+            className={styles.closeButton}
+            onClick={onClose}
+            title="关闭"
+          >
+            ×
+          </button>
+        </div>
+      </div>
+      <div className={styles.content}>
+        {logs.length === 0 ? (
+          <div className={styles.empty}>等待日志输出...</div>
+        ) : (
+          logs.map((log, index) => (
+            <div
+              key={index}
+              className={`${styles.logEntry} ${getLevelColor(log.level)}`}
+            >
+              <span className={styles.timestamp}>
+                {new Date(log.timestamp).toLocaleTimeString()}
+              </span>
+              <span className={styles.level}>[{log.level}]</span>
+              <span className={styles.name}>{log.name}:</span>
+              <span className={styles.message}>{log.message}</span>
+            </div>
+          ))
+        )}
+        <div ref={logsEndRef} />
+      </div>
+    </div>
+  );
+};

+ 43 - 2
frontend/react-template/src/components/TopBar/TopBar.tsx

@@ -29,7 +29,8 @@ export const TopBar: FC<TopBarProps> = ({
   const [isReflectModalVisible, setIsReflectModalVisible] = useState(false);
   const [isExperienceModalVisible, setIsExperienceModalVisible] = useState(false);
   const [experienceContent, setExperienceContent] = useState("");
-  const formApiRef = useRef<{ getValues: () => { system_prompt: string; user_prompt: string } } | null>(null);
+  const [exampleProjects, setExampleProjects] = useState<Array<{ name: string; path: string; has_prompt: boolean }>>([]);
+  const formApiRef = useRef<any>(null);
   const insertFormApiRef = useRef<{ getValues: () => { insert_prompt: string } } | null>(null);
   const reflectFormApiRef = useRef<{ getValues: () => { reflect_focus: string } } | null>(null);
 
@@ -68,8 +69,31 @@ export const TopBar: FC<TopBarProps> = ({
     loadTraces();
   }, [loadTraces]);
 
-  const handleNewTask = () => {
+  const handleNewTask = async () => {
     setIsModalVisible(true);
+    // 加载 example 项目列表
+    try {
+      const data = await traceApi.fetchExamples();
+      setExampleProjects(data.projects.filter(p => p.has_prompt));
+    } catch (error) {
+      console.error("Failed to load examples:", error);
+    }
+  };
+
+  const handleExampleChange = async (value: string) => {
+    if (!value) return;
+
+    try {
+      const promptData = await traceApi.fetchExamplePrompt(value);
+      // 自动填充表单
+      if (formApiRef.current) {
+        formApiRef.current.setValue("system_prompt", promptData.system_prompt);
+        formApiRef.current.setValue("user_prompt", promptData.user_prompt);
+      }
+    } catch (error) {
+      console.error("Failed to load example prompt:", error);
+      Toast.error("加载示例失败");
+    }
   };
 
   const handleConfirm = async () => {
@@ -270,6 +294,23 @@ export const TopBar: FC<TopBarProps> = ({
       >
         {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
         <Form getFormApi={(api: any) => (formApiRef.current = api)}>
+          <Form.Select
+            field="example_project"
+            label="选择示例项目(可选)"
+            placeholder="选择一个示例项目自动填充"
+            style={{ width: "100%" }}
+            onChange={handleExampleChange}
+            showClear
+          >
+            {exampleProjects.map((project) => (
+              <Form.Select.Option
+                key={project.name}
+                value={project.name}
+              >
+                {project.name}
+              </Form.Select.Option>
+            ))}
+          </Form.Select>
           <Form.TextArea
             field="system_prompt"
             label="System Prompt"

+ 39 - 0
frontend/react-template/src/styles/global.css

@@ -82,3 +82,42 @@ body {
   background: linear-gradient(to right, transparent, #d0d7de, transparent);
   flex-shrink: 0;
 }
+
+.app-terminal-float {
+  position: fixed;
+  bottom: 20px;
+  left: 20px;
+  right: 20px;
+  height: 300px;
+  z-index: 1000;
+  border-radius: 8px;
+  overflow: hidden;
+  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
+  resize: vertical;
+  min-height: 150px;
+  max-height: 80vh;
+}
+
+.app-terminal-toggle {
+  position: fixed;
+  bottom: 20px;
+  right: 20px;
+  padding: 10px 16px;
+  background: #2d2d2d;
+  color: #4ec9b0;
+  border: 1px solid #3e3e3e;
+  border-radius: 6px;
+  cursor: pointer;
+  font-size: 13px;
+  font-weight: 600;
+  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
+  transition: all 0.2s;
+  z-index: 100;
+  font-family: "Consolas", "Monaco", "Courier New", monospace;
+}
+
+.app-terminal-toggle:hover {
+  background: #3e3e3e;
+  transform: translateY(-2px);
+  box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4);
+}

+ 17 - 0
frontend/react-template/src/types/goal.ts

@@ -5,6 +5,22 @@ export interface GoalStats {
   preview?: string;
 }
 
+export interface Knowledge {
+  id: string;
+  scenario: string;
+  content: string;
+  score?: number;
+  quality_score?: number;
+  tags?: {
+    type?: string[];
+  };
+  metrics?: {
+    helpful?: number;
+    harmful?: number;
+  };
+  created_at?: string;
+}
+
 export interface Goal {
   id: string;
   description: string;
@@ -20,6 +36,7 @@ export interface Goal {
   sub_goals?: Array<Goal>;
   self_stats?: GoalStats;
   cumulative_stats?: GoalStats;
+  knowledge?: Knowledge[];
 }
 
 export interface BranchContext {