@geminilight/mindos 0.5.54 → 0.5.55
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 +265 -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 +39 -6
- package/app/lib/i18n-zh.ts +37 -6
- package/app/lib/settings-ai-client.ts +26 -0
- package/app/next-env.d.ts +1 -1
- 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,265 @@
|
|
|
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 } 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
|
+
export default function EchoSegmentPageClient({ segment }: { segment: EchoSegment }) {
|
|
55
|
+
const { t, locale } = useLocale();
|
|
56
|
+
const p = t.echoPages;
|
|
57
|
+
const echo = t.panels.echo;
|
|
58
|
+
const title = segmentTitle(segment, echo);
|
|
59
|
+
const lead = segmentLead(segment, p);
|
|
60
|
+
const factsHeadingId = useId();
|
|
61
|
+
const pageTitleId = 'echo-page-title';
|
|
62
|
+
|
|
63
|
+
const [dailyLine, setDailyLine] = useState('');
|
|
64
|
+
const [growthIntent, setGrowthIntent] = useState('');
|
|
65
|
+
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
try {
|
|
68
|
+
const d = localStorage.getItem(STORAGE_DAILY);
|
|
69
|
+
if (d) setDailyLine(d);
|
|
70
|
+
const g = localStorage.getItem(STORAGE_GROWTH);
|
|
71
|
+
if (g) setGrowthIntent(g);
|
|
72
|
+
} catch {
|
|
73
|
+
/* ignore */
|
|
74
|
+
}
|
|
75
|
+
}, []);
|
|
76
|
+
|
|
77
|
+
const persistDaily = useCallback(() => {
|
|
78
|
+
try {
|
|
79
|
+
localStorage.setItem(STORAGE_DAILY, dailyLine);
|
|
80
|
+
} catch {
|
|
81
|
+
/* ignore */
|
|
82
|
+
}
|
|
83
|
+
}, [dailyLine]);
|
|
84
|
+
|
|
85
|
+
const persistGrowth = useCallback(() => {
|
|
86
|
+
try {
|
|
87
|
+
localStorage.setItem(STORAGE_GROWTH, growthIntent);
|
|
88
|
+
} catch {
|
|
89
|
+
/* ignore */
|
|
90
|
+
}
|
|
91
|
+
}, [growthIntent]);
|
|
92
|
+
|
|
93
|
+
const openDailyAsk = useCallback(() => {
|
|
94
|
+
persistDaily();
|
|
95
|
+
openAskModal(p.dailyAskPrefill(dailyLine), 'user');
|
|
96
|
+
}, [dailyLine, p, persistDaily]);
|
|
97
|
+
|
|
98
|
+
const openSegmentAsk = useCallback(() => {
|
|
99
|
+
openAskModal(`${p.parent} / ${title}\n\n`, 'user');
|
|
100
|
+
}, [p.parent, title]);
|
|
101
|
+
|
|
102
|
+
const insightUserPrompt = useMemo(
|
|
103
|
+
() =>
|
|
104
|
+
buildEchoInsightUserPrompt({
|
|
105
|
+
locale: locale as Locale,
|
|
106
|
+
segment,
|
|
107
|
+
segmentTitle: title,
|
|
108
|
+
factsHeading: p.factsHeading,
|
|
109
|
+
emptyTitle: p.emptyFactsTitle,
|
|
110
|
+
emptyBody: p.emptyFactsBody,
|
|
111
|
+
continuedDrafts: p.continuedDrafts,
|
|
112
|
+
continuedTodos: p.continuedTodos,
|
|
113
|
+
subEmptyHint: p.subEmptyHint,
|
|
114
|
+
dailyLineLabel: p.dailyLineLabel,
|
|
115
|
+
dailyLine,
|
|
116
|
+
growthIntentLabel: p.growthIntentLabel,
|
|
117
|
+
growthIntent,
|
|
118
|
+
}),
|
|
119
|
+
[
|
|
120
|
+
locale,
|
|
121
|
+
segment,
|
|
122
|
+
title,
|
|
123
|
+
p.factsHeading,
|
|
124
|
+
p.emptyFactsTitle,
|
|
125
|
+
p.emptyFactsBody,
|
|
126
|
+
p.continuedDrafts,
|
|
127
|
+
p.continuedTodos,
|
|
128
|
+
p.subEmptyHint,
|
|
129
|
+
p.dailyLineLabel,
|
|
130
|
+
dailyLine,
|
|
131
|
+
p.growthIntentLabel,
|
|
132
|
+
growthIntent,
|
|
133
|
+
],
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
const primaryBtnClass =
|
|
137
|
+
'inline-flex items-center rounded-lg bg-primary px-4 py-2.5 font-sans text-sm font-medium text-primary-foreground transition-opacity duration-150 hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring';
|
|
138
|
+
const secondaryBtnClass =
|
|
139
|
+
'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';
|
|
140
|
+
|
|
141
|
+
return (
|
|
142
|
+
<article
|
|
143
|
+
className="mx-auto max-w-3xl px-4 py-6 sm:px-6 md:py-11"
|
|
144
|
+
aria-labelledby={pageTitleId}
|
|
145
|
+
>
|
|
146
|
+
<EchoHero
|
|
147
|
+
breadcrumbNav={p.breadcrumbNav}
|
|
148
|
+
parentHref="/echo/about-you"
|
|
149
|
+
parent={p.parent}
|
|
150
|
+
heroKicker={p.heroKicker}
|
|
151
|
+
pageTitle={title}
|
|
152
|
+
lead={lead}
|
|
153
|
+
titleId={pageTitleId}
|
|
154
|
+
/>
|
|
155
|
+
|
|
156
|
+
<EchoSegmentNav activeSegment={segment} />
|
|
157
|
+
|
|
158
|
+
<div className="mt-6 space-y-6 sm:mt-8">
|
|
159
|
+
<EchoFactSnapshot
|
|
160
|
+
headingId={factsHeadingId}
|
|
161
|
+
heading={p.factsHeading}
|
|
162
|
+
snapshotBadge={p.snapshotBadge}
|
|
163
|
+
emptyTitle={p.emptyFactsTitle}
|
|
164
|
+
emptyBody={p.emptyFactsBody}
|
|
165
|
+
actions={
|
|
166
|
+
segment === 'about-you' ? (
|
|
167
|
+
<button type="button" onClick={openSegmentAsk} className={secondaryBtnClass}>
|
|
168
|
+
{p.continueAgent}
|
|
169
|
+
</button>
|
|
170
|
+
) : undefined
|
|
171
|
+
}
|
|
172
|
+
/>
|
|
173
|
+
{segment === 'continued' ? (
|
|
174
|
+
<EchoContinuedGroups
|
|
175
|
+
draftsLabel={p.continuedDrafts}
|
|
176
|
+
todosLabel={p.continuedTodos}
|
|
177
|
+
subEmptyHint={p.subEmptyHint}
|
|
178
|
+
footer={
|
|
179
|
+
<button type="button" onClick={openSegmentAsk} className={secondaryBtnClass}>
|
|
180
|
+
{p.continueAgent}
|
|
181
|
+
</button>
|
|
182
|
+
}
|
|
183
|
+
/>
|
|
184
|
+
) : null}
|
|
185
|
+
</div>
|
|
186
|
+
|
|
187
|
+
{segment === 'daily' ? (
|
|
188
|
+
<section className={`${cardSectionClass} mt-6`}>
|
|
189
|
+
<label htmlFor="echo-daily-line" className={fieldLabelClass}>
|
|
190
|
+
{p.dailyLineLabel}
|
|
191
|
+
</label>
|
|
192
|
+
<textarea
|
|
193
|
+
id="echo-daily-line"
|
|
194
|
+
value={dailyLine}
|
|
195
|
+
onChange={(e) => setDailyLine(e.target.value)}
|
|
196
|
+
onBlur={persistDaily}
|
|
197
|
+
rows={3}
|
|
198
|
+
placeholder={p.dailyLinePlaceholder}
|
|
199
|
+
className={inputClass}
|
|
200
|
+
/>
|
|
201
|
+
<div className="mt-4">
|
|
202
|
+
<button type="button" onClick={openDailyAsk} className={primaryBtnClass}>
|
|
203
|
+
{p.continueAgent}
|
|
204
|
+
</button>
|
|
205
|
+
</div>
|
|
206
|
+
</section>
|
|
207
|
+
) : null}
|
|
208
|
+
|
|
209
|
+
{segment === 'growth' ? (
|
|
210
|
+
<section className={`${cardSectionClass} mt-6`}>
|
|
211
|
+
<label htmlFor="echo-growth-intent" className={fieldLabelClass}>
|
|
212
|
+
{p.growthIntentLabel}
|
|
213
|
+
</label>
|
|
214
|
+
<textarea
|
|
215
|
+
id="echo-growth-intent"
|
|
216
|
+
value={growthIntent}
|
|
217
|
+
onChange={(e) => setGrowthIntent(e.target.value)}
|
|
218
|
+
onBlur={persistGrowth}
|
|
219
|
+
rows={4}
|
|
220
|
+
placeholder={p.growthIntentPlaceholder}
|
|
221
|
+
className={`${inputClass} min-h-[6.5rem]`}
|
|
222
|
+
/>
|
|
223
|
+
<p className="mt-3 font-sans text-2xs text-muted-foreground">{p.growthSavedNote}</p>
|
|
224
|
+
<div className="mt-4 border-t border-border/60 pt-4">
|
|
225
|
+
<button type="button" onClick={openSegmentAsk} className={secondaryBtnClass}>
|
|
226
|
+
{p.continueAgent}
|
|
227
|
+
</button>
|
|
228
|
+
</div>
|
|
229
|
+
</section>
|
|
230
|
+
) : null}
|
|
231
|
+
|
|
232
|
+
{segment === 'past-you' ? (
|
|
233
|
+
<section className={`${cardSectionClass} mt-6`}>
|
|
234
|
+
<button
|
|
235
|
+
type="button"
|
|
236
|
+
disabled
|
|
237
|
+
title={p.pastYouDisabledHint}
|
|
238
|
+
className="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"
|
|
239
|
+
>
|
|
240
|
+
{p.pastYouAnother}
|
|
241
|
+
</button>
|
|
242
|
+
<p className="mt-3 font-sans text-2xs text-muted-foreground">{p.pastYouDisabledHint}</p>
|
|
243
|
+
<div className="mt-4 border-t border-border/60 pt-4">
|
|
244
|
+
<button type="button" onClick={openSegmentAsk} className={secondaryBtnClass}>
|
|
245
|
+
{p.continueAgent}
|
|
246
|
+
</button>
|
|
247
|
+
</div>
|
|
248
|
+
</section>
|
|
249
|
+
) : null}
|
|
250
|
+
|
|
251
|
+
<EchoInsightCollapsible
|
|
252
|
+
title={p.insightTitle}
|
|
253
|
+
showLabel={p.insightShow}
|
|
254
|
+
hideLabel={p.insightHide}
|
|
255
|
+
hint={p.insightHint}
|
|
256
|
+
generateLabel={p.generateInsight}
|
|
257
|
+
noAiHint={p.generateInsightNoAi}
|
|
258
|
+
generatingLabel={p.insightGenerating}
|
|
259
|
+
errorPrefix={p.insightErrorPrefix}
|
|
260
|
+
retryLabel={p.insightRetry}
|
|
261
|
+
userPrompt={insightUserPrompt}
|
|
262
|
+
/>
|
|
263
|
+
</article>
|
|
264
|
+
);
|
|
265
|
+
}
|
|
@@ -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,45 @@ 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
|
+
emptyFactsTitle: 'Nothing here yet',
|
|
208
|
+
emptyFactsBody:
|
|
209
|
+
'Structured clues from your library will appear here when indexing is enabled. Data stays on your device.',
|
|
210
|
+
insightTitle: 'Insight',
|
|
211
|
+
insightShow: 'Show insight',
|
|
212
|
+
insightHide: 'Hide insight',
|
|
213
|
+
insightHint:
|
|
214
|
+
'When enabled, only the items listed above are sent as context. There is no full-library upload by default.',
|
|
215
|
+
generateInsight: 'Generate insight',
|
|
216
|
+
generateInsightNoAi: 'Add an API key under Settings → AI (or set env vars), then refresh this page.',
|
|
217
|
+
insightGenerating: 'Generating…',
|
|
218
|
+
insightErrorPrefix: 'Something went wrong:',
|
|
219
|
+
insightRetry: 'Try again',
|
|
220
|
+
continueAgent: 'Continue in MindOS Agent',
|
|
221
|
+
continuedDrafts: 'Drafts',
|
|
222
|
+
continuedTodos: 'Open loops',
|
|
223
|
+
subEmptyHint: 'No items in this group yet.',
|
|
224
|
+
dailyLineLabel: 'A line for today',
|
|
225
|
+
dailyLinePlaceholder: 'Write one quiet line…',
|
|
226
|
+
dailyAskPrefill: (line: string) =>
|
|
227
|
+
`Echo / Daily — reflect on this line:\n\n${line.trim() || '(empty line)'}`,
|
|
228
|
+
pastYouAnother: 'Draw another moment',
|
|
229
|
+
pastYouDisabledHint: 'Random sampling arrives in a later update.',
|
|
230
|
+
growthIntentLabel: 'What you are steering toward',
|
|
231
|
+
growthIntentPlaceholder: 'Write your current intent…',
|
|
232
|
+
growthSavedNote: 'Saved on this device.',
|
|
233
|
+
aboutYouLead: 'Paths and links in your library that curve back to your name.',
|
|
234
|
+
continuedLead: 'Pick up where you left off.',
|
|
235
|
+
dailyLead: 'One quiet line for today — no need to open a full chat.',
|
|
236
|
+
pastYouLead: 'Choices and moods you set down at another point on the timeline.',
|
|
237
|
+
growthLead: 'What you are steering toward, and how it slowly shifts.',
|
|
238
|
+
},
|
|
206
239
|
shortcutPanel: {
|
|
207
240
|
title: 'Keyboard Shortcuts',
|
|
208
241
|
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,43 @@ 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
|
+
emptyFactsTitle: '尚无内容',
|
|
232
|
+
emptyFactsBody: '开启索引后,笔记库中的结构化线索会出现在此。数据仅保存在本机。',
|
|
233
|
+
insightTitle: '见解',
|
|
234
|
+
insightShow: '展开见解',
|
|
235
|
+
insightHide: '收起见解',
|
|
236
|
+
insightHint: '启用后,仅将上方列表作为上下文发送;默认不会整库上传。',
|
|
237
|
+
generateInsight: '生成见解',
|
|
238
|
+
generateInsightNoAi: '请在 设置 → AI 中填写 API Key(或配置环境变量)后刷新本页。',
|
|
239
|
+
insightGenerating: '生成中…',
|
|
240
|
+
insightErrorPrefix: '生成失败:',
|
|
241
|
+
insightRetry: '重试',
|
|
242
|
+
continueAgent: '在 MindOS Agent 中继续',
|
|
243
|
+
continuedDrafts: '草稿',
|
|
244
|
+
continuedTodos: '未收口',
|
|
245
|
+
subEmptyHint: '这一组里还没有条目。',
|
|
246
|
+
dailyLineLabel: '今日一行',
|
|
247
|
+
dailyLinePlaceholder: '写下一行轻量的文字…',
|
|
248
|
+
dailyAskPrefill: (line: string) =>
|
|
249
|
+
`回响 / 每日 — 围绕这一行帮我展开:\n\n${line.trim() || '(空行)'}`,
|
|
250
|
+
pastYouAnother: '再抽一笔',
|
|
251
|
+
pastYouDisabledHint: '随机抽样将在后续版本提供。',
|
|
252
|
+
growthIntentLabel: '当前意图',
|
|
253
|
+
growthIntentPlaceholder: '写下你正在推进的方向…',
|
|
254
|
+
growthSavedNote: '已保存在本机。',
|
|
255
|
+
aboutYouLead: '路径与链接里,绕回你名下的那几笔。',
|
|
256
|
+
continuedLead: '接上上次停下的地方。',
|
|
257
|
+
dailyLead: '给今天留一行空白;轻到不必为此开一场对话。',
|
|
258
|
+
pastYouLead: '在另一个时间刻度上,你写下的选择与心情。',
|
|
259
|
+
growthLead: '你在推的方向,以及它怎样慢慢偏转。',
|
|
260
|
+
},
|
|
230
261
|
shortcutPanel: {
|
|
231
262
|
title: '快捷键',
|
|
232
263
|
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/app/next-env.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/// <reference types="next" />
|
|
2
2
|
/// <reference types="next/image-types/global" />
|
|
3
|
-
import "./.next/
|
|
3
|
+
import "./.next/types/routes.d.ts";
|
|
4
4
|
|
|
5
5
|
// NOTE: This file should not be edited
|
|
6
6
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
package/package.json
CHANGED