feat: adopt per-bot account api key for gateway attribution and improve post distinctness
- Add `account_api_key` field to `BotState` and `_adopt_account_api_key()` method that fetches the bot's own `users.api_key` from its profile JSON after auth, switching `LLMClient` to use it instead of the shared bootstrap key, routing gateway spend through the bot's user identity - Replace `interleave_by_author` with a simpler greedy algorithm that picks the first row whose owner differs from the last picked, removing the multi-queue priority logic - Extend `_ai_quota` response with `unlimited`, `requests`, and `last_used` fields sourced from new `user_spend_24h()` analytics helper - Pass `recent_post_titles` from `BotState` into `LLMClient.generate_post()` and add a `distinct_rule` prompt instructing the model to avoid repeating framing, examples, or opinions from the bot's last six posts - Remove early-return dedup check in `ArticleRegistry.admit` that skipped articles already held by the same bot owner, allowing re-admission under a different category - Render bot username as a clickable link with avatar in the admin bots table
This commit is contained in:
+13
-26
@@ -2243,34 +2243,21 @@ def paginate(
|
||||
|
||||
|
||||
def interleave_by_author(rows, uid_key="user_uid"):
|
||||
queues: dict = {}
|
||||
appearance: list = []
|
||||
for position, row in enumerate(rows):
|
||||
owner = row.get(uid_key)
|
||||
queue = queues.get(owner)
|
||||
if queue is None:
|
||||
queue = queues[owner] = []
|
||||
appearance.append(owner)
|
||||
queue.append((position, row))
|
||||
remaining = list(rows)
|
||||
spread = []
|
||||
last_owner = object()
|
||||
while len(spread) < len(rows):
|
||||
chosen = None
|
||||
for owner in appearance:
|
||||
queue = queues[owner]
|
||||
if not queue or owner == last_owner:
|
||||
continue
|
||||
count = len(queue)
|
||||
head_position = queue[0][0]
|
||||
if (
|
||||
chosen is None
|
||||
or count > chosen[0]
|
||||
or (count == chosen[0] and head_position < chosen[1])
|
||||
):
|
||||
chosen = (count, head_position, owner)
|
||||
owner = last_owner if chosen is None else chosen[2]
|
||||
spread.append(queues[owner].pop(0)[1])
|
||||
last_owner = owner
|
||||
while remaining:
|
||||
pick = next(
|
||||
(
|
||||
index
|
||||
for index, row in enumerate(remaining)
|
||||
if row.get(uid_key) != last_owner
|
||||
),
|
||||
0,
|
||||
)
|
||||
row = remaining.pop(pick)
|
||||
spread.append(row)
|
||||
last_owner = row.get(uid_key)
|
||||
return spread
|
||||
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import logging
|
||||
|
||||
from devplacepy.services.manager import service_manager
|
||||
from devplacepy.services.openai_gateway.analytics import user_spend_24h
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -15,13 +16,21 @@ def _ai_quota(
|
||||
return None
|
||||
try:
|
||||
limit = devii.daily_limit_for("user", is_admin)
|
||||
spent = devii.spent_24h("user", user_uid)
|
||||
turns = devii.hub().ledger.turns_24h("user", user_uid)
|
||||
except Exception:
|
||||
logger.exception("Failed to compute AI quota for %s", user_uid)
|
||||
return None
|
||||
used_pct = round(min(100.0, spent / limit * 100), 1) if limit > 0 else 0.0
|
||||
quota = {"used_pct": used_pct, "turns": turns}
|
||||
gateway = user_spend_24h(user_uid)
|
||||
spent = gateway["spent_usd"]
|
||||
unlimited = limit <= 0
|
||||
used_pct = 0.0 if unlimited else round(min(100.0, spent / limit * 100), 1)
|
||||
quota = {
|
||||
"used_pct": used_pct,
|
||||
"unlimited": unlimited,
|
||||
"turns": turns,
|
||||
"requests": gateway["requests"],
|
||||
"last_used": gateway["last_used"],
|
||||
}
|
||||
if include_cost:
|
||||
quota["spent_usd"] = round(spent, 4)
|
||||
quota["limit_usd"] = round(limit, 2)
|
||||
|
||||
@@ -55,8 +55,9 @@ class DevPlaceBot:
|
||||
self.state = BotState.load(cfg.state_path)
|
||||
if not self.state.persona:
|
||||
self.state.persona = random.choice(PERSONAS)
|
||||
self._gateway_fallback_key = cfg.api_key
|
||||
self.llm = LLMClient(
|
||||
cfg.api_key,
|
||||
self.state.account_api_key or cfg.api_key,
|
||||
cfg.api_url,
|
||||
cfg.model,
|
||||
cfg.input_cost_per_1m,
|
||||
@@ -212,6 +213,7 @@ class DevPlaceBot:
|
||||
desc,
|
||||
self.state.persona,
|
||||
cat,
|
||||
self.state.recent_post_titles,
|
||||
)
|
||||
self._post_cache.append(
|
||||
(a["title"], post_title or "", desc, content, cat)
|
||||
@@ -314,6 +316,37 @@ class DevPlaceBot:
|
||||
self._log(f"Signup failed, retrying as {self.state.username}")
|
||||
return False
|
||||
|
||||
async def _fetch_account_api_key(self) -> str:
|
||||
if not self.state.username:
|
||||
return ""
|
||||
try:
|
||||
key = await self.b.page.evaluate(
|
||||
"""async (username) => {
|
||||
const resp = await fetch(`/profile/${username}`, {
|
||||
headers: {Accept: 'application/json'},
|
||||
});
|
||||
if (!resp.ok) return '';
|
||||
const data = await resp.json();
|
||||
return data.api_key || '';
|
||||
}""",
|
||||
self.state.username,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("fetch account api key failed: %s", e)
|
||||
return ""
|
||||
return (key or "").strip()
|
||||
|
||||
async def _adopt_account_api_key(self) -> None:
|
||||
if self.state.account_api_key:
|
||||
return
|
||||
key = await self._fetch_account_api_key()
|
||||
if not key:
|
||||
return
|
||||
self.state.account_api_key = key
|
||||
self.llm.api_key = key
|
||||
self._save()
|
||||
self._log("Adopted account API key for gateway calls")
|
||||
|
||||
async def _read_like_human(self, min_s: float = 3.0, max_s: float = 90.0) -> None:
|
||||
if not self.state.reading_speed_wpm:
|
||||
self.state.reading_speed_wpm = random.randint(180, 320)
|
||||
@@ -1540,6 +1573,7 @@ class DevPlaceBot:
|
||||
desc,
|
||||
self.state.persona,
|
||||
topic,
|
||||
self.state.recent_post_titles,
|
||||
)
|
||||
if not content:
|
||||
self._log("Create post: generation failed/empty")
|
||||
@@ -1559,6 +1593,7 @@ class DevPlaceBot:
|
||||
article_desc,
|
||||
self.state.persona,
|
||||
topic,
|
||||
self.state.recent_post_titles,
|
||||
)
|
||||
if not content:
|
||||
self._log("Create post: regeneration failed/empty")
|
||||
@@ -1588,7 +1623,7 @@ class DevPlaceBot:
|
||||
if not type_title:
|
||||
type_title = clean_title
|
||||
|
||||
ts = f"input[name='topic'][value='{topic}']"
|
||||
ts = f"#create-post-modal input[name='topic'][value='{topic}']"
|
||||
if await b.page.locator(ts).count() == 0:
|
||||
self._log(f"Create post: topic radio '{topic}' not found")
|
||||
else:
|
||||
@@ -1596,16 +1631,18 @@ class DevPlaceBot:
|
||||
await b._idle(0.2, 0.5)
|
||||
|
||||
if type_title:
|
||||
ok = await b.fill("input[name='title']", type_title)
|
||||
ok = await b.fill("#create-post-modal input[name='title']", type_title)
|
||||
if not ok:
|
||||
self._log("Create post: title input not found")
|
||||
await b._idle(0.2, 0.4)
|
||||
|
||||
content_sel = "textarea[name='content']"
|
||||
content_sel = "#create-post-modal textarea[name='content']"
|
||||
if await b.page.locator(content_sel).count() == 0:
|
||||
self._log("Create post: content textarea not found")
|
||||
return False
|
||||
await self._type_like_human(content_sel, content[:5000])
|
||||
if not await self._type_like_human(content_sel, content[:5000]):
|
||||
self._log("Create post: typing content into modal failed")
|
||||
return False
|
||||
await b._idle(0.5, 1.5)
|
||||
|
||||
submit_sels = [
|
||||
@@ -1623,13 +1660,23 @@ class DevPlaceBot:
|
||||
self._log("Create post: submit button not found")
|
||||
return False
|
||||
|
||||
post_url = await b.url()
|
||||
for _ in range(12):
|
||||
if "/posts/" in post_url:
|
||||
break
|
||||
await asyncio.sleep(1)
|
||||
post_url = await b.url()
|
||||
if "/posts/" not in post_url:
|
||||
self._log(f"Create post: submit did not create a post (url={post_url[-45:]})")
|
||||
return False
|
||||
self.state.created_posts += 1
|
||||
self.state.posted_content_hashes.append(content_hash)
|
||||
await asyncio.sleep(2)
|
||||
post_url = await b.url()
|
||||
if "/posts/" in post_url and post_url not in self.state.own_post_urls:
|
||||
if type_title:
|
||||
self.state.recent_post_titles.append(type_title)
|
||||
self.state.recent_post_titles = self.state.recent_post_titles[-8:]
|
||||
if post_url not in self.state.own_post_urls:
|
||||
self.state.own_post_urls.append(post_url)
|
||||
if "/posts/" in post_url and post_url not in self.state.known_posts:
|
||||
if post_url not in self.state.known_posts:
|
||||
self.state.known_posts.append(post_url)
|
||||
self._action(
|
||||
"POST",
|
||||
@@ -2786,6 +2833,8 @@ class DevPlaceBot:
|
||||
await asyncio.sleep(60)
|
||||
continue
|
||||
|
||||
await self._adopt_account_api_key()
|
||||
|
||||
self._bind_monitor()
|
||||
|
||||
if self._ai_decisions:
|
||||
|
||||
@@ -5,7 +5,7 @@ import logging
|
||||
import random
|
||||
import re
|
||||
import time
|
||||
from typing import Any
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -156,7 +156,12 @@ class LLMClient:
|
||||
return text.strip()
|
||||
|
||||
def generate_post(
|
||||
self, title: str, desc: str, persona: str = "", category: str = ""
|
||||
self,
|
||||
title: str,
|
||||
desc: str,
|
||||
persona: str = "",
|
||||
category: str = "",
|
||||
recent_titles: Optional[list[str]] = None,
|
||||
) -> str:
|
||||
persona_extras = {
|
||||
"enthusiastic_junior": "Be excited. Use **bold** for emphasis. Short excited sentences. End with a question sometimes.",
|
||||
@@ -182,6 +187,15 @@ class LLMClient:
|
||||
else " Be casual. No markdown."
|
||||
)
|
||||
category_extra = f" {category_extras.get(category, '')}" if category else ""
|
||||
distinct_rule = ""
|
||||
if recent_titles:
|
||||
recent = "; ".join(t for t in recent_titles[-6:] if t)
|
||||
if recent:
|
||||
distinct_rule = (
|
||||
f" You already posted about: {recent[:600]}. Take a completely "
|
||||
"different angle from those and do not repeat their framing, "
|
||||
"examples, or opinions."
|
||||
)
|
||||
preserve = persona in (
|
||||
"enthusiastic_junior",
|
||||
"hobbyist_maker",
|
||||
@@ -193,7 +207,8 @@ class LLMClient:
|
||||
f"You are a dev writing a social media post reacting to tech news. Write 2-4 short paragraphs."
|
||||
f"{persona_extra}{category_extra} Do not summarize the article; assume the reader already saw it. "
|
||||
f"Add your own value: a concrete opinion, an implication, a personal experience, or a pointed question. "
|
||||
f"Reference a specific detail rather than restating the headline. No em dashes. Under 300 words.",
|
||||
f"Reference a specific detail rather than restating the headline. No em dashes. Under 300 words."
|
||||
f"{distinct_rule}",
|
||||
f"News: {title}\n\n{desc[:800]}",
|
||||
)
|
||||
return self.clean(text, preserve_md=preserve)
|
||||
|
||||
@@ -121,10 +121,6 @@ class ArticleRegistry:
|
||||
return True
|
||||
try:
|
||||
entry = registry.get(title)
|
||||
if entry is not None and any(
|
||||
h.get("bot") == owner for h in entry.get("holders", [])
|
||||
):
|
||||
return True
|
||||
if not self._admit(entry, owner, category):
|
||||
return False
|
||||
self._hold(registry, title, owner, category)
|
||||
|
||||
@@ -356,10 +356,20 @@ class BotsService(BaseService):
|
||||
posts += st.created_posts
|
||||
comments += st.comments_posted
|
||||
votes += st.votes_cast
|
||||
username = (st.username if st else "") or ""
|
||||
user_cell = (
|
||||
{
|
||||
"text": username,
|
||||
"href": f"/profile/{username}",
|
||||
"avatar": username,
|
||||
}
|
||||
if username
|
||||
else "-"
|
||||
)
|
||||
rows.append(
|
||||
[
|
||||
f"bot{slot}",
|
||||
(st.username if st else "") or "-",
|
||||
user_cell,
|
||||
(st.persona if st else "") or "-",
|
||||
"running" if alive else "stopped",
|
||||
st.created_posts if st else 0,
|
||||
|
||||
@@ -14,6 +14,7 @@ class BotState:
|
||||
email: str = ""
|
||||
password: str = ""
|
||||
uid: str = ""
|
||||
account_api_key: str = ""
|
||||
persona: str = ""
|
||||
handle_candidates: list[str] = field(default_factory=list)
|
||||
profile_filled: bool = False
|
||||
@@ -25,6 +26,7 @@ class BotState:
|
||||
known_posts: list[str] = field(default_factory=list)
|
||||
created_posts: int = 0
|
||||
posted_content_hashes: list[str] = field(default_factory=list)
|
||||
recent_post_titles: list[str] = field(default_factory=list)
|
||||
comments_posted: int = 0
|
||||
quality_rejections: int = 0
|
||||
votes_cast: int = 0
|
||||
|
||||
@@ -453,6 +453,29 @@ def _user_hourly(rows: list[dict]) -> list[dict]:
|
||||
return out
|
||||
|
||||
|
||||
def user_spend_24h(owner_id: str, hours: int = 24) -> dict:
|
||||
hours = max(1, min(hours, MAX_WINDOW_HOURS))
|
||||
zero = {"requests": 0, "spent_usd": 0.0, "last_used": None}
|
||||
if not owner_id or GATEWAY_LEDGER not in db.tables:
|
||||
return zero
|
||||
cutoff = _iso(_now() - timedelta(hours=hours))
|
||||
rows = list(
|
||||
db.query(
|
||||
f"SELECT COUNT(*) AS requests, COALESCE(SUM(cost_usd), 0) AS spent, "
|
||||
f"MAX(created_at) AS last_used FROM {GATEWAY_LEDGER} "
|
||||
"WHERE owner_id = :oid AND created_at >= :cutoff",
|
||||
oid=owner_id,
|
||||
cutoff=cutoff,
|
||||
)
|
||||
)
|
||||
row = rows[0] if rows else {}
|
||||
return {
|
||||
"requests": int(row.get("requests") or 0),
|
||||
"spent_usd": float(row.get("spent") or 0.0),
|
||||
"last_used": row.get("last_used"),
|
||||
}
|
||||
|
||||
|
||||
def build_user_usage(
|
||||
owner_id: str, hours: int = 24, pricing: Optional[Pricing] = None
|
||||
) -> dict:
|
||||
|
||||
@@ -816,6 +816,40 @@ button.comment-form-submit:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.comment-action-btn .label,
|
||||
.comment-form-actions .label,
|
||||
.comment-actions .reaction-add-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.comment-action-btn,
|
||||
.comment-form-actions button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.comment-action-btn .icon,
|
||||
.comment-form-actions .icon {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.post-detail-actions .label,
|
||||
.post-detail-actions .bookmark-label,
|
||||
.post-detail-actions .reaction-add-label,
|
||||
.gist-detail-actions .label,
|
||||
.gist-detail-actions .bookmark-label,
|
||||
.gist-detail-actions .reaction-add-label,
|
||||
.project-detail-actions .label,
|
||||
.project-detail-actions .bookmark-label,
|
||||
.project-detail-actions .reaction-add-label {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 360px) {
|
||||
.comment-form {
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -13,6 +13,10 @@
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.reaction-list:not(:has(.reaction-chip:not([hidden]))) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.reaction-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -59,25 +63,28 @@
|
||||
.reaction-add-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
padding: 0.15rem 0.6rem;
|
||||
border: 1px dashed var(--border-light);
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
background: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.4;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.reaction-add-btn:hover {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.reaction-add-icon {
|
||||
font-size: 1rem;
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
@@ -103,6 +110,14 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.reaction-palette {
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
max-width: calc(100vw - 2 * var(--space-md));
|
||||
}
|
||||
}
|
||||
|
||||
.reaction-palette-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
|
||||
@@ -194,6 +194,18 @@
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.metric-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.metric-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -89,12 +89,12 @@ export class CommentManager {
|
||||
actions.className = "comment-form-actions";
|
||||
const save = document.createElement("button");
|
||||
save.type = "submit";
|
||||
save.className = "btn btn-primary btn-sm";
|
||||
save.textContent = "Save";
|
||||
save.className = "btn btn-primary btn-sm comment-form-submit";
|
||||
save.innerHTML = '<span class="icon">💾</span><span class="label"> Save</span>';
|
||||
const cancel = document.createElement("button");
|
||||
cancel.type = "button";
|
||||
cancel.className = "comment-action-btn";
|
||||
cancel.textContent = "Cancel";
|
||||
cancel.className = "comment-action-btn comment-form-cancel";
|
||||
cancel.innerHTML = '<span class="icon">✕</span><span class="label"> Cancel</span>';
|
||||
cancel.addEventListener("click", () => {
|
||||
form.remove();
|
||||
text.style.display = "";
|
||||
@@ -174,8 +174,8 @@ export class CommentManager {
|
||||
|
||||
const cancel = document.createElement("button");
|
||||
cancel.type = "button";
|
||||
cancel.className = "comment-action-btn comment-reply-cancel";
|
||||
cancel.textContent = "Cancel";
|
||||
cancel.className = "comment-action-btn comment-reply-cancel comment-form-cancel";
|
||||
cancel.innerHTML = '<span class="icon">✕</span><span class="label"> Cancel</span>';
|
||||
cancel.addEventListener("click", () => form.remove());
|
||||
form.querySelector(".comment-form-actions").appendChild(cancel);
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ export class DeviiTerminal {
|
||||
this.element = null;
|
||||
this.avatar = null;
|
||||
this.bindEscapeTrigger();
|
||||
this.bindDoubleClickTrigger();
|
||||
this.ready = this.init();
|
||||
}
|
||||
|
||||
@@ -55,8 +56,36 @@ export class DeviiTerminal {
|
||||
);
|
||||
}
|
||||
|
||||
bindDoubleClickTrigger() {
|
||||
document.addEventListener("dblclick", (event) => this._onDoubleClick(event));
|
||||
}
|
||||
|
||||
_onDoubleClick(event) {
|
||||
const terminal = event.target.closest("container-terminal");
|
||||
if (terminal) {
|
||||
if (!this._isInteractive(event.target)) terminal.close();
|
||||
return;
|
||||
}
|
||||
const devii = event.target.closest("devii-terminal");
|
||||
if (devii) {
|
||||
if (this.element && this.element.state !== "closed" && !this._isInteractive(event.target)) {
|
||||
this.close();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (this._canOpenOnGesture()) this.open();
|
||||
}
|
||||
|
||||
_isInteractive(target) {
|
||||
return !!target.closest("button, a, input, textarea, select");
|
||||
}
|
||||
|
||||
_canTriggerOnEscape() {
|
||||
if (this.element && this.element.state !== "closed") return false;
|
||||
return this._canOpenOnGesture();
|
||||
}
|
||||
|
||||
_canOpenOnGesture() {
|
||||
if (document.querySelector(".modal-overlay.visible")) return false;
|
||||
if (document.querySelector(".dialog-overlay.visible")) return false;
|
||||
if (document.querySelector(".context-menu.visible")) return false;
|
||||
|
||||
@@ -29,6 +29,36 @@ export class ReactionBar extends OptimisticAction {
|
||||
const isOpen = palette.hidden === false;
|
||||
this.closeAllPalettes();
|
||||
palette.hidden = isOpen;
|
||||
if (!palette.hidden) {
|
||||
this.clampToViewport(palette);
|
||||
this.ensureInView(palette);
|
||||
}
|
||||
}
|
||||
|
||||
clampToViewport(palette) {
|
||||
const margin = 8;
|
||||
palette.style.left = "";
|
||||
const rect = palette.getBoundingClientRect();
|
||||
const overflowRight = rect.right - (window.innerWidth - margin);
|
||||
if (overflowRight > 0) {
|
||||
palette.style.left = `${-overflowRight}px`;
|
||||
}
|
||||
const adjusted = palette.getBoundingClientRect();
|
||||
if (adjusted.left < margin) {
|
||||
const current = parseFloat(palette.style.left || "0");
|
||||
palette.style.left = `${current + (margin - adjusted.left)}px`;
|
||||
}
|
||||
}
|
||||
|
||||
ensureInView(palette) {
|
||||
const margin = 8;
|
||||
const rect = palette.getBoundingClientRect();
|
||||
const extra = rect.height * 0.3;
|
||||
if (rect.top < margin) {
|
||||
window.scrollBy({ top: rect.top - margin - extra, behavior: "smooth" });
|
||||
} else if (rect.bottom > window.innerHeight - margin) {
|
||||
window.scrollBy({ top: rect.bottom - (window.innerHeight - margin) + extra, behavior: "smooth" });
|
||||
}
|
||||
}
|
||||
|
||||
closeAllPalettes() {
|
||||
|
||||
@@ -191,7 +191,7 @@ class ServiceMonitor {
|
||||
const tr = document.createElement("tr");
|
||||
for (const cell of row) {
|
||||
const td = document.createElement("td");
|
||||
td.textContent = String(cell);
|
||||
this.renderCell(td, cell);
|
||||
tr.appendChild(td);
|
||||
}
|
||||
tbody.appendChild(tr);
|
||||
@@ -201,6 +201,24 @@ class ServiceMonitor {
|
||||
}
|
||||
}
|
||||
|
||||
renderCell(td, cell) {
|
||||
if (cell && typeof cell === "object" && cell.href) {
|
||||
const link = document.createElement("a");
|
||||
link.href = cell.href;
|
||||
link.className = "metric-link";
|
||||
if (cell.avatar) {
|
||||
const avatar = document.createElement("dp-avatar");
|
||||
avatar.setAttribute("username", String(cell.avatar));
|
||||
avatar.setAttribute("size", "20");
|
||||
link.appendChild(avatar);
|
||||
}
|
||||
link.appendChild(document.createTextNode(String(cell.text)));
|
||||
td.appendChild(link);
|
||||
return;
|
||||
}
|
||||
td.textContent = String(cell);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
window.ServiceMonitor = ServiceMonitor;
|
||||
|
||||
@@ -33,7 +33,7 @@ class UserAiUsage {
|
||||
const tokens = data.tokens || {};
|
||||
const latency = data.latency || {};
|
||||
const cards = [
|
||||
["Requests", String(data.requests)],
|
||||
["API requests", String(data.requests)],
|
||||
["Success", `${data.success_pct}%`],
|
||||
["Errors", `${data.error_pct}%`],
|
||||
["Cost 24h", this.money(cost.window_usd)],
|
||||
@@ -75,7 +75,9 @@ class UserAiUsage {
|
||||
}
|
||||
|
||||
money(value) {
|
||||
return `$${Number(value || 0).toFixed(4)}`;
|
||||
const amount = Number(value || 0);
|
||||
if (amount > 0 && amount < 0.0001) return "<$0.0001";
|
||||
return `$${amount.toFixed(4)}`;
|
||||
}
|
||||
|
||||
count(value) {
|
||||
|
||||
@@ -24,11 +24,11 @@
|
||||
{% include "_attachment_display.html" %}
|
||||
{% endif %}
|
||||
<div class="comment-actions">
|
||||
<button type="button" class="comment-action-btn" data-action="reply"{{ guest_disabled(user) }}><span class="icon">💬</span> Reply</button>
|
||||
<button type="button" class="comment-action-btn" data-action="reply"{{ guest_disabled(user) }}><span class="icon">💬</span><span class="label"> Reply</span></button>
|
||||
{% if owns(item.comment, user) %}
|
||||
<button type="button" class="comment-action-btn" data-action="edit" data-edit-url="/comments/edit/{{ item.comment['uid'] }}"><span class="icon">✏️</span> Edit</button>
|
||||
<button type="button" class="comment-action-btn" data-action="edit" data-edit-url="/comments/edit/{{ item.comment['uid'] }}"><span class="icon">✏️</span><span class="label"> Edit</span></button>
|
||||
<form method="POST" action="/comments/delete/{{ item.comment['uid'] }}" class="inline-form comment-delete-form" data-comment-uid="{{ item.comment['uid'] }}">
|
||||
<button type="submit" class="comment-action-btn" data-confirm="Delete this comment?"><span class="icon">🗑️</span> Delete</button>
|
||||
<button type="submit" class="comment-action-btn" data-confirm="Delete this comment?"><span class="icon">🗑️</span><span class="label"> Delete</span></button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% set _type = "comment" %}{% set _uid = item.comment['uid'] %}{% set _reactions = item.reactions %}{% include "_reaction_bar.html" %}
|
||||
|
||||
@@ -9,14 +9,14 @@
|
||||
max-size="{{ max_upload_size_mb() }}"
|
||||
max-files="{{ max_attachments_per_resource() }}"
|
||||
allowed-types="{{ allowed_file_types() }}"></dp-upload>
|
||||
<button type="submit" class="comment-form-submit"><span class="icon">📤</span> Post</button>
|
||||
<button type="submit" class="comment-form-submit"><span class="icon">📤</span><span class="label"> Post</span></button>
|
||||
</div>
|
||||
</form>
|
||||
{% else %}
|
||||
<form class="comment-form comment-form-guest" onsubmit="return false">
|
||||
<textarea name="content" placeholder="Your opinion goes here..." disabled aria-disabled="true"></textarea>
|
||||
<div class="comment-form-actions">
|
||||
<button type="button" class="comment-form-submit" disabled aria-disabled="true" title="Log in to participate"><span class="icon">📤</span> Post</button>
|
||||
<button type="button" class="comment-form-submit" disabled aria-disabled="true" title="Log in to participate"><span class="icon">📤</span><span class="label"> Post</span></button>
|
||||
{{ login_hint(user) }}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -132,9 +132,17 @@
|
||||
{% if is_admin(user) %}
|
||||
<div class="topnav-mobile-divider"></div>
|
||||
<div class="topnav-mobile-section-label">Admin</div>
|
||||
<a href="/admin" class="topnav-mobile-link {% if request.url.path == '/admin' %}active{% endif %}"><span class="icon">🛡️</span> Dashboard</a>
|
||||
<a href="/admin/users" class="topnav-mobile-link {% if request.url.path == '/admin/users' or request.url.path.startswith('/admin/users/') %}active{% endif %}"><span class="icon">👥</span> Users</a>
|
||||
<a href="/admin/news" class="topnav-mobile-link {% if request.url.path == '/admin/news' or request.url.path.startswith('/admin/news/') %}active{% endif %}"><span class="icon">📰</span> News</a>
|
||||
<a href="/admin/media" class="topnav-mobile-link {% if request.url.path == '/admin/media' or request.url.path.startswith('/admin/media/') %}active{% endif %}"><span class="icon">🖼️</span> Media</a>
|
||||
<a href="/admin/trash" class="topnav-mobile-link {% if request.url.path == '/admin/trash' or request.url.path.startswith('/admin/trash/') %}active{% endif %}"><span class="icon">🗑️</span> Trash</a>
|
||||
<a href="/admin/services" class="topnav-mobile-link {% if request.url.path == '/admin/services' or request.url.path.startswith('/admin/services/') %}active{% endif %}"><span class="icon">⚙️</span> Services</a>
|
||||
<a href="/admin/containers" class="topnav-mobile-link {% if request.url.path == '/admin/containers' or request.url.path.startswith('/admin/containers/') %}active{% endif %}"><span class="icon">📦</span> Containers</a>
|
||||
<a href="/admin/bots" class="topnav-mobile-link {% if request.url.path == '/admin/bots' or request.url.path.startswith('/admin/bots/') %}active{% endif %}"><span class="icon">🤖</span> Bot Monitor</a>
|
||||
<a href="/admin/ai-usage" class="topnav-mobile-link {% if request.url.path == '/admin/ai-usage' or request.url.path.startswith('/admin/ai-usage/') %}active{% endif %}"><span class="icon">📊</span> AI usage</a>
|
||||
<a href="/admin/audit-log" class="topnav-mobile-link {% if request.url.path == '/admin/audit-log' or request.url.path.startswith('/admin/audit-log/') %}active{% endif %}"><span class="icon">📋</span> Audit Log</a>
|
||||
<a href="/admin/notifications" class="topnav-mobile-link {% if request.url.path == '/admin/notifications' or request.url.path.startswith('/admin/notifications/') %}active{% endif %}"><span class="icon">🔔</span> Notifications</a>
|
||||
<a href="/admin/settings" class="topnav-mobile-link {% if request.url.path == '/admin/settings' or request.url.path.startswith('/admin/settings/') %}active{% endif %}"><span class="icon">🔧</span> Settings</a>
|
||||
{% endif %}
|
||||
<div class="topnav-mobile-divider"></div>
|
||||
|
||||
@@ -44,17 +44,17 @@
|
||||
</div>
|
||||
|
||||
<div class="gist-detail-actions">
|
||||
<button type="button" class="gist-star-btn" data-share="/gists/{{ gist['slug'] or gist['uid'] }}">🔗 Share</button>
|
||||
<button type="button" class="gist-star-btn" data-share="/gists/{{ gist['slug'] or gist['uid'] }}"><span class="icon">🔗</span><span class="label"> Share</span></button>
|
||||
{% if user %}
|
||||
{% set _type = "gist" %}{% set _uid = gist['uid'] %}{% set _my_vote = my_vote %}{% set _count = star_count %}{% set _btn_class = "gist-star-btn" %}{% include "_star_vote.html" %}
|
||||
{% endif %}
|
||||
{% set _type = "gist" %}{% set _uid = gist['uid'] %}{% set _bookmarked = bookmarked %}{% include "_bookmark_button.html" %}
|
||||
{% if is_owner %}
|
||||
<button type="button" class="gist-star-btn" data-modal="edit-gist-modal"><span class="icon">✏️</span>Edit</button>
|
||||
<button type="button" class="gist-star-btn" data-modal="edit-gist-modal"><span class="icon">✏️</span><span class="label">Edit</span></button>
|
||||
{% endif %}
|
||||
{% if is_owner or is_admin(user) %}
|
||||
<form method="POST" action="/gists/delete/{{ gist['slug'] or gist['uid'] }}" style="display:inline;">
|
||||
<button type="submit" class="gist-star-btn" data-confirm="Delete this gist?"><span class="icon">🗑️</span>Delete</button>
|
||||
<button type="submit" class="gist-star-btn" data-confirm="Delete this gist?"><span class="icon">🗑️</span><span class="label">Delete</span></button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% set _type = "gist" %}{% set _uid = gist['uid'] %}{% set _reactions = reactions %}{% include "_reaction_bar.html" %}
|
||||
|
||||
@@ -41,14 +41,14 @@
|
||||
<div class="post-detail-actions">
|
||||
{% set _uid = post['uid'] %}{% set _my_vote = my_vote %}{% set _count = post.get('stars', 0) %}{% include "_post_votes.html" %}
|
||||
{% set _type = "post" %}{% set _uid = post['uid'] %}{% set _reactions = reactions %}{% include "_reaction_bar.html" %}
|
||||
<button type="button" class="post-action-btn" data-share="/posts/{{ post['slug'] or post['uid'] }}">🔗 Share</button>
|
||||
<button type="button" class="post-action-btn" data-share="/posts/{{ post['slug'] or post['uid'] }}"><span class="icon">🔗</span><span class="label"> Share</span></button>
|
||||
{% set _type = "post" %}{% set _uid = post['uid'] %}{% set _bookmarked = bookmarked %}{% include "_bookmark_button.html" %}
|
||||
{% if is_owner %}
|
||||
<button type="button" class="post-action-btn" data-modal="edit-post-modal"><span class="icon">✏️</span>Edit</button>
|
||||
<button type="button" class="post-action-btn" data-modal="edit-post-modal"><span class="icon">✏️</span><span class="label">Edit</span></button>
|
||||
{% endif %}
|
||||
{% if is_owner or is_admin(user) %}
|
||||
<form method="POST" action="/posts/delete/{{ post['slug'] or post['uid'] }}" class="inline-form">
|
||||
<button type="submit" class="post-action-btn" data-confirm="Delete this post?"><span class="icon">🗑️</span>Delete</button>
|
||||
<button type="submit" class="post-action-btn" data-confirm="Delete this post?"><span class="icon">🗑️</span><span class="label">Delete</span></button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -220,11 +220,11 @@
|
||||
{% if ai_quota %}
|
||||
<div class="ai-quota" data-ai-quota>
|
||||
<div class="ai-quota-bar">
|
||||
<span class="ai-quota-fill {% if ai_quota.used_pct >= 90 %}ai-quota-danger{% endif %}" style="width: {{ ai_quota.used_pct }}%"></span>
|
||||
<span class="ai-quota-fill {% if not ai_quota.unlimited and ai_quota.used_pct >= 90 %}ai-quota-danger{% endif %}" style="width: {{ 0 if ai_quota.unlimited else ai_quota.used_pct }}%"></span>
|
||||
</div>
|
||||
<div class="ai-quota-meta">
|
||||
<span class="ai-quota-pct">{{ ai_quota.used_pct }}% of daily quota used</span>
|
||||
<span class="ai-quota-detail">${{ '%.4f'|format(ai_quota.spent_usd) }} / ${{ '%.2f'|format(ai_quota.limit_usd) }} · {{ ai_quota.turns }} turns</span>
|
||||
<span class="ai-quota-pct">{% if ai_quota.unlimited %}No daily spend cap{% else %}{{ ai_quota.used_pct }}% of daily quota used{% endif %}</span>
|
||||
<span class="ai-quota-detail">${{ '%.4f'|format(ai_quota.spent_usd) }} / {% if ai_quota.unlimited %}no cap{% else %}${{ '%.2f'|format(ai_quota.limit_usd) }}{% endif %} · {{ ai_quota.turns }} Devii turns</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -240,10 +240,10 @@
|
||||
{% if ai_quota %}
|
||||
<div class="ai-quota" data-ai-quota>
|
||||
<div class="ai-quota-bar">
|
||||
<span class="ai-quota-fill {% if ai_quota.used_pct >= 90 %}ai-quota-danger{% endif %}" style="width: {{ ai_quota.used_pct }}%"></span>
|
||||
<span class="ai-quota-fill {% if not ai_quota.unlimited and ai_quota.used_pct >= 90 %}ai-quota-danger{% endif %}" style="width: {{ 0 if ai_quota.unlimited else ai_quota.used_pct }}%"></span>
|
||||
</div>
|
||||
<div class="ai-quota-meta">
|
||||
<span class="ai-quota-pct">{{ ai_quota.used_pct }}% of your daily AI quota used</span>
|
||||
<span class="ai-quota-pct">{% if ai_quota.unlimited %}No daily AI spend cap{% else %}{{ ai_quota.used_pct }}% of your daily AI quota used{% endif %}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
|
||||
@@ -67,14 +67,14 @@
|
||||
{% endif %}
|
||||
|
||||
<div class="project-detail-actions">
|
||||
<a href="/projects/{{ project['slug'] or project['uid'] }}/files" class="project-star-btn"><span class="icon">📁</span> Files ({{ file_count }} files)</a>
|
||||
<button type="button" class="project-star-btn" data-share="/projects/{{ project['slug'] or project['uid'] }}"><span class="icon">🔗</span> Share</button>
|
||||
<a href="/projects/{{ project['slug'] or project['uid'] }}/files" class="project-star-btn"><span class="icon">📁</span><span class="label"> Files ({{ file_count }} files)</span></a>
|
||||
<button type="button" class="project-star-btn" data-share="/projects/{{ project['slug'] or project['uid'] }}"><span class="icon">🔗</span><span class="label"> Share</span></button>
|
||||
{% if user %}
|
||||
{% set _type = "project" %}{% set _uid = project['uid'] %}{% set _my_vote = my_vote %}{% set _count = star_count %}{% set _btn_class = "project-star-btn" %}{% include "_star_vote.html" %}
|
||||
{% endif %}
|
||||
{% set _type = "project" %}{% set _uid = project['uid'] %}{% set _bookmarked = bookmarked %}{% include "_bookmark_button.html" %}
|
||||
{% set _type = "project" %}{% set _uid = project['uid'] %}{% set _reactions = reactions %}{% include "_reaction_bar.html" %}
|
||||
<button type="button" class="project-star-btn project-actions-more" aria-haspopup="menu" aria-label="More actions"><span class="icon">⋯</span> More</button>
|
||||
<button type="button" class="project-star-btn project-actions-more" aria-haspopup="menu" aria-label="More actions"><span class="icon">⋯</span><span class="label"> More</span></button>
|
||||
|
||||
<div class="project-actions-overflow" hidden>
|
||||
{% if is_admin(user) %}
|
||||
|
||||
Reference in New Issue
Block a user