Repository navigation
Expand file tree
/
Copy pathchat.js
More file actions
2862 lines (2800 loc) · 197 KB
/
Copy pathchat.js
File metadata and controls
2862 lines (2800 loc) · 197 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// The chat: a button at the foot of a prose page and the panel it opens over the site's chat
// service. Synced whole, like card.js, and it knows no page: the endpoint and the model page
// come off its own tag, the language off <html lang> at every render, the colors off the tokens.
//
// <script src="chat.js" data-chat="https://chat.example/chat" data-model="/model/"
// data-questions="/model.json" data-questions-de="/questions.de.json" defer>
//
// Any element carrying `data-chat-open` opens the panel on click, as the button does, and an
// address carrying `?chat=open` opens it when the page loads, then drops the parameter.
//
// Opening the panel may read the site's own model file, when `data-questions` names one, but
// nothing is sent to the chat host until the visitor presses send. The conversation lives in
// this closure and in the tab's own `sessionStorage`, under the key
// `chat`, so that following a link does not throw it away; it goes when the tab goes, and it
// reaches no server but the one the tag names. The answer
// arrives as server-sent events, is gathered until the stream ends while a spinner counts the
// seconds, and is then drawn whole, through a Markdown subset the
// model is told to write and nothing outside it — paragraphs, emphasis, code spans, lists,
// tables, with a bare URL made clickable — after every character has been escaped, so text that
// looks like markup stays text.
// The panel is drawn as the tooling's terminal: a fresh conversation opens on the page's own
// lockup, a hello and numbered menus, a question prints at a prompt, and the command line takes a
// number, /new, /clear, /help and the up arrow, none of which reaches the chat host.
// Each picture the host drew arrives as its own event; all of them are drawn under the answer by
// Mermaid, in order and fitted to the log when there are several. Mermaid is fetched from beside
// this file the first time a picture arrives; each node links where the cite line would, or a
// type to its schema's file where the host names one, and the picture is kept with its answer
// in the tab like the rest of the turn.
// A page may carry a picture of its own too, written when its site builds: a figure with
// `data-diagram` and the picture as JSON inside it, drawn the same way once it nears the
// screen, whether or not the tag names a chat. Expanded, any picture zooms and pans.
// Every sentence the widget writes is here, in both languages, so a refusal costs no tokens.
//
// Where the tag also names `data-questions-de`, the same read takes the site's reviewed German
// for its question titles, and a German page offers and sends that German, as do the chips under
// an answer the chat host says it wrote in German, whatever the page's language; which questions
// are offered is still chosen by their titles.
//
// rbChat.md(text) the subset, rendered
// rbChat.readEvents(response, fn) the stream, one fn(name, data) per event
// rbChat.strings(lang) the sentences
// rbChat.link(model, id) where a cite points
// rbChat.nameLinks(root, names, …) the model's names, linked in a rendered answer
// rbChat.heard(turns) every name and cite the conversation's answers brought
// rbChat.refocus(window) whether the cursor goes back after an answer
// rbChat.asked(location.search) the address without ?chat=open, or null if it has none
// rbChat.linkQuestion(location.search) the question ?ask= carries and the address without it, or null
// rbChat.when(retryAt, now, lang) when a limit lifts, in the visitor's language and time
// rbChat.refusalText(code, retryAt, …) the refusal sentence, ending with that moment where there is one
// rbChat.citeLine(cites, model, icon, doc) the line under an answer: the icon, each title, each mark
// rbChat.iconOf(doc) the page's icon, for the head of that line
// rbChat.pick(list, n, random) n items of list, uniformly at random and without repeats
// rbChat.unasked(list, messages) the titles no visitor message in the conversation has asked
// rbChat.spread(items, n, random) the titles offered, one per kind where the model groups them, its first-ranked kind first
// rbChat.mentioned(content, names, items, messages) the entities an answer writes, in its order, not yet asked about
// rbChat.follow(cites, items, messages, lang, random, named) the three that follow an answer, or null
// rbChat.answerLang(turns, page) the language what follows the last answer is offered in
// rbChat.mermaidConfig(read) Mermaid's configuration, from the tokens `read` gives
// rbChat.nodeElement(svg, node) the group Mermaid drew a node as, or null
// rbChat.diagramCaption(d, lang) a picture's caption in the page's language
// rbChat.oriented(source, width) a flow turned top to bottom in a panel narrower than a phone's
// rbChat.lockupOf(doc) the header's mark and name, split as the page splits it, or null
// rbChat.command(text) "new", "help", "login", "logout", "model", "effort" or null, for the command line's own words
// rbChat.CHAT_MODELS, CHAT_EFFORTS the models a signed-in reader may pick and the efforts, chat-server's table
// rbChat.grantedModels(claims, org, prefix) the model ids the sign-in's roles grant in this org, Sonnet 5 first
// rbChat.authorizeUrl(site, challenge, state, redirect) where the sign-in at ZITADEL begins, with PKCE
// rbChat.returnOf(href) the { code, state } a sign-in returns to the page with, or null
// rbChat.lapsed(token, now) whether the token's exp has passed (now in ms), or it cannot be read
// rbChat.claimsOf(token) a JWT's payload, read as UTF-8 JSON, or null
// rbChat.picked(text, rows) the row a bare number picks, or the text as typed
// rbChat.tryPick(facts, random) what an opening offers after the meta-model, two of what the model holds
// rbChat.connected(entities, edges) the systems a landscape row may name
// rbChat.tryRows(facts, lang, random) the intro's asks, each with what comes back
// rbChat.commitOf(cites) the commit the answer was read at, or null
// rbChat.seconds(ms) the spinner's count
// rbChat.rangeOf(n) the rows a number picks, as the keys line names them
// rbChat.versionsOf(file) the model file's core, commit and repository, or null
// rbChat.graphHref(model, id) the embedded graph's address
// rbChat.entityOf(href, model) the entity a link into the model names, or null
// rbChat.graphTarget(href, model, here) the place a link on any page names in the graph, or null
//
// An empty conversation, once the panel is shown, offers three questions as a way in in its
// intro, three of the site's own model's entities of type `question`, picked at random for that
// conversation, and every finished answer offers three more the conversation has not asked
// yet, so a visitor who liked the first answer has somewhere to go next. Where that answer
// writes the titles of entities the visitor has not asked about, two of the three ask to be told
// more about the first two of them. Where it cited entities of one type only, the three follow it
// too: the first cited entity's neighbors, then its type's schema and a question of the model's
// that rests on that entity or its type as far as room is left. `data-questions` names a same-origin path to that model, the file `card.js`
// and `stage.js` already read the same way, asked once and cached for the page's life; a tag
// without it offers none and asks nothing. Nothing here ever reaches the chat host — the one
// runtime call this family's pages make to a service of their own is still the POST on send,
// unmoved by any of this — and a read that 404s, times out, answers something that is not JSON,
// or names no question reads the same as one that named none: no questions, nothing else different.
//
// On a desk the panel is sized by its top left corner and the size is kept in the tab beside
// the conversation, under `chat-size`; on a phone it is the whole screen and has no corner.
//
// A tag that names the site's identity service lets a reader sign in from the command line:
//
// <script src="chat.js" data-chat="…" data-issuer="https://id.example" data-client="<client id>"
// data-org="<org id>" data-project="<project id>" data-role-prefix="" defer>
//
// `/login` goes to the hosted login with a PKCE challenge, the verifier, a random `state` and the
// page's path kept in the tab under `chat-pkce`. The login sends the reader back to the site's
// root with a code; wherever the widget then runs, it checks the state, exchanges the code and
// returns to that path. The access token and the ID token are kept in the browser under
// `chat-signin`, every message after carries the access token as `Authorization: Bearer`, and
// `/logout` forgets both and ends the session at the service. A tag without `data-issuer` offers
// none of the four words, `/login`, `/logout`, `/model` and `/effort`, and sends them as it
// sends any question.
//
// Signed in, `/model` offers Sonnet 5 and the models the reader's roles grant on this site, and
// `/effort` the five efforts, each a numbered menu a number picks from; a reader with no role is
// answered at `low` alone, so `/effort` tells them in a line that effort comes with a model
// grant, and a remembered effort goes as `low`. The choice is kept in the
// browser under `chat-choice`, and every message the reader sends carries it as `model` and
// `effort`; a visitor's message carries neither. The host's own address, where the tag names one
// as `data-chat-direct` (an absolute https URL), takes a signed-in message above `low`, whose
// answer may outlast what the site's front door allows; every other message goes to `data-chat`.
(function(){
// SENT is the tail of the conversation the server reads: its last eight turns, less the
// assistant turn that would open them, since a conversation ends with the visitor. Sending only
// that tail gives the model exactly what the whole would give it, so a conversation runs as long
// as the visitor likes and the request stays far under the server's 64 KB, however long it runs.
// TIMEOUT is how long a message at `low` may take, LONG one above it, since a high effort
// thinks for minutes before it writes.
var LIMIT = 1000, SENT = 7, TIMEOUT = 90000, LONG = 900000;
var STRINGS = {
en: {
open: "Ask the model", close: "Close", title: "Ask the model", size: "Resize the chat",
placeholder: "Ask about the model…", waiting: "Asking…",
ai: "An AI answers here, not a person.", notice: "Your message and the conversation so far go to {host}, which asks the model and Claude through Anthropic's API. Nothing is sent until you press send. The conversation stays in this tab, so it is still here on the next page, and closing the tab ends it.",
privacy: "Privacy", privacyHref: "/privacy/", from: "From the model",
questions: "Questions to start with", next: "Questions to ask next",
follow: { schema: "Show me the schema of {title} ({type})", neighbors: "Show me the neighbors of {title}", more: "Tell me more about {title}" },
cut: "… the answer stopped at its length limit.",
fresh: "New conversation",
// The terminal: the intro, the prompt, the spinner, the head of a finished answer, and
// the keys and commands the command line takes. {name} is the lockup's text, {host} the
// page's own host.
hello: ["Hello. I answer from {name}’s model, and link", "every entity I name back to where it is written."],
helloHost: ["Hello. I answer from the model of {host}, and link", "every entity I name back to where it is written."],
sub: "chat · {host}", bar: "ask · {host}",
prompt: "Type a question, a number, or /help",
asking: "asking the model", answered: "answered", model: "model {sha} · {secs}s",
claim: { partial: ["Partly backed", "The model's pages say only part of this."], contradicted: ["Contradicted", "The model's pages say otherwise."], absent: ["Not backed", "The model's pages don't say this."], unsourced: ["Not backed", "No page the chat read says this."] },
claimsOne: "1 statement here isn't fully backed by the model's pages.", claimsMany: "{n} statements here aren't fully backed by the model's pages.",
keys: { send: "enter send", last: "↑ last question", pick: "{range} pick", help: "/help", chosen: "{model} · {effort}" },
menu: { model: "Choose a model", effort: "Choose an effort" },
signInHint: "Sign in with /login to choose a model.",
effortHint: "The effort can be chosen once your sign-in grants a model.",
help: [["/new", "start a new conversation (also /clear)"], ["/login", "sign in, to choose a model"], ["/logout", "sign out"], ["/model", "choose the model that answers"], ["/effort", "choose how long it thinks"], ["/help", "this list"], ["{range}", "pick from the menu above"], ["↑", "your last question back into the line"]],
tryLabel: "Try", askNext: "Ask next",
versions: "meta-model {core} · model {sha}", versionsModel: "model {sha}",
graph: { head: "graph · {title}", failed: "The graph could not be drawn here; the model page has it." },
try: {
metaModel: "Show me the meta-model", metaModelGets: "a diagram of the types and how they refer to each other",
process: "Walk me through the {name} process", processGets: "its steps as a flow, the loops back included",
organization: "Show me the organization", organizationGets: "its units as a chart, with who leads each and who sits in it",
landscape: "Show me the landscape of {name}", landscapeGets: "what it runs on, what it connects to and the data it keeps",
usecase: "Show me the use case {name}", usecaseGets: "who it serves, what it includes and what it asks of the product",
requirements: "Show me the requirements of {name}", requirementsGets: "what it is held to, every requirement a link into the model",
list: "List {list} as a table", listGets: "one row each, every name a link into the model",
lists: { kpi: "the KPIs", seat: "the seats", product: "the products", decision: "the decisions", value: "the values" }
},
again: { sentence: "You can ask again {when}.", minute: "in a minute", minutes: "in {n} minutes", at: "at {time}", tomorrow: "tomorrow at {time}", day: "on {day} at {time}" },
github: "{title} on GitHub", commit: "commit {sha}",
modalClose: "Close \u00b7 Esc",
ask: { from: "The link you opened asks:", label: "A question from the link you opened", send: "Send", edit: "Edit", discard: "Discard" },
diagram: { concepts: "Concepts", process: "Process", neighborhood: "Connections", schema: "Meta-model", context: "Context map", aggregate: "Aggregate", flow: "Flow", lifecycle: "Lifecycle", organization: "Organization", system: "Landscape", holds: "Data held", usecase: "Use case", scenario: "Scenario", requirements: "Requirements", reading: { context: "One-way arrows run from upstream to downstream; each arrow names the pattern between the two contexts.", aggregate: "The root holds what the diamonds join; the dashed arrows are the events it emits.", flow: "Solid arrows are commands sent to the aggregate, dashed arrows the events it emits; a box names the condition of each branch.", lifecycle: "Each arrow is a step from one state to the next, labeled with the command that takes it where there is one.", organization: "Solid arrows run from a lead to the leads below; a dashed line joins staff to the head they serve; a dashed box is an open position, beside a lead the search for a successor; agents stand in the shaded frame, and a dotted arrow from a person to an agent says who directs it, where an agent with no arrow is directed by everyone in the frame.", system: "Solid arrows run from the system the data comes from to the one that takes it, labeled with the interface, what is carried and how; a dotted line joins a system to what it runs on; a rounded box is a service the system provides; a cylinder is data it holds, by a heavy arrow where its copy leads, a solid one where it writes and a dotted one where it only reads.", holds: "Each cylinder is data kept of the concept named under it; a heavy arrow marks the system whose copy leads, a solid one a system that writes a copy of its own, a dotted one a system that only reads.", usecase: "The actor stands outside the frame, which is the use case's scope; a dashed arrow runs from a use case to one its steps include, a solid one to a feature it asks something of; a hexagon is a requirement it is held to, with a heavy border where it must and a dashed one where it may.", scenario: "The rounded box is the trigger; each box is a step, in order, with who acts; a dashed box is an extension, the condition first and what happens then.", requirements: "A hexagon is a requirement, with a heavy border where it must and a dashed one where it may, in a frame for its kind; an arrow runs to what it applies to, a dotted one to a rule it follows." }, expand: "Open full screen", shut: "Close full screen", zoomIn: "Zoom in", zoomOut: "Zoom out", fit: "Fit", fitTip: "Fit to the screen", failed: "The diagram could not be drawn; this is its source." },
refusal: {
too_long: "That message is over 1,000 characters.",
too_much: "The conversation has grown too long to send; start a new one.",
busy: "Too many messages for the moment; try again later.",
over_day: "Today's share of answers is spent; there is more tomorrow.",
over_month: "This month's share of answers is spent.",
closed: "The chat is switched off for now.",
host_down: "The model's host did not answer; try again shortly.",
foreign: "This page may not use the chat.",
bad_request: "That could not be sent as a message.",
internal: "Something went wrong on the way; try again.",
network: "The chat could not be reached; check the connection and try again.",
not_granted: "Your sign-in does not grant that model.",
signed_out: "You are signed out; sign in again with /login.",
over_user_month: "Your share of answers this month is spent; sign out with /logout to keep asking as a visitor.",
declined: "That model declined to answer this message; try rephrasing it."
}
},
// German: drafts for the translator of conventions/TRANSLATOR.md, to be made from the
// reviewed English.
de: {
open: "Das Modell fragen", close: "Schliessen", title: "Das Modell fragen", size: "Grösse des Chats ändern",
placeholder: "Fragen Sie das Modell…", waiting: "Wird gefragt…",
ai: "Hier antwortet eine KI, keine Person.", notice: "Ihre Nachricht und der bisherige Verlauf gehen an {host}, das das Modell und Claude über Anthropics API fragt. Gesendet wird erst, wenn Sie auf Senden drücken. Das Gespräch bleibt in diesem Tab, ist also auf der nächsten Seite noch da, und endet, wenn Sie den Tab schliessen.",
privacy: "Datenschutz", privacyHref: "/privacy/", from: "Aus dem Modell",
questions: "Fragen für den Einstieg", next: "Weitere Fragen",
follow: { schema: "Zeig mir das Schema von {title} ({type})", neighbors: "Zeig mir die Nachbarn von {title}", more: "Erzähl mir mehr über {title}" },
cut: "… die Antwort endete an ihrer Längengrenze.",
fresh: "Neues Gespräch",
hello: ["Hallo. Ich antworte aus dem Modell von {name}", "und verlinke jede Entität, die ich nenne."],
helloHost: ["Hallo. Ich antworte aus dem Modell von {host}", "und verlinke jede Entität, die ich nenne."],
sub: "Chat · {host}", bar: "fragen · {host}",
prompt: "Frage, Nummer oder /help tippen",
asking: "frage das Modell", answered: "beantwortet", model: "Modell {sha} · {secs}s",
claim: { partial: ["Teilweise belegt", "Die Seiten des Modells sagen das nur zum Teil."], contradicted: ["Widerspricht dem Modell", "Die Seiten des Modells sagen etwas anderes."], absent: ["Nicht belegt", "Die Seiten des Modells sagen das nicht."], unsourced: ["Nicht belegt", "Keine Seite, die der Chat gelesen hat, sagt das."] },
claimsOne: "Eine Aussage in dieser Antwort ist durch die Seiten des Modells nicht vollständig belegt.", claimsMany: "{n} Aussagen in dieser Antwort sind durch die Seiten des Modells nicht vollständig belegt.",
keys: { send: "Enter senden", last: "↑ letzte Frage", pick: "{range} wählen", help: "/help", chosen: "{model} · {effort}" },
menu: { model: "Modell wählen", effort: "Aufwand wählen" },
signInHint: "Melden Sie sich mit /login an, um ein Modell zu wählen.",
effortHint: "Den Aufwand können Sie wählen, sobald Ihre Anmeldung ein Modell erlaubt.",
help: [["/new", "ein neues Gespräch beginnen (auch /clear)"], ["/login", "anmelden, um ein Modell zu wählen"], ["/logout", "abmelden"], ["/model", "das antwortende Modell wählen"], ["/effort", "wählen, wie lange es nachdenkt"], ["/help", "diese Liste"], ["{range}", "aus dem Menü darüber wählen"], ["↑", "Ihre letzte Frage zurück in die Zeile"]],
tryLabel: "Probieren Sie", askNext: "Fragen Sie weiter",
versions: "Meta-Modell {core} · Modell {sha}", versionsModel: "Modell {sha}",
graph: { head: "Graph · {title}", failed: "Der Graph liess sich hier nicht zeichnen; die Modellseite zeigt ihn." },
try: {
metaModel: "Zeig mir das Meta-Modell", metaModelGets: "ein Diagramm der Typen und wie sie aufeinander verweisen",
process: "Zeig mir den Prozess {name} Schritt für Schritt", processGets: "die Schritte als Ablauf, samt Rücksprüngen",
organization: "Zeig mir die Organisation", organizationGets: "die Einheiten als Organigramm: wer sie jeweils leitet und wer dazugehört",
landscape: "Zeig mir die Landschaft von {name}", landscapeGets: "worauf das System läuft, womit es verbunden ist und welche Daten es hält",
usecase: "Zeig mir den Anwendungsfall {name}", usecaseGets: "wem er dient, was er einbindet und was er vom Produkt verlangt",
requirements: "Zeig mir die Anforderungen an {name}", requirementsGets: "woran das Produkt gemessen wird, jede Anforderung ein Link ins Modell",
list: "Liste {list} als Tabelle auf", listGets: "eine Zeile je Eintrag, jeder Name ein Link ins Modell",
lists: { kpi: "die KPIs", seat: "die Sitze", product: "die Produkte", decision: "die Entscheide", value: "die Werte" }
},
again: { sentence: "Sie können {when} wieder fragen.", minute: "in einer Minute", minutes: "in {n} Minuten", at: "um {time}", tomorrow: "morgen um {time}", day: "am {day} um {time}" },
github: "{title} auf GitHub", commit: "Commit {sha}",
modalClose: "Schliessen \u00b7 Esc",
ask: { from: "Der Link, den Sie geöffnet haben, enthält diese Frage:", label: "Eine Frage aus dem Link, den Sie geöffnet haben", send: "Senden", edit: "Bearbeiten", discard: "Verwerfen" },
diagram: { concepts: "Konzepte", process: "Prozess", neighborhood: "Verbindungen", schema: "Meta-Modell", context: "Context Map", aggregate: "Aggregat", flow: "Ablauf", lifecycle: "Lebenszyklus", organization: "Organisation", system: "Landschaft", holds: "Datenbestand", usecase: "Anwendungsfall", scenario: "Szenario", requirements: "Anforderungen", reading: { context: "Einfache Pfeile laufen vom Upstream- zum Downstream-Kontext; jeder Pfeil nennt das Muster zwischen den beiden Kontexten.", aggregate: "Die Wurzel des Aggregats hält, was die Rauten verbinden; die gestrichelten Pfeile sind die Ereignisse, die sie auslöst.", flow: "Durchgezogene Pfeile sind Befehle an das Aggregat, gestrichelte die Ereignisse, die es auslöst; ein Kasten nennt die Bedingung jedes Zweigs.", lifecycle: "Jeder Pfeil ist ein Schritt von einem Zustand zum nächsten, beschriftet mit dem Befehl, der ihn auslöst, wo es einen gibt.", organization: "Durchgezogene Pfeile laufen von einer Führungskraft zu den Führungskräften darunter; eine gestrichelte Linie verbindet eine Stabsstelle mit der Leitung, der sie zuarbeitet; ein gestrichelter Kasten ist eine offene Stelle, neben einer Führungskraft die Suche nach ihrer Nachfolge; Software-Agenten stehen im farbig hinterlegten Rahmen, und ein gepunkteter Pfeil von einer Person zu einem Agenten sagt, wer ihn steuert; ein Agent ohne Pfeil wird von allen im Rahmen gesteuert.", system: "Durchgezogene Pfeile laufen vom System, aus dem die Daten stammen, zum System, das sie übernimmt, beschriftet mit der Schnittstelle und mit dem, was übertragen wird und wie; eine gepunktete Linie verbindet ein System mit dem, worauf es läuft; ein abgerundeter Kasten ist ein Service, den das System bereitstellt; ein Zylinder ist ein Datenbestand, den es hält – mit einem dicken Pfeil, wo seine Kopie führend ist, einem durchgezogenen, wo es schreibt, und einem gepunkteten, wo es nur liest.", holds: "Jeder Zylinder ist ein Datenbestand zum darunter genannten Konzept; ein dicker Pfeil markiert das System, dessen Kopie führend ist, ein durchgezogener ein System, das eine eigene Kopie schreibt, ein gepunkteter ein System, das nur liest.", usecase: "Der Rahmen ist der Geltungsbereich des Anwendungsfalls, und der Akteur steht ausserhalb; ein gestrichelter Pfeil läuft von einem Anwendungsfall zu einem anderen, den seine Schritte einbinden, ein durchgezogener zu einem Feature, von dem er etwas verlangt; ein Sechseck ist eine Anforderung, die für den Anwendungsfall gilt, mit dickem Rand, wo sie «muss» sagt, und gestricheltem, wo sie «darf» sagt.", scenario: "Der abgerundete Kasten ist der Auslöser; die Kästen sind die Schritte in ihrer Reihenfolge, und jeder nennt, wer handelt; ein gestrichelter Kasten ist eine Erweiterung: zuerst die Bedingung, dann, was geschieht.", requirements: "Ein Sechseck ist eine Anforderung, mit dickem Rand, wo sie «muss» sagt, und gestricheltem, wo sie «darf» sagt; der Rahmen um sie nennt ihre Art; ein Pfeil läuft zu dem, wofür sie gilt, ein gepunkteter zu einer Regel, der sie folgt." }, expand: "Im Vollbild öffnen", shut: "Vollbild schliessen", zoomIn: "Vergrössern", zoomOut: "Verkleinern", fit: "Einpassen", fitTip: "Auf den Bildschirm einpassen", failed: "Das Diagramm konnte nicht gezeichnet werden; dies ist seine Quelle." },
refusal: {
too_long: "Diese Nachricht ist länger als 1’000 Zeichen.",
too_much: "Das Gespräch ist zu lang geworden, um es zu senden; beginnen Sie ein neues.",
busy: "Im Moment zu viele Nachrichten; versuchen Sie es später wieder.",
over_day: "Der heutige Anteil an Antworten ist aufgebraucht; morgen gibt es mehr.",
over_month: "Der Anteil dieses Monats an Antworten ist aufgebraucht.",
closed: "Der Chat ist zurzeit abgeschaltet.",
host_down: "Der Host des Modells hat nicht geantwortet; versuchen Sie es gleich wieder.",
foreign: "Diese Seite darf den Chat nicht nutzen.",
bad_request: "Das konnte nicht als Nachricht gesendet werden.",
internal: "Unterwegs ist etwas schiefgegangen; versuchen Sie es noch einmal.",
network: "Der Chat war nicht erreichbar; prüfen Sie die Verbindung und versuchen Sie es wieder.",
not_granted: "Ihre Anmeldung erlaubt dieses Modell nicht.",
signed_out: "Sie sind abgemeldet; melden Sie sich mit /login wieder an.",
over_user_month: "Ihr Anteil an Antworten in diesem Monat ist aufgebraucht; melden Sie sich mit /logout ab, um als Gast weiterzufragen.",
declined: "Dieses Modell hat die Antwort auf diese Nachricht abgelehnt; formulieren Sie sie anders."
}
}
};
function strings(lang){ return STRINGS[lang] || STRINGS.en; }
function langNow(){ return document.documentElement && document.documentElement.lang === "de" ? "de" : "en"; }
// A code the table does not carry — or one that only exists on Object.prototype, `toString`
// and the like, walked by a bare `[code]` lookup — falls back to `internal` rather than
// printing whatever the prototype chain hands back.
function sentence(code, lang){
var r = strings(lang || langNow()).refusal;
return Object.prototype.hasOwnProperty.call(r, code) ? r[code] : r.internal;
}
// When a limit lifts, in the visitor's terms. The server sends the moment as an ISO time in
// UTC on `busy`, `over_day`, `over_month` and `over_user_month`, and the widget writes it against the visitor's
// clock and zone: within the hour in minutes, later the same local day as a time, tomorrow
// by name, and beyond that by the day's name, so midnight UTC reads as the local hour it is.
// Under a minute is "a minute", the one case where the old sentence was right. The sentence
// says "at" and never "exactly at": the bucket is per instance, and a visitor may find the
// chat open earlier than the moment says, never later. `zone` is for the suite; the page
// passes nothing and gets the browser's. An unreadable moment or one already past gives an
// empty string, and the caller writes the plain sentence. `en-CA` writes a date as
// 2026-09-23, which two moments can be compared by; `en-GB` and `de-CH` both write a
// 24-hour time as 14:35, where `en-US` would write 2:35 PM.
function when(retryAt, now, lang, zone){
var t = Date.parse(retryAt);
if (isNaN(t) || t <= now) return "";
var s = strings(lang).again, clause;
var minutes = Math.ceil((t - now) / 60000);
if (minutes <= 60) clause = minutes <= 1 ? s.minute : s.minutes.replace("{n}", String(minutes));
else {
var opts = zone ? { timeZone: zone } : {};
var day = function(ms){ return new Intl.DateTimeFormat("en-CA", Object.assign({ year: "numeric", month: "2-digit", day: "2-digit" }, opts)).format(new Date(ms)); };
var time = new Intl.DateTimeFormat(lang === "de" ? "de-CH" : "en-GB", Object.assign({ hour: "2-digit", minute: "2-digit", hourCycle: "h23" }, opts)).format(new Date(t));
var then = day(t);
if (then === day(now)) clause = s.at.replace("{time}", time);
else if (then === day(now + 86400000)) clause = s.tomorrow.replace("{time}", time);
else clause = s.day.replace("{day}", new Intl.DateTimeFormat(lang === "de" ? "de-CH" : "en-US", Object.assign({ weekday: "long" }, opts)).format(new Date(t))).replace("{time}", time);
}
return s.sentence.replace("{when}", clause);
}
// The refusal as the visitor reads it: the code's sentence, and where the server named the
// moment its limit lifts, that moment after it. A response without the field, or one whose
// body could not be read, gives the sentence alone, so a widget meeting an older server
// degrades to what it said before.
function refusalText(code, retryAt, now, lang, zone){
var base = sentence(code, lang);
var moment = retryAt ? when(retryAt, now, lang, zone) : "";
return moment ? base + " " + moment : base;
}
// The page's own icon, for the head of the cite line: the first `<link rel="icon">`, or
// null, and then the words stand instead. Read once, on the page; the suite passes a stub.
function iconOf(doc){
var l = doc.querySelector && doc.querySelector('link[rel~="icon"]');
return l && l.href ? l.href : null;
}
// GitHub's own mark, the Octicon `mark-github` (MIT, notice in octicons.LICENSE.txt beside this
// file), inlined so the page loads nothing.
var GH = '<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path fill="currentColor" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg>';
// The line under an answer: the receipt for the rule that every claim comes from a tool's
// answer. It opens with the site's icon where the page declares one, the words "From the
// model" as its name and tooltip, and with the words themselves where it does not, so the
// line is never a bare list. Each cite is its title, linked where a name in the text links,
// and after it GitHub's mark to the file at the commit the host serves, the one address
// that lets a reader check the answer without trusting the chat; its name carries the
// title and the commit's first seven characters, read from the URL's `blob/<sha>/`. A cite
// without a URL gets no mark. The icon stands once, at the head: a line that repeated it
// before every title was drawn and declined for the width it costs in a panel that is
// twenty-six rem on a desk and the whole screen on a phone.
function citeLine(cites, model, icon, doc){
var lang = doc.documentElement && doc.documentElement.lang === "de" ? "de" : "en", s = strings(lang);
var c = doc.createElement("p"); c.className = "rbchat-cites";
if (icon) {
var img = doc.createElement("img"); img.className = "rbchat-from";
img.setAttribute("src", icon); img.setAttribute("alt", s.from); img.setAttribute("title", s.from);
img.setAttribute("width", "16"); img.setAttribute("height", "16");
c.appendChild(img);
} else {
var words = doc.createElement("span"); words.textContent = s.from + ": "; c.appendChild(words);
}
cites.forEach(function(x, i){
var a = doc.createElement("a"); a.className = "rbchat-cite"; a.href = link(model, x.id); a.textContent = x.title || x.id;
c.appendChild(a);
if (x.url) {
var m = /\/blob\/([0-9a-f]{7,40})\//.exec(x.url);
var name = s.github.replace("{title}", x.title || x.id) + (m ? ", " + s.commit.replace("{sha}", m[1].slice(0, 7)) : "");
var g = doc.createElement("a"); g.className = "rbchat-gh"; g.href = x.url;
g.setAttribute("aria-label", name); g.setAttribute("title", name); g.innerHTML = GH;
c.appendChild(g);
}
if (i < cites.length - 1) c.appendChild(doc.createTextNode(", "));
});
return c;
}
// ─── The subset ───────────────────────────────────────────────────────────────────────────
function esc(s){ return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """); }
// Inline marks on escaped text: code first, so nothing inside a span is read as emphasis;
// bold before italic, so ** is not two stars. Bold's own content excludes `*` outright, so a
// single star never nests inside it — the price of reading `**` as one token rather than two.
// A lone star or a lone underscore stays what it is, and an underscore inside a word (a
// variable name, `snake_case`) is a letter, not a mark: `_` only opens and closes at a
// boundary no word character sits against.
// A URL in an answer is a place the visitor wants to go, and one they would otherwise have to
// select and copy, so a bare http or https address becomes a link. Bare is the only form the
// model may write — the subset has no link syntax — which is what keeps the text and the
// target the same string: a reader sees where a link goes before following it, and nothing
// can point one word at another address. Trailing punctuation belongs to the sentence, not to
// the address, and a link stays in this tab, as every link of this family does.
var URL_RE = /https?:\/\/[^\s<>"']+/g;
function links(s){
return s.replace(URL_RE, function(u){
var tail = "";
var cut = /[.,;:!?)\]]+$/.exec(u);
if (cut) { tail = cut[0]; u = u.slice(0, -tail.length); }
return '<a href="' + u + '">' + u + "</a>" + tail;
});
}
// The names a tool answered with, linked where the answer writes them. The model is told to
// name the entity a claim rests on, so an answer reads "Skills drawn on: Integration
// architecture, Solution architecture, …" and every one of those is an entity the visitor may
// want to open. The server sends what it showed the model, so nothing here guesses: a name is
// linked only if it arrived, and it is matched whole, longest first, so that "Data
// engineering" wins over a shorter name inside it. The walk is over text nodes of the rendered
// answer, so a name inside a link, a code span or an attribute is left alone.
function nameLinks(root, names, model, doc){
if (!names.length) return;
var byLength = names.slice().sort(function(a, b){ return b.title.length - a.title.length; });
var nodes = [], walk = doc.createTreeWalker(root, 4 /* NodeFilter.SHOW_TEXT */, null);
for (var n = walk.nextNode(); n; n = walk.nextNode()) if (!n.parentNode.closest("a, code")) nodes.push(n);
nodes.forEach(function(node){
var text = node.nodeValue, out = null, at = 0, piece = doc.createDocumentFragment();
while (at < text.length) {
var hit = null, where = -1;
for (var i = 0; i < byLength.length; i++) {
var idx = text.indexOf(byLength[i].title, at);
// A name is a word, not a string inside one: what sits on either side has to be
// something other than a letter or a digit.
while (idx >= 0 && !edged(text, idx, byLength[i].title.length)) idx = text.indexOf(byLength[i].title, idx + 1);
if (idx >= 0 && (where < 0 || idx < where)) { where = idx; hit = byLength[i]; }
}
if (!hit) break;
out = true;
piece.appendChild(doc.createTextNode(text.slice(at, where)));
var a = doc.createElement("a"); a.href = link(model, hit.id); a.textContent = hit.title;
piece.appendChild(a);
at = where + hit.title.length;
}
if (!out) return;
piece.appendChild(doc.createTextNode(text.slice(at)));
node.parentNode.replaceChild(piece, node);
});
}
// Every name the conversation has heard, each answer's names and cites, oldest first. A
// follow-up that only reshapes an earlier answer, the same entities as a table, calls no
// tool, so its own turn brings no names; the names an earlier turn brought are still names
// the server sent, so they are linked wherever a later answer writes them.
function heard(turns){
var out = [];
turns.forEach(function(t){ if (t.role === "assistant") out = out.concat(t.names || [], t.cites || []); });
return out;
}
function edged(text, at, len){
var before = at > 0 ? text.charAt(at - 1) : " ", after = at + len < text.length ? text.charAt(at + len) : " ";
return !/[0-9A-Za-z]/.test(before) && !/[0-9A-Za-z]/.test(after);
}
function inline(s){
var out = "", i = 0, m;
var re = /`([^`]+)`|\*\*(\S(?:[^*]*?\S)?)\*\*|\*(\S(?:[^*]*?\S)?)\*|(?<!\w)_(\S(?:[^_]*?\S)?)_(?!\w)/g;
while ((m = re.exec(s))) {
out += links(s.slice(i, m.index));
// A URL inside a code span is being shown, not offered: it stays as it is.
if (m[1] !== undefined) out += "<code>" + m[1] + "</code>";
else if (m[2] !== undefined) out += "<strong>" + inline(m[2]) + "</strong>";
else out += "<em>" + inline(m[3] !== undefined ? m[3] : m[4]) + "</em>";
i = m.index + m[0].length;
}
return out + links(s.slice(i));
}
var ROW = /^\s*\|(.+)\|\s*$/, DELIM = /^\s*\|(\s*:?-{3,}:?\s*\|)+\s*$/, BULLET = /^\s*[-*]\s+(.*)$/, NUMBER = /^\s*\d+\.\s+(.*)$/;
function cells(line){ return line.replace(/^\s*\|/, "").replace(/\|\s*$/, "").split("|").map(function(c){ return cell(c.trim()); }); }
// A table row is one line, so a cell cannot hold a list the way the body does. The model is
// told to write one as `- first<br>- second`, and that is the one place a `<br>` means
// anything: it arrives escaped like every other character, and it is read back only here. A
// cell whose every line is an item is a list, bulleted or numbered as the body's lists are, and
// the bullet the model reaches for unasked, `•`, counts as one; any other cell keeps its
// lines as lines.
var BR = /<br\s*\/?>/i, CELL_BULLET = /^(?:[-*•])\s+(.*)$/, CELL_NUMBER = /^\d+\.\s+(.*)$/;
function cell(c){
var parts = c.split(BR).map(function(p){ return p.trim(); }).filter(Boolean);
if (!parts.length) return "";
var re = parts.every(function(p){ return CELL_BULLET.test(p); }) ? CELL_BULLET : parts.every(function(p){ return CELL_NUMBER.test(p); }) ? CELL_NUMBER : null;
if (!re) return parts.map(inline).join("<br>");
var tag = re === CELL_NUMBER ? "ol" : "ul";
return "<" + tag + ">" + parts.map(function(p){ return "<li>" + inline(re.exec(p)[1].trim()) + "</li>"; }).join("") + "</" + tag + ">";
}
// A name the answer's language keeps unchanged comes back twice, **Master** (Master), because
// the model is told to follow every name with its exact title and follows that past its own
// sense; no wording of that instruction stopped it. The pair is the same words, so it is
// written once, and the name still links, since the title is what it links by. A bold name
// folds wherever it stands. One not in bold folds only where a name begins, at the head of a
// line, a cell or an item or after a comma, colon or semicolon, because the words before
// "Plan (Plan)" may be the name's own rendering, Business Plan.
var TWICE_BOLD = /\*\*([^*\n]+?)\*\* \(\1\)/g,
TWICE_PLAIN = /(^[ \t]*(?:(?:[-*•]|\d+\.)[ \t]+)?|[|:;,][ \t]*|<br>[ \t]*(?:[-*•][ \t]+)?)([^\s*()|<][^*()|\n<]*?) \(\2\)/gm;
function once(text){ return text.replace(TWICE_BOLD, "**$1**").replace(TWICE_PLAIN, "$1$2"); }
// Blocks, line by line: a table needs its delimiter row before it is a table, so one still
// arriving is a paragraph until its second line lands; a list is consecutive items; the rest
// is paragraphs split at blank lines. The model sometimes leaves a blank line between two rows
// of one table, and the rows after it, with no header of their own, would run together as one
// paragraph of pipes; so a blank line inside a table is skipped when a row follows it that
// does not open a table of its own.
function md(text){
if (!text) return "";
var lines = esc(once(text)).split(/\r?\n/), out = "", i = 0, n = lines.length;
while (i < n) {
var line = lines[i];
if (!line.trim()) { i++; continue; }
if (ROW.test(line) && i + 1 < n && DELIM.test(lines[i + 1])) {
var head = cells(line); i += 2; var rows = [];
for (;;) {
while (i < n && ROW.test(lines[i])) { rows.push(cells(lines[i])); i++; }
var j = i; while (j < n && !lines[j].trim()) j++;
if (j === i || j === n || !ROW.test(lines[j]) || (j + 1 < n && DELIM.test(lines[j + 1]))) break;
i = j;
}
out += "<table><thead><tr>" + head.map(function(c){ return "<th>" + c + "</th>"; }).join("") + "</tr></thead><tbody>"
+ rows.map(function(r){ return "<tr>" + r.map(function(c){ return "<td>" + c + "</td>"; }).join("") + "</tr>"; }).join("") + "</tbody></table>";
continue;
}
if (BULLET.test(line) || NUMBER.test(line)) {
var ordered = NUMBER.test(line), items = [], re = ordered ? NUMBER : BULLET, m;
while (i < n && (m = re.exec(lines[i]))) { items.push("<li>" + inline(m[1].trim()) + "</li>"); i++; }
out += (ordered ? "<ol>" : "<ul>") + items.join("") + (ordered ? "</ol>" : "</ul>");
continue;
}
var para = [];
while (i < n && lines[i].trim() && !(ROW.test(lines[i]) && i + 1 < n && DELIM.test(lines[i + 1])) && !BULLET.test(lines[i]) && !NUMBER.test(lines[i])) { para.push(lines[i].trim()); i++; }
out += "<p>" + inline(para.join(" ")) + "</p>";
}
return out;
}
// ─── The stream ───────────────────────────────────────────────────────────────────────────
// Server-sent events off a fetch body: an event is an `event:` line, a `data:` line of JSON
// and a blank line, and a chunk may end anywhere, so the buffer keeps the tail. The buffer is
// normalized to `\n` after every append, not per chunk, so a CRLF split across two chunks —
// a trailing `\r` in one, the `\n` in the next — still collapses to one line ending.
function readEvents(response, onEvent){
var reader = response.body.getReader(), dec = new TextDecoder(), buf = "";
function emit(block){
var name = null, data = "";
block.split("\n").forEach(function(l){
if (l.indexOf("event:") === 0) name = l.slice(6).trim();
else if (l.indexOf("data:") === 0) data += l.slice(5).trim();
});
if (name) { var parsed; try { parsed = JSON.parse(data); } catch (e) { parsed = { raw: data }; } onEvent(name, parsed); }
}
return reader.read().then(function step(r){
if (r.done) { if (buf.trim()) emit(buf); return; }
buf += dec.decode(r.value, { stream: true });
buf = buf.replace(/\r\n/g, "\n");
var at;
while ((at = buf.indexOf("\n\n")) >= 0) { emit(buf.slice(0, at)); buf = buf.slice(at + 2); }
return reader.read().then(step);
});
}
// Where a cite points: the model page with the entity's id as the hash, asking for the stage
// expanded. The stage takes an id or an address after the hash and writes the address back
// once it has found the entity. An id may still be a path — a model whose instance has no
// stable ids yet carries its address as its id — and the stage reads a path's slashes as
// they are; encoded, the slash is a hash the page does not hold, and the page drops it and
// shows the root. So nothing here is encoded. `?stage=expanded` is the
// request the stage already answers, the one blust.ch's timeline makes for a skill: a reader
// following a cite came for that entity's card, not for the graph around it, and the page
// takes the parameter back out of the address once it has read it.
function link(model, id){
var base = model, cut = base.indexOf("#");
if (cut >= 0) base = base.slice(0, cut);
return base + (base.indexOf("?") >= 0 ? "&" : "?") + "stage=expanded#" + id;
}
// The graph's own address: the model link, embedded, so the page draws the stage alone.
function graphHref(model, id){ return link(model, id).replace("?stage=expanded#", "?stage=expanded&embed#").replace("&stage=expanded#", "&stage=expanded&embed#"); }
// The entity a link into the model names, read back from the address link() wrote; null for
// any address that is not one, so a link to anywhere else is left to the browser.
function entityOf(href, model){
var base = model, cut = base.indexOf("#"); if (cut >= 0) base = base.slice(0, cut);
var at = String(href || ""), hash = at.indexOf("#");
if (hash < 0 || at.slice(0, hash) !== base + (base.indexOf("?") >= 0 ? "&" : "?") + "stage=expanded") return null;
var id = decodeURIComponent(at.slice(hash + 1));
return id || null;
}
// The place in the graph a link on any page of the site names: an address that resolves, on
// this site, to the model page with its stage expanded and a place after the hash. The model
// page itself names none, since its own stage is right there to move.
function graphTarget(href, model, here){
if (!href || !model || !here) return null;
try {
var to = new URL(href, here), page = new URL(model, here), at = new URL(here);
if (to.origin !== at.origin || to.pathname !== page.pathname || page.pathname === at.pathname) return null;
if (!/(^|&)stage=expanded(&|$)/.test(to.search.slice(1))) return null;
var id = decodeURIComponent(to.hash.slice(1));
return id || null;
} catch (e) { return null; }
}
// What the chat answers from, read from the model file it already fetched: the core the model
// is written in, and the model's repository at one commit.
function versionsOf(file){
if (!file || typeof file.commit !== "string" || !file.commit || typeof file.repo !== "string" || !file.repo) return null;
return { core: typeof file.core === "string" && file.core ? file.core : null, commit: file.commit, sha: file.commit.slice(0, 7), repo: file.repo };
}
// Whether the cursor goes back to the input after an answer or a refusal. On a touch screen
// focusing the input opens the keyboard over the answer the visitor is about to read, a
// change nobody asked for, so the cursor goes back only where there is a fine pointer, a mouse
// or a trackpad, and no keyboard to open. A screen reader hears the answer either way: the
// finished answer is a polite live region. Opening the panel and starting a new conversation
// still focus the input everywhere, because the visitor asked for those.
function refocus(win){ var mm = win && win.matchMedia; return !mm || mm.call(win, "(pointer: fine)").matches; }
// A link may ask for the panel open — a post that sends a reader to the chat does — with
// ?chat=open, as a link asks for the stage expanded with ?stage=expanded. The page opens the
// panel and takes the parameter back out of the address, the way it takes lang and theme, so
// a reload or a shared address does not open it again. This gives the query string without
// the parameter, "" where it was the only one, or null where the address never asked.
function asked(search){
var re = /([?&])chat=open(&|$)/;
if (!re.test(search || "")) return null;
return search.replace(re, "$1").replace(/[?&]$/, "");
}
// A link may carry a question too, with ?ask=, so a post can send a reader to one answer. The
// question is never sent on arrival: the panel opens with it waiting under the notice that says
// nothing is sent until the visitor sends, and the visitor sends, edits or discards it. Read
// once and taken out of the address, as chat=open is. This gives the question, trimmed, and the
// query string without the parameter; the question is null where it is empty, longer than a
// message may be, or not decodable, and the whole is null where the address carries no ask.
function linkQuestion(search){
var re = /([?&])ask=([^&#]*)(&|$)/, m = re.exec(search || "");
if (!m) return null;
var question = null;
try { question = decodeURIComponent(m[2].replace(/\+/g, " ")).trim(); } catch (e) { question = null; }
if (!question || question.length > LIMIT) question = null;
return { question: question, rest: search.replace(re, "$1").replace(/[?&]$/, "") };
}
// n items of a list, at random and without repeats: a Fisher–Yates shuffle of a copy, cut to
// n, which is uniform over every ordering and never picks the same item twice even where the
// caller asks for more than the list holds — it then gives back the whole list, shuffled.
// `random` is `Math.random` unless a caller passes its own, which is how the suite gets a
// deterministic answer to check.
function pick(list, n, random){
var rnd = typeof random === "function" ? random : Math.random;
var arr = (list || []).slice();
var count = Math.max(0, Math.min(Number(n) || 0, arr.length));
for (var i = arr.length - 1; i > 0; i--) {
var j = Math.floor(rnd() * (i + 1));
var t = arr[i]; arr[i] = arr[j]; arr[j] = t;
}
return arr.slice(0, count);
}
// The titles still worth offering: every one no visitor message in the conversation asked in
// those words. A chip sends its title as it stands and a typed question is trimmed on send, so
// a title matches the message it became once both are trimmed; a question put in other words
// is not recognized and may come back, which costs a visitor one chip they can ignore.
function unasked(list, messages){
var asked = {};
(messages || []).forEach(function(m){ if (m && m.role === "user" && typeof m.content === "string") asked[m.content.trim()] = true; });
return (list || []).filter(function(t){ return !asked[String(t).trim()]; });
}
// A question as the page offers it: its German where the page is German and the site's
// reviewed file holds one, else the model's own title. A chip shows and sends this; which
// questions to offer is still chosen by title, and a chip that is no model question, such as
// "tell me more", is its own text in either language.
function sayIn(list, lang, title){
if (lang !== "de") return title;
for (var i = 0; i < (list || []).length; i++) if (list[i] && list[i].title === title && typeof list[i].de === "string" && list[i].de) return list[i].de;
return title;
}
// The language what follows an answer is offered in: the answer's own, which the server names
// on done, since a German question on the English page is answered in German and its chips
// should not switch back. Only an answer standing last speaks for what follows it; with none,
// or one from a server that names no language the widget has words for, the page's.
function answerLang(turns, page){
var last = turns && turns.length ? turns[turns.length - 1] : null;
return last && last.role === "assistant" && typeof last.lang === "string" && Object.prototype.hasOwnProperty.call(STRINGS, last.lang) ? last.lang : page;
}
// The conversation with every visitor message that is a question's German read back as its
// title, so unasked() and follow(), which compare titles, know the question was asked —
// whether a chip sent it or the visitor typed the same words.
function asTitles(list, messages){
var back = {};
(list || []).forEach(function(q){ if (q && typeof q.de === "string" && q.de) back[q.de.trim()] = q.title; });
return (messages || []).map(function(m){
return m && m.role === "user" && typeof m.content === "string" && back[m.content.trim()] ? { role: m.role, content: back[m.content.trim()] } : m;
});
}
// The chips as a span of what the model answers: where its questions name a kind, three kinds
// are picked at random and one question from each, so a visitor sees three sorts of question
// rather than three that may all be about one thing. Where the model ranks its kinds, the first
// chip is of the kind it ranks first among those still open, the one its owner says a visitor
// asks first, and the others span the rest at random. Fewer kinds than chips fill from what is
// left, and a model whose questions name no kind is offered exactly as pick() offers it, so a
// site that takes this before its model groups anything loses nothing.
function spread(items, n, random){
var rnd = typeof random === "function" ? random : Math.random;
var list = (items || []).filter(function(q){ return q && typeof q.title === "string"; });
var kinded = list.filter(function(q){ return typeof q.kind === "string" && q.kind; });
if (!kinded.length) return pick(list.map(function(q){ return q.title; }), n, rnd);
var byKind = {}, kinds = [], rankOf = Object.create(null), first = null;
kinded.forEach(function(q){
if (!byKind[q.kind]) { byKind[q.kind] = []; kinds.push(q.kind); }
byKind[q.kind].push(q.title);
if (typeof q.rank === "number" && isFinite(q.rank) && !(rankOf[q.kind] <= q.rank)) rankOf[q.kind] = q.rank;
});
kinds.forEach(function(k){ if (k in rankOf && (first === null || rankOf[k] < rankOf[first])) first = k; });
var count = Math.max(0, Number(n) || 0);
var order = first !== null && count > 0 ? [first].concat(pick(kinds.filter(function(k){ return k !== first; }), count - 1, rnd)) : pick(kinds, n, rnd);
var chosen = order.map(function(k){ return pick(byKind[k], 1, rnd)[0]; });
var taken = {};
chosen.forEach(function(t){ taken[t] = true; });
var rest = list.map(function(q){ return q.title; }).filter(function(t){ return !taken[t]; });
return chosen.concat(pick(rest, Math.max(0, (Number(n) || 0) - chosen.length), rnd));
}
// The entities an answer writes, by exact title and whole, as nameLinks() links them, in the
// order the answer first writes them, each once. `names` is every name and cite the
// conversation has heard, so an answer that reshapes an earlier one still finds its entities.
// Left out: a question of the model's, which is offered as itself and never as something to
// hear more about, and an entity whose title a visitor message already carries, since the
// visitor asked about it and the answer is about it.
function mentioned(content, names, items, messages){
var text = typeof content === "string" ? content : "";
var questions = {};
(items || []).forEach(function(q){ if (q && typeof q.title === "string") questions[q.title] = true; });
var said = (messages || []).filter(function(m){ return m && m.role === "user" && typeof m.content === "string"; }).map(function(m){ return m.content.toLowerCase(); });
var seen = {}, found = [];
(names || []).forEach(function(n){
if (!n || typeof n.title !== "string" || !n.title || seen[n.title] || questions[n.title]) return;
seen[n.title] = true;
var title = n.title, low = title.toLowerCase();
if (said.some(function(m){ return m.indexOf(low) !== -1; })) return;
var at = text.indexOf(title);
while (at >= 0 && !edged(text, at, title.length)) at = text.indexOf(title, at + 1);
if (at >= 0) found.push({ title: title, at: at });
});
// A title inside a longer one is the longer one's word, not a mention of its own.
found = found.filter(function(f){ return !found.some(function(g){ return g !== f && g.title.length > f.title.length && f.at >= g.at && f.at < g.at + g.title.length; }); });
return found.sort(function(a, b){ return a.at - b.at; }).map(function(f){ return f.title; });
}
// The three that follow an answer. Two ask to be told more about the first two entities the
// answer writes and the visitor has not asked about, `named`, as mentioned() finds them. Where
// the answer cited entities of one type only, T, the first of them E, the third is E's
// neighbors (with nothing named, T's schema comes before them), and what room the answer's entities leave goes to T's schema, then to a question
// of the model's that rests on E, else one that rests on anything of type T. The schema chip
// names E with T after it, "Owner (seat)", so a visitor reads which schema it is without
// knowing the type's name; the type is written as its own name in both languages, as the chat
// writes it. Each is offered only where no visitor message asked it and it fits the box; a
// chip left out that way is filled from spread() over the questions still open, so three show
// wherever three exist. An answer that writes no entity left to ask about and cites none, or
// cites more than one type, follows nothing: null, and the caller offers spread() as before.
// `items` are the model's questions, each with the ids and types of what it rests on.
function follow(cites, items, messages, lang, random, named){
var rnd = typeof random === "function" ? random : Math.random;
var s = strings(lang).follow;
var fits = function(t){ return t.length <= LIMIT; };
var more = unasked((named || []).map(function(t){ return s.more.replace("{title}", t); }), messages).filter(fits).slice(0, 2);
var cs = (cites || []).filter(function(c){ return c && typeof c.id === "string" && typeof c.type === "string" && c.type; });
var one = cs.length && !cs.some(function(c){ return c.type !== cs[0].type; });
if (!one && !more.length) return null;
var chosen = more.slice();
var titles = (items || []).filter(function(q){ return q && typeof q.title === "string"; }).map(function(q){ return q.title; });
var open = unasked(titles, messages);
if (one) {
var type = cs[0].type, e = cs[0];
var schema = s.schema.replace("{title}", e.title || e.id).replace("{type}", type), neighbors = s.neighbors.replace("{title}", e.title || e.id);
// With nothing named the three read as before, schema first; beside the answer's own
// entities the neighbors come first, since they are E's and the schema is only its type's.
var own = unasked(more.length ? [neighbors, schema] : [schema, neighbors], messages).filter(fits);
chosen = chosen.concat(own).slice(0, 3);
if (chosen.length < 3) {
var rest0 = (items || []).filter(function(q){ return q && open.indexOf(q.title) !== -1 && chosen.indexOf(q.title) === -1; });
var on = function(test){ return rest0.filter(function(q){ return (q.rests || []).some(test); }).map(function(q){ return q.title; }); };
var third = pick(on(function(r){ return r.id === e.id; }), 1, rnd);
if (!third.length) third = pick(on(function(r){ return r.type === type; }), 1, rnd);
chosen = chosen.concat(third);
}
}
var rest = (items || []).filter(function(q){ return q && open.indexOf(q.title) !== -1 && chosen.indexOf(q.title) === -1; });
return chosen.concat(spread(rest, Math.max(0, 3 - chosen.length), rnd));
}
// Where an open conversation lives while the visitor reads on. A page is a document, so
// following a link throws the panel and everything in it away, and a visitor who asked a
// question and clicked the answer's link lost the conversation. `sessionStorage` is the tab:
// it survives a page and dies with the tab, which is the lifetime the chat already claims.
// The key is `chat`, the family's, made once by the package as `lang` and `theme` are, and
// the value is this widget's shape: whether the panel was open, and the turns as they were
// rendered, each answer with the entities it cited. Every access is wrapped, because a
// browser with site data blocked throws on the first read and the chat still has to work.
var STORE_KEY = "chat";
// How big the panel is, kept in `localStorage` as `lang` and `theme` are: a size is a choice
// about how this visitor reads the chat on this screen, so it holds in every tab and on the
// next visit, where the conversation it frames goes when its tab goes. Its own key, because it
// is no part of any conversation: a link opened in a new tab finds the panel at the size the
// visitor gave it, with nothing else carried over.
var SIZE_KEY = "chat-size";
var MIN_W = 320, MIN_H = 260, STEP = 24;
function stored(){
try { var raw = sessionStorage.getItem(STORE_KEY); return raw ? JSON.parse(raw) : null; }
catch (e) { return null; }
}
function storedSize(){
try { var raw = localStorage.getItem(SIZE_KEY); var v = raw ? JSON.parse(raw) : null; return v && v.w && v.h ? v : null; }
catch (e) { return null; }
}
function keepSize(w, h){ try { localStorage.setItem(SIZE_KEY, JSON.stringify({ w: w, h: h })); } catch (e) {} }
// Where the visitor was reading, kept with the conversation so the back button returns them
// to it and not to the end. A place is a turn and a distance into its bubble, not a pixel
// offset: a picture drawn again after the page comes back takes its height a moment late,
// and a pixel counted from the top would land in the wrong answer. A log read to its end
// keeps no place, and opens at its end as it always did; so does a hidden one, having none.
// The end is a place placed() knows too, since a picture drawing late moves the end as well.
function place(log){
if (log.scrollHeight - log.scrollTop - log.clientHeight < 2) return null;
var top = log.scrollTop, box = log.getBoundingClientRect().top, kids = log.querySelectorAll("[data-turn]");
for (var i = 0; i < kids.length; i++) {
var r = kids[i].getBoundingClientRect(), at = r.top - box + top;
if (at + r.height > top) return { turn: +kids[i].getAttribute("data-turn"), by: top - at };
}
return null;
}
function placed(log, at){
if (at && at.end) { log.scrollTop = log.scrollHeight; return true; }
var b = at && log.querySelector('[data-turn="' + at.turn + '"]');
if (!b) return false;
log.scrollTop = b.getBoundingClientRect().top - log.getBoundingClientRect().top + log.scrollTop + at.by;
return true;
}
// Every place the conversation changes calls keep(), so the control follows it from here and
// no caller has to remember a second line.
function keep(){
if (newBtn) newBtn.hidden = !messages.length;
try {
// A panel left open with nothing asked yet, fresh or just reset, is kept too, so the next
// page shows it open; only an empty conversation behind a closed panel leaves nothing.
if (!messages.length && !(panel && !panel.hidden)) { sessionStorage.removeItem(STORE_KEY); return; }
sessionStorage.setItem(STORE_KEY, JSON.stringify({ open: !!(panel && !panel.hidden), turns: turns, at: reading || (panel && !panel.hidden ? place(log) : null), next: qBox ? qPicked : null, pick: introPick }));
} catch (e) {}
}
// ─── The picture ──────────────────────────────────────────────────────────────────────────
// A diagram the host drew arrives whole, as Mermaid source with each node named by the entity
// it is, and is drawn under the answer by Mermaid, vendored beside this file. The colors are
// the tokens', read when the picture is drawn, so it follows the theme; a token a page does
// not define falls back to the dark theme's value, since Mermaid derives its shades from
// real colors and an empty one would stop the drawing.
var DARK = { "--ground": "#0C0E13", "--raise": "#171A21", "--ink": "#EFEDE8", "--dim": "#8A8B86", "--c-mid": "#7FA3D8", "--press": "#1b2231" };
function mermaidConfig(read){
function v(name){ var x = String(read(name) || "").trim(); return x || DARK[name]; }
var font = String(read("font") || "").trim() || "ui-sans-serif, system-ui, sans-serif";
return {
// The classic look is flat, as the family draws: the default draws shadows and gradients.
startOnLoad: false, securityLevel: "strict", theme: "base", look: "classic", fontFamily: font,
// Mermaid draws a rounded node and a cylinder from a seed, and where this is left at its
// default of 0 it takes a random one for each render, so the same picture came out a few
// pixels different every time. A fixed seed makes a picture the same wherever and whenever
// it is drawn, which a deck's PDF is compared by.
handDrawnSeed: 1,
// At its own size in a box that scrolls: fitted to a bubble, a wide picture's words shrink
// below reading. A concept carries no attributes or methods, so its class has no empty bars.
flowchart: { useMaxWidth: false }, class: { useMaxWidth: false, hideEmptyMembersBox: true },
// A sequence diagram keeps its own size, its actors only above, wide enough that a branch's
// condition stays whole; a state diagram keeps its own size like the others.
sequence: { useMaxWidth: false, mirrorActors: false, actorFontSize: 13, messageFontSize: 13, noteFontSize: 12, actorMargin: 24, width: 150, height: 40, boxMargin: 6, messageMargin: 26, diagramMarginX: 8, diagramMarginY: 8 },
state: { useMaxWidth: false },
// `strict` alone still lets DOMPurify pass an `<img src>` through a label; a label writes
// only `b`, `br` and `small`, so an image is never a label and would be a request to
// another host, which "no request leaves the page's origin" promises never happens.
dompurifyConfig: { FORBID_TAGS: ["img"] },
themeVariables: {
fontFamily: font, fontSize: "13px", background: v("--ground"),
primaryColor: v("--raise"), mainBkg: v("--raise"), secondaryColor: v("--press"), tertiaryColor: v("--ground"),
primaryTextColor: v("--ink"), textColor: v("--ink"), nodeTextColor: v("--ink"), classText: v("--ink"),
// A node is a link, and every link in the family is --c-mid; its own text color is set
// in chat.css instead, scoped to a linked node, because this variable also colors an
// edge label's text and an unlinked overflow bubble's, which stay --ink.
primaryBorderColor: v("--c-mid"), nodeBorder: v("--c-mid"), lineColor: v("--dim"),
// An edge label's own background: Mermaid draws it from this one variable in every
// diagram kind this file uses, so the panel's raise reaches it without a CSS rule.
edgeLabelBackground: v("--raise"),
// A note is the panel's own box, not Mermaid's yellow.
noteBkgColor: v("--raise"), noteBorderColor: v("--dim"), noteTextColor: v("--ink")
}
};
}
// Where Mermaid put a node in its SVG: a group whose id ends in the node's name and a number,
// after `classId` in a class diagram and `flowchart` in a flowchart; a scenario's step is `s0`
// and on and is listed where its actor is a seat. The one place that knows
// it, so a Mermaid release that names them otherwise is fixed here and nowhere else.
function nodeElement(svg, node){
if (!svg || !/^[ns]\d+$/.test(String(node))) return null;
var re = new RegExp("-(?:classId|flowchart)-" + node + "-\\d+$"), all = svg.querySelectorAll("g[id]");
for (var i = 0; i < all.length; i++) if (re.test(all[i].id)) return all[i];
return null;
}
// A node's name alone reads as the link: every run of a label's children that is neither the
// host's own <small> line (a neighborhood's «type», a phase's seats) nor the <br> that sets it
// apart is gathered into one span.rbchat-node-name, so chat.css can underline the name on hover
// and focus without a text-decoration on the label propagating into the quieter line beside it.
// A label with no <small> has nothing to keep apart from, so it is wrapped whole, <br> and all.
// Tolerant of a label Mermaid renders differently: an empty run is left unwrapped.
function wrapNodeName(p){
var kids = [].slice.call(p.childNodes), hasSmall = false, i;
for (i = 0; i < kids.length; i++) if (kids[i].nodeType === 1 && (kids[i].tagName === "SMALL" || (kids[i].classList && kids[i].classList.contains("label-icon")))) { hasSmall = true; break; }
function wrap(run){
if (!run.length) return;
var span = document.createElement("span");
span.className = "rbchat-node-name";
p.insertBefore(span, run[0]);
for (var j = 0; j < run.length; j++) span.appendChild(run[j]);
}
if (!hasSmall) { wrap(kids); return; }
var run = [];
for (i = 0; i < kids.length; i++) {
var k = kids[i];
if (k.nodeType === 1 && (k.tagName === "SMALL" || k.tagName === "BR")) { wrap(run); run = []; }
// A nature's mark Mermaid set before the name stays outside the span a hover underlines.
else if (k.nodeType === 1 && k.classList && k.classList.contains("label-icon")) { wrap(run); run = []; }
else run.push(k);
}
wrap(run);
}
// A flow drawn left to right is wider than a phone: in a narrow panel it runs top to bottom.
// Only the direction changes; every node and arrow is the host's.
var NARROW = 560;
function oriented(source, width){
return width && width < NARROW ? String(source).replace(/^flowchart LR\b/, "flowchart TB") : source;
}
// The caption: the shape in the page's language, then what the host drew it of.
function nodeHref(model, n){
return typeof n.url === "string" && /^https:\/\//.test(n.url) ? n.url : link(model, n.id);
}
// A shape is a key the host sends, so only a word the widget itself wrote for it counts: a
// shape named "reading" or "toString" finds an object or a function, and neither is a word.
function shapeWord(map, shape){
return typeof shape === "string" && Object.prototype.hasOwnProperty.call(map, shape) && typeof map[shape] === "string" ? map[shape] : "";
}
function diagramCaption(d, lang){
var name = shapeWord(strings(lang).diagram, d && d.shape);
return d && d.title ? (name ? name + " · " + d.title : d.title) : name;
}
// ─── The terminal ─────────────────────────────────────────────────────────────────────────
// The lockup the intro opens on is the page's own, as its header draws it: every site writes
// `<a class="brand"><svg>…</svg><b>Company<span>Graph</span></b></a>`, so the tag needs no
// attribute to name it. The accent is the span; the first half is what comes before it, its
// trailing space kept, since blust.ch writes "Robert <span>Blust</span>".
function lockupOf(doc){
var a = doc && doc.querySelector && doc.querySelector("header a.brand");
var svg = a && a.querySelector("svg"), b = a && a.querySelector("b");
if (!svg || !b) return null;
var span = b.querySelector("span"), accent = span ? span.textContent : "";
return { mark: svg, first: b.textContent.slice(0, b.textContent.length - accent.length), accent: accent };
}
// The words the command line keeps for itself. They never reach the host. `/clear` is the
// second name of `/new`, as in Claude Code; the other four stand alone.
function command(text){
var t = String(text == null ? "" : text).trim().toLowerCase();
if (t === "/new" || t === "/clear") return "new";
return t === "/help" || t === "/login" || t === "/logout" || t === "/model" || t === "/effort" ? t.slice(1) : null;
}
// ─── Sign-in ──────────────────────────────────────────────────────────────────────────────
// The pure parts of signing in at ZITADEL and choosing the model. The table is chat-server's
// (lib/models.mjs), in its order: the default first, which every signed-in reader may pick;
// `role` is the name a sign-in grants for the rest. The server checks every grant itself, so
// what the widget offers is a courtesy and never the fence.
var CHAT_MODELS = [
{ id: "claude-sonnet-5", label: "Sonnet 5", role: null },
{ id: "claude-sonnet-5-5", label: "Sonnet 5.5", role: "sonnet-5-5" },
{ id: "claude-opus-5-5", label: "Opus 5.5", role: "opus" },
{ id: "claude-fable-5-1", label: "Fable 5.1", role: "fable" },
{ id: "claude-haiku-5-5", label: "Haiku 5.5", role: "haiku" }
];
var CHAT_EFFORTS = ["low", "medium", "high", "xhigh", "max"];
var ROLES_CLAIM = "urn:zitadel:iam:org:project:roles";
function own(o, k){ return o !== null && typeof o === "object" && Object.prototype.hasOwnProperty.call(o, k); }
// The models the sign-in grants. ZITADEL writes the roles claim as { "<role>": { "<orgId>":
// "<org domain>" } }: a role counts where it is held in this site's org, and, where one
// project serves several sites, under the site's `prefix` ahead of the role's name. Own
// properties only, so no role or org is read off Object.prototype. A claim that is absent or
// not an object grants the default alone.
function grantedModels(claims, org, prefix){
var roles = own(claims, ROLES_CLAIM) ? claims[ROLES_CLAIM] : null;
return CHAT_MODELS.filter(function(m){
if (m.role === null) return true;
var key = (prefix || "") + m.role;
return own(roles, key) && own(roles[key], org);
}).map(function(m){ return m.id; });
}
// Where the sign-in begins: the authorize endpoint with the PKCE challenge, the `state` the
// page will check on the way back, and the audience and roles scopes ZITADEL needs to put the
// project's roles in the token. Every value is encoded, whatever it holds.
function authorizeUrl(site, challenge, state, redirect){
var scope = "openid profile urn:zitadel:iam:org:project:id:" + site.project + ":aud urn:zitadel:iam:org:projects:roles";
var q = [["client_id", site.client], ["redirect_uri", redirect], ["response_type", "code"], ["scope", scope], ["code_challenge", challenge], ["code_challenge_method", "S256"], ["state", state]];
return site.issuer + "/oauth/v2/authorize?" + q.map(function(p){ return p[0] + "=" + encodeURIComponent(p[1]); }).join("&");
}
// What the identity service sends the page back with: `code` and `state`, in the query
// string and only there, both non-empty. Anything less is no return.
function returnOf(href){
var h = String(href == null ? "" : href), hash = h.indexOf("#");
if (hash >= 0) h = h.slice(0, hash);
var at = h.indexOf("?");
if (at < 0) return null;
var got = {};
h.slice(at + 1).split("&").forEach(function(pair){
var eq = pair.indexOf("="), k = eq < 0 ? pair : pair.slice(0, eq);
if (k !== "code" && k !== "state" || own(got, k)) return;
try { got[k] = decodeURIComponent((eq < 0 ? "" : pair.slice(eq + 1)).replace(/\+/g, " ")); } catch (e) { got[k] = ""; }
});
return got.code && got.state ? { code: got.code, state: got.state } : null;
}
// One parameter of a query string, decoded, the first where it repeats; null where it is absent.
function param(search, key){
var parts = String(search == null ? "" : search).replace(/^\?/, "").split("&");
for (var i = 0; i < parts.length; i++) {
var eq = parts[i].indexOf("="), k = eq < 0 ? parts[i] : parts[i].slice(0, eq);
if (k !== key) continue;
try { return decodeURIComponent((eq < 0 ? "" : parts[i].slice(eq + 1)).replace(/\+/g, " ")); } catch (e) { return ""; }
}
return null;
}