feat: complete remaining features
- SkillsPage: skill list, detail view, create new skill - App.tsx: add Skills nav (4 tabs total) - RoomSidebar: agent dropdown multi-select for members - ChatView: workspace file preview modal, load message history on room open - room.go: message history persistence to history/YYYY-MM-DD.md, auto memory update after task - api/server.go: add createSkill, getWorkspaceFile, getMessages endpoints - Clean up unused Vite default files - Update plan.md with completed items and remaining tasks Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
de773586c7
commit
972c822338
@ -7,7 +7,8 @@
|
||||
"Bash(go:*)",
|
||||
"Bash(npm create:*)",
|
||||
"Bash(npm install:*)",
|
||||
"Bash(node:*)"
|
||||
"Bash(node:*)",
|
||||
"Bash(npm run:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,87 +12,52 @@
|
||||
- [x] `internal/llm/client.go` — OpenAI 兼容客户端,支持 DeepSeek/Kimi/Ollama/OpenAI,流式输出
|
||||
- [x] `internal/agent/agent.go` — AGENT.md frontmatter 解析,SOUL.md 加载,memory 读写,system prompt 构建
|
||||
- [x] `internal/skill/skill.go` — agentskills.io 标准,skill 发现/加载/XML 生成
|
||||
- [x] `internal/room/room.go` — 群配置加载,master orchestration 循环(分配→执行→review→迭代),WebSocket 事件广播
|
||||
- [x] `internal/hub/hub.go` — GitHub repo clone,团队包安装(agents/ + skills/)
|
||||
- [x] `internal/api/server.go` — Echo HTTP 服务,WebSocket hub,全部 REST 接口
|
||||
- [x] `cmd/server/main.go` — 入口,读取环境变量启动
|
||||
- [x] `internal/room/room.go` — 群配置加载,master orchestration 循环,消息历史持久化,memory 自动更新
|
||||
- [x] `internal/hub/hub.go` — GitHub repo clone,团队包安装
|
||||
- [x] `internal/api/server.go` — Echo HTTP 服务,WebSocket hub,全部 REST 接口(含 skill 创建、workspace 文件预览、消息历史)
|
||||
- [x] `cmd/server/main.go` — 入口
|
||||
|
||||
### React 前端
|
||||
- [x] Vite + React + TypeScript 项目初始化(`web/`)
|
||||
- [x] Tailwind CSS v4 + `@tailwindcss/vite` 配置
|
||||
- [x] `web/src/types.ts` — 所有类型定义
|
||||
- [x] `web/src/store.ts` — Zustand store,WebSocket 连接,消息流式拼接
|
||||
- [x] `web/src/App.tsx` — 三栏布局骨架,左侧导航(群聊/Agents/市场)
|
||||
- [x] `web/src/components/RoomSidebar.tsx` — 群列表,实时状态 badge,创建群表单
|
||||
- [x] `web/src/components/ChatView.tsx` — 消息流,角色样式,右侧面板(Members/Tasks/产物),抽屉按钮
|
||||
- [x] `web/src/components/AgentsPage.tsx` — Monaco MD 编辑器,AGENT.md/SOUL.md 编辑,创建/删除 agent
|
||||
- [x] `web/src/components/MarketPage.tsx` — GitHub 一键雇佣,发布说明
|
||||
- [x] Vite + React + TypeScript + Tailwind v4
|
||||
- [x] `web/src/types.ts` + `store.ts` — 类型定义,Zustand store,WebSocket 流式消息
|
||||
- [x] `App.tsx` — 四栏导航(群聊/Agents/Skills/市场)
|
||||
- [x] `RoomSidebar.tsx` — 群列表,实时状态,创建群(agent 下拉多选)
|
||||
- [x] `ChatView.tsx` — 消息流,右侧面板,workspace 文件预览 Modal
|
||||
- [x] `AgentsPage.tsx` — Monaco MD 编辑器,AGENT.md/SOUL.md 编辑
|
||||
- [x] `SkillsPage.tsx` — skill 列表,详情查看,新建 skill
|
||||
- [x] `MarketPage.tsx` — GitHub 一键雇佣,发布说明
|
||||
|
||||
### 配置文件
|
||||
- [x] `agents/example-master/AGENT.md` + `SOUL.md` — 示例 master agent
|
||||
- [x] `skills/example/SKILL.md` — 示例 skill
|
||||
- [x] `docs/plans/PRD.md` — 完整产品需求文档
|
||||
- [x] `README.md`
|
||||
### 构建验证
|
||||
- [x] `go build ./...` 通过
|
||||
- [x] `npm run build` 通过(386KB JS bundle)
|
||||
|
||||
---
|
||||
|
||||
## 待完成
|
||||
|
||||
### 紧急(下次开始先做)
|
||||
### 下次开始(优先级高)
|
||||
|
||||
- [ ] **提交代码到 git** — 所有代码已写好但还未 commit/push
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "feat: implement full agent-team platform"
|
||||
git push
|
||||
```
|
||||
|
||||
- [ ] **前端构建验证** — 确认 Tailwind v4 + Monaco Editor 能正常编译
|
||||
```bash
|
||||
cd web && npm run build
|
||||
```
|
||||
|
||||
- [ ] **后端编译验证** — 已通过 `go build ./...`,但需要实际运行测试
|
||||
```bash
|
||||
DEEPSEEK_API_KEY=xxx go run cmd/server/main.go
|
||||
```
|
||||
|
||||
### 功能补全
|
||||
|
||||
- [ ] **SkillsPage 组件** — skills 列表页面(查看/创建 skill),目前 store 有 `fetchSkills` 但没有对应页面
|
||||
- 在 `web/src/components/SkillsPage.tsx` 新建
|
||||
- App.tsx 加入 skills 导航入口
|
||||
|
||||
- [ ] **Agent memory 自动更新** — 任务完成后 master 自动总结经验写入 `agents/<name>/memory/`
|
||||
- 在 `internal/room/room.go` 的 orchestration 循环末尾调用 `agent.SaveMemory()`
|
||||
- 需要让 master 生成一段经验总结
|
||||
|
||||
- [ ] **消息历史持久化** — 目前消息只在内存,刷新页面丢失
|
||||
- 每条消息追加写入 `rooms/<id>/history/YYYY-MM-DD.md`
|
||||
- 前端启动时通过 REST 接口加载历史
|
||||
|
||||
- [ ] **右侧面板 Skills tab** — 点击 Skills 抽屉按钮时展示群内可用 skills
|
||||
- 调用 `GET /api/skills` 获取列表
|
||||
|
||||
- [ ] **Workspace 文件预览** — 点击产物文件名时展示 MD 内容
|
||||
- 新增 `GET /api/rooms/:id/workspace/:filename` 接口
|
||||
- 前端弹出 Modal 展示 ReactMarkdown 渲染
|
||||
|
||||
- [ ] **创建群时成员选择优化** — 目前是手动输入 agent 名,改为下拉多选
|
||||
- 调用 `GET /api/agents` 获取列表,渲染 checkbox
|
||||
|
||||
- [ ] **Leader 群的 orchestration** — 目前 room.go 的 Handle 只支持单 master,Leader 群需要广播给多个 master
|
||||
- [ ] **Leader 群 orchestration** — 目前 room.go 的 Handle 只支持单 master,Leader 群需要广播给多个 master
|
||||
- Leader 群:用户消息广播给所有 master,每个 master 在自己的部门群里处理
|
||||
- 需要在 Room 结构里区分 TypeLeader,Handle 时遍历所有 master
|
||||
|
||||
- [ ] **环境变量配置页** — 前端提供一个设置页,配置各 provider 的 API Key
|
||||
- 写入本地 `.env` 文件或通过 `PUT /api/config` 接口
|
||||
- [ ] **环境变量配置页** — 前端提供设置页,配置各 provider 的 API Key
|
||||
- 新增 `web/src/components/SettingsPage.tsx`
|
||||
- 后端 `PUT /api/config` 写入 `.env` 文件,启动时 `godotenv` 加载
|
||||
|
||||
### 已知问题
|
||||
- [ ] **实际测试** — 配置真实 DeepSeek API Key 跑通一次完整流程
|
||||
```bash
|
||||
export DEEPSEEK_API_KEY=your_key
|
||||
go run cmd/server/main.go
|
||||
# 另一个终端
|
||||
cd web && npm run dev
|
||||
```
|
||||
|
||||
- [ ] `web/src/App.css` 可以删除(Vite 默认生成,已不需要)
|
||||
- [ ] `web/src/assets/react.svg` 可以删除
|
||||
- [ ] `web/public/vite.svg` 可以删除
|
||||
- [ ] `main.tsx` 里的 `import './App.css'` 需要删除
|
||||
### 后续迭代
|
||||
|
||||
- [ ] Skill 脚本执行沙箱(scripts/ 目录下的脚本安全执行)
|
||||
- [ ] 用户认证
|
||||
- [ ] 消息搜索
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -3,10 +3,13 @@ package api
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/labstack/echo/v4"
|
||||
@ -59,10 +62,13 @@ func (s *Server) routes() {
|
||||
g.DELETE("/agents/:name", s.deleteAgent)
|
||||
g.GET("/skills", s.listSkills)
|
||||
g.GET("/skills/:name", s.getSkill)
|
||||
g.POST("/skills", s.createSkill)
|
||||
g.POST("/hub/install", s.hubInstall)
|
||||
g.GET("/rooms/:id/workspace", s.listWorkspace)
|
||||
g.GET("/rooms/:id/workspace/:file", s.getWorkspaceFile)
|
||||
g.GET("/rooms/:id/tasks", s.getTasks)
|
||||
g.GET("/rooms/:id/history", s.listHistory)
|
||||
g.GET("/rooms/:id/messages", s.getMessages)
|
||||
|
||||
s.e.GET("/ws/:roomID", s.wsHandler)
|
||||
}
|
||||
@ -294,3 +300,73 @@ func (s *Server) listHistory(c echo.Context) error {
|
||||
}
|
||||
return c.JSON(200, files)
|
||||
}
|
||||
|
||||
func (s *Server) createSkill(c echo.Context) error {
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if err := c.Bind(&body); err != nil {
|
||||
return err
|
||||
}
|
||||
dir := filepath.Join(s.skillsDir, body.Name)
|
||||
os.MkdirAll(dir, 0755)
|
||||
content := body.Content
|
||||
if content == "" {
|
||||
content = "---\nname: " + body.Name + "\ndescription: \n---\n\n# " + body.Name + "\n\n描述这个 skill 的用途和使用步骤。\n"
|
||||
}
|
||||
return os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(content), 0644)
|
||||
}
|
||||
|
||||
func (s *Server) getWorkspaceFile(c echo.Context) error {
|
||||
id := c.Param("id")
|
||||
file := c.Param("file")
|
||||
data, err := os.ReadFile(filepath.Join(s.roomsDir, id, "workspace", file))
|
||||
if err != nil {
|
||||
return c.JSON(404, map[string]string{"error": "not found"})
|
||||
}
|
||||
return c.JSON(200, map[string]string{"content": string(data)})
|
||||
}
|
||||
|
||||
func (s *Server) getMessages(c echo.Context) error {
|
||||
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")
|
||||
data, err := os.ReadFile(historyFile)
|
||||
if err != nil {
|
||||
return c.JSON(200, []interface{}{})
|
||||
}
|
||||
type msg struct {
|
||||
ID string `json:"id"`
|
||||
Agent string `json:"agent"`
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
var msgs []msg
|
||||
lines := strings.Split(string(data), "\n## ")
|
||||
for i, block := range lines {
|
||||
if block == "" {
|
||||
continue
|
||||
}
|
||||
// Parse "**[HH:MM:SS] agentName** (role)\n\ncontent"
|
||||
parts := strings.SplitN(block, "\n\n", 2)
|
||||
if len(parts) < 2 {
|
||||
continue
|
||||
}
|
||||
header := parts[0]
|
||||
content := strings.TrimSpace(parts[1])
|
||||
// Extract role and agent from header like "**[15:04:05] agentName** (role)"
|
||||
var agentName, role string
|
||||
fmt.Sscanf(header, "**[%*s] %s** (%s)", &agentName, &role)
|
||||
agentName = strings.TrimSuffix(agentName, "**")
|
||||
role = strings.TrimSuffix(role, ")")
|
||||
if agentName == "" {
|
||||
agentName = "unknown"
|
||||
}
|
||||
if role == "" {
|
||||
role = "member"
|
||||
}
|
||||
msgs = append(msgs, msg{ID: fmt.Sprintf("%d", i), Agent: agentName, Role: role, Content: content})
|
||||
}
|
||||
return c.JSON(200, msgs)
|
||||
}
|
||||
|
||||
@ -117,8 +117,23 @@ func (r *Room) setStatus(s Status, activeAgent, action string) {
|
||||
r.emit(Event{Type: EvtRoomStatus, Status: s, ActiveAgent: activeAgent, Action: action})
|
||||
}
|
||||
|
||||
// AppendHistory persists a message to today's history file.
|
||||
func (r *Room) AppendHistory(role, agentName, content string) {
|
||||
dir := filepath.Join(r.Dir, "history")
|
||||
os.MkdirAll(dir, 0755)
|
||||
filename := filepath.Join(dir, time.Now().Format("2006-01-02")+".md")
|
||||
line := fmt.Sprintf("\n**[%s] %s** (%s)\n\n%s\n", time.Now().Format("15:04:05"), agentName, role, content)
|
||||
f, err := os.OpenFile(filename, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
f.WriteString(line)
|
||||
}
|
||||
|
||||
// Handle processes a user message through master orchestration.
|
||||
func (r *Room) Handle(ctx context.Context, userMsg string) error {
|
||||
r.AppendHistory("user", "user", userMsg)
|
||||
r.setStatus(StatusThinking, "", "")
|
||||
|
||||
// Build master context
|
||||
@ -143,6 +158,7 @@ func (r *Room) Handle(ctx context.Context, userMsg string) error {
|
||||
}
|
||||
reply := masterReply.String()
|
||||
masterMsgs = append(masterMsgs, llm.NewMsg("assistant", reply))
|
||||
r.AppendHistory("master", r.master.Config.Name, reply)
|
||||
|
||||
// Parse assignments
|
||||
assignments := parseAssignments(reply)
|
||||
@ -177,6 +193,7 @@ func (r *Room) Handle(ctx context.Context, userMsg string) error {
|
||||
}
|
||||
result := memberReply.String()
|
||||
results.WriteString(fmt.Sprintf("[%s] %s\n", memberName, result))
|
||||
r.AppendHistory("member", memberName, result)
|
||||
|
||||
// Save workspace file if member produced a document
|
||||
if strings.Contains(result, "# ") {
|
||||
@ -199,9 +216,33 @@ func (r *Room) Handle(ctx context.Context, userMsg string) error {
|
||||
}
|
||||
|
||||
r.setStatus(StatusPending, "", "")
|
||||
|
||||
// Auto-update master memory after task completion
|
||||
go r.updateMasterMemory(context.Background(), userMsg, masterMsgs)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Room) updateMasterMemory(ctx context.Context, task string, msgs []llm.Message) {
|
||||
summaryPrompt := fmt.Sprintf("Based on this task: %q\nSummarize key learnings and patterns in 3-5 bullet points for future reference. Be concise.", task)
|
||||
memMsgs := append(msgs, llm.NewMsg("user", summaryPrompt))
|
||||
summary, err := r.master.Chat(ctx, memMsgs, nil)
|
||||
if err != nil || summary == "" {
|
||||
return
|
||||
}
|
||||
filename := time.Now().Format("2006-01") + ".md"
|
||||
existing, _ := os.ReadFile(filepath.Join(r.master.Dir, "memory", filename))
|
||||
content := string(existing) + fmt.Sprintf("\n## %s — %s\n\n%s\n", time.Now().Format("2006-01-02"), task[:min(50, len(task))], summary)
|
||||
r.master.SaveMemory(filename, content)
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func parseAssignments(text string) map[string]string {
|
||||
result := make(map[string]string)
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
|
||||
@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB |
@ -1,42 +0,0 @@
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
@ -2,6 +2,7 @@ import { useStore } from './store'
|
||||
import { RoomSidebar } from './components/RoomSidebar'
|
||||
import { ChatView } from './components/ChatView'
|
||||
import { AgentsPage } from './components/AgentsPage'
|
||||
import { SkillsPage } from './components/SkillsPage'
|
||||
import { MarketPage } from './components/MarketPage'
|
||||
|
||||
export default function App() {
|
||||
@ -12,11 +13,13 @@ export default function App() {
|
||||
<div className="flex flex-col w-12 bg-gray-900 border-r border-gray-700 items-center py-3 gap-3">
|
||||
<NavBtn icon="💬" label="群聊" active={page === 'chat'} onClick={() => setPage('chat')} />
|
||||
<NavBtn icon="🤖" label="Agents" active={page === 'agents'} onClick={() => setPage('agents')} />
|
||||
<NavBtn icon="🔧" label="Skills" active={page === 'skills'} onClick={() => setPage('skills')} />
|
||||
<NavBtn icon="🛒" label="市场" active={page === 'market'} onClick={() => setPage('market')} />
|
||||
</div>
|
||||
|
||||
{page === 'chat' && <><RoomSidebar /><ChatView /></>}
|
||||
{page === 'agents' && <AgentsPage />}
|
||||
{page === 'skills' && <SkillsPage />}
|
||||
{page === 'market' && <MarketPage />}
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 4.0 KiB |
@ -2,12 +2,15 @@ import { useEffect, useRef, useState } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { useStore } from '../store'
|
||||
import { Message } from '../types'
|
||||
import type { Message } from '../types'
|
||||
|
||||
const API = '/api'
|
||||
|
||||
export function ChatView() {
|
||||
const { activeRoomId, rooms, messages, tasks, workspace, sendMessage } = useStore()
|
||||
const [input, setInput] = useState('')
|
||||
const [drawer, setDrawer] = useState<null | 'skills' | 'history' | 'workspace'>(null)
|
||||
const [previewFile, setPreviewFile] = useState<{ name: string; content: string } | null>(null)
|
||||
const bottomRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const room = rooms.find(r => r.id === activeRoomId)
|
||||
@ -17,6 +20,11 @@ export function ChatView() {
|
||||
|
||||
useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [msgs])
|
||||
|
||||
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) return <div className="flex-1 flex items-center justify-center text-gray-400">选择一个群开始</div>
|
||||
|
||||
const statusLabel = room.status === 'working' && room.activeAgent
|
||||
@ -84,7 +92,8 @@ export function ChatView() {
|
||||
<section>
|
||||
<h3 className="text-xs font-semibold text-gray-400 uppercase mb-2">产物</h3>
|
||||
{files.map(f => (
|
||||
<div key={f} className="flex items-center gap-1 text-xs text-indigo-300 hover:text-indigo-200 cursor-pointer py-0.5">
|
||||
<div key={f} onClick={() => openFile(f)}
|
||||
className="flex items-center gap-1 text-xs text-indigo-300 hover:text-indigo-200 cursor-pointer py-0.5">
|
||||
<span>📄</span><span className="truncate">{f}</span>
|
||||
</div>
|
||||
))}
|
||||
@ -111,10 +120,25 @@ export function ChatView() {
|
||||
<button onClick={() => setDrawer(null)} className="text-gray-400 hover:text-white">✕</button>
|
||||
</div>
|
||||
{drawer === 'workspace' && files.map(f => (
|
||||
<div key={f} className="text-sm text-indigo-300 py-1 cursor-pointer hover:text-indigo-200">📄 {f}</div>
|
||||
<div key={f} onClick={() => openFile(f)} className="text-sm text-indigo-300 py-1 cursor-pointer hover:text-indigo-200">📄 {f}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File preview modal */}
|
||||
{previewFile && (
|
||||
<div className="absolute inset-0 bg-black/60 flex items-center justify-center z-20" onClick={() => setPreviewFile(null)}>
|
||||
<div className="bg-gray-800 rounded-lg w-3/4 max-h-[80vh] flex flex-col" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-700">
|
||||
<span className="font-semibold text-sm">📄 {previewFile.name}</span>
|
||||
<button onClick={() => setPreviewFile(null)} className="text-gray-400 hover:text-white">✕</button>
|
||||
</div>
|
||||
<div className="overflow-y-auto p-4 prose prose-invert max-w-none text-sm">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{previewFile.content}</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useStore } from '../store'
|
||||
|
||||
const API = '/api'
|
||||
|
||||
@ -4,18 +4,27 @@ import { useStore } from '../store'
|
||||
const API = '/api'
|
||||
|
||||
export function RoomSidebar() {
|
||||
const { rooms, activeRoomId, setActiveRoom, fetchRooms, agents } = useStore()
|
||||
const { rooms, activeRoomId, setActiveRoom, fetchRooms, agents, fetchAgents } = useStore()
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [form, setForm] = useState({ name: '', type: 'dept', master: '', members: '' })
|
||||
const [form, setForm] = useState({ name: '', type: 'dept', master: '', members: [] as string[] })
|
||||
|
||||
useEffect(() => { fetchRooms() }, [])
|
||||
useEffect(() => { fetchRooms(); fetchAgents() }, [])
|
||||
|
||||
const toggleMember = (name: string) => {
|
||||
setForm(f => ({
|
||||
...f,
|
||||
members: f.members.includes(name) ? f.members.filter(m => m !== name) : [...f.members, name]
|
||||
}))
|
||||
}
|
||||
|
||||
const create = async () => {
|
||||
if (!form.name || !form.master) return
|
||||
await fetch(`${API}/rooms`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: form.name, type: form.type, master: form.master, members: form.members.split(',').map(s => s.trim()).filter(Boolean) })
|
||||
body: JSON.stringify({ name: form.name, type: form.type, master: form.master, members: form.members })
|
||||
})
|
||||
setCreating(false)
|
||||
setForm({ name: '', type: 'dept', master: '', members: [] })
|
||||
fetchRooms()
|
||||
}
|
||||
|
||||
@ -25,6 +34,8 @@ export function RoomSidebar() {
|
||||
return 'bg-gray-500'
|
||||
}
|
||||
|
||||
const availableMembers = agents.filter(a => a.name !== form.master)
|
||||
|
||||
return (
|
||||
<div className="w-56 bg-gray-900 border-r border-gray-700 flex flex-col">
|
||||
<div className="p-3 border-b border-gray-700 flex items-center justify-between">
|
||||
@ -34,14 +45,33 @@ export function RoomSidebar() {
|
||||
|
||||
{creating && (
|
||||
<div className="p-3 border-b border-gray-700 space-y-2 text-xs">
|
||||
<input className="w-full bg-gray-700 rounded px-2 py-1" placeholder="群名称" value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} />
|
||||
<select className="w-full bg-gray-700 rounded px-2 py-1" value={form.type} onChange={e => setForm(f => ({ ...f, type: e.target.value }))}>
|
||||
<input className="w-full bg-gray-700 rounded px-2 py-1" placeholder="群名称" value={form.name}
|
||||
onChange={e => setForm(f => ({ ...f, name: e.target.value }))} />
|
||||
<select className="w-full bg-gray-700 rounded px-2 py-1" value={form.type}
|
||||
onChange={e => setForm(f => ({ ...f, type: e.target.value }))}>
|
||||
<option value="dept">部门群</option>
|
||||
<option value="leader">Leader 群</option>
|
||||
</select>
|
||||
<input className="w-full bg-gray-700 rounded px-2 py-1" placeholder="master agent 名" value={form.master} onChange={e => setForm(f => ({ ...f, master: e.target.value }))} />
|
||||
<input className="w-full bg-gray-700 rounded px-2 py-1" placeholder="成员(逗号分隔)" value={form.members} onChange={e => setForm(f => ({ ...f, members: e.target.value }))} />
|
||||
<button onClick={create} className="w-full bg-indigo-600 hover:bg-indigo-500 rounded py-1">创建</button>
|
||||
<select className="w-full bg-gray-700 rounded px-2 py-1" value={form.master}
|
||||
onChange={e => setForm(f => ({ ...f, master: e.target.value }))}>
|
||||
<option value="">选择 master agent</option>
|
||||
{agents.map(a => <option key={a.name} value={a.name}>{a.name}</option>)}
|
||||
</select>
|
||||
{form.type === 'dept' && availableMembers.length > 0 && (
|
||||
<div className="bg-gray-700 rounded p-2 space-y-1 max-h-28 overflow-y-auto">
|
||||
<div className="text-gray-400 mb-1">选择成员:</div>
|
||||
{availableMembers.map(a => (
|
||||
<label key={a.name} className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" checked={form.members.includes(a.name)} onChange={() => toggleMember(a.name)} />
|
||||
<span>{a.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-1">
|
||||
<button onClick={create} className="flex-1 bg-indigo-600 hover:bg-indigo-500 rounded py-1">创建</button>
|
||||
<button onClick={() => setCreating(false)} className="bg-gray-600 hover:bg-gray-500 rounded px-2 py-1">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
82
web/src/components/SkillsPage.tsx
Normal file
82
web/src/components/SkillsPage.tsx
Normal file
@ -0,0 +1,82 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import Editor from '@monaco-editor/react'
|
||||
import { useStore } from '../store'
|
||||
|
||||
const API = '/api'
|
||||
|
||||
export function SkillsPage() {
|
||||
const { skills, fetchSkills } = useStore()
|
||||
const [selected, setSelected] = useState<string | null>(null)
|
||||
const [body, setBody] = useState('')
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [newName, setNewName] = useState('')
|
||||
|
||||
useEffect(() => { fetchSkills() }, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selected) return
|
||||
fetch(`${API}/skills/${selected}`).then(r => r.json()).then(d => setBody(d.body || ''))
|
||||
}, [selected])
|
||||
|
||||
const create = async () => {
|
||||
if (!newName.trim()) return
|
||||
const content = `---\nname: ${newName.trim()}\ndescription: \n---\n\n# ${newName.trim()}\n\n描述这个 skill 的用途和使用步骤。\n`
|
||||
await fetch(`${API}/agents`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: newName.trim() }) })
|
||||
// Use skill create endpoint
|
||||
await fetch(`${API}/skills`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: newName.trim(), content })
|
||||
})
|
||||
setNewName('')
|
||||
setCreating(false)
|
||||
fetchSkills()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Skill list */}
|
||||
<div className="w-48 border-r border-gray-700 flex flex-col">
|
||||
<div className="p-3 border-b border-gray-700">
|
||||
{creating ? (
|
||||
<div className="flex gap-1">
|
||||
<input className="flex-1 bg-gray-700 rounded px-2 py-1 text-xs" placeholder="skill 名" value={newName}
|
||||
onChange={e => setNewName(e.target.value)} onKeyDown={e => e.key === 'Enter' && create()} autoFocus />
|
||||
<button onClick={create} className="bg-indigo-600 hover:bg-indigo-500 px-2 py-1 rounded text-xs">✓</button>
|
||||
<button onClick={() => setCreating(false)} className="bg-gray-600 hover:bg-gray-500 px-2 py-1 rounded text-xs">✕</button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => setCreating(true)} className="w-full bg-gray-700 hover:bg-gray-600 rounded px-2 py-1 text-xs text-left">+ 新建 Skill</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{(skills || []).map(s => (
|
||||
<div key={s.name} onClick={() => setSelected(s.name)}
|
||||
className={`px-3 py-2 cursor-pointer hover:bg-gray-700 ${selected === s.name ? 'bg-gray-700' : ''}`}>
|
||||
<div className="text-sm truncate">{s.name}</div>
|
||||
<div className="text-xs text-gray-400 truncate">{s.description}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Skill detail / editor */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{selected ? (
|
||||
<>
|
||||
<div className="px-4 py-2 border-b border-gray-700 flex items-center gap-2">
|
||||
<span className="font-semibold text-sm">{selected}</span>
|
||||
<span className="text-xs text-gray-400 ml-1">SKILL.md</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Editor height="100%" defaultLanguage="markdown" theme="vs-dark" value={body}
|
||||
onChange={v => setBody(v || '')}
|
||||
options={{ minimap: { enabled: false }, wordWrap: 'on', fontSize: 13, readOnly: true }} />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-gray-400 text-sm">选择一个 skill 查看详情</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
import { create } from 'zustand'
|
||||
import { Room, Message, AgentInfo, SkillMeta, WsEvent } from './types'
|
||||
import type { Room, Message, AgentInfo, SkillMeta, WsEvent } from './types'
|
||||
|
||||
interface AppState {
|
||||
rooms: Room[]
|
||||
@ -44,6 +44,10 @@ export const useStore = create<AppState>((set, get) => ({
|
||||
fetch(`${API}/rooms/${id}/workspace`).then(r => r.json()).then(files => {
|
||||
set(s => ({ workspace: { ...s.workspace, [id]: files || [] } }))
|
||||
})
|
||||
// Load message history
|
||||
fetch(`${API}/rooms/${id}/messages`).then(r => r.json()).then(msgs => {
|
||||
if (msgs?.length) set(s => ({ messages: { ...s.messages, [id]: msgs } }))
|
||||
}).catch(() => {})
|
||||
},
|
||||
|
||||
fetchRooms: async () => {
|
||||
@ -96,8 +100,7 @@ export const useStore = create<AppState>((set, get) => ({
|
||||
},
|
||||
|
||||
sendMessage: (roomId, content) => {
|
||||
const { ws, messages } = get()
|
||||
// Add user message locally
|
||||
const { ws } = get()
|
||||
const userMsg = { id: Date.now().toString(), agent: 'user', role: 'user' as const, content }
|
||||
set(s => ({ messages: { ...s.messages, [roomId]: [...(s.messages[roomId] || []), userMsg] } }))
|
||||
ws[roomId]?.send(JSON.stringify({ type: 'user_message', content }))
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user