@memori.ai/memori-react 8.43.0 → 8.44.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/CHANGELOG.md +34 -0
- package/dist/components/Avatar/AvatarView/AvatarComponent/constants.d.ts +1 -1
- package/dist/components/Avatar/AvatarView/AvatarComponent/constants.js +1 -1
- package/dist/components/MemoriArtifactSystem/components/ArtifactDrawer/ArtifactDrawer.js +1 -0
- package/dist/components/MemoriArtifactSystem/components/ArtifactDrawer/ArtifactDrawer.js.map +1 -1
- package/dist/components/MemoriArtifactSystem/components/ArtifactHandler/ArtifactHandler.js +204 -59
- package/dist/components/MemoriArtifactSystem/components/ArtifactHandler/ArtifactHandler.js.map +1 -1
- package/dist/components/MemoriArtifactSystem/context/ArtifactContext.d.ts +3 -1
- package/dist/components/MemoriArtifactSystem/context/ArtifactContext.js +76 -16
- package/dist/components/MemoriArtifactSystem/context/ArtifactContext.js.map +1 -1
- package/dist/components/MemoriArtifactSystem/types/artifact.types.d.ts +11 -0
- package/dist/components/MemoriArtifactSystem/utils/ArtifactAPI.js +18 -13
- package/dist/components/MemoriArtifactSystem/utils/ArtifactAPI.js.map +1 -1
- package/dist/components/MemoriArtifactSystem/utils/applyEdits.d.ts +8 -0
- package/dist/components/MemoriArtifactSystem/utils/applyEdits.js +117 -0
- package/dist/components/MemoriArtifactSystem/utils/applyEdits.js.map +1 -0
- package/dist/helpers/message.js +3 -0
- package/dist/helpers/message.js.map +1 -1
- package/dist/locales/de.json +3 -0
- package/dist/locales/en.json +3 -0
- package/dist/locales/es.json +3 -0
- package/dist/locales/fr.json +3 -0
- package/dist/locales/it.json +3 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/esm/components/Avatar/AvatarView/AvatarComponent/constants.d.ts +1 -1
- package/esm/components/Avatar/AvatarView/AvatarComponent/constants.js +1 -1
- package/esm/components/MemoriArtifactSystem/components/ArtifactDrawer/ArtifactDrawer.js +1 -0
- package/esm/components/MemoriArtifactSystem/components/ArtifactDrawer/ArtifactDrawer.js.map +1 -1
- package/esm/components/MemoriArtifactSystem/components/ArtifactHandler/ArtifactHandler.js +205 -60
- package/esm/components/MemoriArtifactSystem/components/ArtifactHandler/ArtifactHandler.js.map +1 -1
- package/esm/components/MemoriArtifactSystem/context/ArtifactContext.d.ts +3 -1
- package/esm/components/MemoriArtifactSystem/context/ArtifactContext.js +77 -17
- package/esm/components/MemoriArtifactSystem/context/ArtifactContext.js.map +1 -1
- package/esm/components/MemoriArtifactSystem/types/artifact.types.d.ts +11 -0
- package/esm/components/MemoriArtifactSystem/utils/ArtifactAPI.js +18 -13
- package/esm/components/MemoriArtifactSystem/utils/ArtifactAPI.js.map +1 -1
- package/esm/components/MemoriArtifactSystem/utils/applyEdits.d.ts +8 -0
- package/esm/components/MemoriArtifactSystem/utils/applyEdits.js +112 -0
- package/esm/components/MemoriArtifactSystem/utils/applyEdits.js.map +1 -0
- package/esm/helpers/message.js +3 -0
- package/esm/helpers/message.js.map +1 -1
- package/esm/locales/de.json +3 -0
- package/esm/locales/en.json +3 -0
- package/esm/locales/es.json +3 -0
- package/esm/locales/fr.json +3 -0
- package/esm/locales/it.json +3 -0
- package/esm/version.d.ts +1 -1
- package/esm/version.js +1 -1
- package/package.json +1 -1
- package/src/components/Avatar/AvatarView/AvatarComponent/constants.ts +1 -1
- package/src/components/MemoriArtifactSystem/components/ArtifactDrawer/ArtifactDrawer.tsx +1 -0
- package/src/components/MemoriArtifactSystem/components/ArtifactHandler/ArtifactHandler.tsx +324 -133
- package/src/components/MemoriArtifactSystem/context/ArtifactContext.tsx +122 -24
- package/src/components/MemoriArtifactSystem/types/artifact.types.ts +20 -1
- package/src/components/MemoriArtifactSystem/utils/ArtifactAPI.test.tsx +1 -0
- package/src/components/MemoriArtifactSystem/utils/ArtifactAPI.tsx +19 -13
- package/src/components/MemoriArtifactSystem/utils/applyEdits.test.ts +85 -0
- package/src/components/MemoriArtifactSystem/utils/applyEdits.ts +173 -0
- package/src/helpers/message.ts +15 -0
- package/src/locales/de.json +3 -0
- package/src/locales/en.json +3 -0
- package/src/locales/es.json +3 -0
- package/src/locales/fr.json +3 -0
- package/src/locales/it.json +3 -0
- package/src/version.ts +1 -1
|
@@ -1,11 +1,30 @@
|
|
|
1
|
-
import React, {
|
|
2
|
-
|
|
1
|
+
import React, {
|
|
2
|
+
createContext,
|
|
3
|
+
useContext,
|
|
4
|
+
useState,
|
|
5
|
+
ReactNode,
|
|
6
|
+
useCallback,
|
|
7
|
+
useMemo,
|
|
8
|
+
useRef,
|
|
9
|
+
} from 'react';
|
|
10
|
+
import {
|
|
11
|
+
ArtifactData,
|
|
12
|
+
ArtifactEdit,
|
|
13
|
+
ArtifactSystemState,
|
|
14
|
+
ApplyUpdateResult,
|
|
15
|
+
} from '../types/artifact.types';
|
|
16
|
+
import { applyEdits } from '../utils/applyEdits';
|
|
3
17
|
|
|
4
18
|
interface ArtifactContextType {
|
|
5
19
|
state: ArtifactSystemState;
|
|
6
20
|
openArtifact: (artifact: ArtifactData) => void;
|
|
7
21
|
closeArtifact: () => void;
|
|
8
22
|
toggleFullscreen: () => void;
|
|
23
|
+
registerArtifact: (artifact: ArtifactData) => void;
|
|
24
|
+
applyArtifactUpdate: (
|
|
25
|
+
artifactId: string,
|
|
26
|
+
edits: ArtifactEdit[]
|
|
27
|
+
) => ApplyUpdateResult;
|
|
9
28
|
}
|
|
10
29
|
|
|
11
30
|
const ArtifactContext = createContext<ArtifactContextType | null>(null);
|
|
@@ -17,26 +36,26 @@ export const ArtifactProvider = ({ children }: { children: ReactNode }) => {
|
|
|
17
36
|
isFullscreen: false,
|
|
18
37
|
});
|
|
19
38
|
|
|
39
|
+
// Synchronous registry keyed by stable artifactId → latest version.
|
|
40
|
+
// useRef avoids race where update handlers read empty React state
|
|
41
|
+
// right after a create in the same render cycle.
|
|
42
|
+
const registryRef = useRef<Record<string, ArtifactData>>({});
|
|
43
|
+
|
|
20
44
|
const openArtifact = useCallback((artifact: ArtifactData) => {
|
|
21
|
-
setState(() => {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
};
|
|
27
|
-
});
|
|
45
|
+
setState(() => ({
|
|
46
|
+
currentArtifact: artifact,
|
|
47
|
+
isDrawerOpen: true,
|
|
48
|
+
isFullscreen: false,
|
|
49
|
+
}));
|
|
28
50
|
}, []);
|
|
29
51
|
|
|
30
52
|
const closeArtifact = useCallback(() => {
|
|
31
|
-
setState(prev => {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
isFullscreen: false,
|
|
38
|
-
};
|
|
39
|
-
});
|
|
53
|
+
setState(prev => ({
|
|
54
|
+
...prev,
|
|
55
|
+
currentArtifact: null,
|
|
56
|
+
isDrawerOpen: false,
|
|
57
|
+
isFullscreen: false,
|
|
58
|
+
}));
|
|
40
59
|
}, []);
|
|
41
60
|
|
|
42
61
|
const toggleFullscreen = useCallback(() => {
|
|
@@ -46,12 +65,91 @@ export const ArtifactProvider = ({ children }: { children: ReactNode }) => {
|
|
|
46
65
|
}));
|
|
47
66
|
}, []);
|
|
48
67
|
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
68
|
+
const registerArtifact = useCallback((artifact: ArtifactData) => {
|
|
69
|
+
if (!artifact?.artifactId) return;
|
|
70
|
+
registryRef.current[artifact.artifactId] = artifact;
|
|
71
|
+
}, []);
|
|
72
|
+
|
|
73
|
+
const applyArtifactUpdate = useCallback(
|
|
74
|
+
(artifactId: string, edits: ArtifactEdit[]): ApplyUpdateResult => {
|
|
75
|
+
const current = registryRef.current[artifactId];
|
|
76
|
+
if (!current) {
|
|
77
|
+
return {
|
|
78
|
+
success: false,
|
|
79
|
+
failedEdits: edits,
|
|
80
|
+
updatedArtifact: null,
|
|
81
|
+
failureReason: 'not-found',
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const { content, failedEdits, appliedCount } = applyEdits(
|
|
86
|
+
current.content,
|
|
87
|
+
edits
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
if (appliedCount === 0) {
|
|
91
|
+
return {
|
|
92
|
+
success: false,
|
|
93
|
+
failedEdits,
|
|
94
|
+
updatedArtifact: null,
|
|
95
|
+
failureReason: 'no-match',
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const updatedArtifact: ArtifactData = {
|
|
100
|
+
id: `artifact-ui-${artifactId}-${Date.now()}-${Math.random()
|
|
101
|
+
.toString(36)
|
|
102
|
+
.slice(2, 8)}`,
|
|
103
|
+
artifactId,
|
|
104
|
+
content,
|
|
105
|
+
mimeType: current.mimeType,
|
|
106
|
+
title: current.title,
|
|
107
|
+
timestamp: new Date(),
|
|
108
|
+
size: content.length,
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
registryRef.current[artifactId] = updatedArtifact;
|
|
112
|
+
|
|
113
|
+
setState(prev => {
|
|
114
|
+
if (
|
|
115
|
+
prev.isDrawerOpen &&
|
|
116
|
+
prev.currentArtifact?.artifactId === artifactId
|
|
117
|
+
) {
|
|
118
|
+
return {
|
|
119
|
+
...prev,
|
|
120
|
+
currentArtifact: updatedArtifact,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
return prev;
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
success: failedEdits.length === 0,
|
|
128
|
+
failedEdits,
|
|
129
|
+
updatedArtifact,
|
|
130
|
+
};
|
|
131
|
+
},
|
|
132
|
+
[]
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
const contextValue = useMemo(
|
|
136
|
+
() => ({
|
|
137
|
+
state,
|
|
138
|
+
openArtifact,
|
|
139
|
+
closeArtifact,
|
|
140
|
+
toggleFullscreen,
|
|
141
|
+
registerArtifact,
|
|
142
|
+
applyArtifactUpdate,
|
|
143
|
+
}),
|
|
144
|
+
[
|
|
145
|
+
state,
|
|
146
|
+
openArtifact,
|
|
147
|
+
closeArtifact,
|
|
148
|
+
toggleFullscreen,
|
|
149
|
+
registerArtifact,
|
|
150
|
+
applyArtifactUpdate,
|
|
151
|
+
]
|
|
152
|
+
);
|
|
55
153
|
|
|
56
154
|
return (
|
|
57
155
|
<ArtifactContext.Provider value={contextValue}>
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
export interface ArtifactData {
|
|
2
|
+
/** Unique UI identifier for this version instance */
|
|
2
3
|
id: string;
|
|
4
|
+
/** Stable lineage id (from data-artifact-id); constant across versions */
|
|
5
|
+
artifactId: string;
|
|
3
6
|
content: string;
|
|
4
7
|
mimeType: string;
|
|
5
8
|
title: string;
|
|
@@ -7,10 +10,26 @@ export interface ArtifactData {
|
|
|
7
10
|
size: number;
|
|
8
11
|
}
|
|
9
12
|
|
|
13
|
+
export interface ArtifactEdit {
|
|
14
|
+
/** Exact string to find and replace (first occurrence) */
|
|
15
|
+
old: string;
|
|
16
|
+
/** Replacement string */
|
|
17
|
+
new: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ApplyUpdateResult {
|
|
21
|
+
success: boolean;
|
|
22
|
+
failedEdits: ArtifactEdit[];
|
|
23
|
+
updatedArtifact: ArtifactData | null;
|
|
24
|
+
/** Why the update failed when updatedArtifact is null */
|
|
25
|
+
failureReason?: 'not-found' | 'no-match';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
10
29
|
export interface ArtifactSystemState {
|
|
11
30
|
currentArtifact: ArtifactData | null;
|
|
12
31
|
isDrawerOpen: boolean;
|
|
13
32
|
isFullscreen: boolean;
|
|
14
33
|
}
|
|
15
34
|
|
|
16
|
-
export type ArtifactTab = 'code' | 'preview';
|
|
35
|
+
export type ArtifactTab = 'code' | 'preview';
|
|
@@ -73,7 +73,7 @@ export const ArtifactAPIBridge = ({
|
|
|
73
73
|
}: {
|
|
74
74
|
pushMessage: (message: Message) => void;
|
|
75
75
|
}) => {
|
|
76
|
-
const { openArtifact, closeArtifact, toggleFullscreen, state } =
|
|
76
|
+
const { openArtifact, closeArtifact, toggleFullscreen, state, registerArtifact } =
|
|
77
77
|
useArtifact();
|
|
78
78
|
|
|
79
79
|
const apiRef = useRef<any>(null);
|
|
@@ -105,12 +105,14 @@ export const ArtifactAPIBridge = ({
|
|
|
105
105
|
title?: string
|
|
106
106
|
) => {
|
|
107
107
|
const autoTitle = title || getTitleFromMimeType(mimeType);
|
|
108
|
+
const artifactId = `api-${Date.now()}-${Math.random()
|
|
109
|
+
.toString(36)
|
|
110
|
+
.substr(2, 9)}`;
|
|
108
111
|
|
|
109
112
|
// Create the artifact object
|
|
110
113
|
const artifact: ArtifactData = {
|
|
111
|
-
id: `artifact-${
|
|
112
|
-
|
|
113
|
-
.substr(2, 9)}`,
|
|
114
|
+
id: `artifact-ui-${artifactId}`,
|
|
115
|
+
artifactId,
|
|
114
116
|
content,
|
|
115
117
|
mimeType,
|
|
116
118
|
title: autoTitle,
|
|
@@ -121,8 +123,8 @@ export const ArtifactAPIBridge = ({
|
|
|
121
123
|
// Create wrapped message text for artifact detection
|
|
122
124
|
// Don't reassign content parameter - use a new variable
|
|
123
125
|
let messageText = content;
|
|
124
|
-
if (!messageText.includes('
|
|
125
|
-
messageText = `<output class="memori-artifact" data-mimetype="${mimeType}" data-title="${autoTitle}">${content}</output>`;
|
|
126
|
+
if (!messageText.includes('class="memori-artifact"')) {
|
|
127
|
+
messageText = `<output class="memori-artifact" data-action="create" data-artifact-id="${artifactId}" data-mimetype="${mimeType}" data-title="${autoTitle}">${content}</output>`;
|
|
126
128
|
}
|
|
127
129
|
|
|
128
130
|
//we have to push in the history the artifact as message
|
|
@@ -143,6 +145,7 @@ export const ArtifactAPIBridge = ({
|
|
|
143
145
|
placeUncertaintyKm: undefined,
|
|
144
146
|
});
|
|
145
147
|
|
|
148
|
+
registerArtifact(artifact);
|
|
146
149
|
// Open the artifact immediately
|
|
147
150
|
openArtifact(artifact);
|
|
148
151
|
},
|
|
@@ -159,15 +162,17 @@ export const ArtifactAPIBridge = ({
|
|
|
159
162
|
const title =
|
|
160
163
|
outputElement.getAttribute('data-title') ||
|
|
161
164
|
getTitleFromMimeType(mimeType);
|
|
165
|
+
const artifactId =
|
|
166
|
+
outputElement.getAttribute('data-artifact-id') ||
|
|
167
|
+
`api-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
162
168
|
|
|
163
169
|
// Nascondi l'elemento originale
|
|
164
170
|
outputElement.style.display = 'none';
|
|
165
171
|
outputElement.setAttribute('data-memori-processed', 'true');
|
|
166
172
|
|
|
167
173
|
const artifact: ArtifactData = {
|
|
168
|
-
id: `artifact-${
|
|
169
|
-
|
|
170
|
-
.substr(2, 9)}`,
|
|
174
|
+
id: `artifact-ui-${artifactId}`,
|
|
175
|
+
artifactId,
|
|
171
176
|
content,
|
|
172
177
|
mimeType,
|
|
173
178
|
title,
|
|
@@ -178,8 +183,8 @@ export const ArtifactAPIBridge = ({
|
|
|
178
183
|
// Create wrapped message text for artifact detection
|
|
179
184
|
// Don't reassign content - use a new variable
|
|
180
185
|
let messageText = content;
|
|
181
|
-
if (!messageText.includes('
|
|
182
|
-
messageText = `<output class="memori-artifact" data-mimetype="${mimeType}" data-title="${title}">${content}</output>`;
|
|
186
|
+
if (!messageText.includes('class="memori-artifact"')) {
|
|
187
|
+
messageText = `<output class="memori-artifact" data-action="create" data-artifact-id="${artifactId}" data-mimetype="${mimeType}" data-title="${title}">${content}</output>`;
|
|
183
188
|
}
|
|
184
189
|
|
|
185
190
|
pushMessage({
|
|
@@ -199,10 +204,11 @@ export const ArtifactAPIBridge = ({
|
|
|
199
204
|
placeUncertaintyKm: undefined,
|
|
200
205
|
});
|
|
201
206
|
|
|
207
|
+
registerArtifact(artifact);
|
|
202
208
|
// Open the artifact immediately
|
|
203
209
|
openArtifact(artifact);
|
|
204
210
|
|
|
205
|
-
return artifact.
|
|
211
|
+
return artifact.artifactId;
|
|
206
212
|
},
|
|
207
213
|
|
|
208
214
|
/**
|
|
@@ -283,7 +289,7 @@ export const ArtifactAPIBridge = ({
|
|
|
283
289
|
delete (window as any).MemoriArtifactAPI;
|
|
284
290
|
}
|
|
285
291
|
};
|
|
286
|
-
}, [openArtifact, closeArtifact, toggleFullscreen, state, pushMessage]);
|
|
292
|
+
}, [openArtifact, closeArtifact, toggleFullscreen, state, pushMessage, registerArtifact]);
|
|
287
293
|
|
|
288
294
|
return null;
|
|
289
295
|
};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { applyEdits, parseEdits } from './applyEdits';
|
|
2
|
+
|
|
3
|
+
describe('applyEdits', () => {
|
|
4
|
+
it('applies an exact string replacement', () => {
|
|
5
|
+
const result = applyEdits('Hello 🌰 World', [{ old: '🌰', new: '🥜' }]);
|
|
6
|
+
expect(result.content).toBe('Hello 🥜 World');
|
|
7
|
+
expect(result.appliedCount).toBe(1);
|
|
8
|
+
expect(result.failedEdits).toEqual([]);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('replaces only the first occurrence', () => {
|
|
12
|
+
const result = applyEdits('aa bb aa', [{ old: 'aa', new: 'XX' }]);
|
|
13
|
+
expect(result.content).toBe('XX bb aa');
|
|
14
|
+
expect(result.appliedCount).toBe(1);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('applies multiple edits sequentially', () => {
|
|
18
|
+
const result = applyEdits('<h1>Title</h1><p>Body</p>', [
|
|
19
|
+
{ old: 'Title', new: 'Heading' },
|
|
20
|
+
{ old: 'Body', new: 'Content' },
|
|
21
|
+
]);
|
|
22
|
+
expect(result.content).toBe('<h1>Heading</h1><p>Content</p>');
|
|
23
|
+
expect(result.appliedCount).toBe(2);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('falls back to whitespace-tolerant matching', () => {
|
|
27
|
+
const content = '<span class="x">\n hello world\n</span>';
|
|
28
|
+
const result = applyEdits(content, [
|
|
29
|
+
{ old: 'hello world', new: 'hi there' },
|
|
30
|
+
]);
|
|
31
|
+
expect(result.content).toContain('hi there');
|
|
32
|
+
expect(result.appliedCount).toBe(1);
|
|
33
|
+
expect(result.failedEdits).toEqual([]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('collects failed edits without blocking others', () => {
|
|
37
|
+
const result = applyEdits('alpha beta', [
|
|
38
|
+
{ old: 'missing', new: 'nope' },
|
|
39
|
+
{ old: 'beta', new: 'gamma' },
|
|
40
|
+
]);
|
|
41
|
+
expect(result.content).toBe('alpha gamma');
|
|
42
|
+
expect(result.appliedCount).toBe(1);
|
|
43
|
+
expect(result.failedEdits).toEqual([{ old: 'missing', new: 'nope' }]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('returns original content when all edits fail', () => {
|
|
47
|
+
const result = applyEdits('unchanged', [
|
|
48
|
+
{ old: 'foo', new: 'bar' },
|
|
49
|
+
]);
|
|
50
|
+
expect(result.content).toBe('unchanged');
|
|
51
|
+
expect(result.appliedCount).toBe(0);
|
|
52
|
+
expect(result.failedEdits).toHaveLength(1);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('parseEdits', () => {
|
|
57
|
+
it('parses a valid JSON edits array', () => {
|
|
58
|
+
const edits = parseEdits('[{"old": "a", "new": "b"}]');
|
|
59
|
+
expect(edits).toEqual([{ old: 'a', new: 'b' }]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('unescapes HTML entities before parsing', () => {
|
|
63
|
+
const edits = parseEdits(
|
|
64
|
+
'[{"old": "🌰", "new": "🥜"}]'
|
|
65
|
+
);
|
|
66
|
+
expect(edits).toEqual([{ old: '🌰', new: '🥜' }]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('returns null for invalid JSON', () => {
|
|
70
|
+
expect(parseEdits('not json')).toBeNull();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('returns null for non-array JSON', () => {
|
|
74
|
+
expect(parseEdits('{"old":"a","new":"b"}')).toBeNull();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('returns null for array items missing old/new', () => {
|
|
78
|
+
expect(parseEdits('[{"foo":"bar"}]')).toBeNull();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('returns null for empty body', () => {
|
|
82
|
+
expect(parseEdits('')).toBeNull();
|
|
83
|
+
expect(parseEdits(' ')).toBeNull();
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { ArtifactEdit } from '../types/artifact.types';
|
|
2
|
+
|
|
3
|
+
export interface ApplyEditsResult {
|
|
4
|
+
content: string;
|
|
5
|
+
failedEdits: ArtifactEdit[];
|
|
6
|
+
appliedCount: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Normalize runs of whitespace to a single space for tolerant matching.
|
|
11
|
+
*/
|
|
12
|
+
const normalizeWhitespace = (s: string): string => s.replace(/\s+/g, ' ');
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Find the first occurrence of `needle` in `haystack` after normalizing
|
|
16
|
+
* whitespace in both, then map the match back to original haystack indices.
|
|
17
|
+
* Returns [start, end] in original string coords, or null if not found.
|
|
18
|
+
*/
|
|
19
|
+
const findWhitespaceTolerant = (
|
|
20
|
+
haystack: string,
|
|
21
|
+
needle: string
|
|
22
|
+
): [number, number] | null => {
|
|
23
|
+
const normNeedle = normalizeWhitespace(needle.trim());
|
|
24
|
+
if (!normNeedle) return null;
|
|
25
|
+
|
|
26
|
+
// Build mapping from normalized index → original index
|
|
27
|
+
const normChars: string[] = [];
|
|
28
|
+
const normToOrig: number[] = [];
|
|
29
|
+
let lastWasSpace = false;
|
|
30
|
+
|
|
31
|
+
for (let i = 0; i < haystack.length; i++) {
|
|
32
|
+
const ch = haystack[i];
|
|
33
|
+
if (/\s/.test(ch)) {
|
|
34
|
+
if (!lastWasSpace && normChars.length > 0) {
|
|
35
|
+
normChars.push(' ');
|
|
36
|
+
normToOrig.push(i);
|
|
37
|
+
lastWasSpace = true;
|
|
38
|
+
}
|
|
39
|
+
} else {
|
|
40
|
+
normChars.push(ch);
|
|
41
|
+
normToOrig.push(i);
|
|
42
|
+
lastWasSpace = false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Trim trailing space from normalized form
|
|
47
|
+
while (normChars.length > 0 && normChars[normChars.length - 1] === ' ') {
|
|
48
|
+
normChars.pop();
|
|
49
|
+
normToOrig.pop();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const normHaystack = normChars.join('');
|
|
53
|
+
const idx = normHaystack.indexOf(normNeedle);
|
|
54
|
+
if (idx === -1) return null;
|
|
55
|
+
|
|
56
|
+
const startOrig = normToOrig[idx];
|
|
57
|
+
const endNormIdx = idx + normNeedle.length - 1;
|
|
58
|
+
// End is exclusive: one past the last matched original char
|
|
59
|
+
const lastOrig = normToOrig[endNormIdx];
|
|
60
|
+
// Include any trailing whitespace that was collapsed after the last char
|
|
61
|
+
let endOrig = lastOrig + 1;
|
|
62
|
+
while (endOrig < haystack.length && /\s/.test(haystack[endOrig])) {
|
|
63
|
+
// Only consume whitespace that was part of a collapsed run inside the match
|
|
64
|
+
// Stop if we've already covered the normalized span
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return [startOrig, endOrig];
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Apply a list of string replacements to content.
|
|
73
|
+
* Each edit replaces the first occurrence of `old` with `new`.
|
|
74
|
+
* Exact match is tried first; whitespace-tolerant fallback second.
|
|
75
|
+
* Failed edits are collected without aborting the rest.
|
|
76
|
+
*/
|
|
77
|
+
export const applyEdits = (
|
|
78
|
+
content: string,
|
|
79
|
+
edits: ArtifactEdit[]
|
|
80
|
+
): ApplyEditsResult => {
|
|
81
|
+
let result = content;
|
|
82
|
+
const failedEdits: ArtifactEdit[] = [];
|
|
83
|
+
let appliedCount = 0;
|
|
84
|
+
|
|
85
|
+
for (const edit of edits) {
|
|
86
|
+
if (!edit || typeof edit.old !== 'string' || typeof edit.new !== 'string') {
|
|
87
|
+
failedEdits.push(edit);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const exactIdx = result.indexOf(edit.old);
|
|
92
|
+
if (exactIdx !== -1) {
|
|
93
|
+
result =
|
|
94
|
+
result.slice(0, exactIdx) +
|
|
95
|
+
edit.new +
|
|
96
|
+
result.slice(exactIdx + edit.old.length);
|
|
97
|
+
appliedCount++;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const tolerant = findWhitespaceTolerant(result, edit.old);
|
|
102
|
+
if (tolerant) {
|
|
103
|
+
const [start, end] = tolerant;
|
|
104
|
+
result = result.slice(0, start) + edit.new + result.slice(end);
|
|
105
|
+
appliedCount++;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
failedEdits.push(edit);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return { content: result, failedEdits, appliedCount };
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const HTML_ENTITIES: Record<string, string> = {
|
|
116
|
+
'"': '"',
|
|
117
|
+
'"': '"',
|
|
118
|
+
''': "'",
|
|
119
|
+
''': "'",
|
|
120
|
+
'&': '&',
|
|
121
|
+
'<': '<',
|
|
122
|
+
'>': '>',
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Unescape common HTML entities that LLMs sometimes emit inside JSON bodies.
|
|
127
|
+
*/
|
|
128
|
+
const unescapeHtmlEntities = (s: string): string =>
|
|
129
|
+
s.replace(
|
|
130
|
+
/"|"|'|'|&|<|>/g,
|
|
131
|
+
m => HTML_ENTITIES[m] ?? m
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
const isValidEditsArray = (value: unknown): value is ArtifactEdit[] => {
|
|
135
|
+
if (!Array.isArray(value)) return false;
|
|
136
|
+
return value.every(
|
|
137
|
+
item =>
|
|
138
|
+
item &&
|
|
139
|
+
typeof item === 'object' &&
|
|
140
|
+
typeof (item as ArtifactEdit).old === 'string' &&
|
|
141
|
+
typeof (item as ArtifactEdit).new === 'string'
|
|
142
|
+
);
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Parse the body of a memori-artifact update tag into an edits array.
|
|
147
|
+
* Handles valid JSON and HTML-entity-encoded JSON. Returns null on failure.
|
|
148
|
+
*/
|
|
149
|
+
export const parseEdits = (body: string): ArtifactEdit[] | null => {
|
|
150
|
+
if (!body || typeof body !== 'string') return null;
|
|
151
|
+
|
|
152
|
+
const trimmed = body.trim();
|
|
153
|
+
if (!trimmed) return null;
|
|
154
|
+
|
|
155
|
+
const tryParse = (raw: string): ArtifactEdit[] | null => {
|
|
156
|
+
try {
|
|
157
|
+
const parsed = JSON.parse(raw);
|
|
158
|
+
return isValidEditsArray(parsed) ? parsed : null;
|
|
159
|
+
} catch {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const direct = tryParse(trimmed);
|
|
165
|
+
if (direct) return direct;
|
|
166
|
+
|
|
167
|
+
const unescaped = unescapeHtmlEntities(trimmed);
|
|
168
|
+
if (unescaped !== trimmed) {
|
|
169
|
+
return tryParse(unescaped);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return null;
|
|
173
|
+
};
|
package/src/helpers/message.ts
CHANGED
|
@@ -114,6 +114,21 @@ export const renderMsg = (
|
|
|
114
114
|
/<output\s+class\s*=\s*["\']memori-artifact["\'][^>]*data-mimetype\s*=\s*["\']([^"']+)["\'][^>]*>([\s\S]*?)(?:<\/output>|$)/gi,
|
|
115
115
|
''
|
|
116
116
|
)
|
|
117
|
+
// Preferred: data-action="update" on memori-artifact
|
|
118
|
+
.replaceAll(
|
|
119
|
+
/<output\s+class\s*=\s*["\']memori-artifact["\'][^>]*data-action\s*=\s*["\']update["\'][^>]*>([\s\S]*?)(?:<\/output>|$)/gi,
|
|
120
|
+
''
|
|
121
|
+
)
|
|
122
|
+
// data-action may appear before class
|
|
123
|
+
.replaceAll(
|
|
124
|
+
/<output\s+[^>]*data-action\s*=\s*["\']update["\'][^>]*class\s*=\s*["\'][^"']*memori-artifact[^"']*["\'][^>]*>([\s\S]*?)(?:<\/output>|$)/gi,
|
|
125
|
+
''
|
|
126
|
+
)
|
|
127
|
+
// Legacy class name
|
|
128
|
+
.replaceAll(
|
|
129
|
+
/<output\s+class\s*=\s*["\']memori-artifact-update["\'][^>]*>([\s\S]*?)(?:<\/output>|$)/gi,
|
|
130
|
+
''
|
|
131
|
+
)
|
|
117
132
|
.replaceAll(/```markdown([^```]+)```/g, '$1')
|
|
118
133
|
.replaceAll('($', '( $')
|
|
119
134
|
.replaceAll(':$', ': $')
|
package/src/locales/de.json
CHANGED
|
@@ -197,6 +197,9 @@
|
|
|
197
197
|
"artifact": {
|
|
198
198
|
"close": "Schließen",
|
|
199
199
|
"size": "Größe",
|
|
200
|
+
"updated": "aktualisiert",
|
|
201
|
+
"updateFailed": "Update konnte nicht angewendet werden",
|
|
202
|
+
"updateFailedHint": "Der Patch stimmt nicht mit dem aktuellen Inhalt überein. Bitten Sie um ein Neuschreiben des Artifacts.",
|
|
200
203
|
"generated": "Generiert",
|
|
201
204
|
"download": "Herunterladen",
|
|
202
205
|
"print": "Drucken",
|
package/src/locales/en.json
CHANGED
|
@@ -210,6 +210,9 @@
|
|
|
210
210
|
"artifact": {
|
|
211
211
|
"close": "Close",
|
|
212
212
|
"size": "Size",
|
|
213
|
+
"updated": "updated",
|
|
214
|
+
"updateFailed": "Update could not be applied",
|
|
215
|
+
"updateFailedHint": "The patch did not match the current content. Ask to rewrite the artifact.",
|
|
213
216
|
"generated": "Generated",
|
|
214
217
|
"download": "Download",
|
|
215
218
|
"print": "Print",
|
package/src/locales/es.json
CHANGED
|
@@ -197,6 +197,9 @@
|
|
|
197
197
|
"artifact": {
|
|
198
198
|
"close": "Cerrar",
|
|
199
199
|
"size": "Tamaño",
|
|
200
|
+
"updated": "actualizado",
|
|
201
|
+
"updateFailed": "No se pudo aplicar la actualización",
|
|
202
|
+
"updateFailedHint": "El parche no coincide con el contenido actual. Pide reescribir el artifact.",
|
|
200
203
|
"generated": "Generado",
|
|
201
204
|
"download": "Descargar",
|
|
202
205
|
"print": "Imprimir",
|
package/src/locales/fr.json
CHANGED
|
@@ -196,6 +196,9 @@
|
|
|
196
196
|
"artifact": {
|
|
197
197
|
"close": "Fermer",
|
|
198
198
|
"size": "Taille",
|
|
199
|
+
"updated": "mis à jour",
|
|
200
|
+
"updateFailed": "Mise à jour non appliquée",
|
|
201
|
+
"updateFailedHint": "Le correctif ne correspond pas au contenu actuel. Demandez de réécrire l'artifact.",
|
|
199
202
|
"generated": "Généré",
|
|
200
203
|
"download": "Télécharger",
|
|
201
204
|
"print": "Imprimer",
|
package/src/locales/it.json
CHANGED
|
@@ -212,6 +212,9 @@
|
|
|
212
212
|
"artifact": {
|
|
213
213
|
"close": "Chiudi",
|
|
214
214
|
"size": "Dimensione",
|
|
215
|
+
"updated": "aggiornato",
|
|
216
|
+
"updateFailed": "Aggiornamento non applicato",
|
|
217
|
+
"updateFailedHint": "Il patch non corrisponde al contenuto attuale. Chiedi di riscrivere l'artifact.",
|
|
215
218
|
"generated": "Generato",
|
|
216
219
|
"download": "Scarica",
|
|
217
220
|
"print": "Stampa",
|
package/src/version.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// This file is auto-generated. Do not edit manually.
|
|
2
|
-
export const version = '8.
|
|
2
|
+
export const version = '8.44.0';
|