mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-08-23 11:22:47 +02:00
Add files via upload
This commit is contained in:
@@ -191,6 +191,7 @@ func setupRoutes(
|
|||||||
|
|
||||||
// 配置管理
|
// 配置管理
|
||||||
protected.GET("/config", configHandler.GetConfig)
|
protected.GET("/config", configHandler.GetConfig)
|
||||||
|
protected.GET("/config/tools", configHandler.GetTools)
|
||||||
protected.PUT("/config", configHandler.UpdateConfig)
|
protected.PUT("/config", configHandler.UpdateConfig)
|
||||||
protected.POST("/config/apply", configHandler.ApplyConfig)
|
protected.POST("/config/apply", configHandler.ApplyConfig)
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,17 @@ func (db *DB) SaveToolExecution(exec *mcp.ToolExecution) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CountToolExecutions 统计工具执行记录总数
|
||||||
|
func (db *DB) CountToolExecutions() (int, error) {
|
||||||
|
query := `SELECT COUNT(*) FROM tool_executions`
|
||||||
|
var count int
|
||||||
|
err := db.QueryRow(query).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
|
||||||
// LoadToolExecutions 加载所有工具执行记录(支持分页)
|
// LoadToolExecutions 加载所有工具执行记录(支持分页)
|
||||||
func (db *DB) LoadToolExecutions() ([]*mcp.ToolExecution, error) {
|
func (db *DB) LoadToolExecutions() ([]*mcp.ToolExecution, error) {
|
||||||
return db.LoadToolExecutionsWithPagination(0, 1000)
|
return db.LoadToolExecutionsWithPagination(0, 1000)
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"cyberstrike-ai/internal/config"
|
"cyberstrike-ai/internal/config"
|
||||||
@@ -91,6 +93,99 @@ func (h *ConfigHandler) GetConfig(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetToolsResponse 获取工具列表响应(分页)
|
||||||
|
type GetToolsResponse struct {
|
||||||
|
Tools []ToolConfigInfo `json:"tools"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
Page int `json:"page"`
|
||||||
|
PageSize int `json:"page_size"`
|
||||||
|
TotalPages int `json:"total_pages"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTools 获取工具列表(支持分页和搜索)
|
||||||
|
func (h *ConfigHandler) GetTools(c *gin.Context) {
|
||||||
|
h.mu.RLock()
|
||||||
|
defer h.mu.RUnlock()
|
||||||
|
|
||||||
|
// 解析分页参数
|
||||||
|
page := 1
|
||||||
|
pageSize := 20
|
||||||
|
if pageStr := c.Query("page"); pageStr != "" {
|
||||||
|
if p, err := strconv.Atoi(pageStr); err == nil && p > 0 {
|
||||||
|
page = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if pageSizeStr := c.Query("page_size"); pageSizeStr != "" {
|
||||||
|
if ps, err := strconv.Atoi(pageSizeStr); err == nil && ps > 0 && ps <= 100 {
|
||||||
|
pageSize = ps
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析搜索参数
|
||||||
|
searchTerm := c.Query("search")
|
||||||
|
searchTermLower := ""
|
||||||
|
if searchTerm != "" {
|
||||||
|
searchTermLower = strings.ToLower(searchTerm)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取所有工具并应用搜索过滤
|
||||||
|
allTools := make([]ToolConfigInfo, 0, len(h.config.Security.Tools))
|
||||||
|
for _, tool := range h.config.Security.Tools {
|
||||||
|
toolInfo := ToolConfigInfo{
|
||||||
|
Name: tool.Name,
|
||||||
|
Description: tool.ShortDescription,
|
||||||
|
Enabled: tool.Enabled,
|
||||||
|
}
|
||||||
|
// 如果没有简短描述,使用详细描述的前100个字符
|
||||||
|
if toolInfo.Description == "" {
|
||||||
|
desc := tool.Description
|
||||||
|
if len(desc) > 100 {
|
||||||
|
desc = desc[:100] + "..."
|
||||||
|
}
|
||||||
|
toolInfo.Description = desc
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果有关键词,进行搜索过滤
|
||||||
|
if searchTermLower != "" {
|
||||||
|
nameLower := strings.ToLower(toolInfo.Name)
|
||||||
|
descLower := strings.ToLower(toolInfo.Description)
|
||||||
|
if !strings.Contains(nameLower, searchTermLower) && !strings.Contains(descLower, searchTermLower) {
|
||||||
|
continue // 不匹配,跳过
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
allTools = append(allTools, toolInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
total := len(allTools)
|
||||||
|
totalPages := (total + pageSize - 1) / pageSize
|
||||||
|
if totalPages == 0 {
|
||||||
|
totalPages = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算分页范围
|
||||||
|
offset := (page - 1) * pageSize
|
||||||
|
end := offset + pageSize
|
||||||
|
if end > total {
|
||||||
|
end = total
|
||||||
|
}
|
||||||
|
|
||||||
|
var tools []ToolConfigInfo
|
||||||
|
if offset < total {
|
||||||
|
tools = allTools[offset:end]
|
||||||
|
} else {
|
||||||
|
tools = []ToolConfigInfo{}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, GetToolsResponse{
|
||||||
|
Tools: tools,
|
||||||
|
Total: total,
|
||||||
|
Page: page,
|
||||||
|
PageSize: pageSize,
|
||||||
|
TotalPages: totalPages,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// UpdateConfigRequest 更新配置请求
|
// UpdateConfigRequest 更新配置请求
|
||||||
type UpdateConfigRequest struct {
|
type UpdateConfigRequest struct {
|
||||||
OpenAI *config.OpenAIConfig `json:"openai,omitempty"`
|
OpenAI *config.OpenAIConfig `json:"openai,omitempty"`
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"cyberstrike-ai/internal/database"
|
"cyberstrike-ai/internal/database"
|
||||||
@@ -34,31 +35,96 @@ type MonitorResponse struct {
|
|||||||
Executions []*mcp.ToolExecution `json:"executions"`
|
Executions []*mcp.ToolExecution `json:"executions"`
|
||||||
Stats map[string]*mcp.ToolStats `json:"stats"`
|
Stats map[string]*mcp.ToolStats `json:"stats"`
|
||||||
Timestamp time.Time `json:"timestamp"`
|
Timestamp time.Time `json:"timestamp"`
|
||||||
|
Total int `json:"total,omitempty"`
|
||||||
|
Page int `json:"page,omitempty"`
|
||||||
|
PageSize int `json:"page_size,omitempty"`
|
||||||
|
TotalPages int `json:"total_pages,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Monitor 获取监控信息
|
// Monitor 获取监控信息
|
||||||
func (h *MonitorHandler) Monitor(c *gin.Context) {
|
func (h *MonitorHandler) Monitor(c *gin.Context) {
|
||||||
executions := h.loadExecutions()
|
// 解析分页参数
|
||||||
|
page := 1
|
||||||
|
pageSize := 20
|
||||||
|
if pageStr := c.Query("page"); pageStr != "" {
|
||||||
|
if p, err := strconv.Atoi(pageStr); err == nil && p > 0 {
|
||||||
|
page = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if pageSizeStr := c.Query("page_size"); pageSizeStr != "" {
|
||||||
|
if ps, err := strconv.Atoi(pageSizeStr); err == nil && ps > 0 && ps <= 100 {
|
||||||
|
pageSize = ps
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
executions, total := h.loadExecutionsWithPagination(page, pageSize)
|
||||||
stats := h.loadStats()
|
stats := h.loadStats()
|
||||||
|
|
||||||
|
totalPages := (total + pageSize - 1) / pageSize
|
||||||
|
if totalPages == 0 {
|
||||||
|
totalPages = 1
|
||||||
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, MonitorResponse{
|
c.JSON(http.StatusOK, MonitorResponse{
|
||||||
Executions: executions,
|
Executions: executions,
|
||||||
Stats: stats,
|
Stats: stats,
|
||||||
Timestamp: time.Now(),
|
Timestamp: time.Now(),
|
||||||
|
Total: total,
|
||||||
|
Page: page,
|
||||||
|
PageSize: pageSize,
|
||||||
|
TotalPages: totalPages,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *MonitorHandler) loadExecutions() []*mcp.ToolExecution {
|
func (h *MonitorHandler) loadExecutions() []*mcp.ToolExecution {
|
||||||
|
executions, _ := h.loadExecutionsWithPagination(1, 1000)
|
||||||
|
return executions
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *MonitorHandler) loadExecutionsWithPagination(page, pageSize int) ([]*mcp.ToolExecution, int) {
|
||||||
if h.db == nil {
|
if h.db == nil {
|
||||||
return h.mcpServer.GetAllExecutions()
|
allExecutions := h.mcpServer.GetAllExecutions()
|
||||||
|
total := len(allExecutions)
|
||||||
|
offset := (page - 1) * pageSize
|
||||||
|
end := offset + pageSize
|
||||||
|
if end > total {
|
||||||
|
end = total
|
||||||
|
}
|
||||||
|
if offset >= total {
|
||||||
|
return []*mcp.ToolExecution{}, total
|
||||||
|
}
|
||||||
|
return allExecutions[offset:end], total
|
||||||
}
|
}
|
||||||
|
|
||||||
executions, err := h.db.LoadToolExecutions()
|
offset := (page - 1) * pageSize
|
||||||
|
executions, err := h.db.LoadToolExecutionsWithPagination(offset, pageSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Warn("从数据库加载执行记录失败,回退到内存数据", zap.Error(err))
|
h.logger.Warn("从数据库加载执行记录失败,回退到内存数据", zap.Error(err))
|
||||||
return h.mcpServer.GetAllExecutions()
|
allExecutions := h.mcpServer.GetAllExecutions()
|
||||||
|
total := len(allExecutions)
|
||||||
|
offset := (page - 1) * pageSize
|
||||||
|
end := offset + pageSize
|
||||||
|
if end > total {
|
||||||
|
end = total
|
||||||
|
}
|
||||||
|
if offset >= total {
|
||||||
|
return []*mcp.ToolExecution{}, total
|
||||||
|
}
|
||||||
|
return allExecutions[offset:end], total
|
||||||
}
|
}
|
||||||
return executions
|
|
||||||
|
// 获取总数
|
||||||
|
total, err := h.db.CountToolExecutions()
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Warn("获取执行记录总数失败", zap.Error(err))
|
||||||
|
// 回退:使用已加载的记录数估算
|
||||||
|
total = offset + len(executions)
|
||||||
|
if len(executions) == pageSize {
|
||||||
|
total = offset + len(executions) + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return executions, total
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *MonitorHandler) loadStats() map[string]*mcp.ToolStats {
|
func (h *MonitorHandler) loadStats() map[string]*mcp.ToolStats {
|
||||||
|
|||||||
@@ -1553,6 +1553,52 @@ header {
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.search-box {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
flex: 1;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
box-shadow: 0 0 0 2px rgba(0, 102, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-search {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
min-width: 40px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-search:hover {
|
||||||
|
background: var(--accent-color);
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
color: white;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
.tools-list {
|
.tools-list {
|
||||||
max-height: 400px;
|
max-height: 400px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@@ -1607,6 +1653,50 @@ header {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tools-list-items {
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tools-pagination,
|
||||||
|
.monitor-pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
margin-top: 8px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-info {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-controls button {
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
min-width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-controls button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-page {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.modal-footer {
|
.modal-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
|||||||
+334
-53
@@ -923,7 +923,7 @@ function addTimelineItem(timeline, type, options) {
|
|||||||
let content = `
|
let content = `
|
||||||
<div class="timeline-item-header">
|
<div class="timeline-item-header">
|
||||||
<span class="timeline-item-time">${time}</span>
|
<span class="timeline-item-time">${time}</span>
|
||||||
<span class="timeline-item-title">${options.title}</span>
|
<span class="timeline-item-title">${escapeHtml(options.title || '')}</span>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -938,7 +938,7 @@ function addTimelineItem(timeline, type, options) {
|
|||||||
<div class="tool-details">
|
<div class="tool-details">
|
||||||
<div class="tool-arg-section">
|
<div class="tool-arg-section">
|
||||||
<strong>参数:</strong>
|
<strong>参数:</strong>
|
||||||
<pre class="tool-args">${JSON.stringify(args, null, 2)}</pre>
|
<pre class="tool-args">${escapeHtml(JSON.stringify(args, null, 2))}</pre>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -947,12 +947,14 @@ function addTimelineItem(timeline, type, options) {
|
|||||||
const data = options.data;
|
const data = options.data;
|
||||||
const isError = data.isError || !data.success;
|
const isError = data.isError || !data.success;
|
||||||
const result = data.result || data.error || '无结果';
|
const result = data.result || data.error || '无结果';
|
||||||
|
// 确保 result 是字符串
|
||||||
|
const resultStr = typeof result === 'string' ? result : JSON.stringify(result);
|
||||||
content += `
|
content += `
|
||||||
<div class="timeline-item-content">
|
<div class="timeline-item-content">
|
||||||
<div class="tool-result-section ${isError ? 'error' : 'success'}">
|
<div class="tool-result-section ${isError ? 'error' : 'success'}">
|
||||||
<strong>执行结果:</strong>
|
<strong>执行结果:</strong>
|
||||||
<pre class="tool-result">${escapeHtml(result)}</pre>
|
<pre class="tool-result">${escapeHtml(resultStr)}</pre>
|
||||||
${data.executionId ? `<div class="tool-execution-id">执行ID: <code>${data.executionId}</code></div>` : ''}
|
${data.executionId ? `<div class="tool-execution-id">执行ID: <code>${escapeHtml(data.executionId)}</code></div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -1006,24 +1008,53 @@ function addMessage(role, content, mcpExecutionIds = null, progressId = null) {
|
|||||||
const bubble = document.createElement('div');
|
const bubble = document.createElement('div');
|
||||||
bubble.className = 'message-bubble';
|
bubble.className = 'message-bubble';
|
||||||
|
|
||||||
// 解析 Markdown 格式
|
// 解析 Markdown 或 HTML 格式
|
||||||
let formattedContent;
|
let formattedContent;
|
||||||
if (typeof marked !== 'undefined') {
|
|
||||||
// 使用 marked.js 解析 Markdown
|
// 先使用 DOMPurify 清理(如果可用),这样可以处理已经是 HTML 的内容
|
||||||
|
if (typeof DOMPurify !== 'undefined') {
|
||||||
|
// 配置 DOMPurify 允许的标签和属性
|
||||||
|
const sanitizeConfig = {
|
||||||
|
// 允许基本的 Markdown 格式化标签
|
||||||
|
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 's', 'code', 'pre', 'blockquote', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'a', 'img', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'hr'],
|
||||||
|
ALLOWED_ATTR: ['href', 'title', 'alt', 'src', 'class'],
|
||||||
|
ALLOW_DATA_ATTR: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 如果内容看起来已经是 HTML(包含 HTML 标签),直接清理
|
||||||
|
// 否则先用 marked.js 解析 Markdown,再清理
|
||||||
|
if (typeof marked !== 'undefined' && !/<[a-z][\s\S]*>/i.test(content)) {
|
||||||
|
// 内容不包含 HTML 标签,可能是 Markdown,使用 marked.js 解析
|
||||||
|
try {
|
||||||
|
marked.setOptions({
|
||||||
|
breaks: true,
|
||||||
|
gfm: true,
|
||||||
|
});
|
||||||
|
let parsedContent = marked.parse(content);
|
||||||
|
formattedContent = DOMPurify.sanitize(parsedContent, sanitizeConfig);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Markdown 解析失败:', e);
|
||||||
|
// 降级处理:直接清理原始内容
|
||||||
|
formattedContent = DOMPurify.sanitize(content, sanitizeConfig);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 内容包含 HTML 标签或 marked.js 不可用,直接清理
|
||||||
|
formattedContent = DOMPurify.sanitize(content, sanitizeConfig);
|
||||||
|
}
|
||||||
|
} else if (typeof marked !== 'undefined') {
|
||||||
|
// 没有 DOMPurify,但有 marked.js
|
||||||
try {
|
try {
|
||||||
// 配置 marked 选项
|
|
||||||
marked.setOptions({
|
marked.setOptions({
|
||||||
breaks: true, // 支持换行
|
breaks: true,
|
||||||
gfm: true, // 支持 GitHub Flavored Markdown
|
gfm: true,
|
||||||
});
|
});
|
||||||
formattedContent = marked.parse(content);
|
formattedContent = marked.parse(content);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Markdown 解析失败:', e);
|
console.error('Markdown 解析失败:', e);
|
||||||
// 降级处理:转义 HTML 并保留换行
|
|
||||||
formattedContent = escapeHtml(content).replace(/\n/g, '<br>');
|
formattedContent = escapeHtml(content).replace(/\n/g, '<br>');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 如果没有 marked.js,使用简单处理
|
// 都没有,简单转义
|
||||||
formattedContent = escapeHtml(content).replace(/\n/g, '<br>');
|
formattedContent = escapeHtml(content).replace(/\n/g, '<br>');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1315,7 +1346,35 @@ function escapeHtml(text) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatMarkdown(text) {
|
function formatMarkdown(text) {
|
||||||
if (typeof marked !== 'undefined') {
|
// 配置 DOMPurify 允许的标签和属性
|
||||||
|
const sanitizeConfig = {
|
||||||
|
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 's', 'code', 'pre', 'blockquote', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'a', 'img', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'hr'],
|
||||||
|
ALLOWED_ATTR: ['href', 'title', 'alt', 'src', 'class'],
|
||||||
|
ALLOW_DATA_ATTR: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof DOMPurify !== 'undefined') {
|
||||||
|
// 如果内容看起来已经是 HTML(包含 HTML 标签),直接清理
|
||||||
|
// 否则先用 marked.js 解析 Markdown,再清理
|
||||||
|
if (typeof marked !== 'undefined' && !/<[a-z][\s\S]*>/i.test(text)) {
|
||||||
|
// 内容不包含 HTML 标签,可能是 Markdown,使用 marked.js 解析
|
||||||
|
try {
|
||||||
|
marked.setOptions({
|
||||||
|
breaks: true,
|
||||||
|
gfm: true,
|
||||||
|
});
|
||||||
|
let parsedContent = marked.parse(text);
|
||||||
|
return DOMPurify.sanitize(parsedContent, sanitizeConfig);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Markdown 解析失败:', e);
|
||||||
|
return DOMPurify.sanitize(text, sanitizeConfig);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 内容包含 HTML 标签或 marked.js 不可用,直接清理
|
||||||
|
return DOMPurify.sanitize(text, sanitizeConfig);
|
||||||
|
}
|
||||||
|
} else if (typeof marked !== 'undefined') {
|
||||||
|
// 没有 DOMPurify,但有 marked.js
|
||||||
try {
|
try {
|
||||||
marked.setOptions({
|
marked.setOptions({
|
||||||
breaks: true,
|
breaks: true,
|
||||||
@@ -1629,6 +1688,12 @@ async function cancelActiveTask(conversationId, button) {
|
|||||||
// 设置相关功能
|
// 设置相关功能
|
||||||
let currentConfig = null;
|
let currentConfig = null;
|
||||||
let allTools = [];
|
let allTools = [];
|
||||||
|
let toolsPagination = {
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
total: 0,
|
||||||
|
totalPages: 0
|
||||||
|
};
|
||||||
|
|
||||||
// 打开设置
|
// 打开设置
|
||||||
async function openSettings() {
|
async function openSettings() {
|
||||||
@@ -1685,19 +1750,95 @@ async function loadConfig() {
|
|||||||
// 填充Agent配置
|
// 填充Agent配置
|
||||||
document.getElementById('agent-max-iterations').value = currentConfig.agent.max_iterations || 30;
|
document.getElementById('agent-max-iterations').value = currentConfig.agent.max_iterations || 30;
|
||||||
|
|
||||||
// 填充工具列表
|
// 加载工具列表(使用分页)
|
||||||
allTools = currentConfig.tools || [];
|
toolsSearchKeyword = '';
|
||||||
renderToolsList();
|
await loadToolsList(1, '');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载配置失败:', error);
|
console.error('加载配置失败:', error);
|
||||||
alert('加载配置失败: ' + error.message);
|
alert('加载配置失败: ' + error.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 工具搜索关键词
|
||||||
|
let toolsSearchKeyword = '';
|
||||||
|
|
||||||
|
// 加载工具列表(分页)
|
||||||
|
async function loadToolsList(page = 1, searchKeyword = '') {
|
||||||
|
try {
|
||||||
|
const pageSize = toolsPagination.pageSize;
|
||||||
|
let url = `/api/config/tools?page=${page}&page_size=${pageSize}`;
|
||||||
|
if (searchKeyword) {
|
||||||
|
url += `&search=${encodeURIComponent(searchKeyword)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await apiFetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('获取工具列表失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
allTools = result.tools || [];
|
||||||
|
toolsPagination = {
|
||||||
|
page: result.page || page,
|
||||||
|
pageSize: result.page_size || pageSize,
|
||||||
|
total: result.total || 0,
|
||||||
|
totalPages: result.total_pages || 1
|
||||||
|
};
|
||||||
|
|
||||||
|
renderToolsList();
|
||||||
|
renderToolsPagination();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载工具列表失败:', error);
|
||||||
|
const toolsList = document.getElementById('tools-list');
|
||||||
|
if (toolsList) {
|
||||||
|
toolsList.innerHTML = `<div class="error">加载工具列表失败: ${escapeHtml(error.message)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 搜索工具
|
||||||
|
function searchTools() {
|
||||||
|
const searchInput = document.getElementById('tools-search');
|
||||||
|
const keyword = searchInput ? searchInput.value.trim() : '';
|
||||||
|
toolsSearchKeyword = keyword;
|
||||||
|
// 搜索时重置到第一页
|
||||||
|
loadToolsList(1, keyword);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除搜索
|
||||||
|
function clearSearch() {
|
||||||
|
const searchInput = document.getElementById('tools-search');
|
||||||
|
if (searchInput) {
|
||||||
|
searchInput.value = '';
|
||||||
|
}
|
||||||
|
toolsSearchKeyword = '';
|
||||||
|
loadToolsList(1, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理搜索框回车事件
|
||||||
|
function handleSearchKeyPress(event) {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
searchTools();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 渲染工具列表
|
// 渲染工具列表
|
||||||
function renderToolsList() {
|
function renderToolsList() {
|
||||||
const toolsList = document.getElementById('tools-list');
|
const toolsList = document.getElementById('tools-list');
|
||||||
toolsList.innerHTML = '';
|
if (!toolsList) return;
|
||||||
|
|
||||||
|
// 只渲染列表部分,分页控件单独渲染
|
||||||
|
const listContainer = toolsList.querySelector('.tools-list-items') || document.createElement('div');
|
||||||
|
listContainer.className = 'tools-list-items';
|
||||||
|
listContainer.innerHTML = '';
|
||||||
|
|
||||||
|
if (allTools.length === 0) {
|
||||||
|
listContainer.innerHTML = '<div class="empty">暂无工具</div>';
|
||||||
|
if (!toolsList.contains(listContainer)) {
|
||||||
|
toolsList.appendChild(listContainer);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
allTools.forEach(tool => {
|
allTools.forEach(tool => {
|
||||||
const toolItem = document.createElement('div');
|
const toolItem = document.createElement('div');
|
||||||
@@ -1710,8 +1851,51 @@ function renderToolsList() {
|
|||||||
<div class="tool-item-desc">${escapeHtml(tool.description || '无描述')}</div>
|
<div class="tool-item-desc">${escapeHtml(tool.description || '无描述')}</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
toolsList.appendChild(toolItem);
|
listContainer.appendChild(toolItem);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!toolsList.contains(listContainer)) {
|
||||||
|
toolsList.appendChild(listContainer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染工具列表分页控件
|
||||||
|
function renderToolsPagination() {
|
||||||
|
const toolsList = document.getElementById('tools-list');
|
||||||
|
if (!toolsList) return;
|
||||||
|
|
||||||
|
// 移除旧的分页控件
|
||||||
|
const oldPagination = toolsList.querySelector('.tools-pagination');
|
||||||
|
if (oldPagination) {
|
||||||
|
oldPagination.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果只有一页或没有数据,不显示分页
|
||||||
|
if (toolsPagination.totalPages <= 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pagination = document.createElement('div');
|
||||||
|
pagination.className = 'tools-pagination';
|
||||||
|
|
||||||
|
const { page, totalPages, total } = toolsPagination;
|
||||||
|
const startItem = (page - 1) * toolsPagination.pageSize + 1;
|
||||||
|
const endItem = Math.min(page * toolsPagination.pageSize, total);
|
||||||
|
|
||||||
|
pagination.innerHTML = `
|
||||||
|
<div class="pagination-info">
|
||||||
|
显示 ${startItem}-${endItem} / 共 ${total} 个工具${toolsSearchKeyword ? ` (搜索: "${escapeHtml(toolsSearchKeyword)}")` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="pagination-controls">
|
||||||
|
<button class="btn-secondary" onclick="loadToolsList(1, '${escapeHtml(toolsSearchKeyword)}')" ${page === 1 ? 'disabled' : ''}>首页</button>
|
||||||
|
<button class="btn-secondary" onclick="loadToolsList(${page - 1}, '${escapeHtml(toolsSearchKeyword)}')" ${page === 1 ? 'disabled' : ''}>上一页</button>
|
||||||
|
<span class="pagination-page">第 ${page} / ${totalPages} 页</span>
|
||||||
|
<button class="btn-secondary" onclick="loadToolsList(${page + 1}, '${escapeHtml(toolsSearchKeyword)}')" ${page === totalPages ? 'disabled' : ''}>下一页</button>
|
||||||
|
<button class="btn-secondary" onclick="loadToolsList(${totalPages}, '${escapeHtml(toolsSearchKeyword)}')" ${page === totalPages ? 'disabled' : ''}>末页</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
toolsList.appendChild(pagination);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 全选工具
|
// 全选工具
|
||||||
@@ -1728,18 +1912,11 @@ function deselectAllTools() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 过滤工具
|
// 过滤工具(已废弃,现在使用服务端搜索)
|
||||||
|
// 保留此函数以防其他地方调用,但实际功能已由searchTools()替代
|
||||||
function filterTools() {
|
function filterTools() {
|
||||||
const searchTerm = document.getElementById('tools-search').value.toLowerCase();
|
// 不再使用客户端过滤,改为触发服务端搜索
|
||||||
document.querySelectorAll('.tool-item').forEach(item => {
|
// 可以保留为空函数或移除oninput事件
|
||||||
const toolName = (item.dataset.toolName || '').toLowerCase();
|
|
||||||
const toolDesc = item.querySelector('.tool-item-desc').textContent.toLowerCase();
|
|
||||||
if (toolName.includes(searchTerm) || toolDesc.includes(searchTerm)) {
|
|
||||||
item.classList.remove('hidden');
|
|
||||||
} else {
|
|
||||||
item.classList.add('hidden');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 应用设置
|
// 应用设置
|
||||||
@@ -1791,18 +1968,49 @@ async function applySettings() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 收集工具启用状态
|
// 收集工具启用状态
|
||||||
|
// 由于使用分页,需要先获取所有工具的状态
|
||||||
|
// 先获取当前页的工具状态
|
||||||
|
const currentPageTools = new Map();
|
||||||
document.querySelectorAll('#tools-list .tool-item').forEach(item => {
|
document.querySelectorAll('#tools-list .tool-item').forEach(item => {
|
||||||
const checkbox = item.querySelector('input[type="checkbox"]');
|
const checkbox = item.querySelector('input[type="checkbox"]');
|
||||||
const toolName = item.dataset.toolName;
|
const toolName = item.dataset.toolName;
|
||||||
if (toolName) {
|
if (toolName) {
|
||||||
// 直接使用工具名称
|
currentPageTools.set(toolName, checkbox.checked);
|
||||||
config.tools.push({
|
|
||||||
name: toolName,
|
|
||||||
enabled: checkbox.checked
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 获取所有工具列表以获取完整状态
|
||||||
|
try {
|
||||||
|
const allToolsResponse = await apiFetch(`/api/config/tools?page=1&page_size=1000`);
|
||||||
|
if (allToolsResponse.ok) {
|
||||||
|
const allToolsResult = await allToolsResponse.json();
|
||||||
|
// 使用所有工具,但用当前页的修改覆盖
|
||||||
|
allToolsResult.tools.forEach(tool => {
|
||||||
|
config.tools.push({
|
||||||
|
name: tool.name,
|
||||||
|
enabled: currentPageTools.has(tool.name) ? currentPageTools.get(tool.name) : tool.enabled
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 如果获取失败,只使用当前页的工具
|
||||||
|
currentPageTools.forEach((enabled, toolName) => {
|
||||||
|
config.tools.push({
|
||||||
|
name: toolName,
|
||||||
|
enabled: enabled
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('获取所有工具列表失败,仅使用当前页工具状态', error);
|
||||||
|
// 如果获取失败,只使用当前页的工具
|
||||||
|
currentPageTools.forEach((enabled, toolName) => {
|
||||||
|
config.tools.push({
|
||||||
|
name: toolName,
|
||||||
|
enabled: enabled
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 更新配置
|
// 更新配置
|
||||||
const updateResponse = await apiFetch('/api/config', {
|
const updateResponse = await apiFetch('/api/config', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
@@ -1922,7 +2130,13 @@ async function changePassword() {
|
|||||||
const monitorState = {
|
const monitorState = {
|
||||||
executions: [],
|
executions: [],
|
||||||
stats: {},
|
stats: {},
|
||||||
lastFetchedAt: null
|
lastFetchedAt: null,
|
||||||
|
pagination: {
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
total: 0,
|
||||||
|
totalPages: 0
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
function openMonitorPanel() {
|
function openMonitorPanel() {
|
||||||
@@ -1947,7 +2161,15 @@ function openMonitorPanel() {
|
|||||||
statusFilter.value = 'all';
|
statusFilter.value = 'all';
|
||||||
}
|
}
|
||||||
|
|
||||||
refreshMonitorPanel();
|
// 重置分页状态
|
||||||
|
monitorState.pagination = {
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
total: 0,
|
||||||
|
totalPages: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
refreshMonitorPanel(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeMonitorPanel() {
|
function closeMonitorPanel() {
|
||||||
@@ -1957,12 +2179,16 @@ function closeMonitorPanel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshMonitorPanel() {
|
async function refreshMonitorPanel(page = null) {
|
||||||
const statsContainer = document.getElementById('monitor-stats');
|
const statsContainer = document.getElementById('monitor-stats');
|
||||||
const execContainer = document.getElementById('monitor-executions');
|
const execContainer = document.getElementById('monitor-executions');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await apiFetch('/api/monitor', { method: 'GET' });
|
// 如果指定了页码,使用指定页码,否则使用当前页码
|
||||||
|
const currentPage = page !== null ? page : monitorState.pagination.page;
|
||||||
|
const pageSize = monitorState.pagination.pageSize;
|
||||||
|
|
||||||
|
const response = await apiFetch(`/api/monitor?page=${currentPage}&page_size=${pageSize}`, { method: 'GET' });
|
||||||
const result = await response.json().catch(() => ({}));
|
const result = await response.json().catch(() => ({}));
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(result.error || '获取监控数据失败');
|
throw new Error(result.error || '获取监控数据失败');
|
||||||
@@ -1972,8 +2198,19 @@ async function refreshMonitorPanel() {
|
|||||||
monitorState.stats = result.stats || {};
|
monitorState.stats = result.stats || {};
|
||||||
monitorState.lastFetchedAt = new Date();
|
monitorState.lastFetchedAt = new Date();
|
||||||
|
|
||||||
|
// 更新分页信息
|
||||||
|
if (result.total !== undefined) {
|
||||||
|
monitorState.pagination = {
|
||||||
|
page: result.page || currentPage,
|
||||||
|
pageSize: result.page_size || pageSize,
|
||||||
|
total: result.total || 0,
|
||||||
|
totalPages: result.total_pages || 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
renderMonitorStats(monitorState.stats, monitorState.lastFetchedAt);
|
renderMonitorStats(monitorState.stats, monitorState.lastFetchedAt);
|
||||||
renderMonitorExecutions(monitorState.executions);
|
renderMonitorExecutions(monitorState.executions);
|
||||||
|
renderMonitorPagination();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('刷新监控面板失败:', error);
|
console.error('刷新监控面板失败:', error);
|
||||||
if (statsContainer) {
|
if (statsContainer) {
|
||||||
@@ -2084,7 +2321,6 @@ function renderMonitorExecutions(executions = [], statusFilter = 'all') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rows = filtered
|
const rows = filtered
|
||||||
.slice(0, 25)
|
|
||||||
.map(exec => {
|
.map(exec => {
|
||||||
const status = (exec.status || 'unknown').toLowerCase();
|
const status = (exec.status || 'unknown').toLowerCase();
|
||||||
const statusClass = `monitor-status-chip ${status}`;
|
const statusClass = `monitor-status-chip ${status}`;
|
||||||
@@ -2109,22 +2345,67 @@ function renderMonitorExecutions(executions = [], statusFilter = 'all') {
|
|||||||
})
|
})
|
||||||
.join('');
|
.join('');
|
||||||
|
|
||||||
container.innerHTML = `
|
// 创建表格容器
|
||||||
<div class="monitor-table-container">
|
const tableContainer = document.createElement('div');
|
||||||
<table class="monitor-table">
|
tableContainer.className = 'monitor-table-container';
|
||||||
<thead>
|
tableContainer.innerHTML = `
|
||||||
<tr>
|
<table class="monitor-table">
|
||||||
<th>工具</th>
|
<thead>
|
||||||
<th>状态</th>
|
<tr>
|
||||||
<th>开始时间</th>
|
<th>工具</th>
|
||||||
<th>耗时</th>
|
<th>状态</th>
|
||||||
<th>操作</th>
|
<th>开始时间</th>
|
||||||
</tr>
|
<th>耗时</th>
|
||||||
</thead>
|
<th>操作</th>
|
||||||
<tbody>${rows}</tbody>
|
</tr>
|
||||||
</table>
|
</thead>
|
||||||
|
<tbody>${rows}</tbody>
|
||||||
|
</table>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 清空容器并添加表格
|
||||||
|
container.innerHTML = '';
|
||||||
|
container.appendChild(tableContainer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染监控面板分页控件
|
||||||
|
function renderMonitorPagination() {
|
||||||
|
const container = document.getElementById('monitor-executions');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
// 移除旧的分页控件
|
||||||
|
const oldPagination = container.querySelector('.monitor-pagination');
|
||||||
|
if (oldPagination) {
|
||||||
|
oldPagination.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { page, totalPages, total, pageSize } = monitorState.pagination;
|
||||||
|
|
||||||
|
// 如果只有一页或没有数据,不显示分页
|
||||||
|
if (totalPages <= 1 || total === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pagination = document.createElement('div');
|
||||||
|
pagination.className = 'monitor-pagination';
|
||||||
|
|
||||||
|
const startItem = (page - 1) * pageSize + 1;
|
||||||
|
const endItem = Math.min(page * pageSize, total);
|
||||||
|
|
||||||
|
pagination.innerHTML = `
|
||||||
|
<div class="pagination-info">
|
||||||
|
显示 ${startItem}-${endItem} / 共 ${total} 条记录
|
||||||
|
</div>
|
||||||
|
<div class="pagination-controls">
|
||||||
|
<button class="btn-secondary" onclick="refreshMonitorPanel(1)" ${page === 1 ? 'disabled' : ''}>首页</button>
|
||||||
|
<button class="btn-secondary" onclick="refreshMonitorPanel(${page - 1})" ${page === 1 ? 'disabled' : ''}>上一页</button>
|
||||||
|
<span class="pagination-page">第 ${page} / ${totalPages} 页</span>
|
||||||
|
<button class="btn-secondary" onclick="refreshMonitorPanel(${page + 1})" ${page === totalPages ? 'disabled' : ''}>下一页</button>
|
||||||
|
<button class="btn-secondary" onclick="refreshMonitorPanel(${totalPages})" ${page === totalPages ? 'disabled' : ''}>末页</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
container.appendChild(pagination);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -115,7 +115,10 @@
|
|||||||
<div class="tools-actions">
|
<div class="tools-actions">
|
||||||
<button class="btn-secondary" onclick="selectAllTools()">全选</button>
|
<button class="btn-secondary" onclick="selectAllTools()">全选</button>
|
||||||
<button class="btn-secondary" onclick="deselectAllTools()">全不选</button>
|
<button class="btn-secondary" onclick="deselectAllTools()">全不选</button>
|
||||||
<input type="text" id="tools-search" placeholder="搜索工具..." oninput="filterTools()" />
|
<div class="search-box">
|
||||||
|
<input type="text" id="tools-search" placeholder="搜索工具..." onkeypress="handleSearchKeyPress(event)" oninput="if(this.value.trim() === '') clearSearch()" />
|
||||||
|
<button class="btn-search" onclick="searchTools()" title="搜索">🔍</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="tools-list" class="tools-list"></div>
|
<div id="tools-list" class="tools-list"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -246,6 +249,8 @@
|
|||||||
|
|
||||||
<!-- Marked.js for Markdown parsing -->
|
<!-- Marked.js for Markdown parsing -->
|
||||||
<script src="https://cdn.jsdelivr.net/npm/marked@11.1.1/marked.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/marked@11.1.1/marked.min.js"></script>
|
||||||
|
<!-- DOMPurify for HTML sanitization to prevent XSS -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.0.8/dist/purify.min.js"></script>
|
||||||
<script src="/static/js/app.js"></script>
|
<script src="/static/js/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user