mirror of
https://github.com/eggent-ai/eggent.git
synced 2026-05-13 15:46:00 +00:00
release: prepare v0.1.1 unified context for GitHub
This commit is contained in:
26
.github/release_template.md
vendored
Normal file
26
.github/release_template.md
vendored
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
## Eggent v{{VERSION}} - {{NAME}}
|
||||||
|
|
||||||
|
One-line summary of what changed and why it matters.
|
||||||
|
|
||||||
|
### Highlights
|
||||||
|
|
||||||
|
- Highlight 1
|
||||||
|
- Highlight 2
|
||||||
|
- Highlight 3
|
||||||
|
|
||||||
|
### Platform Coverage
|
||||||
|
|
||||||
|
- Dashboard:
|
||||||
|
- API:
|
||||||
|
- Integrations:
|
||||||
|
|
||||||
|
### Upgrade Notes
|
||||||
|
|
||||||
|
- Compatibility:
|
||||||
|
- Migration:
|
||||||
|
- Operational changes:
|
||||||
|
|
||||||
|
### Links
|
||||||
|
|
||||||
|
- Full notes: `docs/releases/{{FILE}}`
|
||||||
|
- README: `README.md`
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -40,3 +40,6 @@ yarn-error.log*
|
|||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
# python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
|||||||
16
CHANGELOG.md
Normal file
16
CHANGELOG.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## [0.1.1] - 2026-03-03
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- `PUT /api/projects/[id]/mcp` endpoint for saving raw MCP config content.
|
||||||
|
- Inline MCP JSON editor with save/reset in `Dashboard -> MCP`.
|
||||||
|
- Inline MCP JSON editor with save/reset in project details context panel.
|
||||||
|
- Editable project instructions with save/reset in project details.
|
||||||
|
- Release documentation set in `docs/releases/`.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- MCP content validation and normalization before writing `.meta/mcp/servers.json`.
|
||||||
|
- Package/app health version updated to `0.1.1`.
|
||||||
@@ -20,6 +20,12 @@ Built-in platform capabilities:
|
|||||||
|
|
||||||
The app runs as a Next.js service and stores runtime state on disk (`./data`).
|
The app runs as a Next.js service and stores runtime state on disk (`./data`).
|
||||||
|
|
||||||
|
## Releases
|
||||||
|
|
||||||
|
- Latest release snapshot: [0.1.1 - Unified Context](./docs/releases/0.1.1-unified-context.md)
|
||||||
|
- GitHub release body (ready to paste): [v0.1.1](./docs/releases/github-v0.1.1.md)
|
||||||
|
- Release archive: [docs/releases/README.md](./docs/releases/README.md)
|
||||||
|
|
||||||
## Contributing and Support
|
## Contributing and Support
|
||||||
|
|
||||||
- Contributing guide: [CONTRIBUTING.md](./CONTRIBUTING.md)
|
- Contributing guide: [CONTRIBUTING.md](./CONTRIBUTING.md)
|
||||||
|
|||||||
82
docs/releases/0.1.1-unified-context.md
Normal file
82
docs/releases/0.1.1-unified-context.md
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
# Eggent 0.1.1 - Unified Context
|
||||||
|
|
||||||
|
Date: 2026-03-03
|
||||||
|
Type: Generalized release snapshot
|
||||||
|
|
||||||
|
## Release Name
|
||||||
|
`Unified Context`
|
||||||
|
|
||||||
|
This release combines current platform capabilities into one coherent milestone: project-centric agent work, persistent memory and knowledge, MCP/skills extensibility, scheduled automation, and external integrations.
|
||||||
|
|
||||||
|
## What Is Included
|
||||||
|
|
||||||
|
### 1) Workspace and Projects
|
||||||
|
- Multi-project workspace with chat isolation by project.
|
||||||
|
- Project profile: name, description, instructions, memory mode (`isolated`/`global`).
|
||||||
|
- Full project details page with project context, cron jobs, and knowledge base in one place.
|
||||||
|
- First-run onboarding flow: credentials, first project, model setup, Telegram, and starter skills.
|
||||||
|
|
||||||
|
### 2) Agent Runtime and Tooling
|
||||||
|
- Agent chat loop with tool calls and persistent chat history.
|
||||||
|
- Built-in tool families: code execution, memory operations, knowledge search, web search, cron automation, subordinate-agent call support.
|
||||||
|
- Per-project work directory and context-aware routing.
|
||||||
|
|
||||||
|
### 3) Memory and Knowledge
|
||||||
|
- Persistent vector memory with search and deletion UI.
|
||||||
|
- Project knowledge ingestion via file upload.
|
||||||
|
- Supported ingestion formats: `txt`, `md`, `json`, `csv`, `pdf`, `docx`, `xlsx`, `xls`, images (`png`, `jpg`, `jpeg`, `gif`, `bmp`, `webp`).
|
||||||
|
- Knowledge chunk inspection and memory browsing in dashboard.
|
||||||
|
|
||||||
|
### 4) Skills Platform
|
||||||
|
- Bundled skills catalog with per-project installation.
|
||||||
|
- Installed project skills inspection with full `SKILL.md` view.
|
||||||
|
- Current bundled catalog size: `38` skills.
|
||||||
|
- Bundled skills included: `bear-notes`, `bluebubbles`, `camsnap`, `canvas`, `coding-agent`, `discord`, `excalidraw`, `gemini`, `gh-issues`, `gifgrep`, `github`, `healthcheck`, `imsg`, `last30days`, `mcporter`, `model-usage`, `nano-banana-pro`, `nano-pdf`, `notion`, `obsidian`, `openai-image-gen`, `openai-whisper`, `openai-whisper-api`, `openhue`, `oracle`, `ordercli`, `playwright-cli`, `remotion`, `session-logs`, `skill-creator`, `slack`, `summarize`, `things-mac`, `tmux`, `trello`, `video-frames`, `voice-call`, `weather`.
|
||||||
|
|
||||||
|
### 5) MCP Integration
|
||||||
|
- MCP configuration storage per project at `.meta/mcp/servers.json`.
|
||||||
|
- MCP server normalization for Cursor-style `mcpServers` and legacy `servers` formats.
|
||||||
|
- MCP browser/editor page for all projects.
|
||||||
|
- Project details context panel with inline MCP editing.
|
||||||
|
|
||||||
|
### 6) Cron Automation
|
||||||
|
- Per-project cron jobs with three schedule modes: one-time (`at`), interval (`every`), cron expression (`cron`).
|
||||||
|
- Manual run, enable/disable, delete, and run history inspection.
|
||||||
|
- Optional Telegram delivery target and per-job timeout.
|
||||||
|
|
||||||
|
### 7) External API and Session Context
|
||||||
|
- External message endpoint: `POST /api/external/message`.
|
||||||
|
- Project context resolution across messages and sessions.
|
||||||
|
- External API token management with rotation UI.
|
||||||
|
|
||||||
|
### 8) Messenger Integration
|
||||||
|
- Telegram integration management in dashboard.
|
||||||
|
- Webhook setup/reconnect/disconnect flows.
|
||||||
|
- Access-code gating and allowlist management.
|
||||||
|
- Telegram command set: `/start`, `/help`, `/code <access_code>`, `/new`.
|
||||||
|
|
||||||
|
### 9) Settings, Models, and Security
|
||||||
|
- Model configuration wizards for chat and embeddings.
|
||||||
|
- Provider support: OpenAI, Anthropic, Google, OpenRouter, Ollama, custom.
|
||||||
|
- Code execution controls (enable/timeout/max output).
|
||||||
|
- Memory and search provider controls (Tavily, SearXNG, disabled).
|
||||||
|
- Dashboard credentials management (`/api/auth/credentials`).
|
||||||
|
|
||||||
|
### 10) Operations and Delivery
|
||||||
|
- Install modes: one-command installer, local production, Docker production, manual.
|
||||||
|
- Health endpoint: `GET /api/health`.
|
||||||
|
- Realtime UI sync endpoint and disk-first data persistence under `./data`.
|
||||||
|
|
||||||
|
## New in 0.1.1
|
||||||
|
|
||||||
|
- Added `PUT /api/projects/[id]/mcp` for saving raw MCP config content.
|
||||||
|
- Added MCP raw JSON editing with save/reset on `Dashboard -> MCP` and project details context section.
|
||||||
|
- Added editable project instructions with save/reset on project details page.
|
||||||
|
- Added MCP content validation and normalization before writing `servers.json`.
|
||||||
|
- Bumped package version to `0.1.1`.
|
||||||
|
- Updated health endpoint version response to `0.1.1`.
|
||||||
|
|
||||||
|
## Coverage Checklist (No Module Left Out)
|
||||||
|
|
||||||
|
- Dashboard pages included in this release: `chat`, `projects`, `memory`, `skills`, `mcp`, `cron`, `settings`, `api`, `messengers`.
|
||||||
|
- API surface included in this release: auth, chat, projects, skills, MCP, memory, knowledge, cron, external API, Telegram integration, files, models, settings, health, events.
|
||||||
7
docs/releases/README.md
Normal file
7
docs/releases/README.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Releases
|
||||||
|
|
||||||
|
This directory contains release summaries and publish-ready notes.
|
||||||
|
|
||||||
|
| Version | Name | Date | Notes |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `0.1.1` | Unified Context | 2026-03-03 | [Full snapshot](./0.1.1-unified-context.md), [GitHub body](./github-v0.1.1.md) |
|
||||||
29
docs/releases/github-v0.1.1.md
Normal file
29
docs/releases/github-v0.1.1.md
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
## Eggent v0.1.1 - Unified Context
|
||||||
|
|
||||||
|
Generalized release that consolidates the full platform surface into one project-centric workflow: chat + tools, memory + knowledge, skills + MCP, cron automation, and external messaging integrations.
|
||||||
|
|
||||||
|
### Highlights
|
||||||
|
|
||||||
|
- Added raw MCP config save endpoint: `PUT /api/projects/[id]/mcp`.
|
||||||
|
- Added direct `servers.json` editing with save/reset in `Dashboard -> MCP`.
|
||||||
|
- Added same MCP editing controls in project details context panel.
|
||||||
|
- Added editable project instructions with save/reset in project details.
|
||||||
|
- Added MCP content validation/normalization before writing `.meta/mcp/servers.json`.
|
||||||
|
- Version bump to `0.1.1` across package metadata and health response.
|
||||||
|
|
||||||
|
### Platform Coverage
|
||||||
|
|
||||||
|
- Dashboard modules: `chat`, `projects`, `memory`, `skills`, `mcp`, `cron`, `settings`, `api`, `messengers`.
|
||||||
|
- API modules: auth, chat, projects, skills, MCP, memory, knowledge, cron, external API, Telegram integration, files, models, settings, health, realtime events.
|
||||||
|
- Bundled skills catalog: `38` skills available for per-project install.
|
||||||
|
|
||||||
|
### Upgrade Notes
|
||||||
|
|
||||||
|
- Existing MCP configs continue to work (Cursor `mcpServers` and legacy `servers` are both supported).
|
||||||
|
- `GET /api/health` now reports version `0.1.1`.
|
||||||
|
- No migration step required for existing `data/` projects.
|
||||||
|
|
||||||
|
### Links
|
||||||
|
|
||||||
|
- Full release snapshot: `docs/releases/0.1.1-unified-context.md`
|
||||||
|
- Installation and update guide: `README.md`
|
||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "design-vibe",
|
"name": "design-vibe",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "design-vibe",
|
"name": "design-vibe",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/anthropic": "^3.0.37",
|
"@ai-sdk/anthropic": "^3.0.37",
|
||||||
"@ai-sdk/google": "^3.0.21",
|
"@ai-sdk/google": "^3.0.21",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "design-vibe",
|
"name": "design-vibe",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|||||||
@@ -2,6 +2,6 @@ export async function GET() {
|
|||||||
return Response.json({
|
return Response.json({
|
||||||
status: "ok",
|
status: "ok",
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
version: "0.1.0",
|
version: "0.1.1",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
getProject,
|
getProject,
|
||||||
getProjectMcpServersPath,
|
getProjectMcpServersPath,
|
||||||
loadProjectMcpServers,
|
loadProjectMcpServers,
|
||||||
|
saveProjectMcpServersContent,
|
||||||
} from "@/lib/storage/project-store";
|
} from "@/lib/storage/project-store";
|
||||||
|
|
||||||
function isNotFoundError(error: unknown): boolean {
|
function isNotFoundError(error: unknown): boolean {
|
||||||
@@ -41,3 +42,42 @@ export async function GET(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function PUT(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const { id } = await params;
|
||||||
|
const project = await getProject(id);
|
||||||
|
if (!project) {
|
||||||
|
return NextResponse.json({ error: "Project not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await req.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Invalid JSON payload" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const content =
|
||||||
|
body && typeof body === "object" && "content" in body
|
||||||
|
? (body as { content?: unknown }).content
|
||||||
|
: undefined;
|
||||||
|
if (typeof content !== "string") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Field "content" must be a string.' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await saveProjectMcpServersContent(id, content);
|
||||||
|
if (!result.success) {
|
||||||
|
return NextResponse.json({ error: result.error }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
content: result.content,
|
||||||
|
servers: result.servers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react";
|
|||||||
import { AppSidebar } from "@/components/app-sidebar";
|
import { AppSidebar } from "@/components/app-sidebar";
|
||||||
import { SiteHeader } from "@/components/site-header";
|
import { SiteHeader } from "@/components/site-header";
|
||||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Globe, Loader2, Terminal, Wrench } from "lucide-react";
|
import { Globe, Loader2, Terminal, Wrench } from "lucide-react";
|
||||||
import { useAppStore } from "@/store/app-store";
|
import { useAppStore } from "@/store/app-store";
|
||||||
@@ -74,14 +75,19 @@ function normalizeServers(input: unknown): McpServerItem[] {
|
|||||||
return servers;
|
return servers;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const EMPTY_MCP_JSON = JSON.stringify({ mcpServers: {} }, null, 2);
|
||||||
|
|
||||||
export default function McpPage() {
|
export default function McpPage() {
|
||||||
const { projects, setProjects, activeProjectId } = useAppStore();
|
const { projects, setProjects, activeProjectId } = useAppStore();
|
||||||
const [selectedProjectId, setSelectedProjectId] = useState("");
|
const [selectedProjectId, setSelectedProjectId] = useState("");
|
||||||
const [servers, setServers] = useState<McpServerItem[]>([]);
|
const [servers, setServers] = useState<McpServerItem[]>([]);
|
||||||
const [rawContent, setRawContent] = useState<string | null>(null);
|
const [rawContent, setRawContent] = useState<string | null>(null);
|
||||||
|
const [draftContent, setDraftContent] = useState(EMPTY_MCP_JSON);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
const [projectsLoading, setProjectsLoading] = useState(false);
|
const [projectsLoading, setProjectsLoading] = useState(false);
|
||||||
const [statusMessage, setStatusMessage] = useState<string | null>(null);
|
const [statusMessage, setStatusMessage] = useState<string | null>(null);
|
||||||
|
const [statusTone, setStatusTone] = useState<"success" | "error" | null>(null);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -131,7 +137,9 @@ export default function McpPage() {
|
|||||||
if (!projectId) {
|
if (!projectId) {
|
||||||
setServers([]);
|
setServers([]);
|
||||||
setRawContent(null);
|
setRawContent(null);
|
||||||
|
setDraftContent(EMPTY_MCP_JSON);
|
||||||
setStatusMessage(null);
|
setStatusMessage(null);
|
||||||
|
setStatusTone(null);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -139,6 +147,7 @@ export default function McpPage() {
|
|||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setStatusMessage(null);
|
setStatusMessage(null);
|
||||||
|
setStatusTone(null);
|
||||||
const res = await fetch(`/api/projects/${encodeURIComponent(projectId)}/mcp`);
|
const res = await fetch(`/api/projects/${encodeURIComponent(projectId)}/mcp`);
|
||||||
const payload = await res.json();
|
const payload = await res.json();
|
||||||
|
|
||||||
@@ -148,22 +157,75 @@ export default function McpPage() {
|
|||||||
? payload.error
|
? payload.error
|
||||||
: "Failed to load MCP servers";
|
: "Failed to load MCP servers";
|
||||||
setStatusMessage(message);
|
setStatusMessage(message);
|
||||||
|
setStatusTone("error");
|
||||||
setServers([]);
|
setServers([]);
|
||||||
setRawContent(null);
|
setRawContent(null);
|
||||||
|
setDraftContent(EMPTY_MCP_JSON);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setRawContent(typeof payload?.content === "string" ? payload.content : null);
|
const content =
|
||||||
|
typeof payload?.content === "string" ? payload.content : null;
|
||||||
|
setRawContent(content);
|
||||||
|
setDraftContent(content ?? EMPTY_MCP_JSON);
|
||||||
setServers(normalizeServers(payload?.servers));
|
setServers(normalizeServers(payload?.servers));
|
||||||
} catch {
|
} catch {
|
||||||
setStatusMessage("Failed to load MCP servers");
|
setStatusMessage("Failed to load MCP servers");
|
||||||
|
setStatusTone("error");
|
||||||
setServers([]);
|
setServers([]);
|
||||||
setRawContent(null);
|
setRawContent(null);
|
||||||
|
setDraftContent(EMPTY_MCP_JSON);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleSaveRawContent() {
|
||||||
|
if (!selectedProjectId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSaving(true);
|
||||||
|
setStatusMessage(null);
|
||||||
|
setStatusTone(null);
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/projects/${encodeURIComponent(selectedProjectId)}/mcp`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ content: draftContent }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const payload = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(
|
||||||
|
typeof payload?.error === "string"
|
||||||
|
? payload.error
|
||||||
|
: "Failed to save MCP servers"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const content =
|
||||||
|
typeof payload?.content === "string" ? payload.content : draftContent;
|
||||||
|
setRawContent(content);
|
||||||
|
setDraftContent(content);
|
||||||
|
setServers(normalizeServers(payload?.servers));
|
||||||
|
setStatusMessage("MCP configuration saved.");
|
||||||
|
setStatusTone("success");
|
||||||
|
} catch (error) {
|
||||||
|
setStatusMessage(
|
||||||
|
error instanceof Error ? error.message : "Failed to save MCP servers"
|
||||||
|
);
|
||||||
|
setStatusTone("error");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const baselineContent = rawContent ?? EMPTY_MCP_JSON;
|
||||||
|
const hasDraftChanges = draftContent !== baselineContent;
|
||||||
|
const canSaveDraft = rawContent === null || hasDraftChanges;
|
||||||
|
|
||||||
const filteredServers = useMemo(() => {
|
const filteredServers = useMemo(() => {
|
||||||
const query = search.trim().toLowerCase();
|
const query = search.trim().toLowerCase();
|
||||||
if (!query) return servers;
|
if (!query) return servers;
|
||||||
@@ -188,7 +250,7 @@ export default function McpPage() {
|
|||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h2 className="text-2xl font-semibold">MCP Servers</h2>
|
<h2 className="text-2xl font-semibold">MCP Servers</h2>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
View MCP servers configured for each project from
|
View and edit MCP servers configured for each project from
|
||||||
<span className="font-mono"> .meta/mcp/servers.json </span>
|
<span className="font-mono"> .meta/mcp/servers.json </span>
|
||||||
and switch between projects.
|
and switch between projects.
|
||||||
</p>
|
</p>
|
||||||
@@ -224,7 +286,15 @@ export default function McpPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{statusMessage && (
|
{statusMessage && (
|
||||||
<div className="rounded-md border bg-muted/40 px-3 py-2 text-sm">
|
<div
|
||||||
|
className={`rounded-md border px-3 py-2 text-sm ${
|
||||||
|
statusTone === "error"
|
||||||
|
? "border-destructive/40 bg-destructive/10 text-destructive"
|
||||||
|
: statusTone === "success"
|
||||||
|
? "border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
|
||||||
|
: "bg-muted/40"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{statusMessage}
|
{statusMessage}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -322,15 +392,53 @@ export default function McpPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{rawContent ? (
|
{selectedProjectId ? (
|
||||||
<div className="rounded-lg border bg-card">
|
<div className="rounded-lg border bg-card">
|
||||||
<div className="border-b px-4 py-3">
|
<div className="flex items-center justify-between border-b px-4 py-3">
|
||||||
<h3 className="text-sm font-medium">Raw servers.json</h3>
|
<h3 className="text-sm font-medium">Raw servers.json</h3>
|
||||||
|
{!loading && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Edit JSON directly
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4">
|
<div className="space-y-3 p-4">
|
||||||
<pre className="max-h-[360px] overflow-auto rounded-lg border bg-muted/30 p-3 text-xs font-mono whitespace-pre-wrap break-words">
|
{!loading && !rawContent && (
|
||||||
{rawContent}
|
<p className="text-xs text-muted-foreground">
|
||||||
</pre>
|
`servers.json` does not exist yet for this project. Save to create it.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<textarea
|
||||||
|
value={draftContent}
|
||||||
|
onChange={(e) => setDraftContent(e.target.value)}
|
||||||
|
placeholder='{"mcpServers": {}}'
|
||||||
|
rows={10}
|
||||||
|
disabled={loading || saving}
|
||||||
|
className="w-full rounded-lg border bg-muted/30 p-3 text-xs font-mono whitespace-pre-wrap focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-70"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={handleSaveRawContent}
|
||||||
|
disabled={loading || saving || !canSaveDraft}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
Saving...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Save servers.json"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setDraftContent(baselineContent)}
|
||||||
|
disabled={loading || saving || !hasDraftChanges}
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
@@ -18,6 +17,10 @@ export default function ProjectDetailsPage() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const id = params.id as string;
|
const id = params.id as string;
|
||||||
const [project, setProject] = useState<Project | null>(null);
|
const [project, setProject] = useState<Project | null>(null);
|
||||||
|
const [instructionsDraft, setInstructionsDraft] = useState("");
|
||||||
|
const [instructionsSaving, setInstructionsSaving] = useState(false);
|
||||||
|
const [instructionsStatus, setInstructionsStatus] = useState<string | null>(null);
|
||||||
|
const [instructionsStatusTone, setInstructionsStatusTone] = useState<"success" | "error" | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -28,6 +31,7 @@ export default function ProjectDetailsPage() {
|
|||||||
})
|
})
|
||||||
.then((data: Project) => {
|
.then((data: Project) => {
|
||||||
setProject(data);
|
setProject(data);
|
||||||
|
setInstructionsDraft(data.instructions || "");
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
@@ -36,6 +40,41 @@ export default function ProjectDetailsPage() {
|
|||||||
});
|
});
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
|
async function handleSaveInstructions() {
|
||||||
|
if (!project) return;
|
||||||
|
try {
|
||||||
|
setInstructionsSaving(true);
|
||||||
|
setInstructionsStatus(null);
|
||||||
|
setInstructionsStatusTone(null);
|
||||||
|
|
||||||
|
const res = await fetch(`/api/projects/${project.id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ instructions: instructionsDraft }),
|
||||||
|
});
|
||||||
|
const payload = (await res.json()) as Project | { error?: string };
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(
|
||||||
|
"error" in payload && typeof payload.error === "string"
|
||||||
|
? payload.error
|
||||||
|
: "Failed to save instructions"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setProject(payload as Project);
|
||||||
|
setInstructionsDraft((payload as Project).instructions || "");
|
||||||
|
setInstructionsStatus("Instructions updated.");
|
||||||
|
setInstructionsStatusTone("success");
|
||||||
|
} catch (error) {
|
||||||
|
setInstructionsStatus(
|
||||||
|
error instanceof Error ? error.message : "Failed to save instructions"
|
||||||
|
);
|
||||||
|
setInstructionsStatusTone("error");
|
||||||
|
} finally {
|
||||||
|
setInstructionsSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen items-center justify-center">
|
<div className="flex h-screen items-center justify-center">
|
||||||
@@ -55,6 +94,8 @@ export default function ProjectDetailsPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const instructionsDirty = instructionsDraft !== (project.instructions || "");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="[--header-height:calc(--spacing(14))]">
|
<div className="[--header-height:calc(--spacing(14))]">
|
||||||
<SidebarProvider className="flex flex-col">
|
<SidebarProvider className="flex flex-col">
|
||||||
@@ -93,8 +134,48 @@ export default function ProjectDetailsPage() {
|
|||||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
|
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
Instructions
|
Instructions
|
||||||
</h3>
|
</h3>
|
||||||
<div className="bg-muted/50 p-4 rounded-lg text-sm font-mono whitespace-pre-wrap">
|
{instructionsStatus && (
|
||||||
{project.instructions || "No custom instructions defined."}
|
<div
|
||||||
|
className={`rounded-md border px-3 py-2 text-sm ${
|
||||||
|
instructionsStatusTone === "error"
|
||||||
|
? "border-destructive/40 bg-destructive/10 text-destructive"
|
||||||
|
: "border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{instructionsStatus}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<textarea
|
||||||
|
value={instructionsDraft}
|
||||||
|
onChange={(e) => setInstructionsDraft(e.target.value)}
|
||||||
|
placeholder="No custom instructions defined."
|
||||||
|
disabled={instructionsSaving}
|
||||||
|
className="min-h-[140px] w-full rounded-lg border bg-muted/50 p-4 text-sm font-mono whitespace-pre-wrap focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-70"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSaveInstructions}
|
||||||
|
disabled={instructionsSaving || !instructionsDirty}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
{instructionsSaving ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
Saving...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Save"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setInstructionsDraft(project.instructions || "")}
|
||||||
|
disabled={instructionsSaving || !instructionsDirty}
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { BookText, Loader2, Puzzle, Wrench } from "lucide-react";
|
import { BookText, Loader2, Puzzle, Wrench } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Sheet,
|
Sheet,
|
||||||
SheetContent,
|
SheetContent,
|
||||||
@@ -22,9 +23,17 @@ interface ProjectContextSectionProps {
|
|||||||
projectId: string;
|
projectId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const EMPTY_MCP_JSON = JSON.stringify({ mcpServers: {} }, null, 2);
|
||||||
|
|
||||||
export function ProjectContextSection({ projectId }: ProjectContextSectionProps) {
|
export function ProjectContextSection({ projectId }: ProjectContextSectionProps) {
|
||||||
const [mcpContent, setMcpContent] = useState<string | null>(null);
|
const [mcpContent, setMcpContent] = useState<string | null>(null);
|
||||||
|
const [mcpDraft, setMcpDraft] = useState(EMPTY_MCP_JSON);
|
||||||
const [mcpLoading, setMcpLoading] = useState(true);
|
const [mcpLoading, setMcpLoading] = useState(true);
|
||||||
|
const [mcpSaving, setMcpSaving] = useState(false);
|
||||||
|
const [mcpStatus, setMcpStatus] = useState<string | null>(null);
|
||||||
|
const [mcpStatusTone, setMcpStatusTone] = useState<"success" | "error" | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
const [skills, setSkills] = useState<ProjectSkillItem[]>([]);
|
const [skills, setSkills] = useState<ProjectSkillItem[]>([]);
|
||||||
const [skillsLoading, setSkillsLoading] = useState(true);
|
const [skillsLoading, setSkillsLoading] = useState(true);
|
||||||
@@ -36,6 +45,8 @@ export function ProjectContextSection({ projectId }: ProjectContextSectionProps)
|
|||||||
async function loadContext() {
|
async function loadContext() {
|
||||||
setMcpLoading(true);
|
setMcpLoading(true);
|
||||||
setSkillsLoading(true);
|
setSkillsLoading(true);
|
||||||
|
setMcpStatus(null);
|
||||||
|
setMcpStatusTone(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [mcpRes, skillsRes] = await Promise.all([
|
const [mcpRes, skillsRes] = await Promise.all([
|
||||||
@@ -45,9 +56,13 @@ export function ProjectContextSection({ projectId }: ProjectContextSectionProps)
|
|||||||
|
|
||||||
if (mcpRes.ok) {
|
if (mcpRes.ok) {
|
||||||
const mcpData = await mcpRes.json();
|
const mcpData = await mcpRes.json();
|
||||||
setMcpContent(typeof mcpData.content === "string" ? mcpData.content : null);
|
const content =
|
||||||
|
typeof mcpData.content === "string" ? mcpData.content : null;
|
||||||
|
setMcpContent(content);
|
||||||
|
setMcpDraft(content ?? EMPTY_MCP_JSON);
|
||||||
} else {
|
} else {
|
||||||
setMcpContent(null);
|
setMcpContent(null);
|
||||||
|
setMcpDraft(EMPTY_MCP_JSON);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (skillsRes.ok) {
|
if (skillsRes.ok) {
|
||||||
@@ -73,6 +88,7 @@ export function ProjectContextSection({ projectId }: ProjectContextSectionProps)
|
|||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setMcpContent(null);
|
setMcpContent(null);
|
||||||
|
setMcpDraft(EMPTY_MCP_JSON);
|
||||||
setSkills([]);
|
setSkills([]);
|
||||||
} finally {
|
} finally {
|
||||||
setMcpLoading(false);
|
setMcpLoading(false);
|
||||||
@@ -88,6 +104,46 @@ export function ProjectContextSection({ projectId }: ProjectContextSectionProps)
|
|||||||
setSkillSheetOpen(true);
|
setSkillSheetOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleSaveMcp() {
|
||||||
|
try {
|
||||||
|
setMcpSaving(true);
|
||||||
|
setMcpStatus(null);
|
||||||
|
setMcpStatusTone(null);
|
||||||
|
|
||||||
|
const res = await fetch(`/api/projects/${projectId}/mcp`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ content: mcpDraft }),
|
||||||
|
});
|
||||||
|
const payload = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(
|
||||||
|
typeof payload?.error === "string"
|
||||||
|
? payload.error
|
||||||
|
: "Failed to save MCP config"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const content =
|
||||||
|
typeof payload?.content === "string" ? payload.content : mcpDraft;
|
||||||
|
setMcpContent(content);
|
||||||
|
setMcpDraft(content);
|
||||||
|
setMcpStatus("MCP configuration saved.");
|
||||||
|
setMcpStatusTone("success");
|
||||||
|
} catch (error) {
|
||||||
|
setMcpStatus(
|
||||||
|
error instanceof Error ? error.message : "Failed to save MCP config"
|
||||||
|
);
|
||||||
|
setMcpStatusTone("error");
|
||||||
|
} finally {
|
||||||
|
setMcpSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mcpBaseline = mcpContent ?? EMPTY_MCP_JSON;
|
||||||
|
const mcpDirty = mcpDraft !== mcpBaseline;
|
||||||
|
const mcpCanSave = mcpContent === null || mcpDirty;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="grid gap-4 lg:grid-cols-2">
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
@@ -104,15 +160,57 @@ export function ProjectContextSection({ projectId }: ProjectContextSectionProps)
|
|||||||
<Loader2 className="size-4 animate-spin" />
|
<Loader2 className="size-4 animate-spin" />
|
||||||
Loading MCP config...
|
Loading MCP config...
|
||||||
</div>
|
</div>
|
||||||
) : !mcpContent ? (
|
|
||||||
<div className="p-4 text-sm text-muted-foreground">
|
|
||||||
No `servers.json` found for this project.
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="p-4">
|
<div className="space-y-3 p-4">
|
||||||
<pre className="max-h-[360px] overflow-auto rounded-lg border bg-muted/30 p-3 text-xs font-mono whitespace-pre-wrap break-words">
|
{!mcpContent && (
|
||||||
{mcpContent}
|
<p className="text-xs text-muted-foreground">
|
||||||
</pre>
|
No `servers.json` found for this project. Save to create it.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{mcpStatus && (
|
||||||
|
<div
|
||||||
|
className={`rounded-md border px-3 py-2 text-xs ${
|
||||||
|
mcpStatusTone === "error"
|
||||||
|
? "border-destructive/40 bg-destructive/10 text-destructive"
|
||||||
|
: "border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{mcpStatus}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<textarea
|
||||||
|
value={mcpDraft}
|
||||||
|
onChange={(e) => setMcpDraft(e.target.value)}
|
||||||
|
placeholder='{"mcpServers": {}}'
|
||||||
|
rows={10}
|
||||||
|
disabled={mcpSaving}
|
||||||
|
className="w-full rounded-lg border bg-muted/30 p-3 text-xs font-mono whitespace-pre-wrap focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-70"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSaveMcp}
|
||||||
|
disabled={mcpSaving || !mcpCanSave}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
{mcpSaving ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
Saving...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Save"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setMcpDraft(mcpBaseline)}
|
||||||
|
disabled={mcpSaving || !mcpDirty}
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -261,6 +261,80 @@ export async function deleteProjectMcpServer(
|
|||||||
return { success: true, filePath };
|
return { success: true, filePath };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function saveProjectMcpServersContent(
|
||||||
|
projectId: string,
|
||||||
|
rawContent: string
|
||||||
|
): Promise<
|
||||||
|
| {
|
||||||
|
success: true;
|
||||||
|
filePath: string;
|
||||||
|
content: string;
|
||||||
|
servers: McpServerConfig[];
|
||||||
|
}
|
||||||
|
| { success: false; error: string }
|
||||||
|
> {
|
||||||
|
const trimmed = rawContent.trim();
|
||||||
|
const defaultContent = JSON.stringify({ mcpServers: {} }, null, 2);
|
||||||
|
const parseTarget = trimmed ? rawContent : defaultContent;
|
||||||
|
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(parseTarget);
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "Invalid JSON. Provide a valid servers.json object.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const maybeCursor = parsed as McpServersFileCursor;
|
||||||
|
const isCursorObject =
|
||||||
|
maybeCursor?.mcpServers &&
|
||||||
|
typeof maybeCursor.mcpServers === "object" &&
|
||||||
|
!Array.isArray(maybeCursor.mcpServers);
|
||||||
|
|
||||||
|
const normalized = normalizeMcpServersFile(parsed);
|
||||||
|
if (!normalized && !isCursorObject) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
"Unsupported format. Use { \"mcpServers\": { ... } } (Cursor format) or { \"servers\": [ ... ] }.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const servers = normalized?.servers ?? [];
|
||||||
|
for (const server of servers) {
|
||||||
|
const idError = validateMcpServerId(server.id);
|
||||||
|
if (idError) {
|
||||||
|
return { success: false, error: `Invalid server id "${server.id}": ${idError}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (server.transport === "http" && !server.url.trim()) {
|
||||||
|
return { success: false, error: `HTTP server "${server.id}" requires a non-empty url.` };
|
||||||
|
}
|
||||||
|
if (server.transport === "stdio" && !server.command.trim()) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `STDIO server "${server.id}" requires a non-empty command.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cursor = normalized ? toCursorMcpServersFile(normalized) : { mcpServers: {} };
|
||||||
|
const content = JSON.stringify(cursor, null, 2);
|
||||||
|
|
||||||
|
await ensureDir(getProjectMcpDir(projectId));
|
||||||
|
const filePath = getProjectMcpServersPath(projectId);
|
||||||
|
await fs.writeFile(filePath, content, "utf-8");
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
filePath,
|
||||||
|
content,
|
||||||
|
servers,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const SKILL_FILE = "SKILL.md";
|
const SKILL_FILE = "SKILL.md";
|
||||||
|
|
||||||
/** Agent Skills spec: lowercase, numbers, hyphens; no leading/trailing/consecutive hyphens (e.g. pdf, pdf-parsing) */
|
/** Agent Skills spec: lowercase, numbers, hyphens; no leading/trailing/consecutive hyphens (e.g. pdf, pdf-parsing) */
|
||||||
|
|||||||
Reference in New Issue
Block a user