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

This commit is contained in:
yawan
2026-09-22 16:19:06 +08:00
commit f7fc992f57
3929 changed files with 1006607 additions and 0 deletions
+30
View File
@@ -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
+30
View File
@@ -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"]
+85
View File
@@ -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') } } }))
})
+65
View File
@@ -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(_){}})();`,
}]
},
},
}
}
+73
View File
@@ -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;'
+27
View File
@@ -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>
+9
View File
@@ -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;
+78
View File
@@ -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>
+34
View File
@@ -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; # 增加发送超时时间
+233
View File
@@ -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;
}
}
+5287
View File
File diff suppressed because it is too large Load Diff
+71
View File
@@ -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"
}
}
+1
View File
@@ -0,0 +1 @@
a8c281c741c2499a02564295ccfb3f91
Binary file not shown.
+4
View File
@@ -0,0 +1,4 @@
allowBuilds:
"@vue-office/pptx": true
esbuild: true
vue-demi: true
+9
View File
@@ -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: '',
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

File diff suppressed because one or more lines are too long
+276
View File
@@ -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)
})
+311
View File
@@ -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>
+20
View File
@@ -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 })
+428
View File
@@ -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 });
}
+539
View File
@@ -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') }
}
}
+65
View File
@@ -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)
}
+137
View File
@@ -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`,
);
}
+235
View File
@@ -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}`);
}
+15
View File
@@ -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)
}
+175
View File
@@ -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`)
}
+65
View File
@@ -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;
},
});
}
+771
View File
@@ -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, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
}
/** 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)
}
+88
View File
@@ -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 }>
}
+606
View File
@@ -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([]); // 失败时返回空数组,前端可以回退到默认值
});
});
}
+636
View File
@@ -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`, {})
+274
View File
@@ -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`, {})
}
+209
View File
@@ -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)
}
+45
View File
@@ -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 },
)
}
+281
View File
@@ -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 })
})
})
}
+138
View File
@@ -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')
})
+182
View File
@@ -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) },
}
}
+62
View File
@@ -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 }) : '-'
+783
View File
@@ -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' }
}
}
+24
View File
@@ -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)}` : ''}`)
+22
View File
@@ -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)
}
+88
View File
@@ -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 },
});
}
+46
View File
@@ -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,
})
}
+45
View File
@@ -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`, {})
File diff suppressed because it is too large Load Diff
+81
View File
@@ -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
}
+360
View File
@@ -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')
}
}
}
+267
View File
@@ -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
}
+153
View File
@@ -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
}
+34
View File
@@ -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)}`)
}
+74
View File
@@ -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`, {})
}
+122
View File
@@ -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 || {})
}
+44
View File
@@ -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)
}
+353
View File
@@ -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`, {});
}
+68
View File
@@ -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 || '操作失败,请重试'
}
+723
View File
@@ -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);
}
}
}
+6
View File
@@ -0,0 +1,6 @@
@font-face {
font-family: 'TencentSans';
src: url('fonts/TencentSans.ttf') format('truetype');
font-weight: normal;
font-style: normal;
}
Binary file not shown.
+6
View File
@@ -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

+5
View File
@@ -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

+5
View File
@@ -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

+5
View File
@@ -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

+4
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 543 KiB

Binary file not shown.

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 439 B

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

+5
View File
@@ -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

+4
View File
@@ -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

+4
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

File diff suppressed because one or more lines are too long

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

+4
View File
@@ -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

+5
View File
@@ -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

Binary file not shown.

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

+3
View File
@@ -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

Some files were not shown because too many files have changed in this diff Show More