mirror of
https://github.com/open-webui/open-webui.git
synced 2026-08-26 23:44:48 -06:00
refac
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import logging
|
||||
import time
|
||||
from typing import Optional
|
||||
from typing import Literal, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from open_webui.internal.db import Base, get_async_db_context
|
||||
@@ -64,11 +64,17 @@ class AutomationTerminalConfig(BaseModel):
|
||||
cwd: Optional[str] = None
|
||||
|
||||
|
||||
class AutomationTarget(BaseModel):
|
||||
type: Literal['chat', 'channel'] = 'chat'
|
||||
channel_id: Optional[str] = None
|
||||
|
||||
|
||||
class AutomationData(BaseModel):
|
||||
prompt: str
|
||||
model_id: str
|
||||
rrule: str
|
||||
terminal: Optional[AutomationTerminalConfig] = None
|
||||
target: Optional[AutomationTarget] = None
|
||||
|
||||
|
||||
class AutomationModel(BaseModel):
|
||||
|
||||
@@ -15,6 +15,8 @@ from open_webui.models.automations import (
|
||||
AutomationRuns,
|
||||
Automations,
|
||||
)
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
from open_webui.models.channels import Channels
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.models.folders import Folders
|
||||
from open_webui.utils.access_control import has_permission
|
||||
@@ -104,6 +106,48 @@ async def check_automation_folder_access(folder_id: Optional[str], user, db: Asy
|
||||
)
|
||||
|
||||
|
||||
async def check_automation_channel_access(form_data: AutomationForm, user, db: AsyncSession):
|
||||
target = form_data.data.target
|
||||
if not target or target.type != 'channel':
|
||||
return
|
||||
|
||||
if not target.channel_id or not await Config.get('channels.enable'):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
channel = await Channels.get_channel_by_id(target.channel_id, db=db)
|
||||
if not channel:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
if user.role == 'admin':
|
||||
return
|
||||
if not await has_permission(user.id, 'features.channels', await Config.get('user.permissions')):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ERROR_MESSAGES.DEFAULT(),
|
||||
)
|
||||
if channel.type in ['group', 'dm']:
|
||||
allowed = await Channels.is_user_channel_member(channel.id, user.id, db=db)
|
||||
else:
|
||||
allowed = await AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type='channel',
|
||||
resource_id=channel.id,
|
||||
permission='write',
|
||||
db=db,
|
||||
)
|
||||
if not allowed:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ERROR_MESSAGES.DEFAULT(),
|
||||
)
|
||||
|
||||
|
||||
async def enrich_automation(automation: AutomationModel, db: AsyncSession, tz: str = None) -> AutomationResponse:
|
||||
"""Full enrichment for single-item views (includes next_runs computation)."""
|
||||
last_run = await AutomationRuns.get_latest(automation.id, db=db)
|
||||
@@ -174,6 +218,7 @@ async def create_new_automation(
|
||||
):
|
||||
await check_automations_permission(request, user)
|
||||
await check_automation_folder_access(form_data.folder_id, user, db)
|
||||
await check_automation_channel_access(form_data, user, db)
|
||||
try:
|
||||
validate_rrule(form_data.data.rrule, tz=user.timezone)
|
||||
except ValueError as e:
|
||||
@@ -232,6 +277,7 @@ async def update_automation_by_id(
|
||||
automation = await Automations.get_by_id(id, db=db)
|
||||
check_automation_access(automation, user)
|
||||
await check_automation_folder_access(form_data.folder_id, user, db)
|
||||
await check_automation_channel_access(form_data, user, db)
|
||||
|
||||
try:
|
||||
validate_rrule(form_data.data.rrule, tz=user.timezone)
|
||||
|
||||
@@ -1075,16 +1075,10 @@ async def model_response_handler(request, channel, message, user, db=None):
|
||||
],
|
||||
]
|
||||
|
||||
# Resolve model config (same helpers automations use)
|
||||
from open_webui.utils.automations import (
|
||||
_resolve_model_features,
|
||||
_resolve_model_filter_ids,
|
||||
_resolve_model_tool_ids,
|
||||
)
|
||||
# Resolve model config (same path automations use)
|
||||
from open_webui.utils.automations import _resolve_model_defaults
|
||||
|
||||
tool_ids = _resolve_model_tool_ids(request.app, model_id)
|
||||
features = await _resolve_model_features(request.app, model_id)
|
||||
filter_ids = _resolve_model_filter_ids(request.app, model_id)
|
||||
tool_ids, features, filter_ids, _ = await _resolve_model_defaults(request.app, model_id)
|
||||
|
||||
# Build full form_data — same shape as frontend POST.
|
||||
# The channel: prefix routes pipeline events to the
|
||||
|
||||
@@ -3602,7 +3602,7 @@ async def create_automation(
|
||||
return JSONCodec.dumps({'error': 'User context not available'})
|
||||
|
||||
try:
|
||||
from open_webui.models.automations import AutomationData, AutomationForm, Automations
|
||||
from open_webui.models.automations import AutomationData, AutomationForm, AutomationTarget, Automations
|
||||
from open_webui.models.users import Users
|
||||
from open_webui.routers.automations import check_automation_limits
|
||||
from open_webui.utils.automations import next_n_runs_ns, next_run_ns, validate_rrule
|
||||
@@ -3644,6 +3644,11 @@ async def create_automation(
|
||||
prompt=prompt,
|
||||
model_id=model_id,
|
||||
rrule=rrule,
|
||||
target=(
|
||||
AutomationTarget(type='channel', channel_id=metadata.get('chat_id', '').removeprefix('channel:'))
|
||||
if metadata.get('chat_id', '').startswith('channel:')
|
||||
else None
|
||||
),
|
||||
),
|
||||
is_active=True,
|
||||
)
|
||||
@@ -3657,6 +3662,7 @@ async def create_automation(
|
||||
'name': automation.name,
|
||||
'folder_id': automation.folder_id,
|
||||
'model_id': model_id,
|
||||
'target': automation.data.get('target'),
|
||||
'is_active': automation.is_active,
|
||||
'next_runs': next_n_runs_ns(rrule, tz=tz),
|
||||
},
|
||||
@@ -3695,7 +3701,7 @@ async def update_automation(
|
||||
return JSONCodec.dumps({'error': 'User context not available'})
|
||||
|
||||
try:
|
||||
from open_webui.models.automations import AutomationData, AutomationForm, Automations
|
||||
from open_webui.models.automations import AutomationData, AutomationForm, AutomationTarget, Automations
|
||||
from open_webui.models.users import Users
|
||||
from open_webui.routers.automations import check_automation_limits
|
||||
from open_webui.utils.automations import next_n_runs_ns, next_run_ns, validate_rrule
|
||||
@@ -3746,6 +3752,7 @@ async def update_automation(
|
||||
prompt=new_prompt,
|
||||
model_id=new_model_id,
|
||||
rrule=new_rrule,
|
||||
target=AutomationTarget(**automation.data['target']) if automation.data.get('target') else None,
|
||||
),
|
||||
is_active=automation.is_active,
|
||||
)
|
||||
@@ -3759,6 +3766,7 @@ async def update_automation(
|
||||
'name': updated.name,
|
||||
'folder_id': updated.folder_id,
|
||||
'model_id': new_model_id,
|
||||
'target': updated.data.get('target'),
|
||||
'is_active': updated.is_active,
|
||||
'next_runs': next_n_runs_ns(new_rrule, tz=tz),
|
||||
},
|
||||
@@ -3824,6 +3832,7 @@ async def list_automations(
|
||||
'folder_id': item.folder_id,
|
||||
'prompt_snippet': snippet,
|
||||
'model_id': item.data.get('model_id', ''),
|
||||
'target': item.data.get('target'),
|
||||
'rrule': rrule,
|
||||
'is_active': item.is_active,
|
||||
'last_run_at': item.last_run_at,
|
||||
|
||||
@@ -36,6 +36,7 @@ from open_webui.models.automations import AutomationModel, AutomationRuns, Autom
|
||||
from open_webui.models.chats import ChatForm, Chats
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.models.folders import Folders
|
||||
from open_webui.models.messages import MessageForm
|
||||
from open_webui.models.users import Users
|
||||
from open_webui.utils.auth import create_token
|
||||
from open_webui.utils.misc import parse_duration
|
||||
@@ -300,33 +301,17 @@ def _build_request(
|
||||
return request
|
||||
|
||||
|
||||
def _resolve_model_tool_ids(app, model_id: str) -> list[str]:
|
||||
"""Read model-attached tool_ids from model config.
|
||||
|
||||
The frontend does this in Chat.svelte (model.info.meta.toolIds).
|
||||
The backend never auto-resolves them, so we must do it explicitly.
|
||||
"""
|
||||
models = getattr(app.state, 'MODELS', {})
|
||||
model = models.get(model_id, {})
|
||||
tool_ids = model.get('info', {}).get('meta', {}).get('toolIds', [])
|
||||
return list(tool_ids) if tool_ids else []
|
||||
|
||||
|
||||
async def _resolve_model_features(app, model_id: str) -> dict:
|
||||
"""Read model default features from model config.
|
||||
|
||||
The frontend does this in Chat.svelte (model.info.meta.defaultFeatureIds
|
||||
+ model.info.meta.capabilities). Enables features like web_search,
|
||||
code_interpreter, image_generation when the model has them as defaults
|
||||
AND the capability is enabled AND the admin has enabled the feature.
|
||||
"""
|
||||
async def _resolve_model_defaults(app, model_id: str) -> tuple[list[str], dict, list[str], Optional[str]]:
|
||||
models = getattr(app.state, 'MODELS', {})
|
||||
model = models.get(model_id, {})
|
||||
meta = model.get('info', {}).get('meta', {})
|
||||
|
||||
tool_ids = list(meta.get('toolIds') or [])
|
||||
filter_ids = list(meta.get('defaultFilterIds') or [])
|
||||
terminal_id = meta.get('terminalId') or None
|
||||
default_feature_ids = meta.get('defaultFeatureIds', [])
|
||||
if not default_feature_ids:
|
||||
return {}
|
||||
return tool_ids, {}, filter_ids, terminal_id
|
||||
|
||||
capabilities = meta.get('capabilities') or {}
|
||||
features = {}
|
||||
@@ -344,25 +329,7 @@ async def _resolve_model_features(app, model_id: str) -> dict:
|
||||
if capabilities.get(feature_id) and feature_checks[feature_id]:
|
||||
features[feature_id] = True
|
||||
|
||||
return features
|
||||
|
||||
|
||||
def _resolve_model_filter_ids(app, model_id: str) -> list[str]:
|
||||
"""Read model default filter_ids from model config."""
|
||||
models = getattr(app.state, 'MODELS', {})
|
||||
model = models.get(model_id, {})
|
||||
filter_ids = model.get('info', {}).get('meta', {}).get('defaultFilterIds', [])
|
||||
return list(filter_ids) if filter_ids else []
|
||||
|
||||
|
||||
def _resolve_model_terminal_id(app, model_id: str) -> Optional[str]:
|
||||
"""Read model default terminal_id from model config.
|
||||
|
||||
The frontend does this in Chat.svelte (model.info.meta.terminalId).
|
||||
"""
|
||||
models = getattr(app.state, 'MODELS', {})
|
||||
model = models.get(model_id, {})
|
||||
return model.get('info', {}).get('meta', {}).get('terminalId') or None
|
||||
return tool_ids, features, filter_ids, terminal_id
|
||||
|
||||
|
||||
async def _set_terminal_cwd(app, server_id: str, user, cwd: str, chat_id: str) -> None:
|
||||
@@ -413,10 +380,113 @@ async def _set_terminal_cwd(app, server_id: str, user, cwd: str, chat_id: str) -
|
||||
log.warning(f'Failed to set terminal CWD: {e}')
|
||||
|
||||
|
||||
async def _execute_channel_automation(
|
||||
app,
|
||||
automation: AutomationModel,
|
||||
user,
|
||||
prompt: str,
|
||||
model_id: str,
|
||||
token: str,
|
||||
) -> None:
|
||||
target = automation.data.get('target') or {}
|
||||
channel_id = target.get('channel_id')
|
||||
if not channel_id or not await Config.get('channels.enable'):
|
||||
raise ValueError('Channel not found')
|
||||
|
||||
model = getattr(app.state, 'MODELS', {}).get(model_id, {})
|
||||
request = _build_request(app, token=token)
|
||||
|
||||
from open_webui.routers.channels import new_message_handler
|
||||
|
||||
async with get_async_db() as db:
|
||||
user_message, channel = await new_message_handler(
|
||||
request,
|
||||
channel_id,
|
||||
MessageForm(
|
||||
content=prompt,
|
||||
data={},
|
||||
meta={'automation_id': automation.id},
|
||||
),
|
||||
user,
|
||||
db,
|
||||
)
|
||||
response_parent_id = (
|
||||
user_message.parent_id
|
||||
if user_message.parent_id
|
||||
else (user_message.id if await Config.get('channels.model_response_mode', 'thread') == 'thread' else None)
|
||||
)
|
||||
assistant_message, channel = await new_message_handler(
|
||||
request,
|
||||
channel.id,
|
||||
MessageForm(
|
||||
parent_id=response_parent_id,
|
||||
content='',
|
||||
data={},
|
||||
meta={
|
||||
'automation_id': automation.id,
|
||||
'model_id': model_id,
|
||||
'model_name': model.get('name', model_id),
|
||||
},
|
||||
),
|
||||
user,
|
||||
db,
|
||||
)
|
||||
|
||||
tool_ids, features, filter_ids, _ = await _resolve_model_defaults(app, model_id)
|
||||
|
||||
form_data = {
|
||||
'model': model_id,
|
||||
'messages': [
|
||||
{
|
||||
'role': 'system',
|
||||
'content': f'You are {model.get("name", model_id)}, participating in a channel conversation. Be concise and conversational.',
|
||||
},
|
||||
{'role': 'user', 'content': f'{user.name if user else "User"}: {prompt}'},
|
||||
],
|
||||
'stream': True,
|
||||
'chat_id': f'channel:{channel.id}',
|
||||
'id': assistant_message.id,
|
||||
'session_id': f'channel:{channel.id}',
|
||||
'automation_id': automation.id,
|
||||
'background_tasks': {},
|
||||
}
|
||||
if tool_ids:
|
||||
form_data['tool_ids'] = tool_ids
|
||||
if features:
|
||||
form_data['features'] = features
|
||||
if filter_ids:
|
||||
form_data['filter_ids'] = filter_ids
|
||||
|
||||
await app.state.CHAT_COMPLETION_HANDLER(request, form_data, user=user)
|
||||
|
||||
from open_webui.socket.main import sio
|
||||
|
||||
await sio.emit(
|
||||
'automation:result',
|
||||
{
|
||||
'automation_id': automation.id,
|
||||
'name': automation.name,
|
||||
'chat_id': f'channel:{channel.id}',
|
||||
'message_id': assistant_message.id,
|
||||
'status': 'success',
|
||||
},
|
||||
room=f'user:{automation.user_id}',
|
||||
)
|
||||
|
||||
await _record_run(automation.id, 'success', chat_id=f'channel:{channel.id}')
|
||||
await publish_event(
|
||||
app,
|
||||
EVENTS.AUTOMATION_RUN_COMPLETED,
|
||||
actor=user,
|
||||
subject_id=automation.id,
|
||||
data={'name': automation.name, 'channel_id': channel.id, 'message_id': assistant_message.id},
|
||||
)
|
||||
|
||||
|
||||
async def execute_automation(app, automation: AutomationModel) -> None:
|
||||
"""Execute an automation through the full chat completion pipeline.
|
||||
|
||||
Creates a real chat, then calls chat_completion exactly like the frontend:
|
||||
Creates a real chat or channel message, then calls chat_completion exactly like the frontend:
|
||||
session_id + chat_id + message_id → async task → pipeline handles everything
|
||||
(filters, model params, knowledge/RAG, tools, DB saves, webhooks).
|
||||
"""
|
||||
@@ -452,6 +522,20 @@ async def execute_automation(app, automation: AutomationModel) -> None:
|
||||
|
||||
prompt = await prompt_template(automation.data['prompt'], user)
|
||||
model_id = automation.data['model_id']
|
||||
try:
|
||||
expires_delta = parse_duration(str(await Config.get('automations.auth_token_expires_in', '1h')))
|
||||
except ValueError:
|
||||
expires_delta = None
|
||||
token = create_token(
|
||||
data={'id': user.id, 'typ': 'automation'},
|
||||
expires_delta=expires_delta or timedelta(hours=1),
|
||||
)
|
||||
|
||||
target = automation.data.get('target') or {}
|
||||
if target.get('type') == 'channel':
|
||||
await _execute_channel_automation(app, automation, user, prompt, model_id, token)
|
||||
return
|
||||
|
||||
folder_id = automation.folder_id
|
||||
if folder_id and not await Folders.get_folder_by_id_and_user_id(folder_id, automation.user_id):
|
||||
await Automations.clear_folder_ids(automation.user_id, [folder_id])
|
||||
@@ -528,12 +612,7 @@ async def execute_automation(app, automation: AutomationModel) -> None:
|
||||
)
|
||||
|
||||
# Resolve model defaults (frontend does this, backend doesn't)
|
||||
tool_ids = _resolve_model_tool_ids(app, model_id)
|
||||
features = await _resolve_model_features(app, model_id)
|
||||
filter_ids = _resolve_model_filter_ids(app, model_id)
|
||||
|
||||
# Resolve terminal from model config
|
||||
terminal_id = _resolve_model_terminal_id(app, model_id)
|
||||
tool_ids, features, filter_ids, terminal_id = await _resolve_model_defaults(app, model_id)
|
||||
|
||||
# Build the same payload the frontend sends to /api/chat/completions
|
||||
form_data = {
|
||||
@@ -564,14 +643,6 @@ async def execute_automation(app, automation: AutomationModel) -> None:
|
||||
|
||||
# Call the full chat completion pipeline (same as POST /api/chat/completions).
|
||||
# The handler reference is stored on app.state to avoid circular imports.
|
||||
try:
|
||||
expires_delta = parse_duration(str(await Config.get('automations.auth_token_expires_in', '1h')))
|
||||
except ValueError:
|
||||
expires_delta = None
|
||||
token = create_token(
|
||||
data={'id': user.id, 'typ': 'automation'},
|
||||
expires_delta=expires_delta or timedelta(hours=1),
|
||||
)
|
||||
request = _build_request(app, token=token)
|
||||
await app.state.CHAT_COMPLETION_HANDLER(request, form_data, user=user)
|
||||
|
||||
|
||||
@@ -5,11 +5,17 @@ export type AutomationTerminalConfig = {
|
||||
cwd?: string;
|
||||
};
|
||||
|
||||
export type AutomationTarget = {
|
||||
type: 'chat' | 'channel';
|
||||
channel_id?: string | null;
|
||||
};
|
||||
|
||||
export type AutomationData = {
|
||||
prompt: string;
|
||||
model_id: string;
|
||||
rrule: string;
|
||||
terminal?: AutomationTerminalConfig;
|
||||
target?: AutomationTarget | null;
|
||||
};
|
||||
|
||||
export type AutomationForm = {
|
||||
|
||||
@@ -8,9 +8,10 @@
|
||||
|
||||
import ScheduleDropdown from '$lib/components/automations/ScheduleDropdown.svelte';
|
||||
import ModelDropdown from '$lib/components/automations/ModelDropdown.svelte';
|
||||
import FolderDropdown from '$lib/components/automations/FolderDropdown.svelte';
|
||||
import DestinationDropdown from '$lib/components/automations/DestinationDropdown.svelte';
|
||||
import { getFolders } from '$lib/apis/folders';
|
||||
import { folders } from '$lib/stores';
|
||||
import { getChannels } from '$lib/apis/channels';
|
||||
import { channels, folders } from '$lib/stores';
|
||||
|
||||
import {
|
||||
createAutomation,
|
||||
@@ -30,10 +31,13 @@
|
||||
let prompt = '';
|
||||
let model_id = '';
|
||||
let folder_id = '';
|
||||
let target_type: 'chat' | 'channel' = 'chat';
|
||||
let channel_id = '';
|
||||
let is_active = true;
|
||||
|
||||
let loading = false;
|
||||
let foldersLoaded = false;
|
||||
let channelsLoaded = false;
|
||||
|
||||
// Schedule dropdown ref
|
||||
let scheduleDropdown: ScheduleDropdown;
|
||||
@@ -43,6 +47,10 @@
|
||||
toast.error($i18n.t('Name, prompt, and model are required'));
|
||||
return;
|
||||
}
|
||||
if (target_type === 'channel' && !channel_id) {
|
||||
toast.error($i18n.t('Channel is required'));
|
||||
return;
|
||||
}
|
||||
if (scheduleDropdown?.frequency === 'ONCE') {
|
||||
const scheduled = new Date(`${scheduleDropdown.onceDate}T${scheduleDropdown.onceTime}`);
|
||||
if (scheduled <= new Date()) {
|
||||
@@ -54,11 +62,12 @@
|
||||
try {
|
||||
const form: AutomationForm = {
|
||||
name: name.trim(),
|
||||
folder_id: folder_id || null,
|
||||
folder_id: target_type === 'channel' ? null : folder_id || null,
|
||||
data: {
|
||||
prompt: prompt.trim(),
|
||||
model_id: model_id.trim(),
|
||||
rrule: scheduleDropdown.buildRrule()
|
||||
rrule: scheduleDropdown.buildRrule(),
|
||||
target: target_type === 'channel' ? { type: 'channel', channel_id } : { type: 'chat' }
|
||||
},
|
||||
is_active
|
||||
};
|
||||
@@ -88,12 +97,19 @@
|
||||
if (res) folders.set(res);
|
||||
foldersLoaded = true;
|
||||
}
|
||||
if (!channelsLoaded && ($channels ?? []).length === 0) {
|
||||
const res = await getChannels(localStorage.token).catch(() => null);
|
||||
if (res) channels.set(res);
|
||||
channelsLoaded = true;
|
||||
}
|
||||
|
||||
if (automation) {
|
||||
name = automation.name;
|
||||
prompt = automation.data.prompt;
|
||||
model_id = automation.data.model_id;
|
||||
folder_id = automation.folder_id ?? '';
|
||||
target_type = automation.data.target?.type === 'channel' ? 'channel' : 'chat';
|
||||
channel_id = automation.data.target?.channel_id ?? '';
|
||||
is_active = automation.is_active;
|
||||
if (scheduleDropdown) {
|
||||
scheduleDropdown.parseRrule(automation.data.rrule);
|
||||
@@ -105,6 +121,12 @@
|
||||
folder_id = ($folders ?? []).some((folder) => folder.id === cloneFrom.folder_id)
|
||||
? (cloneFrom.folder_id ?? '')
|
||||
: '';
|
||||
target_type = cloneFrom.data.target?.type === 'channel' ? 'channel' : 'chat';
|
||||
channel_id = ($channels ?? []).some(
|
||||
(channel) => channel.id === cloneFrom.data.target?.channel_id
|
||||
)
|
||||
? (cloneFrom.data.target?.channel_id ?? '')
|
||||
: '';
|
||||
is_active = true;
|
||||
if (scheduleDropdown) {
|
||||
scheduleDropdown.parseRrule(cloneFrom.data.rrule);
|
||||
@@ -114,6 +136,8 @@
|
||||
prompt = '';
|
||||
model_id = '';
|
||||
folder_id = '';
|
||||
target_type = 'chat';
|
||||
channel_id = '';
|
||||
is_active = true;
|
||||
}
|
||||
};
|
||||
@@ -162,7 +186,15 @@
|
||||
|
||||
<ModelDropdown bind:model_id side="top" align="start" />
|
||||
|
||||
<FolderDropdown bind:folder_id side="top" align="start" />
|
||||
<DestinationDropdown
|
||||
bind:target_type
|
||||
bind:channel_id
|
||||
bind:folder_id
|
||||
folders={$folders}
|
||||
channels={$channels}
|
||||
side="top"
|
||||
align="start"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-2 shrink-0">
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
import localizedFormat from 'dayjs/plugin/localizedFormat';
|
||||
import type i18nType from '$lib/i18n';
|
||||
|
||||
import { WEBUI_NAME, folders } from '$lib/stores';
|
||||
import { WEBUI_NAME, channels, folders } from '$lib/stores';
|
||||
import { getFolders } from '$lib/apis/folders';
|
||||
import { getChannels } from '$lib/apis/channels';
|
||||
|
||||
import {
|
||||
getAutomationById,
|
||||
@@ -44,6 +45,7 @@
|
||||
let hasMoreRuns = true;
|
||||
let runsPage = 0;
|
||||
let foldersLoaded = false;
|
||||
let channelsLoaded = false;
|
||||
|
||||
const ensureFolders = async () => {
|
||||
if (foldersLoaded || ($folders ?? []).length > 0) return;
|
||||
@@ -52,11 +54,29 @@
|
||||
foldersLoaded = true;
|
||||
};
|
||||
|
||||
const ensureChannels = async () => {
|
||||
if (channelsLoaded || ($channels ?? []).length > 0) return;
|
||||
const res = await getChannels(localStorage.token).catch(() => null);
|
||||
if (res) channels.set(res);
|
||||
channelsLoaded = true;
|
||||
};
|
||||
|
||||
const getFolderName = (folderId: string | null): string =>
|
||||
folderId
|
||||
? (($folders ?? []).find((folder) => folder.id === folderId)?.name ?? $i18n.t('None'))
|
||||
: $i18n.t('None');
|
||||
|
||||
const getDestinationName = (): string => {
|
||||
const target = automation.data.target;
|
||||
if (target?.type === 'channel') {
|
||||
const channel = ($channels ?? []).find((channel) => channel.id === target.channel_id);
|
||||
return channel?.name ? `#${channel.name}` : $i18n.t('Channel');
|
||||
}
|
||||
return automation.folder_id
|
||||
? `${$i18n.t('Folder')}: ${getFolderName(automation.folder_id)}`
|
||||
: $i18n.t('New chat');
|
||||
};
|
||||
|
||||
const formatTime = (ts: number | null): string => {
|
||||
if (!ts) return '-';
|
||||
return new Date(ts / 1_000_000).toLocaleString(undefined, {
|
||||
@@ -216,6 +236,7 @@
|
||||
is_active = automation.is_active;
|
||||
|
||||
await ensureFolders();
|
||||
await ensureChannels();
|
||||
await loadRuns();
|
||||
});
|
||||
|
||||
@@ -283,10 +304,10 @@
|
||||
|
||||
<div class="flex h-7 items-center px-3">
|
||||
<span class="w-24 shrink-0 text-[0.6875rem] text-gray-400 dark:text-gray-500">
|
||||
{$i18n.t('Folder')}
|
||||
{$i18n.t('Destination')}
|
||||
</span>
|
||||
<span class="min-w-0 truncate text-xs text-gray-700 dark:text-gray-300">
|
||||
{getFolderName(automation.folder_id)}
|
||||
{getDestinationName()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -356,11 +377,19 @@
|
||||
<button
|
||||
class="group flex items-center gap-1 text-[0.6875rem] text-gray-400"
|
||||
on:click={() => {
|
||||
goto(`/c/${run.chat_id}`);
|
||||
if (run.chat_id?.startsWith('channel:')) {
|
||||
goto(`/channels/${run.chat_id.replace('channel:', '')}`);
|
||||
} else {
|
||||
goto(`/c/${run.chat_id}`);
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<span class="group-hover:underline">{$i18n.t('View chat')}</span>
|
||||
<span class="group-hover:underline">
|
||||
{run.chat_id?.startsWith('channel:')
|
||||
? $i18n.t('View channel')
|
||||
: $i18n.t('View chat')}
|
||||
</span>
|
||||
<ArrowRight className="size-2.5" strokeWidth="2" />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from 'svelte';
|
||||
import { fly } from 'svelte/transition';
|
||||
|
||||
import { decodeString } from '$lib/utils';
|
||||
|
||||
import Dropdown from '$lib/components/common/Dropdown.svelte';
|
||||
import DropdownMenu from '$lib/components/common/DropdownMenu.svelte';
|
||||
import ChatBubble from '$lib/components/icons/ChatBubble.svelte';
|
||||
import Check from '$lib/components/icons/Check.svelte';
|
||||
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
|
||||
import ChevronLeft from '$lib/components/icons/ChevronLeft.svelte';
|
||||
import ChevronRight from '$lib/components/icons/ChevronRight.svelte';
|
||||
import Folder from '$lib/components/icons/Folder.svelte';
|
||||
import Hashtag from '$lib/components/icons/Hashtag.svelte';
|
||||
import Search from '$lib/components/icons/Search.svelte';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
export let target_type: 'chat' | 'channel' = 'chat';
|
||||
export let channel_id = '';
|
||||
export let folder_id = '';
|
||||
export let folders: any[] = [];
|
||||
export let channels: any[] = [];
|
||||
export let side: 'top' | 'bottom' = 'top';
|
||||
export let align: 'start' | 'end' = 'start';
|
||||
export let onChange: () => void = () => {};
|
||||
|
||||
let show = false;
|
||||
let tab: '' | 'folders' | 'channels' = '';
|
||||
let folderSearch = '';
|
||||
let channelSearch = '';
|
||||
|
||||
const folderName = (folder: any) => decodeString(folder?.name ?? $i18n.t('Folder'));
|
||||
const channelName = (channel: any) => channel?.name || $i18n.t('Channel');
|
||||
|
||||
$: folderOptions = [...((folders ?? []) as any[])]
|
||||
.filter((folder) => folder?.id && !folder?.shared)
|
||||
.sort((a, b) => folderName(a).localeCompare(folderName(b)));
|
||||
$: folderById = new Map(folderOptions.map((folder) => [folder.id, folder]));
|
||||
$: channelOptions = (channels ?? [])
|
||||
.filter((channel) => channel?.id && channel.type !== 'dm')
|
||||
.sort((a, b) => channelName(a).localeCompare(channelName(b)));
|
||||
$: selectedFolder = folderOptions.find((folder) => folder.id === folder_id);
|
||||
$: selectedChannel = channelOptions.find((channel) => channel.id === channel_id);
|
||||
|
||||
const folderPath = (folder: any) => {
|
||||
const names: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
let current = folder;
|
||||
|
||||
while (current?.parent_id && !seen.has(current.parent_id)) {
|
||||
seen.add(current.parent_id);
|
||||
const parent = folderById.get(current.parent_id);
|
||||
if (!parent) break;
|
||||
names.unshift(folderName(parent));
|
||||
current = parent;
|
||||
}
|
||||
|
||||
return names.join(' / ');
|
||||
};
|
||||
|
||||
$: destinationLabel =
|
||||
target_type === 'channel'
|
||||
? selectedChannel
|
||||
? `#${channelName(selectedChannel)}`
|
||||
: $i18n.t('Choose channel')
|
||||
: selectedFolder
|
||||
? folderName(selectedFolder)
|
||||
: $i18n.t('New chat');
|
||||
$: normalizedFolderSearch = folderSearch.trim().toLowerCase();
|
||||
$: filteredFolderOptions = normalizedFolderSearch
|
||||
? folderOptions.filter((folder) =>
|
||||
`${folderName(folder)} ${folderPath(folder)}`.toLowerCase().includes(normalizedFolderSearch)
|
||||
)
|
||||
: folderOptions;
|
||||
$: normalizedChannelSearch = channelSearch.trim().toLowerCase();
|
||||
$: filteredChannelOptions = normalizedChannelSearch
|
||||
? channelOptions.filter((channel) => channelName(channel).toLowerCase().includes(normalizedChannelSearch))
|
||||
: channelOptions;
|
||||
|
||||
const selectChat = () => {
|
||||
target_type = 'chat';
|
||||
channel_id = '';
|
||||
folder_id = '';
|
||||
show = false;
|
||||
tab = '';
|
||||
onChange();
|
||||
};
|
||||
|
||||
const selectFolder = (id: string) => {
|
||||
target_type = 'chat';
|
||||
channel_id = '';
|
||||
folder_id = id;
|
||||
show = false;
|
||||
tab = '';
|
||||
folderSearch = '';
|
||||
onChange();
|
||||
};
|
||||
|
||||
const selectChannel = (id: string) => {
|
||||
target_type = 'channel';
|
||||
channel_id = id;
|
||||
folder_id = '';
|
||||
show = false;
|
||||
tab = '';
|
||||
channelSearch = '';
|
||||
onChange();
|
||||
};
|
||||
</script>
|
||||
|
||||
<Dropdown
|
||||
bind:show
|
||||
{side}
|
||||
{align}
|
||||
onOpenChange={(state) => {
|
||||
if (!state) {
|
||||
tab = '';
|
||||
folderSearch = '';
|
||||
channelSearch = '';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="relative h-8 max-w-[11rem] flex items-center gap-1.5 px-2.5 py-1.5 bg-transparent rounded-2xl text-xs font-normal text-gray-600 transition hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100"
|
||||
>
|
||||
{#if target_type === 'channel'}
|
||||
<Hashtag className="size-3.5 shrink-0" />
|
||||
{:else if folder_id}
|
||||
<Folder className="size-3.5 shrink-0" />
|
||||
{:else}
|
||||
<ChatBubble className="size-3.5 shrink-0" />
|
||||
{/if}
|
||||
<span class="min-w-0 truncate">{destinationLabel}</span>
|
||||
<ChevronDown className="size-2.5 shrink-0" strokeWidth="2.5" />
|
||||
</button>
|
||||
|
||||
<div slot="content">
|
||||
<DropdownMenu className="w-72 max-h-72 overflow-hidden shadow-lg">
|
||||
{#if tab === ''}
|
||||
<div class="max-h-72 overflow-y-auto overflow-x-hidden scrollbar-thin" in:fly={{ x: -20, duration: 150 }}>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-[1.6875rem] w-full cursor-pointer items-center justify-between gap-2 rounded-xl bg-transparent px-2 text-[0.8125rem] hover:bg-gray-50/40 hover:text-gray-900 dark:hover:bg-gray-800/40 dark:hover:text-gray-100 {target_type ===
|
||||
'chat' && !folder_id
|
||||
? 'text-gray-900 dark:text-gray-100'
|
||||
: 'text-gray-700 dark:text-gray-300'}"
|
||||
on:click={selectChat}
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-1.5">
|
||||
<ChatBubble className="size-3.5 shrink-0" />
|
||||
<span class="min-w-0 truncate">{$i18n.t('New chat')}</span>
|
||||
</div>
|
||||
{#if target_type === 'chat' && !folder_id}
|
||||
<Check className="size-3.5 shrink-0" strokeWidth="2" />
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-[1.6875rem] w-full cursor-pointer items-center justify-between gap-2 rounded-xl bg-transparent px-2 text-[0.8125rem] hover:bg-gray-50/40 hover:text-gray-900 dark:hover:bg-gray-800/40 dark:hover:text-gray-100 {target_type ===
|
||||
'chat' && folder_id
|
||||
? 'text-gray-900 dark:text-gray-100'
|
||||
: 'text-gray-700 dark:text-gray-300'}"
|
||||
on:click={() => (tab = 'folders')}
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-1.5">
|
||||
<Folder className="size-3.5 shrink-0" />
|
||||
<span class="min-w-0 truncate">
|
||||
{selectedFolder ? folderName(selectedFolder) : $i18n.t('Folder')}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-1 text-gray-500">
|
||||
{#if target_type === 'chat' && folder_id}
|
||||
<Check className="size-3.5" strokeWidth="2" />
|
||||
{/if}
|
||||
<ChevronRight />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-[1.6875rem] w-full cursor-pointer items-center justify-between gap-2 rounded-xl bg-transparent px-2 text-[0.8125rem] hover:bg-gray-50/40 hover:text-gray-900 dark:hover:bg-gray-800/40 dark:hover:text-gray-100 {target_type ===
|
||||
'channel'
|
||||
? 'text-gray-900 dark:text-gray-100'
|
||||
: 'text-gray-700 dark:text-gray-300'}"
|
||||
on:click={() => (tab = 'channels')}
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-1.5">
|
||||
<Hashtag className="size-3.5 shrink-0" />
|
||||
<span class="min-w-0 truncate">
|
||||
{selectedChannel ? channelName(selectedChannel) : $i18n.t('Channel')}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-1 text-gray-500">
|
||||
{#if target_type === 'channel'}
|
||||
<Check className="size-3.5" strokeWidth="2" />
|
||||
{/if}
|
||||
<ChevronRight />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
{:else if tab === 'folders'}
|
||||
<div class="flex max-h-72 flex-col overflow-hidden" in:fly={{ x: 20, duration: 150 }}>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-[1.6875rem] w-full shrink-0 cursor-pointer items-center gap-2 rounded-xl px-2 text-[0.8125rem] hover:bg-gray-50/40 dark:hover:bg-gray-800/40"
|
||||
on:click={() => (tab = '')}
|
||||
>
|
||||
<ChevronLeft />
|
||||
<span>{$i18n.t('Folders')}</span>
|
||||
</button>
|
||||
|
||||
<div class="flex shrink-0 items-center gap-1.5 px-2 py-1">
|
||||
<Search className="size-3.5 shrink-0" strokeWidth="2.5" />
|
||||
<input
|
||||
bind:value={folderSearch}
|
||||
class="w-full bg-transparent text-[0.8125rem] outline-hidden"
|
||||
placeholder={$i18n.t('Search folders')}
|
||||
autocomplete="off"
|
||||
on:click|stopPropagation
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="overflow-y-auto scrollbar-thin">
|
||||
{#each filteredFolderOptions as folder (folder.id)}
|
||||
{@const path = folderPath(folder)}
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-[1.6875rem] w-full cursor-pointer items-center justify-between gap-2 rounded-xl bg-transparent px-2 text-[0.8125rem] hover:bg-gray-50/40 hover:text-gray-900 dark:hover:bg-gray-800/40 dark:hover:text-gray-100 {target_type ===
|
||||
'chat' && folder_id === folder.id
|
||||
? 'text-gray-900 dark:text-gray-100'
|
||||
: 'text-gray-700 dark:text-gray-300'}"
|
||||
title={path ? `${path} / ${folderName(folder)}` : folderName(folder)}
|
||||
on:click={() => selectFolder(folder.id)}
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-1.5">
|
||||
<Folder className="size-3.5 shrink-0" />
|
||||
<span class="min-w-0 truncate">{folderName(folder)}</span>
|
||||
{#if path}
|
||||
<span class="min-w-0 truncate text-[0.6875rem] text-gray-400 dark:text-gray-500">
|
||||
{path}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if target_type === 'chat' && folder_id === folder.id}
|
||||
<Check className="size-3.5 shrink-0" strokeWidth="2" />
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
<div class="px-2 py-1 text-[0.6875rem] text-gray-500 dark:text-gray-400">
|
||||
{folderOptions.length > 0 ? $i18n.t('No results found') : $i18n.t('No folders')}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else if tab === 'channels'}
|
||||
<div class="flex max-h-72 flex-col overflow-hidden" in:fly={{ x: 20, duration: 150 }}>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-[1.6875rem] w-full shrink-0 cursor-pointer items-center gap-2 rounded-xl px-2 text-[0.8125rem] hover:bg-gray-50/40 dark:hover:bg-gray-800/40"
|
||||
on:click={() => (tab = '')}
|
||||
>
|
||||
<ChevronLeft />
|
||||
<span>{$i18n.t('Channels')}</span>
|
||||
</button>
|
||||
|
||||
<div class="flex shrink-0 items-center gap-1.5 px-2 py-1">
|
||||
<Search className="size-3.5 shrink-0" strokeWidth="2.5" />
|
||||
<input
|
||||
bind:value={channelSearch}
|
||||
class="w-full bg-transparent text-[0.8125rem] outline-hidden"
|
||||
placeholder={$i18n.t('Search channels')}
|
||||
autocomplete="off"
|
||||
on:click|stopPropagation
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="overflow-y-auto scrollbar-thin">
|
||||
{#each filteredChannelOptions as channel (channel.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-[1.6875rem] w-full cursor-pointer items-center justify-between gap-2 rounded-xl bg-transparent px-2 text-[0.8125rem] hover:bg-gray-50/40 hover:text-gray-900 dark:hover:bg-gray-800/40 dark:hover:text-gray-100 {target_type ===
|
||||
'channel' && channel_id === channel.id
|
||||
? 'text-gray-900 dark:text-gray-100'
|
||||
: 'text-gray-700 dark:text-gray-300'}"
|
||||
on:click={() => selectChannel(channel.id)}
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-1.5">
|
||||
<Hashtag className="size-3.5 shrink-0" />
|
||||
<span class="min-w-0 truncate">{channelName(channel)}</span>
|
||||
</div>
|
||||
{#if target_type === 'channel' && channel_id === channel.id}
|
||||
<Check className="size-3.5 shrink-0" strokeWidth="2" />
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
<div class="px-2 py-1 text-[0.6875rem] text-gray-500 dark:text-gray-400">
|
||||
{channelOptions.length > 0 ? $i18n.t('No results found') : $i18n.t('No channels')}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</Dropdown>
|
||||
@@ -76,7 +76,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Form -->
|
||||
<div class="px-5 pb-2 flex flex-col gap-3">
|
||||
<div class="px-4 pb-2 flex flex-col gap-3">
|
||||
<!-- Name -->
|
||||
<div>
|
||||
<div class="mb-1 text-xs text-gray-500">{$i18n.t('Name')}</div>
|
||||
|
||||
@@ -1186,7 +1186,7 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="relative flex flex-col flex-1 overflow-y-auto scrollbar-hidden pt-2.5 pb-2.5"
|
||||
class="relative flex flex-col flex-1 overflow-y-auto scrollbar-hidden space-y-1.5 pt-2.5 pb-2.5"
|
||||
on:scroll={(e) => {
|
||||
if (e.target.scrollTop === 0) {
|
||||
scrollTop = 0;
|
||||
@@ -1289,7 +1289,6 @@
|
||||
<SidebarSection
|
||||
id="sidebar-models"
|
||||
bind:open={showPinnedModels}
|
||||
className="mt-0.5"
|
||||
name={$i18n.t('Models')}
|
||||
dragAndDrop={false}
|
||||
>
|
||||
@@ -1301,7 +1300,6 @@
|
||||
<SidebarSection
|
||||
id="sidebar-pinned-notes"
|
||||
bind:open={showPinnedNotes}
|
||||
className="mt-0.5"
|
||||
name={$i18n.t('Notes')}
|
||||
dragAndDrop={false}
|
||||
onAdd={async () => {
|
||||
@@ -1320,7 +1318,6 @@
|
||||
<SidebarSection
|
||||
id="sidebar-channels"
|
||||
bind:open={showChannels}
|
||||
className="mt-0.5"
|
||||
name={$i18n.t('Channels')}
|
||||
dragAndDrop={false}
|
||||
onAdd={$user?.role === 'admin' || ($user?.permissions?.features?.channels ?? true)
|
||||
@@ -1354,7 +1351,6 @@
|
||||
<SidebarSection
|
||||
id="sidebar-folders"
|
||||
bind:open={showFolders}
|
||||
className="mt-0.5"
|
||||
name={$i18n.t('Folders')}
|
||||
onAdd={() => {
|
||||
showCreateFolderModal = true;
|
||||
@@ -1406,7 +1402,6 @@
|
||||
|
||||
<SidebarSection
|
||||
id="sidebar-chats"
|
||||
className="mt-0.5"
|
||||
name={$i18n.t('Chats')}
|
||||
on:change={async (e) => {
|
||||
selectedFolder.set(null);
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Form -->
|
||||
<div class="px-5 pb-2 flex flex-col gap-3">
|
||||
<div class="px-4 pb-2 flex flex-col gap-3">
|
||||
<div>
|
||||
<div class="mb-1 text-xs text-gray-500">{$i18n.t('Name')}</div>
|
||||
<input
|
||||
|
||||
@@ -5,8 +5,9 @@
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { goto } from '$app/navigation';
|
||||
import { WEBUI_NAME, user, config, folders } from '$lib/stores';
|
||||
import { WEBUI_NAME, user, config, channels, folders } from '$lib/stores';
|
||||
import { getFolders } from '$lib/apis/folders';
|
||||
import { getChannels } from '$lib/apis/channels';
|
||||
|
||||
import {
|
||||
createAutomation,
|
||||
@@ -67,6 +68,7 @@
|
||||
let importFiles: FileList | null = null;
|
||||
let automationsImportInputElement: HTMLInputElement;
|
||||
let foldersLoaded = false;
|
||||
let channelsLoaded = false;
|
||||
|
||||
const syncHeader = () => {
|
||||
automationsLayout?.setHeader({
|
||||
@@ -151,6 +153,13 @@
|
||||
foldersLoaded = true;
|
||||
};
|
||||
|
||||
const ensureChannels = async () => {
|
||||
if (channelsLoaded || ($channels ?? []).length > 0) return;
|
||||
const res = await getChannels(localStorage.token).catch(() => null);
|
||||
if (res) channels.set(res);
|
||||
channelsLoaded = true;
|
||||
};
|
||||
|
||||
const toggleHandler = async (automation: AutomationResponse) => {
|
||||
const res = await toggleAutomationById(localStorage.token, automation.id).catch((err) => {
|
||||
toast.error(`${err}`);
|
||||
@@ -216,6 +225,16 @@
|
||||
: $i18n.t('Never');
|
||||
};
|
||||
|
||||
const formatDestination = (automation: AutomationResponse): string => {
|
||||
if (automation.data.target?.type === 'channel') {
|
||||
const channel = ($channels ?? []).find(
|
||||
(channel) => channel.id === automation.data.target?.channel_id
|
||||
);
|
||||
return channel?.name ? `#${channel.name}` : $i18n.t('Channel');
|
||||
}
|
||||
return automation.folder_id ? $i18n.t('Folder') : $i18n.t('New chat');
|
||||
};
|
||||
|
||||
const getAllAutomations = async () => {
|
||||
let currentPage = 1;
|
||||
let allAutomations: AutomationResponse[] = [];
|
||||
@@ -341,6 +360,7 @@
|
||||
|
||||
loaded = true;
|
||||
syncHeader();
|
||||
ensureChannels();
|
||||
|
||||
return () => {
|
||||
clearTimeout(searchDebounceTimer);
|
||||
@@ -580,11 +600,14 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="hidden max-w-44 shrink-0 self-center truncate text-right text-[0.6875rem] leading-5 text-gray-500 dark:text-gray-500 md:block"
|
||||
class="hidden max-w-56 shrink-0 self-center truncate text-right text-[0.6875rem] leading-5 text-gray-500 dark:text-gray-500 md:block"
|
||||
>
|
||||
<Tooltip content={formatRRule(automation.data.rrule)} className="min-w-0">
|
||||
<Tooltip
|
||||
content={`${formatRRule(automation.data.rrule)} · ${formatDestination(automation)}`}
|
||||
className="min-w-0"
|
||||
>
|
||||
<div class="truncate">
|
||||
{formatRRule(automation.data.rrule)}
|
||||
{formatRRule(automation.data.rrule)} · {formatDestination(automation)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user