|
|
目录结构说明
- OllamaWebChat
- ├─ Models
- │ └─ ChatRequest.cs 【唯一实体文件,无重复类】
- ├─ Services
- │ └─ OllamaService.cs
- ├─ Controllers
- │ └─ ChatController.cs
- ├─ Views/Chat
- │ └─ Index.cshtml 【前端完整修复版,加全量注释】
- └─ Program.cs
复制代码 1. Models/ChatRequest.cs
- using System.Collections.Generic;
- namespace OllamaWebChat.Models
- {
- /// <summary>
- /// 统一请求实体,兼容2种模式:本地Ollama / OpenAI标准在线API
- /// 全局仅一份此类,删除项目内所有旧ChatRequest文件避免冲突
- /// </summary>
- public class ChatRequest
- {
- /// <summary>运行模式:local=本地Ollama online=第三方在线大模型</summary>
- public string RunMode { get; set; } = "local";
- #region 本地Ollama专用参数
- /// <summary>本地Ollama模型名称,如qwen:7b、llama3:8b</summary>
- public string Model { get; set; }
- #endregion
- #region 在线OpenAI兼容接口参数
- /// <summary>API密钥</summary>
- public string ApiKey { get; set; }
- /// <summary>API完整接口地址</summary>
- public string OnlineApiUrl { get; set; }
- /// <summary>在线平台模型名称</summary>
- public string OnlineModelName { get; set; }
- #endregion
- /// <summary>系统角色提示词,定义AI身份</summary>
- public string SystemPrompt { get; set; }
- /// <summary>用户本次输入文本内容</summary>
- public string UserContent { get; set; }
- /// <summary>完整对话上下文历史(多轮记忆)</summary>
- public List<OllamaMessage> History { get; set; } = new();
- /// <summary>本次上传附件列表(图片/代码/文档)</summary>
- public List<ChatAttachment> Attachments { get; set; } = new();
- }
- /// <summary>上传附件实体,存储文件base64、OCR识别文字</summary>
- public class ChatAttachment
- {
- /// <summary>原始文件名</summary>
- public string name { get; set; }
- /// <summary>MIME文件类型 image/png text/cs等</summary>
- public string type { get; set; }
- /// <summary>base64文件数据</summary>
- public string data { get; set; }
- /// <summary>图片OCR识别出的文字内容</summary>
- public string ocrText { get; set; }
- }
- /// <summary>通用消息结构,兼容Ollama/OpenAI message格式</summary>
- public class OllamaMessage
- {
- /// <summary>角色 system/user/assistant</summary>
- public string role { get; set; }
- /// <summary>对话文本内容</summary>
- public string content { get; set; }
- }
- #region Ollama 本地模型流式返回JSON映射
- public class OllamaStreamChunk
- {
- public OllamaMessage message { get; set; }
- public bool done { get; set; }
- }
- #endregion
- #region OpenAI 在线API流式返回JSON映射
- public class OpenAiStreamChunk
- {
- public List<OpenAiChoice> choices { get; set; }
- }
- public class OpenAiChoice
- {
- public OpenAiDelta delta { get; set; }
- }
- public class OpenAiDelta
- {
- public string content { get; set; }
- }
- #endregion
- #region Ollama /api/tags 获取本地模型列表实体
- public class ModelListResp
- {
- public List<ModelItem> models { get; set; }
- }
- public class ModelItem
- {
- public string name { get; set; }
- }
- #endregion
- }
复制代码 2. Services/OllamaService.cs(支持取消令牌、双模型分流)
- using Microsoft.AspNetCore.Http;
- using OllamaWebChat.Models;
- using System;
- using System.Collections.Generic;
- using System.Linq;
- using System.Net.Http;
- using System.Text;
- using System.Text.Json;
- using System.Threading;
- using System.Threading.Tasks;
- namespace OllamaWebChat.Services
- {
- public class OllamaService
- {
- private readonly HttpClient _httpClient;
- // 本地Ollama默认接口地址
- private const string OllamaChatApi = "http://localhost:11434/api/chat";
- public OllamaService(HttpClient httpClient)
- {
- _httpClient = httpClient;
- // 超时10分钟,长文本生成预留充足时间
- _httpClient.Timeout = TimeSpan.FromMinutes(10);
- }
- /// <summary>
- /// 统一流式SSE输出接口
- /// 支持本地Ollama / 在线OpenAI接口,支持客户端主动取消请求
- /// </summary>
- /// <param name="response">输出流对象</param>
- /// <param name="req">前端统一请求参数</param>
- /// <param name="ct">取消令牌,用于停止生成</param>
- public async Task StreamChatAsync(HttpResponse response, ChatRequest req, CancellationToken ct)
- {
- // SSE固定响应头,浏览器识别流式输出
- response.ContentType = "text/event-stream";
- response.Headers.CacheControl = "no-cache";
- response.Headers.Connection = "keep-alive";
- // 组装消息队列
- var messages = new List<OllamaMessage>();
- if (!string.IsNullOrWhiteSpace(req.SystemPrompt))
- messages.Add(new OllamaMessage { role = "system", content = req.SystemPrompt });
- messages.AddRange(req.History);
- // 拼接所有附件解析内容到用户提问
- string userInput = BuildUserInputWithAttachments(req);
- messages.Add(new OllamaMessage { role = "user", content = userInput });
- // 根据运行模式分流处理
- if (req.RunMode == "local")
- await StreamLocalOllama(response, messages, req.Model, ct);
- else
- await StreamOnlineOpenAi(response, messages, req, ct);
- // 流式结束标记,前端识别对话完成
- byte[] endBuffer = Encoding.UTF8.GetBytes("data:[END]\n\n");
- await response.Body.WriteAsync(endBuffer, ct);
- await response.Body.FlushAsync(ct);
- }
- /// <summary>拼接用户文本+所有附件解析内容</summary>
- private string BuildUserInputWithAttachments(ChatRequest req)
- {
- string userInput = req.UserContent;
- if (!req.Attachments.Any()) return userInput;
- userInput += "\n\n【用户上传附件内容解析】\n";
- foreach (var file in req.Attachments)
- {
- userInput += $"\n——————————\n文件名:{file.name}\n";
- if (file.type.StartsWith("image/"))
- {
- // 图片优先拼接OCR识别文字
- userInput += string.IsNullOrEmpty(file.ocrText)
- ? "图片文件,请根据画面内容分析\n"
- : $"图片OCR识别内容:\n{file.ocrText}\n";
- }
- else
- {
- // 文本/代码文件解码base64,超长截断避免接口报错
- try
- {
- string base64Raw = file.data.Split(",").Last();
- byte[] fileBytes = Convert.FromBase64String(base64Raw);
- string fileText = Encoding.UTF8.GetString(fileBytes);
- if (fileText.Length > 6000)
- fileText = fileText[..6000] + "\n...(内容过长已截断)";
- userInput += $"文件完整内容:\n{fileText}\n";
- }
- catch
- {
- userInput += "二进制文件,无法解析文本\n";
- }
- }
- }
- return userInput;
- }
- /// <summary>本地Ollama流式请求处理</summary>
- private async Task StreamLocalOllama(HttpResponse response, List<OllamaMessage> messages, string model, CancellationToken ct)
- {
- var bodyObj = new
- {
- model = model,
- messages = messages,
- stream = true,
- temperature = 0.7
- };
- string json = JsonSerializer.Serialize(bodyObj);
- var content = new StringContent(json, Encoding.UTF8, "application/json");
- using var msg = new HttpRequestMessage(HttpMethod.Post, OllamaChatApi);
- msg.Content = content;
- using var resp = await _httpClient.SendAsync(msg, HttpCompletionOption.ResponseHeadersRead, ct);
- resp.EnsureSuccessStatusCode();
- using var stream = await resp.Content.ReadAsStreamAsync(ct);
- using var reader = new StreamReader(stream);
- string line;
- while ((line = await reader.ReadLineAsync(ct)) != null)
- {
- ct.ThrowIfCancellationRequested(); // 客户端点击停止,立刻抛出异常中断
- if (string.IsNullOrWhiteSpace(line)) continue;
- var chunk = JsonSerializer.Deserialize<OllamaStreamChunk>(line);
- if (chunk?.message != null && !string.IsNullOrEmpty(chunk.message.content))
- {
- byte[] buffer = Encoding.UTF8.GetBytes($"data:{JsonSerializer.Serialize(chunk.message.content)}\n\n");
- await response.Body.WriteAsync(buffer, ct);
- await response.Body.FlushAsync(ct);
- }
- if (chunk?.done == true) break;
- }
- }
- /// <summary>在线OpenAI兼容接口流式处理(DeepSeek/智谱/通义千问兼容)</summary>
- private async Task StreamOnlineOpenAi(HttpResponse response, List<OllamaMessage> messages, ChatRequest req, CancellationToken ct)
- {
- _httpClient.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", req.ApiKey);
- var bodyObj = new
- {
- model = req.OnlineModelName,
- messages = messages,
- stream = true,
- temperature = 0.7
- };
- string json = JsonSerializer.Serialize(bodyObj);
- var content = new StringContent(json, Encoding.UTF8, "application/json");
- using var msg = new HttpRequestMessage(HttpMethod.Post, req.OnlineApiUrl);
- msg.Content = content;
- using var resp = await _httpClient.SendAsync(msg, HttpCompletionOption.ResponseHeadersRead, ct);
- resp.EnsureSuccessStatusCode();
- using var stream = await resp.Content.ReadAsStreamAsync(ct);
- using var reader = new StreamReader(stream);
- string line;
- while ((line = await reader.ReadLineAsync(ct)) != null)
- {
- ct.ThrowIfCancellationRequested();
- if (string.IsNullOrWhiteSpace(line) || !line.StartsWith("data:")) continue;
- string raw = line.Replace("data:", "").Trim();
- if (raw == "[DONE]") break;
- var chunk = JsonSerializer.Deserialize<OpenAiStreamChunk>(raw);
- string text = chunk?.choices?.FirstOrDefault()?.delta?.content;
- if (!string.IsNullOrEmpty(text))
- {
- byte[] buffer = Encoding.UTF8.GetBytes($"data:{JsonSerializer.Serialize(text)}\n\n");
- await response.Body.WriteAsync(buffer, ct);
- await response.Body.FlushAsync(ct);
- }
- }
- }
- /// <summary>获取本地Ollama已下载模型列表,异常返回提示文本</summary>
- public async Task<List<string>> GetLocalModels()
- {
- try
- {
- var res = await _httpClient.GetFromJsonAsync<ModelListResp>("http://localhost:11434/api/tags");
- return res?.models?.Select(x => x.name).ToList() ?? new List<string>();
- }
- catch
- {
- return new List<string> { "Ollama服务未启动" };
- }
- }
- }
- }
复制代码 3. Controllers/ChatController.cs
- using Microsoft.AspNetCore.Mvc;
- using OllamaWebChat.Models;
- using OllamaWebChat.Services;
- using System.Threading;
- namespace OllamaWebChat.Controllers
- {
- public class ChatController : Controller
- {
- private readonly OllamaService _ollamaService;
- // 构造注入Ollama服务
- public ChatController(OllamaService ollamaService)
- {
- _ollamaService = ollamaService;
- }
- /// <summary>聊天首页,加载本地模型列表传给前端页面</summary>
- public async Task<IActionResult> Index()
- {
- var localModels = await _ollamaService.GetLocalModels();
- ViewBag.Models = localModels;
- return View();
- }
- /// <summary>SSE流式聊天接口,接收前端请求并输出实时文本</summary>
- /// <param name="request">前端表单参数</param>
- /// <param name="ct">请求取消令牌,前端停止按钮触发中断</param>
- [HttpPost]
- public async Task Stream([FromBody] ChatRequest request, CancellationToken ct)
- {
- Response.Headers["X-Content-Type-Options"] = "nosniff";
- await _ollamaService.StreamChatAsync(Response, request, ct);
- }
- }
- }
复制代码 4. Views/Chat/Index.cshtml
- @{
- ViewData["Title"] = "AI对话平台|本地Ollama / 在线API双模式";
- var models = ViewBag.Models as List<string>;
- }
- <!-- 脚本置顶优先加载,解决marked未定义报错 -->
- <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
- <script src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/prism.min.js"></script>
- <link href="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism-tomorrow.min.css" rel="stylesheet" />
- <script src="https://cdn.jsdelivr.net/npm/tesseract.js@5/dist/tesseract.min.js"></script>
- <style>
- * {
- box-sizing: border-box;
- margin: 0;
- padding: 0;
- font-family: "Segoe UI", Microsoft YaHei;
- }
- body {
- background: #f4f7fa;
- padding: 20px;
- }
- .container {
- max-width: 1300px;
- margin: 0 auto;
- background: #fff;
- border-radius: 18px;
- box-shadow: 0 8px 24px rgba(0,0,0,0.07);
- overflow: hidden;
- }
- /* 顶部配置栏 */
- .header-bar {
- display: flex;
- flex-wrap: wrap;
- gap: 12px;
- padding: 18px 24px;
- background: #f8fafc;
- align-items: center;
- }
- .header-bar input,.header-bar select {
- padding: 8px 12px;
- border-radius: 8px;
- border: 1px solid #e2e8f0;
- outline: none;
- }
- #systemPrompt { flex:1; min-width:260px; }
- /* 在线API配置面板,切换模式显示隐藏 */
- .online-config {
- width:100%;
- display:none;
- gap:10px;
- margin-top:10px;
- flex-wrap:wrap;
- }
- /* 聊天消息容器 */
- #chatBox {
- height: 64vh;
- padding: 24px;
- overflow-y: auto;
- background: #ffffff;
- }
- #chatBox::-webkit-scrollbar { width:6px; }
- #chatBox::-webkit-scrollbar-thumb { background:#cbd5e1; border-radius:3px; }
- /* 消息气泡样式 */
- .msg-wrap { margin:16px 0; display:flex; }
- .user-msg { justify-content:flex-end; }
- .ai-msg { justify-content:flex-start; }
- .msg-card {
- max-width:75%;
- padding:14px 18px;
- border-radius:14px;
- line-height:1.7;
- overflow-wrap:break-word;
- }
- .user-msg .msg-card {
- background: #2563eb;
- color: #fff;
- border-top-right-radius: 4px;
- }
- .ai-msg .msg-card {
- background: #f1f5f9;
- color: #111;
- border-top-left-radius: 4px;
- }
- /* 附件预览区域 */
- .attach-list { display:flex; gap:10px; flex-wrap:wrap; margin:10px 0; }
- .attach-item {
- width:90px;height:90px;border-radius:8px;border:1px solid #eee;
- position:relative;overflow:hidden;background:#fafafa;
- }
- .attach-item img { width:100%;height:70px;object-fit:cover; }
- .attach-name { font-size:11px;padding:2px; }
- .attach-del {
- position:absolute;top:2px;right:2px;
- width:18px;height:18px;background:#ef4444;color:#fff;
- border-radius:50%;text-align:center;cursor:pointer;font-size:12px;
- }
- .ocr-tag {
- position:absolute;bottom:0;left:0;right:0;
- background:#00000066;color:#fff;font-size:10px;text-align:center;
- }
- /* 底部输入区域 */
- .input-area { padding:20px;border-top:1px solid #eee; }
- .file-bar { display:flex;gap:10px;align-items:center;margin-bottom:12px; }
- .upload-btn {
- padding:6px 14px;border:1px dashed #94a3b8;
- border-radius:6px;cursor:pointer;font-size:14px;color:#475569;
- }
- #fileInput { display:none; }
- .text-bar { display:flex;gap:12px;align-items:flex-end; }
- #inputText {
- flex:1;height:95px;padding:12px;border:1px solid #ddd;
- border-radius:10px;resize:none;outline:none;font-size:14px;
- }
- .btn-group { display:flex;flex-direction:column;gap:8px; }
- button {
- border:none;border-radius:8px;padding:9px 16px;
- cursor:pointer;color:#fff;
- }
- #sendBtn { background:#2563eb; }
- #stopBtn { background:#f59e0b;display:none; }
- #clearBtn { background:#94a3b8; }
- .loading { color:#666;font-size:13px;margin:6px 0;display:none; }
- /* Markdown代码块样式 */
- .msg-card pre { border-radius:8px;margin:8px 0; }
- .msg-card p { margin:6px 0; }
- </style>
- <div class="container">
- <!-- 顶部配置栏 -->
- <div class="header-bar">
- <label>运行模式:</label>
- <select id="runMode">
- <option value="local">本地 Ollama</option>
- <option value="online">在线API(OpenAI兼容)</option>
- </select>
- <label>系统人设:</label>
- <input id="systemPrompt" value="你是全能AI助手,擅长代码解读、文件分析、图片内容理解">
- <label>模型选择:</label>
- <select id="modelSelect">
- @foreach (var m in models)
- {
- <option value="@m">@m</option>
- }
- </select>
- <!-- 在线API配置面板,切换online模式显示 -->
- <div class="online-config" id="onlinePanel">
- <input id="apiKey" placeholder="API Key" style="width:220px">
- <input id="apiUrl" placeholder="API地址" value="https://api.openai.com/v1/chat/completions" style="width:360px">
- <input id="onlineModel" placeholder="在线模型名(gpt-3.5-turbo)" value="gpt-3.5-turbo">
- </div>
- </div>
- <!-- 聊天消息展示区 -->
- <div id="chatBox"></div>
- <!-- 底部输入+附件区域 -->
- <div class="input-area">
- <div class="file-bar">
- <label class="upload-btn" for="fileInput">📎 上传文件/图片</label>
- <input type="file" id="fileInput" multiple accept="*">
- <div class="attach-list" id="attachList"></div>
- </div>
- <!-- 加载提示文字 -->
- <div class="loading" id="loading">AI思考中...</div>
- <div class="text-bar">
- <textarea id="inputText" placeholder="Enter发送,Shift+Enter换行"></textarea>
- <div class="btn-group">
- <button id="sendBtn">发送</button>
- <button id="stopBtn">停止生成</button>
- <button id="clearBtn">清空对话</button>
- </div>
- </div>
- </div>
- </div>
- <script>
- // ====================== 全局变量定义 ======================
- /** 对话历史,持久化到localStorage */
- let history = [];
- /** 当前待发送附件数组 */
- let attachFiles = [];
- /** 请求中断控制器,用于停止生成按钮 */
- let abortController = null;
- /** 第三方库加载完成标记,修复marked未定义报错 */
- let libReady = false;
- // DOM元素缓存,减少重复查询
- const chatBox = document.getElementById("chatBox");
- const inputText = document.getElementById("inputText");
- const sendBtn = document.getElementById("sendBtn");
- const stopBtn = document.getElementById("stopBtn");
- const clearBtn = document.getElementById("clearBtn");
- const fileInput = document.getElementById("fileInput");
- const attachList = document.getElementById("attachList");
- const loading = document.getElementById("loading");
- const runMode = document.getElementById("runMode");
- const modelSelect = document.getElementById("modelSelect");
- const onlinePanel = document.getElementById("onlinePanel");
- // ====================== 页面初始化绑定事件 ======================
- window.onload = ()=>{
- // 读取本地缓存对话记录
- const cache = localStorage.getItem("chatHistory");
- if(cache) history = JSON.parse(cache);
- // 渲染历史对话
- renderHistoryFromCache();
- // 初始化模式面板显示状态
- modeChange();
- // 标记第三方JS加载完成
- libReady = true;
- }
- // 绑定各类点击/输入事件
- runMode.onchange = modeChange;
- sendBtn.onclick = sendMsg;
- stopBtn.onclick = stopGenerate;
- clearBtn.onclick = clearAll;
- fileInput.onchange = handleFile;
- inputText.onkeydown = inputKeyEvent;
- // ====================== 工具函数:模式切换 ======================
- /** 切换本地/在线模型,控制面板显隐 */
- function modeChange(){
- if(runMode.value === "online"){
- onlinePanel.style.display = "flex";
- modelSelect.style.display = "none";
- }else{
- onlinePanel.style.display = "none";
- modelSelect.style.display = "inline-block";
- }
- }
- // ====================== 工具函数:停止生成 ======================
- /** 中断当前流式请求,终止AI输出 */
- function stopGenerate(){
- if(abortController) abortController.abort();
- abortController = null;
- stopBtn.style.display = "none";
- loading.style.display = "none";
- sendBtn.style.display = "flex";
- }
- // ====================== 工具函数:文件上传、OCR、预览 ======================
- /** 文件选择回调,读取base64并对图片执行OCR识别 */
- async function handleFile(e){
- const files = Array.from(e.target.files);
- for(let file of files){
- // 读取文件为base64字符串
- const base64 = await readFileBase64(file);
- let ocrText = "";
- // 图片执行中英文OCR识别
- if(file.type.startsWith("image/")){
- loading.innerText = "图片OCR识别中...";
- loading.style.display = "block";
- ocrText = await doOCR(file);
- }
- attachFiles.push({name:file.name,type:file.type,data:base64,ocrText});
- }
- // 刷新附件预览列表
- renderAttach();
- loading.style.display = "none";
- // 清空input,允许重复选择同名文件
- fileInput.value = "";
- }
- /** FileReader读取文件为Base64 */
- function readFileBase64(file){
- return new Promise(res=>{
- const reader = new FileReader();
- reader.readAsDataURL(file);
- reader.onload = e=>res(e.target.result);
- })
- }
- /** Tesseract图片OCR识别(中英文) */
- function doOCR(file){
- return new Promise(async res=>{
- const result = await Tesseract.recognize(file,"chi_sim+eng");
- res(result.data.text);
- })
- }
- /** 渲染附件预览卡片,支持删除单个附件 */
- function renderAttach(){
- attachList.innerHTML = "";
- attachFiles.forEach((item,i)=>{
- const div = document.createElement("div");
- div.className = "attach-item";
- if(item.type.startsWith("image/")){
- div.innerHTML = `
- <img src="${item.data}">
- <div class="attach-name">${item.name}</div>
- ${item.ocrText?'<div class="ocr-tag">已OCR</div>':''}
- <span class="attach-del" data-i="${i}">×</span>
- `;
- }else{
- div.innerHTML = `
- <div style="height:70px;display:flex;align-items:center;justify-content:center;font-size:22px;">📄</div>
- <div class="attach-name">${item.name}</div>
- <span class="attach-del" data-i="${i}">×</span>
- `;
- }
- attachList.appendChild(div);
- })
- // 绑定删除附件点击事件
- document.querySelectorAll(".attach-del").forEach(el=>{
- el.onclick=()=>{
- const idx = Number(el.dataset.i);
- attachFiles.splice(idx,1);
- renderAttach();
- }
- })
- }
- // ====================== 工具函数:输入框键盘事件 ======================
- /** Enter发送,Shift+Enter换行 */
- function inputKeyEvent(e){
- if(e.key === "Enter" && !e.shiftKey) {
- e.preventDefault();
- sendMsg();
- }
- }
- // ====================== 核心发送消息逻辑 ======================
- async function sendMsg(){
- const text = inputText.value.trim();
- // 无文字无附件直接返回
- if(!text && attachFiles.length===0) return;
- // 当前正在生成,禁止重复发送
- if(abortController) return;
- // 渲染用户消息气泡
- let userShow = text;
- if(attachFiles.length>0) userShow += "\n[上传 "+attachFiles.length+" 个附件]";
- appendMsg(userShow,"user");
- inputText.value = "";
- // UI切换状态
- loading.style.display = "block";
- sendBtn.style.display = "none";
- stopBtn.style.display = "flex";
- // 创建AI空气泡用于流式输出
- const aiDom = appendMsg("","ai",true);
- let aiFull = "";
- // 创建中断控制器
- abortController = new AbortController();
- const signal = abortController.signal;
- // 组装请求体,和后端ChatRequest一一对应
- const req = {
- RunMode: runMode.value,
- Model: modelSelect.value,
- ApiKey: document.getElementById("apiKey").value,
- OnlineApiUrl: document.getElementById("apiUrl").value,
- OnlineModelName: document.getElementById("onlineModel").value,
- SystemPrompt: document.getElementById("systemPrompt").value,
- UserContent: text,
- History: history,
- Attachments: attachFiles
- };
- try{
- // 请求后端SSE流式接口
- const res = await fetch("/Chat/Stream",{
- method:"POST",
- headers:{"Content-Type":"application/json"},
- body:JSON.stringify(req),
- signal
- });
- const reader = res.body.getReader();
- const decoder = new TextDecoder();
- let buf = "";
- // 循环读取分片数据
- while(true){
- const {done,value} = await reader.read();
- if(done) break;
- buf += decoder.decode(value,{stream:true});
- const lines = buf.split("\n\n");
- buf = lines.pop();
- for(let line of lines){
- if(!line.startsWith("data:")) continue;
- const d = line.replace("data:","").trim();
- // 对话结束标识
- if(d === "[END]"){
- history.push({role:"assistant",content:aiFull});
- attachFiles = [];
- renderAttach();
- break;
- }
- const chunk = JSON.parse(d);
- aiFull += chunk;
- // ========== 核心修复:判断marked是否加载完成,避免未定义报错 ==========
- if (typeof marked !== "undefined" && libReady) {
- aiDom.innerHTML = marked.parse(aiFull);
- if (typeof Prism !== "undefined") Prism.highlightAll();
- } else {
- aiDom.innerText = aiFull;
- }
- scrollBottom();
- }
- }
- // 保存用户对话到历史
- history.push({role:"user",content:text});
- // 写入本地存储,刷新页面不丢失对话
- localStorage.setItem("chatHistory",JSON.stringify(history));
- }catch(err){
- // 区分手动停止和异常报错
- if(err.name !== "AbortError") aiDom.innerText = "请求异常:"+err.message;
- }finally{
- // 重置状态
- abortController = null;
- loading.style.display = "none";
- sendBtn.style.display = "flex";
- stopBtn.style.display = "none";
- }
- }
- // ====================== 消息DOM渲染工具 ======================
- /**
- * 创建聊天气泡
- * @param text 消息文本
- * @param role user/ai
- * @param isTemp 是否临时空气泡(AI流式输出专用)
- */
- function appendMsg(text,role,isTemp=false){
- const wrap = document.createElement("div");
- wrap.className = `msg-wrap ${role}-msg`;
- const card = document.createElement("div");
- card.className = "msg-card";
- if(!isTemp) card.innerText = text;
- wrap.appendChild(card);
- chatBox.appendChild(wrap);
- scrollBottom();
- return isTemp ? card : null;
- }
- /** 页面自动滚动到底部 */
- function scrollBottom(){
- chatBox.scrollTop = chatBox.scrollHeight;
- }
- /** 页面加载时读取localStorage历史并渲染 */
- function renderHistoryFromCache(){
- history.forEach(item=>{
- appendMsg(item.content, item.role==="user"?"user":"ai");
- })
- }
- // ====================== 清空全部对话 ======================
- function clearAll(){
- chatBox.innerHTML = "";
- history = [];
- attachFiles = [];
- localStorage.removeItem("chatHistory");
- renderAttach();
- stopGenerate();
- }
- </script>
复制代码 5. Program.cs 注册服务
- var builder = WebApplication.CreateBuilder(args);
- // 启用MVC控制器视图
- builder.Services.AddControllersWithViews();
- // 注入Ollama专用HttpClient
- builder.Services.AddHttpClient<OllamaWebChat.Services.OllamaService>();
- var app = builder.Build();
- // 开发环境异常页面
- if (!app.Environment.IsDevelopment())
- {
- app.UseExceptionHandler("/Home/Error");
- }
- app.UseStaticFiles();
- app.UseRouting();
- app.UseAuthorization();
- // 默认路由:Chat/Index
- app.MapControllerRoute(
- name: "default",
- pattern: "{controller=Chat}/{action=Index}/{id?}");
- app.Run();
复制代码 运行前置操作(必做)
- 清理解决方案 → 重新生成,消除类重复编译报错
- 本地 Ollama 模式:打开 Ollama 客户端,执行ollama pull qwen:7b下载模型
- 在线 API 模式:填入对应平台 API Key、接口地址、模型名称即可使用
- 内网无网络环境:将 marked/prism/tesseract CDN 改为本地 wwwroot 静态文件引用
|
本帖子中包含更多资源
您需要 登录 才可以下载或查看,没有账号?立即注册
×
|