Skip to content

Commit 764301f

Browse files
committed
docs: let the wizard compose a prompt — Ask AI or Copy prompt
Compose the user's selections into a prompt and add two actions: - Ask AI queues the prompt and opens the in-docs assistant, which sends it and starts a chat. A shared useAssistantPrompt() state carries the prompt; AssistantChat.vue is shadowed from the comark-docs layer to consume it. - Copy prompt copies a self-contained prompt for any external LLM — a short Devframe description, the user's intent, and absolute links to every recommended doc.
1 parent b3c8efc commit 764301f

3 files changed

Lines changed: 390 additions & 3 deletions

File tree

Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
<!--
2+
Shadows the comark-docs layer's `AssistantChat.vue`. It mirrors the layer
3+
component verbatim and adds one thing: it consumes `useAssistantPrompt()`, so
4+
other surfaces (the Getting Started wizard) can queue a prompt and open the
5+
panel to start a chat. Keep this in sync with the layer on comark-docs bumps.
6+
-->
7+
<script setup lang="ts">
8+
import type { DynamicToolUIPart, ToolUIPart, UIMessage } from 'ai'
9+
import { useChat } from '@ai-sdk/vue'
10+
import highlight from '@comark/nuxt/plugins/highlight'
11+
import { isPartStreaming, isToolStreaming } from '@nuxt/ui/utils/ai'
12+
import { DefaultChatTransport, getToolName, isReasoningUIPart, isTextUIPart, isToolUIPart } from 'ai'
13+
14+
const MAX_INPUT = 1000
15+
16+
const open = useAssistant()
17+
const { assistant } = useAppConfig()
18+
19+
const input = ref('')
20+
const { messages, status, error, sendMessage, regenerate, stop } = useChat({
21+
transport: new DefaultChatTransport({ api: '/api/assistant' }),
22+
})
23+
24+
const plugins = [highlight()]
25+
26+
// Suggestions grouped by category, shown before the first message.
27+
const questions = computed(() => assistant?.faqQuestions ?? [])
28+
29+
// devframe addition: a prompt queued from elsewhere (e.g. the Getting Started
30+
// wizard) is sent as soon as this panel mounts/opens, then cleared.
31+
const pendingPrompt = useAssistantPrompt()
32+
watch(pendingPrompt, (text) => {
33+
if (!text)
34+
return
35+
pendingPrompt.value = null
36+
sendMessage({ text })
37+
}, { immediate: true })
38+
39+
watch(input, (value) => {
40+
if (value.length > MAX_INPUT)
41+
input.value = value.slice(0, MAX_INPUT)
42+
})
43+
44+
function onSubmit() {
45+
const text = input.value.trim()
46+
if (!text)
47+
return
48+
sendMessage({ text })
49+
input.value = ''
50+
}
51+
52+
function ask(question: string) {
53+
sendMessage({ text: question })
54+
}
55+
56+
const copied = ref(false)
57+
async function copyConversation() {
58+
const text = messages.value
59+
.map((message) => {
60+
const content = message.parts.filter(isTextUIPart).map(part => part.text).join('\n')
61+
return `${message.role === 'user' ? 'User' : 'Assistant'}:\n${content}`
62+
})
63+
.join('\n\n')
64+
await navigator.clipboard.writeText(text)
65+
copied.value = true
66+
setTimeout(() => (copied.value = false), 1500)
67+
}
68+
69+
function clearChat() {
70+
stop()
71+
messages.value = []
72+
}
73+
74+
function toolMeta(part: ToolUIPart | DynamicToolUIPart) {
75+
const name = getToolName(part)
76+
const streaming = isToolStreaming(part)
77+
const input = part.input as Record<string, string> | undefined
78+
if (name === 'search_docs') {
79+
return { icon: 'i-lucide-text-search', text: streaming ? 'Searching the docs' : 'Searched the docs', suffix: input?.query }
80+
}
81+
if (name === 'get_page') {
82+
return { icon: 'i-lucide-book-open', text: streaming ? 'Reading a page' : 'Read a page', suffix: input?.path }
83+
}
84+
return { icon: 'i-lucide-wrench', text: name.replace(/_/g, ' ') }
85+
}
86+
87+
/** Live tool rows show while the answer streams; once done they collapse into one "Used N sources" row. */
88+
function isMessageStreaming(message: UIMessage) {
89+
const last = messages.value[messages.value.length - 1]
90+
return (
91+
message.role === 'assistant'
92+
&& message.id === last?.id
93+
&& (status.value === 'streaming' || status.value === 'submitted')
94+
)
95+
}
96+
97+
/** Unique doc pages the assistant read for this message, in call order. */
98+
function messageSources(message: UIMessage) {
99+
const paths = new Set<string>()
100+
for (const part of message.parts) {
101+
if (isToolUIPart(part) && getToolName(part) === 'get_page') {
102+
const path = (part.input as { path?: string } | undefined)?.path
103+
if (path)
104+
paths.add(path.startsWith('/') ? path : `/${path}`)
105+
}
106+
}
107+
return [...paths]
108+
}
109+
110+
function messageToolCount(message: UIMessage) {
111+
return message.parts.filter(part => isToolUIPart(part)).length
112+
}
113+
114+
function sourcesLabel(message: UIMessage) {
115+
const count = messageSources(message).length || messageToolCount(message)
116+
return `Used ${count} source${count === 1 ? '' : 's'}`
117+
}
118+
</script>
119+
120+
<template>
121+
<USlideover
122+
v-model:open="open"
123+
:ui="{ content: 'sm:max-w-md', body: 'flex flex-col' }"
124+
>
125+
<template #header>
126+
<div class="flex items-center justify-between w-full">
127+
<h2 class="font-bold text-highlighted">
128+
Chat
129+
</h2>
130+
<div class="flex items-center gap-1">
131+
<UButton
132+
:icon="copied ? 'i-lucide-check' : 'i-lucide-copy'"
133+
color="neutral"
134+
variant="ghost"
135+
:disabled="!messages.length"
136+
:ui="{ leadingIcon: 'size-4' }"
137+
aria-label="Copy conversation"
138+
@click="copyConversation"
139+
/>
140+
<UButton
141+
icon="i-lucide-trash-2"
142+
color="neutral"
143+
variant="ghost"
144+
:disabled="!messages.length"
145+
:ui="{ leadingIcon: 'size-4' }"
146+
aria-label="Clear conversation"
147+
@click="clearChat"
148+
/>
149+
<UButton
150+
icon="i-lucide-chevron-right"
151+
color="neutral"
152+
variant="ghost"
153+
:ui="{ leadingIcon: 'size-4' }"
154+
aria-label="Close chat"
155+
@click="open = false"
156+
/>
157+
</div>
158+
</div>
159+
</template>
160+
161+
<template #body>
162+
<UChatPalette>
163+
<UChatMessages
164+
v-if="messages.length"
165+
:messages="messages"
166+
:status="status"
167+
:user="{ side: 'right', variant: 'soft' }"
168+
:assistant="{ side: 'left', variant: 'naked' }"
169+
>
170+
<template #indicator>
171+
<AssistantIndicator />
172+
</template>
173+
174+
<template #content="{ message }">
175+
<UChatTool
176+
v-if="message.role === 'assistant' && !isMessageStreaming(message) && messageToolCount(message)"
177+
icon="i-lucide-bookmark"
178+
:text="sourcesLabel(message)"
179+
>
180+
<div
181+
v-if="messageSources(message).length"
182+
class="flex flex-col items-start gap-1 pt-1"
183+
>
184+
<ULink
185+
v-for="path in messageSources(message)"
186+
:key="path"
187+
:to="path"
188+
class="text-sm text-muted hover:text-highlighted"
189+
>
190+
{{ path }}
191+
</ULink>
192+
</div>
193+
</UChatTool>
194+
195+
<template
196+
v-for="(part, index) in message.parts"
197+
:key="`${message.id}-${part.type}-${index}`"
198+
>
199+
<UChatReasoning
200+
v-if="isReasoningUIPart(part)"
201+
icon="i-lucide-brain"
202+
:text="part.text"
203+
:streaming="isPartStreaming(part)"
204+
>
205+
<Markdown
206+
:value="part.text"
207+
:streaming="isPartStreaming(part)"
208+
:plugins="plugins"
209+
class="text-sm text-muted *:first:mt-0 *:last:mb-0"
210+
/>
211+
</UChatReasoning>
212+
213+
<UChatTool
214+
v-else-if="isToolUIPart(part) && isMessageStreaming(message)"
215+
v-bind="toolMeta(part)"
216+
:streaming="isToolStreaming(part)"
217+
/>
218+
219+
<template v-else-if="isTextUIPart(part)">
220+
<Markdown
221+
v-if="message.role === 'assistant'"
222+
:value="part.text"
223+
:streaming="isPartStreaming(part)"
224+
:plugins="plugins"
225+
class="*:first:mt-0 *:last:mb-0"
226+
/>
227+
<p
228+
v-else
229+
class="whitespace-pre-wrap"
230+
>
231+
{{ part.text }}
232+
</p>
233+
</template>
234+
</template>
235+
</template>
236+
</UChatMessages>
237+
238+
<div
239+
v-else
240+
class="flex-1 flex flex-col justify-end gap-6 py-4 overflow-y-auto"
241+
>
242+
<div class="flex flex-col gap-6">
243+
<UPageLinks
244+
v-for="category in questions"
245+
:key="category.category"
246+
:title="category.category"
247+
:links="category.items.map((item: string) => ({ label: item, onClick: () => ask(item) }))"
248+
/>
249+
</div>
250+
</div>
251+
252+
<template #prompt>
253+
<UChatPrompt
254+
v-model="input"
255+
:error="error"
256+
:rows="2"
257+
:ui="{ root: 'rounded-lg! px-2.5' }"
258+
placeholder="What would you like to know?"
259+
autofocus
260+
@submit="onSubmit"
261+
>
262+
<template #footer>
263+
<div class="flex items-center justify-between w-full px-2.5">
264+
<span class="text-xs text-dimmed tabular-nums">{{ input.length }} / {{ MAX_INPUT }}</span>
265+
<UChatPromptSubmit
266+
:status="status"
267+
icon="i-lucide-corner-down-left"
268+
color="neutral"
269+
@stop="stop()"
270+
@reload="regenerate()"
271+
/>
272+
</div>
273+
</template>
274+
</UChatPrompt>
275+
</template>
276+
</UChatPalette>
277+
</template>
278+
</USlideover>
279+
</template>

docs/app/components/global/GettingStartedWizard.vue

Lines changed: 100 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,81 @@ const recommendedDocs = computed(() => {
247247
function reset(): void {
248248
for (const section of sections) selections[section.key] = []
249249
}
250+
251+
// --- Prompt composition -----------------------------------------------------
252+
253+
const appConfig = useAppConfig()
254+
const assistantEnabled = computed(() => Boolean((appConfig as { assistant?: { enabled?: boolean } }).assistant?.enabled))
255+
256+
const site = useSiteConfig()
257+
/** Absolute base for doc links in the copyable prompt (external LLMs need full URLs). */
258+
const siteOrigin = computed(() => (site.url || 'https://devfra.me').replace(/\/$/, ''))
259+
260+
/** One "Section: label, label" line per answered question. */
261+
function selectionLines(): string[] {
262+
const lines: string[] = []
263+
for (const section of sections) {
264+
const chosen = section.items.filter(item => selections[section.key]!.includes(item.value))
265+
if (chosen.length)
266+
lines.push(`- ${section.title}: ${chosen.map(item => item.label).join(', ')}`)
267+
}
268+
return lines
269+
}
270+
271+
/**
272+
* Prompt for the in-docs Ask AI assistant. It already has the docs as tools
273+
* (search/read), so this stays concise and leans on those.
274+
*/
275+
function buildAssistantPrompt(): string {
276+
const lines = selectionLines()
277+
const intro = lines.length
278+
? `I want to build a devtool with Devframe. Here's what I have in mind:\n${lines.join('\n')}`
279+
: `I want to get started building a devtool with Devframe.`
280+
return `${intro}\n\nWhich Devframe features fit this, and which docs should I read (in order)? Please outline a concrete plan.`
281+
}
282+
283+
/**
284+
* Prompt for pasting into any external LLM. It has no Devframe context, so this
285+
* bundles a short description, the intent, and absolute links to every
286+
* recommended doc.
287+
*/
288+
function buildCopyPrompt(): string {
289+
const origin = siteOrigin.value
290+
const lines = selectionLines()
291+
const docs = recommendedDocs.value.map(doc => `- ${doc.title} (${origin}${doc.path}): ${doc.description}`)
292+
return [
293+
`I'm building a developer tool with Devframe (${origin}).`,
294+
'',
295+
'Devframe is a framework-neutral foundation for building a devtool once and running it anywhere: mounted inside any host framework (Vite, Nuxt, Next.js, …), as a standalone CLI or a static build, or exposed to coding agents over MCP. A devframe pairs a node side with a browser side over type-safe RPC and shared state, and ships its UI as a built SPA.',
296+
'',
297+
lines.length ? `What I want to build:\n${lines.join('\n')}` : 'I am just getting started and want a solid foundation.',
298+
'',
299+
'Please help me design and implement this with Devframe. Relevant documentation:',
300+
...docs,
301+
'',
302+
'Explain which Devframe primitives to use and give me a step-by-step implementation plan.',
303+
].join('\n')
304+
}
305+
306+
const assistantPrompt = useAssistantPrompt()
307+
const assistantOpen = useAssistant()
308+
309+
function askAI(): void {
310+
assistantPrompt.value = buildAssistantPrompt()
311+
assistantOpen.value = true
312+
}
313+
314+
const promptCopied = ref(false)
315+
async function copyPrompt(): Promise<void> {
316+
try {
317+
await navigator.clipboard.writeText(buildCopyPrompt())
318+
promptCopied.value = true
319+
setTimeout(() => (promptCopied.value = false), 1500)
320+
}
321+
catch {
322+
// Clipboard unavailable (e.g. insecure context) - ignore.
323+
}
324+
}
250325
</script>
251326

252327
<template>
@@ -315,9 +390,31 @@ function reset(): void {
315390
</div>
316391

317392
<div class="px-5 py-4 sm:px-6 bg-muted">
318-
<p class="font-medium text-highlighted mb-3">
319-
{{ hasSelections ? 'Recommended docs, based on your answers' : 'Start here' }}
320-
</p>
393+
<div class="flex flex-wrap items-center justify-between gap-3 mb-3">
394+
<p class="font-medium text-highlighted">
395+
{{ hasSelections ? 'Recommended docs, based on your answers' : 'Start here' }}
396+
</p>
397+
<div class="flex items-center gap-2">
398+
<UButton
399+
v-if="assistantEnabled"
400+
label="Ask AI"
401+
icon="i-lucide-sparkles"
402+
color="primary"
403+
size="sm"
404+
class="cursor-pointer"
405+
@click="askAI"
406+
/>
407+
<UButton
408+
:label="promptCopied ? 'Copied!' : 'Copy prompt'"
409+
:icon="promptCopied ? 'i-lucide-check' : 'i-lucide-copy'"
410+
color="neutral"
411+
variant="outline"
412+
size="sm"
413+
class="cursor-pointer"
414+
@click="copyPrompt"
415+
/>
416+
</div>
417+
</div>
321418
<div class="flex flex-col divide-y divide-default rounded-lg border border-default overflow-hidden bg-default">
322419
<NuxtLink
323420
v-for="doc in recommendedDocs"

0 commit comments

Comments
 (0)