Merge pull request #1267 from NyxJae/human-relay

Human relay
This commit is contained in:
Matt Rubens 2025-03-05 11:13:16 -05:00 committed by GitHub
commit 08a449eb8f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 502 additions and 3 deletions

View file

@ -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)

View file

@ -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)
}

View file

@ -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,
})
})
}

View file

@ -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,

View file

@ -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

View file

@ -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"

View file

@ -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({

View file

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

View file

@ -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}
/>
</>
)
}

View file

@ -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>
)
}

View file

@ -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}