@geminilight/mindos 0.5.19 → 0.5.21
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/app/app/api/ask/route.ts +308 -172
- package/app/app/api/file/route.ts +35 -11
- package/app/app/api/skills/route.ts +22 -3
- package/app/components/SettingsModal.tsx +52 -58
- package/app/components/Sidebar.tsx +21 -1
- package/app/components/settings/AiTab.tsx +4 -25
- package/app/components/settings/AppearanceTab.tsx +31 -13
- package/app/components/settings/KnowledgeTab.tsx +13 -28
- package/app/components/settings/McpAgentInstall.tsx +227 -0
- package/app/components/settings/McpServerStatus.tsx +172 -0
- package/app/components/settings/McpSkillsSection.tsx +583 -0
- package/app/components/settings/McpTab.tsx +16 -728
- package/app/components/settings/PluginsTab.tsx +4 -27
- package/app/components/settings/Primitives.tsx +69 -0
- package/app/components/settings/ShortcutsTab.tsx +2 -4
- package/app/components/settings/SyncTab.tsx +8 -24
- package/app/components/settings/types.ts +116 -2
- package/app/lib/agent/context.ts +151 -87
- package/app/lib/agent/index.ts +4 -3
- package/app/lib/agent/model.ts +76 -10
- package/app/lib/agent/stream-consumer.ts +73 -77
- package/app/lib/agent/to-agent-messages.ts +106 -0
- package/app/lib/agent/tools.ts +260 -266
- package/app/lib/i18n-en.ts +480 -0
- package/app/lib/i18n-zh.ts +505 -0
- package/app/lib/i18n.ts +4 -947
- package/app/next-env.d.ts +1 -1
- package/app/next.config.ts +7 -0
- package/app/package-lock.json +3258 -3093
- package/app/package.json +6 -3
- package/bin/cli.js +140 -5
- package/package.json +4 -1
- package/scripts/setup.js +13 -0
- package/skills/mindos/SKILL.md +10 -168
- package/skills/mindos-zh/SKILL.md +14 -172
- package/templates/skill-rules/en/skill-rules.md +222 -0
- package/templates/skill-rules/en/user-rules.md +20 -0
- package/templates/skill-rules/zh/skill-rules.md +222 -0
- package/templates/skill-rules/zh/user-rules.md +20 -0
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useCallback, useMemo } from 'react';
|
|
4
|
+
import {
|
|
5
|
+
AlertCircle, Loader2, ChevronDown, ChevronRight,
|
|
6
|
+
Trash2, Plus, X, Search, Pencil,
|
|
7
|
+
} from 'lucide-react';
|
|
8
|
+
import { apiFetch } from '@/lib/api';
|
|
9
|
+
import { Toggle } from './Primitives';
|
|
10
|
+
import dynamic from 'next/dynamic';
|
|
11
|
+
import type { SkillInfo, McpSkillsSectionProps } from './types';
|
|
12
|
+
|
|
13
|
+
const MarkdownView = dynamic(() => import('@/components/MarkdownView'), { ssr: false });
|
|
14
|
+
|
|
15
|
+
/* ── Helpers ───────────────────────────────────────────────────── */
|
|
16
|
+
|
|
17
|
+
/** Strip YAML frontmatter (first `---` … `---` block) from markdown content. */
|
|
18
|
+
function stripFrontmatter(content: string): string {
|
|
19
|
+
if (!content.startsWith('---')) return content;
|
|
20
|
+
const end = content.indexOf('\n---', 3);
|
|
21
|
+
if (end === -1) return content;
|
|
22
|
+
return content.slice(end + 4).replace(/^\n+/, '');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const skillFrontmatter = (n: string) => `---
|
|
26
|
+
name: ${n}
|
|
27
|
+
description: >
|
|
28
|
+
Describe WHEN the agent should use this
|
|
29
|
+
skill. Be specific about trigger conditions.
|
|
30
|
+
---`;
|
|
31
|
+
|
|
32
|
+
const SKILL_TEMPLATES: Record<string, (name: string) => string> = {
|
|
33
|
+
general: (n) => `${skillFrontmatter(n)}
|
|
34
|
+
|
|
35
|
+
# Instructions
|
|
36
|
+
|
|
37
|
+
## Context
|
|
38
|
+
<!-- Background knowledge for the agent -->
|
|
39
|
+
|
|
40
|
+
## Steps
|
|
41
|
+
1.
|
|
42
|
+
2.
|
|
43
|
+
|
|
44
|
+
## Rules
|
|
45
|
+
<!-- Constraints, edge cases, formats -->
|
|
46
|
+
- `,
|
|
47
|
+
|
|
48
|
+
'tool-use': (n) => `${skillFrontmatter(n)}
|
|
49
|
+
|
|
50
|
+
# Instructions
|
|
51
|
+
|
|
52
|
+
## Available Tools
|
|
53
|
+
<!-- List tools the agent can use -->
|
|
54
|
+
-
|
|
55
|
+
|
|
56
|
+
## When to Use
|
|
57
|
+
<!-- Conditions that trigger this skill -->
|
|
58
|
+
|
|
59
|
+
## Output Format
|
|
60
|
+
<!-- Expected response structure -->
|
|
61
|
+
`,
|
|
62
|
+
|
|
63
|
+
workflow: (n) => `${skillFrontmatter(n)}
|
|
64
|
+
|
|
65
|
+
# Instructions
|
|
66
|
+
|
|
67
|
+
## Trigger
|
|
68
|
+
<!-- What triggers this workflow -->
|
|
69
|
+
|
|
70
|
+
## Steps
|
|
71
|
+
1.
|
|
72
|
+
2.
|
|
73
|
+
|
|
74
|
+
## Validation
|
|
75
|
+
<!-- How to verify success -->
|
|
76
|
+
|
|
77
|
+
## Rollback
|
|
78
|
+
<!-- What to do on failure -->
|
|
79
|
+
`,
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/* ── Skills Section ────────────────────────────────────────────── */
|
|
83
|
+
|
|
84
|
+
export default function SkillsSection({ t }: McpSkillsSectionProps) {
|
|
85
|
+
const m = t.settings?.mcp;
|
|
86
|
+
const [skills, setSkills] = useState<SkillInfo[]>([]);
|
|
87
|
+
const [loading, setLoading] = useState(true);
|
|
88
|
+
const [expanded, setExpanded] = useState<string | null>(null);
|
|
89
|
+
const [adding, setAdding] = useState(false);
|
|
90
|
+
const [newName, setNewName] = useState('');
|
|
91
|
+
const [newContent, setNewContent] = useState('');
|
|
92
|
+
const [saving, setSaving] = useState(false);
|
|
93
|
+
const [createError, setCreateError] = useState('');
|
|
94
|
+
|
|
95
|
+
const [search, setSearch] = useState('');
|
|
96
|
+
const [builtinCollapsed, setBuiltinCollapsed] = useState(true);
|
|
97
|
+
const [editing, setEditing] = useState<string | null>(null);
|
|
98
|
+
const [editContent, setEditContent] = useState('');
|
|
99
|
+
const [editError, setEditError] = useState('');
|
|
100
|
+
const [fullContent, setFullContent] = useState<Record<string, string>>({});
|
|
101
|
+
const [loadingContent, setLoadingContent] = useState<string | null>(null);
|
|
102
|
+
const [loadErrors, setLoadErrors] = useState<Record<string, string>>({});
|
|
103
|
+
const [selectedTemplate, setSelectedTemplate] = useState<'general' | 'tool-use' | 'workflow'>('general');
|
|
104
|
+
// 🟡 MAJOR #3: Prevent race condition in lang switch
|
|
105
|
+
const [switchingLang, setSwitchingLang] = useState(false);
|
|
106
|
+
|
|
107
|
+
const fetchSkills = useCallback(async () => {
|
|
108
|
+
try {
|
|
109
|
+
const data = await apiFetch<{ skills: SkillInfo[] }>('/api/skills');
|
|
110
|
+
setSkills(data.skills);
|
|
111
|
+
setLoadErrors({}); // Clear errors on success
|
|
112
|
+
} catch (err) {
|
|
113
|
+
const msg = err instanceof Error ? err.message : 'Failed to load skills';
|
|
114
|
+
console.error('fetchSkills error:', msg);
|
|
115
|
+
setLoadErrors(prev => ({ ...prev, _root: msg }));
|
|
116
|
+
// Keep existing skills data rather than clearing
|
|
117
|
+
}
|
|
118
|
+
setLoading(false);
|
|
119
|
+
}, []);
|
|
120
|
+
|
|
121
|
+
useEffect(() => { fetchSkills(); }, [fetchSkills]);
|
|
122
|
+
|
|
123
|
+
// Filtered + grouped
|
|
124
|
+
const filtered = useMemo(() => {
|
|
125
|
+
if (!search) return skills;
|
|
126
|
+
const q = search.toLowerCase();
|
|
127
|
+
return skills.filter(s => s.name.toLowerCase().includes(q) || s.description.toLowerCase().includes(q));
|
|
128
|
+
}, [skills, search]);
|
|
129
|
+
|
|
130
|
+
const customSkills = useMemo(() => filtered.filter(s => s.source === 'user'), [filtered]);
|
|
131
|
+
const builtinSkills = useMemo(() => filtered.filter(s => s.source === 'builtin'), [filtered]);
|
|
132
|
+
|
|
133
|
+
const handleToggle = async (name: string, enabled: boolean) => {
|
|
134
|
+
try {
|
|
135
|
+
await apiFetch('/api/skills', {
|
|
136
|
+
method: 'POST',
|
|
137
|
+
headers: { 'Content-Type': 'application/json' },
|
|
138
|
+
body: JSON.stringify({ action: 'toggle', name, enabled }),
|
|
139
|
+
});
|
|
140
|
+
setSkills(prev => prev.map(s => s.name === name ? { ...s, enabled } : s));
|
|
141
|
+
setLoadErrors(prev => { const next = { ...prev }; delete next[name]; return next; });
|
|
142
|
+
} catch (err) {
|
|
143
|
+
const msg = err instanceof Error ? err.message : 'Failed to toggle skill';
|
|
144
|
+
console.error('handleToggle error:', msg);
|
|
145
|
+
setLoadErrors(prev => ({ ...prev, [name]: msg }));
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const handleDelete = async (name: string) => {
|
|
150
|
+
const confirmMsg = m?.skillDeleteConfirm ? m.skillDeleteConfirm(name) : `Delete skill "${name}"?`;
|
|
151
|
+
if (!confirm(confirmMsg)) return;
|
|
152
|
+
try {
|
|
153
|
+
await apiFetch('/api/skills', {
|
|
154
|
+
method: 'POST',
|
|
155
|
+
headers: { 'Content-Type': 'application/json' },
|
|
156
|
+
body: JSON.stringify({ action: 'delete', name }),
|
|
157
|
+
});
|
|
158
|
+
setFullContent(prev => { const n = { ...prev }; delete n[name]; return n; });
|
|
159
|
+
if (editing === name) setEditing(null);
|
|
160
|
+
if (expanded === name) setExpanded(null);
|
|
161
|
+
setLoadErrors(prev => { const next = { ...prev }; delete next[name]; return next; });
|
|
162
|
+
fetchSkills();
|
|
163
|
+
} catch (err) {
|
|
164
|
+
const msg = err instanceof Error ? err.message : 'Failed to delete skill';
|
|
165
|
+
console.error('handleDelete error:', msg);
|
|
166
|
+
setLoadErrors(prev => ({ ...prev, [name]: msg }));
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const loadFullContent = async (name: string) => {
|
|
171
|
+
if (fullContent[name]) return;
|
|
172
|
+
setLoadingContent(name);
|
|
173
|
+
try {
|
|
174
|
+
const data = await apiFetch<{ content: string }>('/api/skills', {
|
|
175
|
+
method: 'POST',
|
|
176
|
+
headers: { 'Content-Type': 'application/json' },
|
|
177
|
+
body: JSON.stringify({ action: 'read', name }),
|
|
178
|
+
});
|
|
179
|
+
setFullContent(prev => ({ ...prev, [name]: data.content }));
|
|
180
|
+
setLoadErrors(prev => { const n = { ...prev }; delete n[name]; return n; });
|
|
181
|
+
} catch (err: unknown) {
|
|
182
|
+
setLoadErrors(prev => ({ ...prev, [name]: err instanceof Error ? err.message : 'Failed to load skill content' }));
|
|
183
|
+
} finally {
|
|
184
|
+
setLoadingContent(null);
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const handleExpand = (name: string) => {
|
|
189
|
+
const next = expanded === name ? null : name;
|
|
190
|
+
setExpanded(next);
|
|
191
|
+
if (next) {
|
|
192
|
+
loadFullContent(name);
|
|
193
|
+
}
|
|
194
|
+
if (editing && editing !== name) setEditing(null);
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const handleEditStart = (name: string) => {
|
|
198
|
+
setEditing(name);
|
|
199
|
+
setEditContent(fullContent[name] || '');
|
|
200
|
+
setEditError('');
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const handleEditSave = async (name: string) => {
|
|
204
|
+
setSaving(true);
|
|
205
|
+
setEditError('');
|
|
206
|
+
try {
|
|
207
|
+
await apiFetch('/api/skills', {
|
|
208
|
+
method: 'POST',
|
|
209
|
+
headers: { 'Content-Type': 'application/json' },
|
|
210
|
+
body: JSON.stringify({ action: 'update', name, content: editContent }),
|
|
211
|
+
});
|
|
212
|
+
setFullContent(prev => ({ ...prev, [name]: editContent }));
|
|
213
|
+
setEditing(null);
|
|
214
|
+
fetchSkills(); // refresh description from updated frontmatter
|
|
215
|
+
} catch (err: unknown) {
|
|
216
|
+
setEditError(err instanceof Error ? err.message : 'Failed to save skill');
|
|
217
|
+
} finally {
|
|
218
|
+
setSaving(false);
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const handleEditCancel = () => {
|
|
223
|
+
setEditing(null);
|
|
224
|
+
setEditContent('');
|
|
225
|
+
setEditError('');
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const getTemplate = (skillName: string, tmpl?: 'general' | 'tool-use' | 'workflow') => {
|
|
229
|
+
const key = tmpl || selectedTemplate;
|
|
230
|
+
const fn = SKILL_TEMPLATES[key] || SKILL_TEMPLATES.general;
|
|
231
|
+
return fn(skillName || 'my-skill');
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const handleCreate = async () => {
|
|
235
|
+
if (!newName.trim()) return;
|
|
236
|
+
setSaving(true);
|
|
237
|
+
setCreateError('');
|
|
238
|
+
try {
|
|
239
|
+
// Content is the full SKILL.md (with frontmatter)
|
|
240
|
+
const content = newContent || getTemplate(newName.trim());
|
|
241
|
+
await apiFetch('/api/skills', {
|
|
242
|
+
method: 'POST',
|
|
243
|
+
headers: { 'Content-Type': 'application/json' },
|
|
244
|
+
body: JSON.stringify({ action: 'create', name: newName.trim(), content }),
|
|
245
|
+
});
|
|
246
|
+
setAdding(false);
|
|
247
|
+
setNewName('');
|
|
248
|
+
setNewContent('');
|
|
249
|
+
fetchSkills();
|
|
250
|
+
} catch (err: unknown) {
|
|
251
|
+
setCreateError(err instanceof Error ? err.message : 'Failed to create skill');
|
|
252
|
+
} finally {
|
|
253
|
+
setSaving(false);
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
// Sync template name when newName changes (only if content matches a template)
|
|
258
|
+
const handleNameChange = (val: string) => {
|
|
259
|
+
const cleaned = val.toLowerCase().replace(/[^a-z0-9-]/g, '');
|
|
260
|
+
const oldTemplate = getTemplate(newName || 'my-skill');
|
|
261
|
+
if (!newContent || newContent === oldTemplate) {
|
|
262
|
+
setNewContent(getTemplate(cleaned || 'my-skill'));
|
|
263
|
+
}
|
|
264
|
+
setNewName(cleaned);
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const handleTemplateChange = (tmpl: 'general' | 'tool-use' | 'workflow') => {
|
|
268
|
+
const oldTemplate = getTemplate(newName || 'my-skill', selectedTemplate);
|
|
269
|
+
setSelectedTemplate(tmpl);
|
|
270
|
+
// Only replace content if it matches the old template (user hasn't customized)
|
|
271
|
+
if (!newContent || newContent === oldTemplate) {
|
|
272
|
+
setNewContent(getTemplate(newName || 'my-skill', tmpl));
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
if (loading) {
|
|
277
|
+
return (
|
|
278
|
+
<div className="flex justify-center py-4">
|
|
279
|
+
<Loader2 size={16} className="animate-spin text-muted-foreground" />
|
|
280
|
+
</div>
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const renderSkillRow = (skill: SkillInfo) => (
|
|
285
|
+
<div key={skill.name} className="border border-border rounded-lg overflow-hidden">
|
|
286
|
+
<div
|
|
287
|
+
className="flex items-center gap-2 px-3 py-2 cursor-pointer hover:bg-muted/50 transition-colors"
|
|
288
|
+
onClick={() => handleExpand(skill.name)}
|
|
289
|
+
>
|
|
290
|
+
{expanded === skill.name ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
|
291
|
+
<span className="text-xs font-medium flex-1">{skill.name}</span>
|
|
292
|
+
<span className={`text-2xs px-1.5 py-0.5 rounded ${
|
|
293
|
+
skill.source === 'builtin' ? 'bg-blue-500/15 text-blue-500' : 'bg-purple-500/15 text-purple-500'
|
|
294
|
+
}`}>
|
|
295
|
+
{skill.source === 'builtin' ? (m?.skillBuiltin ?? 'Built-in') : (m?.skillUser ?? 'Custom')}
|
|
296
|
+
</span>
|
|
297
|
+
<Toggle size="sm" checked={skill.enabled} onClick={e => { e.stopPropagation(); handleToggle(skill.name, !skill.enabled); }} />
|
|
298
|
+
</div>
|
|
299
|
+
|
|
300
|
+
{expanded === skill.name && (
|
|
301
|
+
<div className="px-3 py-2 border-t border-border text-xs space-y-2 bg-muted/20">
|
|
302
|
+
<p className="text-muted-foreground">{skill.description || 'No description'}</p>
|
|
303
|
+
<p className="text-muted-foreground font-mono text-2xs">{skill.path}</p>
|
|
304
|
+
|
|
305
|
+
{/* Full content display / edit */}
|
|
306
|
+
{loadingContent === skill.name ? (
|
|
307
|
+
<div className="flex items-center gap-1.5 text-muted-foreground">
|
|
308
|
+
<Loader2 size={10} className="animate-spin" />
|
|
309
|
+
<span className="text-2xs">Loading...</span>
|
|
310
|
+
</div>
|
|
311
|
+
) : fullContent[skill.name] ? (
|
|
312
|
+
<div className="space-y-1.5">
|
|
313
|
+
<div className="flex items-center justify-between">
|
|
314
|
+
<span className="text-2xs text-muted-foreground font-medium">{m?.skillContent ?? 'Content'}</span>
|
|
315
|
+
<div className="flex items-center gap-2">
|
|
316
|
+
{skill.editable && editing !== skill.name && (
|
|
317
|
+
<button
|
|
318
|
+
onClick={() => handleEditStart(skill.name)}
|
|
319
|
+
className="flex items-center gap-1 text-2xs text-muted-foreground hover:text-foreground transition-colors"
|
|
320
|
+
>
|
|
321
|
+
<Pencil size={10} />
|
|
322
|
+
{m?.editSkill ?? 'Edit'}
|
|
323
|
+
</button>
|
|
324
|
+
)}
|
|
325
|
+
{skill.editable && (
|
|
326
|
+
<button
|
|
327
|
+
onClick={() => handleDelete(skill.name)}
|
|
328
|
+
className="flex items-center gap-1 text-2xs text-destructive hover:underline"
|
|
329
|
+
>
|
|
330
|
+
<Trash2 size={10} />
|
|
331
|
+
{m?.deleteSkill ?? 'Delete'}
|
|
332
|
+
</button>
|
|
333
|
+
)}
|
|
334
|
+
</div>
|
|
335
|
+
</div>
|
|
336
|
+
|
|
337
|
+
{editing === skill.name ? (
|
|
338
|
+
<div className="space-y-1.5">
|
|
339
|
+
<textarea
|
|
340
|
+
value={editContent}
|
|
341
|
+
onChange={e => setEditContent(e.target.value)}
|
|
342
|
+
rows={Math.min(20, (editContent.match(/\n/g) || []).length + 3)}
|
|
343
|
+
className="w-full px-2.5 py-1.5 text-xs rounded-md border border-border bg-background text-foreground outline-none focus-visible:ring-1 focus-visible:ring-ring resize-y font-mono"
|
|
344
|
+
/>
|
|
345
|
+
<div className="flex items-center gap-2">
|
|
346
|
+
<button
|
|
347
|
+
onClick={() => handleEditSave(skill.name)}
|
|
348
|
+
disabled={saving}
|
|
349
|
+
className="flex items-center gap-1 px-2.5 py-1 text-xs rounded-md disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
|
350
|
+
style={{ background: 'var(--amber)', color: 'var(--amber-foreground)' }}
|
|
351
|
+
>
|
|
352
|
+
{saving && <Loader2 size={10} className="animate-spin" />}
|
|
353
|
+
{m?.saveSkill ?? 'Save'}
|
|
354
|
+
</button>
|
|
355
|
+
<button
|
|
356
|
+
onClick={handleEditCancel}
|
|
357
|
+
className="px-2.5 py-1 text-xs rounded-md border border-border text-muted-foreground hover:text-foreground transition-colors"
|
|
358
|
+
>
|
|
359
|
+
{m?.cancelSkill ?? 'Cancel'}
|
|
360
|
+
</button>
|
|
361
|
+
</div>
|
|
362
|
+
{editError && editing === skill.name && (
|
|
363
|
+
<p className="text-2xs text-destructive flex items-center gap-1">
|
|
364
|
+
<AlertCircle size={10} />
|
|
365
|
+
{editError}
|
|
366
|
+
</p>
|
|
367
|
+
)}
|
|
368
|
+
</div>
|
|
369
|
+
) : (
|
|
370
|
+
<div className="w-full rounded-md border border-border bg-background/50 max-h-[300px] overflow-y-auto px-2.5 py-1.5 text-xs [&_.prose]:max-w-none [&_.prose]:text-xs [&_h1]:text-sm [&_h2]:text-xs [&_h3]:text-xs [&_pre]:text-2xs [&_code]:text-2xs">
|
|
371
|
+
<MarkdownView content={stripFrontmatter(fullContent[skill.name])} />
|
|
372
|
+
</div>
|
|
373
|
+
)}
|
|
374
|
+
</div>
|
|
375
|
+
) : loadErrors[skill.name] ? (
|
|
376
|
+
<p className="text-2xs text-destructive flex items-center gap-1">
|
|
377
|
+
<AlertCircle size={10} />
|
|
378
|
+
{loadErrors[skill.name]}
|
|
379
|
+
</p>
|
|
380
|
+
) : null}
|
|
381
|
+
</div>
|
|
382
|
+
)}
|
|
383
|
+
</div>
|
|
384
|
+
);
|
|
385
|
+
|
|
386
|
+
return (
|
|
387
|
+
<div className="space-y-3 pt-2">
|
|
388
|
+
{/* Search */}
|
|
389
|
+
<div className="relative">
|
|
390
|
+
<Search size={12} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
|
391
|
+
<input
|
|
392
|
+
type="text"
|
|
393
|
+
value={search}
|
|
394
|
+
onChange={e => setSearch(e.target.value)}
|
|
395
|
+
placeholder={m?.searchSkills ?? 'Search skills...'}
|
|
396
|
+
className="w-full pl-7 pr-2.5 py-1.5 text-xs rounded-md border border-border bg-background text-foreground outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
397
|
+
/>
|
|
398
|
+
{search && (
|
|
399
|
+
<button
|
|
400
|
+
onClick={() => setSearch('')}
|
|
401
|
+
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
|
402
|
+
>
|
|
403
|
+
<X size={10} />
|
|
404
|
+
</button>
|
|
405
|
+
)}
|
|
406
|
+
</div>
|
|
407
|
+
|
|
408
|
+
{/* Skill language switcher */}
|
|
409
|
+
{(() => {
|
|
410
|
+
const mindosEnabled = skills.find(s => s.name === 'mindos')?.enabled ?? true;
|
|
411
|
+
const currentLang = mindosEnabled ? 'en' : 'zh';
|
|
412
|
+
const handleLangSwitch = async (lang: 'en' | 'zh') => {
|
|
413
|
+
if (lang === currentLang || switchingLang) return;
|
|
414
|
+
setSwitchingLang(true);
|
|
415
|
+
try {
|
|
416
|
+
if (lang === 'en') {
|
|
417
|
+
// Sequential to ensure both complete or both revert on failure
|
|
418
|
+
await handleToggle('mindos', true);
|
|
419
|
+
await handleToggle('mindos-zh', false);
|
|
420
|
+
} else {
|
|
421
|
+
await handleToggle('mindos-zh', true);
|
|
422
|
+
await handleToggle('mindos', false);
|
|
423
|
+
}
|
|
424
|
+
} catch (err) {
|
|
425
|
+
console.error('Lang switch failed:', err);
|
|
426
|
+
// Errors are already set by handleToggle; no further action needed
|
|
427
|
+
} finally {
|
|
428
|
+
setSwitchingLang(false);
|
|
429
|
+
}
|
|
430
|
+
};
|
|
431
|
+
return (
|
|
432
|
+
<div className="flex items-center gap-2 text-xs">
|
|
433
|
+
<span className="text-muted-foreground">{m?.skillLanguage ?? 'Skill Language'}</span>
|
|
434
|
+
<div className="flex rounded-md border border-border overflow-hidden">
|
|
435
|
+
<button
|
|
436
|
+
onClick={() => handleLangSwitch('en')}
|
|
437
|
+
disabled={switchingLang}
|
|
438
|
+
className={`px-2.5 py-1 text-xs transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
|
|
439
|
+
currentLang === 'en'
|
|
440
|
+
? 'bg-amber-500/15 text-amber-600 font-medium'
|
|
441
|
+
: 'text-muted-foreground hover:bg-muted'
|
|
442
|
+
}`}
|
|
443
|
+
>
|
|
444
|
+
{m?.skillLangEn ?? 'English'}
|
|
445
|
+
</button>
|
|
446
|
+
<button
|
|
447
|
+
onClick={() => handleLangSwitch('zh')}
|
|
448
|
+
disabled={switchingLang}
|
|
449
|
+
className={`px-2.5 py-1 text-xs transition-colors disabled:opacity-50 disabled:cursor-not-allowed border-l border-border ${
|
|
450
|
+
currentLang === 'zh'
|
|
451
|
+
? 'bg-amber-500/15 text-amber-600 font-medium'
|
|
452
|
+
: 'text-muted-foreground hover:bg-muted'
|
|
453
|
+
}`}
|
|
454
|
+
>
|
|
455
|
+
{m?.skillLangZh ?? '中文'}
|
|
456
|
+
</button>
|
|
457
|
+
</div>
|
|
458
|
+
</div>
|
|
459
|
+
);
|
|
460
|
+
})()}
|
|
461
|
+
|
|
462
|
+
{/* Empty search result */}
|
|
463
|
+
{filtered.length === 0 && search && (
|
|
464
|
+
<p className="text-xs text-muted-foreground text-center py-3">
|
|
465
|
+
{m?.noSkillsMatch ? m.noSkillsMatch(search) : `No skills match "${search}"`}
|
|
466
|
+
</p>
|
|
467
|
+
)}
|
|
468
|
+
|
|
469
|
+
{/* Custom group — always open */}
|
|
470
|
+
{customSkills.length > 0 && (
|
|
471
|
+
<div className="space-y-1.5">
|
|
472
|
+
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground">
|
|
473
|
+
<span>{m?.customGroup ?? 'Custom'} ({customSkills.length})</span>
|
|
474
|
+
</div>
|
|
475
|
+
<div className="space-y-1.5">
|
|
476
|
+
{customSkills.map(renderSkillRow)}
|
|
477
|
+
</div>
|
|
478
|
+
</div>
|
|
479
|
+
)}
|
|
480
|
+
|
|
481
|
+
{/* Built-in group — collapsible, default collapsed */}
|
|
482
|
+
{builtinSkills.length > 0 && (
|
|
483
|
+
<div className="space-y-1.5">
|
|
484
|
+
<div
|
|
485
|
+
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
|
|
486
|
+
onClick={() => setBuiltinCollapsed(!builtinCollapsed)}
|
|
487
|
+
>
|
|
488
|
+
{builtinCollapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
|
|
489
|
+
<span>{m?.builtinGroup ?? 'Built-in'} ({builtinSkills.length})</span>
|
|
490
|
+
</div>
|
|
491
|
+
{!builtinCollapsed && (
|
|
492
|
+
<div className="space-y-1.5">
|
|
493
|
+
{builtinSkills.map(renderSkillRow)}
|
|
494
|
+
</div>
|
|
495
|
+
)}
|
|
496
|
+
</div>
|
|
497
|
+
)}
|
|
498
|
+
|
|
499
|
+
{/* Add skill form — template-based */}
|
|
500
|
+
{adding ? (
|
|
501
|
+
<div className="border border-border rounded-lg p-3 space-y-2">
|
|
502
|
+
<div className="flex items-center justify-between">
|
|
503
|
+
<span className="text-xs font-medium">{m?.addSkill ?? '+ Add Skill'}</span>
|
|
504
|
+
<button onClick={() => { setAdding(false); setNewName(''); setNewContent(''); setCreateError(''); }} className="p-0.5 rounded hover:bg-muted text-muted-foreground">
|
|
505
|
+
<X size={12} />
|
|
506
|
+
</button>
|
|
507
|
+
</div>
|
|
508
|
+
<div className="space-y-1">
|
|
509
|
+
<label className="text-2xs text-muted-foreground">{m?.skillName ?? 'Name'}</label>
|
|
510
|
+
<input
|
|
511
|
+
type="text"
|
|
512
|
+
value={newName}
|
|
513
|
+
onChange={e => handleNameChange(e.target.value)}
|
|
514
|
+
placeholder="my-skill"
|
|
515
|
+
className="w-full px-2.5 py-1.5 text-xs rounded-md border border-border bg-background font-mono text-foreground outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
516
|
+
/>
|
|
517
|
+
</div>
|
|
518
|
+
<div className="space-y-1">
|
|
519
|
+
<label className="text-2xs text-muted-foreground">{m?.skillTemplate ?? 'Template'}</label>
|
|
520
|
+
<div className="flex rounded-md border border-border overflow-hidden w-fit">
|
|
521
|
+
{(['general', 'tool-use', 'workflow'] as const).map((tmpl, i) => (
|
|
522
|
+
<button
|
|
523
|
+
key={tmpl}
|
|
524
|
+
onClick={() => handleTemplateChange(tmpl)}
|
|
525
|
+
className={`px-2.5 py-1 text-xs transition-colors ${i > 0 ? 'border-l border-border' : ''} ${
|
|
526
|
+
selectedTemplate === tmpl
|
|
527
|
+
? 'bg-amber-500/15 text-amber-600 font-medium'
|
|
528
|
+
: 'text-muted-foreground hover:bg-muted'
|
|
529
|
+
}`}
|
|
530
|
+
>
|
|
531
|
+
{tmpl === 'general' ? (m?.skillTemplateGeneral ?? 'General')
|
|
532
|
+
: tmpl === 'tool-use' ? (m?.skillTemplateToolUse ?? 'Tool-use')
|
|
533
|
+
: (m?.skillTemplateWorkflow ?? 'Workflow')}
|
|
534
|
+
</button>
|
|
535
|
+
))}
|
|
536
|
+
</div>
|
|
537
|
+
</div>
|
|
538
|
+
<div className="space-y-1">
|
|
539
|
+
<label className="text-2xs text-muted-foreground">{m?.skillContent ?? 'Content'}</label>
|
|
540
|
+
<textarea
|
|
541
|
+
value={newContent}
|
|
542
|
+
onChange={e => setNewContent(e.target.value)}
|
|
543
|
+
rows={16}
|
|
544
|
+
placeholder="Skill instructions (markdown)..."
|
|
545
|
+
className="w-full px-2.5 py-1.5 text-xs rounded-md border border-border bg-background text-foreground outline-none focus-visible:ring-1 focus-visible:ring-ring resize-y font-mono"
|
|
546
|
+
/>
|
|
547
|
+
</div>
|
|
548
|
+
{createError && (
|
|
549
|
+
<p className="text-2xs text-destructive flex items-center gap-1">
|
|
550
|
+
<AlertCircle size={10} />
|
|
551
|
+
{createError}
|
|
552
|
+
</p>
|
|
553
|
+
)}
|
|
554
|
+
<div className="flex items-center gap-2">
|
|
555
|
+
<button
|
|
556
|
+
onClick={handleCreate}
|
|
557
|
+
disabled={!newName.trim() || saving}
|
|
558
|
+
className="flex items-center gap-1 px-2.5 py-1 text-xs rounded-md disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
|
559
|
+
style={{ background: 'var(--amber)', color: 'var(--amber-foreground)' }}
|
|
560
|
+
>
|
|
561
|
+
{saving && <Loader2 size={10} className="animate-spin" />}
|
|
562
|
+
{m?.saveSkill ?? 'Save'}
|
|
563
|
+
</button>
|
|
564
|
+
<button
|
|
565
|
+
onClick={() => { setAdding(false); setNewName(''); setNewContent(''); setCreateError(''); }}
|
|
566
|
+
className="px-2.5 py-1 text-xs rounded-md border border-border text-muted-foreground hover:text-foreground transition-colors"
|
|
567
|
+
>
|
|
568
|
+
{m?.cancelSkill ?? 'Cancel'}
|
|
569
|
+
</button>
|
|
570
|
+
</div>
|
|
571
|
+
</div>
|
|
572
|
+
) : (
|
|
573
|
+
<button
|
|
574
|
+
onClick={() => { setAdding(true); setSelectedTemplate('general'); setNewContent(getTemplate('my-skill', 'general')); }}
|
|
575
|
+
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
|
576
|
+
>
|
|
577
|
+
<Plus size={12} />
|
|
578
|
+
{m?.addSkill ?? '+ Add Skill'}
|
|
579
|
+
</button>
|
|
580
|
+
)}
|
|
581
|
+
</div>
|
|
582
|
+
);
|
|
583
|
+
}
|