groove-dev 0.27.56 → 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 +25 -5
- 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 +37 -9
- package/node_modules/@groove-dev/daemon/src/conversations.js +260 -20
- package/node_modules/@groove-dev/gui/dist/assets/index-C5WTeZO4.css +1 -0
- package/node_modules/@groove-dev/gui/dist/assets/{index-Bb8CIVBT.js → index-X58BAjGp.js} +1736 -1736
- 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/components/chat/chat-header.jsx +28 -10
- package/node_modules/@groove-dev/gui/src/components/chat/chat-input.jsx +9 -23
- package/node_modules/@groove-dev/gui/src/components/chat/chat-view.jsx +17 -10
- package/node_modules/@groove-dev/gui/src/components/chat/conversation-list.jsx +18 -13
- package/node_modules/@groove-dev/gui/src/components/chat/model-picker.jsx +22 -10
- package/node_modules/@groove-dev/gui/src/stores/groove.js +68 -5
- 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 +37 -9
- package/packages/daemon/src/conversations.js +260 -20
- package/packages/gui/dist/assets/index-C5WTeZO4.css +1 -0
- package/packages/gui/dist/assets/{index-Bb8CIVBT.js → index-X58BAjGp.js} +1736 -1736
- package/packages/gui/dist/index.html +2 -2
- package/packages/gui/package.json +1 -1
- package/packages/gui/src/components/chat/chat-header.jsx +28 -10
- package/packages/gui/src/components/chat/chat-input.jsx +9 -23
- package/packages/gui/src/components/chat/chat-view.jsx +17 -10
- package/packages/gui/src/components/chat/conversation-list.jsx +18 -13
- package/packages/gui/src/components/chat/model-picker.jsx +22 -10
- package/packages/gui/src/stores/groove.js +68 -5
- package/node_modules/@groove-dev/gui/dist/assets/index-DOy_oMyr.css +0 -1
- package/packages/gui/dist/assets/index-DOy_oMyr.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>
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
2
2
|
import { useState, useRef, useEffect } from 'react';
|
|
3
|
-
import { Pencil, Pin, PinOff, Trash2, Hash, MoreHorizontal } from 'lucide-react';
|
|
3
|
+
import { Pencil, Pin, PinOff, Trash2, Hash, MoreHorizontal, Zap, Bot } from 'lucide-react';
|
|
4
4
|
import { useGrooveStore } from '../../stores/groove';
|
|
5
|
-
import { cn } from '../../lib/cn';
|
|
6
|
-
import { Badge } from '../ui/badge';
|
|
7
5
|
import { fmtNum } from '../../lib/format';
|
|
6
|
+
import { ModelPicker } from './model-picker';
|
|
7
|
+
import { Tooltip } from '../ui/tooltip';
|
|
8
8
|
|
|
9
|
-
export function ChatHeader({ conversation }) {
|
|
9
|
+
export function ChatHeader({ conversation, model, onModelChange, onModeChange }) {
|
|
10
10
|
const renameConversation = useGrooveStore((s) => s.renameConversation);
|
|
11
11
|
const pinConversation = useGrooveStore((s) => s.pinConversation);
|
|
12
12
|
const deleteConversation = useGrooveStore((s) => s.deleteConversation);
|
|
@@ -47,6 +47,7 @@ export function ChatHeader({ conversation }) {
|
|
|
47
47
|
|
|
48
48
|
const agent = useGrooveStore((s) => s.agents.find((a) => a.id === conversation.agentId));
|
|
49
49
|
const tokens = agent?.tokensUsed || 0;
|
|
50
|
+
const mode = conversation.mode || 'api';
|
|
50
51
|
|
|
51
52
|
return (
|
|
52
53
|
<div className="h-11 flex items-center gap-3 px-4 border-b border-border bg-surface-1 flex-shrink-0">
|
|
@@ -72,12 +73,29 @@ export function ChatHeader({ conversation }) {
|
|
|
72
73
|
)}
|
|
73
74
|
|
|
74
75
|
<div className="flex items-center gap-2 flex-shrink-0">
|
|
75
|
-
|
|
76
|
-
<
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
+
/>
|
|
81
99
|
{tokens > 0 && (
|
|
82
100
|
<span className="text-2xs text-text-3 font-mono">{fmtNum(tokens)} tokens</span>
|
|
83
101
|
)}
|
|
@@ -2,9 +2,8 @@
|
|
|
2
2
|
import { useState, useRef, useEffect, useCallback } from 'react';
|
|
3
3
|
import { Send, Loader2, Square, Paperclip } from 'lucide-react';
|
|
4
4
|
import { cn } from '../../lib/cn';
|
|
5
|
-
import { ModelPicker } from './model-picker';
|
|
6
5
|
|
|
7
|
-
export function ChatInput({ onSend, onStop,
|
|
6
|
+
export function ChatInput({ onSend, onStop, sending, streaming, disabled }) {
|
|
8
7
|
const [input, setInput] = useState('');
|
|
9
8
|
const textareaRef = useRef(null);
|
|
10
9
|
const fileInputRef = useRef(null);
|
|
@@ -13,8 +12,7 @@ export function ChatInput({ onSend, onStop, onModelChange, model, sending, strea
|
|
|
13
12
|
const el = textareaRef.current;
|
|
14
13
|
if (!el) return;
|
|
15
14
|
el.style.height = 'auto';
|
|
16
|
-
|
|
17
|
-
el.style.height = Math.min(el.scrollHeight, maxHeight) + 'px';
|
|
15
|
+
el.style.height = Math.min(el.scrollHeight, 200) + 'px';
|
|
18
16
|
}, []);
|
|
19
17
|
|
|
20
18
|
useEffect(() => {
|
|
@@ -54,14 +52,8 @@ export function ChatInput({ onSend, onStop, onModelChange, model, sending, strea
|
|
|
54
52
|
const canSend = input.trim() && !sending && !disabled;
|
|
55
53
|
|
|
56
54
|
return (
|
|
57
|
-
<div className="
|
|
58
|
-
<div className="flex items-end gap-2">
|
|
59
|
-
<ModelPicker
|
|
60
|
-
value={model}
|
|
61
|
-
onChange={onModelChange}
|
|
62
|
-
disabled={isActive}
|
|
63
|
-
/>
|
|
64
|
-
|
|
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">
|
|
65
57
|
<input
|
|
66
58
|
ref={fileInputRef}
|
|
67
59
|
type="file"
|
|
@@ -73,7 +65,7 @@ export function ChatInput({ onSend, onStop, onModelChange, model, sending, strea
|
|
|
73
65
|
<button
|
|
74
66
|
onClick={() => fileInputRef.current?.click()}
|
|
75
67
|
disabled={disabled}
|
|
76
|
-
className="w-
|
|
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"
|
|
77
69
|
title="Attach file"
|
|
78
70
|
>
|
|
79
71
|
<Paperclip size={15} />
|
|
@@ -87,20 +79,14 @@ export function ChatInput({ onSend, onStop, onModelChange, model, sending, strea
|
|
|
87
79
|
placeholder={disabled ? 'Select a model to start chatting...' : 'Send a message...'}
|
|
88
80
|
disabled={disabled}
|
|
89
81
|
rows={1}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
'bg-surface-0 border text-text-0 font-sans',
|
|
93
|
-
'placeholder:text-text-4',
|
|
94
|
-
'focus:outline-none focus:ring-1',
|
|
95
|
-
'border-border focus:ring-accent/40',
|
|
96
|
-
'disabled:opacity-50 disabled:cursor-not-allowed',
|
|
97
|
-
)}
|
|
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"
|
|
98
84
|
/>
|
|
99
85
|
|
|
100
86
|
{isActive ? (
|
|
101
87
|
<button
|
|
102
88
|
onClick={onStop}
|
|
103
|
-
className="w-
|
|
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"
|
|
104
90
|
title="Stop generation"
|
|
105
91
|
>
|
|
106
92
|
<Square size={14} fill="currentColor" />
|
|
@@ -110,7 +96,7 @@ export function ChatInput({ onSend, onStop, onModelChange, model, sending, strea
|
|
|
110
96
|
onClick={handleSend}
|
|
111
97
|
disabled={!canSend}
|
|
112
98
|
className={cn(
|
|
113
|
-
'w-
|
|
99
|
+
'w-8 h-8 flex items-center justify-center rounded-xl transition-all cursor-pointer flex-shrink-0',
|
|
114
100
|
'disabled:opacity-20 disabled:cursor-not-allowed',
|
|
115
101
|
canSend
|
|
116
102
|
? 'bg-accent/15 text-accent hover:bg-accent/25 border border-accent/25'
|
|
@@ -64,6 +64,8 @@ export function ChatView() {
|
|
|
64
64
|
const setActiveConversation = useGrooveStore((s) => s.setActiveConversation);
|
|
65
65
|
const sendChatMessage = useGrooveStore((s) => s.sendChatMessage);
|
|
66
66
|
const stopAgent = useGrooveStore((s) => s.stopAgent);
|
|
67
|
+
const stopChatStreaming = useGrooveStore((s) => s.stopChatStreaming);
|
|
68
|
+
const setConversationMode = useGrooveStore((s) => s.setConversationMode);
|
|
67
69
|
|
|
68
70
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
|
69
71
|
|
|
@@ -73,21 +75,30 @@ export function ChatView() {
|
|
|
73
75
|
|
|
74
76
|
const handleNewChat = useCallback(async (provider, model) => {
|
|
75
77
|
const p = provider || 'claude-code';
|
|
76
|
-
const m = model || 'sonnet';
|
|
78
|
+
const m = model || 'claude-sonnet-4-6';
|
|
77
79
|
try {
|
|
78
|
-
await createConversation(p, m);
|
|
80
|
+
await createConversation(p, m, 'api');
|
|
79
81
|
} catch { /* toast handles */ }
|
|
80
82
|
}, [createConversation]);
|
|
81
83
|
|
|
84
|
+
const handleModeChange = useCallback((mode) => {
|
|
85
|
+
if (!activeConversationId) return;
|
|
86
|
+
setConversationMode(activeConversationId, mode);
|
|
87
|
+
}, [activeConversationId, setConversationMode]);
|
|
88
|
+
|
|
82
89
|
const handleSend = useCallback((text) => {
|
|
83
90
|
if (!activeConversationId) return;
|
|
84
91
|
sendChatMessage(activeConversationId, text);
|
|
85
92
|
}, [activeConversationId, sendChatMessage]);
|
|
86
93
|
|
|
87
94
|
const handleStop = useCallback(() => {
|
|
88
|
-
if (!activeConversation
|
|
89
|
-
|
|
90
|
-
|
|
95
|
+
if (!activeConversation) return;
|
|
96
|
+
if (activeConversation.mode === 'agent' && activeConversation.agentId) {
|
|
97
|
+
stopAgent(activeConversation.agentId);
|
|
98
|
+
} else {
|
|
99
|
+
stopChatStreaming(activeConversationId);
|
|
100
|
+
}
|
|
101
|
+
}, [activeConversation, activeConversationId, stopAgent, stopChatStreaming]);
|
|
91
102
|
|
|
92
103
|
const handleModelChange = useCallback(async (selection) => {
|
|
93
104
|
if (activeConversationId) {
|
|
@@ -115,7 +126,7 @@ export function ChatView() {
|
|
|
115
126
|
<div className="flex-1 flex flex-col min-w-0">
|
|
116
127
|
{activeConversation ? (
|
|
117
128
|
<>
|
|
118
|
-
<ChatHeader conversation={activeConversation} />
|
|
129
|
+
<ChatHeader conversation={activeConversation} model={currentModel} onModelChange={handleModelChange} onModeChange={handleModeChange} />
|
|
119
130
|
<ChatMessages
|
|
120
131
|
messages={messages}
|
|
121
132
|
isStreaming={isStreaming}
|
|
@@ -124,8 +135,6 @@ export function ChatView() {
|
|
|
124
135
|
<ChatInput
|
|
125
136
|
onSend={handleSend}
|
|
126
137
|
onStop={handleStop}
|
|
127
|
-
onModelChange={handleModelChange}
|
|
128
|
-
model={currentModel}
|
|
129
138
|
sending={sendingMessage}
|
|
130
139
|
streaming={isStreaming}
|
|
131
140
|
disabled={false}
|
|
@@ -144,8 +153,6 @@ export function ChatView() {
|
|
|
144
153
|
});
|
|
145
154
|
}}
|
|
146
155
|
onStop={() => {}}
|
|
147
|
-
onModelChange={handleModelChange}
|
|
148
|
-
model={currentModel}
|
|
149
156
|
sending={false}
|
|
150
157
|
streaming={false}
|
|
151
158
|
disabled={false}
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
2
2
|
import { useMemo } from 'react';
|
|
3
|
-
import { Plus, MessageCircle, Pin, Pencil, PinOff, Trash2 } from 'lucide-react';
|
|
3
|
+
import { Plus, MessageCircle, Pin, Pencil, PinOff, Trash2, Zap, Bot } from 'lucide-react';
|
|
4
4
|
import { useGrooveStore } from '../../stores/groove';
|
|
5
5
|
import { cn } from '../../lib/cn';
|
|
6
6
|
import { Badge } from '../ui/badge';
|
|
7
7
|
import { timeAgo } from '../../lib/format';
|
|
8
8
|
import { ContextMenu, ContextMenuTrigger, ContextMenuContent, ContextMenuItem, ContextMenuSeparator } from '../ui/context-menu';
|
|
9
|
+
import { formatModelName } from './model-picker';
|
|
9
10
|
|
|
10
11
|
function groupByDate(conversations) {
|
|
11
12
|
const now = new Date();
|
|
@@ -52,7 +53,11 @@ function ConversationItem({ conv, isActive, onSelect, onRename, onPin, onDelete
|
|
|
52
53
|
<div className="flex-1 min-w-0">
|
|
53
54
|
<div className="text-xs font-medium font-sans truncate">{conv.title || 'New Chat'}</div>
|
|
54
55
|
<div className="flex items-center gap-1.5 mt-0.5">
|
|
55
|
-
{conv.
|
|
56
|
+
{conv.mode === 'agent'
|
|
57
|
+
? <Bot size={9} className="text-purple flex-shrink-0" />
|
|
58
|
+
: <Zap size={9} className="text-accent flex-shrink-0" />
|
|
59
|
+
}
|
|
60
|
+
{conv.model && <Badge variant="default" className="text-[8px] px-1 py-0">{formatModelName(conv.model)}</Badge>}
|
|
56
61
|
<span className="text-2xs text-text-4 font-sans">{timeAgo(conv.updatedAt || conv.createdAt)}</span>
|
|
57
62
|
</div>
|
|
58
63
|
</div>
|
|
@@ -117,17 +122,7 @@ export function ConversationList({ onNewChat }) {
|
|
|
117
122
|
|
|
118
123
|
return (
|
|
119
124
|
<div className="flex flex-col h-full">
|
|
120
|
-
<div className="
|
|
121
|
-
<button
|
|
122
|
-
onClick={onNewChat}
|
|
123
|
-
className="w-full flex items-center justify-center gap-2 h-9 rounded-lg bg-accent/15 text-accent text-xs font-semibold font-sans hover:bg-accent/25 transition-colors cursor-pointer border border-accent/20"
|
|
124
|
-
>
|
|
125
|
-
<Plus size={14} />
|
|
126
|
-
New Chat
|
|
127
|
-
</button>
|
|
128
|
-
</div>
|
|
129
|
-
|
|
130
|
-
<div className="flex-1 overflow-y-auto px-1.5 pb-3 space-y-0.5">
|
|
125
|
+
<div className="flex-1 overflow-y-auto px-1.5 pt-3 pb-3 space-y-0.5">
|
|
131
126
|
{conversations.length === 0 ? (
|
|
132
127
|
<div className="flex flex-col items-center justify-center py-16 text-center px-4">
|
|
133
128
|
<MessageCircle size={24} className="text-text-4 mb-3" />
|
|
@@ -144,6 +139,16 @@ export function ConversationList({ onNewChat }) {
|
|
|
144
139
|
</>
|
|
145
140
|
)}
|
|
146
141
|
</div>
|
|
142
|
+
|
|
143
|
+
<div className="p-3 border-t border-border-subtle">
|
|
144
|
+
<button
|
|
145
|
+
onClick={onNewChat}
|
|
146
|
+
className="w-full flex items-center justify-center gap-2 h-9 rounded-lg bg-accent/15 text-accent text-xs font-semibold font-sans hover:bg-accent/25 transition-colors cursor-pointer border border-accent/20"
|
|
147
|
+
>
|
|
148
|
+
<Plus size={14} />
|
|
149
|
+
New Chat
|
|
150
|
+
</button>
|
|
151
|
+
</div>
|
|
147
152
|
</div>
|
|
148
153
|
);
|
|
149
154
|
}
|
|
@@ -5,6 +5,16 @@ import { useGrooveStore } from '../../stores/groove';
|
|
|
5
5
|
import { cn } from '../../lib/cn';
|
|
6
6
|
import { Badge } from '../ui/badge';
|
|
7
7
|
|
|
8
|
+
export function formatModelName(id) {
|
|
9
|
+
if (!id) return '';
|
|
10
|
+
return id
|
|
11
|
+
.replace(/^claude-/, '')
|
|
12
|
+
.replace(/-(\d)/, ' $1')
|
|
13
|
+
.split('-')
|
|
14
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
15
|
+
.join(' ');
|
|
16
|
+
}
|
|
17
|
+
|
|
8
18
|
const TIER_CONFIG = {
|
|
9
19
|
frontier: { label: 'Frontier', variant: 'purple', icon: Sparkles },
|
|
10
20
|
mid: { label: 'Mid', variant: 'accent', icon: Zap },
|
|
@@ -50,7 +60,8 @@ export function ModelPicker({ value, onChange, disabled }) {
|
|
|
50
60
|
return () => document.removeEventListener('mousedown', handleClick);
|
|
51
61
|
}, [open]);
|
|
52
62
|
|
|
53
|
-
const currentModel = value?.model || '
|
|
63
|
+
const currentModel = value?.model || '';
|
|
64
|
+
const currentModelDisplay = currentModel ? formatModelName(currentModel) : 'Select model';
|
|
54
65
|
const currentProvider = value?.provider || '';
|
|
55
66
|
const isNetwork = currentProvider === 'groove-network';
|
|
56
67
|
|
|
@@ -67,12 +78,12 @@ export function ModelPicker({ value, onChange, disabled }) {
|
|
|
67
78
|
)}
|
|
68
79
|
>
|
|
69
80
|
{isNetwork ? <Globe size={12} className="text-purple" /> : <Cpu size={12} className="text-text-3" />}
|
|
70
|
-
<span className="text-text-1 max-w-[120px] truncate">{
|
|
81
|
+
<span className="text-text-1 max-w-[120px] truncate">{currentModelDisplay}</span>
|
|
71
82
|
<ChevronDown size={12} className="text-text-4" />
|
|
72
83
|
</button>
|
|
73
84
|
|
|
74
85
|
{open && (
|
|
75
|
-
<div className="absolute
|
|
86
|
+
<div className="absolute top-full left-0 mt-1 w-72 max-h-80 overflow-y-auto rounded-lg border border-border bg-surface-1 shadow-xl z-50">
|
|
76
87
|
{providers.length === 0 && (
|
|
77
88
|
<div className="px-4 py-6 text-center text-xs text-text-3 font-sans">No providers available</div>
|
|
78
89
|
)}
|
|
@@ -87,16 +98,17 @@ export function ModelPicker({ value, onChange, disabled }) {
|
|
|
87
98
|
{provider.name || provider.id}
|
|
88
99
|
</div>
|
|
89
100
|
{models.map((model) => {
|
|
90
|
-
const
|
|
91
|
-
const
|
|
101
|
+
const modelId = typeof model === 'string' ? model : model.id || model.name;
|
|
102
|
+
const modelDisplayName = typeof model === 'string' ? model : model.name || model.id;
|
|
103
|
+
const tier = getTier(modelId);
|
|
92
104
|
const tierConfig = TIER_CONFIG[tier];
|
|
93
105
|
const TierIcon = tierConfig.icon;
|
|
94
|
-
const isActive = currentModel ===
|
|
106
|
+
const isActive = currentModel === modelId && currentProvider === provider.id;
|
|
95
107
|
return (
|
|
96
108
|
<button
|
|
97
|
-
key={
|
|
109
|
+
key={modelId}
|
|
98
110
|
onClick={() => {
|
|
99
|
-
onChange({ provider: provider.id, model:
|
|
111
|
+
onChange({ provider: provider.id, model: modelId });
|
|
100
112
|
setOpen(false);
|
|
101
113
|
}}
|
|
102
114
|
className={cn(
|
|
@@ -105,8 +117,8 @@ export function ModelPicker({ value, onChange, disabled }) {
|
|
|
105
117
|
)}
|
|
106
118
|
>
|
|
107
119
|
<div className="flex-1 min-w-0">
|
|
108
|
-
<div className="text-xs font-medium font-sans truncate">{
|
|
109
|
-
<div className="text-2xs text-text-4 font-sans">{getContextSize(
|
|
120
|
+
<div className="text-xs font-medium font-sans truncate">{modelDisplayName}</div>
|
|
121
|
+
<div className="text-2xs text-text-4 font-sans">{getContextSize(modelId)} context</div>
|
|
110
122
|
</div>
|
|
111
123
|
<div className="flex items-center gap-1.5 flex-shrink-0">
|
|
112
124
|
{isNetworkProvider && (
|
|
@@ -799,6 +799,49 @@ export const useGrooveStore = create((set, get) => ({
|
|
|
799
799
|
}
|
|
800
800
|
break;
|
|
801
801
|
}
|
|
802
|
+
|
|
803
|
+
case 'conversation:chunk': {
|
|
804
|
+
const { conversationId, text } = msg.data || msg;
|
|
805
|
+
if (!conversationId || !text) break;
|
|
806
|
+
set((s) => {
|
|
807
|
+
const msgs = { ...s.conversationMessages };
|
|
808
|
+
if (!msgs[conversationId]) msgs[conversationId] = [];
|
|
809
|
+
const arr = [...msgs[conversationId]];
|
|
810
|
+
const last = arr[arr.length - 1];
|
|
811
|
+
if (last && last.from === 'assistant' && (Date.now() - last.timestamp) < 30000) {
|
|
812
|
+
arr[arr.length - 1] = { ...last, text: last.text + text, timestamp: Date.now() };
|
|
813
|
+
} else {
|
|
814
|
+
arr.push({ from: 'assistant', text, timestamp: Date.now() });
|
|
815
|
+
}
|
|
816
|
+
msgs[conversationId] = arr.slice(-200);
|
|
817
|
+
persistJSON('groove:conversationMessages', msgs);
|
|
818
|
+
return { conversationMessages: msgs, streamingConversationId: conversationId };
|
|
819
|
+
});
|
|
820
|
+
break;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
case 'conversation:complete': {
|
|
824
|
+
const { conversationId } = msg.data || msg;
|
|
825
|
+
if (conversationId) {
|
|
826
|
+
set({ sendingMessage: false, streamingConversationId: null });
|
|
827
|
+
persistJSON('groove:conversationMessages', get().conversationMessages);
|
|
828
|
+
}
|
|
829
|
+
break;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
case 'conversation:error': {
|
|
833
|
+
const { conversationId, error } = msg.data || msg;
|
|
834
|
+
if (conversationId) {
|
|
835
|
+
set((s) => {
|
|
836
|
+
const msgs = { ...s.conversationMessages };
|
|
837
|
+
if (!msgs[conversationId]) msgs[conversationId] = [];
|
|
838
|
+
msgs[conversationId] = [...msgs[conversationId], { from: 'system', text: `Error: ${error || 'Unknown error'}`, timestamp: Date.now() }];
|
|
839
|
+
persistJSON('groove:conversationMessages', msgs);
|
|
840
|
+
return { conversationMessages: msgs, sendingMessage: false, streamingConversationId: null };
|
|
841
|
+
});
|
|
842
|
+
}
|
|
843
|
+
break;
|
|
844
|
+
}
|
|
802
845
|
}
|
|
803
846
|
};
|
|
804
847
|
|
|
@@ -1604,11 +1647,11 @@ export const useGrooveStore = create((set, get) => ({
|
|
|
1604
1647
|
} catch { /* endpoint may not exist yet */ }
|
|
1605
1648
|
},
|
|
1606
1649
|
|
|
1607
|
-
async createConversation(provider, model) {
|
|
1650
|
+
async createConversation(provider, model, mode = 'api') {
|
|
1608
1651
|
try {
|
|
1609
|
-
const conv = await api.post('/conversations', { provider, model });
|
|
1652
|
+
const conv = await api.post('/conversations', { provider, model, mode });
|
|
1610
1653
|
set((s) => ({
|
|
1611
|
-
conversations: [conv, ...s.conversations],
|
|
1654
|
+
conversations: [conv, ...s.conversations.filter((c) => c.id !== conv.id)],
|
|
1612
1655
|
activeConversationId: conv.id,
|
|
1613
1656
|
}));
|
|
1614
1657
|
localStorage.setItem('groove:activeConversationId', conv.id);
|
|
@@ -1619,6 +1662,22 @@ export const useGrooveStore = create((set, get) => ({
|
|
|
1619
1662
|
}
|
|
1620
1663
|
},
|
|
1621
1664
|
|
|
1665
|
+
async setConversationMode(id, mode) {
|
|
1666
|
+
try {
|
|
1667
|
+
const conv = await api.patch(`/conversations/${encodeURIComponent(id)}`, { mode });
|
|
1668
|
+
set((s) => ({ conversations: s.conversations.map((c) => c.id === id ? { ...c, ...conv } : c) }));
|
|
1669
|
+
} catch (err) {
|
|
1670
|
+
get().addToast('error', 'Mode change failed', err.message);
|
|
1671
|
+
}
|
|
1672
|
+
},
|
|
1673
|
+
|
|
1674
|
+
async stopChatStreaming(conversationId) {
|
|
1675
|
+
try {
|
|
1676
|
+
await api.post(`/conversations/${encodeURIComponent(conversationId)}/stop`);
|
|
1677
|
+
set({ sendingMessage: false, streamingConversationId: null });
|
|
1678
|
+
} catch { /* ignore */ }
|
|
1679
|
+
},
|
|
1680
|
+
|
|
1622
1681
|
async deleteConversation(id) {
|
|
1623
1682
|
try {
|
|
1624
1683
|
await api.delete(`/conversations/${encodeURIComponent(id)}`);
|
|
@@ -1675,8 +1734,12 @@ export const useGrooveStore = create((set, get) => ({
|
|
|
1675
1734
|
});
|
|
1676
1735
|
|
|
1677
1736
|
try {
|
|
1678
|
-
|
|
1679
|
-
|
|
1737
|
+
const body = { message };
|
|
1738
|
+
if (conv.mode === 'api' || !conv.mode) {
|
|
1739
|
+
const history = get().conversationMessages[conversationId] || [];
|
|
1740
|
+
body.history = history.slice(0, -1);
|
|
1741
|
+
}
|
|
1742
|
+
await api.post(`/conversations/${encodeURIComponent(conversationId)}/message`, body);
|
|
1680
1743
|
} catch (err) {
|
|
1681
1744
|
set((s) => {
|
|
1682
1745
|
const msgs = { ...s.conversationMessages };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "groove-dev",
|
|
3
|
-
"version": "0.27.
|
|
3
|
+
"version": "0.27.57",
|
|
4
4
|
"description": "Open-source agent orchestration layer — the AI company OS. Local model agent engine (GGUF/Ollama/llama-server), HuggingFace model browser, MCP integrations (Slack, Gmail, Stripe, 15+), agent scheduling (cron), business roles (CMO, CFO, EA). GUI dashboard, multi-agent coordination, zero cold-start, infinite sessions. Works with Claude Code, Codex, Gemini CLI, Ollama, any local model.",
|
|
5
5
|
"license": "FSL-1.1-Apache-2.0",
|
|
6
6
|
"author": "Groove Dev <hello@groovedev.ai> (https://groovedev.ai)",
|
|
@@ -818,12 +818,15 @@ export function createApi(app, daemon) {
|
|
|
818
818
|
|
|
819
819
|
app.post('/api/conversations', async (req, res) => {
|
|
820
820
|
try {
|
|
821
|
-
const { provider, model, title } = req.body;
|
|
821
|
+
const { provider, model, title, mode } = req.body;
|
|
822
822
|
if (!provider || typeof provider !== 'string') {
|
|
823
823
|
return res.status(400).json({ error: 'provider is required' });
|
|
824
824
|
}
|
|
825
|
-
|
|
826
|
-
|
|
825
|
+
if (mode && mode !== 'api' && mode !== 'agent') {
|
|
826
|
+
return res.status(400).json({ error: 'mode must be "api" or "agent"' });
|
|
827
|
+
}
|
|
828
|
+
const conversation = await daemon.conversations.create(provider, model, title, mode || 'api');
|
|
829
|
+
daemon.audit.log('conversation.create', { id: conversation.id, provider, model, mode: conversation.mode });
|
|
827
830
|
res.status(201).json(conversation);
|
|
828
831
|
} catch (err) {
|
|
829
832
|
res.status(400).json({ error: err.message });
|
|
@@ -836,14 +839,20 @@ export function createApi(app, daemon) {
|
|
|
836
839
|
res.json(conversation);
|
|
837
840
|
});
|
|
838
841
|
|
|
839
|
-
app.patch('/api/conversations/:id', (req, res) => {
|
|
842
|
+
app.patch('/api/conversations/:id', async (req, res) => {
|
|
840
843
|
try {
|
|
841
844
|
const conv = daemon.conversations.get(req.params.id);
|
|
842
845
|
if (!conv) return res.status(404).json({ error: 'Conversation not found' });
|
|
843
846
|
if (req.body.title !== undefined) daemon.conversations.rename(req.params.id, req.body.title);
|
|
844
847
|
if (req.body.pinned !== undefined) daemon.conversations.pin(req.params.id, req.body.pinned);
|
|
845
848
|
if (req.body.archived !== undefined) daemon.conversations.archive(req.params.id, req.body.archived);
|
|
846
|
-
|
|
849
|
+
if (req.body.mode !== undefined) {
|
|
850
|
+
if (req.body.mode !== 'api' && req.body.mode !== 'agent') {
|
|
851
|
+
return res.status(400).json({ error: 'mode must be "api" or "agent"' });
|
|
852
|
+
}
|
|
853
|
+
await daemon.conversations.setMode(req.params.id, req.body.mode);
|
|
854
|
+
}
|
|
855
|
+
daemon.audit.log('conversation.update', { id: req.params.id, mode: req.body.mode });
|
|
847
856
|
res.json(daemon.conversations.get(req.params.id));
|
|
848
857
|
} catch (err) {
|
|
849
858
|
res.status(400).json({ error: err.message });
|
|
@@ -864,19 +873,27 @@ export function createApi(app, daemon) {
|
|
|
864
873
|
|
|
865
874
|
app.post('/api/conversations/:id/message', async (req, res) => {
|
|
866
875
|
try {
|
|
867
|
-
const { message } = req.body;
|
|
876
|
+
const { message, history } = req.body;
|
|
868
877
|
if (!message || typeof message !== 'string' || !message.trim()) {
|
|
869
878
|
return res.status(400).json({ error: 'message is required' });
|
|
870
879
|
}
|
|
871
880
|
const conv = daemon.conversations.get(req.params.id);
|
|
872
881
|
if (!conv) return res.status(404).json({ error: 'Conversation not found' });
|
|
873
882
|
|
|
874
|
-
const agent = daemon.registry.get(conv.agentId);
|
|
875
|
-
if (!agent) return res.status(400).json({ error: 'Agent no longer exists' });
|
|
876
|
-
|
|
877
883
|
daemon.conversations.autoTitle(req.params.id, message.trim());
|
|
878
884
|
daemon.conversations.touchUpdatedAt(req.params.id);
|
|
879
885
|
|
|
886
|
+
// API mode — lightweight headless streaming, no agent spawned
|
|
887
|
+
if (conv.mode === 'api' || !conv.agentId) {
|
|
888
|
+
await daemon.conversations.sendMessage(req.params.id, message.trim(), history || []);
|
|
889
|
+
daemon.audit.log('conversation.message', { id: req.params.id, mode: 'api' });
|
|
890
|
+
return res.json({ status: 'streaming', mode: 'api' });
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
// Agent mode — existing behavior
|
|
894
|
+
const agent = daemon.registry.get(conv.agentId);
|
|
895
|
+
if (!agent) return res.status(400).json({ error: 'Agent no longer exists' });
|
|
896
|
+
|
|
880
897
|
// Record user feedback for journalist context
|
|
881
898
|
if (daemon.journalist) daemon.journalist.recordUserFeedback(agent, message.trim());
|
|
882
899
|
|
|
@@ -951,6 +968,17 @@ export function createApi(app, daemon) {
|
|
|
951
968
|
}
|
|
952
969
|
});
|
|
953
970
|
|
|
971
|
+
app.post('/api/conversations/:id/stop', (req, res) => {
|
|
972
|
+
try {
|
|
973
|
+
const conv = daemon.conversations.get(req.params.id);
|
|
974
|
+
if (!conv) return res.status(404).json({ error: 'Conversation not found' });
|
|
975
|
+
daemon.conversations.stopStreaming(req.params.id);
|
|
976
|
+
res.json({ ok: true });
|
|
977
|
+
} catch (err) {
|
|
978
|
+
res.status(400).json({ error: err.message });
|
|
979
|
+
}
|
|
980
|
+
});
|
|
981
|
+
|
|
954
982
|
// --- Approvals ---
|
|
955
983
|
|
|
956
984
|
app.get('/api/approvals', (req, res) => {
|