zaturn 0.1.7__py3-none-any.whl → 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- zaturn/mcp/__init__.py +97 -0
- zaturn/studio/__init__.py +5 -0
- zaturn/studio/agent_wrapper.py +131 -0
- zaturn/studio/app.py +288 -0
- zaturn/studio/static/fira_code.ttf +0 -0
- zaturn/studio/static/inter_ital_var.ttf +0 -0
- zaturn/studio/static/inter_var.ttf +0 -0
- zaturn/studio/static/js/htmx-multi-swap.js +44 -0
- zaturn/studio/static/js/htmx.min.js +1 -0
- zaturn/studio/static/logo.png +0 -0
- zaturn/studio/static/logo.svg +10 -0
- zaturn/studio/static/noto_emoji.ttf +0 -0
- zaturn/studio/storage.py +85 -0
- zaturn/studio/templates/_shell.html +38 -0
- zaturn/studio/templates/ai_message.html +4 -0
- zaturn/studio/templates/c_settings_updated.html +1 -0
- zaturn/studio/templates/c_source_card.html +19 -0
- zaturn/studio/templates/chat.html +22 -0
- zaturn/studio/templates/css/style.css +406 -0
- zaturn/studio/templates/function_call.html +7 -0
- zaturn/studio/templates/loader.html +1 -0
- zaturn/studio/templates/manage_sources.html +45 -0
- zaturn/studio/templates/nav.html +5 -0
- zaturn/studio/templates/new_conversation.html +13 -0
- zaturn/studio/templates/settings.html +29 -0
- zaturn/studio/templates/setup_prompt.html +6 -0
- zaturn/studio/templates/user_message.html +4 -0
- zaturn/tools/__init__.py +13 -0
- zaturn/{config.py → tools/config.py} +7 -9
- zaturn/tools/core.py +97 -0
- zaturn/{query_utils.py → tools/query_utils.py} +52 -2
- zaturn/tools/visualizations.py +267 -0
- zaturn-0.2.0.dist-info/METADATA +128 -0
- zaturn-0.2.0.dist-info/RECORD +39 -0
- {zaturn-0.1.7.dist-info → zaturn-0.2.0.dist-info}/WHEEL +1 -1
- zaturn-0.2.0.dist-info/entry_points.txt +3 -0
- zaturn/__init__.py +0 -14
- zaturn/core.py +0 -140
- zaturn/visualizations.py +0 -155
- zaturn-0.1.7.dist-info/METADATA +0 -185
- zaturn-0.1.7.dist-info/RECORD +0 -12
- zaturn-0.1.7.dist-info/entry_points.txt +0 -2
- /zaturn/{example_data → tools/example_data}/all_pokemon_data.csv +0 -0
- {zaturn-0.1.7.dist-info → zaturn-0.2.0.dist-info}/licenses/LICENSE +0 -0
- {zaturn-0.1.7.dist-info → zaturn-0.2.0.dist-info}/top_level.txt +0 -0
zaturn/mcp/__init__.py
ADDED
@@ -0,0 +1,97 @@
|
|
1
|
+
import argparse
|
2
|
+
import os
|
3
|
+
import platformdirs
|
4
|
+
import pkg_resources
|
5
|
+
import sys
|
6
|
+
|
7
|
+
from fastmcp import FastMCP
|
8
|
+
|
9
|
+
from zaturn.tools import ZaturnTools
|
10
|
+
|
11
|
+
# Basic Setup
|
12
|
+
USER_DATA_DIR = platformdirs.user_data_dir('zaturn', 'zaturn')
|
13
|
+
SOURCES_FILE = os.path.join(USER_DATA_DIR, 'sources.txt')
|
14
|
+
|
15
|
+
# Parse command line args
|
16
|
+
parser = argparse.ArgumentParser(
|
17
|
+
description="Zaturn MCP: A read-only BI tool for analyzing various data sources"
|
18
|
+
)
|
19
|
+
parser.add_argument('sources', nargs=argparse.REMAINDER, default=[],
|
20
|
+
help='Data source (can be specified multiple times). Can be SQLite, MySQL, PostgreSQL connection string, or a path to CSV, Parquet, or DuckDB file.'
|
21
|
+
)
|
22
|
+
args = parser.parse_args()
|
23
|
+
|
24
|
+
source_list = []
|
25
|
+
if os.path.exists(SOURCES_FILE):
|
26
|
+
with open(SOURCES_FILE) as f:
|
27
|
+
source_list = [line.strip('\n') for line in f.readlines() if line.strip('\n')]
|
28
|
+
|
29
|
+
if not source_list:
|
30
|
+
source_list = args.sources
|
31
|
+
|
32
|
+
if not source_list:
|
33
|
+
source_list = [
|
34
|
+
pkg_resources.resource_filename(
|
35
|
+
'zaturn',
|
36
|
+
os.path.join('mcp', 'example_data', 'all_pokemon_data.csv')
|
37
|
+
)
|
38
|
+
]
|
39
|
+
print("No data sources provided. Loading example dataset for demonstration.")
|
40
|
+
print(f"\nTo load your datasets, add them to {SOURCES_FILE} (one source URL or full file path per line)")
|
41
|
+
print("\nOr use command line args to specify data sources:")
|
42
|
+
print("zaturn_mcp sqlite:///path/to/mydata.db /path/to/my_file.csv")
|
43
|
+
print(f"\nNOTE: Sources in command line args will be ignored if sources are found in {SOURCES_FILE}")
|
44
|
+
|
45
|
+
|
46
|
+
SOURCES = {}
|
47
|
+
for s in source_list:
|
48
|
+
source = s.lower()
|
49
|
+
if source.startswith('sqlite://'):
|
50
|
+
source_type = 'sqlite'
|
51
|
+
source_name = source.split('/')[-1].split('?')[0].split('.db')[0]
|
52
|
+
elif source.startswith('postgresql://'):
|
53
|
+
source_type = 'postgresql'
|
54
|
+
source_name = source.split('/')[-1].split('?')[0]
|
55
|
+
elif source.startswith("mysql://") or source.startswith("mysql+pymysql://"):
|
56
|
+
source_type = 'mysql'
|
57
|
+
s = s.replace('mysql://', 'mysql+pymysql://')
|
58
|
+
source_name = source.split('/')[-1].split('?')[0]
|
59
|
+
elif source.startswith('clickhouse://'):
|
60
|
+
source_type = 'clickhouse'
|
61
|
+
source_name = source.split('/')[-1].split('?')[0]
|
62
|
+
elif source.endswith(".duckdb"):
|
63
|
+
source_type = "duckdb"
|
64
|
+
source_name = source.split('/')[-1].split('.')[0]
|
65
|
+
elif source.endswith(".csv"):
|
66
|
+
source_type = "csv"
|
67
|
+
source_name = source.split('/')[-1].split('.')[0]
|
68
|
+
elif source.endswith(".parquet") or source.endswith(".pq"):
|
69
|
+
source_type = "parquet"
|
70
|
+
source_name = source.split('/')[-1].split('.')[0]
|
71
|
+
else:
|
72
|
+
continue
|
73
|
+
|
74
|
+
source_id = f'{source_name}-{source_type}'
|
75
|
+
if source_id in SOURCES:
|
76
|
+
i = 2
|
77
|
+
while True:
|
78
|
+
source_id = f'{source_name}{i}-{source_type}'
|
79
|
+
if source_id not in SOURCES:
|
80
|
+
break
|
81
|
+
i += 1
|
82
|
+
|
83
|
+
SOURCES[source_id] = {'url': s, 'source_type': source_type}
|
84
|
+
|
85
|
+
|
86
|
+
def ZaturnMCP(sources):
|
87
|
+
zaturn_tools = ZaturnTools(sources)
|
88
|
+
zaturn_mcp = FastMCP()
|
89
|
+
for tool in zaturn_tools.tools:
|
90
|
+
zaturn_mcp.add_tool(tool)
|
91
|
+
|
92
|
+
return zaturn_mcp
|
93
|
+
|
94
|
+
|
95
|
+
def main():
|
96
|
+
ZaturnMCP(SOURCES).run()
|
97
|
+
|
@@ -0,0 +1,131 @@
|
|
1
|
+
import asyncio
|
2
|
+
import json
|
3
|
+
|
4
|
+
from function_schema import get_function_schema
|
5
|
+
import httpx
|
6
|
+
from mcp.types import ImageContent
|
7
|
+
|
8
|
+
|
9
|
+
class Agent:
|
10
|
+
|
11
|
+
def __init__(self,
|
12
|
+
endpoint: str,
|
13
|
+
api_key: str,
|
14
|
+
model: str,
|
15
|
+
tools: list = [],
|
16
|
+
image_input: bool = False,
|
17
|
+
):
|
18
|
+
|
19
|
+
self._post_url = f'{endpoint}/chat/completions'
|
20
|
+
self._api_key = api_key
|
21
|
+
self._model = model
|
22
|
+
self._image_input = image_input
|
23
|
+
self._system_message = {
|
24
|
+
'role': 'system',
|
25
|
+
'content': """
|
26
|
+
You are a helpful data analysis assistant.
|
27
|
+
Use only the tool provided data sources to process user inputs.
|
28
|
+
Do not use external sources or your own knowledge base.
|
29
|
+
Also, the tool outputs are shown to the user.
|
30
|
+
So, please avoid repeating the tool outputs in the generated text.
|
31
|
+
Use list_sources and describe_table whenever needed,
|
32
|
+
do not prompt the user for source names and column names.
|
33
|
+
""",
|
34
|
+
}
|
35
|
+
|
36
|
+
self._tools = []
|
37
|
+
self._tool_map = {}
|
38
|
+
for tool in tools:
|
39
|
+
tool_schema = get_function_schema(tool)
|
40
|
+
self._tools.append({
|
41
|
+
'type': 'function',
|
42
|
+
'function': tool_schema,
|
43
|
+
})
|
44
|
+
self._tool_map[tool_schema['name']] = tool
|
45
|
+
|
46
|
+
|
47
|
+
def _prepare_input_messages(self, messages):
|
48
|
+
input_messages = [self._system_message]
|
49
|
+
for message in messages:
|
50
|
+
if message['role']!='tool':
|
51
|
+
input_messages.append(message)
|
52
|
+
elif type(message['content']) is not list:
|
53
|
+
input_messages.append(message)
|
54
|
+
else:
|
55
|
+
new_content = []
|
56
|
+
image_content = None
|
57
|
+
for content in message['content']:
|
58
|
+
if content['type']=='image_url':
|
59
|
+
image_content = content
|
60
|
+
new_content.append({
|
61
|
+
'type': 'text',
|
62
|
+
'text': 'Tool call returned an image to the user.',
|
63
|
+
})
|
64
|
+
else:
|
65
|
+
new_content.append(content)
|
66
|
+
input_messages.append({
|
67
|
+
'role': message['role'],
|
68
|
+
'tool_call_id': message['tool_call_id'],
|
69
|
+
'name': message['name'],
|
70
|
+
'content': new_content,
|
71
|
+
})
|
72
|
+
|
73
|
+
return input_messages
|
74
|
+
|
75
|
+
|
76
|
+
def run(self, messages):
|
77
|
+
if type(messages) is str:
|
78
|
+
messages = [{'role': 'user', 'content': messages}]
|
79
|
+
|
80
|
+
while True:
|
81
|
+
res = httpx.post(
|
82
|
+
url = self._post_url,
|
83
|
+
headers = {
|
84
|
+
'Authorization': f'Bearer {self._api_key}'
|
85
|
+
},
|
86
|
+
json = {
|
87
|
+
'model': self._model,
|
88
|
+
'messages': self._prepare_input_messages(messages),
|
89
|
+
'tools': self._tools,
|
90
|
+
'reasoning': {'exclude': True},
|
91
|
+
}
|
92
|
+
)
|
93
|
+
|
94
|
+
print(res.text)
|
95
|
+
resj = res.json()
|
96
|
+
reply = resj['choices'][0]['message']
|
97
|
+
messages.append(reply)
|
98
|
+
|
99
|
+
tool_calls = reply.get('tool_calls')
|
100
|
+
if tool_calls:
|
101
|
+
for tool_call in tool_calls:
|
102
|
+
tool_name = tool_call['function']['name']
|
103
|
+
tool_args = json.loads(tool_call['function']['arguments'])
|
104
|
+
tool_response = self._tool_map[tool_name](**tool_args)
|
105
|
+
if type(tool_response) is ImageContent:
|
106
|
+
b64_data = tool_response.data
|
107
|
+
data_url = f'data:image/png;base64,{b64_data}'
|
108
|
+
content = [{
|
109
|
+
'type': 'image_url',
|
110
|
+
'image_url': {
|
111
|
+
"url": data_url,
|
112
|
+
}
|
113
|
+
}]
|
114
|
+
else:
|
115
|
+
content = [{
|
116
|
+
'type': 'text',
|
117
|
+
'text': json.dumps(tool_response)
|
118
|
+
}]
|
119
|
+
|
120
|
+
messages.append({
|
121
|
+
'role': 'tool',
|
122
|
+
'tool_call_id': tool_call['id'],
|
123
|
+
'name': tool_name,
|
124
|
+
'content': content
|
125
|
+
})
|
126
|
+
else:
|
127
|
+
break
|
128
|
+
|
129
|
+
return messages
|
130
|
+
|
131
|
+
|
zaturn/studio/app.py
ADDED
@@ -0,0 +1,288 @@
|
|
1
|
+
from datetime import datetime
|
2
|
+
import json
|
3
|
+
|
4
|
+
from flask import Flask, make_response, redirect, request, render_template
|
5
|
+
import httpx
|
6
|
+
import mistune
|
7
|
+
import tomli_w
|
8
|
+
from werkzeug.utils import secure_filename
|
9
|
+
|
10
|
+
from zaturn.studio import storage, agent_wrapper
|
11
|
+
from zaturn.tools import ZaturnTools
|
12
|
+
|
13
|
+
|
14
|
+
app = Flask(__name__)
|
15
|
+
app.config['state'] = storage.load_state()
|
16
|
+
|
17
|
+
|
18
|
+
def boost(content: str, fallback=None, retarget=None, reswap=None, push_url=None) -> str:
|
19
|
+
if request.headers.get('hx-boosted'):
|
20
|
+
response = make_response(content)
|
21
|
+
if retarget:
|
22
|
+
response.headers['hx-retarget'] = retarget
|
23
|
+
if reswap:
|
24
|
+
response.headers['hx-reswap'] = reswap
|
25
|
+
if push_url:
|
26
|
+
response.headers['hx-push-url'] = push_url
|
27
|
+
return response
|
28
|
+
else:
|
29
|
+
if fallback:
|
30
|
+
return fallback
|
31
|
+
else:
|
32
|
+
slugs = storage.list_chats()
|
33
|
+
return render_template('_shell.html', content=content, slugs=slugs)
|
34
|
+
|
35
|
+
|
36
|
+
@app.route('/')
|
37
|
+
def home() -> str:
|
38
|
+
state = app.config['state']
|
39
|
+
if state.get('api_key') and state.get('sources'):
|
40
|
+
return boost(render_template('new_conversation.html'))
|
41
|
+
elif state.get('api_key'):
|
42
|
+
return boost(render_template('manage_sources.html'))
|
43
|
+
else:
|
44
|
+
return boost(render_template('setup_prompt.html'))
|
45
|
+
|
46
|
+
|
47
|
+
@app.route('/settings')
|
48
|
+
def settings() -> str:
|
49
|
+
return boost(render_template(
|
50
|
+
'settings.html',
|
51
|
+
current = app.config['state'],
|
52
|
+
updated = request.args.get('updated'),
|
53
|
+
))
|
54
|
+
|
55
|
+
|
56
|
+
@app.route('/save_settings', methods=['POST'])
|
57
|
+
def save_settings() -> str:
|
58
|
+
app.config['state']['api_key'] = request.form.get('api_key')
|
59
|
+
|
60
|
+
api_model = request.form.get('api_model').strip('/')
|
61
|
+
api_endpoint = request.form.get('api_endpoint').strip('/')
|
62
|
+
app.config['state']['api_model'] = api_model
|
63
|
+
app.config['state']['api_endpoint'] = api_endpoint
|
64
|
+
app.config['state']['api_image_input'] = False
|
65
|
+
|
66
|
+
try:
|
67
|
+
model_info = httpx.get(
|
68
|
+
url = f'{api_endpoint}/models/{api_model}/endpoints'
|
69
|
+
).json()
|
70
|
+
input_modalities = model_info['data']['architecture']['input_modalities']
|
71
|
+
if 'image' in input_modalities:
|
72
|
+
app.config['state']['api_image_input'] = True
|
73
|
+
except:
|
74
|
+
pass
|
75
|
+
storage.save_state(app.config['state'])
|
76
|
+
return redirect(f'/settings?updated={datetime.now().isoformat().split(".")[0]}')
|
77
|
+
|
78
|
+
|
79
|
+
@app.route('/sources/manage')
|
80
|
+
def manage_sources() -> str:
|
81
|
+
return boost(render_template(
|
82
|
+
'manage_sources.html',
|
83
|
+
sources = app.config['state'].get('sources', {})
|
84
|
+
))
|
85
|
+
|
86
|
+
|
87
|
+
@app.route('/source/toggle/', methods=['POST'])
|
88
|
+
def source_toggle_active():
|
89
|
+
key = request.form['key']
|
90
|
+
new_active = request.form['new_status']=='active'
|
91
|
+
app.config['state']['sources'][key]['active'] = new_active
|
92
|
+
storage.save_state(app.config['state'])
|
93
|
+
|
94
|
+
return boost(
|
95
|
+
render_template('c_source_card.html', key=key, active=new_active),
|
96
|
+
fallback = redirect('/sources/manage'),
|
97
|
+
retarget = f'#source-card-{key}',
|
98
|
+
reswap = 'outerHTML',
|
99
|
+
push_url = 'false',
|
100
|
+
)
|
101
|
+
|
102
|
+
|
103
|
+
@app.route('/upload_datafile', methods=['POST'])
|
104
|
+
def upload_datafile() -> str:
|
105
|
+
datafile = request.files.get('datafile')
|
106
|
+
filename = secure_filename(datafile.filename)
|
107
|
+
|
108
|
+
saved_path = storage.save_datafile(datafile, filename)
|
109
|
+
stem = saved_path.stem.replace('.', '_')
|
110
|
+
ext = saved_path.suffix.strip('.').lower()
|
111
|
+
|
112
|
+
app.config['state']['sources'] = app.config['state'].get('sources', {})
|
113
|
+
if ext in ['csv']:
|
114
|
+
app.config['state']['sources'][f'{stem}-csv'] = {
|
115
|
+
'source_type': 'csv',
|
116
|
+
'url': str(saved_path),
|
117
|
+
'active': True,
|
118
|
+
}
|
119
|
+
elif ext in ['parquet', 'pq']:
|
120
|
+
app.config['state']['sources'][f'{stem}-parquet'] = {
|
121
|
+
'source_type': 'parquet',
|
122
|
+
'url': str(saved_path),
|
123
|
+
'active': True,
|
124
|
+
}
|
125
|
+
elif ext in ['duckdb']:
|
126
|
+
app.config['state']['sources'][f'{stem}-duckdb'] = {
|
127
|
+
'source_type': 'duckdb',
|
128
|
+
'url': str(saved_path),
|
129
|
+
'active': True,
|
130
|
+
}
|
131
|
+
elif ext in ['db', 'sqlite', 'sqlite3']:
|
132
|
+
app.config['state']['sources'][f'{stem}-sqlite'] = {
|
133
|
+
'source_type': 'sqlite',
|
134
|
+
'url': f'sqlite:///{str(saved_path)}',
|
135
|
+
'active': True,
|
136
|
+
}
|
137
|
+
else:
|
138
|
+
storage.remove_datafile(saved_path)
|
139
|
+
|
140
|
+
storage.save_state(app.config['state'])
|
141
|
+
|
142
|
+
return redirect('/sources/manage')
|
143
|
+
|
144
|
+
|
145
|
+
@app.route('/add_dataurl', methods=['POST'])
|
146
|
+
def add_dataurl():
|
147
|
+
url = request.form['db_url']
|
148
|
+
name = url.split('/')[-1].split('?')[0]
|
149
|
+
|
150
|
+
if url.startswith("postgresql://"):
|
151
|
+
app.config['state']['sources'][f'{name}-postgresql'] = {
|
152
|
+
'source_type': 'postgresql',
|
153
|
+
'url': url,
|
154
|
+
'active': True,
|
155
|
+
}
|
156
|
+
elif url.startswith("mysql://"):
|
157
|
+
app.config['state']['sources'][f'{name}-mysql'] = {
|
158
|
+
'source_type': 'mysql',
|
159
|
+
'url': url,
|
160
|
+
'active': True,
|
161
|
+
}
|
162
|
+
elif url.startswith("clickhouse://"):
|
163
|
+
app.config['state']['sources'][f'{name}-clickhouse'] = {
|
164
|
+
'source_type': 'clickhouse',
|
165
|
+
'url': url,
|
166
|
+
'active': True,
|
167
|
+
}
|
168
|
+
else:
|
169
|
+
pass
|
170
|
+
|
171
|
+
storage.save_state(app.config['state'])
|
172
|
+
return redirect('/sources/manage')
|
173
|
+
|
174
|
+
|
175
|
+
@app.route('/source/delete', methods=['POST'])
|
176
|
+
def delete_source():
|
177
|
+
key = request.form['key']
|
178
|
+
source = app.config['state']['sources'][key]
|
179
|
+
if source['source_type'] in ['csv', 'parquet', 'sqlite', 'duckdb']:
|
180
|
+
storage.remove_datafile(source['url'])
|
181
|
+
|
182
|
+
del app.config['state']['sources'][key]
|
183
|
+
storage.save_state(app.config['state'])
|
184
|
+
return redirect('/sources/manage')
|
185
|
+
|
186
|
+
|
187
|
+
def get_active_sources():
|
188
|
+
sources = {}
|
189
|
+
for key in app.config['state']['sources']:
|
190
|
+
source = app.config['state']['sources'][key]
|
191
|
+
if source['active']:
|
192
|
+
sources[key] = source
|
193
|
+
return sources
|
194
|
+
|
195
|
+
|
196
|
+
def prepare_chat_for_render(chat):
|
197
|
+
fn_calls = {}
|
198
|
+
for msg in chat['messages']:
|
199
|
+
if msg.get('role')=='assistant':
|
200
|
+
if msg.get('tool_calls'):
|
201
|
+
msg['is_tool_call'] = True
|
202
|
+
for tool_call in msg['tool_calls']:
|
203
|
+
fn_call = tool_call['function']
|
204
|
+
fn_call['arguments'] = tomli_w.dumps(
|
205
|
+
json.loads(fn_call['arguments'])
|
206
|
+
).replace('\n', '<br>')
|
207
|
+
fn_calls[tool_call['id']] = fn_call
|
208
|
+
else:
|
209
|
+
msg['html'] = mistune.html(msg['content'])
|
210
|
+
if msg.get('role')=='tool':
|
211
|
+
msg['call_details'] = fn_calls[msg['tool_call_id']]
|
212
|
+
if type(msg['content']) is str:
|
213
|
+
msg['html'] = mistune.html(json.loads(msg['text']))
|
214
|
+
elif type(msg['content']) is list:
|
215
|
+
msg['html'] = ''
|
216
|
+
for content in msg['content']:
|
217
|
+
if content['type'] == 'image_url':
|
218
|
+
data_url = content['image_url']['url']
|
219
|
+
msg['html'] += f'<img src="{data_url}">'
|
220
|
+
else:
|
221
|
+
msg['html'] += mistune.html(json.loads(content['text']))
|
222
|
+
|
223
|
+
return chat
|
224
|
+
|
225
|
+
|
226
|
+
@app.route('/create_new_chat', methods=['POST'])
|
227
|
+
def create_new_chat():
|
228
|
+
question = request.form['question']
|
229
|
+
slug = storage.create_chat(question)
|
230
|
+
chat = storage.load_chat(slug)
|
231
|
+
|
232
|
+
state = app.config['state']
|
233
|
+
agent = agent_wrapper.Agent(
|
234
|
+
endpoint = state['api_endpoint'],
|
235
|
+
api_key = state['api_key'],
|
236
|
+
model = state['api_model'],
|
237
|
+
tools = ZaturnTools(get_active_sources()).tools,
|
238
|
+
image_input = state['api_image_input'],
|
239
|
+
)
|
240
|
+
chat['messages'] = agent.run(chat['messages'])
|
241
|
+
storage.save_chat(slug, chat)
|
242
|
+
chat = prepare_chat_for_render(chat)
|
243
|
+
|
244
|
+
return boost(
|
245
|
+
''.join([
|
246
|
+
render_template('nav.html', slugs=storage.list_chats()),
|
247
|
+
'<main id="main">',
|
248
|
+
render_template('chat.html', chat=chat),
|
249
|
+
'</main>'
|
250
|
+
]),
|
251
|
+
reswap = 'multi:#sidebar,#main',
|
252
|
+
push_url = f'/c/{slug}',
|
253
|
+
fallback = redirect(f'/c/{slug}'),
|
254
|
+
)
|
255
|
+
|
256
|
+
|
257
|
+
@app.route('/c/<slug>')
|
258
|
+
def show_chat(slug: str):
|
259
|
+
chat = prepare_chat_for_render(storage.load_chat(slug))
|
260
|
+
return boost(render_template('chat.html', chat=chat))
|
261
|
+
|
262
|
+
|
263
|
+
@app.route('/follow_up_message', methods=['POST'])
|
264
|
+
def follow_up_message():
|
265
|
+
slug = request.form['slug']
|
266
|
+
chat = storage.load_chat(slug)
|
267
|
+
chat['messages'].append({
|
268
|
+
'role': 'user',
|
269
|
+
'content': request.form['question'],
|
270
|
+
})
|
271
|
+
|
272
|
+
state = app.config['state']
|
273
|
+
agent = agent_wrapper.Agent(
|
274
|
+
endpoint = state['api_endpoint'],
|
275
|
+
api_key = state['api_key'],
|
276
|
+
model = state['api_model'],
|
277
|
+
tools = ZaturnTools(get_active_sources()).tools,
|
278
|
+
image_input = state['api_image_input'],
|
279
|
+
)
|
280
|
+
chat['messages'] = agent.run(chat['messages'])
|
281
|
+
storage.save_chat(slug, chat)
|
282
|
+
chat = prepare_chat_for_render(chat)
|
283
|
+
|
284
|
+
return boost(
|
285
|
+
render_template('chat.html', chat=chat),
|
286
|
+
push_url = 'false',
|
287
|
+
reswap = 'innerHTML scroll:bottom',
|
288
|
+
)
|
Binary file
|
Binary file
|
Binary file
|
@@ -0,0 +1,44 @@
|
|
1
|
+
(function() {
|
2
|
+
/** @type {import("../htmx").HtmxInternalApi} */
|
3
|
+
var api
|
4
|
+
|
5
|
+
htmx.defineExtension('multi-swap', {
|
6
|
+
init: function(apiRef) {
|
7
|
+
api = apiRef
|
8
|
+
},
|
9
|
+
isInlineSwap: function(swapStyle) {
|
10
|
+
return swapStyle.indexOf('multi:') === 0
|
11
|
+
},
|
12
|
+
handleSwap: function(swapStyle, target, fragment, settleInfo) {
|
13
|
+
if (swapStyle.indexOf('multi:') === 0) {
|
14
|
+
var selectorToSwapStyle = {}
|
15
|
+
var elements = swapStyle.replace(/^multi\s*:\s*/, '').split(/\s*,\s*/)
|
16
|
+
|
17
|
+
elements.forEach(function(element) {
|
18
|
+
var split = element.split(/\s*:\s*/)
|
19
|
+
var elementSelector = split[0]
|
20
|
+
var elementSwapStyle = typeof (split[1]) !== 'undefined' ? split[1] : 'innerHTML'
|
21
|
+
|
22
|
+
if (elementSelector.charAt(0) !== '#') {
|
23
|
+
console.error("HTMX multi-swap: unsupported selector '" + elementSelector + "'. Only ID selectors starting with '#' are supported.")
|
24
|
+
return
|
25
|
+
}
|
26
|
+
|
27
|
+
selectorToSwapStyle[elementSelector] = elementSwapStyle
|
28
|
+
})
|
29
|
+
|
30
|
+
for (var selector in selectorToSwapStyle) {
|
31
|
+
var swapStyle = selectorToSwapStyle[selector]
|
32
|
+
var elementToSwap = fragment.querySelector(selector)
|
33
|
+
if (elementToSwap) {
|
34
|
+
api.oobSwap(swapStyle, elementToSwap, settleInfo)
|
35
|
+
} else {
|
36
|
+
console.warn("HTMX multi-swap: selector '" + selector + "' not found in source content.")
|
37
|
+
}
|
38
|
+
}
|
39
|
+
|
40
|
+
return true
|
41
|
+
}
|
42
|
+
}
|
43
|
+
})
|
44
|
+
})()
|