Compare commits

..

8 Commits

Author SHA1 Message Date
zhaoying
fa4be10e51 fix(web): string type language Editor init 2026-04-02 17:18:08 +08:00
zhaoying
dcb7b496d3 fix(web): jinja2 editor 2026-04-02 15:16:04 +08:00
zhaoying
9535545947 fix(web): if-else cases 2026-04-02 12:13:11 +08:00
zhaoying
59f5c7a8bb fix(web): knowledge base's model types 2026-04-02 11:05:11 +08:00
zhaoying
1305a08c86 fix(web): knowledge base model api params 2026-04-02 10:22:21 +08:00
yingzhao
fe29141437 Merge pull request #753 from SuanmoSuanyangTechnology/fix/v0.2.9_zy
Fix/v0.2.9 zy
2026-03-31 19:08:40 +08:00
zhaoying
17d3c81c02 fix(web): update i18n 2026-03-31 19:06:55 +08:00
zhaoying
baf02e4faa fix(web): update i18n 2026-03-31 15:39:06 +08:00
17 changed files with 750 additions and 449 deletions

View File

@@ -1,5 +1,3 @@
import time
from contextlib import contextmanager
from fastapi import APIRouter, Depends, HTTPException, status, Query from fastapi import APIRouter, Depends, HTTPException, status, Query
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -18,18 +16,6 @@ from app.core.logging_config import get_api_logger
# 获取API专用日志器 # 获取API专用日志器
api_logger = get_api_logger() api_logger = get_api_logger()
@contextmanager
def timer(label: str, user_count: int = 0):
"""上下文管理器:用于测量代码块执行时间"""
start = time.perf_counter()
try:
yield
finally:
elapsed = (time.perf_counter() - start) * 1000 # 转换为毫秒
extra_info = f", 用户数: {user_count}" if user_count > 0 else ""
api_logger.info(f"[性能统计] {label}: {elapsed:.2f}ms{extra_info}")
router = APIRouter( router = APIRouter(
prefix="/dashboard", prefix="/dashboard",
tags=["Dashboard"], tags=["Dashboard"],
@@ -66,7 +52,7 @@ async def get_workspace_end_users(
): ):
""" """
获取工作空间的宿主列表(高性能优化版本 v2 获取工作空间的宿主列表(高性能优化版本 v2
优化策略: 优化策略:
1. 批量查询 end_users一次查询而非循环 1. 批量查询 end_users一次查询而非循环
2. 并发查询所有用户的记忆数量Neo4j 2. 并发查询所有用户的记忆数量Neo4j
@@ -74,7 +60,7 @@ async def get_workspace_end_users(
4. 只返回必要字段减少数据传输 4. 只返回必要字段减少数据传输
5. 添加短期缓存减少重复查询 5. 添加短期缓存减少重复查询
6. 并发执行配置查询和记忆数量查询 6. 并发执行配置查询和记忆数量查询
返回格式: 返回格式:
{ {
"end_user": {"id": "uuid", "other_name": "名称"}, "end_user": {"id": "uuid", "other_name": "名称"},
@@ -84,149 +70,129 @@ async def get_workspace_end_users(
""" """
import asyncio import asyncio
import json import json
# from app.aioRedis import aio_redis_get, aio_redis_set from app.aioRedis import aio_redis_get, aio_redis_set
# 总耗时统计
total_start = time.perf_counter()
workspace_id = current_user.current_workspace_id workspace_id = current_user.current_workspace_id
# # 尝试从缓存获取30秒缓存- 暂时注释以便进行性能测试 # 尝试从缓存获取30秒缓存
# with timer("Redis缓存读取"): cache_key = f"end_users:workspace:{workspace_id}"
# cache_key = f"end_users:workspace:{workspace_id}" try:
# try: cached_data = await aio_redis_get(cache_key)
# cached_data = await aio_redis_get(cache_key) if cached_data:
# if cached_data: api_logger.info(f"从缓存获取宿主列表: workspace_id={workspace_id}")
# api_logger.info(f"从缓存获取宿主列表: workspace_id={workspace_id}") return success(data=json.loads(cached_data), msg="宿主列表获取成功")
# return success(data=json.loads(cached_data), msg="宿主列表获取成功") except Exception as e:
# except Exception as e: api_logger.warning(f"Redis 缓存读取失败: {str(e)}")
# api_logger.warning(f"Redis 缓存读取失败: {str(e)}")
# 获取当前空间类型 # 获取当前空间类型
with timer("获取空间类型"): current_workspace_type = memory_dashboard_service.get_current_workspace_type(db, workspace_id, current_user)
current_workspace_type = memory_dashboard_service.get_current_workspace_type(db, workspace_id, current_user) api_logger.info(f"用户 {current_user.username} 请求获取工作空间 {workspace_id} 的宿主列表")
api_logger.info(f"用户 {current_user.username} 请求获取工作空间 {workspace_id} 的宿主列表, 类型: {current_workspace_type}")
# 获取 end_users已优化为批量查询 # 获取 end_users已优化为批量查询
with timer("获取用户列表"): end_users = memory_dashboard_service.get_workspace_end_users(
end_users = memory_dashboard_service.get_workspace_end_users( db=db,
db=db, workspace_id=workspace_id,
workspace_id=workspace_id, current_user=current_user
current_user=current_user )
)
if not end_users: if not end_users:
api_logger.info("工作空间下没有宿主") api_logger.info("工作空间下没有宿主")
# # 缓存空结果,避免重复查询 - 暂时注释 # 缓存空结果,避免重复查询
# try: try:
# await aio_redis_set(cache_key, json.dumps([]), expire=30) await aio_redis_set(cache_key, json.dumps([]), expire=30)
# except Exception as e: except Exception as e:
# api_logger.warning(f"Redis 缓存写入失败: {str(e)}") api_logger.warning(f"Redis 缓存写入失败: {str(e)}")
return success(data=[], msg="宿主列表获取成功") return success(data=[], msg="宿主列表获取成功")
end_user_ids = [str(user.id) for user in end_users] end_user_ids = [str(user.id) for user in end_users]
user_count = len(end_user_ids)
api_logger.info(f"需要处理的用户数: {user_count}")
# 并发执行两个独立的查询任务 # 并发执行两个独立的查询任务
async def get_memory_configs(): async def get_memory_configs():
"""获取记忆配置(在线程池中执行同步查询)""" """获取记忆配置(在线程池中执行同步查询)"""
with timer("功能模块-获取记忆配置", user_count): try:
try: return await asyncio.to_thread(
return await asyncio.to_thread( get_end_users_connected_configs_batch,
get_end_users_connected_configs_batch, end_user_ids, db
end_user_ids, db )
) except Exception as e:
except Exception as e: api_logger.error(f"批量获取记忆配置失败: {str(e)}")
api_logger.error(f"批量获取记忆配置失败: {str(e)}") return {}
return {}
async def get_memory_nums(): async def get_memory_nums():
"""获取记忆数量""" """获取记忆数量"""
with timer(f"功能模块-获取记忆数量[{current_workspace_type}]", user_count): if current_workspace_type == "rag":
if current_workspace_type == "rag": # RAG 模式:批量查询
# RAG 模式:批量查询 try:
with timer(" - RAG批量查询chunks"): chunk_map = await asyncio.to_thread(
memory_dashboard_service.get_users_total_chunk_batch,
end_user_ids, db, current_user
)
return {uid: {"total": count} for uid, count in chunk_map.items()}
except Exception as e:
api_logger.error(f"批量获取 RAG chunk 数量失败: {str(e)}")
return {uid: {"total": 0} for uid in end_user_ids}
elif current_workspace_type == "neo4j":
# Neo4j 模式:并发查询(带并发限制)
# 使用信号量限制并发数,避免大量用户时压垮 Neo4j
MAX_CONCURRENT_QUERIES = 10
semaphore = asyncio.Semaphore(MAX_CONCURRENT_QUERIES)
async def get_neo4j_memory_num(end_user_id: str):
async with semaphore:
try: try:
chunk_map = await asyncio.to_thread( return await memory_storage_service.search_all(end_user_id)
memory_dashboard_service.get_users_total_chunk_batch,
end_user_ids, db, current_user
)
return {uid: {"total": count} for uid, count in chunk_map.items()}
except Exception as e: except Exception as e:
api_logger.error(f"批量获取 RAG chunk 数量失败: {str(e)}") api_logger.error(f"获取用户 {end_user_id} Neo4j 记忆数量失败: {str(e)}")
return {uid: {"total": 0} for uid in end_user_ids} return {"total": 0}
elif current_workspace_type == "neo4j": memory_nums_list = await asyncio.gather(*[get_neo4j_memory_num(uid) for uid in end_user_ids])
# Neo4j 模式:并发查询(带并发限制) return {end_user_ids[i]: memory_nums_list[i] for i in range(len(end_user_ids))}
# 使用信号量限制并发数,避免大量用户时压垮 Neo4j
MAX_CONCURRENT_QUERIES = 10 return {uid: {"total": 0} for uid in end_user_ids}
semaphore = asyncio.Semaphore(MAX_CONCURRENT_QUERIES)
async def get_neo4j_memory_num(end_user_id: str):
async with semaphore:
single_start = time.perf_counter()
try:
result = await memory_storage_service.search_all(end_user_id)
elapsed = (time.perf_counter() - single_start) * 1000
api_logger.info(f" - Neo4j单用户查询[{end_user_id}]: {elapsed:.2f}ms")
return result
except Exception as e:
api_logger.error(f"获取用户 {end_user_id} Neo4j 记忆数量失败: {str(e)}")
return {"total": 0}
with timer(" - Neo4j并发查询所有用户"):
memory_nums_list = await asyncio.gather(*[get_neo4j_memory_num(uid) for uid in end_user_ids])
return {end_user_ids[i]: memory_nums_list[i] for i in range(len(end_user_ids))}
return {uid: {"total": 0} for uid in end_user_ids}
# 触发按需初始化:为 implicit_emotions_storage 中没有记录的用户异步生成数据 # 触发按需初始化:为 implicit_emotions_storage 中没有记录的用户异步生成数据
with timer("触发Celery初始化任务"): try:
try: from app.celery_app import celery_app as _celery_app
from app.celery_app import celery_app as _celery_app _celery_app.send_task(
_celery_app.send_task( "app.tasks.init_implicit_emotions_for_users",
"app.tasks.init_implicit_emotions_for_users", kwargs={"end_user_ids": end_user_ids},
kwargs={"end_user_ids": end_user_ids}, )
) _celery_app.send_task(
_celery_app.send_task( "app.tasks.init_interest_distribution_for_users",
"app.tasks.init_interest_distribution_for_users", kwargs={"end_user_ids": end_user_ids},
kwargs={"end_user_ids": end_user_ids}, )
) api_logger.info(f"已触发按需初始化任务,候选用户数: {len(end_user_ids)}")
api_logger.info(f"已触发按需初始化任务,候选用户数: {len(end_user_ids)}") except Exception as e:
except Exception as e: api_logger.warning(f"触发按需初始化任务失败(不影响主流程): {e}")
api_logger.warning(f"触发按需初始化任务失败(不影响主流程): {e}")
# 并发执行配置查询和记忆数量查询 # 并发执行配置查询和记忆数量查询
with timer("并发执行两个功能模块"): memory_configs_map, memory_nums_map = await asyncio.gather(
memory_configs_map, memory_nums_map = await asyncio.gather( get_memory_configs(),
get_memory_configs(), get_memory_nums()
get_memory_nums() )
)
# 构建结果(优化:使用列表推导式) # 构建结果(优化:使用列表推导式)
with timer("构建返回结果"): result = []
result = [] for end_user in end_users:
for end_user in end_users: user_id = str(end_user.id)
user_id = str(end_user.id) config_info = memory_configs_map.get(user_id, {})
config_info = memory_configs_map.get(user_id, {}) result.append({
result.append({ 'end_user': {
'end_user': { 'id': user_id,
'id': user_id, 'other_name': end_user.other_name
'other_name': end_user.other_name },
}, 'memory_num': memory_nums_map.get(user_id, {"total": 0}),
'memory_num': memory_nums_map.get(user_id, {"total": 0}), 'memory_config': {
'memory_config': { "memory_config_id": config_info.get("memory_config_id"),
"memory_config_id": config_info.get("memory_config_id"), "memory_config_name": config_info.get("memory_config_name")
"memory_config_name": config_info.get("memory_config_name") }
} })
})
# 写入缓存30秒过期
# # 写入缓存30秒过期- 暂时注释以便进行性能测试 try:
# with timer("Redis缓存写入"): await aio_redis_set(cache_key, json.dumps(result), expire=30)
# try: except Exception as e:
# await aio_redis_set(cache_key, json.dumps(result), expire=30) api_logger.warning(f"Redis 缓存写入失败: {str(e)}")
# except Exception as e:
# api_logger.warning(f"Redis 缓存写入失败: {str(e)}")
# 触发社区聚类补全任务(异步,不阻塞接口响应) # 触发社区聚类补全任务(异步,不阻塞接口响应)
try: try:
@@ -236,8 +202,6 @@ async def get_workspace_end_users(
except Exception as e: except Exception as e:
api_logger.warning(f"触发社区聚类补全任务失败(不影响主流程): {str(e)}") api_logger.warning(f"触发社区聚类补全任务失败(不影响主流程): {str(e)}")
total_elapsed = (time.perf_counter() - total_start) * 1000
api_logger.info(f"[性能统计] 接口总耗时: {total_elapsed:.2f}ms, 用户数: {user_count}")
api_logger.info(f"成功获取 {len(end_users)} 个宿主记录") api_logger.info(f"成功获取 {len(end_users)} 个宿主记录")
return success(data=result, msg="宿主列表获取成功") return success(data=result, msg="宿主列表获取成功")

View File

@@ -68,7 +68,7 @@ export const getModelTypeList = async () => {
return response as any[]; return response as any[];
}; };
// 获取模型列表 // 获取模型列表
export const getModelList = async (types: string[], pageInfo: PageRequest) => { export const getModelList = async (pageInfo: PageRequest, types?: string[]) => {
const response = await request.get(`${apiPrefix}/models`, { ...pageInfo, type: types?.join(','), is_active: true }); const response = await request.get(`${apiPrefix}/models`, { ...pageInfo, type: types?.join(','), is_active: true });
return response as any; return response as any;
}; };

View File

@@ -1508,7 +1508,7 @@ export const en = {
EPISODIC_MEMORY: 'Episodic Memory', EPISODIC_MEMORY: 'Episodic Memory',
FORGET_MEMORY: 'Forget Memory', FORGET_MEMORY: 'Forget Memory',
endUserProfile: 'Profile', endUserProfile: 'Permanent Memory',
editEndUserProfile: 'Edit', editEndUserProfile: 'Edit',
other_name: 'Name', other_name: 'Name',
position: 'Position', position: 'Position',
@@ -1827,6 +1827,7 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re
memoryTipTitle: 'Are you sure you want to enable conversation memory? Conversations will be saved to the memory store.', memoryTipTitle: 'Are you sure you want to enable conversation memory? Conversations will be saved to the memory store.',
stopAudioRecorder: 'Stop Recording', stopAudioRecorder: 'Stop Recording',
startAudioRecorder: 'Start Recording', startAudioRecorder: 'Start Recording',
citations: 'Citations',
}, },
login: { login: {
title: 'Red Bear Memory Science', title: 'Red Bear Memory Science',

View File

@@ -1506,7 +1506,7 @@ export const zh = {
EPISODIC_MEMORY: '情景记忆', EPISODIC_MEMORY: '情景记忆',
FORGET_MEMORY: '遗忘记忆', FORGET_MEMORY: '遗忘记忆',
endUserProfile: '核心档案', endUserProfile: '永久记忆',
editEndUserProfile: '编辑', editEndUserProfile: '编辑',
other_name: '名称', other_name: '名称',
position: '职位', position: '职位',

View File

@@ -162,7 +162,7 @@ const CreateModal = forwardRef<CreateModalRef, CreateModalRefProps>(({
// If model data hasn't been fetched yet, fetch it once // If model data hasn't been fetched yet, fetch it once
if (!models) { if (!models) {
try { try {
models = await getModelList({ page: 1, pagesize: 100 }); models = await getModelList({ page: 1, pagesize: 100 }, ['llm', 'embedding', 'rerank', 'chat']);
} catch (error) { } catch (error) {
console.error('Failed to fetch models:', error); console.error('Failed to fetch models:', error);
models = { items: [] }; models = { items: [] };

View File

@@ -207,7 +207,7 @@ const KnowledgeBaseManagement: FC = () => {
}; };
const fetchModelList = async () => { const fetchModelList = async () => {
try { try {
const response = await getModelList(['llm', 'embedding', 'rerank', 'chat'], { page: 1, pagesize: 100 }); const response = await getModelList({ page: 1, pagesize: 100 }, ['llm', 'embedding', 'rerank', 'chat']);
// 缓存模型列表,建立 id -> name 的映射 // 缓存模型列表,建立 id -> name 的映射
if (response?.items && Array.isArray(response.items)) { if (response?.items && Array.isArray(response.items)) {
const cache: Record<string, string> = {}; const cache: Record<string, string> = {};

View File

@@ -0,0 +1,185 @@
/*
* @Author: ZhaoYing
* @Date: 2026-04-02 15:15:36
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-02 15:15:36
*/
import { type FC, useEffect, useMemo } from 'react';
import { LexicalComposer } from '@lexical/react/LexicalComposer';
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
import { ContentEditable } from '@lexical/react/LexicalContentEditable';
import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin';
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary';
import { type Suggestion } from './plugin/AutocompletePlugin';
import CharacterCountPlugin from './plugin/CharacterCountPlugin';
import Jinja2InitialValuePlugin from './plugin/Jinja2InitialValuePlugin';
import Jinja2AutocompletePlugin from './plugin/Jinja2AutocompletePlugin';
import Jinja2HighlightPlugin from './plugin/Jinja2HighlightPlugin';
import Jinja2BlurPlugin from './plugin/Jinja2BlurPlugin';
import LineNumberPlugin from './plugin/LineNumberPlugin';
const jinja2Theme = {
paragraph: 'editor-paragraph',
code: 'jinja2-expression',
text: {
bold: 'editor-text-bold',
italic: 'editor-text-italic',
code: 'jinja2-inline',
},
};
const initialConfig = {
namespace: 'AutocompleteEditor',
theme: jinja2Theme,
nodes: [],
onError: (error: Error) => console.error(error),
};
const STYLE_ID = 'code-editor-styles';
const JINJA2_STYLES = `
.jinja2-expression {
background-color: #f6f8fa !important;
border: 1px solid #d1d9e0 !important;
border-radius: 3px !important;
padding: 2px 4px !important;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace !important;
font-size: 13px !important;
color: #0969da !important;
}
.jinja2-inline {
background-color: #f6f8fa !important;
padding: 1px 3px !important;
border-radius: 2px !important;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace !important;
font-size: 13px !important;
color: #0969da !important;
}
.editor-paragraph { margin: 0; }
.editor-with-line-numbers { display: flex; }
.line-numbers {
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 12px;
line-height: 16px;
padding: 4px 8px;
text-align: right;
user-select: none;
display: flex;
flex-direction: column;
}
.line-numbers > div { min-height: 20px; display: flex; align-items: flex-start; }
.editor-content-wrapper { flex: 1; }
.editor-content-with-numbers {
white-space: pre-wrap;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
}
.editor-content-with-numbers p { margin: 0; min-height: 20px; }
`;
export interface Jinja2EditorProps {
placeholder?: string;
value?: string;
onChange?: (value: string) => void;
options?: Suggestion[];
variant?: 'outlined' | 'borderless';
height?: number;
size?: 'default' | 'small';
className?: string;
}
const Jinja2Editor: FC<Jinja2EditorProps> = ({
placeholder = '请输入内容...',
value = '',
onChange,
options = [],
variant = 'borderless',
size = 'default',
height,
className,
}) => {
useEffect(() => {
if (!document.getElementById(STYLE_ID)) {
const style = document.createElement('style');
style.id = STYLE_ID;
style.textContent = JINJA2_STYLES;
document.head.appendChild(style);
}
}, []);
const minheight = useMemo(
() => `${height ?? (size === 'small' ? 60 : 120)}px`,
[height, size],
);
const fontSize = size === 'small' ? '12px' : '14px';
const lineHeight = useMemo(
() => `${height ? height - 10 : size === 'small' ? 16 : 20}px`,
[height, size],
);
const placeHolderMinheight = `${height ? 16 : size === 'small' ? 16 : 30}px`;
return (
<LexicalComposer initialConfig={initialConfig}>
<div style={{ position: 'relative' }} className={className}>
<RichTextPlugin
contentEditable={
<div
className="editor-with-line-numbers"
style={{
border: variant === 'borderless' ? 'none' : '1px solid #DFE4ED',
borderRadius: '6px',
minHeight: minheight,
}}
>
<div className="line-numbers">
<div>1</div>
</div>
<div className="editor-content-wrapper">
<ContentEditable
className="editor-content-with-numbers"
style={{
minHeight: minheight,
padding: variant === 'borderless' ? '0' : '4px 0',
outline: 'none',
resize: 'none',
fontSize,
lineHeight,
border: 'none',
}}
/>
</div>
</div>
}
placeholder={
<div
style={{
minHeight: placeHolderMinheight,
position: 'absolute',
top: '4px',
left: '16px',
color: '#A8A9AA',
fontSize,
lineHeight: placeHolderMinheight,
pointerEvents: 'none',
}}
>
{placeholder}
</div>
}
ErrorBoundary={LexicalErrorBoundary}
/>
<HistoryPlugin />
<Jinja2HighlightPlugin />
<LineNumberPlugin />
<Jinja2AutocompletePlugin options={options} />
<CharacterCountPlugin setCount={() => {}} onChange={onChange} waitForInit />
<Jinja2InitialValuePlugin value={value} />
<Jinja2BlurPlugin />
</div>
</LexicalComposer>
);
};
export default Jinja2Editor;

View File

@@ -4,26 +4,20 @@
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-25 10:58:47 * @Last Modified time: 2026-03-25 10:58:47
*/ */
import { type FC, useState, useEffect, useMemo } from 'react'; import { type FC, useState, useMemo } from 'react';
import { LexicalComposer } from '@lexical/react/LexicalComposer'; import { LexicalComposer } from '@lexical/react/LexicalComposer';
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'; import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
import { ContentEditable } from '@lexical/react/LexicalContentEditable'; import { ContentEditable } from '@lexical/react/LexicalContentEditable';
import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin'; import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin';
// import { AutoFocusPlugin } from '@lexical/react/LexicalAutoFocusPlugin';
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'; import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary';
// import { HeadingNode, QuoteNode } from '@lexical/rich-text';
// import { ListItemNode, ListNode } from '@lexical/list';
// import { LinkNode } from '@lexical/link';
// import { CodeNode } from '@lexical/code';
import AutocompletePlugin, { type Suggestion } from './plugin/AutocompletePlugin' import AutocompletePlugin, { type Suggestion } from './plugin/AutocompletePlugin'
import CharacterCountPlugin from './plugin/CharacterCountPlugin' import CharacterCountPlugin from './plugin/CharacterCountPlugin'
import InitialValuePlugin from './plugin/InitialValuePlugin'; import InitialValuePlugin from './plugin/InitialValuePlugin';
import CommandPlugin from './plugin/CommandPlugin'; import CommandPlugin from './plugin/CommandPlugin';
import Jinja2HighlightPlugin from './plugin/Jinja2HighlightPlugin';
import LineNumberPlugin from './plugin/LineNumberPlugin';
import BlurPlugin from './plugin/BlurPlugin'; import BlurPlugin from './plugin/BlurPlugin';
import { VariableNode } from './nodes/VariableNode' import { VariableNode } from './nodes/VariableNode'
import Jinja2Editor from './Jinja2Editor';
// Props interface for Lexical Editor component // Props interface for Lexical Editor component
export interface LexicalEditorProps { export interface LexicalEditorProps {
@@ -39,6 +33,7 @@ export interface LexicalEditorProps {
type?: 'input' | 'textarea'; type?: 'input' | 'textarea';
language?: 'string' | 'jinja2'; language?: 'string' | 'jinja2';
className?: string; className?: string;
waitForInit?: boolean;
} }
// Default theme for editor // Default theme for editor
@@ -50,16 +45,6 @@ const theme = {
}, },
}; };
// Theme with Jinja2 syntax highlighting
const jinja2Theme = {
...theme,
code: 'jinja2-expression',
text: {
...theme.text,
code: 'jinja2-inline',
},
};
// Main Lexical Editor component // Main Lexical Editor component
const Editor: FC<LexicalEditorProps> =({ const Editor: FC<LexicalEditorProps> =({
placeholder = "请输入内容...", placeholder = "请输入内容...",
@@ -71,100 +56,32 @@ const Editor: FC<LexicalEditorProps> =({
type = 'textarea', type = 'textarea',
language = 'string', language = 'string',
height, height,
className className,
waitForInit = false,
}) => { }) => {
console.log('Editor value', value)
const [_count, setCount] = useState(0); const [_count, setCount] = useState(0);
const [enableJinja2, setEnableJinja2] = useState(false)
const [enableLineNumbers, setEnableLineNumbers] = useState(false)
// Setup Jinja2 mode and inject styles when language changes if (language === 'jinja2') {
useEffect(() => { return (
const needsLineNumbers = language === 'jinja2'; <Jinja2Editor
setEnableJinja2(language === 'jinja2'); placeholder={placeholder}
setEnableLineNumbers(needsLineNumbers); value={value}
onChange={onChange}
if (needsLineNumbers) { options={options}
const styleId = 'code-editor-styles'; variant={variant}
let existingStyle = document.getElementById(styleId); size={size}
height={height}
if (!existingStyle) { className={className}
const style = document.createElement('style'); />
style.id = styleId; );
style.textContent = ` }
.jinja2-expression {
background-color: #f6f8fa !important;
border: 1px solid #d1d9e0 !important;
border-radius: 3px !important;
padding: 2px 4px !important;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace !important;
font-size: 13px !important;
color: #0969da !important;
}
.jinja2-inline {
background-color: #f6f8fa !important;
padding: 1px 3px !important;
border-radius: 2px !important;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace !important;
font-size: 13px !important;
color: #0969da !important;
}
.editor-paragraph {
margin: 0;
}
.editor-paragraph:has-text('{') .editor-text,
.editor-paragraph:has-text('[') .editor-text {
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace !important;
}
.editor-with-line-numbers {
display: flex;
}
.line-numbers {
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 12px;
line-height: 16px;
padding: 4px 8px;
text-align: right;
user-select: none;
display: flex;
flex-direction: column;
}
.line-numbers > div {
min-height: 20px;
display: flex;
align-items: flex-start;
}
.editor-content-wrapper {
flex: 1;
}
.editor-content-with-numbers {
white-space: pre-wrap;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
}
.editor-content-with-numbers p {
margin: 0;
min-height: 20px;
}
`;
document.head.appendChild(style);
}
}
}, [language])
// Lexical editor configuration // Lexical editor configuration
const initialConfig = { const initialConfig = {
namespace: 'AutocompleteEditor', namespace: 'AutocompleteEditor',
theme: enableJinja2 ? jinja2Theme : theme, theme,
nodes: enableJinja2 ? [ nodes: [VariableNode],
// When Jinja2 is enabled, use plain text instead of VariableNode
] : [
// HeadingNode,
// QuoteNode,
// ListItemNode,
// ListNode,
// LinkNode,
// CodeNode,
VariableNode,
],
onError: (error: Error) => { onError: (error: Error) => {
console.error(error); console.error(error);
}, },
@@ -198,54 +115,26 @@ const Editor: FC<LexicalEditorProps> =({
<div style={{ position: 'relative' }} className={className}> <div style={{ position: 'relative' }} className={className}>
<RichTextPlugin <RichTextPlugin
contentEditable={ contentEditable={
enableLineNumbers ? ( <ContentEditable
// Editor with line numbers for Jinja2 mode style={{
<div className="editor-with-line-numbers" style={{
border: variant === 'borderless' ? 'none' : '1px solid #DFE4ED',
borderRadius: '6px',
minHeight: minheight, minHeight: minheight,
}}> padding: height ? '4px 6px' : variant === 'borderless' ? '0' : '6px 8px',
<div className="line-numbers"> border: variant === 'borderless' ? 'none' : '1px solid #EBEBEB',
<div>1</div> borderRadius: '8px',
</div> outline: 'none',
<div className="editor-content-wrapper"> resize: 'none',
<ContentEditable fontSize: fontSize,
className="editor-content-with-numbers" lineHeight: lineHeight,
style={{ }}
minHeight: minheight, />
padding: variant === 'borderless' ? '0' : '4px 0',
outline: 'none',
resize: 'none',
fontSize: fontSize,
lineHeight: lineHeight,
border: 'none',
}}
/>
</div>
</div>
) : (
// Standard editor without line numbers
<ContentEditable
style={{
minHeight: minheight,
padding: height ? '4px 6px' : variant === 'borderless' ? '0' : '6px 8px',
border: variant === 'borderless' ? 'none' : '1px solid #EBEBEB',
borderRadius: '8px',
outline: 'none',
resize: 'none',
fontSize: fontSize,
lineHeight: lineHeight,
}}
/>
)
} }
placeholder={ placeholder={
<div <div
style={{ style={{
minHeight: placeHolderMinheight, minHeight: placeHolderMinheight,
position: 'absolute', position: 'absolute',
top: enableLineNumbers ? '4px' : variant === 'borderless' ? '0' : '6px', top: variant === 'borderless' ? '0' : '6px',
left: enableLineNumbers ? '16px' : (variant === 'borderless' ? '0' : '11px'), left: variant === 'borderless' ? '0' : '11px',
color: '#A8A9AA', color: '#A8A9AA',
fontSize: fontSize, fontSize: fontSize,
lineHeight: placeHolderMinheight, lineHeight: placeHolderMinheight,
@@ -257,15 +146,12 @@ const Editor: FC<LexicalEditorProps> =({
} }
ErrorBoundary={LexicalErrorBoundary} ErrorBoundary={LexicalErrorBoundary}
/> />
{/* Editor plugins */}
<HistoryPlugin /> <HistoryPlugin />
<CommandPlugin /> <CommandPlugin />
{language === 'jinja2' && <Jinja2HighlightPlugin />} <AutocompletePlugin options={options} />
{enableLineNumbers && <LineNumberPlugin />} <CharacterCountPlugin setCount={(count) => { setCount(count) }} onChange={onChange} waitForInit={waitForInit || !!value} />
<AutocompletePlugin options={options} enableJinja2={enableJinja2} /> <InitialValuePlugin value={value} options={options} />
<CharacterCountPlugin setCount={(count) => { setCount(count) }} onChange={onChange} /> <BlurPlugin />
<InitialValuePlugin value={value} options={options} enableLineNumbers={enableLineNumbers} />
<BlurPlugin enableJinja2={enableJinja2} />
</div> </div>
</LexicalComposer> </LexicalComposer>
); );

View File

@@ -2,11 +2,11 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2025-12-23 16:22:51 * @Date: 2025-12-23 16:22:51
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-25 16:13:37 * @Last Modified time: 2026-04-02 17:12:41
*/ */
import { useEffect, useState, useRef, type FC } from 'react'; import { useEffect, useState, useRef, type FC } from 'react';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { $getSelection, $isRangeSelection, $isTextNode, COMMAND_PRIORITY_HIGH, KEY_ENTER_COMMAND, KEY_ARROW_DOWN_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ESCAPE_COMMAND } from 'lexical'; import { $getSelection, $isRangeSelection, COMMAND_PRIORITY_HIGH, KEY_ENTER_COMMAND, KEY_ARROW_DOWN_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ESCAPE_COMMAND } from 'lexical';
import { Space, Flex } from 'antd'; import { Space, Flex } from 'antd';
import { INSERT_VARIABLE_COMMAND, CLOSE_AUTOCOMPLETE_COMMAND } from '../commands'; import { INSERT_VARIABLE_COMMAND, CLOSE_AUTOCOMPLETE_COMMAND } from '../commands';
@@ -26,7 +26,7 @@ export interface Suggestion {
} }
// Autocomplete plugin for variable suggestions triggered by '/' character // Autocomplete plugin for variable suggestions triggered by '/' character
const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }> = ({ options, enableJinja2 = false }) => { const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => {
const [editor] = useLexicalComposerContext(); const [editor] = useLexicalComposerContext();
const [showSuggestions, setShowSuggestions] = useState(false); const [showSuggestions, setShowSuggestions] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0); const [selectedIndex, setSelectedIndex] = useState(0);
@@ -129,34 +129,7 @@ const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }>
// Insert selected suggestion into editor // Insert selected suggestion into editor
const insertMention = (suggestion: Suggestion) => { const insertMention = (suggestion: Suggestion) => {
if (enableJinja2) { editor.dispatchCommand(INSERT_VARIABLE_COMMAND, { data: suggestion });
// In Jinja2 mode, insert {{variable}} format text
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
const anchorNode = selection.anchor.getNode();
const anchorOffset = selection.anchor.offset;
const nodeText = anchorNode.getTextContent();
// Remove trigger character '/'
const textBefore = nodeText.substring(0, anchorOffset - 1);
const textAfter = nodeText.substring(anchorOffset);
const newText = textBefore + `{{${suggestion.value}}}` + textAfter;
if ($isTextNode(anchorNode)) {
anchorNode.setTextContent(newText);
}
// Set cursor position after inserted text
const newOffset = textBefore.length + `{{${suggestion.value}}}`.length;
selection.anchor.offset = newOffset;
selection.focus.offset = newOffset;
}
});
} else {
// In normal mode, use VariableNode
editor.dispatchCommand(INSERT_VARIABLE_COMMAND, { data: suggestion });
}
setShowSuggestions(false); setShowSuggestions(false);
}; };

View File

@@ -1,64 +1,33 @@
/* /*
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-01-20 10:42:13 * @Date: 2026-01-20 10:42:13
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-03 10:12:10 * @Last Modified time: 2026-04-02 17:13:08
*/ */
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { $setSelection } from 'lexical';
import { CLOSE_AUTOCOMPLETE_COMMAND } from '../commands'; import { CLOSE_AUTOCOMPLETE_COMMAND } from '../commands';
// Plugin to handle blur events and close autocomplete when clicking outside // Plugin to handle blur events and close autocomplete when clicking outside
export default function BlurPlugin({ enableJinja2 }: { enableJinja2: boolean }) { export default function BlurPlugin() {
const [editor] = useLexicalComposerContext(); const [editor] = useLexicalComposerContext();
useEffect(() => { useEffect(() => {
// Close autocomplete when clicking outside the popup
const handleClickOutside = (e: MouseEvent) => { const handleClickOutside = (e: MouseEvent) => {
const target = e.target as HTMLElement; if ((e.target as HTMLElement)?.closest('[data-autocomplete-popup="true"]')) return;
if (target?.closest('[data-autocomplete-popup="true"]')) {
return;
}
editor.dispatchCommand(CLOSE_AUTOCOMPLETE_COMMAND, undefined); editor.dispatchCommand(CLOSE_AUTOCOMPLETE_COMMAND, undefined);
}; };
document.addEventListener('mousedown', handleClickOutside); document.addEventListener('mousedown', handleClickOutside);
return editor.registerRootListener((rootElement) => { return editor.registerRootListener((rootElement) => {
if (rootElement) { if (rootElement) {
const handleBlur = (e: FocusEvent) => {
if (enableJinja2) {
// Check if autocomplete popup was clicked
const target = e.target as HTMLElement;
if (target?.closest('[data-autocomplete-popup="true"]')) {
return;
}
// Check if blur was caused by paste operation
const relatedTarget = e.relatedTarget as HTMLElement;
if (!relatedTarget || relatedTarget === document.body) {
return;
}
// Clear selection on blur
editor.update(() => {
$setSelection(null);
});
}
};
rootElement.addEventListener('blur', handleBlur);
return () => { return () => {
document.removeEventListener('mousedown', handleClickOutside); document.removeEventListener('mousedown', handleClickOutside);
rootElement.removeEventListener('blur', handleBlur);
}; };
} }
return () => { return () => { document.removeEventListener('mousedown', handleClickOutside); };
document.removeEventListener('mousedown', handleClickOutside);
};
}); });
}, [editor, enableJinja2]); }, [editor]);
return null; return null;
} }

View File

@@ -1,43 +1,73 @@
import { useEffect } from 'react'; /*
* @Author: ZhaoYing
* @Date: 2025-12-23 16:22:51
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-02 17:13:45
*/
import { useEffect, useRef } from 'react';
import { $getRoot, $isParagraphNode } from 'lexical'; import { $getRoot, $isParagraphNode } from 'lexical';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { $isVariableNode } from '../nodes/VariableNode'; import { $isVariableNode } from '../nodes/VariableNode';
const CharacterCountPlugin = ({ setCount, onChange }: { setCount: (count: number) => void; onChange?: (value: string) => void }) => { const serialize = (root: ReturnType<typeof $getRoot>): string => {
const paragraphs: string[] = [];
root.getChildren().forEach(child => {
if ($isParagraphNode(child)) {
let content = '';
child.getChildren().forEach(node => {
content += $isVariableNode(node) ? node.getTextContent() : node.getTextContent();
});
paragraphs.push(content);
}
});
return paragraphs.join('\n');
};
const CharacterCountPlugin = ({
setCount,
onChange,
waitForInit = false,
}: {
setCount: (count: number) => void;
onChange?: (value: string) => void;
waitForInit?: boolean;
}) => {
const [editor] = useLexicalComposerContext(); const [editor] = useLexicalComposerContext();
// lastProgrammaticValue tracks what InitialValuePlugin wrote, so we can
// suppress onChange when the content hasn't actually changed from that value.
const lastProgrammaticValueRef = useRef<string | null>(null);
const isReadyRef = useRef(!waitForInit);
const isFirstUpdateRef = useRef(true);
useEffect(() => { useEffect(() => {
return editor.registerUpdateListener(({ editorState }) => { return editor.registerUpdateListener(({ editorState, tags }) => {
editorState.read(() => { if (tags.has('programmatic')) {
const root = $getRoot(); isReadyRef.current = true;
let serializedContent = ''; isFirstUpdateRef.current = false;
editorState.read(() => {
// Traverse all nodes and serialize properly lastProgrammaticValueRef.current = serialize($getRoot());
const paragraphs: string[] = [];
root.getChildren().forEach(child => {
if ($isParagraphNode(child)) {
let paragraphContent = '';
child.getChildren().forEach(node => {
if ($isVariableNode(node)) {
paragraphContent += node.getTextContent();
} else {
paragraphContent += node.getTextContent();
}
});
paragraphs.push(paragraphContent);
}
}); });
return;
serializedContent = paragraphs.join('\n'); }
if (!isReadyRef.current) return;
setCount(serializedContent.length); editorState.read(() => {
onChange?.(serializedContent); const content = serialize($getRoot());
// Skip the first update if content is empty (editor initial render)
if (isFirstUpdateRef.current) {
isFirstUpdateRef.current = false;
if (content === '') return;
}
// Skip if content is identical to what was programmatically written
if (content === lastProgrammaticValueRef.current) return;
lastProgrammaticValueRef.current = null;
setCount(content.length);
onChange?.(content);
}); });
}); });
}, [editor, setCount, onChange]); }, [editor, setCount, onChange]);
return null; return null;
} };
export default CharacterCountPlugin export default CharacterCountPlugin;

View File

@@ -1,3 +1,9 @@
/*
* @Author: ZhaoYing
* @Date: 2025-12-23 16:22:51
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-02 17:14:15
*/
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { $getRoot, $createParagraphNode, $createTextNode } from 'lexical'; import { $getRoot, $createParagraphNode, $createTextNode } from 'lexical';
@@ -8,19 +14,17 @@ import { type Suggestion } from '../plugin/AutocompletePlugin'
interface InitialValuePluginProps { interface InitialValuePluginProps {
value: string; value: string;
options?: Suggestion[]; options?: Suggestion[];
enableLineNumbers?: boolean;
} }
const InitialValuePlugin: React.FC<InitialValuePluginProps> = ({ value, options = [], enableLineNumbers = false }) => { const InitialValuePlugin: React.FC<InitialValuePluginProps> = ({ value, options = [] }) => {
const [editor] = useLexicalComposerContext(); const [editor] = useLexicalComposerContext();
const prevValueRef = useRef<string>(''); const prevValueRef = useRef<string>('');
const prevEnableLineNumbersRef = useRef<boolean>(enableLineNumbers);
const isUserInputRef = useRef(false); const isUserInputRef = useRef(false);
const optionsRef = useRef(options); const optionsRef = useRef(options);
optionsRef.current = options; optionsRef.current = options;
useEffect(() => { useEffect(() => {
const removeListener = editor.registerUpdateListener(({ editorState, tags }) => { return editor.registerUpdateListener(({ editorState, tags }) => {
if (tags.has('programmatic')) return; if (tags.has('programmatic')) return;
editorState.read(() => { editorState.read(() => {
const root = $getRoot(); const root = $getRoot();
@@ -31,21 +35,16 @@ const InitialValuePlugin: React.FC<InitialValuePluginProps> = ({ value, options
} }
}); });
}); });
return removeListener;
}, [editor]); }, [editor]);
useEffect(() => { useEffect(() => {
if (value !== prevValueRef.current || enableLineNumbers !== prevEnableLineNumbersRef.current) { if (value !== prevValueRef.current) {
// Skip reset if the change was triggered by user input (avoid cursor jump) if (isUserInputRef.current) {
if (isUserInputRef.current && enableLineNumbers === prevEnableLineNumbersRef.current) {
prevValueRef.current = value; prevValueRef.current = value;
isUserInputRef.current = false; isUserInputRef.current = false;
return; return;
} }
// Update refs BEFORE editor.update to prevent re-entry
prevValueRef.current = value; prevValueRef.current = value;
prevEnableLineNumbersRef.current = enableLineNumbers;
isUserInputRef.current = false; isUserInputRef.current = false;
queueMicrotask(() => { queueMicrotask(() => {
@@ -54,16 +53,7 @@ const InitialValuePlugin: React.FC<InitialValuePluginProps> = ({ value, options
root.clear(); root.clear();
const parts = value.split(/(\{\{[^}]+\}\}|\n)/); const parts = value.split(/(\{\{[^}]+\}\}|\n)/);
let paragraph = $createParagraphNode();
if (enableLineNumbers) {
const lines = value.split('\n');
lines.forEach((line) => {
const paragraph = $createParagraphNode();
paragraph.append($createTextNode(line));
root.append(paragraph);
});
} else {
let paragraph = $createParagraphNode();
parts.forEach(part => { parts.forEach(part => {
if (part === '\n') { if (part === '\n') {
@@ -118,15 +108,10 @@ const InitialValuePlugin: React.FC<InitialValuePluginProps> = ({ value, options
} }
}); });
root.append(paragraph); root.append(paragraph);
}
}, { tag: 'programmatic' }); }, { tag: 'programmatic' });
}); });
} else {
prevValueRef.current = value;
prevEnableLineNumbersRef.current = enableLineNumbers;
isUserInputRef.current = false;
} }
}, [value, editor, enableLineNumbers]); }, [value, editor]);
return null; return null;
}; };

View File

@@ -0,0 +1,199 @@
/*
* @Author: ZhaoYing
* @Date: 2026-04-02 17:10:59
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-02 17:10:59
*/
import { useEffect, useState, useRef, type FC } from 'react';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import {
$getSelection, $isRangeSelection, $isTextNode,
COMMAND_PRIORITY_HIGH, KEY_ENTER_COMMAND, KEY_ARROW_DOWN_COMMAND,
KEY_ARROW_UP_COMMAND, KEY_ESCAPE_COMMAND,
} from 'lexical';
import { Space, Flex } from 'antd';
import { CLOSE_AUTOCOMPLETE_COMMAND } from '../commands';
import type { Suggestion } from './AutocompletePlugin';
const Jinja2AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => {
const [editor] = useLexicalComposerContext();
const [showSuggestions, setShowSuggestions] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0);
const [popupPosition, setPopupPosition] = useState({ top: 0, left: 0 });
const popupRef = useRef<HTMLDivElement>(null);
const scrollSelectedIntoView = () => {
if (!popupRef.current) return;
const selectedElement = popupRef.current.querySelector('[data-selected="true"]');
if (!selectedElement) return;
const container = popupRef.current;
const element = selectedElement as HTMLElement;
const containerRect = container.getBoundingClientRect();
const elementRect = element.getBoundingClientRect();
if (elementRect.bottom > containerRect.bottom) {
container.scrollTop += elementRect.bottom - containerRect.bottom;
} else if (elementRect.top < containerRect.top) {
container.scrollTop -= containerRect.top - elementRect.top;
}
};
useEffect(() => {
return editor.registerUpdateListener(({ editorState }) => {
editorState.read(() => {
const selection = $getSelection();
if (!selection || !$isRangeSelection(selection)) {
setShowSuggestions(false);
return;
}
const anchorNode = selection.anchor.getNode();
const anchorOffset = selection.anchor.offset;
const textBeforeCursor = anchorNode.getTextContent().substring(0, anchorOffset);
const shouldShow = textBeforeCursor.endsWith('/');
setShowSuggestions(shouldShow);
if (!shouldShow) { setSelectedIndex(0); return; }
const domSelection = window.getSelection();
if (domSelection && domSelection.rangeCount > 0) {
const rect = domSelection.getRangeAt(0).getBoundingClientRect();
const popupWidth = 280, popupHeight = 200;
const vw = window.innerWidth, vh = window.innerHeight;
let left = Math.min(Math.max(rect.left, 10), vw - popupWidth - 10);
let top = rect.top - 10;
if (top - popupHeight < 10) {
top = Math.min(rect.bottom + 10, vh - popupHeight - 10);
}
setPopupPosition({ top, left });
}
});
});
}, [editor]);
useEffect(() => {
return editor.registerCommand(
CLOSE_AUTOCOMPLETE_COMMAND,
() => { setShowSuggestions(false); return true; },
COMMAND_PRIORITY_HIGH,
);
}, [editor]);
const insertMention = (suggestion: Suggestion) => {
editor.update(() => {
const selection = $getSelection();
if (!$isRangeSelection(selection)) return;
const anchorNode = selection.anchor.getNode();
const anchorOffset = selection.anchor.offset;
const nodeText = anchorNode.getTextContent();
const textBefore = nodeText.substring(0, anchorOffset - 1);
const textAfter = nodeText.substring(anchorOffset);
const inserted = `{{${suggestion.value}}}`;
if ($isTextNode(anchorNode)) {
anchorNode.setTextContent(textBefore + inserted + textAfter);
const newOffset = textBefore.length + inserted.length;
selection.anchor.offset = newOffset;
selection.focus.offset = newOffset;
}
});
setShowSuggestions(false);
};
const groupedSuggestions = options.reduce((groups: Record<string, Suggestion[]>, s) => {
const id = s.nodeData.id as string;
if (!groups[id]) groups[id] = [];
groups[id].push(s);
return groups;
}, {});
const allOptions = Object.values(groupedSuggestions).flat();
useEffect(() => {
if (!showSuggestions) return;
return editor.registerCommand(
KEY_ENTER_COMMAND,
(event) => {
const opt = allOptions[selectedIndex];
if (opt && !opt.disabled) { event?.preventDefault(); insertMention(opt); return true; }
return false;
},
COMMAND_PRIORITY_HIGH,
);
}, [showSuggestions, selectedIndex, allOptions]);
useEffect(() => {
if (!showSuggestions) return;
const down = editor.registerCommand(KEY_ARROW_DOWN_COMMAND, (e) => {
e?.preventDefault();
setSelectedIndex(prev => {
let next = prev + 1;
while (next < allOptions.length && allOptions[next].disabled) next++;
setTimeout(scrollSelectedIntoView, 0);
return next >= allOptions.length ? prev : next;
});
return true;
}, COMMAND_PRIORITY_HIGH);
const up = editor.registerCommand(KEY_ARROW_UP_COMMAND, (e) => {
e?.preventDefault();
setSelectedIndex(prev => {
let p = prev - 1;
while (p >= 0 && allOptions[p].disabled) p--;
setTimeout(scrollSelectedIntoView, 0);
return p < 0 ? prev : p;
});
return true;
}, COMMAND_PRIORITY_HIGH);
const esc = editor.registerCommand(KEY_ESCAPE_COMMAND, (e) => {
e?.preventDefault(); setShowSuggestions(false); return true;
}, COMMAND_PRIORITY_HIGH);
return () => { down(); up(); esc(); };
}, [showSuggestions, selectedIndex, allOptions, editor]);
if (!showSuggestions || Object.keys(groupedSuggestions).length === 0) return null;
return (
<div
ref={popupRef}
data-autocomplete-popup="true"
onMouseDown={(e) => e.preventDefault()}
className="rb:fixed rb:z-1000 rb:py-1 rb:bg-white rb:rounded-xl rb:min-w-70 rb:max-h-50 rb:overflow-y-auto rb:transform-[translateY(-100%)] rb:shadow-[0px_2px_12px_0px_rgba(23,23,25,0.12)]"
style={{ top: popupPosition.top, left: popupPosition.left }}
>
<Flex vertical gap={12}>
{Object.entries(groupedSuggestions).map(([nodeId, nodeOptions]) => (
<div key={nodeId}>
<Flex align="center" gap={4} className="rb:px-3! rb:text-[12px] rb:py-1.25! rb:font-medium rb:text-[#5B6167]">
{nodeOptions[0]?.nodeData?.icon && <img src={nodeOptions[0].nodeData.icon} className="rb:size-3" alt="" />}
{nodeOptions[0]?.nodeData?.name || nodeId}
</Flex>
{nodeOptions.map((option) => {
const globalIndex = allOptions.indexOf(option);
return (
<Flex
key={option.key}
data-selected={selectedIndex === globalIndex}
className="rb:pl-6! rb:pr-3! rb:py-2!"
align="center"
justify="space-between"
style={{
cursor: option.disabled ? 'not-allowed' : 'pointer',
background: selectedIndex === globalIndex ? '#f0f8ff' : 'white',
opacity: option.disabled ? 0.5 : 1,
}}
onClick={() => !option.disabled && insertMention(option)}
onMouseEnter={() => setSelectedIndex(globalIndex)}
>
<Space size={4}>
<span className="rb:text-[#155EEF]">{option.isContext ? '📄' : '{x}'}</span>
<span>{option.label}</span>
</Space>
{option.dataType && <span className="rb:text-[#5B6167]">{option.dataType}</span>}
</Flex>
);
})}
</div>
))}
</Flex>
</div>
);
};
export default Jinja2AutocompletePlugin;

View File

@@ -0,0 +1,41 @@
/*
* @Author: ZhaoYing
* @Date: 2026-04-02 17:11:04
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-02 17:11:04
*/
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { useEffect } from 'react';
import { $setSelection } from 'lexical';
import { CLOSE_AUTOCOMPLETE_COMMAND } from '../commands';
export default function Jinja2BlurPlugin() {
const [editor] = useLexicalComposerContext();
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if ((e.target as HTMLElement)?.closest('[data-autocomplete-popup="true"]')) return;
editor.dispatchCommand(CLOSE_AUTOCOMPLETE_COMMAND, undefined);
};
document.addEventListener('mousedown', handleClickOutside);
return editor.registerRootListener((rootElement) => {
if (rootElement) {
const handleBlur = (e: FocusEvent) => {
if ((e.target as HTMLElement)?.closest('[data-autocomplete-popup="true"]')) return;
const relatedTarget = e.relatedTarget as HTMLElement;
if (!relatedTarget || relatedTarget === document.body) return;
editor.update(() => { $setSelection(null); });
};
rootElement.addEventListener('blur', handleBlur);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
rootElement.removeEventListener('blur', handleBlur);
};
}
return () => { document.removeEventListener('mousedown', handleClickOutside); };
});
}, [editor]);
return null;
}

View File

@@ -0,0 +1,61 @@
/*
* @Author: ZhaoYing
* @Date: 2026-04-02 17:11:07
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-02 17:11:07
*/
import { useEffect, useRef } from 'react';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { $getRoot, $createParagraphNode, $createTextNode } from 'lexical';
interface Jinja2InitialValuePluginProps {
value: string;
}
const Jinja2InitialValuePlugin: React.FC<Jinja2InitialValuePluginProps> = ({ value }) => {
const [editor] = useLexicalComposerContext();
const prevValueRef = useRef<string>('');
const isUserInputRef = useRef(false);
useEffect(() => {
return editor.registerUpdateListener(({ editorState, tags }) => {
if (tags.has('programmatic')) return;
editorState.read(() => {
const textContent = $getRoot().getTextContent();
if (textContent !== prevValueRef.current) {
isUserInputRef.current = true;
prevValueRef.current = textContent;
}
});
});
}, [editor]);
useEffect(() => {
if (value === prevValueRef.current) return;
if (isUserInputRef.current) {
prevValueRef.current = value;
isUserInputRef.current = false;
return;
}
prevValueRef.current = value;
isUserInputRef.current = false;
queueMicrotask(() => {
editor.update(() => {
const root = $getRoot();
root.clear();
value.split('\n').forEach((line) => {
const paragraph = $createParagraphNode();
paragraph.append($createTextNode(line));
root.append(paragraph);
});
}, { tag: 'programmatic' });
});
}, [value, editor]);
return null;
};
export default Jinja2InitialValuePlugin;

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-02-09 18:35:43 * @Date: 2026-02-09 18:35:43
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-20 11:32:44 * @Last Modified time: 2026-04-02 17:17:06
*/ */
import { type FC, useRef, useState } from "react"; import { type FC, useRef, useState } from "react";
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@@ -114,6 +114,7 @@ const HttpRequest: FC<{ options: Suggestion[]; selectedNode?: any; graphRef?: an
<Col span={16}> <Col span={16}>
<Form.Item name="url"> <Form.Item name="url">
<Editor <Editor
key="url"
options={options.filter(vo => vo.dataType === 'string' || vo.dataType === 'number')} options={options.filter(vo => vo.dataType === 'string' || vo.dataType === 'number')}
variant="outlined" variant="outlined"
type="input" type="input"
@@ -212,13 +213,15 @@ const HttpRequest: FC<{ options: Suggestion[]; selectedNode?: any; graphRef?: an
} }
{values?.body?.content_type === 'binary' && {values?.body?.content_type === 'binary' &&
<Form.Item name={['body', 'data']} <Form.Item name={['body', 'data']}
className="rb:bg-[#F6F6F6] rb:border-[#F6F6F6]! rb:hover:bg-white rb:hover:border-[#171719]! rb:border rb:rounded-lg rb:px-2! rb:py-1.5! rb:mb-0!" className="rb:bg-[#F6F6F6] rb:border-[#F6F6F6]! rb:hover:bg-white rb:hover:border-[#171719]! rb:border rb:rounded-lg rb:mb-0!"
> >
<Editor <Editor
key={['body', 'data'].join('_')}
placeholder={t('common.pleaseSelect')} placeholder={t('common.pleaseSelect')}
options={options.filter(vo => vo.dataType.includes('file'))} options={options.filter(vo => vo.dataType.includes('file'))}
type="input" type="input"
size="small" size="small"
height={28}
/> />
</Form.Item> </Form.Item>
} }

View File

@@ -490,32 +490,36 @@ export const useWorkflowGraph = ({
* @param node - Clicked node * @param node - Clicked node
*/ */
const nodeClick = ({ node }: { node: Node }) => { const nodeClick = ({ node }: { node: Node }) => {
// Ignore add-node type node clicks blankClick()
const nodeData = node.getData()
if (nodeData?.type === 'add-node' || nodeData.type === 'break' || nodeData.type === 'cycle-start') {
setSelectedNode(null)
return;
}
const nodes = graphRef.current?.getNodes(); setTimeout(() => {
// Ignore add-node type node clicks
nodes?.forEach(vo => { const nodeData = node.getData()
const data = vo.getData(); if (nodeData?.type === 'add-node' || nodeData.type === 'break' || nodeData.type === 'cycle-start') {
if (data.isSelected) { setSelectedNode(null)
vo.setData({ return;
...data,
isSelected: false,
});
} }
});
node.setData({ const nodes = graphRef.current?.getNodes();
...nodeData,
isSelected: true, nodes?.forEach(vo => {
}); const data = vo.getData();
clearEdgeSelect() if (data.isSelected) {
if (nodeData.type !== 'notes') { vo.setData({
setSelectedNode(node); ...data,
} isSelected: false,
});
}
});
node.setData({
...nodeData,
isSelected: true,
});
clearEdgeSelect()
if (nodeData.type !== 'notes') {
setSelectedNode(node);
}
}, 0)
}; };
/** /**
* Handle edge click event * Handle edge click event