diff --git a/core/modules/startup/rootwidget.js b/core/modules/startup/rootwidget.js index d81e07aee..0f67634d1 100644 --- a/core/modules/startup/rootwidget.js +++ b/core/modules/startup/rootwidget.js @@ -52,7 +52,8 @@ exports.startup = function() { basicAuthUsername: params["basic-auth-username"], basicAuthUsernameFromStore: params["basic-auth-username-from-store"], basicAuthPassword: params["basic-auth-password"], - basicAuthPasswordFromStore: params["basic-auth-password-from-store"] + basicAuthPasswordFromStore: params["basic-auth-password-from-store"], + bearerAuthTokenFromStore: params["bearer-auth-token-from-store"] }); }); $tw.rootWidget.addEventListener("tm-http-cancel-all-requests",function(event) { diff --git a/core/modules/utils/dom/http.js b/core/modules/utils/dom/http.js index 65bdfd1e5..f16f1c512 100644 --- a/core/modules/utils/dom/http.js +++ b/core/modules/utils/dom/http.js @@ -104,6 +104,8 @@ basicAuthUsername: plain username for basic authentication basicAuthUsernameFromStore: name of password store entry containing username basicAuthPassword: plain password for basic authentication basicAuthPasswordFromStore: name of password store entry containing password +bearerAuthToken: plain text token for bearer authentication +bearerAuthTokenFromStore: name of password store entry contain bear authorization token */ function HttpClientRequest(options) { var self = this; @@ -135,8 +137,11 @@ function HttpClientRequest(options) { }); this.basicAuthUsername = options.basicAuthUsername || (options.basicAuthUsernameFromStore && $tw.utils.getPassword(options.basicAuthUsernameFromStore)) || ""; this.basicAuthPassword = options.basicAuthPassword || (options.basicAuthPasswordFromStore && $tw.utils.getPassword(options.basicAuthPasswordFromStore)) || ""; + this.bearerAuthToken = options.bearerAuthToken || (options.bearerAuthTokenFromStore && $tw.utils.getPassword(options.bearerAuthTokenFromStore)) || ""; if(this.basicAuthUsername && this.basicAuthPassword) { this.requestHeaders.Authorization = "Basic " + $tw.utils.base64Encode(this.basicAuthUsername + ":" + this.basicAuthPassword); + } else if(this.bearerAuthToken) { + this.requestHeaders.Authorization = "Bearer " + this.bearerAuthToken; } } diff --git a/editions/prerelease/tiddlywiki.info b/editions/prerelease/tiddlywiki.info index c469dcf99..6e2428221 100644 --- a/editions/prerelease/tiddlywiki.info +++ b/editions/prerelease/tiddlywiki.info @@ -17,7 +17,9 @@ "tiddlywiki/jszip", "tiddlywiki/confetti", "tiddlywiki/dynannotate", - "tiddlywiki/tour" + "tiddlywiki/tour", + "tiddlywiki/markdown", + "tiddlywiki/ai-tools" ], "themes": [ "tiddlywiki/vanilla", diff --git a/plugins/tiddlywiki/ai-tools/docs.tid b/plugins/tiddlywiki/ai-tools/docs.tid new file mode 100644 index 000000000..103c1e92a --- /dev/null +++ b/plugins/tiddlywiki/ai-tools/docs.tid @@ -0,0 +1,2 @@ +title: $:/plugins/tiddlywiki/ai-tools/docs + diff --git a/plugins/tiddlywiki/ai-tools/globals.tid b/plugins/tiddlywiki/ai-tools/globals.tid new file mode 100644 index 000000000..411da6136 --- /dev/null +++ b/plugins/tiddlywiki/ai-tools/globals.tid @@ -0,0 +1,60 @@ +title: $:/plugins/tiddlywiki/ai-tools/globals +tags: $:/tags/Global + +\function default-llm-completion-server() +[all[shadows+tiddlers]tag[$:/tags/AI/CompletionServer]sort[caption]first[]] +\end + + +\procedure get-llm-completion(payload,resultTitlePrefix,resultTags,statusTitle,completionServer) + + \procedure get-llm-completion-callback() + <%if [compare:number:gteq[200]compare:number:lteq[299]] %> + + <$action-createtiddler + $basetitle=<> + tags=<> + type="text/markdown" + role={{{ [jsonget[choices],[0],[message],[role]] }}} + text={{{ [jsonget[choices],[0],[message],[content]] }}} + /> + <%else%> + + <$action-createtiddler + $basetitle=<> + tags=<> + type="text/markdown" + role="error" + text={{{ [[Error:]] [] [jsonget[error],[message]] +[join[]] }}} + /> + <%endif%> + \end get-llm-completion-callback + +<$let + completionServer={{{ [!is[blank]else] }}} +> + <$action-log message="get-llm-completion"/> + <$action-log/> + <$action-sendmessage + $message="tm-http-request" + url={{{ [get[url]addsuffix[/v1/chat/completions]] }}} + body=<> + header-content-type="application/json" + bearer-auth-token-from-store="openai-secret-key" + method="POST" + oncompletion=<> + bind-status=<> + var-resultTitlePrefix=<> + var-resultTags=<> + /> + +\end get-llm-completion diff --git a/plugins/tiddlywiki/ai-tools/icon.tid b/plugins/tiddlywiki/ai-tools/icon.tid new file mode 100644 index 000000000..3fdc090b5 --- /dev/null +++ b/plugins/tiddlywiki/ai-tools/icon.tid @@ -0,0 +1,5 @@ +title: $:/plugins/tiddlywiki/ai-tools/icon +tags: $:/tags/Image + + + \ No newline at end of file diff --git a/plugins/tiddlywiki/ai-tools/page-menu.tid b/plugins/tiddlywiki/ai-tools/page-menu.tid new file mode 100644 index 000000000..779d01dc7 --- /dev/null +++ b/plugins/tiddlywiki/ai-tools/page-menu.tid @@ -0,0 +1,21 @@ +title: $:/plugins/tiddlywiki/ai-tools/page-menu +tags: $:/tags/PageControls +caption: {{$:/plugins/tiddlywiki/ai-tools/icon}} AI Tools +description: Tools for interactive AI services + +\whitespace trim +<$button popup=<> tooltip="Tools for interactive AI services" aria-label="AI Tools" class=<> selectedClass="tc-selected"> + <%if [match[yes]] %> + {{$:/plugins/tiddlywiki/ai-tools/icon}} + <%endif%> + <%if [match[yes]] %> + <$text text="AI Tools"/> + <%endif%> + +<$reveal state=<> type="popup" position="belowleft" animate="yes"> +
+ <$list filter="[all[shadows+tiddlers]tag[$:/tags/AI/PageMenu]!has[draft.of]]" variable="listItem"> + <$transclude tiddler=<>/> + +
+ diff --git a/plugins/tiddlywiki/ai-tools/page-menu/new-conversation.tid b/plugins/tiddlywiki/ai-tools/page-menu/new-conversation.tid new file mode 100644 index 000000000..1e7464de5 --- /dev/null +++ b/plugins/tiddlywiki/ai-tools/page-menu/new-conversation.tid @@ -0,0 +1,17 @@ +title: $:/plugins/tiddlywiki/ai-tools/page-menu/new-conversation +tags: $:/tags/AI/PageMenu + +\define actions() +<$action-createtiddler + $basetitle="AI Conversation" + tags="$:/tags/AI/Conversation" + system-prompt="You are a helpful assistant." + current-response-text="Please describe this picture" +> +<$action-navigate $to=<>/> + +\end actions + +<$button actions=<> class="tc-btn-invisible"> +{{$:/core/images/new-button}} New Conversation + diff --git a/plugins/tiddlywiki/ai-tools/plugin.info b/plugins/tiddlywiki/ai-tools/plugin.info new file mode 100644 index 000000000..d074b2d4b --- /dev/null +++ b/plugins/tiddlywiki/ai-tools/plugin.info @@ -0,0 +1,7 @@ +{ + "title": "$:/plugins/tiddlywiki/ai-tools", + "name": "AI Tools", + "description": "AI Tools for TiddlyWiki", + "list": "readme docs settings", + "stability": "STABILITY_1_EXPERIMENTAL" +} diff --git a/plugins/tiddlywiki/ai-tools/readme.tid b/plugins/tiddlywiki/ai-tools/readme.tid new file mode 100644 index 000000000..f2fe911d8 --- /dev/null +++ b/plugins/tiddlywiki/ai-tools/readme.tid @@ -0,0 +1,6 @@ +title: $:/plugins/tiddlywiki/ai-tools/readme + +AI Tools for TiddlyWiki. + +This plugin adds new AI tools to the TiddlyWiki platform. + diff --git a/plugins/tiddlywiki/ai-tools/servers/local-llamafile.tid b/plugins/tiddlywiki/ai-tools/servers/local-llamafile.tid new file mode 100644 index 000000000..cd52bd9fe --- /dev/null +++ b/plugins/tiddlywiki/ai-tools/servers/local-llamafile.tid @@ -0,0 +1,4 @@ +title: $:/plugins/tiddlywiki/ai-tools/servers/local-llamafile +tags: $:/tags/AI/CompletionServer +url: http://127.0.0.1:8080 +caption: Locally running Llamafile server diff --git a/plugins/tiddlywiki/ai-tools/servers/openai.tid b/plugins/tiddlywiki/ai-tools/servers/openai.tid new file mode 100644 index 000000000..a1bc1c787 --- /dev/null +++ b/plugins/tiddlywiki/ai-tools/servers/openai.tid @@ -0,0 +1,4 @@ +title: $:/plugins/tiddlywiki/ai-tools/servers/openai +tags: $:/tags/AI/CompletionServer +url: https://api.openai.com +caption: OpenAI Service diff --git a/plugins/tiddlywiki/ai-tools/settings.tid b/plugins/tiddlywiki/ai-tools/settings.tid new file mode 100644 index 000000000..ce35c72cb --- /dev/null +++ b/plugins/tiddlywiki/ai-tools/settings.tid @@ -0,0 +1,16 @@ +title: $:/plugins/tiddlywiki/ai-tools/settings + +! AI Tools Settings + +This plugin runs entirely in the browser, with no backend server component. A consequence of this design is that the API keys required to access external services must be obtained by the end user. These keys are stored in the browser and so only need to be set up once. + +!! ~OpenAI API key + +# Register for an account at https://platform.openai.com/ +#* Newly registered accounts can claim a small amount of credit, thereafter payment is needed +#* Note that ~OpenAI run completely different payment systems for ~ChatGPT and the API platform. Even if you are already a subscriber to ~ChatGPT you will still need to pay for API usage after the initial free service +# Visit https://platform.openai.com/api-keys to create a new secret API key +# Copy and paste the value into the box below + +~OpenAI Secret API Key: <$password name="openai-secret-key"/> + diff --git a/plugins/tiddlywiki/ai-tools/styles.tid b/plugins/tiddlywiki/ai-tools/styles.tid new file mode 100644 index 000000000..7caea5f0e --- /dev/null +++ b/plugins/tiddlywiki/ai-tools/styles.tid @@ -0,0 +1,204 @@ +title: $:/plugins/tiddlywiki/ai-tools/styles +tags: [[$:/tags/Stylesheet]] + +\rules only filteredtranscludeinline transcludeinline macrodef macrocallinline + +.ai-conversation { + background: #f0eeff; + border-radius: 2em; + padding: 1em 1em; + display: flex; + flex-direction: column; + gap: 1em; + box-shadow: 2px 2px 5px rgba(0,0,0,0.2); +} + +.ai-conversation .ai-message { + box-shadow: 2px 2px 5px rgba(0,0,0,0.2); + border-radius: 1em; + display: flex; + flex-direction: column; +} + +.ai-conversation .ai-message .ai-message-toolbar { + background: rgba(1,1,1,0.35); + color: white; + padding: 0.25em 1em 0.25em 1em; + border-top-left-radius: 1em; + border-top-right-radius: 1em; + display: flex; + justify-content: space-between; +} + +.ai-conversation .ai-message .ai-message-toolbar .tc-tiddlylink { + color: inherit; +} + +.ai-conversation .ai-message .ai-message-toolbar .ai-message-toolbar-button { + background: rgba(255,255,255,0.35); + color: #333333; + cursor: pointer; + display: inline-block; + outline: 0; + overflow: hidden; + pointer-events: auto; + position: relative; + text-align: center; + touch-action: manipulation; + user-select: none; + -webkit-user-select: none; + vertical-align: top; + white-space: nowrap; + border: 0; + border-radius: 4px; +} + +.ai-conversation .ai-message .ai-message-toolbar .ai-message-toolbar-button:hover { + color: #ffffff; + background: rgba(255,255,255,0.55); + +} + +.ai-conversation .ai-message .ai-message-body { + padding: 0 1em 0 1em +} + +.ai-conversation .ai-message.ai-message-role-system { + width: 60%; + background: #4c4c80; + color: white; +} + +.ai-conversation .ai-message.ai-message-role-user { + width: 60%; + margin-left: auto; + background: #ffcde0; +} + +.ai-conversation .ai-message.ai-message-role-assistant { + background: #dfd; +} + +.ai-conversation .ai-message.ai-message-role-error { + background: #fdd; +} + +.ai-conversation .ai-user-prompt { + padding: 1em; + background: #ffcde0; + border-radius: 1em; + box-shadow: inset 3px 4px 2px rgba(0, 0, 0, 0.1); +} + +.ai-conversation .ai-user-prompt button svg.tc-image-button { + fill: #000; +} + +.ai-conversation .ai-user-prompt-text { + display: flex; + align-items: flex-start; + gap: 1em; +} + +.ai-conversation .ai-user-prompt button.ai-user-prompt-send { + background-color: initial; + background-image: linear-gradient(-180deg, #e0c3ce, #963057); + border-radius: 1em; + box-shadow: rgba(0, 0, 0, 0.1) 0 2px 4px; + color: #FFFFFF; + cursor: pointer; + display: inline-block; + outline: 0; + overflow: hidden; + padding: 0 20px; + pointer-events: auto; + position: relative; + text-align: center; + touch-action: manipulation; + user-select: none; + -webkit-user-select: none; + vertical-align: top; + white-space: nowrap; + border: 0; + transition: box-shadow .2s; + line-height: 2; +} + +.ai-conversation .ai-user-prompt button.ai-user-prompt-send:hover:not(:disabled) { + box-shadow: rgb(255 62 135 / 64%) 0 3px 8px; +} + +.ai-conversation .ai-user-prompt button.ai-user-prompt-send:disabled { + background: #ddd; + color: #444; +} + +.ai-conversation .ai-user-prompt textarea { + margin: 0; +} + +.ai-request-spinner { + animation: ai-request-spinner-animation-rotate 1s infinite; + height: 50px; + width: 50px; + margin-left: auto; + margin-right: auto; +} + +.ai-request-spinner:before, +.ai-request-spinner:after { + border-radius: 50%; + content: ""; + display: block; + height: 20px; + width: 20px; +} + +.ai-request-spinner:before { + animation: ai-request-spinner-animation-ball1 1s infinite; + background-color: #ddffdd; + box-shadow: 30px 0 0 #90a690; + margin-bottom: 10px; +} + +.ai-request-spinner:after { + animation: ai-request-spinner-animation-ball2 1s infinite; + background-color: #90a690; + box-shadow: 30px 0 0 #ddffdd; +} + +@keyframes ai-request-spinner-animation-rotate { + 0% { transform: rotate(0deg) scale(0.8) } + 50% { transform: rotate(360deg) scale(1.2) } + 100% { transform: rotate(720deg) scale(0.8) } +} + +@keyframes ai-request-spinner-animation-ball1 { + 0% { + box-shadow: 30px 0 0 #90a690; + } + 50% { + box-shadow: 0 0 0 #90a690; + margin-bottom: 0; + transform: translate(15px, 15px); + } + 100% { + box-shadow: 30px 0 0 #90a690; + margin-bottom: 10px; + } +} + +@keyframes ai-request-spinner-animation-ball2 { + 0% { + box-shadow: 30px 0 0 #ddffdd; + } + 50% { + box-shadow: 0 0 0 #ddffdd; + margin-top: -20px; + transform: translate(15px, 15px); + } + 100% { + box-shadow: 30px 0 0 #ddffdd; + margin-top: 0; + } +} \ No newline at end of file diff --git a/plugins/tiddlywiki/ai-tools/view-templates/conversation.tid b/plugins/tiddlywiki/ai-tools/view-templates/conversation.tid new file mode 100644 index 000000000..5e7adab00 --- /dev/null +++ b/plugins/tiddlywiki/ai-tools/view-templates/conversation.tid @@ -0,0 +1,211 @@ +title: $:/plugins/tiddlywiki/ai-tools/view-templates/conversation +tags: $:/tags/ViewTemplate +list-after: $:/core/ui/ViewTemplate/body + + +\function statusTitle() +[addprefix[$:/temp/ai-tools/status/]] +\end statusTitle + + +\procedure ai-message(tiddler,field,role,makeLink:"yes") +<$qualify + name="state" + title={{{ [[$:/state/ai-message-state/]addsuffix] }}} +> + <$let + editStateTiddler={{{ [addsuffix[-edit-state]] }}} + editState={{{ [get[text]else[view]] }}} + > +
addprefix[ai-message-role-]] +[join[ ]] }}}> +
+
+ <$genesis $type={{{ [match[yes]then[$link]else[span]] }}} to=<>> + <$text text=<>/> + +
+
+ <%if [!match[edit]] %> + <$button class="ai-message-toolbar-button"> + <$action-setfield $tiddler=<> text="edit"/> + edit + + <%endif%> + <%if [!match[view]] %> + <$button class="ai-message-toolbar-button"> + <$action-setfield $tiddler=<> text="view"/> + view + + <%endif%> + <$button class="ai-message-toolbar-button"> + <$action-sendmessage $message="tm-copy-to-clipboard" $param={{{ [getelse[]] }}}/> + copy + + <$button class="ai-message-toolbar-button"> + <$action-deletetiddler $tiddler=<>/> + delete + +
+
+
+ <%if [match[view]] %> + <$transclude $tiddler=<> $field=<> $mode="block"/> + <%else%> + <$edit-text tiddler=<> field=<> tag="textarea" class="tc-edit-texteditor"/> + <%endif%> + <%if [get[image]else[]!match[]] %> + <$image source={{{ [get[image]] }}}/> + <%endif%> +
+
+ + +\end ai-message + + +\procedure payload() +\rules only filteredtranscludeinline transcludeinline macrodef macrocallinline html conditional commentblock commentinline +{ + "model": "gpt-4o", + "messages": [ + { + "role": "system", + "content": "<$text text={{{ [get[system-prompt]jsonstringify[]] }}}/>" + } + + <$list filter="[all[shadows+tiddlers]tag!is[draft]sort[created]]"> + , + { + + "role": "<$text text={{{ [get[role]jsonstringify[]] }}}/>", + "content": [ + { + "type": "text", + "text": "<$text text={{{ [get[text]jsonstringify[]] }}}/>" + } + <%if [get[image]else[]!match[]] %> + , + { + "type": "image_url", + "image_url": { + "url": "<$text text={{{ [[data:]] [get[image]get[type]] [[;base64,]] [get[image]get[text]jsonstringify[]] +[join[]] }}}/>" + } + } + <%endif%> + ] + + } + + ] +} +\end payload + + +\procedure action-get-response() +<$let + resultTitlePrefix={{{ [addsuffix[ - Prompt]] }}} + resultTags={{{ [format:titlelist[]] }}} +> + <$action-createtiddler + $basetitle=<> + tags=<> + type="text/markdown" + role="user" + text={{!!current-response-text}} + image={{!!current-response-image}} + > + <$action-deletefield $tiddler=<> $field="current-response-text"/> + <$action-deletefield $tiddler=<> $field="current-response-image"/> + <$wikify name="json" text=<>> + <$transclude + $variable="get-llm-completion" + payload=<> + completionServer={{!!completion-server}} + resultTitlePrefix=<> + resultTags=<> + statusTitle=<> + /> + + + +\end action-get-response + +<%if [tag[$:/tags/AI/Conversation]] %> + +<$select tiddler=<> field="completion-server" default=<>> +<$list filter="[all[shadows+tiddlers]tag[$:/tags/AI/CompletionServer]sort[caption]]"> + + + + +
+ <$transclude + $variable="ai-message" + tiddler=<> + field="system-prompt" + role="system" + makeLink="no" + /> + <$list filter="[all[shadows+tiddlers]tag!is[draft]sort[created]]" variable="message" storyview="pop"> + <$transclude + $variable="ai-message" + tiddler=<> + field="text" + role={{{ [get[role]] }}} + /> + + <%if [get[text]else[complete]match[pending]] %> +
+
+
+ <%endif%> +
+
+ <$edit-text tiddler=<> field="current-response-text" tag="textarea" class="tc-edit-texteditor"/> + <$button + class="ai-user-prompt-send" + actions=<> + disabled={{{ [get[text]else[complete]match[pending]then[yes]] [get[current-response-text]else[]match[]then[yes]] ~[[no]] }}} + > + Send + +
+
+
+ <$let state=<>> + <$button popup=<> class="tc-btn-invisible tc-btn-dropdown">Choose an image {{$:/core/images/down-arrow}} + <$link to={{!!current-response-image}}> + <$text text={{!!current-response-image}}/> + + <$reveal state=<> type="popup" position="belowleft" text="" default="" class="tc-popup-keep"> +
+ <$transclude + $variable="image-picker" + filter="[all[shadows+tiddlers]is[image]is[binary]!has[_canonical_uri]] -[type[application/pdf]] +[!has[draft.of]sort[title]]" + actions=""" + <$action-setfield + $tiddler=<> + current-response-image=<> + /> + <$action-deletetiddler $tiddler=<>/> + """ + /> +
+ + + <$image source={{!!current-response-image}}/> +
+
+
+
+ + +<%endif%> +