@brainfish-ai/devdoc 0.1.21
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/LICENSE +33 -0
- package/README.md +415 -0
- package/bin/devdoc.js +13 -0
- package/dist/cli/commands/build.d.ts +5 -0
- package/dist/cli/commands/build.js +87 -0
- package/dist/cli/commands/check.d.ts +1 -0
- package/dist/cli/commands/check.js +143 -0
- package/dist/cli/commands/create.d.ts +24 -0
- package/dist/cli/commands/create.js +387 -0
- package/dist/cli/commands/deploy.d.ts +9 -0
- package/dist/cli/commands/deploy.js +433 -0
- package/dist/cli/commands/dev.d.ts +6 -0
- package/dist/cli/commands/dev.js +139 -0
- package/dist/cli/commands/init.d.ts +11 -0
- package/dist/cli/commands/init.js +238 -0
- package/dist/cli/commands/keys.d.ts +12 -0
- package/dist/cli/commands/keys.js +165 -0
- package/dist/cli/commands/start.d.ts +5 -0
- package/dist/cli/commands/start.js +56 -0
- package/dist/cli/commands/upload.d.ts +13 -0
- package/dist/cli/commands/upload.js +238 -0
- package/dist/cli/commands/whoami.d.ts +8 -0
- package/dist/cli/commands/whoami.js +91 -0
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +106 -0
- package/dist/config/index.d.ts +80 -0
- package/dist/config/index.js +133 -0
- package/dist/constants.d.ts +9 -0
- package/dist/constants.js +13 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +12 -0
- package/dist/utils/logger.d.ts +16 -0
- package/dist/utils/logger.js +61 -0
- package/dist/utils/paths.d.ts +16 -0
- package/dist/utils/paths.js +50 -0
- package/package.json +51 -0
- package/renderer/app/api/assets/[...path]/route.ts +123 -0
- package/renderer/app/api/assets/route.ts +124 -0
- package/renderer/app/api/assets/upload/route.ts +177 -0
- package/renderer/app/api/auth-schemes/route.ts +77 -0
- package/renderer/app/api/chat/route.ts +858 -0
- package/renderer/app/api/codegen/route.ts +72 -0
- package/renderer/app/api/collections/route.ts +1016 -0
- package/renderer/app/api/debug/route.ts +53 -0
- package/renderer/app/api/deploy/route.ts +234 -0
- package/renderer/app/api/device/route.ts +42 -0
- package/renderer/app/api/docs/route.ts +187 -0
- package/renderer/app/api/keys/regenerate/route.ts +80 -0
- package/renderer/app/api/openapi-spec/route.ts +151 -0
- package/renderer/app/api/projects/[slug]/route.ts +153 -0
- package/renderer/app/api/projects/[slug]/stats/route.ts +96 -0
- package/renderer/app/api/projects/register/route.ts +152 -0
- package/renderer/app/api/proxy/route.ts +149 -0
- package/renderer/app/api/proxy-stream/route.ts +168 -0
- package/renderer/app/api/redirects/route.ts +47 -0
- package/renderer/app/api/schema/route.ts +65 -0
- package/renderer/app/api/subdomains/check/route.ts +172 -0
- package/renderer/app/api/suggestions/route.ts +144 -0
- package/renderer/app/favicon.ico +0 -0
- package/renderer/app/globals.css +1103 -0
- package/renderer/app/layout.tsx +47 -0
- package/renderer/app/llms-full.txt/route.ts +346 -0
- package/renderer/app/llms.txt/route.ts +279 -0
- package/renderer/app/page.tsx +14 -0
- package/renderer/app/robots.txt/route.ts +84 -0
- package/renderer/app/sitemap.xml/route.ts +199 -0
- package/renderer/components/docs/index.ts +12 -0
- package/renderer/components/docs/mdx/accordion.tsx +169 -0
- package/renderer/components/docs/mdx/badge.tsx +132 -0
- package/renderer/components/docs/mdx/callouts.tsx +154 -0
- package/renderer/components/docs/mdx/cards.tsx +213 -0
- package/renderer/components/docs/mdx/changelog.tsx +120 -0
- package/renderer/components/docs/mdx/code-block.tsx +186 -0
- package/renderer/components/docs/mdx/code-group.tsx +421 -0
- package/renderer/components/docs/mdx/file-embeds.tsx +105 -0
- package/renderer/components/docs/mdx/frame.tsx +112 -0
- package/renderer/components/docs/mdx/highlight.tsx +151 -0
- package/renderer/components/docs/mdx/iframe.tsx +134 -0
- package/renderer/components/docs/mdx/image.tsx +235 -0
- package/renderer/components/docs/mdx/index.ts +204 -0
- package/renderer/components/docs/mdx/mermaid.tsx +240 -0
- package/renderer/components/docs/mdx/param-field.tsx +200 -0
- package/renderer/components/docs/mdx/steps.tsx +113 -0
- package/renderer/components/docs/mdx/tabs.tsx +86 -0
- package/renderer/components/docs/mdx-renderer.tsx +100 -0
- package/renderer/components/docs/navigation/breadcrumbs.tsx +76 -0
- package/renderer/components/docs/navigation/index.ts +8 -0
- package/renderer/components/docs/navigation/page-nav.tsx +64 -0
- package/renderer/components/docs/navigation/sidebar.tsx +515 -0
- package/renderer/components/docs/navigation/toc.tsx +113 -0
- package/renderer/components/docs/notice.tsx +105 -0
- package/renderer/components/docs-header.tsx +274 -0
- package/renderer/components/docs-viewer/agent/agent-chat.tsx +2076 -0
- package/renderer/components/docs-viewer/agent/cards/debug-context-card.tsx +90 -0
- package/renderer/components/docs-viewer/agent/cards/endpoint-context-card.tsx +49 -0
- package/renderer/components/docs-viewer/agent/cards/index.tsx +50 -0
- package/renderer/components/docs-viewer/agent/cards/response-options-card.tsx +212 -0
- package/renderer/components/docs-viewer/agent/cards/types.ts +84 -0
- package/renderer/components/docs-viewer/agent/chat-message.tsx +17 -0
- package/renderer/components/docs-viewer/agent/index.tsx +6 -0
- package/renderer/components/docs-viewer/agent/messages/assistant-message.tsx +119 -0
- package/renderer/components/docs-viewer/agent/messages/chat-message.tsx +46 -0
- package/renderer/components/docs-viewer/agent/messages/index.ts +17 -0
- package/renderer/components/docs-viewer/agent/messages/tool-call-display.tsx +721 -0
- package/renderer/components/docs-viewer/agent/messages/types.ts +61 -0
- package/renderer/components/docs-viewer/agent/messages/typing-indicator.tsx +24 -0
- package/renderer/components/docs-viewer/agent/messages/user-message.tsx +51 -0
- package/renderer/components/docs-viewer/code-editor/index.tsx +2 -0
- package/renderer/components/docs-viewer/code-editor/notes-mode.tsx +1283 -0
- package/renderer/components/docs-viewer/content/changelog-page.tsx +331 -0
- package/renderer/components/docs-viewer/content/doc-page.tsx +285 -0
- package/renderer/components/docs-viewer/content/documentation-viewer.tsx +17 -0
- package/renderer/components/docs-viewer/content/index.tsx +29 -0
- package/renderer/components/docs-viewer/content/introduction.tsx +21 -0
- package/renderer/components/docs-viewer/content/request-details.tsx +330 -0
- package/renderer/components/docs-viewer/content/sections/auth.tsx +69 -0
- package/renderer/components/docs-viewer/content/sections/body.tsx +66 -0
- package/renderer/components/docs-viewer/content/sections/headers.tsx +43 -0
- package/renderer/components/docs-viewer/content/sections/overview.tsx +40 -0
- package/renderer/components/docs-viewer/content/sections/parameters.tsx +43 -0
- package/renderer/components/docs-viewer/content/sections/responses.tsx +87 -0
- package/renderer/components/docs-viewer/global-auth-modal.tsx +352 -0
- package/renderer/components/docs-viewer/index.tsx +1466 -0
- package/renderer/components/docs-viewer/playground/auth-editor.tsx +280 -0
- package/renderer/components/docs-viewer/playground/body-editor.tsx +221 -0
- package/renderer/components/docs-viewer/playground/code-editor.tsx +224 -0
- package/renderer/components/docs-viewer/playground/code-snippet.tsx +387 -0
- package/renderer/components/docs-viewer/playground/graphql-playground.tsx +745 -0
- package/renderer/components/docs-viewer/playground/index.tsx +671 -0
- package/renderer/components/docs-viewer/playground/key-value-editor.tsx +261 -0
- package/renderer/components/docs-viewer/playground/method-selector.tsx +60 -0
- package/renderer/components/docs-viewer/playground/request-builder.tsx +179 -0
- package/renderer/components/docs-viewer/playground/request-tabs.tsx +237 -0
- package/renderer/components/docs-viewer/playground/response-cards/idle-card.tsx +21 -0
- package/renderer/components/docs-viewer/playground/response-cards/index.tsx +93 -0
- package/renderer/components/docs-viewer/playground/response-cards/loading-card.tsx +16 -0
- package/renderer/components/docs-viewer/playground/response-cards/network-error-card.tsx +23 -0
- package/renderer/components/docs-viewer/playground/response-cards/response-body-card.tsx +268 -0
- package/renderer/components/docs-viewer/playground/response-cards/types.ts +82 -0
- package/renderer/components/docs-viewer/playground/response-viewer.tsx +43 -0
- package/renderer/components/docs-viewer/search/index.ts +2 -0
- package/renderer/components/docs-viewer/search/search-dialog.tsx +331 -0
- package/renderer/components/docs-viewer/search/use-search.ts +117 -0
- package/renderer/components/docs-viewer/shared/markdown-renderer.tsx +431 -0
- package/renderer/components/docs-viewer/shared/method-badge.tsx +41 -0
- package/renderer/components/docs-viewer/shared/schema-viewer.tsx +349 -0
- package/renderer/components/docs-viewer/sidebar/collection-tree.tsx +239 -0
- package/renderer/components/docs-viewer/sidebar/endpoint-options.tsx +316 -0
- package/renderer/components/docs-viewer/sidebar/index.tsx +343 -0
- package/renderer/components/docs-viewer/sidebar/right-sidebar.tsx +202 -0
- package/renderer/components/docs-viewer/sidebar/sidebar-group.tsx +118 -0
- package/renderer/components/docs-viewer/sidebar/sidebar-item.tsx +226 -0
- package/renderer/components/docs-viewer/sidebar/sidebar-section.tsx +52 -0
- package/renderer/components/theme-provider.tsx +11 -0
- package/renderer/components/theme-toggle.tsx +76 -0
- package/renderer/components/ui/badge.tsx +46 -0
- package/renderer/components/ui/button.tsx +59 -0
- package/renderer/components/ui/dialog.tsx +118 -0
- package/renderer/components/ui/dropdown-menu.tsx +257 -0
- package/renderer/components/ui/input.tsx +21 -0
- package/renderer/components/ui/label.tsx +24 -0
- package/renderer/components/ui/navigation-menu.tsx +168 -0
- package/renderer/components/ui/select.tsx +190 -0
- package/renderer/components/ui/spinner.tsx +114 -0
- package/renderer/components/ui/tabs.tsx +66 -0
- package/renderer/components/ui/tooltip.tsx +61 -0
- package/renderer/hooks/use-code-copy.ts +88 -0
- package/renderer/hooks/use-openapi-title.ts +44 -0
- package/renderer/lib/api-docs/agent/index.ts +6 -0
- package/renderer/lib/api-docs/agent/indexer.ts +323 -0
- package/renderer/lib/api-docs/agent/spec-summary.ts +335 -0
- package/renderer/lib/api-docs/agent/types.ts +116 -0
- package/renderer/lib/api-docs/auth/auth-context.tsx +225 -0
- package/renderer/lib/api-docs/auth/auth-storage.ts +87 -0
- package/renderer/lib/api-docs/auth/crypto.ts +89 -0
- package/renderer/lib/api-docs/auth/index.ts +4 -0
- package/renderer/lib/api-docs/code-editor/db.ts +164 -0
- package/renderer/lib/api-docs/code-editor/hooks.ts +266 -0
- package/renderer/lib/api-docs/code-editor/index.ts +6 -0
- package/renderer/lib/api-docs/code-editor/mode-context.tsx +207 -0
- package/renderer/lib/api-docs/code-editor/types.ts +105 -0
- package/renderer/lib/api-docs/codegen/definitions.ts +297 -0
- package/renderer/lib/api-docs/codegen/har.ts +251 -0
- package/renderer/lib/api-docs/codegen/index.ts +159 -0
- package/renderer/lib/api-docs/factories.ts +151 -0
- package/renderer/lib/api-docs/index.ts +17 -0
- package/renderer/lib/api-docs/mobile-context.tsx +112 -0
- package/renderer/lib/api-docs/navigation-context.tsx +88 -0
- package/renderer/lib/api-docs/parsers/graphql/README.md +129 -0
- package/renderer/lib/api-docs/parsers/graphql/index.ts +91 -0
- package/renderer/lib/api-docs/parsers/graphql/parser.ts +491 -0
- package/renderer/lib/api-docs/parsers/graphql/transformer.ts +246 -0
- package/renderer/lib/api-docs/parsers/graphql/types.ts +283 -0
- package/renderer/lib/api-docs/parsers/openapi/README.md +32 -0
- package/renderer/lib/api-docs/parsers/openapi/dereferencer.ts +60 -0
- package/renderer/lib/api-docs/parsers/openapi/extractors/auth.ts +574 -0
- package/renderer/lib/api-docs/parsers/openapi/extractors/body.ts +403 -0
- package/renderer/lib/api-docs/parsers/openapi/extractors/index.ts +232 -0
- package/renderer/lib/api-docs/parsers/openapi/index.ts +171 -0
- package/renderer/lib/api-docs/parsers/openapi/transformer.ts +277 -0
- package/renderer/lib/api-docs/parsers/openapi/validator.ts +31 -0
- package/renderer/lib/api-docs/playground/context.tsx +107 -0
- package/renderer/lib/api-docs/playground/navigation-context.tsx +124 -0
- package/renderer/lib/api-docs/playground/request-builder.ts +223 -0
- package/renderer/lib/api-docs/playground/request-runner.ts +282 -0
- package/renderer/lib/api-docs/playground/types.ts +35 -0
- package/renderer/lib/api-docs/types.ts +269 -0
- package/renderer/lib/api-docs/utils.ts +311 -0
- package/renderer/lib/cache.ts +193 -0
- package/renderer/lib/docs/config/index.ts +29 -0
- package/renderer/lib/docs/config/loader.ts +142 -0
- package/renderer/lib/docs/config/schema.ts +298 -0
- package/renderer/lib/docs/index.ts +12 -0
- package/renderer/lib/docs/mdx/compiler.ts +176 -0
- package/renderer/lib/docs/mdx/frontmatter.ts +80 -0
- package/renderer/lib/docs/mdx/index.ts +26 -0
- package/renderer/lib/docs/navigation/generator.ts +348 -0
- package/renderer/lib/docs/navigation/index.ts +12 -0
- package/renderer/lib/docs/navigation/types.ts +123 -0
- package/renderer/lib/docs-navigation-context.tsx +80 -0
- package/renderer/lib/multi-tenant/context.ts +105 -0
- package/renderer/lib/storage/blob.ts +845 -0
- package/renderer/lib/utils.ts +6 -0
- package/renderer/next.config.ts +76 -0
- package/renderer/package.json +66 -0
- package/renderer/postcss.config.mjs +5 -0
- package/renderer/public/assets/images/screenshot.png +0 -0
- package/renderer/public/assets/logo/dark.svg +9 -0
- package/renderer/public/assets/logo/light.svg +9 -0
- package/renderer/public/assets/logo.svg +9 -0
- package/renderer/public/file.svg +1 -0
- package/renderer/public/globe.svg +1 -0
- package/renderer/public/icon.png +0 -0
- package/renderer/public/logo.svg +9 -0
- package/renderer/public/window.svg +1 -0
- package/renderer/tsconfig.json +28 -0
- package/templates/basic/README.md +139 -0
- package/templates/basic/assets/favicon.svg +4 -0
- package/templates/basic/assets/logo.svg +9 -0
- package/templates/basic/docs.json +47 -0
- package/templates/basic/guides/configuration.mdx +149 -0
- package/templates/basic/guides/overview.mdx +96 -0
- package/templates/basic/index.mdx +39 -0
- package/templates/basic/package.json +14 -0
- package/templates/basic/quickstart.mdx +92 -0
- package/templates/basic/vercel.json +6 -0
- package/templates/graphql/README.md +139 -0
- package/templates/graphql/api-reference/schema.graphql +305 -0
- package/templates/graphql/assets/favicon.svg +4 -0
- package/templates/graphql/assets/logo.svg +9 -0
- package/templates/graphql/docs.json +54 -0
- package/templates/graphql/guides/configuration.mdx +149 -0
- package/templates/graphql/guides/overview.mdx +96 -0
- package/templates/graphql/index.mdx +39 -0
- package/templates/graphql/package.json +14 -0
- package/templates/graphql/quickstart.mdx +92 -0
- package/templates/graphql/vercel.json +6 -0
- package/templates/openapi/README.md +139 -0
- package/templates/openapi/api-reference/openapi.json +419 -0
- package/templates/openapi/assets/favicon.svg +4 -0
- package/templates/openapi/assets/logo.svg +9 -0
- package/templates/openapi/docs.json +61 -0
- package/templates/openapi/guides/configuration.mdx +149 -0
- package/templates/openapi/guides/overview.mdx +96 -0
- package/templates/openapi/index.mdx +39 -0
- package/templates/openapi/package.json +14 -0
- package/templates/openapi/quickstart.mdx +92 -0
- package/templates/openapi/vercel.json +6 -0
|
@@ -0,0 +1,2076 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useCallback, useState, FormEvent, useMemo } from 'react'
|
|
4
|
+
import { useChat } from '@ai-sdk/react'
|
|
5
|
+
import {
|
|
6
|
+
DefaultChatTransport,
|
|
7
|
+
lastAssistantMessageIsCompleteWithToolCalls,
|
|
8
|
+
UIMessage,
|
|
9
|
+
} from 'ai'
|
|
10
|
+
import {
|
|
11
|
+
ArrowUp,
|
|
12
|
+
Square,
|
|
13
|
+
Image as ImageIcon,
|
|
14
|
+
X
|
|
15
|
+
} from '@phosphor-icons/react'
|
|
16
|
+
import { Button } from '@/components/ui/button'
|
|
17
|
+
import type { BrainfishCollection, BrainfishRESTRequest } from '@/lib/api-docs/types'
|
|
18
|
+
import type { PrefillData, RequestValidationResult } from '@/lib/api-docs/agent/types'
|
|
19
|
+
import type { DebugContext } from '../playground/response-viewer'
|
|
20
|
+
import { encodeCardMessage, detectErrorType } from './cards'
|
|
21
|
+
import { buildEndpointIndex, searchEndpoints, findRequestById, formatRequestForAI } from '@/lib/api-docs/agent/indexer'
|
|
22
|
+
import { ChatMessage, TypingIndicator } from './chat-message'
|
|
23
|
+
import { cn } from '@/lib/utils'
|
|
24
|
+
import {
|
|
25
|
+
Tooltip,
|
|
26
|
+
TooltipContent,
|
|
27
|
+
TooltipTrigger,
|
|
28
|
+
} from '@/components/ui/tooltip'
|
|
29
|
+
import { useModeContext, useNotes, useWorkspace } from '@/lib/api-docs/code-editor'
|
|
30
|
+
import { useCurrentRequestPayload, useSendTrigger } from '@/lib/api-docs/playground/context'
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Custom transport that intercepts streaming events for write_file tool
|
|
34
|
+
* Wraps DefaultChatTransport and intercepts the stream to extract write_file content
|
|
35
|
+
*/
|
|
36
|
+
function createStreamingTransport(
|
|
37
|
+
config: ConstructorParameters<typeof DefaultChatTransport>[0],
|
|
38
|
+
callbacks: {
|
|
39
|
+
onWriteFileDelta: (toolCallId: string, path: string, content: string) => void
|
|
40
|
+
onWriteFileStart: (toolCallId: string, path: string) => void
|
|
41
|
+
onWriteFileEnd: (toolCallId: string) => void
|
|
42
|
+
}
|
|
43
|
+
): DefaultChatTransport<UIMessage> {
|
|
44
|
+
const transport = new DefaultChatTransport<UIMessage>(config)
|
|
45
|
+
|
|
46
|
+
// Track active write_file tool calls
|
|
47
|
+
const activeWriteFiles = new Map<string, { path: string; lastContent: string }>()
|
|
48
|
+
const toolInputBuffers = new Map<string, string>()
|
|
49
|
+
|
|
50
|
+
// Override the fetch to intercept the stream
|
|
51
|
+
const originalFetch = transport['fetch'] || globalThis.fetch
|
|
52
|
+
|
|
53
|
+
transport['fetch'] = async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
54
|
+
const response = await originalFetch(input, init)
|
|
55
|
+
|
|
56
|
+
if (!response.body) return response
|
|
57
|
+
|
|
58
|
+
// Create a transform stream to intercept and process the SSE events
|
|
59
|
+
const reader = response.body.getReader()
|
|
60
|
+
const decoder = new TextDecoder()
|
|
61
|
+
let buffer = ''
|
|
62
|
+
|
|
63
|
+
const processedStream = new ReadableStream({
|
|
64
|
+
async start(controller) {
|
|
65
|
+
try {
|
|
66
|
+
while (true) {
|
|
67
|
+
const { done, value } = await reader.read()
|
|
68
|
+
|
|
69
|
+
if (done) {
|
|
70
|
+
// Stream ended - close all active write_files
|
|
71
|
+
for (const [toolCallId] of activeWriteFiles) {
|
|
72
|
+
callbacks.onWriteFileEnd(toolCallId)
|
|
73
|
+
}
|
|
74
|
+
activeWriteFiles.clear()
|
|
75
|
+
toolInputBuffers.clear()
|
|
76
|
+
controller.close()
|
|
77
|
+
break
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Pass through the original data
|
|
81
|
+
controller.enqueue(value)
|
|
82
|
+
|
|
83
|
+
// Parse the SSE events for write_file content
|
|
84
|
+
buffer += decoder.decode(value, { stream: true })
|
|
85
|
+
const lines = buffer.split('\n')
|
|
86
|
+
buffer = lines.pop() || '' // Keep incomplete line in buffer
|
|
87
|
+
|
|
88
|
+
for (const line of lines) {
|
|
89
|
+
if (line.startsWith('data: ')) {
|
|
90
|
+
const data = line.slice(6)
|
|
91
|
+
if (data === '[DONE]') continue
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
const event = JSON.parse(data)
|
|
95
|
+
|
|
96
|
+
// Handle tool-input-start for write_file
|
|
97
|
+
if (event.type === 'tool-input-start' && event.toolName === 'write_file') {
|
|
98
|
+
toolInputBuffers.set(event.toolCallId, '')
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Handle tool-input-delta for write_file
|
|
102
|
+
if (event.type === 'tool-input-delta' && toolInputBuffers.has(event.toolCallId)) {
|
|
103
|
+
const currentBuffer = toolInputBuffers.get(event.toolCallId) || ''
|
|
104
|
+
const newBuffer = currentBuffer + (event.inputTextDelta || '')
|
|
105
|
+
toolInputBuffers.set(event.toolCallId, newBuffer)
|
|
106
|
+
|
|
107
|
+
// Try to parse partial JSON to extract content
|
|
108
|
+
try {
|
|
109
|
+
// Look for "content": " and extract what comes after
|
|
110
|
+
const contentMatch = newBuffer.match(/"content":\s*"((?:[^"\\]|\\.)*)/)
|
|
111
|
+
if (contentMatch) {
|
|
112
|
+
const escapedContent = contentMatch[1]
|
|
113
|
+
// Unescape the content
|
|
114
|
+
const content = escapedContent
|
|
115
|
+
.replace(/\\n/g, '\n')
|
|
116
|
+
.replace(/\\t/g, '\t')
|
|
117
|
+
.replace(/\\r/g, '\r')
|
|
118
|
+
.replace(/\\"/g, '"')
|
|
119
|
+
.replace(/\\\\/g, '\\')
|
|
120
|
+
|
|
121
|
+
const activeWrite = activeWriteFiles.get(event.toolCallId)
|
|
122
|
+
|
|
123
|
+
// Also try to extract path
|
|
124
|
+
const pathMatch = newBuffer.match(/"path":\s*"([^"]+)"/)
|
|
125
|
+
const path = pathMatch?.[1] || ''
|
|
126
|
+
|
|
127
|
+
if (!activeWrite && path) {
|
|
128
|
+
// First time seeing content with path - start streaming
|
|
129
|
+
activeWriteFiles.set(event.toolCallId, { path, lastContent: content })
|
|
130
|
+
callbacks.onWriteFileStart(event.toolCallId, path)
|
|
131
|
+
callbacks.onWriteFileDelta(event.toolCallId, path, content)
|
|
132
|
+
} else if (activeWrite && content.length > activeWrite.lastContent.length) {
|
|
133
|
+
// Content grew - send new content
|
|
134
|
+
activeWrite.lastContent = content
|
|
135
|
+
callbacks.onWriteFileDelta(event.toolCallId, activeWrite.path, content)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
} catch {
|
|
139
|
+
// JSON not complete yet, continue buffering
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Handle tool-input-available (complete)
|
|
144
|
+
if (event.type === 'tool-input-available' && event.toolName === 'write_file') {
|
|
145
|
+
const activeWrite = activeWriteFiles.get(event.toolCallId)
|
|
146
|
+
if (activeWrite) {
|
|
147
|
+
callbacks.onWriteFileEnd(event.toolCallId)
|
|
148
|
+
activeWriteFiles.delete(event.toolCallId)
|
|
149
|
+
}
|
|
150
|
+
toolInputBuffers.delete(event.toolCallId)
|
|
151
|
+
}
|
|
152
|
+
} catch {
|
|
153
|
+
// Invalid JSON, skip
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
} catch (err) {
|
|
159
|
+
controller.error(err)
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
// Return new response with the processed stream
|
|
165
|
+
return new Response(processedStream, {
|
|
166
|
+
headers: response.headers,
|
|
167
|
+
status: response.status,
|
|
168
|
+
statusText: response.statusText,
|
|
169
|
+
})
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return transport
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
interface Suggestion {
|
|
176
|
+
title: string
|
|
177
|
+
label: string
|
|
178
|
+
prompt: string
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Client-side cache for suggestions
|
|
182
|
+
const SUGGESTIONS_CACHE_KEY = 'brainfish-suggestions-cache'
|
|
183
|
+
|
|
184
|
+
function getSuggestionsFromCache(key: string): Suggestion[] | null {
|
|
185
|
+
try {
|
|
186
|
+
const cached = localStorage.getItem(SUGGESTIONS_CACHE_KEY)
|
|
187
|
+
if (cached) {
|
|
188
|
+
const data = JSON.parse(cached)
|
|
189
|
+
if (data[key] && Date.now() - data[key].timestamp < 1000 * 60 * 60) { // 1 hour TTL
|
|
190
|
+
return data[key].suggestions
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
} catch {
|
|
194
|
+
// Ignore cache errors
|
|
195
|
+
}
|
|
196
|
+
return null
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function saveSuggestionsToCache(key: string, suggestions: Suggestion[]) {
|
|
200
|
+
try {
|
|
201
|
+
const cached = localStorage.getItem(SUGGESTIONS_CACHE_KEY)
|
|
202
|
+
const data = cached ? JSON.parse(cached) : {}
|
|
203
|
+
data[key] = { suggestions, timestamp: Date.now() }
|
|
204
|
+
localStorage.setItem(SUGGESTIONS_CACHE_KEY, JSON.stringify(data))
|
|
205
|
+
} catch {
|
|
206
|
+
// Ignore cache errors
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Changelog release type for agent search
|
|
211
|
+
interface ChangelogRelease {
|
|
212
|
+
version: string
|
|
213
|
+
date: string
|
|
214
|
+
title: string
|
|
215
|
+
slug: string
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Extended collection type with optional changelog
|
|
219
|
+
interface ExtendedCollection extends BrainfishCollection {
|
|
220
|
+
changelogReleases?: ChangelogRelease[]
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
interface AgentChatProps {
|
|
224
|
+
collection: ExtendedCollection
|
|
225
|
+
currentEndpoint: BrainfishRESTRequest | null
|
|
226
|
+
onNavigate: (endpointId: string) => void
|
|
227
|
+
onPrefill: (data: PrefillData) => void
|
|
228
|
+
apiSummary?: string | null
|
|
229
|
+
debugContext?: DebugContext | null
|
|
230
|
+
onDebugContextConsumed?: () => void
|
|
231
|
+
/** Explain context from playground response - triggers agent explanation */
|
|
232
|
+
explainContext?: DebugContext | null
|
|
233
|
+
/** Callback to clear explain context after processing */
|
|
234
|
+
onExplainContextConsumed?: () => void
|
|
235
|
+
onOpenGlobalAuth?: () => void
|
|
236
|
+
onNavigateToAuthTab?: () => void
|
|
237
|
+
onNavigateToParamsTab?: () => void
|
|
238
|
+
onNavigateToBodyTab?: () => void
|
|
239
|
+
onNavigateToHeadersTab?: () => void
|
|
240
|
+
/** Callback to navigate to a documentation section */
|
|
241
|
+
onNavigateToDocSection?: (sectionId: string) => void
|
|
242
|
+
/** Callback to navigate to a documentation page */
|
|
243
|
+
onNavigateToDocPage?: (slug: string) => void
|
|
244
|
+
/** Callback when message count changes */
|
|
245
|
+
onHasMessagesChange?: (hasMessages: boolean) => void
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Storage key for chat history
|
|
249
|
+
const CHAT_STORAGE_KEY = 'brainfish-agent-chat'
|
|
250
|
+
|
|
251
|
+
export function AgentChat({
|
|
252
|
+
collection,
|
|
253
|
+
currentEndpoint,
|
|
254
|
+
onNavigate,
|
|
255
|
+
onPrefill,
|
|
256
|
+
apiSummary,
|
|
257
|
+
debugContext,
|
|
258
|
+
onDebugContextConsumed,
|
|
259
|
+
explainContext,
|
|
260
|
+
onExplainContextConsumed,
|
|
261
|
+
onOpenGlobalAuth,
|
|
262
|
+
onNavigateToAuthTab,
|
|
263
|
+
onNavigateToParamsTab,
|
|
264
|
+
onNavigateToBodyTab,
|
|
265
|
+
onNavigateToHeadersTab,
|
|
266
|
+
onNavigateToDocSection,
|
|
267
|
+
onNavigateToDocPage,
|
|
268
|
+
onHasMessagesChange,
|
|
269
|
+
}: AgentChatProps) {
|
|
270
|
+
const messagesEndRef = useRef<HTMLDivElement>(null)
|
|
271
|
+
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
|
272
|
+
const processedDebugRef = useRef<string | null>(null)
|
|
273
|
+
|
|
274
|
+
// Local input state (controlled by us, not useChat)
|
|
275
|
+
const [inputValue, setInputValue] = useState('')
|
|
276
|
+
|
|
277
|
+
// Image upload state
|
|
278
|
+
const [selectedImages, setSelectedImages] = useState<Array<{ file: File; preview: string; base64: string }>>([])
|
|
279
|
+
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
280
|
+
|
|
281
|
+
// Mode context for notes, API client, and docs
|
|
282
|
+
const { switchToNotes, switchToApiClient, switchToDocs, setActiveFilePath, setStreamingContent, triggerNotesRefresh } = useModeContext()
|
|
283
|
+
|
|
284
|
+
// Get workspace for notes
|
|
285
|
+
const apiSpecUrl = process.env.NEXT_PUBLIC_OPENAPI_URL || collection.name || 'default'
|
|
286
|
+
const { workspace } = useWorkspace(apiSpecUrl, collection.name)
|
|
287
|
+
const { notes, createNote, updateNote, deleteNote, deleteAllNotes, refresh: refreshNotes } = useNotes(workspace?.id || null)
|
|
288
|
+
|
|
289
|
+
// Suggestions state
|
|
290
|
+
const [suggestions, setSuggestions] = useState<Suggestion[]>([])
|
|
291
|
+
const [loadingSuggestions, setLoadingSuggestions] = useState(false)
|
|
292
|
+
const [showSuggestions, setShowSuggestions] = useState(true)
|
|
293
|
+
|
|
294
|
+
// Current request payload from playground
|
|
295
|
+
const { currentRequestPayload } = useCurrentRequestPayload()
|
|
296
|
+
|
|
297
|
+
// Send trigger - to invoke playground's send button
|
|
298
|
+
const { requestSend } = useSendTrigger()
|
|
299
|
+
|
|
300
|
+
// Build endpoint index for search
|
|
301
|
+
const endpointIndex = useMemo(() => buildEndpointIndex(collection), [collection])
|
|
302
|
+
|
|
303
|
+
// Handler to open a file from chat (when user clicks on file name in tool display)
|
|
304
|
+
const handleOpenFile = useCallback((path: string) => {
|
|
305
|
+
switchToNotes(path) // Pass path directly to avoid URL reset
|
|
306
|
+
}, [switchToNotes])
|
|
307
|
+
|
|
308
|
+
// Track last streamed content to compute deltas
|
|
309
|
+
const lastStreamedContent = useRef<Map<string, string>>(new Map())
|
|
310
|
+
|
|
311
|
+
// Build documentation index for search (including nested pages)
|
|
312
|
+
const docIndex = useMemo(() => {
|
|
313
|
+
if (!collection.docGroups) return []
|
|
314
|
+
|
|
315
|
+
const pages: Array<{
|
|
316
|
+
id: string
|
|
317
|
+
slug: string
|
|
318
|
+
title: string
|
|
319
|
+
description: string
|
|
320
|
+
group: string
|
|
321
|
+
groupId: string
|
|
322
|
+
}> = []
|
|
323
|
+
|
|
324
|
+
// Helper to recursively add pages
|
|
325
|
+
const addPages = (pageList: typeof collection.docGroups[0]['pages'], groupTitle: string, groupId: string) => {
|
|
326
|
+
for (const page of pageList) {
|
|
327
|
+
pages.push({
|
|
328
|
+
id: page.id,
|
|
329
|
+
slug: page.slug,
|
|
330
|
+
title: page.title,
|
|
331
|
+
description: page.description || '',
|
|
332
|
+
group: groupTitle,
|
|
333
|
+
groupId: groupId,
|
|
334
|
+
})
|
|
335
|
+
// Also add children if present
|
|
336
|
+
if (page.children && page.children.length > 0) {
|
|
337
|
+
addPages(page.children, page.title || groupTitle, groupId)
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
for (const group of collection.docGroups) {
|
|
343
|
+
addPages(group.pages, group.title, group.id)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return pages
|
|
347
|
+
}, [collection])
|
|
348
|
+
|
|
349
|
+
// Build changelog index for search
|
|
350
|
+
const changelogIndex = useMemo(() => {
|
|
351
|
+
if (!collection.changelogReleases) return []
|
|
352
|
+
return collection.changelogReleases.map(release => ({
|
|
353
|
+
version: release.version,
|
|
354
|
+
date: release.date,
|
|
355
|
+
title: release.title,
|
|
356
|
+
slug: release.slug,
|
|
357
|
+
}))
|
|
358
|
+
}, [collection])
|
|
359
|
+
|
|
360
|
+
// Create streaming transport with callbacks
|
|
361
|
+
const transport = useMemo(() => {
|
|
362
|
+
return createStreamingTransport(
|
|
363
|
+
{
|
|
364
|
+
api: '/api/chat',
|
|
365
|
+
body: {
|
|
366
|
+
endpointIndex,
|
|
367
|
+
docIndex,
|
|
368
|
+
changelogIndex,
|
|
369
|
+
currentEndpointId: currentEndpoint?.id,
|
|
370
|
+
apiSummary: apiSummary || undefined,
|
|
371
|
+
},
|
|
372
|
+
},
|
|
373
|
+
{
|
|
374
|
+
onWriteFileStart: (toolCallId, path) => {
|
|
375
|
+
lastStreamedContent.current.set(toolCallId, '')
|
|
376
|
+
setStreamingContent({ path, content: '', isStreaming: true })
|
|
377
|
+
// Navigate to the file being written
|
|
378
|
+
setActiveFilePath(path)
|
|
379
|
+
},
|
|
380
|
+
onWriteFileDelta: (toolCallId, path, fullContent) => {
|
|
381
|
+
// Update streaming content with full content (already includes all previous)
|
|
382
|
+
setStreamingContent({ path, content: fullContent, isStreaming: true })
|
|
383
|
+
},
|
|
384
|
+
onWriteFileEnd: (toolCallId) => {
|
|
385
|
+
lastStreamedContent.current.delete(toolCallId)
|
|
386
|
+
// Don't clear streaming content immediately - let the final save do it
|
|
387
|
+
},
|
|
388
|
+
}
|
|
389
|
+
)
|
|
390
|
+
}, [endpointIndex, docIndex, changelogIndex, currentEndpoint?.id, apiSummary, setStreamingContent, setActiveFilePath])
|
|
391
|
+
|
|
392
|
+
// Chat hook with custom streaming transport
|
|
393
|
+
const {
|
|
394
|
+
messages,
|
|
395
|
+
status,
|
|
396
|
+
sendMessage,
|
|
397
|
+
addToolOutput,
|
|
398
|
+
setMessages,
|
|
399
|
+
stop,
|
|
400
|
+
} = useChat({
|
|
401
|
+
id: 'agent-chat',
|
|
402
|
+
transport,
|
|
403
|
+
|
|
404
|
+
// Automatically submit when all tool calls are complete
|
|
405
|
+
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
|
|
406
|
+
|
|
407
|
+
// Handle client-side tool execution
|
|
408
|
+
async onToolCall({ toolCall }) {
|
|
409
|
+
|
|
410
|
+
if ('dynamic' in toolCall && toolCall.dynamic) {
|
|
411
|
+
return
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const { toolName, toolCallId, input: toolInput } = toolCall as {
|
|
415
|
+
toolName: string
|
|
416
|
+
toolCallId: string
|
|
417
|
+
input: Record<string, unknown>
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
try {
|
|
422
|
+
// === UNIFIED SEARCH TOOL ===
|
|
423
|
+
// Searches across docs, endpoints, changelog, and future content types
|
|
424
|
+
if (toolName === 'search') {
|
|
425
|
+
const { query, type = 'all' } = toolInput as { query: string; type?: 'all' | 'docs' | 'endpoints' | 'changelog' }
|
|
426
|
+
|
|
427
|
+
const results: Array<{
|
|
428
|
+
type: 'doc' | 'endpoint' | 'changelog'
|
|
429
|
+
id: string
|
|
430
|
+
title: string
|
|
431
|
+
description?: string
|
|
432
|
+
method?: string
|
|
433
|
+
path?: string
|
|
434
|
+
group?: string
|
|
435
|
+
date?: string
|
|
436
|
+
score: number
|
|
437
|
+
}> = []
|
|
438
|
+
|
|
439
|
+
// Tokenize query
|
|
440
|
+
const queryTokens = query.toLowerCase().split(/\s+/).filter(t => t.length > 1)
|
|
441
|
+
const queryLower = query.toLowerCase()
|
|
442
|
+
|
|
443
|
+
// Search changelog if type is 'all' or 'changelog'
|
|
444
|
+
const changelogReleases = collection.changelogReleases || []
|
|
445
|
+
if ((type === 'all' || type === 'changelog') && changelogReleases.length > 0) {
|
|
446
|
+
// Check for keywords that indicate changelog intent
|
|
447
|
+
const changelogKeywords = ['release', 'changelog', 'version', 'update', 'latest', 'new', 'what\'s new', 'history']
|
|
448
|
+
const isChangelogQuery = changelogKeywords.some(kw => queryLower.includes(kw))
|
|
449
|
+
|
|
450
|
+
for (const release of changelogReleases) {
|
|
451
|
+
let score = 0
|
|
452
|
+
const versionLower = release.version.toLowerCase()
|
|
453
|
+
const titleLower = release.title.toLowerCase()
|
|
454
|
+
const dateLower = release.date.toLowerCase()
|
|
455
|
+
|
|
456
|
+
// Boost score for changelog-related queries
|
|
457
|
+
if (isChangelogQuery) score += 50
|
|
458
|
+
|
|
459
|
+
if (versionLower.includes(queryLower)) score += 100
|
|
460
|
+
if (titleLower.includes(queryLower)) score += 80
|
|
461
|
+
if (queryLower.includes('latest') && release === changelogReleases[0]) score += 150
|
|
462
|
+
|
|
463
|
+
for (const token of queryTokens) {
|
|
464
|
+
if (versionLower.includes(token)) score += 30
|
|
465
|
+
if (titleLower.includes(token)) score += 25
|
|
466
|
+
if (dateLower.includes(token)) score += 20
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if (score > 0) {
|
|
470
|
+
// Extract just the version from the slug (e.g., "changelog/v1.2.0" -> "v1.2.0")
|
|
471
|
+
const versionId = release.slug.replace(/^changelog\//, '')
|
|
472
|
+
results.push({
|
|
473
|
+
type: 'changelog',
|
|
474
|
+
id: versionId,
|
|
475
|
+
title: `${release.version} - ${release.title}`,
|
|
476
|
+
description: `Released ${release.date}`,
|
|
477
|
+
date: release.date,
|
|
478
|
+
group: 'Changelog',
|
|
479
|
+
score,
|
|
480
|
+
})
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Search docs if type is 'all' or 'docs'
|
|
486
|
+
if ((type === 'all' || type === 'docs') && docIndex && docIndex.length > 0) {
|
|
487
|
+
for (const doc of docIndex) {
|
|
488
|
+
let score = 0
|
|
489
|
+
const titleLower = doc.title.toLowerCase()
|
|
490
|
+
const descLower = doc.description.toLowerCase()
|
|
491
|
+
const slugLower = doc.slug.toLowerCase()
|
|
492
|
+
|
|
493
|
+
if (titleLower.includes(queryLower)) score += 100
|
|
494
|
+
if (slugLower.includes(queryLower)) score += 80
|
|
495
|
+
if (descLower.includes(queryLower)) score += 60
|
|
496
|
+
|
|
497
|
+
for (const token of queryTokens) {
|
|
498
|
+
if (titleLower.includes(token)) score += 20
|
|
499
|
+
if (slugLower.includes(token)) score += 25
|
|
500
|
+
if (descLower.includes(token)) score += 15
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
if (score > 0) {
|
|
504
|
+
results.push({
|
|
505
|
+
type: 'doc',
|
|
506
|
+
id: doc.slug,
|
|
507
|
+
title: doc.title,
|
|
508
|
+
description: doc.description,
|
|
509
|
+
group: doc.group,
|
|
510
|
+
score,
|
|
511
|
+
})
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Search endpoints if type is 'all' or 'endpoints'
|
|
517
|
+
if ((type === 'all' || type === 'endpoints') && endpointIndex && endpointIndex.length > 0) {
|
|
518
|
+
for (const ep of endpointIndex) {
|
|
519
|
+
let score = 0
|
|
520
|
+
const nameLower = ep.name.toLowerCase()
|
|
521
|
+
const pathLower = ep.path.toLowerCase()
|
|
522
|
+
const descLower = (ep.description || '').toLowerCase()
|
|
523
|
+
|
|
524
|
+
if (nameLower.includes(queryLower)) score += 100
|
|
525
|
+
if (pathLower.includes(queryLower)) score += 80
|
|
526
|
+
if (descLower.includes(queryLower)) score += 60
|
|
527
|
+
|
|
528
|
+
for (const token of queryTokens) {
|
|
529
|
+
if (nameLower.includes(token)) score += 20
|
|
530
|
+
if (pathLower.includes(token)) score += 25
|
|
531
|
+
if (descLower.includes(token)) score += 15
|
|
532
|
+
if (ep.method.toLowerCase() === token) score += 30
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
if (score > 0) {
|
|
536
|
+
results.push({
|
|
537
|
+
type: 'endpoint',
|
|
538
|
+
id: ep.id,
|
|
539
|
+
title: ep.name,
|
|
540
|
+
description: ep.description || undefined,
|
|
541
|
+
method: ep.method,
|
|
542
|
+
path: ep.path,
|
|
543
|
+
score,
|
|
544
|
+
})
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// Sort by score and limit
|
|
550
|
+
const sortedResults = results
|
|
551
|
+
.sort((a, b) => b.score - a.score)
|
|
552
|
+
.slice(0, 10)
|
|
553
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
554
|
+
.map(({ score, ...rest }) => rest)
|
|
555
|
+
|
|
556
|
+
addToolOutput({
|
|
557
|
+
tool: 'search',
|
|
558
|
+
toolCallId,
|
|
559
|
+
output: {
|
|
560
|
+
results: sortedResults,
|
|
561
|
+
message: sortedResults.length > 0
|
|
562
|
+
? `Found ${sortedResults.length} result(s)`
|
|
563
|
+
: 'No results found. Try different keywords.',
|
|
564
|
+
},
|
|
565
|
+
})
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// === UNIFIED NAVIGATE TOOL ===
|
|
569
|
+
// Navigates to docs, endpoints, changelog, sections, and future content types
|
|
570
|
+
if (toolName === 'navigate') {
|
|
571
|
+
const { type, id, title } = toolInput as { type: 'doc' | 'endpoint' | 'changelog' | 'section'; id: string; title?: string }
|
|
572
|
+
|
|
573
|
+
if (type === 'doc') {
|
|
574
|
+
switchToDocs()
|
|
575
|
+
if (onNavigateToDocPage) {
|
|
576
|
+
onNavigateToDocPage(id)
|
|
577
|
+
}
|
|
578
|
+
addToolOutput({
|
|
579
|
+
tool: 'navigate',
|
|
580
|
+
toolCallId,
|
|
581
|
+
output: { success: true, type: 'doc', id, title, message: `Opened ${title || id}` },
|
|
582
|
+
})
|
|
583
|
+
} else if (type === 'changelog') {
|
|
584
|
+
// Navigate to changelog tab and scroll to specific release
|
|
585
|
+
// handleSelectDocPage handles the scrolling automatically
|
|
586
|
+
switchToDocs()
|
|
587
|
+
if (onNavigateToDocPage) {
|
|
588
|
+
onNavigateToDocPage(`changelog/${id}`)
|
|
589
|
+
}
|
|
590
|
+
addToolOutput({
|
|
591
|
+
tool: 'navigate',
|
|
592
|
+
toolCallId,
|
|
593
|
+
output: { success: true, type: 'changelog', id, title, message: `Opened changelog ${title || id}` },
|
|
594
|
+
})
|
|
595
|
+
} else if (type === 'endpoint') {
|
|
596
|
+
switchToDocs()
|
|
597
|
+
onNavigate(id)
|
|
598
|
+
const endpoint = endpointIndex.find(e => e.id === id)
|
|
599
|
+
addToolOutput({
|
|
600
|
+
tool: 'navigate',
|
|
601
|
+
toolCallId,
|
|
602
|
+
output: {
|
|
603
|
+
success: true,
|
|
604
|
+
type: 'endpoint',
|
|
605
|
+
id,
|
|
606
|
+
title: endpoint?.name || title,
|
|
607
|
+
method: endpoint?.method,
|
|
608
|
+
message: `Opened ${endpoint?.name || title || id}`
|
|
609
|
+
},
|
|
610
|
+
})
|
|
611
|
+
} else if (type === 'section') {
|
|
612
|
+
// Scroll to section on current page with retry for newly rendered content
|
|
613
|
+
const scrollToSection = (): boolean => {
|
|
614
|
+
const headings = document.querySelectorAll('.docs-content h1[id], .docs-content h2[id], .docs-content h3[id], .docs-content h4[id], .docs-prose h1[id], .docs-prose h2[id], .docs-prose h3[id], .docs-prose h4[id], .docs-page h1[id], .docs-page h2[id], .docs-page h3[id], .docs-page h4[id]')
|
|
615
|
+
for (const heading of headings) {
|
|
616
|
+
const text = heading.textContent?.toLowerCase() || ''
|
|
617
|
+
const headingId = heading.getAttribute('id') || ''
|
|
618
|
+
if (text.includes(id.toLowerCase()) || headingId.includes(id.toLowerCase())) {
|
|
619
|
+
(heading as HTMLElement).scrollIntoView({ behavior: 'smooth', block: 'start' })
|
|
620
|
+
return true
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
return false
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// Try immediately, then retry with delays if not found
|
|
627
|
+
let found = scrollToSection()
|
|
628
|
+
if (!found) {
|
|
629
|
+
// Retry after a short delay (page might still be rendering)
|
|
630
|
+
setTimeout(() => {
|
|
631
|
+
found = scrollToSection()
|
|
632
|
+
if (!found) {
|
|
633
|
+
// Final retry after longer delay
|
|
634
|
+
setTimeout(() => {
|
|
635
|
+
scrollToSection()
|
|
636
|
+
}, 300)
|
|
637
|
+
}
|
|
638
|
+
}, 100)
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
addToolOutput({
|
|
642
|
+
tool: 'navigate',
|
|
643
|
+
toolCallId,
|
|
644
|
+
output: {
|
|
645
|
+
success: true, // Optimistically report success as we're retrying
|
|
646
|
+
type: 'section',
|
|
647
|
+
id,
|
|
648
|
+
message: `Scrolled to ${id}`
|
|
649
|
+
},
|
|
650
|
+
})
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// === LEGACY TOOL HANDLERS (for backwards compatibility) ===
|
|
655
|
+
// These can be removed once the unified tools are fully adopted
|
|
656
|
+
|
|
657
|
+
if (toolName === 'search_docs') {
|
|
658
|
+
// Redirect to unified search
|
|
659
|
+
const { query } = toolInput as { query: string }
|
|
660
|
+
const results = docIndex
|
|
661
|
+
.filter(doc =>
|
|
662
|
+
doc.title.toLowerCase().includes(query.toLowerCase()) ||
|
|
663
|
+
doc.slug.toLowerCase().includes(query.toLowerCase())
|
|
664
|
+
)
|
|
665
|
+
.slice(0, 10)
|
|
666
|
+
addToolOutput({
|
|
667
|
+
tool: 'search_docs',
|
|
668
|
+
toolCallId,
|
|
669
|
+
output: { results, message: `Found ${results.length} result(s)` },
|
|
670
|
+
})
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
if (toolName === 'search_endpoints') {
|
|
674
|
+
const { query, method } = toolInput as { query: string; method?: string }
|
|
675
|
+
const results = searchEndpoints(endpointIndex, query, method)
|
|
676
|
+
addToolOutput({
|
|
677
|
+
tool: 'search_endpoints',
|
|
678
|
+
toolCallId,
|
|
679
|
+
output: results,
|
|
680
|
+
})
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
if (toolName === 'navigate_to_doc_page') {
|
|
684
|
+
const { slug, title } = toolInput as { slug: string; title: string }
|
|
685
|
+
switchToDocs()
|
|
686
|
+
if (onNavigateToDocPage) {
|
|
687
|
+
onNavigateToDocPage(slug)
|
|
688
|
+
}
|
|
689
|
+
addToolOutput({
|
|
690
|
+
tool: 'navigate_to_doc_page',
|
|
691
|
+
toolCallId,
|
|
692
|
+
output: { success: true, slug, title, message: `Navigated to "${title}"` },
|
|
693
|
+
})
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
if (toolName === 'navigate_to_endpoint') {
|
|
697
|
+
const { endpointId } = toolInput as { endpointId: string; reason?: string }
|
|
698
|
+
switchToDocs()
|
|
699
|
+
onNavigate(endpointId)
|
|
700
|
+
const endpoint = endpointIndex.find(e => e.id === endpointId)
|
|
701
|
+
addToolOutput({
|
|
702
|
+
tool: 'navigate_to_endpoint',
|
|
703
|
+
toolCallId,
|
|
704
|
+
output: { success: true, endpoint },
|
|
705
|
+
})
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
if (toolName === 'navigate_to_doc_section') {
|
|
709
|
+
const { sectionId, sectionName } = toolInput as { sectionId: string; sectionName: string }
|
|
710
|
+
switchToDocs()
|
|
711
|
+
if (onNavigateToDocSection) {
|
|
712
|
+
onNavigateToDocSection(sectionId)
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
addToolOutput({
|
|
716
|
+
tool: 'navigate_to_doc_section',
|
|
717
|
+
toolCallId,
|
|
718
|
+
output: { success: true, sectionId, sectionName },
|
|
719
|
+
})
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// Handle scroll_to_section tool - scroll within current page
|
|
723
|
+
if (toolName === 'scroll_to_section') {
|
|
724
|
+
const { query } = toolInput as { query: string }
|
|
725
|
+
|
|
726
|
+
// Find matching heading in the current page
|
|
727
|
+
const findAndScrollToSection = (): { found: boolean; heading?: string; id?: string } => {
|
|
728
|
+
const queryLower = query.toLowerCase()
|
|
729
|
+
const headings = document.querySelectorAll('.docs-content h1[id], .docs-content h2[id], .docs-content h3[id], .docs-content h4[id], .docs-content h5[id], .docs-content h6[id], .docs-prose h1[id], .docs-prose h2[id], .docs-prose h3[id], .docs-prose h4[id], .docs-prose h5[id], .docs-prose h6[id], .docs-page h1[id], .docs-page h2[id], .docs-page h3[id], .docs-page h4[id], .docs-page h5[id], .docs-page h6[id]')
|
|
730
|
+
|
|
731
|
+
let bestMatch: { element: HTMLElement; score: number; text: string; id: string } | null = null
|
|
732
|
+
|
|
733
|
+
for (const heading of headings) {
|
|
734
|
+
const text = heading.textContent?.toLowerCase() || ''
|
|
735
|
+
const id = heading.getAttribute('id') || ''
|
|
736
|
+
let score = 0
|
|
737
|
+
|
|
738
|
+
// Exact match
|
|
739
|
+
if (text === queryLower || id === queryLower) {
|
|
740
|
+
score = 100
|
|
741
|
+
}
|
|
742
|
+
// Starts with query
|
|
743
|
+
else if (text.startsWith(queryLower) || id.startsWith(queryLower)) {
|
|
744
|
+
score = 80
|
|
745
|
+
}
|
|
746
|
+
// Contains query
|
|
747
|
+
else if (text.includes(queryLower) || id.includes(queryLower)) {
|
|
748
|
+
score = 60
|
|
749
|
+
}
|
|
750
|
+
// Word match
|
|
751
|
+
else {
|
|
752
|
+
const queryWords = queryLower.split(/\s+/)
|
|
753
|
+
const textWords = text.split(/\s+/)
|
|
754
|
+
const matchingWords = queryWords.filter(qw =>
|
|
755
|
+
textWords.some(tw => tw.includes(qw) || qw.includes(tw))
|
|
756
|
+
)
|
|
757
|
+
if (matchingWords.length > 0) {
|
|
758
|
+
score = 30 + (matchingWords.length / queryWords.length) * 30
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
if (score > 0 && (!bestMatch || score > bestMatch.score)) {
|
|
763
|
+
bestMatch = {
|
|
764
|
+
element: heading as HTMLElement,
|
|
765
|
+
score,
|
|
766
|
+
text: heading.textContent || '',
|
|
767
|
+
id
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
if (bestMatch) {
|
|
773
|
+
// Scroll to the section
|
|
774
|
+
bestMatch.element.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
|
775
|
+
return { found: true, heading: bestMatch.text, id: bestMatch.id }
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
return { found: false }
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// Try immediately
|
|
782
|
+
let result = findAndScrollToSection()
|
|
783
|
+
|
|
784
|
+
// If not found, retry with delays (page might still be rendering)
|
|
785
|
+
if (!result.found) {
|
|
786
|
+
setTimeout(() => {
|
|
787
|
+
result = findAndScrollToSection()
|
|
788
|
+
if (!result.found) {
|
|
789
|
+
setTimeout(() => {
|
|
790
|
+
findAndScrollToSection()
|
|
791
|
+
}, 300)
|
|
792
|
+
}
|
|
793
|
+
}, 100)
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
addToolOutput({
|
|
797
|
+
tool: 'scroll_to_section',
|
|
798
|
+
toolCallId,
|
|
799
|
+
output: result.found
|
|
800
|
+
? { success: true, heading: result.heading, id: result.id, message: `Scrolled to "${result.heading}"` }
|
|
801
|
+
: { success: true, heading: query, id: query.toLowerCase().replace(/\s+/g, '-'), message: `Scrolling to "${query}"` }, // Optimistically report success
|
|
802
|
+
})
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
if (toolName === 'prefill_parameters') {
|
|
806
|
+
const prefillData = toolInput as PrefillData
|
|
807
|
+
|
|
808
|
+
onPrefill(prefillData)
|
|
809
|
+
|
|
810
|
+
addToolOutput({
|
|
811
|
+
tool: 'prefill_parameters',
|
|
812
|
+
toolCallId,
|
|
813
|
+
output: { success: true, ...prefillData },
|
|
814
|
+
})
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
if (toolName === 'get_endpoint_details') {
|
|
818
|
+
const { endpointId } = toolInput as { endpointId: string }
|
|
819
|
+
const fullRequest = findRequestById(collection, endpointId)
|
|
820
|
+
|
|
821
|
+
if (fullRequest) {
|
|
822
|
+
const formattedDetails = formatRequestForAI(fullRequest)
|
|
823
|
+
addToolOutput({
|
|
824
|
+
tool: 'get_endpoint_details',
|
|
825
|
+
toolCallId,
|
|
826
|
+
output: formattedDetails,
|
|
827
|
+
})
|
|
828
|
+
} else {
|
|
829
|
+
addToolOutput({
|
|
830
|
+
tool: 'get_endpoint_details',
|
|
831
|
+
toolCallId,
|
|
832
|
+
output: { error: 'Endpoint not found' },
|
|
833
|
+
})
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// Handle get_current_request tool - returns current playground request payload
|
|
838
|
+
if (toolName === 'get_current_request') {
|
|
839
|
+
|
|
840
|
+
if (currentRequestPayload) {
|
|
841
|
+
addToolOutput({
|
|
842
|
+
tool: 'get_current_request',
|
|
843
|
+
toolCallId,
|
|
844
|
+
output: {
|
|
845
|
+
success: true,
|
|
846
|
+
request: {
|
|
847
|
+
method: currentRequestPayload.method,
|
|
848
|
+
url: currentRequestPayload.endpoint,
|
|
849
|
+
queryParams: currentRequestPayload.params.filter(p => p.active),
|
|
850
|
+
headers: currentRequestPayload.headers.filter(h => h.active),
|
|
851
|
+
body: currentRequestPayload.body,
|
|
852
|
+
contentType: currentRequestPayload.bodyContentType,
|
|
853
|
+
},
|
|
854
|
+
},
|
|
855
|
+
})
|
|
856
|
+
} else {
|
|
857
|
+
addToolOutput({
|
|
858
|
+
tool: 'get_current_request',
|
|
859
|
+
toolCallId,
|
|
860
|
+
output: {
|
|
861
|
+
success: false,
|
|
862
|
+
error: 'No request payload available. Please select an endpoint first.'
|
|
863
|
+
},
|
|
864
|
+
})
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// Handle switch_to_notes tool
|
|
869
|
+
if (toolName === 'switch_to_notes') {
|
|
870
|
+
|
|
871
|
+
switchToNotes()
|
|
872
|
+
|
|
873
|
+
addToolOutput({
|
|
874
|
+
tool: 'switch_to_notes',
|
|
875
|
+
toolCallId,
|
|
876
|
+
output: { success: true, message: 'Switched to Notes workspace' },
|
|
877
|
+
})
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// Handle switch_to_docs tool
|
|
881
|
+
if (toolName === 'switch_to_docs') {
|
|
882
|
+
|
|
883
|
+
switchToDocs()
|
|
884
|
+
|
|
885
|
+
addToolOutput({
|
|
886
|
+
tool: 'switch_to_docs',
|
|
887
|
+
toolCallId,
|
|
888
|
+
output: { success: true, message: 'Switched to Docs' },
|
|
889
|
+
})
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// Handle switch_to_api_client tool
|
|
893
|
+
if (toolName === 'switch_to_api_client') {
|
|
894
|
+
|
|
895
|
+
switchToApiClient()
|
|
896
|
+
|
|
897
|
+
addToolOutput({
|
|
898
|
+
tool: 'switch_to_api_client',
|
|
899
|
+
toolCallId,
|
|
900
|
+
output: { success: true, message: 'Switched to API Client' },
|
|
901
|
+
})
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// Handle list_notes tool (lists existing files to check for duplicates)
|
|
905
|
+
if (toolName === 'list_notes') {
|
|
906
|
+
const { filter } = toolInput as { filter?: string }
|
|
907
|
+
|
|
908
|
+
// Switch to notes workspace
|
|
909
|
+
switchToNotes()
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
// Filter notes if a filter is provided
|
|
913
|
+
let filteredNotes = notes.filter(n => !n.path.endsWith('.folder')) // Exclude folder placeholders
|
|
914
|
+
|
|
915
|
+
if (filter) {
|
|
916
|
+
const lowerFilter = filter.toLowerCase()
|
|
917
|
+
filteredNotes = filteredNotes.filter(n =>
|
|
918
|
+
n.path.toLowerCase().includes(lowerFilter)
|
|
919
|
+
)
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// Return list of file paths with basic info
|
|
923
|
+
const notesList = filteredNotes.map(n => ({
|
|
924
|
+
path: n.path,
|
|
925
|
+
updatedAt: n.updatedAt,
|
|
926
|
+
}))
|
|
927
|
+
|
|
928
|
+
addToolOutput({
|
|
929
|
+
tool: 'list_notes',
|
|
930
|
+
toolCallId,
|
|
931
|
+
output: {
|
|
932
|
+
success: true,
|
|
933
|
+
count: notesList.length,
|
|
934
|
+
files: notesList,
|
|
935
|
+
message: notesList.length > 0
|
|
936
|
+
? `Found ${notesList.length} file(s)${filter ? ` matching "${filter}"` : ''}`
|
|
937
|
+
: `No files found${filter ? ` matching "${filter}"` : ' in workspace'}`
|
|
938
|
+
},
|
|
939
|
+
})
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
// Handle open_file tool (opens existing file)
|
|
943
|
+
if (toolName === 'open_file') {
|
|
944
|
+
const { path } = toolInput as { path: string }
|
|
945
|
+
|
|
946
|
+
|
|
947
|
+
// Switch to notes mode with the file path
|
|
948
|
+
switchToNotes(path)
|
|
949
|
+
|
|
950
|
+
addToolOutput({
|
|
951
|
+
tool: 'open_file',
|
|
952
|
+
toolCallId,
|
|
953
|
+
output: { success: true, path, message: `Opened ${path}` },
|
|
954
|
+
})
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
// Handle create_folder tool (creates a folder for organizing files)
|
|
958
|
+
if (toolName === 'create_folder') {
|
|
959
|
+
const { path, description } = toolInput as { path: string; description: string }
|
|
960
|
+
|
|
961
|
+
// Switch to notes workspace
|
|
962
|
+
switchToNotes()
|
|
963
|
+
|
|
964
|
+
|
|
965
|
+
// Folders are virtual in our system - files with paths like "folder/file.ext"
|
|
966
|
+
// automatically create the folder structure when files are added.
|
|
967
|
+
// We just acknowledge the intent - the folder will appear when files are created inside it.
|
|
968
|
+
|
|
969
|
+
addToolOutput({
|
|
970
|
+
tool: 'create_folder',
|
|
971
|
+
toolCallId,
|
|
972
|
+
output: { success: true, path, description, message: `Folder "${path}" will be created when files are added` },
|
|
973
|
+
})
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// Handle create_file tool (creates empty file)
|
|
977
|
+
if (toolName === 'create_file') {
|
|
978
|
+
const { path, description } = toolInput as { path: string; description: string }
|
|
979
|
+
|
|
980
|
+
// Switch to notes workspace
|
|
981
|
+
switchToNotes()
|
|
982
|
+
|
|
983
|
+
|
|
984
|
+
if (workspace) {
|
|
985
|
+
try {
|
|
986
|
+
// Create truly empty file - content will be written by write_file
|
|
987
|
+
await createNote(path, '')
|
|
988
|
+
await refreshNotes()
|
|
989
|
+
|
|
990
|
+
// Trigger refresh in notes-mode UI
|
|
991
|
+
triggerNotesRefresh()
|
|
992
|
+
|
|
993
|
+
setActiveFilePath(path)
|
|
994
|
+
|
|
995
|
+
|
|
996
|
+
addToolOutput({
|
|
997
|
+
tool: 'create_file',
|
|
998
|
+
toolCallId,
|
|
999
|
+
output: { success: true, path, description },
|
|
1000
|
+
})
|
|
1001
|
+
} catch (err) {
|
|
1002
|
+
console.error('[AgentChat] Failed to create file:', err)
|
|
1003
|
+
addToolOutput({
|
|
1004
|
+
tool: 'create_file',
|
|
1005
|
+
toolCallId,
|
|
1006
|
+
output: { success: false, error: err instanceof Error ? err.message : 'Failed to create file' },
|
|
1007
|
+
})
|
|
1008
|
+
}
|
|
1009
|
+
} else {
|
|
1010
|
+
addToolOutput({
|
|
1011
|
+
tool: 'create_file',
|
|
1012
|
+
toolCallId,
|
|
1013
|
+
output: { success: false, error: 'Workspace not available' },
|
|
1014
|
+
})
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
// Handle write_file tool (writes content to file)
|
|
1019
|
+
// Note: Streaming already shows content in real-time via onWriteFileDelta
|
|
1020
|
+
// This handler saves the final content to IndexedDB for persistence
|
|
1021
|
+
if (toolName === 'write_file') {
|
|
1022
|
+
const { path, content } = toolInput as { path: string; content: string }
|
|
1023
|
+
|
|
1024
|
+
// Switch to notes workspace
|
|
1025
|
+
switchToNotes()
|
|
1026
|
+
|
|
1027
|
+
|
|
1028
|
+
if (workspace) {
|
|
1029
|
+
try {
|
|
1030
|
+
// Don't clear streaming content here - notes-mode will clear it after loading
|
|
1031
|
+
// This prevents flash of empty content for mermaid previews
|
|
1032
|
+
|
|
1033
|
+
// Save the final content to IndexedDB for persistence
|
|
1034
|
+
await updateNote(path, content)
|
|
1035
|
+
await refreshNotes()
|
|
1036
|
+
|
|
1037
|
+
// Trigger refresh in notes-mode UI
|
|
1038
|
+
triggerNotesRefresh()
|
|
1039
|
+
|
|
1040
|
+
// Force reload the file content from DB
|
|
1041
|
+
setActiveFilePath(`${path}?t=${Date.now()}`)
|
|
1042
|
+
|
|
1043
|
+
|
|
1044
|
+
addToolOutput({
|
|
1045
|
+
tool: 'write_file',
|
|
1046
|
+
toolCallId,
|
|
1047
|
+
output: { success: true, path },
|
|
1048
|
+
})
|
|
1049
|
+
} catch (err) {
|
|
1050
|
+
console.error('[AgentChat] Failed to save file:', err)
|
|
1051
|
+
setStreamingContent(null) // Clear on error too
|
|
1052
|
+
addToolOutput({
|
|
1053
|
+
tool: 'write_file',
|
|
1054
|
+
toolCallId,
|
|
1055
|
+
output: { success: false, error: err instanceof Error ? err.message : 'Failed to save file' },
|
|
1056
|
+
})
|
|
1057
|
+
}
|
|
1058
|
+
} else {
|
|
1059
|
+
setStreamingContent(null)
|
|
1060
|
+
addToolOutput({
|
|
1061
|
+
tool: 'write_file',
|
|
1062
|
+
toolCallId,
|
|
1063
|
+
output: { success: false, error: 'Workspace not available' },
|
|
1064
|
+
})
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
// Handle delete_file tool (deletes a file from workspace)
|
|
1069
|
+
if (toolName === 'delete_file') {
|
|
1070
|
+
const { path, reason } = toolInput as { path: string; reason?: string }
|
|
1071
|
+
|
|
1072
|
+
// Switch to notes workspace
|
|
1073
|
+
switchToNotes()
|
|
1074
|
+
|
|
1075
|
+
|
|
1076
|
+
if (workspace) {
|
|
1077
|
+
try {
|
|
1078
|
+
// Check if file exists
|
|
1079
|
+
const fileExists = notes.some(n => n.path === path)
|
|
1080
|
+
|
|
1081
|
+
if (!fileExists) {
|
|
1082
|
+
addToolOutput({
|
|
1083
|
+
tool: 'delete_file',
|
|
1084
|
+
toolCallId,
|
|
1085
|
+
output: { success: false, error: `File not found: ${path}` },
|
|
1086
|
+
})
|
|
1087
|
+
return
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
// Delete the file
|
|
1091
|
+
await deleteNote(path)
|
|
1092
|
+
await refreshNotes()
|
|
1093
|
+
|
|
1094
|
+
// Trigger refresh in notes-mode UI
|
|
1095
|
+
triggerNotesRefresh()
|
|
1096
|
+
|
|
1097
|
+
// Clear active file if it was the deleted one
|
|
1098
|
+
setActiveFilePath(null)
|
|
1099
|
+
|
|
1100
|
+
|
|
1101
|
+
addToolOutput({
|
|
1102
|
+
tool: 'delete_file',
|
|
1103
|
+
toolCallId,
|
|
1104
|
+
output: { success: true, path, reason, message: `Deleted ${path}` },
|
|
1105
|
+
})
|
|
1106
|
+
} catch (err) {
|
|
1107
|
+
console.error('[AgentChat] Failed to delete file:', err)
|
|
1108
|
+
addToolOutput({
|
|
1109
|
+
tool: 'delete_file',
|
|
1110
|
+
toolCallId,
|
|
1111
|
+
output: { success: false, error: err instanceof Error ? err.message : 'Failed to delete file' },
|
|
1112
|
+
})
|
|
1113
|
+
}
|
|
1114
|
+
} else {
|
|
1115
|
+
addToolOutput({
|
|
1116
|
+
tool: 'delete_file',
|
|
1117
|
+
toolCallId,
|
|
1118
|
+
output: { success: false, error: 'Workspace not available' },
|
|
1119
|
+
})
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
// Handle delete_all_notes tool (deletes all files from workspace)
|
|
1124
|
+
if (toolName === 'delete_all_notes') {
|
|
1125
|
+
const { confirmed } = toolInput as { confirmed: boolean }
|
|
1126
|
+
|
|
1127
|
+
// Switch to notes workspace
|
|
1128
|
+
switchToNotes()
|
|
1129
|
+
|
|
1130
|
+
|
|
1131
|
+
if (!confirmed) {
|
|
1132
|
+
addToolOutput({
|
|
1133
|
+
tool: 'delete_all_notes',
|
|
1134
|
+
toolCallId,
|
|
1135
|
+
output: { success: false, error: 'Deletion not confirmed. Please confirm with the user first.' },
|
|
1136
|
+
})
|
|
1137
|
+
return
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
if (workspace) {
|
|
1141
|
+
try {
|
|
1142
|
+
// Refresh notes first to get accurate count (notes state might be stale)
|
|
1143
|
+
await refreshNotes()
|
|
1144
|
+
|
|
1145
|
+
// Get fresh count from database directly
|
|
1146
|
+
const { dbOperations } = await import('@/lib/api-docs/code-editor/db')
|
|
1147
|
+
const currentNotes = await dbOperations.listNotes(workspace.id)
|
|
1148
|
+
const visibleNotes = currentNotes.filter(n => !n.path.endsWith('.folder'))
|
|
1149
|
+
const noteCount = visibleNotes.length
|
|
1150
|
+
|
|
1151
|
+
// Get list of files for display
|
|
1152
|
+
const fileList = visibleNotes.map(n => n.path)
|
|
1153
|
+
|
|
1154
|
+
|
|
1155
|
+
if (noteCount === 0) {
|
|
1156
|
+
addToolOutput({
|
|
1157
|
+
tool: 'delete_all_notes',
|
|
1158
|
+
toolCallId,
|
|
1159
|
+
output: { success: true, deletedCount: 0, files: [], message: 'No notes to delete' },
|
|
1160
|
+
})
|
|
1161
|
+
return
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
// Delete all notes
|
|
1165
|
+
await deleteAllNotes()
|
|
1166
|
+
await refreshNotes()
|
|
1167
|
+
|
|
1168
|
+
// Trigger refresh in notes-mode UI
|
|
1169
|
+
triggerNotesRefresh()
|
|
1170
|
+
|
|
1171
|
+
// Clear active file
|
|
1172
|
+
setActiveFilePath(null)
|
|
1173
|
+
|
|
1174
|
+
|
|
1175
|
+
addToolOutput({
|
|
1176
|
+
tool: 'delete_all_notes',
|
|
1177
|
+
toolCallId,
|
|
1178
|
+
output: {
|
|
1179
|
+
success: true,
|
|
1180
|
+
deletedCount: noteCount,
|
|
1181
|
+
files: fileList,
|
|
1182
|
+
message: `Deleted ${noteCount} note${noteCount !== 1 ? 's' : ''}`
|
|
1183
|
+
},
|
|
1184
|
+
})
|
|
1185
|
+
} catch (err) {
|
|
1186
|
+
console.error('[AgentChat] Failed to delete all notes:', err)
|
|
1187
|
+
addToolOutput({
|
|
1188
|
+
tool: 'delete_all_notes',
|
|
1189
|
+
toolCallId,
|
|
1190
|
+
output: { success: false, error: err instanceof Error ? err.message : 'Failed to delete notes' },
|
|
1191
|
+
})
|
|
1192
|
+
}
|
|
1193
|
+
} else {
|
|
1194
|
+
addToolOutput({
|
|
1195
|
+
tool: 'delete_all_notes',
|
|
1196
|
+
toolCallId,
|
|
1197
|
+
output: { success: false, error: 'Workspace not available' },
|
|
1198
|
+
})
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
// Handle check_request_validity tool - validates if request is ready to send
|
|
1203
|
+
if (toolName === 'check_request_validity') {
|
|
1204
|
+
const { endpointId } = toolInput as { endpointId: string }
|
|
1205
|
+
|
|
1206
|
+
const endpoint = findRequestById(collection, endpointId)
|
|
1207
|
+
|
|
1208
|
+
if (!endpoint) {
|
|
1209
|
+
addToolOutput({
|
|
1210
|
+
tool: 'check_request_validity',
|
|
1211
|
+
toolCallId,
|
|
1212
|
+
output: { success: false, error: 'Endpoint not found' },
|
|
1213
|
+
})
|
|
1214
|
+
return
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
// Get current request payload from context
|
|
1218
|
+
const payload = currentRequestPayload
|
|
1219
|
+
|
|
1220
|
+
// Analyze path parameters from the endpoint URL
|
|
1221
|
+
const pathParamMatches = endpoint.endpoint.match(/\{([^}]+)\}/g) || []
|
|
1222
|
+
const requiredPathParams = pathParamMatches.map(p => p.slice(1, -1))
|
|
1223
|
+
|
|
1224
|
+
// Check which path params have values
|
|
1225
|
+
const filledPathParams = requiredPathParams.filter(param => {
|
|
1226
|
+
// Check if the URL has been filled with actual values (not still containing {param})
|
|
1227
|
+
return payload?.endpoint && !payload.endpoint.includes(`{${param}}`)
|
|
1228
|
+
})
|
|
1229
|
+
const missingPathParams = requiredPathParams.filter(p => !filledPathParams.includes(p))
|
|
1230
|
+
|
|
1231
|
+
// Analyze query parameters
|
|
1232
|
+
const requiredQueryParams = endpoint.params
|
|
1233
|
+
.filter(p => p.description?.toLowerCase().includes('required'))
|
|
1234
|
+
.map(p => p.key)
|
|
1235
|
+
const filledQueryParams = (payload?.params || [])
|
|
1236
|
+
.filter(p => p.active && p.value)
|
|
1237
|
+
.map(p => p.key)
|
|
1238
|
+
const missingQueryParams = requiredQueryParams.filter(p => !filledQueryParams.includes(p))
|
|
1239
|
+
|
|
1240
|
+
// Analyze body (for POST/PUT/PATCH)
|
|
1241
|
+
const needsBody = ['POST', 'PUT', 'PATCH'].includes(endpoint.method)
|
|
1242
|
+
const hasBody = !!(payload?.body && typeof payload.body === 'string' && payload.body.trim().length > 0)
|
|
1243
|
+
|
|
1244
|
+
// Try to extract required fields from the body schema
|
|
1245
|
+
const bodyRequiredFields: string[] = []
|
|
1246
|
+
if (endpoint.body.body && typeof endpoint.body.body === 'string') {
|
|
1247
|
+
try {
|
|
1248
|
+
const bodySchema = JSON.parse(endpoint.body.body)
|
|
1249
|
+
if (bodySchema.required && Array.isArray(bodySchema.required)) {
|
|
1250
|
+
bodyRequiredFields.push(...bodySchema.required)
|
|
1251
|
+
}
|
|
1252
|
+
} catch {
|
|
1253
|
+
// Not a schema, might be example body
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
// Check missing body fields
|
|
1258
|
+
let missingBodyFields: string[] = []
|
|
1259
|
+
if (hasBody && bodyRequiredFields.length > 0) {
|
|
1260
|
+
try {
|
|
1261
|
+
const currentBody = JSON.parse(payload?.body || '{}')
|
|
1262
|
+
missingBodyFields = bodyRequiredFields.filter(field =>
|
|
1263
|
+
currentBody[field] === undefined || currentBody[field] === ''
|
|
1264
|
+
)
|
|
1265
|
+
} catch {
|
|
1266
|
+
// Invalid JSON in body
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
// Check authentication
|
|
1271
|
+
const needsAuth = endpoint.auth.authType !== 'none'
|
|
1272
|
+
const hasAuth = payload?.headers?.some(h =>
|
|
1273
|
+
h.active && (
|
|
1274
|
+
h.key.toLowerCase() === 'authorization' ||
|
|
1275
|
+
h.key.toLowerCase() === 'x-api-key'
|
|
1276
|
+
)
|
|
1277
|
+
) || false
|
|
1278
|
+
|
|
1279
|
+
// Build validation result
|
|
1280
|
+
const isValid: boolean =
|
|
1281
|
+
missingPathParams.length === 0 &&
|
|
1282
|
+
missingQueryParams.length === 0 &&
|
|
1283
|
+
(!needsBody || hasBody) &&
|
|
1284
|
+
(!needsAuth || hasAuth)
|
|
1285
|
+
|
|
1286
|
+
// Build summary message
|
|
1287
|
+
const issues: string[] = []
|
|
1288
|
+
if (missingPathParams.length > 0) {
|
|
1289
|
+
issues.push(`Missing path params: ${missingPathParams.join(', ')}`)
|
|
1290
|
+
}
|
|
1291
|
+
if (missingQueryParams.length > 0) {
|
|
1292
|
+
issues.push(`Missing required params: ${missingQueryParams.join(', ')}`)
|
|
1293
|
+
}
|
|
1294
|
+
if (needsBody && !hasBody) {
|
|
1295
|
+
issues.push('Request body is empty')
|
|
1296
|
+
}
|
|
1297
|
+
if (missingBodyFields.length > 0) {
|
|
1298
|
+
issues.push(`Missing body fields: ${missingBodyFields.join(', ')}`)
|
|
1299
|
+
}
|
|
1300
|
+
if (needsAuth && !hasAuth) {
|
|
1301
|
+
issues.push('Authentication not configured')
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
const summary = isValid
|
|
1305
|
+
? 'Request is ready to send!'
|
|
1306
|
+
: `Issues found: ${issues.join('; ')}`
|
|
1307
|
+
|
|
1308
|
+
const result: RequestValidationResult = {
|
|
1309
|
+
isValid,
|
|
1310
|
+
endpoint: {
|
|
1311
|
+
id: endpoint.id || endpointId,
|
|
1312
|
+
name: endpoint.name,
|
|
1313
|
+
method: endpoint.method,
|
|
1314
|
+
path: endpoint.endpoint,
|
|
1315
|
+
},
|
|
1316
|
+
validation: {
|
|
1317
|
+
pathParams: {
|
|
1318
|
+
required: requiredPathParams,
|
|
1319
|
+
filled: filledPathParams,
|
|
1320
|
+
missing: missingPathParams,
|
|
1321
|
+
},
|
|
1322
|
+
queryParams: {
|
|
1323
|
+
required: requiredQueryParams,
|
|
1324
|
+
filled: filledQueryParams,
|
|
1325
|
+
missing: missingQueryParams,
|
|
1326
|
+
},
|
|
1327
|
+
body: {
|
|
1328
|
+
required: needsBody,
|
|
1329
|
+
hasContent: hasBody,
|
|
1330
|
+
requiredFields: bodyRequiredFields,
|
|
1331
|
+
missingFields: missingBodyFields,
|
|
1332
|
+
},
|
|
1333
|
+
auth: {
|
|
1334
|
+
required: needsAuth,
|
|
1335
|
+
configured: hasAuth,
|
|
1336
|
+
type: needsAuth ? endpoint.auth.authType : null,
|
|
1337
|
+
},
|
|
1338
|
+
},
|
|
1339
|
+
summary,
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
addToolOutput({
|
|
1343
|
+
tool: 'check_request_validity',
|
|
1344
|
+
toolCallId,
|
|
1345
|
+
output: result,
|
|
1346
|
+
})
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
// Handle send_request tool - triggers the playground's send button
|
|
1350
|
+
if (toolName === 'send_request') {
|
|
1351
|
+
const { confirmSend } = toolInput as { confirmSend: boolean }
|
|
1352
|
+
|
|
1353
|
+
if (!confirmSend) {
|
|
1354
|
+
addToolOutput({
|
|
1355
|
+
tool: 'send_request',
|
|
1356
|
+
toolCallId,
|
|
1357
|
+
output: { success: false, error: 'Request not confirmed' },
|
|
1358
|
+
})
|
|
1359
|
+
return
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
if (!currentEndpoint) {
|
|
1363
|
+
addToolOutput({
|
|
1364
|
+
tool: 'send_request',
|
|
1365
|
+
toolCallId,
|
|
1366
|
+
output: { success: false, error: 'No endpoint selected' },
|
|
1367
|
+
})
|
|
1368
|
+
return
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
// Trigger the playground's send button
|
|
1372
|
+
requestSend()
|
|
1373
|
+
|
|
1374
|
+
addToolOutput({
|
|
1375
|
+
tool: 'send_request',
|
|
1376
|
+
toolCallId,
|
|
1377
|
+
output: {
|
|
1378
|
+
success: true,
|
|
1379
|
+
message: 'Request sent! Check the response viewer in the playground.',
|
|
1380
|
+
},
|
|
1381
|
+
})
|
|
1382
|
+
}
|
|
1383
|
+
} catch (err) {
|
|
1384
|
+
addToolOutput({
|
|
1385
|
+
tool: toolName,
|
|
1386
|
+
toolCallId,
|
|
1387
|
+
state: 'output-error',
|
|
1388
|
+
errorText: err instanceof Error ? err.message : 'Tool execution failed',
|
|
1389
|
+
})
|
|
1390
|
+
}
|
|
1391
|
+
},
|
|
1392
|
+
})
|
|
1393
|
+
|
|
1394
|
+
// Derived loading state
|
|
1395
|
+
const isLoading = status === 'streaming' || status === 'submitted'
|
|
1396
|
+
|
|
1397
|
+
// Load saved messages on mount
|
|
1398
|
+
useEffect(() => {
|
|
1399
|
+
try {
|
|
1400
|
+
const saved = localStorage.getItem(CHAT_STORAGE_KEY)
|
|
1401
|
+
if (saved) {
|
|
1402
|
+
const parsed = JSON.parse(saved)
|
|
1403
|
+
if (Array.isArray(parsed) && parsed.length > 0) {
|
|
1404
|
+
setMessages(parsed)
|
|
1405
|
+
setShowSuggestions(false)
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
} catch {
|
|
1409
|
+
// Ignore parse errors
|
|
1410
|
+
}
|
|
1411
|
+
}, [setMessages])
|
|
1412
|
+
|
|
1413
|
+
// Save messages when they change
|
|
1414
|
+
useEffect(() => {
|
|
1415
|
+
if (messages.length > 0) {
|
|
1416
|
+
try {
|
|
1417
|
+
localStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify(messages))
|
|
1418
|
+
} catch {
|
|
1419
|
+
// Ignore storage errors
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
}, [messages])
|
|
1423
|
+
|
|
1424
|
+
// Notify parent about message count changes
|
|
1425
|
+
useEffect(() => {
|
|
1426
|
+
onHasMessagesChange?.(messages.length > 0)
|
|
1427
|
+
}, [messages.length, onHasMessagesChange])
|
|
1428
|
+
|
|
1429
|
+
// Auto-scroll to bottom
|
|
1430
|
+
useEffect(() => {
|
|
1431
|
+
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
|
1432
|
+
}, [messages])
|
|
1433
|
+
|
|
1434
|
+
// Cache key for suggestions
|
|
1435
|
+
const suggestionsCacheKey = currentEndpoint?.id
|
|
1436
|
+
? `endpoint:${currentEndpoint.id}`
|
|
1437
|
+
: `general:${endpointIndex.length}`
|
|
1438
|
+
|
|
1439
|
+
// Load dynamic suggestions from API
|
|
1440
|
+
useEffect(() => {
|
|
1441
|
+
// Check cache first
|
|
1442
|
+
const cached = getSuggestionsFromCache(suggestionsCacheKey)
|
|
1443
|
+
if (cached) {
|
|
1444
|
+
setSuggestions(cached)
|
|
1445
|
+
return
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
// Fetch from API
|
|
1449
|
+
let cancelled = false
|
|
1450
|
+
setLoadingSuggestions(true)
|
|
1451
|
+
|
|
1452
|
+
fetch('/api/suggestions', {
|
|
1453
|
+
method: 'POST',
|
|
1454
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1455
|
+
body: JSON.stringify({
|
|
1456
|
+
endpointIndex,
|
|
1457
|
+
currentEndpointId: currentEndpoint?.id,
|
|
1458
|
+
}),
|
|
1459
|
+
})
|
|
1460
|
+
.then(res => res.json())
|
|
1461
|
+
.then(data => {
|
|
1462
|
+
if (cancelled) return
|
|
1463
|
+
if (data.suggestions) {
|
|
1464
|
+
setSuggestions(data.suggestions)
|
|
1465
|
+
saveSuggestionsToCache(suggestionsCacheKey, data.suggestions)
|
|
1466
|
+
}
|
|
1467
|
+
})
|
|
1468
|
+
.catch(err => {
|
|
1469
|
+
if (cancelled) return
|
|
1470
|
+
console.warn('[AgentChat] Failed to fetch suggestions:', err)
|
|
1471
|
+
// Fallback suggestions
|
|
1472
|
+
const fallback: Suggestion[] = currentEndpoint
|
|
1473
|
+
? [
|
|
1474
|
+
{ title: 'What does this', label: 'endpoint do?', prompt: `What does ${currentEndpoint.name} do?` },
|
|
1475
|
+
{ title: 'What parameters', label: 'are required?', prompt: 'What parameters are required?' },
|
|
1476
|
+
{ title: 'Python example', label: 'Show code', prompt: 'Show me a Python example' },
|
|
1477
|
+
]
|
|
1478
|
+
: [
|
|
1479
|
+
{ title: 'Find an endpoint', label: 'to create resources', prompt: 'Find endpoints for creating resources' },
|
|
1480
|
+
{ title: 'How do I', label: 'authenticate?', prompt: 'How do I authenticate?' },
|
|
1481
|
+
{ title: 'Overview', label: 'What can I do?', prompt: 'What can I do with this API?' },
|
|
1482
|
+
]
|
|
1483
|
+
setSuggestions(fallback)
|
|
1484
|
+
})
|
|
1485
|
+
.finally(() => {
|
|
1486
|
+
if (!cancelled) setLoadingSuggestions(false)
|
|
1487
|
+
})
|
|
1488
|
+
|
|
1489
|
+
return () => { cancelled = true }
|
|
1490
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1491
|
+
}, [suggestionsCacheKey])
|
|
1492
|
+
|
|
1493
|
+
// Track pending debug error type for showing options after AI response
|
|
1494
|
+
const [pendingErrorType, setPendingErrorType] = useState<ReturnType<typeof detectErrorType> | null>(null)
|
|
1495
|
+
const prevStatus = useRef(status)
|
|
1496
|
+
|
|
1497
|
+
// Handle debug context from response viewer - show context card and send prompt
|
|
1498
|
+
useEffect(() => {
|
|
1499
|
+
if (!debugContext || status === 'streaming' || status === 'submitted') {
|
|
1500
|
+
if (!debugContext) processedDebugRef.current = null
|
|
1501
|
+
return
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
const contextKey = JSON.stringify(debugContext)
|
|
1505
|
+
if (processedDebugRef.current === contextKey) return
|
|
1506
|
+
processedDebugRef.current = contextKey
|
|
1507
|
+
|
|
1508
|
+
// Detect and store error type for later (to show options after AI responds)
|
|
1509
|
+
const errorType = detectErrorType(debugContext.status, debugContext.responseBody)
|
|
1510
|
+
setPendingErrorType(errorType)
|
|
1511
|
+
|
|
1512
|
+
// Create a concise debug prompt
|
|
1513
|
+
const prompt = `Debug this ${debugContext.status} error and suggest a fix.`
|
|
1514
|
+
|
|
1515
|
+
// Encode the debug context as a card message
|
|
1516
|
+
const contextCardMessage = encodeCardMessage({
|
|
1517
|
+
type: 'debug_context',
|
|
1518
|
+
context: debugContext,
|
|
1519
|
+
})
|
|
1520
|
+
|
|
1521
|
+
// Add the debug context card as a user message
|
|
1522
|
+
const debugContextMessage = {
|
|
1523
|
+
id: `debug-${Date.now()}`,
|
|
1524
|
+
role: 'user' as const,
|
|
1525
|
+
parts: [{
|
|
1526
|
+
type: 'text' as const,
|
|
1527
|
+
text: contextCardMessage,
|
|
1528
|
+
}],
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
// Add context card, then send the actual prompt
|
|
1532
|
+
setMessages(prev => [...prev, debugContextMessage])
|
|
1533
|
+
setShowSuggestions(false)
|
|
1534
|
+
|
|
1535
|
+
// Small delay to let the card render, then send prompt
|
|
1536
|
+
setTimeout(() => {
|
|
1537
|
+
sendMessage({
|
|
1538
|
+
role: 'user',
|
|
1539
|
+
parts: [{ type: 'text', text: prompt }],
|
|
1540
|
+
})
|
|
1541
|
+
}, 100)
|
|
1542
|
+
|
|
1543
|
+
onDebugContextConsumed?.()
|
|
1544
|
+
}, [debugContext, status, sendMessage, setMessages, onDebugContextConsumed])
|
|
1545
|
+
|
|
1546
|
+
// Track processed explain context to avoid duplicates
|
|
1547
|
+
const processedExplainRef = useRef<string | null>(null)
|
|
1548
|
+
|
|
1549
|
+
// Handle explain context from response viewer - send explain prompt
|
|
1550
|
+
useEffect(() => {
|
|
1551
|
+
if (!explainContext || status === 'streaming' || status === 'submitted') {
|
|
1552
|
+
if (!explainContext) processedExplainRef.current = null
|
|
1553
|
+
return
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
const contextKey = JSON.stringify(explainContext)
|
|
1557
|
+
if (processedExplainRef.current === contextKey) return
|
|
1558
|
+
processedExplainRef.current = contextKey
|
|
1559
|
+
|
|
1560
|
+
// Create a concise explain prompt
|
|
1561
|
+
const prompt = `Explain this ${explainContext.status} response. What does it mean and what are the key fields?`
|
|
1562
|
+
|
|
1563
|
+
// Encode the context as a card message (reuse debug_context type)
|
|
1564
|
+
const contextCardMessage = encodeCardMessage({
|
|
1565
|
+
type: 'debug_context',
|
|
1566
|
+
context: {
|
|
1567
|
+
...explainContext,
|
|
1568
|
+
errorMessage: undefined, // Clear error message since this is a success
|
|
1569
|
+
},
|
|
1570
|
+
})
|
|
1571
|
+
|
|
1572
|
+
// Add the context card as a user message
|
|
1573
|
+
const explainContextMessage = {
|
|
1574
|
+
id: `explain-${Date.now()}`,
|
|
1575
|
+
role: 'user' as const,
|
|
1576
|
+
parts: [{
|
|
1577
|
+
type: 'text' as const,
|
|
1578
|
+
text: contextCardMessage,
|
|
1579
|
+
}],
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
// Add context card, then send the actual prompt
|
|
1583
|
+
setMessages(prev => [...prev, explainContextMessage])
|
|
1584
|
+
setShowSuggestions(false)
|
|
1585
|
+
|
|
1586
|
+
// Small delay to let the card render, then send prompt
|
|
1587
|
+
setTimeout(() => {
|
|
1588
|
+
sendMessage({
|
|
1589
|
+
role: 'user',
|
|
1590
|
+
parts: [{ type: 'text', text: prompt }],
|
|
1591
|
+
})
|
|
1592
|
+
}, 100)
|
|
1593
|
+
|
|
1594
|
+
onExplainContextConsumed?.()
|
|
1595
|
+
}, [explainContext, status, sendMessage, setMessages, onExplainContextConsumed])
|
|
1596
|
+
|
|
1597
|
+
// Add response options card after AI finishes responding to debug request
|
|
1598
|
+
useEffect(() => {
|
|
1599
|
+
// Check if status changed from streaming/submitted to ready
|
|
1600
|
+
const wasProcessing = prevStatus.current === 'streaming' || prevStatus.current === 'submitted'
|
|
1601
|
+
const isNowReady = status === 'ready'
|
|
1602
|
+
|
|
1603
|
+
if (wasProcessing && isNowReady && pendingErrorType) {
|
|
1604
|
+
// Encode the response options as a card message
|
|
1605
|
+
const optionsCardMessage = encodeCardMessage({
|
|
1606
|
+
type: 'response_options',
|
|
1607
|
+
errorType: pendingErrorType,
|
|
1608
|
+
})
|
|
1609
|
+
|
|
1610
|
+
// Add the response options card after AI response
|
|
1611
|
+
const responseOptionsMessage = {
|
|
1612
|
+
id: `options-${Date.now()}`,
|
|
1613
|
+
role: 'assistant' as const,
|
|
1614
|
+
parts: [{
|
|
1615
|
+
type: 'text' as const,
|
|
1616
|
+
text: optionsCardMessage,
|
|
1617
|
+
}],
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
setMessages(prev => [...prev, responseOptionsMessage])
|
|
1621
|
+
|
|
1622
|
+
// Clear pending error type
|
|
1623
|
+
setPendingErrorType(null)
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
// Update previous status
|
|
1627
|
+
prevStatus.current = status
|
|
1628
|
+
}, [status, pendingErrorType, setMessages])
|
|
1629
|
+
|
|
1630
|
+
// Handle image selection
|
|
1631
|
+
const handleImageSelect = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
1632
|
+
const files = Array.from(e.target.files || [])
|
|
1633
|
+
if (files.length === 0) return
|
|
1634
|
+
|
|
1635
|
+
// Limit to 4 images total
|
|
1636
|
+
const availableSlots = 4 - selectedImages.length
|
|
1637
|
+
const filesToProcess = files.slice(0, availableSlots)
|
|
1638
|
+
|
|
1639
|
+
const newImages = await Promise.all(
|
|
1640
|
+
filesToProcess.map(async (file) => {
|
|
1641
|
+
// Create preview URL
|
|
1642
|
+
const preview = URL.createObjectURL(file)
|
|
1643
|
+
|
|
1644
|
+
// Convert to base64
|
|
1645
|
+
const base64 = await new Promise<string>((resolve) => {
|
|
1646
|
+
const reader = new FileReader()
|
|
1647
|
+
reader.onloadend = () => {
|
|
1648
|
+
const result = reader.result as string
|
|
1649
|
+
// Extract base64 data without the data URL prefix
|
|
1650
|
+
resolve(result)
|
|
1651
|
+
}
|
|
1652
|
+
reader.readAsDataURL(file)
|
|
1653
|
+
})
|
|
1654
|
+
|
|
1655
|
+
return { file, preview, base64 }
|
|
1656
|
+
})
|
|
1657
|
+
)
|
|
1658
|
+
|
|
1659
|
+
setSelectedImages(prev => [...prev, ...newImages])
|
|
1660
|
+
|
|
1661
|
+
// Reset file input
|
|
1662
|
+
if (fileInputRef.current) {
|
|
1663
|
+
fileInputRef.current.value = ''
|
|
1664
|
+
}
|
|
1665
|
+
}, [selectedImages.length])
|
|
1666
|
+
|
|
1667
|
+
// Remove selected image
|
|
1668
|
+
const handleRemoveImage = useCallback((index: number) => {
|
|
1669
|
+
setSelectedImages(prev => {
|
|
1670
|
+
const newImages = [...prev]
|
|
1671
|
+
// Revoke the preview URL to free memory
|
|
1672
|
+
URL.revokeObjectURL(newImages[index].preview)
|
|
1673
|
+
newImages.splice(index, 1)
|
|
1674
|
+
return newImages
|
|
1675
|
+
})
|
|
1676
|
+
}, [])
|
|
1677
|
+
|
|
1678
|
+
// Auto-resize textarea
|
|
1679
|
+
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
1680
|
+
setInputValue(e.target.value)
|
|
1681
|
+
const textarea = e.target
|
|
1682
|
+
textarea.style.height = 'auto'
|
|
1683
|
+
textarea.style.height = `${Math.min(textarea.scrollHeight, 128)}px`
|
|
1684
|
+
}, [])
|
|
1685
|
+
|
|
1686
|
+
// Handle form submit
|
|
1687
|
+
const handleSubmit = useCallback((e: FormEvent) => {
|
|
1688
|
+
e.preventDefault()
|
|
1689
|
+
if ((!inputValue.trim() && selectedImages.length === 0) || isLoading) return
|
|
1690
|
+
|
|
1691
|
+
const trimmedInput = inputValue.trim()
|
|
1692
|
+
setInputValue('')
|
|
1693
|
+
if (textareaRef.current) {
|
|
1694
|
+
textareaRef.current.style.height = 'auto'
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
// Build message parts with images and text
|
|
1698
|
+
const parts: Array<{ type: 'text'; text: string } | { type: 'image'; image: string }> = []
|
|
1699
|
+
|
|
1700
|
+
// Add images first
|
|
1701
|
+
selectedImages.forEach(img => {
|
|
1702
|
+
parts.push({ type: 'image', image: img.base64 })
|
|
1703
|
+
})
|
|
1704
|
+
|
|
1705
|
+
// Add text if present
|
|
1706
|
+
if (trimmedInput) {
|
|
1707
|
+
parts.push({ type: 'text', text: trimmedInput })
|
|
1708
|
+
} else if (selectedImages.length > 0) {
|
|
1709
|
+
// If only images, add a default prompt
|
|
1710
|
+
parts.push({ type: 'text', text: 'What can you tell me about this image?' })
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
// Clear selected images
|
|
1714
|
+
selectedImages.forEach(img => URL.revokeObjectURL(img.preview))
|
|
1715
|
+
setSelectedImages([])
|
|
1716
|
+
|
|
1717
|
+
setShowSuggestions(false)
|
|
1718
|
+
sendMessage({
|
|
1719
|
+
role: 'user',
|
|
1720
|
+
// Cast to allow image parts which our API handles correctly
|
|
1721
|
+
parts: parts as Array<{ type: 'text'; text: string }>,
|
|
1722
|
+
})
|
|
1723
|
+
}, [inputValue, isLoading, sendMessage, selectedImages])
|
|
1724
|
+
|
|
1725
|
+
// Handle suggestion click
|
|
1726
|
+
const handleSuggestionClick = useCallback((prompt: string) => {
|
|
1727
|
+
setShowSuggestions(false)
|
|
1728
|
+
sendMessage({
|
|
1729
|
+
role: 'user',
|
|
1730
|
+
parts: [{ type: 'text', text: prompt }],
|
|
1731
|
+
})
|
|
1732
|
+
}, [sendMessage])
|
|
1733
|
+
|
|
1734
|
+
// Handle delete single message
|
|
1735
|
+
const handleDeleteMessage = useCallback((messageId: string) => {
|
|
1736
|
+
setMessages(prevMessages => {
|
|
1737
|
+
const newMessages = prevMessages.filter(msg => msg.id !== messageId)
|
|
1738
|
+
// Update localStorage with the new messages
|
|
1739
|
+
if (newMessages.length === 0) {
|
|
1740
|
+
localStorage.removeItem(CHAT_STORAGE_KEY)
|
|
1741
|
+
setShowSuggestions(true)
|
|
1742
|
+
} else {
|
|
1743
|
+
localStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify(newMessages))
|
|
1744
|
+
}
|
|
1745
|
+
return newMessages
|
|
1746
|
+
})
|
|
1747
|
+
}, [setMessages])
|
|
1748
|
+
|
|
1749
|
+
// Handle keyboard shortcuts
|
|
1750
|
+
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
1751
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
1752
|
+
e.preventDefault()
|
|
1753
|
+
handleSubmit(e as unknown as FormEvent)
|
|
1754
|
+
}
|
|
1755
|
+
}, [handleSubmit])
|
|
1756
|
+
|
|
1757
|
+
// Card actions for error handling
|
|
1758
|
+
const cardActions = useMemo(() => ({
|
|
1759
|
+
onOpenGlobalAuth,
|
|
1760
|
+
onNavigateToAuthTab,
|
|
1761
|
+
onNavigateToParamsTab,
|
|
1762
|
+
onNavigateToBodyTab,
|
|
1763
|
+
onNavigateToHeadersTab,
|
|
1764
|
+
}), [onOpenGlobalAuth, onNavigateToAuthTab, onNavigateToParamsTab, onNavigateToBodyTab, onNavigateToHeadersTab])
|
|
1765
|
+
|
|
1766
|
+
// Convert AI SDK messages to our format for rendering
|
|
1767
|
+
const renderableMessages = messages.map(msg => ({
|
|
1768
|
+
id: msg.id,
|
|
1769
|
+
role: msg.role as 'user' | 'assistant' | 'system',
|
|
1770
|
+
content: getMessageContent(msg),
|
|
1771
|
+
images: getMessageImages(msg),
|
|
1772
|
+
toolInvocations: getToolInvocations(msg),
|
|
1773
|
+
}))
|
|
1774
|
+
|
|
1775
|
+
return (
|
|
1776
|
+
<div className="flex flex-col h-full">
|
|
1777
|
+
{/* Messages */}
|
|
1778
|
+
<div className="flex-1 overflow-y-auto flex flex-col">
|
|
1779
|
+
<div className={cn(
|
|
1780
|
+
"max-w-2xl mx-auto px-4 py-4 w-full",
|
|
1781
|
+
messages.length === 0 && showSuggestions ? "flex-1 flex flex-col" : ""
|
|
1782
|
+
)}>
|
|
1783
|
+
{messages.length === 0 && showSuggestions && (
|
|
1784
|
+
<div className="flex w-full grow flex-col">
|
|
1785
|
+
{/* Center - Title and subtitle */}
|
|
1786
|
+
<div className="flex w-full grow flex-col items-center justify-center">
|
|
1787
|
+
<div className="flex flex-col items-center text-center px-4">
|
|
1788
|
+
<h1 className="font-semibold text-2xl animate-in fade-in slide-in-from-bottom-1 duration-200">
|
|
1789
|
+
{currentEndpoint ? currentEndpoint.name : 'Hello there!'}
|
|
1790
|
+
</h1>
|
|
1791
|
+
<p className="text-muted-foreground text-lg animate-in fade-in slide-in-from-bottom-1 duration-200 delay-75">
|
|
1792
|
+
{currentEndpoint
|
|
1793
|
+
? 'Ask me anything about this endpoint'
|
|
1794
|
+
: 'How can I help you today?'
|
|
1795
|
+
}
|
|
1796
|
+
</p>
|
|
1797
|
+
</div>
|
|
1798
|
+
</div>
|
|
1799
|
+
|
|
1800
|
+
{/* Suggestions - at the bottom */}
|
|
1801
|
+
<div className="grid w-full grid-cols-1 gap-2 pb-4 mt-8">
|
|
1802
|
+
{loadingSuggestions ? (
|
|
1803
|
+
// Loading skeletons
|
|
1804
|
+
<>
|
|
1805
|
+
{[1, 2, 3, 4].map((i) => (
|
|
1806
|
+
<div
|
|
1807
|
+
key={i}
|
|
1808
|
+
className="h-[52px] w-full rounded-2xl border bg-muted/30 animate-pulse"
|
|
1809
|
+
style={{ animationDelay: `${i * 100}ms` }}
|
|
1810
|
+
/>
|
|
1811
|
+
))}
|
|
1812
|
+
</>
|
|
1813
|
+
) : (
|
|
1814
|
+
suggestions.map((suggestion, index) => (
|
|
1815
|
+
<div
|
|
1816
|
+
key={suggestion.prompt}
|
|
1817
|
+
className="animate-in fade-in slide-in-from-bottom-2 duration-200 fill-mode-both"
|
|
1818
|
+
style={{ animationDelay: `${100 + index * 50}ms` }}
|
|
1819
|
+
>
|
|
1820
|
+
<button
|
|
1821
|
+
onClick={() => handleSuggestionClick(suggestion.prompt)}
|
|
1822
|
+
className="h-auto w-full flex flex-wrap items-start justify-start gap-1 rounded-2xl border px-4 py-3 text-left text-sm transition-colors hover:bg-muted"
|
|
1823
|
+
>
|
|
1824
|
+
<span className="font-medium">{suggestion.title}</span>
|
|
1825
|
+
<span className="text-muted-foreground">{suggestion.label}</span>
|
|
1826
|
+
</button>
|
|
1827
|
+
</div>
|
|
1828
|
+
))
|
|
1829
|
+
)}
|
|
1830
|
+
</div>
|
|
1831
|
+
</div>
|
|
1832
|
+
)}
|
|
1833
|
+
|
|
1834
|
+
{renderableMessages.map((message, i) => (
|
|
1835
|
+
<ChatMessage
|
|
1836
|
+
key={message.id}
|
|
1837
|
+
role={message.role}
|
|
1838
|
+
content={message.content}
|
|
1839
|
+
messageId={message.id}
|
|
1840
|
+
images={message.images}
|
|
1841
|
+
toolInvocations={message.toolInvocations}
|
|
1842
|
+
isStreaming={isLoading && i === renderableMessages.length - 1}
|
|
1843
|
+
onDeleteMessage={handleDeleteMessage}
|
|
1844
|
+
onNavigate={onNavigate}
|
|
1845
|
+
onNavigateToDocPage={onNavigateToDocPage}
|
|
1846
|
+
onOpenFile={handleOpenFile}
|
|
1847
|
+
cardActions={cardActions}
|
|
1848
|
+
/>
|
|
1849
|
+
))}
|
|
1850
|
+
|
|
1851
|
+
{/* Show typing indicator when waiting for stream to start */}
|
|
1852
|
+
{status === 'submitted' && <TypingIndicator />}
|
|
1853
|
+
|
|
1854
|
+
<div ref={messagesEndRef} />
|
|
1855
|
+
</div>
|
|
1856
|
+
</div>
|
|
1857
|
+
|
|
1858
|
+
{/* Input */}
|
|
1859
|
+
<div className="border-t border-border p-4">
|
|
1860
|
+
<form onSubmit={handleSubmit} className="max-w-2xl mx-auto">
|
|
1861
|
+
{/* Hidden file input */}
|
|
1862
|
+
<input
|
|
1863
|
+
ref={fileInputRef}
|
|
1864
|
+
type="file"
|
|
1865
|
+
accept="image/*"
|
|
1866
|
+
multiple
|
|
1867
|
+
onChange={handleImageSelect}
|
|
1868
|
+
className="hidden"
|
|
1869
|
+
aria-hidden="true"
|
|
1870
|
+
/>
|
|
1871
|
+
|
|
1872
|
+
<div className="relative rounded-2xl border border-border bg-background shadow-sm focus-within:ring-2 focus-within:ring-ring">
|
|
1873
|
+
{/* Image previews */}
|
|
1874
|
+
{selectedImages.length > 0 && (
|
|
1875
|
+
<div className="flex flex-wrap gap-2 p-3 pb-0">
|
|
1876
|
+
{selectedImages.map((img, index) => (
|
|
1877
|
+
<div key={index} className="relative group">
|
|
1878
|
+
<img
|
|
1879
|
+
src={img.preview}
|
|
1880
|
+
alt={`Selected image ${index + 1}`}
|
|
1881
|
+
className="h-16 w-16 object-cover rounded-lg border border-border"
|
|
1882
|
+
/>
|
|
1883
|
+
<Button
|
|
1884
|
+
type="button"
|
|
1885
|
+
size="icon"
|
|
1886
|
+
variant="secondary"
|
|
1887
|
+
onClick={() => handleRemoveImage(index)}
|
|
1888
|
+
className="absolute -top-1.5 -right-1.5 h-4 w-4 min-w-0 rounded-full opacity-0 group-hover:opacity-100 transition-opacity shadow-sm p-2"
|
|
1889
|
+
aria-label={`Remove image ${index + 1}`}
|
|
1890
|
+
>
|
|
1891
|
+
<X className="h-2.5 w-2.5 p-1" weight="bold" />
|
|
1892
|
+
</Button>
|
|
1893
|
+
</div>
|
|
1894
|
+
))}
|
|
1895
|
+
</div>
|
|
1896
|
+
)}
|
|
1897
|
+
|
|
1898
|
+
<textarea
|
|
1899
|
+
ref={textareaRef}
|
|
1900
|
+
value={inputValue}
|
|
1901
|
+
onChange={handleInputChange}
|
|
1902
|
+
onKeyDown={handleKeyDown}
|
|
1903
|
+
placeholder="Send a message..."
|
|
1904
|
+
className={cn(
|
|
1905
|
+
"w-full resize-none bg-transparent px-4 pt-3 pb-12 text-sm",
|
|
1906
|
+
"placeholder:text-muted-foreground/70 outline-none",
|
|
1907
|
+
"min-h-[56px] max-h-32"
|
|
1908
|
+
)}
|
|
1909
|
+
rows={1}
|
|
1910
|
+
disabled={isLoading}
|
|
1911
|
+
aria-label="Message input"
|
|
1912
|
+
/>
|
|
1913
|
+
|
|
1914
|
+
<div className="absolute bottom-2 left-2 right-2 flex items-center justify-between">
|
|
1915
|
+
{/* Image upload button */}
|
|
1916
|
+
<div>
|
|
1917
|
+
<Tooltip>
|
|
1918
|
+
<TooltipTrigger asChild>
|
|
1919
|
+
<Button
|
|
1920
|
+
type="button"
|
|
1921
|
+
size="icon"
|
|
1922
|
+
variant="ghost"
|
|
1923
|
+
className={cn(
|
|
1924
|
+
"h-8 w-8 rounded-full text-muted-foreground hover:text-foreground",
|
|
1925
|
+
selectedImages.length >= 4 && "opacity-50 cursor-not-allowed"
|
|
1926
|
+
)}
|
|
1927
|
+
onClick={() => fileInputRef.current?.click()}
|
|
1928
|
+
disabled={isLoading || selectedImages.length >= 4}
|
|
1929
|
+
aria-label="Upload image"
|
|
1930
|
+
>
|
|
1931
|
+
<ImageIcon className="h-4 w-4" weight="bold" />
|
|
1932
|
+
</Button>
|
|
1933
|
+
</TooltipTrigger>
|
|
1934
|
+
<TooltipContent side="top">
|
|
1935
|
+
{selectedImages.length >= 4 ? 'Maximum 4 images' : 'Upload image'}
|
|
1936
|
+
</TooltipContent>
|
|
1937
|
+
</Tooltip>
|
|
1938
|
+
</div>
|
|
1939
|
+
|
|
1940
|
+
{isLoading ? (
|
|
1941
|
+
<Tooltip>
|
|
1942
|
+
<TooltipTrigger asChild>
|
|
1943
|
+
<Button
|
|
1944
|
+
type="button"
|
|
1945
|
+
onClick={() => stop()}
|
|
1946
|
+
size="icon"
|
|
1947
|
+
variant="default"
|
|
1948
|
+
className="h-8 w-8 rounded-full"
|
|
1949
|
+
aria-label="Stop generating"
|
|
1950
|
+
>
|
|
1951
|
+
<Square className="h-3 w-3" weight="fill" />
|
|
1952
|
+
</Button>
|
|
1953
|
+
</TooltipTrigger>
|
|
1954
|
+
<TooltipContent side="top">Stop generating</TooltipContent>
|
|
1955
|
+
</Tooltip>
|
|
1956
|
+
) : (
|
|
1957
|
+
<Tooltip>
|
|
1958
|
+
<TooltipTrigger asChild>
|
|
1959
|
+
<Button
|
|
1960
|
+
type="submit"
|
|
1961
|
+
disabled={!inputValue.trim()}
|
|
1962
|
+
size="icon"
|
|
1963
|
+
variant="default"
|
|
1964
|
+
className="h-8 w-8 rounded-full"
|
|
1965
|
+
aria-label="Send message"
|
|
1966
|
+
>
|
|
1967
|
+
<ArrowUp className="h-4 w-4" weight="bold" />
|
|
1968
|
+
</Button>
|
|
1969
|
+
</TooltipTrigger>
|
|
1970
|
+
<TooltipContent side="top">Send message</TooltipContent>
|
|
1971
|
+
</Tooltip>
|
|
1972
|
+
)}
|
|
1973
|
+
</div>
|
|
1974
|
+
</div>
|
|
1975
|
+
</form>
|
|
1976
|
+
</div>
|
|
1977
|
+
</div>
|
|
1978
|
+
)
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
// Helper to extract text content from a message
|
|
1982
|
+
function getMessageContent(msg: { parts?: Array<{ type: string; text?: string }> }): string {
|
|
1983
|
+
if (!msg.parts) return ''
|
|
1984
|
+
|
|
1985
|
+
const textParts = msg.parts.filter(
|
|
1986
|
+
(part): part is { type: 'text'; text: string } =>
|
|
1987
|
+
part.type === 'text' && typeof part.text === 'string'
|
|
1988
|
+
)
|
|
1989
|
+
|
|
1990
|
+
let content = textParts.map(p => p.text).join('')
|
|
1991
|
+
|
|
1992
|
+
// Remove any trailing JSON blocks that the model might have included
|
|
1993
|
+
content = content.replace(/\n*```json[\s\S]*?```\n*/g, '')
|
|
1994
|
+
content = content.replace(/\n*\{[\s\S]*?"id":\s*"[^"]+",[\s\S]*?"name":\s*"[^"]+"[\s\S]*?\}\s*$/g, '')
|
|
1995
|
+
|
|
1996
|
+
return content.trim()
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
// Helper to extract images from a message
|
|
2000
|
+
function getMessageImages(msg: { parts?: Array<{ type: string; image?: string }> }): Array<{ image: string }> {
|
|
2001
|
+
if (!msg.parts) return []
|
|
2002
|
+
|
|
2003
|
+
return msg.parts
|
|
2004
|
+
.filter((part): part is { type: 'image'; image: string } =>
|
|
2005
|
+
part.type === 'image' && typeof part.image === 'string'
|
|
2006
|
+
)
|
|
2007
|
+
.map(part => ({ image: part.image }))
|
|
2008
|
+
}
|
|
2009
|
+
|
|
2010
|
+
// Helper to extract tool invocations from a message
|
|
2011
|
+
function getToolInvocations(msg: { parts?: Array<Record<string, unknown>> }): Array<{
|
|
2012
|
+
toolCallId: string
|
|
2013
|
+
toolName: string
|
|
2014
|
+
args: Record<string, unknown>
|
|
2015
|
+
result?: unknown
|
|
2016
|
+
state: 'pending' | 'result' | 'error'
|
|
2017
|
+
}> {
|
|
2018
|
+
if (!msg.parts) return []
|
|
2019
|
+
|
|
2020
|
+
// Use a Map to deduplicate by toolCallId (keep latest version)
|
|
2021
|
+
const toolCallMap = new Map<string, {
|
|
2022
|
+
toolCallId: string
|
|
2023
|
+
toolName: string
|
|
2024
|
+
args: Record<string, unknown>
|
|
2025
|
+
result?: unknown
|
|
2026
|
+
state: 'pending' | 'result' | 'error'
|
|
2027
|
+
}>()
|
|
2028
|
+
|
|
2029
|
+
msg.parts
|
|
2030
|
+
.filter((part): part is {
|
|
2031
|
+
type: string
|
|
2032
|
+
toolCallId: string
|
|
2033
|
+
toolName?: string
|
|
2034
|
+
input?: Record<string, unknown>
|
|
2035
|
+
args?: Record<string, unknown>
|
|
2036
|
+
output?: unknown
|
|
2037
|
+
result?: unknown
|
|
2038
|
+
state?: string
|
|
2039
|
+
} => {
|
|
2040
|
+
return typeof part === 'object' &&
|
|
2041
|
+
part !== null &&
|
|
2042
|
+
'type' in part &&
|
|
2043
|
+
typeof part.type === 'string' &&
|
|
2044
|
+
part.type.startsWith('tool-') &&
|
|
2045
|
+
part.type !== 'tool-call' &&
|
|
2046
|
+
'toolCallId' in part
|
|
2047
|
+
})
|
|
2048
|
+
.forEach(part => {
|
|
2049
|
+
const toolName = part.toolName || part.type.replace('tool-', '')
|
|
2050
|
+
|
|
2051
|
+
// Only update if we don't have this tool call yet, or if the new one has more info
|
|
2052
|
+
const existing = toolCallMap.get(part.toolCallId)
|
|
2053
|
+
const newState = getToolState(part)
|
|
2054
|
+
|
|
2055
|
+
// Prefer 'result' state over 'pending'
|
|
2056
|
+
if (!existing || newState === 'result' || (newState === 'error' && existing.state === 'pending')) {
|
|
2057
|
+
toolCallMap.set(part.toolCallId, {
|
|
2058
|
+
toolCallId: part.toolCallId,
|
|
2059
|
+
toolName,
|
|
2060
|
+
args: (part.input as Record<string, unknown>) || (part.args as Record<string, unknown>) || {},
|
|
2061
|
+
result: part.output ?? part.result,
|
|
2062
|
+
state: newState,
|
|
2063
|
+
})
|
|
2064
|
+
}
|
|
2065
|
+
})
|
|
2066
|
+
|
|
2067
|
+
return Array.from(toolCallMap.values())
|
|
2068
|
+
}
|
|
2069
|
+
|
|
2070
|
+
// Helper to determine tool state from part
|
|
2071
|
+
function getToolState(part: { state?: string }): 'pending' | 'result' | 'error' {
|
|
2072
|
+
if (part.state === 'output-error') return 'error'
|
|
2073
|
+
if (part.state === 'output-available') return 'result'
|
|
2074
|
+
if (part.state === 'input-streaming' || part.state === 'input-available') return 'pending'
|
|
2075
|
+
return 'result'
|
|
2076
|
+
}
|