mirror of
https://github.com/Tencent/WeKnora.git
synced 2026-08-31 00:50:02 +08:00
fix lint and test failures
This commit is contained in:
@@ -200,7 +200,7 @@ export function useStream() {
|
||||
|
||||
let chunkHandler: ((data: any) => void) | null = null
|
||||
// 注册块处理器
|
||||
const onChunk = (handler: () => void) => {
|
||||
const onChunk = (handler: (data: any) => void) => {
|
||||
chunkHandler = handler
|
||||
}
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@ export async function listAllTenants(): Promise<{ success: boolean; data?: { ite
|
||||
* 重置租户的 API Key。成功后返回新的明文 Key,旧 Key 立即失效。
|
||||
*/
|
||||
export async function resetTenantApiKey(
|
||||
tenantId: number,
|
||||
tenantId: string | number,
|
||||
): Promise<{ success: boolean; data?: { api_key: string }; message?: string }> {
|
||||
try {
|
||||
const response = await post(`/api/v1/tenants/${tenantId}/api-key`)
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
</div>
|
||||
<div v-if="authStore.hasRole('admin')" class="channel-card__actions" @click.stop>
|
||||
<t-dropdown trigger="click" placement="bottom-right" attach="body" :options="channelMenuOptions(ch)"
|
||||
@click="(data) => handleChannelMenuClick(data, ch)">
|
||||
@click="handleChannelMenuClick($event, ch)">
|
||||
<t-button variant="text" shape="square" size="small" class="channel-card__action-btn channel-card__more"
|
||||
@click.stop>
|
||||
<template #icon><t-icon name="ellipsis" /></template>
|
||||
|
||||
@@ -204,6 +204,10 @@ type AgentDetailTarget = {
|
||||
sharedMeta?: { org_name?: string; shared_by_username?: string };
|
||||
};
|
||||
|
||||
type SharedAgentSelection = Omit<SharedAgentInfo, 'agent'> & {
|
||||
agent: CustomAgent;
|
||||
};
|
||||
|
||||
const dropdownStyle = ref<Record<string, string>>({});
|
||||
const activeDetail = ref<AgentDetailTarget | null>(null);
|
||||
const detailAnchorEl = ref<HTMLElement | null>(null);
|
||||
@@ -232,13 +236,21 @@ const builtinAgents = computed(() => {
|
||||
|
||||
const customAgents = computed(() => agentsList.value.filter(a => !a.is_builtin));
|
||||
|
||||
const sharedAgentsList = computed<SharedAgentInfo[]>(() =>
|
||||
(orgStore.sharedAgents || []).filter(shared => !shared.disabled_by_me),
|
||||
const toCustomAgent = (agent: SharedAgentInfo['agent']): CustomAgent => ({
|
||||
is_builtin: false,
|
||||
config: {},
|
||||
...agent,
|
||||
});
|
||||
|
||||
const sharedAgentsList = computed<SharedAgentSelection[]>(() =>
|
||||
(orgStore.sharedAgents || [])
|
||||
.filter(shared => !shared.disabled_by_me)
|
||||
.map(shared => ({ ...shared, agent: toCustomAgent(shared.agent) })),
|
||||
);
|
||||
|
||||
const currentAgentSourceTenantId = computed(() => settingsStore.selectedAgentSourceTenantId ?? null);
|
||||
|
||||
const isSharedAgentSelected = (shared: SharedAgentInfo) =>
|
||||
const isSharedAgentSelected = (shared: SharedAgentSelection) =>
|
||||
props.currentAgentId === shared.agent.id && currentAgentSourceTenantId.value === String(shared.source_tenant_id);
|
||||
|
||||
const isMyAgentSelected = (agent: CustomAgent) =>
|
||||
@@ -394,8 +406,8 @@ const onOptionEnter = (agent: CustomAgent, event: MouseEvent, sourceTenantId?: s
|
||||
scheduleDetailPanelPosition();
|
||||
};
|
||||
|
||||
const onSharedOptionEnter = (shared: SharedAgentInfo, event: MouseEvent) => {
|
||||
onOptionEnter(shared.agent as CustomAgent, event, String(shared.source_tenant_id), {
|
||||
const onSharedOptionEnter = (shared: SharedAgentSelection, event: MouseEvent) => {
|
||||
onOptionEnter(shared.agent, event, String(shared.source_tenant_id), {
|
||||
org_name: shared.org_name,
|
||||
shared_by_username: shared.shared_by_username,
|
||||
});
|
||||
@@ -430,13 +442,13 @@ const selectAgent = (agent: CustomAgent) => {
|
||||
emit('select', agent);
|
||||
};
|
||||
|
||||
const selectSharedAgent = (shared: SharedAgentInfo) => {
|
||||
const selectSharedAgent = (shared: SharedAgentSelection) => {
|
||||
const sourceTenantId = String(shared.source_tenant_id);
|
||||
if (getAgentNotReadyLabels(shared.agent, sourceTenantId).length > 0) {
|
||||
emitAgentNotReady(shared.agent as CustomAgent, sourceTenantId);
|
||||
emitAgentNotReady(shared.agent, sourceTenantId);
|
||||
return;
|
||||
}
|
||||
emit('select', shared.agent as CustomAgent, sourceTenantId);
|
||||
emit('select', shared.agent, sourceTenantId);
|
||||
};
|
||||
|
||||
const goToSettings = (agent: CustomAgent, sourceTenantId?: string) => {
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
</div>
|
||||
<div v-if="authStore.hasRole('admin')" class="channel-card__actions" @click.stop>
|
||||
<t-dropdown trigger="click" placement="bottom-right" attach="body" :options="channelMenuOptions(channel)"
|
||||
@click="(data) => handleChannelMenuClick(data, channel)">
|
||||
@click="handleChannelMenuClick($event, channel)">
|
||||
<t-button variant="text" shape="square" size="small" class="channel-card__action-btn channel-card__more"
|
||||
@click.stop>
|
||||
<template #icon><t-icon name="ellipsis" /></template>
|
||||
|
||||
@@ -967,7 +967,7 @@ const selectModelType = async (type: EditorModelType) => {
|
||||
formData.value.provider = 'generic'
|
||||
formData.value.baseUrl = ''
|
||||
} else {
|
||||
handleProviderChange(formData.value.provider)
|
||||
handleProviderChange(formData.value.provider || 'generic')
|
||||
}
|
||||
if (showThinkingControlField.value && !isEdit.value) {
|
||||
thinkingControlManual.value = false
|
||||
@@ -1319,7 +1319,7 @@ const checkRemoteAPI = async () => {
|
||||
// 对话模型(KnowledgeQA)
|
||||
result = await checkRemoteModel({
|
||||
modelName: formData.value.modelName,
|
||||
baseUrl: formData.value.baseUrl,
|
||||
baseUrl: formData.value.baseUrl || '',
|
||||
apiKey: formData.value.apiKey || '',
|
||||
provider: formData.value.provider,
|
||||
...idPayload,
|
||||
@@ -1332,7 +1332,7 @@ const checkRemoteAPI = async () => {
|
||||
result = await testEmbeddingModel({
|
||||
source: 'remote',
|
||||
modelName: formData.value.modelName,
|
||||
baseUrl: formData.value.baseUrl,
|
||||
baseUrl: formData.value.baseUrl || '',
|
||||
apiKey: formData.value.apiKey || '',
|
||||
dimension: formData.value.dimension,
|
||||
supportsDimensionOverride: formData.value.supportsDimensionOverride ?? false,
|
||||
@@ -1360,7 +1360,7 @@ const checkRemoteAPI = async () => {
|
||||
: {}
|
||||
result = await checkRerankModel({
|
||||
modelName: formData.value.modelName,
|
||||
baseUrl: formData.value.baseUrl,
|
||||
baseUrl: formData.value.baseUrl || '',
|
||||
apiKey: formData.value.apiKey || '',
|
||||
provider: formData.value.provider,
|
||||
...idPayload,
|
||||
@@ -1375,7 +1375,7 @@ const checkRemoteAPI = async () => {
|
||||
// VLLM 使用 checkRemoteModel 进行基础连接测试
|
||||
result = await checkRemoteModel({
|
||||
modelName: formData.value.modelName,
|
||||
baseUrl: formData.value.baseUrl,
|
||||
baseUrl: formData.value.baseUrl || '',
|
||||
apiKey: formData.value.apiKey || '',
|
||||
provider: formData.value.provider,
|
||||
...idPayload,
|
||||
@@ -1387,7 +1387,7 @@ const checkRemoteAPI = async () => {
|
||||
// ASR 模型(语音识别)— 使用专用的 ASR 测试接口(/v1/audio/transcriptions)
|
||||
result = await checkASRModel({
|
||||
modelName: formData.value.modelName,
|
||||
baseUrl: formData.value.baseUrl,
|
||||
baseUrl: formData.value.baseUrl || '',
|
||||
apiKey: formData.value.apiKey || '',
|
||||
provider: formData.value.provider,
|
||||
...idPayload,
|
||||
|
||||
@@ -414,7 +414,7 @@ const previewHTML = computed(() => {
|
||||
return `<p class="empty-preview">${t('manualEditor.preview.empty')}</p>`
|
||||
}
|
||||
const safeMarkdown = safeMarkdownToHTML(form.content)
|
||||
const html = marked.parse(safeMarkdown)
|
||||
const html = marked.parse(safeMarkdown, { async: false })
|
||||
return sanitizeHTML(html)
|
||||
})
|
||||
|
||||
|
||||
@@ -482,6 +482,7 @@ const filteredGroupedSessions = computed(() => {
|
||||
bucket.items.map((item) => ({
|
||||
...item,
|
||||
path: `chat/${item.id}`,
|
||||
title: item.title || '',
|
||||
})),
|
||||
dateBucketLabels.value,
|
||||
(session) => classifyDateBucket(session.updated_at || session.created_at),
|
||||
|
||||
@@ -259,7 +259,8 @@ export function useChatStreamHandler(options: UseChatStreamHandlerOptions) {
|
||||
const events: ChatMessage[] = []
|
||||
|
||||
if (agentSteps && Array.isArray(agentSteps) && agentSteps.length > 0) {
|
||||
agentSteps.forEach((step: ChatMessage) => {
|
||||
agentSteps.forEach((rawStep) => {
|
||||
const step = rawStep as ChatMessage
|
||||
const stepTimestamp = step.timestamp ? new Date(String(step.timestamp)).getTime() : 0
|
||||
const toolCalls = step.tool_calls
|
||||
const hasToolCalls = toolCalls && Array.isArray(toolCalls) && toolCalls.length > 0
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from '@/api/system'
|
||||
import { listMCPServices, type MCPService } from '@/api/mcp-service'
|
||||
import { listSkills, type SkillInfo } from '@/api/skill'
|
||||
import { getAgentTypePresets, getPlaceholders, type AgentTypePreset, type PlaceholderDefinition } from '@/api/agent'
|
||||
import { getAgentTypePresets, getPlaceholders, type AgentTypePreset, type PlaceholdersResponse } from '@/api/agent'
|
||||
import { getTenantRetrievalConfig } from '@/api/retrieval'
|
||||
|
||||
const CACHE_TTL_MS = 60_000
|
||||
@@ -38,7 +38,7 @@ export const useEditorResourcesStore = defineStore('editorResources', () => {
|
||||
const skillsAvailable = ref(true)
|
||||
const agentTypePresets = ref<AgentTypePreset[]>([])
|
||||
const promptTemplates = ref<PromptTemplatesConfig | null>(null)
|
||||
const placeholders = ref<PlaceholderDefinition[]>([])
|
||||
const placeholders = ref<PlaceholdersResponse | null>(null)
|
||||
const tenantRetrievalConfig = ref<Record<string, unknown> | null>(null)
|
||||
const parserEngines = ref<ParserEngineInfo[]>([])
|
||||
const systemInfo = ref<SystemInfo | null>(null)
|
||||
@@ -114,7 +114,7 @@ export const useEditorResourcesStore = defineStore('editorResources', () => {
|
||||
async function ensurePlaceholders(force = false): Promise<void> {
|
||||
return runOnce('placeholders', force, async () => {
|
||||
const placeholdersRes = await getPlaceholders()
|
||||
placeholders.value = placeholdersRes?.data ?? []
|
||||
placeholders.value = placeholdersRes?.data ?? null
|
||||
loadedAt.value.placeholders = Date.now()
|
||||
})
|
||||
}
|
||||
@@ -166,7 +166,7 @@ export const useEditorResourcesStore = defineStore('editorResources', () => {
|
||||
skills.value = []
|
||||
agentTypePresets.value = []
|
||||
promptTemplates.value = null
|
||||
placeholders.value = []
|
||||
placeholders.value = null
|
||||
tenantRetrievalConfig.value = null
|
||||
parserEngines.value = []
|
||||
systemInfo.value = null
|
||||
|
||||
@@ -18,6 +18,7 @@ interface Settings {
|
||||
selectedTags: Array<{ id: string; name: string; kbId: string; kbName?: string }>;
|
||||
selectedMCPServices: string[];
|
||||
selectedSkills: string[];
|
||||
selectedTools?: string[];
|
||||
modelConfig: ModelConfig; // 模型配置
|
||||
ollamaConfig: OllamaConfig; // Ollama配置
|
||||
webSearchEnabled: boolean; // 网络搜索是否启用
|
||||
|
||||
@@ -246,14 +246,14 @@ instance.interceptors.response.use(
|
||||
}
|
||||
);
|
||||
|
||||
export function get(url: string, config?: any) {
|
||||
return instance.get(url, config);
|
||||
export function get<T = any>(url: string, config?: any): Promise<T> {
|
||||
return instance.get<T>(url, config) as unknown as Promise<T>;
|
||||
}
|
||||
|
||||
export async function getDown(url: string) {
|
||||
let res = await instance.get(url, {
|
||||
export async function getDown(url: string): Promise<Blob> {
|
||||
const res = await instance.get<Blob>(url, {
|
||||
responseType: "blob",
|
||||
});
|
||||
}) as unknown as Blob;
|
||||
return res
|
||||
}
|
||||
|
||||
@@ -262,7 +262,7 @@ export function postUpload(
|
||||
data = {},
|
||||
onUploadProgress?: (progressEvent: any) => void,
|
||||
config: any = {},
|
||||
) {
|
||||
): Promise<any> {
|
||||
return instance.post(url, data, {
|
||||
...config,
|
||||
headers: {
|
||||
@@ -271,26 +271,26 @@ export function postUpload(
|
||||
...(config.headers || {}),
|
||||
},
|
||||
onUploadProgress: onUploadProgress || config.onUploadProgress,
|
||||
});
|
||||
}) as unknown as Promise<any>;
|
||||
}
|
||||
|
||||
export function postChat(url: string, data = {}) {
|
||||
export function postChat<T = any>(url: string, data = {}): Promise<T> {
|
||||
return instance.post(url, data, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream;charset=utf-8",
|
||||
"X-Request-ID": `${generateRandomString(12)}`,
|
||||
},
|
||||
});
|
||||
}) as unknown as Promise<T>;
|
||||
}
|
||||
|
||||
export function post(url: string, data = {}, config?: any) {
|
||||
return instance.post(url, data, config);
|
||||
export function post<T = any>(url: string, data = {}, config?: any): Promise<T> {
|
||||
return instance.post<T>(url, data, config) as unknown as Promise<T>;
|
||||
}
|
||||
|
||||
export function put(url: string, data = {}) {
|
||||
return instance.put(url, data);
|
||||
export function put<T = any>(url: string, data = {}, config?: any): Promise<T> {
|
||||
return instance.put<T>(url, data, config) as unknown as Promise<T>;
|
||||
}
|
||||
|
||||
export function del(url: string, data?: any) {
|
||||
return instance.delete(url, { data });
|
||||
export function del<T = any>(url: string, data?: any): Promise<T> {
|
||||
return instance.delete<T>(url, { data }) as unknown as Promise<T>;
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ export function sanitizeHTML(html: string): string {
|
||||
|
||||
try {
|
||||
const preparedHTML = protectProviderImageSrcInHTML(html);
|
||||
return DOMPurify.sanitize(preparedHTML, DOMPurifyConfig);
|
||||
return DOMPurify.sanitize(preparedHTML, DOMPurifyConfig as unknown as Config);
|
||||
} catch (error) {
|
||||
console.error('HTML sanitization failed:', error);
|
||||
// 如果清理失败,返回转义的纯文本
|
||||
@@ -85,7 +85,7 @@ export function sanitizeMarkdownHTML(html: string): string {
|
||||
|
||||
try {
|
||||
const preparedHTML = protectProviderImageSrcInHTML(html);
|
||||
return DOMPurify.sanitize(preparedHTML, markdownDomPurifyConfig as Config);
|
||||
return DOMPurify.sanitize(preparedHTML, markdownDomPurifyConfig as unknown as Config);
|
||||
} catch (error) {
|
||||
console.error('Markdown HTML sanitization failed:', error);
|
||||
return escapeHTML(html);
|
||||
|
||||
@@ -2994,7 +2994,9 @@ const loadDependencies = async () => {
|
||||
|
||||
webSearchProviderList.value = chatResources.webSearchProviders as WebSearchProviderEntity[];
|
||||
|
||||
placeholderData.value = editorResources.placeholders as PlaceholderDefinition[];
|
||||
if (editorResources.placeholders) {
|
||||
placeholderData.value = editorResources.placeholders;
|
||||
}
|
||||
|
||||
const rc = editorResources.tenantRetrievalConfig as Record<string, number> | null;
|
||||
if (rc?.embedding_top_k) defaultEmbeddingTopK.value = rc.embedding_top_k;
|
||||
|
||||
@@ -719,6 +719,7 @@ interface SessionData {
|
||||
isAgentMode?: boolean;
|
||||
agentEventStream?: any[];
|
||||
knowledge_references?: any[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -1617,7 +1618,7 @@ const onRootClick = (e: Event) => {
|
||||
const slug = wikiEl.getAttribute('data-slug');
|
||||
|
||||
// Determine the relevant KB ID
|
||||
const kbId = getKbIdForWiki(slug);
|
||||
const kbId = getKbIdForWiki(slug || '');
|
||||
|
||||
if (kbId && slug) {
|
||||
openWikiDrawer(kbId, slug);
|
||||
|
||||
@@ -81,8 +81,8 @@ const { t } = useI18n()
|
||||
const authorizing = ref(false)
|
||||
const canceling = ref(false)
|
||||
const now = ref(Date.now())
|
||||
let clock: ReturnType<typeof setInterval> | null = null
|
||||
let poll: ReturnType<typeof setInterval> | null = null
|
||||
let clock: number | null = null
|
||||
let poll: number | null = null
|
||||
|
||||
const deadline = computed(() => {
|
||||
const base = (props.requestedAt || 0) * 1000
|
||||
@@ -138,7 +138,7 @@ function formatCountdown(s: number): string {
|
||||
|
||||
function stopPoll() {
|
||||
if (poll) {
|
||||
clearInterval(poll)
|
||||
window.clearInterval(poll)
|
||||
poll = null
|
||||
}
|
||||
}
|
||||
@@ -248,13 +248,13 @@ const authorize = async () => {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
clock = setInterval(() => {
|
||||
clock = window.setInterval(() => {
|
||||
now.value = Date.now()
|
||||
}, 1000)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (clock) clearInterval(clock)
|
||||
if (clock) window.clearInterval(clock)
|
||||
stopPoll()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { defineProps, computed } from 'vue';
|
||||
import { computed } from 'vue';
|
||||
import type {
|
||||
DisplayType,
|
||||
SearchResultsData,
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { onMounted, onBeforeUnmount, watch, computed, ref, reactive, defineProps, nextTick, onUpdated } from 'vue';
|
||||
import { onMounted, onBeforeUnmount, watch, computed, ref, reactive, nextTick, onUpdated } from 'vue';
|
||||
import 'katex/dist/katex.min.css';
|
||||
import docInfo from './docInfo.vue';
|
||||
import deepThink from './deepThink.vue';
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { watch, ref, defineProps, onMounted, nextTick } from 'vue';
|
||||
import { watch, ref, onMounted, nextTick } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const isFold = ref(false)
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { defineProps, computed, ref, reactive } from "vue";
|
||||
import { computed, ref, reactive } from "vue";
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { sanitizeHTML } from '@/utils/security';
|
||||
|
||||
@@ -35,7 +35,6 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { defineProps } from 'vue';
|
||||
import type { ChunkDetailData } from '@/types/tool-results';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
@@ -91,4 +90,3 @@ code {
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, defineProps } from 'vue';
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import type { DocumentInfoData, DocumentInfoDocument } from '@/types/tool-results';
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, defineProps } from 'vue';
|
||||
import { ref } from 'vue';
|
||||
import type { GraphQueryResultsData, RelevanceLevel } from '@/types/tool-results';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
|
||||
@@ -35,7 +35,6 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { defineProps } from 'vue';
|
||||
import type { KnowledgeBaseListData } from '@/types/tool-results';
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -105,4 +104,3 @@ code {
|
||||
border-radius: 3px;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, defineProps, computed } from 'vue';
|
||||
import { ref, computed } from 'vue';
|
||||
import type { RelatedChunksData } from '@/types/tool-results';
|
||||
import ContentPopup from './ContentPopup.vue';
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { defineProps } from 'vue';
|
||||
import type { ThinkingData } from '@/types/tool-results';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
@@ -51,4 +50,3 @@ useI18n(); // ensure component reacts to locale changes if needed
|
||||
font-weight: 400;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { defineProps, computed, ref, watch, onMounted, nextTick } from "vue";
|
||||
import { computed, ref, watch, onMounted, nextTick } from "vue";
|
||||
import { hydrateProtectedFileImages } from '@/utils/security';
|
||||
import picturePreview from '@/components/picture-preview.vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
@@ -110,7 +110,7 @@
|
||||
</template>
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { ref, onMounted, onBeforeMount, onUnmounted, nextTick, watch, reactive, defineProps, computed } from 'vue';
|
||||
import { ref, onMounted, onBeforeMount, onUnmounted, nextTick, watch, reactive, computed } from 'vue';
|
||||
import { useRoute, onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router';
|
||||
import InputField from '../../components/Input-field.vue';
|
||||
import botmsg from './components/botmsg.vue';
|
||||
|
||||
@@ -47,13 +47,13 @@
|
||||
class="msg-item-wrapper"
|
||||
>
|
||||
<div v-if="session.role === 'user'">
|
||||
<EmbedUserMessage
|
||||
:content="String(session.content || '')"
|
||||
:mentioned_items="session.mentioned_items"
|
||||
:images="session.images"
|
||||
:attachments="session.attachments"
|
||||
:embeddedMode="true"
|
||||
:embed-channel-id="channelId"
|
||||
<EmbedUserMessage
|
||||
:content="String(session.content || '')"
|
||||
:mentioned_items="asUnknownArray(session.mentioned_items)"
|
||||
:images="asEmbedImages(session.images)"
|
||||
:attachments="asEmbedAttachments(session.attachments)"
|
||||
:embeddedMode="true"
|
||||
:embed-channel-id="channelId"
|
||||
:embed-token="token"
|
||||
/>
|
||||
</div>
|
||||
@@ -112,6 +112,9 @@ import EmbedBotMessage from '@/views/embed/EmbedBotMessage.vue'
|
||||
import EmbedUserMessage from '@/views/embed/EmbedUserMessage.vue'
|
||||
import { useEmbedChatSession } from '@/composables/useEmbedChatSession'
|
||||
|
||||
type EmbedImage = { url?: string; data?: string }
|
||||
type EmbedAttachment = { file_name: string; file_size?: number }
|
||||
|
||||
const props = defineProps<{
|
||||
sessionId: string
|
||||
sessionSig: string
|
||||
@@ -143,6 +146,18 @@ const suggestedQuestions = ref<SuggestedQuestion[]>([])
|
||||
const suggestedLoading = ref(false)
|
||||
const hostContextRef = ref<Record<string, unknown>>(props.hostContext || {})
|
||||
|
||||
function asUnknownArray(value: unknown): unknown[] | undefined {
|
||||
return Array.isArray(value) ? value : undefined
|
||||
}
|
||||
|
||||
function asEmbedImages(value: unknown): EmbedImage[] | undefined {
|
||||
return Array.isArray(value) ? value as EmbedImage[] : undefined
|
||||
}
|
||||
|
||||
function asEmbedAttachments(value: unknown): EmbedAttachment[] | undefined {
|
||||
return Array.isArray(value) ? value as EmbedAttachment[] : undefined
|
||||
}
|
||||
|
||||
const embedWebSearchStorageKey = () => `weknora-embed-web-search:${props.channelId}`
|
||||
|
||||
const readStoredWebSearchEnabled = () => {
|
||||
|
||||
@@ -512,8 +512,8 @@ const tagPage = ref(1);
|
||||
const tagHasMore = ref(false);
|
||||
const tagLoadingMore = ref(false);
|
||||
const tagTotal = ref(0);
|
||||
let tagSearchDebounce: ReturnType<typeof setTimeout> | null = null;
|
||||
let docSearchDebounce: ReturnType<typeof setTimeout> | null = null;
|
||||
let tagSearchDebounce: number | null = null;
|
||||
let docSearchDebounce: number | null = null;
|
||||
const docSearchKeyword = ref('');
|
||||
const selectedFileType = ref('');
|
||||
const fileTypeOptions = computed(() => [
|
||||
@@ -961,7 +961,7 @@ watch(selectedTagIds, (newVal, oldVal) => {
|
||||
watch(tagSearchQuery, (newVal, oldVal) => {
|
||||
if (newVal === oldVal) return;
|
||||
if (tagSearchDebounce) {
|
||||
clearTimeout(tagSearchDebounce);
|
||||
window.clearTimeout(tagSearchDebounce);
|
||||
}
|
||||
tagSearchDebounce = window.setTimeout(() => {
|
||||
if (kbId.value) {
|
||||
@@ -974,7 +974,7 @@ watch(tagSearchQuery, (newVal, oldVal) => {
|
||||
watch(docSearchKeyword, (newVal, oldVal) => {
|
||||
if (newVal === oldVal) return;
|
||||
if (docSearchDebounce) {
|
||||
clearTimeout(docSearchDebounce);
|
||||
window.clearTimeout(docSearchDebounce);
|
||||
}
|
||||
docSearchDebounce = window.setTimeout(() => {
|
||||
if (kbId.value) {
|
||||
@@ -2409,7 +2409,7 @@ async function createNewSession(value: string): Promise<void> {
|
||||
<div v-if="canEdit && batchMode" class="card-nav-check" @click.stop>
|
||||
<t-checkbox class="card-select-checkbox" size="small" :checked="selectedIds.has(item.id)"
|
||||
:title="item.file_name"
|
||||
@change="(checked, ctx) => onCardGridCheckboxChange(item.id, checked, ctx)" />
|
||||
@change="(checked: boolean, ctx?: { e?: Event }) => onCardGridCheckboxChange(item.id, checked, ctx)" />
|
||||
</div>
|
||||
<span class="card-content-title" :title="item.file_name">{{ item.file_name }}</span>
|
||||
<t-popup v-if="canEdit" v-model="item.isMore" overlayClassName="card-more"
|
||||
|
||||
@@ -822,6 +822,8 @@ interface KB {
|
||||
name: string;
|
||||
description?: string;
|
||||
updated_at?: string;
|
||||
created_at?: string;
|
||||
pinned_at?: string;
|
||||
embedding_model_id?: string;
|
||||
summary_model_id?: string;
|
||||
type?: 'document' | 'faq';
|
||||
|
||||
@@ -220,7 +220,7 @@ const handleAction = (action: 'edit' | 'reparse' | 'cancel-parse' | 'move' | 'de
|
||||
role="row" @click="emit('open', item)">
|
||||
<div class="cell cell-check" @click.stop>
|
||||
<t-checkbox class="doc-list-check" size="small" :checked="selectedIds.has(item.id)" :title="item.file_name"
|
||||
@change="(c, ctx) => onRowCheckboxChange(item, c, ctx)" />
|
||||
@change="(c: boolean, ctx?: { e?: Event }) => onRowCheckboxChange(item, c, ctx)" />
|
||||
</div>
|
||||
|
||||
<div class="cell cell-name">
|
||||
@@ -236,20 +236,20 @@ const handleAction = (action: 'edit' | 'reparse' | 'cancel-parse' | 'move' | 'de
|
||||
|
||||
<div class="cell cell-tag">
|
||||
<template v-if="item.tags && item.tags.length > 0">
|
||||
<t-tooltip v-if="hasTagOverflow(item.id, item.tags.length)"
|
||||
:content="item.tags.map((t: any) => t.name).join(', ')" placement="top">
|
||||
<div class="row-tag-chips" :ref="(el: any) => setupTagChipsObserver(el, item.id, item.tags.length)"
|
||||
<t-tooltip v-if="hasTagOverflow(item.id, (item.tags || []).length)"
|
||||
:content="(item.tags || []).map((t: any) => t.name).join(', ')" placement="top">
|
||||
<div class="row-tag-chips" :ref="(el: any) => setupTagChipsObserver(el, item.id, (item.tags || []).length)"
|
||||
:class="{ 'is-clickable': canEdit }" @click.stop="canEdit && emit('tag-edit', item)">
|
||||
<t-tag v-for="tag in item.tags.slice(0, getTagLimit(item.id))" :key="tag.id" size="small"
|
||||
<t-tag v-for="tag in (item.tags || []).slice(0, getTagLimit(item.id))" :key="tag.id" size="small"
|
||||
variant="light-outline" class="row-tag">
|
||||
{{ tag.name }}
|
||||
</t-tag>
|
||||
<span class="row-tag-overflow">+{{ getOverflowCount(item.id, item.tags.length) }}</span>
|
||||
<span class="row-tag-overflow">+{{ getOverflowCount(item.id, (item.tags || []).length) }}</span>
|
||||
</div>
|
||||
</t-tooltip>
|
||||
<div v-else class="row-tag-chips" :ref="(el: any) => setupTagChipsObserver(el, item.id, item.tags.length)"
|
||||
<div v-else class="row-tag-chips" :ref="(el: any) => setupTagChipsObserver(el, item.id, (item.tags || []).length)"
|
||||
:class="{ 'is-clickable': canEdit }" @click.stop="canEdit && emit('tag-edit', item)">
|
||||
<t-tag v-for="tag in item.tags.slice(0, getTagLimit(item.id))" :key="tag.id" size="small"
|
||||
<t-tag v-for="tag in (item.tags || []).slice(0, getTagLimit(item.id))" :key="tag.id" size="small"
|
||||
variant="light-outline" class="row-tag">
|
||||
{{ tag.name }}
|
||||
</t-tag>
|
||||
|
||||
@@ -185,7 +185,7 @@
|
||||
<t-input ref="newTagInputRef" v-model="newTagName" size="small" :maxlength="40"
|
||||
:placeholder="$t('knowledgeBase.tagNamePlaceholder')"
|
||||
@enter="submitCreateTag"
|
||||
@keydown="(_v, ctx) => { if (ctx?.e?.key === 'Escape') { ctx.e.stopPropagation(); ctx.e.preventDefault(); cancelCreateTag() } }" />
|
||||
@keydown="(_v: string, ctx?: { e?: KeyboardEvent }) => { if (ctx?.e?.key === 'Escape') { ctx.e.stopPropagation(); ctx.e.preventDefault(); cancelCreateTag() } }" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="tag-inline-actions">
|
||||
@@ -210,7 +210,7 @@
|
||||
<div class="tag-edit-input" @click.stop>
|
||||
<t-input :ref="setEditingTagInputRefByTag(tag.id)" v-model="editingTagName" size="small"
|
||||
:maxlength="40" @enter="submitEditTag"
|
||||
@keydown="(_v, ctx) => { if (ctx?.e?.key === 'Escape') { ctx.e.stopPropagation(); ctx.e.preventDefault(); cancelEditTag() } }" />
|
||||
@keydown="(_v: string, ctx?: { e?: KeyboardEvent }) => { if (ctx?.e?.key === 'Escape') { ctx.e.stopPropagation(); ctx.e.preventDefault(); cancelEditTag() } }" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
@@ -1086,7 +1086,7 @@ const hasMore = ref(true)
|
||||
const pageSize = 20
|
||||
let currentPage = 1
|
||||
const entrySearchKeyword = ref('')
|
||||
let entrySearchDebounce: ReturnType<typeof setTimeout> | null = null
|
||||
let entrySearchDebounce: number | null = null
|
||||
type TagInputInstance = ComponentPublicInstance<{ focus: () => void; select: () => void }>
|
||||
|
||||
const tagList = ref<any[]>([])
|
||||
@@ -1101,7 +1101,7 @@ const tagPage = ref(1)
|
||||
const tagHasMore = ref(false)
|
||||
const tagLoadingMore = ref(false)
|
||||
const tagTotal = ref(0)
|
||||
let tagSearchDebounce: ReturnType<typeof setTimeout> | null = null
|
||||
let tagSearchDebounce: number | null = null
|
||||
const editingTagInputRefs = new Map<string, TagInputInstance | null>()
|
||||
const setEditingTagInputRef = (el: TagInputInstance | null, tagId: string) => {
|
||||
if (el) {
|
||||
@@ -1175,14 +1175,14 @@ const loadKnowledgeInfo = async (kbId: string) => {
|
||||
const loadKnowledgeList = async () => {
|
||||
try {
|
||||
const res: any = await listKnowledgeBases()
|
||||
const myKbs = (res?.data || []).map((item: any) => ({
|
||||
const myKbs: typeof knowledgeList.value = (res?.data || []).map((item: any) => ({
|
||||
id: String(item.id),
|
||||
name: item.name,
|
||||
type: item.type,
|
||||
}))
|
||||
|
||||
// Also include shared knowledge bases from orgStore
|
||||
const sharedKbs = (orgStore.sharedKnowledgeBases || [])
|
||||
const sharedKbs: typeof knowledgeList.value = (orgStore.sharedKnowledgeBases || [])
|
||||
.filter(s => s.knowledge_base != null)
|
||||
.map(s => ({
|
||||
id: String(s.knowledge_base.id),
|
||||
@@ -2691,7 +2691,7 @@ watch(selectedTagId, (newVal, oldVal) => {
|
||||
watch(tagSearchQuery, (newVal, oldVal) => {
|
||||
if (newVal === oldVal) return
|
||||
if (tagSearchDebounce) {
|
||||
clearTimeout(tagSearchDebounce)
|
||||
window.clearTimeout(tagSearchDebounce)
|
||||
}
|
||||
tagSearchDebounce = window.setTimeout(() => {
|
||||
loadTags(true)
|
||||
@@ -2702,7 +2702,7 @@ watch(tagSearchQuery, (newVal, oldVal) => {
|
||||
watch(entrySearchKeyword, (newVal, oldVal) => {
|
||||
if (newVal === oldVal) return
|
||||
if (entrySearchDebounce) {
|
||||
clearTimeout(entrySearchDebounce)
|
||||
window.clearTimeout(entrySearchDebounce)
|
||||
}
|
||||
entrySearchDebounce = window.setTimeout(() => {
|
||||
loadEntries()
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
class="tag-tile__input"
|
||||
:placeholder="$t('knowledgeBase.tagNamePlaceholder')"
|
||||
@enter="submitCreateTag"
|
||||
@keydown="(_v, ctx) => onEditKeydown(ctx, cancelCreateTag)"
|
||||
@keydown="(_v: string, ctx?: { e?: KeyboardEvent }) => onEditKeydown(ctx, cancelCreateTag)"
|
||||
/>
|
||||
</div>
|
||||
<div class="tag-tile__actions">
|
||||
@@ -116,7 +116,7 @@
|
||||
class="tag-tile__input"
|
||||
:placeholder="$t('knowledgeBase.tagNamePlaceholder')"
|
||||
@enter="submitEditTag"
|
||||
@keydown="(_v, ctx) => onEditKeydown(ctx, cancelEditTag)"
|
||||
@keydown="(_v: string, ctx?: { e?: KeyboardEvent }) => onEditKeydown(ctx, cancelEditTag)"
|
||||
/>
|
||||
</div>
|
||||
<div class="tag-tile__actions">
|
||||
@@ -276,7 +276,7 @@ const setEditingTagInputRef = (el: TagInputInstance | null, tagId: string) => {
|
||||
const getDeleteConfirmContent = (tag: { name: string }) =>
|
||||
t(props.isFaq ? 'knowledgeBase.tagDeleteDesc' : 'knowledgeBase.tagDeleteDescDoc', { name: tag.name });
|
||||
|
||||
const onEditKeydown = (ctx: { e?: KeyboardEvent }, cancel: () => void) => {
|
||||
const onEditKeydown = (ctx: { e?: KeyboardEvent } | undefined, cancel: () => void) => {
|
||||
if (ctx?.e?.key === 'Escape') {
|
||||
ctx.e.stopPropagation();
|
||||
ctx.e.preventDefault();
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, h, withDefaults } from 'vue'
|
||||
import { ref, computed, h } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { MessagePlugin, Icon as TIcon } from 'tdesign-vue-next'
|
||||
import { filterUploadFiles } from '../utils/uploadSources'
|
||||
|
||||
@@ -251,7 +251,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, withDefaults } from 'vue'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { MessagePlugin } from 'tdesign-vue-next'
|
||||
import ModelSelector from '@/components/ModelSelector.vue'
|
||||
|
||||
@@ -737,7 +737,7 @@ async function nextStep() {
|
||||
if (!validateStep1Fields()) return
|
||||
if (needsConnectionTest() && testResult.value !== 'success') {
|
||||
await testConnection()
|
||||
if (testResult.value !== 'success') return
|
||||
if ((testResult.value as string) !== 'success') return
|
||||
}
|
||||
}
|
||||
step.value++
|
||||
|
||||
@@ -304,7 +304,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted, computed, withDefaults } from 'vue'
|
||||
import { ref, watch, onMounted, computed } from 'vue'
|
||||
import { MessagePlugin } from 'tdesign-vue-next'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { extractTextRelations, fabriText, fabriTag, type Node, type Relation } from '@/api/initialization'
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, withDefaults } from 'vue'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
interface QuestionGenerationConfig {
|
||||
enabled: boolean
|
||||
|
||||
@@ -212,7 +212,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed, withDefaults } from 'vue'
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ChevronRightIcon } from 'tdesign-icons-vue-next'
|
||||
import KBChunkingDebug from './KBChunkingDebug.vue'
|
||||
|
||||
@@ -387,7 +387,7 @@ const loadInfo = async () => {
|
||||
const userResponse = await getCurrentUser()
|
||||
|
||||
if ((userResponse as any).success && userResponse.data) {
|
||||
tenantInfo.value = userResponse.data.tenant
|
||||
tenantInfo.value = userResponse.data.tenant ?? null
|
||||
} else {
|
||||
error.value = userResponse.message || t('tenant.messages.fetchFailed')
|
||||
}
|
||||
@@ -664,4 +664,3 @@ onMounted(async () => {
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -167,7 +167,7 @@
|
||||
:class="[
|
||||
'footer-test-message',
|
||||
currentCheckState.result.ok
|
||||
? (currentCheckState.result.bucket_created ? 'created' : 'success')
|
||||
? ((currentCheckState.result as { bucket_created?: boolean }).bucket_created ? 'created' : 'success')
|
||||
: 'error'
|
||||
]"
|
||||
:title="currentCheckState.result.message"
|
||||
@@ -1685,4 +1685,3 @@ onMounted(loadAll)
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
"exclude": ["src/**/__tests__/*"],
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"types": ["vite/client"],
|
||||
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
|
||||
@@ -176,6 +176,16 @@ func waitForMCPOAuthAuthorization(
|
||||
requestID, _ = types.RequestIDFromContext(ctx)
|
||||
}
|
||||
|
||||
// Non-interactive channels (e.g. IM bots) have no live client that can click
|
||||
// "Authorize" and call the resolve endpoint, so blocking on the OAuth wait
|
||||
// would just hang the agent until it times out (once per unauthorized
|
||||
// service). Instead, emit a one-shot notice the channel can surface to the
|
||||
// user and continue without the tool. See types.WithMCPOAuthNonInteractive.
|
||||
if types.IsMCPOAuthNonInteractive(ctx) || types.IsMCPOAuthNonInteractive(waitCtx) {
|
||||
emitMCPOAuthRequiredNotice(waitCtx, sess, service, mcpToolName, toolCallID, tenantID, requestID)
|
||||
return ctx, noop, false
|
||||
}
|
||||
|
||||
decision, waitErr := ow.RequestOAuthAndWait(waitCtx, approval.OAuthPendingRequest{
|
||||
TenantID: tenantID,
|
||||
UserID: userID,
|
||||
@@ -200,6 +210,47 @@ func waitForMCPOAuthAuthorization(
|
||||
return ctx, noop, true
|
||||
}
|
||||
|
||||
// emitMCPOAuthRequiredNotice publishes a one-shot "MCP OAuth required" event
|
||||
// WITHOUT registering a pending waiter. It is used for non-interactive channels
|
||||
// that cannot complete an in-conversation authorization: subscribers (e.g. the
|
||||
// IM reply builder) surface the notice to the user, who then authorizes the
|
||||
// service from the web console out-of-band. TimeoutSeconds is 0 to distinguish
|
||||
// this notice from a resolvable prompt.
|
||||
func emitMCPOAuthRequiredNotice(
|
||||
ctx context.Context,
|
||||
sess *MCPOAuthSession,
|
||||
service *types.MCPService,
|
||||
mcpToolName, toolCallID string,
|
||||
tenantID uint64,
|
||||
requestID string,
|
||||
) {
|
||||
if sess == nil || sess.EventBus == nil || service == nil {
|
||||
return
|
||||
}
|
||||
_ = sess.EventBus.Emit(context.WithoutCancel(ctx), event.Event{
|
||||
ID: "mcp-oauth-notice-" + service.ID,
|
||||
Type: event.EventMCPOAuthRequired,
|
||||
SessionID: sess.SessionID,
|
||||
Data: event.MCPOAuthRequiredData{
|
||||
TenantID: tenantID,
|
||||
SessionID: sess.SessionID,
|
||||
AssistantMessageID: sess.AssistantMessageID,
|
||||
ServiceID: service.ID,
|
||||
ServiceName: service.Name,
|
||||
MCPToolName: mcpToolName,
|
||||
TimeoutSeconds: 0, // 0 => notice only, not an in-conversation prompt
|
||||
RequestedAtUnix: time.Now().Unix(),
|
||||
ToolCallID: toolCallID,
|
||||
RequestID: requestID,
|
||||
},
|
||||
Metadata: map[string]interface{}{
|
||||
"assistant_message_id": sess.AssistantMessageID,
|
||||
"notice_only": true,
|
||||
},
|
||||
RequestID: requestID,
|
||||
})
|
||||
}
|
||||
|
||||
// oauthAwareConnectError turns a low-level MCP connect/call error into a
|
||||
// message the agent (and ultimately the user) can act on.
|
||||
func oauthAwareConnectError(service *types.MCPService, err error) string {
|
||||
|
||||
@@ -116,6 +116,10 @@ func TestClient_Ping_403WrapsInvalidCredentials(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestClient_TokenNeverLoggedInFull(t *testing.T) {
|
||||
t.Setenv("LOG_FORMAT", "")
|
||||
logger.ConfigureFromEnv()
|
||||
defer logger.ConfigureFromEnv()
|
||||
|
||||
// Redirect the project's internal logger to an in-memory buffer so we can
|
||||
// assert the raw token never appears in log output. Using stdlib `log`
|
||||
// would be vacuous — the real logger is a private logrus instance.
|
||||
|
||||
@@ -476,9 +476,49 @@ func withIMIdentity(ctx context.Context, tenantID uint64, channelID string, msg
|
||||
ctx = types.WithPrincipal(ctx, types.Principal{Type: types.PrincipalIMUser, ID: principalID})
|
||||
}
|
||||
ctx = context.WithValue(ctx, types.TenantRoleContextKey, types.TenantRoleViewer)
|
||||
// IM bots have no live client that can complete an in-conversation MCP OAuth
|
||||
// prompt, so mark the context non-interactive: the agent emits a one-shot
|
||||
// authorization notice (surfaced in the reply) instead of blocking until the
|
||||
// OAuth wait times out for every unauthorized service.
|
||||
ctx = types.WithMCPOAuthNonInteractive(ctx)
|
||||
return ctx
|
||||
}
|
||||
|
||||
// formatIMMCPAuthNotice builds a user-facing hint listing OAuth-enabled MCP
|
||||
// services that still need authorization. Returns "" when there is nothing to
|
||||
// report. IM users cannot authorize inline, so they are directed to the console.
|
||||
func formatIMMCPAuthNotice(serviceNames []string) string {
|
||||
names := make([]string, 0, len(serviceNames))
|
||||
seen := make(map[string]bool, len(serviceNames))
|
||||
for _, name := range serviceNames {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" || seen[name] {
|
||||
continue
|
||||
}
|
||||
seen[name] = true
|
||||
names = append(names, name)
|
||||
}
|
||||
if len(names) == 0 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"⚠️ 以下 MCP 服务需要授权后才能使用:%s。请在 WeKnora 管理后台完成 OAuth 授权后重试。",
|
||||
strings.Join(names, "、"),
|
||||
)
|
||||
}
|
||||
|
||||
// appendIMAuthNotice appends an authorization notice to an existing reply body,
|
||||
// separated by a blank line. When the body is empty the notice becomes the body.
|
||||
func appendIMAuthNotice(body, notice string) string {
|
||||
if notice == "" {
|
||||
return body
|
||||
}
|
||||
if strings.TrimSpace(body) == "" {
|
||||
return notice
|
||||
}
|
||||
return body + "\n\n" + notice
|
||||
}
|
||||
|
||||
func buildIMQARequest(
|
||||
session *types.Session,
|
||||
query string,
|
||||
@@ -1900,6 +1940,11 @@ func (s *Service) handleMessageStream(ctx context.Context, msg *IncomingMessage,
|
||||
|
||||
agentCompleteFinalAnswer string
|
||||
streamedAny bool
|
||||
|
||||
// mcpAuthNotices collects OAuth service names that need out-of-band
|
||||
// authorization (IM cannot resolve the in-conversation prompt).
|
||||
mcpAuthNotices []string
|
||||
mcpAuthSeen = make(map[string]bool)
|
||||
)
|
||||
closeDone := func() { closeOnce.Do(func() { close(done) }) }
|
||||
closeComplete := func() { completeOnce.Do(func() { close(completeDone) }) }
|
||||
@@ -2104,6 +2149,23 @@ func (s *Service) handleMessageStream(ctx context.Context, msg *IncomingMessage,
|
||||
return nil
|
||||
})
|
||||
|
||||
// An OAuth-enabled MCP service the IM user has not authorized yet. IM cannot
|
||||
// resolve the in-conversation prompt, so collect the service name and append
|
||||
// an authorization notice to the final reply (deduped per service).
|
||||
eventBus.On(event.EventMCPOAuthRequired, func(_ context.Context, evt event.Event) error {
|
||||
data, ok := evt.Data.(event.MCPOAuthRequiredData)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
bufMu.Lock()
|
||||
if !mcpAuthSeen[data.ServiceID] {
|
||||
mcpAuthSeen[data.ServiceID] = true
|
||||
mcpAuthNotices = append(mcpAuthNotices, data.ServiceName)
|
||||
}
|
||||
bufMu.Unlock()
|
||||
return nil
|
||||
})
|
||||
|
||||
// Determine whether to use agent mode (already set above for event handlers).
|
||||
requestID := uuid.New().String()
|
||||
|
||||
@@ -2212,6 +2274,7 @@ loop:
|
||||
answer := resolvedAnswer
|
||||
finalErr := qaErr
|
||||
noVisibleContent := !streamedAny && strings.TrimSpace(resolvedAnswer) == ""
|
||||
authNotices := append([]string(nil), mcpAuthNotices...)
|
||||
bufMu.Unlock()
|
||||
|
||||
finalDisplay := cleanIMContent(ctx, FormatIMFinalFromParts(parts), tenant, s.defaultFileSvc)
|
||||
@@ -2225,6 +2288,10 @@ loop:
|
||||
answer = fallback
|
||||
}
|
||||
}
|
||||
if notice := formatIMMCPAuthNotice(authNotices); notice != "" {
|
||||
finalDisplay = appendIMAuthNotice(finalDisplay, notice)
|
||||
answer = appendIMAuthNotice(answer, notice)
|
||||
}
|
||||
|
||||
if err := streamer.FinalizeStream(ctx, msg, streamID, finalDisplay); err != nil {
|
||||
logger.Warnf(ctx, "[IM] FinalizeStream failed: %v", err)
|
||||
@@ -2273,6 +2340,8 @@ func (s *Service) runQA(ctx context.Context, session *types.Session, query strin
|
||||
var answerMu sync.Mutex
|
||||
var answerBuilder strings.Builder
|
||||
var qaErr error
|
||||
var mcpAuthNotices []string
|
||||
mcpAuthSeen := make(map[string]bool)
|
||||
done := make(chan struct{})
|
||||
completeDone := make(chan struct{})
|
||||
var closeOnce sync.Once
|
||||
@@ -2308,6 +2377,22 @@ func (s *Service) runQA(ctx context.Context, session *types.Session, query strin
|
||||
return nil
|
||||
})
|
||||
|
||||
// Collect OAuth services that need out-of-band authorization (IM cannot
|
||||
// resolve the in-conversation prompt); appended to the answer below.
|
||||
eventBus.On(event.EventMCPOAuthRequired, func(_ context.Context, evt event.Event) error {
|
||||
data, ok := evt.Data.(event.MCPOAuthRequiredData)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
answerMu.Lock()
|
||||
if !mcpAuthSeen[data.ServiceID] {
|
||||
mcpAuthSeen[data.ServiceID] = true
|
||||
mcpAuthNotices = append(mcpAuthNotices, data.ServiceName)
|
||||
}
|
||||
answerMu.Unlock()
|
||||
return nil
|
||||
})
|
||||
|
||||
// Determine whether to use agent mode
|
||||
useAgent := customAgent != nil && customAgent.IsAgentMode()
|
||||
|
||||
@@ -2408,6 +2493,7 @@ func (s *Service) runQA(ctx context.Context, session *types.Session, query strin
|
||||
answerMu.Lock()
|
||||
answer := answerBuilder.String()
|
||||
qaError := qaErr
|
||||
authNotices := append([]string(nil), mcpAuthNotices...)
|
||||
answerMu.Unlock()
|
||||
|
||||
if answer == "" && qaError != nil {
|
||||
@@ -2416,6 +2502,9 @@ func (s *Service) runQA(ctx context.Context, session *types.Session, query strin
|
||||
if answer == "" {
|
||||
answer = "抱歉,我暂时无法回答这个问题。"
|
||||
}
|
||||
if notice := formatIMMCPAuthNotice(authNotices); notice != "" {
|
||||
answer = appendIMAuthNotice(answer, notice)
|
||||
}
|
||||
|
||||
// Update assistant message with the full answer (including citation tags for web rendering).
|
||||
assistantMsg.Content = answer
|
||||
|
||||
@@ -37,6 +37,12 @@ const (
|
||||
LangfuseTraceContextKey ContextKey = "LangfuseTrace"
|
||||
// SystemAdminContextKey is the context key indicating whether the user is a system administrator
|
||||
SystemAdminContextKey ContextKey = "SystemAdmin"
|
||||
// MCPOAuthNonInteractiveContextKey marks a request whose channel cannot
|
||||
// resolve an in-conversation MCP OAuth prompt (e.g. an IM bot: there is no
|
||||
// live client to click "Authorize" and call the resolve endpoint). When set,
|
||||
// the agent emits a one-shot authorization notice and continues instead of
|
||||
// blocking until the OAuth wait times out. See IsMCPOAuthNonInteractive.
|
||||
MCPOAuthNonInteractiveContextKey ContextKey = "MCPOAuthNonInteractive"
|
||||
)
|
||||
|
||||
// String returns the string representation of the context key
|
||||
|
||||
@@ -113,6 +113,24 @@ func SessionTenantIDFromContext(ctx context.Context) (uint64, bool) {
|
||||
return TenantIDFromContext(ctx)
|
||||
}
|
||||
|
||||
// WithMCPOAuthNonInteractive marks ctx as originating from a channel that cannot
|
||||
// complete an in-conversation MCP OAuth prompt (e.g. an IM bot). The agent uses
|
||||
// this to emit a one-shot authorization notice instead of blocking on the OAuth
|
||||
// wait until it times out. See MCPOAuthNonInteractiveContextKey.
|
||||
func WithMCPOAuthNonInteractive(ctx context.Context) context.Context {
|
||||
return context.WithValue(ctx, MCPOAuthNonInteractiveContextKey, true)
|
||||
}
|
||||
|
||||
// IsMCPOAuthNonInteractive reports whether ctx was marked non-interactive for
|
||||
// MCP OAuth (see WithMCPOAuthNonInteractive).
|
||||
func IsMCPOAuthNonInteractive(ctx context.Context) bool {
|
||||
if ctx == nil {
|
||||
return false
|
||||
}
|
||||
v, _ := ctx.Value(MCPOAuthNonInteractiveContextKey).(bool)
|
||||
return v
|
||||
}
|
||||
|
||||
// LanguageFromContext extracts the language locale string from ctx (e.g. "zh-CN", "en-US").
|
||||
// Returns ("zh-CN", false) when the key is absent.
|
||||
func LanguageFromContext(ctx context.Context) (string, bool) {
|
||||
|
||||
Reference in New Issue
Block a user