vedana-backoffice 0.1.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.
- vedana_backoffice/Caddyfile +17 -0
- vedana_backoffice/__init__.py +0 -0
- vedana_backoffice/components/__init__.py +0 -0
- vedana_backoffice/components/etl_graph.py +132 -0
- vedana_backoffice/components/ui_chat.py +236 -0
- vedana_backoffice/graph/__init__.py +0 -0
- vedana_backoffice/graph/build.py +169 -0
- vedana_backoffice/pages/__init__.py +0 -0
- vedana_backoffice/pages/chat.py +204 -0
- vedana_backoffice/pages/etl.py +353 -0
- vedana_backoffice/pages/eval.py +1006 -0
- vedana_backoffice/pages/jims_thread_list_page.py +894 -0
- vedana_backoffice/pages/main_dashboard.py +483 -0
- vedana_backoffice/py.typed +0 -0
- vedana_backoffice/start_services.py +39 -0
- vedana_backoffice/state.py +0 -0
- vedana_backoffice/states/__init__.py +0 -0
- vedana_backoffice/states/chat.py +368 -0
- vedana_backoffice/states/common.py +66 -0
- vedana_backoffice/states/etl.py +1590 -0
- vedana_backoffice/states/eval.py +1940 -0
- vedana_backoffice/states/jims.py +508 -0
- vedana_backoffice/states/main_dashboard.py +757 -0
- vedana_backoffice/ui.py +115 -0
- vedana_backoffice/util.py +71 -0
- vedana_backoffice/vedana_backoffice.py +23 -0
- vedana_backoffice-0.1.0.dist-info/METADATA +10 -0
- vedana_backoffice-0.1.0.dist-info/RECORD +30 -0
- vedana_backoffice-0.1.0.dist-info/WHEEL +4 -0
- vedana_backoffice-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,894 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from datetime import datetime, timedelta
|
|
3
|
+
|
|
4
|
+
import reflex as rx
|
|
5
|
+
import sqlalchemy as sa
|
|
6
|
+
from jims_core.db import ThreadDB, ThreadEventDB
|
|
7
|
+
from vedana_core.app import make_vedana_app
|
|
8
|
+
|
|
9
|
+
from vedana_backoffice.components.ui_chat import render_message_bubble
|
|
10
|
+
from vedana_backoffice.states.jims import ThreadViewState
|
|
11
|
+
from vedana_backoffice.ui import app_header
|
|
12
|
+
from vedana_backoffice.util import datetime_to_age
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class ThreadVis:
|
|
17
|
+
thread_id: str
|
|
18
|
+
created_at: str
|
|
19
|
+
thread_age: str
|
|
20
|
+
interface: str
|
|
21
|
+
review_status: str
|
|
22
|
+
priority: str
|
|
23
|
+
tag1: str = ""
|
|
24
|
+
tag2: str = ""
|
|
25
|
+
tag3: str = ""
|
|
26
|
+
|
|
27
|
+
@classmethod
|
|
28
|
+
def create(
|
|
29
|
+
cls,
|
|
30
|
+
thread_id: str,
|
|
31
|
+
created_at: datetime,
|
|
32
|
+
thread_config: dict,
|
|
33
|
+
review_status: str = "-",
|
|
34
|
+
priority: str = "-",
|
|
35
|
+
tags_sample: list[str] | None = None,
|
|
36
|
+
) -> "ThreadVis":
|
|
37
|
+
cfg = thread_config or {}
|
|
38
|
+
iface_val = cfg.get("interface") or cfg.get("channel") or cfg.get("source")
|
|
39
|
+
if isinstance(iface_val, dict):
|
|
40
|
+
iface_val = iface_val.get("name") or iface_val.get("type") or str(iface_val)
|
|
41
|
+
ts = list(tags_sample or [])
|
|
42
|
+
while len(ts) < 3:
|
|
43
|
+
ts.append("")
|
|
44
|
+
return cls(
|
|
45
|
+
thread_id=str(thread_id),
|
|
46
|
+
created_at=datetime.strftime(created_at, "%Y-%m-%d %H:%M:%S"),
|
|
47
|
+
thread_age=datetime_to_age(created_at),
|
|
48
|
+
interface=str(iface_val or ""),
|
|
49
|
+
review_status=str(review_status),
|
|
50
|
+
priority=str(priority),
|
|
51
|
+
tag1=str(ts[0] or ""),
|
|
52
|
+
tag2=str(ts[1] or ""),
|
|
53
|
+
tag3=str(ts[2] or ""),
|
|
54
|
+
# last_activity=datetime.strftime(last_activity, "%Y-%m-%d %H:%M:%S"), # todo format tz
|
|
55
|
+
# last_activity_age=datetime_to_age(last_activity),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ThreadListState(rx.State):
|
|
60
|
+
threads_refreshing: bool = True
|
|
61
|
+
threads: list[ThreadVis] = []
|
|
62
|
+
|
|
63
|
+
# Filters
|
|
64
|
+
from_date: str = ""
|
|
65
|
+
to_date: str = ""
|
|
66
|
+
search_text: str = ""
|
|
67
|
+
sort_reverse: bool = True
|
|
68
|
+
review_filter: str = "Review: All" # Default
|
|
69
|
+
sort_by: str = "Sort by: Date"
|
|
70
|
+
available_tags: list[str] = []
|
|
71
|
+
selected_tags: list[str] = []
|
|
72
|
+
|
|
73
|
+
@staticmethod
|
|
74
|
+
def _parse_date(date_str: str | None) -> datetime | None:
|
|
75
|
+
try:
|
|
76
|
+
if date_str:
|
|
77
|
+
return datetime.strptime(date_str, "%Y-%m-%d")
|
|
78
|
+
except Exception:
|
|
79
|
+
return None
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
@rx.event
|
|
83
|
+
def set_from_date(self, value: str) -> None:
|
|
84
|
+
self.from_date = value
|
|
85
|
+
|
|
86
|
+
@rx.event
|
|
87
|
+
def set_to_date(self, value: str) -> None:
|
|
88
|
+
self.to_date = value
|
|
89
|
+
|
|
90
|
+
@rx.event
|
|
91
|
+
def set_search_text(self, value: str) -> None:
|
|
92
|
+
self.search_text = value
|
|
93
|
+
|
|
94
|
+
@rx.event
|
|
95
|
+
def clear_filters(self) -> None:
|
|
96
|
+
self.from_date = ""
|
|
97
|
+
self.to_date = ""
|
|
98
|
+
self.search_text = ""
|
|
99
|
+
self.sort_reverse = True
|
|
100
|
+
self.review_filter = "Review: All"
|
|
101
|
+
self.sort_by = "Sort by: Date"
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
@rx.event
|
|
105
|
+
async def toggle_sort(self) -> None:
|
|
106
|
+
self.sort_reverse = not self.sort_reverse
|
|
107
|
+
await self.get_data() # type: ignore[operator]
|
|
108
|
+
|
|
109
|
+
@rx.event
|
|
110
|
+
async def set_review_filter(self, value: str) -> None:
|
|
111
|
+
self.review_filter = value
|
|
112
|
+
await self.get_data() # type: ignore[operator]
|
|
113
|
+
|
|
114
|
+
@rx.event
|
|
115
|
+
async def set_sort_by(self, value: str) -> None:
|
|
116
|
+
self.sort_by = value
|
|
117
|
+
await self.get_data() # type: ignore[operator]
|
|
118
|
+
|
|
119
|
+
@rx.event
|
|
120
|
+
def toggle_tag_filter(self, tag: str, value: bool) -> None:
|
|
121
|
+
try:
|
|
122
|
+
t = str(tag)
|
|
123
|
+
if value:
|
|
124
|
+
if t not in self.selected_tags:
|
|
125
|
+
self.selected_tags = [*self.selected_tags, t]
|
|
126
|
+
else:
|
|
127
|
+
self.selected_tags = [x for x in self.selected_tags if x != t]
|
|
128
|
+
except Exception:
|
|
129
|
+
pass
|
|
130
|
+
|
|
131
|
+
@rx.event
|
|
132
|
+
def clear_tag_filter(self) -> None:
|
|
133
|
+
self.selected_tags = []
|
|
134
|
+
|
|
135
|
+
@rx.event
|
|
136
|
+
async def get_data(self) -> None:
|
|
137
|
+
"""compiles the entire thread list table"""
|
|
138
|
+
vedana_app = await make_vedana_app()
|
|
139
|
+
|
|
140
|
+
from_dt = self._parse_date(self.from_date)
|
|
141
|
+
to_dt = self._parse_date(self.to_date)
|
|
142
|
+
if to_dt is not None:
|
|
143
|
+
# Make end exclusive by adding one day
|
|
144
|
+
to_dt = to_dt + timedelta(days=1)
|
|
145
|
+
|
|
146
|
+
async with vedana_app.sessionmaker() as session:
|
|
147
|
+
last_event_sq = (
|
|
148
|
+
sa.select(
|
|
149
|
+
ThreadEventDB.thread_id.label("t_id"),
|
|
150
|
+
sa.func.max(ThreadEventDB.created_at).label("last_at"),
|
|
151
|
+
)
|
|
152
|
+
.group_by(ThreadEventDB.thread_id)
|
|
153
|
+
.subquery()
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
stmt = sa.select(ThreadDB, last_event_sq.c.last_at).join(
|
|
157
|
+
last_event_sq, last_event_sq.c.t_id == ThreadDB.thread_id
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
# Apply date filters at SQL level when possible
|
|
161
|
+
if from_dt is not None:
|
|
162
|
+
stmt = stmt.where(last_event_sq.c.last_at >= from_dt)
|
|
163
|
+
if to_dt is not None:
|
|
164
|
+
stmt = stmt.where(last_event_sq.c.last_at < to_dt)
|
|
165
|
+
|
|
166
|
+
results = await session.execute(stmt)
|
|
167
|
+
rows = results.all()
|
|
168
|
+
|
|
169
|
+
# Collect thread ids and last_at (any event)
|
|
170
|
+
thread_ids: list[str] = []
|
|
171
|
+
last_at_by_tid: dict[str, datetime] = {}
|
|
172
|
+
for thread_obj, last_at in rows:
|
|
173
|
+
tid = str(thread_obj.thread_id)
|
|
174
|
+
thread_ids.append(tid)
|
|
175
|
+
try:
|
|
176
|
+
last_at_by_tid[tid] = last_at or thread_obj.created_at
|
|
177
|
+
except Exception:
|
|
178
|
+
last_at_by_tid[tid] = thread_obj.created_at
|
|
179
|
+
|
|
180
|
+
# Also collect last chat-related activity (exclude jims.backoffice.% events)
|
|
181
|
+
last_chat_at_by_tid: dict[str, datetime] = {}
|
|
182
|
+
if thread_ids:
|
|
183
|
+
chat_stmt = (
|
|
184
|
+
sa.select(
|
|
185
|
+
ThreadEventDB.thread_id.label("t_id"),
|
|
186
|
+
sa.func.max(ThreadEventDB.created_at).label("last_chat_at"),
|
|
187
|
+
)
|
|
188
|
+
.where(
|
|
189
|
+
sa.and_(
|
|
190
|
+
ThreadEventDB.thread_id.in_(thread_ids),
|
|
191
|
+
sa.not_(ThreadEventDB.event_type.like("jims.backoffice.%")),
|
|
192
|
+
)
|
|
193
|
+
)
|
|
194
|
+
.group_by(ThreadEventDB.thread_id)
|
|
195
|
+
)
|
|
196
|
+
chat_rows = (await session.execute(chat_stmt)).all()
|
|
197
|
+
for t_id, last_chat_at in chat_rows:
|
|
198
|
+
try:
|
|
199
|
+
last_chat_at_by_tid[str(t_id)] = last_chat_at
|
|
200
|
+
except Exception:
|
|
201
|
+
continue
|
|
202
|
+
|
|
203
|
+
# Load backoffice events for review/priority aggregation
|
|
204
|
+
review_status_by_tid: dict[str, str] = {tid: "-" for tid in thread_ids}
|
|
205
|
+
priority_by_tid: dict[str, str] = {tid: "-" for tid in thread_ids}
|
|
206
|
+
priority_rank_by_tid: dict[str, int] = {tid: -1 for tid in thread_ids}
|
|
207
|
+
|
|
208
|
+
if thread_ids:
|
|
209
|
+
bo_stmt = sa.select(ThreadEventDB).where(
|
|
210
|
+
sa.and_(
|
|
211
|
+
ThreadEventDB.thread_id.in_(thread_ids),
|
|
212
|
+
ThreadEventDB.event_type.like("jims.backoffice.%"), # todo split jims event_type in domains?
|
|
213
|
+
)
|
|
214
|
+
)
|
|
215
|
+
bo_rows = (await session.execute(bo_stmt)).scalars().all()
|
|
216
|
+
|
|
217
|
+
has_feedback: dict[str, bool] = {tid: False for tid in thread_ids}
|
|
218
|
+
has_resolved: dict[str, bool] = {tid: False for tid in thread_ids}
|
|
219
|
+
|
|
220
|
+
# Tag aggregation
|
|
221
|
+
available_tags_set: set[str] = set()
|
|
222
|
+
tags_by_event: dict[str, set[str]] = {}
|
|
223
|
+
bo_rows_sorted = sorted(bo_rows, key=lambda r: getattr(r, "created_at", datetime.min))
|
|
224
|
+
|
|
225
|
+
# Comment resolution aggregation
|
|
226
|
+
unresolved_by_tid: dict[str, int] = {tid: 0 for tid in thread_ids}
|
|
227
|
+
comment_tid_by_id: dict[str, str] = {}
|
|
228
|
+
resolved_cids: set[str] = set()
|
|
229
|
+
|
|
230
|
+
for ev in bo_rows_sorted:
|
|
231
|
+
tid = str(ev.thread_id)
|
|
232
|
+
etype = str(getattr(ev, "event_type", ""))
|
|
233
|
+
if etype == "jims.backoffice.feedback":
|
|
234
|
+
has_feedback[tid] = True
|
|
235
|
+
try:
|
|
236
|
+
sev = str((getattr(ev, "event_data", {}) or {}).get("severity", "Low"))
|
|
237
|
+
except Exception:
|
|
238
|
+
sev = "Low"
|
|
239
|
+
rank = {"Low": 0, "Medium": 1, "High": 2}.get(sev, 0)
|
|
240
|
+
if rank > priority_rank_by_tid.get(tid, -1):
|
|
241
|
+
priority_rank_by_tid[tid] = rank
|
|
242
|
+
priority_by_tid[tid] = {0: "Low", 1: "Medium", 2: "High"}.get(rank, "Low")
|
|
243
|
+
# Count unresolved comment
|
|
244
|
+
try:
|
|
245
|
+
cid = str(getattr(ev, "event_id"))
|
|
246
|
+
if cid:
|
|
247
|
+
comment_tid_by_id[cid] = tid
|
|
248
|
+
unresolved_by_tid[tid] = unresolved_by_tid.get(tid, 0) + 1
|
|
249
|
+
except Exception:
|
|
250
|
+
pass
|
|
251
|
+
elif etype == "jims.backoffice.review_resolved":
|
|
252
|
+
has_resolved[tid] = True
|
|
253
|
+
elif etype in ("jims.backoffice.tag_added", "jims.backoffice.tag_removed"):
|
|
254
|
+
ed = dict(getattr(ev, "event_data", {}) or {})
|
|
255
|
+
tag = str(ed.get("tag", "")).strip()
|
|
256
|
+
target = str(ed.get("target_event_id", ""))
|
|
257
|
+
if tag:
|
|
258
|
+
available_tags_set.add(tag)
|
|
259
|
+
if not target:
|
|
260
|
+
continue
|
|
261
|
+
cur = tags_by_event.setdefault(target, set())
|
|
262
|
+
if etype == "jims.backoffice.tag_added" and tag:
|
|
263
|
+
cur.add(tag)
|
|
264
|
+
elif etype == "jims.backoffice.tag_removed" and tag:
|
|
265
|
+
try:
|
|
266
|
+
cur.discard(tag)
|
|
267
|
+
except Exception:
|
|
268
|
+
pass
|
|
269
|
+
elif etype in ("jims.backoffice.comment_resolved", "jims.backoffice.comment_closed"):
|
|
270
|
+
ed = dict(getattr(ev, "event_data", {}) or {})
|
|
271
|
+
cid = str(ed.get("comment_id", ""))
|
|
272
|
+
if not cid or cid in resolved_cids:
|
|
273
|
+
continue
|
|
274
|
+
resolved_cids.add(cid)
|
|
275
|
+
rtid = comment_tid_by_id.get(cid, tid)
|
|
276
|
+
try:
|
|
277
|
+
unresolved_by_tid[rtid] = max(0, unresolved_by_tid.get(rtid, 0) - 1)
|
|
278
|
+
except Exception:
|
|
279
|
+
pass
|
|
280
|
+
|
|
281
|
+
for tid in thread_ids:
|
|
282
|
+
if has_resolved.get(tid) or (has_feedback.get(tid) and unresolved_by_tid.get(tid, 0) == 0):
|
|
283
|
+
review_status_by_tid[tid] = "Complete"
|
|
284
|
+
elif has_feedback.get(tid):
|
|
285
|
+
review_status_by_tid[tid] = "Pending"
|
|
286
|
+
else:
|
|
287
|
+
review_status_by_tid[tid] = "-"
|
|
288
|
+
|
|
289
|
+
# derive thread-level tag sets from event-level tags
|
|
290
|
+
thread_tags_by_tid: dict[str, set[str]] = {tid: set() for tid in thread_ids}
|
|
291
|
+
for ev in bo_rows_sorted:
|
|
292
|
+
etype = str(getattr(ev, "event_type", ""))
|
|
293
|
+
if etype not in ("jims.backoffice.tag_added", "jims.backoffice.tag_removed"):
|
|
294
|
+
continue
|
|
295
|
+
ed = dict(getattr(ev, "event_data", {}) or {})
|
|
296
|
+
target = str(ed.get("target_event_id", ""))
|
|
297
|
+
if not target:
|
|
298
|
+
continue
|
|
299
|
+
cur_tags = tags_by_event.get(target, set())
|
|
300
|
+
# union into its thread
|
|
301
|
+
thread_tags_by_tid[str(ev.thread_id)].update(cur_tags)
|
|
302
|
+
|
|
303
|
+
# store available tag values for multi-select
|
|
304
|
+
current_tags: set[str] = set()
|
|
305
|
+
for _tid, _tags in thread_tags_by_tid.items():
|
|
306
|
+
current_tags.update(set(_tags))
|
|
307
|
+
self.available_tags = sorted(list(current_tags))
|
|
308
|
+
|
|
309
|
+
else:
|
|
310
|
+
thread_tags_by_tid = {}
|
|
311
|
+
|
|
312
|
+
items: list[ThreadVis] = []
|
|
313
|
+
for thread_obj, last_at in rows:
|
|
314
|
+
try:
|
|
315
|
+
sample = sorted(list(thread_tags_by_tid.get(str(thread_obj.thread_id), set())))[:3]
|
|
316
|
+
items.append(
|
|
317
|
+
ThreadVis.create(
|
|
318
|
+
thread_id=str(thread_obj.thread_id),
|
|
319
|
+
created_at=thread_obj.created_at,
|
|
320
|
+
# last_activity=last_at or thread_obj.created_at,
|
|
321
|
+
thread_config=thread_obj.thread_config,
|
|
322
|
+
review_status=review_status_by_tid.get(str(thread_obj.thread_id), "-"),
|
|
323
|
+
priority=priority_by_tid.get(str(thread_obj.thread_id), "-"),
|
|
324
|
+
tags_sample=sample,
|
|
325
|
+
)
|
|
326
|
+
)
|
|
327
|
+
except Exception:
|
|
328
|
+
# Fall back if last_at is None or bad
|
|
329
|
+
sample = sorted(list(thread_tags_by_tid.get(str(thread_obj.thread_id), set())))[:3]
|
|
330
|
+
items.append(
|
|
331
|
+
ThreadVis.create(
|
|
332
|
+
thread_id=str(thread_obj.thread_id),
|
|
333
|
+
created_at=thread_obj.created_at,
|
|
334
|
+
thread_config=thread_obj.thread_config,
|
|
335
|
+
review_status=review_status_by_tid.get(str(thread_obj.thread_id), "-"),
|
|
336
|
+
priority=priority_by_tid.get(str(thread_obj.thread_id), "-"),
|
|
337
|
+
tags_sample=sample,
|
|
338
|
+
)
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
# In-memory search (thread_id or interface)
|
|
342
|
+
search = (self.search_text or "").strip().lower()
|
|
343
|
+
if search:
|
|
344
|
+
items = [it for it in items if search in it.thread_id.lower() or search in (it.interface or "").lower()]
|
|
345
|
+
|
|
346
|
+
# Filter by review status
|
|
347
|
+
rf = (self.review_filter.removeprefix("Review: ") or "All").strip()
|
|
348
|
+
if rf and rf != "All":
|
|
349
|
+
items = [it for it in items if it.review_status == rf]
|
|
350
|
+
|
|
351
|
+
# Filter by selected tags (OR semantics)
|
|
352
|
+
sel = set(self.selected_tags or [])
|
|
353
|
+
if sel:
|
|
354
|
+
|
|
355
|
+
def _has_any(t: ThreadVis) -> bool:
|
|
356
|
+
try:
|
|
357
|
+
tid = t.thread_id
|
|
358
|
+
return len(sel.intersection(thread_tags_by_tid.get(tid, set()))) > 0
|
|
359
|
+
except Exception:
|
|
360
|
+
return False
|
|
361
|
+
|
|
362
|
+
items = [it for it in items if _has_any(it)]
|
|
363
|
+
|
|
364
|
+
# Sorting (by last chat-related activity for Date sorts)
|
|
365
|
+
sort_val = self.sort_by.removeprefix("Sort by:").strip()
|
|
366
|
+
if sort_val == "Date":
|
|
367
|
+
items = sorted(
|
|
368
|
+
items,
|
|
369
|
+
key=lambda it: last_chat_at_by_tid.get(it.thread_id, last_at_by_tid.get(it.thread_id, datetime.min)),
|
|
370
|
+
reverse=self.sort_reverse, # sort_reverse = True by default
|
|
371
|
+
)
|
|
372
|
+
elif sort_val == "Priority":
|
|
373
|
+
rank_map = {"-": -1, "Low": 0, "Medium": 1, "High": 2}
|
|
374
|
+
items = sorted(
|
|
375
|
+
items,
|
|
376
|
+
key=lambda it: (
|
|
377
|
+
rank_map.get(it.priority, -1),
|
|
378
|
+
last_chat_at_by_tid.get(it.thread_id, last_at_by_tid.get(it.thread_id, datetime.min)),
|
|
379
|
+
),
|
|
380
|
+
reverse=self.sort_reverse, # sort_reverse = True by default
|
|
381
|
+
)
|
|
382
|
+
else: # Date descending as a fallback
|
|
383
|
+
items = sorted(
|
|
384
|
+
items,
|
|
385
|
+
key=lambda it: last_chat_at_by_tid.get(it.thread_id, last_at_by_tid.get(it.thread_id, datetime.min)),
|
|
386
|
+
reverse=True,
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
self.threads = items
|
|
390
|
+
self.threads_refreshing = False
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
@rx.page(route="/jims", on_load=ThreadListState.get_data)
|
|
394
|
+
def jims_thread_list_page() -> rx.Component:
|
|
395
|
+
def _badge_style(bg: str, fg: str) -> dict[str, str]:
|
|
396
|
+
return {
|
|
397
|
+
"backgroundColor": bg,
|
|
398
|
+
"color": fg,
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
def review_badge(value: str) -> rx.Component: # type: ignore[valid-type]
|
|
402
|
+
return rx.cond(
|
|
403
|
+
value == "Pending",
|
|
404
|
+
rx.badge("Pending", variant="soft", size="1", style=_badge_style("var(--amber-4)", "var(--amber-11)")),
|
|
405
|
+
rx.cond(
|
|
406
|
+
value == "Complete",
|
|
407
|
+
rx.badge("Complete", variant="soft", size="1", style=_badge_style("var(--green-4)", "var(--green-11)")),
|
|
408
|
+
rx.badge(value, variant="soft", size="1", style=_badge_style("var(--gray-4)", "var(--gray-12)")),
|
|
409
|
+
),
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
def priority_badge(value: str) -> rx.Component: # type: ignore[valid-type]
|
|
413
|
+
return rx.cond(
|
|
414
|
+
value == "High",
|
|
415
|
+
rx.badge("High", variant="soft", size="1", style=_badge_style("var(--tomato-4)", "var(--tomato-11)")),
|
|
416
|
+
rx.cond(
|
|
417
|
+
value == "Medium",
|
|
418
|
+
rx.badge("Medium", variant="soft", size="1", style=_badge_style("var(--amber-4)", "var(--amber-11)")),
|
|
419
|
+
rx.badge(value, variant="soft", size="1", style=_badge_style("var(--gray-4)", "var(--gray-12)")),
|
|
420
|
+
),
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
filters = rx.hstack(
|
|
424
|
+
rx.hstack(
|
|
425
|
+
rx.hstack(
|
|
426
|
+
rx.input(
|
|
427
|
+
value=ThreadListState.from_date,
|
|
428
|
+
type="date",
|
|
429
|
+
on_change=ThreadListState.set_from_date,
|
|
430
|
+
),
|
|
431
|
+
rx.text("-"),
|
|
432
|
+
rx.input(
|
|
433
|
+
value=ThreadListState.to_date,
|
|
434
|
+
type="date",
|
|
435
|
+
on_change=ThreadListState.set_to_date,
|
|
436
|
+
),
|
|
437
|
+
align="center",
|
|
438
|
+
spacing="1",
|
|
439
|
+
),
|
|
440
|
+
rx.select(
|
|
441
|
+
items=["All", "Pending", "Complete"],
|
|
442
|
+
placeholder="Review: All",
|
|
443
|
+
on_change=ThreadListState.set_review_filter,
|
|
444
|
+
width="180px",
|
|
445
|
+
),
|
|
446
|
+
rx.dialog.root(
|
|
447
|
+
rx.dialog.trigger(
|
|
448
|
+
rx.button(
|
|
449
|
+
rx.cond(
|
|
450
|
+
ThreadListState.selected_tags.length() > 0, # type: ignore[attr-defined]
|
|
451
|
+
"Tags: " + ThreadListState.selected_tags.join(", "), # type: ignore[attr-defined]
|
|
452
|
+
"Tags: All",
|
|
453
|
+
),
|
|
454
|
+
variant="soft",
|
|
455
|
+
color_scheme="gray",
|
|
456
|
+
)
|
|
457
|
+
),
|
|
458
|
+
rx.dialog.content(
|
|
459
|
+
rx.vstack(
|
|
460
|
+
rx.hstack(
|
|
461
|
+
rx.dialog.title("Filter by Tags"),
|
|
462
|
+
rx.dialog.close(rx.button("Close", variant="ghost", size="1")),
|
|
463
|
+
justify="between",
|
|
464
|
+
align="center",
|
|
465
|
+
width="100%",
|
|
466
|
+
),
|
|
467
|
+
rx.scroll_area(
|
|
468
|
+
rx.vstack(
|
|
469
|
+
rx.foreach(
|
|
470
|
+
ThreadListState.available_tags,
|
|
471
|
+
lambda t: rx.checkbox(
|
|
472
|
+
t,
|
|
473
|
+
checked=ThreadListState.selected_tags.contains(t), # type: ignore[attr-defined]
|
|
474
|
+
on_change=lambda v, tag=t: ThreadListState.toggle_tag_filter(tag=tag, value=v), # type: ignore[operator]
|
|
475
|
+
),
|
|
476
|
+
),
|
|
477
|
+
spacing="2",
|
|
478
|
+
width="100%",
|
|
479
|
+
),
|
|
480
|
+
type="always",
|
|
481
|
+
scrollbars="vertical",
|
|
482
|
+
),
|
|
483
|
+
rx.hstack(
|
|
484
|
+
rx.button("Apply", on_click=ThreadListState.get_data, size="1"),
|
|
485
|
+
rx.button(
|
|
486
|
+
"Clear",
|
|
487
|
+
variant="soft",
|
|
488
|
+
on_click=[ThreadListState.clear_tag_filter, ThreadListState.get_data],
|
|
489
|
+
size="1",
|
|
490
|
+
),
|
|
491
|
+
spacing="2",
|
|
492
|
+
justify="end",
|
|
493
|
+
width="100%",
|
|
494
|
+
),
|
|
495
|
+
spacing="3",
|
|
496
|
+
)
|
|
497
|
+
),
|
|
498
|
+
),
|
|
499
|
+
rx.hstack(
|
|
500
|
+
rx.select(
|
|
501
|
+
items=["Date", "Priority"],
|
|
502
|
+
on_change=ThreadListState.set_sort_by,
|
|
503
|
+
placeholder="Sort By: Date",
|
|
504
|
+
width="160px",
|
|
505
|
+
),
|
|
506
|
+
rx.cond(
|
|
507
|
+
ThreadListState.sort_reverse,
|
|
508
|
+
rx.icon(
|
|
509
|
+
"arrow-down-1-0",
|
|
510
|
+
size=28,
|
|
511
|
+
stroke_width=1.5,
|
|
512
|
+
cursor="pointer",
|
|
513
|
+
flex_shrink="0",
|
|
514
|
+
on_click=ThreadListState.toggle_sort,
|
|
515
|
+
), # type: ignore
|
|
516
|
+
rx.icon(
|
|
517
|
+
"arrow-down-0-1",
|
|
518
|
+
size=28,
|
|
519
|
+
stroke_width=1.5,
|
|
520
|
+
cursor="pointer",
|
|
521
|
+
flex_shrink="0",
|
|
522
|
+
on_click=ThreadListState.toggle_sort,
|
|
523
|
+
), # type: ignore
|
|
524
|
+
),
|
|
525
|
+
spacing="0",
|
|
526
|
+
),
|
|
527
|
+
rx.input(
|
|
528
|
+
placeholder="Search thread or interface",
|
|
529
|
+
value=ThreadListState.search_text,
|
|
530
|
+
on_change=ThreadListState.set_search_text,
|
|
531
|
+
width="280px",
|
|
532
|
+
),
|
|
533
|
+
),
|
|
534
|
+
rx.hstack(
|
|
535
|
+
rx.button("Search", on_click=ThreadListState.get_data),
|
|
536
|
+
rx.button(
|
|
537
|
+
"Clear",
|
|
538
|
+
variant="soft",
|
|
539
|
+
on_click=[ThreadListState.clear_filters, ThreadListState.get_data],
|
|
540
|
+
),
|
|
541
|
+
spacing="2",
|
|
542
|
+
),
|
|
543
|
+
justify="between",
|
|
544
|
+
width="100%",
|
|
545
|
+
align="center",
|
|
546
|
+
wrap="wrap",
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
table = rx.table.root(
|
|
550
|
+
rx.table.header(
|
|
551
|
+
rx.table.row(
|
|
552
|
+
rx.table.column_header_cell("Thread ID"),
|
|
553
|
+
rx.table.column_header_cell("Created"),
|
|
554
|
+
rx.table.column_header_cell("Age"),
|
|
555
|
+
rx.table.column_header_cell("Interface"),
|
|
556
|
+
rx.table.column_header_cell("Tags"),
|
|
557
|
+
rx.table.column_header_cell("Review"),
|
|
558
|
+
rx.table.column_header_cell("Priority"),
|
|
559
|
+
),
|
|
560
|
+
),
|
|
561
|
+
rx.table.body(
|
|
562
|
+
rx.foreach(
|
|
563
|
+
ThreadListState.threads,
|
|
564
|
+
lambda t: rx.table.row(
|
|
565
|
+
rx.table.cell(
|
|
566
|
+
rx.button(
|
|
567
|
+
t.thread_id,
|
|
568
|
+
variant="ghost",
|
|
569
|
+
color_scheme="gray",
|
|
570
|
+
size="1",
|
|
571
|
+
on_click=ThreadViewState.select_thread(thread_id=t.thread_id), # type: ignore[operator]
|
|
572
|
+
)
|
|
573
|
+
), # type: ignore[call-arg,func-returns-value]
|
|
574
|
+
rx.table.cell(t.created_at),
|
|
575
|
+
rx.table.cell(t.thread_age),
|
|
576
|
+
rx.table.cell(t.interface),
|
|
577
|
+
rx.table.cell(
|
|
578
|
+
rx.hstack(
|
|
579
|
+
rx.cond(t.tag1 != "", rx.badge(t.tag1, variant="soft", size="1", color_scheme="gray")),
|
|
580
|
+
rx.cond(t.tag2 != "", rx.badge(t.tag2, variant="soft", size="1", color_scheme="gray")),
|
|
581
|
+
rx.cond(t.tag3 != "", rx.badge(t.tag3, variant="soft", size="1", color_scheme="gray")),
|
|
582
|
+
spacing="1",
|
|
583
|
+
)
|
|
584
|
+
),
|
|
585
|
+
rx.table.cell(review_badge(t.review_status)),
|
|
586
|
+
rx.table.cell(priority_badge(t.priority)),
|
|
587
|
+
style=rx.cond(
|
|
588
|
+
t.thread_id == ThreadViewState.selected_thread_id, {"backgroundColor": "var(--accent-3)"}, {}
|
|
589
|
+
),
|
|
590
|
+
),
|
|
591
|
+
),
|
|
592
|
+
),
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
def _render_event_as_msg(ev): # type: ignore[valid-type]
|
|
596
|
+
msg = {
|
|
597
|
+
"id": ev.event_id,
|
|
598
|
+
"content": ev.content,
|
|
599
|
+
"created_at": ev.created_at_str,
|
|
600
|
+
"is_assistant": ev.role == "assistant",
|
|
601
|
+
"tag_label": ev.event_type,
|
|
602
|
+
"tags": ev.visible_tags, # tags/comments from backoffice annotations
|
|
603
|
+
"comments": ev.feedback_comments,
|
|
604
|
+
"has_tech": ev.has_technical_info,
|
|
605
|
+
"has_models": ev.has_models,
|
|
606
|
+
"has_vts": ev.has_vts,
|
|
607
|
+
"has_cypher": ev.has_cypher,
|
|
608
|
+
"models_str": ev.models_str,
|
|
609
|
+
"vts_str": ev.vts_str,
|
|
610
|
+
"cypher_str": ev.cypher_str,
|
|
611
|
+
"show_details": ThreadViewState.expanded_event_id == ev.event_id,
|
|
612
|
+
"event_data_str": ev.event_data_str,
|
|
613
|
+
"generic_meta": ev.generic_meta,
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
tag_dialog = rx.dialog.root(
|
|
617
|
+
rx.dialog.content(
|
|
618
|
+
rx.vstack(
|
|
619
|
+
rx.hstack(
|
|
620
|
+
rx.dialog.title("Add Tags"),
|
|
621
|
+
rx.dialog.close(
|
|
622
|
+
rx.button("Close", variant="ghost", size="1", on_click=ThreadViewState.close_tag_dialog)
|
|
623
|
+
),
|
|
624
|
+
justify="between",
|
|
625
|
+
align="center",
|
|
626
|
+
width="100%",
|
|
627
|
+
),
|
|
628
|
+
rx.hstack(
|
|
629
|
+
rx.input(
|
|
630
|
+
placeholder="Add new tag...",
|
|
631
|
+
value=ThreadViewState.new_tag_text_for_event.get(ev.event_id, ""),
|
|
632
|
+
on_change=lambda v: ThreadViewState.set_new_tag_text_for_event(v, event_id=ev.event_id),
|
|
633
|
+
width="100%",
|
|
634
|
+
),
|
|
635
|
+
rx.button(
|
|
636
|
+
"Add",
|
|
637
|
+
size="1",
|
|
638
|
+
on_click=ThreadViewState.add_new_tag_to_available(event_id=ev.event_id),
|
|
639
|
+
),
|
|
640
|
+
spacing="2",
|
|
641
|
+
width="100%",
|
|
642
|
+
),
|
|
643
|
+
rx.scroll_area(
|
|
644
|
+
rx.vstack(
|
|
645
|
+
rx.foreach(
|
|
646
|
+
ThreadViewState.available_tags,
|
|
647
|
+
lambda t: rx.checkbox(
|
|
648
|
+
t,
|
|
649
|
+
checked=ThreadViewState.selected_tags_for_event.get(ev.event_id, []).contains(t), # type: ignore[attr-defined]
|
|
650
|
+
on_change=lambda v, tag=t: ThreadViewState.toggle_tag_selection_for_event(
|
|
651
|
+
tag=tag, event_id=ev.event_id, checked=v
|
|
652
|
+
), # type: ignore[operator]
|
|
653
|
+
),
|
|
654
|
+
),
|
|
655
|
+
spacing="2",
|
|
656
|
+
width="100%",
|
|
657
|
+
),
|
|
658
|
+
type="always",
|
|
659
|
+
scrollbars="vertical",
|
|
660
|
+
style={"width": "100%", "padding": "0"},
|
|
661
|
+
),
|
|
662
|
+
rx.hstack(
|
|
663
|
+
rx.button(
|
|
664
|
+
"Apply",
|
|
665
|
+
size="1",
|
|
666
|
+
on_click=ThreadViewState.apply_tags_to_event(event_id=ev.event_id), # type: ignore[call-arg,func-returns-value]
|
|
667
|
+
),
|
|
668
|
+
spacing="2",
|
|
669
|
+
justify="end",
|
|
670
|
+
width="100%",
|
|
671
|
+
),
|
|
672
|
+
spacing="3",
|
|
673
|
+
),
|
|
674
|
+
),
|
|
675
|
+
open=ThreadViewState.tag_dialog_open_for_event == ev.event_id,
|
|
676
|
+
on_open_change=ThreadViewState.handle_tag_dialog_open_change, # type: ignore[operator]
|
|
677
|
+
)
|
|
678
|
+
|
|
679
|
+
action_line = rx.cond(
|
|
680
|
+
(ev.role == "assistant") & ev.content != "",
|
|
681
|
+
rx.hstack(
|
|
682
|
+
rx.button(
|
|
683
|
+
"Add tag",
|
|
684
|
+
size="1",
|
|
685
|
+
on_click=ThreadViewState.open_tag_dialog(event_id=ev.event_id), # type: ignore[call-arg,func-returns-value]
|
|
686
|
+
),
|
|
687
|
+
tag_dialog,
|
|
688
|
+
# Add Note (single-line input)
|
|
689
|
+
rx.input(
|
|
690
|
+
placeholder="Add note...",
|
|
691
|
+
value=ThreadViewState.note_text_by_event.get(ev.event_id, ""),
|
|
692
|
+
on_change=lambda v: ThreadViewState.set_note_text_for(v, event_id=ev.event_id), # type: ignore[call-arg,func-returns-value]
|
|
693
|
+
width="40%",
|
|
694
|
+
size="1",
|
|
695
|
+
),
|
|
696
|
+
rx.select(
|
|
697
|
+
items=["Low", "Medium", "High"],
|
|
698
|
+
value=ThreadViewState.note_severity_by_event.get(ev.event_id, "Low"),
|
|
699
|
+
on_change=lambda v: ThreadViewState.set_note_severity_for(v, event_id=ev.event_id), # type: ignore[call-arg,func-returns-value]
|
|
700
|
+
size="1",
|
|
701
|
+
),
|
|
702
|
+
rx.button(
|
|
703
|
+
"Add note",
|
|
704
|
+
size="1",
|
|
705
|
+
on_click=ThreadViewState.submit_note_for(event_id=ev.event_id), # type: ignore[call-arg,func-returns-value]
|
|
706
|
+
),
|
|
707
|
+
spacing="2",
|
|
708
|
+
wrap="wrap",
|
|
709
|
+
width="100%",
|
|
710
|
+
align="center",
|
|
711
|
+
),
|
|
712
|
+
rx.box(),
|
|
713
|
+
)
|
|
714
|
+
|
|
715
|
+
# Comments thread displayed under the message
|
|
716
|
+
comments_component = rx.vstack(
|
|
717
|
+
rx.foreach(
|
|
718
|
+
ev.feedback_comments,
|
|
719
|
+
lambda c: rx.card(
|
|
720
|
+
rx.vstack(
|
|
721
|
+
rx.hstack(
|
|
722
|
+
rx.cond(
|
|
723
|
+
c["severity"] == "High",
|
|
724
|
+
rx.badge("High", variant="soft", size="1", color_scheme="tomato"),
|
|
725
|
+
rx.cond(
|
|
726
|
+
c["severity"] == "Medium",
|
|
727
|
+
rx.badge("Medium", variant="soft", size="1", color_scheme="amber"),
|
|
728
|
+
rx.badge("Low", variant="soft", size="1", color_scheme="gray"),
|
|
729
|
+
),
|
|
730
|
+
),
|
|
731
|
+
rx.text(c["created_at"], size="1", color="gray"),
|
|
732
|
+
rx.spacer(),
|
|
733
|
+
# Status badge
|
|
734
|
+
rx.cond(
|
|
735
|
+
c.get("status", "open") == "resolved",
|
|
736
|
+
rx.badge("Resolved", variant="soft", size="1", color_scheme="green"),
|
|
737
|
+
rx.cond(
|
|
738
|
+
c.get("status", "open") == "closed",
|
|
739
|
+
rx.badge("Ignored", variant="soft", size="1", color_scheme="gray"),
|
|
740
|
+
rx.box(),
|
|
741
|
+
),
|
|
742
|
+
),
|
|
743
|
+
spacing="2",
|
|
744
|
+
align="center",
|
|
745
|
+
width="100%",
|
|
746
|
+
),
|
|
747
|
+
rx.text(
|
|
748
|
+
c["note"],
|
|
749
|
+
style={
|
|
750
|
+
"whiteSpace": "pre-wrap",
|
|
751
|
+
"wordBreak": "break-word",
|
|
752
|
+
},
|
|
753
|
+
),
|
|
754
|
+
# Actions row
|
|
755
|
+
rx.hstack(
|
|
756
|
+
rx.button(
|
|
757
|
+
"✔",
|
|
758
|
+
variant="soft",
|
|
759
|
+
size="1",
|
|
760
|
+
color_scheme="green",
|
|
761
|
+
disabled=c.get("status", "open") != "open",
|
|
762
|
+
on_click=ThreadViewState.mark_comment_resolved(comment_id=c["id"]), # type: ignore[call-arg,func-returns-value]
|
|
763
|
+
),
|
|
764
|
+
rx.button(
|
|
765
|
+
"✖",
|
|
766
|
+
variant="soft",
|
|
767
|
+
size="1",
|
|
768
|
+
color_scheme="gray",
|
|
769
|
+
disabled=c.get("status", "open") != "open",
|
|
770
|
+
on_click=ThreadViewState.mark_comment_closed(comment_id=c["id"]), # type: ignore[call-arg,func-returns-value]
|
|
771
|
+
),
|
|
772
|
+
spacing="2",
|
|
773
|
+
),
|
|
774
|
+
spacing="1",
|
|
775
|
+
width="100%",
|
|
776
|
+
),
|
|
777
|
+
padding="0.5em",
|
|
778
|
+
variant="surface",
|
|
779
|
+
),
|
|
780
|
+
),
|
|
781
|
+
spacing="2",
|
|
782
|
+
width="100%",
|
|
783
|
+
)
|
|
784
|
+
|
|
785
|
+
extras = rx.vstack(
|
|
786
|
+
comments_component,
|
|
787
|
+
action_line,
|
|
788
|
+
spacing="2",
|
|
789
|
+
)
|
|
790
|
+
|
|
791
|
+
def _tag_badge(tag: str): # type: ignore[valid-type]
|
|
792
|
+
return rx.badge(
|
|
793
|
+
rx.hstack(
|
|
794
|
+
rx.text(tag),
|
|
795
|
+
rx.button(
|
|
796
|
+
"×",
|
|
797
|
+
variant="ghost",
|
|
798
|
+
size="1",
|
|
799
|
+
color_scheme="gray",
|
|
800
|
+
on_click=ThreadViewState.remove_tag(event_id=ev.event_id, tag=tag), # type: ignore[operator, call-arg,func-returns-value]
|
|
801
|
+
),
|
|
802
|
+
spacing="1",
|
|
803
|
+
),
|
|
804
|
+
variant="soft",
|
|
805
|
+
size="1",
|
|
806
|
+
color_scheme="gray",
|
|
807
|
+
)
|
|
808
|
+
|
|
809
|
+
tags_component = rx.hstack(
|
|
810
|
+
rx.foreach(ev.visible_tags, _tag_badge),
|
|
811
|
+
spacing="1",
|
|
812
|
+
)
|
|
813
|
+
|
|
814
|
+
return render_message_bubble(
|
|
815
|
+
msg,
|
|
816
|
+
on_toggle_details=ThreadViewState.toggle_details(event_id=ev.event_id), # type: ignore[call-arg,func-returns-value]
|
|
817
|
+
extras=extras,
|
|
818
|
+
corner_tags_component=tags_component,
|
|
819
|
+
)
|
|
820
|
+
|
|
821
|
+
filters_box = rx.box(
|
|
822
|
+
filters,
|
|
823
|
+
margin_bottom="1em",
|
|
824
|
+
)
|
|
825
|
+
|
|
826
|
+
# Left panel (thread list with its own scroll)
|
|
827
|
+
left_panel = rx.box(
|
|
828
|
+
rx.cond(
|
|
829
|
+
ThreadListState.threads_refreshing,
|
|
830
|
+
rx.center("Loading threads..."),
|
|
831
|
+
rx.scroll_area(
|
|
832
|
+
table,
|
|
833
|
+
type="always",
|
|
834
|
+
scrollbars="vertical",
|
|
835
|
+
style={"height": "100%"},
|
|
836
|
+
),
|
|
837
|
+
),
|
|
838
|
+
flex="1",
|
|
839
|
+
min_height="0",
|
|
840
|
+
style={"height": "100%", "overflow": "hidden"},
|
|
841
|
+
)
|
|
842
|
+
|
|
843
|
+
# Right panel (conversation fills height with scroll)
|
|
844
|
+
right_panel = rx.cond(
|
|
845
|
+
ThreadViewState.selected_thread_id == "",
|
|
846
|
+
rx.center(rx.text("Select a thread to view conversation"), style={"height": "100%"}),
|
|
847
|
+
rx.box(
|
|
848
|
+
rx.scroll_area(
|
|
849
|
+
rx.vstack(
|
|
850
|
+
rx.foreach(ThreadViewState.events, _render_event_as_msg),
|
|
851
|
+
spacing="3",
|
|
852
|
+
width="100%",
|
|
853
|
+
padding_bottom="1em",
|
|
854
|
+
style={
|
|
855
|
+
"maxWidth": "100%",
|
|
856
|
+
"overflowX": "hidden",
|
|
857
|
+
},
|
|
858
|
+
),
|
|
859
|
+
type="always",
|
|
860
|
+
scrollbars="vertical",
|
|
861
|
+
style={
|
|
862
|
+
"height": "100%",
|
|
863
|
+
"maxWidth": "100%",
|
|
864
|
+
"overflowX": "hidden",
|
|
865
|
+
},
|
|
866
|
+
),
|
|
867
|
+
flex="1",
|
|
868
|
+
min_height="0",
|
|
869
|
+
style={"height": "100%", "overflow": "hidden"},
|
|
870
|
+
),
|
|
871
|
+
)
|
|
872
|
+
|
|
873
|
+
return rx.vstack(
|
|
874
|
+
app_header(),
|
|
875
|
+
filters_box,
|
|
876
|
+
rx.box(
|
|
877
|
+
rx.grid(
|
|
878
|
+
left_panel,
|
|
879
|
+
right_panel,
|
|
880
|
+
columns="2",
|
|
881
|
+
spacing="4",
|
|
882
|
+
sm_columns="1",
|
|
883
|
+
width="100%",
|
|
884
|
+
style={"height": "100%"},
|
|
885
|
+
),
|
|
886
|
+
flex="1",
|
|
887
|
+
min_height="0",
|
|
888
|
+
width="100%",
|
|
889
|
+
),
|
|
890
|
+
spacing="4",
|
|
891
|
+
height="100vh",
|
|
892
|
+
overflow="hidden",
|
|
893
|
+
width="100%",
|
|
894
|
+
)
|