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.
@@ -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