Skip to content

Commit 5563ddb

Browse files
committed
ワーカーの段を、画面から入れ替えられるようにする
段の並びは「詰まったら次へ」の順そのものなのに、変えるには相手を選び直すしか なかった。3 段あれば 3 つとも選び直すことになり、そのあいだに 1 つ間違えると 無人で回る層が別の相手に回り続ける。 ↑↓ はワーカーのフォームの submit として出す。form は入れ子にできないので、 中にもう 1 枚置くと内側が丸ごと無視される(押しても何も起きないボタンになる)。 同じフォームなら、書きかけの欄も一緒に保存してから動く —— 動かすために 保存し直す手間も要らない。 端では出さない(押せないボタンを置かない)。まだ相手を選んでいない末尾の行にも 出さない —— 動かす中身が無い。押された形だけが入ってきたときは何もしない。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 50ec137 commit 5563ddb

4 files changed

Lines changed: 139 additions & 4 deletions

File tree

CLAUDE.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -910,7 +910,13 @@ GeoNames 全世界地名辞典 = `geonames`(いずれも 348 言語版・195 か
910910
**ワーカーも同じ面に置く**(`app/views/ai_workers.py`)—— 何を回すかと、それを
911911
誰に回すかは 1 つの話で、離すと「なぜこの相手に回ったのか」を別の面と
912912
突き合わせて読むことになる
913-
- **ワーカーは相手のセレクトに並ぶ**(`workers.OPTION_PREFIX`)。欄を分けていた
913+
- **段は画面から入れ替えられる**(`views/ai_workers.py``_move_step_html`)。
914+
並びは「詰まったら次へ」の順そのものなのに、変えるには相手を選び直すしか
915+
なかった —— 3 段あれば 3 つとも選び直すことになり、そのあいだに 1 つ間違えると
916+
無人で回る層が別の相手に回り続ける。**↑↓ はワーカーのフォームの submit にする**
917+
(`<form>` は入れ子にできないので、中にもう 1 枚置くと内側が丸ごと無視される)
918+
—— 同じフォームなら**書きかけの欄も一緒に保存してから動く**。端では出さない
919+
- **ワーカーは相手のセレクトに並ぶ**(`workers.OPTION_PREFIX`)。欄を分けていた
914920
頃は相手とワーカーの両方を選べて、**どちらが効くのかが画面から読めなかった**
915921
(効くのはワーカー)。1 つの欄にすれば選べるのは片方だけになる。
916922
**ワーカーを選んだ回にはモデルも考える量も出さない**(保存時にも落とす)——

app/pages.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,9 @@ def doc_url(source: str, doc_id: int) -> str:
219219
(table.ai-settings に掛けているのと同じ調整) */
220220
details.prompt-open { margin-top: 0.2rem; }
221221
details.prompt-open > summary { font-weight: normal; padding: 0.1rem 0; }
222+
/* ワーカーの段を 1 つ上下へ動かす印(`views/ai_workers.py`)。**行から浮かせない**
223+
—— 段の並びは「詰まったら次へ」の順そのもので、相手の名前の隣が読む場所 */
224+
.step-move { font-size: .8rem; padding: .05rem .35rem; margin-left: .2rem; }
222225
.muted { color: #666; font-size: 0.85rem; }
223226
.pager { display: flex; gap: 1rem; align-items: baseline; margin-top: 1rem; }
224227
/* 玄関の使用量の表に添える 1 行(取り直す口と、全部の相手への入口)。

app/views/ai_workers.py

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,9 @@ def _percent(provider: str) -> str:
4242
return f'<span class="muted">{busiest:.0f}% 使用{mark}</span>'
4343

4444

45-
def _step_row(index: int, step: workers.Step | None, backend_select, model_select) -> str:
45+
def _step_row(
46+
index: int, step: workers.Step | None, backend_select, model_select, last: int = -1
47+
) -> str:
4648
"""段 1 つぶんの欄。
4749
4850
**考える量の欄は持たない。** 考える量はモデルの名前に畳んである
@@ -59,20 +61,53 @@ def _step_row(index: int, step: workers.Step | None, backend_select, model_selec
5961
'<div class="sweep-row">'
6062
f'<p><label>{index + 1} 番目<br>'
6163
f'{backend_select(current, "step_backend", empty_label=_EMPTY_STEP)}</label>'
62-
f" {picked}</p>"
64+
f" {picked} {_move_step_html(index, step, last)}</p>"
6365
f'<p><label>モデル<br>{model_select(current, step.model if step else "", "step_model")}'
6466
"</label></p>"
6567
"</div>"
6668
)
6769

6870

71+
def _move_step_html(index: int, step: workers.Step | None, last: int) -> str:
72+
"""段を 1 つ上/下へ動かす印。**段の並びは「詰まったら次へ」の順そのもの**。
73+
74+
**入れ替えるのに打ち直させない。** 並びを変えるには相手を選び直すしかなく、
75+
3 段あれば 3 つとも選び直すことになっていた —— そのあいだに 1 つ間違えると、
76+
無人で回る層が別の相手に回り続ける。
77+
78+
**このフォームの submit として出す**(別のフォームにしない)。`<form>` は
79+
入れ子にできないので、中にもう 1 枚置くと**内側が丸ごと無視される**
80+
(押しても何も起きないボタンになる)。同じフォームなら、**書きかけの欄も
81+
一緒に保存してから動く** —— 動かすために保存し直す手間も要らない。
82+
83+
**端では出さない**(押せないボタンを置かない、の流儀)。空の段(まだ相手を
84+
選んでいない末尾の 1 行)にも出さない —— 動かす中身が無い。
85+
"""
86+
if step is None:
87+
return ""
88+
up = (
89+
"" if index == 0 else
90+
'<button type="submit" name="step_move" class="step-move"'
91+
f' value="up:{index}" title="上へ">↑</button>'
92+
)
93+
down = (
94+
"" if index >= last else
95+
'<button type="submit" name="step_move" class="step-move"'
96+
f' value="down:{index}" title="下へ">↓</button>'
97+
)
98+
return up + down
99+
100+
69101
def _worker_form(worker: workers.Worker | None, selects, running: str = "") -> str:
70102
backend_select, model_select = selects
71103
name = worker.name if worker else ""
72104
steps = list(worker.steps) if worker else []
73105
# **空の段を 1 つ足して出す。** 足すのに押す手数を要らなくするため
74106
rows = [
75-
_step_row(i, steps[i] if i < len(steps) else None, backend_select, model_select)
107+
_step_row(
108+
i, steps[i] if i < len(steps) else None, backend_select, model_select,
109+
last=len(steps) - 1,
110+
)
76111
for i in range(min(len(steps) + 1, MAX_STEPS))
77112
]
78113
hint = ("名前を消すと、このワーカーは無くなります" if worker
@@ -247,6 +282,23 @@ async def wake_worker(request: Request):
247282
return RedirectResponse(BACK_TO_SECTION, status_code=303)
248283

249284

285+
def _moved_steps(steps: tuple, raw: str) -> tuple:
286+
"""`up:1` / `down:0` で段を 1 つ動かした並び。読めない指示は無視する。
287+
288+
**端は動かさない**(画面は端にボタンを出さないが、押された形は入ってくる)。
289+
"""
290+
kind, _, index = raw.partition(":")
291+
if kind not in ("up", "down") or not index.isdigit():
292+
return steps
293+
at = int(index)
294+
to = at - 1 if kind == "up" else at + 1
295+
if not (0 <= at < len(steps) and 0 <= to < len(steps)):
296+
return steps
297+
out = list(steps)
298+
out[at], out[to] = out[to], out[at]
299+
return tuple(out)
300+
301+
250302
@router.post("/admin/ai/workers")
251303
async def save_worker(request: Request):
252304
"""ワーカー 1 つぶんを保存する。**その 1 つだけ**を書き換える。
@@ -268,6 +320,9 @@ async def save_worker(request: Request):
268320
workers.Step(str(b).strip(), str(models[i] if i < len(models) else "").strip())
269321
for i, b in enumerate(backends) if str(b).strip()
270322
)
323+
# **↑↓ はこのフォームの submit。** 書きかけの欄も一緒に保存してから動かす
324+
# (`_move_step_html`。`<form>` は入れ子にできないので、別フォームにはできない)
325+
steps = _moved_steps(steps, str(form.get("step_move") or ""))
271326

272327
try:
273328
current = workers.load()

tests/test_workers.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1072,3 +1072,74 @@ def test_it_is_not_folded_away(self, enabled):
10721072
html = self._form(workers.Worker("精査", (workers.Step("codex"),)))
10731073

10741074
assert "<details><summary>待ち行列" not in html
1075+
1076+
1077+
class TestReorderingTheSteps:
1078+
"""段の並びは「詰まったら次へ」の順そのもの。**入れ替えるのに打ち直させない。**
1079+
1080+
並びを変えるには相手を選び直すしかなく、3 段あれば 3 つとも選び直すことに
1081+
なっていた —— そのあいだに 1 つ間違えると、無人で回る層が別の相手に回り続ける。
1082+
"""
1083+
1084+
def _post(self, **extra):
1085+
from starlette.datastructures import FormData
1086+
1087+
rows = extra.pop("rows", [("codex", ""), ("antigravity", ""), ("claude", "")])
1088+
items = [("worker_key", "精査"), ("worker_name", "精査")]
1089+
for backend, model in rows:
1090+
items += [("step_backend", backend), ("step_model", model)]
1091+
items += list(extra.items())
1092+
return FormData(items)
1093+
1094+
async def _save(self, form):
1095+
from app.views import ai_workers
1096+
1097+
class _Request:
1098+
async def form(self):
1099+
return form
1100+
1101+
await ai_workers.save_worker(_Request())
1102+
return [s.backend for s in workers.load()[0].steps]
1103+
1104+
def test_up_swaps_with_the_one_before(self, enabled):
1105+
got = asyncio.run(self._save(self._post(step_move="up:1")))
1106+
assert got == ["antigravity", "codex", "claude"]
1107+
1108+
def test_down_swaps_with_the_one_after(self, enabled):
1109+
got = asyncio.run(self._save(self._post(step_move="down:0")))
1110+
assert got == ["antigravity", "codex", "claude"]
1111+
1112+
def test_the_typed_rows_are_kept(self, enabled):
1113+
"""**書きかけの欄も一緒に保存してから動く**(別のフォームにはできない)。"""
1114+
rows = [("codex", "gpt-6"), ("antigravity", "gemini-3.8-flash")]
1115+
got = asyncio.run(self._save(self._post(rows=rows, step_move="up:1")))
1116+
assert got == ["antigravity", "codex"]
1117+
assert [s.model for s in workers.load()[0].steps] == ["gemini-3.8-flash", "gpt-6"]
1118+
1119+
def test_the_ends_do_not_move(self, enabled):
1120+
"""画面は端にボタンを出さないが、押された形は入ってくる。"""
1121+
assert asyncio.run(self._save(self._post(step_move="up:0"))) == [
1122+
"codex", "antigravity", "claude"
1123+
]
1124+
assert asyncio.run(self._save(self._post(step_move="down:2"))) == [
1125+
"codex", "antigravity", "claude"
1126+
]
1127+
1128+
def test_a_move_we_cannot_read_changes_nothing(self, enabled):
1129+
for raw in ("", "sideways:1", "up:", "up:x", "up:99"):
1130+
assert asyncio.run(self._save(self._post(step_move=raw))) == [
1131+
"codex", "antigravity", "claude"
1132+
], raw
1133+
1134+
def test_the_screen_shows_the_arrows_only_where_they_work(self, enabled):
1135+
from app.views import admin, ai_workers
1136+
1137+
workers.save([_worker(workers.Step("codex"), workers.Step("antigravity"))])
1138+
html = ai_workers.section_html(
1139+
(admin._backend_select, admin._model_select)
1140+
)
1141+
1142+
# 2 段あるので ↑ は 2 番目だけ、↓ は 1 番目だけ。空の 3 行目には出ない
1143+
assert 'value="up:1"' in html and 'value="up:0"' not in html
1144+
assert 'value="down:0"' in html and 'value="down:1"' not in html
1145+
assert 'value="down:2"' not in html and 'value="up:2"' not in html

0 commit comments

Comments
 (0)