Skip to content

Commit fe7f511

Browse files
authored
feat(chat-export): include custom prompt info in chat download (#590) (#592)
Chat export now surfaces the custom prompt the user selected. The active prompt is added to metadata.activePrompt (key/name/description/server), and synthetic system entries are injected into the conversation at the points where the user switched prompts mid-chat. Each user message carries an _activePromptKey snapshot at send-time so transitions can be reconstructed deterministically. The actual prompt body lives on the backend MCP server and isn't available to the frontend, so the injected entry uses name/description instead of the prompt text.
1 parent eaebe1d commit fe7f511

3 files changed

Lines changed: 238 additions & 6 deletions

File tree

frontend/src/contexts/ChatContext.jsx

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { useSettings } from '../hooks/useSettings'
1010
import { usePersistentState } from '../hooks/chat/usePersistentState'
1111
import { createWebSocketHandler, cleanupStreamState } from '../handlers/chat/websocketHandlers'
1212
import { saveConversation as saveLocalConv } from '../utils/localConversationDB'
13+
import { buildPromptInfoByKey, resolvePromptInfo, buildExportConversation } from '../utils/chatExport'
1314

1415
// Safety timeout for stuck thinking state (no backend response)
1516
const THINKING_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes
@@ -310,7 +311,12 @@ export const ChatProvider = ({ children }) => {
310311
if (!content.trim() || !currentModel) return
311312
if (isWelcomeVisible) setIsWelcomeVisible(false)
312313
setFollowUpSuggestions([])
313-
addMessage({ role: 'user', content, timestamp: new Date().toISOString() })
314+
addMessage({
315+
role: 'user',
316+
content,
317+
timestamp: new Date().toISOString(),
318+
_activePromptKey: selections.activePromptKey || null,
319+
})
314320
setIsThinking(true)
315321
setIsSynthesizing(false)
316322
const tagged = files.getTaggedFilesContent()
@@ -481,9 +487,17 @@ export const ChatProvider = ({ children }) => {
481487
const ragSourcesDisplay = ragEnabled
482488
? ([...selectedDataSources].join(', ') || 'None selected')
483489
: 'None (RAG disabled)'
490+
491+
const promptInfoByKey = buildPromptInfoByKey(config.prompts)
492+
const activePromptInfo = resolvePromptInfo(selections.activePromptKey, promptInfoByKey)
493+
const exportConversation = buildExportConversation(messages, promptInfoByKey)
494+
484495
if (asText) {
485-
let text = `Chat Export - ${config.appName}\nDate: ${new Date().toLocaleString()}\nUser: ${config.user}\nModel: ${currentModel}\nSelected Tools: ${[...selectedTools].join(', ') || 'None'}\nSelected RAG Sources: ${ragSourcesDisplay}\nAgent Mode: ${agent.agentModeEnabled ? 'Enabled' : 'Disabled'}\n\n${'='.repeat(50)}\n\n`
486-
messages.forEach(m => { text += `${m.role.toUpperCase()}:\n${m.content}\n\n` })
496+
const promptLine = activePromptInfo
497+
? `Active Custom Prompt: ${activePromptInfo.name}${activePromptInfo.server ? ` (from ${activePromptInfo.server})` : ''}${activePromptInfo.description ? ` — ${activePromptInfo.description}` : ''}\n`
498+
: 'Active Custom Prompt: Default\n'
499+
let text = `Chat Export - ${config.appName}\nDate: ${new Date().toLocaleString()}\nUser: ${config.user}\nModel: ${currentModel}\nSelected Tools: ${[...selectedTools].join(', ') || 'None'}\nSelected RAG Sources: ${ragSourcesDisplay}\nAgent Mode: ${agent.agentModeEnabled ? 'Enabled' : 'Disabled'}\n${promptLine}\n${'='.repeat(50)}\n\n`
500+
exportConversation.forEach(m => { text += `${m.role.toUpperCase()}:\n${m.content}\n\n` })
487501
if (files.canvasContent) text += `${'='.repeat(50)}\nCANVAS CONTENT:\n${files.canvasContent}\n`
488502
const blob = new Blob([text], { type: 'text/plain' })
489503
const url = URL.createObjectURL(blob)
@@ -500,15 +514,16 @@ export const ChatProvider = ({ children }) => {
500514
user: config.user,
501515
model: currentModel,
502516
selectedTools: [...selectedTools],
517+
activePrompt: activePromptInfo,
503518
ragEnabled: ragEnabled,
504519
selectedRagSources: ragEnabled ? [...selectedDataSources] : null,
505520
toolChoiceRequired: selections.toolChoiceRequired,
506521
agentModeEnabled: agent.agentModeEnabled,
507522
agentMaxSteps: agent.agentMaxSteps,
508523
messageCount: messages.length,
509-
exportVersion: '1.1'
524+
exportVersion: '1.2'
510525
},
511-
conversation: messages,
526+
conversation: exportConversation,
512527
canvasContent: files.canvasContent || null
513528
}
514529
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
@@ -519,7 +534,7 @@ export const ChatProvider = ({ children }) => {
519534
a.download = `chat-export-${ts}.json`
520535
document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url)
521536
}
522-
}, [messages, config.appName, config.user, config.features, currentModel, selectedTools, selectedDataSources, agent.agentModeEnabled, agent.agentMaxSteps, selections.toolChoiceRequired, files.canvasContent])
537+
}, [messages, config.appName, config.user, config.features, config.prompts, currentModel, selectedTools, selectedDataSources, agent.agentModeEnabled, agent.agentMaxSteps, selections.toolChoiceRequired, selections.activePromptKey, files.canvasContent])
523538

524539
const downloadChat = useCallback(() => exportData(false), [exportData])
525540
const downloadChatAsText = useCallback(() => exportData(true), [exportData])
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { describe, it, expect } from 'vitest'
2+
import {
3+
buildPromptInfoByKey,
4+
resolvePromptInfo,
5+
buildExportConversation,
6+
} from '../utils/chatExport'
7+
8+
const PROMPTS_CONFIG = [
9+
{
10+
server: 'prompts',
11+
prompts: [
12+
{ name: 'intel_analyst', description: 'Acts as an intelligence analyst' },
13+
{ name: 'plain_summary' },
14+
],
15+
},
16+
{
17+
server: 'work_helper',
18+
prompts: [
19+
{ name: 'pirate_voice', description: 'Talk like a pirate' },
20+
],
21+
},
22+
]
23+
24+
describe('chatExport.buildPromptInfoByKey', () => {
25+
it('keys prompts by server_name and preserves description/server', () => {
26+
const lookup = buildPromptInfoByKey(PROMPTS_CONFIG)
27+
expect(lookup.prompts_intel_analyst).toEqual({
28+
key: 'prompts_intel_analyst',
29+
name: 'intel_analyst',
30+
description: 'Acts as an intelligence analyst',
31+
server: 'prompts',
32+
})
33+
expect(lookup.work_helper_pirate_voice.server).toBe('work_helper')
34+
expect(lookup.prompts_plain_summary.description).toBe('')
35+
})
36+
37+
it('handles empty/missing input', () => {
38+
expect(buildPromptInfoByKey(undefined)).toEqual({})
39+
expect(buildPromptInfoByKey([])).toEqual({})
40+
expect(buildPromptInfoByKey([{ server: 'x' }])).toEqual({})
41+
})
42+
})
43+
44+
describe('chatExport.resolvePromptInfo', () => {
45+
const lookup = buildPromptInfoByKey(PROMPTS_CONFIG)
46+
47+
it('returns null for null/empty key', () => {
48+
expect(resolvePromptInfo(null, lookup)).toBeNull()
49+
expect(resolvePromptInfo('', lookup)).toBeNull()
50+
})
51+
52+
it('resolves a known key', () => {
53+
expect(resolvePromptInfo('prompts_intel_analyst', lookup).name).toBe('intel_analyst')
54+
})
55+
56+
it('falls back to a stub for unknown keys', () => {
57+
expect(resolvePromptInfo('gone_server_gone_prompt', lookup)).toEqual({
58+
key: 'gone_server_gone_prompt',
59+
name: 'gone_server_gone_prompt',
60+
description: '',
61+
server: '',
62+
})
63+
})
64+
})
65+
66+
describe('chatExport.buildExportConversation', () => {
67+
const lookup = buildPromptInfoByKey(PROMPTS_CONFIG)
68+
69+
it('passes messages through unchanged when none carry a prompt snapshot', () => {
70+
const messages = [
71+
{ role: 'user', content: 'hi', timestamp: '2026-05-08T00:00:00Z' },
72+
{ role: 'assistant', content: 'hello', timestamp: '2026-05-08T00:00:01Z' },
73+
]
74+
expect(buildExportConversation(messages, lookup)).toEqual(messages)
75+
})
76+
77+
it('injects a system entry when a custom prompt is first activated', () => {
78+
const messages = [
79+
{ role: 'user', content: 'plain', timestamp: 't0', _activePromptKey: null },
80+
{ role: 'assistant', content: 'reply', timestamp: 't0a' },
81+
{ role: 'user', content: 'with prompt', timestamp: 't1', _activePromptKey: 'prompts_intel_analyst' },
82+
{ role: 'assistant', content: 'analyst reply', timestamp: 't1a' },
83+
]
84+
const out = buildExportConversation(messages, lookup)
85+
expect(out).toHaveLength(5)
86+
const sys = out[2]
87+
expect(sys.role).toBe('system')
88+
expect(sys._promptChange).toBe(true)
89+
expect(sys.promptKey).toBe('prompts_intel_analyst')
90+
expect(sys.promptName).toBe('intel_analyst')
91+
expect(sys.promptServer).toBe('prompts')
92+
expect(sys.content).toContain('intel_analyst')
93+
expect(sys.content).toContain('Acts as an intelligence analyst')
94+
expect(sys.timestamp).toBe('t1')
95+
})
96+
97+
it('strips _activePromptKey from exported messages', () => {
98+
const messages = [
99+
{ role: 'user', content: 'hi', _activePromptKey: 'prompts_intel_analyst', timestamp: 't0' },
100+
]
101+
const out = buildExportConversation(messages, lookup)
102+
out.forEach(m => expect(m).not.toHaveProperty('_activePromptKey'))
103+
})
104+
105+
it('injects a system entry when switching from one custom prompt to another', () => {
106+
const messages = [
107+
{ role: 'user', content: 'a', timestamp: 't0', _activePromptKey: 'prompts_intel_analyst' },
108+
{ role: 'assistant', content: 'r', timestamp: 't0a' },
109+
{ role: 'user', content: 'b', timestamp: 't1', _activePromptKey: 'work_helper_pirate_voice' },
110+
]
111+
const out = buildExportConversation(messages, lookup)
112+
const changes = out.filter(m => m._promptChange)
113+
expect(changes).toHaveLength(2)
114+
expect(changes[0].promptKey).toBe('prompts_intel_analyst')
115+
expect(changes[1].promptKey).toBe('work_helper_pirate_voice')
116+
})
117+
118+
it('emits a "cleared" entry when switching from custom back to default', () => {
119+
const messages = [
120+
{ role: 'user', content: 'a', timestamp: 't0', _activePromptKey: 'prompts_intel_analyst' },
121+
{ role: 'user', content: 'b', timestamp: 't1', _activePromptKey: null },
122+
]
123+
const out = buildExportConversation(messages, lookup)
124+
const cleared = out.find(m => m._promptChange && m.promptKey === null)
125+
expect(cleared).toBeDefined()
126+
expect(cleared.content).toMatch(/cleared/i)
127+
})
128+
129+
it('does not emit a "cleared" entry when the conversation simply starts on default', () => {
130+
const messages = [
131+
{ role: 'user', content: 'a', timestamp: 't0', _activePromptKey: null },
132+
{ role: 'user', content: 'b', timestamp: 't1', _activePromptKey: null },
133+
]
134+
const out = buildExportConversation(messages, lookup)
135+
expect(out.find(m => m._promptChange)).toBeUndefined()
136+
})
137+
138+
it('falls back to a stub for prompts no longer present in config', () => {
139+
const messages = [
140+
{ role: 'user', content: 'a', timestamp: 't0', _activePromptKey: 'gone_server_gone_prompt' },
141+
]
142+
const out = buildExportConversation(messages, lookup)
143+
const sys = out.find(m => m._promptChange)
144+
expect(sys.promptName).toBe('gone_server_gone_prompt')
145+
expect(sys.promptServer).toBe('')
146+
})
147+
})

frontend/src/utils/chatExport.js

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
// Helpers for the chat-download / export flow.
2+
//
3+
// The frontend does not have the actual prompt body (that lives on the backend
4+
// MCP server), so when the user selects a custom prompt mid-conversation we
5+
// surface the prompt's name / description / server in the export instead.
6+
7+
export function buildPromptInfoByKey(promptsConfig) {
8+
const out = {}
9+
;(promptsConfig || []).forEach(server => {
10+
(server.prompts || []).forEach(p => {
11+
const key = `${server.server}_${p.name}`
12+
out[key] = {
13+
key,
14+
name: p.name,
15+
description: p.description || '',
16+
server: server.server,
17+
}
18+
})
19+
})
20+
return out
21+
}
22+
23+
export function resolvePromptInfo(key, promptInfoByKey) {
24+
if (!key) return null
25+
return promptInfoByKey[key] || { key, name: key, description: '', server: '' }
26+
}
27+
28+
// Walk through messages and inject synthetic system entries at points where the
29+
// active custom prompt changed (based on the per-user-message _activePromptKey
30+
// snapshot). Strips _activePromptKey from the exported messages.
31+
export function buildExportConversation(messages, promptInfoByKey) {
32+
const out = []
33+
let prev = null
34+
let sawAny = false
35+
for (const m of messages) {
36+
if (m && m.role === 'user' && Object.prototype.hasOwnProperty.call(m, '_activePromptKey')) {
37+
const cur = m._activePromptKey || null
38+
if (!sawAny || cur !== prev) {
39+
if (cur) {
40+
const info = resolvePromptInfo(cur, promptInfoByKey)
41+
out.push({
42+
role: 'system',
43+
content: info.description
44+
? `Custom prompt activated: ${info.name} (from ${info.server}) — ${info.description}`
45+
: `Custom prompt activated: ${info.name} (from ${info.server})`,
46+
timestamp: m.timestamp,
47+
_promptChange: true,
48+
promptKey: info.key,
49+
promptName: info.name,
50+
promptServer: info.server,
51+
promptDescription: info.description,
52+
})
53+
} else if (sawAny && prev) {
54+
out.push({
55+
role: 'system',
56+
content: 'Custom prompt cleared (using default prompt)',
57+
timestamp: m.timestamp,
58+
_promptChange: true,
59+
promptKey: null,
60+
})
61+
}
62+
prev = cur
63+
sawAny = true
64+
}
65+
}
66+
const { _activePromptKey, ...rest } = m || {}
67+
out.push(rest)
68+
}
69+
return out
70+
}

0 commit comments

Comments
 (0)