rift-ds 1.9.0 → 1.10.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 +3 -3
- package/bin/templates/chat/app/api/chat/route.ts +3 -0
- package/chat/AssistantTurn.js +5 -2
- package/chat/ChatWidget.css +5 -4
- package/chat/ChatWidget.d.ts +10 -1
- package/chat/ChatWidget.js +17 -4
- package/chat/DemoWelcome.js +17 -5
- package/chat/FollowupSuggestions.d.ts +7 -4
- package/chat/FollowupSuggestions.js +5 -3
- package/chat/TurnQuestion.d.ts +19 -0
- package/chat/TurnQuestion.js +38 -0
- package/chat/demo-script.js +256 -50
- package/chat/followups.d.ts +2 -2
- package/chat/followups.js +1 -0
- package/chat/index.d.ts +2 -1
- package/chat/index.js +11 -0
- package/chat/queries.d.ts +2 -2
- package/chat/queries.js +3 -2
- package/chat/questions.d.ts +97 -0
- package/chat/questions.js +140 -0
- package/chat/registry.json.d.ts +3 -2
- package/chat/registry.json.js +1 -1
- package/chat/server.d.ts +19 -0
- package/chat/server.js +45 -1
- package/chat/sim.d.ts +12 -1
- package/chat/sim.js +19 -3
- package/chat/types.d.ts +58 -1
- package/chat/types.js +1 -0
- package/chat/useChat.d.ts +16 -1
- package/chat/useChat.js +39 -2
- package/components/AlertDialog/AlertDialog.css +1 -1
- package/components/AnchorNav/AnchorNav.css +1 -1
- package/components/AppSidebar/AppSidebar.css +5 -5
- package/components/AppSidebar/AppSidebar.js +3 -2
- package/components/Attachment/Attachment.css +3 -1
- package/components/BottomNav/BottomNav.css +1 -1
- package/components/Chart/Chart.css +1 -0
- package/components/ChatThread/ChatThread.css +21 -0
- package/components/ChatThread/ChatThread.d.ts +12 -1
- package/components/ChatThread/ChatThread.js +40 -1
- package/components/ColorPicker/ColorPicker.css +1 -1
- package/components/Combobox/Combobox.css +1 -1
- package/components/CommandPalette/CommandPalette.css +1 -1
- package/components/Composer/Composer.css +1 -1
- package/components/ContextMenu/ContextMenu.css +1 -1
- package/components/Dialog/Dialog.css +1 -1
- package/components/Drawer/Drawer.css +1 -1
- package/components/Dropdown/Dropdown.css +1 -1
- package/components/DropdownMenu/DropdownMenu.css +1 -1
- package/components/FilterBar/FilterBar.css +1 -1
- package/components/HoverCard/HoverCard.css +1 -1
- package/components/Lightbox/Lightbox.css +1 -1
- package/components/ModelPicker/ModelPicker.css +1 -1
- package/components/Popover/Popover.css +1 -1
- package/components/PromptSuggestions/PromptSuggestions.css +67 -0
- package/components/PromptSuggestions/PromptSuggestions.d.ts +8 -0
- package/components/PromptSuggestions/PromptSuggestions.js +34 -26
- package/components/QuestionCard/QuestionCard.css +358 -0
- package/components/QuestionCard/QuestionCard.d.ts +136 -0
- package/components/QuestionCard/QuestionCard.js +371 -0
- package/components/RichDropdown/RichDropdown.css +1 -1
- package/components/SourceChip/SourceChip.css +1 -1
- package/components/TimePicker/TimePicker.css +1 -1
- package/components/Toast/Toast.css +1 -1
- package/components/Tooltip/Tooltip.css +1 -1
- package/components/TopAppBar/TopAppBar.css +1 -1
- package/components/registry.json +8 -0
- package/components/registry.json.d.ts +8 -0
- package/components/registry.json.js +1 -1
- package/index.d.ts +1 -0
- package/index.js +3 -0
- package/package.json +5 -1
- package/tokens/breakpoints.d.ts +60 -0
- package/tokens/breakpoints.js +92 -0
- package/tokens/registry.d.ts +1 -1
- package/tokens/registry.json +14 -0
- package/tokens/registry.json.d.ts +14 -0
- package/tokens/registry.json.js +1 -1
- package/tokens/tokens-light.css +22 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { SUGGESTION_MAX_CHARS, fitsChip } from "./suggestions.js";
|
|
2
|
+
const MAX_QUESTIONS = 4;
|
|
3
|
+
const MAX_QUESTION_OPTIONS = 5;
|
|
4
|
+
const MIN_QUESTION_OPTIONS = 2;
|
|
5
|
+
const QUESTION_PROMPT_MAX_CHARS = 60;
|
|
6
|
+
const OPTION_DESCRIPTION_MAX_CHARS = 80;
|
|
7
|
+
const str = (value) => typeof value === "string" ? value.trim() : "";
|
|
8
|
+
const slug = (text, index) => text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40) || `option-${index + 1}`;
|
|
9
|
+
function sanitizeQuestion(input, index) {
|
|
10
|
+
if (typeof input !== "object" || input === null) return null;
|
|
11
|
+
const raw = input;
|
|
12
|
+
const prompt = str(raw.prompt);
|
|
13
|
+
if (!prompt || prompt.length > QUESTION_PROMPT_MAX_CHARS) return null;
|
|
14
|
+
const mode = raw.mode === "multi" ? "multi" : "single";
|
|
15
|
+
if (!Array.isArray(raw.options)) return null;
|
|
16
|
+
const options = [];
|
|
17
|
+
const seen = /* @__PURE__ */ new Set();
|
|
18
|
+
for (const [optionIndex, entry] of raw.options.entries()) {
|
|
19
|
+
if (typeof entry !== "object" || entry === null) return null;
|
|
20
|
+
const option = entry;
|
|
21
|
+
const label = str(option.label);
|
|
22
|
+
if (!fitsChip(label)) return null;
|
|
23
|
+
const value = str(option.value) || slug(label, optionIndex);
|
|
24
|
+
if (seen.has(value) || seen.has(label.toLowerCase())) return null;
|
|
25
|
+
seen.add(value);
|
|
26
|
+
seen.add(label.toLowerCase());
|
|
27
|
+
const description = str(option.description);
|
|
28
|
+
options.push(
|
|
29
|
+
description && description.length <= OPTION_DESCRIPTION_MAX_CHARS ? { value, label, description } : { value, label }
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
if (options.length < MIN_QUESTION_OPTIONS || options.length > MAX_QUESTION_OPTIONS) return null;
|
|
33
|
+
const question = {
|
|
34
|
+
id: str(raw.id) || `q${index + 1}`,
|
|
35
|
+
prompt,
|
|
36
|
+
mode,
|
|
37
|
+
options
|
|
38
|
+
};
|
|
39
|
+
if (raw.other === true) question.other = true;
|
|
40
|
+
if (mode === "multi") {
|
|
41
|
+
const available = options.length + (question.other ? 1 : 0);
|
|
42
|
+
const min = typeof raw.min === "number" ? Math.floor(raw.min) : 1;
|
|
43
|
+
const max = typeof raw.max === "number" ? Math.floor(raw.max) : available;
|
|
44
|
+
if (min < 1 || max < min || max > available) return null;
|
|
45
|
+
if (min !== 1) question.min = min;
|
|
46
|
+
if (max !== available) question.max = max;
|
|
47
|
+
}
|
|
48
|
+
return question;
|
|
49
|
+
}
|
|
50
|
+
function sanitizeQuestionSet(input, fallbackId = "questions") {
|
|
51
|
+
if (typeof input !== "object" || input === null) return null;
|
|
52
|
+
const raw = input;
|
|
53
|
+
if (!Array.isArray(raw.questions) || raw.questions.length > MAX_QUESTIONS) return null;
|
|
54
|
+
const questions = raw.questions.map((question, index) => sanitizeQuestion(question, index)).filter((question) => question !== null);
|
|
55
|
+
if (questions.length === 0) return null;
|
|
56
|
+
if (new Set(questions.map((question) => question.id)).size !== questions.length) return null;
|
|
57
|
+
return { id: str(raw.id) || fallbackId, questions };
|
|
58
|
+
}
|
|
59
|
+
function isBlockingQuestion(turn, live) {
|
|
60
|
+
return !live && turn?.role === "assistant" && !!turn.question && turn.question.questions.length > 1 && turn.answers === void 0;
|
|
61
|
+
}
|
|
62
|
+
function describeAnswers(set, answers) {
|
|
63
|
+
return {
|
|
64
|
+
items: set.questions.map((question) => {
|
|
65
|
+
const answer = answers.find((entry) => entry.questionId === question.id);
|
|
66
|
+
const labels = question.options.filter((option) => answer?.values.includes(option.value)).map((option) => option.label);
|
|
67
|
+
if (answer?.other) labels.push(answer.other);
|
|
68
|
+
return { prompt: question.prompt, answers: labels };
|
|
69
|
+
})
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
function serializeReply(reply) {
|
|
73
|
+
const answered = (labels) => labels.length > 0 ? labels.join(", ") : "(no answer)";
|
|
74
|
+
if (reply.items.length === 1) return answered(reply.items[0].answers);
|
|
75
|
+
return reply.items.map((item) => `${item.prompt} ${answered(item.answers)}`).join("\n");
|
|
76
|
+
}
|
|
77
|
+
const ASK_USER_INPUT_SCHEMA = {
|
|
78
|
+
type: "object",
|
|
79
|
+
properties: {
|
|
80
|
+
questions: {
|
|
81
|
+
type: "array",
|
|
82
|
+
minItems: 1,
|
|
83
|
+
maxItems: MAX_QUESTIONS,
|
|
84
|
+
items: {
|
|
85
|
+
type: "object",
|
|
86
|
+
properties: {
|
|
87
|
+
id: { type: "string", description: 'A short stable key for the question, e.g. "framework".' },
|
|
88
|
+
prompt: {
|
|
89
|
+
type: "string",
|
|
90
|
+
description: `The question in a few words, at most ${QUESTION_PROMPT_MAX_CHARS} characters, e.g. "Which framework?".`
|
|
91
|
+
},
|
|
92
|
+
mode: {
|
|
93
|
+
type: "string",
|
|
94
|
+
enum: ["single", "multi"],
|
|
95
|
+
description: "single when exactly one answer fits; multi when several can be true at once."
|
|
96
|
+
},
|
|
97
|
+
options: {
|
|
98
|
+
type: "array",
|
|
99
|
+
minItems: MIN_QUESTION_OPTIONS,
|
|
100
|
+
maxItems: MAX_QUESTION_OPTIONS,
|
|
101
|
+
items: {
|
|
102
|
+
type: "object",
|
|
103
|
+
properties: {
|
|
104
|
+
label: {
|
|
105
|
+
type: "string",
|
|
106
|
+
description: `The answer as the person would say it, at most ${SUGGESTION_MAX_CHARS} characters.`
|
|
107
|
+
},
|
|
108
|
+
description: {
|
|
109
|
+
type: "string",
|
|
110
|
+
description: `Optional: what choosing it means, at most ${OPTION_DESCRIPTION_MAX_CHARS} characters.`
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
required: ["label"]
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
other: {
|
|
117
|
+
type: "boolean",
|
|
118
|
+
description: 'Offer a free-text "Something else" when the options may not cover every answer.'
|
|
119
|
+
},
|
|
120
|
+
min: { type: "integer", description: "multi only: the fewest picks that make an answer." },
|
|
121
|
+
max: { type: "integer", description: "multi only: the most picks allowed." }
|
|
122
|
+
},
|
|
123
|
+
required: ["prompt", "options"]
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
required: ["questions"]
|
|
128
|
+
};
|
|
129
|
+
export {
|
|
130
|
+
ASK_USER_INPUT_SCHEMA,
|
|
131
|
+
MAX_QUESTIONS,
|
|
132
|
+
MAX_QUESTION_OPTIONS,
|
|
133
|
+
MIN_QUESTION_OPTIONS,
|
|
134
|
+
OPTION_DESCRIPTION_MAX_CHARS,
|
|
135
|
+
QUESTION_PROMPT_MAX_CHARS,
|
|
136
|
+
describeAnswers,
|
|
137
|
+
isBlockingQuestion,
|
|
138
|
+
sanitizeQuestionSet,
|
|
139
|
+
serializeReply
|
|
140
|
+
};
|
package/chat/registry.json.d.ts
CHANGED
|
@@ -6,11 +6,11 @@ declare const _default: {
|
|
|
6
6
|
"slug": "ai-chat",
|
|
7
7
|
"label": "AI chat",
|
|
8
8
|
"category": "ai",
|
|
9
|
-
"description": "The whole AI chat
|
|
9
|
+
"description": "The whole AI chat: the panel, the streaming replies, the history, the cards and questions a reply can carry and the server handlers, set up with props.",
|
|
10
10
|
"command": "init chat",
|
|
11
11
|
"subpaths": {
|
|
12
12
|
"./chat": "The widget, the provider, the launcher and the engine under them.",
|
|
13
|
-
"./chat/server": "The streaming chat handler and its follow-ups and feedback siblings, for any web-standard route.",
|
|
13
|
+
"./chat/server": "The streaming chat handler, with an opt-in tool for asking the visitor questions, and its follow-ups and feedback siblings, for any web-standard route.",
|
|
14
14
|
"./chat/upstash": "Rate limits, a daily budget and the exchange log over Upstash Redis, as the handlers' guardrails.",
|
|
15
15
|
"./chat/demo": "A scripted conversation with rich cards and fixtures, for a preview that needs no key.",
|
|
16
16
|
"./chat/chat.css": "The widget's stylesheet."
|
|
@@ -52,6 +52,7 @@ declare const _default: {
|
|
|
52
52
|
"PixelAvatar",
|
|
53
53
|
"PromptSuggestions",
|
|
54
54
|
"Prose",
|
|
55
|
+
"QuestionCard",
|
|
55
56
|
"Reasoning",
|
|
56
57
|
"ThreadPanel",
|
|
57
58
|
"ThreadTabs"
|
package/chat/registry.json.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
const $comment = "The widget registry: the fourth kind of thing the package ships, between the components and the templates. One member by design. `parts` names the library components the widget composes (held to src/components/registry.json by scripts/validate-chat-widget.mjs), `subpaths` the package exports it occupies (held to scripts/package-manifest.mjs), `category` the component category whose section and sidebar group its page joins (held to src/components/registry.json), `peers` the optional peer dependencies each subpath needs (held to package.json). The widget's page under /components, the /docs/chat page, the chat corpus and the MCP tools read this file; never type its facts elsewhere.";
|
|
2
|
-
const widgets = [{ "name": "ChatWidget", "slug": "ai-chat", "label": "AI chat", "category": "ai", "description": "The whole AI chat
|
|
2
|
+
const widgets = [{ "name": "ChatWidget", "slug": "ai-chat", "label": "AI chat", "category": "ai", "description": "The whole AI chat: the panel, the streaming replies, the history, the cards and questions a reply can carry and the server handlers, set up with props.", "command": "init chat", "subpaths": { "./chat": "The widget, the provider, the launcher and the engine under them.", "./chat/server": "The streaming chat handler, with an opt-in tool for asking the visitor questions, and its follow-ups and feedback siblings, for any web-standard route.", "./chat/upstash": "Rate limits, a daily budget and the exchange log over Upstash Redis, as the handlers' guardrails.", "./chat/demo": "A scripted conversation with rich cards and fixtures, for a preview that needs no key.", "./chat/chat.css": "The widget's stylesheet." }, "peers": { "./chat": ["react-markdown", "remark-gfm"], "./chat/server": ["@anthropic-ai/sdk"], "./chat/upstash": ["@upstash/ratelimit", "@upstash/redis"], "./chat/demo": ["recharts"] }, "parts": ["AgentRail", "AgentStatus", "AiButton", "ArtifactCard", "ArtifactPanel", "AttachmentDropZone", "AttachmentGroup", "ChatHeader", "ChatMessage", "ChatThread", "CircularButton", "CodeBlock", "Composer", "DropdownMenu", "EmptyState", "Mention", "ModelPicker", "PixelAvatar", "PromptSuggestions", "Prose", "QuestionCard", "Reasoning", "ThreadPanel", "ThreadTabs"], "documented": ["ChatWidget", "ChatProvider", "ChatLauncher", "ChatThreads", "ChatTabs", "ChatAgentPane", "ChatArtifactPane", "ChatArtifactCard"] }];
|
|
3
3
|
const registry = {
|
|
4
4
|
$comment,
|
|
5
5
|
widgets
|
package/chat/server.d.ts
CHANGED
|
@@ -16,6 +16,13 @@ export interface ChatTool {
|
|
|
16
16
|
/** The trace point shown while the round runs, e.g. "Checked the Button prop contract". */
|
|
17
17
|
tracePoint?: (input: Record<string, unknown>) => string;
|
|
18
18
|
}
|
|
19
|
+
/** The name of the built-in tool the model asks the visitor questions through. */
|
|
20
|
+
export declare const ASK_USER_TOOL_NAME = "ask_user";
|
|
21
|
+
/**
|
|
22
|
+
* What the model is told about asking. Lives in the tool's description, not
|
|
23
|
+
* the system prompt, so switching it on never disturbs a cached prefix.
|
|
24
|
+
*/
|
|
25
|
+
export declare const DEFAULT_ASK_USER_DESCRIPTION: string;
|
|
19
26
|
/**
|
|
20
27
|
* How much of the day's budget remains, as a model policy: `open` means the
|
|
21
28
|
* default model serves, `reduced` means the budget model is the default (the
|
|
@@ -105,6 +112,8 @@ export interface ChatHandlerCopy {
|
|
|
105
112
|
thinking: string;
|
|
106
113
|
/** The status label while a tool round runs. */
|
|
107
114
|
lookingUp: string;
|
|
115
|
+
/** The reply text when the model asks several questions without writing any. One question stands in for itself. */
|
|
116
|
+
questionsIntro: string;
|
|
108
117
|
/** The error for a busy or overloaded model. */
|
|
109
118
|
busy: string;
|
|
110
119
|
/** The error for a rejected key. */
|
|
@@ -140,6 +149,16 @@ export interface ChatHandlerOptions {
|
|
|
140
149
|
/** The wire value that serves once the budget steps down, and the only one past the lock. Defaults to `defaultModel`. */
|
|
141
150
|
budgetModel?: string;
|
|
142
151
|
tools?: ChatTool[];
|
|
152
|
+
/**
|
|
153
|
+
* Give the model the built-in `ask_user` tool, through which a reply can
|
|
154
|
+
* end in questions the widget draws as answers to pick. Pass an object to
|
|
155
|
+
* replace the guidance the model reads. Off by default. Calling it ends
|
|
156
|
+
* the exchange: no further model call is made, so it never counts against
|
|
157
|
+
* `maxModelCalls` beyond the pass that asked.
|
|
158
|
+
*/
|
|
159
|
+
askUser?: boolean | {
|
|
160
|
+
description?: string;
|
|
161
|
+
};
|
|
143
162
|
/** Model calls per exchange: the first pass plus tool rounds. The last allowed pass withholds the tools. */
|
|
144
163
|
maxModelCalls?: number;
|
|
145
164
|
/** Caps thinking and response text together. */
|
package/chat/server.js
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
import Anthropic from "@anthropic-ai/sdk";
|
|
2
|
+
import { ASK_USER_INPUT_SCHEMA, sanitizeQuestionSet } from "./questions.js";
|
|
2
3
|
import { SUGGESTION_MAX_CHARS, fitsChip } from "./suggestions.js";
|
|
4
|
+
const ASK_USER_TOOL_NAME = "ask_user";
|
|
5
|
+
const DEFAULT_ASK_USER_DESCRIPTION = [
|
|
6
|
+
"Offer the person answers to pick for a question your reply has just asked in words.",
|
|
7
|
+
"Use it only when the most useful next step depends on their answer and the likely answers can be listed: which framework, which of these options, which parts apply.",
|
|
8
|
+
"Do not use it for open questions, for confirming something they already said, or when you can simply answer. Most replies should not ask.",
|
|
9
|
+
"Write the question in your reply first, then call this once, as your last action. The conversation waits for their answer.",
|
|
10
|
+
"Prefer one question. Use several (at most 4) only when you need all the answers before you can continue. mode is single when one answer fits and multi when several can be true together.",
|
|
11
|
+
"Set other to true when the options may not cover every answer."
|
|
12
|
+
].join(" ");
|
|
3
13
|
const DEFAULT_CHAT_LIMITS = {
|
|
4
14
|
maxTurns: 10,
|
|
5
15
|
maxMessageChars: 4e3,
|
|
@@ -15,6 +25,7 @@ const DEFAULT_CHAT_COPY = {
|
|
|
15
25
|
reading: "Reading",
|
|
16
26
|
thinking: "Thinking",
|
|
17
27
|
lookingUp: "Looking it up",
|
|
28
|
+
questionsIntro: "A few questions before I go on.",
|
|
18
29
|
busy: "The model is busy right now. Give it a moment and ask again.",
|
|
19
30
|
unauthorised: "This chat is not configured correctly at the moment.",
|
|
20
31
|
failed: "Something went wrong reaching the model. Try that again in a moment."
|
|
@@ -121,7 +132,15 @@ function createChatHandler(options) {
|
|
|
121
132
|
const copy = { ...DEFAULT_CHAT_COPY, ...options.copy };
|
|
122
133
|
const sanitise = options.sanitiseContext ?? sanitisePath;
|
|
123
134
|
const tools = options.tools ?? [];
|
|
124
|
-
const
|
|
135
|
+
const askUserDefinition = options.askUser ? {
|
|
136
|
+
name: ASK_USER_TOOL_NAME,
|
|
137
|
+
description: (typeof options.askUser === "object" ? options.askUser.description : void 0) ?? DEFAULT_ASK_USER_DESCRIPTION,
|
|
138
|
+
input_schema: ASK_USER_INPUT_SCHEMA
|
|
139
|
+
} : null;
|
|
140
|
+
const toolDefinitions = [
|
|
141
|
+
...tools.map((tool) => tool.definition),
|
|
142
|
+
...askUserDefinition ? [askUserDefinition] : []
|
|
143
|
+
];
|
|
125
144
|
const toolByName = new Map(tools.map((tool) => [tool.definition.name, tool]));
|
|
126
145
|
const maxModelCalls = options.maxModelCalls ?? 3;
|
|
127
146
|
const maxTokens = options.maxTokens ?? 3e3;
|
|
@@ -273,7 +292,30 @@ function createChatHandler(options) {
|
|
|
273
292
|
(block) => block.type === "tool_use"
|
|
274
293
|
);
|
|
275
294
|
const results = [];
|
|
295
|
+
const ask = askUserDefinition ? toolUses.find((block) => block.name === ASK_USER_TOOL_NAME) : void 0;
|
|
296
|
+
const set = ask ? sanitizeQuestionSet(ask.input, `${exchangeId}-questions`) : null;
|
|
297
|
+
if (set) {
|
|
298
|
+
if (!streamedText) {
|
|
299
|
+
const lead = set.questions.length === 1 ? set.questions[0].prompt : copy.questionsIntro;
|
|
300
|
+
reasoning.close();
|
|
301
|
+
streamedText = true;
|
|
302
|
+
firstTextMs = Date.now() - startedAt;
|
|
303
|
+
answerText += lead;
|
|
304
|
+
send({ type: "delta", text: lead });
|
|
305
|
+
}
|
|
306
|
+
send({ type: "question", set });
|
|
307
|
+
break;
|
|
308
|
+
}
|
|
276
309
|
for (const block of toolUses) {
|
|
310
|
+
if (block.name === ASK_USER_TOOL_NAME) {
|
|
311
|
+
results.push({
|
|
312
|
+
type: "tool_result",
|
|
313
|
+
tool_use_id: block.id,
|
|
314
|
+
is_error: true,
|
|
315
|
+
content: `Those questions could not be shown. Each needs a short prompt and 2 to 5 distinct options of at most ${SUGGESTION_MAX_CHARS} characters, and a set holds at most 4. Fix them, or ask in plain text.`
|
|
316
|
+
});
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
277
319
|
const args = typeof block.input === "object" && block.input !== null ? block.input : {};
|
|
278
320
|
send({
|
|
279
321
|
type: "status",
|
|
@@ -477,6 +519,8 @@ function createFeedbackHandler(options = {}) {
|
|
|
477
519
|
};
|
|
478
520
|
}
|
|
479
521
|
export {
|
|
522
|
+
ASK_USER_TOOL_NAME,
|
|
523
|
+
DEFAULT_ASK_USER_DESCRIPTION,
|
|
480
524
|
DEFAULT_CHAT_COPY,
|
|
481
525
|
DEFAULT_CHAT_LIMITS,
|
|
482
526
|
createChatHandler,
|
package/chat/sim.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ReactNode } from 'react';
|
|
2
|
-
import { ChatCard, ChatTransport } from './types';
|
|
2
|
+
import { ChatCard, ChatQuestionSet, ChatTransport } from './types';
|
|
3
3
|
/** One status beat shown while the scripted reply "thinks". */
|
|
4
4
|
export interface ScriptStep {
|
|
5
5
|
/** The AgentStatus label. */
|
|
@@ -19,6 +19,17 @@ export interface ScriptedReply {
|
|
|
19
19
|
content?: ReactNode;
|
|
20
20
|
/** The chips offered under the reply. Each should be a scripted prompt so no path dead-ends. */
|
|
21
21
|
followups?: string[];
|
|
22
|
+
/**
|
|
23
|
+
* Questions the reply asks, drawn as a QuestionCard in place of the chips.
|
|
24
|
+
* The `text` should ask them in words too, as a live model's reply would.
|
|
25
|
+
*/
|
|
26
|
+
question?: ChatQuestionSet;
|
|
27
|
+
/**
|
|
28
|
+
* The reply to whatever the visitor answers to `question`: one reply for
|
|
29
|
+
* every answer, or a function of the answer as the message it was sent
|
|
30
|
+
* as. A scripted prompt that matches the answer exactly still wins.
|
|
31
|
+
*/
|
|
32
|
+
answered?: ScriptedReply | ((answer: string) => ScriptedReply);
|
|
22
33
|
}
|
|
23
34
|
/** A prompt the visitor can send, and the reply it routes to. */
|
|
24
35
|
export interface ScriptNode extends ScriptedReply {
|
package/chat/sim.js
CHANGED
|
@@ -50,11 +50,23 @@ function createSimTransport(script, timing = {}) {
|
|
|
50
50
|
const byPrompt = new Map(script.nodes.map((node) => [normalize(node.prompt), node]));
|
|
51
51
|
const fallbacks = script.fallbacks.length > 0 ? script.fallbacks : [DEFAULT_FALLBACK_REPLY];
|
|
52
52
|
const filesReply = script.filesReply ?? defaultFilesReply;
|
|
53
|
+
const asking = /* @__PURE__ */ new Map();
|
|
54
|
+
const collect = (reply) => {
|
|
55
|
+
if (!reply.question || asking.has(normalize(reply.text))) return;
|
|
56
|
+
asking.set(normalize(reply.text), reply);
|
|
57
|
+
if (reply.answered && typeof reply.answered !== "function") collect(reply.answered);
|
|
58
|
+
};
|
|
59
|
+
for (const reply of [...script.nodes, ...script.fallbacks]) collect(reply);
|
|
53
60
|
const defaultFollowups = script.nodes.map((node) => node.prompt).filter(fitsChip).slice(0, STARTER_COUNT);
|
|
54
61
|
let exchangeCount = 0;
|
|
55
62
|
return async function* simTransport(messages, signal) {
|
|
56
63
|
const lastUser = [...messages].reverse().find((m) => m.role === "user");
|
|
57
|
-
const
|
|
64
|
+
const prompted = lastUser ? byPrompt.get(normalize(lastUser.content)) : void 0;
|
|
65
|
+
const previous = messages[messages.length - 2];
|
|
66
|
+
const asked = !prompted && previous?.role === "assistant" ? asking.get(normalize(previous.content)) : void 0;
|
|
67
|
+
const answered = asked?.answered && lastUser ? typeof asked.answered === "function" ? asked.answered(lastUser.content) : asked.answered : void 0;
|
|
68
|
+
if (answered) collect(answered);
|
|
69
|
+
const node = prompted ?? answered;
|
|
58
70
|
const attached = !node ? lastUser?.attachments ?? [] : [];
|
|
59
71
|
const reply = node ?? (attached.length > 0 ? filesReply(attached.map((file) => file.name)) : fallbacks[exchangeCount % fallbacks.length]);
|
|
60
72
|
if (!node && attached.length === 0) exchangeCount += 1;
|
|
@@ -70,8 +82,12 @@ function createSimTransport(script, timing = {}) {
|
|
|
70
82
|
}
|
|
71
83
|
if (reply.content) yield { type: "content", node: reply.content };
|
|
72
84
|
if (reply.card) yield { type: "card", card: reply.card };
|
|
73
|
-
|
|
74
|
-
|
|
85
|
+
if (reply.question) {
|
|
86
|
+
yield { type: "question", set: reply.question };
|
|
87
|
+
} else {
|
|
88
|
+
const followups = (reply.followups ?? defaultFollowups).filter(fitsChip);
|
|
89
|
+
if (followups.length > 0) yield { type: "followups", items: followups };
|
|
90
|
+
}
|
|
75
91
|
yield { type: "done" };
|
|
76
92
|
};
|
|
77
93
|
}
|
package/chat/types.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { ReactNode } from 'react';
|
|
2
2
|
import { AttachmentItem } from '../components/Attachment/fileTypes';
|
|
3
|
+
import { QuestionCardAnswer, QuestionCardQuestion } from '../components/QuestionCard/QuestionCard';
|
|
3
4
|
/**
|
|
4
5
|
* Rich content that can cross a network: a kind the host knows how to draw
|
|
5
6
|
* and the data to draw it from. The provider's `renderers` map turns one
|
|
@@ -48,6 +49,33 @@ export interface ChatArtifact {
|
|
|
48
49
|
}
|
|
49
50
|
/** The card kind an artifact travels as. Its `data` is a `ChatArtifact`. */
|
|
50
51
|
export declare const ARTIFACT_CARD_KIND = "artifact";
|
|
52
|
+
/** One question the agent asks: a prompt and the answers on offer. The shape QuestionCard draws. */
|
|
53
|
+
export type ChatQuestion = QuestionCardQuestion;
|
|
54
|
+
/** What the visitor answered to one question. */
|
|
55
|
+
export type ChatQuestionAnswer = QuestionCardAnswer;
|
|
56
|
+
/**
|
|
57
|
+
* Questions the agent asks at the end of a reply, with the answers it can
|
|
58
|
+
* list: one question or a short set, each taking one option or several.
|
|
59
|
+
* The reply's text asks in words too, so a transport or a renderer that
|
|
60
|
+
* drops the set still leaves a conversation that reads correctly.
|
|
61
|
+
*/
|
|
62
|
+
export interface ChatQuestionSet {
|
|
63
|
+
/** Stable identity within the conversation. */
|
|
64
|
+
id: string;
|
|
65
|
+
/** Asked one at a time when there is more than one. A set of more than one holds the composer until it is answered or skipped. */
|
|
66
|
+
questions: ChatQuestion[];
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* The answers to a question set as a user turn draws them: each question's
|
|
70
|
+
* prompt and the labels picked. The turn's `text` is the same answers as
|
|
71
|
+
* the plain message the model reads.
|
|
72
|
+
*/
|
|
73
|
+
export interface ChatReply {
|
|
74
|
+
items: {
|
|
75
|
+
prompt: string;
|
|
76
|
+
answers: string[];
|
|
77
|
+
}[];
|
|
78
|
+
}
|
|
51
79
|
export type ChatEvent =
|
|
52
80
|
/** A live status step: the AgentStatus label, with an optional reasoning trace point. */
|
|
53
81
|
{
|
|
@@ -106,6 +134,12 @@ export type ChatEvent =
|
|
|
106
134
|
type: 'card';
|
|
107
135
|
card: ChatCard;
|
|
108
136
|
}
|
|
137
|
+
/** Questions the agent asks with answers to pick, drawn under the committed
|
|
138
|
+
turn in place of follow-ups: a turn that asks is never asked about. */
|
|
139
|
+
| {
|
|
140
|
+
type: 'question';
|
|
141
|
+
set: ChatQuestionSet;
|
|
142
|
+
}
|
|
109
143
|
/** Terminal: the response is complete. */
|
|
110
144
|
| {
|
|
111
145
|
type: 'done';
|
|
@@ -121,7 +155,7 @@ export type ChatEvent =
|
|
|
121
155
|
message: string;
|
|
122
156
|
};
|
|
123
157
|
/** Every event kind, for documentation that enumerates the protocol. */
|
|
124
|
-
export declare const CHAT_EVENT_KINDS: readonly ["status", "model", "models", "delta", "exchange", "followups", "content", "card", "done", "notice", "error"];
|
|
158
|
+
export declare const CHAT_EVENT_KINDS: readonly ["status", "model", "models", "delta", "exchange", "followups", "content", "card", "question", "done", "notice", "error"];
|
|
125
159
|
export interface ChatTransportMessage {
|
|
126
160
|
role: 'user' | 'assistant';
|
|
127
161
|
content: string;
|
|
@@ -187,6 +221,27 @@ export interface ChatTurn {
|
|
|
187
221
|
* carried from a transport's `card` event. Drawn where `content` is.
|
|
188
222
|
*/
|
|
189
223
|
card?: ChatCard;
|
|
224
|
+
/**
|
|
225
|
+
* Questions this assistant turn asks, drawn under it as a QuestionCard in
|
|
226
|
+
* place of follow-ups. Carried from a transport's `question` event.
|
|
227
|
+
*/
|
|
228
|
+
question?: ChatQuestionSet;
|
|
229
|
+
/**
|
|
230
|
+
* The answers given to `question`. Undefined while it is open; an empty
|
|
231
|
+
* array records a skip. Kept on the asking turn, so the card renders its
|
|
232
|
+
* answered state where it was asked.
|
|
233
|
+
*/
|
|
234
|
+
answers?: ChatQuestionAnswer[];
|
|
235
|
+
/**
|
|
236
|
+
* The answers a user turn carries, drawn as the bubble when there is more
|
|
237
|
+
* than one to show. Its `text` is the same answers as a plain message.
|
|
238
|
+
*/
|
|
239
|
+
reply?: ChatReply;
|
|
240
|
+
/**
|
|
241
|
+
* The follow-up chip the visitor sent from under this turn. Once set the
|
|
242
|
+
* row is a record: the chip marked, the rest dimmed and inert.
|
|
243
|
+
*/
|
|
244
|
+
followupChosen?: string;
|
|
190
245
|
/**
|
|
191
246
|
* Render `content` on its own in the thread, outside any message chrome,
|
|
192
247
|
* for conversation furniture like a day marker.
|
|
@@ -216,6 +271,8 @@ export interface LiveResponse {
|
|
|
216
271
|
content?: ReactNode;
|
|
217
272
|
/** Carried from the transport's `card` event into the committed turn. */
|
|
218
273
|
card?: ChatCard;
|
|
274
|
+
/** Carried from the transport's `question` event into the committed turn. */
|
|
275
|
+
question?: ChatQuestionSet;
|
|
219
276
|
}
|
|
220
277
|
/**
|
|
221
278
|
* One model the widget can offer in its picker and the server handler can
|
package/chat/types.js
CHANGED
package/chat/useChat.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { ReactNode } from 'react';
|
|
2
2
|
import { AttachmentItem } from '../components/Attachment/fileTypes';
|
|
3
|
-
import { ChatTransport, ChatTurn, LiveResponse } from './types';
|
|
3
|
+
import { ChatQuestionAnswer, ChatReply, ChatTransport, ChatTurn, LiveResponse } from './types';
|
|
4
4
|
/** The copy the hook shows when a transport ends a turn with nothing to show. */
|
|
5
5
|
export interface ChatPlaceholderCopy {
|
|
6
6
|
/** A completion that produced no text. */
|
|
@@ -35,7 +35,22 @@ export interface ChatState {
|
|
|
35
35
|
send: (text: string, options?: {
|
|
36
36
|
content?: ReactNode;
|
|
37
37
|
attachments?: AttachmentItem[];
|
|
38
|
+
reply?: ChatReply;
|
|
38
39
|
}) => boolean;
|
|
40
|
+
/**
|
|
41
|
+
* Answer the questions an assistant turn asked: records the answers on
|
|
42
|
+
* that turn, so its card renders the record, and sends them as the next
|
|
43
|
+
* message. Returns false, recording nothing, when the send is refused
|
|
44
|
+
* (a response in flight) or the turn asks nothing.
|
|
45
|
+
*/
|
|
46
|
+
answerQuestion: (turnId: string, answers: ChatQuestionAnswer[]) => boolean;
|
|
47
|
+
/** Decline the questions an assistant turn asked. Records the skip and sends nothing. */
|
|
48
|
+
skipQuestion: (turnId: string) => void;
|
|
49
|
+
/**
|
|
50
|
+
* Send a follow-up chip from under a turn and record which it was, so the
|
|
51
|
+
* row keeps the choice. Returns whether the send was accepted.
|
|
52
|
+
*/
|
|
53
|
+
chooseFollowup: (turnId: string, label: string) => boolean;
|
|
39
54
|
/** Stop the in-flight response, keeping the text that already arrived. */
|
|
40
55
|
stop: () => void;
|
|
41
56
|
/** New chat: abort anything in flight and clear the conversation. */
|
package/chat/useChat.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { useState, useRef, useEffect, useCallback } from "react";
|
|
3
3
|
import { revokeAttachmentUrls } from "../components/Attachment/useAttachments.js";
|
|
4
4
|
import { createStreamReveal } from "../components/StreamingText/useStreamReveal.js";
|
|
5
|
+
import { describeAnswers, serializeReply, sanitizeQuestionSet } from "./questions.js";
|
|
5
6
|
let nextId = 0;
|
|
6
7
|
const makeId = () => `turn-${++nextId}`;
|
|
7
8
|
const DEFAULT_PLACEHOLDER_COPY = {
|
|
@@ -50,6 +51,7 @@ function useChat(transport, placeholders = DEFAULT_PLACEHOLDER_COPY) {
|
|
|
50
51
|
followups: finished.followups,
|
|
51
52
|
content: finished.content,
|
|
52
53
|
card: finished.card,
|
|
54
|
+
question: finished.question,
|
|
53
55
|
durationSeconds: finished.durationSeconds ?? Math.max(1, Math.round((Date.now() - startedAt) / 1e3))
|
|
54
56
|
}
|
|
55
57
|
]);
|
|
@@ -83,7 +85,8 @@ function useChat(transport, placeholders = DEFAULT_PLACEHOLDER_COPY) {
|
|
|
83
85
|
role: "user",
|
|
84
86
|
text: trimmed,
|
|
85
87
|
content: options?.content,
|
|
86
|
-
attachments
|
|
88
|
+
attachments,
|
|
89
|
+
reply: options?.reply
|
|
87
90
|
}
|
|
88
91
|
]);
|
|
89
92
|
let current = {
|
|
@@ -126,6 +129,11 @@ function useChat(transport, placeholders = DEFAULT_PLACEHOLDER_COPY) {
|
|
|
126
129
|
case "card":
|
|
127
130
|
current = { ...current, card: event.card };
|
|
128
131
|
break;
|
|
132
|
+
case "question": {
|
|
133
|
+
const set = sanitizeQuestionSet(event.set, `${current.id}-questions`);
|
|
134
|
+
if (set) current = { ...current, question: set };
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
129
137
|
case "status":
|
|
130
138
|
update({
|
|
131
139
|
...current,
|
|
@@ -206,6 +214,32 @@ function useChat(transport, placeholders = DEFAULT_PLACEHOLDER_COPY) {
|
|
|
206
214
|
(prev) => prev.map((turn) => turn.id === turnId ? { ...turn, followups } : turn)
|
|
207
215
|
);
|
|
208
216
|
}, []);
|
|
217
|
+
const patchTurn = useCallback((turnId, patch) => {
|
|
218
|
+
setTurns((prev) => prev.map((turn) => turn.id === turnId ? { ...turn, ...patch } : turn));
|
|
219
|
+
}, []);
|
|
220
|
+
const answerQuestion = useCallback(
|
|
221
|
+
(turnId, answers) => {
|
|
222
|
+
const set = turnsRef.current.find((turn) => turn.id === turnId)?.question;
|
|
223
|
+
if (!set) return false;
|
|
224
|
+
const reply = describeAnswers(set, answers);
|
|
225
|
+
if (!send(serializeReply(reply), { reply })) return false;
|
|
226
|
+
patchTurn(turnId, { answers });
|
|
227
|
+
return true;
|
|
228
|
+
},
|
|
229
|
+
[send, patchTurn]
|
|
230
|
+
);
|
|
231
|
+
const skipQuestion = useCallback(
|
|
232
|
+
(turnId) => patchTurn(turnId, { answers: [] }),
|
|
233
|
+
[patchTurn]
|
|
234
|
+
);
|
|
235
|
+
const chooseFollowup = useCallback(
|
|
236
|
+
(turnId, label) => {
|
|
237
|
+
if (!send(label)) return false;
|
|
238
|
+
patchTurn(turnId, { followupChosen: label });
|
|
239
|
+
return true;
|
|
240
|
+
},
|
|
241
|
+
[send, patchTurn]
|
|
242
|
+
);
|
|
209
243
|
const injectTurn = useCallback((turn) => {
|
|
210
244
|
setTurns((prev) => [...prev, { ...turn, id: makeId() }]);
|
|
211
245
|
}, []);
|
|
@@ -232,7 +266,10 @@ function useChat(transport, placeholders = DEFAULT_PLACEHOLDER_COPY) {
|
|
|
232
266
|
reset,
|
|
233
267
|
injectTurn,
|
|
234
268
|
setTurnFeedback,
|
|
235
|
-
setTurnFollowups
|
|
269
|
+
setTurnFollowups,
|
|
270
|
+
answerQuestion,
|
|
271
|
+
skipQuestion,
|
|
272
|
+
chooseFollowup
|
|
236
273
|
};
|
|
237
274
|
}
|
|
238
275
|
export {
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
position: fixed;
|
|
12
12
|
top: 0;
|
|
13
13
|
left: 0;
|
|
14
|
-
z-index:
|
|
14
|
+
z-index: var(--layer-nav-rail);
|
|
15
15
|
display: flex;
|
|
16
16
|
flex-direction: column;
|
|
17
17
|
justify-content: space-between;
|
|
@@ -548,7 +548,7 @@
|
|
|
548
548
|
top: 0;
|
|
549
549
|
left: 64px;
|
|
550
550
|
/* One step under the rail, so the slide starts from beneath it. */
|
|
551
|
-
z-index:
|
|
551
|
+
z-index: calc(var(--layer-nav-rail) - 1);
|
|
552
552
|
height: 100vh;
|
|
553
553
|
border-left: var(--border-025) solid var(--color-bg-container-border);
|
|
554
554
|
border-right: var(--border-025) solid var(--color-bg-container-border);
|
|
@@ -762,7 +762,7 @@
|
|
|
762
762
|
a 40px header row's. */
|
|
763
763
|
top: var(--padding-400);
|
|
764
764
|
left: var(--padding-400);
|
|
765
|
-
z-index:
|
|
765
|
+
z-index: var(--layer-nav);
|
|
766
766
|
display: flex;
|
|
767
767
|
align-items: center;
|
|
768
768
|
justify-content: center;
|
|
@@ -781,7 +781,7 @@
|
|
|
781
781
|
display: block;
|
|
782
782
|
position: fixed;
|
|
783
783
|
inset: 0;
|
|
784
|
-
z-index:
|
|
784
|
+
z-index: var(--layer-nav-scrim);
|
|
785
785
|
background: var(--color-scrim);
|
|
786
786
|
}
|
|
787
787
|
|
|
@@ -794,7 +794,7 @@
|
|
|
794
794
|
top: 0;
|
|
795
795
|
left: 0;
|
|
796
796
|
bottom: 0;
|
|
797
|
-
z-index:
|
|
797
|
+
z-index: var(--layer-nav-rail);
|
|
798
798
|
width: 280px;
|
|
799
799
|
height: 100dvh;
|
|
800
800
|
padding: var(--padding-500) 0;
|