PraisonAI 0.0.46__py3-none-any.whl → 0.0.48__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.
Potentially problematic release.
This version of PraisonAI might be problematic. Click here for more details.
- praisonai/cli.py +35 -0
- praisonai/deploy.py +3 -3
- praisonai/ui/chat.py +79 -214
- praisonai/ui/code.py +317 -0
- praisonai/ui/context.py +140 -0
- praisonai/ui/sql_alchemy.py +638 -0
- {praisonai-0.0.46.dist-info → praisonai-0.0.48.dist-info}/METADATA +8 -7
- {praisonai-0.0.46.dist-info → praisonai-0.0.48.dist-info}/RECORD +11 -8
- {praisonai-0.0.46.dist-info → praisonai-0.0.48.dist-info}/LICENSE +0 -0
- {praisonai-0.0.46.dist-info → praisonai-0.0.48.dist-info}/WHEEL +0 -0
- {praisonai-0.0.46.dist-info → praisonai-0.0.48.dist-info}/entry_points.txt +0 -0
praisonai/cli.py
CHANGED
|
@@ -95,6 +95,10 @@ class PraisonAI:
|
|
|
95
95
|
self.create_chainlit_chat_interface()
|
|
96
96
|
return
|
|
97
97
|
|
|
98
|
+
if getattr(args, 'code', False):
|
|
99
|
+
self.create_code_interface()
|
|
100
|
+
return
|
|
101
|
+
|
|
98
102
|
invocation_cmd = "praisonai"
|
|
99
103
|
version_string = f"PraisonAI version {__version__}"
|
|
100
104
|
|
|
@@ -177,6 +181,9 @@ class PraisonAI:
|
|
|
177
181
|
if args.agent_file == 'chat':
|
|
178
182
|
args.ui = 'chainlit'
|
|
179
183
|
args.chat = True
|
|
184
|
+
if args.agent_file == 'code':
|
|
185
|
+
args.ui = 'chainlit'
|
|
186
|
+
args.code = True
|
|
180
187
|
|
|
181
188
|
return args
|
|
182
189
|
|
|
@@ -207,6 +214,34 @@ class PraisonAI:
|
|
|
207
214
|
chainlit_run([chat_ui_path])
|
|
208
215
|
else:
|
|
209
216
|
print("ERROR: Chat UI is not installed. Please install it with 'pip install \"praisonai\[chat]\"' to use the chat UI.")
|
|
217
|
+
|
|
218
|
+
def create_code_interface(self):
|
|
219
|
+
"""
|
|
220
|
+
Create a Chainlit interface for the code application.
|
|
221
|
+
|
|
222
|
+
This function sets up a Chainlit application that listens for messages.
|
|
223
|
+
When a message is received, it runs PraisonAI with the provided message as the topic.
|
|
224
|
+
The generated agents are then used to perform tasks.
|
|
225
|
+
|
|
226
|
+
Returns:
|
|
227
|
+
None: This function does not return any value. It starts the Chainlit application.
|
|
228
|
+
"""
|
|
229
|
+
if CHAINLIT_AVAILABLE:
|
|
230
|
+
import praisonai
|
|
231
|
+
os.environ["CHAINLIT_PORT"] = "8086"
|
|
232
|
+
public_folder = os.path.join(os.path.dirname(praisonai.__file__), 'public')
|
|
233
|
+
if not os.path.exists("public"): # Check if the folder exists in the current directory
|
|
234
|
+
if os.path.exists(public_folder):
|
|
235
|
+
shutil.copytree(public_folder, 'public', dirs_exist_ok=True)
|
|
236
|
+
logging.info("Public folder copied successfully!")
|
|
237
|
+
else:
|
|
238
|
+
logging.info("Public folder not found in the package.")
|
|
239
|
+
else:
|
|
240
|
+
logging.info("Public folder already exists.")
|
|
241
|
+
code_ui_path = os.path.join(os.path.dirname(praisonai.__file__), 'ui', 'code.py')
|
|
242
|
+
chainlit_run([code_ui_path])
|
|
243
|
+
else:
|
|
244
|
+
print("ERROR: Code UI is not installed. Please install it with 'pip install \"praisonai\[code]\"' to use the code UI.")
|
|
210
245
|
|
|
211
246
|
def create_gradio_interface(self):
|
|
212
247
|
"""
|
praisonai/deploy.py
CHANGED
|
@@ -56,7 +56,7 @@ class CloudDeployer:
|
|
|
56
56
|
file.write("FROM python:3.11-slim\n")
|
|
57
57
|
file.write("WORKDIR /app\n")
|
|
58
58
|
file.write("COPY . .\n")
|
|
59
|
-
file.write("RUN pip install flask praisonai==0.0.
|
|
59
|
+
file.write("RUN pip install flask praisonai==0.0.48 gunicorn markdown\n")
|
|
60
60
|
file.write("EXPOSE 8080\n")
|
|
61
61
|
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
|
|
62
62
|
|
|
@@ -78,8 +78,8 @@ class CloudDeployer:
|
|
|
78
78
|
file.write("import markdown\n\n")
|
|
79
79
|
file.write("app = Flask(__name__)\n\n")
|
|
80
80
|
file.write("def basic():\n")
|
|
81
|
-
file.write("
|
|
82
|
-
file.write(" return
|
|
81
|
+
file.write(" praisonai = PraisonAI(agent_file=\"agents.yaml\")\n")
|
|
82
|
+
file.write(" return praisonai.run()\n\n")
|
|
83
83
|
file.write("@app.route('/')\n")
|
|
84
84
|
file.write("def home():\n")
|
|
85
85
|
file.write(" output = basic()\n")
|
praisonai/ui/chat.py
CHANGED
|
@@ -12,6 +12,8 @@ import chainlit.data as cl_data
|
|
|
12
12
|
from chainlit.step import StepDict
|
|
13
13
|
from literalai.helper import utc_now
|
|
14
14
|
import logging
|
|
15
|
+
import json
|
|
16
|
+
from sql_alchemy import SQLAlchemyDataLayer
|
|
15
17
|
|
|
16
18
|
# Set up logging
|
|
17
19
|
logger = logging.getLogger(__name__)
|
|
@@ -38,31 +40,81 @@ now = utc_now()
|
|
|
38
40
|
|
|
39
41
|
create_step_counter = 0
|
|
40
42
|
|
|
41
|
-
import json
|
|
42
|
-
|
|
43
43
|
DB_PATH = "threads.db"
|
|
44
44
|
|
|
45
45
|
def initialize_db():
|
|
46
46
|
conn = sqlite3.connect(DB_PATH)
|
|
47
47
|
cursor = conn.cursor()
|
|
48
|
+
cursor.execute('''
|
|
49
|
+
CREATE TABLE IF NOT EXISTS users (
|
|
50
|
+
id UUID PRIMARY KEY,
|
|
51
|
+
identifier TEXT NOT NULL UNIQUE,
|
|
52
|
+
metadata JSONB NOT NULL,
|
|
53
|
+
createdAt TEXT
|
|
54
|
+
)
|
|
55
|
+
''')
|
|
48
56
|
cursor.execute('''
|
|
49
57
|
CREATE TABLE IF NOT EXISTS threads (
|
|
50
|
-
id
|
|
51
|
-
name TEXT,
|
|
58
|
+
id UUID PRIMARY KEY,
|
|
52
59
|
createdAt TEXT,
|
|
53
|
-
|
|
54
|
-
|
|
60
|
+
name TEXT,
|
|
61
|
+
userId UUID,
|
|
62
|
+
userIdentifier TEXT,
|
|
63
|
+
tags TEXT[],
|
|
64
|
+
metadata JSONB NOT NULL DEFAULT '{}',
|
|
65
|
+
FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE
|
|
55
66
|
)
|
|
56
67
|
''')
|
|
57
68
|
cursor.execute('''
|
|
58
69
|
CREATE TABLE IF NOT EXISTS steps (
|
|
59
|
-
id
|
|
60
|
-
|
|
61
|
-
|
|
70
|
+
id UUID PRIMARY KEY,
|
|
71
|
+
name TEXT NOT NULL,
|
|
72
|
+
type TEXT NOT NULL,
|
|
73
|
+
threadId UUID NOT NULL,
|
|
74
|
+
parentId UUID,
|
|
75
|
+
disableFeedback BOOLEAN NOT NULL,
|
|
76
|
+
streaming BOOLEAN NOT NULL,
|
|
77
|
+
waitForAnswer BOOLEAN,
|
|
78
|
+
isError BOOLEAN,
|
|
79
|
+
metadata JSONB,
|
|
80
|
+
tags TEXT[],
|
|
81
|
+
input TEXT,
|
|
82
|
+
output TEXT,
|
|
62
83
|
createdAt TEXT,
|
|
84
|
+
start TEXT,
|
|
85
|
+
end TEXT,
|
|
86
|
+
generation JSONB,
|
|
87
|
+
showInput TEXT,
|
|
88
|
+
language TEXT,
|
|
89
|
+
indent INT,
|
|
90
|
+
FOREIGN KEY (threadId) REFERENCES threads (id) ON DELETE CASCADE
|
|
91
|
+
)
|
|
92
|
+
''')
|
|
93
|
+
cursor.execute('''
|
|
94
|
+
CREATE TABLE IF NOT EXISTS elements (
|
|
95
|
+
id UUID PRIMARY KEY,
|
|
96
|
+
threadId UUID,
|
|
63
97
|
type TEXT,
|
|
64
|
-
|
|
65
|
-
|
|
98
|
+
url TEXT,
|
|
99
|
+
chainlitKey TEXT,
|
|
100
|
+
name TEXT NOT NULL,
|
|
101
|
+
display TEXT,
|
|
102
|
+
objectKey TEXT,
|
|
103
|
+
size TEXT,
|
|
104
|
+
page INT,
|
|
105
|
+
language TEXT,
|
|
106
|
+
forId UUID,
|
|
107
|
+
mime TEXT,
|
|
108
|
+
FOREIGN KEY (threadId) REFERENCES threads (id) ON DELETE CASCADE
|
|
109
|
+
)
|
|
110
|
+
''')
|
|
111
|
+
cursor.execute('''
|
|
112
|
+
CREATE TABLE IF NOT EXISTS feedbacks (
|
|
113
|
+
id UUID PRIMARY KEY,
|
|
114
|
+
forId UUID NOT NULL,
|
|
115
|
+
value INT NOT NULL,
|
|
116
|
+
threadId UUID,
|
|
117
|
+
comment TEXT
|
|
66
118
|
)
|
|
67
119
|
''')
|
|
68
120
|
cursor.execute('''
|
|
@@ -110,210 +162,13 @@ def load_setting(key: str) -> str:
|
|
|
110
162
|
conn.close()
|
|
111
163
|
return result[0] if result else None
|
|
112
164
|
|
|
113
|
-
def save_thread_to_db(thread):
|
|
114
|
-
conn = sqlite3.connect(DB_PATH)
|
|
115
|
-
cursor = conn.cursor()
|
|
116
|
-
cursor.execute('''
|
|
117
|
-
INSERT OR REPLACE INTO threads (id, name, createdAt, userId, userIdentifier)
|
|
118
|
-
VALUES (?, ?, ?, ?, ?)
|
|
119
|
-
''', (thread['id'], thread['name'], thread['createdAt'], thread['userId'], thread['userIdentifier']))
|
|
120
|
-
|
|
121
|
-
# No steps to save as steps are empty in the provided thread data
|
|
122
|
-
conn.commit()
|
|
123
|
-
conn.close()
|
|
124
|
-
logger.debug("Thread saved to DB")
|
|
125
|
-
|
|
126
|
-
def update_thread_in_db(thread):
|
|
127
|
-
conn = sqlite3.connect(DB_PATH)
|
|
128
|
-
cursor = conn.cursor()
|
|
129
|
-
|
|
130
|
-
# Insert or update the thread
|
|
131
|
-
cursor.execute('''
|
|
132
|
-
INSERT OR REPLACE INTO threads (id, name, createdAt, userId, userIdentifier)
|
|
133
|
-
VALUES (?, ?, ?, ?, ?)
|
|
134
|
-
''', (thread['id'], thread['name'], thread['createdAt'], thread['userId'], thread['userIdentifier']))
|
|
135
|
-
|
|
136
|
-
# Fetch message_history from metadata
|
|
137
|
-
message_history = cl.user_session.get("message_history", [])
|
|
138
|
-
|
|
139
|
-
# Ensure user messages come first followed by assistant messages
|
|
140
|
-
user_messages = [msg for msg in message_history if msg['role'] == 'user']
|
|
141
|
-
assistant_messages = [msg for msg in message_history if msg['role'] == 'assistant']
|
|
142
|
-
ordered_steps = [val for pair in zip(user_messages, assistant_messages) for val in pair]
|
|
143
|
-
|
|
144
|
-
# Generate steps from ordered message_history
|
|
145
|
-
steps = []
|
|
146
|
-
for idx, message in enumerate(ordered_steps):
|
|
147
|
-
step_id = f"{thread['id']}-step-{idx}"
|
|
148
|
-
step_type = 'user_message' if message['role'] == 'user' else 'assistant_message'
|
|
149
|
-
step_name = 'user' if message['role'] == 'user' else 'assistant'
|
|
150
|
-
created_at = message.get('createdAt', thread['createdAt']) # Use thread's createdAt if no timestamp in message
|
|
151
|
-
steps.append({
|
|
152
|
-
'id': step_id,
|
|
153
|
-
'threadId': thread['id'],
|
|
154
|
-
'name': step_name,
|
|
155
|
-
'createdAt': created_at,
|
|
156
|
-
'type': step_type,
|
|
157
|
-
'output': message['content']
|
|
158
|
-
})
|
|
159
|
-
|
|
160
|
-
# Insert all steps into the database
|
|
161
|
-
for step in steps:
|
|
162
|
-
cursor.execute('''
|
|
163
|
-
INSERT OR REPLACE INTO steps (id, threadId, name, createdAt, type, output)
|
|
164
|
-
VALUES (?, ?, ?, ?, ?, ?)
|
|
165
|
-
''', (step['id'], step['threadId'], step['name'], step['createdAt'], step['type'], step['output']))
|
|
166
|
-
|
|
167
|
-
conn.commit()
|
|
168
|
-
conn.close()
|
|
169
|
-
logger.debug("Thread updated in DB")
|
|
170
|
-
|
|
171
|
-
def delete_thread_from_db(thread_id: str):
|
|
172
|
-
"""Deletes a thread and its steps from the database.
|
|
173
|
-
|
|
174
|
-
Args:
|
|
175
|
-
thread_id: The ID of the thread to delete.
|
|
176
|
-
"""
|
|
177
|
-
conn = sqlite3.connect(DB_PATH)
|
|
178
|
-
cursor = conn.cursor()
|
|
179
|
-
cursor.execute('DELETE FROM threads WHERE id = ?', (thread_id,))
|
|
180
|
-
cursor.execute('DELETE FROM steps WHERE threadId = ?', (thread_id,))
|
|
181
|
-
conn.commit()
|
|
182
|
-
conn.close()
|
|
183
|
-
|
|
184
|
-
def load_threads_from_db():
|
|
185
|
-
conn = sqlite3.connect(DB_PATH)
|
|
186
|
-
cursor = conn.cursor()
|
|
187
|
-
cursor.execute('SELECT * FROM threads ORDER BY createdAt ASC')
|
|
188
|
-
thread_rows = cursor.fetchall()
|
|
189
|
-
threads = []
|
|
190
|
-
for thread_row in thread_rows:
|
|
191
|
-
cursor.execute('SELECT * FROM steps WHERE threadId = ? ORDER BY createdAt ASC', (thread_row[0],))
|
|
192
|
-
step_rows = cursor.fetchall()
|
|
193
|
-
steps = []
|
|
194
|
-
for step_row in step_rows:
|
|
195
|
-
steps.append({
|
|
196
|
-
"id": step_row[0],
|
|
197
|
-
"threadId": step_row[1],
|
|
198
|
-
"name": step_row[2],
|
|
199
|
-
"createdAt": step_row[3],
|
|
200
|
-
"type": step_row[4],
|
|
201
|
-
"output": step_row[5]
|
|
202
|
-
})
|
|
203
|
-
threads.append({
|
|
204
|
-
"id": thread_row[0],
|
|
205
|
-
"name": thread_row[1],
|
|
206
|
-
"createdAt": thread_row[2],
|
|
207
|
-
"userId": thread_row[3],
|
|
208
|
-
"userIdentifier": thread_row[4],
|
|
209
|
-
"steps": steps
|
|
210
|
-
})
|
|
211
|
-
conn.close()
|
|
212
|
-
logger.debug("Threads loaded from DB")
|
|
213
|
-
return threads
|
|
214
165
|
|
|
215
166
|
# Initialize the database
|
|
216
167
|
initialize_db()
|
|
217
|
-
thread_history = load_threads_from_db()
|
|
218
168
|
|
|
219
169
|
deleted_thread_ids = [] # type: List[str]
|
|
220
170
|
|
|
221
|
-
|
|
222
|
-
async def get_user(self, identifier: str):
|
|
223
|
-
logger.debug(f"Getting user: {identifier}")
|
|
224
|
-
return cl.PersistedUser(id="test", createdAt=now, identifier=identifier)
|
|
225
|
-
|
|
226
|
-
async def create_user(self, user: cl.User):
|
|
227
|
-
logger.debug(f"Creating user: {user.identifier}")
|
|
228
|
-
return cl.PersistedUser(id="test", createdAt=now, identifier=user.identifier)
|
|
229
|
-
|
|
230
|
-
async def update_thread(
|
|
231
|
-
self,
|
|
232
|
-
thread_id: str,
|
|
233
|
-
name: Optional[str] = None,
|
|
234
|
-
user_id: Optional[str] = None,
|
|
235
|
-
metadata: Optional[Dict] = None,
|
|
236
|
-
tags: Optional[List[str]] = None,
|
|
237
|
-
):
|
|
238
|
-
logger.debug(f"Updating thread: {thread_id}")
|
|
239
|
-
thread = next((t for t in thread_history if t["id"] == thread_id), None)
|
|
240
|
-
if thread:
|
|
241
|
-
if name:
|
|
242
|
-
thread["name"] = name
|
|
243
|
-
if metadata:
|
|
244
|
-
thread["metadata"] = metadata
|
|
245
|
-
if tags:
|
|
246
|
-
thread["tags"] = tags
|
|
247
|
-
|
|
248
|
-
logger.debug(f"Thread: {thread}")
|
|
249
|
-
cl.user_session.set("message_history", thread['metadata']['message_history'])
|
|
250
|
-
cl.user_session.set("thread_id", thread["id"])
|
|
251
|
-
update_thread_in_db(thread)
|
|
252
|
-
logger.debug(f"Thread updated: {thread_id}")
|
|
253
|
-
|
|
254
|
-
else:
|
|
255
|
-
thread_history.append(
|
|
256
|
-
{
|
|
257
|
-
"id": thread_id,
|
|
258
|
-
"name": name,
|
|
259
|
-
"metadata": metadata,
|
|
260
|
-
"tags": tags,
|
|
261
|
-
"createdAt": utc_now(),
|
|
262
|
-
"userId": user_id,
|
|
263
|
-
"userIdentifier": "admin",
|
|
264
|
-
"steps": [],
|
|
265
|
-
}
|
|
266
|
-
)
|
|
267
|
-
thread = {
|
|
268
|
-
"id": thread_id,
|
|
269
|
-
"name": name,
|
|
270
|
-
"metadata": metadata,
|
|
271
|
-
"tags": tags,
|
|
272
|
-
"createdAt": utc_now(),
|
|
273
|
-
"userId": user_id,
|
|
274
|
-
"userIdentifier": "admin",
|
|
275
|
-
"steps": [],
|
|
276
|
-
}
|
|
277
|
-
save_thread_to_db(thread)
|
|
278
|
-
logger.debug(f"Thread created: {thread_id}")
|
|
279
|
-
|
|
280
|
-
@cl_data.queue_until_user_message()
|
|
281
|
-
async def create_step(self, step_dict: StepDict):
|
|
282
|
-
global create_step_counter
|
|
283
|
-
create_step_counter += 1
|
|
284
|
-
|
|
285
|
-
thread = next(
|
|
286
|
-
(t for t in thread_history if t["id"] == step_dict.get("threadId")), None
|
|
287
|
-
)
|
|
288
|
-
if thread:
|
|
289
|
-
thread["steps"].append(step_dict)
|
|
290
|
-
|
|
291
|
-
async def get_thread_author(self, thread_id: str):
|
|
292
|
-
logger.debug(f"Getting thread author: {thread_id}")
|
|
293
|
-
return "admin"
|
|
294
|
-
|
|
295
|
-
async def list_threads(
|
|
296
|
-
self, pagination: cl_data.Pagination, filters: cl_data.ThreadFilter
|
|
297
|
-
) -> cl_data.PaginatedResponse[cl_data.ThreadDict]:
|
|
298
|
-
logger.debug(f"Listing threads")
|
|
299
|
-
return cl_data.PaginatedResponse(
|
|
300
|
-
data=[t for t in thread_history if t["id"] not in deleted_thread_ids][::-1],
|
|
301
|
-
pageInfo=cl_data.PageInfo(
|
|
302
|
-
hasNextPage=False, startCursor=None, endCursor=None
|
|
303
|
-
),
|
|
304
|
-
)
|
|
305
|
-
|
|
306
|
-
async def get_thread(self, thread_id: str):
|
|
307
|
-
logger.debug(f"Getting thread: {thread_id}")
|
|
308
|
-
thread_history = load_threads_from_db()
|
|
309
|
-
return next((t for t in thread_history if t["id"] == thread_id), None)
|
|
310
|
-
|
|
311
|
-
async def delete_thread(self, thread_id: str):
|
|
312
|
-
deleted_thread_ids.append(thread_id)
|
|
313
|
-
delete_thread_from_db(thread_id)
|
|
314
|
-
logger.debug(f"Deleted thread: {thread_id}")
|
|
315
|
-
|
|
316
|
-
cl_data._data_layer = TestDataLayer()
|
|
171
|
+
cl_data._data_layer = SQLAlchemyDataLayer(conninfo=f"sqlite+aiosqlite:///{DB_PATH}")
|
|
317
172
|
|
|
318
173
|
@cl.on_chat_start
|
|
319
174
|
async def start():
|
|
@@ -357,7 +212,12 @@ async def setup_agent(settings):
|
|
|
357
212
|
if thread:
|
|
358
213
|
metadata = thread.get("metadata", {})
|
|
359
214
|
metadata["model_name"] = model_name
|
|
360
|
-
|
|
215
|
+
|
|
216
|
+
# Always store metadata as a JSON string
|
|
217
|
+
await cl_data.update_thread(thread_id, metadata=json.dumps(metadata))
|
|
218
|
+
|
|
219
|
+
# Update the user session with the new metadata
|
|
220
|
+
cl.user_session.set("metadata", metadata)
|
|
361
221
|
|
|
362
222
|
@cl.on_message
|
|
363
223
|
async def main(message: cl.Message):
|
|
@@ -372,9 +232,9 @@ async def main(message: cl.Message):
|
|
|
372
232
|
model=model_name,
|
|
373
233
|
messages=message_history,
|
|
374
234
|
stream=True,
|
|
375
|
-
temperature=0.7,
|
|
376
|
-
max_tokens=500,
|
|
377
|
-
top_p=1
|
|
235
|
+
# temperature=0.7,
|
|
236
|
+
# max_tokens=500,
|
|
237
|
+
# top_p=1
|
|
378
238
|
)
|
|
379
239
|
|
|
380
240
|
full_response = ""
|
|
@@ -423,6 +283,11 @@ async def on_chat_resume(thread: cl_data.ThreadDict):
|
|
|
423
283
|
await settings.send()
|
|
424
284
|
thread_id = thread["id"]
|
|
425
285
|
cl.user_session.set("thread_id", thread["id"])
|
|
286
|
+
|
|
287
|
+
# The metadata should now already be a dictionary
|
|
288
|
+
metadata = thread.get("metadata", {})
|
|
289
|
+
cl.user_session.set("metadata", metadata)
|
|
290
|
+
|
|
426
291
|
message_history = cl.user_session.get("message_history", [])
|
|
427
292
|
steps = thread["steps"]
|
|
428
293
|
|
|
@@ -435,4 +300,4 @@ async def on_chat_resume(thread: cl_data.ThreadDict):
|
|
|
435
300
|
else:
|
|
436
301
|
logger.warning(f"Message without type: {message}")
|
|
437
302
|
|
|
438
|
-
|
|
303
|
+
cl.user_session.set("message_history", message_history)
|