@kitnai/chat 0.1.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/LICENSE +21 -0
- package/README.md +314 -0
- package/dist/bash-InADTalH.js +6 -0
- package/dist/core-AYMC6_lb.js +5874 -0
- package/dist/engine-javascript-vq0WuIJl.js +2643 -0
- package/dist/github-dark-dimmed-DUshB20C.js +4 -0
- package/dist/github-light-JYsPkUQd.js +4 -0
- package/dist/javascript-C25yR2R2.js +6 -0
- package/dist/json-DxJze_jm.js +6 -0
- package/dist/kitn-chat.es.js +6632 -0
- package/dist/tsx-B8rCNbgL.js +6 -0
- package/dist/typescript-RycA9KXf.js +6 -0
- package/package.json +80 -0
- package/src/components/attachments.stories.tsx +304 -0
- package/src/components/attachments.tsx +394 -0
- package/src/components/chain-of-thought.stories.tsx +212 -0
- package/src/components/chain-of-thought.tsx +139 -0
- package/src/components/chat-container.stories.tsx +188 -0
- package/src/components/chat-container.tsx +78 -0
- package/src/components/chat-scope-picker.tsx +47 -0
- package/src/components/checkpoint.stories.tsx +103 -0
- package/src/components/checkpoint.tsx +81 -0
- package/src/components/code-block.stories.tsx +151 -0
- package/src/components/code-block.tsx +99 -0
- package/src/components/context.stories.tsx +180 -0
- package/src/components/context.tsx +323 -0
- package/src/components/conversation-item.stories.tsx +126 -0
- package/src/components/conversation-item.tsx +18 -0
- package/src/components/conversation-list.stories.tsx +134 -0
- package/src/components/conversation-list.tsx +100 -0
- package/src/components/empty.stories.tsx +435 -0
- package/src/components/empty.tsx +166 -0
- package/src/components/feedback-bar.stories.tsx +101 -0
- package/src/components/feedback-bar.tsx +58 -0
- package/src/components/file-upload.stories.tsx +157 -0
- package/src/components/file-upload.tsx +161 -0
- package/src/components/image.stories.tsx +90 -0
- package/src/components/image.tsx +67 -0
- package/src/components/loader.stories.tsx +182 -0
- package/src/components/loader.tsx +333 -0
- package/src/components/markdown.stories.tsx +181 -0
- package/src/components/markdown.tsx +81 -0
- package/src/components/message-narrow.stories.tsx +330 -0
- package/src/components/message-skills.stories.tsx +212 -0
- package/src/components/message-skills.tsx +36 -0
- package/src/components/message.stories.tsx +282 -0
- package/src/components/message.tsx +149 -0
- package/src/components/model-switcher.stories.tsx +98 -0
- package/src/components/model-switcher.tsx +36 -0
- package/src/components/prompt-input.stories.tsx +223 -0
- package/src/components/prompt-input.tsx +190 -0
- package/src/components/prompt-suggestion.stories.tsx +143 -0
- package/src/components/prompt-suggestion.tsx +115 -0
- package/src/components/reasoning.stories.tsx +141 -0
- package/src/components/reasoning.tsx +157 -0
- package/src/components/response-stream.tsx +103 -0
- package/src/components/scroll-button.stories.tsx +101 -0
- package/src/components/scroll-button.tsx +33 -0
- package/src/components/slash-command.stories.tsx +164 -0
- package/src/components/slash-command.tsx +223 -0
- package/src/components/source.stories.tsx +125 -0
- package/src/components/source.tsx +129 -0
- package/src/components/text-shimmer.stories.tsx +88 -0
- package/src/components/text-shimmer.tsx +37 -0
- package/src/components/thinking-bar.stories.tsx +88 -0
- package/src/components/thinking-bar.tsx +50 -0
- package/src/components/tool.stories.tsx +154 -0
- package/src/components/tool.tsx +173 -0
- package/src/components/voice-input.stories.tsx +84 -0
- package/src/components/voice-input.tsx +103 -0
- package/src/elements/chat-types.ts +14 -0
- package/src/elements/chat.tsx +111 -0
- package/src/elements/compiled.css +2 -0
- package/src/elements/conversation-list.tsx +26 -0
- package/src/elements/css.ts +5 -0
- package/src/elements/default-input.tsx +53 -0
- package/src/elements/define.tsx +54 -0
- package/src/elements/kitn-chat.stories.tsx +105 -0
- package/src/elements/kitn-conversation-list.stories.tsx +177 -0
- package/src/elements/kitn-prompt-input.stories.tsx +123 -0
- package/src/elements/prompt-input.tsx +39 -0
- package/src/elements/register.ts +9 -0
- package/src/elements/styles.css +12 -0
- package/src/index.ts +128 -0
- package/src/primitives/chat-config.tsx +76 -0
- package/src/primitives/highlighter.ts +150 -0
- package/src/primitives/use-auto-resize.ts +31 -0
- package/src/primitives/use-stick-to-bottom.ts +43 -0
- package/src/primitives/use-text-stream.ts +112 -0
- package/src/primitives/use-voice-recorder.ts +50 -0
- package/src/stories/chat-panel-layout.stories.tsx +144 -0
- package/src/stories/chat-scene.tsx +570 -0
- package/src/stories/checkpoint-restore.stories.tsx +224 -0
- package/src/stories/context-usage.stories.tsx +155 -0
- package/src/stories/conversation-with-reasoning.stories.tsx +151 -0
- package/src/stories/conversation-with-sources.stories.tsx +165 -0
- package/src/stories/docs/GettingStarted.mdx +76 -0
- package/src/stories/docs/Installation.mdx +48 -0
- package/src/stories/docs/Integrations.mdx +110 -0
- package/src/stories/docs/Introduction.mdx +29 -0
- package/src/stories/docs/Theming.mdx +87 -0
- package/src/stories/docs/theme-editor/canvas.tsx +32 -0
- package/src/stories/docs/theme-editor/inspector.tsx +66 -0
- package/src/stories/docs/theme-editor/presets.test.ts +32 -0
- package/src/stories/docs/theme-editor/presets.ts +64 -0
- package/src/stories/docs/theme-editor/theme-css.test.ts +19 -0
- package/src/stories/docs/theme-editor/theme-css.ts +15 -0
- package/src/stories/docs/theme-editor/theme-editor.tsx +145 -0
- package/src/stories/docs/theme-tokens.tsx +174 -0
- package/src/stories/full-chat.stories.tsx +18 -0
- package/src/stories/message-actions.stories.tsx +167 -0
- package/src/stories/prompt-input-variants.stories.tsx +179 -0
- package/src/stories/streaming-response.stories.tsx +234 -0
- package/src/stories/theme-editor.stories.tsx +16 -0
- package/src/stories/token-reference.stories.tsx +18 -0
- package/src/types.ts +41 -0
- package/src/ui/avatar.stories.tsx +104 -0
- package/src/ui/avatar.tsx +23 -0
- package/src/ui/badge.stories.tsx +87 -0
- package/src/ui/badge.tsx +21 -0
- package/src/ui/button.stories.tsx +146 -0
- package/src/ui/button.tsx +37 -0
- package/src/ui/collapsible.tsx +14 -0
- package/src/ui/dialog.tsx +21 -0
- package/src/ui/dropdown.tsx +26 -0
- package/src/ui/hover-card.tsx +48 -0
- package/src/ui/resizable.stories.tsx +171 -0
- package/src/ui/resizable.tsx +219 -0
- package/src/ui/scroll-area.tsx +13 -0
- package/src/ui/separator.stories.tsx +82 -0
- package/src/ui/separator.tsx +10 -0
- package/src/ui/skeleton.stories.tsx +338 -0
- package/src/ui/skeleton.tsx +16 -0
- package/src/ui/textarea.tsx +21 -0
- package/src/ui/tooltip.stories.tsx +75 -0
- package/src/ui/tooltip.tsx +22 -0
- package/src/utils/cn.ts +6 -0
- package/theme.css +115 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "storybook-solidjs-vite";
|
|
2
|
+
import { fn } from "storybook/test";
|
|
3
|
+
import { createSignal } from "solid-js";
|
|
4
|
+
import { SlashCommand, type SlashCommandItem } from "./slash-command";
|
|
5
|
+
import { PromptInput, PromptInputTextarea } from "./prompt-input";
|
|
6
|
+
import { ChatConfig } from "../primitives/chat-config";
|
|
7
|
+
|
|
8
|
+
const skillCommands: SlashCommandItem[] = [
|
|
9
|
+
{ id: "caveman", label: "Caveman", description: "Ultra-compressed terse responses", category: "Skills" },
|
|
10
|
+
{ id: "detailed", label: "Detailed", description: "Thorough, comprehensive responses", category: "Skills" },
|
|
11
|
+
{ id: "eli5", label: "ELI5", description: "Explain simply, avoid jargon", category: "Skills" },
|
|
12
|
+
{ id: "socratic", label: "Socratic", description: "Ask guiding questions", category: "Skills" },
|
|
13
|
+
{ id: "concise", label: "Concise", description: "Short, direct answers", category: "Skills" },
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
const mixedCommands: SlashCommandItem[] = [
|
|
17
|
+
...skillCommands,
|
|
18
|
+
{ id: "clear", label: "Clear", description: "Clear conversation history", category: "Actions" },
|
|
19
|
+
{ id: "export", label: "Export", description: "Export conversation as markdown", category: "Actions" },
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* `SlashCommand` reads the input value from the enclosing `PromptInput`
|
|
24
|
+
* context, so every story mounts it inside a `PromptInput`. The popup opens
|
|
25
|
+
* when the input starts with `/`.
|
|
26
|
+
*/
|
|
27
|
+
function SlashDemo(props: { commands: SlashCommandItem[]; activeIds?: string[]; compact?: boolean; onSelect: (cmd: SlashCommandItem) => void }) {
|
|
28
|
+
const [value, setValue] = createSignal("/");
|
|
29
|
+
return (
|
|
30
|
+
<ChatConfig proseSize="sm">
|
|
31
|
+
<div style={{ width: "420px" }} class="bg-card rounded-lg p-4">
|
|
32
|
+
<div class="relative">
|
|
33
|
+
<PromptInput value={value()} onValueChange={setValue} onSubmit={() => setValue("")}>
|
|
34
|
+
<PromptInputTextarea placeholder="Type / for commands..." class="min-h-[36px] pt-2 pl-3" />
|
|
35
|
+
<SlashCommand
|
|
36
|
+
commands={props.commands}
|
|
37
|
+
activeIds={props.activeIds}
|
|
38
|
+
compact={props.compact}
|
|
39
|
+
onSelect={(cmd) => {
|
|
40
|
+
props.onSelect(cmd);
|
|
41
|
+
setValue("");
|
|
42
|
+
}}
|
|
43
|
+
/>
|
|
44
|
+
</PromptInput>
|
|
45
|
+
</div>
|
|
46
|
+
<p class="text-xs text-muted-foreground/40 mt-2 text-center">
|
|
47
|
+
Type <code class="bg-muted px-1 rounded">/</code> to see commands. Arrow keys + Tab/Enter to select.
|
|
48
|
+
</p>
|
|
49
|
+
</div>
|
|
50
|
+
</ChatConfig>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const meta = {
|
|
55
|
+
title: "Components/SlashCommand",
|
|
56
|
+
component: SlashCommand,
|
|
57
|
+
tags: ["autodocs"],
|
|
58
|
+
parameters: {
|
|
59
|
+
layout: "centered",
|
|
60
|
+
docs: {
|
|
61
|
+
description: {
|
|
62
|
+
component: [
|
|
63
|
+
"A keyboard-navigable command palette that pops above a `PromptInput` when the user types `/`. Filters and groups commands by category, with arrow-key navigation and Tab/Enter to select.",
|
|
64
|
+
"**When to use:** to offer slash commands or toggleable skills/modes directly from the prompt input as the user types `/name`.",
|
|
65
|
+
"**How to use:** render it as a child of `PromptInput` (it consumes that context). Pass `commands`, handle `onSelect`, and optionally pass `activeIds` to mark active toggles. Set `compact={false}` for two-line rows.",
|
|
66
|
+
"**Placement:** inside the relative-positioned `PromptInput`, absolutely anchored to the top of the textarea.",
|
|
67
|
+
].join("\n\n"),
|
|
68
|
+
},
|
|
69
|
+
controls: { exclude: ["use:eventListener"] },
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
argTypes: {
|
|
73
|
+
commands: {
|
|
74
|
+
control: "object",
|
|
75
|
+
description: "List of selectable commands ({ id, label, description?, category? }).",
|
|
76
|
+
},
|
|
77
|
+
activeIds: {
|
|
78
|
+
control: "object",
|
|
79
|
+
description: "IDs currently marked active — selecting an active command toggles it off.",
|
|
80
|
+
},
|
|
81
|
+
compact: {
|
|
82
|
+
control: "boolean",
|
|
83
|
+
description: "Single-line rows (label + description side by side). When false, two-line rows.",
|
|
84
|
+
table: { defaultValue: { summary: "true" } },
|
|
85
|
+
},
|
|
86
|
+
onSelect: {
|
|
87
|
+
action: "select",
|
|
88
|
+
description: "Fired with the chosen command when an item is selected.",
|
|
89
|
+
table: { category: "Events" },
|
|
90
|
+
},
|
|
91
|
+
class: { control: "text", description: "Additional classes on the popup container." },
|
|
92
|
+
},
|
|
93
|
+
args: {
|
|
94
|
+
commands: skillCommands,
|
|
95
|
+
compact: true,
|
|
96
|
+
activeIds: [],
|
|
97
|
+
onSelect: fn(),
|
|
98
|
+
},
|
|
99
|
+
render: (args) => (
|
|
100
|
+
<SlashDemo commands={args.commands} activeIds={args.activeIds} compact={args.compact} onSelect={args.onSelect} />
|
|
101
|
+
),
|
|
102
|
+
} satisfies Meta<typeof SlashCommand>;
|
|
103
|
+
|
|
104
|
+
export default meta;
|
|
105
|
+
type Story = StoryObj<typeof meta>;
|
|
106
|
+
|
|
107
|
+
const IMPORT = `import { SlashCommand, PromptInput, PromptInputTextarea } from '@kitnai/chat';`;
|
|
108
|
+
const src = (code: string) => ({
|
|
109
|
+
parameters: { docs: { source: { code: `${IMPORT}\n\n${code}`, language: 'tsx' } } },
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
/** Interactive playground — type `/` in the input; tweak `commands`/`compact`/`activeIds`. */
|
|
113
|
+
export const Playground: Story = {
|
|
114
|
+
...src(`<PromptInput value={value()} onValueChange={setValue} onSubmit={() => setValue('')}>
|
|
115
|
+
<PromptInputTextarea placeholder="Type / for commands..." />
|
|
116
|
+
<SlashCommand commands={commands} onSelect={(cmd) => { /* apply */ }} />
|
|
117
|
+
</PromptInput>`),
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/** Compact single-line rows (default). */
|
|
121
|
+
export const Compact: Story = {
|
|
122
|
+
args: { compact: true },
|
|
123
|
+
...src(`<SlashCommand commands={commands} onSelect={handleSelect} />`),
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
/** Two-line rows with label above description. */
|
|
127
|
+
export const Expanded: Story = {
|
|
128
|
+
args: { compact: false },
|
|
129
|
+
...src(`<SlashCommand commands={commands} compact={false} onSelect={handleSelect} />`),
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/** Commands grouped by category (Skills + Actions). */
|
|
133
|
+
export const WithCategories: Story = {
|
|
134
|
+
args: { commands: mixedCommands },
|
|
135
|
+
...src(`const commands = [
|
|
136
|
+
{ id: 'eli5', label: 'ELI5', description: 'Explain simply', category: 'Skills' },
|
|
137
|
+
{ id: 'clear', label: 'Clear', description: 'Clear conversation', category: 'Actions' },
|
|
138
|
+
];
|
|
139
|
+
|
|
140
|
+
<SlashCommand commands={commands} onSelect={handleSelect} />`),
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
/** Toggleable skills — active IDs are marked and toggle off when reselected (showcase). */
|
|
144
|
+
export const ActiveToggles: Story = {
|
|
145
|
+
render: () => {
|
|
146
|
+
const [selected, setSelected] = createSignal<string[]>(["eli5"]);
|
|
147
|
+
return (
|
|
148
|
+
<SlashDemo
|
|
149
|
+
commands={skillCommands}
|
|
150
|
+
activeIds={selected()}
|
|
151
|
+
onSelect={(cmd) =>
|
|
152
|
+
setSelected((prev) =>
|
|
153
|
+
prev.includes(cmd.id) ? prev.filter((id) => id !== cmd.id) : [...prev, cmd.id],
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
/>
|
|
157
|
+
);
|
|
158
|
+
},
|
|
159
|
+
...src(`<SlashCommand
|
|
160
|
+
commands={skillCommands}
|
|
161
|
+
activeIds={selected()}
|
|
162
|
+
onSelect={(cmd) => toggle(cmd.id)}
|
|
163
|
+
/>`),
|
|
164
|
+
};
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { createSignal, createEffect, createMemo, Show, For, on, onCleanup } from "solid-js";
|
|
2
|
+
import { cn } from "../utils/cn";
|
|
3
|
+
import { usePromptInput } from "./prompt-input";
|
|
4
|
+
|
|
5
|
+
// --- Types ---
|
|
6
|
+
|
|
7
|
+
export interface SlashCommandItem {
|
|
8
|
+
id: string;
|
|
9
|
+
label: string;
|
|
10
|
+
description?: string;
|
|
11
|
+
category?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface SlashCommandProps {
|
|
15
|
+
commands: SlashCommandItem[];
|
|
16
|
+
activeIds?: string[]; // currently active command IDs — selecting again removes
|
|
17
|
+
onSelect: (command: SlashCommandItem) => void;
|
|
18
|
+
compact?: boolean; // single line: label + description side by side (default: true)
|
|
19
|
+
class?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// --- Component ---
|
|
23
|
+
|
|
24
|
+
function SlashCommand(props: SlashCommandProps) {
|
|
25
|
+
const ctx = usePromptInput();
|
|
26
|
+
const [open, setOpen] = createSignal(false);
|
|
27
|
+
const [selectedIndex, setSelectedIndex] = createSignal(0);
|
|
28
|
+
const [query, setQuery] = createSignal("");
|
|
29
|
+
const isCompact = props.compact !== false; // default true
|
|
30
|
+
let listRef: HTMLDivElement | undefined;
|
|
31
|
+
|
|
32
|
+
// Detect slash at the start of input
|
|
33
|
+
const slashMatch = createMemo(() => {
|
|
34
|
+
const val = ctx.value();
|
|
35
|
+
const match = val.match(/^\/(\S*)$/);
|
|
36
|
+
return match ? match[1] : null;
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// Filter and sort commands alphabetically
|
|
40
|
+
const filtered = createMemo(() => {
|
|
41
|
+
const q = slashMatch();
|
|
42
|
+
if (q === null) return [];
|
|
43
|
+
const items = q === ""
|
|
44
|
+
? [...props.commands]
|
|
45
|
+
: props.commands.filter(
|
|
46
|
+
(cmd) =>
|
|
47
|
+
cmd.label.toLowerCase().includes(q.toLowerCase()) ||
|
|
48
|
+
(cmd.description?.toLowerCase().includes(q.toLowerCase()) ?? false),
|
|
49
|
+
);
|
|
50
|
+
return items.sort((a, b) => a.label.localeCompare(b.label));
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// Group by category
|
|
54
|
+
const grouped = createMemo(() => {
|
|
55
|
+
const items = filtered();
|
|
56
|
+
const groups = new Map<string, SlashCommandItem[]>();
|
|
57
|
+
for (const item of items) {
|
|
58
|
+
const cat = item.category ?? "";
|
|
59
|
+
if (!groups.has(cat)) groups.set(cat, []);
|
|
60
|
+
groups.get(cat)!.push(item);
|
|
61
|
+
}
|
|
62
|
+
return groups;
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// Flat list for keyboard navigation
|
|
66
|
+
const flatList = createMemo(() => {
|
|
67
|
+
const items: SlashCommandItem[] = [];
|
|
68
|
+
for (const group of grouped().values()) {
|
|
69
|
+
items.push(...group);
|
|
70
|
+
}
|
|
71
|
+
return items;
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// Open/close based on slash detection
|
|
75
|
+
createEffect(
|
|
76
|
+
on(slashMatch, (match) => {
|
|
77
|
+
if (match !== null) {
|
|
78
|
+
setOpen(true);
|
|
79
|
+
setQuery(match);
|
|
80
|
+
setSelectedIndex(0);
|
|
81
|
+
} else {
|
|
82
|
+
setOpen(false);
|
|
83
|
+
}
|
|
84
|
+
}),
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
// Keep selected index in bounds
|
|
88
|
+
createEffect(() => {
|
|
89
|
+
const max = flatList().length;
|
|
90
|
+
if (selectedIndex() >= max) setSelectedIndex(Math.max(0, max - 1));
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// Scroll selected item into view
|
|
94
|
+
createEffect(() => {
|
|
95
|
+
const idx = selectedIndex();
|
|
96
|
+
if (!listRef) return;
|
|
97
|
+
const el = listRef.querySelector(`[data-index="${idx}"]`) as HTMLElement;
|
|
98
|
+
el?.scrollIntoView({ block: "nearest" });
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
function selectItem(item: SlashCommandItem) {
|
|
102
|
+
ctx.setValue("");
|
|
103
|
+
setOpen(false);
|
|
104
|
+
props.onSelect(item);
|
|
105
|
+
// Refocus textarea
|
|
106
|
+
setTimeout(() => ctx.textareaRef?.focus(), 0);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function handleKeyDown(e: KeyboardEvent) {
|
|
110
|
+
if (!open()) return;
|
|
111
|
+
|
|
112
|
+
const list = flatList();
|
|
113
|
+
if (list.length === 0) return;
|
|
114
|
+
|
|
115
|
+
switch (e.key) {
|
|
116
|
+
case "ArrowDown":
|
|
117
|
+
e.preventDefault();
|
|
118
|
+
setSelectedIndex((i) => (i + 1) % list.length);
|
|
119
|
+
break;
|
|
120
|
+
case "ArrowUp":
|
|
121
|
+
e.preventDefault();
|
|
122
|
+
setSelectedIndex((i) => (i - 1 + list.length) % list.length);
|
|
123
|
+
break;
|
|
124
|
+
case "Tab":
|
|
125
|
+
case "Enter":
|
|
126
|
+
e.preventDefault();
|
|
127
|
+
e.stopPropagation();
|
|
128
|
+
selectItem(list[selectedIndex()]);
|
|
129
|
+
break;
|
|
130
|
+
case "Escape":
|
|
131
|
+
e.preventDefault();
|
|
132
|
+
ctx.setValue("");
|
|
133
|
+
setOpen(false);
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Attach keyboard listener to textarea
|
|
139
|
+
createEffect(() => {
|
|
140
|
+
const textarea = ctx.textareaRef;
|
|
141
|
+
if (!textarea) return;
|
|
142
|
+
|
|
143
|
+
textarea.addEventListener("keydown", handleKeyDown, true);
|
|
144
|
+
onCleanup(() => textarea.removeEventListener("keydown", handleKeyDown, true));
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
let flatIndex = 0;
|
|
148
|
+
|
|
149
|
+
return (
|
|
150
|
+
<Show when={open() && flatList().length > 0}>
|
|
151
|
+
<div
|
|
152
|
+
class={cn(
|
|
153
|
+
"absolute bottom-full left-0 right-0 mb-1 z-50 bg-card rounded-lg shadow-lg overflow-hidden",
|
|
154
|
+
props.class,
|
|
155
|
+
)}
|
|
156
|
+
>
|
|
157
|
+
<div ref={listRef} class="max-h-56 overflow-y-auto py-1">
|
|
158
|
+
{(() => {
|
|
159
|
+
flatIndex = 0;
|
|
160
|
+
return null;
|
|
161
|
+
})()}
|
|
162
|
+
<For each={[...grouped().entries()]}>
|
|
163
|
+
{([category, items]) => (
|
|
164
|
+
<>
|
|
165
|
+
<Show when={category}>
|
|
166
|
+
<div class="px-3 pt-2 pb-1 text-[10px] font-semibold text-muted-foreground/60 uppercase tracking-wide">
|
|
167
|
+
{category}
|
|
168
|
+
</div>
|
|
169
|
+
</Show>
|
|
170
|
+
<For each={items}>
|
|
171
|
+
{(item) => {
|
|
172
|
+
const idx = flatIndex++;
|
|
173
|
+
const isActive = () => props.activeIds?.includes(item.id) ?? false;
|
|
174
|
+
return (
|
|
175
|
+
<button
|
|
176
|
+
data-index={idx}
|
|
177
|
+
class={cn(
|
|
178
|
+
"w-full flex items-center gap-2 px-3 text-left transition-colors",
|
|
179
|
+
isCompact ? "py-1" : "py-1.5",
|
|
180
|
+
selectedIndex() === idx
|
|
181
|
+
? "bg-muted/50 text-foreground"
|
|
182
|
+
: "text-foreground/80 hover:bg-muted/30",
|
|
183
|
+
)}
|
|
184
|
+
onMouseEnter={() => setSelectedIndex(idx)}
|
|
185
|
+
onClick={() => selectItem(item)}
|
|
186
|
+
>
|
|
187
|
+
<Show when={isCompact} fallback={
|
|
188
|
+
<div class="flex-1 min-w-0">
|
|
189
|
+
<div class="text-xs flex items-center gap-1.5">
|
|
190
|
+
{item.label}
|
|
191
|
+
<Show when={isActive()}>
|
|
192
|
+
<span class="text-[10px] text-violet-400">active</span>
|
|
193
|
+
</Show>
|
|
194
|
+
</div>
|
|
195
|
+
<Show when={item.description}>
|
|
196
|
+
<div class="text-xs text-muted-foreground/50 truncate">
|
|
197
|
+
{item.description}
|
|
198
|
+
</div>
|
|
199
|
+
</Show>
|
|
200
|
+
</div>
|
|
201
|
+
}>
|
|
202
|
+
<Show when={isActive()}>
|
|
203
|
+
<span class="w-1 h-1 rounded-full bg-violet-400 flex-shrink-0" />
|
|
204
|
+
</Show>
|
|
205
|
+
<span class={cn("text-xs flex-shrink-0", isActive() && "text-violet-400")}>{item.label}</span>
|
|
206
|
+
<Show when={item.description}>
|
|
207
|
+
<span class="text-xs text-muted-foreground/40 truncate">{item.description}</span>
|
|
208
|
+
</Show>
|
|
209
|
+
</Show>
|
|
210
|
+
</button>
|
|
211
|
+
);
|
|
212
|
+
}}
|
|
213
|
+
</For>
|
|
214
|
+
</>
|
|
215
|
+
)}
|
|
216
|
+
</For>
|
|
217
|
+
</div>
|
|
218
|
+
</div>
|
|
219
|
+
</Show>
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export { SlashCommand };
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from 'storybook-solidjs-vite';
|
|
2
|
+
import { Source, SourceTrigger, SourceContent, SourceList } from './source';
|
|
3
|
+
|
|
4
|
+
const meta = {
|
|
5
|
+
title: 'Components/Source',
|
|
6
|
+
component: Source,
|
|
7
|
+
tags: ['autodocs'],
|
|
8
|
+
parameters: {
|
|
9
|
+
layout: 'padded',
|
|
10
|
+
docs: {
|
|
11
|
+
description: {
|
|
12
|
+
component: [
|
|
13
|
+
'An inline citation chip with a hover-card preview. Compose `Source` (root, holds the `href`) with `SourceTrigger` (the clickable pill) and `SourceContent` (the hover preview). `SourceList` lays out several side by side.',
|
|
14
|
+
'**When to use:** to cite a referenced web source inline in an assistant message — show a compact domain/number pill that previews the title and description on hover.',
|
|
15
|
+
'**How to use:** wrap `SourceTrigger` and `SourceContent` in `Source` with the target `href`. Use `label` for custom text or a citation number, `showFavicon` for the site icon, and pass `title`/`description` to `SourceContent`.',
|
|
16
|
+
'**Placement:** within message body text as citations, or grouped under a message in a `SourceList`.',
|
|
17
|
+
].join('\n\n'),
|
|
18
|
+
},
|
|
19
|
+
controls: { exclude: ['use:eventListener'] },
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
argTypes: {
|
|
23
|
+
href: {
|
|
24
|
+
control: 'text',
|
|
25
|
+
description: 'Target URL of the source. The domain is derived from it for the default label.',
|
|
26
|
+
},
|
|
27
|
+
children: { control: false, description: 'Trigger and content composition.' },
|
|
28
|
+
},
|
|
29
|
+
args: {
|
|
30
|
+
href: 'https://solidjs.com/docs/basic-reactivity/signals',
|
|
31
|
+
},
|
|
32
|
+
render: (args) => (
|
|
33
|
+
<Source href={args.href}>
|
|
34
|
+
<SourceTrigger label="solidjs.com" />
|
|
35
|
+
<SourceContent
|
|
36
|
+
title="Signals - SolidJS"
|
|
37
|
+
description="Signals are the most basic reactive primitive. They track a single value that changes over time."
|
|
38
|
+
/>
|
|
39
|
+
</Source>
|
|
40
|
+
),
|
|
41
|
+
} satisfies Meta<typeof Source>;
|
|
42
|
+
|
|
43
|
+
export default meta;
|
|
44
|
+
type Story = StoryObj<typeof meta>;
|
|
45
|
+
|
|
46
|
+
const IMPORT = `import { Source, SourceTrigger, SourceContent, SourceList } from '@kitnai/chat';`;
|
|
47
|
+
const src = (code: string) => ({
|
|
48
|
+
parameters: { docs: { source: { code: `${IMPORT}\n\n${code}`, language: 'tsx' } } },
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
/** Interactive playground — set the `href` and hover the chip to preview. */
|
|
52
|
+
export const Playground: Story = {
|
|
53
|
+
...src(`<Source href="https://solidjs.com/docs/basic-reactivity/signals">
|
|
54
|
+
<SourceTrigger label="solidjs.com" />
|
|
55
|
+
<SourceContent
|
|
56
|
+
title="Signals - SolidJS"
|
|
57
|
+
description="The most basic reactive primitive — a single value that changes over time."
|
|
58
|
+
/>
|
|
59
|
+
</Source>`),
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** Trigger shows the site favicon and derives its label from the domain. */
|
|
63
|
+
export const WithFavicon: Story = {
|
|
64
|
+
render: () => (
|
|
65
|
+
<Source href="https://developer.mozilla.org/en-US/docs/Web/JavaScript">
|
|
66
|
+
<SourceTrigger showFavicon />
|
|
67
|
+
<SourceContent
|
|
68
|
+
title="JavaScript | MDN"
|
|
69
|
+
description="JavaScript (JS) is a lightweight interpreted programming language with first-class functions."
|
|
70
|
+
/>
|
|
71
|
+
</Source>
|
|
72
|
+
),
|
|
73
|
+
...src(`<Source href="https://developer.mozilla.org/en-US/docs/Web/JavaScript">
|
|
74
|
+
<SourceTrigger showFavicon />
|
|
75
|
+
<SourceContent title="JavaScript | MDN" description="A lightweight interpreted language." />
|
|
76
|
+
</Source>`),
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/** A numbered citation chip. */
|
|
80
|
+
export const NumberedCitation: Story = {
|
|
81
|
+
render: () => (
|
|
82
|
+
<Source href="https://example.com/article">
|
|
83
|
+
<SourceTrigger label={1} />
|
|
84
|
+
<SourceContent
|
|
85
|
+
title="Example Article"
|
|
86
|
+
description="This is a sample article used as a citation reference."
|
|
87
|
+
/>
|
|
88
|
+
</Source>
|
|
89
|
+
),
|
|
90
|
+
...src(`<Source href="https://example.com/article">
|
|
91
|
+
<SourceTrigger label={1} />
|
|
92
|
+
<SourceContent title="Example Article" description="A sample citation reference." />
|
|
93
|
+
</Source>`),
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/** Several sources laid out together with `SourceList` (showcase). */
|
|
97
|
+
export const SourceListExample: Story = {
|
|
98
|
+
name: 'Source list',
|
|
99
|
+
render: () => (
|
|
100
|
+
<SourceList>
|
|
101
|
+
<Source href="https://solidjs.com">
|
|
102
|
+
<SourceTrigger showFavicon />
|
|
103
|
+
<SourceContent title="SolidJS" description="Simple and performant reactivity for building user interfaces." />
|
|
104
|
+
</Source>
|
|
105
|
+
<Source href="https://developer.mozilla.org">
|
|
106
|
+
<SourceTrigger showFavicon />
|
|
107
|
+
<SourceContent title="MDN Web Docs" description="Resources for developers, by developers." />
|
|
108
|
+
</Source>
|
|
109
|
+
<Source href="https://tailwindcss.com">
|
|
110
|
+
<SourceTrigger showFavicon />
|
|
111
|
+
<SourceContent title="Tailwind CSS" description="A utility-first CSS framework." />
|
|
112
|
+
</Source>
|
|
113
|
+
</SourceList>
|
|
114
|
+
),
|
|
115
|
+
...src(`<SourceList>
|
|
116
|
+
<Source href="https://solidjs.com">
|
|
117
|
+
<SourceTrigger showFavicon />
|
|
118
|
+
<SourceContent title="SolidJS" description="Simple and performant reactivity." />
|
|
119
|
+
</Source>
|
|
120
|
+
<Source href="https://tailwindcss.com">
|
|
121
|
+
<SourceTrigger showFavicon />
|
|
122
|
+
<SourceContent title="Tailwind CSS" description="A utility-first CSS framework." />
|
|
123
|
+
</Source>
|
|
124
|
+
</SourceList>`),
|
|
125
|
+
};
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { type JSX, createContext, useContext, Show, splitProps } from 'solid-js';
|
|
2
|
+
import { cn } from '../utils/cn';
|
|
3
|
+
import { HoverCardRoot, HoverCardTrigger, HoverCardContent } from '../ui/hover-card';
|
|
4
|
+
|
|
5
|
+
// --- Context ---
|
|
6
|
+
|
|
7
|
+
interface SourceContextValue {
|
|
8
|
+
href: string;
|
|
9
|
+
domain: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const SourceContext = createContext<SourceContextValue>();
|
|
13
|
+
|
|
14
|
+
function useSourceContext() {
|
|
15
|
+
const ctx = useContext(SourceContext);
|
|
16
|
+
if (!ctx) throw new Error('Source.* must be used inside <Source>');
|
|
17
|
+
return ctx;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// --- Source (Root) ---
|
|
21
|
+
|
|
22
|
+
export interface SourceProps {
|
|
23
|
+
href: string;
|
|
24
|
+
children: JSX.Element;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function Source(props: SourceProps) {
|
|
28
|
+
const domain = () => {
|
|
29
|
+
try {
|
|
30
|
+
return new URL(props.href).hostname;
|
|
31
|
+
} catch {
|
|
32
|
+
return props.href.split('/').pop() || props.href;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<SourceContext.Provider value={{ get href() { return props.href; }, get domain() { return domain(); } }}>
|
|
38
|
+
<HoverCardRoot openDelay={150} closeDelay={0}>
|
|
39
|
+
{props.children}
|
|
40
|
+
</HoverCardRoot>
|
|
41
|
+
</SourceContext.Provider>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// --- SourceTrigger ---
|
|
46
|
+
|
|
47
|
+
export interface SourceTriggerProps {
|
|
48
|
+
label?: string | number;
|
|
49
|
+
showFavicon?: boolean;
|
|
50
|
+
class?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function SourceTrigger(props: SourceTriggerProps) {
|
|
54
|
+
const ctx = useSourceContext();
|
|
55
|
+
const labelToShow = () => props.label ?? ctx.domain.replace('www.', '');
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<HoverCardTrigger>
|
|
59
|
+
<a
|
|
60
|
+
href={ctx.href}
|
|
61
|
+
target="_blank"
|
|
62
|
+
rel="noopener noreferrer"
|
|
63
|
+
class={cn(
|
|
64
|
+
'bg-muted text-muted-foreground hover:bg-muted-foreground/30 hover:text-primary inline-flex h-5 max-w-32 items-center gap-1 overflow-hidden rounded-full py-0 text-xs no-underline transition-colors duration-150',
|
|
65
|
+
props.showFavicon ? 'pr-2 pl-1' : 'px-2',
|
|
66
|
+
props.class
|
|
67
|
+
)}
|
|
68
|
+
>
|
|
69
|
+
<Show when={props.showFavicon}>
|
|
70
|
+
<img
|
|
71
|
+
src={`https://www.google.com/s2/favicons?sz=64&domain_url=${encodeURIComponent(ctx.href)}`}
|
|
72
|
+
alt="favicon"
|
|
73
|
+
width={14}
|
|
74
|
+
height={14}
|
|
75
|
+
class="size-3.5 rounded-full"
|
|
76
|
+
/>
|
|
77
|
+
</Show>
|
|
78
|
+
<span class="truncate tabular-nums text-center font-normal">{labelToShow()}</span>
|
|
79
|
+
</a>
|
|
80
|
+
</HoverCardTrigger>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// --- SourceContent ---
|
|
85
|
+
|
|
86
|
+
export interface SourceContentProps {
|
|
87
|
+
title: string;
|
|
88
|
+
description: string;
|
|
89
|
+
class?: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function SourceContent(props: SourceContentProps) {
|
|
93
|
+
const ctx = useSourceContext();
|
|
94
|
+
return (
|
|
95
|
+
<HoverCardContent class={cn('w-80 p-0 shadow-xs', props.class)}>
|
|
96
|
+
<a
|
|
97
|
+
href={ctx.href}
|
|
98
|
+
target="_blank"
|
|
99
|
+
rel="noopener noreferrer"
|
|
100
|
+
class="flex flex-col gap-2 p-3"
|
|
101
|
+
>
|
|
102
|
+
<div class="flex items-center gap-1.5">
|
|
103
|
+
<img
|
|
104
|
+
src={`https://www.google.com/s2/favicons?sz=64&domain_url=${encodeURIComponent(ctx.href)}`}
|
|
105
|
+
alt="favicon"
|
|
106
|
+
class="size-4 rounded-full"
|
|
107
|
+
width={16}
|
|
108
|
+
height={16}
|
|
109
|
+
/>
|
|
110
|
+
<div class="text-primary truncate text-sm">
|
|
111
|
+
{ctx.domain.replace('www.', '')}
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
<div class="line-clamp-2 text-sm font-medium">{props.title}</div>
|
|
115
|
+
<div class="text-muted-foreground line-clamp-2 text-sm">
|
|
116
|
+
{props.description}
|
|
117
|
+
</div>
|
|
118
|
+
</a>
|
|
119
|
+
</HoverCardContent>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// --- SourceList (convenience) ---
|
|
124
|
+
|
|
125
|
+
function SourceList(props: { children: JSX.Element; class?: string }) {
|
|
126
|
+
return <div class={cn('flex flex-wrap gap-1.5 mt-3', props.class)}>{props.children}</div>;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export { Source, SourceTrigger, SourceContent, SourceList };
|