[{"data":1,"prerenderedAt":818},["ShallowReactive",2],{"portfolio-\u002Fportfolio\u002Fnexusgpt":3},{"id":4,"title":5,"award":6,"body":7,"cover":795,"description":150,"extension":796,"featured":797,"link":6,"meta":798,"navigation":288,"order":207,"path":799,"pinned":797,"repo":800,"role":801,"seo":802,"stack":803,"stem":814,"summary":815,"year":816,"__hash__":817},"portfolio\u002Fportfolio\u002Fnexusgpt.md","NexusGPT — 企業內部 ChatGPT 平台",null,{"type":8,"value":9,"toc":782},"minimark",[10,14,23,30,33,37,44,76,79,82,89,92,119,123,130,141,144,386,389,412,416,419,426,452,459,462,468,474,480,483,486,544,547,720,727,731,751,754,771,775,778],[11,12,13],"h2",{"id":13},"專案概述",[15,16,17,18,22],"p",{},"ChatGPT 推出之後，公司裡很多同仁開始自己拿個人帳號用 GPT 寫程式、整理資料、寫\n文案。問題也跟著來：每個人各自付費，費用分散又難管理；更麻煩的是，開發人員會直\n接把",[19,20,21],"strong",{},"內部程式碼、客戶資料","貼到外部工具上，資安上完全無法掌握。",[15,24,25,26,29],{},"於是我設計並開發了 ",[19,27,28],{},"NexusGPT","：一個只在公司內部使用的 ChatGPT 平台。同仁用\n公司帳號登入，就能使用 GPT-4 與 DALL-E 3，所有對話都留在公司自己的系統裡，費用\n也由公司統一向 OpenAI 採購 API 用量。",[15,31,32],{},"核心程式碼後來以 Hexagonal Architecture 整理成獨立的開源專案，拿掉了內部串接的\n部分。",[11,34,36],{"id":35},"介面做得跟-chatgpt-一樣","介面：做得跟 ChatGPT 一樣",[15,38,39,40,43],{},"一個內部工具要讓大家願意用，最快的方式就是",[19,41,42],{},"讓大家不用重新學","。當時同仁已經習\n慣 ChatGPT 的操作方式，所以 NexusGPT 的前端刻意做得和 ChatGPT 幾乎一模一樣：",[45,46,47,54,60,66],"ul",{},[48,49,50,53],"li",{},[19,51,52],{},"深色主題","：整個介面採用黑底配色，和當時的 ChatGPT 一致",[48,55,56,59],{},[19,57,58],{},"左側聊天室列表","：依「今天、過去 7 天」分組，點一下就切換對話；上方是「新對\n話」按鈕，下方顯示目前登入的帳號",[48,61,62,65],{},[19,63,64],{},"右側對話區","：使用者的訊息靠右、以圓角氣泡呈現，AI 的回覆靠左，支援 Markdown\n表格、程式碼區塊與檔案下載",[48,67,68,71,72,75],{},[19,69,70],{},"底部輸入框","：圓角輸入列與送出按鈕，送出後回覆會",[19,73,74],{},"一個字一個字","出現在畫面上",[15,77,78],{},"因為介面和操作方式幾乎沒有落差，導入時不需要額外辦教育訓練，同仁打開就會用。",[11,80,81],{"id":81},"系統架構",[15,83,84],{},[85,86],"img",{"alt":87,"src":88},"NexusGPT 架構圖：前端先透過 OpenID Connect 以公司帳號登入取得 Token，再經由 SignalR WebSocket 送出訊息；後端驗證 Token 取出使用者 ID，向 OpenAI API 以串流方式取得回覆並逐字推送回前端，聊天室與訊息依使用者 ID 存入 MSSQL，DALL-E 生成的圖片存到 AWS S3","\u002Fimages\u002Fportfolio\u002Fnexusgpt-architecture.svg",[15,90,91],{},"整個流程分成四步：",[93,94,95,101,107,113],"ol",{},[48,96,97,100],{},[19,98,99],{},"登入","：前端導向公司的 OpenID Connect 服務，以公司帳號登入後取得 Token",[48,102,103,106],{},[19,104,105],{},"送出訊息","：前端透過 SignalR 建立的 WebSocket 連線，把訊息送到後端",[48,108,109,112],{},[19,110,111],{},"串流回覆","：後端以串流模式呼叫 OpenAI API，GPT 每產生一小段文字就收到一段",[48,114,115,118],{},[19,116,117],{},"即時推送","：每收到一段，就立刻透過同一條 WebSocket 推回前端，畫面上逐字長出\n回覆",[11,120,122],{"id":121},"即時對話signalr-串流回覆","即時對話：SignalR + 串流回覆",[15,124,125,126,129],{},"GPT-4 產生一段完整的回答常常要十幾秒到幾十秒。如果用一般的 HTTP 請求，使用者要\n盯著空白畫面等到整段生成完才看得到結果，體驗很差。ChatGPT 之所以感覺「很快」，\n是因為它",[19,127,128],{},"邊生成邊顯示","。",[15,131,132,133,136,137,140],{},"要做到這點，後端需要能「主動、連續地」把資料推給前端，所以選用 ",[19,134,135],{},"SignalR","——\nASP.NET Core 的即時通訊框架，底層優先使用 ",[19,138,139],{},"WebSocket","，連線建立後雙方都能隨時\n傳送訊息。",[15,142,143],{},"後端的做法是把 OpenAI 的串流回覆，原封不動地接到 SignalR 的串流上：",[145,146,151],"pre",{"className":147,"code":148,"language":149,"meta":150,"style":150},"language-csharp shiki shiki-themes github-dark","\u002F\u002F 示意：OpenAI 每回傳一小段文字，就立刻推給前端\npublic async IAsyncEnumerable\u003Cstring> SendMessage(\n    Guid chatRoomId,\n    string message,\n    [EnumeratorCancellation] CancellationToken cancellationToken)\n{\n    var userId = Context.UserIdentifier!;\n    var answer = new StringBuilder();\n\n    await foreach (var chunk in _chatService.StreamAsync(userId, chatRoomId, message, cancellationToken))\n    {\n        answer.Append(chunk);\n        yield return chunk;\n    }\n\n    \u002F\u002F 串流結束後，把完整的回答與 Token 用量寫回該使用者的聊天室\n    await _chatService.SaveAnswerAsync(userId, chatRoomId, answer.ToString());\n}\n","csharp","",[152,153,154,163,193,205,216,237,243,264,283,290,320,326,338,344,350,355,361,380],"code",{"__ignoreMap":150},[155,156,159],"span",{"class":157,"line":158},"line",1,[155,160,162],{"class":161},"sAwPA","\u002F\u002F 示意：OpenAI 每回傳一小段文字，就立刻推給前端\n",[155,164,166,170,173,177,181,184,187,190],{"class":157,"line":165},2,[155,167,169],{"class":168},"snl16","public",[155,171,172],{"class":168}," async",[155,174,176],{"class":175},"svObZ"," IAsyncEnumerable",[155,178,180],{"class":179},"s95oV","\u003C",[155,182,183],{"class":168},"string",[155,185,186],{"class":179},"> ",[155,188,189],{"class":175},"SendMessage",[155,191,192],{"class":179},"(\n",[155,194,196,199,202],{"class":157,"line":195},3,[155,197,198],{"class":175},"    Guid",[155,200,201],{"class":175}," chatRoomId",[155,203,204],{"class":179},",\n",[155,206,208,211,214],{"class":157,"line":207},4,[155,209,210],{"class":168},"    string",[155,212,213],{"class":175}," message",[155,215,204],{"class":179},[155,217,219,222,225,228,231,234],{"class":157,"line":218},5,[155,220,221],{"class":179},"    [",[155,223,224],{"class":175},"EnumeratorCancellation",[155,226,227],{"class":179},"] ",[155,229,230],{"class":175},"CancellationToken",[155,232,233],{"class":175}," cancellationToken",[155,235,236],{"class":179},")\n",[155,238,240],{"class":157,"line":239},6,[155,241,242],{"class":179},"{\n",[155,244,246,249,252,255,258,261],{"class":157,"line":245},7,[155,247,248],{"class":168},"    var",[155,250,251],{"class":175}," userId",[155,253,254],{"class":168}," =",[155,256,257],{"class":179}," Context.UserIdentifier",[155,259,260],{"class":168},"!",[155,262,263],{"class":179},";\n",[155,265,267,269,272,274,277,280],{"class":157,"line":266},8,[155,268,248],{"class":168},[155,270,271],{"class":175}," answer",[155,273,254],{"class":168},[155,275,276],{"class":168}," new",[155,278,279],{"class":175}," StringBuilder",[155,281,282],{"class":179},"();\n",[155,284,286],{"class":157,"line":285},9,[155,287,289],{"emptyLinePlaceholder":288},true,"\n",[155,291,293,296,299,302,305,308,311,314,317],{"class":157,"line":292},10,[155,294,295],{"class":168},"    await",[155,297,298],{"class":168}," foreach",[155,300,301],{"class":179}," (",[155,303,304],{"class":168},"var",[155,306,307],{"class":175}," chunk",[155,309,310],{"class":168}," in",[155,312,313],{"class":179}," _chatService.",[155,315,316],{"class":175},"StreamAsync",[155,318,319],{"class":179},"(userId, chatRoomId, message, cancellationToken))\n",[155,321,323],{"class":157,"line":322},11,[155,324,325],{"class":179},"    {\n",[155,327,329,332,335],{"class":157,"line":328},12,[155,330,331],{"class":179},"        answer.",[155,333,334],{"class":175},"Append",[155,336,337],{"class":179},"(chunk);\n",[155,339,341],{"class":157,"line":340},13,[155,342,343],{"class":179},"        yield return chunk;\n",[155,345,347],{"class":157,"line":346},14,[155,348,349],{"class":179},"    }\n",[155,351,353],{"class":157,"line":352},15,[155,354,289],{"emptyLinePlaceholder":288},[155,356,358],{"class":157,"line":357},16,[155,359,360],{"class":161},"    \u002F\u002F 串流結束後，把完整的回答與 Token 用量寫回該使用者的聊天室\n",[155,362,364,366,368,371,374,377],{"class":157,"line":363},17,[155,365,295],{"class":168},[155,367,313],{"class":179},[155,369,370],{"class":175},"SaveAnswerAsync",[155,372,373],{"class":179},"(userId, chatRoomId, answer.",[155,375,376],{"class":175},"ToString",[155,378,379],{"class":179},"());\n",[155,381,383],{"class":157,"line":382},18,[155,384,385],{"class":179},"}\n",[15,387,388],{},"幾個實作上的細節：",[45,390,391,397,406],{},[48,392,393,396],{},[19,394,395],{},"串流中不寫資料庫","：每一小段文字只推給前端，等整段回覆結束後才一次把完整訊\n息寫回聊天室，避免每個字都打一次資料庫",[48,398,399,402,403,405],{},[19,400,401],{},"支援中途停止","：使用者按下停止或關掉頁面時，",[152,404,230],{}," 會一路傳到\nOpenAI 的請求，立刻中斷生成，不會在背景繼續消耗 Token",[48,407,408,411],{},[19,409,410],{},"前端只負責拼接","：前端收到一段就接在目前回覆的後面，並重新渲染 Markdown，\n表格和程式碼區塊在生成過程中就會逐步成形",[11,413,415],{"id":414},"帳號整合openid-connect","帳號整合：OpenID Connect",[15,417,418],{},"一開始的版本，聊天紀錄是跟著「瀏覽器」走的：換一台電腦、清掉瀏覽器資料，之前的\n對話就找不回來，也沒辦法知道某段對話是誰發起的。",[15,420,421,422,425],{},"後來把 NexusGPT 串上公司的 ",[19,423,424],{},"OpenID Connect"," 服務：",[45,427,428,434,440,446],{},[48,429,430,433],{},[19,431,432],{},"公司帳號登入","：同仁直接用既有的公司帳號登入，不需要另外註冊；離職的帳號停\n用後，自然也就無法再使用",[48,435,436,439],{},[19,437,438],{},"對話跟著使用者走","：後端驗證 Token 後，從中取出使用者的唯一識別碼，所有聊天\n室都以這個 ID 存放與查詢。不論在公司電腦、筆電還是換了瀏覽器，登入後看到的都\n是自己的聊天室列表與完整對話紀錄",[48,441,442,445],{},[19,443,444],{},"只看得到自己的資料","：查詢、改名、刪除聊天室時，一律以 Token 中的使用者 ID 過\n濾，不能靠改網址或參數看到別人的對話",[48,447,448,451],{},[19,449,450],{},"用量可以歸戶","：每段對話的 GPT Token 費用都記在對應的使用者底下，各部門的實\n際用量一目了然",[15,453,454,455,458],{},"SignalR 的連線同樣需要帶上 Token，Hub 透過 ",[152,456,457],{},"Context.UserIdentifier"," 就能知道目前\n是哪位使用者在說話，即時對話與一般 API 共用同一套身分驗證。",[11,460,461],{"id":461},"功能",[15,463,464,467],{},[19,465,466],{},"聊天室","：建立、改名、刪除、列表、取得內容、上傳、分享、搜尋聊天室",[15,469,470,473],{},[19,471,472],{},"聊天室訊息","：支援一般文字對話（串流回覆），以及 DALL-E 3 圖片訊息",[15,475,476,479],{},[19,477,478],{},"資料分析","：上傳資料讓 GPT-4 分析，並可把 AI 回覆內容匯出成 Excel",[11,481,482],{"id":482},"架構設計",[15,484,485],{},"同樣採用 Hexagonal Architecture，並把「圖片儲存」做成可抽換的 Port／Adapter：",[45,487,488,498,504,510,524,530],{},[48,489,490,493,494,497],{},[152,491,492],{},"NexusGPT.Entities","：聚合根與領域事件（",[152,495,496],{},"Events.cs","），以 Aggregate Root、\nCode First 方式建模",[48,499,500,503],{},[152,501,502],{},"NexusGPT.UseCase","：應用層服務（建立\u002F變更聊天室、新增文字訊息、新增圖片訊\n息、以 Stream 方式新增訊息）",[48,505,506,509],{},[152,507,508],{},"NexusGPT.Adapter.Out","：以 EF Core 存取 SQL Server 的資料庫存取層",[48,511,512,515,516,519,520,523],{},[152,513,514],{},"NexusGPT.Adapter.Out.ImageStorage.Local"," \u002F ",[152,517,518],{},"NexusGPT.Adapter.Out.ImageStorage.S3","：\n",[19,521,522],{},"同一個 Port 的兩種 Adapter 實作","——本機測試用本地檔案系統，正式環境換成\nAWS S3，上層程式完全不需要改動",[48,525,526,529],{},[152,527,528],{},"NexusGPT.EventBus","：領域事件匯流排",[48,531,532,535,536,539,540,543],{},[152,533,534],{},"NexusGPT.WebApplication","：Web API 進入點，整合 SignalR（",[152,537,538],{},"SignalRSwaggerGen","\n讓 SignalR Hub 也能出現在 Swagger 文件中）與 API 版本控制\n（",[152,541,542],{},"Asp.Versioning.Mvc.ApiExplorer","）",[11,545,546],{"id":546},"部署方式",[145,548,552],{"className":549,"code":550,"language":551,"meta":150,"style":150},"language-bash shiki shiki-themes github-dark","# 建立 Migration\ndotnet ef migrations add InitialCreate \\\n  --project NexusGPT.Adapter.Out --startup-project NexusGPT.WebApplication\n\n# 打包 Docker image\ndocker build -f NexusGPT.WebApplication\u002FDockerfile -t nexusgpt:0.0.1 .\n\n# 使用 AWS S3 儲存圖片\ndocker run -d -p 8080:80 --name nexusgpt \\\n  -e ConnectionString=\"Your ConnectionString\" \\\n  -e AWS_ACCESSKEY=\"Your AWSAccessKey\" \\\n  -e AWS_SECRET=\"Your AWSSecretKey\" \\\n  -e AWS_REGION=\"Your AWSRegion\" \\\n  -e AWS_BUCKET=\"Your AWSBucketName\" \\\n  -e OPENAI_API_KEY=\"Your OpenAI API Key\" \\\n  nexusgpt:0.0.1\n","bash",[152,553,554,559,581,595,599,604,627,631,636,660,670,679,688,697,706,715],{"__ignoreMap":150},[155,555,556],{"class":157,"line":158},[155,557,558],{"class":161},"# 建立 Migration\n",[155,560,561,564,568,571,574,577],{"class":157,"line":165},[155,562,563],{"class":175},"dotnet",[155,565,567],{"class":566},"sU2Wk"," ef",[155,569,570],{"class":566}," migrations",[155,572,573],{"class":566}," add",[155,575,576],{"class":566}," InitialCreate",[155,578,580],{"class":579},"sDLfK"," \\\n",[155,582,583,586,589,592],{"class":157,"line":195},[155,584,585],{"class":579},"  --project",[155,587,588],{"class":566}," NexusGPT.Adapter.Out",[155,590,591],{"class":579}," --startup-project",[155,593,594],{"class":566}," NexusGPT.WebApplication\n",[155,596,597],{"class":157,"line":207},[155,598,289],{"emptyLinePlaceholder":288},[155,600,601],{"class":157,"line":218},[155,602,603],{"class":161},"# 打包 Docker image\n",[155,605,606,609,612,615,618,621,624],{"class":157,"line":239},[155,607,608],{"class":175},"docker",[155,610,611],{"class":566}," build",[155,613,614],{"class":579}," -f",[155,616,617],{"class":566}," NexusGPT.WebApplication\u002FDockerfile",[155,619,620],{"class":579}," -t",[155,622,623],{"class":566}," nexusgpt:0.0.1",[155,625,626],{"class":566}," .\n",[155,628,629],{"class":157,"line":245},[155,630,289],{"emptyLinePlaceholder":288},[155,632,633],{"class":157,"line":266},[155,634,635],{"class":161},"# 使用 AWS S3 儲存圖片\n",[155,637,638,640,643,646,649,652,655,658],{"class":157,"line":285},[155,639,608],{"class":175},[155,641,642],{"class":566}," run",[155,644,645],{"class":579}," -d",[155,647,648],{"class":579}," -p",[155,650,651],{"class":566}," 8080:80",[155,653,654],{"class":579}," --name",[155,656,657],{"class":566}," nexusgpt",[155,659,580],{"class":579},[155,661,662,665,668],{"class":157,"line":292},[155,663,664],{"class":579},"  -e",[155,666,667],{"class":566}," ConnectionString=\"Your ConnectionString\"",[155,669,580],{"class":579},[155,671,672,674,677],{"class":157,"line":322},[155,673,664],{"class":579},[155,675,676],{"class":566}," AWS_ACCESSKEY=\"Your AWSAccessKey\"",[155,678,580],{"class":579},[155,680,681,683,686],{"class":157,"line":328},[155,682,664],{"class":579},[155,684,685],{"class":566}," AWS_SECRET=\"Your AWSSecretKey\"",[155,687,580],{"class":579},[155,689,690,692,695],{"class":157,"line":340},[155,691,664],{"class":579},[155,693,694],{"class":566}," AWS_REGION=\"Your AWSRegion\"",[155,696,580],{"class":579},[155,698,699,701,704],{"class":157,"line":346},[155,700,664],{"class":579},[155,702,703],{"class":566}," AWS_BUCKET=\"Your AWSBucketName\"",[155,705,580],{"class":579},[155,707,708,710,713],{"class":157,"line":352},[155,709,664],{"class":579},[155,711,712],{"class":566}," OPENAI_API_KEY=\"Your OpenAI API Key\"",[155,714,580],{"class":579},[155,716,717],{"class":157,"line":357},[155,718,719],{"class":566},"  nexusgpt:0.0.1\n",[15,721,722,723,726],{},"換成本地檔案儲存則只需要把 S3 相關環境變數換成一個掛載路徑（",[152,724,725],{},"-v \u002Fpath\u002Fto\u002Flocal\u002Ffile:\u002Fapp\u002FImages","），不需要改任何程式碼——這就是 Port／Adapter\n模式的意義所在。",[11,728,730],{"id":729},"實際導入內容公司內部版本","實際導入內容（公司內部版本）",[45,732,733,736,739,742,745,748],{},[48,734,735],{},"仿 ChatGPT 的深色對話介面，同仁無需教育訓練即可上手",[48,737,738],{},"串接公司 OpenID Connect 登入，聊天室與對話紀錄依使用者保存，跨裝置都找得回來",[48,740,741],{},"建立個人聊天室，儲存對話紀錄、計算每段對話的 GPT Token 費用，並支援聊天室分享",[48,743,744],{},"串接 GPT-4，支援上傳資料進行分析，並可將 AI 回覆內容匯出成 Excel",[48,746,747],{},"串接 DALL-E 3 圖片對話，並將生成圖片儲存至 AWS S3",[48,749,750],{},"搭配 SignalR（WebSocket）與 OpenAI 串流回覆，實現逐字顯示的即時對話",[11,752,753],{"id":753},"成果",[45,755,756,762,765,768],{},[48,757,758,759],{},"公司採購 GPT-4 的費用減省 ",[19,760,761],{},"3 倍以上",[48,763,764],{},"有效過濾資安風險，防止開發人員將內部程式碼提供給外部 GPT 工具",[48,766,767],{},"提升企劃、行政等單位的作業效率，如圖片生成、報表分析與資料整理",[48,769,770],{},"所有對話與用量都能對應到個人帳號，管理與費用歸屬一目了然",[11,772,774],{"id":773},"尚未完成規劃中","尚未完成／規劃中",[15,776,777],{},"開源版本 README 上列出的後續項目：支援 AWS DynamoDB、支援 MongoDB、加上使用\n者驗證、加上 OpenTelemetry 可觀測性。（使用者驗證在公司內部版本是以 OpenID\nConnect 完成，開源版本因為拿掉了內部串接，尚未補上。）",[779,780,781],"style",{},"html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}",{"title":150,"searchDepth":165,"depth":165,"links":783},[784,785,786,787,788,789,790,791,792,793,794],{"id":13,"depth":165,"text":13},{"id":35,"depth":165,"text":36},{"id":81,"depth":165,"text":81},{"id":121,"depth":165,"text":122},{"id":414,"depth":165,"text":415},{"id":461,"depth":165,"text":461},{"id":482,"depth":165,"text":482},{"id":546,"depth":165,"text":546},{"id":729,"depth":165,"text":730},{"id":753,"depth":165,"text":753},{"id":773,"depth":165,"text":774},"\u002Fimages\u002Fportfolio\u002Fnexusgpt-cover.svg","md",false,{},"\u002Fportfolio\u002Fnexusgpt","https:\u002F\u002Fgithub.com\u002Ftom3019\u002FNexusGPT","後端開發與架構設計 ‧ 房仲業",{"title":5,"description":150},[804,805,135,139,424,806,807,808,809,810,811,812,813],".NET 8","ASP.NET Core","DDD","Event Sourcing","Hexagonal Architecture","EF Core","MSSQL","OpenAI API","AWS S3","Docker","portfolio\u002Fnexusgpt","公司內部使用的 ChatGPT 平台，前端做成和 ChatGPT 幾乎一樣的深色對話介面，後端以 SignalR（WebSocket）把 GPT 回覆逐字即時推送到畫面上，並串接公司的 OpenID Connect 登入，讓每個人的聊天室跟著帳號走、換電腦也找得回來；具備聊天室、圖片生成與 AI 資料分析功能，使外部 GPT 費用節省 3 倍以上。開源版本以 Hexagonal Architecture 實作，儲存後端可在本機檔案與 AWS S3 間直接抽換。",2023,"xqkDy8O92TGTrOjHnhpXaxJj60-Iz76w9WamLjDmWK0",1792959149967]