feat: Added human relay function and related message processing initial version

This commit is contained in:
Felix NyxJae 2025-02-27 18:08:05 +08:00
parent 931af8fcc4
commit 3bb1d78c17
12 changed files with 483 additions and 3 deletions

5
.gitignore vendored
View file

@ -28,3 +28,8 @@ docs/_site/
#Logging
logs
.clinerules-architect
.clinerules-ask
.clinerules-code
MemoryBank
.github/copilot-instructions.md

View file

@ -3,6 +3,19 @@ import delay from "delay"
import { ClineProvider } from "../core/webview/ClineProvider"
// Add a global variable to store panel references
let panel: vscode.WebviewPanel | undefined = undefined
// Get the panel function for command access
export function getPanel(): vscode.WebviewPanel | undefined {
return panel
}
// Setting the function of the panel
export function setPanel(newPanel: vscode.WebviewPanel | undefined): void {
panel = newPanel
}
export type RegisterCommandOptions = {
context: vscode.ExtensionContext
outputChannel: vscode.OutputChannel
@ -15,6 +28,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-code.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 +94,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 panel references
setPanel(newPanel)
// 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)
})
// Lock the editor group so clicking on files doesn't open them over the panel
await delay(100)

View file

@ -16,6 +16,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>
@ -59,6 +60,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,162 @@
// 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"
/**
* 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
}
/**
* 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 callback to the global callback map
vscode.commands.executeCommand(
"roo-code.registerHumanRelayCallback",
requestId,
(response: string | undefined) => {
resolve(response)
},
)
// Show the WebView dialog
vscode.commands.executeCommand("roo-code.showHumanRelayDialog", {
requestId,
promptText,
})
// Provide a temporary UI in case the WebView fails to load
vscode.window
.showInformationMessage(
"Please paste the copied message to the AI, then copy the response back into the dialog",
{
modal: true,
detail: "The message has been copied to the clipboard. If the dialog does not open, please try using the input box.",
},
"Use Input Box",
)
.then((selection) => {
if (selection === "Use Input Box") {
// Unregister the callback
vscode.commands.executeCommand("roo-code.unregisterHumanRelayCallback", requestId)
vscode.window
.showInputBox({
prompt: "Please paste the AI's response here",
placeHolder: "Paste the AI's response here...",
ignoreFocusOut: true,
})
.then((input) => {
resolve(input || undefined)
})
}
})
})
}

View file

@ -1522,7 +1522,26 @@ export class ClineProvider implements vscode.WebviewViewProvider {
// Switch back to default mode after deletion
await this.updateGlobalState("mode", defaultModeSlug)
await this.postStateToWebview()
break
}
case "humanRelayResponse":
if (message.requestId && message.text) {
vscode.commands.executeCommand("roo-code.handleHumanRelayResponse", {
requestId: message.requestId,
text: message.text,
cancelled: false,
})
}
break
case "humanRelayCancel":
if (message.requestId) {
vscode.commands.executeCommand("roo-code.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,30 @@ export function activate(context: vscode.ExtensionContext) {
registerCommands({ context, outputChannel, provider: sidebarProvider })
// Register human relay response processing command
context.subscriptions.push(
vscode.commands.registerCommand(
"roo-code.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-code.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

@ -45,6 +45,9 @@ export interface ExtensionMessage {
| "updateCustomMode"
| "deleteCustomMode"
| "currentCheckpointUpdated"
| "showHumanRelayDialog"
| "humanRelayResponse"
| "humanRelayCancel"
text?: string
action?:
| "chatButtonClicked"
@ -239,4 +242,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

@ -94,6 +94,8 @@ export interface WebviewMessage {
| "checkpointRestore"
| "deleteMcpServer"
| "maxOpenTabsContext"
| "HumanRelayResponseMessage"
| "HumanRelayCancelMessage"
text?: string
disabled?: boolean
askResponse?: ClineAskResponse
@ -119,6 +121,18 @@ export interface WebviewMessage {
source?: "global" | "project"
}
// Human relay related message types
export interface HumanRelayResponseMessage {
type: "humanRelayResponse"
requestId: string
text: string
}
export interface HumanRelayCancelMessage {
type: "humanRelayCancel"
requestId: string
}
export const checkoutDiffPayloadSchema = z.object({
ts: z.number(),
commitHash: z.string(),

View file

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

View file

@ -11,6 +11,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 +29,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 +59,36 @@ const App = () => {
switchTab(newTab)
}
}
// Processing displays human relay dialog messages
if (message.type === "showHumanRelayDialog" && message.requestId && message.promptText) {
setHumanRelayDialogState({
isOpen: true,
requestId: message.requestId,
promptText: message.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 +98,11 @@ const App = () => {
}
}, [shouldShowAnnouncement])
// Tell Extension that we are ready to receive messages
useEffect(() => {
vscode.postMessage({ type: "webviewDidLaunch" })
}, [])
if (!didHydrateState) {
return null
}
@ -80,6 +123,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,105 @@
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 { onCopy } = useClipboard(promptText)
const [isCopyClicked, setIsCopyClicked] = React.useState(false)
// Copy to clipboard and show a success message
const handleCopy = () => {
onCopy()
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

@ -259,6 +259,7 @@ const ApiOptions = ({
{ value: "ollama", label: "Ollama" },
{ value: "unbound", label: "Unbound" },
{ value: "requesty", label: "Requesty" },
{ value: "human-relay", label: "Human Relay" },
]}
/>
</div>
@ -1307,6 +1308,30 @@ const ApiOptions = ({
</div>
)}
{selectedProvider === "human-relay" && (
<div>
<p
style={{
fontSize: "12px",
marginTop: 5,
color: "var(--vscode-descriptionForeground)",
lineHeight: "1.4",
}}>
API keyweb的聊天AI
</p>
<p
style={{
fontSize: "12px",
marginTop: 10,
color: "var(--vscode-descriptionForeground)",
lineHeight: "1.4",
}}>
使AIChatGPT或Claude
AI的回复复制回对话框中点击确认按钮
</p>
</div>
)}
{selectedProvider === "openrouter" && (
<ModelPicker
apiConfiguration={apiConfiguration}