Compare commits

..
Author SHA1 Message Date
Typosaurus 3397dfbb64 ticket #97 attempt 1 2026-07-19 20:22:17 +00:00
12 changed files with 135 additions and 34 deletions
File diff suppressed because one or more lines are too long
+13 -1
View File
@@ -1,5 +1,6 @@
# retoor <retoor@molodetz.nl> # retoor <retoor@molodetz.nl>
import logging import logging
import re
from typing import Any from typing import Any
from datetime import datetime, timezone from datetime import datetime, timezone
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
@@ -55,6 +56,12 @@ CREATE_METADATA_KEYS = ("project_type", "is_private", "language", "topic", "stat
BOOKMARKABLE_TYPES = {"post", "gist", "project", "news"} BOOKMARKABLE_TYPES = {"post", "gist", "project", "news"}
REACTABLE_TYPES = {"post", "comment", "gist", "project"} REACTABLE_TYPES = {"post", "comment", "gist", "project"}
NOTE_PATTERN = re.compile(r"(^|\s)@note(\s|$)")
def has_note(text: str) -> bool:
return bool(text and NOTE_PATTERN.search(text))
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -198,6 +205,8 @@ def create_content_item(
schedule_correction(user, table_name, uid, request) schedule_correction(user, table_name, uid, request)
schedule_modification(user, table_name, uid, request) schedule_modification(user, table_name, uid, request)
schedule_seo_meta_for_table(table_name, uid) schedule_seo_meta_for_table(table_name, uid)
if request and has_note(mention_text):
set_bookmark(request, user, target_type, uid, saved=True, source="note")
return uid, slug return uid, slug
@@ -379,6 +388,8 @@ def create_comment_record(
summary=f"{user['username']} commented on {target_type} {target_uid}: {content}", summary=f"{user['username']} commented on {target_type} {target_uid}: {content}",
links=comment_links, links=comment_links,
) )
if request and target_type in BOOKMARKABLE_TYPES and has_note(content):
set_bookmark(request, user, target_type, target_uid, saved=True, source="note")
return comment_uid, comment_url return comment_uid, comment_url
@@ -435,7 +446,7 @@ def delete_comment_record(request, user: dict, comment: dict) -> tuple[str, str]
def set_bookmark( def set_bookmark(
request, user: dict, target_type: str, target_uid: str, saved: bool request, user: dict, target_type: str, target_uid: str, saved: bool, source: str = "manual"
) -> bool: ) -> bool:
bookmarks = get_table("bookmarks") bookmarks = get_table("bookmarks")
existing = bookmarks.find_one( existing = bookmarks.find_one(
@@ -458,6 +469,7 @@ def set_bookmark(
"created_at": datetime.now(timezone.utc).isoformat(), "created_at": datetime.now(timezone.utc).isoformat(),
"deleted_at": None, "deleted_at": None,
"deleted_by": None, "deleted_by": None,
"source": source,
} }
) )
changed = True changed = True
+6 -3
View File
@@ -243,6 +243,12 @@ def init_db():
) )
_index(db, "bookmarks", "idx_bookmarks_user", ["user_uid"]) _index(db, "bookmarks", "idx_bookmarks_user", ["user_uid"])
_index(db, "bookmarks", "idx_bookmarks_target", ["target_type", "target_uid"]) _index(db, "bookmarks", "idx_bookmarks_target", ["target_type", "target_uid"])
if "bookmarks" in tables:
bookmarks_table = get_table("bookmarks")
if not bookmarks_table.has_column("source"):
bookmarks_table.create_column_by_example("source", "manual")
_index(db, "bookmarks", "idx_bookmarks_source", ["source"])
_index(db, "bookmarks", "idx_bookmarks_user_source", ["user_uid", "source"])
_index(db, "polls", "idx_polls_post", ["post_uid"]) _index(db, "polls", "idx_polls_post", ["post_uid"])
_index(db, "poll_options", "idx_poll_options_poll", ["poll_uid"]) _index(db, "poll_options", "idx_poll_options_poll", ["poll_uid"])
_index(db, "poll_votes", "idx_poll_votes_poll", ["poll_uid"]) _index(db, "poll_votes", "idx_poll_votes_poll", ["poll_uid"])
@@ -1353,9 +1359,6 @@ def migrate_ai_gateway_settings() -> None:
if not get_setting("gateway_vision_key", "") and openrouter: if not get_setting("gateway_vision_key", "") and openrouter:
set_setting("gateway_vision_key", openrouter) set_setting("gateway_vision_key", openrouter)
logger.info("Migrated gateway vision key from environment") logger.info("Migrated gateway vision key from environment")
if not get_setting("gateway_embed_key", "") and openrouter:
set_setting("gateway_embed_key", openrouter)
logger.info("Migrated gateway embed key from environment")
for key in ("news_ai_url", "bot_api_url", "devii_ai_url"): for key in ("news_ai_url", "bot_api_url", "devii_ai_url"):
if get_setting(key, "") == OLD_GATEWAY_URL: if get_setting(key, "") == OLD_GATEWAY_URL:
set_setting(key, INTERNAL_GATEWAY_URL) set_setting(key, INTERNAL_GATEWAY_URL)
@@ -152,6 +152,28 @@ four ways to sign requests.
"Bookmarks target posts, projects, gists, and news; see [Posts, Comments, Projects, Gists & News](/docs/content.html)." "Bookmarks target posts, projects, gists, and news; see [Posts, Comments, Projects, Gists & News](/docs/content.html)."
], ],
), ),
endpoint(
id="notes-listing",
method="GET",
path="/bookmarks/saved?source=note",
title="View personal notes",
summary="Render your personal notes (items tagged with @note). Returns an HTML page.",
auth="user",
interactive=True,
params=[
field(
"before",
"query",
"string",
False,
"",
"Pagination cursor (created_at of the last item).",
)
],
notes=[
"Notes are auto-created when you include @note in a post or comment body."
],
),
endpoint( endpoint(
id="polls-vote", id="polls-vote",
method="POST", method="POST",
+6 -2
View File
@@ -31,10 +31,13 @@ LABEL_BY_TYPE: dict[str, str] = {
@router.get("/saved", response_class=HTMLResponse) @router.get("/saved", response_class=HTMLResponse)
async def saved_page(request: Request, before: str = None): async def saved_page(request: Request, before: str = None, source: str = None):
user = require_user(request) user = require_user(request)
bookmarks = get_table("bookmarks") bookmarks = get_table("bookmarks")
rows, next_cursor = paginate(bookmarks, before=before, user_uid=user["uid"]) filters: dict = {"user_uid": user["uid"]}
if source:
filters["source"] = source
rows, next_cursor = paginate(bookmarks, before=before, **filters)
uids_by_type: dict[str, list] = {} uids_by_type: dict[str, list] = {}
for row in rows: for row in rows:
@@ -85,6 +88,7 @@ async def saved_page(request: Request, before: str = None):
"user": user, "user": user,
"items": items, "items": items,
"next_cursor": next_cursor, "next_cursor": next_cursor,
"current_source": source or "",
}, },
model=SavedOut, model=SavedOut,
) )
+1 -1
View File
@@ -211,7 +211,7 @@ async def _set_favorite(request: Request, rant_id: str, saved: bool):
post = post_by_id(rant_id) post = post_by_id(rant_id)
if not post: if not post:
return dr_error("This rant does not exist.") return dr_error("This rant does not exist.")
set_bookmark(request, user, "post", post["uid"], saved) set_bookmark(request, user, "post", post["uid"], saved, source="manual")
return dr_ok() return dr_ok()
@@ -65,6 +65,13 @@ ENGAGEMENT_ACTIONS: tuple[Action, ...] = (
), ),
), ),
), ),
Action(
name="list_notes",
method="GET",
path="/bookmarks/saved?source=note",
summary="List personal notes (items tagged with @note)",
params=(query("before", "Pagination cursor."),),
),
Action( Action(
name="vote_poll", name="vote_poll",
method="POST", method="POST",
+1 -1
View File
@@ -76,7 +76,7 @@ The gateway is the only place that holds real provider URLs/models/keys. Every o
- Defaults live in `config.py`: `INTERNAL_GATEWAY_URL` (`http://localhost:10500/openai/v1/chat/completions`, override with `DEVPLACE_INTERNAL_BASE_URL`) and `INTERNAL_MODEL` (`molodetz`). `news_ai_url`, `bot_api_url`, `devii_ai_url` default to `INTERNAL_GATEWAY_URL`; their model defaults to `molodetz`. - Defaults live in `config.py`: `INTERNAL_GATEWAY_URL` (`http://localhost:10500/openai/v1/chat/completions`, override with `DEVPLACE_INTERNAL_BASE_URL`) and `INTERNAL_MODEL` (`molodetz`). `news_ai_url`, `bot_api_url`, `devii_ai_url` default to `INTERNAL_GATEWAY_URL`; their model defaults to `molodetz`.
- Each consumer's key falls back to `database.internal_gateway_key()` (reads the `gateway_internal_key` setting) when its own key field/env is unset - the provider-key fallbacks (`DEEPSEEK_API_KEY`/`OPENROUTER_API_KEY`) were removed from news and bots. - Each consumer's key falls back to `database.internal_gateway_key()` (reads the `gateway_internal_key` setting) when its own key field/env is unset - the provider-key fallbacks (`DEEPSEEK_API_KEY`/`OPENROUTER_API_KEY`) were removed from news and bots.
- `gateway_force_model` (default on) and a `molodetz`/empty alias in `handle_chat` make the upstream always receive `gateway_model`, so `molodetz` is a stable generic alias. - `gateway_force_model` (default on) and a `molodetz`/empty alias in `handle_chat` make the upstream always receive `gateway_model`, so `molodetz` is a stable generic alias.
- `database.migrate_ai_gateway_settings()` (called at the end of `init_db()`, under the startup `init_lock`): generates `gateway_internal_key` (uuid4) if missing; migrates `DEEPSEEK_API_KEY`/`OPENROUTER_API_KEY` env into `gateway_api_key`/`gateway_vision_key`/`gateway_embed_key` when the db value is empty; and rewrites any consumer AI URL still equal to the old `openai.app.molodetz.nl` default to the gateway, plus `bot_model` `deepseek-chat` -> `molodetz` (only uncustomized values). - `database.migrate_ai_gateway_settings()` (called at the end of `init_db()`, under the startup `init_lock`): generates `gateway_internal_key` (uuid4) if missing; migrates `DEEPSEEK_API_KEY`/`OPENROUTER_API_KEY` env into `gateway_api_key`/`gateway_vision_key` when the db value is empty; and rewrites any consumer AI URL still equal to the old `openai.app.molodetz.nl` default to the gateway, plus `bot_model` `deepseek-chat` -> `molodetz` (only uncustomized values).
- The gateway's `gateway_api_key`/`gateway_vision_key`/`gateway_internal_key` fields are **non-secret** so the admin services page shows the value actually in use, editable. - The gateway's `gateway_api_key`/`gateway_vision_key`/`gateway_internal_key` fields are **non-secret** so the admin services page shows the value actually in use, editable.
- Bot LLM calls are synchronous `urllib` but already run via `asyncio.to_thread` (`bot/bot.py`), so the local round-trip never blocks the event loop. - Bot LLM calls are synchronous `urllib` but already run via `asyncio.to_thread` (`bot/bot.py`), so the local round-trip never blocks the event loop.
- Real provider keys/URLs/models live ONLY in the gateway. The gateway is `default_enabled=True`. - Real provider keys/URLs/models live ONLY in the gateway. The gateway is `default_enabled=True`.
+32 -2
View File
@@ -2,10 +2,40 @@
{% block extra_head %} {% block extra_head %}
<link rel="stylesheet" href="{{ static_url('/static/css/feed.css') }}"> <link rel="stylesheet" href="{{ static_url('/static/css/feed.css') }}">
<link rel="stylesheet" href="{{ static_url('/static/css/post.css') }}"> <link rel="stylesheet" href="{{ static_url('/static/css/post.css') }}">
<style>
.tab-bar {
display: flex;
gap: 0;
margin-bottom: 1rem;
border-bottom: 2px solid var(--border-color, #ddd);
}
.tab-btn {
padding: 0.5rem 1.25rem;
text-decoration: none;
color: var(--text-muted, #666);
border-bottom: 2px solid transparent;
margin-bottom: -2px;
transition: color 0.15s, border-color 0.15s;
font-weight: 500;
}
.tab-btn:hover {
color: var(--text-color, #333);
}
.tab-btn.active {
color: var(--accent-color, #0066cc);
border-bottom-color: var(--accent-color, #0066cc);
}
</style>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="saved-page"> <div class="saved-page">
<h1 class="saved-title">Saved</h1> <h1 class="saved-title">Saved</h1>
<div class="tab-bar">
<a href="/bookmarks/saved" class="tab-btn {% if not current_source %}active{% endif %}">All</a>
<a href="/bookmarks/saved?source=note" class="tab-btn {% if current_source == 'note' %}active{% endif %}">Notes</a>
</div>
<div class="saved-list"> <div class="saved-list">
{% for item in items %} {% for item in items %}
<a href="{{ item.url }}" class="saved-item"> <a href="{{ item.url }}" class="saved-item">
@@ -14,13 +44,13 @@
<span class="saved-item-time">{{ dt_ago(item.created_at) if item.created_at else item.time_ago }}</span> <span class="saved-item-time">{{ dt_ago(item.created_at) if item.created_at else item.time_ago }}</span>
</a> </a>
{% else %} {% else %}
<div class="empty-state">Nothing saved yet. Use the Save button on posts, gists, projects and news.</div> <div class="empty-state">Nothing saved yet. Use the Save button on posts, gists, projects and news, or add <code>@note</code> to your post or comment to auto-save it.</div>
{% endfor %} {% endfor %}
</div> </div>
{% if next_cursor %} {% if next_cursor %}
<div class="load-more-wrap"> <div class="load-more-wrap">
<a href="/bookmarks/saved?before={{ next_cursor }}" class="btn btn-secondary">Load more</a> <a href="/bookmarks/saved?before={{ next_cursor }}{% if current_source %}&source={{ current_source }}{% endif %}" class="btn btn-secondary">Load more</a>
</div> </div>
{% endif %} {% endif %}
</div> </div>
+47
View File
@@ -76,3 +76,50 @@ def test_saved_page_lists_post_and_gist(app_server):
html = s.get(f"{BASE_URL}/bookmarks/saved").text html = s.get(f"{BASE_URL}/bookmarks/saved").text
assert post_title in html assert post_title in html
assert gist_title in html assert gist_title in html
def test_saved_page_source_filter_notes(app_server):
s, name = _session_bookmarks()
owner_uid = _uid_bookmarks(name)
post_title = f"Note post {int(time.time() * 1000)}"
manual_title = f"Manual bookmark {int(time.time() * 1000)}"
post_uid = _make_post_bookmarks(owner_uid, post_title)
manual_uid = _make_post_bookmarks(owner_uid, manual_title)
# Create a note bookmark (source=note) directly via DB
bookmarks = get_table("bookmarks")
note_uid = generate_uid()
bookmarks.insert(
{
"uid": note_uid,
"user_uid": owner_uid,
"target_uid": post_uid,
"target_type": "post",
"created_at": datetime.now(timezone.utc).isoformat(),
"deleted_at": None,
"deleted_by": None,
"source": "note",
}
)
# Create a manual bookmark (source=manual)
bookmarks.insert(
{
"uid": generate_uid(),
"user_uid": owner_uid,
"target_uid": manual_uid,
"target_type": "post",
"created_at": datetime.now(timezone.utc).isoformat(),
"deleted_at": None,
"deleted_by": None,
"source": "manual",
}
)
html = s.get(f"{BASE_URL}/bookmarks/saved?source=note").text
assert post_title in html
assert manual_title not in html
html_all = s.get(f"{BASE_URL}/bookmarks/saved").text
assert post_title in html_all
assert manual_title in html_all
@@ -1,11 +0,0 @@
# retoor <retoor@molodetz.nl>
from devplacepy.database import get_setting, set_setting
from devplacepy.database.schema import migrate_ai_gateway_settings
def test_migrate_embed_key_populates_db(local_db, monkeypatch):
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-v1-test-embed-key")
set_setting("gateway_embed_key", "")
migrate_ai_gateway_settings()
assert get_setting("gateway_embed_key", "") == "sk-or-v1-test-embed-key"
@@ -1,12 +0,0 @@
# retoor <retoor@molodetz.nl>
from devplacepy.database import set_setting
from devplacepy.services.openai_gateway import GatewayService
def test_effective_config_fallback_embed_key(local_db):
set_setting("gateway_embed_key", "")
set_setting("gateway_vision_key", "sk-or-v1-vision-test-key")
svc = GatewayService()
cfg = svc.effective_config()
assert cfg["gateway_embed_key"] == "sk-or-v1-vision-test-key"