@geminilight/mindos 0.5.60 → 0.5.62
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/agents/[agentKey]/page.tsx +17 -0
- package/app/app/agents/page.tsx +20 -0
- package/app/app/api/changes/route.ts +57 -0
- package/app/app/api/file/route.ts +146 -1
- package/app/app/api/settings/test-key/route.ts +43 -1
- package/app/app/changes/page.tsx +16 -0
- package/app/components/ActivityBar.tsx +9 -1
- package/app/components/SidebarLayout.tsx +12 -2
- package/app/components/agents/AgentDetailContent.tsx +117 -0
- package/app/components/agents/AgentsContentPage.tsx +87 -0
- package/app/components/agents/AgentsMcpSection.tsx +123 -0
- package/app/components/agents/AgentsOverviewSection.tsx +89 -0
- package/app/components/agents/AgentsSkillsSection.tsx +146 -0
- package/app/components/agents/agents-content-model.ts +80 -0
- package/app/components/ask/AskContent.tsx +15 -6
- package/app/components/changes/ChangesBanner.tsx +51 -0
- package/app/components/changes/ChangesContentPage.tsx +190 -0
- package/app/components/changes/line-diff.ts +75 -0
- package/app/components/panels/AgentsPanel.tsx +11 -0
- package/app/components/settings/UpdateTab.tsx +190 -1
- package/app/lib/core/content-changes.ts +153 -0
- package/app/lib/core/index.ts +14 -0
- package/app/lib/fs.ts +22 -0
- package/app/lib/i18n-en.ts +92 -0
- package/app/lib/i18n-zh.ts +92 -0
- package/app/lib/renderers/index.ts +1 -2
- package/app/package.json +3 -2
- package/package.json +1 -1
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
4
|
+
import Link from 'next/link';
|
|
5
|
+
import { ChevronDown, ChevronRight, History, RefreshCw } from 'lucide-react';
|
|
6
|
+
import { apiFetch } from '@/lib/api';
|
|
7
|
+
import { collapseDiffContext, buildLineDiff } from './line-diff';
|
|
8
|
+
|
|
9
|
+
interface ChangeEvent {
|
|
10
|
+
id: string;
|
|
11
|
+
ts: string;
|
|
12
|
+
op: string;
|
|
13
|
+
path: string;
|
|
14
|
+
source: 'user' | 'agent' | 'system';
|
|
15
|
+
summary: string;
|
|
16
|
+
before?: string;
|
|
17
|
+
after?: string;
|
|
18
|
+
beforePath?: string;
|
|
19
|
+
afterPath?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface SummaryPayload {
|
|
23
|
+
unreadCount: number;
|
|
24
|
+
totalCount: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface ListPayload {
|
|
28
|
+
events: ChangeEvent[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function relativeTime(ts: string): string {
|
|
32
|
+
const delta = Date.now() - new Date(ts).getTime();
|
|
33
|
+
const mins = Math.floor(delta / 60000);
|
|
34
|
+
if (mins < 1) return 'just now';
|
|
35
|
+
if (mins < 60) return `${mins}m ago`;
|
|
36
|
+
const hours = Math.floor(mins / 60);
|
|
37
|
+
if (hours < 24) return `${hours}h ago`;
|
|
38
|
+
return `${Math.floor(hours / 24)}d ago`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export default function ChangesContentPage({ initialPath = '' }: { initialPath?: string }) {
|
|
42
|
+
const [pathFilter, setPathFilter] = useState(initialPath);
|
|
43
|
+
const [events, setEvents] = useState<ChangeEvent[]>([]);
|
|
44
|
+
const [summary, setSummary] = useState<SummaryPayload>({ unreadCount: 0, totalCount: 0 });
|
|
45
|
+
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
|
46
|
+
const [loading, setLoading] = useState(true);
|
|
47
|
+
const [error, setError] = useState<string | null>(null);
|
|
48
|
+
|
|
49
|
+
const fetchData = useCallback(async () => {
|
|
50
|
+
setLoading(true);
|
|
51
|
+
setError(null);
|
|
52
|
+
try {
|
|
53
|
+
const listUrl = pathFilter
|
|
54
|
+
? `/api/changes?op=list&limit=80&path=${encodeURIComponent(pathFilter)}`
|
|
55
|
+
: '/api/changes?op=list&limit=80';
|
|
56
|
+
const [list, summaryData] = await Promise.all([
|
|
57
|
+
apiFetch<ListPayload>(listUrl),
|
|
58
|
+
apiFetch<SummaryPayload>('/api/changes?op=summary'),
|
|
59
|
+
]);
|
|
60
|
+
setEvents(list.events);
|
|
61
|
+
setSummary(summaryData);
|
|
62
|
+
} catch (e) {
|
|
63
|
+
setError(e instanceof Error ? e.message : 'Failed to load changes');
|
|
64
|
+
} finally {
|
|
65
|
+
setLoading(false);
|
|
66
|
+
}
|
|
67
|
+
}, [pathFilter]);
|
|
68
|
+
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
void fetchData();
|
|
71
|
+
}, [fetchData]);
|
|
72
|
+
|
|
73
|
+
const markSeen = useCallback(async () => {
|
|
74
|
+
await apiFetch('/api/changes', {
|
|
75
|
+
method: 'POST',
|
|
76
|
+
headers: { 'Content-Type': 'application/json' },
|
|
77
|
+
body: JSON.stringify({ op: 'mark_seen' }),
|
|
78
|
+
});
|
|
79
|
+
await fetchData();
|
|
80
|
+
}, [fetchData]);
|
|
81
|
+
|
|
82
|
+
const eventCountLabel = useMemo(() => `${events.length} event${events.length === 1 ? '' : 's'}`, [events.length]);
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<div className="min-h-screen">
|
|
86
|
+
<div className="sticky top-[52px] md:top-0 z-20 border-b border-border px-4 md:px-6 py-2.5 bg-background">
|
|
87
|
+
<div className="content-width xl:mr-[220px] flex items-center justify-between gap-3">
|
|
88
|
+
<div className="min-w-0">
|
|
89
|
+
<div className="flex items-center gap-2 text-sm font-medium text-foreground font-display">
|
|
90
|
+
<History size={15} />
|
|
91
|
+
Content changes
|
|
92
|
+
</div>
|
|
93
|
+
<div className="text-xs text-muted-foreground mt-1">{eventCountLabel} · {summary.unreadCount} unread</div>
|
|
94
|
+
</div>
|
|
95
|
+
<div className="flex items-center gap-2">
|
|
96
|
+
<button
|
|
97
|
+
type="button"
|
|
98
|
+
onClick={() => void fetchData()}
|
|
99
|
+
className="px-2.5 py-1.5 rounded-md text-xs font-medium bg-muted text-muted-foreground hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
|
100
|
+
>
|
|
101
|
+
<span className="inline-flex items-center gap-1"><RefreshCw size={12} /> Refresh</span>
|
|
102
|
+
</button>
|
|
103
|
+
<button
|
|
104
|
+
type="button"
|
|
105
|
+
onClick={() => void markSeen()}
|
|
106
|
+
className="px-2.5 py-1.5 rounded-md text-xs font-medium bg-[var(--amber)] text-[var(--amber-foreground)] focus-visible:ring-2 focus-visible:ring-ring"
|
|
107
|
+
>
|
|
108
|
+
Mark seen
|
|
109
|
+
</button>
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
|
|
114
|
+
<div className="px-4 md:px-6 py-6 md:py-8">
|
|
115
|
+
<div className="content-width xl:mr-[220px] space-y-3">
|
|
116
|
+
<div className="rounded-lg border border-border bg-card p-3">
|
|
117
|
+
<label className="text-xs text-muted-foreground">Filter by file path</label>
|
|
118
|
+
<input
|
|
119
|
+
value={pathFilter}
|
|
120
|
+
onChange={(e) => setPathFilter(e.target.value)}
|
|
121
|
+
placeholder="e.g. Projects/plan.md"
|
|
122
|
+
className="mt-1 w-full px-2.5 py-1.5 text-sm bg-background border border-border rounded-lg text-foreground outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
123
|
+
/>
|
|
124
|
+
</div>
|
|
125
|
+
|
|
126
|
+
{loading && <p className="text-sm text-muted-foreground">Loading changes...</p>}
|
|
127
|
+
{error && <p className="text-sm text-error">{error}</p>}
|
|
128
|
+
{!loading && !error && events.length === 0 && (
|
|
129
|
+
<div className="rounded-lg border border-border bg-card p-6 text-center text-sm text-muted-foreground">
|
|
130
|
+
No content changes yet.
|
|
131
|
+
</div>
|
|
132
|
+
)}
|
|
133
|
+
|
|
134
|
+
{!loading && !error && events.map((event) => {
|
|
135
|
+
const open = !!expanded[event.id];
|
|
136
|
+
const rows = collapseDiffContext(buildLineDiff(event.before ?? '', event.after ?? ''));
|
|
137
|
+
return (
|
|
138
|
+
<div key={event.id} className="rounded-lg border border-border bg-card overflow-hidden">
|
|
139
|
+
<button
|
|
140
|
+
type="button"
|
|
141
|
+
onClick={() => setExpanded((prev) => ({ ...prev, [event.id]: !prev[event.id] }))}
|
|
142
|
+
className="w-full px-3 py-2.5 text-left hover:bg-muted/30 focus-visible:ring-2 focus-visible:ring-ring"
|
|
143
|
+
>
|
|
144
|
+
<div className="flex items-start gap-2">
|
|
145
|
+
<span className="pt-0.5 text-muted-foreground">{open ? <ChevronDown size={14} /> : <ChevronRight size={14} />}</span>
|
|
146
|
+
<div className="min-w-0 flex-1">
|
|
147
|
+
<div className="text-sm font-medium text-foreground font-display">{event.summary}</div>
|
|
148
|
+
<div className="text-xs text-muted-foreground mt-0.5">
|
|
149
|
+
{event.path} · {event.op} · {event.source} · {relativeTime(event.ts)}
|
|
150
|
+
</div>
|
|
151
|
+
</div>
|
|
152
|
+
<Link
|
|
153
|
+
href={`/view/${event.path.split('/').map(encodeURIComponent).join('/')}`}
|
|
154
|
+
className="text-xs text-[var(--amber)] hover:underline"
|
|
155
|
+
onClick={(e) => e.stopPropagation()}
|
|
156
|
+
>
|
|
157
|
+
Open
|
|
158
|
+
</Link>
|
|
159
|
+
</div>
|
|
160
|
+
</button>
|
|
161
|
+
|
|
162
|
+
{open && (
|
|
163
|
+
<div className="border-t border-border bg-background">
|
|
164
|
+
{rows.map((row, idx) => {
|
|
165
|
+
if (row.type === 'gap') {
|
|
166
|
+
return <div key={`${event.id}-gap-${idx}`} className="px-3 py-1 text-2xs text-muted-foreground">... {row.count} unchanged lines ...</div>;
|
|
167
|
+
}
|
|
168
|
+
const prefix = row.type === 'insert' ? '+' : row.type === 'delete' ? '-' : ' ';
|
|
169
|
+
const color = row.type === 'insert'
|
|
170
|
+
? 'var(--success)'
|
|
171
|
+
: row.type === 'delete'
|
|
172
|
+
? 'var(--error)'
|
|
173
|
+
: 'var(--muted-foreground)';
|
|
174
|
+
return (
|
|
175
|
+
<div key={`${event.id}-${idx}`} className="px-3 py-0.5 text-xs font-mono flex items-start gap-2">
|
|
176
|
+
<span style={{ color }} className="select-none w-3">{prefix}</span>
|
|
177
|
+
<span style={{ color }} className="whitespace-pre-wrap break-all flex-1">{row.text || ' '}</span>
|
|
178
|
+
</div>
|
|
179
|
+
);
|
|
180
|
+
})}
|
|
181
|
+
</div>
|
|
182
|
+
)}
|
|
183
|
+
</div>
|
|
184
|
+
);
|
|
185
|
+
})}
|
|
186
|
+
</div>
|
|
187
|
+
</div>
|
|
188
|
+
</div>
|
|
189
|
+
);
|
|
190
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
export type DiffLineType = 'equal' | 'insert' | 'delete';
|
|
2
|
+
|
|
3
|
+
export interface DiffLine {
|
|
4
|
+
type: DiffLineType;
|
|
5
|
+
text: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface CollapsedGap {
|
|
9
|
+
type: 'gap';
|
|
10
|
+
count: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type DiffRow = DiffLine | CollapsedGap;
|
|
14
|
+
|
|
15
|
+
export function buildLineDiff(before: string, after: string): DiffLine[] {
|
|
16
|
+
const oldLines = before.split('\n');
|
|
17
|
+
const newLines = after.split('\n');
|
|
18
|
+
const m = oldLines.length;
|
|
19
|
+
const n = newLines.length;
|
|
20
|
+
const lcs: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
|
21
|
+
|
|
22
|
+
for (let i = m - 1; i >= 0; i--) {
|
|
23
|
+
for (let j = n - 1; j >= 0; j--) {
|
|
24
|
+
lcs[i][j] = oldLines[i] === newLines[j]
|
|
25
|
+
? 1 + lcs[i + 1][j + 1]
|
|
26
|
+
: Math.max(lcs[i + 1][j], lcs[i][j + 1]);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const rows: DiffLine[] = [];
|
|
31
|
+
let i = 0;
|
|
32
|
+
let j = 0;
|
|
33
|
+
while (i < m || j < n) {
|
|
34
|
+
if (i < m && j < n && oldLines[i] === newLines[j]) {
|
|
35
|
+
rows.push({ type: 'equal', text: oldLines[i] });
|
|
36
|
+
i += 1;
|
|
37
|
+
j += 1;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (j < n && (i >= m || lcs[i][j + 1] >= lcs[i + 1][j])) {
|
|
41
|
+
rows.push({ type: 'insert', text: newLines[j] });
|
|
42
|
+
j += 1;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
rows.push({ type: 'delete', text: oldLines[i] });
|
|
46
|
+
i += 1;
|
|
47
|
+
}
|
|
48
|
+
return rows;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function collapseDiffContext(lines: DiffLine[], context = 2): DiffRow[] {
|
|
52
|
+
const keep = new Set<number>();
|
|
53
|
+
lines.forEach((line, idx) => {
|
|
54
|
+
if (line.type === 'equal') return;
|
|
55
|
+
for (let i = Math.max(0, idx - context); i <= Math.min(lines.length - 1, idx + context); i++) {
|
|
56
|
+
keep.add(i);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const out: DiffRow[] = [];
|
|
61
|
+
let gapStart = -1;
|
|
62
|
+
for (let i = 0; i < lines.length; i++) {
|
|
63
|
+
if (keep.has(i)) {
|
|
64
|
+
if (gapStart !== -1) {
|
|
65
|
+
out.push({ type: 'gap', count: i - gapStart });
|
|
66
|
+
gapStart = -1;
|
|
67
|
+
}
|
|
68
|
+
out.push(lines[i]);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (gapStart === -1) gapStart = i;
|
|
72
|
+
}
|
|
73
|
+
if (gapStart !== -1) out.push({ type: 'gap', count: lines.length - gapStart });
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useState, useRef, useCallback } from 'react';
|
|
4
|
+
import { useRouter } from 'next/navigation';
|
|
4
5
|
import { Loader2, RefreshCw, ChevronDown, ChevronRight, Settings } from 'lucide-react';
|
|
5
6
|
import { useMcpData } from '@/hooks/useMcpData';
|
|
6
7
|
import { useLocale } from '@/lib/LocaleContext';
|
|
@@ -27,6 +28,7 @@ export default function AgentsPanel({
|
|
|
27
28
|
onOpenAgentDetail,
|
|
28
29
|
}: AgentsPanelProps) {
|
|
29
30
|
const { t } = useLocale();
|
|
31
|
+
const router = useRouter();
|
|
30
32
|
const p = t.panels.agents;
|
|
31
33
|
const mcp = useMcpData();
|
|
32
34
|
const [refreshing, setRefreshing] = useState(false);
|
|
@@ -89,6 +91,15 @@ export default function AgentsPanel({
|
|
|
89
91
|
{connected.length} {p.connected}
|
|
90
92
|
</span>
|
|
91
93
|
)}
|
|
94
|
+
<button
|
|
95
|
+
onClick={() => router.push('/agents')}
|
|
96
|
+
className="px-2 py-1 rounded border border-border text-2xs text-muted-foreground hover:text-foreground hover:bg-muted transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
97
|
+
aria-label={p.openDashboard}
|
|
98
|
+
title={p.openDashboard}
|
|
99
|
+
type="button"
|
|
100
|
+
>
|
|
101
|
+
{p.openDashboard}
|
|
102
|
+
</button>
|
|
92
103
|
<button
|
|
93
104
|
onClick={handleRefresh}
|
|
94
105
|
disabled={refreshing}
|
|
@@ -1,10 +1,24 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
4
|
-
import { Download, RefreshCw, CheckCircle2, AlertCircle, Loader2, ExternalLink, Circle } from 'lucide-react';
|
|
4
|
+
import { Download, RefreshCw, CheckCircle2, AlertCircle, Loader2, ExternalLink, Circle, Monitor } from 'lucide-react';
|
|
5
5
|
import { apiFetch } from '@/lib/api';
|
|
6
6
|
import { useLocale } from '@/lib/LocaleContext';
|
|
7
7
|
|
|
8
|
+
interface MindosDesktopBridge {
|
|
9
|
+
checkUpdate: () => Promise<{ available: boolean; version?: string }>;
|
|
10
|
+
installUpdate: () => Promise<void>;
|
|
11
|
+
onUpdateProgress?: (cb: (progress: { percent: number }) => void) => () => void;
|
|
12
|
+
onUpdateReady?: (cb: () => void) => () => void;
|
|
13
|
+
getAppInfo?: () => Promise<{ version?: string }>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function getDesktopBridge(): MindosDesktopBridge | null {
|
|
17
|
+
if (typeof window === 'undefined') return null;
|
|
18
|
+
const w = window as unknown as { mindos?: MindosDesktopBridge };
|
|
19
|
+
return w.mindos?.checkUpdate ? (w.mindos as MindosDesktopBridge) : null;
|
|
20
|
+
}
|
|
21
|
+
|
|
8
22
|
interface UpdateInfo {
|
|
9
23
|
current: string;
|
|
10
24
|
latest: string;
|
|
@@ -51,7 +65,182 @@ function StageIcon({ status }: { status: string }) {
|
|
|
51
65
|
}
|
|
52
66
|
}
|
|
53
67
|
|
|
68
|
+
/** Desktop (Electron) update: uses electron-updater via preload IPC bridge */
|
|
69
|
+
function DesktopUpdateTab() {
|
|
70
|
+
const { t } = useLocale();
|
|
71
|
+
const u = t.settings.update;
|
|
72
|
+
const bridge = getDesktopBridge()!;
|
|
73
|
+
const [state, setState] = useState<'idle' | 'checking' | 'downloading' | 'ready' | 'error'>('idle');
|
|
74
|
+
const [available, setAvailable] = useState(false);
|
|
75
|
+
const [version, setVersion] = useState<string | null>(null);
|
|
76
|
+
const [appVersion, setAppVersion] = useState('');
|
|
77
|
+
const [progress, setProgress] = useState(0);
|
|
78
|
+
const [errorMsg, setErrorMsg] = useState('');
|
|
79
|
+
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
bridge.getAppInfo?.().then((info) => {
|
|
82
|
+
if (info?.version) setAppVersion(info.version);
|
|
83
|
+
}).catch(() => {});
|
|
84
|
+
handleCheck();
|
|
85
|
+
const cleanups: Array<() => void> = [];
|
|
86
|
+
if (bridge.onUpdateProgress) {
|
|
87
|
+
cleanups.push(bridge.onUpdateProgress((p) => setProgress(Math.round(p.percent))));
|
|
88
|
+
}
|
|
89
|
+
if (bridge.onUpdateReady) {
|
|
90
|
+
cleanups.push(bridge.onUpdateReady(() => setState('ready')));
|
|
91
|
+
}
|
|
92
|
+
return () => cleanups.forEach((fn) => fn());
|
|
93
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
94
|
+
}, []);
|
|
95
|
+
|
|
96
|
+
const handleCheck = async () => {
|
|
97
|
+
setState('checking');
|
|
98
|
+
setErrorMsg('');
|
|
99
|
+
try {
|
|
100
|
+
const r = await bridge.checkUpdate();
|
|
101
|
+
setAvailable(r.available);
|
|
102
|
+
if (r.version) setVersion(r.version);
|
|
103
|
+
setState('idle');
|
|
104
|
+
} catch {
|
|
105
|
+
setState('error');
|
|
106
|
+
setErrorMsg(u?.error ?? 'Failed to check for updates.');
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const handleInstall = async () => {
|
|
111
|
+
setState('downloading');
|
|
112
|
+
setProgress(0);
|
|
113
|
+
try {
|
|
114
|
+
await bridge.installUpdate();
|
|
115
|
+
} catch {
|
|
116
|
+
setState('error');
|
|
117
|
+
setErrorMsg('Update failed. Please try again.');
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
<div className="space-y-5">
|
|
123
|
+
<div className="rounded-xl border border-border bg-card p-4 space-y-3">
|
|
124
|
+
<div className="flex items-center justify-between">
|
|
125
|
+
<div className="flex items-center gap-2">
|
|
126
|
+
<Monitor size={14} className="text-muted-foreground" />
|
|
127
|
+
<span className="text-sm font-medium text-foreground">MindOS Desktop</span>
|
|
128
|
+
</div>
|
|
129
|
+
{appVersion && <span className="text-xs font-mono text-muted-foreground">v{appVersion}</span>}
|
|
130
|
+
</div>
|
|
131
|
+
|
|
132
|
+
{state === 'checking' && (
|
|
133
|
+
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
134
|
+
<Loader2 size={13} className="animate-spin" />
|
|
135
|
+
{u?.checking ?? 'Checking for updates...'}
|
|
136
|
+
</div>
|
|
137
|
+
)}
|
|
138
|
+
|
|
139
|
+
{state === 'idle' && !available && (
|
|
140
|
+
<div className="flex items-center gap-2 text-xs text-emerald-600 dark:text-emerald-400">
|
|
141
|
+
<CheckCircle2 size={13} />
|
|
142
|
+
{u?.upToDate ?? "You're up to date"}
|
|
143
|
+
</div>
|
|
144
|
+
)}
|
|
145
|
+
|
|
146
|
+
{state === 'idle' && available && (
|
|
147
|
+
<div className="flex items-center gap-2 text-xs" style={{ color: 'var(--amber)' }}>
|
|
148
|
+
<Download size={13} />
|
|
149
|
+
{version ? `Update available: v${version}` : 'Update available'}
|
|
150
|
+
</div>
|
|
151
|
+
)}
|
|
152
|
+
|
|
153
|
+
{state === 'downloading' && (
|
|
154
|
+
<div className="space-y-2">
|
|
155
|
+
<div className="flex items-center gap-2 text-xs text-foreground">
|
|
156
|
+
<Loader2 size={13} className="animate-spin" style={{ color: 'var(--amber)' }} />
|
|
157
|
+
{u?.desktopDownloading ?? 'Downloading update...'}
|
|
158
|
+
</div>
|
|
159
|
+
<div className="h-1 rounded-full bg-muted overflow-hidden">
|
|
160
|
+
<div
|
|
161
|
+
className="h-full rounded-full transition-all duration-300"
|
|
162
|
+
style={{ width: `${Math.max(progress, 3)}%`, background: 'var(--amber)' }}
|
|
163
|
+
/>
|
|
164
|
+
</div>
|
|
165
|
+
</div>
|
|
166
|
+
)}
|
|
167
|
+
|
|
168
|
+
{state === 'ready' && (
|
|
169
|
+
<div className="flex items-center gap-2 text-xs text-emerald-600 dark:text-emerald-400">
|
|
170
|
+
<CheckCircle2 size={13} />
|
|
171
|
+
{u?.desktopReady ?? 'Update downloaded. Restart to apply.'}
|
|
172
|
+
</div>
|
|
173
|
+
)}
|
|
174
|
+
|
|
175
|
+
{state === 'error' && (
|
|
176
|
+
<div className="flex items-center gap-2 text-xs text-destructive">
|
|
177
|
+
<AlertCircle size={13} />
|
|
178
|
+
{errorMsg}
|
|
179
|
+
</div>
|
|
180
|
+
)}
|
|
181
|
+
</div>
|
|
182
|
+
|
|
183
|
+
<div className="flex items-center gap-2">
|
|
184
|
+
<button
|
|
185
|
+
onClick={handleCheck}
|
|
186
|
+
disabled={state === 'checking' || state === 'downloading'}
|
|
187
|
+
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border border-border text-muted-foreground hover:text-foreground hover:bg-muted disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
|
188
|
+
>
|
|
189
|
+
<RefreshCw size={12} className={state === 'checking' ? 'animate-spin' : ''} />
|
|
190
|
+
{u?.checkButton ?? 'Check for Updates'}
|
|
191
|
+
</button>
|
|
192
|
+
|
|
193
|
+
{state === 'idle' && available && (
|
|
194
|
+
<button
|
|
195
|
+
onClick={handleInstall}
|
|
196
|
+
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg font-medium text-white transition-colors"
|
|
197
|
+
style={{ background: 'var(--amber)' }}
|
|
198
|
+
>
|
|
199
|
+
<Download size={12} />
|
|
200
|
+
{version ? `Update to v${version}` : 'Update'}
|
|
201
|
+
</button>
|
|
202
|
+
)}
|
|
203
|
+
|
|
204
|
+
{state === 'ready' && (
|
|
205
|
+
<button
|
|
206
|
+
onClick={() => bridge.installUpdate()}
|
|
207
|
+
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg font-medium text-white transition-colors"
|
|
208
|
+
style={{ background: 'var(--amber)' }}
|
|
209
|
+
>
|
|
210
|
+
<RefreshCw size={12} />
|
|
211
|
+
{u?.desktopRestart ?? 'Restart Now'}
|
|
212
|
+
</button>
|
|
213
|
+
)}
|
|
214
|
+
</div>
|
|
215
|
+
|
|
216
|
+
<div className="border-t border-border pt-4 space-y-2">
|
|
217
|
+
<a
|
|
218
|
+
href={CHANGELOG_URL}
|
|
219
|
+
target="_blank"
|
|
220
|
+
rel="noopener noreferrer"
|
|
221
|
+
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
|
222
|
+
>
|
|
223
|
+
<ExternalLink size={12} />
|
|
224
|
+
{u?.releaseNotes ?? 'View release notes'}
|
|
225
|
+
</a>
|
|
226
|
+
<p className="text-2xs text-muted-foreground/60">
|
|
227
|
+
{u?.desktopHint ?? 'Updates are delivered through the Desktop app auto-updater.'}
|
|
228
|
+
</p>
|
|
229
|
+
</div>
|
|
230
|
+
</div>
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Router: Desktop uses electron-updater IPC; browser/CLI uses npm API */
|
|
54
235
|
export function UpdateTab() {
|
|
236
|
+
const [isDesktop, setIsDesktop] = useState(false);
|
|
237
|
+
useEffect(() => { setIsDesktop(!!getDesktopBridge()); }, []);
|
|
238
|
+
if (isDesktop) return <DesktopUpdateTab />;
|
|
239
|
+
return <BrowserUpdateTab />;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Browser / CLI update: uses npm registry check + POST /api/update */
|
|
243
|
+
function BrowserUpdateTab() {
|
|
55
244
|
const { t, locale } = useLocale();
|
|
56
245
|
const u = t.settings.update;
|
|
57
246
|
const [info, setInfo] = useState<UpdateInfo | null>(null);
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
export type ContentChangeSource = 'user' | 'agent' | 'system';
|
|
5
|
+
|
|
6
|
+
export interface ContentChangeEvent {
|
|
7
|
+
id: string;
|
|
8
|
+
ts: string;
|
|
9
|
+
op: string;
|
|
10
|
+
path: string;
|
|
11
|
+
source: ContentChangeSource;
|
|
12
|
+
summary: string;
|
|
13
|
+
before?: string;
|
|
14
|
+
after?: string;
|
|
15
|
+
beforePath?: string;
|
|
16
|
+
afterPath?: string;
|
|
17
|
+
truncated?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ContentChangeInput {
|
|
21
|
+
op: string;
|
|
22
|
+
path: string;
|
|
23
|
+
source: ContentChangeSource;
|
|
24
|
+
summary: string;
|
|
25
|
+
before?: string;
|
|
26
|
+
after?: string;
|
|
27
|
+
beforePath?: string;
|
|
28
|
+
afterPath?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface ChangeLogState {
|
|
32
|
+
version: 1;
|
|
33
|
+
lastSeenAt: string | null;
|
|
34
|
+
events: ContentChangeEvent[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface ListOptions {
|
|
38
|
+
path?: string;
|
|
39
|
+
limit?: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ContentChangeSummary {
|
|
43
|
+
unreadCount: number;
|
|
44
|
+
totalCount: number;
|
|
45
|
+
lastSeenAt: string | null;
|
|
46
|
+
latest: ContentChangeEvent | null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const LOG_DIR_NAME = '.mindos';
|
|
50
|
+
const LOG_FILE_NAME = 'change-log.json';
|
|
51
|
+
const MAX_EVENTS = 500;
|
|
52
|
+
const MAX_TEXT_CHARS = 12_000;
|
|
53
|
+
|
|
54
|
+
function nowIso() {
|
|
55
|
+
return new Date().toISOString();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function changeLogPath(mindRoot: string) {
|
|
59
|
+
return path.join(mindRoot, LOG_DIR_NAME, LOG_FILE_NAME);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function defaultState(): ChangeLogState {
|
|
63
|
+
return {
|
|
64
|
+
version: 1,
|
|
65
|
+
lastSeenAt: null,
|
|
66
|
+
events: [],
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function normalizeText(value: string | undefined): { value: string | undefined; truncated: boolean } {
|
|
71
|
+
if (typeof value !== 'string') return { value: undefined, truncated: false };
|
|
72
|
+
if (value.length <= MAX_TEXT_CHARS) return { value, truncated: false };
|
|
73
|
+
return {
|
|
74
|
+
value: value.slice(0, MAX_TEXT_CHARS),
|
|
75
|
+
truncated: true,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function readState(mindRoot: string): ChangeLogState {
|
|
80
|
+
const file = changeLogPath(mindRoot);
|
|
81
|
+
try {
|
|
82
|
+
if (!fs.existsSync(file)) return defaultState();
|
|
83
|
+
const raw = fs.readFileSync(file, 'utf-8');
|
|
84
|
+
const parsed = JSON.parse(raw) as Partial<ChangeLogState>;
|
|
85
|
+
if (!Array.isArray(parsed.events)) return defaultState();
|
|
86
|
+
return {
|
|
87
|
+
version: 1,
|
|
88
|
+
lastSeenAt: typeof parsed.lastSeenAt === 'string' ? parsed.lastSeenAt : null,
|
|
89
|
+
events: parsed.events,
|
|
90
|
+
};
|
|
91
|
+
} catch {
|
|
92
|
+
return defaultState();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function writeState(mindRoot: string, state: ChangeLogState): void {
|
|
97
|
+
const file = changeLogPath(mindRoot);
|
|
98
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
99
|
+
fs.writeFileSync(file, JSON.stringify(state, null, 2), 'utf-8');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function appendContentChange(mindRoot: string, input: ContentChangeInput): ContentChangeEvent {
|
|
103
|
+
const state = readState(mindRoot);
|
|
104
|
+
const before = normalizeText(input.before);
|
|
105
|
+
const after = normalizeText(input.after);
|
|
106
|
+
const event: ContentChangeEvent = {
|
|
107
|
+
id: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
|
|
108
|
+
ts: nowIso(),
|
|
109
|
+
op: input.op,
|
|
110
|
+
path: input.path,
|
|
111
|
+
source: input.source,
|
|
112
|
+
summary: input.summary,
|
|
113
|
+
before: before.value,
|
|
114
|
+
after: after.value,
|
|
115
|
+
beforePath: input.beforePath,
|
|
116
|
+
afterPath: input.afterPath,
|
|
117
|
+
truncated: before.truncated || after.truncated || undefined,
|
|
118
|
+
};
|
|
119
|
+
state.events.unshift(event);
|
|
120
|
+
if (state.events.length > MAX_EVENTS) {
|
|
121
|
+
state.events = state.events.slice(0, MAX_EVENTS);
|
|
122
|
+
}
|
|
123
|
+
writeState(mindRoot, state);
|
|
124
|
+
return event;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function listContentChanges(mindRoot: string, options: ListOptions = {}): ContentChangeEvent[] {
|
|
128
|
+
const state = readState(mindRoot);
|
|
129
|
+
const limit = Math.max(1, Math.min(options.limit ?? 50, 200));
|
|
130
|
+
const pathFilter = options.path;
|
|
131
|
+
const events = pathFilter
|
|
132
|
+
? state.events.filter((event) => event.path === pathFilter || event.beforePath === pathFilter || event.afterPath === pathFilter)
|
|
133
|
+
: state.events;
|
|
134
|
+
return events.slice(0, limit);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function markContentChangesSeen(mindRoot: string): void {
|
|
138
|
+
const state = readState(mindRoot);
|
|
139
|
+
state.lastSeenAt = nowIso();
|
|
140
|
+
writeState(mindRoot, state);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function getContentChangeSummary(mindRoot: string): ContentChangeSummary {
|
|
144
|
+
const state = readState(mindRoot);
|
|
145
|
+
const lastSeenAtMs = state.lastSeenAt ? new Date(state.lastSeenAt).getTime() : 0;
|
|
146
|
+
const unreadCount = state.events.filter((event) => new Date(event.ts).getTime() > lastSeenAtMs).length;
|
|
147
|
+
return {
|
|
148
|
+
unreadCount,
|
|
149
|
+
totalCount: state.events.length,
|
|
150
|
+
lastSeenAt: state.lastSeenAt,
|
|
151
|
+
latest: state.events[0] ?? null,
|
|
152
|
+
};
|
|
153
|
+
}
|
package/app/lib/core/index.ts
CHANGED
|
@@ -64,3 +64,17 @@ export { isGitRepo, gitLog, gitShowFile } from './git';
|
|
|
64
64
|
export { createSpaceFilesystem } from './create-space';
|
|
65
65
|
export { summarizeTopLevelSpaces } from './list-spaces';
|
|
66
66
|
export type { MindSpaceSummary } from './list-spaces';
|
|
67
|
+
|
|
68
|
+
// Content changes
|
|
69
|
+
export {
|
|
70
|
+
appendContentChange,
|
|
71
|
+
listContentChanges,
|
|
72
|
+
markContentChangesSeen,
|
|
73
|
+
getContentChangeSummary,
|
|
74
|
+
} from './content-changes';
|
|
75
|
+
export type {
|
|
76
|
+
ContentChangeEvent,
|
|
77
|
+
ContentChangeInput,
|
|
78
|
+
ContentChangeSummary,
|
|
79
|
+
ContentChangeSource,
|
|
80
|
+
} from './content-changes';
|