Skip to content

Commit 58b5bd4

Browse files
committed
feat: unload ux opti, profile default prompt removed, better ux tag on dataset panel
1 parent ed75b5b commit 58b5bd4

28 files changed

Lines changed: 2340 additions & 1118 deletions

server/routers/profiles.py

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,12 @@
1212

1313
from server.jobs import manager
1414
from server.runners import model as model_runner
15-
from server.schemas import ProfileBody, ProfileDetectBody, ProfileSelectBody
15+
from server.schemas import (
16+
ProfileBody,
17+
ProfileDetectBody,
18+
ProfilePromptBody,
19+
ProfileSelectBody,
20+
)
1621
from src import fs_browse, model_profiles
1722
from src.settings import get_model_dir
1823

@@ -56,6 +61,14 @@ def select_profile(body: ProfileSelectBody) -> dict:
5661
return {"ok": True}
5762

5863

64+
@router.post("/{profile_id}/prompt")
65+
def remember_prompt(profile_id: int, body: ProfilePromptBody) -> dict:
66+
"""Remember the prompt preset last used with a profile."""
67+
if not model_profiles.set_last_prompt(profile_id, body.title):
68+
raise HTTPException(status_code=404, detail="profile not found")
69+
return {"ok": True}
70+
71+
5972
@router.post("/detect")
6073
def detect(body: ProfileDetectBody) -> dict:
6174
"""Re-run type / mmproj auto-detection for a picked weights file."""
@@ -68,6 +81,22 @@ def detect(body: ProfileDetectBody) -> dict:
6881
}
6982

7083

84+
@router.get("/detect-hf")
85+
def detect_hf(repo: str = "") -> dict:
86+
"""Guess the family / format / name for a Hugging Face repo id.
87+
88+
``type`` is auto-detected from the repo tail (empty → the editor shows a
89+
"from repo config" badge, resolved from the config at load); ``format`` is
90+
guessed from the repo name; ``name`` is the repo tail.
91+
"""
92+
repo = repo.strip()
93+
return {
94+
"type": model_profiles.detect_repo_type(repo) if repo else "",
95+
"format": "gguf" if "gguf" in repo.lower() else "safetensors",
96+
"name": repo.rsplit("/", 1)[-1] if repo else "",
97+
}
98+
99+
71100
@router.put("/{profile_id}")
72101
def update_profile(profile_id: int, body: ProfileBody) -> dict:
73102
"""Apply the provided fields to an existing profile."""
@@ -101,10 +130,20 @@ def load_profile(profile_id: int) -> dict:
101130
raise HTTPException(
102131
status_code=409, detail="profile has no weights file"
103132
)
133+
# An HF profile whose repo is not yet cached downloads on first load: name
134+
# the job so the drawer reads "Download <repo>" with a byte progress bar.
135+
downloads = profile.get(
136+
"source"
137+
) == "hf" and not model_profiles.is_repo_cached(profile.get("repo") or "")
138+
name = (
139+
f"Download {profile['repo']}"
140+
if downloads
141+
else f"Load {profile['name']}"
142+
)
104143
job = manager.submit(
105144
"load-model",
106-
f"Load {profile['name']}",
145+
name,
107146
model_runner.load_profile_body(cfg, profile),
108-
sub="loading",
147+
sub="downloading" if downloads else "loading",
109148
)
110149
return {"job_id": job.id}

server/runners/model.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,13 @@ def run(progress: Progress) -> dict:
2828
for status, _loaded in loader.unload_model():
2929
progress(sub=status)
3030
model_profiles.set_loaded_id(None)
31-
for status, _loaded in loader.load_model(model_cfg):
31+
32+
def on_bytes(done: int, total: int, label: str) -> None:
33+
# Stream Hugging Face download progress as the job's byte counter;
34+
# progress() raises JobStopped when the user cancels the download.
35+
progress(done=done, total=total, sub=label)
36+
37+
for status, _loaded in loader.load_model(model_cfg, on_bytes=on_bytes):
3238
progress(sub=status)
3339
if loader.is_model_loaded():
3440
model_profiles.set_loaded_id(profile["id"])

server/schemas.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ class ProfileBody(BaseModel):
2626
"""
2727

2828
name: str | None = None
29+
source: str | None = None # "local" | "hf"
30+
repo: str | None = None # HF hub repo id (source == "hf")
2931
file: str | None = None
3032
dir: str | None = None
3133
format: str | None = None
@@ -49,6 +51,12 @@ class ProfileSelectBody(BaseModel):
4951
id: int
5052

5153

54+
class ProfilePromptBody(BaseModel):
55+
"""Body remembering the prompt preset last used with a profile."""
56+
57+
title: str
58+
59+
5260
class ProfileDetectBody(BaseModel):
5361
"""Body for re-running type/mmproj auto-detection on a picked file."""
5462

src/caption_judge.py

Lines changed: 116 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -125,69 +125,133 @@ def _edits(base_tokens: list, side_tokens: list) -> list:
125125
]
126126

127127

128-
def apply_fix(base: str, current: str, incoming: str) -> str:
129-
"""Apply the ``base → incoming`` correction on top of ``current``.
130-
131-
Three-way word merge, so accepting one finding after another keeps the
132-
earlier accept instead of resurrecting the run-time caption: regions the
133-
fix rewrites take the fix's words, regions only ``current`` changed keep
134-
the current words, and when both touched the same region the fix — the
135-
one the user just accepted — wins.
128+
# A base segment ends on a word carrying sentence/clause punctuation, so a
129+
# conflict resolves over a whole sentence (prose) or a whole tag (booru).
130+
_SEGMENT_END = re.compile(r"[.!?;,]$")
131+
132+
133+
def _segment_bounds(tokens: list) -> list:
134+
"""Return the base's segments as token ranges (sentences / clauses)."""
135+
bounds, start = [], 0
136+
for index, token in enumerate(tokens):
137+
if not token.isspace() and _SEGMENT_END.search(token):
138+
end = index + 1
139+
if end < len(tokens) and tokens[end].isspace():
140+
end += 1
141+
bounds.append((start, end))
142+
start = end
143+
if start < len(tokens) or not bounds:
144+
bounds.append((start, len(tokens)))
145+
return bounds
146+
147+
148+
def _overlaps(first: tuple, second: tuple) -> bool:
149+
"""Whether two base-coordinate edits collide (insertions zero-width)."""
150+
a1, a2, _ = first
151+
b1, b2, _ = second
152+
if a1 == a2 and b1 == b2: # two insertions at the same point collide
153+
return a1 == b1
154+
return a1 < b2 and b1 < a2
155+
156+
157+
def _replay(tokens: list, start: int, end: int, edits: list) -> list:
158+
"""Return ``tokens[start:end]`` with the (contained) edits applied."""
159+
output = []
160+
position = start
161+
for span_start, span_end, replacement in sorted(
162+
edits, key=lambda edit: (edit[0], edit[1])
163+
):
164+
output.extend(tokens[position:span_start])
165+
output.extend(replacement)
166+
position = span_end
167+
output.extend(tokens[position:end])
168+
return output
169+
170+
171+
def resolve_fix(base: str, current: str, incoming: str) -> dict:
172+
"""Resolve the ``base → incoming`` fix against the live ``current``.
173+
174+
Git-style three-way merge that never re-locates a diff: both sides'
175+
edits stay in the coordinates of the ``base`` they were computed
176+
against, so collision detection is exact interval arithmetic — no fuzzy
177+
matching, hence no false anchor when a sentence changed.
178+
179+
Edits that do not collide merge automatically, wherever they sit. When
180+
the two sides collide, the *whole segment* (sentence / clause) around
181+
the collision takes the incoming fix's rendering verbatim — one
182+
coherent, judge-authored sentence, never an interleaving of two
183+
rewrites — and the result is flagged so the UI shows a ⚠ conflict the
184+
user settles with accept / reject / inline edit.
185+
186+
Returns ``{"text": str, "conflict": bool}``.
136187
"""
137188
if base == incoming: # no-op fix — never clobber the live caption
138-
return current
189+
return {"text": current, "conflict": False}
139190
if current in (base, incoming):
140-
return incoming
191+
return {"text": incoming, "conflict": False}
141192
tokens = _tokens(base)
142193
ours = _edits(tokens, _tokens(current))
143194
theirs = _edits(tokens, _tokens(incoming))
144195

145-
merged = []
146-
cursor = 0 # position in base tokens
147-
oi = ti = 0
148-
while oi < len(ours) or ti < len(theirs):
149-
# Open the next cluster at the earliest remaining edit, then absorb
150-
# every span (either side) that overlaps or touches it.
151-
start = min(
152-
edits[index][0]
153-
for edits, index in ((ours, oi), (theirs, ti))
154-
if index < len(edits)
196+
# Group the base's segments so every edit falls entirely inside one
197+
# group (an edit spanning a boundary fuses the neighbours).
198+
groups = _segment_bounds(tokens)
199+
for start, end, _ in ours + theirs:
200+
end = max(end, min(start + 1, len(tokens)))
201+
touched = [g for g in groups if g[0] < end and start < g[1]] or [
202+
groups[-1]
203+
]
204+
fused = (
205+
min(g[0] for g in touched),
206+
max(g[1] for g in touched),
155207
)
156-
end = start
157-
cluster_ours, cluster_theirs = [], []
208+
groups = [g for g in groups if g not in touched]
209+
groups.append(fused)
210+
groups.sort()
158211

159-
def touches(span_start, cluster_end):
160-
"""Overlap, or separated from the cluster by whitespace only."""
161-
return span_start <= cluster_end or all(
162-
token.isspace() for token in tokens[cluster_end:span_start]
212+
merged = []
213+
conflict = False
214+
for index, (group_start, group_end) in enumerate(groups):
215+
last = index == len(groups) - 1
216+
inside_ours = [
217+
e for e in ours if _inside(e, group_start, group_end, last)
218+
]
219+
inside_theirs = [
220+
e for e in theirs if _inside(e, group_start, group_end, last)
221+
]
222+
collides = any(
223+
_overlaps(first, second)
224+
for first in inside_ours
225+
for second in inside_theirs
226+
)
227+
if collides:
228+
# The judge's whole sentence, verbatim; our edits there drop.
229+
conflict = True
230+
merged.extend(
231+
_replay(tokens, group_start, group_end, inside_theirs)
232+
)
233+
else:
234+
merged.extend(
235+
_replay(
236+
tokens,
237+
group_start,
238+
group_end,
239+
inside_ours + inside_theirs,
240+
)
163241
)
242+
return {"text": "".join(merged), "conflict": conflict}
164243

165-
grew = True
166-
while grew:
167-
grew = False
168-
while oi < len(ours) and touches(ours[oi][0], end):
169-
cluster_ours.append(ours[oi])
170-
end = max(end, ours[oi][1])
171-
oi += 1
172-
grew = True
173-
while ti < len(theirs) and touches(theirs[ti][0], end):
174-
cluster_theirs.append(theirs[ti])
175-
end = max(end, theirs[ti][1])
176-
ti += 1
177-
grew = True
178-
merged.extend(tokens[cursor:start])
179-
# Conflicting cluster → the incoming fix wins; otherwise replay the
180-
# only side that touched it.
181-
winner = cluster_theirs if cluster_theirs else cluster_ours
182-
position = start
183-
for span_start, span_end, replacement in winner:
184-
merged.extend(tokens[position:span_start])
185-
merged.extend(replacement)
186-
position = span_end
187-
merged.extend(tokens[position:end])
188-
cursor = end
189-
merged.extend(tokens[cursor:])
190-
return "".join(merged)
244+
245+
def _inside(edit: tuple, start: int, end: int, last: bool) -> bool:
246+
"""Whether an edit falls inside ``[start, end)`` (inserts included).
247+
248+
An insertion belongs to the group containing its point; one sitting at
249+
the very end of the text belongs to the last group.
250+
"""
251+
e1, e2, _ = edit
252+
if e1 == e2:
253+
return start <= e1 < end or (last and e1 == end)
254+
return start <= e1 and e2 <= end
191255

192256

193257
def judged_finding(before: str, verdict: dict | None) -> dict | None:

src/db.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -436,6 +436,10 @@
436436
status TEXT NOT NULL DEFAULT 'pending'
437437
CHECK (status IN ('pending', 'accepted', 'rejected')),
438438
applied_caption TEXT,
439+
-- The live caption at accept time, so an undo restores exactly the text
440+
-- the accept replaced (never the run-time original, which other accepts
441+
-- may have already moved past).
442+
undo_caption TEXT,
439443
created_at TEXT NOT NULL DEFAULT (datetime('now')),
440444
decided_at TEXT
441445
);
@@ -878,6 +882,11 @@ def ensure_database(db_path=None):
878882
("query", "query TEXT"),
879883
):
880884
_ensure_column(conn, "watermark_zone", column, definition)
885+
# Undo snapshot for review accepts (see review_queue): databases
886+
# created before conflict-aware accepts lack the column.
887+
_ensure_column(
888+
conn, "review_finding", "undo_caption", "undo_caption TEXT"
889+
)
881890
# v2 retired the per-zone "review" status: any pre-v2 zone still
882891
# carrying it becomes a plain detected zone (idempotent).
883892
conn.execute(

0 commit comments

Comments
 (0)