Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4244b9370c | ||
|
|
dae76a5636 | ||
|
|
ff8ff585a2 | ||
|
|
434aa84d40 |
No files matched your search
@@ -3,6 +3,23 @@
|
|||||||
All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/);
|
All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/);
|
||||||
until 1.0, minor versions may contain breaking changes.
|
until 1.0, minor versions may contain breaking changes.
|
||||||
|
|
||||||
|
## [v202611-00008] – 2026-11-09
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
- OpenAI-compatible providers: tool results no longer carry a `name` field. Gateways that
|
||||||
|
translate to Anthropic (opencode) rejected the request with "anthropic-messages cannot
|
||||||
|
preserve named conversation participants".
|
||||||
|
- 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
|
## [v202611-00007] – 2026-11-09
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img alt="Version v202611-00007" src="https://img.shields.io/badge/version-v202611--00007-d69e2e?style=flat-square">
|
<img alt="Version v202611-00008" src="https://img.shields.io/badge/version-v202611--00008-d69e2e?style=flat-square">
|
||||||
<img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-689d6a?style=flat-square">
|
<img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-689d6a?style=flat-square">
|
||||||
<img alt="Rust backend" src="https://img.shields.io/badge/backend-Rust-b7410e?style=flat-square&logo=rust">
|
<img alt="Rust backend" src="https://img.shields.io/badge/backend-Rust-b7410e?style=flat-square&logo=rust">
|
||||||
<img alt="React PWA" src="https://img.shields.io/badge/frontend-React%20PWA-2d81ff?style=flat-square&logo=react">
|
<img alt="React PWA" src="https://img.shields.io/badge/frontend-React%20PWA-2d81ff?style=flat-square&logo=react">
|
||||||
|
|||||||
@@ -47,10 +47,13 @@ pub fn to_wire_messages(system: &str, messages: &[Message]) -> Vec<Value> {
|
|||||||
Part::Image { image } => parts.push(json!({
|
Part::Image { image } => parts.push(json!({
|
||||||
"type": "image_url", "image_url": {"url": data_url(image)}
|
"type": "image_url", "image_url": {"url": data_url(image)}
|
||||||
})),
|
})),
|
||||||
Part::ToolResult { call_id, name, content, images, is_error, .. } => {
|
Part::ToolResult { call_id, content, images, is_error, .. } => {
|
||||||
let text = if *is_error { format!("ERROR: {content}") } else { content.clone() };
|
let text = if *is_error { format!("ERROR: {content}") } else { content.clone() };
|
||||||
|
// No "name": it is not part of the tool message in the Chat
|
||||||
|
// Completions API, and gateways that translate to Anthropic
|
||||||
|
// (opencode) reject named participants.
|
||||||
out.push(json!({
|
out.push(json!({
|
||||||
"role": "tool", "tool_call_id": call_id, "name": name,
|
"role": "tool", "tool_call_id": call_id,
|
||||||
"content": if text.is_empty() { "(no output)".into() } else { text }
|
"content": if text.is_empty() { "(no output)".into() } else { text }
|
||||||
}));
|
}));
|
||||||
tool_images.extend(images.iter().cloned());
|
tool_images.extend(images.iter().cloned());
|
||||||
@@ -328,6 +331,22 @@ mod tests {
|
|||||||
(text, reasoning)
|
(text, reasoning)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tool_messages_have_no_name() {
|
||||||
|
use crate::types::{Message, Part, Role};
|
||||||
|
let call = Part::ToolCall { id: "c1".into(), name: "bash".into(), input: serde_json::json!({"command": "ls"}), extra: None };
|
||||||
|
let result = Part::ToolResult { call_id: "c1".into(), name: "bash".into(), content: "ok".into(), images: vec![], is_error: false, meta: None };
|
||||||
|
let msgs = vec![
|
||||||
|
Message::new(Role::User, vec![Part::Text { text: "hi".into() }]),
|
||||||
|
Message::new(Role::Assistant, vec![call]),
|
||||||
|
Message::new(Role::User, vec![result]),
|
||||||
|
];
|
||||||
|
let wire = super::to_wire_messages("sys", &msgs);
|
||||||
|
let tool = wire.iter().find(|m| m["role"] == "tool").expect("tool message");
|
||||||
|
assert_eq!(tool["tool_call_id"], "c1");
|
||||||
|
assert!(wire.iter().all(|m| m.get("name").is_none()), "no message may carry a name: {wire:?}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn splits_think_tags_across_chunks() {
|
fn splits_think_tags_across_chunks() {
|
||||||
let (text, reasoning) = run(&["<thi", "nk>plan ", "the fix</th", "ink>Here is ", "the answer."]);
|
let (text, reasoning) = run(&["<thi", "nk>plan ", "the fix</th", "ink>Here is ", "the answer."]);
|
||||||
|
|||||||
@@ -166,6 +166,9 @@ pub struct Session {
|
|||||||
/// User-defined group in the session list.
|
/// User-defined group in the session list.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub folder: Option<String>,
|
pub folder: Option<String>,
|
||||||
|
/// 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 {
|
impl Session {
|
||||||
|
|||||||
+12
-2
@@ -349,7 +349,8 @@ struct TelegramBody {
|
|||||||
|
|
||||||
/// Connect a bot: check the token, start polling and create a pairing code.
|
/// Connect a bot: check the token, start polling and create a pairing code.
|
||||||
async fn put_telegram(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<TelegramBody>) -> ApiResult {
|
async fn put_telegram(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<TelegramBody>) -> ApiResult {
|
||||||
let token = b.token.trim().to_string();
|
let token = crate::telegram::extract_token(&b.token)
|
||||||
|
.ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "that is not a bot token – it looks like 123456789:AAH… (paste only the token from @BotFather)".into()))?;
|
||||||
let bot = crate::telegram::check_token(&token).await.map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("token rejected by Telegram: {e:#}")))?;
|
let bot = crate::telegram::check_token(&token).await.map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("token rejected by Telegram: {e:#}")))?;
|
||||||
let s = crate::telegram::Settings { token, bot, pair_code: crate::telegram::new_pair_code(), ..Default::default() };
|
let s = crate::telegram::Settings { token, bot, pair_code: crate::telegram::new_pair_code(), ..Default::default() };
|
||||||
app.store.set_user_setting(&user.id, "telegram", &s)?;
|
app.store.set_user_setting(&user.id, "telegram", &s)?;
|
||||||
@@ -573,6 +574,7 @@ async fn create_session(Extension(user): Extension<User>, State(app): State<AppS
|
|||||||
pinned: false,
|
pinned: false,
|
||||||
archived: false,
|
archived: false,
|
||||||
folder: None,
|
folder: None,
|
||||||
|
instructions: String::new(),
|
||||||
};
|
};
|
||||||
let s = app.save_session(s)?;
|
let s = app.save_session(s)?;
|
||||||
Ok(Json(json!(s)))
|
Ok(Json(json!(s)))
|
||||||
@@ -781,12 +783,17 @@ struct UpdateSession {
|
|||||||
archived: Option<bool>,
|
archived: Option<bool>,
|
||||||
/// "" removes the folder.
|
/// "" removes the folder.
|
||||||
folder: Option<String>,
|
folder: Option<String>,
|
||||||
|
/// Instructions for this chat ("" clears them).
|
||||||
|
instructions: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn update_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<UpdateSession>) -> ApiResult {
|
async fn update_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<UpdateSession>) -> ApiResult {
|
||||||
writable(&app, &user, &id)?;
|
writable(&app, &user, &id)?;
|
||||||
// Organising the list must not move the session to "today".
|
// 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 {
|
if let Some(m) = &body.model {
|
||||||
app.registry().resolve(m)?;
|
app.registry().resolve(m)?;
|
||||||
}
|
}
|
||||||
@@ -823,6 +830,9 @@ async fn update_session(Extension(user): Extension<User>, State(app): State<AppS
|
|||||||
if let Some(p) = body.permission_mode {
|
if let Some(p) = body.permission_mode {
|
||||||
s.permission_mode = p;
|
s.permission_mode = p;
|
||||||
}
|
}
|
||||||
|
if let Some(i) = body.instructions {
|
||||||
|
s.instructions = i.trim().to_string();
|
||||||
|
}
|
||||||
})?;
|
})?;
|
||||||
Ok(Json(json!(s)))
|
Ok(Json(json!(s)))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -164,6 +164,7 @@ pub async fn run_with(app: &Arc<App>, job: &Job, trigger: &str, payload: Option<
|
|||||||
pinned: false,
|
pinned: false,
|
||||||
archived: false,
|
archived: false,
|
||||||
folder: None,
|
folder: None,
|
||||||
|
instructions: String::new(),
|
||||||
};
|
};
|
||||||
let session = app.save_session(session)?;
|
let session = app.save_session(session)?;
|
||||||
app.store.job_started(&job.id, &session.id, now)?;
|
app.store.job_started(&job.id, &session.id, now)?;
|
||||||
|
|||||||
@@ -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
|
/// 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.
|
/// 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 {
|
fn chat_prompt(app: &App, agent: &Agent, model: &str, owner: Option<&crate::store::User>) -> String {
|
||||||
let mut s = agent.prompt.clone();
|
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")));
|
s.push_str(&format!("\n\n# Context\n- Date: {}\n- Model: {model}\n", chrono::Local::now().format("%A, %Y-%m-%d")));
|
||||||
|
|||||||
@@ -293,7 +293,7 @@ pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationTo
|
|||||||
let tools = available_tools(&app, &agent, owner.as_ref());
|
let tools = available_tools(&app, &agent, owner.as_ref());
|
||||||
let history = active_history(app.store.messages(session_id)?);
|
let history = active_history(app.store.messages(session_id)?);
|
||||||
let req = ChatRequest {
|
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,
|
messages: history,
|
||||||
tools: tools.iter().map(|t| t.def()).collect(),
|
tools: tools.iter().map(|t| t.def()).collect(),
|
||||||
max_tokens: model.max_output.unwrap_or(16_000),
|
max_tokens: model.max_output.unwrap_or(16_000),
|
||||||
@@ -704,6 +704,7 @@ fn create_child(app: &Arc<App>, ctx: &ToolCtx, agent_name: &str, description: &s
|
|||||||
pinned: false,
|
pinned: false,
|
||||||
archived: false,
|
archived: false,
|
||||||
folder: None,
|
folder: None,
|
||||||
|
instructions: String::new(),
|
||||||
};
|
};
|
||||||
let child = app.save_session(child)?;
|
let child = app.save_session(child)?;
|
||||||
let msg = Message::new(Role::User, vec![Part::Text { text: prompt }]);
|
let msg = Message::new(Role::User, vec![Part::Text { text: prompt }]);
|
||||||
|
|||||||
+37
-2
@@ -149,6 +149,12 @@ impl Bot {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Find the bot token in what the user pasted (also the whole BotFather message).
|
||||||
|
pub fn extract_token(input: &str) -> Option<String> {
|
||||||
|
let re = regex::Regex::new(r"\b(\d{5,}:[A-Za-z0-9_-]{30,})\b").unwrap();
|
||||||
|
re.captures(input).map(|c| c[1].to_string())
|
||||||
|
}
|
||||||
|
|
||||||
/// Check a token and return the bot's username.
|
/// Check a token and return the bot's username.
|
||||||
pub async fn check_token(token: &str) -> Result<String> {
|
pub async fn check_token(token: &str) -> Result<String> {
|
||||||
let me = Bot { token: token.to_string() }.call("getMe", json!({})).await?;
|
let me = Bot { token: token.to_string() }.call("getMe", json!({})).await?;
|
||||||
@@ -323,6 +329,7 @@ async fn handle_update(app: &Arc<App>, user_id: &str, bot: &Bot, u: &Value) -> R
|
|||||||
return new_session(app, &u, bot, chat, &format!("{folder} {}", cur.agent), de).await;
|
return new_session(app, &u, bot, chat, &format!("{folder} {}", cur.agent), de).await;
|
||||||
}
|
}
|
||||||
"permissions" => return set_permissions(app, &u, bot, chat, arg, 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,
|
"effort" => return set_effort(app, &u, bot, chat, arg, de).await,
|
||||||
"costs" => return costs(app, &u, bot, chat, de).await,
|
"costs" => return costs(app, &u, bot, chat, de).await,
|
||||||
"memory" => return memory(app, &u, bot, chat, de).await,
|
"memory" => return memory(app, &u, bot, chat, de).await,
|
||||||
@@ -395,13 +402,13 @@ fn help(de: bool) -> String {
|
|||||||
if de {
|
if de {
|
||||||
"<b>CodeCraft</b> – Nachrichten gehen an die aktuelle Sitzung (Fotos und Dateien auch).\n\n\
|
"<b>CodeCraft</b> – 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\
|
/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"
|
/stop – Runde abbrechen\n/status – aktuelle Sitzung\n/costs – Kosten und Budgets\n/memory – Gedächtnis\n/jobs – Automationen\n/compact – Unterhaltung komprimieren"
|
||||||
.into()
|
.into()
|
||||||
} else {
|
} else {
|
||||||
"<b>CodeCraft</b> – messages go to the current session (photos and files too).\n\n\
|
"<b>CodeCraft</b> – 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\
|
/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"
|
/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()
|
.into()
|
||||||
}
|
}
|
||||||
@@ -530,6 +537,7 @@ async fn new_session(app: &Arc<App>, u: &User, bot: &Bot, chat: i64, arg: &str,
|
|||||||
pinned: false,
|
pinned: false,
|
||||||
archived: false,
|
archived: false,
|
||||||
folder: None,
|
folder: None,
|
||||||
|
instructions: String::new(),
|
||||||
})?;
|
})?;
|
||||||
update(app, &u.id, |st| st.session = Some(s.id.clone()));
|
update(app, &u.id, |st| st.session = Some(s.id.clone()));
|
||||||
bot.send(chat, &format!("{} <b>{}</b> · {} · {}", tr(de, "Neue Sitzung", "New session"), esc(&s.cwd), esc(&s.agent), esc(&s.model)), None).await?;
|
bot.send(chat, &format!("{} <b>{}</b> · {} · {}", tr(de, "Neue Sitzung", "New session"), esc(&s.cwd), esc(&s.agent), esc(&s.model)), None).await?;
|
||||||
@@ -614,6 +622,25 @@ async fn status_message(app: &App, u: &User, bot: &Bot, chat: i64, de: bool) ->
|
|||||||
Ok(())
|
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!("<b>{}</b>\n{}\n\n<i>{}</i>", 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 PERMISSION_MODES: &[&str] = &["auto", "default", "accept_edits", "bypass"];
|
||||||
const EFFORTS: &[&str] = &["low", "medium", "high", "xhigh", "max"];
|
const EFFORTS: &[&str] = &["low", "medium", "high", "xhigh", "max"];
|
||||||
|
|
||||||
@@ -1145,6 +1172,14 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn finds_the_token_in_pasted_text() {
|
||||||
|
let msg = "Done! Congratulations on your new bot.\nUse this token to access the HTTP API:\n7123456789:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsaw4\nKeep your token secure";
|
||||||
|
assert_eq!(extract_token(msg).as_deref(), Some("7123456789:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsaw4"));
|
||||||
|
assert_eq!(extract_token(" 7123456789:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsaw4 ").as_deref(), Some("7123456789:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsaw4"));
|
||||||
|
assert_eq!(extract_token("my-password-123"), None);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn step_lines() {
|
fn step_lines() {
|
||||||
assert_eq!(step_line("bash", &json!({"command": "cargo test\nmore"})), "bash: cargo test");
|
assert_eq!(step_line("bash", &json!({"command": "cargo test\nmore"})), "bash: cargo test");
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { useReasoningMode } from "../prefs";
|
|||||||
import { Composer } from "./Composer";
|
import { Composer } from "./Composer";
|
||||||
import { ShareDialog } from "./ShareDialog";
|
import { ShareDialog } from "./ShareDialog";
|
||||||
import { RenameDialog } from "./RenameDialog";
|
import { RenameDialog } from "./RenameDialog";
|
||||||
|
import { InstructionsDialog } from "./InstructionsDialog";
|
||||||
import { RegenerateButton } from "./Regenerate";
|
import { RegenerateButton } from "./Regenerate";
|
||||||
import { WorktreeChip } from "./WorktreeChip";
|
import { WorktreeChip } from "./WorktreeChip";
|
||||||
import { QueueList } from "./QueueList";
|
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 isAdmin = useStore((s) => s.me?.role === "admin");
|
||||||
const tabs = useChatTabs(sessionId, session?.cwd ?? "");
|
const tabs = useChatTabs(sessionId, session?.cwd ?? "");
|
||||||
const [sharing, setSharing] = useState(false);
|
const [sharing, setSharing] = useState(false);
|
||||||
|
const [editingInstructions, setEditingInstructions] = useState(false);
|
||||||
const models = useModels();
|
const models = useModels();
|
||||||
const [loadError, setLoadError] = useState<string>();
|
const [loadError, setLoadError] = useState<string>();
|
||||||
const [text, setText] = useState(() => takePrefill(sessionId) ?? "");
|
const [text, setText] = useState(() => takePrefill(sessionId) ?? "");
|
||||||
@@ -307,6 +309,11 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
|||||||
<ShellsChip sessionId={sessionId} busy={busy} />
|
<ShellsChip sessionId={sessionId} busy={busy} />
|
||||||
{isAdmin && <BudgetChip />}
|
{isAdmin && <BudgetChip />}
|
||||||
{session.permission_mode === "bypass" && <span className="chip warn">{t("permissionMode.bypass")}</span>}
|
{session.permission_mode === "bypass" && <span className="chip warn">{t("permissionMode.bypass")}</span>}
|
||||||
|
{!!session.instructions && (
|
||||||
|
<button className="chip" style={{ border: "none" }} onClick={() => !readOnly && setEditingInstructions(true)} title={session.instructions}>
|
||||||
|
<Icon name="edit" /> {t("instructions.chip")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{readOnly && <span className="chip">{t("chat.readOnly")}</span>}
|
{readOnly && <span className="chip">{t("chat.readOnly")}</span>}
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<>
|
<>
|
||||||
@@ -335,6 +342,16 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
|||||||
<Icon name="more" />
|
<Icon name="more" />
|
||||||
</summary>
|
</summary>
|
||||||
<div className="menu-pop-body">
|
<div className="menu-pop-body">
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
(e.currentTarget.closest("details") as HTMLDetailsElement).open = false;
|
||||||
|
setEditingInstructions(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name="edit" /> {t("instructions.title")}
|
||||||
|
</a>
|
||||||
<a href={`/api/sessions/${sessionId}/export?format=md`} download>
|
<a href={`/api/sessions/${sessionId}/export?format=md`} download>
|
||||||
<Icon name="download" /> {t("chat.exportMd")}
|
<Icon name="download" /> {t("chat.exportMd")}
|
||||||
</a>
|
</a>
|
||||||
@@ -527,6 +544,7 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{editingInstructions && <InstructionsDialog sessionId={sessionId} value={session.instructions ?? ""} onClose={() => setEditingInstructions(false)} />}
|
||||||
{editingTitle && <RenameDialog sessionId={sessionId} title={session.title} onClose={() => setEditingTitle(false)} />}
|
{editingTitle && <RenameDialog sessionId={sessionId} title={session.title} onClose={() => setEditingTitle(false)} />}
|
||||||
{sharing && <ShareDialog sessionId={sessionId} onClose={() => setSharing(false)} />}
|
{sharing && <ShareDialog sessionId={sessionId} onClose={() => setSharing(false)} />}
|
||||||
{rewinding && <RewindDialog sessionId={sessionId} messageId={rewinding.id} onDone={rewindDone} onClose={() => setRewinding(undefined)} />}
|
{rewinding && <RewindDialog sessionId={sessionId} messageId={rewinding.id} onDone={rewindDone} onClose={() => setRewinding(undefined)} />}
|
||||||
|
|||||||
@@ -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<string>();
|
||||||
|
|
||||||
|
const save = async (next: string) => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await patch(`/sessions/${sessionId}`, { instructions: next });
|
||||||
|
onClose();
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message);
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-backdrop" onClick={onClose}>
|
||||||
|
<div className="modal instructions-modal" role="dialog" aria-label={t("instructions.title")} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h3>{t("instructions.title")}</h3>
|
||||||
|
<div className="hint" style={{ marginBottom: 8 }}>
|
||||||
|
{t("instructions.hint")}
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
autoFocus
|
||||||
|
rows={10}
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
value={text}
|
||||||
|
placeholder={t("instructions.placeholder")}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
onKeyDown={(e) => (e.ctrlKey || e.metaKey) && e.key === "Enter" && !busy && void save(text)}
|
||||||
|
/>
|
||||||
|
{error && <div className="notice err">{error}</div>}
|
||||||
|
<div className="row" style={{ marginTop: 10, justifyContent: "flex-end", gap: 6 }}>
|
||||||
|
{value && (
|
||||||
|
<button className="danger" onClick={() => void save("")} disabled={busy}>
|
||||||
|
{t("instructions.clear")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<span className="spacer" />
|
||||||
|
<button onClick={onClose}>{t("common.cancel")}</button>
|
||||||
|
<button className="primary" onClick={() => void save(text)} disabled={busy || text.trim() === value.trim()}>
|
||||||
|
{t("common.save")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -60,7 +60,17 @@ export function Telegram() {
|
|||||||
<li>{t("telegram.step2")}</li>
|
<li>{t("telegram.step2")}</li>
|
||||||
</ol>
|
</ol>
|
||||||
<div className="row" style={{ gap: 6 }}>
|
<div className="row" style={{ gap: 6 }}>
|
||||||
<input type="password" value={token} placeholder="123456789:AA…" autoComplete="off" onChange={(e) => setToken(e.target.value)} style={{ flex: 1 }} />
|
<input
|
||||||
|
type="text"
|
||||||
|
name="telegram-bot-token"
|
||||||
|
value={token}
|
||||||
|
placeholder="123456789:AAH…"
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
className="mono"
|
||||||
|
onChange={(e) => setToken(e.target.value)}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
<button className="primary" onClick={() => void connect()} disabled={busy || !token.trim()}>
|
<button className="primary" onClick={() => void connect()} disabled={busy || !token.trim()}>
|
||||||
{busy ? t("telegram.checking") : t("telegram.connect")}
|
{busy ? t("telegram.checking") : t("telegram.connect")}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -593,6 +593,11 @@ export const de: Messages = {
|
|||||||
"chat.compactingAuto": "Kontext fast voll – Unterhaltung wird automatisch komprimiert",
|
"chat.compactingAuto": "Kontext fast voll – Unterhaltung wird automatisch komprimiert",
|
||||||
"chat.compactingManual": "Unterhaltung wird komprimiert",
|
"chat.compactingManual": "Unterhaltung wird komprimiert",
|
||||||
"chat.compactingDetail": "{tokens} von {limit} Tokens ({pct}) – danach arbeitet der Agent mit einer Zusammenfassung weiter",
|
"chat.compactingDetail": "{tokens} von {limit} Tokens ({pct}) – danach arbeitet der Agent mit einer Zusammenfassung weiter",
|
||||||
|
"instructions.title": "Anweisungen für diesen Chat",
|
||||||
|
"instructions.hint": "Gelten für jede Nachricht in diesem Chat, zusätzlich zum Modus – z. B. Sprache, Ton, Regeln oder der Teil des Projekts, um den es geht.",
|
||||||
|
"instructions.placeholder": "z. B. Antworte immer auf Englisch. Ändere nur Dateien unter api/. Frag, bevor du Abhängigkeiten hinzufügst.",
|
||||||
|
"instructions.clear": "Entfernen",
|
||||||
|
"instructions.chip": "Anweisungen",
|
||||||
"chat.compacted": "Unterhaltung komprimiert – Zusammenfassung",
|
"chat.compacted": "Unterhaltung komprimiert – Zusammenfassung",
|
||||||
"chat.context": "Kontext",
|
"chat.context": "Kontext",
|
||||||
"chat.contextHint": "{tokens} Tokens in der letzten Anfrage",
|
"chat.contextHint": "{tokens} Tokens in der letzten Anfrage",
|
||||||
|
|||||||
@@ -592,6 +592,11 @@ export const en = {
|
|||||||
"chat.compactingAuto": "Context nearly full – compacting the conversation automatically",
|
"chat.compactingAuto": "Context nearly full – compacting the conversation automatically",
|
||||||
"chat.compactingManual": "Compacting the conversation",
|
"chat.compactingManual": "Compacting the conversation",
|
||||||
"chat.compactingDetail": "{tokens} of {limit} tokens ({pct}) – the agent continues with a summary afterwards",
|
"chat.compactingDetail": "{tokens} of {limit} tokens ({pct}) – the agent continues with a summary afterwards",
|
||||||
|
"instructions.title": "Instructions for this chat",
|
||||||
|
"instructions.hint": "Apply to every message in this chat, in addition to the mode – e.g. language, tone, rules, the part of the project to work on.",
|
||||||
|
"instructions.placeholder": "e.g. Always answer in English. Only change files under api/. Ask before adding dependencies.",
|
||||||
|
"instructions.clear": "Remove",
|
||||||
|
"instructions.chip": "Instructions",
|
||||||
"chat.compacted": "Conversation compacted – summary",
|
"chat.compacted": "Conversation compacted – summary",
|
||||||
"chat.context": "Context",
|
"chat.context": "Context",
|
||||||
"chat.contextHint": "{tokens} tokens in the last request",
|
"chat.contextHint": "{tokens} tokens in the last request",
|
||||||
|
|||||||
@@ -3770,3 +3770,6 @@ span.chat-tab > button {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.instructions-modal {
|
||||||
|
width: min(640px, calc(100vw - 32px));
|
||||||
|
}
|
||||||
@@ -67,6 +67,8 @@ export interface Session {
|
|||||||
pinned?: boolean;
|
pinned?: boolean;
|
||||||
archived?: boolean;
|
archived?: boolean;
|
||||||
folder?: string | null;
|
folder?: string | null;
|
||||||
|
/** Instructions for this chat (added to the system prompt). */
|
||||||
|
instructions?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QuestionOption {
|
export interface QuestionOption {
|
||||||
|
|||||||
@@ -3,6 +3,10 @@
|
|||||||
* under "unreleased"; scripts/release.sh renames the key to the release version.
|
* under "unreleased"; scripts/release.sh renames the key to the release version.
|
||||||
*/
|
*/
|
||||||
export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = {
|
export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = {
|
||||||
|
"202611-00008": {
|
||||||
|
en: ["Instructions per chat: ⋯ → Instructions for this chat (Telegram: /prompt)"],
|
||||||
|
de: ["Anweisungen pro Chat: ⋯ → Anweisungen für diesen Chat (Telegram: /prompt)"],
|
||||||
|
},
|
||||||
"202611-00007": {
|
"202611-00007": {
|
||||||
en: ["Telegram: connect your own bot under Settings → Account → Telegram and chat with CodeCraft from your phone"],
|
en: ["Telegram: connect your own bot under Settings → Account → Telegram and chat with CodeCraft from your phone"],
|
||||||
de: ["Telegram: eigenen Bot unter Einstellungen → Konto → Telegram verbinden und von unterwegs mit CodeCraft chatten"],
|
de: ["Telegram: eigenen Bot unter Einstellungen → Konto → Telegram verbinden und von unterwegs mit CodeCraft chatten"],
|
||||||
|
|||||||
Reference in new issue
Block a user