mirror of
https://github.com/eggent-ai/eggent.git
synced 2026-03-07 01:53:08 +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
|
||||
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`).
|
||||
|
||||
## 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 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",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "design-vibe",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^3.0.37",
|
||||
"@ai-sdk/google": "^3.0.21",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "design-vibe",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
@@ -2,6 +2,6 @@ export async function GET() {
|
||||
return Response.json({
|
||||
status: "ok",
|
||||
timestamp: new Date().toISOString(),
|
||||
version: "0.1.0",
|
||||
version: "0.1.1",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
getProject,
|
||||
getProjectMcpServersPath,
|
||||
loadProjectMcpServers,
|
||||
saveProjectMcpServersContent,
|
||||
} from "@/lib/storage/project-store";
|
||||
|
||||
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 { SiteHeader } from "@/components/site-header";
|
||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Globe, Loader2, Terminal, Wrench } from "lucide-react";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
@@ -74,14 +75,19 @@ function normalizeServers(input: unknown): McpServerItem[] {
|
||||
return servers;
|
||||
}
|
||||
|
||||
const EMPTY_MCP_JSON = JSON.stringify({ mcpServers: {} }, null, 2);
|
||||
|
||||
export default function McpPage() {
|
||||
const { projects, setProjects, activeProjectId } = useAppStore();
|
||||
const [selectedProjectId, setSelectedProjectId] = useState("");
|
||||
const [servers, setServers] = useState<McpServerItem[]>([]);
|
||||
const [rawContent, setRawContent] = useState<string | null>(null);
|
||||
const [draftContent, setDraftContent] = useState(EMPTY_MCP_JSON);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [projectsLoading, setProjectsLoading] = useState(false);
|
||||
const [statusMessage, setStatusMessage] = useState<string | null>(null);
|
||||
const [statusTone, setStatusTone] = useState<"success" | "error" | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
@@ -131,7 +137,9 @@ export default function McpPage() {
|
||||
if (!projectId) {
|
||||
setServers([]);
|
||||
setRawContent(null);
|
||||
setDraftContent(EMPTY_MCP_JSON);
|
||||
setStatusMessage(null);
|
||||
setStatusTone(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -139,6 +147,7 @@ export default function McpPage() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setStatusMessage(null);
|
||||
setStatusTone(null);
|
||||
const res = await fetch(`/api/projects/${encodeURIComponent(projectId)}/mcp`);
|
||||
const payload = await res.json();
|
||||
|
||||
@@ -148,22 +157,75 @@ export default function McpPage() {
|
||||
? payload.error
|
||||
: "Failed to load MCP servers";
|
||||
setStatusMessage(message);
|
||||
setStatusTone("error");
|
||||
setServers([]);
|
||||
setRawContent(null);
|
||||
setDraftContent(EMPTY_MCP_JSON);
|
||||
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));
|
||||
} catch {
|
||||
setStatusMessage("Failed to load MCP servers");
|
||||
setStatusTone("error");
|
||||
setServers([]);
|
||||
setRawContent(null);
|
||||
setDraftContent(EMPTY_MCP_JSON);
|
||||
} finally {
|
||||
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 query = search.trim().toLowerCase();
|
||||
if (!query) return servers;
|
||||
@@ -188,7 +250,7 @@ export default function McpPage() {
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-2xl font-semibold">MCP Servers</h2>
|
||||
<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>
|
||||
and switch between projects.
|
||||
</p>
|
||||
@@ -224,7 +286,15 @@ export default function McpPage() {
|
||||
</div>
|
||||
|
||||
{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}
|
||||
</div>
|
||||
)}
|
||||
@@ -322,15 +392,53 @@ export default function McpPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{rawContent ? (
|
||||
{selectedProjectId ? (
|
||||
<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>
|
||||
{!loading && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Edit JSON directly
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="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">
|
||||
{rawContent}
|
||||
</pre>
|
||||
<div className="space-y-3 p-4">
|
||||
{!loading && !rawContent && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
`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>
|
||||
) : null}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -18,6 +17,10 @@ export default function ProjectDetailsPage() {
|
||||
const router = useRouter();
|
||||
const id = params.id as string;
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -28,6 +31,7 @@ export default function ProjectDetailsPage() {
|
||||
})
|
||||
.then((data: Project) => {
|
||||
setProject(data);
|
||||
setInstructionsDraft(data.instructions || "");
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -36,6 +40,41 @@ export default function ProjectDetailsPage() {
|
||||
});
|
||||
}, [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) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
@@ -55,6 +94,8 @@ export default function ProjectDetailsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const instructionsDirty = instructionsDraft !== (project.instructions || "");
|
||||
|
||||
return (
|
||||
<div className="[--header-height:calc(--spacing(14))]">
|
||||
<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">
|
||||
Instructions
|
||||
</h3>
|
||||
<div className="bg-muted/50 p-4 rounded-lg text-sm font-mono whitespace-pre-wrap">
|
||||
{project.instructions || "No custom instructions defined."}
|
||||
{instructionsStatus && (
|
||||
<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>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { BookText, Loader2, Puzzle, Wrench } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
@@ -22,9 +23,17 @@ interface ProjectContextSectionProps {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
const EMPTY_MCP_JSON = JSON.stringify({ mcpServers: {} }, null, 2);
|
||||
|
||||
export function ProjectContextSection({ projectId }: ProjectContextSectionProps) {
|
||||
const [mcpContent, setMcpContent] = useState<string | null>(null);
|
||||
const [mcpDraft, setMcpDraft] = useState(EMPTY_MCP_JSON);
|
||||
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 [skillsLoading, setSkillsLoading] = useState(true);
|
||||
@@ -36,6 +45,8 @@ export function ProjectContextSection({ projectId }: ProjectContextSectionProps)
|
||||
async function loadContext() {
|
||||
setMcpLoading(true);
|
||||
setSkillsLoading(true);
|
||||
setMcpStatus(null);
|
||||
setMcpStatusTone(null);
|
||||
|
||||
try {
|
||||
const [mcpRes, skillsRes] = await Promise.all([
|
||||
@@ -45,9 +56,13 @@ export function ProjectContextSection({ projectId }: ProjectContextSectionProps)
|
||||
|
||||
if (mcpRes.ok) {
|
||||
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 {
|
||||
setMcpContent(null);
|
||||
setMcpDraft(EMPTY_MCP_JSON);
|
||||
}
|
||||
|
||||
if (skillsRes.ok) {
|
||||
@@ -73,6 +88,7 @@ export function ProjectContextSection({ projectId }: ProjectContextSectionProps)
|
||||
}
|
||||
} catch {
|
||||
setMcpContent(null);
|
||||
setMcpDraft(EMPTY_MCP_JSON);
|
||||
setSkills([]);
|
||||
} finally {
|
||||
setMcpLoading(false);
|
||||
@@ -88,6 +104,46 @@ export function ProjectContextSection({ projectId }: ProjectContextSectionProps)
|
||||
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 (
|
||||
<>
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
@@ -104,15 +160,57 @@ export function ProjectContextSection({ projectId }: ProjectContextSectionProps)
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading MCP config...
|
||||
</div>
|
||||
) : !mcpContent ? (
|
||||
<div className="p-4 text-sm text-muted-foreground">
|
||||
No `servers.json` found for this project.
|
||||
</div>
|
||||
) : (
|
||||
<div className="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}
|
||||
</pre>
|
||||
<div className="space-y-3 p-4">
|
||||
{!mcpContent && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
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>
|
||||
|
||||
@@ -261,6 +261,80 @@ export async function deleteProjectMcpServer(
|
||||
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";
|
||||
|
||||
/** Agent Skills spec: lowercase, numbers, hyphens; no leading/trailing/consecutive hyphens (e.g. pdf, pdf-parsing) */
|
||||
|
||||
Reference in New Issue
Block a user