Compare commits

...

13 Commits

Author SHA1 Message Date
Tim
bcd6a3249d feat: add MCP search server 2025-10-25 21:58:11 +08:00
Tim
a24bd81942 Merge pull request #1080 from nagisa77/codex/modify-post-visibility-and-update-changelog
Log post visibility scope changes
2025-10-24 11:08:22 +08:00
Tim
8a008a090a Log post visibility changes 2025-10-24 10:41:10 +08:00
Tim
5dfb69e636 Merge pull request #1075 from smallclover/main
markdown 调试信息中的错误日志删除
2025-10-24 10:14:09 +08:00
Tim
499069573e fix: 弹窗修改为overlay 2025-10-24 10:11:56 +08:00
Tim
636912941a fix: commit 2025-10-23 17:56:45 +08:00
Tim
bdcc1488b9 fix: 修改可见范围 2025-10-23 17:54:03 +08:00
Tim
d33bd233af fix: 修改登录遮罩 2025-10-23 17:22:49 +08:00
Tim
efe4b97d83 Merge branch 'main' into main 2025-10-23 17:06:25 +08:00
Tim
8a256e167d Merge pull request #1031 from sivdead/feat/category_proposal
feat: 添加分类提案功能,包括提案表单和相关后端逻辑
2025-10-23 17:03:48 +08:00
smallclover
458b125834 1.追加文章可见范围功能
2.删除文章右侧滚动条
2025-10-18 22:32:22 +09:00
smallclover
971a3d36c6 修复:header文字不换行 2025-10-17 23:19:45 +09:00
smallclover
e5d66d73cb markdown 调试信息中的错误日志删除 2025-10-17 22:34:58 +09:00
35 changed files with 1020 additions and 53 deletions

View File

@@ -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

View File

@@ -28,6 +28,7 @@ OpenIsle 是一个使用 Spring Boot 和 Vue 3 构建的全栈开源社区平台
- 支持图片上传,默认使用腾讯云 COS 扩展
- 默认头像使用 DiceBear Avatars可通过 `AVATAR_STYLE``AVATAR_SIZE` 环境变量自定义主题和大小
- 浏览器推送通知,离开网站也能及时收到提醒
- 新增 Python MCP 搜索服务,方便 AI 助手通过统一协议检索社区内容
## 🌟 项目优势

View File

@@ -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()));
}

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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());
}

View File

@@ -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())

View File

@@ -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;

View File

@@ -8,6 +8,7 @@ public enum PostChangeType {
CLOSED,
PINNED,
FEATURED,
VISIBLE_SCOPE,
VOTE_RESULT,
LOTTERY_RESULT,
DONATE,

View File

@@ -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;
}

View File

@@ -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();
}
}

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -0,0 +1 @@
ALTER TABLE posts ADD COLUMN visible_scope ENUM('ALL', 'ONLY_ME', 'ONLY_REGISTER') NOT NULL DEFAULT 'ALL'

View File

@@ -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 \

View File

@@ -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

View File

@@ -543,6 +543,7 @@ onMounted(async () => {
.header-label {
font-size: 12px;
line-height: 1;
white-space: nowrap;
}
/* 在线人数的数字文字样式(无背景) */

View File

@@ -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>

View File

@@ -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'

View 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>

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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()

View File

@@ -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);

View File

@@ -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)
})

View File

@@ -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
View 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
View 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
View 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",
]

View File

@@ -0,0 +1,5 @@
"""OpenIsle MCP server package."""
from .server import main
__all__ = ["main"]

View 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 []

View 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",
}

View 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()

View 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("/")