| 1 |
import { buildToolMessages } from '@agent/lib/tool-messages'; |
| 2 |
import { isChangeSiteDesignWorkflowAvailable, makeId } from '@agent/lib/util'; |
| 3 |
import { useStatusStore } from '@agent/state/status'; |
| 4 |
import apiFetch from '@wordpress/api-fetch'; |
| 5 |
import { __ } from '@wordpress/i18n'; |
| 6 |
import { create } from 'zustand'; |
| 7 |
import { createJSONStorage, devtools, persist } from 'zustand/middleware'; |
| 8 |
|
| 9 |
const { chatHistory } = window.extAgentData; |
| 10 |
|
| 11 |
const welcomeMessage = [ |
| 12 |
{ |
| 13 |
id: 1, |
| 14 |
type: 'message', |
| 15 |
details: { |
| 16 |
role: 'assistant', |
| 17 |
// translators: this is the initial message in the agent chat, welcoming the user. Keep it short and friendly and follow the same markdown format and emoji. |
| 18 |
content: isChangeSiteDesignWorkflowAvailable() |
| 19 |
? __( |
| 20 |
'#### Your site is ready 🎉\nWant to explore other website designs?', |
| 21 |
'extendify-local', |
| 22 |
) |
| 23 |
: __( |
| 24 |
'#### Your site is ready 🎉\nWant to explore other site colors?', |
| 25 |
'extendify-local', |
| 26 |
), |
| 27 |
}, |
| 28 |
}, |
| 29 |
]; |
| 30 |
const state = (set, get) => ({ |
| 31 |
messages: chatHistory?.length ? chatHistory.toReversed() : welcomeMessage, |
| 32 |
// API messages, back to the last finished workflow. |
| 33 |
getCurrentMessages: ({ includeTools = true } = {}) => { |
| 34 |
const messages = []; |
| 35 |
let foundUserMessage = false; |
| 36 |
for (const { type, details } of get().messages.toReversed()) { |
| 37 |
const finished = ['completed', 'canceled'].includes(details.status); |
| 38 |
if (type === 'workflow' && finished) break; |
| 39 |
if (type === 'workflow-component' && finished) break; |
| 40 |
if (type === 'message' && details.error) break; |
| 41 |
// A call with no result leaves the model answering a phantom. |
| 42 |
if (type === 'tool' && !('result' in details)) continue; |
| 43 |
if (type === 'tool' && includeTools) { |
| 44 |
// buildToolMessages returns [call, result]; push reversed so the |
| 45 |
// final toReversed() restores call-before-result order. |
| 46 |
for (const m of buildToolMessages(details).toReversed()) |
| 47 |
messages.push(m); |
| 48 |
} |
| 49 |
// This prevents a loop of assistant messages from being at the end |
| 50 |
if (type === 'message' && details.role === 'user') { |
| 51 |
foundUserMessage = true; |
| 52 |
} |
| 53 |
if (type === 'message' && !foundUserMessage) continue; |
| 54 |
if (type === 'message') messages.push(details); |
| 55 |
} |
| 56 |
return messages.toReversed(); |
| 57 |
}, |
| 58 |
// Most recent consecutive runs — another workflow in between resets the |
| 59 |
// memory. |
| 60 |
getMessagesFor: (workflowId) => { |
| 61 |
if (!workflowId) return []; |
| 62 |
const segments = []; |
| 63 |
let segment = []; |
| 64 |
for (const { type, details } of get().messages) { |
| 65 |
const finished = ['completed', 'canceled'].includes(details.status); |
| 66 |
if (['workflow', 'workflow-component'].includes(type) && finished) { |
| 67 |
segments.push({ workflowId: details.workflowId, segment }); |
| 68 |
segment = []; |
| 69 |
continue; |
| 70 |
} |
| 71 |
// An error ended a run without a marker; what precedes it is dead. |
| 72 |
if (type === 'message' && details.error) { |
| 73 |
segment = []; |
| 74 |
continue; |
| 75 |
} |
| 76 |
if (type === 'tool' && 'result' in details) { |
| 77 |
segment.push(...buildToolMessages(details, { summarize: true })); |
| 78 |
continue; |
| 79 |
} |
| 80 |
if (type === 'message') segment.push(details); |
| 81 |
} |
| 82 |
const broken = segments.findLastIndex( |
| 83 |
(finished) => finished.workflowId !== workflowId, |
| 84 |
); |
| 85 |
return segments.slice(broken + 1).flatMap(({ segment }) => segment); |
| 86 |
}, |
| 87 |
getLastAssistantMessage: () => |
| 88 |
get()?.messages?.findLast( |
| 89 |
(message) => |
| 90 |
message.type === 'message' && message.details?.role === 'assistant', |
| 91 |
), |
| 92 |
hasMessages: () => get().messages.length > 0, |
| 93 |
addMessage: (type, details) => { |
| 94 |
const id = makeId(); |
| 95 |
set((state) => { |
| 96 |
// max 250 messages |
| 97 |
const max = Math.max(0, state.messages.length - 249); |
| 98 |
const next = { id, type, details }; |
| 99 |
return { |
| 100 |
// { id: 1, type: message, details: { role: 'user', content: 'Hello' } } |
| 101 |
// { id: 2, type: message, details: { role: 'assistant', content: 'Hi there!' } } |
| 102 |
// { id: 3, type: workflow, details: { name: 'Workflow 1' } } |
| 103 |
messages: [...state.messages.toSpliced(0, max), next], |
| 104 |
}; |
| 105 |
}); |
| 106 |
// A real message supersedes any in-flight progress status. |
| 107 |
useStatusStore.getState().clearStatuses(); |
| 108 |
return id; |
| 109 |
}, |
| 110 |
updateMessage: (id, details) => |
| 111 |
set((state) => ({ |
| 112 |
messages: state.messages.map((message) => |
| 113 |
message.id === id |
| 114 |
? { ...message, details: { ...message.details, ...details } } |
| 115 |
: message, |
| 116 |
), |
| 117 |
})), |
| 118 |
// pop messages all the way back to the last agent message |
| 119 |
popMessage: () => { |
| 120 |
set((state) => ({ |
| 121 |
messages: state.messages?.slice(0, -1) || [], |
| 122 |
})); |
| 123 |
}, |
| 124 |
clearMessages: () => set({ messages: [] }), |
| 125 |
}); |
| 126 |
|
| 127 |
const path = '/extendify/v1/agent/chat-events'; |
| 128 |
const storage = { |
| 129 |
getItem: async () => await apiFetch({ path }), |
| 130 |
setItem: async (_name, state) => |
| 131 |
await apiFetch({ path, method: 'POST', data: { state } }), |
| 132 |
}; |
| 133 |
|
| 134 |
export const useChatStore = create()( |
| 135 |
persist(devtools(state, { name: 'Extendify Agent Chat' }), { |
| 136 |
name: `extendify-agent-chat-${window.extSharedData.siteId}`, |
| 137 |
storage: createJSONStorage(() => storage), |
| 138 |
skipHydration: true, |
| 139 |
}), |
| 140 |
); |
| 141 |
|