Add configurable model prompts
This commit is contained in:
+34
@@ -0,0 +1,34 @@
|
||||
# Thinkloom prompt configuration
|
||||
|
||||
Thinkloom 0.2.0 exposes every instruction sent to a language model as editable JSON. The desktop app creates a prompts folder in its operating-system configuration directory and shows its exact path under Settings → Prompt configuration. Use Open prompt folder to open it.
|
||||
|
||||
Prompt files are loaded immediately before every model request. Save a valid edit, then make the next request; no restart or rebuild is required. Thinkloom never overwrites existing user prompt files during startup or an update.
|
||||
|
||||
## Files and effects
|
||||
|
||||
### conversation.json
|
||||
|
||||
Affects replies in Ideation. systemPrompt defines the overall role, userPromptTemplate defines the turn task, and challengeGuidance supplies the Gentle, Balanced, or Rigorous instruction. The {{challenge_guidance}} and {{context}} placeholders are required.
|
||||
|
||||
### drafting.json
|
||||
|
||||
Affects passage previews in Drafting and editorial previews in Finalization. systemPrompt defines the overall role, draftPromptTemplate is used by Draft a passage, and editorialPromptTemplate is used by the editorial actions. Available placeholders are {{relation}}, {{action}}, and {{context}}.
|
||||
|
||||
The description, effect, and variables objects document the configuration and are not sent to the model.
|
||||
|
||||
## Editing safely
|
||||
|
||||
1. You do not need to close Thinkloom; prompt files reload automatically.
|
||||
2. Back up a JSON file before a substantial change.
|
||||
3. Edit string values, preserving double quotes, commas, escaped line breaks as \n, and double-brace placeholders.
|
||||
4. Save the file and make a new request in the affected screen.
|
||||
|
||||
Thinkloom validates the schema, required fields, challenge level, and unresolved placeholders before contacting the provider. An invalid file leaves the writing untouched and displays the file path and correction needed.
|
||||
|
||||
## Resetting a prompt
|
||||
|
||||
Close Thinkloom, rename or delete only the affected JSON file in the user prompt folder, then reopen Thinkloom. The missing file is recreated from the bundled default. Source defaults for developers are in src-tauri/prompts/; rebuilding them does not overwrite an existing user's customized files.
|
||||
|
||||
## Privacy and security
|
||||
|
||||
Prompt content and substituted context are sent only to the provider selected in Settings. Local Ollama requests remain local. Cloud providers still require project approval. Do not put passwords or API keys in prompt files.
|
||||
@@ -78,6 +78,12 @@ Judges should not need to rebuild Thinkloom. A public Windows x64 installer rele
|
||||
|
||||
A Thinkloom project is self-contained. Canonical files live under `manuscript/`, `ideas/`, `conversations/`, `provenance/`, and `style/`. Live SQLite state and rotating snapshots are under `.thinkloom/` and are excluded from the project's hidden Git history. Audio retention is always false; no audio file extension is created by the native service.
|
||||
|
||||
## Prompt configuration
|
||||
|
||||
Every language-model prompt is editable JSON. Thinkloom creates documented user copies of conversation.json and drafting.json and exposes their folder under Settings → Prompt configuration. The files reload before every request, so technical users can iterate without rebuilding or restarting. Invalid JSON or missing template variables stop the request with a recoverable error and leave the writing unchanged.
|
||||
|
||||
See [PROMPTS.md](PROMPTS.md) for each file's effect, variables, editing workflow, validation behavior, and reset procedure.
|
||||
|
||||
## Provider setup
|
||||
|
||||
Ollama defaults to `http://127.0.0.1:11434` and model `llama3.2`. OpenAI and compatible credentials are entered in Settings and saved through Windows Credential Manager, macOS Keychain, or Linux Secret Service. The first cloud operation in each project requires explicit approval.
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "thinkloom",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "thinkloom",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@tiptap/markdown": "^3.28.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "thinkloom",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"author": "Christopher Chambers",
|
||||
"license": "AGPL-3.0-only",
|
||||
"repository": "https://github.com/Labyricorn/thinkloom-openai-hackathon.git",
|
||||
|
||||
Generated
+1
-1
@@ -3980,7 +3980,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "thinkloom"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"hex",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "thinkloom"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
description = "Local-first writing studio with creative provenance"
|
||||
authors = ["Christopher Chambers"]
|
||||
license = "AGPL-3.0-only"
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "conversation",
|
||||
"description": "Controls Thinkloom replies during the Ideation conversation.",
|
||||
"effect": "Changes how the assistant reflects on the writer's latest message and which single follow-up question it asks.",
|
||||
"systemPrompt": "You are Thinkloom, a focused writing collaborator in an ideation conversation. Respond naturally to the writer's latest message, briefly reflect what is useful, and ask exactly one focused question. Do not force a stock interpretation, fabricate details, or draft prose unless asked.",
|
||||
"userPromptTemplate": "Continue this ideation conversation. Respond directly and naturally to the writer's latest message, then ask exactly one focused question that helps develop their writing. {{challenge_guidance}}\n\nConversation so far:\n{{context}}",
|
||||
"challengeGuidance": {
|
||||
"Gentle": "Be warm and exploratory; help the writer locate a concrete personal example.",
|
||||
"Balanced": "Surface one useful tension or assumption without forcing a predetermined interpretation.",
|
||||
"Rigorous": "Test the claim respectfully; ask for evidence, a distinction, or a counterexample."
|
||||
},
|
||||
"variables": {
|
||||
"challenge_guidance": "Selected from challengeGuidance using the current Gentle, Balanced, or Rigorous setting.",
|
||||
"context": "The ten most recent Writer and Thinkloom conversation turns."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "drafting",
|
||||
"description": "Controls generated passages and editorial previews in Drafting and Finalization.",
|
||||
"effect": "Changes the prose returned when drafting from selected ideas or applying an editorial action to a passage.",
|
||||
"systemPrompt": "You are Thinkloom, a focused writing collaborator. Return only proposed prose; it will be staged for review. Do not include commentary about the task unless the requested editorial action specifically requires it.",
|
||||
"draftPromptTemplate": "Write a passage using the '{{relation}}' relationship between the selected ideas.\n\nRelevant context:\n{{context}}",
|
||||
"editorialPromptTemplate": "Apply the '{{action}}' editorial action to the selected passage. Return only the proposed result.\n\nRelevant context:\n{{context}}",
|
||||
"variables": {
|
||||
"relation": "The relationship selected in Drafting, such as synthesize, compare, contrast, sequence, support with evidence, or separate into sections.",
|
||||
"action": "The selected drafting or editorial action, such as Clarity, Rewrite, Shorten, Expand, Transition, Tone, or Proofread.",
|
||||
"context": "The selected ideas or current passage supplied by Thinkloom."
|
||||
}
|
||||
}
|
||||
+249
-12
@@ -1,22 +1,26 @@
|
||||
use chrono::Utc;
|
||||
use rusqlite::{params, Connection};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::{de::DeserializeOwned, Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs,
|
||||
io::Write,
|
||||
path::{Path, PathBuf},
|
||||
process::Command,
|
||||
sync::Mutex,
|
||||
};
|
||||
use tauri::State;
|
||||
use tauri::{AppHandle, Manager, State};
|
||||
use uuid::Uuid;
|
||||
use walkdir::WalkDir;
|
||||
use zip::{write::SimpleFileOptions, ZipArchive, ZipWriter};
|
||||
|
||||
const SCHEMA_VERSION: &str = "1.0";
|
||||
const APP_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
const CONVERSATION_PROMPT_DEFAULT: &str = include_str!("../prompts/conversation.json");
|
||||
const DRAFTING_PROMPT_DEFAULT: &str = include_str!("../prompts/drafting.json");
|
||||
const PROMPT_GUIDE: &str = include_str!("../../PROMPTS.md");
|
||||
|
||||
#[derive(Default)]
|
||||
struct RuntimeState {
|
||||
@@ -85,6 +89,32 @@ struct ProviderProfile {
|
||||
model: String,
|
||||
mode: String,
|
||||
}
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ConversationPromptConfig {
|
||||
schema_version: u32,
|
||||
system_prompt: String,
|
||||
user_prompt_template: String,
|
||||
challenge_guidance: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct DraftingPromptConfig {
|
||||
schema_version: u32,
|
||||
system_prompt: String,
|
||||
draft_prompt_template: String,
|
||||
editorial_prompt_template: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct PromptConfigInfo {
|
||||
directory: String,
|
||||
files: Vec<String>,
|
||||
reload_behavior: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ConnectionResult {
|
||||
ok: bool,
|
||||
@@ -147,6 +177,194 @@ fn write_json(path: &Path, value: &impl Serialize) -> CommandResult<()> {
|
||||
.map_err(|e| CommandError::new("SERIALIZE_ERROR", e.to_string(), false))?;
|
||||
atomic_write(path, &bytes)
|
||||
}
|
||||
fn prompt_config_dir(app: &AppHandle) -> CommandResult<PathBuf> {
|
||||
app.path()
|
||||
.app_config_dir()
|
||||
.map(|path| path.join("prompts"))
|
||||
.map_err(|error| {
|
||||
CommandError::io(
|
||||
"Could not locate the application configuration folder",
|
||||
error,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn ensure_prompt_files_at(app: &AppHandle) -> CommandResult<PathBuf> {
|
||||
let directory = prompt_config_dir(app)?;
|
||||
fs::create_dir_all(&directory).map_err(|error| {
|
||||
CommandError::io("Could not create the prompt configuration folder", error)
|
||||
})?;
|
||||
for (name, contents) in [
|
||||
("conversation.json", CONVERSATION_PROMPT_DEFAULT),
|
||||
("drafting.json", DRAFTING_PROMPT_DEFAULT),
|
||||
("README.md", PROMPT_GUIDE),
|
||||
] {
|
||||
let path = directory.join(name);
|
||||
if !path.exists() {
|
||||
atomic_write(&path, contents.as_bytes())?;
|
||||
}
|
||||
}
|
||||
Ok(directory)
|
||||
}
|
||||
|
||||
fn load_prompt_config<T: DeserializeOwned>(path: &Path) -> CommandResult<T> {
|
||||
let raw = fs::read_to_string(path).map_err(|error| {
|
||||
CommandError::io(
|
||||
&format!("Could not read prompt configuration {}", path.display()),
|
||||
error,
|
||||
)
|
||||
})?;
|
||||
serde_json::from_str(&raw).map_err(|error| {
|
||||
CommandError::new(
|
||||
"PROMPT_CONFIG_INVALID",
|
||||
format!("Invalid prompt configuration {}: {error}", path.display()),
|
||||
true,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn required_prompt_variable<'a>(
|
||||
variables: &'a HashMap<String, String>,
|
||||
name: &str,
|
||||
) -> CommandResult<&'a str> {
|
||||
variables
|
||||
.get(name)
|
||||
.map(String::as_str)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.ok_or_else(|| {
|
||||
CommandError::new(
|
||||
"PROMPT_VARIABLE_MISSING",
|
||||
format!("The prompt variable '{{{{{name}}}}}' is missing."),
|
||||
true,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn render_prompt_template(
|
||||
template: &str,
|
||||
variables: &HashMap<String, String>,
|
||||
) -> CommandResult<String> {
|
||||
let mut rendered = template.to_owned();
|
||||
for (name, value) in variables {
|
||||
rendered = rendered.replace(&format!("{{{{{name}}}}}"), value);
|
||||
}
|
||||
if let Some(start) = rendered.find("{{") {
|
||||
if let Some(end) = rendered[start + 2..].find("}}") {
|
||||
let name = &rendered[start + 2..start + 2 + end];
|
||||
return Err(CommandError::new(
|
||||
"PROMPT_VARIABLE_MISSING",
|
||||
format!("The prompt template references unknown or unavailable variable '{{{{{name}}}}}'."),
|
||||
true,
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(rendered)
|
||||
}
|
||||
|
||||
fn prompts_for_request(
|
||||
app: &AppHandle,
|
||||
purpose: &str,
|
||||
mut variables: HashMap<String, String>,
|
||||
) -> CommandResult<(String, String)> {
|
||||
let directory = ensure_prompt_files_at(app)?;
|
||||
match purpose {
|
||||
"conversation" => {
|
||||
let path = directory.join("conversation.json");
|
||||
let config: ConversationPromptConfig = load_prompt_config(&path)?;
|
||||
if config.schema_version != 1
|
||||
|| config.system_prompt.trim().is_empty()
|
||||
|| config.user_prompt_template.trim().is_empty()
|
||||
{
|
||||
return Err(CommandError::new(
|
||||
"PROMPT_CONFIG_INVALID",
|
||||
format!(
|
||||
"{} must use schemaVersion 1 and non-empty required prompt fields.",
|
||||
path.display()
|
||||
),
|
||||
true,
|
||||
));
|
||||
}
|
||||
let challenge = required_prompt_variable(&variables, "challenge")?;
|
||||
let guidance = config.challenge_guidance.get(challenge).ok_or_else(|| {
|
||||
CommandError::new(
|
||||
"PROMPT_CONFIG_INVALID",
|
||||
format!(
|
||||
"{} has no challengeGuidance entry for '{challenge}'.",
|
||||
path.display()
|
||||
),
|
||||
true,
|
||||
)
|
||||
})?;
|
||||
variables.insert("challenge_guidance".into(), guidance.clone());
|
||||
Ok((
|
||||
config.system_prompt,
|
||||
render_prompt_template(&config.user_prompt_template, &variables)?,
|
||||
))
|
||||
}
|
||||
"drafting" => {
|
||||
let path = directory.join("drafting.json");
|
||||
let config: DraftingPromptConfig = load_prompt_config(&path)?;
|
||||
if config.schema_version != 1
|
||||
|| config.system_prompt.trim().is_empty()
|
||||
|| config.draft_prompt_template.trim().is_empty()
|
||||
|| config.editorial_prompt_template.trim().is_empty()
|
||||
{
|
||||
return Err(CommandError::new(
|
||||
"PROMPT_CONFIG_INVALID",
|
||||
format!(
|
||||
"{} must use schemaVersion 1 and non-empty required prompt fields.",
|
||||
path.display()
|
||||
),
|
||||
true,
|
||||
));
|
||||
}
|
||||
let template = if required_prompt_variable(&variables, "action")? == "draft" {
|
||||
&config.draft_prompt_template
|
||||
} else {
|
||||
&config.editorial_prompt_template
|
||||
};
|
||||
Ok((
|
||||
config.system_prompt,
|
||||
render_prompt_template(template, &variables)?,
|
||||
))
|
||||
}
|
||||
_ => Err(CommandError::new(
|
||||
"PROMPT_PURPOSE_INVALID",
|
||||
format!("No prompt configuration is registered for '{purpose}'."),
|
||||
false,
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn ensure_prompt_files(app: AppHandle) -> CommandResult<PromptConfigInfo> {
|
||||
let directory = ensure_prompt_files_at(&app)?;
|
||||
Ok(PromptConfigInfo {
|
||||
directory: directory.to_string_lossy().into_owned(),
|
||||
files: vec![
|
||||
"conversation.json".into(),
|
||||
"drafting.json".into(),
|
||||
"README.md".into(),
|
||||
],
|
||||
reload_behavior: "Prompt files reload before every model request.".into(),
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn open_prompt_folder(app: AppHandle) -> CommandResult<String> {
|
||||
let directory = ensure_prompt_files_at(&app)?;
|
||||
#[cfg(target_os = "windows")]
|
||||
let mut command = Command::new("explorer.exe");
|
||||
#[cfg(target_os = "macos")]
|
||||
let mut command = Command::new("open");
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
let mut command = Command::new("xdg-open");
|
||||
command.arg(&directory).spawn().map_err(|error| {
|
||||
CommandError::io("Could not open the prompt configuration folder", error)
|
||||
})?;
|
||||
Ok(directory.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
fn init_db(root: &Path) -> CommandResult<Connection> {
|
||||
let state_dir = root.join(".thinkloom");
|
||||
fs::create_dir_all(&state_dir)
|
||||
@@ -1179,11 +1397,11 @@ fn load_project_state(state: State<RuntimeState>) -> CommandResult<Option<Value>
|
||||
|
||||
#[tauri::command]
|
||||
fn generate_text(
|
||||
app: AppHandle,
|
||||
profile: ProviderProfile,
|
||||
prompt: String,
|
||||
context: String,
|
||||
prompt_variables: HashMap<String, String>,
|
||||
cloud_approved: bool,
|
||||
purpose: Option<String>,
|
||||
purpose: String,
|
||||
) -> CommandResult<String> {
|
||||
if profile.mode == "cloud" && !cloud_approved {
|
||||
return Err(CommandError::new(
|
||||
@@ -1192,19 +1410,15 @@ fn generate_text(
|
||||
true,
|
||||
));
|
||||
}
|
||||
let (system, prompt) = prompts_for_request(&app, &purpose, prompt_variables)?;
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(90))
|
||||
.build()
|
||||
.map_err(|e| CommandError::new("PROVIDER_ERROR", e.to_string(), true))?;
|
||||
let system = if purpose.as_deref() == Some("conversation") {
|
||||
"You are Thinkloom, a focused writing collaborator in an ideation conversation. Respond naturally to the writer's latest message, briefly reflect what is useful, and ask exactly one focused question. Do not force a stock interpretation, fabricate details, or draft prose unless asked."
|
||||
} else {
|
||||
"You are Thinkloom, a focused writing collaborator. Return only proposed prose; it will be staged for review."
|
||||
};
|
||||
let (url, body) = if profile.kind == "ollama" {
|
||||
(
|
||||
format!("{}/api/chat", profile.endpoint.trim_end_matches('/')),
|
||||
json!({"model":profile.model,"stream":false,"messages":[{"role":"system","content":system},{"role":"user","content":format!("{prompt}\n\nRelevant context:\n{context}")}]}),
|
||||
json!({"model":profile.model,"stream":false,"messages":[{"role":"system","content":system},{"role":"user","content":prompt}]}),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
@@ -1212,7 +1426,7 @@ fn generate_text(
|
||||
"{}/chat/completions",
|
||||
profile.endpoint.trim_end_matches('/')
|
||||
),
|
||||
json!({"model":profile.model,"stream":false,"messages":[{"role":"system","content":system},{"role":"user","content":format!("{prompt}\n\nRelevant context:\n{context}")}]}),
|
||||
json!({"model":profile.model,"stream":false,"messages":[{"role":"system","content":system},{"role":"user","content":prompt}]}),
|
||||
)
|
||||
};
|
||||
let mut request = client.post(url).json(&body);
|
||||
@@ -1271,6 +1485,8 @@ pub fn run() {
|
||||
store_provider_secret,
|
||||
delete_provider_secret,
|
||||
test_provider,
|
||||
ensure_prompt_files,
|
||||
open_prompt_folder,
|
||||
generate_text,
|
||||
export_project,
|
||||
create_backup,
|
||||
@@ -1286,6 +1502,27 @@ pub fn run() {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bundled_prompt_configs_parse_and_templates_validate() {
|
||||
let conversation: ConversationPromptConfig =
|
||||
serde_json::from_str(CONVERSATION_PROMPT_DEFAULT).unwrap();
|
||||
let drafting: DraftingPromptConfig = serde_json::from_str(DRAFTING_PROMPT_DEFAULT).unwrap();
|
||||
assert_eq!(conversation.schema_version, 1);
|
||||
assert_eq!(drafting.schema_version, 1);
|
||||
|
||||
let mut variables = HashMap::new();
|
||||
variables.insert("context".into(), "A current thought".into());
|
||||
variables.insert("challenge_guidance".into(), "Ask one question.".into());
|
||||
let rendered =
|
||||
render_prompt_template(&conversation.user_prompt_template, &variables).unwrap();
|
||||
assert!(rendered.contains("A current thought"));
|
||||
|
||||
variables.remove("context");
|
||||
let error = render_prompt_template(&conversation.user_prompt_template, &variables)
|
||||
.expect_err("an unresolved placeholder must fail");
|
||||
assert_eq!(error.code, "PROMPT_VARIABLE_MISSING");
|
||||
}
|
||||
#[test]
|
||||
fn pdf_is_structurally_complete() {
|
||||
let bytes = pdf_bytes("Title", "A short manuscript.");
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Thinkloom",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"identifier": "com.thinkloom.desktop",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
+30
-13
@@ -88,9 +88,10 @@ const StructuredEditor = forwardRef<StructuredEditorHandle, { value: string; onC
|
||||
return <div className="manuscript-editor structured-editor"><div className="format-toolbar" aria-label="Text formatting"><button type="button" onClick={() => instance.chain().focus().toggleBold().run()} aria-pressed={instance.isActive("bold")}><strong>B</strong></button><button type="button" onClick={() => instance.chain().focus().toggleItalic().run()} aria-pressed={instance.isActive("italic")}><em>I</em></button><button type="button" onClick={() => instance.chain().focus().toggleHeading({ level: 2 }).run()} aria-pressed={instance.isActive("heading", { level: 2 })}>H2</button><button type="button" onClick={() => instance.chain().focus().toggleBulletList().run()} aria-pressed={instance.isActive("bulletList")}>List</button><button type="button" onClick={() => instance.chain().focus().undo().run()} disabled={!instance.can().undo()}>Undo</button><button type="button" onClick={() => instance.chain().focus().redo().run()} disabled={!instance.can().redo()}>Redo</button></div><EditorContent editor={instance} /></div>;
|
||||
});
|
||||
export default function Thinkloom() {
|
||||
const [project, setProject] = useState<State>(initial); const [hydrated, setHydrated] = useState(false); const [view, setView] = useState<View>("ideation"); const [message, setMessage] = useState(""); const [notice, setNotice] = useState("Project ready. History is being recorded."); const [busy, setBusy] = useState(false); const [listening, setListening] = useState(false); const [showRejected, setShowRejected] = useState(false); const [editingIdea, setEditingIdea] = useState<string | null>(null); const [newHabit, setNewHabit] = useState(""); const [sanitized, setSanitized] = useState(true); const [credential, setCredential] = useState(""); const [projectPath, setProjectPath] = useState(""); const editor = useRef<StructuredEditorHandle>(null); const finalEditor = useRef<HTMLTextAreaElement>(null); const conversationEnd = useRef<HTMLDivElement>(null);
|
||||
const [project, setProject] = useState<State>(initial); const [hydrated, setHydrated] = useState(false); const [view, setView] = useState<View>("ideation"); const [message, setMessage] = useState(""); const [notice, setNotice] = useState("Project ready. History is being recorded."); const [busy, setBusy] = useState(false); const [listening, setListening] = useState(false); const [showRejected, setShowRejected] = useState(false); const [editingIdea, setEditingIdea] = useState<string | null>(null); const [newHabit, setNewHabit] = useState(""); const [sanitized, setSanitized] = useState(true); const [credential, setCredential] = useState(""); const [projectPath, setProjectPath] = useState(""); const [promptPath, setPromptPath] = useState(""); const editor = useRef<StructuredEditorHandle>(null); const finalEditor = useRef<HTMLTextAreaElement>(null); const conversationEnd = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => { queueMicrotask(() => { try { const raw = localStorage.getItem("thinkloom-project-v1"); if (raw) setProject({ ...initial, ...JSON.parse(raw) as State }); } catch { /* Ignore invalid local recovery state. */ } setHydrated(true); }); }, []);
|
||||
useEffect(() => { if (hydrated) localStorage.setItem("thinkloom-project-v1", JSON.stringify(project)); }, [project, hydrated]);
|
||||
useEffect(() => { void invokeNative<{ directory: string }>("ensure_prompt_files").then((info) => { if (info?.directory) setPromptPath(info.directory); }).catch((error) => setNotice(`Prompt configuration could not be prepared: ${nativeError(error)}`)); }, []);
|
||||
const mutate = useCallback((type: string, summary: string, update: (current: State) => State, actor: Actor = "user") => { setProject((current) => { const previousHash = current.events.at(-1)?.hash ?? null; const event: Event = { id: uid("event"), type, actor, at: now(), summary, previousHash, hash: hash(`${previousHash}|${type}|${summary}|${Date.now()}`), provider: actor === "assistant" ? current.provider.name : undefined }; const next = { ...update(current), events: [...current.events, event], updatedAt: event.at }; void invokeNative("persist_state", { appState: next }).catch(() => undefined); return next; }); setNotice(summary); }, []);
|
||||
const navigate = (next: View) => { setView(next); if (["ideation", "drafting", "finalization"].includes(next) && project.phase !== next) mutate("PHASE_CHANGED", `Moved to ${next}`, (current) => ({ ...current, phase: next as Phase })); };
|
||||
useEffect(() => { const shortcut = (e: KeyboardEvent) => { if (!e.altKey) return; if (e.key === "1") document.getElementById("ideas-panel")?.focus(); if (e.key === "2") editor.current?.focus(); if (e.key === "3") document.getElementById("assistant-panel")?.focus(); }; window.addEventListener("keydown", shortcut); return () => window.removeEventListener("keydown", shortcut); }, []);
|
||||
@@ -102,11 +103,9 @@ export default function Thinkloom() {
|
||||
const userTurn: Turn = { id: uid("turn"), speaker: "user", text, createdAt: now() };
|
||||
mutate("USER_TEXT_TURN_CREATED", "Saved your conversation turn", (current) => ({ ...current, turns: [...current.turns, userTurn] }));
|
||||
const conversation = [...project.turns, userTurn].slice(-10).map((turn) => `${turn.speaker === "user" ? "Writer" : "Thinkloom"}: ${turn.text}`).join("\n");
|
||||
const challengeGuidance = project.challenge === "Gentle" ? "Be warm and exploratory; help the writer locate a concrete personal example." : project.challenge === "Rigorous" ? "Test the claim respectfully; ask for evidence, a distinction, or a counterexample." : "Surface one useful tension or assumption without forcing a predetermined interpretation.";
|
||||
const prompt = `Continue this ideation conversation. Respond directly and naturally to the writer's latest message, then ask exactly one focused question that helps develop their writing. ${challengeGuidance}`;
|
||||
let reply: string;
|
||||
try {
|
||||
const generated = await invokeNative<string>("generate_text", { profile: project.provider, prompt, context: conversation, cloudApproved: project.cloudApproved, purpose: "conversation" });
|
||||
const generated = await invokeNative<string>("generate_text", { profile: project.provider, promptVariables: { challenge: project.challenge, context: conversation }, cloudApproved: project.cloudApproved, purpose: "conversation" });
|
||||
if (!generated?.trim()) throw new Error("The provider returned an empty response.");
|
||||
reply = generated.trim();
|
||||
setProject((current) => ({ ...current, provider: { ...current.provider, connected: true } }));
|
||||
@@ -128,14 +127,30 @@ export default function Thinkloom() {
|
||||
const mergeSelected = () => { if (selectedIdeas.length < 2) { setNotice("Select at least two ideas to merge."); return; } const merged: Idea = { id: uid("idea"), title: selectedIdeas.map((idea) => idea.title).join(" + ").slice(0, 70), summary: selectedIdeas.map((idea) => idea.summary).join(" "), detail: "A synthesis retaining both parent ideas.", status: "accepted", sourceTurnIds: [...new Set(selectedIdeas.flatMap((idea) => idea.sourceTurnIds))], parentIdeaIds: selectedIdeas.map((idea) => idea.id), tags: [...new Set(selectedIdeas.flatMap((idea) => idea.tags))], pinned: true, selected: true, createdBy: "mixed" }; mutate("IDEAS_MERGED", `Merged ${selectedIdeas.length} ideas`, (current) => ({ ...current, ideas: [...current.ideas.map((idea) => selectedIdeas.some((selected) => selected.id === idea.id) ? { ...idea, selected: false } : idea), merged] })); };
|
||||
|
||||
const startGeneration = async (action = "draft") => {
|
||||
if (busy) return; if (action === "draft" && !selectedIdeas.length) { setNotice("Select at least one accepted idea first."); return; } setBusy(true); const id = uid("generation"); const source = selectedIdeas.map((idea) => idea.summary).join(" ") || "the current passage"; const prompt = action === "draft" ? `Draft a passage that ${project.generation.relation}s the selected ideas.` : `${action} the selected passage.`;
|
||||
mutate("GENERATION_REQUESTED", `Started ${action.toLowerCase()} preview`, (current) => ({ ...current, generation: { ...current.generation, id, state: "streaming", text: "", prompt } })); await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
let nativeText: string | null = null;
|
||||
try { nativeText = await invokeNative<string>("generate_text", { profile: project.provider, prompt, context: source, cloudApproved: project.cloudApproved, purpose: "draft" }); }
|
||||
catch (error) { mutate("GENERATION_FAILED", "Provider request failed; your request is preserved for retry", (current) => ({ ...current, generation: { ...current.generation, id, state: "failed", text: "", prompt } }), "system"); setNotice(`Provider request failed: ${nativeError(error)}`); setBusy(false); return; }
|
||||
const variants: Record<string, string> = { draft: `The language of personal discipline obscures a public design problem. ${source} A community’s capacity for sustained thought depends on the expectations it builds into ordinary places: whether a classroom protects silence long enough for uncertainty, whether a meeting rewards listening before reaction, and whether public argument leaves room for ideas that cannot arrive as slogans.`, Clarity: "Attention is personal in experience but public in consequence. Institutions shape whether people can sustain it together.", Rewrite: "We guard attention as private property, yet its most important work happens between us—in classrooms, meetings, and the slow exchange of public argument.", Shorten: "Attention feels private, but its loss reshapes public life.", Expand: "Attention feels private, but its loss reshapes public life. The change appears first as friction: a classroom that cannot settle, a meeting that repeats itself, a debate that rewards instant response. Over time, those moments become an institutional condition.", Transition: "That is why the question must move beyond individual habit and toward the environments we share.", Repetition: "Consider trimming repeated uses of “private,” “shared,” and “attention” in the opening two paragraphs.", Consistency: "The draft consistently frames attention as infrastructure; keep the institutional examples parallel to maintain that logic.", Tone: "A measured version can make the claim firmly without blaming readers for systems they did not design.", "User voice": "In your reflective voice: the room matters because attention is never brought into it alone; it is invited, protected, or spent there.", Proofread: "The selected passage is mechanically clean. Consider an em dash instead of the current parenthetical pause." };
|
||||
mutate("GENERATION_COMPLETED", "Preview ready — your manuscript is unchanged", (current) => ({ ...current, generation: { ...current.generation, id, state: "staged", text: nativeText ?? variants[action] ?? variants.draft, prompt } }), "assistant"); setBusy(false);
|
||||
if (busy) return;
|
||||
if (action === "draft" && !selectedIdeas.length) { setNotice("Select at least one accepted idea first."); return; }
|
||||
setBusy(true);
|
||||
const id = uid("generation");
|
||||
const source = selectedIdeas.map((idea) => idea.summary).join(" ") || "the current passage";
|
||||
const prompt = action;
|
||||
mutate("GENERATION_REQUESTED", `Started ${action.toLowerCase()} preview`, (current) => ({ ...current, generation: { ...current.generation, id, state: "streaming", text: "", prompt } }));
|
||||
try {
|
||||
const nativeText = await invokeNative<string>("generate_text", {
|
||||
profile: project.provider,
|
||||
promptVariables: { action, relation: project.generation.relation, context: source },
|
||||
cloudApproved: project.cloudApproved,
|
||||
purpose: "drafting",
|
||||
});
|
||||
if (!nativeText?.trim()) throw new Error("The provider returned an empty response.");
|
||||
mutate("GENERATION_COMPLETED", "Preview ready — your manuscript is unchanged", (current) => ({ ...current, generation: { ...current.generation, id, state: "staged", text: nativeText.trim(), prompt } }), "assistant");
|
||||
} catch (error) {
|
||||
mutate("GENERATION_FAILED", "Provider request failed; your request is preserved for retry", (current) => ({ ...current, generation: { ...current.generation, id, state: "failed", text: "", prompt } }), "system");
|
||||
setNotice(`Provider request failed: ${nativeError(error)}`);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const acceptGeneration = (mode: "cursor" | "replace" | "append" | "section", partial = false) => { const full = project.generation.text; if (!full) return; const accepted = partial ? full.split(/(?<=[.!?])\s+/).slice(0, 2).join(" ") : full; let manuscript = project.manuscript; if ((mode === "replace" || mode === "cursor") && view === "drafting") { manuscript = editor.current?.insert(accepted, mode === "replace") ?? manuscript; } else if (mode === "replace") { const start = finalEditor.current?.selectionStart ?? manuscript.length; const end = finalEditor.current?.selectionEnd ?? start; manuscript = `${manuscript.slice(0, start)}${accepted}${manuscript.slice(end)}`; } else if (mode === "section") manuscript = `${manuscript.trimEnd()}\n\n## New section\n\n${accepted}\n`; else manuscript = `${manuscript.trimEnd()}\n\n${accepted}\n`; mutate(partial ? "GENERATION_PARTIALLY_ACCEPTED" : "GENERATION_ACCEPTED", `${partial ? "Partially accepted" : "Accepted"} generated text`, (current) => ({ ...current, manuscript, generation: { ...current.generation, state: "accepted" } })); };
|
||||
const discardGeneration = () => mutate("GENERATION_REJECTED", "Discarded generated preview", (current) => ({ ...current, generation: { ...current.generation, state: "rejected", text: "" } }));
|
||||
const saveCheckpoint = (name = `Version ${project.checkpoints.length + 1}`) => { const point: Checkpoint = { id: uid("version"), name, at: now(), manuscript: project.manuscript, words: countWords(project.manuscript) }; mutate("CHECKPOINT_CREATED", `Saved version “${name}”`, (current) => ({ ...current, checkpoints: [...current.checkpoints, point] })); void invokeNative("create_checkpoint", { name }); };
|
||||
@@ -162,6 +177,7 @@ export default function Thinkloom() {
|
||||
};
|
||||
const createNativeProject = async () => { try { const folder = await invokeNative<string>("choose_project_folder"); if (!folder) { setNotice("Choose a folder when you are ready to create the desktop project."); return; } const result = await invokeNative<{ path: string }>("create_project", { path: folder, title: project.title }); if (!result) { setNotice("Project folders are available in the installed desktop app."); return; } setProjectPath(result.path); await invokeNative("persist_state", { appState: project }); setNotice("Self-contained project created and ready."); } catch (error) { setNotice(`Project could not be created: ${String(error)}`); } };
|
||||
const openNativeProject = async () => { try { const folder = await invokeNative<string>("choose_project_folder"); if (!folder) return; const result = await invokeNative<{ path: string }>("open_project", { path: folder }); if (!result) { setNotice("Opening project folders is available in the installed desktop app."); return; } setProjectPath(result.path); const restored = await invokeNative<State>("load_project_state"); if (restored) setProject(restored); setNotice("Project reopened and its history verified."); } catch (error) { setNotice(`Project could not be opened: ${String(error)}`); } };
|
||||
const openPromptFolder = async () => { try { const path = await invokeNative<string>("open_prompt_folder"); if (path) { setPromptPath(path); setNotice("Prompt configuration folder opened."); } } catch (error) { setNotice(`Prompt configuration folder could not be opened: ${nativeError(error)}`); } };
|
||||
|
||||
if (!hydrated) return <main className="loading"><div className="brand-mark">T</div><p>Gathering your threads…</p></main>;
|
||||
return <main className="app-shell">
|
||||
@@ -200,9 +216,10 @@ export default function Thinkloom() {
|
||||
{view === "settings" && <section className="page-layout"><div className="page-heading"><span className="eyebrow">Private by design</span><h1>Settings</h1><p>Choose where thinking happens and how Thinkloom supports the work.</p></div><div className="settings-grid">
|
||||
<section className="settings-section panel"><div className="settings-heading"><span className="settings-number">01</span><div><h2>Model provider</h2><p>Your active writing assistant.</p></div></div><label>Provider<select value={project.provider.kind} onChange={(event) => { const kind = event.target.value as Provider["kind"]; const profile: Provider = kind === "ollama" ? { kind, name: "Ollama", endpoint: "http://127.0.0.1:11434", model: "llama3.2", mode: "local", connected: false } : kind === "openai" ? { kind, name: "OpenAI", endpoint: "https://api.openai.com/v1", model: "gpt-4.1-mini", mode: "cloud", connected: false } : { kind, name: "Compatible endpoint", endpoint: "http://127.0.0.1:1234/v1", model: "local-model", mode: "local", connected: false }; mutate("PROVIDER_CHANGED", `Changed provider to ${profile.name}`, (current) => ({ ...current, provider: profile, privacy: profile.mode === "local" ? "Local" : "Cloud" })); }}><option value="ollama">Ollama · Local</option><option value="openai">OpenAI · Cloud</option><option value="compatible">OpenAI-compatible</option></select></label><label>Endpoint<input value={project.provider.endpoint} onChange={(event) => setProject((current) => ({ ...current, provider: { ...current.provider, endpoint: event.target.value } }))} /></label><label>Model<input value={project.provider.model} onChange={(event) => setProject((current) => ({ ...current, provider: { ...current.provider, model: event.target.value } }))} /></label>{project.provider.kind !== "ollama" && <label>Credential<input type="password" autoComplete="new-password" value={credential} onChange={(event) => setCredential(event.target.value)} placeholder="Stored only in your system vault" /><button className="secondary-button" type="button" onClick={() => void invokeNative("store_provider_secret", { profileId: project.provider.kind, secret: credential }).then(() => { setCredential(""); setNotice("Credential saved in the operating-system vault."); }).catch((error) => setNotice(`Credential could not be saved: ${String(error)}`))}>Save securely</button></label>}{project.provider.mode === "cloud" && !project.cloudApproved && <div className="cloud-warning"><strong>Cloud approval required</strong><p>Your first cloud request sends only relevant context shown in preview.</p><button onClick={() => mutate("CLOUD_PROCESSING_APPROVED", "Approved cloud processing for this project", (current) => ({ ...current, cloudApproved: true }))}>Approve for this project</button></div>}<button className="secondary-button" onClick={() => void invokeNative<{ ok: boolean; message: string }>("test_provider", { profile: project.provider }).then((result) => { setProject((current) => ({ ...current, provider: { ...current.provider, connected: Boolean(result?.ok) } })); setNotice(result?.message ?? "Provider testing is available in the desktop app."); })}>Test connection</button></section>
|
||||
<section className="settings-section panel"><div className="settings-heading"><span className="settings-number">02</span><div><h2>Voice & listening</h2><p>Audio is processed in memory and never retained.</p></div></div><label className="toggle-row"><input type="checkbox" checked={project.spokenReplies} onChange={(event) => setProject((current) => ({ ...current, spokenReplies: event.target.checked }))} /><span><strong>Spoken assistant replies</strong><small>Always accompanied by visible text. Off by default.</small></span></label><button className="secondary-button" onClick={startVoice}>Test microphone</button></section>
|
||||
<section className="settings-section panel"><div className="settings-heading"><span className="settings-number">03</span><div><h2>Your writing voice</h2><p>Inspectable traits guide suggestions without impersonating you.</p></div></div><div className="trait-list">{project.styleTraits.map((trait, index) => <label key={`${index}-${trait}`}><span className="confidence">Developing</span><input value={trait} onChange={(event) => setProject((current) => ({ ...current, styleTraits: current.styleTraits.map((item, itemIndex) => itemIndex === index ? event.target.value : item) }))} /></label>)}</div><label>Habit to avoid<div className="inline-input"><input value={newHabit} onChange={(event) => setNewHabit(event.target.value)} placeholder="Add a pattern to avoid" /><button onClick={() => { if (!newHabit.trim()) return; mutate("STYLE_PROFILE_UPDATED", "Updated writing voice profile", (current) => ({ ...current, disallowedHabits: [...current.disallowedHabits, newHabit.trim()] })); setNewHabit(""); }}>Add</button></div></label><div className="tag-row">{project.disallowedHabits.map((habit) => <span key={habit}>{habit}</span>)}</div></section>
|
||||
<section className="settings-section panel wide"><div className="settings-heading"><span className="settings-number">03</span><div><h2>Your writing voice</h2><p>Inspectable traits guide suggestions without impersonating you.</p></div></div><div className="trait-list">{project.styleTraits.map((trait, index) => <label key={`${index}-${trait}`}><span className="confidence">Developing</span><input value={trait} onChange={(event) => setProject((current) => ({ ...current, styleTraits: current.styleTraits.map((item, itemIndex) => itemIndex === index ? event.target.value : item) }))} /></label>)}</div><label>Habit to avoid<div className="inline-input"><input value={newHabit} onChange={(event) => setNewHabit(event.target.value)} placeholder="Add a pattern to avoid" /><button onClick={() => { if (!newHabit.trim()) return; mutate("STYLE_PROFILE_UPDATED", "Updated writing voice profile", (current) => ({ ...current, disallowedHabits: [...current.disallowedHabits, newHabit.trim()] })); setNewHabit(""); }}>Add</button></div></label><div className="tag-row">{project.disallowedHabits.map((habit) => <span key={habit}>{habit}</span>)}</div></section>
|
||||
<section className="settings-section panel wide prompt-settings"><div className="settings-heading"><span className="settings-number">04</span><div><h2>Prompt configuration</h2><p>Technical users can tune every instruction sent to the model.</p></div></div><p><strong>conversation.json</strong> affects Ideation replies. <strong>drafting.json</strong> affects passage and editorial previews. Files reload before every model request, so no restart is needed.</p><code className="prompt-path">{promptPath || "Preparing prompt files…"}</code><div className="project-actions"><button className="secondary-button" onClick={() => void openPromptFolder()}>Open prompt folder</button></div><small>README.md in this folder documents every field, variable, effect, validation rule, and reset procedure.</small></section>
|
||||
</div></section>}
|
||||
</section>
|
||||
<footer className="app-footer"><span>Thinkloom 0.1.0</span><span>Local-first · audio retention always off</span><span>{project.events.at(-1)?.hash ?? "No history"}</span></footer>
|
||||
<footer className="app-footer"><span>Thinkloom 0.2.0</span><span>Local-first · audio retention always off</span><span>{project.events.at(-1)?.hash ?? "No history"}</span></footer>
|
||||
</main>;
|
||||
}
|
||||
|
||||
+2
-2
File diff suppressed because one or more lines are too long
@@ -93,3 +93,48 @@ test("never ships retained audio assets", async () => {
|
||||
const files = await walk(root);
|
||||
assert.equal(files.some((name) => /\.(wav|mp3|m4a|ogg|webm)$/i.test(name)), false);
|
||||
});
|
||||
|
||||
test("externalizes and documents every model prompt", async () => {
|
||||
const [conversationRaw, draftingRaw, source, rust, guide, packageRaw, tauriRaw, cargoRaw] = await Promise.all([
|
||||
readFile(new URL("../src-tauri/prompts/conversation.json", import.meta.url), "utf8"),
|
||||
readFile(new URL("../src-tauri/prompts/drafting.json", import.meta.url), "utf8"),
|
||||
readFile(new URL("../src/Thinkloom.tsx", import.meta.url), "utf8"),
|
||||
readFile(new URL("../src-tauri/src/lib.rs", import.meta.url), "utf8"),
|
||||
readFile(new URL("../PROMPTS.md", import.meta.url), "utf8"),
|
||||
readFile(new URL("../package.json", import.meta.url), "utf8"),
|
||||
readFile(new URL("../src-tauri/tauri.conf.json", import.meta.url), "utf8"),
|
||||
readFile(new URL("../src-tauri/Cargo.toml", import.meta.url), "utf8"),
|
||||
]);
|
||||
const conversation = JSON.parse(conversationRaw);
|
||||
const drafting = JSON.parse(draftingRaw);
|
||||
|
||||
assert.equal(conversation.schemaVersion, 1);
|
||||
assert.match(conversation.systemPrompt, /Thinkloom/i);
|
||||
assert.match(conversation.userPromptTemplate, /\{\{challenge_guidance\}\}/);
|
||||
assert.match(conversation.userPromptTemplate, /\{\{context\}\}/);
|
||||
assert.deepEqual(Object.keys(conversation.challengeGuidance).sort(), ["Balanced", "Gentle", "Rigorous"]);
|
||||
assert.equal(drafting.schemaVersion, 1);
|
||||
assert.match(drafting.draftPromptTemplate, /\{\{relation\}\}/);
|
||||
assert.match(drafting.editorialPromptTemplate, /\{\{action\}\}/);
|
||||
assert.match(drafting.draftPromptTemplate, /\{\{context\}\}/);
|
||||
assert.match(drafting.editorialPromptTemplate, /\{\{context\}\}/);
|
||||
|
||||
assert.match(source, /promptVariables/);
|
||||
assert.match(source, /ensure_prompt_files/);
|
||||
assert.match(source, /Open prompt folder/);
|
||||
assert.doesNotMatch(source, /const challengeGuidance|const variants: Record/);
|
||||
assert.match(rust, /include_str!\("\.\.\/prompts\/conversation\.json"\)/);
|
||||
assert.match(rust, /include_str!\("\.\.\/prompts\/drafting\.json"\)/);
|
||||
assert.match(rust, /Prompt files reload before every model request/);
|
||||
assert.doesNotMatch(rust, /You are Thinkloom, a focused writing collaborator in an ideation conversation/);
|
||||
for (const phrase of ["Files and effects", "Editing safely", "Resetting a prompt", "Privacy and security"]) {
|
||||
assert.match(guide, new RegExp(phrase, "i"));
|
||||
}
|
||||
|
||||
const version = JSON.parse(packageRaw).version;
|
||||
assert.equal(version, "0.2.0");
|
||||
assert.equal(JSON.parse(tauriRaw).version, version);
|
||||
const escapedVersion = version.replaceAll(".", "\\.");
|
||||
assert.match(cargoRaw, new RegExp("^version = \"" + escapedVersion + "\"$", "m"));
|
||||
assert.match(source, new RegExp("Thinkloom " + escapedVersion));
|
||||
});
|
||||
Reference in New Issue
Block a user