Clean up ACP branch: gate debug log, fix capitalizations, harden types

- Gate acpDebugLog behind import.meta.env.DEV (no logging in production)
- Remove saveAcpDebugLog (Desktop hardcode) — clipboard copy is sufficient
- Hide ACP log button unless in DEV mode with entries
- Fix textFromContent: proper type guards instead of unsafe casts
- Fix trailing empty line in server.ts handleBrowserMessage
- Rename Acp → ACP, Mcp → MCP in all identifiers:
  AcpPermissionDialog → ACPPermissionDialog (file + component)
  AcpDebugEntry → ACPDebugEntry
  spawnMcpIfNeeded → spawnMCPIfNeeded
  checkMcpHealth → checkMCPHealth
  McpContent/McpResult/McpTransport → MCPContent/MCPResult/MCPTransport
  createMcpSession → createMCPSession
This commit is contained in:
Danila Poyarkov 2026-03-15 18:24:37 +03:00
parent 63f3ef3755
commit 12d3b60e69
9 changed files with 134 additions and 139 deletions

View file

@ -21,8 +21,8 @@ import type { ParamDef, ParamType } from '@open-pencil/core'
const require = createRequire(import.meta.url)
const MCP_VERSION: string = (require('../package.json') as { version: string }).version
type McpContent = { type: 'text'; text: string } | { type: 'image'; data: string; mimeType: string }
type McpResult = { content: McpContent[]; isError?: boolean }
type MCPContent = { type: 'text'; text: string } | { type: 'image'; data: string; mimeType: string }
type MCPResult = { content: MCPContent[]; isError?: boolean }
const RPC_TIMEOUT = 30_000
@ -32,11 +32,11 @@ interface PendingRequest {
timer: ReturnType<typeof setTimeout>
}
function ok(data: unknown): McpResult {
function ok(data: unknown): MCPResult {
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] }
}
function fail(e: unknown): McpResult {
function fail(e: unknown): MCPResult {
const msg = e instanceof Error ? e.message : String(e)
return { content: [{ type: 'text', text: JSON.stringify({ error: msg }) }], isError: true }
}
@ -127,7 +127,6 @@ export function startServer(options: ServerOptions = {}) {
} catch (e) {
console.warn('Malformed automation message:', e)
}
}
function rejectAllPending(reason: string) {
@ -235,11 +234,11 @@ export function startServer(options: ServerOptions = {}) {
// --- MCP Streamable HTTP ---
type McpTransport = { handleRequest: (r: Request) => Promise<Response> }
const mcpSessions = new Map<string, McpTransport>()
type MCPTransport = { handleRequest: (r: Request) => Promise<Response> }
const mcpSessions = new Map<string, MCPTransport>()
const MAX_MCP_SESSIONS = 10
function createMcpSession(id: string): McpTransport {
function createMCPSession(id: string): MCPTransport {
const mcpServer = new McpServer({ name: 'open-pencil', version: MCP_VERSION })
const register = mcpServer.registerTool.bind(mcpServer) as (...a: unknown[]) => void
@ -304,7 +303,7 @@ export function startServer(options: ServerOptions = {}) {
{ status: 503, headers: { 'Retry-After': '5' } }
)
}
const transport = existing ?? createMcpSession(sessionId ?? randomUUID())
const transport = existing ?? createMCPSession(sessionId ?? randomUUID())
const response = await transport.handleRequest(c.req.raw)
if (c.req.method === 'DELETE' && sessionId) {
mcpSessions.delete(sessionId)

View file

@ -90,11 +90,13 @@ export function textFromContent(
content: Record<string, unknown>[] | undefined
): string | undefined {
if (!content) return undefined
return content
.filter(
(c) =>
c.type === 'content' && (c.content as Record<string, unknown> | undefined)?.type === 'text'
)
.map((c) => (c.content as Record<string, string>).text)
.join('\n')
const parts: string[] = []
for (const c of content) {
if (c.type !== 'content') continue
const inner = c.content as Record<string, unknown> | undefined
if (inner?.type === 'text' && typeof inner.text === 'string') {
parts.push(inner.text)
}
}
return parts.length > 0 ? parts.join('\n') : undefined
}

View file

@ -56,15 +56,16 @@ export function buildCrashChunks(
return { chunks, shouldNullSession: true }
}
interface AcpDebugEntry {
interface ACPDebugEntry {
ts: number
type: string
data: unknown
}
const MAX_LOG_AGE_MS = 5 * 60 * 1000
const IS_DEV = import.meta.env.DEV
export const acpDebugLog: AcpDebugEntry[] = []
export const acpDebugLog: ACPDebugEntry[] = []
function pruneOldEntries() {
const cutoff = Date.now() - MAX_LOG_AGE_MS
@ -75,29 +76,18 @@ function pruneOldEntries() {
export function getAcpDebugText(): string {
pruneOldEntries()
return acpDebugLog.map((e) =>
`[${new Date(e.ts).toISOString()}] ${e.type}\n${JSON.stringify(e.data, null, 2)}`
).join('\n\n---\n\n')
return acpDebugLog
.map((e) => `[${new Date(e.ts).toISOString()}] ${e.type}\n${JSON.stringify(e.data, null, 2)}`)
.join('\n\n---\n\n')
}
export function clearAcpDebugLog() {
acpDebugLog.length = 0
}
export async function saveAcpDebugLog(): Promise<string | null> {
export function hasAcpDebugEntries(): boolean {
pruneOldEntries()
const text = getAcpDebugText()
if (!text) return null
try {
const { writeTextFile } = await import('@tauri-apps/plugin-fs')
const { join, desktopDir } = await import('@tauri-apps/api/path')
const filename = `acp-debug-${new Date().toISOString().replace(/[:.]/g, '-')}.log`
const path = await join(await desktopDir(), filename)
await writeTextFile(path, text)
return path
} catch {
return null
}
return acpDebugLog.length > 0
}
export class ACPChatTransport implements ChatTransport<UIMessage> {
@ -158,11 +148,13 @@ export class ACPChatTransport implements ChatTransport<UIMessage> {
session.onUpdate = (params) => {
if (closed) return
acpDebugLog.push({
ts: Date.now(),
type: params.update.sessionUpdate,
data: params.update
})
if (IS_DEV) {
acpDebugLog.push({
ts: Date.now(),
type: params.update.sessionUpdate,
data: params.update
})
}
const result = mapUpdate(params.update, textId, textStarted)
for (const chunk of result.chunks) {
controller.enqueue(chunk)

View file

@ -19,7 +19,7 @@ async function pollHealth(retries: number, delayMs: number): Promise<boolean> {
return false
}
export async function spawnMcpIfNeeded(): Promise<(() => void) | null> {
export async function spawnMCPIfNeeded(): Promise<(() => void) | null> {
if (import.meta.env.DEV || !IS_TAURI) return null
if (await checkHealth()) return null

View file

@ -2,10 +2,10 @@
import { ScrollAreaRoot, ScrollAreaScrollbar, ScrollAreaThumb, ScrollAreaViewport } from 'reka-ui'
import { computed, markRaw, nextTick, ref, watch } from 'vue'
import { getAcpDebugText, clearAcpDebugLog, saveAcpDebugLog } from '@/ai/acp-transport'
import { getAcpDebugText, clearAcpDebugLog, hasAcpDebugEntries } from '@/ai/acp-transport'
import { copyChatLog } from '@/ai/chat-debug'
import { clearToolLogEntries, didHitStepLimit } from '@/ai/tools'
import AcpPermissionDialog from '@/components/chat/AcpPermissionDialog.vue'
import ACPPermissionDialog from '@/components/chat/ACPPermissionDialog.vue'
import ChatInput from '@/components/chat/ChatInput.vue'
import ChatMessage from '@/components/chat/ChatMessage.vue'
import ProviderSetup from '@/components/chat/ProviderSetup.vue'
@ -92,9 +92,10 @@ async function handleCopyAcpLog() {
const text = getAcpDebugText()
if (!text) return
await navigator.clipboard.writeText(text)
void saveAcpDebugLog()
acpLogCopied.value = true
setTimeout(() => { acpLogCopied.value = false }, 1500)
setTimeout(() => {
acpLogCopied.value = false
}, 1500)
}
function handleClearChat() {
@ -183,6 +184,7 @@ function handleClearChat() {
{{ debugCopied ? 'Copied' : 'Copy log' }}
</button>
<button
v-if="IS_DEV && hasAcpDebugEntries()"
class="flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] text-muted hover:bg-hover hover:text-surface"
@click="handleCopyAcpLog"
>
@ -213,7 +215,7 @@ function handleClearChat() {
<ChatInput :status="status" @submit="handleSubmit" @stop="handleStop" />
<AcpPermissionDialog />
<ACPPermissionDialog />
</template>
</div>
</template>

View file

@ -21,7 +21,7 @@ const { providerID, providerDef } = useAIChat()
const mcpAvailable = ref(false)
async function checkMcpHealth(retries = 3, delayMs = 1000) {
async function checkMCPHealth(retries = 3, delayMs = 1000) {
for (let i = 0; i < retries; i++) {
try {
const res = await fetch(`http://127.0.0.1:${AUTOMATION_HTTP_PORT}/health`, {
@ -39,7 +39,7 @@ async function checkMcpHealth(retries = 3, delayMs = 1000) {
if (IS_TAURI) {
onMounted(() => {
void checkMcpHealth()
void checkMCPHealth()
})
}

View file

@ -207,97 +207,97 @@ function clearUnsplashKey() {
</div>
<template v-if="!isACP">
<!-- Base URL (OpenAI-compatible only) -->
<div v-if="providerDef.supportsCustomBaseURL" class="flex flex-col gap-1">
<label class="text-[10px] text-muted">Base URL</label>
<input
v-model="baseURLInput"
type="text"
data-test-id="provider-settings-base-url"
placeholder="http://localhost:11434/v1"
:class="uiInput({ size: 'sm' })"
@change="save"
/>
</div>
<!-- Custom model ID (OpenAI-compatible only) -->
<div v-if="providerDef.supportsCustomModel" class="flex flex-col gap-1">
<label class="text-[10px] text-muted">Model ID</label>
<input
v-model="customModelInput"
type="text"
data-test-id="provider-settings-custom-model"
placeholder="e.g. llama-3.3-70b"
:class="uiInput({ size: 'sm' })"
@change="save"
/>
</div>
<!-- API type (OpenAI-compatible only) -->
<div v-if="providerID === 'openai-compatible'" class="flex flex-col gap-1">
<label class="text-[10px] text-muted">API Type</label>
<TabsRoot
:model-value="customAPIType"
data-test-id="provider-settings-api-type"
class="flex flex-col"
@update:model-value="
(v: string) => {
customAPIType = v as 'completions' | 'responses'
save()
}
"
>
<TabsList class="flex rounded bg-canvas">
<TabsTrigger
value="completions"
class="flex-1 rounded px-2 py-1 text-[10px] text-muted data-[state=active]:bg-hover data-[state=active]:text-surface"
>
Completions
</TabsTrigger>
<TabsTrigger
value="responses"
class="flex-1 rounded px-2 py-1 text-[10px] text-muted data-[state=active]:bg-hover data-[state=active]:text-surface"
>
Responses
</TabsTrigger>
</TabsList>
<TabsContent value="completions" />
<TabsContent value="responses" />
</TabsRoot>
</div>
<!-- API key -->
<div class="flex flex-col gap-1">
<div class="flex items-center justify-between">
<label class="text-[10px] text-muted">API Key</label>
<button
v-if="apiKey"
class="cursor-pointer text-[10px] text-muted hover:text-surface"
data-test-id="provider-settings-clear-key"
@click="clearKey"
>
Clear
</button>
<!-- Base URL (OpenAI-compatible only) -->
<div v-if="providerDef.supportsCustomBaseURL" class="flex flex-col gap-1">
<label class="text-[10px] text-muted">Base URL</label>
<input
v-model="baseURLInput"
type="text"
data-test-id="provider-settings-base-url"
placeholder="http://localhost:11434/v1"
:class="uiInput({ size: 'sm' })"
@change="save"
/>
</div>
<!-- Custom model ID (OpenAI-compatible only) -->
<div v-if="providerDef.supportsCustomModel" class="flex flex-col gap-1">
<label class="text-[10px] text-muted">Model ID</label>
<input
v-model="customModelInput"
type="text"
data-test-id="provider-settings-custom-model"
placeholder="e.g. llama-3.3-70b"
:class="uiInput({ size: 'sm' })"
@change="save"
/>
</div>
<!-- API type (OpenAI-compatible only) -->
<div v-if="providerID === 'openai-compatible'" class="flex flex-col gap-1">
<label class="text-[10px] text-muted">API Type</label>
<TabsRoot
:model-value="customAPIType"
data-test-id="provider-settings-api-type"
class="flex flex-col"
@update:model-value="
(v: string) => {
customAPIType = v as 'completions' | 'responses'
save()
}
"
>
<TabsList class="flex rounded bg-canvas">
<TabsTrigger
value="completions"
class="flex-1 rounded px-2 py-1 text-[10px] text-muted data-[state=active]:bg-hover data-[state=active]:text-surface"
>
Completions
</TabsTrigger>
<TabsTrigger
value="responses"
class="flex-1 rounded px-2 py-1 text-[10px] text-muted data-[state=active]:bg-hover data-[state=active]:text-surface"
>
Responses
</TabsTrigger>
</TabsList>
<TabsContent value="completions" />
<TabsContent value="responses" />
</TabsRoot>
</div>
<!-- API key -->
<div class="flex flex-col gap-1">
<div class="flex items-center justify-between">
<label class="text-[10px] text-muted">API Key</label>
<button
v-if="apiKey"
class="cursor-pointer text-[10px] text-muted hover:text-surface"
data-test-id="provider-settings-clear-key"
@click="clearKey"
>
Clear
</button>
</div>
<input
v-model="keyInput"
type="password"
data-test-id="provider-settings-api-key"
:placeholder="
hasExistingKey ? 'Key saved — enter new to replace' : providerDef.keyPlaceholder
"
:class="uiInput({ size: 'sm' })"
@change="save"
/>
<a
v-if="providerDef.keyURL"
:href="providerDef.keyURL"
target="_blank"
class="text-[9px] text-muted underline hover:text-surface"
>
Get API key →
</a>
</div>
<input
v-model="keyInput"
type="password"
data-test-id="provider-settings-api-key"
:placeholder="
hasExistingKey ? 'Key saved — enter new to replace' : providerDef.keyPlaceholder
"
:class="uiInput({ size: 'sm' })"
@change="save"
/>
<a
v-if="providerDef.keyURL"
:href="providerDef.keyURL"
target="_blank"
class="text-[9px] text-muted underline hover:text-surface"
>
Get API key →
</a>
</div>
</template>
<PopoverClose

View file

@ -9,7 +9,7 @@ import { useKeyboard } from '@/composables/use-keyboard'
import { useMenu } from '@/composables/use-menu'
import { useCollab, COLLAB_KEY } from '@/composables/use-collab'
import { connectAutomation } from '@/automation/server'
import { spawnMcpIfNeeded } from '@/automation/spawn-mcp'
import { spawnMCPIfNeeded } from '@/automation/spawn-mcp'
import { createDemoShapes } from '@/demo'
import { useEditorStore } from '@/stores/editor'
import { createTab, activeTab, getActiveStore } from '@/stores/tabs'
@ -35,7 +35,7 @@ const { disconnect: disconnectAutomation } = connectAutomation(getActiveStore)
const mcpCleanup = ref<(() => void) | null>(null)
onMounted(async () => {
try {
mcpCleanup.value = await spawnMcpIfNeeded()
mcpCleanup.value = await spawnMCPIfNeeded()
} catch (e) {
console.error(e)
}