Skip to content

Commit 41ad5cb

Browse files
committed
Add reasoning-model support, multi-file output, pipeline profiles, and persistent chats
- Streaming now hides model reasoning: inline <think> blocks (tag-split tolerant ThinkTagFilter) and reasoning_content/reasoning deltas stream into a collapsed "Model reasoning" expander instead of the output - Live tok/s caption updates every ~24 chunks during generation; final stats formatting shared via core/api.py::format_stream_stats - Output page parses every fenced block: multi-file results render as per-file tabs with a zip download and save-all into a timestamped folder (filenames sniffed from the line preceding each fence, path-sanitized) - Refine/regenerate keeps a version stack: unified diff against the previous code with one-click revert; a rough token estimate warns when the refine payload may exceed small local context windows - New swap_policy setting (auto/never) gates every cross-role VRAM unload for GPUs that fit both models at once - Chats persist to chats.json (new core/chats.py); Prompter-chat replies gain "Use as pipeline prompt", jumping to the review step with the fenced prompt extracted and the preceding message as the task - Pipeline profiles: named snapshots of backends/models/params/system prompts saved in config, applied in one click from the task page - 39 new tests (think filter + SSE reasoning paths, extract_files, profiles, swap policy, chat store, coder-role and category-switch preset loading); suite now at 142
1 parent c7de5fd commit 41ad5cb

15 files changed

Lines changed: 1038 additions & 98 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
config.json
22
history.json
3+
chats.json
34
output/
45
__pycache__/
56
*.pyc

app.py

Lines changed: 46 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,12 @@
1010

1111
import streamlit as st
1212

13-
from core.config import config_exists, load_config, get_role_endpoint
13+
from core.config import config_exists, load_config, get_role_endpoint, swap_enabled
1414
from core.api import (
1515
test_connection,
1616
unload_model,
1717
is_cloud,
18-
estimate_cost,
18+
format_stream_stats,
1919
BACKEND_LABELS,
2020
schedule_unload,
2121
cancel_unload,
@@ -103,6 +103,9 @@ def init_session_state():
103103
# Which role's model most recently handled a request (pipeline or
104104
# chat), so all generation paths can swap VRAM cooperatively.
105105
"last_model_role": None,
106+
# Previous code outputs of this run (newest last), so a refine or
107+
# regenerate that makes things worse can be compared and reverted.
108+
"code_versions": [],
106109
}
107110
for key, value in defaults.items():
108111
if key not in st.session_state:
@@ -126,6 +129,7 @@ def reset_pipeline():
126129
st.session_state["prompt_refine_instruction"] = ""
127130
st.session_state["needs_swap"] = False
128131
st.session_state["show_settings"] = False
132+
st.session_state["code_versions"] = []
129133
# Drop widget state so inputs re-initialize cleanly
130134
for key in (
131135
"task_input_area",
@@ -399,6 +403,7 @@ def run_streaming_generation(
399403
first_token = True
400404
chunk_count = 0
401405
usage: dict = {}
406+
reasoning: dict = {}
402407
start_time = time.perf_counter()
403408

404409
try:
@@ -414,16 +419,31 @@ def run_streaming_generation(
414419
messages=messages,
415420
api_key=endpoint["api_key"],
416421
usage_out=usage,
422+
reasoning_out=reasoning,
417423
)
418424

419-
# Create a placeholder for streaming output
425+
# Placeholders in display order: hidden reasoning (filled lazily,
426+
# only for thinking models), then the output, then live stats.
427+
reasoning_slot = st.empty()
428+
reasoning_box = None
420429
output_placeholder = st.empty()
430+
stats_placeholder = st.empty()
421431

422432
for token in stream:
423433
if first_token:
424434
first_token = False
425435
if on_first_token:
426436
on_first_token()
437+
# Thinking models: stream the hidden reasoning into a collapsed
438+
# expander instead of polluting the prompt/code output
439+
if reasoning.get("text"):
440+
if reasoning_box is None:
441+
with reasoning_slot.container():
442+
with st.expander("Model reasoning", expanded=False):
443+
reasoning_box = st.empty()
444+
reasoning_box.markdown(reasoning["text"])
445+
if not token:
446+
continue # reasoning-only chunk
427447
chunk_count += 1
428448
full_output += token
429449
if partial_key:
@@ -432,30 +452,18 @@ def run_streaming_generation(
432452
output_placeholder.code(full_output, line_numbers=True)
433453
else:
434454
output_placeholder.markdown(full_output)
435-
436-
elapsed = time.perf_counter() - start_time
437-
stats = []
438-
if chunk_count and elapsed > 0:
439-
# One SSE chunk is roughly one token
440-
stats.append(
441-
f"~{chunk_count} tokens in {elapsed:.1f}s "
442-
f"({chunk_count / elapsed:.1f} tok/s)"
443-
)
444-
if usage.get("input_tokens") is not None or usage.get("output_tokens") is not None:
445-
# Exact billed counts (cloud backends report these)
446-
stats.append(
447-
f"{usage.get('input_tokens', '?')} in / "
448-
f"{usage.get('output_tokens', '?')} out tokens"
449-
)
450-
cost = estimate_cost(
451-
endpoint["model"],
452-
usage.get("input_tokens"),
453-
usage.get("output_tokens"),
454-
)
455-
if cost is not None:
456-
stats.append(f"~${cost:.4f} est.")
457-
if stats:
458-
st.caption(" · ".join(stats))
455+
if chunk_count % 24 == 0:
456+
elapsed = time.perf_counter() - start_time
457+
if elapsed > 0:
458+
stats_placeholder.caption(
459+
f"~{chunk_count} tokens · {chunk_count / elapsed:.1f} tok/s"
460+
)
461+
462+
stats_line = format_stream_stats(
463+
endpoint["model"], chunk_count, time.perf_counter() - start_time, usage
464+
)
465+
if stats_line:
466+
stats_placeholder.caption(stats_line)
459467

460468
return full_output, ""
461469

@@ -534,8 +542,9 @@ def run_prompter(status_placeholder, messages: list[dict] | None = None) -> tupl
534542
step) instead of the default single-turn task rewrite."""
535543

536544
# A local coder model left loaded by a chat or earlier run would fight the
537-
# prompter for VRAM — evict it first. (No-op for a cloud coder.)
538-
if st.session_state.get("last_model_role") == "coder":
545+
# prompter for VRAM — evict it first. (No-op for a cloud coder, skipped
546+
# entirely when the swap policy says both models fit.)
547+
if st.session_state.get("last_model_role") == "coder" and swap_enabled(config):
539548
coder = get_role_endpoint(config, "coder")
540549
unload_model(coder["base_url"], coder["model"], coder["backend"])
541550
st.session_state["last_model_role"] = "prompter"
@@ -721,8 +730,9 @@ def _stop_coder():
721730

722731
# Only swap if a local prompter actually ran since the last unload —
723732
# regenerating from the output page (or a cloud prompter) would
724-
# otherwise trigger a pointless unload.
725-
if st.session_state.pop("needs_swap", False):
733+
# otherwise trigger a pointless unload. The 'never' swap policy
734+
# (both models fit in VRAM) skips it too; the flag is still popped.
735+
if st.session_state.pop("needs_swap", False) and swap_enabled(config):
726736
with status_placeholder.container():
727737
render_pipeline_checklist([
728738
{"label": "Prompt confirmed", "state": "done"},
@@ -803,6 +813,11 @@ def _on_first_token():
803813
st.session_state["coder_partial"] = result
804814
st.rerun() # re-render into the stable error view below
805815
else:
816+
# Keep the code this run replaced, so the output page can show a
817+
# diff of what changed and offer a one-click revert.
818+
prev_code = st.session_state.get("generated_code", "")
819+
if prev_code and prev_code != result:
820+
st.session_state.setdefault("code_versions", []).append(prev_code)
806821
st.session_state["generated_code"] = result
807822
st.session_state["coder_partial"] = ""
808823
st.session_state["coder_mode"] = "full"

core/api.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,38 @@ def estimate_cost(model: str, input_tokens, output_tokens) -> float | None:
7676
return None
7777

7878

79+
def rough_token_count(*texts: str) -> int:
80+
"""Very rough token estimate (~4 chars/token) across the given strings.
81+
Good enough for a context-size warning; never used for billing."""
82+
return sum(len(t) for t in texts if t) // 4
83+
84+
85+
def format_stream_stats(
86+
model: str, chunk_count: int, elapsed: float, usage: dict
87+
) -> str:
88+
"""One-line stats caption for a generation: chunk-based tok/s estimate,
89+
plus exact billed tokens and estimated cost when the backend reported
90+
usage. Returns '' when there is nothing to show."""
91+
stats = []
92+
if chunk_count and elapsed > 0:
93+
# One SSE chunk is roughly one token
94+
stats.append(
95+
f"~{chunk_count} tokens in {elapsed:.1f}s "
96+
f"({chunk_count / elapsed:.1f} tok/s)"
97+
)
98+
if usage.get("input_tokens") is not None or usage.get("output_tokens") is not None:
99+
stats.append(
100+
f"{usage.get('input_tokens', '?')} in / "
101+
f"{usage.get('output_tokens', '?')} out tokens"
102+
)
103+
cost = estimate_cost(
104+
model, usage.get("input_tokens"), usage.get("output_tokens")
105+
)
106+
if cost is not None:
107+
stats.append(f"~${cost:.4f} est.")
108+
return " · ".join(stats)
109+
110+
79111
def chat_completions_url(base_url: str, backend: str) -> str:
80112
"""Chat-completions endpoint for a backend (Anthropic excluded — it
81113
goes through the official SDK, not an OpenAI-compatible path)."""

core/chats.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
"""
2+
PromptChain — Chat Persistence
3+
4+
Persists the two single-model chats (Prompter / Coder) to chats.json so a
5+
conversation survives app restarts, mirroring how history.json persists
6+
pipeline runs. Shared across tabs/sessions; concurrent saves are
7+
last-writer-wins, which is acceptable for a single-user local app.
8+
"""
9+
10+
import json
11+
from pathlib import Path
12+
13+
CHATS_PATH = Path(__file__).parent.parent / "chats.json"
14+
# Keep persisted conversations from growing without bound
15+
MAX_MESSAGES = 200
16+
17+
18+
def _load_all() -> dict:
19+
if not CHATS_PATH.exists():
20+
return {}
21+
try:
22+
data = json.loads(CHATS_PATH.read_text(encoding="utf-8"))
23+
return data if isinstance(data, dict) else {}
24+
except (json.JSONDecodeError, OSError):
25+
return {}
26+
27+
28+
def load_chat_messages(role: str) -> list[dict]:
29+
"""Persisted messages for 'prompter' or 'coder'. [] on any problem."""
30+
msgs = _load_all().get(role, [])
31+
return msgs if isinstance(msgs, list) else []
32+
33+
34+
def save_chat_messages(role: str, messages: list[dict]) -> None:
35+
"""Persist one role's conversation (trimmed to the newest MAX_MESSAGES)."""
36+
data = _load_all()
37+
data[role] = messages[-MAX_MESSAGES:]
38+
CHATS_PATH.write_text(
39+
json.dumps(data, indent=2, ensure_ascii=False),
40+
encoding="utf-8",
41+
)

core/config.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,13 @@ def get_default_config() -> dict:
3232
# Free a resident local model's VRAM after this many idle minutes;
3333
# 0 = never. Applies to whichever role (prompter or coder) served last.
3434
"idle_unload_minutes": 5,
35+
# 'auto' unloads one local model before running the other (single-GPU
36+
# default); 'never' skips all cross-role unloads for machines whose
37+
# VRAM fits both models at once.
38+
"swap_policy": "auto",
39+
# Named snapshots of a full pipeline setup (endpoints, sampling params,
40+
# system prompts) that can be applied in one click from the task page.
41+
"pipeline_profiles": {},
3542
"custom_presets": {},
3643
# Edits to built-in presets, keyed role -> category -> name -> text.
3744
# Kept separate so the shipped presets.json is never mutated.
@@ -114,6 +121,12 @@ def get_role_endpoint(config: dict, role: str) -> dict:
114121
"api_key": _resolve_api_key(config, backend),
115122
}
116123

124+
def swap_enabled(config: dict) -> bool:
125+
"""False when the user opted out of VRAM swapping ('never' policy —
126+
both models fit in VRAM, so cross-role unloads are pure overhead)."""
127+
return config.get("swap_policy", "auto") != "never"
128+
129+
117130
def save_config(config: dict) -> None:
118131
"""
119132
Save config dict to config.json.
@@ -127,6 +140,56 @@ def save_config(config: dict) -> None:
127140
)
128141

129142

143+
# ═══════════════════════════════════════════════════════
144+
# Pipeline Profiles
145+
# ═══════════════════════════════════════════════════════
146+
147+
# Config keys snapshotted into (and restored from) a pipeline profile —
148+
# everything that defines "which two models run and how", so switching e.g.
149+
# "local drafting" ↔ "cloud final pass" is one click instead of a Settings trip.
150+
PROFILE_CONFIG_KEYS = (
151+
"prompter_backend", "prompter_base_url", "prompter_model",
152+
"coder_backend", "coder_base_url", "coder_model",
153+
"prompter_temperature", "coder_temperature",
154+
"prompter_max_tokens", "coder_max_tokens",
155+
)
156+
157+
158+
def capture_pipeline_profile(config: dict, prompter_system: str, coder_system: str) -> dict:
159+
"""Snapshot the current endpoints/params plus the active system prompts."""
160+
profile = {key: config.get(key) for key in PROFILE_CONFIG_KEYS}
161+
profile["prompter_system"] = prompter_system
162+
profile["coder_system"] = coder_system
163+
return profile
164+
165+
166+
def apply_pipeline_profile(config: dict, profile: dict) -> dict:
167+
"""Write a profile's endpoint/param snapshot into the config and persist
168+
it. System prompts are returned to the caller via the profile itself —
169+
they live in session state, not config."""
170+
for key in PROFILE_CONFIG_KEYS:
171+
if profile.get(key) is not None:
172+
config[key] = profile[key]
173+
save_config(config)
174+
return config
175+
176+
177+
def save_pipeline_profile(config: dict, name: str, profile: dict) -> dict:
178+
"""Create or overwrite a named pipeline profile."""
179+
config.setdefault("pipeline_profiles", {})[name] = profile
180+
save_config(config)
181+
return config
182+
183+
184+
def delete_pipeline_profile(config: dict, name: str) -> dict:
185+
"""Remove a named pipeline profile (no-op if absent)."""
186+
profiles = config.get("pipeline_profiles", {})
187+
if name in profiles:
188+
del profiles[name]
189+
save_config(config)
190+
return config
191+
192+
130193
# ═══════════════════════════════════════════════════════
131194
# Preset Management
132195
# ═══════════════════════════════════════════════════════

0 commit comments

Comments
 (0)