mirror of
https://github.com/cline/cline.git
synced 2026-09-01 15:11:04 +08:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fb6ae14f3c | |||
| fc422f253a | |||
| 68de0e732c | |||
| ba28e09fa0 | |||
| b527031cab | |||
| 63fe6f21fa | |||
| d890895adc | |||
| 810d325f83 | |||
| 38e33348b1 | |||
| 4ffef314a6 | |||
| 94a2122653 | |||
| 343b12a562 | |||
| 2aadea75c3 | |||
| b039ee7601 | |||
| 1b42c584ee | |||
| 631a009384 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
Add rich MCP responses with images and link previews
|
||||
Generated
+22
@@ -36,6 +36,7 @@
|
||||
"isbinaryfile": "^5.0.2",
|
||||
"mammoth": "^1.8.0",
|
||||
"monaco-vscode-textmate-theme-converter": "^0.1.7",
|
||||
"open-graph-scraper": "^6.9.0",
|
||||
"openai": "^4.83.0",
|
||||
"os-name": "^6.0.0",
|
||||
"p-wait-for": "^5.0.2",
|
||||
@@ -11047,6 +11048,27 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/open-graph-scraper": {
|
||||
"version": "6.9.0",
|
||||
"resolved": "https://registry.npmjs.org/open-graph-scraper/-/open-graph-scraper-6.9.0.tgz",
|
||||
"integrity": "sha512-1KoV5v6GT0/MqlryrVGQROhEAD4u8wC3VjYOxsnhj3mWeGJ6N6nF/rbrcZREFr+kiYm9I5LMrzdK9t9hBMbL2Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chardet": "^2.0.0",
|
||||
"cheerio": "^1.0.0-rc.12",
|
||||
"iconv-lite": "^0.6.3",
|
||||
"undici": "^6.21.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/open-graph-scraper/node_modules/chardet": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chardet/-/chardet-2.0.0.tgz",
|
||||
"integrity": "sha512-xVgPpulCooDjY6zH4m9YW3jbkaBe3FKIAvF5sj5t7aBNsVl2ljIE+xwJ4iNgiDZHFQvNIpjdKdVOQvvk5ZfxbQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/openai": {
|
||||
"version": "4.83.0",
|
||||
"resolved": "https://registry.npmjs.org/openai/-/openai-4.83.0.tgz",
|
||||
|
||||
@@ -264,6 +264,7 @@
|
||||
"isbinaryfile": "^5.0.2",
|
||||
"mammoth": "^1.8.0",
|
||||
"monaco-vscode-textmate-theme-converter": "^0.1.7",
|
||||
"open-graph-scraper": "^6.9.0",
|
||||
"openai": "^4.83.0",
|
||||
"os-name": "^6.0.0",
|
||||
"p-wait-for": "^5.0.2",
|
||||
|
||||
@@ -10,6 +10,7 @@ import * as vscode from "vscode"
|
||||
import { buildApiHandler } from "../../api"
|
||||
import { downloadTask } from "../../integrations/misc/export-markdown"
|
||||
import { openFile, openImage } from "../../integrations/misc/open-file"
|
||||
import { fetchOpenGraphData, isImageUrl } from "../../integrations/misc/link-preview"
|
||||
import { selectImages } from "../../integrations/misc/process-images"
|
||||
import { getTheme } from "../../integrations/theme/getTheme"
|
||||
import WorkspaceTracker from "../../integrations/workspace/WorkspaceTracker"
|
||||
@@ -635,6 +636,17 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
case "openImage":
|
||||
openImage(message.text!)
|
||||
break
|
||||
case "openInBrowser":
|
||||
if (message.url) {
|
||||
vscode.env.openExternal(vscode.Uri.parse(message.url))
|
||||
}
|
||||
break
|
||||
case "fetchOpenGraphData":
|
||||
this.fetchOpenGraphData(message.text!)
|
||||
break
|
||||
case "checkIsImageUrl":
|
||||
this.checkIsImageUrl(message.text!)
|
||||
break
|
||||
case "openFile":
|
||||
openFile(message.text!)
|
||||
break
|
||||
@@ -1906,6 +1918,53 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
|
||||
return await this.context.secrets.get(key)
|
||||
}
|
||||
|
||||
// Open Graph Data
|
||||
|
||||
async fetchOpenGraphData(url: string) {
|
||||
try {
|
||||
// Use the fetchOpenGraphData function from link-preview.ts
|
||||
const ogData = await fetchOpenGraphData(url)
|
||||
|
||||
// Send the data back to the webview
|
||||
await this.postMessageToWebview({
|
||||
type: "openGraphData",
|
||||
openGraphData: ogData,
|
||||
url: url,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(`Error fetching Open Graph data for ${url}:`, error)
|
||||
// Send an error response
|
||||
await this.postMessageToWebview({
|
||||
type: "openGraphData",
|
||||
error: `Failed to fetch Open Graph data: ${error}`,
|
||||
url: url,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Check if a URL is an image
|
||||
async checkIsImageUrl(url: string) {
|
||||
try {
|
||||
// Check if the URL is an image
|
||||
const isImage = await isImageUrl(url)
|
||||
|
||||
// Send the result back to the webview
|
||||
await this.postMessageToWebview({
|
||||
type: "isImageUrlResult",
|
||||
isImage,
|
||||
url,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(`Error checking if URL is an image: ${url}`, error)
|
||||
// Send an error response
|
||||
await this.postMessageToWebview({
|
||||
type: "isImageUrlResult",
|
||||
isImage: false,
|
||||
url,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// dev
|
||||
|
||||
async resetState() {
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import axios from "axios"
|
||||
import ogs from "open-graph-scraper"
|
||||
|
||||
export interface OpenGraphData {
|
||||
title?: string
|
||||
description?: string
|
||||
image?: string
|
||||
url?: string
|
||||
siteName?: string
|
||||
type?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches Open Graph metadata from a URL
|
||||
* @param url The URL to fetch metadata from
|
||||
* @returns Promise resolving to OpenGraphData
|
||||
*/
|
||||
export async function fetchOpenGraphData(url: string): Promise<OpenGraphData> {
|
||||
try {
|
||||
const options = {
|
||||
url: url,
|
||||
timeout: 5000,
|
||||
headers: {
|
||||
"user-agent": "Mozilla/5.0 (compatible; VSCodeExtension/1.0; +https://cline.bot)",
|
||||
},
|
||||
onlyGetOpenGraphInfo: false, // Get all metadata, not just Open Graph
|
||||
fetchOptions: {
|
||||
redirect: "follow", // Follow redirects
|
||||
} as any,
|
||||
}
|
||||
|
||||
const { result } = await ogs(options)
|
||||
|
||||
// Use type assertion to avoid TypeScript errors
|
||||
const data = result as any
|
||||
|
||||
// Handle image URLs
|
||||
let imageUrl = data.ogImage?.[0]?.url || data.twitterImage?.[0]?.url
|
||||
|
||||
// If the image URL is relative, make it absolute
|
||||
if (imageUrl && (imageUrl.startsWith("/") || imageUrl.startsWith("./"))) {
|
||||
try {
|
||||
// Extract the base URL and make the relative URL absolute
|
||||
const urlObj = new URL(url)
|
||||
const baseUrl = `${urlObj.protocol}//${urlObj.hostname}`
|
||||
imageUrl = new URL(imageUrl, baseUrl).href
|
||||
} catch (error) {
|
||||
console.error(`Error converting relative URL to absolute: ${imageUrl}`, error)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title: data.ogTitle || data.twitterTitle || data.dcTitle || data.title || new URL(url).hostname,
|
||||
description:
|
||||
data.ogDescription ||
|
||||
data.twitterDescription ||
|
||||
data.dcDescription ||
|
||||
data.description ||
|
||||
"No description available",
|
||||
image: imageUrl,
|
||||
url: data.ogUrl || url,
|
||||
siteName: data.ogSiteName || new URL(url).hostname,
|
||||
type: data.ogType,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error fetching Open Graph data for ${url}:`, error)
|
||||
// Return basic information based on the URL
|
||||
try {
|
||||
const urlObj = new URL(url)
|
||||
return {
|
||||
title: urlObj.hostname,
|
||||
description: url,
|
||||
url: url,
|
||||
siteName: urlObj.hostname,
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
title: url,
|
||||
description: url,
|
||||
url: url,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a URL is an image by making a HEAD request and checking the content type
|
||||
* @param url The URL to check
|
||||
* @returns Promise resolving to boolean indicating if the URL is an image
|
||||
*/
|
||||
export async function isImageUrl(url: string): Promise<boolean> {
|
||||
try {
|
||||
const response = await axios.head(url, {
|
||||
headers: {
|
||||
"User-Agent": "Mozilla/5.0 (compatible; VSCodeExtension/1.0; +https://cline.bot)",
|
||||
},
|
||||
timeout: 3000,
|
||||
})
|
||||
|
||||
const contentType = response.headers["content-type"]
|
||||
return contentType && contentType.startsWith("image/")
|
||||
} catch (error) {
|
||||
console.error(`Error checking if URL is an image: ${url}`, error)
|
||||
// If we can't determine, fall back to checking the file extension
|
||||
return /\.(jpg|jpeg|png|gif|webp|svg)$/i.test(url)
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,8 @@ export interface ExtensionMessage {
|
||||
| "mcpMarketplaceCatalog"
|
||||
| "mcpDownloadDetails"
|
||||
| "commitSearchResults"
|
||||
| "openGraphData"
|
||||
| "isImageUrlResult"
|
||||
text?: string
|
||||
action?:
|
||||
| "chatButtonClicked"
|
||||
@@ -54,6 +56,16 @@ export interface ExtensionMessage {
|
||||
error?: string
|
||||
mcpDownloadDetails?: McpDownloadResponse
|
||||
commits?: GitCommit[]
|
||||
openGraphData?: {
|
||||
title?: string
|
||||
description?: string
|
||||
image?: string
|
||||
url?: string
|
||||
siteName?: string
|
||||
type?: string
|
||||
}
|
||||
url?: string
|
||||
isImage?: boolean
|
||||
}
|
||||
|
||||
export type Platform = "aix" | "darwin" | "freebsd" | "linux" | "openbsd" | "sunos" | "win32" | "unknown"
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface WebviewMessage {
|
||||
| "requestOllamaModels"
|
||||
| "requestLmStudioModels"
|
||||
| "openImage"
|
||||
| "openInBrowser"
|
||||
| "openFile"
|
||||
| "openMention"
|
||||
| "cancelTask"
|
||||
@@ -50,6 +51,9 @@ export interface WebviewMessage {
|
||||
| "searchCommits"
|
||||
| "showMcpView"
|
||||
| "fetchLatestMcpServersFromHub"
|
||||
| "updateMcpTimeout"
|
||||
| "fetchOpenGraphData"
|
||||
| "checkIsImageUrl"
|
||||
// | "relaunchChromeDebugMode"
|
||||
text?: string
|
||||
disabled?: boolean
|
||||
@@ -68,6 +72,9 @@ export interface WebviewMessage {
|
||||
serverName?: string
|
||||
toolName?: string
|
||||
autoApprove?: boolean
|
||||
|
||||
// For openInBrowser
|
||||
url?: string
|
||||
}
|
||||
|
||||
export type ClineAskResponse = "yesButtonClicked" | "noButtonClicked" | "messageResponse"
|
||||
|
||||
Generated
+12
@@ -9,8 +9,10 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@floating-ui/react": "^0.27.4",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@vscode/webview-ui-toolkit": "^1.4.0",
|
||||
"debounce": "^2.1.1",
|
||||
"dompurify": "^3.2.4",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fuse.js": "^7.0.0",
|
||||
"fzf": "^0.5.2",
|
||||
@@ -4979,6 +4981,15 @@
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/dompurify": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
|
||||
"integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/trusted-types": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/eslint": {
|
||||
"version": "8.56.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.12.tgz",
|
||||
@@ -8981,6 +8992,7 @@
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.4.tgz",
|
||||
"integrity": "sha512-ysFSFEDVduQpyhzAob/kkuJjf5zWkZD8/A9ywSp1byueyuCfHamrCBa14/Oc2iiB0e51B+NpxSl5gmzn+Ms/mg==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
|
||||
@@ -4,8 +4,10 @@
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@floating-ui/react": "^0.27.4",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@vscode/webview-ui-toolkit": "^1.4.0",
|
||||
"debounce": "^2.1.1",
|
||||
"dompurify": "^3.2.4",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fuse.js": "^7.0.0",
|
||||
"fzf": "^0.5.2",
|
||||
|
||||
@@ -25,6 +25,7 @@ import McpResourceRow from "../mcp/McpResourceRow"
|
||||
import McpToolRow from "../mcp/McpToolRow"
|
||||
import { highlightMentions } from "./TaskHeader"
|
||||
import { CheckmarkControl } from "../common/CheckmarkControl"
|
||||
import McpResponseDisplay from "../mcp/McpResponseDisplay"
|
||||
|
||||
const ChatRowContainer = styled.div`
|
||||
padding: 10px 6px 10px 15px;
|
||||
@@ -46,62 +47,87 @@ interface ChatRowProps {
|
||||
|
||||
interface ChatRowContentProps extends Omit<ChatRowProps, "onHeightChange"> {}
|
||||
|
||||
const ChatRow = memo(
|
||||
(props: ChatRowProps) => {
|
||||
const { isLast, onHeightChange, message, lastModifiedMessage } = props
|
||||
// Store the previous height to compare with the current height
|
||||
// This allows us to detect changes without causing re-renders
|
||||
const prevHeightRef = useRef(0)
|
||||
|
||||
// NOTE: for tools that are interrupted and not responded to (approved or rejected), there won't be a checkpoint hash
|
||||
let shouldShowCheckpoints =
|
||||
message.lastCheckpointHash != null &&
|
||||
(message.say === "tool" ||
|
||||
message.ask === "tool" ||
|
||||
message.say === "command" ||
|
||||
message.ask === "command" ||
|
||||
// message.say === "completion_result" ||
|
||||
// message.ask === "completion_result" ||
|
||||
message.say === "use_mcp_server" ||
|
||||
message.ask === "use_mcp_server")
|
||||
|
||||
if (shouldShowCheckpoints && isLast) {
|
||||
shouldShowCheckpoints =
|
||||
lastModifiedMessage?.ask === "resume_completed_task" || lastModifiedMessage?.ask === "resume_task"
|
||||
}
|
||||
|
||||
const [chatrow, { height }] = useSize(
|
||||
<ChatRowContainer>
|
||||
<ChatRowContent {...props} />
|
||||
{shouldShowCheckpoints && <CheckpointOverlay messageTs={message.ts} />}
|
||||
</ChatRowContainer>,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
// used for partials, command output, etc.
|
||||
// NOTE: it's important we don't distinguish between partial or complete here since our scroll effects in chatview need to handle height change during partial -> complete
|
||||
const isInitialRender = prevHeightRef.current === 0 // prevents scrolling when new element is added since we already scroll for that
|
||||
// height starts off at Infinity
|
||||
if (isLast && height !== 0 && height !== Infinity && height !== prevHeightRef.current) {
|
||||
if (!isInitialRender) {
|
||||
onHeightChange(height > prevHeightRef.current)
|
||||
}
|
||||
prevHeightRef.current = height
|
||||
}
|
||||
}, [height, isLast, onHeightChange, message])
|
||||
|
||||
// we cannot return null as virtuoso does not support it, so we use a separate visibleMessages array to filter out messages that should not be rendered
|
||||
return chatrow
|
||||
},
|
||||
// memo does shallow comparison of props, so we need to do deep comparison of arrays/objects whose properties might change
|
||||
deepEqual,
|
||||
export const ProgressIndicator = () => (
|
||||
<div
|
||||
style={{
|
||||
width: "16px",
|
||||
height: "16px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}>
|
||||
<div style={{ transform: "scale(0.55)", transformOrigin: "center" }}>
|
||||
<VSCodeProgressRing />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const Markdown = memo(({ markdown }: { markdown?: string }) => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
wordBreak: "break-word",
|
||||
overflowWrap: "anywhere",
|
||||
marginBottom: -15,
|
||||
marginTop: -15,
|
||||
}}>
|
||||
<MarkdownBlock markdown={markdown} />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
const ChatRow = memo((props: ChatRowProps) => {
|
||||
const { isLast, onHeightChange, message, lastModifiedMessage } = props
|
||||
// Store the previous height to compare with the current height
|
||||
// This allows us to detect changes without causing re-renders
|
||||
const prevHeightRef = useRef(0)
|
||||
|
||||
// NOTE: for tools that are interrupted and not responded to (approved or rejected) there won't be a checkpoint hash
|
||||
let shouldShowCheckpoints =
|
||||
message.lastCheckpointHash != null &&
|
||||
(message.say === "tool" ||
|
||||
message.ask === "tool" ||
|
||||
message.say === "command" ||
|
||||
message.ask === "command" ||
|
||||
// message.say === "completion_result" ||
|
||||
// message.ask === "completion_result" ||
|
||||
message.say === "use_mcp_server" ||
|
||||
message.ask === "use_mcp_server")
|
||||
|
||||
if (shouldShowCheckpoints && isLast) {
|
||||
shouldShowCheckpoints = lastModifiedMessage?.ask === "resume_completed_task" || lastModifiedMessage?.ask === "resume_task"
|
||||
}
|
||||
|
||||
const [chatrow, { height }] = useSize(
|
||||
<ChatRowContainer>
|
||||
<ChatRowContent {...props} />
|
||||
{shouldShowCheckpoints && <CheckpointOverlay messageTs={message.ts} />}
|
||||
</ChatRowContainer>,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
// used for partials command output etc.
|
||||
// NOTE: it's important we don't distinguish between partial or complete here since our scroll effects in chatview need to handle height change during partial -> complete
|
||||
const isInitialRender = prevHeightRef.current === 0 // prevents scrolling when new element is added since we already scroll for that
|
||||
// height starts off at Infinity
|
||||
if (isLast && height !== 0 && height !== Infinity && height !== prevHeightRef.current) {
|
||||
if (!isInitialRender) {
|
||||
onHeightChange(height > prevHeightRef.current)
|
||||
}
|
||||
prevHeightRef.current = height
|
||||
}
|
||||
}, [height, isLast, onHeightChange, message])
|
||||
|
||||
// we cannot return null as virtuoso does not support it so we use a separate visibleMessages array to filter out messages that should not be rendered
|
||||
return chatrow
|
||||
},
|
||||
// memo does shallow comparison of props, so we need to do deep comparison of arrays/objects whose properties might change
|
||||
deepEqual)
|
||||
|
||||
export default ChatRow
|
||||
|
||||
export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifiedMessage, isLast }: ChatRowContentProps) => {
|
||||
const { mcpServers, mcpMarketplaceCatalog } = useExtensionState()
|
||||
|
||||
const [seeNewChangesDisabled, setSeeNewChangesDisabled] = useState(false)
|
||||
|
||||
const [cost, apiReqCancelReason, apiReqStreamingFailedMessage] = useMemo(() => {
|
||||
@@ -111,11 +137,13 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
|
||||
}
|
||||
return [undefined, undefined, undefined]
|
||||
}, [message.text, message.say])
|
||||
// when resuming task, last wont be api_req_failed but a resume_task message, so api_req_started will show loading spinner. that's why we just remove the last api_req_started that failed without streaming anything
|
||||
|
||||
// when resuming task last won't be api_req_failed but a resume_task message so api_req_started will show loading spinner. that's why we just remove the last api_req_started that failed without streaming anything
|
||||
const apiRequestFailedMessage =
|
||||
isLast && lastModifiedMessage?.ask === "api_req_failed" // if request is retried then the latest message is a api_req_retried
|
||||
? lastModifiedMessage?.text
|
||||
: undefined
|
||||
|
||||
const isCommandExecuting =
|
||||
isLast &&
|
||||
(lastModifiedMessage?.ask === "command" || lastModifiedMessage?.say === "command") &&
|
||||
@@ -367,12 +395,6 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
|
||||
Cline wants to read this file:
|
||||
</span>
|
||||
</div>
|
||||
{/* <CodeAccordian
|
||||
code={tool.content!}
|
||||
path={tool.path!}
|
||||
isExpanded={isExpanded}
|
||||
onToggleExpand={onToggleExpand}
|
||||
/> */}
|
||||
<div
|
||||
style={{
|
||||
borderRadius: 3,
|
||||
@@ -498,32 +520,6 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
|
||||
/>
|
||||
</>
|
||||
)
|
||||
// case "inspectSite":
|
||||
// const isInspecting =
|
||||
// isLast && lastModifiedMessage?.say === "inspect_site_result" && !lastModifiedMessage?.images
|
||||
// return (
|
||||
// <>
|
||||
// <div style={headerStyle}>
|
||||
// {isInspecting ? <ProgressIndicator /> : toolIcon("inspect")}
|
||||
// <span style={{ fontWeight: "bold" }}>
|
||||
// {message.type === "ask" ? (
|
||||
// <>Cline wants to inspect this website:</>
|
||||
// ) : (
|
||||
// <>Cline is inspecting this website:</>
|
||||
// )}
|
||||
// </span>
|
||||
// </div>
|
||||
// <div
|
||||
// style={{
|
||||
// borderRadius: 3,
|
||||
// border: "1px solid var(--vscode-editorGroup-border)",
|
||||
// overflow: "hidden",
|
||||
// backgroundColor: CODE_BLOCK_BG_COLOR,
|
||||
// }}>
|
||||
// <CodeBlock source={`${"```"}shell\n${tool.path}\n${"```"}`} forceWrap={true} />
|
||||
// </div>
|
||||
// </>
|
||||
// )
|
||||
default:
|
||||
return null
|
||||
}
|
||||
@@ -570,10 +566,6 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
|
||||
{icon}
|
||||
{title}
|
||||
</div>
|
||||
{/* <Terminal
|
||||
rawOutput={command + (output ? "\n" + output : "")}
|
||||
shouldAllowInput={!!isCommandExecuting && output.length > 0}
|
||||
/> */}
|
||||
<div
|
||||
style={{
|
||||
borderRadius: 3,
|
||||
@@ -640,7 +632,6 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
|
||||
{useMcpServer.type === "access_mcp_resource" && (
|
||||
<McpResourceRow
|
||||
item={{
|
||||
// Use the matched resource/template details, with fallbacks
|
||||
...(findMatchingResourceOrTemplate(
|
||||
useMcpServer.uri || "",
|
||||
server?.resources,
|
||||
@@ -650,7 +641,6 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
|
||||
mimeType: "",
|
||||
description: "",
|
||||
}),
|
||||
// Always use the actual URI from the request
|
||||
uri: useMcpServer.uri || "",
|
||||
}}
|
||||
/>
|
||||
@@ -742,6 +732,39 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
|
||||
color: "var(--vscode-errorForeground)",
|
||||
}}>
|
||||
{apiRequestFailedMessage || apiReqStreamingFailedMessage}
|
||||
|
||||
{/* {apiProvider === "" && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
backgroundColor:
|
||||
"color-mix(in srgb, var(--vscode-errorForeground) 20%, transparent)",
|
||||
color: "var(--vscode-editor-foreground)",
|
||||
padding: "6px 8px",
|
||||
borderRadius: "3px",
|
||||
margin: "10px 0 0 0",
|
||||
fontSize: "12px",
|
||||
}}>
|
||||
<i
|
||||
className="codicon codicon-warning"
|
||||
style={{
|
||||
marginRight: 6,
|
||||
fontSize: 16,
|
||||
color: "var(--vscode-errorForeground)",
|
||||
}}></i>
|
||||
<span>
|
||||
Uh-oh this could be a problem on end. We've been alerted and
|
||||
will resolve this ASAP. You can also{" "}
|
||||
<a
|
||||
href=""
|
||||
style={{ color: "inherit", textDecoration: "underline" }}>
|
||||
contact us
|
||||
</a>
|
||||
.
|
||||
</span>
|
||||
</div>
|
||||
)} */}
|
||||
{apiRequestFailedMessage?.toLowerCase().includes("powershell") && (
|
||||
<>
|
||||
<br />
|
||||
@@ -759,39 +782,6 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
|
||||
{/* {apiProvider === "" && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
backgroundColor:
|
||||
"color-mix(in srgb, var(--vscode-errorForeground) 20%, transparent)",
|
||||
color: "var(--vscode-editor-foreground)",
|
||||
padding: "6px 8px",
|
||||
borderRadius: "3px",
|
||||
margin: "10px 0 0 0",
|
||||
fontSize: "12px",
|
||||
}}>
|
||||
<i
|
||||
className="codicon codicon-warning"
|
||||
style={{
|
||||
marginRight: 6,
|
||||
fontSize: 16,
|
||||
color: "var(--vscode-errorForeground)",
|
||||
}}></i>
|
||||
<span>
|
||||
Uh-oh, this could be a problem on end. We've been alerted and
|
||||
will resolve this ASAP. You can also{" "}
|
||||
<a
|
||||
href=""
|
||||
style={{ color: "inherit", textDecoration: "underline" }}>
|
||||
contact us
|
||||
</a>
|
||||
.
|
||||
</span>
|
||||
</div>
|
||||
)} */}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -809,6 +799,8 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
|
||||
)
|
||||
case "api_req_finished":
|
||||
return null // we should never see this message type
|
||||
case "mcp_server_response":
|
||||
return <McpResponseDisplay responseText={message.text || ""} />
|
||||
case "text":
|
||||
return (
|
||||
<div>
|
||||
@@ -825,7 +817,7 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
|
||||
// marginBottom: 15,
|
||||
cursor: "pointer",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
|
||||
|
||||
fontStyle: "italic",
|
||||
overflow: "hidden",
|
||||
}}>
|
||||
@@ -1101,28 +1093,6 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
case "mcp_server_response":
|
||||
return (
|
||||
<>
|
||||
<div style={{ paddingTop: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: "4px",
|
||||
opacity: 0.8,
|
||||
fontSize: "12px",
|
||||
textTransform: "uppercase",
|
||||
}}>
|
||||
Response
|
||||
</div>
|
||||
<CodeAccordian
|
||||
code={message.text}
|
||||
language="json"
|
||||
isExpanded={true}
|
||||
onToggleExpand={onToggleExpand}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
default:
|
||||
return (
|
||||
<>
|
||||
@@ -1174,7 +1144,6 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
|
||||
)
|
||||
case "completion_result":
|
||||
if (message.text) {
|
||||
// FIXME: is this ever even used?
|
||||
const hasChanges = message.text.endsWith(COMPLETION_RESULT_CHANGES_FLAG) ?? false
|
||||
const text = hasChanges ? message.text.slice(0, -COMPLETION_RESULT_CHANGES_FLAG.length) : message.text
|
||||
return (
|
||||
@@ -1247,32 +1216,3 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const ProgressIndicator = () => (
|
||||
<div
|
||||
style={{
|
||||
width: "16px",
|
||||
height: "16px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}>
|
||||
<div style={{ transform: "scale(0.55)", transformOrigin: "center" }}>
|
||||
<VSCodeProgressRing />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const Markdown = memo(({ markdown }: { markdown?: string }) => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
wordBreak: "break-word",
|
||||
overflowWrap: "anywhere",
|
||||
marginBottom: -15,
|
||||
marginTop: -15,
|
||||
}}>
|
||||
<MarkdownBlock markdown={markdown} />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import React, { useEffect, useState } from "react"
|
||||
import { vscode } from "../../utils/vscode"
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
interface OpenGraphData {
|
||||
title?: string
|
||||
description?: string
|
||||
image?: string
|
||||
url?: string
|
||||
siteName?: string
|
||||
type?: string
|
||||
}
|
||||
|
||||
interface LinkPreviewProps {
|
||||
url: string
|
||||
}
|
||||
|
||||
const LinkPreview: React.FC<LinkPreviewProps> = ({ url }) => {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [ogData, setOgData] = useState<OpenGraphData | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchOpenGraphData = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
|
||||
// Send a message to the extension to fetch Open Graph data
|
||||
vscode.postMessage({
|
||||
type: "fetchOpenGraphData",
|
||||
text: url,
|
||||
})
|
||||
|
||||
// Set up a listener for the response
|
||||
const messageListener = (event: MessageEvent) => {
|
||||
const message = event.data
|
||||
if (message.type === "openGraphData" && message.url === url) {
|
||||
setOgData(message.openGraphData)
|
||||
setLoading(false)
|
||||
window.removeEventListener("message", messageListener)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("message", messageListener)
|
||||
|
||||
// Clean up the listener if the component unmounts
|
||||
return () => {
|
||||
window.removeEventListener("message", messageListener)
|
||||
}
|
||||
} catch (err) {
|
||||
setError("Failed to fetch preview data")
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch Open Graph data immediately when component mounts
|
||||
fetchOpenGraphData()
|
||||
}, [url])
|
||||
|
||||
// Fallback display while loading
|
||||
if (loading) {
|
||||
return (
|
||||
<div
|
||||
className="link-preview-loading"
|
||||
style={{
|
||||
padding: "12px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
border: "1px solid var(--vscode-editorWidget-border, rgba(127, 127, 127, 0.3))",
|
||||
borderRadius: "4px",
|
||||
}}>
|
||||
<div
|
||||
className="loading-spinner"
|
||||
style={{
|
||||
marginRight: "8px",
|
||||
width: "16px",
|
||||
height: "16px",
|
||||
border: "2px solid rgba(127, 127, 127, 0.3)",
|
||||
borderTopColor: "var(--vscode-textLink-foreground, #3794ff)",
|
||||
borderRadius: "50%",
|
||||
animation: "spin 1s linear infinite",
|
||||
}}
|
||||
/>
|
||||
<style>
|
||||
{`
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
Loading preview for {new URL(url).hostname}...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Create a fallback object if ogData is null
|
||||
const data = ogData || {
|
||||
title: new URL(url).hostname,
|
||||
description: "No description available",
|
||||
siteName: new URL(url).hostname,
|
||||
url: url,
|
||||
}
|
||||
|
||||
// Render the Open Graph preview
|
||||
return (
|
||||
<div
|
||||
className="link-preview"
|
||||
style={{
|
||||
display: "flex",
|
||||
border: "1px solid var(--vscode-editorWidget-border, rgba(127, 127, 127, 0.3))",
|
||||
borderRadius: "4px",
|
||||
overflow: "hidden",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={() => {
|
||||
vscode.postMessage({
|
||||
type: "openInBrowser",
|
||||
url: DOMPurify.sanitize(url),
|
||||
})
|
||||
}}>
|
||||
{data.image && (
|
||||
<div className="link-preview-image" style={{ width: "128px", height: "128px", flexShrink: 0 }}>
|
||||
<img
|
||||
src={DOMPurify.sanitize(data.image)}
|
||||
alt=""
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="link-preview-content"
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "12px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
}}>
|
||||
<div
|
||||
className="link-preview-title"
|
||||
style={{
|
||||
fontWeight: "bold",
|
||||
marginBottom: "4px",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}>
|
||||
{data.title || "No title"}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="link-preview-url"
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: "var(--vscode-textLink-foreground, #3794ff)",
|
||||
marginBottom: "8px",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}>
|
||||
{data.siteName || new URL(url).hostname}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="link-preview-description"
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: "var(--vscode-descriptionForeground, rgba(204, 204, 204, 0.7))",
|
||||
overflow: "hidden",
|
||||
display: "-webkit-box",
|
||||
WebkitLineClamp: 3,
|
||||
WebkitBoxOrient: "vertical",
|
||||
textOverflow: "ellipsis",
|
||||
}}>
|
||||
{data.description || "No description available"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LinkPreview
|
||||
@@ -0,0 +1,460 @@
|
||||
import React, { useEffect, useState, useCallback } from "react"
|
||||
import { vscode } from "../../utils/vscode"
|
||||
import LinkPreview from "./LinkPreview"
|
||||
import styled from "styled-components"
|
||||
import { CODE_BLOCK_BG_COLOR } from "../common/CodeBlock"
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
// We'll use the backend isImageUrl function for HEAD requests
|
||||
// This is a client-side fallback for data URLs and obvious image extensions
|
||||
const isImageUrlSync = (str: string): boolean => {
|
||||
// Check for data URLs which are definitely images
|
||||
if (str.startsWith("data:image/")) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for common image file extensions
|
||||
return str.match(/\.(jpg|jpeg|png|gif|webp)$/i) !== null
|
||||
}
|
||||
|
||||
export const isUrl = (str: string): boolean => {
|
||||
// Basic URL validation
|
||||
const urlPattern = /^(https?:\/\/)?([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}(\/[^\s]*)?$/
|
||||
return urlPattern.test(str)
|
||||
}
|
||||
|
||||
// Function to check if a URL is an image using HEAD request
|
||||
export const checkIfImageUrl = async (url: string): Promise<boolean> => {
|
||||
// For data URLs, we can check synchronously
|
||||
if (url.startsWith("data:image/")) {
|
||||
return true
|
||||
}
|
||||
|
||||
// For http/https URLs, we need to send a message to the extension
|
||||
if (url.startsWith("http")) {
|
||||
try {
|
||||
// Create a promise that will resolve when we get a response
|
||||
return new Promise((resolve) => {
|
||||
// Set up a one-time listener for the response
|
||||
const messageListener = (event: MessageEvent) => {
|
||||
const message = event.data
|
||||
if (message.type === "isImageUrlResult" && message.url === url) {
|
||||
window.removeEventListener("message", messageListener)
|
||||
resolve(message.isImage)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("message", messageListener)
|
||||
|
||||
// Send the request to the extension
|
||||
vscode.postMessage({
|
||||
type: "checkIsImageUrl",
|
||||
text: url,
|
||||
})
|
||||
|
||||
// Set a timeout to avoid hanging indefinitely
|
||||
setTimeout(() => {
|
||||
window.removeEventListener("message", messageListener)
|
||||
// Fall back to extension check
|
||||
resolve(isImageUrlSync(url))
|
||||
}, 3000)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Error checking if URL is an image:", error)
|
||||
return isImageUrlSync(url)
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to extension check for other URLs
|
||||
return isImageUrlSync(url)
|
||||
}
|
||||
|
||||
// No longer needed as our regex directly extracts the URL part
|
||||
|
||||
// Helper to ensure URL is in a format that can be opened
|
||||
export const formatUrlForOpening = (url: string): string => {
|
||||
// If it's a data URI, return as is
|
||||
if (url.startsWith("data:image/")) {
|
||||
return url
|
||||
}
|
||||
|
||||
// If it's a regular URL but doesn't have a protocol, add https://
|
||||
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
||||
return `https://${url}`
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
// Find all URLs (both image and regular) in an object
|
||||
export const findUrls = async (obj: any): Promise<{ imageUrls: string[]; regularUrls: string[] }> => {
|
||||
const imageUrls: string[] = []
|
||||
const regularUrls: string[] = []
|
||||
const pendingChecks: Promise<void>[] = []
|
||||
|
||||
if (typeof obj === "object" && obj !== null) {
|
||||
for (const value of Object.values(obj)) {
|
||||
if (typeof value === "string") {
|
||||
// First check with synchronous method
|
||||
if (isImageUrlSync(value)) {
|
||||
imageUrls.push(value)
|
||||
} else if (isUrl(value)) {
|
||||
// For URLs that don't obviously look like images, we'll check asynchronously
|
||||
const checkPromise = checkIfImageUrl(value).then((isImage) => {
|
||||
if (isImage) {
|
||||
imageUrls.push(value)
|
||||
} else {
|
||||
regularUrls.push(value)
|
||||
}
|
||||
})
|
||||
pendingChecks.push(checkPromise)
|
||||
}
|
||||
} else if (typeof value === "object") {
|
||||
const nestedUrlsPromise = findUrls(value).then((nestedUrls) => {
|
||||
imageUrls.push(...nestedUrls.imageUrls)
|
||||
regularUrls.push(...nestedUrls.regularUrls)
|
||||
})
|
||||
pendingChecks.push(nestedUrlsPromise)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for all async checks to complete
|
||||
await Promise.all(pendingChecks)
|
||||
|
||||
return { imageUrls, regularUrls }
|
||||
}
|
||||
|
||||
// Extract URLs from text using regex
|
||||
export const extractUrlsFromText = async (text: string): Promise<{ imageUrls: string[]; regularUrls: string[] }> => {
|
||||
const imageUrls: string[] = []
|
||||
const regularUrls: string[] = []
|
||||
const pendingChecks: Promise<void>[] = []
|
||||
|
||||
// Match URLs with image: prefix and extract just the URL part
|
||||
const imageMatches = text.match(/image:\s*(https?:\/\/[^\s]+)/g)
|
||||
if (imageMatches) {
|
||||
// Extract just the URL part from matches with image: prefix
|
||||
const extractedUrls = imageMatches
|
||||
.map((match) => {
|
||||
const urlMatch = /image:\s*(https?:\/\/[^\s]+)/.exec(match)
|
||||
return urlMatch ? urlMatch[1] : null
|
||||
})
|
||||
.filter(Boolean) as string[]
|
||||
|
||||
imageUrls.push(...extractedUrls)
|
||||
}
|
||||
|
||||
// Match all URLs (including those that might be in the middle of paragraphs)
|
||||
const urlMatches = text.match(/https?:\/\/[^\s]+/g)
|
||||
if (urlMatches) {
|
||||
// Filter out URLs that are already in imageUrls
|
||||
const filteredUrls = urlMatches.filter((url) => !imageUrls.includes(url))
|
||||
|
||||
// Check each URL to see if it's an image
|
||||
for (const url of filteredUrls) {
|
||||
// First check with synchronous method
|
||||
if (isImageUrlSync(url)) {
|
||||
imageUrls.push(url)
|
||||
} else {
|
||||
// For URLs that don't obviously look like images, we'll check asynchronously
|
||||
const checkPromise = checkIfImageUrl(url).then((isImage) => {
|
||||
if (isImage) {
|
||||
imageUrls.push(url)
|
||||
} else {
|
||||
regularUrls.push(url)
|
||||
}
|
||||
})
|
||||
pendingChecks.push(checkPromise)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for all async checks to complete
|
||||
await Promise.all(pendingChecks)
|
||||
|
||||
return { imageUrls, regularUrls }
|
||||
}
|
||||
|
||||
const ResponseHeader = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 9px 10px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
border-bottom: 1px dashed var(--vscode-editorGroup-border);
|
||||
margin-bottom: 8px;
|
||||
|
||||
.header-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-right: 8px;
|
||||
}
|
||||
`
|
||||
|
||||
const ToggleSwitch = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
|
||||
.toggle-label {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.toggle-container {
|
||||
position: relative;
|
||||
width: 40px;
|
||||
height: 20px;
|
||||
background-color: var(--vscode-button-secondaryBackground);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.toggle-container.active {
|
||||
background-color: var(--vscode-button-background);
|
||||
}
|
||||
|
||||
.toggle-handle {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background-color: var(--vscode-button-foreground);
|
||||
border-radius: 50%;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.toggle-container.active .toggle-handle {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
`
|
||||
|
||||
const ResponseContainer = styled.div`
|
||||
position: relative;
|
||||
font-family: var(--vscode-editor-font-family, monospace);
|
||||
font-size: var(--vscode-editor-font-size, 12px);
|
||||
background-color: ${CODE_BLOCK_BG_COLOR};
|
||||
color: var(--vscode-editor-foreground, #d4d4d4);
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--vscode-editorGroup-border);
|
||||
overflow: hidden;
|
||||
|
||||
.response-content {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
max-width: 100%;
|
||||
padding: 10px;
|
||||
}
|
||||
`
|
||||
|
||||
// Style for URL text to ensure proper wrapping
|
||||
const UrlText = styled.div`
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
overflow-wrap: break-word;
|
||||
font-family: var(--vscode-editor-font-family, monospace);
|
||||
font-size: var(--vscode-editor-font-size, 12px);
|
||||
`
|
||||
|
||||
interface McpResponseDisplayProps {
|
||||
responseText: string
|
||||
}
|
||||
|
||||
// Represents a URL found in the text with its position and metadata
|
||||
interface UrlMatch {
|
||||
url: string // The actual URL
|
||||
fullMatch: string // The full matched text (including any prefix like "image:")
|
||||
index: number // Position in the text
|
||||
isImage: boolean // Whether this URL is an image
|
||||
isProcessed: boolean // Whether we've already processed this URL (to avoid duplicates)
|
||||
}
|
||||
|
||||
const McpResponseDisplay: React.FC<McpResponseDisplayProps> = ({ responseText }) => {
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [displayMode, setDisplayMode] = useState<"rich" | "plain">(() => {
|
||||
// Get saved preference from localStorage, default to 'rich'
|
||||
const savedMode = localStorage.getItem("mcpDisplayMode")
|
||||
return (savedMode === "plain" ? "plain" : "rich") as "rich" | "plain"
|
||||
})
|
||||
const [urlMatches, setUrlMatches] = useState<UrlMatch[]>([])
|
||||
|
||||
const toggleDisplayMode = useCallback(() => {
|
||||
const newMode = displayMode === "rich" ? "plain" : "rich"
|
||||
setDisplayMode(newMode)
|
||||
localStorage.setItem("mcpDisplayMode", newMode)
|
||||
}, [displayMode])
|
||||
|
||||
// Find all URLs in the text and determine if they're images
|
||||
useEffect(() => {
|
||||
const processResponse = async () => {
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
const text = responseText || ""
|
||||
const matches: UrlMatch[] = []
|
||||
|
||||
const urlRegex = /https?:\/\/[^\s]+/g
|
||||
let urlMatch: RegExpExecArray | null
|
||||
|
||||
while ((urlMatch = urlRegex.exec(text)) !== null) {
|
||||
const url = urlMatch[0]
|
||||
const fullMatch = url
|
||||
|
||||
matches.push({
|
||||
url,
|
||||
fullMatch,
|
||||
index: urlMatch.index,
|
||||
isImage: false, // Will check later
|
||||
isProcessed: false,
|
||||
})
|
||||
}
|
||||
|
||||
// Check if URLs are images
|
||||
for (const match of matches) {
|
||||
match.isImage = await checkIfImageUrl(match.url)
|
||||
}
|
||||
|
||||
// Sort by position in the text
|
||||
matches.sort((a, b) => a.index - b.index)
|
||||
|
||||
setUrlMatches(matches)
|
||||
} catch (error) {
|
||||
console.error("Error processing MCP response:", error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
processResponse()
|
||||
}, [responseText])
|
||||
|
||||
// Function to render content based on display mode
|
||||
const renderContent = () => {
|
||||
// For plain text mode, just show the text
|
||||
if (displayMode === "plain" || isLoading) {
|
||||
return <UrlText>{responseText}</UrlText>
|
||||
}
|
||||
|
||||
// For rich display mode, show the text with embedded content
|
||||
if (displayMode === "rich" && !isLoading) {
|
||||
// Create an array of text segments and embedded content
|
||||
const segments: JSX.Element[] = []
|
||||
let lastIndex = 0
|
||||
let segmentIndex = 0
|
||||
|
||||
// Reset the processed flag for all URLs
|
||||
const processedUrls = new Set<string>()
|
||||
|
||||
// Add the text before the first URL
|
||||
if (urlMatches.length === 0) {
|
||||
segments.push(<UrlText key={`segment-${segmentIndex}`}>{responseText}</UrlText>)
|
||||
} else {
|
||||
for (let i = 0; i < urlMatches.length; i++) {
|
||||
const match = urlMatches[i]
|
||||
const { url, fullMatch, index } = match
|
||||
|
||||
// Add text segment before this URL
|
||||
if (index > lastIndex) {
|
||||
segments.push(
|
||||
<UrlText key={`segment-${segmentIndex++}`}>{responseText.substring(lastIndex, index)}</UrlText>,
|
||||
)
|
||||
}
|
||||
|
||||
// Add the URL text itself
|
||||
segments.push(<UrlText key={`url-${segmentIndex++}`}>{fullMatch}</UrlText>)
|
||||
|
||||
// Calculate the end position of this URL in the text
|
||||
const urlEndIndex = index + fullMatch.length
|
||||
|
||||
// Add embedded content after the URL
|
||||
if (match.isImage) {
|
||||
segments.push(
|
||||
<div key={`embed-${segmentIndex++}`} style={{ margin: "10px 0" }}>
|
||||
<img
|
||||
src={DOMPurify.sanitize(url)}
|
||||
alt={`Image for ${url}`}
|
||||
style={{
|
||||
width: "85%",
|
||||
height: "auto",
|
||||
borderRadius: "4px",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={() => {
|
||||
const formattedUrl = formatUrlForOpening(url)
|
||||
vscode.postMessage({
|
||||
type: "openInBrowser",
|
||||
url: DOMPurify.sanitize(formattedUrl),
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>,
|
||||
)
|
||||
} else if (!processedUrls.has(url)) {
|
||||
// For non-image URLs, only show the preview once
|
||||
segments.push(
|
||||
<div key={`embed-${segmentIndex++}`} style={{ margin: "10px 0" }}>
|
||||
<LinkPreview url={formatUrlForOpening(url)} />
|
||||
</div>,
|
||||
)
|
||||
|
||||
// Mark this URL as processed
|
||||
processedUrls.add(url)
|
||||
}
|
||||
|
||||
// Update lastIndex for next segment
|
||||
lastIndex = urlEndIndex
|
||||
}
|
||||
|
||||
// Add any remaining text after the last URL
|
||||
if (lastIndex < responseText.length) {
|
||||
segments.push(<UrlText key={`segment-${segmentIndex++}`}>{responseText.substring(lastIndex)}</UrlText>)
|
||||
}
|
||||
}
|
||||
|
||||
return <>{segments}</>
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
return (
|
||||
<ResponseContainer>
|
||||
<ResponseHeader>
|
||||
<span className="header-title">Response</span>
|
||||
<ToggleSwitch>
|
||||
<span className="toggle-label">{displayMode === "rich" ? "Rich Display" : "Plain Text"}</span>
|
||||
<div className={`toggle-container ${displayMode === "rich" ? "active" : ""}`} onClick={toggleDisplayMode}>
|
||||
<div className="toggle-handle"></div>
|
||||
</div>
|
||||
</ToggleSwitch>
|
||||
</ResponseHeader>
|
||||
|
||||
<div className="response-content">{renderContent()}</div>
|
||||
</ResponseContainer>
|
||||
)
|
||||
} catch (error) {
|
||||
console.error("Error parsing MCP response:", error)
|
||||
return (
|
||||
<ResponseContainer>
|
||||
<ResponseHeader>
|
||||
<span className="header-title">Response</span>
|
||||
</ResponseHeader>
|
||||
<div className="response-content">
|
||||
<div>Error parsing response:</div>
|
||||
<UrlText>{responseText}</UrlText>
|
||||
</div>
|
||||
</ResponseContainer>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default McpResponseDisplay
|
||||
Reference in New Issue
Block a user