HaloAI 助手插件
一、方案选型:基于 Halo AI Foundation 构建 Agent 插件
Halo 官方已经提供了 AI Foundation 插件,它的定位是“统一接入主流大模型,为插件生态提供文本生成、嵌入向量、工具调用等智能化能力”。其他 Halo 插件可以依赖它的 api 模块,直接获得模型调用、流式输出和 Tool Calling 支持。
这意味着我们不需要从零对接 OpenAI / DeepSeek / 通义千问的 API,也不需要自己实现工具调用的协议解析。我们的插件只需要做三件事:
定义工具:把“发文章”“点赞”“搜索文章”“提取片段”等操作声明为 AI 可调用的工具(Tool)。
暴露自定义端点:用 CustomEndpoint 或 @RestController 暴露 AI 对话接口,接收用户消息,交给 AI Foundation 处理。
注入 Console UI:在 Halo 控制台侧边栏或插件详情页嵌入一个聊天面板。
Halo 的 CustomEndpoint 会自动为你的路由加上 /apis/{group}/{version} 前缀,并且天然支持 WebFlux 响应式编程。AI 对话接口就通过这个机制暴露。
为什么不用 MCP Server 方案?
社区已有 Halo MCP Server,通过外部 MCP 客户端(Claude Desktop 等)操作 Halo。但用户的需求是“注入到 Halo 中”直接聊天,MCP 方案需要用户额外配置外部客户端,体验链路更长。插件内嵌 Agent 的方案更适合“打开后台就能用”的场景。
二、项目结构
插件项目命名建议为 plugin-ai-blog-agent。核心目录结构如下:
plugin-ai-blog-agent/
├── build.gradle
├── src/main/java/run/halo/aiblogagent/
│ ├── AiBlogAgentPlugin.java # 插件入口
│ ├── endpoint/
│ │ └── ChatEndpoint.java # AI 对话自定义端点
│ ├── agent/
│ │ ├── BlogAgentService.java # Agent 编排:接收消息 → 调 AI → 执行工具
│ │ └── tools/
│ │ ├── PostTools.java # 发文章工具
│ │ ├── UpvoteTools.java # 点赞工具
│ │ └── ContentTools.java # 搜索/整理/片段工具
│ ├── ui/
│ │ └── ConsoleChatExtension.java # Console UI 注入
│ └── settings/
│ └── AgentSettings.java # 插件设置(模型选择等)
├── src/main/resources/
│ ├── extensions/
│ │ ├── plugin.yaml
│ │ ├── settings.yaml
│ │ └── role-template.yaml
│ └── console/
│ └── src/
│ └── components/
│ └── AgentChatPanel.vue # 聊天面板 Vue 组件
└── README.md
三、后端实现:CustomEndpoint + AI Foundation Tool Calling
3.1 依赖配置(build.gradle)
dependencies {
implementation platform('run.halo.tools.platform:plugin:2.20.0')
compileOnly 'run.halo.app:api'
// AI Foundation API
compileOnly 'run.halo.app:plugin-ai-foundation-api:1.0.0'
}
plugin-ai-foundation-api 提供了模型调用、流式输出和 Tool Calling 的标准接口。
3.2 AI 对话端点(ChatEndpoint.java)
使用 CustomEndpoint,group 选 console.api.ai-blog-agent.halo.run,让端点只对控制台可用,并纳入 Halo 的权限体系。
@Component
public class ChatEndpoint implements CustomEndpoint {
private final BlogAgentService agentService;
public ChatEndpoint(BlogAgentService agentService) {
this.agentService = agentService;
}
@Override
public RouterFunction<ServerResponse> endpoint() {
return RouterFunctions.route()
.POST("/chat", this::chat)
.build();
}
private Mono<ServerResponse> chat(ServerRequest request) {
return request.bodyToMono(ChatRequest.class)
.flatMap(req -> agentService.chat(req))
.flatMap(result -> ServerResponse.ok().bodyValue(result));
}
@Override
public GroupVersion groupVersion() {
return new GroupVersion("console.api.ai-blog-agent.halo.run", "v1alpha1");
}
}
最终路径为 /apis/console.api.ai-blog-agent.halo.run/v1alpha1/chat。
ChatRequest 包含 sessionId(会话标识)和 message(用户自然语言输入)。
3.3 Agent 编排服务(BlogAgentService.java)
这是整个插件的核心。它把用户消息交给 AI Foundation,同时把插件定义的工具集注入到请求中,让模型自主决定调用哪个工具。
java
@Service
public class BlogAgentService {
private final AiClient aiClient; // AI Foundation 提供的模型客户端
private final PostTools postTools;
private final UpvoteTools upvoteTools;
private final ContentTools contentTools;
public BlogAgentService(AiClient aiClient,
PostTools postTools,
UpvoteTools upvoteTools,
ContentTools contentTools) {
this.aiClient = aiClient;
this.postTools = postTools;
this.upvoteTools = upvoteTools;
this.contentTools = contentTools;
}
public Mono<ChatResult> chat(ChatRequest request) {
var tools = List.of(
postTools.createPostTool(),
upvoteTools.upvoteTool(),
contentTools.searchAndSummarizeTool(),
contentTools.extractHighlightsTool()
);
return aiClient.chat(ChatRequest.builder()
.sessionId(request.sessionId())
.systemPrompt("""
你是 Halo 博客的 AI 助手。你可以帮助用户:
- 创建并发布文章
- 给文章点赞
- 搜索并整理文章
- 提取文章中的优秀片段
当用户请求涉及以上操作时,调用对应工具完成。
操作完成后用简洁的中文告知用户结果。
""")
.message(request.message())
.tools(tools)
.build());
}
}
关键设计:systemPrompt 明确告诉模型“有哪些工具可用、什么场景下用”,模型根据用户意图自动选择工具。AI Foundation 负责处理模型返回的 tool_calls,执行对应的 Java 方法,并把结果回传给模型生成最终回复。
四、工具实现:覆盖你列出的四个核心场景
4.1 发文章工具(PostTools.java)
Halo 的文章创建通过 /apis/content.halo.run/v1alpha1/posts 完成。插件内部可以直接注入 Halo 的 ReactiveExtensionClient 来操作 Post 自定义模型,也可以调用 REST API。这里用 ReactiveExtensionClient,避免额外的 HTTP 开销。
@Component
public class PostTools {
private final ReactiveExtensionClient client;
public PostTools(ReactiveExtensionClient client) {
this.client = client;
}
public Tool createPostTool() {
return Tool.builder()
.name("createPost")
.description("在 Halo 博客中创建一篇新文章并发布。参数:title(标题),content(Markdown 正文),slug(可选,URL 别名)")
.inputSchema(Map.of(
"type", "object",
"properties", Map.of(
"title", Map.of("type", "string", "description", "文章标题"),
"content", Map.of("type", "string", "description", "Markdown 格式的正文"),
"slug", Map.of("type", "string", "description", "URL 别名,可选")
),
"required", List.of("title", "content")
))
.handler(args -> {
String title = (String) args.get("title");
String content = (String) args.get("content");
String slug = (String) args.getOrDefault("slug", "");
Post post = new Post();
post.setMetadata(new Metadata());
post.getMetadata().setGenerateName("ai-post-");
post.getMetadata().setAnnotations(Map.of("content.halo.run/slug", slug));
Post.Spec spec = new Post.Spec();
spec.setTitle(title);
spec.setContent(content); // Halo 的 Post content 可以是 Markdown
spec.setPublish(true);
spec.setPinned(false);
spec.setAllowComment(true);
post.setSpec(spec);
return client.create(post)
.map(created -> "文章已发布:/archives/" + created.getMetadata().getName());
})
.build();
}
}
Halo 的 Post 是自定义模型(Custom Model),ReactiveExtensionClient.create() 是标准的响应式创建入口。publish(true) 表示直接发布,如果要先存草稿可以设为 false。
4.2 点赞工具(UpvoteTools.java)
Halo 的点赞接口路径是 /apis/api.halo.run/v1alpha1/trackers/upvote,请求体包含 group、plural、name 三个字段,分别对应资源组、资源复数名和资源名称。
@Component
public class UpvoteTools {
private final WebClient webClient; // 指向当前 Halo 实例
public Tool upvoteTool() {
return Tool.builder()
.name("upvotePost")
.description("给指定的 Halo 文章点赞。参数:postName(文章的唯一名称,即 metadata.name)")
.inputSchema(Map.of(
"type", "object",
"properties", Map.of(
"postName", Map.of("type", "string", "description", "文章的 metadata.name")
),
"required", List.of("postName")
))
.handler(args -> {
String postName = (String) args.get("postName");
return webClient.post()
.uri("/apis/api.halo.run/v1alpha1/trackers/upvote")
.bodyValue(Map.of(
"group", "content.halo.run",
"plural", "posts",
"name", postName
))
.retrieve()
.bodyToMono(String.class)
.thenReturn("已为文章 " + postName + " 点赞");
})
.build();
}
}
如果点赞接口需要认证,插件内部调用可以复用 Halo 的安全上下文,或者通过 WebClient 携带当前用户的 Token。Halo 的 CustomEndpoint 会将请求包装为 SecureServerRequest,下游处理器可以拿到已认证的安全上下文。
4.3 搜索 + 整理文章工具(ContentTools.java)
Halo 的全文搜索接口是 /apis/api.halo.run/v1alpha1/indices/post,用 keyword 参数检索文章。这个工具负责“搜索 → 获取详情 → 整理成结构化摘要”的全流程。
public Tool searchAndSummarizeTool() {
return Tool.builder()
.name("searchAndSummarizePosts")
.description("搜索 Halo 博客中的文章,并整理出标题、摘要、分类、标签等结构化信息。参数:keyword(搜索关键词)")
.inputSchema(Map.of(
"type", "object",
"properties", Map.of(
"keyword", Map.of("type", "string", "description", "搜索关键词")
),
"required", List.of("keyword")
))
.handler(args -> {
String keyword = (String) args.get("keyword");
return webClient.get()
.uri(uriBuilder -> uriBuilder
.path("/apis/api.halo.run/v1alpha1/indices/post")
.queryParam("keyword", keyword)
.queryParam("limit", 10)
.build())
.retrieve()
.bodyToMono(JsonNode.class)
.map(this::formatSearchResult);
})
.build();
}
formatSearchResult 把 JSON 响应整理成 Markdown 列表,包含标题、摘要、链接、发布时间、分类和标签。AI 拿到这个结构化结果后,会用自己的语言向用户呈现,而不是把原始 JSON 直接抛给用户。
4.4 提取优秀片段工具(ContentTools.java)
这个工具接收文章标识,获取文章全文,然后让 AI 在工具内部或后续轮次中提取重点片段。实现上有两种粒度:
方案 A:工具只负责“取全文”,提取交给 AI 的推理能力。
public Tool getPostContentTool() {
return Tool.builder()
.name("getPostContent")
.description("获取指定文章的完整正文。参数:postName(文章的 metadata.name)")
.inputSchema(...)
.handler(args -> {
String postName = (String) args.get("postName");
return client.fetch(Post.class, postName)
.map(post -> post.getSpec().getContent())
.defaultIfEmpty("文章不存在或内容为空");
})
.build();
}
方案 B:工具内部直接调用 AI 完成“提取片段”的专项任务。 如果希望工具返回的就是“已经提取好的片段”,可以在 handler 内再发起一次 AI 调用(使用 AI Foundation 的文本生成能力),prompt 为:“从以下文章中提取 3 个最值得分享的片段,每个片段附上简短说明”。这适合“整理一些重点文章的优秀片段”这种明确指令。
五、前端注入:Console 聊天面板
Halo 控制台的前端路由和菜单通过 @halo-dev/ui-shared 的 definePlugin 动态注册。插件可以声明一个路由,指向自己的 Vue 组件,该组件就是一个简单的聊天界面。
5.1 注册 Console 路由
在插件的前端入口中:
import { definePlugin } from '@halo-dev/ui-shared'
export default definePlugin({
components: {},
routes: [
{
path: '/ai-blog-agent',
name: 'AiBlogAgent',
component: () => import('./components/AgentChatPanel.vue'),
meta: {
menu: {
name: 'AI 博客助手',
group: 'content',
icon: ' ri:robot-2-line',
priority: 30,
},
},
},
],
})
这样插件安装启用后,控制台侧边栏“内容”分组下就会出现“AI 博客助手”菜单项。
5.2 聊天面板组件(AgentChatPanel.vue)
面板只需要做三件事:展示消息列表、发送用户输入、渲染 AI 回复(包括工具调用状态)。
<template>
<div class="agent-chat-panel">
<div class="messages" ref="messagesRef">
<div v-for="msg in messages" :key="msg.id" :class="['message', msg.role]">
<div class="bubble" v-html="renderMarkdown(msg.content)" />
<div v-if="msg.toolCalls?.length" class="tool-calls">
<span v-for="tc in msg.toolCalls" :key="tc.id" class="tool-badge">
🔧 {{ tc.name }} {{ tc.status === 'done' ? '✓' : '...' }}
</span>
</div>
</div>
</div>
<div class="input-area">
<textarea
v-model="input"
placeholder="例如:帮我发一篇关于 Halo 插件开发的文章 / 给 xxx 文章点赞 / 整理最近的优秀文章"
@keydown.enter.exact.prevent="send"
/>
<button @click="send" :disabled="loading">发送</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, nextTick } from 'vue'
const messages = ref<Message[]>([])
const input = ref('')
const loading = ref(false)
const messagesRef = ref<HTMLElement>()
async function send() {
if (!input.value.trim() || loading.value) return
const userMsg = input.value
input.value = ''
messages.value.push({ id: crypto.randomUUID(), role: 'user', content: userMsg })
loading.value = true
try {
const res = await fetch(
'/apis/console.api.ai-blog-agent.halo.run/v1alpha1/chat',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId: 'default', message: userMsg }),
}
)
const data = await res.json()
messages.value.push({
id: crypto.randomUUID(),
role: 'assistant',
content: data.content,
toolCalls: data.toolCalls,
})
} finally {
loading.value = false
await nextTick()
messagesRef.value?.scrollTo({ top: messagesRef.value.scrollHeight })
}
}
</script>
更精细的做法是使用 AI Foundation 的 SSE 流式能力,通过 DefaultChatTransport 逐步渲染 AI 回复和工具调用状态,而不是等待完整响应。上面为了简洁使用了普通 JSON 响应,在插件设置中可以选择是否开启流式模式。
六、权限与设置
6.1 角色模板(role-template.yaml)
Halo 插件中的自定义 API 默认只有超级管理员可以访问。如果你希望作者角色也能使用 AI 助手,需要定义角色模板:
apiVersion: v1alpha1
kind: Role
metadata:
name: ai-blog-agent-role
labels:
halo.run/role-template: "true"
rules:
- apiGroups: ["console.api.ai-blog-agent.halo.run"]
resources: ["chat"]
verbs: ["create"]
6.2 插件设置(settings.yaml)
在插件设置中暴露 AI 模型选择和是否允许发布文章的开关,模型选择可以直接复用 AI Foundation 的模型配置。
apiVersion: v1alpha1
kind: Setting
metadata:
name: ai-blog-agent-settings
spec:
forms:
- group: basic
label: AI 博客助手设置
formSchema:
- $formkit: select
name: modelName
label: 对话模型
options:
- label: 站点默认模型
value: default
- $formkit: checkbox
name: allowPublishPost
label: 允许 AI 直接发布文章
value: false
- $formkit: checkbox
name: allowUpvote
label: 允许 AI 执行点赞操作
value: true
在 BlogAgentService 中读取这些设置:如果 allowPublishPost 为 false,createPostTool() 返回的工具描述中应注明“仅创建草稿,不发布”,或者直接从工具列表中移除该工具。
七、部署与验证步骤
准备环境:Halo 2.20+,已安装并启用 AI Foundation 插件,并在 AI Foundation 中配置好至少一个模型提供商(OpenAI / DeepSeek / 通义千问等)。
构建插件:./gradlew build,产物为 build/libs/plugin-ai-blog-agent-*.jar。
安装插件:Halo 控制台 → 插件 → 安装,上传 JAR,启用。
配置模型:进入插件设置,确认模型选择为“站点默认模型”(或指定具体模型)。
验证对话:侧边栏打开“AI 博客助手”,依次测试:
“帮我搜索关于‘插件开发’的文章并整理一下” → 应触发 searchAndSummarizePosts 工具。
“给刚才搜索结果里的第一篇点赞” → AI 应先拿到上文的文章名称,再触发 upvotePost。
“写一篇关于 Halo WebFlux 自定义端点的短文并发布” → 应触发 createPost(如果开启了发布权限)。
“提取《xxx》这篇文章里最值得分享的三个片段” → 应触发 getPostContent,AI 随后完成片段提取。
八、与 MCP / Skills 方案的对比定位
社区已有的 Halo MCP Server 提供了 30+ 管理工具,包括文章创建、发布、分类标签管理等。你的插件在工具覆盖面上可以对齐它,但把入口收回到 Halo 后台内部,降低使用门槛。如果后续要扩展功能,可以优先增加“批量标签整理”“站点内容巡检”“按阅读量推荐重点文章”等工具。
九、可扩展方向
这个插件骨架完成后,以下几个方向可以作为后续迭代点,也是博文中展示“项目可生长”的部分:
RAG 增强:利用 AI Foundation 的文本嵌入能力,对文章建立向量索引,让“整理优秀文章”不再依赖关键词搜索,而是语义检索。
流式 + 工具调用可视化:接入 AI Foundation 的 SSE 流式 SDK,实时展示 AI 的推理步骤和工具执行状态,体验更接近现代 AI 产品。
操作预览与确认:对“发布文章”“点赞”等写操作,在工具执行前先返回一个“待确认”状态,由用户在聊天面板中点击确认后再真正执行,避免 AI 误操作。
多会话管理:当前 sessionId 为固定值,可以扩展为多会话,在面板中切换历史对话。