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:
parent
63f3ef3755
commit
12d3b60e69
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue