@jskit-ai/assistant-core 0.1.165 → 0.1.167

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.
Files changed (35) hide show
  1. package/examples/conversation/App.vue +104 -0
  2. package/examples/conversation/README.md +42 -0
  3. package/examples/conversation/index.html +2 -0
  4. package/examples/conversation/main.js +9 -0
  5. package/examples/conversation/package.json +22 -0
  6. package/examples/conversation/server.js +123 -0
  7. package/examples/conversation/vite.config.js +8 -0
  8. package/package.json +59 -7
  9. package/src/client/conversation/AssistantComposerActions.vue +43 -0
  10. package/src/client/conversation/AssistantConversationElement.vue +78 -0
  11. package/src/client/conversation/AssistantProgress.vue +88 -0
  12. package/src/client/conversation/AssistantPromptInput.vue +467 -0
  13. package/src/client/conversation/AssistantTranscript.vue +1361 -0
  14. package/src/client/conversation/LongTextInlineParts.vue +64 -0
  15. package/src/client/conversation/LongTextPreviewBlocks.vue +324 -0
  16. package/src/client/conversation/index.js +9 -0
  17. package/src/client/conversation/submitText.js +29 -0
  18. package/src/client/conversation/useScrollToBottom.js +151 -0
  19. package/src/server/conversation/codexClient.js +328 -0
  20. package/src/server/conversation/codexEvents.js +623 -0
  21. package/src/server/conversation/codexTurn.js +280 -0
  22. package/src/server/conversation/index.js +2 -0
  23. package/src/server/conversation/memoryStorage.js +76 -0
  24. package/src/server/conversation/normalize.js +7 -0
  25. package/src/server/conversation/openCodeClient.js +512 -0
  26. package/src/server/conversation/transcript.js +92 -0
  27. package/src/shared/conversation/answerChoiceSugar.js +191 -0
  28. package/src/shared/conversation/index.js +6 -0
  29. package/src/shared/conversation/longTextBlocks.js +403 -0
  30. package/src/shared/conversation/numberedQuestionSugar.js +295 -0
  31. package/src/shared/conversation/scrollFollowState.js +32 -0
  32. package/src/shared/conversation/thinkingText.js +13 -0
  33. package/src/shared/conversation/turns.js +30 -0
  34. package/src/testing/conversationStorageContract.js +35 -0
  35. package/test/conversation.test.js +57 -0
@@ -0,0 +1,191 @@
1
+ const UI_ANSWER_CHOICE_FIELD = "__ui_answer_choice";
2
+ const ANSWER_CHOICE_MIN_COUNT = 2;
3
+ const ANSWER_CHOICE_MAX_COUNT = 6;
4
+ const ANSWER_CHOICE_LABEL_MAX_LENGTH = 96;
5
+ const ANSWER_CHOICE_VALUE_MAX_LENGTH = 320;
6
+
7
+ function inactiveAnswerChoiceSugar() {
8
+ return {
9
+ choices: []
10
+ };
11
+ }
12
+
13
+ function normalizedChoiceText(value = "") {
14
+ return String(value || "").trim().replace(/\s+/gu, " ");
15
+ }
16
+
17
+ function isSingleTextareaMessageField(fields = [], fieldName = "response") {
18
+ return fields.length === 1 &&
19
+ fields[0]?.name === fieldName &&
20
+ fields[0]?.kind === "textarea";
21
+ }
22
+
23
+ function optionalValueMatches(actual = "", expected = "") {
24
+ return !expected || String(actual || "") === String(expected || "");
25
+ }
26
+
27
+ function canRenderAnswerChoiceSugar({
28
+ fields = [],
29
+ fieldName = "response",
30
+ intentId = "",
31
+ requiredIntentId = "",
32
+ requiredStepStatus = "",
33
+ stepStatus = ""
34
+ } = {}) {
35
+ return Boolean(
36
+ isSingleTextareaMessageField(fields, fieldName) &&
37
+ optionalValueMatches(intentId, requiredIntentId) &&
38
+ optionalValueMatches(stepStatus, requiredStepStatus)
39
+ );
40
+ }
41
+
42
+ function answerChoiceHeadingLine(line = "") {
43
+ return /^(possible answers|choices):$/iu.test(String(line || "").trim());
44
+ }
45
+
46
+ function answerChoiceLineText(line = "") {
47
+ const match = String(line || "").trim().match(/^[-*]\s+(.+)$/u);
48
+ return match ? normalizedChoiceText(match[1]) : "";
49
+ }
50
+
51
+ function answerChoiceFromLine(line = "") {
52
+ const text = answerChoiceLineText(line);
53
+ if (!text || text.length > ANSWER_CHOICE_VALUE_MAX_LENGTH) {
54
+ return null;
55
+ }
56
+
57
+ const recommendedMatch = text.match(/^(.{1,96}?)\s+\(recommended\)$/iu);
58
+ if (recommendedMatch) {
59
+ const label = normalizedChoiceText(recommendedMatch[1]);
60
+ return label ? { label, recommended: true, value: label } : null;
61
+ }
62
+
63
+ const colonMatch = text.match(/^([^:]{1,96}):\s+(.+)$/u);
64
+ if (colonMatch) {
65
+ const label = normalizedChoiceText(colonMatch[1]);
66
+ const value = normalizedChoiceText(colonMatch[2]);
67
+ return label && value ? { label, value } : null;
68
+ }
69
+
70
+ const parentheticalMatch = text.match(/^(.{1,96}?)\s+\(([^()]+)\)$/u);
71
+ if (parentheticalMatch) {
72
+ const rawLabel = normalizedChoiceText(parentheticalMatch[1]);
73
+ const markdownTokenMatch = rawLabel.match(/^(?:`([^`]+)`|\*\*([^*]+)\*\*)$/u);
74
+ const label = normalizedChoiceText(
75
+ markdownTokenMatch?.[1] || markdownTokenMatch?.[2] || rawLabel
76
+ );
77
+ if (markdownTokenMatch) {
78
+ return label ? { label, value: label } : null;
79
+ }
80
+ const value = normalizedChoiceText(parentheticalMatch[2]);
81
+ return label && value ? { label, value } : null;
82
+ }
83
+
84
+ return {
85
+ label: text,
86
+ value: text
87
+ };
88
+ }
89
+
90
+ function validAnswerChoice(choice = {}) {
91
+ return Boolean(
92
+ choice &&
93
+ choice.label &&
94
+ choice.value &&
95
+ choice.label.length <= ANSWER_CHOICE_LABEL_MAX_LENGTH &&
96
+ choice.value.length <= ANSWER_CHOICE_VALUE_MAX_LENGTH
97
+ );
98
+ }
99
+
100
+ function parseAnswerChoicePrompt(value = "") {
101
+ const lines = String(value || "").replace(/\r\n/gu, "\n").split("\n");
102
+ if (lines.some((line) => {
103
+ const match = String(line || "").trim().match(/^\[(?:Q)?\d+\]\s+(.+)/iu);
104
+ return match && /\?(?:[*_`”"'’)\]]*)$/u.test(match[1].trim());
105
+ })) {
106
+ return inactiveAnswerChoiceSugar();
107
+ }
108
+ const headingIndex = lines.findIndex(answerChoiceHeadingLine);
109
+ if (headingIndex < 0) {
110
+ return inactiveAnswerChoiceSugar();
111
+ }
112
+
113
+ const choices = [];
114
+ for (let index = headingIndex + 1; index < lines.length; index += 1) {
115
+ const line = lines[index];
116
+ if (!String(line || "").trim()) {
117
+ continue;
118
+ }
119
+ const choice = answerChoiceFromLine(line);
120
+ if (!choice) {
121
+ if (choices.length) {
122
+ break;
123
+ }
124
+ return inactiveAnswerChoiceSugar();
125
+ }
126
+ choices.push(choice);
127
+ }
128
+
129
+ const uniqueValues = new Set(choices.map((choice) => choice.value));
130
+ if (
131
+ choices.length < ANSWER_CHOICE_MIN_COUNT ||
132
+ choices.length > ANSWER_CHOICE_MAX_COUNT ||
133
+ uniqueValues.size !== choices.length ||
134
+ choices.some((choice) => !validAnswerChoice(choice))
135
+ ) {
136
+ return inactiveAnswerChoiceSugar();
137
+ }
138
+
139
+ return {
140
+ choices
141
+ };
142
+ }
143
+
144
+ function answerChoiceSugarForMessageInput({
145
+ fields = [],
146
+ fieldName = "response",
147
+ intentId = "",
148
+ message = "",
149
+ requiredIntentId = "",
150
+ requiredStepStatus = "",
151
+ stepStatus = ""
152
+ } = {}) {
153
+ if (!canRenderAnswerChoiceSugar({
154
+ fields,
155
+ fieldName,
156
+ intentId,
157
+ requiredIntentId,
158
+ requiredStepStatus,
159
+ stepStatus
160
+ })) {
161
+ return inactiveAnswerChoiceSugar();
162
+ }
163
+ return parseAnswerChoicePrompt(message);
164
+ }
165
+
166
+ function answerChoiceInputFields(choices = []) {
167
+ return [
168
+ {
169
+ choices,
170
+ kind: "answer_choices",
171
+ name: UI_ANSWER_CHOICE_FIELD,
172
+ required: false
173
+ }
174
+ ];
175
+ }
176
+
177
+ function answerChoiceSubmissionFields(choiceValue = "", fieldName = "response") {
178
+ return {
179
+ [fieldName]: normalizedChoiceText(choiceValue)
180
+ };
181
+ }
182
+
183
+ export {
184
+ UI_ANSWER_CHOICE_FIELD,
185
+ answerChoiceFromLine,
186
+ answerChoiceInputFields,
187
+ answerChoiceSubmissionFields,
188
+ answerChoiceSugarForMessageInput,
189
+ canRenderAnswerChoiceSugar,
190
+ parseAnswerChoicePrompt
191
+ };
@@ -0,0 +1,6 @@
1
+ export * from "./turns.js";
2
+ export * from "./longTextBlocks.js";
3
+ export * from "./numberedQuestionSugar.js";
4
+ export * from "./answerChoiceSugar.js";
5
+ export * from "./thinkingText.js";
6
+ export * from "./scrollFollowState.js";
@@ -0,0 +1,403 @@
1
+ function parseLongTextReviewBlocks(value, options = {}) {
2
+ const lines = expandCompactPipeTableLines(String(value || "").replace(/\r\n/gu, "\n").split("\n"));
3
+ const blocks = [];
4
+ let paragraphLines = [];
5
+ let listBlock = null;
6
+ let codeLines = null;
7
+ let detailsBlock = null;
8
+ const preserveParagraphLineBreaks = options.preserveParagraphLineBreaks === true;
9
+
10
+ const parseDetailsOpening = (trimmed = "") => {
11
+ if (trimmed === "<details>") {
12
+ return {
13
+ closed: false,
14
+ remainder: "",
15
+ summary: "Details"
16
+ };
17
+ }
18
+ const inlineMatch = trimmed.match(/^<details>\s*<summary>\s*(.*?)\s*<\/summary>\s*(.*)$/u);
19
+ if (!inlineMatch) {
20
+ return null;
21
+ }
22
+ let remainder = inlineMatch[2] || "";
23
+ let closed = false;
24
+ const closingMatch = remainder.match(/^(.*?)\s*<\/details>$/u);
25
+ if (closingMatch) {
26
+ remainder = closingMatch[1] || "";
27
+ closed = true;
28
+ }
29
+ return {
30
+ closed,
31
+ remainder: remainder.trim(),
32
+ summary: inlineMatch[1].trim() || "Details"
33
+ };
34
+ };
35
+
36
+ const flushParagraph = () => {
37
+ const text = preserveParagraphLineBreaks
38
+ ? paragraphLines.join("\n").trim()
39
+ : paragraphLines.join(" ").replace(/\s+/gu, " ").trim();
40
+ paragraphLines = [];
41
+ if (text) {
42
+ blocks.push({
43
+ text,
44
+ type: "paragraph"
45
+ });
46
+ }
47
+ };
48
+
49
+ const flushList = () => {
50
+ if (listBlock?.items.length) {
51
+ blocks.push(listBlock);
52
+ }
53
+ listBlock = null;
54
+ };
55
+
56
+ const flushCode = () => {
57
+ if (codeLines) {
58
+ blocks.push({
59
+ text: codeLines.join("\n").replace(/\n+$/u, ""),
60
+ type: "code"
61
+ });
62
+ }
63
+ codeLines = null;
64
+ };
65
+
66
+ const flushDetails = () => {
67
+ if (detailsBlock) {
68
+ blocks.push({
69
+ blocks: parseLongTextReviewBlocks(detailsBlock.lines.join("\n"), options),
70
+ summary: detailsBlock.summary || "Details",
71
+ type: "details"
72
+ });
73
+ }
74
+ detailsBlock = null;
75
+ };
76
+
77
+ for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
78
+ const rawLine = lines[lineIndex];
79
+ const line = rawLine.replace(/\s+$/u, "");
80
+ const trimmed = line.trim();
81
+
82
+ if (detailsBlock) {
83
+ const summaryMatch = trimmed.match(/^<summary>\s*(.*?)\s*<\/summary>$/u);
84
+ const closingMatch = trimmed.match(/^(.*?)\s*<\/details>$/u);
85
+ if (/^<\/details>$/u.test(trimmed)) {
86
+ flushDetails();
87
+ } else if (closingMatch) {
88
+ const beforeClose = closingMatch[1].trim();
89
+ if (beforeClose) {
90
+ detailsBlock.lines.push(beforeClose);
91
+ }
92
+ flushDetails();
93
+ } else if (summaryMatch) {
94
+ detailsBlock.summary = summaryMatch[1].trim() || detailsBlock.summary;
95
+ } else {
96
+ detailsBlock.lines.push(line);
97
+ }
98
+ continue;
99
+ }
100
+
101
+ if (codeLines) {
102
+ if (/^```/u.test(trimmed)) {
103
+ flushCode();
104
+ } else {
105
+ codeLines.push(line);
106
+ }
107
+ continue;
108
+ }
109
+
110
+ if (/^```/u.test(trimmed)) {
111
+ flushParagraph();
112
+ flushList();
113
+ codeLines = [];
114
+ continue;
115
+ }
116
+
117
+ const detailsOpening = parseDetailsOpening(trimmed);
118
+ if (detailsOpening) {
119
+ flushParagraph();
120
+ flushList();
121
+ detailsBlock = {
122
+ lines: detailsOpening.remainder ? [detailsOpening.remainder] : [],
123
+ summary: detailsOpening.summary
124
+ };
125
+ if (detailsOpening.closed) {
126
+ flushDetails();
127
+ }
128
+ continue;
129
+ }
130
+
131
+ if (!trimmed) {
132
+ flushParagraph();
133
+ flushList();
134
+ continue;
135
+ }
136
+
137
+ const tableBlock = parsePipeTableAt(lines, lineIndex);
138
+ if (tableBlock) {
139
+ flushParagraph();
140
+ flushList();
141
+ blocks.push(tableBlock.block);
142
+ lineIndex = tableBlock.endIndex;
143
+ continue;
144
+ }
145
+
146
+ const headingMatch = trimmed.match(/^(#{1,6})\s+(.+)$/u);
147
+ if (headingMatch) {
148
+ flushParagraph();
149
+ flushList();
150
+ blocks.push({
151
+ level: headingMatch[1].length,
152
+ text: headingMatch[2].trim(),
153
+ type: "heading"
154
+ });
155
+ continue;
156
+ }
157
+
158
+ const unorderedMatch = trimmed.match(/^[-*+•]\s+(.*)$/u);
159
+ const orderedMatch = trimmed.match(/^\d+[.)]\s+(.*)$/u);
160
+ if (unorderedMatch || orderedMatch) {
161
+ flushParagraph();
162
+ const type = orderedMatch ? "ol" : "ul";
163
+ if (!listBlock || listBlock.type !== type) {
164
+ flushList();
165
+ listBlock = {
166
+ items: [],
167
+ type
168
+ };
169
+ }
170
+ listBlock.items.push({
171
+ text: (orderedMatch?.[1] || unorderedMatch?.[1] || "").trim()
172
+ });
173
+ continue;
174
+ }
175
+
176
+ if (listBlock) {
177
+ listBlock.items.at(-1).text += ` ${trimmed}`;
178
+ continue;
179
+ }
180
+ paragraphLines.push(trimmed);
181
+ }
182
+
183
+ flushParagraph();
184
+ flushList();
185
+ flushCode();
186
+ flushDetails();
187
+ return blocks;
188
+ }
189
+
190
+ function expandCompactPipeTableLines(lines = []) {
191
+ return lines.flatMap((line) => {
192
+ const text = String(line || "");
193
+ const trimmed = text.trim();
194
+ const compactRowBoundaries = text.match(/\|\s+\|/gu) || [];
195
+ if (!trimmed.startsWith("|") || compactRowBoundaries.length < 2) {
196
+ return [line];
197
+ }
198
+ const expandedLines = text.replace(/\|\s+\|/gu, "|\n|").split("\n");
199
+ if (!tableAlignments(parsePipeTableRow(expandedLines[1]))) {
200
+ return [line];
201
+ }
202
+ return expandedLines;
203
+ });
204
+ }
205
+
206
+ function parsePipeTableAt(lines = [], startIndex = 0) {
207
+ const headers = parsePipeTableRow(lines[startIndex]);
208
+ const alignments = tableAlignments(parsePipeTableRow(lines[startIndex + 1]));
209
+ if (!headers || !alignments || headers.length !== alignments.length) {
210
+ return null;
211
+ }
212
+ if (!headers.some((header) => header)) {
213
+ return null;
214
+ }
215
+
216
+ const rows = [];
217
+ let rowIndex = startIndex + 2;
218
+ while (rowIndex < lines.length) {
219
+ const row = parsePipeTableRow(lines[rowIndex]);
220
+ if (!row || row.length !== headers.length) {
221
+ break;
222
+ }
223
+ rows.push(row);
224
+ rowIndex += 1;
225
+ }
226
+
227
+ return {
228
+ block: {
229
+ alignments,
230
+ headers,
231
+ rows,
232
+ type: "table"
233
+ },
234
+ endIndex: rowIndex - 1
235
+ };
236
+ }
237
+
238
+ function parsePipeTableRow(value = "") {
239
+ const trimmed = String(value || "").trim();
240
+ if (!trimmed.startsWith("|") || !trimmed.includes("|", 1)) {
241
+ return null;
242
+ }
243
+ const cells = trimmed
244
+ .replace(/^\|/u, "")
245
+ .replace(/\|$/u, "")
246
+ .split("|")
247
+ .map((cell) => cell.trim());
248
+ return cells.length >= 2 ? cells : null;
249
+ }
250
+
251
+ function tableAlignments(cells = null) {
252
+ if (!Array.isArray(cells) || !cells.length) {
253
+ return null;
254
+ }
255
+ const alignments = [];
256
+ for (const cell of cells) {
257
+ const marker = String(cell || "").replace(/\s+/gu, "");
258
+ if (!/^:?-{3,}:?$/u.test(marker)) {
259
+ return null;
260
+ }
261
+ if (marker.startsWith(":") && marker.endsWith(":")) {
262
+ alignments.push("center");
263
+ } else if (marker.endsWith(":")) {
264
+ alignments.push("right");
265
+ } else {
266
+ alignments.push("left");
267
+ }
268
+ }
269
+ return alignments;
270
+ }
271
+
272
+ function pushInlineTextPart(parts = [], text = "") {
273
+ if (!text) {
274
+ return;
275
+ }
276
+ const previous = parts.at(-1);
277
+ if (previous?.type === "text") {
278
+ previous.text += text;
279
+ return;
280
+ }
281
+ parts.push({
282
+ text,
283
+ type: "text"
284
+ });
285
+ }
286
+
287
+ function closingMarkdownBracketIndex(text = "", start = 0) {
288
+ let depth = 1;
289
+ for (let index = start + 1; index < text.length; index += 1) {
290
+ const character = text[index];
291
+ if (character === "\\") {
292
+ index += 1;
293
+ continue;
294
+ }
295
+ if (character === "[") {
296
+ depth += 1;
297
+ } else if (character === "]") {
298
+ depth -= 1;
299
+ if (depth === 0) {
300
+ return index;
301
+ }
302
+ }
303
+ }
304
+ return -1;
305
+ }
306
+
307
+ function closingMarkdownDestinationIndex(text = "", start = 0) {
308
+ for (let index = start + 1; index < text.length; index += 1) {
309
+ if (text[index] === "\\") {
310
+ index += 1;
311
+ continue;
312
+ }
313
+ if (text[index] === ")") {
314
+ return index;
315
+ }
316
+ }
317
+ return -1;
318
+ }
319
+
320
+ function normalizeMarkdownLinkHref(value = "") {
321
+ const href = String(value || "").trim().replace(/^<|>$/gu, "");
322
+ // eslint-disable-next-line no-control-regex -- Strip intentional ASCII controls before checking unsafe URL schemes.
323
+ const protocolText = href.replace(/[\u0000-\u0020\u007f]/gu, "");
324
+ return /^(?:javascript|vbscript|data):/iu.test(protocolText) ? "" : href;
325
+ }
326
+
327
+ function markdownLinkAt(text = "", start = 0) {
328
+ if (text[start] !== "[") {
329
+ return null;
330
+ }
331
+ const labelEnd = closingMarkdownBracketIndex(text, start);
332
+ if (labelEnd === -1 || text[labelEnd + 1] !== "(") {
333
+ return null;
334
+ }
335
+ const destinationStart = labelEnd + 1;
336
+ const destinationEnd = closingMarkdownDestinationIndex(text, destinationStart);
337
+ if (destinationEnd === -1) {
338
+ return null;
339
+ }
340
+ const label = text.slice(start + 1, labelEnd);
341
+ const href = normalizeMarkdownLinkHref(text.slice(destinationStart + 1, destinationEnd));
342
+ if (!label || !href) {
343
+ return null;
344
+ }
345
+ return {
346
+ end: destinationEnd + 1,
347
+ href,
348
+ text: label
349
+ };
350
+ }
351
+
352
+ function parseLongTextInlineParts(value = "") {
353
+ const text = String(value || "");
354
+ const parts = [];
355
+ let cursor = 0;
356
+ while (cursor < text.length) {
357
+ if (text[cursor] === "[") {
358
+ const link = markdownLinkAt(text, cursor);
359
+ if (link) {
360
+ parts.push({
361
+ href: link.href,
362
+ text: link.text,
363
+ type: "link"
364
+ });
365
+ cursor = link.end;
366
+ continue;
367
+ }
368
+ }
369
+
370
+ if (text[cursor] === "`") {
371
+ const end = text.indexOf("`", cursor + 1);
372
+ if (end > cursor + 1) {
373
+ parts.push({
374
+ text: text.slice(cursor + 1, end),
375
+ type: "code"
376
+ });
377
+ cursor = end + 1;
378
+ continue;
379
+ }
380
+ }
381
+
382
+ if (text.startsWith("**", cursor)) {
383
+ const end = text.indexOf("**", cursor + 2);
384
+ if (end > cursor + 2) {
385
+ parts.push({
386
+ text: text.slice(cursor + 2, end),
387
+ type: "strong"
388
+ });
389
+ cursor = end + 2;
390
+ continue;
391
+ }
392
+ }
393
+
394
+ pushInlineTextPart(parts, text[cursor]);
395
+ cursor += 1;
396
+ }
397
+ return parts.length ? parts : [{ text, type: "text" }];
398
+ }
399
+
400
+ export {
401
+ parseLongTextInlineParts,
402
+ parseLongTextReviewBlocks
403
+ };