WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
@@ -0,0 +1,30 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
*.tsbuildinfo
|
||||
@@ -0,0 +1,30 @@
|
||||
# Production image. Build static assets on the host first:
|
||||
# ./scripts/build_frontend_dist.sh
|
||||
#
|
||||
# Base image pinned by digest for reproducibility. Do NOT switch back to the
|
||||
# floating `nginx:stable-alpine` tag: it drifts with every stable release and a
|
||||
# newer Alpine base (3.24+) fails to start on old hosts such as CentOS 7
|
||||
# (kernel 3.10 + old libseccomp), which broke v0.7.0. This digest is
|
||||
# nginx 1.30.3 / Alpine 3.23.5, the same base as the working v0.6.3 image.
|
||||
FROM nginx:1.30.3-alpine@sha256:0d3b80406a13a767339fbe2f41406d6c7da727ab89cf8fae399e81f780f814d1
|
||||
|
||||
COPY dist /usr/share/nginx/html
|
||||
|
||||
RUN mkdir -p /usr/share/nginx/runtime-templates \
|
||||
&& cp /usr/share/nginx/html/index.html /usr/share/nginx/runtime-templates/index.html \
|
||||
&& cp /usr/share/nginx/html/embed.html /usr/share/nginx/runtime-templates/embed.html
|
||||
|
||||
COPY nginx.conf /etc/nginx/templates/default.conf.template
|
||||
|
||||
# Copied outside templates/ on purpose: envsubst would eat $host and the other
|
||||
# nginx variables in it.
|
||||
COPY nginx-api-proxy.conf /etc/nginx/api-proxy.conf
|
||||
|
||||
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||
RUN chmod +x /docker-entrypoint.sh
|
||||
|
||||
ENV MAX_FILE_SIZE_MB=50
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||
@@ -0,0 +1,85 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { runInNewContext } from 'node:vm'
|
||||
import { loginRoutePreload } from './loginRoutePreload'
|
||||
|
||||
function generate(base = '/') {
|
||||
const chunk = (fileName: string, facadeModuleId: string | null, imports: string[], css: string[] = [], dynamicImports: string[] = []) => ({
|
||||
type: 'chunk', fileName, facadeModuleId, imports, dynamicImports,
|
||||
viteMetadata: { importedCss: new Set(css) },
|
||||
})
|
||||
const bundle = {
|
||||
'assets/main.js': chunk('assets/main.js', '/project/index.html', ['assets/shared.js'], ['assets/main.css'], ['assets/editor.js']),
|
||||
'assets/shared.js': chunk('assets/shared.js', null, [], ['assets/shared.css']),
|
||||
'assets/login.js': chunk('assets/login.js', '/project/src/views/auth/Login.vue', ['assets/main.js', 'assets/password.js'], ['assets/login.css']),
|
||||
'assets/password.js': chunk('assets/password.js', null, ['assets/login.js']),
|
||||
'assets/editor.js': chunk('assets/editor.js', '/project/src/components/editor.vue', [], ['assets/editor.css']),
|
||||
}
|
||||
const plugin = loginRoutePreload() as any
|
||||
plugin.configResolved({ base })
|
||||
return { hook: plugin.transformIndexHtml.handler, context: { path: '/index.html', filename: '/project/index.html', bundle } }
|
||||
}
|
||||
|
||||
function execute(pathname: string, base = '/', supported = true) {
|
||||
const { hook, context } = generate(base)
|
||||
const tags = hook('', context)
|
||||
const links: any[] = []
|
||||
const script = tags[0].children
|
||||
runInNewContext(script, {
|
||||
URL,
|
||||
location: { pathname, href: `https://example.test${pathname}?token=fixture#oidc_error=fixture` },
|
||||
document: {
|
||||
createElement(tag: string) {
|
||||
assert.equal(tag, 'link')
|
||||
return { relList: { supports: (rel: string) => supported && rel === 'modulepreload' }, setAttribute() {} }
|
||||
},
|
||||
head: { appendChild(link: any) { links.push(link) } },
|
||||
},
|
||||
})
|
||||
return links
|
||||
}
|
||||
|
||||
test('downloads only login-specific static resources, despite cycles and lazy imports', () => {
|
||||
const links = execute('/login')
|
||||
assert.deepEqual(links.map(link => link.href), ['/assets/login.js', '/assets/login.css', '/assets/password.js'])
|
||||
assert.deepEqual(links.map(link => [link.rel, link.as]), [['modulepreload', undefined], ['preload', 'style'], ['modulepreload', undefined]])
|
||||
assert.ok(links.every(link => link.crossOrigin === ''))
|
||||
})
|
||||
|
||||
test('login and registration paths permit query and callback hashes without route changes', () => {
|
||||
for (const path of ['/login', '/login/', '/register', '/register/']) assert.equal(execute(path).length, 3, path)
|
||||
})
|
||||
|
||||
test('authenticated, root, embed, legacy, and similar routes do not fetch login resources', () => {
|
||||
for (const path of ['/', '/platform/knowledge-bases', '/embed/fixture', '/admin', '/admin/login', '/login-extra', '/login/nested']) {
|
||||
assert.equal(execute(path).length, 0, path)
|
||||
}
|
||||
})
|
||||
|
||||
test('honors deployment path and CDN base without matching sibling paths', () => {
|
||||
assert.equal(execute('/app/login', '/app/')[0].href, '/app/assets/login.js')
|
||||
assert.equal(execute('/application/login', '/app/').length, 0)
|
||||
assert.equal(execute('/app/register', 'https://cdn.example.test/app/')[0].href, 'https://cdn.example.test/app/assets/login.js')
|
||||
})
|
||||
|
||||
test('relative-base deployments keep normal Vite loading instead of guessing asset paths', () => {
|
||||
assert.equal(execute('/login/', './').length, 0)
|
||||
assert.equal(execute('/login/', '').length, 0)
|
||||
})
|
||||
|
||||
test('unsupported modulepreload preserves stylesheet preloading without fetching scripts as plain preload', () => {
|
||||
assert.deepEqual(execute('/login', '/', false).map(link => link.href), ['/assets/login.css'])
|
||||
})
|
||||
|
||||
test('embed output remains independent and incomplete main build fails visibly', () => {
|
||||
const { hook, context } = generate()
|
||||
assert.equal(hook('', { ...context, path: '/embed.html' }), undefined)
|
||||
assert.throws(() => hook('', { ...context, bundle: {} }), /could not resolve/)
|
||||
})
|
||||
|
||||
test('preload-only script does not evaluate app code, touch storage, or propagate a DOM failure', () => {
|
||||
const { hook, context } = generate()
|
||||
const script = hook('', context)[0].children
|
||||
assert.doesNotMatch(script, /\b(?:import|fetch|localStorage|sessionStorage)\b/)
|
||||
assert.doesNotThrow(() => runInNewContext(script, { URL, location: { pathname: '/login', href: 'https://example.test/login' }, document: { createElement() { throw new Error('unavailable') } } }))
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { OutputBundle, OutputChunk } from 'rollup'
|
||||
import type { Plugin, ResolvedConfig } from 'vite'
|
||||
|
||||
type StyleChunk = OutputChunk & { viteMetadata?: { importedCss: Set<string> } }
|
||||
|
||||
function staticAssets(bundle: OutputBundle, entry: OutputChunk): Set<string> {
|
||||
const assets = new Set<string>()
|
||||
const visit = (chunk: StyleChunk) => {
|
||||
if (assets.has(chunk.fileName)) return
|
||||
assets.add(chunk.fileName)
|
||||
for (const css of chunk.viteMetadata?.importedCss ?? []) assets.add(css)
|
||||
for (const file of chunk.imports) {
|
||||
const dependency = bundle[file]
|
||||
if (dependency?.type === 'chunk') visit(dependency)
|
||||
}
|
||||
}
|
||||
visit(entry)
|
||||
return assets
|
||||
}
|
||||
|
||||
// Download route resources without evaluating the module or applying its CSS.
|
||||
export function loginRoutePreload(): Plugin {
|
||||
let config: ResolvedConfig
|
||||
return {
|
||||
name: 'login-route-preload',
|
||||
apply: 'build',
|
||||
configResolved(resolved) { config = resolved },
|
||||
transformIndexHtml: {
|
||||
order: 'post',
|
||||
handler(_html, context) {
|
||||
if (context.path !== '/index.html' || !context.bundle) return
|
||||
const bundle = context.bundle
|
||||
const chunks = Object.values(bundle).filter((item): item is OutputChunk => item.type === 'chunk')
|
||||
const main = chunks.find(chunk => chunk.facadeModuleId === context.filename)
|
||||
const login = chunks.find(chunk => chunk.facadeModuleId?.replaceAll('\\', '/').endsWith('/src/views/auth/Login.vue'))
|
||||
if (!main || !login) throw new Error('Login route preload could not resolve the main and login chunks')
|
||||
const shared = staticAssets(bundle, main)
|
||||
const files = [...staticAssets(bundle, login)].filter(file => !shared.has(file))
|
||||
const json = (value: unknown) => JSON.stringify(value).replaceAll('<', '\\u003c')
|
||||
return [{
|
||||
tag: 'script',
|
||||
attrs: { 'data-login-route-preload': '' },
|
||||
injectTo: 'head-prepend',
|
||||
children: `(function(){try{
|
||||
var base=${json(config.base)},path=location.pathname;
|
||||
if(base==='./'||base==='')return;
|
||||
var prefix=new URL(base,location.href).pathname;
|
||||
if(path.indexOf(prefix)!==0)return;
|
||||
path=path.slice(prefix.length);
|
||||
if(path!=='login'&&path!=='login/'&&path!=='register'&&path!=='register/')return;
|
||||
${json(files)}.forEach(function(file){
|
||||
var link=document.createElement('link'),css=file.endsWith('.css');
|
||||
if(!css&&(!link.relList||!link.relList.supports('modulepreload')))return;
|
||||
link.rel=css?'preload':'modulepreload';
|
||||
if(css)link.as='style';
|
||||
link.crossOrigin='';link.href=base+file;
|
||||
link.setAttribute('data-login-route-preload','');
|
||||
document.head.appendChild(link);
|
||||
});
|
||||
}catch(_){}})();`,
|
||||
}]
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
# Only emit whitelisted locale tags to avoid config.js injection from env values.
|
||||
RUNTIME_DEFAULT_LOCALE=""
|
||||
case "${DEFAULT_LOCALE:-}" in
|
||||
zh-CN|en-US|ru-RU|ko-KR) RUNTIME_DEFAULT_LOCALE="${DEFAULT_LOCALE}" ;;
|
||||
esac
|
||||
|
||||
# 生成运行时配置文件,注入环境变量到前端
|
||||
FILE_MB=${MAX_FILE_SIZE_MB:-50}
|
||||
# Keep the browser and nginx in lockstep with the Go application ceiling.
|
||||
# Values above 20 GiB are rejected by the backend and must not produce a
|
||||
# misleadingly larger client-side limit or an oversized nginx request body.
|
||||
case "$FILE_MB" in
|
||||
''|*[!0-9]*) FILE_MB=50 ;;
|
||||
esac
|
||||
if [ "$FILE_MB" -gt 20480 ]; then
|
||||
FILE_MB=20480
|
||||
fi
|
||||
if [ "$FILE_MB" -le 0 ]; then
|
||||
FILE_MB=50
|
||||
fi
|
||||
SKILL_MB=${MAX_SKILL_BUNDLE_SIZE_MB:-256}
|
||||
case "$SKILL_MB" in
|
||||
''|*[!0-9]*) SKILL_MB=256 ;;
|
||||
esac
|
||||
if [ "$SKILL_MB" -lt "$FILE_MB" ] 2>/dev/null; then
|
||||
SKILL_MB=$FILE_MB
|
||||
fi
|
||||
if [ "$SKILL_MB" -gt 512 ] 2>/dev/null; then
|
||||
SKILL_MB=512
|
||||
fi
|
||||
|
||||
cat > /usr/share/nginx/html/config.js << EOF
|
||||
window.__RUNTIME_CONFIG__ = {
|
||||
MAX_FILE_SIZE_MB: ${FILE_MB},
|
||||
MAX_SKILL_BUNDLE_SIZE_MB: ${SKILL_MB},
|
||||
DEFAULT_LOCALE: "${RUNTIME_DEFAULT_LOCALE}"
|
||||
};
|
||||
EOF
|
||||
|
||||
# Rebuild from immutable templates on every start, including restarts with a
|
||||
# changed locale. Inlining removes a blocking request before the app can boot.
|
||||
for PAGE in index.html embed.html; do
|
||||
awk '
|
||||
NR == FNR { config = config $0 "\n"; next }
|
||||
/<script src="\/config.js"><\/script>/ {
|
||||
if (++replaced != 1) exit 1
|
||||
print "<script data-runtime-config>\n" config "</script>"
|
||||
next
|
||||
}
|
||||
{ print }
|
||||
END { if (replaced != 1) exit 1 }
|
||||
' /usr/share/nginx/html/config.js \
|
||||
"/usr/share/nginx/runtime-templates/$PAGE" > "/usr/share/nginx/html/$PAGE.next"
|
||||
mv "/usr/share/nginx/html/$PAGE.next" "/usr/share/nginx/html/$PAGE"
|
||||
done
|
||||
|
||||
# 处理 nginx 配置。
|
||||
# 两个上限分开注入:全站保持知识库的 MAX_FILE_SIZE,只有技能 zip 上传的两条
|
||||
# 集合路由放宽到 MAX_SKILL_BUNDLE_SIZE(不含 /install、PATCH 等子路径)。
|
||||
# 合成一个全站上限会让每个上传端点都能收到技能包那么大的 body。
|
||||
export MAX_FILE_SIZE=${FILE_MB}M
|
||||
export MAX_SKILL_BUNDLE_SIZE=${SKILL_MB}M
|
||||
export APP_HOST=${APP_HOST:-app}
|
||||
export APP_PORT=${APP_PORT:-8080}
|
||||
export APP_SCHEME=${APP_SCHEME:-http}
|
||||
envsubst '${MAX_FILE_SIZE} ${MAX_SKILL_BUNDLE_SIZE} ${APP_HOST} ${APP_PORT} ${APP_SCHEME}' \
|
||||
< /etc/nginx/templates/default.conf.template > /etc/nginx/conf.d/default.conf
|
||||
|
||||
# 启动 nginx
|
||||
exec nginx -g 'daemon off;'
|
||||
@@ -0,0 +1,27 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>WeKnora Embed</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, minimal-ui">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
|
||||
<meta name="renderer" content="webkit">
|
||||
<link rel="shortcut icon" type="image/png" href="./public/favicon.ico"/>
|
||||
<style>
|
||||
html, body, #embed-app { height: 100%; margin: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<script>
|
||||
(function(){
|
||||
document.documentElement.setAttribute('theme-mode', 'light');
|
||||
document.documentElement.style.background = '#fff';
|
||||
document.body.style.background = '#fff';
|
||||
})();
|
||||
</script>
|
||||
<script src="/config.js"></script>
|
||||
<script defer src="/tdesign-icons/0.4.1/fonts/index.js"></script>
|
||||
<div id="embed-app"></div>
|
||||
<script type="module" src="/src/embed-main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
// 配置这个文件是 解决错误:找不到模块“@/views/login/index.vue”或其相应的类型声明。ts(2307)
|
||||
// 这段代码告诉 TypeScript,所有以 .vue 结尾的文件都是 Vue 组件,可以通过 import 语句进行导入。这样做通常可以解决无法识别模块的问题。
|
||||
declare module '*.vue' {
|
||||
import { Component } from 'vue'; const component: Component; export default component;
|
||||
}
|
||||
|
||||
declare const __FRONTEND_VERSION__: string;
|
||||
declare const __FRONTEND_COMMIT__: string;
|
||||
@@ -0,0 +1,78 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<title>私域Agent</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, minimal-ui">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
|
||||
<meta name="renderer" content="webkit">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||
<meta name="keywords" content="知识问答、微信对话开放平台、对话开放平台、对话平台、人工智能定制、人机对话、智能问答、人机交互、自然语言处理、自然语言理解、NLP、人工智能产品、人工智能开源、人工智能算法、语音助手"/>
|
||||
<meta name="description" content="私域Agent 内容工作台"/>
|
||||
<link rel="shortcut icon" type="image/png" href="./public/favicon.ico"/>
|
||||
<link rel="apple-touch-icon" sizes="120x120" type="image/png" href="./public/favicon.ico"/>
|
||||
</head>
|
||||
<body>
|
||||
<script>
|
||||
(function(){
|
||||
var t=localStorage.getItem('WeKnora_theme')||'light';
|
||||
if(t==='system')t=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';
|
||||
document.documentElement.setAttribute('theme-mode',t);
|
||||
/* 与 theme.css 中 --td-bg-color-page 一致,避免样式加载后整页背景从 #fff 跳到 #eee 触发重绘闪动 */
|
||||
var bg=t==='dark'?'#181818':'#eee';
|
||||
document.documentElement.style.background=bg;
|
||||
document.documentElement.style.minHeight='100%';
|
||||
document.documentElement.style.colorScheme=t==='dark'?'dark':'light';
|
||||
document.body.style.background=bg;
|
||||
document.body.style.minHeight='100%';
|
||||
try{
|
||||
var w=window.runtime;
|
||||
if(w){
|
||||
if(t==='dark'){
|
||||
if(w.WindowSetDarkTheme)w.WindowSetDarkTheme();
|
||||
if(w.WindowSetBackgroundColour)w.WindowSetBackgroundColour(24,24,24,255);
|
||||
}else{
|
||||
if(w.WindowSetLightTheme)w.WindowSetLightTheme();
|
||||
if(w.WindowSetBackgroundColour)w.WindowSetBackgroundColour(238,238,238,255);
|
||||
}
|
||||
}
|
||||
}catch(_){}
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
(function(){
|
||||
try {
|
||||
if (localStorage.getItem('weknora_lite_mode') !== 'true') return;
|
||||
var saved = sessionStorage.getItem('weknora_lite_last_path');
|
||||
if (!saved || saved.indexOf('/platform/') !== 0 || saved.indexOf('/platform/organizations') === 0) return;
|
||||
var path = window.location.pathname;
|
||||
if (path !== '/' && path !== '/platform' && path !== '/platform/knowledge-bases') return;
|
||||
var target = saved.indexOf('#') >= 0 ? saved.slice(0, saved.indexOf('#')) : saved;
|
||||
if (!target) return;
|
||||
var current = window.location.pathname + window.location.search;
|
||||
if (target !== current) window.history.replaceState(window.history.state, '', target);
|
||||
} catch (e) {}
|
||||
})();
|
||||
</script>
|
||||
<script src="/config.js"></script>
|
||||
<!--
|
||||
离线部署兼容:提前本地加载 tdesign-icons SVG sprite,避免 tdesign-vue-next 的
|
||||
<t-icon> 运行时向 https://tdesign.gtimg.com 发起请求(见 issue #867 / #897)。
|
||||
升级 tdesign-icons-vue-next 时需同步更新 public/tdesign-icons/<version>/fonts/index.js。
|
||||
-->
|
||||
<script defer src="/tdesign-icons/0.4.1/fonts/index.js"></script>
|
||||
<div id="app"></div>
|
||||
<script>
|
||||
(function(){
|
||||
var t=localStorage.getItem('WeKnora_theme')||'light';
|
||||
if(t==='system')t=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';
|
||||
var bg=t==='dark'?'#181818':'#eee';
|
||||
var el=document.getElementById('app');
|
||||
if(el){ el.style.background=bg; el.style.minHeight='100%'; }
|
||||
})();
|
||||
</script>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,34 @@
|
||||
# Shared backend proxy settings for the /api/ locations.
|
||||
#
|
||||
# Two locations proxy to the backend — the general one and the skill-upload one
|
||||
# that raises client_max_body_size — and they must behave identically in every
|
||||
# other respect, so the settings live here instead of being copied. Only
|
||||
# proxy_pass stays in each location, since a regex location cannot carry a URI
|
||||
# in it.
|
||||
#
|
||||
# This file is copied verbatim, not run through envsubst, so $host and friends
|
||||
# survive as nginx variables.
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $original_scheme;
|
||||
add_header Cache-Control "no-store" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
# 连接和重试配置
|
||||
proxy_connect_timeout 30s; # 连接超时时间
|
||||
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503 http_504;
|
||||
proxy_next_upstream_tries 3; # 重试次数
|
||||
proxy_next_upstream_timeout 30s; # 重试超时时间
|
||||
|
||||
# SSE 相关配置
|
||||
proxy_http_version 1.1; # 使用 HTTP/1.1
|
||||
proxy_set_header Connection ""; # 禁用 Connection: close,保持连接打开
|
||||
chunked_transfer_encoding off; # 关闭分块传输编码
|
||||
proxy_buffering off; # 关闭缓冲
|
||||
proxy_cache off; # 关闭缓存
|
||||
proxy_read_timeout 3600s; # 增加读取超时时间
|
||||
proxy_send_timeout 3600s; # 增加发送超时时间
|
||||
@@ -0,0 +1,233 @@
|
||||
# Optional dedicated embed origin — uncomment and set server_name (e.g. embed.example.com).
|
||||
# Serve only embed.html + static assets; proxy /api to the WeKnora backend.
|
||||
# See docs/embed-subdomain.md
|
||||
#
|
||||
# server {
|
||||
# listen 80;
|
||||
# server_name embed.example.com;
|
||||
# client_max_body_size ${MAX_FILE_SIZE};
|
||||
# location = /weknora-widget.js { root /usr/share/nginx/html; }
|
||||
# location ^~ /embed/ { root /usr/share/nginx/html; try_files $uri $uri/ /embed.html; }
|
||||
# location ^~ /assets/ { root /usr/share/nginx/html; add_header Cache-Control "public, max-age=31536000, immutable"; }
|
||||
# location /api/ { proxy_pass http://weknora-backend:8080; proxy_set_header Host $host; }
|
||||
# }
|
||||
|
||||
map $http_x_forwarded_proto $original_scheme {
|
||||
default $scheme;
|
||||
https https;
|
||||
http http;
|
||||
}
|
||||
|
||||
# Classify the original request before SPA rewrites without logging its URL.
|
||||
map $request_uri $ingress_route_class {
|
||||
default other;
|
||||
~^/api/v1/auth/me(?:\?|$) auth_me;
|
||||
~^/api/v1/access/me(?:\?|$) access_me;
|
||||
~^/api/v1/system/capabilities(?:\?|$) capabilities;
|
||||
~^/api/v1/workbench/resources/[^/?]+(?:\?|$) resource_list;
|
||||
~^/api/v1/workbench/resources/[^/?]+/[^/?]+(?:\?|$) resource_detail;
|
||||
~^/api(?:/|\?|$) api;
|
||||
~^/assets(?:/|\?|$) asset;
|
||||
~^/tdesign-icons(?:/|\?|$) icon;
|
||||
~^/embed(?:/|\?|$) embed;
|
||||
~^/r(?:/|\?|$) resource_link;
|
||||
~^/files(?:/|\?|$) file;
|
||||
~^/config\.js(?:\?|$) config;
|
||||
~^/release\.json(?:\?|$) release;
|
||||
~^/weknora-widget\.js(?:\?|$) widget;
|
||||
~^/(?:$|\?) shell;
|
||||
~^/(?:index\.html|login|register|admin|platform)(?:/|\?|$) shell;
|
||||
}
|
||||
|
||||
# Local clients can supply this header too; retain only a bounded ray ID.
|
||||
map $http_cf_ray $ingress_cf_ray {
|
||||
default "-";
|
||||
"~^[0-9a-fA-F]{16,32}-[A-Z]{3}$" $http_cf_ray;
|
||||
}
|
||||
|
||||
map $upstream_connect_time $ingress_upstream_connect_time {
|
||||
default $upstream_connect_time;
|
||||
"" "-";
|
||||
}
|
||||
map $upstream_header_time $ingress_upstream_header_time {
|
||||
default $upstream_header_time;
|
||||
"" "-";
|
||||
}
|
||||
map $upstream_response_time $ingress_upstream_response_time {
|
||||
default $upstream_response_time;
|
||||
"" "-";
|
||||
}
|
||||
|
||||
log_format ingress_timing escape=json
|
||||
'{"time":"$time_iso8601","route":"$ingress_route_class","status":$status,'
|
||||
'"request_time":"$request_time","upstream_connect_time":"$ingress_upstream_connect_time",'
|
||||
'"upstream_header_time":"$ingress_upstream_header_time","upstream_response_time":"$ingress_upstream_response_time",'
|
||||
'"cf_ray":"$ingress_cf_ray"}';
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||
set $app_backend ${APP_SCHEME}://${APP_HOST}:${APP_PORT};
|
||||
# Default 50M, configured via MAX_FILE_SIZE_MB env var
|
||||
client_max_body_size ${MAX_FILE_SIZE};
|
||||
|
||||
# 启用 gzip 压缩静态资源 (前端 index.js 单文件 ~1MB, 不压实测 20s+,
|
||||
# 压缩后约 200-300KB, 大陆同地域低带宽机器加载从 25s 降到 3-5s)
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_comp_level 6;
|
||||
gzip_proxied any;
|
||||
gzip_types text/plain text/css text/javascript application/javascript application/json
|
||||
application/xml application/rss+xml image/svg+xml font/ttf font/otf;
|
||||
|
||||
# 安全头配置
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
# 错误日志配置
|
||||
error_log /var/log/nginx/error.log warn;
|
||||
access_log /var/log/nginx/access.log ingress_timing;
|
||||
|
||||
# Static widget loader for third-party sites
|
||||
location = /weknora-widget.js {
|
||||
root /usr/share/nginx/html;
|
||||
add_header Cache-Control "public, max-age=3600" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
}
|
||||
|
||||
# Internal fallback target for /embed/* routes. Keep it separate from the
|
||||
# main SPA location so embed pages do not inherit X-Frame-Options.
|
||||
location = /embed.html {
|
||||
internal;
|
||||
root /usr/share/nginx/html;
|
||||
add_header Cache-Control "no-cache, must-revalidate" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
}
|
||||
|
||||
# Embed widget pages may be loaded inside third-party iframes (lightweight embed.html)
|
||||
location ^~ /embed/ {
|
||||
root /usr/share/nginx/html;
|
||||
try_files $uri $uri/ /embed.html;
|
||||
add_header Cache-Control "no-cache, must-revalidate" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
}
|
||||
|
||||
# 前端静态文件
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
try_files $uri $uri/ /index.html;
|
||||
|
||||
# index.html 及 SPA fallback 不可缓存,否则前端升级后用户看不到新版本
|
||||
add_header Cache-Control "no-cache, must-revalidate" always;
|
||||
# nginx add_header 不从上层继承,需要在 location 内重复声明安全头
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
}
|
||||
|
||||
# Runtime values can change between deployments without changing this URL.
|
||||
location = /config.js {
|
||||
root /usr/share/nginx/html;
|
||||
try_files $uri =404;
|
||||
add_header Cache-Control "no-store" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
}
|
||||
|
||||
# Change the versioned directory when updating the bundled icon assets.
|
||||
location ~ ^/tdesign-icons/[0-9]+\.[0-9]+\.[0-9]+/ {
|
||||
root /usr/share/nginx/html;
|
||||
try_files $uri =404;
|
||||
error_page 404 = @missing_asset;
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
}
|
||||
|
||||
# Vite 构建产物 /assets/* 带 hash 文件名,可长期缓存
|
||||
location ^~ /assets/ {
|
||||
root /usr/share/nginx/html;
|
||||
try_files $uri =404;
|
||||
error_page 404 = @missing_asset;
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
}
|
||||
|
||||
location @missing_asset {
|
||||
add_header Cache-Control "no-store" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
return 404;
|
||||
}
|
||||
|
||||
# 本地存储文件代理到后端服务(用于渲染 markdown 中的图片)
|
||||
# 精确匹配 /files,避免 Nginx 自动补 / 触发 301
|
||||
location = /files {
|
||||
proxy_pass $app_backend;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $original_scheme;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
add_header Cache-Control "no-store" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
}
|
||||
|
||||
# 资源能力短链 /r/<token>:IM 渠道渲染 resource:// 图片依赖此路由,
|
||||
# 缺此段会落进 SPA fallback 返回空白页,图片加载不出来。
|
||||
location ^~ /r/ {
|
||||
proxy_pass $app_backend;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $original_scheme;
|
||||
proxy_cache off;
|
||||
add_header Cache-Control "no-store" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
}
|
||||
|
||||
# 技能 zip 上传的两条集合路由,body 上限放宽到 MAX_SKILL_BUNDLE_SIZE。
|
||||
# 单独成一个 location 而不是抬高全站上限:client_max_body_size 是按
|
||||
# location 生效的,写在 server 级会让知识库上传等端点也能收到技能包大小的
|
||||
# body。这里放在 /api/ 之前,正则 location 优先于前缀 location 命中。
|
||||
# 只匹配集合本身(POST 上传 / GET 列表),不带 /install、PATCH 等子路径:
|
||||
# 那些是小 JSON,必须继续走下面 /api/ 的 MAX_FILE_SIZE。
|
||||
location ~ ^/api/v1/(?:skills/catalog|sandbox-configs/[^/]+/skills)/?$ {
|
||||
client_max_body_size ${MAX_SKILL_BUNDLE_SIZE};
|
||||
|
||||
# 正则 location 的 proxy_pass 不能带 URI,原始路径原样透传,
|
||||
# 与 /api/ 那条 `proxy_pass .../api/` 的改写结果一致。
|
||||
proxy_pass $app_backend;
|
||||
include /etc/nginx/api-proxy.conf;
|
||||
}
|
||||
|
||||
# API请求代理到后端服务
|
||||
# APP_SCHEME 默认 http,远程 HTTPS 后端可设为 https
|
||||
location /api/ {
|
||||
proxy_pass $app_backend;
|
||||
include /etc/nginx/api-proxy.conf;
|
||||
}
|
||||
|
||||
# 错误页面
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
location = /50x.html {
|
||||
root /usr/share/nginx/html;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"name": "knowledage-base",
|
||||
"version": "0.8.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"build-with-types": "run-p type-check \"build-only {@}\" --",
|
||||
"preview": "vite preview",
|
||||
"build-only": "vite build",
|
||||
"type-check": "vue-tsc --build",
|
||||
"test": "tsx --test",
|
||||
"check-i18n": "tsx --test src/i18n/localeKeyAudit.test.ts",
|
||||
"scan-i18n-gaps": "tsx src/i18n/localeGapScan.ts",
|
||||
"regenerate-i18n-locales": "tsx src/i18n/regeneratePrunedLocales.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@microsoft/fetch-event-source": "^2.0.1",
|
||||
"@types/dompurify": "^3.2.0",
|
||||
"@types/papaparse": "^5.5.2",
|
||||
"@vue-office/pptx": "^1.0.1",
|
||||
"axios": "^1.16.0",
|
||||
"docx-preview": "^0.3.7",
|
||||
"dompurify": "^3.4.11",
|
||||
"highlight.js": "^11.11.1",
|
||||
"katex": "^0.16.45",
|
||||
"marked": "^17.0.5",
|
||||
"marked-katex-extension": "^5.1.8",
|
||||
"mermaid": "^11.15.0",
|
||||
"pagefind": "^1.5.2",
|
||||
"papaparse": "^5.5.3",
|
||||
"pinia": "^3.0.4",
|
||||
"swiper": "^12.1.4",
|
||||
"tdesign-icons-vue-next": "0.4.4",
|
||||
"tdesign-vue-next": "^1.19.2",
|
||||
"vue": "^3.5.34",
|
||||
"vue-demi": "^0.14.10",
|
||||
"vue-i18n": "^11.4.2",
|
||||
"vue-router": "^4.5.0",
|
||||
"vue-virtual-scroller": "^2.0.0-beta.8",
|
||||
"webpack": "^5.106.2",
|
||||
"xlsx": "file:./packages/xlsx-0.20.2.tgz"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/node22": "^22.0.5",
|
||||
"@types/node": "^22.14.0",
|
||||
"@vitejs/plugin-vue": "6.0.6",
|
||||
"@vitejs/plugin-vue-jsx": "5.1.5",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"less": "^4.6.4",
|
||||
"less-loader": "^12.3.2",
|
||||
"npm-run-all2": "^8.0.4",
|
||||
"tsx": "^4.23.1",
|
||||
"typescript": "~6.0.3",
|
||||
"vite": "^7.3.5",
|
||||
"vue-tsc": "^3.2.8"
|
||||
},
|
||||
"overrides": {
|
||||
"lightningcss": "none",
|
||||
"esbuild": "^0.25.0",
|
||||
"serialize-javascript": "^7.0.3",
|
||||
"tdesign-icons-vue-next": "0.4.4"
|
||||
},
|
||||
"resolutions": {
|
||||
"lightningcss": "none",
|
||||
"esbuild": "^0.25.0",
|
||||
"serialize-javascript": "^7.0.3",
|
||||
"tdesign-icons-vue-next": "0.4.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
a8c281c741c2499a02564295ccfb3f91
|
||||
@@ -0,0 +1,4 @@
|
||||
allowBuilds:
|
||||
"@vue-office/pptx": true
|
||||
esbuild: true
|
||||
vue-demi: true
|
||||
@@ -0,0 +1,9 @@
|
||||
// 运行时配置(本地开发默认值,Docker 环境会被 entrypoint 脚本覆盖)
|
||||
window.__RUNTIME_CONFIG__ = {
|
||||
MAX_FILE_SIZE_MB: 50,
|
||||
MAX_SKILL_BUNDLE_SIZE_MB: 256,
|
||||
// Optional: serve embed on a dedicated origin, e.g. 'https://embed.example.com'
|
||||
EMBED_BASE_URL: '',
|
||||
// Optional: default UI locale for first-time visitors (zh-CN | en-US | ru-RU | ko-KR)
|
||||
DEFAULT_LOCALE: '',
|
||||
};
|
||||
|
After Width: | Height: | Size: 5.4 KiB |
@@ -0,0 +1,276 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { compileTemplate, parse } from '@vue/compiler-sfc'
|
||||
import { transformSync } from 'esbuild'
|
||||
import ts from 'typescript'
|
||||
import * as Vue from 'vue'
|
||||
|
||||
function source(name: string) {
|
||||
const { descriptor } = parse(readFileSync(new URL(name, import.meta.url), 'utf8'))
|
||||
const ast = ts.createSourceFile(name + '.ts', descriptor.scriptSetup!.content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS)
|
||||
return { descriptor, ast }
|
||||
}
|
||||
|
||||
function evaluate(script: string, context: Record<string, any>, names: string[]) {
|
||||
const code = transformSync(script, { loader: 'ts', target: 'es2022' }).code
|
||||
const keys = Object.keys(context).filter(key => /^[a-zA-Z_$][\w$]*$/.test(key) && key !== 'default')
|
||||
return new Function(...keys, `${code}; return { ${names.join(',')} };`)(...keys.map(key => context[key]))
|
||||
}
|
||||
|
||||
const appSource = source('./App.vue')
|
||||
const hostSource = source('./components/ManualKnowledgeEditorHost.vue')
|
||||
const editorSource = source('./components/manual-knowledge-editor.vue')
|
||||
|
||||
function harness(initiallyOpen = false) {
|
||||
const uiStore = Vue.reactive({
|
||||
manualEditorVisible: initiallyOpen,
|
||||
manualEditorMode: 'create',
|
||||
closeManualEditor() { uiStore.manualEditorVisible = false },
|
||||
})
|
||||
const calls: string[] = []
|
||||
let resolveEditor!: (component: any) => void
|
||||
let rejectEditor!: (error: Error) => void
|
||||
const scope = Vue.effectScope()
|
||||
const declarations = hostSource.ast.statements.filter(node => !ts.isImportDeclaration(node)).map(node => node.getText(hostSource.ast)).join('\n')
|
||||
const script = declarations.replace(/import\((['"])[^'"]+\1\)/g, 'loadEditor()')
|
||||
const state = scope.run(() => evaluate(script, {
|
||||
...Vue, useUIStore: () => uiStore, useI18n: () => ({ t: (key: string) => key }),
|
||||
offerModuleReload: () => { calls.push('offer-reload') },
|
||||
loadEditor: () => {
|
||||
calls.push('load')
|
||||
return new Promise<any>((resolve, reject) => { resolveEditor = resolve; rejectEditor = reject })
|
||||
},
|
||||
}, ['ManualKnowledgeEditor', 'uiStore', 'loading', 'loadFailed', 'loadManualEditor', 'offerModuleReload', 't']))!
|
||||
|
||||
const compiled = compileTemplate({ source: hostSource.descriptor.template!.content, filename: 'ManualKnowledgeEditorHost.vue', id: 'app-lazy-editor', transformAssetUrls: false })
|
||||
assert.deepEqual(compiled.errors, [])
|
||||
const renderAST = ts.createSourceFile('render.ts', compiled.code, ts.ScriptTarget.Latest, true)
|
||||
const runtime: Record<string, any> = {}
|
||||
for (const node of renderAST.statements) {
|
||||
if (ts.isImportDeclaration(node) && node.importClause?.namedBindings && ts.isNamedImports(node.importClause.namedBindings)) {
|
||||
for (const binding of node.importClause.namedBindings.elements) runtime[binding.name.text] = (Vue as any)[binding.propertyName?.text || binding.name.text]
|
||||
}
|
||||
}
|
||||
const renderCode = renderAST.statements.filter(node => !ts.isImportDeclaration(node)).map(node => node.getText(renderAST)).join('\n').replace('export function render', 'function render')
|
||||
const { render } = evaluate(renderCode, runtime, ['render'])
|
||||
const host = (type = 'root') => ({ type, parent: null as any, children: [] as any[], props: {} as Record<string, any> })
|
||||
const renderer = Vue.createRenderer<any, any>({
|
||||
patchProp: (el, key, _old, value) => { el.props[key] = value },
|
||||
insert: (el, parent, anchor) => { el.parent = parent; const index = anchor ? parent.children.indexOf(anchor) : -1; index < 0 ? parent.children.push(el) : parent.children.splice(index, 0, el) },
|
||||
remove: el => { if (el.parent) el.parent.children.splice(el.parent.children.indexOf(el), 1) },
|
||||
createElement: host, createText: text => ({ ...host('text'), text }), createComment: text => ({ ...host('comment'), text }),
|
||||
setText: (el, text) => { el.text = text }, setElementText: (el, text) => { el.text = text }, parentNode: el => el.parent,
|
||||
nextSibling: el => el.parent?.children[el.parent.children.indexOf(el) + 1],
|
||||
insertStaticContent: (text, parent) => { const el = { ...host('static'), text, parent }; parent.children.push(el); return [el, el] },
|
||||
})
|
||||
const app = renderer.createApp({ setup: () => state, render })
|
||||
for (const [name, tag] of [['TDialog', 'dialog'], ['TAlert', 'alert'], ['TLoading', 'loading']]) {
|
||||
app.component(name!, { setup: (_props: any, { slots }: any) => () => Vue.h(tag!, slots.default?.()) })
|
||||
}
|
||||
const root = host()
|
||||
app.mount(root)
|
||||
|
||||
function find(type: string, node: any = root): any {
|
||||
if (node.type === type) return node
|
||||
for (const child of node.children) { const result = find(type, child); if (result) return result }
|
||||
return null
|
||||
}
|
||||
|
||||
function finishLoading() {
|
||||
const watchNode = editorSource.ast.statements.find(node => ts.isExpressionStatement(node)
|
||||
&& ts.isCallExpression(node.expression) && node.expression.expression.getText(editorSource.ast) === 'watch'
|
||||
&& node.expression.arguments[0]?.getText(editorSource.ast) === 'visible')
|
||||
assert.ok(watchNode)
|
||||
resolveEditor({ default: {
|
||||
setup() {
|
||||
calls.push('mount')
|
||||
const visible = Vue.computed(() => uiStore.manualEditorVisible)
|
||||
evaluate(watchNode.getText(editorSource.ast), {
|
||||
watch: Vue.watch, nextTick: Vue.nextTick, visible, form: { content: 'Existing draft' },
|
||||
initialize: async () => { calls.push('initialize') },
|
||||
attachTextareaListeners: () => calls.push('attach'),
|
||||
detachTextareaListeners: () => calls.push('detach'),
|
||||
resetForm: () => calls.push('reset'),
|
||||
setSelectionRange: (start: number, end: number) => calls.push(`selection:${start}:${end}`),
|
||||
}, [])
|
||||
Vue.onUnmounted(() => calls.push('unmount'))
|
||||
return () => Vue.h('editor', { visible: visible.value })
|
||||
},
|
||||
} })
|
||||
}
|
||||
return {
|
||||
uiStore, calls, find, finishLoading, state,
|
||||
failLoading: () => rejectEditor(new Error('Failed to fetch dynamically imported module')),
|
||||
handledPreloadFailure: () => resolveEditor(undefined),
|
||||
stop: () => { app.unmount(); scope.stop() },
|
||||
}
|
||||
}
|
||||
|
||||
async function flush() { for (let i = 0; i < 12; i++) await Vue.nextTick() }
|
||||
|
||||
test('startup does not import or mount the unopened manual editor', async () => {
|
||||
const staticImports = appSource.ast.statements.filter(ts.isImportDeclaration)
|
||||
assert.ok(!staticImports.some(node => node.moduleSpecifier.getText().includes('manual-knowledge-editor')))
|
||||
assert.ok(appSource.descriptor.template!.content.includes('<ManualKnowledgeEditorHost'))
|
||||
assert.ok(!hostSource.ast.statements.filter(ts.isImportDeclaration).some(node => node.moduleSpecifier.getText().includes('manual-knowledge-editor')))
|
||||
const h = harness()
|
||||
await flush()
|
||||
assert.deepEqual(h.calls, [])
|
||||
assert.equal(h.find('dialog'), null)
|
||||
h.stop()
|
||||
})
|
||||
|
||||
test('first async open initializes the current draft and close/reopen retains the existing lifecycle', async () => {
|
||||
const h = harness()
|
||||
h.uiStore.manualEditorVisible = true
|
||||
await flush()
|
||||
assert.deepEqual(h.calls, ['load'])
|
||||
assert.equal(h.find('dialog').props.visible, true)
|
||||
assert.equal(h.find('loading').props.text, 'manualEditor.loading.preparing')
|
||||
assert.equal(h.find('dialog').props['confirm-btn'], null)
|
||||
h.finishLoading()
|
||||
await flush()
|
||||
assert.deepEqual(h.calls, ['load', 'mount', 'initialize', 'attach', 'selection:14:14'])
|
||||
assert.equal(h.find('dialog'), null)
|
||||
h.uiStore.closeManualEditor()
|
||||
await flush()
|
||||
assert.equal(h.find('dialog'), null)
|
||||
assert.deepEqual(h.calls.slice(-2), ['detach', 'reset'])
|
||||
assert.equal(h.calls.includes('unmount'), false)
|
||||
h.uiStore.manualEditorVisible = true
|
||||
await flush()
|
||||
assert.equal(h.calls.filter(call => call === 'load').length, 1)
|
||||
assert.equal(h.calls.filter(call => call === 'mount').length, 1)
|
||||
assert.equal(h.calls.filter(call => call === 'initialize').length, 2)
|
||||
h.stop()
|
||||
})
|
||||
|
||||
test('failed import offers explicit reload consent without retrying the rejected module', async () => {
|
||||
const h = harness(true)
|
||||
await flush()
|
||||
h.failLoading()
|
||||
await flush()
|
||||
assert.equal(h.find('loading'), null)
|
||||
assert.equal(h.find('dialog').props['confirm-btn'], '重新加载页面')
|
||||
assert.ok(h.find('alert').props.message.includes('加载失败'))
|
||||
h.find('dialog').props.onConfirm()
|
||||
await flush()
|
||||
assert.ok(h.find('alert'))
|
||||
assert.equal(h.find('loading'), null)
|
||||
assert.equal(h.calls.filter(call => call === 'load').length, 1)
|
||||
assert.equal(h.calls.filter(call => call === 'offer-reload').length, 1)
|
||||
h.stop()
|
||||
})
|
||||
|
||||
test('a preload error handled by global recovery still clears the local spinner and permits reopening', async () => {
|
||||
const h = harness(true)
|
||||
await flush()
|
||||
h.handledPreloadFailure()
|
||||
await flush()
|
||||
assert.equal(h.find('loading'), null)
|
||||
assert.ok(h.find('alert'))
|
||||
h.find('dialog').props.onClose()
|
||||
await flush()
|
||||
assert.equal(h.find('dialog'), null)
|
||||
h.uiStore.manualEditorVisible = true
|
||||
await flush()
|
||||
assert.equal(h.calls.filter(call => call === 'load').length, 1)
|
||||
assert.equal(h.find('dialog').props['confirm-btn'], '重新加载页面')
|
||||
assert.ok(h.find('alert'))
|
||||
h.stop()
|
||||
})
|
||||
|
||||
test('reopening while download is pending restores feedback and shares the original request', async () => {
|
||||
const h = harness(true)
|
||||
await flush()
|
||||
h.find('dialog').props.onClose()
|
||||
await flush()
|
||||
assert.equal(h.find('dialog'), null)
|
||||
h.uiStore.manualEditorVisible = true
|
||||
await flush()
|
||||
assert.ok(h.find('loading'))
|
||||
assert.equal(h.calls.filter(call => call === 'load').length, 1)
|
||||
h.finishLoading()
|
||||
await flush()
|
||||
assert.ok(h.calls.includes('initialize'))
|
||||
h.stop()
|
||||
})
|
||||
|
||||
test('an import failing after cancellation stays hidden and the next open offers recovery', async () => {
|
||||
const h = harness(true)
|
||||
await flush()
|
||||
h.find('dialog').props.onClose()
|
||||
await flush()
|
||||
h.failLoading()
|
||||
await flush()
|
||||
assert.equal(h.find('dialog'), null)
|
||||
h.uiStore.manualEditorVisible = true
|
||||
await flush()
|
||||
assert.equal(h.find('loading'), null)
|
||||
assert.ok(h.find('alert'))
|
||||
assert.equal(h.calls.filter(call => call === 'load').length, 1)
|
||||
assert.equal(h.find('dialog').props['confirm-btn'], '重新加载页面')
|
||||
h.stop()
|
||||
})
|
||||
|
||||
test('an editor requested before App mounts initializes after the async import', async () => {
|
||||
const h = harness(true)
|
||||
await flush()
|
||||
h.finishLoading()
|
||||
await flush()
|
||||
assert.ok(h.calls.includes('initialize'))
|
||||
h.stop()
|
||||
})
|
||||
|
||||
test('closing before the async import completes skips hidden initialization and remains reopenable', async () => {
|
||||
const h = harness()
|
||||
h.uiStore.manualEditorVisible = true
|
||||
await flush()
|
||||
h.find('dialog').props.onClose()
|
||||
await flush()
|
||||
assert.equal(h.find('dialog'), null)
|
||||
h.finishLoading()
|
||||
await flush()
|
||||
assert.equal(h.calls.includes('initialize'), false)
|
||||
assert.deepEqual(h.calls, ['load', 'mount', 'detach', 'reset'])
|
||||
h.uiStore.manualEditorVisible = true
|
||||
await flush()
|
||||
assert.ok(h.calls.includes('initialize'))
|
||||
h.stop()
|
||||
})
|
||||
|
||||
test('global and editor recovery share one consent prompt and reload only on confirmation', () => {
|
||||
const code = readFileSync(new URL('./utils/moduleLoadRecovery.ts', import.meta.url), 'utf8')
|
||||
const ast = ts.createSourceFile('moduleLoadRecovery.ts', code, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS)
|
||||
const declarations = ast.statements.filter(node => !ts.isImportDeclaration(node)).map(node => node.getText(ast)).join('\n').replace(/^export /gm, '')
|
||||
const prompts: any[] = []
|
||||
let reloads = 0, destroyed = 0, prevented = 0
|
||||
let preloadError!: (event: { preventDefault: () => void }) => void
|
||||
let routeError!: (error: Error) => void
|
||||
const recovery = evaluate(declarations, {
|
||||
DialogPlugin: { confirm: (options: any) => { prompts.push(options); return { destroy: () => { destroyed++ } } } },
|
||||
window: {
|
||||
location: { reload: () => { reloads++ } },
|
||||
addEventListener: (name: string, handler: typeof preloadError) => { assert.equal(name, 'vite:preloadError'); preloadError = handler },
|
||||
},
|
||||
}, ['installModuleLoadRecovery', 'offerModuleReload'])
|
||||
recovery.installModuleLoadRecovery({ onError: (handler: typeof routeError) => { routeError = handler } })
|
||||
preloadError({ preventDefault: () => { prevented++ } })
|
||||
routeError(new Error('Failed to fetch dynamically imported module'))
|
||||
recovery.offerModuleReload()
|
||||
assert.equal(prevented, 1)
|
||||
assert.equal(prompts.length, 1)
|
||||
assert.equal(reloads, 0)
|
||||
assert.ok(prompts[0].body.includes('未保存的输入'))
|
||||
prompts[0].onClose()
|
||||
assert.equal(destroyed, 1)
|
||||
recovery.offerModuleReload()
|
||||
assert.equal(prompts.length, 2)
|
||||
assert.equal(reloads, 0)
|
||||
prompts[1].onConfirm()
|
||||
assert.equal(reloads, 1)
|
||||
prompts[1].onClose()
|
||||
routeError(new Error('unrelated error'))
|
||||
assert.equal(prompts.length, 2)
|
||||
})
|
||||
@@ -0,0 +1,311 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { MessagePlugin, NotifyPlugin } from 'tdesign-vue-next'
|
||||
import ManualKnowledgeEditorHost from '@/components/ManualKnowledgeEditorHost.vue'
|
||||
import UploadConfirmHost from '@/components/UploadConfirmHost.vue'
|
||||
import ResumableUploadHost from '@/components/ResumableUploadHost.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { getCurrentUser, userInfoFromApi } from '@/api/auth'
|
||||
import { consumePendingTenantSwitchToast } from '@/utils/tenantSwitch'
|
||||
import { useRoleLabel } from '@/composables/useRoleLabel'
|
||||
import { notifyLoginSuccess } from '@/utils/loginNotify'
|
||||
import { renderWorkspaceNotifyContent } from '@/utils/workspaceNotifyContent'
|
||||
|
||||
// TDesign locale configs
|
||||
import enUSConfig from 'tdesign-vue-next/esm/locale/en_US'
|
||||
import zhCNConfig from 'tdesign-vue-next/esm/locale/zh_CN'
|
||||
import koKRConfig from 'tdesign-vue-next/esm/locale/ko_KR'
|
||||
import ruRUConfig from 'tdesign-vue-next/esm/locale/ru_RU'
|
||||
|
||||
const { locale, t, tm } = useI18n()
|
||||
const { formatRole, roleIcon } = useRoleLabel()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
|
||||
const tdLocaleMap: Record<string, object> = {
|
||||
'en-US': enUSConfig,
|
||||
'zh-CN': zhCNConfig,
|
||||
'ko-KR': koKRConfig,
|
||||
'ru-RU': ruRUConfig,
|
||||
}
|
||||
|
||||
const tdGlobalConfig = computed(() => tdLocaleMap[locale.value] || enUSConfig)
|
||||
|
||||
const decodeOIDCResult = (encoded: string) => {
|
||||
const normalized = encoded.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const padded = normalized + '='.repeat((4 - normalized.length % 4) % 4)
|
||||
const binary = window.atob(padded)
|
||||
const bytes = Uint8Array.from(binary, char => char.charCodeAt(0))
|
||||
return JSON.parse(new TextDecoder().decode(bytes))
|
||||
}
|
||||
|
||||
const clearOIDCCallbackState = (path = '/') => {
|
||||
window.history.replaceState({}, document.title, path)
|
||||
}
|
||||
|
||||
const syncOIDCUserContext = async () => {
|
||||
const currentUserResponse = await getCurrentUser()
|
||||
if (!currentUserResponse.success || !currentUserResponse.data?.user) {
|
||||
throw new Error(currentUserResponse.message || 'Failed to get user information')
|
||||
}
|
||||
|
||||
const { user, tenant, memberships, capabilities } = currentUserResponse.data
|
||||
authStore.setUser(userInfoFromApi(user, tenant?.id))
|
||||
if (tenant) {
|
||||
authStore.setTenant({
|
||||
id: String(tenant.id) || '',
|
||||
name: tenant.name || '',
|
||||
owner_id: tenant.owner_id || user.id || '',
|
||||
description: tenant.description,
|
||||
status: tenant.status,
|
||||
business: tenant.business,
|
||||
storage_quota: tenant.storage_quota,
|
||||
storage_used: tenant.storage_used,
|
||||
created_at: tenant.created_at || new Date().toISOString(),
|
||||
updated_at: tenant.updated_at || new Date().toISOString()
|
||||
})
|
||||
} else {
|
||||
authStore.setTenant(null)
|
||||
}
|
||||
// Refresh memberships so currentTenantRole reflects any role change
|
||||
// since the last login (e.g. an Owner demoted us to Viewer in a
|
||||
// peer tenant). Without this, memberships stay frozen at the
|
||||
// login-time snapshot and the UI silently lies about our authority.
|
||||
if (Array.isArray(memberships)) {
|
||||
authStore.setMemberships(memberships)
|
||||
}
|
||||
if (typeof capabilities?.can_create_tenant === 'boolean') {
|
||||
authStore.setCanCreateTenant(capabilities.can_create_tenant)
|
||||
}
|
||||
// Same active-vs-home reconciliation as Login.vue: if the OIDC login
|
||||
// landed us in a non-home tenant (because the backend honoured a
|
||||
// remembered last-active-tenant preference) make sure X-Tenant-ID
|
||||
// override is set; otherwise drop any stale override.
|
||||
const activeIdNum = tenant?.id != null ? Number(tenant.id) : NaN
|
||||
const homeIdNum = user.tenant_id != null ? Number(user.tenant_id) : NaN
|
||||
if (Number.isFinite(activeIdNum) && Number.isFinite(homeIdNum) && activeIdNum !== homeIdNum) {
|
||||
authStore.setSelectedTenant(activeIdNum, tenant?.name || null)
|
||||
} else {
|
||||
authStore.setSelectedTenant(null, null)
|
||||
}
|
||||
}
|
||||
|
||||
const persistOIDCLoginResponse = async (response: any) => {
|
||||
if (!response.token) {
|
||||
throw new Error(response.message || 'OIDC login failed')
|
||||
}
|
||||
|
||||
authStore.setToken(response.token)
|
||||
if (response.refresh_token) {
|
||||
authStore.setRefreshToken(response.refresh_token)
|
||||
}
|
||||
|
||||
await syncOIDCUserContext()
|
||||
|
||||
// OIDC 跳转前暂存的邀请 token:拿到会话后兑换并进入对应空间。
|
||||
const pendingInviteToken = sessionStorage.getItem('weknora_pending_invite_token')
|
||||
if (pendingInviteToken) {
|
||||
sessionStorage.removeItem('weknora_pending_invite_token')
|
||||
const result = await authStore.acceptInvitationByTokenAndRefresh(pendingInviteToken)
|
||||
await nextTick()
|
||||
if (result.ok) MessagePlugin.success(t('inviteRegister.joined'))
|
||||
else MessagePlugin.warning(t('inviteRegister.invalidBody'))
|
||||
// 会话已有效,无论 token 是否兑换成功都进入应用。
|
||||
router.replace('/platform/knowledge-bases')
|
||||
return
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
router.replace(authStore.hasValidTenant ? '/platform/knowledge-bases' : '/onboarding/workspace')
|
||||
}
|
||||
|
||||
const handleGlobalOIDCCallback = async () => {
|
||||
const hash = window.location.hash.startsWith('#') ? window.location.hash.slice(1) : ''
|
||||
if (!hash) return
|
||||
|
||||
const params = new URLSearchParams(hash)
|
||||
const oidcError = params.get('oidc_error')
|
||||
const oidcErrorDescription = params.get('oidc_error_description')
|
||||
const oidcResult = params.get('oidc_result')
|
||||
|
||||
if (!oidcError && !oidcResult) return
|
||||
|
||||
if (oidcError) {
|
||||
clearOIDCCallbackState('/login')
|
||||
await router.replace('/login')
|
||||
MessagePlugin.error(oidcErrorDescription || 'OIDC login failed')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (!oidcResult) {
|
||||
clearOIDCCallbackState('/login')
|
||||
await router.replace('/login')
|
||||
MessagePlugin.error('OIDC login failed')
|
||||
return
|
||||
}
|
||||
|
||||
const response = decodeOIDCResult(oidcResult)
|
||||
if (response.success) {
|
||||
clearOIDCCallbackState('/')
|
||||
await persistOIDCLoginResponse(response)
|
||||
notifyLoginSuccess(response, t, tm, formatRole, roleIcon)
|
||||
return
|
||||
}
|
||||
|
||||
clearOIDCCallbackState('/login')
|
||||
await router.replace('/login')
|
||||
MessagePlugin.error(response.message || 'OIDC login failed')
|
||||
} catch (error: any) {
|
||||
console.error('Global OIDC callback handling failed:', error)
|
||||
authStore.logout()
|
||||
clearOIDCCallbackState('/login')
|
||||
await router.replace('/login')
|
||||
MessagePlugin.error(error.message || 'OIDC login failed')
|
||||
}
|
||||
}
|
||||
|
||||
let updateCheckTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
// Pending invitations poll: fires once on mount (logged-in case) and
|
||||
// then every 2 minutes. Light enough to keep the avatar-row badge
|
||||
// near-live without slamming the API, and avoids the cost of a
|
||||
// dedicated SSE/WebSocket connection. Stopped on logout via the
|
||||
// computed below.
|
||||
let invitationPollTimer: ReturnType<typeof setInterval> | null = null
|
||||
const INVITATION_POLL_INTERVAL_MS = 2 * 60 * 1000
|
||||
|
||||
const startInvitationPolling = () => {
|
||||
if (invitationPollTimer || !authStore.isLoggedIn) return
|
||||
// Immediate fetch so the badge is correct before the first tick.
|
||||
authStore.fetchPendingInvitationCount()
|
||||
invitationPollTimer = setInterval(() => {
|
||||
if (!authStore.isLoggedIn) return
|
||||
authStore.fetchPendingInvitationCount()
|
||||
}, INVITATION_POLL_INTERVAL_MS)
|
||||
}
|
||||
|
||||
const stopInvitationPolling = () => {
|
||||
if (invitationPollTimer) {
|
||||
clearInterval(invitationPollTimer)
|
||||
invitationPollTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
// React to login/logout via the store's isLoggedIn computed. Watching
|
||||
// here (rather than only on first mount) handles the OIDC callback
|
||||
// flow where the user logs in well after App.vue has already mounted.
|
||||
watch(
|
||||
() => authStore.isLoggedIn,
|
||||
(logged) => {
|
||||
if (logged) startInvitationPolling()
|
||||
else stopInvitationPolling()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// 切换空间后会 hard reload;切换前 stash 的 toast 这里 consume 并弹出,
|
||||
// 这样 toast 显示在新页面上,duration 才真正生效。
|
||||
const showPendingTenantSwitchToast = () => {
|
||||
const pending = consumePendingTenantSwitchToast()
|
||||
if (!pending) return
|
||||
const templateKey = pending.role
|
||||
? 'tenant.switchSuccessContentWithRole'
|
||||
: 'tenant.switchSuccessContent'
|
||||
// Use tm() not t() — vue-i18n v11's `t()` replaces unspecified named
|
||||
// placeholders with empty strings, which would strip {name}/{role}
|
||||
// before the chip renderer can split on them. tm() returns the raw
|
||||
// message verbatim.
|
||||
const rawTemplate = tm(templateKey)
|
||||
const template = typeof rawTemplate === 'string' ? rawTemplate : ''
|
||||
NotifyPlugin.success({
|
||||
title: t('tenant.switchSuccessTitle'),
|
||||
content: renderWorkspaceNotifyContent({
|
||||
template,
|
||||
name: pending.name,
|
||||
roleLabel: pending.role,
|
||||
roleEnum: pending.roleEnum,
|
||||
roleIconName: pending.roleEnum ? roleIcon(pending.roleEnum) : undefined,
|
||||
}),
|
||||
duration: 6000,
|
||||
closeBtn: true,
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
handleGlobalOIDCCallback()
|
||||
showPendingTenantSwitchToast()
|
||||
|
||||
// Auto check for updates on startup
|
||||
setTimeout(() => {
|
||||
if (settingsStore.isAutoCheckUpdateEnabled) {
|
||||
// @ts-ignore
|
||||
if (window.go && window.go.main && window.go.main.App && window.go.main.App.AutoCheckForUpdates) {
|
||||
// @ts-ignore
|
||||
window.go.main.App.AutoCheckForUpdates()
|
||||
}
|
||||
}
|
||||
}, 2000)
|
||||
|
||||
// Periodically check for updates (every 4 hours)
|
||||
updateCheckTimer = setInterval(() => {
|
||||
if (settingsStore.isAutoCheckUpdateEnabled) {
|
||||
// @ts-ignore
|
||||
if (window.go && window.go.main && window.go.main.App && window.go.main.App.AutoCheckForUpdates) {
|
||||
// @ts-ignore
|
||||
window.go.main.App.AutoCheckForUpdates()
|
||||
}
|
||||
}
|
||||
}, 4 * 60 * 60 * 1000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (updateCheckTimer) {
|
||||
clearInterval(updateCheckTimer)
|
||||
}
|
||||
stopInvitationPolling()
|
||||
})
|
||||
|
||||
</script>
|
||||
<template>
|
||||
<t-config-provider :globalConfig="tdGlobalConfig">
|
||||
<div id="app">
|
||||
<RouterView />
|
||||
<ManualKnowledgeEditorHost />
|
||||
<UploadConfirmHost />
|
||||
<ResumableUploadHost />
|
||||
</div>
|
||||
</t-config-provider>
|
||||
</template>
|
||||
<style>
|
||||
html {
|
||||
/* 提示 UA 使用对应配色绘制滚动条等,减少主题切换时的额外重绘 */
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
body,
|
||||
html,
|
||||
#app {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-size: 14px;
|
||||
font-family: var(--app-font-family);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background: var(--td-bg-color-page);
|
||||
color: var(--td-text-color-primary);
|
||||
}
|
||||
|
||||
#app {
|
||||
/* 独立合成层,减轻 WebKit 全量重绘时整窗与内容的撕裂感(桌面 WebView 尤其明显) */
|
||||
isolation: isolate;
|
||||
transform: translateZ(0);
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,20 @@
|
||||
import { get, post, put, del } from '@/utils/request'
|
||||
|
||||
export interface AccessRole { id: string; name: string; description: string; permissions: string[]; version: number }
|
||||
export interface AccessUser { id: string; username: string; email: string; role: string; status: string; custom_role_id: string; is_system_admin: boolean; is_active: boolean }
|
||||
export interface AccessResource { kind: string; id: string; name: string; owner_id: string; owner_name: string; global: boolean }
|
||||
export interface AccessGrant { kind: string; resource_id: string; user_id: string }
|
||||
export interface PermissionModule { id: string; name: string; admin_only?: boolean; permissions: { id: string; name: string }[] }
|
||||
export interface AccessSnapshot { permissions: string[]; restricted: boolean; administrator: boolean }
|
||||
const root = '/api/v1/access'
|
||||
export const getAccess = () => get<{ data: AccessSnapshot }>(`${root}/me`)
|
||||
export const getAccessCatalog = () => get<{ data: PermissionModule[] }>(`${root}/catalog`)
|
||||
export const getAccessRoles = () => get<{ data: AccessRole[] }>(`${root}/roles`)
|
||||
export const saveAccessRole = (role: AccessRole) => role.id ? put(`${root}/roles/${encodeURIComponent(role.id)}`, role) : post(`${root}/roles`, role)
|
||||
export const deleteAccessRole = (id: string) => del(`${root}/roles/${encodeURIComponent(id)}`)
|
||||
export const getAccessUsers = () => get<{ data: AccessUser[] }>(`${root}/users`)
|
||||
export const saveAccessUser = (user: Record<string, unknown>) => user.id ? put(`${root}/users/${encodeURIComponent(String(user.id))}`, user) : post(`${root}/users`, user)
|
||||
export const deleteAccessUser = (id: string) => del(`${root}/users/${encodeURIComponent(id)}`)
|
||||
export const getAccessResources = () => get<{ data: AccessResource[] }>(`${root}/resources`)
|
||||
export const getAccessGrants = () => get<{ data: AccessGrant[] }>(`${root}/grants`)
|
||||
export const updateAccessGrants = (resources: { kind: string; id: string }[], user_ids: string[], action: string) => post(`${root}/grants`, { resources, user_ids, action })
|
||||
@@ -0,0 +1,428 @@
|
||||
import { get, post, put, del } from "../../utils/request";
|
||||
|
||||
// 智能体配置
|
||||
// 智能推理下的智能体类型预设 ID
|
||||
// 'rag-qa' : 经典文档/FAQ 分块 RAG
|
||||
// 'wiki-qa' : Wiki 图谱导航问答
|
||||
// 'hybrid-rag-wiki': Wiki + 分块混合检索
|
||||
// 'custom' : 完全自定义(不应用预设)
|
||||
export type AgentType = 'rag-qa' | 'wiki-qa' | 'hybrid-rag-wiki' | 'data-analysis' | 'custom';
|
||||
|
||||
export interface QuestionSuggestionConfig {
|
||||
starters: {
|
||||
enabled: boolean;
|
||||
mode: 'curated' | 'knowledge' | 'hybrid';
|
||||
items: string[];
|
||||
count: number;
|
||||
};
|
||||
follow_ups: {
|
||||
enabled: boolean;
|
||||
mode: 'generated' | 'knowledge' | 'hybrid';
|
||||
count: number;
|
||||
model_id?: string;
|
||||
additional_instruction?: string;
|
||||
categories: Array<'clarify' | 'deepen' | 'action'>;
|
||||
max_context_turns: number;
|
||||
suppress_on_fallback: boolean;
|
||||
suppress_when_answer_asks_question: boolean;
|
||||
knowledge_fallback: boolean;
|
||||
allow_regenerate: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export type AgentRole = '' | 'general' | 'coordinator' | 'production' | 'qc' | 'service' | 'training_map';
|
||||
|
||||
export interface CustomAgentConfig {
|
||||
// ===== 基础设置 =====
|
||||
role?: AgentRole;
|
||||
content_tags?: string[];
|
||||
fusion_agent_ids?: string[];
|
||||
agent_mode?: 'quick-answer' | 'smart-reasoning'; // 运行模式:quick-answer=RAG模式, smart-reasoning=ReAct Agent模式
|
||||
// 智能推理模式下的类型预设,用于一键应用"系统提示词 + 工具 + KB 兼容性"组合
|
||||
// 仅在 agent_mode === 'smart-reasoning' 时生效;quick-answer 模式忽略
|
||||
agent_type?: AgentType;
|
||||
system_prompt?: string; // 统一系统提示词(使用 {{web_search_status}} 占位符动态控制行为)
|
||||
system_prompt_id?: string; // 引用的 prompt template ID(预设会填入此字段)
|
||||
context_template?: string; // 上下文模板(普通模式)
|
||||
|
||||
// ===== 模型设置 =====
|
||||
model_id?: string;
|
||||
rerank_model_id?: string; // ReRank 模型 ID
|
||||
temperature?: number;
|
||||
max_completion_tokens?: number; // 0 = 跟随系统默认(快速问答 2048;智能推理 4096,绑沙箱可写文件时 24576)。大于 0 为自定义上限
|
||||
thinking?: boolean; // 是否启用思考模式(支持扩展思考的模型)
|
||||
citation_enabled?: boolean; // 是否在最终回答中输出知识库/网页来源引用(默认开启)
|
||||
|
||||
// ===== Agent模式设置 =====
|
||||
max_iterations?: number; // 最大迭代次数;-1 表示不限制
|
||||
llm_call_timeout?: number; // LLM调用超时时间(秒)
|
||||
allowed_tools?: string[]; // 允许的工具
|
||||
reflection_enabled?: boolean; // 是否启用反思
|
||||
// MCP服务选择模式:all=全部启用的MCP服务, selected=指定服务, none=不使用MCP
|
||||
mcp_selection_mode?: 'all' | 'selected' | 'none';
|
||||
mcp_services?: string[]; // 选择的MCP服务ID列表
|
||||
// 对话中触发 OAuth 授权时的等待超时(秒):到点后自动跳过授权提示。
|
||||
// <=0 时使用服务端默认超时。仅对使用 OAuth 的 MCP 服务生效。
|
||||
mcp_auth_wait_timeout?: number;
|
||||
|
||||
// ===== Skills设置(仅Agent模式)=====
|
||||
// Skills选择模式:all=全部预装, selected=指定, none=不使用
|
||||
skills_selection_mode?: 'all' | 'selected' | 'none';
|
||||
selected_skills?: string[]; // 选择的Skill名称列表
|
||||
instruction_skill_ids?: string[]; // Markdown 指令技能,无需沙箱
|
||||
|
||||
// ===== 沙箱设置 =====
|
||||
// 该智能体的技能脚本运行在哪个沙箱配置上;为空表示不启用沙箱执行。
|
||||
// 指向逻辑配置而非某个具体版本,凭据轮换时无需重新指派每个智能体。
|
||||
sandbox_config_id?: string;
|
||||
|
||||
// ===== 知识库设置 =====
|
||||
// 知识库选择模式:all=全部知识库, selected=指定知识库, none=不使用知识库
|
||||
kb_selection_mode?: 'all' | 'selected' | 'none';
|
||||
knowledge_bases?: string[];
|
||||
// 是否仅在显式 @ 提及时检索知识库(默认: false)
|
||||
// true: 只有用户通过 @ 明确提及知识库/文档时才检索
|
||||
// false: 根据 kb_selection_mode 自动检索知识库
|
||||
retrieve_kb_only_when_mentioned?: boolean;
|
||||
|
||||
// ===== 图片上传/多模态设置 =====
|
||||
image_upload_enabled?: boolean; // 是否启用图片上传(默认: false)
|
||||
vlm_model_id?: string; // VLM模型ID(图片分析用)
|
||||
image_storage_provider?: string; // 图片存储提供商
|
||||
audio_upload_enabled?: boolean; // 是否启用音频上传/ASR转录(默认: false)
|
||||
asr_model_id?: string; // ASR模型ID(音频转录用)
|
||||
// 附件图片理解 / 扫描件 OCR 开关(默认: false,开启会增加解析耗时)
|
||||
attachment_image_understanding?: boolean;
|
||||
// 扫描件 OCR 最大页数(0 = 使用全局默认 WEKNORA_CHAT_ATTACHMENT_OCR_MAX_PAGES)
|
||||
attachment_ocr_max_pages?: number;
|
||||
// 单轮问答等待附件解析完成的最长时间(秒,0 = 使用全局默认 WEKNORA_CHAT_ATTACHMENT_WAIT_TIMEOUT_SEC)
|
||||
attachment_parse_wait_timeout_sec?: number;
|
||||
|
||||
// ===== 聊天附件解析引擎策略 =====
|
||||
// 按文件类型选择解析引擎;优先级:请求 parser_engine > 智能体规则 > 租户规则 > auto
|
||||
chat_parser_engine_rules?: { file_types: string[]; engine: string }[];
|
||||
|
||||
// ===== 文件类型限制 =====
|
||||
// 支持的文件类型(如 ["csv", "xlsx", "xls"])
|
||||
// 为空表示支持所有文件类型
|
||||
supported_file_types?: string[];
|
||||
|
||||
// ===== 网络搜索设置 =====
|
||||
web_search_enabled?: boolean;
|
||||
web_search_provider_id?: string;
|
||||
web_search_max_results?: number;
|
||||
|
||||
// ===== 多轮对话设置 =====
|
||||
multi_turn_enabled?: boolean; // 是否启用多轮对话
|
||||
history_turns?: number; // 保留历史轮数
|
||||
|
||||
// ===== 长期记忆 =====
|
||||
// 该智能体是否可以读取用户的长期记忆。
|
||||
// 缺省(旧数据)等同于 true:这是一个只能"关"的开关,空间设置关闭时
|
||||
// 这里打开也不会生效。
|
||||
memory_enabled?: boolean;
|
||||
|
||||
// ===== 检索策略设置 =====
|
||||
embedding_top_k?: number; // 向量召回TopK
|
||||
keyword_threshold?: number; // 关键词召回阈值
|
||||
vector_threshold?: number; // 向量召回阈值
|
||||
rerank_top_k?: number; // 重排TopK
|
||||
rerank_threshold?: number; // 重排阈值
|
||||
|
||||
// ===== 高级设置(主要用于普通模式)=====
|
||||
enable_query_expansion?: boolean; // 是否启用查询扩展
|
||||
enable_rewrite?: boolean; // 是否启用问题改写
|
||||
rewrite_prompt_system?: string; // 改写系统提示词
|
||||
rewrite_prompt_user?: string; // 改写用户提示词模板
|
||||
fallback_strategy?: 'fixed' | 'model'; // 兜底策略
|
||||
fallback_response?: string; // 固定兜底回复
|
||||
fallback_prompt?: string; // 兜底提示词(模型生成时)
|
||||
// 意图提示词:非检索意图(问候、闲聊等)时覆盖主系统提示词
|
||||
intent_prompts?: Record<string, string>;
|
||||
|
||||
// ===== 已废弃字段(保留兼容)=====
|
||||
welcome_message?: string;
|
||||
question_suggestions?: QuestionSuggestionConfig;
|
||||
}
|
||||
|
||||
// 智能体
|
||||
export interface CustomAgent {
|
||||
digital_human?: { id: string; name: string; applied_version: number; active_version: number };
|
||||
enabled?: boolean;
|
||||
visibility?: 'private' | 'global';
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
avatar?: string;
|
||||
is_builtin: boolean;
|
||||
tenant_id?: number;
|
||||
created_by?: string;
|
||||
// creator_name 由后端 list 接口批量回填,仅用于列表卡片来源徽章。
|
||||
creator_name?: string;
|
||||
config: CustomAgentConfig;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
// 创建智能体请求
|
||||
export interface CreateAgentRequest {
|
||||
name: string;
|
||||
description?: string;
|
||||
avatar?: string;
|
||||
config?: CustomAgentConfig;
|
||||
}
|
||||
|
||||
// 更新智能体请求
|
||||
export interface UpdateAgentRequest {
|
||||
name: string;
|
||||
description?: string;
|
||||
avatar?: string;
|
||||
config?: CustomAgentConfig;
|
||||
}
|
||||
|
||||
// 内置智能体 ID(常用的保留常量,便于代码引用)
|
||||
export const BUILTIN_QUICK_ANSWER_ID = 'builtin-quick-answer';
|
||||
export const BUILTIN_SMART_REASONING_ID = 'builtin-smart-reasoning';
|
||||
|
||||
// AgentMode 常量
|
||||
export const AGENT_MODE_QUICK_ANSWER = 'quick-answer';
|
||||
export const AGENT_MODE_SMART_REASONING = 'smart-reasoning';
|
||||
|
||||
// Deprecated: Use BUILTIN_QUICK_ANSWER_ID instead
|
||||
export const BUILTIN_AGENT_NORMAL_ID = BUILTIN_QUICK_ANSWER_ID;
|
||||
// Deprecated: Use BUILTIN_SMART_REASONING_ID instead
|
||||
export const BUILTIN_AGENT_AGENT_ID = BUILTIN_SMART_REASONING_ID;
|
||||
|
||||
// 获取智能体列表(包括内置智能体)
|
||||
// disabled_own_agent_ids: 当前空间在对话下拉中停用的「我的」智能体 ID,仅影响本空间
|
||||
export function listAgents(params?: {
|
||||
/**
|
||||
* Optional creator filter; mirrors listKnowledgeBases. Built-in agents
|
||||
* (is_builtin=true) are always returned regardless of this filter so
|
||||
* the conversation dropdown never silently loses quick-answer /
|
||||
* smart-reasoning when a user picks "Created by me".
|
||||
*/
|
||||
creator?: 'all' | 'mine' | 'others';
|
||||
}) {
|
||||
const qs = params?.creator && params.creator !== 'all' ? `?creator=${params.creator}` : '';
|
||||
return get<{ data: CustomAgent[]; disabled_own_agent_ids?: string[] }>(`/api/v1/agents${qs}`);
|
||||
}
|
||||
|
||||
// 获取智能体详情
|
||||
export function getAgentById(id: string) {
|
||||
return get<{ data: CustomAgent }>(`/api/v1/agents/${id}`);
|
||||
}
|
||||
|
||||
// 创建智能体
|
||||
export function createAgent(data: CreateAgentRequest) {
|
||||
return post<{ data: CustomAgent }>('/api/v1/agents', data);
|
||||
}
|
||||
|
||||
export function draftAgent(data: { model_id: string; requirement: string }) {
|
||||
return post<{ data: CreateAgentRequest }>('/api/v1/agents/draft', data, { timeout: 120000 });
|
||||
}
|
||||
|
||||
// 更新智能体
|
||||
export function updateAgent(id: string, data: UpdateAgentRequest) {
|
||||
return put<{ data: CustomAgent }>(`/api/v1/agents/${id}`, data);
|
||||
}
|
||||
|
||||
// 删除智能体
|
||||
export function deleteAgent(id: string) {
|
||||
return del<{ success: boolean }>(`/api/v1/agents/${id}`);
|
||||
}
|
||||
|
||||
// 复制智能体
|
||||
export function copyAgent(id: string) {
|
||||
return post<{ data: CustomAgent }>(`/api/v1/agents/${id}/copy`);
|
||||
}
|
||||
|
||||
// 判断是否为内置智能体(通过 agent.is_builtin 字段或 ID 前缀判断)
|
||||
export function isBuiltinAgent(agentId: string): boolean {
|
||||
return agentId.startsWith('builtin-');
|
||||
}
|
||||
|
||||
// 占位符定义
|
||||
export interface PlaceholderDefinition {
|
||||
name: string;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
// 占位符响应
|
||||
export interface PlaceholdersResponse {
|
||||
all: PlaceholderDefinition[];
|
||||
system_prompt: PlaceholderDefinition[];
|
||||
agent_system_prompt: PlaceholderDefinition[];
|
||||
context_template: PlaceholderDefinition[];
|
||||
rewrite_system_prompt: PlaceholderDefinition[];
|
||||
rewrite_prompt: PlaceholderDefinition[];
|
||||
fallback_prompt: PlaceholderDefinition[];
|
||||
}
|
||||
|
||||
// 获取占位符定义
|
||||
export function getPlaceholders() {
|
||||
return get<{ data: PlaceholdersResponse }>('/api/v1/agents/placeholders');
|
||||
}
|
||||
|
||||
// ===== 智能体类型预设 =====
|
||||
|
||||
// 后端 kb_filter 结构(见 internal/types/agent_type_preset.go)
|
||||
export interface AgentTypeKBFilter {
|
||||
any_of?: string[]; // KB 至少拥有其一
|
||||
all_of?: string[]; // KB 必须全部拥有
|
||||
none_of?: string[]; // KB 必须全部不拥有
|
||||
}
|
||||
|
||||
// KB 能力标签(后端 types.KBCapabilities 的 JSON)
|
||||
export interface KBCapabilities {
|
||||
vector: boolean;
|
||||
keyword: boolean;
|
||||
wiki: boolean;
|
||||
graph: boolean;
|
||||
faq: boolean;
|
||||
}
|
||||
|
||||
// 预设的"自动填充"配置载荷:仅包含被预设覆盖的字段;其他字段不动
|
||||
export interface AgentTypePresetConfig {
|
||||
system_prompt_id?: string;
|
||||
temperature?: number;
|
||||
max_iterations?: number;
|
||||
allowed_tools?: string[];
|
||||
retain_retrieval_history?: boolean;
|
||||
faq_priority_enabled?: boolean;
|
||||
web_search_enabled?: boolean;
|
||||
supported_file_types?: string[];
|
||||
kb_selection_mode?: 'all' | 'selected' | 'none';
|
||||
}
|
||||
|
||||
export interface AgentTypePresetI18n {
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface AgentTypePreset {
|
||||
id: AgentType;
|
||||
i18n: Record<string, AgentTypePresetI18n>;
|
||||
config?: AgentTypePresetConfig; // 为空表示"自定义"类型(无预设)
|
||||
kb_filter?: AgentTypeKBFilter; // 为空表示所有 KB 可选
|
||||
}
|
||||
|
||||
// 拉取类型预设列表(编辑器用)
|
||||
export function getAgentTypePresets() {
|
||||
return get<{ data: AgentTypePreset[] }>('/api/v1/agents/type-presets');
|
||||
}
|
||||
|
||||
// ===== IM渠道 =====
|
||||
|
||||
export interface IMChannel {
|
||||
id: string;
|
||||
tenant_id?: number;
|
||||
agent_id: string;
|
||||
// 'lark' is Feishu's international edition; it shares Feishu's credentials and modes.
|
||||
platform: 'wecom' | 'feishu' | 'lark' | 'slack' | 'telegram' | 'dingtalk' | 'mattermost' | 'wechat' | 'qqbot' | 'yunzhijia';
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
mode: 'webhook' | 'websocket' | 'longpoll';
|
||||
output_mode: 'stream' | 'full';
|
||||
session_mode?: 'user' | 'thread';
|
||||
knowledge_base_id?: string;
|
||||
credentials: Record<string, any>;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export function listIMChannels(agentId: string) {
|
||||
return get<{ data: IMChannel[] }>(`/api/v1/agents/${agentId}/im-channels`);
|
||||
}
|
||||
|
||||
// Tenant-wide overview row. Credentials are intentionally omitted — use
|
||||
// listIMChannels(agentId) when you need to edit a specific channel.
|
||||
export interface IMChannelOverview {
|
||||
id: string;
|
||||
tenant_id: number;
|
||||
agent_id: string;
|
||||
agent_name: string; // localized built-in name when the agent is built-in
|
||||
platform: IMChannel['platform'];
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
mode: IMChannel['mode'];
|
||||
output_mode: IMChannel['output_mode'];
|
||||
session_mode?: IMChannel['session_mode'];
|
||||
bot_identity: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export function listAllIMChannels() {
|
||||
return get<{ data: IMChannelOverview[] }>('/api/v1/im-channels');
|
||||
}
|
||||
|
||||
export function createIMChannel(agentId: string, data: Partial<IMChannel>) {
|
||||
return post<{ data: IMChannel }>(`/api/v1/agents/${agentId}/im-channels`, data);
|
||||
}
|
||||
|
||||
export function updateIMChannel(id: string, data: Partial<IMChannel>) {
|
||||
return put<{ data: IMChannel }>(`/api/v1/im-channels/${id}`, data);
|
||||
}
|
||||
|
||||
export function deleteIMChannel(id: string) {
|
||||
return del<{ success: boolean }>(`/api/v1/im-channels/${id}`);
|
||||
}
|
||||
|
||||
export function toggleIMChannel(id: string) {
|
||||
return post<{ data: IMChannel }>(`/api/v1/im-channels/${id}/toggle`);
|
||||
}
|
||||
|
||||
// ===== 推荐问题 =====
|
||||
|
||||
// 推荐问题
|
||||
export interface SuggestedQuestion {
|
||||
question: string;
|
||||
source: 'faq' | 'document' | 'agent_config' | 'wiki';
|
||||
knowledge_base_id?: string;
|
||||
}
|
||||
|
||||
// 获取智能体推荐问题
|
||||
// 根据智能体关联的知识库范围返回推荐问题,用于前端对话面板快捷提问
|
||||
export function getSuggestedQuestions(
|
||||
agentId: string,
|
||||
params?: {
|
||||
knowledge_base_ids?: string[];
|
||||
knowledge_ids?: string[];
|
||||
tag_scopes?: Array<{ knowledge_base_id: string; tag_ids: string[] }>;
|
||||
limit?: number;
|
||||
}
|
||||
) {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.knowledge_base_ids?.length) query.set('knowledge_base_ids', params.knowledge_base_ids.join(','));
|
||||
if (params?.knowledge_ids?.length) query.set('knowledge_ids', params.knowledge_ids.join(','));
|
||||
if (params?.tag_scopes?.length) query.set('tag_scopes', JSON.stringify(params.tag_scopes));
|
||||
if (params?.limit) query.set('limit', String(params.limit));
|
||||
const qs = query.toString();
|
||||
return get<{ data: { questions: SuggestedQuestion[] } }>(`/api/v1/agents/${agentId}/suggested-questions${qs ? '?' + qs : ''}`);
|
||||
}
|
||||
// ===== WeChat QR Code Login =====
|
||||
|
||||
export interface WeChatQRCodeResult {
|
||||
qrcode_url: string;
|
||||
qrcode: string;
|
||||
}
|
||||
|
||||
export interface WeChatQRCodeStatus {
|
||||
status: 'wait' | 'scaned' | 'confirmed' | 'expired';
|
||||
credentials?: {
|
||||
bot_token: string;
|
||||
ilink_bot_id: string;
|
||||
ilink_user_id: string;
|
||||
};
|
||||
baseurl?: string;
|
||||
}
|
||||
|
||||
export function getWeChatQRCode() {
|
||||
return post<{ data: WeChatQRCodeResult }>('/api/v1/wechat/qrcode');
|
||||
}
|
||||
|
||||
export function pollWeChatQRCodeStatus(qrcode: string) {
|
||||
return post<{ data: WeChatQRCodeStatus }>('/api/v1/wechat/qrcode/status', { qrcode });
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
import { post, get, put } from '@/utils/request'
|
||||
import i18n from '@/i18n'
|
||||
|
||||
const t = (key: string) => i18n.global.t(key)
|
||||
|
||||
// 用户登录接口
|
||||
export interface LoginRequest {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
success: boolean
|
||||
message?: string
|
||||
user?: {
|
||||
id: string
|
||||
username: string
|
||||
email: string
|
||||
avatar?: string
|
||||
tenant_id: number
|
||||
can_access_all_tenants?: boolean
|
||||
is_system_admin?: boolean
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
tenant?: {
|
||||
id: number
|
||||
name: string
|
||||
description: string
|
||||
status: string
|
||||
business: string
|
||||
storage_quota: number
|
||||
storage_used: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
} | null
|
||||
// active_tenant mirrors `tenant` for endpoints that distinguish home
|
||||
// tenant from current tenant (e.g. /auth/register-by-invite). Only
|
||||
// one of `tenant` / `active_tenant` is populated by any given endpoint.
|
||||
active_tenant?: {
|
||||
id: number
|
||||
name: string
|
||||
description?: string
|
||||
status?: string
|
||||
business?: string
|
||||
storage_quota?: number
|
||||
storage_used?: number
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
} | null
|
||||
memberships?: MembershipInfo[]
|
||||
token?: string
|
||||
refresh_token?: string
|
||||
}
|
||||
|
||||
export interface OIDCAuthURLResponse {
|
||||
success: boolean
|
||||
authorization_url?: string
|
||||
state?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface OIDCConfigResponse {
|
||||
success: boolean
|
||||
enabled: boolean
|
||||
provider_display_name?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
// 用户注册接口
|
||||
export interface RegisterRequest {
|
||||
username: string
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface RegisterResponse {
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: {
|
||||
user: {
|
||||
id: string
|
||||
username: string
|
||||
email: string
|
||||
}
|
||||
tenant: {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 用户偏好(与后端 types.UserPreferences 对齐,字段可选 = 没显式设置过)。
|
||||
// 新加 key 时记得:后端 service.UpdateUserPreferences 也要在 merge 分支里
|
||||
// 处理;前端调用方按需读 / 默认值降级。
|
||||
export interface UserPreferences {
|
||||
// last_active_tenant_id 持久化「刷新 / 换设备 / 重新登录后回到上次的空间」
|
||||
// 偏好;后端在 Login / RefreshToken 时校验 membership 有效后才会沿用,
|
||||
// 否则回退到 home 并清掉这个字段。传 0 给 PATCH 表示「清除偏好」。
|
||||
last_active_tenant_id?: number | null
|
||||
// oidc_only_login 为 true 表示账号由 OIDC 自动开通且用户尚未设置已知密码。
|
||||
oidc_only_login?: boolean
|
||||
}
|
||||
|
||||
// 用户信息接口
|
||||
export interface UserInfo {
|
||||
id: string
|
||||
username: string
|
||||
email: string
|
||||
avatar?: string
|
||||
tenant_id: string
|
||||
can_access_all_tenants?: boolean
|
||||
preferences?: UserPreferences
|
||||
is_system_admin?: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 把后端返回的 user JSON 规范化成前端 UserInfo。
|
||||
*
|
||||
* 历史上有 4 处独立的 setUser 调用(Login、autoSetup、token rehydrate、
|
||||
* /auth/me 主动 refresh)各自手写字段白名单,每加一个 user 字段都要在
|
||||
* 4 处同步——否则该字段就被悄悄过滤掉。is_system_admin 上线时就因为
|
||||
* 漏拷一处而看不到「系统管理」入口;这个工厂存在的目的就是杜绝同类
|
||||
* 漏拷再发生。**新增 user 字段请只改这里**。
|
||||
*
|
||||
* fallbackTenantId 是 tenant_id 缺失时的兜底来源——
|
||||
* - autoSetup 响应顶层有 tenant.id,但 user 对象上没有 tenant_id
|
||||
* - /auth/me 偶发只返回 user 不带 tenant 时也走兜底
|
||||
* 调用方按需传入;不传则保持空字符串(与历史行为一致)。
|
||||
*
|
||||
* 字段读取统一走 `=== true` 而不是 `|| false`,对偶发非 boolean
|
||||
* 类型(后端某天传 1/0 或字符串)做严格收敛,避免把 truthy 字符串
|
||||
* 误判为权限通过。
|
||||
*/
|
||||
export function userInfoFromApi(
|
||||
u: any,
|
||||
fallbackTenantId?: string | number | null,
|
||||
): UserInfo {
|
||||
const rawTenantId =
|
||||
u?.tenant_id !== undefined && u?.tenant_id !== null && u.tenant_id !== ''
|
||||
? u.tenant_id
|
||||
: fallbackTenantId ?? ''
|
||||
const tid = Number(rawTenantId) > 0 ? rawTenantId : ''
|
||||
return {
|
||||
id: u?.id || '',
|
||||
username: u?.username || '',
|
||||
email: u?.email || '',
|
||||
avatar: u?.avatar,
|
||||
tenant_id: String(tid) || '',
|
||||
can_access_all_tenants: u?.can_access_all_tenants === true,
|
||||
is_system_admin: u?.is_system_admin === true,
|
||||
preferences: u?.preferences,
|
||||
created_at: u?.created_at || new Date().toISOString(),
|
||||
updated_at: u?.updated_at || new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
// 空间信息接口
|
||||
export interface TenantInfo {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
status?: string
|
||||
business?: string
|
||||
owner_id: string
|
||||
storage_quota?: number
|
||||
storage_used?: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
knowledge_bases?: KnowledgeBaseInfo[]
|
||||
}
|
||||
|
||||
// 知识库信息接口
|
||||
export interface KnowledgeBaseInfo {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
tenant_id: string
|
||||
// creator_id is the user id of whoever originally created the KB.
|
||||
// Set by PR 5 of the multi-tenant RBAC series; nullable for legacy
|
||||
// KBs created before that migration backfilled the column.
|
||||
creator_id?: string
|
||||
// creator_name 由后端 list 接口批量回填(username 优先,退化到 email),
|
||||
// 仅用于列表卡片来源徽章;缺失代表无法解析(已删除 / 老数据)。
|
||||
creator_name?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
document_count?: number
|
||||
chunk_count?: number
|
||||
}
|
||||
|
||||
// 模型信息接口
|
||||
export interface ModelInfo {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
source: string
|
||||
description?: string
|
||||
is_default?: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户登录
|
||||
*/
|
||||
export async function login(data: LoginRequest): Promise<LoginResponse> {
|
||||
try {
|
||||
const response = await post('/api/v1/auth/login', data)
|
||||
return response as unknown as LoginResponse
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: error.message || t('error.auth.loginFailed')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 OIDC 登录跳转地址
|
||||
*/
|
||||
export async function getOIDCAuthorizationURL(redirectURI: string): Promise<OIDCAuthURLResponse> {
|
||||
try {
|
||||
const response = await get(`/api/v1/auth/oidc/url?redirect_uri=${encodeURIComponent(redirectURI)}`)
|
||||
return response as unknown as OIDCAuthURLResponse
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: error.message || t('error.auth.loginFailed')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 OIDC 登录配置
|
||||
*/
|
||||
export async function getOIDCConfig(): Promise<OIDCConfigResponse> {
|
||||
try {
|
||||
const response = await get('/api/v1/auth/oidc/config')
|
||||
return response as unknown as OIDCConfigResponse
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
enabled: false,
|
||||
message: error.message || t('error.auth.loginFailed')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取认证配置(仅返回前端渲染需要的公开字段,例如注册模式)。
|
||||
*
|
||||
* 后端通过 `auth.registration_mode` 控制是否允许自助注册:
|
||||
* - "self_serve" 保留现有自助注册入口(默认)
|
||||
* - "invite_only" 关闭注册,要求管理员邀请
|
||||
*
|
||||
* 失败时回落到 self_serve,避免接口异常导致注册入口直接消失。
|
||||
*/
|
||||
export interface AuthConfigResponse {
|
||||
success: boolean
|
||||
registration_mode: 'self_serve' | 'invite_only' | string
|
||||
complex_password_enabled: boolean
|
||||
}
|
||||
|
||||
export async function getAuthConfig(): Promise<AuthConfigResponse> {
|
||||
try {
|
||||
const response = await get('/api/v1/auth/config')
|
||||
return response as unknown as AuthConfigResponse
|
||||
} catch {
|
||||
return { success: false, registration_mode: 'self_serve', complex_password_enabled: false }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户注册
|
||||
*/
|
||||
export async function register(data: RegisterRequest): Promise<RegisterResponse> {
|
||||
try {
|
||||
const response = await post('/api/v1/auth/register', data)
|
||||
return response as unknown as RegisterResponse
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: error.message || t('error.auth.registerFailed')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lite 版自动初始化(创建默认用户/空间 + 签发令牌)
|
||||
*/
|
||||
export async function autoSetup(): Promise<LoginResponse> {
|
||||
try {
|
||||
const response = await post('/api/v1/auth/auto-setup', {})
|
||||
return response as unknown as LoginResponse
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: error.message || 'Auto-setup unavailable'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Membership row returned alongside /auth/me. Mirrors the LoginResponse
|
||||
* shape so the frontend can refresh `currentTenantRole` on every page
|
||||
* load — without it, role changes after login (e.g. an Owner demoting
|
||||
* us in a peer tenant) stay invisible until the user logs out and back
|
||||
* in.
|
||||
*/
|
||||
export interface MembershipInfo {
|
||||
tenant_id: number
|
||||
tenant_name?: string
|
||||
role: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户信息
|
||||
*/
|
||||
export interface AuthCapabilities {
|
||||
can_create_tenant: boolean
|
||||
auto_accept_invitation: boolean
|
||||
}
|
||||
|
||||
export async function getCurrentUser(): Promise<{ success: boolean; data?: { user: UserInfo; tenant?: TenantInfo | null; memberships?: MembershipInfo[]; tenant_required?: boolean; capabilities?: AuthCapabilities }; message?: string }> {
|
||||
try {
|
||||
const response = await get('/api/v1/auth/me')
|
||||
return response as unknown as { success: boolean; data?: { user: UserInfo; tenant?: TenantInfo | null; memberships?: MembershipInfo[]; tenant_required?: boolean; capabilities?: AuthCapabilities }; message?: string }
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: error.message || t('error.auth.getUserFailed')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新当前用户的偏好设置(PATCH 语义:只发要改的字段,后端只覆盖发了的 key,
|
||||
* 其它 key 保持不变)。后端会返回更新后的完整 preferences 对象。
|
||||
*/
|
||||
export async function updateMyPreferences(
|
||||
patch: Partial<UserPreferences>,
|
||||
): Promise<{ success: boolean; data?: UserPreferences; message?: string }> {
|
||||
try {
|
||||
const response = await put('/api/v1/auth/me/preferences', patch)
|
||||
return response as unknown as { success: boolean; data?: UserPreferences; message?: string }
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: error.message || t('error.auth.updatePreferencesFailed'),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前空间信息
|
||||
*/
|
||||
export async function getCurrentTenant(): Promise<{ success: boolean; data?: TenantInfo; message?: string }> {
|
||||
try {
|
||||
const response = await get('/api/v1/auth/tenant')
|
||||
return response as unknown as { success: boolean; data?: TenantInfo; message?: string }
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: error.message || t('error.auth.getTenantFailed')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新Token
|
||||
*/
|
||||
export async function refreshToken(refreshToken: string): Promise<{ success: boolean; data?: { token: string; refreshToken: string }; message?: string }> {
|
||||
try {
|
||||
const response: any = await post('/api/v1/auth/refresh', { refreshToken })
|
||||
if (response && response.success) {
|
||||
if (response.access_token || response.refresh_token) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
token: response.access_token,
|
||||
refreshToken: response.refresh_token,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 其他情况直接返回原始消息
|
||||
return {
|
||||
success: false,
|
||||
message: response?.message || t('error.auth.refreshTokenFailed')
|
||||
}
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: error.message || t('error.auth.refreshTokenFailed')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户登出
|
||||
*/
|
||||
export async function logout(): Promise<{ success: boolean; message?: string }> {
|
||||
try {
|
||||
await post('/api/v1/auth/logout', {})
|
||||
return {
|
||||
success: true
|
||||
}
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: error.message || t('error.auth.logoutFailed')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface ChangePasswordRequest {
|
||||
old_password: string
|
||||
new_password: string
|
||||
}
|
||||
|
||||
/** Map change-password API failures to localized UI strings. */
|
||||
export function resolveChangePasswordError(error: any): string {
|
||||
const details =
|
||||
typeof error?.error?.details === 'string'
|
||||
? error.error.details
|
||||
: typeof error?.details === 'string'
|
||||
? error.details
|
||||
: ''
|
||||
switch (details) {
|
||||
case 'invalid_old_password':
|
||||
return t('userProfile.changePassword.failed')
|
||||
case 'password_policy':
|
||||
return t('userProfile.changePassword.policyFailed')
|
||||
case 'same_password':
|
||||
return t('userProfile.changePassword.sameAsCurrent')
|
||||
default:
|
||||
return error?.message || t('userProfile.changePassword.failed')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-service password rotation. On success the backend revokes every
|
||||
* outstanding session for the caller, so the client should clear local
|
||||
* auth state and send the user back to /login.
|
||||
*/
|
||||
export async function changePassword(
|
||||
data: ChangePasswordRequest,
|
||||
): Promise<{ success: boolean; message?: string }> {
|
||||
try {
|
||||
const response = await post('/api/v1/auth/change-password', data)
|
||||
return response as unknown as { success: boolean; message?: string }
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: resolveChangePasswordError(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证Token有效性
|
||||
*/
|
||||
export async function validateToken(): Promise<{ success: boolean; valid?: boolean; message?: string }> {
|
||||
try {
|
||||
const response = await get('/api/v1/auth/validate')
|
||||
return response as unknown as { success: boolean; valid?: boolean; message?: string }
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
valid: false,
|
||||
message: error.message || t('error.auth.validateTokenFailed')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// ---- share-link registration --------------------------------------------
|
||||
|
||||
// InviteLookup is the public projection of a share-link row used by
|
||||
// /register?token=xxx — enough to render the registration page header
|
||||
// ("X invited you to Y") without leaking sensitive inviter fields.
|
||||
export interface InviteLookup {
|
||||
tenant_id: number
|
||||
tenant_name?: string
|
||||
role: string
|
||||
expires_at: string
|
||||
}
|
||||
|
||||
export interface InviteLookupResponse {
|
||||
success: boolean
|
||||
data?: InviteLookup
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface RegisterByInviteRequest {
|
||||
token: string
|
||||
email: string
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a share-link token (no auth) into the context the
|
||||
* registration page needs (tenant name, role, expiry). Returns 410
|
||||
* when the link is invalid / revoked / expired.
|
||||
*
|
||||
* Uses POST + body (rather than GET + path) so the plaintext token
|
||||
* never appears in access logs, browser history, or tracing spans.
|
||||
*/
|
||||
export async function getInvitationByToken(token: string): Promise<InviteLookupResponse> {
|
||||
try {
|
||||
const response = await post(`/api/v1/auth/invitations/lookup`, { token })
|
||||
return response as unknown as InviteLookupResponse
|
||||
} catch (error: any) {
|
||||
return { success: false, message: error.message || '' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete registration via a share-link token. The invitee supplies
|
||||
* their own email — the token is the authorisation, not an identity
|
||||
* lock.
|
||||
*/
|
||||
export async function registerByInvite(data: RegisterByInviteRequest): Promise<LoginResponse> {
|
||||
try {
|
||||
const response = await post('/api/v1/auth/register-by-invite', data)
|
||||
return response as unknown as LoginResponse
|
||||
} catch (error: any) {
|
||||
return { success: false, message: error.message || t('error.auth.registerFailed') }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { get, put, post } from '@/utils/request'
|
||||
|
||||
// ChatHistoryConfig represents the chat history KB configuration for a tenant.
|
||||
// knowledge_base_id is auto-managed by the backend; frontend only sets other fields.
|
||||
export interface ChatHistoryConfig {
|
||||
enabled: boolean
|
||||
embedding_model_id: string
|
||||
knowledge_base_id?: string // read-only, auto-managed
|
||||
}
|
||||
|
||||
// ChatHistoryKBStats represents statistics about the chat history knowledge base
|
||||
export interface ChatHistoryKBStats {
|
||||
enabled: boolean
|
||||
embedding_model_id?: string
|
||||
knowledge_base_id?: string
|
||||
knowledge_base_name?: string
|
||||
indexed_message_count: number
|
||||
has_indexed_messages: boolean
|
||||
}
|
||||
|
||||
// MessageSearchRequest defines search parameters for message search
|
||||
export interface MessageSearchRequest {
|
||||
query: string
|
||||
mode?: 'keyword' | 'vector' | 'hybrid'
|
||||
limit?: number
|
||||
session_ids?: string[]
|
||||
}
|
||||
|
||||
// MessageSearchGroupItem represents a merged Q&A pair in search results
|
||||
export interface MessageSearchGroupItem {
|
||||
request_id: string
|
||||
session_id: string
|
||||
session_title: string
|
||||
query_content: string
|
||||
answer_content: string
|
||||
score: number
|
||||
match_type: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// MessageSearchResult represents the full search result
|
||||
export interface MessageSearchResult {
|
||||
items: MessageSearchGroupItem[]
|
||||
total: number
|
||||
}
|
||||
|
||||
// Get tenant chat history config via KV API
|
||||
export function getTenantChatHistoryConfig() {
|
||||
return get('/api/v1/tenants/kv/chat-history-config')
|
||||
}
|
||||
|
||||
// Update tenant chat history config via KV API
|
||||
export function updateTenantChatHistoryConfig(config: ChatHistoryConfig) {
|
||||
return put('/api/v1/tenants/kv/chat-history-config', config)
|
||||
}
|
||||
|
||||
// Get chat history KB statistics
|
||||
export function getChatHistoryKBStats() {
|
||||
return get('/api/v1/messages/chat-history-stats')
|
||||
}
|
||||
|
||||
// Search messages across all sessions (keyword + vector hybrid search)
|
||||
export function searchMessages(data: MessageSearchRequest) {
|
||||
return post('/api/v1/messages/search', data)
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { get, post, put, del, postChat, getDown } from "../../utils/request";
|
||||
|
||||
|
||||
|
||||
export async function createSessions(data = {}) {
|
||||
return post("/api/v1/sessions", data);
|
||||
}
|
||||
|
||||
export async function getSessionsList(page: number, page_size: number, source?: string) {
|
||||
const params = new URLSearchParams({ page: String(page), page_size: String(page_size) });
|
||||
if (source) {
|
||||
params.set("source", source);
|
||||
}
|
||||
return get(`/api/v1/sessions?${params.toString()}`);
|
||||
}
|
||||
|
||||
export async function pinSession(session_id: string) {
|
||||
return post(`/api/v1/sessions/${session_id}/pin`, {});
|
||||
}
|
||||
|
||||
export async function unpinSession(session_id: string) {
|
||||
return del(`/api/v1/sessions/${session_id}/pin`);
|
||||
}
|
||||
|
||||
export async function generateSessionsTitle(session_id: string, data: any) {
|
||||
return post(`/api/v1/sessions/${session_id}/generate_title`, data);
|
||||
}
|
||||
|
||||
export async function updateSession(session_id: string, data: { title: string; description?: string }) {
|
||||
return put(`/api/v1/sessions/${session_id}`, data);
|
||||
}
|
||||
|
||||
export async function knowledgeChat(data: { session_id: string; query: string; }) {
|
||||
return postChat(`/api/v1/knowledge-chat/${data.session_id}`, { query: data.query, channel: "web" });
|
||||
}
|
||||
|
||||
// Agent chat with streaming support
|
||||
export async function agentChat(data: {
|
||||
session_id: string;
|
||||
query: string;
|
||||
knowledge_base_ids?: string[];
|
||||
agent_enabled: boolean;
|
||||
}) {
|
||||
return postChat(`/api/v1/agent-chat/${data.session_id}`, {
|
||||
query: data.query,
|
||||
knowledge_base_ids: data.knowledge_base_ids,
|
||||
agent_enabled: data.agent_enabled,
|
||||
channel: "web"
|
||||
});
|
||||
}
|
||||
|
||||
export async function getMessageList(data: { session_id: string; limit: number, created_at: string }) {
|
||||
if (data.created_at) {
|
||||
return get(`/api/v1/messages/${data.session_id}/load?before_time=${encodeURIComponent(data.created_at)}&limit=${data.limit}`);
|
||||
} else {
|
||||
return get(`/api/v1/messages/${data.session_id}/load?limit=${data.limit}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function delSession(session_id: string) {
|
||||
return del(`/api/v1/sessions/${session_id}`);
|
||||
}
|
||||
|
||||
export async function batchDelSessions(ids: string[]) {
|
||||
return del(`/api/v1/sessions/batch`, { ids });
|
||||
}
|
||||
|
||||
export async function deleteAllSessions() {
|
||||
return del(`/api/v1/sessions/batch`, { delete_all: true });
|
||||
}
|
||||
|
||||
export async function getSession(session_id: string) {
|
||||
return get(`/api/v1/sessions/${session_id}`);
|
||||
}
|
||||
|
||||
export async function stopSession(session_id: string, message_id: string) {
|
||||
return post(`/api/v1/sessions/${session_id}/stop`, { message_id });
|
||||
}
|
||||
|
||||
export async function clearSessionMessages(session_id: string) {
|
||||
return del(`/api/v1/sessions/${session_id}/messages`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Skill artifacts (files generated by skill scripts during agent turns)
|
||||
//
|
||||
// The backend keeps the physical storage path private and indexes each file by
|
||||
// its position (:index) in the owning assistant message. The list endpoints
|
||||
// return metadata only — clients must go through downloadArtifact to fetch
|
||||
// the actual bytes so ownership checks always run server-side.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ArtifactMeta {
|
||||
index: number;
|
||||
/**
|
||||
* `resource://<handle>` — the artifact's stable identity, and the destination
|
||||
* the answer body references. Empty when the deployment runs without a
|
||||
* resource catalog, in which case the body references files by name.
|
||||
*/
|
||||
handle?: string;
|
||||
file_name: string;
|
||||
file_type: string;
|
||||
file_size: number;
|
||||
source_path: string;
|
||||
mod_time: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// listMessageArtifacts returns the artifacts attached to a single assistant
|
||||
// message. Used by the drawer opened from the message action toolbar.
|
||||
export async function listMessageArtifacts(session_id: string, message_id: string) {
|
||||
return get(
|
||||
`/api/v1/sessions/${session_id}/messages/${message_id}/artifacts`,
|
||||
);
|
||||
}
|
||||
|
||||
// listSessionArtifacts returns every artifact recorded against the whole
|
||||
// session, in creation order. Powers "download everything generated in this
|
||||
// chat" drawers.
|
||||
export async function listSessionArtifacts(session_id: string) {
|
||||
return get(`/api/v1/sessions/${session_id}/artifacts`);
|
||||
}
|
||||
|
||||
// downloadArtifact streams a single artifact's bytes as a Blob so the caller
|
||||
// can trigger a browser download. A plain `<a href>` cannot be used because
|
||||
// axios attaches the Bearer token per request; issuing the download via
|
||||
// `getDown` keeps auth intact and lets the response be turned into an
|
||||
// object URL.
|
||||
export async function downloadArtifact(
|
||||
session_id: string,
|
||||
message_id: string,
|
||||
index: number,
|
||||
): Promise<Blob> {
|
||||
return getDown(
|
||||
`/api/v1/sessions/${session_id}/messages/${message_id}/artifacts/${index}/download`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import { fetchEventSource } from '@microsoft/fetch-event-source';
|
||||
import { ref, onUnmounted } from 'vue';
|
||||
import { generateRandomString } from '@/utils/index';
|
||||
import i18n from '@/i18n';
|
||||
import { getApiBaseUrl } from '@/utils/api-base';
|
||||
import {
|
||||
sanitizeStreamRequestBody,
|
||||
type StreamRequestMeta,
|
||||
} from '@/utils/chatRequestDebug';
|
||||
|
||||
|
||||
|
||||
interface StreamOptions {
|
||||
// 请求方法 (默认POST)
|
||||
method?: 'GET' | 'POST'
|
||||
// 请求头
|
||||
headers?: Record<string, string>
|
||||
// 请求体自动序列化
|
||||
body?: Record<string, any>
|
||||
// 流式渲染间隔 (ms)
|
||||
chunkInterval?: number
|
||||
}
|
||||
|
||||
export function useStream() {
|
||||
// 响应式状态
|
||||
const output = ref('') // 显示内容
|
||||
const isStreaming = ref(false) // 流状态
|
||||
const isLoading = ref(false) // 初始加载
|
||||
const error = ref<string | null>(null)// 错误信息
|
||||
const lastStreamRequest = ref<StreamRequestMeta | null>(null)
|
||||
let controller = new AbortController()
|
||||
let streamGeneration = 0
|
||||
|
||||
// 流式渲染缓冲
|
||||
let buffer: string[] = []
|
||||
let renderTimer: number | null = null
|
||||
|
||||
// 启动流式请求
|
||||
const startStream = async (params: { session_id: any; query: any; knowledge_base_ids?: string[]; knowledge_ids?: string[]; tag_ids?: string[]; agent_enabled?: boolean; agent_id?: string; agent_source_tenant_id?: string | number; web_search_enabled?: boolean; summary_model_id?: string; mcp_service_ids?: string[]; skill_names?: string[]; mentioned_items?: Array<{id: string; name: string; type: string; kb_type?: string; kb_id?: string; kb_name?: string; service_id?: string; skill_name?: string}>; images?: Array<{data: string}>; attachment_uploads?: Array<{data: string; file_name: string; file_size: number}>; attachment_ids?: string[]; suggestion_attribution?: { suggestion_set_id: string; question_id: string }; method: string; url: string; embed_token?: string; embed_session_sig?: string; embed_visitor_id?: string }) => {
|
||||
const myGeneration = ++streamGeneration
|
||||
// 重置状态
|
||||
output.value = '';
|
||||
error.value = null;
|
||||
isStreaming.value = true;
|
||||
isLoading.value = true;
|
||||
|
||||
// 获取API配置
|
||||
const apiUrl = getApiBaseUrl();
|
||||
|
||||
const embedToken = params.embed_token;
|
||||
const token = embedToken || localStorage.getItem('weknora_token');
|
||||
if (!token) {
|
||||
error.value = i18n.global.t('error.tokenNotFound');
|
||||
stopStream();
|
||||
return;
|
||||
}
|
||||
|
||||
// 跨空间访问请求头:只要 setSelectedTenant 写过激活空间,就附
|
||||
// X-Tenant-ID。早期版本会 short-circuit "selectedTenantId ===
|
||||
// defaultTenantId 时不附" 来减少 header 体积,但任何把 weknora_tenant
|
||||
// 写成激活空间的代码(OIDC 同步 / UserMenu loadUserInfo / router
|
||||
// hydrate)都会让两者相等,使得后续流式请求悄悄丢 header、落到
|
||||
// home 空间上,导致 SSE 接口返回 404。直接附即可——后端
|
||||
// IsTenantAccessible 也允许 header 指向自家空间。
|
||||
const selectedTenantId = localStorage.getItem('weknora_selected_tenant_id');
|
||||
const tenantIdHeader: string | null = selectedTenantId || null;
|
||||
|
||||
// TTFB instrumentation: record the moment we kick off the request so
|
||||
// we can compare it with the first answer chunk we receive from the
|
||||
// server. This makes it possible to correlate the frontend-observed
|
||||
// latency with the backend "TTFB:first_answer_chunk" log line by
|
||||
// matching on X-Request-ID.
|
||||
const sentAt = performance.now();
|
||||
const requestID = generateRandomString(12);
|
||||
let firstAnswerLogged = false;
|
||||
|
||||
try {
|
||||
let url =
|
||||
params.method == "POST"
|
||||
? `${apiUrl}${params.url}/${params.session_id}`
|
||||
: `${apiUrl}${params.url}/${params.session_id}?message_id=${params.query}`;
|
||||
console.log(`[TTFB] request:start request_id=${requestID} url=${url} sent_at=${Date.now()}`);
|
||||
|
||||
// Prepare POST body with required fields for agent-chat
|
||||
// knowledge_base_ids array and agent_enabled can update Session's SessionAgentConfig
|
||||
const postBody: any = {
|
||||
query: params.query,
|
||||
agent_enabled: params.agent_enabled !== undefined ? params.agent_enabled : true
|
||||
};
|
||||
// Always include knowledge_base_ids for agent-chat (already validated above)
|
||||
if (params.knowledge_base_ids !== undefined && params.knowledge_base_ids.length > 0) {
|
||||
postBody.knowledge_base_ids = params.knowledge_base_ids;
|
||||
}
|
||||
// Include knowledge_ids if provided
|
||||
if (params.knowledge_ids !== undefined && params.knowledge_ids.length > 0) {
|
||||
postBody.knowledge_ids = params.knowledge_ids;
|
||||
}
|
||||
// Include agent_id if provided (backend resolves shared agent and tenant from share relation)
|
||||
if (params.agent_id) {
|
||||
postBody.agent_id = params.agent_id;
|
||||
}
|
||||
if (params.agent_source_tenant_id) {
|
||||
postBody.agent_source_tenant_id = Number(params.agent_source_tenant_id);
|
||||
}
|
||||
// Include web_search_enabled if provided
|
||||
if (params.web_search_enabled !== undefined) {
|
||||
postBody.web_search_enabled = params.web_search_enabled;
|
||||
}
|
||||
// Include summary_model_id if provided (for non-Agent mode)
|
||||
if (params.summary_model_id) {
|
||||
postBody.summary_model_id = params.summary_model_id;
|
||||
}
|
||||
// Include mcp_service_ids if provided (for Agent mode)
|
||||
if (params.mcp_service_ids !== undefined && params.mcp_service_ids.length > 0) {
|
||||
postBody.mcp_service_ids = params.mcp_service_ids;
|
||||
}
|
||||
if (params.skill_names !== undefined && params.skill_names.length > 0) {
|
||||
postBody.skill_names = params.skill_names;
|
||||
}
|
||||
if (params.tag_ids !== undefined && params.tag_ids.length > 0) {
|
||||
postBody.tag_ids = params.tag_ids;
|
||||
}
|
||||
// Include mentioned_items if provided (for displaying @mentions in chat)
|
||||
if (params.mentioned_items !== undefined && params.mentioned_items.length > 0) {
|
||||
postBody.mentioned_items = params.mentioned_items;
|
||||
}
|
||||
// Include images if provided (base64 data URIs for multimodal chat)
|
||||
if (params.images !== undefined && params.images.length > 0) {
|
||||
postBody.images = params.images;
|
||||
}
|
||||
// Include attachment_uploads if provided (documents, audio, etc.)
|
||||
if (params.attachment_uploads !== undefined && params.attachment_uploads.length > 0) {
|
||||
postBody.attachment_uploads = params.attachment_uploads;
|
||||
}
|
||||
if (params.attachment_ids !== undefined && params.attachment_ids.length > 0) {
|
||||
postBody.attachment_ids = params.attachment_ids;
|
||||
}
|
||||
if (params.suggestion_attribution) {
|
||||
postBody.suggestion_attribution = params.suggestion_attribution;
|
||||
}
|
||||
postBody.channel = embedToken ? "embed" : "web";
|
||||
|
||||
lastStreamRequest.value = {
|
||||
requestId: requestID,
|
||||
url,
|
||||
method: params.method,
|
||||
body: params.method === 'POST' ? sanitizeStreamRequestBody(postBody) : null,
|
||||
sentAt: Date.now(),
|
||||
};
|
||||
|
||||
await fetchEventSource(url, {
|
||||
method: params.method,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": embedToken ? `Embed ${embedToken}` : `Bearer ${token}`,
|
||||
"Accept-Language": i18n.global.locale?.value || localStorage.getItem('locale') || 'zh-CN',
|
||||
"X-Request-ID": requestID,
|
||||
...(!embedToken && tenantIdHeader ? { "X-Tenant-ID": tenantIdHeader } : {}),
|
||||
...(params.embed_session_sig ? { "X-Embed-Session": params.embed_session_sig } : {}),
|
||||
...(params.embed_visitor_id ? { "X-Embed-Visitor": params.embed_visitor_id } : {}),
|
||||
},
|
||||
body:
|
||||
params.method == "POST"
|
||||
? JSON.stringify(postBody)
|
||||
: null,
|
||||
signal: controller.signal,
|
||||
openWhenHidden: true,
|
||||
|
||||
onopen: async (res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
console.log(`[TTFB] response:headers request_id=${requestID} elapsed_ms=${(performance.now() - sentAt).toFixed(1)}`);
|
||||
isLoading.value = false;
|
||||
},
|
||||
|
||||
onmessage: (ev) => {
|
||||
if (myGeneration !== streamGeneration) return
|
||||
const parsed = JSON.parse(ev.data);
|
||||
// Log first answer chunk for end-to-end TTFB measurement.
|
||||
// Filter by event type so non-answer events (references, tool
|
||||
// calls, etc.) don't count as the "first token" arrival.
|
||||
if (!firstAnswerLogged && (parsed?.response_type === 'answer' || parsed?.type === 'answer')) {
|
||||
firstAnswerLogged = true;
|
||||
console.log(`[TTFB] response:first_answer request_id=${requestID} elapsed_ms=${(performance.now() - sentAt).toFixed(1)}`);
|
||||
}
|
||||
buffer.push(parsed); // 数据存入缓冲
|
||||
// 执行自定义处理
|
||||
if (chunkHandler) {
|
||||
chunkHandler(parsed);
|
||||
}
|
||||
},
|
||||
|
||||
onerror: (err) => {
|
||||
throw new Error(`${i18n.global.t('error.streamFailed')}: ${err}`);
|
||||
},
|
||||
|
||||
onclose: () => {
|
||||
stopStream();
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : String(err)
|
||||
stopStream()
|
||||
}
|
||||
}
|
||||
|
||||
let chunkHandler: ((data: any) => void) | null = null
|
||||
// 注册块处理器
|
||||
const onChunk = (handler: (data: any) => void) => {
|
||||
chunkHandler = handler
|
||||
}
|
||||
|
||||
|
||||
// 停止流
|
||||
const stopStream = () => {
|
||||
streamGeneration++
|
||||
controller.abort();
|
||||
controller = new AbortController(); // 重置控制器(如需重新发起)
|
||||
isStreaming.value = false;
|
||||
isLoading.value = false;
|
||||
}
|
||||
|
||||
// 组件卸载时自动清理
|
||||
onUnmounted(stopStream)
|
||||
|
||||
return {
|
||||
output, // 显示内容
|
||||
isStreaming, // 是否在流式传输中
|
||||
isLoading, // 初始连接状态
|
||||
error,
|
||||
lastStreamRequest,
|
||||
onChunk,
|
||||
startStream, // 启动流
|
||||
stopStream // 手动停止
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { del, get, getDown, postUpload } from '@/utils/request';
|
||||
|
||||
export type TemporaryAttachmentStatus = 'uploaded' | 'processing' | 'ready' | 'failed';
|
||||
|
||||
export interface TemporaryAttachment {
|
||||
id: string;
|
||||
session_id: string;
|
||||
file_name: string;
|
||||
file_type: string;
|
||||
file_size: number;
|
||||
mime_type?: string;
|
||||
status: TemporaryAttachmentStatus;
|
||||
token_count: number;
|
||||
chunk_count: number;
|
||||
image_refs?: Array<{ original_ref?: string; url: string; mime_type?: string }>;
|
||||
error_message?: string;
|
||||
expires_at: string;
|
||||
}
|
||||
|
||||
interface AttachmentResponse {
|
||||
success: boolean;
|
||||
data: TemporaryAttachment;
|
||||
}
|
||||
|
||||
export function uploadTemporaryAttachment(
|
||||
sessionId: string,
|
||||
file: File,
|
||||
agentId?: string,
|
||||
agentSourceTenantId?: string,
|
||||
parserEngine?: string,
|
||||
onProgress?: (percent: number) => void,
|
||||
): Promise<AttachmentResponse> {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
if (agentId) form.append('agent_id', agentId);
|
||||
if (agentSourceTenantId) form.append('agent_source_tenant_id', agentSourceTenantId);
|
||||
if (parserEngine) form.append('parser_engine', parserEngine);
|
||||
return postUpload(
|
||||
`/api/v1/sessions/${sessionId}/attachments`,
|
||||
form,
|
||||
(event) => {
|
||||
if (event.total) onProgress?.(Math.round((event.loaded * 100) / event.total));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function getTemporaryAttachment(sessionId: string, attachmentId: string): Promise<AttachmentResponse> {
|
||||
return get(`/api/v1/sessions/${sessionId}/attachments/${attachmentId}`);
|
||||
}
|
||||
|
||||
export function previewTemporaryAttachment(sessionId: string, attachmentId: string): Promise<Blob> {
|
||||
return getDown(`/api/v1/sessions/${sessionId}/attachments/${attachmentId}/preview`);
|
||||
}
|
||||
|
||||
export function deleteTemporaryAttachment(sessionId: string, attachmentId: string): Promise<void> {
|
||||
return del(`/api/v1/sessions/${sessionId}/attachments/${attachmentId}`);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// Chunker debug / preview API. Single endpoint that runs the adaptive
|
||||
// chunker over a sample text without touching DB or embeddings. Used by
|
||||
// the KB editor debug panel.
|
||||
|
||||
import { post } from '../../utils/request'
|
||||
import type {
|
||||
PreviewChunkingRequest,
|
||||
PreviewChunkingResponse
|
||||
} from '../../types/chunker'
|
||||
|
||||
export function previewChunking(
|
||||
body: PreviewChunkingRequest
|
||||
): Promise<{ success: boolean; data: PreviewChunkingResponse }> {
|
||||
return post('/api/v1/chunker/preview', body)
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { get, post, put, del } from '../../utils/request'
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export interface DataSource {
|
||||
id: string
|
||||
tenant_id: number
|
||||
knowledge_base_id: string
|
||||
name: string
|
||||
type: string
|
||||
config: any
|
||||
sync_schedule: string
|
||||
sync_mode: 'incremental' | 'full'
|
||||
status: 'active' | 'paused' | 'error'
|
||||
conflict_strategy: 'overwrite' | 'skip'
|
||||
sync_deletions: boolean
|
||||
last_sync_at: string | null
|
||||
last_sync_result: any
|
||||
error_message: string
|
||||
// Single-field "credentials" map from the main response — DataSource
|
||||
// credentials are a per-connector atomic set.
|
||||
credentials?: { credentials: { configured: boolean } }
|
||||
created_at: string
|
||||
updated_at: string
|
||||
latest_sync_log?: SyncLog
|
||||
}
|
||||
|
||||
/**
|
||||
* One user-facing failure sample. The backend sends a stable i18n `code`
|
||||
* (+ interpolation `params`) so the UI localises it to the viewer's language;
|
||||
* `message` is a fallback when no i18n key exists (old logs decode into it).
|
||||
*/
|
||||
export interface SyncItemError {
|
||||
title?: string
|
||||
code?: string
|
||||
params?: Record<string, string>
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface SyncResultDetail {
|
||||
total?: number
|
||||
created?: number
|
||||
updated?: number
|
||||
deleted?: number
|
||||
skipped?: number
|
||||
failed?: number
|
||||
/** Per-item failure samples (capped); localised in the sync-log drawer. */
|
||||
errors?: SyncItemError[]
|
||||
}
|
||||
|
||||
export interface SyncLog {
|
||||
id: string
|
||||
data_source_id: string
|
||||
status: 'running' | 'success' | 'partial' | 'failed' | 'canceled'
|
||||
started_at: string
|
||||
finished_at: string | null
|
||||
items_total: number
|
||||
items_created: number
|
||||
items_updated: number
|
||||
items_deleted: number
|
||||
items_skipped: number
|
||||
items_failed: number
|
||||
error_message: string
|
||||
result?: SyncResultDetail
|
||||
}
|
||||
|
||||
export interface ConnectorMeta {
|
||||
type: string
|
||||
name: string
|
||||
description: string
|
||||
icon: string
|
||||
priority: number
|
||||
auth_type: string
|
||||
capabilities: string[]
|
||||
}
|
||||
|
||||
export interface Resource {
|
||||
external_id: string
|
||||
name: string
|
||||
type: string
|
||||
description: string
|
||||
url: string
|
||||
parent_id?: string
|
||||
has_children?: boolean
|
||||
}
|
||||
|
||||
// --- API calls ---
|
||||
|
||||
export function getConnectorTypes() {
|
||||
return get('/api/v1/datasource/types')
|
||||
}
|
||||
|
||||
export function listDataSources(kbId: string) {
|
||||
return get(`/api/v1/datasource?kb_id=${encodeURIComponent(kbId)}`)
|
||||
}
|
||||
|
||||
export function getDataSource(id: string) {
|
||||
return get(`/api/v1/datasource/${id}`)
|
||||
}
|
||||
|
||||
export function createDataSource(data: Partial<DataSource>) {
|
||||
return post('/api/v1/datasource', data)
|
||||
}
|
||||
|
||||
export function updateDataSource(id: string, data: Partial<DataSource>) {
|
||||
return put(`/api/v1/datasource/${id}`, data)
|
||||
}
|
||||
|
||||
export function deleteDataSource(id: string) {
|
||||
return del(`/api/v1/datasource/${id}`)
|
||||
}
|
||||
|
||||
export function validateConnection(id: string) {
|
||||
return post(`/api/v1/datasource/${id}/validate`, {})
|
||||
}
|
||||
|
||||
// Validate credentials without persisting (for "Test Connection" during creation)
|
||||
export function validateCredentials(type: string, credentials: Record<string, any>) {
|
||||
return post('/api/v1/datasource/validate-credentials', { type, credentials })
|
||||
}
|
||||
|
||||
// listResources lists selectable resources for a data source. Pass parentId to
|
||||
// lazily load the direct children of a resource (e.g. expanding a Feishu wiki
|
||||
// space/node), which avoids traversing the whole tree up front.
|
||||
export function listResources(id: string, parentId?: string) {
|
||||
const query = parentId ? `?parent_id=${encodeURIComponent(parentId)}` : ''
|
||||
return get(`/api/v1/datasource/${id}/resources${query}`, { timeout: 120000 })
|
||||
}
|
||||
|
||||
// resolveResourceAncestors returns the ExternalIDs of every parent that must be
|
||||
// expanded to reveal the given (possibly deeply nested) selections in a lazily
|
||||
// loaded picker. Used when editing a data source to restore an existing selection.
|
||||
export function resolveResourceAncestors(id: string, resourceIds: string[]) {
|
||||
return post(`/api/v1/datasource/${id}/resource-ancestors`, { resource_ids: resourceIds }, { timeout: 120000 })
|
||||
}
|
||||
|
||||
export function triggerSync(id: string) {
|
||||
return post(`/api/v1/datasource/${id}/sync`, {})
|
||||
}
|
||||
|
||||
export function pauseDataSource(id: string) {
|
||||
return post(`/api/v1/datasource/${id}/pause`, {})
|
||||
}
|
||||
|
||||
export function resumeDataSource(id: string) {
|
||||
return post(`/api/v1/datasource/${id}/resume`, {})
|
||||
}
|
||||
|
||||
export function getSyncLogs(id: string, limit = 20, offset = 0) {
|
||||
return get(`/api/v1/datasource/${id}/logs?limit=${limit}&offset=${offset}`)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Data source credential subresource. Unlike the other three resources,
|
||||
// DataSource exposes a single logical field "credentials" because connector
|
||||
// auth is a per-connector atomic map. See internal/handler/dto/datasource.go.
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
export interface DataSourceCredentialsResponse {
|
||||
fields: {
|
||||
credentials: { configured: boolean }
|
||||
}
|
||||
}
|
||||
|
||||
export async function putDataSourceCredentials(
|
||||
id: string,
|
||||
credentials: Record<string, unknown>,
|
||||
): Promise<DataSourceCredentialsResponse> {
|
||||
const response: any = await put(`/api/v1/datasource/${id}/credentials`, { credentials })
|
||||
return (response.data ?? response) as DataSourceCredentialsResponse
|
||||
}
|
||||
|
||||
export async function deleteDataSourceCredentials(id: string): Promise<void> {
|
||||
await del(`/api/v1/datasource/${id}/credentials/credentials`)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { fetchEventSource } from '@microsoft/fetch-event-source';
|
||||
import { getApiBaseUrl } from '@/utils/api-base';
|
||||
|
||||
export interface DigitalHumanTrialInput {
|
||||
version: number;
|
||||
theme: string;
|
||||
advice: string;
|
||||
}
|
||||
|
||||
export interface DigitalHumanTrialEvent {
|
||||
type: 'progress' | 'delta' | 'complete' | 'error';
|
||||
version: number;
|
||||
model_id: string;
|
||||
progress: number;
|
||||
stage: string;
|
||||
content?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function streamDigitalHumanTrial(
|
||||
id: string,
|
||||
input: DigitalHumanTrialInput,
|
||||
signal: AbortSignal,
|
||||
onEvent: (event: DigitalHumanTrialEvent) => void,
|
||||
): Promise<void> {
|
||||
const token = localStorage.getItem('weknora_token');
|
||||
if (!token) throw new Error('登录已过期,请重新登录');
|
||||
const tenantID = localStorage.getItem('weknora_selected_tenant_id');
|
||||
let completed = false;
|
||||
await fetchEventSource(`${getApiBaseUrl()}/api/v1/digital-humans/${encodeURIComponent(id)}/trial`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
...(tenantID ? { 'X-Tenant-ID': tenantID } : {}),
|
||||
},
|
||||
body: JSON.stringify(input),
|
||||
signal,
|
||||
openWhenHidden: true,
|
||||
async onopen(response) {
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => null);
|
||||
throw new Error(body?.error?.message || body?.message || `试写请求失败(${response.status})`);
|
||||
}
|
||||
if (!response.headers.get('content-type')?.includes('text/event-stream')) {
|
||||
throw new Error('试写连接异常,请刷新后重试');
|
||||
}
|
||||
},
|
||||
onmessage(message) {
|
||||
if (message.event !== 'trial' || !message.data) return;
|
||||
const event = JSON.parse(message.data) as DigitalHumanTrialEvent;
|
||||
if (event.version !== input.version) throw new Error('试写版本不一致,请刷新后重试');
|
||||
onEvent(event);
|
||||
if (event.type === 'error') throw new Error(event.error || '试写失败,请重试');
|
||||
if (event.type === 'complete') completed = true;
|
||||
},
|
||||
onclose() {
|
||||
if (!completed && !signal.aborted) throw new Error('试写连接已中断或超时,请重试');
|
||||
},
|
||||
onerror(error) {
|
||||
// A generation must never be replayed automatically after a disconnect.
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,771 @@
|
||||
import { get, post, put, del } from '@/utils/request'
|
||||
import { resolveEmbedBaseUrl } from '@/utils/embedBaseUrl'
|
||||
|
||||
export interface EmbedChannel {
|
||||
id: string
|
||||
tenant_id: number
|
||||
agent_id: string
|
||||
name: string
|
||||
enabled: boolean
|
||||
allowed_origins: string[]
|
||||
welcome_message: string
|
||||
rate_limit_per_minute: number
|
||||
rate_limit_per_day?: number
|
||||
primary_color?: string
|
||||
page_title?: string
|
||||
header_title_mode?: HeaderTitleMode
|
||||
show_suggested_questions?: boolean
|
||||
widget_position?: WidgetPosition
|
||||
allow_web_search?: boolean
|
||||
allow_file_upload?: boolean
|
||||
default_locale?: string
|
||||
webhook_url?: string
|
||||
has_webhook_secret?: boolean
|
||||
publish_token?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface EmbedChannelPublicConfig {
|
||||
channel_id: string
|
||||
name: string
|
||||
display_title?: string
|
||||
knowledge_base_ids?: string[]
|
||||
agent_id: string
|
||||
agent_name?: string
|
||||
agent_avatar?: string
|
||||
welcome_message: string
|
||||
primary_color?: string
|
||||
page_title?: string
|
||||
header_title_mode?: HeaderTitleMode
|
||||
show_suggested_questions?: boolean
|
||||
widget_position?: WidgetPosition
|
||||
allow_web_search?: boolean
|
||||
allow_file_upload?: boolean
|
||||
agent_web_search_enabled?: boolean
|
||||
agent_image_upload_enabled?: boolean
|
||||
default_locale?: string
|
||||
}
|
||||
|
||||
export type EmbedLocaleTag = 'zh-CN' | 'en-US' | 'ko-KR' | 'ru-RU' | ''
|
||||
|
||||
export interface EmbedChannelStats {
|
||||
session_count: number
|
||||
}
|
||||
|
||||
export type HeaderTitleMode = 'channel' | 'session'
|
||||
export type WidgetPosition = 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left'
|
||||
|
||||
/** Prefix of short-lived embed session tokens minted by the backend. */
|
||||
export const EMBED_SESSION_TOKEN_PREFIX = 'ems_'
|
||||
|
||||
/** localStorage key prefix for persisted embed chat sessions (per channel). */
|
||||
export const EMBED_CHAT_SESSION_STORAGE_PREFIX = 'weknora-embed-session:'
|
||||
|
||||
/** localStorage key prefix for anonymous embed visitor ids (per channel). */
|
||||
export const EMBED_VISITOR_STORAGE_PREFIX = 'weknora-embed-visitor:'
|
||||
|
||||
export function embedVisitorStorageKey(channelId: string): string {
|
||||
return `${EMBED_VISITOR_STORAGE_PREFIX}${channelId}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成符合 UUID v4 格式的随机 ID。
|
||||
* 优先使用 crypto.randomUUID(),在不支持的浏览器中回退到 crypto.getRandomValues(),
|
||||
* 最终兜底使用 Math.random()。
|
||||
*/
|
||||
export function generateUUID(): string {
|
||||
// 优先使用原生 API(HTTPS 环境下可用)
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
|
||||
// 回退:使用 crypto.getRandomValues()
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') {
|
||||
const buf = new Uint8Array(16)
|
||||
crypto.getRandomValues(buf)
|
||||
// 设置 UUID v4 标志位
|
||||
buf[6] = (buf[6] & 0x0f) | 0x40
|
||||
buf[8] = (buf[8] & 0x3f) | 0x80
|
||||
const hex = Array.from(buf, (b) => b.toString(16).padStart(2, '0'))
|
||||
return [
|
||||
hex[0], hex[1], hex[2], hex[3], '-',
|
||||
hex[4], hex[5], '-',
|
||||
hex[6], hex[7], '-',
|
||||
hex[8], hex[9], '-',
|
||||
hex[10], hex[11], hex[12], hex[13], hex[14], hex[15],
|
||||
].join('')
|
||||
}
|
||||
|
||||
// 最终兜底:Math.random()(随机性较弱,仅保证格式正确)
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0
|
||||
const v = c === 'x' ? r : (r & 0x3) | 0x8
|
||||
return v.toString(16)
|
||||
})
|
||||
}
|
||||
|
||||
/** Stable anonymous id for this browser on the given channel. */
|
||||
export function getOrCreateEmbedVisitorId(channelId: string): string {
|
||||
if (typeof localStorage === 'undefined' || !channelId) {
|
||||
return generateUUID()
|
||||
}
|
||||
const key = embedVisitorStorageKey(channelId)
|
||||
try {
|
||||
const existing = localStorage.getItem(key)?.trim()
|
||||
if (existing) return existing
|
||||
const id = generateUUID()
|
||||
localStorage.setItem(key, id)
|
||||
return id
|
||||
} catch {
|
||||
return generateUUID()
|
||||
}
|
||||
}
|
||||
|
||||
export function embedChatSessionStorageKey(channelId: string): string {
|
||||
return `${EMBED_CHAT_SESSION_STORAGE_PREFIX}${channelId}`
|
||||
}
|
||||
|
||||
/** Drop a persisted embed chat session so the next load starts fresh. */
|
||||
export function clearEmbedStoredChatSession(channelId: string): void {
|
||||
if (typeof localStorage === 'undefined') return
|
||||
try {
|
||||
localStorage.removeItem(embedChatSessionStorageKey(channelId))
|
||||
} catch {
|
||||
// localStorage may be unavailable in private mode.
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear a stored chat session when it was created under a different agent binding. */
|
||||
export function clearEmbedStoredChatSessionIfAgentMismatch(channelId: string, agentId: string): void {
|
||||
if (typeof localStorage === 'undefined' || !channelId || !agentId) return
|
||||
try {
|
||||
const raw = localStorage.getItem(embedChatSessionStorageKey(channelId))
|
||||
if (!raw) return
|
||||
const parsed = JSON.parse(raw) as { agentId?: string }
|
||||
if (parsed?.agentId && parsed.agentId !== agentId) {
|
||||
localStorage.removeItem(embedChatSessionStorageKey(channelId))
|
||||
}
|
||||
} catch {
|
||||
// Malformed entry — remove so bootstrap can recover.
|
||||
localStorage.removeItem(embedChatSessionStorageKey(channelId))
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a token is already a short-lived session token (secure mode). */
|
||||
export function isEmbedSessionToken(token: string): boolean {
|
||||
return typeof token === 'string' && token.trim().startsWith(EMBED_SESSION_TOKEN_PREFIX)
|
||||
}
|
||||
|
||||
export async function listEmbedChannels(agentId: string) {
|
||||
return get<{ success: boolean; data: EmbedChannel[] }>(`/api/v1/agents/${agentId}/embed-channels`)
|
||||
}
|
||||
|
||||
export async function listAllEmbedChannels() {
|
||||
return get<{ success: boolean; data: EmbedChannel[] }>('/api/v1/embed-channels')
|
||||
}
|
||||
|
||||
export async function createEmbedChannel(agentId: string, data: Partial<EmbedChannel>) {
|
||||
return post<{ success: boolean; data: EmbedChannel }>(`/api/v1/agents/${agentId}/embed-channels`, data)
|
||||
}
|
||||
|
||||
export async function getEmbedChannel(channelId: string) {
|
||||
return get<{ success: boolean; data: EmbedChannel }>(`/api/v1/embed-channels/${channelId}`)
|
||||
}
|
||||
|
||||
export async function updateEmbedChannel(channelId: string, data: Partial<EmbedChannel>) {
|
||||
return put<{ success: boolean; data: EmbedChannel }>(`/api/v1/embed-channels/${channelId}`, data)
|
||||
}
|
||||
|
||||
export async function deleteEmbedChannel(channelId: string) {
|
||||
return del(`/api/v1/embed-channels/${channelId}`)
|
||||
}
|
||||
|
||||
export async function rotateEmbedToken(channelId: string) {
|
||||
return post<{ success: boolean; data: EmbedChannel }>(`/api/v1/embed-channels/${channelId}/rotate-token`, {})
|
||||
}
|
||||
|
||||
/** Short-lived session token for management UI preview (JWT auth, no publish token needed). */
|
||||
export async function getEmbedChannelStats(channelId: string) {
|
||||
return get<{ success: boolean; data: EmbedChannelStats }>(
|
||||
`/api/v1/embed-channels/${channelId}/stats`,
|
||||
)
|
||||
}
|
||||
|
||||
export async function issueEmbedPreviewSession(channelId: string) {
|
||||
return post<{ success: boolean; data: { session_token: string; expires_in: number } }>(
|
||||
`/api/v1/embed-channels/${channelId}/preview-session`,
|
||||
{},
|
||||
)
|
||||
}
|
||||
|
||||
export interface SuggestedQuestion {
|
||||
question: string
|
||||
source?: string
|
||||
}
|
||||
|
||||
export interface EmbedMessageSuggestionItem {
|
||||
id: string
|
||||
text: string
|
||||
category?: string
|
||||
source: string
|
||||
}
|
||||
|
||||
export interface EmbedMessageSuggestionSet {
|
||||
id: string
|
||||
status: 'generating' | 'ready' | 'suppressed' | 'failed'
|
||||
allow_regenerate: boolean
|
||||
questions: EmbedMessageSuggestionItem[]
|
||||
}
|
||||
|
||||
export async function getEmbedChunkById(channelId: string, token: string, chunkId: string) {
|
||||
return get<{ success: boolean; data: { content?: string } }>(
|
||||
`/api/v1/embed/${channelId}/chunks/${chunkId}`,
|
||||
{ headers: { Authorization: `Embed ${token}` } },
|
||||
)
|
||||
}
|
||||
|
||||
export async function getEmbedSuggestedQuestions(channelId: string, token: string, limit?: number) {
|
||||
// Omit limit to let the channel agent's configured starter count apply.
|
||||
const qs = typeof limit === 'number' && limit > 0 ? `?limit=${limit}` : ''
|
||||
return get<{ success: boolean; data: { questions: SuggestedQuestion[] } }>(
|
||||
`/api/v1/embed/${channelId}/suggested-questions${qs}`,
|
||||
{ headers: { Authorization: `Embed ${token}` } },
|
||||
)
|
||||
}
|
||||
|
||||
export async function ensureEmbedMessageSuggestions(
|
||||
channelId: string,
|
||||
token: string,
|
||||
sessionId: string,
|
||||
messageId: string,
|
||||
sessionSig: string,
|
||||
visitorId: string,
|
||||
regenerate = false,
|
||||
) {
|
||||
return post<{ success: boolean; data: EmbedMessageSuggestionSet }>(
|
||||
`/api/v1/embed/${channelId}/sessions/${sessionId}/messages/${messageId}/suggestions`,
|
||||
{ regenerate },
|
||||
{ headers: embedSessionHeaders(token, sessionSig, visitorId) },
|
||||
)
|
||||
}
|
||||
|
||||
export async function getEmbedMessageSuggestions(
|
||||
channelId: string,
|
||||
token: string,
|
||||
sessionId: string,
|
||||
messageId: string,
|
||||
sessionSig: string,
|
||||
visitorId: string,
|
||||
) {
|
||||
return get<{ success: boolean; data: EmbedMessageSuggestionSet }>(
|
||||
`/api/v1/embed/${channelId}/sessions/${sessionId}/messages/${messageId}/suggestions`,
|
||||
{ headers: embedSessionHeaders(token, sessionSig, visitorId) },
|
||||
)
|
||||
}
|
||||
|
||||
export async function recordEmbedMessageSuggestionEvent(
|
||||
channelId: string,
|
||||
token: string,
|
||||
sessionId: string,
|
||||
sessionSig: string,
|
||||
visitorId: string,
|
||||
suggestionSetId: string,
|
||||
eventType: 'impression' | 'click' | 'dismiss',
|
||||
questionId = '',
|
||||
) {
|
||||
return post(
|
||||
`/api/v1/embed/${channelId}/sessions/${sessionId}/suggestion-events`,
|
||||
{ suggestion_set_id: suggestionSetId, question_id: questionId, event_type: eventType },
|
||||
{ headers: embedSessionHeaders(token, sessionSig, visitorId) },
|
||||
)
|
||||
}
|
||||
|
||||
export async function getEmbedConfig(channelId: string, token: string) {
|
||||
return get<{ success: boolean; data: EmbedChannelPublicConfig }>(
|
||||
`/api/v1/embed/${channelId}/config`,
|
||||
{ headers: { Authorization: `Embed ${token}` } },
|
||||
)
|
||||
}
|
||||
|
||||
export async function createEmbedSession(channelId: string, token: string) {
|
||||
return post<{ success: boolean; data: { id: string; sig: string } }>(
|
||||
`/api/v1/embed/${channelId}/sessions`,
|
||||
{},
|
||||
{ headers: { Authorization: `Embed ${token}` } },
|
||||
)
|
||||
}
|
||||
|
||||
export async function exchangeEmbedSession(channelId: string, publishToken: string) {
|
||||
return post<{ success: boolean; data: { session_token: string; expires_in: number } }>(
|
||||
`/api/v1/embed/${channelId}/exchange`,
|
||||
{},
|
||||
{ headers: { Authorization: `Embed ${publishToken}` } },
|
||||
)
|
||||
}
|
||||
|
||||
export async function stopEmbedSession(
|
||||
channelId: string,
|
||||
token: string,
|
||||
sessionId: string,
|
||||
messageId: string,
|
||||
sessionSig: string,
|
||||
) {
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Embed ${token}`,
|
||||
'X-Embed-Session': sessionSig,
|
||||
}
|
||||
return post(`/api/v1/embed/${channelId}/sessions/${sessionId}/stop`, { message_id: messageId }, { headers })
|
||||
}
|
||||
|
||||
function embedSessionHeaders(token: string, sessionSig: string, visitorId?: string): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Embed ${token}`,
|
||||
'X-Embed-Session': sessionSig,
|
||||
}
|
||||
const visitor = visitorId?.trim()
|
||||
if (visitor) headers['X-Embed-Visitor'] = visitor
|
||||
return headers
|
||||
}
|
||||
|
||||
export async function resolveEmbedMCPOAuth(
|
||||
channelId: string,
|
||||
token: string,
|
||||
sessionId: string,
|
||||
sessionSig: string,
|
||||
visitorId: string,
|
||||
pendingId: string,
|
||||
body: { service_id: string; decision?: 'authorize' | 'cancel' },
|
||||
): Promise<void> {
|
||||
await post(
|
||||
`/api/v1/embed/${channelId}/sessions/${encodeURIComponent(sessionId)}/mcp-oauth-resolutions/${encodeURIComponent(pendingId)}`,
|
||||
body,
|
||||
{ headers: embedSessionHeaders(token, sessionSig, visitorId) },
|
||||
)
|
||||
}
|
||||
|
||||
export async function cancelEmbedMCPOAuth(
|
||||
channelId: string,
|
||||
token: string,
|
||||
sessionId: string,
|
||||
sessionSig: string,
|
||||
visitorId: string,
|
||||
pendingId: string,
|
||||
): Promise<void> {
|
||||
await post(
|
||||
`/api/v1/embed/${channelId}/sessions/${encodeURIComponent(sessionId)}/mcp-oauth-resolutions/${encodeURIComponent(pendingId)}/cancel`,
|
||||
{},
|
||||
{ headers: embedSessionHeaders(token, sessionSig, visitorId) },
|
||||
)
|
||||
}
|
||||
|
||||
export async function getEmbedMCPOAuthAuthorizeURL(
|
||||
channelId: string,
|
||||
token: string,
|
||||
sessionId: string,
|
||||
sessionSig: string,
|
||||
visitorId: string,
|
||||
serviceId: string,
|
||||
body: { redirect_uri: string; frontend_redirect?: string },
|
||||
): Promise<{ authorizationUrl: string; authorizationAttempt: string }> {
|
||||
const response: any = await post(
|
||||
`/api/v1/embed/${channelId}/sessions/${encodeURIComponent(sessionId)}/mcp-services/${encodeURIComponent(serviceId)}/oauth/authorize-url`,
|
||||
body,
|
||||
{ headers: embedSessionHeaders(token, sessionSig, visitorId) },
|
||||
)
|
||||
const data = response.data ?? response
|
||||
return {
|
||||
authorizationUrl: data?.authorization_url ?? '',
|
||||
authorizationAttempt: data?.authorization_attempt ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
export async function getEmbedMCPOAuthStatus(
|
||||
channelId: string,
|
||||
token: string,
|
||||
sessionId: string,
|
||||
sessionSig: string,
|
||||
visitorId: string,
|
||||
serviceId: string,
|
||||
authorizationAttempt?: string,
|
||||
): Promise<boolean> {
|
||||
const query = authorizationAttempt
|
||||
? `?authorization_attempt=${encodeURIComponent(authorizationAttempt)}`
|
||||
: ''
|
||||
const response: any = await get(
|
||||
`/api/v1/embed/${channelId}/sessions/${encodeURIComponent(sessionId)}/mcp-services/${encodeURIComponent(serviceId)}/oauth/status${query}`,
|
||||
{ headers: embedSessionHeaders(token, sessionSig, visitorId) },
|
||||
)
|
||||
return Boolean((response.data ?? response)?.authorized)
|
||||
}
|
||||
|
||||
export async function resolveEmbedToolApproval(
|
||||
channelId: string,
|
||||
token: string,
|
||||
sessionId: string,
|
||||
sessionSig: string,
|
||||
visitorId: string,
|
||||
pendingId: string,
|
||||
body: { decision: 'approve' | 'reject'; modified_args?: Record<string, unknown>; reason?: string },
|
||||
): Promise<void> {
|
||||
await post(
|
||||
`/api/v1/embed/${channelId}/sessions/${encodeURIComponent(sessionId)}/tool-approvals/${encodeURIComponent(pendingId)}`,
|
||||
body,
|
||||
{ headers: embedSessionHeaders(token, sessionSig, visitorId) },
|
||||
)
|
||||
}
|
||||
|
||||
export async function getEmbedMessageList(
|
||||
channelId: string,
|
||||
token: string,
|
||||
sessionId: string,
|
||||
limit: number,
|
||||
beforeTime?: string,
|
||||
sig?: string,
|
||||
) {
|
||||
const params = new URLSearchParams({ limit: String(limit) })
|
||||
if (beforeTime) {
|
||||
params.set('before_time', beforeTime)
|
||||
}
|
||||
const headers: Record<string, string> = { Authorization: `Embed ${token}` }
|
||||
// Signed session handle — sent as a header so it never lands in URL/access logs.
|
||||
if (sig) headers['X-Embed-Session'] = sig
|
||||
return get<{ success: boolean; data: unknown[] }>(
|
||||
`/api/v1/embed/${channelId}/messages/${sessionId}/load?${params.toString()}`,
|
||||
{ headers },
|
||||
)
|
||||
}
|
||||
|
||||
const EMBED_MSG_SOURCE = 'weknora-embed'
|
||||
const EMBED_HOST_SOURCE = 'weknora-host'
|
||||
|
||||
// The exact parent origin, learned from the first trusted host message
|
||||
// (trust-on-first-use). Once known, every inbound/outbound message is pinned to
|
||||
// it so conversation content is never broadcast to an unexpected window.
|
||||
let verifiedParentOrigin = ''
|
||||
|
||||
function referrerParentOrigin(): string {
|
||||
if (window.parent === window) return ''
|
||||
try {
|
||||
if (document.referrer) {
|
||||
return new URL(document.referrer).origin
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed referrer
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/** Best-known parent origin: verified handshake first, then referrer. */
|
||||
function knownParentOrigin(): string {
|
||||
return verifiedParentOrigin || referrerParentOrigin()
|
||||
}
|
||||
|
||||
function isTrustedParentMessage(event: MessageEvent): boolean {
|
||||
if (window.parent === window) return false
|
||||
if (event.source !== window.parent) return false
|
||||
if (!event.data || event.data.source !== EMBED_HOST_SOURCE) return false
|
||||
if (typeof event.origin !== 'string' || event.origin === 'null') return false
|
||||
const expected = knownParentOrigin()
|
||||
if (expected) {
|
||||
if (event.origin !== expected) return false
|
||||
} else {
|
||||
// First trusted handshake with no referrer hint: pin to this origin.
|
||||
verifiedParentOrigin = event.origin
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Post a message to the host page.
|
||||
* `sensitive` payloads (conversation content) are dropped when the parent
|
||||
* origin is unknown rather than broadcast to '*'. Non-sensitive handshake
|
||||
* messages (bootstrap_request/ready) may fall back to '*' so token handoff can
|
||||
* still bootstrap when the referrer is stripped.
|
||||
*/
|
||||
function postToParent(payload: Record<string, unknown>, opts?: { sensitive?: boolean }) {
|
||||
if (window.parent === window) return
|
||||
const target = knownParentOrigin()
|
||||
if (!target) {
|
||||
if (opts?.sensitive) return
|
||||
window.parent.postMessage({ source: EMBED_MSG_SOURCE, ...payload }, '*')
|
||||
return
|
||||
}
|
||||
window.parent.postMessage({ source: EMBED_MSG_SOURCE, ...payload }, target)
|
||||
}
|
||||
|
||||
/** Notify the parent page that the embed widget is ready. */
|
||||
export function postEmbedReady(channelId: string) {
|
||||
postToParent({ type: 'ready', channel_id: channelId })
|
||||
}
|
||||
|
||||
/** Request a publish token from the parent host page. */
|
||||
export function postEmbedBootstrapRequest(channelId: string) {
|
||||
postToParent({ type: 'bootstrap_request', channel_id: channelId })
|
||||
}
|
||||
|
||||
/** Notify the parent page when a user message is sent. */
|
||||
export function postEmbedMessageSent(channelId: string, sessionId: string, query: string) {
|
||||
postToParent(
|
||||
{
|
||||
type: 'message_sent',
|
||||
channel_id: channelId,
|
||||
session_id: sessionId,
|
||||
query,
|
||||
},
|
||||
{ sensitive: true },
|
||||
)
|
||||
}
|
||||
|
||||
/** Relay a chat event to the channel webhook (server-side, best-effort). */
|
||||
export function relayEmbedWebhookEvent(
|
||||
channelId: string,
|
||||
token: string,
|
||||
sessionId: string,
|
||||
sessionSig: string,
|
||||
body: { type: 'message_sent' | 'message_received'; query?: string; content?: string },
|
||||
) {
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Embed ${token}`,
|
||||
'X-Embed-Session': sessionSig,
|
||||
}
|
||||
void post(
|
||||
`/api/v1/embed/${channelId}/sessions/${sessionId}/events`,
|
||||
{ type: body.type, session_id: sessionId, query: body.query, content: body.content },
|
||||
{ headers },
|
||||
).catch(() => {
|
||||
// Webhook relay must not block chat.
|
||||
})
|
||||
}
|
||||
|
||||
/** Notify the parent page when an assistant reply completes. */
|
||||
export function postEmbedMessageReceived(channelId: string, sessionId: string, content: string) {
|
||||
postToParent(
|
||||
{
|
||||
type: 'message_received',
|
||||
channel_id: channelId,
|
||||
session_id: sessionId,
|
||||
content,
|
||||
},
|
||||
{ sensitive: true },
|
||||
)
|
||||
}
|
||||
|
||||
export function parseEmbedTokenFromLocation(): string {
|
||||
const queryToken = new URLSearchParams(window.location.search).get('token')
|
||||
if (queryToken) return queryToken
|
||||
|
||||
const hash = window.location.hash.startsWith('#') ? window.location.hash.slice(1) : ''
|
||||
if (!hash) return ''
|
||||
return new URLSearchParams(hash).get('token') || ''
|
||||
}
|
||||
|
||||
export function buildEmbedURL(
|
||||
channelId: string,
|
||||
token?: string,
|
||||
opts?: { locale?: string; refreshKey?: number; baseUrl?: string },
|
||||
) {
|
||||
const base = safeBaseUrl(opts?.baseUrl) || resolveEmbedBaseUrl()
|
||||
let path = `${base}/embed/${encodeURIComponent(channelId)}`
|
||||
const params = new URLSearchParams()
|
||||
if (opts?.locale?.trim()) params.set('locale', opts.locale.trim())
|
||||
if (opts?.refreshKey) params.set('r', String(opts.refreshKey))
|
||||
const qs = params.toString()
|
||||
if (qs) path += `?${qs}`
|
||||
if (token) path += `#token=${encodeURIComponent(token)}`
|
||||
return path
|
||||
}
|
||||
|
||||
/** Escape a value for safe interpolation inside an HTML double-quoted attribute. */
|
||||
function escapeHtmlAttr(value: string): string {
|
||||
return String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
}
|
||||
|
||||
/** Validate that a base URL is a well-formed http(s) origin; fall back otherwise. */
|
||||
function safeBaseUrl(raw?: string): string {
|
||||
const fallback = window.location.origin
|
||||
if (!raw) return fallback
|
||||
try {
|
||||
const u = new URL(raw, window.location.href)
|
||||
if (u.protocol !== 'http:' && u.protocol !== 'https:') return fallback
|
||||
return u.origin
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
export function buildEmbedSnippet(channelId: string, token?: string) {
|
||||
// A bare iframe has no token-handoff host, so the snippet must carry the
|
||||
// publish token in the URL hash, otherwise the embed page cannot bootstrap.
|
||||
const url = escapeHtmlAttr(buildEmbedURL(channelId, token))
|
||||
return `<iframe src="${url}" style="width:400px;height:600px;border:none;border-radius:12px" allow="clipboard-write"></iframe>`
|
||||
}
|
||||
|
||||
export function buildWidgetSnippet(
|
||||
channelId: string,
|
||||
token: string,
|
||||
opts?: { primaryColor?: string; title?: string; position?: WidgetPosition; baseUrl?: string },
|
||||
) {
|
||||
const base = safeBaseUrl(opts?.baseUrl)
|
||||
const position = opts?.position || 'bottom-right'
|
||||
const attrs = [
|
||||
`src="${escapeHtmlAttr(`${base}/weknora-widget.js`)}"`,
|
||||
`data-channel="${escapeHtmlAttr(channelId)}"`,
|
||||
`data-token="${escapeHtmlAttr(token)}"`,
|
||||
`data-position="${escapeHtmlAttr(position)}"`,
|
||||
]
|
||||
if (opts?.primaryColor) attrs.push(`data-primary-color="${escapeHtmlAttr(opts.primaryColor)}"`)
|
||||
if (opts?.title) attrs.push(`data-title="${escapeHtmlAttr(opts.title)}"`)
|
||||
// Cross-origin embed: add data-sandbox="true" when using a dedicated embed subdomain.
|
||||
return `<script ${attrs.join('\n ')}></script>`
|
||||
}
|
||||
|
||||
/** Default placeholder for the integrator's own token-minting endpoint. */
|
||||
export const SECURE_TOKEN_ENDPOINT_PLACEHOLDER = 'https://your-backend.example.com/weknora/embed-token'
|
||||
|
||||
/**
|
||||
* Secure-mode widget snippet: the page references an endpoint on the
|
||||
* integrator's own backend (data-token-endpoint) instead of carrying the
|
||||
* publish token. The publish token never reaches the browser.
|
||||
*/
|
||||
export function buildSecureWidgetSnippet(
|
||||
channelId: string,
|
||||
opts?: { primaryColor?: string; title?: string; position?: WidgetPosition; baseUrl?: string; tokenEndpoint?: string },
|
||||
) {
|
||||
const base = safeBaseUrl(opts?.baseUrl)
|
||||
const position = opts?.position || 'bottom-right'
|
||||
const endpoint = opts?.tokenEndpoint || SECURE_TOKEN_ENDPOINT_PLACEHOLDER
|
||||
const attrs = [
|
||||
`src="${escapeHtmlAttr(`${base}/weknora-widget.js`)}"`,
|
||||
`data-channel="${escapeHtmlAttr(channelId)}"`,
|
||||
`data-token-endpoint="${escapeHtmlAttr(endpoint)}"`,
|
||||
`data-position="${escapeHtmlAttr(position)}"`,
|
||||
]
|
||||
if (opts?.primaryColor) attrs.push(`data-primary-color="${escapeHtmlAttr(opts.primaryColor)}"`)
|
||||
if (opts?.title) attrs.push(`data-title="${escapeHtmlAttr(opts.title)}"`)
|
||||
return `<script ${attrs.join('\n ')}></script>`
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side reference (Node/Express) for the token-minting endpoint that
|
||||
* secure mode points at. The publish token stays on the server; the endpoint
|
||||
* exchanges it for a short-lived session token and returns that to the page.
|
||||
*/
|
||||
export function buildSecureServerNodeExample(channelId: string, opts?: { baseUrl?: string }) {
|
||||
const base = safeBaseUrl(opts?.baseUrl)
|
||||
const exchangeUrl = `${base}/api/v1/embed/${channelId}/exchange`
|
||||
return [
|
||||
`// Node/Express — keep WEKNORA_PUBLISH_TOKEN only on the server (env var).`,
|
||||
`app.get('/weknora/embed-token', async (req, res) => {`,
|
||||
` // Only mint for logged-in visitors — e.g. session cookie or Bearer token.`,
|
||||
` const auth = req.headers.authorization || ''`,
|
||||
` const hasSession = Boolean(req.cookies?.session_id)`,
|
||||
` if (!hasSession && !auth.startsWith('Bearer ')) {`,
|
||||
` return res.status(401).json({ error: 'unauthorized' })`,
|
||||
` }`,
|
||||
` const r = await fetch('${exchangeUrl}', {`,
|
||||
` method: 'POST',`,
|
||||
` headers: {`,
|
||||
` Authorization: 'Embed ' + process.env.WEKNORA_PUBLISH_TOKEN,`,
|
||||
` Origin: 'https://your-site.example.com', // must match channel allowed_origins`,
|
||||
` },`,
|
||||
` })`,
|
||||
` const body = await r.json()`,
|
||||
` if (!body?.data?.session_token) return res.status(502).json({ error: 'mint failed' })`,
|
||||
` res.json({ token: body.data.session_token, expiresIn: body.data.expires_in })`,
|
||||
`})`,
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export function buildSecureServerGoExample(channelId: string, opts?: { baseUrl?: string }) {
|
||||
const base = safeBaseUrl(opts?.baseUrl)
|
||||
const exchangeUrl = `${base}/api/v1/embed/${channelId}/exchange`
|
||||
return [
|
||||
`// Go net/http — keep WEKNORA_PUBLISH_TOKEN only on the server (env var).`,
|
||||
`func embedTokenHandler(w http.ResponseWriter, r *http.Request) {`,
|
||||
` if r.Header.Get("Authorization") == "" && r.Header.Get("Cookie") == "" {`,
|
||||
` http.Error(w, \`{"error":"unauthorized"}\`, http.StatusUnauthorized)`,
|
||||
` return`,
|
||||
` }`,
|
||||
` req, _ := http.NewRequest(http.MethodPost, "${exchangeUrl}", nil)`,
|
||||
` req.Header.Set("Authorization", "Embed "+os.Getenv("WEKNORA_PUBLISH_TOKEN"))`,
|
||||
` req.Header.Set("Origin", "https://your-site.example.com") // must match channel allowed_origins`,
|
||||
` resp, err := http.DefaultClient.Do(req)`,
|
||||
` if err != nil || resp.StatusCode >= 300 {`,
|
||||
` http.Error(w, \`{"error":"mint failed"}\`, http.StatusBadGateway)`,
|
||||
` return`,
|
||||
` }`,
|
||||
` defer resp.Body.Close()`,
|
||||
` var body struct { Data struct { SessionToken string \`json:"session_token"\` ExpiresIn int \`json:"expires_in"\` } \`json:"data"\` }`,
|
||||
` if json.NewDecoder(resp.Body).Decode(&body) != nil || body.Data.SessionToken == "" {`,
|
||||
` http.Error(w, \`{"error":"mint failed"}\`, http.StatusBadGateway)`,
|
||||
` return`,
|
||||
` }`,
|
||||
` w.Header().Set("Content-Type", "application/json")`,
|
||||
` json.NewEncoder(w).Encode(map[string]any{"token": body.Data.SessionToken, "expiresIn": body.Data.ExpiresIn})`,
|
||||
`}`,
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/** @deprecated Use buildSecureServerNodeExample */
|
||||
export function buildSecureServerExample(channelId: string, opts?: { baseUrl?: string }) {
|
||||
return buildSecureServerNodeExample(channelId, opts)
|
||||
}
|
||||
|
||||
export function buildSecureServerExamples(channelId: string, opts?: { baseUrl?: string }) {
|
||||
return [
|
||||
buildSecureServerNodeExample(channelId, opts),
|
||||
'',
|
||||
buildSecureServerGoExample(channelId, opts),
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/** Listen for context injected by the parent page (embed host). */
|
||||
export function onEmbedHostContext(handler: (payload: Record<string, unknown>) => void) {
|
||||
const listener = (e: MessageEvent) => {
|
||||
if (!isTrustedParentMessage(e) || e.data.type !== 'set_context') return
|
||||
handler(e.data.payload || {})
|
||||
}
|
||||
window.addEventListener('message', listener)
|
||||
return () => window.removeEventListener('message', listener)
|
||||
}
|
||||
|
||||
/** Listen for a publish token provided by the parent host page. */
|
||||
export function onEmbedHostToken(handler: (token: string, channelId?: string) => void) {
|
||||
const listener = (e: MessageEvent) => {
|
||||
if (!isTrustedParentMessage(e) || e.data.type !== 'provide_token') return
|
||||
const token = String(e.data.token || '').trim()
|
||||
if (!token) return
|
||||
handler(token, e.data.channel_id)
|
||||
}
|
||||
window.addEventListener('message', listener)
|
||||
return () => window.removeEventListener('message', listener)
|
||||
}
|
||||
|
||||
/** Listen for locale changes from the parent host page. */
|
||||
export function onEmbedHostLocale(handler: (locale: string) => void) {
|
||||
const listener = (e: MessageEvent) => {
|
||||
if (!isTrustedParentMessage(e) || e.data.type !== 'set_locale') return
|
||||
const locale = String(e.data.payload?.locale || e.data.locale || '').trim()
|
||||
if (!locale) return
|
||||
handler(locale)
|
||||
}
|
||||
window.addEventListener('message', listener)
|
||||
return () => window.removeEventListener('message', listener)
|
||||
}
|
||||
|
||||
/** Listen for a pre-filled query from the parent host page. */
|
||||
export function onEmbedHostOpenWithQuery(handler: (query: string) => void) {
|
||||
const listener = (e: MessageEvent) => {
|
||||
if (!isTrustedParentMessage(e) || e.data.type !== 'open_with_query') return
|
||||
const query = String(e.data.payload?.query || e.data.query || '').trim()
|
||||
if (!query) return
|
||||
handler(query)
|
||||
}
|
||||
window.addEventListener('message', listener)
|
||||
return () => window.removeEventListener('message', listener)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { del, get, put } from '@/utils/request'
|
||||
|
||||
/**
|
||||
* Which value an execution would actually use for one variable.
|
||||
*
|
||||
* `unset` means nothing is stored anywhere, `workspace` means an admin filled
|
||||
* it in for everyone, `user` means the caller filled in their own — which wins
|
||||
* over the workspace one for this caller only.
|
||||
*/
|
||||
export type EnvVarSource = 'unset' | 'workspace' | 'user'
|
||||
|
||||
/**
|
||||
* One variable as its owner may see it. There is deliberately no value field:
|
||||
* the endpoint reports what is missing, never what is stored.
|
||||
*/
|
||||
export interface EnvVarView {
|
||||
name: string
|
||||
description?: string
|
||||
required?: boolean
|
||||
source: EnvVarSource
|
||||
/** Present only for the caller's own value; a workspace value has no per-variable timestamp. */
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
/** One skill's declared credentials. */
|
||||
export interface SkillEnvGroup {
|
||||
skill_id: string
|
||||
skill_name: string
|
||||
/** SKILL.md one-liner; not the instruction body. */
|
||||
description?: string
|
||||
vars: EnvVarView[]
|
||||
}
|
||||
|
||||
/**
|
||||
* One sandbox config: the caller's own config-wide variables, injected into
|
||||
* every execution on it, plus the credentials its skills declared.
|
||||
*/
|
||||
export interface ConfigEnvGroup {
|
||||
sandbox_config_id: string
|
||||
sandbox_config_name: string
|
||||
description?: string
|
||||
vars: EnvVarView[]
|
||||
skills: SkillEnvGroup[]
|
||||
}
|
||||
|
||||
export function listMyEnvVars(): Promise<{ data: ConfigEnvGroup[] }> {
|
||||
return get('/api/v1/me/env-vars') as unknown as Promise<{ data: ConfigEnvGroup[] }>
|
||||
}
|
||||
|
||||
export function setMySkillEnv(
|
||||
skillId: string,
|
||||
name: string,
|
||||
value: string,
|
||||
): Promise<{ success: boolean }> {
|
||||
return put('/api/v1/me/env-vars/skill', { skill_id: skillId, name, value }) as unknown as Promise<{
|
||||
success: boolean
|
||||
}>
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the caller's own value, after which the workspace value (if any)
|
||||
* applies again. The scope and name travel in a JSON body rather than the query
|
||||
* string, matching the PUT above.
|
||||
*/
|
||||
export function deleteMySkillEnv(skillId: string, name: string): Promise<{ success: boolean }> {
|
||||
return del('/api/v1/me/env-vars/skill', { skill_id: skillId, name }) as unknown as Promise<{
|
||||
success: boolean
|
||||
}>
|
||||
}
|
||||
|
||||
export function setMySandboxEnv(
|
||||
configId: string,
|
||||
name: string,
|
||||
value: string,
|
||||
): Promise<{ success: boolean }> {
|
||||
return put('/api/v1/me/env-vars/sandbox', {
|
||||
sandbox_config_id: configId,
|
||||
name,
|
||||
value,
|
||||
}) as unknown as Promise<{ success: boolean }>
|
||||
}
|
||||
|
||||
export function deleteMySandboxEnv(configId: string, name: string): Promise<{ success: boolean }> {
|
||||
return del('/api/v1/me/env-vars/sandbox', {
|
||||
sandbox_config_id: configId,
|
||||
name,
|
||||
}) as unknown as Promise<{ success: boolean }>
|
||||
}
|
||||
@@ -0,0 +1,606 @@
|
||||
import { get, post, put } from '../../utils/request';
|
||||
import i18n from '@/i18n'
|
||||
|
||||
const t = (key: string) => i18n.global.t(key)
|
||||
|
||||
// GET /initialization/config/:kbId exposes credential presence, not values.
|
||||
export interface ModelCredentialStatus {
|
||||
apiKey?: boolean;
|
||||
}
|
||||
|
||||
export interface COSCredentialStatus {
|
||||
secretId?: boolean;
|
||||
secretKey?: boolean;
|
||||
}
|
||||
|
||||
// 初始化配置数据类型
|
||||
export interface InitializationConfig {
|
||||
llm: {
|
||||
source: string;
|
||||
modelName: string;
|
||||
baseUrl?: string;
|
||||
/** @deprecated Use credentials.apiKey from GET responses */
|
||||
apiKey?: string;
|
||||
credentials?: ModelCredentialStatus;
|
||||
};
|
||||
embedding: {
|
||||
source: string;
|
||||
modelName: string;
|
||||
baseUrl?: string;
|
||||
/** @deprecated Use credentials.apiKey from GET responses */
|
||||
apiKey?: string;
|
||||
dimension?: number; // 添加embedding维度字段
|
||||
credentials?: ModelCredentialStatus;
|
||||
};
|
||||
rerank: {
|
||||
modelName: string;
|
||||
baseUrl: string;
|
||||
/** @deprecated Use credentials.apiKey from GET responses */
|
||||
apiKey?: string;
|
||||
enabled: boolean;
|
||||
credentials?: ModelCredentialStatus;
|
||||
};
|
||||
multimodal: {
|
||||
enabled: boolean;
|
||||
storageType: 'cos' | 'minio';
|
||||
vlm?: {
|
||||
modelName: string;
|
||||
baseUrl: string;
|
||||
/** @deprecated Use credentials.apiKey from GET responses */
|
||||
apiKey?: string;
|
||||
interfaceType?: string; // "ollama" or "openai"
|
||||
credentials?: ModelCredentialStatus;
|
||||
};
|
||||
cos?: {
|
||||
region: string;
|
||||
bucketName: string;
|
||||
appId: string;
|
||||
pathPrefix?: string;
|
||||
/** @deprecated Use credentials from GET responses */
|
||||
secretId?: string;
|
||||
/** @deprecated Use credentials from GET responses */
|
||||
secretKey?: string;
|
||||
credentials?: COSCredentialStatus;
|
||||
};
|
||||
minio?: {
|
||||
bucketName: string;
|
||||
pathPrefix?: string;
|
||||
};
|
||||
};
|
||||
documentSplitting: {
|
||||
chunkSize: number;
|
||||
chunkOverlap: number;
|
||||
separators: string[];
|
||||
// Adaptive chunking strategy. Empty / "legacy" = classic recursive splitter.
|
||||
// "auto" lets the backend profiler pick a tier; "heading" / "heuristic"
|
||||
// pin the tier explicitly. See backend chunker package for details.
|
||||
strategy?: string;
|
||||
// Cap chunk size in approx tokens. 0 = char-based budget only.
|
||||
tokenLimit?: number;
|
||||
// Language hints for heuristic patterns ("de", "en", "zh"). Empty = auto-detect.
|
||||
languages?: string[];
|
||||
};
|
||||
// Frontend-only hint for storage selection UI
|
||||
storageType?: 'cos' | 'minio';
|
||||
nodeExtract: {
|
||||
enabled: boolean,
|
||||
text: string,
|
||||
tags: string[],
|
||||
nodes: Node[],
|
||||
relations: Relation[]
|
||||
}
|
||||
}
|
||||
|
||||
// 下载任务状态类型
|
||||
export interface DownloadTask {
|
||||
id: string;
|
||||
modelName: string;
|
||||
status: 'pending' | 'downloading' | 'completed' | 'failed';
|
||||
progress: number;
|
||||
message: string;
|
||||
startTime: string;
|
||||
endTime?: string;
|
||||
}
|
||||
|
||||
// 简化版知识库配置更新接口(只传模型ID)
|
||||
export interface KBModelConfigRequest {
|
||||
llmModelId: string
|
||||
embeddingModelId: string
|
||||
vlm_config?: {
|
||||
enabled: boolean
|
||||
model_id?: string
|
||||
description_language?: string
|
||||
custom_instructions?: string
|
||||
}
|
||||
asr_config?: {
|
||||
enabled: boolean
|
||||
model_id?: string
|
||||
language?: string
|
||||
}
|
||||
documentSplitting: {
|
||||
chunkSize: number
|
||||
chunkOverlap: number
|
||||
separators: string[]
|
||||
parserEngineRules?: {
|
||||
file_types: string[]
|
||||
engine: string
|
||||
xlsx_first_row_as_header?: boolean
|
||||
}[]
|
||||
enableParentChild?: boolean
|
||||
parentChunkSize?: number
|
||||
childChunkSize?: number
|
||||
// Adaptive chunking strategy ("auto" | "heading" | "heuristic" | "legacy").
|
||||
// The backend uses pointer-based DTOs for these three fields:
|
||||
// - undefined / not set in payload → no change on server
|
||||
// - "" / 0 / [] explicitly sent → clears the value
|
||||
// Send the field whenever the user has opened the editor — even
|
||||
// empty values — so the user can always reset back to defaults.
|
||||
strategy?: string
|
||||
// Approximate token budget per chunk; 0 = char-based.
|
||||
tokenLimit?: number
|
||||
// Language hints for heuristic patterns. Empty array = auto-detect.
|
||||
languages?: string[]
|
||||
tableMetadataInstructions?: string
|
||||
}
|
||||
multimodal: {
|
||||
enabled: boolean
|
||||
}
|
||||
/** 存储引擎选择:"local" | "minio" | "cos" | "obs" 等,影响文档上传与文档内图片存储 */
|
||||
storageBackendId?: string
|
||||
storageProvider?: string
|
||||
nodeExtract: {
|
||||
enabled: boolean
|
||||
text: string
|
||||
tags: string[]
|
||||
nodes: Node[]
|
||||
relations: Relation[]
|
||||
customInstructions?: string
|
||||
}
|
||||
questionGeneration?: {
|
||||
enabled: boolean
|
||||
questionCount: number
|
||||
customInstructions?: string
|
||||
}
|
||||
}
|
||||
|
||||
export function updateKBConfig(kbId: string, config: KBModelConfigRequest): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
console.log('Starting KB config update (simplified)...', kbId, config);
|
||||
put(`/api/v1/initialization/config/${kbId}`, config)
|
||||
.then((response: any) => {
|
||||
console.log('KB config update completed', response);
|
||||
resolve(response);
|
||||
})
|
||||
.catch((error: any) => {
|
||||
console.error('Failed to update KB config:', error);
|
||||
reject(error.error || error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 根据知识库ID执行配置更新(旧版,保留兼容性)
|
||||
export function initializeSystemByKB(kbId: string, config: InitializationConfig): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
console.log('Starting KB config update...', kbId, config);
|
||||
post(`/api/v1/initialization/initialize/${kbId}`, config)
|
||||
.then((response: any) => {
|
||||
console.log('KB config update completed', response);
|
||||
resolve(response);
|
||||
})
|
||||
.catch((error: any) => {
|
||||
console.error('Failed to update KB config:', error);
|
||||
reject(error.error || error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 检查Ollama服务状态
|
||||
export function checkOllamaStatus(): Promise<{ available: boolean; version?: string; error?: string; baseUrl?: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
get('/api/v1/initialization/ollama/status')
|
||||
.then((response: any) => {
|
||||
resolve(response.data || { available: false });
|
||||
})
|
||||
.catch((error: any) => {
|
||||
console.error('Failed to check Ollama status:', error);
|
||||
resolve({ available: false, error: error.message || t('error.initialization.checkFailed') });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Ollama 模型详细信息接口
|
||||
export interface OllamaModelInfo {
|
||||
name: string;
|
||||
size: number;
|
||||
digest: string;
|
||||
modified_at: string;
|
||||
}
|
||||
|
||||
// 列出已安装的 Ollama 模型(详细信息)
|
||||
export function listOllamaModels(): Promise<OllamaModelInfo[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
get('/api/v1/initialization/ollama/models')
|
||||
.then((response: any) => {
|
||||
resolve((response.data && response.data.models) || []);
|
||||
})
|
||||
.catch((error: any) => {
|
||||
console.error('Failed to list Ollama models:', error);
|
||||
resolve([]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 检查Ollama模型状态
|
||||
export function checkOllamaModels(models: string[]): Promise<{ models: Record<string, boolean> }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
post('/api/v1/initialization/ollama/models/check', { models })
|
||||
.then((response: any) => {
|
||||
resolve(response.data || { models: {} });
|
||||
})
|
||||
.catch((error: any) => {
|
||||
console.error('Failed to check Ollama models:', error);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 启动Ollama模型下载(异步)
|
||||
export function downloadOllamaModel(modelName: string): Promise<{ taskId: string; modelName: string; status: string; progress: number }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
post('/api/v1/initialization/ollama/models/download', { modelName })
|
||||
.then((response: any) => {
|
||||
resolve(response.data || { taskId: '', modelName, status: 'failed', progress: 0 });
|
||||
})
|
||||
.catch((error: any) => {
|
||||
console.error('Failed to start Ollama model download:', error);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 查询下载进度
|
||||
export function getDownloadProgress(taskId: string): Promise<DownloadTask> {
|
||||
return new Promise((resolve, reject) => {
|
||||
get(`/api/v1/initialization/ollama/download/progress/${taskId}`)
|
||||
.then((response: any) => {
|
||||
resolve(response.data);
|
||||
})
|
||||
.catch((error: any) => {
|
||||
console.error('Failed to get download progress:', error);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 获取所有下载任务
|
||||
export function listDownloadTasks(): Promise<DownloadTask[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
get('/api/v1/initialization/ollama/download/tasks')
|
||||
.then((response: any) => {
|
||||
resolve(response.data || []);
|
||||
})
|
||||
.catch((error: any) => {
|
||||
console.error('Failed to list download tasks:', error);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
export function getCurrentConfigByKB(kbId: string): Promise<InitializationConfig & { hasFiles: boolean }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
get(`/api/v1/initialization/config/${kbId}`)
|
||||
.then((response: any) => {
|
||||
resolve(response.data || {});
|
||||
})
|
||||
.catch((error: any) => {
|
||||
console.error('Failed to get KB config:', error);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 所有"测试连接"接口共用的通用可选参数。
|
||||
// customHeaders / extraConfig / interfaceType 对应后端 ModelTestRequest 里的同名字段,
|
||||
// 会被透传给真正的模型装配流程,保证测试连接与生产调用走完全相同的路径。
|
||||
interface BaseModelTestPayload {
|
||||
customHeaders?: Record<string, string>;
|
||||
extraConfig?: Record<string, string>;
|
||||
interfaceType?: string;
|
||||
/** 第二段密钥(如 LKEAP Rerank 的腾讯云 SecretKey) */
|
||||
appSecret?: string;
|
||||
}
|
||||
|
||||
// 检查远程API模型
|
||||
export function checkRemoteModel(modelConfig: {
|
||||
modelName: string;
|
||||
baseUrl: string;
|
||||
apiKey?: string;
|
||||
provider?: string;
|
||||
// 编辑已存在模型时传 modelId,后端会自动从存储中带出 apiKey
|
||||
// (前端不再回显明文密钥,所以测试连接必须用这个回填路径)
|
||||
modelId?: string;
|
||||
} & BaseModelTestPayload): Promise<{
|
||||
available: boolean;
|
||||
message?: string;
|
||||
}> {
|
||||
return new Promise((resolve, reject) => {
|
||||
post('/api/v1/initialization/remote/check', modelConfig)
|
||||
.then((response: any) => {
|
||||
resolve(response.data || {});
|
||||
})
|
||||
.catch((error: any) => {
|
||||
console.error('Failed to check remote model:', error);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 测试 Embedding 模型(本地/远程)是否可用
|
||||
export function testEmbeddingModel(modelConfig: {
|
||||
source: 'local' | 'remote';
|
||||
modelName: string;
|
||||
baseUrl?: string;
|
||||
apiKey?: string;
|
||||
dimension?: number;
|
||||
supportsDimensionOverride?: boolean;
|
||||
provider?: string;
|
||||
modelId?: string;
|
||||
} & BaseModelTestPayload): Promise<{ available: boolean; message?: string; dimension?: number }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
post('/api/v1/initialization/embedding/test', modelConfig)
|
||||
.then((response: any) => {
|
||||
resolve(response.data || {});
|
||||
})
|
||||
.catch((error: any) => {
|
||||
console.error('Failed to test Embedding model:', error);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
export function checkRerankModel(modelConfig: {
|
||||
modelName: string;
|
||||
baseUrl: string;
|
||||
apiKey?: string;
|
||||
provider?: string;
|
||||
modelId?: string;
|
||||
} & BaseModelTestPayload): Promise<{
|
||||
available: boolean;
|
||||
message?: string;
|
||||
}> {
|
||||
return new Promise((resolve, reject) => {
|
||||
post('/api/v1/initialization/rerank/check', modelConfig)
|
||||
.then((response: any) => {
|
||||
resolve(response.data || {});
|
||||
})
|
||||
.catch((error: any) => {
|
||||
console.error('Failed to check Rerank model:', error);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 检查 ASR 模型连接(通过 /v1/audio/transcriptions 端点测试)
|
||||
export function checkASRModel(modelConfig: {
|
||||
modelName: string;
|
||||
baseUrl: string;
|
||||
apiKey?: string;
|
||||
provider?: string;
|
||||
modelId?: string;
|
||||
} & BaseModelTestPayload): Promise<{
|
||||
available: boolean;
|
||||
message?: string;
|
||||
}> {
|
||||
return new Promise((resolve, reject) => {
|
||||
post('/api/v1/initialization/asr/check', modelConfig)
|
||||
.then((response: any) => {
|
||||
resolve(response.data || {});
|
||||
})
|
||||
.catch((error: any) => {
|
||||
console.error('Failed to check ASR model:', error);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function testMultimodalFunction(testData: {
|
||||
image: File;
|
||||
vlm_model: string;
|
||||
vlm_base_url: string;
|
||||
vlm_api_key?: string;
|
||||
vlm_interface_type?: string;
|
||||
storage_type?: 'cos' | 'minio';
|
||||
// COS optional fields (required only when storage_type === 'cos')
|
||||
cos_secret_id?: string;
|
||||
cos_secret_key?: string;
|
||||
cos_region?: string;
|
||||
cos_bucket_name?: string;
|
||||
cos_app_id?: string;
|
||||
cos_path_prefix?: string;
|
||||
// MinIO optional fields
|
||||
minio_bucket_name?: string;
|
||||
minio_path_prefix?: string;
|
||||
chunk_size: number;
|
||||
chunk_overlap: number;
|
||||
separators: string[];
|
||||
}): Promise<{
|
||||
success: boolean;
|
||||
caption?: string;
|
||||
ocr?: string;
|
||||
processing_time?: number;
|
||||
message?: string;
|
||||
}> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const formData = new FormData();
|
||||
formData.append('image', testData.image);
|
||||
formData.append('vlm_model', testData.vlm_model);
|
||||
formData.append('vlm_base_url', testData.vlm_base_url);
|
||||
if (testData.vlm_api_key) {
|
||||
formData.append('vlm_api_key', testData.vlm_api_key);
|
||||
}
|
||||
if (testData.vlm_interface_type) {
|
||||
formData.append('vlm_interface_type', testData.vlm_interface_type);
|
||||
}
|
||||
if (testData.storage_type) {
|
||||
formData.append('storage_type', testData.storage_type);
|
||||
}
|
||||
// Append COS fields only when storage_type is COS
|
||||
if (testData.storage_type === 'cos') {
|
||||
if (testData.cos_secret_id) formData.append('cos_secret_id', testData.cos_secret_id);
|
||||
if (testData.cos_secret_key) formData.append('cos_secret_key', testData.cos_secret_key);
|
||||
if (testData.cos_region) formData.append('cos_region', testData.cos_region);
|
||||
if (testData.cos_bucket_name) formData.append('cos_bucket_name', testData.cos_bucket_name);
|
||||
if (testData.cos_app_id) formData.append('cos_app_id', testData.cos_app_id);
|
||||
if (testData.cos_path_prefix) formData.append('cos_path_prefix', testData.cos_path_prefix);
|
||||
}
|
||||
// MinIO fields
|
||||
if (testData.minio_bucket_name) formData.append('minio_bucket_name', testData.minio_bucket_name);
|
||||
if (testData.minio_path_prefix) formData.append('minio_path_prefix', testData.minio_path_prefix);
|
||||
formData.append('chunk_size', testData.chunk_size.toString());
|
||||
formData.append('chunk_overlap', testData.chunk_overlap.toString());
|
||||
formData.append('separators', JSON.stringify(testData.separators));
|
||||
|
||||
// 获取鉴权Token
|
||||
const token = localStorage.getItem('weknora_token');
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
// 跨空间访问请求头:直接附,避免 short-circuit "selectedTenantId
|
||||
// === defaultTenantId 时不附" 在某些边角下让 header 静默丢失。
|
||||
// 与 utils/request.ts、api/chat/streame.ts 行为一致。
|
||||
const selectedTenantId = localStorage.getItem('weknora_selected_tenant_id');
|
||||
if (selectedTenantId) {
|
||||
headers['X-Tenant-ID'] = selectedTenantId;
|
||||
}
|
||||
|
||||
// 使用原生fetch因为需要发送FormData
|
||||
fetch('/api/v1/initialization/multimodal/test', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: formData
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then((data: any) => {
|
||||
if (data.success) {
|
||||
resolve(data.data || {});
|
||||
} else {
|
||||
resolve({ success: false, message: data.message || t('error.initialization.testFailed') });
|
||||
}
|
||||
})
|
||||
.catch((error: any) => {
|
||||
console.error('Failed multimodal test:', error);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 文本内容关系提取接口
|
||||
export interface TextRelationExtractionRequest {
|
||||
text: string;
|
||||
tags: string[];
|
||||
model_id: string;
|
||||
}
|
||||
|
||||
export interface Node {
|
||||
name: string;
|
||||
attributes: string[];
|
||||
}
|
||||
|
||||
export interface Relation {
|
||||
node1: string;
|
||||
node2: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface TextRelationExtractionResponse {
|
||||
nodes: Node[];
|
||||
relations: Relation[];
|
||||
}
|
||||
|
||||
// 文本内容关系提取
|
||||
export function extractTextRelations(request: TextRelationExtractionRequest): Promise<TextRelationExtractionResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
post('/api/v1/initialization/extract/text-relation', request, { timeout: 60000 })
|
||||
.then((response: any) => {
|
||||
resolve(response.data || { nodes: [], relations: [] });
|
||||
})
|
||||
.catch((error: any) => {
|
||||
console.error('Failed to extract text relations:', error);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export interface FabriTextRequest {
|
||||
tags: string[];
|
||||
model_id: string;
|
||||
}
|
||||
|
||||
export interface FabriTextResponse {
|
||||
text: string;
|
||||
}
|
||||
|
||||
// 文本内容生成
|
||||
export function fabriText(request: FabriTextRequest): Promise<FabriTextResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
post('/api/v1/initialization/extract/fabri-text', request)
|
||||
.then((response: any) => {
|
||||
resolve(response.data || { text: '' });
|
||||
})
|
||||
.catch((error: any) => {
|
||||
console.error('Failed to generate text:', error);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export interface FabriTagRequest {
|
||||
}
|
||||
|
||||
export interface FabriTagResponse {
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
// 标签生成
|
||||
export function fabriTag(request: FabriTagRequest): Promise<FabriTagResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
post('/api/v1/initialization/extract/fabri-tag', request)
|
||||
.then((response: any) => {
|
||||
resolve(response.data || { tags: [] as string[] });
|
||||
})
|
||||
.catch((error: any) => {
|
||||
console.error('Failed to generate tags:', error);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 模型厂商信息类型
|
||||
export interface ModelProviderOption {
|
||||
value: string; // provider 标识符
|
||||
label: string; // 显示名称
|
||||
description: string; // 描述
|
||||
defaultUrls: Record<string, string>; // 按模型类型区分的默认 URL
|
||||
modelTypes: string[]; // 支持的模型类型
|
||||
}
|
||||
|
||||
// 获取模型厂商列表
|
||||
export function listModelProviders(modelType?: string): Promise<ModelProviderOption[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = modelType
|
||||
? `/api/v1/models/providers?model_type=${encodeURIComponent(modelType)}`
|
||||
: '/api/v1/models/providers';
|
||||
get(url)
|
||||
.then((response: any) => {
|
||||
resolve(response.data || []);
|
||||
})
|
||||
.catch((error: any) => {
|
||||
console.error('Failed to list model providers:', error);
|
||||
resolve([]); // 失败时返回空数组,前端可以回退到默认值
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,636 @@
|
||||
import { get, post, put, del, postUpload, getDown } from "../../utils/request";
|
||||
import { useResumableUploads } from '@/stores/resumableUploads';
|
||||
import type { KnowledgeProcessOverrides } from '@/types/knowledgeProcess';
|
||||
import type { AuditLog, AuditOutcome, ListAuditLogResponse } from '@/api/tenant/audit-log';
|
||||
|
||||
export type KnowledgeBaseActivity = AuditLog;
|
||||
|
||||
export interface ListKnowledgeBaseActivityParams {
|
||||
after_id?: number;
|
||||
limit?: number;
|
||||
action?: string;
|
||||
outcome?: AuditOutcome;
|
||||
actor?: string;
|
||||
}
|
||||
|
||||
export async function listKnowledgeBaseActivity(
|
||||
id: string,
|
||||
params: ListKnowledgeBaseActivityParams = {},
|
||||
): Promise<ListAuditLogResponse> {
|
||||
const query = new URLSearchParams();
|
||||
if (params.after_id) query.set('after_id', String(params.after_id));
|
||||
if (params.limit) query.set('limit', String(params.limit));
|
||||
if (params.action) query.set('action', params.action);
|
||||
if (params.outcome) query.set('outcome', params.outcome);
|
||||
if (params.actor) query.set('actor', params.actor);
|
||||
const qs = query.toString();
|
||||
return (await get(`/api/v1/knowledge-bases/${id}/activity${qs ? `?${qs}` : ''}`)) as unknown as ListAuditLogResponse;
|
||||
}
|
||||
|
||||
// 知识库管理 API(列表、创建、获取、更新、删除、复制)
|
||||
export function listKnowledgeBases(params?: {
|
||||
agent_id?: string;
|
||||
agent_source_tenant_id?: string;
|
||||
/**
|
||||
* Optional creator filter. Server-side semantics:
|
||||
* - "mine" → only KBs whose creator_id matches the caller
|
||||
* - "others" → only KBs created by someone else in this tenant
|
||||
* - omitted/"all" → no filter
|
||||
* KBs predating the RBAC backfill (creator_id="") never match
|
||||
* mine/others — they fall out of both views by design.
|
||||
*/
|
||||
creator?: 'all' | 'mine' | 'others';
|
||||
}) {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.agent_id) query.set('agent_id', params.agent_id);
|
||||
if (params?.agent_source_tenant_id) query.set('agent_source_tenant_id', params.agent_source_tenant_id);
|
||||
if (params?.creator && params.creator !== 'all') query.set('creator', params.creator);
|
||||
const qs = query.toString();
|
||||
return get(qs ? `/api/v1/knowledge-bases?${qs}` : '/api/v1/knowledge-bases');
|
||||
}
|
||||
|
||||
// Read-only vector-store binding metadata enriched onto every KB
|
||||
// response (list, create, get, update, pin). Source carries where the
|
||||
// binding points; status reports whether that target is currently
|
||||
// reachable by the server.
|
||||
//
|
||||
// - source 'env' → KB uses the tenant's env-configured store
|
||||
// (RETRIEVE_DRIVER). vector_store_id is null and
|
||||
// vector_store_name is the localized "System
|
||||
// default" label; vector_store_engine_type still
|
||||
// reports the underlying engine (e.g. "postgres").
|
||||
// - source 'user' → KB is bound to a tenant-owned VectorStore.
|
||||
// vector_store_id / name / engine_type are real.
|
||||
// - source 'shared' → KB belongs to a different tenant and is
|
||||
// readable via cross-organization sharing. The
|
||||
// server strips vector_store_id and engine_type
|
||||
// to avoid leaking the owner tenant's store
|
||||
// inventory; only this source marker arrives.
|
||||
// - status 'unavailable' → the binding cannot be reached right now
|
||||
// (deleted row, registry miss, transient infra
|
||||
// failure). Operators recover via the global
|
||||
// Vector Stores settings page.
|
||||
export type VectorStoreSource = 'env' | 'user' | 'shared' | 'unavailable';
|
||||
export type VectorStoreStatus = 'available' | 'unavailable';
|
||||
|
||||
export interface KnowledgeBaseStoreView {
|
||||
vector_store_id?: string | null;
|
||||
vector_store_name?: string;
|
||||
vector_store_engine_type?: string;
|
||||
vector_store_source?: VectorStoreSource;
|
||||
vector_store_status?: VectorStoreStatus;
|
||||
}
|
||||
|
||||
export function createKnowledgeBase(data: {
|
||||
name: string;
|
||||
description?: string;
|
||||
type?: 'document' | 'faq';
|
||||
chunking_config?: any;
|
||||
embedding_model_id?: string;
|
||||
summary_model_id?: string;
|
||||
auto_tag_config?: { enabled: boolean; model_id?: string; max_tags?: number; skip_if_tagged?: boolean };
|
||||
// Opt-in binding to a specific tenant-owned VectorStore. Omit (or
|
||||
// send undefined / empty string) to fall back to the env-configured
|
||||
// store. Immutable after creation — UpdateKnowledgeBase intentionally
|
||||
// does not accept this field.
|
||||
vector_store_id?: string;
|
||||
// Concrete tenant-owned storage instance. When omitted, the tenant default
|
||||
// backend is bound by the server at creation time.
|
||||
storage_backend_id?: string;
|
||||
vlm_config?: {
|
||||
enabled: boolean;
|
||||
model_id?: string;
|
||||
description_language?: string;
|
||||
custom_instructions?: string;
|
||||
};
|
||||
storage_provider_config?: { provider: string };
|
||||
storage_config?: any; // legacy, kept for backward compat (dual-write)
|
||||
asr_config?: {
|
||||
enabled: boolean;
|
||||
model_id?: string;
|
||||
language?: string;
|
||||
};
|
||||
extract_config?: any;
|
||||
faq_config?: { index_mode: string; question_index_mode?: string };
|
||||
wiki_config?: {
|
||||
synthesis_model_id?: string;
|
||||
max_pages_per_ingest?: number;
|
||||
extraction_granularity?: 'focused' | 'standard' | 'exhaustive';
|
||||
content_instructions?: string;
|
||||
extraction_instructions?: string;
|
||||
};
|
||||
indexing_strategy?: {
|
||||
vector_enabled: boolean;
|
||||
keyword_enabled: boolean;
|
||||
wiki_enabled: boolean;
|
||||
graph_enabled: boolean;
|
||||
};
|
||||
}) {
|
||||
return post(`/api/v1/knowledge-bases`, data);
|
||||
}
|
||||
|
||||
export function getKnowledgeBaseById(id: string, options?: { agent_id?: string; agent_source_tenant_id?: string }) {
|
||||
const query = new URLSearchParams();
|
||||
if (options?.agent_id) query.set('agent_id', options.agent_id);
|
||||
if (options?.agent_source_tenant_id) query.set('agent_source_tenant_id', options.agent_source_tenant_id);
|
||||
const qs = query.toString();
|
||||
return get(qs ? `/api/v1/knowledge-bases/${id}?${qs}` : `/api/v1/knowledge-bases/${id}`);
|
||||
}
|
||||
|
||||
export function updateKnowledgeBase(id: string, data: {
|
||||
name: string;
|
||||
description?: string;
|
||||
config?: {
|
||||
chunking_config?: any;
|
||||
image_processing_config?: any;
|
||||
faq_config?: any;
|
||||
wiki_config?: {
|
||||
synthesis_model_id?: string;
|
||||
max_pages_per_ingest?: number;
|
||||
extraction_granularity?: 'focused' | 'standard' | 'exhaustive';
|
||||
content_instructions?: string;
|
||||
extraction_instructions?: string;
|
||||
};
|
||||
auto_tag_config?: { enabled: boolean; model_id?: string; max_tags?: number; skip_if_tagged?: boolean };
|
||||
indexing_strategy?: {
|
||||
vector_enabled: boolean;
|
||||
keyword_enabled: boolean;
|
||||
wiki_enabled: boolean;
|
||||
graph_enabled: boolean;
|
||||
};
|
||||
}
|
||||
}) {
|
||||
return put(`/api/v1/knowledge-bases/${id}`, data);
|
||||
}
|
||||
|
||||
export function rebuildKBIndex(kbId: string) {
|
||||
return post(`/api/v1/knowledge-bases/${kbId}/rebuild-index`, {});
|
||||
}
|
||||
|
||||
export function deleteKnowledgeBase(id: string) {
|
||||
return del(`/api/v1/knowledge-bases/${id}`);
|
||||
}
|
||||
|
||||
export function copyKnowledgeBase(data: { source_id: string; target_id?: string }) {
|
||||
return post(`/api/v1/knowledge-bases/copy`, data);
|
||||
}
|
||||
|
||||
export function duplicateKnowledgeBase(id: string) {
|
||||
return post(`/api/v1/knowledge-bases/${id}/duplicate`);
|
||||
}
|
||||
|
||||
// 获取可移动目标知识库列表(同类型、同Embedding模型)
|
||||
export function listMoveTargets(sourceKbId: string) {
|
||||
return get(`/api/v1/knowledge-bases/${sourceKbId}/move-targets`);
|
||||
}
|
||||
|
||||
// 移动知识到其他知识库
|
||||
export function moveKnowledge(data: {
|
||||
knowledge_ids: string[];
|
||||
source_kb_id: string;
|
||||
target_kb_id: string;
|
||||
mode: 'reuse_vectors' | 'reparse';
|
||||
}) {
|
||||
return post('/api/v1/knowledge/move', data);
|
||||
}
|
||||
|
||||
// 获取知识移动进度
|
||||
export function getKnowledgeMoveProgress(taskId: string) {
|
||||
return get(`/api/v1/knowledge/move/progress/${taskId}`);
|
||||
}
|
||||
|
||||
export function togglePinKnowledgeBase(id: string) {
|
||||
return put(`/api/v1/knowledge-bases/${id}/pin`);
|
||||
}
|
||||
|
||||
// 知识文件 API(基于具体知识库)
|
||||
// data.tag_ids: 可选,指定知识所属的多个标签 ID
|
||||
export async function uploadKnowledgeFile(
|
||||
kbId: string,
|
||||
data: {
|
||||
file: File
|
||||
tag_ids?: string[]
|
||||
fileName?: string
|
||||
process_config?: KnowledgeProcessOverrides | string
|
||||
[key: string]: any
|
||||
} = { file: new File([], '') },
|
||||
onProgress?: (progressEvent: any) => void,
|
||||
) {
|
||||
const options = Object.fromEntries(Object.entries({
|
||||
tag_ids: data.tag_ids,
|
||||
fileName: data.fileName,
|
||||
process_config: typeof data.process_config === 'string' ? JSON.parse(data.process_config) : data.process_config,
|
||||
}).filter(([, value]) => value !== undefined));
|
||||
const row = await useResumableUploads().upload(data.file, 'knowledge', kbId, options, progress => onProgress?.({ loaded: progress.offset, total: progress.size }));
|
||||
return { success: true, data: row };
|
||||
}
|
||||
|
||||
// 从URL创建知识
|
||||
// data.tag_ids: 可选,指定知识所属的多个标签 ID
|
||||
export function createKnowledgeFromURL(
|
||||
kbId: string,
|
||||
data: { url: string; enable_multimodel?: boolean; tag_ids?: string[]; process_config?: KnowledgeProcessOverrides },
|
||||
) {
|
||||
return post(`/api/v1/knowledge-bases/${kbId}/knowledge/url`, data);
|
||||
}
|
||||
|
||||
// 手工创建知识
|
||||
// data.tag_ids: 可选,指定知识所属的标签 ID
|
||||
export function createManualKnowledge(
|
||||
kbId: string,
|
||||
data: {
|
||||
title: string
|
||||
content: string
|
||||
status: string
|
||||
tag_ids?: string[]
|
||||
process_config?: KnowledgeProcessOverrides
|
||||
},
|
||||
) {
|
||||
return post(`/api/v1/knowledge-bases/${kbId}/knowledge/manual`, data);
|
||||
}
|
||||
|
||||
export function listKnowledgeFiles(
|
||||
kbId: string,
|
||||
params: {
|
||||
page: number;
|
||||
page_size: number;
|
||||
tag_ids?: string;
|
||||
keyword?: string;
|
||||
file_type?: string;
|
||||
parse_status?: string;
|
||||
source?: string;
|
||||
start_time?: string;
|
||||
end_time?: string;
|
||||
/**
|
||||
* Folder to browse. An empty string means the knowledge base root, so the
|
||||
* parameter is only sent when it is defined — leaving it out lists every
|
||||
* folder (the flat view).
|
||||
*/
|
||||
folder_path?: string;
|
||||
/** Include documents stored in sub-folders of folder_path. */
|
||||
folder_recursive?: boolean;
|
||||
},
|
||||
) {
|
||||
const query = new URLSearchParams();
|
||||
query.append('page', String(params.page));
|
||||
query.append('page_size', String(params.page_size));
|
||||
if (params.tag_ids) query.append('tag_ids', params.tag_ids);
|
||||
if (params.keyword) query.append('keyword', params.keyword);
|
||||
if (params.file_type) query.append('file_type', params.file_type);
|
||||
if (params.parse_status) query.append('parse_status', params.parse_status);
|
||||
if (params.source) query.append('source', params.source);
|
||||
if (params.start_time) query.append('start_time', params.start_time);
|
||||
if (params.end_time) query.append('end_time', params.end_time);
|
||||
if (params.folder_path !== undefined) {
|
||||
query.append('folder_path', params.folder_path);
|
||||
if (params.folder_recursive) query.append('folder_recursive', 'true');
|
||||
}
|
||||
const qs = query.toString();
|
||||
return get(`/api/v1/knowledge-bases/${kbId}/knowledge?${qs}`);
|
||||
}
|
||||
|
||||
/** One node of the knowledge base folder tree. */
|
||||
export interface KnowledgeFolderNode {
|
||||
/** Canonical folder path, e.g. "docs/spec". */
|
||||
path: string;
|
||||
/** Last segment of the path, used as the row label. */
|
||||
name: string;
|
||||
/** Documents stored directly in this folder. */
|
||||
document_count: number;
|
||||
/** Documents in this folder plus every descendant folder. */
|
||||
total_count: number;
|
||||
children?: KnowledgeFolderNode[];
|
||||
}
|
||||
|
||||
export interface KnowledgeFolderTree {
|
||||
/** Documents that are not part of any uploaded folder. */
|
||||
root_document_count: number;
|
||||
/** Documents in the whole knowledge base. */
|
||||
total_document_count: number;
|
||||
folders: KnowledgeFolderNode[];
|
||||
}
|
||||
|
||||
export function listKnowledgeFolders(kbId: string) {
|
||||
return get(`/api/v1/knowledge-bases/${kbId}/knowledge/folders`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-file documents under `folderPath` ('' = knowledge base top level). Folders
|
||||
* are derived from the stored paths, so a path that does not exist yet is
|
||||
* created by this call. Only the grouping changes; documents are not re-parsed.
|
||||
*/
|
||||
export function moveKnowledgeToFolder(kbId: string, ids: string[], folderPath: string) {
|
||||
return post('/api/v1/knowledge/folder', {
|
||||
kb_id: kbId,
|
||||
knowledge_ids: ids,
|
||||
folder_path: folderPath,
|
||||
});
|
||||
}
|
||||
|
||||
/** Rename or move a folder together with everything below it. */
|
||||
export function renameKnowledgeFolder(kbId: string, from: string, to: string) {
|
||||
return put(`/api/v1/knowledge-bases/${kbId}/knowledge/folders`, { from, to });
|
||||
}
|
||||
|
||||
export function getKnowledgeDetails(id: string, options?: { agent_id?: string; agent_source_tenant_id?: string }) {
|
||||
const query = new URLSearchParams();
|
||||
if (options?.agent_id) query.set('agent_id', options.agent_id);
|
||||
if (options?.agent_source_tenant_id) query.set('agent_source_tenant_id', options.agent_source_tenant_id);
|
||||
const qs = query.toString();
|
||||
return get(qs ? `/api/v1/knowledge/${id}?${qs}` : `/api/v1/knowledge/${id}`);
|
||||
}
|
||||
|
||||
export function updateManualKnowledge(
|
||||
id: string,
|
||||
data: { title: string; content: string; status: string; process_config?: KnowledgeProcessOverrides },
|
||||
) {
|
||||
return put(`/api/v1/knowledge/manual/${id}`, data);
|
||||
}
|
||||
|
||||
export function reparseKnowledge(id: string, data?: { process_config?: KnowledgeProcessOverrides }) {
|
||||
return post(`/api/v1/knowledge/${id}/reparse`, data);
|
||||
}
|
||||
|
||||
export function cancelKnowledgeParse(id: string) {
|
||||
return post(`/api/v1/knowledge/${id}/cancel-parse`);
|
||||
}
|
||||
|
||||
export function getKnowledgeSpans(id: string, attempt?: number) {
|
||||
const qs = attempt ? `?attempt=${attempt}` : '';
|
||||
return get(`/api/v1/knowledge/${id}/spans${qs}`);
|
||||
}
|
||||
|
||||
export function delKnowledgeDetails(id: string) {
|
||||
return del(`/api/v1/knowledge/${id}`);
|
||||
}
|
||||
|
||||
// 批量删除(同一知识库内)。后端会校验所有 id 隶属于 kb_id 且具有编辑权限。
|
||||
export function batchDeleteKnowledge(kbId: string, ids: string[]) {
|
||||
return post(`/api/v1/knowledge/batch-delete`, { kb_id: kbId, ids });
|
||||
}
|
||||
|
||||
export function downKnowledgeDetails(id: string) {
|
||||
return getDown(`/api/v1/knowledge/${id}/download`);
|
||||
}
|
||||
|
||||
export function previewKnowledgeFile(id: string) {
|
||||
return getDown(`/api/v1/knowledge/${id}/preview`);
|
||||
}
|
||||
|
||||
/** @param idsQueryString - query string with ids (e.g. ids=xxx&ids=yyy) */
|
||||
export function batchQueryKnowledge(idsQueryString: string, kbId?: string, agentId?: string, agentSourceTenantId?: string) {
|
||||
let qs = idsQueryString;
|
||||
if (kbId) qs += `&kb_id=${encodeURIComponent(kbId)}`;
|
||||
if (agentId) qs += `&agent_id=${encodeURIComponent(agentId)}`;
|
||||
if (agentSourceTenantId) qs += `&agent_source_tenant_id=${encodeURIComponent(agentSourceTenantId)}`;
|
||||
return get(`/api/v1/knowledge/batch?${qs}`);
|
||||
}
|
||||
|
||||
export const KNOWLEDGE_CHUNK_PAGE_SIZE = 25;
|
||||
|
||||
export function getKnowledgeDetailsCon(id: string, page: number) {
|
||||
return get(`/api/v1/chunks/${id}?page=${page}&page_size=${KNOWLEDGE_CHUNK_PAGE_SIZE}`);
|
||||
}
|
||||
|
||||
export interface ChunkEditPayload {
|
||||
content?: string;
|
||||
is_enabled?: boolean;
|
||||
expected_revision?: number;
|
||||
}
|
||||
|
||||
export function updateDocumentChunk(knowledgeId: string, chunkId: string, data: ChunkEditPayload) {
|
||||
return put(`/api/v1/chunks/${knowledgeId}/${chunkId}`, data);
|
||||
}
|
||||
|
||||
export function listChunkRevisions(knowledgeId: string, chunkId: string) {
|
||||
return get(`/api/v1/chunks/${knowledgeId}/${chunkId}/revisions`);
|
||||
}
|
||||
|
||||
export function revertDocumentChunk(knowledgeId: string, chunkId: string, revision: number, expectedRevision: number) {
|
||||
return post(`/api/v1/chunks/${knowledgeId}/${chunkId}/revert`, {
|
||||
revision,
|
||||
expected_revision: expectedRevision,
|
||||
});
|
||||
}
|
||||
|
||||
export function updateKnowledgeMetadata(knowledgeId: string, customMetadata: Record<string, unknown>) {
|
||||
return put(`/api/v1/knowledge/${knowledgeId}`, { custom_metadata: customMetadata });
|
||||
}
|
||||
|
||||
export function updateKnowledgeSummary(knowledgeId: string, description: string) {
|
||||
return put(`/api/v1/knowledge/${knowledgeId}`, { description });
|
||||
}
|
||||
|
||||
export function regenerateKnowledgeSummary(knowledgeId: string) {
|
||||
return post(`/api/v1/knowledge/${knowledgeId}/regenerate-summary`, {});
|
||||
}
|
||||
|
||||
// Get chunk by chunk_id only (new endpoint - to be added to backend)
|
||||
export function getChunkByIdOnly(chunkId: string) {
|
||||
return get(`/api/v1/chunks/by-id/${chunkId}`);
|
||||
}
|
||||
|
||||
// Delete a single generated question from a chunk by question ID
|
||||
export function deleteGeneratedQuestion(chunkId: string, questionId: string) {
|
||||
return del(`/api/v1/chunks/by-id/${chunkId}/questions`, { question_id: questionId });
|
||||
}
|
||||
|
||||
export function upsertGeneratedQuestion(chunkId: string, question: string, questionId?: string) {
|
||||
return put(`/api/v1/chunks/by-id/${chunkId}/questions`, {
|
||||
question_id: questionId || '',
|
||||
question,
|
||||
});
|
||||
}
|
||||
|
||||
export function regenerateGeneratedQuestions(chunkId: string) {
|
||||
return post(`/api/v1/chunks/by-id/${chunkId}/questions/regenerate`, {});
|
||||
}
|
||||
|
||||
export function listKnowledgeTags(
|
||||
kbId: string,
|
||||
params?: { page?: number; page_size?: number; keyword?: string },
|
||||
) {
|
||||
const query = buildQuery(params);
|
||||
return get(`/api/v1/knowledge-bases/${kbId}/tags${query}`);
|
||||
}
|
||||
|
||||
export function createKnowledgeBaseTag(
|
||||
kbId: string,
|
||||
data: { name: string; color?: string; sort_order?: number },
|
||||
) {
|
||||
return post(`/api/v1/knowledge-bases/${kbId}/tags`, data);
|
||||
}
|
||||
|
||||
export function updateKnowledgeBaseTag(
|
||||
kbId: string,
|
||||
tagId: string,
|
||||
data: { name?: string; color?: string; sort_order?: number },
|
||||
) {
|
||||
return put(`/api/v1/knowledge-bases/${kbId}/tags/${tagId}`, data);
|
||||
}
|
||||
|
||||
export function deleteKnowledgeBaseTag(kbId: string, tagSeqId: number, params?: { force?: boolean }) {
|
||||
const forceQuery = params?.force ? '?force=true' : '';
|
||||
return del(`/api/v1/knowledge-bases/${kbId}/tags/${tagSeqId}${forceQuery}`);
|
||||
}
|
||||
|
||||
export function updateKnowledgeTagBatch(data: { updates: Record<string, string[]> }) {
|
||||
return put(`/api/v1/knowledge/tags`, data);
|
||||
}
|
||||
|
||||
export function updateFAQEntryTagBatch(kbId: string, data: { updates: Record<number, number | null> }) {
|
||||
return put(`/api/v1/knowledge-bases/${kbId}/faq/entries/tags`, data);
|
||||
}
|
||||
|
||||
const buildQuery = (params?: Record<string, any>) => {
|
||||
if (!params) return '';
|
||||
const query = new URLSearchParams();
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null || value === '') return;
|
||||
query.append(key, String(value));
|
||||
});
|
||||
const queryString = query.toString();
|
||||
return queryString ? `?${queryString}` : '';
|
||||
};
|
||||
|
||||
export function listFAQEntries(
|
||||
kbId: string,
|
||||
params?: { page?: number; page_size?: number; tag_id?: number; tag_ids?: string; keyword?: string },
|
||||
) {
|
||||
const query = buildQuery(params);
|
||||
return get(`/api/v1/knowledge-bases/${kbId}/faq/entries${query}`);
|
||||
}
|
||||
|
||||
export function upsertFAQEntries(kbId: string, data: { entries: any[]; mode: 'append' | 'replace' }) {
|
||||
return post(`/api/v1/knowledge-bases/${kbId}/faq/entries`, data);
|
||||
}
|
||||
|
||||
export function createFAQEntry(kbId: string, data: any) {
|
||||
return post(`/api/v1/knowledge-bases/${kbId}/faq/entry`, data);
|
||||
}
|
||||
|
||||
export function updateFAQEntry(kbId: string, entryId: number, data: any) {
|
||||
return put(`/api/v1/knowledge-bases/${kbId}/faq/entries/${entryId}`, data);
|
||||
}
|
||||
|
||||
// Unified batch update API - supports is_enabled, is_recommended, tag_id
|
||||
// Supports two modes:
|
||||
// 1. By entry ID: use by_id field
|
||||
// 2. By Tag: use by_tag field to apply the same update to all entries under a tag
|
||||
export interface FAQEntryFieldsUpdate {
|
||||
is_enabled?: boolean
|
||||
is_recommended?: boolean
|
||||
tag_id?: number | null
|
||||
}
|
||||
|
||||
export interface FAQEntryFieldsBatchRequest {
|
||||
by_id?: Record<number, FAQEntryFieldsUpdate>
|
||||
by_tag?: Record<number, FAQEntryFieldsUpdate>
|
||||
exclude_ids?: number[]
|
||||
}
|
||||
|
||||
export function updateFAQEntryFieldsBatch(kbId: string, data: FAQEntryFieldsBatchRequest) {
|
||||
return put(`/api/v1/knowledge-bases/${kbId}/faq/entries/fields`, data);
|
||||
}
|
||||
|
||||
export function deleteFAQEntries(kbId: string, ids: number[]) {
|
||||
return del(`/api/v1/knowledge-bases/${kbId}/faq/entries`, { ids });
|
||||
}
|
||||
|
||||
export function searchFAQEntries(
|
||||
kbId: string,
|
||||
data: {
|
||||
query_text: string
|
||||
vector_threshold?: number
|
||||
match_count?: number
|
||||
}
|
||||
) {
|
||||
return post(`/api/v1/knowledge-bases/${kbId}/faq/search`, data);
|
||||
}
|
||||
|
||||
// Export FAQ entries as CSV or JSON file
|
||||
export async function exportFAQEntries(kbId: string, format: 'csv' | 'json' = 'csv'): Promise<Blob> {
|
||||
const suffix = format === 'json' ? '?format=json' : ''
|
||||
const response = await getDown(`/api/v1/knowledge-bases/${kbId}/faq/entries/export${suffix}`)
|
||||
return response as unknown as Blob
|
||||
}
|
||||
|
||||
// FAQ Import Progress API
|
||||
export interface FAQBlockedEntry {
|
||||
index: number
|
||||
standard_question: string
|
||||
reason: string
|
||||
}
|
||||
|
||||
export interface FAQSuccessEntry {
|
||||
index: number
|
||||
seq_id: number
|
||||
tag_id?: number
|
||||
tag_name?: string
|
||||
standard_question: string
|
||||
}
|
||||
|
||||
export interface FAQImportProgress {
|
||||
task_id: string
|
||||
kb_id: string
|
||||
knowledge_id: string
|
||||
status: 'pending' | 'processing' | 'completed' | 'failed'
|
||||
progress: number
|
||||
total: number
|
||||
processed: number
|
||||
blocked: number
|
||||
blocked_entries?: FAQBlockedEntry[]
|
||||
success_entries?: FAQSuccessEntry[]
|
||||
message: string
|
||||
error: string
|
||||
created_at: number
|
||||
updated_at: number
|
||||
}
|
||||
|
||||
export function getFAQImportProgress(taskId: string) {
|
||||
return get(`/api/v1/faq/import/progress/${taskId}`);
|
||||
}
|
||||
|
||||
export function updateFAQImportResultDisplayStatus(knowledgeBaseId: string, displayStatus: 'open' | 'close') {
|
||||
return put(`/api/v1/knowledge-bases/${knowledgeBaseId}/faq/import/last-result/display`, {
|
||||
display_status: displayStatus
|
||||
});
|
||||
}
|
||||
|
||||
export function searchKnowledge(
|
||||
keyword: string,
|
||||
offset = 0,
|
||||
limit = 20,
|
||||
fileTypes?: string[],
|
||||
options?: { agent_id?: string; agent_source_tenant_id?: string; recent?: boolean }
|
||||
) {
|
||||
const query = new URLSearchParams();
|
||||
if (keyword) {
|
||||
query.set('keyword', keyword);
|
||||
}
|
||||
query.set('offset', String(offset));
|
||||
query.set('limit', String(limit));
|
||||
if (fileTypes && fileTypes.length > 0) {
|
||||
query.set('file_types', fileTypes.join(','));
|
||||
}
|
||||
if (options?.agent_id) query.set('agent_id', options.agent_id);
|
||||
if (options?.agent_source_tenant_id) query.set('agent_source_tenant_id', options.agent_source_tenant_id);
|
||||
if (options?.recent) query.set('recent', 'true');
|
||||
return get(`/api/v1/knowledge/search?${query.toString()}`);
|
||||
}
|
||||
|
||||
export function knowledgeSemanticSearch(data: {
|
||||
query: string;
|
||||
knowledge_base_ids?: string[];
|
||||
knowledge_ids?: string[];
|
||||
}) {
|
||||
return post('/api/v1/knowledge-search', data);
|
||||
}
|
||||
|
||||
export function batchReparseKnowledge(kbId: string, ids: string[], processConfig?: KnowledgeProcessOverrides) {
|
||||
return post(`/api/v1/knowledge/batch-reparse`, {
|
||||
kb_id: kbId,
|
||||
ids,
|
||||
process_config: processConfig,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { get, post } from '@/utils/request'
|
||||
export interface ClassificationSuggestion { tag_id: string; name: string; reason: string; confidence: number }
|
||||
export interface ClassificationItem { id: string; knowledge_id: string; title: string; status: string; suggestions: ClassificationSuggestion[]; error?: string }
|
||||
export interface ClassificationJob { id: string; owner_id: string; knowledge_base_id: string; model_id: string; status: string; total: number; processed: number; failed: number; created_at: string; items: ClassificationItem[] }
|
||||
const base = (kb: string) => `/api/v1/knowledge-bases/${encodeURIComponent(kb)}/classification-jobs`
|
||||
export const listClassificationJobs = (kb: string) => get<{ data: ClassificationJob[] }>(base(kb))
|
||||
export const createClassificationJob = (kb: string, model_id: string, knowledge_ids: string[]) => post<{ data: ClassificationJob }>(base(kb), { model_id, knowledge_ids })
|
||||
export const applyClassification = (kb: string, job: string, item_ids: string[]) => post(`${base(kb)}/${encodeURIComponent(job)}/apply`, { item_ids })
|
||||
export const retryClassification = (kb: string, job: string) => post(`${base(kb)}/${encodeURIComponent(job)}/retry`, {})
|
||||
@@ -0,0 +1,274 @@
|
||||
import { get, post, put, del } from '@/utils/request'
|
||||
|
||||
export interface MCPService {
|
||||
id: string
|
||||
tenant_id?: number
|
||||
name: string
|
||||
description: string
|
||||
enabled: boolean
|
||||
transport_type: 'sse' | 'http-streamable' | 'stdio'
|
||||
url?: string // Optional: required for SSE/HTTP Streamable
|
||||
headers?: Record<string, string>
|
||||
auth_config?: {
|
||||
// Authentication strategy. Empty/absent means none. "oauth" enables the
|
||||
// per-user OAuth2 authorization-code flow (zero-config: discovery +
|
||||
// dynamic client registration).
|
||||
auth_type?: '' | 'api_key' | 'bearer' | 'oauth'
|
||||
// Secret fields (api_key, token) are NEVER returned by the server in
|
||||
// this shape — they live behind the /credentials subresource. The
|
||||
// optional-property typing remains so create-mode payloads can still
|
||||
// carry them in the initial POST body.
|
||||
api_key?: string
|
||||
// Header name carrying api_key when auth_type is "api_key". Non-secret;
|
||||
// empty defaults to "X-API-Key". Lets services expecting the key in a
|
||||
// different header (e.g. raw token in "Authorization") work.
|
||||
api_key_header?: string
|
||||
token?: string
|
||||
custom_headers?: Record<string, string>
|
||||
// OAuth-only, non-secret configuration.
|
||||
scopes?: string[]
|
||||
auth_server_metadata_url?: string
|
||||
}
|
||||
advanced_config?: {
|
||||
timeout?: number
|
||||
retry_count?: number
|
||||
retry_delay?: number
|
||||
}
|
||||
stdio_config?: {
|
||||
command: 'uvx' | 'npx' // Command: uvx or npx
|
||||
args: string[] // Command arguments array
|
||||
}
|
||||
env_vars?: Record<string, string> // Environment variables for stdio transport
|
||||
is_builtin?: boolean // Whether this is a builtin MCP service
|
||||
// Per-field "configured?" map embedded on the main response (server-side
|
||||
// dto.MCPServiceResponse.Credentials). Drives the CredentialResource card
|
||||
// without a follow-up GET. Absent for builtin services.
|
||||
credentials?: Record<McpCredentialField, CredentialFieldMetadata>
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
export interface MCPTool {
|
||||
name: string
|
||||
description: string
|
||||
inputSchema: Record<string, any>
|
||||
require_approval?: boolean
|
||||
}
|
||||
|
||||
export interface MCPToolApprovalRow {
|
||||
id: string
|
||||
tenant_id?: number
|
||||
service_id: string
|
||||
tool_name: string
|
||||
require_approval: boolean
|
||||
}
|
||||
|
||||
export interface MCPResource {
|
||||
uri: string
|
||||
name: string
|
||||
description?: string
|
||||
mimeType?: string
|
||||
}
|
||||
|
||||
export interface MCPTestResult {
|
||||
success: boolean
|
||||
message?: string
|
||||
description?: string
|
||||
// Set when the server requires OAuth (RFC 9728) but the service was not
|
||||
// configured for it — the UI guides the user to switch to OAuth 2.0.
|
||||
oauth_required?: boolean
|
||||
tools?: MCPTool[]
|
||||
resources?: MCPResource[]
|
||||
}
|
||||
|
||||
// List all MCP services
|
||||
export async function listMCPServices(): Promise<MCPService[]> {
|
||||
const response: any = await get('/api/v1/mcp-services')
|
||||
return response.data || []
|
||||
}
|
||||
|
||||
// Get a single MCP service by ID
|
||||
export async function getMCPService(id: string): Promise<MCPService> {
|
||||
const response: any = await get(`/api/v1/mcp-services/${id}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
// Create a new MCP service
|
||||
export async function createMCPService(data: Partial<MCPService>): Promise<MCPService> {
|
||||
const response: any = await post('/api/v1/mcp-services', data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
// Update an existing MCP service
|
||||
export async function updateMCPService(id: string, data: Partial<MCPService>): Promise<MCPService> {
|
||||
const response: any = await put(`/api/v1/mcp-services/${id}`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
// Delete an MCP service
|
||||
export async function deleteMCPService(id: string): Promise<void> {
|
||||
await del(`/api/v1/mcp-services/${id}`)
|
||||
}
|
||||
|
||||
// Test MCP service connection
|
||||
export async function testMCPService(id: string): Promise<MCPTestResult> {
|
||||
const response: any = await post(`/api/v1/mcp-services/${id}/test`, {})
|
||||
// 后端返回格式: { success: true, data: MCPTestResult }
|
||||
// response interceptor 已经返回了 data,所以 response 就是 { success: true, data: {...} }
|
||||
if (response && response.data) {
|
||||
return response.data
|
||||
}
|
||||
// 如果格式不对,尝试直接返回 response(可能是直接返回的数据)
|
||||
return response
|
||||
}
|
||||
|
||||
// Get tools from an MCP service
|
||||
export async function getMCPServiceTools(id: string): Promise<MCPTool[]> {
|
||||
const response: any = await get(`/api/v1/mcp-services/${id}/tools`)
|
||||
return response.data || []
|
||||
}
|
||||
|
||||
// Get resources from an MCP service
|
||||
export async function getMCPServiceResources(id: string): Promise<MCPResource[]> {
|
||||
const response: any = await get(`/api/v1/mcp-services/${id}/resources`)
|
||||
return response.data || []
|
||||
}
|
||||
|
||||
/** Persisted per-tool human-approval flags (issue #1173) */
|
||||
export async function getMCPToolApprovals(serviceId: string): Promise<MCPToolApprovalRow[]> {
|
||||
const response: any = await get(`/api/v1/mcp-services/${serviceId}/tool-approvals`)
|
||||
return response.data || []
|
||||
}
|
||||
|
||||
export async function setMCPToolApproval(serviceId: string, toolName: string, requireApproval: boolean): Promise<void> {
|
||||
await put(`/api/v1/mcp-services/${serviceId}/tool-approvals/${encodeURIComponent(toolName)}`, {
|
||||
require_approval: requireApproval
|
||||
})
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Credential subresource (issue #988 follow-up).
|
||||
//
|
||||
// Secrets travel through a dedicated /credentials endpoint instead of the
|
||||
// main MCP PUT body. "Is this configured?" metadata is embedded on the main
|
||||
// MCPService response (MCPService.credentials), so there is no GET on this
|
||||
// endpoint — only PUT (write) and DELETE (clear). Both trigger an MCP
|
||||
// client reconnect server-side.
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
export type McpCredentialField = 'api_key' | 'token'
|
||||
|
||||
export interface CredentialFieldMetadata {
|
||||
configured: boolean
|
||||
}
|
||||
|
||||
export interface McpCredentialsResponse {
|
||||
fields: Record<McpCredentialField, CredentialFieldMetadata>
|
||||
}
|
||||
|
||||
export async function putMCPCredentials(
|
||||
serviceId: string,
|
||||
body: Partial<Record<McpCredentialField, string>>
|
||||
): Promise<McpCredentialsResponse> {
|
||||
const response: any = await put(`/api/v1/mcp-services/${serviceId}/credentials`, body)
|
||||
return (response.data ?? response) as McpCredentialsResponse
|
||||
}
|
||||
|
||||
export async function deleteMCPCredentialField(
|
||||
serviceId: string,
|
||||
field: McpCredentialField
|
||||
): Promise<void> {
|
||||
await del(`/api/v1/mcp-services/${serviceId}/credentials/${field}`)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Per-user OAuth2 authorization-code flow.
|
||||
//
|
||||
// The user authorizes a service once; the backend stores their access/refresh
|
||||
// token (per tenant + user + service) and refreshes it transparently. The
|
||||
// callback is a public backend route that the third-party authorization
|
||||
// server redirects to.
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// Path of the public backend OAuth callback (registered outside /mcp-services
|
||||
// to avoid a route conflict, and allow-listed for no-auth in the backend).
|
||||
export const MCP_OAUTH_CALLBACK_PATH = '/api/v1/mcp-oauth/callback'
|
||||
|
||||
export interface MCPOAuthAuthorization {
|
||||
authorizationUrl: string
|
||||
authorizationAttempt: string
|
||||
}
|
||||
|
||||
export type MCPOAuthTokenState = 'authorized' | 'refreshable' | 'reauth_required' | 'pending'
|
||||
|
||||
export interface MCPOAuthStatus {
|
||||
authorized: boolean
|
||||
state: MCPOAuthTokenState
|
||||
refresh_available: boolean
|
||||
expires_at?: string
|
||||
}
|
||||
|
||||
// Begin authorization for the current user. The attempt id binds polling to
|
||||
// this popup, so an older stored token cannot be mistaken for fresh consent.
|
||||
export async function getMCPOAuthAuthorizeURL(
|
||||
serviceId: string,
|
||||
body: { redirect_uri: string; frontend_redirect?: string }
|
||||
): Promise<MCPOAuthAuthorization> {
|
||||
const response: any = await post(`/api/v1/mcp-services/${serviceId}/oauth/authorize-url`, body)
|
||||
const data = response.data ?? response
|
||||
return {
|
||||
authorizationUrl: data?.authorization_url ?? '',
|
||||
authorizationAttempt: data?.authorization_attempt ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
// Whether the current user has authorized this service.
|
||||
export async function getMCPOAuthStatus(
|
||||
serviceId: string,
|
||||
authorizationAttempt?: string,
|
||||
): Promise<boolean> {
|
||||
const query = authorizationAttempt
|
||||
? `?authorization_attempt=${encodeURIComponent(authorizationAttempt)}`
|
||||
: ''
|
||||
const response: any = await get(`/api/v1/mcp-services/${serviceId}/oauth/status${query}`)
|
||||
return Boolean((response.data ?? response)?.authorized)
|
||||
}
|
||||
|
||||
// Full lifecycle status for management surfaces. Expired access tokens with a
|
||||
// refresh token are "refreshable", not falsely presented as already usable.
|
||||
export async function getMCPOAuthAuthorizationStatus(serviceId: string): Promise<MCPOAuthStatus> {
|
||||
const response: any = await get(`/api/v1/mcp-services/${serviceId}/oauth/status`)
|
||||
const data = response.data ?? response
|
||||
return {
|
||||
authorized: Boolean(data?.authorized),
|
||||
state: data?.state ?? 'reauth_required',
|
||||
refresh_available: Boolean(data?.refresh_available),
|
||||
expires_at: data?.expires_at,
|
||||
}
|
||||
}
|
||||
|
||||
// Revoke the current user's token (forces re-authorization).
|
||||
export async function revokeMCPOAuthToken(serviceId: string): Promise<void> {
|
||||
await del(`/api/v1/mcp-services/${serviceId}/oauth/token`)
|
||||
}
|
||||
|
||||
export async function resolveToolApproval(
|
||||
pendingId: string,
|
||||
body: { decision: 'approve' | 'reject'; modified_args?: Record<string, unknown>; reason?: string }
|
||||
): Promise<void> {
|
||||
await post(`/api/v1/agent/tool-approvals/${encodeURIComponent(pendingId)}`, body)
|
||||
}
|
||||
|
||||
// Resume an agent run that paused on an in-conversation MCP OAuth prompt.
|
||||
// Call after the per-user authorization popup completes; the backend verifies
|
||||
// the token exists before unblocking the paused tool call.
|
||||
export async function resolveMCPOAuth(
|
||||
pendingId: string,
|
||||
body: { service_id: string; decision?: 'authorize' | 'cancel' }
|
||||
): Promise<void> {
|
||||
await post(`/api/v1/agent/mcp-oauth-resolutions/${encodeURIComponent(pendingId)}`, body)
|
||||
}
|
||||
|
||||
export async function cancelMCPOAuth(pendingId: string): Promise<void> {
|
||||
await post(`/api/v1/agent/mcp-oauth-resolutions/${encodeURIComponent(pendingId)}/cancel`, {})
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import { get, put, post, del } from '@/utils/request'
|
||||
|
||||
// Kinds mirror internal/types/memory.go. profile and preference make up the
|
||||
// block injected on every turn; fact and task are pulled in only when the
|
||||
// current question matches them.
|
||||
export type MemoryKind = 'profile' | 'preference' | 'fact' | 'task' | 'interest'
|
||||
export type MemoryStatus = 'active' | 'superseded' | 'archived' | 'pending'
|
||||
export type MemoryOrigin = 'explicit' | 'extracted' | 'manual'
|
||||
|
||||
export interface MemoryItem {
|
||||
id: string
|
||||
kind: MemoryKind
|
||||
content: string
|
||||
topic: string
|
||||
importance: number
|
||||
origin: MemoryOrigin
|
||||
status: MemoryStatus
|
||||
source_session_id: string
|
||||
source_message_id: string
|
||||
valid_from: string
|
||||
invalid_at: string | null
|
||||
superseded_by: string
|
||||
last_used_at: string | null
|
||||
use_count: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// MemorySettings is already merged server-side, so the UI never has to combine
|
||||
// a workspace switch with a personal one itself.
|
||||
export interface MemorySettings {
|
||||
workspace_enabled: boolean
|
||||
user_enabled: boolean
|
||||
effective: boolean
|
||||
write_mode: string
|
||||
item_count: number
|
||||
max_items: number
|
||||
extraction_pending?: boolean
|
||||
extraction_failures?: number
|
||||
extraction_failure_limit?: number
|
||||
extraction_exhausted?: boolean
|
||||
extraction_next_retry_at?: string | null
|
||||
}
|
||||
|
||||
export interface MemoryConfig {
|
||||
enabled: boolean
|
||||
write_mode: 'explicit_only' | 'auto'
|
||||
extract_model_id: string
|
||||
max_items: number
|
||||
/** Debounce before distillation runs, in seconds. */
|
||||
extract_delay_seconds: number
|
||||
/** Floor between two distillation runs for one person, in seconds. */
|
||||
extract_min_interval_seconds: number
|
||||
/** Workspace-specific rules appended to the distillation prompt. */
|
||||
extract_instructions: string
|
||||
/** How many conversations must touch a topic before it becomes an interest. */
|
||||
interest_threshold: number
|
||||
/** Whether memory may shape retrieval, not only the answer prompt. */
|
||||
retrieval_conditioning: boolean
|
||||
/** Model used to score memory against a question. Blank = lexical matching only. */
|
||||
embedding_model_id: string
|
||||
/** Whether recall also matches on meaning, not only on wording. */
|
||||
vector_recall: boolean
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Personal memory. Every endpoint operates on the caller's own memory space,
|
||||
// which the server derives from the request principal, so none of these take
|
||||
// an owner parameter.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getMemorySettings() {
|
||||
return get<{ success: boolean; data: MemorySettings }>('/api/v1/memory/settings')
|
||||
}
|
||||
|
||||
export function updateMemoryEnabled(enabled: boolean) {
|
||||
return put<{ success: boolean; data: MemorySettings }>('/api/v1/memory/settings', { enabled })
|
||||
}
|
||||
|
||||
export function retryMemoryExtraction() {
|
||||
return post<{ success: boolean }>('/api/v1/memory/extraction/retry', {})
|
||||
}
|
||||
|
||||
export function listMemoryItems(params: { status?: MemoryStatus; limit?: number; offset?: number } = {}) {
|
||||
const query = new URLSearchParams()
|
||||
if (params.status) query.set('status', params.status)
|
||||
if (params.limit != null) query.set('limit', String(params.limit))
|
||||
if (params.offset != null) query.set('offset', String(params.offset))
|
||||
const suffix = query.toString() ? `?${query.toString()}` : ''
|
||||
return get<{ success: boolean; data: MemoryItem[]; total: number }>(`/api/v1/memory/items${suffix}`)
|
||||
}
|
||||
|
||||
/** Accept a memory the system inferred, so it starts being used. */
|
||||
export function confirmMemoryItem(id: string) {
|
||||
return post<{ success: boolean; data: MemoryItem }>(`/api/v1/memory/items/${id}/confirm`, {})
|
||||
}
|
||||
|
||||
/** Decline an inference. The refusal is remembered, so it is not re-proposed. */
|
||||
export function rejectMemoryItem(id: string) {
|
||||
return post<{ success: boolean }>(`/api/v1/memory/items/${id}/reject`, {})
|
||||
}
|
||||
|
||||
export function createMemoryItem(payload: { kind: MemoryKind; content: string; importance?: number }) {
|
||||
return post<{ success: boolean; data: MemoryItem }>('/api/v1/memory/items', payload)
|
||||
}
|
||||
|
||||
export function updateMemoryItem(id: string, payload: { content: string; importance: number }) {
|
||||
return put<{ success: boolean; data: MemoryItem }>(
|
||||
`/api/v1/memory/items/${encodeURIComponent(id)}`,
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function deleteMemoryItem(id: string) {
|
||||
return del<{ success: boolean }>(`/api/v1/memory/items/${encodeURIComponent(id)}`)
|
||||
}
|
||||
|
||||
export function clearMemoryItems() {
|
||||
return del<{ success: boolean; removed: number }>('/api/v1/memory/items')
|
||||
}
|
||||
|
||||
export function exportMemoryItems() {
|
||||
return get<{ success: boolean; total: number; data: MemoryItem[] }>('/api/v1/memory/export')
|
||||
}
|
||||
|
||||
/** Why a review changed nothing. Empty when it did change something. */
|
||||
export type MemoryConsolidationSkip =
|
||||
| 'too_few_items'
|
||||
| 'no_candidates'
|
||||
| 'model_unavailable'
|
||||
| 'model_declined'
|
||||
|
||||
export interface MemoryConsolidationResult {
|
||||
merged: number
|
||||
demoted: number
|
||||
expired: number
|
||||
reviewed: number
|
||||
candidates: number
|
||||
skipped?: MemoryConsolidationSkip
|
||||
}
|
||||
|
||||
/** Merge near-duplicates now, without waiting for the daily distillation pass. */
|
||||
export function consolidateMemory() {
|
||||
return post<{ success: boolean; data: MemoryConsolidationResult }>('/api/v1/memory/consolidate', {})
|
||||
}
|
||||
|
||||
export interface MemoryTopic {
|
||||
id: string
|
||||
topic: string
|
||||
aliases: string[]
|
||||
hits: number
|
||||
threshold: number
|
||||
last_seen_at: string
|
||||
}
|
||||
|
||||
export function listMemoryTopics(params: { limit?: number; offset?: number } = {}) {
|
||||
const query = new URLSearchParams()
|
||||
if (params.limit != null) query.set('limit', String(params.limit))
|
||||
if (params.offset != null) query.set('offset', String(params.offset))
|
||||
const suffix = query.toString() ? `?${query.toString()}` : ''
|
||||
return get<{ success: boolean; data: MemoryTopic[]; total: number }>(`/api/v1/memory/topics${suffix}`)
|
||||
}
|
||||
|
||||
/** Promote a counted topic into a long-term interest without waiting. */
|
||||
export function promoteMemoryTopic(id: string) {
|
||||
return post<{ success: boolean; data: MemoryItem }>(
|
||||
`/api/v1/memory/topics/${encodeURIComponent(id)}/promote`,
|
||||
{},
|
||||
)
|
||||
}
|
||||
|
||||
/** Stop tracking a topic. The refusal is remembered so it is not auto-promoted later. */
|
||||
export function deleteMemoryTopic(id: string) {
|
||||
return del<{ success: boolean }>(`/api/v1/memory/topics/${encodeURIComponent(id)}`)
|
||||
}
|
||||
|
||||
export interface MemoryDoc {
|
||||
id: string
|
||||
knowledge_id: string
|
||||
knowledge_base_id: string
|
||||
title: string
|
||||
hits: number
|
||||
last_used_at: string
|
||||
}
|
||||
|
||||
export function listMemoryDocuments(params: { limit?: number; offset?: number } = {}) {
|
||||
const query = new URLSearchParams()
|
||||
if (params.limit != null) query.set('limit', String(params.limit))
|
||||
if (params.offset != null) query.set('offset', String(params.offset))
|
||||
const suffix = query.toString() ? `?${query.toString()}` : ''
|
||||
return get<{ success: boolean; data: MemoryDoc[]; total: number }>(`/api/v1/memory/documents${suffix}`)
|
||||
}
|
||||
|
||||
/** Stop using one document as a personal retrieval signal. */
|
||||
export function deleteMemoryDocument(id: string) {
|
||||
return del<{ success: boolean }>(`/api/v1/memory/documents/${encodeURIComponent(id)}`)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Workspace configuration, stored on the tenant like the other KV configs.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getTenantMemoryConfig() {
|
||||
return get<{ success: boolean; data: MemoryConfig }>('/api/v1/tenants/kv/memory-config')
|
||||
}
|
||||
|
||||
export function updateTenantMemoryConfig(config: MemoryConfig) {
|
||||
return put<{ success: boolean; data: MemoryConfig }>('/api/v1/tenants/kv/memory-config', config)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { get, post } from '@/utils/request'
|
||||
|
||||
export interface MessageSuggestionItem {
|
||||
id: string
|
||||
text: string
|
||||
category?: 'clarify' | 'deepen' | 'action'
|
||||
source: 'model' | 'faq' | 'document' | 'wiki' | string
|
||||
knowledge_base_ids?: string[]
|
||||
}
|
||||
|
||||
export interface MessageSuggestionSet {
|
||||
id: string
|
||||
session_id: string
|
||||
assistant_message_id: string
|
||||
status: 'generating' | 'ready' | 'suppressed' | 'failed'
|
||||
allow_regenerate: boolean
|
||||
suppression_reason?: string
|
||||
questions: MessageSuggestionItem[]
|
||||
generated_at?: string
|
||||
}
|
||||
|
||||
export function ensureMessageSuggestions(sessionId: string, messageId: string, regenerate = false) {
|
||||
return post<{ data: MessageSuggestionSet }>(
|
||||
`/api/v1/sessions/${sessionId}/messages/${messageId}/suggestions`,
|
||||
{ regenerate },
|
||||
)
|
||||
}
|
||||
|
||||
export function getMessageSuggestions(sessionId: string, messageId: string) {
|
||||
return get<{ data: MessageSuggestionSet }>(
|
||||
`/api/v1/sessions/${sessionId}/messages/${messageId}/suggestions`,
|
||||
)
|
||||
}
|
||||
|
||||
export function recordMessageSuggestionEvent(
|
||||
sessionId: string,
|
||||
suggestionSetId: string,
|
||||
eventType: 'impression' | 'click' | 'dismiss',
|
||||
questionId = '',
|
||||
) {
|
||||
return post(
|
||||
`/api/v1/sessions/${sessionId}/suggestion-events`,
|
||||
{ suggestion_set_id: suggestionSetId, question_id: questionId, event_type: eventType },
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import { get, post, postUpload, put, del } from '../../utils/request';
|
||||
import i18n from '@/i18n'
|
||||
import { ModelInUseError, modelInUseErrorFromRequest } from './modelUsage'
|
||||
|
||||
export * from './modelUsage'
|
||||
|
||||
const t = (key: string) => i18n.global.t(key)
|
||||
|
||||
// 模型类型定义
|
||||
export interface ModelConfig {
|
||||
id?: string;
|
||||
tenant_id?: number;
|
||||
name: string;
|
||||
display_name?: string;
|
||||
type: 'KnowledgeQA' | 'Embedding' | 'Rerank' | 'VLLM' | 'ASR';
|
||||
source: 'local' | 'remote';
|
||||
description?: string;
|
||||
parameters: {
|
||||
base_url?: string;
|
||||
api_key?: string;
|
||||
provider?: string; // Provider identifier: openai, aliyun, zhipu, generic
|
||||
embedding_parameters?: {
|
||||
dimension?: number;
|
||||
truncate_prompt_tokens?: number;
|
||||
supports_dimension_override?: boolean;
|
||||
};
|
||||
interface_type?: 'ollama' | 'openai'; // VLLM专用
|
||||
parameter_size?: string; // Ollama模型参数大小 (e.g., "7B", "13B", "70B")
|
||||
extra_config?: Record<string, string>; // Provider-specific configuration
|
||||
// 自定义 HTTP 请求头(类似 Python OpenAI SDK 的 extra_headers),
|
||||
// 会在调用远程模型 API 时附加到每个请求上。Authorization、Content-Type 等保留头会被忽略。
|
||||
custom_headers?: Record<string, string>;
|
||||
supports_vision?: boolean; // Whether the model accepts image/multimodal input
|
||||
// 对话/VLM 的上下文窗口(token)。0 或不填表示使用后端默认 200000。
|
||||
context_window?: number;
|
||||
max_output_tokens?: number;
|
||||
// 后台任务(入库/富化)对该模型的并发上限,按模型 ID 全副本共享。
|
||||
// 0 或不填表示沿用全局默认(model.max_concurrency);仅对 chat/embedding/vllm 生效。
|
||||
max_concurrency?: number;
|
||||
app_id?: string;
|
||||
// Secret fields (api_key, app_secret) are never returned by the server in
|
||||
// this shape — they live behind the /credentials subresource. They are
|
||||
// kept on the type so create-mode payloads can still carry them in the
|
||||
// initial POST body.
|
||||
app_secret?: string;
|
||||
};
|
||||
is_default?: boolean;
|
||||
is_builtin?: boolean;
|
||||
status?: string;
|
||||
// Per-field configured? metadata from the main response. For builtin
|
||||
// models it is returned only to system administrators.
|
||||
credentials?: Record<ModelCredentialField, { configured: boolean }>;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
deleted_at?: string | null;
|
||||
}
|
||||
|
||||
// 创建模型
|
||||
export function createModel(data: ModelConfig): Promise<ModelConfig> {
|
||||
return new Promise((resolve, reject) => {
|
||||
post('/api/v1/models', data)
|
||||
.then((response: any) => {
|
||||
if (response.success && response.data) {
|
||||
resolve(response.data);
|
||||
} else {
|
||||
reject(new Error(response.message || t('error.model.createFailed')));
|
||||
}
|
||||
})
|
||||
.catch((error: any) => {
|
||||
console.error('Failed to create model:', error);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 获取模型列表
|
||||
export function listModels(type?: string): Promise<ModelConfig[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = `/api/v1/models`;
|
||||
get(url)
|
||||
.then((response: any) => {
|
||||
if (response.success && response.data) {
|
||||
if (type) {
|
||||
response.data = response.data.filter((item: ModelConfig) => item.type === type);
|
||||
}
|
||||
resolve(response.data);
|
||||
} else {
|
||||
resolve([]);
|
||||
}
|
||||
})
|
||||
.catch((error: any) => {
|
||||
console.error('Failed to list models:', error);
|
||||
// 抛出而非吞掉:调用方(含缓存层)才能区分「真失败」与「成功但无模型」,
|
||||
// 避免把一次瞬时失败的空结果缓存下来。各 UI 调用点均已 try/catch 兜底。
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 获取单个模型
|
||||
export function getModel(id: string): Promise<ModelConfig> {
|
||||
return new Promise((resolve, reject) => {
|
||||
get(`/api/v1/models/${id}`)
|
||||
.then((response: any) => {
|
||||
if (response.success && response.data) {
|
||||
resolve(response.data);
|
||||
} else {
|
||||
reject(new Error(response.message || t('error.model.getFailed')));
|
||||
}
|
||||
})
|
||||
.catch((error: any) => {
|
||||
console.error('Failed to get model:', error);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 更新模型
|
||||
export function updateModel(id: string, data: Partial<ModelConfig>): Promise<ModelConfig> {
|
||||
return new Promise((resolve, reject) => {
|
||||
put(`/api/v1/models/${id}`, data)
|
||||
.then((response: any) => {
|
||||
if (response.success && response.data) {
|
||||
resolve(response.data);
|
||||
} else {
|
||||
reject(new Error(response.message || t('error.model.updateFailed')));
|
||||
}
|
||||
})
|
||||
.catch((error: any) => {
|
||||
console.error('Failed to update model:', error);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 删除模型
|
||||
export function deleteModel(id: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
del(`/api/v1/models/${id}`)
|
||||
.then((response: any) => {
|
||||
if (response.success) {
|
||||
resolve();
|
||||
} else {
|
||||
const conflict = modelInUseErrorFromRequest(response)
|
||||
if (conflict) {
|
||||
reject(conflict)
|
||||
return
|
||||
}
|
||||
reject(new Error(response.message || t('error.model.deleteFailed')));
|
||||
}
|
||||
})
|
||||
.catch((error: any) => {
|
||||
console.error('Failed to delete model:', error);
|
||||
if (error instanceof ModelInUseError) {
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
const conflict = modelInUseErrorFromRequest(error)
|
||||
if (conflict) {
|
||||
reject(conflict)
|
||||
return
|
||||
}
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export interface ModelDebugOptions {
|
||||
system_prompt?: string
|
||||
temperature?: number
|
||||
top_p?: number
|
||||
max_tokens?: number
|
||||
thinking?: boolean
|
||||
}
|
||||
|
||||
export interface ModelDebugResult {
|
||||
ok: boolean
|
||||
elapsed_ms: number
|
||||
request: Record<string, unknown>
|
||||
raw_response: unknown
|
||||
observations: Record<string, unknown>
|
||||
error?: string
|
||||
}
|
||||
|
||||
export async function debugModel(
|
||||
id: string,
|
||||
data: {
|
||||
input?: string
|
||||
documents?: string[]
|
||||
options?: ModelDebugOptions
|
||||
file?: File | null
|
||||
},
|
||||
): Promise<ModelDebugResult> {
|
||||
const form = new FormData()
|
||||
form.append('input', data.input || '')
|
||||
form.append('documents', JSON.stringify(data.documents || []))
|
||||
form.append('options', JSON.stringify(data.options || {}))
|
||||
if (data.file) form.append('file', data.file)
|
||||
const response: any = await postUpload(
|
||||
`/api/v1/models/${id}/debug`,
|
||||
form,
|
||||
undefined,
|
||||
{ timeout: 300000 },
|
||||
)
|
||||
if (response?.success && response?.data) return response.data
|
||||
throw new Error(response?.message || t('error.model.getFailed'))
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Model credential subresource. See mcp-service.ts for the matching MCP API
|
||||
// shape and the design notes in internal/handler/dto/mcp.go.
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
export type ModelCredentialField = 'api_key' | 'app_secret'
|
||||
|
||||
export interface ModelCredentialsResponse {
|
||||
fields: Record<ModelCredentialField, { configured: boolean }>
|
||||
}
|
||||
|
||||
export async function putModelCredentials(
|
||||
id: string,
|
||||
body: Partial<Record<ModelCredentialField, string>>,
|
||||
): Promise<ModelCredentialsResponse> {
|
||||
const response: any = await put(`/api/v1/models/${id}/credentials`, body)
|
||||
return (response.data ?? response) as ModelCredentialsResponse
|
||||
}
|
||||
|
||||
export async function deleteModelCredentialField(
|
||||
id: string,
|
||||
field: ModelCredentialField,
|
||||
): Promise<void> {
|
||||
await del(`/api/v1/models/${id}/credentials/${field}`)
|
||||
}
|
||||
|
||||
export interface InitializeWeKnoraCloudRequest {
|
||||
app_id: string
|
||||
app_secret: string
|
||||
}
|
||||
|
||||
// 仅保存 WeKnoraCloud 凭证,不自动创建模型
|
||||
export function saveWeKnoraCloudCredentials(data: InitializeWeKnoraCloudRequest): Promise<{ success: boolean; message: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
post('/api/v1/weknoracloud/credentials', data)
|
||||
.then((response: any) => {
|
||||
if (response.success) {
|
||||
resolve(response)
|
||||
} else {
|
||||
reject(new Error(response.message || response.error || '凭证保存失败'))
|
||||
}
|
||||
})
|
||||
.catch((error: any) => {
|
||||
console.error('Failed to save WeKnoraCloud credentials:', error)
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export interface WeKnoraCloudStatusResult {
|
||||
has_models: boolean
|
||||
needs_reinit: boolean
|
||||
reason?: string
|
||||
}
|
||||
|
||||
export function getWeKnoraCloudStatus(): Promise<WeKnoraCloudStatusResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
get('/api/v1/models/weknoracloud/status')
|
||||
.then((response: any) => {
|
||||
// status 接口直接返回对象,不包在 success/data 中
|
||||
if (response && typeof response.has_models === 'boolean') {
|
||||
resolve(response)
|
||||
} else if (response?.success && response?.data) {
|
||||
resolve(response.data)
|
||||
} else {
|
||||
resolve({ has_models: false, needs_reinit: false })
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
resolve({ has_models: false, needs_reinit: false })
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
MODEL_IN_USE_ERROR_CODE,
|
||||
ModelInUseError,
|
||||
modelInUseErrorFromRequest,
|
||||
modelUsageAgentSection,
|
||||
modelUsageBindingI18nKey,
|
||||
modelUsageKnowledgeBaseSection,
|
||||
modelUsageListTruncated,
|
||||
modelUsageResourceCount,
|
||||
modelUsageResourceRoute,
|
||||
parseModelUsageDetails,
|
||||
} from './modelUsage'
|
||||
|
||||
const details = {
|
||||
knowledge_bases: [
|
||||
{ id: 'kb-1', name: 'Product docs', bindings: ['vlm_model'] },
|
||||
{ id: 'kb-2', name: 'Engineering', bindings: ['vlm_model'] },
|
||||
],
|
||||
agents: [
|
||||
{ id: 'agent-1', name: 'Support', bindings: ['chat_model', 'follow_up_model'] },
|
||||
],
|
||||
long_term_memory: { bindings: ['extract_model'] },
|
||||
knowledge_base_total: 2,
|
||||
agent_total: 1,
|
||||
}
|
||||
|
||||
test('parses grouped model usage details without dropping multiple bindings', () => {
|
||||
assert.deepEqual(parseModelUsageDetails(details), details)
|
||||
})
|
||||
|
||||
test('fills missing totals from the listed collections', () => {
|
||||
const { knowledge_base_total, agent_total, ...withoutTotals } = details
|
||||
assert.deepEqual(parseModelUsageDetails(withoutTotals), details)
|
||||
assert.equal(knowledge_base_total, 2)
|
||||
assert.equal(agent_total, 1)
|
||||
})
|
||||
|
||||
test('keeps untruncated totals when the listed collections are capped', () => {
|
||||
const parsed = parseModelUsageDetails({
|
||||
...details,
|
||||
knowledge_base_total: 80,
|
||||
agent_total: 12,
|
||||
})
|
||||
assert.equal(parsed?.knowledge_base_total, 80)
|
||||
assert.equal(parsed?.agent_total, 12)
|
||||
assert.equal(modelUsageResourceCount(parsed!.knowledge_bases, parsed!.knowledge_base_total), 80)
|
||||
assert.equal(modelUsageListTruncated(parsed!.knowledge_bases, parsed!.knowledge_base_total), true)
|
||||
assert.equal(modelUsageListTruncated(parsed!.agents, parsed!.agent_total), true)
|
||||
})
|
||||
|
||||
test('recognizes only the dedicated model-in-use code with a valid payload', () => {
|
||||
const conflict = modelInUseErrorFromRequest({
|
||||
status: 400,
|
||||
success: false,
|
||||
error: {
|
||||
code: MODEL_IN_USE_ERROR_CODE,
|
||||
message: 'backwards-compatible server message',
|
||||
details,
|
||||
},
|
||||
})
|
||||
|
||||
assert.ok(conflict instanceof ModelInUseError)
|
||||
assert.deepEqual(conflict.details, details)
|
||||
assert.equal(modelInUseErrorFromRequest({ error: { code: 1000, details } }), null)
|
||||
})
|
||||
|
||||
test('rejects malformed details instead of guessing from the server message', () => {
|
||||
assert.equal(parseModelUsageDetails({ knowledge_bases: [], agents: [] }), null)
|
||||
assert.equal(parseModelUsageDetails({
|
||||
knowledge_bases: [],
|
||||
agents: [],
|
||||
long_term_memory: { bindings: [] },
|
||||
}), null)
|
||||
assert.equal(parseModelUsageDetails({
|
||||
knowledge_bases: [{ id: 'kb-1', name: 'Docs', bindings: [] }],
|
||||
agents: [],
|
||||
long_term_memory: { bindings: [] },
|
||||
}), null)
|
||||
assert.equal(parseModelUsageDetails({
|
||||
knowledge_bases: [{ id: 'kb-1', name: 'Docs', bindings: 'vlm_model' }],
|
||||
agents: [],
|
||||
long_term_memory: { bindings: [] },
|
||||
}), null)
|
||||
assert.equal(modelInUseErrorFromRequest({
|
||||
error: {
|
||||
code: MODEL_IN_USE_ERROR_CODE,
|
||||
message: 'model is used by 1 knowledge base(s)',
|
||||
details: null,
|
||||
},
|
||||
}), null)
|
||||
})
|
||||
|
||||
test('keeps navigation and localization stable for all resource kinds', () => {
|
||||
assert.deepEqual(
|
||||
modelUsageResourceRoute('knowledge_base', 'kb/with slash'),
|
||||
{ path: '/platform/knowledge-bases/kb%2Fwith%20slash' },
|
||||
)
|
||||
assert.deepEqual(
|
||||
modelUsageResourceRoute('agent', 'agent-1'),
|
||||
{ path: '/platform/agents', query: { edit: 'agent-1', section: 'model' } },
|
||||
)
|
||||
assert.deepEqual(
|
||||
modelUsageResourceRoute('agent', 'agent-1', ['vlm_model']),
|
||||
{ path: '/platform/agents', query: { edit: 'agent-1', section: 'multimodal' } },
|
||||
)
|
||||
assert.deepEqual(
|
||||
modelUsageResourceRoute('agent', 'agent-1', ['follow_up_model']),
|
||||
{ path: '/platform/agents', query: { edit: 'agent-1', section: 'suggestions' } },
|
||||
)
|
||||
assert.equal(modelUsageBindingI18nKey('vlm_model'), 'modelSettings.usage.bindings.vlm_model')
|
||||
assert.equal(modelUsageBindingI18nKey('future_binding'), 'modelSettings.usage.bindings.unknown')
|
||||
})
|
||||
|
||||
test('maps knowledge-base bindings to the configuration section that owns them', () => {
|
||||
assert.equal(modelUsageKnowledgeBaseSection(['embedding_model']), 'models')
|
||||
assert.equal(modelUsageKnowledgeBaseSection(['summary_model']), 'models')
|
||||
assert.equal(modelUsageKnowledgeBaseSection(['wiki_synthesis_model']), 'models')
|
||||
assert.equal(modelUsageKnowledgeBaseSection(['image_processing_model']), 'multimodal')
|
||||
assert.equal(modelUsageKnowledgeBaseSection(['vlm_model']), 'multimodal')
|
||||
assert.equal(modelUsageKnowledgeBaseSection(['asr_model']), 'asr')
|
||||
assert.equal(modelUsageKnowledgeBaseSection(['future_binding']), 'models')
|
||||
assert.equal(modelUsageKnowledgeBaseSection(['summary_model', 'vlm_model']), 'models')
|
||||
})
|
||||
|
||||
test('maps agent bindings to the configuration section that owns them', () => {
|
||||
assert.equal(modelUsageAgentSection(['chat_model']), 'model')
|
||||
assert.equal(modelUsageAgentSection(['rerank_model']), 'model')
|
||||
assert.equal(modelUsageAgentSection(['query_understand_model']), 'model')
|
||||
assert.equal(modelUsageAgentSection(['vlm_model']), 'multimodal')
|
||||
assert.equal(modelUsageAgentSection(['asr_model']), 'multimodal')
|
||||
assert.equal(modelUsageAgentSection(['follow_up_model']), 'suggestions')
|
||||
assert.equal(modelUsageAgentSection(['future_binding']), 'model')
|
||||
assert.equal(modelUsageAgentSection(['chat_model', 'vlm_model']), 'model')
|
||||
assert.equal(modelUsageAgentSection(['vlm_model', 'follow_up_model']), 'multimodal')
|
||||
})
|
||||
@@ -0,0 +1,182 @@
|
||||
export const MODEL_IN_USE_ERROR_CODE = 2300
|
||||
|
||||
export const KNOWN_MODEL_USAGE_BINDINGS = [
|
||||
'embedding_model',
|
||||
'summary_model',
|
||||
'image_processing_model',
|
||||
'vlm_model',
|
||||
'asr_model',
|
||||
'wiki_synthesis_model',
|
||||
'chat_model',
|
||||
'rerank_model',
|
||||
'query_understand_model',
|
||||
'follow_up_model',
|
||||
'extract_model',
|
||||
] as const
|
||||
|
||||
export type KnownModelUsageBinding = typeof KNOWN_MODEL_USAGE_BINDINGS[number]
|
||||
|
||||
export interface ModelUsageResource {
|
||||
id: string
|
||||
name: string
|
||||
bindings: string[]
|
||||
}
|
||||
|
||||
export interface ModelUsageDetails {
|
||||
knowledge_bases: ModelUsageResource[]
|
||||
agents: ModelUsageResource[]
|
||||
long_term_memory: {
|
||||
bindings: string[]
|
||||
}
|
||||
knowledge_base_total: number
|
||||
agent_total: number
|
||||
}
|
||||
|
||||
export class ModelInUseError extends Error {
|
||||
readonly details: ModelUsageDetails
|
||||
|
||||
constructor(details: ModelUsageDetails) {
|
||||
super('model is in use')
|
||||
this.name = 'ModelInUseError'
|
||||
this.details = details
|
||||
}
|
||||
}
|
||||
|
||||
type UnknownRecord = Record<string, unknown>
|
||||
|
||||
function isRecord(value: unknown): value is UnknownRecord {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function parseBindings(value: unknown): string[] | null {
|
||||
if (!Array.isArray(value) || value.some(binding => typeof binding !== 'string' || binding.trim() === '')) {
|
||||
return null
|
||||
}
|
||||
return [...value]
|
||||
}
|
||||
|
||||
function parseResources(value: unknown): ModelUsageResource[] | null {
|
||||
if (!Array.isArray(value)) return null
|
||||
|
||||
const resources: ModelUsageResource[] = []
|
||||
for (const item of value) {
|
||||
if (!isRecord(item) || typeof item.id !== 'string' || item.id.trim() === '' || typeof item.name !== 'string') {
|
||||
return null
|
||||
}
|
||||
const bindings = parseBindings(item.bindings)
|
||||
if (!bindings || bindings.length === 0) return null
|
||||
resources.push({ id: item.id, name: item.name, bindings })
|
||||
}
|
||||
return resources
|
||||
}
|
||||
|
||||
function parseTotal(value: unknown, listed: number): number {
|
||||
if (typeof value === 'number' && Number.isFinite(value) && value >= listed) {
|
||||
return Math.floor(value)
|
||||
}
|
||||
return listed
|
||||
}
|
||||
|
||||
export function parseModelUsageDetails(value: unknown): ModelUsageDetails | null {
|
||||
if (!isRecord(value) || !isRecord(value.long_term_memory)) return null
|
||||
|
||||
const knowledgeBases = parseResources(value.knowledge_bases)
|
||||
const agents = parseResources(value.agents)
|
||||
const memoryBindings = parseBindings(value.long_term_memory.bindings)
|
||||
if (!knowledgeBases || !agents || !memoryBindings) return null
|
||||
|
||||
const details: ModelUsageDetails = {
|
||||
knowledge_bases: knowledgeBases,
|
||||
agents,
|
||||
long_term_memory: { bindings: memoryBindings },
|
||||
knowledge_base_total: parseTotal(value.knowledge_base_total, knowledgeBases.length),
|
||||
agent_total: parseTotal(value.agent_total, agents.length),
|
||||
}
|
||||
if (knowledgeBases.length === 0 && agents.length === 0 && memoryBindings.length === 0) {
|
||||
return null
|
||||
}
|
||||
return details
|
||||
}
|
||||
|
||||
export function modelInUseErrorFromRequest(error: unknown): ModelInUseError | null {
|
||||
if (!isRecord(error) || !isRecord(error.error) || error.error.code !== MODEL_IN_USE_ERROR_CODE) {
|
||||
return null
|
||||
}
|
||||
const details = parseModelUsageDetails(error.error.details)
|
||||
return details ? new ModelInUseError(details) : null
|
||||
}
|
||||
|
||||
const knownBindings = new Set<string>(KNOWN_MODEL_USAGE_BINDINGS)
|
||||
|
||||
export function modelUsageBindingI18nKey(binding: string): string {
|
||||
const key = knownBindings.has(binding) ? binding : 'unknown'
|
||||
return `modelSettings.usage.bindings.${key}`
|
||||
}
|
||||
|
||||
export function modelUsageResourceCount(resources: readonly ModelUsageResource[], total?: number): number {
|
||||
return typeof total === 'number' && Number.isFinite(total) && total >= resources.length
|
||||
? Math.floor(total)
|
||||
: resources.length
|
||||
}
|
||||
|
||||
export function modelUsageListTruncated(resources: readonly ModelUsageResource[], total?: number): boolean {
|
||||
return modelUsageResourceCount(resources, total) > resources.length
|
||||
}
|
||||
|
||||
export type ModelUsageResourceKind = 'knowledge_base' | 'agent'
|
||||
|
||||
export type KnowledgeBaseModelUsageSection = 'models' | 'multimodal' | 'asr'
|
||||
|
||||
export type AgentModelUsageSection = 'model' | 'multimodal' | 'suggestions'
|
||||
|
||||
const knowledgeBaseUsageSections: Partial<Record<KnownModelUsageBinding, KnowledgeBaseModelUsageSection>> = {
|
||||
embedding_model: 'models',
|
||||
summary_model: 'models',
|
||||
wiki_synthesis_model: 'models',
|
||||
image_processing_model: 'multimodal',
|
||||
vlm_model: 'multimodal',
|
||||
asr_model: 'asr',
|
||||
}
|
||||
|
||||
const agentUsageSections: Partial<Record<KnownModelUsageBinding, AgentModelUsageSection>> = {
|
||||
chat_model: 'model',
|
||||
rerank_model: 'model',
|
||||
query_understand_model: 'model',
|
||||
vlm_model: 'multimodal',
|
||||
asr_model: 'multimodal',
|
||||
follow_up_model: 'suggestions',
|
||||
}
|
||||
|
||||
export function modelUsageKnowledgeBaseSection(
|
||||
bindings: readonly string[],
|
||||
): KnowledgeBaseModelUsageSection {
|
||||
for (const binding of bindings) {
|
||||
const section = knowledgeBaseUsageSections[binding as KnownModelUsageBinding]
|
||||
if (section) return section
|
||||
}
|
||||
return 'models'
|
||||
}
|
||||
|
||||
export function modelUsageAgentSection(
|
||||
bindings: readonly string[],
|
||||
): AgentModelUsageSection {
|
||||
for (const binding of bindings) {
|
||||
const section = agentUsageSections[binding as KnownModelUsageBinding]
|
||||
if (section) return section
|
||||
}
|
||||
return 'model'
|
||||
}
|
||||
|
||||
export function modelUsageResourceRoute(
|
||||
kind: ModelUsageResourceKind,
|
||||
id: string,
|
||||
bindings: readonly string[] = [],
|
||||
) {
|
||||
if (kind === 'knowledge_base') {
|
||||
return { path: `/platform/knowledge-bases/${encodeURIComponent(id)}` }
|
||||
}
|
||||
return {
|
||||
path: '/platform/agents',
|
||||
query: { edit: id, section: modelUsageAgentSection(bindings) },
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { get, post, put, del } from '@/utils/request'
|
||||
|
||||
export interface Account { user_id: string; user_name: string; balance: number; reserved: number }
|
||||
export interface UserBalance { balance: number; available: number; reserved: number }
|
||||
export interface Transaction { id: string; user_id: string; delta: number; kind: string; reason: string; actor_id: string; metadata: Record<string, unknown>; created_at: string }
|
||||
export interface Usage { id: string; owner_id: string; job_id: string; agent_id: string; model_id: string; action: string; input_tokens: number; output_tokens: number; points: number; status: string; metadata: Record<string, unknown>; created_at: string }
|
||||
export interface OperationLog { id: string; owner_id: string; owner_name: string; level: string; action: string; message: string; resource_id: string; trace_id: string; status: number; metadata: Record<string, unknown>; created_at: string }
|
||||
export interface Consumption { id: string; owner_id: string; agent_id: string; kb_id: string; knowledge_id: string; file_name: string; category: string; excerpt: string; resource_id: string; segment_id: string; job_id: string; status: string; banned: boolean; lease_until?: string; created_at: string; updated_at: string }
|
||||
export interface PendingMemory { id: string; owner_id: string; agent_id: string; content: string; source_ref: string; status: 'pending' | 'accepted' | 'rejected' | 'invalidated'; created_at: string; updated_at: string; legacy_history_id?: string; native_memory_id?: string; reconfirmed_content?: string; reconfirmed_at?: string; can_reconfirm?: boolean; reconfirmation_unavailable_reason?: string }
|
||||
export interface Notification { id: string; resource_id: string; action: string; message: string; level: string; read: boolean; created_at: string }
|
||||
export type RecordKind = 'transactions' | 'usage' | 'audit' | 'system' | 'consumption' | 'memories'
|
||||
const root = '/api/v1/workbench'
|
||||
export const getBalance = () => get<{ data: UserBalance }>(`${root}/balance`)
|
||||
export const getAccounts = () => get<{ data: Account[] }>(`${root}/operations/accounts`)
|
||||
export const grantBalance = (data: { user_id: string; amount: number; reason: string; idempotency_key: string }) => post<{ data: Transaction }>(`${root}/operations/grants`, data)
|
||||
export function getOperationRecords<T>(kind: RecordKind, filters: Record<string, string | number | boolean | undefined> = {}) {
|
||||
const query = new URLSearchParams()
|
||||
Object.entries(filters).forEach(([key, value]) => { if (value !== undefined && value !== '') query.set(key, String(value)) })
|
||||
return get<{ data: T[]; total: number }>(`${root}/operations/records/${kind}?${query}`)
|
||||
}
|
||||
export const updateConsumption = (ids: string[], action: 'open' | 'ban') => post<{ data: { updated: number } }>(`${root}/operations/consumption`, { ids, action })
|
||||
export const reviewMemory = (id: string, status: 'accepted' | 'rejected', content: string, confirmation?: { reconfirm_legacy: true; confirm_personal_scope: true }) => post<{ data: PendingMemory }>(`${root}/operations/memories/${encodeURIComponent(id)}`, { status, content, ...confirmation })
|
||||
export const getNotifications = () => get<{ data: Notification[] }>(`${root}/notifications`)
|
||||
export const readAllNotifications = () => post(`${root}/notifications/read-all`, {})
|
||||
|
||||
export interface UsageQuota {
|
||||
id: string; user_id: string; user_name: string; period: 'daily' | 'monthly'; token_limit: number; points_limit: number;
|
||||
storage_limit_bytes: number; concurrent_limit: number; warning_ratio: number; overage_policy: 'warn' | 'soft_limit' | 'hard_limit'; enabled: boolean;
|
||||
warning: boolean; exceeded: boolean; allowed: boolean; ratio: number; period_start: string; period_end: string; period_timezone: string; storage_enforced: boolean;
|
||||
used: { tokens: number; points: number; reserved_tokens: number; reserved_points: number; active_calls: number }
|
||||
}
|
||||
export type QuotaInput = Pick<UsageQuota, 'period' | 'token_limit' | 'points_limit' | 'storage_limit_bytes' | 'concurrent_limit' | 'warning_ratio' | 'overage_policy' | 'enabled'>
|
||||
export const getQuotas = () => get<{ data: UsageQuota[] }>(`${root}/operations/quotas`)
|
||||
export const saveQuota = (user: string, quota: QuotaInput) => put(`${root}/operations/quotas/${encodeURIComponent(user)}`, quota)
|
||||
export const deleteQuota = (id: string) => del(`${root}/operations/quotas/${encodeURIComponent(id)}`)
|
||||
|
||||
const actionNames: Record<string, string> = { generate: '生成内容', 'generate-day': '生成每日连线框架', decompose: '拆解内容段', 'import-image': '识别导图图片', revise: '按建议修改', review: '跨稿质检', compose: '组合成稿', accept: '采用内容段', skip: '跳过内容段', confirm: '确认训练营方案', 'confirm-day': '确认每日框架', create: '创建', update: '修改', delete: '删除', cancel: '取消任务', pause: '暂停任务', resume: '继续任务', distill: '蒸馏数字人', redistill: '重新蒸馏', credit: '发放余额', debit: '消费扣费', refund: '退款' }
|
||||
const statusNames: Record<string, string> = { pending: '待审核', accepted: '已采纳', rejected: '已拒绝', invalidated: '已失效', queued: '排队中', running: '执行中', succeeded: '成功', success: '成功', failed: '失败', error: '错误', cancelled: '已取消', consumed: '已消费', reserved: '使用中', open: '已开放', info: '信息', debug: '调试' }
|
||||
const historicalActionNames: Record<string, string> = {
|
||||
digital_human_distillation: '蒸馏数字人', digital_human_trial: '数字人版本试写',
|
||||
document_auto_tag: '自动分类知识资料', question_generation: '生成资料问题', knowledge_qa: '知识问答',
|
||||
agent_round: 'Agent 推理与回答', agent_context_compaction: '整理对话上下文',
|
||||
'follow_up.suggestions': '生成后续问题建议', web_fetch_summary: '生成网页摘要',
|
||||
query_rewrite: '优化检索问题', data_analysis_plan: '生成数据分析计划', entity_extraction: '提取知识实体',
|
||||
wiki_page_modify: '修改 Wiki 页面', wiki_chunk_citation: '整理 Wiki 来源引用',
|
||||
wiki_candidate_slug: '整理 Wiki 页面名称', wiki_summary: '生成 Wiki 摘要',
|
||||
wiki_knowledge_extract: '提取 Wiki 知识', wiki_taxonomy_plan: '规划 Wiki 分类',
|
||||
wiki_deduplication: '合并 Wiki 重复内容', wiki_index_intro: '生成 Wiki 目录介绍', wiki_generation: '生成 Wiki 内容',
|
||||
document_summary: '生成资料摘要', storage: '资料存储', import: '导入知识', llm: 'AI 调用',
|
||||
rebuild: '重建知识索引', query: '知识检索', http: '接口访问', 'http.post': '创建或执行',
|
||||
'http.put': '修改', 'http.patch': '修改', 'http.delete': '删除', 'http.get': '查看',
|
||||
ai: 'AI 调用', startup: '系统启动', 'knowledge-auto-read': '知识自动预读',
|
||||
login: '登录系统', rag: '知识检索', training_map: '训练营导图', knowledge_maintenance: '知识库维护',
|
||||
}
|
||||
const classificationActionNames: Record<string, string> = {
|
||||
'knowledge.classification_requested': '请求 AI 推荐知识分类',
|
||||
'knowledge.classification_applied': '采用 AI 推荐分类',
|
||||
'knowledge.classification_retried': '重试 AI 推荐分类',
|
||||
knowledge_classification_recommend: 'AI 推荐知识分类',
|
||||
}
|
||||
export const operationLabel = (value: string) => classificationActionNames[value] || actionNames[value] || historicalActionNames[value] || statusNames[value] || value || '-'
|
||||
export const operationTime = (value: string) => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '-'
|
||||
@@ -0,0 +1,783 @@
|
||||
import { get, post, put, del } from '@/utils/request'
|
||||
|
||||
// Organization types
|
||||
export interface Organization {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
avatar?: string
|
||||
owner_id: string
|
||||
/**
|
||||
* Persisted owner tenant of the organization (Plan 3, migration 000046).
|
||||
* After Plan 3, member ownership is tenant-keyed: identifying the
|
||||
* "owner row" in the members list means matching member.tenant_id
|
||||
* against owner_tenant_id (NOT member.user_id against owner_id).
|
||||
* May be 0 on pre-000046 legacy rows; in that case fall back to
|
||||
* owner_id for display only.
|
||||
*/
|
||||
owner_tenant_id: number
|
||||
invite_code?: string
|
||||
invite_code_expires_at?: string | null
|
||||
invite_code_validity_days?: number
|
||||
require_approval?: boolean
|
||||
searchable?: boolean
|
||||
/** Max members; 0 = unlimited */
|
||||
member_limit?: number
|
||||
member_count?: number
|
||||
share_count?: number
|
||||
agent_share_count?: number
|
||||
pending_join_request_count?: number
|
||||
is_owner?: boolean
|
||||
my_role?: string
|
||||
has_pending_upgrade?: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/**
|
||||
* OrganizationMember represents one row in the organization's per-tenant
|
||||
* member list (Plan 3 / migration 000045). Each row is a (org, tenant)
|
||||
* tuple; the user fields describe the *representative* user attached to
|
||||
* that row for display/audit, not the member identity itself.
|
||||
*
|
||||
* - `tenant_id` + `tenant_name` are the canonical member identity.
|
||||
* - `representative_user_id` is the post-Plan-3 explicit alias; `user_id`
|
||||
* is kept for backward compatibility and points at the same value.
|
||||
* - Two users belonging to the *same* tenant produce a single row here
|
||||
* (UNIQUE(org_id, tenant_id)); the rep is the user who first brought
|
||||
* the tenant in.
|
||||
*/
|
||||
export interface OrganizationMember {
|
||||
id: string
|
||||
user_id: string
|
||||
representative_user_id?: string
|
||||
username: string
|
||||
email: string
|
||||
avatar?: string
|
||||
role: 'admin' | 'editor' | 'viewer'
|
||||
tenant_id: number
|
||||
tenant_name?: string
|
||||
joined_at: string
|
||||
}
|
||||
|
||||
export interface KnowledgeBaseShare {
|
||||
id: string
|
||||
knowledge_base_id: string
|
||||
knowledge_base_name?: string
|
||||
knowledge_base_type?: string
|
||||
knowledge_count?: number
|
||||
chunk_count?: number
|
||||
organization_id: string
|
||||
organization_name?: string
|
||||
shared_by_user_id: string
|
||||
shared_by_username?: string
|
||||
source_tenant_id: number
|
||||
/** Share permission: what the space was granted (viewer/editor) */
|
||||
permission: 'admin' | 'editor' | 'viewer'
|
||||
/** Current user's role in this organization (admin/editor/viewer) */
|
||||
my_role_in_org?: 'admin' | 'editor' | 'viewer'
|
||||
/** Effective permission for current user = min(permission, my_role_in_org) */
|
||||
my_permission?: 'admin' | 'editor' | 'viewer'
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface SharedKnowledgeBase {
|
||||
knowledge_base: {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
type: string
|
||||
knowledge_count?: number
|
||||
chunk_count?: number
|
||||
}
|
||||
share_id: string
|
||||
organization_id: string
|
||||
org_name: string
|
||||
permission: 'admin' | 'editor' | 'viewer'
|
||||
source_tenant_id: number
|
||||
shared_at: string
|
||||
}
|
||||
|
||||
/** When set, this KB is visible in the space via a shared agent (read-only, no direct KB share) */
|
||||
export interface SourceFromAgentInfo {
|
||||
agent_id: string
|
||||
agent_name: string
|
||||
/** "all" | "selected" | "none" — for showing agent's KB strategy in the drawer */
|
||||
kb_selection_mode?: string
|
||||
}
|
||||
|
||||
/** Item from GET /organizations/:id/shared-knowledge-bases (space-scoped list including mine and agent-carried) */
|
||||
export type OrganizationSharedKnowledgeBaseItem = SharedKnowledgeBase & {
|
||||
is_mine: boolean
|
||||
/** Present when the KB is from a shared agent's config (not directly shared to the space) */
|
||||
source_from_agent?: SourceFromAgentInfo
|
||||
}
|
||||
|
||||
export interface OrganizationPreview {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
avatar?: string
|
||||
member_count: number
|
||||
share_count: number
|
||||
agent_share_count?: number
|
||||
is_already_member: boolean
|
||||
require_approval: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
/** Searchable (discoverable) organization item for join flow */
|
||||
export interface SearchableOrganizationItem {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
avatar?: string
|
||||
member_count: number
|
||||
member_limit: number // 0 = unlimited
|
||||
share_count: number
|
||||
agent_share_count?: number
|
||||
is_already_member: boolean
|
||||
require_approval: boolean
|
||||
}
|
||||
|
||||
// Request types
|
||||
export interface CreateOrganizationRequest {
|
||||
name: string
|
||||
description?: string
|
||||
avatar?: string
|
||||
invite_code_validity_days?: number // 0=never, 1, 7, 30; default 7
|
||||
member_limit?: number // 0=unlimited; default 50
|
||||
}
|
||||
|
||||
export interface UpdateOrganizationRequest {
|
||||
name?: string
|
||||
description?: string
|
||||
avatar?: string
|
||||
require_approval?: boolean
|
||||
searchable?: boolean
|
||||
invite_code_validity_days?: number // 0=never, 1, 7, 30
|
||||
member_limit?: number // 0=unlimited
|
||||
}
|
||||
|
||||
export interface UpdateMemberRoleRequest {
|
||||
role: 'admin' | 'editor' | 'viewer'
|
||||
}
|
||||
|
||||
export interface JoinOrganizationRequest {
|
||||
invite_code: string
|
||||
}
|
||||
|
||||
export interface ShareKnowledgeBaseRequest {
|
||||
organization_id: string
|
||||
permission: 'admin' | 'editor' | 'viewer'
|
||||
}
|
||||
|
||||
export interface UpdateSharePermissionRequest {
|
||||
permission: 'admin' | 'editor' | 'viewer'
|
||||
}
|
||||
|
||||
// Response types
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean
|
||||
data?: T
|
||||
message?: string
|
||||
}
|
||||
|
||||
/** Per-org resource counts (included in list my organizations to avoid extra GET /me/resource-counts) */
|
||||
export interface ResourceCountsByOrg {
|
||||
knowledge_bases: { by_organization: Record<string, number> }
|
||||
agents: { by_organization: Record<string, number> }
|
||||
}
|
||||
|
||||
export interface ListOrganizationsResponse {
|
||||
organizations: Organization[]
|
||||
total: number
|
||||
resource_counts?: ResourceCountsByOrg
|
||||
}
|
||||
|
||||
export interface ListMembersResponse {
|
||||
members: OrganizationMember[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface JoinRequestResponse {
|
||||
id: string
|
||||
user_id: string
|
||||
username: string
|
||||
email: string
|
||||
message: string
|
||||
request_type: 'join' | 'upgrade' // 'join' for new member, 'upgrade' for role upgrade
|
||||
prev_role?: string // Previous role (only for upgrade requests)
|
||||
requested_role: string // Role applicant requested: admin, editor, viewer
|
||||
status: string
|
||||
created_at: string
|
||||
reviewed_at?: string
|
||||
}
|
||||
|
||||
export interface ListJoinRequestsResponse {
|
||||
requests: JoinRequestResponse[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface SubmitJoinRequestRequest {
|
||||
invite_code: string
|
||||
message?: string
|
||||
role?: 'admin' | 'editor' | 'viewer' // Optional: role applicant requests; default viewer
|
||||
}
|
||||
|
||||
export interface ReviewJoinRequestRequest {
|
||||
approved: boolean
|
||||
message?: string
|
||||
role?: 'admin' | 'editor' | 'viewer' // Optional: role to assign when approving; overrides applicant's requested role
|
||||
}
|
||||
|
||||
export interface RequestRoleUpgradeRequest {
|
||||
requested_role: 'admin' | 'editor' | 'viewer' // The role user wants to upgrade to
|
||||
message?: string // Optional message explaining the reason
|
||||
}
|
||||
|
||||
/**
|
||||
* InviteMemberRequest enrols a *tenant* into the organization (Plan 3).
|
||||
* - `tenant_id` is the preferred identity for the invitee.
|
||||
* - `representative_user_id` is optional metadata for the OTM row's
|
||||
* display/audit; when omitted the server picks a sensible default.
|
||||
* - `user_id` is kept for backward compatibility with pre-Plan-3 callers
|
||||
* (the server resolves the user's tenant if `tenant_id` is unset).
|
||||
*/
|
||||
export interface InviteMemberRequest {
|
||||
tenant_id?: number
|
||||
representative_user_id?: string
|
||||
user_id?: string
|
||||
role: 'admin' | 'editor' | 'viewer'
|
||||
}
|
||||
|
||||
export interface UserSearchResult {
|
||||
id: string
|
||||
username: string
|
||||
email: string
|
||||
avatar?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* TenantInviteCandidate is one row in the search-tenants-for-invite picker.
|
||||
* The picker is tenant-centric; the representative_* fields describe the
|
||||
* user that caused the tenant to appear in the search (for display only).
|
||||
*/
|
||||
export interface TenantInviteCandidate {
|
||||
tenant_id: number
|
||||
tenant_name: string
|
||||
representative_user_id: string
|
||||
representative_username: string
|
||||
representative_email: string
|
||||
representative_avatar?: string
|
||||
}
|
||||
|
||||
export interface ListSharesResponse {
|
||||
shares: KnowledgeBaseShare[]
|
||||
total: number
|
||||
}
|
||||
|
||||
// Agent share types
|
||||
export interface AgentShareResponse {
|
||||
id: string
|
||||
agent_id: string
|
||||
agent_name?: string
|
||||
organization_id: string
|
||||
organization_name?: string
|
||||
shared_by_user_id: string
|
||||
shared_by_username?: string
|
||||
source_tenant_id: number
|
||||
permission: string
|
||||
my_role_in_org?: string
|
||||
my_permission?: string
|
||||
created_at: string
|
||||
/** Agent scope summary for list display */
|
||||
scope_kb?: string
|
||||
scope_kb_count?: number
|
||||
scope_web_search?: boolean
|
||||
scope_mcp?: string
|
||||
scope_mcp_count?: number
|
||||
/** Agent avatar (emoji) for list display */
|
||||
agent_avatar?: string
|
||||
}
|
||||
|
||||
export interface SharedAgentInfo {
|
||||
agent: { id: string; name: string; description?: string; [key: string]: any }
|
||||
share_id: string
|
||||
organization_id: string
|
||||
org_name: string
|
||||
permission: string
|
||||
source_tenant_id: number
|
||||
shared_at: string
|
||||
shared_by_user_id?: string
|
||||
shared_by_username?: string
|
||||
/** 由后端在源空间解析,不与当前空间的搜索引擎列表比较 */
|
||||
web_search_ready: boolean
|
||||
/** 当前用户是否已停用该共享智能体(仅影响本人对话下拉显示) */
|
||||
disabled_by_me?: boolean
|
||||
}
|
||||
|
||||
/** Item from GET /organizations/:id/shared-agents (space-scoped list including mine) */
|
||||
export type OrganizationSharedAgentItem = SharedAgentInfo & { is_mine: boolean }
|
||||
|
||||
export interface ListAgentSharesResponse {
|
||||
shares: AgentShareResponse[]
|
||||
total: number
|
||||
}
|
||||
|
||||
// Organization API functions
|
||||
|
||||
/**
|
||||
* Create a new organization
|
||||
*/
|
||||
export async function createOrganization(req: CreateOrganizationRequest): Promise<ApiResponse<Organization>> {
|
||||
try {
|
||||
const response = await post('/api/v1/organizations', req)
|
||||
return response as unknown as ApiResponse<Organization>
|
||||
} catch (error: any) {
|
||||
return { success: false, message: error.message || 'Failed to create organization' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get organization by ID
|
||||
*/
|
||||
export async function getOrganization(id: string): Promise<ApiResponse<Organization>> {
|
||||
try {
|
||||
const response = await get(`/api/v1/organizations/${id}`)
|
||||
return response as unknown as ApiResponse<Organization>
|
||||
} catch (error: any) {
|
||||
return { success: false, message: error.message || 'Failed to get organization' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List my organizations
|
||||
*/
|
||||
export async function listMyOrganizations(): Promise<ApiResponse<ListOrganizationsResponse>> {
|
||||
try {
|
||||
const response = await get('/api/v1/organizations')
|
||||
return response as unknown as ApiResponse<ListOrganizationsResponse>
|
||||
} catch (error: any) {
|
||||
return { success: false, message: error.message || 'Failed to list organizations' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update organization
|
||||
*/
|
||||
export async function updateOrganization(id: string, req: UpdateOrganizationRequest): Promise<ApiResponse<Organization>> {
|
||||
try {
|
||||
const response = await put(`/api/v1/organizations/${id}`, req)
|
||||
return response as unknown as ApiResponse<Organization>
|
||||
} catch (error: any) {
|
||||
return { success: false, message: error.message || 'Failed to update organization' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete organization
|
||||
*/
|
||||
export async function deleteOrganization(id: string): Promise<ApiResponse<void>> {
|
||||
try {
|
||||
const response = await del(`/api/v1/organizations/${id}`)
|
||||
return response as unknown as ApiResponse<void>
|
||||
} catch (error: any) {
|
||||
return { success: false, message: error.message || 'Failed to delete organization' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Join organization by invite code
|
||||
*/
|
||||
export async function joinOrganization(req: JoinOrganizationRequest): Promise<ApiResponse<Organization>> {
|
||||
try {
|
||||
const response = await post('/api/v1/organizations/join', req)
|
||||
return response as unknown as ApiResponse<Organization>
|
||||
} catch (error: any) {
|
||||
return { success: false, message: error.message || 'Failed to join organization' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit a join request (for organizations that require approval).
|
||||
* Optional role: applicant's requested role (admin/editor/viewer); default viewer.
|
||||
*/
|
||||
export async function submitJoinRequest(req: SubmitJoinRequestRequest): Promise<ApiResponse<void>> {
|
||||
try {
|
||||
const response = await post('/api/v1/organizations/join-request', req)
|
||||
return response as unknown as ApiResponse<void>
|
||||
} catch (error: any) {
|
||||
return { success: false, message: error.message || 'Failed to submit join request' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview organization by invite code (without joining)
|
||||
*/
|
||||
export async function previewOrganization(inviteCode: string): Promise<ApiResponse<OrganizationPreview>> {
|
||||
try {
|
||||
const response = await get(`/api/v1/organizations/preview/${inviteCode}`)
|
||||
return response as unknown as ApiResponse<OrganizationPreview>
|
||||
} catch (error: any) {
|
||||
return { success: false, message: error.message || 'Failed to preview organization' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search searchable (discoverable) organizations
|
||||
*/
|
||||
export async function searchSearchableOrganizations(
|
||||
q: string = '',
|
||||
limit: number = 20
|
||||
): Promise<ApiResponse<{ data: SearchableOrganizationItem[]; total: number }>> {
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
if (q) params.set('q', q)
|
||||
params.set('limit', String(limit))
|
||||
const response = await get(`/api/v1/organizations/search?${params.toString()}`)
|
||||
const res = response as unknown as { success: boolean; data?: SearchableOrganizationItem[]; total?: number; message?: string }
|
||||
return {
|
||||
success: res.success,
|
||||
data: res.success ? { data: res.data || [], total: res.total ?? 0 } : undefined,
|
||||
message: res.message,
|
||||
}
|
||||
} catch (error: any) {
|
||||
return { success: false, message: error.message || 'Failed to search organizations' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Join a searchable organization by ID (no invite code)
|
||||
*/
|
||||
export async function joinOrganizationById(
|
||||
organizationId: string,
|
||||
message?: string,
|
||||
role?: 'admin' | 'editor' | 'viewer'
|
||||
): Promise<ApiResponse<Organization>> {
|
||||
try {
|
||||
const body: { organization_id: string; message?: string; role?: string } = { organization_id: organizationId }
|
||||
if (message) body.message = message
|
||||
if (role) body.role = role
|
||||
const response = await post('/api/v1/organizations/join-by-id', body)
|
||||
return response as unknown as ApiResponse<Organization>
|
||||
} catch (error: any) {
|
||||
return { success: false, message: error.message || 'Failed to join organization' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Leave organization
|
||||
*/
|
||||
export async function leaveOrganization(id: string): Promise<ApiResponse<void>> {
|
||||
try {
|
||||
const response = await post(`/api/v1/organizations/${id}/leave`, {})
|
||||
return response as unknown as ApiResponse<void>
|
||||
} catch (error: any) {
|
||||
return { success: false, message: error.message || 'Failed to leave organization' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request role upgrade in an organization
|
||||
*/
|
||||
export async function requestRoleUpgrade(
|
||||
orgId: string,
|
||||
request: RequestRoleUpgradeRequest
|
||||
): Promise<ApiResponse<JoinRequestResponse>> {
|
||||
try {
|
||||
const response = await post(`/api/v1/organizations/${orgId}/request-upgrade`, request)
|
||||
return response as unknown as ApiResponse<JoinRequestResponse>
|
||||
} catch (error: any) {
|
||||
return { success: false, message: error.message || 'Failed to submit upgrade request' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate new invite code
|
||||
*/
|
||||
export async function generateInviteCode(id: string): Promise<ApiResponse<{ invite_code: string }>> {
|
||||
try {
|
||||
const response = await post(`/api/v1/organizations/${id}/invite-code`, {})
|
||||
return response as unknown as ApiResponse<{ invite_code: string }>
|
||||
} catch (error: any) {
|
||||
return { success: false, message: error.message || 'Failed to generate invite code' }
|
||||
}
|
||||
}
|
||||
|
||||
// Member management
|
||||
|
||||
/**
|
||||
* List organization members
|
||||
*/
|
||||
export async function listMembers(orgId: string): Promise<ApiResponse<ListMembersResponse>> {
|
||||
try {
|
||||
const response = await get(`/api/v1/organizations/${orgId}/members`)
|
||||
return response as unknown as ApiResponse<ListMembersResponse>
|
||||
} catch (error: any) {
|
||||
return { success: false, message: error.message || 'Failed to list members' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update member role (member is identified by tenant_id)
|
||||
*/
|
||||
export async function updateMemberRole(orgId: string, tenantId: number, req: UpdateMemberRoleRequest): Promise<ApiResponse<void>> {
|
||||
try {
|
||||
const response = await put(`/api/v1/organizations/${orgId}/members/${tenantId}`, req)
|
||||
return response as unknown as ApiResponse<void>
|
||||
} catch (error: any) {
|
||||
return { success: false, message: error.message || 'Failed to update member role' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove member (member is identified by tenant_id)
|
||||
*/
|
||||
export async function removeMember(orgId: string, tenantId: number): Promise<ApiResponse<void>> {
|
||||
try {
|
||||
const response = await del(`/api/v1/organizations/${orgId}/members/${tenantId}`)
|
||||
return response as unknown as ApiResponse<void>
|
||||
} catch (error: any) {
|
||||
return { success: false, message: error.message || 'Failed to remove member' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List join requests (pending) for an organization (admin only)
|
||||
*/
|
||||
export async function listJoinRequests(orgId: string): Promise<ApiResponse<ListJoinRequestsResponse>> {
|
||||
try {
|
||||
const response = await get(`/api/v1/organizations/${orgId}/join-requests`)
|
||||
return response as unknown as ApiResponse<ListJoinRequestsResponse>
|
||||
} catch (error: any) {
|
||||
return { success: false, message: error.message || 'Failed to list join requests' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Review join request (approve or reject) - admin only
|
||||
*/
|
||||
export async function reviewJoinRequest(orgId: string, requestId: string, req: ReviewJoinRequestRequest): Promise<ApiResponse<void>> {
|
||||
try {
|
||||
const response = await put(`/api/v1/organizations/${orgId}/join-requests/${requestId}/review`, req)
|
||||
return response as unknown as ApiResponse<void>
|
||||
} catch (error: any) {
|
||||
return { success: false, message: error.message || 'Failed to review join request' }
|
||||
}
|
||||
}
|
||||
|
||||
// Knowledge base sharing
|
||||
|
||||
/**
|
||||
* Share knowledge base to organization
|
||||
*/
|
||||
export async function shareKnowledgeBase(kbId: string, req: ShareKnowledgeBaseRequest): Promise<ApiResponse<KnowledgeBaseShare>> {
|
||||
try {
|
||||
const response = await post(`/api/v1/knowledge-bases/${kbId}/shares`, req)
|
||||
return response as unknown as ApiResponse<KnowledgeBaseShare>
|
||||
} catch (error: any) {
|
||||
return { success: false, message: error.message || 'Failed to share knowledge base' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List shares for a knowledge base
|
||||
*/
|
||||
export async function listKBShares(kbId: string): Promise<ApiResponse<ListSharesResponse>> {
|
||||
try {
|
||||
const response = await get(`/api/v1/knowledge-bases/${kbId}/shares`)
|
||||
return response as unknown as ApiResponse<ListSharesResponse>
|
||||
} catch (error: any) {
|
||||
return { success: false, message: error.message || 'Failed to list shares' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update share permission
|
||||
*/
|
||||
export async function updateSharePermission(kbId: string, shareId: string, req: UpdateSharePermissionRequest): Promise<ApiResponse<void>> {
|
||||
try {
|
||||
const response = await put(`/api/v1/knowledge-bases/${kbId}/shares/${shareId}`, req)
|
||||
return response as unknown as ApiResponse<void>
|
||||
} catch (error: any) {
|
||||
return { success: false, message: error.message || 'Failed to update share permission' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove share
|
||||
*/
|
||||
export async function removeShare(kbId: string, shareId: string): Promise<ApiResponse<void>> {
|
||||
try {
|
||||
const response = await del(`/api/v1/knowledge-bases/${kbId}/shares/${shareId}`)
|
||||
return response as unknown as ApiResponse<void>
|
||||
} catch (error: any) {
|
||||
return { success: false, message: error.message || 'Failed to remove share' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List shared knowledge bases (shared to me through organizations)
|
||||
*/
|
||||
export async function listSharedKnowledgeBases(): Promise<ApiResponse<SharedKnowledgeBase[]>> {
|
||||
try {
|
||||
const response = await get('/api/v1/shared-knowledge-bases')
|
||||
return response as unknown as ApiResponse<SharedKnowledgeBase[]>
|
||||
} catch (error: any) {
|
||||
return { success: false, message: error.message || 'Failed to list shared knowledge bases' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all knowledge bases in an organization (including those shared by current tenant), for list page when a space is selected.
|
||||
*/
|
||||
export async function listOrganizationSharedKnowledgeBases(orgId: string): Promise<ApiResponse<OrganizationSharedKnowledgeBaseItem[]>> {
|
||||
try {
|
||||
const response = await get(`/api/v1/organizations/${orgId}/shared-knowledge-bases`)
|
||||
return response as unknown as ApiResponse<OrganizationSharedKnowledgeBaseItem[]>
|
||||
} catch (error: any) {
|
||||
return { success: false, message: error.message || 'Failed to list organization shared knowledge bases' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List knowledge bases shared to a specific organization
|
||||
*/
|
||||
export async function listOrgShares(orgId: string): Promise<ApiResponse<ListSharesResponse>> {
|
||||
try {
|
||||
const response = await get(`/api/v1/organizations/${orgId}/shares`)
|
||||
return response as unknown as ApiResponse<ListSharesResponse>
|
||||
} catch (error: any) {
|
||||
return { success: false, message: error.message || 'Failed to list organization shares' }
|
||||
}
|
||||
}
|
||||
|
||||
// Agent sharing
|
||||
export async function shareAgent(agentId: string, req: ShareKnowledgeBaseRequest): Promise<ApiResponse<AgentShareResponse>> {
|
||||
try {
|
||||
const response = await post(`/api/v1/agents/${agentId}/shares`, req)
|
||||
return response as unknown as ApiResponse<AgentShareResponse>
|
||||
} catch (error: any) {
|
||||
return { success: false, message: error.message || 'Failed to share agent' }
|
||||
}
|
||||
}
|
||||
|
||||
export async function listAgentShares(agentId: string): Promise<ApiResponse<ListAgentSharesResponse>> {
|
||||
try {
|
||||
const response = await get(`/api/v1/agents/${agentId}/shares`)
|
||||
return response as unknown as ApiResponse<ListAgentSharesResponse>
|
||||
} catch (error: any) {
|
||||
return { success: false, message: error.message || 'Failed to list agent shares' }
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateAgentSharePermission(agentId: string, shareId: string, req: UpdateSharePermissionRequest): Promise<ApiResponse<void>> {
|
||||
try {
|
||||
const response = await put(`/api/v1/agents/${agentId}/shares/${shareId}`, req)
|
||||
return response as unknown as ApiResponse<void>
|
||||
} catch (error: any) {
|
||||
return { success: false, message: error.message || 'Failed to update share permission' }
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeAgentShare(agentId: string, shareId: string): Promise<ApiResponse<void>> {
|
||||
try {
|
||||
const response = await del(`/api/v1/agents/${agentId}/shares/${shareId}`)
|
||||
return response as unknown as ApiResponse<void>
|
||||
} catch (error: any) {
|
||||
return { success: false, message: error.message || 'Failed to remove share' }
|
||||
}
|
||||
}
|
||||
|
||||
export async function listSharedAgents(): Promise<ApiResponse<SharedAgentInfo[]>> {
|
||||
try {
|
||||
const response = await get('/api/v1/shared-agents')
|
||||
return response as unknown as ApiResponse<SharedAgentInfo[]>
|
||||
} catch (error: any) {
|
||||
return { success: false, message: error.message || 'Failed to list shared agents' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all agents in an organization (including those shared by current tenant), for list page when a space is selected.
|
||||
*/
|
||||
export async function listOrganizationSharedAgents(orgId: string): Promise<ApiResponse<OrganizationSharedAgentItem[]>> {
|
||||
try {
|
||||
const response = await get(`/api/v1/organizations/${orgId}/shared-agents`)
|
||||
return response as unknown as ApiResponse<OrganizationSharedAgentItem[]>
|
||||
} catch (error: any) {
|
||||
return { success: false, message: error.message || 'Failed to list organization shared agents' }
|
||||
}
|
||||
}
|
||||
|
||||
/** 设置当前用户对某共享智能体的停用状态(仅影响本人对话下拉显示) */
|
||||
export async function setSharedAgentDisabledByMe(
|
||||
agentId: string,
|
||||
disabled: boolean
|
||||
): Promise<ApiResponse<void>> {
|
||||
try {
|
||||
const response = await post('/api/v1/shared-agents/disabled', {
|
||||
agent_id: agentId,
|
||||
disabled
|
||||
})
|
||||
return response as unknown as ApiResponse<void>
|
||||
} catch (error: any) {
|
||||
return { success: false, message: error.message || 'Failed to update preference' }
|
||||
}
|
||||
}
|
||||
|
||||
export async function listOrgAgentShares(orgId: string): Promise<ApiResponse<ListAgentSharesResponse>> {
|
||||
try {
|
||||
const response = await get(`/api/v1/organizations/${orgId}/agent-shares`)
|
||||
return response as unknown as ApiResponse<ListAgentSharesResponse>
|
||||
} catch (error: any) {
|
||||
return { success: false, message: error.message || 'Failed to list organization agent shares' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search candidate tenants for inviting to organization (excludes tenants
|
||||
* already in the org). The endpoint matches by tenant name, username, or
|
||||
* email and de-duplicates results by tenant_id.
|
||||
*/
|
||||
export async function searchTenantsForInvite(
|
||||
orgId: string,
|
||||
query: string,
|
||||
limit: number = 10
|
||||
): Promise<ApiResponse<TenantInviteCandidate[]>> {
|
||||
try {
|
||||
const response = await get(`/api/v1/organizations/${orgId}/search-tenants?q=${encodeURIComponent(query)}&limit=${limit}`)
|
||||
return response as unknown as ApiResponse<TenantInviteCandidate[]>
|
||||
} catch (error: any) {
|
||||
return { success: false, message: error.message || 'Failed to search tenants' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use `searchTenantsForInvite`. Kept for callers that haven't
|
||||
* migrated yet; the backend serves the tenant-grouped shape from the
|
||||
* legacy `/search-users` path as well.
|
||||
*/
|
||||
export async function searchUsersForInvite(
|
||||
orgId: string,
|
||||
query: string,
|
||||
limit: number = 10
|
||||
): Promise<ApiResponse<TenantInviteCandidate[]>> {
|
||||
return searchTenantsForInvite(orgId, query, limit)
|
||||
}
|
||||
|
||||
/**
|
||||
* Invite a user to organization directly (admin only)
|
||||
*/
|
||||
export async function inviteMember(
|
||||
orgId: string,
|
||||
req: InviteMemberRequest
|
||||
): Promise<ApiResponse<void>> {
|
||||
try {
|
||||
const response = await post(`/api/v1/organizations/${orgId}/invite`, req)
|
||||
return response as unknown as ApiResponse<void>
|
||||
} catch (error: any) {
|
||||
return { success: false, message: error.message || 'Failed to invite member' }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { get, post, put } from '@/utils/request'
|
||||
|
||||
export type ResourceKind = 'agents' | 'skills' | 'knowledge' | 'digital-humans' | 'flows'
|
||||
export interface VersionResource { kind: ResourceKind; id: string; name: string; owner_name: string; hidden: boolean; can_fork: boolean }
|
||||
export interface ResourceFork { id: string; owner_id: string; kind: ResourceKind; source_id: string; personal_id: string; name: string; status: string; base_fingerprint: string; base_snapshot: Record<string, unknown>; no_export: boolean; hidden: boolean; created_at: string }
|
||||
export interface ResourceSubmission { id: string; owner_id: string; owner_name: string; fork_id: string; kind: ResourceKind; source_id: string; personal_id: string; name: string; status: string; base_snapshot: Record<string, unknown>; proposed_snapshot: Record<string, unknown>; note: string; review_note: string; created_at: string; reviewed_at?: string; historical?: boolean; snapshot_id?: string; published_id?: string }
|
||||
export interface KnowledgeVersionJob { id: string; fork_id: string; submission_id?: string; purpose: string; status: string; processed: number; total: number; attempts: number; error?: string }
|
||||
export interface VersionDetail { fork: ResourceFork; snapshot: Record<string, any>; fingerprint: string }
|
||||
const root = '/api/v1/resource-versions'
|
||||
export const getVersionCatalog = () => get<{data: VersionResource[]}>(`${root}/catalog`)
|
||||
export const getResourceVersions = () => get<{data: {forks: ResourceFork[]; submissions: ResourceSubmission[]; history: ResourceSubmission[]; jobs?: KnowledgeVersionJob[]}}>(root)
|
||||
export const getResourceVersion = (id: string) => get<{data: VersionDetail}>(`${root}/forks/${encodeURIComponent(id)}`)
|
||||
export const forkResource = (kind: ResourceKind, id: string) => post<{data: ResourceFork}>(`${root}/${kind}/${encodeURIComponent(id)}/fork`, {})
|
||||
export const hideResource = (kind: ResourceKind, id: string, hidden: boolean) => put(`${root}/${kind}/${encodeURIComponent(id)}/hidden`, {hidden})
|
||||
export const editResourceVersion = (id: string, fingerprint: string, snapshot: Record<string, unknown>) => put(`${root}/forks/${encodeURIComponent(id)}`, {fingerprint, snapshot})
|
||||
export const submitResourceVersion = (id: string, note: string) => post(`${root}/forks/${encodeURIComponent(id)}/submit`, {note})
|
||||
export const rebaseResourceVersion = (id: string) => post(`${root}/forks/${encodeURIComponent(id)}/rebase`, {})
|
||||
export const withdrawResourceVersion = (id: string) => post(`${root}/submissions/${encodeURIComponent(id)}/withdraw`, {})
|
||||
export const reviewResourceVersion = (id: string, approve: boolean, note: string) => post(`${root}/submissions/${encodeURIComponent(id)}/review`, {approve, note})
|
||||
export const retryKnowledgeVersionJob = (id: string) => post(`${root}/jobs/${encodeURIComponent(id)}/retry`, {})
|
||||
export interface KnowledgeReviewDocument { id: string; title: string; type: string }
|
||||
export interface KnowledgeReviewChunk { id: string; content: string; chunk_type: string; chunk_index: number; is_enabled: boolean }
|
||||
export interface KnowledgeReviewPage { documents?: KnowledgeReviewDocument[]; document?: KnowledgeReviewDocument; chunks?: KnowledgeReviewChunk[]; page: number; page_size: number; total: number }
|
||||
export const getKnowledgeVersionReview = (id: string, page: number, documentId = '') => get<{data: KnowledgeReviewPage}>(`${root}/submissions/${encodeURIComponent(id)}/knowledge?page=${page}${documentId ? `&document_id=${encodeURIComponent(documentId)}` : ''}`)
|
||||
@@ -0,0 +1,22 @@
|
||||
import { get, put } from '@/utils/request'
|
||||
|
||||
// RetrievalConfig represents the global retrieval/search configuration for a tenant.
|
||||
// Shared by knowledge search and message search.
|
||||
export interface RetrievalConfig {
|
||||
embedding_top_k: number
|
||||
vector_threshold: number
|
||||
keyword_threshold: number
|
||||
rerank_top_k: number
|
||||
rerank_threshold: number
|
||||
rerank_model_id: string
|
||||
}
|
||||
|
||||
// Get tenant retrieval config via KV API
|
||||
export function getTenantRetrievalConfig() {
|
||||
return get('/api/v1/tenants/kv/retrieval-config')
|
||||
}
|
||||
|
||||
// Update tenant retrieval config via KV API
|
||||
export function updateTenantRetrievalConfig(config: RetrievalConfig) {
|
||||
return put('/api/v1/tenants/kv/retrieval-config', config)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { del, get, post, postUpload } from "../../utils/request";
|
||||
import type { ConfigSkillFileContent, ConfigSkillFileEntry } from "../system";
|
||||
|
||||
// Skill信息
|
||||
export interface SkillInfo {
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface SkillCatalogInstall {
|
||||
skill_id: string;
|
||||
sandbox_config_id: string;
|
||||
sandbox_config_name?: string;
|
||||
sandbox_type?: string;
|
||||
status: string;
|
||||
enabled: boolean;
|
||||
error?: string;
|
||||
bundle_sha256?: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface SkillCatalogItem {
|
||||
id: string;
|
||||
name: string;
|
||||
version?: string;
|
||||
description?: string;
|
||||
bundle_sha256?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
installations: SkillCatalogInstall[];
|
||||
}
|
||||
|
||||
export interface SkillCatalogRegisterResult {
|
||||
id: string;
|
||||
name: string;
|
||||
version?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// 获取当前沙箱配置上可执行的 Skills;未传 sandboxConfigId 或
|
||||
// skills_available 为 false 时,前端应隐藏/禁用 Skills 配置
|
||||
export function listSkills(sandboxConfigId?: string) {
|
||||
return get<{ data: SkillInfo[]; skills_available?: boolean }>('/api/v1/skills', {
|
||||
params: sandboxConfigId ? { sandbox_config_id: sandboxConfigId } : {},
|
||||
});
|
||||
}
|
||||
|
||||
export function listSkillCatalog() {
|
||||
return get<{ data: SkillCatalogItem[] }>('/api/v1/skills/catalog');
|
||||
}
|
||||
|
||||
export function registerSkillCatalogFromSource(source: string) {
|
||||
return post<{ data: SkillCatalogRegisterResult }>('/api/v1/skills/catalog', { source }, {
|
||||
timeout: 2 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function registerSkillCatalogFromFile(
|
||||
file: File,
|
||||
onProgress?: (percent: number) => void,
|
||||
) {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
return postUpload('/api/v1/skills/catalog', form, (e: any) => {
|
||||
if (e.total) onProgress?.(Math.round((e.loaded * 100) / e.total));
|
||||
}, { timeout: 5 * 60 * 1000 }) as Promise<{ data: SkillCatalogRegisterResult }>;
|
||||
}
|
||||
|
||||
export function installSkillCatalog(catalogId: string, sandboxConfigIds: string[]) {
|
||||
return post<{ data: { installs: Record<string, string>; errors?: Record<string, string> } }>(
|
||||
`/api/v1/skills/catalog/${catalogId}/install`,
|
||||
{ sandbox_config_ids: sandboxConfigIds },
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteSkillCatalog(catalogId: string) {
|
||||
return del(`/api/v1/skills/catalog/${catalogId}`);
|
||||
}
|
||||
|
||||
export function listCatalogSkillFiles(catalogId: string) {
|
||||
return get<{ data: ConfigSkillFileEntry[] }>(`/api/v1/skills/catalog/${catalogId}/files`);
|
||||
}
|
||||
|
||||
export function getCatalogSkillFile(catalogId: string, path: string) {
|
||||
return get<{ data: ConfigSkillFileContent }>(`/api/v1/skills/catalog/${catalogId}/files/content`, {
|
||||
params: { path },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { del, get, post, postUpload } from '@/utils/request'
|
||||
|
||||
export interface InstructionSkill {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
version?: string
|
||||
created_by?: string
|
||||
version_source_id?: string
|
||||
creator_name?: string
|
||||
visibility?: 'private' | 'global'
|
||||
enabled?: boolean
|
||||
instruction_only: true
|
||||
updated_at: string
|
||||
instructions?: string
|
||||
}
|
||||
|
||||
export function listInstructionSkills() {
|
||||
return get<{ data: InstructionSkill[] }>('/api/v1/skills/instructions')
|
||||
}
|
||||
|
||||
export function getInstructionSkill(id: string) {
|
||||
return get<{ data: InstructionSkill }>(`/api/v1/skills/instructions/${encodeURIComponent(id)}`)
|
||||
}
|
||||
|
||||
export function deleteInstructionSkill(id: string, detach = false) {
|
||||
return del(`/api/v1/skills/instructions/${encodeURIComponent(id)}${detach ? '?detach=true' : ''}`)
|
||||
}
|
||||
|
||||
export function importInstructionSkill(file: File, name: string, description: string, onProgress: (percent: number) => void) {
|
||||
const data = new FormData()
|
||||
data.append('file', file)
|
||||
if (name.trim()) data.append('name', name.trim())
|
||||
if (description.trim()) data.append('description', description.trim())
|
||||
return postUpload('/api/v1/skills/instructions/import', data, (event: { loaded: number; total?: number }) => {
|
||||
if (event.total) onProgress(Math.min(100, Math.round(event.loaded * 100 / event.total)))
|
||||
}) as Promise<{ data: InstructionSkill }>
|
||||
}
|
||||
|
||||
export function mountInstructionSkills(agentIDs: string[], skillIDs: string[], mode: 'add' | 'remove') {
|
||||
return post<{ data: Array<{ agent_id: string; instruction_skill_ids: string[]; updated_at: string }> }>('/api/v1/skills/instructions/mount', {
|
||||
agent_ids: agentIDs,
|
||||
skill_ids: skillIDs,
|
||||
mode,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { get, post, put, del } from '@/utils/request'
|
||||
|
||||
export interface StorageBackendConfig {
|
||||
mode?: string
|
||||
endpoint?: string
|
||||
region?: string
|
||||
access_key_id?: string
|
||||
secret_access_key?: string
|
||||
bucket_name?: string
|
||||
path_prefix?: string
|
||||
app_id?: string
|
||||
use_ssl?: boolean
|
||||
force_path_style?: boolean
|
||||
use_temp_bucket?: boolean
|
||||
temp_bucket_name?: string
|
||||
temp_region?: string
|
||||
}
|
||||
|
||||
export interface StorageBackend {
|
||||
id: string
|
||||
tenant_id?: number
|
||||
name: string
|
||||
provider: string
|
||||
config: StorageBackendConfig
|
||||
source: 'user' | 'env'
|
||||
status: 'active' | 'disabled'
|
||||
legacy_alias?: boolean
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
export interface StorageBackendListResponse {
|
||||
success: boolean
|
||||
data: StorageBackend[]
|
||||
default_storage_backend_id?: string | null
|
||||
}
|
||||
|
||||
export const listStorageBackends = (): Promise<StorageBackendListResponse> => get('/api/v1/storage-backends')
|
||||
export const listStorageBackendTypes = (): Promise<{ success: boolean; data: string[] }> => get('/api/v1/storage-backends/types')
|
||||
export const createStorageBackend = (data: Partial<StorageBackend>) => post('/api/v1/storage-backends', data)
|
||||
export const updateStorageBackend = (id: string, data: Partial<StorageBackend>) => put(`/api/v1/storage-backends/${id}`, data)
|
||||
export const deleteStorageBackend = (id: string) => del(`/api/v1/storage-backends/${id}`)
|
||||
export const setDefaultStorageBackend = (id: string) => put(`/api/v1/storage-backends/${id}/default`, {})
|
||||
export const testStorageBackend = (data: Partial<StorageBackend>) => post('/api/v1/storage-backends/test', data)
|
||||
export const testStorageBackendByID = (id: string) => post(`/api/v1/storage-backends/${id}/test`, {})
|
||||
@@ -0,0 +1,81 @@
|
||||
import { get } from '@/utils/request'
|
||||
|
||||
// AuditAction mirrors internal/types/audit_log.go's namespaced action
|
||||
// enum. The dot prefix (`rbac.`) is deliberate — future PRs will add
|
||||
// `kb.*` / `agent.*` namespaces without a schema change, and the
|
||||
// backend already treats this column as an opaque string. Keep this
|
||||
// list in sync with types/audit_log.go.
|
||||
export type AuditAction =
|
||||
| 'rbac.member_added'
|
||||
| 'rbac.member_removed'
|
||||
| 'rbac.member_role_changed'
|
||||
| 'rbac.member_left'
|
||||
| 'rbac.access_denied'
|
||||
| string // forward-compat: future namespaces shouldn't break the type
|
||||
|
||||
export type AuditOutcome = 'accepted' | 'success' | 'failed' | 'partial' | 'canceled' | 'denied'
|
||||
|
||||
// AuditLog mirrors internal/types/audit_log.go. `details` is the JSONB
|
||||
// blob — for role changes it carries `{"old_role":..., "new_role":...}`,
|
||||
// for access_denied it carries `{"required_role":...}`. We keep it as
|
||||
// an opaque record so future detail shapes don't need a frontend
|
||||
// breaking change.
|
||||
export interface AuditLog {
|
||||
id: number
|
||||
tenant_id: number
|
||||
actor_user_id: string
|
||||
actor_role: string
|
||||
action: AuditAction
|
||||
scope_type: string
|
||||
scope_id: string
|
||||
target_type: string
|
||||
target_id: string
|
||||
target_user_id: string
|
||||
request_path: string
|
||||
request_method: string
|
||||
outcome: AuditOutcome
|
||||
details: Record<string, unknown> | string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface ListAuditLogResponse {
|
||||
success: boolean
|
||||
data?: AuditLog[]
|
||||
next_cursor?: number
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface ListAuditLogParams {
|
||||
// Cursor: rows with id < after_id, newest first. Pass the
|
||||
// previous response's `next_cursor`. Omit on first page.
|
||||
after_id?: number
|
||||
// Page size, 1–100. Server defaults to 50 if omitted.
|
||||
limit?: number
|
||||
// Optional filters; backend matches on equality.
|
||||
action?: AuditAction
|
||||
outcome?: AuditOutcome
|
||||
actor?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* List the per-tenant audit log. Cursor-paginated by descending id.
|
||||
* Backend: GET /api/v1/tenants/:id/audit-log (Admin+).
|
||||
*
|
||||
* The first call should pass no cursor. Each subsequent page should
|
||||
* pass `after_id = previousResponse.next_cursor` until next_cursor
|
||||
* comes back as 0 (no older rows).
|
||||
*/
|
||||
export async function listAuditLog(
|
||||
tenantId: number,
|
||||
params: ListAuditLogParams = {},
|
||||
): Promise<ListAuditLogResponse> {
|
||||
const qs = new URLSearchParams()
|
||||
if (params.after_id) qs.append('after_id', String(params.after_id))
|
||||
if (params.limit) qs.append('limit', String(params.limit))
|
||||
if (params.action) qs.append('action', params.action)
|
||||
if (params.outcome) qs.append('outcome', params.outcome)
|
||||
if (params.actor) qs.append('actor', params.actor)
|
||||
const tail = qs.toString()
|
||||
const url = `/api/v1/tenants/${tenantId}/audit-log${tail ? '?' + tail : ''}`
|
||||
return (await get(url)) as unknown as ListAuditLogResponse
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
import { del, get, post, put } from '@/utils/request'
|
||||
import i18n from '@/i18n'
|
||||
|
||||
const t = (key: string) => i18n.global.t(key)
|
||||
|
||||
// 空间信息接口
|
||||
export interface TenantInfo {
|
||||
id: number
|
||||
name: string
|
||||
description?: string
|
||||
status?: string
|
||||
business?: string
|
||||
storage_quota?: number
|
||||
storage_used?: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type APIPrincipalMode = 'tenant' | 'direct_header' | 'signed_token'
|
||||
|
||||
export interface APIPrincipalConfig {
|
||||
mode: APIPrincipalMode
|
||||
direct_header_name: string
|
||||
signed_token_header_name: string
|
||||
require_direct_header: boolean
|
||||
// The server never returns the plaintext secret; only its presence.
|
||||
has_hmac_secret: boolean
|
||||
}
|
||||
|
||||
export interface UpdateAPIPrincipalConfigPayload {
|
||||
mode: APIPrincipalMode
|
||||
direct_header_name?: string
|
||||
signed_token_header_name?: string
|
||||
require_direct_header?: boolean
|
||||
hmac_secret?: string
|
||||
}
|
||||
|
||||
export interface CreateAPIPrincipalTestTokenPayload {
|
||||
external_user_id: string
|
||||
expires_in_seconds?: number
|
||||
}
|
||||
|
||||
export interface APIPrincipalTestToken {
|
||||
token: string
|
||||
header_name: string
|
||||
expires_in_seconds: number
|
||||
expires_at_unix: number
|
||||
external_user_id: string
|
||||
}
|
||||
|
||||
// Bounded per-key grants for non-full-access API keys.
|
||||
// - 'retrieve': read/search knowledge-base data within scope
|
||||
// - 'chat': run the conversation flow (sessions + agent listing + self identity)
|
||||
// - 'read_agents': list/read agents without chat or authoring
|
||||
// - 'ingest': write content into allowed knowledge bases (docs/chunks/FAQ/tags/wiki)
|
||||
// - 'manage_kbs': manage the KB lifecycle (create/copy/duplicate/update/delete + config)
|
||||
// - 'manage_agents': create/update/delete/copy agents
|
||||
// - 'message_history': search/read tenant chat-history metadata
|
||||
// - 'manage_models': manage tenant model definitions, checks, and credentials
|
||||
// - 'manage_mcp_services': manage MCP services, credentials, tool policies, and OAuth state
|
||||
// - 'manage_datasources': manage data-source connectors and sync jobs
|
||||
// - 'manage_channels': manage embed and IM channels
|
||||
// - 'manage_vector_stores': manage vector stores and parser/storage checks
|
||||
// - 'manage_web_search': manage web-search providers
|
||||
// - 'run_evaluations': run/read evaluation jobs
|
||||
// - 'manage_members': manage tenant members and invitations
|
||||
// - 'manage_spaces': manage organization/space collaboration
|
||||
// - 'manage_tenant_settings': read/update tenant integration settings (API principal mode, headers, tenant KV)
|
||||
export type TenantAPIKeyCapability =
|
||||
| 'retrieve'
|
||||
| 'chat'
|
||||
| 'read_agents'
|
||||
| 'ingest'
|
||||
| 'manage_kbs'
|
||||
| 'manage_agents'
|
||||
| 'message_history'
|
||||
| 'manage_models'
|
||||
| 'manage_mcp_services'
|
||||
| 'manage_datasources'
|
||||
| 'manage_channels'
|
||||
| 'manage_vector_stores'
|
||||
| 'manage_storage_backends'
|
||||
| 'manage_web_search'
|
||||
| 'run_evaluations'
|
||||
| 'manage_members'
|
||||
| 'manage_spaces'
|
||||
| 'manage_tenant_settings'
|
||||
| 'system_tenants_read'
|
||||
| 'system_tenants_manage'
|
||||
| 'system_settings_read'
|
||||
| 'system_settings_manage'
|
||||
| 'system_runtime_read'
|
||||
| 'system_runtime_manage'
|
||||
| 'system_audit_read'
|
||||
|
||||
export interface TenantAPIKey {
|
||||
id: number
|
||||
scope_type?: 'tenant' | 'platform'
|
||||
name: string
|
||||
api_key: string
|
||||
full_access: boolean
|
||||
knowledge_base_ids: string[] | null
|
||||
capabilities?: TenantAPIKeyCapability[]
|
||||
last_used_at?: string
|
||||
expires_at?: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface CreatedTenantAPIKey extends TenantAPIKey {
|
||||
token?: string
|
||||
}
|
||||
|
||||
export interface CreateTenantAPIKeyPayload {
|
||||
name: string
|
||||
full_access?: boolean
|
||||
knowledge_base_ids?: string[]
|
||||
capabilities?: TenantAPIKeyCapability[]
|
||||
expires_at_unix?: number
|
||||
}
|
||||
|
||||
export interface UpdateTenantAPIKeyPayload {
|
||||
name: string
|
||||
full_access: boolean
|
||||
knowledge_base_ids: string[]
|
||||
capabilities: TenantAPIKeyCapability[]
|
||||
expires_at_unix?: number
|
||||
}
|
||||
|
||||
// 搜索空间参数
|
||||
export interface SearchTenantsParams {
|
||||
keyword?: string
|
||||
tenant_id?: number
|
||||
page?: number
|
||||
page_size?: number
|
||||
}
|
||||
|
||||
// 搜索空间响应
|
||||
export interface SearchTenantsResponse {
|
||||
success: boolean
|
||||
data?: {
|
||||
items: TenantInfo[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
message?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有空间列表(需要跨空间访问权限)
|
||||
* @deprecated 建议使用 searchTenants 代替,支持分页和搜索
|
||||
*/
|
||||
export async function listAllTenants(): Promise<{ success: boolean; data?: { items: TenantInfo[] }; message?: string }> {
|
||||
try {
|
||||
const response = await get('/api/v1/tenants/all')
|
||||
return response as unknown as { success: boolean; data?: { items: TenantInfo[] }; message?: string }
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: error.message || t('error.tenant.listFailed')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAPIPrincipalConfig(
|
||||
tenantId: number,
|
||||
): Promise<{ success: boolean; data?: APIPrincipalConfig; message?: string }> {
|
||||
try {
|
||||
const response = await get(`/api/v1/tenants/${tenantId}/api-principal-config`)
|
||||
return response as unknown as { success: boolean; data?: APIPrincipalConfig; message?: string }
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: error.message || t('error.tenant.getApiPrincipalConfigFailed'),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateAPIPrincipalConfig(
|
||||
tenantId: number,
|
||||
payload: UpdateAPIPrincipalConfigPayload,
|
||||
): Promise<{ success: boolean; data?: APIPrincipalConfig; message?: string }> {
|
||||
try {
|
||||
const response = await put(`/api/v1/tenants/${tenantId}/api-principal-config`, payload)
|
||||
return response as unknown as { success: boolean; data?: APIPrincipalConfig; message?: string }
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: error.message || t('error.tenant.updateApiPrincipalConfigFailed'),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function createAPIPrincipalTestToken(
|
||||
tenantId: number,
|
||||
payload: CreateAPIPrincipalTestTokenPayload,
|
||||
): Promise<{ success: boolean; data?: APIPrincipalTestToken; message?: string }> {
|
||||
try {
|
||||
const response = await post(`/api/v1/tenants/${tenantId}/api-principal-test-token`, payload)
|
||||
return response as unknown as { success: boolean; data?: APIPrincipalTestToken; message?: string }
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: error.message || t('error.tenant.createApiPrincipalTestTokenFailed'),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function listTenantAPIKeys(
|
||||
tenantId: number,
|
||||
): Promise<{ success: boolean; data?: TenantAPIKey[]; message?: string }> {
|
||||
try {
|
||||
const response = await get(`/api/v1/tenants/${tenantId}/api-keys`)
|
||||
return response as unknown as { success: boolean; data?: TenantAPIKey[]; message?: string }
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: error.message || t('error.tenant.listApiKeysFailed'),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function createTenantAPIKey(
|
||||
tenantId: number,
|
||||
payload: CreateTenantAPIKeyPayload,
|
||||
): Promise<{ success: boolean; data?: CreatedTenantAPIKey; message?: string }> {
|
||||
try {
|
||||
const response = await post(`/api/v1/tenants/${tenantId}/api-keys`, payload)
|
||||
return response as unknown as { success: boolean; data?: CreatedTenantAPIKey; message?: string }
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: error.message || t('error.tenant.createApiKeyFailed'),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 更新已创建租户 API Key 的授权范围和其他可配置属性。 */
|
||||
export async function updateTenantAPIKey(
|
||||
tenantId: number,
|
||||
keyId: number,
|
||||
payload: UpdateTenantAPIKeyPayload,
|
||||
): Promise<{ success: boolean; data?: TenantAPIKey; message?: string }> {
|
||||
try {
|
||||
const response = await put(`/api/v1/tenants/${tenantId}/api-keys/${keyId}`, payload)
|
||||
return response as unknown as { success: boolean; data?: TenantAPIKey; message?: string }
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: error.message || t('integrations.api.updateApiKeyScopeFailed'),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteTenantAPIKey(
|
||||
tenantId: number,
|
||||
keyId: number,
|
||||
): Promise<{ success: boolean; message?: string }> {
|
||||
try {
|
||||
const response = await del(`/api/v1/tenants/${tenantId}/api-keys/${keyId}`)
|
||||
return response as unknown as { success: boolean; message?: string }
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: error.message || t('error.tenant.deleteApiKeyFailed'),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新空间信息(目前暴露名称、描述两个字段的编辑入口)。
|
||||
* 后端 `PUT /tenants/:id` 用指针字段区分"未传"和"显式空串",未传的列不会
|
||||
* 被改动;这里也按需选择性传 `name` / `description`,互不影响。
|
||||
* 权限:owner(与 router.go 中的 g.Owner() 守卫保持一致)。
|
||||
*/
|
||||
export async function updateTenant(
|
||||
tenantId: number,
|
||||
payload: { name?: string; description?: string },
|
||||
): Promise<{ success: boolean; data?: TenantInfo; message?: string }> {
|
||||
try {
|
||||
const response = await put(`/api/v1/tenants/${tenantId}`, payload)
|
||||
return response as unknown as { success: boolean; data?: TenantInfo; message?: string }
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: error.message || t('error.tenant.updateFailed'),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除当前工作区。权限:owner。
|
||||
*/
|
||||
export async function deleteTenant(
|
||||
tenantId: number,
|
||||
): Promise<{ success: boolean; message?: string }> {
|
||||
try {
|
||||
const response = await del(`/api/v1/tenants/${tenantId}`)
|
||||
return response as unknown as { success: boolean; message?: string }
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: error.message || t('error.tenant.deleteFailed'),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新工作区(任意已登录用户均可调用)。
|
||||
* 后端会自动把调用者写成新空间的 Owner,并填充默认 storage_quota
|
||||
* 等服务端字段;API Key 由用户在集成页手动创建。
|
||||
* 路由:POST /api/v1/tenants(router 上不挂 g.CrossTenant(),自助场景使用)。
|
||||
*/
|
||||
export async function createTenant(
|
||||
payload: { name: string; description?: string },
|
||||
): Promise<{ success: boolean; data?: TenantInfo; message?: string }> {
|
||||
try {
|
||||
const response = await post('/api/v1/tenants', payload)
|
||||
return response as unknown as { success: boolean; data?: TenantInfo; message?: string }
|
||||
} catch (error: any) {
|
||||
const code = error?.error?.code ?? error?.code
|
||||
return {
|
||||
success: false,
|
||||
message: code === 2005
|
||||
? t('tenant.create.disabled')
|
||||
: (error.message || t('error.tenant.createFailed')),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索空间(支持分页、关键词搜索和空间ID过滤)
|
||||
*/
|
||||
export async function searchTenants(params: SearchTenantsParams = {}): Promise<SearchTenantsResponse> {
|
||||
try {
|
||||
const queryParams = new URLSearchParams()
|
||||
if (params.keyword) {
|
||||
queryParams.append('keyword', params.keyword)
|
||||
}
|
||||
if (params.tenant_id) {
|
||||
queryParams.append('tenant_id', String(params.tenant_id))
|
||||
}
|
||||
if (params.page) {
|
||||
queryParams.append('page', String(params.page))
|
||||
}
|
||||
if (params.page_size) {
|
||||
queryParams.append('page_size', String(params.page_size))
|
||||
}
|
||||
|
||||
const queryString = queryParams.toString()
|
||||
const url = `/api/v1/tenants/search${queryString ? '?' + queryString : ''}`
|
||||
const response = await get(url)
|
||||
return response as unknown as SearchTenantsResponse
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: error.message || t('error.tenant.searchFailed')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import { get, post, del } from '@/utils/request'
|
||||
import type { TenantMember, TenantRole } from '@/api/tenant/members'
|
||||
|
||||
// TenantInvitationStatus mirrors internal/types/tenant_invitation.go's
|
||||
// five-state machine. pending is the only non-terminal state; the rest
|
||||
// are recorded for the audit trail.
|
||||
export type TenantInvitationStatus =
|
||||
| 'pending'
|
||||
| 'accepted'
|
||||
| 'declined'
|
||||
| 'revoked'
|
||||
| 'expired'
|
||||
|
||||
// TenantInvitation is the API projection of a tenant_invitations row,
|
||||
// hydrated with the inviter / invitee user fields and the tenant name
|
||||
// when the backend has them. Missing optional fields render as the
|
||||
// raw id in the UI rather than dropping the row.
|
||||
export interface TenantInvitation {
|
||||
id: number
|
||||
tenant_id: number
|
||||
tenant_name?: string
|
||||
invitee_user_id: string
|
||||
invitee_email?: string
|
||||
invitee_name?: string
|
||||
invited_by?: string | null
|
||||
inviter_email?: string
|
||||
inviter_name?: string
|
||||
role: TenantRole
|
||||
status: TenantInvitationStatus
|
||||
message?: string
|
||||
expires_at: string
|
||||
responded_at?: string | null
|
||||
created_at: string
|
||||
// invite_url is set on share-link rows that are still pending. The
|
||||
// backend re-emits it on every list/get so Owners can copy the
|
||||
// link on demand without "copy now or revoke" pressure.
|
||||
invite_url?: string
|
||||
// is_share_link distinguishes share-link rows (no specific invitee,
|
||||
// multi-use, copyable URL) from per-user invitations.
|
||||
is_share_link?: boolean
|
||||
// accepted_count counts how many users have completed registration
|
||||
// through this invitation. Surfaced in the management UI for
|
||||
// share-link rows ("已加入 N 人").
|
||||
accepted_count?: number
|
||||
}
|
||||
|
||||
export interface ListInvitationsResponse {
|
||||
success: boolean
|
||||
data?: {
|
||||
invitations: TenantInvitation[]
|
||||
total: number
|
||||
page?: number
|
||||
page_size?: number
|
||||
}
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface ListTenantInvitationsParams {
|
||||
includeTerminal?: boolean
|
||||
page?: number
|
||||
page_size?: number
|
||||
}
|
||||
|
||||
function buildTenantInvitationsQuery(options: ListTenantInvitationsParams): string {
|
||||
const u = new URLSearchParams()
|
||||
if (options.includeTerminal) u.set('include_terminal', 'true')
|
||||
if (options.page != null && options.page > 0) u.set('page', String(options.page))
|
||||
if (options.page_size != null && options.page_size > 0) u.set('page_size', String(options.page_size))
|
||||
const qs = u.toString()
|
||||
return qs ? `?${qs}` : ''
|
||||
}
|
||||
|
||||
export interface CreateInvitationRequest {
|
||||
email: string
|
||||
role: TenantRole
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface CreateInvitationResponse {
|
||||
success: boolean
|
||||
// With tenant.auto_accept_invitation enabled the backend returns a
|
||||
// TenantMember (user_id set) instead of a pending TenantInvitation.
|
||||
data?: TenantInvitation | TenantMember
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface SimpleResponse {
|
||||
success: boolean
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface AcceptInvitationResponse {
|
||||
success: boolean
|
||||
data?: {
|
||||
membership: {
|
||||
tenant_id: number
|
||||
role: TenantRole
|
||||
status: string
|
||||
joined_at: string
|
||||
}
|
||||
}
|
||||
message?: string
|
||||
}
|
||||
|
||||
// AcceptInvitationByTokenResponse:已登录用户用 token 加入空间的响应(tenant_name 供前端展示)。
|
||||
export interface AcceptInvitationByTokenResponse {
|
||||
success: boolean
|
||||
data?: {
|
||||
membership: {
|
||||
tenant_id: number
|
||||
role: TenantRole
|
||||
status: string
|
||||
joined_at: string
|
||||
}
|
||||
tenant_name?: string
|
||||
}
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface PendingCountResponse {
|
||||
success: boolean
|
||||
data?: { pending_count: number }
|
||||
message?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* List invitations for a tenant. Defaults to pending only; pass
|
||||
* includeTerminal=true to also surface accepted/declined/revoked/
|
||||
* expired rows for the history view. Supports `page` / `page_size`.
|
||||
* Backend: GET /api/v1/tenants/:id/invitations (Viewer+).
|
||||
*/
|
||||
export async function listTenantInvitations(
|
||||
tenantId: number,
|
||||
options: ListTenantInvitationsParams = {},
|
||||
): Promise<ListInvitationsResponse> {
|
||||
const qs = buildTenantInvitationsQuery(options)
|
||||
return (await get(
|
||||
`/api/v1/tenants/${tenantId}/invitations${qs}`,
|
||||
)) as unknown as ListInvitationsResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a new invitation. The invitee will see it in /me/invitations
|
||||
* and must accept before they actually become a member.
|
||||
* Backend: POST /api/v1/tenants/:id/invitations (Owner+).
|
||||
*
|
||||
* 404 when the email is not a registered user (ask them to register).
|
||||
* 409 when an existing pending invitation already covers this pair, or
|
||||
* when the invitee is already an active member.
|
||||
*/
|
||||
export async function createInvitation(
|
||||
tenantId: number,
|
||||
body: CreateInvitationRequest,
|
||||
): Promise<CreateInvitationResponse> {
|
||||
return (await post(
|
||||
`/api/v1/tenants/${tenantId}/invitations`,
|
||||
body,
|
||||
)) as unknown as CreateInvitationResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a still-pending invitation. Already-finalised rows return
|
||||
* 409; rows from another tenant render as 404 to avoid existence
|
||||
* leaks across tenants.
|
||||
* Backend: DELETE /api/v1/tenants/:id/invitations/:inv_id (Owner+).
|
||||
*/
|
||||
export async function revokeInvitation(
|
||||
tenantId: number,
|
||||
invId: number,
|
||||
): Promise<SimpleResponse> {
|
||||
return (await del(
|
||||
`/api/v1/tenants/${tenantId}/invitations/${invId}`,
|
||||
)) as unknown as SimpleResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* List MY invitations. Defaults to pending only — the inbox page
|
||||
* filters terminal rows by default; pass includeTerminal=true for a
|
||||
* history view if/when the UI grows one.
|
||||
* Backend: GET /api/v1/me/invitations (authenticated).
|
||||
*/
|
||||
export async function listMyInvitations(
|
||||
options: { includeTerminal?: boolean } = {},
|
||||
): Promise<ListInvitationsResponse> {
|
||||
const qs = options.includeTerminal ? '?include_terminal=true' : ''
|
||||
return (await get(`/api/v1/me/invitations${qs}`)) as unknown as ListInvitationsResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight pending-count endpoint used by the avatar-row badge
|
||||
* poller. Separate from the list endpoint so polling doesn't transfer
|
||||
* the full payload every cycle.
|
||||
* Backend: GET /api/v1/me/invitations/pending-count (authenticated).
|
||||
*/
|
||||
export async function getMyPendingInvitationCount(): Promise<PendingCountResponse> {
|
||||
return (await get(
|
||||
`/api/v1/me/invitations/pending-count`,
|
||||
)) as unknown as PendingCountResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept one of MY pending invitations. On success the backend also
|
||||
* creates the tenant_members row in the same flow; the caller should
|
||||
* then refresh memberships in the auth store.
|
||||
* Backend: POST /api/v1/me/invitations/:inv_id/accept (authenticated).
|
||||
*/
|
||||
export async function acceptInvitation(invId: number): Promise<AcceptInvitationResponse> {
|
||||
return (await post(
|
||||
`/api/v1/me/invitations/${invId}/accept`,
|
||||
)) as unknown as AcceptInvitationResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* 已登录用户用共享链接 token 加入空间(需鉴权,不创建新账号)。
|
||||
* 用于 invite_only 模式下的邀请链接流程:链接导向登录而非注册,登录后再兑换 token。
|
||||
* Backend: POST /api/v1/me/invitations/accept-by-token (authenticated).
|
||||
*/
|
||||
export async function acceptInvitationByToken(
|
||||
token: string,
|
||||
): Promise<AcceptInvitationByTokenResponse> {
|
||||
return (await post(
|
||||
`/api/v1/me/invitations/accept-by-token`,
|
||||
{ token },
|
||||
)) as unknown as AcceptInvitationByTokenResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* Decline one of MY pending invitations.
|
||||
* Backend: POST /api/v1/me/invitations/:inv_id/decline (authenticated).
|
||||
*/
|
||||
export async function declineInvitation(invId: number): Promise<SimpleResponse> {
|
||||
return (await post(
|
||||
`/api/v1/me/invitations/${invId}/decline`,
|
||||
)) as unknown as SimpleResponse
|
||||
}
|
||||
|
||||
// ---- share-link API ----------------------------------------------------
|
||||
|
||||
export interface CreateInviteLinkRequest {
|
||||
role: TenantRole
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface CreateInviteLinkResponse {
|
||||
success: boolean
|
||||
data?: TenantInvitation
|
||||
message?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a multi-use share-link invitation for the tenant. The
|
||||
* returned row carries `invite_url` (composed from the persisted
|
||||
* plaintext token) which the SPA copies into clipboards. The link
|
||||
* stays valid until expiry or revocation; revoking is the same DELETE
|
||||
* as a per-user invitation.
|
||||
*
|
||||
* Backend: POST /api/v1/tenants/:id/invite-links (Owner+).
|
||||
*/
|
||||
export async function createInviteLink(
|
||||
tenantId: number,
|
||||
body: CreateInviteLinkRequest,
|
||||
): Promise<CreateInviteLinkResponse> {
|
||||
return (await post(
|
||||
`/api/v1/tenants/${tenantId}/invite-links`,
|
||||
body,
|
||||
)) as unknown as CreateInviteLinkResponse
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { get, post, put, del } from '@/utils/request'
|
||||
|
||||
// TenantRole mirrors internal/types/tenant_member.go's four-role enum.
|
||||
// Keep the string values aligned with the Go constants.
|
||||
export type TenantRole = 'owner' | 'admin' | 'contributor' | 'viewer'
|
||||
|
||||
export type TenantMemberStatus = 'active' | 'invited' | 'suspended'
|
||||
|
||||
// TenantMember is the API projection of a (user, tenant) membership row,
|
||||
// already joined with the user's email/username/avatar by the backend.
|
||||
export interface TenantMember {
|
||||
user_id: string
|
||||
email: string
|
||||
username: string
|
||||
avatar?: string
|
||||
role: TenantRole
|
||||
status: TenantMemberStatus
|
||||
invited_by?: string | null
|
||||
joined_at: string
|
||||
}
|
||||
|
||||
export interface ListMembersResponse {
|
||||
success: boolean
|
||||
data?: {
|
||||
members: TenantMember[]
|
||||
total: number
|
||||
page?: number
|
||||
page_size?: number
|
||||
}
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface ListMembersParams {
|
||||
page?: number
|
||||
page_size?: number
|
||||
/** 按邮箱/用户名筛选(服务端模糊匹配) */
|
||||
q?: string
|
||||
}
|
||||
|
||||
function buildMembersQuery(params: ListMembersParams | undefined): string {
|
||||
if (!params) return ''
|
||||
const u = new URLSearchParams()
|
||||
if (params.page != null && params.page > 0) u.set('page', String(params.page))
|
||||
if (params.page_size != null && params.page_size > 0) u.set('page_size', String(params.page_size))
|
||||
const q = params.q?.trim()
|
||||
if (q) u.set('q', q)
|
||||
const qs = u.toString()
|
||||
return qs ? `?${qs}` : ''
|
||||
}
|
||||
|
||||
export interface AddMemberRequest {
|
||||
email: string
|
||||
role: TenantRole
|
||||
}
|
||||
|
||||
export interface AddMemberResponse {
|
||||
success: boolean
|
||||
data?: TenantMember
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface SimpleResponse {
|
||||
success: boolean
|
||||
message?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页列出空间成员。
|
||||
* Backend: GET /api/v1/tenants/:id/members (Viewer+)。查询参数:`q`、`page`、`page_size`。
|
||||
*/
|
||||
export async function listMembers(
|
||||
tenantId: number,
|
||||
params: ListMembersParams = {},
|
||||
): Promise<ListMembersResponse> {
|
||||
const qs = buildMembersQuery(params)
|
||||
return (await get(
|
||||
`/api/v1/tenants/${tenantId}/members${qs}`,
|
||||
)) as unknown as ListMembersResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* 遍历分页拉取空间的全部成员(每页最大 100,最多 500 页兜底)。
|
||||
* 用于「退出空间」等对全量成员的轻量校验;普通表格请直接使用 {@link listMembers} 分页接口。
|
||||
*/
|
||||
export async function fetchAllTenantMembers(tenantId: number): Promise<TenantMember[]> {
|
||||
const pageSize = 100
|
||||
let page = 1
|
||||
const out: TenantMember[] = []
|
||||
let total = Number.POSITIVE_INFINITY
|
||||
for (let guard = 0; guard < 500 && out.length < total; guard++) {
|
||||
const resp = await listMembers(tenantId, { page, page_size: pageSize })
|
||||
if (!resp.success || !resp.data) break
|
||||
total = resp.data.total
|
||||
const batch = resp.data.members || []
|
||||
if (batch.length === 0 && page >= 2) break
|
||||
out.push(...batch)
|
||||
if (batch.length < pageSize) break
|
||||
page++
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Invite an existing user (by email) to the tenant with the given role.
|
||||
* Backend: POST /api/v1/tenants/:id/members (Owner+).
|
||||
*
|
||||
* Returns 404 when the email does not match any registered user — the
|
||||
* caller should ask the invitee to register first. PR 3 does not yet
|
||||
* support email-based invites for users who don't have an account.
|
||||
*/
|
||||
export async function addMember(
|
||||
tenantId: number,
|
||||
body: AddMemberRequest,
|
||||
): Promise<AddMemberResponse> {
|
||||
return (await post(`/api/v1/tenants/${tenantId}/members`, body)) as unknown as AddMemberResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* Change an existing member's role.
|
||||
* Backend: PUT /api/v1/tenants/:id/members/:user_id (Owner+).
|
||||
*
|
||||
* Returns 409 when this would demote the last active Owner of the tenant.
|
||||
*/
|
||||
export async function updateMemberRole(
|
||||
tenantId: number,
|
||||
userId: string,
|
||||
role: TenantRole,
|
||||
): Promise<SimpleResponse> {
|
||||
return (await put(`/api/v1/tenants/${tenantId}/members/${userId}`, { role })) as unknown as SimpleResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a member from the tenant.
|
||||
* Backend: DELETE /api/v1/tenants/:id/members/:user_id (Owner+).
|
||||
*
|
||||
* Returns 409 when this would remove the last active Owner.
|
||||
*/
|
||||
export async function removeMember(
|
||||
tenantId: number,
|
||||
userId: string,
|
||||
): Promise<SimpleResponse> {
|
||||
return (await del(`/api/v1/tenants/${tenantId}/members/${userId}`)) as unknown as SimpleResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* Quit the tenant on your own. Same last-Owner invariant as
|
||||
* removeMember, but does NOT require Owner+ — any active member can
|
||||
* call it.
|
||||
* Backend: POST /api/v1/tenants/:id/leave (Viewer+).
|
||||
*/
|
||||
export async function leaveTenant(tenantId: number): Promise<SimpleResponse> {
|
||||
return (await post(`/api/v1/tenants/${tenantId}/leave`)) as unknown as SimpleResponse
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// API client for per-user starred resources (DB-backed; see migration
|
||||
// 000047 and internal/handler/user_resource_favorite.go).
|
||||
//
|
||||
// The backend authoritatively scopes favorites to the active (user, tenant)
|
||||
// pair from the auth context — these helpers therefore never pass user_id
|
||||
// or tenant_id, and a tenant switch automatically points reads at the
|
||||
// right namespace.
|
||||
|
||||
import { get, post, del } from '@/utils/request'
|
||||
|
||||
export type FavoriteResourceType = 'kb' | 'agent'
|
||||
|
||||
export interface FavoriteEntry {
|
||||
user_id: string
|
||||
tenant_id: number
|
||||
resource_type: FavoriteResourceType
|
||||
resource_id: string
|
||||
/** ISO timestamp from the server (created_at column). */
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export function listFavorites(type: FavoriteResourceType) {
|
||||
return get<{ success: boolean; data: FavoriteEntry[] }>(
|
||||
`/api/v1/user/favorites?type=${encodeURIComponent(type)}`
|
||||
)
|
||||
}
|
||||
|
||||
export function addFavorite(type: FavoriteResourceType, id: string) {
|
||||
return post('/api/v1/user/favorites', { type, id })
|
||||
}
|
||||
|
||||
export function removeFavorite(type: FavoriteResourceType, id: string) {
|
||||
return del(`/api/v1/user/favorites/${encodeURIComponent(type)}/${encodeURIComponent(id)}`)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { get, post, put, del } from '@/utils/request'
|
||||
|
||||
// ===== Types =====
|
||||
|
||||
export interface VectorStoreEntity {
|
||||
id?: string
|
||||
name: string
|
||||
engine_type: string
|
||||
connection_config: Record<string, any>
|
||||
index_config: Record<string, any>
|
||||
source: 'env' | 'user'
|
||||
readonly: boolean
|
||||
tenant_id?: number
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
export interface VectorStoreTypeInfo {
|
||||
type: string
|
||||
display_name: string
|
||||
connection_fields: FieldSchema[]
|
||||
index_fields: FieldSchema[]
|
||||
}
|
||||
|
||||
export interface FieldSchema {
|
||||
name: string
|
||||
type: 'string' | 'number' | 'boolean'
|
||||
required: boolean
|
||||
sensitive?: boolean
|
||||
description?: string
|
||||
default?: any
|
||||
// Inclusive bounds for number fields (omitempty on the backend). When
|
||||
// absent the UI falls back to per-field heuristics (isReplicaField).
|
||||
min?: number
|
||||
max?: number
|
||||
// Closed value set for string fields (e.g. knn_engine ∈ lucene|faiss).
|
||||
// When non-empty the UI renders a select instead of a free-text input.
|
||||
enum?: string[]
|
||||
// Marks a field that cannot change after store creation. Informational
|
||||
// for now (edit mode is fully read-only); kept for forward use.
|
||||
immutable?: boolean
|
||||
}
|
||||
|
||||
// ===== API Functions =====
|
||||
|
||||
export function listVectorStoreTypes(): Promise<VectorStoreTypeInfo[]> {
|
||||
return get('/api/v1/vector-stores/types').then((res: any) => {
|
||||
return res.success && res.data ? res.data : []
|
||||
})
|
||||
}
|
||||
|
||||
export function listVectorStores(): Promise<{ success: boolean; data: VectorStoreEntity[] }> {
|
||||
return get('/api/v1/vector-stores')
|
||||
}
|
||||
|
||||
export function createVectorStore(data: Partial<VectorStoreEntity>) {
|
||||
return post('/api/v1/vector-stores', data)
|
||||
}
|
||||
|
||||
export function updateVectorStore(id: string, data: Partial<VectorStoreEntity>) {
|
||||
return put(`/api/v1/vector-stores/${id}`, data)
|
||||
}
|
||||
|
||||
export function deleteVectorStore(id: string) {
|
||||
return del(`/api/v1/vector-stores/${id}`)
|
||||
}
|
||||
|
||||
export function testVectorStoreRaw(data: { engine_type: string; connection_config: any }): Promise<any> {
|
||||
return post('/api/v1/vector-stores/test', data)
|
||||
}
|
||||
|
||||
export function testVectorStoreById(id: string): Promise<any> {
|
||||
return post(`/api/v1/vector-stores/${id}/test`, {})
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { get, post, put, del } from '@/utils/request'
|
||||
|
||||
// WebSearchProviderEntity represents a configured web search provider instance
|
||||
export interface WebSearchProviderEntity {
|
||||
id?: string
|
||||
tenant_id?: number
|
||||
name: string
|
||||
provider: 'bing' | 'google' | 'duckduckgo' | 'tavily' | 'ollama' | 'baidu' | 'searxng' | 'keenable' | 'zhipu' | 'metaso' | 'exa'
|
||||
description?: string
|
||||
parameters: {
|
||||
// api_key is never returned by the server in this shape; it lives behind
|
||||
// the /credentials subresource. Kept on the type so the initial create
|
||||
// POST can still include it.
|
||||
api_key?: string
|
||||
engine_id?: string
|
||||
base_url?: string
|
||||
proxy_url?: string
|
||||
extra_config?: Record<string, string>
|
||||
}
|
||||
is_default?: boolean
|
||||
// Per-field configured? metadata from the main response.
|
||||
credentials?: Record<WebSearchCredentialField, { configured: boolean }>
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
// WebSearchProviderTypeInfo describes metadata for a provider type
|
||||
export interface WebSearchProviderTypeInfo {
|
||||
id: string
|
||||
name: string
|
||||
requires_api_key: boolean
|
||||
// Keyless-by-default providers that still accept an optional key (e.g. Keenable).
|
||||
supports_optional_api_key?: boolean
|
||||
requires_engine_id?: boolean
|
||||
requires_base_url?: boolean
|
||||
supports_proxy?: boolean
|
||||
description?: string
|
||||
docs_url?: string
|
||||
config_fields?: WebSearchProviderConfigField[]
|
||||
}
|
||||
|
||||
export interface WebSearchProviderConfigField {
|
||||
key: string
|
||||
label: string
|
||||
label_key?: string
|
||||
type: 'select'
|
||||
required?: boolean
|
||||
default?: string
|
||||
description?: string
|
||||
description_key?: string
|
||||
options?: Array<{ label: string; label_key?: string; value: string }>
|
||||
}
|
||||
|
||||
// Create a new web search provider
|
||||
export function createWebSearchProvider(data: Partial<WebSearchProviderEntity>) {
|
||||
return post('/api/v1/web-search-providers', data)
|
||||
}
|
||||
|
||||
// List all web search providers for the current tenant
|
||||
export function listWebSearchProviders() {
|
||||
return get('/api/v1/web-search-providers')
|
||||
}
|
||||
|
||||
// Get a single web search provider by ID
|
||||
export function getWebSearchProvider(id: string) {
|
||||
return get(`/api/v1/web-search-providers/${id}`)
|
||||
}
|
||||
|
||||
// Update an existing web search provider
|
||||
export function updateWebSearchProvider(id: string, data: Partial<WebSearchProviderEntity>) {
|
||||
return put(`/api/v1/web-search-providers/${id}`, data)
|
||||
}
|
||||
|
||||
// Delete a web search provider
|
||||
export function deleteWebSearchProvider(id: string) {
|
||||
return del(`/api/v1/web-search-providers/${id}`)
|
||||
}
|
||||
|
||||
// Get available provider types (for dynamic form rendering)
|
||||
export function listWebSearchProviderTypes(): Promise<WebSearchProviderTypeInfo[]> {
|
||||
return get('/api/v1/web-search-providers/types').then((res: any) => {
|
||||
if (res.success && res.data) {
|
||||
return res.data
|
||||
}
|
||||
return []
|
||||
})
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Web search provider credential subresource.
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
export type WebSearchCredentialField = 'api_key'
|
||||
|
||||
export interface WebSearchCredentialsResponse {
|
||||
fields: Record<WebSearchCredentialField, { configured: boolean }>
|
||||
}
|
||||
|
||||
export async function putWebSearchProviderCredentials(
|
||||
id: string,
|
||||
body: Partial<Record<WebSearchCredentialField, string>>,
|
||||
): Promise<WebSearchCredentialsResponse> {
|
||||
const response: any = await put(`/api/v1/web-search-providers/${id}/credentials`, body)
|
||||
return (response.data ?? response) as WebSearchCredentialsResponse
|
||||
}
|
||||
|
||||
export async function deleteWebSearchProviderCredentialField(
|
||||
id: string,
|
||||
field: WebSearchCredentialField,
|
||||
): Promise<void> {
|
||||
await del(`/api/v1/web-search-providers/${id}/credentials/${field}`)
|
||||
}
|
||||
|
||||
// Test a web search provider connection.
|
||||
// If id is provided, tests the existing saved provider.
|
||||
// If data is provided, tests with raw credentials (no persistence).
|
||||
export function testWebSearchProvider(id?: string, data?: { provider: string; parameters: any }): Promise<any> {
|
||||
if (id) {
|
||||
return post(`/api/v1/web-search-providers/${id}/test`, {})
|
||||
}
|
||||
return post('/api/v1/web-search-providers/test', data || {})
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { get, put } from '@/utils/request'
|
||||
|
||||
// WebSearchProviderConfig represents information about a web search provider type
|
||||
// Deprecated: Use WebSearchProviderTypeInfo from web-search-provider.ts instead
|
||||
export interface WebSearchProviderConfig {
|
||||
id: string
|
||||
name: string
|
||||
free: boolean
|
||||
requires_api_key: boolean
|
||||
description?: string
|
||||
api_url?: string
|
||||
}
|
||||
|
||||
// WebSearchConfig represents the web search configuration for a tenant
|
||||
export interface WebSearchConfig {
|
||||
// New: references a WebSearchProviderEntity by ID
|
||||
default_provider_id?: string
|
||||
// Deprecated: kept for backward compatibility
|
||||
provider?: string
|
||||
api_key?: string
|
||||
max_results: number
|
||||
include_date: boolean
|
||||
compression_method: string
|
||||
blacklist: string[]
|
||||
embedding_model_id?: string
|
||||
embedding_dimension?: number
|
||||
rerank_model_id?: string
|
||||
document_fragments?: number
|
||||
}
|
||||
|
||||
// Get web search provider types (available engines)
|
||||
export function getWebSearchProviders() {
|
||||
return get('/api/v1/web-search/providers')
|
||||
}
|
||||
|
||||
// Get tenant web search config via KV API
|
||||
export function getTenantWebSearchConfig() {
|
||||
return get('/api/v1/tenants/kv/web-search-config')
|
||||
}
|
||||
|
||||
// Update tenant web search config via KV API
|
||||
export function updateTenantWebSearchConfig(config: WebSearchConfig) {
|
||||
return put('/api/v1/tenants/kv/web-search-config', config)
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
import { get, post, put, del } from "../../utils/request";
|
||||
|
||||
// encodeSlugPath encodes each segment of a hierarchical wiki slug (e.g.
|
||||
// "foo/bar baz?") so the URL is safe while preserving the "/" separators
|
||||
// between segments. Using encodeURIComponent on the whole slug would also
|
||||
// escape the "/" and break hierarchical routing on the backend.
|
||||
function encodeSlugPath(slug: string): string {
|
||||
return slug.split("/").map(encodeURIComponent).join("/");
|
||||
}
|
||||
|
||||
// Wiki Page Types
|
||||
export interface WikiPage {
|
||||
id: string;
|
||||
tenant_id: number;
|
||||
knowledge_base_id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
page_type: string;
|
||||
status: string;
|
||||
content: string;
|
||||
summary: string;
|
||||
aliases: string[];
|
||||
parent_slug?: string;
|
||||
category_path?: string[];
|
||||
wiki_path?: string;
|
||||
depth?: number;
|
||||
sort_order?: number;
|
||||
source_refs: string[];
|
||||
in_links: string[];
|
||||
out_links: string[];
|
||||
page_metadata: Record<string, any>;
|
||||
version: number;
|
||||
// Author kind of the current version: 'pipeline' | 'agent' | 'user' |
|
||||
// 'revert'. Empty/missing on legacy rows (treat as 'pipeline').
|
||||
last_edit_source?: string;
|
||||
last_editor_id?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface WikiPageListResponse {
|
||||
pages: WikiPage[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
export interface WikiFolder {
|
||||
id: string;
|
||||
tenant_id: number;
|
||||
knowledge_base_id: string;
|
||||
parent_id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
depth: number;
|
||||
sort_order: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface WikiFolderNode extends WikiFolder {
|
||||
page_count: number;
|
||||
has_children: boolean;
|
||||
}
|
||||
|
||||
export interface WikiFolderListResponse {
|
||||
parent_id: string;
|
||||
folders: WikiFolderNode[];
|
||||
}
|
||||
|
||||
export interface WikiGraphMeta {
|
||||
mode: 'overview' | 'ego' | string;
|
||||
total: number;
|
||||
returned: number;
|
||||
truncated: boolean;
|
||||
center?: string;
|
||||
depth?: number;
|
||||
familiar_count?: number;
|
||||
}
|
||||
|
||||
export interface WikiGraphData {
|
||||
nodes: { slug: string; title: string; page_type: string; link_count: number; familiar?: boolean }[];
|
||||
edges: { source: string; target: string }[];
|
||||
meta: WikiGraphMeta;
|
||||
}
|
||||
|
||||
export interface WikiStats {
|
||||
total_pages: number;
|
||||
pages_by_type: Record<string, number>;
|
||||
total_links: number;
|
||||
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;
|
||||
status?: string;
|
||||
query?: string;
|
||||
category_path?: string;
|
||||
category_depth?: number;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
sort_by?: string;
|
||||
sort_order?: string;
|
||||
}) {
|
||||
const query = new URLSearchParams();
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== '') {
|
||||
query.set(key, String(value));
|
||||
}
|
||||
});
|
||||
}
|
||||
const qs = query.toString();
|
||||
return get(`/api/v1/knowledgebase/${kbId}/wiki/pages${qs ? '?' + qs : ''}`);
|
||||
}
|
||||
|
||||
// listWikiFolders returns the direct child folders of parentId ("" = root),
|
||||
// each enriched with a recursive page_count and a has_children flag so the tree
|
||||
// can render expand affordances and empty folders without a second request.
|
||||
// pageTypes scopes the view to a sidebar tab: only folders whose subtree holds
|
||||
// a page of those types (or are entirely empty) come back, and page_count is
|
||||
// counted within those types.
|
||||
export function listWikiFolders(kbId: string, parentId = '', pageTypes = '') {
|
||||
const query = new URLSearchParams();
|
||||
if (parentId) query.set('parent_id', parentId);
|
||||
if (pageTypes) query.set('page_types', pageTypes);
|
||||
const qs = query.toString();
|
||||
return get(`/api/v1/knowledgebase/${kbId}/wiki/folders${qs ? '?' + qs : ''}`);
|
||||
}
|
||||
|
||||
// createWikiFolder creates a new empty folder under parentId ("" = root).
|
||||
export function createWikiFolder(kbId: string, parentId: string, name: string) {
|
||||
return post(`/api/v1/knowledgebase/${kbId}/wiki/folders`, { parent_id: parentId, name });
|
||||
}
|
||||
|
||||
// updateWikiFolder renames and/or reparents a folder. Pass move_parent: true
|
||||
// (and parent_id) to reparent; omit it for a pure rename.
|
||||
export function updateWikiFolder(
|
||||
kbId: string,
|
||||
folderId: string,
|
||||
data: { name?: string; parent_id?: string; move_parent?: boolean },
|
||||
) {
|
||||
return put(`/api/v1/knowledgebase/${kbId}/wiki/folders/${folderId}`, data);
|
||||
}
|
||||
|
||||
// deleteWikiFolder removes an empty folder (no pages, no sub-folders).
|
||||
export function deleteWikiFolder(kbId: string, folderId: string) {
|
||||
return del(`/api/v1/knowledgebase/${kbId}/wiki/folders/${folderId}`);
|
||||
}
|
||||
|
||||
// moveWikiPage relocates a page into folderId ("" = root). The slug is sent in
|
||||
// the body because wiki slugs are hierarchical.
|
||||
export function moveWikiPage(kbId: string, slug: string, folderId: string) {
|
||||
return put(`/api/v1/knowledgebase/${kbId}/wiki/move-page`, { slug, folder_id: folderId });
|
||||
}
|
||||
|
||||
export function createWikiPage(kbId: string, data: Partial<WikiPage>) {
|
||||
return post(`/api/v1/knowledgebase/${kbId}/wiki/pages`, data);
|
||||
}
|
||||
|
||||
export function getWikiPage(kbId: string, slug: string) {
|
||||
return get(`/api/v1/knowledgebase/${kbId}/wiki/pages/${encodeSlugPath(slug)}`);
|
||||
}
|
||||
|
||||
// WikiPageUpdatePayload is a partial update: absent fields keep their stored
|
||||
// value. `version` is the optimistic-lock guard — send the version the page
|
||||
// had when the user started editing; the backend answers 409 (with
|
||||
// `current_version` in the body) when someone else edited in between.
|
||||
export interface WikiPageUpdatePayload {
|
||||
title?: string;
|
||||
content?: string;
|
||||
summary?: string;
|
||||
page_type?: string;
|
||||
status?: string;
|
||||
aliases?: string[];
|
||||
version?: number;
|
||||
}
|
||||
|
||||
export function updateWikiPage(kbId: string, slug: string, data: WikiPageUpdatePayload) {
|
||||
return put(`/api/v1/knowledgebase/${kbId}/wiki/pages/${encodeSlugPath(slug)}`, data);
|
||||
}
|
||||
|
||||
export function deleteWikiPage(kbId: string, slug: string) {
|
||||
return del(`/api/v1/knowledgebase/${kbId}/wiki/pages/${encodeSlugPath(slug)}`);
|
||||
}
|
||||
|
||||
// WikiPageRevision is one immutable snapshot of a superseded page version.
|
||||
// `content` is only populated when fetching a single revision.
|
||||
export interface WikiPageRevision {
|
||||
id: string;
|
||||
tenant_id: number;
|
||||
knowledge_base_id: string;
|
||||
page_id: string;
|
||||
slug: string;
|
||||
version: number;
|
||||
title: string;
|
||||
page_type: string;
|
||||
status: string;
|
||||
content?: string;
|
||||
summary: string;
|
||||
aliases: string[];
|
||||
edit_source: string;
|
||||
editor_id: string;
|
||||
edited_at: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface WikiRevisionListResponse {
|
||||
revisions: WikiPageRevision[];
|
||||
total: number;
|
||||
current_version: number;
|
||||
}
|
||||
|
||||
// listWikiRevisions returns the page's historical snapshots newest-first
|
||||
// (content omitted) plus the current version number. The current version has
|
||||
// no revision row — it is the page itself.
|
||||
export function listWikiRevisions(kbId: string, slug: string, params?: { limit?: number; offset?: number }) {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.limit !== undefined) query.set('limit', String(params.limit));
|
||||
if (params?.offset !== undefined) query.set('offset', String(params.offset));
|
||||
const qs = query.toString();
|
||||
return get(`/api/v1/knowledgebase/${kbId}/wiki/revisions/${encodeSlugPath(slug)}${qs ? '?' + qs : ''}`);
|
||||
}
|
||||
|
||||
// getWikiRevision returns one snapshot with full content.
|
||||
export function getWikiRevision(kbId: string, slug: string, version: number) {
|
||||
return get(`/api/v1/knowledgebase/${kbId}/wiki/revisions/${encodeSlugPath(slug)}?version=${version}`);
|
||||
}
|
||||
|
||||
// revertWikiPage rolls the page back to a stored revision. Applied as a
|
||||
// regular edit: the pre-revert state is snapshotted and version advances,
|
||||
// so a revert is itself revertable.
|
||||
export function revertWikiPage(kbId: string, slug: string, version: number) {
|
||||
return post(`/api/v1/knowledgebase/${kbId}/wiki/revert`, { slug, version });
|
||||
}
|
||||
|
||||
export interface WikiIndexEntryDTO {
|
||||
slug: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
parent_slug?: string;
|
||||
category_path?: string[];
|
||||
wiki_path?: string;
|
||||
depth?: number;
|
||||
sort_order?: number;
|
||||
}
|
||||
|
||||
export interface WikiIndexGroup {
|
||||
type: string;
|
||||
total: number;
|
||||
items: WikiIndexEntryDTO[];
|
||||
next_cursor?: string;
|
||||
}
|
||||
|
||||
export interface WikiIndexResponse {
|
||||
intro: string;
|
||||
version: number;
|
||||
groups: WikiIndexGroup[];
|
||||
}
|
||||
|
||||
// getWikiIndex fetches the structured index view for a wiki KB. The
|
||||
// backend replaced the legacy "markdown blob of intro + directory" with
|
||||
// { intro, groups } so a 40k-page wiki no longer round-trips multiple
|
||||
// megabytes on every index open. Pass `types` to restrict which
|
||||
// page_type buckets come back; `limit` bounds the per-group window;
|
||||
// `cursor` resumes from a previous response.
|
||||
export function getWikiIndex(
|
||||
kbId: string,
|
||||
params?: { types?: string[]; limit?: number; cursor?: string },
|
||||
) {
|
||||
const query = new URLSearchParams();
|
||||
if (params) {
|
||||
if (params.types && params.types.length > 0) query.set('types', params.types.join(','));
|
||||
if (params.limit !== undefined) query.set('limit', String(params.limit));
|
||||
if (params.cursor) query.set('cursor', params.cursor);
|
||||
}
|
||||
const qs = query.toString();
|
||||
const suffix = qs ? `?${qs}` : '';
|
||||
return get(`/api/v1/knowledgebase/${kbId}/wiki/index${suffix}`);
|
||||
}
|
||||
|
||||
export interface WikiGraphQueryParams {
|
||||
mode?: 'overview' | 'ego';
|
||||
center?: string;
|
||||
depth?: number;
|
||||
types?: string[];
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
// getWikiGraph fetches a slice of the wiki link graph. Without params the
|
||||
// backend returns the top-500 most-connected pages (overview mode). Pass
|
||||
// `mode: 'ego', center: <slug>` to drill into a specific page's neighborhood.
|
||||
// For knowledge bases with tens of thousands of pages the overview cap is
|
||||
// what prevents the browser from choking on a 30MB payload / 100k SVG nodes.
|
||||
export function getWikiGraph(kbId: string, params?: WikiGraphQueryParams) {
|
||||
const query = new URLSearchParams();
|
||||
if (params) {
|
||||
if (params.mode) query.set('mode', params.mode);
|
||||
if (params.center) query.set('center', params.center);
|
||||
if (params.depth !== undefined) query.set('depth', String(params.depth));
|
||||
if (params.limit !== undefined) query.set('limit', String(params.limit));
|
||||
if (params.types && params.types.length > 0) {
|
||||
query.set('types', params.types.join(','));
|
||||
}
|
||||
}
|
||||
const qs = query.toString();
|
||||
return get(`/api/v1/knowledgebase/${kbId}/wiki/graph${qs ? '?' + qs : ''}`);
|
||||
}
|
||||
|
||||
export function getWikiStats(kbId: string) {
|
||||
return get(`/api/v1/knowledgebase/${kbId}/wiki/stats`);
|
||||
}
|
||||
|
||||
export function searchWikiPages(kbId: string, q: string, limit?: number) {
|
||||
const params = new URLSearchParams({ q });
|
||||
if (limit) params.set('limit', String(limit));
|
||||
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`, {});
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { get, post, put, del } from '@/utils/request'
|
||||
|
||||
export type ResourceKind = 'training-maps' | 'content-tasks' | 'flows'
|
||||
export interface WorkbenchResource<T = Record<string, any>> {
|
||||
id: string
|
||||
tenant_id: number
|
||||
owner_id: string
|
||||
owner_name: string
|
||||
kind: ResourceKind
|
||||
name: string
|
||||
status: string
|
||||
version: number
|
||||
data: T
|
||||
created_at: string
|
||||
updated_at: string
|
||||
summary?: boolean
|
||||
}
|
||||
export interface WorkbenchJob {
|
||||
id: string
|
||||
resource_id: string
|
||||
action: string
|
||||
status: 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled' | 'paused'
|
||||
progress: number
|
||||
stage: string
|
||||
error?: string
|
||||
kind?: ResourceKind
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
const root = '/api/v1/workbench'
|
||||
const resourcePath = (kind: ResourceKind, id?: string) => `${root}/resources/${kind}${id ? `/${encodeURIComponent(id)}` : ''}`
|
||||
async function listResourcePages(kind: ResourceKind, q: string, summary: boolean, selectedId = '') {
|
||||
const data: WorkbenchResource[] = []
|
||||
let total = 0
|
||||
let initial_detail: WorkbenchResource | null | undefined
|
||||
for (let page = 1; ; page++) {
|
||||
const detail = summary && page === 1 && selectedId ? `&include=initial_detail&selected_id=${encodeURIComponent(selectedId)}` : ''
|
||||
const response = await get<{ data: WorkbenchResource[]; total: number; initial_detail?: WorkbenchResource | null }>(`${resourcePath(kind)}?page=${page}&page_size=100&q=${encodeURIComponent(q)}${summary ? '&view=summary' : ''}${detail}`)
|
||||
if (page === 1 && Object.prototype.hasOwnProperty.call(response, 'initial_detail')) initial_detail = response.initial_detail
|
||||
const rows = response.data || []
|
||||
total = response.total ?? rows.length
|
||||
const known = new Set(data.map(item => item.id))
|
||||
const added = rows.filter(item => !known.has(item.id))
|
||||
data.push(...added)
|
||||
if (!added.length || data.length >= total) break
|
||||
}
|
||||
return { data, total, initial_detail }
|
||||
}
|
||||
export const listResources = (kind: ResourceKind, q = '') => listResourcePages(kind, q, false)
|
||||
export const listResourceSummaries = (kind: ResourceKind, q = '', selectedId = '') => listResourcePages(kind, q, true, selectedId)
|
||||
export const getResource = (kind: ResourceKind, id: string) => get<{ data: WorkbenchResource }>(resourcePath(kind, id))
|
||||
export const exportResource = (resource: WorkbenchResource) => get<{ data: { name: string; content: string } }>(`${resourcePath(resource.kind, resource.id)}/export`)
|
||||
export const createResource = (kind: ResourceKind, name: string, data: Record<string, any>) => post<{ data: WorkbenchResource }>(resourcePath(kind), { name, data })
|
||||
export const updateResource = (resource: WorkbenchResource, data = resource.data, configure = false) => {
|
||||
if (resource.summary) throw new Error('请先加载完整内容后再保存')
|
||||
return put<{ data: WorkbenchResource }>(resourcePath(resource.kind, resource.id), { name: resource.name, data, version: resource.version, configure })
|
||||
}
|
||||
export const deleteResource = (resource: WorkbenchResource, detach = false) => del(`${resourcePath(resource.kind, resource.id)}${detach ? '?detach=true' : ''}`)
|
||||
export const resourceAction = (resource: WorkbenchResource, action: string, parameters: Record<string, any> = {}) => post<{ data: WorkbenchJob | WorkbenchResource }>(`${resourcePath(resource.kind, resource.id)}/actions/${action}`, { version: resource.version, ...parameters })
|
||||
export const getJob = (id: string) => get<{ data: WorkbenchJob }>(`${root}/jobs/${encodeURIComponent(id)}`)
|
||||
export const controlJob = (id: string, action: 'pause' | 'cancel' | 'resume' | 'retry') => post<{ data: WorkbenchJob }>(`${root}/jobs/${encodeURIComponent(id)}/${action}`, {})
|
||||
export const listResourceJobs = (resource: WorkbenchResource) => get<{ data: WorkbenchJob[]; total: number }>(`${resourcePath(resource.kind, resource.id)}/jobs?page_size=100`)
|
||||
|
||||
export function errorMessage(error: unknown): string {
|
||||
const item = error as { message?: string; error?: string | { message?: string }; status?: number }
|
||||
if (item?.status === 409) return '内容已更新或任务仍在执行,请刷新后重试'
|
||||
return (typeof item?.error === 'string' ? item.error : item?.error?.message) || item?.message || '操作失败,请重试'
|
||||
}
|
||||
@@ -0,0 +1,723 @@
|
||||
/**
|
||||
* 全局统一下拉菜单样式
|
||||
*
|
||||
* 适用于所有场景的 popup/dropdown 菜单,包括:
|
||||
* - 卡片操作菜单(知识库、智能体、组织)
|
||||
* - 文档操作菜单
|
||||
* - 对话会话菜单
|
||||
* - 上传操作菜单
|
||||
*
|
||||
* 使用方式:
|
||||
* 1. <t-popup> + .popup-menu 自定义内容模式: overlayClassName="card-more-popup"
|
||||
* 2. <t-dropdown> TDesign 下拉模式: 自动匹配全局样式
|
||||
*/
|
||||
|
||||
/* 弹出动画 */
|
||||
@keyframes dropdownSlideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px) scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dropdownSlideInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px) scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
一、Popup 容器统一样式(<t-popup> 模式)
|
||||
============================================ */
|
||||
.card-more-popup {
|
||||
z-index: 99 !important;
|
||||
|
||||
.t-popup__content {
|
||||
padding: 4px !important;
|
||||
margin-top: 4px !important;
|
||||
min-width: 148px;
|
||||
border-radius: 10px !important;
|
||||
background: var(--td-bg-color-container) !important;
|
||||
border: 0.5px solid var(--td-component-stroke) !important;
|
||||
box-shadow:
|
||||
0 0 0 0.5px rgba(0, 0, 0, 0.03),
|
||||
0 2px 4px rgba(0, 0, 0, 0.04),
|
||||
0 8px 24px rgba(0, 0, 0, 0.1) !important;
|
||||
backdrop-filter: blur(20px) saturate(180%) !important;
|
||||
-webkit-backdrop-filter: blur(20px) saturate(180%) !important;
|
||||
animation: dropdownSlideIn 0.18s cubic-bezier(0.2, 0, 0, 1) both;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
二、自定义菜单项统一样式(.popup-menu 模式)
|
||||
============================================ */
|
||||
.popup-menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.popup-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s cubic-bezier(0.2, 0, 0, 1);
|
||||
color: var(--td-text-color-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
border-radius: 6px;
|
||||
position: relative;
|
||||
|
||||
.menu-icon {
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
color: var(--td-text-color-secondary);
|
||||
transition: all 0.15s cubic-bezier(0.2, 0, 0, 1);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--td-bg-color-container-hover);
|
||||
color: var(--td-text-color-primary);
|
||||
|
||||
.menu-icon {
|
||||
color: var(--td-text-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: var(--td-bg-color-container-active);
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
&.delete,
|
||||
&.danger {
|
||||
color: var(--td-error-color-6);
|
||||
margin-top: 4px;
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -3px;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
height: 1px;
|
||||
background: var(--td-component-stroke);
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
color: var(--td-error-color-6);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--td-error-color-1);
|
||||
color: var(--td-error-color-6);
|
||||
|
||||
.menu-icon {
|
||||
color: var(--td-error-color-6);
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: var(--td-error-color-2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
三、文档操作菜单统一样式(.card-more 容器)
|
||||
============================================ */
|
||||
.card-more {
|
||||
z-index: 99 !important;
|
||||
|
||||
.t-popup__content {
|
||||
padding: 4px !important;
|
||||
margin-top: 4px !important;
|
||||
min-width: 148px;
|
||||
width: auto;
|
||||
border-radius: 10px !important;
|
||||
background: var(--td-bg-color-container) !important;
|
||||
border: 0.5px solid var(--td-component-stroke) !important;
|
||||
box-shadow:
|
||||
0 0 0 0.5px rgba(0, 0, 0, 0.03),
|
||||
0 2px 4px rgba(0, 0, 0, 0.04),
|
||||
0 8px 24px rgba(0, 0, 0, 0.1) !important;
|
||||
backdrop-filter: blur(20px) saturate(180%) !important;
|
||||
-webkit-backdrop-filter: blur(20px) saturate(180%) !important;
|
||||
color: var(--td-text-color-primary);
|
||||
animation: dropdownSlideIn 0.18s cubic-bezier(0.2, 0, 0, 1) both;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 140px;
|
||||
gap: 1px;
|
||||
|
||||
&.move-menu {
|
||||
min-width: 220px;
|
||||
max-width: 280px;
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.card-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
color: var(--td-text-color-primary);
|
||||
transition: all 0.15s cubic-bezier(0.2, 0, 0, 1);
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
|
||||
&:hover {
|
||||
background: var(--td-bg-color-container-hover);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: var(--td-bg-color-container-active);
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 16px;
|
||||
color: var(--td-text-color-secondary);
|
||||
transition: all 0.15s cubic-bezier(0.2, 0, 0, 1);
|
||||
}
|
||||
|
||||
&:hover .icon {
|
||||
color: var(--td-text-color-primary);
|
||||
}
|
||||
|
||||
&.danger {
|
||||
color: var(--td-error-color-6);
|
||||
margin-top: 4px;
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -3px;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
height: 1px;
|
||||
background: var(--td-component-stroke);
|
||||
}
|
||||
|
||||
.icon {
|
||||
color: var(--td-error-color-6);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--td-error-color-1);
|
||||
color: var(--td-error-color-6);
|
||||
|
||||
.icon {
|
||||
color: var(--td-error-color-6);
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: var(--td-error-color-2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.move-menu-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--td-text-color-primary);
|
||||
border-bottom: 1px solid var(--td-component-stroke);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--td-bg-color-container-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.move-menu-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.move-menu-empty {
|
||||
padding: 12px 16px;
|
||||
font-size: 12px;
|
||||
color: var(--td-text-color-placeholder);
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.move-target-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.move-target-count {
|
||||
font-size: 12px;
|
||||
color: var(--td-text-color-placeholder);
|
||||
}
|
||||
|
||||
.move-confirm-body {
|
||||
padding: 8px;
|
||||
|
||||
.move-target-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
background: var(--td-bg-color-container-hover);
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--td-text-color-secondary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.move-mode-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
margin-bottom: 4px;
|
||||
|
||||
&:hover {
|
||||
background: var(--td-bg-color-container-hover);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--td-brand-color-light);
|
||||
}
|
||||
|
||||
.move-mode-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
|
||||
.move-mode-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--td-text-color-primary);
|
||||
}
|
||||
|
||||
.move-mode-desc {
|
||||
font-size: 11px;
|
||||
color: var(--td-text-color-placeholder);
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.move-confirm-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
四、TDesign Dropdown 统一样式
|
||||
适用于 <t-dropdown> 组件(挂载到 body 上)
|
||||
============================================ */
|
||||
.t-popup__content {
|
||||
.t-dropdown__menu {
|
||||
background: var(--td-bg-color-container);
|
||||
border: 0.5px solid var(--td-component-stroke);
|
||||
box-shadow:
|
||||
0 0 0 0.5px rgba(0, 0, 0, 0.03),
|
||||
0 2px 4px rgba(0, 0, 0, 0.04),
|
||||
0 8px 24px rgba(0, 0, 0, 0.1);
|
||||
backdrop-filter: blur(20px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||
padding: 4px;
|
||||
min-width: 148px;
|
||||
animation: dropdownSlideIn 0.18s cubic-bezier(0.2, 0, 0, 1) both;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: transparent transparent;
|
||||
transition: scrollbar-color 0.2s ease;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
scrollbar-color: var(--td-scrollbar-color, rgba(0, 0, 0, 0.18)) transparent;
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: var(--td-scrollbar-color, rgba(0, 0, 0, 0.18));
|
||||
}
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--td-scrollbar-hover-color, rgba(0, 0, 0, 0.32));
|
||||
}
|
||||
}
|
||||
|
||||
.t-dropdown__item {
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
margin: 1px 0;
|
||||
transition: all 0.15s cubic-bezier(0.2, 0, 0, 1);
|
||||
font-size: 14px;
|
||||
color: var(--td-text-color-primary);
|
||||
cursor: pointer;
|
||||
min-width: auto !important;
|
||||
max-width: 100% !important;
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
background: var(--td-bg-color-container-hover);
|
||||
color: var(--td-text-color-primary);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: var(--td-bg-color-container-active);
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.t-dropdown__item-icon {
|
||||
flex-shrink: 0;
|
||||
margin-right: 8px;
|
||||
color: var(--td-text-color-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition: all 0.15s cubic-bezier(0.2, 0, 0, 1);
|
||||
|
||||
.t-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .t-dropdown__item-icon {
|
||||
color: var(--td-text-color-primary);
|
||||
}
|
||||
|
||||
.t-dropdown__item-text {
|
||||
color: inherit !important;
|
||||
font-size: 14px !important;
|
||||
line-height: 20px !important;
|
||||
white-space: nowrap !important;
|
||||
overflow: hidden !important;
|
||||
text-overflow: ellipsis !important;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* TDesign error 主题项(删除操作) */
|
||||
&.t-dropdown__item--theme-error {
|
||||
color: var(--td-error-color-6) !important;
|
||||
margin-top: 4px;
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -3px;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
height: 1px;
|
||||
background: var(--td-component-stroke);
|
||||
}
|
||||
|
||||
.t-dropdown__item-icon {
|
||||
color: var(--td-error-color-6);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--td-error-color-1);
|
||||
color: var(--td-error-color-6) !important;
|
||||
|
||||
.t-dropdown__item-icon {
|
||||
color: var(--td-error-color-6);
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: var(--td-error-color-2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* tag 更多弹窗 */
|
||||
.tag-more-popup {
|
||||
z-index: 99 !important;
|
||||
|
||||
.t-popup__content {
|
||||
padding: 4px !important;
|
||||
margin-top: 4px !important;
|
||||
min-width: 120px;
|
||||
border-radius: 10px !important;
|
||||
background: var(--td-bg-color-container) !important;
|
||||
border: 0.5px solid var(--td-component-stroke) !important;
|
||||
box-shadow:
|
||||
0 0 0 0.5px rgba(0, 0, 0, 0.03),
|
||||
0 2px 4px rgba(0, 0, 0, 0.04),
|
||||
0 8px 24px rgba(0, 0, 0, 0.1) !important;
|
||||
backdrop-filter: blur(20px) saturate(180%) !important;
|
||||
-webkit-backdrop-filter: blur(20px) saturate(180%) !important;
|
||||
animation: dropdownSlideIn 0.18s cubic-bezier(0.2, 0, 0, 1) both;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
/* 文档卡片标签选择弹窗 */
|
||||
.card-tag-popup {
|
||||
z-index: 99 !important;
|
||||
|
||||
.t-popup__content {
|
||||
padding: 4px !important;
|
||||
margin-top: 4px !important;
|
||||
border-radius: 10px !important;
|
||||
background: var(--td-bg-color-container) !important;
|
||||
border: 0.5px solid var(--td-component-stroke) !important;
|
||||
box-shadow:
|
||||
0 0 0 0.5px rgba(0, 0, 0, 0.03),
|
||||
0 2px 4px rgba(0, 0, 0, 0.04),
|
||||
0 8px 24px rgba(0, 0, 0, 0.1) !important;
|
||||
backdrop-filter: blur(20px) saturate(180%) !important;
|
||||
-webkit-backdrop-filter: blur(20px) saturate(180%) !important;
|
||||
animation: dropdownSlideIn 0.18s cubic-bezier(0.2, 0, 0, 1) both;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tag-popup-list {
|
||||
min-width: 160px;
|
||||
max-width: 240px;
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.tag-popup-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--td-text-color-primary);
|
||||
cursor: pointer;
|
||||
line-height: 1.2;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--td-bg-color-container-hover);
|
||||
}
|
||||
|
||||
&.is-selected {
|
||||
color: var(--td-brand-color);
|
||||
background: var(--td-bg-color-secondarycontainer);
|
||||
}
|
||||
}
|
||||
|
||||
.tag-popup-check {
|
||||
font-size: 14px;
|
||||
color: var(--td-brand-color);
|
||||
visibility: hidden;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.visible {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
.tag-popup-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
&.muted {
|
||||
color: var(--td-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.tag-popup-item.is-action {
|
||||
color: var(--td-text-color-secondary);
|
||||
|
||||
.tag-popup-check {
|
||||
visibility: visible;
|
||||
color: var(--td-text-color-secondary);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--td-error-color);
|
||||
background: var(--td-error-color-1);
|
||||
|
||||
.tag-popup-check {
|
||||
color: var(--td-error-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tag-popup-divider {
|
||||
height: 1px;
|
||||
margin: 4px 4px;
|
||||
background: var(--td-component-stroke);
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
五、锚定表单/确认弹层(成员邀请、Wiki 移动/删除等)
|
||||
============================================ */
|
||||
.anchored-form-popup-overlay {
|
||||
z-index: 3500 !important;
|
||||
|
||||
.t-popup__content {
|
||||
padding: 14px 16px !important;
|
||||
min-width: 300px;
|
||||
max-width: min(392px, calc(100vw - 24px));
|
||||
border-radius: 12px !important;
|
||||
background: var(--td-bg-color-container) !important;
|
||||
border: 0.5px solid var(--td-component-stroke) !important;
|
||||
box-shadow:
|
||||
0 0 0 0.5px rgba(0, 0, 0, 0.03),
|
||||
0 2px 4px rgba(0, 0, 0, 0.04),
|
||||
0 8px 24px rgba(0, 0, 0, 0.1) !important;
|
||||
backdrop-filter: blur(20px) saturate(180%) !important;
|
||||
-webkit-backdrop-filter: blur(20px) saturate(180%) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.anchored-form-popup-inner {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.anchored-form-popup-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--td-text-color-primary);
|
||||
margin: 0 0 12px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.anchored-form-popup-body {
|
||||
padding: 0 0 4px;
|
||||
color: var(--td-text-color-primary);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.anchored-form-popup-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
// Standalone card (teleport / fixed anchor) — same visual tokens as overlay above.
|
||||
.anchored-form-popup-card {
|
||||
padding: 14px 16px;
|
||||
min-width: 300px;
|
||||
max-width: min(392px, calc(100vw - 24px));
|
||||
border-radius: 12px;
|
||||
background: var(--td-bg-color-container);
|
||||
border: 0.5px solid var(--td-component-stroke);
|
||||
box-shadow:
|
||||
0 0 0 0.5px rgba(0, 0, 0, 0.03),
|
||||
0 2px 4px rgba(0, 0, 0, 0.04),
|
||||
0 8px 24px rgba(0, 0, 0, 0.1);
|
||||
backdrop-filter: blur(20px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
六、暗色模式增强
|
||||
============================================ */
|
||||
:root[theme-mode="dark"] {
|
||||
.card-more-popup .t-popup__content,
|
||||
.card-more .t-popup__content,
|
||||
.tag-more-popup .t-popup__content,
|
||||
.card-tag-popup .t-popup__content,
|
||||
.anchored-form-popup-overlay .t-popup__content {
|
||||
background: rgba(36, 36, 36, 0.92) !important;
|
||||
border-color: rgba(255, 255, 255, 0.08) !important;
|
||||
box-shadow:
|
||||
0 0 0 0.5px rgba(255, 255, 255, 0.05),
|
||||
0 2px 4px rgba(0, 0, 0, 0.12),
|
||||
0 8px 32px rgba(0, 0, 0, 0.28) !important;
|
||||
}
|
||||
|
||||
.anchored-form-popup-card {
|
||||
background: rgba(36, 36, 36, 0.92);
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
box-shadow:
|
||||
0 0 0 0.5px rgba(255, 255, 255, 0.05),
|
||||
0 2px 4px rgba(0, 0, 0, 0.12),
|
||||
0 8px 32px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
.t-popup__content .t-dropdown__menu {
|
||||
background: rgba(36, 36, 36, 0.85);
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
box-shadow:
|
||||
0 0 0 0.5px rgba(255, 255, 255, 0.05),
|
||||
0 2px 4px rgba(0, 0, 0, 0.12),
|
||||
0 8px 32px rgba(0, 0, 0, 0.28);
|
||||
|
||||
&:hover {
|
||||
scrollbar-color: rgba(255, 255, 255, 0.22) transparent;
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(255, 255, 255, 0.22);
|
||||
}
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(255, 255, 255, 0.38);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
@font-face {
|
||||
font-family: 'TencentSans';
|
||||
src: url('fonts/TencentSans.ttf') format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 16 16" width="128" height="128">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M8 2C5.79086 2 4 3.79086 4 6C4 7.56981 4.90422 8.92935 6.22252 9.58448C6.39251 9.66896 6.5 9.84241 6.5 10.0322V11H9.5V10.0322C9.5 9.84241 9.60749 9.66896 9.77748 9.58448C11.0958 8.92935 12 7.56981 12 6C12 3.79086 10.2091 2 8 2ZM3 6C3 3.23858 5.23858 1 8 1C10.7614 1 13 3.23858 13 6C13 7.85144 11.9936 9.46702 10.5 10.331V11.5C10.5 11.7761 10.2761 12 10 12H6C5.72386 12 5.5 11.7761 5.5 11.5V10.331C4.00636 9.46702 3 7.85144 3 6Z" fill="#07C05F" stroke="#07C05F" stroke-width="0.3"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M10 13.5H6V12.5H10V13.5Z" fill="#07C05F" stroke="#07C05F" stroke-width="0.3"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M9 15H7V14H9V15Z" fill="#07C05F" stroke="#07C05F" stroke-width="0.3"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.4269 5.11946L7.60516 7.94123L5.7821 6.11819L6.48921 5.41108L7.60516 6.52702L9.71982 4.41235L10.4269 5.11946Z" fill="#07C05F" stroke="#07C05F" stroke-width="0.3"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10 3L10.8 6.2C10.9 6.7 11.3 7.1 11.8 7.2L15 8L11.8 8.8C11.3 8.9 10.9 9.3 10.8 9.8L10 13L9.2 9.8C9.1 9.3 8.7 8.9 8.2 8.8L5 8L8.2 7.2C8.7 7.1 9.1 6.7 9.2 6.2L10 3Z" fill="#07C05F" stroke="#07C05F" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M15.5 4L15.8 5.2C15.85 5.45 16.05 5.65 16.3 5.7L17.5 6L16.3 6.3C16.05 6.35 15.85 6.55 15.8 6.8L15.5 8L15.2 6.8C15.15 6.55 14.95 6.35 14.7 6.3L13.5 6L14.7 5.7C14.95 5.65 15.15 5.45 15.2 5.2L15.5 4Z" fill="#07C05F" stroke="#07C05F" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M4.5 13L4.8 14.2C4.85 14.45 5.05 14.65 5.3 14.7L6.5 15L5.3 15.3C5.05 15.35 4.85 15.55 4.8 15.8L4.5 17L4.2 15.8C4.15 15.55 3.95 15.35 3.7 15.3L2.5 15L3.7 14.7C3.95 14.65 4.15 14.45 4.2 14.2L4.5 13Z" fill="#07C05F" stroke="#07C05F" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 992 B |
@@ -0,0 +1,5 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10 3L10.8 6.2C10.9 6.7 11.3 7.1 11.8 7.2L15 8L11.8 8.8C11.3 8.9 10.9 9.3 10.8 9.8L10 13L9.2 9.8C9.1 9.3 8.7 8.9 8.2 8.8L5 8L8.2 7.2C8.7 7.1 9.1 6.7 9.2 6.2L10 3Z" fill="#07C05F" stroke="#07C05F" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M15.5 4L15.8 5.2C15.85 5.45 16.05 5.65 16.3 5.7L17.5 6L16.3 6.3C16.05 6.35 15.85 6.55 15.8 6.8L15.5 8L15.2 6.8C15.15 6.55 14.95 6.35 14.7 6.3L13.5 6L14.7 5.7C14.95 5.65 15.15 5.45 15.2 5.2L15.5 4Z" fill="#07C05F" stroke="#07C05F" stroke-width="0.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M4.5 13L4.8 14.2C4.85 14.45 5.05 14.65 5.3 14.7L6.5 15L5.3 15.3C5.05 15.35 4.85 15.55 4.8 15.8L4.5 17L4.2 15.8C4.15 15.55 3.95 15.35 3.7 15.3L2.5 15L3.7 14.7C3.95 14.65 4.15 14.45 4.2 14.2L4.5 13Z" fill="#07C05F" stroke="#07C05F" stroke-width="0.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 996 B |
@@ -0,0 +1,5 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10 3L10.8 6.2C10.9 6.7 11.3 7.1 11.8 7.2L15 8L11.8 8.8C11.3 8.9 10.9 9.3 10.8 9.8L10 13L9.2 9.8C9.1 9.3 8.7 8.9 8.2 8.8L5 8L8.2 7.2C8.7 7.1 9.1 6.7 9.2 6.2L10 3Z" fill="currentColor" stroke="currentColor" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M15.5 4L15.8 5.2C15.85 5.45 16.05 5.65 16.3 5.7L17.5 6L16.3 6.3C16.05 6.35 15.85 6.55 15.8 6.8L15.5 8L15.2 6.8C15.15 6.55 14.95 6.35 14.7 6.3L13.5 6L14.7 5.7C14.95 5.65 15.15 5.45 15.2 5.2L15.5 4Z" fill="currentColor" stroke="currentColor" stroke-width="0.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M4.5 13L4.8 14.2C4.85 14.45 5.05 14.65 5.3 14.7L6.5 15L5.3 15.3C5.05 15.35 4.85 15.55 4.8 15.8L4.5 17L4.2 15.8C4.15 15.55 3.95 15.35 3.7 15.3L2.5 15L3.7 14.7C3.95 14.65 4.15 14.45 4.2 14.2L4.5 13Z" fill="currentColor" stroke="currentColor" stroke-width="0.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="10" cy="10" r="3.5" stroke="currentColor" stroke-width="1.8"/>
|
||||
<path d="M13.5 10V11.5C13.5 12.163 13.7634 12.7989 14.2322 13.2678C14.7011 13.7366 15.337 14 16 14C16.663 14 17.2989 13.7366 17.7678 13.2678C18.2366 12.7989 18.5 12.163 18.5 11.5V10C18.5 7.74566 17.6045 5.58365 16.0104 3.98959C14.4163 2.39553 12.2543 1.5 10 1.5C7.74566 1.5 5.58365 2.39553 3.98959 3.98959C2.39553 5.58365 1.5 7.74566 1.5 10C1.5 12.2543 2.39553 14.4163 3.98959 16.0104C5.58365 17.6045 7.74566 18.5 10 18.5H12" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 698 B |
|
After Width: | Height: | Size: 543 KiB |
|
After Width: | Height: | Size: 824 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 16 16" width="128" height="128">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M2 7.5H14V8.5H2V7.5Z" fill="#07C05F"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M8 1L11.1213 4.12132L10.4142 4.82843L8.5 2.91421V6H7.5V2.91421L5.58579 4.82843L4.87868 4.12132L8 1Z" fill="#07C05F"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M8 15L4.87868 11.8787L5.58579 11.1716L7.5 13.0858V10H8.5V13.0858L10.4142 11.1716L11.1213 11.8787L8 15Z" fill="#07C05F"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 536 B |
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 439 B |
|
After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 48 48"><g fill="#3370ff" fill-rule="evenodd" clip-rule="evenodd"><path d="M41.0716 5.99409L3.31071 16.5187L12.3856 25.8126L20.7998 25.9594L30.4827 16.5187C30.2266 15.9943 30.0985 15.5552 30.0985 15.2013C30.0985 14.4074 30.4104 13.7786 30.8947 13.333C31.7241 12.57 32.7222 12.4558 33.8889 12.9905L41.0716 5.99409Z"/><path d="M42.1021 6.72842L31.5775 44.4893L22.2836 35.4144L22.1367 27.0002L31.5115 17.4816C32.0195 17.8454 32.5743 18.0105 33.1759 17.9769C34.0784 17.9264 34.6614 17.3813 34.9349 17.0602C35.2083 16.7392 35.5293 16.2051 35.5025 15.4113C35.4847 14.8821 35.3109 14.3941 34.9812 13.9472L42.1021 6.72842Z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 705 B |
|
After Width: | Height: | Size: 31 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" role="img" aria-label="RSS">
|
||||
<circle cx="14" cy="34" r="4" fill="#FF6600"/>
|
||||
<path d="M10 22a18 18 0 0 1 18 18" fill="none" stroke="#FF6600" stroke-width="4" stroke-linecap="round"/>
|
||||
<path d="M10 14a26 26 0 0 1 26 26" fill="none" stroke="#FF6600" stroke-width="4" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 361 B |
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" class="r6a8hvpwe__design-iconfont" width="128" height="128">
|
||||
<path d="M6 12V6H7V12H6Z"></path>
|
||||
<path d="M9 6V12H10V6H9Z"></path>
|
||||
<path d="M10.5 3H14V4H13V14C13 14.5523 12.5523 15 12 15H4C3.44772 15 3 14.5523 3 14V4H2V3H5.5L5.5 1.8C5.5 1.35817 5.85817 1 6.3 1H9.7C10.1418 1 10.5 1.35817 10.5 1.8V3ZM6.5 3H9.5L9.5 2L6.5 2V3ZM4 4V14H12V4H4Z"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 419 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" class="wb1yuxk13__design-iconfont" width="128" height="128">
|
||||
<path d="M12.2636 5.81387L8.5 9.57746L8.49998 0.5L7.49998 0.500002L7.5 9.57746L3.73641 5.81387L3.02931 6.52098L7.64645 11.1381C7.84171 11.3334 8.15829 11.3334 8.35355 11.1381L12.9707 6.52098L12.2636 5.81387Z" fill-opacity=".9" fill="#07C05F"></path>
|
||||
<path d="M2 11V13C2 13.5523 2.44772 14 3 14H13C13.5523 14 14 13.5523 14 13V11H13V13H3V11H2Z" fill-opacity=".9" fill="#07C05F"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 515 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20" class="g8nqf16e8__design-iconfont" width="128" height="128">
|
||||
<path d="M5 1.25C4.30482 1.25 3.75 1.80482 3.75 2.5V16.25C3.75 16.9451 4.30482 17.5 5 17.5H10.6702V16.25H5V2.5H10V7.5H15V10.6055H16.25V7.13388C16.25 6.80236 16.1183 6.48442 15.8839 6.25L15.7808 6.14692L15.7794 6.14556L11.1419 1.50806C11.036 1.40209 10.9104 1.3443 10.7858 1.32256C10.6523 1.27498 10.5105 1.25 10.3661 1.25H5ZM14.1161 6.25H11.25V3.38388L14.1161 6.25Z" fill="#07C05F"></path>
|
||||
<path d="M15 18.75V16.25H12.5V15H15V12.5H16.25V15H18.75V16.25H16.25V18.75H15Z" fill="#07C05F"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 635 B |
@@ -0,0 +1,4 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5 1.25C4.30482 1.25 3.75 1.80482 3.75 2.5V16.25C3.75 16.9451 4.30482 17.5 5 17.5H10.6702V16.25H5V2.5H10V7.5H15V10.6055H16.25V7.13388C16.25 6.80236 16.1183 6.48442 15.8839 6.25L15.7808 6.14692L15.7794 6.14556L11.1419 1.50806C11.036 1.40209 10.9104 1.3443 10.7858 1.32256C10.6523 1.27498 10.5105 1.25 10.3661 1.25H5ZM14.1161 6.25H11.25V3.38388L14.1161 6.25Z" fill="#07C05F"/>
|
||||
<path d="M15 18.75V16.25H12.5V15H15V12.5H16.25V15H18.75V16.25H16.25V18.75H15Z" fill="#07C05F"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 583 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20" class="g8nqf16e8__design-iconfont" width="128" height="128">
|
||||
<path d="M5 1.25C4.30482 1.25 3.75 1.80482 3.75 2.5V16.25C3.75 16.9451 4.30482 17.5 5 17.5H10.6702V16.25H5V2.5H10V7.5H15V10.6055H16.25V7.13388C16.25 6.80236 16.1183 6.48442 15.8839 6.25L15.7808 6.14692L15.7794 6.14556L11.1419 1.50806C11.036 1.40209 10.9104 1.3443 10.7858 1.32256C10.6523 1.27498 10.5105 1.25 10.3661 1.25H5ZM14.1161 6.25H11.25V3.38388L14.1161 6.25Z" fill="#666666"></path>
|
||||
<path d="M15 18.75V16.25H12.5V15H15V12.5H16.25V15H18.75V16.25H16.25V18.75H15Z" fill="#666666"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 635 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="#1677ff" d="M12 2C6.477 2 2 6.477 2 12s4.477 10 10 10s10-4.477 10-10S17.523 2 12 2m4.49 9.04l-.006.014c-.42.898-1.516 2.66-1.516 2.66l-.005-.012l-.32.558h1.543l-2.948 3.919l.67-2.666h-1.215l.422-1.763a17 17 0 0 0-1.223.349s-.646.378-1.862-.729c0 0-.82-.722-.344-.902c.202-.077.981-.175 1.595-.257a80 80 0 0 1 1.338-.172s-2.555.039-3.161-.057c-.606-.095-1.375-1.107-1.539-1.996c0 0-.253-.488.545-.257s4.101.9 4.101.9S8.27 9.312 7.983 8.99c-.286-.32-.841-1.754-.769-2.634c0 0 .031-.22.257-.16c0 0 3.176 1.45 5.347 2.245s4.06 1.199 3.816 2.228c-.02.087-.072.216-.144.37"/></svg>
|
||||
|
After Width: | Height: | Size: 672 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 48 48"><g fill="#00d6b9" fill-rule="evenodd" clip-rule="evenodd"><path d="M41.0716 5.99409L3.31071 16.5187L12.3856 25.8126L20.7998 25.9594L30.4827 16.5187C30.2266 15.9943 30.0985 15.5552 30.0985 15.2013C30.0985 14.4074 30.4104 13.7786 30.8947 13.333C31.7241 12.57 32.7222 12.4558 33.8889 12.9905L41.0716 5.99409Z"/><path d="M42.1021 6.72842L31.5775 44.4893L22.2836 35.4144L22.1367 27.0002L31.5115 17.4816C32.0195 17.8454 32.5743 18.0105 33.1759 17.9769C34.0784 17.9264 34.6614 17.3813 34.9349 17.0602C35.2083 16.7392 35.5293 16.2051 35.5025 15.4113C35.4847 14.8821 35.3109 14.3941 34.9812 13.9472L42.1021 6.72842Z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 705 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 48 48"><g fill="#3370ff" fill-rule="evenodd" clip-rule="evenodd"><path d="M41.0716 5.99409L3.31071 16.5187L12.3856 25.8126L20.7998 25.9594L30.4827 16.5187C30.2266 15.9943 30.0985 15.5552 30.0985 15.2013C30.0985 14.4074 30.4104 13.7786 30.8947 13.333C31.7241 12.57 32.7222 12.4558 33.8889 12.9905L41.0716 5.99409Z"/><path d="M42.1021 6.72842L31.5775 44.4893L22.2836 35.4144L22.1367 27.0002L31.5115 17.4816C32.0195 17.8454 32.5743 18.0105 33.1759 17.9769C34.0784 17.9264 34.6614 17.3813 34.9349 17.0602C35.2083 16.7392 35.5293 16.2051 35.5025 15.4113C35.4847 14.8821 35.3109 14.3941 34.9812 13.9472L42.1021 6.72842Z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 705 B |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><path fill="#e01e5a" d="M53.841 161.32c0 14.832-11.987 26.82-26.819 26.82S.203 176.152.203 161.32c0-14.831 11.987-26.818 26.82-26.818H53.84zm13.41 0c0-14.831 11.987-26.818 26.819-26.818s26.819 11.987 26.819 26.819v67.047c0 14.832-11.987 26.82-26.82 26.82c-14.83 0-26.818-11.988-26.818-26.82z"/><path fill="#36c5f0" d="M94.07 53.638c-14.832 0-26.82-11.987-26.82-26.819S79.239 0 94.07 0s26.819 11.987 26.819 26.819v26.82zm0 13.613c14.832 0 26.819 11.987 26.819 26.819s-11.987 26.819-26.82 26.819H26.82C11.987 120.889 0 108.902 0 94.069c0-14.83 11.987-26.818 26.819-26.818z"/><path fill="#2eb67d" d="M201.55 94.07c0-14.832 11.987-26.82 26.818-26.82s26.82 11.988 26.82 26.82s-11.988 26.819-26.82 26.819H201.55zm-13.41 0c0 14.832-11.988 26.819-26.82 26.819c-14.831 0-26.818-11.987-26.818-26.82V26.82C134.502 11.987 146.489 0 161.32 0s26.819 11.987 26.819 26.819z"/><path fill="#ecb22e" d="M161.32 201.55c14.832 0 26.82 11.987 26.82 26.818s-11.988 26.82-26.82 26.82c-14.831 0-26.818-11.988-26.818-26.82V201.55zm0-13.41c-14.831 0-26.818-11.988-26.818-26.82c0-14.831 11.987-26.818 26.819-26.818h67.25c14.832 0 26.82 11.987 26.82 26.819s-11.988 26.819-26.82 26.819z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><defs><linearGradient id="SVG6DaOZcwt" x1="50%" x2="50%" y1="0%" y2="100%"><stop offset="0%" stop-color="#2aabee"/><stop offset="100%" stop-color="#229ed9"/></linearGradient></defs><path fill="url(#SVG6DaOZcwt)" d="M128 0C94.06 0 61.48 13.494 37.5 37.49A128.04 128.04 0 0 0 0 128c0 33.934 13.5 66.514 37.5 90.51C61.48 242.506 94.06 256 128 256s66.52-13.494 90.5-37.49c24-23.996 37.5-56.576 37.5-90.51s-13.5-66.514-37.5-90.51C194.52 13.494 161.94 0 128 0"/><path fill="#fff" d="M57.94 126.648q55.98-24.384 74.64-32.152c35.56-14.786 42.94-17.354 47.76-17.441c1.06-.017 3.42.245 4.96 1.49c1.28 1.05 1.64 2.47 1.82 3.467c.16.996.38 3.266.2 5.038c-1.92 20.24-10.26 69.356-14.5 92.026c-1.78 9.592-5.32 12.808-8.74 13.122c-7.44.684-13.08-4.912-20.28-9.63c-11.26-7.386-17.62-11.982-28.56-19.188c-12.64-8.328-4.44-12.906 2.76-20.386c1.88-1.958 34.64-31.748 35.26-34.45c.08-.338.16-1.598-.6-2.262c-.74-.666-1.84-.438-2.64-.258c-1.14.256-19.12 12.152-54 35.686c-5.1 3.508-9.72 5.218-13.88 5.128c-4.56-.098-13.36-2.584-19.9-4.708c-8-2.606-14.38-3.984-13.82-8.41c.28-2.304 3.46-4.662 9.52-7.072"/></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="#07c160" d="M15.85 8.14c.39 0 .77.03 1.14.08C16.31 5.25 13.19 3 9.44 3c-4.25 0-7.7 2.88-7.7 6.43c0 2.05 1.15 3.86 2.94 5.04L3.67 16.5l2.76-1.19c.59.21 1.21.38 1.87.47c-.09-.39-.14-.79-.14-1.21c-.01-3.54 3.44-6.43 7.69-6.43M12 5.89a.96.96 0 1 1 0 1.92a.96.96 0 0 1 0-1.92M6.87 7.82a.96.96 0 1 1 0-1.92a.96.96 0 0 1 0 1.92"/><path fill="#07c160" d="M22.26 14.57c0-2.84-2.87-5.14-6.41-5.14s-6.41 2.3-6.41 5.14s2.87 5.14 6.41 5.14c.58 0 1.14-.08 1.67-.2L20.98 21l-1.2-2.4c1.5-.94 2.48-2.38 2.48-4.03m-8.34-.32a.96.96 0 1 1 .96-.96c.01.53-.43.96-.96.96m3.85 0a.96.96 0 1 1 0-1.92a.96.96 0 0 1 0 1.92"/></svg>
|
||||
|
After Width: | Height: | Size: 700 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 1024 1024"><path fill="#0082EF" fill-rule="evenodd" d="M909.784 729.585a135.9 135.9 0 0 0-47.042 19.049a114.24 114.24 0 0 1-51.41 31.075c2.613-17.554 11.313-33.577 24.455-45.418a169.3 169.3 0 0 0 23.409-55.017c-.061-27.838 22.44-50.459 50.265-50.534c27.827-.076 50.45 22.423 50.539 50.26c.089 27.839-22.39 50.482-50.215 50.585zM817.568 608.83a168.8 168.8 0 0 0-54.808-23.68c-27.836 0-50.402-22.575-50.402-50.423s22.566-50.423 50.402-50.423s50.402 22.575 50.402 50.423a137.5 137.5 0 0 0 18.817 47.211a114.8 114.8 0 0 1 30.764 51.656a76.08 76.08 0 0 1-45.026-24.763h-.186zm-83.033-177.713C719.34 303.789 587.548 204.026 428.09 204.026c-169.874 0-308.088 113.097-308.088 252.19c2.72 78.1 43.941 149.778 110.063 191.385a311 311 0 0 0 33.602 21.588l-13.664 54.569c4.928 2.316 9.706 4.78 14.746 6.91l68.996-34.512c10.08 2.615 20.683 4.295 31.21 6.088c6.721 1.195 13.442 2.428 20.35 3.25a354.8 354.8 0 0 0 128.805-7.396a249 249 0 0 0 10.154 55.055a425.6 425.6 0 0 1-96.175 11.242a418 418 0 0 1-86.392-9.524l-125.186 62.526a27.62 27.62 0 0 1-29.98-3.137a28.02 28.02 0 0 1-9.67-28.611l22.401-90.239C117.176 643.186 66.463 553.506 64 456.216C64 285.973 227.004 148 428.09 148c190.93 0 347.29 124.527 362.521 282.818a245 245 0 0 0-26.47-2.614c-9.893.374-19.787 1.307-29.607 2.876zM618.237 629.934c16.764-3.362 32.706-9.786 47.042-19.049a114.24 114.24 0 0 1 51.447-31.001a76.47 76.47 0 0 1-24.491 45.344c-11.014 16.807-18.929 35.483-23.409 55.054c.041 27.833-22.468 50.435-50.29 50.497s-50.43-22.44-50.514-50.273c-.082-27.833 22.393-50.469 50.215-50.572m90.798 121.314c16.652 11.168 35.17 19.236 54.659 23.904c20.386 0 38.764 12.286 46.565 31.127s3.49 40.53-10.926 54.95a50.38 50.38 0 0 1-54.927 10.931c-18.834-7.804-31.114-26.19-31.114-46.585a136.7 136.7 0 0 0-18.668-47.285a114.7 114.7 0 0 1-30.54-51.805a76 76 0 0 1 44.951 25.062z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 10 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" width="20" height="20" fill="none">
|
||||
<path d="M4.25 8V12a2 2 0 0 0 2 2H11.5" stroke="#07C05F" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M15.75 12V8a2 2 0 0 0-2-2H8.5" stroke="#07C05F" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 353 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" width="20" height="20" fill="none">
|
||||
<path d="M4.25 8V12a2 2 0 0 0 2 2H11.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M15.75 12V8a2 2 0 0 0-2-2H8.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 363 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M10 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M17 16l4-4-4-4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M21 12H10" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 469 B |
|
After Width: | Height: | Size: 128 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10 10C8.8 7.5 7.8 3.8 4.8 3.8C2.2 3.8 0.8 6.8 0.8 10C0.8 13.2 2.2 16.2 4.8 16.2C7.8 16.2 8.8 12.5 10 10C11.2 7.5 12.5 5.5 14.5 5.5C16.5 5.5 18 7.5 18 10C18 12.5 16.5 14.5 14.5 14.5C12.5 14.5 11.2 12.5 10 10Z" stroke="#07C05F" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 421 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10 10C8.8 7.5 7.8 3.8 4.8 3.8C2.2 3.8 0.8 6.8 0.8 10C0.8 13.2 2.2 16.2 4.8 16.2C7.8 16.2 8.8 12.5 10 10C11.2 7.5 12.5 5.5 14.5 5.5C16.5 5.5 18 7.5 18 10C18 12.5 16.5 14.5 14.5 14.5C12.5 14.5 11.2 12.5 10 10Z" stroke="currentColor" stroke-opacity=".4" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 446 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10 10C8.8 7.5 7.8 3.8 4.8 3.8C2.2 3.8 0.8 6.8 0.8 10C0.8 13.2 2.2 16.2 4.8 16.2C7.8 16.2 8.8 12.5 10 10C11.2 7.5 12.5 5.5 14.5 5.5C16.5 5.5 18 7.5 18 10C18 12.5 16.5 14.5 14.5 14.5C12.5 14.5 11.2 12.5 10 10Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 426 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20" width="20" height="20">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M1.875 2.5C1.875 2.15483 2.15482 1.875 2.5 1.875H17.5C17.8451 1.875 18.125 2.15483 18.125 2.5V13.75C18.125 14.0951 17.8451 14.375 17.5 14.375H9.04274L6.10514 17.9001C5.93669 18.1023 5.65965 18.1773 5.41224 18.0876C5.16481 17.9981 5 17.7631 5 17.5V14.375H2.5C2.15482 14.375 1.875 14.0951 1.875 13.75V2.5ZM3.125 3.125V13.125H5.625C5.97017 13.125 6.25 13.4049 6.25 13.75V15.7738L8.26986 13.3499C8.38861 13.2074 8.56451 13.125 8.75 13.125H16.875V3.125H3.125Z" fill="#07C05F" fill-opacity=".9"></path>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.375 5H10.625V8.44961L13.5154 10.762L12.7346 11.738L9.375 9.05039V5Z" fill="#07C05F" fill-opacity=".9"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 814 B |