feat: Introduce Wiki Fixer agent and related functionalities

- Added a new built-in agent, "Wiki Fixer," designed to repair and optimize Wiki pages based on linter issues, with multilingual support for enhanced accessibility.
- Implemented a corresponding system prompt detailing the agent's role, mission, and workflow for effective issue resolution.
- Introduced new API functions for listing and updating wiki issues, allowing for better management of content conflicts and errors.
- Enhanced the frontend with new UI elements to display pending issues and facilitate auto-fixing, improving user interaction with the Wiki content.

These changes significantly enhance the agent's capabilities for maintaining the accuracy and quality of Wiki pages, fostering a more reliable knowledge base.
This commit is contained in:
wizardchen
2026-04-10 17:56:17 +08:00
parent 3e47376539
commit bd096454e1
29 changed files with 1724 additions and 50 deletions
+51
View File
@@ -186,3 +186,54 @@ builtin_agents:
rerank_top_k: 10
rerank_threshold: 0.3
- id: "builtin-wiki-fixer"
avatar: "🔧"
is_builtin: true
i18n:
default:
name: "Wiki Fixer"
description: "Specialized agent for repairing and optimizing Wiki pages based on linter issues"
zh-CN:
name: "维基修复助手"
description: "专门用于根据巡检问题修复和优化 Wiki 页面的智能体"
zh-TW:
name: "維基修復助手"
description: "專門用於根據巡檢問題修復和優化 Wiki 頁面的智能體"
ja-JP:
name: "Wiki修正アシスタント"
description: "リンターの問題に基づいてWikiページを修復および最適化するための専用エージェント"
ko-KR:
name: "위키 수정 도우미"
description: "린터 문제를 기반으로 Wiki 페이지를 복구하고 최적화하는 전용 에이전트"
config:
agent_mode: "smart-reasoning"
system_prompt_id: "wiki_fixer"
temperature: 0.7
max_completion_tokens: 4096
max_iterations: 30
kb_selection_mode: "selected"
retrieve_kb_only_when_mentioned: false
retain_retrieval_history: true
allowed_tools:
- "thinking"
- "todo_write"
- "wiki_search"
- "wiki_read_page"
- "wiki_read_source_doc"
- "wiki_write_page"
- "wiki_replace_text"
- "wiki_rename_page"
- "wiki_delete_page"
- "wiki_read_issue"
- "wiki_update_issue"
web_search_enabled: false
web_search_max_results: 0
reflection_enabled: false
multi_turn_enabled: true
history_turns: 10
embedding_top_k: 10
keyword_threshold: 0.3
vector_threshold: 0.5
rerank_top_k: 10
rerank_threshold: 0.3
@@ -277,3 +277,73 @@ templates:
Current Time: {{current_time}}
User Language: {{language}}
</system_status>
- id: "wiki_fixer"
name: "Wiki Fixer System Prompt"
description: "System prompt for repairing and optimizing Wiki pages based on linter issues"
i18n:
zh-CN:
name: "Wiki 修复系统提示词"
description: "用于根据巡检问题修复和优化 Wiki 页面的系统提示词"
en-US:
name: "Wiki Fixer System Prompt"
description: "System prompt for repairing and optimizing Wiki pages based on linter issues"
mode: "smart-reasoning"
content: |
<role>
You are the WeKnora Wiki Fixer, a specialized AI agent responsible for maintaining, repairing, and optimizing pages in a Markdown-based Wiki knowledge base.
</role>
<mission>
Your primary goal is to resolve "Issues" reported by the Wiki Linter or users. These issues usually involve factual conflicts, mixed entities (e.g. merging two different products), or outdated information.
You must carefully investigate the reported issue, trace back to the original source documents if necessary, determine the correct information, and apply fixes directly to the Wiki pages.
</mission>
<workflow>
You must follow these steps for EVERY fixing task:
1. **Understand the Issue:** The user will provide you with the target page slug, issue description, and optionally suspected knowledge IDs. Use `wiki_read_issue` if you need to list other pending issues.
2. **Read the Current State:** Call `wiki_read_page` on the target slug to see the current content and related links. Use `wiki_search` if you need to find related entities or check if a page already exists for separated content.
3. **Investigate Sources:** If the issue involves conflicting facts or mixed entities, the current wiki page might be poisoned. You MUST call `wiki_read_source_doc` using the `knowledge_id`s listed in the page's `<sources>` or provided in the issue description. Read the raw text to discover the truth.
4. **Determine the Fix Strategy:**
- *Correction:* Fix minor errors efficiently using the `wiki_replace_text` tool, or rewrite the page using `wiki_write_page`.
- *Renaming:* If the page title or slug is fundamentally wrong, use `wiki_rename_page` to change the slug. Incoming links will be updated automatically!
- *Separation (Disambiguation):* Rewrite the target page to only focus on its true subject (using `wiki_write_page`), and remove the competitor's info.
- *Creation:* Create a new page for the separated entity using `wiki_write_page`.
- *Deletion:* If a page is completely redundant or should not exist, use `wiki_delete_page`. Incoming links will be cleaned up automatically!
5. **Plan & Confirm:** Before calling any write/replace/rename/delete tool, you MUST present a plan of what you intend to change to the user and ask for their confirmation. Wait for the user to say "yes" or "approved" or express general agreement before making the actual edits. You should be robust to conversational affirmative responses (e.g. "好的", "ok", "go ahead", "没问题") instead of requiring exact phrase matches.
6. **Apply the Fix:** After user confirmation, use the appropriate tool.
- For `wiki_replace_text`, provide the exact `old_text` and the `new_text`.
- For `wiki_rename_page`, provide the `new_slug`.
- For `wiki_write_page`, provide the `title`, a concise 1-sentence `summary` for the index, the `page_type`, and the FULL, complete, corrected Markdown `content`. Do not output diffs in `content`.
- For `wiki_delete_page`, just provide the `slug`.
7. **Update Issue Status:** Use `wiki_update_issue` to mark the issue as "resolved" after successfully editing the page.
</workflow>
<constraints>
ABSOLUTE RULES:
1. **Never Guess:** Always base your fixes on evidence found in the raw source documents (`wiki_read_source_doc`).
2. **Require Confirmation:** NEVER edit without first presenting your plan and getting explicit approval from the user in the conversation. Accept variations of affirmative responses (like "ok", "好的", "没问题", "可以").
3. **Complete Content for Write:** When using `wiki_write_page`, you must provide the ENTIRE page content. Do not truncate or use placeholders like "...rest of the content...". Also, NEVER forget to provide the one-sentence `summary` field.
4. **Exact Match for Replace:** When using `wiki_replace_text`, the `old_text` must EXACTLY match the text currently in the page.
5. **Maintain Links:** When rewriting a page, try to preserve valid Wiki links `[[slug|Display Name]]`.
6. **Writing Style:** You must strictly follow the standard Wiki writing style:
- Use proper heading hierarchy (`##` for sections, `###` for subsections).
- Include a "## Key Takeaways" section with bullet points at the end.
- Preserve any valid image links `![alt](url)`.
7. **Source Refs:** When calling `wiki_write_page` or `wiki_replace_text`, you MUST provide the `source_refs` array containing the `knowledge_id`s of the source documents you used to verify the information.
</constraints>
<tool_guidelines>
* **wiki_read_page:** Use this first to see what the broken page looks like.
* **wiki_search:** Use this to explore the wiki if you need to find related concepts or verify if another page already exists.
* **wiki_read_source_doc:** Use this to find the ground truth. It is crucial for resolving "contradictory_facts" or "mixed_entities" issues.
* **wiki_read_issue:** Use this to read specific issue details or list pending issues for a page.
* **todo_write:** Use this to write down the plan and modifications you intend to make, so that you can remember them across conversation turns, and present them to the user.
* **wiki_write_page / wiki_replace_text / wiki_rename_page / wiki_delete_page:** Use these to apply your fix ONLY AFTER the user confirms your plan.
* **wiki_update_issue:** Use this to set the issue status to "resolved" after the page is fixed.
</tool_guidelines>
<system_status>
Current Time: {{current_time}}
User Language: {{language}}
</system_status>
+26
View File
@@ -41,9 +41,24 @@ export interface WikiStats {
orphan_count: number;
recent_updates: WikiPage[];
pending_tasks: number;
pending_issues: number;
is_active: boolean;
}
export interface WikiPageIssue {
id: string;
tenant_id: number;
knowledge_base_id: string;
slug: string;
issue_type: string;
description: string;
suspected_knowledge_ids: string[];
status: string;
reported_by: string;
created_at: string;
updated_at: string;
}
// Wiki API Functions
export function listWikiPages(kbId: string, params?: {
page_type?: string;
@@ -104,6 +119,17 @@ export function searchWikiPages(kbId: string, q: string, limit?: number) {
return get(`/api/v1/knowledgebase/${kbId}/wiki/search?${params.toString()}`);
}
export function listWikiIssues(kbId: string, slug?: string, status?: string) {
const params = new URLSearchParams();
if (slug) params.set('slug', slug);
if (status) params.set('status', status);
return get(`/api/v1/knowledgebase/${kbId}/wiki/issues?${params.toString()}`);
}
export function updateWikiIssueStatus(kbId: string, issueId: string, status: string) {
return put(`/api/v1/knowledgebase/${kbId}/wiki/issues/${issueId}/status`, { status });
}
export function rebuildWikiLinks(kbId: string) {
return post(`/api/v1/knowledgebase/${kbId}/wiki/rebuild-links`, {});
}
+18 -14
View File
@@ -1704,17 +1704,16 @@ const getBuiltinAgentNotReadyReasons = (agent: CustomAgent, isAgentMode: boolean
const reasons: string[] = []
const config = agent.config || {}
// 检查对话模型(Summary Model
if (!config.model_id || config.model_id.trim() === '') {
reasons.push(t('input.customAgentMissingSummaryModel'))
}
// 内置智能体会自动回退到租户的默认模型,因此不再在前端强制校验 model_id
// 检查重排模型(Rerank Model- 如果使用知识库则需要
if (config.kb_selection_mode !== 'none') {
if (!config.rerank_model_id || config.rerank_model_id.trim() === '') {
reasons.push(t('input.customAgentMissingRerankModel'))
}
}
// 检查重排模型(Rerank Model- 仅当允许使用 knowledge_search 工具时需要
// 内置智能体允许重排模型为空(使用默认配置)
// const hasKnowledgeSearchTool = config.allowed_tools && config.allowed_tools.includes('knowledge_search')
// if (hasKnowledgeSearchTool) {
// if (!config.rerank_model_id || config.rerank_model_id.trim() === '') {
// reasons.push(t('input.customAgentMissingRerankModel'))
// }
// }
// Agent 模式还需要检查允许的工具
if (isAgentMode) {
@@ -1735,8 +1734,9 @@ const getCustomAgentNotReadyReasons = (agent: CustomAgent): string[] => {
if (!config.model_id || config.model_id.trim() === '') {
reasons.push(t('input.customAgentMissingSummaryModel'))
}
// 检查重排模型(Rerank Model- 如果使用知识库则需要
if (config.kb_selection_mode !== 'none') {
// 检查重排模型(Rerank Model- 仅当允许使用 knowledge_search 工具时需要
const hasKnowledgeSearchTool = config.allowed_tools && config.allowed_tools.includes('knowledge_search')
if (hasKnowledgeSearchTool) {
if (!config.rerank_model_id || config.rerank_model_id.trim() === '') {
reasons.push(t('input.customAgentMissingRerankModel'))
}
@@ -2211,13 +2211,17 @@ const getImgSrc = (url: string) => {
z-index: 99;
bottom: 60px;
left: 50%;
transform: translateX(-400px);
transform: translateX(-50%);
width: 100%;
display: flex;
justify-content: center;
}
/* 富文本输入框容器 */
.rich-input-container {
position: relative;
width: 800px;
width: 100%;
max-width: 800px;
background: var(--td-bg-color-container, #FFF);
border-radius: 12px;
border: .5px solid var(--td-component-border, #E7E7E7);
+21
View File
@@ -1638,6 +1638,27 @@ export default {
hideArrows: 'Hide arrows',
healthCheck: 'Health Check',
queueStatus: '{count} pending tasks in Wiki queue',
issueTitle: 'This page has {count} pending knowledge conflicts or errors',
issueFixBtn: 'AI Auto-Fix',
issueMixed: 'Mixed Info',
issueConflict: 'Factual Conflict',
issueOutdated: 'Outdated',
issueAttention: 'Needs Attention',
issueReportedBy: 'Reported by {reporter}',
issueAiLinter: 'AI Linter',
issueIgnore: 'Ignore False Alarm',
globalIssuesTitle: 'Global Pending Content Issues',
globalIssuesCount: '{count} Pending Content Issues',
globalIssuesEmpty: 'No pending content issues',
issuePagePrefix: 'Page: ',
issueGoFix: 'Go to fix',
fixAssistantTitle: 'Wiki Smart Fix Assistant',
issueFixSuggestions: 'Content Optimization Suggestions ({count})',
issueFixSingle: 'Fix',
fixStartError: 'Failed to start fix assistant',
issueFixPromptSingle: 'Please help me fix the following issue on page [[{slug}]]:\n\n- [{type}] {desc}\n\nOnce resolved, please call the `wiki_update_issue` tool to mark this issue (ID: {id}) as resolved.',
issueFixPromptAutoStart: 'Please help me fix all pending issues on page [[{slug}]]:',
issueFixPromptAutoEnd: 'Once resolved, please call the `wiki_update_issue` tool to mark these issues as resolved.',
},
buttons: {
create: 'Create Knowledge Base',
+21
View File
@@ -2161,6 +2161,27 @@ export default {
hideArrows: '화살표 숨기기',
healthCheck: "상태 점검",
queueStatus: "Wiki 대기열에 {count}개의 보류 중인 작업",
issueTitle: "이 페이지에는 {count}개의 대기 중인 지식 충돌 또는 오류가 있습니다",
issueFixBtn: "AI 자동 수정",
issueMixed: "혼합된 정보",
issueConflict: "사실 충돌",
issueOutdated: "오래된 정보",
issueAttention: "주의 필요",
issueReportedBy: "{reporter} 보고함",
issueAiLinter: "AI 검사기",
issueIgnore: "오탐지 무시",
globalIssuesTitle: "전체 보류 중인 콘텐츠 문제",
globalIssuesCount: "보류 중인 콘텐츠 문제: {count}개",
globalIssuesEmpty: "보류 중인 콘텐츠 문제가 없습니다",
issuePagePrefix: "페이지: ",
issueGoFix: "수정하러 가기",
fixAssistantTitle: "Wiki 스마트 수정 도우미",
issueFixSuggestions: "콘텐츠 최적화 제안 ({count})",
issueFixSingle: "수정",
fixStartError: "수정 도우미 시작 실패",
issueFixPromptSingle: "페이지 [[{slug}]] 의 다음 문제를 수정해 주세요:\n\n- [{type}] {desc}\n\n해결 완료 후 `wiki_update_issue` 도구를 호출하여 이 문제(ID: {id})를 해결됨으로 표시해 주세요.",
issueFixPromptAutoStart: "페이지 [[{slug}]] 의 모든 보류 중인 문제를 수정해 주세요:",
issueFixPromptAutoEnd: "해결 완료 후 `wiki_update_issue` 도구를 호출하여 이 문제들을 해결됨으로 표시해 주세요.",
},
buttons: {
create: "지식베이스 생성",
+21
View File
@@ -1840,6 +1840,27 @@ export default {
hideArrows: 'Скрыть стрелки',
healthCheck: 'Проверка здоровья',
queueStatus: '{count} ожидающих задач в очереди Wiki',
issueTitle: 'На этой странице есть {count} неразрешенных конфликтов или ошибок знаний',
issueFixBtn: 'AI Автоисправление',
issueMixed: 'Смешанная инфо',
issueConflict: 'Фактический конфликт',
issueOutdated: 'Устарело',
issueAttention: 'Требует внимания',
issueReportedBy: 'Сообщил {reporter}',
issueAiLinter: 'AI Линтер',
issueIgnore: 'Игнорировать',
globalIssuesTitle: 'Глобальные проблемы содержимого',
globalIssuesCount: 'Ожидающие проблемы содержимого: {count}',
globalIssuesEmpty: 'Нет ожидающих проблем содержимого',
issuePagePrefix: 'Страница: ',
issueGoFix: 'Исправить',
fixAssistantTitle: 'Wiki Умный помощник исправления',
issueFixSuggestions: 'Предложения по оптимизации ({count})',
issueFixSingle: 'Исправить',
fixStartError: 'Не удалось запустить помощник исправления',
issueFixPromptSingle: 'Пожалуйста, помогите исправить следующую проблему на странице [[{slug}]]:\n\n- [{type}] {desc}\n\nПосле устранения проблемы, пожалуйста, вызовите инструмент `wiki_update_issue`, чтобы пометить эту проблему (ID: {id}) как решенную.',
issueFixPromptAutoStart: 'Пожалуйста, помогите исправить все ожидающие проблемы на странице [[{slug}]]:',
issueFixPromptAutoEnd: 'После устранения проблем, пожалуйста, вызовите инструмент `wiki_update_issue`, чтобы пометить эти проблемы как решенные.',
},
buttons: {
create: 'Создать базу знаний',
+21
View File
@@ -2146,6 +2146,27 @@ export default {
hideArrows: '隐藏箭头',
healthCheck: "健康检查",
queueStatus: "Wiki 队列中 {count} 个待处理任务",
issueTitle: "此页面存在 {count} 个待处理的知识冲突或错误",
issueFixBtn: "AI 一键修复",
issueMixed: "信息混杂",
issueConflict: "事实冲突",
issueOutdated: "信息过时",
issueAttention: "需要关注",
issueReportedBy: "由 {reporter} 报告",
issueAiLinter: "AI 巡检",
issueIgnore: "忽略误报",
globalIssuesTitle: "全库待修复内容问题",
globalIssuesCount: "待修复内容问题: {count} 项",
globalIssuesEmpty: "暂无待处理的内容问题",
issuePagePrefix: "页面: ",
issueGoFix: "去处理",
fixAssistantTitle: "Wiki 智能修复助手",
issueFixSuggestions: "内容优化建议 ({count})",
issueFixSingle: "修复",
fixStartError: "启动修复助手失败",
issueFixPromptSingle: "请帮我修复页面 [[{slug}]] 上的以下问题:\n\n- [{type}] {desc}\n\n解决完毕后,请调用 `wiki_update_issue` 工具将该问题 (ID: {id}) 标记为已解决。",
issueFixPromptAutoStart: "请帮我修复页面 [[{slug}]] 上的所有待处理问题:",
issueFixPromptAutoEnd: "解决完毕后,请调用 `wiki_update_issue` 工具将这些问题标记为已解决。",
},
buttons: {
create: "创建知识库",
+15 -1
View File
@@ -1,5 +1,5 @@
<template>
<div class="bot_msg">
<div class="bot_msg" :class="{ 'is-embedded': embeddedMode }">
<div style="display: flex;flex-direction: column; gap:8px">
<!-- 显示@的知识库和文件 Agent 模式下显示 -->
<div v-if="!session.isAgentMode && mentionedItems && mentionedItems.length > 0" class="mentioned_items">
@@ -127,6 +127,10 @@ const props = defineProps({
isFirstEnter: {
type: Boolean,
required: false
},
embeddedMode: {
type: Boolean,
default: false
}
});
@@ -298,6 +302,16 @@ onBeforeUnmount(() => {
@import '../../../components/css/markdown.less';
@import '../../../components/css/chat-message-shared.less';
.bot_msg {
&.is-embedded {
width: 100%;
:deep(.agent-stream-display) {
width: 100%;
}
}
}
// - Agent answer
.content-wrapper {
background: var(--td-bg-color-container);
@@ -197,6 +197,7 @@ const getWebSearchDisplayText = (item) => {
background-color: var(--td-bg-color-container);
border: .5px solid var(--td-component-stroke);
box-shadow: 0 2px 4px rgba(7, 192, 95, 0.08);
box-sizing: border-box;
overflow: hidden;
box-sizing: border-box;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
+13 -2
View File
@@ -1,5 +1,5 @@
<template>
<div class="user_msg_container" ref="containerRef">
<div class="user_msg_container" ref="containerRef" :class="{ 'is-embedded': embeddedMode }">
<!-- 显示@的知识库和文件 -->
<div v-if="mentioned_items && mentioned_items.length > 0" class="mentioned_items">
<span
@@ -84,6 +84,10 @@ const props = defineProps({
type: String,
required: false,
default: ''
},
embeddedMode: {
type: Boolean,
default: false
}
});
@@ -216,6 +220,14 @@ const closePreImg = () => {
}
}
.user_msg_container {
&.is-embedded {
.user_msg {
max-width: 100%;
}
}
}
.user_msg {
width: max-content;
max-width: 776px;
@@ -233,7 +245,6 @@ const closePreImg = () => {
font-size: 15px;
text-align: justify;
word-break: break-all;
max-width: 100%;
box-sizing: border-box;
}
+53 -8
View File
@@ -1,7 +1,7 @@
<template>
<div class="chat">
<div class="chat" :class="{ 'is-embedded': embeddedMode }">
<div ref="scrollContainer" class="chat_scroll_box" @scroll="handleScroll">
<div class="msg_list">
<div class="msg_list" :class="{ 'is-embedded': embeddedMode }">
<!-- 消息列表骨架屏 -->
<div v-if="historyLoading && messagesList.length === 0" class="msg-skeleton-list">
<div class="msg-skeleton msg-skeleton-user">
@@ -47,11 +47,11 @@
</div>
<div v-for="(session, id) in messagesList" :key='id'>
<div v-if="session.role == 'user'">
<usermsg :content="session.content" :mentioned_items="session.mentioned_items" :images="session.images" :attachments="session.attachments"></usermsg>
<usermsg :content="session.content" :mentioned_items="session.mentioned_items" :images="session.images" :attachments="session.attachments" :embeddedMode="embeddedMode"></usermsg>
</div>
<div v-if="session.role == 'assistant'">
<botmsg :content="session.content" :session="session" :user-query="getUserQuery(id)" @scroll-bottom="scrollToBottom"
:isFirstEnter="isFirstEnter"></botmsg>
:isFirstEnter="isFirstEnter" :embeddedMode="embeddedMode"></botmsg>
</div>
</div>
<div v-if="loading"
@@ -69,7 +69,7 @@
<t-icon name="chevron-down" size="20px" />
</div>
</transition>
<div style="min-height: 115px; margin: 16px auto 4px;width: 100%;max-width: 800px;">
<div class="input-container" :class="{ 'is-embedded': embeddedMode }">
<InputField
ref="inputFieldRef"
@send-msg="(query, modelId, mentionedItems, imageFiles, attachmentFiles) => sendMsg(query, modelId, mentionedItems, imageFiles, attachmentFiles)"
@@ -77,6 +77,7 @@
:isReplying="isReplying"
:sessionId="session_id"
:assistantMessageId="currentAssistantMessageId"
:embeddedMode="embeddedMode"
></InputField>
</div>
</div>
@@ -91,7 +92,7 @@
</template>
<script setup>
import { storeToRefs } from 'pinia';
import { ref, onMounted, onUnmounted, nextTick, watch, reactive, onBeforeUnmount } from 'vue';
import { ref, onMounted, onUnmounted, nextTick, watch, reactive, onBeforeUnmount, defineProps } from 'vue';
import { useRoute, useRouter, onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router';
import InputField from '../../components/Input-field.vue';
import botmsg from './components/botmsg.vue';
@@ -106,6 +107,14 @@ import { useI18n } from 'vue-i18n';
import { useUIStore } from '@/stores/ui';
import KnowledgeBaseEditorModal from '@/views/knowledge/KnowledgeBaseEditorModal.vue';
import { useKnowledgeBaseCreationNavigation } from '@/hooks/useKnowledgeBaseCreationNavigation';
const props = defineProps({
session_id: { type: String, default: '' },
agentId: { type: String, default: '' },
kbIds: { type: Array, default: () => [] },
embeddedMode: { type: Boolean, default: false }
});
const usemenuStore = useMenuStore();
const useSettingsStoreInstance = useSettingsStore();
const uiStore = useUIStore();
@@ -115,7 +124,7 @@ const { menuArr, isFirstSession, firstQuery, firstMentionedItems, firstModelId,
const { output, onChunk, isStreaming, isLoading, error, startStream, stopStream } = useStream();
const route = useRoute();
const router = useRouter();
const session_id = ref(route.params.chatid);
const session_id = ref(props.session_id || route.params.chatid);
const sessionData = ref(null);
const inputFieldRef = ref();
const created_at = ref('');
@@ -1140,10 +1149,16 @@ onMounted(async () => {
messagesList.splice(0);
// URL agent_id source_tenant_id store
const agentIdFromQuery = route.query.agent_id && String(route.query.agent_id);
const agentIdFromQuery = props.embeddedAgentId || (route.query.agent_id && String(route.query.agent_id));
const sourceTenantIdFromQuery = route.query.source_tenant_id && String(route.query.source_tenant_id);
if (agentIdFromQuery && sourceTenantIdFromQuery) {
useSettingsStoreInstance.selectAgent(agentIdFromQuery, sourceTenantIdFromQuery);
} else if (agentIdFromQuery) {
useSettingsStoreInstance.selectAgent(agentIdFromQuery, null);
}
if (props.embeddedKbIds && props.embeddedKbIds.length > 0) {
useSettingsStoreInstance.selectKnowledgeBases(props.embeddedKbIds);
}
// loading
@@ -1211,6 +1226,21 @@ onBeforeRouteUpdate((to, from, next) => {
max-width: calc(100vw - 260px);
min-width: 400px;
&.is-embedded {
max-width: 100%;
min-width: 100%;
padding: 0;
overflow-x: hidden;
}
&.is-embedded :deep(.answers-input) {
transform: translateX(0);
width: 100%;
left: 0;
display: flex;
justify-content: center;
}
:deep(.answers-input) {
position: static;
transform: translateX(0);
@@ -1321,6 +1351,21 @@ onBeforeRouteUpdate((to, from, next) => {
padding-left: 4px;
}
.input-container {
min-height: 115px;
margin: 16px auto 4px;
width: 100%;
max-width: 800px;
box-sizing: border-box;
&.is-embedded {
max-width: 100%;
width: 100%;
margin: 0;
overflow-x: hidden;
}
}
.msg_list {
display: flex;
flex-direction: column;
+488 -16
View File
@@ -6,19 +6,25 @@
<div ref="graphRef" class="wiki-graph-canvas"></div>
<!-- Graph Search Overlay -->
<div v-if="graphReady" class="wiki-graph-search">
<t-select
v-model="graphSearchValue"
filterable
:options="graphSearchOptions"
:placeholder="$t('knowledgeEditor.wikiBrowser.searchPlaceholder')"
@change="handleGraphSearchSelect"
@enter="handleGraphSearchEnter"
:popup-props="{ zIndex: 100 }"
class="graph-search-select"
>
<template #prefixIcon><t-icon name="search" /></template>
</t-select>
<div v-if="graphReady" class="wiki-graph-search-container">
<div class="wiki-graph-search">
<t-select
v-model="graphSearchValue"
filterable
:options="graphSearchOptions"
:placeholder="$t('knowledgeEditor.wikiBrowser.searchPlaceholder')"
@change="handleGraphSearchSelect"
@enter="handleGraphSearchEnter"
:popup-props="{ zIndex: 100 }"
class="graph-search-select"
>
<template #prefixIcon><t-icon name="search" /></template>
</t-select>
</div>
<div v-if="stats && stats.pending_issues > 0" class="wiki-global-issues-status graph-issues-badge" @click="showGlobalIssuesDrawer = true">
<t-icon name="error-circle" style="color: var(--td-warning-color);" />
<span class="queue-text">{{ $t('knowledgeEditor.wikiBrowser.globalIssuesCount', { count: stats.pending_issues }) }}</span>
</div>
</div>
<!-- Legend Overlay -->
@@ -121,6 +127,11 @@
<t-loading size="small" />
<span class="queue-text">{{ $t('knowledgeEditor.wikiBrowser.queueStatus', { count: stats.pending_tasks || 0 }) }}</span>
</div>
<!-- Global Issues -->
<div v-if="stats && stats.pending_issues > 0" class="wiki-global-issues-status" @click="showGlobalIssuesDrawer = true">
<t-icon name="error-circle" style="color: var(--td-warning-color);" />
<span class="queue-text">{{ $t('knowledgeEditor.wikiBrowser.globalIssuesCount', { count: stats.pending_issues }) }}</span>
</div>
<t-input
v-model="searchQuery"
:placeholder="$t('knowledgeEditor.wikiBrowser.searchPlaceholder')"
@@ -211,7 +222,64 @@
<!-- Page header -->
<div class="wiki-reader-header">
<h2 class="wiki-reader-title">{{ selectedPage.title }}</h2>
<h2 class="wiki-reader-title" style="display: flex; align-items: center;">
{{ selectedPage.title }}
<t-popup
v-if="pageIssues.length > 0"
v-model="showIssuesBox"
placement="bottom-left"
trigger="click"
:overlayInnerStyle="{ padding: 0, boxShadow: 'var(--td-shadow-3)', borderRadius: '8px', width: '560px', maxWidth: '90vw' }"
>
<t-tag
theme="warning"
variant="light-outline"
class="wiki-issue-trigger"
>
<template #icon><t-icon name="error-circle" /></template>
{{ $t('knowledgeEditor.wikiBrowser.issueTitle', { count: pageIssues.length }) }}
</t-tag>
<template #content>
<div class="wiki-issue-popup-content">
<div class="wiki-issue-popup-header">
<div class="wiki-issue-popup-title">
<span>{{ $t('knowledgeEditor.wikiBrowser.issueFixSuggestions', { count: pageIssues.length }) }}</span>
</div>
<t-button size="small" theme="primary" variant="base" @click="triggerAutoFix">
<template #icon><t-icon name="magic" /></template>
{{ $t('knowledgeEditor.wikiBrowser.issueFixBtn') }}
</t-button>
</div>
<div class="wiki-issue-popup-list">
<div v-for="issue in pageIssues" :key="issue.id" class="wiki-issue-popup-item">
<div class="wiki-issue-popup-main">
<div class="wiki-issue-popup-tags">
<t-tag v-if="issue.issue_type === 'mixed_entities'" theme="warning" variant="light" size="small">{{ $t('knowledgeEditor.wikiBrowser.issueMixed') }}</t-tag>
<t-tag v-else-if="issue.issue_type === 'contradictory_facts'" theme="danger" variant="light" size="small">{{ $t('knowledgeEditor.wikiBrowser.issueConflict') }}</t-tag>
<t-tag v-else-if="issue.issue_type === 'out_of_date'" theme="default" variant="light" size="small">{{ $t('knowledgeEditor.wikiBrowser.issueOutdated') }}</t-tag>
<t-tag v-else theme="primary" variant="light" size="small">{{ $t('knowledgeEditor.wikiBrowser.issueAttention') }}</t-tag>
</div>
<div class="wiki-issue-popup-desc">
{{ issue.description }}
</div>
<div class="wiki-issue-popup-meta">
<span class="wiki-issue-popup-reporter">
{{ issue.reported_by === 'wiki-researcher-agent' ? $t('knowledgeEditor.wikiBrowser.issueAiLinter') : $t('knowledgeEditor.wikiBrowser.issueReportedBy', { reporter: issue.reported_by }) }}
</span>
<span class="wiki-issue-popup-action" @click="triggerFixIssue(issue)" style="margin-right: 12px; font-weight: 500;">
<t-icon name="magic" style="margin-right: 2px;" />{{ $t('knowledgeEditor.wikiBrowser.issueFixSingle') }}
</span>
<span class="wiki-issue-popup-action" style="color: var(--td-text-color-placeholder);" @click="handleIssueIgnore(issue.id)">{{ $t('knowledgeEditor.wikiBrowser.issueIgnore') }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
</t-popup>
</h2>
<div v-if="selectedPage.aliases && selectedPage.aliases.length" class="wiki-reader-aliases">
<span class="wiki-alias-label">{{ $t('knowledgeEditor.wikiBrowser.aliases') }}:</span>
<t-tag v-for="alias in selectedPage.aliases" :key="alias" size="small" variant="light" class="wiki-alias-tag">
@@ -281,26 +349,96 @@
<Teleport to="body">
<picturePreview v-if="imagePreviewVisible" :reviewImg="imagePreviewVisible" :reviewUrl="imagePreviewUrl" @closePreImg="closeImagePreview" />
</Teleport>
<!-- Global Issues Drawer -->
<t-drawer
v-model:visible="showGlobalIssuesDrawer"
:header="$t('knowledgeEditor.wikiBrowser.globalIssuesTitle')"
size="480px"
:footer="false"
class="wiki-global-issues-drawer"
>
<div class="wiki-issue-popup-list">
<div v-for="issue in globalIssues" :key="issue.id" class="wiki-issue-popup-item">
<div class="wiki-issue-popup-main">
<div class="wiki-issue-popup-tags">
<t-tag v-if="issue.issue_type === 'mixed_entities'" theme="warning" variant="light" size="small">{{ $t('knowledgeEditor.wikiBrowser.issueMixed') }}</t-tag>
<t-tag v-else-if="issue.issue_type === 'contradictory_facts'" theme="danger" variant="light" size="small">{{ $t('knowledgeEditor.wikiBrowser.issueConflict') }}</t-tag>
<t-tag v-else-if="issue.issue_type === 'out_of_date'" theme="default" variant="light" size="small">{{ $t('knowledgeEditor.wikiBrowser.issueOutdated') }}</t-tag>
<t-tag v-else theme="primary" variant="light" size="small">{{ $t('knowledgeEditor.wikiBrowser.issueAttention') }}</t-tag>
</div>
<div class="wiki-issue-popup-desc">
<div style="font-weight: 500; margin-bottom: 4px; color: var(--td-brand-color); cursor: pointer;" @click="navigateToSlugAndFix(issue.slug)">
<t-icon name="link" size="12px"/> {{ $t('knowledgeEditor.wikiBrowser.issuePagePrefix') }}{{ slugDisplayName(issue.slug) }}
</div>
{{ issue.description }}
</div>
<div class="wiki-issue-popup-meta">
<span class="wiki-issue-popup-reporter">
{{ issue.reported_by === 'wiki-researcher-agent' ? $t('knowledgeEditor.wikiBrowser.issueAiLinter') : $t('knowledgeEditor.wikiBrowser.issueReportedBy', { reporter: issue.reported_by }) }}
</span>
<span class="wiki-issue-popup-action" @click="navigateToSlugAndFix(issue.slug)" style="margin-right: 12px; font-weight: 500;">
<t-icon name="arrow-right-circle" style="margin-right: 2px;" />{{ $t('knowledgeEditor.wikiBrowser.issueGoFix') }}
</span>
<span class="wiki-issue-popup-action" style="color: var(--td-text-color-placeholder);" @click="handleGlobalIssueIgnore(issue.id)">{{ $t('knowledgeEditor.wikiBrowser.issueIgnore') }}</span>
</div>
</div>
</div>
<div v-if="globalIssues.length === 0" style="padding: 40px; text-align: center; color: var(--td-text-color-placeholder);">
{{ $t('knowledgeEditor.wikiBrowser.globalIssuesEmpty') }}
</div>
</div>
</t-drawer>
<!-- Fix Chat Drawer -->
<t-drawer
v-model:visible="showFixDrawer"
:header="$t('knowledgeEditor.wikiBrowser.fixAssistantTitle')"
size="700px"
:footer="false"
class="wiki-fix-drawer"
>
<ChatView
v-if="showFixDrawer"
:session_id="currentFixSessionId"
agentId="builtin-wiki-fixer"
:kbIds="[props.knowledgeBaseId]"
:embeddedMode="true"
/>
</t-drawer>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch, nextTick, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { useMenuStore } from '@/stores/menu'
import { useSettingsStore } from '@/stores/settings'
import { useI18n } from 'vue-i18n'
import { marked } from 'marked'
import { MessagePlugin } from 'tdesign-vue-next'
import { hydrateProtectedFileImages } from '@/utils/security'
import picturePreview from '@/components/picture-preview.vue'
import { createSessions } from '@/api/chat'
import ChatView from '@/views/chat/index.vue'
import {
listWikiPages,
getWikiPage,
getWikiGraph,
getWikiStats,
searchWikiPages,
listWikiIssues,
updateWikiIssueStatus,
type WikiPage,
type WikiGraphData,
type WikiStats,
type WikiPageIssue,
} from '@/api/wiki'
const router = useRouter()
const menuStore = useMenuStore()
const settingsStore = useSettingsStore()
const { t } = useI18n()
const props = defineProps<{
@@ -313,6 +451,12 @@ const emit = defineEmits<{
}>()
const pages = ref<WikiPage[]>([])
const selectedPage = ref<WikiPage | null>(null)
const pageIssues = ref<WikiPageIssue[]>([])
const showIssuesBox = ref(false)
const showFixDrawer = ref(false)
const showGlobalIssuesDrawer = ref(false)
const globalIssues = ref<WikiPageIssue[]>([])
const currentFixSessionId = ref('')
const stats = ref<WikiStats | null>(null)
const graphData = ref<WikiGraphData | null>(null)
const searchQuery = ref('')
@@ -328,6 +472,40 @@ const showArrows = ref(true)
// Graph filtering
const graphFilterTypes = ref<Set<string>>(new Set(['summary', 'entity', 'concept', 'synthesis', 'comparison', 'index', 'log']))
watch(showGlobalIssuesDrawer, async (val) => {
if (val) {
try {
const res = await listWikiIssues(props.knowledgeBaseId, '', 'pending')
globalIssues.value = (res as any).data || res as any || []
} catch (e) {
console.error('Failed to load global wiki issues:', e)
globalIssues.value = []
}
}
})
async function navigateToSlugAndFix(slug: string) {
showGlobalIssuesDrawer.value = false
if (props.view === 'graph') {
handleGraphSearchSelect(slug)
} else {
await navigateToSlug(slug)
showIssuesBox.value = true
}
}
async function handleGlobalIssueIgnore(issueId: string) {
try {
await updateWikiIssueStatus(props.knowledgeBaseId, issueId, 'ignored')
// Refresh list
const res = await listWikiIssues(props.knowledgeBaseId, '', 'pending')
globalIssues.value = (res as any).data || res as any || []
loadStats()
} catch (e) {
console.error('Failed to update issue status:', e)
}
}
function toggleGraphFilterType(type: string) {
const newSet = new Set(graphFilterTypes.value)
if (newSet.has(type)) {
@@ -646,6 +824,18 @@ async function loadGraph() {
}
}
async function loadPageIssues(slug: string) {
try {
const res = await listWikiIssues(props.knowledgeBaseId, slug, 'pending')
pageIssues.value = (res as any).data || res as any || []
showIssuesBox.value = false
} catch (e) {
console.error('Failed to load wiki issues:', e)
pageIssues.value = []
showIssuesBox.value = false
}
}
async function selectPage(page: WikiPage) {
try {
if (selectedPage.value && selectedPage.value.id !== page.id) {
@@ -653,6 +843,7 @@ async function selectPage(page: WikiPage) {
}
const res = await getWikiPage(props.knowledgeBaseId, page.slug)
selectedPage.value = (res as any).data || res as any
await loadPageIssues(page.slug)
} catch (e) {
console.error('Failed to load wiki page:', e)
}
@@ -665,6 +856,7 @@ async function navigateToSlug(slug: string) {
}
const res = await getWikiPage(props.knowledgeBaseId, slug)
selectedPage.value = (res as any).data || res as any
await loadPageIssues(slug)
} catch (e) {
console.error(`Failed to navigate to ${slug}:`, e)
}
@@ -674,9 +866,89 @@ function goBack() {
const prev = navHistory.value.pop()
if (prev) {
selectedPage.value = prev
loadPageIssues(prev.slug)
}
}
async function handleIssueIgnore(issueId: string) {
try {
await updateWikiIssueStatus(props.knowledgeBaseId, issueId, 'ignored')
if (selectedPage.value) {
await loadPageIssues(selectedPage.value.slug)
}
} catch (e) {
console.error('Failed to update issue status:', e)
}
}
async function startFixSession(prompt: string) {
settingsStore.selectAgent("builtin-wiki-fixer", null)
const sessionData: any = {
agent_config: {
enabled: true,
max_iterations: 30,
temperature: 0.7,
knowledge_bases: [props.knowledgeBaseId],
knowledge_ids: [],
allowed_tools: ["thinking", "wiki_read_page", "wiki_search", "wiki_read_source_doc", "wiki_edit_page", "wiki_read_issue", "wiki_update_issue"]
}
}
try {
const res = await createSessions(sessionData)
if (res && (res as any).data && (res as any).data.id) {
const sessionId = (res as any).data.id
const now = new Date().toISOString()
menuStore.updataMenuChildren({
title: t('knowledgeEditor.wikiBrowser.fixAssistantTitle'),
path: `chat/${sessionId}`,
id: sessionId,
isMore: false,
isNoTitle: true,
created_at: now,
updated_at: now
})
menuStore.changeIsFirstSession(true)
menuStore.changeFirstQuery(prompt, [], '', [])
currentFixSessionId.value = sessionId
showFixDrawer.value = true
showIssuesBox.value = false // Hide issues box
} else {
MessagePlugin.error(t('knowledgeEditor.wikiBrowser.fixStartError'))
}
} catch (e) {
console.error('Failed to create fix session', e)
MessagePlugin.error(t('knowledgeEditor.wikiBrowser.fixStartError'))
}
}
function triggerFixIssue(issue: WikiPageIssue) {
if (!selectedPage.value) return
const prompt = t('knowledgeEditor.wikiBrowser.issueFixPromptSingle', {
slug: selectedPage.value.slug,
type: issue.issue_type,
desc: issue.description,
id: issue.id
})
startFixSession(prompt)
}
function triggerAutoFix() {
if (!selectedPage.value || pageIssues.value.length === 0) return
let prompt = t('knowledgeEditor.wikiBrowser.issueFixPromptAutoStart', { slug: selectedPage.value.slug }) + '\n\n'
pageIssues.value.forEach((issue, idx) => {
prompt += `${idx + 1}. [${issue.issue_type}] ${issue.description} (ID: ${issue.id})\n`
})
prompt += '\n' + t('knowledgeEditor.wikiBrowser.issueFixPromptAutoEnd')
startFixSession(prompt)
}
async function doSearch() {
if (!searchQuery.value.trim()) { loadPages(); return }
loading.value = true
@@ -1567,6 +1839,28 @@ onUnmounted(() => {
}
}
.wiki-global-issues-status {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: var(--td-warning-color-light);
border-radius: 6px;
color: var(--td-warning-color-8);
font-size: 13px;
cursor: pointer;
transition: filter 0.2s;
&:hover {
filter: brightness(0.95);
}
.queue-text {
line-height: 1.2;
font-weight: 500;
}
}
.wiki-page-list {
flex: 1;
overflow-y: auto;
@@ -2006,16 +2300,28 @@ onUnmounted(() => {
height: 100%;
}
.wiki-graph-search {
.wiki-graph-search-container {
position: absolute;
top: 16px;
left: 16px;
width: 280px;
display: flex;
flex-direction: column;
gap: 12px;
z-index: 10;
width: 280px;
}
.wiki-graph-search {
width: 100%;
box-shadow: var(--td-shadow-1);
border-radius: 4px;
}
.graph-issues-badge {
box-shadow: var(--td-shadow-1);
opacity: 0.95;
}
:deep(.wiki-graph-drawer) {
box-shadow: -4px 0 16px rgba(0, 0, 0, 0.08);
}
@@ -2137,4 +2443,170 @@ onUnmounted(() => {
transform-origin: 0 0;
animation: node-active-pulse 1.5s cubic-bezier(0.25, 0.46, 0.45, 0.94) infinite;
}
// Issues Popup
.wiki-issue-trigger {
margin-left: 12px;
cursor: pointer;
transition: all 0.2s ease;
user-select: none;
&:hover {
filter: brightness(0.95);
}
}
.wiki-issue-popup-content {
display: flex;
flex-direction: column;
background: var(--td-bg-color-container);
border-radius: 8px;
overflow: hidden;
}
.wiki-issue-popup-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: var(--td-bg-color-secondarycontainer);
border-bottom: 1px solid var(--td-component-stroke);
}
.wiki-issue-popup-title {
display: flex;
align-items: center;
font-weight: 500;
font-size: 14px;
color: var(--td-text-color-primary);
.wiki-issue-popup-icon {
color: var(--td-brand-color);
margin-right: 8px;
font-size: 16px;
}
}
.wiki-issue-popup-list {
display: flex;
flex-direction: column;
max-height: 400px;
overflow-y: auto;
}
.wiki-issue-popup-item {
display: flex;
padding: 16px;
gap: 12px;
border-bottom: 1px solid var(--td-component-stroke);
transition: background-color 0.2s ease;
&:last-child {
border-bottom: none;
}
&:hover {
background: var(--td-bg-color-container-hover);
}
}
.wiki-issue-popup-main {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
}
.wiki-issue-popup-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.wiki-issue-popup-desc {
font-size: 13px;
color: var(--td-text-color-primary);
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
max-height: 150px;
overflow-y: auto;
padding-right: 4px;
}
/* 优化描述区域的滚动条样式 */
.wiki-issue-popup-desc::-webkit-scrollbar {
width: 4px;
}
.wiki-issue-popup-desc::-webkit-scrollbar-thumb {
background: var(--td-scrollbar-color);
border-radius: 4px;
}
.wiki-issue-popup-desc::-webkit-scrollbar-track {
background: transparent;
}
.wiki-issue-popup-meta {
display: flex;
align-items: center;
gap: 16px;
margin-top: 4px;
}
.wiki-issue-popup-reporter {
font-size: 12px;
color: var(--td-text-color-placeholder);
}
.wiki-issue-popup-action {
font-size: 12px;
color: var(--td-brand-color);
cursor: pointer;
transition: opacity 0.2s ease;
&:hover {
opacity: 0.8;
}
}
</style>
<style lang="less">
/* Fix Embedded Chat UI (unscoped because drawer attaches to body) */
.wiki-fix-drawer {
.t-drawer__body {
padding: 20px !important;
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.chat {
max-width: 100% !important;
min-width: 100% !important;
padding: 0 !important;
height: 100% !important;
flex: 1 !important;
border-radius: 0 !important;
}
.chat_scroll_box {
padding: 0 !important;
}
.chat > .input-container {
padding: 16px 0 0 0 !important;
box-sizing: border-box;
width: 100% !important;
max-width: 100% !important;
margin: 0 !important;
overflow-x: hidden;
}
.msg_list {
max-width: 100% !important;
padding-bottom: 0 !important;
margin: 0 !important;
}
}
</style>
+11 -4
View File
@@ -369,9 +369,17 @@ func (e *AgentEngine) buildMessagesWithLLMContext(
}
if len(llmContext) > 0 {
// Redact KB tool results from previous turns to prevent the LLM
// from reusing stale retrieval data when the KB has been modified.
sanitized := redactHistoryKBResults(llmContext)
var sanitized []chat.Message
if e.config.RetainRetrievalHistory {
sanitized = llmContext
logger.Infof(context.Background(), "Retaining full retrieval history in context (RetainRetrievalHistory=true)")
} else {
// Redact KB tool results from previous turns to prevent the LLM
// from reusing stale retrieval data when the KB has been modified.
sanitized = redactHistoryKBResults(llmContext)
logger.Infof(context.Background(), "Added %d history messages to context (KB tool results redacted)", len(llmContext))
}
for _, msg := range sanitized {
if msg.Role == "system" {
continue
@@ -380,7 +388,6 @@ func (e *AgentEngine) buildMessagesWithLLMContext(
messages = append(messages, msg)
}
}
logger.Infof(context.Background(), "Added %d history messages to context (KB tool results redacted)", len(llmContext))
}
// Build user message with runtime context safety tag
+12
View File
@@ -24,9 +24,15 @@ const (
ToolReadSkill = "read_skill"
// Wiki-related tools (only available when wiki KBs are in scope)
ToolWikiReadPage = "wiki_read_page"
ToolWikiWritePage = "wiki_write_page"
ToolWikiReplaceText = "wiki_replace_text"
ToolWikiRenamePage = "wiki_rename_page"
ToolWikiDeletePage = "wiki_delete_page"
ToolWikiSearch = "wiki_search"
ToolWikiReadSourceDoc = "wiki_read_source_doc"
ToolWikiFlagIssue = "wiki_flag_issue"
ToolWikiReadIssue = "wiki_read_issue"
ToolWikiUpdateIssue = "wiki_update_issue"
)
// AvailableTool defines a simple tool metadata used by settings APIs.
@@ -57,6 +63,12 @@ func AvailableToolDefinitions() []AvailableTool {
{Name: ToolWikiSearch, Label: "搜索Wiki", Description: "在Wiki中搜索页面"},
{Name: ToolWikiReadSourceDoc, Label: "精读源文档", Description: "使用知识点深入阅读特定原始文档"},
{Name: ToolWikiFlagIssue, Label: "标记Wiki问题", Description: "标记页面中存在的事实错误或合并冲突问题"},
{Name: ToolWikiWritePage, Label: "创建/覆盖Wiki", Description: "创建新页面或完全覆盖已有页面"},
{Name: ToolWikiReplaceText, Label: "局部替换Wiki", Description: "替换Wiki页面中的特定文本"},
{Name: ToolWikiRenamePage, Label: "重命名Wiki", Description: "重命名Wiki页面并自动更新关联链接"},
{Name: ToolWikiDeletePage, Label: "删除Wiki", Description: "删除Wiki页面并自动清理关联死链"},
{Name: ToolWikiReadIssue, Label: "查看Wiki问题", Description: "查看特定的Wiki页面问题详情"},
{Name: ToolWikiUpdateIssue, Label: "更新Wiki问题状态", Description: "更新特定的Wiki页面问题状态"},
}
}
+115
View File
@@ -0,0 +1,115 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"regexp"
"strings"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
)
type wikiDeletePageTool struct {
BaseTool
wikiPageService interfaces.WikiPageService
kbIDs []string
}
// NewWikiDeletePageTool creates a new wiki_delete_page tool
func NewWikiDeletePageTool(wikiPageService interfaces.WikiPageService, kbIDs []string) types.Tool {
return &wikiDeletePageTool{
BaseTool: NewBaseTool(
ToolWikiDeletePage,
"Delete a Wiki page. Automatically cleans up incoming links on other pages to prevent dead links.",
json.RawMessage(`{
"type": "object",
"properties": {
"slug": {
"type": "string",
"description": "The slug of the Wiki page to delete"
}
},
"required": ["slug"]
}`),
),
wikiPageService: wikiPageService,
kbIDs: kbIDs,
}
}
func (t *wikiDeletePageTool) Execute(ctx context.Context, args json.RawMessage) (*types.ToolResult, error) {
var params struct {
Slug string `json:"slug"`
}
if err := json.Unmarshal(args, &params); err != nil {
return &types.ToolResult{Success: false, Error: "Failed to parse arguments: " + err.Error()}, nil
}
if len(t.kbIDs) == 0 {
return &types.ToolResult{Success: false, Error: "No knowledge bases available for editing"}, nil
}
kbID := t.kbIDs[0]
if params.Slug == "" {
return &types.ToolResult{Success: false, Error: "slug is required"}, nil
}
// Fetch page to get its incoming links before deleting
existingPage, err := t.wikiPageService.GetPageBySlug(ctx, kbID, params.Slug)
if err != nil {
return &types.ToolResult{Success: false, Error: "Failed to fetch page to delete: " + err.Error()}, nil
}
inLinks := make([]string, len(existingPage.InLinks))
copy(inLinks, existingPage.InLinks)
err = t.wikiPageService.DeletePage(ctx, kbID, params.Slug)
if err != nil {
return &types.ToolResult{Success: false, Error: "Failed to delete page: " + err.Error()}, nil
}
// Clean up incoming links to prevent dead links
updatedCount := 0
var updatedSlugs []string
for _, sourceSlug := range inLinks {
sourcePage, err := t.wikiPageService.GetPageBySlug(ctx, kbID, sourceSlug)
if err == nil {
changed := false
// Replace [[deleted-slug]] with readable name
parts := strings.Split(params.Slug, "/")
readableName := parts[len(parts)-1]
readableName = strings.ReplaceAll(readableName, "-", " ")
link1 := "[[" + params.Slug + "]]"
if strings.Contains(sourcePage.Content, link1) {
sourcePage.Content = strings.ReplaceAll(sourcePage.Content, link1, readableName)
changed = true
}
// Replace [[deleted-slug|Text]] with just Text
re := regexp.MustCompile(`\[\[` + regexp.QuoteMeta(params.Slug) + `\|([^\]]+)\]\]`)
if re.MatchString(sourcePage.Content) {
sourcePage.Content = re.ReplaceAllString(sourcePage.Content, "$1")
changed = true
}
if changed {
_, updateErr := t.wikiPageService.UpdatePage(ctx, sourcePage)
if updateErr == nil {
updatedCount++
updatedSlugs = append(updatedSlugs, sourceSlug)
}
}
}
}
outputMsg := fmt.Sprintf("Successfully deleted page %s and cleaned up %d incoming links.", params.Slug, updatedCount)
if updatedCount > 0 {
outputMsg += fmt.Sprintf(" Affected pages: %s", strings.Join(updatedSlugs, ", "))
}
return &types.ToolResult{Success: true, Output: outputMsg}, nil
}
+92
View File
@@ -0,0 +1,92 @@
package tools
import (
"context"
"encoding/json"
"strings"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
)
type wikiReadIssueTool struct {
BaseTool
wikiService interfaces.WikiPageService
kbIDs []string
}
func NewWikiReadIssueTool(wikiService interfaces.WikiPageService, kbIDs []string) types.Tool {
return &wikiReadIssueTool{
BaseTool: NewBaseTool(
ToolWikiReadIssue,
"Read the details of a specific wiki page issue or list pending issues for a wiki page.",
json.RawMessage(`{
"type": "object",
"properties": {
"issue_id": {
"type": "string",
"description": "Optional: The ID of a specific issue to read."
},
"slug": {
"type": "string",
"description": "Optional: The slug of the wiki page to list pending issues for."
}
},
"description": "Provide either issue_id or slug to read issue(s)."
}`),
),
wikiService: wikiService,
kbIDs: kbIDs,
}
}
func (t *wikiReadIssueTool) Execute(ctx context.Context, args json.RawMessage) (*types.ToolResult, error) {
var params struct {
IssueID string `json:"issue_id"`
Slug string `json:"slug"`
}
if err := json.Unmarshal(args, &params); err != nil {
return &types.ToolResult{Success: false, Error: "Invalid parameters: " + err.Error()}, nil
}
issueID := strings.TrimSpace(params.IssueID)
slug := strings.TrimSpace(params.Slug)
if issueID == "" && slug == "" {
return &types.ToolResult{Success: false, Error: "Either issue_id or slug is required"}, nil
}
if len(t.kbIDs) == 0 {
return &types.ToolResult{Success: false, Error: "No knowledge bases available"}, nil
}
kbID := t.kbIDs[0]
if issueID != "" {
// Just reuse ListIssues since there's no GetIssueByID yet
issues, err := t.wikiService.ListIssues(ctx, kbID, "", "")
if err != nil {
return &types.ToolResult{Success: false, Error: "Failed to list issues: " + err.Error()}, nil
}
for _, issue := range issues {
if issue.ID == issueID {
out, _ := json.MarshalIndent(issue, "", " ")
return &types.ToolResult{Success: true, Output: string(out)}, nil
}
}
return &types.ToolResult{Success: false, Error: "Issue not found"}, nil
}
issues, err := t.wikiService.ListIssues(ctx, kbID, slug, "pending")
if err != nil {
return &types.ToolResult{Success: false, Error: "Failed to list issues: " + err.Error()}, nil
}
if len(issues) == 0 {
return &types.ToolResult{Success: true, Output: "No pending issues found for slug: " + slug}, nil
}
out, _ := json.MarshalIndent(issues, "", " ")
return &types.ToolResult{Success: true, Output: string(out)}, nil
}
+143
View File
@@ -0,0 +1,143 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
)
type wikiRenamePageTool struct {
BaseTool
wikiPageService interfaces.WikiPageService
kbIDs []string
}
// NewWikiRenamePageTool creates a new wiki_rename_page tool
func NewWikiRenamePageTool(wikiPageService interfaces.WikiPageService, kbIDs []string) types.Tool {
return &wikiRenamePageTool{
BaseTool: NewBaseTool(
ToolWikiRenamePage,
"Rename a Wiki page's slug. Automatically cascades the new slug to all pages that linked to the old one.",
json.RawMessage(`{
"type": "object",
"properties": {
"slug": {
"type": "string",
"description": "The current slug of the Wiki page"
},
"new_slug": {
"type": "string",
"description": "The new slug for the page"
}
},
"required": ["slug", "new_slug"]
}`),
),
wikiPageService: wikiPageService,
kbIDs: kbIDs,
}
}
func (t *wikiRenamePageTool) Execute(ctx context.Context, args json.RawMessage) (*types.ToolResult, error) {
var params struct {
Slug string `json:"slug"`
NewSlug string `json:"new_slug"`
}
if err := json.Unmarshal(args, &params); err != nil {
return &types.ToolResult{Success: false, Error: "Failed to parse arguments: " + err.Error()}, nil
}
if len(t.kbIDs) == 0 {
return &types.ToolResult{Success: false, Error: "No knowledge bases available for editing"}, nil
}
kbID := t.kbIDs[0]
if params.NewSlug == "" {
return &types.ToolResult{Success: false, Error: "new_slug is required"}, nil
}
if params.NewSlug == params.Slug {
return &types.ToolResult{Success: false, Error: "new_slug must be different from old slug"}, nil
}
// Get existing page
existingPage, err := t.wikiPageService.GetPageBySlug(ctx, kbID, params.Slug)
if err != nil {
return &types.ToolResult{Success: false, Error: fmt.Sprintf("Page %s not found. Cannot rename a non-existent page.", params.Slug)}, nil
}
inLinks := make([]string, len(existingPage.InLinks))
copy(inLinks, existingPage.InLinks)
// Create new page with new slug but same content
newPage := &types.WikiPage{
KnowledgeBaseID: kbID,
Slug: params.NewSlug,
Title: existingPage.Title,
Summary: existingPage.Summary,
Content: existingPage.Content,
PageType: existingPage.PageType,
Aliases: existingPage.Aliases,
}
_, err = t.wikiPageService.CreatePage(ctx, newPage)
if err != nil {
return &types.ToolResult{Success: false, Error: "Failed to create renamed page: " + err.Error()}, nil
}
// Update incoming links in other pages
updatedCount := 0
var updatedSlugs []string
for _, sourceSlug := range inLinks {
sourcePage, err := t.wikiPageService.GetPageBySlug(ctx, kbID, sourceSlug)
if err == nil {
changed := false
// Replace [[old-slug]] with [[new-slug]]
link1 := "[[" + params.Slug + "]]"
newLink1 := "[[" + params.NewSlug + "]]"
if strings.Contains(sourcePage.Content, link1) {
sourcePage.Content = strings.ReplaceAll(sourcePage.Content, link1, newLink1)
changed = true
}
// Replace [[old-slug|text]] with [[new-slug|text]]
link2 := "[[" + params.Slug + "|"
newLink2 := "[[" + params.NewSlug + "|"
if strings.Contains(sourcePage.Content, link2) {
sourcePage.Content = strings.ReplaceAll(sourcePage.Content, link2, newLink2)
changed = true
}
if changed {
_, updateErr := t.wikiPageService.UpdatePage(ctx, sourcePage)
if updateErr == nil {
updatedCount++
updatedSlugs = append(updatedSlugs, sourceSlug)
}
}
}
}
// Delete old page
err = t.wikiPageService.DeletePage(ctx, kbID, params.Slug)
if err != nil {
return &types.ToolResult{Success: false, Error: "Successfully created new page and updated links, but failed to delete old page: " + err.Error()}, nil
}
// Inject cross-links so other pages know about this new slug
t.wikiPageService.InjectCrossLinks(ctx, kbID, []string{params.NewSlug})
// Rebuild the index page to reflect the new/updated summary
_ = t.wikiPageService.RebuildIndexPage(ctx, kbID)
outputMsg := fmt.Sprintf("Successfully renamed page %s to %s and updated %d incoming links.", params.Slug, params.NewSlug, updatedCount)
if updatedCount > 0 {
outputMsg += fmt.Sprintf(" Affected pages: %s", strings.Join(updatedSlugs, ", "))
}
return &types.ToolResult{Success: true, Output: outputMsg}, nil
}
+97
View File
@@ -0,0 +1,97 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
)
type wikiReplaceTextTool struct {
BaseTool
wikiPageService interfaces.WikiPageService
kbIDs []string
}
// NewWikiReplaceTextTool creates a new wiki_replace_text tool
func NewWikiReplaceTextTool(wikiPageService interfaces.WikiPageService, kbIDs []string) types.Tool {
return &wikiReplaceTextTool{
BaseTool: NewBaseTool(
ToolWikiReplaceText,
"Replace specific exact text in a Wiki page. Ideal for minor corrections.",
json.RawMessage(`{
"type": "object",
"properties": {
"slug": {
"type": "string",
"description": "The slug of the Wiki page"
},
"old_text": {
"type": "string",
"description": "The exact text to find and replace"
},
"new_text": {
"type": "string",
"description": "The new text to insert"
},
"source_refs": {
"type": "array",
"items": {"type": "string"},
"description": "An optional list of source knowledge IDs that justify this change. If provided, these will COMPLETELY REPLACE the existing source_refs of the page."
}
},
"required": ["slug", "old_text", "new_text"]
}`),
),
wikiPageService: wikiPageService,
kbIDs: kbIDs,
}
}
func (t *wikiReplaceTextTool) Execute(ctx context.Context, args json.RawMessage) (*types.ToolResult, error) {
var params struct {
Slug string `json:"slug"`
OldText string `json:"old_text"`
NewText string `json:"new_text"`
SourceRefs []string `json:"source_refs"`
}
if err := json.Unmarshal(args, &params); err != nil {
return &types.ToolResult{Success: false, Error: "Failed to parse arguments: " + err.Error()}, nil
}
if len(t.kbIDs) == 0 {
return &types.ToolResult{Success: false, Error: "No knowledge bases available for editing"}, nil
}
kbID := t.kbIDs[0]
if params.OldText == "" {
return &types.ToolResult{Success: false, Error: "old_text is required"}, nil
}
// Get the existing page
existingPage, err := t.wikiPageService.GetPageBySlug(ctx, kbID, params.Slug)
if err != nil {
return &types.ToolResult{Success: false, Error: fmt.Sprintf("Failed to fetch page %s: %v", params.Slug, err)}, nil
}
if !strings.Contains(existingPage.Content, params.OldText) {
return &types.ToolResult{Success: false, Error: "old_text not found in the current page content. Ensure you copy it exactly as it appears."}, nil
}
existingPage.Content = strings.Replace(existingPage.Content, params.OldText, params.NewText, 1)
if len(params.SourceRefs) > 0 {
existingPage.SourceRefs = params.SourceRefs
}
_, err = t.wikiPageService.UpdatePage(ctx, existingPage)
if err != nil {
return &types.ToolResult{Success: false, Error: "Failed to update page: " + err.Error()}, nil
}
return &types.ToolResult{Success: true, Output: fmt.Sprintf("Successfully replaced text on page %s.", params.Slug)}, nil
}
+4
View File
@@ -145,6 +145,9 @@ func (t *wikiReadPageTool) Execute(ctx context.Context, args json.RawMessage) (*
<sources>
%s
</sources>
<summary>
%s
</summary>
<content>
%s
</content>
@@ -153,6 +156,7 @@ func (t *wikiReadPageTool) Execute(ctx context.Context, args json.RawMessage) (*
strings.Join(outLinksDesc, ", "),
strings.Join(inLinksDesc, ", "),
strings.Join(sourcesDesc, "\n"),
page.Summary,
page.Content,
)
outputs = append(outputs, output)
+74
View File
@@ -0,0 +1,74 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
)
type wikiUpdateIssueTool struct {
BaseTool
wikiService interfaces.WikiPageService
kbIDs []string
}
func NewWikiUpdateIssueTool(wikiService interfaces.WikiPageService, kbIDs []string) types.Tool {
return &wikiUpdateIssueTool{
BaseTool: NewBaseTool(
ToolWikiUpdateIssue,
"Update the status of a specific wiki page issue (e.g., set it to 'resolved' or 'ignored').",
json.RawMessage(`{
"type": "object",
"properties": {
"issue_id": {
"type": "string",
"description": "The ID of the issue to update."
},
"status": {
"type": "string",
"enum": ["resolved", "ignored", "pending"],
"description": "The new status for the issue."
}
},
"required": ["issue_id", "status"]
}`),
),
wikiService: wikiService,
kbIDs: kbIDs,
}
}
func (t *wikiUpdateIssueTool) Execute(ctx context.Context, args json.RawMessage) (*types.ToolResult, error) {
var params struct {
IssueID string `json:"issue_id"`
Status string `json:"status"`
}
if err := json.Unmarshal(args, &params); err != nil {
return &types.ToolResult{Success: false, Error: "Invalid parameters: " + err.Error()}, nil
}
if params.IssueID == "" {
return &types.ToolResult{Success: false, Error: "issue_id is required"}, nil
}
if params.Status == "" {
return &types.ToolResult{Success: false, Error: "status is required"}, nil
}
if len(t.kbIDs) == 0 {
return &types.ToolResult{Success: false, Error: "No knowledge bases available"}, nil
}
// Update issue status
err := t.wikiService.UpdateIssueStatus(ctx, params.IssueID, params.Status)
if err != nil {
return &types.ToolResult{Success: false, Error: "Failed to update issue status: " + err.Error()}, nil
}
return &types.ToolResult{
Success: true,
Output: fmt.Sprintf("Successfully updated issue %s to status '%s'", params.IssueID, params.Status),
}, nil
}
+145
View File
@@ -0,0 +1,145 @@
package tools
import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/Tencent/WeKnora/internal/application/repository"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
)
type wikiWritePageTool struct {
BaseTool
wikiPageService interfaces.WikiPageService
kbIDs []string
}
// NewWikiWritePageTool creates a new wiki_write_page tool
func NewWikiWritePageTool(wikiPageService interfaces.WikiPageService, kbIDs []string) types.Tool {
return &wikiWritePageTool{
BaseTool: NewBaseTool(
ToolWikiWritePage,
"Create a new Wiki page or completely overwrite an existing one. Automatically handles outbound links.",
json.RawMessage(`{
"type": "object",
"properties": {
"slug": {
"type": "string",
"description": "The slug of the Wiki page (e.g. 'entity/hunyuan-damoxing')"
},
"title": {
"type": "string",
"description": "The title of the page"
},
"summary": {
"type": "string",
"description": "A one-sentence summary for the index listing"
},
"content": {
"type": "string",
"description": "The FULL, complete Markdown content of the page. Do NOT use placeholders."
},
"page_type": {
"type": "string",
"description": "The page type, e.g., 'summary', 'entity', 'concept', 'synthesis', 'comparison'"
},
"aliases": {
"type": "array",
"items": {"type": "string"},
"description": "A list of aliases for the page (optional)"
},
"source_refs": {
"type": "array",
"items": {"type": "string"},
"description": "A list of source knowledge IDs that contributed to this page. If provided, these will COMPLETELY REPLACE the existing source_refs of the page."
}
},
"required": ["slug", "title", "summary", "content", "page_type"]
}`),
),
wikiPageService: wikiPageService,
kbIDs: kbIDs,
}
}
func (t *wikiWritePageTool) Execute(ctx context.Context, args json.RawMessage) (*types.ToolResult, error) {
var params struct {
Slug string `json:"slug"`
Title string `json:"title"`
Summary string `json:"summary"`
Content string `json:"content"`
PageType string `json:"page_type"`
Aliases []string `json:"aliases"`
SourceRefs []string `json:"source_refs"`
}
if err := json.Unmarshal(args, &params); err != nil {
return &types.ToolResult{Success: false, Error: "Failed to parse arguments: " + err.Error()}, nil
}
if len(t.kbIDs) == 0 {
return &types.ToolResult{Success: false, Error: "No knowledge bases available for editing"}, nil
}
kbID := t.kbIDs[0]
if params.Title == "" || params.PageType == "" || params.Content == "" || params.Summary == "" {
return &types.ToolResult{Success: false, Error: "title, summary, content, and page_type are required for write action"}, nil
}
// Try to get the existing page
existingPage, err := t.wikiPageService.GetPageBySlug(ctx, kbID, params.Slug)
if err != nil && !errors.Is(err, repository.ErrWikiPageNotFound) {
return &types.ToolResult{Success: false, Error: "Failed to check existing page: " + err.Error()}, nil
}
var action string
if existingPage != nil {
// Update
existingPage.Title = params.Title
existingPage.Summary = params.Summary
existingPage.Content = params.Content
existingPage.PageType = params.PageType
existingPage.Aliases = params.Aliases
if len(params.SourceRefs) > 0 {
existingPage.SourceRefs = params.SourceRefs
}
_, err = t.wikiPageService.UpdatePage(ctx, existingPage)
if err != nil {
return &types.ToolResult{Success: false, Error: "Failed to update page: " + err.Error()}, nil
}
action = "updated"
} else {
// Create
newPage := &types.WikiPage{
KnowledgeBaseID: kbID,
Slug: params.Slug,
Title: params.Title,
Summary: params.Summary,
Content: params.Content,
PageType: params.PageType,
Aliases: params.Aliases,
SourceRefs: params.SourceRefs,
}
_, err = t.wikiPageService.CreatePage(ctx, newPage)
if err != nil {
return &types.ToolResult{Success: false, Error: "Failed to create page: " + err.Error()}, nil
}
action = "created"
}
// Inject cross-links so other pages know about this new/updated entity
t.wikiPageService.InjectCrossLinks(ctx, kbID, []string{params.Slug})
// Rebuild the index page to reflect the new/updated summary
_ = t.wikiPageService.RebuildIndexPage(ctx, kbID)
return &types.ToolResult{
Success: true,
Output: fmt.Sprintf("Successfully %s page %s.", action, params.Slug),
}, nil
}
@@ -456,6 +456,18 @@ func (s *agentService) registerTools(
toolToRegister = tools.NewWikiReadSourceDocTool(s.knowledgeService, s.chunkService)
case tools.ToolWikiFlagIssue:
toolToRegister = tools.NewWikiFlagIssueTool(s.wikiPageService, wikiKBIDs)
case tools.ToolWikiReadIssue:
toolToRegister = tools.NewWikiReadIssueTool(s.wikiPageService, wikiKBIDs)
case tools.ToolWikiUpdateIssue:
toolToRegister = tools.NewWikiUpdateIssueTool(s.wikiPageService, wikiKBIDs)
case tools.ToolWikiWritePage:
toolToRegister = tools.NewWikiWritePageTool(s.wikiPageService, wikiKBIDs)
case tools.ToolWikiReplaceText:
toolToRegister = tools.NewWikiReplaceTextTool(s.wikiPageService, wikiKBIDs)
case tools.ToolWikiRenamePage:
toolToRegister = tools.NewWikiRenamePageTool(s.wikiPageService, wikiKBIDs)
case tools.ToolWikiDeletePage:
toolToRegister = tools.NewWikiDeletePageTool(s.wikiPageService, wikiKBIDs)
default:
logger.Warnf(ctx, "Unknown tool: %s", toolName)
@@ -91,13 +91,20 @@ func (s *sessionService) AgentQA(
return fmt.Errorf("failed to get chat model: %w", err)
}
// Get rerank model from custom agent config (only required when knowledge bases are configured)
// Get rerank model from custom agent config (only required when knowledge_search is allowed)
var rerankModel rerank.Reranker
hasKnowledge := len(agentConfig.KnowledgeBases) > 0 || len(agentConfig.KnowledgeIDs) > 0
if hasKnowledge {
hasKnowledgeSearchTool := false
for _, tool := range agentConfig.AllowedTools {
if tool == tools.ToolKnowledgeSearch {
hasKnowledgeSearchTool = true
break
}
}
if hasKnowledgeSearchTool {
rerankModelID := req.CustomAgent.Config.RerankModelID
if rerankModelID == "" {
logger.Warnf(ctx, "No rerank model configured for custom agent %s, but knowledge bases are specified", req.CustomAgent.ID)
logger.Warnf(ctx, "No rerank model configured for custom agent %s, but knowledge_search tool is enabled", req.CustomAgent.ID)
return errors.New("rerank model (rerank_model_id) is not configured in custom agent settings")
}
@@ -107,7 +114,7 @@ func (s *sessionService) AgentQA(
return fmt.Errorf("failed to get rerank model: %w", err)
}
} else {
logger.Infof(ctx, "No knowledge bases configured, skipping rerank model initialization")
logger.Infof(ctx, "knowledge_search tool not enabled, skipping rerank model initialization")
}
// Get or create contextManager for this session
@@ -225,6 +232,7 @@ func (s *sessionService) buildAgentConfig(
// Falls back to global configuration if no specific timeout is set for the agent.
if agentConfig.LLMCallTimeout == 0 && s.cfg.Agent != nil && s.cfg.Agent.LLMCallTimeout > 0 {
agentConfig.LLMCallTimeout = s.cfg.Agent.LLMCallTimeout
RetainRetrievalHistory: customAgent.Config.RetainRetrievalHistory,
}
// Configure skills based on CustomAgentConfig
+167
View File
@@ -288,6 +288,7 @@ func (s *wikiPageService) GetStats(ctx context.Context, kbID string) (*types.Wik
}
var pendingTasks int64
var pendingIssues int64
var isActive bool
if s.redisClient != nil {
pendingTasks, _ = s.redisClient.LLen(ctx, "wiki:pending:"+kbID).Result()
@@ -295,6 +296,9 @@ func (s *wikiPageService) GetStats(ctx context.Context, kbID string) (*types.Wik
isActive = activeFlag > 0
}
issues, _ := s.ListIssues(ctx, kbID, "", "pending")
pendingIssues = int64(len(issues))
return &types.WikiStats{
TotalPages: total,
PagesByType: counts,
@@ -302,6 +306,7 @@ func (s *wikiPageService) GetStats(ctx context.Context, kbID string) (*types.Wik
OrphanCount: orphans,
RecentUpdates: recentPages,
PendingTasks: pendingTasks,
PendingIssues: pendingIssues,
IsActive: isActive,
}, nil
}
@@ -498,3 +503,165 @@ func (s *wikiPageService) ListIssues(ctx context.Context, kbID string, slug stri
func (s *wikiPageService) UpdateIssueStatus(ctx context.Context, issueID string, status string) error {
return s.repo.UpdateIssueStatus(ctx, issueID, status)
}
// InjectCrossLinks scans affected pages and injects [[wiki-links]] for mentions
// of other wiki page titles in the content. Pure text replacement, no LLM call.
func (s *wikiPageService) InjectCrossLinks(ctx context.Context, kbID string, affectedSlugs []string) {
// Build a title→slug lookup from ALL pages in this KB
allPages, err := s.ListAllPages(ctx, kbID)
if err != nil || len(allPages) < 2 {
return
}
type pageRef struct {
slug string
matchText string
}
var allRefs []pageRef
for _, p := range allPages {
if p.PageType == types.WikiPageTypeIndex || p.PageType == types.WikiPageTypeLog {
continue
}
if p.Title != "" {
allRefs = append(allRefs, pageRef{slug: p.Slug, matchText: p.Title})
}
for _, alias := range p.Aliases {
if alias != "" {
allRefs = append(allRefs, pageRef{slug: p.Slug, matchText: alias})
}
}
}
if len(allRefs) == 0 {
return
}
// Sort by title length descending
for i := 0; i < len(allRefs); i++ {
for j := i + 1; j < len(allRefs); j++ {
if len([]rune(allRefs[j].matchText)) > len([]rune(allRefs[i].matchText)) {
allRefs[i], allRefs[j] = allRefs[j], allRefs[i]
}
}
}
affectedSet := make(map[string]bool, len(affectedSlugs))
for _, slug := range affectedSlugs {
affectedSet[slug] = true
}
var updated int
for _, p := range allPages {
if !affectedSet[p.Slug] {
continue
}
if p.PageType == types.WikiPageTypeIndex || p.PageType == types.WikiPageTypeLog {
continue
}
content := p.Content
changed := false
for _, ref := range allRefs {
if ref.slug == p.Slug {
continue
}
if strings.Contains(content, "[["+ref.slug+"|") || strings.Contains(content, "[["+ref.slug+"]]") {
continue
}
if strings.Contains(content, ref.matchText) {
content = replaceFirstOutsideLinks(content, ref.matchText, "[["+ref.slug+"|"+ref.matchText+"]]")
changed = true
}
}
if changed {
p.Content = content
if _, err := s.UpdatePage(ctx, p); err != nil {
logger.Warnf(ctx, "wiki: cross-link injection failed for %s: %v", p.Slug, err)
} else {
updated++
}
}
}
if updated > 0 {
logger.Infof(ctx, "wiki: injected cross-links in %d pages", updated)
}
}
// RebuildIndexPage regenerates the index page directory.
func (s *wikiPageService) RebuildIndexPage(ctx context.Context, kbID string) error {
indexPage, err := s.GetIndex(ctx, kbID)
if err != nil {
return err
}
allPages, err := s.ListAllPages(ctx, kbID)
if err != nil {
return err
}
typeOrder := []string{
types.WikiPageTypeSummary, types.WikiPageTypeEntity, types.WikiPageTypeConcept,
types.WikiPageTypeSynthesis, types.WikiPageTypeComparison,
}
typeLabels := map[string]string{
types.WikiPageTypeSummary: "Summary", types.WikiPageTypeEntity: "Entity",
types.WikiPageTypeConcept: "Concept", types.WikiPageTypeSynthesis: "Synthesis",
types.WikiPageTypeComparison: "Comparison",
}
grouped := make(map[string][]*types.WikiPage)
totalPages := 0
for _, p := range allPages {
if p.PageType == types.WikiPageTypeIndex || p.PageType == types.WikiPageTypeLog {
continue
}
if p.Status == types.WikiPageStatusArchived {
continue
}
grouped[p.PageType] = append(grouped[p.PageType], p)
totalPages++
}
var dir strings.Builder
for _, pt := range typeOrder {
pages := grouped[pt]
if len(pages) == 0 {
continue
}
fmt.Fprintf(&dir, "\n## %s (%d)\n\n", typeLabels[pt], len(pages))
for _, p := range pages {
fmt.Fprintf(&dir, "[[%s]] — %s\n", p.Slug, p.Summary)
}
}
for pt, pages := range grouped {
inOrder := false
for _, o := range typeOrder {
if o == pt {
inOrder = true
break
}
}
if inOrder || len(pages) == 0 {
continue
}
fmt.Fprintf(&dir, "\n## %s (%d)\n\n", pt, len(pages))
for _, p := range pages {
fmt.Fprintf(&dir, "[[%s]] — %s\n", p.Slug, p.Summary)
}
}
if totalPages == 0 {
dir.WriteString("\n*No wiki pages yet. Upload documents to get started.*\n")
}
intro := indexPage.Summary
if intro == "" {
intro = "# Wiki Index\n\nThis wiki contains knowledge extracted from uploaded documents.\n"
indexPage.Summary = intro
}
indexPage.Content = intro + "\n" + dir.String()
_, err = s.UpdatePage(ctx, indexPage)
return err
}
+3
View File
@@ -37,6 +37,9 @@ type AgentConfig struct {
// Whether to retrieve knowledge base only when explicitly mentioned with @ (default: false)
RetrieveKBOnlyWhenMentioned bool `json:"retrieve_kb_only_when_mentioned"`
// Whether to retain retrieval history (like wiki_read_page results) across turns (default: false)
RetainRetrievalHistory bool `json:"retain_retrieval_history"`
// Skills configuration (Progressive Disclosure pattern)
SkillsEnabled bool `json:"skills_enabled"` // Whether skills are enabled (default: false)
SkillDirs []string `json:"skill_dirs"` // Directories to search for skills
+9
View File
@@ -22,6 +22,10 @@ const (
BuiltinKnowledgeGraphExpertID = "builtin-knowledge-graph-expert"
// BuiltinDocumentAssistantID is the ID for the built-in document assistant agent
BuiltinDocumentAssistantID = "builtin-document-assistant"
// BuiltinWikiResearcherID is the ID for the built-in wiki researcher agent
BuiltinWikiResearcherID = "builtin-wiki-researcher"
// BuiltinWikiFixerID is the ID for the built-in wiki fixer agent
BuiltinWikiFixerID = "builtin-wiki-fixer"
)
// AgentMode constants for agent running mode
@@ -115,6 +119,9 @@ type CustomAgentConfig struct {
// When false, knowledge base retrieval happens according to KBSelectionMode
RetrieveKBOnlyWhenMentioned bool `yaml:"retrieve_kb_only_when_mentioned" json:"retrieve_kb_only_when_mentioned"`
// Whether to retain retrieval history across turns
RetainRetrievalHistory bool `yaml:"retain_retrieval_history" json:"retain_retrieval_history"`
// ===== Image Upload / Multimodal Settings =====
// Whether image upload is enabled for this agent (default: false)
ImageUploadEnabled bool `yaml:"image_upload_enabled" json:"image_upload_enabled"`
@@ -288,6 +295,8 @@ var BuiltinAgentRegistry = map[string]func(uint64) *CustomAgent{}
var builtinAgentIDsOrdered = []string{
BuiltinQuickAnswerID,
BuiltinSmartReasoningID,
BuiltinWikiResearcherID,
BuiltinWikiFixerID,
BuiltinDeepResearcherID,
BuiltinDataAnalystID,
BuiltinKnowledgeGraphExpertID,
+7
View File
@@ -52,6 +52,13 @@ type WikiPageService interface {
// RebuildLinks re-parses all pages and rebuilds bidirectional link references.
RebuildLinks(ctx context.Context, kbID string) error
// InjectCrossLinks scans specified pages and injects [[wiki-links]] for mentions
// of other wiki page titles/aliases in the content.
InjectCrossLinks(ctx context.Context, kbID string, affectedSlugs []string)
// RebuildIndexPage regenerates the index page.
RebuildIndexPage(ctx context.Context, kbID string) error
// ListAllPages retrieves all wiki pages in a knowledge base without pagination.
// Used for index rebuild, graph generation, cross-link injection, etc.
ListAllPages(ctx context.Context, kbID string) ([]*types.WikiPage, error)
+1
View File
@@ -169,6 +169,7 @@ type WikiStats struct {
OrphanCount int64 `json:"orphan_count"` // pages with no inbound links
RecentUpdates []*WikiPage `json:"recent_updates"` // last N updated pages
PendingTasks int64 `json:"pending_tasks"` // number of documents waiting to be ingested
PendingIssues int64 `json:"pending_issues"` // number of pending wiki issues
IsActive bool `json:"is_active"` // whether wiki ingestion is currently running
}