这篇文档主要是第一次尝试用Spring AI写一个简单的问答页面,主要是尝试模型的接入、问答的回复的方式,堵塞和非堵塞,乱码的处理方式,通过拦截器记录日志等。文章主要是通过代码展示的,文字比较少。
第一步
deepseek官方配置秘钥,可以先充值一块钱或者使用其它平台的免费额度。https://platform.deepseek.com/usage
代码结构
https://start.spring.io/ 可以使用这个简单的搭建项目,jdk最低是17

代码实现
java
package com.example.springaidemo;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;
@RequiredArgsConstructor
@RestController
@RequestMapping("/ai")
@Slf4j
public class ChatController {
private final ChatClient chatClient;
/**
* 聊天接口
* call()是堵塞的,stream()是流式的,页面展示的是乱码,需要设置编码
*
* @param prompt
* @return
*/
@RequestMapping(value = "/chat",produces = "text/html;charset=UTF-8")
public Flux<String> chat(String prompt) {
log.debug("---prompt:{}", prompt);
return chatClient.prompt()
.user(prompt)
//.call()
.stream()
.content();
}
@RequestMapping("/test")
public String test() {
return "Hello, 应用运行正常!";
}
}
java
package com.example.springaidemo.config;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor;
import org.springframework.ai.openai.OpenAiChatModel;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* 记录日志使用defaultAdvisors,默认的Advisor是SimpleLoggerAdvisor,配置文件配置日志级别
*/
@Configuration
public class CommonConfiguration {
@Bean
public ChatClient openaiChatClient(OpenAiChatModel chatModel) {
return ChatClient.builder(chatModel)
.defaultSystem("你是一位专业的Java技术专家,名字叫小豆子")
.defaultAdvisors(new SimpleLoggerAdvisor())
.build();
}
}
java
package com.example.springaidemo.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* 处理其他访问方式跨域问题
*/
@Configuration
public class MvcConfiguration implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("*")
.allowedMethods("GET","POST","PUT","DELETE","OPTION")
.allowedHeaders("*");
}
}
java
spring.application.name=spring-ai-demo
server.port=8080
# OpenAI API 密钥
spring.ai.openai.api-key=替换成deepseek秘钥
# DeepSeek API 基础 URL
spring.ai.openai.base-url=https://api.deepseek.com
#模型名称
spring.ai.openai.chat.options.model=deepseek-v4-flash
# 模型温度,值越大,输出结果越随机
spring.ai.openai.chat.options.temperature=0.8
#日志级别
logging.level.org.springframework.ai.chat.client.advisor=debug
logging.level.com.example.springaidemo=debug
java
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta http-equiv="refresh" content="0;url=/chat.html">
<title>DeepSeek - AI 智能助手</title>
</head>
<body>
<script>window.location.href = '/chat.html';</script>
</body>
</html>
java
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>傻蛋 - AI 智能助手</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--sidebar-bg: #1e1e1e;
--sidebar-hover: #2a2a2a;
--main-bg: #212121;
--msg-user-bg: #2f2f2f;
--msg-ai-bg: transparent;
--text-primary: #e5e5e5;
--text-secondary: #999;
--text-muted: #666;
--border-color: #333;
--accent: #4f7cff;
--accent-hover: #3b66e0;
--input-bg: #2f2f2f;
--danger: #ef4444;
--green-dot: #4ade80;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
background: var(--main-bg);
color: var(--text-primary);
height: 100vh;
overflow: hidden;
display: flex;
}
/* ========== Sidebar ========== */
.sidebar {
width: 260px;
min-width: 260px;
background: var(--sidebar-bg);
display: flex;
flex-direction: column;
border-right: 1px solid var(--border-color);
transition: transform 0.3s ease;
z-index: 100;
}
.sidebar-header {
padding: 14px 16px;
border-bottom: 1px solid var(--border-color);
}
.new-chat-btn {
width: 100%;
padding: 12px 16px;
background: transparent;
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-primary);
font-size: 14px;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
transition: background 0.2s;
}
.new-chat-btn:hover {
background: var(--sidebar-hover);
}
.new-chat-btn svg {
width: 18px;
height: 18px;
flex-shrink: 0;
}
.conversation-list {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.conversation-list::-webkit-scrollbar {
width: 4px;
}
.conversation-list::-webkit-scrollbar-track {
background: transparent;
}
.conversation-list::-webkit-scrollbar-thumb {
background: #444;
border-radius: 2px;
}
.conv-item {
padding: 10px 12px;
border-radius: 8px;
cursor: pointer;
font-size: 13px;
color: var(--text-secondary);
display: flex;
align-items: center;
gap: 10px;
transition: all 0.15s;
margin-bottom: 2px;
position: relative;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.conv-item:hover {
background: var(--sidebar-hover);
color: var(--text-primary);
}
.conv-item.active {
background: var(--sidebar-hover);
color: var(--text-primary);
}
.conv-item .conv-icon {
font-size: 16px;
flex-shrink: 0;
}
.conv-item .conv-title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
}
.conv-item .delete-btn {
opacity: 0;
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 2px 4px;
border-radius: 4px;
font-size: 14px;
flex-shrink: 0;
transition: all 0.15s;
}
.conv-item:hover .delete-btn {
opacity: 1;
}
.conv-item .delete-btn:hover {
color: var(--danger);
background: rgba(239, 68, 68, 0.15);
}
.sidebar-footer {
padding: 12px 16px;
border-top: 1px solid var(--border-color);
font-size: 12px;
color: var(--text-muted);
display: flex;
align-items: center;
gap: 8px;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--green-dot);
flex-shrink: 0;
}
/* ========== Main Content ========== */
.main {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
/* ========== Chat Header ========== */
.chat-header {
padding: 12px 24px;
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: space-between;
min-height: 56px;
}
.chat-header .model-info {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 500;
}
.chat-header .model-info .model-badge {
background: rgba(79,124,255,0.15);
color: var(--accent);
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
}
.toggle-sidebar {
display: none;
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
padding: 6px;
border-radius: 6px;
font-size: 20px;
}
.toggle-sidebar:hover {
background: var(--sidebar-hover);
}
/* ========== Messages Area ========== */
.messages-container {
flex: 1;
overflow-y: auto;
padding: 0;
}
.messages-container::-webkit-scrollbar {
width: 6px;
}
.messages-container::-webkit-scrollbar-track {
background: transparent;
}
.messages-container::-webkit-scrollbar-thumb {
background: #444;
border-radius: 3px;
}
.messages-wrapper {
max-width: 800px;
margin: 0 auto;
padding: 24px 24px 0;
}
.message {
padding: 16px 0;
border-bottom: 1px solid rgba(255,255,255,0.05);
}
.message:last-child {
border-bottom: none;
}
.message-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.message-avatar {
width: 32px;
height: 32px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
flex-shrink: 0;
}
.message-avatar.user {
background: var(--accent);
color: #fff;
font-weight: 600;
}
.message-avatar.ai {
background: linear-gradient(135deg, #6c5ce7, #a29bfe);
color: #fff;
font-weight: 600;
}
.message-name {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
}
.message-content {
padding-left: 42px;
font-size: 15px;
line-height: 1.7;
color: var(--text-primary);
word-wrap: break-word;
}
.message-content p {
margin-bottom: 12px;
}
.message-content p:last-child {
margin-bottom: 0;
}
.message-content ul,
.message-content ol {
margin: 8px 0;
padding-left: 24px;
}
.message-content li {
margin-bottom: 4px;
}
.message-content blockquote {
border-left: 3px solid var(--accent);
padding: 8px 16px;
margin: 12px 0;
background: rgba(79,124,255,0.06);
border-radius: 0 6px 6px 0;
color: var(--text-secondary);
}
.message-content h1, .message-content h2, .message-content h3,
.message-content h4, .message-content h5, .message-content h6 {
margin: 20px 0 12px;
font-weight: 600;
line-height: 1.4;
}
.message-content h1 { font-size: 22px; }
.message-content h2 { font-size: 19px; }
.message-content h3 { font-size: 17px; }
.message-content a {
color: var(--accent);
text-decoration: none;
}
.message-content a:hover {
text-decoration: underline;
}
.message-content table {
border-collapse: collapse;
margin: 12px 0;
width: 100%;
font-size: 14px;
}
.message-content th,
.message-content td {
border: 1px solid var(--border-color);
padding: 8px 12px;
text-align: left;
}
.message-content th {
background: rgba(255,255,255,0.05);
font-weight: 600;
}
.message-content code {
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
font-size: 13px;
}
.message-content p > code,
.message-content li > code {
background: rgba(255,255,255,0.08);
padding: 2px 6px;
border-radius: 4px;
color: #f472b6;
}
.message-content pre {
margin: 12px 0;
border-radius: 8px;
overflow: hidden;
position: relative;
}
.message-content pre code {
display: block;
padding: 16px;
overflow-x: auto;
background: #1a1a2e;
color: #e5e5e5;
font-size: 13px;
line-height: 1.6;
}
.message-content pre .code-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 16px;
background: #16162a;
font-size: 12px;
color: var(--text-muted);
border-bottom: 1px solid rgba(255,255,255,0.05);
}
.message-content pre .code-header .lang-label {
text-transform: uppercase;
font-weight: 500;
letter-spacing: 0.5px;
}
.message-content pre .code-header .copy-btn {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
transition: all 0.15s;
}
.message-content pre .code-header .copy-btn:hover {
color: var(--text-primary);
background: rgba(255,255,255,0.08);
}
.message-content img {
max-width: 100%;
border-radius: 8px;
margin: 8px 0;
}
.message-content hr {
border: none;
border-top: 1px solid var(--border-color);
margin: 16px 0;
}
/* Thinking block (DeepSeek style) */
.thinking-block {
background: rgba(255,255,255,0.03);
border-left: 3px solid var(--accent);
border-radius: 0 8px 8px 0;
padding: 12px 16px;
margin: 8px 0 16px 0;
font-size: 14px;
color: var(--text-secondary);
}
.thinking-block .thinking-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 500;
color: var(--accent);
margin-bottom: 8px;
cursor: pointer;
}
.thinking-block .thinking-title svg {
width: 14px;
height: 14px;
transition: transform 0.2s;
}
.thinking-block .thinking-title svg.rotated {
transform: rotate(90deg);
}
.thinking-block .thinking-content {
display: none;
font-size: 13px;
color: var(--text-secondary);
line-height: 1.6;
}
.thinking-block .thinking-content.show {
display: block;
}
/* ========== Empty State ========== */
.empty-state {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 24px;
text-align: center;
}
.empty-state .logo {
width: 64px;
height: 64px;
background: linear-gradient(135deg, #6c5ce7, #a29bfe);
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
color: #fff;
margin-bottom: 24px;
font-weight: 700;
}
.empty-state h1 {
font-size: 28px;
font-weight: 600;
margin-bottom: 8px;
}
.empty-state p {
color: var(--text-secondary);
font-size: 14px;
max-width: 400px;
margin-bottom: 32px;
}
.suggestion-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
max-width: 600px;
}
.suggestion-chip {
padding: 10px 16px;
background: var(--msg-user-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-secondary);
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
}
.suggestion-chip:hover {
background: var(--sidebar-hover);
border-color: var(--accent);
color: var(--text-primary);
}
/* ========== Input Area ========== */
.input-area {
padding: 16px 24px 24px;
border-top: 1px solid var(--border-color);
}
.input-wrapper {
max-width: 800px;
margin: 0 auto;
position: relative;
}
.input-box {
background: var(--input-bg);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 10px 48px 10px 16px;
display: flex;
align-items: flex-end;
transition: border-color 0.2s;
}
.input-box:focus-within {
border-color: var(--accent);
}
.input-box textarea {
flex: 1;
background: none;
border: none;
outline: none;
color: var(--text-primary);
font-size: 14px;
line-height: 1.5;
resize: none;
max-height: 200px;
font-family: inherit;
padding: 4px 0;
}
.input-box textarea::placeholder {
color: var(--text-muted);
}
.send-btn {
position: absolute;
bottom: 14px;
right: 14px;
width: 36px;
height: 36px;
border-radius: 8px;
background: var(--accent);
border: none;
color: #fff;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s, opacity 0.2s;
flex-shrink: 0;
}
.send-btn:hover {
background: var(--accent-hover);
}
.send-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.send-btn svg {
width: 18px;
height: 18px;
}
.input-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 4px 0;
font-size: 12px;
color: var(--text-muted);
}
.stop-btn {
display: none;
background: var(--danger);
border: none;
color: #fff;
padding: 6px 16px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
align-items: center;
gap: 6px;
transition: background 0.2s;
}
.stop-btn:hover {
background: #dc2626;
}
.stop-btn svg {
width: 14px;
height: 14px;
}
/* ========== Typing Indicator ========== */
.typing-indicator {
display: none;
padding: 16px 0 16px 42px;
align-items: center;
gap: 8px;
}
.typing-indicator.show {
display: flex;
}
.typing-dots {
display: flex;
gap: 4px;
}
.typing-dots span {
width: 6px;
height: 6px;
background: var(--text-muted);
border-radius: 50%;
animation: typing 1.4s infinite ease-in-out;
}
.typing-dots span:nth-child(1) { animation-delay: 0s; }
.typing-dots span:nth-child(2) { animation-delay: 0.2s; }
.typing-dots span:nth-child(3) { animation-delay: 0.4s; }
@keyframes typing {
0%, 60%, 100% { opacity: 0.3; transform: scale(0.8); }
30% { opacity: 1; transform: scale(1.1); }
}
.typing-label {
font-size: 13px;
color: var(--text-muted);
}
/* ========== Responsive ========== */
.sidebar-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
z-index: 99;
}
@media (max-width: 768px) {
.sidebar {
position: fixed;
left: -280px;
top: 0;
bottom: 0;
transition: left 0.3s ease;
}
.sidebar.open {
left: 0;
}
.sidebar-overlay.show {
display: block;
}
.toggle-sidebar {
display: block;
}
.messages-wrapper {
padding: 16px 16px 0;
}
.input-area {
padding: 12px 16px 16px;
}
.empty-state h1 {
font-size: 22px;
}
}
</style>
</head>
<body>
<!-- Sidebar Overlay (mobile) -->
<div class="sidebar-overlay" id="sidebarOverlay"></div>
<!-- Sidebar -->
<aside class="sidebar" id="sidebar">
<div class="sidebar-header">
<button class="new-chat-btn" onclick="newConversation()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
新对话
</button>
</div>
<div class="conversation-list" id="convList">
<!-- conversations rendered by JS -->
</div>
<div class="sidebar-footer">
<span class="status-dot"></span>
<span>傻蛋 在线</span>
</div>
</aside>
<!-- Main Content -->
<div class="main">
<!-- Header -->
<div class="chat-header">
<div style="display:flex;align-items:center;gap:12px;">
<button class="toggle-sidebar" id="toggleSidebar" onclick="toggleSidebar()">☰</button>
<div class="model-info">
<span>傻蛋</span>
<span class="model-badge">深度思考</span>
</div>
</div>
</div>
<!-- Messages -->
<div class="messages-container" id="messagesContainer">
<!-- Empty State -->
<div class="empty-state" id="emptyState">
<div class="logo">DS</div>
<h1>你好,有什么可以帮助你的?</h1>
<p>我是 傻蛋,一个由 AI 驱动的智能助手。我可以帮你回答问题、编写代码、分析数据等。</p>
<div class="suggestion-chips">
<div class="suggestion-chip" onclick="suggestClick('用Java写一个二分查找算法')">📝 写一个二分查找</div>
<div class="suggestion-chip" onclick="suggestClick('什么是Spring IOC?用简单的话解释')">📖 解释 Spring IoC</div>
<div class="suggestion-chip" onclick="suggestClick('Python和Java的区别')">⚖️ Python vs Java</div>
<div class="suggestion-chip" onclick="suggestClick('写一个冒泡排序并分析时间复杂度')">🔢 冒泡排序</div>
</div>
</div>
<div class="messages-wrapper" id="messagesWrapper" style="display:none;">
<!-- messages rendered by JS -->
</div>
<!-- Typing Indicator -->
<div class="messages-wrapper" style="max-width:800px;margin:0 auto;">
<div class="typing-indicator" id="typingIndicator">
<div class="typing-dots">
<span></span><span></span><span></span>
</div>
<span class="typing-label">AI 正在思考...</span>
</div>
</div>
</div>
<!-- Input Area -->
<div class="input-area">
<div class="input-wrapper">
<div class="input-box">
<textarea id="promptInput" rows="1" placeholder="给 傻蛋 发送消息"
oninput="autoResize(this)" onkeydown="handleKeyDown(event)"></textarea>
</div>
<button class="send-btn" id="sendBtn" onclick="sendMessage()" disabled>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="22" y1="2" x2="11" y2="13"></line>
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
</svg>
</button>
</div>
<div class="input-footer">
<span>模型可能会产生不准确的信息,请甄别使用</span>
<button class="stop-btn" id="stopBtn" onclick="stopStream()">
<svg viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="1"/></svg>
停止生成
</button>
</div>
</div>
</div>
<!-- Marked & Highlight.js -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/15.0.6/marked.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
<script>
// ========== State ==========
let conversations = [];
let currentConvId = null;
let isStreaming = false;
let abortController = null;
// ========== Initialization ==========
document.addEventListener('DOMContentLoaded', () => {
loadConversations();
const input = document.getElementById('promptInput');
input.addEventListener('focus', () => {
document.activeElement === input && checkSendButton();
});
// Focus input
setTimeout(() => input.focus(), 300);
});
// ========== Conversation Management ==========
function generateId() {
return Date.now().toString(36) + Math.random().toString(36).substring(2, 6);
}
function loadConversations() {
const saved = localStorage.getItem('deepseek_conv');
if (saved) {
try {
conversations = JSON.parse(saved);
} catch (e) {
conversations = [];
}
}
if (conversations.length === 0) {
newConversation();
} else {
currentConvId = conversations[0].id;
renderConversations();
renderCurrentMessages();
}
}
function saveConversations() {
localStorage.setItem('deepseek_conv', JSON.stringify(conversations));
}
function newConversation() {
if (isStreaming) return;
const conv = {
id: generateId(),
title: '新对话',
messages: []
};
conversations.unshift(conv);
currentConvId = conv.id;
saveConversations();
renderConversations();
renderCurrentMessages();
document.getElementById('promptInput').value = '';
autoResize(document.getElementById('promptInput'));
checkSendButton();
document.getElementById('promptInput').focus();
// Close sidebar on mobile
closeSidebar();
}
function getCurrentConv() {
return conversations.find(c => c.id === currentConvId);
}
function switchConversation(id) {
if (isStreaming) return;
currentConvId = id;
renderConversations();
renderCurrentMessages();
document.getElementById('promptInput').value = '';
autoResize(document.getElementById('promptInput'));
checkSendButton();
closeSidebar();
}
function deleteConversation(e, id) {
e.stopPropagation();
if (isStreaming) return;
if (conversations.length <= 1) {
newConversation();
return;
}
conversations = conversations.filter(c => c.id !== id);
if (currentConvId === id) {
currentConvId = conversations[0].id;
}
saveConversations();
renderConversations();
renderCurrentMessages();
}
function renderConversations() {
const list = document.getElementById('convList');
list.innerHTML = conversations.map(c => `
<div class="conv-item ${c.id === currentConvId ? 'active' : ''}"
onclick="switchConversation('${c.id}')">
<span class="conv-icon">💬</span>
<span class="conv-title">${escapeHtml(c.title)}</span>
<button class="delete-btn" onclick="deleteConversation(event,'${c.id}')" title="删除对话">✕</button>
</div>
`).join('');
}
function renderCurrentMessages() {
const conv = getCurrentConv();
const emptyState = document.getElementById('emptyState');
const wrapper = document.getElementById('messagesWrapper');
const typing = document.getElementById('typingIndicator');
if (!conv || conv.messages.length === 0) {
emptyState.style.display = 'flex';
wrapper.style.display = 'none';
typing.classList.remove('show');
return;
}
emptyState.style.display = 'none';
wrapper.style.display = 'block';
// Update title
const firstMsg = conv.messages[0];
if (firstMsg && firstMsg.role === 'user') {
const newTitle = firstMsg.content.substring(0, 30) + (firstMsg.content.length > 30 ? '...' : '');
if (conv.title === '新对话' || conv.title === newTitle) {
conv.title = newTitle;
saveConversations();
renderConversations();
}
}
wrapper.innerHTML = conv.messages.map((msg, idx) => {
if (msg.role === 'user') {
return renderUserMessage(msg.content);
} else {
return renderAiMessage(msg.content, idx === conv.messages.length - 1 && msg.isStreaming);
}
}).join('');
// Re-apply syntax highlighting
wrapper.querySelectorAll('pre code').forEach(block => {
hljs.highlightElement(block);
});
scrollToBottom();
}
function renderUserMessage(content) {
return `
<div class="message">
<div class="message-header">
<div class="message-avatar user">我</div>
<span class="message-name">你</span>
</div>
<div class="message-content">${escapeHtml(content)}</div>
</div>
`;
}
function renderAiMessage(content, isStreaming) {
const html = marked.parse(content || '', { breaks: true, gfm: true });
return `
<div class="message">
<div class="message-header">
<div class="message-avatar ai">SD</div>
<span class="message-name">傻蛋</span>
</div>
<div class="message-content">${html}</div>
</div>
`;
}
// ========== Streaming ==========
async function sendMessage() {
const input = document.getElementById('promptInput');
const text = input.value.trim();
if (!text || isStreaming) return;
let conv = getCurrentConv();
if (!conv) {
newConversation();
conv = getCurrentConv();
}
// Add user message
conv.messages.push({ role: 'user', content: text });
// Add placeholder for AI response
conv.messages.push({ role: 'assistant', content: '', isStreaming: true });
saveConversations();
renderCurrentMessages();
input.value = '';
autoResize(input);
checkSendButton();
// Show typing indicator
document.getElementById('emptyState').style.display = 'none';
document.getElementById('messagesWrapper').style.display = 'block';
document.getElementById('typingIndicator').classList.add('show');
document.getElementById('stopBtn').style.display = 'inline-flex';
isStreaming = true;
document.getElementById('sendBtn').disabled = true;
abortController = new AbortController();
try {
const url = '/ai/chat?prompt=' + encodeURIComponent(text);
const response = await fetch(url, {
signal: abortController.signal,
headers: { 'Accept': 'text/html, text/plain, */*' }
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
document.getElementById('typingIndicator').classList.remove('show');
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
buffer += chunk;
// Update the AI message content
const conv = getCurrentConv();
if (conv && conv.messages.length > 0) {
const lastMsg = conv.messages[conv.messages.length - 1];
if (lastMsg.role === 'assistant') {
lastMsg.content = buffer;
// Re-render last message only
const wrapper = document.getElementById('messagesWrapper');
const msgDivs = wrapper.querySelectorAll('.message');
if (msgDivs.length > 0) {
const lastMsgDiv = msgDivs[msgDivs.length - 1];
const contentDiv = lastMsgDiv.querySelector('.message-content');
if (contentDiv) {
const html = marked.parse(buffer, { breaks: true, gfm: true });
contentDiv.innerHTML = html;
// Highlight code blocks
contentDiv.querySelectorAll('pre code').forEach(block => {
hljs.highlightElement(block);
});
}
}
scrollToBottom();
}
}
}
// Mark streaming as complete
const conv2 = getCurrentConv();
if (conv2 && conv2.messages.length > 0) {
const lastMsg = conv2.messages[conv2.messages.length - 1];
if (lastMsg.role === 'assistant') {
lastMsg.isStreaming = false;
}
}
saveConversations();
} catch (err) {
if (err.name === 'AbortError') {
console.log('Stream stopped by user');
} else {
console.error('Stream error:', err);
// Show error in the message
const conv = getCurrentConv();
if (conv && conv.messages.length > 0) {
const lastMsg = conv.messages[conv.messages.length - 1];
if (lastMsg.role === 'assistant') {
lastMsg.content = '⚠️ 请求出错了:' + err.message;
lastMsg.isStreaming = false;
}
}
renderCurrentMessages();
}
saveConversations();
} finally {
isStreaming = false;
document.getElementById('sendBtn').disabled = false;
document.getElementById('stopBtn').style.display = 'none';
document.getElementById('typingIndicator').classList.remove('show');
checkSendButton();
input.focus();
}
}
function stopStream() {
if (abortController) {
abortController.abort();
abortController = null;
}
}
// ========== UI Helpers ==========
function checkSendButton() {
const input = document.getElementById('promptInput');
const btn = document.getElementById('sendBtn');
btn.disabled = !input.value.trim() || isStreaming;
}
function autoResize(textarea) {
textarea.style.height = 'auto';
textarea.style.height = Math.min(textarea.scrollHeight, 200) + 'px';
checkSendButton();
}
function handleKeyDown(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
}
function suggestClick(text) {
document.getElementById('promptInput').value = text;
autoResize(document.getElementById('promptInput'));
checkSendButton();
sendMessage();
}
function scrollToBottom() {
const container = document.getElementById('messagesContainer');
setTimeout(() => {
container.scrollTop = container.scrollHeight;
}, 50);
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function toggleSidebar() {
const sidebar = document.getElementById('sidebar');
const overlay = document.getElementById('sidebarOverlay');
sidebar.classList.toggle('open');
overlay.classList.toggle('show');
}
function closeSidebar() {
const sidebar = document.getElementById('sidebar');
const overlay = document.getElementById('sidebarOverlay');
sidebar.classList.remove('open');
overlay.classList.remove('show');
}
// Click overlay to close sidebar
document.getElementById('sidebarOverlay').addEventListener('click', closeSidebar);
// Periodic save of ongoing stream content
setInterval(() => {
if (isStreaming) {
saveConversations();
}
}, 2000);
</script>
</body>
</html>
效果展示

使用SimpleLoggerAdvisor的结果体现在控制台
