找回密码
 立即注册
查看: 31|回复: 1

[C#] C#实现网页端调用OLLAMA本地模型或API网络模型,进行交互聊天

[复制链接]

14

主题

8

回帖

118

积分

管理员

积分
118
发表于 2026-6-25 11:24:27 | 显示全部楼层 |阅读模式




目录结构说明
  1. OllamaWebChat
  2. ├─ Models
  3. │  └─ ChatRequest.cs 【唯一实体文件,无重复类】
  4. ├─ Services
  5. │  └─ OllamaService.cs
  6. ├─ Controllers
  7. │  └─ ChatController.cs
  8. ├─ Views/Chat
  9. │  └─ Index.cshtml 【前端完整修复版,加全量注释】
  10. └─ Program.cs
复制代码
1. Models/ChatRequest.cs
  1. using System.Collections.Generic;

  2. namespace OllamaWebChat.Models
  3. {
  4.     /// <summary>
  5.     /// 统一请求实体,兼容2种模式:本地Ollama / OpenAI标准在线API
  6.     /// 全局仅一份此类,删除项目内所有旧ChatRequest文件避免冲突
  7.     /// </summary>
  8.     public class ChatRequest
  9.     {
  10.         /// <summary>运行模式:local=本地Ollama  online=第三方在线大模型</summary>
  11.         public string RunMode { get; set; } = "local";

  12.         #region 本地Ollama专用参数
  13.         /// <summary>本地Ollama模型名称,如qwen:7b、llama3:8b</summary>
  14.         public string Model { get; set; }
  15.         #endregion

  16.         #region 在线OpenAI兼容接口参数
  17.         /// <summary>API密钥</summary>
  18.         public string ApiKey { get; set; }
  19.         /// <summary>API完整接口地址</summary>
  20.         public string OnlineApiUrl { get; set; }
  21.         /// <summary>在线平台模型名称</summary>
  22.         public string OnlineModelName { get; set; }
  23.         #endregion

  24.         /// <summary>系统角色提示词,定义AI身份</summary>
  25.         public string SystemPrompt { get; set; }
  26.         /// <summary>用户本次输入文本内容</summary>
  27.         public string UserContent { get; set; }
  28.         /// <summary>完整对话上下文历史(多轮记忆)</summary>
  29.         public List<OllamaMessage> History { get; set; } = new();
  30.         /// <summary>本次上传附件列表(图片/代码/文档)</summary>
  31.         public List<ChatAttachment> Attachments { get; set; } = new();
  32.     }

  33.     /// <summary>上传附件实体,存储文件base64、OCR识别文字</summary>
  34.     public class ChatAttachment
  35.     {
  36.         /// <summary>原始文件名</summary>
  37.         public string name { get; set; }
  38.         /// <summary>MIME文件类型 image/png text/cs等</summary>
  39.         public string type { get; set; }
  40.         /// <summary>base64文件数据</summary>
  41.         public string data { get; set; }
  42.         /// <summary>图片OCR识别出的文字内容</summary>
  43.         public string ocrText { get; set; }
  44.     }

  45.     /// <summary>通用消息结构,兼容Ollama/OpenAI message格式</summary>
  46.     public class OllamaMessage
  47.     {
  48.         /// <summary>角色 system/user/assistant</summary>
  49.         public string role { get; set; }
  50.         /// <summary>对话文本内容</summary>
  51.         public string content { get; set; }
  52.     }

  53.     #region Ollama 本地模型流式返回JSON映射
  54.     public class OllamaStreamChunk
  55.     {
  56.         public OllamaMessage message { get; set; }
  57.         public bool done { get; set; }
  58.     }
  59.     #endregion

  60.     #region OpenAI 在线API流式返回JSON映射
  61.     public class OpenAiStreamChunk
  62.     {
  63.         public List<OpenAiChoice> choices { get; set; }
  64.     }
  65.     public class OpenAiChoice
  66.     {
  67.         public OpenAiDelta delta { get; set; }
  68.     }
  69.     public class OpenAiDelta
  70.     {
  71.         public string content { get; set; }
  72.     }
  73.     #endregion

  74.     #region Ollama /api/tags 获取本地模型列表实体
  75.     public class ModelListResp
  76.     {
  77.         public List<ModelItem> models { get; set; }
  78.     }
  79.     public class ModelItem
  80.     {
  81.         public string name { get; set; }
  82.     }
  83.     #endregion
  84. }
复制代码
2. Services/OllamaService.cs(支持取消令牌、双模型分流)
  1. using Microsoft.AspNetCore.Http;
  2. using OllamaWebChat.Models;
  3. using System;
  4. using System.Collections.Generic;
  5. using System.Linq;
  6. using System.Net.Http;
  7. using System.Text;
  8. using System.Text.Json;
  9. using System.Threading;
  10. using System.Threading.Tasks;

  11. namespace OllamaWebChat.Services
  12. {
  13.     public class OllamaService
  14.     {
  15.         private readonly HttpClient _httpClient;
  16.         // 本地Ollama默认接口地址
  17.         private const string OllamaChatApi = "http://localhost:11434/api/chat";

  18.         public OllamaService(HttpClient httpClient)
  19.         {
  20.             _httpClient = httpClient;
  21.             // 超时10分钟,长文本生成预留充足时间
  22.             _httpClient.Timeout = TimeSpan.FromMinutes(10);
  23.         }

  24.         /// <summary>
  25.         /// 统一流式SSE输出接口
  26.         /// 支持本地Ollama / 在线OpenAI接口,支持客户端主动取消请求
  27.         /// </summary>
  28.         /// <param name="response">输出流对象</param>
  29.         /// <param name="req">前端统一请求参数</param>
  30.         /// <param name="ct">取消令牌,用于停止生成</param>
  31.         public async Task StreamChatAsync(HttpResponse response, ChatRequest req, CancellationToken ct)
  32.         {
  33.             // SSE固定响应头,浏览器识别流式输出
  34.             response.ContentType = "text/event-stream";
  35.             response.Headers.CacheControl = "no-cache";
  36.             response.Headers.Connection = "keep-alive";

  37.             // 组装消息队列
  38.             var messages = new List<OllamaMessage>();
  39.             if (!string.IsNullOrWhiteSpace(req.SystemPrompt))
  40.                 messages.Add(new OllamaMessage { role = "system", content = req.SystemPrompt });
  41.             messages.AddRange(req.History);

  42.             // 拼接所有附件解析内容到用户提问
  43.             string userInput = BuildUserInputWithAttachments(req);
  44.             messages.Add(new OllamaMessage { role = "user", content = userInput });

  45.             // 根据运行模式分流处理
  46.             if (req.RunMode == "local")
  47.                 await StreamLocalOllama(response, messages, req.Model, ct);
  48.             else
  49.                 await StreamOnlineOpenAi(response, messages, req, ct);

  50.             // 流式结束标记,前端识别对话完成
  51.             byte[] endBuffer = Encoding.UTF8.GetBytes("data:[END]\n\n");
  52.             await response.Body.WriteAsync(endBuffer, ct);
  53.             await response.Body.FlushAsync(ct);
  54.         }

  55.         /// <summary>拼接用户文本+所有附件解析内容</summary>
  56.         private string BuildUserInputWithAttachments(ChatRequest req)
  57.         {
  58.             string userInput = req.UserContent;
  59.             if (!req.Attachments.Any()) return userInput;

  60.             userInput += "\n\n【用户上传附件内容解析】\n";
  61.             foreach (var file in req.Attachments)
  62.             {
  63.                 userInput += $"\n——————————\n文件名:{file.name}\n";
  64.                 if (file.type.StartsWith("image/"))
  65.                 {
  66.                     // 图片优先拼接OCR识别文字
  67.                     userInput += string.IsNullOrEmpty(file.ocrText)
  68.                         ? "图片文件,请根据画面内容分析\n"
  69.                         : $"图片OCR识别内容:\n{file.ocrText}\n";
  70.                 }
  71.                 else
  72.                 {
  73.                     // 文本/代码文件解码base64,超长截断避免接口报错
  74.                     try
  75.                     {
  76.                         string base64Raw = file.data.Split(",").Last();
  77.                         byte[] fileBytes = Convert.FromBase64String(base64Raw);
  78.                         string fileText = Encoding.UTF8.GetString(fileBytes);
  79.                         if (fileText.Length > 6000)
  80.                             fileText = fileText[..6000] + "\n...(内容过长已截断)";
  81.                         userInput += $"文件完整内容:\n{fileText}\n";
  82.                     }
  83.                     catch
  84.                     {
  85.                         userInput += "二进制文件,无法解析文本\n";
  86.                     }
  87.                 }
  88.             }
  89.             return userInput;
  90.         }

  91.         /// <summary>本地Ollama流式请求处理</summary>
  92.         private async Task StreamLocalOllama(HttpResponse response, List<OllamaMessage> messages, string model, CancellationToken ct)
  93.         {
  94.             var bodyObj = new
  95.             {
  96.                 model = model,
  97.                 messages = messages,
  98.                 stream = true,
  99.                 temperature = 0.7
  100.             };
  101.             string json = JsonSerializer.Serialize(bodyObj);
  102.             var content = new StringContent(json, Encoding.UTF8, "application/json");

  103.             using var msg = new HttpRequestMessage(HttpMethod.Post, OllamaChatApi);
  104.             msg.Content = content;
  105.             using var resp = await _httpClient.SendAsync(msg, HttpCompletionOption.ResponseHeadersRead, ct);
  106.             resp.EnsureSuccessStatusCode();

  107.             using var stream = await resp.Content.ReadAsStreamAsync(ct);
  108.             using var reader = new StreamReader(stream);
  109.             string line;
  110.             while ((line = await reader.ReadLineAsync(ct)) != null)
  111.             {
  112.                 ct.ThrowIfCancellationRequested(); // 客户端点击停止,立刻抛出异常中断
  113.                 if (string.IsNullOrWhiteSpace(line)) continue;

  114.                 var chunk = JsonSerializer.Deserialize<OllamaStreamChunk>(line);
  115.                 if (chunk?.message != null && !string.IsNullOrEmpty(chunk.message.content))
  116.                 {
  117.                     byte[] buffer = Encoding.UTF8.GetBytes($"data:{JsonSerializer.Serialize(chunk.message.content)}\n\n");
  118.                     await response.Body.WriteAsync(buffer, ct);
  119.                     await response.Body.FlushAsync(ct);
  120.                 }
  121.                 if (chunk?.done == true) break;
  122.             }
  123.         }

  124.         /// <summary>在线OpenAI兼容接口流式处理(DeepSeek/智谱/通义千问兼容)</summary>
  125.         private async Task StreamOnlineOpenAi(HttpResponse response, List<OllamaMessage> messages, ChatRequest req, CancellationToken ct)
  126.         {
  127.             _httpClient.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", req.ApiKey);
  128.             var bodyObj = new
  129.             {
  130.                 model = req.OnlineModelName,
  131.                 messages = messages,
  132.                 stream = true,
  133.                 temperature = 0.7
  134.             };
  135.             string json = JsonSerializer.Serialize(bodyObj);
  136.             var content = new StringContent(json, Encoding.UTF8, "application/json");

  137.             using var msg = new HttpRequestMessage(HttpMethod.Post, req.OnlineApiUrl);
  138.             msg.Content = content;
  139.             using var resp = await _httpClient.SendAsync(msg, HttpCompletionOption.ResponseHeadersRead, ct);
  140.             resp.EnsureSuccessStatusCode();

  141.             using var stream = await resp.Content.ReadAsStreamAsync(ct);
  142.             using var reader = new StreamReader(stream);
  143.             string line;
  144.             while ((line = await reader.ReadLineAsync(ct)) != null)
  145.             {
  146.                 ct.ThrowIfCancellationRequested();
  147.                 if (string.IsNullOrWhiteSpace(line) || !line.StartsWith("data:")) continue;

  148.                 string raw = line.Replace("data:", "").Trim();
  149.                 if (raw == "[DONE]") break;
  150.                 var chunk = JsonSerializer.Deserialize<OpenAiStreamChunk>(raw);
  151.                 string text = chunk?.choices?.FirstOrDefault()?.delta?.content;
  152.                 if (!string.IsNullOrEmpty(text))
  153.                 {
  154.                     byte[] buffer = Encoding.UTF8.GetBytes($"data:{JsonSerializer.Serialize(text)}\n\n");
  155.                     await response.Body.WriteAsync(buffer, ct);
  156.                     await response.Body.FlushAsync(ct);
  157.                 }
  158.             }
  159.         }

  160.         /// <summary>获取本地Ollama已下载模型列表,异常返回提示文本</summary>
  161.         public async Task<List<string>> GetLocalModels()
  162.         {
  163.             try
  164.             {
  165.                 var res = await _httpClient.GetFromJsonAsync<ModelListResp>("http://localhost:11434/api/tags");
  166.                 return res?.models?.Select(x => x.name).ToList() ?? new List<string>();
  167.             }
  168.             catch
  169.             {
  170.                 return new List<string> { "Ollama服务未启动" };
  171.             }
  172.         }
  173.     }
  174. }
复制代码
3. Controllers/ChatController.cs
  1. using Microsoft.AspNetCore.Mvc;
  2. using OllamaWebChat.Models;
  3. using OllamaWebChat.Services;
  4. using System.Threading;

  5. namespace OllamaWebChat.Controllers
  6. {
  7.     public class ChatController : Controller
  8.     {
  9.         private readonly OllamaService _ollamaService;
  10.         // 构造注入Ollama服务
  11.         public ChatController(OllamaService ollamaService)
  12.         {
  13.             _ollamaService = ollamaService;
  14.         }

  15.         /// <summary>聊天首页,加载本地模型列表传给前端页面</summary>
  16.         public async Task<IActionResult> Index()
  17.         {
  18.             var localModels = await _ollamaService.GetLocalModels();
  19.             ViewBag.Models = localModels;
  20.             return View();
  21.         }

  22.         /// <summary>SSE流式聊天接口,接收前端请求并输出实时文本</summary>
  23.         /// <param name="request">前端表单参数</param>
  24.         /// <param name="ct">请求取消令牌,前端停止按钮触发中断</param>
  25.         [HttpPost]
  26.         public async Task Stream([FromBody] ChatRequest request, CancellationToken ct)
  27.         {
  28.             Response.Headers["X-Content-Type-Options"] = "nosniff";
  29.             await _ollamaService.StreamChatAsync(Response, request, ct);
  30.         }
  31.     }
  32. }
复制代码
4. Views/Chat/Index.cshtml
  1. @{
  2.     ViewData["Title"] = "AI对话平台|本地Ollama / 在线API双模式";
  3.     var models = ViewBag.Models as List<string>;
  4. }

  5. <!-- 脚本置顶优先加载,解决marked未定义报错 -->
  6. <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
  7. <script src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/prism.min.js"></script>
  8. <link href="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism-tomorrow.min.css" rel="stylesheet" />
  9. <script src="https://cdn.jsdelivr.net/npm/tesseract.js@5/dist/tesseract.min.js"></script>

  10. <style>
  11.     * {
  12.         box-sizing: border-box;
  13.         margin: 0;
  14.         padding: 0;
  15.         font-family: "Segoe UI", Microsoft YaHei;
  16.     }
  17.     body {
  18.         background: #f4f7fa;
  19.         padding: 20px;
  20.     }
  21.     .container {
  22.         max-width: 1300px;
  23.         margin: 0 auto;
  24.         background: #fff;
  25.         border-radius: 18px;
  26.         box-shadow: 0 8px 24px rgba(0,0,0,0.07);
  27.         overflow: hidden;
  28.     }
  29.     /* 顶部配置栏 */
  30.     .header-bar {
  31.         display: flex;
  32.         flex-wrap: wrap;
  33.         gap: 12px;
  34.         padding: 18px 24px;
  35.         background: #f8fafc;
  36.         align-items: center;
  37.     }
  38.     .header-bar input,.header-bar select {
  39.         padding: 8px 12px;
  40.         border-radius: 8px;
  41.         border: 1px solid #e2e8f0;
  42.         outline: none;
  43.     }
  44.     #systemPrompt { flex:1; min-width:260px; }
  45.     /* 在线API配置面板,切换模式显示隐藏 */
  46.     .online-config {
  47.         width:100%;
  48.         display:none;
  49.         gap:10px;
  50.         margin-top:10px;
  51.         flex-wrap:wrap;
  52.     }
  53.     /* 聊天消息容器 */
  54.     #chatBox {
  55.         height: 64vh;
  56.         padding: 24px;
  57.         overflow-y: auto;
  58.         background: #ffffff;
  59.     }
  60.     #chatBox::-webkit-scrollbar { width:6px; }
  61.     #chatBox::-webkit-scrollbar-thumb { background:#cbd5e1; border-radius:3px; }
  62.     /* 消息气泡样式 */
  63.     .msg-wrap { margin:16px 0; display:flex; }
  64.     .user-msg { justify-content:flex-end; }
  65.     .ai-msg { justify-content:flex-start; }
  66.     .msg-card {
  67.         max-width:75%;
  68.         padding:14px 18px;
  69.         border-radius:14px;
  70.         line-height:1.7;
  71.         overflow-wrap:break-word;
  72.     }
  73.     .user-msg .msg-card {
  74.         background: #2563eb;
  75.         color: #fff;
  76.         border-top-right-radius: 4px;
  77.     }
  78.     .ai-msg .msg-card {
  79.         background: #f1f5f9;
  80.         color: #111;
  81.         border-top-left-radius: 4px;
  82.     }
  83.     /* 附件预览区域 */
  84.     .attach-list { display:flex; gap:10px; flex-wrap:wrap; margin:10px 0; }
  85.     .attach-item {
  86.         width:90px;height:90px;border-radius:8px;border:1px solid #eee;
  87.         position:relative;overflow:hidden;background:#fafafa;
  88.     }
  89.     .attach-item img { width:100%;height:70px;object-fit:cover; }
  90.     .attach-name { font-size:11px;padding:2px; }
  91.     .attach-del {
  92.         position:absolute;top:2px;right:2px;
  93.         width:18px;height:18px;background:#ef4444;color:#fff;
  94.         border-radius:50%;text-align:center;cursor:pointer;font-size:12px;
  95.     }
  96.     .ocr-tag {
  97.         position:absolute;bottom:0;left:0;right:0;
  98.         background:#00000066;color:#fff;font-size:10px;text-align:center;
  99.     }
  100.     /* 底部输入区域 */
  101.     .input-area { padding:20px;border-top:1px solid #eee; }
  102.     .file-bar { display:flex;gap:10px;align-items:center;margin-bottom:12px; }
  103.     .upload-btn {
  104.         padding:6px 14px;border:1px dashed #94a3b8;
  105.         border-radius:6px;cursor:pointer;font-size:14px;color:#475569;
  106.     }
  107.     #fileInput { display:none; }
  108.     .text-bar { display:flex;gap:12px;align-items:flex-end; }
  109.     #inputText {
  110.         flex:1;height:95px;padding:12px;border:1px solid #ddd;
  111.         border-radius:10px;resize:none;outline:none;font-size:14px;
  112.     }
  113.     .btn-group { display:flex;flex-direction:column;gap:8px; }
  114.     button {
  115.         border:none;border-radius:8px;padding:9px 16px;
  116.         cursor:pointer;color:#fff;
  117.     }
  118.     #sendBtn { background:#2563eb; }
  119.     #stopBtn { background:#f59e0b;display:none; }
  120.     #clearBtn { background:#94a3b8; }
  121.     .loading { color:#666;font-size:13px;margin:6px 0;display:none; }
  122.     /* Markdown代码块样式 */
  123.     .msg-card pre { border-radius:8px;margin:8px 0; }
  124.     .msg-card p { margin:6px 0; }
  125. </style>

  126. <div class="container">
  127.     <!-- 顶部配置栏 -->
  128.     <div class="header-bar">
  129.         <label>运行模式:</label>
  130.         <select id="runMode">
  131.             <option value="local">本地 Ollama</option>
  132.             <option value="online">在线API(OpenAI兼容)</option>
  133.         </select>

  134.         <label>系统人设:</label>
  135.         <input id="systemPrompt" value="你是全能AI助手,擅长代码解读、文件分析、图片内容理解">

  136.         <label>模型选择:</label>
  137.         <select id="modelSelect">
  138.             @foreach (var m in models)
  139.             {
  140.                 <option value="@m">@m</option>
  141.             }
  142.         </select>

  143.         <!-- 在线API配置面板,切换online模式显示 -->
  144.         <div class="online-config" id="onlinePanel">
  145.             <input id="apiKey" placeholder="API Key" style="width:220px">
  146.             <input id="apiUrl" placeholder="API地址" value="https://api.openai.com/v1/chat/completions" style="width:360px">
  147.             <input id="onlineModel" placeholder="在线模型名(gpt-3.5-turbo)" value="gpt-3.5-turbo">
  148.         </div>
  149.     </div>

  150.     <!-- 聊天消息展示区 -->
  151.     <div id="chatBox"></div>

  152.     <!-- 底部输入+附件区域 -->
  153.     <div class="input-area">
  154.         <div class="file-bar">
  155.             <label class="upload-btn" for="fileInput">📎 上传文件/图片</label>
  156.             <input type="file" id="fileInput" multiple accept="*">
  157.             <div class="attach-list" id="attachList"></div>
  158.         </div>
  159.         <!-- 加载提示文字 -->
  160.         <div class="loading" id="loading">AI思考中...</div>
  161.         <div class="text-bar">
  162.             <textarea id="inputText" placeholder="Enter发送,Shift+Enter换行"></textarea>
  163.             <div class="btn-group">
  164.                 <button id="sendBtn">发送</button>
  165.                 <button id="stopBtn">停止生成</button>
  166.                 <button id="clearBtn">清空对话</button>
  167.             </div>
  168.         </div>
  169.     </div>
  170. </div>

  171. <script>
  172. // ====================== 全局变量定义 ======================
  173. /** 对话历史,持久化到localStorage */
  174. let history = [];
  175. /** 当前待发送附件数组 */
  176. let attachFiles = [];
  177. /** 请求中断控制器,用于停止生成按钮 */
  178. let abortController = null;
  179. /** 第三方库加载完成标记,修复marked未定义报错 */
  180. let libReady = false;

  181. // DOM元素缓存,减少重复查询
  182. const chatBox = document.getElementById("chatBox");
  183. const inputText = document.getElementById("inputText");
  184. const sendBtn = document.getElementById("sendBtn");
  185. const stopBtn = document.getElementById("stopBtn");
  186. const clearBtn = document.getElementById("clearBtn");
  187. const fileInput = document.getElementById("fileInput");
  188. const attachList = document.getElementById("attachList");
  189. const loading = document.getElementById("loading");
  190. const runMode = document.getElementById("runMode");
  191. const modelSelect = document.getElementById("modelSelect");
  192. const onlinePanel = document.getElementById("onlinePanel");

  193. // ====================== 页面初始化绑定事件 ======================
  194. window.onload = ()=>{
  195.     // 读取本地缓存对话记录
  196.     const cache = localStorage.getItem("chatHistory");
  197.     if(cache) history = JSON.parse(cache);
  198.     // 渲染历史对话
  199.     renderHistoryFromCache();
  200.     // 初始化模式面板显示状态
  201.     modeChange();
  202.     // 标记第三方JS加载完成
  203.     libReady = true;
  204. }

  205. // 绑定各类点击/输入事件
  206. runMode.onchange = modeChange;
  207. sendBtn.onclick = sendMsg;
  208. stopBtn.onclick = stopGenerate;
  209. clearBtn.onclick = clearAll;
  210. fileInput.onchange = handleFile;
  211. inputText.onkeydown = inputKeyEvent;

  212. // ====================== 工具函数:模式切换 ======================
  213. /** 切换本地/在线模型,控制面板显隐 */
  214. function modeChange(){
  215.     if(runMode.value === "online"){
  216.         onlinePanel.style.display = "flex";
  217.         modelSelect.style.display = "none";
  218.     }else{
  219.         onlinePanel.style.display = "none";
  220.         modelSelect.style.display = "inline-block";
  221.     }
  222. }

  223. // ====================== 工具函数:停止生成 ======================
  224. /** 中断当前流式请求,终止AI输出 */
  225. function stopGenerate(){
  226.     if(abortController) abortController.abort();
  227.     abortController = null;
  228.     stopBtn.style.display = "none";
  229.     loading.style.display = "none";
  230.     sendBtn.style.display = "flex";
  231. }

  232. // ====================== 工具函数:文件上传、OCR、预览 ======================
  233. /** 文件选择回调,读取base64并对图片执行OCR识别 */
  234. async function handleFile(e){
  235.     const files = Array.from(e.target.files);
  236.     for(let file of files){
  237.         // 读取文件为base64字符串
  238.         const base64 = await readFileBase64(file);
  239.         let ocrText = "";
  240.         // 图片执行中英文OCR识别
  241.         if(file.type.startsWith("image/")){
  242.             loading.innerText = "图片OCR识别中...";
  243.             loading.style.display = "block";
  244.             ocrText = await doOCR(file);
  245.         }
  246.         attachFiles.push({name:file.name,type:file.type,data:base64,ocrText});
  247.     }
  248.     // 刷新附件预览列表
  249.     renderAttach();
  250.     loading.style.display = "none";
  251.     // 清空input,允许重复选择同名文件
  252.     fileInput.value = "";
  253. }

  254. /** FileReader读取文件为Base64 */
  255. function readFileBase64(file){
  256.     return new Promise(res=>{
  257.         const reader = new FileReader();
  258.         reader.readAsDataURL(file);
  259.         reader.onload = e=>res(e.target.result);
  260.     })
  261. }

  262. /** Tesseract图片OCR识别(中英文) */
  263. function doOCR(file){
  264.     return new Promise(async res=>{
  265.         const result = await Tesseract.recognize(file,"chi_sim+eng");
  266.         res(result.data.text);
  267.     })
  268. }

  269. /** 渲染附件预览卡片,支持删除单个附件 */
  270. function renderAttach(){
  271.     attachList.innerHTML = "";
  272.     attachFiles.forEach((item,i)=>{
  273.         const div = document.createElement("div");
  274.         div.className = "attach-item";
  275.         if(item.type.startsWith("image/")){
  276.             div.innerHTML = `
  277.                 <img src="${item.data}">
  278.                 <div class="attach-name">${item.name}</div>
  279.                 ${item.ocrText?'<div class="ocr-tag">已OCR</div>':''}
  280.                 <span class="attach-del" data-i="${i}">×</span>
  281.             `;
  282.         }else{
  283.             div.innerHTML = `
  284.                 <div style="height:70px;display:flex;align-items:center;justify-content:center;font-size:22px;">📄</div>
  285.                 <div class="attach-name">${item.name}</div>
  286.                 <span class="attach-del" data-i="${i}">×</span>
  287.             `;
  288.         }
  289.         attachList.appendChild(div);
  290.     })
  291.     // 绑定删除附件点击事件
  292.     document.querySelectorAll(".attach-del").forEach(el=>{
  293.         el.onclick=()=>{
  294.             const idx = Number(el.dataset.i);
  295.             attachFiles.splice(idx,1);
  296.             renderAttach();
  297.         }
  298.     })
  299. }

  300. // ====================== 工具函数:输入框键盘事件 ======================
  301. /** Enter发送,Shift+Enter换行 */
  302. function inputKeyEvent(e){
  303.     if(e.key === "Enter" && !e.shiftKey) {
  304.         e.preventDefault();
  305.         sendMsg();
  306.     }
  307. }

  308. // ====================== 核心发送消息逻辑 ======================
  309. async function sendMsg(){
  310.     const text = inputText.value.trim();
  311.     // 无文字无附件直接返回
  312.     if(!text && attachFiles.length===0) return;
  313.     // 当前正在生成,禁止重复发送
  314.     if(abortController) return;

  315.     // 渲染用户消息气泡
  316.     let userShow = text;
  317.     if(attachFiles.length>0) userShow += "\n[上传 "+attachFiles.length+" 个附件]";
  318.     appendMsg(userShow,"user");
  319.     inputText.value = "";

  320.     // UI切换状态
  321.     loading.style.display = "block";
  322.     sendBtn.style.display = "none";
  323.     stopBtn.style.display = "flex";

  324.     // 创建AI空气泡用于流式输出
  325.     const aiDom = appendMsg("","ai",true);
  326.     let aiFull = "";
  327.     // 创建中断控制器
  328.     abortController = new AbortController();
  329.     const signal = abortController.signal;

  330.     // 组装请求体,和后端ChatRequest一一对应
  331.     const req = {
  332.         RunMode: runMode.value,
  333.         Model: modelSelect.value,
  334.         ApiKey: document.getElementById("apiKey").value,
  335.         OnlineApiUrl: document.getElementById("apiUrl").value,
  336.         OnlineModelName: document.getElementById("onlineModel").value,
  337.         SystemPrompt: document.getElementById("systemPrompt").value,
  338.         UserContent: text,
  339.         History: history,
  340.         Attachments: attachFiles
  341.     };

  342.     try{
  343.         // 请求后端SSE流式接口
  344.         const res = await fetch("/Chat/Stream",{
  345.             method:"POST",
  346.             headers:{"Content-Type":"application/json"},
  347.             body:JSON.stringify(req),
  348.             signal
  349.         });
  350.         const reader = res.body.getReader();
  351.         const decoder = new TextDecoder();
  352.         let buf = "";

  353.         // 循环读取分片数据
  354.         while(true){
  355.             const {done,value} = await reader.read();
  356.             if(done) break;
  357.             buf += decoder.decode(value,{stream:true});
  358.             const lines = buf.split("\n\n");
  359.             buf = lines.pop();

  360.             for(let line of lines){
  361.                 if(!line.startsWith("data:")) continue;
  362.                 const d = line.replace("data:","").trim();
  363.                 // 对话结束标识
  364.                 if(d === "[END]"){
  365.                     history.push({role:"assistant",content:aiFull});
  366.                     attachFiles = [];
  367.                     renderAttach();
  368.                     break;
  369.                 }
  370.                 const chunk = JSON.parse(d);
  371.                 aiFull += chunk;

  372.                 // ========== 核心修复:判断marked是否加载完成,避免未定义报错 ==========
  373.                 if (typeof marked !== "undefined" && libReady) {
  374.                     aiDom.innerHTML = marked.parse(aiFull);
  375.                     if (typeof Prism !== "undefined") Prism.highlightAll();
  376.                 } else {
  377.                     aiDom.innerText = aiFull;
  378.                 }
  379.                 scrollBottom();
  380.             }
  381.         }
  382.         // 保存用户对话到历史
  383.         history.push({role:"user",content:text});
  384.         // 写入本地存储,刷新页面不丢失对话
  385.         localStorage.setItem("chatHistory",JSON.stringify(history));
  386.     }catch(err){
  387.         // 区分手动停止和异常报错
  388.         if(err.name !== "AbortError") aiDom.innerText = "请求异常:"+err.message;
  389.     }finally{
  390.         // 重置状态
  391.         abortController = null;
  392.         loading.style.display = "none";
  393.         sendBtn.style.display = "flex";
  394.         stopBtn.style.display = "none";
  395.     }
  396. }

  397. // ====================== 消息DOM渲染工具 ======================
  398. /**
  399. * 创建聊天气泡
  400. * @param text 消息文本
  401. * @param role user/ai
  402. * @param isTemp 是否临时空气泡(AI流式输出专用)
  403. */
  404. function appendMsg(text,role,isTemp=false){
  405.     const wrap = document.createElement("div");
  406.     wrap.className = `msg-wrap ${role}-msg`;
  407.     const card = document.createElement("div");
  408.     card.className = "msg-card";
  409.     if(!isTemp) card.innerText = text;
  410.     wrap.appendChild(card);
  411.     chatBox.appendChild(wrap);
  412.     scrollBottom();
  413.     return isTemp ? card : null;
  414. }

  415. /** 页面自动滚动到底部 */
  416. function scrollBottom(){
  417.     chatBox.scrollTop = chatBox.scrollHeight;
  418. }

  419. /** 页面加载时读取localStorage历史并渲染 */
  420. function renderHistoryFromCache(){
  421.     history.forEach(item=>{
  422.         appendMsg(item.content, item.role==="user"?"user":"ai");
  423.     })
  424. }

  425. // ====================== 清空全部对话 ======================
  426. function clearAll(){
  427.     chatBox.innerHTML = "";
  428.     history = [];
  429.     attachFiles = [];
  430.     localStorage.removeItem("chatHistory");
  431.     renderAttach();
  432.     stopGenerate();
  433. }
  434. </script>
复制代码
5. Program.cs 注册服务
  1. var builder = WebApplication.CreateBuilder(args);

  2. // 启用MVC控制器视图
  3. builder.Services.AddControllersWithViews();
  4. // 注入Ollama专用HttpClient
  5. builder.Services.AddHttpClient<OllamaWebChat.Services.OllamaService>();

  6. var app = builder.Build();

  7. // 开发环境异常页面
  8. if (!app.Environment.IsDevelopment())
  9. {
  10.     app.UseExceptionHandler("/Home/Error");
  11. }
  12. app.UseStaticFiles();
  13. app.UseRouting();
  14. app.UseAuthorization();

  15. // 默认路由:Chat/Index
  16. app.MapControllerRoute(
  17.     name: "default",
  18.     pattern: "{controller=Chat}/{action=Index}/{id?}");

  19. app.Run();
复制代码
运行前置操作(必做)


  • 清理解决方案 → 重新生成,消除类重复编译报错
  • 本地 Ollama 模式:打开 Ollama 客户端,执行ollama pull qwen:7b下载模型
  • 在线 API 模式:填入对应平台 API Key、接口地址、模型名称即可使用
  • 内网无网络环境:将 marked/prism/tesseract CDN 改为本地 wwwroot 静态文件引用



















本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

×

14

主题

8

回帖

118

积分

管理员

积分
118
 楼主| 发表于 2026-6-25 12:39:25 | 显示全部楼层
ASP.NET Core 项目启动时允许局域网:
  1. dotnet run --urls=http://0.0.0.0:5000
复制代码
同一局域网其他设备通过 http://本机IP:5000/Chat 访问网页聊天界面
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

Archiver|手机版|小黑屋|兔窝窝

GMT+8, 2026-7-26 16:28 , Processed in 0.102696 second(s), 27 queries .

Powered by www.tu55.cn

Copyright © www.tu55.cn, All Rights Reserved.

快速回复 返回顶部 返回列表