Compare commits
2 Commits
9e279a0627
...
122ab6ef3e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
122ab6ef3e | ||
|
|
b37e66b9a5 |
@ -9,7 +9,10 @@
|
|||||||
"Bash(npm install:*)",
|
"Bash(npm install:*)",
|
||||||
"Bash(node:*)",
|
"Bash(node:*)",
|
||||||
"Bash(npm run:*)",
|
"Bash(npm run:*)",
|
||||||
"Bash(curl:*)"
|
"Bash(curl:*)",
|
||||||
|
"Bash(cat:*)",
|
||||||
|
"Bash(grep:*)",
|
||||||
|
"Bash(echo DEEPSEEK_API_KEY=$DEEPSEEK_API_KEY:*)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
38
agents/legal-team/TEAM.md
Normal file
38
agents/legal-team/TEAM.md
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
---
|
||||||
|
name: 法律咨询团队
|
||||||
|
description: 专业法律咨询团队,包含合同审查、法律风险评估和合规建议
|
||||||
|
author: Agent Team
|
||||||
|
version: 1.0.0
|
||||||
|
repo_url: https://gitea.catter.cn/agent-teams/legal-team.git
|
||||||
|
agents:
|
||||||
|
- 法律总监
|
||||||
|
- 合同律师
|
||||||
|
- 合规专员
|
||||||
|
skills:
|
||||||
|
- 合同审查
|
||||||
|
- 法律知识库
|
||||||
|
---
|
||||||
|
|
||||||
|
# 法律咨询团队
|
||||||
|
|
||||||
|
专业法律咨询团队,提供全方位的法律服务支持。
|
||||||
|
|
||||||
|
## 团队成员
|
||||||
|
|
||||||
|
- **法律总监**:团队负责人,协调各项法律事务,提供战略级法律建议
|
||||||
|
- **合同律师**:专注合同审查、起草和风险评估
|
||||||
|
- **合规专员**:负责合规检查、风险识别和合规建议
|
||||||
|
|
||||||
|
## 核心能力
|
||||||
|
|
||||||
|
- 合同审查与风险评估
|
||||||
|
- 法律咨询与建议
|
||||||
|
- 合规检查与指导
|
||||||
|
- 法律文档起草
|
||||||
|
|
||||||
|
## 使用场景
|
||||||
|
|
||||||
|
- 企业合同审查
|
||||||
|
- 法律风险评估
|
||||||
|
- 合规体系建设
|
||||||
|
- 法律咨询问答
|
||||||
16
agents/legal-team/法律总监/memory/2026-03.md
Normal file
16
agents/legal-team/法律总监/memory/2026-03.md
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
|
||||||
|
## 2026-03-05 — ä½ å¥½
|
||||||
|
|
||||||
|
ASSIGN:å<>ˆå<CB86>Œå¾‹å¸ˆ:分æž<C3A6>用户"第五å£"çš„å·¥ä½œé£Žæ ¼å’Œæ²Ÿé€šå<C5A1><C3A5>好,整ç<C2B4>†æˆ<C3A6>简æ´<C3A6>è¦<C3A8>点,为å<C2BA>Žç»æ³•律咨询æ<C2A2><C3A6>ä¾›å<E280BA>‚考框架。
|
||||||
|
|
||||||
|
ASSIGN:å<>ˆè§„专员:评估用户简介ä¸å<C2AD>¯èƒ½æ¶‰å<E280B0>Šçš„æ³•律å<E280B9>ˆè§„风险点(如沟通方å¼<C3A5>ã€<C3A3>æ•°æ<C2B0>®ä½¿ç”¨ç‰ï¼‰ï¼Œæ<C592><C3A6>出åˆ<C3A5>æ¥æ³¨æ„<C3A6>事项。
|
||||||
|
|
||||||
|
## 2026-03-05 — 我一个ä¸é—´äººï¼Œè¯´å<C2B4>¯ä»¥ç»™æˆ‘介ç»<C3A7>一个è¶
|
||||||
|
|
||||||
|
基于本次咨询,总结关键è¦<EFBFBD>点如下:
|
||||||
|
|
||||||
|
- **æ<>ƒå±žæ ¸æŸ¥æ˜¯åŸºçŸ³**:投资任何实体项目å‰<C3A5>,**å¿…é¡»**é¦–å…ˆå®¡æŸ¥å¹¶ç¡®è®¤æ ¸å¿ƒèµ„äº§ï¼ˆå¦‚åœŸåœ°ã€<C3A3>场地)的完整ã€<C3A3>å<EFBFBD>ˆæ³•æ<E280A2>ƒå±žé“¾æ<C2BE>¡å<C2A1>Šä½¿ç”¨è®¸å<C2B8>¯ï¼Œæ<C592>ƒå±žä¸<C3A4>æ¸…æ˜¯æ ¹æœ¬æ€§é£Žé™©ã€‚
|
||||||
|
- **ä¸é—´äººæ¨¡å¼<C3A5>风险高**ï¼šé€šè¿‡æ— æ˜Žç¡®æŽˆæ<CB86>ƒçš„“ä¸é—´äººâ€<C3A2>进行投资,会导致法律关系模糊ã€<C3A3>资金失控,且æž<C3A6>易与项目实际方脱节,应争å<E280B0>–与æ<C5BD>ƒå±žæ–¹å»ºç«‹ç›´æŽ¥å<C2A5>ˆå<CB86>Œå…³ç³»ã€‚
|
||||||
|
- **异常高回报是å<C2AF>±é™©ä¿¡å<C2A1>·**:远高于市场平å<C2B3>‡æ°´å¹³çš„回报承诺(如年化30%)通常伴éš<C3A9>æž<C3A6>高的è¿<C3A8>约风险或项目本身ä¸<C3A4>å<EFBFBD>¯è¡Œï¼Œéœ€æ·±ç©¶å…¶å•†ä¸šå<C5A1>ˆç<CB86>†æ€§ä¸Žæ³•律å<E280B9>ˆè§„性。
|
||||||
|
- **å<><C3A5>议性质必须明确**:“投资å<E2809E><C3A5>è®®â€<C3A2>的法律定性(借贷ã€<C3A3>å<EFBFBD>ˆä¼™æˆ–委托)直接决定您的æ<E2809E>ƒåˆ©ã€<C3A3>风险与责任,必须在æ<C2A8>¡æ¬¾ä¸æ¸…晰界定,é<C592>¿å…<C3A5>å<EFBFBD>Žç»äº‰è®®ã€‚
|
||||||
|
- **å<>ˆè§„红线ä¸<C3A4>å<EFBFBD>¯è§¦ç¢°**:涉å<E280B0>Šæ”¿åºœèµ„æº<C3A6>或资产的项目,若è¿<C3A8>作模å¼<C3A5>ä¾<C3A4>赖“关系â€<C3A2>而é<C592>žå…¬å¼€ç¨‹åº<C3A5>,æž<C3A6>易涉å<E280B0>Šå•†ä¸šè´¿èµ‚ã€<C3A3>è¿<C3A8>è§„èž<C3A8>资ç‰åˆ‘事风险,必须å<C2BB>šæŒ<C3A6>å<EFBFBD>ˆæ³•é€<C3A9>明路径。
|
||||||
@ -62,6 +62,7 @@ func (s *Server) routes() {
|
|||||||
g := s.e.Group("/api")
|
g := s.e.Group("/api")
|
||||||
g.GET("/rooms", s.listRooms)
|
g.GET("/rooms", s.listRooms)
|
||||||
g.POST("/rooms", s.createRoom)
|
g.POST("/rooms", s.createRoom)
|
||||||
|
g.PUT("/rooms/:id/team", s.setRoomTeam)
|
||||||
g.GET("/agents", s.listAgents)
|
g.GET("/agents", s.listAgents)
|
||||||
g.GET("/agents/:name/files/:file", s.readAgentFile)
|
g.GET("/agents/:name/files/:file", s.readAgentFile)
|
||||||
g.PUT("/agents/:name/files/:file", s.writeAgentFile)
|
g.PUT("/agents/:name/files/:file", s.writeAgentFile)
|
||||||
@ -196,10 +197,13 @@ func (s *Server) listRooms(c echo.Context) error {
|
|||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Status room.Status `json:"status"`
|
Status room.Status `json:"status"`
|
||||||
Master string `json:"master"`
|
Master string `json:"master"`
|
||||||
|
Members []string `json:"members"`
|
||||||
|
Color string `json:"color"`
|
||||||
|
Team string `json:"team"`
|
||||||
}
|
}
|
||||||
var list []roomInfo
|
var list []roomInfo
|
||||||
for id, r := range s.rooms {
|
for id, r := range s.rooms {
|
||||||
list = append(list, roomInfo{ID: id, Name: r.Config.Name, Type: string(r.Config.Type), Status: r.Status, Master: r.Config.Master})
|
list = append(list, roomInfo{ID: id, Name: r.Config.Name, Type: string(r.Config.Type), Status: r.Status, Master: r.Config.Master, Members: r.Config.Members, Color: r.Config.Color, Team: r.Config.Team})
|
||||||
}
|
}
|
||||||
return c.JSON(200, list)
|
return c.JSON(200, list)
|
||||||
}
|
}
|
||||||
@ -209,16 +213,14 @@ func (s *Server) createRoom(c echo.Context) error {
|
|||||||
if err := c.Bind(&cfg); err != nil {
|
if err := c.Bind(&cfg); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if cfg.Type == "" {
|
||||||
|
cfg.Type = room.TypeDept
|
||||||
|
}
|
||||||
dir := filepath.Join(s.roomsDir, cfg.Name)
|
dir := filepath.Join(s.roomsDir, cfg.Name)
|
||||||
os.MkdirAll(filepath.Join(dir, "workspace"), 0755)
|
os.MkdirAll(filepath.Join(dir, "workspace"), 0755)
|
||||||
os.MkdirAll(filepath.Join(dir, "history"), 0755)
|
os.MkdirAll(filepath.Join(dir, "history"), 0755)
|
||||||
|
|
||||||
content := "---\nname: " + cfg.Name + "\ntype: " + string(cfg.Type) + "\nmaster: " + cfg.Master + "\nmembers:\n"
|
s.writeRoomFile(dir, cfg)
|
||||||
for _, m := range cfg.Members {
|
|
||||||
content += " - " + m + "\n"
|
|
||||||
}
|
|
||||||
content += "---\n"
|
|
||||||
os.WriteFile(filepath.Join(dir, "room.md"), []byte(content), 0644)
|
|
||||||
|
|
||||||
r, err := room.Load(dir, s.agentsDir, s.skillsDir)
|
r, err := room.Load(dir, s.agentsDir, s.skillsDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -231,6 +233,76 @@ func (s *Server) createRoom(c echo.Context) error {
|
|||||||
return c.JSON(201, map[string]string{"id": cfg.Name})
|
return c.JSON(201, map[string]string{"id": cfg.Name})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) writeRoomFile(dir string, cfg room.Config) {
|
||||||
|
content := "---\nname: " + cfg.Name + "\ntype: " + string(cfg.Type) + "\nmaster: " + cfg.Master + "\ncolor: " + cfg.Color + "\nteam: " + cfg.Team + "\nmembers:\n"
|
||||||
|
for _, m := range cfg.Members {
|
||||||
|
content += " - " + m + "\n"
|
||||||
|
}
|
||||||
|
content += "---\n"
|
||||||
|
os.WriteFile(filepath.Join(dir, "room.md"), []byte(content), 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) setRoomTeam(c echo.Context) error {
|
||||||
|
id := c.Param("id")
|
||||||
|
var body struct {
|
||||||
|
Team string `json:"team"`
|
||||||
|
}
|
||||||
|
if err := c.Bind(&body); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load team agents
|
||||||
|
teamDir := filepath.Join(s.teamsDir, body.Team)
|
||||||
|
team, err := loadTeam(filepath.Join(teamDir, "team.yaml"))
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(404, map[string]string{"error": "team not found"})
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
r := s.rooms[id]
|
||||||
|
if r == nil {
|
||||||
|
return c.JSON(404, map[string]string{"error": "room not found"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find master and members from team agents by reading AGENT.md
|
||||||
|
master := ""
|
||||||
|
var members []string
|
||||||
|
for _, agentName := range team.Agents {
|
||||||
|
agentMD, err := os.ReadFile(filepath.Join(s.agentsDir, body.Team, agentName, "AGENT.md"))
|
||||||
|
if err == nil && strings.Contains(string(agentMD), "role: master") {
|
||||||
|
master = agentName
|
||||||
|
} else {
|
||||||
|
members = append(members, agentName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if master == "" && len(team.Agents) > 0 {
|
||||||
|
master = team.Agents[0]
|
||||||
|
members = team.Agents[1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := r.Config
|
||||||
|
cfg.Team = body.Team
|
||||||
|
cfg.Master = master
|
||||||
|
cfg.Members = members
|
||||||
|
|
||||||
|
s.writeRoomFile(r.Dir, cfg)
|
||||||
|
|
||||||
|
newRoom, err := room.Load(r.Dir, s.agentsDir, s.skillsDir)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(500, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
newRoom.Broadcast = func(ev room.Event) { s.broadcast(ev.RoomID, ev) }
|
||||||
|
newRoom.User = s.user
|
||||||
|
s.rooms[id] = newRoom
|
||||||
|
|
||||||
|
return c.JSON(200, map[string]interface{}{
|
||||||
|
"team": body.Team,
|
||||||
|
"master": master,
|
||||||
|
"members": members,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) listAgents(c echo.Context) error {
|
func (s *Server) listAgents(c echo.Context) error {
|
||||||
entries, _ := os.ReadDir(s.agentsDir)
|
entries, _ := os.ReadDir(s.agentsDir)
|
||||||
type agentInfo struct {
|
type agentInfo struct {
|
||||||
@ -393,7 +465,6 @@ func (s *Server) getWorkspaceFile(c echo.Context) error {
|
|||||||
|
|
||||||
func (s *Server) getMessages(c echo.Context) error {
|
func (s *Server) getMessages(c echo.Context) error {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
// Read today's history file and parse into messages
|
|
||||||
historyFile := filepath.Join(s.roomsDir, id, "history", time.Now().Format("2006-01-02")+".md")
|
historyFile := filepath.Join(s.roomsDir, id, "history", time.Now().Format("2006-01-02")+".md")
|
||||||
data, err := os.ReadFile(historyFile)
|
data, err := os.ReadFile(historyFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -406,30 +477,40 @@ func (s *Server) getMessages(c echo.Context) error {
|
|||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
}
|
}
|
||||||
var msgs []msg
|
var msgs []msg
|
||||||
lines := strings.Split(string(data), "\n## ")
|
// Format: "\n**[HH:MM:SS] agentName** (role)\n\ncontent\n"
|
||||||
for i, block := range lines {
|
blocks := strings.Split(string(data), "\n**[")
|
||||||
|
for i, block := range blocks {
|
||||||
if block == "" {
|
if block == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Parse "**[HH:MM:SS] agentName** (role)\n\ncontent"
|
// block starts with "HH:MM:SS] agentName** (role)\n\ncontent"
|
||||||
parts := strings.SplitN(block, "\n\n", 2)
|
headerEnd := strings.Index(block, "\n\n")
|
||||||
if len(parts) < 2 {
|
if headerEnd < 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
header := parts[0]
|
header := block[:headerEnd]
|
||||||
content := strings.TrimSpace(parts[1])
|
content := strings.TrimSpace(block[headerEnd+2:])
|
||||||
// Extract role and agent from header like "**[15:04:05] agentName** (role)"
|
// header: "HH:MM:SS] agentName** (role)"
|
||||||
var agentName, role string
|
bracketEnd := strings.Index(header, "] ")
|
||||||
fmt.Sscanf(header, "**[%*s] %s** (%s)", &agentName, &role)
|
if bracketEnd < 0 {
|
||||||
agentName = strings.TrimSuffix(agentName, "**")
|
continue
|
||||||
role = strings.TrimSuffix(role, ")")
|
}
|
||||||
|
rest := header[bracketEnd+2:] // "agentName** (role)"
|
||||||
|
starIdx := strings.Index(rest, "**")
|
||||||
|
if starIdx < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
agentName := rest[:starIdx]
|
||||||
|
roleStr := strings.TrimSpace(rest[starIdx+2:]) // " (role)"
|
||||||
|
roleStr = strings.TrimPrefix(roleStr, "(")
|
||||||
|
roleStr = strings.TrimSuffix(roleStr, ")")
|
||||||
if agentName == "" {
|
if agentName == "" {
|
||||||
agentName = "unknown"
|
agentName = "unknown"
|
||||||
}
|
}
|
||||||
if role == "" {
|
if roleStr == "" {
|
||||||
role = "member"
|
roleStr = "member"
|
||||||
}
|
}
|
||||||
msgs = append(msgs, msg{ID: fmt.Sprintf("%d", i), Agent: agentName, Role: role, Content: content})
|
msgs = append(msgs, msg{ID: fmt.Sprintf("%d", i), Agent: agentName, Role: roleStr, Content: content})
|
||||||
}
|
}
|
||||||
return c.JSON(200, msgs)
|
return c.JSON(200, msgs)
|
||||||
}
|
}
|
||||||
@ -529,6 +610,7 @@ func (s *Server) listTeams(c echo.Context) error {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
teams = append(teams, map[string]interface{}{
|
teams = append(teams, map[string]interface{}{
|
||||||
|
"id": e.Name(),
|
||||||
"name": team.Name,
|
"name": team.Name,
|
||||||
"description": team.Description,
|
"description": team.Description,
|
||||||
"author": team.Author,
|
"author": team.Author,
|
||||||
@ -650,22 +732,36 @@ func loadTeam(path string) (*hub.Team, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
// Simple YAML parse
|
|
||||||
team := &hub.Team{}
|
team := &hub.Team{}
|
||||||
lines := strings.Split(string(data), "\n")
|
lines := strings.Split(string(data), "\n")
|
||||||
|
var currentField string
|
||||||
for _, line := range lines {
|
for _, line := range lines {
|
||||||
if strings.HasPrefix(line, "name: ") {
|
if strings.HasPrefix(line, "name: ") {
|
||||||
team.Name = strings.TrimSpace(strings.TrimPrefix(line, "name: "))
|
team.Name = strings.TrimSpace(strings.TrimPrefix(line, "name: "))
|
||||||
|
currentField = ""
|
||||||
} else if strings.HasPrefix(line, "description: ") {
|
} else if strings.HasPrefix(line, "description: ") {
|
||||||
team.Description = strings.TrimSpace(strings.TrimPrefix(line, "description: "))
|
team.Description = strings.TrimSpace(strings.TrimPrefix(line, "description: "))
|
||||||
|
currentField = ""
|
||||||
} else if strings.HasPrefix(line, "author: ") {
|
} else if strings.HasPrefix(line, "author: ") {
|
||||||
team.Author = strings.TrimSpace(strings.TrimPrefix(line, "author: "))
|
team.Author = strings.TrimSpace(strings.TrimPrefix(line, "author: "))
|
||||||
|
currentField = ""
|
||||||
} else if strings.HasPrefix(line, "repo_url: ") {
|
} else if strings.HasPrefix(line, "repo_url: ") {
|
||||||
team.RepoURL = strings.TrimSpace(strings.TrimPrefix(line, "repo_url: "))
|
team.RepoURL = strings.TrimSpace(strings.TrimPrefix(line, "repo_url: "))
|
||||||
|
currentField = ""
|
||||||
} else if strings.HasPrefix(line, "installed_at: ") {
|
} else if strings.HasPrefix(line, "installed_at: ") {
|
||||||
team.InstalledAt = strings.TrimSpace(strings.TrimPrefix(line, "installed_at: "))
|
team.InstalledAt = strings.TrimSpace(strings.TrimPrefix(line, "installed_at: "))
|
||||||
|
currentField = ""
|
||||||
|
} else if strings.HasPrefix(line, "agents:") {
|
||||||
|
currentField = "agents"
|
||||||
|
} else if strings.HasPrefix(line, "skills:") {
|
||||||
|
currentField = "skills"
|
||||||
} else if strings.HasPrefix(line, " - ") {
|
} else if strings.HasPrefix(line, " - ") {
|
||||||
team.Agents = append(team.Agents, strings.TrimSpace(strings.TrimPrefix(line, " - ")))
|
item := strings.TrimSpace(strings.TrimPrefix(line, " - "))
|
||||||
|
if currentField == "agents" {
|
||||||
|
team.Agents = append(team.Agents, item)
|
||||||
|
} else if currentField == "skills" {
|
||||||
|
team.Skills = append(team.Skills, item)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return team, nil
|
return team, nil
|
||||||
|
|||||||
@ -8,6 +8,8 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Team struct {
|
type Team struct {
|
||||||
@ -108,10 +110,14 @@ func installFromDir(tmp, teamName, repoURL, agentsDir, skillsDir, teamsDir strin
|
|||||||
// Create knowledge dir
|
// Create knowledge dir
|
||||||
os.MkdirAll(filepath.Join(agentsDir, teamName, "knowledge"), 0755)
|
os.MkdirAll(filepath.Join(agentsDir, teamName, "knowledge"), 0755)
|
||||||
|
|
||||||
|
// Read TEAM.md for metadata
|
||||||
|
teamMeta := readTeamMeta(tmp)
|
||||||
|
|
||||||
// Create team record
|
// Create team record
|
||||||
team := &Team{
|
team := &Team{
|
||||||
Name: teamName,
|
Name: teamMeta.Name,
|
||||||
Description: "团队描述",
|
Description: teamMeta.Description,
|
||||||
|
Author: teamMeta.Author,
|
||||||
RepoURL: repoURL,
|
RepoURL: repoURL,
|
||||||
Agents: installedAgents,
|
Agents: installedAgents,
|
||||||
Skills: installedSkills,
|
Skills: installedSkills,
|
||||||
@ -238,3 +244,30 @@ func copyDir(src, dst string) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func readTeamMeta(tmp string) Team {
|
||||||
|
team := Team{}
|
||||||
|
teamFile := filepath.Join(tmp, "TEAM.md")
|
||||||
|
data, err := os.ReadFile(teamFile)
|
||||||
|
if err != nil {
|
||||||
|
return Team{Name: extractRepoName(tmp)}
|
||||||
|
}
|
||||||
|
|
||||||
|
content := string(data)
|
||||||
|
if strings.HasPrefix(content, "---") {
|
||||||
|
parts := strings.SplitN(content, "---", 3)
|
||||||
|
if len(parts) >= 3 {
|
||||||
|
yamlContent := parts[1]
|
||||||
|
yaml.Unmarshal([]byte(yamlContent), &team)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if team.Name == "" {
|
||||||
|
team.Name = extractRepoName(tmp)
|
||||||
|
}
|
||||||
|
if team.Description == "" {
|
||||||
|
team.Description = "团队描述"
|
||||||
|
}
|
||||||
|
|
||||||
|
return team
|
||||||
|
}
|
||||||
|
|||||||
@ -36,6 +36,8 @@ type Config struct {
|
|||||||
Type RoomType `yaml:"type"`
|
Type RoomType `yaml:"type"`
|
||||||
Master string `yaml:"master"` // agent name
|
Master string `yaml:"master"` // agent name
|
||||||
Members []string `yaml:"members"` // agent names
|
Members []string `yaml:"members"` // agent names
|
||||||
|
Color string `yaml:"color"` // avatar color
|
||||||
|
Team string `yaml:"team"` // installed team name
|
||||||
}
|
}
|
||||||
|
|
||||||
type Room struct {
|
type Room struct {
|
||||||
@ -90,17 +92,20 @@ func Load(roomDir string, agentsDir string, skillsDir string) (*Room, error) {
|
|||||||
|
|
||||||
r := &Room{Config: cfg, Dir: roomDir, members: make(map[string]*agent.Agent)}
|
r := &Room{Config: cfg, Dir: roomDir, members: make(map[string]*agent.Agent)}
|
||||||
|
|
||||||
r.master, err = agent.Load(filepath.Join(agentsDir, cfg.Master))
|
if cfg.Master != "" {
|
||||||
|
agentPath := resolveAgentPath(agentsDir, cfg.Team, cfg.Master)
|
||||||
|
r.master, err = agent.Load(agentPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("load master %s: %w", cfg.Master, err)
|
return nil, fmt.Errorf("load master %s: %w", cfg.Master, err)
|
||||||
}
|
}
|
||||||
for _, name := range cfg.Members {
|
for _, name := range cfg.Members {
|
||||||
a, err := agent.Load(filepath.Join(agentsDir, name))
|
a, err := agent.Load(resolveAgentPath(agentsDir, cfg.Team, name))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("load member %s: %w", name, err)
|
return nil, fmt.Errorf("load member %s: %w", name, err)
|
||||||
}
|
}
|
||||||
r.members[name] = a
|
r.members[name] = a
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
r.skillMeta, _ = skill.Discover(skillsDir)
|
r.skillMeta, _ = skill.Discover(skillsDir)
|
||||||
return r, nil
|
return r, nil
|
||||||
@ -140,6 +145,9 @@ func (r *Room) Handle(ctx context.Context, userMsg string) error {
|
|||||||
|
|
||||||
// HandleUserMessage processes a user message with a specific user name.
|
// HandleUserMessage processes a user message with a specific user name.
|
||||||
func (r *Room) HandleUserMessage(ctx context.Context, userName, userMsg string) error {
|
func (r *Room) HandleUserMessage(ctx context.Context, userName, userMsg string) error {
|
||||||
|
if r.master == nil {
|
||||||
|
return fmt.Errorf("room has no master agent configured")
|
||||||
|
}
|
||||||
r.AppendHistory("user", userName, userMsg)
|
r.AppendHistory("user", userName, userMsg)
|
||||||
r.setStatus(StatusThinking, "", "")
|
r.setStatus(StatusThinking, "", "")
|
||||||
|
|
||||||
@ -319,3 +327,14 @@ func parseRoomConfig(data []byte) (Config, error) {
|
|||||||
}
|
}
|
||||||
return cfg, yaml.Unmarshal(parts[1], &cfg)
|
return cfg, yaml.Unmarshal(parts[1], &cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveAgentPath finds agent dir: prefers agentsDir/team/name, falls back to agentsDir/name
|
||||||
|
func resolveAgentPath(agentsDir, team, name string) string {
|
||||||
|
if team != "" {
|
||||||
|
p := filepath.Join(agentsDir, team, name)
|
||||||
|
if _, err := os.Stat(p); err == nil {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filepath.Join(agentsDir, name)
|
||||||
|
}
|
||||||
|
|||||||
72
progress.md
Normal file
72
progress.md
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
# Project: Agent Team
|
||||||
|
Last updated: 2026-03-05
|
||||||
|
|
||||||
|
## Pinned (关键约束)
|
||||||
|
- 一切皆 MD — agent 配置、soul、memory、tasks、history 全部是 Markdown 文件
|
||||||
|
- 使用前必须设置环境变量 `DEEPSEEK_API_KEY` 或其他 LLM provider 的 API key
|
||||||
|
|
||||||
|
## Decisions (决策)
|
||||||
|
- 2026-03-05:使用 Go + Echo + go-openai 作为后端技术栈
|
||||||
|
- 2026-03-05:使用 React 19 + TypeScript + Tailwind v4 + Zustand 作为前端技术栈
|
||||||
|
- 2026-03-05:采用纯文件系统存储,无数据库
|
||||||
|
|
||||||
|
## Constraints (约束)
|
||||||
|
- 暂无
|
||||||
|
|
||||||
|
## TODO
|
||||||
|
### In Progress
|
||||||
|
- 暂无
|
||||||
|
### Pending
|
||||||
|
- 暂无
|
||||||
|
|
||||||
|
## Done
|
||||||
|
- 2026-03-05:支持从 TEAM.md 读取团队元信息
|
||||||
|
- 修改 internal/hub/hub.go,添加 readTeamMeta 函数
|
||||||
|
- 安装团队时从 TEAM.md 解析 name/description/author
|
||||||
|
- 代码变更:`files changed: 1`, `insertions: +29`, `deletions: -4`
|
||||||
|
- Evidence: internal/hub/hub.go:129-175
|
||||||
|
- 2026-03-05:配置精选团队为营销团队和律师团队
|
||||||
|
- 代码变更:`files changed: 1`, `insertions: +20`, `deletions: -51`
|
||||||
|
- Evidence: web/src/components/MarketPage.tsx:18-37
|
||||||
|
- 2026-03-05:创建营销团队 (marketing-team) 并提交到 Gitea
|
||||||
|
- 参考 legal-team 律师团队结构
|
||||||
|
- 包含 3 个 Agent:营销总监(master)、市场分析师(member)、内容策划师(member)
|
||||||
|
- 提交地址:https://gitea.catter.cn/agent-teams/marketing-team.git
|
||||||
|
- Evidence: agents/marketing-team/
|
||||||
|
- 2026-03-05:修复后端 loadTeam 函数解析 skills 错误添加到 agents 的问题
|
||||||
|
- 代码变更:`files changed: 1`, `insertions: +16`, `deletions: -2`
|
||||||
|
- Evidence: internal/api/server.go:648-672
|
||||||
|
- 2026-03-05:修复精选团队和我的团队 grid 风格不一致
|
||||||
|
- 代码变更:`files changed: 1`, `insertions: +26`, `deletions: -8`
|
||||||
|
- Evidence: web/src/components/MarketPage.tsx:388-432
|
||||||
|
- 2026-03-05:替换 MDEditor 为简洁的 GitHub 风格 Markdown 编辑器
|
||||||
|
- 代码变更:`files changed: 4`, `insertions: +68`, `deletions: -45`
|
||||||
|
- Evidence: web/src/components/MarkdownEditor.tsx (new), TeamDetail.tsx, UserSettings.tsx, Onboarding.tsx
|
||||||
|
- 2026-03-05:精选团队查看详情改为打开 GitHub 链接,我的团队查看详情打开本地详情
|
||||||
|
- 代码变更:`files changed: 2`, `insertions: +30`, `deletions: -15`
|
||||||
|
- Evidence: MarketPage.tsx, TeamDetail.tsx
|
||||||
|
- 2026-03-05:精选团队添加"立即雇佣"按钮
|
||||||
|
- 代码变更:`files changed: 1`, `insertions: +25`, `deletions: -10`
|
||||||
|
- Evidence: MarketPage.tsx
|
||||||
|
- 2026-03-05:侧边栏布局改为 Discord 风格 - 聊天按钮在上,工具栏(市场/主题/设置)在下
|
||||||
|
- 代码变更:`files changed: 1`, `insertions: +80`, `deletions: -20`
|
||||||
|
- Evidence: App.tsx
|
||||||
|
- 2026-03-05:顶部 Logo 改为 Sparkles AI 图标,移除背景色
|
||||||
|
- 代码变更:`files changed: 1`, `insertions: +5`, `deletions: -3`
|
||||||
|
- Evidence: App.tsx
|
||||||
|
- 2026-03-05:实施 Discord 风格三栏布局 - 左侧群列表 + 中间成员列表 + 聊天区域 + 右侧工作区
|
||||||
|
- 新建 MemberList.tsx、RightSidebar.tsx
|
||||||
|
- 修改 ChatView.tsx 移除内置右侧栏
|
||||||
|
- 代码变更:`files changed: 5`, `insertions: +350`, `deletions: -200`
|
||||||
|
- Evidence: App.tsx, MemberList.tsx (new), RightSidebar.tsx (new), ChatView.tsx
|
||||||
|
- 2026-03-05:完成群聊创建和实时对话功能
|
||||||
|
- 支持创建新群聊,WebSocket 实时消息推送
|
||||||
|
- 后端房间管理优化,支持任务和工作区文件同步
|
||||||
|
- 代码变更:`files changed: 4`, `insertions: +200`, `deletions: -50`
|
||||||
|
- Evidence: internal/room/room.go, internal/api/server.go, web/src/store.ts, web/src/types.ts
|
||||||
|
|
||||||
|
## Experiments
|
||||||
|
- 暂无
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- 2026-03-05:⚠️ 启动前必须设置环境变量 `export DEEPSEEK_API_KEY=your_key_here` 或其他 provider (OPENAI_API_KEY, KIMI_API_KEY, OLLAMA_HOST)
|
||||||
@ -1,7 +1,7 @@
|
|||||||
name: legal-team
|
name: 法律咨询团队
|
||||||
description: 团队描述
|
description: 专业法律咨询团队,包含合同审查、法律风险评估和合规建议
|
||||||
author:
|
author: Agent Team
|
||||||
repo_url: https://gitea.catter.cn/agent-teams/legal-team.git
|
repo_url: https://gitea.catter.cn/agent-teams/legal-team
|
||||||
agents:
|
agents:
|
||||||
- 合同律师
|
- 合同律师
|
||||||
- 合规专员
|
- 合规专员
|
||||||
|
|||||||
9
teams/marketing-team/team.yaml
Normal file
9
teams/marketing-team/team.yaml
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
name: 营销团队
|
||||||
|
description: 营销团队,包含市场分析、竞品研究和内容策划
|
||||||
|
author: Agent Team
|
||||||
|
repo_url: https://gitea.catter.cn/agent-teams/marketing-team
|
||||||
|
agents:
|
||||||
|
[]
|
||||||
|
skills:
|
||||||
|
[]
|
||||||
|
installed_at: 2026-03-05
|
||||||
@ -1,9 +1,14 @@
|
|||||||
# 我的简介
|
# 我的简介
|
||||||
|
|
||||||
## 我是谁
|
## 我是谁
|
||||||
(介绍你的身份、职业背景)
|
我是[
|
||||||
|
|
||||||
## 工作风格
|
## 工作风格
|
||||||
- 喜欢的沟通方式
|
- 喜欢[沟通方式]
|
||||||
- 重视的点
|
- 重视[重视的点]
|
||||||
- 不喜欢的内容
|
- 不喜欢[不喜欢的]
|
||||||
|
|
||||||
|
## 期望的回复方式
|
||||||
|
- 用 bullet point 列出要点
|
||||||
|
- 重要的决定给出 pros 和 cons
|
||||||
|
- 提供数据支持
|
||||||
@ -1,8 +1,11 @@
|
|||||||
---
|
---
|
||||||
name: 用户
|
name: 第五季
|
||||||
description:
|
description: 产品经理,擅长需求分析
|
||||||
provider: deepseek
|
provider: deepseek
|
||||||
model: deepseek-chat
|
model: deepseek-chat
|
||||||
api_key_env: DEEPSEEK_API_KEY
|
api_key_env: DEEPSEEK_API_KEY
|
||||||
avatar_color: "#5865F2"
|
avatar_color: '#5865F2'
|
||||||
|
preferences:
|
||||||
|
language: ""
|
||||||
|
tone: ""
|
||||||
---
|
---
|
||||||
|
|||||||
163
web/src/App.tsx
163
web/src/App.tsx
@ -1,20 +1,29 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { MessageSquare, ShoppingCart, Hash, Settings } from 'lucide-react'
|
import { ShoppingCart, Sparkles, Settings, Plus } from 'lucide-react'
|
||||||
import { useStore } from './store'
|
import { useStore } from './store'
|
||||||
import { RoomSidebar } from './components/RoomSidebar'
|
|
||||||
import { ChatView } from './components/ChatView'
|
import { ChatView } from './components/ChatView'
|
||||||
import { MarketPage } from './components/MarketPage'
|
import { MarketPage } from './components/MarketPage'
|
||||||
import { UserSettings } from './components/UserSettings'
|
import { UserSettings } from './components/UserSettings'
|
||||||
import { ThemeToggle } from './components/ThemeToggle'
|
import { ThemeToggle } from './components/ThemeToggle'
|
||||||
import { Onboarding } from './components/Onboarding'
|
import { Onboarding } from './components/Onboarding'
|
||||||
|
import { MemberList } from './components/MemberList'
|
||||||
|
import { RightSidebar } from './components/RightSidebar'
|
||||||
|
|
||||||
|
const API = '/api'
|
||||||
|
|
||||||
|
const COLORS = ['#5865F2', '#57F287', '#FEE75C', '#EB459E', '#ED4245', '#3BA55C', '#FAA61A', '#00B0F4']
|
||||||
|
const randomColor = () => COLORS[Math.floor(Math.random() * COLORS.length)]
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { page, setPage, onboardingCompleted, setOnboardingCompleted, fetchUser } = useStore()
|
const { page, setPage, onboardingCompleted, setOnboardingCompleted, fetchUser, fetchRooms, rooms, activeRoomId, setActiveRoom } = useStore()
|
||||||
const [showOnboarding, setShowOnboarding] = useState(onboardingCompleted ? false : true)
|
const [showOnboarding, setShowOnboarding] = useState(onboardingCompleted ? false : true)
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
|
const [newRoomName, setNewRoomName] = useState('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchUser()
|
fetchUser()
|
||||||
}, [fetchUser])
|
fetchRooms()
|
||||||
|
}, [fetchUser, fetchRooms])
|
||||||
|
|
||||||
const handleOnboardingComplete = () => {
|
const handleOnboardingComplete = () => {
|
||||||
setShowOnboarding(false)
|
setShowOnboarding(false)
|
||||||
@ -22,10 +31,17 @@ export default function App() {
|
|||||||
fetchUser()
|
fetchUser()
|
||||||
}
|
}
|
||||||
|
|
||||||
const navItems = [
|
const createRoom = async () => {
|
||||||
{ id: 'chat', icon: MessageSquare, label: '群聊' },
|
if (!newRoomName.trim()) return
|
||||||
{ id: 'market', icon: ShoppingCart, label: '市场' },
|
const color = randomColor()
|
||||||
] as const
|
await fetch(`${API}/rooms`, {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: newRoomName.trim(), type: 'dept', color, master: '', members: [] })
|
||||||
|
})
|
||||||
|
setCreating(false)
|
||||||
|
setNewRoomName('')
|
||||||
|
useStore.getState().fetchRooms()
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -37,25 +53,59 @@ export default function App() {
|
|||||||
{/* Left sidebar - Navigation */}
|
{/* Left sidebar - Navigation */}
|
||||||
<div className="flex flex-col w-[72px] bg-[var(--sidebar-bg)] items-center py-3 gap-2">
|
<div className="flex flex-col w-[72px] bg-[var(--sidebar-bg)] items-center py-3 gap-2">
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className="w-12 h-12 bg-[var(--accent)] rounded-xl flex items-center justify-center mb-2">
|
<div className="w-12 h-12 rounded-xl flex items-center justify-center mb-2">
|
||||||
<Hash className="w-6 h-6 text-white" />
|
<Sparkles className="w-7 h-7 text-[var(--accent)]" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Nav buttons */}
|
{/* Room list */}
|
||||||
<div className="flex flex-col gap-1 w-full px-3">
|
<div className="flex flex-col gap-1 w-full px-3 flex-1 overflow-y-auto scrollbar-thin">
|
||||||
{navItems.map(item => (
|
{rooms.map(r => {
|
||||||
<NavBtn
|
const isActive = activeRoomId === r.id
|
||||||
key={item.id}
|
const initial = r.name[0]?.toUpperCase() || '?'
|
||||||
icon={<item.icon className="w-5 h-5" />}
|
const color = r.color || '#5865F2'
|
||||||
label={item.label}
|
return (
|
||||||
active={page === item.id}
|
<button
|
||||||
onClick={() => setPage(item.id as typeof page)}
|
key={r.id}
|
||||||
/>
|
onClick={() => { setPage('chat'); setActiveRoom(r.id) }}
|
||||||
))}
|
title={r.name}
|
||||||
|
className={`
|
||||||
|
w-full h-10 rounded-lg flex items-center justify-center transition-all duration-200 relative text-white text-sm font-bold
|
||||||
|
${isActive ? 'ring-2 ring-white/40' : 'hover:rounded-xl'}
|
||||||
|
`}
|
||||||
|
style={{ backgroundColor: color }}
|
||||||
|
>
|
||||||
|
{initial}
|
||||||
|
{r.status !== 'pending' && (
|
||||||
|
<span className={`absolute top-0.5 right-0.5 w-2.5 h-2.5 rounded-full border-2 border-[var(--sidebar-bg)] ${r.status === 'thinking' ? 'bg-yellow-400' : 'bg-green-400 animate-pulse'}`} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Add room button */}
|
||||||
|
<button
|
||||||
|
onClick={() => setCreating(true)}
|
||||||
|
title="创建群聊"
|
||||||
|
className="w-full h-10 rounded-lg flex items-center justify-center text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-hover)] transition-all duration-200"
|
||||||
|
>
|
||||||
|
<Plus className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Divider */}
|
{/* Bottom buttons */}
|
||||||
<div className="w-8 h-[2px] bg-[var(--border)] rounded-full my-1" />
|
<div className="mt-auto flex flex-col gap-1 w-full px-3">
|
||||||
|
{/* Market */}
|
||||||
|
<button
|
||||||
|
onClick={() => setPage('market')}
|
||||||
|
className={`
|
||||||
|
w-9 h-9 rounded-lg flex items-center justify-center transition-colors duration-200
|
||||||
|
text-[var(--text-secondary)] hover:text-[var(--text-primary)]
|
||||||
|
${page === 'market' ? 'bg-[var(--accent)] text-white' : 'hover:bg-[var(--bg-hover)]'}
|
||||||
|
`}
|
||||||
|
title="市场"
|
||||||
|
>
|
||||||
|
<ShoppingCart className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
{/* Theme toggle */}
|
{/* Theme toggle */}
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
@ -73,39 +123,50 @@ export default function App() {
|
|||||||
<Settings className="w-5 h-5" />
|
<Settings className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create room modal */}
|
||||||
|
{creating && (
|
||||||
|
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={() => setCreating(false)}>
|
||||||
|
<div className="bg-[var(--bg-secondary)] rounded-lg p-4 w-80 shadow-xl" onClick={e => e.stopPropagation()}>
|
||||||
|
<h3 className="font-semibold mb-3">创建新群聊</h3>
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
className="w-full bg-[var(--bg-tertiary)] border border-[var(--border)] rounded-lg px-3 py-2 text-sm outline-none focus:border-[var(--accent)] mb-3"
|
||||||
|
placeholder="群聊名称"
|
||||||
|
value={newRoomName}
|
||||||
|
onChange={e => setNewRoomName(e.target.value)}
|
||||||
|
onKeyDown={e => e.key === 'Enter' && createRoom()}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={createRoom}
|
||||||
|
className="flex-1 bg-[var(--accent)] hover:bg-[var(--accent-hover)] text-white rounded-lg py-2 text-sm font-medium"
|
||||||
|
>
|
||||||
|
创建
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setCreating(false)}
|
||||||
|
className="flex-1 bg-[var(--bg-hover)] hover:bg-[var(--bg-active)] rounded-lg py-2 text-sm"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Main content */}
|
{/* Main content */}
|
||||||
{page === 'chat' && <RoomSidebar />}
|
{page === 'chat' && (
|
||||||
{page === 'chat' && <ChatView />}
|
<>
|
||||||
|
<MemberList />
|
||||||
|
<ChatView />
|
||||||
|
<RightSidebar />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{page === 'market' && <MarketPage />}
|
{page === 'market' && <MarketPage />}
|
||||||
{page === 'settings' && <UserSettings />}
|
{page === 'settings' && <UserSettings />}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function NavBtn({ icon, label, active, onClick }: { icon: React.ReactNode; label: string; active: boolean; onClick: () => void }) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={onClick}
|
|
||||||
title={label}
|
|
||||||
className={`
|
|
||||||
w-full h-12 rounded-xl flex items-center justify-center transition-all duration-200 group relative
|
|
||||||
${active
|
|
||||||
? 'bg-[var(--accent)] text-white'
|
|
||||||
: 'text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-primary)]'
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
{icon}
|
|
||||||
{/* Active indicator */}
|
|
||||||
{active && (
|
|
||||||
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-6 bg-white rounded-r-full" />
|
|
||||||
)}
|
|
||||||
{/* Hover tooltip */}
|
|
||||||
<div className="absolute left-full ml-4 px-2 py-1 bg-[var(--bg-tertiary)] text-[var(--text-primary)] text-sm rounded opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity whitespace-nowrap z-50 shadow-lg">
|
|
||||||
{label}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -3,38 +3,23 @@ import ReactMarkdown from 'react-markdown'
|
|||||||
import remarkGfm from 'remark-gfm'
|
import remarkGfm from 'remark-gfm'
|
||||||
import {
|
import {
|
||||||
Hash,
|
Hash,
|
||||||
Users,
|
Crown,
|
||||||
ListTodo,
|
|
||||||
FileText,
|
|
||||||
X,
|
|
||||||
Plus,
|
Plus,
|
||||||
Smile,
|
Smile,
|
||||||
Send,
|
Send,
|
||||||
Crown,
|
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useStore } from '../store'
|
import { useStore } from '../store'
|
||||||
import type { Message } from '../types'
|
import type { Message } from '../types'
|
||||||
|
|
||||||
const API = '/api'
|
|
||||||
|
|
||||||
export function ChatView() {
|
export function ChatView() {
|
||||||
const { activeRoomId, rooms, messages, tasks, workspace, sendMessage } = useStore()
|
const { activeRoomId, rooms, messages, sendMessage, user } = useStore()
|
||||||
const [input, setInput] = useState('')
|
const [input, setInput] = useState('')
|
||||||
const [drawer, setDrawer] = useState<null | 'members' | 'tasks' | 'files'>(null)
|
|
||||||
const [previewFile, setPreviewFile] = useState<{ name: string; content: string } | null>(null)
|
|
||||||
const bottomRef = useRef<HTMLDivElement>(null)
|
const bottomRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const room = rooms.find(r => r.id === activeRoomId)
|
const room = rooms.find(r => r.id === activeRoomId)
|
||||||
const msgs: Message[] = activeRoomId ? (messages[activeRoomId] || []) : []
|
const msgs: Message[] = activeRoomId ? (messages[activeRoomId] || []) : []
|
||||||
const tasksMd = activeRoomId ? (tasks[activeRoomId] || '') : ''
|
|
||||||
const files = activeRoomId ? (workspace[activeRoomId] || []) : []
|
|
||||||
|
|
||||||
useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [msgs])
|
useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [msgs, room?.status])
|
||||||
|
|
||||||
const openFile = async (filename: string) => {
|
|
||||||
const d = await fetch(`${API}/rooms/${activeRoomId}/workspace/${filename}`).then(r => r.json())
|
|
||||||
setPreviewFile({ name: filename, content: d.content || '' })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!room) {
|
if (!room) {
|
||||||
return (
|
return (
|
||||||
@ -45,16 +30,10 @@ export function ChatView() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusLabel = room.status === 'working' && room.activeAgent
|
const statusLabel = room.status === 'working' && room.activeAgent
|
||||||
? `${room.activeAgent} ${room.action || ''}`
|
? `${room.activeAgent} ${room.action || ''}`
|
||||||
: room.status === 'thinking' ? '思考中...' : room.status === 'working' ? '工作中...' : '空闲'
|
: room.status === 'thinking' ? '思考中...' : room.status === 'working' ? '工作中...' : '空闲'
|
||||||
|
|
||||||
const drawerButtons = [
|
|
||||||
{ id: 'members', icon: Users, label: '成员' },
|
|
||||||
{ id: 'tasks', icon: ListTodo, label: '任务' },
|
|
||||||
{ id: 'files', icon: FileText, label: '产物' },
|
|
||||||
] as const
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 overflow-hidden bg-[var(--bg-primary)]">
|
<div className="flex flex-1 overflow-hidden bg-[var(--bg-primary)]">
|
||||||
{/* Main chat area */}
|
{/* Main chat area */}
|
||||||
@ -94,7 +73,11 @@ export function ChatView() {
|
|||||||
<p className="text-xs mt-1">发送消息开始对话</p>
|
<p className="text-xs mt-1">发送消息开始对话</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{msgs.map(msg => <MessageBubble key={msg.id} msg={msg} />)}
|
{msgs.map(msg => <MessageBubble key={msg.id} msg={msg} userName={user?.name} />)}
|
||||||
|
{/* Thinking indicator */}
|
||||||
|
{room.status === 'thinking' && msgs[msgs.length - 1]?.role === 'user' && (
|
||||||
|
<ThinkingBubble agent={room.master} />
|
||||||
|
)}
|
||||||
<div ref={bottomRef} />
|
<div ref={bottomRef} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -134,124 +117,14 @@ export function ChatView() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right panel */}
|
|
||||||
<div className="w-72 border-l border-[var(--border)] bg-[var(--bg-secondary)] flex flex-col overflow-hidden">
|
|
||||||
{/* Panel tabs */}
|
|
||||||
<div className="flex border-b border-[var(--border)]">
|
|
||||||
{drawerButtons.map(btn => (
|
|
||||||
<button
|
|
||||||
key={btn.id}
|
|
||||||
onClick={() => setDrawer(drawer === btn.id ? null : btn.id)}
|
|
||||||
className={`
|
|
||||||
flex-1 py-3 text-xs font-medium flex items-center justify-center gap-1.5 transition-colors border-b-2
|
|
||||||
${drawer === btn.id
|
|
||||||
? 'border-[var(--accent)] text-[var(--accent)]'
|
|
||||||
: 'border-transparent text-[var(--text-muted)] hover:text-[var(--text-secondary)]'
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<btn.icon className="w-4 h-4" />
|
|
||||||
{btn.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Panel content */}
|
|
||||||
<div className="flex-1 overflow-y-auto p-4 scrollbar-thin">
|
|
||||||
{/* Members */}
|
|
||||||
{(drawer === 'members' || drawer === null) && (
|
|
||||||
<section className={drawer !== 'members' && drawer !== null ? 'hidden' : ''}>
|
|
||||||
<h3 className="text-xs font-semibold text-[var(--text-muted)] uppercase mb-3 flex items-center gap-1">
|
|
||||||
<Users className="w-3 h-3" />
|
|
||||||
成员 — {1 + msgs.filter(m => m.role === 'member').map(m => m.agent).filter((v, i, a) => a.indexOf(v) === i).length}
|
|
||||||
</h3>
|
|
||||||
<MemberItem
|
|
||||||
name={room.master}
|
|
||||||
role="master"
|
|
||||||
status={room.status === 'thinking' ? 'thinking' : 'idle'}
|
|
||||||
/>
|
|
||||||
{msgs.filter(m => m.role === 'member').map(m => m.agent).filter((v, i, a) => a.indexOf(v) === i).map(name => (
|
|
||||||
<MemberItem
|
|
||||||
key={name}
|
|
||||||
name={name}
|
|
||||||
role="member"
|
|
||||||
status={room.status === 'working' && room.activeAgent === name ? 'working' : 'idle'}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Tasks */}
|
|
||||||
{(drawer === 'tasks' || drawer === null) && tasksMd && (
|
|
||||||
<section className={`mt-4 ${drawer !== 'tasks' && drawer !== null ? 'hidden' : ''}`}>
|
|
||||||
<h3 className="text-xs font-semibold text-[var(--text-muted)] uppercase mb-3 flex items-center gap-1">
|
|
||||||
<ListTodo className="w-3 h-3" />
|
|
||||||
任务列表
|
|
||||||
</h3>
|
|
||||||
<div className="text-sm prose prose-sm max-w-none dark:prose-invert">
|
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{tasksMd}</ReactMarkdown>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Files */}
|
|
||||||
{(drawer === 'files' || drawer === null) && files.length > 0 && (
|
|
||||||
<section className={`mt-4 ${drawer !== 'files' && drawer !== null ? 'hidden' : ''}`}>
|
|
||||||
<h3 className="text-xs font-semibold text-[var(--text-muted)] uppercase mb-3 flex items-center gap-1">
|
|
||||||
<FileText className="w-3 h-3" />
|
|
||||||
产物文件
|
|
||||||
</h3>
|
|
||||||
{files.map(f => (
|
|
||||||
<div
|
|
||||||
key={f}
|
|
||||||
onClick={() => openFile(f)}
|
|
||||||
className="flex items-center gap-2 text-sm text-[var(--accent)] hover:text-[var(--accent-hover)] cursor-pointer py-1.5 px-2 rounded hover:bg-[var(--bg-hover)] transition-colors"
|
|
||||||
>
|
|
||||||
<FileText className="w-4 h-4" />
|
|
||||||
<span className="truncate">{f}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* File preview modal */}
|
|
||||||
{previewFile && (
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4"
|
|
||||||
onClick={() => setPreviewFile(null)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="bg-[var(--bg-secondary)] rounded-lg w-full max-w-3xl max-h-[80vh] flex flex-col shadow-2xl"
|
|
||||||
onClick={e => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--border)]">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<FileText className="w-5 h-5 text-[var(--accent)]" />
|
|
||||||
<span className="font-semibold">{previewFile.name}</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setPreviewFile(null)}
|
|
||||||
className="w-8 h-8 rounded flex items-center justify-center text-[var(--text-muted)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-primary)] transition-colors"
|
|
||||||
>
|
|
||||||
<X className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="overflow-y-auto p-4 prose dark:prose-invert max-w-none text-sm">
|
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{previewFile.content}</ReactMarkdown>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function MessageBubble({ msg }: { msg: Message }) {
|
function MessageBubble({ msg, userName }: { msg: Message; userName?: string }) {
|
||||||
const isUser = msg.role === 'user'
|
const isUser = msg.role === 'user'
|
||||||
const isMaster = msg.role === 'master'
|
const isMaster = msg.role === 'master'
|
||||||
|
const agentName = msg.agent || 'Unknown'
|
||||||
|
|
||||||
const avatarColors = [
|
const avatarColors = [
|
||||||
'bg-red-500', 'bg-orange-500', 'bg-amber-500', 'bg-yellow-500',
|
'bg-red-500', 'bg-orange-500', 'bg-amber-500', 'bg-yellow-500',
|
||||||
@ -259,24 +132,22 @@ function MessageBubble({ msg }: { msg: Message }) {
|
|||||||
'bg-cyan-500', 'bg-sky-500', 'bg-blue-500', 'bg-indigo-500',
|
'bg-cyan-500', 'bg-sky-500', 'bg-blue-500', 'bg-indigo-500',
|
||||||
'bg-violet-500', 'bg-purple-500', 'bg-fuchsia-500', 'bg-pink-500', 'bg-rose-500'
|
'bg-violet-500', 'bg-purple-500', 'bg-fuchsia-500', 'bg-pink-500', 'bg-rose-500'
|
||||||
]
|
]
|
||||||
const colorIndex = msg.agent.charCodeAt(0) % avatarColors.length
|
const colorIndex = agentName.charCodeAt(0) % avatarColors.length
|
||||||
|
const displayName = isUser ? (userName || agentName) : agentName
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`flex gap-3 group ${isUser ? 'flex-row-reverse' : ''}`}>
|
<div className={`flex gap-3 group ${isUser ? 'flex-row-reverse' : ''}`}>
|
||||||
{/* Avatar */}
|
{/* Avatar */}
|
||||||
{!isUser && (
|
<div className={`w-10 h-10 rounded-full flex items-center justify-center text-white font-semibold flex-shrink-0 mt-0.5 ${isUser ? 'bg-[var(--accent)]' : avatarColors[colorIndex]}`}>
|
||||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center text-white font-semibold flex-shrink-0 mt-0.5 ${avatarColors[colorIndex]}`}>
|
{isMaster ? <Crown className="w-5 h-5" /> : displayName[0]?.toUpperCase()}
|
||||||
{isMaster ? <Crown className="w-5 h-5" /> : msg.agent[0]?.toUpperCase()}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className={`flex flex-col max-w-[70%] ${isUser ? 'items-end' : 'items-start'}`}>
|
<div className={`flex flex-col max-w-[70%] ${isUser ? 'items-end' : 'items-start'}`}>
|
||||||
{/* Name + timestamp */}
|
{/* Name */}
|
||||||
{!isUser && (
|
<div className={`flex items-center gap-2 mb-0.5 ${isUser ? 'flex-row-reverse' : ''}`}>
|
||||||
<div className="flex items-center gap-2 mb-0.5">
|
<span className={`font-medium text-sm ${isMaster ? 'text-[var(--color-warning)]' : isUser ? 'text-[var(--accent)]' : 'text-[var(--text-primary)]'}`}>
|
||||||
<span className={`font-medium text-sm ${isMaster ? 'text-[var(--color-warning)]' : 'text-[var(--text-primary)]'}`}>
|
{displayName}
|
||||||
{msg.agent}
|
|
||||||
</span>
|
</span>
|
||||||
{isMaster && (
|
{isMaster && (
|
||||||
<span className="text-[10px] px-1 py-0.5 bg-[var(--color-warning)]/20 text-[var(--color-warning)] rounded font-medium">
|
<span className="text-[10px] px-1 py-0.5 bg-[var(--color-warning)]/20 text-[var(--color-warning)] rounded font-medium">
|
||||||
@ -284,16 +155,15 @@ function MessageBubble({ msg }: { msg: Message }) {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Message bubble */}
|
{/* Message bubble */}
|
||||||
<div
|
<div
|
||||||
className={`
|
className={`
|
||||||
px-3.5 py-2 rounded-lg text-sm relative group
|
px-3.5 py-2 rounded-lg text-sm relative
|
||||||
${isUser
|
${isUser
|
||||||
? 'bg-[var(--accent)] text-white rounded-tr-sm'
|
? 'bg-[var(--accent)] text-white rounded-tr-sm'
|
||||||
: isMaster
|
: isMaster
|
||||||
? 'bg-[var(--bg-tertiary)] border-2 border-[var(--color-warning)]/30 rounded-tl-sm'
|
? 'bg-[var(--bg-tertiary)] border-l-4 border-[var(--color-warning)] rounded-tl-sm'
|
||||||
: 'bg-[var(--bg-tertiary)] rounded-tl-sm'
|
: 'bg-[var(--bg-tertiary)] rounded-tl-sm'
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
@ -310,37 +180,20 @@ function MessageBubble({ msg }: { msg: Message }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function MemberItem({ name, role, status }: { name: string; role: string; status: string }) {
|
function ThinkingBubble({ agent }: { agent: string }) {
|
||||||
const dotClass = status === 'thinking'
|
|
||||||
? 'bg-[var(--color-warning)] animate-pulse'
|
|
||||||
: status === 'working'
|
|
||||||
? 'bg-[var(--color-success)] animate-pulse'
|
|
||||||
: 'bg-[var(--text-muted)]'
|
|
||||||
|
|
||||||
const statusText = status === 'thinking' ? '思考中' : status === 'working' ? '工作中' : '空闲'
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 py-1.5 px-2 rounded hover:bg-[var(--bg-hover)] transition-colors group">
|
<div className="flex gap-3">
|
||||||
<div className="relative">
|
<div className="w-10 h-10 rounded-full bg-[var(--color-warning)]/20 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||||
<div className={`w-7 h-7 rounded-full bg-[var(--accent)] flex items-center justify-center text-white text-xs font-semibold`}>
|
<Crown className="w-5 h-5 text-[var(--color-warning)]" />
|
||||||
{role === 'master' ? <Crown className="w-3.5 h-3.5" /> : name[0]?.toUpperCase()}
|
|
||||||
</div>
|
</div>
|
||||||
<div className={`absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full border-2 border-[var(--bg-secondary)] ${dotClass}`} />
|
<div className="flex flex-col items-start">
|
||||||
</div>
|
<span className="font-medium text-sm text-[var(--color-warning)] mb-0.5">{agent || 'Master'}</span>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="px-3.5 py-2.5 rounded-lg bg-[var(--bg-tertiary)] border-l-4 border-[var(--color-warning)] rounded-tl-sm flex items-center gap-1.5">
|
||||||
<div className="flex items-center gap-1">
|
<span className="w-2 h-2 rounded-full bg-[var(--color-warning)] animate-bounce" style={{ animationDelay: '0ms' }} />
|
||||||
{role === 'master' && <Crown className="w-3 h-3 text-[var(--color-warning)]" />}
|
<span className="w-2 h-2 rounded-full bg-[var(--color-warning)] animate-bounce" style={{ animationDelay: '150ms' }} />
|
||||||
<span className="text-sm font-medium truncate">{name}</span>
|
<span className="w-2 h-2 rounded-full bg-[var(--color-warning)] animate-bounce" style={{ animationDelay: '300ms' }} />
|
||||||
{role === 'master' && (
|
|
||||||
<span className="text-[10px] px-1 bg-[var(--color-warning)]/20 text-[var(--color-warning)] rounded">
|
|
||||||
MASTER
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-[var(--text-muted)] opacity-0 group-hover:opacity-100 transition-opacity">
|
|
||||||
{statusText}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
59
web/src/components/MarkdownEditor.tsx
Normal file
59
web/src/components/MarkdownEditor.tsx
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import ReactMarkdown from 'react-markdown'
|
||||||
|
import remarkGfm from 'remark-gfm'
|
||||||
|
import { Eye, Edit2 } from 'lucide-react'
|
||||||
|
|
||||||
|
interface MarkdownEditorProps {
|
||||||
|
value: string
|
||||||
|
onChange: (value: string) => void
|
||||||
|
height?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MarkdownEditor({ value, onChange, height = 250 }: MarkdownEditorProps) {
|
||||||
|
const [mode, setMode] = useState<'edit' | 'preview'>('edit')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border border-[var(--border)] rounded-lg overflow-hidden" style={{ height }}>
|
||||||
|
<div className="flex items-center gap-1 px-3 py-2 bg-[var(--bg-secondary)] border-b border-[var(--border)]">
|
||||||
|
<button
|
||||||
|
onClick={() => setMode('edit')}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded text-xs transition-colors ${
|
||||||
|
mode === 'edit'
|
||||||
|
? 'bg-[var(--accent)] text-white'
|
||||||
|
: 'hover:bg-[var(--bg-hover)] text-[var(--text-secondary)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Edit2 className="w-3.5 h-3.5" />
|
||||||
|
编辑
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setMode('preview')}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded text-xs transition-colors ${
|
||||||
|
mode === 'preview'
|
||||||
|
? 'bg-[var(--accent)] text-white'
|
||||||
|
: 'hover:bg-[var(--bg-hover)] text-[var(--text-secondary)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Eye className="w-3.5 h-3.5" />
|
||||||
|
预览
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="h-[calc(100%-40px)] overflow-y-auto">
|
||||||
|
{mode === 'edit' ? (
|
||||||
|
<textarea
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
className="w-full h-full p-4 bg-[var(--bg-tertiary)] text-[var(--text-primary)] outline-none resize-none font-mono text-sm"
|
||||||
|
style={{ height: height - 40 }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="p-4 prose prose-invert prose-sm max-w-none bg-[var(--bg-tertiary)]">
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||||
|
{value || '*无内容*'}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { ShoppingCart, Github, Upload, Users, ArrowRight, Check, AlertCircle, Package, Zap, Star, Globe, Link as LinkIcon } from 'lucide-react'
|
import { ShoppingCart, Github, Upload, Users, ArrowRight, Check, AlertCircle, Package, Zap, Star, Globe, Link as LinkIcon, Loader2 } from 'lucide-react'
|
||||||
import { useStore } from '../store'
|
import { useStore } from '../store'
|
||||||
import { TeamDetail } from './TeamDetail'
|
import { TeamDetail } from './TeamDetail'
|
||||||
|
|
||||||
@ -18,56 +18,20 @@ interface Team {
|
|||||||
const PRESET_TEAMS: Team[] = [
|
const PRESET_TEAMS: Team[] = [
|
||||||
{
|
{
|
||||||
name: '法律咨询团队',
|
name: '法律咨询团队',
|
||||||
description: '法律咨询团队,包含合同审查和法律风险评估',
|
description: '法律咨询团队,包含合同审查、法律风险评估和合规管理',
|
||||||
author: 'Agent Team',
|
author: 'Agent Team',
|
||||||
repo_url: 'https://github.com/sdaduanbilei/legal-team',
|
repo_url: 'https://gitea.catter.cn/agent-teams/legal-team',
|
||||||
agents: ['法律总监', '合同律师', '合规专员'],
|
agents: ['法律总监', '合同律师', '合规专员'],
|
||||||
skills: ['合同审查', '法律知识库'],
|
skills: ['合同审查', '法律知识库'],
|
||||||
installed_at: '',
|
installed_at: '',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: '开发团队',
|
|
||||||
description: '开发团队,包含代码审查、架构设计、全栈测试',
|
|
||||||
author: 'Agent Team',
|
|
||||||
repo_url: 'https://github.com/sdaduanbilei/dev-team',
|
|
||||||
agents: ['技术主管', '代码审查员', '测试工程师'],
|
|
||||||
skills: ['代码扫描', '性能分析'],
|
|
||||||
installed_at: '',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: '营销团队',
|
name: '营销团队',
|
||||||
description: '营销团队,包含内容创意、广告投放策略制定',
|
description: '营销团队,包含市场分析、竞品研究和内容策划',
|
||||||
author: 'Agent Team',
|
author: 'Agent Team',
|
||||||
repo_url: 'https://github.com/sdaduanbilei/marketing-team',
|
repo_url: 'https://gitea.catter.cn/agent-teams/marketing-team',
|
||||||
agents: ['创意总监', '策略分析师', '文案', '运营'],
|
agents: ['营销总监', '市场分析师', '内容策划师'],
|
||||||
skills: ['数据分析', '创意评估'],
|
skills: ['数据分析', '用户画像'],
|
||||||
installed_at: '',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '医疗咨询',
|
|
||||||
description: '医疗领域专业咨询团队',
|
|
||||||
author: 'Expert AI Corp',
|
|
||||||
repo_url: 'https://github.com/expert-ai/medical-team',
|
|
||||||
agents: ['医疗顾问', '营养师', '健康指导员'],
|
|
||||||
skills: ['症状分析', '饮食建议'],
|
|
||||||
installed_at: '',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '金融投资',
|
|
||||||
description: '金融分析与投资咨询团队',
|
|
||||||
author: 'FiTech Solutions',
|
|
||||||
repo_url: 'https://github.com/fitech/finance-team',
|
|
||||||
agents: ['财务分析师', '投资顾问', '风险测评'],
|
|
||||||
skills: ['股市预测', '财报分析'],
|
|
||||||
installed_at: '',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '心理咨询',
|
|
||||||
description: '专业心理咨询服务团队',
|
|
||||||
author: 'MindWell Inc',
|
|
||||||
repo_url: 'https://github.com/mindwell/psychology-team',
|
|
||||||
agents: ['心理咨询师', '治疗助理', '危机干预'],
|
|
||||||
skills: ['情绪评估', '心理疏导'],
|
|
||||||
installed_at: '',
|
installed_at: '',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@ -81,6 +45,7 @@ export function MarketPage() {
|
|||||||
const [uploading, setUploading] = useState(false)
|
const [uploading, setUploading] = useState(false)
|
||||||
const [teams, setTeams] = useState<Team[]>([])
|
const [teams, setTeams] = useState<Team[]>([])
|
||||||
const [selectedTeam, setSelectedTeam] = useState<Team | null>(null)
|
const [selectedTeam, setSelectedTeam] = useState<Team | null>(null)
|
||||||
|
const [installingTeams, setInstallingTeams] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (tab === 'teams') {
|
if (tab === 'teams') {
|
||||||
@ -94,6 +59,25 @@ export function MarketPage() {
|
|||||||
setTeams(data || [])
|
setTeams(data || [])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const installTeam = async (team: Team) => {
|
||||||
|
if (!team.repo_url || installingTeams.has(team.name)) return
|
||||||
|
setInstallingTeams(prev => new Set(prev).add(team.name))
|
||||||
|
try {
|
||||||
|
await fetch(`${API}/hub/install`, {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ repo: team.repo_url })
|
||||||
|
})
|
||||||
|
fetchAgents()
|
||||||
|
fetchTeams()
|
||||||
|
} finally {
|
||||||
|
setInstallingTeams(prev => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
next.delete(team.name)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const install = async () => {
|
const install = async () => {
|
||||||
if (!repo.trim()) return
|
if (!repo.trim()) return
|
||||||
setStatus('loading')
|
setStatus('loading')
|
||||||
@ -149,13 +133,6 @@ export function MarketPage() {
|
|||||||
fetchTeams()
|
fetchTeams()
|
||||||
}
|
}
|
||||||
|
|
||||||
const isSelected = (teamName: string) => {
|
|
||||||
if (selectedTeam) {
|
|
||||||
return selectedTeam.name === teamName
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 overflow-y-auto bg-[var(--bg-primary)]">
|
<div className="flex-1 overflow-y-auto bg-[var(--bg-primary)]">
|
||||||
<div className="max-w-4xl mx-auto p-6">
|
<div className="max-w-4xl mx-auto p-6">
|
||||||
@ -209,10 +186,10 @@ export function MarketPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedTeam && !isSelected(selectedTeam.name) && (
|
{selectedTeam && (
|
||||||
<TeamDetail
|
<TeamDetail
|
||||||
team={selectedTeam}
|
team={selectedTeam}
|
||||||
installed={teams.some(t => t.name === selectedTeam.name)}
|
installed={tab === 'teams' || teams.some(t => t.name === selectedTeam.name)}
|
||||||
onBack={() => setSelectedTeam(null)}
|
onBack={() => setSelectedTeam(null)}
|
||||||
onInstalled={() => {
|
onInstalled={() => {
|
||||||
setSelectedTeam(null)
|
setSelectedTeam(null)
|
||||||
@ -237,13 +214,13 @@ export function MarketPage() {
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{PRESET_TEAMS.map(team => {
|
{PRESET_TEAMS.map(team => {
|
||||||
const isInstalled = teams.some(t => t.name === team.name)
|
const isInstalled = teams.some(t => t.name === team.name)
|
||||||
|
const isInstalling = installingTeams.has(team.name)
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={team.name}
|
key={team.name}
|
||||||
className={`bg-[var(--bg-secondary)] border border-[var(--border)] rounded-xl p-4 hover:border-[var(--accent)] transition-all cursor-pointer ${
|
className={`bg-[var(--bg-secondary)] border border-[var(--border)] rounded-xl p-4 hover:border-[var(--accent)] transition-all ${
|
||||||
isInstalled ? 'ring-2 ring-[var(--accent)] ring-offset-2' : ''
|
isInstalled ? 'ring-2 ring-[var(--accent)] ring-offset-2' : ''
|
||||||
}`}
|
}`}
|
||||||
onClick={() => setSelectedTeam(team)}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between mb-3">
|
<div className="flex items-start justify-between mb-3">
|
||||||
<div>
|
<div>
|
||||||
@ -274,6 +251,7 @@ export function MarketPage() {
|
|||||||
{team.skills?.length || 0} Skills
|
{team.skills?.length || 0} Skills
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
{team.repo_url && (
|
{team.repo_url && (
|
||||||
<a
|
<a
|
||||||
href={team.repo_url}
|
href={team.repo_url}
|
||||||
@ -285,6 +263,26 @@ export function MarketPage() {
|
|||||||
查看详情
|
查看详情
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
{!isInstalled && team.repo_url && (
|
||||||
|
<button
|
||||||
|
onClick={() => installTeam(team)}
|
||||||
|
disabled={isInstalling}
|
||||||
|
className="ml-auto flex items-center gap-1.5 bg-[var(--accent)] hover:bg-[var(--accent-hover)] disabled:opacity-50 px-3 py-1.5 rounded-lg text-xs font-medium text-white transition-colors"
|
||||||
|
>
|
||||||
|
{isInstalling ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
安装中...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ShoppingCart className="w-3.5 h-3.5" />
|
||||||
|
立即雇佣
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@ -405,23 +403,25 @@ export function MarketPage() {
|
|||||||
<div
|
<div
|
||||||
key={team.name}
|
key={team.name}
|
||||||
onClick={() => setSelectedTeam(team)}
|
onClick={() => setSelectedTeam(team)}
|
||||||
className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-xl p-4 cursor-pointer hover:border-[var(--accent)] transition-colors"
|
className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-xl p-4 hover:border-[var(--accent)] transition-all cursor-pointer ring-2 ring-[var(--accent)] ring-offset-2"
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between mb-2">
|
<div className="flex items-start justify-between mb-3">
|
||||||
<h3 className="font-semibold">{team.name}</h3>
|
<div>
|
||||||
<ArrowRight className="w-4 h-4 text-[var(--text-muted)]" />
|
<h3 className="font-semibold mb-1">{team.name}</h3>
|
||||||
|
<p className="text-xs text-[var(--text-muted)]">{team.description || '暂无描述'}</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-[var(--text-muted)] mb-3 line-clamp-2">
|
<div className="w-8 h-8 bg-[var(--color-success)] rounded-full flex items-center justify-center text-white">
|
||||||
{team.description || '暂无描述'}
|
<Check className="w-4 h-4" />
|
||||||
</p>
|
</div>
|
||||||
<div className="flex items-center gap-3 text-xs text-[var(--text-secondary)]">
|
</div>
|
||||||
|
<div className="flex items-center gap-3 text-xs text-[var(--text-secondary)] mb-3">
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Users className="w-3.5 h-3.5" />
|
<Users className="w-3.5 h-3.5" />
|
||||||
{team.agents?.length || 0} agents
|
{team.agents?.length || 0} 成员
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Package className="w-3.5 h-3.5" />
|
<Package className="w-3.5 h-3.5" />
|
||||||
{team.skills?.length || 0} skills
|
{team.skills?.length || 0} Skills
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
139
web/src/components/MemberList.tsx
Normal file
139
web/src/components/MemberList.tsx
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Crown, Users } from 'lucide-react'
|
||||||
|
import { useStore } from '../store'
|
||||||
|
|
||||||
|
const API = '/api'
|
||||||
|
|
||||||
|
interface TeamInfo {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MemberList() {
|
||||||
|
const { activeRoomId, rooms, setRoomTeam } = useStore()
|
||||||
|
const [teams, setTeams] = useState<TeamInfo[]>([])
|
||||||
|
const [loadingTeam, setLoadingTeam] = useState(false)
|
||||||
|
|
||||||
|
const room = rooms.find(r => r.id === activeRoomId)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (room && !room.team) {
|
||||||
|
fetch(`${API}/teams`).then(r => r.json()).then(data => setTeams(data || []))
|
||||||
|
}
|
||||||
|
}, [room?.id, room?.team])
|
||||||
|
|
||||||
|
if (!room) {
|
||||||
|
return (
|
||||||
|
<div className="w-[200px] bg-[var(--channel-list-bg)] border-r border-[var(--border)] flex flex-col">
|
||||||
|
<div className="h-12 px-4 border-b border-[var(--border)] flex items-center">
|
||||||
|
<span className="text-sm font-semibold text-[var(--text-muted)]">成员</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 flex items-center justify-center text-[var(--text-muted)] text-xs">
|
||||||
|
选择群聊查看成员
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!room.team) {
|
||||||
|
return (
|
||||||
|
<div className="w-[200px] bg-[var(--channel-list-bg)] border-r border-[var(--border)] flex flex-col">
|
||||||
|
<div className="h-12 px-4 border-b border-[var(--border)] flex items-center">
|
||||||
|
<span className="text-sm font-semibold text-[var(--text-muted)]">成员</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 flex flex-col items-center justify-center gap-3 px-4">
|
||||||
|
<Users className="w-8 h-8 text-[var(--text-muted)]" />
|
||||||
|
<p className="text-xs text-[var(--text-muted)] text-center">选择一个团队开始工作</p>
|
||||||
|
<div className="w-full flex flex-col gap-1.5">
|
||||||
|
{teams.map(t => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
disabled={loadingTeam}
|
||||||
|
onClick={async () => {
|
||||||
|
setLoadingTeam(true)
|
||||||
|
await setRoomTeam(room.id, t.id)
|
||||||
|
setLoadingTeam(false)
|
||||||
|
}}
|
||||||
|
className="w-full text-left px-3 py-2 rounded-lg bg-[var(--bg-hover)] hover:bg-[var(--bg-active)] transition-colors"
|
||||||
|
>
|
||||||
|
<div className="text-xs font-medium truncate">{t.name}</div>
|
||||||
|
{t.description && <div className="text-[10px] text-[var(--text-muted)] truncate mt-0.5">{t.description}</div>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{teams.length === 0 && (
|
||||||
|
<p className="text-[10px] text-[var(--text-muted)] text-center">暂无已安装团队</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const memberAgents = room.members || []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-[200px] bg-[var(--channel-list-bg)] border-r border-[var(--border)] flex flex-col overflow-hidden">
|
||||||
|
<div className="h-12 px-4 border-b border-[var(--border)] flex items-center justify-between">
|
||||||
|
<span className="text-sm font-semibold text-[var(--text-muted)]">成员 — {1 + memberAgents.length}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
fetch(`${API}/teams`).then(r => r.json()).then(data => setTeams(data || []))
|
||||||
|
}}
|
||||||
|
title="切换团队"
|
||||||
|
className="text-[10px] text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors"
|
||||||
|
>
|
||||||
|
{room.team}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto py-2 scrollbar-thin">
|
||||||
|
<div className="px-2">
|
||||||
|
<MemberItem
|
||||||
|
name={room.master}
|
||||||
|
role="master"
|
||||||
|
status={room.status === 'thinking' ? 'thinking' : 'idle'}
|
||||||
|
/>
|
||||||
|
{memberAgents.map(name => (
|
||||||
|
<MemberItem
|
||||||
|
key={name}
|
||||||
|
name={name}
|
||||||
|
role="member"
|
||||||
|
status={room.status === 'working' && room.activeAgent === name ? 'working' : 'idle'}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MemberItem({ name, role, status }: { name: string; role: string; status: string }) {
|
||||||
|
const dotClass = status === 'thinking'
|
||||||
|
? 'bg-[var(--color-warning)] animate-pulse'
|
||||||
|
: status === 'working'
|
||||||
|
? 'bg-[var(--color-success)] animate-pulse'
|
||||||
|
: 'bg-[var(--text-muted)]'
|
||||||
|
|
||||||
|
const statusText = status === 'thinking' ? '思考中' : status === 'working' ? '工作中' : '空闲'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 py-1.5 px-2 rounded hover:bg-[var(--bg-hover)] transition-colors group mb-0.5">
|
||||||
|
<div className="relative flex-shrink-0">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-[var(--accent)] flex items-center justify-center text-white text-xs font-semibold">
|
||||||
|
{role === 'master' ? <Crown className="w-4 h-4" /> : name[0]?.toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div className={`absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full border-2 border-[var(--channel-list-bg)] ${dotClass}`} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{role === 'master' && <Crown className="w-3 h-3 text-[var(--color-warning)] flex-shrink-0" />}
|
||||||
|
<span className="text-sm font-medium truncate">{name}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-[var(--text-muted)] opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
{statusText}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { ChevronRight, ChevronLeft, Check, Hash, Sparkles, Key, User } from 'lucide-react'
|
import { ChevronRight, ChevronLeft, Check, Hash, Sparkles, Key, User } from 'lucide-react'
|
||||||
import MDEditor from '@uiw/react-md-editor'
|
import { MarkdownEditor } from './MarkdownEditor'
|
||||||
|
|
||||||
const API = '/api'
|
const API = '/api'
|
||||||
|
|
||||||
@ -211,8 +211,8 @@ export function Onboarding({ onComplete }: OnboardingProps) {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium mb-1.5 block">详细简介(支持 Markdown)</label>
|
<label className="text-sm font-medium mb-1.5 block">详细简介(支持 Markdown)</label>
|
||||||
<div className="border border-[var(--border)] rounded-lg overflow-hidden bg-[var(--bg-tertiary)]">
|
<div className="mt-2">
|
||||||
<MDEditor
|
<MarkdownEditor
|
||||||
value={profile || `# 我的简介
|
value={profile || `# 我的简介
|
||||||
|
|
||||||
## 我是谁
|
## 我是谁
|
||||||
@ -227,24 +227,8 @@ export function Onboarding({ onComplete }: OnboardingProps) {
|
|||||||
- 用 bullet point 列出要点
|
- 用 bullet point 列出要点
|
||||||
- 重要的决定给出 pros 和 cons
|
- 重要的决定给出 pros 和 cons
|
||||||
- 提供数据支持`}
|
- 提供数据支持`}
|
||||||
onChange={v => setProfile(v || '')}
|
onChange={(v: string) => setProfile(v)}
|
||||||
height={250}
|
height={250}
|
||||||
preview="edit"
|
|
||||||
visibleDragbar={false}
|
|
||||||
className="mt-2"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--bg-primary)',
|
|
||||||
}}
|
|
||||||
textareaProps={{
|
|
||||||
placeholder: '详细编辑你的个人简介...',
|
|
||||||
style: {
|
|
||||||
fontFamily: 'var(--font-mono), monospace',
|
|
||||||
fontSize: '13px',
|
|
||||||
backgroundColor: 'var(--bg-primary)',
|
|
||||||
color: 'var(--text-primary)',
|
|
||||||
padding: '1rem',
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
119
web/src/components/RightSidebar.tsx
Normal file
119
web/src/components/RightSidebar.tsx
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import ReactMarkdown from 'react-markdown'
|
||||||
|
import remarkGfm from 'remark-gfm'
|
||||||
|
import { ListTodo, FileText, X, ExternalLink } from 'lucide-react'
|
||||||
|
import { useStore } from '../store'
|
||||||
|
|
||||||
|
const API = '/api'
|
||||||
|
|
||||||
|
export function RightSidebar() {
|
||||||
|
const { activeRoomId, tasks, workspace } = useStore()
|
||||||
|
const [previewFile, setPreviewFile] = useState<{ name: string; content: string } | null>(null)
|
||||||
|
|
||||||
|
const tasksMd = activeRoomId ? (tasks[activeRoomId] || '') : ''
|
||||||
|
const files = activeRoomId ? (workspace[activeRoomId] || []) : []
|
||||||
|
|
||||||
|
const openFile = async (filename: string) => {
|
||||||
|
if (!activeRoomId) return
|
||||||
|
const d = await fetch(`${API}/rooms/${activeRoomId}/workspace/${filename}`).then(r => r.json())
|
||||||
|
setPreviewFile({ name: filename, content: d.content || '' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!activeRoomId) {
|
||||||
|
return (
|
||||||
|
<div className="w-[280px] bg-[var(--bg-secondary)] border-l border-[var(--border)] flex flex-col">
|
||||||
|
<div className="h-12 px-4 border-b border-[var(--border)] flex items-center">
|
||||||
|
<span className="text-sm font-semibold text-[var(--text-muted)]">工作区</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 flex items-center justify-center text-[var(--text-muted)] text-xs">
|
||||||
|
选择群聊查看工作区
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="w-[280px] bg-[var(--bg-secondary)] border-l border-[var(--border)] flex flex-col overflow-hidden">
|
||||||
|
<div className="h-12 px-4 border-b border-[var(--border)] flex items-center">
|
||||||
|
<span className="text-sm font-semibold text-[var(--text-muted)]">工作区</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto scrollbar-thin">
|
||||||
|
{/* TodoList */}
|
||||||
|
<section className="border-b border-[var(--border)]">
|
||||||
|
<div className="px-4 py-3 flex items-center gap-2 text-xs font-semibold text-[var(--text-muted)] uppercase">
|
||||||
|
<ListTodo className="w-3.5 h-3.5" />
|
||||||
|
任务列表
|
||||||
|
</div>
|
||||||
|
<div className="px-4 pb-4">
|
||||||
|
{tasksMd ? (
|
||||||
|
<div className="text-sm prose prose-sm max-w-none dark:prose-invert">
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{tasksMd}</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-[var(--text-muted)]">暂无任务</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Modified Files */}
|
||||||
|
<section>
|
||||||
|
<div className="px-4 py-3 flex items-center gap-2 text-xs font-semibold text-[var(--text-muted)] uppercase">
|
||||||
|
<FileText className="w-3.5 h-3.5" />
|
||||||
|
修改的文件 ({files.length})
|
||||||
|
</div>
|
||||||
|
<div className="px-4 pb-4">
|
||||||
|
{files.length > 0 ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{files.map(f => (
|
||||||
|
<button
|
||||||
|
key={f}
|
||||||
|
onClick={() => openFile(f)}
|
||||||
|
className="w-full flex items-center gap-2 text-sm text-[var(--accent)] hover:text-[var(--accent-hover)] py-1.5 px-2 rounded hover:bg-[var(--bg-hover)] transition-colors text-left"
|
||||||
|
>
|
||||||
|
<FileText className="w-4 h-4 flex-shrink-0" />
|
||||||
|
<span className="truncate">{f}</span>
|
||||||
|
<ExternalLink className="w-3 h-3 flex-shrink-0 ml-auto opacity-0 group-hover:opacity-100" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-[var(--text-muted)]">暂无修改的文件</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* File preview modal */}
|
||||||
|
{previewFile && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4"
|
||||||
|
onClick={() => setPreviewFile(null)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="bg-[var(--bg-secondary)] rounded-lg w-full max-w-3xl max-h-[80vh] flex flex-col shadow-2xl"
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--border)]">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FileText className="w-5 h-5 text-[var(--accent)]" />
|
||||||
|
<span className="font-semibold">{previewFile.name}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setPreviewFile(null)}
|
||||||
|
className="w-8 h-8 rounded flex items-center justify-center text-[var(--text-muted)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-primary)] transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-y-auto p-4 prose dark:prose-invert max-w-none text-sm">
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{previewFile.content}</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import MDEditor from '@uiw/react-md-editor'
|
import { ChevronLeft, Users, Package, BookOpen, ChevronDown, ChevronRight, Save, Loader2, Trash2, Plus, ExternalLink } from 'lucide-react'
|
||||||
import { ChevronLeft, Users, Package, BookOpen, ChevronDown, ChevronRight, Save, Loader2, Trash2, Plus } from 'lucide-react'
|
import { MarkdownEditor } from './MarkdownEditor'
|
||||||
|
|
||||||
const API = '/api'
|
const API = '/api'
|
||||||
|
|
||||||
@ -170,6 +170,18 @@ export function TeamDetail({ team, installed, onBack, onInstalled, onUninstalled
|
|||||||
<h2 className="font-semibold">{team.name}</h2>
|
<h2 className="font-semibold">{team.name}</h2>
|
||||||
</div>
|
</div>
|
||||||
{installed ? (
|
{installed ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{team.repo_url && (
|
||||||
|
<a
|
||||||
|
href={team.repo_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-1.5 text-[var(--text-muted)] hover:text-[var(--accent)] text-sm px-3 py-1.5 rounded-lg hover:bg-[var(--bg-hover)]"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-4 h-4" />
|
||||||
|
查看 GitHub
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
disabled={deleting}
|
disabled={deleting}
|
||||||
@ -182,6 +194,7 @@ export function TeamDetail({ team, installed, onBack, onInstalled, onUninstalled
|
|||||||
)}
|
)}
|
||||||
卸载团队
|
卸载团队
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={handleInstall}
|
onClick={handleInstall}
|
||||||
@ -204,20 +217,11 @@ export function TeamDetail({ team, installed, onBack, onInstalled, onUninstalled
|
|||||||
{/* Team info */}
|
{/* Team info */}
|
||||||
<div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-xl p-4">
|
<div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-xl p-4">
|
||||||
<p className="text-sm text-[var(--text-secondary)] mb-2">{team.description}</p>
|
<p className="text-sm text-[var(--text-secondary)] mb-2">{team.description}</p>
|
||||||
<div className="flex items-center gap-4 text-xs text-[var(--text-muted)]">
|
{team.author && (
|
||||||
{team.author && <span>作者: {team.author}</span>}
|
<div className="text-xs text-[var(--text-muted)]">
|
||||||
{team.repo_url && (
|
作者: {team.author}
|
||||||
<a
|
|
||||||
href={team.repo_url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-[var(--accent)] hover:underline flex items-center gap-1"
|
|
||||||
>
|
|
||||||
<Plus className="w-3 h-3" />
|
|
||||||
项目页面
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Agents */}
|
{/* Agents */}
|
||||||
@ -270,18 +274,16 @@ export function TeamDetail({ team, installed, onBack, onInstalled, onUninstalled
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{/* Editor */}
|
{/* Editor */}
|
||||||
<div className="h-64 border-t border-[var(--border)]">
|
<div className="border-t border-[var(--border)]">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex items-center justify-center h-64">
|
||||||
<Loader2 className="w-5 h-5 animate-spin text-[var(--accent)]" />
|
<Loader2 className="w-5 h-5 animate-spin text-[var(--accent)]" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<MDEditor
|
<MarkdownEditor
|
||||||
value={agentContent}
|
value={agentContent}
|
||||||
onChange={(v: string | undefined) => setAgentContent(v || '')}
|
onChange={(v: string) => setAgentContent(v)}
|
||||||
height={250}
|
height={250}
|
||||||
preview="edit"
|
|
||||||
visibleDragbar={false}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -372,13 +374,11 @@ export function TeamDetail({ team, installed, onBack, onInstalled, onUninstalled
|
|||||||
<div className="flex-1 flex flex-col border border-[var(--border)] rounded-lg overflow-hidden">
|
<div className="flex-1 flex flex-col border border-[var(--border)] rounded-lg overflow-hidden">
|
||||||
{selectedKnowledge ? (
|
{selectedKnowledge ? (
|
||||||
<>
|
<>
|
||||||
<div className="h-80">
|
<div className="flex-1">
|
||||||
<MDEditor
|
<MarkdownEditor
|
||||||
value={knowledgeContent}
|
value={knowledgeContent}
|
||||||
onChange={(v: string | undefined) => setKnowledgeContent(v || '')}
|
onChange={(v: string) => setKnowledgeContent(v)}
|
||||||
height={300}
|
height={320}
|
||||||
preview="edit"
|
|
||||||
visibleDragbar={false}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end px-4 py-2 bg-[var(--bg-tertiary)]">
|
<div className="flex justify-end px-4 py-2 bg-[var(--bg-tertiary)]">
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import MDEditor from '@uiw/react-md-editor'
|
|
||||||
import { User, Key, Save, Loader2 } from 'lucide-react'
|
import { User, Key, Save, Loader2 } from 'lucide-react'
|
||||||
|
import { MarkdownEditor } from './MarkdownEditor'
|
||||||
|
|
||||||
const API = '/api'
|
const API = '/api'
|
||||||
|
|
||||||
@ -142,25 +142,10 @@ export function UserSettings() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-auto p-4">
|
<div className="flex-1 overflow-auto p-4">
|
||||||
<MDEditor
|
<MarkdownEditor
|
||||||
value={profile}
|
value={profile}
|
||||||
onChange={(v) => setProfile(v || '')}
|
onChange={(v: string) => setProfile(v)}
|
||||||
height={300}
|
height={300}
|
||||||
preview="edit"
|
|
||||||
visibleDragbar={false}
|
|
||||||
className="border border-[var(--border)] rounded-lg overflow-hidden"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--bg-primary)',
|
|
||||||
}}
|
|
||||||
textareaProps={{
|
|
||||||
placeholder: '编辑个人资料介绍...',
|
|
||||||
style: {
|
|
||||||
fontFamily: 'var(--font-mono), monospace',
|
|
||||||
fontSize: '13px',
|
|
||||||
backgroundColor: 'var(--bg-primary)',
|
|
||||||
color: 'var(--text-primary)',
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -75,145 +75,6 @@
|
|||||||
--channel-list-bg: #2B2D31;
|
--channel-list-bg: #2B2D31;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Custom styling for react-md-editor - Complete override */
|
|
||||||
.w-md-editor {
|
|
||||||
border: 1px solid var(--border) !important;
|
|
||||||
border-radius: 8px !important;
|
|
||||||
background: var(--bg-tertiary) !important;
|
|
||||||
color: var(--text-primary) !important;
|
|
||||||
box-shadow: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.w-md-editor * {
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.w-md-editor-toolbar {
|
|
||||||
border-bottom: 1px solid var(--border) !important;
|
|
||||||
background: var(--bg-secondary) !important;
|
|
||||||
padding: 8px 12px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.w-md-editor-toolbar button {
|
|
||||||
color: var(--text-secondary) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.w-md-editor-toolbar button:hover {
|
|
||||||
background: var(--bg-hover) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.w-md-editor-toolbar button.active {
|
|
||||||
color: var(--accent) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.w-md-editor-text-pre > code,
|
|
||||||
.w-md-editor-text-input {
|
|
||||||
font-family: var(--font-mono), monospace !important;
|
|
||||||
font-size: 13px !important;
|
|
||||||
line-height: 1.6 !important;
|
|
||||||
color: var(--text-primary) !important;
|
|
||||||
background: transparent !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.w-md-editor-text-input {
|
|
||||||
caret-color: var(--text-primary) !important;
|
|
||||||
padding: 12px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.w-md-editor-text-pre {
|
|
||||||
padding: 12px !important;
|
|
||||||
background: var(--bg-primary) !important;
|
|
||||||
margin: 0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.w-md-editor-preview {
|
|
||||||
padding: 12px !important;
|
|
||||||
background: var(--bg-primary) !important;
|
|
||||||
color: var(--text-primary) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.w-md-editor-preview h1,
|
|
||||||
.w-md-editor-preview h2,
|
|
||||||
.w-md-editor-preview h3,
|
|
||||||
.w-md-editor-preview h4,
|
|
||||||
.w-md-editor-preview h5,
|
|
||||||
.w-md-editor-preview h6 {
|
|
||||||
color: var(--text-primary) !important;
|
|
||||||
border-bottom: 1px solid var(--border) !important;
|
|
||||||
padding-bottom: 0.3em !important;
|
|
||||||
margin-top: 1.5em !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.w-md-editor-preview a {
|
|
||||||
color: var(--accent) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.w-md-editor-preview code {
|
|
||||||
background: var(--bg-tertiary) !important;
|
|
||||||
padding: 2px 6px !important;
|
|
||||||
border-radius: 4px !important;
|
|
||||||
font-family: var(--font-mono), monospace !important;
|
|
||||||
font-size: 0.9em !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.w-md-editor-preview pre {
|
|
||||||
background: var(--bg-tertiary) !important;
|
|
||||||
padding: 12px !important;
|
|
||||||
border-radius: 6px !important;
|
|
||||||
overflow-x: auto !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.w-md-editor-preview pre code {
|
|
||||||
background: transparent !important;
|
|
||||||
padding: 0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.w-md-editor-preview blockquote {
|
|
||||||
border-left: 4px solid var(--accent) !important;
|
|
||||||
padding-left: 16px !important;
|
|
||||||
color: var(--text-secondary) !important;
|
|
||||||
margin: 1em 0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.w-md-editor-preview ul,
|
|
||||||
.w-md-editor-preview ol {
|
|
||||||
padding-left: 24px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.w-md-editor-preview table {
|
|
||||||
border-collapse: collapse !important;
|
|
||||||
width: 100% !important;
|
|
||||||
margin: 1em 0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.w-md-editor-preview th,
|
|
||||||
.w-md-editor-preview td {
|
|
||||||
border: 1px solid var(--border) !important;
|
|
||||||
padding: 8px 12px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.w-md-editor-preview th {
|
|
||||||
background: var(--bg-tertiary) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.w-md-editor-input {
|
|
||||||
background: var(--bg-primary) !important;
|
|
||||||
color: var(--text-primary) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.w-md-editor-text,
|
|
||||||
.w-md-editor-text-pre {
|
|
||||||
background: var(--bg-primary) !important;
|
|
||||||
color: var(--text-primary) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hide drag bar in certain contexts */
|
|
||||||
.w-md-editor .w-md-editor-bar {
|
|
||||||
background: var(--bg-tertiary) !important;
|
|
||||||
width: 4px !important;
|
|
||||||
cursor: col-resize !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
|
|||||||
@ -29,6 +29,7 @@ interface AppState {
|
|||||||
fetchSkills: () => Promise<void>
|
fetchSkills: () => Promise<void>
|
||||||
sendMessage: (roomId: string, content: string) => void
|
sendMessage: (roomId: string, content: string) => void
|
||||||
connectRoom: (roomId: string) => void
|
connectRoom: (roomId: string) => void
|
||||||
|
setRoomTeam: (roomId: string, team: string) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
const API = '/api'
|
const API = '/api'
|
||||||
@ -171,5 +172,18 @@ export const useStore = create<AppState>((set, get) => {
|
|||||||
set(s => ({ messages: { ...s.messages, [roomId]: [...(s.messages[roomId] || []), userMsg] } }))
|
set(s => ({ messages: { ...s.messages, [roomId]: [...(s.messages[roomId] || []), userMsg] } }))
|
||||||
ws[roomId]?.send(JSON.stringify({ type: 'user_message', content, user_name: userName }))
|
ws[roomId]?.send(JSON.stringify({ type: 'user_message', content, user_name: userName }))
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setRoomTeam: async (roomId, team) => {
|
||||||
|
const res = await fetch(`${API}/rooms/${roomId}/team`, {
|
||||||
|
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ team })
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
set(s => ({
|
||||||
|
rooms: s.rooms.map(r => r.id === roomId ? { ...r, team, master: data.master } : r)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@ -7,8 +7,11 @@ export interface Room {
|
|||||||
type: RoomType
|
type: RoomType
|
||||||
status: RoomStatus
|
status: RoomStatus
|
||||||
master: string
|
master: string
|
||||||
|
members?: string[]
|
||||||
activeAgent?: string
|
activeAgent?: string
|
||||||
action?: string
|
action?: string
|
||||||
|
color?: string
|
||||||
|
team?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user