samagotchi 0.3.0 → 0.5.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 +162 -1
- data/README.md +29 -2
- data/bin/chi +60 -69
- data/docs/cli.md +211 -77
- data/docs/configuration.md +118 -21
- data/docs/desktop.md +39 -4
- data/docs/guardrails.md +11 -0
- data/docs/hooks.md +89 -7
- data/docs/memory.md +40 -0
- data/docs/plugins.md +50 -0
- data/docs/releasing.md +15 -12
- data/docs/sessions.md +20 -18
- data/lib/samagotchi/bootstrap/config_writer.rb +1 -2
- data/lib/samagotchi/bridge/sse_writer.rb +0 -3
- data/lib/samagotchi/bridge/turn_accumulator.rb +2 -0
- data/lib/samagotchi/bridge.rb +20 -12
- data/lib/samagotchi/bundles/skills/manifest.yml +10 -0
- data/lib/samagotchi/bundles/skills/plugin.rb +419 -0
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +178 -5
- data/lib/samagotchi/bundles/source-links/manifest.yml +3 -3
- data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +9 -10
- data/lib/samagotchi/bundles/system/delegated.md +6 -7
- data/lib/samagotchi/bundles/system/identity.md +5 -0
- data/lib/samagotchi/bundles/system/manifest.yml +6 -6
- data/lib/samagotchi/bundles/system/memory_guide.md +26 -0
- data/lib/samagotchi/bundles/system/self_map.md +2 -1
- data/lib/samagotchi/client.rb +25 -26
- data/lib/samagotchi/commands/registry.rb +8 -0
- data/lib/samagotchi/config.rb +97 -113
- data/lib/samagotchi/desktop/macos/App.swift +12 -8
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +4 -2
- data/lib/samagotchi/desktop/macos/Images.swift +113 -0
- data/lib/samagotchi/desktop/macos/Info.plist.erb +6 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +112 -9
- data/lib/samagotchi/desktop/macos.rb +59 -8
- data/lib/samagotchi/desktop_command.rb +6 -3
- data/lib/samagotchi/edit_preview.rb +82 -0
- data/lib/samagotchi/engine.rb +236 -443
- data/lib/samagotchi/gem_update.rb +89 -0
- data/lib/samagotchi/guardrails/approval.rb +26 -4
- data/lib/samagotchi/guardrails/load_failures.rb +9 -3
- data/lib/samagotchi/host_registry.rb +8 -12
- data/lib/samagotchi/idle_client.rb +24 -15
- data/lib/samagotchi/idle_reminders.rb +2 -2
- data/lib/samagotchi/image_store.rb +10 -6
- data/lib/samagotchi/kernel_loop.rb +59 -123
- data/lib/samagotchi/live_versions.rb +65 -0
- data/lib/samagotchi/llm/api_key.rb +41 -0
- data/lib/samagotchi/llm/chat_loop.rb +77 -13
- data/lib/samagotchi/llm/errors.rb +38 -7
- data/lib/samagotchi/llm/http.rb +19 -22
- data/lib/samagotchi/llm/openai_chat.rb +22 -26
- data/lib/samagotchi/memory_bundle/installer.rb +65 -63
- data/lib/samagotchi/memory_bundle/provenance.rb +51 -12
- data/lib/samagotchi/memory_bundle/shipped_update.rb +157 -0
- data/lib/samagotchi/memory_bundle/status.rb +4 -1
- data/lib/samagotchi/memory_bundle/system_bundle.rb +81 -53
- data/lib/samagotchi/model_profile.rb +27 -10
- data/lib/samagotchi/note_command.rb +2 -1
- data/lib/samagotchi/prompt.rb +4 -2
- data/lib/samagotchi/reminder_store.rb +1 -9
- data/lib/samagotchi/reply_wait.rb +48 -4
- data/lib/samagotchi/self_report.rb +37 -5
- data/lib/samagotchi/send_command.rb +190 -17
- data/lib/samagotchi/session.rb +4 -2
- data/lib/samagotchi/session_commands.rb +38 -8
- data/lib/samagotchi/session_manager.rb +19 -53
- data/lib/samagotchi/system_prompt.rb +403 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -3
- data/lib/samagotchi/terminal_ui/attached_loop.rb +141 -108
- data/lib/samagotchi/terminal_ui/attached_view.rb +27 -12
- data/lib/samagotchi/terminal_ui/event_renderer.rb +29 -8
- data/lib/samagotchi/terminal_ui/formatting.rb +41 -22
- data/lib/samagotchi/terminal_ui/input_support.rb +7 -23
- data/lib/samagotchi/terminal_ui/plain_surface.rb +13 -7
- data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
- data/lib/samagotchi/terminal_ui/status_row.rb +81 -0
- data/lib/samagotchi/terminal_ui/surface.rb +1 -1
- data/lib/samagotchi/terminal_ui.rb +142 -923
- data/lib/samagotchi/text_diff.rb +181 -0
- data/lib/samagotchi/thinking.rb +126 -0
- data/lib/samagotchi/tool_activity.rb +52 -2
- data/lib/samagotchi/tool_runner.rb +37 -1
- data/lib/samagotchi/tools/ask_user_question.rb +41 -33
- data/lib/samagotchi/tools/edit.rb +23 -9
- data/lib/samagotchi/tools/execute.rb +3 -3
- data/lib/samagotchi/tools/output_guardrails.rb +8 -7
- data/lib/samagotchi/tools/read.rb +4 -4
- data/lib/samagotchi/tools/write.rb +4 -0
- data/lib/samagotchi/turn_flow.rb +12 -2
- data/lib/samagotchi/update_command.rb +309 -0
- data/lib/samagotchi/update_hint.rb +59 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +6 -4
- data/lib/samagotchi/web/app.rb +173 -38
- data/lib/samagotchi/web/lan.rb +99 -0
- data/lib/samagotchi/web/message_parts.rb +19 -10
- data/lib/samagotchi/web/public/activity.js +10 -0
- data/lib/samagotchi/web/public/app.js +135 -78
- data/lib/samagotchi/web/public/chat_view.js +8 -1
- data/lib/samagotchi/web/public/data.js +2 -0
- data/lib/samagotchi/web/public/diff_view.js +58 -0
- data/lib/samagotchi/web/public/index.html +185 -18
- data/lib/samagotchi/web/public/model_pick.js +136 -0
- data/lib/samagotchi/web/public/model_picker.js +224 -0
- data/lib/samagotchi/web/public/notify.js +10 -0
- data/lib/samagotchi/web/public/question_card.js +3 -1
- data/lib/samagotchi/web/public/stage_model.js +110 -0
- data/lib/samagotchi/web/public/stage_view.js +580 -0
- data/lib/samagotchi/web/public/timing.js +6 -2
- data/lib/samagotchi/web/public/turn_events.js +38 -10
- data/lib/samagotchi/web/public/turn_model.js +11 -3
- data/lib/samagotchi/web/public/turn_view.js +76 -20
- data/lib/samagotchi/web/qr.rb +40 -0
- data/lib/samagotchi/web/server.rb +101 -11
- data/lib/samagotchi/web/token.rb +97 -0
- data/lib/samagotchi/worker.rb +5 -4
- metadata +38 -3
- data/lib/samagotchi/terminal_ui/legacy_surface.rb +0 -111
data/lib/samagotchi/config.rb
CHANGED
|
@@ -8,6 +8,9 @@ require "set"
|
|
|
8
8
|
require_relative "log"
|
|
9
9
|
|
|
10
10
|
module Samagotchi
|
|
11
|
+
# Parses the thinking: levels of host and model entries (it needs Config).
|
|
12
|
+
autoload :Thinking, File.expand_path("thinking", __dir__)
|
|
13
|
+
|
|
11
14
|
# Unified configuration registry implementing the implicit convention:
|
|
12
15
|
# ENV SAMAGOTCHI_ATTR (UPPER + prefix + _ = nesting)
|
|
13
16
|
# YAML attr: / nested: {param:} (lower snake, dot = nesting, Option A leaf keeps _)
|
|
@@ -22,7 +25,7 @@ module Samagotchi
|
|
|
22
25
|
# handled by ConfigFile (same file, below), which reads the *same* parsed YAML
|
|
23
26
|
# through ConfigFile.read_yaml so the file is parsed once per change.
|
|
24
27
|
module Config
|
|
25
|
-
Entry = Struct.new(:key, :yaml_path, :type, :default, :expose, :enum_values, :
|
|
28
|
+
Entry = Struct.new(:key, :yaml_path, :type, :default, :expose, :enum_values, :yaml_aliases, keyword_init: true) do
|
|
26
29
|
def env_key
|
|
27
30
|
"SAMAGOTCHI_" + yaml_path.map { |p| p.upcase }.join("_")
|
|
28
31
|
end
|
|
@@ -80,7 +83,7 @@ module Samagotchi
|
|
|
80
83
|
|
|
81
84
|
Entry.new(key: "session.retention_days", yaml_path: %w[session retention_days], type: :integer, default: 14, expose: %i[env config cli]),
|
|
82
85
|
Entry.new(key: "session.max_count", yaml_path: %w[session max_count], type: :integer, default: 500, expose: %i[env config cli]),
|
|
83
|
-
Entry.new(key: "session.keep_status", yaml_path: %w[session keep_status], type: :string, default: "
|
|
86
|
+
Entry.new(key: "session.keep_status", yaml_path: %w[session keep_status], type: :string, default: "", expose: %i[env config cli]),
|
|
84
87
|
Entry.new(key: "session.sweep_interval_hours", yaml_path: %w[session sweep_interval_hours], type: :integer, default: 24, expose: %i[env config cli]),
|
|
85
88
|
Entry.new(key: "session.idle_exit_minutes", yaml_path: %w[session idle_exit_minutes], type: :float, default: 30.0, expose: %i[env config cli]),
|
|
86
89
|
# Plain `chi` runs like `chi --shared` (bin/chi, LaunchMode); false, or --no-shared per run, keeps the plain REPL. No CLI flag: that would duplicate --shared.
|
|
@@ -105,9 +108,6 @@ module Samagotchi
|
|
|
105
108
|
Entry.new(key: "log.level", yaml_path: %w[log level], type: :enum, default: "info", expose: %i[env config cli], enum_values: %w[debug info warn error]),
|
|
106
109
|
|
|
107
110
|
Entry.new(key: "status.line", yaml_path: %w[status line], type: :string, default: "on", expose: %i[env config cli]),
|
|
108
|
-
Entry.new(key: "status.width_mode", yaml_path: %w[status width_mode], type: :string, default: "terminal_cap", expose: %i[env config cli]),
|
|
109
|
-
Entry.new(key: "status.max_width", yaml_path: %w[status max_width], type: :integer, default: 160, expose: %i[env config cli]),
|
|
110
|
-
Entry.new(key: "status.fixed_width", yaml_path: %w[status fixed_width], type: :integer, default: 120, expose: %i[env config cli]),
|
|
111
111
|
|
|
112
112
|
Entry.new(key: "context.status", yaml_path: %w[context status], type: :bool, default: true, expose: %i[env config cli]),
|
|
113
113
|
Entry.new(key: "context.window_tokens", yaml_path: %w[context window_tokens], type: :integer, default: nil, expose: %i[env config cli]),
|
|
@@ -115,9 +115,12 @@ module Samagotchi
|
|
|
115
115
|
Entry.new(key: "context.status_thresholds", yaml_path: %w[context status_thresholds],type: :string, default: "20,40,60,80", expose: %i[env config cli]),
|
|
116
116
|
Entry.new(key: "context.status_cadence", yaml_path: %w[context status_cadence], type: :integer, default: 0, expose: %i[env config cli]),
|
|
117
117
|
|
|
118
|
-
Entry.new(key: "thinking.ui", yaml_path: %w[thinking ui], type: :string, default: nil, expose: %i[env config cli]),
|
|
119
|
-
Entry.new(key: "thinking.render_interval", yaml_path: %w[thinking render_interval], type: :float, default: 0.08, expose: %i[env config cli]),
|
|
120
118
|
Entry.new(key: "thinking.turn_preamble", yaml_path: %w[thinking turn_preamble], type: :bool, default: true, expose: %i[env config cli]),
|
|
119
|
+
# How much models think: off|low|medium|high|default, parsed by Thinking
|
|
120
|
+
# (a :string, since YAML reads an unquoted off as false). The CLI flag
|
|
121
|
+
# is bin/chi's own --thinking; models: and hosts: entries rank below
|
|
122
|
+
# the flag and the env, above config.yml's value.
|
|
123
|
+
Entry.new(key: "thinking.level", yaml_path: %w[thinking level], type: :string, default: "default", expose: %i[env config]),
|
|
121
124
|
|
|
122
125
|
Entry.new(key: "default.n_predict", yaml_path: %w[default n_predict], type: :integer, default: nil, expose: %i[env config cli]),
|
|
123
126
|
Entry.new(key: "max_tool_output_chars", yaml_path: %w[max_tool_output_chars], type: :integer, default: 10_000, expose: %i[env config cli]),
|
|
@@ -128,6 +131,10 @@ module Samagotchi
|
|
|
128
131
|
# Asks again in the same turn after an empty answer (EmptyAnswerRetry);
|
|
129
132
|
# 0 = off, capped at 3. No CLI flag: workers get no CLI args.
|
|
130
133
|
Entry.new(key: "retry.empty_answer", yaml_path: %w[retry empty_answer], type: :integer, default: 1, expose: %i[env config]),
|
|
134
|
+
# What `chi update` touches; its --no-gem/--no-bundles/--no-desktop turn one off for a run.
|
|
135
|
+
Entry.new(key: "update.gem", yaml_path: %w[update gem], type: :bool, default: true, expose: %i[env config]),
|
|
136
|
+
Entry.new(key: "update.bundles", yaml_path: %w[update bundles], type: :bool, default: true, expose: %i[env config]),
|
|
137
|
+
Entry.new(key: "update.desktop", yaml_path: %w[update desktop], type: :bool, default: true, expose: %i[env config]),
|
|
131
138
|
|
|
132
139
|
Entry.new(key: "read.truncate_at_bytes", yaml_path: %w[read truncate_at_bytes], type: :integer, default: 65_536, expose: %i[env config cli]),
|
|
133
140
|
Entry.new(key: "read.preview_bytes", yaml_path: %w[read preview_bytes], type: :integer, default: 12_288, expose: %i[env config cli]),
|
|
@@ -141,8 +148,8 @@ module Samagotchi
|
|
|
141
148
|
Entry.new(key: "web.port", yaml_path: %w[web port], type: :integer, default: 4567, expose: %i[env config cli]),
|
|
142
149
|
Entry.new(key: "web.host", yaml_path: %w[web host], type: :string, default: "127.0.0.1", expose: %i[env config cli]),
|
|
143
150
|
Entry.new(key: "web.markdown", yaml_path: %w[web markdown], type: :bool, default: false, expose: %i[env config cli]),
|
|
144
|
-
# The page
|
|
145
|
-
Entry.new(key: "web.
|
|
151
|
+
# The page's view of a turn: turn (one block of generations, the live one at the bottom), stage (the running turn pinned above the composer) or chat (a row of bubbles); ?view= overrides it per page load.
|
|
152
|
+
Entry.new(key: "web.view", yaml_path: %w[web view], type: :enum, default: "turn", expose: %i[env config cli], enum_values: %w[turn stage chat]),
|
|
146
153
|
# Quick replies next to Annotate in the page's selection bubble, "|"-separated (a YAML list works too); "" leaves only Annotate.
|
|
147
154
|
Entry.new(key: "web.annotate_presets", yaml_path: %w[web annotate_presets], type: :string, default: "Agreed|Could you please elaborate?", expose: %i[env config cli]),
|
|
148
155
|
|
|
@@ -162,33 +169,24 @@ module Samagotchi
|
|
|
162
169
|
# Maps of named entries and the keys an entry may hold
|
|
163
170
|
# (ConfigFile.hosts_config, ConfigFile.model_settings).
|
|
164
171
|
MAP_ENTRY_KEYS = {
|
|
165
|
-
"hosts" => %w[host port url transport api api_key_env profile first_token_timeout vision sampling enabled].freeze,
|
|
166
|
-
"models" => %w[profile vision sampling].freeze
|
|
172
|
+
"hosts" => %w[host port url transport api api_key_env profile first_token_timeout vision sampling thinking enabled].freeze,
|
|
173
|
+
"models" => %w[profile vision sampling thinking].freeze
|
|
167
174
|
}.freeze
|
|
168
175
|
# Section keys beyond the registry's: guardrails' YAML rules (Engine#guardrail_rules).
|
|
169
176
|
SECTION_EXTRA_KEYS = { "guardrails" => %w[rules disable].freeze }.freeze
|
|
170
|
-
# Removed settings that warn on their own (Engine.warn_removed_backend_setting).
|
|
171
|
-
REMOVED_KEYS = %w[backend].freeze
|
|
172
177
|
|
|
173
178
|
# Fast lookup maps
|
|
174
179
|
BY_KEY = ENTRIES.each_with_object({}) { |e, h| h[e.key] = e }.freeze
|
|
175
|
-
BY_ENV = ENTRIES.each_with_object({}) do |e, h|
|
|
176
|
-
h[e.env_key] = e
|
|
177
|
-
Array(e.aliases).each { |a| h[a] = e }
|
|
178
|
-
end.freeze
|
|
179
|
-
BY_CLI = ENTRIES.each_with_object({}) { |e, h| h[e.cli_flag] = e if e.cli_exposed? }.freeze
|
|
180
180
|
|
|
181
181
|
class << self
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
def find_by_env(env_key)
|
|
187
|
-
BY_ENV[env_key.to_s]
|
|
182
|
+
# The +candidates+ within edit distance 2 of +probe+, closest first
|
|
183
|
+
# (the "did you mean" of an unknown key or host).
|
|
184
|
+
def near_names(probe, candidates)
|
|
185
|
+
candidates.uniq.map { |c| [levenshtein(probe, c), c] }.select { |d, _| d <= 2 }.sort.map(&:last)
|
|
188
186
|
end
|
|
189
187
|
|
|
190
|
-
def
|
|
191
|
-
|
|
188
|
+
def find_by_key(key)
|
|
189
|
+
BY_KEY[key.to_s]
|
|
192
190
|
end
|
|
193
191
|
|
|
194
192
|
def all_entries
|
|
@@ -306,26 +304,10 @@ module Samagotchi
|
|
|
306
304
|
end
|
|
307
305
|
|
|
308
306
|
# Lookup yaml_path in nested hash, accepting snake leaf, kebab alias and
|
|
309
|
-
# entry-specific yaml_aliases
|
|
310
|
-
# SAMAGOTCHI_DEFAULT_MODEL) for transition: the nested key wins when a
|
|
311
|
-
# file has both (ConfigFile.load_global_env! warns about that).
|
|
307
|
+
# entry-specific yaml_aliases.
|
|
312
308
|
def lookup_yaml(data, yaml_path)
|
|
313
309
|
entry = ENTRIES.find { |e| e.yaml_path == yaml_path }
|
|
314
|
-
|
|
315
|
-
return nested unless nested.nil?
|
|
316
|
-
|
|
317
|
-
lookup_legacy(data, entry)
|
|
318
|
-
end
|
|
319
|
-
|
|
320
|
-
# The legacy flat key's value for +entry+, or nil.
|
|
321
|
-
def lookup_legacy(data, entry)
|
|
322
|
-
return nil unless entry && data.is_a?(Hash)
|
|
323
|
-
|
|
324
|
-
[entry.env_key, *Array(entry.aliases)].each do |key|
|
|
325
|
-
return data[key] if data.key?(key)
|
|
326
|
-
return data[key.to_sym] if data.key?(key.to_sym)
|
|
327
|
-
end
|
|
328
|
-
nil
|
|
310
|
+
lookup_nested(data, yaml_path, entry)
|
|
329
311
|
end
|
|
330
312
|
|
|
331
313
|
# The nested key's value for +yaml_path+, or nil.
|
|
@@ -364,21 +346,11 @@ module Samagotchi
|
|
|
364
346
|
end
|
|
365
347
|
end
|
|
366
348
|
|
|
367
|
-
#
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
snapshot(file_data: file_data, env: env, cli_overrides: cli_overrides)
|
|
372
|
-
end
|
|
373
|
-
|
|
374
|
-
# In-memory store for current process (populated after CLI parse)
|
|
375
|
-
def store
|
|
376
|
-
@store ||= load_snapshot
|
|
377
|
-
end
|
|
378
|
-
|
|
379
|
-
def reload!(path: nil, env: ENV, cli_overrides: {})
|
|
349
|
+
# Set this process's CLI settings (bin/chi, after it parsed the flags).
|
|
350
|
+
# Nothing is cached: #get resolves every call, reading config.yml
|
|
351
|
+
# again when it changed.
|
|
352
|
+
def reload!(cli_overrides: {})
|
|
380
353
|
@cli_overrides = cli_overrides.dup
|
|
381
|
-
@store = load_snapshot(path: path, env: env, cli_overrides: cli_overrides)
|
|
382
354
|
ensure
|
|
383
355
|
# The log resolves its file and level from here.
|
|
384
356
|
Samagotchi::Log.invalidate! if defined?(Samagotchi::Log)
|
|
@@ -388,10 +360,32 @@ module Samagotchi
|
|
|
388
360
|
@cli_overrides ||= {}
|
|
389
361
|
end
|
|
390
362
|
|
|
363
|
+
# This process's CLI settings as the env a spawned worker takes them
|
|
364
|
+
# from ({"SAMAGOTCHI_..." => "value"}): a worker gets no CLI args, and
|
|
365
|
+
# reads everything else from ENV and config.yml itself.
|
|
366
|
+
def cli_env
|
|
367
|
+
cli_overrides.each_with_object({}) do |(key, value), env|
|
|
368
|
+
entry = find_by_key(key) || ENTRIES.find { |e| e.cli_flag == key.to_s }
|
|
369
|
+
next unless entry&.env_exposed? && !value.nil?
|
|
370
|
+
|
|
371
|
+
env[entry.env_key] = value.to_s
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
|
|
391
375
|
def get(key)
|
|
392
376
|
get_with_origin(key).first
|
|
393
377
|
end
|
|
394
378
|
|
|
379
|
+
# A timeout setting (server.open_timeout, server.read_timeout) in
|
|
380
|
+
# seconds, +given+ (a caller's own value) first; one that isn't
|
|
381
|
+
# positive (0, a word) is the key's default, on every kind of host.
|
|
382
|
+
def positive_seconds(key, given = nil)
|
|
383
|
+
value = (given.nil? ? get(key) : given).to_i
|
|
384
|
+
value.positive? ? value : find_by_key(key).default
|
|
385
|
+
rescue StandardError
|
|
386
|
+
find_by_key(key)&.default
|
|
387
|
+
end
|
|
388
|
+
|
|
395
389
|
# [value, origin] for the live value #get returns; origin is one of
|
|
396
390
|
# :cli, :env, :file, :default.
|
|
397
391
|
def get_with_origin(key)
|
|
@@ -413,13 +407,13 @@ module Samagotchi
|
|
|
413
407
|
# code doesn't read (with a "did you mean" when a known key is close).
|
|
414
408
|
# Every config-exposed entry in ENTRIES is known as written; names under
|
|
415
409
|
# the maps are the user's, and host/model entries may hold the keys
|
|
416
|
-
# their readers take.
|
|
410
|
+
# their readers take.
|
|
417
411
|
def validate_yaml_sections(data)
|
|
418
412
|
return [] unless data.is_a?(Hash)
|
|
419
413
|
|
|
420
414
|
data.flat_map do |key, value|
|
|
421
415
|
key = key.to_s
|
|
422
|
-
if
|
|
416
|
+
if FREE_FORM_MAPS.include?(key)
|
|
423
417
|
[]
|
|
424
418
|
elsif MAP_ENTRY_KEYS.key?(key)
|
|
425
419
|
map_entry_problems(key, value)
|
|
@@ -454,7 +448,7 @@ module Samagotchi
|
|
|
454
448
|
def key_problem(dotted)
|
|
455
449
|
return nil if known_config_keys.include?(dotted)
|
|
456
450
|
|
|
457
|
-
entry = find_by_key(dotted)
|
|
451
|
+
entry = find_by_key(dotted)
|
|
458
452
|
if entry
|
|
459
453
|
where = entry.cli_exposed? ? "#{entry.env_key} or #{entry.cli_flag}" : entry.env_key
|
|
460
454
|
return "config: '#{dotted}' can't be set in config.yml; use #{where}"
|
|
@@ -462,7 +456,7 @@ module Samagotchi
|
|
|
462
456
|
|
|
463
457
|
canonical = ENTRIES.select(&:config_exposed?).map(&:key) + known_sections.to_a +
|
|
464
458
|
SECTION_EXTRA_KEYS.flat_map { |s, leaves| leaves.map { |l| "#{s}.#{l}" } } + FREE_FORM_MAPS + MAP_ENTRY_KEYS.keys
|
|
465
|
-
# A
|
|
459
|
+
# A flat SAMAGOTCHI_* key (the env name) compares by its nested form.
|
|
466
460
|
probe = dotted.start_with?("SAMAGOTCHI_") ? dotted.delete_prefix("SAMAGOTCHI_").downcase : dotted
|
|
467
461
|
unknown_key_message(dotted, probe, canonical)
|
|
468
462
|
end
|
|
@@ -485,7 +479,7 @@ module Samagotchi
|
|
|
485
479
|
# share its last segment, at most three.
|
|
486
480
|
def unknown_key_message(key, probe, candidates, prefix: "")
|
|
487
481
|
candidates = candidates.uniq
|
|
488
|
-
near =
|
|
482
|
+
near = near_names(probe, candidates)
|
|
489
483
|
close = (near + candidates.select { |c| c.split(".").last == probe.split(".").last }).uniq.first(3)
|
|
490
484
|
hint = close.empty? ? "" : " (did you mean #{close.map { |c| "'#{prefix}#{c}'" }.join(' or ')}?)"
|
|
491
485
|
"config: unknown key '#{key}'#{hint}"
|
|
@@ -513,7 +507,6 @@ module Samagotchi
|
|
|
513
507
|
XDG_CONFIG_HOME_ENV = "XDG_CONFIG_HOME"
|
|
514
508
|
CONFIG_DIR = "samagotchi"
|
|
515
509
|
CONFIG_FILE = "config.yml"
|
|
516
|
-
DEFAULT_MODEL_KEY = "SAMAGOTCHI_DEFAULT_MODEL"
|
|
517
510
|
MODEL_ALIASES_KEY = "model_aliases"
|
|
518
511
|
MODELS_KEY = "models"
|
|
519
512
|
|
|
@@ -621,46 +614,18 @@ module Samagotchi
|
|
|
621
614
|
end
|
|
622
615
|
end
|
|
623
616
|
|
|
624
|
-
#
|
|
625
|
-
#
|
|
626
|
-
#
|
|
627
|
-
#
|
|
628
|
-
#
|
|
629
|
-
def
|
|
617
|
+
# Check config.yml once at start (warnings for keys the code doesn't
|
|
618
|
+
# read) and clear the CLI settings. Nothing is copied into ENV: every
|
|
619
|
+
# Config.get reads the file again when it changed, so a long-lived
|
|
620
|
+
# process (chi web) and the workers it spawns see edits, and a file
|
|
621
|
+
# value reports origin :file. Returns true if the file existed.
|
|
622
|
+
def load!(env: ENV, path: global_path(env: env))
|
|
630
623
|
existed = File.file?(path)
|
|
631
624
|
raw = read_yaml(env: env, path: path) || {}
|
|
632
625
|
if existed
|
|
633
626
|
Samagotchi::Config.validate_yaml_sections(raw).each { |w| Log.warn(:config, "config_key", echo: "Warning: #{w}") }
|
|
634
|
-
# Warn on legacy UPPER keys; the nested key wins when both are set.
|
|
635
|
-
# Any other flat key is unknown to validate_yaml_sections.
|
|
636
|
-
raw.each_key do |k|
|
|
637
|
-
entry = Samagotchi::Config.find_by_env(k)
|
|
638
|
-
next unless entry&.config_exposed?
|
|
639
|
-
|
|
640
|
-
if !Samagotchi::Config.lookup_nested(raw, entry.yaml_path, entry).nil?
|
|
641
|
-
nested = entry.yaml_path.join(".")
|
|
642
|
-
Log.warn(:config, "legacy_key", echo: "Warning: config: both '#{k}' and '#{nested}' are set; using '#{nested}', remove the flat key", key: k.to_s)
|
|
643
|
-
else
|
|
644
|
-
Log.warn(:config, "legacy_key", echo: "Warning: config key '#{k}' is legacy UPPER — use '#{entry.yaml_path.join('.')}'", key: k.to_s)
|
|
645
|
-
end
|
|
646
|
-
end
|
|
647
|
-
end
|
|
648
|
-
# For process-wide access, prime the Config store (so Config.get works)
|
|
649
|
-
Samagotchi::Config.reload!(path: path, env: env, cli_overrides: {})
|
|
650
|
-
# Keep ENV in sync for any code still reading ENV directly (transition).
|
|
651
|
-
# Only for keys that were actually present in file (not defaults) to
|
|
652
|
-
# avoid polluting ENV with defaults.
|
|
653
|
-
Samagotchi::Config.all_entries.each do |entry|
|
|
654
|
-
next unless entry.env_exposed?
|
|
655
|
-
# check if file actually contained this key (including legacy flat)
|
|
656
|
-
file_val = Samagotchi::Config.lookup_yaml(raw, entry.yaml_path)
|
|
657
|
-
file_val ||= raw[entry.env_key] if raw.key?(entry.env_key)
|
|
658
|
-
Array(entry.aliases).each { |a| file_val ||= raw[a] if raw.key?(a) }
|
|
659
|
-
next if file_val.nil?
|
|
660
|
-
val = Samagotchi::Config.store[entry.key]
|
|
661
|
-
next if val.nil?
|
|
662
|
-
env[entry.env_key] = val.to_s unless env.key?(entry.env_key)
|
|
663
627
|
end
|
|
628
|
+
Samagotchi::Config.reload!(cli_overrides: {})
|
|
664
629
|
existed
|
|
665
630
|
end
|
|
666
631
|
|
|
@@ -726,6 +691,7 @@ module Samagotchi
|
|
|
726
691
|
first_token_timeout = raw_cfg.key?("first_token_timeout") ? raw_cfg["first_token_timeout"] : raw_cfg[:first_token_timeout]
|
|
727
692
|
vision = ConfigFile.vision_flag(raw_cfg.key?("vision") ? raw_cfg["vision"] : raw_cfg[:vision], "hosts entry '#{name}'")
|
|
728
693
|
sampling = ConfigFile.sampling_map(raw_cfg.key?("sampling") ? raw_cfg["sampling"] : raw_cfg[:sampling], "hosts entry '#{name}'")
|
|
694
|
+
thinking = Thinking.level(raw_cfg.key?("thinking") ? raw_cfg["thinking"] : raw_cfg[:thinking], "hosts entry '#{name}'")
|
|
729
695
|
unless first_token_timeout.nil? || (first_token_timeout.is_a?(Numeric) && !first_token_timeout.negative?)
|
|
730
696
|
warn_once "Warning: hosts entry '#{name}': first_token_timeout must be seconds (0 = off); using the default"
|
|
731
697
|
first_token_timeout = nil
|
|
@@ -789,19 +755,20 @@ module Samagotchi
|
|
|
789
755
|
api: api_val&.to_sym, original_name: name, scheme: scheme,
|
|
790
756
|
url: url.empty? ? nil : url, api_key_env: api_key_env.empty? ? nil : api_key_env,
|
|
791
757
|
profile: profile.empty? ? nil : profile, first_token_timeout: first_token_timeout,
|
|
792
|
-
vision: vision, sampling: sampling }
|
|
758
|
+
vision: vision, sampling: sampling, thinking: thinking }
|
|
793
759
|
end
|
|
794
760
|
end
|
|
795
761
|
|
|
796
|
-
# If no hosts defined, synthesize "default" from
|
|
762
|
+
# If no hosts defined, synthesize "default" from server.host/port/transport.
|
|
797
763
|
if normalized.empty?
|
|
798
|
-
|
|
764
|
+
opts = { file_data: data, env: env, cli_overrides: Samagotchi::Config.cli_overrides }
|
|
765
|
+
default_host = Samagotchi::Config.resolve("server.host", **opts).to_s.strip
|
|
799
766
|
default_host = "localhost" if default_host.empty?
|
|
800
|
-
default_port =
|
|
801
|
-
default_port = default_port.empty? ? 8080 : default_port.to_i
|
|
767
|
+
default_port = Samagotchi::Config.resolve("server.port", **opts).to_i
|
|
802
768
|
default_port = 8080 if default_port <= 0 || default_port > 65535
|
|
803
|
-
|
|
804
|
-
|
|
769
|
+
transport_sym = Samagotchi::Config.resolve_with_origin("server.transport", **opts).then do |value, origin|
|
|
770
|
+
origin == :default ? nil : value.to_sym
|
|
771
|
+
end
|
|
805
772
|
normalized["default"] = { name: "default", host: default_host, port: default_port, transport: transport_sym, original_name: "default" }
|
|
806
773
|
end
|
|
807
774
|
normalized
|
|
@@ -880,6 +847,26 @@ module Samagotchi
|
|
|
880
847
|
[nil, value]
|
|
881
848
|
end
|
|
882
849
|
|
|
850
|
+
# Hosted providers' names, which no local model family uses: as a
|
|
851
|
+
# prefix they always mean a host ("openai:gpt-4o"). "mistral" and
|
|
852
|
+
# "deepseek" are left out: Ollama has mistral:7b and deepseek-* tags.
|
|
853
|
+
PROVIDER_HOST_NAMES = %w[openrouter openai anthropic google gemini groq xai together fireworks].freeze
|
|
854
|
+
|
|
855
|
+
# The host a model ref names when that host isn't configured, or nil.
|
|
856
|
+
# A ':' in a model id is often a tag (qwen3:8b, org/model:Q4_K_M), so
|
|
857
|
+
# the prefix counts as a host only when it could be a host name and
|
|
858
|
+
# either the rest looks like a hosted provider's org/model id
|
|
859
|
+
# ("nosuch:anthropic/claude-sonnet-4"; Ollama-style tags never hold a
|
|
860
|
+
# '/') or the prefix is a provider's name (PROVIDER_HOST_NAMES).
|
|
861
|
+
def unknown_host_prefix(raw, hosts:)
|
|
862
|
+
prefix, rest = raw.to_s.strip.split(":", 2)
|
|
863
|
+
return nil if rest.to_s.strip.empty? || !prefix.match?(HOST_NAME_RE)
|
|
864
|
+
return nil unless rest.include?("/") || PROVIDER_HOST_NAMES.include?(prefix.downcase)
|
|
865
|
+
|
|
866
|
+
prefix = prefix.downcase
|
|
867
|
+
hosts.keys.map { |k| k.to_s.downcase }.include?(prefix) ? nil : prefix
|
|
868
|
+
end
|
|
869
|
+
|
|
883
870
|
def hosts_json_for_env(env: ENV, path: global_path(env: env))
|
|
884
871
|
hosts = hosts_config(env: env, path: path)
|
|
885
872
|
# Only serialize if non-default or explicitly configured hosts:
|
|
@@ -892,7 +879,7 @@ module Samagotchi
|
|
|
892
879
|
location = v[:url] ? { "url" => v[:url] } : { "host" => v[:host], "port" => v[:port] }
|
|
893
880
|
location.merge("transport" => v[:transport]&.to_s, "api" => v[:api]&.to_s, "api_key_env" => v[:api_key_env],
|
|
894
881
|
"profile" => v[:profile], "first_token_timeout" => v[:first_token_timeout],
|
|
895
|
-
"vision" => v[:vision], "sampling" => v[:sampling]).compact
|
|
882
|
+
"vision" => v[:vision], "sampling" => v[:sampling], "thinking" => v[:thinking]&.to_s).compact
|
|
896
883
|
end
|
|
897
884
|
JSON.generate(simple)
|
|
898
885
|
rescue StandardError
|
|
@@ -917,23 +904,18 @@ module Samagotchi
|
|
|
917
904
|
|
|
918
905
|
raw_data = read_yaml(env: env, path: path) || {}
|
|
919
906
|
|
|
920
|
-
# Migrate to new dotted nested form: default.model (Option A)
|
|
921
907
|
raw_data["default"] ||= {}
|
|
922
908
|
if raw_data["default"].is_a?(Hash)
|
|
923
909
|
raw_data["default"]["model"] = resolved
|
|
924
910
|
else
|
|
925
911
|
raw_data["default"] = { "model" => resolved }
|
|
926
912
|
end
|
|
927
|
-
# Remove legacy UPPER key if present
|
|
928
|
-
raw_data.delete(DEFAULT_MODEL_KEY)
|
|
929
913
|
FileUtils.mkdir_p(File.dirname(path))
|
|
930
914
|
tmp = "#{path}.tmp"
|
|
931
915
|
File.write(tmp, YAML.dump(raw_data))
|
|
932
916
|
File.rename(tmp, path)
|
|
933
917
|
clear_yaml_cache!(path)
|
|
934
|
-
|
|
935
|
-
# Also sync new Config store if loaded
|
|
936
|
-
Samagotchi::Config.reload!(path: path, env: env, cli_overrides: {})
|
|
918
|
+
Samagotchi::Config.reload!(cli_overrides: Samagotchi::Config.cli_overrides)
|
|
937
919
|
true
|
|
938
920
|
end
|
|
939
921
|
|
|
@@ -976,6 +958,8 @@ module Samagotchi
|
|
|
976
958
|
result[key][:vision] = vision unless vision.nil?
|
|
977
959
|
sampling = sampling_map(v.key?("sampling") ? v["sampling"] : v[:sampling], "models: #{key}")
|
|
978
960
|
result[key][:sampling] = sampling if sampling
|
|
961
|
+
thinking = Thinking.level(v.key?("thinking") ? v["thinking"] : v[:thinking], "models: #{key}")
|
|
962
|
+
result[key][:thinking] = thinking if thinking
|
|
979
963
|
end
|
|
980
964
|
rescue StandardError
|
|
981
965
|
{}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
// Chi Helper: the "Send to chi" Service opens the panel with the selection
|
|
2
|
-
//
|
|
1
|
+
// Chi Helper: the "Send to chi" Service opens the panel with the selection
|
|
2
|
+
// (text, or images: files in Finder, a picture in Preview), the hotkey with
|
|
3
|
+
// the clipboard (a screenshot too). No Dock icon (LSUIElement); it stays
|
|
3
4
|
// running after the first launch. `ChiHelper --login on|off|status` (run
|
|
4
5
|
// directly by chi desktop) manages the login item and exits.
|
|
5
6
|
import AppKit
|
|
@@ -10,11 +11,12 @@ final class ServiceProvider: NSObject {
|
|
|
10
11
|
|
|
11
12
|
// Info.plist NSServices: NSMessage = sendToChi.
|
|
12
13
|
@objc func sendToChi(_ pboard: NSPasteboard, userData: String?, error: AutoreleasingUnsafeMutablePointer<NSString>) {
|
|
13
|
-
|
|
14
|
-
|
|
14
|
+
let (text, images) = ImageIntake.read(pboard)
|
|
15
|
+
guard !text.isEmpty || !images.isEmpty else {
|
|
16
|
+
error.pointee = "No text or image in the selection" as NSString
|
|
15
17
|
return
|
|
16
18
|
}
|
|
17
|
-
app?.open(text: text)
|
|
19
|
+
app?.open(text: text, images: images)
|
|
18
20
|
}
|
|
19
21
|
}
|
|
20
22
|
|
|
@@ -28,6 +30,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|
|
28
30
|
|
|
29
31
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
|
30
32
|
provider.app = self
|
|
33
|
+
ImageIntake.sweep()
|
|
31
34
|
NSApp.servicesProvider = provider
|
|
32
35
|
NSUpdateDynamicServices()
|
|
33
36
|
hotkey = Hotkey { [weak self] in self?.openFromClipboard() }
|
|
@@ -54,12 +57,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|
|
54
57
|
|
|
55
58
|
/// The hotkey doesn't activate us, so the frontmost app is the source.
|
|
56
59
|
func openFromClipboard() {
|
|
57
|
-
|
|
60
|
+
let (text, images) = ImageIntake.read(NSPasteboard.general)
|
|
61
|
+
open(text: text, images: images)
|
|
58
62
|
}
|
|
59
63
|
|
|
60
|
-
func open(text: String) {
|
|
64
|
+
func open(text: String, images: [PanelImage] = []) {
|
|
61
65
|
let from = sourceApp
|
|
62
|
-
panel.show(text: text, source: from?.localizedName?.lowercased() ?? "desktop", returnTo: from)
|
|
66
|
+
panel.show(text: text, images: images, source: from?.localizedName?.lowercased() ?? "desktop", returnTo: from)
|
|
63
67
|
}
|
|
64
68
|
}
|
|
65
69
|
|
|
@@ -106,7 +106,9 @@ final class ChiRunner {
|
|
|
106
106
|
/// Runs `chi <args>`, with stdin written from a background queue and
|
|
107
107
|
/// stdout/stderr drained as they come (no pipe deadlock near the 16 KiB
|
|
108
108
|
/// note cap). Calls back on the main queue.
|
|
109
|
-
|
|
109
|
+
/// @param timeout this call's limit instead of the runner's
|
|
110
|
+
func run(_ args: [String], stdin: Data? = nil, timeout: TimeInterval? = nil,
|
|
111
|
+
completion: @escaping (Result<ChiResult, ChiError>) -> Void) {
|
|
110
112
|
let config: LaunchConfig
|
|
111
113
|
do { config = try loadLaunch() } catch let error as ChiError {
|
|
112
114
|
completion(.failure(error)); return
|
|
@@ -161,7 +163,7 @@ final class ChiRunner {
|
|
|
161
163
|
try? input.fileHandleForWriting.close()
|
|
162
164
|
}
|
|
163
165
|
}
|
|
164
|
-
DispatchQueue.global().asyncAfter(deadline: .now() + timeout) {
|
|
166
|
+
DispatchQueue.global().asyncAfter(deadline: .now() + (timeout ?? self.timeout)) {
|
|
165
167
|
if process.isRunning {
|
|
166
168
|
lock.lock(); timedOut = true; lock.unlock()
|
|
167
169
|
process.terminate()
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Images for the panel: what a pasteboard (the clipboard, the Service's,
|
|
2
|
+
// a drop) carries, as files `chi send --image` can read. Image files are
|
|
3
|
+
// passed as they are; image data (a ⌃⇧⌘4 screenshot, Preview, Safari's
|
|
4
|
+
// Copy Image) is written to a temp file first, deleted after the send.
|
|
5
|
+
import AppKit
|
|
6
|
+
import UniformTypeIdentifiers
|
|
7
|
+
|
|
8
|
+
/// The Bridge's cap on one turn's images.
|
|
9
|
+
let maxImages = 20
|
|
10
|
+
|
|
11
|
+
struct PanelImage: Identifiable, Equatable {
|
|
12
|
+
let id = UUID()
|
|
13
|
+
let url: URL
|
|
14
|
+
/// Written by the helper (image data), so the helper deletes it.
|
|
15
|
+
let temp: Bool
|
|
16
|
+
let thumbnail: NSImage?
|
|
17
|
+
|
|
18
|
+
var name: String { url.lastPathComponent }
|
|
19
|
+
|
|
20
|
+
init(url: URL, temp: Bool) {
|
|
21
|
+
self.url = url
|
|
22
|
+
self.temp = temp
|
|
23
|
+
thumbnail = NSImage(contentsOf: url)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
static func == (a: PanelImage, b: PanelImage) -> Bool { a.id == b.id }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
enum ImageIntake {
|
|
30
|
+
static let tempDir = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("chi-helper", isDirectory: true)
|
|
31
|
+
|
|
32
|
+
/// A pasteboard's content for the panel, in this order: image files
|
|
33
|
+
/// (Finder ⌘C, a Service on files) with no text; else its text, as before
|
|
34
|
+
/// (an office app's copy carries a picture of the cells too: the text
|
|
35
|
+
/// wins); else image data (png, then tiff). A non-image file is skipped.
|
|
36
|
+
static func read(_ pb: NSPasteboard) -> (text: String, images: [PanelImage]) {
|
|
37
|
+
let files = imageFiles(pb)
|
|
38
|
+
if !files.isEmpty { return ("", files) }
|
|
39
|
+
if let text = pb.string(forType: .string), !text.isEmpty { return (text, []) }
|
|
40
|
+
return ("", imageData(pb))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/// A drop adds images only: text dropped on the fields is theirs.
|
|
44
|
+
static func dropped(_ pb: NSPasteboard) -> [PanelImage] {
|
|
45
|
+
let files = imageFiles(pb)
|
|
46
|
+
return files.isEmpty ? imageData(pb) : files
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
static func imageFiles(_ pb: NSPasteboard) -> [PanelImage] {
|
|
50
|
+
let urls = pb.readObjects(forClasses: [NSURL.self], options: [.urlReadingFileURLsOnly: true]) as? [URL] ?? []
|
|
51
|
+
return urls.filter(isImage).prefix(maxImages).map { PanelImage(url: $0, temp: false) }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
static func isImage(_ url: URL) -> Bool {
|
|
55
|
+
(try? url.resourceValues(forKeys: [.contentTypeKey]))?.contentType?.conforms(to: .image) == true
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
static func imageData(_ pb: NSPasteboard) -> [PanelImage] {
|
|
59
|
+
for (type, ext) in [(NSPasteboard.PasteboardType.png, "png"), (.tiff, "tiff")] {
|
|
60
|
+
guard let data = pb.data(forType: type) else { continue }
|
|
61
|
+
// A folder per image, so the name chi shows is "clipboard.png".
|
|
62
|
+
let dir = tempDir.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
|
63
|
+
do {
|
|
64
|
+
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
|
65
|
+
let url = dir.appendingPathComponent("clipboard.\(ext)")
|
|
66
|
+
try data.write(to: url)
|
|
67
|
+
return [PanelImage(url: url, temp: true)]
|
|
68
|
+
} catch {
|
|
69
|
+
return []
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return []
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
static func delete(_ images: [PanelImage]) {
|
|
76
|
+
for image in images where image.temp {
|
|
77
|
+
try? FileManager.default.removeItem(at: image.url.deletingLastPathComponent())
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/// At launch: what a helper that quit mid-send left.
|
|
82
|
+
static func sweep() {
|
|
83
|
+
try? FileManager.default.removeItem(at: tempDir)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/// The panel's background, taking dropped images anywhere on the panel.
|
|
88
|
+
final class DropEffectView: NSVisualEffectView {
|
|
89
|
+
var onDrop: (([PanelImage]) -> Void)?
|
|
90
|
+
|
|
91
|
+
override init(frame: NSRect) {
|
|
92
|
+
super.init(frame: frame)
|
|
93
|
+
registerForDraggedTypes([.fileURL, .png, .tiff])
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
required init?(coder: NSCoder) { fatalError("init(coder:) is not used") }
|
|
97
|
+
|
|
98
|
+
override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
|
|
99
|
+
carriesImages(sender.draggingPasteboard) ? .copy : []
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
override func performDragOperation(_ sender: NSDraggingInfo) -> Bool {
|
|
103
|
+
let images = ImageIntake.dropped(sender.draggingPasteboard)
|
|
104
|
+
guard !images.isEmpty else { return false }
|
|
105
|
+
onDrop?(images)
|
|
106
|
+
return true
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private func carriesImages(_ pb: NSPasteboard) -> Bool {
|
|
110
|
+
let urls = pb.readObjects(forClasses: [NSURL.self], options: [.urlReadingFileURLsOnly: true]) as? [URL] ?? []
|
|
111
|
+
return urls.contains(where: ImageIntake.isImage) || pb.availableType(from: [.png, .tiff]) != nil
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -33,6 +33,12 @@
|
|
|
33
33
|
<key>NSSendTypes</key>
|
|
34
34
|
<array>
|
|
35
35
|
<string>public.utf8-plain-text</string>
|
|
36
|
+
<string>public.png</string>
|
|
37
|
+
<string>public.tiff</string>
|
|
38
|
+
</array>
|
|
39
|
+
<key>NSSendFileTypes</key>
|
|
40
|
+
<array>
|
|
41
|
+
<string>public.image</string>
|
|
36
42
|
</array>
|
|
37
43
|
<key>NSRequiredContext</key>
|
|
38
44
|
<dict/>
|