mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-05-08 03:37:28 +08:00
feat: add OpenAI markdown format endpoint
This commit is contained in:
@@ -55,6 +55,8 @@ OpenIsle 基于 Spring Boot 构建,提供社区后台常见的注册、登录
|
|||||||
- `CAPTCHA_LOGIN_ENABLED`:登录是否需要验证码
|
- `CAPTCHA_LOGIN_ENABLED`:登录是否需要验证码
|
||||||
- `CAPTCHA_POST_ENABLED`:发帖是否需要验证码
|
- `CAPTCHA_POST_ENABLED`:发帖是否需要验证码
|
||||||
- `CAPTCHA_COMMENT_ENABLED`:评论是否需要验证码
|
- `CAPTCHA_COMMENT_ENABLED`:评论是否需要验证码
|
||||||
|
- `OPENAI_API_KEY`:OpenAI 接口密钥
|
||||||
|
- `OPENAI_MODEL`:调用的模型名称,默认为 `gpt-3.5-turbo`
|
||||||
2. 启动项目:
|
2. 启动项目:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="post-editor-container">
|
<div class="post-editor-container">
|
||||||
<div :id="editorId" ref="vditorElement"></div>
|
<div :id="editorId" ref="vditorElement"></div>
|
||||||
|
<div v-if="loading" class="editor-loading-overlay">
|
||||||
|
<i class="fa-solid fa-spinner fa-spin"></i>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -22,11 +25,24 @@ export default {
|
|||||||
editorId: {
|
editorId: {
|
||||||
type: String,
|
type: String,
|
||||||
default: () => 'post-editor-' + Math.random().toString(36).slice(2)
|
default: () => 'post-editor-' + Math.random().toString(36).slice(2)
|
||||||
|
},
|
||||||
|
loading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
setup(props, { emit }) {
|
setup(props, { emit }) {
|
||||||
const vditorInstance = ref(null)
|
const vditorInstance = ref(null)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.loading,
|
||||||
|
val => {
|
||||||
|
if (vditorInstance.value && typeof vditorInstance.value.disabled === 'function') {
|
||||||
|
vditorInstance.value.disabled(val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.modelValue,
|
() => props.modelValue,
|
||||||
val => {
|
val => {
|
||||||
@@ -109,5 +125,19 @@ export default {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.post-editor-container {
|
.post-editor-container {
|
||||||
border: 1px solid #e2e2e2;
|
border: 1px solid #e2e2e2;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.editor-loading-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(255, 255, 255, 0.6);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
pointer-events: all;
|
||||||
|
z-index: 10;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<div class="new-post-form">
|
<div class="new-post-form">
|
||||||
<input class="post-title-input" v-model="title" placeholder="标题" />
|
<input class="post-title-input" v-model="title" placeholder="标题" />
|
||||||
<div class="post-editor-container">
|
<div class="post-editor-container">
|
||||||
<PostEditor v-model="content" />
|
<PostEditor v-model="content" :loading="isAiLoading" />
|
||||||
</div>
|
</div>
|
||||||
<div class="post-options">
|
<div class="post-options">
|
||||||
<div class="post-options-left">
|
<div class="post-options-left">
|
||||||
@@ -47,6 +47,7 @@ export default {
|
|||||||
const selectedCategory = ref('')
|
const selectedCategory = ref('')
|
||||||
const selectedTags = ref([])
|
const selectedTags = ref([])
|
||||||
const isWaitingPosting = ref(false)
|
const isWaitingPosting = ref(false)
|
||||||
|
const isAiLoading = ref(false)
|
||||||
|
|
||||||
const loadDraft = async () => {
|
const loadDraft = async () => {
|
||||||
const token = getToken()
|
const token = getToken()
|
||||||
@@ -154,6 +155,31 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const aiGenerate = async () => {
|
||||||
|
if (!content.value.trim()) {
|
||||||
|
toast.error('内容为空,无法优化')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isAiLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/ai/format`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ text: content.value })
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
content.value = data.content || ''
|
||||||
|
} else {
|
||||||
|
toast.error('AI 优化失败')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast.error('AI 优化失败')
|
||||||
|
} finally {
|
||||||
|
isAiLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
const submitPost = async () => {
|
const submitPost = async () => {
|
||||||
if (!title.value.trim()) {
|
if (!title.value.trim()) {
|
||||||
toast.error('标题不能为空')
|
toast.error('标题不能为空')
|
||||||
@@ -203,7 +229,7 @@ export default {
|
|||||||
isWaitingPosting.value = false
|
isWaitingPosting.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { title, content, selectedCategory, selectedTags, submitPost, saveDraft, clearPost, isWaitingPosting }
|
return { title, content, selectedCategory, selectedTags, submitPost, saveDraft, clearPost, isWaitingPosting, aiGenerate, isAiLoading }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
30
src/main/java/com/openisle/controller/AiController.java
Normal file
30
src/main/java/com/openisle/controller/AiController.java
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
package com.openisle.controller;
|
||||||
|
|
||||||
|
import com.openisle.service.OpenAiService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/ai")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AiController {
|
||||||
|
|
||||||
|
private final OpenAiService openAiService;
|
||||||
|
|
||||||
|
@PostMapping("/format")
|
||||||
|
public ResponseEntity<Map<String, String>> format(@RequestBody Map<String, String> req) {
|
||||||
|
String text = req.get("text");
|
||||||
|
if (text == null) {
|
||||||
|
return ResponseEntity.badRequest().build();
|
||||||
|
}
|
||||||
|
return openAiService.formatMarkdown(text)
|
||||||
|
.map(t -> ResponseEntity.ok(Map.of("content", t)))
|
||||||
|
.orElse(ResponseEntity.status(500).build());
|
||||||
|
}
|
||||||
|
}
|
||||||
65
src/main/java/com/openisle/service/OpenAiService.java
Normal file
65
src/main/java/com/openisle/service/OpenAiService.java
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
package com.openisle.service;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.http.HttpEntity;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.HttpMethod;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class OpenAiService {
|
||||||
|
|
||||||
|
@Value("${openai.api-key:}")
|
||||||
|
private String apiKey;
|
||||||
|
|
||||||
|
@Value("${openai.model:gpt-3.5-turbo}")
|
||||||
|
private String model;
|
||||||
|
|
||||||
|
private final RestTemplate restTemplate = new RestTemplate();
|
||||||
|
|
||||||
|
public Optional<String> formatMarkdown(String text) {
|
||||||
|
if (apiKey == null || apiKey.isBlank()) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
String url = "https://api.openai.com/v1/chat/completions";
|
||||||
|
|
||||||
|
HttpHeaders headers = new HttpHeaders();
|
||||||
|
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||||
|
headers.set("Authorization", "Bearer " + apiKey);
|
||||||
|
|
||||||
|
Map<String, Object> body = new HashMap<>();
|
||||||
|
body.put("model", model);
|
||||||
|
List<Map<String, String>> messages = new ArrayList<>();
|
||||||
|
messages.add(Map.of("role", "system", "content", "请优化以下 Markdown 文本的格式,不改变其内容。"));
|
||||||
|
messages.add(Map.of("role", "user", "content", text));
|
||||||
|
body.put("messages", messages);
|
||||||
|
|
||||||
|
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(body, headers);
|
||||||
|
try {
|
||||||
|
ResponseEntity<Map> resp = restTemplate.exchange(url, HttpMethod.POST, entity, Map.class);
|
||||||
|
Map respBody = resp.getBody();
|
||||||
|
if (respBody != null) {
|
||||||
|
Object choicesObj = respBody.get("choices");
|
||||||
|
if (choicesObj instanceof List choices && !choices.isEmpty()) {
|
||||||
|
Object first = choices.get(0);
|
||||||
|
if (first instanceof Map firstMap) {
|
||||||
|
Object messageObj = firstMap.get("message");
|
||||||
|
if (messageObj instanceof Map message) {
|
||||||
|
Object content = message.get("content");
|
||||||
|
if (content instanceof String str) {
|
||||||
|
return Optional.of(str.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,3 +46,6 @@ cos.bucket-name=${COS_BUCKET_NAME:}
|
|||||||
|
|
||||||
# Google OAuth configuration
|
# Google OAuth configuration
|
||||||
google.client-id=${GOOGLE_CLIENT_ID:}
|
google.client-id=${GOOGLE_CLIENT_ID:}
|
||||||
|
# OpenAI configuration
|
||||||
|
openai.api-key=${OPENAI_API_KEY:}
|
||||||
|
openai.model=${OPENAI_MODEL:gpt-3.5-turbo}
|
||||||
|
|||||||
Reference in New Issue
Block a user