llm_meta_widget 0.1.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 +7 -0
- data/LICENSE +201 -0
- data/README.md +173 -0
- data/Rakefile +11 -0
- data/app/assets/javascripts/llm_meta_widget/marked.esm.js +2432 -0
- data/app/assets/javascripts/llm_meta_widget/orchestrator.js +717 -0
- data/app/assets/stylesheets/llm_meta_widget/conversation.css +183 -0
- data/app/controllers/llm_meta_widget/assets_controller.rb +62 -0
- data/app/helpers/llm_meta_widget/widget_helper.rb +46 -0
- data/app/views/llm_meta_widget/_chat_panel.html.erb +962 -0
- data/config/routes.rb +21 -0
- data/lib/llm_meta_widget/engine.rb +13 -0
- data/lib/llm_meta_widget/version.rb +3 -0
- data/lib/llm_meta_widget.rb +5 -0
- metadata +72 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Canonical "chat conversation" styles for the llm_meta ecosystem.
|
|
3
|
+
*
|
|
4
|
+
* Owned by the llm_meta_widget gem. Consumed by:
|
|
5
|
+
* - the widget partial itself (llm_meta_widget/_chat_panel.html.erb)
|
|
6
|
+
* - any llm_meta_client-scaffolded chat host that adopts the same
|
|
7
|
+
* conversation surface
|
|
8
|
+
*
|
|
9
|
+
* Scoped by the `.llm-meta-conversation` opt-in class on a wrapping
|
|
10
|
+
* element — so these rules only affect elements the consumer has
|
|
11
|
+
* declared as chat-conversation surfaces. Prevents accidental style
|
|
12
|
+
* bleed onto random host-page elements named .message or .message-role.
|
|
13
|
+
*
|
|
14
|
+
* Any host that wants the styles applied wraps its conversation
|
|
15
|
+
* markup in <div class="llm-meta-conversation"> ... </div> and includes
|
|
16
|
+
* this file (served via /llm_meta_widget_assets/conversation.css).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
.llm-meta-conversation .message {
|
|
20
|
+
position: relative;
|
|
21
|
+
margin-bottom: 6px;
|
|
22
|
+
padding: 6px 10px;
|
|
23
|
+
border-radius: 8px;
|
|
24
|
+
max-width: 85%;
|
|
25
|
+
line-height: 1.4;
|
|
26
|
+
word-wrap: break-word;
|
|
27
|
+
}
|
|
28
|
+
.llm-meta-conversation .message.user {
|
|
29
|
+
background-color: #dbeafe;
|
|
30
|
+
border: 1px solid #bfdbfe;
|
|
31
|
+
margin-left: auto;
|
|
32
|
+
}
|
|
33
|
+
.llm-meta-conversation .message.assistant {
|
|
34
|
+
background-color: #ffffff;
|
|
35
|
+
border: 1px solid #e5e7eb;
|
|
36
|
+
margin-right: auto;
|
|
37
|
+
}
|
|
38
|
+
.llm-meta-conversation .message.system {
|
|
39
|
+
background-color: #fef3c7;
|
|
40
|
+
border: 1px solid #fde68a;
|
|
41
|
+
color: #78350f;
|
|
42
|
+
font-size: 0.9em;
|
|
43
|
+
font-style: italic;
|
|
44
|
+
}
|
|
45
|
+
.llm-meta-conversation .message.error {
|
|
46
|
+
background-color: #fee2e2;
|
|
47
|
+
border: 1px solid #fecaca;
|
|
48
|
+
color: #991b1b;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.llm-meta-conversation .message-role {
|
|
52
|
+
display: block;
|
|
53
|
+
font-weight: 600;
|
|
54
|
+
font-size: 13px;
|
|
55
|
+
color: #374151;
|
|
56
|
+
margin-bottom: 4px;
|
|
57
|
+
}
|
|
58
|
+
.llm-meta-conversation .message-content {
|
|
59
|
+
line-height: 1.4;
|
|
60
|
+
word-wrap: break-word;
|
|
61
|
+
overflow-wrap: anywhere;
|
|
62
|
+
min-width: 0;
|
|
63
|
+
color: #1f2937;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/* Markdown-rendered content styling — matches llm_meta_chat's
|
|
67
|
+
* .message-content descendants in chats.css so LLM output looks the
|
|
68
|
+
* same wherever it renders (widget's streaming path uses marked.esm.js;
|
|
69
|
+
* chat host's saved-message path uses Redcarpet server-side; both
|
|
70
|
+
* produce standard HTML that these rules style). */
|
|
71
|
+
.llm-meta-conversation .message-content p { margin: 0 0 0.5em 0; }
|
|
72
|
+
.llm-meta-conversation .message-content p:last-child { margin-bottom: 0; }
|
|
73
|
+
.llm-meta-conversation .message-content h1,
|
|
74
|
+
.llm-meta-conversation .message-content h2,
|
|
75
|
+
.llm-meta-conversation .message-content h3,
|
|
76
|
+
.llm-meta-conversation .message-content h4,
|
|
77
|
+
.llm-meta-conversation .message-content h5,
|
|
78
|
+
.llm-meta-conversation .message-content h6 {
|
|
79
|
+
margin: 0.5em 0 0.25em 0;
|
|
80
|
+
line-height: 1.25;
|
|
81
|
+
}
|
|
82
|
+
.llm-meta-conversation .message-content ul,
|
|
83
|
+
.llm-meta-conversation .message-content ol {
|
|
84
|
+
margin: 0.25em 0 0.5em 1.25em;
|
|
85
|
+
padding: 0;
|
|
86
|
+
}
|
|
87
|
+
.llm-meta-conversation .message-content li { margin: 0.1em 0; }
|
|
88
|
+
.llm-meta-conversation .message-content blockquote {
|
|
89
|
+
border-left: 3px solid #d1d5db;
|
|
90
|
+
margin: 0.5em 0;
|
|
91
|
+
padding: 0.1em 0.75em;
|
|
92
|
+
color: #4b5563;
|
|
93
|
+
}
|
|
94
|
+
.llm-meta-conversation .message-content code {
|
|
95
|
+
background-color: #f3f4f6;
|
|
96
|
+
padding: 1px 4px;
|
|
97
|
+
border-radius: 4px;
|
|
98
|
+
font-size: 0.9em;
|
|
99
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
100
|
+
}
|
|
101
|
+
.llm-meta-conversation .message-content pre {
|
|
102
|
+
background-color: #f3f4f6;
|
|
103
|
+
border: 1px solid #e5e7eb;
|
|
104
|
+
border-radius: 6px;
|
|
105
|
+
padding: 8px 10px;
|
|
106
|
+
margin: 0.5em 0;
|
|
107
|
+
overflow-x: auto;
|
|
108
|
+
max-width: 100%;
|
|
109
|
+
white-space: pre;
|
|
110
|
+
}
|
|
111
|
+
.llm-meta-conversation .message-content pre code { background: none; padding: 0; }
|
|
112
|
+
.llm-meta-conversation .message-content table {
|
|
113
|
+
border-collapse: collapse;
|
|
114
|
+
margin: 0.5em 0;
|
|
115
|
+
display: block;
|
|
116
|
+
overflow-x: auto;
|
|
117
|
+
max-width: 100%;
|
|
118
|
+
}
|
|
119
|
+
.llm-meta-conversation .message-content th,
|
|
120
|
+
.llm-meta-conversation .message-content td {
|
|
121
|
+
border: 1px solid #d1d5db;
|
|
122
|
+
padding: 4px 8px;
|
|
123
|
+
}
|
|
124
|
+
.llm-meta-conversation .message-content th { background-color: #f3f4f6; }
|
|
125
|
+
.llm-meta-conversation .message-content a {
|
|
126
|
+
color: #2563eb;
|
|
127
|
+
text-decoration: underline;
|
|
128
|
+
}
|
|
129
|
+
.llm-meta-conversation .message-content img { max-width: 100%; height: auto; }
|
|
130
|
+
|
|
131
|
+
/* Tool-use footer inside an assistant bubble — compact chips naming the
|
|
132
|
+
* tools the LLM invoked this turn. Gives users visual confirmation of
|
|
133
|
+
* what actually happened, particularly when the LLM's own text is thin. */
|
|
134
|
+
.llm-meta-conversation .lmw-tool-chips {
|
|
135
|
+
margin-top: 0.5em;
|
|
136
|
+
padding-top: 0.5em;
|
|
137
|
+
border-top: 1px dashed #e5e7eb;
|
|
138
|
+
display: flex; flex-wrap: wrap; gap: 4px;
|
|
139
|
+
}
|
|
140
|
+
.llm-meta-conversation .lmw-tool-chip {
|
|
141
|
+
display: inline-block;
|
|
142
|
+
padding: 2px 8px;
|
|
143
|
+
background: #eef2ff;
|
|
144
|
+
color: #3730a3;
|
|
145
|
+
border: 1px solid #c7d2fe;
|
|
146
|
+
border-radius: 10px;
|
|
147
|
+
font-size: 0.8em;
|
|
148
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
149
|
+
line-height: 1.4;
|
|
150
|
+
}
|
|
151
|
+
.llm-meta-conversation .lmw-tool-chip.error {
|
|
152
|
+
background: #fee2e2;
|
|
153
|
+
color: #991b1b;
|
|
154
|
+
border-color: #fecaca;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/* Thinking-token collapsible block — same shape as llm_meta_chat's
|
|
158
|
+
* .message-thinking. Streams during reasoning, collapses to a
|
|
159
|
+
* "(finished)" summary once text starts flowing. */
|
|
160
|
+
.llm-meta-conversation .message-thinking {
|
|
161
|
+
margin: 0.5em 0;
|
|
162
|
+
padding: 0.5em 0.75em;
|
|
163
|
+
background: #f3f4f6;
|
|
164
|
+
border-left: 3px solid #9ca3af;
|
|
165
|
+
border-radius: 4px;
|
|
166
|
+
font-size: 0.85em;
|
|
167
|
+
}
|
|
168
|
+
.llm-meta-conversation .message-thinking summary {
|
|
169
|
+
cursor: pointer;
|
|
170
|
+
color: #6b7280;
|
|
171
|
+
font-weight: 500;
|
|
172
|
+
user-select: none;
|
|
173
|
+
}
|
|
174
|
+
.llm-meta-conversation .message-thinking-content {
|
|
175
|
+
margin-top: 0.5em;
|
|
176
|
+
color: #4b5563;
|
|
177
|
+
white-space: pre-wrap;
|
|
178
|
+
font-family: ui-monospace, "SF Mono", monospace;
|
|
179
|
+
font-size: 0.9em;
|
|
180
|
+
line-height: 1.5;
|
|
181
|
+
max-height: 10em;
|
|
182
|
+
overflow-y: auto;
|
|
183
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
require "action_controller/base"
|
|
2
|
+
|
|
3
|
+
module LlmMetaWidget
|
|
4
|
+
# Serves the widget's JS module from the gem's disk. Bypasses the host's
|
|
5
|
+
# asset pipeline (Sprockets vs Propshaft compatibility) — same wire URL
|
|
6
|
+
# on every host that installs the gem. The widget partial references
|
|
7
|
+
# this route via <%= llm_meta_widget_orchestrator_path %>.
|
|
8
|
+
class AssetsController < ActionController::Base
|
|
9
|
+
# This action serves a public read-only JS module intended to be
|
|
10
|
+
# <script src="…">-embedded on any page. Rails' default
|
|
11
|
+
# RequestForgeryProtection includes a cross-origin JS check that
|
|
12
|
+
# raises `InvalidCrossOriginRequest` on such loads whenever the
|
|
13
|
+
# request's Referer is missing or from a "different" origin — even
|
|
14
|
+
# same-app-same-host requests can trigger it in dev. Disable both
|
|
15
|
+
# CSRF and that check for this controller: the endpoint has no
|
|
16
|
+
# side effects and no authenticated context to protect.
|
|
17
|
+
skip_forgery_protection
|
|
18
|
+
|
|
19
|
+
ORCHESTRATOR_PATH = File.expand_path("../../assets/javascripts/llm_meta_widget/orchestrator.js", __dir__).freeze
|
|
20
|
+
ORCHESTRATOR_BODY = File.read(ORCHESTRATOR_PATH).freeze
|
|
21
|
+
ORCHESTRATOR_ETAG = %("#{Digest::SHA1.hexdigest(ORCHESTRATOR_BODY)}").freeze
|
|
22
|
+
|
|
23
|
+
CONVERSATION_CSS_PATH = File.expand_path("../../assets/stylesheets/llm_meta_widget/conversation.css", __dir__).freeze
|
|
24
|
+
CONVERSATION_CSS_BODY = File.read(CONVERSATION_CSS_PATH).freeze
|
|
25
|
+
CONVERSATION_CSS_ETAG = %("#{Digest::SHA1.hexdigest(CONVERSATION_CSS_BODY)}").freeze
|
|
26
|
+
|
|
27
|
+
# Vendored marked v12 ESM — client-side markdown renderer used by the
|
|
28
|
+
# widget for streaming assistant responses (llm_meta_chat renders
|
|
29
|
+
# server-side via Redcarpet on saved messages; the widget streams
|
|
30
|
+
# deltas, so it needs a client-side renderer). Sourced from
|
|
31
|
+
# https://cdn.jsdelivr.net/npm/marked@12/lib/marked.esm.js.
|
|
32
|
+
MARKED_PATH = File.expand_path("../../assets/javascripts/llm_meta_widget/marked.esm.js", __dir__).freeze
|
|
33
|
+
MARKED_BODY = File.read(MARKED_PATH).freeze
|
|
34
|
+
MARKED_ETAG = %("#{Digest::SHA1.hexdigest(MARKED_BODY)}").freeze
|
|
35
|
+
|
|
36
|
+
def orchestrator
|
|
37
|
+
serve_asset(ORCHESTRATOR_BODY, ORCHESTRATOR_ETAG, "text/javascript")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def conversation_css
|
|
41
|
+
serve_asset(CONVERSATION_CSS_BODY, CONVERSATION_CSS_ETAG, "text/css")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def marked
|
|
45
|
+
serve_asset(MARKED_BODY, MARKED_ETAG, "text/javascript")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
# Weak-etag revalidation — browsers cache the body but revalidate on
|
|
51
|
+
# each page load so a gem update propagates without user hard-reloads.
|
|
52
|
+
def serve_asset(body, etag, mime)
|
|
53
|
+
response.set_header("ETag", etag)
|
|
54
|
+
if request.headers["If-None-Match"] == etag
|
|
55
|
+
head :not_modified
|
|
56
|
+
else
|
|
57
|
+
response.set_header("Cache-Control", "public, must-revalidate, max-age=0")
|
|
58
|
+
send_data body, type: mime, disposition: "inline"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
module LlmMetaWidget
|
|
2
|
+
# Renders the client-orchestrated chat widget on the current page.
|
|
3
|
+
#
|
|
4
|
+
# <%= llm_meta_widget(base_url: "https://llmbranch.dbcls.jp",
|
|
5
|
+
# model: "qwen3-6-35b-fast") %>
|
|
6
|
+
#
|
|
7
|
+
# The host page must ALSO provide:
|
|
8
|
+
# - <script type="application/json" id="ai-actions">…</script> — local
|
|
9
|
+
# action schemas (name/description/input_schema each).
|
|
10
|
+
# - window.aiState — reader functions called each turn to build the
|
|
11
|
+
# system prompt with current page state.
|
|
12
|
+
# - window.aiActions — action implementations invoked fire-and-forget
|
|
13
|
+
# when the LLM emits a matching tool_call.
|
|
14
|
+
#
|
|
15
|
+
# See llm_meta_widget's README for the three MCP-tool classes it supports
|
|
16
|
+
# (page-embedded aiActions / host-wide well-known / hub-registered).
|
|
17
|
+
module WidgetHelper
|
|
18
|
+
DEFAULTS = {
|
|
19
|
+
api_key_uuid: "ollama-local",
|
|
20
|
+
orchestrator_path: "/llm_meta_widget_assets/orchestrator.js",
|
|
21
|
+
actions_schema_id: "ai-actions",
|
|
22
|
+
state_global: "aiState",
|
|
23
|
+
actions_global: "aiActions",
|
|
24
|
+
remote_tools_schema_id: "remote-mcp-tools",
|
|
25
|
+
# nil → widget auto-discovers same-origin /.well-known/mcp.json at boot;
|
|
26
|
+
# explicit array → fetch those URLs; empty array → disable entirely.
|
|
27
|
+
well_known_urls: nil,
|
|
28
|
+
max_rounds: 3,
|
|
29
|
+
# Level-1 pickers — enable visitor-driven selection of model and
|
|
30
|
+
# hub-registered anon-public MCP tools. See README for the level
|
|
31
|
+
# taxonomy (0 = independent, 1 = hub anon, 2 = hub signed-in).
|
|
32
|
+
enable_model_picker: true,
|
|
33
|
+
enable_tool_picker: true,
|
|
34
|
+
# Host allowlists — nil means "show everything the hub returns for
|
|
35
|
+
# anon" (all Ollama models / all public_to_anonymous MCP servers).
|
|
36
|
+
# Pass arrays to curate.
|
|
37
|
+
models: nil, # e.g. ["qwen3-6-35b-fast", "qwen3-6-35b-no-think"]
|
|
38
|
+
hub_tools: nil # e.g. ["togomcp", "pubdictionaries"] — MCP server names
|
|
39
|
+
}.freeze
|
|
40
|
+
|
|
41
|
+
def llm_meta_widget(base_url:, model:, **overrides)
|
|
42
|
+
locals = DEFAULTS.merge(base_url: base_url, model: model, **overrides)
|
|
43
|
+
render partial: "llm_meta_widget/chat_panel", locals: locals
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|