slidict 0.2.0 → 0.3.1
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/README.md +23 -6
- data/lib/slidict/cli/app.rb +316 -0
- data/lib/slidict/cli/serve.rb +113 -0
- data/lib/slidict/cli/slides.rb +271 -0
- data/lib/slidict/external/slidict_io/auth.rb +80 -0
- data/lib/slidict/external/slidict_io/client.rb +126 -0
- data/lib/slidict/external/slidict_io/credentials.rb +43 -0
- data/lib/slidict/llm/client.rb +146 -0
- data/lib/slidict/output/format.rb +44 -0
- data/lib/slidict/output/renderer.rb +36 -0
- data/lib/slidict/version.rb +1 -1
- data/lib/slidict.rb +9 -7
- metadata +53 -9
- data/lib/slidict/auth_client.rb +0 -75
- data/lib/slidict/cli.rb +0 -266
- data/lib/slidict/credentials.rb +0 -39
- data/lib/slidict/llm_client.rb +0 -144
- data/lib/slidict/markdown_renderer.rb +0 -39
- data/lib/slidict/slides_client.rb +0 -120
- data/lib/slidict/slides_command.rb +0 -253
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slidict
|
|
4
|
+
module Cli
|
|
5
|
+
# Implements the `slidict slides <list|show|create|edit>` subcommands.
|
|
6
|
+
class Slides
|
|
7
|
+
def initialize(output:, credentials: nil, client: nil, reauthenticate: nil)
|
|
8
|
+
@output = output
|
|
9
|
+
@credentials = credentials || External::SlidictIo::Credentials.new
|
|
10
|
+
@client = client
|
|
11
|
+
@reauthenticate = reauthenticate
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def run(argv)
|
|
15
|
+
options = parse(argv)
|
|
16
|
+
return print_help if options[:help] || options[:subcommand].nil?
|
|
17
|
+
|
|
18
|
+
send(options[:subcommand], options)
|
|
19
|
+
rescue ArgumentError => e
|
|
20
|
+
@output.puts "Error: #{e.message}"
|
|
21
|
+
@output.puts
|
|
22
|
+
print_help
|
|
23
|
+
1
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Creates or edits a draft directly, bypassing argv parsing (and its
|
|
27
|
+
# "looks like a flag" checks, which would reject body text such as
|
|
28
|
+
# YAML frontmatter that happens to start with "-").
|
|
29
|
+
def publish(body:, id: nil, title: nil, body_format: nil, visibility: nil)
|
|
30
|
+
options = { id: id, title: title, body: body, body_format: body_format, visibility: visibility }
|
|
31
|
+
id ? edit(options) : create(options)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def parse(argv)
|
|
37
|
+
args = argv.dup
|
|
38
|
+
subcommand = args.shift
|
|
39
|
+
return { help: true } if subcommand.nil? || %w[-h --help].include?(subcommand)
|
|
40
|
+
|
|
41
|
+
case subcommand
|
|
42
|
+
when "list" then parse_list(args)
|
|
43
|
+
when "show" then parse_show(args)
|
|
44
|
+
when "create" then parse_create(args)
|
|
45
|
+
when "edit" then parse_edit(args)
|
|
46
|
+
else raise ArgumentError, "unknown slides command #{subcommand}"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def parse_list(args)
|
|
51
|
+
options = { subcommand: :list }
|
|
52
|
+
until args.empty?
|
|
53
|
+
case (arg = args.shift)
|
|
54
|
+
when "--page" then options[:page] = Integer(fetch_value!(args, arg))
|
|
55
|
+
when "-h", "--help" then options[:help] = true
|
|
56
|
+
else raise ArgumentError, "unknown option #{arg}"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
options
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def parse_show(args)
|
|
63
|
+
id = args.shift
|
|
64
|
+
raise ArgumentError, "show requires a slide id" if id.nil?
|
|
65
|
+
raise ArgumentError, "unknown option #{args.first}" unless args.empty?
|
|
66
|
+
|
|
67
|
+
{ subcommand: :show, id: id }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def parse_create(args)
|
|
71
|
+
options = { subcommand: :create }
|
|
72
|
+
parse_body_options!(args, options)
|
|
73
|
+
options
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def parse_edit(args)
|
|
77
|
+
id = args.shift
|
|
78
|
+
raise ArgumentError, "edit requires a slide id" if id.nil?
|
|
79
|
+
|
|
80
|
+
options = { subcommand: :edit, id: id }
|
|
81
|
+
parse_body_options!(args, options)
|
|
82
|
+
options
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def parse_body_options!(args, options)
|
|
86
|
+
until args.empty?
|
|
87
|
+
case (arg = args.shift)
|
|
88
|
+
when "--title" then options[:title] = fetch_value!(args, arg)
|
|
89
|
+
when "--body" then options[:body] = fetch_value!(args, arg)
|
|
90
|
+
when "--file" then options[:file] = fetch_value!(args, arg)
|
|
91
|
+
when "--body-format" then options[:body_format] = fetch_value!(args, arg)
|
|
92
|
+
when "--visibility" then options[:visibility] = fetch_value!(args, arg)
|
|
93
|
+
when "-h", "--help" then options[:help] = true
|
|
94
|
+
else raise ArgumentError, "unknown option #{arg}"
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
raise ArgumentError, "specify only one of --body or --file" if options[:body] && options[:file]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Does not reject values starting with "-": body text or titles may
|
|
101
|
+
# legitimately start with a dash (e.g. YAML frontmatter), and a genuinely
|
|
102
|
+
# missing value still surfaces as an "unknown option" error from the
|
|
103
|
+
# next iteration of the parse loop, or a nil check below.
|
|
104
|
+
def fetch_value!(args, option)
|
|
105
|
+
value = args.shift
|
|
106
|
+
raise ArgumentError, "#{option} requires a value" if value.nil?
|
|
107
|
+
|
|
108
|
+
value
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def list(options)
|
|
112
|
+
with_reauth_retry do
|
|
113
|
+
print_slide_list(client.list(page: options[:page]))
|
|
114
|
+
0
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def show(options)
|
|
119
|
+
with_reauth_retry do
|
|
120
|
+
print_slide_detail(client.show(options[:id]))
|
|
121
|
+
0
|
|
122
|
+
rescue External::SlidictIo::Client::NotFound
|
|
123
|
+
@output.puts "Error: slide not found"
|
|
124
|
+
1
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def create(options)
|
|
129
|
+
body = read_body(options)
|
|
130
|
+
raise ArgumentError, "create requires --body or --file" if body.to_s.strip.empty?
|
|
131
|
+
|
|
132
|
+
submit("Created") do
|
|
133
|
+
client.create(title: options[:title], body: body, body_format: options[:body_format],
|
|
134
|
+
visibility: options[:visibility])
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def edit(options)
|
|
139
|
+
submit("Updated") do
|
|
140
|
+
client.update(options[:id], title: options[:title], body: read_body(options),
|
|
141
|
+
body_format: options[:body_format], visibility: options[:visibility])
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def submit(verb)
|
|
146
|
+
with_reauth_retry do
|
|
147
|
+
slide = yield
|
|
148
|
+
@output.puts "#{verb} slide ##{slide["id"]} (draft)"
|
|
149
|
+
print_slide_detail(slide)
|
|
150
|
+
0
|
|
151
|
+
rescue External::SlidictIo::Client::NotFound
|
|
152
|
+
@output.puts "Error: slide not found"
|
|
153
|
+
1
|
|
154
|
+
rescue External::SlidictIo::Client::NotEditable
|
|
155
|
+
@output.puts "Error: this slide is already published. Edit it from the Web UI instead."
|
|
156
|
+
1
|
|
157
|
+
rescue External::SlidictIo::Client::RateLimited
|
|
158
|
+
print_rate_limited
|
|
159
|
+
rescue External::SlidictIo::Client::Unprocessable => e
|
|
160
|
+
print_unprocessable(e)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Shared by list/show/submit: on a 401 from an expired/invalid token,
|
|
165
|
+
# silently re-authenticate once (via the injected reauthenticate flow)
|
|
166
|
+
# and retry the same request before giving up.
|
|
167
|
+
def with_reauth_retry
|
|
168
|
+
reauthenticated = false
|
|
169
|
+
begin
|
|
170
|
+
yield
|
|
171
|
+
rescue External::SlidictIo::Client::Unauthorized => e
|
|
172
|
+
if !reauthenticated && reauthenticate!
|
|
173
|
+
reauthenticated = true
|
|
174
|
+
retry
|
|
175
|
+
end
|
|
176
|
+
print_client_error(e)
|
|
177
|
+
rescue External::SlidictIo::Client::Error => e
|
|
178
|
+
print_client_error(e)
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def read_body(options)
|
|
183
|
+
return options[:body] unless options[:file]
|
|
184
|
+
|
|
185
|
+
File.read(options[:file])
|
|
186
|
+
rescue Errno::ENOENT, Errno::EACCES => e
|
|
187
|
+
raise ArgumentError, "could not read #{options[:file]}: #{e.message}"
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def client
|
|
191
|
+
@client ||= begin
|
|
192
|
+
token = @credentials.read_cli_token
|
|
193
|
+
token = @credentials.read_cli_token if token.nil? && reauthenticate!
|
|
194
|
+
raise ArgumentError, "not authenticated. Run `slidict auth` first." unless token
|
|
195
|
+
|
|
196
|
+
External::SlidictIo::Client.new(access_token: token[:access_token], token_type: token[:token_type])
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Triggers the injected login flow (see Slidict::Cli::App#auth) and clears the
|
|
201
|
+
# memoized client so the next call to `client` picks up the fresh token.
|
|
202
|
+
def reauthenticate!
|
|
203
|
+
return false unless @reauthenticate
|
|
204
|
+
|
|
205
|
+
@client = nil
|
|
206
|
+
@reauthenticate.call.zero?
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def print_slide_list(result)
|
|
210
|
+
slides = result["slides"] || []
|
|
211
|
+
if slides.empty?
|
|
212
|
+
@output.puts "No slides found."
|
|
213
|
+
return
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
slides.each do |slide|
|
|
217
|
+
@output.puts "##{slide["id"]} [#{slide["status"]}/#{slide["visibility"]}] " \
|
|
218
|
+
"#{slide["title"]} (updated_at: #{slide["updated_at"]})"
|
|
219
|
+
end
|
|
220
|
+
@output.puts "(more slides available, use --page to see the next page)" if result["has_more"]
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def print_slide_detail(slide)
|
|
224
|
+
@output.puts "##{slide["id"]} #{slide["title"]}"
|
|
225
|
+
@output.puts "status: #{slide["status"]} visibility: #{slide["visibility"]} " \
|
|
226
|
+
"updated_at: #{slide["updated_at"]}"
|
|
227
|
+
@output.puts
|
|
228
|
+
@output.puts slide["body"]
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def print_rate_limited
|
|
232
|
+
@output.puts "Error: rate limited. Create/edit is limited to once per minute. Wait and try again."
|
|
233
|
+
1
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def print_unprocessable(error)
|
|
237
|
+
@output.puts "Error: #{error.message}"
|
|
238
|
+
error.errors.each { |message| @output.puts " - #{message}" }
|
|
239
|
+
1
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def print_client_error(error)
|
|
243
|
+
@output.puts "Error: #{error.message}"
|
|
244
|
+
1
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def print_help
|
|
248
|
+
@output.puts <<~HELP
|
|
249
|
+
Usage: slidict slides <command> [options]
|
|
250
|
+
|
|
251
|
+
Commands:
|
|
252
|
+
list [--page N] List your slides (20 per page)
|
|
253
|
+
show <id> Show a slide's details and body
|
|
254
|
+
create [options] Create a new draft slide
|
|
255
|
+
edit <id> [options] Edit an existing draft slide
|
|
256
|
+
|
|
257
|
+
Create/edit options:
|
|
258
|
+
--title TEXT Slide title
|
|
259
|
+
--body TEXT Slide body text
|
|
260
|
+
--file PATH Read the slide body from a file (instead of --body)
|
|
261
|
+
--body-format FORMAT asciidoc or markdown (default: auto-detected from body)
|
|
262
|
+
--visibility VIS public, unlisted, or group_only (default: public)
|
|
263
|
+
-h, --help Show this help
|
|
264
|
+
|
|
265
|
+
Note: slides are always created/edited as drafts. Publish from the Web UI.
|
|
266
|
+
HELP
|
|
267
|
+
0
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module Slidict
|
|
8
|
+
module External
|
|
9
|
+
module SlidictIo
|
|
10
|
+
class Auth
|
|
11
|
+
Error = Class.new(StandardError)
|
|
12
|
+
Pending = Class.new(StandardError)
|
|
13
|
+
|
|
14
|
+
DEFAULT_BASE_URL = "https://slidict.io"
|
|
15
|
+
|
|
16
|
+
attr_reader :base_url
|
|
17
|
+
|
|
18
|
+
def initialize(base_url: ENV.fetch("SLIDICT_AUTH_BASE_URL", DEFAULT_BASE_URL))
|
|
19
|
+
@base_url = base_url
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def request_device_code
|
|
23
|
+
response = post_json("/api/cli/device/code", { provider: "github" })
|
|
24
|
+
{
|
|
25
|
+
device_code: fetch!(response, "device_code"),
|
|
26
|
+
user_code: fetch!(response, "user_code"),
|
|
27
|
+
verification_uri: response["verification_uri"] || "#{base_url}/cli/activate",
|
|
28
|
+
interval: response.fetch("interval", 5).to_i,
|
|
29
|
+
expires_in: response.fetch("expires_in", 600).to_i
|
|
30
|
+
}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def poll_token(device_code:)
|
|
34
|
+
response = post_json(
|
|
35
|
+
"/api/cli/device/token",
|
|
36
|
+
{ device_code: device_code, grant_type: "urn:ietf:params:oauth:grant-type:device_code" },
|
|
37
|
+
raise_on_http_error: false
|
|
38
|
+
)
|
|
39
|
+
return response if response["access_token"]
|
|
40
|
+
|
|
41
|
+
error = response["error"].to_s
|
|
42
|
+
raise Pending if %w[authorization_pending slow_down].include?(error)
|
|
43
|
+
|
|
44
|
+
raise Error, response["error_description"] || error unless error.empty?
|
|
45
|
+
|
|
46
|
+
raise Error, "token response did not include access_token"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def post_json(path, payload, raise_on_http_error: true)
|
|
52
|
+
uri = URI.join(base_url, path)
|
|
53
|
+
request = Net::HTTP::Post.new(uri)
|
|
54
|
+
request["Accept"] = "application/json"
|
|
55
|
+
request["Content-Type"] = "application/json"
|
|
56
|
+
request.body = JSON.generate(payload)
|
|
57
|
+
|
|
58
|
+
response = Net::HTTP.start(uri.hostname, uri.port,
|
|
59
|
+
use_ssl: uri.scheme == "https", open_timeout: 5, read_timeout: 30) do |http|
|
|
60
|
+
http.request(request)
|
|
61
|
+
end
|
|
62
|
+
body = response.body.to_s.empty? ? {} : JSON.parse(response.body)
|
|
63
|
+
return body if response.is_a?(Net::HTTPSuccess) || !raise_on_http_error
|
|
64
|
+
|
|
65
|
+
raise Error, body["error_description"] || body["error"] || "HTTP #{response.code}"
|
|
66
|
+
rescue JSON::ParserError => e
|
|
67
|
+
raise Error, "invalid JSON response: #{e.message}"
|
|
68
|
+
rescue SystemCallError, Timeout::Error => e
|
|
69
|
+
raise Error, e.message
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def fetch!(hash, key)
|
|
73
|
+
hash.fetch(key)
|
|
74
|
+
rescue KeyError
|
|
75
|
+
raise Error, "device code response did not include #{key}"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module Slidict
|
|
8
|
+
module External
|
|
9
|
+
module SlidictIo
|
|
10
|
+
# Talks to the slidict.io CLI slides API using a CLI access token
|
|
11
|
+
# (obtained via Slidict::External::SlidictIo::Auth / `slidict auth`).
|
|
12
|
+
class Client
|
|
13
|
+
class Error < StandardError; end
|
|
14
|
+
class Unauthorized < Error; end
|
|
15
|
+
class Forbidden < Error; end
|
|
16
|
+
class NotFound < Error; end
|
|
17
|
+
class NotEditable < Error; end
|
|
18
|
+
class RateLimited < Error; end
|
|
19
|
+
|
|
20
|
+
# Raised for 422 responses other than "not_editable", carrying the API's validation errors.
|
|
21
|
+
class Unprocessable < Error
|
|
22
|
+
attr_reader :errors
|
|
23
|
+
|
|
24
|
+
def initialize(message, errors: [])
|
|
25
|
+
super(message)
|
|
26
|
+
@errors = errors
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
DEFAULT_BASE_URL = "https://slidict.io"
|
|
31
|
+
|
|
32
|
+
attr_reader :base_url
|
|
33
|
+
|
|
34
|
+
def initialize(access_token:, token_type: "Bearer",
|
|
35
|
+
base_url: ENV.fetch("SLIDICT_AUTH_BASE_URL", DEFAULT_BASE_URL))
|
|
36
|
+
@access_token = access_token
|
|
37
|
+
@token_type = token_type
|
|
38
|
+
@base_url = base_url
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def list(page: nil)
|
|
42
|
+
get_json("/api/cli/slides", query: page ? { page: page } : {})
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def show(id)
|
|
46
|
+
get_json("/api/cli/slides/#{id}")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def create(body:, title: nil, body_format: nil, visibility: nil)
|
|
50
|
+
post_json("/api/cli/slides",
|
|
51
|
+
slide_payload(title: title, body: body, body_format: body_format, visibility: visibility))
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def update(id, body: nil, title: nil, body_format: nil, visibility: nil)
|
|
55
|
+
patch_json("/api/cli/slides/#{id}",
|
|
56
|
+
slide_payload(title: title, body: body, body_format: body_format, visibility: visibility))
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def slide_payload(title:, body:, body_format:, visibility:)
|
|
62
|
+
{ title: title, body: body, body_format: body_format, visibility: visibility }.compact
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def get_json(path, query: {})
|
|
66
|
+
uri = URI.join(base_url, path)
|
|
67
|
+
uri.query = URI.encode_www_form(query) unless query.empty?
|
|
68
|
+
perform(uri, Net::HTTP::Get.new(uri))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def post_json(path, payload)
|
|
72
|
+
uri = URI.join(base_url, path)
|
|
73
|
+
perform(uri, build_request(Net::HTTP::Post.new(uri), payload))
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def patch_json(path, payload)
|
|
77
|
+
uri = URI.join(base_url, path)
|
|
78
|
+
perform(uri, build_request(Net::HTTP::Patch.new(uri), payload))
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def build_request(request, payload)
|
|
82
|
+
request["Content-Type"] = "application/json"
|
|
83
|
+
request.body = JSON.generate(payload)
|
|
84
|
+
request
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def perform(uri, request)
|
|
88
|
+
request["Accept"] = "application/json"
|
|
89
|
+
request["Authorization"] = "#{@token_type} #{@access_token}"
|
|
90
|
+
|
|
91
|
+
response = Net::HTTP.start(uri.hostname, uri.port,
|
|
92
|
+
use_ssl: uri.scheme == "https", open_timeout: 5, read_timeout: 30) do |http|
|
|
93
|
+
http.request(request)
|
|
94
|
+
end
|
|
95
|
+
body = response.body.to_s.empty? ? {} : JSON.parse(response.body)
|
|
96
|
+
handle_response(response, body)
|
|
97
|
+
rescue JSON::ParserError => e
|
|
98
|
+
raise Error, "invalid JSON response: #{e.message}"
|
|
99
|
+
rescue SystemCallError, Timeout::Error => e
|
|
100
|
+
raise Error, e.message
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
ERROR_CLASSES_BY_STATUS = {
|
|
104
|
+
"401" => Unauthorized,
|
|
105
|
+
"403" => Forbidden,
|
|
106
|
+
"404" => NotFound,
|
|
107
|
+
"429" => RateLimited
|
|
108
|
+
}.freeze
|
|
109
|
+
|
|
110
|
+
def handle_response(response, body)
|
|
111
|
+
return body if response.is_a?(Net::HTTPSuccess)
|
|
112
|
+
return raise_unprocessable(body) if response.code == "422"
|
|
113
|
+
|
|
114
|
+
error_class = ERROR_CLASSES_BY_STATUS.fetch(response.code, Error)
|
|
115
|
+
raise error_class, body["error_description"] || body["error"] || "HTTP #{response.code}"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def raise_unprocessable(body)
|
|
119
|
+
raise NotEditable, "not_editable" if body["error"] == "not_editable"
|
|
120
|
+
|
|
121
|
+
raise Unprocessable.new(body["error"] || "unprocessable", errors: Array(body["errors"]))
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Slidict
|
|
7
|
+
module External
|
|
8
|
+
module SlidictIo
|
|
9
|
+
class Credentials
|
|
10
|
+
DEFAULT_PATH = File.expand_path("~/.config/slidict/credentials.json")
|
|
11
|
+
|
|
12
|
+
def initialize(path: DEFAULT_PATH)
|
|
13
|
+
@path = path
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def write_cli_token!(access_token:, token_type: "Bearer", provider: "github")
|
|
17
|
+
FileUtils.mkdir_p(File.dirname(@path), mode: 0o700)
|
|
18
|
+
json = JSON.pretty_generate(
|
|
19
|
+
"access_token" => access_token,
|
|
20
|
+
"token_type" => token_type,
|
|
21
|
+
"provider" => provider,
|
|
22
|
+
"kind" => "cli_access_token"
|
|
23
|
+
)
|
|
24
|
+
File.write(@path, "#{json}\n", mode: "w", perm: 0o600)
|
|
25
|
+
File.chmod(0o600, @path)
|
|
26
|
+
@path
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Returns { access_token:, token_type: } or nil if no CLI access token is saved.
|
|
30
|
+
def read_cli_token
|
|
31
|
+
return nil unless File.exist?(@path)
|
|
32
|
+
|
|
33
|
+
data = JSON.parse(File.read(@path))
|
|
34
|
+
return nil unless data["kind"] == "cli_access_token" && data["access_token"]
|
|
35
|
+
|
|
36
|
+
{ access_token: data["access_token"], token_type: data.fetch("token_type", "Bearer") }
|
|
37
|
+
rescue JSON::ParserError
|
|
38
|
+
nil
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module Slidict
|
|
8
|
+
module Llm
|
|
9
|
+
# Talks to any OpenAI Compatible API (OpenAI, Ollama, LM Studio, vLLM, etc.)
|
|
10
|
+
# via the standard /chat/completions endpoint. Configure the target with
|
|
11
|
+
# Slidict::Config (base_url, api_key, model).
|
|
12
|
+
class Client
|
|
13
|
+
class Error < StandardError; end
|
|
14
|
+
|
|
15
|
+
def initialize(base_url:, api_key:, model:)
|
|
16
|
+
@base_url = base_url
|
|
17
|
+
@api_key = api_key
|
|
18
|
+
@model = model
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Checks that the endpoint is reachable before the (slower, more
|
|
22
|
+
# expensive) chat completion request is made. Raises Error on failure.
|
|
23
|
+
def verify_connection!
|
|
24
|
+
uri = endpoint_uri("models")
|
|
25
|
+
request = Net::HTTP::Get.new(uri)
|
|
26
|
+
request["Authorization"] = "Bearer #{@api_key}"
|
|
27
|
+
response = perform_request(uri, request)
|
|
28
|
+
|
|
29
|
+
raise Error, "#{response.code} #{response.message}" unless response.is_a?(Net::HTTPSuccess)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def generate_slides(deck)
|
|
33
|
+
content = chat_completion(prompt_for(deck))
|
|
34
|
+
slides_from(content)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def prompt_for(deck)
|
|
40
|
+
<<~PROMPT
|
|
41
|
+
You are an assistant that designs presentation slide outlines.
|
|
42
|
+
Topic: #{deck.topic}
|
|
43
|
+
Duration: #{deck.duration}
|
|
44
|
+
Audience: #{deck.audience}
|
|
45
|
+
Goal: #{deck.goal}
|
|
46
|
+
|
|
47
|
+
Return exactly 5 slides as a JSON array. Each item must be an object with
|
|
48
|
+
a "title" string and a "bullets" array of 2-4 short strings.
|
|
49
|
+
Respond with the JSON array only: no commentary, no markdown code fences,
|
|
50
|
+
and no reasoning or thinking content before or after it.
|
|
51
|
+
PROMPT
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def chat_completion(prompt)
|
|
55
|
+
response = JSON.parse(post_chat_completion(prompt))
|
|
56
|
+
content = response.dig("choices", 0, "message", "content")
|
|
57
|
+
raise Error, "empty response from model" if content.to_s.strip.empty?
|
|
58
|
+
|
|
59
|
+
content
|
|
60
|
+
rescue JSON::ParserError => e
|
|
61
|
+
raise Error, "could not parse model response: #{e.message}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def post_chat_completion(prompt)
|
|
65
|
+
uri = endpoint_uri("chat/completions")
|
|
66
|
+
request = build_request(uri, prompt)
|
|
67
|
+
response = perform_request(uri, request)
|
|
68
|
+
|
|
69
|
+
raise Error, "#{response.code} #{response.message}" unless response.is_a?(Net::HTTPSuccess)
|
|
70
|
+
|
|
71
|
+
response.body
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def perform_request(uri, request)
|
|
75
|
+
Net::HTTP.start(uri.host, uri.port,
|
|
76
|
+
use_ssl: uri.scheme == "https",
|
|
77
|
+
open_timeout: 5,
|
|
78
|
+
read_timeout: 30,
|
|
79
|
+
write_timeout: 30) do |http|
|
|
80
|
+
http.request(request)
|
|
81
|
+
end
|
|
82
|
+
rescue StandardError => e
|
|
83
|
+
raise Error, e.message
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def endpoint_uri(path)
|
|
87
|
+
base = @base_url.to_s.sub(%r{/+\z}, "")
|
|
88
|
+
URI.join("#{base}/", path)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def build_request(uri, prompt)
|
|
92
|
+
request = Net::HTTP::Post.new(uri)
|
|
93
|
+
request["Content-Type"] = "application/json"
|
|
94
|
+
request["Authorization"] = "Bearer #{@api_key}"
|
|
95
|
+
request.body = JSON.generate(
|
|
96
|
+
model: @model,
|
|
97
|
+
messages: [{ role: "user", content: prompt }],
|
|
98
|
+
temperature: 0.7
|
|
99
|
+
)
|
|
100
|
+
request
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def slides_from(content)
|
|
104
|
+
parsed = JSON.parse(extract_json_array(content))
|
|
105
|
+
raise Error, "expected a JSON array of slides" unless parsed.is_a?(Array)
|
|
106
|
+
|
|
107
|
+
parsed.map do |item|
|
|
108
|
+
Slide.new(title: item.fetch("title"), bullets: Array(item.fetch("bullets")))
|
|
109
|
+
end
|
|
110
|
+
rescue JSON::ParserError, KeyError => e
|
|
111
|
+
raise Error, "could not parse model response: #{e.message}"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Some models (especially reasoning models served through LM Studio or
|
|
115
|
+
# Ollama) prepend or append thinking/reasoning text around the JSON
|
|
116
|
+
# answer instead of returning it verbatim, so the array is extracted from
|
|
117
|
+
# within the raw content rather than parsed as-is.
|
|
118
|
+
def extract_json_array(content)
|
|
119
|
+
content.enum_for(:scan, /\[/).each do
|
|
120
|
+
start = Regexp.last_match.begin(0)
|
|
121
|
+
candidate = json_array_from(content, start)
|
|
122
|
+
return candidate if candidate
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
raise Error, "no JSON array found in model response"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def json_array_from(content, start)
|
|
129
|
+
finish = start
|
|
130
|
+
while (finish = content.index("]", finish))
|
|
131
|
+
candidate = content[start..finish]
|
|
132
|
+
return candidate if parses_to_array?(candidate)
|
|
133
|
+
|
|
134
|
+
finish += 1
|
|
135
|
+
end
|
|
136
|
+
nil
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def parses_to_array?(candidate)
|
|
140
|
+
JSON.parse(candidate).is_a?(Array)
|
|
141
|
+
rescue JSON::ParserError
|
|
142
|
+
false
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|