├── docs ├── CNAME ├── _config.yml ├── README_zh.md └── README.md ├── Sources ├── Assets.xcassets │ ├── Contents.json │ ├── Status.imageset │ │ ├── 22.png │ │ ├── 44.png │ │ ├── 88.png │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ ├── mac-16x16.png │ │ ├── mac-32x32.png │ │ ├── mac-128x128.png │ │ ├── mac-16x16@2x.png │ │ ├── mac-256x256.png │ │ ├── mac-32x32@2x.png │ │ ├── mac-512x512.png │ │ ├── mac-128x128@2x.png │ │ ├── mac-256x256@2x.png │ │ ├── mac-512x512@2x.png │ │ └── Contents.json │ └── AccentColor.colorset │ │ └── Contents.json ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json ├── Enums │ ├── ChatRoleEnum.swift │ ├── PromptEnum.swift │ ├── LanguageEnum.swift │ ├── SettingsEnum.swift │ ├── Models │ │ ├── PromptEnumModel.swift │ │ ├── SettingsEnumModel.swift │ │ └── AIProviderEnumModel.swift │ ├── AppearanceEnum.swift │ └── AIProviderEnum.swift ├── Markdown │ ├── Node │ │ ├── ThematicBreakView.swift │ │ ├── Share │ │ │ ├── ShareData.swift │ │ │ ├── PhotoShare │ │ │ │ ├── SharePhotoView.swift │ │ │ │ └── ShareButtonView.swift │ │ │ ├── ImageDocument.swift │ │ │ ├── HeadingView.swift │ │ │ ├── SharePhoto.swift │ │ │ └── ShareFile.swift │ │ ├── UnorderedListView.swift │ │ ├── OrderedListView.swift │ │ ├── HeadingView.swift │ │ ├── BlockQuoteView.swift │ │ ├── ListItemView.swift │ │ ├── CodeBlockView.swift │ │ ├── ParagraphView.swift │ │ ├── ImageView.swift │ │ └── TableView.swift │ ├── MarkdownView.swift │ └── MarkdownNodeView.swift ├── Models │ ├── Assistant.swift │ ├── Prompt.swift │ ├── AIProvider.swift │ ├── AIModel.swift │ ├── ChatSession.swift │ └── ChatMessage.swift ├── Protocols │ ├── AIProtocol.swift │ └── Services │ │ ├── GrokService.swift │ │ ├── MiMoService.swift │ │ ├── ZenMuxService.swift │ │ ├── OpenRouterService.swift │ │ ├── DeepSeekService.swift │ │ ├── APIResponseMessage.swift │ │ ├── GeminiService.swift │ │ └── CloudflareService.swift ├── Views │ ├── Chat │ │ ├── SessionView.swift │ │ ├── SessionSideView.swift │ │ ├── Message │ │ │ ├── ChatReasoningView.swift │ │ │ ├── ChatMessageView.swift │ │ │ ├── TOCMessageView.swift │ │ │ ├── ChatContentView.swift │ │ │ └── ChatOperationView.swift │ │ ├── ChatSessionView.swift │ │ ├── SessionDetailView.swift │ │ ├── ViewModels │ │ │ └── ChatViewModel.swift │ │ └── InputAreaView.swift │ ├── SettingsView.swift │ ├── SettingView │ │ ├── Settings.swift │ │ ├── CustomView │ │ │ ├── CustomMessageView.swift │ │ │ ├── CustomSearchView.swift │ │ │ └── CustomButtonView.swift │ │ ├── Provider │ │ │ ├── ModelAddView.swift │ │ │ ├── ModelEditorView.swift │ │ │ ├── ProviderView.swift │ │ │ ├── ProviderEditorView.swift │ │ │ └── ModelFetchView.swift │ │ ├── GeneralView.swift │ │ ├── Detail │ │ │ └── ServiceModelsEditorView.swift │ │ └── AboutView.swift │ ├── MenuBarExtraView.swift │ ├── Assistant │ │ ├── AssistantSideView.swift │ │ ├── AssistantEditView.swift │ │ ├── AssistantAddView.swift │ │ └── AssistantView.swift │ └── MainView.swift ├── Helper │ └── ResponseContentHelper.swift └── Main.swift ├── iChat.xcodeproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── swiftpm │ │ └── Package.resolved └── xcshareddata │ └── xcschemes │ └── iChat.xcscheme ├── LICENSE └── .gitignore /docs/CNAME: -------------------------------------------------------------------------------- 1 | ai.ichochy.com -------------------------------------------------------------------------------- /docs/_config.yml: -------------------------------------------------------------------------------- 1 | remote_theme: pages-themes/hacker@v0.2.0 2 | plugins: 3 | - jekyll-remote-theme 4 | -------------------------------------------------------------------------------- /Sources/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Sources/Assets.xcassets/Status.imageset/22.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iChochy/iChat/HEAD/Sources/Assets.xcassets/Status.imageset/22.png -------------------------------------------------------------------------------- /Sources/Assets.xcassets/Status.imageset/44.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iChochy/iChat/HEAD/Sources/Assets.xcassets/Status.imageset/44.png -------------------------------------------------------------------------------- /Sources/Assets.xcassets/Status.imageset/88.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iChochy/iChat/HEAD/Sources/Assets.xcassets/Status.imageset/88.png -------------------------------------------------------------------------------- /Sources/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Sources/Assets.xcassets/AppIcon.appiconset/mac-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iChochy/iChat/HEAD/Sources/Assets.xcassets/AppIcon.appiconset/mac-16x16.png -------------------------------------------------------------------------------- /Sources/Assets.xcassets/AppIcon.appiconset/mac-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iChochy/iChat/HEAD/Sources/Assets.xcassets/AppIcon.appiconset/mac-32x32.png -------------------------------------------------------------------------------- /Sources/Assets.xcassets/AppIcon.appiconset/mac-128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iChochy/iChat/HEAD/Sources/Assets.xcassets/AppIcon.appiconset/mac-128x128.png -------------------------------------------------------------------------------- /Sources/Assets.xcassets/AppIcon.appiconset/mac-16x16@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iChochy/iChat/HEAD/Sources/Assets.xcassets/AppIcon.appiconset/mac-16x16@2x.png -------------------------------------------------------------------------------- /Sources/Assets.xcassets/AppIcon.appiconset/mac-256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iChochy/iChat/HEAD/Sources/Assets.xcassets/AppIcon.appiconset/mac-256x256.png -------------------------------------------------------------------------------- /Sources/Assets.xcassets/AppIcon.appiconset/mac-32x32@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iChochy/iChat/HEAD/Sources/Assets.xcassets/AppIcon.appiconset/mac-32x32@2x.png -------------------------------------------------------------------------------- /Sources/Assets.xcassets/AppIcon.appiconset/mac-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iChochy/iChat/HEAD/Sources/Assets.xcassets/AppIcon.appiconset/mac-512x512.png -------------------------------------------------------------------------------- /Sources/Assets.xcassets/AppIcon.appiconset/mac-128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iChochy/iChat/HEAD/Sources/Assets.xcassets/AppIcon.appiconset/mac-128x128@2x.png -------------------------------------------------------------------------------- /Sources/Assets.xcassets/AppIcon.appiconset/mac-256x256@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iChochy/iChat/HEAD/Sources/Assets.xcassets/AppIcon.appiconset/mac-256x256@2x.png -------------------------------------------------------------------------------- /Sources/Assets.xcassets/AppIcon.appiconset/mac-512x512@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iChochy/iChat/HEAD/Sources/Assets.xcassets/AppIcon.appiconset/mac-512x512@2x.png -------------------------------------------------------------------------------- /iChat.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Sources/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Sources/Enums/ChatRoleEnum.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatRole.swift 3 | // iChat 4 | // 5 | // Created by Lion on 2025/4/28. 6 | // 7 | 8 | 9 | // 聊天角色 10 | enum ChatRoleEnum: String, Codable, Hashable { 11 | case system = "system" 12 | case user = "user" 13 | case assistant = "assistant" 14 | } 15 | -------------------------------------------------------------------------------- /Sources/Markdown/Node/ThematicBreakView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HeadingView.swift 3 | // iMenu 4 | // 5 | // Created by OSX on 2025/12/3. 6 | // 7 | import SwiftUI 8 | import Markdown 9 | 10 | // 引用块视图 11 | struct ThematicBreakView: View { 12 | let thematicBreak: ThematicBreak 13 | 14 | var body: some View { 15 | Divider() 16 | .padding(.vertical, 8) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/Assets.xcassets/Status.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "22.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "44.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "88.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Sources/Models/Assistant.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatSession.swift 3 | // iChat 4 | // 5 | // Created by Lion on 2025/4/28. 6 | // 7 | 8 | import Foundation 9 | import SwiftData 10 | 11 | // 智能助手 12 | @Model 13 | final class Assistant { 14 | var id: UUID = UUID() 15 | var title: String = "" 16 | var desc:String = "" 17 | var temperature:Double = 1.0 18 | var prompt: String = "" 19 | var isFavorite: Bool = true 20 | var model:AIModel? 21 | var timestamp: Date = Date() 22 | 23 | init() {} 24 | } 25 | 26 | -------------------------------------------------------------------------------- /Sources/Markdown/Node/Share/ShareData.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ShareData.swift 3 | // iUploader 4 | // 5 | // Created by Lion on 2025/3/21. 6 | // 7 | 8 | import CoreTransferable 9 | import UniformTypeIdentifiers 10 | 11 | struct ShareData: Transferable { 12 | let data: Data 13 | let name: String 14 | 15 | static var transferRepresentation: some TransferRepresentation { 16 | DataRepresentation(exportedContentType: .png) { item in 17 | item.data 18 | } 19 | .suggestedFileName(\.name) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/Markdown/Node/Share/PhotoShare/SharePhotoView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PhotoShareView.swift 3 | // iChat 4 | // 5 | // Created by OSX on 2025/12/14. 6 | // 7 | 8 | import SwiftUI 9 | 10 | 11 | struct SharePhotoView: View { 12 | let photo: SharePhoto 13 | 14 | var body: some View { 15 | ShareLink( 16 | item: photo, 17 | preview: SharePreview( 18 | photo.name, 19 | image: photo 20 | )) { 21 | Image(systemName: "square.and.arrow.up") 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sources/Models/Prompt.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatSession.swift 3 | // iChat 4 | // 5 | // Created by Lion on 2025/4/28. 6 | // 7 | 8 | import Foundation 9 | import SwiftData 10 | 11 | // 聊天会话模型 12 | @Model 13 | final class Prompt { 14 | var id: UUID = UUID() 15 | var title: String = "" 16 | var content: String = "" 17 | var type: PromptEnum 18 | var timestamp: Date = Date() 19 | 20 | init(title: String,content: String,type: PromptEnum) { 21 | self.title = title 22 | self.content = content 23 | self.type = type 24 | } 25 | 26 | } 27 | -------------------------------------------------------------------------------- /Sources/Markdown/Node/UnorderedListView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HeadingView.swift 3 | // iMenu 4 | // 5 | // Created by OSX on 2025/12/3. 6 | // 7 | import SwiftUI 8 | import Markdown 9 | 10 | // 无序列表视图 11 | struct UnorderedListView: View { 12 | let list: UnorderedList 13 | 14 | var body: some View { 15 | VStack(alignment: .leading, spacing: 4) { 16 | ForEach(Array(list.listItems.enumerated()), id: \.offset) { _, item in 17 | ListItemView(item: item, ordered: false, index: 0) 18 | } 19 | } 20 | .padding(.leading, 16) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/Markdown/Node/OrderedListView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HeadingView.swift 3 | // iMenu 4 | // 5 | // Created by OSX on 2025/12/3. 6 | // 7 | import SwiftUI 8 | import Markdown 9 | 10 | // 有序列表视图 11 | struct OrderedListView: View { 12 | let list: OrderedList 13 | 14 | var body: some View { 15 | VStack(alignment: .leading, spacing: 4) { 16 | ForEach(Array(list.listItems.enumerated()), id: \.offset) { index, item in 17 | ListItemView(item: item, ordered: true, index: index + 1) 18 | } 19 | } 20 | .padding(.leading, 16) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/Protocols/AIProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // iChatProtocol.swift 3 | // iChat 4 | // 5 | // Created by Lion on 2025/4/28. 6 | // 7 | 8 | // AI 客户端协议 9 | protocol AIProtocol { 10 | 11 | func streamChatResponse( 12 | provider:AIProvider, 13 | model: AIModel, 14 | messages: [ChatMessage], 15 | temperature:Double 16 | ) async throws -> AsyncThrowingStream // 返回文本块的流 17 | 18 | 19 | /// 获取可用模型 20 | /// - Parameter provider: 服务商信息 21 | /// - Returns: 模型数据 22 | func getModels(provider:AIProvider) async throws -> [Model] 23 | } 24 | 25 | 26 | -------------------------------------------------------------------------------- /Sources/Enums/PromptEnum.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatRole.swift 3 | // iChat 4 | // 5 | // Created by Lion on 2025/4/28. 6 | // 7 | 8 | import SwiftUI 9 | 10 | // 提示词枚举 11 | enum PromptEnum: String, Codable, CaseIterable, Identifiable{ 12 | case general 13 | case system 14 | case custom 15 | 16 | var id: Self { self } 17 | 18 | private static let data:[Self:PromptEnumModel] = [ 19 | .general:PromptEnumModel.getGeneral(), 20 | .system:PromptEnumModel.getSystem(), 21 | .custom:PromptEnumModel.getCustom() 22 | ] 23 | 24 | 25 | var data:PromptEnumModel { 26 | Self.data[self]! 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /Sources/Enums/LanguageEnum.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LanguageEnum.swift 3 | // iChat 4 | // 5 | // Created by Lion on 2025/6/1. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | enum LanguageEnum: String, CaseIterable, Identifiable, Hashable { 12 | case auto = "Auto" 13 | case chinese = "中文" 14 | case english = "English" 15 | 16 | var id: Self { self } 17 | 18 | var content: String { 19 | switch self { 20 | case .chinese: 21 | return "Answer in Chinese. " 22 | case .english: 23 | return "Answers in English. " 24 | default: 25 | return "" 26 | } 27 | } 28 | 29 | 30 | } 31 | -------------------------------------------------------------------------------- /Sources/Enums/SettingsEnum.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatRole.swift 3 | // iChat 4 | // 5 | // Created by Lion on 2025/4/28. 6 | // 7 | 8 | import SwiftUI 9 | 10 | // 聊天角色 11 | enum SettingsEnum: String, CaseIterable,Identifiable{ 12 | case general 13 | case provider 14 | case about 15 | 16 | var id: String { rawValue } 17 | 18 | private static let data:[Self:SettingsEnumModel] = [ 19 | .general:SettingsEnumModel.getGeneral(), 20 | .provider:SettingsEnumModel.getProvider(), 21 | .about:SettingsEnumModel.getAbout() 22 | ] 23 | 24 | 25 | var data:SettingsEnumModel { 26 | Self.data[self]! 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /Sources/Markdown/MarkdownView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MarkdownView.swift 3 | // iMenu 4 | // 5 | // Created by OSX on 2025/12/3. 6 | // 7 | 8 | import Markdown 9 | import SwiftUI 10 | 11 | // Markdown 渲染视图 12 | struct MarkdownView: View { 13 | let markdown: String 14 | 15 | var body: some View { 16 | VStack(alignment: .leading, spacing: 12) { 17 | let document = Document(parsing: markdown) 18 | ForEach(Array(document.children.enumerated()), id: \.offset) { 19 | _, 20 | node in 21 | MarkdownNodeView(node: node) 22 | } 23 | } 24 | .frame(maxWidth: .infinity, alignment: .leading) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/Markdown/Node/Share/ImageDocument.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ImageDocument.swift 3 | // iChat 4 | // 5 | // Created by OSX on 2025/12/15. 6 | // 7 | 8 | import SwiftUI 9 | import UniformTypeIdentifiers 10 | 11 | struct ImageDocument: FileDocument { 12 | 13 | var data: Data 14 | var name: String 15 | init(data:Data,name:String) { 16 | self.data = data 17 | self.name = name 18 | } 19 | 20 | static var readableContentTypes: [UTType] = [.png, .jpeg] 21 | init(configuration: ReadConfiguration) throws { 22 | fatalError() 23 | } 24 | 25 | func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { 26 | return FileWrapper(regularFileWithContents: data) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Sources/Markdown/Node/HeadingView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HeadingView.swift 3 | // iMenu 4 | // 5 | // Created by OSX on 2025/12/3. 6 | // 7 | import SwiftUI 8 | import Markdown 9 | 10 | // 标题视图 11 | struct HeadingView: View { 12 | let heading: Heading 13 | 14 | var body: some View { 15 | Text(heading.plainText) 16 | .font(fontForLevel(heading.level)) 17 | .fontWeight(.bold) 18 | .padding(.vertical, 4) 19 | } 20 | 21 | func fontForLevel(_ level: Int) -> Font { 22 | switch level { 23 | case 1: return .title 24 | case 2: return .title2 25 | case 3: return .title3 26 | case 4: return .headline 27 | default: return .body 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/Markdown/Node/Share/HeadingView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HeadingView.swift 3 | // iMenu 4 | // 5 | // Created by OSX on 2025/12/3. 6 | // 7 | import SwiftUI 8 | import Markdown 9 | 10 | // 标题视图 11 | struct HeadingView: View { 12 | let heading: Heading 13 | 14 | var body: some View { 15 | Text(heading.plainText) 16 | .font(fontForLevel(heading.level)) 17 | .fontWeight(.bold) 18 | .padding(.vertical, 4) 19 | } 20 | 21 | func fontForLevel(_ level: Int) -> Font { 22 | switch level { 23 | case 1: return .title 24 | case 2: return .title2 25 | case 3: return .title3 26 | case 4: return .headline 27 | default: return .body 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/Models/AIProvider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatSession.swift 3 | // iChat 4 | // 5 | // Created by Lion on 2025/4/28. 6 | // 7 | 8 | import Foundation 9 | import SwiftData 10 | 11 | // 聊天会话模型 12 | @Model 13 | final class AIProvider { 14 | var id: UUID = UUID() 15 | var title: String = "" 16 | var APIKey:String = "" 17 | var APIURL:String = "" 18 | var type:AIProviderEnum = AIProviderEnum.grok 19 | 20 | @Relationship(deleteRule: .cascade, inverse: \AIModel.provider) 21 | var models: [AIModel] = [] 22 | 23 | var createdAt: Date = Date() 24 | 25 | init(title: String,APIURL: String,type:AIProviderEnum) { 26 | self.title = title 27 | self.APIURL = APIURL 28 | self.type = type 29 | } 30 | 31 | } 32 | -------------------------------------------------------------------------------- /Sources/Models/AIModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatSession.swift 3 | // iChat 4 | // 5 | // Created by Lion on 2025/4/28. 6 | // 7 | 8 | import Foundation 9 | import SwiftData 10 | 11 | // 聊天会话模型 12 | @Model 13 | final class AIModel { 14 | var id: UUID = UUID() 15 | var name: String = "" 16 | var isDefault: Bool = false 17 | 18 | var provider: AIProvider? 19 | 20 | @Relationship(deleteRule: .nullify, inverse: \ChatSession.model) 21 | var sessions: [ChatSession] = [] 22 | 23 | @Relationship(deleteRule: .nullify, inverse: \Assistant.model) 24 | var assistant: Assistant? 25 | 26 | var createdAt: Date = Date() 27 | 28 | init(name: String,provider:AIProvider) { 29 | self.name = name 30 | self.provider = provider 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /iChat.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "b809819531d430dc5b7a0fb1fb5538d0321c5d541eed52a9990f37c38dd0f98a", 3 | "pins" : [ 4 | { 5 | "identity" : "swift-cmark", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/swiftlang/swift-cmark.git", 8 | "state" : { 9 | "revision" : "5d9bdaa4228b381639fff09403e39a04926e2dbe", 10 | "version" : "0.7.1" 11 | } 12 | }, 13 | { 14 | "identity" : "swift-markdown", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/apple/swift-markdown", 17 | "state" : { 18 | "revision" : "7d9a5ce307528578dfa777d505496bd5f544ad94", 19 | "version" : "0.7.3" 20 | } 21 | } 22 | ], 23 | "version" : 3 24 | } 25 | -------------------------------------------------------------------------------- /Sources/Views/Chat/SessionView.swift: -------------------------------------------------------------------------------- 1 | import SwiftData 2 | // 3 | // ChatView.swift 4 | // iChat 5 | // 6 | // Created by Lion on 2025/4/28. 7 | // 8 | import SwiftUI 9 | 10 | struct SessionView: View { 11 | @Environment(\.modelContext) private var modelContext 12 | 13 | @Bindable var session: ChatSession 14 | 15 | var body: some View { 16 | VStack { 17 | ZStack(alignment: .top) { 18 | SessionDetailView( 19 | messages: session.sortedMessages, 20 | ) 21 | CustomMessageView(message: $session.message) 22 | } 23 | } 24 | .onTapGesture { 25 | session.message = "" 26 | } 27 | .textSelection(.enabled) // 允许选择文本 28 | .navigationTitle(session.title.isEmpty ? "New Chat" : session.title) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/Models/ChatSession.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatSession.swift 3 | // iChat 4 | // 5 | // Created by Lion on 2025/4/28. 6 | // 7 | 8 | import Foundation 9 | import SwiftData 10 | 11 | // 聊天会话模型 12 | @Model 13 | final class ChatSession { 14 | var id: UUID = UUID() 15 | var title: String = "" 16 | var message:String = "" 17 | var temperature:Double = 1.0 18 | 19 | var model: AIModel? 20 | 21 | // 与 ChatMessage 的关系 (一对多), 按时间戳排序 22 | @Relationship(deleteRule: .cascade, inverse: \ChatMessage.session) 23 | var messages: [ChatMessage] = [] // 初始化为空数组 24 | 25 | var createdAt: Date = Date() 26 | 27 | init(model: AIModel) { 28 | self.model = model 29 | } 30 | 31 | // 按时间戳排序消息的计算属性 (方便 UI 使用) 32 | var sortedMessages: [ChatMessage] { 33 | messages.sorted { $0.timestamp < $1.timestamp } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Sources/Markdown/Node/Share/SharePhoto.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ShareData.swift 3 | // iUploader 4 | // 5 | // Created by Lion on 2025/3/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct SharePhoto: Transferable { 11 | static var transferRepresentation: some TransferRepresentation { 12 | ProxyRepresentation { item in 13 | guard let image = item.image else { 14 | fatalError() 15 | } 16 | return image 17 | }.suggestedFileName { item in 18 | item.name 19 | } 20 | 21 | 22 | // ProxyRepresentation(exporting: \.image).suggestedFileName(\.name) 23 | 24 | // ProxyRepresentation(exporting: \.image) 25 | // .suggestedFileName { item in 26 | // item.name 27 | // } 28 | // 29 | 30 | } 31 | public var image: Image? 32 | public var name: String 33 | } 34 | -------------------------------------------------------------------------------- /Sources/Markdown/Node/Share/PhotoShare/ShareButtonView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PhotoShareView.swift 3 | // iChat 4 | // 5 | // Created by OSX on 2025/12/14. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ShareButtonView: View { 11 | let document: ImageDocument 12 | 13 | @State private var showSavePanel = false 14 | 15 | var body: some View { 16 | Button { 17 | showSavePanel = true 18 | } label: { 19 | Image(systemName: "square.and.arrow.down") 20 | } 21 | .fileExporter( 22 | isPresented: $showSavePanel, 23 | document: document, 24 | contentType: .png, 25 | defaultFilename: document.name 26 | ) { result in 27 | switch result { 28 | case .success(let url): 29 | print("成功导出到: \(url)") 30 | case .failure(let error): 31 | print("导出失败: \(error)") 32 | } 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Sources/Enums/Models/PromptEnumModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsModel.swift 3 | // iChat 4 | // 5 | // Created by Lion on 2025/5/19. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | class PromptEnumModel { 12 | var icon: String = "" 13 | var title: String = "" 14 | var color: Color = Color.accentColor 15 | 16 | init(icon: String, title: String, color: Color) { 17 | self.icon = icon 18 | self.title = title 19 | self.color = color 20 | } 21 | 22 | 23 | static func getGeneral() -> PromptEnumModel{ 24 | return PromptEnumModel(icon: "", title: "general", color: .green) 25 | } 26 | 27 | static func getSystem() -> PromptEnumModel{ 28 | return PromptEnumModel(icon: "", title: "system", color: .accentColor) 29 | } 30 | 31 | static func getCustom() -> PromptEnumModel{ 32 | return PromptEnumModel(icon: "", title: "custom", color: .pink) 33 | } 34 | 35 | 36 | } 37 | -------------------------------------------------------------------------------- /Sources/Views/SettingsView.swift: -------------------------------------------------------------------------------- 1 | import SwiftData 2 | // 3 | // SettingsView.swift 4 | // iChat 5 | // 6 | // Created by Lion on 2025/4/28. 7 | // 8 | import SwiftUI 9 | 10 | struct SettingsView: View { 11 | @Environment(\.dismiss) var dismiss 12 | @State var selectView: SettingsEnum = SettingsEnum.general 13 | 14 | var body: some View { 15 | NavigationSplitView { 16 | List(selection: $selectView) { 17 | Section { 18 | Divider() 19 | ForEach(SettingsEnum.allCases) { item in 20 | Label( 21 | item.data.title, 22 | systemImage: item.data.icon 23 | ).tag(item) 24 | } 25 | } header: { 26 | Text("Settings").font(.largeTitle) 27 | } 28 | } 29 | } detail: { 30 | selectView.data.view 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Sources/Enums/AppearanceEnum.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LanguageEnum.swift 3 | // iChat 4 | // 5 | // Created by Lion on 2025/6/1. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | enum AppearanceEnum: String, CaseIterable, Identifiable, Hashable { 12 | case system = "System" 13 | case light = "Light" 14 | case dark = "Dark" 15 | // Conformance to Identifiable protocol for ForEach 16 | var id: Self { self } 17 | 18 | var theme: ColorScheme? { 19 | switch self { 20 | case .light: 21 | return .light 22 | case .dark: 23 | return .dark 24 | case .system: 25 | return .none 26 | } 27 | } 28 | 29 | var name: NSAppearance? { 30 | switch self { 31 | case .light: 32 | return NSAppearance.init(named: .aqua) 33 | case .dark: 34 | return NSAppearance.init(named: .darkAqua) 35 | case .system: 36 | return .none 37 | } 38 | } 39 | 40 | } 41 | -------------------------------------------------------------------------------- /Sources/Views/SettingView/Settings.swift: -------------------------------------------------------------------------------- 1 | import SwiftData 2 | // 3 | // SettingsView.swift 4 | // AIClient 5 | // 6 | // Created by Lion on 2025/4/28. 7 | // 8 | import SwiftUI 9 | 10 | struct SettingsView: View { 11 | @Environment(\.dismiss) var dismiss 12 | @AppStorage("APIKey") private var GrokAPIKey: String = "" 13 | @State var selectView: SettingsEnum = SettingsEnum.service 14 | 15 | var body: some View { 16 | NavigationSplitView { 17 | List(selection: $selectView){ 18 | Section { 19 | Divider() 20 | ForEach(SettingsEnum.allCases) { item in 21 | Label( 22 | item.model.title, 23 | systemImage: item.model.icon 24 | ).tag(item) 25 | } 26 | }header: { 27 | Text("Settings").font(.largeTitle) 28 | } 29 | } 30 | } detail: { 31 | selectView.model.view 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Sources/Markdown/Node/BlockQuoteView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HeadingView.swift 3 | // iMenu 4 | // 5 | // Created by OSX on 2025/12/3. 6 | // 7 | import SwiftUI 8 | import Markdown 9 | 10 | // 引用块视图 11 | struct BlockQuoteView: View { 12 | let blockQuote: BlockQuote 13 | 14 | var body: some View { 15 | VStack(alignment: .leading, spacing: 4) { 16 | ForEach(Array(blockQuote.children.enumerated()), id: \.offset) { _, child in 17 | if let paragraph = child as? Paragraph { 18 | ParagraphView(paragraph: paragraph) 19 | } else if let nestedQuote = child as? BlockQuote { 20 | BlockQuoteView(blockQuote: nestedQuote) 21 | .padding(.leading, 16) 22 | } 23 | } 24 | } 25 | .padding(.leading, 16) 26 | .padding(.vertical, 8) 27 | .overlay( 28 | Rectangle() 29 | .fill(Color.accentColor.opacity(0.5)) 30 | .frame(width: 4), 31 | alignment: .leading 32 | ) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 iChochy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Sources/Enums/Models/SettingsEnumModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsModel.swift 3 | // iChat 4 | // 5 | // Created by Lion on 2025/5/19. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | class SettingsEnumModel { 12 | var title: String = "" 13 | var icon: String = "" 14 | var view: AnyView = AnyView(EmptyView()) 15 | 16 | init(title: String, icon: String, view: some View) { 17 | self.title = title 18 | self.icon = icon 19 | self.view = AnyView(view) 20 | } 21 | 22 | 23 | static func getGeneral() -> SettingsEnumModel{ 24 | let view = GeneralView(); 25 | return SettingsEnumModel(title: view.title, icon: view.icon, view: view) 26 | } 27 | 28 | static func getProvider() -> SettingsEnumModel{ 29 | let view = ProviderView(); 30 | return SettingsEnumModel(title: view.title, icon: view.icon, view: view) 31 | } 32 | 33 | static func getAbout() -> SettingsEnumModel{ 34 | let view = AboutView(); 35 | return SettingsEnumModel(title: view.title, icon: view.icon, view: view) 36 | } 37 | 38 | 39 | } 40 | -------------------------------------------------------------------------------- /Sources/Models/ChatMessage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatMessage.swift 3 | // iChat 4 | // 5 | // Created by Lion on 2025/4/28. 6 | // 7 | 8 | import Foundation 9 | import SwiftData 10 | // 聊天消息模型 11 | @Model 12 | final class ChatMessage { 13 | var id: UUID = UUID() 14 | var modelName:String = "" 15 | var content: String = "" 16 | var reasoning:String = "" 17 | var isExpanded:Bool = true 18 | var role: ChatRoleEnum // 使用枚举 19 | var isStreaming: Bool = false // 标记这条消息是否正在流式接收中 20 | var session: ChatSession? 21 | var timestamp: Date = Date() 22 | 23 | init( 24 | modelName:String, 25 | content: String, 26 | role: ChatRoleEnum, 27 | isStreaming: Bool = false, 28 | session: ChatSession 29 | ) { 30 | self.modelName = modelName 31 | self.content = content 32 | self.role = role 33 | self.isStreaming = isStreaming 34 | self.session = session 35 | } 36 | 37 | // 用于 API 请求的便捷字典表示 (根据目标 API 调整) 38 | var apiRepresentation: [String: String] { 39 | ["role": self.role.rawValue, "content": self.content] 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Sources/Markdown/Node/Share/ShareFile.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ShareData.swift 3 | // iUploader 4 | // 5 | // Created by Lion on 2025/3/21. 6 | // 7 | 8 | import CoreTransferable 9 | import SwiftUI 10 | import UniformTypeIdentifiers 11 | 12 | struct ShareFile: Transferable { 13 | let data: Data? 14 | let name: String 15 | 16 | static var transferRepresentation: some TransferRepresentation { 17 | 18 | FileRepresentation(exportedContentType: .png) { item in 19 | guard let data = item.data else { 20 | fatalError() 21 | } 22 | // 动态创建临时文件 23 | let tempURL = FileManager.default.temporaryDirectory 24 | .appendingPathComponent(item.name) 25 | .appendingPathExtension("png") 26 | 27 | // 将 Data 写入临时文件(如果文件已存在,先删除) 28 | if FileManager.default.fileExists(atPath: tempURL.path) { 29 | try? FileManager.default.removeItem(at: tempURL) 30 | } 31 | try data.write(to: tempURL) 32 | 33 | // 返回 SentTransferredFile,系统会处理传输和清理 34 | return SentTransferredFile(tempURL) 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Sources/Views/SettingView/CustomView/CustomMessageView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CustomMessageView.swift 3 | // iChat 4 | // 5 | // Created by Lion on 2025/5/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct CustomMessageView: View { 11 | @Binding var message: String 12 | var body: some View { 13 | if !message.isEmpty { 14 | HStack { 15 | Text(message) 16 | .bold() 17 | .foregroundStyle(.red) 18 | Button { 19 | withAnimation { 20 | message = "" 21 | } 22 | } label: { 23 | Image(systemName: "xmark.circle") 24 | .foregroundStyle(.gray) 25 | }.buttonStyle(.accessoryBar) 26 | } 27 | .padding() 28 | .padding(.horizontal) 29 | .background(.black.opacity(0.8)) 30 | .border(.black, width: 2) 31 | .cornerRadius(30) 32 | .shadow(radius: 10) 33 | .padding() 34 | .shadow(radius: 10) 35 | .animation(.default, value: message) 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/Markdown/MarkdownNodeView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MarkdownNodeView.swift 3 | // iMenu 4 | // 5 | // Created by OSX on 2025/12/3. 6 | // 7 | 8 | import SwiftUI 9 | import Markdown 10 | 11 | // 渲染单个 Markdown 节点 12 | struct MarkdownNodeView: View { 13 | let node: any Markup 14 | 15 | var body: some View { 16 | Group { 17 | if let heading = node as? Heading { 18 | HeadingView(heading: heading) 19 | } else if let paragraph = node as? Paragraph { 20 | ParagraphView(paragraph: paragraph) 21 | } else if let list = node as? UnorderedList { 22 | UnorderedListView(list: list) 23 | } else if let list = node as? OrderedList { 24 | OrderedListView(list: list) 25 | } else if let codeBlock = node as? CodeBlock { 26 | CodeBlockView(codeBlock: codeBlock) 27 | } else if let blockQuote = node as? BlockQuote { 28 | BlockQuoteView(blockQuote: blockQuote) 29 | } else if let thematicBreak = node as? ThematicBreak { 30 | ThematicBreakView(thematicBreak:thematicBreak) 31 | } else if let table = node as? Markdown.Table { 32 | TableView(table: table) 33 | } 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Sources/Enums/AIProviderEnum.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AIProviderIdentifier.swift 3 | // iChat 4 | // 5 | // Created by Lion on 2025/4/28. 6 | // 7 | 8 | 9 | // AI 提供商标识符 (可扩展) 10 | enum AIProviderEnum: String, Codable, CaseIterable, Identifiable { 11 | case grok = "Grok" 12 | case deepSeek = "DeepSeek" 13 | case mimo = "MiMo" 14 | case openAI = "OpenAI" 15 | case gemini = "Gemini" 16 | case zenMux = "ZenMux" 17 | case openRouter = "OpenRouter" 18 | case cloudflare = "Cloudflare" 19 | case custom = "Custom(OpenAI)" 20 | 21 | var id: String { self.rawValue } 22 | var name: String { self.rawValue } 23 | 24 | private static let data:[Self:AIProviderEnumModel] = [ 25 | .grok:AIProviderEnumModel.getGrok(), 26 | .mimo:AIProviderEnumModel.getMimo(), 27 | .openAI:AIProviderEnumModel.getOpenAI(), 28 | .gemini:AIProviderEnumModel.getGemini(), 29 | .zenMux:AIProviderEnumModel.getZenMux(), 30 | .openRouter:AIProviderEnumModel.getOpenRouter(), 31 | .deepSeek:AIProviderEnumModel.getDeepSeek(), 32 | .cloudflare:AIProviderEnumModel.getCloudflare(), 33 | .custom:AIProviderEnumModel.getCustom() 34 | ] 35 | 36 | 37 | var data:AIProviderEnumModel { 38 | Self.data[self]! 39 | } 40 | 41 | 42 | } 43 | -------------------------------------------------------------------------------- /Sources/Views/SettingView/Provider/ModelAddView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProviderView.swift 3 | // iChat 4 | // 5 | // Created by Lion on 2025/5/19. 6 | // 7 | 8 | import SwiftData 9 | import SwiftUI 10 | 11 | struct ModelAddView: View { 12 | @Environment(\.modelContext) private var context 13 | @Environment(\.dismiss) var dismiss 14 | 15 | @Bindable var provider: AIProvider 16 | 17 | @State var name: String = "" 18 | 19 | var body: some View { 20 | GroupBox("Add Model") { 21 | Form { 22 | TextField("name", text: $name).textFieldStyle( 23 | .roundedBorder 24 | ) 25 | }.padding() 26 | }.padding() 27 | HStack { 28 | Spacer() 29 | Button("Close") { 30 | dismiss() 31 | } 32 | Button { 33 | add() 34 | } label: { 35 | Text("Add") 36 | }.disabled(name == "") 37 | .keyboardShortcut(.return, modifiers: []) 38 | .buttonStyle(.borderedProminent) 39 | }.padding() 40 | } 41 | 42 | private func add() { 43 | let model = AIModel(name: name, provider: provider) 44 | context.insert(model) 45 | try? context.save() 46 | dismiss() 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Sources/Views/MenuBarExtraView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MenuBarExtraView.swift 3 | // iChat 4 | // 5 | // Created by Lion on 2025/6/5. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct MenuBarExtraView: View { 11 | @Environment(\.openWindow) private var openWindow 12 | 13 | var body: some View { 14 | Button("Chat") { 15 | openChatWindow() 16 | }.keyboardShortcut("N") 17 | Button("Setting..") { 18 | NSApp.activate(ignoringOtherApps: true) 19 | openWindow(id: "Settings") 20 | }.keyboardShortcut(",") 21 | Divider() 22 | Button("About"){ 23 | NSApp.orderFrontStandardAboutPanel(nil) 24 | } 25 | Button("Quit") { 26 | NSApp.terminate(nil) 27 | }.keyboardShortcut("Q") 28 | } 29 | 30 | func openChatWindow() { 31 | let existingWindow = NSApp.windows.first { window in 32 | window.identifier?.rawValue.hasPrefix("Chat") == true 33 | } 34 | if let window = existingWindow { 35 | if window.isMiniaturized { 36 | window.deminiaturize(nil) 37 | } else { 38 | window.makeKeyAndOrderFront(nil) 39 | } 40 | } else { 41 | NSApp.activate(ignoringOtherApps: true) 42 | openWindow(id: "Chat") 43 | } 44 | } 45 | 46 | } 47 | 48 | #Preview { 49 | MenuBarExtraView() 50 | } 51 | -------------------------------------------------------------------------------- /Sources/Views/Chat/SessionSideView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PromptView.swift 3 | // iChat 4 | // 5 | // Created by Lion on 2025/6/9. 6 | // 7 | 8 | import SwiftData 9 | import SwiftUI 10 | 11 | struct SessionSideView: View { 12 | @Environment(\.modelContext) private var modelContext 13 | 14 | @Query(sort: [SortDescriptor(\ChatSession.createdAt, order: .reverse)]) 15 | private var sessions: [ChatSession] = [] 16 | var deleteSession: () -> Void 17 | 18 | var body: some View { 19 | Section { 20 | Divider() 21 | ForEach(sessions) { session in 22 | Text( 23 | session.title.isEmpty 24 | ? "New Chat" : session.title 25 | ) 26 | .tag(session) // 重要: 使用 id 作为 tag 27 | .contextMenu { // 右键菜单删除 28 | Button { 29 | deleteSession(session) 30 | } label: { 31 | Image(systemName: "trash") 32 | Text("Delete Chat") 33 | } 34 | } 35 | } 36 | } header: { 37 | Text("Session").font(.title).bold() 38 | } 39 | } 40 | 41 | // 删除会话 42 | private func deleteSession(_ session: ChatSession) { 43 | modelContext.delete(session) 44 | try? modelContext.save() 45 | deleteSession() 46 | } 47 | 48 | } 49 | -------------------------------------------------------------------------------- /Sources/Views/SettingView/CustomView/CustomSearchView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CustomSearchView.swift 3 | // iChat 4 | // 5 | // Created by Lion on 2025/5/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct CustomSearchView: View { 11 | @Binding var searchText: String // 用于绑定搜索文本 12 | @FocusState var isFocused 13 | var body: some View { 14 | HStack { 15 | Image(systemName: "magnifyingglass") // 搜索图标 16 | .foregroundColor(.gray) 17 | TextField("Search", text: $searchText) 18 | .textFieldStyle(PlainTextFieldStyle()) 19 | .focused($isFocused) 20 | if !searchText.isEmpty { 21 | Button { 22 | searchText = "" // 清空按钮 23 | } label: { 24 | Image(systemName: "xmark.circle.fill") 25 | .foregroundColor(.gray) 26 | } 27 | .buttonStyle(PlainButtonStyle()) // 确保按钮没有多余的背景 28 | } 29 | } 30 | .padding(.horizontal, 8) 31 | .padding(.vertical, 5) 32 | .background(Color(NSColor.controlBackgroundColor)) // 给一个背景色以区分 33 | .overlay( 34 | RoundedRectangle(cornerRadius: 5) 35 | .stroke(isFocused ? Color.accentColor : Color.gray.opacity(0.3), lineWidth: 3) 36 | ).animation(.easeInOut(duration: 0.2), value: isFocused) 37 | .frame(maxWidth: 200) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Sources/Views/Chat/Message/ChatReasoningView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatReasoning.swift 3 | // iChat 4 | // 5 | // Created by Lion on 2025/5/30. 6 | // 7 | 8 | import SwiftUI 9 | 10 | // 推理 View 11 | struct ChatReasoningView: View { 12 | var message: ChatMessage 13 | 14 | var body: some View { 15 | VStack { 16 | GroupBox { 17 | HStack { 18 | Button(action: { 19 | withAnimation { 20 | message.isExpanded.toggle() 21 | } 22 | }) { 23 | HStack { 24 | Image( 25 | systemName: message.isExpanded 26 | ? "chevron.down" : "chevron.right" 27 | ).frame(width: 5) 28 | Text("Thinking ......") 29 | .font(.title2) 30 | .fontWeight(.bold) 31 | Spacer() 32 | } 33 | } 34 | .buttonStyle(.borderedProminent) 35 | } 36 | if message.isExpanded { 37 | ScrollView { 38 | MarkdownView(markdown: message.reasoning) 39 | }.frame(maxHeight: 80) 40 | } 41 | } 42 | }.frame(maxWidth: 400) 43 | } 44 | 45 | } 46 | -------------------------------------------------------------------------------- /Sources/Views/Chat/Message/ChatMessageView.swift: -------------------------------------------------------------------------------- 1 | import SwiftData 2 | // 3 | // ChatMessageView.swift 4 | // iChat 5 | // 6 | // Created by Lion on 2025/4/28. 7 | // 8 | import SwiftUI 9 | 10 | // 单条消息视图 11 | struct ChatMessageView: View { 12 | 13 | let message: ChatMessage 14 | 15 | var body: some View { 16 | if message.role == .system { 17 | if !message.content.isEmpty { 18 | Text(message.content).foregroundStyle(Color.orange.opacity(0.8)) 19 | } 20 | } else { 21 | VStack(alignment: message.role == .user ? .trailing : .leading) { 22 | HStack { 23 | Text(getModelName(message: message)) 24 | .bold() 25 | .foregroundColor(.gray) 26 | if message.isStreaming { 27 | ProgressView().controlSize(.mini) // 显示流式指示器 28 | } 29 | } 30 | if !message.reasoning.isEmpty { 31 | ChatReasoningView(message: message) 32 | } 33 | if !message.content.isEmpty { 34 | ChatContentView(message: message) 35 | } 36 | ChatOperationView(message: message) 37 | } 38 | .opacity(message.isStreaming ? 0.7 : 1.0) // 流式消息可以稍微透明 39 | } 40 | } 41 | 42 | private func getModelName(message: ChatMessage) -> String { 43 | if message.role != .user { 44 | return message.modelName.capitalized 45 | } 46 | return message.modelName 47 | } 48 | 49 | } 50 | -------------------------------------------------------------------------------- /Sources/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "mac-16x16.png", 5 | "idiom" : "mac", 6 | "scale" : "1x", 7 | "size" : "16x16" 8 | }, 9 | { 10 | "filename" : "mac-16x16@2x.png", 11 | "idiom" : "mac", 12 | "scale" : "2x", 13 | "size" : "16x16" 14 | }, 15 | { 16 | "filename" : "mac-32x32.png", 17 | "idiom" : "mac", 18 | "scale" : "1x", 19 | "size" : "32x32" 20 | }, 21 | { 22 | "filename" : "mac-32x32@2x.png", 23 | "idiom" : "mac", 24 | "scale" : "2x", 25 | "size" : "32x32" 26 | }, 27 | { 28 | "filename" : "mac-128x128.png", 29 | "idiom" : "mac", 30 | "scale" : "1x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "filename" : "mac-128x128@2x.png", 35 | "idiom" : "mac", 36 | "scale" : "2x", 37 | "size" : "128x128" 38 | }, 39 | { 40 | "filename" : "mac-256x256.png", 41 | "idiom" : "mac", 42 | "scale" : "1x", 43 | "size" : "256x256" 44 | }, 45 | { 46 | "filename" : "mac-256x256@2x.png", 47 | "idiom" : "mac", 48 | "scale" : "2x", 49 | "size" : "256x256" 50 | }, 51 | { 52 | "filename" : "mac-512x512.png", 53 | "idiom" : "mac", 54 | "scale" : "1x", 55 | "size" : "512x512" 56 | }, 57 | { 58 | "filename" : "mac-512x512@2x.png", 59 | "idiom" : "mac", 60 | "scale" : "2x", 61 | "size" : "512x512" 62 | } 63 | ], 64 | "info" : { 65 | "author" : "xcode", 66 | "version" : 1 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Sources/Views/SettingView/CustomView/CustomButtonView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CustomButtonView.swift 3 | // iChat 4 | // 5 | // Created by Lion on 2025/5/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct CustomButtonView: View { 11 | @Binding var showingPopover:Bool 12 | let message: ChatMessage 13 | var scrollView : () -> Void 14 | @State var isHover = false 15 | var body: some View { 16 | Button { 17 | showingPopover.toggle() 18 | scrollView() 19 | } label: { 20 | HStack{ 21 | Text(getPlainText(message: message)) 22 | .font(.headline) 23 | .lineLimit(1) 24 | .truncationMode(.tail) 25 | .padding(message.role == .assistant ? .leading: .trailing) 26 | Spacer() 27 | }.frame(width: 200) 28 | }.padding(5) 29 | .buttonStyle(.plain) 30 | .background( 31 | RoundedRectangle(cornerRadius: 5) 32 | .fill( 33 | isHover ? Color.accentColor.opacity(0.8) : Color.clear 34 | ) 35 | ) 36 | .foregroundColor(isHover ? .white : .primary) 37 | .onHover { hover in 38 | isHover = hover 39 | } 40 | } 41 | 42 | private func getPlainText(message: ChatMessage )-> String{ 43 | let content = try? String(AttributedString(markdown: message.content).characters) 44 | guard let msg = content else { 45 | return "---" 46 | } 47 | return String(msg.prefix(50)) 48 | } 49 | 50 | } 51 | -------------------------------------------------------------------------------- /Sources/Views/Chat/Message/TOCMessageView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TOCToolbarItemView.swift 3 | // iChat 4 | // 5 | // Created by Lion on 2025/6/8. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct TOCMessageView: View { 11 | @State private var showingPopover = false 12 | let messages: [ChatMessage] 13 | let proxy: ScrollViewProxy 14 | 15 | var body: some View { 16 | Button { 17 | showingPopover.toggle() 18 | } label: { 19 | Label("TOC", systemImage: "list.bullet") 20 | } 21 | .popover( 22 | isPresented: $showingPopover, 23 | arrowEdge: .bottom 24 | ) { 25 | GroupBox { 26 | if messages.count == 1 { 27 | HStack { 28 | Spacer() 29 | Text("---") 30 | Spacer() 31 | }.frame(width: 200) 32 | } 33 | ForEach(messages) { item in 34 | if !item.content.isEmpty { 35 | CustomButtonView( 36 | showingPopover: $showingPopover, 37 | message: item 38 | ) { 39 | scrollToMessage(message: item) 40 | } 41 | } 42 | } 43 | } label: { 44 | Text("TOC").font(.title3) 45 | }.padding(.vertical) 46 | } 47 | } 48 | 49 | func scrollToMessage(message: ChatMessage) { 50 | withAnimation { 51 | proxy.scrollTo(message.id, anchor: .top) 52 | } 53 | } 54 | 55 | } 56 | -------------------------------------------------------------------------------- /Sources/Views/Chat/Message/ChatContentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatReasoning.swift 3 | // iChat 4 | // 5 | // Created by Lion on 2025/5/30. 6 | // 7 | 8 | import SwiftUI 9 | 10 | // 内容 View 11 | struct ChatContentView: View { 12 | var message: ChatMessage 13 | 14 | var body: some View { 15 | HStack { 16 | switch message.role { 17 | case .system: 18 | Text(message.content) 19 | .fixedSize(horizontal: false, vertical: true) 20 | .background(messageBackgroundColor) 21 | .foregroundStyle(messageForegroundColor) 22 | .cornerRadius(20) 23 | case .user: 24 | Text(message.content) 25 | .padding(10) 26 | .padding(.horizontal, 5) 27 | .fixedSize(horizontal: false, vertical: true) 28 | .background(messageBackgroundColor) 29 | .foregroundStyle(messageForegroundColor) 30 | .cornerRadius(20) 31 | case .assistant: 32 | MarkdownView(markdown: message.content) 33 | } 34 | } 35 | } 36 | 37 | // 根据角色决定背景色 38 | private var messageBackgroundColor: Color { 39 | switch message.role { 40 | case .user: return Color.accentColor 41 | case .assistant: return Color.gray.opacity(0.3) 42 | case .system: return Color.orange.opacity(0.3) 43 | } 44 | } 45 | 46 | // 根据角色决定前景色 47 | private var messageForegroundColor: Color { 48 | switch message.role { 49 | case .user: return .white 50 | default: return .primary // 自动适应浅色/深色模式 51 | } 52 | } 53 | 54 | } 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | 8 | ## Obj-C/Swift specific 9 | *.hmap 10 | 11 | ## App packaging 12 | *.ipa 13 | *.dSYM.zip 14 | *.dSYM 15 | 16 | ## Playgrounds 17 | timeline.xctimeline 18 | playground.xcworkspace 19 | 20 | # Swift Package Manager 21 | # 22 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 23 | # Packages/ 24 | # Package.pins 25 | # Package.resolved 26 | # *.xcodeproj 27 | # 28 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 29 | # hence it is not needed unless you have added a package configuration file to your project 30 | # .swiftpm 31 | 32 | .build/ 33 | 34 | # CocoaPods 35 | # 36 | # We recommend against adding the Pods directory to your .gitignore. However 37 | # you should judge for yourself, the pros and cons are mentioned at: 38 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 39 | # 40 | # Pods/ 41 | # 42 | # Add this line if you want to avoid checking in source code from the Xcode workspace 43 | # *.xcworkspace 44 | 45 | # Carthage 46 | # 47 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 48 | # Carthage/Checkouts 49 | 50 | Carthage/Build/ 51 | 52 | # fastlane 53 | # 54 | # It is recommended to not store the screenshots in the git repo. 55 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 56 | # For more information about the recommended setup visit: 57 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 58 | 59 | fastlane/report.xml 60 | fastlane/Preview.html 61 | fastlane/screenshots/**/*.png 62 | fastlane/test_output 63 | 64 | # Other 65 | .DS_Store 66 | Test/ 67 | 68 | -------------------------------------------------------------------------------- /Sources/Views/Chat/ChatSessionView.swift: -------------------------------------------------------------------------------- 1 | import SwiftData 2 | // 3 | // ChatView.swift 4 | // iChat 5 | // 6 | // Created by Lion on 2025/4/28. 7 | // 8 | import SwiftUI 9 | 10 | struct ChatSessionView: View { 11 | @Environment(\.modelContext) private var modelContext 12 | @Query var providers: [AIProvider] = [] 13 | 14 | @Bindable var session: ChatSession 15 | 16 | 17 | var body: some View { 18 | VStack { 19 | SessionView(session: session) 20 | InputAreaView(session:session) 21 | } 22 | // .onTapGesture { 23 | // session.message = "" 24 | // } 25 | // .textSelection(.enabled) // 允许选择文本 26 | .navigationTitle(session.title.isEmpty ? "New Chat" : session.title) 27 | .toolbar { 28 | ToolbarItem { 29 | Menu(getSessionModelName()) { 30 | ForEach(providers) { provider in 31 | Menu(provider.title) { 32 | ForEach(provider.models) { model in 33 | Button { 34 | setSessionModel(model: model) 35 | } label: { 36 | Text(model.name) 37 | } 38 | } 39 | } 40 | } 41 | } 42 | } 43 | } 44 | } 45 | 46 | /// 设置会话模型 47 | /// - Parameter model: 选择的模型 48 | private func setSessionModel(model: AIModel) { 49 | session.model = model 50 | try? modelContext.save() 51 | } 52 | 53 | /// 获取会话模型的名称 54 | /// - Returns: 模型名称 55 | private func getSessionModelName() -> String { 56 | guard let model = session.model else { 57 | return "Select Model" 58 | } 59 | return model.name 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Sources/Views/Chat/SessionDetailView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MessageListView.swift 3 | // iChat 4 | // 5 | // Created by Lion on 2025/4/30. 6 | // 7 | 8 | import SwiftData 9 | import SwiftUI 10 | 11 | // MARK: - Message List View 12 | struct SessionDetailView: View { 13 | @Environment(\.modelContext) private var modelContext 14 | let messages: [ChatMessage] 15 | 16 | var body: some View { 17 | ScrollViewReader { proxy in 18 | ScrollView { 19 | LazyVStack(spacing: 10) { 20 | ForEach(messages) { message in 21 | ChatMessageView(message: message).id(message.id) 22 | } 23 | } 24 | .padding() 25 | }.onChange(of: messages.count) { oldValue, newValue in 26 | if oldValue < newValue { 27 | scrollView( 28 | proxy: proxy, 29 | message: messages.last, 30 | anchor: .bottom 31 | ) 32 | } 33 | }.onAppear { 34 | scrollView( 35 | proxy: proxy, 36 | message: messages.last, 37 | anchor: .bottom 38 | ) 39 | } 40 | .scrollClipDisabled() 41 | .padding() 42 | .toolbar { 43 | if messages.count > 0 { 44 | TOCMessageView(messages: messages, proxy: proxy) 45 | } 46 | } 47 | } 48 | } 49 | 50 | private func scrollView( 51 | proxy: ScrollViewProxy, 52 | message: ChatMessage?, 53 | anchor: UnitPoint 54 | ) { 55 | guard let msg = message else { 56 | return 57 | } 58 | withAnimation{ 59 | proxy.scrollTo(msg.id, anchor: anchor) 60 | } 61 | } 62 | 63 | } 64 | -------------------------------------------------------------------------------- /Sources/Views/Chat/Message/ChatOperationView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatReasoning.swift 3 | // iChat 4 | // 5 | // Created by Lion on 2025/5/30. 6 | // 7 | 8 | import SwiftUI 9 | 10 | // 内容 View 11 | struct ChatOperationView: View { 12 | @Environment(\.modelContext) private var modelContext 13 | var message: ChatMessage 14 | @State private var isCopied = false 15 | 16 | var body: some View { 17 | Text( 18 | "\(message.timestamp, style: .date) \(message.timestamp, style: .time)" 19 | ) 20 | .font(.caption) 21 | .foregroundColor(.gray) 22 | HStack { 23 | ShareLink(item: message.content) { 24 | Image(systemName: "square.and.arrow.up") 25 | .frame(width: 15,height: 15) 26 | } 27 | Button { 28 | copyMessage() 29 | isCopied = true 30 | DispatchQueue.main.asyncAfter(deadline: .now()+1){ 31 | isCopied = false 32 | } 33 | } label: { 34 | Image(systemName: isCopied ? "checkmark" : "doc.on.doc") 35 | .frame(width: 15,height: 15) 36 | .foregroundColor(isCopied ? .green : Color.primary) 37 | } 38 | Button { 39 | deleteMessage() 40 | } label: { 41 | Image(systemName: "trash") 42 | .frame(width: 15,height: 15) 43 | } 44 | } 45 | .buttonBorderShape(.circle) 46 | HStack { 47 | Spacer() 48 | } 49 | } 50 | 51 | private func copyMessage() { 52 | NSPasteboard.general.clearContents() 53 | NSPasteboard.general.setString(message.content, forType: .string) 54 | } 55 | 56 | private func deleteMessage() { 57 | withAnimation { 58 | modelContext.delete(message) 59 | try? modelContext.save() 60 | } 61 | } 62 | 63 | } 64 | -------------------------------------------------------------------------------- /Sources/Markdown/Node/ListItemView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Untitled.swift 3 | // iMenu 4 | // 5 | // Created by OSX on 2025/12/3. 6 | // 7 | 8 | import SwiftUI 9 | import Markdown 10 | 11 | // 列表项视图 12 | struct ListItemView: View { 13 | let item: ListItem 14 | let ordered: Bool 15 | let index: Int 16 | @State private var isChecked: Bool = false 17 | 18 | var body: some View { 19 | HStack(alignment: .top, spacing: 8) { 20 | // 复选框或列表标记 21 | if let checkbox = item.checkbox { 22 | Button(action: { 23 | isChecked.toggle() 24 | }) { 25 | Image(systemName: isChecked ? "checkmark.square.fill" : "square") 26 | .foregroundColor(isChecked ? .blue : .secondary) 27 | } 28 | .buttonStyle(.plain) 29 | .onAppear { 30 | isChecked = (checkbox == .checked) 31 | } 32 | } else if ordered { 33 | Text("\(index).") 34 | .frame(minWidth: 20, alignment: .trailing) 35 | } else { 36 | Text("•") 37 | .frame(minWidth: 20, alignment: .center) 38 | } 39 | 40 | // 列表项内容 41 | VStack(alignment: .leading, spacing: 4) { 42 | ForEach(Array(item.children.enumerated()), id: \.offset) { _, child in 43 | if let paragraph = child as? Paragraph { 44 | ParagraphView(paragraph: paragraph) 45 | } else if let nestedList = child as? UnorderedList { 46 | UnorderedListView(list: nestedList) 47 | } else if let nestedList = child as? OrderedList { 48 | OrderedListView(list: nestedList) 49 | } else if let table = child as? Markdown.Table { 50 | TableView(table: table) 51 | } 52 | } 53 | } 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Sources/Markdown/Node/CodeBlockView.swift: -------------------------------------------------------------------------------- 1 | import Markdown 2 | // 3 | // HeadingView.swift 4 | // iMenu 5 | // 6 | // Created by OSX on 2025/12/3. 7 | // 8 | import SwiftUI 9 | 10 | // 代码块视图 11 | struct CodeBlockView: View { 12 | let codeBlock: CodeBlock 13 | @State private var isCopied = false 14 | 15 | var body: some View { 16 | VStack(alignment: .leading, spacing: 0) { 17 | // 代码块头部 18 | HStack { 19 | Text((codeBlock.language ?? "").capitalized) 20 | Spacer() 21 | Button(action: { 22 | NSPasteboard.general.clearContents() 23 | NSPasteboard.general.setString( 24 | codeBlock.code, 25 | forType: .string 26 | ) 27 | isCopied = true 28 | DispatchQueue.main.asyncAfter(deadline: .now() + 1) { 29 | isCopied = false 30 | } 31 | }) { 32 | Image(systemName: isCopied ? "checkmark" : "doc.on.doc") 33 | .frame(width: 15, height: 15) 34 | .foregroundColor(isCopied ? .green : .secondary) 35 | } 36 | .buttonStyle(.plain) 37 | } 38 | .padding(.horizontal, 10) 39 | .padding(.vertical, 5) 40 | .font(.body.bold()) 41 | .foregroundColor(.secondary) 42 | .background(Color.gray.opacity(0.15)) 43 | 44 | // 代码内容 45 | ScrollView(.horizontal, showsIndicators: false) { 46 | Text(codeBlock.code) 47 | .font(.system(.body, design: .monospaced)) 48 | .padding(12) 49 | .frame(maxWidth: .infinity, alignment: .leading) 50 | } 51 | .background(Color.gray.opacity(0.1)) 52 | } 53 | .cornerRadius(8) 54 | .overlay( 55 | RoundedRectangle(cornerRadius: 8) 56 | .stroke(Color.gray.opacity(0.2), lineWidth: 1) 57 | ) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /docs/README_zh.md: -------------------------------------------------------------------------------- 1 | # iChat(AI Chat) 2 | 3 | [[中文](https://ai.ichochy.com/README_zh.html)]|[[English](https://ai.ichochy.com)] 4 | 5 | iChat 是一款开源的智能聊天工具,支持 MiMo、DeepSeek 、Gemini、Grok、OpenAI、ZenMux、OpenRouter、Cloudflare(AI Gateway)和自定义AI,使用 SwiftUI 开发,macOS 原生 AI 客服端 6 | 7 | ## 开发环境 8 | 开发工具:Xcode 16.3 9 | 开发技术:SwiftUI SwiftData 10 | 系统支持:macOS 14+ 11 | 12 | 13 | ## 功能说明 14 | 现支持 MiMo、DeepSeek 、Gemini、Grok、OpenAI、ZenMux、OpenRouter、Cloudflare(AI Gateway)和自定义AI 15 | 1. 实现了 AI 服务商的添加、使用和删除 16 | 2. 实现了模型的加载、添加、使用和删除 17 | 3. 实现了 AI 的会话功能,多个模型灵活切换 18 | 4. Markdown渲染(使用MarkdownUI) 19 | 5. 支持思考模型,流式输出 20 | 6. 菜单栏快速入口 21 | 7. 会话目录导航(TOC) 22 | 8. 自定义提示词(Prompt) 23 | 9. 自定义AI智能体助手(Assistant) 24 | 25 | ## 截图预览 26 | ![Chat.jpg](https://image.ichochy.com/AIChat/Chat.jpg) 27 | 28 | ![TOC.jpg](https://image.ichochy.com/AIChat/TOC.jpg) 29 | 30 | ![Assistant.jpg](https://image.ichochy.com/AIChat/Assistant.jpg) 31 | 32 | ![Assistant Add.jpg](https://image.ichochy.com/AIChat/AssistantAdd.jpg) 33 | 34 | ![General.jpg](https://image.ichochy.com/AIChat/General.jpg) 35 | 36 | ![Provider.jpg](https://image.ichochy.com/AIChat/Provider.jpg) 37 | 38 | ![About.jpg](https://image.ichochy.com/AIChat/About.jpg) 39 | 40 | ## About 41 | ### 博客 42 | [https://ichochy.com](https://ichochy.com) 43 | 44 | ### 网站 45 | [https://ai.ichochy.com](https://ai.ichochy.com) 46 | 47 | ### GitHub 48 | [https://github.com/iChochy/iChat](https://github.com/iChochy/iChat) 49 | 50 | ### 下载 51 | [https://file.ichochy.com/iChat.zip](https://file.ichochy.com/iChat.zip) 52 | 53 | ### 安装说明 54 | **注意:** 因未使用开发者签名,首次运行会触发 macOS 安全提示。 55 | **前往 “系统设置 > 隐私与安全性”,选择 “仍要打开”** 56 | 57 | 58 | ## 更新 59 | ### 20251217(0.2(7)) 60 | * 添加Xiaomi MiMo 提供商 61 | * 整体样式细节优化 62 | 63 | ### 20251210(0.1(10)) 64 | * 添加更多的 AI 提供商 65 | * 优化支持 macOS 26 66 | * 优化页面懒加载问题 67 | * 整体样式细节优化 68 | 69 | ### 20250822(0.1(5)) 70 | * 完善Gemini的思考信息展示 71 | * 调整设置按钮、输入框的样式 72 | * 整体样式细节优化 73 | 74 | ### 20250619(0.1(4)) 75 | * 添加AI智能体助手设置(Assistant) 76 | * 添加自定义提示词(Prompt) 77 | * 添加加温度参数设置(temperature) 78 | * 整体样式细节优化 79 | 80 | ### 202506010(0.1(3)) 81 | * 整体样式细节优化 82 | * 添加会话目录导航(TOC) 83 | 84 | ### 20250605(0.1(2)) 85 | * 添加DeepSeek的支持 86 | * 添加MenuBar快速入口 87 | * 更新输入框样式 88 | 89 | ## 鸣谢: 90 | Swift Markdown: [https://github.com/swiftlang/swift-markdown](https://github.com/swiftlang/swift-markdown) 91 | 92 | ## 自嗨 93 | 自娱自乐,后续持续跟进……………… 94 | 功能还在开发完善中 …………………… 95 | 96 | -------------------------------------------------------------------------------- /Sources/Views/SettingView/Provider/ModelEditorView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProviderView.swift 3 | // iChat 4 | // 5 | // Created by Lion on 2025/5/19. 6 | // 7 | 8 | import SwiftData 9 | import SwiftUI 10 | 11 | struct ModelEditorView: View { 12 | @Environment(\.modelContext) private var context 13 | @Bindable var provider: AIProvider 14 | @State var isPresentedAdd = false 15 | @State var isPresentedEdit = false 16 | 17 | var body: some View { 18 | Section { 19 | ForEach(provider.models) { item in 20 | Divider() 21 | HStack { 22 | Text(item.name).tag(item) 23 | Spacer() 24 | Button { 25 | delete(model: item) 26 | } label: { 27 | Image(systemName: "minus") 28 | .frame(width: 15,height: 15) 29 | }.buttonBorderShape(.circle) 30 | } 31 | } 32 | } header: { 33 | HStack { 34 | Text("Models").font(.title2) 35 | Button { 36 | openModelFetchView() 37 | } label: { 38 | Label("fetch", systemImage: "square.and.arrow.down") 39 | }.disabled(provider.APIKey.isEmpty || provider.APIURL.isEmpty) 40 | .buttonStyle(.borderedProminent) 41 | .sheet(isPresented: $isPresentedEdit){ 42 | ModelFetchView(provider: provider) 43 | } 44 | Spacer() 45 | Button { 46 | openModelAddView() 47 | } label: { 48 | Image(systemName: "plus") 49 | }.buttonBorderShape(.circle) 50 | .background(Color.accentColor) 51 | .clipShape(.circle) 52 | .sheet( 53 | isPresented: $isPresentedAdd, 54 | ) { 55 | ModelAddView(provider: provider) 56 | } 57 | } 58 | } 59 | } 60 | 61 | 62 | 63 | private func delete(model: AIModel) { 64 | context.delete(model) 65 | try? context.save() 66 | } 67 | private func openModelAddView() { 68 | isPresentedAdd.toggle() 69 | } 70 | private func openModelFetchView() { 71 | isPresentedEdit.toggle() 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /Sources/Helper/ResponseContentHelper.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ResponseContentHelper.swift 3 | // iChat 4 | // 5 | // Created by OSX on 2025/6/20. 6 | // 7 | 8 | import Foundation 9 | 10 | 11 | class ResponseContentHelper{ 12 | let message:ChatMessage 13 | var totalCount = 1 14 | var accumulatedContent = "" 15 | var accumulatedReasoning = "" 16 | 17 | init(message: ChatMessage) { 18 | self.message = message 19 | } 20 | 21 | func contentHelper(stream:AsyncThrowingStream) async throws { 22 | for try await chunk in stream { 23 | setReasoning(reasoning: chunk.reasoning) 24 | setContent(content: chunk.content) 25 | } 26 | // 处理剩余的积累内容,最后追加信息 27 | if !accumulatedContent.isEmpty { 28 | message.content.append(accumulatedContent) 29 | accumulatedContent = "" 30 | } 31 | message.isStreaming = false 32 | print(totalCount) 33 | } 34 | 35 | 36 | func setReasoning(reasoning:String?){ 37 | if let reasoning = reasoning { 38 | accumulatedReasoning.append(reasoning) 39 | if accumulatedReasoning.count > totalCount { 40 | message.reasoning.append(accumulatedReasoning) 41 | accumulatedReasoning = "" 42 | totalCount += 1 43 | // try await Task.sleep(nanoseconds: 300_000_000) 44 | } 45 | // 处理剩余的积累内容 46 | } else if !accumulatedReasoning.isEmpty { 47 | message.reasoning.append(accumulatedReasoning) 48 | accumulatedReasoning = "" 49 | } 50 | } 51 | 52 | 53 | func setContent(content:String?){ 54 | if let content = content { 55 | accumulatedContent.append(content) 56 | if accumulatedContent.count > totalCount { 57 | message.content.append(accumulatedContent) 58 | accumulatedContent = "" 59 | totalCount += 1 60 | // try await Task.sleep(nanoseconds: 300_000_000) 61 | } 62 | // 处理剩余的积累内容 63 | }else if !accumulatedContent.isEmpty { 64 | message.content.append(accumulatedContent) 65 | accumulatedContent = "" 66 | } 67 | } 68 | 69 | private func setReasoning(message: ChatMessage, reasoning: String?) { 70 | if let reasoning = reasoning { 71 | message.reasoning.append(contentsOf: reasoning) 72 | } 73 | } 74 | 75 | private func setContent(message: ChatMessage, content: String?) { 76 | if let content = content { 77 | message.content.append(contentsOf: content) 78 | } 79 | } 80 | 81 | } 82 | -------------------------------------------------------------------------------- /Sources/Views/Assistant/AssistantSideView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PromptView.swift 3 | // iChat 4 | // 5 | // Created by Lion on 2025/6/9. 6 | // 7 | 8 | import SwiftData 9 | import SwiftUI 10 | 11 | struct AssistantSideView: View { 12 | @Environment(\.openWindow) private var openWindow 13 | 14 | 15 | @State private var selectedItem:[UUID:Bool] = [:] 16 | 17 | @Query(filter: #Predicate { $0.isFavorite == true }) 18 | var assistants: [Assistant] = [] 19 | 20 | var createSession: (Assistant) -> Void 21 | let assistant = Assistant() 22 | 23 | var body: some View { 24 | Section { 25 | Divider() 26 | Button { 27 | createSession(assistant) 28 | } label: { 29 | HStack { 30 | Text("Ask AI Chat") 31 | Spacer() 32 | }.padding(5) 33 | .background(selectedItem[assistant.id] ?? false ? Color.accentColor : Color.clear) 34 | .foregroundColor(selectedItem[assistant.id] ?? false ? .white : .primary) 35 | .cornerRadius(5) 36 | }.buttonStyle(.plain) 37 | .onHover { hover in 38 | selectedItem[assistant.id] = hover 39 | } 40 | ForEach(assistants) { item in 41 | Button { 42 | selectedItem[item.id] = false 43 | createSession(item) 44 | } label: { 45 | HStack{ 46 | Text(item.title.isEmpty ? "AI Assistant" : item.title) 47 | Spacer() 48 | Button { 49 | item.isFavorite.toggle() 50 | } label: { 51 | Image(systemName: "heart.slash") 52 | }.buttonBorderShape(.circle) 53 | .help("Cancel Favorites") 54 | .shadow(radius: 10) 55 | } 56 | .padding(5) 57 | .background(selectedItem[item.id] ?? false ? Color.accentColor : Color.clear) 58 | .foregroundColor(selectedItem[item.id] ?? false ? .white : .primary) 59 | .cornerRadius(5) 60 | } 61 | .buttonStyle(.plain) 62 | .onHover { hover in 63 | selectedItem[item.id] = hover 64 | } 65 | } 66 | } header: { 67 | HStack { 68 | Text("Assistant").font(.title).bold() 69 | Button { 70 | NSApp.activate(ignoringOtherApps: true) 71 | openWindow(id: "Assistant") 72 | } label: { 73 | Image(systemName: "ellipsis") 74 | .frame(width: 15, height: 15) 75 | }.buttonBorderShape(.circle) 76 | .help("All assistant information") 77 | } 78 | } 79 | } 80 | 81 | } 82 | -------------------------------------------------------------------------------- /Sources/Views/Chat/ViewModels/ChatViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatViewModel.swift 3 | // iChat 4 | // 5 | // Created by Lion on 2025/4/30. 6 | // 7 | 8 | import SwiftData 9 | import SwiftUI 10 | 11 | // MARK: - View Model 12 | @MainActor 13 | class ChatViewModel: ObservableObject { 14 | @AppStorage("language") var language = LanguageEnum.auto 15 | @AppStorage("nickname") var nickname = 16 | Bundle.main.infoDictionary?["CFBundleName"] as? String ?? "iChat" 17 | @Published var userInput: String = "" 18 | @Published var isSending: Bool = false 19 | 20 | func sendMessage(session: ChatSession, modelContext: ModelContext) { 21 | let userContent = userInput.trimmingCharacters( 22 | in: .whitespacesAndNewlines 23 | ) 24 | guard 25 | !userContent.isEmpty, 26 | !isSending 27 | else { return } 28 | 29 | userInput = "" 30 | isSending = true 31 | 32 | let userMessage = ChatMessage( 33 | modelName: nickname, 34 | content: userContent, 35 | role: .user, 36 | session: session 37 | ) 38 | 39 | if session.title.isEmpty { 40 | session.title = userContent 41 | } 42 | session.message = "" 43 | 44 | modelContext.insert(userMessage) 45 | try? modelContext.save() 46 | 47 | Task { 48 | await handleAIResponse(session: session, modelContext: modelContext) 49 | } 50 | } 51 | 52 | private func handleAIResponse( 53 | session: ChatSession, 54 | modelContext: ModelContext 55 | ) async { 56 | defer { 57 | isSending = false 58 | } 59 | 60 | guard let model = session.model else { 61 | session.message = "Please select model!" 62 | return 63 | } 64 | 65 | guard let provider = model.provider else { 66 | session.message = String(describing: AIError.MissingProvider) 67 | return 68 | } 69 | 70 | do { 71 | let stream = try await provider.type.data.service 72 | .streamChatResponse( 73 | provider: provider, 74 | model: model, 75 | messages: session.sortedMessages, 76 | temperature: session.temperature 77 | ) 78 | let assistantMessage = ChatMessage( 79 | modelName: model.name, 80 | content: "", 81 | role: .assistant, 82 | isStreaming: true, 83 | session: session 84 | ) 85 | 86 | await MainActor.run { 87 | modelContext.insert(assistantMessage) 88 | try? modelContext.save() 89 | } 90 | 91 | try await ResponseContentHelper(message: assistantMessage) 92 | .contentHelper(stream: stream) 93 | } catch { 94 | session.message = String(describing: error) 95 | } 96 | 97 | } 98 | 99 | } 100 | -------------------------------------------------------------------------------- /iChat.xcodeproj/xcshareddata/xcschemes/iChat.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 22 | 23 | 24 | 25 | 26 | 32 | 33 | 43 | 45 | 51 | 52 | 53 | 54 | 60 | 62 | 68 | 69 | 70 | 71 | 73 | 74 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /Sources/Views/Chat/InputAreaView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // InputAreaView.swift 3 | // iChat 4 | // 5 | // Created by Lion on 2025/4/30. 6 | // 7 | 8 | import SwiftData 9 | import SwiftUI 10 | 11 | // MARK: - Input Area View 12 | struct InputAreaView: View { 13 | @Environment(\.modelContext) private var modelContext 14 | @StateObject var chatViewModel: ChatViewModel = ChatViewModel() 15 | @State private var textHeight: CGFloat = 0 16 | @Bindable var session: ChatSession 17 | 18 | var body: some View { 19 | HStack(alignment: .bottom, spacing: 0) { 20 | TextEditor(text: $chatViewModel.userInput) 21 | .scrollContentBackground(.hidden) 22 | .disabled(chatViewModel.isSending) 23 | .frame(height: min(200, max(28, textHeight))) 24 | .background( 25 | Text( 26 | chatViewModel.userInput.isEmpty 27 | ? "" : chatViewModel.userInput 28 | ) 29 | .fixedSize(horizontal: false, vertical: true) 30 | .hidden() 31 | .background( 32 | GeometryReader { geometry in 33 | Color.clear.onAppear { 34 | textHeight = geometry.size.height 35 | } 36 | .onChange( 37 | of: chatViewModel.userInput, 38 | { 39 | textHeight = geometry.size.height 40 | } 41 | ) 42 | } 43 | ) 44 | .allowsHitTesting(false) 45 | ) 46 | .padding(10) 47 | .font(.title) 48 | .scrollIndicators(.hidden) 49 | 50 | Button { 51 | sendMessage() 52 | } label: { 53 | if chatViewModel.isSending { 54 | ZStack { 55 | ProgressView().scaleEffect(0.8) 56 | Image(systemName: "arrow.up.circle.fill") 57 | .font(.system(size: 45)).hidden() 58 | } 59 | } else { 60 | Image(systemName: "arrow.up.circle.fill") 61 | .font(.system(size: 45)) 62 | } 63 | } 64 | .keyboardShortcut(.return, modifiers: .command) 65 | .disabled( 66 | chatViewModel.userInput.trimmingCharacters( 67 | in: .whitespacesAndNewlines 68 | ) 69 | .isEmpty || chatViewModel.isSending 70 | ) 71 | .buttonStyle(.link) 72 | } 73 | .overlay( 74 | RoundedRectangle(cornerRadius: 25) 75 | .stroke(Color.gray.opacity(0.8), lineWidth: 2) 76 | ) 77 | .background(.quaternary) 78 | .background() 79 | .cornerRadius(25) 80 | .padding(.bottom,10) 81 | .padding(.horizontal, 50) 82 | .shadow(radius: 10) 83 | } 84 | 85 | func sendMessage() { 86 | chatViewModel.sendMessage(session: session, modelContext: modelContext) 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /Sources/Main.swift: -------------------------------------------------------------------------------- 1 | // 2 | // iChat.swift 3 | // iChat 4 | // 5 | // Created by Lion on 2025/4/1. 6 | // 7 | 8 | import SwiftData 9 | import SwiftUI 10 | 11 | @main 12 | struct Main: App { 13 | @Environment(\.colorScheme) var colorScheme 14 | @AppStorage("appearance") var appearance = AppearanceEnum.system 15 | @AppStorage("isInserted") var isInserted = true 16 | var sharedModelContainer: ModelContainer = { 17 | let schema = Schema([ 18 | ChatSession.self, 19 | ChatMessage.self, 20 | AIProvider.self, 21 | AIModel.self, 22 | Assistant.self 23 | ]) 24 | let modelConfiguration = ModelConfiguration( 25 | schema: schema, 26 | isStoredInMemoryOnly: false 27 | ) 28 | do { 29 | return try ModelContainer( 30 | for: schema, 31 | configurations: [modelConfiguration] 32 | ) 33 | } catch { 34 | fatalError("Could not create ModelContainer: \(error)") 35 | } 36 | }() 37 | 38 | var body: some Scene { 39 | WindowGroup(id: "Chat") { 40 | // MarkdownParserView() 41 | // AssistantView() 42 | MainView() 43 | .onAppear(perform: { 44 | NSApp.appearance = appearance.name 45 | }).onChange( 46 | of: appearance, 47 | { 48 | NSApp.appearance = appearance.name 49 | } 50 | ) 51 | .frame(minWidth: 800, minHeight: 500) // 可以设置最小尺寸 52 | }.modelContainer(sharedModelContainer) 53 | 54 | 55 | MenuBarExtra(isInserted: $isInserted) { 56 | MenuBarExtraView() 57 | } label: { 58 | Image("Status").renderingMode(.original) 59 | } 60 | 61 | Window("Settings", id: "Settings") { // 给窗口一个标题和 ID 62 | SettingsView() 63 | .onAppear(perform: { 64 | NSApp.appearance = appearance.name 65 | }).onChange( 66 | of: appearance, 67 | { 68 | NSApp.appearance = appearance.name 69 | } 70 | ) 71 | .frame(minWidth: 600, minHeight: 400) // 可以设置最小尺寸 72 | }.modelContainer(sharedModelContainer) 73 | 74 | Window("Assistant", id: "Assistant") { // 给窗口一个标题和 ID 75 | AssistantView() 76 | .onAppear(perform: { 77 | NSApp.appearance = appearance.name 78 | }).onChange( 79 | of: appearance, 80 | { 81 | NSApp.appearance = appearance.name 82 | } 83 | ) 84 | .frame(minWidth: 400, minHeight: 400) // 可以设置最小尺寸 85 | }.modelContainer(sharedModelContainer) 86 | 87 | // 定义你的置顶窗口场景 88 | // Window("Pinned Utility Panel", id: "pinned-window") { // 给窗口一个标题和 ID 89 | // WindowView() // 这是置顶窗口的内容视图 90 | // .frame(minWidth: 800, minHeight: 500) // 可以设置最小尺寸 91 | // } 92 | // .windowResizability(.contentSize) // 可以让窗口大小根据内容调整,不可手动调整大小 93 | 94 | } 95 | 96 | } 97 | -------------------------------------------------------------------------------- /Sources/Views/SettingView/GeneralView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GeneralView.swift 3 | // iChat 4 | // 5 | // Created by Lion on 2025/5/19. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct GeneralView: View { 11 | let title = "General" 12 | let icon = "gear" 13 | @AppStorage("fontSize") var fontSize = 15.0 14 | @AppStorage("nickname") var nickname = 15 | Bundle.main.infoDictionary?["CFBundleName"] as? String ?? "iChat" 16 | @AppStorage("language") var language = LanguageEnum.auto 17 | @AppStorage("appearance") var appearance = AppearanceEnum.system 18 | @AppStorage("isInserted") var isInserted = true 19 | 20 | var body: some View { 21 | ScrollView { 22 | Form { 23 | VStack(alignment: .leading, spacing: 15) { 24 | GroupBox { 25 | Section( 26 | "Open menu bar icon?" 27 | ) { 28 | Picker("MenuBar", selection: $isInserted) { 29 | Text("Open").tag(true) 30 | Text("Close").tag(false) 31 | }.pickerStyle(.segmented).padding() 32 | } 33 | Divider() 34 | Section( 35 | "Select app appearance?" 36 | ) { 37 | Picker("Appearance", selection: $appearance) { 38 | ForEach(AppearanceEnum.allCases) { item in 39 | Text(item.rawValue) 40 | } 41 | }.pickerStyle(.segmented).padding() 42 | 43 | } 44 | } label: { 45 | Label("App Settings", systemImage: "gearshape") 46 | .font(.title2) 47 | } 48 | 49 | GroupBox { 50 | Section("What should we call you?") { 51 | 52 | TextField( 53 | "Nickname", 54 | text: $nickname, 55 | prompt: Text("What should we call you?") 56 | ).padding() 57 | .frame(maxWidth: 200) 58 | 59 | } 60 | Divider() 61 | Section( 62 | "Select the language for AI response?" 63 | ) { 64 | Picker("Language", selection: $language) { 65 | ForEach(LanguageEnum.allCases) { item in 66 | Text(item.rawValue) 67 | } 68 | }.pickerStyle(.segmented).padding() 69 | } 70 | } label: { 71 | Label( 72 | "AI Settings", 73 | systemImage: "bubble.left.and.bubble.right" 74 | ).font(.title2) 75 | } 76 | } 77 | }.padding() 78 | .textFieldStyle(.roundedBorder) 79 | .navigationTitle("General") 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /Sources/Views/SettingView/Detail/ServiceModelsEditorView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ServiceView.swift 3 | // AIClient 4 | // 5 | // Created by Lion on 2025/5/19. 6 | // 7 | 8 | import SwiftData 9 | import SwiftUI 10 | 11 | struct ServiceModelEditorView: View { 12 | @Environment(\.modelContext) private var context 13 | 14 | @Bindable var provider: AIProvider 15 | 16 | @State var isPresented = false 17 | @State var name: String = "" 18 | 19 | var body: some View { 20 | Section { 21 | ForEach(provider.models) { item in 22 | HStack { 23 | Text(item.name).tag(item) 24 | Spacer() 25 | Button { 26 | delete(model: item) 27 | } label: { 28 | Image(systemName: "minus") 29 | }.buttonBorderShape(.circle) 30 | } 31 | } 32 | } header: { 33 | HStack { 34 | Text("Models").font(.title2) 35 | Button { 36 | fetch() 37 | } label: { 38 | Label("fetch", systemImage: "square.and.arrow.down") 39 | }.disabled(provider.APIKey.isEmpty || provider.APIURL.isEmpty) 40 | .buttonStyle(.borderedProminent) 41 | Spacer() 42 | Button { 43 | show() 44 | } label: { 45 | Image(systemName: "plus") 46 | }.buttonBorderShape(.circle) 47 | .background(Color.accentColor) 48 | .clipShape(.circle) 49 | .sheet( 50 | isPresented: $isPresented, 51 | onDismiss: { 52 | name = "" 53 | } 54 | ) { 55 | GroupBox("Add Model") { 56 | Form { 57 | TextField("name", text: $name).textFieldStyle( 58 | .roundedBorder 59 | ) 60 | HStack { 61 | Spacer() 62 | Button { 63 | add() 64 | } label: { 65 | Label("Add", systemImage: "plus") 66 | }.disabled(name == "") 67 | .keyboardShortcut(.return,modifiers:[]) 68 | .buttonStyle(.borderedProminent) 69 | } 70 | }.padding() 71 | }.padding() 72 | } 73 | } 74 | Divider() 75 | } 76 | } 77 | 78 | private func fetch() { 79 | 80 | } 81 | 82 | private func delete(model: AIModel) { 83 | context.delete(model) 84 | try? context.save() 85 | } 86 | private func show() { 87 | isPresented.toggle() 88 | } 89 | private func add() { 90 | isPresented.toggle() 91 | let model = AIModel(name: name, isSystem: false, provider: provider) 92 | context.insert(model) 93 | try? context.save() 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /Sources/Views/SettingView/Provider/ProviderView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProviderView.swift 3 | // iChat 4 | // 5 | // Created by Lion on 2025/5/19. 6 | // 7 | 8 | import SwiftData 9 | import SwiftUI 10 | 11 | struct ProviderView: View { 12 | let title = "Provider" 13 | let icon = "server.rack" 14 | 15 | @State var APIKey: String = "" 16 | @Environment(\.modelContext) private var context 17 | 18 | @State var selectModel: AIModel? 19 | 20 | @State private var selectedProvider: String = "选择 AI 提供商" 21 | 22 | @Query 23 | var providers: [AIProvider] = [] 24 | 25 | var body: some View { 26 | 27 | List(content: { 28 | if providers.isEmpty { 29 | noDataView() 30 | } else { 31 | ForEach(providers) { item in 32 | ProviderEditorView(provider: item) 33 | } 34 | } 35 | }).scrollContentBackground(.hidden) 36 | .toolbar { 37 | ToolbarItemGroup { 38 | Menu { 39 | ForEach(AIProviderEnum.allCases) { item in 40 | Button { 41 | addProvider(type: item) 42 | } label: { 43 | HStack { 44 | Image(systemName: "plus").foregroundStyle( 45 | Color.accentColor 46 | ) 47 | Text(item.data.title) 48 | } 49 | } 50 | } 51 | } label: { 52 | Label("Plus", systemImage: "plus") 53 | } 54 | } 55 | } 56 | .navigationTitle("Provider") 57 | } 58 | 59 | private func addProvider(type: AIProviderEnum) { 60 | let model = type.data 61 | let provider = AIProvider( 62 | title: model.title, 63 | APIURL: model.APIURL, 64 | type: type 65 | ) 66 | context.insert(provider) 67 | try? context.save() 68 | } 69 | 70 | @ViewBuilder 71 | func noDataView() -> some View{ 72 | HStack { 73 | Spacer() 74 | Text("No Data").font(.title) 75 | Menu { 76 | ForEach(AIProviderEnum.allCases) { item in 77 | Button { 78 | addProvider(type: item) 79 | } label: { 80 | HStack { 81 | Image(systemName: "plus").foregroundStyle( 82 | Color.accentColor 83 | ) 84 | Text(item.data.title) 85 | } 86 | } 87 | } 88 | } label: { 89 | Image(systemName: "plus") 90 | .padding(5) 91 | .background(Color.accentColor) 92 | .foregroundColor(.white) 93 | .clipShape(.buttonBorder) 94 | } 95 | Spacer() 96 | }.padding(10) 97 | .buttonStyle(PlainButtonStyle()) 98 | } 99 | 100 | } 101 | -------------------------------------------------------------------------------- /Sources/Markdown/Node/ParagraphView.swift: -------------------------------------------------------------------------------- 1 | import Markdown 2 | // 3 | // HeadingView.swift 4 | // iMenu 5 | // 6 | // Created by OSX on 2025/12/3. 7 | // 8 | import SwiftUI 9 | 10 | // 段落视图 11 | struct ParagraphView: View { 12 | let paragraph: Paragraph 13 | 14 | var body: some View { 15 | let content = contentView(for: paragraph) 16 | content.padding(.vertical, 2) 17 | } 18 | 19 | @ViewBuilder 20 | func contentView(for paragraph: Paragraph) -> some View { 21 | // 检查是否包含图片 22 | if hasImage(in: paragraph) { 23 | VStack(alignment: .leading, spacing: 8) { 24 | ForEach(Array(paragraph.children.enumerated()), id: \.offset) { 25 | _, 26 | child in 27 | if let image = child as? Markdown.Image { 28 | ImageView(image: image) 29 | } else { 30 | Text(attributedString(for: child)) 31 | } 32 | } 33 | } 34 | } else { 35 | Text(attributedString(for: paragraph)) 36 | .lineLimit(nil) // 不限制行数 37 | .multilineTextAlignment(.leading) 38 | .fixedSize(horizontal: false, vertical: true) 39 | } 40 | } 41 | 42 | func hasImage(in markup: any Markup) -> Bool { 43 | for child in markup.children { 44 | if child is Markdown.Image { 45 | return true 46 | } 47 | } 48 | return false 49 | } 50 | 51 | func attributedString(for markup: any Markup) -> AttributedString { 52 | var result = AttributedString() 53 | 54 | for child in markup.children { 55 | if let text = child as? Markdown.Text { 56 | result += AttributedString(text.string) 57 | } else if let strong = child as? Strong { 58 | var boldText = attributedString(for: strong) 59 | boldText.font = .body.bold() 60 | result += boldText 61 | } else if let emphasis = child as? Emphasis { 62 | var italicText = attributedString(for: emphasis) 63 | italicText.font = .body.italic() 64 | result += italicText 65 | } else if let code = child as? InlineCode { 66 | var codeText = AttributedString(code.code) 67 | codeText.font = .system(.body, design: .monospaced) 68 | codeText.backgroundColor = Color.gray.opacity(0.2) 69 | result += codeText 70 | } else if let link = child as? Markdown.Link { 71 | var linkText = AttributedString(link.plainText) 72 | linkText.foregroundColor = Color.blue 73 | linkText.underlineStyle = .single 74 | if let url = link.destination { 75 | linkText.link = URL(string: url) 76 | } 77 | result += linkText 78 | } else if let strikethrough = child as? Strikethrough { 79 | var strikeText = AttributedString(strikethrough.plainText) 80 | strikeText.strikethroughStyle = .single 81 | result += strikeText 82 | } else if child.childCount > 0 { 83 | result += attributedString(for: child) 84 | } 85 | } 86 | 87 | return result 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /Sources/Markdown/Node/ImageView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ImageView.swift 3 | // iMenu 4 | // 5 | // Created by OSX on 2025/12/3. 6 | // 7 | 8 | import Markdown 9 | import SwiftUI 10 | import UniformTypeIdentifiers 11 | 12 | // 图片视图 13 | struct ImageView: View { 14 | let image: Markdown.Image 15 | @State private var imgageData: Data? 16 | @State private var isLoading = false 17 | @State private var showSavePanel = false 18 | 19 | let shareName = "Share" 20 | 21 | var body: some View { 22 | VStack(alignment: .leading, spacing: 0) { 23 | HStack(alignment: .center, spacing: 5) { 24 | Text((image.title ?? image.plainText).capitalized) 25 | Spacer() 26 | if let data = imgageData, let nsImage = NSImage(data: data) { 27 | 28 | let document = ImageDocument(data: data, name: shareName) 29 | ShareButtonView(document: document) 30 | 31 | let photo = SharePhoto( 32 | image: Image(nsImage: nsImage), 33 | name: shareName 34 | ) 35 | SharePhotoView(photo: photo) 36 | } 37 | }.font(.body.bold()) 38 | .buttonStyle(.plain) 39 | .foregroundColor(.secondary) 40 | .padding(.horizontal, 10) 41 | .padding(.vertical, 5) 42 | .background(Color.gray.opacity(0.15)) 43 | if let data = imgageData, let nsImage = NSImage(data: data) { 44 | Image(nsImage: nsImage) 45 | .resizable() 46 | .scaledToFit() 47 | .shadow(radius: 4) 48 | } else if isLoading { 49 | ProgressView() 50 | .frame(height: 100) 51 | } else { 52 | HStack { 53 | Image(systemName: "photo") 54 | Text(image.source ?? image.plainText) 55 | .foregroundColor(.secondary) 56 | } 57 | .frame(height: 100) 58 | .frame(maxWidth: .infinity) 59 | .background(Color.gray.opacity(0.1)) 60 | } 61 | } 62 | .background(Color.gray.opacity(0.1)) 63 | .cornerRadius(8) 64 | .overlay( 65 | RoundedRectangle(cornerRadius: 8) 66 | .stroke(Color.gray.opacity(0.2), lineWidth: 1) 67 | ) 68 | .onAppear { 69 | loadImage() 70 | } 71 | } 72 | 73 | func loadImage() { 74 | guard let source = image.source else { return } 75 | // 如果是 URL 76 | if source.hasPrefix("http://") || source.hasPrefix("https://") { 77 | guard let url = URL(string: source) else { return } 78 | isLoading = true 79 | URLSession.shared.dataTask(with: url) { data, _, _ in 80 | DispatchQueue.main.async { 81 | isLoading = false 82 | if let data = data { 83 | imgageData = data 84 | } 85 | } 86 | }.resume() 87 | } else { 88 | let fileURL = URL(fileURLWithPath: source) 89 | do { 90 | let data = try Data(contentsOf: fileURL) 91 | imgageData = data 92 | } catch { 93 | print("加载文件失败: \(error.localizedDescription)") 94 | } 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # iChat(AI Chat) 2 | 3 | [[中文](https://ai.ichochy.com/README_zh.html)]|[[English](https://ai.ichochy.com)] 4 | 5 | iChat is an open-source intelligent chat application that supports MiMo, DeepSeek, Gemini, Grok, OpenAI, ZenMux, OpenRouter, Cloudflare(AI Gateway) and Custom AI providers. Developed with SwiftUI, it is a native macOS AI client application. 6 | 7 | ## Development Environment 8 | Development Tool: Xcode 16.3 9 | Development Technologies: SwiftUI, SwiftData 10 | System Requirements: macOS 14+ 11 | 12 | ## Features 13 | Currently supports MiMo, DeepSeek, Gemini, Grok, OpenAI, ZenMux, OpenRouter, Cloudflare(AI Gateway) and Custom AI providers. 14 | 1. Allows adding, using, and deleting AI service providers. 15 | 2. Enables loading, adding, using, and deleting models. 16 | 3. Implements AI conversation functionality with flexible switching between multiple models. 17 | 4. Markdown rendering (powered by MarkdownUI). 18 | 5. Supports thinking model and streaming output. 19 | 6. Quick access from the menu bar. 20 | 7. Conversation table of contents (TOC) navigation. 21 | 8. Customizable prompts. 22 | 9. Customizable AI Assistant agents. 23 | 24 | ## Screenshots 25 | ![Chat.jpg](https://image.ichochy.com/AIChat/Chat.jpg) 26 | 27 | ![TOC.jpg](https://image.ichochy.com/AIChat/TOC.jpg) 28 | 29 | ![Assistant.jpg](https://image.ichochy.com/AIChat/Assistant.jpg) 30 | 31 | ![Assistant Add.jpg](https://image.ichochy.com/AIChat/AssistantAdd.jpg) 32 | 33 | ![General.jpg](https://image.ichochy.com/AIChat/General.jpg) 34 | 35 | ![Provider.jpg](https://image.ichochy.com/AIChat/Provider.jpg) 36 | 37 | ![About.jpg](https://image.ichochy.com/AIChat/About.jpg) 38 | 39 | ## About 40 | ### Blog 41 | [https://ichochy.com](https://ichochy.com) 42 | 43 | ### Website 44 | [https://ai.ichochy.com](https://ai.ichochy.com) 45 | 46 | ### GitHub 47 | [https://github.com/iChochy/iChat](https://github.com/iChochy/iChat) 48 | 49 | ### Download 50 | [https://file.ichochy.com/iChat.zip](https://file.ichochy.com/iChat.zip) 51 | 52 | ### Installation Instructions 53 | **Note:** As the application is not signed with an Apple Developer ID, macOS will display a security warning upon first launch. 54 | **Please go to "System Settings > Privacy & Security" and click "Open Anyway"** to proceed. 55 | 56 | ## Updates 57 | ### 20251217(0.2(7)) 58 | * Add Xiaomi MiMo AI providers 59 | * Overall style detail optimizations 60 | 61 | ### 20251210(0.1(10)) 62 | * Add More AI providers 63 | * Optimize support for macOS 26 64 | * Optimize page lazy loading issues 65 | * Overall style detail optimizations 66 | 67 | ### 20250822(0.1(5)) 68 | * Enhanced Gemini's thought process display. 69 | * Adjusted the styling of the settings button and input field. 70 | * Overall style details optimized. 71 | 72 | ### 20250619 (0.1(4)) 73 | * Added AI Assistant settings. 74 | * Added customizable prompts. 75 | * Added temperature parameter settings. 76 | * Overall style details optimized. 77 | 78 | ### 20250610 (0.1(3)) 79 | * Overall style details optimized. 80 | * Added conversation table of contents (TOC) navigation. 81 | 82 | ### 20250605 (0.1(2)) 83 | * Added DeepSeek support. 84 | * Added Menu Bar quick access. 85 | * Updated input box style. 86 | 87 | ## Acknowledgements: 88 | Swift Markdown: [https://github.com/swiftlang/swift-markdown](https://github.com/swiftlang/swift-markdown) 89 | 90 | ## Self-Commentary 91 | Built for personal enjoyment and ongoing development. 92 | Features are still under active development and continuously being improved. 93 | -------------------------------------------------------------------------------- /Sources/Views/SettingView/Provider/ProviderEditorView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProviderView.swift 3 | // iChat 4 | // 5 | // Created by Lion on 2025/5/19. 6 | // 7 | 8 | import SwiftData 9 | import SwiftUI 10 | 11 | struct ProviderEditorView: View { 12 | @Environment(\.modelContext) private var context 13 | 14 | @Bindable var provider: AIProvider 15 | @State var isShowAlert = false 16 | @State var isKeyVisible = true 17 | 18 | var body: some View { 19 | Section { 20 | Form { 21 | TextField( 22 | "APIURL", 23 | text: $provider.APIURL, 24 | prompt: Text("e.g. https://api.ichochy.com") 25 | ).textFieldStyle( 26 | .roundedBorder 27 | ) 28 | ZStack { 29 | HStack { 30 | if isKeyVisible { 31 | SecureField( 32 | "APIKey", 33 | text: $provider.APIKey, 34 | prompt: Text("API Key") 35 | ) 36 | } else { 37 | TextField( 38 | "APIKey", 39 | text: $provider.APIKey, 40 | prompt: Text("API Key") 41 | ) 42 | } 43 | Button(action: { 44 | isKeyVisible.toggle() 45 | }) { 46 | Image( 47 | systemName: isKeyVisible 48 | ? "eye" : "eye.slash.fill" 49 | ) 50 | }.buttonStyle(.borderless) 51 | } 52 | } 53 | ModelEditorView(provider: provider) 54 | }.textFieldStyle(.roundedBorder) 55 | } header: { 56 | HStack { 57 | Spacer() 58 | Text(provider.title) 59 | .font(.title) 60 | .fontWeight(.bold) 61 | .foregroundStyle(Color.accentColor) 62 | Button { 63 | showAlert() 64 | } label: { 65 | Image(systemName: "trash") 66 | .frame(width: 15, height: 15) 67 | } 68 | .buttonBorderShape(.circle) 69 | .alert("确认删除吗?", isPresented: $isShowAlert) { 70 | Button("Cancel", role: .cancel) { isShowAlert.toggle() } 71 | Button("Delete", role: .destructive) { 72 | delete(provider: provider) 73 | } 74 | } message: { 75 | Text(provider.title) 76 | } 77 | Spacer() 78 | if let url = URL(string: provider.type.data.supportUrl) { 79 | Button { 80 | NSWorkspace.shared.open(url) 81 | } label: { 82 | Image(systemName: "safari") 83 | }.buttonBorderShape(.circle) 84 | } 85 | 86 | } 87 | }.padding() 88 | .padding(.horizontal) 89 | } 90 | 91 | private func delete(provider: AIProvider) { 92 | context.delete(provider) 93 | try? context.save() 94 | } 95 | 96 | private func showAlert() { 97 | isShowAlert.toggle() 98 | } 99 | 100 | } 101 | -------------------------------------------------------------------------------- /Sources/Views/SettingView/Provider/ModelFetchView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProviderView.swift 3 | // iChat 4 | // 5 | // Created by Lion on 2025/5/19. 6 | // 7 | 8 | import SwiftData 9 | import SwiftUI 10 | 11 | struct ModelFetchView: View { 12 | @Environment(\.modelContext) private var context 13 | @Environment(\.dismiss) var dismiss 14 | 15 | @Bindable var provider: AIProvider 16 | 17 | @State var isLoading = false 18 | @State var searchText = "" 19 | 20 | @State var models: [Model] = [] 21 | @State var messageError = "" 22 | 23 | var body: some View { 24 | HStack { 25 | Text("All Models").font(.title2).padding(.leading) 26 | Spacer() 27 | CustomSearchView(searchText: $searchText) 28 | }.padding().padding(.top) 29 | 30 | GroupBox { 31 | ScrollView { 32 | Form { 33 | if filteredModels.isEmpty { 34 | HStack { 35 | Spacer() 36 | if isLoading { 37 | ProgressView() 38 | Text("Loading data") 39 | } else { 40 | Text("No data") 41 | } 42 | Spacer() 43 | } 44 | Divider() 45 | HStack { 46 | Spacer() 47 | Text(messageError).font(.footnote).foregroundColor(.red) 48 | Spacer() 49 | } 50 | } 51 | ForEach(filteredModels) { item in 52 | HStack { 53 | Text(item.name) 54 | Spacer() 55 | Button { 56 | addModel(name: item.name) 57 | } label: { 58 | Image(systemName: "plus") 59 | }.buttonBorderShape(.circle) 60 | } 61 | Divider() 62 | } 63 | }.padding() 64 | } 65 | }.padding(.horizontal) 66 | .onAppear(perform: { 67 | DispatchQueue.main.async { 68 | fetchModels() 69 | } 70 | }) 71 | Button("Close") { 72 | dismiss() 73 | }.padding() 74 | } 75 | 76 | private var filteredModels: [Model] { 77 | if searchText.isEmpty { 78 | return showModels(models: models) 79 | } else { 80 | return showModels(models: models).filter { 81 | $0.id.localizedCaseInsensitiveContains(searchText) 82 | } 83 | } 84 | } 85 | 86 | 87 | private func showModels(models: [Model]) -> [Model] { 88 | let names = Set(provider.models.map { $0.name }) 89 | return models.filter { !names.contains($0.name) } 90 | } 91 | 92 | private func fetchModels() { 93 | isLoading = true 94 | Task { 95 | do { 96 | defer { 97 | isLoading = false 98 | } 99 | models = try await provider.type.data.service.getModels( 100 | provider: provider 101 | ) 102 | } catch { 103 | messageError = String(describing: error) 104 | } 105 | } 106 | } 107 | 108 | private func addModel(name: String) { 109 | let model = AIModel(name: name, provider: provider) 110 | context.insert(model) 111 | try? context.save() 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /Sources/Views/Assistant/AssistantEditView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PromptView.swift 3 | // iChat 4 | // 5 | // Created by Lion on 2025/6/9. 6 | // 7 | 8 | import SwiftData 9 | import SwiftUI 10 | 11 | struct AssistantEditView: View { 12 | @Environment(\.modelContext) private var modelContext 13 | @Environment(\.dismiss) var dismiss 14 | @Query var providers: [AIProvider] = [] 15 | 16 | @Query var assistants: [Assistant] = [] 17 | 18 | @State var selectedAssistant: Assistant? 19 | 20 | @State var isPresentedAssistantAdd = false 21 | 22 | @Bindable var assistant: Assistant 23 | 24 | var body: some View { 25 | GroupBox { 26 | VStack { 27 | Form { 28 | TextField( 29 | "Title", 30 | text: $assistant.title, 31 | prompt: Text("Title") 32 | ) 33 | TextField( 34 | "Description", 35 | text: $assistant.desc, 36 | prompt: Text("Description") 37 | ) 38 | HStack { 39 | Slider( 40 | value: $assistant.temperature, 41 | in: 0...2, 42 | step: 0.1 43 | ) { 44 | Text("Temperature") 45 | } 46 | Text("\(assistant.temperature, specifier: "%.1f")") 47 | } 48 | 49 | } 50 | HStack { 51 | Text("Model").padding(.leading, 35) 52 | Menu(getDefaultModelName()) { 53 | ForEach(providers) { provider in 54 | Menu(provider.title) { 55 | ForEach(provider.models) { model in 56 | Button { 57 | setDefaultModel(model: model) 58 | } label: { 59 | Text(model.name) 60 | } 61 | } 62 | } 63 | } 64 | } 65 | Spacer() 66 | } 67 | 68 | HStack(alignment: .top) { 69 | Text("Prompt").padding(.leading, 30) 70 | TextEditor(text: $assistant.prompt) 71 | .frame(height: 100) 72 | .font(.title3) 73 | .textEditorStyle(.plain) 74 | .padding(.top, 2) 75 | .background { 76 | RoundedRectangle(cornerRadius: 5) 77 | .stroke(Color.gray.opacity(0.3), lineWidth: 1) 78 | .shadow(radius: 10) 79 | } 80 | } 81 | }.textFieldStyle(.roundedBorder) 82 | .padding() 83 | } label: { 84 | Label("Edit", systemImage: "slider.vertical.3") 85 | .font(.title).bold() 86 | }.padding() 87 | Button("Save") { 88 | dismiss() 89 | }.padding(.bottom).keyboardShortcut(.return, modifiers: []) 90 | } 91 | 92 | /// 获取默认模型的名字 93 | /// - Returns: 默认模型的名字 94 | private func getDefaultModelName() -> String { 95 | var name = "Select Model" 96 | if let model = assistant.model { 97 | name = model.name 98 | } 99 | return name 100 | } 101 | 102 | private func setDefaultModel(model: AIModel) { 103 | assistant.model = model 104 | } 105 | 106 | } 107 | -------------------------------------------------------------------------------- /Sources/Views/Assistant/AssistantAddView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PromptView.swift 3 | // iChat 4 | // 5 | // Created by Lion on 2025/6/9. 6 | // 7 | 8 | import SwiftData 9 | import SwiftUI 10 | 11 | struct AssistantAddView: View { 12 | @Environment(\.modelContext) private var modelContext 13 | @Environment(\.dismiss) var dismiss 14 | @Query var providers: [AIProvider] = [] 15 | 16 | @State var assistant: Assistant 17 | @State var selectModel: AIModel? 18 | 19 | var body: some View { 20 | GroupBox { 21 | VStack { 22 | Form { 23 | TextField( 24 | "Title", 25 | text: $assistant.title, 26 | prompt: Text("Title") 27 | ) 28 | TextField( 29 | "Description", 30 | text: $assistant.desc, 31 | prompt: Text("Description") 32 | ) 33 | Picker("Favorite", selection: $assistant.isFavorite) { 34 | Text("Enable").tag(true) 35 | Text("Disable").tag(false) 36 | }.pickerStyle(.segmented) 37 | 38 | HStack { 39 | Slider( 40 | value: $assistant.temperature, 41 | in: 0...2, 42 | step: 0.1 43 | ) { 44 | Text("Temperature") 45 | } 46 | Text("\(assistant.temperature, specifier: "%.1f")") 47 | } 48 | 49 | } 50 | HStack { 51 | Text("Model").padding(.leading, 35) 52 | Menu(getDefaultModelName()) { 53 | ForEach(providers) { provider in 54 | Menu(provider.title) { 55 | ForEach(provider.models) { model in 56 | Button { 57 | setDefaultModel(model: model) 58 | } label: { 59 | Text(model.name) 60 | } 61 | } 62 | } 63 | } 64 | } 65 | Spacer() 66 | } 67 | 68 | HStack(alignment: .top) { 69 | Text("Prompt").padding(.leading, 30) 70 | TextEditor(text: $assistant.prompt) 71 | .frame(height: 100) 72 | .font(.title3) 73 | .textEditorStyle(.plain) 74 | .padding(.top, 2) 75 | .background { 76 | RoundedRectangle(cornerRadius: 5) 77 | .stroke(Color.gray.opacity(0.3), lineWidth: 1) 78 | .shadow(radius: 10) 79 | } 80 | } 81 | }.textFieldStyle(.roundedBorder) 82 | .padding() 83 | } label: { 84 | Label("Add", systemImage: "plus") 85 | .font(.title).bold() 86 | }.padding() 87 | HStack { 88 | Spacer() 89 | Button("Close") { 90 | dismiss() 91 | } 92 | Button("Add") { 93 | addAssistant() 94 | dismiss() 95 | }.keyboardShortcut(.return, modifiers: []) 96 | }.padding() 97 | } 98 | 99 | private func addAssistant() { 100 | assistant.model = selectModel 101 | modelContext.insert(assistant) 102 | try? modelContext.save() 103 | } 104 | 105 | /// 获取默认模型的名字 106 | /// - Returns: 默认模型的名字 107 | private func getDefaultModelName() -> String { 108 | var name = "Select Model" 109 | if let model = selectModel { 110 | name = model.name 111 | } 112 | return name 113 | } 114 | 115 | private func setDefaultModel(model: AIModel) { 116 | selectModel = model 117 | } 118 | 119 | } 120 | -------------------------------------------------------------------------------- /Sources/Protocols/Services/GrokService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GrokService.swift 3 | // iChat 4 | // 5 | // Created by Lion on 2025/4/28. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Grok 客户端实现 11 | class GrokService: AIProtocol { 12 | private let chatPath = "/v1/chat/completions" 13 | private let modelPath = "/v1/models" 14 | private let session: URLSession = .shared 15 | 16 | func getModels(provider: AIProvider) async throws -> [Model] { 17 | guard let APIURL = URL(string: provider.APIURL + modelPath) else { 18 | throw AIError.WrongAPIURL 19 | } 20 | // 构建请求 21 | var request = URLRequest(url: APIURL) 22 | request.httpMethod = "GET" 23 | request.setValue( 24 | "Bearer \(provider.APIKey)", 25 | forHTTPHeaderField: "Authorization" 26 | ) 27 | request.setValue( 28 | "application/json", 29 | forHTTPHeaderField: "Content-Type" 30 | ) 31 | let (data, response) = try await URLSession.shared.data(for: request) 32 | try validateResponse(response) 33 | let modelResponse = try JSONDecoder().decode( 34 | ModelResponse.self, 35 | from: data 36 | ) 37 | return modelResponse.data 38 | } 39 | 40 | 41 | func streamChatResponse( 42 | provider:AIProvider, 43 | model: AIModel, 44 | messages: [ChatMessage], 45 | temperature:Double 46 | )async throws -> AsyncThrowingStream { 47 | guard let provider = model.provider else { 48 | throw AIError.MissingProvider 49 | } 50 | guard 51 | let APIURL = URL(string: provider.APIURL + chatPath) 52 | else { 53 | throw AIError.WrongAPIURL 54 | } 55 | // 构建请求 56 | var request = URLRequest(url: APIURL) 57 | request.httpMethod = "POST" 58 | request.setValue( 59 | "Bearer \(provider.APIKey)", 60 | forHTTPHeaderField: "Authorization" 61 | ) 62 | request.setValue( 63 | "application/json", 64 | forHTTPHeaderField: "Content-Type" 65 | ) 66 | 67 | // 构建请求体 68 | let requestBody = StreamRequestBody( 69 | model: model.name, 70 | messages: messages.map({ message in 71 | message.apiRepresentation 72 | }), 73 | temperature: temperature, 74 | stream: true 75 | ) 76 | request.httpBody = try JSONEncoder().encode(requestBody) 77 | // 获取流式响应 78 | let (bytes, response) = try await session.bytes( 79 | for: request 80 | ) 81 | try validateResponse(response) 82 | return AsyncThrowingStream { continuation in 83 | Task { 84 | do { 85 | // 处理 SSE 流 86 | try await processStream( 87 | bytes: bytes, 88 | continuation: continuation 89 | ) 90 | continuation.finish() 91 | } catch { 92 | continuation.finish(throwing: error) 93 | } 94 | } 95 | } 96 | } 97 | 98 | 99 | 100 | // 处理 SSE 流 101 | private func processStream( 102 | bytes: URLSession.AsyncBytes, 103 | continuation: AsyncThrowingStream.Continuation 104 | ) async throws { 105 | for try await line in bytes.lines { 106 | guard line.hasPrefix("data:") else { continue } 107 | 108 | let jsonDataString = line.dropFirst(5).trimmingCharacters( 109 | in: .whitespacesAndNewlines 110 | ) 111 | 112 | if jsonDataString == "[DONE]" { 113 | return 114 | } 115 | print(jsonDataString) 116 | guard let jsonData = jsonDataString.data(using: .utf8) else { 117 | continue 118 | } 119 | do { 120 | let response = try JSONDecoder().decode( 121 | APIResponseMessage.self, 122 | from: jsonData 123 | ) 124 | if let content = response.choices?.first?.delta { 125 | continuation.yield(content) 126 | } 127 | } catch { 128 | print("JSON 解码错误: \(error) for data: \(jsonDataString)") 129 | continue 130 | } 131 | } 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /Sources/Protocols/Services/MiMoService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GrokService.swift 3 | // iChat 4 | // 5 | // Created by Lion on 2025/4/28. 6 | // 7 | 8 | import Foundation 9 | 10 | /// MiMo 客户端实现 11 | class MiMoService: AIProtocol { 12 | private let chatPath = "/v1/chat/completions" 13 | private let modelPath = "/v1/models" 14 | private let session: URLSession = .shared 15 | 16 | func getModels(provider: AIProvider) async throws -> [Model] { 17 | guard let APIURL = URL(string: provider.APIURL + modelPath) else { 18 | throw AIError.WrongAPIURL 19 | } 20 | // 构建请求 21 | var request = URLRequest(url: APIURL) 22 | request.httpMethod = "GET" 23 | request.setValue( 24 | "Bearer \(provider.APIKey)", 25 | forHTTPHeaderField: "Authorization" 26 | ) 27 | request.setValue( 28 | "application/json", 29 | forHTTPHeaderField: "Content-Type" 30 | ) 31 | let (data, response) = try await URLSession.shared.data(for: request) 32 | try validateResponse(response) 33 | let modelResponse = try JSONDecoder().decode( 34 | ModelResponse.self, 35 | from: data 36 | ) 37 | return modelResponse.data 38 | } 39 | 40 | 41 | func streamChatResponse( 42 | provider:AIProvider, 43 | model: AIModel, 44 | messages: [ChatMessage], 45 | temperature:Double 46 | )async throws -> AsyncThrowingStream { 47 | guard let provider = model.provider else { 48 | throw AIError.MissingProvider 49 | } 50 | guard 51 | let APIURL = URL(string: provider.APIURL + chatPath) 52 | else { 53 | throw AIError.WrongAPIURL 54 | } 55 | // 构建请求 56 | var request = URLRequest(url: APIURL) 57 | request.httpMethod = "POST" 58 | request.setValue( 59 | "Bearer \(provider.APIKey)", 60 | forHTTPHeaderField: "Authorization" 61 | ) 62 | request.setValue( 63 | "application/json", 64 | forHTTPHeaderField: "Content-Type" 65 | ) 66 | 67 | // 构建请求体 68 | let requestBody = StreamRequestBody( 69 | model: model.name, 70 | messages: messages.map({ message in 71 | message.apiRepresentation 72 | }), 73 | temperature: temperature, 74 | stream: true 75 | ) 76 | request.httpBody = try JSONEncoder().encode(requestBody) 77 | // 获取流式响应 78 | let (bytes, response) = try await session.bytes( 79 | for: request 80 | ) 81 | try validateResponse(response) 82 | return AsyncThrowingStream { continuation in 83 | Task { 84 | do { 85 | // 处理 SSE 流 86 | try await processStream( 87 | bytes: bytes, 88 | continuation: continuation 89 | ) 90 | continuation.finish() 91 | } catch { 92 | continuation.finish(throwing: error) 93 | } 94 | } 95 | } 96 | } 97 | 98 | 99 | 100 | // 处理 SSE 流 101 | private func processStream( 102 | bytes: URLSession.AsyncBytes, 103 | continuation: AsyncThrowingStream.Continuation 104 | ) async throws { 105 | for try await line in bytes.lines { 106 | guard line.hasPrefix("data:") else { continue } 107 | 108 | let jsonDataString = line.dropFirst(5).trimmingCharacters( 109 | in: .whitespacesAndNewlines 110 | ) 111 | 112 | if jsonDataString == "[DONE]" { 113 | return 114 | } 115 | print(jsonDataString) 116 | guard let jsonData = jsonDataString.data(using: .utf8) else { 117 | continue 118 | } 119 | do { 120 | let response = try JSONDecoder().decode( 121 | APIResponseMessage.self, 122 | from: jsonData 123 | ) 124 | if let content = response.choices?.first?.delta { 125 | continuation.yield(content) 126 | } 127 | } catch { 128 | print("JSON 解码错误: \(error) for data: \(jsonDataString)") 129 | continue 130 | } 131 | } 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /Sources/Protocols/Services/ZenMuxService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GrokService.swift 3 | // iChat 4 | // 5 | // Created by Lion on 2025/4/28. 6 | // 7 | 8 | import Foundation 9 | 10 | /// ZenMux 客户端实现 11 | class ZenMuxService: AIProtocol { 12 | private let chatPath = "/v1/chat/completions" 13 | private let modelPath = "/v1/models" 14 | private let session: URLSession = .shared 15 | 16 | func getModels(provider: AIProvider) async throws -> [Model] { 17 | guard let APIURL = URL(string: provider.APIURL + modelPath) else { 18 | throw AIError.WrongAPIURL 19 | } 20 | // 构建请求 21 | var request = URLRequest(url: APIURL) 22 | request.httpMethod = "GET" 23 | request.setValue( 24 | "Bearer \(provider.APIKey)", 25 | forHTTPHeaderField: "Authorization" 26 | ) 27 | request.setValue( 28 | "application/json", 29 | forHTTPHeaderField: "Content-Type" 30 | ) 31 | let (data, response) = try await URLSession.shared.data(for: request) 32 | try validateResponse(response) 33 | let modelResponse = try JSONDecoder().decode( 34 | ModelResponse.self, 35 | from: data 36 | ) 37 | return modelResponse.data 38 | } 39 | 40 | 41 | func streamChatResponse( 42 | provider:AIProvider, 43 | model: AIModel, 44 | messages: [ChatMessage], 45 | temperature:Double 46 | )async throws -> AsyncThrowingStream { 47 | guard let provider = model.provider else { 48 | throw AIError.MissingProvider 49 | } 50 | guard 51 | let APIURL = URL(string: provider.APIURL + chatPath) 52 | else { 53 | throw AIError.WrongAPIURL 54 | } 55 | // 构建请求 56 | var request = URLRequest(url: APIURL) 57 | request.httpMethod = "POST" 58 | request.setValue( 59 | "Bearer \(provider.APIKey)", 60 | forHTTPHeaderField: "Authorization" 61 | ) 62 | request.setValue( 63 | "application/json", 64 | forHTTPHeaderField: "Content-Type" 65 | ) 66 | 67 | // 构建请求体 68 | let requestBody = StreamRequestBody( 69 | model: model.name, 70 | messages: messages.map({ message in 71 | message.apiRepresentation 72 | }), 73 | temperature: temperature, 74 | stream: true 75 | ) 76 | request.httpBody = try JSONEncoder().encode(requestBody) 77 | // 获取流式响应 78 | let (bytes, response) = try await session.bytes( 79 | for: request 80 | ) 81 | try validateResponse(response) 82 | return AsyncThrowingStream { continuation in 83 | Task { 84 | do { 85 | // 处理 SSE 流 86 | try await processStream( 87 | bytes: bytes, 88 | continuation: continuation 89 | ) 90 | continuation.finish() 91 | } catch { 92 | continuation.finish(throwing: error) 93 | } 94 | } 95 | } 96 | } 97 | 98 | 99 | 100 | // 处理 SSE 流 101 | private func processStream( 102 | bytes: URLSession.AsyncBytes, 103 | continuation: AsyncThrowingStream.Continuation 104 | ) async throws { 105 | for try await line in bytes.lines { 106 | guard line.hasPrefix("data:") else { continue } 107 | 108 | let jsonDataString = line.dropFirst(5).trimmingCharacters( 109 | in: .whitespacesAndNewlines 110 | ) 111 | 112 | if jsonDataString == "[DONE]" { 113 | return 114 | } 115 | print(jsonDataString) 116 | guard let jsonData = jsonDataString.data(using: .utf8) else { 117 | continue 118 | } 119 | do { 120 | let response = try JSONDecoder().decode( 121 | APIResponseMessage.self, 122 | from: jsonData 123 | ) 124 | if let content = response.choices?.first?.delta { 125 | continuation.yield(content) 126 | } 127 | } catch { 128 | print("JSON 解码错误: \(error) for data: \(jsonDataString)") 129 | continue 130 | } 131 | } 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /Sources/Protocols/Services/OpenRouterService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GrokService.swift 3 | // iChat 4 | // 5 | // Created by Lion on 2025/4/28. 6 | // 7 | 8 | import Foundation 9 | 10 | /// OpenRouter 客户端实现 11 | class OpenRouterService: AIProtocol { 12 | private let chatPath = "/v1/chat/completions" 13 | private let modelPath = "/v1/models" 14 | private let session: URLSession = .shared 15 | 16 | func getModels(provider: AIProvider) async throws -> [Model] { 17 | guard let APIURL = URL(string: provider.APIURL + modelPath) else { 18 | throw AIError.WrongAPIURL 19 | } 20 | // 构建请求 21 | var request = URLRequest(url: APIURL) 22 | request.httpMethod = "GET" 23 | request.setValue( 24 | "Bearer \(provider.APIKey)", 25 | forHTTPHeaderField: "Authorization" 26 | ) 27 | request.setValue( 28 | "application/json", 29 | forHTTPHeaderField: "Content-Type" 30 | ) 31 | let (data, response) = try await URLSession.shared.data(for: request) 32 | try validateResponse(response) 33 | let modelResponse = try JSONDecoder().decode( 34 | ModelResponse.self, 35 | from: data 36 | ) 37 | return modelResponse.data 38 | } 39 | 40 | 41 | func streamChatResponse( 42 | provider:AIProvider, 43 | model: AIModel, 44 | messages: [ChatMessage], 45 | temperature:Double 46 | )async throws -> AsyncThrowingStream { 47 | guard let provider = model.provider else { 48 | throw AIError.MissingProvider 49 | } 50 | guard 51 | let APIURL = URL(string: provider.APIURL + chatPath) 52 | else { 53 | throw AIError.WrongAPIURL 54 | } 55 | // 构建请求 56 | var request = URLRequest(url: APIURL) 57 | request.httpMethod = "POST" 58 | request.setValue( 59 | "Bearer \(provider.APIKey)", 60 | forHTTPHeaderField: "Authorization" 61 | ) 62 | request.setValue( 63 | "application/json", 64 | forHTTPHeaderField: "Content-Type" 65 | ) 66 | 67 | // 构建请求体 68 | let requestBody = StreamRequestBody( 69 | model: model.name, 70 | messages: messages.map({ message in 71 | message.apiRepresentation 72 | }), 73 | temperature: temperature, 74 | stream: true 75 | ) 76 | request.httpBody = try JSONEncoder().encode(requestBody) 77 | // 获取流式响应 78 | let (bytes, response) = try await session.bytes( 79 | for: request 80 | ) 81 | try validateResponse(response) 82 | return AsyncThrowingStream { continuation in 83 | Task { 84 | do { 85 | // 处理 SSE 流 86 | try await processStream( 87 | bytes: bytes, 88 | continuation: continuation 89 | ) 90 | continuation.finish() 91 | } catch { 92 | continuation.finish(throwing: error) 93 | } 94 | } 95 | } 96 | } 97 | 98 | 99 | 100 | // 处理 SSE 流 101 | private func processStream( 102 | bytes: URLSession.AsyncBytes, 103 | continuation: AsyncThrowingStream.Continuation 104 | ) async throws { 105 | for try await line in bytes.lines { 106 | guard line.hasPrefix("data:") else { continue } 107 | 108 | let jsonDataString = line.dropFirst(5).trimmingCharacters( 109 | in: .whitespacesAndNewlines 110 | ) 111 | 112 | if jsonDataString == "[DONE]" { 113 | return 114 | } 115 | print(jsonDataString) 116 | guard let jsonData = jsonDataString.data(using: .utf8) else { 117 | continue 118 | } 119 | do { 120 | let response = try JSONDecoder().decode( 121 | APIResponseMessage.self, 122 | from: jsonData 123 | ) 124 | if let content = response.choices?.first?.delta { 125 | continuation.yield(content) 126 | } 127 | } catch { 128 | print("JSON 解码错误: \(error) for data: \(jsonDataString)") 129 | continue 130 | } 131 | } 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /Sources/Protocols/Services/DeepSeekService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GrokService.swift 3 | // iChat 4 | // 5 | // Created by Lion on 2025/4/28. 6 | // 7 | 8 | import Foundation 9 | 10 | /// DeepSeek 客户端实现 11 | class DeepSeekService: AIProtocol { 12 | 13 | 14 | private let chatPath = "/chat/completions" 15 | private let modelPath = "/models" 16 | private let session: URLSession = .shared 17 | 18 | 19 | func getModels(provider: AIProvider) async throws -> [Model] { 20 | guard let APIURL = URL(string: provider.APIURL + modelPath) else { 21 | throw AIError.WrongAPIURL 22 | } 23 | // 构建请求 24 | var request = URLRequest(url: APIURL) 25 | request.httpMethod = "GET" 26 | request.setValue( 27 | "Bearer \(provider.APIKey)", 28 | forHTTPHeaderField: "Authorization" 29 | ) 30 | request.setValue( 31 | "application/json", 32 | forHTTPHeaderField: "Content-Type" 33 | ) 34 | let (data, response) = try await session.data(for: request) 35 | // print(String(data: data, encoding: .utf8)) 36 | try validateResponse(response) 37 | let modelResponse = try JSONDecoder().decode( 38 | ModelResponse.self, 39 | from: data 40 | ) 41 | return modelResponse.data 42 | } 43 | 44 | 45 | 46 | 47 | func streamChatResponse( 48 | provider:AIProvider, 49 | model: AIModel, 50 | messages: [ChatMessage], 51 | temperature:Double 52 | ) async throws -> AsyncThrowingStream { 53 | guard 54 | let APIURL = URL(string: provider.APIURL + chatPath) 55 | else { 56 | throw AIError.WrongAPIURL 57 | } 58 | // 构建请求 59 | var request = URLRequest(url: APIURL) 60 | request.httpMethod = "POST" 61 | request.setValue( 62 | "Bearer \(provider.APIKey)", 63 | forHTTPHeaderField: "Authorization" 64 | ) 65 | request.setValue( 66 | "application/json", 67 | forHTTPHeaderField: "Content-Type" 68 | ) 69 | 70 | // 构建请求体 71 | let requestBody = StreamRequestBody( 72 | model: model.name, 73 | messages: messages.map({ message in 74 | message.apiRepresentation 75 | }), 76 | temperature: temperature, 77 | stream: true 78 | ) 79 | request.httpBody = try JSONEncoder().encode(requestBody) 80 | 81 | // 获取流式响应 82 | let (bytes, response) = try await session.bytes( 83 | for: request 84 | ) 85 | 86 | try validateResponse(response) 87 | 88 | return AsyncThrowingStream { continuation in 89 | Task { 90 | do { 91 | // 处理 SSE 流 92 | try await processStream( 93 | bytes: bytes, 94 | continuation: continuation 95 | ) 96 | continuation.finish() 97 | } catch { 98 | continuation.finish(throwing: error) 99 | } 100 | } 101 | } 102 | } 103 | 104 | 105 | 106 | // 处理 SSE 流 107 | private func processStream( 108 | bytes: URLSession.AsyncBytes, 109 | continuation: AsyncThrowingStream.Continuation 110 | ) async throws { 111 | for try await line in bytes.lines { 112 | print(line) 113 | guard line.hasPrefix("data:") else { continue } 114 | 115 | let jsonDataString = line.dropFirst(5).trimmingCharacters( 116 | in: .whitespacesAndNewlines 117 | ) 118 | 119 | if jsonDataString == "[DONE]" { 120 | return 121 | } 122 | 123 | guard let jsonData = jsonDataString.data(using: .utf8) else { 124 | continue 125 | } 126 | 127 | do { 128 | let response = try JSONDecoder().decode( 129 | APIResponseMessage.self, 130 | from: jsonData 131 | ) 132 | if let content = response.choices?.first?.delta { 133 | continuation.yield(content) 134 | } 135 | } catch { 136 | print("JSON 解码错误: \(error) for data: \(jsonDataString)") 137 | continue 138 | } 139 | } 140 | } 141 | } 142 | 143 | -------------------------------------------------------------------------------- /Sources/Markdown/Node/TableView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TableView.swift 3 | // iMenu 4 | // 5 | // Created by OSX on 2025/12/3. 6 | // 7 | 8 | import Markdown 9 | import SwiftUI 10 | 11 | // 表格视图 12 | struct TableView: View { 13 | let table: Markdown.Table 14 | 15 | var body: some View { 16 | VStack(spacing: 0) { 17 | // 表格头部 18 | TableHeadView( 19 | head: table.head, 20 | columnAlignments: table.columnAlignments 21 | ) 22 | // 表格内容 23 | ForEach(Array(table.body.rows.enumerated()), id: \.offset) { 24 | index, 25 | row in 26 | Divider() 27 | TableRowView( 28 | row: row, 29 | index: index, 30 | columnAlignments: table.columnAlignments 31 | ) 32 | } 33 | 34 | } 35 | .overlay( 36 | RoundedRectangle(cornerRadius: 8) 37 | .stroke(Color.gray.opacity(0.3), lineWidth: 1) 38 | ) 39 | .padding(.vertical, 8) 40 | } 41 | } 42 | 43 | // 表格头部视图 44 | struct TableHeadView: View { 45 | let head: Markdown.Table.Head 46 | let columnAlignments: [Markdown.Table.ColumnAlignment?] 47 | 48 | var body: some View { 49 | HStack(spacing: 0) { 50 | ForEach(Array(head.cells.enumerated()), id: \.offset) { 51 | index, 52 | cell in 53 | TableCellView( 54 | cell: cell, 55 | alignment: columnAlignments.indices.contains(index) 56 | ? columnAlignments[index] : nil 57 | ) 58 | } 59 | } 60 | .background(Color.gray.opacity(0.15)) 61 | .foregroundStyle(.secondary) 62 | .font(.body.bold()) 63 | } 64 | } 65 | 66 | // 表格行视图 67 | struct TableRowView: View { 68 | let row: Markdown.Table.Row 69 | let index: Int 70 | let columnAlignments: [Markdown.Table.ColumnAlignment?] 71 | 72 | var body: some View { 73 | HStack(spacing: 0) { 74 | ForEach(Array(row.cells.enumerated()), id: \.offset) { 75 | index, 76 | cell in 77 | TableCellView( 78 | cell: cell, 79 | alignment: columnAlignments.indices.contains(index) 80 | ? columnAlignments[index] : nil 81 | ) 82 | } 83 | } 84 | .background(index%2 == 0 ? Color.clear : Color.gray.opacity(0.1)) 85 | } 86 | } 87 | 88 | // 表格单元格视图 89 | struct TableCellView: View { 90 | let cell: Markdown.Table.Cell 91 | let alignment: Markdown.Table.ColumnAlignment? 92 | 93 | var body: some View { 94 | VStack(alignment: textAlignment, spacing: 4) { 95 | Text(attributedString(for: cell)) 96 | .frame( 97 | maxWidth: .infinity, 98 | alignment: Alignment( 99 | horizontal: textAlignment, 100 | vertical: .center 101 | ) 102 | ) 103 | } 104 | .padding(.horizontal, 12) 105 | .padding(.vertical, 8) 106 | .frame(maxWidth: .infinity) 107 | } 108 | 109 | var textAlignment: HorizontalAlignment { 110 | switch alignment { 111 | case .left: return .leading 112 | case .center: return .center 113 | case .right: return .trailing 114 | default: return .leading 115 | } 116 | } 117 | 118 | func attributedString(for markup: any Markup) -> AttributedString { 119 | var result = AttributedString() 120 | for child in markup.children { 121 | if let text = child as? Markdown.Text { 122 | result += AttributedString(text.string) 123 | } else if let strong = child as? Strong { 124 | var boldText = AttributedString(strong.plainText) 125 | boldText.font = .body.bold() 126 | result += boldText 127 | } else if let emphasis = child as? Emphasis { 128 | var italicText = AttributedString(emphasis.plainText) 129 | italicText.font = .body.italic() 130 | result += italicText 131 | } else if let code = child as? InlineCode { 132 | var codeText = AttributedString(code.code) 133 | codeText.font = .system(.body, design: .monospaced) 134 | codeText.backgroundColor = Color.gray.opacity(0.2) 135 | result += codeText 136 | } else if child.childCount > 0 { 137 | result += attributedString(for: child) 138 | } 139 | } 140 | 141 | return result 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /Sources/Views/SettingView/AboutView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProviderView.swift 3 | // iChat 4 | // 5 | // Created by Lion on 2025/5/19. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct AboutView: View { 11 | let title = "About" 12 | let icon = "info.circle" 13 | 14 | // 从 Bundle 获取基本信息 15 | 16 | let appName = 17 | Bundle.main.infoDictionary?["CFBundleName"] as? String ?? "" 18 | let appVersion = 19 | Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String 20 | ?? "1.0" 21 | let appBuild = 22 | Bundle.main.infoDictionary?["CFBundleVersion"] as? String 23 | ?? "1" 24 | let copyright = 25 | Bundle.main.infoDictionary?["NSHumanReadableCopyright"] as? String 26 | ?? "© iChochy" 27 | 28 | // 额外信息(可以根据需要修改) 29 | private let developer = "iChochy" 30 | private let blog = "https://ichochy.com" 31 | private let website = "https://ai.ichochy.com" 32 | private let supportEmail = "it.osx@icloud.com" 33 | 34 | var body: some View { 35 | ScrollView { 36 | VStack(spacing: 20) { 37 | // 应用图标 38 | Image( 39 | nsImage: NSImage(named: NSImage.applicationIconName) 40 | ?? NSImage() 41 | ) 42 | .frame(width: 128, height: 128) 43 | .shadow(radius: 5) 44 | 45 | // 应用基本信息 46 | Text(appName) 47 | .font(.title) 48 | .fontWeight(.bold) 49 | 50 | Text("Bate Version \(appVersion) (Build \(appBuild))") 51 | .font(.subheadline) 52 | .foregroundColor(.gray) 53 | 54 | // 分割线 55 | Divider() 56 | 57 | // 详细信息 58 | VStack(alignment: .leading, spacing: 10) { 59 | HStack { 60 | Text("Developer:") 61 | .fontWeight(.semibold) 62 | Text(developer) 63 | } 64 | 65 | HStack { 66 | Text("Blog:") 67 | .fontWeight(.semibold) 68 | Button(action: { 69 | if let url = URL(string: website) { 70 | NSWorkspace.shared.open(url) 71 | } 72 | }) { 73 | Text(blog) 74 | .foregroundColor(.blue) 75 | } 76 | .buttonStyle(PlainButtonStyle()) 77 | } 78 | 79 | HStack { 80 | Text("Support:") 81 | .fontWeight(.semibold) 82 | Button(action: { 83 | if let url = URL(string: "mailto:\(supportEmail)") { 84 | NSWorkspace.shared.open(url) 85 | } 86 | }) { 87 | Text(supportEmail) 88 | .foregroundColor(.blue) 89 | } 90 | .buttonStyle(PlainButtonStyle()) 91 | } 92 | HStack { 93 | Text("Released:") 94 | .fontWeight(.semibold) 95 | Text(getBuildDate(), style: .date) 96 | } 97 | 98 | } 99 | .font(.body) 100 | 101 | Divider() 102 | 103 | // 操作按钮 104 | HStack(spacing: 20) { 105 | Button("Visit Website") { 106 | if let url = URL(string: website) { 107 | NSWorkspace.shared.open(url) 108 | } 109 | } 110 | .keyboardShortcut(.defaultAction) 111 | } 112 | Spacer() 113 | // 版权信息 114 | Text(copyright) 115 | .font(.footnote) 116 | .foregroundColor(.gray) 117 | 118 | }.padding() 119 | .navigationTitle("About") 120 | } 121 | } 122 | 123 | private func getBuildDate() -> Date { 124 | if let executablePath = Bundle.main.executablePath { 125 | do { 126 | let attributes = try FileManager.default.attributesOfItem( 127 | atPath: executablePath 128 | ) 129 | if let creationDate = attributes[.modificationDate] as? Date { 130 | return creationDate 131 | } 132 | } catch { 133 | print("获取可执行文件时间失败: \(error)") 134 | } 135 | } 136 | return Date() 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /Sources/Views/Assistant/AssistantView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PromptView.swift 3 | // iChat 4 | // 5 | // Created by Lion on 2025/6/9. 6 | // 7 | 8 | import SwiftData 9 | import SwiftUI 10 | 11 | struct AssistantView: View { 12 | @Environment(\.modelContext) private var modelContext 13 | 14 | @Query var assistants: [Assistant] = [] 15 | 16 | @State var editAssistant: Assistant? 17 | @State var addAssistant: Assistant? 18 | 19 | let gridLayout = [ 20 | GridItem(.adaptive(minimum: 400), spacing: 20) // 最小宽度为150,列间距20 21 | ] 22 | 23 | var body: some View { 24 | ScrollView { 25 | VStack { 26 | HStack { 27 | Text("AI Assistants") 28 | .font(.title) 29 | .bold() 30 | Button { 31 | createAssistant() 32 | } label: { 33 | Label("Plus", systemImage: "plus") 34 | }.buttonBorderShape(.circle) 35 | .sheet(item: $addAssistant) { item in 36 | AssistantAddView(assistant: item) 37 | } 38 | } 39 | Divider() 40 | .shadow(radius: 10) 41 | LazyVGrid(columns: gridLayout) { 42 | ForEach(assistants) { item in 43 | HStack { 44 | Image(systemName: "bookmark") 45 | .font(.system(size: 40)) 46 | .padding() 47 | VStack(alignment: .leading) { 48 | HStack { 49 | Text(item.title) 50 | .font(.title3) 51 | .bold() 52 | .lineLimit(1) 53 | if let model = item.model { 54 | Text("(\(model.name))") 55 | } 56 | } 57 | Divider() 58 | Text(item.prompt).lineLimit(1) 59 | } 60 | VStack(spacing: 5) { 61 | Button { 62 | item.isFavorite.toggle() 63 | } label: { 64 | Image( 65 | systemName: item.isFavorite 66 | ? "heart.slash" : "heart" 67 | ).frame( 68 | width: 15, 69 | height: 15 70 | ) 71 | }.help( 72 | item.isFavorite 73 | ? "Cancel Favorites" : "Favorites" 74 | ) 75 | Button { 76 | editAssistant = item 77 | } label: { 78 | Image(systemName: "slider.vertical.3") 79 | .frame( 80 | width: 15, 81 | height: 15 82 | ) 83 | }.help("Edit assistant") 84 | .padding(.leading, 30) 85 | Button { 86 | deleteAssistant(assistant: item) 87 | } label: { 88 | Image(systemName: "trash") 89 | .frame( 90 | width: 15, 91 | height: 15 92 | ) 93 | }.help("Delete assistant") 94 | }.buttonBorderShape(.circle) 95 | } 96 | .padding(10) 97 | .background(Color.gray.opacity(0.2)) 98 | .cornerRadius(50) 99 | } 100 | }.textSelection(.disabled) 101 | .sheet(item: $editAssistant) { item in 102 | AssistantEditView(assistant: item) 103 | } 104 | }.padding() 105 | } 106 | } 107 | 108 | private func createAssistant() { 109 | addAssistant = Assistant() 110 | } 111 | 112 | private func favoriteAssistant(assistant: Assistant) { 113 | assistant.isFavorite = true 114 | try? modelContext.save() 115 | } 116 | 117 | private func deleteAssistant(assistant: Assistant) { 118 | modelContext.delete(assistant) 119 | try? modelContext.save() 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /Sources/Protocols/Services/APIResponseMessage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AIService.swift 3 | // iChat 4 | // 5 | // Created by Lion on 2025/4/25. 6 | // 7 | 8 | import Foundation 9 | 10 | // API 响应消息结构 (根据具体 API 调整) 11 | struct APIResponseMessage: Decodable { 12 | let choices: [Choice]? 13 | struct Choice: Decodable { 14 | let delta: Delta? 15 | } 16 | } 17 | 18 | struct Delta: Decodable { 19 | let content: String? 20 | let reasoning: String? 21 | enum CodingKeys: String, CodingKey { 22 | case content 23 | case reasoning = "reasoning_content" 24 | } 25 | } 26 | 27 | // 验证响应 28 | func validateResponse(_ response: URLResponse) throws { 29 | guard let httpResponse = response as? HTTPURLResponse else { 30 | throw AIError.InvalidResponse 31 | } 32 | guard (200...299).contains(httpResponse.statusCode) else { 33 | print(httpResponse.statusCode) 34 | throw StatusError(rawValue: httpResponse.statusCode) 35 | ?? StatusError.SystemError 36 | } 37 | } 38 | 39 | // 错误类型 40 | enum AIError: LocalizedError { 41 | case MissingProvider 42 | case WrongAPIURL 43 | case InvalidResponse 44 | case SystemError 45 | 46 | } 47 | 48 | enum StatusError: Int, LocalizedError { 49 | // 2xx Success (成功) 50 | case OK = 200 // 请求成功 51 | case Created = 201 // 已创建:请求成功并在服务器上创建了新的资源 52 | case Accepted = 202 // 已接受:请求已接受处理,但处理尚未完成 53 | case NoContent = 204 // 无内容:服务器已成功处理请求,但没有返回任何内容 (例如 DELETE 请求成功) 54 | 55 | // 3xx Redirection (重定向) 56 | case MultipleChoices = 300 // 多种选择:针对请求有多种可能的响应 57 | case MovedPermanently = 301 // 永久移动:请求的资源已被永久移动到新的 URL 58 | case Found = 302 // 已找到 (原名 Moved Temporarily):请求的资源暂时在不同的 URL 下 59 | case SeeOther = 303 // 查看其他:请求的资源应该从另一个 URI 使用 GET 方法获取 60 | case NotModified = 304 // 未修改:客户端缓存的资源是最新的,无需再次传输 61 | case TemporaryRedirect = 307 // 临时重定向:请求的资源暂时在不同的 URL 下,且不允许改变请求方法 62 | case PermanentRedirect = 308 // 永久重定向:请求的资源已被永久移动到新的 URL,且不允许改变请求方法 63 | 64 | // 4xx Client Error (客户端错误) 65 | case BadRequest = 400 // 错误请求:服务器无法理解请求,可能是由于请求语法错误 66 | case Unauthorized = 401 // 未授权:请求需要用户身份验证 (与 AuthError 含义相同) 67 | case Forbidden = 403 // 禁止:服务器理解请求,但拒绝执行。通常是因为缺乏访问权限 68 | case ResourceNotFound = 404 // 未找到:服务器找不到请求的资源 (常见错误,如页面不存在) 69 | case MethodNotAllowed = 405 // 方法不允许:服务器理解请求方法,但目标资源不支持该方法 70 | case NotAcceptable = 406 // 不可接受:服务器无法根据请求的 'Accept' 头生成响应 71 | case RequestTimeout = 408 // 请求超时:服务器在指定时间内没有收到完整的请求 72 | case Conflict = 409 // 冲突:请求与目标资源的当前状态冲突 (例如,尝试创建已存在的资源) 73 | case Gone = 410 // 已失效:请求的资源在服务器上已不再可用,且没有转发地址 74 | case LengthRequired = 411 // 需要长度:服务器拒绝在没有定义 Content-Length 的请求 75 | case PreconditionFailed = 412 // 前置条件失败:客户端请求中的某些前置条件未满足 76 | case PayloadTooLarge = 413 // 有效载荷过大:请求实体比服务器能处理的要大 77 | case URITooLong = 414 // URI 过长:请求的 URI 比服务器能处理的要长 78 | case UnsupportedMediaType = 415 // 不支持的媒体类型:请求的媒体类型不被服务器支持 79 | case RangeNotSatisfiable = 416 // 范围未满足:客户端请求的范围超出资源的可用范围 80 | case ExpectationFailed = 417 // 期望失败:请求的 Expect 头字段值与服务器期望的不符 81 | case UnprocessableEntity = 422 // 不可处理实体:请求格式正确,但包含语义错误 (常用于验证失败,与 ValidationFailed 含义相同) 82 | case TooManyRequests = 429 // 请求过多:用户在给定时间内发送了太多请求 (速率限制) 83 | 84 | // 5xx Server Error (服务器错误) 85 | case InternalServerError = 500 // 内部服务器错误:服务器遇到了一个意外情况,阻止它完成请求 86 | case NotImplemented = 501 // 未实现:服务器不支持完成请求所需的功能 87 | case BadGateway = 502 // 错误的网关:服务器作为网关或代理,从上游服务器收到无效响应 88 | case ServiceUnavailable = 503 // 服务不可用:服务器目前无法处理请求,可能是由于临时过载或维护 89 | case GatewayTimeout = 504 // 网关超时:服务器作为网关或代理,没有及时从上游服务器收到响应 90 | 91 | // Custom/Non-standard errors (自定义/非标准错误) 92 | case SystemError = 999 // 自定义系统错误,不在标准 HTTP 范围,可能用于客户端内部逻辑 93 | 94 | func compare(_ code: Int) -> Bool { 95 | return self.rawValue == code 96 | } 97 | } 98 | 99 | struct ModelResponse: Codable { 100 | let data: [Model] 101 | } 102 | 103 | struct Model: Codable, Identifiable { 104 | var id: String 105 | //Gemini 统一修改 106 | var name: String { 107 | id.replacingOccurrences(of: "models/", with: "") 108 | } 109 | 110 | } 111 | 112 | // 请求体模型 113 | struct StreamRequestBody: Codable { 114 | let model: String 115 | let messages: [[String: String]] 116 | let temperature:Double 117 | let stream: Bool 118 | var extraBody:ExtraBody? 119 | enum CodingKeys: String, CodingKey { 120 | case model 121 | case messages 122 | case temperature 123 | case stream 124 | case extraBody = "extra_body" 125 | } 126 | } 127 | 128 | 129 | // 外层结构:extra_body 130 | struct ExtraBody: Codable { 131 | let google: Google 132 | } 133 | 134 | // 内层结构:google 135 | struct Google: Codable { 136 | let thinkingConfig: ThinkingConfig 137 | 138 | // 处理下划线键名转换为驼峰式 139 | enum CodingKeys: String, CodingKey { 140 | case thinkingConfig = "thinking_config" 141 | } 142 | } 143 | 144 | // 最内层结构:thinking_config 145 | struct ThinkingConfig: Codable { 146 | let includeThoughts: Bool = true 147 | 148 | // 处理下划线键名转换为驼峰式 149 | enum CodingKeys: String, CodingKey { 150 | case includeThoughts = "include_thoughts" 151 | } 152 | } 153 | 154 | -------------------------------------------------------------------------------- /Sources/Enums/Models/AIProviderEnumModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsModel.swift 3 | // iChat 4 | // 5 | // Created by Lion on 2025/5/19. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | class AIProviderEnumModel { 12 | var title: String = "" 13 | var icon: String = "" 14 | var supportUrl: String = "" 15 | var APIURL: String = "" 16 | var service: AIProtocol = GrokService() 17 | 18 | init( 19 | title:String, 20 | icon: String, 21 | supportUrl: String, 22 | APIURL: String, 23 | service: AIProtocol 24 | ) { 25 | self.title = title 26 | self.icon = icon 27 | self.supportUrl = supportUrl 28 | self.APIURL = APIURL 29 | self.service = service 30 | } 31 | 32 | static func getMimo() -> AIProviderEnumModel { 33 | let title = "MiMo(Xiaomi)" 34 | let icon = "" 35 | let supportUrl = "https://platform.xiaomimimo.com/#/console/api-keys" 36 | let APIURL = "https://api.xiaomimimo.com" 37 | let service = MiMoService() 38 | return AIProviderEnumModel( 39 | title: title, 40 | icon: icon, 41 | supportUrl: supportUrl, 42 | APIURL: APIURL, 43 | service: service 44 | ) 45 | } 46 | static func getGrok() -> AIProviderEnumModel { 47 | let title = "XAI(Grok)" 48 | let icon = "" 49 | let supportUrl = "https://console.x.ai" 50 | let APIURL = "https://api.x.ai" 51 | let service = GrokService() 52 | return AIProviderEnumModel( 53 | title: title, 54 | icon: icon, 55 | supportUrl: supportUrl, 56 | APIURL: APIURL, 57 | service: service 58 | ) 59 | } 60 | 61 | static func getOpenAI() -> AIProviderEnumModel { 62 | let title = "OpenAI(GPT)" 63 | let icon = "" 64 | let supportUrl = "https://platform.openai.com/settings/organization/api-keys" 65 | let APIURL = "https://api.openai.com" 66 | let service = GrokService() 67 | return AIProviderEnumModel( 68 | title: title, 69 | icon: icon, 70 | supportUrl: supportUrl, 71 | APIURL: APIURL, 72 | service: service 73 | ) 74 | } 75 | static func getZenMux() -> AIProviderEnumModel { 76 | let title = "ZenMux(OpenAI)" 77 | let icon = "" 78 | let supportUrl = "https://zenmux.ai/invite/3KA0VI" 79 | let APIURL = "https://zenmux.ai/api" 80 | let service = ZenMuxService() 81 | return AIProviderEnumModel( 82 | title: title, 83 | icon: icon, 84 | supportUrl: supportUrl, 85 | APIURL: APIURL, 86 | service: service 87 | ) 88 | } 89 | static func getOpenRouter() -> AIProviderEnumModel { 90 | let title = "OpenRouter(OpenAI)" 91 | let icon = "" 92 | let supportUrl = "https://openrouter.ai/settings/keys" 93 | let APIURL = "https://openrouter.ai/api" 94 | let service = OpenRouterService() 95 | return AIProviderEnumModel( 96 | title: title, 97 | icon: icon, 98 | supportUrl: supportUrl, 99 | APIURL: APIURL, 100 | service: service 101 | ) 102 | } 103 | 104 | static func getGemini() -> AIProviderEnumModel { 105 | let title = "Google(Gemini)" 106 | let icon = "" 107 | let supportUrl = "https://aistudio.google.com/apikey" 108 | let APIURL = "https://generativelanguage.googleapis.com" 109 | let service = GeminiService() 110 | return AIProviderEnumModel( 111 | title: title, 112 | icon: icon, 113 | supportUrl: supportUrl, 114 | APIURL: APIURL, 115 | service: service 116 | ) 117 | } 118 | static func getDeepSeek() -> AIProviderEnumModel { 119 | let title = "DeepSeek" 120 | let icon = "" 121 | let supportUrl = "https://platform.deepseek.com/api_keys" 122 | let APIURL = "https://api.deepseek.com" 123 | let service = DeepSeekService() 124 | return AIProviderEnumModel( 125 | title: title, 126 | icon: icon, 127 | supportUrl: supportUrl, 128 | APIURL: APIURL, 129 | service: service 130 | ) 131 | } 132 | static func getCloudflare() -> AIProviderEnumModel { 133 | let title = "Cloudflare(Compat)" 134 | let icon = "" 135 | let supportUrl = "https://developers.cloudflare.com/ai-gateway/usage/chat-completion/" 136 | let APIURL = "https://gateway.ai.cloudflare.com/v1/{account_id}/{gateway_id}" 137 | let service = CloudflareService() 138 | return AIProviderEnumModel( 139 | title: title, 140 | icon: icon, 141 | supportUrl: supportUrl, 142 | APIURL: APIURL, 143 | service: service 144 | ) 145 | } 146 | static func getCustom() -> AIProviderEnumModel { 147 | let title = "Custom(OpenAI)" 148 | let icon = "" 149 | let supportUrl = "" 150 | let APIURL = "" 151 | let service = GrokService() 152 | return AIProviderEnumModel( 153 | title: title, 154 | icon: icon, 155 | supportUrl: supportUrl, 156 | APIURL: APIURL, 157 | service: service 158 | ) 159 | } 160 | 161 | } 162 | -------------------------------------------------------------------------------- /Sources/Protocols/Services/GeminiService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GrokService.swift 3 | // iChat 4 | // 5 | // Created by Lion on 2025/4/28. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Gemini 客户端实现 11 | class GeminiService: AIProtocol { 12 | 13 | private let chatPath = "/v1beta/openai/chat/completions" 14 | private let modelPath = "/v1beta/openai/models" 15 | private let session: URLSession = .shared 16 | 17 | func getModels(provider: AIProvider) async throws -> [Model] { 18 | guard let APIURL = URL(string: provider.APIURL + modelPath) else { 19 | throw AIError.WrongAPIURL 20 | } 21 | // 构建请求 22 | var request = URLRequest(url: APIURL) 23 | request.httpMethod = "GET" 24 | request.setValue( 25 | "Bearer \(provider.APIKey)", 26 | forHTTPHeaderField: "Authorization" 27 | ) 28 | request.setValue( 29 | "application/json", 30 | forHTTPHeaderField: "Content-Type" 31 | ) 32 | let (data, response) = try await session.data(for: request) 33 | // print(String(data: data, encoding: .utf8)) 34 | try validateResponse(response) 35 | let modelResponse = try JSONDecoder().decode( 36 | ModelResponse.self, 37 | from: data 38 | ) 39 | return modelResponse.data 40 | } 41 | 42 | 43 | 44 | 45 | func streamChatResponse( 46 | provider:AIProvider, 47 | model: AIModel, 48 | messages: [ChatMessage], 49 | temperature:Double 50 | ) async throws -> AsyncThrowingStream { 51 | guard let provider = model.provider else{ 52 | throw AIError.MissingProvider 53 | } 54 | guard 55 | let APIURL = URL(string: provider.APIURL + chatPath) 56 | else { 57 | throw AIError.WrongAPIURL 58 | } 59 | // 构建请求 60 | var request = URLRequest(url: APIURL) 61 | request.httpMethod = "POST" 62 | request.setValue( 63 | "Bearer \(provider.APIKey)", 64 | forHTTPHeaderField: "Authorization" 65 | ) 66 | request.setValue( 67 | "application/json", 68 | forHTTPHeaderField: "Content-Type" 69 | ) 70 | 71 | // 构建请求体 72 | var requestBody = StreamRequestBody( 73 | model: model.name, 74 | messages: messages.map({ message in 75 | message.apiRepresentation 76 | }), 77 | temperature: temperature, 78 | stream: true 79 | ) 80 | requestBody.extraBody = ExtraBody(google: Google(thinkingConfig: ThinkingConfig())) 81 | request.httpBody = try JSONEncoder().encode(requestBody) 82 | 83 | // 获取流式响应 84 | let (bytes, response) = try await session.bytes( 85 | for: request 86 | ) 87 | 88 | try validateResponse(response) 89 | 90 | return AsyncThrowingStream { continuation in 91 | Task { 92 | do { 93 | // 处理 SSE 流 94 | try await processStream( 95 | bytes: bytes, 96 | continuation: continuation 97 | ) 98 | continuation.finish() 99 | } catch { 100 | continuation.finish(throwing: error) 101 | } 102 | } 103 | } 104 | } 105 | 106 | 107 | 108 | // 处理 SSE 流 109 | private func processStream( 110 | bytes: URLSession.AsyncBytes, 111 | continuation: AsyncThrowingStream.Continuation 112 | ) async throws { 113 | var isThinking = false 114 | for try await line in bytes.lines { 115 | print(line) 116 | guard line.hasPrefix("data:") else { continue } 117 | 118 | let jsonDataString = line.dropFirst(5).trimmingCharacters( 119 | in: .whitespacesAndNewlines 120 | ) 121 | 122 | if jsonDataString == "[DONE]" { 123 | return 124 | } 125 | 126 | guard let jsonData = jsonDataString.data(using: .utf8) else { 127 | continue 128 | } 129 | 130 | do { 131 | let response = try JSONDecoder().decode( 132 | APIResponseMessage.self, 133 | from: jsonData 134 | ) 135 | if let content = response.choices?.first?.delta { 136 | let content = transformThinking(delta: content,isThinking:&isThinking) 137 | continuation.yield(content) 138 | } 139 | } catch { 140 | print("JSON 解码错误: \(error) for data: \(jsonDataString)") 141 | continue 142 | } 143 | } 144 | } 145 | 146 | private func transformThinking(delta: Delta,isThinking: inout Bool) -> Delta{ 147 | guard var content = delta.content else { 148 | return delta 149 | } 150 | if content.contains("") { 151 | isThinking = true 152 | content.removeAll{ "".contains($0) } 153 | }else if content.contains("") { 154 | isThinking = false 155 | content.removeAll { content in 156 | "".contains(content) 157 | } 158 | } 159 | let delta = Delta( 160 | content: isThinking ? nil : content, 161 | reasoning: isThinking ? content : nil 162 | ) 163 | return delta 164 | } 165 | } 166 | 167 | -------------------------------------------------------------------------------- /Sources/Protocols/Services/CloudflareService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GrokService.swift 3 | // iChat 4 | // 5 | // Created by Lion on 2025/4/28. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Cloudflare客户端实现 11 | class CloudflareService: AIProtocol { 12 | 13 | private let chatPath = "/compat/chat/completions" 14 | private let modelPath = "/compat/openai/models" 15 | private let session: URLSession = .shared 16 | 17 | func getModels(provider: AIProvider) async throws -> [Model] { 18 | guard let APIURL = URL(string: provider.APIURL + modelPath) else { 19 | throw AIError.WrongAPIURL 20 | } 21 | // 构建请求 22 | var request = URLRequest(url: APIURL) 23 | request.httpMethod = "GET" 24 | request.setValue( 25 | "Bearer \(provider.APIKey)", 26 | forHTTPHeaderField: "Authorization" 27 | ) 28 | request.setValue( 29 | "application/json", 30 | forHTTPHeaderField: "Content-Type" 31 | ) 32 | let (data, response) = try await session.data(for: request) 33 | // print(String(data: data, encoding: .utf8)) 34 | try validateResponse(response) 35 | let modelResponse = try JSONDecoder().decode( 36 | ModelResponse.self, 37 | from: data 38 | ) 39 | return modelResponse.data 40 | } 41 | 42 | 43 | 44 | 45 | func streamChatResponse( 46 | provider:AIProvider, 47 | model: AIModel, 48 | messages: [ChatMessage], 49 | temperature:Double 50 | ) async throws -> AsyncThrowingStream { 51 | guard let provider = model.provider else{ 52 | throw AIError.MissingProvider 53 | } 54 | guard 55 | let APIURL = URL(string: provider.APIURL + chatPath) 56 | else { 57 | throw AIError.WrongAPIURL 58 | } 59 | // 构建请求 60 | var request = URLRequest(url: APIURL) 61 | request.httpMethod = "POST" 62 | request.setValue( 63 | "Bearer \(provider.APIKey)", 64 | forHTTPHeaderField: "Authorization" 65 | ) 66 | request.setValue( 67 | "application/json", 68 | forHTTPHeaderField: "Content-Type" 69 | ) 70 | 71 | // 构建请求体 72 | var requestBody = StreamRequestBody( 73 | model: model.name, 74 | messages: messages.map({ message in 75 | message.apiRepresentation 76 | }), 77 | temperature: temperature, 78 | stream: true 79 | ) 80 | requestBody.extraBody = ExtraBody(google: Google(thinkingConfig: ThinkingConfig())) 81 | request.httpBody = try JSONEncoder().encode(requestBody) 82 | 83 | // 获取流式响应 84 | let (bytes, response) = try await session.bytes( 85 | for: request 86 | ) 87 | 88 | try validateResponse(response) 89 | 90 | return AsyncThrowingStream { continuation in 91 | Task { 92 | do { 93 | // 处理 SSE 流 94 | try await processStream( 95 | bytes: bytes, 96 | continuation: continuation 97 | ) 98 | continuation.finish() 99 | } catch { 100 | continuation.finish(throwing: error) 101 | } 102 | } 103 | } 104 | } 105 | 106 | 107 | 108 | // 处理 SSE 流 109 | private func processStream( 110 | bytes: URLSession.AsyncBytes, 111 | continuation: AsyncThrowingStream.Continuation 112 | ) async throws { 113 | var isThinking = false 114 | for try await line in bytes.lines { 115 | print(line) 116 | guard line.hasPrefix("data:") else { continue } 117 | 118 | let jsonDataString = line.dropFirst(5).trimmingCharacters( 119 | in: .whitespacesAndNewlines 120 | ) 121 | 122 | if jsonDataString == "[DONE]" { 123 | return 124 | } 125 | 126 | guard let jsonData = jsonDataString.data(using: .utf8) else { 127 | continue 128 | } 129 | 130 | do { 131 | let response = try JSONDecoder().decode( 132 | APIResponseMessage.self, 133 | from: jsonData 134 | ) 135 | if let content = response.choices?.first?.delta { 136 | let content = transformThinking(delta: content,isThinking:&isThinking) 137 | continuation.yield(content) 138 | } 139 | } catch { 140 | print("JSON 解码错误: \(error) for data: \(jsonDataString)") 141 | continue 142 | } 143 | } 144 | } 145 | 146 | private func transformThinking(delta: Delta,isThinking: inout Bool) -> Delta{ 147 | guard var content = delta.content else { 148 | return delta 149 | } 150 | if content.contains("") { 151 | isThinking = true 152 | content.removeAll{ "".contains($0) } 153 | }else if content.contains("") { 154 | isThinking = false 155 | content.removeAll { content in 156 | "".contains(content) 157 | } 158 | } 159 | let delta = Delta( 160 | content: isThinking ? nil : content, 161 | reasoning: isThinking ? content : nil 162 | ) 163 | return delta 164 | } 165 | } 166 | 167 | -------------------------------------------------------------------------------- /Sources/Views/MainView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // iChatView.swift 3 | // iChat 4 | // 5 | // Created by Lion on 2025/4/25. 6 | // 7 | 8 | import SwiftData 9 | import SwiftUI 10 | 11 | struct MainView: View { 12 | @AppStorage("language") var language = LanguageEnum.auto 13 | @Environment(\.modelContext) private var modelContext 14 | @Environment(\.openWindow) private var openWindow 15 | @State private var selectedSession: ChatSession? 16 | @State private var isShowError = false 17 | 18 | @Query(filter: #Predicate { $0.isDefault == true }) 19 | private var models: [AIModel] = [] 20 | 21 | @Query var providers: [AIProvider] = [] 22 | 23 | @Query(sort: [SortDescriptor(\ChatSession.createdAt, order: .reverse)]) 24 | private var sessions: [ChatSession] = [] 25 | 26 | var body: some View { 27 | NavigationSplitView { 28 | VStack { 29 | List(selection: $selectedSession) { 30 | AssistantSideView { assistant in 31 | createNewSession(assistant: assistant) 32 | } 33 | SessionSideView{ 34 | selectedSession = nil 35 | } 36 | } 37 | VStack { 38 | Menu(getDefaultModelName()) { 39 | ForEach(providers) { provider in 40 | Menu(provider.title) { 41 | ForEach(provider.models) { model in 42 | Button { 43 | setDefaultModel(model: model) 44 | } label: { 45 | Text(model.name) 46 | } 47 | } 48 | } 49 | } 50 | } 51 | Divider() 52 | Button { 53 | NSApp.activate(ignoringOtherApps: true) 54 | openWindow(id: "Settings") 55 | } label: { 56 | HStack { 57 | Image(systemName: "gear") 58 | .foregroundStyle(Color.accentColor) 59 | Text("Settings") 60 | Spacer() 61 | }.font(.title2) 62 | } 63 | .buttonStyle(.plain) 64 | .padding(.top,5) 65 | } 66 | .padding() 67 | } 68 | .navigationTitle("Chat Sessions") 69 | .navigationSplitViewColumnWidth(220) 70 | .toolbar { 71 | ToolbarItem { 72 | Button { 73 | createNewSession() 74 | } label: { 75 | Label("New Chat", systemImage: "plus.bubble") 76 | .foregroundStyle(Color.accentColor) 77 | }.alert("Error", isPresented: $isShowError) { 78 | Button("OK") { 79 | isShowError = false 80 | } 81 | } message: { 82 | Text("Please select default model").foregroundColor( 83 | .red 84 | ) 85 | } 86 | } 87 | } 88 | 89 | } detail: { 90 | if let session = selectedSession { 91 | ChatSessionView(session: session) 92 | .id(session) // 重要: 当 sessionId 改变时,强制刷新 ChatView 93 | } 94 | }.onAppear { 95 | // createNewSession() 96 | } 97 | } 98 | 99 | /// 创建系统信息 100 | /// - Parameter session: Session 101 | /// 102 | private func createSystemMessage(session: ChatSession) { 103 | let systemMessage = ChatMessage( 104 | modelName: ChatRoleEnum.system.rawValue, 105 | content: language.content, 106 | role: .system, 107 | session: session 108 | ) 109 | modelContext.insert(systemMessage) 110 | } 111 | 112 | /// 设置默认模型 113 | /// - Parameter model: 选择的模型 114 | private func setDefaultModel(model: AIModel) { 115 | if let defaultModel = getDefaultModel() { 116 | defaultModel.isDefault = false 117 | } 118 | model.isDefault = true 119 | try? modelContext.save() 120 | } 121 | 122 | /// 获取默认模型 123 | /// - Returns: 默认模型 124 | private func getDefaultModel() -> AIModel? { 125 | if let defaultModel = models.first { 126 | return defaultModel 127 | } 128 | return nil 129 | } 130 | 131 | /// 获取默认模型的名字 132 | /// - Returns: 默认模型的名字 133 | private func getDefaultModelName() -> String { 134 | var name = "Select Default Model" 135 | if let model = getDefaultModel() { 136 | name = model.name 137 | } 138 | return name 139 | } 140 | 141 | private func createNewSession(assistant: Assistant) { 142 | if let model = assistant.model { 143 | createNewSession(model: model, assistant: assistant) 144 | return 145 | } 146 | if let model = getDefaultModel() { 147 | createNewSession(model: model, assistant: assistant) 148 | return 149 | } 150 | isShowError = true 151 | } 152 | 153 | private func createNewSession() { 154 | if let model = getDefaultModel() { 155 | let session = createNewSession( 156 | model: model 157 | ) 158 | selectedSession = session 159 | } else { 160 | isShowError = true 161 | } 162 | } 163 | 164 | @MainActor 165 | private func createNewSession(model: AIModel, assistant: Assistant) { 166 | let session = createNewSession(model: model) 167 | session.temperature = assistant.temperature 168 | if let model = assistant.model { 169 | session.model = model 170 | } 171 | if let message = session.messages.first { 172 | message.content.append(assistant.prompt) 173 | } 174 | selectedSession = session 175 | } 176 | // 创建新会话 177 | 178 | @MainActor 179 | private func createNewSession(model: AIModel) -> ChatSession { 180 | if let session = sessions.first(where: { $0.title.isEmpty }) { 181 | modelContext.delete(session) 182 | try? modelContext.save() // 保存以获取持久化 ID 183 | } 184 | let newSession = ChatSession(model: model) 185 | modelContext.insert(newSession) 186 | try? modelContext.save() // 保存以获取持久化 ID 187 | createSystemMessage(session: newSession) 188 | return newSession 189 | } 190 | 191 | } 192 | --------------------------------------------------------------------------------