Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -206,3 +206,12 @@ button {
0%, 100% { opacity: 0.5; }
50% { opacity: 1; }
}

@keyframes mcp-activity-settle {
0%, 72% {
opacity: 1;
}
100% {
opacity: 0;
}
}
9 changes: 9 additions & 0 deletions app/mcp/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { resolveRequestOrigin } from "@/common/lib/siteUrl";
import { createSlideXMcpServer } from "@/mcp/server";
import { mcpResourceUrl } from "@/mcp/oauthMetadata";
import { SupabaseMcpOAuthStore } from "@/mcp/supabaseOAuthStore";
import { SupabaseMcpOperationActivityStore } from "@/mcp/supabaseOperationActivityStore";
import { SupabaseMcpPresentationImageUploadStore } from "@/mcp/supabasePresentationImageUploadStore";
import { SupabaseMcpPresentationStore } from "@/mcp/supabasePresentationStore";

Expand Down Expand Up @@ -35,6 +36,13 @@ async function handleMcpRequest(request: NextRequest) {
return NextResponse.json({ error: "insufficient_scope" }, { status: 403 });
}

const oauthClient = await oauthStore.getClient(auth.clientId).catch(() => null);
const operationActivity = new SupabaseMcpOperationActivityStore(admin, {
clientId: auth.clientId,
clientName: oauthClient?.client_name?.trim() || "MCP client",
userId: auth.userId
});

const server = createSlideXMcpServer({
enablePresentationWrites: auth.scopes.includes("presentations:write"),
imageUploads: auth.scopes.includes("presentation-assets:write")
Expand All @@ -44,6 +52,7 @@ async function handleMcpRequest(request: NextRequest) {
userId: auth.userId
}
: undefined,
operationActivity,
profile: "remote",
presentationStore: new SupabaseMcpPresentationStore(admin, auth.userId)
});
Expand Down
4 changes: 4 additions & 0 deletions app/workspace/pitch/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ function LocalPitchWorkspace() {
agentSessionId,
error,
isReady,
mcpActivities,
mcpActivityWarning,
openAgentSession,
presentation,
save,
Expand Down Expand Up @@ -70,6 +72,8 @@ function LocalPitchWorkspace() {
onSelectedAgentSessionChange={selectAgentSession}
presentationId={presentation.id}
projectVersion={presentation.sourceRevision}
remoteMcpActivityWarning={mcpActivityWarning}
remoteMcpOperations={mcpActivities}
syncWarning={syncWarning}
/>
</PitchLocaleOverride>
Expand Down
55 changes: 55 additions & 0 deletions common/lib/supabase/database.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,61 @@ export type Database = {
}
];
};
mcp_operation_events: {
Row: {
client_id: string;
client_name: string;
completed_at: string | null;
completed_revision: number | null;
created_at: string;
error_code: string | null;
expires_at: string;
id: string;
node_id: string | null;
presentation_id: string;
slide_index: number | null;
status: "running" | "completed" | "failed";
target_kind: "presentation" | "slide" | "block";
tool_name: string;
updated_at: string;
user_id: string;
};
Insert: {
client_id: string;
client_name: string;
completed_at?: string | null;
completed_revision?: number | null;
created_at?: string;
error_code?: string | null;
id?: string;
node_id?: string | null;
presentation_id: string;
slide_index?: number | null;
status?: "running" | "completed" | "failed";
target_kind: "presentation" | "slide" | "block";
tool_name: string;
updated_at?: string;
user_id: string;
};
Update: {
completed_at?: string | null;
completed_revision?: number | null;
error_code?: string | null;
node_id?: string | null;
slide_index?: number | null;
status?: "running" | "completed" | "failed";
target_kind?: "presentation" | "slide" | "block";
};
Relationships: [
{
foreignKeyName: "mcp_operation_events_presentation_id_fkey";
columns: ["presentation_id"];
isOneToOne: false;
referencedRelation: "presentations";
referencedColumns: ["id"];
}
];
};
official_templates: {
Row: {
created_at: string;
Expand Down
9 changes: 7 additions & 2 deletions features/marketing/ui/DocsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -161,11 +161,11 @@ export function DocsPage() {
<h2 className="text-[30px] font-semibold tracking-[-0.045em]">SlideX MCP Server</h2>
<a
className="inline-flex items-center gap-1.5 rounded-full border border-[#9ad7ff]/20 bg-[#9ad7ff]/[0.08] px-2.5 py-1 text-[11px] font-semibold text-[#9ad7ff] transition-colors hover:border-[#9ad7ff]/40 hover:bg-[#9ad7ff]/[0.12]"
href="https://www.npmjs.com/package/@z7589xxz758/slidex-mcp-server/v/0.4.0"
href="https://www.npmjs.com/package/@z7589xxz758/slidex-mcp-server/v/0.5.0"
rel="noreferrer"
target="_blank"
>
v0.4.0 on npm
v0.5.0 on npm
<ArrowUpRight className="h-3 w-3" />
</a>
</div>
Expand All @@ -174,6 +174,11 @@ export function DocsPage() {
? "透過 Model Context Protocol,讓相容的 AI 客戶端建立、檢查與編輯 MotionDoc 簡報。你可以選擇在電腦執行本機 MCP,或連接 SlideX 的受保護 Remote MCP。"
: "Use the Model Context Protocol to let compatible AI clients create, validate, and edit MotionDoc presentations. Run the local MCP on your computer or connect to SlideX through the protected Remote MCP."}
</p>
<p className="mt-3 max-w-2xl text-[13px] leading-6 text-[#c4b5fd]/72">
{isZh
? "v0.5 會在開啟的 Workspace 與 Pitch 以紫色框即時標示真實 Remote MCP 操作與 OAuth client 名稱;它不會模擬滑鼠,也不會顯示模型未呼叫 tool 時的思考過程。"
: "v0.5 visualizes real Remote MCP operations in an open Workspace or Pitch editor with purple frames and the OAuth client name. It does not simulate a cursor or expose model reasoning when no tool is called."}
</p>

<div className="mt-8 grid gap-4 sm:grid-cols-2">
<div className="rounded-lg border border-white/[0.09] bg-white/[0.02] p-5">
Expand Down
58 changes: 58 additions & 0 deletions features/pitch/application/remoteMcpOperation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import type { RemoteMcpOperation } from "@/features/pitch/domain/remoteMcpOperation";

const runningLifetimeMs = 10 * 60_000;
const settledLifetimeMs = 6_000;

export function remoteMcpOperationDeadline(activity: RemoteMcpOperation) {
const timestamp = activity.status === "running"
? activity.createdAt
: activity.completedAt ?? activity.updatedAt;
return new Date(timestamp).getTime()
+ (activity.status === "running" ? runningLifetimeMs : settledLifetimeMs);
}

export function remoteMcpOperationAction(
activity: Pick<RemoteMcpOperation, "errorCode" | "status" | "toolName">,
locale: "en" | "zh-TW"
) {
if (activity.status === "failed") {
if (activity.errorCode === "revision_conflict") {
return locale === "zh-TW" ? "版本衝突,未覆蓋內容" : "Revision conflict; nothing overwritten";
}
return locale === "zh-TW" ? "操作未完成" : "Operation not completed";
}

const action = remoteToolAction(activity.toolName, locale);
if (activity.status === "completed") {
return locale === "zh-TW" ? `${action}完成` : `${action} complete`;
}
return action;
}

export function remoteMcpOperationTargetsSlide(
activity: RemoteMcpOperation,
slideIndex: number,
activeSlideIndex: number
) {
if (activity.target.kind === "presentation") return slideIndex === activeSlideIndex;
return activity.target.slideIndex === slideIndex;
}

function remoteToolAction(toolName: string, locale: "en" | "zh-TW") {
const actions: Record<string, [string, string]> = {
slidex_add_block: ["Adding an object", "新增物件"],
slidex_add_slide_from_layout: ["Adding a slide", "新增投影片"],
slidex_apply_shader_preset: ["Applying a shader", "套用 Shader"],
slidex_delete_block: ["Deleting an object", "刪除物件"],
slidex_duplicate_block: ["Duplicating an object", "複製物件"],
slidex_finalize_presentation_image_upload: ["Finalizing an image", "完成圖片上傳"],
slidex_prepare_presentation_image_upload: ["Preparing an image", "準備圖片上傳"],
slidex_reorder_block: ["Reordering objects", "調整物件順序"],
slidex_replace_slide_with_layout: ["Applying a layout", "套用版面"],
slidex_save_presentation: ["Saving the presentation", "儲存整份簡報"],
slidex_update_block: ["Editing an object", "修改物件"],
slidex_update_canvas_node: ["Moving an object", "調整物件位置"]
};
return actions[toolName]?.[locale === "zh-TW" ? 1 : 0]
?? (locale === "zh-TW" ? "修改簡報" : "Editing presentation");
}
18 changes: 18 additions & 0 deletions features/pitch/domain/remoteMcpOperation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
export type RemoteMcpOperation = {
clientId: string;
clientName: string;
completedAt?: string;
completedRevision?: number;
createdAt: string;
errorCode?: string;
expiresAt: string;
id: string;
presentationId: string;
status: "running" | "completed" | "failed";
target:
| { kind: "presentation" }
| { kind: "slide"; slideIndex: number }
| { kind: "block"; nodeId: string; slideIndex: number };
toolName: string;
updatedAt: string;
};
24 changes: 22 additions & 2 deletions features/pitch/ui/LayerSidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { Group, Layers, Plus, Trash2 } from "lucide-react";
import { Bot, Group, Layers, Plus, Trash2 } from "lucide-react";
import type { MouseEvent } from "react";
import { useState } from "react";
import { LayerRow } from "@/features/pitch/ui/LayerRow";
Expand All @@ -9,6 +9,11 @@ import type { MotionDocBlock, MotionDocScene } from "@/core/motion-doc/domain/mo
import { SlideThumbnailPreview } from "@/features/pitch/ui/preview/SlideThumbnailPreview";
import type { SlideRow } from "@/features/pitch/application/slideRows";
import { usePitchI18n } from "@/features/pitch/ui/pitchI18n";
import {
remoteMcpOperationAction,
remoteMcpOperationTargetsSlide
} from "@/features/pitch/application/remoteMcpOperation";
import type { RemoteMcpOperation } from "@/features/pitch/domain/remoteMcpOperation";

export function LayerSidebar({
activeSlideIndex,
Expand All @@ -24,6 +29,7 @@ export function LayerSidebar({
reorderBlock,
reorderSlide,
renameBlock,
remoteMcpOperations,
replayNonce,
scenes,
selectedBlockIndex,
Expand All @@ -47,6 +53,7 @@ export function LayerSidebar({
reorderBlock: (fromIndex: number, toIndex: number) => void;
reorderSlide: (fromIndex: number, toIndex: number) => void;
renameBlock: (index: number, name: string) => void;
remoteMcpOperations: readonly RemoteMcpOperation[];
replayNonce: number;
scenes: MotionDocScene[];
selectedBlockIndex: number | null;
Expand Down Expand Up @@ -119,6 +126,9 @@ export function LayerSidebar({
{slideRows.map((slide) => {
const isActive = slide.index === activeSlideIndex;
const currentSlide = scenes[slide.index];
const mcpActivity = remoteMcpOperations.find((activity) => (
remoteMcpOperationTargetsSlide(activity, slide.index, activeSlideIndex)
));
return (
<div className="flex flex-col" key={slide.index}>

Expand All @@ -129,7 +139,7 @@ export function LayerSidebar({
isActive
? "bg-white/[0.08] text-white shadow-[inset_0_1px_1px_0_rgba(255,255,255,0.05)] border border-white/[0.04]"
: "text-neutral-400 hover:bg-white/[0.03] hover:text-neutral-200 border border-transparent"
}`}
} ${mcpActivity ? `border-[#8b5cf6]/70 ${mcpActivity.status === "running" ? "motion-safe:animate-pulse" : "motion-safe:animate-[mcp-activity-settle_6s_ease-out_forwards]"} ${mcpActivity.status === "failed" ? "border-dashed" : "border-solid"}` : ""}`}
onClick={() => onSelectSlide(slide.index)}
>
<div className="flex items-center gap-2.5 overflow-hidden">
Expand All @@ -139,6 +149,7 @@ export function LayerSidebar({
</span>
</div>
<div className="flex items-center gap-2.5">
{mcpActivity ? <Bot aria-label={`AI · ${mcpActivity.clientName}`} className="h-3.5 w-3.5 text-[#a78bfa]" /> : null}
<span className="font-mono text-xs font-semibold text-neutral-400/80 bg-neutral-900/40 px-2 py-0.5 rounded-lg border border-white/[0.04]">
{slide.duration}s
</span>
Expand Down Expand Up @@ -202,6 +213,15 @@ export function LayerSidebar({
scene={currentSlide}
source={source}
/>
{mcpActivity ? (
<div className={`pointer-events-none absolute inset-0 z-10 rounded-lg border-2 border-[#8b5cf6] shadow-[inset_0_0_0_1px_rgba(139,92,246,0.22),0_0_18px_rgba(139,92,246,0.2)] ${mcpActivity.status === "running" ? "motion-safe:animate-pulse" : "motion-safe:animate-[mcp-activity-settle_6s_ease-out_forwards]"} ${mcpActivity.status === "failed" ? "border-dashed" : "border-solid"}`}>
<div className="absolute bottom-1 left-1 flex max-w-[calc(100%-8px)] items-center gap-1 rounded bg-[#2e1065]/92 px-1.5 py-0.5 text-[9px] leading-3 text-[#ede9fe]">
<Bot className="h-2.5 w-2.5 shrink-0" />
<span className="truncate font-semibold">AI · {mcpActivity.clientName}</span>
<span className="truncate text-[#ddd6fe]/65">{remoteMcpOperationAction(mcpActivity, locale)}</span>
</div>
</div>
) : null}
</div>
<span className={`absolute bottom-1.5 left-2.5 text-[11px] font-medium ${isActive ? "text-[#8ea5ff]" : "text-neutral-500"}`}>
{slide.index + 1}
Expand Down
9 changes: 9 additions & 0 deletions features/pitch/ui/MotionDocApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
type GuestSignInIntent
} from "@/features/pitch/ui/GuestSignInDialog";
import type { AgentSessionSummary } from "@/features/pitch/domain/agentRun";
import type { RemoteMcpOperation } from "@/features/pitch/domain/remoteMcpOperation";

const isSlideXAgentEnabled = process.env.NEXT_PUBLIC_SLIDEX_AGENT_ENABLED === "true";

Expand All @@ -59,6 +60,8 @@ type MotionDocAppProps = {
onSelectedAgentSessionChange?: (sessionId?: string) => void;
presentationId?: string;
projectVersion?: number;
remoteMcpActivityWarning?: string | null;
remoteMcpOperations?: readonly RemoteMcpOperation[];
syncWarning?: string | null;
};

Expand All @@ -76,6 +79,8 @@ export function MotionDocApp({
onSelectedAgentSessionChange,
presentationId,
projectVersion,
remoteMcpActivityWarning,
remoteMcpOperations = [],
syncWarning
}: MotionDocAppProps = {}) {
const isMobileViewport = useMobilePitchViewport();
Expand Down Expand Up @@ -498,6 +503,10 @@ export function MotionDocApp({
const desktopExperience = (
<>
<PitchWorkspace
remoteMcp={{
activities: remoteMcpOperations,
connectionWarning: remoteMcpActivityWarning
}}
agent={{
isEnabled: isAgentAvailable,
isPanelOpen: isAgentAvailable && isAgentPanelOpen,
Expand Down
7 changes: 6 additions & 1 deletion features/pitch/ui/PitchWorkspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,15 @@ import { WorkspaceScrollbarStyle } from "@/features/pitch/ui/workspace/Workspace
import { WorkspaceTemplateDialog } from "@/features/pitch/ui/workspace/WorkspaceTemplateDialog";
import type { PitchWorkspaceProps } from "@/features/pitch/ui/workspace/PitchWorkspaceTypes";
import { usePitchI18n } from "@/features/pitch/ui/pitchI18n";
import { useVisibleRemoteMcpOperations } from "@/features/pitch/ui/hooks/useVisibleRemoteMcpOperations";

export function PitchWorkspace({ agent, commands, document, selection, view }: PitchWorkspaceProps) {
export function PitchWorkspace({ agent, commands, document, remoteMcp, selection, view }: PitchWorkspaceProps) {
const { tx } = usePitchI18n();
const sceneCount = document.scenes.length;
const setActiveCanvasTool = view.setActiveCanvasTool;
const [zoomLevel, setZoomLevel] = useState<number | "fit">("fit");
const [fitScale, setFitScale] = useState(1);
const visibleRemoteMcpOperations = useVisibleRemoteMcpOperations(remoteMcp?.activities ?? []);

useMobileEdgePanels({
isLeftPanelOpen: view.isMobileSidebarOpen,
Expand Down Expand Up @@ -77,6 +79,7 @@ export function PitchWorkspace({ agent, commands, document, selection, view }: P
commands={commands}
document={document}
onSelectSlide={selectSlide}
remoteMcpOperations={visibleRemoteMcpOperations}
selection={selection}
view={view}
/>
Expand Down Expand Up @@ -121,6 +124,8 @@ export function PitchWorkspace({ agent, commands, document, selection, view }: P
onUpdateBlockFrames={commands.updatePositionedBlockFrames}
onUseSelectedImageAsBackground={commands.useSelectedImageAsBackground}
replayNonce={view.replayNonce}
remoteMcpActivityWarning={remoteMcp?.connectionWarning}
remoteMcpOperations={visibleRemoteMcpOperations}
sceneCount={sceneCount}
scenes={document.scenes}
selectedBlockIndex={selection.selectedBlockIndex}
Expand Down
Loading
Loading