editor-ts 0.0.10 → 0.0.12
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 +197 -318
- package/index.ts +70 -0
- package/package.json +31 -10
- package/src/core/ComponentManager.ts +697 -6
- package/src/core/ComponentPalette.ts +109 -0
- package/src/core/CustomComponentRegistry.ts +74 -0
- package/src/core/KeyboardShortcuts.ts +220 -0
- package/src/core/LayerManager.ts +378 -0
- package/src/core/Page.ts +24 -5
- package/src/core/StorageManager.ts +447 -0
- package/src/core/StyleManager.ts +38 -2
- package/src/core/VersionControl.ts +189 -0
- package/src/core/aiChat.ts +427 -0
- package/src/core/iframeCanvas.ts +672 -0
- package/src/core/init.ts +3081 -248
- package/src/server/bun_server.ts +155 -0
- package/src/server/cf_worker.ts +225 -0
- package/src/server/schema.ts +21 -0
- package/src/server/sync.ts +195 -0
- package/src/types/sqlocal.d.ts +6 -0
- package/src/types.ts +591 -18
- package/src/utils/toolbar.ts +15 -1
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import type { PageData } from '../types';
|
|
2
|
+
|
|
3
|
+
export type VersionNodeMeta = {
|
|
4
|
+
source?: 'user' | 'ai' | 'system';
|
|
5
|
+
message?: string;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export type VersionNode = {
|
|
9
|
+
id: string;
|
|
10
|
+
parentId: string | null;
|
|
11
|
+
childrenIds: string[];
|
|
12
|
+
createdAt: number;
|
|
13
|
+
snapshot: PageData;
|
|
14
|
+
meta?: VersionNodeMeta;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type VersionControlState = {
|
|
18
|
+
rootId: string;
|
|
19
|
+
currentId: string;
|
|
20
|
+
nodes: Record<string, VersionNode>;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type VersionControlOptions = {
|
|
24
|
+
maxSnapshots?: number;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const createId = (): string => {
|
|
28
|
+
return `vc_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export class VersionControl {
|
|
32
|
+
private maxSnapshots: number;
|
|
33
|
+
private state: VersionControlState;
|
|
34
|
+
|
|
35
|
+
constructor(options?: VersionControlOptions) {
|
|
36
|
+
this.maxSnapshots = options?.maxSnapshots ?? 200;
|
|
37
|
+
|
|
38
|
+
const rootId = createId();
|
|
39
|
+
// Temporary placeholder; caller must init() to set snapshot.
|
|
40
|
+
this.state = {
|
|
41
|
+
rootId,
|
|
42
|
+
currentId: rootId,
|
|
43
|
+
nodes: {
|
|
44
|
+
[rootId]: {
|
|
45
|
+
id: rootId,
|
|
46
|
+
parentId: null,
|
|
47
|
+
childrenIds: [],
|
|
48
|
+
createdAt: Date.now(),
|
|
49
|
+
snapshot: { title: '', item_id: 0, body: {} },
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private cloneSnapshot(snapshot: PageData): PageData {
|
|
56
|
+
return JSON.parse(JSON.stringify(snapshot)) as PageData;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
init(snapshot: PageData, meta?: VersionNodeMeta): void {
|
|
60
|
+
const root = this.state.nodes[this.state.rootId];
|
|
61
|
+
if (!root) return;
|
|
62
|
+
|
|
63
|
+
root.snapshot = this.cloneSnapshot(snapshot);
|
|
64
|
+
root.meta = meta;
|
|
65
|
+
root.createdAt = Date.now();
|
|
66
|
+
this.state.currentId = root.id;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
static fromState(state: VersionControlState, options?: VersionControlOptions): VersionControl {
|
|
70
|
+
const vc = new VersionControl(options);
|
|
71
|
+
vc.state = state;
|
|
72
|
+
return vc;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
getState(): VersionControlState {
|
|
76
|
+
return this.state;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
getCurrentId(): string {
|
|
80
|
+
return this.state.currentId;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
getCurrentSnapshot(): PageData {
|
|
84
|
+
const current = this.state.nodes[this.state.currentId];
|
|
85
|
+
const snapshot = current ? current.snapshot : this.state.nodes[this.state.rootId]!.snapshot;
|
|
86
|
+
return this.cloneSnapshot(snapshot);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
getRedoOptions(): string[] {
|
|
90
|
+
const current = this.state.nodes[this.state.currentId];
|
|
91
|
+
return current ? current.childrenIds.slice() : [];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
canUndo(): boolean {
|
|
95
|
+
const current = this.state.nodes[this.state.currentId];
|
|
96
|
+
return !!current && current.parentId !== null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
canRedo(): boolean {
|
|
100
|
+
const current = this.state.nodes[this.state.currentId];
|
|
101
|
+
return !!current && current.childrenIds.length > 0;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
commit(snapshot: PageData, meta?: VersionNodeMeta): string {
|
|
105
|
+
const parentId = this.state.currentId;
|
|
106
|
+
const parent = this.state.nodes[parentId];
|
|
107
|
+
if (!parent) {
|
|
108
|
+
throw new Error('VersionControl: missing parent node');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const id = createId();
|
|
112
|
+
|
|
113
|
+
const node: VersionNode = {
|
|
114
|
+
id,
|
|
115
|
+
parentId,
|
|
116
|
+
childrenIds: [],
|
|
117
|
+
createdAt: Date.now(),
|
|
118
|
+
snapshot: this.cloneSnapshot(snapshot),
|
|
119
|
+
meta,
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
this.state.nodes[id] = node;
|
|
123
|
+
parent.childrenIds.push(id);
|
|
124
|
+
this.state.currentId = id;
|
|
125
|
+
|
|
126
|
+
this.prune();
|
|
127
|
+
|
|
128
|
+
return id;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
undo(): PageData | null {
|
|
132
|
+
const current = this.state.nodes[this.state.currentId];
|
|
133
|
+
if (!current || !current.parentId) return null;
|
|
134
|
+
|
|
135
|
+
const parent = this.state.nodes[current.parentId];
|
|
136
|
+
if (!parent) return null;
|
|
137
|
+
|
|
138
|
+
this.state.currentId = parent.id;
|
|
139
|
+
return this.cloneSnapshot(parent.snapshot);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
redo(childId?: string): PageData | null {
|
|
143
|
+
const current = this.state.nodes[this.state.currentId];
|
|
144
|
+
if (!current || current.childrenIds.length === 0) return null;
|
|
145
|
+
|
|
146
|
+
const nextId = childId ?? current.childrenIds[current.childrenIds.length - 1]!;
|
|
147
|
+
const next = this.state.nodes[nextId];
|
|
148
|
+
if (!next) return null;
|
|
149
|
+
|
|
150
|
+
this.state.currentId = next.id;
|
|
151
|
+
return this.cloneSnapshot(next.snapshot);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
checkout(id: string): PageData | null {
|
|
155
|
+
if (!this.state.nodes[id]) return null;
|
|
156
|
+
this.state.currentId = id;
|
|
157
|
+
return this.cloneSnapshot(this.state.nodes[id].snapshot);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private prune(): void {
|
|
161
|
+
const nodeIds = Object.keys(this.state.nodes);
|
|
162
|
+
if (nodeIds.length <= this.maxSnapshots) return;
|
|
163
|
+
|
|
164
|
+
const protectedIds = new Set<string>([this.state.rootId, this.state.currentId]);
|
|
165
|
+
|
|
166
|
+
const leaves = nodeIds
|
|
167
|
+
.map((id) => this.state.nodes[id])
|
|
168
|
+
.filter((n): n is VersionNode => !!n && !protectedIds.has(n.id) && n.childrenIds.length === 0);
|
|
169
|
+
|
|
170
|
+
leaves.sort((a, b) => a.createdAt - b.createdAt);
|
|
171
|
+
|
|
172
|
+
while (Object.keys(this.state.nodes).length > this.maxSnapshots && leaves.length > 0) {
|
|
173
|
+
const leaf = leaves.shift();
|
|
174
|
+
if (!leaf) break;
|
|
175
|
+
|
|
176
|
+
const parentId = leaf.parentId;
|
|
177
|
+
delete this.state.nodes[leaf.id];
|
|
178
|
+
|
|
179
|
+
if (parentId && this.state.nodes[parentId]) {
|
|
180
|
+
const parent = this.state.nodes[parentId];
|
|
181
|
+
parent.childrenIds = parent.childrenIds.filter((cid) => cid !== leaf.id);
|
|
182
|
+
if (parent.childrenIds.length === 0 && !protectedIds.has(parent.id)) {
|
|
183
|
+
leaves.push(parent);
|
|
184
|
+
leaves.sort((a, b) => a.createdAt - b.createdAt);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
import type { OpencodeClient, Part, Event, Message, TextPart } from '@opencode-ai/sdk';
|
|
2
|
+
import type { Page } from './Page';
|
|
3
|
+
import type { EditorTsAiChatReplacement, EditorTsAiChatResult } from '../types';
|
|
4
|
+
|
|
5
|
+
type ParsedChatResponse = {
|
|
6
|
+
replacements?: Array<{
|
|
7
|
+
path?: unknown;
|
|
8
|
+
content?: unknown;
|
|
9
|
+
content_b64?: unknown;
|
|
10
|
+
}>;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const stripCodeFences = (text: string): string => {
|
|
14
|
+
const trimmed = text.trim();
|
|
15
|
+
if (!trimmed.startsWith('```')) return trimmed;
|
|
16
|
+
return trimmed.replace(/^```[a-zA-Z]*\n?/, '').replace(/```$/, '').trim();
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const extractJsonFromText = (text: string): string | null => {
|
|
20
|
+
const trimmed = text.trim();
|
|
21
|
+
|
|
22
|
+
// Prefer fenced JSON blocks.
|
|
23
|
+
const fenceStart = trimmed.indexOf('```');
|
|
24
|
+
if (fenceStart >= 0) {
|
|
25
|
+
const fenceLangEnd = trimmed.indexOf('\n', fenceStart);
|
|
26
|
+
const contentStart = fenceLangEnd >= 0 ? fenceLangEnd + 1 : fenceStart + 3;
|
|
27
|
+
const fenceEnd = trimmed.indexOf('```', contentStart);
|
|
28
|
+
if (fenceEnd > contentStart) {
|
|
29
|
+
const inner = trimmed.slice(contentStart, fenceEnd).trim();
|
|
30
|
+
if (inner.startsWith('{') || inner.startsWith('[')) {
|
|
31
|
+
return inner;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Fallback: scan for the first valid JSON object/array within the text.
|
|
37
|
+
const tryParseSlice = (slice: string): boolean => {
|
|
38
|
+
try {
|
|
39
|
+
JSON.parse(slice);
|
|
40
|
+
return true;
|
|
41
|
+
} catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const scan = (open: '{' | '[', close: '}' | ']'): string | null => {
|
|
47
|
+
let depth = 0;
|
|
48
|
+
let start: number | null = null;
|
|
49
|
+
let inString = false;
|
|
50
|
+
let escaped = false;
|
|
51
|
+
|
|
52
|
+
for (let i = 0; i < trimmed.length; i++) {
|
|
53
|
+
const ch = trimmed[i] ?? '';
|
|
54
|
+
|
|
55
|
+
if (inString) {
|
|
56
|
+
if (escaped) {
|
|
57
|
+
escaped = false;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (ch === '\\') {
|
|
61
|
+
escaped = true;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (ch === '"') {
|
|
65
|
+
inString = false;
|
|
66
|
+
}
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (ch === '"') {
|
|
71
|
+
inString = true;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (ch === open) {
|
|
76
|
+
if (depth === 0) start = i;
|
|
77
|
+
depth++;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (ch === close && depth > 0) {
|
|
82
|
+
depth--;
|
|
83
|
+
if (depth === 0 && start !== null) {
|
|
84
|
+
const slice = trimmed.slice(start, i + 1);
|
|
85
|
+
if (tryParseSlice(slice)) return slice;
|
|
86
|
+
start = null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return null;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
return scan('{', '}') ?? scan('[', ']');
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const decodeBase64ToString = (b64: string): string => {
|
|
98
|
+
// Browser-safe base64 decode
|
|
99
|
+
const binary = atob(b64);
|
|
100
|
+
const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
|
|
101
|
+
return new TextDecoder('utf-8').decode(bytes);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export const parseAiChatResponse = (assistantText: string, sessionId: string): EditorTsAiChatResult => {
|
|
105
|
+
const rawText = assistantText;
|
|
106
|
+
|
|
107
|
+
const extracted = extractJsonFromText(assistantText);
|
|
108
|
+
const jsonText = extracted ?? stripCodeFences(assistantText);
|
|
109
|
+
|
|
110
|
+
const parsed = JSON.parse(jsonText) as ParsedChatResponse;
|
|
111
|
+
const rawReplacements = Array.isArray(parsed.replacements) ? parsed.replacements : [];
|
|
112
|
+
|
|
113
|
+
const replacements: EditorTsAiChatReplacement[] = [];
|
|
114
|
+
for (const item of rawReplacements) {
|
|
115
|
+
const path = typeof item?.path === 'string' ? item.path : null;
|
|
116
|
+
if (!path) continue;
|
|
117
|
+
|
|
118
|
+
if (typeof item?.content_b64 === 'string') {
|
|
119
|
+
replacements.push({ path, content: decodeBase64ToString(item.content_b64) });
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (typeof item?.content === 'string') {
|
|
124
|
+
replacements.push({ path, content: item.content });
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return { replacements, rawText, sessionId };
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
export const buildAiChatSystemPrompt = (): string => {
|
|
133
|
+
return [
|
|
134
|
+
'You are an automated assistant integrated with EditorTs.',
|
|
135
|
+
'Return JSON only. No markdown. No backticks. No commentary.',
|
|
136
|
+
'Schema: { "replacements": [{ "path": string, "content_b64": string }] }',
|
|
137
|
+
'Always use content_b64 (base64 of full UTF-8 file contents).',
|
|
138
|
+
'Allowed paths: page.json, styles.css, components/<id>.js',
|
|
139
|
+
'',
|
|
140
|
+
'IMPORTANT CSS RULES:',
|
|
141
|
+
'- When writing styles.css, use valid CSS selectors.',
|
|
142
|
+
'- IDs must be prefixed with # (e.g. #hero-1, #hero-title).',
|
|
143
|
+
].join('\n');
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
export const buildAiChatSnapshot = (pageJson: string, css: string, componentScripts: Record<string, string>): string => {
|
|
147
|
+
const scriptsList = Object.keys(componentScripts).sort();
|
|
148
|
+
const scriptLines = scriptsList.length
|
|
149
|
+
? scriptsList.map((p) => `- ${p}`).join('\n')
|
|
150
|
+
: '- (none)';
|
|
151
|
+
|
|
152
|
+
return [
|
|
153
|
+
'WORKSPACE TREE:',
|
|
154
|
+
'- page.json',
|
|
155
|
+
'- styles.css',
|
|
156
|
+
'- index.html (derived; do not edit)',
|
|
157
|
+
'- components/<id>.js',
|
|
158
|
+
'',
|
|
159
|
+
'COMPONENT SCRIPTS:',
|
|
160
|
+
scriptLines,
|
|
161
|
+
'',
|
|
162
|
+
'FILES:',
|
|
163
|
+
`page.json:\n${pageJson}`,
|
|
164
|
+
`\nstyles.css:\n${css}`,
|
|
165
|
+
].join('\n');
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
export const normalizeOpencodeModelId = (providerID: string, modelID: string): string => {
|
|
169
|
+
if (providerID !== 'opencode') return modelID;
|
|
170
|
+
if (modelID === 'claude-sonnet-4-5-20250929') return 'claude-sonnet-4-5';
|
|
171
|
+
return modelID;
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
export const chooseChatModel = async (client: OpencodeClient): Promise<{ providerID: string; modelID: string } | undefined> => {
|
|
175
|
+
const configResult = await client.config.get();
|
|
176
|
+
const configured = configResult.data?.model;
|
|
177
|
+
if (configured) {
|
|
178
|
+
const [providerID, ...rest] = configured.split('/');
|
|
179
|
+
if (providerID && rest.length > 0) {
|
|
180
|
+
const modelID = rest.join('/');
|
|
181
|
+
return { providerID, modelID: normalizeOpencodeModelId(providerID, modelID) };
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const providersResult = await client.config.providers();
|
|
186
|
+
if (!providersResult.data) return undefined;
|
|
187
|
+
|
|
188
|
+
const modelID = providersResult.data.default?.opencode;
|
|
189
|
+
if (modelID) return { providerID: 'opencode', modelID };
|
|
190
|
+
|
|
191
|
+
return undefined;
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
export const requestAiReplacements = async (args: {
|
|
195
|
+
client: OpencodeClient;
|
|
196
|
+
prompt: string;
|
|
197
|
+
pageJson: string;
|
|
198
|
+
css: string;
|
|
199
|
+
componentScripts: Record<string, string>;
|
|
200
|
+
sessionId?: string;
|
|
201
|
+
sessionTitle?: string;
|
|
202
|
+
model?: {
|
|
203
|
+
providerID: string;
|
|
204
|
+
modelID: string;
|
|
205
|
+
};
|
|
206
|
+
stream?: boolean;
|
|
207
|
+
onStream?: (delta: string) => void;
|
|
208
|
+
}): Promise<EditorTsAiChatResult> => {
|
|
209
|
+
const {
|
|
210
|
+
client,
|
|
211
|
+
prompt,
|
|
212
|
+
pageJson,
|
|
213
|
+
css,
|
|
214
|
+
componentScripts,
|
|
215
|
+
sessionId: existingSessionId,
|
|
216
|
+
sessionTitle,
|
|
217
|
+
model: selectedModel,
|
|
218
|
+
stream,
|
|
219
|
+
onStream,
|
|
220
|
+
} = args;
|
|
221
|
+
|
|
222
|
+
let sessionId = existingSessionId;
|
|
223
|
+
|
|
224
|
+
if (!sessionId) {
|
|
225
|
+
const sessionResult = await client.session.create({ body: { title: sessionTitle ?? 'EditorTs Chat' } });
|
|
226
|
+
if (!sessionResult.data) {
|
|
227
|
+
throw new Error(`Failed to create session: ${String(sessionResult.error)}`);
|
|
228
|
+
}
|
|
229
|
+
sessionId = sessionResult.data.id;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const system = buildAiChatSystemPrompt();
|
|
233
|
+
const snapshot = buildAiChatSnapshot(pageJson, css, componentScripts);
|
|
234
|
+
|
|
235
|
+
const model = selectedModel ?? await chooseChatModel(client);
|
|
236
|
+
|
|
237
|
+
if (stream && typeof onStream === 'function') {
|
|
238
|
+
// Fire-and-forget the prompt (async), then listen to SSE for message part deltas.
|
|
239
|
+
const sendResult = await client.session.promptAsync({
|
|
240
|
+
path: { id: sessionId },
|
|
241
|
+
body: {
|
|
242
|
+
...(model ? { model } : {}),
|
|
243
|
+
system,
|
|
244
|
+
parts: [
|
|
245
|
+
{ type: 'text', text: snapshot },
|
|
246
|
+
{ type: 'text', text: `\nREQUEST:\n${prompt}` },
|
|
247
|
+
],
|
|
248
|
+
},
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
if (sendResult.error) {
|
|
252
|
+
throw new Error(`Prompt failed: ${String(sendResult.error)}`);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const events = await client.event.subscribe();
|
|
256
|
+
|
|
257
|
+
let assembled = '';
|
|
258
|
+
let targetSessionId: string | null = null;
|
|
259
|
+
let doneMessageId: string | null = null;
|
|
260
|
+
|
|
261
|
+
const isMessage = (value: unknown): value is Message => {
|
|
262
|
+
if (!value || typeof value !== 'object') return false;
|
|
263
|
+
return typeof (value as { id?: unknown }).id === 'string' && typeof (value as { role?: unknown }).role === 'string';
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
const isTextPart = (value: unknown): value is TextPart => {
|
|
267
|
+
if (!value || typeof value !== 'object') return false;
|
|
268
|
+
return (value as { type?: unknown }).type === 'text' && typeof (value as { text?: unknown }).text === 'string';
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
const isEventMessageUpdated = (value: unknown): value is Extract<Event, { type: 'message.updated' }> => {
|
|
272
|
+
if (!value || typeof value !== 'object') return false;
|
|
273
|
+
return (value as { type?: unknown }).type === 'message.updated';
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
const isEventMessagePartUpdated = (value: unknown): value is Extract<Event, { type: 'message.part.updated' }> => {
|
|
277
|
+
if (!value || typeof value !== 'object') return false;
|
|
278
|
+
return (value as { type?: unknown }).type === 'message.part.updated';
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
const waitForResult = async (): Promise<string> => {
|
|
282
|
+
// Bound the streaming loop so we don't hang forever if the connection dies.
|
|
283
|
+
const timeoutMs = 90_000;
|
|
284
|
+
const timeoutAt = Date.now() + timeoutMs;
|
|
285
|
+
|
|
286
|
+
for await (const payload of events.stream) {
|
|
287
|
+
if (Date.now() > timeoutAt) {
|
|
288
|
+
throw new Error('Streaming timed out waiting for assistant response.');
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const globalEvent = payload as unknown;
|
|
292
|
+
const eventPayload = (globalEvent as { payload?: unknown }).payload;
|
|
293
|
+
if (!eventPayload || typeof eventPayload !== 'object') continue;
|
|
294
|
+
|
|
295
|
+
if (isEventMessageUpdated(eventPayload)) {
|
|
296
|
+
const info = (eventPayload as { properties?: unknown }).properties as { info?: unknown } | undefined;
|
|
297
|
+
if (!info || !isMessage(info.info)) continue;
|
|
298
|
+
|
|
299
|
+
const msg = info.info;
|
|
300
|
+
|
|
301
|
+
// Track the first assistant message for this session that has a completion.
|
|
302
|
+
if (msg.role === 'assistant' && typeof msg.sessionID === 'string' && msg.sessionID === sessionId) {
|
|
303
|
+
targetSessionId = msg.sessionID;
|
|
304
|
+
|
|
305
|
+
// We only know we're done once the assistant message has completed.
|
|
306
|
+
const completed = (msg as { time?: { completed?: number } }).time?.completed;
|
|
307
|
+
if (typeof completed === 'number') {
|
|
308
|
+
doneMessageId = msg.id;
|
|
309
|
+
break;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (isEventMessagePartUpdated(eventPayload)) {
|
|
315
|
+
const properties = (eventPayload as { properties?: unknown }).properties as { part?: unknown; delta?: unknown } | undefined;
|
|
316
|
+
if (!properties) continue;
|
|
317
|
+
|
|
318
|
+
const part = properties.part;
|
|
319
|
+
if (!isTextPart(part)) continue;
|
|
320
|
+
|
|
321
|
+
const rawSessionId = (part as { sessionID?: unknown }).sessionID;
|
|
322
|
+
const partSessionId = typeof rawSessionId === 'string' ? rawSessionId : null;
|
|
323
|
+
|
|
324
|
+
if (!targetSessionId) {
|
|
325
|
+
targetSessionId = partSessionId ?? sessionId;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (targetSessionId && partSessionId && partSessionId !== targetSessionId) continue;
|
|
329
|
+
|
|
330
|
+
const delta = typeof properties.delta === 'string' ? properties.delta : null;
|
|
331
|
+
if (delta && delta.length > 0) {
|
|
332
|
+
assembled += delta;
|
|
333
|
+
onStream(delta);
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Some servers may send the full text instead of delta.
|
|
338
|
+
if (typeof part.text === 'string' && part.text.length > assembled.length) {
|
|
339
|
+
const next = part.text.slice(assembled.length);
|
|
340
|
+
assembled = part.text;
|
|
341
|
+
if (next.length > 0) {
|
|
342
|
+
onStream(next);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// If we didn't gather anything via deltas, fetch full message parts as fallback.
|
|
349
|
+
if (!assembled.trim() && doneMessageId) {
|
|
350
|
+
const message = await client.session.message({ path: { id: sessionId, messageID: doneMessageId } });
|
|
351
|
+
if (!message.data) {
|
|
352
|
+
throw new Error(`Failed to fetch message: ${String(message.error)}`);
|
|
353
|
+
}
|
|
354
|
+
const parts = Array.isArray(message.data.parts) ? (message.data.parts as Part[]) : [];
|
|
355
|
+
assembled = parts
|
|
356
|
+
.filter((p) => p.type === 'text')
|
|
357
|
+
.map((p) => (p.type === 'text' ? p.text ?? '' : ''))
|
|
358
|
+
.join('');
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return assembled;
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
const assistantText = await waitForResult();
|
|
365
|
+
|
|
366
|
+
if (!assistantText.trim()) {
|
|
367
|
+
throw new Error('No assistant text returned.');
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return parseAiChatResponse(assistantText, sessionId);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const result = await client.session.prompt({
|
|
374
|
+
path: { id: sessionId },
|
|
375
|
+
body: {
|
|
376
|
+
...(model ? { model } : {}),
|
|
377
|
+
system,
|
|
378
|
+
parts: [
|
|
379
|
+
{ type: 'text', text: snapshot },
|
|
380
|
+
{ type: 'text', text: `\nREQUEST:\n${prompt}` },
|
|
381
|
+
],
|
|
382
|
+
},
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
if (!result.data) {
|
|
386
|
+
throw new Error(`Prompt failed: ${String(result.error)}`);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const parts = Array.isArray(result.data.parts) ? (result.data.parts as Part[]) : [];
|
|
390
|
+
const assistantText = parts
|
|
391
|
+
.filter((p) => p.type === 'text')
|
|
392
|
+
.map((p) => (p.type === 'text' ? p.text ?? '' : ''))
|
|
393
|
+
.join('');
|
|
394
|
+
|
|
395
|
+
if (!assistantText.trim()) {
|
|
396
|
+
throw new Error('No assistant text returned.');
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
return parseAiChatResponse(assistantText, sessionId);
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
export const applyAiReplacementsToPage = async (args: {
|
|
403
|
+
page: Page;
|
|
404
|
+
replacements: EditorTsAiChatReplacement[];
|
|
405
|
+
saveJson: (json: string) => Promise<void>;
|
|
406
|
+
saveCss: (css: string) => Promise<void>;
|
|
407
|
+
saveComponentScript: (id: string, script: string) => Promise<void>;
|
|
408
|
+
}): Promise<void> => {
|
|
409
|
+
const { replacements, saveJson, saveCss, saveComponentScript } = args;
|
|
410
|
+
|
|
411
|
+
for (const r of replacements) {
|
|
412
|
+
if (r.path === 'page.json') {
|
|
413
|
+
await saveJson(r.content);
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (r.path === 'styles.css') {
|
|
418
|
+
await saveCss(r.content);
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
if (r.path.startsWith('components/') && r.path.endsWith('.js')) {
|
|
423
|
+
const id = r.path.slice('components/'.length, -3);
|
|
424
|
+
await saveComponentScript(id, r.content);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
};
|