iris-chatbot 0.2.4

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 (66) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +49 -0
  3. package/bin/iris.mjs +267 -0
  4. package/package.json +61 -0
  5. package/template/LICENSE +21 -0
  6. package/template/README.md +49 -0
  7. package/template/eslint.config.mjs +18 -0
  8. package/template/next.config.ts +7 -0
  9. package/template/package-lock.json +9193 -0
  10. package/template/package.json +46 -0
  11. package/template/postcss.config.mjs +7 -0
  12. package/template/public/file.svg +1 -0
  13. package/template/public/globe.svg +1 -0
  14. package/template/public/next.svg +1 -0
  15. package/template/public/vercel.svg +1 -0
  16. package/template/public/window.svg +1 -0
  17. package/template/src/app/api/chat/route.ts +2445 -0
  18. package/template/src/app/api/connections/models/route.ts +255 -0
  19. package/template/src/app/api/connections/test/route.ts +124 -0
  20. package/template/src/app/api/local-sync/route.ts +74 -0
  21. package/template/src/app/api/tool-approval/route.ts +47 -0
  22. package/template/src/app/favicon.ico +0 -0
  23. package/template/src/app/globals.css +808 -0
  24. package/template/src/app/layout.tsx +74 -0
  25. package/template/src/app/page.tsx +444 -0
  26. package/template/src/components/ChatView.tsx +1537 -0
  27. package/template/src/components/Composer.tsx +160 -0
  28. package/template/src/components/MapView.tsx +244 -0
  29. package/template/src/components/MessageCard.tsx +955 -0
  30. package/template/src/components/SearchModal.tsx +72 -0
  31. package/template/src/components/SettingsModal.tsx +1257 -0
  32. package/template/src/components/Sidebar.tsx +153 -0
  33. package/template/src/components/TopBar.tsx +164 -0
  34. package/template/src/lib/connections.ts +275 -0
  35. package/template/src/lib/data.ts +324 -0
  36. package/template/src/lib/db.ts +49 -0
  37. package/template/src/lib/hooks.ts +76 -0
  38. package/template/src/lib/local-sync.ts +192 -0
  39. package/template/src/lib/memory.ts +695 -0
  40. package/template/src/lib/model-presets.ts +251 -0
  41. package/template/src/lib/store.ts +36 -0
  42. package/template/src/lib/tooling/approvals.ts +78 -0
  43. package/template/src/lib/tooling/providers/anthropic.ts +155 -0
  44. package/template/src/lib/tooling/providers/ollama.ts +73 -0
  45. package/template/src/lib/tooling/providers/openai.ts +267 -0
  46. package/template/src/lib/tooling/providers/openai_compatible.ts +16 -0
  47. package/template/src/lib/tooling/providers/types.ts +44 -0
  48. package/template/src/lib/tooling/registry.ts +103 -0
  49. package/template/src/lib/tooling/runtime.ts +189 -0
  50. package/template/src/lib/tooling/safety.ts +165 -0
  51. package/template/src/lib/tooling/tools/apps.ts +108 -0
  52. package/template/src/lib/tooling/tools/apps_plus.ts +153 -0
  53. package/template/src/lib/tooling/tools/communication.ts +883 -0
  54. package/template/src/lib/tooling/tools/files.ts +395 -0
  55. package/template/src/lib/tooling/tools/music.ts +988 -0
  56. package/template/src/lib/tooling/tools/notes.ts +461 -0
  57. package/template/src/lib/tooling/tools/notes_plus.ts +294 -0
  58. package/template/src/lib/tooling/tools/numbers.ts +175 -0
  59. package/template/src/lib/tooling/tools/schedule.ts +579 -0
  60. package/template/src/lib/tooling/tools/system.ts +142 -0
  61. package/template/src/lib/tooling/tools/web.ts +212 -0
  62. package/template/src/lib/tooling/tools/workflow.ts +218 -0
  63. package/template/src/lib/tooling/types.ts +27 -0
  64. package/template/src/lib/types.ts +309 -0
  65. package/template/src/lib/utils.ts +108 -0
  66. package/template/tsconfig.json +34 -0
@@ -0,0 +1,160 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
4
+ import { ArrowUp, Loader2, Mic, Square } from "lucide-react";
5
+
6
+ function normalizePastedText(input: string): string {
7
+ return input
8
+ .replace(/\r\n?/g, "\n")
9
+ .replace(/\u200B/g, "")
10
+ .replace(/[ \t]+\n/g, "\n")
11
+ .replace(/\n{3,}/g, "\n\n")
12
+ .replace(/\n+$/g, "");
13
+ }
14
+
15
+ export default function Composer({
16
+ value,
17
+ onChange,
18
+ onSend,
19
+ onStop,
20
+ isStreaming,
21
+ onMicToggle,
22
+ micState = "idle",
23
+ micDisabled = false,
24
+ }: {
25
+ value: string;
26
+ onChange: (value: string) => void;
27
+ onSend: () => void;
28
+ onStop: () => void;
29
+ isStreaming: boolean;
30
+ onMicToggle?: () => void;
31
+ micState?: "idle" | "listening" | "processing";
32
+ micDisabled?: boolean;
33
+ }) {
34
+ const textareaRef = useRef<HTMLTextAreaElement | null>(null);
35
+ const minRowsRef = useRef(1);
36
+ const maxRowsRef = useRef(8);
37
+ const hasText = value.trim().length > 0;
38
+ const micButtonTitle =
39
+ micState === "listening"
40
+ ? "Stop dictation"
41
+ : micState === "processing"
42
+ ? "Processing dictation"
43
+ : "Start dictation";
44
+ const insertAtCursor = useCallback(
45
+ (insertText: string) => {
46
+ const element = textareaRef.current;
47
+ if (!element) {
48
+ onChange(value + insertText);
49
+ return;
50
+ }
51
+ const start = element.selectionStart ?? value.length;
52
+ const end = element.selectionEnd ?? start;
53
+ const nextValue = `${value.slice(0, start)}${insertText}${value.slice(end)}`;
54
+ onChange(nextValue);
55
+ const nextCursor = start + insertText.length;
56
+ requestAnimationFrame(() => {
57
+ if (!textareaRef.current) {
58
+ return;
59
+ }
60
+ textareaRef.current.focus();
61
+ textareaRef.current.setSelectionRange(nextCursor, nextCursor);
62
+ });
63
+ },
64
+ [onChange, value],
65
+ );
66
+
67
+ const resizeTextarea = useCallback(() => {
68
+ const element = textareaRef.current;
69
+ if (!element) {
70
+ return;
71
+ }
72
+
73
+ const style = window.getComputedStyle(element);
74
+ const lineHeight = Number.parseFloat(style.lineHeight) || 22;
75
+ const paddingTop = Number.parseFloat(style.paddingTop) || 0;
76
+ const paddingBottom = Number.parseFloat(style.paddingBottom) || 0;
77
+
78
+ const minHeight = lineHeight * minRowsRef.current + paddingTop + paddingBottom;
79
+ const maxHeight = lineHeight * maxRowsRef.current + paddingTop + paddingBottom;
80
+
81
+ // Use scrollHeight so wrapped lines (no explicit newline) are included; min 1 line, max 8
82
+ element.style.height = "0px";
83
+ const scrollHeight = element.scrollHeight;
84
+ const nextHeight = Math.min(Math.max(scrollHeight, minHeight), maxHeight);
85
+ element.style.height = `${nextHeight}px`;
86
+ element.style.overflowY = scrollHeight > maxHeight ? "auto" : "hidden";
87
+ }, []);
88
+
89
+ useLayoutEffect(() => {
90
+ resizeTextarea();
91
+ }, [value, resizeTextarea]);
92
+
93
+ useEffect(() => {
94
+ const handleResize = () => {
95
+ resizeTextarea();
96
+ };
97
+ window.addEventListener("resize", handleResize);
98
+ return () => {
99
+ window.removeEventListener("resize", handleResize);
100
+ };
101
+ }, [resizeTextarea]);
102
+
103
+ return (
104
+ <div className="composer flex items-end gap-3">
105
+ <textarea
106
+ ref={textareaRef}
107
+ rows={1}
108
+ className="composer-textarea flex-1 text-base text-[var(--text-primary)]"
109
+ value={value}
110
+ onChange={(event) => onChange(event.target.value)}
111
+ onPaste={(event) => {
112
+ const clipboardText = event.clipboardData.getData("text/plain");
113
+ if (!clipboardText) {
114
+ return;
115
+ }
116
+ event.preventDefault();
117
+ insertAtCursor(normalizePastedText(clipboardText));
118
+ }}
119
+ placeholder="Ask anything"
120
+ onKeyDown={(event) => {
121
+ if (event.key === "Enter" && !event.shiftKey) {
122
+ event.preventDefault();
123
+ if (!isStreaming) onSend();
124
+ }
125
+ }}
126
+ />
127
+ {onMicToggle ? (
128
+ <button
129
+ onClick={onMicToggle}
130
+ className={`send-button mic-button shrink-0 ${micState !== "idle" ? "active" : ""}`}
131
+ disabled={micDisabled || micState === "processing"}
132
+ title={micButtonTitle}
133
+ aria-label={micButtonTitle}
134
+ >
135
+ {micState === "processing" ? (
136
+ <Loader2 className="h-4 w-4 animate-spin" />
137
+ ) : (
138
+ <Mic className="h-4 w-4" />
139
+ )}
140
+ </button>
141
+ ) : null}
142
+ {isStreaming ? (
143
+ <button
144
+ onClick={onStop}
145
+ className="flex items-center gap-2 rounded-full border border-[var(--border)] bg-[var(--panel-2)] px-3 py-2 text-xs text-[var(--text-secondary)]"
146
+ >
147
+ <Square className="h-3 w-3" />
148
+ Stop
149
+ </button>
150
+ ) : (
151
+ <button
152
+ onClick={onSend}
153
+ className={`send-button shrink-0 ${hasText ? "active" : ""}`}
154
+ >
155
+ <ArrowUp className="h-4 w-4" />
156
+ </button>
157
+ )}
158
+ </div>
159
+ );
160
+ }
@@ -0,0 +1,244 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useMemo, useRef } from "react";
4
+ import dagre from "dagre";
5
+ import ReactFlow, {
6
+ Background,
7
+ type Edge,
8
+ type Node,
9
+ type ReactFlowInstance,
10
+ } from "reactflow";
11
+ import type { MessageNode, Thread } from "../lib/types";
12
+ import { buildPathIds, splitContentAndSources } from "../lib/utils";
13
+ import { createThreadFromMessage } from "../lib/data";
14
+ import { useUIStore } from "../lib/store";
15
+
16
+ const NODE_WIDTH = 220;
17
+ const NODE_HEIGHT = 80;
18
+
19
+ const graph = new dagre.graphlib.Graph();
20
+ graph.setDefaultEdgeLabel(() => ({}));
21
+
22
+ function layoutElements(nodes: Node[], edges: Edge[]) {
23
+ graph.setGraph({ rankdir: "TB", nodesep: 24, ranksep: 60 });
24
+
25
+ nodes.forEach((node) => {
26
+ graph.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT });
27
+ });
28
+
29
+ edges.forEach((edge) => {
30
+ graph.setEdge(edge.source, edge.target);
31
+ });
32
+
33
+ dagre.layout(graph);
34
+
35
+ const layoutedNodes = nodes.map((node) => {
36
+ const { x, y } = graph.node(node.id);
37
+ return {
38
+ ...node,
39
+ position: { x: x - NODE_WIDTH / 2, y: y - NODE_HEIGHT / 2 },
40
+ };
41
+ });
42
+
43
+ return { nodes: layoutedNodes, edges };
44
+ }
45
+
46
+ export default function MapView({
47
+ threads,
48
+ activeThreadId,
49
+ messages,
50
+ onSelectThread,
51
+ onNavigateToChat,
52
+ }: {
53
+ threads: Thread[];
54
+ activeThreadId: string | null;
55
+ messages: MessageNode[];
56
+ onSelectThread: (id: string) => void;
57
+ onNavigateToChat: () => void;
58
+ }) {
59
+ const flowRef = useRef<ReactFlowInstance | null>(null);
60
+ const setFocusedMessageId = useUIStore((state) => state.setFocusedMessageId);
61
+ const activeThread = threads.find((thread) => thread.id === activeThreadId) || null;
62
+ const conversationThreads = useMemo(
63
+ () =>
64
+ activeThread
65
+ ? threads.filter(
66
+ (thread) => thread.conversationId === activeThread.conversationId
67
+ )
68
+ : [],
69
+ [threads, activeThread]
70
+ );
71
+ const messageMap = useMemo(
72
+ () => new Map(messages.map((message) => [message.id, message])),
73
+ [messages]
74
+ );
75
+
76
+ const visibleMessageIds = useMemo(() => {
77
+ if (!activeThread) return new Set<string>();
78
+ const ids = new Set<string>();
79
+ conversationThreads.forEach((thread) => {
80
+ buildPathIds(thread.headMessageId, messageMap).forEach((id) => ids.add(id));
81
+ });
82
+ return ids;
83
+ }, [activeThread, conversationThreads, messageMap]);
84
+
85
+ const visibleMessages = useMemo(
86
+ () => messages.filter((message) => visibleMessageIds.has(message.id)),
87
+ [messages, visibleMessageIds]
88
+ );
89
+
90
+ const firstAssistantChildByUserId = useMemo(() => {
91
+ const map = new Map<string, MessageNode>();
92
+ visibleMessages.forEach((message) => {
93
+ if (message.role !== "assistant" || !message.parentId) return;
94
+ const parent = messageMap.get(message.parentId);
95
+ if (!parent || parent.role !== "user") return;
96
+ const current = map.get(parent.id);
97
+ if (!current || message.createdAt < current.createdAt) {
98
+ map.set(parent.id, message);
99
+ }
100
+ });
101
+ return map;
102
+ }, [visibleMessages, messageMap]);
103
+
104
+ const activePathIds = useMemo(
105
+ () => buildPathIds(activeThread?.headMessageId || null, messageMap),
106
+ [activeThread?.headMessageId, messageMap]
107
+ );
108
+
109
+ const threadPathIds = useMemo(() => {
110
+ return conversationThreads.map((thread) => {
111
+ // Precompute path once per thread for click-target matching.
112
+ const pathIds = buildPathIds(thread.headMessageId, messageMap);
113
+ return {
114
+ pathIds,
115
+ thread,
116
+ depth: pathIds.size,
117
+ };
118
+ });
119
+ }, [conversationThreads, messageMap]);
120
+
121
+ const { nodes, edges } = useMemo(() => {
122
+ const nodes: Node[] = visibleMessages.map((message) => {
123
+ const preview = splitContentAndSources(message.content).content.trim().slice(0, 60) || "(empty)";
124
+ const isActive = activePathIds.has(message.id);
125
+ const roleClass =
126
+ message.role === "user"
127
+ ? "node-user"
128
+ : message.role === "assistant"
129
+ ? "node-assistant"
130
+ : "node-system";
131
+ return {
132
+ id: message.id,
133
+ data: {
134
+ label: (
135
+ <div>
136
+ <div className="text-[10px] uppercase tracking-[0.2em] text-[var(--text-muted)]">
137
+ {message.role}
138
+ </div>
139
+ <div className="mt-1 text-xs text-[var(--text-primary)]">
140
+ {preview}
141
+ </div>
142
+ </div>
143
+ ),
144
+ },
145
+ position: { x: 0, y: 0 },
146
+ className: `node-base ${roleClass} ${isActive ? "node-active" : ""}`,
147
+ };
148
+ });
149
+
150
+ const edges: Edge[] = visibleMessages
151
+ .filter((message) => message.parentId)
152
+ .map((message) => ({
153
+ id: `${message.parentId}-${message.id}`,
154
+ source: message.parentId as string,
155
+ target: message.id,
156
+ style: {
157
+ stroke: activePathIds.has(message.id)
158
+ ? "var(--accent)"
159
+ : "var(--border-strong)",
160
+ strokeWidth: 1.4,
161
+ },
162
+ }));
163
+
164
+ return layoutElements(nodes, edges);
165
+ }, [visibleMessages, activePathIds]);
166
+
167
+ const refitView = useCallback((duration = 0) => {
168
+ flowRef.current?.fitView({
169
+ padding: 0.2,
170
+ duration,
171
+ includeHiddenNodes: false,
172
+ });
173
+ }, []);
174
+
175
+ useEffect(() => {
176
+ if (!flowRef.current) {
177
+ return;
178
+ }
179
+
180
+ // Fit immediately and once more after layout settles to avoid right-shift on view toggle.
181
+ refitView(0);
182
+ const timeoutId = window.setTimeout(() => {
183
+ refitView(200);
184
+ }, 220);
185
+
186
+ const onResize = () => refitView(0);
187
+ window.addEventListener("resize", onResize);
188
+
189
+ return () => {
190
+ window.clearTimeout(timeoutId);
191
+ window.removeEventListener("resize", onResize);
192
+ };
193
+ }, [nodes, edges, activeThreadId, refitView]);
194
+
195
+ return (
196
+ <div className="map-shell h-full">
197
+ <ReactFlow
198
+ nodes={nodes}
199
+ edges={edges}
200
+ fitView
201
+ fitViewOptions={{ padding: 0.2, includeHiddenNodes: false }}
202
+ onInit={(instance) => {
203
+ flowRef.current = instance;
204
+ refitView(0);
205
+ }}
206
+ onNodeClick={async (_, node) => {
207
+ if (!activeThread) return;
208
+ const clicked = messageMap.get(node.id);
209
+ const targetHeadId =
210
+ clicked?.role === "user"
211
+ ? firstAssistantChildByUserId.get(clicked.id)?.id ?? null
212
+ : node.id;
213
+ if (!targetHeadId) return;
214
+
215
+ const existing = threadPathIds
216
+ .filter(({ pathIds }) => pathIds.has(targetHeadId))
217
+ .sort((a, b) => b.depth - a.depth)[0]?.thread;
218
+ const exact = conversationThreads.find(
219
+ (thread) => thread.headMessageId === targetHeadId
220
+ );
221
+ try {
222
+ if (existing) {
223
+ onSelectThread(existing.id);
224
+ } else if (exact) {
225
+ onSelectThread(exact.id);
226
+ } else {
227
+ const newThread = await createThreadFromMessage(
228
+ targetHeadId,
229
+ activeThread.conversationId
230
+ );
231
+ onSelectThread(newThread.id);
232
+ }
233
+ setFocusedMessageId(node.id);
234
+ onNavigateToChat();
235
+ } catch {
236
+ // Ignore if thread limit reached.
237
+ }
238
+ }}
239
+ >
240
+ <Background gap={24} />
241
+ </ReactFlow>
242
+ </div>
243
+ );
244
+ }