@greenstones/qui-ai 0.0.1

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.
@@ -0,0 +1,25 @@
1
+ import { join, dirname } from "path";
2
+
3
+ /**
4
+ * This function is used to resolve the absolute path of a package.
5
+ * It is needed in projects that use Yarn PnP or are set up within a monorepo.
6
+ */
7
+ function getAbsolutePath(value) {
8
+ return dirname(require.resolve(join(value, "package.json")));
9
+ }
10
+
11
+ /** @type { import('@storybook/react-vite').StorybookConfig } */
12
+ const config = {
13
+ stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
14
+ addons: [
15
+ getAbsolutePath("@storybook/addon-onboarding"),
16
+ getAbsolutePath("@storybook/addon-essentials"),
17
+ getAbsolutePath("@chromatic-com/storybook"),
18
+ getAbsolutePath("@storybook/addon-interactions"),
19
+ ],
20
+ framework: {
21
+ name: getAbsolutePath("@storybook/react-vite"),
22
+ options: {},
23
+ },
24
+ };
25
+ export default config;
@@ -0,0 +1,13 @@
1
+ /** @type { import('@storybook/react').Preview } */
2
+ const preview = {
3
+ parameters: {
4
+ controls: {
5
+ matchers: {
6
+ color: /(background|color)$/i,
7
+ date: /Date$/i,
8
+ },
9
+ },
10
+ },
11
+ };
12
+
13
+ export default preview;
package/index.ts ADDED
@@ -0,0 +1 @@
1
+ export * from "./src";
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@greenstones/qui-ai",
3
+ "private": false,
4
+ "version": "0.0.1",
5
+ "type": "module",
6
+ "module": "index.ts",
7
+ "scripts": {
8
+ "lint": "eslint .",
9
+ "storybook": "storybook dev -p 6006",
10
+ "build-storybook": "storybook build"
11
+ },
12
+ "dependencies": {
13
+ "@greenstones/qui-bootstrap": "0.1.0",
14
+ "@greenstones/qui-core": "0.1.0-alpha.1",
15
+ "chroma-js": "^3.2.0",
16
+ "classnames": "^2.5.1",
17
+ "lucide-react": "^0.500",
18
+ "react-markdown": "^10.1.0",
19
+ "recharts": "^3",
20
+ "rehype-raw": "^7",
21
+ "remark-gfm": "^4"
22
+ },
23
+ "devDependencies": {
24
+ "@types/react": "^19",
25
+ "@types/react-dom": "^19"
26
+ },
27
+ "peerDependencies": {
28
+ "date-fns": "^4.1",
29
+ "react": "^19",
30
+ "react-dom": "^19",
31
+ "react-bootstrap": "^2.10",
32
+ "react-router-dom": "^7",
33
+ "react-hook-form": "^7",
34
+ "@ai-sdk/amazon-bedrock": "^4",
35
+ "@ai-sdk/anthropic": "^3",
36
+ "@ai-sdk/openai": "^3",
37
+ "@openai/agents": "^0.7",
38
+ "@openai/agents-extensions": "^0.7"
39
+ },
40
+ "sideEffects": false
41
+ }
@@ -0,0 +1,95 @@
1
+ import { Bot, ChevronDown } from "lucide-react";
2
+ import { useState } from "react";
3
+ import { Button, Offcanvas, OverlayTrigger, Tooltip } from "react-bootstrap";
4
+ import { ChatPanel, EmptyPlaceholder } from "./ChatPanel";
5
+ import { type Chat } from "./use-chat";
6
+
7
+ export const AssistantButton = ({
8
+ chat,
9
+ type = "window",
10
+ }: {
11
+ chat: Chat;
12
+ type?: "window" | "offcanvas";
13
+ }) => {
14
+ const [show, setShow] = useState(false);
15
+ const handleClose = () => setShow(false);
16
+
17
+ return (
18
+ <div>
19
+ <div style={{ position: "absolute", bottom: "1.2rem", right: "1.2rem" }}>
20
+ <OverlayTrigger
21
+ overlay={<Tooltip>Open Assistant</Tooltip>}
22
+ placement="left"
23
+ >
24
+ <Button
25
+ variant="primary"
26
+ onClick={(e) => {
27
+ setShow((s) => !s);
28
+ }}
29
+ style={{ borderRadius: 32, padding: 12 }}
30
+ >
31
+ {!show ? (
32
+ <Bot width={24} height={24} />
33
+ ) : (
34
+ <ChevronDown width={24} height={24} />
35
+ )}
36
+ </Button>
37
+ </OverlayTrigger>
38
+ </div>
39
+
40
+ {type === "offcanvas" && (
41
+ <Offcanvas
42
+ show={show}
43
+ onHide={handleClose}
44
+ placement="end"
45
+ scroll={true}
46
+ backdrop={false}
47
+ className="shadow"
48
+ >
49
+ <Offcanvas.Header closeButton>
50
+ <Offcanvas.Title>Assistant</Offcanvas.Title>
51
+ </Offcanvas.Header>
52
+ <Offcanvas.Body className="flex-fill d-flex flex-column">
53
+ <ChatPanel
54
+ chat={chat}
55
+ examples={[
56
+ "What’s the weather like in Frankfurt?",
57
+ "Create a table listing the current weather in the 5 largest German cities",
58
+ ]}
59
+ userMessagePosition="end"
60
+ centerOnEmpty={false}
61
+ chatMessagesClassName="gap-1"
62
+ empty={<EmptyPlaceholder className="text-secondary" />}
63
+ />
64
+ </Offcanvas.Body>
65
+ </Offcanvas>
66
+ )}
67
+
68
+ {type === "window" && show && (
69
+ <div
70
+ className="bg-white shadow-lg border rounded-4 p-3 d-flex flex-column"
71
+ style={{
72
+ height: "50%",
73
+ minHeight: 600,
74
+ position: "absolute",
75
+ bottom: "5.4rem",
76
+ right: "1.2rem",
77
+ width: "28rem",
78
+ }}
79
+ >
80
+ <ChatPanel
81
+ chat={chat}
82
+ examples={[
83
+ "What’s the weather like in Frankfurt?",
84
+ "Create a table listing the current weather in the 5 largest German cities",
85
+ ]}
86
+ userMessagePosition="end"
87
+ centerOnEmpty={false}
88
+ chatMessagesClassName="gap-1"
89
+ empty={<EmptyPlaceholder className="text-secondary" />}
90
+ />
91
+ </div>
92
+ )}
93
+ </div>
94
+ );
95
+ };
@@ -0,0 +1,12 @@
1
+ .spin {
2
+ animation: spin 2s linear infinite;
3
+ }
4
+
5
+ @keyframes spin {
6
+ from {
7
+ transform: rotate(0deg);
8
+ }
9
+ to {
10
+ transform: rotate(360deg);
11
+ }
12
+ }
@@ -0,0 +1,201 @@
1
+ import classNames from "classnames";
2
+ import { ArrowUp, Loader, Trash, WandSparkles } from "lucide-react";
3
+ import { useRef, type ReactNode, type Ref } from "react";
4
+ import { Button, ButtonToolbar, Dropdown, Form } from "react-bootstrap";
5
+ import { useForm } from "react-hook-form";
6
+ import type { Chat } from "./use-chat";
7
+ import "./ChatForm.css";
8
+
9
+ export interface ChatFormProps {
10
+ chat: Chat;
11
+ className?: string;
12
+ tools?: ReactNode;
13
+ examples?: string[];
14
+ variant?: undefined | "bordered" | "standard";
15
+ }
16
+
17
+ export function ChatForm({
18
+ chat,
19
+ examples,
20
+ tools,
21
+ className,
22
+ variant,
23
+ }: ChatFormProps) {
24
+ const formRef = useRef<HTMLFormElement>(null);
25
+ const inputRef = useRef<HTMLTextAreaElement>(null);
26
+
27
+ const { register, handleSubmit, setValue } = useForm<{ message: string }>({
28
+ defaultValues: { message: "" },
29
+ });
30
+
31
+ const field = register("message", { required: true });
32
+ const onSubmit = async (data) => {
33
+ const msg = data.message;
34
+ if (msg) {
35
+ chat.callChat(msg, {
36
+ monitor: (state: string) => {
37
+ if (state === "start") {
38
+ inputRef.current!.value = "";
39
+ }
40
+ },
41
+ });
42
+ }
43
+ };
44
+
45
+ const handleKeyDown = (event: React.KeyboardEvent) => {
46
+ if (event.key === "Enter" && !event.shiftKey) {
47
+ event.preventDefault();
48
+ if (!chat.isRunning) formRef.current?.requestSubmit();
49
+ }
50
+ };
51
+
52
+ let variantProps;
53
+ switch (variant) {
54
+ case "bordered":
55
+ variantProps = {
56
+ mainClassName: "border rounded-4 px-3 py-3 mb-3 shadow-sm",
57
+ inputStyle: {
58
+ border: "none",
59
+ borderRadius: 0,
60
+ padding: 0,
61
+ minHeight: "auto",
62
+ boxShadow: "none",
63
+ background: "none",
64
+ marginTop: ".375rem",
65
+ },
66
+ rows: 1,
67
+ };
68
+ break;
69
+
70
+ default:
71
+ variantProps = {
72
+ mainClassName: "mt-1",
73
+ inputStyle: undefined,
74
+ rows: 2,
75
+ //minHeight: "1rem",
76
+ };
77
+ break;
78
+ }
79
+
80
+ console.log(variantProps);
81
+
82
+ return (
83
+ <div className={classNames(variantProps.mainClassName, className)}>
84
+ <form
85
+ className="d-grid gap-2"
86
+ ref={formRef}
87
+ onSubmit={handleSubmit(onSubmit)}
88
+ >
89
+ <div className="px-0">
90
+ <Form.Group className="" controlId="message">
91
+ <Form.Control
92
+ style={variantProps.inputStyle}
93
+ as="textarea"
94
+ {...{
95
+ ...field,
96
+ ref: ((r: Ref<HTMLTextAreaElement>) => {
97
+ field.ref?.(r);
98
+ inputRef.current = r as any;
99
+ return;
100
+ }) as any,
101
+ }}
102
+ autoFocus
103
+ rows={variantProps.rows}
104
+ onKeyDown={handleKeyDown}
105
+ onInput={autoresize(inputRef)}
106
+ // placeholder={
107
+ // history.length > 0
108
+ // ? "Type your message or use ↑/↓ to browse input history"
109
+ // : "Type your message..."
110
+ // }
111
+ placeholder={"Type your message..."}
112
+ />
113
+ </Form.Group>
114
+ </div>
115
+
116
+ <ButtonToolbar className="gap-2">
117
+ <Dropdown className="">
118
+ <Dropdown.Toggle variant="light" size="sm">
119
+ <WandSparkles size={16} />
120
+ </Dropdown.Toggle>
121
+ <Dropdown.Menu>
122
+ {examples?.map((c) => (
123
+ <Dropdown.Item
124
+ key={c}
125
+ onClick={() => {
126
+ setValue("message", c);
127
+ formRef.current?.requestSubmit();
128
+ }}
129
+ >
130
+ {c}
131
+ </Dropdown.Item>
132
+ ))}
133
+ </Dropdown.Menu>
134
+ </Dropdown>
135
+
136
+ <Button
137
+ variant={"light"}
138
+ title="Clear"
139
+ onClick={(e) => {
140
+ chat.clearMessages();
141
+ }}
142
+ size="sm"
143
+ >
144
+ <Trash size={16} />
145
+ </Button>
146
+
147
+ {tools}
148
+
149
+ <Button
150
+ type="submit"
151
+ variant="primary"
152
+ size="sm"
153
+ className="ms-auto"
154
+ disabled={chat.isRunning}
155
+ >
156
+ {!chat.isRunning && (
157
+ <ArrowUp
158
+ color="white"
159
+ size={18}
160
+ strokeWidth={3}
161
+ style={{ marginTop: -2 }}
162
+ />
163
+ )}
164
+ {chat.isRunning && (
165
+ <Loader
166
+ color="white"
167
+ size={18}
168
+ strokeWidth={3}
169
+ style={{ marginTop: -2 }}
170
+ className="spin"
171
+ />
172
+ )}
173
+ </Button>
174
+ </ButtonToolbar>
175
+ </form>
176
+ </div>
177
+ );
178
+ }
179
+
180
+ function _autoresize(ref: React.RefObject<HTMLTextAreaElement | null>) {
181
+ const el = ref.current;
182
+ if (el) {
183
+ el.style.height = "auto";
184
+ el.style.height = Math.min(el.scrollHeight, 250) + "px";
185
+ }
186
+ }
187
+
188
+ function autoresize(ref: React.RefObject<HTMLTextAreaElement | null>) {
189
+ return () => {
190
+ _autoresize(ref);
191
+ };
192
+ }
193
+
194
+ function waitAndAutoresize(ref: React.RefObject<HTMLTextAreaElement | null>) {
195
+ const el = ref.current;
196
+ if (el) {
197
+ setTimeout(() => {
198
+ _autoresize(ref);
199
+ }, 100);
200
+ }
201
+ }
@@ -0,0 +1,83 @@
1
+ import type {
2
+ AssistantMessageItem,
3
+ FunctionCallItem,
4
+ FunctionCallResultItem,
5
+ UnknownItem,
6
+ UserMessageItem,
7
+ } from "@openai/agents";
8
+ import { Fragment, type ReactNode } from "react";
9
+ import type { MessageItem } from "./MessageItem";
10
+
11
+ export function ChatMessages({
12
+ messages,
13
+ className,
14
+ renderUserMessage,
15
+ renderAssistantMessage,
16
+ renderFunctionCall,
17
+ }: {
18
+ messages: MessageItem[];
19
+ className: string;
20
+ renderUserMessage: (message: UserMessageItem) => ReactNode;
21
+ renderAssistantMessage: (message: AssistantMessageItem) => ReactNode;
22
+ renderFunctionCall?: (
23
+ call: FunctionCallItem,
24
+ result?: FunctionCallResultItem,
25
+ ) => ReactNode;
26
+ }) {
27
+ const functionResultItems = messages
28
+ .filter((m) => isFunctionCallResult(m))
29
+ .reduce(
30
+ (p, c) => {
31
+ p[c.callId] = c;
32
+ return p;
33
+ },
34
+ {} as Record<string, FunctionCallResultItem>,
35
+ );
36
+
37
+ function renderMessage(m: MessageItem) {
38
+ if (isUserMessage(m)) {
39
+ return renderUserMessage(m);
40
+ }
41
+ if (isAssistantMessage(m)) {
42
+ return renderAssistantMessage(m);
43
+ }
44
+
45
+ if (isFunctionCall(m)) {
46
+ return renderFunctionCall?.(m, functionResultItems[m.callId]);
47
+ }
48
+
49
+ return null;
50
+ }
51
+
52
+ return (
53
+ <div className={className}>
54
+ {messages.map((m, index) => (
55
+ <Fragment key={index}>{renderMessage(m)}</Fragment>
56
+ ))}
57
+ </div>
58
+ );
59
+ }
60
+
61
+ export const isUserMessage = (m: MessageItem): m is UserMessageItem => {
62
+ return (m as UserMessageItem).role === "user";
63
+ };
64
+
65
+ export const isAssistantMessage = (
66
+ m: MessageItem,
67
+ ): m is AssistantMessageItem => {
68
+ return (m as AssistantMessageItem).role === "assistant";
69
+ };
70
+
71
+ export const isFunctionCallResult = (
72
+ m: MessageItem,
73
+ ): m is FunctionCallResultItem => {
74
+ return (m as FunctionCallResultItem).type === "function_call_result";
75
+ };
76
+
77
+ export const isFunctionCall = (m: MessageItem): m is FunctionCallItem => {
78
+ return (m as FunctionCallItem).type === "function_call";
79
+ };
80
+
81
+ export const isUnknown = (m: MessageItem): m is UnknownItem => {
82
+ return (m as UnknownItem).type === "unknown";
83
+ };
@@ -0,0 +1,33 @@
1
+ import { Page, type PageOptions } from "@greenstones/qui-bootstrap";
2
+ import { ChatPanel, EmptyPlaceholder, type ChatPanelProps } from "./ChatPanel";
3
+
4
+ export function ChatPage({
5
+ chat,
6
+ empty = <EmptyPlaceholder />,
7
+ examples,
8
+ tools,
9
+ variant,
10
+ formClassName,
11
+ userMessagePosition,
12
+ userMessageClassName,
13
+ children,
14
+ ...pageOptions
15
+ }: ChatPanelProps & PageOptions) {
16
+ return (
17
+ <Page {...pageOptions} scrollBody={false}>
18
+ <ChatPanel
19
+ {...{
20
+ chat,
21
+ userMessagePosition,
22
+ userMessageClassName,
23
+ empty,
24
+ formClassName,
25
+ tools,
26
+ examples,
27
+ variant,
28
+ }}
29
+ />
30
+ {children}
31
+ </Page>
32
+ );
33
+ }
@@ -0,0 +1,133 @@
1
+ import classNames from "classnames";
2
+ import type { ReactNode } from "react";
3
+ import { Spinner } from "react-bootstrap";
4
+ import { BootstrapStylesComponents } from "../md/BootstrapStyles";
5
+ import { MarkdownContent } from "../md/MarkdownContent";
6
+ import { ChatForm } from "./ChatForm";
7
+ import { ChatMessages } from "./ChatMessages";
8
+ import { AssistantMessage } from "./messages/AssistantMessage";
9
+ import { FunctionCall } from "./messages/FunctionCall";
10
+ import { UserMessage } from "./messages/UserMessage";
11
+ import { MessageContent } from "./TextContent";
12
+ import type { Chat } from "./use-chat";
13
+
14
+ export interface ChatPanelProps {
15
+ chat: Chat;
16
+ empty?: ReactNode;
17
+ examples?: string[];
18
+ tools?: ReactNode;
19
+ variant?: undefined | "bordered" | "standard";
20
+ formClassName?: string;
21
+ userMessagePosition?: undefined | "start" | "end";
22
+ userMessageClassName?: string;
23
+ centerOnEmpty?: boolean;
24
+ chatMessagesClassName?: string;
25
+ }
26
+
27
+ export function ChatPanel({
28
+ chat,
29
+ userMessagePosition,
30
+ userMessageClassName,
31
+ empty,
32
+ formClassName,
33
+ tools,
34
+ examples,
35
+ variant,
36
+ centerOnEmpty = true,
37
+ chatMessagesClassName = "gap-3",
38
+ }: ChatPanelProps) {
39
+ const isEmptyChat = !chat.messages || chat.messages.length === 0;
40
+ return (
41
+ <div className="flex-fill d-flex flex-column" style={{ height: 1 }}>
42
+ {!isEmptyChat && (
43
+ <div className="overflow-auto d-flex flex-column flex-fill">
44
+ <ChatMessages
45
+ className={classNames("d-flex flex-column", chatMessagesClassName)}
46
+ messages={chat.messages}
47
+ renderUserMessage={(message) => (
48
+ <UserMessage
49
+ message={message}
50
+ renderContent={(c) => (
51
+ <MessageContent
52
+ content={c}
53
+ wrapperClassName={classNames("d-flex", {
54
+ "justify-content-end": userMessagePosition === "end",
55
+ })}
56
+ className={
57
+ userMessageClassName || "bg-light py-3 px-3 rounded-4 "
58
+ }
59
+ />
60
+ )}
61
+ />
62
+ )}
63
+ renderAssistantMessage={(message) => (
64
+ <AssistantMessage
65
+ message={message}
66
+ renderContent={(c) => (
67
+ <MarkdownContent
68
+ content={c}
69
+ wrapperClassName=""
70
+ components={{
71
+ ...BootstrapStylesComponents,
72
+ ...chat.widgets,
73
+ }}
74
+ />
75
+ )}
76
+ />
77
+ )}
78
+ renderFunctionCall={(call, result) => (
79
+ <FunctionCall call={call} result={result} />
80
+ )}
81
+ />
82
+
83
+ {chat.isRunning && (
84
+ <Spinner
85
+ className="ms-2 mt-3"
86
+ animation="grow"
87
+ variant="secondary"
88
+ size="sm"
89
+ />
90
+ )}
91
+ <div
92
+ ref={chat.endRef}
93
+ style={{ height: "10rem", minHeight: "10rem" }}
94
+ >
95
+ {" "}
96
+ </div>
97
+ </div>
98
+ )}
99
+
100
+ {isEmptyChat && !centerOnEmpty && empty && (
101
+ <div className="flex-fill1 my-auto">{empty}</div>
102
+ )}
103
+
104
+ <div
105
+ className={classNames("", {
106
+ " my-auto ": centerOnEmpty && isEmptyChat,
107
+ })}
108
+ >
109
+ {isEmptyChat && centerOnEmpty && empty}
110
+ <ChatForm
111
+ chat={chat}
112
+ className={formClassName}
113
+ tools={tools}
114
+ examples={examples}
115
+ variant={variant}
116
+ />
117
+ {centerOnEmpty && isEmptyChat && (
118
+ <div style={{ height: "20rem" }}></div>
119
+ )}
120
+ </div>
121
+ </div>
122
+ );
123
+ }
124
+
125
+ export function EmptyPlaceholder({ className }: { className?: string }) {
126
+ return (
127
+ <div>
128
+ <div className={classNames("h4 fw-normal text-center mb-4", className)}>
129
+ How can I help you today?
130
+ </div>
131
+ </div>
132
+ );
133
+ }
@@ -0,0 +1,3 @@
1
+ import type { AgentInputItem, AgentOutputItem } from "@openai/agents";
2
+
3
+ export type MessageItem = AgentInputItem | AgentOutputItem;
@@ -0,0 +1,15 @@
1
+ export function MessageContent({
2
+ content,
3
+ wrapperClassName,
4
+ className,
5
+ }: {
6
+ content: string;
7
+ wrapperClassName?: string;
8
+ className?: string;
9
+ }) {
10
+ return (
11
+ <div className={wrapperClassName}>
12
+ <div className={className}>{content}</div>
13
+ </div>
14
+ );
15
+ }
@@ -0,0 +1,8 @@
1
+ export * from "./ChatPage";
2
+ export * from "./ChatForm";
3
+ export * from "./ChatPanel";
4
+ export * from "./MessageItem";
5
+ export * from "./AssistantButton";
6
+ export * from "./ChatMessages";
7
+ export * from "./TextContent";
8
+ export * from "./use-chat";