feat: support Github Copilot (#2432)

* feat: support Github Copilot

* feat: finish i18n translate

* fix: add safeStorage

* clean code

* chore: remove vision model

*  feat: add Model Context Protocol (MCP) support (#2809)

*  feat: add Model Context Protocol (MCP) server configuration (main)

- Added `@modelcontextprotocol/sdk` dependency for MCP integration.
- Introduced MCP server configuration UI in settings with add, edit, delete, and activation functionalities.
- Created `useMCPServers` hook to manage MCP server state and actions.
- Added i18n support for MCP settings with translation keys.
- Integrated MCP settings into the application's settings navigation and routing.
- Implemented Redux state management for MCP servers.
- Updated `yarn.lock` with new dependencies and their resolutions.

* 🌟 feat: implement mcp service and integrate with ipc handlers

- Added `MCPService` class to manage Model Context Protocol servers.
- Implemented various handlers in `ipc.ts` for managing MCP servers including listing, adding, updating, deleting, and activating/deactivating servers.
- Integrated MCP related types into existing type declarations for consistency across the application.
- Updated `preload` to expose new MCP related APIs to the renderer process.
- Enhanced `MCPSettings` component to interact directly with the new MCP service for adding, updating, deleting servers and setting their active states.
- Introduced selectors in the MCP Redux slice for fetching active and all servers from the store.
- Moved MCP types to a centralized location in `@renderer/types` for reuse across different parts of the application.

* feat: enhance MCPService initialization to prevent recursive calls and improve error handling

* feat: enhance MCP integration by adding MCPTool type and updating related methods

* feat: implement streaming support for tool calls in OpenAIProvider and enhance message processing

* fix: finish_reason undefined

* fix migrate

* feat: add rate limit and warning

* feat: add delete copilot token file

feat: add login message

feat: add default headers and change getCopilotToken algorithm

* fix

* feat: add rate limit

* chore: change apihost

* fix: remove duplicate apikey

* fix: change api host

* chore: add vertify first tooltip

---------

Co-authored-by: 亢奋猫 <kangfenmao@qq.com>
Co-authored-by: LiuVaayne <10231735+vaayne@users.noreply.github.com>
This commit is contained in:
Chen Tao 2025-03-19 13:24:50 +08:00 committed by GitHub
parent f9f2586dc4
commit 0ddcecabdf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 936 additions and 31 deletions

View File

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

View File

@ -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<string, string>
constructor() {
this.tokenFilePath = path.join(app.getPath('userData'), '.copilot_token')
this.headers = { ...CONFIG.DEFAULT_HEADERS }
}
/**
*
*/
private updateHeaders = (headers?: Record<string, string>): void => {
if (headers && Object.keys(headers).length > 0) {
this.headers = { ...headers }
}
}
/**
* GitHub登录信息
*/
public getUser = async (_: Electron.IpcMainInvokeEvent, token: string): Promise<UserResponse> => {
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<string, string>
): Promise<AuthResponse> => {
try {
this.updateHeaders(headers)
const response = await axios.post<AuthResponse>(
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<string, string>
): Promise<TokenResponse> => {
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<TokenResponse>(
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<void> => {
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<string, string>
): Promise<CopilotTokenResponse> => {
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<CopilotTokenResponse>(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<void> => {
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<void> => {
return new Promise((resolve) => setTimeout(resolve, ms))
}
}
export default new CopilotService()

View File

@ -144,6 +144,16 @@ declare global {
// status
cleanup: () => Promise<void>
}
copilot: {
getAuthMessage: (
headers?: Record<string, string>
) => Promise<{ device_code: string; user_code: string; verification_uri: string }>
getCopilotToken: (device_code: string, headers?: Record<string, string>) => Promise<{ access_token: string }>
saveCopilotToken: (access_token: string) => Promise<void>
getToken: (headers?: Record<string, string>) => Promise<{ token: string }>
logout: () => Promise<void>
getUser: (token: string) => Promise<{ login: string; avatar: string }>
}
isBinaryExist: (name: string) => Promise<boolean>
getBinaryPath: (name: string) => Promise<string>
installUVBinary: () => Promise<void>

View File

@ -121,7 +121,16 @@ const api = {
cleanup: () => ipcRenderer.invoke('mcp:cleanup')
},
shell: {
openExternal: shell?.openExternal
openExternal: shell.openExternal
},
copilot: {
getAuthMessage: (headers?: Record<string, string>) => ipcRenderer.invoke('copilot:get-auth-message', headers),
getCopilotToken: (device_code: string, headers?: Record<string, string>) =>
ipcRenderer.invoke('copilot:get-copilot-token', device_code, headers),
saveCopilotToken: (access_token: string) => ipcRenderer.invoke('copilot:save-copilot-token', access_token),
getToken: (headers?: Record<string, string>) => ipcRenderer.invoke('copilot:get-token', headers),
logout: () => ipcRenderer.invoke('copilot:logout'),
getUser: (token: string) => ipcRenderer.invoke('copilot:get-user', token)
},
// Binary related APIs

View File

@ -1035,6 +1035,14 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
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

View File

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

View File

@ -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<string, string>) => {
dispatch(setDefaultHeaders(headers))
}
const updateState = (state: Partial<CopilotState>) => {
dispatch(updateCopilotState(state))
}
const resetState = () => {
dispatch(resetCopilotState())
}
return {
// 当前状态
...copilotState,
// 状态更新方法
updateUsername,
updateAvatar,
updateDefaultHeaders,
updateState,
resetState
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
if (inputEmpty || loading) {
return
}
if (checkRateLimit(assistant)) {
return
}
EventEmitter.emit(EVENT_NAMES.SEND_MESSAGE)

View File

@ -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<GithubCopilotSettingsProps> = ({ provider: initialProvider, setApiKey }) => {
const { t } = useTranslation()
const { provider, updateProvider } = useProvider(initialProvider.id)
const { username, avatar, defaultHeaders, updateState, updateDefaultHeaders } = useCopilot()
// 状态管理
const [authStatus, setAuthStatus] = useState<AuthStatus>(AuthStatus.NOT_STARTED)
const [deviceCode, setDeviceCode] = useState<string>('')
const [userCode, setUserCode] = useState<string>('')
const [verificationUri, setVerificationUri] = useState<string>('')
const [loading, setLoading] = useState<boolean>(false)
const [showHeadersForm, setShowHeadersForm] = useState<boolean>(false)
const [headerText, setHeaderText] = useState<string>(JSON.stringify(defaultHeaders || {}, null, 2))
const [verificationPageOpened, setVerificationPageOpened] = useState<boolean>(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 (
<>
<Alert
type="success"
message={
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%' }}>
<div style={{ display: 'flex', alignItems: 'center' }}>
{avatar && (
<img
src={avatar}
alt="Avatar"
style={{ width: 20, height: 20, borderRadius: '50%', marginRight: 8 }}
loading="lazy"
/>
)}
<span>{username || t('settings.provider.copilot.auth_success_title')}</span>
</div>
<Button type="primary" danger size="small" loading={loading} onClick={handleLogout}>
{t('settings.provider.copilot.logout')}
</Button>
</div>
}
icon={<CheckCircleOutlined />}
showIcon
/>
</>
)
case AuthStatus.CODE_GENERATED:
return (
<>
<Alert
type="info"
message={t('settings.provider.copilot.code_generated_title')}
description={
<>
<p>{t('settings.provider.copilot.code_generated_desc')}</p>
<Typography.Link onClick={handleOpenVerificationPage}>{verificationUri}</Typography.Link>
</>
}
showIcon
/>
<SettingRow>
<Input value={userCode} readOnly />
<Button icon={<CopyOutlined />} onClick={handleCopyUserCode}>
{t('common.copy')}
</Button>
</SettingRow>
<SettingRow>
<Tooltip title={!verificationPageOpened ? t('settings.provider.copilot.open_verification_first') : ''}>
<Button type="primary" loading={loading} disabled={!verificationPageOpened} onClick={handleGetToken}>
{t('settings.provider.copilot.connect')}
</Button>
</Tooltip>
</SettingRow>
</>
)
default: // AuthStatus.NOT_STARTED
return (
<>
<Alert
type="warning"
message={t('settings.provider.copilot.tooltip')}
description={t('settings.provider.copilot.description')}
showIcon
/>
<Popconfirm
title={t('settings.provider.copilot.confirm_title')}
description={t('settings.provider.copilot.confirm_login')}
okText={t('common.confirm')}
cancelText={t('common.cancel')}
onConfirm={handleGetDeviceCode}
icon={<ExclamationCircleOutlined style={{ color: 'red' }} />}>
<Button type="primary" loading={loading}>
{t('settings.provider.copilot.login')}
</Button>
</Popconfirm>
</>
)
}
}
return (
<Container>
<Space direction="vertical" style={{ width: '100%' }}>
{renderAuthContent()}
<SettingDivider />
<SettingGroup>
<SettingTitle> {t('settings.provider.copilot.model_setting')}</SettingTitle>
<SettingDivider />
<SettingRow>
{t('settings.provider.copilot.rate_limit')}
<Slider
defaultValue={provider.rateLimit ?? 10}
style={{ width: 200 }}
min={1}
max={60}
step={1}
marks={{ 1: '1', 10: t('settings.websearch.search_result_default'), 60: '60' }}
onChangeComplete={(value) => updateProvider({ ...provider, rateLimit: value })}
/>
</SettingRow>
<SettingRow>
{t('settings.provider.copilot.custom_headers')}
<Button onClick={() => setShowHeadersForm((prev) => !prev)} style={{ width: 200 }}>
{t('settings.provider.copilot.expand')}
</Button>
</SettingRow>
{showHeadersForm && (
<SettingRow>
<Space direction="vertical" style={{ width: '100%' }}>
<SettingHelpText>{t('settings.provider.copilot.headers_description')}</SettingHelpText>
<Input.TextArea
rows={5}
autoSize={{ minRows: 2, maxRows: 8 }}
value={headerText}
onChange={(e) => setHeaderText(e.target.value)}
placeholder={`{\n "Header-Name": "Header-Value"\n}`}
/>
<Space>
<Button onClick={handleUpdateHeaders} type="primary">
{t('common.save')}
</Button>
<Button onClick={() => setHeaderText(JSON.stringify({}, null, 2))}>{t('common.reset')}</Button>
</Space>
</Space>
</SettingRow>
)}
</SettingGroup>
</Space>
</Container>
)
}
const Container = styled.div``
export default GithubCopilotSettings

View File

@ -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<Props> = ({ 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<Props> = ({ provider: _provider }) => {
spellCheck={false}
type="password"
autoFocus={provider.enabled && apiKey === ''}
disabled={provider.id === 'copilot'}
/>
{isProviderSupportAuth(provider) && <OAuthButton provider={provider} onSuccess={setApiKey} />}
<Button
@ -350,6 +355,7 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
{provider.id === 'graphrag-kylin-mountain' && provider.models.length > 0 && (
<GraphRAGSettings provider={provider} />
)}
{provider.id === 'copilot' && <GithubCopilotSettings provider={provider} setApiKey={setApiKey} />}
<SettingSubtitle style={{ marginBottom: 5 }}>
<Flex align="center" justify="space-between" style={{ width: '100%' }}>
<span>{t('common.models')}</span>

View File

@ -15,6 +15,7 @@ import {
filterEmptyMessages,
filterUserRoleStartMessages
} from '@renderer/services/MessagesService'
import store from '@renderer/store'
import {
Assistant,
FileTypes,
@ -69,7 +70,10 @@ export default class OpenAIProvider extends BaseProvider {
dangerouslyAllowBrowser: true,
apiKey: this.apiKey,
baseURL: this.getBaseURL(),
defaultHeaders: this.defaultHeaders()
defaultHeaders: {
...this.defaultHeaders(),
...(this.provider.id === 'copilot' ? { 'editor-version': 'vscode/1.97.2' } : {})
}
})
}
@ -415,6 +419,7 @@ export default class OpenAIProvider extends BaseProvider {
const lastUserMessage = _messages.findLast((m) => m.role === 'user')
const { abortController, cleanup, signalPromise } = this.createAbortController(lastUserMessage?.id, true)
const { signal } = abortController
await this.checkIsCopilot()
mcpTools = filterMCPTools(mcpTools, lastUserMessage?.enabledMCPs)
const tools = mcpTools && mcpTools.length > 0 ? mcpToolsToOpenAITools(mcpTools) : undefined
@ -598,7 +603,6 @@ export default class OpenAIProvider extends BaseProvider {
})
}
}
const stream = await this.sdk.chat.completions
// @ts-ignore key is not typed
.create(
@ -659,6 +663,8 @@ export default class OpenAIProvider extends BaseProvider {
const stream = isSupportedStreamOutput()
await this.checkIsCopilot()
// @ts-ignore key is not typed
const response = await this.sdk.chat.completions.create({
model: model.id,
@ -732,6 +738,8 @@ export default class OpenAIProvider extends BaseProvider {
content: userMessageContent
}
await this.checkIsCopilot()
// @ts-ignore key is not typed
const response = await this.sdk.chat.completions.create({
model: model.id,
@ -791,6 +799,8 @@ export default class OpenAIProvider extends BaseProvider {
public async generateText({ prompt, content }: { prompt: string; content: string }): Promise<string> {
const model = getDefaultModel()
await this.checkIsCopilot()
const response = await this.sdk.chat.completions.create({
model: model.id,
stream: false,
@ -816,6 +826,8 @@ export default class OpenAIProvider extends BaseProvider {
return []
}
await this.checkIsCopilot()
const response: any = await this.sdk.request({
method: 'post',
path: '/advice_questions',
@ -840,7 +852,6 @@ export default class OpenAIProvider extends BaseProvider {
if (!model) {
return { valid: false, error: new Error('No model found') }
}
const body = {
model: model.id,
messages: [{ role: 'user', content: 'hi' }],
@ -848,6 +859,7 @@ export default class OpenAIProvider extends BaseProvider {
}
try {
await this.checkIsCopilot()
const response = await this.sdk.chat.completions.create(body as ChatCompletionCreateParamsNonStreaming)
return {
@ -868,6 +880,8 @@ export default class OpenAIProvider extends BaseProvider {
*/
public async models(): Promise<OpenAI.Models.Model[]> {
try {
await this.checkIsCopilot()
const response = await this.sdk.models.list()
if (this.provider.id === 'github') {
@ -945,10 +959,20 @@ export default class OpenAIProvider extends BaseProvider {
* @returns The embedding dimensions
*/
public async getEmbeddingDimensions(model: Model): Promise<number> {
await this.checkIsCopilot()
const data = await this.sdk.embeddings.create({
model: model.id,
input: model?.provider === 'baidu-cloud' ? ['hi'] : 'hi'
})
return data.data[0].embedding.length
}
public async checkIsCopilot() {
if (this.provider.id !== 'copilot') return
const defaultHeaders = store.getState().copilot.defaultHeaders
// copilot每次请求前需要重新获取token因为token中附带时间戳
const { token } = await window.api.copilot.getToken(defaultHeaders)
this.sdk.apiKey = token
}
}

View File

@ -6,10 +6,11 @@ import store from '@renderer/store'
import { Assistant, Message, Model, Topic } from '@renderer/types'
import { getTitleFromString, uuid } from '@renderer/utils'
import dayjs from 'dayjs'
import { t } from 'i18next'
import { isEmpty, remove, takeRight } from 'lodash'
import { NavigateFunction } from 'react-router'
import { getAssistantById, getDefaultModel } from './AssistantService'
import { getAssistantById, getAssistantProvider, getDefaultModel } from './AssistantService'
import { EVENT_NAMES, EventEmitter } from './EventService'
import FileManager from './FileManager'
@ -212,3 +213,36 @@ export function getMessageTitle(message: Message, length = 30) {
return title
}
export function checkRateLimit(assistant: Assistant): boolean {
const provider = getAssistantProvider(assistant)
if (!provider.rateLimit) {
return false
}
const topicId = assistant.topics[0].id
const messages = store.getState().messages.messagesByTopic[topicId]
if (!messages || messages.length <= 1) {
return false
}
const now = Date.now()
const lastMessage = messages[messages.length - 1]
const lastMessageTime = new Date(lastMessage.createdAt).getTime()
const timeDiff = now - lastMessageTime
const rateLimitMs = provider.rateLimit * 1000
if (timeDiff < rateLimitMs) {
const waitTimeSeconds = Math.ceil((rateLimitMs - timeDiff) / 1000)
window.message.warning({
content: t('message.warning.rate.limit', { seconds: waitTimeSeconds }),
duration: 5,
key: 'rate-limit-message'
})
return true
}
return false
}

View File

@ -0,0 +1,36 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
export interface CopilotState {
username?: string
avatar?: string
defaultHeaders?: Record<string, string>
}
const initialState: CopilotState = {
username: '',
avatar: ''
}
export const copilotSlice = createSlice({
name: 'copilot',
initialState,
reducers: {
setUsername: (state, action: PayloadAction<string>) => {
state.username = action.payload
},
setAvatar: (state, action: PayloadAction<string>) => {
state.avatar = action.payload
},
setDefaultHeaders: (state, action: PayloadAction<Record<string, string>>) => {
state.defaultHeaders = action.payload
},
updateCopilotState: (state, action: PayloadAction<Partial<CopilotState>>) => {
return { ...state, ...action.payload }
},
resetCopilotState: () => initialState
}
})
export const { setUsername, setAvatar, setDefaultHeaders, updateCopilotState, resetCopilotState } = copilotSlice.actions
export default copilotSlice.reducer

View File

@ -5,6 +5,7 @@ import storage from 'redux-persist/lib/storage'
import agents from './agents'
import assistants from './assistants'
import copilot from './copilot'
import knowledge from './knowledge'
import llm from './llm'
import mcp from './mcp'
@ -28,8 +29,9 @@ const rootReducer = combineReducers({
knowledge,
minapps,
websearch,
messages: messagesReducer,
mcp
mcp,
copilot,
messages: messagesReducer
})
const persistedReducer = persistReducer(

View File

@ -200,6 +200,17 @@ const initialState: LlmState = {
isSystem: true,
enabled: false
},
{
id: 'copilot',
name: 'Github Copilot',
type: 'openai',
apiKey: '',
apiHost: 'https://api.githubcopilot.com/',
models: SYSTEM_MODELS.copilot,
isSystem: true,
enabled: false,
isAuthed: false
},
{
id: 'dmxapi',
name: 'DMXAPI',

View File

@ -1178,16 +1178,18 @@ const migrateConfig = {
return state
},
'74': (state: RootState) => {
state.llm.providers.push({
id: 'xirang',
name: 'Xirang',
type: 'openai',
apiKey: '',
apiHost: 'https://wishub-x1.ctyun.cn',
models: SYSTEM_MODELS.xirang,
isSystem: true,
enabled: false
})
if (!state.llm.providers.find((provider) => provider.id === 'xirang')) {
state.llm.providers.push({
id: 'xirang',
name: 'Xirang',
type: 'openai',
apiKey: '',
apiHost: 'https://wishub-x1.ctyun.cn',
models: SYSTEM_MODELS.xirang,
isSystem: true,
enabled: false
})
}
return state
},
'75': (state: RootState) => {
@ -1235,10 +1237,21 @@ const migrateConfig = {
delete p.enabled
})
}
return state
},
'78': (state: RootState) => {
if (!state.llm.providers.find((p) => p.id === 'copilot')) {
state.llm.providers.push({
id: 'copilot',
name: 'Github Copilot',
type: 'openai',
apiKey: '',
apiHost: 'https://api.githubcopilot.com/',
models: SYSTEM_MODELS.copilot,
isSystem: true,
enabled: false
})
}
state.llm.providers = moveProvider(state.llm.providers, 'ppio', 9)
state.llm.providers = moveProvider(state.llm.providers, 'infini', 10)
removeMiniAppIconsFromState(state)

View File

@ -116,6 +116,8 @@ export type Provider = {
models: Model[]
enabled?: boolean
isSystem?: boolean
isAuthed?: boolean
rateLimit?: number
}
export type ProviderType = 'openai' | 'anthropic' | 'gemini' | 'qwenlm' | 'azure-openai'