diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 196484f5..0c7d23e7 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -9,6 +9,7 @@ import { titleBarOverlayDark, titleBarOverlayLight } from './config' import AppUpdater from './services/AppUpdater' import BackupManager from './services/BackupManager' import { configManager } from './services/ConfigManager' +import CopilotService from './services/CopilotService' import { ExportService } from './services/ExportService' import FileService from './services/FileService' import FileStorage from './services/FileStorage' @@ -252,6 +253,13 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { mainWindow?.webContents.send('mcp:servers-updated', servers) }) - // Clean up MCP services when app quits app.on('before-quit', () => mcpService.cleanup()) + + //copilot + ipcMain.handle('copilot:get-auth-message', CopilotService.getAuthMessage) + ipcMain.handle('copilot:get-copilot-token', CopilotService.getCopilotToken) + ipcMain.handle('copilot:save-copilot-token', CopilotService.saveCopilotToken) + ipcMain.handle('copilot:get-token', CopilotService.getToken) + ipcMain.handle('copilot:logout', CopilotService.logout) + ipcMain.handle('copilot:get-user', CopilotService.getUser) } diff --git a/src/main/services/CopilotService.ts b/src/main/services/CopilotService.ts new file mode 100644 index 00000000..e19bb59f --- /dev/null +++ b/src/main/services/CopilotService.ts @@ -0,0 +1,247 @@ +import axios, { AxiosRequestConfig } from 'axios' +import { app, safeStorage } from 'electron' +import fs from 'fs/promises' +import path from 'path' + +// 配置常量,集中管理 +const CONFIG = { + GITHUB_CLIENT_ID: 'Iv1.b507a08c87ecfe98', + POLLING: { + MAX_ATTEMPTS: 8, + INITIAL_DELAY_MS: 1000, + MAX_DELAY_MS: 16000 // 最大延迟16秒 + }, + DEFAULT_HEADERS: { + accept: 'application/json', + 'editor-version': 'Neovim/0.6.1', + 'editor-plugin-version': 'copilot.vim/1.16.0', + 'content-type': 'application/json', + 'user-agent': 'GithubCopilot/1.155.0', + 'accept-encoding': 'gzip,deflate,br' + }, + // API端点集中管理 + API_URLS: { + GITHUB_USER: 'https://api.github.com/user', + GITHUB_DEVICE_CODE: 'https://github.com/login/device/code', + GITHUB_ACCESS_TOKEN: 'https://github.com/login/oauth/access_token', + COPILOT_TOKEN: 'https://api.github.com/copilot_internal/v2/token' + } +} + +// 接口定义移到顶部,便于查阅 +interface UserResponse { + login: string + avatar: string +} + +interface AuthResponse { + device_code: string + user_code: string + verification_uri: string +} + +interface TokenResponse { + access_token: string +} + +interface CopilotTokenResponse { + token: string +} + +// 自定义错误类,统一错误处理 +class CopilotServiceError extends Error { + constructor( + message: string, + public readonly cause?: unknown + ) { + super(message) + this.name = 'CopilotServiceError' + } +} + +class CopilotService { + private readonly tokenFilePath: string + private headers: Record + + constructor() { + this.tokenFilePath = path.join(app.getPath('userData'), '.copilot_token') + this.headers = { ...CONFIG.DEFAULT_HEADERS } + } + + /** + * 设置自定义请求头 + */ + private updateHeaders = (headers?: Record): void => { + if (headers && Object.keys(headers).length > 0) { + this.headers = { ...headers } + } + } + + /** + * 获取GitHub登录信息 + */ + public getUser = async (_: Electron.IpcMainInvokeEvent, token: string): Promise => { + try { + const config: AxiosRequestConfig = { + headers: { + Connection: 'keep-alive', + 'user-agent': 'Visual Studio Code (desktop)', + 'Sec-Fetch-Site': 'none', + 'Sec-Fetch-Mode': 'no-cors', + 'Sec-Fetch-Dest': 'empty', + authorization: `token ${token}` + } + } + + const response = await axios.get(CONFIG.API_URLS.GITHUB_USER, config) + return { + login: response.data.login, + avatar: response.data.avatar_url + } + } catch (error) { + console.error('Failed to get user information:', error) + throw new CopilotServiceError('无法获取GitHub用户信息', error) + } + } + + /** + * 获取GitHub设备授权信息 + */ + public getAuthMessage = async ( + _: Electron.IpcMainInvokeEvent, + headers?: Record + ): Promise => { + try { + this.updateHeaders(headers) + + const response = await axios.post( + CONFIG.API_URLS.GITHUB_DEVICE_CODE, + { + client_id: CONFIG.GITHUB_CLIENT_ID, + scope: 'read:user' + }, + { headers: this.headers } + ) + + return response.data + } catch (error) { + console.error('Failed to get auth message:', error) + throw new CopilotServiceError('无法获取GitHub授权信息', error) + } + } + + /** + * 使用设备码获取访问令牌 - 优化轮询逻辑 + */ + public getCopilotToken = async ( + _: Electron.IpcMainInvokeEvent, + device_code: string, + headers?: Record + ): Promise => { + this.updateHeaders(headers) + + let currentDelay = CONFIG.POLLING.INITIAL_DELAY_MS + + for (let attempt = 0; attempt < CONFIG.POLLING.MAX_ATTEMPTS; attempt++) { + await this.delay(currentDelay) + + try { + const response = await axios.post( + CONFIG.API_URLS.GITHUB_ACCESS_TOKEN, + { + client_id: CONFIG.GITHUB_CLIENT_ID, + device_code, + grant_type: 'urn:ietf:params:oauth:grant-type:device_code' + }, + { headers: this.headers } + ) + + const { access_token } = response.data + if (access_token) { + return { access_token } + } + } catch (error) { + // 指数退避策略 + currentDelay = Math.min(currentDelay * 2, CONFIG.POLLING.MAX_DELAY_MS) + + // 仅在最后一次尝试失败时记录详细错误 + const isLastAttempt = attempt === CONFIG.POLLING.MAX_ATTEMPTS - 1 + if (isLastAttempt) { + console.error(`Token polling failed after ${CONFIG.POLLING.MAX_ATTEMPTS} attempts:`, error) + } + } + } + + throw new CopilotServiceError('获取访问令牌超时,请重试') + } + + /** + * 保存Copilot令牌到本地文件 + */ + public saveCopilotToken = async (_: Electron.IpcMainInvokeEvent, token: string): Promise => { + try { + const encryptedToken = safeStorage.encryptString(token) + await fs.writeFile(this.tokenFilePath, encryptedToken) + } catch (error) { + console.error('Failed to save token:', error) + throw new CopilotServiceError('无法保存访问令牌', error) + } + } + + /** + * 从本地文件读取令牌并获取Copilot令牌 + */ + public getToken = async ( + _: Electron.IpcMainInvokeEvent, + headers?: Record + ): Promise => { + try { + this.updateHeaders(headers) + + const encryptedToken = await fs.readFile(this.tokenFilePath) + const access_token = safeStorage.decryptString(Buffer.from(encryptedToken)) + + const config: AxiosRequestConfig = { + headers: { + ...this.headers, + authorization: `token ${access_token}` + } + } + + const response = await axios.get(CONFIG.API_URLS.COPILOT_TOKEN, config) + + return response.data + } catch (error) { + console.error('Failed to get Copilot token:', error) + throw new CopilotServiceError('无法获取Copilot令牌,请重新授权', error) + } + } + + /** + * 退出登录,删除本地token文件 + */ + public logout = async (): Promise => { + try { + try { + await fs.access(this.tokenFilePath) + await fs.unlink(this.tokenFilePath) + console.log('Successfully logged out from Copilot') + } catch (error) { + // 文件不存在不是错误,只是记录一下 + console.log('Token file not found, nothing to delete') + } + } catch (error) { + console.error('Failed to logout:', error) + throw new CopilotServiceError('无法完成退出登录操作', error) + } + } + + /** + * 辅助方法:延迟执行 + */ + private delay = (ms: number): Promise => { + return new Promise((resolve) => setTimeout(resolve, ms)) + } +} + +export default new CopilotService() diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 28213713..27e14735 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -144,6 +144,16 @@ declare global { // status cleanup: () => Promise } + copilot: { + getAuthMessage: ( + headers?: Record + ) => Promise<{ device_code: string; user_code: string; verification_uri: string }> + getCopilotToken: (device_code: string, headers?: Record) => Promise<{ access_token: string }> + saveCopilotToken: (access_token: string) => Promise + getToken: (headers?: Record) => Promise<{ token: string }> + logout: () => Promise + getUser: (token: string) => Promise<{ login: string; avatar: string }> + } isBinaryExist: (name: string) => Promise getBinaryPath: (name: string) => Promise installUVBinary: () => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index 3ef2dcf7..16505ae6 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -121,7 +121,16 @@ const api = { cleanup: () => ipcRenderer.invoke('mcp:cleanup') }, shell: { - openExternal: shell?.openExternal + openExternal: shell.openExternal + }, + copilot: { + getAuthMessage: (headers?: Record) => ipcRenderer.invoke('copilot:get-auth-message', headers), + getCopilotToken: (device_code: string, headers?: Record) => + ipcRenderer.invoke('copilot:get-copilot-token', device_code, headers), + saveCopilotToken: (access_token: string) => ipcRenderer.invoke('copilot:save-copilot-token', access_token), + getToken: (headers?: Record) => ipcRenderer.invoke('copilot:get-token', headers), + logout: () => ipcRenderer.invoke('copilot:logout'), + getUser: (token: string) => ipcRenderer.invoke('copilot:get-user', token) }, // Binary related APIs diff --git a/src/renderer/src/config/models.ts b/src/renderer/src/config/models.ts index 0a5ab6ef..2b35d994 100644 --- a/src/renderer/src/config/models.ts +++ b/src/renderer/src/config/models.ts @@ -1035,6 +1035,14 @@ export const SYSTEM_MODELS: Record = { group: 'OpenAI' } ], + copilot: [ + { + id: 'gpt-4o-mini', + provider: 'copilot', + name: 'OpenAI GPT-4o-mini', + group: 'OpenAI' + } + ], yi: [ { id: 'yi-lightning', name: 'Yi Lightning', provider: 'yi', group: 'yi-lightning', owned_by: '01.ai' }, { id: 'yi-vision-v2', name: 'Yi Vision v2', provider: 'yi', group: 'yi-vision', owned_by: '01.ai' } @@ -1895,6 +1903,9 @@ export function isVisionModel(model: Model): boolean { if (!model) { return false } + if (model.provider === 'copilot') { + return false + } if (model.provider === 'doubao') { return VISION_REGEX.test(model.name) || model.type?.includes('vision') || false diff --git a/src/renderer/src/config/providers.ts b/src/renderer/src/config/providers.ts index 10fd7c92..3521c2bf 100644 --- a/src/renderer/src/config/providers.ts +++ b/src/renderer/src/config/providers.ts @@ -66,6 +66,7 @@ const PROVIDER_LOGO_MAP = { 'graphrag-kylin-mountain': GraphRagProviderLogo, minimax: MinimaxProviderLogo, github: GithubProviderLogo, + copilot: GithubProviderLogo, ocoolai: OcoolAiProviderLogo, together: TogetherProviderLogo, fireworks: FireworksProviderLogo, @@ -238,6 +239,11 @@ export const PROVIDER_CONFIG = { models: 'https://github.com/marketplace/models' } }, + copilot: { + api: { + url: 'https://api.githubcopilot.com/' + } + }, yi: { api: { url: 'https://api.lingyiwanwu.com' diff --git a/src/renderer/src/hooks/useCopilot.ts b/src/renderer/src/hooks/useCopilot.ts new file mode 100644 index 00000000..cf7b67b2 --- /dev/null +++ b/src/renderer/src/hooks/useCopilot.ts @@ -0,0 +1,52 @@ +import { useDispatch, useSelector } from 'react-redux' + +import type { RootState } from '../store' +import { + type CopilotState, + resetCopilotState, + setAvatar, + setDefaultHeaders, + setUsername, + updateCopilotState +} from '../store/copilot' + +/** + * 用于访问和操作Copilot相关状态的钩子函数 + * @returns Copilot状态和操作方法 + */ +export function useCopilot() { + const dispatch = useDispatch() + const copilotState = useSelector((state: RootState) => state.copilot) + + const updateUsername = (username: string) => { + dispatch(setUsername(username)) + } + + const updateAvatar = (avatar: string) => { + dispatch(setAvatar(avatar)) + } + + const updateDefaultHeaders = (headers: Record) => { + dispatch(setDefaultHeaders(headers)) + } + + const updateState = (state: Partial) => { + dispatch(updateCopilotState(state)) + } + + const resetState = () => { + dispatch(resetCopilotState()) + } + + return { + // 当前状态 + ...copilotState, + + // 状态更新方法 + updateUsername, + updateAvatar, + updateDefaultHeaders, + updateState, + resetState + } +} diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 42a2d79a..6ccede00 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -233,6 +233,8 @@ "topics": "Topics", "warning": "Warning", "you": "You", + "copied": "Copied", + "confirm": "Confirm", "more": "More" }, "docs": { @@ -475,7 +477,8 @@ "upgrade.success.button": "Restart", "upgrade.success.content": "Please restart the application to complete the upgrade", "upgrade.success.title": "Upgrade successfully", - "warn.notion.exporting": "Exporting to Notion, please do not request export repeatedly!" + "warn.notion.exporting": "Exporting to Notion, please do not request export repeatedly!", + "warning.rate.limit": "Too many requests. Please wait {{seconds}} seconds before trying again." }, "minapp": { "sidebar.add.title": "Add to sidebar", @@ -639,6 +642,7 @@ "yi": "Yi", "zhinao": "360AI", "zhipu": "ZHIPU AI", + "copilot": "GitHub Copilot", "gpustack": "GPUStack", "alayanew": "Alaya NeW" }, @@ -976,7 +980,31 @@ "remove_invalid_keys": "Remove Invalid Keys", "search": "Search Providers...", "search_placeholder": "Search model id or name", - "title": "Model Provider" + "title": "Model Provider", + "copilot": { + "tooltip": "You need to log in to Github before using Github Copilot in Cherry Studio.", + "description": "Your GitHub account needs to subscribe to Copilot.", + "login": "Log in to Github", + "connect": "Connect to Github", + "logout": "Exit GitHub", + "auth_success_title": "Certification successful.", + "code_generated_title": "Obtain Device Code", + "code_generated_desc": "Please copy the device code into the browser link below.", + "code_failed": "Failed to obtain Device Code, please try again.", + "auth_success": "GitHub Copilot authentication successful.", + "auth_failed": "Github Copilot authentication failed.", + "logout_success": "Successfully logged out.", + "logout_failed": "Exit failed, please try again.", + "confirm_title": "Risk Warning", + "confirm_login": "Excessive use may lead to your Github account being banned, please use it cautiously!!!!", + "rate_limit": "Rate limiting", + "custom_headers": "Custom request header", + "headers_description": "Custom request headers (JSON format)", + "expand": "Expand", + "model_setting": "Model settings", + "invalid_json": "JSON format error", + "open_verification_first": "Please click the link above to access the verification page." + } }, "proxy": { "mode": { diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index fb59f13e..af396cde 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -233,6 +233,8 @@ "topics": "トピック", "warning": "警告", "you": "あなた", + "copied": "コピーされました", + "confirm": "確認", "more": "もっと" }, "docs": { @@ -475,7 +477,8 @@ "upgrade.success.button": "再起動", "upgrade.success.content": "アップグレードを完了するためにアプリケーションを再起動してください", "upgrade.success.title": "アップグレードに成功しました", - "warn.notion.exporting": "Notionにエクスポート中です。重複してエクスポートしないでください! " + "warn.notion.exporting": "Notionにエクスポート中です。重複してエクスポートしないでください! ", + "warning.rate.limit": "送信が頻繁すぎます。{{seconds}} 秒待ってから再試行してください。" }, "minapp": { "sidebar.add.title": "サイドバーに追加", @@ -639,6 +642,7 @@ "yi": "零一万物", "zhinao": "360智脳", "zhipu": "智譜AI", + "copilot": "GitHub Copilot", "gpustack": "GPUStack", "alayanew": "Alaya NeW" }, @@ -976,7 +980,31 @@ "remove_invalid_keys": "無効なキーを削除", "search": "プロバイダーを検索...", "search_placeholder": "モデルIDまたは名前を検索", - "title": "モデルプロバイダー" + "title": "モデルプロバイダー", + "copilot": { + "tooltip": "Cherry StudioでGithub Copilotを使用するには、まずGithubにログインする必要があります。", + "description": "あなたのGithubアカウントはCopilotを購読する必要があります。", + "login": "GitHubにログインする", + "connect": "GitHubに接続する", + "logout": "GitHubから退出する", + "auth_success_title": "認証成功", + "code_generated_title": "デバイスコードを取得する", + "code_generated_desc": "デバイスコードを下記のブラウザリンクにコピーしてください。", + "code_failed": "デバイスコードの取得に失敗しました。再試行してください。", + "auth_success": "Github Copilotの認証が成功しました", + "auth_failed": "Github Copilotの認証に失敗しました。", + "logout_success": "正常にログアウトしました。", + "logout_failed": "ログアウトに失敗しました。もう一度お試しください。", + "confirm_title": "リスク警告", + "confirm_login": "過度使用すると、あなたのGithubアカウントが停止される可能性があるため、慎重に使用してください!!!!", + "rate_limit": "レート制限", + "custom_headers": "カスタムリクエストヘッダー", + "headers_description": "カスタムリクエストヘッダー(JSONフォーマット)", + "expand": "展開", + "model_setting": "モデル設定", + "invalid_json": "JSONフォーマットエラー", + "open_verification_first": "上のリンクをクリックして、確認ページにアクセスしてください。" + } }, "proxy": { "mode": { diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index dd4bc77a..e582c13b 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -233,6 +233,8 @@ "topics": "Топики", "warning": "Предупреждение", "you": "Вы", + "confirm": "确认的翻译是: Подтверждение", + "copied": "Скопировано", "more": "Ещё" }, "docs": { @@ -481,7 +483,8 @@ "upgrade.success.button": "Перезапустить", "upgrade.success.content": "Пожалуйста, перезапустите приложение для завершения обновления", "upgrade.success.title": "Обновление успешно", - "warn.notion.exporting": "Экспортируется в Notion, пожалуйста, не отправляйте повторные запросы!" + "warn.notion.exporting": "Экспортируется в Notion, пожалуйста, не отправляйте повторные запросы!", + "warning.rate.limit": "Отправка слишком частая, пожалуйста, подождите {{seconds}} секунд, прежде чем попробовать снова." }, "minapp": { "sidebar.add.title": "Добавить в боковую панель", @@ -639,6 +642,7 @@ "yi": "Yi", "zhinao": "360AI", "zhipu": "ZHIPU AI", + "copilot": "GitHub Copilot", "gpustack": "GPUStack", "alayanew": "Alaya NeW" }, @@ -976,7 +980,31 @@ "remove_invalid_keys": "Удалить недействительные ключи", "search": "Поиск поставщиков...", "search_placeholder": "Поиск по ID или имени модели", - "title": "Провайдеры моделей" + "title": "Провайдеры моделей", + "copilot": { + "tooltip": "В Cherry Studio для использования Github Copilot необходимо сначала войти в Github.", + "description": "Ваша учетная запись Github должна подписаться на Copilot.", + "login": "Войти в Github", + "connect": "Подключить Github", + "logout": "Выйти из Github", + "auth_success_title": "Аутентификация успешна", + "code_generated_title": "Получить код устройства", + "code_generated_desc": "Пожалуйста, скопируйте код устройства в приведенную ниже ссылку браузера.", + "code_failed": "Получение кода устройства не удалось, пожалуйста, попробуйте еще раз.", + "auth_success": "Github Copilot认证成功", + "auth_failed": "Github Copilot认证失败", + "logout_success": "Успешно вышел", + "logout_failed": "Не удалось выйти, пожалуйста, повторите попытку.", + "confirm_title": "Предупреждение о рисках", + "confirm_login": "Чрезмерное использование может привести к блокировке вашего Github, будьте осторожны!!!!", + "rate_limit": "Ограничение скорости", + "custom_headers": "Пользовательские заголовки запроса", + "headers_description": "Пользовательские заголовки запроса (формат json)", + "expand": "развернуть", + "model_setting": "Настройки модели", + "invalid_json": "Ошибка формата JSON", + "open_verification_first": "Пожалуйста, сначала щелкните по ссылке выше, чтобы перейти на страницу проверки." + } }, "proxy": { "mode": { diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 0444d346..e722b6a3 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -204,7 +204,9 @@ "chat": "聊天", "clear": "清除", "close": "关闭", + "confirm": "确认", "copy": "复制", + "copied": "已复制", "cut": "剪切", "default": "默认", "delete": "删除", @@ -475,7 +477,8 @@ "upgrade.success.button": "重启", "upgrade.success.content": "重启用以完成升级", "upgrade.success.title": "升级成功", - "warn.notion.exporting": "正在导出到 Notion, 请勿重复请求导出!" + "warn.notion.exporting": "正在导出到 Notion, 请勿重复请求导出!", + "warning.rate.limit": "发送过于频繁,请等待 {{seconds}} 秒后再尝试" }, "minapp": { "sidebar.add.title": "添加到侧边栏", @@ -633,6 +636,7 @@ "yi": "零一万物", "zhinao": "360智脑", "zhipu": "智谱AI", + "copilot": "GitHub Copilot", "gpustack": "GPUStack", "alayanew": "Alaya NeW" }, @@ -976,7 +980,31 @@ "remove_invalid_keys": "删除无效密钥", "search": "搜索模型平台...", "search_placeholder": "搜索模型 ID 或名称", - "title": "模型服务" + "title": "模型服务", + "copilot": { + "tooltip": "在Cherry Studio中使用Github Copilot需要先登录Github", + "description": "您的Github账号需要订阅Copilot", + "login": "登录Github", + "connect": "连接Github", + "logout": "退出Github", + "auth_success_title": "认证成功", + "code_generated_title": "获取Device Code", + "code_generated_desc": "请将device code复制到下面的浏览器链接中", + "code_failed": "获取Device Code失败,请重试", + "auth_success": "Github Copilot认证成功", + "auth_failed": "Github Copilot认证失败", + "logout_success": "已成功退出", + "logout_failed": "退出失败,请重试", + "confirm_title": "风险警告", + "confirm_login": "过度使用可能会导致您的Github遭到封号,请谨慎使用!!!!", + "rate_limit": "速率限制", + "custom_headers": "自定义请求头", + "headers_description": "自定义请求头(json格式)", + "expand": "展开", + "model_setting": "模型设置", + "invalid_json": "JSON格式错误", + "open_verification_first": "请先点击上方链接访问验证页面" + } }, "proxy": { "mode": { diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index e93ef51c..5ad89528 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -233,6 +233,8 @@ "topics": "話題", "warning": "警告", "you": "您", + "copied": "已複製", + "confirm": "確認", "more": "更多" }, "docs": { @@ -475,7 +477,8 @@ "upgrade.success.button": "重新啟動", "upgrade.success.content": "請重新啟動程式以完成升級", "upgrade.success.title": "升級成功", - "warn.notion.exporting": "正在匯出到 Notion,請勿重複請求匯出!" + "warn.notion.exporting": "正在匯出到 Notion,請勿重複請求匯出!", + "warning.rate.limit": "發送過於頻繁,請在 {{seconds}} 秒後再嘗試" }, "minapp": { "sidebar.add.title": "新增到側邊欄", @@ -633,6 +636,7 @@ "yi": "零一萬物", "zhinao": "360 智腦", "zhipu": "智譜 AI", + "copilot": "GitHub Copilot", "gpustack": "GPUStack", "alayanew": "Alaya NeW" }, @@ -976,7 +980,31 @@ "remove_invalid_keys": "刪除無效金鑰", "search": "搜尋模型平臺...", "search_placeholder": "搜尋模型 ID 或名稱", - "title": "模型提供者" + "title": "模型提供者", + "copilot": { + "tooltip": "在Cherry Studio中使用Github Copilot需要先登入Github", + "description": "您的Github帳號需要訂閱Copilot", + "login": "登入Github", + "connect": "連接Github", + "logout": "退出Github", + "auth_success_title": "認證成功", + "code_generated_title": "獲取設備代碼", + "code_generated_desc": "請將設備代碼複製到下面的瀏覽器連結中。", + "code_failed": "獲取Device Code失敗,請重試", + "auth_success": "Github Copilot 認證成功", + "auth_failed": "Github Copilot認證失敗", + "logout_success": "已成功登出", + "logout_failed": "退出失敗,請重試", + "confirm_title": "風險警告", + "confirm_login": "過度使用可能會導致您的Github帳號被封,請謹慎使用!!!!", + "rate_limit": "速率限制", + "custom_headers": "自訂請求標頭", + "headers_description": "自訂請求標頭(json格式)", + "expand": "展開", + "model_setting": "模型設定", + "invalid_json": "JSON格式錯誤", + "open_verification_first": "請先點擊上方連結訪問驗證頁面" + } }, "proxy": { "mode": { diff --git a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx index 06d31dcb..da67816e 100644 --- a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx +++ b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx @@ -21,7 +21,7 @@ import { useSidebarIconShow } from '@renderer/hooks/useSidebarIcon' import { addAssistantMessagesToTopic, getDefaultTopic } from '@renderer/services/AssistantService' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import FileManager from '@renderer/services/FileManager' -import { getUserMessage } from '@renderer/services/MessagesService' +import { checkRateLimit, getUserMessage } from '@renderer/services/MessagesService' import { estimateMessageUsage, estimateTextTokens as estimateTxtTokens } from '@renderer/services/TokenService' import { translateText } from '@renderer/services/TranslateService' import WebSearchService from '@renderer/services/WebSearchService' @@ -147,6 +147,9 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = if (inputEmpty || loading) { return } + if (checkRateLimit(assistant)) { + return + } EventEmitter.emit(EVENT_NAMES.SEND_MESSAGE) diff --git a/src/renderer/src/pages/settings/ProviderSettings/GithubCopilotSettings.tsx b/src/renderer/src/pages/settings/ProviderSettings/GithubCopilotSettings.tsx new file mode 100644 index 00000000..871b8237 --- /dev/null +++ b/src/renderer/src/pages/settings/ProviderSettings/GithubCopilotSettings.tsx @@ -0,0 +1,291 @@ +import { CheckCircleOutlined, CopyOutlined, ExclamationCircleOutlined } from '@ant-design/icons' +import { useCopilot } from '@renderer/hooks/useCopilot' +import { useProvider } from '@renderer/hooks/useProvider' +import { Provider } from '@renderer/types' +import { Alert, Button, Input, message, Popconfirm, Slider, Space, Tooltip, Typography } from 'antd' +import { FC, useCallback, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +import { SettingDivider, SettingGroup, SettingHelpText, SettingRow, SettingTitle } from '..' + +interface GithubCopilotSettingsProps { + provider: Provider + setApiKey: (apiKey: string) => void +} + +enum AuthStatus { + NOT_STARTED, + CODE_GENERATED, + AUTHENTICATED +} + +const GithubCopilotSettings: FC = ({ provider: initialProvider, setApiKey }) => { + const { t } = useTranslation() + const { provider, updateProvider } = useProvider(initialProvider.id) + const { username, avatar, defaultHeaders, updateState, updateDefaultHeaders } = useCopilot() + // 状态管理 + const [authStatus, setAuthStatus] = useState(AuthStatus.NOT_STARTED) + const [deviceCode, setDeviceCode] = useState('') + const [userCode, setUserCode] = useState('') + const [verificationUri, setVerificationUri] = useState('') + const [loading, setLoading] = useState(false) + const [showHeadersForm, setShowHeadersForm] = useState(false) + const [headerText, setHeaderText] = useState(JSON.stringify(defaultHeaders || {}, null, 2)) + const [verificationPageOpened, setVerificationPageOpened] = useState(false) + + // 初始化及同步状态 + useEffect(() => { + if (provider.isAuthed) { + setAuthStatus(AuthStatus.AUTHENTICATED) + } else { + setAuthStatus(AuthStatus.NOT_STARTED) + // 重置其他状态 + setDeviceCode('') + setUserCode('') + setVerificationUri('') + } + }, [provider]) + + // 获取设备代码 + const handleGetDeviceCode = useCallback(async () => { + try { + setLoading(true) + const { device_code, user_code, verification_uri } = await window.api.copilot.getAuthMessage(defaultHeaders) + + setDeviceCode(device_code) + setUserCode(user_code) + setVerificationUri(verification_uri) + setAuthStatus(AuthStatus.CODE_GENERATED) + } catch (error) { + console.error('Failed to get device code:', error) + message.error(t('settings.provider.copilot.code_failed')) + } finally { + setLoading(false) + } + }, [t, defaultHeaders]) + + // 使用设备代码获取访问令牌 + const handleGetToken = useCallback(async () => { + try { + setLoading(true) + const { access_token } = await window.api.copilot.getCopilotToken(deviceCode, defaultHeaders) + + await window.api.copilot.saveCopilotToken(access_token) + const { token } = await window.api.copilot.getToken(defaultHeaders) + + if (token) { + const { login, avatar } = await window.api.copilot.getUser(access_token) + setAuthStatus(AuthStatus.AUTHENTICATED) + updateState({ username: login, avatar: avatar }) + updateProvider({ ...provider, apiKey: token, isAuthed: true }) + setApiKey(token) + message.success(t('settings.provider.copilot.auth_success')) + } + } catch (error) { + console.error('Failed to get token:', error) + message.error(t('settings.provider.copilot.auth_failed')) + } finally { + setLoading(false) + } + }, [deviceCode, t, updateProvider, provider, setApiKey, updateState, defaultHeaders]) + + // 登出 + const handleLogout = useCallback(async () => { + try { + setLoading(true) + + // 1. 保存登出状态到本地 + updateProvider({ ...provider, apiKey: '', isAuthed: false }) + setApiKey('') + + // 3. 清除本地存储的token + await window.api.copilot.logout() + + // 4. 更新UI状态 + setAuthStatus(AuthStatus.NOT_STARTED) + setDeviceCode('') + setUserCode('') + setVerificationUri('') + + message.success(t('settings.provider.copilot.logout_success')) + } catch (error) { + console.error('Failed to logout:', error) + message.error(t('settings.provider.copilot.logout_failed')) + // 如果登出失败,重置登出状态 + updateProvider({ ...provider, apiKey: '', isAuthed: false }) + setApiKey('') + } finally { + setLoading(false) + } + }, [t, updateProvider, provider, setApiKey]) + + // 复制用户代码 + const handleCopyUserCode = useCallback(() => { + navigator.clipboard.writeText(userCode) + message.success(t('common.copied')) + }, [userCode, t]) + + // 打开验证页面 + const handleOpenVerificationPage = useCallback(() => { + if (verificationUri) { + window.open(verificationUri, '_blank') + setVerificationPageOpened(true) + } + }, [verificationUri]) + + // 处理更新请求头 + const handleUpdateHeaders = useCallback(() => { + try { + // 处理headerText可能为空的情况 + const headers = headerText.trim() ? JSON.parse(headerText) : {} + updateDefaultHeaders(headers) + message.success(t('message.save.success.title')) + } catch (error) { + message.error(t('settings.provider.copilot.invalid_json')) + } + }, [headerText, updateDefaultHeaders, t]) + + // 根据认证状态渲染不同的UI + const renderAuthContent = () => { + switch (authStatus) { + case AuthStatus.AUTHENTICATED: + return ( + <> + +
+ {avatar && ( + Avatar + )} + {username || t('settings.provider.copilot.auth_success_title')} +
+ + + } + icon={} + showIcon + /> + + ) + + case AuthStatus.CODE_GENERATED: + return ( + <> + +

{t('settings.provider.copilot.code_generated_desc')}

+ {verificationUri} + + } + showIcon + /> + + + + + + + + + + + ) + + default: // AuthStatus.NOT_STARTED + return ( + <> + + + }> + + + + ) + } + } + + return ( + + + {renderAuthContent()} + + + {t('settings.provider.copilot.model_setting')} + + + {t('settings.provider.copilot.rate_limit')} + updateProvider({ ...provider, rateLimit: value })} + /> + + + {t('settings.provider.copilot.custom_headers')} + + + {showHeadersForm && ( + + + {t('settings.provider.copilot.headers_description')} + setHeaderText(e.target.value)} + placeholder={`{\n "Header-Name": "Header-Value"\n}`} + /> + + + + + + + )} + + + + ) +} + +const Container = styled.div`` + +export default GithubCopilotSettings diff --git a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx index 6a5a4ba2..fd4880a1 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx @@ -28,6 +28,7 @@ import { SettingTitle } from '..' import ApiCheckPopup from './ApiCheckPopup' +import GithubCopilotSettings from './GithubCopilotSettings' import GPUStackSettings from './GPUStackSettings' import GraphRAGSettings from './GraphRAGSettings' import HealthCheckPopup from './HealthCheckPopup' @@ -242,9 +243,12 @@ const ProviderSetting: FC = ({ provider: _provider }) => { } useEffect(() => { + if (provider.id === 'copilot') { + return + } setApiKey(provider.apiKey) setApiHost(provider.apiHost) - }, [provider.apiKey, provider.apiHost]) + }, [provider.apiKey, provider.apiHost, provider.id]) // Save apiKey to provider when unmount useEffect(() => { @@ -283,6 +287,7 @@ const ProviderSetting: FC = ({ provider: _provider }) => { spellCheck={false} type="password" autoFocus={provider.enabled && apiKey === ''} + disabled={provider.id === 'copilot'} /> {isProviderSupportAuth(provider) && }