feat(renderer): sandboxed code runner for HTML/JS/CSS (M10.11)
- CodeRunner.vue: sandboxed iframe with srcdoc, allow-scripts only - Console capture via postMessage (log + error) - Run button, clear output, code preview - Extracts runnable code blocks from markdown Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
7f67f0863d
commit
7fa06ce500
@@ -214,6 +214,15 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="runnableCodeBlocks.length > 0" class="mt-3 space-y-2" @click.stop>
|
||||
<CodeRunner
|
||||
v-for="(block, i) in runnableCodeBlocks"
|
||||
:key="`code-${i}`"
|
||||
:code="block.code"
|
||||
:language="block.language"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="inlineNewsLinks.length > 0" class="mt-3 space-y-1" @click.stop>
|
||||
<NewsCard
|
||||
v-for="(link, i) in inlineNewsLinks"
|
||||
@@ -362,7 +371,9 @@ import RecipeCard from '@/components/renderers/RecipeCard.vue'
|
||||
import EventCard from '@/components/renderers/EventCard.vue'
|
||||
import InteractiveTable from '@/components/renderers/InteractiveTable.vue'
|
||||
import TimelineRenderer from '@/components/renderers/TimelineRenderer.vue'
|
||||
import CodeRunner from '@/components/renderers/CodeRunner.vue'
|
||||
import { extractTables } from '@/composables/useTableExtractor'
|
||||
import { extractRunnableCodeBlocks } from '@/composables/useCodeBlockExtractor'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -470,6 +481,11 @@ const inlineEvents = computed(() => {
|
||||
return extractEvents(props.message.content)
|
||||
})
|
||||
|
||||
const runnableCodeBlocks = computed(() => {
|
||||
if (isUser.value) return []
|
||||
return extractRunnableCodeBlocks(props.message.content)
|
||||
})
|
||||
|
||||
const isCodeResponse = computed(() =>
|
||||
!isUser.value && props.triggeringQuery.trim().toLowerCase() === '/code'
|
||||
)
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
<template>
|
||||
<div class="code-runner rounded-xl bg-white/5 border border-white/10 overflow-hidden">
|
||||
<!-- Toolbar -->
|
||||
<div class="flex items-center gap-2 px-3 py-2 border-b border-white/5">
|
||||
<span class="text-[10px] text-white/30 uppercase tracking-wider">{{ language }}</span>
|
||||
<div class="flex-1" />
|
||||
<button
|
||||
class="text-[10px] px-2.5 py-1 rounded bg-accent/15 text-accent/80 hover:bg-accent/25 transition-colors"
|
||||
@click="runCode"
|
||||
>
|
||||
Run
|
||||
</button>
|
||||
<button
|
||||
v-if="consoleOutput.length > 0"
|
||||
class="text-[10px] px-2 py-1 rounded bg-white/5 text-white/40 hover:text-white/60 hover:bg-white/10 transition-colors"
|
||||
@click="clearOutput"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Code display -->
|
||||
<pre class="px-3 py-2 text-xs text-white/70 overflow-x-auto max-h-48 bg-black/20"><code>{{ code }}</code></pre>
|
||||
|
||||
<!-- Output iframe (hidden, used for execution) -->
|
||||
<iframe
|
||||
v-if="showIframe"
|
||||
ref="iframeRef"
|
||||
:srcdoc="srcdoc"
|
||||
sandbox="allow-scripts"
|
||||
class="w-full border-t border-white/5"
|
||||
:class="isHtml ? 'h-48' : 'h-0 invisible'"
|
||||
title="Code output"
|
||||
/>
|
||||
|
||||
<!-- Console output -->
|
||||
<div
|
||||
v-if="consoleOutput.length > 0"
|
||||
class="border-t border-white/5 bg-black/30 px-3 py-2 max-h-32 overflow-y-auto"
|
||||
>
|
||||
<p class="text-[9px] text-white/20 uppercase tracking-wider mb-1">Console</p>
|
||||
<div
|
||||
v-for="(entry, i) in consoleOutput"
|
||||
:key="i"
|
||||
class="text-[11px] font-mono leading-relaxed"
|
||||
:class="entry.type === 'error' ? 'text-red-400/80' : 'text-white/60'"
|
||||
>
|
||||
{{ entry.text }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
code: string
|
||||
language: string
|
||||
}>()
|
||||
|
||||
interface ConsoleEntry {
|
||||
type: 'log' | 'error'
|
||||
text: string
|
||||
}
|
||||
|
||||
const consoleOutput = ref<ConsoleEntry[]>([])
|
||||
const showIframe = ref(false)
|
||||
const iframeRef = ref<HTMLIFrameElement | null>(null)
|
||||
|
||||
const isHtml = computed(() => props.language === 'html')
|
||||
|
||||
const srcdoc = computed(() => {
|
||||
if (isHtml.value) {
|
||||
// For HTML, wrap with console capture
|
||||
return `<!DOCTYPE html>
|
||||
<html><head><style>body{background:#0a0a0a;color:#e0e0e0;font-family:system-ui;margin:8px}</style>
|
||||
<script>
|
||||
const _post = (type, args) => parent.postMessage({type:'code-runner-console',level:type,text:args.map(a=>typeof a==='object'?JSON.stringify(a):String(a)).join(' ')},'*');
|
||||
console.log = (...a) => _post('log', a);
|
||||
console.error = (...a) => _post('error', a);
|
||||
window.onerror = (m) => _post('error', [m]);
|
||||
<\/script></head><body>${props.code}</body></html>`
|
||||
}
|
||||
|
||||
// For JS/CSS, wrap in HTML
|
||||
if (props.language === 'javascript' || props.language === 'js') {
|
||||
return `<!DOCTYPE html><html><head>
|
||||
<script>
|
||||
const _post = (type, args) => parent.postMessage({type:'code-runner-console',level:type,text:args.map(a=>typeof a==='object'?JSON.stringify(a):String(a)).join(' ')},'*');
|
||||
console.log = (...a) => _post('log', a);
|
||||
console.error = (...a) => _post('error', a);
|
||||
window.onerror = (m) => _post('error', [m]);
|
||||
<\/script></head><body><script>${props.code}<\/script></body></html>`
|
||||
}
|
||||
|
||||
if (props.language === 'css') {
|
||||
return `<!DOCTYPE html><html><head><style>${props.code}</style></head><body style="background:#0a0a0a"><div style="padding:16px;color:#e0e0e0;font-family:system-ui">CSS Preview</div></body></html>`
|
||||
}
|
||||
|
||||
return ''
|
||||
})
|
||||
|
||||
function runCode() {
|
||||
consoleOutput.value = []
|
||||
showIframe.value = false
|
||||
// Force re-mount iframe
|
||||
requestAnimationFrame(() => {
|
||||
showIframe.value = true
|
||||
})
|
||||
}
|
||||
|
||||
function clearOutput() {
|
||||
consoleOutput.value = []
|
||||
}
|
||||
|
||||
function handleMessage(e: MessageEvent) {
|
||||
if (e.data?.type === 'code-runner-console') {
|
||||
consoleOutput.value.push({
|
||||
type: e.data.level === 'error' ? 'error' : 'log',
|
||||
text: e.data.text,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('message', handleMessage)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('message', handleMessage)
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Extract runnable code blocks (HTML, JS, CSS) from markdown text.
|
||||
*/
|
||||
|
||||
export interface CodeBlock {
|
||||
language: string
|
||||
code: string
|
||||
}
|
||||
|
||||
const RUNNABLE_LANGS = new Set(['html', 'javascript', 'js', 'css'])
|
||||
|
||||
const FENCED_CODE_RE = /```(html|javascript|js|css)\n([\s\S]*?)```/gi
|
||||
|
||||
export function extractRunnableCodeBlocks(text: string): CodeBlock[] {
|
||||
const results: CodeBlock[] = []
|
||||
let m: RegExpExecArray | null
|
||||
const re = new RegExp(FENCED_CODE_RE.source, FENCED_CODE_RE.flags)
|
||||
|
||||
while ((m = re.exec(text)) !== null) {
|
||||
const language = m[1].toLowerCase()
|
||||
const code = m[2].trim()
|
||||
if (RUNNABLE_LANGS.has(language) && code.length > 0) {
|
||||
results.push({ language, code })
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
export function hasRunnableCode(text: string): boolean {
|
||||
return FENCED_CODE_RE.test(text)
|
||||
}
|
||||
Reference in New Issue
Block a user