@makemore/agent-frontend 1.8.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +140 -7
- package/dist/chat-widget.css +611 -1
- package/dist/chat-widget.js +305 -1376
- package/package.json +19 -7
- package/src/components/ChatWidget.js +259 -0
- package/src/components/Header.js +111 -0
- package/src/components/InputForm.js +95 -0
- package/src/components/Message.js +115 -0
- package/src/components/MessageList.js +106 -0
- package/src/components/ModelSelector.js +68 -0
- package/src/components/Sidebar.js +58 -0
- package/src/hooks/useChat.js +455 -0
- package/src/hooks/useModels.js +69 -0
- package/src/index.js +222 -0
- package/src/utils/api.js +90 -0
- package/src/utils/config.js +83 -0
- package/src/utils/helpers.js +83 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Models hook - manages available models and selection
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { useState, useEffect, useCallback } from 'preact/hooks';
|
|
6
|
+
|
|
7
|
+
export function useModels(config, api, storage) {
|
|
8
|
+
const [availableModels, setAvailableModels] = useState([]);
|
|
9
|
+
const [selectedModel, setSelectedModel] = useState(null);
|
|
10
|
+
const [defaultModel, setDefaultModel] = useState(null);
|
|
11
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
12
|
+
|
|
13
|
+
// Load available models on mount
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
const loadModels = async () => {
|
|
16
|
+
if (!config.showModelSelector) return;
|
|
17
|
+
|
|
18
|
+
setIsLoading(true);
|
|
19
|
+
try {
|
|
20
|
+
const response = await fetch(
|
|
21
|
+
`${config.backendUrl}${config.apiPaths.models}`,
|
|
22
|
+
api.getFetchOptions({ method: 'GET' })
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
if (response.ok) {
|
|
26
|
+
const data = await response.json();
|
|
27
|
+
const models = data.models || [];
|
|
28
|
+
setAvailableModels(models);
|
|
29
|
+
setDefaultModel(data.default);
|
|
30
|
+
|
|
31
|
+
// Restore saved model or use default
|
|
32
|
+
const savedModel = storage?.get(config.modelKey);
|
|
33
|
+
if (savedModel && models.some(m => m.id === savedModel)) {
|
|
34
|
+
setSelectedModel(savedModel);
|
|
35
|
+
} else {
|
|
36
|
+
setSelectedModel(data.default);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
} catch (err) {
|
|
40
|
+
console.warn('[ChatWidget] Failed to load models:', err);
|
|
41
|
+
} finally {
|
|
42
|
+
setIsLoading(false);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
loadModels();
|
|
47
|
+
}, [config.backendUrl, config.apiPaths.models, config.showModelSelector, config.modelKey, api, storage]);
|
|
48
|
+
|
|
49
|
+
// Select a model
|
|
50
|
+
const selectModel = useCallback((modelId) => {
|
|
51
|
+
setSelectedModel(modelId);
|
|
52
|
+
storage?.set(config.modelKey, modelId);
|
|
53
|
+
}, [config.modelKey, storage]);
|
|
54
|
+
|
|
55
|
+
// Get the currently selected model object
|
|
56
|
+
const getSelectedModelInfo = useCallback(() => {
|
|
57
|
+
return availableModels.find(m => m.id === selectedModel) || null;
|
|
58
|
+
}, [availableModels, selectedModel]);
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
availableModels,
|
|
62
|
+
selectedModel,
|
|
63
|
+
defaultModel,
|
|
64
|
+
isLoading,
|
|
65
|
+
selectModel,
|
|
66
|
+
getSelectedModelInfo,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
package/src/index.js
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chat Widget - Main Entry Point
|
|
3
|
+
*
|
|
4
|
+
* Provides the same public API as the original vanilla JS version:
|
|
5
|
+
* - ChatWidget.init(config) - Initialize singleton instance
|
|
6
|
+
* - ChatWidget.createInstance(config) - Create multiple instances
|
|
7
|
+
* - ChatWidget.open/close/send/etc - Control the widget
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { render, h } from 'preact';
|
|
11
|
+
import { html } from 'htm/preact';
|
|
12
|
+
import { ChatWidget as ChatWidgetComponent } from './components/ChatWidget.js';
|
|
13
|
+
import { mergeConfig } from './utils/config.js';
|
|
14
|
+
|
|
15
|
+
// Track instances
|
|
16
|
+
const instances = new Map();
|
|
17
|
+
let instanceCounter = 0;
|
|
18
|
+
let defaultInstance = null;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Widget instance wrapper - provides imperative API around the Preact component
|
|
22
|
+
*/
|
|
23
|
+
class ChatWidgetInstance {
|
|
24
|
+
constructor(userConfig = {}) {
|
|
25
|
+
this.instanceId = `cw-${++instanceCounter}`;
|
|
26
|
+
this.config = mergeConfig(userConfig);
|
|
27
|
+
this.container = null;
|
|
28
|
+
this._state = {};
|
|
29
|
+
this._apiRef = { current: null };
|
|
30
|
+
|
|
31
|
+
instances.set(this.instanceId, this);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
_handleStateChange = (state) => {
|
|
35
|
+
this._state = state;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
init() {
|
|
39
|
+
if (this.config.containerId) {
|
|
40
|
+
// Embedded mode: use existing container
|
|
41
|
+
this.container = document.getElementById(this.config.containerId);
|
|
42
|
+
if (!this.container) {
|
|
43
|
+
console.error(`[ChatWidget] Container not found: ${this.config.containerId}`);
|
|
44
|
+
return this;
|
|
45
|
+
}
|
|
46
|
+
this.container.classList.add('cw-container-embedded');
|
|
47
|
+
} else {
|
|
48
|
+
// Floating mode: create container
|
|
49
|
+
this.container = document.createElement('div');
|
|
50
|
+
this.container.id = this.instanceId;
|
|
51
|
+
this.container.className = `cw-container cw-position-${this.config.position}`;
|
|
52
|
+
document.body.appendChild(this.container);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Render the Preact component
|
|
56
|
+
this._render();
|
|
57
|
+
|
|
58
|
+
console.log(`[ChatWidget] Instance ${this.instanceId} initialized`);
|
|
59
|
+
return this;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
_render(configOverrides = {}) {
|
|
63
|
+
if (!this.container) return;
|
|
64
|
+
render(
|
|
65
|
+
html`<${ChatWidgetComponent}
|
|
66
|
+
config=${{ ...this.config, ...configOverrides }}
|
|
67
|
+
onStateChange=${this._handleStateChange}
|
|
68
|
+
markdownParser=${ChatWidget._enhancedMarkdownParser}
|
|
69
|
+
apiRef=${this._apiRef}
|
|
70
|
+
/>`,
|
|
71
|
+
this.container
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
destroy() {
|
|
76
|
+
if (this.container) {
|
|
77
|
+
render(null, this.container);
|
|
78
|
+
|
|
79
|
+
if (this.config.containerId) {
|
|
80
|
+
this.container.classList.remove('cw-container-embedded');
|
|
81
|
+
} else {
|
|
82
|
+
this.container.remove();
|
|
83
|
+
}
|
|
84
|
+
this.container = null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
instances.delete(this.instanceId);
|
|
88
|
+
console.log(`[ChatWidget] Instance ${this.instanceId} destroyed`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Public API methods - delegate to component via apiRef
|
|
92
|
+
open() {
|
|
93
|
+
if (this._apiRef.current) {
|
|
94
|
+
this._apiRef.current.open();
|
|
95
|
+
} else {
|
|
96
|
+
this._render({ forceOpen: true });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
close() {
|
|
101
|
+
if (this._apiRef.current) {
|
|
102
|
+
this._apiRef.current.close();
|
|
103
|
+
} else {
|
|
104
|
+
this._render({ forceOpen: false });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
send(message) {
|
|
109
|
+
if (this._apiRef.current) {
|
|
110
|
+
this._apiRef.current.send(message);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
clearMessages() {
|
|
115
|
+
if (this._apiRef.current) {
|
|
116
|
+
this._apiRef.current.clearMessages();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
toggleTTS() {
|
|
121
|
+
if (this._apiRef.current) {
|
|
122
|
+
this._apiRef.current.toggleTTS();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
stopSpeech() {
|
|
127
|
+
if (this._apiRef.current) {
|
|
128
|
+
this._apiRef.current.stopSpeech();
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
setAuth(config) {
|
|
133
|
+
if (this._apiRef.current) {
|
|
134
|
+
this._apiRef.current.setAuth(config);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
clearAuth() {
|
|
139
|
+
if (this._apiRef.current) {
|
|
140
|
+
this._apiRef.current.clearAuth();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
getState() {
|
|
145
|
+
return { ...this._state };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
getConfig() {
|
|
149
|
+
return { ...this.config };
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ============================================================================
|
|
154
|
+
// Public API
|
|
155
|
+
// ============================================================================
|
|
156
|
+
|
|
157
|
+
function createInstance(config = {}) {
|
|
158
|
+
const instance = new ChatWidgetInstance(config);
|
|
159
|
+
return instance.init();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function init(config = {}) {
|
|
163
|
+
if (defaultInstance) {
|
|
164
|
+
defaultInstance.destroy();
|
|
165
|
+
}
|
|
166
|
+
defaultInstance = createInstance(config);
|
|
167
|
+
return defaultInstance;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function destroy() {
|
|
171
|
+
if (defaultInstance) {
|
|
172
|
+
defaultInstance.destroy();
|
|
173
|
+
defaultInstance = null;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Proxy methods to default instance
|
|
178
|
+
function open() { if (defaultInstance) defaultInstance.open(); }
|
|
179
|
+
function close() { if (defaultInstance) defaultInstance.close(); }
|
|
180
|
+
function send(message) { if (defaultInstance) defaultInstance.send(message); }
|
|
181
|
+
function clearMessages() { if (defaultInstance) defaultInstance.clearMessages(); }
|
|
182
|
+
function toggleTTS() { if (defaultInstance) defaultInstance.toggleTTS(); }
|
|
183
|
+
function stopSpeech() { if (defaultInstance) defaultInstance.stopSpeech(); }
|
|
184
|
+
function setAuth(config) { if (defaultInstance) defaultInstance.setAuth(config); }
|
|
185
|
+
function clearAuth() { if (defaultInstance) defaultInstance.clearAuth(); }
|
|
186
|
+
function getState() { return defaultInstance ? defaultInstance.getState() : null; }
|
|
187
|
+
function getConfig() { return defaultInstance ? defaultInstance.getConfig() : null; }
|
|
188
|
+
|
|
189
|
+
// Export public API
|
|
190
|
+
const ChatWidget = {
|
|
191
|
+
// Multi-instance API
|
|
192
|
+
createInstance,
|
|
193
|
+
getInstance: (id) => instances.get(id),
|
|
194
|
+
getAllInstances: () => Array.from(instances.values()),
|
|
195
|
+
|
|
196
|
+
// Singleton API (backwards compatible)
|
|
197
|
+
init,
|
|
198
|
+
destroy,
|
|
199
|
+
open,
|
|
200
|
+
close,
|
|
201
|
+
send,
|
|
202
|
+
clearMessages,
|
|
203
|
+
toggleTTS,
|
|
204
|
+
stopSpeech,
|
|
205
|
+
setAuth,
|
|
206
|
+
clearAuth,
|
|
207
|
+
getState,
|
|
208
|
+
getConfig,
|
|
209
|
+
|
|
210
|
+
// For markdown plugin
|
|
211
|
+
_enhancedMarkdownParser: null,
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
// Export for both module and global usage
|
|
215
|
+
export { ChatWidget };
|
|
216
|
+
export default ChatWidget;
|
|
217
|
+
|
|
218
|
+
// Also attach to window for script tag usage
|
|
219
|
+
if (typeof window !== 'undefined') {
|
|
220
|
+
window.ChatWidget = ChatWidget;
|
|
221
|
+
}
|
|
222
|
+
|
package/src/utils/api.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API utilities for the chat widget
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { getCSRFToken } from './helpers.js';
|
|
6
|
+
|
|
7
|
+
export function createApiClient(config, getState, setState) {
|
|
8
|
+
const getAuthStrategy = () => {
|
|
9
|
+
if (config.authStrategy) return config.authStrategy;
|
|
10
|
+
if (config.authToken) return 'token';
|
|
11
|
+
if (config.apiPaths.anonymousSession || config.anonymousSessionEndpoint) return 'anonymous';
|
|
12
|
+
return 'none';
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const getAuthHeaders = () => {
|
|
16
|
+
const strategy = getAuthStrategy();
|
|
17
|
+
const headers = {};
|
|
18
|
+
const token = config.authToken || getState().authToken;
|
|
19
|
+
|
|
20
|
+
if (strategy === 'token' && token) {
|
|
21
|
+
const headerName = config.authHeader || 'Authorization';
|
|
22
|
+
const prefix = config.authTokenPrefix !== undefined ? config.authTokenPrefix : 'Token';
|
|
23
|
+
headers[headerName] = prefix ? `${prefix} ${token}` : token;
|
|
24
|
+
} else if (strategy === 'jwt' && token) {
|
|
25
|
+
const headerName = config.authHeader || 'Authorization';
|
|
26
|
+
const prefix = config.authTokenPrefix !== undefined ? config.authTokenPrefix : 'Bearer';
|
|
27
|
+
headers[headerName] = prefix ? `${prefix} ${token}` : token;
|
|
28
|
+
} else if (strategy === 'anonymous' && token) {
|
|
29
|
+
const headerName = config.authHeader || config.anonymousTokenHeader || 'X-Anonymous-Token';
|
|
30
|
+
headers[headerName] = token;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (strategy === 'session') {
|
|
34
|
+
const csrfToken = getCSRFToken(config.csrfCookieName);
|
|
35
|
+
if (csrfToken) {
|
|
36
|
+
headers['X-CSRFToken'] = csrfToken;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return headers;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const getFetchOptions = (options = {}) => {
|
|
44
|
+
const strategy = getAuthStrategy();
|
|
45
|
+
const fetchOptions = { ...options };
|
|
46
|
+
fetchOptions.headers = { ...fetchOptions.headers, ...getAuthHeaders() };
|
|
47
|
+
if (strategy === 'session') fetchOptions.credentials = 'include';
|
|
48
|
+
return fetchOptions;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const getOrCreateSession = async () => {
|
|
52
|
+
const strategy = getAuthStrategy();
|
|
53
|
+
const state = getState();
|
|
54
|
+
|
|
55
|
+
if (strategy !== 'anonymous') return config.authToken || state.authToken;
|
|
56
|
+
if (state.authToken) return state.authToken;
|
|
57
|
+
|
|
58
|
+
const storageKey = config.anonymousTokenKey || config.sessionTokenKey;
|
|
59
|
+
const stored = state.storage?.get(storageKey);
|
|
60
|
+
if (stored) {
|
|
61
|
+
setState(s => ({ ...s, authToken: stored }));
|
|
62
|
+
return stored;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
const endpoint = config.anonymousSessionEndpoint || config.apiPaths.anonymousSession;
|
|
67
|
+
const response = await fetch(`${config.backendUrl}${endpoint}`, {
|
|
68
|
+
method: 'POST',
|
|
69
|
+
headers: { 'Content-Type': 'application/json' },
|
|
70
|
+
});
|
|
71
|
+
if (response.ok) {
|
|
72
|
+
const data = await response.json();
|
|
73
|
+
setState(s => ({ ...s, authToken: data.token }));
|
|
74
|
+
state.storage?.set(storageKey, data.token);
|
|
75
|
+
return data.token;
|
|
76
|
+
}
|
|
77
|
+
} catch (e) {
|
|
78
|
+
console.warn('[ChatWidget] Failed to create session:', e);
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
getAuthStrategy,
|
|
85
|
+
getAuthHeaders,
|
|
86
|
+
getFetchOptions,
|
|
87
|
+
getOrCreateSession,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default configuration for the chat widget
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_CONFIG = {
|
|
6
|
+
backendUrl: 'http://localhost:8000',
|
|
7
|
+
agentKey: 'default-agent',
|
|
8
|
+
title: 'Chat Assistant',
|
|
9
|
+
subtitle: 'How can we help you today?',
|
|
10
|
+
primaryColor: '#0066cc',
|
|
11
|
+
position: 'bottom-right',
|
|
12
|
+
defaultJourneyType: 'general',
|
|
13
|
+
enableDebugMode: true,
|
|
14
|
+
enableAutoRun: true,
|
|
15
|
+
journeyTypes: {},
|
|
16
|
+
customerPrompts: {},
|
|
17
|
+
placeholder: 'Type your message...',
|
|
18
|
+
emptyStateTitle: 'Start a Conversation',
|
|
19
|
+
emptyStateMessage: 'Send a message to get started.',
|
|
20
|
+
|
|
21
|
+
// Auth
|
|
22
|
+
authStrategy: null,
|
|
23
|
+
authToken: null,
|
|
24
|
+
authHeader: null,
|
|
25
|
+
authTokenPrefix: null,
|
|
26
|
+
anonymousSessionEndpoint: null,
|
|
27
|
+
anonymousTokenKey: 'chat_widget_anonymous_token',
|
|
28
|
+
onAuthError: null,
|
|
29
|
+
anonymousTokenHeader: 'X-Anonymous-Token',
|
|
30
|
+
conversationIdKey: 'chat_widget_conversation_id',
|
|
31
|
+
sessionTokenKey: 'chat_widget_session_token',
|
|
32
|
+
|
|
33
|
+
// API paths
|
|
34
|
+
apiPaths: {
|
|
35
|
+
anonymousSession: '/api/accounts/anonymous-session/',
|
|
36
|
+
conversations: '/api/agent-runtime/conversations/',
|
|
37
|
+
runs: '/api/agent-runtime/runs/',
|
|
38
|
+
runEvents: '/api/agent-runtime/runs/{runId}/events/',
|
|
39
|
+
simulateCustomer: '/api/agent-runtime/simulate-customer/',
|
|
40
|
+
ttsVoices: '/api/tts/voices/',
|
|
41
|
+
ttsSetVoice: '/api/tts/set-voice/',
|
|
42
|
+
models: '/api/agent-runtime/models/',
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
// UI options
|
|
46
|
+
showConversationSidebar: true,
|
|
47
|
+
showClearButton: true,
|
|
48
|
+
showDebugButton: true,
|
|
49
|
+
showTTSButton: true,
|
|
50
|
+
showVoiceSettings: true,
|
|
51
|
+
showExpandButton: true,
|
|
52
|
+
showModelSelector: false,
|
|
53
|
+
|
|
54
|
+
// Model selection
|
|
55
|
+
modelKey: 'chat_widget_selected_model',
|
|
56
|
+
|
|
57
|
+
// Auto-run
|
|
58
|
+
autoRunDelay: 1000,
|
|
59
|
+
autoRunMode: 'automatic',
|
|
60
|
+
|
|
61
|
+
// TTS
|
|
62
|
+
enableTTS: false,
|
|
63
|
+
ttsProxyUrl: null,
|
|
64
|
+
elevenLabsApiKey: null,
|
|
65
|
+
ttsVoices: { assistant: null, user: null },
|
|
66
|
+
ttsModel: 'eleven_turbo_v2_5',
|
|
67
|
+
ttsSettings: { stability: 0.5, similarity_boost: 0.75, style: 0.0, use_speaker_boost: true },
|
|
68
|
+
availableVoices: [],
|
|
69
|
+
|
|
70
|
+
// Callbacks
|
|
71
|
+
onEvent: null,
|
|
72
|
+
|
|
73
|
+
// Multi-instance options
|
|
74
|
+
containerId: null,
|
|
75
|
+
embedded: false,
|
|
76
|
+
metadata: {},
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export function mergeConfig(userConfig) {
|
|
80
|
+
const mergedApiPaths = { ...DEFAULT_CONFIG.apiPaths, ...(userConfig.apiPaths || {}) };
|
|
81
|
+
return { ...DEFAULT_CONFIG, ...userConfig, apiPaths: mergedApiPaths };
|
|
82
|
+
}
|
|
83
|
+
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Utility functions for the chat widget
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export function generateId() {
|
|
6
|
+
return 'msg-' + Date.now() + '-' + Math.random().toString(36).substr(2, 9);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function escapeHtml(text) {
|
|
10
|
+
const div = document.createElement('div');
|
|
11
|
+
div.textContent = text;
|
|
12
|
+
return div.innerHTML;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function formatDate(dateStr) {
|
|
16
|
+
if (!dateStr) return '';
|
|
17
|
+
try {
|
|
18
|
+
const date = new Date(dateStr);
|
|
19
|
+
const now = new Date();
|
|
20
|
+
const diffMs = now - date;
|
|
21
|
+
const diffMins = Math.floor(diffMs / 60000);
|
|
22
|
+
const diffHours = Math.floor(diffMs / 3600000);
|
|
23
|
+
const diffDays = Math.floor(diffMs / 86400000);
|
|
24
|
+
|
|
25
|
+
if (diffMins < 1) return 'Just now';
|
|
26
|
+
if (diffMins < 60) return `${diffMins}m ago`;
|
|
27
|
+
if (diffHours < 24) return `${diffHours}h ago`;
|
|
28
|
+
if (diffDays < 7) return `${diffDays}d ago`;
|
|
29
|
+
|
|
30
|
+
return date.toLocaleDateString();
|
|
31
|
+
} catch (e) {
|
|
32
|
+
return '';
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function parseMarkdown(text, enhancedParser = null) {
|
|
37
|
+
if (enhancedParser) {
|
|
38
|
+
return enhancedParser(text);
|
|
39
|
+
}
|
|
40
|
+
let html = escapeHtml(text);
|
|
41
|
+
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
|
42
|
+
html = html.replace(/__(.+?)__/g, '<strong>$1</strong>');
|
|
43
|
+
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
|
|
44
|
+
html = html.replace(/_(.+?)_/g, '<em>$1</em>');
|
|
45
|
+
html = html.replace(/`(.+?)`/g, '<code>$1</code>');
|
|
46
|
+
html = html.replace(/\[(.+?)\]\((.+?)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
|
|
47
|
+
html = html.replace(/\n/g, '<br>');
|
|
48
|
+
return html;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Storage helpers
|
|
52
|
+
export function createStorage(prefix = '') {
|
|
53
|
+
const storageKey = (key) => prefix ? `${key}_${prefix}` : key;
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
get(key) {
|
|
57
|
+
try { return localStorage.getItem(storageKey(key)); } catch (e) { return null; }
|
|
58
|
+
},
|
|
59
|
+
set(key, value) {
|
|
60
|
+
try {
|
|
61
|
+
const k = storageKey(key);
|
|
62
|
+
value === null ? localStorage.removeItem(k) : localStorage.setItem(k, value);
|
|
63
|
+
} catch (e) {}
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// CSRF token helper
|
|
69
|
+
export function getCSRFToken(cookieName = 'csrftoken') {
|
|
70
|
+
const cookies = document.cookie.split(';');
|
|
71
|
+
for (let cookie of cookies) {
|
|
72
|
+
const [name, value] = cookie.trim().split('=');
|
|
73
|
+
if (name === cookieName) {
|
|
74
|
+
return decodeURIComponent(value);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const metaTag = document.querySelector('meta[name="csrf-token"]');
|
|
78
|
+
if (metaTag) {
|
|
79
|
+
return metaTag.getAttribute('content');
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|