mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-19 21:41:03 +08:00
Compare commits
13 Commits
feat_categ
...
codex/crea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bcd6a3249d | ||
|
|
a24bd81942 | ||
|
|
8a008a090a | ||
|
|
5dfb69e636 | ||
|
|
499069573e | ||
|
|
636912941a | ||
|
|
bdcc1488b9 | ||
|
|
d33bd233af | ||
|
|
efe4b97d83 | ||
|
|
8a256e167d | ||
|
|
458b125834 | ||
|
|
971a3d36c6 | ||
|
|
e5d66d73cb |
@@ -7,6 +7,15 @@ REDIS_PORT=6379
|
||||
RABBITMQ_PORT=5672
|
||||
RABBITMQ_MANAGEMENT_PORT=15672
|
||||
|
||||
# === MCP Server ===
|
||||
OPENISLE_MCP_TRANSPORT=http
|
||||
OPENISLE_MCP_HOST=0.0.0.0
|
||||
OPENISLE_MCP_PORT=8974
|
||||
OPENISLE_API_BASE_URL=http://springboot:8080
|
||||
OPENISLE_API_TIMEOUT=10
|
||||
OPENISLE_MCP_DEFAULT_LIMIT=20
|
||||
OPENISLE_MCP_SNIPPET_LENGTH=160
|
||||
|
||||
# === OpenSearch Configuration ===
|
||||
OPENSEARCH_PORT=9200
|
||||
OPENSEARCH_METRICS_PORT=9600
|
||||
|
||||
@@ -28,6 +28,7 @@ OpenIsle 是一个使用 Spring Boot 和 Vue 3 构建的全栈开源社区平台
|
||||
- 支持图片上传,默认使用腾讯云 COS 扩展
|
||||
- 默认头像使用 DiceBear Avatars,可通过 `AVATAR_STYLE` 和 `AVATAR_SIZE` 环境变量自定义主题和大小
|
||||
- 浏览器推送通知,离开网站也能及时收到提醒
|
||||
- 新增 Python MCP 搜索服务,方便 AI 助手通过统一协议检索社区内容
|
||||
|
||||
## 🌟 项目优势
|
||||
|
||||
|
||||
@@ -66,6 +66,7 @@ public class PostController {
|
||||
req.getContent(),
|
||||
req.getTagIds(),
|
||||
req.getType(),
|
||||
req.getPostVisibleScopeType(),
|
||||
req.getPrizeDescription(),
|
||||
req.getPrizeIcon(),
|
||||
req.getPrizeCount(),
|
||||
@@ -103,7 +104,8 @@ public class PostController {
|
||||
req.getCategoryId(),
|
||||
req.getTitle(),
|
||||
req.getContent(),
|
||||
req.getTagIds()
|
||||
req.getTagIds(),
|
||||
req.getPostVisibleScopeType()
|
||||
);
|
||||
return ResponseEntity.ok(postMapper.toDetailDto(post, auth.getName()));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.openisle.dto;
|
||||
|
||||
import com.openisle.model.PostChangeType;
|
||||
import com.openisle.model.PostVisibleScopeType;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import lombok.Getter;
|
||||
@@ -29,5 +30,7 @@ public class PostChangeLogDto {
|
||||
private LocalDateTime newPinnedAt;
|
||||
private Boolean oldFeatured;
|
||||
private Boolean newFeatured;
|
||||
private PostVisibleScopeType oldVisibleScope;
|
||||
private PostVisibleScopeType newVisibleScope;
|
||||
private Integer amount;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ package com.openisle.dto;
|
||||
import com.openisle.model.PostType;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
import com.openisle.model.PostVisibleScopeType;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
@@ -19,6 +21,7 @@ public class PostRequest {
|
||||
|
||||
// optional for lottery posts
|
||||
private PostType type;
|
||||
private PostVisibleScopeType postVisibleScopeType;
|
||||
private String prizeDescription;
|
||||
private String prizeIcon;
|
||||
private Integer prizeCount;
|
||||
|
||||
@@ -4,6 +4,8 @@ import com.openisle.model.PostStatus;
|
||||
import com.openisle.model.PostType;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
import com.openisle.model.PostVisibleScopeType;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
@@ -34,4 +36,5 @@ public class PostSummaryDto {
|
||||
private PollDto poll;
|
||||
private boolean rssExcluded;
|
||||
private boolean closed;
|
||||
private PostVisibleScopeType visibleScope;
|
||||
}
|
||||
|
||||
@@ -52,6 +52,9 @@ public class PostChangeLogMapper {
|
||||
} else if (log instanceof PostFeaturedChangeLog f) {
|
||||
dto.setOldFeatured(f.isOldFeatured());
|
||||
dto.setNewFeatured(f.isNewFeatured());
|
||||
} else if (log instanceof PostVisibleScopeChangeLog v) {
|
||||
dto.setOldVisibleScope(v.getOldVisibleScope());
|
||||
dto.setNewVisibleScope(v.getNewVisibleScope());
|
||||
} else if (log instanceof PostDonateChangeLog d) {
|
||||
dto.setAmount(d.getAmount());
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ public class PostMapper {
|
||||
dto.setPinnedAt(post.getPinnedAt());
|
||||
dto.setRssExcluded(post.getRssExcluded() == null || post.getRssExcluded());
|
||||
dto.setClosed(post.isClosed());
|
||||
dto.setVisibleScope(post.getVisibleScope());
|
||||
|
||||
List<ReactionDto> reactions = reactionService
|
||||
.getReactionsForPost(post.getId())
|
||||
|
||||
@@ -66,6 +66,10 @@ public class Post {
|
||||
@Column(nullable = false)
|
||||
private PostType type = PostType.NORMAL;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false)
|
||||
private PostVisibleScopeType visibleScope = PostVisibleScopeType.ALL;
|
||||
|
||||
@Column(nullable = false)
|
||||
private boolean closed = false;
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ public enum PostChangeType {
|
||||
CLOSED,
|
||||
PINNED,
|
||||
FEATURED,
|
||||
VISIBLE_SCOPE,
|
||||
VOTE_RESULT,
|
||||
LOTTERY_RESULT,
|
||||
DONATE,
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.openisle.model;
|
||||
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.Table;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@Entity
|
||||
@Table(name = "post_visible_scope_change_logs")
|
||||
public class PostVisibleScopeChangeLog extends PostChangeLog {
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
private PostVisibleScopeType oldVisibleScope;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
private PostVisibleScopeType newVisibleScope;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.openisle.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonCreator;
|
||||
import com.fasterxml.jackson.annotation.JsonValue;
|
||||
|
||||
public enum PostVisibleScopeType {
|
||||
ALL,
|
||||
ONLY_ME,
|
||||
ONLY_REGISTER;
|
||||
|
||||
/**
|
||||
* 防止画面传递错误的值
|
||||
* @param value
|
||||
* @return
|
||||
*/
|
||||
@JsonCreator
|
||||
public static PostVisibleScopeType fromString(String value) {
|
||||
if (value == null) return ALL;
|
||||
for (PostVisibleScopeType type : PostVisibleScopeType.values()) {
|
||||
if (type.name().equalsIgnoreCase(value)) {
|
||||
return type;
|
||||
}
|
||||
}
|
||||
// 不匹配时给默认值,而不是抛异常
|
||||
return ALL;
|
||||
}
|
||||
|
||||
@JsonValue
|
||||
public String toValue() {
|
||||
return this.name();
|
||||
}
|
||||
}
|
||||
@@ -99,6 +99,21 @@ public class PostChangeLogService {
|
||||
logRepository.save(log);
|
||||
}
|
||||
|
||||
public void recordVisibleScopeChange(
|
||||
Post post,
|
||||
User user,
|
||||
PostVisibleScopeType oldVisibleScope,
|
||||
PostVisibleScopeType newVisibleScope
|
||||
) {
|
||||
PostVisibleScopeChangeLog log = new PostVisibleScopeChangeLog();
|
||||
log.setPost(post);
|
||||
log.setUser(user);
|
||||
log.setType(PostChangeType.VISIBLE_SCOPE);
|
||||
log.setOldVisibleScope(oldVisibleScope);
|
||||
log.setNewVisibleScope(newVisibleScope);
|
||||
logRepository.save(log);
|
||||
}
|
||||
|
||||
public void recordVoteResult(Post post) {
|
||||
PostVoteResultChangeLog log = new PostVoteResultChangeLog();
|
||||
log.setPost(post);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.openisle.service;
|
||||
|
||||
import com.openisle.config.CachingConfig;
|
||||
import com.openisle.exception.NotFoundException;
|
||||
import com.openisle.exception.RateLimitException;
|
||||
import com.openisle.model.*;
|
||||
import com.openisle.repository.CategoryProposalPostRepository;
|
||||
@@ -252,6 +253,7 @@ public class PostService {
|
||||
String content,
|
||||
List<Long> tagIds,
|
||||
PostType type,
|
||||
PostVisibleScopeType postVisibleScopeType,
|
||||
String prizeDescription,
|
||||
String prizeIcon,
|
||||
Integer prizeCount,
|
||||
@@ -336,6 +338,14 @@ public class PostService {
|
||||
post.setCategory(category);
|
||||
post.setTags(new HashSet<>(tags));
|
||||
post.setStatus(publishMode == PublishMode.REVIEW ? PostStatus.PENDING : PostStatus.PUBLISHED);
|
||||
|
||||
// 什么都没设置的情况下,默认为ALL
|
||||
if (Objects.isNull(postVisibleScopeType)) {
|
||||
post.setVisibleScope(PostVisibleScopeType.ALL);
|
||||
} else {
|
||||
post.setVisibleScope(postVisibleScopeType);
|
||||
}
|
||||
|
||||
if (post instanceof LotteryPost) {
|
||||
post = lotteryPostRepository.save((LotteryPost) post);
|
||||
} else if (post instanceof CategoryProposalPost categoryProposalPost) {
|
||||
@@ -716,7 +726,7 @@ public class PostService {
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
|
||||
if (post.getStatus() != PostStatus.PUBLISHED) {
|
||||
if (viewer == null) {
|
||||
throw new com.openisle.exception.NotFoundException("Post not found");
|
||||
throw new com.openisle.exception.NotFoundException("User not found");
|
||||
}
|
||||
User viewerUser = userRepository
|
||||
.findByUsername(viewer)
|
||||
@@ -1147,7 +1157,8 @@ public class PostService {
|
||||
Long categoryId,
|
||||
String title,
|
||||
String content,
|
||||
java.util.List<Long> tagIds
|
||||
List<Long> tagIds,
|
||||
PostVisibleScopeType postVisibleScopeType
|
||||
) {
|
||||
if (tagIds == null || tagIds.isEmpty()) {
|
||||
throw new IllegalArgumentException("At least one tag required");
|
||||
@@ -1179,6 +1190,8 @@ public class PostService {
|
||||
post.setContent(content);
|
||||
post.setCategory(category);
|
||||
post.setTags(new java.util.HashSet<>(tags));
|
||||
PostVisibleScopeType oldVisibleScope = post.getVisibleScope();
|
||||
post.setVisibleScope(postVisibleScopeType);
|
||||
Post updated = postRepository.save(post);
|
||||
imageUploader.adjustReferences(oldContent, content);
|
||||
notificationService.notifyMentions(content, user, updated, null);
|
||||
@@ -1200,6 +1213,14 @@ public class PostService {
|
||||
if (!oldTags.equals(newTags)) {
|
||||
postChangeLogService.recordTagChange(updated, user, oldTags, newTags);
|
||||
}
|
||||
if (!java.util.Objects.equals(oldVisibleScope, postVisibleScopeType)) {
|
||||
postChangeLogService.recordVisibleScopeChange(
|
||||
updated,
|
||||
user,
|
||||
oldVisibleScope,
|
||||
postVisibleScopeType
|
||||
);
|
||||
}
|
||||
if (updated.getStatus() == PostStatus.PUBLISHED) {
|
||||
searchIndexEventPublisher.publishPostSaved(updated);
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE posts ADD COLUMN visible_scope ENUM('ALL', 'ONLY_ME', 'ONLY_REGISTER') NOT NULL DEFAULT 'ALL'
|
||||
@@ -36,7 +36,6 @@ echo "👉 Pull base images (for image-based services)..."
|
||||
docker compose -f "$compose_file" --env-file "$env_file" pull --ignore-pull-failures
|
||||
|
||||
echo "👉 Build images (staging)..."
|
||||
# 前端 + OpenSearch 都是自建镜像;--pull 更新其基础镜像
|
||||
docker compose -f "$compose_file" --env-file "$env_file" \
|
||||
build --pull \
|
||||
--build-arg NUXT_ENV=staging \
|
||||
|
||||
@@ -161,8 +161,8 @@ services:
|
||||
condition: service_started
|
||||
websocket-service:
|
||||
condition: service_healthy
|
||||
opensearch:
|
||||
condition: service_healthy
|
||||
# opensearch:
|
||||
# condition: service_healthy
|
||||
command: >
|
||||
sh -c "apt-get update && apt-get install -y --no-install-recommends curl &&
|
||||
mvn clean spring-boot:run -Dmaven.test.skip=true"
|
||||
@@ -178,6 +178,34 @@ services:
|
||||
- dev
|
||||
- prod
|
||||
|
||||
mcp-server:
|
||||
build:
|
||||
context: ../mcp
|
||||
dockerfile: Dockerfile
|
||||
container_name: ${COMPOSE_PROJECT_NAME}-openisle-mcp
|
||||
env_file:
|
||||
- ${ENV_FILE:-../.env}
|
||||
environment:
|
||||
OPENISLE_API_BASE_URL: ${OPENISLE_API_BASE_URL:-http://springboot:8080}
|
||||
OPENISLE_API_TIMEOUT: ${OPENISLE_API_TIMEOUT:-10}
|
||||
OPENISLE_MCP_DEFAULT_LIMIT: ${OPENISLE_MCP_DEFAULT_LIMIT:-20}
|
||||
OPENISLE_MCP_SNIPPET_LENGTH: ${OPENISLE_MCP_SNIPPET_LENGTH:-160}
|
||||
OPENISLE_MCP_TRANSPORT: ${OPENISLE_MCP_TRANSPORT:-http}
|
||||
OPENISLE_MCP_HOST: 0.0.0.0
|
||||
OPENISLE_MCP_PORT: ${OPENISLE_MCP_PORT:-8974}
|
||||
ports:
|
||||
- "${OPENISLE_MCP_PORT:-8974}:${OPENISLE_MCP_PORT:-8974}"
|
||||
depends_on:
|
||||
springboot:
|
||||
condition: service_started
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- openisle-network
|
||||
profiles:
|
||||
- dev
|
||||
- dev_local_backend
|
||||
- prod
|
||||
|
||||
websocket-service:
|
||||
image: maven:3.9-eclipse-temurin-17
|
||||
container_name: ${COMPOSE_PROJECT_NAME}-openisle-websocket
|
||||
|
||||
@@ -543,6 +543,7 @@ onMounted(async () => {
|
||||
.header-label {
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 在线人数的数字文字样式(无背景) */
|
||||
|
||||
@@ -3,15 +3,30 @@
|
||||
<div class="login-overlay-blur"></div>
|
||||
<div class="login-overlay-content">
|
||||
<user-icon class="login-overlay-icon" />
|
||||
<div class="login-overlay-text">请先登录,点击跳转到登录页面</div>
|
||||
<div class="login-overlay-button" @click="goLogin">登录</div>
|
||||
<div class="login-overlay-text">{{ props.text }}</div>
|
||||
<div class="login-overlay-button" @click="goLogin">{{ props.buttonText }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
text: {
|
||||
type: String,
|
||||
default: '请先登录,点击跳转到登录页面',
|
||||
},
|
||||
buttonText: {
|
||||
type: String,
|
||||
default: '登录',
|
||||
},
|
||||
buttonLink: {
|
||||
type: String,
|
||||
default: '/login',
|
||||
},
|
||||
})
|
||||
|
||||
const goLogin = () => {
|
||||
navigateTo('/login', { replace: true })
|
||||
navigateTo(props.buttonLink, { replace: true })
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -36,6 +36,10 @@
|
||||
<template v-if="log.newFeatured">将文章设为精选</template>
|
||||
<template v-else>取消精选文章</template>
|
||||
</span>
|
||||
<span v-else-if="log.type === 'VISIBLE_SCOPE'" class="change-log-content">
|
||||
变更了文章可见范围, 从 {{ formatVisibleScope(log.oldVisibleScope) }} 修改为
|
||||
{{ formatVisibleScope(log.newVisibleScope) }}
|
||||
</span>
|
||||
<span v-else-if="log.type === 'VOTE_RESULT'" class="change-log-content"
|
||||
>系统已计算投票结果</span
|
||||
>
|
||||
@@ -69,6 +73,17 @@ const props = defineProps({
|
||||
title: String,
|
||||
})
|
||||
|
||||
const VISIBLE_SCOPE_LABELS = {
|
||||
ALL: '全部可见',
|
||||
ONLY_ME: '仅自己可见',
|
||||
ONLY_REGISTER: '仅注册用户可见',
|
||||
}
|
||||
|
||||
const formatVisibleScope = (scope) => {
|
||||
if (!scope) return VISIBLE_SCOPE_LABELS.ALL
|
||||
return VISIBLE_SCOPE_LABELS[scope] ?? scope
|
||||
}
|
||||
|
||||
const diffHtml = computed(() => {
|
||||
// Track theme changes
|
||||
const isDark = import.meta.client && document.documentElement.dataset.theme === 'dark'
|
||||
|
||||
41
frontend_nuxt/components/PostVisibleScopeSelect.vue
Normal file
41
frontend_nuxt/components/PostVisibleScopeSelect.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<Dropdown
|
||||
v-model="selected"
|
||||
:fetch-options="fetchTypes"
|
||||
placeholder="选择帖子可见范围"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import Dropdown from '~/components/Dropdown.vue'
|
||||
|
||||
export default {
|
||||
name: 'PostVisibleScopeSelect',
|
||||
components: { Dropdown },
|
||||
props: {
|
||||
modelValue: { type: String, default: 'ALL' },
|
||||
// options: { type: Array, default: () => [] },
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup(props, { emit }) {
|
||||
|
||||
const fetchTypes = async () => {
|
||||
return [
|
||||
{ id: 'ALL', name: '全部可见', icon: 'communication' },
|
||||
{ id: 'ONLY_ME', name: '仅自己可见', icon: 'user-icon' },
|
||||
{ id: 'ONLY_REGISTER', name: '仅注册用户可见', icon: 'peoples-two' },
|
||||
]
|
||||
}
|
||||
|
||||
const selected = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v),
|
||||
})
|
||||
|
||||
return { fetchTypes, selected }
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -70,6 +70,7 @@
|
||||
<hands v-else-if="article.type === 'PROPOSAL'" class="proposal-icon" />
|
||||
<star v-if="!article.rssExcluded" class="featured-icon" />
|
||||
{{ article.title }}
|
||||
<lock class="preview-close-icon" v-if="article.isRestricted" />
|
||||
</NuxtLink>
|
||||
<NuxtLink class="article-item-description main-item" :to="`/posts/${article.id}`">
|
||||
<div v-html="stripMarkdownWithTiebaMoji(article.description, 500)"></div>
|
||||
@@ -295,6 +296,7 @@ const {
|
||||
comments: p.commentCount,
|
||||
views: p.views,
|
||||
rssExcluded: p.rssExcluded || false,
|
||||
isRestricted: p.visibleScope === 'ONLY_ME' || p.visibleScope === 'ONLY_REGISTER',
|
||||
time: TimeManager.format(
|
||||
selectedTopic.value === '最新回复' ? p.lastReplyAt || p.createdAt : p.createdAt,
|
||||
),
|
||||
@@ -336,6 +338,7 @@ const fetchNextPage = async () => {
|
||||
members: (p.participants || []).map((m) => ({ id: m.id, avatar: m.avatar })),
|
||||
comments: p.commentCount,
|
||||
views: p.views,
|
||||
isRestricted: p.visibleScope === 'ONLY_ME' || p.visibleScope === 'ONLY_REGISTER',
|
||||
rssExcluded: p.rssExcluded || false,
|
||||
time: TimeManager.format(
|
||||
selectedTopic.value === '最新回复' ? p.lastReplyAt || p.createdAt : p.createdAt,
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
<CategorySelect v-model="selectedCategory" />
|
||||
<TagSelect v-model="selectedTags" creatable />
|
||||
<PostTypeSelect v-model="postType" />
|
||||
<PostVisibleScopeSelect v-model="postVisibleScope"/>
|
||||
</div>
|
||||
<div class="post-options-right">
|
||||
<div class="post-clear" @click="clearPost"><clear-icon /> 清空</div>
|
||||
@@ -54,6 +55,7 @@ import PollForm from '~/components/PollForm.vue'
|
||||
import ProposalForm from '~/components/ProposalForm.vue'
|
||||
import { toast } from '~/main'
|
||||
import { authState, getToken } from '~/utils/auth'
|
||||
import PostVisibleScopeSelect from '~/components/PostVisibleScopeSelect.vue'
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
|
||||
@@ -62,6 +64,7 @@ const content = ref('')
|
||||
const selectedCategory = ref('')
|
||||
const selectedTags = ref([])
|
||||
const postType = ref('NORMAL')
|
||||
const postVisibleScope = ref('ALL')
|
||||
const lottery = reactive({
|
||||
prizeIcon: '',
|
||||
prizeIconFile: null,
|
||||
@@ -100,6 +103,7 @@ const loadDraft = async () => {
|
||||
content.value = data.content || ''
|
||||
selectedCategory.value = data.categoryId || ''
|
||||
selectedTags.value = data.tagIds || []
|
||||
postVisibleScope.value = data.visiblescope
|
||||
|
||||
toast.success('草稿已加载')
|
||||
}
|
||||
@@ -115,6 +119,7 @@ const clearPost = async () => {
|
||||
content.value = ''
|
||||
selectedCategory.value = ''
|
||||
selectedTags.value = []
|
||||
postVisibleScope.value = 'ALL'
|
||||
postType.value = 'NORMAL'
|
||||
lottery.prizeIcon = ''
|
||||
lottery.prizeIconFile = null
|
||||
@@ -168,6 +173,7 @@ const saveDraft = async () => {
|
||||
content: content.value,
|
||||
categoryId: selectedCategory.value || null,
|
||||
tagIds,
|
||||
postVisibleScopeType:postVisibleScope.value
|
||||
}),
|
||||
})
|
||||
if (res.ok) {
|
||||
@@ -328,6 +334,7 @@ const submitPost = async () => {
|
||||
categoryId: selectedCategory.value,
|
||||
tagIds: selectedTags.value,
|
||||
type: postType.value,
|
||||
postVisibleScopeType: postVisibleScope.value,
|
||||
}
|
||||
|
||||
if (postType.value === 'LOTTERY') {
|
||||
@@ -355,6 +362,7 @@ const submitPost = async () => {
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
if (res.ok) {
|
||||
if (data.reward && data.reward > 0) {
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
<div class="post-options-left">
|
||||
<CategorySelect v-model="selectedCategory" />
|
||||
<TagSelect v-model="selectedTags" creatable />
|
||||
<PostVisibleScopeSelect v-model="selectedVisibleScope"/>
|
||||
</div>
|
||||
<div class="post-options-right">
|
||||
<div class="post-clear" @click="clearPost"><clear-icon /> 清空</div>
|
||||
@@ -44,6 +45,7 @@ import TagSelect from '~/components/TagSelect.vue'
|
||||
import { toast } from '~/main'
|
||||
import { getToken, authState } from '~/utils/auth'
|
||||
import LoginOverlay from '~/components/LoginOverlay.vue'
|
||||
import PostVisibleScopeSelect from '~/components/PostVisibleScopeSelect.vue'
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
|
||||
@@ -51,6 +53,7 @@ const title = ref('')
|
||||
const content = ref('')
|
||||
const selectedCategory = ref('')
|
||||
const selectedTags = ref([])
|
||||
const selectedVisibleScope = ref('ALL')
|
||||
const isWaitingPosting = ref(false)
|
||||
const isAiLoading = ref(false)
|
||||
const isLogin = computed(() => authState.loggedIn)
|
||||
@@ -70,6 +73,7 @@ const loadPost = async () => {
|
||||
content.value = data.content || ''
|
||||
selectedCategory.value = data.category.id || ''
|
||||
selectedTags.value = (data.tags || []).map((t) => t.id)
|
||||
selectedVisibleScope.value = data.visibleScope
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error('加载失败')
|
||||
@@ -180,6 +184,7 @@ const submitPost = async () => {
|
||||
content: content.value,
|
||||
categoryId: selectedCategory.value,
|
||||
tagIds: selectedTags.value,
|
||||
postVisibleScopeType:selectedVisibleScope.value
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
<template>
|
||||
<div class="post-page-container">
|
||||
<div v-if="isRestricted" class="restricted-content">
|
||||
<template v-if="visibleScope === 'ONLY_ME'">
|
||||
<LoginOverlay
|
||||
text="这是一篇私密文章,仅作者本人及管理员可见"
|
||||
button-text="返回首页"
|
||||
button-link="/"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-else-if="visibleScope === 'ONLY_REGISTER'">
|
||||
<LoginOverlay
|
||||
text="这是一篇仅登录用户可见的文章,请先登录"
|
||||
button-text="登录"
|
||||
button-link="/login"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
<div v-else class="post-page-container">
|
||||
<div v-if="isWaitingFetchingPost" class="loading-container">
|
||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||
</div>
|
||||
@@ -16,7 +33,9 @@
|
||||
<div v-if="status === 'PENDING'" class="article-pending-button">审核中</div>
|
||||
<div v-if="status === 'REJECTED'" class="article-block-button">已拒绝</div>
|
||||
<div v-if="!rssExcluded" class="article-featured-button">精品</div>
|
||||
<div v-if="closed" class="article-closed-button">已关闭</div>
|
||||
<div v-if="closed" class="article-gray-button">已关闭</div>
|
||||
<div v-if="visibleScope === 'ONLY_ME'" class="article-gray-button">仅自己可见</div>
|
||||
<div v-if="visibleScope === 'ONLY_REGISTER'" class="article-gray-button">仅登录可见</div>
|
||||
<div
|
||||
v-if="!closed && loggedIn && !isAuthor && !subscribed"
|
||||
class="article-subscribe-button"
|
||||
@@ -165,25 +184,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="post-page-scroller-container">
|
||||
<div class="scroller">
|
||||
<div v-if="isWaitingFetchingPost" class="scroller-time">loading...</div>
|
||||
<div v-else class="scroller-time">{{ scrollerTopTime }}</div>
|
||||
<div class="scroller-middle">
|
||||
<input
|
||||
type="range"
|
||||
class="scroller-range"
|
||||
:max="totalPosts"
|
||||
:min="1"
|
||||
v-model.number="currentIndex"
|
||||
@input="onSliderInput"
|
||||
/>
|
||||
<div class="scroller-index">{{ currentIndex }}/{{ totalPosts }}</div>
|
||||
</div>
|
||||
<div v-if="isWaitingFetchingPost" class="scroller-time">loading...</div>
|
||||
<div v-else class="scroller-time">{{ lastReplyTime }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<vue-easy-lightbox
|
||||
:visible="lightboxVisible"
|
||||
:index="lightboxIndex"
|
||||
@@ -228,6 +228,7 @@ import { useIsMobile } from '~/utils/screen'
|
||||
import Dropdown from '~/components/Dropdown.vue'
|
||||
import { ClientOnly } from '#components'
|
||||
import { useConfirm } from '~/composables/useConfirm'
|
||||
import { Lock } from '@icon-park/vue-next'
|
||||
const { confirm } = useConfirm()
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
@@ -241,6 +242,13 @@ const author = ref('')
|
||||
const postContent = ref('')
|
||||
const category = ref('')
|
||||
const tags = ref([])
|
||||
const visibleScope = ref('ALL') // 可见范围
|
||||
const isRestricted = computed(() => {
|
||||
return (
|
||||
(visibleScope.value === 'ONLY_ME' && !isAuthor.value && !isAdmin.value) ||
|
||||
(visibleScope.value === 'ONLY_REGISTER' && !loggedIn.value)
|
||||
)
|
||||
})
|
||||
const postReactions = ref([])
|
||||
const postReactionsGroupRef = ref(null)
|
||||
const postLikeCount = computed(
|
||||
@@ -408,6 +416,14 @@ const changeLogIcon = (l) => {
|
||||
} else {
|
||||
return 'dislike'
|
||||
}
|
||||
} else if (l.type === 'VISIBLE_SCOPE') {
|
||||
if (l.newVisibleScope === 'ONLY_ME') {
|
||||
return 'lock-one'
|
||||
} else if (l.newVisibleScope === 'ONLY_REGISTER') {
|
||||
return 'peoples-two'
|
||||
} else {
|
||||
return 'communication'
|
||||
}
|
||||
} else if (l.type === 'VOTE_RESULT') {
|
||||
return 'check-one'
|
||||
} else if (l.type === 'LOTTERY_RESULT') {
|
||||
@@ -438,6 +454,8 @@ const mapChangeLog = (l) => ({
|
||||
newCategory: l.newCategory,
|
||||
oldTags: l.oldTags,
|
||||
newTags: l.newTags,
|
||||
oldVisibleScope: l.oldVisibleScope,
|
||||
newVisibleScope: l.newVisibleScope,
|
||||
amount: l.amount,
|
||||
icon: changeLogIcon(l),
|
||||
})
|
||||
@@ -497,15 +515,27 @@ const onCommentDeleted = (id) => {
|
||||
fetchTimeline()
|
||||
}
|
||||
|
||||
const tokenHeader = computed(() => {
|
||||
const token = getToken()
|
||||
return token ? { Authorization: `Bearer ${token}` } : {}
|
||||
})
|
||||
const {
|
||||
data: postData,
|
||||
pending: pendingPost,
|
||||
error: postError,
|
||||
refresh: refreshPost,
|
||||
} = await useAsyncData(`post-${postId}`, () => $fetch(`${API_BASE_URL}/api/posts/${postId}`), {
|
||||
server: true,
|
||||
lazy: false,
|
||||
})
|
||||
} = await useAsyncData(
|
||||
`post-${postId}`,
|
||||
async () => {
|
||||
try {
|
||||
return await $fetch(`${API_BASE_URL}/api/posts/${postId}`, { headers: tokenHeader.value })
|
||||
} catch (err) {}
|
||||
},
|
||||
{
|
||||
server: false,
|
||||
lazy: false,
|
||||
},
|
||||
)
|
||||
|
||||
// 用 pendingPost 驱动现有 UI(替代 isWaitingFetchingPost 手控)
|
||||
const isWaitingFetchingPost = computed(() => pendingPost.value)
|
||||
@@ -519,6 +549,7 @@ watchEffect(() => {
|
||||
title.value = data.title
|
||||
category.value = data.category
|
||||
tags.value = data.tags || []
|
||||
visibleScope.value = data.visibleScope || 'ALL'
|
||||
postReactions.value = data.reactions || []
|
||||
subscribed.value = !!data.subscribed
|
||||
status.value = data.status
|
||||
@@ -935,7 +966,7 @@ onMounted(async () => {
|
||||
<style>
|
||||
.post-page-container {
|
||||
background-color: var(--background-color);
|
||||
display: flex;
|
||||
display: block;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
@@ -948,9 +979,10 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.post-page-main-container {
|
||||
position: relative;
|
||||
scrollbar-width: none;
|
||||
padding: 20px;
|
||||
width: calc(85% - 40px);
|
||||
width: calc(100% - 40px);
|
||||
}
|
||||
|
||||
.info-content-text p code {
|
||||
@@ -1002,6 +1034,35 @@ onMounted(async () => {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
background-color: #eee;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
height: 20px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.skeleton::before {
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
background: linear-gradient(90deg, #eee 0%, #f5f5f7 40%, #e0e0e0 100%);
|
||||
transform: translateX(-100%);
|
||||
animation: skeleton-shimmer 1.5s infinite linear;
|
||||
z-index: 1;
|
||||
border-radius: 8px;
|
||||
}
|
||||
@keyframes skeleton-shimmer {
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
.user-avatar-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@@ -1106,7 +1167,7 @@ onMounted(async () => {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.article-closed-button,
|
||||
.article-gray-button,
|
||||
.article-subscribe-button-text,
|
||||
.article-featured-button,
|
||||
.article-unsubscribe-button-text {
|
||||
@@ -1159,7 +1220,7 @@ onMounted(async () => {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.article-closed-button {
|
||||
.article-gray-button {
|
||||
background-color: var(--background-color);
|
||||
color: gray;
|
||||
border: 1px solid gray;
|
||||
@@ -1341,6 +1402,76 @@ onMounted(async () => {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* ======== 权限锁定状态 ======== */
|
||||
.is-blurred {
|
||||
filter: blur(10px);
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
transition: filter 0.3s ease;
|
||||
}
|
||||
|
||||
/* 遮罩层 */
|
||||
.restricted-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 999;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
backdrop-filter: blur(12px);
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
animation: fadeIn 0.3s ease forwards;
|
||||
}
|
||||
|
||||
/* 中央提示框 */
|
||||
.restricted-content {
|
||||
background: #ffff;
|
||||
color: var(--primary-color);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.restricted-icon {
|
||||
font-size: 60px;
|
||||
opacity: 0.8;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.restricted-button {
|
||||
display: inline-block;
|
||||
margin-top: 20px;
|
||||
padding: 10px 18px;
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.restricted-button:hover {
|
||||
background: var(--primary-color-hover);
|
||||
}
|
||||
|
||||
.restricted-actions {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* 淡入动画 */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.post-page-main-container {
|
||||
width: calc(100% - 20px);
|
||||
|
||||
@@ -82,6 +82,7 @@ import {
|
||||
Share,
|
||||
Financing,
|
||||
Hands,
|
||||
PreviewCloseOne,
|
||||
} from '@icon-park/vue-next'
|
||||
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
@@ -167,4 +168,5 @@ export default defineNuxtPlugin((nuxtApp) => {
|
||||
nuxtApp.vueApp.component('Share', Share)
|
||||
nuxtApp.vueApp.component('Financing', Financing)
|
||||
nuxtApp.vueApp.component('Hands', Hands)
|
||||
nuxtApp.vueApp.component('PreviewCloseOne', PreviewCloseOne)
|
||||
})
|
||||
|
||||
@@ -268,23 +268,21 @@ export function stripMarkdownLength(text, length) {
|
||||
|
||||
// 朴素文本带贴吧表情
|
||||
export function stripMarkdownWithTiebaMoji(text, length){
|
||||
console.error(text)
|
||||
if (!text) return ''
|
||||
|
||||
// Markdown 转成纯文本
|
||||
const plain = stripMarkdown(text)
|
||||
console.error(plain)
|
||||
// 替换 :tieba123: 为 <img>
|
||||
const withEmoji = plain.replace(/:tieba(\d+):/g, (match, num) => {
|
||||
const key = `tieba${num}`
|
||||
const file = tiebaEmoji[key]
|
||||
return file
|
||||
? `<img loading="lazy" class="emoji" src="${file}" alt="${key}">`
|
||||
: match // 没有匹配到图片则保留原样
|
||||
})
|
||||
// Markdown 转成纯文本
|
||||
const plain = stripMarkdown(text)
|
||||
// 替换 :tieba123: 为 <img>
|
||||
const withEmoji = plain.replace(/:tieba(\d+):/g, (match, num) => {
|
||||
const key = `tieba${num}`
|
||||
const file = tiebaEmoji[key]
|
||||
return file
|
||||
? `<img loading="lazy" class="emoji" src="${file}" alt="${key}">`
|
||||
: match // 没有匹配到图片则保留原样
|
||||
})
|
||||
|
||||
// 截断纯文本长度(防止撑太长)
|
||||
const truncated = withEmoji.length > length ? withEmoji.slice(0, length) + '...' : withEmoji
|
||||
return truncated
|
||||
// 截断纯文本长度(防止撑太长)
|
||||
const truncated = withEmoji.length > length ? withEmoji.slice(0, length) + '...' : withEmoji
|
||||
return truncated
|
||||
|
||||
}
|
||||
|
||||
27
mcp/Dockerfile
Normal file
27
mcp/Dockerfile
Normal file
@@ -0,0 +1,27 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM python:3.11-slim AS base
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY pyproject.toml README.md ./
|
||||
COPY src ./src
|
||||
|
||||
RUN pip install --upgrade pip \
|
||||
&& pip install --no-cache-dir . \
|
||||
&& pip cache purge
|
||||
|
||||
ENV OPENISLE_MCP_TRANSPORT=http \
|
||||
OPENISLE_MCP_HOST=0.0.0.0 \
|
||||
OPENISLE_MCP_PORT=8974
|
||||
|
||||
EXPOSE 8974
|
||||
|
||||
ENTRYPOINT ["openisle-mcp"]
|
||||
51
mcp/README.md
Normal file
51
mcp/README.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# OpenIsle MCP Server
|
||||
|
||||
This package provides a Python implementation of a Model Context Protocol (MCP) server for OpenIsle. The server focuses on the community search APIs so that AI assistants and other MCP-aware clients can discover OpenIsle users, posts, categories, comments, and tags. Additional capabilities such as content creation tools can be layered on later without changing the transport or deployment model.
|
||||
|
||||
## Features
|
||||
|
||||
- ✅ Implements the MCP tooling interface using [FastMCP](https://github.com/modelcontextprotocol/fastmcp).
|
||||
- 🔍 Exposes a `search` tool that proxies requests to the existing OpenIsle REST endpoints and normalises the response payload.
|
||||
- ⚙️ Configurable through environment variables for API base URL, timeout, result limits, and snippet size.
|
||||
- 🐳 Packaged with a Docker image so it can be launched alongside the other OpenIsle services.
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `OPENISLE_API_BASE_URL` | `http://springboot:8080` | Base URL of the OpenIsle backend REST API. |
|
||||
| `OPENISLE_API_TIMEOUT` | `10` | Timeout (in seconds) used when calling the backend search endpoints. |
|
||||
| `OPENISLE_MCP_DEFAULT_LIMIT` | `20` | Default maximum number of search results to return when the caller does not provide a limit. Use `0` or a negative number to disable limiting. |
|
||||
| `OPENISLE_MCP_SNIPPET_LENGTH` | `160` | Maximum length (in characters) of the normalised summary snippet returned by the MCP tool. |
|
||||
| `OPENISLE_MCP_TRANSPORT` | `stdio` | Transport used when running the server directly. Set to `http` when running inside Docker. |
|
||||
| `OPENISLE_MCP_HOST` | `127.0.0.1` | Bind host used when the transport is HTTP/SSE. |
|
||||
| `OPENISLE_MCP_PORT` | `8974` | Bind port used when the transport is HTTP/SSE. |
|
||||
|
||||
## Local development
|
||||
|
||||
```bash
|
||||
cd mcp
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install --upgrade pip
|
||||
pip install -e .
|
||||
OPENISLE_API_BASE_URL=http://localhost:8080 OPENISLE_MCP_TRANSPORT=http openisle-mcp
|
||||
```
|
||||
|
||||
By default the server listens over stdio, which is useful when integrating with MCP-aware IDEs. When the `OPENISLE_MCP_TRANSPORT` variable is set to `http` the server will expose an HTTP transport on `OPENISLE_MCP_HOST:OPENISLE_MCP_PORT`.
|
||||
|
||||
## Docker image
|
||||
|
||||
The accompanying `Dockerfile` builds a minimal image that installs the package and starts the MCP server. The root Docker Compose manifest is configured to launch this service and connect it to the same internal network as the Spring Boot API so the MCP tools can reach the search endpoints.
|
||||
|
||||
## MCP tool contract
|
||||
|
||||
The `search` tool accepts the following arguments:
|
||||
|
||||
- `keyword` (string, required): Search phrase passed directly to the OpenIsle API.
|
||||
- `scope` (string, optional): One of `global`, `posts`, `posts_content`, `posts_title`, or `users`. Defaults to `global`.
|
||||
- `limit` (integer, optional): Overrides the default limit from `OPENISLE_MCP_DEFAULT_LIMIT`.
|
||||
|
||||
The tool returns a JSON object containing both the raw API response and a normalised representation with concise titles, subtitles, and snippets for each result.
|
||||
|
||||
Future tools (for example posting or moderation helpers) can be added within this package and exposed via additional decorators without changing the deployment setup.
|
||||
30
mcp/pyproject.toml
Normal file
30
mcp/pyproject.toml
Normal file
@@ -0,0 +1,30 @@
|
||||
[build-system]
|
||||
requires = ["hatchling>=1.25.0"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "openisle-mcp"
|
||||
version = "0.1.0"
|
||||
description = "Model Context Protocol server exposing OpenIsle search functionality."
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
authors = [{name = "OpenIsle Contributors"}]
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"fastmcp>=2.12.5",
|
||||
"httpx>=0.28.1",
|
||||
"pydantic>=2.7",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
openisle-mcp = "openisle_mcp.server:main"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/openisle_mcp"]
|
||||
|
||||
[tool.hatch.build.targets.sdist]
|
||||
include = [
|
||||
"src/openisle_mcp",
|
||||
"README.md",
|
||||
"pyproject.toml",
|
||||
]
|
||||
5
mcp/src/openisle_mcp/__init__.py
Normal file
5
mcp/src/openisle_mcp/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""OpenIsle MCP server package."""
|
||||
|
||||
from .server import main
|
||||
|
||||
__all__ = ["main"]
|
||||
218
mcp/src/openisle_mcp/client.py
Normal file
218
mcp/src/openisle_mcp/client.py
Normal file
@@ -0,0 +1,218 @@
|
||||
"""HTTP client wrappers for interacting with the OpenIsle backend."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import re
|
||||
from typing import Any, Iterable
|
||||
|
||||
import httpx
|
||||
|
||||
from .models import NormalizedSearchResult, SearchResponse, SearchScope
|
||||
from .settings import Settings
|
||||
|
||||
_TAG_RE = re.compile(r"<[^>]+>")
|
||||
_WHITESPACE_RE = re.compile(r"\s+")
|
||||
|
||||
|
||||
class SearchClient:
|
||||
"""High level client around the OpenIsle search API."""
|
||||
|
||||
_ENDPOINTS: dict[SearchScope, str] = {
|
||||
SearchScope.GLOBAL: "/api/search/global",
|
||||
SearchScope.POSTS: "/api/search/posts",
|
||||
SearchScope.POSTS_CONTENT: "/api/search/posts/content",
|
||||
SearchScope.POSTS_TITLE: "/api/search/posts/title",
|
||||
SearchScope.USERS: "/api/search/users",
|
||||
}
|
||||
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
self._base_url = settings.sanitized_base_url()
|
||||
self._timeout = settings.request_timeout
|
||||
self._default_limit = settings.default_limit
|
||||
self._snippet_length = settings.snippet_length
|
||||
self._client = httpx.AsyncClient(
|
||||
base_url=self._base_url,
|
||||
timeout=self._timeout,
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
|
||||
async def aclose(self) -> None:
|
||||
await self._client.aclose()
|
||||
|
||||
def endpoint_path(self, scope: SearchScope) -> str:
|
||||
return self._ENDPOINTS[scope]
|
||||
|
||||
def endpoint_url(self, scope: SearchScope) -> str:
|
||||
return f"{self._base_url}{self.endpoint_path(scope)}"
|
||||
|
||||
async def search(
|
||||
self,
|
||||
keyword: str,
|
||||
scope: SearchScope,
|
||||
*,
|
||||
limit: int | None = None,
|
||||
) -> SearchResponse:
|
||||
"""Execute a search request and normalise the results."""
|
||||
|
||||
keyword = keyword.strip()
|
||||
effective_limit = self._resolve_limit(limit)
|
||||
|
||||
if not keyword:
|
||||
return SearchResponse(
|
||||
keyword=keyword,
|
||||
scope=scope,
|
||||
endpoint=self.endpoint_url(scope),
|
||||
limit=effective_limit,
|
||||
total_results=0,
|
||||
returned_results=0,
|
||||
normalized=[],
|
||||
raw=[],
|
||||
)
|
||||
|
||||
response = await self._client.get(
|
||||
self.endpoint_path(scope),
|
||||
params={"keyword": keyword},
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
if not isinstance(payload, list): # pragma: no cover - defensive programming
|
||||
raise ValueError("Search API did not return a JSON array")
|
||||
|
||||
total_results = len(payload)
|
||||
items = payload if effective_limit is None else payload[:effective_limit]
|
||||
normalized = [self._normalise_item(scope, item) for item in items]
|
||||
|
||||
return SearchResponse(
|
||||
keyword=keyword,
|
||||
scope=scope,
|
||||
endpoint=self.endpoint_url(scope),
|
||||
limit=effective_limit,
|
||||
total_results=total_results,
|
||||
returned_results=len(items),
|
||||
normalized=normalized,
|
||||
raw=items,
|
||||
)
|
||||
|
||||
def _resolve_limit(self, requested: int | None) -> int | None:
|
||||
value = requested if requested is not None else self._default_limit
|
||||
if value is None:
|
||||
return None
|
||||
if value <= 0:
|
||||
return None
|
||||
return value
|
||||
|
||||
def _normalise_item(
|
||||
self,
|
||||
scope: SearchScope,
|
||||
item: Any,
|
||||
) -> NormalizedSearchResult:
|
||||
"""Normalise raw API objects into a consistent structure."""
|
||||
|
||||
if not isinstance(item, dict): # pragma: no cover - defensive programming
|
||||
return NormalizedSearchResult(type=scope.value, metadata={"raw": item})
|
||||
|
||||
if scope == SearchScope.GLOBAL:
|
||||
return self._normalise_global(item)
|
||||
if scope in {SearchScope.POSTS, SearchScope.POSTS_CONTENT, SearchScope.POSTS_TITLE}:
|
||||
return self._normalise_post(item)
|
||||
if scope == SearchScope.USERS:
|
||||
return self._normalise_user(item)
|
||||
return NormalizedSearchResult(type=scope.value, metadata=item)
|
||||
|
||||
def _normalise_global(self, item: dict[str, Any]) -> NormalizedSearchResult:
|
||||
highlights = {
|
||||
"title": item.get("highlightedText"),
|
||||
"subtitle": item.get("highlightedSubText"),
|
||||
"snippet": item.get("highlightedExtra"),
|
||||
}
|
||||
snippet_source = highlights.get("snippet") or item.get("extra")
|
||||
metadata = {
|
||||
"postId": item.get("postId"),
|
||||
"highlights": {k: v for k, v in highlights.items() if v},
|
||||
}
|
||||
return NormalizedSearchResult(
|
||||
type=str(item.get("type", "result")),
|
||||
id=_safe_int(item.get("id")),
|
||||
title=highlights.get("title") or _safe_str(item.get("text")),
|
||||
subtitle=highlights.get("subtitle") or _safe_str(item.get("subText")),
|
||||
snippet=self._snippet(snippet_source),
|
||||
metadata={k: v for k, v in metadata.items() if v not in (None, {}, [])},
|
||||
)
|
||||
|
||||
def _normalise_post(self, item: dict[str, Any]) -> NormalizedSearchResult:
|
||||
author = _safe_dict(item.get("author"))
|
||||
category = _safe_dict(item.get("category"))
|
||||
tags = [tag.get("name") for tag in _safe_iter(item.get("tags")) if isinstance(tag, dict)]
|
||||
metadata = {
|
||||
"author": author.get("username"),
|
||||
"category": category.get("name"),
|
||||
"tags": tags,
|
||||
"views": item.get("views"),
|
||||
"commentCount": item.get("commentCount"),
|
||||
"status": item.get("status"),
|
||||
"apiUrl": f"{self._base_url}/api/posts/{item.get('id')}" if item.get("id") else None,
|
||||
}
|
||||
return NormalizedSearchResult(
|
||||
type="post",
|
||||
id=_safe_int(item.get("id")),
|
||||
title=_safe_str(item.get("title")),
|
||||
subtitle=_safe_str(category.get("name")),
|
||||
snippet=self._snippet(item.get("content")),
|
||||
metadata={k: v for k, v in metadata.items() if v not in (None, [], {})},
|
||||
)
|
||||
|
||||
def _normalise_user(self, item: dict[str, Any]) -> NormalizedSearchResult:
|
||||
metadata = {
|
||||
"followers": item.get("followers"),
|
||||
"following": item.get("following"),
|
||||
"totalViews": item.get("totalViews"),
|
||||
"role": item.get("role"),
|
||||
"subscribed": item.get("subscribed"),
|
||||
"apiUrl": f"{self._base_url}/api/users/{item.get('id')}" if item.get("id") else None,
|
||||
}
|
||||
return NormalizedSearchResult(
|
||||
type="user",
|
||||
id=_safe_int(item.get("id")),
|
||||
title=_safe_str(item.get("username")),
|
||||
subtitle=_safe_str(item.get("email") or item.get("role")),
|
||||
snippet=self._snippet(item.get("introduction")),
|
||||
metadata={k: v for k, v in metadata.items() if v not in (None, [], {})},
|
||||
)
|
||||
|
||||
def _snippet(self, value: Any) -> str | None:
|
||||
text = _safe_str(value)
|
||||
if not text:
|
||||
return None
|
||||
text = html.unescape(text)
|
||||
text = _TAG_RE.sub(" ", text)
|
||||
text = _WHITESPACE_RE.sub(" ", text).strip()
|
||||
if not text:
|
||||
return None
|
||||
if len(text) <= self._snippet_length:
|
||||
return text
|
||||
return text[: self._snippet_length - 1].rstrip() + "…"
|
||||
|
||||
|
||||
def _safe_int(value: Any) -> int | None:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError): # pragma: no cover - defensive
|
||||
return None
|
||||
|
||||
|
||||
def _safe_str(value: Any) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
return text or None
|
||||
|
||||
|
||||
def _safe_dict(value: Any) -> dict[str, Any]:
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def _safe_iter(value: Any) -> Iterable[Any]:
|
||||
if isinstance(value, list | tuple | set):
|
||||
return value
|
||||
return []
|
||||
71
mcp/src/openisle_mcp/models.py
Normal file
71
mcp/src/openisle_mcp/models.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""Shared models for the OpenIsle MCP server."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class SearchScope(str, Enum):
|
||||
"""Supported search endpoints."""
|
||||
|
||||
GLOBAL = "global"
|
||||
POSTS = "posts"
|
||||
POSTS_CONTENT = "posts_content"
|
||||
POSTS_TITLE = "posts_title"
|
||||
USERS = "users"
|
||||
|
||||
def __str__(self) -> str: # pragma: no cover - convenience for logging
|
||||
return self.value
|
||||
|
||||
|
||||
class NormalizedSearchResult(BaseModel):
|
||||
"""Compact structure returned by the MCP search tool."""
|
||||
|
||||
type: str = Field(description="Entity type, e.g. user, post, comment.")
|
||||
id: int | None = Field(default=None, description="Primary identifier of the entity.")
|
||||
title: str | None = Field(default=None, description="Display title for the result.")
|
||||
subtitle: str | None = Field(default=None, description="Secondary line of context.")
|
||||
snippet: str | None = Field(default=None, description="Short summary of the result.")
|
||||
metadata: dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="Additional attributes extracted from the API response.",
|
||||
)
|
||||
|
||||
model_config = {
|
||||
"extra": "ignore",
|
||||
}
|
||||
|
||||
|
||||
class SearchResponse(BaseModel):
|
||||
"""Payload returned to MCP clients."""
|
||||
|
||||
keyword: str
|
||||
scope: SearchScope
|
||||
endpoint: str
|
||||
limit: int | None = Field(
|
||||
default=None,
|
||||
description="Result limit applied to the request. None means unlimited.",
|
||||
)
|
||||
total_results: int = Field(
|
||||
default=0,
|
||||
description="Total number of items returned by the OpenIsle API before limiting.",
|
||||
)
|
||||
returned_results: int = Field(
|
||||
default=0,
|
||||
description="Number of items returned to the MCP client after limiting.",
|
||||
)
|
||||
normalized: list[NormalizedSearchResult] = Field(
|
||||
default_factory=list,
|
||||
description="Normalised representation of each search hit.",
|
||||
)
|
||||
raw: list[Any] = Field(
|
||||
default_factory=list,
|
||||
description="Raw response objects from the OpenIsle REST API.",
|
||||
)
|
||||
|
||||
model_config = {
|
||||
"extra": "ignore",
|
||||
}
|
||||
95
mcp/src/openisle_mcp/server.py
Normal file
95
mcp/src/openisle_mcp/server.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""Entrypoint for the OpenIsle MCP server."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastmcp import Context, FastMCP
|
||||
|
||||
from .client import SearchClient
|
||||
from .models import SearchResponse, SearchScope
|
||||
from .settings import Settings
|
||||
|
||||
__all__ = ["main"]
|
||||
|
||||
|
||||
def _create_lifespan(settings: Settings):
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastMCP):
|
||||
client = SearchClient(settings)
|
||||
setattr(app, "_search_client", client)
|
||||
try:
|
||||
yield {"client": client}
|
||||
finally:
|
||||
await client.aclose()
|
||||
if hasattr(app, "_search_client"):
|
||||
delattr(app, "_search_client")
|
||||
|
||||
return lifespan
|
||||
|
||||
|
||||
_settings = Settings.from_env()
|
||||
|
||||
mcp = FastMCP(
|
||||
name="OpenIsle Search",
|
||||
version="0.1.0",
|
||||
instructions=(
|
||||
"Provides access to OpenIsle search endpoints for retrieving users, posts, "
|
||||
"comments, tags, and categories."
|
||||
),
|
||||
lifespan=_create_lifespan(_settings),
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool("search")
|
||||
async def search(
|
||||
keyword: str,
|
||||
scope: SearchScope = SearchScope.GLOBAL,
|
||||
limit: int | None = None,
|
||||
ctx: Context | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Perform a search against the OpenIsle backend."""
|
||||
|
||||
client = _resolve_client(ctx)
|
||||
try:
|
||||
response: SearchResponse = await client.search(keyword=keyword, scope=scope, limit=limit)
|
||||
except httpx.HTTPError as exc:
|
||||
message = f"OpenIsle search request failed: {exc}".rstrip()
|
||||
raise RuntimeError(message) from exc
|
||||
|
||||
payload = response.model_dump()
|
||||
payload["transport"] = {
|
||||
"scope": scope.value,
|
||||
"endpoint": client.endpoint_url(scope),
|
||||
}
|
||||
return payload
|
||||
|
||||
|
||||
def _resolve_client(ctx: Context | None) -> SearchClient:
|
||||
app = ctx.fastmcp if ctx is not None else mcp
|
||||
client = getattr(app, "_search_client", None)
|
||||
if client is None:
|
||||
raise RuntimeError("Search client is not initialised; lifespan hook not executed")
|
||||
return client
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""CLI entrypoint."""
|
||||
|
||||
transport = os.getenv("OPENISLE_MCP_TRANSPORT", "stdio").strip().lower()
|
||||
show_banner = os.getenv("OPENISLE_MCP_SHOW_BANNER", "true").lower() in {"1", "true", "yes"}
|
||||
run_kwargs: dict[str, Any] = {"show_banner": show_banner}
|
||||
|
||||
if transport in {"http", "sse", "streamable-http"}:
|
||||
host = os.getenv("OPENISLE_MCP_HOST", "127.0.0.1")
|
||||
port = int(os.getenv("OPENISLE_MCP_PORT", "8974"))
|
||||
run_kwargs.update({"host": host, "port": port})
|
||||
|
||||
mcp.run(transport=transport, **run_kwargs)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover - manual execution guard
|
||||
main()
|
||||
102
mcp/src/openisle_mcp/settings.py
Normal file
102
mcp/src/openisle_mcp/settings.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""Environment configuration for the MCP server."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field, ValidationError, field_validator
|
||||
|
||||
|
||||
class Settings(BaseModel):
|
||||
"""Runtime configuration sourced from environment variables."""
|
||||
|
||||
api_base_url: str = Field(
|
||||
default="http://springboot:8080",
|
||||
description="Base URL of the OpenIsle backend REST API.",
|
||||
)
|
||||
request_timeout: float = Field(
|
||||
default=10.0,
|
||||
description="Timeout in seconds for outgoing HTTP requests.",
|
||||
ge=0.1,
|
||||
)
|
||||
default_limit: int = Field(
|
||||
default=20,
|
||||
description="Default maximum number of results returned by the search tool.",
|
||||
)
|
||||
snippet_length: int = Field(
|
||||
default=160,
|
||||
description="Maximum length for the normalised snippet field.",
|
||||
ge=40,
|
||||
)
|
||||
|
||||
model_config = {
|
||||
"extra": "ignore",
|
||||
"validate_assignment": True,
|
||||
}
|
||||
|
||||
@field_validator("api_base_url", mode="before")
|
||||
@classmethod
|
||||
def _strip_trailing_slash(cls, value: Any) -> Any:
|
||||
if isinstance(value, str):
|
||||
value = value.strip()
|
||||
if value.endswith("/"):
|
||||
return value.rstrip("/")
|
||||
return value
|
||||
|
||||
@field_validator("default_limit", mode="before")
|
||||
@classmethod
|
||||
def _parse_default_limit(cls, value: Any) -> Any:
|
||||
if isinstance(value, str) and value.strip():
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError as exc: # pragma: no cover - defensive
|
||||
raise ValueError("default_limit must be an integer") from exc
|
||||
return value
|
||||
|
||||
@field_validator("snippet_length", mode="before")
|
||||
@classmethod
|
||||
def _parse_snippet_length(cls, value: Any) -> Any:
|
||||
if isinstance(value, str) and value.strip():
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError as exc: # pragma: no cover - defensive
|
||||
raise ValueError("snippet_length must be an integer") from exc
|
||||
return value
|
||||
|
||||
@field_validator("request_timeout", mode="before")
|
||||
@classmethod
|
||||
def _parse_timeout(cls, value: Any) -> Any:
|
||||
if isinstance(value, str) and value.strip():
|
||||
try:
|
||||
return float(value)
|
||||
except ValueError as exc: # pragma: no cover - defensive
|
||||
raise ValueError("request_timeout must be a number") from exc
|
||||
return value
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "Settings":
|
||||
"""Build a settings object using environment variables."""
|
||||
|
||||
data: dict[str, Any] = {}
|
||||
mapping = {
|
||||
"api_base_url": "OPENISLE_API_BASE_URL",
|
||||
"request_timeout": "OPENISLE_API_TIMEOUT",
|
||||
"default_limit": "OPENISLE_MCP_DEFAULT_LIMIT",
|
||||
"snippet_length": "OPENISLE_MCP_SNIPPET_LENGTH",
|
||||
}
|
||||
for field, env_key in mapping.items():
|
||||
value = os.getenv(env_key)
|
||||
if value is not None and value != "":
|
||||
data[field] = value
|
||||
try:
|
||||
return cls.model_validate(data)
|
||||
except ValidationError as exc: # pragma: no cover - validation errors surface early
|
||||
raise ValueError(
|
||||
"Invalid MCP settings derived from environment variables"
|
||||
) from exc
|
||||
|
||||
def sanitized_base_url(self) -> str:
|
||||
"""Return the API base URL without trailing slashes."""
|
||||
|
||||
return self.api_base_url.rstrip("/")
|
||||
Reference in New Issue
Block a user