feature update

- browse queue history
- bookmark task
- name a task
- requeue a task (create new task with the same params)
- view generated images
- send params directly to txt2img, img2img
- queue apis
- bugs fixing and stability improverments
pull/20/head
AutoAgentX 2023-06-05 16:53:01 +07:00 committed by Tung Nguyen
parent b070319583
commit 57f23512cf
32 changed files with 11958 additions and 11702 deletions

View File

@ -5,8 +5,8 @@ Introducing AgentScheduler, an A1111/Vladmandic Stable Diffusion Web UI extensio
## Table of Content
- [Compatibility](#compatibility)
- [Functionality](#functionality--as-of-current-version-)
- [Installation](#installation)
- [Functionality](#functionality--as-of-current-version-)
- [Using the built-in extension list](#using-the-built-in-extension-list)
- [Manual clone](#manual-clone)
- [Road Map](#road-map)
@ -18,42 +18,18 @@ Introducing AgentScheduler, an A1111/Vladmandic Stable Diffusion Web UI extensio
## Compatibility
This version of AgentScheduler is compatible with:
This version of AgentScheduler is compatible with latest versions of:
- A1111: [commit 5ab7f213](https://github.com/AUTOMATIC1111/stable-diffusion-webui/commit/5ab7f213bec2f816f9c5644becb32eb72c8ffb89)
- Vladmandic: [commit 0a46f8ad](https://github.com/vladmandic/automatic/commit/0a46f8ada7751ee993c565198ec4c3327ff43c04)
- A1111: [commit baf6946](https://github.com/AUTOMATIC1111/stable-diffusion-webui/commit/baf6946e06249c5af9851c60171692c44ef633e0)
- Vladmandic: [commit 9726b4d](https://github.com/vladmandic/automatic/commit/9726b4d23cb63779964e1d4edff49dd2c9c11e51)
> Older versions may not working properly.
## Functionality [as of current version]
![Extension Walkthrough](https://user-images.githubusercontent.com/90659883/236373779-43bb9625-d5d9-4450-abc5-93e7af7251fd.jpg)
1⃣ Input your usual Prompts & Settings. **Enqueue** to send your current prompts, settings, controlnets to **AgentScheduler**.
2**AgentScheduler** Tab Navigation.
3**Pause** function to stop auto generation. **Refresh** to update.
4⃣ See all queued tasks, current image being generated and tasks' associated information.
5**Delete** tasks that you no longer want. Press ▶️ to prioritize selected task.
6**Show/Hide** Column of Information that you want.
7**Drag and Drop** to reorder columns.
https://github.com/ArtVentureX/sd-webui-agent-scheduler/assets/133728487/50c74922-b85f-493c-9be8-b8e78f0cd061
## Road Map
To list possible feature upgrades for this extension
- Sync with GenAI Management Platform **ArtVenture**
- See history of completed jobs (Logs)
## Installation
### Using Vlad's WebUI Fork
The extension is already included in [Vlad fork](https://github.com/vladmandic/automatic)'s builtin extensions.
### Using the built-in extension list
1. Open the Extensions tab
@ -61,7 +37,7 @@ To list possible feature upgrades for this extension
3. Paste the repo url: https://github.com/ArtVentureX/sd-webui-agent-scheduler.git
4. Click "Install"
![Install](/docs/images/install.png)
![Install](https://github.com/ArtVentureX/sd-webui-agent-scheduler/assets/133728487/f0fa740b-392a-4dd6-abe1-49c770ea60da)
### Manual clone
@ -71,6 +47,61 @@ git clone "https://github.com/ArtVentureX/sd-webui-agent-scheduler.git" extensio
(The second argument specifies the name of the folder, you can choose whatever you like).
## Functionality [as of current version]
![Extension Walkthrough 1](https://github.com/ArtVentureX/sd-webui-agent-scheduler/assets/133728487/a5a039a7-d98b-4186-9131-6775f0812c39)
1⃣ Input your usual Prompts & Settings. **Enqueue** to send your current prompts, settings, controlnets to **AgentScheduler**.
![Extension Walkthrough 2](https://github.com/ArtVentureX/sd-webui-agent-scheduler/assets/133728487/734176b4-7ee3-40e5-bb92-35608fabfc4b)
2**AgentScheduler** Extension Tab.
3⃣ See all queued tasks, current image being generated and tasks' associated information. **Drag and drop** the handle in the begining of each row to reaggrange the generation order.
4**Pause** to stop queue auto generation. **Resume** to start.
5⃣ Press ▶️ to prioritize selected task, or to start a single task when queue is paused. **Delete** tasks that you no longer want.
![ Extension Walkthrough 3](https://github.com/ArtVentureX/sd-webui-agent-scheduler/assets/133728487/23109761-2633-4b24-bbb3-091628367047)
6⃣ Show queue history.
7**Filter** task status or search by text.
8**Bookmark** task to easier filtering.
9⃣ Double click the task id to **rename**. Click ↩️ to **Requeue** old task.
🔟 Click on each task to **view** the generation results.
https://github.com/ArtVentureX/sd-webui-agent-scheduler/assets/133728487/50c74922-b85f-493c-9be8-b8e78f0cd061
## Extension Settings
Go to `Settings > Agent Scheduler` to access extension settings.
![Settings](https://github.com/ArtVentureX/sd-webui-agent-scheduler/assets/133728487/b0377ccd-f9bf-486e-8393-c06fe26aa117)
**Disable Queue Auto-Processing**: Check this option to disable queue auto-processing on start-up. You can also temporarily pause or resume the queue from the Extension tab.
**Queue Button Placement**: Change the placement of the queue button on the UI.
**Hide the Checkpoint Dropdown**: The Extension provides a custom checkpoint dropdown.
![Custom Checkpoint](https://github.com/ArtVentureX/sd-webui-agent-scheduler/assets/133728487/d110d314-a208-4eec-bb54-9f8c73cb450b)
By default, queued tasks use the currently loaded checkpoint. However, changing the system checkpoint requires some time to load the checkpoint into memory, and you also cannot change the checkpoint during image generation. You can use this dropdown to quickly queue a task with a custom checkpoint.
**Auto Delete Queue History**: Select a timeframe to keep your queue history. Tasks that are older than the configured value will be automatically deleted. Please note that bookmarked tasks will not be deleted.
## Road Map
To list possible feature upgrades for this extension
- Connect multiple SD webui nodes to run task.
- Sync with GenAI Management Platform **ArtVenture**
## Contributing
We welcome contributions to the Agent Scheduler Extension project! Please feel free to submit issues, bug reports, and feature requests through the GitHub repository.

BIN
docs/.DS_Store vendored Normal file

Binary file not shown.

BIN
docs/images/settings.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

BIN
docs/images/walkthrough.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

File diff suppressed because it is too large Load Diff

View File

@ -1,14 +1,21 @@
import json
import threading
from uuid import uuid4
from gradio.routes import App
import modules.shared as shared
from modules import progress, script_callbacks, sd_samplers
from modules import shared, progress, script_callbacks
from scripts.db import TaskStatus, task_manager
from scripts.models import QueueStatusResponse
from scripts.models import (
Txt2ImgApiTaskArgs,
Img2ImgApiTaskArgs,
QueueTaskResponse,
QueueStatusResponse,
HistoryResponse,
TaskModel,
)
from scripts.task_runner import TaskRunner, get_instance
from scripts.helpers import log
from scripts.task_helpers import serialize_api_task_args
task_runner: TaskRunner = None
@ -16,35 +23,104 @@ task_runner: TaskRunner = None
def regsiter_apis(app: App):
log.info("[AgentScheduler] Registering APIs")
@app.post("/agent-scheduler/v1/queue/txt2img", response_model=QueueTaskResponse)
def queue_txt2img(body: Txt2ImgApiTaskArgs):
params = body.dict()
task_id = str(uuid4())
checkpoint = params.pop("model_hash", None)
task_args = serialize_api_task_args(
params,
is_img2img=False,
checkpoint=checkpoint,
)
task_runner.register_api_task(
task_id, api_task_id=False, is_img2img=False, args=task_args
)
task_runner.execute_pending_tasks_threading()
return QueueTaskResponse(task_id=task_id)
@app.post("/agent-scheduler/v1/queue/img2img", response_model=QueueTaskResponse)
def queue_img2img(body: Img2ImgApiTaskArgs):
params = body.dict()
task_id = str(uuid4())
checkpoint = params.pop("model_hash", None)
task_args = serialize_api_task_args(
params,
is_img2img=True,
checkpoint=checkpoint,
)
task_runner.register_api_task(
task_id, api_task_id=False, is_img2img=True, args=task_args
)
task_runner.execute_pending_tasks_threading()
return QueueTaskResponse(task_id=task_id)
@app.get("/agent-scheduler/v1/queue", response_model=QueueStatusResponse)
def queue_status_api(limit: int = 20, offset: int = 0):
current_task_id = progress.current_task
total_pending_tasks = total_pending_tasks = task_manager.count_tasks(
status="pending"
)
total_pending_tasks = task_manager.count_tasks(status="pending")
pending_tasks = task_manager.get_tasks(
status=TaskStatus.PENDING, limit=limit, offset=offset
)
parsed_tasks = []
for task in pending_tasks:
task_args = TaskRunner.instance.parse_task_args(
task.params, task.script_params, deserialization=False
)
named_args = task_args.named_args
named_args["checkpoint"] = task_args.checkpoint
sampler_index = named_args.get("sampler_index", None)
if sampler_index is not None:
named_args["sampler_name"] = sd_samplers.samplers[
named_args["sampler_index"]
].name
task.params = json.dumps(named_args)
task_data = task.dict()
task_data["params"] = named_args
if task.id == current_task_id:
task_data["status"] = "running"
parsed_tasks.append(TaskModel(**task_data))
return QueueStatusResponse(
current_task_id=current_task_id,
pending_tasks=pending_tasks,
pending_tasks=parsed_tasks,
total_pending_tasks=total_pending_tasks,
paused=TaskRunner.instance.paused,
)
@app.get("/agent-scheduler/v1/history", response_model=HistoryResponse)
def history_api(status: str = None, limit: int = 20, offset: int = 0):
bookmarked = True if status == "bookmarked" else None
if not status or status == "all" or bookmarked:
status = [
TaskStatus.DONE,
TaskStatus.FAILED,
TaskStatus.INTERRUPTED,
]
total = task_manager.count_tasks(status=status)
tasks = task_manager.get_tasks(
status=status,
bookmarked=bookmarked,
limit=limit,
offset=offset,
order="desc",
)
parsed_tasks = []
for task in tasks:
task_args = TaskRunner.instance.parse_task_args(
task.params, task.script_params, deserialization=False
)
named_args = task_args.named_args
named_args["checkpoint"] = task_args.checkpoint
task_data = task.dict()
task_data["params"] = named_args
parsed_tasks.append(TaskModel(**task_data))
return HistoryResponse(
total=total,
tasks=parsed_tasks,
)
@app.post("/agent-scheduler/v1/run/{id}")
def run_task(id: str):
if progress.current_task is not None:
@ -72,10 +148,27 @@ def regsiter_apis(app: App):
return {"success": True, "message": f"Task {id} is executing"}
@app.post("/agent-scheduler/v1/requeue/{id}")
def requeue_task(id: str):
task = task_manager.get_task(id)
if task is None:
return {"success": False, "message": f"Task {id} not found"}
task.id = str(uuid4())
task.result = None
task.status = TaskStatus.PENDING
task.bookmarked = False
task.name = f"Copy of {task.name}" if task.name else None
task_manager.add_task(task)
task_runner.execute_pending_tasks_threading()
return {"success": True, "message": f"Task {id} is requeued"}
@app.post("/agent-scheduler/v1/delete/{id}")
def delete_task(id: str):
if progress.current_task == id:
shared.state.interrupt()
task_runner.interrupted = id
return {"success": True, "message": f"Task {id} is interrupted"}
task_manager.delete_task(id)
@ -101,6 +194,34 @@ def regsiter_apis(app: App):
task_manager.prioritize_task(id, over_task.priority)
return {"success": True, "message": f"Task {id} is moved"}
@app.post("/agent-scheduler/v1/bookmark/{id}")
def pin_task(id: str):
task = task_manager.get_task(id)
if task is None:
return {"success": False, "message": f"Task {id} not found"}
task.bookmarked = True
task_manager.update_task(id, bookmarked=True)
return {"success": True, "message": f"Task {id} is bookmarked"}
@app.post("/agent-scheduler/v1/unbookmark/{id}")
def unpin_task(id: str):
task = task_manager.get_task(id)
if task is None:
return {"success": False, "message": f"Task {id} not found"}
task_manager.update_task(id, bookmarked=False)
return {"success": True, "message": f"Task {id} is unbookmarked"}
@app.post("/agent-scheduler/v1/rename/{id}")
def rename_task(id: str, name: str):
task = task_manager.get_task(id)
if task is None:
return {"success": False, "message": f"Task {id} not found"}
task_manager.update_task(id, name=name)
return {"success": True, "message": f"Task {id} is renamed"}
@app.post("/agent-scheduler/v1/pause")
def pause_queue():
shared.opts.queue_paused = True
@ -114,11 +235,10 @@ def regsiter_apis(app: App):
def on_app_started(block, app: App):
if block is not None:
global task_runner
task_runner = get_instance(block)
global task_runner
task_runner = get_instance(block)
regsiter_apis(app)
regsiter_apis(app)
script_callbacks.on_app_started(on_app_started)

View File

@ -36,6 +36,14 @@ def init():
if not any(col["name"] == "api_task_id" for col in task_columns):
conn.execute(text("ALTER TABLE task ADD COLUMN api_task_id VARCHAR(64)"))
# add name column
if not any(col["name"] == "name" for col in task_columns):
conn.execute(text("ALTER TABLE task ADD COLUMN name VARCHAR(255)"))
# add bookmarked column
if not any(col["name"] == "bookmarked" for col in task_columns):
conn.execute(text("ALTER TABLE task ADD COLUMN bookmarked BOOLEAN DEFAULT FALSE"))
params_column = next(col for col in task_columns if col["name"] == "params")
if version > "1" and not isinstance(params_column["type"], Text):
transaction = conn.begin()

View File

@ -2,7 +2,17 @@ from enum import Enum
from datetime import datetime
from typing import Optional, Union
from sqlalchemy import Column, String, Text, Integer, DateTime, LargeBinary, text, func
from sqlalchemy import (
Column,
String,
Text,
Integer,
DateTime,
LargeBinary,
Boolean,
text,
func,
)
from sqlalchemy.orm import Session
from .base import BaseTableManager, Base
@ -14,21 +24,24 @@ class TaskStatus(str, Enum):
RUNNING = "running"
DONE = "done"
FAILED = "failed"
INTERRUPTED = "interrupted"
class Task(TaskModel):
script_params: bytes = None
params: str
def __init__(
self,
id: str = "",
api_task_id: str = None,
name: str = None,
type: str = "unknown",
params: str = "",
script_params: bytes = b"",
priority: int = None,
status: str = TaskStatus.PENDING.value,
result: str = None,
bookmarked: bool = False,
created_at: Optional[datetime] = None,
updated_at: Optional[datetime] = None,
):
@ -37,24 +50,16 @@ class Task(TaskModel):
super().__init__(
id=id,
api_task_id=api_task_id,
name=name,
type=type,
params=params,
status=status,
priority=priority,
result=result,
bookmarked=bookmarked,
created_at=created_at,
updated_at=created_at,
updated_at=updated_at,
)
self.id: str = id
self.api_task_id: str = api_task_id
self.type: str = type
self.params: str = params
self.script_params: bytes = script_params
self.priority: int = priority
self.status: str = status
self.result: str = result
self.created_at: datetime = created_at
self.updated_at: datetime = updated_at
class Config(TaskModel.__config__):
exclude = ["script_params"]
@ -64,11 +69,13 @@ class Task(TaskModel):
return Task(
id=table.id,
api_task_id=table.api_task_id,
name=table.name,
type=table.type,
params=table.params,
script_params=table.script_params,
priority=table.priority,
status=table.status,
result=table.result,
bookmarked=table.bookmarked,
created_at=table.created_at,
updated_at=table.updated_at,
)
@ -77,11 +84,14 @@ class Task(TaskModel):
return TaskTable(
id=self.id,
api_task_id=self.api_task_id,
name=self.name,
type=self.type,
params=self.params,
script_params=self.script_params,
script_params=b"",
priority=self.priority,
status=self.status,
result=self.result,
bookmarked=self.bookmarked,
)
@ -90,6 +100,7 @@ class TaskTable(Base):
id = Column(String(64), primary_key=True)
api_task_id = Column(String(64), nullable=True)
name = Column(String(255), nullable=True)
type = Column(String(20), nullable=False) # txt2img or img2txt
params = Column(Text, nullable=False) # task args
script_params = Column(LargeBinary, nullable=False) # script args
@ -98,6 +109,7 @@ class TaskTable(Base):
String(20), nullable=False, default="pending"
) # pending, running, done, failed
result = Column(Text) # task result
bookmarked = Column(Boolean, nullable=True, default=False)
created_at = Column(
DateTime,
nullable=False,
@ -130,9 +142,11 @@ class TaskManager(BaseTableManager):
def get_tasks(
self,
type: str = None,
status: str = None,
status: Union[str, list[str]] = None,
bookmarked: bool = None,
limit: int = None,
offset: int = None,
order: str = "asc",
) -> list[TaskTable]:
session = Session(self.engine)
try:
@ -140,11 +154,21 @@ class TaskManager(BaseTableManager):
if type:
query = query.filter(TaskTable.type == type)
if status:
query = query.filter(TaskTable.status == status)
if status is not None:
if isinstance(status, list):
query = query.filter(TaskTable.status.in_(status))
else:
query = query.filter(TaskTable.status == status)
query = query.order_by(TaskTable.priority.asc()).order_by(
TaskTable.created_at.asc()
if bookmarked == True:
query = query.filter(TaskTable.bookmarked == bookmarked)
else:
query = query.order_by(TaskTable.bookmarked.asc())
query = query.order_by(
TaskTable.priority.asc()
if order == "asc"
else TaskTable.priority.desc()
)
if limit:
@ -164,7 +188,7 @@ class TaskManager(BaseTableManager):
def count_tasks(
self,
type: str = None,
status: str = None,
status: Union[str, list[str]] = None,
) -> int:
session = Session(self.engine)
try:
@ -172,8 +196,11 @@ class TaskManager(BaseTableManager):
if type:
query = query.filter(TaskTable.type == type)
if status:
query = query.filter(TaskTable.status == status)
if status is not None:
if isinstance(status, list):
query = query.filter(TaskTable.status.in_(status))
else:
query = query.filter(TaskTable.status == status)
return query.count()
except Exception as e:
@ -195,17 +222,32 @@ class TaskManager(BaseTableManager):
finally:
session.close()
def update_task(self, id: str, status: str, result=None) -> TaskTable:
def update_task(
self,
id: str,
name: str = None,
status: str = None,
result: str = None,
bookmarked: bool = None,
) -> TaskTable:
session = Session(self.engine)
try:
task = session.get(TaskTable, id)
if task:
task.status = status
task.result = result
session.commit()
return task
else:
if task is None:
raise Exception(f"Task with id {id} not found")
if name is not None:
task.name = name
if status is not None:
task.status = status
if result is not None:
task.result = result
if bookmarked is not None:
task.bookmarked = bookmarked
session.commit()
return task
except Exception as e:
print(f"Exception updating task in database: {e}")
raise e
@ -259,11 +301,15 @@ class TaskManager(BaseTableManager):
query = session.query(TaskTable).filter(TaskTable.created_at < before)
if not all:
query = query.filter(
TaskTable.status.in_([TaskStatus.DONE, TaskStatus.FAILED])
)
TaskTable.status.in_(
[TaskStatus.DONE, TaskStatus.FAILED, TaskStatus.INTERRUPTED]
)
).filter(TaskTable.bookmarked == False)
query.delete()
deleted_rows = query.delete()
session.commit()
return deleted_rows
except Exception as e:
print(f"Exception deleting tasks from database: {e}")
raise e

View File

@ -1,12 +1,16 @@
from datetime import datetime, timezone
from typing import Optional, List
from typing import Optional, List, Any, Dict
from pydantic import BaseModel, Field
from modules import sd_samplers
from modules.api.models import (
StableDiffusionTxt2ImgProcessingAPI,
StableDiffusionImg2ImgProcessingAPI,
)
def convert_datetime_to_iso_8601_with_z_suffix(dt: datetime) -> str:
return dt.strftime('%Y-%m-%dT%H:%M:%S.%f')[:-3] + 'Z' if dt else None
return dt.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z" if dt else None
def transform_to_utc_datetime(dt: datetime) -> datetime:
@ -14,20 +18,40 @@ def transform_to_utc_datetime(dt: datetime) -> datetime:
class QueueStatusAPI(BaseModel):
limit: Optional[int] = Field(title="Limit", description="The maximum number of tasks to return", default=20)
offset: Optional[int] = Field(title="Offset", description="The offset of the tasks to return", default=0)
limit: Optional[int] = Field(
title="Limit", description="The maximum number of tasks to return", default=20
)
offset: Optional[int] = Field(
title="Offset", description="The offset of the tasks to return", default=0
)
class TaskModel(BaseModel):
id: str = Field(title="Task Id")
api_task_id: Optional[str] = Field(title="API Task Id", default=None)
name: Optional[str] = Field(title="Task Name")
type: str = Field(title="Task Type", description="Either txt2img or img2img")
status: str = Field(title="Task Status", description="Either pending, running, done or failed")
params: str = Field(title="Task Parameters", description="The parameters of the task in JSON format")
status: str = Field(
title="Task Status", description="Either pending, running, done or failed"
)
params: dict[str, Any] = Field(
title="Task Parameters", description="The parameters of the task in JSON format"
)
priority: int = Field(title="Task Priority")
result: Optional[str] = Field(title="Task Result", description="The result of the task in JSON format")
created_at: Optional[datetime] = Field(title="Task Created At", description="The time when the task was created", default=None)
updated_at: Optional[datetime] = Field(title="Task Updated At", description="The time when the task was updated", default=None)
result: Optional[str] = Field(
title="Task Result", description="The result of the task in JSON format"
)
bookmarked: Optional[bool] = Field(title="Is task bookmarked")
created_at: Optional[datetime] = Field(
title="Task Created At",
description="The time when the task was created",
default=None,
)
updated_at: Optional[datetime] = Field(
title="Task Updated At",
description="The time when the task was updated",
default=None,
)
class Config:
json_encoders = {
@ -36,8 +60,63 @@ class TaskModel(BaseModel):
}
class Txt2ImgApiTaskArgs(StableDiffusionTxt2ImgProcessingAPI):
checkpoint: Optional[str] = Field(
None,
title="Custom checkpoint.",
description="Custom checkpoint hash. If not specified, the latest checkpoint will be used.",
)
sampler_index: Optional[str] = Field(
sd_samplers.samplers[0].name,
title="Sampler name",
alias="sampler_name"
)
class Config(StableDiffusionTxt2ImgProcessingAPI.__config__):
@staticmethod
def schema_extra(schema: Dict[str, Any], model) -> None:
props = schema.get("properties", {})
props.pop("send_images", None)
props.pop("save_images", None)
class Img2ImgApiTaskArgs(StableDiffusionImg2ImgProcessingAPI):
checkpoint: Optional[str] = Field(
None,
title="Custom checkpoint.",
description="Custom checkpoint hash. If not specified, the latest checkpoint will be used.",
)
sampler_index: Optional[str] = Field(
sd_samplers.samplers[0].name,
title="Sampler name",
alias="sampler_name"
)
class Config(StableDiffusionImg2ImgProcessingAPI.__config__):
@staticmethod
def schema_extra(schema: Dict[str, Any], model) -> None:
props = schema.get("properties", {})
props.pop("send_images", None)
props.pop("save_images", None)
class QueueTaskResponse(BaseModel):
task_id: str = Field(title="Task Id")
class QueueStatusResponse(BaseModel):
current_task_id: Optional[str] = Field(title="Current Task Id", description="The on progress task id")
pending_tasks: List[TaskModel] = Field(title="Pending Tasks", description="The pending tasks in the queue")
total_pending_tasks: int = Field(title="Queue length", description="The total pending tasks in the queue")
current_task_id: Optional[str] = Field(
title="Current Task Id", description="The on progress task id"
)
pending_tasks: List[TaskModel] = Field(
title="Pending Tasks", description="The pending tasks in the queue"
)
total_pending_tasks: int = Field(
title="Queue length", description="The total pending tasks in the queue"
)
paused: bool = Field(title="Paused", description="Whether the queue is paused")
class HistoryResponse(BaseModel):
tasks: List[TaskModel] = Field(title="Tasks")
total: int = Field(title="Task count")

View File

@ -193,12 +193,43 @@ def map_ui_task_args_list_to_named_args(
override_settings_texts.append("Model hash: " + checkpoint)
named_args["override_settings_texts"] = override_settings_texts
sampler_index = named_args.get("sampler_index", None)
if sampler_index is not None:
sampler_name = sd_samplers.samplers[named_args["sampler_index"]].name
named_args["sampler_name"] = sampler_name
log.debug(f"serialize sampler index: {str(sampler_index)} as {sampler_name}")
return (
named_args,
script_args,
)
def map_named_args_to_ui_task_args_list(
named_args: dict, script_args: list, is_img2img: bool
):
args_name = []
if is_img2img:
args_name = inspect.getfullargspec(img2img).args
else:
args_name = inspect.getfullargspec(txt2img).args
sampler_name = named_args.get("sampler_name", None)
if sampler_name is not None:
available_samplers = (
sd_samplers.samplers_for_img2img if is_img2img else sd_samplers.samplers
)
sampler_index = next(
(i for i, x in enumerate(available_samplers) if x.name == sampler_name), 0
)
named_args["sampler_index"] = sampler_index
args = [named_args.get(name, None) for name in args_name]
args.extend(script_args)
return args
def map_ui_task_args_to_api_task_args(
named_args: dict, script_args: list, is_img2img: bool
):
@ -301,11 +332,12 @@ def map_ui_task_args_to_api_task_args(
return api_task_args
def serialize_api_task_args(params: dict, is_img2img: bool):
# pop out custom params
model_hash = params.pop("model_hash", None)
controlnet_args = params.pop("controlnet_args", None)
def serialize_api_task_args(
params: dict,
is_img2img: bool,
checkpoint: str = None,
controlnet_args: list[dict] = None,
):
args = (
StableDiffusionImg2ImgProcessingAPI(**params)
if is_img2img
@ -315,18 +347,14 @@ def serialize_api_task_args(params: dict, is_img2img: bool):
if args.override_settings is None:
args.override_settings = {}
if model_hash is None:
model_hash = args.override_settings.get("sd_model_checkpoint", None)
if model_hash is None:
log.error("[AgentScheduler] API task must supply model hash")
return
checkpoint: CheckpointInfo = get_closet_checkpoint_match(model_hash)
if not checkpoint:
log.warn(f"[AgentScheduler] No checkpoint found for model hash {model_hash}")
return
args.override_settings["sd_model_checkpoint"] = checkpoint.title
if checkpoint is not None:
checkpoint_info: CheckpointInfo = get_closet_checkpoint_match(checkpoint)
if not checkpoint_info:
log.warn(
f"[AgentScheduler] No checkpoint found for model hash {checkpoint}"
)
return
args.override_settings["sd_model_checkpoint"] = checkpoint_info.title
# load images from url or file if needed
if is_img2img:

View File

@ -1,12 +1,11 @@
import json
import time
import pickle
import traceback
import threading
from pydantic import BaseModel
from datetime import datetime, timedelta
from typing import Any, Callable, Union
from typing import Any, Callable, Union, Optional
from fastapi import FastAPI
from modules import progress, shared, script_callbacks
@ -27,15 +26,24 @@ from scripts.task_helpers import (
serialize_controlnet_args,
deserialize_controlnet_args,
map_ui_task_args_list_to_named_args,
map_named_args_to_ui_task_args_list,
)
task_history_retenion_map = {
"7 days": 7,
"14 days": 14,
"30 days": 30,
"90 days": 90,
"Keep forever": 0,
}
class ParsedTaskArgs(BaseModel):
args: list[Any]
is_ui: bool
ui_args: list[Any]
named_args: dict[str, Any]
script_args: list[Any]
checkpoint: str
is_ui: bool
checkpoint: Optional[str] = None
class TaskRunner:
@ -48,7 +56,7 @@ class TaskRunner:
self.__current_thread: threading.Thread = None
self.__api = Api(FastAPI(), queue_lock)
self.__saved_images_path: list[str] = []
self.__saved_images_path: list[tuple[str, str]] = []
script_callbacks.on_image_saved(self.__on_image_saved)
self.script_callbacks = {
@ -60,6 +68,7 @@ class TaskRunner:
# Mark this to True when reload UI
self.dispose = False
self.interrupted = None
if TaskRunner.instance is not None:
raise Exception("TaskRunner instance already exists")
@ -137,7 +146,7 @@ class TaskRunner:
pass
def parse_task_args(
self, params: str, script_params: bytes, deserialization: bool = True
self, params: str, script_params: bytes = None, deserialization: bool = True
):
parsed: dict[str, Any] = json.loads(params)
@ -145,25 +154,25 @@ class TaskRunner:
is_img2img = parsed.get("is_img2img", None)
checkpoint = parsed.get("checkpoint", None)
named_args: dict[str, Any] = parsed["args"]
script_args: list[Any] = (
parsed["script_args"]
if "script_args" in parsed
else pickle.loads(script_params)
)
script_args: list[Any] = parsed.get("script_args", [])
if is_ui and deserialization:
self.__deserialize_ui_task_args(is_img2img, named_args, script_args)
elif deserialization:
self.__deserialize_api_task_args(is_img2img, named_args)
args = list(named_args.values()) + script_args
ui_args = (
map_named_args_to_ui_task_args_list(named_args, script_args, is_img2img)
if is_ui
else []
)
return ParsedTaskArgs(
args=args,
is_ui=is_ui,
ui_args=ui_args,
named_args=named_args,
script_args=script_args,
checkpoint=checkpoint,
is_ui=is_ui,
)
def register_ui_task(
@ -220,39 +229,54 @@ class TaskRunner:
"api_task_id": task.api_task_id,
}
self.interrupted = None
self.__saved_images_path = []
self.__run_callbacks("task_started", task_id, **task_meta)
res = self.__execute_task(task_id, is_img2img, task_args)
if not res or isinstance(res, Exception):
task_manager.update_task(id=task_id, status=TaskStatus.FAILED)
task_manager.update_task(
id=task_id,
status=TaskStatus.FAILED,
result=str(res) if res else None,
)
self.__run_callbacks(
"task_finished", task_id, status=TaskStatus.FAILED, **task_meta
)
else:
res = json.loads(res)
log.info(f"\n[AgentScheduler] Task {task.id} done")
infotexts = []
for line in res["infotexts"]:
infotexts.extend(line.split("\n"))
infotexts[0] = f"Prompt: {infotexts[0]}"
log.info("\n".join(["** " + text for text in infotexts]))
is_interrupted = self.interrupted == task_id
if is_interrupted:
log.info(f"\n[AgentScheduler] Task {task.id} interrupted")
task_manager.update_task(
id=task_id,
status=TaskStatus.INTERRUPTED,
)
self.__run_callbacks(
"task_finished",
task_id,
status=TaskStatus.INTERRUPTED,
**task_meta,
)
else:
result = {
"images": [],
"infotexts": [],
}
for filename, pnginfo in self.__saved_images_path:
result["images"].append(filename)
result["infotexts"].append(pnginfo)
result = {
"images": self.__saved_images_path.copy(),
"infotexts": infotexts,
}
task_manager.update_task(
id=task_id,
status=TaskStatus.DONE,
result=json.dumps(result),
)
self.__run_callbacks(
"task_finished",
task_id,
status=TaskStatus.DONE,
result=result,
**task_meta,
)
task_manager.update_task(
id=task_id,
status=TaskStatus.DONE,
result=json.dumps(result),
)
self.__run_callbacks(
"task_finished",
task_id,
status=TaskStatus.DONE,
result=result,
**task_meta,
)
self.__saved_images_path = []
else:
@ -285,22 +309,9 @@ class TaskRunner:
self.__current_thread.daemon = True
self.__current_thread.start()
def get_task_info(self, task: Task) -> list[Any]:
task_args = self.parse_task_args(
task.params,
task.script_params,
)
return [
task.id,
task.type,
json.dumps(task_args.named_args),
task.created_at.strftime("%Y-%m-%d %H:%M:%S"),
]
def __execute_task(self, task_id: str, is_img2img: bool, task_args: ParsedTaskArgs):
if task_args.is_ui:
return self.__execute_ui_task(task_id, is_img2img, *task_args.args)
return self.__execute_ui_task(task_id, is_img2img, *task_args.ui_args)
else:
return self.__execute_api_task(
task_id,
@ -360,9 +371,25 @@ class TaskRunner:
if self.paused:
log.info("[AgentScheduler] Runner is paused")
return None
# delete task that are 7 days old
task_manager.delete_tasks_before(datetime.now() - timedelta(days=7))
# delete task that are too old
retention_days = 30
if (
shared.opts.queue_history_retention_days
and shared.opts.queue_history_retention_days in task_history_retenion_map
):
retention_days = task_history_retenion_map[
shared.opts.queue_history_retention_days
]
if retention_days > 0:
deleted_rows = task_manager.delete_tasks_before(
datetime.now() - timedelta(days=retention_days)
)
if deleted_rows > 0:
log.debug(
f"[AgentScheduler] Deleted {deleted_rows} tasks older than {retention_days} days"
)
self.__total_pending_tasks = task_manager.count_tasks(status="pending")
@ -379,7 +406,7 @@ class TaskRunner:
self.__run_callbacks("task_cleared")
def __on_image_saved(self, data: script_callbacks.ImageSaveParams):
self.__saved_images_path.append(data.filename)
self.__saved_images_path.append((data.filename, data.pnginfo["parameters"]))
def on_task_registered(self, callback: Callable):
"""Callback when a task is registered

View File

@ -1,12 +1,20 @@
import os
import json
import gradio as gr
from PIL import Image
from modules import shared, script_callbacks, scripts
from modules.shared import list_checkpoint_tiles, refresh_checkpoints
from modules.ui import create_refresh_button
from modules.generation_parameters_copypaste import (
create_buttons,
register_paste_params_button,
ParamBinding,
)
from scripts.task_runner import TaskRunner, get_instance
from scripts.helpers import compare_components_with_ids, get_components_by_ids
from scripts.db import init
from scripts.task_runner import TaskRunner, get_instance, task_history_retenion_map
from scripts.helpers import log, compare_components_with_ids, get_components_by_ids
from scripts.db import init, task_manager, TaskStatus
task_runner: TaskRunner = None
initialized = False
@ -17,6 +25,8 @@ checkpoint_runtime = "Runtime Checkpoint"
placement_under_generate = "Under Generate button"
placement_between_prompt_and_generate = "Between Prompt and Generate button"
task_filter_choices = ["All", "Bookmarked", "Done", "Failed", "Interrupted"]
class Script(scripts.Script):
def __init__(self):
@ -42,7 +52,7 @@ class Script(scripts.Script):
def on_app_started(self, block):
if self.generate_button is not None:
self.add_enqueue_button(block, self.generate_button)
self.add_enqueue_button(block, self.generate_button)
def add_enqueue_button(self, root: gr.Blocks, generate: gr.Button):
is_img2img = self.is_img2img
@ -121,7 +131,7 @@ class Script(scripts.Script):
row.parent = parent
parent.children.insert(1, row)
else:
# insert after the tools div
# insert after the generate button
parent = generate.parent.parent
parent.children.insert(1, row)
@ -170,6 +180,45 @@ def get_checkpoint_choices():
return choices
def get_task_results(task_id: str, image_idx: int = None):
task = task_manager.get_task(task_id)
galerry = None
infotexts = None
if task is None:
pass
elif task.status != TaskStatus.DONE:
infotexts = f"Status: {task.status}"
if task.status == TaskStatus.FAILED and task.result:
infotexts += f"\nError: {task.result}"
elif task.status == TaskStatus.DONE:
try:
result: dict = json.loads(task.result)
images = result.get("images", [])
infos = result.get("infotexts", [])
galerry = (
[Image.open(i) for i in images if os.path.exists(i)]
if image_idx is None
else gr.update()
)
idx = image_idx if image_idx is not None else 0
if len(infos) == len(images):
infotexts = infos[idx]
else:
infotexts = "\n".join(infos).split("Prompt: ")[1:][idx]
except Exception as e:
log.error(f"[AgentScheduler] Failed to load task result")
log.error(e)
infotexts = f"Failed to load task result: {str(e)}"
res = (
gr.Textbox.update(infotexts, visible=infotexts is not None),
gr.Row.update(visible=galerry is not None),
)
return res if image_idx is not None else (galerry,) + res
def on_ui_tab(**_kwargs):
global initialized
if not initialized:
@ -177,46 +226,127 @@ def on_ui_tab(**_kwargs):
init()
with gr.Blocks(analytics_enabled=False) as scheduler_tab:
gr.Textbox(
shared.opts.queue_button_placement,
elem_id="agent_scheduler_queue_button_placement",
show_label=False,
visible=False,
interactive=False,
)
with gr.Row(elem_id="agent_scheduler_pending_tasks_wrapper"):
with gr.Column(scale=1):
with gr.Group(elem_id="agent_scheduler_actions"):
paused = shared.opts.queue_paused
with gr.Tabs(elem_id="agent_scheduler_tabs"):
with gr.Tab(
"Task Queue", id=0, elem_id="agent_scheduler_pending_tasks_tab"
):
with gr.Row(elem_id="agent_scheduler_pending_tasks_wrapper"):
with gr.Column(scale=1):
with gr.Group(elem_id="agent_scheduler_pending_tasks_actions"):
paused = shared.opts.queue_paused
gr.Button(
"Pause",
elem_id="agent_scheduler_action_pause",
variant="stop",
visible=not paused,
gr.Button(
"Pause",
elem_id="agent_scheduler_action_pause",
variant="stop",
visible=not paused,
)
gr.Button(
"Resume",
elem_id="agent_scheduler_action_resume",
variant="primary",
visible=paused,
)
gr.Button(
"Refresh",
elem_id="agent_scheduler_action_refresh",
elem_classes="agent_scheduler_action_refresh",
variant="secondary",
)
gr.HTML('<div id="agent_scheduler_action_search"></div>')
gr.HTML(
'<div id="agent_scheduler_pending_tasks_grid" class="ag-theme-alpine"></div>'
)
with gr.Column(scale=1):
gr.Gallery(
elem_id="agent_scheduler_current_task_images",
label="Output",
show_label=False,
).style(columns=2, object_fit="contain")
with gr.Tab("Task History", id=1, elem_id="agent_scheduler_history_tab"):
with gr.Row(elem_id="agent_scheduler_history_wrapper"):
with gr.Column(scale=1):
with gr.Group(elem_id="agent_scheduler_history_actions"):
gr.Button(
"Refresh",
elem_id="agent_scheduler_action_refresh_history",
elem_classes="agent_scheduler_action_refresh",
variant="secondary",
)
status = gr.Dropdown(
elem_id="agent_scheduler_status_filter",
choices=task_filter_choices,
value="All",
show_label=False,
)
gr.HTML(
'<div id="agent_scheduler_action_search_history"></div>'
)
gr.HTML(
'<div id="agent_scheduler_history_tasks_grid" class="ag-theme-alpine"></div>'
)
with gr.Column(scale=1, elem_id="agent_scheduler_history_results"):
galerry = gr.Gallery(
elem_id="agent_scheduler_history_gallery",
label="Output",
show_label=False,
).style(columns=2, object_fit="contain", preview=True)
gen_info = gr.Textbox(
label="Generation Info",
elem_id=f"agent_scheduler_history_gen_info",
interactive=False,
visible=True,
lines=3,
)
with gr.Row(
elem_id="agent_scheduler_history_result_actions",
visible=False,
) as result_actions:
try:
send_to_buttons = create_buttons(
["txt2img", "img2img", "inpaint", "extras"]
)
except:
pass
selected_task = gr.Textbox(
elem_id="agent_scheduler_history_selected_task",
visible=False,
show_label=False,
)
selected_task_id = gr.Textbox(
elem_id="agent_scheduler_history_selected_image",
visible=False,
show_label=False,
)
# register event handlers
status.change(
fn=lambda x: None,
_js="agent_scheduler_status_filter_changed",
inputs=[status],
)
selected_task.change(
fn=get_task_results,
inputs=[selected_task],
outputs=[galerry, gen_info, result_actions],
)
selected_task_id.change(
fn=lambda x, y: get_task_results(x, image_idx=int(y)),
inputs=[selected_task, selected_task_id],
outputs=[gen_info, result_actions],
)
try:
for paste_tabname, paste_button in send_to_buttons.items():
register_paste_params_button(
ParamBinding(
paste_button=paste_button,
tabname=paste_tabname,
source_text_component=gen_info,
source_image_component=galerry,
)
gr.Button(
"Resume",
elem_id="agent_scheduler_action_resume",
variant="primary",
visible=paused,
)
gr.Button(
"Refresh",
elem_id="agent_scheduler_action_refresh",
variant="secondary",
)
gr.HTML('<div id="agent_scheduler_action_search"></div>')
gr.HTML(
'<div id="agent_scheduler_pending_tasks_grid" class="ag-theme-alpine"></div>'
)
with gr.Column(scale=1):
with gr.Group(elem_id="agent_scheduler_current_task_progress"):
gr.Gallery(
elem_id="agent_scheduler_current_task_images",
label="Output",
show_label=False,
).style(grid=4)
except:
pass
return [(scheduler_tab, "Agent Scheduler", "agent_scheduler")]
@ -227,7 +357,7 @@ def on_ui_settings():
"queue_paused",
shared.OptionInfo(
False,
"Disable queue auto processing",
"Disable queue auto-processing",
gr.Checkbox,
{"interactive": True},
section=section,
@ -258,6 +388,18 @@ def on_ui_settings():
section=section,
),
)
shared.opts.add_option(
"queue_history_retention_days",
shared.OptionInfo(
"30 days",
"Auto delete queue history (bookmarked tasks excluded)",
gr.Radio,
lambda: {
"choices": list(task_history_retenion_map.keys()),
},
section=section,
),
)
def on_app_started(block, _):

File diff suppressed because one or more lines are too long

View File

@ -14,7 +14,7 @@
"notyf": "^3.10.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"rxjs": "^7.8.1"
"zustand": "^4.3.8"
},
"devDependencies": {
"@types/node": "^18",
@ -28,6 +28,7 @@
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.3.4",
"postcss": "^8.4.24",
"sass": "^1.62.1",
"tailwindcss": "^3.3.2",
"typescript": "^5.0.2",
"vite": "^4.3.9"

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M15 3a3 3 0 0 1 2.995 2.824l.005 .176v14a1 1 0 0 1 -1.413 .911l-.101 -.054l-4.487 -2.691l-4.485 2.691a1 1 0 0 1 -1.508 -.743l-.006 -.114v-14a3 3 0 0 1 2.824 -2.995l.176 -.005h6z" stroke-width="0" fill="currentColor"></path>
</svg>

After

Width:  |  Height:  |  Size: 485 B

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M9 4h6a2 2 0 0 1 2 2v14l-5 -3l-5 3v-14a2 2 0 0 1 2 -2"></path>
</svg>

After

Width:  |  Height:  |  Size: 324 B

View File

@ -0,0 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M18 6l-12 12"/>
<path d="M6 6l12 12"/>
</svg>

After

Width:  |  Height:  |  Size: 259 B

View File

@ -0,0 +1,8 @@
<svg width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M4 7l16 0"/>
<path d="M10 11l0 6"/>
<path d="M14 11l0 6"/>
<path d="M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2 -2l1 -12"/>
<path d="M9 7v-3a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v3"/>
</svg>

After

Width:  |  Height:  |  Size: 397 B

View File

@ -0,0 +1,13 @@
<svg
width="24"
height="24"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M7 4v16l13 -8z" />
</svg>

After

Width:  |  Height:  |  Size: 256 B

View File

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M9 4.55a8 8 0 0 1 6 14.9m0 -4.45v5h5"></path>
<path d="M5.63 7.16l0 .01"></path>
<path d="M4.06 11l0 .01"></path>
<path d="M4.63 15.1l0 .01"></path>
<path d="M7.16 18.37l0 .01"></path>
<path d="M11 19.94l0 .01"></path>
</svg>

After

Width:  |  Height:  |  Size: 495 B

View File

@ -0,0 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M10 10m-7 0a7 7 0 1 0 14 0a7 7 0 1 0 -14 0"/>
<path d="M21 21l-6 -6"/>
</svg>

After

Width:  |  Height:  |  Size: 285 B

170
ui/src/extension/index.scss Normal file
View File

@ -0,0 +1,170 @@
@import './tailwind.css';
/* ========================================================================= */
.ag-theme-alpine,
.ag-theme-alpine-dark {
--ag-row-height: 45px;
--ag-header-height: 45px;
--ag-cell-horizontal-padding: calc(var(--ag-grid-size) * 2);
--body-text-color: 'inherit';
}
.cell-span {
border-bottom-color: var(--ag-border-color);
}
.cell-not-span {
opacity: 0;
}
.ag-row-hover .ag-cell {
background-color: transparent;
}
.ag-cell {
.ag-input-field-input {
background-color: var(--input-background-fill);;
.dark & {
background-color: var(--input-background-fill);
}
}
}
.notyf {
font-family: var(--font);
}
/* ========================================================================= */
#agent_scheduler_pending_tasks_wrapper,
#agent_scheduler_history_wrapper {
border: none;
border-width: 0;
box-shadow: none;
justify-content: flex-end;
gap: var(--layout-gap);
padding: 0;
@media (max-width: 1024px) {
flex-wrap: wrap;
}
> div:last-child {
width: 100%;
@media (min-width: 1280px) {
min-width: 400px !important;
max-width: min(25%, 700px);
}
}
> button {
flex: 0 0 auto;
}
}
#agent_scheduler_pending_tasks_wrapper {
.livePreview {
margin: 0;
padding-top: 100%;
img {
top: 0;
border-radius: 5px;
}
}
.progressDiv {
height: 42px;
line-height: 42px;
max-width: 100%;
text-align: center;
position: static;
font-size: var(--button-large-text-size);
font-weight: var(--button-large-text-weight);
.progress {
height: 42px;
line-height: 42px;
}
+ .livePreview {
margin-top: calc(40px + var(--layout-gap));
}
}
}
#agent_scheduler_current_task_images,
#agent_scheduler_history_gallery {
width: 100%;
padding-top: calc(100%);
position: relative;
box-sizing: content-box;
> div {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
}
#agent_scheduler_history_gallery {
@media screen and (min-width: 1280px) {
.fixed-height {
min-height: 400px;
}
}
}
#agent_scheduler_pending_tasks_actions,
#agent_scheduler_history_actions {
display: flex;
gap: var(--layout-gap);
> button {
border-radius: var(--radius-lg) !important;
}
}
#agent_scheduler_history_actions {
.form {
width: var(--size-32);
margin-left: auto;
}
.gradio-html {
width: var(--size-64);
}
}
#txt2img_enqueue_wrapper,
#img2img_enqueue_wrapper {
min-width: 210px;
display: flex;
flex-direction: column;
gap: calc(var(--layout-gap) / 2);
> div:first-child {
flex-direction: row;
flex-wrap: nowrap;
align-items: flex-start;
flex: 0 0 auto;
}
.gradio-button,
.gradio-dropdown .wrap-inner {
min-height: 36px;
max-height: 42px;
}
}
#img2img_toprow .interrogate-col.has-queue-button {
min-width: unset !important;
flex-direction: row !important;
gap: calc(var(--layout-gap) / 2) !important;
button {
margin: 0;
}
}

View File

@ -1,12 +1,27 @@
import * as rxjs from 'rxjs';
import type { Observer } from 'rxjs';
import { Grid, GridOptions } from 'ag-grid-community';
import { Notyf } from 'notyf';
import 'ag-grid-community/styles/ag-grid.css';
import 'ag-grid-community/styles/ag-theme-alpine.css';
import 'notyf/notyf.min.css';
import './index.css';
import './index.scss';
import { createPendingTasksStore } from './stores/pending.store';
import { ProgressResponse, ResponseStatus, Task, TaskStatus } from './types';
import { debounce } from '../utils/debounce';
import { extractArgs } from '../utils/extract-args';
import { createHistoryTasksStore } from './stores/history.store';
import { createSharedStore } from './stores/shared.store';
import deleteIcon from '../assets/icons/delete.svg?raw';
import cancelIcon from '../assets/icons/cancel.svg?raw';
import searchIcon from '../assets/icons/search.svg?raw';
import playIcon from '../assets/icons/play.svg?raw';
import rotateIcon from '../assets/icons/rotate.svg?raw';
import bookmark from '../assets/icons/bookmark.svg?raw';
import bookmarked from '../assets/icons/bookmark-filled.svg?raw';
const notyf = new Notyf();
declare global {
var country: string;
@ -18,127 +33,252 @@ declare global {
id: string,
progressContainer: HTMLElement,
imagesContainer: HTMLElement,
onDone: () => void,
onDone?: () => void,
onProgress?: (res: ProgressResponse) => void,
): void;
function onUiLoaded(callback: () => void): void;
function submit_enqueue(): any[];
function submit_enqueue_img2img(): any[];
function agent_scheduler_status_filter_changed(value: string): void;
}
type Task = {
id: string;
api_task_id: string;
type: string;
status: string;
params: Record<string, any>;
priority: number;
result: string;
const sharedStore = createSharedStore({
selectedTab: 'pending',
});
const pendingStore = createPendingTasksStore({
current_task_id: null,
total_pending_tasks: 0,
pending_tasks: [],
paused: false,
});
const historyStore = createHistoryTasksStore({
total: 0,
tasks: [],
});
const sharedGridOptions: GridOptions<Task> = {
// default col def properties get applied to all columns
defaultColDef: {
sortable: false,
filter: true,
resizable: true,
suppressMenu: true,
},
// each entry here represents one column
columnDefs: [
{
field: 'name',
headerName: 'Task Id',
minWidth: 240,
maxWidth: 240,
pinned: 'left',
rowDrag: true,
editable: true,
valueGetter: ({ data }) => data?.name ?? data?.id,
cellClass: ({ data }) => [
'cursor-pointer',
data?.status === 'pending' ? 'task-pending' : '',
data?.status === 'running' ? 'task-running' : '',
data?.status === 'done' ? 'task-done' : '',
data?.status === 'failed' ? 'task-failed' : '',
data?.status === 'interrupted' ? 'task-interrupted' : '',
],
},
{
field: 'type',
headerName: 'Type',
minWidth: 80,
maxWidth: 80,
},
{
headerName: 'Params',
children: [
{
field: 'params.prompt',
headerName: 'Prompt',
minWidth: 400,
autoHeight: true,
wrapText: true,
cellStyle: { 'line-height': '24px', 'padding-top': '8px', 'padding-bottom': '8px' },
},
{
field: 'params.negative_prompt',
headerName: 'Negative Prompt',
minWidth: 400,
autoHeight: true,
wrapText: true,
cellStyle: { 'line-height': '24px', 'padding-top': '8px', 'padding-bottom': '8px' },
},
{
field: 'params.checkpoint',
headerName: 'Checkpoint',
minWidth: 150,
maxWidth: 300,
valueFormatter: ({ value }) => value || 'System',
},
{
field: 'params.sampler_name',
headerName: 'Sampler',
width: 150,
minWidth: 150,
},
{
field: 'params.steps',
headerName: 'Steps',
minWidth: 80,
maxWidth: 80,
filter: 'agNumberColumnFilter',
},
{
field: 'params.cfg_scale',
headerName: 'CFG Scale',
width: 100,
minWidth: 100,
filter: 'agNumberColumnFilter',
},
{
field: 'params.size',
headerName: 'Size',
minWidth: 110,
maxWidth: 110,
valueGetter: ({ data }) => (data ? `${data.params.width}x${data.params.height}` : ''),
},
{
field: 'params.batch',
headerName: 'Batching',
minWidth: 100,
maxWidth: 100,
valueGetter: ({ data }) =>
data ? `${data.params.n_iter}x${data.params.batch_size}` : '1x1',
},
],
},
{ field: 'created_at', headerName: 'Date', minWidth: 200 },
],
getRowId: ({ data }) => data.id,
rowSelection: 'single', // allow rows to be selected
animateRows: true, // have rows animate to new positions when sorted
pagination: true,
paginationAutoPageSize: true,
suppressCopyRowsToClipboard: true,
suppressRowTransform: true,
enableBrowserTooltips: true,
readOnlyEdit: true,
onCellEditRequest: ({ data, newValue, api, colDef }) => {
if (colDef.field !== 'name') return;
if (!newValue) return;
api.showLoadingOverlay();
historyStore.renameTask(data.id, newValue).then((res) => {
notify(res);
const newData = { ...data, name: newValue };
const tx = {
update: [newData],
};
api.applyTransaction(tx);
api.hideOverlay();
});
},
};
type AppState = {
current_task_id: string | null;
total_pending_tasks: number;
pending_tasks: Task[];
paused: boolean;
};
function initSearchInput(selector: string) {
const searchContainer = gradioApp().querySelector(selector);
if (!searchContainer) {
throw new Error(`search container ${selector} not found`);
}
function initTaskScheduler() {
const notyf = new Notyf();
const subject = new rxjs.Subject<AppState>();
searchContainer.className = 'ts-search';
searchContainer.innerHTML = `
<div class="ts-search-icon">
${searchIcon}
</div>
<input type="text" class="ts-search-input" placeholder="Search" required>
`;
const store = {
subject,
subscribe: (callback: Partial<Observer<[AppState, AppState]>>) => {
return store.subject.pipe(rxjs.pairwise()).subscribe(callback);
},
refresh: async () => {
return fetch('/agent-scheduler/v1/queue?limit=1000')
.then((response) => response.json())
.then((data: AppState) => {
const pending_tasks = data.pending_tasks.map((item) => ({
...item,
params: JSON.parse(item.params as any),
status: item.id === data.current_task_id ? 'running' : 'pending',
}));
store.subject.next({
...data,
pending_tasks,
});
});
},
pauseQueue: async () => {
return fetch('/agent-scheduler/v1/pause', { method: 'POST' })
.then((response) => response.json())
.then((data) => {
if (data.success) {
notyf.success(data.message);
} else {
notyf.error(data.message);
}
return searchContainer;
}
return store.refresh();
});
},
resumeQueue: async () => {
return fetch('/agent-scheduler/v1/resume', { method: 'POST' })
.then((response) => response.json())
.then((data) => {
if (data.success) {
notyf.success(data.message);
} else {
notyf.error(data.message);
}
function notify(response: ResponseStatus) {
if (response.success) {
notyf.success(response.message);
} else {
notyf.error(response.message);
}
}
return store.refresh();
});
},
runTask: async (id: string) => {
return fetch(`/agent-scheduler/v1/run/${id}`, { method: 'POST' })
.then((response) => response.json())
.then((data) => {
if (data.success) {
notyf.success(data.message);
} else {
notyf.error(data.message);
}
function showTaskProgress(task_id: string, callback: () => void) {
const args = extractArgs(requestProgress);
return store.refresh();
});
},
deleteTask: async (id: string) => {
return fetch(`/agent-scheduler/v1/delete/${id}`, { method: 'POST' })
.then((response) => response.json())
.then((data) => {
if (data.success) {
notyf.success(data.message);
} else {
notyf.error(data.message);
}
const gallery: HTMLDivElement = gradioApp().querySelector(
'#agent_scheduler_current_task_images',
)!;
return store.refresh();
});
},
moveTask: async (id: string, overId: string) => {
return fetch(`/agent-scheduler/v1/move/${id}/${overId}`, { method: 'POST' })
.then((response) => response.json())
.then((data) => {
if (data.success) {
notyf.success(data.message);
} else {
notyf.error(data.message);
}
// A1111 version
if (args.includes('progressbarContainer')) {
requestProgress(task_id, gallery, gallery, callback);
} else {
// Vlad version
const progressDiv = document.createElement('div');
progressDiv.className = 'progressDiv';
gallery.parentNode?.insertBefore(progressDiv, gallery);
requestProgress(
task_id,
gallery,
gallery,
() => {
gallery.parentNode?.removeChild(progressDiv);
callback();
},
(res) => {
if (!res) return;
const perc = res ? `${Math.round((res?.progress || 0) * 100.0)}%` : '';
const eta = res?.paused ? ' Paused' : ` ETA: ${Math.round(res?.eta || 0)}s`;
progressDiv.innerText = `${perc}${eta}`;
progressDiv.style.background = res
? `linear-gradient(to right, var(--primary-500) 0%, var(--primary-800) ${perc}, var(--neutral-700) ${perc})`
: 'var(--button-primary-background-fill)';
},
);
}
}
return store.refresh();
});
},
};
function initTabChangeHandler() {
// watch for tab activation
const observer = new MutationObserver(function (mutationsList) {
mutationsList.forEach((styleChange) => {
const tab = styleChange.target as HTMLElement;
const visible = tab.style.display === 'block';
if (!visible) return;
store.subject.next({
current_task_id: null,
total_pending_tasks: 0,
pending_tasks: [],
paused: false,
if (tab.id === 'tab_agent_scheduler') {
if (sharedStore.getState().selectedTab === 'pending') {
pendingStore.refresh();
} else {
historyStore.refresh();
}
} else if (tab.id === 'agent_scheduler_pending_tasks_tab') {
sharedStore.selectSelectedTab('pending');
pendingStore.refresh();
} else if (tab.id === 'agent_scheduler_history_tab') {
sharedStore.selectSelectedTab('history');
historyStore.refresh();
}
});
});
observer.observe(document.getElementById('tab_agent_scheduler')!, { attributeFilter: ['style'] });
observer.observe(document.getElementById('agent_scheduler_pending_tasks_tab')!, {
attributeFilter: ['style'],
});
observer.observe(document.getElementById('agent_scheduler_history_tab')!, {
attributeFilter: ['style'],
});
}
function initPendingTab() {
const store = pendingStore;
window.submit_enqueue = function submit_enqueue() {
var id = randomId();
@ -150,7 +290,6 @@ function initTaskScheduler() {
btnEnqueue.innerHTML = 'Queued';
setTimeout(() => {
btnEnqueue.innerHTML = 'Enqueue';
store.refresh();
}, 1000);
}
@ -168,7 +307,6 @@ function initTaskScheduler() {
btnEnqueue.innerHTML = 'Queued';
setTimeout(() => {
btnEnqueue.innerHTML = 'Enqueue';
store.refresh();
}, 1000);
}
@ -181,183 +319,44 @@ function initTaskScheduler() {
interrogateCol.classList.add('has-queue-button');
}
// watch for tab activation
const observer = new MutationObserver(function (mutationsList) {
const styleChange = mutationsList.find((mutation) => mutation.attributeName === 'style');
if (styleChange) {
const tab = styleChange.target as HTMLElement;
if (tab.style.display === 'block') {
store.refresh();
}
}
});
observer.observe(document.getElementById('tab_agent_scheduler')!, { attributes: true });
// init actions
const refreshButton = gradioApp().querySelector('#agent_scheduler_action_refresh')!;
const pauseButton = gradioApp().querySelector('#agent_scheduler_action_pause')!;
const resumeButton = gradioApp().querySelector('#agent_scheduler_action_resume')!;
refreshButton.addEventListener('click', store.refresh);
pauseButton.addEventListener('click', store.pauseQueue);
resumeButton.addEventListener('click', store.resumeQueue);
const searchContainer = gradioApp().querySelector('#agent_scheduler_action_search')!;
searchContainer.className = 'ts-search';
searchContainer.innerHTML = `
<div class="ts-search-icon">
<svg width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M10 10m-7 0a7 7 0 1 0 14 0a7 7 0 1 0 -14 0"/>
<path d="M21 21l-6 -6"/>
</svg>
</div>
<input type="text" id="agent_scheduler_search_input" class="ts-search-input" placeholder="Search" required>
`;
pauseButton.addEventListener('click', () => store.pauseQueue().then(notify));
resumeButton.addEventListener('click', () => store.resumeQueue().then(notify));
// watch for current task id change
const onTaskIdChange = (id: string | null) => {
if (id) {
requestProgress(
id,
gradioApp().querySelector('#agent_scheduler_current_task_progress')!,
gradioApp().querySelector('#agent_scheduler_current_task_images')!,
() => {
setTimeout(() => {
store.refresh();
}, 1000);
},
);
showTaskProgress(id, store.refresh);
}
};
store.subscribe({
next: ([prev, curr]) => {
if (prev.current_task_id !== curr.current_task_id) {
onTaskIdChange(curr.current_task_id);
}
if (curr.paused) {
pauseButton.classList.add('hide');
resumeButton.classList.remove('hide');
} else {
pauseButton.classList.remove('hide');
resumeButton.classList.add('hide');
}
},
store.subscribe((curr, prev) => {
if (prev.current_task_id !== curr.current_task_id) {
onTaskIdChange(curr.current_task_id);
}
if (curr.paused) {
pauseButton.classList.add('hide');
resumeButton.classList.remove('hide');
} else {
pauseButton.classList.remove('hide');
resumeButton.classList.add('hide');
}
});
// init grid
const deleteIcon = `
<svg width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M4 7l16 0"/>
<path d="M10 11l0 6"/>
<path d="M14 11l0 6"/>
<path d="M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2 -2l1 -12"/>
<path d="M9 7v-3a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v3"/>
</svg>`;
const cancelIcon = `
<svg width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M18 6l-12 12"/>
<path d="M6 6l12 12"/>
</svg>
`;
const pendingTasksGridOptions: GridOptions<Task> = {
// domLayout: 'autoHeight',
// default col def properties get applied to all columns
defaultColDef: {
sortable: false,
filter: true,
resizable: true,
suppressMenu: true,
},
const gridOptions: GridOptions<Task> = {
...sharedGridOptions,
// each entry here represents one column
columnDefs: [
{
field: 'id',
headerName: 'Task Id',
minWidth: 240,
maxWidth: 240,
pinned: 'left',
rowDrag: true,
cellClass: ({ data }) => [
data?.status === 'running' ? 'task-running' : '',
],
},
{
field: 'type',
headerName: 'Type',
minWidth: 80,
maxWidth: 80,
},
{
field: 'priority',
headerName: 'Priority',
hide: true,
sort: 'asc',
},
{
headerName: 'Params',
children: [
{
field: 'params.prompt',
headerName: 'Prompt',
minWidth: 400,
autoHeight: true,
wrapText: true,
cellStyle: { 'line-height': '24px', 'padding-top': '8px', 'padding-bottom': '8px' },
},
{
field: 'params.negative_prompt',
headerName: 'Negative Prompt',
minWidth: 400,
autoHeight: true,
wrapText: true,
cellStyle: { 'line-height': '24px', 'padding-top': '8px', 'padding-bottom': '8px' },
},
{
field: 'params.checkpoint',
headerName: 'Checkpoint',
minWidth: 150,
maxWidth: 300,
valueFormatter: ({ value }) => value || 'System',
},
{
field: 'params.sampler_name',
headerName: 'Sampler',
width: 150,
minWidth: 150,
},
{
field: 'params.steps',
headerName: 'Steps',
minWidth: 80,
maxWidth: 80,
filter: 'agNumberColumnFilter',
},
{
field: 'params.cfg_scale',
headerName: 'CFG Scale',
width: 100,
minWidth: 100,
filter: 'agNumberColumnFilter',
},
{
field: 'params.size',
headerName: 'Size',
minWidth: 110,
maxWidth: 110,
valueGetter: ({ data }) => (data ? `${data.params.width}x${data.params.height}` : ''),
},
{
field: 'params.batch',
headerName: 'Batching',
minWidth: 100,
maxWidth: 100,
valueGetter: ({ data }) =>
data ? `${data.params.n_iter}x${data.params.batch_size}` : '1x1',
},
],
},
{ field: 'created_at', headerName: 'Date', minWidth: 200 },
...(sharedGridOptions.columnDefs || []),
{
headerName: 'Action',
pinned: 'right',
@ -366,17 +365,16 @@ function initTaskScheduler() {
resizable: false,
valueGetter: ({ data }) => data?.id,
cellRenderer: ({ api, value, data }: any) => {
if (!data) return undefined;
const html = `
<div class="inline-flex rounded-md shadow-sm mt-1.5" role="group">
<button type="button" ${
<button type="button" title="Run" ${
data.status === 'running' ? 'disabled' : ''
} class="ts-btn-action ts-btn-run">
<svg width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M7 4v16l13 -8z"/>
</svg>
${playIcon}
</button>
<button type="button" class="ts-btn-action ts-btn-delete">
<button type="button" title="Delete" class="ts-btn-action ts-btn-delete">
${data.status === 'pending' ? deleteIcon : cancelIcon}
</button>
</div>
@ -388,62 +386,49 @@ function initTaskScheduler() {
const btnRun = node.querySelector('button.ts-btn-run')!;
btnRun.addEventListener('click', () => {
console.log('run', value);
api.showLoadingOverlay();
store.runTask(value).then(() => api.hideOverlay());
});
const btnDelete = node.querySelector('button.ts-btn-delete')!;
btnDelete.addEventListener('click', () => {
console.log('delete', value);
api.showLoadingOverlay();
store.deleteTask(value).then(() => api.hideOverlay());
store.deleteTask(value).then((res) => {
notify(res);
api.applyTransaction({
remove: [data],
});
api.hideOverlay();
});
});
return node;
},
},
],
getRowId: ({ data }) => data.id,
rowData: [],
rowSelection: 'single', // allow rows to be selected
animateRows: true, // have rows animate to new positions when sorted
pagination: true,
paginationPageSize: 10,
suppressCopyRowsToClipboard: true,
suppressRowTransform: true,
suppressRowClickSelection: true,
enableBrowserTooltips: true,
onGridReady: ({ api }) => {
// init quick search input
const searchInput: HTMLInputElement = searchContainer.querySelector(
'input#agent_scheduler_search_input',
)!;
rxjs
.fromEvent(searchInput, 'keyup')
.pipe(rxjs.debounce(() => rxjs.interval(200)))
.subscribe((e) => {
const searchContainer = initSearchInput('#agent_scheduler_action_search');
const searchInput: HTMLInputElement = searchContainer.querySelector('input.ts-search-input')!;
searchInput.addEventListener(
'keyup',
debounce((e: KeyboardEvent) => {
api.setQuickFilter((e.target as HTMLInputElement).value);
});
}, 200),
);
store.subscribe({
next: ([_, newState]) => {
api.setRowData(newState.pending_tasks);
store.subscribe((state) => {
api.setRowData(state.pending_tasks);
if (newState.current_task_id) {
const node = api.getRowNode(newState.current_task_id);
if (node) {
api.refreshCells({ rowNodes: [node], force: true });
}
if (state.current_task_id) {
const node = api.getRowNode(state.current_task_id);
if (node) {
api.refreshCells({ rowNodes: [node], force: true });
}
}
api.sizeColumnsToFit();
},
api.sizeColumnsToFit();
});
// refresh the state
store.refresh();
},
onRowDragEnd: ({ api, node, overNode }) => {
const id = node.data?.id;
@ -461,10 +446,179 @@ function initTaskScheduler() {
if (document.querySelector('.dark')) {
eGridDiv.className = 'ag-theme-alpine-dark';
}
eGridDiv.style.height = 'calc(100vh - 250px)';
new Grid(eGridDiv, pendingTasksGridOptions);
store.refresh();
eGridDiv.style.height = 'calc(100vh - 300px)';
new Grid(eGridDiv, gridOptions);
}
onUiLoaded(initTaskScheduler);
function initHistoryTab() {
const store = historyStore;
// init actions
const refreshButton = gradioApp().querySelector('#agent_scheduler_action_refresh_history')!;
refreshButton.addEventListener('click', () => {
store.refresh();
});
const resultTaskId: HTMLTextAreaElement = gradioApp().querySelector(
'#agent_scheduler_history_selected_task textarea',
)!;
const resultImageId: HTMLTextAreaElement = gradioApp().querySelector(
'#agent_scheduler_history_selected_image textarea',
)!;
const resultGallery: HTMLDivElement = gradioApp().querySelector(
'#agent_scheduler_history_gallery',
)!;
resultGallery.addEventListener('click', (e) => {
const target = e.target as HTMLImageElement;
if (target.tagName === 'IMG') {
const imageIdx = Array.prototype.indexOf.call(
target.parentNode?.parentNode?.childNodes ?? [],
target.parentNode,
);
resultImageId.value = imageIdx.toString();
resultImageId.dispatchEvent(new Event('input', { bubbles: true }));
}
});
window.agent_scheduler_status_filter_changed = function (value) {
store.onFilterStatus(value?.toLowerCase() as TaskStatus);
};
// init grid
const gridOptions: GridOptions<Task> = {
...sharedGridOptions,
defaultColDef: {
...sharedGridOptions.defaultColDef,
sortable: true,
},
// each entry here represents one column
columnDefs: [
{
headerName: '',
field: 'bookmarked',
minWidth: 55,
maxWidth: 55,
pinned: 'left',
sort: 'desc',
cellClass: 'cursor-pointer pt-3',
cellRenderer: ({ data, value }: any) => {
if (!data) return undefined;
return value
? `<span class="!text-yellow-400">${bookmarked}</span>`
: `<span class="!text-gray-400">${bookmark}</span>`;
},
onCellClicked: ({ data, event, api }) => {
if (!data) return;
event?.stopPropagation();
event?.preventDefault();
store.bookmarkTask(data.id, !data.bookmarked).then((res) => {
notify(res);
api.applyTransaction({
update: [{ ...data, bookmarked: !data.bookmarked }],
});
});
},
},
{
field: 'priority',
hide: true,
sort: 'desc',
},
{
...(sharedGridOptions.columnDefs || [])[0],
rowDrag: false,
},
...(sharedGridOptions.columnDefs || []).slice(1),
{
headerName: 'Action',
pinned: 'right',
minWidth: 110,
maxWidth: 110,
resizable: false,
valueGetter: ({ data }) => data?.id,
cellRenderer: ({ api, data, value }: any) => {
if (!data) return undefined;
const html = `
<div class="inline-flex rounded-md shadow-sm mt-1.5" role="group">
<button type="button" title="Requeue" class="ts-btn-action ts-btn-run">
${rotateIcon}
</button>
<button type="button" title="Delete" class="ts-btn-action ts-btn-delete">
${deleteIcon}
</button>
</div>
`;
const placeholder = document.createElement('div');
placeholder.innerHTML = html;
const node = placeholder.firstElementChild!;
const btnRun = node.querySelector('button.ts-btn-run')!;
btnRun.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
api.showLoadingOverlay();
pendingStore.requeueTask(value).then((res) => {
notify(res);
api.hideOverlay();
});
});
const btnDelete = node.querySelector('button.ts-btn-delete')!;
btnDelete.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
api.showLoadingOverlay();
pendingStore.deleteTask(value).then((res) => {
notify(res);
api.applyTransaction({
remove: [data],
});
api.hideOverlay();
});
});
return node;
},
},
],
rowSelection: 'single',
suppressRowDeselection: true,
onGridReady: ({ api }) => {
// init quick search input
const searchContainer = initSearchInput('#agent_scheduler_action_search_history');
const searchInput: HTMLInputElement = searchContainer.querySelector('input.ts-search-input')!;
searchInput.addEventListener(
'keyup',
debounce((e: KeyboardEvent) => {
api.setQuickFilter((e.target as HTMLInputElement).value);
}, 200),
);
store.subscribe((state) => {
api.setRowData(state.tasks);
api.sizeColumnsToFit();
});
},
onSelectionChanged: (e) => {
const [selected] = e.api.getSelectedRows();
if (selected) {
resultTaskId.value = selected.id;
resultTaskId.dispatchEvent(new Event('input', { bubbles: true }));
}
},
};
const eGridDiv = gradioApp().querySelector<HTMLDivElement>(
'#agent_scheduler_history_tasks_grid',
)!;
if (document.querySelector('.dark')) {
eGridDiv.className = 'ag-theme-alpine-dark';
}
eGridDiv.style.height = 'calc(100vh - 300px)';
new Grid(eGridDiv, gridOptions);
}
onUiLoaded(() => {
initTabChangeHandler();
initPendingTab();
initHistoryTab();
});

View File

@ -0,0 +1,54 @@
import { createStore } from 'zustand/vanilla';
import { ResponseStatus, Task, TaskHistoryResponse, TaskStatus } from '../types';
type HistoryTasksState = {
total: number;
tasks: Task[];
status?: TaskStatus;
};
type HistoryTasksActions = {
refresh: (options?: { limit?: number; offset?: number }) => Promise<TaskHistoryResponse>;
onFilterStatus: (status?: TaskStatus) => void;
bookmarkTask: (id: string, bookmarked: boolean) => Promise<ResponseStatus>;
renameTask: (id: string, name: string) => Promise<ResponseStatus>;
};
export type HistoryTasksStore = ReturnType<typeof createHistoryTasksStore>;
export const createHistoryTasksStore = (initialState: HistoryTasksState) => {
const store = createStore<HistoryTasksState>()(() => initialState);
const { getState, setState, subscribe } = store;
const actions: HistoryTasksActions = {
refresh: async (options) => {
const { limit = 1000, offset = 0 } = options ?? {};
const status = getState().status ?? '';
return fetch(`/agent-scheduler/v1/history?status=${status}&limit=${limit}&offset=${offset}`)
.then((response) => response.json())
.then((data: TaskHistoryResponse) => {
setState({ ...data });
return data;
});
},
onFilterStatus: (status) => {
setState({ status });
actions.refresh();
},
bookmarkTask: async (id: string, bookmarked: boolean) => {
return fetch(`/agent-scheduler/v1/${bookmarked ? 'bookmark' : 'unbookmark'}/${id}`, {
method: 'POST',
}).then((response) => response.json());
},
renameTask: async (id: string, name: string) => {
return fetch(`/agent-scheduler/v1/rename/${id}?name=${encodeURIComponent(name)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
}).then((response) => response.json());
},
};
return { getState, setState, subscribe, ...actions };
};

View File

@ -0,0 +1,84 @@
import { createStore } from 'zustand/vanilla';
import { ResponseStatus, Task } from '../types';
type PendingTasksState = {
current_task_id: string | null;
total_pending_tasks: number;
pending_tasks: Task[];
paused: boolean;
};
type PendingTasksActions = {
refresh: () => Promise<void>;
pauseQueue: () => Promise<ResponseStatus>;
resumeQueue: () => Promise<ResponseStatus>;
runTask: (id: string) => Promise<ResponseStatus>;
requeueTask: (id: string) => Promise<ResponseStatus>;
moveTask: (id: string, overId: string) => Promise<ResponseStatus>;
deleteTask: (id: string) => Promise<ResponseStatus>;
};
export type PendingTasksStore = ReturnType<typeof createPendingTasksStore>;
export const createPendingTasksStore = (initialState: PendingTasksState) => {
const store = createStore<PendingTasksState>()(() => initialState);
const { getState, setState, subscribe } = store;
const actions: PendingTasksActions = {
refresh: async () => {
return fetch('/agent-scheduler/v1/queue?limit=1000')
.then((response) => response.json())
.then((data: PendingTasksState) => {
setState(data);
});
},
pauseQueue: async () => {
return fetch('/agent-scheduler/v1/pause', { method: 'POST' })
.then((response) => response.json())
.then((data) => {
actions.refresh();
return data;
});
},
resumeQueue: async () => {
return fetch('/agent-scheduler/v1/resume', { method: 'POST' })
.then((response) => response.json())
.then((data) => {
actions.refresh();
return data;
});
},
runTask: async (id: string) => {
return fetch(`/agent-scheduler/v1/run/${id}`, { method: 'POST' })
.then((response) => response.json())
.then((data) => {
actions.refresh();
return data;
});
},
requeueTask: async (id: string) => {
return fetch(`/agent-scheduler/v1/requeue/${id}`, { method: 'POST' })
.then((response) => response.json())
.then((data) => {
actions.refresh();
return data;
});
},
moveTask: async (id: string, overId: string) => {
return fetch(`/agent-scheduler/v1/move/${id}/${overId}`, { method: 'POST' })
.then((response) => response.json())
.then((data) => {
actions.refresh();
return data;
});
},
deleteTask: async (id: string) => {
return fetch(`/agent-scheduler/v1/delete/${id}`, { method: 'POST' }).then((response) =>
response.json(),
);
},
};
return { getState, setState, subscribe, ...actions };
};

View File

@ -0,0 +1,24 @@
import { createStore } from 'zustand/vanilla';
type SelectedTab = 'history' | 'pending';
type SharedState = {
selectedTab: SelectedTab;
};
type SharedActions = {
selectSelectedTab: (tab: SelectedTab) => void;
};
export const createSharedStore = (initialState: SharedState) => {
const store = createStore<SharedState>(() => initialState);
const { getState, setState, subscribe } = store;
const actions: SharedActions = {
selectSelectedTab: (tab: SelectedTab) => {
setState({ selectedTab: tab });
},
};
return { getState, setState, subscribe, ...actions };
};

View File

@ -0,0 +1,54 @@
@tailwind components;
@tailwind utilities;
@layer components {
.ts-search {
@apply relative w-full max-w-xs ml-auto;
}
.ts-search-input {
@apply bg-gray-50 border border-gray-300 text-gray-900 text-sm !rounded-md focus:ring-blue-500 focus:border-blue-500 block !pl-10 !p-2 w-full dark:!bg-gray-700 dark:!border-gray-600 dark:!placeholder-gray-400 dark:!text-white dark:focus:ring-blue-500 dark:focus:border-blue-500;
}
.ts-search-icon {
@apply absolute inset-y-0 left-0 flex items-center dark:text-white pl-3 pointer-events-none;
}
.ts-btn-action {
@apply inline-flex items-center !px-2 !py-1 !m-0 text-sm font-medium border focus:z-10 focus:ring-2 disabled:opacity-50 disabled:hover:!bg-transparent disabled:cursor-not-allowed;
}
.ts-btn-run {
@apply !text-green-500 hover:!text-white border-green-500 hover:bg-green-600 rounded-l-md focus:ring-green-400 dark:border-green-500 dark:hover:bg-green-600 dark:focus:ring-green-900 disabled:hover:!text-green-500;
}
.ts-btn-delete {
@apply !text-red-500 hover:!text-white border-red-600 hover:bg-red-600 rounded-r-md focus:ring-red-300 dark:border-red-500 dark:hover:bg-red-600 dark:focus:ring-red-900;
}
@keyframes blink {
from,
to {
opacity: 0;
}
50% {
opacity: 1;
}
}
.ag-cell.task-running .ag-cell-wrapper {
@apply !text-blue-500;
animation: 1s blink ease infinite;
}
/* .ag-cell.task-done .ag-cell-wrapper {
@apply !text-green-500;
} */
.ag-cell.task-failed .ag-cell-wrapper {
@apply !text-red-500;
}
.ag-cell.task-interrupted .ag-cell-wrapper {
@apply !text-gray-400;
}
}

34
ui/src/extension/types.ts Normal file
View File

@ -0,0 +1,34 @@
export type TaskStatus = 'pending' | 'running' | 'done' | 'failed' | 'interrupted' | 'saved';
export type Task = {
id: string;
api_task_id?: string;
name?: string;
type: string;
status: TaskStatus;
params: Record<string, any>;
priority: number;
result: string;
bookmarked?: boolean;
};
export type ResponseStatus = {
success: boolean;
message: string;
};
export type TaskHistoryResponse = {
tasks: Task[];
total: number;
};
export type ProgressResponse = {
active: boolean;
completed: boolean;
eta: number;
id_live_preview: number;
live_preview: string | null;
paused: boolean;
progress: number;
queued: false;
};

7
ui/src/utils/debounce.ts Normal file
View File

@ -0,0 +1,7 @@
export const debounce = (fn: Function, ms = 300) => {
let timeoutId: ReturnType<typeof setTimeout>;
return function (this: any, ...args: any[]) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn.apply(this, args), ms);
};
};

View File

@ -0,0 +1,11 @@
export const extractArgs = (func: Function) => {
return (func + '')
.replace(/[/][/].*$/gm, '') // strip single-line comments
.replace(/\s+/g, '') // strip white space
.replace(/[/][*][^/*]*[*][/]/g, '') // strip multi-line comments
.split('){', 1)[0]
.replace(/^[^(]*[(]/, '') // extract the parameters
.replace(/=[^,]+/g, '') // strip any ES6 defaults
.split(',')
.filter(Boolean); // split & filter [""]
};

View File

@ -6,7 +6,7 @@ export default defineConfig({
outDir: '../',
copyPublicDir: false,
lib: {
name: 'agent-scheduler',
name: 'agentScheduler',
entry: 'src/extension/index.ts',
fileName: 'javascript/extension',
formats: ['es']

View File

@ -725,7 +725,7 @@ chalk@^4.0.0:
ansi-styles "^4.1.0"
supports-color "^7.1.0"
chokidar@^3.5.3:
"chokidar@>=3.0.0 <4.0.0", chokidar@^3.5.3:
version "3.5.3"
resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd"
integrity sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==
@ -1183,6 +1183,11 @@ ignore@^5.2.0:
resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.2.4.tgz#a291c0c6178ff1b960befe47fcdec301674a6324"
integrity sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==
immutable@^4.0.0:
version "4.3.0"
resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.3.0.tgz#eb1738f14ffb39fd068b1dbe1296117484dd34be"
integrity sha512-0AOCmOip+xgJwEVTQj1EfiDDOkPmuyllDuTuEX+DDXUgapLAsBIfkg3sxCYyCEA8mQqZrrxPUGjcOQ2JS3WLkg==
import-fresh@^3.0.0, import-fresh@^3.2.1:
version "3.3.0"
resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b"
@ -1644,12 +1649,14 @@ run-parallel@^1.1.9:
dependencies:
queue-microtask "^1.2.2"
rxjs@^7.8.1:
version "7.8.1"
resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.8.1.tgz#6f6f3d99ea8044291efd92e7c7fcf562c4057543"
integrity sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==
sass@^1.62.1:
version "1.62.1"
resolved "https://registry.yarnpkg.com/sass/-/sass-1.62.1.tgz#caa8d6bf098935bc92fc73fa169fb3790cacd029"
integrity sha512-NHpxIzN29MXvWiuswfc1W3I0N8SXBd8UR26WntmDlRYf0bSADnwnOjsyMZ3lMezSlArD33Vs3YFhp7dWvL770A==
dependencies:
tslib "^2.1.0"
chokidar ">=3.0.0 <4.0.0"
immutable "^4.0.0"
source-map-js ">=0.6.2 <2.0.0"
scheduler@^0.23.0:
version "0.23.0"
@ -1687,7 +1694,7 @@ slash@^3.0.0:
resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634"
integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==
source-map-js@^1.0.2:
"source-map-js@>=0.6.2 <2.0.0", source-map-js@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c"
integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==
@ -1806,11 +1813,6 @@ tslib@^1.8.1:
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
tslib@^2.1.0:
version "2.5.2"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.5.2.tgz#1b6f07185c881557b0ffa84b111a0106989e8338"
integrity sha512-5svOrSA2w3iGFDs1HibEVBGbDrAY82bFQ3HZ3ixB+88nsbsWQoKqDRb5UBYAUPEzbBn6dAp5gRNXglySbx1MlA==
tsutils@^3.21.0:
version "3.21.0"
resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623"
@ -1850,6 +1852,11 @@ uri-js@^4.2.2:
dependencies:
punycode "^2.1.0"
use-sync-external-store@1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a"
integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==
util-deprecate@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
@ -1902,3 +1909,10 @@ yocto-queue@^0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"
integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==
zustand@^4.3.8:
version "4.3.8"
resolved "https://registry.yarnpkg.com/zustand/-/zustand-4.3.8.tgz#37113df8e9e1421b0be1b2dca02b49b76210e7c4"
integrity sha512-4h28KCkHg5ii/wcFFJ5Fp+k1J3gJoasaIbppdgZFO4BPJnsNxL0mQXBSFgOgAdCdBj35aDTPvdAJReTMntFPGg==
dependencies:
use-sync-external-store "1.2.0"