7 Commits

Author SHA1 Message Date
a7c13bc449 feat: open-ai 2026-01-18 17:59:10 +08:00
24d189f945 feat: rag-git的api 2026-01-18 16:59:11 +08:00
e042e548f9 feat: rag的api 2026-01-17 23:54:47 +08:00
64ba3b5767 feat: rag测试 2026-01-14 23:22:57 +08:00
c99b73d406 fix: 修复错误 2026-01-13 00:20:51 +08:00
25c0abd666 feat: 流式应答ui 2025-09-07 11:42:27 +08:00
21b60a0216 feat: ollama流式应答接口实现 2025-06-07 22:32:41 +08:00
28 changed files with 2745 additions and 3 deletions

4
.gitignore vendored
View File

@@ -35,4 +35,6 @@ build/
.vscode/
### Mac OS ###
.DS_Store
.DS_Store
/ai-rag-app/cloned-repo/
/.idea/

124
.idea/uiDesigner.xml generated Normal file
View File

@@ -0,0 +1,124 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Palette2">
<group name="Swing">
<item class="com.intellij.uiDesigner.HSpacer" tooltip-text="Horizontal Spacer" icon="/com/intellij/uiDesigner/icons/hspacer.svg" removable="false" auto-create-binding="false" can-attach-label="false">
<default-constraints vsize-policy="1" hsize-policy="6" anchor="0" fill="1" />
</item>
<item class="com.intellij.uiDesigner.VSpacer" tooltip-text="Vertical Spacer" icon="/com/intellij/uiDesigner/icons/vspacer.svg" removable="false" auto-create-binding="false" can-attach-label="false">
<default-constraints vsize-policy="6" hsize-policy="1" anchor="0" fill="2" />
</item>
<item class="javax.swing.JPanel" icon="/com/intellij/uiDesigner/icons/panel.svg" removable="false" auto-create-binding="false" can-attach-label="false">
<default-constraints vsize-policy="3" hsize-policy="3" anchor="0" fill="3" />
</item>
<item class="javax.swing.JScrollPane" icon="/com/intellij/uiDesigner/icons/scrollPane.svg" removable="false" auto-create-binding="false" can-attach-label="true">
<default-constraints vsize-policy="7" hsize-policy="7" anchor="0" fill="3" />
</item>
<item class="javax.swing.JButton" icon="/com/intellij/uiDesigner/icons/button.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="0" hsize-policy="3" anchor="0" fill="1" />
<initial-values>
<property name="text" value="Button" />
</initial-values>
</item>
<item class="javax.swing.JRadioButton" icon="/com/intellij/uiDesigner/icons/radioButton.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="0" hsize-policy="3" anchor="8" fill="0" />
<initial-values>
<property name="text" value="RadioButton" />
</initial-values>
</item>
<item class="javax.swing.JCheckBox" icon="/com/intellij/uiDesigner/icons/checkBox.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="0" hsize-policy="3" anchor="8" fill="0" />
<initial-values>
<property name="text" value="CheckBox" />
</initial-values>
</item>
<item class="javax.swing.JLabel" icon="/com/intellij/uiDesigner/icons/label.svg" removable="false" auto-create-binding="false" can-attach-label="false">
<default-constraints vsize-policy="0" hsize-policy="0" anchor="8" fill="0" />
<initial-values>
<property name="text" value="Label" />
</initial-values>
</item>
<item class="javax.swing.JTextField" icon="/com/intellij/uiDesigner/icons/textField.svg" removable="false" auto-create-binding="true" can-attach-label="true">
<default-constraints vsize-policy="0" hsize-policy="6" anchor="8" fill="1">
<preferred-size width="150" height="-1" />
</default-constraints>
</item>
<item class="javax.swing.JPasswordField" icon="/com/intellij/uiDesigner/icons/passwordField.svg" removable="false" auto-create-binding="true" can-attach-label="true">
<default-constraints vsize-policy="0" hsize-policy="6" anchor="8" fill="1">
<preferred-size width="150" height="-1" />
</default-constraints>
</item>
<item class="javax.swing.JFormattedTextField" icon="/com/intellij/uiDesigner/icons/formattedTextField.svg" removable="false" auto-create-binding="true" can-attach-label="true">
<default-constraints vsize-policy="0" hsize-policy="6" anchor="8" fill="1">
<preferred-size width="150" height="-1" />
</default-constraints>
</item>
<item class="javax.swing.JTextArea" icon="/com/intellij/uiDesigner/icons/textArea.svg" removable="false" auto-create-binding="true" can-attach-label="true">
<default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3">
<preferred-size width="150" height="50" />
</default-constraints>
</item>
<item class="javax.swing.JTextPane" icon="/com/intellij/uiDesigner/icons/textPane.svg" removable="false" auto-create-binding="true" can-attach-label="true">
<default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3">
<preferred-size width="150" height="50" />
</default-constraints>
</item>
<item class="javax.swing.JEditorPane" icon="/com/intellij/uiDesigner/icons/editorPane.svg" removable="false" auto-create-binding="true" can-attach-label="true">
<default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3">
<preferred-size width="150" height="50" />
</default-constraints>
</item>
<item class="javax.swing.JComboBox" icon="/com/intellij/uiDesigner/icons/comboBox.svg" removable="false" auto-create-binding="true" can-attach-label="true">
<default-constraints vsize-policy="0" hsize-policy="2" anchor="8" fill="1" />
</item>
<item class="javax.swing.JTable" icon="/com/intellij/uiDesigner/icons/table.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3">
<preferred-size width="150" height="50" />
</default-constraints>
</item>
<item class="javax.swing.JList" icon="/com/intellij/uiDesigner/icons/list.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="6" hsize-policy="2" anchor="0" fill="3">
<preferred-size width="150" height="50" />
</default-constraints>
</item>
<item class="javax.swing.JTree" icon="/com/intellij/uiDesigner/icons/tree.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3">
<preferred-size width="150" height="50" />
</default-constraints>
</item>
<item class="javax.swing.JTabbedPane" icon="/com/intellij/uiDesigner/icons/tabbedPane.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="3" hsize-policy="3" anchor="0" fill="3">
<preferred-size width="200" height="200" />
</default-constraints>
</item>
<item class="javax.swing.JSplitPane" icon="/com/intellij/uiDesigner/icons/splitPane.svg" removable="false" auto-create-binding="false" can-attach-label="false">
<default-constraints vsize-policy="3" hsize-policy="3" anchor="0" fill="3">
<preferred-size width="200" height="200" />
</default-constraints>
</item>
<item class="javax.swing.JSpinner" icon="/com/intellij/uiDesigner/icons/spinner.svg" removable="false" auto-create-binding="true" can-attach-label="true">
<default-constraints vsize-policy="0" hsize-policy="6" anchor="8" fill="1" />
</item>
<item class="javax.swing.JSlider" icon="/com/intellij/uiDesigner/icons/slider.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="0" hsize-policy="6" anchor="8" fill="1" />
</item>
<item class="javax.swing.JSeparator" icon="/com/intellij/uiDesigner/icons/separator.svg" removable="false" auto-create-binding="false" can-attach-label="false">
<default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3" />
</item>
<item class="javax.swing.JProgressBar" icon="/com/intellij/uiDesigner/icons/progressbar.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="0" hsize-policy="6" anchor="0" fill="1" />
</item>
<item class="javax.swing.JToolBar" icon="/com/intellij/uiDesigner/icons/toolbar.svg" removable="false" auto-create-binding="false" can-attach-label="false">
<default-constraints vsize-policy="0" hsize-policy="6" anchor="0" fill="1">
<preferred-size width="-1" height="20" />
</default-constraints>
</item>
<item class="javax.swing.JToolBar$Separator" icon="/com/intellij/uiDesigner/icons/toolbarSeparator.svg" removable="false" auto-create-binding="false" can-attach-label="false">
<default-constraints vsize-policy="0" hsize-policy="0" anchor="0" fill="1" />
</item>
<item class="javax.swing.JScrollBar" icon="/com/intellij/uiDesigner/icons/scrollbar.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="6" hsize-policy="0" anchor="0" fill="2" />
</item>
</group>
</component>
</project>

View File

@@ -0,0 +1,28 @@
package com.storm.dev.api;
import org.springframework.ai.chat.ChatResponse;
import reactor.core.publisher.Flux;
/**
* @author: lyd
* @date: 2025/6/7 22:12
*/
public interface IAiService {
/**
* 非流式生成
* @param model 模型
* @param message 信息
* @return
*/
ChatResponse generate(String model, String message);
/**
* 流式生成
* @param model 模型
* @param message 信息
* @return
*/
Flux<ChatResponse> generateStream(String model, String message);
Flux<ChatResponse> generateStreamRag(String model, String ragTag, String message);
}

View File

@@ -0,0 +1,36 @@
package com.storm.dev.api;
import com.storm.dev.api.response.Response;
import org.springframework.ai.chat.ChatResponse;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.multipart.MultipartFile;
import reactor.core.publisher.Flux;
import java.util.List;
/**
* @author: lyd
* @date: 2026/1/14 23:41
*/
public interface IRAGService {
/**
* 获取标签列表
*
* @return
*/
Response<List<String>> queryRagTagList();
/**
* 上传知识库
*
* @param ragTag
* @param files
* @return
*/
Response<String> uploadFile(String ragTag, List<MultipartFile> files);
ChatResponse generateStreamRag(String model, String ragTag, String message);
Response<String> analyzeGitRepository(String repoUrl, String userName, String token) throws Exception;
}

View File

@@ -1,4 +1,5 @@
/**
* @author: lyd
* @date: 2025/6/7 19:12
*/
*/
package com.storm.dev.api;

View File

@@ -0,0 +1,20 @@
package com.storm.dev.api.response;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Response<T> implements Serializable {
private String code;
private String info;
private T data;
}

View File

@@ -34,11 +34,13 @@
<artifactId>spring-ai-tika-document-reader</artifactId>
</dependency>
<!-- 处理知识库:向量库 -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-pgvector-store-spring-boot-starter</artifactId>
</dependency>
<!-- 使用ollama的api -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-ollama</artifactId>
@@ -122,7 +124,7 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<!-- <mainClass>cn.bugstack.xfg.dev.tech.Application</mainClass>-->
<mainClass>com.storm.dev.Application</mainClass>
<layout>JAR</layout>
</configuration>
</plugin>

View File

@@ -0,0 +1,18 @@
package com.storm.dev;
import org.springframework.beans.factory.annotation.Configurable;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* 会扫描com.storm.dev的包
*/
@SpringBootApplication
@Configurable
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class);
}
}

View File

@@ -0,0 +1,70 @@
package com.storm.dev.config;
import org.springframework.ai.ollama.OllamaChatClient;
import org.springframework.ai.ollama.OllamaEmbeddingClient;
import org.springframework.ai.ollama.api.OllamaApi;
import org.springframework.ai.ollama.api.OllamaOptions;
import org.springframework.ai.openai.OpenAiEmbeddingClient;
import org.springframework.ai.openai.api.OpenAiApi;
import org.springframework.ai.transformer.splitter.TokenTextSplitter;
import org.springframework.ai.vectorstore.PgVectorStore;
import org.springframework.ai.vectorstore.SimpleVectorStore;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.JdbcTemplate;
/**
* 注入OllamaApi、OllamaChatClient对象
* @author: lyd
* @date: 2025/6/7 22:28
*/
@Configuration
public class OllamaConfig {
@Bean
public OllamaApi ollamaApi(@Value("${spring.ai.ollama.base-url}") String baseUrl) {
return new OllamaApi(baseUrl);
}
@Bean
public OpenAiApi openAiApi(@Value("${spring.ai.openai.base-url}") String baseUrl, @Value("${spring.ai.openai.api-key}") String apikey) {
return new OpenAiApi(baseUrl, apikey);
}
@Bean
public OllamaChatClient ollamaChatClient(OllamaApi ollamaApi) {
return new OllamaChatClient(ollamaApi);
}
@Bean
public TokenTextSplitter tokenTextSplitter() {
return new TokenTextSplitter();
}
@Bean
public SimpleVectorStore vectorStore(@Value("${spring.ai.rag.embed}") String model, OllamaApi ollamaApi, OpenAiApi openAiApi) {
if ("nomic-embed-text".equalsIgnoreCase(model)) {
OllamaEmbeddingClient embeddingClient = new OllamaEmbeddingClient(ollamaApi);
embeddingClient.withDefaultOptions(OllamaOptions.create().withModel("nomic-embed-text"));
return new SimpleVectorStore(embeddingClient);
} else {
OpenAiEmbeddingClient embeddingClient = new OpenAiEmbeddingClient(openAiApi);
return new SimpleVectorStore(embeddingClient);
}
}
@Bean
public PgVectorStore pgVectorStore(@Value("${spring.ai.rag.embed}") String model, OllamaApi ollamaApi, OpenAiApi openAiApi, JdbcTemplate jdbcTemplate) {
if ("nomic-embed-text".equalsIgnoreCase(model)) {
OllamaEmbeddingClient embeddingClient = new OllamaEmbeddingClient(ollamaApi);
embeddingClient.withDefaultOptions(OllamaOptions.create().withModel("nomic-embed-text"));
return new PgVectorStore(jdbcTemplate, embeddingClient);
} else {
OpenAiEmbeddingClient embeddingClient = new OpenAiEmbeddingClient(openAiApi);
return new PgVectorStore(jdbcTemplate, embeddingClient);
}
}
}

View File

@@ -0,0 +1,42 @@
package com.storm.dev.config;
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.codec.JsonJacksonCodec;
import org.redisson.config.Config;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Redis 客户端,使用 Redisson <a href="https://github.com/redisson/redisson">Redisson</a>
*
*/
@Configuration
@EnableConfigurationProperties(RedisClientConfigProperties.class)
public class RedisClientConfig {
@Bean("redissonClient")
public RedissonClient redissonClient(ConfigurableApplicationContext applicationContext, RedisClientConfigProperties properties) {
Config config = new Config();
// 根据需要可以设定编解码器https://github.com/redisson/redisson/wiki/4.-%E6%95%B0%E6%8D%AE%E5%BA%8F%E5%88%97%E5%8C%96
config.setCodec(JsonJacksonCodec.INSTANCE);
config.useSingleServer()
.setAddress("redis://" + properties.getHost() + ":" + properties.getPort())
// .setPassword(properties.getPassword())
.setConnectionPoolSize(properties.getPoolSize())
.setConnectionMinimumIdleSize(properties.getMinIdleSize())
.setIdleConnectionTimeout(properties.getIdleTimeout())
.setConnectTimeout(properties.getConnectTimeout())
.setRetryAttempts(properties.getRetryAttempts())
.setRetryInterval(properties.getRetryInterval())
.setPingConnectionInterval(properties.getPingInterval())
.setKeepAlive(properties.isKeepAlive())
;
return Redisson.create(config);
}
}

View File

@@ -0,0 +1,37 @@
package com.storm.dev.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* @author Fuzhengwei bugstack.cn @小傅哥
* Redis 连接配置 <a href="https://github.com/redisson/redisson/tree/master/redisson-spring-boot-starter">redisson-spring-boot-starter</a>
*/
@Data
@ConfigurationProperties(prefix = "redis.sdk.config", ignoreInvalidFields = true)
public class RedisClientConfigProperties {
/** host:ip */
private String host;
/** 端口 */
private int port;
/** 账密 */
private String password;
/** 设置连接池的大小默认为64 */
private int poolSize = 64;
/** 设置连接池的最小空闲连接数默认为10 */
private int minIdleSize = 10;
/** 设置连接的最大空闲时间单位毫秒超过该时间的空闲连接将被关闭默认为10000 */
private int idleTimeout = 10000;
/** 设置连接超时时间单位毫秒默认为10000 */
private int connectTimeout = 10000;
/** 设置连接重试次数默认为3 */
private int retryAttempts = 3;
/** 设置连接重试的间隔时间单位毫秒默认为1000 */
private int retryInterval = 1000;
/** 设置定期检查连接是否可用的时间间隔单位毫秒默认为0表示不进行定期检查 */
private int pingInterval = 0;
/** 设置是否保持长连接默认为true */
private boolean keepAlive = true;
}

View File

@@ -0,0 +1,6 @@
/**
* 启动模块
* @author: lyd
* @date: 2025/6/7 22:04
*/
package com.storm.dev;

View File

@@ -0,0 +1,42 @@
server:
port: 8090
spring:
datasource:
driver-class-name: org.postgresql.Driver
username: postgres
password: postgres
url: jdbc:postgresql://192.168.109.134:15432/ai-rag-knowledge
type: com.zaxxer.hikari.HikariDataSource
ai:
ollama:
base-url: http://192.168.109.134:11434
embedding:
options:
num-batch: 512
model: nomic-embed-text
openai:
base-url: xxx
api-key: xxx
embedding-model: text-embedding-ada-002
rag:
embed: nomic-embed-text #nomic-embed-text、text-embedding-ada-002
# Redis
redis:
sdk:
config:
host: 127.0.0.1
port: 6379
pool-size: 10
min-idle-size: 5
idle-timeout: 30000
connect-timeout: 5000
retry-attempts: 3
retry-interval: 1000
ping-interval: 60000
keep-alive: true
logging:
level:
root: info
config: classpath:logback-spring.xml

View File

@@ -0,0 +1,7 @@
server:
port: 8090
logging:
level:
root: info
config: classpath:logback-spring.xml

View File

@@ -0,0 +1,7 @@
server:
port: 8090
logging:
level:
root: info
config: classpath:logback-spring.xml

View File

@@ -0,0 +1,5 @@
spring:
application:
name: ai-rag-knowledge
profiles:
active: dev

View File

@@ -0,0 +1,113 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- 日志级别从低到高分为TRACE < DEBUG < INFO < WARN < ERROR < FATAL如果设置为WARN则低于WARN的信息都不会输出 -->
<configuration scan="true" scanPeriod="10 seconds">
<contextName>logback</contextName>
<!-- name的值是变量的名称value的值时变量定义的值。通过定义的值会被插入到logger上下文中。定义变量后可以使“${}”来使用变量。 -->
<springProperty scope="context" name="log.path" source="logging.path"/>
<!-- 日志格式 -->
<conversionRule conversionWord="clr" converterClass="org.springframework.boot.logging.logback.ColorConverter"/>
<conversionRule conversionWord="wex"
converterClass="org.springframework.boot.logging.logback.WhitespaceThrowableProxyConverter"/>
<conversionRule conversionWord="wEx"
converterClass="org.springframework.boot.logging.logback.ExtendedWhitespaceThrowableProxyConverter"/>
<!-- 输出到控制台 -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<!-- 此日志appender是为开发使用只配置最底级别控制台输出的日志级别是大于或等于此级别的日志信息 -->
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>info</level>
</filter>
<encoder>
<pattern>%d{yy-MM-dd.HH:mm:ss.SSS} [%-16t] %-5p %-22c{0}%X{ServiceId} -%X{trace-id} %m%n</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>
<!--输出到文件-->
<!-- 时间滚动输出 level为 INFO 日志 -->
<appender name="INFO_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<!-- 正在记录的日志文件的路径及文件名 -->
<file>./data/log/log_info.log</file>
<!--日志文件输出格式-->
<encoder>
<pattern>%d{yy-MM-dd.HH:mm:ss.SSS} [%-16t] %-5p %-22c{0}%X{ServiceId} -%X{trace-id} %m%n</pattern>
<charset>UTF-8</charset>
</encoder>
<!-- 日志记录器的滚动策略,按日期,按大小记录 -->
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<!-- 每天日志归档路径以及格式 -->
<fileNamePattern>./data/log/log-info-%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
<maxFileSize>100MB</maxFileSize>
</timeBasedFileNamingAndTriggeringPolicy>
<!--日志文件保留天数-->
<maxHistory>15</maxHistory>
<totalSizeCap>10GB</totalSizeCap>
</rollingPolicy>
</appender>
<!-- 时间滚动输出 level为 ERROR 日志 -->
<appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<!-- 正在记录的日志文件的路径及文件名 -->
<file>./data/log/log_error.log</file>
<!--日志文件输出格式-->
<encoder>
<pattern>%d{yy-MM-dd.HH:mm:ss.SSS} [%-16t] %-5p %-22c{0}%X{ServiceId} -%X{trace-id} %m%n</pattern>
<charset>UTF-8</charset>
</encoder>
<!-- 日志记录器的滚动策略,按日期,按大小记录 -->
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>./data/log/log-error-%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
<maxFileSize>100MB</maxFileSize>
</timeBasedFileNamingAndTriggeringPolicy>
<!-- 日志文件保留天数【根据服务器预留,可自行调整】 -->
<maxHistory>7</maxHistory>
<totalSizeCap>5GB</totalSizeCap>
</rollingPolicy>
<!-- WARN 级别及以上 -->
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>WARN</level>
</filter>
</appender>
<!-- 异步输出 -->
<appender name="ASYNC_FILE_INFO" class="ch.qos.logback.classic.AsyncAppender">
<!-- 队列剩余容量小于discardingThreshold,则会丢弃TRACT、DEBUG、INFO级别的日志;默认值-1,为queueSize的20%;0不丢失日志 -->
<discardingThreshold>0</discardingThreshold>
<!-- 更改默认的队列的深度,该值会影响性能.默认值为256 -->
<queueSize>8192</queueSize>
<!-- neverBlock:true 会丢失日志,但业务性能不受影响 -->
<neverBlock>true</neverBlock>
<!--是否提取调用者数据-->
<includeCallerData>false</includeCallerData>
<appender-ref ref="INFO_FILE"/>
</appender>
<appender name="ASYNC_FILE_ERROR" class="ch.qos.logback.classic.AsyncAppender">
<!-- 队列剩余容量小于discardingThreshold,则会丢弃TRACT、DEBUG、INFO级别的日志;默认值-1,为queueSize的20%;0不丢失日志 -->
<discardingThreshold>0</discardingThreshold>
<!-- 更改默认的队列的深度,该值会影响性能.默认值为256 -->
<queueSize>1024</queueSize>
<!-- neverBlock:true 会丢失日志,但业务性能不受影响 -->
<neverBlock>true</neverBlock>
<!--是否提取调用者数据-->
<includeCallerData>false</includeCallerData>
<appender-ref ref="ERROR_FILE"/>
</appender>
<!-- 开发环境:控制台打印 -->
<springProfile name="dev">
<logger name="com.nmys.view" level="debug"/>
</springProfile>
<root level="info">
<appender-ref ref="CONSOLE"/>
<!-- 异步日志-INFO -->
<appender-ref ref="ASYNC_FILE_INFO"/>
<!-- 异步日志-ERROR -->
<appender-ref ref="ASYNC_FILE_ERROR"/>
</root>
</configuration>

View File

@@ -0,0 +1,86 @@
package com.storm.dev.text;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.FileUtils;
import org.eclipse.jgit.api.Git;
import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.ai.document.Document;
import org.springframework.ai.ollama.OllamaChatClient;
import org.springframework.ai.reader.tika.TikaDocumentReader;
import org.springframework.ai.transformer.splitter.TokenTextSplitter;
import org.springframework.ai.vectorstore.PgVectorStore;
import org.springframework.ai.vectorstore.SimpleVectorStore;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.core.io.PathResource;
import org.springframework.test.context.junit4.SpringRunner;
import java.io.File;
import java.io.IOException;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.List;
/**
* @author: lyd
* @date: 2026/1/18 14:55
*/
@Slf4j
@RunWith(SpringRunner.class)
@SpringBootTest
public class GitTest {
@Resource
private OllamaChatClient ollamaChatClient;
@Resource
private TokenTextSplitter tokenTextSplitter;
@Resource
private SimpleVectorStore simpleVectorStore;
@Resource
private PgVectorStore pgVectorStore;
public final String LOCALPATH = "./cloned-repo";
@Test
public void test() throws Exception {
String repoUrl = "https://gitee.com/liyongde/java-trial.git";
String username = "liyongde";
String password = "a1c280a3bfe97eb5a53f7f04a01e7fca";
log.info("克隆路径:" + new File(LOCALPATH).getAbsolutePath());
FileUtils.deleteDirectory(new File(LOCALPATH));
Git git = Git.cloneRepository()
.setURI(repoUrl)
.setDirectory(new File(LOCALPATH))
.setCredentialsProvider(new UsernamePasswordCredentialsProvider(username, password))
.call();
git.close();
}
@Test
public void test_file() throws IOException {
Files.walkFileTree(Path.of(LOCALPATH), new SimpleFileVisitor<>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
log.info("文件路径:{}", file.toString());
PathResource resource = new PathResource(file);
TikaDocumentReader reader = new TikaDocumentReader(resource);
List<Document> documents = reader.get();
List<Document> documentSplitterList = tokenTextSplitter.apply(documents);
documents.forEach(doc -> doc.getMetadata().put("knowledge", "java-trial"));
documentSplitterList.forEach(doc -> doc.getMetadata().put("knowledge", "java-trial"));
pgVectorStore.accept(documentSplitterList);
return super.visitFile(file, attrs);
}
});
}
}

View File

@@ -0,0 +1,92 @@
package com.storm.dev.text;
import com.alibaba.fastjson.JSON;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.ai.chat.ChatResponse;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.chat.prompt.SystemPromptTemplate;
import org.springframework.ai.document.Document;
import org.springframework.ai.ollama.OllamaChatClient;
import org.springframework.ai.ollama.api.OllamaApi;
import org.springframework.ai.ollama.api.OllamaOptions;
import org.springframework.ai.reader.tika.TikaDocumentReader;
import org.springframework.ai.transformer.splitter.TokenTextSplitter;
import org.springframework.ai.vectorstore.PgVectorStore;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.SimpleVectorStore;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;
import reactor.core.publisher.Flux;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* @author: lyd
* @date: 2026/1/14 21:47
*/
@Slf4j
@RunWith(SpringRunner.class)
@SpringBootTest
public class RAGApiTest {
@Resource
private OllamaChatClient ollamaChatClient;
@Resource
private TokenTextSplitter tokenTextSplitter;
@Resource
private SimpleVectorStore simpleVectorStore;
@Resource
private PgVectorStore pgVectorStore;
@Test
public void upload() {
// 上传
TikaDocumentReader reader = new TikaDocumentReader("./data/file.text");
List<Document> documents = reader.get();
List<Document> documentSplitterList = tokenTextSplitter.apply(documents);
// 打标
documents.forEach(document -> document.getMetadata().put("knowledge", "德德"));
documentSplitterList.forEach(document -> document.getMetadata().put("knowledge", "德德"));
pgVectorStore.accept(documentSplitterList);
log.info("上传完成!");
}
@Test
public void chat() {
// 构建提问
String message = "拆装出库的操作流程是什么?";
// 构建推理模板
String SYSTEM_PROMPT = """
Use the information from the DOCUMENTS section to provide accurate answers but act as if you knew this information innately.
If unsure, simply state that you don't know.
Another thing you need to note is that your reply must be in Chinese!
DOCUMENTS:
{documents}
""";
// 读取向量库信息
SearchRequest request = SearchRequest.query(message).withTopK(5).withFilterExpression("knowledge == '富士迈泰国项目软件方案'");
// 相似性搜索
List<Document> documents = pgVectorStore.similaritySearch(request);
String documentsCollectors = documents.stream().map(Document::getContent).collect(Collectors.joining());
// 推理RAG
Message ragMessage = new SystemPromptTemplate(SYSTEM_PROMPT).createMessage(Map.of("documents", documentsCollectors));
ArrayList<Message> messages = new ArrayList<>();
messages.add(new UserMessage(message));
messages.add(ragMessage);
// 提问
// ChatResponse chatResponse = ollamaChatClient.call(new Prompt(messages, OllamaOptions.create().withModel("deepseek-r1:7b")));
Flux<ChatResponse> stream = ollamaChatClient.stream(new Prompt(messages, OllamaOptions.create().withModel("deepseek-r1:7b")));
log.info("测试结果:{}", JSON.toJSONString(stream));
}
}

View File

@@ -0,0 +1 @@
李永德1999年12月31日出生福建泉州人。

View File

@@ -0,0 +1,85 @@
package com.storm.dev.trigger.http;
import com.storm.dev.api.IAiService;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.ChatResponse;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.chat.prompt.SystemPromptTemplate;
import org.springframework.ai.document.Document;
import org.springframework.ai.ollama.OllamaChatClient;
import org.springframework.ai.ollama.api.OllamaOptions;
import org.springframework.ai.vectorstore.PgVectorStore;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Flux;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* @author: lyd
* @date: 2025/6/7 22:18
*/
@Slf4j
@RestController()
@CrossOrigin("*")
@RequestMapping("/api/v1/ollama/")
public class OllamaController implements IAiService {
@Resource
private OllamaChatClient chatClient;
@Resource
private PgVectorStore pgVectorStore;
/**
* http://localhost:8090/api/v1/ollama/generate?model=deepseek-r1:7b&message=1+1
*/
@GetMapping("generate")
@Override
public ChatResponse generate(@RequestParam String model, @RequestParam String message) {
return chatClient.call(new Prompt(message, OllamaOptions.create().withModel(model)));
}
/**
* http://localhost:8090/api/v1/ollama/generate_stream?model=deepseek-r1:7b&message=hi
*/
@GetMapping("generate_stream")
@Override
public Flux<ChatResponse> generateStream(@RequestParam String model, @RequestParam String message) {
return chatClient.stream(new Prompt(message, OllamaOptions.create().withModel(model)));
}
@Override
@RequestMapping(value = "generate_stream_rag", method = RequestMethod.GET)
public Flux<ChatResponse> generateStreamRag(@RequestParam String model, @RequestParam String ragTag, @RequestParam String message) {
log.info("用户选择模型:{},知识库:{},提问问题:{}", model, ragTag, message);
// 构建推理模板
String SYSTEM_PROMPT = """
Use the information from the DOCUMENTS section to provide accurate answers but act as if you knew this information innately.
If unsure, simply state that you don't know.
Another thing you need to note is that your reply must be in Chinese!
DOCUMENTS:
{documents}
""";
// 读取向量库信息
SearchRequest request = SearchRequest.query(message).withTopK(5).withFilterExpression("knowledge == '" + ragTag + "'");
// 相似性搜索
List<Document> documents = pgVectorStore.similaritySearch(request);
String documentsCollectors = documents.stream().map(Document::getContent).collect(Collectors.joining());
// 推理RAG
Message ragMessage = new SystemPromptTemplate(SYSTEM_PROMPT).createMessage(Map.of("documents", documentsCollectors));
ArrayList<Message> messages = new ArrayList<>();
messages.add(new UserMessage(message));
messages.add(ragMessage);
// 提问
Flux<ChatResponse> chatResponse = chatClient.stream(new Prompt(messages, OllamaOptions.create().withModel(model)));
return chatResponse;
}
}

View File

@@ -0,0 +1,86 @@
package com.storm.dev.trigger.http;
import com.storm.dev.api.IAiService;
import jakarta.annotation.Resource;
import org.springframework.ai.chat.ChatResponse;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.chat.prompt.SystemPromptTemplate;
import org.springframework.ai.document.Document;
import org.springframework.ai.openai.OpenAiChatClient;
import org.springframework.ai.openai.OpenAiChatOptions;
import org.springframework.ai.vectorstore.PgVectorStore;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* @author: lyd
* @date: 2026/1/18 17:08
*/
@RestController()
@CrossOrigin("*")
@RequestMapping("/api/v1/openai/")
public class OpenAiController implements IAiService {
@Resource
private OpenAiChatClient chatClient;
@Resource
private PgVectorStore pgVectorStore;
@Override
public ChatResponse generate(String model, String message) {
return chatClient.call(new Prompt(message, OpenAiChatOptions.builder().withModel(model).build()));
}
@Override
public Flux<ChatResponse> generateStream(String model, String message) {
return chatClient.stream(new Prompt(
message,
OpenAiChatOptions.builder()
.withModel(model)
.build()
));
}
@Override
public Flux<ChatResponse> generateStreamRag(String model, String ragTag, String message) {
String SYSTEM_PROMPT = """
Use the information from the DOCUMENTS section to provide accurate answers but act as if you knew this information innately.
If unsure, simply state that you don't know.
Another thing you need to note is that your reply must be in Chinese!
DOCUMENTS:
{documents}
""";
// 指定文档搜索
SearchRequest request = SearchRequest.query(message)
.withTopK(5)
.withFilterExpression("knowledge == '" + ragTag + "'");
List<Document> documents = pgVectorStore.similaritySearch(request);
String documentCollectors = documents.stream().map(Document::getContent).collect(Collectors.joining());
Message ragMessage = new SystemPromptTemplate(SYSTEM_PROMPT).createMessage(Map.of("documents", documentCollectors));
List<Message> messages = new ArrayList<>();
messages.add(new UserMessage(message));
messages.add(ragMessage);
return chatClient.stream(new Prompt(
messages,
OpenAiChatOptions.builder()
.withModel(model)
.build()
));
}
}

View File

@@ -0,0 +1,187 @@
package com.storm.dev.trigger.http;
import com.alibaba.fastjson.JSON;
import com.storm.dev.api.IRAGService;
import com.storm.dev.api.response.Response;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.FileUtils;
import org.eclipse.jgit.api.Git;
import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider;
import org.redisson.api.RList;
import org.redisson.api.RedissonClient;
import org.springframework.ai.chat.ChatResponse;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.chat.prompt.SystemPromptTemplate;
import org.springframework.ai.document.Document;
import org.springframework.ai.ollama.OllamaChatClient;
import org.springframework.ai.ollama.api.OllamaOptions;
import org.springframework.ai.reader.tika.TikaDocumentReader;
import org.springframework.ai.transformer.splitter.TokenTextSplitter;
import org.springframework.ai.vectorstore.PgVectorStore;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.SimpleVectorStore;
import org.springframework.core.io.PathResource;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import reactor.core.publisher.Flux;
import java.io.File;
import java.io.IOException;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* @author: lyd
* @date: 2026/1/14 23:43
*/
@Slf4j
@RestController()
@CrossOrigin("*")
@RequestMapping("/api/v1/rag/")
public class RAGController implements IRAGService {
@Resource
private RedissonClient redissonClient;
@Resource
private OllamaChatClient ollamaChatClient;
@Resource
private TokenTextSplitter tokenTextSplitter;
@Resource
private SimpleVectorStore simpleVectorStore;
@Resource
private PgVectorStore pgVectorStore;
@Override
@RequestMapping(value = "query_rag_tag_list", method = RequestMethod.GET)
public Response<List<String>> queryRagTagList() {
RList<String> ragTag = redissonClient.getList("ragTag");
return Response.<List<String>>builder()
.code("0000")
.info("调用成功")
.data(ragTag)
.build();
}
@Override
@RequestMapping(value = "file/upload", method = RequestMethod.POST, headers = "content-type=multipart/form-data")
public Response<String> uploadFile(@RequestParam String ragTag, @RequestParam("file") List<MultipartFile> files) {
log.info("上传知识库开始 {}", ragTag);
for (MultipartFile file : files) {
// 上传
TikaDocumentReader reader = new TikaDocumentReader(file.getResource());
List<Document> documents = reader.get();
List<Document> documentSplitterList = tokenTextSplitter.apply(documents);
// 打标
documents.forEach(document -> document.getMetadata().put("knowledge", ragTag));
documentSplitterList.forEach(document -> document.getMetadata().put("knowledge", ragTag));
pgVectorStore.accept(documentSplitterList);
// 可以用MySQL存储
RList<String> elements = redissonClient.getList("ragTag");
if (!elements.contains(ragTag)){
elements.add(ragTag);
}
log.info("上传完成!");
}
return Response.<String>builder().code("0000").info("调用成功").build();
}
@Override
@RequestMapping(value = "generate_stream_rag", method = RequestMethod.GET)
public ChatResponse generateStreamRag(@RequestParam String model, @RequestParam String ragTag, @RequestParam String message) {
log.info("用户选择模型:{},知识库:{},提问问题:{}", model, ragTag, message);
// 构建推理模板
String SYSTEM_PROMPT = """
Use the information from the DOCUMENTS section to provide accurate answers but act as if you knew this information innately.
If unsure, simply state that you don't know.
Another thing you need to note is that your reply must be in Chinese!
DOCUMENTS:
{documents}
""";
// 读取向量库信息
SearchRequest request = SearchRequest.query(message).withTopK(5).withFilterExpression("knowledge == '" + ragTag + "'");
// 相似性搜索
List<Document> documents = pgVectorStore.similaritySearch(request);
String documentsCollectors = documents.stream().map(Document::getContent).collect(Collectors.joining());
// 推理RAG
Message ragMessage = new SystemPromptTemplate(SYSTEM_PROMPT).createMessage(Map.of("documents", documentsCollectors));
ArrayList<Message> messages = new ArrayList<>();
messages.add(new UserMessage(message));
messages.add(ragMessage);
// 提问
// Flux<ChatResponse> chatResponse = ollamaChatClient.stream(new Prompt(messages, OllamaOptions.create().withModel(model)));
ChatResponse call = ollamaChatClient.call(new Prompt(messages, OllamaOptions.create().withModel(model)));
log.info("测试结果:{}", call);
return call;
}
@RequestMapping(value = "analyze_git_repository", method = RequestMethod.POST)
@Override
public Response<String> analyzeGitRepository(@RequestParam String repoUrl, @RequestParam String userName, @RequestParam String token) throws Exception {
String localPath = "./git-cloned-repo";
String repoProjectName = extractProjectName(repoUrl);
log.info("克隆路径:{}", new File(localPath).getAbsolutePath());
FileUtils.deleteDirectory(new File(localPath));
Git git = Git.cloneRepository()
.setURI(repoUrl)
.setDirectory(new File(localPath))
.setCredentialsProvider(new UsernamePasswordCredentialsProvider(userName, token))
.call();
Files.walkFileTree(Paths.get(localPath), new SimpleFileVisitor<>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
log.info("{} 遍历解析路径,上传知识库:{}", repoProjectName, file.getFileName());
try {
TikaDocumentReader reader = new TikaDocumentReader(new PathResource(file));
List<Document> documents = reader.get();
List<Document> documentSplitterList = tokenTextSplitter.apply(documents);
documents.forEach(doc -> doc.getMetadata().put("knowledge", repoProjectName));
documentSplitterList.forEach(doc -> doc.getMetadata().put("knowledge", repoProjectName));
pgVectorStore.accept(documentSplitterList);
} catch (Exception e) {
log.error("遍历解析路径,上传知识库失败:{}", file.getFileName());
}
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult visitFileFailed(Path file, IOException exc) throws IOException {
log.info("Failed to access file: {} - {}", file.toString(), exc.getMessage());
return FileVisitResult.CONTINUE;
}
});
FileUtils.deleteDirectory(new File(localPath));
RList<String> elements = redissonClient.getList("ragTag");
if (!elements.contains(repoProjectName)) {
elements.add(repoProjectName);
}
git.close();
log.info("遍历解析路径,上传完成:{}", repoUrl);
return Response.<String>builder().code("0000").info("调用成功").build();
}
private String extractProjectName(String repoUrl) {
String[] parts = repoUrl.split("/");
String projectNameWithGit = parts[parts.length - 1];
return projectNameWithGit.replace(".git", "");
}
}

View File

@@ -0,0 +1,6 @@
/**
* 触发器
* @author: lyd
* @date: 2025/6/7 22:17
*/
package com.storm.dev.trigger;

View File

@@ -0,0 +1,43 @@
# docker-compose -f docker-compose-environment-aliyun.yml up -d
version: '3'
services:
# 对话模型
# ollama pull deepseek-r1:1.5b
# 运行模型
# ollama run deepseek-r1:1.5b
# 联网模型
# ollama pull nomic-embed-text
ollama:
image: registry.cn-hangzhou.aliyuncs.com/xfg-studio/ollama:0.5.10
container_name: ollama
restart: unless-stopped
ports:
- "11434:11434"
vector_db:
image: registry.cn-hangzhou.aliyuncs.com/xfg-studio/pgvector:v0.5.0
container_name: vector_db
restart: always
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- POSTGRES_DB=ai-rag-knowledge
- PGPASSWORD=postgres
volumes:
- ./pgvector/sql/init.sql:/docker-entrypoint-initdb.d/init.sql
logging:
options:
max-size: 10m
max-file: "3"
ports:
- '15432:5432'
healthcheck:
test: "pg_isready -U postgres -d ai-rag-knowledge"
interval: 2s
timeout: 20s
retries: 10
networks:
- my-network
networks:
my-network:
driver: bridge

View File

@@ -0,0 +1,121 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI Chat</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-100 h-screen">
<div class="container mx-auto max-w-3xl h-screen flex flex-col">
<!-- 消息容器 -->
<div id="messageContainer" class="flex-1 overflow-y-auto p-4 space-y-4 bg-white rounded-lg shadow-lg">
<!-- 消息历史将在此动态生成 -->
</div>
<!-- 输入区域 -->
<div class="p-4 bg-white rounded-lg shadow-lg mt-4">
<div class="flex space-x-2">
<input
type="text"
id="messageInput"
placeholder="输入消息..."
class="flex-1 p-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
onkeypress="handleKeyPress(event)"
>
<button
onclick="sendMessage()"
class="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
>
发送
</button>
</div>
</div>
</div>
<script>
// 添加消息到容器
function addMessage(content, isUser = false) {
const container = document.getElementById('messageContainer');
const messageDiv = document.createElement('div');
messageDiv.className = `flex ${isUser ? 'justify-end' : 'justify-start'}`;
messageDiv.innerHTML = `
<div class="max-w-[80%] p-3 rounded-lg ${
isUser ? 'bg-blue-500 text-white' : 'bg-gray-200 text-gray-800'
}">
${content}
</div>
`;
container.appendChild(messageDiv);
container.scrollTop = container.scrollHeight; // 滚动到底部
}
// 发送消息
async function sendMessage() {
const input = document.getElementById('messageInput');
const message = input.value.trim();
if (!message) return;
// 清空输入框
input.value = '';
// 添加用户消息
addMessage(message, true);
// 添加初始AI消息占位
addMessage('<span class="animate-pulse">▍</span>');
// 构建API URL
const apiUrl = `http://localhost:8090/api/v1/ollama/generate_stream?model=deepseek-r1:7b&message=${encodeURIComponent(message)}`;
// 使用EventSource接收流式响应
const eventSource = new EventSource(apiUrl);
let buffer = '';
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
const content = data.result?.output?.content || '';
const finishReason = data.result?.metadata?.finishReason;
if (content) {
buffer += content;
updateLastMessage(buffer + '<span class="animate-pulse">▍</span>');
}
if (finishReason === 'STOP') {
eventSource.close();
updateLastMessage(buffer); // 移除加载动画
}
} catch (error) {
console.error('解析错误:', error);
}
};
eventSource.onerror = (error) => {
console.error('EventSource错误:', error);
eventSource.close();
};
}
// 更新最后一条消息
function updateLastMessage(content) {
const container = document.getElementById('messageContainer');
const lastMessage = container.lastChild.querySelector('div');
lastMessage.innerHTML = content;
container.scrollTop = container.scrollHeight;
}
// 回车发送
function handleKeyPress(event) {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
sendMessage();
}
}
</script>
</body>
</html>

190
docs/nginx/html/index.html Normal file
View File

@@ -0,0 +1,190 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI Chat</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
/* Custom scrollbar for chat container */
.chat-container::-webkit-scrollbar {
width: 6px;
}
.chat-container::-webkit-scrollbar-track {
background: #f3f4f6;
}
.chat-container::-webkit-scrollbar-thumb {
background: #d1d5db;
border-radius: 3px;
}
.chat-container::-webkit-scrollbar-thumb:hover {
background: #9ca3af;
}
</style>
</head>
<body class="bg-gray-100 min-h-screen flex flex-col">
<div class="container mx-auto px-4 py-8 max-w-4xl">
<!-- Header -->
<div class="text-center mb-8">
<h1 class="text-3xl font-bold text-gray-800">AI Chat</h1>
<p class="text-gray-600 mt-2">Simple AI conversation powered by Ollama</p>
</div>
<!-- Chat Container -->
<div id="chatContainer" class="chat-container bg-white rounded-lg shadow-lg h-96 overflow-y-auto mb-4 p-4 space-y-4 flex flex-col">
<!-- Messages will be appended here -->
</div>
<!-- Input Form -->
<form id="messageForm" class="flex space-x-2">
<input
type="text"
id="messageInput"
placeholder="Type your message..."
class="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
required
>
<button
type="submit"
class="px-6 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
id="sendButton"
>
Send
</button>
</form>
</div>
<script>
const API_BASE = 'http://localhost:8090/api/v1/ollama/generate_stream';
const MODEL = 'deepseek-r1:7b';
const chatContainer = document.getElementById('chatContainer');
const messageInput = document.getElementById('messageInput');
const sendButton = document.getElementById('sendButton');
const messageForm = document.getElementById('messageForm');
let currentEventSource = null;
let currentAIMessageElement = null;
// Function to add user message to chat
function addUserMessage(message) {
const messageDiv = document.createElement('div');
messageDiv.className = 'flex justify-end mb-4';
messageDiv.innerHTML = `
<div class="max-w-xs lg:max-w-md px-4 py-2 bg-blue-500 text-white rounded-lg rounded-tr-sm">
${escapeHtml(message)}
</div>
`;
chatContainer.appendChild(messageDiv);
chatContainer.scrollTop = chatContainer.scrollHeight;
}
// Function to add AI message container
function addAIMessageContainer() {
const messageDiv = document.createElement('div');
messageDiv.className = 'flex justify-start mb-4';
messageDiv.id = 'aiMessage';
messageDiv.innerHTML = `
<div class="max-w-xs lg:max-w-md px-4 py-2 bg-gray-200 text-gray-800 rounded-lg rounded-tl-sm">
<span id="aiContent"></span>
</div>
`;
chatContainer.appendChild(messageDiv);
currentAIMessageElement = document.getElementById('aiContent');
chatContainer.scrollTop = chatContainer.scrollHeight;
}
// Function to append text to AI message
function appendToAIMessage(text) {
if (currentAIMessageElement && text) {
currentAIMessageElement.textContent += text;
chatContainer.scrollTop = chatContainer.scrollHeight;
}
}
// Function to escape HTML
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Function to close EventSource
function closeEventSource() {
if (currentEventSource) {
currentEventSource.close();
currentEventSource = null;
}
}
// Form submit handler
messageForm.addEventListener('submit', async (e) => {
e.preventDefault();
const message = messageInput.value.trim();
if (!message || currentEventSource) return; // Prevent multiple requests
// Add user message
addUserMessage(message);
// Clear input
messageInput.value = '';
// Disable send button
sendButton.disabled = true;
sendButton.textContent = 'Sending...';
// Add AI message container
addAIMessageContainer();
// Prepare API URL
const encodedMessage = encodeURIComponent(message);
const apiUrl = `${API_BASE}?model=${MODEL}&message=${encodedMessage}`;
// Create EventSource for streaming
currentEventSource = new EventSource(apiUrl);
currentEventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data && data.result) {
const content = data.result.output?.content || '';
const finishReason = data.result.metadata?.finishReason;
// Append content if not empty
if (content) {
appendToAIMessage(content);
}
// Check for end of stream
if (finishReason === 'STOP') {
closeEventSource();
sendButton.disabled = false;
sendButton.textContent = 'Send';
currentAIMessageElement = null;
}
}
} catch (error) {
console.error('Error parsing SSE data:', error);
}
};
currentEventSource.onerror = (error) => {
console.error('EventSource failed:', error);
closeEventSource();
sendButton.disabled = false;
sendButton.textContent = 'Send';
console.log(currentAIMessageElement)
// if (currentAIMessageElement) {
// currentAIMessageElement.textContent += ' (Error: Connection failed)';
// }
};
});
// Handle input focus to scroll to bottom
messageInput.addEventListener('focus', () => {
setTimeout(() => {
chatContainer.scrollTop = chatContainer.scrollHeight;
}, 100);
});
</script>
</body>
</html>

1285
docs/nginx/html/rag-ai.html Normal file

File diff suppressed because it is too large Load Diff