commit 24431ca308105e9d14999514814b6111aebee4a8 Author: shen893514322@126.com Date: Tue Mar 10 17:13:19 2026 +0800 初始化仅前端版本 diff --git a/.env b/.env new file mode 100644 index 0000000..e69de29 diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..35410ca --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# 默认忽略的文件 +/shelf/ +/workspace.xml +# 基于编辑器的 HTTP 客户端请求 +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/.name b/.idea/.name new file mode 100644 index 0000000..1fbe8b2 --- /dev/null +++ b/.idea/.name @@ -0,0 +1 @@ +llm-workflow-engine \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..4b7439e --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,12 @@ + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/llm-workflow-engine.iml b/.idea/llm-workflow-engine.iml new file mode 100644 index 0000000..155a4cb --- /dev/null +++ b/.idea/llm-workflow-engine.iml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..1de89aa --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..301ff13 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..1142fda --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,18 @@ +# 使用 Python 3.11 基础镜像 +FROM python:3.11-slim + +# 设置工作目录 +WORKDIR /app + +# 复制依赖文件并安装 +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# 复制所有代码 +COPY app/ ./app/ + +# 暴露端口 +EXPOSE 8000 + +# 启动命令 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/backend/app/nodes/Start.py b/backend/app/nodes/Start.py new file mode 100644 index 0000000..056c040 --- /dev/null +++ b/backend/app/nodes/Start.py @@ -0,0 +1,29 @@ +import base64 +from core.node_base import BaseNode +from typing import Any, Dict + + +class StartNode(BaseNode): + name = "开始节点" + inputs = {} # 没有输入参数 + outputs = { + "text": "string", # 文本内容 + "image": "string" # 图片以 Base64 编码的字符串传递 + } + + async def run(self, text: str = None, image: bytes = None, **kwargs) -> Dict[str, Any]: + # 查空:文本不能为空字符串 + if not text or text.strip() == "": + raise ValueError("文本输入不能为空") + + # 查空:图片数据不能为空 + if image is None or len(image) == 0: + raise ValueError("图片输入不能为空") + + # 将图片字节转换为 Base64 字符串,便于在节点间传递 + image_base64 = base64.b64encode(image).decode('utf-8') + + return { + "text": text, + "image": image_base64 + } \ No newline at end of file diff --git a/backend/app/nodes/__init__.py b/backend/app/nodes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/nodes/text_splitter_node.py b/backend/app/nodes/text_splitter_node.py new file mode 100644 index 0000000..85ecd51 --- /dev/null +++ b/backend/app/nodes/text_splitter_node.py @@ -0,0 +1,56 @@ +import re +from core.node_base import BaseNode +from typing import List, Dict, Any + + +class TextSplitterNode(BaseNode): + name = "文本分割节点" + inputs = {"text": "string"} + outputs = { + "outline": "list", # 大纲部分列表 + "requirement": "list", # 要求部分列表 + "dialogue": "list", # 对话部分列表 + "weak_guidance": "list" # 弱指引部分列表 + } + + async def run(self, text: str) -> Dict[str, List[str]]: + # 正则匹配三种括号内的内容 + # 注意:此正则假设括号不嵌套,且没有转义字符 + pattern = r'\{([^{}]*)\}|\(([^()]*)\)|“([^”]*)”' + + outline = [] + requirement = [] + dialogue = [] + weak_guidance = [] + + pos = 0 + for match in re.finditer(pattern, text): + start, end = match.span() + # 处理匹配前的普通文本(弱指引) + if start > pos: + weak_part = text[pos:start].strip() + if weak_part: + weak_guidance.append(weak_part) + + # 根据捕获组确定类型 + if match.group(1) is not None: # 大括号 + outline.append(match.group(1).strip()) + elif match.group(2) is not None: # 小括号 + requirement.append(match.group(2).strip()) + elif match.group(3) is not None: # 中文引号 + dialogue.append(match.group(3).strip()) + + pos = end + + # 处理剩余的普通文本 + if pos < len(text): + weak_part = text[pos:].strip() + if weak_part: + weak_guidance.append(weak_part) + + return { + "outline": outline, + "requirement": requirement, + "dialogue": dialogue, + "weak_guidance": weak_guidance + } \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..f8050aa --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,3 @@ +fastapi==0.104.1 +uvicorn[standard]==0.24.0 +python-multipart==0.0.6 \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b9d1e84 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,26 @@ +version: '3.8' + +services: + # 后端服务 + backend: + build: ./backend # 使用 backend 目录下的 Dockerfile 构建 + ports: + - "8000:8000" # 映射端口:主机8000 -> 容器8000 + volumes: + - ./data:/data # 挂载数据目录(持久化) + - ./outputs:/outputs # 挂载输出目录 + environment: + - DATA_PATH=/data + - OUTPUT_PATH=/outputs + restart: always + + # 前端服务 + frontend: + build: ./frontend # 使用 frontend 目录下的 Dockerfile 构建 + ports: + - "8501:8501" # 映射端口:主机8501 -> 容器8501 + environment: + - BACKEND_URL=http://backend:8000 # 后端内部地址 + depends_on: + - backend # 等后端启动后再启动前端 + restart: always \ No newline at end of file diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..ecfc272 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,18 @@ +# 使用 Python 3.11 基础镜像 +FROM python:3.11-slim + +# 设置工作目录 +WORKDIR /app + +# 复制依赖文件并安装 +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# 复制所有代码 +COPY . . + +# 暴露端口 +EXPOSE 8501 + +# 启动命令 +CMD ["streamlit", "run", "app.py", "--server.port=8501", "--server.address=0.0.0.0"] \ No newline at end of file diff --git a/frontend/app.py b/frontend/app.py new file mode 100644 index 0000000..d809223 --- /dev/null +++ b/frontend/app.py @@ -0,0 +1,349 @@ +import streamlit as st +import os +from pathlib import Path +import time +import random + +# --- 页面配置 --- +st.set_page_config( + page_title="AI WorkFlow Engine", + page_icon="🤖", + layout="wide", + initial_sidebar_state="expanded" +) + +# --- 自定义 CSS (蓝白清晰风格) --- +st.markdown(""" + +""", unsafe_allow_html=True) + +# --- 状态初始化 --- +if "messages" not in st.session_state: + # 初始化一些示例数据,方便查看效果 + st.session_state.messages = [ + {"role": "assistant", "content": "你好!我是你的 AI 工作流助手。系统已就绪,请开始对话。"}, + {"role": "user", "content": "帮我生成一个角色卡,需要包含姓名、年龄和背景故事。"}, + {"role": "assistant", + "content": "好的,这是一个示例角色卡:
姓名: 艾莉娅
年龄: 24
背景: 一位来自北方边境的流浪法师。
(这是 HTML 渲染测试)"} + ] +if "render_html" not in st.session_state: + st.session_state.render_html = True # 默认开启 HTML 渲染以展示效果 +if "image_folder" not in st.session_state: + st.session_state.image_folder = "./assets/images" +if "splice_blocks" not in st.session_state: + st.session_state.splice_blocks = [ + {"id": 1, "name": "[必看] 系统指令", "active": True, "type": "system"}, + {"id": 2, "name": "A.U.T.O. 预设设置", "active": True, "type": "system"}, + {"id": 3, "name": "世界书:人物关系", "active": False, "type": "world"}, + {"id": 4, "name": "Chat History (自动)", "active": True, "type": "history", "editable": False}, + ] + +# --- 顶部工具栏 --- +c_top1, c_top2, c_top3 = st.columns([1, 6, 1]) +with c_top1: + if st.button("📂 打开", key="btn_open"): + st.toast("打开会话功能预留") + if st.button("💾 保存", key="btn_save"): + st.toast("会话已保存") +with c_top2: + st.markdown("

AI WorkFlow Engine

", unsafe_allow_html=True) +with c_top3: + if st.button("⚙️ 设置", key="btn_settings"): + st.toast("全局设置预留") + +st.divider() + +# --- 三栏布局 --- +col_left, col_mid, col_right = st.columns([1, 3, 1], gap="small") + +# ======================= +# 1. 左侧:预设与拼接管理 (蓝白风格适配) +# ======================= +with col_left: + st.markdown("#### 📜 全局预设") + c_pre1, c_pre2 = st.columns([4, 1]) + with c_pre1: + preset_options = ["Default", "A.U.T.O. v1.47", "Roleplay Pro"] + st.selectbox("选择预设", preset_options, label_visibility="collapsed") + with c_pre2: + if st.button("📥", key="btn_import", help="导入预设"): + st.toast("导入功能预留") + + st.markdown("#### ⚙️ 生成参数") + c_p1, c_p2 = st.columns(2) + with c_p1: + st.slider("温度", 0.0, 2.0, 1.0, key="slider_temp") + st.slider("Top P", 0.0, 1.0, 0.9, key="slider_top_p") + with c_p2: + st.slider("频率惩罚", 0.0, 2.0, 1.0, key="slider_freq") + st.slider("存在惩罚", 0.0, 2.0, 0.0, key="slider_pres") + + c_l1, c_l2 = st.columns(2) + with c_l1: + st.number_input("上下文长度", value=30000, key="input_ctx") + with c_l2: + st.number_input("最大回复", value=500, key="input_max") + + st.checkbox("✅ 流式传输", value=True, key="chk_stream") + + st.markdown("#### 🧩 内容拼接块") + st.caption("控制发送至后端的上下文组成") + + # 渲染拼接块列表 + for block in st.session_state.splice_blocks: + with st.container(): + # 自定义行布局模拟列表项 + cols = st.columns([0.5, 3, 0.5, 0.5]) + with cols[0]: + icon = "🌍" if block['type'] == 'world' else ("💬" if block['type'] == 'history' else "📄") + st.write(icon) + with cols[1]: + name_class = "splice-name-active" if block['active'] else "splice-name-inactive" + st.markdown(f"
{block['name']}
", + unsafe_allow_html=True) + with cols[2]: + disabled = not block.get('editable', True) + if st.button("✏️", key=f"edit_{block['id']}", disabled=disabled): + st.toast(f"编辑:{block['name']}") + with cols[3]: + is_active = st.checkbox("✓", value=block['active'], key=f"act_{block['id']}", + label_visibility="collapsed") + if is_active != block['active']: + block['active'] = is_active + st.rerun() + st.markdown("
", unsafe_allow_html=True) + + if st.button("+ 添加拼接块", use_container_width=True): + st.toast("添加新功能预留") + +# ======================= +# 2. 中间:流式对话区 (动态读取历史) +# ======================= +with col_mid: + # 顶部控制条 + c_ctrl1, c_ctrl2 = st.columns([4, 1]) + with c_ctrl1: + st.caption("当前会话:Active_Session_01") + with c_ctrl2: + # HTML 渲染切换 + toggle_html = st.toggle("HTML 渲染", value=st.session_state.render_html, key="html_toggle") + if toggle_html != st.session_state.render_html: + st.session_state.render_html = toggle_html + st.rerun() + + # --- 核心:动态渲染历史记录 --- + # 使用 st.container 包裹,确保每次 rerun 都能完整重绘 + chat_container = st.container() + + with chat_container: + # 遍历 session_state 中的消息历史 + for i, msg in enumerate(st.session_state.messages): + with st.chat_message(msg["role"]): + content = msg["content"] + + # 根据开关决定是否解析 HTML + if st.session_state.render_html and msg["role"] == "assistant": + # 允许 HTML 标签 + st.markdown(content, unsafe_allow_html=True) + else: + # 纯文本/Markdown 模式 + st.markdown(content) + + # 可选:在每条消息下添加操作按钮 (编辑/复制/重试) - 预留 + # with st.expander("...", expanded=False): ... + + # --- 输入区域 --- + st.markdown("---") + + # 聊天输入框 + user_input = st.chat_input("输入消息... (支持 /命令)") + + if user_input: + # 1. 将用户输入加入历史 + st.session_state.messages.append({"role": "user", "content": user_input}) + + # 2. 触发重新渲染,此时上方循环会立即显示用户消息 + # 注意:Streamlit 是同步执行的,要模拟“流式”通常需要后端配合 yield + # 这里为了演示前端动态读取,我们先显示用户消息,然后模拟一个后台任务 + + # 占位符用于显示正在生成的状态或流式内容 + with st.chat_message("assistant"): + message_placeholder = st.empty() + message_placeholder.markdown("*思考中...*") + + # === 模拟后端流式响应 (实际项目中此处替换为 requests.post(stream=True)) === + full_response = "" + # 构造一个包含 HTML 的回复用于测试 + simulated_text = f"收到您的指令:**{user_input}**。\n\n这是一个测试回复,如果您开启了 **HTML 渲染**,下方将显示彩色文本和表格:
蓝色高亮文本
属性
状态正常
" + + # 简单的逐字模拟 (实际应来自后端 chunk) + chunks = simulated_text.split(" ") + for chunk in chunks: + full_response += chunk + " " + time.sleep(0.1) # 模拟网络延迟 + + if st.session_state.render_html: + message_placeholder.markdown(full_response, unsafe_allow_html=True) + else: + message_placeholder.markdown(full_response) + + # 3. 将完整的助手回复存入历史 + st.session_state.messages.append({"role": "assistant", "content": full_response}) + + # 强制刷新以确保持久化显示 + st.rerun() + +# ======================= +# 3. 右侧:图片与骰子 (蓝白风格) +# ======================= +with col_right: + st.markdown("#### 🖼️ 本地图库") + img_path = Path(st.session_state.image_folder) + img_path.mkdir(parents=True, exist_ok=True) + + try: + images = [f for f in os.listdir(img_path) if f.endswith(('.png', '.jpg', '.jpeg', '.webp'))] + if images: + cols = st.columns(2) + for idx, img_name in enumerate(images[:8]): + with cols[idx % 2]: + # 增加白色背景和边框,使图片在浅灰底上更突出 + st.markdown( + f"
", + unsafe_allow_html=True) + st.image(str(img_path / img_name), use_container_width=True) + st.caption(img_name) + st.markdown("
", unsafe_allow_html=True) + else: + st.info("图片文件夹为空") + except Exception as e: + st.error(f"读取错误:{e}") + + st.divider() + + st.markdown("#### 🎲 检定工具") + tab_table, tab_dice = st.tabs(["📊 表格", "🎲 骰子"]) + + with tab_table: + st.markdown("**动态数据表**") + st.dataframe( + {"属性": ["力量", "敏捷", "智力"], "数值": [50, 60, 70]}, + hide_index=True, + use_container_width=True + ) + + with tab_dice: + roll_type = st.radio("类型", ["难度检定", "对抗骰"], horizontal=True) + diff_opts = ["极难 (95)", "困难 (75)", "普通 (50)"] + selected_diff = st.selectbox("难度", diff_opts) + + c_r1, c_r2 = st.columns(2) + with c_r1: + if st.button("🎲 投掷", type="primary", use_container_width=True): + res = random.randint(1, 100) + color = "#d9534f" if res > int(selected_diff.split('(')[1].strip(')')) else "#5cb85c" + st.markdown( + f"
{res}
", + unsafe_allow_html=True) + with c_r2: + st.caption(f"目标:{selected_diff.split('(')[1].strip(')')}") \ No newline at end of file diff --git a/frontend/assets/images/ceaa019ee659f86fc8e2a22217df30f7.jpg b/frontend/assets/images/ceaa019ee659f86fc8e2a22217df30f7.jpg new file mode 100644 index 0000000..41b964d Binary files /dev/null and b/frontend/assets/images/ceaa019ee659f86fc8e2a22217df30f7.jpg differ diff --git a/frontend/components/chat_window.py b/frontend/components/chat_window.py new file mode 100644 index 0000000..d0609f9 --- /dev/null +++ b/frontend/components/chat_window.py @@ -0,0 +1,53 @@ +import streamlit as st +import requests + + +def render_chat_window(backend_url): + st.subheader("💬 流式对话") + + # 聊天历史显示 + chat_container = st.container() + with chat_container: + for message in st.session_state.messages: + with st.chat_message(message["role"]): + st.markdown(message["content"]) + # 如果有关联图片,也可以在这里显示 + if "images" in message: + for img_url in message["images"]: + st.image(img_url, width=200) + + # 输入框 + if prompt := st.chat_input("输入消息..."): + # 1. 显示用户消息 + st.session_state.messages.append({"role": "user", "content": prompt}) + with st.chat_message("user"): + st.markdown(prompt) + + # 2. 调用后端流式接口 + with st.chat_message("assistant"): + message_placeholder = st.empty() + full_response = "" + + # 模拟流式接收 (实际需使用 requests stream 或 websocket) + # POST /api/chat/stream + try: + # 伪代码示例: + # with requests.post(f"{backend_url}/api/chat/stream", json={"message": prompt}, stream=True) as r: + # for chunk in r.iter_content(chunk_size=None): + # if chunk: + # full_response += chunk.decode('utf-8') + # message_placeholder.markdown(full_response + "▌") + + # 演示用静态延迟 + import time + response_text = "这是一个流式响应的演示。后端正在处理您的请求..." + for char in response_text: + full_response += char + message_placeholder.markdown(full_response + "▌") + time.sleep(0.05) + + message_placeholder.markdown(full_response) + st.session_state.messages.append({"role": "assistant", "content": full_response}) + + except Exception as e: + st.error(f"连接后端失败: {e}") \ No newline at end of file diff --git a/frontend/components/dice_roller.py b/frontend/components/dice_roller.py new file mode 100644 index 0000000..ea32a11 --- /dev/null +++ b/frontend/components/dice_roller.py @@ -0,0 +1,26 @@ +import streamlit as st +import random + + +def render_dice_roller(): + st.subheader("🎲 命运骰子") + + col1, col2 = st.columns(2) + + with col1: + d20 = st.button("D20", use_container_width=True) + if d20: + roll = random.randint(1, 20) + st.metric("结果", roll, delta=None) + + with col2: + d6 = st.button("D6", use_container_width=True) + if d6: + roll = random.randint(1, 6) + st.metric("结果", roll, delta=None) + + # 自定义骰子 + sides = st.number_input("面数", min_value=2, max_value=100, value=10) + if st.button(f"投掷 D{sides}", use_container_width=True): + roll = random.randint(1, sides) + st.success(f"🎲 结果是: **{roll}**") \ No newline at end of file diff --git a/frontend/components/image_gallery.py b/frontend/components/image_gallery.py new file mode 100644 index 0000000..273099b --- /dev/null +++ b/frontend/components/image_gallery.py @@ -0,0 +1,17 @@ +import streamlit as st + + +def render_image_gallery(backend_url): + st.subheader("🖼️ 生成画廊") + + # 这里通常轮询后端获取最新生成的图片 + # GET /api/images/latest + + if not st.session_state.generated_images: + st.info("暂无生成图片,对话中触发绘图后将在此显示。") + else: + cols = st.columns(2) + for idx, img_url in enumerate(st.session_state.generated_images[-4:]): # 只显示最近4张 + with cols[idx % 2]: + st.image(img_url, use_container_width=True) + st.caption(f"Image {idx + 1}") \ No newline at end of file diff --git a/frontend/components/settings_panel.py b/frontend/components/settings_panel.py new file mode 100644 index 0000000..d5e43fe --- /dev/null +++ b/frontend/components/settings_panel.py @@ -0,0 +1,30 @@ +import streamlit as st +import requests + + +def render_settings_panel(backend_url): + st.subheader("⚙️ 预设设置") + + # 模拟获取预设列表 (实际应调用后端 API) + # GET /api/presets + try: + # response = requests.get(f"{backend_url}/api/presets") + # presets = response.json() + presets = ["角色扮演-奇幻", "项目管理", "旅行规划", "自定义"] # 占位数据 + except: + presets = ["默认预设"] + + selected_preset = st.selectbox("选择预设模板", presets, index=0) + + st.text_area("系统指令 (System)", height=100, placeholder="在此输入系统级指令...") + + st.checkbox("启用状态记忆", value=True) + st.checkbox("启用异步生图", value=True) + st.checkbox("启用输入预处理", value=False) + + st.info("💡 修改配置后自动生效,无需重启。") + + # 保存按钮 (调用后端更新配置) + if st.button("💾 保存配置", use_container_width=True): + st.success("配置已保存!") + # requests.post(f"{backend_url}/api/config", json={...}) \ No newline at end of file diff --git a/frontend/components/status_panel.py b/frontend/components/status_panel.py new file mode 100644 index 0000000..e69de29 diff --git a/frontend/components/toolbar.py b/frontend/components/toolbar.py new file mode 100644 index 0000000..191e25f --- /dev/null +++ b/frontend/components/toolbar.py @@ -0,0 +1,18 @@ +import streamlit as st + + +def render_toolbar(backend_url): + col1, col2, col3 = st.columns([1, 2, 1]) + + with col1: + st.logo("https://streamlit.io/images/brand/streamlit-logo-primary-colormark-darktext.png", + size="large") # 可替换为项目Logo + + with col2: + st.title("AI Tavern 工作流引擎") + + with col3: + if st.button("🔄 重置会话", use_container_width=True): + st.session_state.messages = [] + st.rerun() + # 这里可以添加更多工具栏按钮,如:知识库管理、系统状态等 \ No newline at end of file diff --git a/frontend/requirements.txt b/frontend/requirements.txt new file mode 100644 index 0000000..32fb1ed --- /dev/null +++ b/frontend/requirements.txt @@ -0,0 +1,4 @@ +streamlit>=1.30.0 +requests>=2.31.0 +websockets>=12.0 +Pillow>=10.0.0 \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..6d7c6d9 --- /dev/null +++ b/main.py @@ -0,0 +1,13 @@ +from fastapi import FastAPI + +app = FastAPI() + + +@app.get("/") +async def root(): + return {"message": "Hello World"} + + +@app.get("/hello/{name}") +async def say_hello(name: str): + return {"message": f"Hello {name}"} diff --git a/test_main.http b/test_main.http new file mode 100644 index 0000000..a2d81a9 --- /dev/null +++ b/test_main.http @@ -0,0 +1,11 @@ +# Test your FastAPI endpoints + +GET http://127.0.0.1:8000/ +Accept: application/json + +### + +GET http://127.0.0.1:8000/hello/User +Accept: application/json + +###