groove-dev 0.27.55 → 0.27.57
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/ai-chat/CHAT_MASTER_PLAN.md +184 -0
- package/node_modules/@groove-dev/cli/package.json +1 -1
- package/node_modules/@groove-dev/daemon/package.json +1 -1
- package/node_modules/@groove-dev/daemon/src/api.js +169 -0
- package/node_modules/@groove-dev/daemon/src/conversations.js +423 -0
- package/node_modules/@groove-dev/daemon/src/index.js +2 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-C5WTeZO4.css +1 -0
- package/node_modules/@groove-dev/gui/dist/assets/{index-De-OWmBX.js → index-X58BAjGp.js} +1752 -1745
- package/node_modules/@groove-dev/gui/dist/index.html +2 -2
- package/node_modules/@groove-dev/gui/package.json +1 -1
- package/node_modules/@groove-dev/gui/src/app.jsx +2 -0
- package/node_modules/@groove-dev/gui/src/components/chat/chat-header.jsx +138 -0
- package/node_modules/@groove-dev/gui/src/components/chat/chat-input.jsx +112 -0
- package/node_modules/@groove-dev/gui/src/components/chat/chat-messages.jsx +347 -0
- package/node_modules/@groove-dev/gui/src/components/chat/chat-view.jsx +165 -0
- package/node_modules/@groove-dev/gui/src/components/chat/conversation-list.jsx +154 -0
- package/node_modules/@groove-dev/gui/src/components/chat/model-picker.jsx +143 -0
- package/node_modules/@groove-dev/gui/src/components/layout/activity-bar.jsx +2 -1
- package/node_modules/@groove-dev/gui/src/stores/groove.js +220 -0
- package/node_modules/@groove-dev/gui/src/views/chat.jsx +6 -0
- package/package.json +1 -1
- package/packages/cli/package.json +1 -1
- package/packages/daemon/package.json +1 -1
- package/packages/daemon/src/api.js +169 -0
- package/packages/daemon/src/conversations.js +423 -0
- package/packages/daemon/src/index.js +2 -0
- package/packages/gui/dist/assets/index-C5WTeZO4.css +1 -0
- package/packages/gui/dist/assets/{index-De-OWmBX.js → index-X58BAjGp.js} +1752 -1745
- package/packages/gui/dist/index.html +2 -2
- package/packages/gui/package.json +1 -1
- package/packages/gui/src/app.jsx +2 -0
- package/packages/gui/src/components/chat/chat-header.jsx +138 -0
- package/packages/gui/src/components/chat/chat-input.jsx +112 -0
- package/packages/gui/src/components/chat/chat-messages.jsx +347 -0
- package/packages/gui/src/components/chat/chat-view.jsx +165 -0
- package/packages/gui/src/components/chat/conversation-list.jsx +154 -0
- package/packages/gui/src/components/chat/model-picker.jsx +143 -0
- package/packages/gui/src/components/layout/activity-bar.jsx +2 -1
- package/packages/gui/src/stores/groove.js +220 -0
- package/packages/gui/src/views/chat.jsx +6 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-CyVj0fHl.css +0 -1
- package/packages/gui/dist/assets/index-CyVj0fHl.css +0 -1
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
7
7
|
<link rel="icon" type="image/png" href="/favicon.png" />
|
|
8
8
|
<title>Groove GUI</title>
|
|
9
|
-
<script type="module" crossorigin src="/assets/index-
|
|
9
|
+
<script type="module" crossorigin src="/assets/index-X58BAjGp.js"></script>
|
|
10
10
|
<link rel="modulepreload" crossorigin href="/assets/vendor-C0HXlhrU.js">
|
|
11
11
|
<link rel="modulepreload" crossorigin href="/assets/reactflow-BQPfi37R.js">
|
|
12
12
|
<link rel="modulepreload" crossorigin href="/assets/codemirror-BBL3i_JW.js">
|
|
13
13
|
<link rel="modulepreload" crossorigin href="/assets/xterm--7_ns2zW.js">
|
|
14
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
14
|
+
<link rel="stylesheet" crossorigin href="/assets/index-C5WTeZO4.css">
|
|
15
15
|
</head>
|
|
16
16
|
<body>
|
|
17
17
|
<div id="root"></div>
|
|
@@ -19,6 +19,7 @@ import ModelsView from './views/models';
|
|
|
19
19
|
import FederationView from './views/federation';
|
|
20
20
|
import ToysView from './views/toys';
|
|
21
21
|
import NetworkView from './views/network';
|
|
22
|
+
import ChatView from './views/chat';
|
|
22
23
|
|
|
23
24
|
// Agent components
|
|
24
25
|
import { AgentPanel } from './components/agents/agent-panel';
|
|
@@ -70,6 +71,7 @@ function ViewRouter() {
|
|
|
70
71
|
case 'models': content = <ModelsView />; break;
|
|
71
72
|
case 'federation': content = <FederationView />; break;
|
|
72
73
|
case 'settings': content = <SettingsView />; break;
|
|
74
|
+
case 'chat': content = <ChatView />; break;
|
|
73
75
|
case 'network': content = networkUnlocked ? <NetworkView /> : <AgentsView />; break;
|
|
74
76
|
default: content = <AgentsView />;
|
|
75
77
|
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
2
|
+
import { useState, useRef, useEffect } from 'react';
|
|
3
|
+
import { Pencil, Pin, PinOff, Trash2, Hash, MoreHorizontal, Zap, Bot } from 'lucide-react';
|
|
4
|
+
import { useGrooveStore } from '../../stores/groove';
|
|
5
|
+
import { fmtNum } from '../../lib/format';
|
|
6
|
+
import { ModelPicker } from './model-picker';
|
|
7
|
+
import { Tooltip } from '../ui/tooltip';
|
|
8
|
+
|
|
9
|
+
export function ChatHeader({ conversation, model, onModelChange, onModeChange }) {
|
|
10
|
+
const renameConversation = useGrooveStore((s) => s.renameConversation);
|
|
11
|
+
const pinConversation = useGrooveStore((s) => s.pinConversation);
|
|
12
|
+
const deleteConversation = useGrooveStore((s) => s.deleteConversation);
|
|
13
|
+
|
|
14
|
+
const [editing, setEditing] = useState(false);
|
|
15
|
+
const [title, setTitle] = useState(conversation.title || '');
|
|
16
|
+
const [menuOpen, setMenuOpen] = useState(false);
|
|
17
|
+
const inputRef = useRef(null);
|
|
18
|
+
const menuRef = useRef(null);
|
|
19
|
+
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
setTitle(conversation.title || '');
|
|
22
|
+
setEditing(false);
|
|
23
|
+
}, [conversation.id, conversation.title]);
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
if (editing && inputRef.current) inputRef.current.focus();
|
|
27
|
+
}, [editing]);
|
|
28
|
+
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (!menuOpen) return;
|
|
31
|
+
function handleClick(e) {
|
|
32
|
+
if (menuRef.current && !menuRef.current.contains(e.target)) setMenuOpen(false);
|
|
33
|
+
}
|
|
34
|
+
document.addEventListener('mousedown', handleClick);
|
|
35
|
+
return () => document.removeEventListener('mousedown', handleClick);
|
|
36
|
+
}, [menuOpen]);
|
|
37
|
+
|
|
38
|
+
function handleRename() {
|
|
39
|
+
const trimmed = title.trim();
|
|
40
|
+
if (trimmed && trimmed !== conversation.title) {
|
|
41
|
+
renameConversation(conversation.id, trimmed);
|
|
42
|
+
} else {
|
|
43
|
+
setTitle(conversation.title || '');
|
|
44
|
+
}
|
|
45
|
+
setEditing(false);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const agent = useGrooveStore((s) => s.agents.find((a) => a.id === conversation.agentId));
|
|
49
|
+
const tokens = agent?.tokensUsed || 0;
|
|
50
|
+
const mode = conversation.mode || 'api';
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<div className="h-11 flex items-center gap-3 px-4 border-b border-border bg-surface-1 flex-shrink-0">
|
|
54
|
+
<Hash size={14} className="text-text-4 flex-shrink-0" />
|
|
55
|
+
|
|
56
|
+
{editing ? (
|
|
57
|
+
<input
|
|
58
|
+
ref={inputRef}
|
|
59
|
+
value={title}
|
|
60
|
+
onChange={(e) => setTitle(e.target.value)}
|
|
61
|
+
onKeyDown={(e) => { if (e.key === 'Enter') handleRename(); if (e.key === 'Escape') { setTitle(conversation.title || ''); setEditing(false); } }}
|
|
62
|
+
onBlur={handleRename}
|
|
63
|
+
className="flex-1 min-w-0 bg-transparent text-sm font-semibold text-text-0 font-sans outline-none border-b border-accent"
|
|
64
|
+
maxLength={100}
|
|
65
|
+
/>
|
|
66
|
+
) : (
|
|
67
|
+
<button
|
|
68
|
+
onClick={() => setEditing(true)}
|
|
69
|
+
className="flex-1 min-w-0 text-left text-sm font-semibold text-text-0 font-sans truncate hover:text-accent transition-colors cursor-pointer"
|
|
70
|
+
>
|
|
71
|
+
{conversation.title || 'New Chat'}
|
|
72
|
+
</button>
|
|
73
|
+
)}
|
|
74
|
+
|
|
75
|
+
<div className="flex items-center gap-2 flex-shrink-0">
|
|
76
|
+
<div className="flex items-center h-7 rounded-lg bg-surface-3 border border-border-subtle p-0.5">
|
|
77
|
+
<Tooltip content="Lightweight — fast and cheap, no tools" side="bottom">
|
|
78
|
+
<button
|
|
79
|
+
onClick={() => onModeChange?.('api')}
|
|
80
|
+
className={`flex items-center gap-1 h-6 px-2 rounded-md text-2xs font-semibold font-sans transition-colors cursor-pointer ${mode === 'api' ? 'bg-accent/15 text-accent border border-accent/25' : 'text-text-3 hover:text-text-1'}`}
|
|
81
|
+
>
|
|
82
|
+
<Zap size={11} /> Chat
|
|
83
|
+
</button>
|
|
84
|
+
</Tooltip>
|
|
85
|
+
<Tooltip content="Full agent — tools, files, session resume" side="bottom">
|
|
86
|
+
<button
|
|
87
|
+
onClick={() => onModeChange?.('agent')}
|
|
88
|
+
className={`flex items-center gap-1 h-6 px-2 rounded-md text-2xs font-semibold font-sans transition-colors cursor-pointer ${mode === 'agent' ? 'bg-purple/15 text-purple border border-purple/25' : 'text-text-3 hover:text-text-1'}`}
|
|
89
|
+
>
|
|
90
|
+
<Bot size={11} /> Agent
|
|
91
|
+
</button>
|
|
92
|
+
</Tooltip>
|
|
93
|
+
</div>
|
|
94
|
+
<ModelPicker
|
|
95
|
+
value={model || { provider: conversation.provider, model: conversation.model }}
|
|
96
|
+
onChange={onModelChange}
|
|
97
|
+
disabled={false}
|
|
98
|
+
/>
|
|
99
|
+
{tokens > 0 && (
|
|
100
|
+
<span className="text-2xs text-text-3 font-mono">{fmtNum(tokens)} tokens</span>
|
|
101
|
+
)}
|
|
102
|
+
|
|
103
|
+
<div ref={menuRef} className="relative">
|
|
104
|
+
<button
|
|
105
|
+
onClick={() => setMenuOpen(!menuOpen)}
|
|
106
|
+
className="w-7 h-7 flex items-center justify-center rounded-md text-text-3 hover:text-text-1 hover:bg-surface-3 transition-colors cursor-pointer"
|
|
107
|
+
>
|
|
108
|
+
<MoreHorizontal size={14} />
|
|
109
|
+
</button>
|
|
110
|
+
{menuOpen && (
|
|
111
|
+
<div className="absolute right-0 top-full mt-1 w-40 rounded-md border border-border bg-surface-1 shadow-xl z-50 py-1">
|
|
112
|
+
<button
|
|
113
|
+
onClick={() => { setEditing(true); setMenuOpen(false); }}
|
|
114
|
+
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm text-text-1 hover:bg-surface-5 cursor-pointer font-sans"
|
|
115
|
+
>
|
|
116
|
+
<Pencil size={12} /> Rename
|
|
117
|
+
</button>
|
|
118
|
+
<button
|
|
119
|
+
onClick={() => { pinConversation(conversation.id, !conversation.pinned); setMenuOpen(false); }}
|
|
120
|
+
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm text-text-1 hover:bg-surface-5 cursor-pointer font-sans"
|
|
121
|
+
>
|
|
122
|
+
{conversation.pinned ? <PinOff size={12} /> : <Pin size={12} />}
|
|
123
|
+
{conversation.pinned ? 'Unpin' : 'Pin'}
|
|
124
|
+
</button>
|
|
125
|
+
<div className="h-px my-1 bg-border-subtle" />
|
|
126
|
+
<button
|
|
127
|
+
onClick={() => { deleteConversation(conversation.id); setMenuOpen(false); }}
|
|
128
|
+
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm text-danger hover:bg-danger/10 cursor-pointer font-sans"
|
|
129
|
+
>
|
|
130
|
+
<Trash2 size={12} /> Delete
|
|
131
|
+
</button>
|
|
132
|
+
</div>
|
|
133
|
+
)}
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
</div>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
2
|
+
import { useState, useRef, useEffect, useCallback } from 'react';
|
|
3
|
+
import { Send, Loader2, Square, Paperclip } from 'lucide-react';
|
|
4
|
+
import { cn } from '../../lib/cn';
|
|
5
|
+
|
|
6
|
+
export function ChatInput({ onSend, onStop, sending, streaming, disabled }) {
|
|
7
|
+
const [input, setInput] = useState('');
|
|
8
|
+
const textareaRef = useRef(null);
|
|
9
|
+
const fileInputRef = useRef(null);
|
|
10
|
+
|
|
11
|
+
const adjustHeight = useCallback(() => {
|
|
12
|
+
const el = textareaRef.current;
|
|
13
|
+
if (!el) return;
|
|
14
|
+
el.style.height = 'auto';
|
|
15
|
+
el.style.height = Math.min(el.scrollHeight, 200) + 'px';
|
|
16
|
+
}, []);
|
|
17
|
+
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
adjustHeight();
|
|
20
|
+
}, [input, adjustHeight]);
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
if (!disabled && textareaRef.current) textareaRef.current.focus();
|
|
24
|
+
}, [disabled]);
|
|
25
|
+
|
|
26
|
+
function handleSend() {
|
|
27
|
+
const text = input.trim();
|
|
28
|
+
if (!text || sending || disabled) return;
|
|
29
|
+
onSend(text);
|
|
30
|
+
setInput('');
|
|
31
|
+
if (textareaRef.current) {
|
|
32
|
+
textareaRef.current.style.height = 'auto';
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function onKeyDown(e) {
|
|
37
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
38
|
+
e.preventDefault();
|
|
39
|
+
handleSend();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function handleFileSelect(e) {
|
|
44
|
+
const files = Array.from(e.target.files || []);
|
|
45
|
+
if (files.length === 0) return;
|
|
46
|
+
const pathList = files.map((f) => f.name).join(', ');
|
|
47
|
+
setInput((prev) => prev + (prev ? '\n' : '') + `[Attached: ${pathList}]`);
|
|
48
|
+
e.target.value = '';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const isActive = streaming || sending;
|
|
52
|
+
const canSend = input.trim() && !sending && !disabled;
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<div className="px-4 py-3">
|
|
56
|
+
<div className="flex items-end gap-2 rounded-2xl bg-surface-3/60 border border-border-subtle px-3 py-2 focus-within:border-accent/30 transition-colors">
|
|
57
|
+
<input
|
|
58
|
+
ref={fileInputRef}
|
|
59
|
+
type="file"
|
|
60
|
+
multiple
|
|
61
|
+
accept=".pdf,.png,.jpg,.jpeg,.gif,.svg,.csv,.txt,.md,.json,.yaml,.yml,.docx,.pptx,.xlsx"
|
|
62
|
+
onChange={handleFileSelect}
|
|
63
|
+
className="hidden"
|
|
64
|
+
/>
|
|
65
|
+
<button
|
|
66
|
+
onClick={() => fileInputRef.current?.click()}
|
|
67
|
+
disabled={disabled}
|
|
68
|
+
className="w-8 h-8 flex items-center justify-center rounded-lg text-text-4 hover:text-text-1 hover:bg-surface-4 transition-colors cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed flex-shrink-0"
|
|
69
|
+
title="Attach file"
|
|
70
|
+
>
|
|
71
|
+
<Paperclip size={15} />
|
|
72
|
+
</button>
|
|
73
|
+
|
|
74
|
+
<textarea
|
|
75
|
+
ref={textareaRef}
|
|
76
|
+
value={input}
|
|
77
|
+
onChange={(e) => setInput(e.target.value)}
|
|
78
|
+
onKeyDown={onKeyDown}
|
|
79
|
+
placeholder={disabled ? 'Select a model to start chatting...' : 'Send a message...'}
|
|
80
|
+
disabled={disabled}
|
|
81
|
+
rows={1}
|
|
82
|
+
style={{ minHeight: '36px' }}
|
|
83
|
+
className="flex-1 resize-none bg-transparent text-sm text-text-0 font-sans placeholder:text-text-4 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed py-1.5"
|
|
84
|
+
/>
|
|
85
|
+
|
|
86
|
+
{isActive ? (
|
|
87
|
+
<button
|
|
88
|
+
onClick={onStop}
|
|
89
|
+
className="w-8 h-8 flex items-center justify-center rounded-xl bg-danger/80 text-white hover:bg-danger transition-all cursor-pointer shadow-lg shadow-danger/20 flex-shrink-0"
|
|
90
|
+
title="Stop generation"
|
|
91
|
+
>
|
|
92
|
+
<Square size={14} fill="currentColor" />
|
|
93
|
+
</button>
|
|
94
|
+
) : (
|
|
95
|
+
<button
|
|
96
|
+
onClick={handleSend}
|
|
97
|
+
disabled={!canSend}
|
|
98
|
+
className={cn(
|
|
99
|
+
'w-8 h-8 flex items-center justify-center rounded-xl transition-all cursor-pointer flex-shrink-0',
|
|
100
|
+
'disabled:opacity-20 disabled:cursor-not-allowed',
|
|
101
|
+
canSend
|
|
102
|
+
? 'bg-accent/15 text-accent hover:bg-accent/25 border border-accent/25'
|
|
103
|
+
: 'bg-surface-4 text-text-4',
|
|
104
|
+
)}
|
|
105
|
+
>
|
|
106
|
+
{sending ? <Loader2 size={16} className="animate-spin" /> : <Send size={16} />}
|
|
107
|
+
</button>
|
|
108
|
+
)}
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
2
|
+
import { useRef, useEffect, useState } from 'react';
|
|
3
|
+
import { Copy, Check, ArrowRight, MessageCircle, Sparkles } from 'lucide-react';
|
|
4
|
+
import { cn } from '../../lib/cn';
|
|
5
|
+
import { timeAgo } from '../../lib/format';
|
|
6
|
+
import { ThinkingIndicator } from '../ui/thinking-indicator';
|
|
7
|
+
|
|
8
|
+
function CopyButton({ text }) {
|
|
9
|
+
const [copied, setCopied] = useState(false);
|
|
10
|
+
function handleCopy() {
|
|
11
|
+
navigator.clipboard.writeText(text).then(() => {
|
|
12
|
+
setCopied(true);
|
|
13
|
+
setTimeout(() => setCopied(false), 2000);
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
return (
|
|
17
|
+
<button
|
|
18
|
+
onClick={handleCopy}
|
|
19
|
+
className="flex items-center gap-1 px-2 py-1 text-2xs font-sans text-text-3 hover:text-text-1 transition-colors cursor-pointer"
|
|
20
|
+
>
|
|
21
|
+
{copied ? <Check size={12} className="text-success" /> : <Copy size={12} />}
|
|
22
|
+
{copied ? 'Copied' : 'Copy'}
|
|
23
|
+
</button>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function CodeBlock({ language, code }) {
|
|
28
|
+
return (
|
|
29
|
+
<div className="my-3 rounded-lg border border-border-subtle overflow-hidden bg-surface-0">
|
|
30
|
+
<div className="flex items-center justify-between px-3 py-1.5 bg-surface-3 border-b border-border-subtle">
|
|
31
|
+
<span className="text-2xs font-mono text-text-3">{language || 'code'}</span>
|
|
32
|
+
<CopyButton text={code} />
|
|
33
|
+
</div>
|
|
34
|
+
<pre className="px-4 py-3 overflow-x-auto">
|
|
35
|
+
<code className="text-xs font-mono text-text-1 leading-relaxed whitespace-pre">{code}</code>
|
|
36
|
+
</pre>
|
|
37
|
+
</div>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function parseMarkdown(text) {
|
|
42
|
+
if (!text) return [];
|
|
43
|
+
const lines = text.split('\n');
|
|
44
|
+
const blocks = [];
|
|
45
|
+
let i = 0;
|
|
46
|
+
|
|
47
|
+
while (i < lines.length) {
|
|
48
|
+
const line = lines[i];
|
|
49
|
+
|
|
50
|
+
// Fenced code block
|
|
51
|
+
if (line.startsWith('```')) {
|
|
52
|
+
const lang = line.slice(3).trim();
|
|
53
|
+
const codeLines = [];
|
|
54
|
+
i++;
|
|
55
|
+
while (i < lines.length && !lines[i].startsWith('```')) {
|
|
56
|
+
codeLines.push(lines[i]);
|
|
57
|
+
i++;
|
|
58
|
+
}
|
|
59
|
+
i++; // skip closing ```
|
|
60
|
+
blocks.push({ type: 'code', language: lang, code: codeLines.join('\n') });
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Heading
|
|
65
|
+
const headingMatch = line.match(/^(#{1,6})\s+(.+)/);
|
|
66
|
+
if (headingMatch) {
|
|
67
|
+
blocks.push({ type: 'heading', level: headingMatch[1].length, text: headingMatch[2] });
|
|
68
|
+
i++;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Horizontal rule
|
|
73
|
+
if (/^(-{3,}|_{3,}|\*{3,})$/.test(line.trim())) {
|
|
74
|
+
blocks.push({ type: 'hr' });
|
|
75
|
+
i++;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Blockquote
|
|
80
|
+
if (line.startsWith('> ')) {
|
|
81
|
+
const quoteLines = [line.slice(2)];
|
|
82
|
+
i++;
|
|
83
|
+
while (i < lines.length && lines[i].startsWith('> ')) {
|
|
84
|
+
quoteLines.push(lines[i].slice(2));
|
|
85
|
+
i++;
|
|
86
|
+
}
|
|
87
|
+
blocks.push({ type: 'blockquote', text: quoteLines.join('\n') });
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Unordered list
|
|
92
|
+
if (/^[-*+]\s/.test(line)) {
|
|
93
|
+
const items = [line.replace(/^[-*+]\s/, '')];
|
|
94
|
+
i++;
|
|
95
|
+
while (i < lines.length && /^[-*+]\s/.test(lines[i])) {
|
|
96
|
+
items.push(lines[i].replace(/^[-*+]\s/, ''));
|
|
97
|
+
i++;
|
|
98
|
+
}
|
|
99
|
+
blocks.push({ type: 'ul', items });
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Ordered list
|
|
104
|
+
if (/^\d+\.\s/.test(line)) {
|
|
105
|
+
const items = [line.replace(/^\d+\.\s/, '')];
|
|
106
|
+
i++;
|
|
107
|
+
while (i < lines.length && /^\d+\.\s/.test(lines[i])) {
|
|
108
|
+
items.push(lines[i].replace(/^\d+\.\s/, ''));
|
|
109
|
+
i++;
|
|
110
|
+
}
|
|
111
|
+
blocks.push({ type: 'ol', items });
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Table
|
|
116
|
+
if (line.includes('|') && i + 1 < lines.length && /^\|?\s*[-:]+/.test(lines[i + 1])) {
|
|
117
|
+
const headerCells = line.split('|').map((c) => c.trim()).filter(Boolean);
|
|
118
|
+
i += 2; // skip header + separator
|
|
119
|
+
const rows = [];
|
|
120
|
+
while (i < lines.length && lines[i].includes('|')) {
|
|
121
|
+
rows.push(lines[i].split('|').map((c) => c.trim()).filter(Boolean));
|
|
122
|
+
i++;
|
|
123
|
+
}
|
|
124
|
+
blocks.push({ type: 'table', headers: headerCells, rows });
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Empty line
|
|
129
|
+
if (line.trim() === '') {
|
|
130
|
+
i++;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Paragraph — collect consecutive non-empty lines
|
|
135
|
+
const paraLines = [line];
|
|
136
|
+
i++;
|
|
137
|
+
while (i < lines.length && lines[i].trim() !== '' && !lines[i].startsWith('```') && !lines[i].startsWith('#') && !/^[-*+]\s/.test(lines[i]) && !/^\d+\.\s/.test(lines[i]) && !lines[i].startsWith('> ') && !/^(-{3,}|_{3,}|\*{3,})$/.test(lines[i].trim())) {
|
|
138
|
+
paraLines.push(lines[i]);
|
|
139
|
+
i++;
|
|
140
|
+
}
|
|
141
|
+
blocks.push({ type: 'paragraph', text: paraLines.join('\n') });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return blocks;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function InlineMarkdown({ text }) {
|
|
148
|
+
if (!text) return null;
|
|
149
|
+
const parts = text.split(/(```[\s\S]*?```|`[^`]+`|\*\*[^*]+\*\*|\*[^*]+\*|~~[^~]+~~|\[([^\]]+)\]\(([^)]+)\))/g);
|
|
150
|
+
return (
|
|
151
|
+
<>
|
|
152
|
+
{parts.map((part, i) => {
|
|
153
|
+
if (!part) return null;
|
|
154
|
+
if (part.startsWith('`') && part.endsWith('`') && !part.startsWith('``')) {
|
|
155
|
+
return <code key={i} className="px-1.5 py-0.5 rounded bg-surface-0 text-xs font-mono text-accent">{part.slice(1, -1)}</code>;
|
|
156
|
+
}
|
|
157
|
+
if (part.startsWith('**') && part.endsWith('**')) {
|
|
158
|
+
return <strong key={i} className="font-semibold text-text-0">{part.slice(2, -2)}</strong>;
|
|
159
|
+
}
|
|
160
|
+
if (part.startsWith('*') && part.endsWith('*') && !part.startsWith('**')) {
|
|
161
|
+
return <em key={i} className="italic">{part.slice(1, -1)}</em>;
|
|
162
|
+
}
|
|
163
|
+
if (part.startsWith('~~') && part.endsWith('~~')) {
|
|
164
|
+
return <del key={i} className="line-through text-text-3">{part.slice(2, -2)}</del>;
|
|
165
|
+
}
|
|
166
|
+
const linkMatch = part.match(/^\[([^\]]+)\]\(([^)]+)\)$/);
|
|
167
|
+
if (linkMatch) {
|
|
168
|
+
return <a key={i} href={linkMatch[2]} target="_blank" rel="noopener noreferrer" className="text-accent hover:underline">{linkMatch[1]}</a>;
|
|
169
|
+
}
|
|
170
|
+
return <span key={i}>{part}</span>;
|
|
171
|
+
})}
|
|
172
|
+
</>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function RenderedMarkdown({ text }) {
|
|
177
|
+
const blocks = parseMarkdown(text);
|
|
178
|
+
return (
|
|
179
|
+
<div className="space-y-2">
|
|
180
|
+
{blocks.map((block, i) => {
|
|
181
|
+
switch (block.type) {
|
|
182
|
+
case 'code':
|
|
183
|
+
return <CodeBlock key={i} language={block.language} code={block.code} />;
|
|
184
|
+
case 'heading': {
|
|
185
|
+
const sizes = ['text-lg font-bold', 'text-base font-bold', 'text-sm font-semibold', 'text-sm font-semibold', 'text-xs font-semibold', 'text-xs font-semibold'];
|
|
186
|
+
return <div key={i} className={cn(sizes[block.level - 1] || sizes[0], 'text-text-0 font-sans mt-3 mb-1')}><InlineMarkdown text={block.text} /></div>;
|
|
187
|
+
}
|
|
188
|
+
case 'hr':
|
|
189
|
+
return <hr key={i} className="border-border-subtle my-3" />;
|
|
190
|
+
case 'blockquote':
|
|
191
|
+
return (
|
|
192
|
+
<div key={i} className="border-l-2 border-accent/40 pl-3 py-1 text-sm text-text-2 italic font-sans">
|
|
193
|
+
<InlineMarkdown text={block.text} />
|
|
194
|
+
</div>
|
|
195
|
+
);
|
|
196
|
+
case 'ul':
|
|
197
|
+
return (
|
|
198
|
+
<ul key={i} className="list-disc list-inside space-y-0.5 text-sm text-text-1 font-sans">
|
|
199
|
+
{block.items.map((item, j) => <li key={j}><InlineMarkdown text={item} /></li>)}
|
|
200
|
+
</ul>
|
|
201
|
+
);
|
|
202
|
+
case 'ol':
|
|
203
|
+
return (
|
|
204
|
+
<ol key={i} className="list-decimal list-inside space-y-0.5 text-sm text-text-1 font-sans">
|
|
205
|
+
{block.items.map((item, j) => <li key={j}><InlineMarkdown text={item} /></li>)}
|
|
206
|
+
</ol>
|
|
207
|
+
);
|
|
208
|
+
case 'table':
|
|
209
|
+
return (
|
|
210
|
+
<div key={i} className="overflow-x-auto my-2">
|
|
211
|
+
<table className="text-xs font-sans border-collapse w-full">
|
|
212
|
+
<thead>
|
|
213
|
+
<tr className="border-b border-border">
|
|
214
|
+
{block.headers.map((h, j) => (
|
|
215
|
+
<th key={j} className="px-3 py-1.5 text-left font-semibold text-text-0">{h}</th>
|
|
216
|
+
))}
|
|
217
|
+
</tr>
|
|
218
|
+
</thead>
|
|
219
|
+
<tbody>
|
|
220
|
+
{block.rows.map((row, j) => (
|
|
221
|
+
<tr key={j} className="border-b border-border-subtle">
|
|
222
|
+
{row.map((cell, k) => (
|
|
223
|
+
<td key={k} className="px-3 py-1.5 text-text-1"><InlineMarkdown text={cell} /></td>
|
|
224
|
+
))}
|
|
225
|
+
</tr>
|
|
226
|
+
))}
|
|
227
|
+
</tbody>
|
|
228
|
+
</table>
|
|
229
|
+
</div>
|
|
230
|
+
);
|
|
231
|
+
case 'paragraph':
|
|
232
|
+
return <p key={i} className="text-sm text-text-1 font-sans leading-relaxed whitespace-pre-wrap break-words"><InlineMarkdown text={block.text} /></p>;
|
|
233
|
+
default:
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
})}
|
|
237
|
+
</div>
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function UserMessage({ msg }) {
|
|
242
|
+
return (
|
|
243
|
+
<div className="flex justify-end">
|
|
244
|
+
<div className="max-w-[75%]">
|
|
245
|
+
<div className="px-4 py-3 rounded-2xl rounded-br-md bg-accent/10 border border-accent/15">
|
|
246
|
+
<p className="text-sm text-text-0 font-sans whitespace-pre-wrap break-words leading-relaxed">{msg.text}</p>
|
|
247
|
+
</div>
|
|
248
|
+
<div className="text-2xs text-text-4 font-sans mt-1 text-right">{timeAgo(msg.timestamp)}</div>
|
|
249
|
+
</div>
|
|
250
|
+
</div>
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function AssistantMessage({ msg, model }) {
|
|
255
|
+
return (
|
|
256
|
+
<div className="flex gap-3">
|
|
257
|
+
<div className="w-7 h-7 rounded-full bg-surface-4 border border-border-subtle flex items-center justify-center flex-shrink-0 mt-0.5">
|
|
258
|
+
<Sparkles size={13} className="text-accent" />
|
|
259
|
+
</div>
|
|
260
|
+
<div className="flex-1 min-w-0 max-w-[85%]">
|
|
261
|
+
{model && <div className="text-2xs text-text-3 font-sans mb-1 font-medium">{model}</div>}
|
|
262
|
+
<div className="px-4 py-3 rounded-2xl rounded-bl-md bg-surface-4 border border-border-subtle">
|
|
263
|
+
<RenderedMarkdown text={msg.text} />
|
|
264
|
+
</div>
|
|
265
|
+
<div className="text-2xs text-text-4 font-sans mt-1">{timeAgo(msg.timestamp)}</div>
|
|
266
|
+
</div>
|
|
267
|
+
</div>
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function SystemMessage({ msg }) {
|
|
272
|
+
return (
|
|
273
|
+
<div className="flex justify-center py-1">
|
|
274
|
+
<div className="flex items-center gap-1.5 px-3 py-1 rounded-full bg-surface-4/50">
|
|
275
|
+
<ArrowRight size={10} className="text-text-4" />
|
|
276
|
+
<span className="text-2xs text-text-3 font-sans">{msg.text}</span>
|
|
277
|
+
</div>
|
|
278
|
+
</div>
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function StreamingCursor() {
|
|
283
|
+
return (
|
|
284
|
+
<span className="inline-block w-2 h-4 bg-accent/60 ml-0.5 animate-pulse rounded-sm" />
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function WelcomeMessage() {
|
|
289
|
+
return (
|
|
290
|
+
<div className="flex flex-col items-center justify-center h-full text-center py-16">
|
|
291
|
+
<div className="w-16 h-16 rounded-full bg-accent/8 flex items-center justify-center mb-5">
|
|
292
|
+
<MessageCircle size={28} className="text-accent" />
|
|
293
|
+
</div>
|
|
294
|
+
<h2 className="text-xl font-bold text-text-0 font-sans mb-2">Start a conversation</h2>
|
|
295
|
+
<p className="text-sm text-text-2 font-sans max-w-sm leading-relaxed">
|
|
296
|
+
Send a message to begin. Your conversation history is saved locally and syncs across sessions.
|
|
297
|
+
</p>
|
|
298
|
+
</div>
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export function ChatMessages({ messages, isStreaming, model }) {
|
|
303
|
+
const scrollRef = useRef(null);
|
|
304
|
+
const isAtBottomRef = useRef(true);
|
|
305
|
+
|
|
306
|
+
useEffect(() => {
|
|
307
|
+
const el = scrollRef.current;
|
|
308
|
+
if (!el) return;
|
|
309
|
+
function handleScroll() {
|
|
310
|
+
isAtBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 50;
|
|
311
|
+
}
|
|
312
|
+
el.addEventListener('scroll', handleScroll);
|
|
313
|
+
return () => el.removeEventListener('scroll', handleScroll);
|
|
314
|
+
}, []);
|
|
315
|
+
|
|
316
|
+
useEffect(() => {
|
|
317
|
+
if (isAtBottomRef.current && scrollRef.current) {
|
|
318
|
+
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
|
319
|
+
}
|
|
320
|
+
}, [messages?.length, isStreaming]);
|
|
321
|
+
|
|
322
|
+
if (!messages || messages.length === 0) {
|
|
323
|
+
return (
|
|
324
|
+
<div ref={scrollRef} className="flex-1 overflow-y-auto">
|
|
325
|
+
<WelcomeMessage />
|
|
326
|
+
</div>
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return (
|
|
331
|
+
<div ref={scrollRef} className="flex-1 overflow-y-auto px-6 py-5 space-y-5">
|
|
332
|
+
{messages.map((msg, i) => {
|
|
333
|
+
if (msg.from === 'user') return <UserMessage key={i} msg={msg} />;
|
|
334
|
+
if (msg.from === 'system') return <SystemMessage key={i} msg={msg} />;
|
|
335
|
+
return <AssistantMessage key={i} msg={msg} model={model} />;
|
|
336
|
+
})}
|
|
337
|
+
{isStreaming && (
|
|
338
|
+
<div className="flex gap-3">
|
|
339
|
+
<div className="w-7 h-7 rounded-full bg-surface-4 border border-border-subtle flex items-center justify-center flex-shrink-0">
|
|
340
|
+
<Sparkles size={13} className="text-accent" />
|
|
341
|
+
</div>
|
|
342
|
+
<ThinkingIndicator className="py-1" />
|
|
343
|
+
</div>
|
|
344
|
+
)}
|
|
345
|
+
</div>
|
|
346
|
+
);
|
|
347
|
+
}
|