diff --git a/src/activate/registerCommands.ts b/src/activate/registerCommands.ts index 69e257e7a5..4aeb87168c 100644 --- a/src/activate/registerCommands.ts +++ b/src/activate/registerCommands.ts @@ -3,6 +3,34 @@ import delay from "delay" import { ClineProvider } from "../core/webview/ClineProvider" +// Store panel references in both modes +let sidebarPanel: vscode.WebviewView | undefined = undefined +let tabPanel: vscode.WebviewPanel | undefined = undefined + +/** + * Get the currently active panel + * @returns WebviewPanelꈖWebviewView + */ +export function getPanel(): vscode.WebviewPanel | vscode.WebviewView | undefined { + return tabPanel || sidebarPanel +} + +/** + * Set panel references + */ +export function setPanel( + newPanel: vscode.WebviewPanel | vscode.WebviewView | undefined, + type: "sidebar" | "tab", +): void { + if (type === "sidebar") { + sidebarPanel = newPanel as vscode.WebviewView + tabPanel = undefined + } else { + tabPanel = newPanel as vscode.WebviewPanel + sidebarPanel = undefined + } +} + export type RegisterCommandOptions = { context: vscode.ExtensionContext outputChannel: vscode.OutputChannel @@ -15,6 +43,22 @@ export const registerCommands = (options: RegisterCommandOptions) => { for (const [command, callback] of Object.entries(getCommandsMap(options))) { context.subscriptions.push(vscode.commands.registerCommand(command, callback)) } + + // Human Relay Dialog Command + context.subscriptions.push( + vscode.commands.registerCommand( + "roo-cline.showHumanRelayDialog", + (params: { requestId: string; promptText: string }) => { + if (getPanel()) { + getPanel()?.webview.postMessage({ + type: "showHumanRelayDialog", + requestId: params.requestId, + promptText: params.promptText, + }) + } + }, + ), + ) } const getCommandsMap = ({ context, outputChannel, provider }: RegisterCommandOptions) => { @@ -65,20 +109,28 @@ const openClineInNewTab = async ({ context, outputChannel }: Omit { + setPanel(undefined, "tab") + }) // Lock the editor group so clicking on files doesn't open them over the panel await delay(100) diff --git a/src/api/index.ts b/src/api/index.ts index bcbd710a55..cf8085e289 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -19,6 +19,7 @@ import { VsCodeLmHandler } from "./providers/vscode-lm" import { ApiStream } from "./transform/stream" import { UnboundHandler } from "./providers/unbound" import { RequestyHandler } from "./providers/requesty" +import { HumanRelayHandler } from "./providers/human-relay" export interface SingleCompletionHandler { completePrompt(prompt: string): Promise @@ -72,6 +73,8 @@ export function buildApiHandler(configuration: ApiConfiguration): ApiHandler { return new UnboundHandler(options) case "requesty": return new RequestyHandler(options) + case "human-relay": + return new HumanRelayHandler(options) default: return new AnthropicHandler(options) } diff --git a/src/api/providers/human-relay.ts b/src/api/providers/human-relay.ts new file mode 100644 index 0000000000..b8bd4c2829 --- /dev/null +++ b/src/api/providers/human-relay.ts @@ -0,0 +1,139 @@ +// filepath: e:\Project\Roo-Code\src\api\providers\human-relay.ts +import { Anthropic } from "@anthropic-ai/sdk" +import { ApiHandlerOptions, ModelInfo } from "../../shared/api" +import { ApiHandler, SingleCompletionHandler } from "../index" +import { ApiStream } from "../transform/stream" +import * as vscode from "vscode" +import { ExtensionMessage } from "../../shared/ExtensionMessage" +import { getPanel } from "../../activate/registerCommands" // Import the getPanel function + +/** + * Human Relay API processor + * This processor does not directly call the API, but interacts with the model through human operations copy and paste. + */ +export class HumanRelayHandler implements ApiHandler, SingleCompletionHandler { + private options: ApiHandlerOptions + + constructor(options: ApiHandlerOptions) { + this.options = options + } + countTokens(content: Array): Promise { + return Promise.resolve(0) + } + + /** + * Create a message processing flow, display a dialog box to request human assistance + * @param systemPrompt System prompt words + * @param messages Message list + */ + async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream { + // Get the most recent user message + const latestMessage = messages[messages.length - 1] + + if (!latestMessage) { + throw new Error("No message to relay") + } + + // If it is the first message, splice the system prompt word with the user message + let promptText = "" + if (messages.length === 1) { + promptText = `${systemPrompt}\n\n${getMessageContent(latestMessage)}` + } else { + promptText = getMessageContent(latestMessage) + } + + // Copy to clipboard + await vscode.env.clipboard.writeText(promptText) + + // A dialog box pops up to request user action + const response = await showHumanRelayDialog(promptText) + + if (!response) { + // The user canceled the operation + throw new Error("Human relay operation cancelled") + } + + // Return to the user input reply + yield { type: "text", text: response } + } + + /** + * Get model information + */ + getModel(): { id: string; info: ModelInfo } { + // Human relay does not depend on a specific model, here is a default configuration + return { + id: "human-relay", + info: { + maxTokens: 16384, + contextWindow: 100000, + supportsImages: true, + supportsPromptCache: false, + supportsComputerUse: true, + inputPrice: 0, + outputPrice: 0, + description: "Calling web-side AI model through human relay", + }, + } + } + + /** + * Implementation of a single prompt + * @param prompt Prompt content + */ + async completePrompt(prompt: string): Promise { + // Copy to clipboard + await vscode.env.clipboard.writeText(prompt) + + // A dialog box pops up to request user action + const response = await showHumanRelayDialog(prompt) + + if (!response) { + throw new Error("Human relay operation cancelled") + } + + return response + } +} + +/** + * Extract text content from message object + * @param message + */ +function getMessageContent(message: Anthropic.Messages.MessageParam): string { + if (typeof message.content === "string") { + return message.content + } else if (Array.isArray(message.content)) { + return message.content + .filter((item) => item.type === "text") + .map((item) => (item.type === "text" ? item.text : "")) + .join("\n") + } + return "" +} +/** + * Displays the human relay dialog and waits for user response. + * @param promptText The prompt text that needs to be copied. + * @returns The user's input response or undefined (if canceled). + */ +async function showHumanRelayDialog(promptText: string): Promise { + return new Promise((resolve) => { + // Create a unique request ID + const requestId = Date.now().toString() + + // Register a global callback function + vscode.commands.executeCommand( + "roo-cline.registerHumanRelayCallback", + requestId, + (response: string | undefined) => { + resolve(response) + }, + ) + + // Open the dialog box directly using the current panel + vscode.commands.executeCommand("roo-cline.showHumanRelayDialog", { + requestId, + promptText, + }) + }) +} diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index a09f18d278..8d8732bdd7 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -7,6 +7,7 @@ import pWaitFor from "p-wait-for" import * as path from "path" import * as vscode from "vscode" import simpleGit from "simple-git" +import { setPanel } from "../../activate/registerCommands" import { ApiConfiguration, ApiProvider, ModelInfo, API_CONFIG_KEYS } from "../../shared/api" import { CheckpointStorage } from "../../shared/checkpoints" @@ -237,6 +238,15 @@ export class ClineProvider implements vscode.WebviewViewProvider { this.outputChannel.appendLine("Resolving webview view") this.view = webviewView + // Set panel reference according to webview type + if ("onDidChangeViewState" in webviewView) { + // Tag page type + setPanel(webviewView, "tab") + } else if ("onDidChangeVisibility" in webviewView) { + // Sidebar Type + setPanel(webviewView, "sidebar") + } + // Initialize sound enabled state this.getState().then(({ soundEnabled }) => { setSoundEnabled(soundEnabled ?? false) @@ -1558,6 +1568,25 @@ export class ClineProvider implements vscode.WebviewViewProvider { await this.updateGlobalState("mode", defaultModeSlug) await this.postStateToWebview() } + break + case "humanRelayResponse": + if (message.requestId && message.text) { + vscode.commands.executeCommand("roo-cline.handleHumanRelayResponse", { + requestId: message.requestId, + text: message.text, + cancelled: false, + }) + } + break + + case "humanRelayCancel": + if (message.requestId) { + vscode.commands.executeCommand("roo-cline.handleHumanRelayResponse", { + requestId: message.requestId, + cancelled: true, + }) + } + break } }, null, diff --git a/src/extension.ts b/src/extension.ts index 3a91e990a3..fa6a148435 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -19,6 +19,18 @@ import { McpServerManager } from "./services/mcp/McpServerManager" let outputChannel: vscode.OutputChannel let extensionContext: vscode.ExtensionContext +// Callback mapping of human relay response +const humanRelayCallbacks = new Map void>() + +/** + * Register a callback function for human relay response + * @param requestId + * @param callback + */ +export function registerHumanRelayCallback(requestId: string, callback: (response: string | undefined) => void): void { + humanRelayCallbacks.set(requestId, callback) +} + // This method is called when your extension is activated. // Your extension is activated the very first time the command is executed. export function activate(context: vscode.ExtensionContext) { @@ -45,6 +57,40 @@ export function activate(context: vscode.ExtensionContext) { registerCommands({ context, outputChannel, provider: sidebarProvider }) + // Register human relay callback registration command + context.subscriptions.push( + vscode.commands.registerCommand( + "roo-cline.registerHumanRelayCallback", + (requestId: string, callback: (response: string | undefined) => void) => { + registerHumanRelayCallback(requestId, callback) + }, + ), + ) + + // Register human relay response processing command + context.subscriptions.push( + vscode.commands.registerCommand( + "roo-cline.handleHumanRelayResponse", + (response: { requestId: string; text?: string; cancelled?: boolean }) => { + const callback = humanRelayCallbacks.get(response.requestId) + if (callback) { + if (response.cancelled) { + callback(undefined) + } else { + callback(response.text) + } + humanRelayCallbacks.delete(response.requestId) + } + }, + ), + ) + + context.subscriptions.push( + vscode.commands.registerCommand("roo-cline.unregisterHumanRelayCallback", (requestId: string) => { + humanRelayCallbacks.delete(requestId) + }), + ) + /** * We use the text document content provider API to show the left side for diff * view by creating a virtual document for the original content. This makes it diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index ff9e2a24df..6f43cc65d5 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -46,6 +46,9 @@ export interface ExtensionMessage { | "updateCustomMode" | "deleteCustomMode" | "currentCheckpointUpdated" + | "showHumanRelayDialog" + | "humanRelayResponse" + | "humanRelayCancel" | "browserToolEnabled" text?: string action?: @@ -243,4 +246,22 @@ export interface ClineApiReqInfo { streamingFailedMessage?: string } +// Human relay related message types +export interface ShowHumanRelayDialogMessage { + type: "showHumanRelayDialog" + requestId: string + promptText: string +} + +export interface HumanRelayResponseMessage { + type: "humanRelayResponse" + requestId: string + text: string +} + +export interface HumanRelayCancelMessage { + type: "humanRelayCancel" + requestId: string +} + export type ClineApiReqCancelReason = "streaming_failed" | "user_cancelled" diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 19e3d3bac3..9592b559e2 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -95,6 +95,8 @@ export interface WebviewMessage { | "checkpointRestore" | "deleteMcpServer" | "maxOpenTabsContext" + | "humanRelayResponse" + | "humanRelayCancel" | "browserToolEnabled" text?: string disabled?: boolean @@ -119,6 +121,19 @@ export interface WebviewMessage { timeout?: number payload?: WebViewMessagePayload source?: "global" | "project" + requestId?: string +} + +// Human relay related message types +export interface HumanRelayResponseMessage extends WebviewMessage { + type: "humanRelayResponse" + requestId: string + text: string +} + +export interface HumanRelayCancelMessage extends WebviewMessage { + type: "humanRelayCancel" + requestId: string } export const checkoutDiffPayloadSchema = z.object({ diff --git a/src/shared/api.ts b/src/shared/api.ts index 981fcf8d76..d066039850 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -16,6 +16,7 @@ export type ApiProvider = | "mistral" | "unbound" | "requesty" + | "human-relay" export interface ApiHandlerOptions { apiModelId?: string diff --git a/webview-ui/src/App.tsx b/webview-ui/src/App.tsx index 3ae441cd52..8c37236adb 100644 --- a/webview-ui/src/App.tsx +++ b/webview-ui/src/App.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react" import { useEvent } from "react-use" import { ExtensionMessage } from "../../src/shared/ExtensionMessage" +import { ShowHumanRelayDialogMessage } from "../../src/shared/ExtensionMessage" import { vscode } from "./utils/vscode" import { ExtensionStateContextProvider, useExtensionState } from "./context/ExtensionStateContext" @@ -11,6 +12,7 @@ import SettingsView, { SettingsViewRef } from "./components/settings/SettingsVie import WelcomeView from "./components/welcome/WelcomeView" import McpView from "./components/mcp/McpView" import PromptsView from "./components/prompts/PromptsView" +import { HumanRelayDialog } from "./components/human-relay/HumanRelayDialog" type Tab = "settings" | "history" | "mcp" | "prompts" | "chat" @@ -28,6 +30,17 @@ const App = () => { const [tab, setTab] = useState("chat") const settingsRef = useRef(null) + // Human Relay Dialog Status + const [humanRelayDialogState, setHumanRelayDialogState] = useState<{ + isOpen: boolean + requestId: string + promptText: string + }>({ + isOpen: false, + requestId: "", + promptText: "", + }) + const switchTab = useCallback((newTab: Tab) => { if (settingsRef.current?.checkUnsaveChanges) { settingsRef.current.checkUnsaveChanges(() => setTab(newTab)) @@ -47,10 +60,36 @@ const App = () => { switchTab(newTab) } } + const mes: ShowHumanRelayDialogMessage = message as ShowHumanRelayDialogMessage + // Processing displays human relay dialog messages + if (mes.type === "showHumanRelayDialog" && mes.requestId && mes.promptText) { + setHumanRelayDialogState({ + isOpen: true, + requestId: mes.requestId, + promptText: mes.promptText, + }) + } }, [switchTab], ) + // Processing Human Relay Dialog Submission + const handleHumanRelaySubmit = (requestId: string, text: string) => { + vscode.postMessage({ + type: "humanRelayResponse", + requestId, + text, + }) + } + + // Handle Human Relay dialog box cancel + const handleHumanRelayCancel = (requestId: string) => { + vscode.postMessage({ + type: "humanRelayCancel", + requestId, + }) + } + useEvent("message", onMessage) useEffect(() => { @@ -60,6 +99,11 @@ const App = () => { } }, [shouldShowAnnouncement]) + // Tell Extension that we are ready to receive messages + useEffect(() => { + vscode.postMessage({ type: "webviewDidLaunch" }) + }, []) + if (!didHydrateState) { return null } @@ -80,6 +124,15 @@ const App = () => { hideAnnouncement={() => setShowAnnouncement(false)} showHistoryView={() => switchTab("history")} /> + {/* Human Relay Dialog */} + setHumanRelayDialogState((prev) => ({ ...prev, isOpen: false }))} + onSubmit={handleHumanRelaySubmit} + onCancel={handleHumanRelayCancel} + /> ) } diff --git a/webview-ui/src/components/human-relay/HumanRelayDialog.tsx b/webview-ui/src/components/human-relay/HumanRelayDialog.tsx new file mode 100644 index 0000000000..61d4cbe213 --- /dev/null +++ b/webview-ui/src/components/human-relay/HumanRelayDialog.tsx @@ -0,0 +1,113 @@ +import * as React from "react" +import { Button } from "../ui/button" +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../ui/dialog" +import { Textarea } from "../ui/textarea" +import { useClipboard } from "../ui/hooks" +import { Check, Copy, X } from "lucide-react" + +interface HumanRelayDialogProps { + isOpen: boolean + onClose: () => void + requestId: string + promptText: string + onSubmit: (requestId: string, text: string) => void + onCancel: (requestId: string) => void +} + +/** + * Human Relay Dialog Component + * Displays the prompt text that needs to be copied and provides an input box for the user to paste the AI's response. + */ +export const HumanRelayDialog: React.FC = ({ + isOpen, + onClose, + requestId, + promptText, + onSubmit, + onCancel, +}) => { + const [response, setResponse] = React.useState("") + const { copy } = useClipboard() + const [isCopyClicked, setIsCopyClicked] = React.useState(false) + + // Listen to isOpen changes, clear the input box when the dialog box is opened + React.useEffect(() => { + if (isOpen) { + setResponse("") + setIsCopyClicked(false) + } + }, [isOpen]) + + // Copy to clipboard and show a success message + const handleCopy = () => { + copy(promptText) + setIsCopyClicked(true) + setTimeout(() => { + setIsCopyClicked(false) + }, 2000) + } + + // Submit the response + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + if (response.trim()) { + onSubmit(requestId, response) + onClose() + } + } + + // Cancel the operation + const handleCancel = () => { + onCancel(requestId) + onClose() + } + + return ( + !open && handleCancel()}> + + + Human Relay - Please Help Copy and Paste Information + + Please copy the text below to the web AI, then paste the AI's response into the input box below. + + + +
+
+