Compare commits

..

1 Commits

Author SHA1 Message Date
lanceyq
82c6d1a90f [feat] Context manager: Used to measure the execution time of code blocks 2026-03-31 14:56:26 +08:00
17 changed files with 449 additions and 750 deletions

View File

@@ -1,3 +1,5 @@
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
@@ -16,6 +18,18 @@ 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"],
@@ -70,129 +84,149 @@ 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秒缓存- 暂时注释以便进行性能测试
cache_key = f"end_users:workspace:{workspace_id}" # with timer("Redis缓存读取"):
try: # cache_key = f"end_users:workspace:{workspace_id}"
cached_data = await aio_redis_get(cache_key) # try:
if cached_data: # cached_data = await aio_redis_get(cache_key)
api_logger.info(f"从缓存获取宿主列表: workspace_id={workspace_id}") # if cached_data:
return success(data=json.loads(cached_data), msg="宿主列表获取成功") # api_logger.info(f"从缓存获取宿主列表: workspace_id={workspace_id}")
except Exception as e: # return success(data=json.loads(cached_data), msg="宿主列表获取成功")
api_logger.warning(f"Redis 缓存读取失败: {str(e)}") # except Exception as e:
# api_logger.warning(f"Redis 缓存读取失败: {str(e)}")
# 获取当前空间类型 # 获取当前空间类型
current_workspace_type = memory_dashboard_service.get_current_workspace_type(db, workspace_id, current_user) with timer("获取空间类型"):
api_logger.info(f"用户 {current_user.username} 请求获取工作空间 {workspace_id} 的宿主列表") current_workspace_type = memory_dashboard_service.get_current_workspace_type(db, workspace_id, current_user)
api_logger.info(f"用户 {current_user.username} 请求获取工作空间 {workspace_id} 的宿主列表, 类型: {current_workspace_type}")
# 获取 end_users已优化为批量查询 # 获取 end_users已优化为批量查询
end_users = memory_dashboard_service.get_workspace_end_users( with timer("获取用户列表"):
db=db, end_users = memory_dashboard_service.get_workspace_end_users(
workspace_id=workspace_id, db=db,
current_user=current_user workspace_id=workspace_id,
) 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():
"""获取记忆配置(在线程池中执行同步查询)""" """获取记忆配置(在线程池中执行同步查询)"""
try: with timer("功能模块-获取记忆配置", user_count):
return await asyncio.to_thread( try:
get_end_users_connected_configs_batch, return await asyncio.to_thread(
end_user_ids, db get_end_users_connected_configs_batch,
) end_user_ids, db
except Exception as e: )
api_logger.error(f"批量获取记忆配置失败: {str(e)}") except Exception as e:
return {} api_logger.error(f"批量获取记忆配置失败: {str(e)}")
return {}
async def get_memory_nums(): async def get_memory_nums():
"""获取记忆数量""" """获取记忆数量"""
if current_workspace_type == "rag": with timer(f"功能模块-获取记忆数量[{current_workspace_type}]", user_count):
# RAG 模式:批量查询 if current_workspace_type == "rag":
try: # RAG 模式:批量查询
chunk_map = await asyncio.to_thread( with timer(" - RAG批量查询chunks"):
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:
return await memory_storage_service.search_all(end_user_id) 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: except Exception as e:
api_logger.error(f"获取用户 {end_user_id} Neo4j 记忆数量失败: {str(e)}") api_logger.error(f"批量获取 RAG chunk 数量失败: {str(e)}")
return {"total": 0} return {uid: {"total": 0} for uid in end_user_ids}
memory_nums_list = await asyncio.gather(*[get_neo4j_memory_num(uid) for uid in end_user_ids]) elif current_workspace_type == "neo4j":
return {end_user_ids[i]: memory_nums_list[i] for i in range(len(end_user_ids))} # Neo4j 模式:并发查询(带并发限制)
# 使用信号量限制并发数,避免大量用户时压垮 Neo4j
MAX_CONCURRENT_QUERIES = 10
semaphore = asyncio.Semaphore(MAX_CONCURRENT_QUERIES)
return {uid: {"total": 0} for uid in end_user_ids} 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 中没有记录的用户异步生成数据
try: with timer("触发Celery初始化任务"):
from app.celery_app import celery_app as _celery_app try:
_celery_app.send_task( from app.celery_app import celery_app as _celery_app
"app.tasks.init_implicit_emotions_for_users", _celery_app.send_task(
kwargs={"end_user_ids": end_user_ids}, "app.tasks.init_implicit_emotions_for_users",
) kwargs={"end_user_ids": end_user_ids},
_celery_app.send_task( )
"app.tasks.init_interest_distribution_for_users", _celery_app.send_task(
kwargs={"end_user_ids": end_user_ids}, "app.tasks.init_interest_distribution_for_users",
) kwargs={"end_user_ids": end_user_ids},
api_logger.info(f"已触发按需初始化任务,候选用户数: {len(end_user_ids)}") )
except Exception as e: api_logger.info(f"已触发按需初始化任务,候选用户数: {len(end_user_ids)}")
api_logger.warning(f"触发按需初始化任务失败(不影响主流程): {e}") except Exception as e:
api_logger.warning(f"触发按需初始化任务失败(不影响主流程): {e}")
# 并发执行配置查询和记忆数量查询 # 并发执行配置查询和记忆数量查询
memory_configs_map, memory_nums_map = await asyncio.gather( with timer("并发执行两个功能模块"):
get_memory_configs(), memory_configs_map, memory_nums_map = await asyncio.gather(
get_memory_nums() get_memory_configs(),
) get_memory_nums()
)
# 构建结果(优化:使用列表推导式) # 构建结果(优化:使用列表推导式)
result = [] with timer("构建返回结果"):
for end_user in end_users: result = []
user_id = str(end_user.id) for end_user in end_users:
config_info = memory_configs_map.get(user_id, {}) user_id = str(end_user.id)
result.append({ config_info = memory_configs_map.get(user_id, {})
'end_user': { result.append({
'id': user_id, 'end_user': {
'other_name': end_user.other_name 'id': user_id,
}, 'other_name': end_user.other_name
'memory_num': memory_nums_map.get(user_id, {"total": 0}), },
'memory_config': { 'memory_num': memory_nums_map.get(user_id, {"total": 0}),
"memory_config_id": config_info.get("memory_config_id"), 'memory_config': {
"memory_config_name": config_info.get("memory_config_name") "memory_config_id": config_info.get("memory_config_id"),
} "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:
@@ -202,6 +236,8 @@ 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 (pageInfo: PageRequest, types?: string[]) => { export const getModelList = async (types: string[], pageInfo: PageRequest) => {
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: 'Permanent Memory', endUserProfile: 'Profile',
editEndUserProfile: 'Edit', editEndUserProfile: 'Edit',
other_name: 'Name', other_name: 'Name',
position: 'Position', position: 'Position',
@@ -1827,7 +1827,6 @@ 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 }, ['llm', 'embedding', 'rerank', 'chat']); models = await getModelList({ page: 1, pagesize: 100 });
} 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({ page: 1, pagesize: 100 }, ['llm', 'embedding', 'rerank', 'chat']); const response = await getModelList(['llm', 'embedding', 'rerank', 'chat'], { page: 1, pagesize: 100 });
// 缓存模型列表,建立 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

@@ -1,185 +0,0 @@
/*
* @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,20 +4,26 @@
* @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, useMemo } from 'react'; import { type FC, useState, useEffect, 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 {
@@ -33,7 +39,6 @@ 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
@@ -45,6 +50,16 @@ 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 = "请输入内容...",
@@ -56,32 +71,100 @@ 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)
if (language === 'jinja2') { // Setup Jinja2 mode and inject styles when language changes
return ( useEffect(() => {
<Jinja2Editor const needsLineNumbers = language === 'jinja2';
placeholder={placeholder} setEnableJinja2(language === 'jinja2');
value={value} setEnableLineNumbers(needsLineNumbers);
onChange={onChange}
options={options} if (needsLineNumbers) {
variant={variant} const styleId = 'code-editor-styles';
size={size} let existingStyle = document.getElementById(styleId);
height={height}
className={className} if (!existingStyle) {
/> 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, theme: enableJinja2 ? jinja2Theme : theme,
nodes: [VariableNode], nodes: enableJinja2 ? [
// 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);
}, },
@@ -115,26 +198,54 @@ const Editor: FC<LexicalEditorProps> =({
<div style={{ position: 'relative' }} className={className}> <div style={{ position: 'relative' }} className={className}>
<RichTextPlugin <RichTextPlugin
contentEditable={ contentEditable={
<ContentEditable enableLineNumbers ? (
style={{ // Editor with line numbers for Jinja2 mode
<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', }}>
border: variant === 'borderless' ? 'none' : '1px solid #EBEBEB', <div className="line-numbers">
borderRadius: '8px', <div>1</div>
outline: 'none', </div>
resize: 'none', <div className="editor-content-wrapper">
fontSize: fontSize, <ContentEditable
lineHeight: lineHeight, className="editor-content-with-numbers"
}} 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: variant === 'borderless' ? '0' : '6px', top: enableLineNumbers ? '4px' : variant === 'borderless' ? '0' : '6px',
left: variant === 'borderless' ? '0' : '11px', left: enableLineNumbers ? '16px' : (variant === 'borderless' ? '0' : '11px'),
color: '#A8A9AA', color: '#A8A9AA',
fontSize: fontSize, fontSize: fontSize,
lineHeight: placeHolderMinheight, lineHeight: placeHolderMinheight,
@@ -146,12 +257,15 @@ const Editor: FC<LexicalEditorProps> =({
} }
ErrorBoundary={LexicalErrorBoundary} ErrorBoundary={LexicalErrorBoundary}
/> />
{/* Editor plugins */}
<HistoryPlugin /> <HistoryPlugin />
<CommandPlugin /> <CommandPlugin />
<AutocompletePlugin options={options} /> {language === 'jinja2' && <Jinja2HighlightPlugin />}
<CharacterCountPlugin setCount={(count) => { setCount(count) }} onChange={onChange} waitForInit={waitForInit || !!value} /> {enableLineNumbers && <LineNumberPlugin />}
<InitialValuePlugin value={value} options={options} /> <AutocompletePlugin options={options} enableJinja2={enableJinja2} />
<BlurPlugin /> <CharacterCountPlugin setCount={(count) => { setCount(count) }} onChange={onChange} />
<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-04-02 17:12:41 * @Last Modified time: 2026-03-25 16:13:37
*/ */
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, COMMAND_PRIORITY_HIGH, KEY_ENTER_COMMAND, KEY_ARROW_DOWN_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ESCAPE_COMMAND } from 'lexical'; 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 { 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[] }> = ({ options }) => { const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }> = ({ options, enableJinja2 = false }) => {
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,7 +129,34 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => {
// Insert selected suggestion into editor // Insert selected suggestion into editor
const insertMention = (suggestion: Suggestion) => { const insertMention = (suggestion: Suggestion) => {
editor.dispatchCommand(INSERT_VARIABLE_COMMAND, { data: suggestion }); if (enableJinja2) {
// 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,33 +1,64 @@
/* /*
* @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-04-02 17:13:08 * @Last Modified time: 2026-03-03 10:12:10
*/ */
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() { export default function BlurPlugin({ enableJinja2 }: { enableJinja2: boolean }) {
const [editor] = useLexicalComposerContext(); const [editor] = useLexicalComposerContext();
useEffect(() => { useEffect(() => {
// Close autocomplete when clicking outside the popup
const handleClickOutside = (e: MouseEvent) => { const handleClickOutside = (e: MouseEvent) => {
if ((e.target as HTMLElement)?.closest('[data-autocomplete-popup="true"]')) return; const target = e.target as HTMLElement;
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 () => { document.removeEventListener('mousedown', handleClickOutside); }; return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}); });
}, [editor]); }, [editor, enableJinja2]);
return null; return null;
} }

View File

@@ -1,73 +1,43 @@
/* 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 serialize = (root: ReturnType<typeof $getRoot>): string => { const CharacterCountPlugin = ({ setCount, onChange }: { setCount: (count: number) => void; onChange?: (value: string) => void }) => {
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, tags }) => { return editor.registerUpdateListener(({ editorState }) => {
if (tags.has('programmatic')) {
isReadyRef.current = true;
isFirstUpdateRef.current = false;
editorState.read(() => {
lastProgrammaticValueRef.current = serialize($getRoot());
});
return;
}
if (!isReadyRef.current) return;
editorState.read(() => { editorState.read(() => {
const content = serialize($getRoot()); const root = $getRoot();
// Skip the first update if content is empty (editor initial render) let serializedContent = '';
if (isFirstUpdateRef.current) {
isFirstUpdateRef.current = false; // Traverse all nodes and serialize properly
if (content === '') return; const paragraphs: string[] = [];
} root.getChildren().forEach(child => {
// Skip if content is identical to what was programmatically written if ($isParagraphNode(child)) {
if (content === lastProgrammaticValueRef.current) return; let paragraphContent = '';
lastProgrammaticValueRef.current = null; child.getChildren().forEach(node => {
setCount(content.length); if ($isVariableNode(node)) {
onChange?.(content); paragraphContent += node.getTextContent();
} else {
paragraphContent += node.getTextContent();
}
});
paragraphs.push(paragraphContent);
}
});
serializedContent = paragraphs.join('\n');
setCount(serializedContent.length);
onChange?.(serializedContent);
}); });
}); });
}, [editor, setCount, onChange]); }, [editor, setCount, onChange]);
return null; return null;
}; }
export default CharacterCountPlugin; export default CharacterCountPlugin

View File

@@ -1,9 +1,3 @@
/*
* @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';
@@ -14,17 +8,19 @@ 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 = [] }) => { const InitialValuePlugin: React.FC<InitialValuePluginProps> = ({ value, options = [], enableLineNumbers = false }) => {
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(() => {
return editor.registerUpdateListener(({ editorState, tags }) => { const removeListener = editor.registerUpdateListener(({ editorState, tags }) => {
if (tags.has('programmatic')) return; if (tags.has('programmatic')) return;
editorState.read(() => { editorState.read(() => {
const root = $getRoot(); const root = $getRoot();
@@ -35,16 +31,21 @@ const InitialValuePlugin: React.FC<InitialValuePluginProps> = ({ value, options
} }
}); });
}); });
return removeListener;
}, [editor]); }, [editor]);
useEffect(() => { useEffect(() => {
if (value !== prevValueRef.current) { if (value !== prevValueRef.current || enableLineNumbers !== prevEnableLineNumbersRef.current) {
if (isUserInputRef.current) { // Skip reset if the change was triggered by user input (avoid cursor jump)
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(() => {
@@ -53,7 +54,16 @@ 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') {
@@ -108,10 +118,15 @@ 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]); }, [value, editor, enableLineNumbers]);
return null; return null;
}; };

View File

@@ -1,199 +0,0 @@
/*
* @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

@@ -1,41 +0,0 @@
/*
* @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

@@ -1,61 +0,0 @@
/*
* @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-04-02 17:17:06 * @Last Modified time: 2026-03-20 11:32:44
*/ */
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,7 +114,6 @@ 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"
@@ -213,15 +212,13 @@ 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:mb-0!" 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!"
> >
<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,36 +490,32 @@ export const useWorkflowGraph = ({
* @param node - Clicked node * @param node - Clicked node
*/ */
const nodeClick = ({ node }: { node: Node }) => { const nodeClick = ({ node }: { node: Node }) => {
blankClick() // Ignore add-node type node clicks
const nodeData = node.getData()
if (nodeData?.type === 'add-node' || nodeData.type === 'break' || nodeData.type === 'cycle-start') {
setSelectedNode(null)
return;
}
setTimeout(() => { const nodes = graphRef.current?.getNodes();
// Ignore add-node type node clicks
const nodeData = node.getData() nodes?.forEach(vo => {
if (nodeData?.type === 'add-node' || nodeData.type === 'break' || nodeData.type === 'cycle-start') { const data = vo.getData();
setSelectedNode(null) if (data.isSelected) {
return; vo.setData({
...data,
isSelected: false,
});
} }
});
const nodes = graphRef.current?.getNodes(); node.setData({
...nodeData,
nodes?.forEach(vo => { isSelected: true,
const data = vo.getData(); });
if (data.isSelected) { clearEdgeSelect()
vo.setData({ if (nodeData.type !== 'notes') {
...data, setSelectedNode(node);
isSelected: false, }
});
}
});
node.setData({
...nodeData,
isSelected: true,
});
clearEdgeSelect()
if (nodeData.type !== 'notes') {
setSelectedNode(node);
}
}, 0)
}; };
/** /**
* Handle edge click event * Handle edge click event