diff --git a/CHANGELOG.md b/CHANGELOG.md index f3b384b..dc93e2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,20 @@ All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/); until 1.0, minor versions may contain breaking changes. +## [Unreleased] + +### Added + +- **Instructions per chat:** *⋯ → Instructions for this chat* (Telegram: `/prompt`) sets rules + that apply to every message of that chat on top of the mode – language, tone, the part of the + project to work on. A chip in the header shows when a chat has them; forks keep them. + +### Fixed + +- Telegram: the token field is no longer filled in by the browser's password manager, the bot + token is found in whatever is pasted (also the whole BotFather message), and a wrong format + gets a clear message instead of "HTTP 404". + ## [v202611-00007] – 2026-11-09 ### Added diff --git a/core/src/types.rs b/core/src/types.rs index e8ee0dd..027b57d 100644 --- a/core/src/types.rs +++ b/core/src/types.rs @@ -166,6 +166,9 @@ pub struct Session { /// User-defined group in the session list. #[serde(default, skip_serializing_if = "Option::is_none")] pub folder: Option, + /// Instructions the user set for this chat; added to the system prompt of every turn. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub instructions: String, } impl Session { diff --git a/server/src/api.rs b/server/src/api.rs index f8ce772..763573b 100644 --- a/server/src/api.rs +++ b/server/src/api.rs @@ -574,6 +574,7 @@ async fn create_session(Extension(user): Extension, State(app): State, /// "" removes the folder. folder: Option, + /// Instructions for this chat ("" clears them). + instructions: Option, } async fn update_session(Extension(user): Extension, State(app): State, Path(id): Path, Json(body): Json) -> ApiResult { writable(&app, &user, &id)?; // Organising the list must not move the session to "today". - let organise_only = body.title.is_none() && body.model.is_none() && body.agent.is_none() && body.clean_room.is_none() && body.permission_mode.is_none(); + let organise_only = body.title.is_none() && body.model.is_none() && body.agent.is_none() && body.clean_room.is_none() && body.permission_mode.is_none() && body.instructions.is_none(); + if body.instructions.as_ref().is_some_and(|i| i.len() > 20_000) { + return Err(ApiError(StatusCode::BAD_REQUEST, "instructions too long (max 20,000 characters)".into())); + } if let Some(m) = &body.model { app.registry().resolve(m)?; } @@ -824,6 +830,9 @@ async fn update_session(Extension(user): Extension, State(app): State, job: &Job, trigger: &str, payload: Option< pinned: false, archived: false, folder: None, + instructions: String::new(), }; let session = app.save_session(session)?; app.store.job_started(&job.id, &session.id, now)?; diff --git a/server/src/prompts.rs b/server/src/prompts.rs index a61d834..0776b95 100644 --- a/server/src/prompts.rs +++ b/server/src/prompts.rs @@ -216,6 +216,17 @@ fn instruction_files(app: &App, root: &Path, cwd: &Path) -> Vec<(PathBuf, String /// The plain chat mode: no coding instructions, project files or memory – only the /// date, the model and what the user keeps in their personal memory. +/// Add the instructions the user set for this chat (they come last, so they win). +pub fn with_chat_instructions(mut system: String, instructions: &str) -> String { + if !instructions.trim().is_empty() { + system.push_str(&format!( + "\n\n# Instructions for this chat\nThe user set these for this chat. They apply to every message and override the defaults above:\n{}\n", + instructions.trim() + )); + } + system +} + fn chat_prompt(app: &App, agent: &Agent, model: &str, owner: Option<&crate::store::User>) -> String { let mut s = agent.prompt.clone(); s.push_str(&format!("\n\n# Context\n- Date: {}\n- Model: {model}\n", chrono::Local::now().format("%A, %Y-%m-%d"))); diff --git a/server/src/runner.rs b/server/src/runner.rs index 467f7c0..41233d3 100644 --- a/server/src/runner.rs +++ b/server/src/runner.rs @@ -293,7 +293,7 @@ pub async fn run_session(app: Arc, session_id: &str, cancel: CancellationTo let tools = available_tools(&app, &agent, owner.as_ref()); let history = active_history(app.store.messages(session_id)?); let req = ChatRequest { - system: prompts::system_prompt(&app, &agent, &root, &cwd, &model_spec, &session.permission_mode, owner.as_ref()), + system: prompts::with_chat_instructions(prompts::system_prompt(&app, &agent, &root, &cwd, &model_spec, &session.permission_mode, owner.as_ref()), &session.instructions), messages: history, tools: tools.iter().map(|t| t.def()).collect(), max_tokens: model.max_output.unwrap_or(16_000), @@ -704,6 +704,7 @@ fn create_child(app: &Arc, ctx: &ToolCtx, agent_name: &str, description: &s pinned: false, archived: false, folder: None, + instructions: String::new(), }; let child = app.save_session(child)?; let msg = Message::new(Role::User, vec![Part::Text { text: prompt }]); diff --git a/server/src/telegram.rs b/server/src/telegram.rs index 15f7c03..6f0d667 100644 --- a/server/src/telegram.rs +++ b/server/src/telegram.rs @@ -329,6 +329,7 @@ async fn handle_update(app: &Arc, user_id: &str, bot: &Bot, u: &Value) -> R return new_session(app, &u, bot, chat, &format!("{folder} {}", cur.agent), de).await; } "permissions" => return set_permissions(app, &u, bot, chat, arg, de).await, + "prompt" => return chat_instructions(app, &u, bot, chat, arg, de).await, "effort" => return set_effort(app, &u, bot, chat, arg, de).await, "costs" => return costs(app, &u, bot, chat, de).await, "memory" => return memory(app, &u, bot, chat, de).await, @@ -401,13 +402,13 @@ fn help(de: bool) -> String { if de { "CodeCraft – Nachrichten gehen an die aktuelle Sitzung (Fotos und Dateien auch).\n\n\ /sessions – letzte Sitzungen\n/session <Nr|Name> – Sitzung wechseln\n/new [Ordner] [Modus] – neue Sitzung (ohne Ordner: Chat)\n\ - /clear – neue Sitzung im gleichen Ordner\n/mode [Name] – Modus\n/model [Modell] – Modell\n/permissions [Modus] – Freigaben\n/effort [Stufe] – Denktiefe\n\ + /clear – neue Sitzung im gleichen Ordner\n/mode [Name] – Modus\n/model [Modell] – Modell\n/permissions [Modus] – Freigaben\n/prompt [Text|-] – Anweisungen für diesen Chat\n/effort [Stufe] – Denktiefe\n\ /stop – Runde abbrechen\n/status – aktuelle Sitzung\n/costs – Kosten und Budgets\n/memory – Gedächtnis\n/jobs – Automationen\n/compact – Unterhaltung komprimieren" .into() } else { "CodeCraft – messages go to the current session (photos and files too).\n\n\ /sessions – recent sessions\n/session <n|name> – switch session\n/new [folder] [mode] – new session (no folder: chat)\n\ - /clear – new session in the same folder\n/mode [name] – mode\n/model [model] – model\n/permissions [mode] – approvals\n/effort [level] – reasoning effort\n\ + /clear – new session in the same folder\n/mode [name] – mode\n/model [model] – model\n/permissions [mode] – approvals\n/prompt [text|-] – instructions for this chat\n/effort [level] – reasoning effort\n\ /stop – stop the turn\n/status – current session\n/costs – costs and budgets\n/memory – memory\n/jobs – automations\n/compact – compact the conversation" .into() } @@ -536,6 +537,7 @@ async fn new_session(app: &Arc, u: &User, bot: &Bot, chat: i64, arg: &str, pinned: false, archived: false, folder: None, + instructions: String::new(), })?; update(app, &u.id, |st| st.session = Some(s.id.clone())); bot.send(chat, &format!("{} {} · {} · {}", tr(de, "Neue Sitzung", "New session"), esc(&s.cwd), esc(&s.agent), esc(&s.model)), None).await?; @@ -620,6 +622,25 @@ async fn status_message(app: &App, u: &User, bot: &Bot, chat: i64, de: bool) -> Ok(()) } +/// Show, set ("/prompt text") or clear ("/prompt -") the instructions of the current chat. +async fn chat_instructions(app: &App, u: &User, bot: &Bot, chat: i64, arg: &str, de: bool) -> Result<()> { + let s = current(app, u, de)?; + if arg.is_empty() { + let text = if s.instructions.is_empty() { + tr(de, "Keine Anweisungen für diesen Chat. Setzen mit /prompt <Text>.", "No instructions for this chat. Set them with /prompt <text>.").to_string() + } else { + format!("{}\n{}\n\n{}", tr(de, "Anweisungen für diesen Chat", "Instructions for this chat"), esc(&s.instructions), tr(de, "Löschen mit /prompt -", "Clear with /prompt -")) + }; + bot.send(chat, &text, None).await?; + return Ok(()); + } + let new = if arg == "-" { String::new() } else { arg.to_string() }; + app.update_session(&s.id, |x| x.instructions = new.clone())?; + let text = if new.is_empty() { tr(de, "Anweisungen gelöscht.", "Instructions cleared.") } else { tr(de, "Anweisungen gesetzt – sie gelten ab der nächsten Nachricht.", "Instructions set – they apply from the next message.") }; + bot.send(chat, text, None).await?; + Ok(()) +} + const PERMISSION_MODES: &[&str] = &["auto", "default", "accept_edits", "bypass"]; const EFFORTS: &[&str] = &["low", "medium", "high", "xhigh", "max"]; diff --git a/web/src/components/ChatView.tsx b/web/src/components/ChatView.tsx index fa7bb82..606b258 100644 --- a/web/src/components/ChatView.tsx +++ b/web/src/components/ChatView.tsx @@ -13,6 +13,7 @@ import { useReasoningMode } from "../prefs"; import { Composer } from "./Composer"; import { ShareDialog } from "./ShareDialog"; import { RenameDialog } from "./RenameDialog"; +import { InstructionsDialog } from "./InstructionsDialog"; import { RegenerateButton } from "./Regenerate"; import { WorktreeChip } from "./WorktreeChip"; import { QueueList } from "./QueueList"; @@ -151,6 +152,7 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string const isAdmin = useStore((s) => s.me?.role === "admin"); const tabs = useChatTabs(sessionId, session?.cwd ?? ""); const [sharing, setSharing] = useState(false); + const [editingInstructions, setEditingInstructions] = useState(false); const models = useModels(); const [loadError, setLoadError] = useState(); const [text, setText] = useState(() => takePrefill(sessionId) ?? ""); @@ -307,6 +309,11 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string {isAdmin && } {session.permission_mode === "bypass" && {t("permissionMode.bypass")}} + {!!session.instructions && ( + + )} {readOnly && {t("chat.readOnly")}} {!readOnly && ( <> @@ -335,6 +342,16 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string )} + {editingInstructions && setEditingInstructions(false)} />} {editingTitle && setEditingTitle(false)} />} {sharing && setSharing(false)} />} {rewinding && setRewinding(undefined)} />} diff --git a/web/src/components/InstructionsDialog.tsx b/web/src/components/InstructionsDialog.tsx new file mode 100644 index 0000000..90ba8f5 --- /dev/null +++ b/web/src/components/InstructionsDialog.tsx @@ -0,0 +1,55 @@ +import { useState } from "react"; +import { patch } from "../api"; +import { useT } from "../i18n"; + +/** Instructions for one chat: added to the system prompt of every turn. */ +export function InstructionsDialog({ sessionId, value, onClose }: { sessionId: string; value: string; onClose: () => void }) { + const t = useT(); + const [text, setText] = useState(value); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(); + + const save = async (next: string) => { + setBusy(true); + try { + await patch(`/sessions/${sessionId}`, { instructions: next }); + onClose(); + } catch (e: any) { + setError(e.message); + setBusy(false); + } + }; + + return ( +
+
e.stopPropagation()}> +

{t("instructions.title")}

+
+ {t("instructions.hint")} +
+