From 447f5951a99eb34b93b5d71f57f612aac28ce26a Mon Sep 17 00:00:00 2001 From: sykuang Date: Mon, 16 Feb 2026 03:13:32 +0800 Subject: [PATCH 1/2] feat: Implement GitHub Copilot integration with device flow authentication - Added GitHub Copilot backend to handle chat requests and token exchanges. - Implemented device flow for GitHub OAuth, allowing users to authenticate via a device code. - Created functions to manage Copilot API tokens, including caching and refreshing. - Updated existing structures to support GitHub Copilot as a new AI provider. - Enhanced the chat request handling to include Copilot-specific headers. - Added commands for device login start, poll, status, and logout in the RPC server. - Updated configuration schema to include GitHub Copilot as a valid provider and API type. - Ensured that Copilot modes are created in the waveai.json configuration file upon successful login. --- frontend/app/aipanel/aimode.tsx | 155 ++++++++++- frontend/app/store/wshclientapi.ts | 20 ++ frontend/types/gotypes.d.ts | 24 ++ pkg/aiusechat/githubcopilot/copilotbackend.go | 122 +++++++++ pkg/aiusechat/githubcopilot/copilottoken.go | 253 ++++++++++++++++++ pkg/aiusechat/githubcopilot/deviceflow.go | 167 ++++++++++++ .../openaichat/openaichat-convertmessage.go | 25 ++ pkg/aiusechat/uctypes/uctypes.go | 16 +- pkg/aiusechat/usechat-backend.go | 46 ++++ pkg/aiusechat/usechat-mode.go | 28 +- pkg/wconfig/settingsconfig.go | 4 +- pkg/wshrpc/wshclient/wshclient.go | 24 ++ pkg/wshrpc/wshrpctypes.go | 27 ++ pkg/wshrpc/wshserver/wshserver.go | 189 +++++++++++++ schema/waveai.json | 4 +- 15 files changed, 1088 insertions(+), 16 deletions(-) create mode 100644 pkg/aiusechat/githubcopilot/copilotbackend.go create mode 100644 pkg/aiusechat/githubcopilot/copilottoken.go create mode 100644 pkg/aiusechat/githubcopilot/deviceflow.go diff --git a/frontend/app/aipanel/aimode.tsx b/frontend/app/aipanel/aimode.tsx index 3602cdd360..9f2f66e43e 100644 --- a/frontend/app/aipanel/aimode.tsx +++ b/frontend/app/aipanel/aimode.tsx @@ -2,12 +2,12 @@ // SPDX-License-Identifier: Apache-2.0 import { Tooltip } from "@/app/element/tooltip"; -import { atoms, getSettingsKeyAtom } from "@/app/store/global"; +import { atoms, getApi, getSettingsKeyAtom } from "@/app/store/global"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { cn, fireAndForget, makeIconClass } from "@/util/util"; import { useAtomValue } from "jotai"; -import { memo, useRef, useState } from "react"; +import { memo, useCallback, useEffect, useRef, useState } from "react"; import { getFilteredAIModeConfigs, getModeDisplayName } from "./ai-utils"; import { WaveAIModel } from "./waveai-model"; @@ -147,6 +147,91 @@ export const AIModeDropdown = memo(({ compatibilityMode = false }: AIModeDropdow const telemetryEnabled = useAtomValue(getSettingsKeyAtom("telemetry:enabled")) ?? false; const [isOpen, setIsOpen] = useState(false); const dropdownRef = useRef(null); + const copilotLoginAbortRef = useRef(null); + const [copilotLoggedIn, setCopilotLoggedIn] = useState(false); + const [copilotLoginState, setCopilotLoginState] = useState< + | { status: "idle" } + | { status: "waiting"; userCode: string; verificationUri: string } + | { status: "polling" } + | { status: "complete" } + | { status: "error"; message: string } + >({ status: "idle" }); + + // Check Copilot login status on mount and when dropdown opens + useEffect(() => { + fireAndForget(async () => { + try { + const status = await RpcApi.CopilotDeviceLoginStatusCommand(TabRpcClient); + setCopilotLoggedIn(status.loggedin); + } catch { + // ignore errors + } + }); + }, [isOpen]); + + const handleCopilotLogin = useCallback(() => { + const abortController = new AbortController(); + copilotLoginAbortRef.current = abortController; + fireAndForget(async () => { + setCopilotLoginState({ status: "polling" }); + try { + const startData = await RpcApi.CopilotDeviceLoginStartCommand(TabRpcClient); + if (abortController.signal.aborted) return; + setCopilotLoginState({ + status: "waiting", + userCode: startData.usercode, + verificationUri: startData.verificationuri, + }); + // Copy code to clipboard and open browser + await navigator.clipboard.writeText(startData.usercode); + getApi().openExternal(startData.verificationuri); + // Now poll for completion + const pollResult = await RpcApi.CopilotDeviceLoginPollCommand(TabRpcClient, {}, { timeout: 15 * 60 * 1000 }); + if (abortController.signal.aborted) return; + if (pollResult.status === "complete") { + setCopilotLoginState({ status: "complete" }); + setCopilotLoggedIn(true); + // Auto-switch to the copilot mode if one was created + if (pollResult.modename) { + // Wait briefly for config watcher to pick up the new mode + setTimeout(() => { + model.setAIMode(pollResult.modename); + }, 500); + } + setTimeout(() => setCopilotLoginState({ status: "idle" }), 3000); + } else { + setCopilotLoginState({ + status: "error", + message: pollResult.error || "Login failed", + }); + setTimeout(() => setCopilotLoginState({ status: "idle" }), 5000); + } + } catch (e: any) { + if (abortController.signal.aborted) return; + setCopilotLoginState({ status: "error", message: e.message || "Login failed" }); + setTimeout(() => setCopilotLoginState({ status: "idle" }), 5000); + } + }); + }, []); + + const handleCopilotLoginCancel = useCallback(() => { + if (copilotLoginAbortRef.current) { + copilotLoginAbortRef.current.abort(); + copilotLoginAbortRef.current = null; + } + setCopilotLoginState({ status: "idle" }); + }, []); + + const handleCopilotLogout = useCallback(() => { + fireAndForget(async () => { + try { + await RpcApi.CopilotDeviceLogoutCommand(TabRpcClient); + setCopilotLoggedIn(false); + } catch (e: any) { + console.error("Failed to logout from Copilot:", e); + } + }); + }, []); const { waveProviderConfigs, otherProviderConfigs } = getFilteredAIModeConfigs( aiModeConfigs, @@ -312,6 +397,72 @@ export const AIModeDropdown = memo(({ compatibilityMode = false }: AIModeDropdow New Chat +
+ {copilotLoginState.status === "idle" && !copilotLoggedIn && ( + + )} + {copilotLoginState.status === "idle" && copilotLoggedIn && ( +
+
+ + Copilot Connected +
+ +
+ )} + {copilotLoginState.status === "polling" && ( +
+ + Starting login... +
+ )} + {copilotLoginState.status === "waiting" && ( +
+
+ + {copilotLoginState.userCode} +
+
+ Code copied! Paste it on GitHub. +
+
+
+ + Waiting for authorization... +
+ +
+
+ )} + {copilotLoginState.status === "complete" && ( +
+ + Logged in to GitHub Copilot! +
+ )} + {copilotLoginState.status === "error" && ( +
+ + {copilotLoginState.message} +
+ )} +