This commit is contained in:
parent
9d778f6fd6
commit
8649b278a0
3 changed files with 453 additions and 1 deletions
|
|
@ -30,6 +30,8 @@ import secrets
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||||
from urllib.parse import urlparse, parse_qs, urlencode
|
from urllib.parse import urlparse, parse_qs, urlencode
|
||||||
|
|
||||||
|
|
@ -57,6 +59,14 @@ CHAT_MAX_REQUESTS_PER_HOUR = int(os.environ.get("CHAT_MAX_REQUESTS_PER_HOUR", 60
|
||||||
CHAT_MAX_REQUESTS_PER_DAY = int(os.environ.get("CHAT_MAX_REQUESTS_PER_DAY", 500))
|
CHAT_MAX_REQUESTS_PER_DAY = int(os.environ.get("CHAT_MAX_REQUESTS_PER_DAY", 500))
|
||||||
CHAT_MAX_TOKENS_PER_DAY = int(os.environ.get("CHAT_MAX_TOKENS_PER_DAY", 1000000))
|
CHAT_MAX_TOKENS_PER_DAY = int(os.environ.get("CHAT_MAX_TOKENS_PER_DAY", 1000000))
|
||||||
|
|
||||||
|
# Action endpoints configuration (#712)
|
||||||
|
WOODPECKER_TOKEN = os.environ.get("WOODPECKER_TOKEN", "")
|
||||||
|
WOODPECKER_URL = os.environ.get("WOODPECKER_URL", "http://woodpecker:8000")
|
||||||
|
FORGE_TOKEN = os.environ.get("FORGE_TOKEN", "")
|
||||||
|
FORGE_URL = os.environ.get("FORGE_URL", "http://forgejo:3000")
|
||||||
|
FORGE_OWNER = os.environ.get("FORGE_OWNER", "")
|
||||||
|
FORGE_REPO = os.environ.get("FORGE_REPO", "")
|
||||||
|
|
||||||
# Allowed users - disinto-admin always allowed; CSV allowlist extends it
|
# Allowed users - disinto-admin always allowed; CSV allowlist extends it
|
||||||
_allowed_csv = os.environ.get("DISINTO_CHAT_ALLOWED_USERS", "")
|
_allowed_csv = os.environ.get("DISINTO_CHAT_ALLOWED_USERS", "")
|
||||||
ALLOWED_USERS = {"disinto-admin"}
|
ALLOWED_USERS = {"disinto-admin"}
|
||||||
|
|
@ -423,6 +433,181 @@ def _delete_conversation(user, conv_id):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Action Endpoints (#712)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def _write_action_record(user, conv_id, action_type, payload, response_data):
|
||||||
|
"""Write an action record to the conversation history."""
|
||||||
|
record = {
|
||||||
|
"ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||||
|
"user": user,
|
||||||
|
"role": "action",
|
||||||
|
"action_type": action_type,
|
||||||
|
"payload": payload,
|
||||||
|
"response": response_data,
|
||||||
|
}
|
||||||
|
conv_path = _get_conversation_path(user, conv_id)
|
||||||
|
_ensure_user_dir(user)
|
||||||
|
with open(conv_path, "a", encoding="utf-8") as f:
|
||||||
|
f.write(json.dumps(record, ensure_ascii=False) + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
def _trigger_woodpecker_pipeline(repo, branch):
|
||||||
|
"""Trigger a Woodpecker CI pipeline for the given repo and branch.
|
||||||
|
|
||||||
|
Woodpecker API: POST /api/v1/repos/{owner}/{repo}/pipeline
|
||||||
|
Returns dict with success status and response data.
|
||||||
|
"""
|
||||||
|
if not WOODPECKER_TOKEN:
|
||||||
|
return {"success": False, "error": "WOODPECKER_TOKEN not configured"}
|
||||||
|
|
||||||
|
if not FORGE_OWNER or not FORGE_REPO:
|
||||||
|
return {"success": False, "error": "FORGE_OWNER and FORGE_REPO not configured"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
url = f"{WOODPECKER_URL}/api/v1/repos/{FORGE_OWNER}/{FORGE_REPO}/pipeline"
|
||||||
|
data = json.dumps({"branch": branch, "event": "push"}).encode("utf-8")
|
||||||
|
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
data=data,
|
||||||
|
headers={
|
||||||
|
"Authorization": f"token {WOODPECKER_TOKEN}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json",
|
||||||
|
},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||||
|
result = json.loads(resp.read().decode())
|
||||||
|
return {"success": True, "data": result}
|
||||||
|
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
try:
|
||||||
|
body = json.loads(e.read().decode())
|
||||||
|
return {"success": False, "error": str(e.reason), "details": body}
|
||||||
|
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||||
|
return {"success": False, "error": str(e.reason)}
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
return {"success": False, "error": f"Network error: {e.reason}"}
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
return {"success": False, "error": f"Invalid JSON response: {e}"}
|
||||||
|
except Exception as e:
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def _create_forgejo_issue(title, body, labels=None):
|
||||||
|
"""Create a Forgejo issue.
|
||||||
|
|
||||||
|
Forgejo API: POST /api/v1/repos/{owner}/{repo}/issues
|
||||||
|
Returns dict with success status and response data.
|
||||||
|
"""
|
||||||
|
if not FORGE_TOKEN:
|
||||||
|
return {"success": False, "error": "FORGE_TOKEN not configured"}
|
||||||
|
|
||||||
|
if not FORGE_OWNER or not FORGE_REPO:
|
||||||
|
return {"success": False, "error": "FORGE_OWNER and FORGE_REPO not configured"}
|
||||||
|
|
||||||
|
if not title:
|
||||||
|
return {"success": False, "error": "Title is required"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
url = f"{FORGE_URL}/api/v1/repos/{FORGE_OWNER}/{FORGE_REPO}/issues"
|
||||||
|
payload = {
|
||||||
|
"title": title,
|
||||||
|
"body": body or "",
|
||||||
|
}
|
||||||
|
if labels:
|
||||||
|
payload["labels"] = labels
|
||||||
|
|
||||||
|
data = json.dumps(payload).encode("utf-8")
|
||||||
|
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
data=data,
|
||||||
|
headers={
|
||||||
|
"Authorization": f"token {FORGE_TOKEN}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json",
|
||||||
|
},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||||
|
result = json.loads(resp.read().decode())
|
||||||
|
return {"success": True, "data": result}
|
||||||
|
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
try:
|
||||||
|
body = json.loads(e.read().decode())
|
||||||
|
return {"success": False, "error": str(e.reason), "details": body}
|
||||||
|
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||||
|
return {"success": False, "error": str(e.reason)}
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
return {"success": False, "error": f"Network error: {e.reason}"}
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
return {"success": False, "error": f"Invalid JSON response: {e}"}
|
||||||
|
except Exception as e:
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def _create_forgejo_pull_request(head, base, title, body=None):
|
||||||
|
"""Create a Forgejo pull request.
|
||||||
|
|
||||||
|
Forgejo API: POST /api/v1/repos/{owner}/{repo}/pulls
|
||||||
|
Returns dict with success status and response data.
|
||||||
|
"""
|
||||||
|
if not FORGE_TOKEN:
|
||||||
|
return {"success": False, "error": "FORGE_TOKEN not configured"}
|
||||||
|
|
||||||
|
if not FORGE_OWNER or not FORGE_REPO:
|
||||||
|
return {"success": False, "error": "FORGE_OWNER and FORGE_REPO not configured"}
|
||||||
|
|
||||||
|
if not head or not base or not title:
|
||||||
|
return {"success": False, "error": "head, base, and title are required"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
url = f"{FORGE_URL}/api/v1/repos/{FORGE_OWNER}/{FORGE_REPO}/pulls"
|
||||||
|
payload = {
|
||||||
|
"head": head,
|
||||||
|
"base": base,
|
||||||
|
"title": title,
|
||||||
|
"body": body or "",
|
||||||
|
}
|
||||||
|
|
||||||
|
data = json.dumps(payload).encode("utf-8")
|
||||||
|
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
data=data,
|
||||||
|
headers={
|
||||||
|
"Authorization": f"token {FORGE_TOKEN}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json",
|
||||||
|
},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||||
|
result = json.loads(resp.read().decode())
|
||||||
|
return {"success": True, "data": result}
|
||||||
|
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
try:
|
||||||
|
body = json.loads(e.read().decode())
|
||||||
|
return {"success": False, "error": str(e.reason), "details": body}
|
||||||
|
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||||
|
return {"success": False, "error": str(e.reason)}
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
return {"success": False, "error": f"Network error: {e.reason}"}
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
return {"success": False, "error": f"Invalid JSON response: {e}"}
|
||||||
|
except Exception as e:
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
class ChatHandler(BaseHTTPRequestHandler):
|
class ChatHandler(BaseHTTPRequestHandler):
|
||||||
"""HTTP request handler for disinto-chat with Forgejo OAuth."""
|
"""HTTP request handler for disinto-chat with Forgejo OAuth."""
|
||||||
|
|
||||||
|
|
@ -549,6 +734,16 @@ class ChatHandler(BaseHTTPRequestHandler):
|
||||||
parsed = urlparse(self.path)
|
parsed = urlparse(self.path)
|
||||||
path = parsed.path
|
path = parsed.path
|
||||||
|
|
||||||
|
# Action endpoints (#712)
|
||||||
|
if path in ("/chat/action/ci-run", "/chat/action/issue-create", "/chat/action/pr-create"):
|
||||||
|
user = self._require_session()
|
||||||
|
if not user:
|
||||||
|
return
|
||||||
|
if not self._check_forwarded_user(user):
|
||||||
|
return
|
||||||
|
self.handle_action(user, path)
|
||||||
|
return
|
||||||
|
|
||||||
# New conversation endpoint (session required)
|
# New conversation endpoint (session required)
|
||||||
if path == "/chat/new":
|
if path == "/chat/new":
|
||||||
user = self._require_session()
|
user = self._require_session()
|
||||||
|
|
@ -901,6 +1096,75 @@ class ChatHandler(BaseHTTPRequestHandler):
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
self.wfile.write(json.dumps({"conversation_id": conv_id}, ensure_ascii=False).encode("utf-8"))
|
self.wfile.write(json.dumps({"conversation_id": conv_id}, ensure_ascii=False).encode("utf-8"))
|
||||||
|
|
||||||
|
def handle_action(self, user, path):
|
||||||
|
"""Handle action requests (ci-run, issue-create, pr-create)."""
|
||||||
|
# Determine action type from path
|
||||||
|
action_type = path.replace("/chat/action/", "")
|
||||||
|
|
||||||
|
# Read request body
|
||||||
|
content_length = int(self.headers.get("Content-Length", 0))
|
||||||
|
if content_length == 0:
|
||||||
|
self.send_error_page(400, "No request body provided")
|
||||||
|
return
|
||||||
|
|
||||||
|
body = self.rfile.read(content_length)
|
||||||
|
try:
|
||||||
|
request_data = json.loads(body.decode("utf-8"))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
self.send_error_page(400, "Invalid JSON in request body")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get conversation ID from request or session
|
||||||
|
conv_id = request_data.get("conversation_id")
|
||||||
|
if not conv_id or not _validate_conversation_id(conv_id):
|
||||||
|
# Fall back to session-based conversation if available
|
||||||
|
# For now, we'll use a default or generate one
|
||||||
|
conv_id = request_data.get("conversation_id")
|
||||||
|
if not conv_id:
|
||||||
|
self.send_error_page(400, "conversation_id is required")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Route to appropriate handler
|
||||||
|
if action_type == "ci-run":
|
||||||
|
repo = request_data.get("repo")
|
||||||
|
branch = request_data.get("branch")
|
||||||
|
if not repo or not branch:
|
||||||
|
self.send_error_page(400, "repo and branch are required for ci-run")
|
||||||
|
return
|
||||||
|
payload = {"repo": repo, "branch": branch}
|
||||||
|
result = _trigger_woodpecker_pipeline(repo, branch)
|
||||||
|
elif action_type == "issue-create":
|
||||||
|
title = request_data.get("title")
|
||||||
|
body_text = request_data.get("body", "")
|
||||||
|
labels = request_data.get("labels", [])
|
||||||
|
if not title:
|
||||||
|
self.send_error_page(400, "title is required for issue-create")
|
||||||
|
return
|
||||||
|
payload = {"title": title, "body": body_text, "labels": labels}
|
||||||
|
result = _create_forgejo_issue(title, body_text, labels)
|
||||||
|
elif action_type == "pr-create":
|
||||||
|
head = request_data.get("head")
|
||||||
|
base = request_data.get("base")
|
||||||
|
title = request_data.get("title")
|
||||||
|
body_text = request_data.get("body", "")
|
||||||
|
if not head or not base or not title:
|
||||||
|
self.send_error_page(400, "head, base, and title are required for pr-create")
|
||||||
|
return
|
||||||
|
payload = {"head": head, "base": base, "title": title, "body": body_text}
|
||||||
|
result = _create_forgejo_pull_request(head, base, title, body_text)
|
||||||
|
else:
|
||||||
|
self.send_error_page(404, f"Unknown action type: {action_type}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Log the action to history
|
||||||
|
_write_action_record(user, conv_id, action_type, payload, result)
|
||||||
|
|
||||||
|
# Send response
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(json.dumps(result, ensure_ascii=False).encode("utf-8"))
|
||||||
|
|
||||||
def do_DELETE(self):
|
def do_DELETE(self):
|
||||||
"""Handle DELETE requests."""
|
"""Handle DELETE requests."""
|
||||||
parsed = urlparse(self.path)
|
parsed = urlparse(self.path)
|
||||||
|
|
|
||||||
|
|
@ -161,6 +161,56 @@
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
/* Action button container */
|
||||||
|
.action-buttons {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.action-btn {
|
||||||
|
background: #0f3460;
|
||||||
|
border: 1px solid #e94560;
|
||||||
|
color: #e94560;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.action-btn:hover {
|
||||||
|
background: #e94560;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.action-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.action-btn .spinner {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border: 2px solid currentColor;
|
||||||
|
border-top-color: transparent;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
.action-btn.success {
|
||||||
|
background: #1a1a2e;
|
||||||
|
border-color: #4ade80;
|
||||||
|
color: #4ade80;
|
||||||
|
}
|
||||||
|
.action-btn.error {
|
||||||
|
background: #1a1a2e;
|
||||||
|
border-color: #f87171;
|
||||||
|
color: #f87171;
|
||||||
|
}
|
||||||
.input-area {
|
.input-area {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
|
@ -404,11 +454,28 @@
|
||||||
function addMessage(role, content, streaming = false) {
|
function addMessage(role, content, streaming = false) {
|
||||||
const msgDiv = document.createElement('div');
|
const msgDiv = document.createElement('div');
|
||||||
msgDiv.className = `message ${role}`;
|
msgDiv.className = `message ${role}`;
|
||||||
|
|
||||||
|
// Parse action markers if this is an assistant message
|
||||||
|
let contentHtml = escapeHtml(content);
|
||||||
|
let actions = [];
|
||||||
|
|
||||||
|
if (role === 'assistant' && !streaming) {
|
||||||
|
const parsed = parseActionMarkers(content, messagesDiv.children.length);
|
||||||
|
contentHtml = parsed.html;
|
||||||
|
actions = parsed.actions;
|
||||||
|
}
|
||||||
|
|
||||||
msgDiv.innerHTML = `
|
msgDiv.innerHTML = `
|
||||||
<div class="role">${role}</div>
|
<div class="role">${role}</div>
|
||||||
<div class="content${streaming ? ' streaming' : ''}">${escapeHtml(content)}</div>
|
<div class="content${streaming ? ' streaming' : ''}">${contentHtml}</div>
|
||||||
`;
|
`;
|
||||||
messagesDiv.appendChild(msgDiv);
|
messagesDiv.appendChild(msgDiv);
|
||||||
|
|
||||||
|
// Render action buttons for assistant messages
|
||||||
|
if (actions.length > 0) {
|
||||||
|
renderActionButtons(msgDiv, actions, messagesDiv.children.length - 1);
|
||||||
|
}
|
||||||
|
|
||||||
messagesDiv.scrollTop = messagesDiv.scrollHeight;
|
messagesDiv.scrollTop = messagesDiv.scrollHeight;
|
||||||
return msgDiv.querySelector('.content');
|
return msgDiv.querySelector('.content');
|
||||||
}
|
}
|
||||||
|
|
@ -430,6 +497,121 @@
|
||||||
return div.innerHTML.replace(/\n/g, '<br>');
|
return div.innerHTML.replace(/\n/g, '<br>');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Action buttons state - track pending actions by message index
|
||||||
|
const pendingActions = new Map();
|
||||||
|
|
||||||
|
// Parse action markers from content and return HTML with action buttons
|
||||||
|
function parseActionMarkers(content, messageIndex) {
|
||||||
|
const actionPattern = /<action type="([^"]+)">(.*?)<\/action>/gs;
|
||||||
|
const hasActions = actionPattern.test(content);
|
||||||
|
|
||||||
|
if (!hasActions) {
|
||||||
|
return { html: escapeHtml(content), actions: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset pending actions for this message
|
||||||
|
pendingActions.set(messageIndex, []);
|
||||||
|
|
||||||
|
let html = content;
|
||||||
|
const actions = [];
|
||||||
|
|
||||||
|
// Replace action markers with placeholders and collect actions
|
||||||
|
html = html.replace(actionPattern, (match, type, jsonStr) => {
|
||||||
|
try {
|
||||||
|
const action = JSON.parse(jsonStr);
|
||||||
|
actions.push({ type, payload: action, id: `${messageIndex}-${actions.length}` });
|
||||||
|
// Replace with placeholder that will be rendered as button
|
||||||
|
return `<div class="action-placeholder" data-action-id="${actions[actions.length - 1].id}"></div>`;
|
||||||
|
} catch (e) {
|
||||||
|
// If JSON parsing fails, keep the original marker
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Convert newlines to <br> for HTML output
|
||||||
|
html = html.replace(/\n/g, '<br>');
|
||||||
|
|
||||||
|
return { html, actions };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render action buttons for a message
|
||||||
|
function renderActionButtons(msgDiv, actions, messageIndex) {
|
||||||
|
if (actions.length === 0) return;
|
||||||
|
|
||||||
|
const buttonsDiv = document.createElement('div');
|
||||||
|
buttonsDiv.className = 'action-buttons';
|
||||||
|
|
||||||
|
actions.forEach(action => {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = 'action-btn';
|
||||||
|
btn.dataset.actionId = action.id;
|
||||||
|
btn.dataset.messageIndex = messageIndex;
|
||||||
|
|
||||||
|
let btnText = 'Execute';
|
||||||
|
let icon = '';
|
||||||
|
|
||||||
|
switch (action.type) {
|
||||||
|
case 'ci-run':
|
||||||
|
icon = '🚀';
|
||||||
|
btnText = `Run CI for ${action.payload.branch || 'default'}`;
|
||||||
|
break;
|
||||||
|
case 'issue-create':
|
||||||
|
icon = '📝';
|
||||||
|
btnText = `Create Issue: ${action.payload.title ? action.payload.title.substring(0, 30) + (action.payload.title.length > 30 ? '...' : '') : 'New Issue'}`;
|
||||||
|
break;
|
||||||
|
case 'pr-create':
|
||||||
|
icon = '🔀';
|
||||||
|
btnText = `Create PR: ${action.payload.title ? action.payload.title.substring(0, 30) + (action.payload.title.length > 30 ? '...' : '') : 'New PR'}`;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
btnText = `Execute ${action.type}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.innerHTML = `<span>${icon}</span><span>${btnText}</span>`;
|
||||||
|
|
||||||
|
btn.addEventListener('click', () => executeAction(btn, action));
|
||||||
|
buttonsDiv.appendChild(btn);
|
||||||
|
});
|
||||||
|
|
||||||
|
msgDiv.appendChild(buttonsDiv);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute an action
|
||||||
|
async function executeAction(btn, action) {
|
||||||
|
const messageIndex = btn.dataset.messageIndex;
|
||||||
|
const actionId = btn.dataset.actionId;
|
||||||
|
|
||||||
|
// Disable button and show loading state
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<span class="spinner"></span> Executing...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/chat/action/${action.type}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
...action.payload,
|
||||||
|
conversation_id: currentConversationId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
btn.className = 'action-btn success';
|
||||||
|
btn.innerHTML = '<span>✓</span> Executed successfully';
|
||||||
|
} else {
|
||||||
|
btn.className = 'action-btn error';
|
||||||
|
btn.innerHTML = `<span>✗</span> Error: ${result.error || 'Unknown error'}`;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
btn.className = 'action-btn error';
|
||||||
|
btn.innerHTML = `<span>✗</span> Error: ${error.message}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Send message handler
|
// Send message handler
|
||||||
async function sendMessage() {
|
async function sendMessage() {
|
||||||
const message = textarea.value.trim();
|
const message = textarea.value.trim();
|
||||||
|
|
|
||||||
|
|
@ -518,6 +518,12 @@ services:
|
||||||
CHAT_MAX_REQUESTS_PER_HOUR: ${CHAT_MAX_REQUESTS_PER_HOUR:-60}
|
CHAT_MAX_REQUESTS_PER_HOUR: ${CHAT_MAX_REQUESTS_PER_HOUR:-60}
|
||||||
CHAT_MAX_REQUESTS_PER_DAY: ${CHAT_MAX_REQUESTS_PER_DAY:-500}
|
CHAT_MAX_REQUESTS_PER_DAY: ${CHAT_MAX_REQUESTS_PER_DAY:-500}
|
||||||
CHAT_MAX_TOKENS_PER_DAY: ${CHAT_MAX_TOKENS_PER_DAY:-1000000}
|
CHAT_MAX_TOKENS_PER_DAY: ${CHAT_MAX_TOKENS_PER_DAY:-1000000}
|
||||||
|
# Action endpoints (#712)
|
||||||
|
WOODPECKER_TOKEN: ${WOODPECKER_TOKEN:-}
|
||||||
|
WOODPECKER_URL: http://woodpecker:8000
|
||||||
|
FORGE_TOKEN: ${FORGE_TOKEN:-}
|
||||||
|
FORGE_OWNER: ${FORGE_OWNER:-}
|
||||||
|
FORGE_REPO: ${FORGE_REPO:-}
|
||||||
networks:
|
networks:
|
||||||
- disinto-net
|
- disinto-net
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue