├── 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 | 
27 |
28 | 
29 |
30 | 
31 |
32 | 
33 |
34 | 
35 |
36 | 
37 |
38 | 
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 | 
26 |
27 | 
28 |
29 | 
30 |
31 | 
32 |
33 | 
34 |
35 | 
36 |
37 | 
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 |
--------------------------------------------------------------------------------