Przeglądaj źródła

Merge pull request #1267 from NyxJae/human-relay

Human relay
Matt Rubens 10 miesięcy temu
rodzic
commit
08a449eb8f

+ 55 - 3
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<RegisterComman
 
 	const targetCol = hasVisibleEditors ? Math.max(lastCol + 1, 1) : vscode.ViewColumn.Two
 
-	const panel = vscode.window.createWebviewPanel(ClineProvider.tabPanelId, "Roo Code", targetCol, {
+	const newPanel = vscode.window.createWebviewPanel(ClineProvider.tabPanelId, "Roo Code", targetCol, {
 		enableScripts: true,
 		retainContextWhenHidden: true,
 		localResourceRoots: [context.extensionUri],
 	})
 
+	// Save as tab type panel
+	setPanel(newPanel, "tab")
+
 	// TODO: use better svg icon with light and dark variants (see
 	// https://stackoverflow.com/questions/58365687/vscode-extension-iconpath).
-	panel.iconPath = {
+	newPanel.iconPath = {
 		light: vscode.Uri.joinPath(context.extensionUri, "assets", "icons", "rocket.png"),
 		dark: vscode.Uri.joinPath(context.extensionUri, "assets", "icons", "rocket.png"),
 	}
 
-	await tabProvider.resolveWebviewView(panel)
+	await tabProvider.resolveWebviewView(newPanel)
+
+	// Handle panel closing events
+	newPanel.onDidDispose(() => {
+		setPanel(undefined, "tab")
+	})
 
 	// Lock the editor group so clicking on files doesn't open them over the panel
 	await delay(100)

+ 3 - 0
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<string>
@@ -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)
 	}

+ 139 - 0
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<Anthropic.Messages.ContentBlockParam>): Promise<number> {
+		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<string> {
+		// 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<string | undefined> {
+	return new Promise<string | undefined>((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,
+		})
+	})
+}

+ 29 - 0
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,

+ 46 - 0
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<string, (response: string | undefined) => 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

+ 21 - 0
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"

+ 15 - 0
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({

+ 1 - 0
src/shared/api.ts

@@ -16,6 +16,7 @@ export type ApiProvider =
 	| "mistral"
 	| "unbound"
 	| "requesty"
+	| "human-relay"
 
 export interface ApiHandlerOptions {
 	apiModelId?: string

+ 53 - 0
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<Tab>("chat")
 	const settingsRef = useRef<SettingsViewRef>(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 */}
+			<HumanRelayDialog
+				isOpen={humanRelayDialogState.isOpen}
+				requestId={humanRelayDialogState.requestId}
+				promptText={humanRelayDialogState.promptText}
+				onClose={() => setHumanRelayDialogState((prev) => ({ ...prev, isOpen: false }))}
+				onSubmit={handleHumanRelaySubmit}
+				onCancel={handleHumanRelayCancel}
+			/>
 		</>
 	)
 }

+ 113 - 0
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<HumanRelayDialogProps> = ({
+	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 (
+		<Dialog open={isOpen} onOpenChange={(open) => !open && handleCancel()}>
+			<DialogContent className="sm:max-w-[600px]">
+				<DialogHeader>
+					<DialogTitle>Human Relay - Please Help Copy and Paste Information</DialogTitle>
+					<DialogDescription>
+						Please copy the text below to the web AI, then paste the AI's response into the input box below.
+					</DialogDescription>
+				</DialogHeader>
+
+				<div className="grid gap-4 py-4">
+					<div className="relative">
+						<Textarea
+							className="min-h-[200px] font-mono text-sm p-4 pr-12 whitespace-pre-wrap"
+							value={promptText}
+							readOnly
+						/>
+						<Button variant="ghost" size="icon" className="absolute top-2 right-2" onClick={handleCopy}>
+							{isCopyClicked ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
+						</Button>
+					</div>
+
+					{isCopyClicked && <div className="text-sm text-emerald-500 font-medium">Copied to clipboard</div>}
+
+					<div>
+						<div className="mb-2 font-medium">Please enter the AI's response:</div>
+						<Textarea
+							placeholder="Paste the AI's response here..."
+							value={response}
+							onChange={(e) => setResponse(e.target.value)}
+							className="min-h-[150px]"
+						/>
+					</div>
+				</div>
+
+				<DialogFooter>
+					<Button variant="outline" onClick={handleCancel} className="gap-1">
+						<X className="h-4 w-4" />
+						Cancel
+					</Button>
+					<Button onClick={handleSubmit} disabled={!response.trim()} className="gap-1">
+						<Check className="h-4 w-4" />
+						Submit
+					</Button>
+				</DialogFooter>
+			</DialogContent>
+		</Dialog>
+	)
+}

+ 27 - 0
webview-ui/src/components/settings/ApiOptions.tsx

@@ -257,6 +257,7 @@ const ApiOptions = ({
 						{ value: "ollama", label: "Ollama" },
 						{ value: "unbound", label: "Unbound" },
 						{ value: "requesty", label: "Requesty" },
+						{ value: "human-relay", label: "Human Relay" },
 					]}
 				/>
 			</div>
@@ -1378,6 +1379,32 @@ const ApiOptions = ({
 				</div>
 			)}
 
+			{selectedProvider === "human-relay" && (
+				<div>
+					<p
+						style={{
+							fontSize: "12px",
+							marginTop: 5,
+							color: "var(--vscode-descriptionForeground)",
+							lineHeight: "1.4",
+						}}>
+						The API key is not required, but the user needs to help copy and paste the information to the
+						web chat AI.
+					</p>
+					<p
+						style={{
+							fontSize: "12px",
+							marginTop: 10,
+							color: "var(--vscode-descriptionForeground)",
+							lineHeight: "1.4",
+						}}>
+						During use, a dialog box will pop up and the current message will be copied to the clipboard
+						automatically. You need to paste these to web versions of AI (such as ChatGPT or Claude), then
+						copy the AI's reply back to the dialog box and click the confirm button.
+					</p>
+				</div>
+			)}
+
 			{selectedProvider === "openrouter" && (
 				<ModelPicker
 					apiConfiguration={apiConfiguration}