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.
- package/LICENSE +21 -0
- package/README.md +49 -0
- package/bin/iris.mjs +267 -0
- package/package.json +61 -0
- package/template/LICENSE +21 -0
- package/template/README.md +49 -0
- package/template/eslint.config.mjs +18 -0
- package/template/next.config.ts +7 -0
- package/template/package-lock.json +9193 -0
- package/template/package.json +46 -0
- package/template/postcss.config.mjs +7 -0
- package/template/public/file.svg +1 -0
- package/template/public/globe.svg +1 -0
- package/template/public/next.svg +1 -0
- package/template/public/vercel.svg +1 -0
- package/template/public/window.svg +1 -0
- package/template/src/app/api/chat/route.ts +2445 -0
- package/template/src/app/api/connections/models/route.ts +255 -0
- package/template/src/app/api/connections/test/route.ts +124 -0
- package/template/src/app/api/local-sync/route.ts +74 -0
- package/template/src/app/api/tool-approval/route.ts +47 -0
- package/template/src/app/favicon.ico +0 -0
- package/template/src/app/globals.css +808 -0
- package/template/src/app/layout.tsx +74 -0
- package/template/src/app/page.tsx +444 -0
- package/template/src/components/ChatView.tsx +1537 -0
- package/template/src/components/Composer.tsx +160 -0
- package/template/src/components/MapView.tsx +244 -0
- package/template/src/components/MessageCard.tsx +955 -0
- package/template/src/components/SearchModal.tsx +72 -0
- package/template/src/components/SettingsModal.tsx +1257 -0
- package/template/src/components/Sidebar.tsx +153 -0
- package/template/src/components/TopBar.tsx +164 -0
- package/template/src/lib/connections.ts +275 -0
- package/template/src/lib/data.ts +324 -0
- package/template/src/lib/db.ts +49 -0
- package/template/src/lib/hooks.ts +76 -0
- package/template/src/lib/local-sync.ts +192 -0
- package/template/src/lib/memory.ts +695 -0
- package/template/src/lib/model-presets.ts +251 -0
- package/template/src/lib/store.ts +36 -0
- package/template/src/lib/tooling/approvals.ts +78 -0
- package/template/src/lib/tooling/providers/anthropic.ts +155 -0
- package/template/src/lib/tooling/providers/ollama.ts +73 -0
- package/template/src/lib/tooling/providers/openai.ts +267 -0
- package/template/src/lib/tooling/providers/openai_compatible.ts +16 -0
- package/template/src/lib/tooling/providers/types.ts +44 -0
- package/template/src/lib/tooling/registry.ts +103 -0
- package/template/src/lib/tooling/runtime.ts +189 -0
- package/template/src/lib/tooling/safety.ts +165 -0
- package/template/src/lib/tooling/tools/apps.ts +108 -0
- package/template/src/lib/tooling/tools/apps_plus.ts +153 -0
- package/template/src/lib/tooling/tools/communication.ts +883 -0
- package/template/src/lib/tooling/tools/files.ts +395 -0
- package/template/src/lib/tooling/tools/music.ts +988 -0
- package/template/src/lib/tooling/tools/notes.ts +461 -0
- package/template/src/lib/tooling/tools/notes_plus.ts +294 -0
- package/template/src/lib/tooling/tools/numbers.ts +175 -0
- package/template/src/lib/tooling/tools/schedule.ts +579 -0
- package/template/src/lib/tooling/tools/system.ts +142 -0
- package/template/src/lib/tooling/tools/web.ts +212 -0
- package/template/src/lib/tooling/tools/workflow.ts +218 -0
- package/template/src/lib/tooling/types.ts +27 -0
- package/template/src/lib/types.ts +309 -0
- package/template/src/lib/utils.ts +108 -0
- 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
|
+
}
|