samagotchi 0.26.0 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +55 -1
- data/docs/architecture.md +6 -0
- data/docs/cli.md +6 -3
- data/docs/configuration.md +17 -3
- data/docs/hooks.md +1 -1
- data/docs/internals/prompt-caching.md +38 -7
- data/docs/sessions.md +1 -1
- data/lib/samagotchi/bridge.rb +37 -1
- data/lib/samagotchi/bridge_client.rb +9 -0
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/plugin.rb +2 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/config.rb +8 -0
- data/lib/samagotchi/desktop/macos/App.swift +6 -4
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +5 -3
- data/lib/samagotchi/desktop/macos/Panel.swift +30 -10
- data/lib/samagotchi/desktop/macos.rb +5 -1
- data/lib/samagotchi/engine.rb +1 -1
- data/lib/samagotchi/llm/openai_chat.rb +40 -4
- data/lib/samagotchi/llm/prompt_cache.rb +31 -5
- data/lib/samagotchi/log.rb +19 -3
- data/lib/samagotchi/session.rb +9 -0
- data/lib/samagotchi/session_manager.rb +4 -3
- data/lib/samagotchi/sessions_command.rb +26 -13
- data/lib/samagotchi/terminal_ui/repl_input.rb +13 -0
- data/lib/samagotchi/terminal_ui.rb +5 -0
- data/lib/samagotchi/thinking_tails.rb +1 -1
- data/lib/samagotchi/tool_activity.rb +3 -2
- data/lib/samagotchi/tool_view.rb +14 -3
- data/lib/samagotchi/tools/task_get.rb +3 -2
- data/lib/samagotchi/tools/task_runtime.rb +49 -12
- data/lib/samagotchi/tools/task_stop.rb +1 -1
- data/lib/samagotchi/tools/task_wait.rb +22 -10
- data/lib/samagotchi/turn_note.rb +4 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_context.rb +17 -4
- data/lib/samagotchi/web/app.rb +25 -0
- data/lib/samagotchi/web/markdown_renderer.rb +21 -6
- data/lib/samagotchi/web/public/app.js +4 -0
- data/lib/samagotchi/web/public/data.js +11 -0
- data/lib/samagotchi/web/public/index.html +19 -0
- data/lib/samagotchi/web/public/stage_model.js +8 -2
- data/lib/samagotchi/web/public/stage_view.js +14 -2
- data/lib/samagotchi/web/public/task_stop.js +124 -0
- data/lib/samagotchi/web/public/turn_view.js +24 -1
- metadata +2 -1
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -139,6 +139,7 @@ module Samagotchi
|
|
|
139
139
|
["GET", %r{\A/api/sessions/([^/]+)/stream\z}, :handle_stream],
|
|
140
140
|
["GET", %r{\A/api/sessions/([^/]+)/output\z}, :handle_output],
|
|
141
141
|
["POST", %r{\A/api/sessions/([^/]+)/cancel\z}, :handle_cancel],
|
|
142
|
+
["POST", %r{\A/api/sessions/([^/]+)/tasks/([^/]+)/stop\z}, :handle_task_stop],
|
|
142
143
|
["POST", %r{\A/api/sessions/([^/]+)/stop\z}, :handle_stop],
|
|
143
144
|
["POST", %r{\A/api/sessions/([^/]+)/restart\z}, :handle_restart],
|
|
144
145
|
["POST", %r{\A/api/sessions/([^/]+)/archive\z}, :handle_archive],
|
|
@@ -1019,6 +1020,30 @@ module Samagotchi
|
|
|
1019
1020
|
error_response(404, "not_found", e.message)
|
|
1020
1021
|
end
|
|
1021
1022
|
|
|
1023
|
+
# A task id as TaskRuntime.generate_task_id makes it.
|
|
1024
|
+
TASK_ID = /\A\d{14}-\h{8}\z/
|
|
1025
|
+
|
|
1026
|
+
# The stop-task button: the worker stops the task as the user
|
|
1027
|
+
# (Bridge#handle_task_stop) and its task_wait returns. Its own 404
|
|
1028
|
+
# (not this conversation's task) and 409 (already ended) pass through;
|
|
1029
|
+
# a 404 from a worker without the route is the stale worker's 501.
|
|
1030
|
+
def handle_task_stop(_req, id, task_id)
|
|
1031
|
+
return error_response(400, "invalid_task_id", "not a task id: #{task_id.to_s[0, 80]}") unless TASK_ID.match?(task_id)
|
|
1032
|
+
|
|
1033
|
+
@session_class.load(id, state_dir: default_state_dir)
|
|
1034
|
+
request = ->(client) { client.stop_task(task_id) }
|
|
1035
|
+
relay(id, bridge_client(id), request, what: "the task was not stopped", cant: "stop tasks") do |reply|
|
|
1036
|
+
body = reply.json || {}
|
|
1037
|
+
case reply.status
|
|
1038
|
+
when 200 then json_response(200, { status: body["status"], stop_reason: body["stop_reason"], task_id: task_id, session_id: id })
|
|
1039
|
+
when 404 then (error_response(404, "task_not_found", "no task #{task_id} in this conversation") if body["error"] == "task_not_found")
|
|
1040
|
+
when 409 then json_response(409, { error: "not_running", status: body["status"], task_id: task_id, session_id: id })
|
|
1041
|
+
end
|
|
1042
|
+
end
|
|
1043
|
+
rescue ArgumentError => e
|
|
1044
|
+
error_response(404, "not_found", e.message)
|
|
1045
|
+
end
|
|
1046
|
+
|
|
1022
1047
|
def handle_stop(_req, id)
|
|
1023
1048
|
# Bounded, so a Rack thread isn't held long; a worker still exiting
|
|
1024
1049
|
# after it just means an immediate resume finds it (rare).
|
|
@@ -8,7 +8,7 @@ module Samagotchi
|
|
|
8
8
|
class MarkdownRenderer
|
|
9
9
|
WARNING = "Markdown rendering is enabled, but the optional commonmarker gem is not installed. Install it with: gem install commonmarker"
|
|
10
10
|
# NOTE: `span` is allowed because the syntax highlighter (commonmarker/syntect)
|
|
11
|
-
# emits
|
|
11
|
+
# emits classed <span>s; keeping it lets highlighting render.
|
|
12
12
|
ALLOWED_ELEMENTS = %w[a blockquote br code del em h1 h2 h3 h4 h5 h6 hr li ol p pre s strong span table tbody td th thead tr ul].freeze
|
|
13
13
|
DROP_ELEMENTS = %w[iframe object script style svg].freeze
|
|
14
14
|
ALLOWED_PROTOCOLS = %w[http https mailto].freeze
|
|
@@ -35,6 +35,11 @@ module Samagotchi
|
|
|
35
35
|
html = Commonmarker.to_html(markdown.to_s, options: {
|
|
36
36
|
render: { escape: true }, # escape raw HTML from source; highlighter output is still emitted
|
|
37
37
|
extension: { header_ids: nil }
|
|
38
|
+
}, plugins: {
|
|
39
|
+
# An empty theme makes syntect emit scope classes (`keyword`, `string`, ...)
|
|
40
|
+
# instead of inline colours from one fixed theme, so the page's light and
|
|
41
|
+
# dark palettes colour code blocks (the --code-* tokens in index.html).
|
|
42
|
+
syntax_highlighter: { theme: "" }
|
|
38
43
|
})
|
|
39
44
|
return nil unless html
|
|
40
45
|
|
|
@@ -74,6 +79,7 @@ module Samagotchi
|
|
|
74
79
|
|
|
75
80
|
node.remove_attribute(attribute.name)
|
|
76
81
|
end
|
|
82
|
+
prefix_highlight_classes(node) if node.name == "span"
|
|
77
83
|
return unless node.name == "a"
|
|
78
84
|
|
|
79
85
|
href = node["href"].to_s.strip
|
|
@@ -85,16 +91,25 @@ module Samagotchi
|
|
|
85
91
|
node["rel"] = "noopener noreferrer"
|
|
86
92
|
end
|
|
87
93
|
|
|
88
|
-
# The syntax highlighter emits
|
|
89
|
-
#
|
|
90
|
-
# markdown source is already escaped by commonmarker
|
|
91
|
-
# attributes are safe to keep on these elements only.
|
|
94
|
+
# The syntax highlighter emits `class=` on <pre> ("syntax-highlighting") and
|
|
95
|
+
# <span> (syntect scope names). They come from the highlighter, not user input:
|
|
96
|
+
# raw HTML from the markdown source is already escaped by commonmarker
|
|
97
|
+
# (escape: true), so class attributes are safe to keep on these elements only.
|
|
92
98
|
def allowed_attribute?(node, name)
|
|
93
|
-
return true if name == "
|
|
99
|
+
return true if name == "class" && %w[span pre].include?(node.name)
|
|
94
100
|
|
|
95
101
|
node.name == "a" && %w[href title].include?(name)
|
|
96
102
|
end
|
|
97
103
|
|
|
104
|
+
# Scope names are plain words ("diff", "meta", "name", ...) that page CSS and
|
|
105
|
+
# querySelectors use too: prefix them so they only ever match the hl-* rules.
|
|
106
|
+
def prefix_highlight_classes(node)
|
|
107
|
+
words = node["class"].to_s.split
|
|
108
|
+
return node.remove_attribute("class") if words.empty?
|
|
109
|
+
|
|
110
|
+
node["class"] = words.map { |word| "hl-#{word}" }.join(" ")
|
|
111
|
+
end
|
|
112
|
+
|
|
98
113
|
def safe_href?(href)
|
|
99
114
|
return true if href.start_with?("#", "/")
|
|
100
115
|
|
|
@@ -23,6 +23,7 @@ import { chipName, chipsHtml, imageFiles, restoredChips, thumbsHtml, turnRefs, t
|
|
|
23
23
|
import { escapeHtml, memChipText, memChipTitle, noteHtml, normalize, steerRowHtml, userBodyHtml, ownerBadge, previewForCard, relativeTime, failedTurnText, modelLabel, deleteConfirmText, recapLabel, canStopSession, goneSessionNotice, withLiveStatus, withoutWorker, recapPlace, delegatedBy } from "./format.js";
|
|
24
24
|
import { EMPTY_ANSWER_CLASS, createTurnView, turnHistoryHtml } from "./turn_view.js";
|
|
25
25
|
import { createStageView } from "./stage_view.js";
|
|
26
|
+
import { createTaskStopper } from "./task_stop.js";
|
|
26
27
|
import { appendQuote } from "./annotations.js";
|
|
27
28
|
import { parsePresets } from "./annotate_presets.js";
|
|
28
29
|
import { createAnnotateBar } from "./annotate_bar.js";
|
|
@@ -86,6 +87,9 @@ const viewArgs = {
|
|
|
86
87
|
isNearBottom: () => isNearBottom(),
|
|
87
88
|
sawText: (k) => seenContent.add(k),
|
|
88
89
|
sessionId: () => selected,
|
|
90
|
+
// The stop-task button on a running task_wait, for a worker that has
|
|
91
|
+
// the route (its sidecar features).
|
|
92
|
+
taskStop: createTaskStopper({ sessionId: () => selected, features: () => current.workerFeatures }),
|
|
89
93
|
};
|
|
90
94
|
const turnDom = view === "stage"
|
|
91
95
|
? createStageView({ ...viewArgs, isNearBottom: () => atHistoryEnd(), dockEl: $("#dock"), centerEl: $("#center"), composerEl })
|
|
@@ -123,6 +123,17 @@ export function stopSession(id, opts = {}) {
|
|
|
123
123
|
return api(`/api/sessions/${id}/stop`, { method: "POST", body: JSON.stringify({}), ...opts });
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
// Kills a background task the session's conversation started (the
|
|
127
|
+
// stop-task button): 200 {status, stop_reason}; 409 it already ended, 503
|
|
128
|
+
// no live worker.
|
|
129
|
+
export function stopTask(id, taskId, opts = {}) {
|
|
130
|
+
return api(`/api/sessions/${encodeURIComponent(id)}/tasks/${encodeURIComponent(taskId)}/stop`, {
|
|
131
|
+
method: "POST",
|
|
132
|
+
body: JSON.stringify({}),
|
|
133
|
+
...opts,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
126
137
|
// Hands the session to a new worker on the newest chi installed: 200
|
|
127
138
|
// {status, from_version, version}, or 409 with the reason in words (detail).
|
|
128
139
|
export function restartSession(id, opts = {}) {
|
|
@@ -8,11 +8,13 @@
|
|
|
8
8
|
:root{color-scheme:dark;--bg:#0b0d11;--bg-panel:#151821;--bg-inset:#0f1115;--bg-raised:#22262f;--bg-hover:#1b1f27;--border:#1f232b;--border-strong:#2d333f;--text:#e6e8eb;--text-bright:#f0f3f6;--text-2:#c9d1d9;--text-3:#9aa4b2;--text-muted:#8b949e;--text-faint:#6e7681;--accent:#7aa5ff;--accent-strong:#1f6feb;--accent-bg:#1b2336;--accent-ring:rgba(122,165,255,.18);--primary:#2a68d1;--ok:#3fb950;--ok-text:#7ee787;--ok-bg:#1a2e1a;--warn:#d29922;--warn-text:#e3b341;--warn-bright:#f2cc60;--warn-bg:#2b2108;--warn-border:#6b5416;--danger:#f85149;--danger-bg:#da3633;--danger-text:#ff7b72;--danger-bg-dim:#3d1f1f;--think:#8957e5;--think-text:#d2a8ff;--accent-border:#223150;--accent-tint:color-mix(in srgb,var(--accent) 7%,transparent);--narr:color-mix(in srgb,var(--accent) 38%,var(--text-muted));
|
|
9
9
|
--glass-bg:color-mix(in srgb,var(--bg-panel) 70%,transparent);--glass-border:rgba(255,255,255,.08);--glass-highlight:rgba(255,255,255,.05);--glass-shadow:0 8px 30px rgba(0,0,0,.35);
|
|
10
10
|
--diff-add-bg:rgba(63,185,80,.14);--diff-add-text:#aff5b4;--diff-del-bg:rgba(248,81,73,.14);--diff-del-text:#ffc1bc;
|
|
11
|
+
--code-text:#c9d1d9;--code-keyword:#ff7b72;--code-string:#a5d6ff;--code-comment:#8b949e;--code-constant:#79c0ff;--code-func:#d2a8ff;--code-type:#ffa657;
|
|
11
12
|
--card-hover-border:rgba(255,255,255,.14);--on-solid:#fff;--on-warn:#fff;--text-hover:#fff;--badge-bg:rgba(255,255,255,.08);--badge-ring:rgba(255,255,255,.18);--shadow-strong:rgba(0,0,0,.5);--backdrop:rgba(0,0,0,.85);--toast-shadow:0 6px 24px rgba(0,0,0,.25)}
|
|
12
13
|
/* Light palette: follows the system setting. :not([data-theme="dark"]) leaves room for a later in-page toggle (data-theme on <html>); there is none yet. Each palette sets color-scheme, so native radios, inputs and scrollbars follow it. */
|
|
13
14
|
@media (prefers-color-scheme:light){:root:not([data-theme="dark"]){color-scheme:light;--bg:#f5f6f8;--bg-panel:#ffffff;--bg-inset:#f1f3f6;--bg-raised:#e8ebf0;--bg-hover:#eceff4;--border:#dde1e7;--border-strong:#c3c9d2;--text:#1f2328;--text-bright:#0d1117;--text-2:#2f363d;--text-3:#475160;--text-muted:#57606a;--text-faint:#636c76;--accent:#0b5cd5;--accent-strong:#0550ae;--accent-bg:#e2ecfd;--accent-ring:rgba(11,92,213,.2);--primary:#1f6feb;--ok:#1a7f37;--ok-text:#116329;--ok-bg:#dafbe1;--warn:#9a6700;--warn-text:#7d4e00;--warn-bright:#875c00;--warn-bg:#fff6cc;--warn-border:#d4a72c;--danger:#cf222e;--danger-bg:#cf222e;--danger-text:#a40e26;--danger-bg-dim:#ffebe9;--think:#8250df;--think-text:#6639ba;--accent-border:#b8cef5;
|
|
14
15
|
--glass-border:rgba(31,35,40,.10);--glass-highlight:rgba(255,255,255,.7);--glass-shadow:0 8px 30px rgba(31,35,40,.10);
|
|
15
16
|
--diff-add-bg:rgba(26,127,55,.12);--diff-add-text:#116329;--diff-del-bg:rgba(207,34,46,.10);--diff-del-text:#82071e;
|
|
17
|
+
--code-text:#1f2328;--code-keyword:#cf222e;--code-string:#0a3069;--code-comment:#57606a;--code-constant:#0550ae;--code-func:#8250df;--code-type:#953800;
|
|
16
18
|
--card-hover-border:rgba(31,35,40,.18);--on-solid:#fff;--on-warn:#1f2328;--text-hover:#0d1117;--badge-bg:rgba(31,35,40,.06);--badge-ring:rgba(31,35,40,.15);--shadow-strong:rgba(31,35,40,.18);--backdrop:rgba(13,17,23,.85);--toast-shadow:0 6px 24px rgba(31,35,40,.14)}}
|
|
17
19
|
*{box-sizing:border-box;margin:0;padding:0}
|
|
18
20
|
body{font-family: ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;background:radial-gradient(1100px 700px at 8% 0%,color-mix(in srgb,var(--accent) 13%,transparent),transparent 70%),radial-gradient(900px 700px at 100% 100%,color-mix(in srgb,var(--think) 9%,transparent),transparent 70%),var(--bg);background-attachment:fixed;color:var(--text);height:100vh;display:flex;flex-direction:column}
|
|
@@ -62,6 +64,18 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
|
|
|
62
64
|
.bubble.output.markdown code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.88em}
|
|
63
65
|
.bubble.output.markdown :not(pre)>code{background:var(--bg-inset);border:1px solid var(--border);border-radius:4px;padding:1px 5px;color:var(--text)}
|
|
64
66
|
.bubble.output.markdown pre{font-size:13px;line-height:1.45;border:1px solid var(--border)}
|
|
67
|
+
/* Code highlighting (markdown_renderer.rb): syntect scope classes, hl-prefixed, coloured from the palette's --code-* tokens. More classes win over fewer (a function name is also "entity"). */
|
|
68
|
+
.syntax-highlighting{color:var(--code-text)}
|
|
69
|
+
.syntax-highlighting .hl-keyword,.syntax-highlighting .hl-storage{color:var(--code-keyword)}
|
|
70
|
+
.syntax-highlighting .hl-string{color:var(--code-string)}
|
|
71
|
+
.syntax-highlighting .hl-string .hl-embedded{color:var(--code-text)}
|
|
72
|
+
.syntax-highlighting .hl-constant{color:var(--code-constant)}
|
|
73
|
+
.syntax-highlighting .hl-entity.hl-name.hl-function,.syntax-highlighting .hl-support.hl-function{color:var(--code-func)}
|
|
74
|
+
.syntax-highlighting .hl-entity.hl-name.hl-class,.syntax-highlighting .hl-entity.hl-name.hl-type,.syntax-highlighting .hl-entity.hl-other.hl-inherited-class,.syntax-highlighting .hl-support.hl-class,.syntax-highlighting .hl-support.hl-type,.syntax-highlighting .hl-meta.hl-diff.hl-range{color:var(--code-type)}
|
|
75
|
+
.syntax-highlighting .hl-comment{color:var(--code-comment);font-style:italic}
|
|
76
|
+
.syntax-highlighting .hl-markup.hl-inserted{color:var(--diff-add-text)}
|
|
77
|
+
.syntax-highlighting .hl-markup.hl-deleted{color:var(--diff-del-text)}
|
|
78
|
+
.syntax-highlighting .hl-invalid{color:var(--danger-text)}
|
|
65
79
|
.bubble.output.markdown h1,.bubble.output.markdown h2,.bubble.output.markdown h3,.bubble.output.markdown h4,.bubble.output.markdown h5,.bubble.output.markdown h6{font-weight:600;line-height:1.3;margin:18px 0 8px;color:var(--text-bright)}
|
|
66
80
|
.bubble.output.markdown h1{font-size:19px}
|
|
67
81
|
.bubble.output.markdown h2{font-size:17px}
|
|
@@ -122,6 +136,11 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
|
|
|
122
136
|
.activity-status.stopped{color:var(--warn-text);opacity:.8}
|
|
123
137
|
.activity-status.blocked{color:var(--danger-text)}
|
|
124
138
|
.activity-tool{color:var(--accent);font-weight:600}
|
|
139
|
+
/* The stop-task button on a running task_wait (task_stop.js): a small ghost
|
|
140
|
+
button in the danger colours, at the row's end and in the stage's now slot. */
|
|
141
|
+
button.task-stop{flex:none;margin-left:auto;padding:1px 8px;border:1px solid color-mix(in srgb,var(--danger) 45%,transparent);border-radius:6px;background:transparent;color:var(--danger-text);font-size:11.5px;line-height:18px;white-space:nowrap}
|
|
142
|
+
button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:none}
|
|
143
|
+
.ts-now button.task-stop{align-self:center}
|
|
125
144
|
.activity-params{color:var(--text-muted);word-break:break-all}
|
|
126
145
|
.activity-duration{color:var(--text-faint);font-size:11px;font-variant-numeric:tabular-nums;white-space:nowrap}
|
|
127
146
|
.activity-output{margin:4px 0 2px 60px;font-size:12px;color:var(--text-muted);white-space:pre-wrap;word-break:break-word;background:var(--bg);border-radius:6px;padding:6px 8px;max-height:160px;overflow:auto}
|
|
@@ -85,6 +85,12 @@ function withCommand(slot, row) {
|
|
|
85
85
|
return row.view?.command ? { ...slot, command: row.view.command } : slot;
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
// A running task_wait's slot: the task it waits on and the row's key, for
|
|
89
|
+
// the stop-task button (task_stop.js).
|
|
90
|
+
function withTask(slot, row) {
|
|
91
|
+
return row.tool === "task_wait" && row.view?.task_id ? { ...slot, taskId: row.view.task_id, key: row.key } : slot;
|
|
92
|
+
}
|
|
93
|
+
|
|
88
94
|
// While the first step has no thinking and no tool rows the turn is plain:
|
|
89
95
|
// its text streams in the stage's answer slot, as the turn view's
|
|
90
96
|
// chrome-less block streams like a bubble.
|
|
@@ -106,7 +112,7 @@ const NO_ANSWER = "no answer";
|
|
|
106
112
|
// feed), else the newest thinking one (+thinking+, its ticker),
|
|
107
113
|
// else "…";
|
|
108
114
|
// tool the running call ({name, title, kind}, and command: its full
|
|
109
|
-
// command when it has one) or null;
|
|
115
|
+
// command when it has one; taskId and key: a task_wait's) or null;
|
|
110
116
|
// trail the last three finished calls, newest last (a file's title
|
|
111
117
|
// without its dir; command as for tool);
|
|
112
118
|
// ticks one per call: {kind, status, step (the gen's index)}.
|
|
@@ -137,7 +143,7 @@ export function liveSlots(turn, { narration = "", thinking = "", pending = false
|
|
|
137
143
|
phase,
|
|
138
144
|
step: ended ? gens.filter((g) => !g.answer || g.thinking || g.tools.length).length : gens.length,
|
|
139
145
|
headline,
|
|
140
|
-
tool: running ? withCommand({ name: toolName(running), title: rowTitle(running), kind: toolKind(running.tool) }, running) : null,
|
|
146
|
+
tool: running ? withTask(withCommand({ name: toolName(running), title: rowTitle(running), kind: toolKind(running.tool) }, running), running) : null,
|
|
141
147
|
trail,
|
|
142
148
|
ticks: rows.map(({ row, step }) => ({ kind: toolKind(row.tool), status: row.status, step })),
|
|
143
149
|
};
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import { createTurnView } from "./turn_view.js";
|
|
14
14
|
import { flashOf, handOffDue, handOffOrder, headlineOf, inUseFrom, isPlain, liveSlots, placeFor } from "./stage_model.js";
|
|
15
15
|
import { formatDuration } from "./timing.js";
|
|
16
|
+
import { taskCommand, taskStopButton } from "./task_stop.js";
|
|
16
17
|
|
|
17
18
|
const COLLAPSE_KEY = "chi_stage_collapsed";
|
|
18
19
|
const WATCH_MS = 200;
|
|
@@ -45,7 +46,9 @@ function readCollapsed() {
|
|
|
45
46
|
// +historyNearBottom+: the history's own follow check (the hand-off scrolls
|
|
46
47
|
// the history only for a reader at its end). +dockEl+, +centerEl+ and
|
|
47
48
|
// +composerEl+ size the stage's cap.
|
|
48
|
-
|
|
49
|
+
// +taskStop+: the page's stopper (task_stop.js), for the stop-task button
|
|
50
|
+
// on a running task_wait.
|
|
51
|
+
export function createStageView({ historyEl, appendToHistory, isNearBottom: historyNearBottom, sawText, sessionId, dockEl, centerEl, composerEl, taskStop = null }) {
|
|
49
52
|
// The dock becomes a clear column: the stage's card, then the composer's
|
|
50
53
|
// (its rows moved into .dock-card, which wears the glass and the focus ring).
|
|
51
54
|
document.body.classList.add("stage-view");
|
|
@@ -131,6 +134,7 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
|
|
|
131
134
|
},
|
|
132
135
|
useHold: false,
|
|
133
136
|
newestFirst: true,
|
|
137
|
+
taskStop,
|
|
134
138
|
onChange: () => scheduleRefresh(),
|
|
135
139
|
onThinkingLine: (text) => {
|
|
136
140
|
const { turn } = view.current();
|
|
@@ -223,7 +227,11 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
|
|
|
223
227
|
|
|
224
228
|
function drawNow(tool, gen) {
|
|
225
229
|
const quiet = gen?.text && !gen.textDone ? "writing…" : "thinking…";
|
|
226
|
-
|
|
230
|
+
// A task_wait's stop button, as the row's (task_stop.js): in the key
|
|
231
|
+
// with its in-flight state, so a redraw keeps a clicked one disabled.
|
|
232
|
+
const stopId = tool?.taskId && taskStop?.target({ tool: "task_wait", status: "running", view: { task_id: tool.taskId } });
|
|
233
|
+
const stopKey = stopId ? `|stop:${tool.key}:${taskStop.busy(stopId)}` : "";
|
|
234
|
+
const key = tool ? `run|${tool.name}|${tool.title}|${tool.command || ""}${stopKey}` : `quiet|${quiet}`;
|
|
227
235
|
if (shown.now === key) return;
|
|
228
236
|
shown.now = key;
|
|
229
237
|
now.replaceChildren();
|
|
@@ -236,6 +244,10 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
|
|
|
236
244
|
title.textContent = tool.title;
|
|
237
245
|
// The full command when the call has one (the title is its first step +N).
|
|
238
246
|
title.title = tool.command || tool.title;
|
|
247
|
+
if (stopId) {
|
|
248
|
+
const command = () => taskCommand(view.current().turn?.gens, stopId);
|
|
249
|
+
now.appendChild(taskStopButton(taskStop, stopId, { command, onChange: scheduleRefresh }));
|
|
250
|
+
}
|
|
239
251
|
} else {
|
|
240
252
|
const word = node("span", "ts-title quiet", now);
|
|
241
253
|
word.textContent = quiet;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// The stop-task button on a running task_wait (POST …/tasks/:task_id/stop,
|
|
2
|
+
// Bridge#handle_task_stop): the worker kills the task as the user, the wait
|
|
3
|
+
// returns, and the turn goes on. Which row gets one, what the confirm says,
|
|
4
|
+
// and the stops in flight. The in-flight set lives here, not in the DOM:
|
|
5
|
+
// the stage rebuilds its "now" slot, and a redraw must keep a clicked
|
|
6
|
+
// button disabled. Pure but for the injected calls, so `node --test`
|
|
7
|
+
// covers it; turn_view.js and stage_view.js draw the button.
|
|
8
|
+
import { stopTask as postStopTask } from "./data.js";
|
|
9
|
+
|
|
10
|
+
// What a worker that has the route names in its sidecar features.
|
|
11
|
+
export const TASK_STOP_FEATURE = "task_stop";
|
|
12
|
+
const COMMAND_SHOWN = 120;
|
|
13
|
+
|
|
14
|
+
// The task a row's button would stop: a running task_wait's (its server
|
|
15
|
+
// view's task_id), on a worker that has the route; else null.
|
|
16
|
+
export function stopTarget(row, features) {
|
|
17
|
+
if (!row || row.tool !== "task_wait" || row.status !== "running") return null;
|
|
18
|
+
if (!Array.isArray(features) || !features.includes(TASK_STOP_FEATURE)) return null;
|
|
19
|
+
return row.view?.task_id || null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// The command a task_create of this turn started +taskId+ with (its row's
|
|
23
|
+
// view), or null: a task from an earlier turn has no row here.
|
|
24
|
+
export function taskCommand(gens, taskId) {
|
|
25
|
+
for (const gen of gens || []) {
|
|
26
|
+
for (const row of gen.tools || []) {
|
|
27
|
+
if (row.tool !== "task_create") continue;
|
|
28
|
+
const id = /(?:^|\n)task_id: (\S+)/.exec(row.output || "")?.[1];
|
|
29
|
+
if (id === taskId) return row.view?.command || null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function confirmText(taskId, command) {
|
|
36
|
+
const line = String(command || "").replace(/\s+/g, " ").trim();
|
|
37
|
+
const what = !line ? taskId : line.length > COMMAND_SHOWN ? `${line.slice(0, COMMAND_SHOWN - 1)}…` : line;
|
|
38
|
+
return `Stop task ${what}?`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// A failed stop said nothing about: 409 (the task ended meanwhile), 503
|
|
42
|
+
// (no live worker: it can't be stopped now).
|
|
43
|
+
export function quietFailure(error) {
|
|
44
|
+
return error?.status === 409 || error?.status === 503;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// +sessionId+ and +features+ are read at each call (the selected session,
|
|
48
|
+
// its worker's sidecar features).
|
|
49
|
+
export function createTaskStopper({
|
|
50
|
+
sessionId,
|
|
51
|
+
features,
|
|
52
|
+
stopTask = postStopTask,
|
|
53
|
+
confirm = (text) => globalThis.confirm(text),
|
|
54
|
+
alert = (text) => globalThis.alert(text),
|
|
55
|
+
}) {
|
|
56
|
+
const inFlight = new Set();
|
|
57
|
+
const watchers = new Map(); // key -> Set of fn(busy)
|
|
58
|
+
const keyOf = (taskId) => `${sessionId()}|${taskId}`;
|
|
59
|
+
const notify = (key, busy) => watchers.get(key)?.forEach((fn) => fn(busy));
|
|
60
|
+
return {
|
|
61
|
+
target: (row) => stopTarget(row, features()),
|
|
62
|
+
busy: (taskId) => inFlight.has(keyOf(taskId)),
|
|
63
|
+
// Call +fn+ whenever a stop for +taskId+ starts or ends (the key enters
|
|
64
|
+
// or leaves the in-flight set) — the session's own task, on this session.
|
|
65
|
+
// Returns unwatch. A task drawn in two rows (the stage's "now" slot and
|
|
66
|
+
// the step list) so both buttons stay in step; a watcher for a button
|
|
67
|
+
// that is gone unwatches itself.
|
|
68
|
+
watch(taskId, fn) {
|
|
69
|
+
const key = keyOf(taskId);
|
|
70
|
+
let set = watchers.get(key);
|
|
71
|
+
if (!set) watchers.set(key, (set = new Set()));
|
|
72
|
+
set.add(fn);
|
|
73
|
+
return () => set.delete(fn);
|
|
74
|
+
},
|
|
75
|
+
// true once the worker stopped it. A stop that went through stays in
|
|
76
|
+
// flight: the button goes with its row's completion a moment later.
|
|
77
|
+
async stop(taskId, command = null) {
|
|
78
|
+
const id = sessionId();
|
|
79
|
+
const key = keyOf(taskId);
|
|
80
|
+
if (!id || inFlight.has(key) || !confirm(confirmText(taskId, command))) return false;
|
|
81
|
+
inFlight.add(key);
|
|
82
|
+
notify(key, true);
|
|
83
|
+
try {
|
|
84
|
+
await stopTask(id, taskId);
|
|
85
|
+
return true;
|
|
86
|
+
} catch (e) {
|
|
87
|
+
inFlight.delete(key);
|
|
88
|
+
notify(key, false);
|
|
89
|
+
if (!quietFailure(e)) alert(e.message);
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// The button itself (the one DOM part): a click asks, posts, and keeps it
|
|
97
|
+
// disabled while the stop is in flight. +command+ is read at the click;
|
|
98
|
+
// +onChange+ lets a view redraw (the stage's slot). It also watches the
|
|
99
|
+
// task's in-flight state, so a stop started from a second button of the
|
|
100
|
+
// same row (the stage draws one in the "now" slot and one in the step
|
|
101
|
+
// list) disables this one too — a button that is gone unwatched.
|
|
102
|
+
export function taskStopButton(stopper, taskId, { command = () => null, onChange = () => {} } = {}) {
|
|
103
|
+
const btn = document.createElement("button");
|
|
104
|
+
btn.type = "button";
|
|
105
|
+
btn.className = "ghost task-stop";
|
|
106
|
+
btn.textContent = "stop task";
|
|
107
|
+
btn.title = "Kill this background task; the model is told you stopped it";
|
|
108
|
+
btn.disabled = stopper.busy(taskId);
|
|
109
|
+
const unwatch = stopper.watch?.(taskId, (busy) => {
|
|
110
|
+
if (!btn.isConnected) { unwatch(); return; }
|
|
111
|
+
btn.disabled = busy;
|
|
112
|
+
});
|
|
113
|
+
btn.addEventListener("click", (e) => {
|
|
114
|
+
e.stopPropagation();
|
|
115
|
+
const pending = stopper.stop(taskId, command());
|
|
116
|
+
btn.disabled = stopper.busy(taskId);
|
|
117
|
+
onChange();
|
|
118
|
+
pending.then(() => {
|
|
119
|
+
btn.disabled = stopper.busy(taskId);
|
|
120
|
+
onChange();
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
return btn;
|
|
124
|
+
}
|
|
@@ -23,6 +23,7 @@ import { createSentenceFeed } from "./sentences.js";
|
|
|
23
23
|
import { createTicker } from "./thinking_ticker.js";
|
|
24
24
|
import * as model from "./turn_model.js";
|
|
25
25
|
import { createHold } from "./hold.js";
|
|
26
|
+
import { taskCommand, taskStopButton } from "./task_stop.js";
|
|
26
27
|
|
|
27
28
|
// A row's status (live, or a saved tool record's on reload): running,
|
|
28
29
|
// error, stopped (a task_wait the user's Stop ended), blocked (a guardrail
|
|
@@ -90,7 +91,9 @@ function fillActivityRow(el, row, sessionId) {
|
|
|
90
91
|
// (chronological() restores the order);
|
|
91
92
|
// onChange() after anything in the turn changed (the stage redraws
|
|
92
93
|
// its slots from current());
|
|
93
|
-
// onThinkingLine(sentence) the live step's ticker showed a new line
|
|
94
|
+
// onThinkingLine(sentence) the live step's ticker showed a new line;
|
|
95
|
+
// taskStop the page's stopper (task_stop.js): a running task_wait
|
|
96
|
+
// row gets its stop-task button (none: no button).
|
|
94
97
|
export function createTurnView({
|
|
95
98
|
historyEl, appendToHistory, isNearBottom, sawText, sessionId,
|
|
96
99
|
blockHost = appendToHistory,
|
|
@@ -100,6 +103,7 @@ export function createTurnView({
|
|
|
100
103
|
newestFirst = false,
|
|
101
104
|
onChange = () => {},
|
|
102
105
|
onThinkingLine = () => {},
|
|
106
|
+
taskStop = null,
|
|
103
107
|
}) {
|
|
104
108
|
let turn = null;
|
|
105
109
|
let blockEl = null;
|
|
@@ -407,14 +411,33 @@ export function createTurnView({
|
|
|
407
411
|
const stick = isNearBottom();
|
|
408
412
|
body.appendChild(el);
|
|
409
413
|
fillActivityRow(el, r.row, sessionId());
|
|
414
|
+
syncTaskStop(el, r.row);
|
|
410
415
|
if (stick) follow();
|
|
411
416
|
} else {
|
|
412
417
|
fillActivityRow(el, r.row, sessionId());
|
|
418
|
+
syncTaskStop(el, r.row);
|
|
413
419
|
}
|
|
414
420
|
updateBlockSummary();
|
|
415
421
|
onChange();
|
|
416
422
|
}
|
|
417
423
|
|
|
424
|
+
// The stop-task button after a running task_wait's params; gone once the
|
|
425
|
+
// row completes.
|
|
426
|
+
function syncTaskStop(el, row) {
|
|
427
|
+
const taskId = taskStop?.target(row);
|
|
428
|
+
const btn = el.querySelector(":scope > .task-stop");
|
|
429
|
+
if (!taskId) {
|
|
430
|
+
btn?.remove();
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
if (btn) {
|
|
434
|
+
btn.disabled = taskStop.busy(taskId);
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
const command = () => taskCommand(turn?.gens, taskId);
|
|
438
|
+
el.querySelector(".activity-params").after(taskStopButton(taskStop, taskId, { command }));
|
|
439
|
+
}
|
|
440
|
+
|
|
418
441
|
// A hook's line (hook_notice) as a row of the current step, above the row
|
|
419
442
|
// of the call it judged; false with no running turn (app.js shows the
|
|
420
443
|
// bubble then). data.line is a whole row of chi's own (an empty-answer
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: samagotchi
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.28.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Dmitry Dedov
|
|
@@ -471,6 +471,7 @@ files:
|
|
|
471
471
|
- lib/samagotchi/web/public/stage_view.js
|
|
472
472
|
- lib/samagotchi/web/public/strip.js
|
|
473
473
|
- lib/samagotchi/web/public/tally.js
|
|
474
|
+
- lib/samagotchi/web/public/task_stop.js
|
|
474
475
|
- lib/samagotchi/web/public/thinking_ticker.js
|
|
475
476
|
- lib/samagotchi/web/public/timing.js
|
|
476
477
|
- lib/samagotchi/web/public/turn_events.js
|