@brainfish-ai/devdoc 0.1.43 → 0.1.45
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/dist/cli/commands/create.js +2 -2
- package/package.json +1 -1
- package/renderer/app/api/collections/route.js +35 -4
- package/renderer/app/api/docs/route.js +9 -4
- package/renderer/app/api/suggestions/route.js +33 -13
- package/renderer/app/globals.css +69 -0
- package/renderer/app/layout.js +2 -2
- package/renderer/app/llms-full.txt/route.js +10 -1
- package/renderer/app/llms.txt/route.js +10 -1
- package/renderer/app/sitemap.xml/route.js +11 -1
- package/renderer/components/docs/mdx/cards.js +1 -1
- package/renderer/components/docs/mdx/landing.js +7 -5
- package/renderer/components/docs-viewer/agent/agent-chat.js +13 -112
- package/renderer/components/docs-viewer/agent/agent-popup-button.js +99 -0
- package/renderer/components/docs-viewer/agent/index.js +3 -0
- package/renderer/components/docs-viewer/content/content-router.js +182 -0
- package/renderer/components/docs-viewer/content/doc-page.js +73 -37
- package/renderer/components/docs-viewer/content/index.js +2 -0
- package/renderer/components/docs-viewer/content/mdx-error-boundary.js +184 -0
- package/renderer/components/docs-viewer/index.js +381 -485
- package/renderer/components/docs-viewer/playground/graphql-playground.js +205 -3
- package/renderer/components/docs-viewer/sidebar/right-sidebar.js +35 -39
- package/renderer/components/theme-toggle.js +1 -21
- package/renderer/hooks/use-route-state.js +159 -0
- package/renderer/lib/api-docs/agent/use-suggestions.js +97 -0
- package/renderer/lib/api-docs/code-editor/mode-context.js +61 -89
- package/renderer/lib/api-docs/mobile-context.js +40 -3
- package/renderer/lib/docs/config/environment.js +38 -0
- package/renderer/lib/docs/config/index.js +1 -0
- package/renderer/lib/docs/config/schema.js +17 -5
- package/renderer/lib/docs/mdx/compiler.js +5 -2
- package/renderer/lib/docs/mdx/index.js +2 -0
- package/renderer/lib/docs/mdx/remark-mermaid.js +63 -0
- package/renderer/lib/docs/navigation/index.js +1 -2
- package/renderer/lib/docs/navigation/types.js +3 -1
- package/renderer/lib/docs-navigation.js +140 -0
- package/renderer/package.json +1 -0
|
@@ -6,12 +6,182 @@ import { Play, ArrowClockwise, Copy, Check, Spinner, X, FileText, BracketsCurly,
|
|
|
6
6
|
import { Button } from '@/components/ui/button';
|
|
7
7
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
|
8
8
|
import Editor, { loader } from '@monaco-editor/react';
|
|
9
|
-
|
|
9
|
+
import { buildSchema } from 'graphql';
|
|
10
|
+
import { getAutocompleteSuggestions, CompletionItemKind } from 'graphql-language-service';
|
|
11
|
+
// Configure Monaco to load from CDN
|
|
10
12
|
loader.config({
|
|
11
13
|
paths: {
|
|
12
14
|
vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs'
|
|
13
15
|
}
|
|
14
16
|
});
|
|
17
|
+
// Store parsed schema and completion provider disposable
|
|
18
|
+
let parsedSchema = null;
|
|
19
|
+
let completionProviderDisposable = null;
|
|
20
|
+
// Map graphql-language-service completion kinds to Monaco kinds
|
|
21
|
+
function toMonacoCompletionKind(kind, monaco) {
|
|
22
|
+
const mKinds = monaco.languages.CompletionItemKind;
|
|
23
|
+
switch(kind){
|
|
24
|
+
case CompletionItemKind.Field:
|
|
25
|
+
return mKinds.Field;
|
|
26
|
+
case CompletionItemKind.Variable:
|
|
27
|
+
return mKinds.Variable;
|
|
28
|
+
case CompletionItemKind.Property:
|
|
29
|
+
return mKinds.Property;
|
|
30
|
+
case CompletionItemKind.EnumMember:
|
|
31
|
+
return mKinds.EnumMember;
|
|
32
|
+
case CompletionItemKind.Class:
|
|
33
|
+
return mKinds.Class;
|
|
34
|
+
case CompletionItemKind.Interface:
|
|
35
|
+
return mKinds.Interface;
|
|
36
|
+
case CompletionItemKind.Function:
|
|
37
|
+
return mKinds.Function;
|
|
38
|
+
case CompletionItemKind.Snippet:
|
|
39
|
+
return mKinds.Snippet;
|
|
40
|
+
case CompletionItemKind.Keyword:
|
|
41
|
+
return mKinds.Keyword;
|
|
42
|
+
case CompletionItemKind.Constant:
|
|
43
|
+
return mKinds.Constant;
|
|
44
|
+
case CompletionItemKind.Enum:
|
|
45
|
+
return mKinds.Enum;
|
|
46
|
+
default:
|
|
47
|
+
return mKinds.Text;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// Find fields already selected in the current selection set
|
|
51
|
+
function getExistingFieldsInSelectionSet(documentText, cursorOffset) {
|
|
52
|
+
const existingFields = new Set();
|
|
53
|
+
// Find the opening brace of the current selection set
|
|
54
|
+
let braceCount = 0;
|
|
55
|
+
let selectionSetStart = -1;
|
|
56
|
+
for(let i = cursorOffset - 1; i >= 0; i--){
|
|
57
|
+
const char = documentText[i];
|
|
58
|
+
if (char === '}') braceCount++;
|
|
59
|
+
if (char === '{') {
|
|
60
|
+
if (braceCount === 0) {
|
|
61
|
+
selectionSetStart = i;
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
braceCount--;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (selectionSetStart === -1) return existingFields;
|
|
68
|
+
// Find the closing brace
|
|
69
|
+
braceCount = 1;
|
|
70
|
+
let selectionSetEnd = documentText.length;
|
|
71
|
+
for(let i = selectionSetStart + 1; i < documentText.length; i++){
|
|
72
|
+
const char = documentText[i];
|
|
73
|
+
if (char === '{') braceCount++;
|
|
74
|
+
if (char === '}') {
|
|
75
|
+
braceCount--;
|
|
76
|
+
if (braceCount === 0) {
|
|
77
|
+
selectionSetEnd = i;
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// Extract the selection set content
|
|
83
|
+
const selectionSetContent = documentText.slice(selectionSetStart + 1, selectionSetEnd);
|
|
84
|
+
// Parse field names - match field names that appear on their own or with arguments/aliases
|
|
85
|
+
// Matches: fieldName, fieldName(args), alias: fieldName, ...fieldName
|
|
86
|
+
const lines = selectionSetContent.split('\n');
|
|
87
|
+
for (const line of lines){
|
|
88
|
+
const trimmed = line.trim();
|
|
89
|
+
if (!trimmed || trimmed.startsWith('#')) continue; // Skip empty lines and comments
|
|
90
|
+
// Match field name at start of line (possibly with alias)
|
|
91
|
+
// Pattern: optionalAlias: fieldName or just fieldName
|
|
92
|
+
const fieldMatch = trimmed.match(/^(?:\.\.\.)?(?:[a-zA-Z_][a-zA-Z0-9_]*\s*:\s*)?([a-zA-Z_][a-zA-Z0-9_]*)/);
|
|
93
|
+
if (fieldMatch) {
|
|
94
|
+
const fieldName = fieldMatch[1];
|
|
95
|
+
// Skip GraphQL keywords but include __typename
|
|
96
|
+
if (![
|
|
97
|
+
'query',
|
|
98
|
+
'mutation',
|
|
99
|
+
'subscription',
|
|
100
|
+
'fragment',
|
|
101
|
+
'on'
|
|
102
|
+
].includes(fieldName)) {
|
|
103
|
+
existingFields.add(fieldName);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return existingFields;
|
|
108
|
+
}
|
|
109
|
+
// Register GraphQL completion provider using graphql-language-service
|
|
110
|
+
function registerGraphQLCompletionProvider(monaco, schema) {
|
|
111
|
+
// Dispose previous provider if exists
|
|
112
|
+
if (completionProviderDisposable) {
|
|
113
|
+
completionProviderDisposable.dispose();
|
|
114
|
+
}
|
|
115
|
+
completionProviderDisposable = monaco.languages.registerCompletionItemProvider('graphql', {
|
|
116
|
+
triggerCharacters: [
|
|
117
|
+
'{',
|
|
118
|
+
'(',
|
|
119
|
+
' ',
|
|
120
|
+
':',
|
|
121
|
+
'$',
|
|
122
|
+
'@',
|
|
123
|
+
'.',
|
|
124
|
+
'\n'
|
|
125
|
+
],
|
|
126
|
+
provideCompletionItems: (model, position)=>{
|
|
127
|
+
const documentText = model.getValue();
|
|
128
|
+
const cursorOffset = model.getOffsetAt(position);
|
|
129
|
+
// graphql-language-service Position interface
|
|
130
|
+
const gqlPosition = {
|
|
131
|
+
line: position.lineNumber - 1,
|
|
132
|
+
character: position.column - 1,
|
|
133
|
+
setLine: (line)=>{
|
|
134
|
+
gqlPosition.line = line;
|
|
135
|
+
},
|
|
136
|
+
setCharacter: (char)=>{
|
|
137
|
+
gqlPosition.character = char;
|
|
138
|
+
},
|
|
139
|
+
lessThanOrEqualTo: (other)=>{
|
|
140
|
+
return gqlPosition.line < other.line || gqlPosition.line === other.line && gqlPosition.character <= other.character;
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
try {
|
|
144
|
+
// Get completions from graphql-language-service
|
|
145
|
+
const completions = getAutocompleteSuggestions(schema, documentText, gqlPosition);
|
|
146
|
+
// Get existing fields in current selection set to filter them out
|
|
147
|
+
const existingFields = getExistingFieldsInSelectionSet(documentText, cursorOffset);
|
|
148
|
+
// Filter out already-selected fields (only for Field kind suggestions)
|
|
149
|
+
const filteredCompletions = completions.filter((item)=>{
|
|
150
|
+
// Only filter out Field suggestions (kind = 5), keep keywords, types, etc.
|
|
151
|
+
if (item.kind === CompletionItemKind.Field) {
|
|
152
|
+
return !existingFields.has(item.label);
|
|
153
|
+
}
|
|
154
|
+
return true;
|
|
155
|
+
});
|
|
156
|
+
const word = model.getWordUntilPosition(position);
|
|
157
|
+
const range = {
|
|
158
|
+
startLineNumber: position.lineNumber,
|
|
159
|
+
endLineNumber: position.lineNumber,
|
|
160
|
+
startColumn: word.startColumn,
|
|
161
|
+
endColumn: word.endColumn
|
|
162
|
+
};
|
|
163
|
+
const suggestions = filteredCompletions.map((item, index)=>({
|
|
164
|
+
label: item.label,
|
|
165
|
+
kind: toMonacoCompletionKind(item.kind, monaco),
|
|
166
|
+
insertText: item.insertText || item.label,
|
|
167
|
+
insertTextRules: item.insertText?.includes('$') ? monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet : undefined,
|
|
168
|
+
detail: item.detail || undefined,
|
|
169
|
+
documentation: item.documentation || undefined,
|
|
170
|
+
sortText: String(index).padStart(5, '0'),
|
|
171
|
+
range
|
|
172
|
+
}));
|
|
173
|
+
return {
|
|
174
|
+
suggestions
|
|
175
|
+
};
|
|
176
|
+
} catch (err) {
|
|
177
|
+
console.error('[GraphQL Playground] Completion error:', err);
|
|
178
|
+
return {
|
|
179
|
+
suggestions: []
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
}
|
|
15
185
|
const STORAGE_KEY = 'brainfish-graphql-playground';
|
|
16
186
|
// ============================================================================
|
|
17
187
|
// Helpers
|
|
@@ -184,7 +354,7 @@ export function GraphQLPlayground({ endpoint, defaultQuery = `# Welcome to the G
|
|
|
184
354
|
query {
|
|
185
355
|
__typename
|
|
186
356
|
}
|
|
187
|
-
`, headers: defaultHeaders = {}, operations = [], selectedOperationId: externalSelectedId, hideExplorer = false, className, theme = 'dark' }) {
|
|
357
|
+
`, headers: defaultHeaders = {}, operations = [], selectedOperationId: externalSelectedId, hideExplorer = false, className, theme = 'dark', schemaSDL }) {
|
|
188
358
|
const [query, setQuery] = useState(defaultQuery);
|
|
189
359
|
const [variables, setVariables] = useState('{}');
|
|
190
360
|
const [customHeaders, setCustomHeaders] = useState(Object.keys(defaultHeaders).length > 0 ? JSON.stringify(defaultHeaders, null, 2) : '{}');
|
|
@@ -199,6 +369,35 @@ query {
|
|
|
199
369
|
const selectedOperationId = externalSelectedId ?? internalSelectedId;
|
|
200
370
|
const abortControllerRef = useRef(null);
|
|
201
371
|
const hasLoadedRef = useRef(false);
|
|
372
|
+
const monacoRef = useRef(null);
|
|
373
|
+
// Initialize Monaco GraphQL when editor mounts
|
|
374
|
+
const handleEditorDidMount = useCallback((editor, monaco)=>{
|
|
375
|
+
monacoRef.current = monaco;
|
|
376
|
+
// Parse schema and register completion provider
|
|
377
|
+
if (schemaSDL) {
|
|
378
|
+
try {
|
|
379
|
+
parsedSchema = buildSchema(schemaSDL);
|
|
380
|
+
registerGraphQLCompletionProvider(monaco, parsedSchema);
|
|
381
|
+
} catch (err) {
|
|
382
|
+
console.error('[GraphQL Playground] Failed to parse schema:', err);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}, [
|
|
386
|
+
schemaSDL
|
|
387
|
+
]);
|
|
388
|
+
// Update completion provider when schema changes
|
|
389
|
+
useEffect(()=>{
|
|
390
|
+
if (schemaSDL && monacoRef.current) {
|
|
391
|
+
try {
|
|
392
|
+
parsedSchema = buildSchema(schemaSDL);
|
|
393
|
+
registerGraphQLCompletionProvider(monacoRef.current, parsedSchema);
|
|
394
|
+
} catch (err) {
|
|
395
|
+
console.error('[GraphQL Playground] Failed to update schema:', err);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}, [
|
|
399
|
+
schemaSDL
|
|
400
|
+
]);
|
|
202
401
|
// Load from localStorage on mount
|
|
203
402
|
useEffect(()=>{
|
|
204
403
|
const stored = loadStoredState();
|
|
@@ -478,6 +677,7 @@ query {
|
|
|
478
677
|
height: "100%",
|
|
479
678
|
language: "graphql",
|
|
480
679
|
value: query,
|
|
680
|
+
onMount: handleEditorDidMount,
|
|
481
681
|
onChange: (value)=>{
|
|
482
682
|
setQuery(value || '');
|
|
483
683
|
setInternalSelectedId(undefined); // Clear internal selection when manually editing
|
|
@@ -502,7 +702,9 @@ query {
|
|
|
502
702
|
},
|
|
503
703
|
wordWrap: 'on',
|
|
504
704
|
automaticLayout: true,
|
|
505
|
-
tabSize: 2
|
|
705
|
+
tabSize: 2,
|
|
706
|
+
quickSuggestions: true,
|
|
707
|
+
suggestOnTriggerCharacters: true
|
|
506
708
|
},
|
|
507
709
|
loading: /*#__PURE__*/ _jsxs("div", {
|
|
508
710
|
className: "flex items-center justify-center h-full text-muted-foreground text-sm",
|
|
@@ -2,15 +2,14 @@
|
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
3
|
import { useEffect, useState, useCallback } from 'react';
|
|
4
4
|
import { createPortal } from 'react-dom';
|
|
5
|
-
import
|
|
6
|
-
import { X, ArrowClockwise } from '@phosphor-icons/react';
|
|
5
|
+
import { X, ArrowClockwise, Sparkle } from '@phosphor-icons/react';
|
|
7
6
|
import { AgentChat } from '../agent';
|
|
8
7
|
import { Button } from '@/components/ui/button';
|
|
9
8
|
import { useMobile } from '@/lib/api-docs/mobile-context';
|
|
10
9
|
import { cn } from '@/lib/utils';
|
|
11
10
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
|
12
11
|
export function RightSidebar({ request, collection, apiSummary, onNavigateToEndpoint, onPrefillParameters, debugContext, onClearDebugContext, explainContext, onClearExplainContext, onOpenGlobalAuth, onNavigateToAuthTab, onNavigateToParamsTab, onNavigateToBodyTab, onNavigateToHeadersTab, onNavigateToDocSection, onNavigateToDocPage }) {
|
|
13
|
-
// Mobile context
|
|
12
|
+
// Mobile context - now used for both mobile and desktop
|
|
14
13
|
const { isMobile, isRightSidebarOpen, closeRightSidebar } = useMobile();
|
|
15
14
|
const [mounted, setMounted] = useState(false);
|
|
16
15
|
// Ensure we're mounted before using portal (for SSR)
|
|
@@ -46,7 +45,7 @@ export function RightSidebar({ request, collection, apiSummary, onNavigateToEndp
|
|
|
46
45
|
setChatKey((prev)=>prev + 1);
|
|
47
46
|
setHasMessages(false);
|
|
48
47
|
}, []);
|
|
49
|
-
// Sidebar content component
|
|
48
|
+
// Sidebar content component
|
|
50
49
|
const sidebarContent = /*#__PURE__*/ _jsxs(_Fragment, {
|
|
51
50
|
children: [
|
|
52
51
|
/*#__PURE__*/ _jsx("div", {
|
|
@@ -57,15 +56,9 @@ export function RightSidebar({ request, collection, apiSummary, onNavigateToEndp
|
|
|
57
56
|
/*#__PURE__*/ _jsxs("div", {
|
|
58
57
|
className: "docs-agent-title flex items-center gap-2.5",
|
|
59
58
|
children: [
|
|
60
|
-
/*#__PURE__*/ _jsx(
|
|
61
|
-
className: "
|
|
62
|
-
|
|
63
|
-
src: "/icon.png",
|
|
64
|
-
alt: "Assistant",
|
|
65
|
-
width: 28,
|
|
66
|
-
height: 28,
|
|
67
|
-
className: "size-7 object-cover"
|
|
68
|
-
})
|
|
59
|
+
/*#__PURE__*/ _jsx(Sparkle, {
|
|
60
|
+
className: "size-5 text-primary",
|
|
61
|
+
weight: "fill"
|
|
69
62
|
}),
|
|
70
63
|
/*#__PURE__*/ _jsx("span", {
|
|
71
64
|
className: "text-sm font-medium",
|
|
@@ -97,15 +90,26 @@ export function RightSidebar({ request, collection, apiSummary, onNavigateToEndp
|
|
|
97
90
|
})
|
|
98
91
|
]
|
|
99
92
|
}),
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
93
|
+
/*#__PURE__*/ _jsxs(Tooltip, {
|
|
94
|
+
children: [
|
|
95
|
+
/*#__PURE__*/ _jsx(TooltipTrigger, {
|
|
96
|
+
asChild: true,
|
|
97
|
+
children: /*#__PURE__*/ _jsx(Button, {
|
|
98
|
+
variant: "ghost",
|
|
99
|
+
size: "icon",
|
|
100
|
+
onClick: closeRightSidebar,
|
|
101
|
+
className: "h-7 w-7 text-muted-foreground hover:text-foreground",
|
|
102
|
+
children: /*#__PURE__*/ _jsx(X, {
|
|
103
|
+
className: "h-4 w-4",
|
|
104
|
+
weight: "bold"
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
}),
|
|
108
|
+
/*#__PURE__*/ _jsx(TooltipContent, {
|
|
109
|
+
side: "bottom",
|
|
110
|
+
children: "Close"
|
|
111
|
+
})
|
|
112
|
+
]
|
|
109
113
|
})
|
|
110
114
|
]
|
|
111
115
|
})
|
|
@@ -136,28 +140,20 @@ export function RightSidebar({ request, collection, apiSummary, onNavigateToEndp
|
|
|
136
140
|
})
|
|
137
141
|
]
|
|
138
142
|
});
|
|
139
|
-
//
|
|
140
|
-
|
|
143
|
+
// Render via portal for proper z-index stacking (both mobile and desktop)
|
|
144
|
+
if (!mounted) return null;
|
|
145
|
+
return /*#__PURE__*/ createPortal(/*#__PURE__*/ _jsxs(_Fragment, {
|
|
141
146
|
children: [
|
|
142
|
-
isRightSidebarOpen && /*#__PURE__*/ _jsx("div", {
|
|
143
|
-
className: "docs-agent-overlay fixed inset-0 bg-black/50 z-[55]
|
|
147
|
+
isMobile && isRightSidebarOpen && /*#__PURE__*/ _jsx("div", {
|
|
148
|
+
className: "docs-agent-overlay fixed inset-0 bg-black/50 z-[55]",
|
|
144
149
|
onClick: closeRightSidebar
|
|
145
150
|
}),
|
|
146
151
|
/*#__PURE__*/ _jsx("div", {
|
|
147
|
-
className: cn("docs-agent-panel border-l border-border bg-background flex flex-col overflow-hidden", "fixed inset-y-0 right-0 z-[60]
|
|
152
|
+
className: cn("docs-agent-panel border-l border-border bg-background flex flex-col overflow-hidden", "fixed inset-y-0 right-0 z-[60]", // Width: smaller on mobile, full 384px on desktop
|
|
153
|
+
"w-[320px] sm:w-[360px] lg:w-96", // Slide animation
|
|
154
|
+
"transform transition-transform duration-300 ease-in-out", isRightSidebarOpen ? "translate-x-0" : "translate-x-full"),
|
|
148
155
|
children: sidebarContent
|
|
149
156
|
})
|
|
150
157
|
]
|
|
151
|
-
}), document.body)
|
|
152
|
-
// Desktop sidebar rendered in place
|
|
153
|
-
const desktopSidebar = !isMobile ? /*#__PURE__*/ _jsx("div", {
|
|
154
|
-
className: cn("docs-agent-panel border-l border-border bg-background flex flex-col overflow-hidden", "relative w-96 h-full"),
|
|
155
|
-
children: sidebarContent
|
|
156
|
-
}) : null;
|
|
157
|
-
return /*#__PURE__*/ _jsxs(_Fragment, {
|
|
158
|
-
children: [
|
|
159
|
-
mobileSidebar,
|
|
160
|
-
desktopSidebar
|
|
161
|
-
]
|
|
162
|
-
});
|
|
158
|
+
}), document.body);
|
|
163
159
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import * as React from "react";
|
|
4
|
-
import { Moon, Sun,
|
|
4
|
+
import { Moon, Sun, Check } from "@phosphor-icons/react";
|
|
5
5
|
import { useTheme } from "next-themes";
|
|
6
6
|
import { Button } from "@/components/ui/button";
|
|
7
7
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
|
@@ -78,26 +78,6 @@ export function ThemeToggle() {
|
|
|
78
78
|
className: "h-4 w-4"
|
|
79
79
|
})
|
|
80
80
|
]
|
|
81
|
-
}),
|
|
82
|
-
/*#__PURE__*/ _jsxs(DropdownMenuItem, {
|
|
83
|
-
onClick: ()=>setTheme("system"),
|
|
84
|
-
className: cn("flex items-center justify-between gap-2", mounted && theme === "system" && "bg-accent"),
|
|
85
|
-
children: [
|
|
86
|
-
/*#__PURE__*/ _jsxs("div", {
|
|
87
|
-
className: "flex items-center gap-2",
|
|
88
|
-
children: [
|
|
89
|
-
/*#__PURE__*/ _jsx(Desktop, {
|
|
90
|
-
className: "h-4 w-4"
|
|
91
|
-
}),
|
|
92
|
-
/*#__PURE__*/ _jsx("span", {
|
|
93
|
-
children: "System"
|
|
94
|
-
})
|
|
95
|
-
]
|
|
96
|
-
}),
|
|
97
|
-
mounted && theme === "system" && /*#__PURE__*/ _jsx(Check, {
|
|
98
|
-
className: "h-4 w-4"
|
|
99
|
-
})
|
|
100
|
-
]
|
|
101
81
|
})
|
|
102
82
|
]
|
|
103
83
|
})
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useState, useEffect, useCallback, useMemo } from 'react';
|
|
3
|
+
/**
|
|
4
|
+
* Parse URL hash into route state
|
|
5
|
+
*
|
|
6
|
+
* URL Schema:
|
|
7
|
+
* - #tab → Tab only, show default content (docs view)
|
|
8
|
+
* - #tab/page/{slug} → Doc page (docs view)
|
|
9
|
+
* - #tab/endpoint/{id} → API endpoint (docs view)
|
|
10
|
+
* - #tab/endpoint/{id}/playground → API endpoint (playground view)
|
|
11
|
+
* - #tab/endpoint/{id}/notes → API endpoint (notes view)
|
|
12
|
+
* - #tab/page/{slug}/notes → Doc page (notes view)
|
|
13
|
+
*/ function parseHash(hash) {
|
|
14
|
+
// Remove leading # if present
|
|
15
|
+
const cleanHash = hash.startsWith('#') ? hash.slice(1) : hash;
|
|
16
|
+
// Default state
|
|
17
|
+
const defaultState = {
|
|
18
|
+
tab: null,
|
|
19
|
+
contentType: null,
|
|
20
|
+
contentId: null,
|
|
21
|
+
view: 'docs',
|
|
22
|
+
hash: cleanHash
|
|
23
|
+
};
|
|
24
|
+
if (!cleanHash) {
|
|
25
|
+
return defaultState;
|
|
26
|
+
}
|
|
27
|
+
// Split hash into parts: tab/type/id[/view]
|
|
28
|
+
const parts = cleanHash.split('/');
|
|
29
|
+
const tab = parts[0] || null;
|
|
30
|
+
const type = parts[1] || null;
|
|
31
|
+
// Check if last part is a view mode
|
|
32
|
+
const lastPart = parts[parts.length - 1];
|
|
33
|
+
const isViewSuffix = lastPart === 'playground' || lastPart === 'notes';
|
|
34
|
+
const view = isViewSuffix ? lastPart : 'docs';
|
|
35
|
+
// Get ID (everything between type and view, or type and end)
|
|
36
|
+
const idParts = isViewSuffix ? parts.slice(2, -1) : parts.slice(2);
|
|
37
|
+
const id = idParts.join('/') || null;
|
|
38
|
+
// Parse content type
|
|
39
|
+
let contentType = null;
|
|
40
|
+
if (type === 'endpoint') {
|
|
41
|
+
contentType = 'endpoint';
|
|
42
|
+
} else if (type === 'page') {
|
|
43
|
+
contentType = 'page';
|
|
44
|
+
} else if (type === 'section') {
|
|
45
|
+
contentType = 'section';
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
tab,
|
|
49
|
+
contentType,
|
|
50
|
+
contentId: id,
|
|
51
|
+
view,
|
|
52
|
+
hash: cleanHash
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Get the current hash from window.location
|
|
57
|
+
*/ function getCurrentHash() {
|
|
58
|
+
if (typeof window === 'undefined') return '';
|
|
59
|
+
return window.location.hash;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Hook that provides URL-based route state
|
|
63
|
+
*
|
|
64
|
+
* This hook is the single source of truth for page/content selection.
|
|
65
|
+
* It automatically updates when the URL hash changes (via navigation or browser back/forward).
|
|
66
|
+
*
|
|
67
|
+
* @example
|
|
68
|
+
* const { tab, contentType, contentId } = useRouteState()
|
|
69
|
+
*
|
|
70
|
+
* // Render based on content type
|
|
71
|
+
* if (contentType === 'page') {
|
|
72
|
+
* return <DocPage slug={contentId} />
|
|
73
|
+
* }
|
|
74
|
+
*/ export function useRouteState() {
|
|
75
|
+
const [hash, setHash] = useState(()=>getCurrentHash());
|
|
76
|
+
// Parse hash into route state
|
|
77
|
+
const state = useMemo(()=>parseHash(hash), [
|
|
78
|
+
hash
|
|
79
|
+
]);
|
|
80
|
+
// Listen for hash changes
|
|
81
|
+
useEffect(()=>{
|
|
82
|
+
const handleHashChange = ()=>{
|
|
83
|
+
setHash(getCurrentHash());
|
|
84
|
+
};
|
|
85
|
+
// Listen for both hashchange and popstate (browser back/forward)
|
|
86
|
+
window.addEventListener('hashchange', handleHashChange);
|
|
87
|
+
window.addEventListener('popstate', handleHashChange);
|
|
88
|
+
// Set initial hash (in case it was set before this effect ran)
|
|
89
|
+
handleHashChange();
|
|
90
|
+
return ()=>{
|
|
91
|
+
window.removeEventListener('hashchange', handleHashChange);
|
|
92
|
+
window.removeEventListener('popstate', handleHashChange);
|
|
93
|
+
};
|
|
94
|
+
}, []);
|
|
95
|
+
return state;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Hook that provides navigation functions for updating the URL
|
|
99
|
+
*
|
|
100
|
+
* @param defaultTab - Default tab to use when navigating
|
|
101
|
+
* @returns Navigation functions
|
|
102
|
+
*/ export function useRouteNavigation(defaultTab) {
|
|
103
|
+
const navigateToPage = useCallback((slug, tab, view)=>{
|
|
104
|
+
const targetTab = tab || defaultTab || '';
|
|
105
|
+
const viewSuffix = view && view !== 'docs' ? `/${view}` : '';
|
|
106
|
+
window.location.hash = targetTab ? `${targetTab}/page/${slug}${viewSuffix}` : `page/${slug}${viewSuffix}`;
|
|
107
|
+
}, [
|
|
108
|
+
defaultTab
|
|
109
|
+
]);
|
|
110
|
+
const navigateToEndpoint = useCallback((id, tab, view)=>{
|
|
111
|
+
const targetTab = tab || defaultTab || '';
|
|
112
|
+
const viewSuffix = view && view !== 'docs' ? `/${view}` : '';
|
|
113
|
+
window.location.hash = targetTab ? `${targetTab}/endpoint/${id}${viewSuffix}` : `endpoint/${id}${viewSuffix}`;
|
|
114
|
+
}, [
|
|
115
|
+
defaultTab
|
|
116
|
+
]);
|
|
117
|
+
const navigateToSection = useCallback((sectionId, tab)=>{
|
|
118
|
+
const targetTab = tab || defaultTab || '';
|
|
119
|
+
window.location.hash = targetTab ? `${targetTab}/section/${sectionId}` : `section/${sectionId}`;
|
|
120
|
+
}, [
|
|
121
|
+
defaultTab
|
|
122
|
+
]);
|
|
123
|
+
const navigateToTab = useCallback((tab)=>{
|
|
124
|
+
window.location.hash = tab;
|
|
125
|
+
}, []);
|
|
126
|
+
const clearSelection = useCallback((tab)=>{
|
|
127
|
+
const targetTab = tab || defaultTab;
|
|
128
|
+
if (targetTab) {
|
|
129
|
+
window.location.hash = targetTab;
|
|
130
|
+
} else {
|
|
131
|
+
// Remove hash entirely
|
|
132
|
+
window.history.pushState(null, '', window.location.pathname + window.location.search);
|
|
133
|
+
}
|
|
134
|
+
}, [
|
|
135
|
+
defaultTab
|
|
136
|
+
]);
|
|
137
|
+
return {
|
|
138
|
+
navigateToPage,
|
|
139
|
+
navigateToEndpoint,
|
|
140
|
+
navigateToSection,
|
|
141
|
+
navigateToTab,
|
|
142
|
+
clearSelection
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Utility to build hash URLs (for href attributes)
|
|
147
|
+
*/ export const buildHashUrl = {
|
|
148
|
+
page: (tab, slug, view)=>{
|
|
149
|
+
const viewSuffix = view && view !== 'docs' ? `/${view}` : '';
|
|
150
|
+
return `#${tab}/page/${slug}${viewSuffix}`;
|
|
151
|
+
},
|
|
152
|
+
endpoint: (tab, id, view)=>{
|
|
153
|
+
const viewSuffix = view && view !== 'docs' ? `/${view}` : '';
|
|
154
|
+
return `#${tab}/endpoint/${id}${viewSuffix}`;
|
|
155
|
+
},
|
|
156
|
+
section: (tab, sectionId)=>`#${tab}/section/${sectionId}`,
|
|
157
|
+
tab: (tab)=>`#${tab}`
|
|
158
|
+
};
|
|
159
|
+
export { parseHash };
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useState, useEffect, useMemo } from 'react';
|
|
3
|
+
/**
|
|
4
|
+
* Shared hook for fetching contextual suggestions
|
|
5
|
+
* Used by both AgentChat and AgentPopupButton
|
|
6
|
+
*
|
|
7
|
+
* Server-side caching with KV (1 week TTL) - no client-side cache needed
|
|
8
|
+
*/ export function useSuggestions({ currentEndpoint, endpointIndex = [], limit }) {
|
|
9
|
+
const [suggestions, setSuggestions] = useState([]);
|
|
10
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
11
|
+
// Stable key for request deduplication
|
|
12
|
+
const requestKey = useMemo(()=>currentEndpoint?.id || `general:${endpointIndex.length}`, [
|
|
13
|
+
currentEndpoint?.id,
|
|
14
|
+
endpointIndex.length
|
|
15
|
+
]);
|
|
16
|
+
// Fallback suggestions
|
|
17
|
+
const fallbackSuggestions = useMemo(()=>{
|
|
18
|
+
if (currentEndpoint) {
|
|
19
|
+
return [
|
|
20
|
+
{
|
|
21
|
+
title: 'What does this',
|
|
22
|
+
label: 'endpoint do?',
|
|
23
|
+
prompt: `What does ${currentEndpoint.name} do?`
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
title: 'What parameters',
|
|
27
|
+
label: 'are required?',
|
|
28
|
+
prompt: 'What parameters are required?'
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
title: 'Python example',
|
|
32
|
+
label: 'Show code',
|
|
33
|
+
prompt: 'Show me a Python example'
|
|
34
|
+
}
|
|
35
|
+
];
|
|
36
|
+
}
|
|
37
|
+
return [
|
|
38
|
+
{
|
|
39
|
+
title: 'Find an endpoint',
|
|
40
|
+
label: 'to create resources',
|
|
41
|
+
prompt: 'Find endpoints for creating resources'
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
title: 'How do I',
|
|
45
|
+
label: 'authenticate?',
|
|
46
|
+
prompt: 'How do I authenticate?'
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
title: 'Overview',
|
|
50
|
+
label: 'What can I do?',
|
|
51
|
+
prompt: 'What can I do with this API?'
|
|
52
|
+
}
|
|
53
|
+
];
|
|
54
|
+
}, [
|
|
55
|
+
currentEndpoint
|
|
56
|
+
]);
|
|
57
|
+
// Fetch suggestions from API (server handles KV caching)
|
|
58
|
+
useEffect(()=>{
|
|
59
|
+
let cancelled = false;
|
|
60
|
+
setIsLoading(true);
|
|
61
|
+
fetch('/api/suggestions', {
|
|
62
|
+
method: 'POST',
|
|
63
|
+
headers: {
|
|
64
|
+
'Content-Type': 'application/json'
|
|
65
|
+
},
|
|
66
|
+
body: JSON.stringify({
|
|
67
|
+
endpointIndex,
|
|
68
|
+
currentEndpointId: currentEndpoint?.id
|
|
69
|
+
})
|
|
70
|
+
}).then((res)=>res.json()).then((data)=>{
|
|
71
|
+
if (cancelled) return;
|
|
72
|
+
if (data.suggestions) {
|
|
73
|
+
const allSuggestions = data.suggestions;
|
|
74
|
+
setSuggestions(limit ? allSuggestions.slice(0, limit) : allSuggestions);
|
|
75
|
+
}
|
|
76
|
+
}).catch((err)=>{
|
|
77
|
+
if (cancelled) return;
|
|
78
|
+
console.warn('[useSuggestions] Failed to fetch suggestions:', err);
|
|
79
|
+
setSuggestions(limit ? fallbackSuggestions.slice(0, limit) : fallbackSuggestions);
|
|
80
|
+
}).finally(()=>{
|
|
81
|
+
if (!cancelled) setIsLoading(false);
|
|
82
|
+
});
|
|
83
|
+
return ()=>{
|
|
84
|
+
cancelled = true;
|
|
85
|
+
};
|
|
86
|
+
}, [
|
|
87
|
+
requestKey,
|
|
88
|
+
currentEndpoint,
|
|
89
|
+
endpointIndex,
|
|
90
|
+
limit,
|
|
91
|
+
fallbackSuggestions
|
|
92
|
+
]);
|
|
93
|
+
return {
|
|
94
|
+
suggestions,
|
|
95
|
+
isLoading
|
|
96
|
+
};
|
|
97
|
+
}
|