@geminilight/mindos 0.5.69 → 0.5.70
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/file/import/route.ts +197 -0
- package/app/components/ActivityBar.tsx +3 -4
- package/app/components/FileTree.tsx +35 -9
- package/app/components/ImportModal.tsx +415 -0
- package/app/components/OnboardingView.tsx +9 -0
- package/app/components/Panel.tsx +4 -2
- package/app/components/SidebarLayout.tsx +83 -8
- package/app/components/TableOfContents.tsx +1 -0
- package/app/components/agents/AgentDetailContent.tsx +37 -28
- package/app/components/agents/AgentsMcpSection.tsx +16 -12
- package/app/components/agents/AgentsOverviewSection.tsx +48 -34
- package/app/components/agents/AgentsPrimitives.tsx +41 -20
- package/app/components/agents/AgentsSkillsSection.tsx +16 -7
- package/app/components/agents/SkillDetailPopover.tsx +11 -11
- package/app/components/ask/AskContent.tsx +11 -0
- package/app/components/panels/AgentsPanelAgentGroups.tsx +8 -6
- package/app/components/panels/AgentsPanelHubNav.tsx +3 -3
- package/app/components/panels/DiscoverPanel.tsx +88 -2
- package/app/hooks/useFileImport.ts +191 -0
- package/app/hooks/useFileUpload.ts +11 -0
- package/app/lib/agent/tools.ts +146 -0
- package/app/lib/core/file-convert.ts +97 -0
- package/app/lib/core/organize.ts +105 -0
- package/app/lib/i18n-en.ts +51 -0
- package/app/lib/i18n-zh.ts +51 -0
- package/package.json +1 -1
|
@@ -177,6 +177,17 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
|
|
|
177
177
|
const upload = useFileUpload();
|
|
178
178
|
const mention = useMention();
|
|
179
179
|
|
|
180
|
+
useEffect(() => {
|
|
181
|
+
const handler = (e: Event) => {
|
|
182
|
+
const files = (e as CustomEvent).detail?.files;
|
|
183
|
+
if (Array.isArray(files) && files.length > 0) {
|
|
184
|
+
upload.injectFiles(files);
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
window.addEventListener('mindos:inject-ask-files', handler);
|
|
188
|
+
return () => window.removeEventListener('mindos:inject-ask-files', handler);
|
|
189
|
+
}, [upload]);
|
|
190
|
+
|
|
180
191
|
// Focus and init session when becoming visible (edge-triggered for panel, level-triggered for modal)
|
|
181
192
|
const prevVisibleRef = useRef(false);
|
|
182
193
|
useEffect(() => {
|
|
@@ -34,13 +34,14 @@ export function AgentsPanelAgentGroups({
|
|
|
34
34
|
}) {
|
|
35
35
|
return (
|
|
36
36
|
<div>
|
|
37
|
-
<div className="px-0 py-1 mb-
|
|
38
|
-
<span className="text-2xs font-semibold text-muted-foreground uppercase tracking-wider">{p.rosterLabel}</span>
|
|
37
|
+
<div className="px-0 py-1.5 mb-1">
|
|
38
|
+
<span className="text-2xs font-semibold text-muted-foreground/70 uppercase tracking-wider">{p.rosterLabel}</span>
|
|
39
39
|
</div>
|
|
40
40
|
{connected.length > 0 && (
|
|
41
41
|
<section className="mb-3">
|
|
42
|
-
<h3 className="text-[11px] font-medium text-muted-foreground/
|
|
43
|
-
|
|
42
|
+
<h3 className="flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground/80 uppercase tracking-wider mb-2 pl-0.5">
|
|
43
|
+
<span className="w-1 h-3 rounded-full bg-[var(--success)]/50" aria-hidden="true" />
|
|
44
|
+
{p.sectionConnected} <span className="text-muted-foreground/50 tabular-nums">({connected.length})</span>
|
|
44
45
|
</h3>
|
|
45
46
|
<div className="space-y-1.5">
|
|
46
47
|
{connected.map(agent => (
|
|
@@ -60,8 +61,9 @@ export function AgentsPanelAgentGroups({
|
|
|
60
61
|
|
|
61
62
|
{detected.length > 0 && (
|
|
62
63
|
<section className="mb-3">
|
|
63
|
-
<h3 className="text-[11px] font-medium text-muted-foreground/
|
|
64
|
-
|
|
64
|
+
<h3 className="flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground/80 uppercase tracking-wider mb-2 pl-0.5">
|
|
65
|
+
<span className="w-1 h-3 rounded-full bg-[var(--amber)]/50" aria-hidden="true" />
|
|
66
|
+
{p.sectionDetected} <span className="text-muted-foreground/50 tabular-nums">({detected.length})</span>
|
|
65
67
|
</h3>
|
|
66
68
|
<div className="space-y-1.5">
|
|
67
69
|
{detected.map(agent => (
|
|
@@ -27,18 +27,18 @@ export function AgentsPanelHubNav({
|
|
|
27
27
|
<PanelNavRow
|
|
28
28
|
icon={<LayoutDashboard size={14} className="text-[var(--amber)]" />}
|
|
29
29
|
title={copy.navOverview}
|
|
30
|
-
badge={<span className="text-2xs tabular-nums text-muted-foreground">{connectedCount}</span>}
|
|
30
|
+
badge={<span className="text-2xs tabular-nums text-muted-foreground/60 px-1.5 py-0.5 rounded bg-muted/40 font-medium">{connectedCount}</span>}
|
|
31
31
|
href="/agents"
|
|
32
32
|
active={inAgentsRoute && (tab === null || tab === 'overview')}
|
|
33
33
|
/>
|
|
34
34
|
<PanelNavRow
|
|
35
|
-
icon={<Server size={14} className=
|
|
35
|
+
icon={<Server size={14} className={inAgentsRoute && tab === 'mcp' ? 'text-[var(--amber)]' : 'text-muted-foreground'} />}
|
|
36
36
|
title={copy.navMcp}
|
|
37
37
|
href="/agents?tab=mcp"
|
|
38
38
|
active={inAgentsRoute && tab === 'mcp'}
|
|
39
39
|
/>
|
|
40
40
|
<PanelNavRow
|
|
41
|
-
icon={<Zap size={14} className=
|
|
41
|
+
icon={<Zap size={14} className={inAgentsRoute && tab === 'skills' ? 'text-[var(--amber)]' : 'text-muted-foreground'} />}
|
|
42
42
|
title={copy.navSkills}
|
|
43
43
|
href="/agents?tab=skills"
|
|
44
44
|
active={inAgentsRoute && tab === 'skills'}
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
4
|
+
import { useRouter } from 'next/navigation';
|
|
5
|
+
import { Lightbulb, Blocks, Zap, LayoutTemplate, User, Download, RefreshCw, Repeat, Rocket, Search, Handshake, ShieldCheck, ChevronDown } from 'lucide-react';
|
|
4
6
|
import PanelHeader from './PanelHeader';
|
|
5
7
|
import { PanelNavRow, ComingSoonBadge } from './PanelNavRow';
|
|
6
8
|
import { useLocale } from '@/lib/LocaleContext';
|
|
7
9
|
import { useCases } from '@/components/explore/use-cases';
|
|
8
10
|
import { openAskModal } from '@/hooks/useAskModal';
|
|
11
|
+
import { getPluginRenderers, isRendererEnabled, setRendererEnabled, loadDisabledState } from '@/lib/renderers/registry';
|
|
12
|
+
import { Toggle } from '../settings/Primitives';
|
|
9
13
|
|
|
10
14
|
interface DiscoverPanelProps {
|
|
11
15
|
active: boolean;
|
|
@@ -56,8 +60,44 @@ export default function DiscoverPanel({ active, maximized, onMaximize }: Discove
|
|
|
56
60
|
const { t } = useLocale();
|
|
57
61
|
const d = t.panels.discover;
|
|
58
62
|
const e = t.explore;
|
|
63
|
+
const p = t.panels.plugins;
|
|
64
|
+
const router = useRouter();
|
|
65
|
+
|
|
66
|
+
const [pluginsMounted, setPluginsMounted] = useState(false);
|
|
67
|
+
const [showPlugins, setShowPlugins] = useState(false);
|
|
68
|
+
const [, forceUpdate] = useState(0);
|
|
69
|
+
const [existingFiles, setExistingFiles] = useState<Set<string>>(new Set());
|
|
70
|
+
const fetchedRef = useRef(false);
|
|
71
|
+
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
loadDisabledState();
|
|
74
|
+
setPluginsMounted(true);
|
|
75
|
+
}, []);
|
|
76
|
+
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
if (!pluginsMounted || fetchedRef.current) return;
|
|
79
|
+
fetchedRef.current = true;
|
|
80
|
+
const entryPaths = getPluginRenderers().map(r => r.entryPath).filter((ep): ep is string => !!ep);
|
|
81
|
+
if (entryPaths.length === 0) return;
|
|
82
|
+
fetch('/api/files')
|
|
83
|
+
.then(r => r.ok ? r.json() : [])
|
|
84
|
+
.then((allPaths: string[]) => {
|
|
85
|
+
const pathSet = new Set(allPaths);
|
|
86
|
+
setExistingFiles(new Set(entryPaths.filter(ep => pathSet.has(ep))));
|
|
87
|
+
})
|
|
88
|
+
.catch(() => {});
|
|
89
|
+
}, [pluginsMounted]);
|
|
90
|
+
|
|
91
|
+
const handleToggle = useCallback((id: string, enabled: boolean) => {
|
|
92
|
+
setRendererEnabled(id, enabled);
|
|
93
|
+
forceUpdate(n => n + 1);
|
|
94
|
+
window.dispatchEvent(new Event('renderer-state-changed'));
|
|
95
|
+
}, []);
|
|
96
|
+
|
|
97
|
+
const handleOpenPlugin = useCallback((entryPath: string) => {
|
|
98
|
+
router.push(`/view/${entryPath.split('/').map(encodeURIComponent).join('/')}`);
|
|
99
|
+
}, [router]);
|
|
59
100
|
|
|
60
|
-
/** Type-safe lookup for use case i18n */
|
|
61
101
|
const getUseCaseText = (id: string): { title: string; prompt: string } | undefined => {
|
|
62
102
|
const map: Record<string, { title: string; desc: string; prompt: string }> = {
|
|
63
103
|
c1: e.c1, c2: e.c2, c3: e.c3, c4: e.c4, c5: e.c5,
|
|
@@ -66,6 +106,9 @@ export default function DiscoverPanel({ active, maximized, onMaximize }: Discove
|
|
|
66
106
|
return map[id];
|
|
67
107
|
};
|
|
68
108
|
|
|
109
|
+
const renderers = pluginsMounted ? getPluginRenderers() : [];
|
|
110
|
+
const enabledCount = pluginsMounted ? renderers.filter(r => isRendererEnabled(r.id)).length : 0;
|
|
111
|
+
|
|
69
112
|
return (
|
|
70
113
|
<div className={`flex flex-col h-full ${active ? '' : 'hidden'}`}>
|
|
71
114
|
<PanelHeader title={d.title} maximized={maximized} onMaximize={onMaximize} />
|
|
@@ -97,6 +140,49 @@ export default function DiscoverPanel({ active, maximized, onMaximize }: Discove
|
|
|
97
140
|
|
|
98
141
|
<div className="mx-4 border-t border-border" />
|
|
99
142
|
|
|
143
|
+
{/* Installed extensions (merged from Plugins panel) */}
|
|
144
|
+
<div className="py-2">
|
|
145
|
+
<button
|
|
146
|
+
type="button"
|
|
147
|
+
onClick={() => setShowPlugins(v => !v)}
|
|
148
|
+
className="w-full flex items-center gap-1.5 px-4 py-1.5 text-left"
|
|
149
|
+
>
|
|
150
|
+
<ChevronDown size={11} className={`text-muted-foreground transition-transform duration-150 ${showPlugins ? '' : '-rotate-90'}`} />
|
|
151
|
+
<Blocks size={13} className="text-muted-foreground shrink-0" />
|
|
152
|
+
<span className="text-2xs font-medium text-muted-foreground uppercase tracking-wider flex-1">
|
|
153
|
+
{p.title}
|
|
154
|
+
</span>
|
|
155
|
+
<span className="text-2xs text-muted-foreground tabular-nums">{enabledCount}/{renderers.length}</span>
|
|
156
|
+
</button>
|
|
157
|
+
{showPlugins && renderers.map(r => {
|
|
158
|
+
const enabled = isRendererEnabled(r.id);
|
|
159
|
+
const fileExists = r.entryPath ? existingFiles.has(r.entryPath) : false;
|
|
160
|
+
const canOpen = enabled && r.entryPath && fileExists;
|
|
161
|
+
return (
|
|
162
|
+
<div
|
|
163
|
+
key={r.id}
|
|
164
|
+
className={`flex items-center gap-2 px-4 py-1.5 mx-1 rounded-sm transition-colors ${canOpen ? 'cursor-pointer hover:bg-muted/50' : ''} ${!enabled ? 'opacity-50' : ''}`}
|
|
165
|
+
onClick={canOpen ? () => handleOpenPlugin(r.entryPath!) : undefined}
|
|
166
|
+
role={canOpen ? 'link' : undefined}
|
|
167
|
+
tabIndex={canOpen ? 0 : undefined}
|
|
168
|
+
onKeyDown={canOpen ? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleOpenPlugin(r.entryPath!); } } : undefined}
|
|
169
|
+
>
|
|
170
|
+
<span className="text-sm shrink-0" suppressHydrationWarning>{r.icon}</span>
|
|
171
|
+
<span className="text-xs text-foreground truncate flex-1">{r.name}</span>
|
|
172
|
+
{r.core ? (
|
|
173
|
+
<span className="text-2xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground shrink-0">{p.core}</span>
|
|
174
|
+
) : (
|
|
175
|
+
<div onClick={e => e.stopPropagation()}>
|
|
176
|
+
<Toggle checked={enabled} onChange={v => handleToggle(r.id, v)} size="sm" />
|
|
177
|
+
</div>
|
|
178
|
+
)}
|
|
179
|
+
</div>
|
|
180
|
+
);
|
|
181
|
+
})}
|
|
182
|
+
</div>
|
|
183
|
+
|
|
184
|
+
<div className="mx-4 border-t border-border" />
|
|
185
|
+
|
|
100
186
|
{/* Quick try — use case list */}
|
|
101
187
|
<div className="py-2">
|
|
102
188
|
<div className="px-4 py-1.5">
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useCallback, useRef } from 'react';
|
|
4
|
+
import { ALLOWED_IMPORT_EXTENSIONS } from '@/lib/core/file-convert';
|
|
5
|
+
|
|
6
|
+
export type ImportIntent = 'archive' | 'digest';
|
|
7
|
+
export type ImportStep = 'select' | 'archive_config' | 'importing' | 'done';
|
|
8
|
+
export type ConflictMode = 'skip' | 'rename' | 'overwrite';
|
|
9
|
+
|
|
10
|
+
export interface ImportFile {
|
|
11
|
+
file: File;
|
|
12
|
+
name: string;
|
|
13
|
+
size: number;
|
|
14
|
+
content: string | null;
|
|
15
|
+
loading: boolean;
|
|
16
|
+
error: string | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const MAX_FILE_SIZE = 5 * 1024 * 1024;
|
|
20
|
+
const MAX_PDF_SIZE = 12 * 1024 * 1024;
|
|
21
|
+
const MAX_FILES = 20;
|
|
22
|
+
|
|
23
|
+
function getExt(name: string): string {
|
|
24
|
+
const idx = name.lastIndexOf('.');
|
|
25
|
+
return idx >= 0 ? name.slice(idx).toLowerCase() : '';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function formatSize(bytes: number): string {
|
|
29
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
30
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
31
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function useFileImport() {
|
|
35
|
+
const [files, setFiles] = useState<ImportFile[]>([]);
|
|
36
|
+
const [step, setStep] = useState<ImportStep>('select');
|
|
37
|
+
const [intent, setIntent] = useState<ImportIntent>('archive');
|
|
38
|
+
const [targetSpace, setTargetSpace] = useState('');
|
|
39
|
+
const [conflict, setConflict] = useState<ConflictMode>('rename');
|
|
40
|
+
const [importing, setImporting] = useState(false);
|
|
41
|
+
const [result, setResult] = useState<{
|
|
42
|
+
created: Array<{ original: string; path: string }>;
|
|
43
|
+
skipped: Array<{ name: string; reason: string }>;
|
|
44
|
+
errors: Array<{ name: string; error: string }>;
|
|
45
|
+
updatedFiles: string[];
|
|
46
|
+
} | null>(null);
|
|
47
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
48
|
+
|
|
49
|
+
const addFiles = useCallback(async (fileList: FileList | File[]) => {
|
|
50
|
+
const incoming = Array.from(fileList).slice(0, MAX_FILES);
|
|
51
|
+
const newFiles: ImportFile[] = [];
|
|
52
|
+
|
|
53
|
+
for (const file of incoming) {
|
|
54
|
+
const ext = getExt(file.name);
|
|
55
|
+
const maxSize = ext === '.pdf' ? MAX_PDF_SIZE : MAX_FILE_SIZE;
|
|
56
|
+
|
|
57
|
+
let error: string | null = null;
|
|
58
|
+
if (!ALLOWED_IMPORT_EXTENSIONS.has(ext)) {
|
|
59
|
+
error = 'unsupported';
|
|
60
|
+
} else if (file.size > maxSize) {
|
|
61
|
+
error = 'tooLarge';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
newFiles.push({
|
|
65
|
+
file,
|
|
66
|
+
name: file.name,
|
|
67
|
+
size: file.size,
|
|
68
|
+
content: null,
|
|
69
|
+
loading: !error,
|
|
70
|
+
error,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
setFiles(prev => {
|
|
75
|
+
const merged = [...prev];
|
|
76
|
+
for (const f of newFiles) {
|
|
77
|
+
const isDup = merged.some(m =>
|
|
78
|
+
m.name === f.name && m.size === f.size
|
|
79
|
+
);
|
|
80
|
+
if (!isDup && merged.length < MAX_FILES) merged.push(f);
|
|
81
|
+
}
|
|
82
|
+
return merged;
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
for (const f of newFiles) {
|
|
86
|
+
if (f.error) continue;
|
|
87
|
+
try {
|
|
88
|
+
const text = await f.file.text();
|
|
89
|
+
setFiles(prev => prev.map(p =>
|
|
90
|
+
p.name === f.name && p.size === f.size
|
|
91
|
+
? { ...p, content: text, loading: false }
|
|
92
|
+
: p
|
|
93
|
+
));
|
|
94
|
+
} catch {
|
|
95
|
+
setFiles(prev => prev.map(p =>
|
|
96
|
+
p.name === f.name && p.size === f.size
|
|
97
|
+
? { ...p, loading: false, error: 'readFailed' }
|
|
98
|
+
: p
|
|
99
|
+
));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}, []);
|
|
103
|
+
|
|
104
|
+
const removeFile = useCallback((index: number) => {
|
|
105
|
+
setFiles(prev => prev.filter((_, i) => i !== index));
|
|
106
|
+
}, []);
|
|
107
|
+
|
|
108
|
+
const clearFiles = useCallback(() => {
|
|
109
|
+
setFiles([]);
|
|
110
|
+
setStep('select');
|
|
111
|
+
setResult(null);
|
|
112
|
+
}, []);
|
|
113
|
+
|
|
114
|
+
const validFiles = files.filter(f => !f.error && f.content !== null);
|
|
115
|
+
const allReady = files.length > 0 && files.every(f => !f.loading);
|
|
116
|
+
const hasErrors = files.some(f => f.error);
|
|
117
|
+
|
|
118
|
+
const doArchive = useCallback(async () => {
|
|
119
|
+
if (validFiles.length === 0) return;
|
|
120
|
+
setImporting(true);
|
|
121
|
+
setStep('importing');
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
const payload = {
|
|
125
|
+
files: validFiles.map(f => ({
|
|
126
|
+
name: f.name,
|
|
127
|
+
content: f.content!,
|
|
128
|
+
})),
|
|
129
|
+
targetSpace: targetSpace || undefined,
|
|
130
|
+
conflict,
|
|
131
|
+
organize: true,
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const res = await fetch('/api/file/import', {
|
|
135
|
+
method: 'POST',
|
|
136
|
+
headers: { 'Content-Type': 'application/json' },
|
|
137
|
+
body: JSON.stringify(payload),
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const data = await res.json();
|
|
141
|
+
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
|
142
|
+
|
|
143
|
+
setResult(data);
|
|
144
|
+
setStep('done');
|
|
145
|
+
} catch (err) {
|
|
146
|
+
setResult({
|
|
147
|
+
created: [],
|
|
148
|
+
skipped: [],
|
|
149
|
+
errors: [{ name: '*', error: (err as Error).message }],
|
|
150
|
+
updatedFiles: [],
|
|
151
|
+
});
|
|
152
|
+
setStep('done');
|
|
153
|
+
} finally {
|
|
154
|
+
setImporting(false);
|
|
155
|
+
}
|
|
156
|
+
}, [validFiles, targetSpace, conflict]);
|
|
157
|
+
|
|
158
|
+
const reset = useCallback(() => {
|
|
159
|
+
setFiles([]);
|
|
160
|
+
setStep('select');
|
|
161
|
+
setIntent('archive');
|
|
162
|
+
setTargetSpace('');
|
|
163
|
+
setConflict('rename');
|
|
164
|
+
setImporting(false);
|
|
165
|
+
setResult(null);
|
|
166
|
+
}, []);
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
files,
|
|
170
|
+
step,
|
|
171
|
+
intent,
|
|
172
|
+
targetSpace,
|
|
173
|
+
conflict,
|
|
174
|
+
importing,
|
|
175
|
+
result,
|
|
176
|
+
inputRef,
|
|
177
|
+
validFiles,
|
|
178
|
+
allReady,
|
|
179
|
+
hasErrors,
|
|
180
|
+
addFiles,
|
|
181
|
+
removeFile,
|
|
182
|
+
clearFiles,
|
|
183
|
+
setStep,
|
|
184
|
+
setIntent,
|
|
185
|
+
setTargetSpace,
|
|
186
|
+
setConflict,
|
|
187
|
+
doArchive,
|
|
188
|
+
reset,
|
|
189
|
+
formatSize,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
@@ -115,6 +115,16 @@ export function useFileUpload() {
|
|
|
115
115
|
setUploadError('');
|
|
116
116
|
}, []);
|
|
117
117
|
|
|
118
|
+
const injectFiles = useCallback((files: LocalAttachment[]) => {
|
|
119
|
+
setLocalAttachments(prev => {
|
|
120
|
+
const merged = [...prev];
|
|
121
|
+
for (const item of files) {
|
|
122
|
+
if (!merged.some(m => m.name === item.name)) merged.push(item);
|
|
123
|
+
}
|
|
124
|
+
return merged;
|
|
125
|
+
});
|
|
126
|
+
}, []);
|
|
127
|
+
|
|
118
128
|
return {
|
|
119
129
|
localAttachments,
|
|
120
130
|
uploadError,
|
|
@@ -122,5 +132,6 @@ export function useFileUpload() {
|
|
|
122
132
|
pickFiles,
|
|
123
133
|
removeAttachment,
|
|
124
134
|
clearAttachments,
|
|
135
|
+
injectFiles,
|
|
125
136
|
};
|
|
126
137
|
}
|
package/app/lib/agent/tools.ts
CHANGED
|
@@ -94,6 +94,14 @@ const AppendParams = Type.Object({
|
|
|
94
94
|
content: Type.String({ description: 'Content to append' }),
|
|
95
95
|
});
|
|
96
96
|
|
|
97
|
+
const FetchUrlParams = Type.Object({
|
|
98
|
+
url: Type.String({ description: 'The HTTP/HTTPS URL to fetch' }),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const WebSearchParams = Type.Object({
|
|
102
|
+
query: Type.String({ description: 'The search query or keywords to look up on the internet' }),
|
|
103
|
+
});
|
|
104
|
+
|
|
97
105
|
const InsertHeadingParams = Type.Object({
|
|
98
106
|
path: Type.String({ description: 'Relative file path' }),
|
|
99
107
|
heading: Type.String({ description: 'Heading text to find (e.g. "## Tasks" or just "Tasks")' }),
|
|
@@ -243,6 +251,144 @@ export const knowledgeBaseTools: AgentTool<any>[] = [
|
|
|
243
251
|
}),
|
|
244
252
|
},
|
|
245
253
|
|
|
254
|
+
{
|
|
255
|
+
name: 'web_search',
|
|
256
|
+
label: 'Web Search',
|
|
257
|
+
description: 'Search the internet for up-to-date information. Uses DuckDuckGo HTML search. Returns top search results with titles, snippets, and URLs.',
|
|
258
|
+
parameters: WebSearchParams,
|
|
259
|
+
execute: safeExecute(async (_id, params: Static<typeof WebSearchParams>) => {
|
|
260
|
+
try {
|
|
261
|
+
const url = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(params.query)}`;
|
|
262
|
+
const res = await fetch(url, {
|
|
263
|
+
headers: {
|
|
264
|
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
|
265
|
+
'Accept-Language': 'en-US,en;q=0.9',
|
|
266
|
+
},
|
|
267
|
+
signal: AbortSignal.timeout(10000)
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
if (!res.ok) {
|
|
271
|
+
return textResult(`Failed to search: HTTP ${res.status}`);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const html = await res.text();
|
|
275
|
+
const results: string[] = [];
|
|
276
|
+
|
|
277
|
+
// Simple regex parsing for DuckDuckGo HTML results
|
|
278
|
+
const resultBlocks = html.split('class="result__body"').slice(1);
|
|
279
|
+
|
|
280
|
+
for (let i = 0; i < Math.min(resultBlocks.length, 5); i++) {
|
|
281
|
+
const block = resultBlocks[i];
|
|
282
|
+
|
|
283
|
+
const titleMatch = block.match(/class="result__title"[^>]*>[\s\S]*?<a[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/i);
|
|
284
|
+
const snippetMatch = block.match(/class="result__snippet[^>]*>([\s\S]*?)(?:<\/a>|<\/div>)/i);
|
|
285
|
+
|
|
286
|
+
if (titleMatch) {
|
|
287
|
+
let link = titleMatch[1];
|
|
288
|
+
// Decode DuckDuckGo redirect URL if necessary
|
|
289
|
+
if (link.startsWith('//duckduckgo.com/l/?uddg=')) {
|
|
290
|
+
const urlParam = new URL('https:' + link).searchParams.get('uddg');
|
|
291
|
+
if (urlParam) link = decodeURIComponent(urlParam);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Clean up tags
|
|
295
|
+
const title = titleMatch[2].replace(/<[^>]+>/g, '').trim();
|
|
296
|
+
const snippet = snippetMatch ? snippetMatch[1].replace(/<[^>]+>/g, '').trim() : '';
|
|
297
|
+
|
|
298
|
+
results.push(`### ${i+1}. ${title}\n**URL:** ${link}\n**Snippet:** ${snippet}\n`);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (results.length === 0) {
|
|
303
|
+
return textResult(`No web search results found for: ${params.query}`);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return textResult(`## Web Search Results for: "${params.query}"\n\n${results.join('\n')}\n\n*Note: Use web_fetch tool with any of the URLs above to read the full page content.*`);
|
|
307
|
+
} catch (err) {
|
|
308
|
+
return textResult(`Web search failed: ${formatToolError(err)}`);
|
|
309
|
+
}
|
|
310
|
+
}),
|
|
311
|
+
},
|
|
312
|
+
|
|
313
|
+
{
|
|
314
|
+
name: 'web_fetch',
|
|
315
|
+
label: 'Web Fetch',
|
|
316
|
+
description: 'Fetch the text content of any public URL. Extracts main text from HTML and converts it to Markdown. Use this to read external docs, repos, or articles.',
|
|
317
|
+
parameters: FetchUrlParams,
|
|
318
|
+
execute: safeExecute(async (_id, params: Static<typeof FetchUrlParams>) => {
|
|
319
|
+
let url = params.url;
|
|
320
|
+
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
|
321
|
+
url = 'https://' + url;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
try {
|
|
325
|
+
const res = await fetch(url, {
|
|
326
|
+
headers: {
|
|
327
|
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
|
328
|
+
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
|
|
329
|
+
},
|
|
330
|
+
// Don't wait forever
|
|
331
|
+
signal: AbortSignal.timeout(10000)
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
if (!res.ok) {
|
|
335
|
+
return textResult(`Failed to fetch URL: HTTP ${res.status} ${res.statusText}`);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const contentType = res.headers.get('content-type') || '';
|
|
339
|
+
|
|
340
|
+
// If it's a raw file (like raw.githubusercontent.com or a raw text file)
|
|
341
|
+
if (contentType.includes('text/plain') || contentType.includes('application/json') || url.includes('raw.githubusercontent.com')) {
|
|
342
|
+
const text = await res.text();
|
|
343
|
+
return textResult(truncate(text));
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// For HTML, we do a basic extraction (in a real app you might use JSDOM/Readability, but we'll do a robust regex cleanup here to avoid new dependencies)
|
|
347
|
+
let html = await res.text();
|
|
348
|
+
|
|
349
|
+
// Extract title if possible
|
|
350
|
+
const titleMatch = html.match(/<title[^>]*>([^<]+)<\/title>/i);
|
|
351
|
+
const title = titleMatch ? titleMatch[1].trim() : url;
|
|
352
|
+
|
|
353
|
+
// Strip out scripts, styles, svg, and headers/footers roughly
|
|
354
|
+
html = html.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, ' ')
|
|
355
|
+
.replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, ' ')
|
|
356
|
+
.replace(/<svg\b[^<]*(?:(?!<\/svg>)<[^<]*)*<\/svg>/gi, ' ')
|
|
357
|
+
.replace(/<header\b[^<]*(?:(?!<\/header>)<[^<]*)*<\/header>/gi, ' ')
|
|
358
|
+
.replace(/<footer\b[^<]*(?:(?!<\/footer>)<[^<]*)*<\/footer>/gi, ' ')
|
|
359
|
+
.replace(/<nav\b[^<]*(?:(?!<\/nav>)<[^<]*)*<\/nav>/gi, ' ');
|
|
360
|
+
|
|
361
|
+
// Convert some basic tags to markdown equivalents roughly before stripping all HTML
|
|
362
|
+
html = html.replace(/<h[1-2][^>]*>(.*?)<\/h[1-2]>/gi, '\n\n# $1\n\n')
|
|
363
|
+
.replace(/<h[3-6][^>]*>(.*?)<\/h[3-6]>/gi, '\n\n## $1\n\n')
|
|
364
|
+
.replace(/<p[^>]*>(.*?)<\/p>/gi, '\n\n$1\n\n')
|
|
365
|
+
.replace(/<li[^>]*>(.*?)<\/li>/gi, '\n- $1')
|
|
366
|
+
.replace(/<br\s*\/?>/gi, '\n');
|
|
367
|
+
|
|
368
|
+
// Strip remaining HTML tags
|
|
369
|
+
let text = html.replace(/<[^>]+>/g, ' ');
|
|
370
|
+
|
|
371
|
+
// Decode common HTML entities
|
|
372
|
+
text = text.replace(/ /g, ' ')
|
|
373
|
+
.replace(/&/g, ' ')
|
|
374
|
+
.replace(/</g, '<')
|
|
375
|
+
.replace(/>/g, '>')
|
|
376
|
+
.replace(/"/g, '"')
|
|
377
|
+
.replace(/'/g, "'");
|
|
378
|
+
|
|
379
|
+
// Clean up whitespace: remove empty lines and extra spaces
|
|
380
|
+
text = text.replace(/[ \t]+/g, ' ')
|
|
381
|
+
.replace(/\n\s*\n\s*\n/g, '\n\n')
|
|
382
|
+
.trim();
|
|
383
|
+
|
|
384
|
+
const result = `# ${title}\nSource: ${url}\n\n${text}`;
|
|
385
|
+
return textResult(truncate(result));
|
|
386
|
+
} catch (err) {
|
|
387
|
+
return textResult(`Failed to fetch URL: ${formatToolError(err)}`);
|
|
388
|
+
}
|
|
389
|
+
}),
|
|
390
|
+
},
|
|
391
|
+
|
|
246
392
|
{
|
|
247
393
|
name: 'get_recent',
|
|
248
394
|
label: 'Recent Files',
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
|
|
3
|
+
export const ALLOWED_IMPORT_EXTENSIONS = new Set([
|
|
4
|
+
'.txt', '.md', '.markdown', '.csv', '.json', '.yaml', '.yml', '.xml', '.html', '.htm', '.pdf',
|
|
5
|
+
]);
|
|
6
|
+
|
|
7
|
+
export interface ConvertResult {
|
|
8
|
+
content: string;
|
|
9
|
+
originalName: string;
|
|
10
|
+
targetName: string;
|
|
11
|
+
metadata?: Record<string, string>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function sanitizeFileName(name: string): string {
|
|
15
|
+
let base = name.replace(/\\/g, '/').split('/').pop() ?? '';
|
|
16
|
+
base = base.replace(/\.\./g, '').replace(/^\/+/, '');
|
|
17
|
+
base = base.replace(/[\\:*?"<>|]/g, '-');
|
|
18
|
+
base = base.replace(/-{2,}/g, '-');
|
|
19
|
+
base = base.replace(/^[-\s]+|[-\s]+$/g, '');
|
|
20
|
+
return base || 'imported-file';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function titleFromFileName(name: string): string {
|
|
24
|
+
const ext = path.extname(name);
|
|
25
|
+
const stem = (ext ? name.slice(0, -ext.length) : name).replace(/^\.+/, '');
|
|
26
|
+
const words = stem.replace(/[-_]+/g, ' ').trim().split(/\s+/);
|
|
27
|
+
if (words.length === 0 || (words.length === 1 && !words[0])) return 'Untitled';
|
|
28
|
+
return words.map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function stripHtmlTags(html: string): string {
|
|
32
|
+
return html
|
|
33
|
+
.replace(/<script[\s\S]*?<\/script>/gi, '')
|
|
34
|
+
.replace(/<style[\s\S]*?<\/style>/gi, '')
|
|
35
|
+
.replace(/<[^>]+>/g, '')
|
|
36
|
+
.replace(/ /g, ' ')
|
|
37
|
+
.replace(/&/g, '&')
|
|
38
|
+
.replace(/</g, '<')
|
|
39
|
+
.replace(/>/g, '>')
|
|
40
|
+
.replace(/"/g, '"')
|
|
41
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
42
|
+
.trim();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function convertToMarkdown(fileName: string, rawContent: string): ConvertResult {
|
|
46
|
+
const originalName = fileName;
|
|
47
|
+
const ext = path.extname(fileName).toLowerCase();
|
|
48
|
+
const stem = path.basename(fileName, ext) || 'note';
|
|
49
|
+
const title = titleFromFileName(fileName);
|
|
50
|
+
|
|
51
|
+
if (ext === '.md' || ext === '.markdown') {
|
|
52
|
+
return { content: rawContent, originalName, targetName: sanitizeFileName(fileName) };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (ext === '.csv' || ext === '.json') {
|
|
56
|
+
return { content: rawContent, originalName, targetName: sanitizeFileName(fileName) };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (ext === '.txt') {
|
|
60
|
+
return {
|
|
61
|
+
content: `# ${title}\n\n${rawContent}`,
|
|
62
|
+
originalName,
|
|
63
|
+
targetName: sanitizeFileName(`${stem}.md`),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (ext === '.yaml' || ext === '.yml') {
|
|
68
|
+
return {
|
|
69
|
+
content: `# ${title}\n\n\`\`\`yaml\n${rawContent}\n\`\`\`\n`,
|
|
70
|
+
originalName,
|
|
71
|
+
targetName: sanitizeFileName(`${stem}.md`),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (ext === '.html' || ext === '.htm') {
|
|
76
|
+
const text = stripHtmlTags(rawContent);
|
|
77
|
+
return {
|
|
78
|
+
content: `# ${title}\n\n${text}\n`,
|
|
79
|
+
originalName,
|
|
80
|
+
targetName: sanitizeFileName(`${stem}.md`),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (ext === '.xml') {
|
|
85
|
+
return {
|
|
86
|
+
content: `# ${title}\n\n\`\`\`xml\n${rawContent}\n\`\`\`\n`,
|
|
87
|
+
originalName,
|
|
88
|
+
targetName: sanitizeFileName(`${stem}.md`),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
content: `# ${title}\n\n${rawContent}`,
|
|
94
|
+
originalName,
|
|
95
|
+
targetName: sanitizeFileName(`${stem}.md`),
|
|
96
|
+
};
|
|
97
|
+
}
|