@geminilight/mindos 0.5.54 → 0.5.56
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/echo/[segment]/page.tsx +15 -0
- package/app/app/echo/page.tsx +6 -0
- package/app/components/RightAskPanel.tsx +14 -11
- package/app/components/SidebarLayout.tsx +7 -0
- package/app/components/ask/AskContent.tsx +10 -2
- package/app/components/echo/EchoHero.tsx +55 -0
- package/app/components/echo/EchoInsightCollapsible.tsx +184 -0
- package/app/components/echo/EchoPageSections.tsx +86 -0
- package/app/components/echo/EchoSegmentNav.tsx +58 -0
- package/app/components/echo/EchoSegmentPageClient.tsx +281 -0
- package/app/components/panels/EchoPanel.tsx +23 -56
- package/app/components/panels/PanelNavRow.tsx +24 -7
- package/app/hooks/useSettingsAiAvailable.ts +29 -0
- package/app/lib/echo-insight-prompt.ts +44 -0
- package/app/lib/echo-segments.ts +27 -0
- package/app/lib/i18n-en.ts +53 -6
- package/app/lib/i18n-zh.ts +53 -6
- package/app/lib/settings-ai-client.ts +26 -0
- package/package.json +1 -1
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { notFound } from 'next/navigation';
|
|
2
|
+
import { isEchoSegment } from '@/lib/echo-segments';
|
|
3
|
+
import EchoSegmentPageClient from '@/components/echo/EchoSegmentPageClient';
|
|
4
|
+
|
|
5
|
+
interface PageProps {
|
|
6
|
+
params: Promise<{ segment: string }>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export default async function EchoSegmentPage({ params }: PageProps) {
|
|
10
|
+
const { segment } = await params;
|
|
11
|
+
if (!isEchoSegment(segment)) {
|
|
12
|
+
notFound();
|
|
13
|
+
}
|
|
14
|
+
return <EchoSegmentPageClient segment={segment} />;
|
|
15
|
+
}
|
|
@@ -40,7 +40,7 @@ export default function RightAskPanel({
|
|
|
40
40
|
return (
|
|
41
41
|
<aside
|
|
42
42
|
className={`
|
|
43
|
-
hidden md:flex fixed top-0 right-0 h-screen z-
|
|
43
|
+
hidden md:flex fixed top-0 right-0 h-screen z-40
|
|
44
44
|
flex-col bg-card border-l border-border
|
|
45
45
|
transition-transform duration-200 ease-out
|
|
46
46
|
${open ? 'translate-x-0' : 'translate-x-full pointer-events-none'}
|
|
@@ -61,16 +61,19 @@ export default function RightAskPanel({
|
|
|
61
61
|
</button>
|
|
62
62
|
</div>
|
|
63
63
|
}>
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
64
|
+
{/* Flex column + min-h-0 so MessageList flex-1 gets a bounded height (fragment children are direct flex items) */}
|
|
65
|
+
<div className="flex min-h-0 w-full flex-1 flex-col overflow-hidden">
|
|
66
|
+
<AskContent
|
|
67
|
+
visible={open}
|
|
68
|
+
variant="panel"
|
|
69
|
+
currentFile={open ? currentFile : undefined}
|
|
70
|
+
initialMessage={initialMessage}
|
|
71
|
+
onFirstMessage={onFirstMessage}
|
|
72
|
+
onClose={onClose}
|
|
73
|
+
askMode={askMode}
|
|
74
|
+
onModeSwitch={onModeSwitch}
|
|
75
|
+
/>
|
|
76
|
+
</div>
|
|
74
77
|
</ErrorBoundary>
|
|
75
78
|
|
|
76
79
|
{/* Drag resize handle — LEFT edge */}
|
|
@@ -115,6 +115,13 @@ export default function SidebarLayout({ fileTree, children }: SidebarLayoutProps
|
|
|
115
115
|
return () => cancelAnimationFrame(id);
|
|
116
116
|
}, [pathname]);
|
|
117
117
|
|
|
118
|
+
// Deep-link Echo routes: keep left Echo panel aligned with URL
|
|
119
|
+
useEffect(() => {
|
|
120
|
+
if (pathname?.startsWith('/echo')) {
|
|
121
|
+
lp.setActivePanel('echo');
|
|
122
|
+
}
|
|
123
|
+
}, [pathname, lp.setActivePanel]);
|
|
124
|
+
|
|
118
125
|
const handleAgentDetailWidthCommit = useCallback((w: number) => {
|
|
119
126
|
setAgentDetailWidth(w);
|
|
120
127
|
try {
|
|
@@ -12,6 +12,7 @@ import MentionPopover from '@/components/ask/MentionPopover';
|
|
|
12
12
|
import SessionHistory from '@/components/ask/SessionHistory';
|
|
13
13
|
import FileChip from '@/components/ask/FileChip';
|
|
14
14
|
import { consumeUIMessageStream } from '@/lib/agent/stream-consumer';
|
|
15
|
+
import { cn } from '@/lib/utils';
|
|
15
16
|
|
|
16
17
|
interface AskContentProps {
|
|
17
18
|
/** Controls visibility — 'open' for modal, 'active' for panel */
|
|
@@ -414,8 +415,15 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
|
|
|
414
415
|
</form>
|
|
415
416
|
</div>
|
|
416
417
|
|
|
417
|
-
{/* Footer hints */}
|
|
418
|
-
<div
|
|
418
|
+
{/* Footer hints — use full class strings so Tailwind JIT includes utilities */}
|
|
419
|
+
<div
|
|
420
|
+
className={cn(
|
|
421
|
+
'flex shrink-0 items-center',
|
|
422
|
+
isPanel
|
|
423
|
+
? 'gap-2 px-3 pb-1.5 text-[10px] text-muted-foreground/40'
|
|
424
|
+
: 'hidden gap-3 px-4 pb-2 text-xs text-muted-foreground/50 md:flex',
|
|
425
|
+
)}
|
|
426
|
+
>
|
|
419
427
|
<span><kbd className="font-mono">↵</kbd> {t.ask.send}</span>
|
|
420
428
|
<span><kbd className="font-mono">@</kbd> {t.ask.attachFile}</span>
|
|
421
429
|
{!isPanel && <span><kbd className="font-mono">ESC</kbd> {t.search.close}</span>}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import Link from 'next/link';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Echo page hero: kicker, minimal breadcrumb (parent only — h1 holds the section title),
|
|
7
|
+
* lead. Avoids repeating the current segment name in both breadcrumb and h1.
|
|
8
|
+
*/
|
|
9
|
+
export function EchoHero({
|
|
10
|
+
breadcrumbNav,
|
|
11
|
+
parentHref,
|
|
12
|
+
parent,
|
|
13
|
+
heroKicker,
|
|
14
|
+
pageTitle,
|
|
15
|
+
lead,
|
|
16
|
+
titleId,
|
|
17
|
+
}: {
|
|
18
|
+
breadcrumbNav: string;
|
|
19
|
+
parentHref: string;
|
|
20
|
+
parent: string;
|
|
21
|
+
heroKicker: string;
|
|
22
|
+
pageTitle: string;
|
|
23
|
+
lead: string;
|
|
24
|
+
titleId: string;
|
|
25
|
+
}) {
|
|
26
|
+
return (
|
|
27
|
+
<header className="relative overflow-hidden rounded-xl border border-border bg-card px-5 py-6 shadow-sm sm:px-8 sm:py-8">
|
|
28
|
+
<div
|
|
29
|
+
className="absolute bottom-5 left-0 top-5 w-0.5 rounded-full bg-[var(--amber)] sm:bottom-6 sm:top-6"
|
|
30
|
+
aria-hidden
|
|
31
|
+
/>
|
|
32
|
+
<div className="relative pl-4 sm:pl-5">
|
|
33
|
+
<p className="mb-3 font-sans text-2xs font-semibold uppercase tracking-[0.2em] text-[var(--amber)]">
|
|
34
|
+
{heroKicker}
|
|
35
|
+
</p>
|
|
36
|
+
<nav aria-label={breadcrumbNav} className="mb-5 font-sans text-sm">
|
|
37
|
+
<ol className="m-0 list-none p-0">
|
|
38
|
+
<li>
|
|
39
|
+
<Link
|
|
40
|
+
href={parentHref}
|
|
41
|
+
className="text-muted-foreground transition-colors duration-150 hover:text-[var(--amber)] focus-visible:rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
42
|
+
>
|
|
43
|
+
{parent}
|
|
44
|
+
</Link>
|
|
45
|
+
</li>
|
|
46
|
+
</ol>
|
|
47
|
+
</nav>
|
|
48
|
+
<h1 id={titleId} className="font-display text-2xl font-semibold tracking-tight text-foreground md:text-3xl">
|
|
49
|
+
{pageTitle}
|
|
50
|
+
</h1>
|
|
51
|
+
<p className="mt-3 max-w-prose font-sans text-base leading-relaxed text-muted-foreground">{lead}</p>
|
|
52
|
+
</div>
|
|
53
|
+
</header>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useId, useRef, useState } from 'react';
|
|
4
|
+
import { ChevronDown, Loader2, Sparkles } from 'lucide-react';
|
|
5
|
+
import ReactMarkdown from 'react-markdown';
|
|
6
|
+
import remarkGfm from 'remark-gfm';
|
|
7
|
+
import { cn } from '@/lib/utils';
|
|
8
|
+
import { consumeUIMessageStream } from '@/lib/agent/stream-consumer';
|
|
9
|
+
import { useSettingsAiAvailable } from '@/hooks/useSettingsAiAvailable';
|
|
10
|
+
|
|
11
|
+
const proseInsight =
|
|
12
|
+
'prose prose-sm prose-panel dark:prose-invert max-w-none text-foreground ' +
|
|
13
|
+
'prose-p:my-1 prose-p:leading-relaxed ' +
|
|
14
|
+
'prose-headings:font-semibold prose-headings:my-2 prose-headings:text-[13px] ' +
|
|
15
|
+
'prose-ul:my-1 prose-li:my-0.5 prose-ol:my-1 ' +
|
|
16
|
+
'prose-code:text-[0.8em] prose-code:bg-muted prose-code:px-1 prose-code:py-0.5 prose-code:rounded prose-code:before:content-none prose-code:after:content-none ' +
|
|
17
|
+
'prose-pre:bg-muted prose-pre:text-foreground prose-pre:text-xs ' +
|
|
18
|
+
'prose-blockquote:border-l-[var(--amber)] prose-blockquote:text-muted-foreground ' +
|
|
19
|
+
'prose-a:text-[var(--amber)] prose-a:no-underline hover:prose-a:underline ' +
|
|
20
|
+
'prose-strong:text-foreground prose-strong:font-semibold';
|
|
21
|
+
|
|
22
|
+
export function EchoInsightCollapsible({
|
|
23
|
+
title,
|
|
24
|
+
showLabel,
|
|
25
|
+
hideLabel,
|
|
26
|
+
hint,
|
|
27
|
+
generateLabel,
|
|
28
|
+
noAiHint,
|
|
29
|
+
generatingLabel,
|
|
30
|
+
errorPrefix,
|
|
31
|
+
retryLabel,
|
|
32
|
+
userPrompt,
|
|
33
|
+
}: {
|
|
34
|
+
title: string;
|
|
35
|
+
showLabel: string;
|
|
36
|
+
hideLabel: string;
|
|
37
|
+
hint: string;
|
|
38
|
+
generateLabel: string;
|
|
39
|
+
noAiHint: string;
|
|
40
|
+
generatingLabel: string;
|
|
41
|
+
errorPrefix: string;
|
|
42
|
+
retryLabel: string;
|
|
43
|
+
userPrompt: string;
|
|
44
|
+
}) {
|
|
45
|
+
const [open, setOpen] = useState(false);
|
|
46
|
+
const [streaming, setStreaming] = useState(false);
|
|
47
|
+
const [insightMd, setInsightMd] = useState('');
|
|
48
|
+
const [err, setErr] = useState('');
|
|
49
|
+
const panelId = useId();
|
|
50
|
+
const btnId = `${panelId}-btn`;
|
|
51
|
+
const abortRef = useRef<AbortController | null>(null);
|
|
52
|
+
const { ready: aiReady, loading: aiLoading } = useSettingsAiAvailable();
|
|
53
|
+
|
|
54
|
+
useEffect(() => () => abortRef.current?.abort(), []);
|
|
55
|
+
|
|
56
|
+
const runGenerate = useCallback(async () => {
|
|
57
|
+
abortRef.current?.abort();
|
|
58
|
+
const ctrl = new AbortController();
|
|
59
|
+
abortRef.current = ctrl;
|
|
60
|
+
setErr('');
|
|
61
|
+
setInsightMd('');
|
|
62
|
+
setStreaming(true);
|
|
63
|
+
try {
|
|
64
|
+
const res = await fetch('/api/ask', {
|
|
65
|
+
method: 'POST',
|
|
66
|
+
headers: { 'Content-Type': 'application/json' },
|
|
67
|
+
body: JSON.stringify({
|
|
68
|
+
messages: [{ role: 'user', content: userPrompt }],
|
|
69
|
+
maxSteps: 16,
|
|
70
|
+
}),
|
|
71
|
+
signal: ctrl.signal,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
if (!res.ok) {
|
|
75
|
+
let msg = `HTTP ${res.status}`;
|
|
76
|
+
try {
|
|
77
|
+
const j = (await res.json()) as { error?: { message?: string }; message?: string };
|
|
78
|
+
msg = j?.error?.message ?? j?.message ?? msg;
|
|
79
|
+
} catch {
|
|
80
|
+
/* ignore */
|
|
81
|
+
}
|
|
82
|
+
throw new Error(msg);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (!res.body) throw new Error('No response body');
|
|
86
|
+
|
|
87
|
+
await consumeUIMessageStream(
|
|
88
|
+
res.body,
|
|
89
|
+
(msg) => {
|
|
90
|
+
setInsightMd(msg.content ?? '');
|
|
91
|
+
},
|
|
92
|
+
ctrl.signal,
|
|
93
|
+
);
|
|
94
|
+
} catch (e) {
|
|
95
|
+
if (e instanceof Error && e.name === 'AbortError') return;
|
|
96
|
+
setErr(e instanceof Error ? e.message : String(e));
|
|
97
|
+
} finally {
|
|
98
|
+
setStreaming(false);
|
|
99
|
+
abortRef.current = null;
|
|
100
|
+
}
|
|
101
|
+
}, [userPrompt]);
|
|
102
|
+
|
|
103
|
+
const generateDisabled = aiLoading || !aiReady || streaming;
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<div className="mt-10 overflow-hidden rounded-xl border border-border bg-card shadow-sm transition-[border-color,box-shadow] duration-150 ease-out hover:border-[var(--amber)]/15 hover:shadow-md">
|
|
107
|
+
<button
|
|
108
|
+
id={btnId}
|
|
109
|
+
type="button"
|
|
110
|
+
className="flex w-full items-center gap-3 px-5 py-4 text-left transition-colors duration-200 hover:bg-muted/25 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
|
111
|
+
aria-expanded={open}
|
|
112
|
+
aria-controls={panelId}
|
|
113
|
+
onClick={() => setOpen((v) => !v)}
|
|
114
|
+
>
|
|
115
|
+
<span
|
|
116
|
+
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-[var(--amber-dim)] text-[var(--amber)]"
|
|
117
|
+
aria-hidden
|
|
118
|
+
>
|
|
119
|
+
<Sparkles size={17} strokeWidth={1.75} />
|
|
120
|
+
</span>
|
|
121
|
+
<span className="flex-1 font-sans text-sm font-medium text-foreground">{title}</span>
|
|
122
|
+
<ChevronDown
|
|
123
|
+
size={16}
|
|
124
|
+
className={cn(
|
|
125
|
+
'shrink-0 text-muted-foreground transition-transform duration-200',
|
|
126
|
+
open && 'rotate-180',
|
|
127
|
+
)}
|
|
128
|
+
aria-hidden
|
|
129
|
+
/>
|
|
130
|
+
<span className="sr-only">{open ? hideLabel : showLabel}</span>
|
|
131
|
+
</button>
|
|
132
|
+
{open ? (
|
|
133
|
+
<div
|
|
134
|
+
id={panelId}
|
|
135
|
+
role="region"
|
|
136
|
+
aria-labelledby={btnId}
|
|
137
|
+
className="border-t border-border/60 px-5 pb-5 pt-4"
|
|
138
|
+
>
|
|
139
|
+
<p className="font-sans text-sm leading-relaxed text-muted-foreground">{hint}</p>
|
|
140
|
+
<div className="mt-4 flex flex-wrap items-center gap-2">
|
|
141
|
+
<button
|
|
142
|
+
type="button"
|
|
143
|
+
disabled={generateDisabled}
|
|
144
|
+
onClick={runGenerate}
|
|
145
|
+
className="inline-flex items-center gap-2 rounded-lg bg-[var(--amber)] px-3 py-2 font-sans text-sm font-medium text-white transition-opacity duration-150 hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
146
|
+
>
|
|
147
|
+
{streaming ? <Loader2 size={16} className="animate-spin shrink-0" aria-hidden /> : null}
|
|
148
|
+
{streaming ? generatingLabel : generateLabel}
|
|
149
|
+
</button>
|
|
150
|
+
{err ? (
|
|
151
|
+
<button
|
|
152
|
+
type="button"
|
|
153
|
+
onClick={runGenerate}
|
|
154
|
+
disabled={streaming || !aiReady}
|
|
155
|
+
className="font-sans text-sm text-[var(--amber)] underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm"
|
|
156
|
+
>
|
|
157
|
+
{retryLabel}
|
|
158
|
+
</button>
|
|
159
|
+
) : null}
|
|
160
|
+
</div>
|
|
161
|
+
{!aiLoading && !aiReady ? (
|
|
162
|
+
<p className="mt-2 font-sans text-2xs text-muted-foreground">{noAiHint}</p>
|
|
163
|
+
) : null}
|
|
164
|
+
{err ? (
|
|
165
|
+
<p className="mt-3 font-sans text-sm text-error" role="alert">
|
|
166
|
+
{errorPrefix} {err}
|
|
167
|
+
</p>
|
|
168
|
+
) : null}
|
|
169
|
+
{insightMd ? (
|
|
170
|
+
<div className={cn(proseInsight, 'mt-4 border-t border-border/60 pt-4')}>
|
|
171
|
+
<ReactMarkdown remarkPlugins={[remarkGfm]}>{insightMd}</ReactMarkdown>
|
|
172
|
+
{streaming ? (
|
|
173
|
+
<span
|
|
174
|
+
className="ml-0.5 inline-block h-3.5 w-1 animate-pulse rounded-sm bg-[var(--amber)] align-middle"
|
|
175
|
+
aria-hidden
|
|
176
|
+
/>
|
|
177
|
+
) : null}
|
|
178
|
+
</div>
|
|
179
|
+
) : null}
|
|
180
|
+
</div>
|
|
181
|
+
) : null}
|
|
182
|
+
</div>
|
|
183
|
+
);
|
|
184
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from 'react';
|
|
4
|
+
import { Library } from 'lucide-react';
|
|
5
|
+
|
|
6
|
+
export function EchoFactSnapshot({
|
|
7
|
+
headingId,
|
|
8
|
+
heading,
|
|
9
|
+
snapshotBadge,
|
|
10
|
+
emptyTitle,
|
|
11
|
+
emptyBody,
|
|
12
|
+
actions,
|
|
13
|
+
}: {
|
|
14
|
+
headingId: string;
|
|
15
|
+
heading: string;
|
|
16
|
+
snapshotBadge: string;
|
|
17
|
+
emptyTitle: string;
|
|
18
|
+
emptyBody: string;
|
|
19
|
+
/** e.g. continue-in-Agent CTA — inside this card so it is not orphaned between sections */
|
|
20
|
+
actions?: ReactNode;
|
|
21
|
+
}) {
|
|
22
|
+
return (
|
|
23
|
+
<section
|
|
24
|
+
className="rounded-xl border border-border bg-card p-5 shadow-sm transition-[border-color,box-shadow] duration-150 ease-out hover:border-[var(--amber)]/20 hover:shadow-md sm:p-6"
|
|
25
|
+
aria-labelledby={headingId}
|
|
26
|
+
>
|
|
27
|
+
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
|
28
|
+
<div className="flex items-start gap-3">
|
|
29
|
+
<span
|
|
30
|
+
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-[var(--amber-dim)] text-[var(--amber)]"
|
|
31
|
+
aria-hidden
|
|
32
|
+
>
|
|
33
|
+
<Library size={18} strokeWidth={1.75} />
|
|
34
|
+
</span>
|
|
35
|
+
<div>
|
|
36
|
+
<h2
|
|
37
|
+
id={headingId}
|
|
38
|
+
className="font-sans text-xs font-semibold uppercase tracking-wide text-muted-foreground"
|
|
39
|
+
>
|
|
40
|
+
{heading}
|
|
41
|
+
</h2>
|
|
42
|
+
<p className="mt-2 font-sans font-medium text-foreground">{emptyTitle}</p>
|
|
43
|
+
</div>
|
|
44
|
+
</div>
|
|
45
|
+
<span className="font-sans text-2xs font-medium uppercase tracking-wide text-[var(--amber)] sm:mt-0.5 sm:shrink-0 rounded-md bg-[var(--amber-dim)] px-2 py-1">
|
|
46
|
+
{snapshotBadge}
|
|
47
|
+
</span>
|
|
48
|
+
</div>
|
|
49
|
+
<p className="mt-4 border-t border-border/60 pt-4 font-sans text-sm leading-relaxed text-muted-foreground">
|
|
50
|
+
{emptyBody}
|
|
51
|
+
</p>
|
|
52
|
+
{actions ? (
|
|
53
|
+
<div className="mt-4 border-t border-border/60 pt-4">{actions}</div>
|
|
54
|
+
) : null}
|
|
55
|
+
</section>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function EchoContinuedGroups({
|
|
60
|
+
draftsLabel,
|
|
61
|
+
todosLabel,
|
|
62
|
+
subEmptyHint,
|
|
63
|
+
footer,
|
|
64
|
+
}: {
|
|
65
|
+
draftsLabel: string;
|
|
66
|
+
todosLabel: string;
|
|
67
|
+
subEmptyHint: string;
|
|
68
|
+
footer?: ReactNode;
|
|
69
|
+
}) {
|
|
70
|
+
const cell = (label: string) => (
|
|
71
|
+
<div className="flex min-h-[5.75rem] flex-col justify-center rounded-xl border border-dashed border-border/80 bg-muted/10 px-4 py-4">
|
|
72
|
+
<h3 className="font-sans text-sm font-medium text-foreground">{label}</h3>
|
|
73
|
+
<p className="mt-2 font-sans text-2xs leading-relaxed text-muted-foreground">{subEmptyHint}</p>
|
|
74
|
+
</div>
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<div className="space-y-4">
|
|
79
|
+
<div className="grid gap-3 sm:grid-cols-2">
|
|
80
|
+
{cell(draftsLabel)}
|
|
81
|
+
{cell(todosLabel)}
|
|
82
|
+
</div>
|
|
83
|
+
{footer ? <div className="border-t border-border/60 pt-4">{footer}</div> : null}
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import Link from 'next/link';
|
|
4
|
+
import { useLocale } from '@/lib/LocaleContext';
|
|
5
|
+
import { cn } from '@/lib/utils';
|
|
6
|
+
import { ECHO_SEGMENT_HREF, ECHO_SEGMENT_ORDER, type EchoSegment } from '@/lib/echo-segments';
|
|
7
|
+
|
|
8
|
+
function labelForSegment(
|
|
9
|
+
segment: EchoSegment,
|
|
10
|
+
echo: ReturnType<typeof useLocale>['t']['panels']['echo'],
|
|
11
|
+
): string {
|
|
12
|
+
switch (segment) {
|
|
13
|
+
case 'about-you':
|
|
14
|
+
return echo.aboutYouTitle;
|
|
15
|
+
case 'continued':
|
|
16
|
+
return echo.continuedTitle;
|
|
17
|
+
case 'daily':
|
|
18
|
+
return echo.dailyEchoTitle;
|
|
19
|
+
case 'past-you':
|
|
20
|
+
return echo.pastYouTitle;
|
|
21
|
+
case 'growth':
|
|
22
|
+
return echo.intentGrowthTitle;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export default function EchoSegmentNav({ activeSegment }: { activeSegment: EchoSegment }) {
|
|
27
|
+
const { t } = useLocale();
|
|
28
|
+
const echo = t.panels.echo;
|
|
29
|
+
const aria = t.echoPages.segmentNavAria;
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<nav aria-label={aria} className="mt-6 font-sans">
|
|
33
|
+
<ul className="-mx-1 flex snap-x snap-mandatory gap-1.5 overflow-x-auto px-1 pb-1 [scrollbar-width:thin]">
|
|
34
|
+
{ECHO_SEGMENT_ORDER.map((segment) => {
|
|
35
|
+
const href = ECHO_SEGMENT_HREF[segment];
|
|
36
|
+
const label = labelForSegment(segment, echo);
|
|
37
|
+
const isActive = segment === activeSegment;
|
|
38
|
+
return (
|
|
39
|
+
<li key={segment} className="snap-start shrink-0">
|
|
40
|
+
<Link
|
|
41
|
+
href={href}
|
|
42
|
+
aria-current={isActive ? 'page' : undefined}
|
|
43
|
+
className={cn(
|
|
44
|
+
'inline-flex min-h-9 max-w-[11rem] items-center rounded-full border px-3 py-1.5 text-sm transition-[background-color,border-color,color] duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
|
45
|
+
isActive
|
|
46
|
+
? 'border-[var(--amber)]/45 bg-[var(--amber-dim)]/50 font-medium text-foreground'
|
|
47
|
+
: 'border-transparent bg-muted/35 text-muted-foreground hover:bg-muted/55 hover:text-foreground',
|
|
48
|
+
)}
|
|
49
|
+
>
|
|
50
|
+
<span className="truncate">{label}</span>
|
|
51
|
+
</Link>
|
|
52
|
+
</li>
|
|
53
|
+
);
|
|
54
|
+
})}
|
|
55
|
+
</ul>
|
|
56
|
+
</nav>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useId, useMemo, useState } from 'react';
|
|
4
|
+
import type { EchoSegment } from '@/lib/echo-segments';
|
|
5
|
+
import { buildEchoInsightUserPrompt } from '@/lib/echo-insight-prompt';
|
|
6
|
+
import type { Locale, Messages } from '@/lib/i18n';
|
|
7
|
+
import { useLocale } from '@/lib/LocaleContext';
|
|
8
|
+
import { openAskModal } from '@/hooks/useAskModal';
|
|
9
|
+
import { EchoHero } from './EchoHero';
|
|
10
|
+
import EchoSegmentNav from './EchoSegmentNav';
|
|
11
|
+
import { EchoInsightCollapsible } from './EchoInsightCollapsible';
|
|
12
|
+
import { EchoContinuedGroups, EchoFactSnapshot } from './EchoPageSections';
|
|
13
|
+
|
|
14
|
+
const STORAGE_DAILY = 'mindos-echo-daily-line';
|
|
15
|
+
const STORAGE_GROWTH = 'mindos-echo-growth-intent';
|
|
16
|
+
|
|
17
|
+
function segmentTitle(segment: EchoSegment, echo: ReturnType<typeof useLocale>['t']['panels']['echo']): string {
|
|
18
|
+
switch (segment) {
|
|
19
|
+
case 'about-you':
|
|
20
|
+
return echo.aboutYouTitle;
|
|
21
|
+
case 'continued':
|
|
22
|
+
return echo.continuedTitle;
|
|
23
|
+
case 'daily':
|
|
24
|
+
return echo.dailyEchoTitle;
|
|
25
|
+
case 'past-you':
|
|
26
|
+
return echo.pastYouTitle;
|
|
27
|
+
case 'growth':
|
|
28
|
+
return echo.intentGrowthTitle;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function segmentLead(segment: EchoSegment, p: ReturnType<typeof useLocale>['t']['echoPages']): string {
|
|
33
|
+
switch (segment) {
|
|
34
|
+
case 'about-you':
|
|
35
|
+
return p.aboutYouLead;
|
|
36
|
+
case 'continued':
|
|
37
|
+
return p.continuedLead;
|
|
38
|
+
case 'daily':
|
|
39
|
+
return p.dailyLead;
|
|
40
|
+
case 'past-you':
|
|
41
|
+
return p.pastYouLead;
|
|
42
|
+
case 'growth':
|
|
43
|
+
return p.growthLead;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const fieldLabelClass =
|
|
48
|
+
'block font-sans text-2xs font-semibold uppercase tracking-wide text-muted-foreground';
|
|
49
|
+
const inputClass =
|
|
50
|
+
'mt-2 w-full min-h-[5rem] resize-y rounded-lg border border-border bg-background px-3 py-2.5 font-sans text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring';
|
|
51
|
+
const cardSectionClass =
|
|
52
|
+
'rounded-xl border border-border bg-card p-5 shadow-sm transition-[border-color,box-shadow] duration-150 ease-out hover:border-[var(--amber)]/20 hover:shadow-md sm:p-6';
|
|
53
|
+
|
|
54
|
+
function echoSnapshotCopy(segment: EchoSegment, p: Messages['echoPages']): { title: string; body: string } {
|
|
55
|
+
switch (segment) {
|
|
56
|
+
case 'about-you':
|
|
57
|
+
return { title: p.snapshotAboutYouTitle, body: p.snapshotAboutYouBody };
|
|
58
|
+
case 'continued':
|
|
59
|
+
return { title: p.snapshotContinuedTitle, body: p.snapshotContinuedBody };
|
|
60
|
+
case 'daily':
|
|
61
|
+
return { title: p.snapshotDailyTitle, body: p.snapshotDailyBody };
|
|
62
|
+
case 'past-you':
|
|
63
|
+
return { title: p.snapshotPastYouTitle, body: p.snapshotPastYouBody };
|
|
64
|
+
case 'growth':
|
|
65
|
+
return { title: p.snapshotGrowthTitle, body: p.snapshotGrowthBody };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export default function EchoSegmentPageClient({ segment }: { segment: EchoSegment }) {
|
|
70
|
+
const { t, locale } = useLocale();
|
|
71
|
+
const p = t.echoPages;
|
|
72
|
+
const echo = t.panels.echo;
|
|
73
|
+
const title = segmentTitle(segment, echo);
|
|
74
|
+
const lead = segmentLead(segment, p);
|
|
75
|
+
const factsHeadingId = useId();
|
|
76
|
+
const pageTitleId = 'echo-page-title';
|
|
77
|
+
|
|
78
|
+
const [dailyLine, setDailyLine] = useState('');
|
|
79
|
+
const [growthIntent, setGrowthIntent] = useState('');
|
|
80
|
+
|
|
81
|
+
const snapshot = useMemo(() => echoSnapshotCopy(segment, p), [segment, p]);
|
|
82
|
+
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
try {
|
|
85
|
+
const d = localStorage.getItem(STORAGE_DAILY);
|
|
86
|
+
if (d) setDailyLine(d);
|
|
87
|
+
const g = localStorage.getItem(STORAGE_GROWTH);
|
|
88
|
+
if (g) setGrowthIntent(g);
|
|
89
|
+
} catch {
|
|
90
|
+
/* ignore */
|
|
91
|
+
}
|
|
92
|
+
}, []);
|
|
93
|
+
|
|
94
|
+
const persistDaily = useCallback(() => {
|
|
95
|
+
try {
|
|
96
|
+
localStorage.setItem(STORAGE_DAILY, dailyLine);
|
|
97
|
+
} catch {
|
|
98
|
+
/* ignore */
|
|
99
|
+
}
|
|
100
|
+
}, [dailyLine]);
|
|
101
|
+
|
|
102
|
+
const persistGrowth = useCallback(() => {
|
|
103
|
+
try {
|
|
104
|
+
localStorage.setItem(STORAGE_GROWTH, growthIntent);
|
|
105
|
+
} catch {
|
|
106
|
+
/* ignore */
|
|
107
|
+
}
|
|
108
|
+
}, [growthIntent]);
|
|
109
|
+
|
|
110
|
+
const openDailyAsk = useCallback(() => {
|
|
111
|
+
persistDaily();
|
|
112
|
+
openAskModal(p.dailyAskPrefill(dailyLine), 'user');
|
|
113
|
+
}, [dailyLine, p, persistDaily]);
|
|
114
|
+
|
|
115
|
+
const openSegmentAsk = useCallback(() => {
|
|
116
|
+
openAskModal(`${p.parent} / ${title}\n\n`, 'user');
|
|
117
|
+
}, [p.parent, title]);
|
|
118
|
+
|
|
119
|
+
const insightUserPrompt = useMemo(
|
|
120
|
+
() =>
|
|
121
|
+
buildEchoInsightUserPrompt({
|
|
122
|
+
locale: locale as Locale,
|
|
123
|
+
segment,
|
|
124
|
+
segmentTitle: title,
|
|
125
|
+
factsHeading: p.factsHeading,
|
|
126
|
+
emptyTitle: snapshot.title,
|
|
127
|
+
emptyBody: snapshot.body,
|
|
128
|
+
continuedDrafts: p.continuedDrafts,
|
|
129
|
+
continuedTodos: p.continuedTodos,
|
|
130
|
+
subEmptyHint: p.subEmptyHint,
|
|
131
|
+
dailyLineLabel: p.dailyLineLabel,
|
|
132
|
+
dailyLine,
|
|
133
|
+
growthIntentLabel: p.growthIntentLabel,
|
|
134
|
+
growthIntent,
|
|
135
|
+
}),
|
|
136
|
+
[
|
|
137
|
+
locale,
|
|
138
|
+
segment,
|
|
139
|
+
title,
|
|
140
|
+
p.factsHeading,
|
|
141
|
+
snapshot,
|
|
142
|
+
p.continuedDrafts,
|
|
143
|
+
p.continuedTodos,
|
|
144
|
+
p.subEmptyHint,
|
|
145
|
+
p.dailyLineLabel,
|
|
146
|
+
dailyLine,
|
|
147
|
+
p.growthIntentLabel,
|
|
148
|
+
growthIntent,
|
|
149
|
+
],
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
const secondaryBtnClass =
|
|
153
|
+
'inline-flex items-center rounded-lg border border-border bg-background px-4 py-2.5 font-sans text-sm font-medium text-foreground transition-colors duration-150 hover:border-[var(--amber)]/35 hover:bg-[var(--amber-dim)]/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring';
|
|
154
|
+
|
|
155
|
+
return (
|
|
156
|
+
<article
|
|
157
|
+
className="mx-auto max-w-3xl px-4 py-6 sm:px-6 md:py-11"
|
|
158
|
+
aria-labelledby={pageTitleId}
|
|
159
|
+
>
|
|
160
|
+
<EchoHero
|
|
161
|
+
breadcrumbNav={p.breadcrumbNav}
|
|
162
|
+
parentHref="/echo/about-you"
|
|
163
|
+
parent={p.parent}
|
|
164
|
+
heroKicker={p.heroKicker}
|
|
165
|
+
pageTitle={title}
|
|
166
|
+
lead={lead}
|
|
167
|
+
titleId={pageTitleId}
|
|
168
|
+
/>
|
|
169
|
+
|
|
170
|
+
<EchoSegmentNav activeSegment={segment} />
|
|
171
|
+
|
|
172
|
+
<div className="mt-6 space-y-6 sm:mt-8">
|
|
173
|
+
<EchoFactSnapshot
|
|
174
|
+
headingId={factsHeadingId}
|
|
175
|
+
heading={p.factsHeading}
|
|
176
|
+
snapshotBadge={p.snapshotBadge}
|
|
177
|
+
emptyTitle={snapshot.title}
|
|
178
|
+
emptyBody={snapshot.body}
|
|
179
|
+
actions={
|
|
180
|
+
segment === 'about-you' ? (
|
|
181
|
+
<button type="button" onClick={openSegmentAsk} className={secondaryBtnClass}>
|
|
182
|
+
{p.continueAgent}
|
|
183
|
+
</button>
|
|
184
|
+
) : undefined
|
|
185
|
+
}
|
|
186
|
+
/>
|
|
187
|
+
{segment === 'continued' ? (
|
|
188
|
+
<EchoContinuedGroups
|
|
189
|
+
draftsLabel={p.continuedDrafts}
|
|
190
|
+
todosLabel={p.continuedTodos}
|
|
191
|
+
subEmptyHint={p.subEmptyHint}
|
|
192
|
+
footer={
|
|
193
|
+
<button type="button" onClick={openSegmentAsk} className={secondaryBtnClass}>
|
|
194
|
+
{p.continueAgent}
|
|
195
|
+
</button>
|
|
196
|
+
}
|
|
197
|
+
/>
|
|
198
|
+
) : null}
|
|
199
|
+
</div>
|
|
200
|
+
|
|
201
|
+
{segment === 'daily' ? (
|
|
202
|
+
<section className={`${cardSectionClass} mt-6`}>
|
|
203
|
+
<label htmlFor="echo-daily-line" className={fieldLabelClass}>
|
|
204
|
+
{p.dailyLineLabel}
|
|
205
|
+
</label>
|
|
206
|
+
<textarea
|
|
207
|
+
id="echo-daily-line"
|
|
208
|
+
value={dailyLine}
|
|
209
|
+
onChange={(e) => setDailyLine(e.target.value)}
|
|
210
|
+
onBlur={persistDaily}
|
|
211
|
+
rows={3}
|
|
212
|
+
placeholder={p.dailyLinePlaceholder}
|
|
213
|
+
className={inputClass}
|
|
214
|
+
/>
|
|
215
|
+
<p className="mt-3 font-sans text-2xs text-muted-foreground">{p.dailySavedNote}</p>
|
|
216
|
+
<div className="mt-4">
|
|
217
|
+
<button type="button" onClick={openDailyAsk} className={secondaryBtnClass}>
|
|
218
|
+
{p.continueAgent}
|
|
219
|
+
</button>
|
|
220
|
+
</div>
|
|
221
|
+
</section>
|
|
222
|
+
) : null}
|
|
223
|
+
|
|
224
|
+
{segment === 'growth' ? (
|
|
225
|
+
<section className={`${cardSectionClass} mt-6`}>
|
|
226
|
+
<label htmlFor="echo-growth-intent" className={fieldLabelClass}>
|
|
227
|
+
{p.growthIntentLabel}
|
|
228
|
+
</label>
|
|
229
|
+
<textarea
|
|
230
|
+
id="echo-growth-intent"
|
|
231
|
+
value={growthIntent}
|
|
232
|
+
onChange={(e) => setGrowthIntent(e.target.value)}
|
|
233
|
+
onBlur={persistGrowth}
|
|
234
|
+
rows={4}
|
|
235
|
+
placeholder={p.growthIntentPlaceholder}
|
|
236
|
+
className={`${inputClass} min-h-[6.5rem]`}
|
|
237
|
+
/>
|
|
238
|
+
<p className="mt-3 font-sans text-2xs text-muted-foreground">{p.growthSavedNote}</p>
|
|
239
|
+
<div className="mt-4 border-t border-border/60 pt-4">
|
|
240
|
+
<button type="button" onClick={openSegmentAsk} className={secondaryBtnClass}>
|
|
241
|
+
{p.continueAgent}
|
|
242
|
+
</button>
|
|
243
|
+
</div>
|
|
244
|
+
</section>
|
|
245
|
+
) : null}
|
|
246
|
+
|
|
247
|
+
{segment === 'past-you' ? (
|
|
248
|
+
<section className={`${cardSectionClass} mt-6`}>
|
|
249
|
+
<label className={fieldLabelClass}>{p.pastYouDrawLabel}</label>
|
|
250
|
+
<button
|
|
251
|
+
type="button"
|
|
252
|
+
disabled
|
|
253
|
+
title={p.pastYouDisabledHint}
|
|
254
|
+
className="mt-2 inline-flex cursor-not-allowed items-center rounded-lg border border-dashed border-border bg-muted/20 px-4 py-2.5 font-sans text-sm text-muted-foreground opacity-85"
|
|
255
|
+
>
|
|
256
|
+
{p.pastYouAnother}
|
|
257
|
+
</button>
|
|
258
|
+
<p className="mt-3 font-sans text-2xs text-muted-foreground">{p.pastYouDisabledHint}</p>
|
|
259
|
+
<div className="mt-4 border-t border-border/60 pt-4">
|
|
260
|
+
<button type="button" onClick={openSegmentAsk} className={secondaryBtnClass}>
|
|
261
|
+
{p.continueAgent}
|
|
262
|
+
</button>
|
|
263
|
+
</div>
|
|
264
|
+
</section>
|
|
265
|
+
) : null}
|
|
266
|
+
|
|
267
|
+
<EchoInsightCollapsible
|
|
268
|
+
title={p.insightTitle}
|
|
269
|
+
showLabel={p.insightShow}
|
|
270
|
+
hideLabel={p.insightHide}
|
|
271
|
+
hint={p.insightHint}
|
|
272
|
+
generateLabel={p.generateInsight}
|
|
273
|
+
noAiHint={p.generateInsightNoAi}
|
|
274
|
+
generatingLabel={p.insightGenerating}
|
|
275
|
+
errorPrefix={p.insightErrorPrefix}
|
|
276
|
+
retryLabel={p.insightRetry}
|
|
277
|
+
userPrompt={insightUserPrompt}
|
|
278
|
+
/>
|
|
279
|
+
</article>
|
|
280
|
+
);
|
|
281
|
+
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import type { ReactNode } from 'react';
|
|
4
|
+
import { usePathname } from 'next/navigation';
|
|
4
5
|
import { UserRound, Bookmark, Sun, History, Brain } from 'lucide-react';
|
|
5
6
|
import PanelHeader from './PanelHeader';
|
|
6
|
-
import {
|
|
7
|
+
import { PanelNavRow } from './PanelNavRow';
|
|
7
8
|
import { useLocale } from '@/lib/LocaleContext';
|
|
9
|
+
import { ECHO_SEGMENT_HREF, ECHO_SEGMENT_ORDER, type EchoSegment } from '@/lib/echo-segments';
|
|
8
10
|
|
|
9
11
|
interface EchoPanelProps {
|
|
10
12
|
active: boolean;
|
|
@@ -12,69 +14,34 @@ interface EchoPanelProps {
|
|
|
12
14
|
onMaximize?: () => void;
|
|
13
15
|
}
|
|
14
16
|
|
|
15
|
-
function EchoPlaceholdSection({
|
|
16
|
-
icon,
|
|
17
|
-
title,
|
|
18
|
-
hint,
|
|
19
|
-
comingSoonLabel,
|
|
20
|
-
}: {
|
|
21
|
-
icon: ReactNode;
|
|
22
|
-
title: string;
|
|
23
|
-
hint: string;
|
|
24
|
-
comingSoonLabel: string;
|
|
25
|
-
}) {
|
|
26
|
-
return (
|
|
27
|
-
<div className="px-4 py-2.5 border-b border-border/60 last:border-b-0">
|
|
28
|
-
<div className="flex items-center gap-2.5">
|
|
29
|
-
<span className="flex items-center justify-center w-7 h-7 rounded-md bg-muted shrink-0 text-muted-foreground">{icon}</span>
|
|
30
|
-
<span className="text-sm font-medium text-foreground flex-1 text-left">{title}</span>
|
|
31
|
-
<ComingSoonBadge label={comingSoonLabel} />
|
|
32
|
-
</div>
|
|
33
|
-
<p className="text-2xs text-muted-foreground leading-relaxed mt-1.5 pl-9">{hint}</p>
|
|
34
|
-
</div>
|
|
35
|
-
);
|
|
36
|
-
}
|
|
37
|
-
|
|
38
17
|
export default function EchoPanel({ active, maximized, onMaximize }: EchoPanelProps) {
|
|
39
18
|
const { t } = useLocale();
|
|
40
19
|
const e = t.panels.echo;
|
|
41
|
-
const
|
|
20
|
+
const pathname = usePathname() ?? '';
|
|
21
|
+
|
|
22
|
+
const rowBySegment: Record<EchoSegment, { icon: ReactNode; title: string }> = {
|
|
23
|
+
'about-you': { icon: <UserRound size={14} />, title: e.aboutYouTitle },
|
|
24
|
+
continued: { icon: <Bookmark size={14} />, title: e.continuedTitle },
|
|
25
|
+
daily: { icon: <Sun size={14} />, title: e.dailyEchoTitle },
|
|
26
|
+
'past-you': { icon: <History size={14} />, title: e.pastYouTitle },
|
|
27
|
+
growth: { icon: <Brain size={14} />, title: e.intentGrowthTitle },
|
|
28
|
+
};
|
|
42
29
|
|
|
43
30
|
return (
|
|
44
31
|
<div className={`flex flex-col h-full ${active ? '' : 'hidden'}`}>
|
|
45
32
|
<PanelHeader title={e.title} maximized={maximized} onMaximize={onMaximize} />
|
|
46
33
|
<div className="flex-1 overflow-y-auto min-h-0">
|
|
47
|
-
<div className="py-1">
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
comingSoonLabel={soon}
|
|
59
|
-
/>
|
|
60
|
-
<EchoPlaceholdSection
|
|
61
|
-
icon={<Sun size={14} />}
|
|
62
|
-
title={e.dailyEchoTitle}
|
|
63
|
-
hint={e.dailyEchoHint}
|
|
64
|
-
comingSoonLabel={soon}
|
|
65
|
-
/>
|
|
66
|
-
<EchoPlaceholdSection
|
|
67
|
-
icon={<History size={14} />}
|
|
68
|
-
title={e.pastYouTitle}
|
|
69
|
-
hint={e.pastYouHint}
|
|
70
|
-
comingSoonLabel={soon}
|
|
71
|
-
/>
|
|
72
|
-
<EchoPlaceholdSection
|
|
73
|
-
icon={<Brain size={14} />}
|
|
74
|
-
title={e.intentGrowthTitle}
|
|
75
|
-
hint={e.intentGrowthHint}
|
|
76
|
-
comingSoonLabel={soon}
|
|
77
|
-
/>
|
|
34
|
+
<div className="flex flex-col py-1">
|
|
35
|
+
{ECHO_SEGMENT_ORDER.map((segment) => {
|
|
36
|
+
const row = rowBySegment[segment];
|
|
37
|
+
const href = ECHO_SEGMENT_HREF[segment];
|
|
38
|
+
const isActive = pathname === href || pathname.startsWith(`${href}/`);
|
|
39
|
+
return (
|
|
40
|
+
<div key={segment} className="border-b border-border/60 last:border-b-0">
|
|
41
|
+
<PanelNavRow href={href} icon={row.icon} title={row.title} active={isActive} />
|
|
42
|
+
</div>
|
|
43
|
+
);
|
|
44
|
+
})}
|
|
78
45
|
</div>
|
|
79
46
|
</div>
|
|
80
47
|
</div>
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import type { ReactNode } from 'react';
|
|
4
4
|
import Link from 'next/link';
|
|
5
5
|
import { ChevronRight } from 'lucide-react';
|
|
6
|
+
import { cn } from '@/lib/utils';
|
|
6
7
|
|
|
7
8
|
/** Row matching Discover panel nav: icon tile, title, optional badge, chevron. */
|
|
8
9
|
export function PanelNavRow({
|
|
@@ -11,34 +12,50 @@ export function PanelNavRow({
|
|
|
11
12
|
badge,
|
|
12
13
|
href,
|
|
13
14
|
onClick,
|
|
15
|
+
active,
|
|
14
16
|
}: {
|
|
15
17
|
icon: ReactNode;
|
|
16
18
|
title: string;
|
|
17
19
|
badge?: React.ReactNode;
|
|
18
20
|
href?: string;
|
|
19
21
|
onClick?: () => void;
|
|
22
|
+
/** When true, row shows selected state (e.g. current Echo segment). */
|
|
23
|
+
active?: boolean;
|
|
20
24
|
}) {
|
|
21
25
|
const content = (
|
|
22
26
|
<>
|
|
23
|
-
<span className="flex items-center justify-center
|
|
24
|
-
<span className="text-sm font-medium text-foreground
|
|
27
|
+
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-muted">{icon}</span>
|
|
28
|
+
<span className="flex-1 text-left text-sm font-medium text-foreground">{title}</span>
|
|
25
29
|
{badge}
|
|
26
|
-
<ChevronRight size={14} className="text-muted-foreground
|
|
30
|
+
<ChevronRight size={14} className="shrink-0 text-muted-foreground" />
|
|
27
31
|
</>
|
|
28
32
|
);
|
|
29
33
|
|
|
30
|
-
const
|
|
31
|
-
|
|
34
|
+
const showRail = Boolean(active && href);
|
|
35
|
+
|
|
36
|
+
const className = cn(
|
|
37
|
+
'relative flex items-center gap-3 py-2.5 transition-colors duration-150 rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
|
38
|
+
showRail ? 'bg-[var(--amber-dim)]/40 pl-3.5 pr-4 text-foreground' : 'px-4',
|
|
39
|
+
href && !showRail && 'cursor-pointer hover:bg-muted/50',
|
|
40
|
+
showRail && 'cursor-default',
|
|
41
|
+
!href && 'cursor-pointer hover:bg-muted/50',
|
|
42
|
+
);
|
|
32
43
|
|
|
33
44
|
if (href) {
|
|
34
45
|
return (
|
|
35
|
-
<Link href={href} className={className}>
|
|
46
|
+
<Link href={href} className={className} aria-current={active ? 'page' : undefined}>
|
|
47
|
+
{showRail ? (
|
|
48
|
+
<span
|
|
49
|
+
className="pointer-events-none absolute bottom-[22%] left-0 top-[22%] w-0.5 rounded-r-full bg-[var(--amber)]"
|
|
50
|
+
aria-hidden
|
|
51
|
+
/>
|
|
52
|
+
) : null}
|
|
36
53
|
{content}
|
|
37
54
|
</Link>
|
|
38
55
|
);
|
|
39
56
|
}
|
|
40
57
|
return (
|
|
41
|
-
<button type="button" onClick={onClick} className={
|
|
58
|
+
<button type="button" onClick={onClick} className={cn(className, 'w-full')}>
|
|
42
59
|
{content}
|
|
43
60
|
</button>
|
|
44
61
|
);
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
import { isAiConfiguredForAsk, type SettingsJsonForAi } from '@/lib/settings-ai-client';
|
|
5
|
+
|
|
6
|
+
export function useSettingsAiAvailable(): { ready: boolean; loading: boolean } {
|
|
7
|
+
const [ready, setReady] = useState(false);
|
|
8
|
+
const [loading, setLoading] = useState(true);
|
|
9
|
+
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
let cancelled = false;
|
|
12
|
+
fetch('/api/settings', { cache: 'no-store' })
|
|
13
|
+
.then((r) => r.json())
|
|
14
|
+
.then((d: SettingsJsonForAi) => {
|
|
15
|
+
if (!cancelled) setReady(isAiConfiguredForAsk(d));
|
|
16
|
+
})
|
|
17
|
+
.catch(() => {
|
|
18
|
+
if (!cancelled) setReady(false);
|
|
19
|
+
})
|
|
20
|
+
.finally(() => {
|
|
21
|
+
if (!cancelled) setLoading(false);
|
|
22
|
+
});
|
|
23
|
+
return () => {
|
|
24
|
+
cancelled = true;
|
|
25
|
+
};
|
|
26
|
+
}, []);
|
|
27
|
+
|
|
28
|
+
return { ready, loading };
|
|
29
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { EchoSegment } from '@/lib/echo-segments';
|
|
2
|
+
|
|
3
|
+
export function buildEchoInsightUserPrompt(opts: {
|
|
4
|
+
locale: 'en' | 'zh';
|
|
5
|
+
segment: EchoSegment;
|
|
6
|
+
segmentTitle: string;
|
|
7
|
+
factsHeading: string;
|
|
8
|
+
emptyTitle: string;
|
|
9
|
+
emptyBody: string;
|
|
10
|
+
continuedDrafts: string;
|
|
11
|
+
continuedTodos: string;
|
|
12
|
+
subEmptyHint: string;
|
|
13
|
+
dailyLineLabel: string;
|
|
14
|
+
dailyLine: string;
|
|
15
|
+
growthIntentLabel: string;
|
|
16
|
+
growthIntent: string;
|
|
17
|
+
}): string {
|
|
18
|
+
const lang = opts.locale === 'zh' ? 'Chinese' : 'English';
|
|
19
|
+
const lines: string[] = [
|
|
20
|
+
`Echo section: ${opts.segmentTitle}`,
|
|
21
|
+
`${opts.factsHeading}: ${opts.emptyTitle}`,
|
|
22
|
+
opts.emptyBody,
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
if (opts.segment === 'continued') {
|
|
26
|
+
lines.push(`${opts.continuedDrafts} — ${opts.subEmptyHint}`, `${opts.continuedTodos} — ${opts.subEmptyHint}`);
|
|
27
|
+
}
|
|
28
|
+
if (opts.segment === 'daily' && opts.dailyLine.trim()) {
|
|
29
|
+
lines.push(`${opts.dailyLineLabel}: ${opts.dailyLine.trim()}`);
|
|
30
|
+
}
|
|
31
|
+
if (opts.segment === 'growth' && opts.growthIntent.trim()) {
|
|
32
|
+
lines.push(`${opts.growthIntentLabel}: ${opts.growthIntent.trim()}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const context = lines.join('\n\n');
|
|
36
|
+
|
|
37
|
+
return `You are a reflective assistant inside MindOS Echo (a personal, local-first notes companion). The user is viewing one Echo section. Below is exactly what they see on screen right now—it may be an empty-state placeholder until indexing fills the list.
|
|
38
|
+
|
|
39
|
+
--- Visible context ---
|
|
40
|
+
${context}
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
Write a short insight in ${lang} as Markdown (under 220 words). Tone: warm, restrained, "quiet notebook"—not a hype coach. If the context is only generic empty copy, acknowledge that briefly and offer 2–3 reflection prompts instead of inventing files or facts. Do not claim you read their whole library. Prefer answering from this context; avoid unnecessary tool use.`;
|
|
44
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Echo content routes: segment slugs and validation.
|
|
3
|
+
* Keep in sync with `wiki/specs/spec-echo-content-pages.md`.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const ECHO_SEGMENT_IDS = ['about-you', 'continued', 'daily', 'past-you', 'growth'] as const;
|
|
7
|
+
|
|
8
|
+
export type EchoSegment = (typeof ECHO_SEGMENT_IDS)[number];
|
|
9
|
+
|
|
10
|
+
export const ECHO_SEGMENT_ORDER: readonly EchoSegment[] = ECHO_SEGMENT_IDS;
|
|
11
|
+
|
|
12
|
+
/** App Router paths for each segment (single source for panel + in-page nav). */
|
|
13
|
+
export const ECHO_SEGMENT_HREF: Record<EchoSegment, string> = {
|
|
14
|
+
'about-you': '/echo/about-you',
|
|
15
|
+
continued: '/echo/continued',
|
|
16
|
+
daily: '/echo/daily',
|
|
17
|
+
'past-you': '/echo/past-you',
|
|
18
|
+
growth: '/echo/growth',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function isEchoSegment(value: string): value is EchoSegment {
|
|
22
|
+
return (ECHO_SEGMENT_IDS as readonly string[]).includes(value);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function defaultEchoSegment(): EchoSegment {
|
|
26
|
+
return 'about-you';
|
|
27
|
+
}
|
package/app/lib/i18n-en.ts
CHANGED
|
@@ -177,16 +177,10 @@ export const en = {
|
|
|
177
177
|
echo: {
|
|
178
178
|
title: 'Echo',
|
|
179
179
|
aboutYouTitle: 'Tied to you',
|
|
180
|
-
aboutYouHint: 'Paths and links in your library that curve back to your name.',
|
|
181
180
|
continuedTitle: 'Still open',
|
|
182
|
-
continuedHint: 'Drafts, half-written lines, todos you never closed.',
|
|
183
181
|
dailyEchoTitle: 'Daily echo',
|
|
184
|
-
dailyEchoHint: 'One quiet line for today—no need to open a full chat.',
|
|
185
182
|
pastYouTitle: 'Who you were',
|
|
186
|
-
pastYouHint: 'Choices and moods you set down at another point on the timeline.',
|
|
187
183
|
intentGrowthTitle: 'Heart & growth',
|
|
188
|
-
intentGrowthHint: 'What you are steering toward, and how it slowly shifts. Stays on device; you can wipe it anytime.',
|
|
189
|
-
comingSoon: 'On its way',
|
|
190
184
|
},
|
|
191
185
|
discover: {
|
|
192
186
|
title: 'Discover',
|
|
@@ -203,6 +197,59 @@ export const en = {
|
|
|
203
197
|
tryIt: 'Try',
|
|
204
198
|
},
|
|
205
199
|
},
|
|
200
|
+
echoPages: {
|
|
201
|
+
breadcrumbNav: 'Breadcrumb',
|
|
202
|
+
parent: 'Echo',
|
|
203
|
+
heroKicker: 'Echo',
|
|
204
|
+
segmentNavAria: 'Echo sections',
|
|
205
|
+
snapshotBadge: 'Local · private',
|
|
206
|
+
factsHeading: 'Snapshot',
|
|
207
|
+
snapshotAboutYouTitle: 'Clues will gather here',
|
|
208
|
+
snapshotAboutYouBody:
|
|
209
|
+
'Soon this will list notes whose paths, links, or titles curve back toward you—each one opens in the editor. Aggregation is still wiring up; for now, use the button below to talk it through in MindOS Agent.',
|
|
210
|
+
snapshotContinuedTitle: 'Drafts and open loops',
|
|
211
|
+
snapshotContinuedBody:
|
|
212
|
+
'This will hold untitled drafts, half-finished pieces, and unchecked tasks. The list feed is on the way; the two columns set the rhythm early.',
|
|
213
|
+
snapshotDailyTitle: 'Start with one line',
|
|
214
|
+
snapshotDailyBody:
|
|
215
|
+
'Your line for today saves in this browser only, as soon as you leave the field. Echo does not have to be an essay—one line is enough; open Agent when you want depth.',
|
|
216
|
+
snapshotPastYouTitle: 'A glance at the past',
|
|
217
|
+
snapshotPastYouBody:
|
|
218
|
+
'We will gently sample an older note so you can see who you were at another point—not a diff tool, just a glimpse. Sampling arrives in a later pass.',
|
|
219
|
+
snapshotGrowthTitle: 'Intent lives here',
|
|
220
|
+
snapshotGrowthBody:
|
|
221
|
+
'Below, write one sentence about what you are steering toward. It stays on this device and can change over time; later you may see how it drifts.',
|
|
222
|
+
insightTitle: 'Insight',
|
|
223
|
+
insightShow: 'Show insight',
|
|
224
|
+
insightHide: 'Hide insight',
|
|
225
|
+
insightHint:
|
|
226
|
+
'Uses only what is visible on this page—including this snapshot and anything you typed here—not your full library. Configure AI under Settings first.',
|
|
227
|
+
generateInsight: 'Generate insight',
|
|
228
|
+
generateInsightNoAi: 'Add an API key under Settings → AI (or set env vars), then refresh this page.',
|
|
229
|
+
insightGenerating: 'Generating…',
|
|
230
|
+
insightErrorPrefix: 'Something went wrong:',
|
|
231
|
+
insightRetry: 'Try again',
|
|
232
|
+
continueAgent: 'Continue in MindOS Agent',
|
|
233
|
+
continuedDrafts: 'Drafts',
|
|
234
|
+
continuedTodos: 'Open loops',
|
|
235
|
+
subEmptyHint: 'Items will appear in each column once the library feed is connected.',
|
|
236
|
+
dailyLineLabel: 'A line for today',
|
|
237
|
+
dailyLinePlaceholder: 'Write one quiet line…',
|
|
238
|
+
dailySavedNote: 'Saved in this browser; visible only on this device.',
|
|
239
|
+
dailyAskPrefill: (line: string) =>
|
|
240
|
+
`Echo / Daily — reflect on this line:\n\n${line.trim() || '(empty line)'}`,
|
|
241
|
+
pastYouDrawLabel: 'A draw from the past',
|
|
242
|
+
pastYouAnother: 'Draw another moment',
|
|
243
|
+
pastYouDisabledHint: 'We are connecting time and your library; soon a tap here will surface an old excerpt.',
|
|
244
|
+
growthIntentLabel: 'What you are steering toward',
|
|
245
|
+
growthIntentPlaceholder: 'Write your current intent…',
|
|
246
|
+
growthSavedNote: 'Saved on this device.',
|
|
247
|
+
aboutYouLead: 'Sense what curves toward you—without digging through every folder.',
|
|
248
|
+
continuedLead: 'Pick up the sentence you left mid-air.',
|
|
249
|
+
dailyLead: 'One quiet line for today — no need to open a full chat.',
|
|
250
|
+
pastYouLead: 'Choices and moods you set down at another point on the timeline.',
|
|
251
|
+
growthLead: 'What you are steering toward, and how it slowly shifts.',
|
|
252
|
+
},
|
|
206
253
|
shortcutPanel: {
|
|
207
254
|
title: 'Keyboard Shortcuts',
|
|
208
255
|
navigation: 'Navigation',
|
package/app/lib/i18n-zh.ts
CHANGED
|
@@ -201,16 +201,10 @@ export const zh = {
|
|
|
201
201
|
echo: {
|
|
202
202
|
title: '回响',
|
|
203
203
|
aboutYouTitle: '与你有关',
|
|
204
|
-
aboutYouHint: '路径与链接里,绕回你名下的那几笔。',
|
|
205
204
|
continuedTitle: '未完待续',
|
|
206
|
-
continuedHint: '草稿、写到一半的句子、没收口的待办。',
|
|
207
205
|
dailyEchoTitle: '每日回响',
|
|
208
|
-
dailyEchoHint: '给今天留一行空白;轻到不必为此开一场对话。',
|
|
209
206
|
pastYouTitle: '历史的你',
|
|
210
|
-
pastYouHint: '在另一个时间刻度上,你写下的选择与心情。',
|
|
211
207
|
intentGrowthTitle: '心向生长',
|
|
212
|
-
intentGrowthHint: '你在推的方向,以及它怎样慢慢偏转。只存本机,可随时清空。',
|
|
213
|
-
comingSoon: '随后到来',
|
|
214
208
|
},
|
|
215
209
|
discover: {
|
|
216
210
|
title: '探索',
|
|
@@ -227,6 +221,59 @@ export const zh = {
|
|
|
227
221
|
tryIt: '试试',
|
|
228
222
|
},
|
|
229
223
|
},
|
|
224
|
+
echoPages: {
|
|
225
|
+
breadcrumbNav: '面包屑导航',
|
|
226
|
+
parent: '回响',
|
|
227
|
+
heroKicker: '回响',
|
|
228
|
+
segmentNavAria: '回响模块',
|
|
229
|
+
snapshotBadge: '本地 · 私密',
|
|
230
|
+
factsHeading: '所见',
|
|
231
|
+
snapshotAboutYouTitle: '线索会住在这里',
|
|
232
|
+
snapshotAboutYouBody:
|
|
233
|
+
'以后这里会列出路径、链接或标题里「绕回你」的笔记,点开即读。文库聚合尚在接入;若想现在就着「与我相关」整理或深聊,可用下方按钮打开 MindOS Agent。',
|
|
234
|
+
snapshotContinuedTitle: '草稿与未收口',
|
|
235
|
+
snapshotContinuedBody:
|
|
236
|
+
'这里将汇集未命名草稿、写到一半的稿,以及待办里尚未勾上的项。列表能力正在接入;两栏结构先帮你建立心理预期。',
|
|
237
|
+
snapshotDailyTitle: '今天,从一小行开始',
|
|
238
|
+
snapshotDailyBody:
|
|
239
|
+
'下方「今日一行」写在浏览器本机,离手即存。回响不必写成文章——一行就够,需要展开时再找 Agent。',
|
|
240
|
+
snapshotPastYouTitle: '随机一瞥往日',
|
|
241
|
+
snapshotPastYouBody:
|
|
242
|
+
'我们会从时间轴里温和地抽一篇旧笔记的片段,让你看见那个时间点的自己:不是对比工具,只是一眼。抽样能力随后就到。',
|
|
243
|
+
snapshotGrowthTitle: '意图落在这里',
|
|
244
|
+
snapshotGrowthBody:
|
|
245
|
+
'下方可写一句「此刻最想推进的方向」。它只保存在本机,可随时间改写;日后可在此看见缓慢的变化。',
|
|
246
|
+
insightTitle: '见解',
|
|
247
|
+
insightShow: '展开见解',
|
|
248
|
+
insightHide: '收起见解',
|
|
249
|
+
insightHint:
|
|
250
|
+
'只会带上本页可见的文字(含「所见」与你在本页输入的内容),不会默认上传整库。需在 设置 → AI 中配置后再用。',
|
|
251
|
+
generateInsight: '生成见解',
|
|
252
|
+
generateInsightNoAi: '请在 设置 → AI 中填写 API Key(或配置环境变量)后刷新本页。',
|
|
253
|
+
insightGenerating: '生成中…',
|
|
254
|
+
insightErrorPrefix: '生成失败:',
|
|
255
|
+
insightRetry: '重试',
|
|
256
|
+
continueAgent: '在 MindOS Agent 中继续',
|
|
257
|
+
continuedDrafts: '草稿',
|
|
258
|
+
continuedTodos: '未收口',
|
|
259
|
+
subEmptyHint: '接入文库后,条目会出现在对应分组里。',
|
|
260
|
+
dailyLineLabel: '今日一行',
|
|
261
|
+
dailyLinePlaceholder: '写下一行轻量的文字…',
|
|
262
|
+
dailySavedNote: '已保存在本机浏览器;仅本设备可见。',
|
|
263
|
+
dailyAskPrefill: (line: string) =>
|
|
264
|
+
`回响 / 每日 — 围绕这一行帮我展开:\n\n${line.trim() || '(空行)'}`,
|
|
265
|
+
pastYouDrawLabel: '往日一瞥',
|
|
266
|
+
pastYouAnother: '再抽一笔',
|
|
267
|
+
pastYouDisabledHint: '正在连接时间与文库,稍后在此轻点即可抽读旧笔记片段。',
|
|
268
|
+
growthIntentLabel: '当前意图',
|
|
269
|
+
growthIntentPlaceholder: '写下你正在推进的方向…',
|
|
270
|
+
growthSavedNote: '已保存在本机。',
|
|
271
|
+
aboutYouLead: '不经翻找,也能感到有什么在轻轻指向你。',
|
|
272
|
+
continuedLead: '上次停下的句号,在这里接着写。',
|
|
273
|
+
dailyLead: '给今天留一行空白;轻到不必为此开一场对话。',
|
|
274
|
+
pastYouLead: '在另一个时间刻度上,瞥见你写下的选择与心情。',
|
|
275
|
+
growthLead: '你在推的方向,以及它怎样慢慢偏转。',
|
|
276
|
+
},
|
|
230
277
|
shortcutPanel: {
|
|
231
278
|
title: '快捷键',
|
|
232
279
|
navigation: '导航',
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side mirror of "can /api/ask run?" using GET /api/settings payload.
|
|
3
|
+
* Must stay aligned with server `effectiveAiConfig()` provider + key resolution.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type SettingsJsonForAi = {
|
|
7
|
+
ai?: {
|
|
8
|
+
provider?: string;
|
|
9
|
+
providers?: {
|
|
10
|
+
anthropic?: { apiKey?: string };
|
|
11
|
+
openai?: { apiKey?: string };
|
|
12
|
+
};
|
|
13
|
+
};
|
|
14
|
+
envOverrides?: Partial<Record<'ANTHROPIC_API_KEY' | 'OPENAI_API_KEY', boolean>>;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function isAiConfiguredForAsk(data: SettingsJsonForAi): boolean {
|
|
18
|
+
const prov = data.ai?.provider === 'openai' ? 'openai' : 'anthropic';
|
|
19
|
+
const env = data.envOverrides ?? {};
|
|
20
|
+
if (prov === 'openai') {
|
|
21
|
+
const k = data.ai?.providers?.openai?.apiKey;
|
|
22
|
+
return (typeof k === 'string' && k.length > 0) || !!env.OPENAI_API_KEY;
|
|
23
|
+
}
|
|
24
|
+
const k = data.ai?.providers?.anthropic?.apiKey;
|
|
25
|
+
return (typeof k === 'string' && k.length > 0) || !!env.ANTHROPIC_API_KEY;
|
|
26
|
+
}
|
package/package.json
CHANGED