diff --git a/.gitattributes b/.gitattributes index ef65336..f4d5078 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,6 +1,7 @@ -calcit.cirru -diff linguist-generated yarn.lock -diff linguist-generated Agents.md -diff linguist-generated -llms/*.md -diff linguist-generated \ No newline at end of file +llms/*.md -diff linguist-generated + +history/*.md -diff linguist-generated \ No newline at end of file diff --git a/.github/workflows/upload.yaml b/.github/workflows/upload.yaml index bca16bb..b7da071 100644 --- a/.github/workflows/upload.yaml +++ b/.github/workflows/upload.yaml @@ -22,9 +22,7 @@ jobs: corepack prepare yarn@4.12.0 --activate yarn --version - - uses: calcit-lang/setup-cr@0.0.8 - with: - version: 0.12.14 + - uses: calcit-lang/setup-cr@0.0.9 - name: "compiles to js" run: > diff --git a/README.md b/README.md index 34ae2ef..d0acf98 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Docs https://ai.google.dev/gemini-api/docs/get-started/tutorial?lang=rest#text-a Configurations: - `gemini-key` in localStorage -- `?model=YOUR_MODEL`, defaults to `gemini-1.5-flash` +- `?model=YOUR_MODEL`, defaults to `gemini-3.5-flash-lite` ### Workflow diff --git a/calcit.cirru b/calcit.cirru new file mode 100644 index 0000000..5e9a653 --- /dev/null +++ b/calcit.cirru @@ -0,0 +1,1883 @@ + +{} (:about "|Machine-generated snapshot. Do not edit directly — changes will be overwritten. Use `cr query` to inspect and `cr edit`/`cr tree` to modify. Run `cr docs agents --full` first. Manual edits must follow format and schema conventions, then run `cr edit format`.") (:package |app) + :configs $ {} (:init-fn |app.main/main!) (:reload-fn |app.main/reload!) (:version |0.0.1) + :modules $ [] |respo.calcit/ |memof/ |respo-ui.calcit/ |reel.calcit/ |respo-markdown.calcit/ |alerts.calcit/ |respo-feather.calcit/ |genai.calcit/ + :entries $ {} + :files $ {} + |app.comp.container $ %{} :FileEntry + :defs $ {} + |*abort-control $ %{} :CodeEntry (:doc |) (:schema :ref) + :code $ quote (defatom *abort-control nil) + :examples $ [] + |*archived-sessions $ %{} :CodeEntry (:doc |) (:schema :ref) + :code $ quote (defatom *archived-sessions nil) + :examples $ [] + |*gen-ai-new $ %{} :CodeEntry (:doc |) (:schema :ref) + :code $ quote (defatom *gen-ai-new nil) + :examples $ [] + |*image-cache $ %{} :CodeEntry (:doc |) (:schema :ref) + :code $ quote (defatom *image-cache nil) + :examples $ [] + |*openai $ %{} :CodeEntry (:doc "|called openai sdk, but actually for openrouter") (:schema :ref) + :code $ quote (defatom *openai nil) + :examples $ [] + |*viewing-archive-session $ %{} :CodeEntry (:doc |) (:schema :ref) + :code $ quote (defatom *viewing-archive-session nil) + :examples $ [] + |append-user-message $ %{} :CodeEntry (:doc |) + :code $ quote + defn append-user-message (messages content) + let + messages0 $ if (some? messages) messages ([]) + conj messages0 $ {} (:role :user) (:content content) + :examples $ [] + :schema $ :: :fn + {} + :args $ [] + :: :optional $ :: :list (:: :map :tag :dynamic) + , :string + :return $ :: :list (:: :map :tag :dynamic) + |call-anthropic-msg! $ %{} :CodeEntry (:doc |) + :code $ quote + defn call-anthropic-msg! (cursor state prompt-text model thinking? d!) + hint-fn $ {} (:async true) + if-let + abort $ deref *abort-control + do (js/console.warn "|Aborting prev") (.!abort abort) + d! $ :: :change-model + let + selected $ js-await (get-selected) + content $ .replace prompt-text |{{selected}} (or selected "|<未找到内容>") + messages0 $ append-user-message (:messages state) content + messages1 $ upsert-assistant-message messages0 | nil + result $ js-await + .!post axios (str |https://sa.chenyong.life/v1/messages) + js-object + :model $ get-env |claude-model (or model |claude-3-5-sonnet-latest) + :max_tokens 1024 + :stream true + :thinking $ if thinking? + js-object (:type |enabled) (:budget_tokens 2000) + , js/undefined + :messages $ messages->anthropic messages0 + js-object + :params $ js-object + :headers $ js-object (; :Accept |text/event-stream) (; :Content-Type |application/json) + |x-api-key $ get-anthropic-key! + |anthropic-version |2023-06-01 + |anthropic-dangerous-direct-browser-access true + :responseType |stream + :adapter |fetch + :signal $ let + abort $ new js/AbortController + reset! *abort-control abort + .-signal abort + stream $ .-data result + reader $ -> + .!pipeThrough stream $ new js/TextDecoderStream + .!getReader + *text $ atom (str "|Claude AI:" &newline &newline) + js/setTimeout $ fn () + d! $ :: :states-merge cursor state + {} (:answer nil) (:thinking nil) (:loading? true) (:done? false) (:messages messages1) + apply-args () $ fn () + hint-fn $ {} (:async true) + let + info $ js-await (.!read reader) + value $ wo-js-log (.-value info) + done? $ .-done info + if (wo-log done?) (:: :unit) + do + let + events $ -> value .split-lines + filter $ fn (s) (.starts-with? s "|data: ") + map $ fn (s) + -> (.strip-prefix s "|data: ") js/JSON.parse to-calcit-data + apply-args (events) + fn (xs) + list-match xs + () $ ;nil println "|no thing to handle in this Loop" + (x0 xss) + let + stop? $ = (get x0 |type) |message_stop + wo-js-log x0 + if stop? + d! $ :: :states-merge cursor state + {} (:answer @*text) (:loading? false) (:done? true) + :messages $ upsert-assistant-message messages1 @*text nil + let + content $ get-in x0 ([] |delta |text) + if (nil? content) + do + ;nil d! $ :: :states cursor + -> state + assoc :answer $ str @*text &newline "|[STOPPED: " (.-finishReason candidate0) |] + assoc :loading? false + assoc :done? true + println "|content is nil" + recur xss + let () (swap! *text str content) + d! $ :: :states-merge cursor state + {} (:answer @*text) (:loading? false) (:done? false) + :messages $ upsert-assistant-message messages1 @*text nil + recur xss + recur + :examples $ [] + :schema $ :: :fn + {} (:return :dynamic) + :args $ [] :list (:: :map :tag :dynamic) :string :string :bool :dynamic + :features $ #{} :js-ffi + |call-flash-imagen-msg! $ %{} :CodeEntry (:doc |) + :code $ quote + defn call-flash-imagen-msg! (variant cursor state prompt-text d!) + hint-fn $ {} (:async true) + if (nil? @*gen-ai-new) + let + mod $ js-await (js/import |@google/genai) + GoogleGenAI $ .-GoogleGenAI mod + reset! *gen-ai-new $ new GoogleGenAI + js-object $ :apiKey (get-gemini-key!) + if-let + target $ js/document.querySelector |.show-image + .!setAttribute target |src | + if-let + abort $ deref *abort-control + do (js/console.warn "|Aborting prev") (.!abort abort) + clear-image-cache! + d! $ :: :states cursor + -> state (assoc :answer nil) (assoc :loading? true) + let + selected $ js-await (get-selected) + gen-ai @*gen-ai-new + content $ .!replace prompt-text |{{selected}} (or selected "|<未找到选中内容>") + abort-signal $ let + abort $ new js/AbortController + reset! *abort-control abort + .-signal abort + sdk-result $ js-await + .!generateContent (.-models gen-ai) + js-object (:model |gemini-2.5-flash-image) (:contents content) + :config $ js-object (:abortSignal abort-signal) + :httpOptions $ js-object (:baseUrl |https://ja.chenyong.life) + :responseModalities $ js-array |TEXT |IMAGE + parts $ -> sdk-result .-candidates .-0 .-content .-parts + *text $ atom | + js-await $ .!forEach parts + fn (? chunk _a _b) + if (some? chunk) + if-let + text $ .-text chunk + do (swap! *text str text) + d! $ :: :states cursor + -> state (assoc :answer @*text) (assoc :loading? false) (assoc :done? false) + if-let + image-data $ .-inlineData chunk + let + image-blob $ base64ToBlob (.-data image-data) + url $ js/URL.createObjectURL image-blob + target $ js/document.querySelector |.show-image + -> target $ .!setAttribute |src url + reset! *image-cache url + do (swap! *text str "|(image ready)") + d! $ :: :states cursor + -> state (assoc :answer @*text) (assoc :loading? false) (assoc :done? false) + d! $ :: :states cursor + -> state (assoc :answer @*text) (assoc :loading? false) (assoc :done? false) + d! $ :: :states cursor + -> state (assoc :answer @*text) (assoc :loading? false) (assoc :done? true) + :examples $ [] + :schema $ :: :fn + {} (:return :dynamic) + :args $ [] :tag :list (:: :map :tag :dynamic) :string :dynamic + :features $ #{} :js-ffi + |call-genai-msg! $ %{} :CodeEntry (:doc |) + :code $ quote + defn call-genai-msg! (variant cursor state prompt-text search? think? d! *text *thinking-text) + hint-fn $ {} (:async true) + if (nil? @*gen-ai-new) + let + mod $ js-await (js/import |@google/genai) + GoogleGenAI $ .-GoogleGenAI mod + reset! *gen-ai-new $ new GoogleGenAI + js-object $ :apiKey (get-gemini-key!) + if-let + abort $ deref *abort-control + do (js/console.warn "|Aborting prev") (.!abort abort) + let + selected $ if (.includes? prompt-text |{{selected}}) + js-await $ get-selected + gen-ai @*gen-ai-new + model $ pick-model variant + content $ .!replace prompt-text |{{selected}} (or selected "|<未找到选中内容>") + json? $ or (.!includes prompt-text |{{json}}) (.!includes prompt-text |{{JSON}}) + pro? $ .!includes model |pro + has-url? $ or (.!includes prompt-text |http://) (.!includes prompt-text |https://) + messages0 $ or (:messages state) ([]) + messages1 $ upsert-assistant-message messages0 | nil + abort-signal $ let + abort $ new js/AbortController + reset! *abort-control abort + .-signal abort + tools $ -> + js-array + if search? + js-object $ :googleSearch (js-object) + , js/undefined + if has-url? + js-object $ :urlContext (js-object) + , js/undefined + .!filter $ fn (x _idx _arr) x + sdk-result $ js-await + .!generateContentStream (.-models gen-ai) + js-object (:model model) + :contents $ messages->gemini messages0 + :config $ js-object + :thinkingConfig $ if think? + js-object (:thinkingLevel |high) (:includeThoughts true) + js-object (:thinkingLevel |minimal) (:includeThoughts false) + :tools $ if + > (.-length tools) 0 + , tools js/undefined + :abortSignal abort-signal + :httpOptions $ js-object (:baseUrl |https://ja.chenyong.life) + :responseMimeType $ if json? |application/json js/undefined + do + js/setTimeout $ fn () + d! $ :: :states-merge cursor state + {} (:answer nil) (:thinking nil) (:loading? true) (:done? false) (:messages messages1) + js-await $ js-for-await sdk-result + fn (? chunk) + if (some? chunk) + let + part $ -> chunk .-candidates .-0 .-content .-parts .-0 + is-thinking? $ if (some? part) (.-thought part) false + text $ or + if (some? part) (.-text part) (.-text chunk) + -> chunk .-promptFeedback .-blockReason + if is-thinking? (swap! *thinking-text str text) (swap! *text str text) + d! $ :: :states-merge cursor state + {} (:answer @*text) (:thinking @*thinking-text) (:loading? false) (:done? false) + :messages $ upsert-assistant-message messages1 @*text @*thinking-text + d! $ :: :states-merge cursor state + {} (:answer @*text) (:thinking @*thinking-text) (:loading? false) (:done? false) + :messages $ upsert-assistant-message messages1 @*text @*thinking-text + d! $ :: :states-merge cursor state + {} (:answer @*text) (:thinking @*thinking-text) (:loading? false) (:done? true) + :messages $ upsert-assistant-message messages1 @*text @*thinking-text + :examples $ [] + :schema $ :: :fn + {} (:return :dynamic) + :args $ [] :tag :list (:: :map :tag :dynamic) :string :bool :bool :dynamic :ref :ref + :features $ #{} :js-ffi + |call-imagen-4-msg! $ %{} :CodeEntry (:doc |) + :code $ quote + defn call-imagen-4-msg! (variant cursor state prompt-text d!) + hint-fn $ {} (:async true) + if (nil? @*gen-ai-new) + let + mod $ js-await (js/import |@google/genai) + GoogleGenAI $ .-GoogleGenAI mod + reset! *gen-ai-new $ new GoogleGenAI + js-object $ :apiKey (get-gemini-key!) + if-let + target $ js/document.querySelector |.show-image + .!removeAttribute target |src + if-let + abort $ deref *abort-control + do (js/console.warn "|Aborting prev") (.!abort abort) + clear-image-cache! + d! $ :: :states cursor + -> state (assoc :answer nil) (assoc :loading? true) + let + selected $ js-await (get-selected) + gen-ai @*gen-ai-new + abort-signal $ let + abort $ new js/AbortController + reset! *abort-control abort + .-signal abort + response $ js-await + .!generateImages (.-models gen-ai) + js-object (:model |imagen-4.0-generate-001) (:prompt prompt-text) + :config $ js-object (:numberOfImages 1) (:includeRaiReason true) + :httpOptions $ js-object (:baseUrl |https://ja.chenyong.life) + :signal abort-signal + *text $ atom | + if-let + image-data $ -> response .-generatedImages .-0 .-image .-imageBytes + let + image-blob $ base64ToBlob image-data + url $ js/URL.createObjectURL image-blob + target $ js/document.querySelector |.show-image + reset! *image-cache url + -> target $ .!setAttribute |src url + do (swap! *text str "|(image ready)") + d! $ :: :states cursor + -> state (assoc :answer @*text) (assoc :loading? false) (assoc :done? false) + d! $ :: :states cursor + -> state (assoc :answer @*text) (assoc :loading? false) (assoc :done? true) + :examples $ [] + :schema $ :: :fn + {} (:return :dynamic) + :args $ [] :tag :list (:: :map :tag :dynamic) :string :dynamic + :features $ #{} :js-ffi + |call-openrouter! $ %{} :CodeEntry (:doc |) + :code $ quote + defn call-openrouter! (cursor state prompt-text variant thinking? d! *text) + hint-fn $ {} (:async true) + if (nil? @*openai) + let + mod $ js-await (js/import |openai) + OpenAI $ .-default mod + deepseek? $ .!includes variant |deepseek + base-url $ if deepseek? |https://api.deepseek.com |https://openrouter.ai/api/v1 + key-fn $ if deepseek? get-deepseek-key! get-openrouter-key! + reset! *openai $ new OpenAI + js-object (:baseURL base-url) + :apiKey $ key-fn + :defaultHeaders $ js-object + :dangerouslyAllowBrowser true + if-let + abort $ deref *abort-control + do (js/console.warn "|Aborting prev") (.!abort abort) + let + selected $ js-await (get-selected) + openai $ let + ai @*openai + , ai + content $ .!replace prompt-text |{{selected}} (or selected "|<未找到选中内容>") + json? $ or (.!includes prompt-text |{{json}}) (.!includes prompt-text |{{JSON}}) + messages0 $ or (:messages state) ([]) + messages1 $ upsert-assistant-message messages0 | nil + sdk-result $ js-await + -> openai .-chat .-completions $ .!create + js-object (:model variant) + :messages $ messages->openai messages0 + :stream true + :headers $ js-object (:HTTP-Referer js/location.host) + js-object $ :signal + let + abort $ new js/AbortController + reset! *abort-control abort + .-signal abort + do + js/setTimeout $ fn () + d! $ :: :states-merge cursor state + {} (:answer nil) (:thinking nil) (:loading? true) (:done? false) (:messages messages1) + let + *thinking-text $ atom | + *char-buf $ atom 0 + js-await $ js-for-await sdk-result + fn (? chunk) + if (some? chunk) + let + choice $ -> chunk .-choices .-0 .-delta + reason $ -> choice .-reasoning_content (or |) + text $ -> choice .-content (or |) + if + not $ blank? reason + swap! *thinking-text str reason + if + not $ blank? text + swap! *text str text + swap! *char-buf + $ count text + when + or (> @*char-buf 80) (.!includes text &newline) + do (reset! *char-buf 0) + d! $ :: :states-merge cursor state + {} (:answer @*text) (:thinking @*thinking-text) (:loading? false) (:done? false) + :messages $ upsert-assistant-message messages1 @*text @*thinking-text + d! $ :: :states-merge cursor state + {} (:answer @*text) (:thinking @*thinking-text) (:loading? false) (:done? false) + :messages $ upsert-assistant-message messages1 @*text @*thinking-text + d! $ :: :states-merge cursor state + {} (:answer @*text) (:thinking @*thinking-text) (:loading? false) (:done? true) + :messages $ upsert-assistant-message messages1 @*text @*thinking-text + :examples $ [] + :schema $ :: :fn + {} (:return :dynamic) + :args $ [] :list (:: :map :tag :dynamic) :string :string :bool :dynamic :ref + :features $ #{} :js-ffi + |clear-image-cache! $ %{} :CodeEntry (:doc |) (:schema :dynamic) + :code $ quote + defn clear-image-cache! () $ if-let (url @*image-cache) + do (js/URL.revokeObjectURL url) (reset! *image-cache nil) + :examples $ [] + |comp-abort $ %{} :CodeEntry (:doc |) (:schema :dynamic) + :code $ quote + defn comp-abort (t) + span + {} (:role |button) + :aria-label $ str |abort- t + :class-name $ str-spaced css/font-fancy css/row-middle style-more + :style $ {} (:cursor :pointer) + :on-click $ fn (e d!) + if-let + abort $ deref *abort-control + do (js/console.warn "|Aborting prev") (.!abort abort) + <> t + =< 8 nil + <> "|✕" style-abort-close + :examples $ [] + |comp-container $ %{} :CodeEntry (:doc |) + :code $ quote + defcomp comp-container (reel) + let + store $ :store reel + sessions $ or (:sessions store) ([]) + archived-count $ or (:archived-count store) 0 + current-session-id $ :current-session-id store + states $ :states store + cursor $ or (:cursor states) ([]) + state $ or (:data states) + {} (:answer nil) (:loading? false) (:done? false) + :messages $ [] + done? $ :done? state + messages $ or (:messages state) ([]) + model $ either (:model state) :gemini + is-viewing-history? $ and (some? current-session-id) + let + current-session $ -> sessions + filter $ fn (s) + = (:id s) current-session-id + , first + if (some? current-session) (:is-history? current-session) false + last-assistant $ let + size $ count messages + last-msg $ if (> size 0) (last messages) nil + if + and (some? last-msg) + = :assistant $ :role last-msg + :content last-msg + :answer state + model-plugin $ use-modal-menu (>> states :model) + {} (; :title "|Select model") + :style $ {} (:width 300) + :backdrop-style $ {} + ; :card-class style-card + ; :backdrop-class style-backdrop + ; :confirm-class style-confirm + :items models-menu + :on-result $ fn (result d!) + d! cursor $ assoc state :model (nth result 1) + reply-plugin $ use-prompt (>> states :reply-prompt) + {} (:text |Follow-up) (:placeholder "|Enter your follow-up") (:multiline? true) (:button-text |Send) + :validator $ fn (text) + if (blank? text) "|Please enter text" nil + message-box-state $ either + :data $ >> states :message-box + {} (:search? false) (:think? false) + sessions-plugin $ use-drawer (>> states :sessions-modal) + {} (:title "|History Sessions") + :style $ {} (:min-width "||max(320px,30vw)\"") (:max-width |80vw) + :render $ fn (on-close) + comp-sessions-modal sessions archived-count + fn (session-id d!) + when + and + > (count messages) 0 + :done? state + nil? current-session-id + d! $ :: :save-session state + d! $ :: :load-session cursor state + -> sessions + filter $ fn (s) + = (:id s) session-id + , first $ either ({}) + on-close d! + , on-close $ fn (d!) + hint-fn $ {} (:async true) + let + archive-key $ :archive-key site + raw $ js-await (db-get archive-key) + if (blank? raw) (js/alert "|No archives found!") + let + parsed $ parse-cirru-edn raw + reset! *archived-sessions parsed + div + {} $ :class-name (str-spaced css/preset css/global css/column css/fullscreen css/gap8 style-app-global) + if (some? @*archived-sessions) + if (some? @*viewing-archive-session) (; Render specific read-only archived session) + div + {} $ :class-name (str-spaced css/expand style-message-area) + div + {} $ :class-name style-archive-header + div $ {} + :style $ {} (:font-weight :bold) + :inner-text $ str "|Archived: " (:preview @*viewing-archive-session) + div + {} (:class-name style-archive-close) + :on-click $ fn (e d!) (reset! *viewing-archive-session nil) + <> "|✕" + ; Messages list $ read only + div + {} (:role |region) (:aria-label |message-list) + :class-name $ str-spaced css/column style-message-list + list-> + {} $ :class-name (str-spaced css/column css/gap8) + -> (:messages @*viewing-archive-session) + map-indexed $ fn (idx msg) + [] idx $ let + role $ :role msg + content $ :content msg + thinking $ :thinking msg + div + {} $ :class-name + str-spaced style-message-item $ if (= role :assistant) style-message-assistant style-message-user + div + {} $ :class-name style-message-role + <> $ if (= role :assistant) |Assistant |You + if + not $ blank? thinking + div + {} $ :class-name style-thinking + memof1-call comp-md-block + -> thinking $ either | + {} $ :class-name style-md-content + if (= role :assistant) + if (json-pattern? content) + pre $ {} (:class-name style-code-content) (:inner-text content) + memof1-call comp-md-block + -> content $ either | + {} $ :class-name style-md-content + pre $ {} (:class-name style-message-text) (:inner-text content) + ; Render archived sessions list + div + {} $ :class-name (str-spaced css/expand style-message-area) + div + {} $ :class-name style-archive-header + div $ {} + :style $ {} (:font-weight :bold) + :inner-text "|All Archived Sessions" + div + {} (:class-name style-archive-close) + :on-click $ fn (e d!) (reset! *archived-sessions nil) (reset! *viewing-archive-session nil) + <> "|✕" + ; List + div + {} $ :class-name (str-spaced css/column css/gap8 style-message-list) + list-> + {} $ :class-name css/column + let + current-archives $ or @*archived-sessions ([]) + if (empty? current-archives) + [] :empty $ div + {} $ :style + {} (:padding |12px) + :color $ hsl 0 0 60 + <> "|No archived sessions left." + -> current-archives .reverse $ map + fn (session) + let + session-id $ :id session + created-at $ :created-at session + preview $ :preview session + date-str $ .!toLocaleString (new js/Date created-at) + [] session-id $ div + {} $ :class-name style-session-item + div + {} (:role |button) + :style $ {} (:flex |1) (:cursor :pointer) (:min-width 0) (:overflow :hidden) + :on-click $ fn (e d!) (reset! *viewing-archive-session session) + div + {} $ :style + {} (:font-size |12px) + :color $ hsl 0 0 60 + <> date-str + div + {} $ :style + {} (:margin-top |4px) (:white-space :nowrap) (:overflow :hidden) (:text-overflow :ellipsis) (:max-height |1.2em) (:line-height |1.2) + <> preview + div + {} (:class-name style-delete-button) (:role |button) + :on-click $ fn (e d!) + hint-fn $ {} (:async true) + let + proceed? $ js/confirm "|Delete this archived session?" + when proceed? $ let + new-archives $ filter @*archived-sessions + fn (s) + not= (:id s) session-id + reset! *archived-sessions new-archives + let + archive-key $ :archive-key site + js-await $ db-set archive-key (format-cirru-edn new-archives) + d! $ :: :update-archived-count (count new-archives) + <> "|✕" + ; Else render normal chat view + div + {} $ :class-name (str-spaced css/expand css/column) + div + {} $ :class-name (str-spaced css/expand style-message-area) + div + {} + :class-name $ str-spaced css/row-parted + :style $ {} (:padding |8px) + div $ {} + div + {} (:class-name css/row-middle) (:title |History) (:role |button) (:aria-label |history-open) + :style $ {} (:cursor :pointer) + :on-click $ fn (e d!) (.show sessions-plugin d!) + div + {} $ :class-name style-history-button + comp-i |clock + =< 4 nil + if + > (count sessions) 0 + <> + str $ count sessions + str-spaced css/font-fancy style-history-count + div + {} (:role |region) (:aria-label |message-list) + :class-name $ str-spaced css/column style-message-list + if + or (= :imagen-4 model) (= :flash-imagen model) + img $ {} + :class-name $ str-spaced style-image |show-image + list-> + {} $ :class-name (str-spaced css/column css/gap8) + -> messages $ map-indexed + fn (idx msg) + [] idx $ let + role $ :role msg + content $ :content msg + thinking $ :thinking msg + div + {} $ :class-name + str-spaced style-message-item $ if (= role :assistant) style-message-assistant style-message-user + div + {} $ :class-name style-message-role + <> $ if (= role :assistant) |Assistant |You + if + not $ blank? thinking + div + {} $ :class-name style-thinking + memof1-call comp-md-block + -> thinking $ either | + {} $ :class-name style-md-content + if (= role :assistant) + if (json-pattern? content) + pre $ {} (:class-name style-code-content) (:inner-text content) + memof1-call comp-md-block + -> content $ either | + {} $ :class-name style-md-content + pre $ {} (:class-name style-message-text) (:inner-text content) + if + and (= role :assistant) + or done? $ not= idx + dec $ count messages + div + {} $ :class-name (str-spaced css/row-middle css/gap8 style-message-actions) + if chrome-extension? + comp-fill $ either content | + , nil + comp-copy $ either content | + , nil + if + and + > (count messages) 0 + :done? state + not is-viewing-history? + div + {} $ :class-name (str-spaced css/row-middle css/gap8 style-reply-actions) + button + {} (:role |button) (:aria-label |reply-message) + :class-name $ str-spaced css/button style-reply-button + :on-click $ fn (e d!) + .show reply-plugin d! $ fn (text) + submit-message! cursor state text (:search? message-box-state) (:think? message-box-state) model d! + <> |Reply + if (:focus-mode? message-box-state) nil $ a + {} (:class-name style-focus-link) (:inner-text |Focus) (:role |button) (:aria-label |focus-composer) + :on-click $ fn (e d!) + let + focused $ .-activeElement js/document + do + if (some? focused) (.!blur focused) + d! + :cursor $ >> states :message-box + assoc message-box-state :focus-mode? true + , nil + if (:loading? state) + div ({}) (memof1-call-by :abort-loading comp-abort |Loading...) + div + {} $ :class-name css/row-parted + div + {} $ :class-name (str-spaced css/row-middle css/gap8) + if (:done? state) nil $ div + {} $ :style + {} (:display :flex) (:justify-content :center) (:align-items :center) + memof1-call-by :abort-streaming comp-abort |Streaming... + if (:done? state) + div $ {} + :class-name $ str-spaced css/row-middle css/gap8 + =< nil 200 + comp-message-box (>> states :message-box) + a $ {} + :inner-text $ or (turn-str model) |- + :role |button + :aria-label $ str |model-picker: (turn-string model) + :class-name $ str-spaced style-a-toggler + :style $ {} + :opacity $ if (= model :anthropic) 1 0.3 + :on-click $ fn (e d!) + ; d! $ :: :change-model + .show model-plugin d! + fn (text search? think? d!) + do + when + and + > (count messages) 0 + :done? state + nil? current-session-id + d! $ :: :save-session state + d! cursor $ -> state + assoc :messages $ [] + assoc :answer nil + assoc :thinking nil + assoc :done? false + d! $ :: :session :session-id nil + submit-message! cursor + -> state + assoc :messages $ [] + assoc :answer nil + assoc :thinking nil + assoc :done? false + , text search? think? model d! + , model + model-plugin.render + reply-plugin.render + sessions-plugin.render + if dev? $ comp-reel (>> states :reel) reel ({}) + if dev? $ comp-inspect |Store store nil + :examples $ [] + :schema $ :: :fn + {} (:return :dynamic) + :args $ [] (:: :map :tag :dynamic) + :features $ #{} :js-ffi + |comp-fill $ %{} :CodeEntry (:doc |) (:schema :dynamic) + :code $ quote + defcomp comp-fill (text) + div + {} (:class-name style-fill) (:role |button) (:aria-label |fill-extension) + :on-click $ fn (e d!) + when chrome-extension? $ js/chrome.runtime.sendMessage + js-object (:action |fill-text) (:text text) + comp-i :send 12 :currentColor + :examples $ [] + |comp-message-box $ %{} :CodeEntry (:doc |) (:schema :dynamic) + :code $ quote + defcomp comp-message-box (states picker-el on-submit model) + let + cursor $ :cursor states + state $ either (:data states) + {} (:content |) (:search? false) (:think? false) (:focus-mode? false) + [] (effect-focus) (on-fill cursor state on-submit) + div + {} $ :class-name (str-spaced css/center style-message-box-panel) + div + {} $ :class-name (str-spaced css/column style-message-box) + if (:focus-mode? state) + div + {} (:role |button) (:aria-label |expand-prompt) + :class-name $ str-spaced css/font-code! style-focus-box style-textbox-compact + :on-click $ fn (e d!) + do + d! cursor $ assoc state :focus-mode? false + js/setTimeout + fn () $ -> (js/document.querySelector |#message) (.!focus) + , 0 + <> $ if + blank? $ :content state + , "|Click to expand and type..." (:content state) + textarea $ {} + :value $ :content state + :placeholder "|Prompt to try LLM..." + :id |message + :role |textbox + :aria-label |prompt-input + :class-name $ str-spaced css/textarea css/font-code! style-textbox + :on-input $ fn (e d!) + d! cursor $ assoc state :content + str $ :value e + :on-keydown $ fn (e d!) + if + and + = 13 $ :keycode e + or (:meta? e) (:ctrl? e) + on-submit (:content state) (:search? state) (:think? state) d! + :on-focus $ fn (e d!) + let + target $ .-target (:event e) + box $ .-parentElement (.-parentElement target) + class-list $ .-classList target + box-class $ .-classList box + if + not $ .!contains class-list |focus-within + .!add class-list |focus-within + if + not $ .!contains box-class |focus-within + .!add box-class |focus-within + :on-blur $ fn (e d!) + let + target $ .-target (:event e) + box $ .-parentElement (.-parentElement target) + class-list $ .-classList target + box-class $ .-classList box + if (.!contains class-list |focus-within) (.!remove class-list |focus-within) + if (.!contains box-class |focus-within) (.!remove box-class |focus-within) + if + not $ :focus-mode? state + do (=< nil 4) + div + {} $ :class-name css/row-parted + if + not $ blank? (:content state) + span $ {} (:inner-text "|✕") + :class-name $ str-spaced style-close style-clear + :role |button + :aria-label |clear-prompt + :on-click $ fn (e d!) + d! cursor $ assoc state :content | + -> (js/document.querySelector |#message) (.!focus) + span $ {} (:class-name style-clear) + div + {} $ :class-name (str-spaced css/row style-gap12) + , picker-el + if + contains? (#{} :gemini-flash :gemini-3.5-flash :gemini-3.1-flash-lite-preview) model + div + {} (:role |group) (:aria-label |think-toggle) + :class-name $ str-spaced css/row style-checkbox + :on-click $ fn (e d!) + d! cursor $ assoc state :think? + not $ :think? state + input $ {} + :checked $ :think? state + :type |checkbox + :role |checkbox + :aria-label |think-toggle + <> |Think css/font-fancy + , nil + div + {} (:role |group) (:aria-label |search-toggle) + :class-name $ str-spaced css/row style-checkbox + :on-click $ fn (e d!) + d! cursor $ assoc state :search? + not $ :search? state + input $ {} + :checked $ :search? state + :type |checkbox + :role |checkbox + :aria-label |search-toggle + <> |Search css/font-fancy + button $ {} (:role |button) (:aria-label |submit-message) + :class-name $ str-spaced css/button style-submit + :inner-text |Submit + :on-click $ fn (e d!) + on-submit (:content state) (:search? state) (:think? state) d! + , nil + :examples $ [] + |comp-sessions-modal $ %{} :CodeEntry (:doc |) + :code $ quote + defcomp comp-sessions-modal (sessions archived-count on-select on-close on-view-archive) + let + history-items $ foldl sessions 0 + fn (acc session) + + acc $ count + or (:messages session) ([]) + div + {} $ :class-name (str-spaced css/column css/gap8 style-sessions-list) + if (> archived-count 0) + div + {} $ :class-name style-archive-row + span $ {} + :style $ {} + :color $ hsl 0 0 50 + :inner-text $ str "|Archived: " archived-count "| sessions" + button + {} (:class-name css/button) + :style $ {} (:cursor :pointer) + :on-click $ fn (e d!) (on-close d!) (on-view-archive d!) + <> "|View Archive" + if (empty? sessions) + div + {} $ :style + {} (:padding |12px) + :color $ hsl 0 0 60 + <> "|No history sessions" + list-> + {} $ :class-name css/column + -> sessions (.!reverse) + map $ fn (session) + let + session-id $ :id session + created-at $ :created-at session + preview $ :preview session + date-str $ .!toLocaleString (new js/Date created-at) + [] session-id $ div + {} $ :class-name style-session-item + div + {} (:role |button) + :aria-label $ str |session-select: preview + :style $ {} (:flex |1) (:cursor :pointer) (:min-width 0) (:overflow :hidden) + :on-click $ fn (e d!) (on-select session-id d!) (on-close d!) + div + {} $ :style + {} (:font-size |12px) + :color $ hsl 0 0 60 + <> date-str + div + {} $ :style + {} (:margin-top |4px) (:white-space :nowrap) (:overflow :hidden) (:text-overflow :ellipsis) (:max-height |1.2em) (:line-height |1.2) + <> preview + div + {} (:class-name style-delete-button) (:role |button) + :aria-label $ str |session-delete: session-id + :on-click $ fn (e d!) (-> e :event .!stopPropagation) + d! $ :: :remove-session session-id + <> "|✕" + if + > (count sessions) 0 + div + {} + :class-name $ str-spaced css/column css/gap8 + :style $ {} (:padding "|0 12px 12px 12px") + div + {} $ :class-name (str-spaced css/row-parted) + div + {} $ :class-name (str-spaced css/row css/gap8) + a $ {} (:class-name style-clear) (:inner-text |Data) (:role |button) (:aria-label |sessions-export-data) + :on-click $ fn (e d!) (tab-echo! sessions :edn) + a $ {} (:class-name style-clear) (:inner-text |Download) (:role |button) (:aria-label |sessions-download) + :on-click $ fn (e d!) (download-sessions! sessions) + if + > (count sessions) 0 + a $ {} (:class-name style-clear) (:inner-text "|Archive all") (:role |button) (:aria-label |sessions-archive-all) + :on-click $ fn (e d!) + hint-fn $ {} (:async true) + let + proceed? $ js/confirm + str "|Archive " (count sessions) "| sessions and clear active view?" + when proceed? $ let + archive-key $ :archive-key site + raw $ js-await (db-get archive-key) + old-archives $ if (blank? raw) ([]) (parse-cirru-edn raw) + new-archives $ concat old-archives sessions + js-await $ db-set archive-key (format-cirru-edn new-archives) + d! $ :: :archive-sessions (count new-archives) + span $ {} + div $ {} + :style $ {} (:height 200) + , nil + :examples $ [] + :schema $ :: :fn + {} (:return :dynamic) + :args $ [] + :: :list $ :: :map :tag :dynamic + , :number :dynamic :dynamic :dynamic + :features $ #{} :js-ffi + |create-session $ %{} :CodeEntry (:doc |) + :code $ quote + defn create-session (messages model) + let + id $ generate-session-id + first-msg $ if + > (count messages) 0 + :content $ first messages + , "|New chat" + {} (:id id) + :created-at $ js/Date.now + :messages messages + :model model + :preview $ let + len $ count first-msg + end $ if (< len 100) len 100 + .!slice first-msg 0 end + :is-history? false + :examples $ [] + :schema $ :: :fn + {} + :args $ [] + :: :list $ :: :map :tag :dynamic + , :tag + :return $ :: :map :tag :dynamic + |download-sessions! $ %{} :CodeEntry (:doc |) + :code $ quote + defn download-sessions! (sessions) + let + content $ format-cirru-edn sessions + blob $ new js/Blob (js-array content) + js-object $ :type |application/edn;charset=utf-8 + url $ js/URL.createObjectURL blob + link $ js/document.createElement |a + filename $ str |sessions- (js/Date.now) |.cirru + do (.!setAttribute link |href url) (.!setAttribute link |download filename) (.!appendChild js/document.body link) (.!click link) (.!remove link) + js/setTimeout + fn () $ js/URL.revokeObjectURL url + , 0 + :examples $ [] + :schema $ :: :fn + {} (:return :dynamic) + :args $ [] + :: :list $ :: :map :tag :dynamic + :features $ #{} :js-ffi + |effect-focus $ %{} :CodeEntry (:doc |) (:schema :dynamic) + :code $ quote + defeffect effect-focus () (action el at?) + when (= action :mount) + js/setTimeout $ fn () + .!select $ .!querySelector el |textarea + :examples $ [] + |first-line $ %{} :CodeEntry (:doc "|last message from error contains a line starts with \"data: \" and an extra error message. In order that JSON is parsed correctly, only first line is used now.") + :code $ quote + defn first-line (tt) + let + lines $ -> tt (.!split &newline) + .!filter $ fn (line idx _a) + not $ blank? line + if + > (.-length lines) 1 + js/console.warn "|Droping some unexpected lines:" $ .!slice lines 1 + .-0 lines + :examples $ [] + :schema $ :: :fn + {} (:return :string) + :args $ [] :string + |generate-session-id $ %{} :CodeEntry (:doc |) + :code $ quote + defn generate-session-id () $ str (js/Date.now) + :examples $ [] + :schema $ :: :fn + {} (:return :string) + :args $ [] + |get-anthropic-key! $ %{} :CodeEntry (:doc |) + :code $ quote + defn get-anthropic-key! () $ let + key $ js/localStorage.getItem |claude-key + if (blank? key) + let + v $ js/prompt "|Required claude-key in localStorage" + if (blank? v) + raise $ new js/Error "|key is empty" + js/localStorage.setItem |claude-key v + , v + , key + :examples $ [] + :schema $ :: :fn + {} (:return :dynamic) + :args $ [] + :features $ #{} :js-ffi + |get-deepinfra-key! $ %{} :CodeEntry (:doc |) (:schema :dynamic) + :code $ quote + defn get-deepinfra-key! () $ let + key $ js/localStorage.getItem |deepinfra-key + if (blank? key) + let + v $ js/prompt "|Required deepinfra-key in localStorage" + if (blank? v) + raise $ new js/Error "|key is empty" + js/localStorage.setItem |deepinfra-key v + , v + , key + :examples $ [] + |get-deepseek-key! $ %{} :CodeEntry (:doc |) + :code $ quote + defn get-deepseek-key! () $ let + key $ js/localStorage.getItem |deepseek-key + if (blank? key) + let + v $ js/prompt "|Required deepseek-key in localStorage" + if (blank? v) + raise $ new js/Error "|key is empty" + js/localStorage.setItem |deepseek-key v + js/chrome.storage.local.set $ js-object (:deepseekKey v) + , v + , key + :examples $ [] + :schema $ :: :fn + {} (:return :dynamic) + :args $ [] + :features $ #{} :js-ffi + |get-gemini-key! $ %{} :CodeEntry (:doc |) + :code $ quote + defn get-gemini-key! () $ let + key $ js/localStorage.getItem |gemini-key + if (blank? key) + let + v $ js/prompt "|Required gemini-key in localStorage" + if (blank? v) + raise $ new js/Error "|key is empty" + js/localStorage.setItem |gemini-key v + js/chrome.storage.local.set $ js-object (:geminiKey v) + , v + , key + :examples $ [] + :schema $ :: :fn + {} (:return :dynamic) + :args $ [] + :features $ #{} :js-ffi + |get-openrouter-key! $ %{} :CodeEntry (:doc |) + :code $ quote + defn get-openrouter-key! () $ let + key $ js/localStorage.getItem |openrouter-key + if (blank? key) + let + v $ js/prompt "|Required openrouter-key in localStorage" + if (blank? v) + raise $ new js/Error "|key is empty" + js/localStorage.setItem |openrouter-key v + , v + , key + :examples $ [] + :schema $ :: :fn + {} (:return :dynamic) + :args $ [] + :features $ #{} :js-ffi + |json-pattern? $ %{} :CodeEntry (:doc |) + :code $ quote + defn json-pattern? (text) + or (.!startsWith text |{) (.!startsWith text |[) + :examples $ [] + :schema $ :: :fn + {} (:return :bool) + :args $ [] :string + |messages->anthropic $ %{} :CodeEntry (:doc |) + :code $ quote + defn messages->anthropic (messages) + to-js-data $ map (or messages []) + fn (m) + {} + :role $ if + = :assistant $ :role m + , |assistant |user + :content $ :content m + :examples $ [] + :schema $ :: :fn + {} (:return :dynamic) + :args $ [] + :: :optional $ :: :list (:: :map :tag :dynamic) + :features $ #{} :js-ffi + |messages->gemini $ %{} :CodeEntry (:doc |) + :code $ quote + defn messages->gemini (messages) + let + messages0 $ if (some? messages) messages ([]) + to-js-data $ map messages0 + fn (m) + {} + :role $ if + = :assistant $ :role m + , |model |user + :parts $ [] + {} $ :text (:content m) + :examples $ [] + :schema $ :: :fn + {} + :args $ [] + :: :list $ :: :map :tag :dynamic + :features $ #{} :js-ffi + :return $ :: :map :tag :dynamic + |messages->openai $ %{} :CodeEntry (:doc |) + :code $ quote + defn messages->openai (messages) + let + messages0 $ if (some? messages) messages ([]) + to-js-data $ map messages0 + fn (m) + {} + :role $ if + = :assistant $ :role m + , |assistant |user + :content $ :content m + :examples $ [] + :schema $ :: :fn + {} (:return :dynamic) + :args $ [] + :: :optional $ :: :list (:: :map :tag :dynamic) + :features $ #{} :js-ffi + |models-menu $ %{} :CodeEntry (:doc |) (:schema :list) + :code $ quote + def models-menu $ [] (:: :item :gemini-3.5-flash-lite "|Gemini Flash Lite 3.5") (:: :item :gemini-3.6-flash "|Gemini Flash 3.6") (:: :item :gemini-flash "|Gemini Flash 3") (:: :item :gemini-3.5-flash "|Gemini Flash 3.5") (:: :item :gemini-pro "|Gemini Pro 3.1") (:: :item :gemini-3.1-flash-lite-preview "|Gemini Flash Lite 3.1") (:: :item :flash-imagen "|Flash Imagen") (:: :item :imagen-4 "|Imagen 4") (:: :item :gemma "|Gemma 3 27b") (:: :item :openrouter/anthropic/claude-sonnet-4.5 "|Openrouter Claude Sonnet 4.5") (:: :item :openrouter/anthropic/claude-opus-4 "|Openrouter Claude Opus 4") (:: :item :openrouter/google/gemini-2.5-pro-preview "|Openrouter Google Gemini 2.5 pro preview") (:: :item :openrouter/google/gemini-2.5-flash-preview-05-20 "|Openrouter Google Gemini 2.5 flash preview") (:: :item :openrouter/openai/gpt-5 "|Openrouter GPT 5") (:: :item :openrouter/deepseek/deepseek-chat-v3.1 "|Openrouter deepseek-chat-v3.1") (:: :item :deepseek-v4-pro "|DeepSeek V4 Pro") (:: :item :deepseek-v4-flash "|DeepSeek V4 Flash") (; :: :item :claude-4.5 "|Claude 4.5") + :examples $ [] + |on-fill $ %{} :CodeEntry (:doc |) (:schema :dynamic) + :code $ quote + defn on-fill (cursor state on-submit) + %{} respo.schema/RespoListener (:name :on-fill) + :handler $ fn (event dispatch!) + tag-match event $ + :fill-text info + let + submit? $ either (:submit? info) true + do + dispatch! $ :: :states cursor + assoc state :content $ :text info + if submit? + on-submit (:text info) (:search? state) (:think? state) dispatch! + , nil + :examples $ [] + |pattern-spaced-code $ %{} :CodeEntry (:doc |) (:schema :dynamic) + :code $ quote + def pattern-spaced-code $ noted "|temp fix of nested code block" (&raw-code "|/\\n\\s+```/g") + :examples $ [] + |pick-model $ %{} :CodeEntry (:doc |) + :code $ quote + defn pick-model (variant) + case-default variant |gemini-3.5-flash (:gemini-3.5-flash-lite |gemini-3.1-flash-lite) (:gemini-3.6-flash |gemini-3.5-flash) (:gemini-3.5-flash-lite |gemini-3.1-flash-lite) (:gemini-3.1-flash-lite-preview |gemini-3.1-flash-lite) (:gemini-pro |gemini-3.1-pro-preview) (:gemma |gemma-3-27b-it) + :examples $ [] + :schema $ :: :fn + {} (:return :string) + :args $ [] :tag + |save-current-session $ %{} :CodeEntry (:doc |) + :code $ quote + defn save-current-session (store state) + let + messages $ :messages state + model $ either (:model state) :gemini + if + > (count messages) 0 + let + new-session $ create-session messages model + updated-session $ assoc new-session :is-history? true + sessions $ or (:sessions store) ([]) + assoc store :sessions $ append sessions updated-session + , store + :examples $ [] + :schema $ :: :fn + {} + :args $ [] (:: :map :tag :dynamic) (:: :map :tag :dynamic) + :return $ :: :map :tag :dynamic + |style-a-toggler $ %{} :CodeEntry (:doc |) (:schema :dynamic) + :code $ quote + defstyle style-a-toggler $ {} + |& $ {} (:cursor :pointer) (:background-color :white) (:color :black) + "|.focus-within &" $ {} (:color :black) + :examples $ [] + |style-abort-close $ %{} :CodeEntry (:doc |) (:schema :dynamic) + :code $ quote + defstyle style-abort-close $ {} + |& $ {} (:vertical-align :middle) (:font-size 10) + :examples $ [] + |style-app-global $ %{} :CodeEntry (:doc |) (:schema :dynamic) + :code $ quote + defstyle style-app-global $ {} + str "|& ." style-code-block + {} $ :max-width |90vw + |& $ {} (:color |#999) (:transition-duration |300ms) + :background-color $ hsl 0 0 98 + :touch-action :none + |&:hover $ {} (:color |#777) + :background-color $ hsl 0 0 100 + :examples $ [] + |style-archive-close $ %{} :CodeEntry (:doc |) (:schema :dynamic) + :code $ quote + defstyle style-archive-close $ {} + |& $ {} (:cursor :pointer) (:font-size 18) + :color $ hsl 0 0 50 + :transition-duration |200ms + |&:hover $ {} + :color $ hsl 0 0 20 + :examples $ [] + |style-archive-header $ %{} :CodeEntry (:doc |) (:schema :dynamic) + :code $ quote + defstyle style-archive-header $ {} + |& $ {} (:padding "|12px 16px") + :border-bottom $ str "|1px solid " (hsl 0 0 90) + :background-color $ hsl 0 0 96 + :display :flex + :flex-direction :row + :align-items :center + :justify-content :space-between + :examples $ [] + |style-archive-row $ %{} :CodeEntry (:doc |) (:schema :dynamic) + :code $ quote + defstyle style-archive-row $ {} + |& $ {} (:padding |12px) + :border-bottom $ str "|1px solid " (hsl 0 0 90) + :display :flex + :justify-content :space-between + :align-items :center + :examples $ [] + |style-checkbox $ %{} :CodeEntry (:doc |) (:schema :dynamic) + :code $ quote + defstyle style-checkbox $ {} + |& $ {} (:cursor :pointer) (:user-select :none) (:font-size 12) (:line-height |28px) (:vertical-align :middle) + :examples $ [] + |style-clear $ %{} :CodeEntry (:doc |) (:schema :dynamic) + :code $ quote + defstyle style-clear $ {} + |& $ {} (:opacity 0.4) (:padding "|4px 8px") (:display :inline-block) (:height |24px) + :examples $ [] + |style-code-content $ %{} :CodeEntry (:doc |) (:schema :dynamic) + :code $ quote + defstyle style-code-content $ {} + |& $ {} (:line-height |1.5) (:font-size 13) + :examples $ [] + |style-delete-button $ %{} :CodeEntry (:doc |) (:schema :dynamic) + :code $ quote + defstyle style-delete-button $ {} + |& $ {} (:padding "|4px 8px") (:font-size |18px) (:font-weight |50) + :color $ hsl 0 80 50 + :opacity 0.5 + :cursor :pointer + :transition "|opacity 0.15s, color 0.15s" + :user-select :none + |&:hover $ {} (:opacity 1) + :color $ hsl 0 90 45 + |&:active $ {} (:opacity 1) + :color $ hsl 0 90 40 + :examples $ [] + |style-fill $ %{} :CodeEntry (:doc |) (:schema :dynamic) + :code $ quote + defstyle style-fill $ {} + |& $ {} (:cursor :pointer) (:user-select :none) (:display :inline-flex) (:align-items :center) (:justify-content :center) (:transition-duration |200ms) + :color $ hsl 0 0 80 + :margin "|0 4px 0 8px" + |&:hover $ {} + :color $ hsl 0 0 40 + :transform "|scale(1.06)" + :examples $ [] + |style-focus-box $ %{} :CodeEntry (:doc |) (:schema :dynamic) + :code $ quote + defstyle style-focus-box $ {} + |& $ {} (:width |100%) (:border-radius 12) (:min-height 40) (:max-height 40) (:padding "|9px 12px") (:cursor :text) (:overflow :hidden) (:white-space :pre) (:text-overflow :ellipsis) (:background-color :transparent) + :examples $ [] + |style-focus-link $ %{} :CodeEntry (:doc |) (:schema :dynamic) + :code $ quote + defstyle style-focus-link $ {} + |& $ {} (:cursor :pointer) (:font-size 13) + :color $ hsl 200 80 40 + :text-decoration :none + :padding "|4px 0" + |&:hover $ {} (:text-decoration :underline) + :examples $ [] + |style-gap12 $ %{} :CodeEntry (:doc |) (:schema :dynamic) + :code $ quote + defstyle style-gap12 $ {} + |& $ {} (:gap 12) + :examples $ [] + |style-history-button $ %{} :CodeEntry (:doc |) (:schema :dynamic) + :code $ quote + defstyle style-history-button $ {} + |& $ {} (:font-size |20px) + :color $ hsl 200 80 60 + :height |14px + :line-height |14px + :display :flex + :align-items :center + :justify-content :center + |&:hover $ {} + :color $ hsl 200 80 50 + :examples $ [] + |style-history-count $ %{} :CodeEntry (:doc |) (:schema :dynamic) + :code $ quote + defstyle style-history-count $ {} + |& $ {} + :color $ hsl 200 80 60 + :font-size |12px + :display :inline-block + :examples $ [] + |style-image $ %{} :CodeEntry (:doc |) (:schema :dynamic) + :code $ quote + defstyle style-image $ {} + |& $ {} (:max-width |100%) (:align-self :flex-start) (:border-radius |6px) + :border $ str "|1px solid " (hsl 0 0 90) + :examples $ [] + |style-md-content $ %{} :CodeEntry (:doc |) (:schema :dynamic) + :code $ quote + defstyle style-md-content $ {} + "|& .md-p" $ {} (:margin "|16px 0") (:line-height |1.6) + :examples $ [] + |style-message-actions $ %{} :CodeEntry (:doc |) (:schema :dynamic) + :code $ quote + defstyle style-message-actions $ {} + |& $ {} (:margin-top 6) (:justify-content :flex-end) (:width |100%) + :examples $ [] + |style-message-area $ %{} :CodeEntry (:doc |) (:schema :dynamic) + :code $ quote + defstyle style-message-area $ {} + |& $ {} (:flex 2) (:overflow :scroll) + :examples $ [] + |style-message-assistant $ %{} :CodeEntry (:doc |) (:schema :dynamic) + :code $ quote + defstyle style-message-assistant $ {} + |& $ {} (:align-self :flex-start) + :examples $ [] + |style-message-box $ %{} :CodeEntry (:doc |) (:schema :dynamic) + :code $ quote + defstyle style-message-box $ {} + |& $ {} (:width |100%) (:max-width 1200) (:right |50%) (:padding |8px) (:margin :auto) (:transition-duration |300ms) (; :transform "|translate(50%,0)") (:transition-property |height) + |&:focus-within $ {} (:opacity 1) (; :transform "|translate(50%,0)") + :examples $ [] + |style-message-box-panel $ %{} :CodeEntry (:doc |) (:schema :dynamic) + :code $ quote + defstyle style-message-box-panel $ {} + |& $ {} (:position :absolute) (:bottom 0) (:opacity 1) (:width |100%) + :background-color $ hsl 0 0 100 0.7 + :border-top $ str "|1px solid " (hsl 0 0 80 0.6) + |&.focus-within $ {} + :background-color $ hsl 0 0 100 0.9 + :box-shadow $ str "|0 0px 8px " (hsl 0 0 0 0.3) + :examples $ [] + |style-message-item $ %{} :CodeEntry (:doc |) (:schema :dynamic) + :code $ quote + defstyle style-message-item $ {} + |& $ {} (:line-height |1.6) + :examples $ [] + |style-message-list $ %{} :CodeEntry (:doc |) (:schema :dynamic) + :code $ quote + defstyle style-message-list $ {} + |& $ {} (:flex 2) (:padding "|40px 16px 20vh 16px") (:width |100%) (:max-width 1200) (:margin :auto) (:position :relative) + :examples $ [] + |style-message-role $ %{} :CodeEntry (:doc |) (:schema :dynamic) + :code $ quote + defstyle style-message-role $ {} + |& $ {} (:font-size 12) + :color $ hsl 0 0 50 + :margin-bottom 6 + :padding-right |16px + :examples $ [] + |style-message-text $ %{} :CodeEntry (:doc |) (:schema :dynamic) + :code $ quote + defstyle style-message-text $ {} + |& $ {} (:white-space :pre-wrap) (:line-height |1.6) (:margin 0) (:padding-right |16px) + :examples $ [] + |style-message-user $ %{} :CodeEntry (:doc |) (:schema :dynamic) + :code $ quote + defstyle style-message-user $ {} + |& $ {} (:align-self :flex-end) + :background-color $ hsl 0 0 96 + :padding "|12px 0 12px 16px" + :border-radius 10 + :max-height |240px + :max-width |100% + :overflow-y :auto + |&::-webkit-scrollbar $ {} (:width |4px) + |&::-webkit-scrollbar-thumb $ {} + :background-color $ hsl 0 0 80 + :border-radius |2px + |&::-webkit-scrollbar-track $ {} (:background-color :transparent) + :examples $ [] + |style-more $ %{} :CodeEntry (:doc |) (:schema :dynamic) + :code $ quote + defstyle style-more $ {} + |& $ {} (:text-align :center) (:min-width 80) + :background-color $ hsl 0 0 94 + :border-radius 16 + :padding "|4px 12px" + :margin "|8px 0" + :white-space :nowrap + :display :inline-block + |&:hover $ {} + :box-shadow $ str "|1px 1px 4px " (hsl 0 0 0 0.2) + :examples $ [] + |style-reply-actions $ %{} :CodeEntry (:doc |) (:schema :dynamic) + :code $ quote + defstyle style-reply-actions $ {} + |& $ {} (:margin-top 6) (:justify-content :flex-start) (:width |100%) + :examples $ [] + |style-reply-button $ %{} :CodeEntry (:doc |) (:schema :dynamic) + :code $ quote + defstyle style-reply-button $ {} + |& $ {} (:text-align :center) (:min-width 80) + :background-color $ hsl 0 0 100 + :border-radius 16 + :padding "|4px 12px" + :margin "|8px 0" + :white-space :nowrap + :display :inline-block + |&:hover $ {} + :box-shadow $ str "|1px 1px 4px " (hsl 0 0 0 0.2) + :examples $ [] + |style-session-item $ %{} :CodeEntry (:doc |) (:schema :dynamic) + :code $ quote + defstyle style-session-item $ {} + |& $ {} (:padding |12px) + :border-bottom $ str "|1px solid " (hsl 0 0 90) + :display :flex + :flex-direction :row + :align-items :center + :gap |12px + |:hover $ {} + :background-color $ hsl 0 0 96 + :examples $ [] + |style-sessions-list $ %{} :CodeEntry (:doc |) (:schema :dynamic) + :code $ quote + defstyle style-sessions-list $ {} + |& $ {} (:flex |1) (:overflow-y :auto) (:min-width |300px) + :examples $ [] + |style-submit $ %{} :CodeEntry (:doc |) (:schema :dynamic) + :code $ quote + defstyle style-submit $ {} + |& $ {} + :examples $ [] + |style-textbox $ %{} :CodeEntry (:doc |) (:schema :dynamic) + :code $ quote + defstyle style-textbox $ {} + |& $ {} (:border-radius 12) (:height "|max(100px,15vh)") (:width |100%) (:transition-duration |320ms) (:border :none) (:background-color :transparent) + |&.focus-within $ {} (:height "|max(240px,32vh)") (:border :none) (:box-shadow :none) + :examples $ [] + |style-textbox-compact $ %{} :CodeEntry (:doc |) (:schema :dynamic) + :code $ quote + defstyle style-textbox-compact $ {} + |& $ {} (:height 40) (:min-height 40) (:max-height 40) (:overflow :hidden) + |&.focus-within $ {} (:height "|max(240px,32vh)") (:min-height |unset) (:max-height |unset) + :examples $ [] + |style-thinking $ %{} :CodeEntry (:doc |) (:schema :dynamic) + :code $ quote + defstyle style-thinking $ {} + |& $ {} (:max-height 200) (:overflow :auto) (:padding "|12px 16px") + :background-color $ hsl 0 0 96 + :font-size 12 + :line-height |1.8 + :color $ hsl 0 0 50 + :border-radius 8 + :margin-bottom 12 + :border $ str "|1px solid " (hsl 0 0 90) + "|& .md-p" $ {} (:margin "|4px 0") + :examples $ [] + |submit-message! $ %{} :CodeEntry (:doc |) + :code $ quote + defn submit-message! (cursor state prompt-text search? think? model d!) + hint-fn $ {} (:async true) + let + state1 $ assoc state :messages + append-user-message (:messages state) prompt-text + *text $ atom | + *thinking-text $ atom | + model $ :model state + d! cursor state1 + try + do $ case-default model + js-await $ call-genai-msg! model cursor state1 prompt-text search? think? d! *text *thinking-text + :gemini-3.1-flash-lite-preview $ js-await (call-genai-msg! model cursor state1 prompt-text search? think? d! *text *thinking-text) + :gemini-pro $ js-await (call-genai-msg! model cursor state1 prompt-text search? true d! *text *thinking-text) + :flash-imagen $ js-await (call-flash-imagen-msg! model cursor state1 prompt-text d!) + :imagen-4 $ js-await (call-imagen-4-msg! model cursor state1 prompt-text d!) + :gemini-thinking $ js-await (call-genai-msg! model cursor state1 prompt-text search? think? d! *text *thinking-text) + :gemini-flash-thinking $ js-await (call-genai-msg! model cursor state1 prompt-text search? think? d! *text *thinking-text) + :gemini-flash-lite $ js-await (call-genai-msg! model cursor state1 prompt-text search? think? d! *text *thinking-text) + :gemini-flash $ js-await (call-genai-msg! model cursor state1 prompt-text search? think? d! *text *thinking-text) + :gemini-learnlm $ js-await (call-genai-msg! model cursor state1 prompt-text search? think? d! *text *thinking-text) + :claude-3.7 $ js-await (call-anthropic-msg! cursor state1 prompt-text |claude-3-7-sonnet-20250219 false d!) + :openrouter/anthropic/claude-sonnet-4 $ js-await (call-openrouter! cursor state1 prompt-text |anthropic/claude-sonnet-4 true d! *text) + :openrouter/anthropic/claude-opus-4 $ js-await (call-openrouter! cursor state1 prompt-text |anthropic/claude-opus-4 true d! *text) + :openrouter/anthropic/claude-3.7-sonnet:thinking $ js-await (call-openrouter! cursor state1 prompt-text |anthropic/claude-3.7-sonnet:thinking true d! *text) + :openrouter/google/gemini-2.5-pro-preview $ js-await (call-openrouter! cursor state1 prompt-text |google/gemini-2.5-pro-preview true d! *text) + :openrouter/google/gemini-2.5-flash-preview-05-20 $ js-await (call-openrouter! cursor state1 prompt-text |google/gemini-2.5-flash-preview-05-20 true d! *text) + :openrouter/openai/gpt-5 $ js-await (call-openrouter! cursor state1 prompt-text |openai/gpt-5 true d! *text) + :openrouter/deepseek/deepseek-chat-v3.1 $ js-await (call-openrouter! cursor state1 prompt-text |deepseek/deepseek-chat-v3.1 true d! *text) + :deepseek-v4-pro $ js-await (call-openrouter! cursor state1 prompt-text |deepseek-v4-pro true d! *text) + :deepseek-v4-flash $ js-await (call-openrouter! cursor state1 prompt-text |deepseek-v4-flash true d! *text) + fn (e) + let + err-text $ str "|Failed to load: " e + d! cursor $ -> state (assoc :answer err-text) (assoc :loading? false) (assoc :done? true) + assoc :messages $ upsert-assistant-message (:messages state) err-text nil + :examples $ [] + :schema $ :: :fn + {} (:return :dynamic) + :args $ [] :list (:: :map :tag :dynamic) :dynamic :bool :bool :tag :dynamic + :features $ #{} :js-ffi + |upsert-assistant-message $ %{} :CodeEntry (:doc |) + :code $ quote + defn upsert-assistant-message (messages content thinking) + let + messages0 $ if (some? messages) messages ([]) + size $ count messages0 + last-msg $ if (> size 0) (last messages0) nil + if + and (some? last-msg) + = :assistant $ :role last-msg + assoc messages0 (dec size) + -> last-msg (assoc :content content) (assoc :thinking thinking) + conj messages0 $ {} (:role :assistant) (:content content) (:thinking thinking) + :examples $ [] + :schema $ :: :fn + {} + :args $ [] + :: :optional $ :: :list (:: :map :tag :dynamic) + :: :optional :string + :: :optional :string + :return $ :: :list (:: :map :tag :dynamic) + :ns $ %{} :NsEntry (:doc |) + :code $ quote + ns app.comp.container $ :require (respo-ui.css :as css) + respo.css :refer $ defstyle + respo.util.format :refer $ hsl + respo.core :refer $ defcomp defeffect <> >> list-> div button textarea span input a pre img + respo.comp.space :refer $ =< + respo.comp.inspect :refer $ comp-inspect + reel.comp.reel :refer $ comp-reel + app.config :refer $ dev? chrome-extension? site + |axios :default axios + respo-md.comp.md :refer $ comp-md-block style-code-block + respo-ui.comp :refer $ comp-copy style-close + |../extension/get-selected :refer $ get-selected + |../lib/db :refer $ db-get db-set + memof.once :refer $ memof1-call memof1-call-by + |../lib/image :refer $ base64ToBlob + feather.core :refer $ comp-i + respo-alerts.core :refer $ [] use-modal-menu use-prompt use-drawer + respo-ui.util :refer $ tab-echo! + |app.config $ %{} :FileEntry + :defs $ {} + |chrome-extension? $ %{} :CodeEntry (:doc |) (:schema :bool) + :code $ quote + def chrome-extension? $ and (some? js/window.chrome) (some? js/window.chrome.runtime) (some? js/window.chrome.runtime.id) + :examples $ [] + |dev? $ %{} :CodeEntry (:doc |) (:schema :bool) + :code $ quote + def dev? $ = |dev (get-env |mode |release) + :examples $ [] + |site $ %{} :CodeEntry (:doc |) (:schema :map) + :code $ quote + def site $ {} (:storage-key |msg-buffer) (:archive-key |msg-buffer-archive) + :examples $ [] + :ns $ %{} :NsEntry (:doc |) + :code $ quote (ns app.config) + |app.main $ %{} :FileEntry + :defs $ {} + |*reel $ %{} :CodeEntry (:doc |) (:schema :ref) + :code $ quote + defatom *reel $ -> reel-schema/reel (assoc :base schema/store) (assoc :store schema/store) + :examples $ [] + |connect-to-worker! $ %{} :CodeEntry (:doc |) (:schema :dynamic) + :code $ quote + defn connect-to-worker! () $ if + and (some? js/window.chrome) (some? js/window.chrome.runtime) (some? js/window.chrome.runtime.connect) + do (println "|Connecting to worker...") + let + port $ js/chrome.runtime.connect + js-object $ :name |mySidepanel + .!addListener (.-onDisconnect port) + fn (event) + do (println "|Worker disconnected, retrying in 500ms...") (js/setTimeout connect-to-worker! 500) + , nil + :examples $ [] + |dispatch! $ %{} :CodeEntry (:doc |) (:schema :dynamic) + :code $ quote + defn dispatch! (op) + when + and config/dev? $ not= op :states + js/console.log |Dispatch: op + reset! *reel $ reel-updater updater @*reel op + :examples $ [] + |hydrate-storage-later! $ %{} :CodeEntry (:doc |) (:schema :dynamic) + :code $ quote + defn hydrate-storage-later! () $ js/setTimeout + fn () $ let + raw $ js/localStorage.getItem (:storage-key config/site) + when (some? raw) + let + t_start $ .!now js/Date + dispatch! $ :: :hydrate-storage (parse-cirru-edn raw) + println "|Hydrated in" + - (.!now js/Date) t_start + , |ms + :examples $ [] + |listen-extension! $ %{} :CodeEntry (:doc |) (:schema :dynamic) + :code $ quote + defn listen-extension! () + js/chrome.runtime.onMessage.addListener $ fn (message sender respond!) + when + = |menu-summary $ .-action message + let + content $ str "|你扮演一个专业的工程师, 对以下内容做一下讲解, 用中文, 注意要简略, 内容注意分块.\n\n" &newline &newline (.-content message) + event-tuple $ :: :fill-text + {} (:text content) (:submit? true) + send-to-component! event-tuple + when + = |fill-text $ .-action message + let + content $ .-text message + submit? $ either (.-submit? message) true + event-tuple $ :: :fill-text + {} (:text content) (:submit? submit?) + send-to-component! event-tuple + when + = |menu-translate $ .-action message + let + content $ str "|请将以下内容翻译成中文, 保持简洁分段:\n\n" &newline &newline (.-content message) + event-tuple $ :: :fill-text + {} (:text content) (:submit? true) + send-to-component! event-tuple + when + = |menu-custom $ .-action message + let + content $ .-content message + event-tuple $ :: :fill-text + {} (:text content) (:submit? false) + send-to-component! event-tuple + connect-to-worker! + :examples $ [] + |main! $ %{} :CodeEntry (:doc |) + :code $ quote + defn main! () $ let + t0 $ .!now js/Date + println "|Starting main! at" t0 + println "|Running mode:" $ if config/dev? |dev |release + if config/dev? $ load-console-formatter! + render-app! + add-watch *reel :changes $ fn (reel prev) (render-app!) + add-watch *archived-sessions :changes $ fn (s prev) (render-app!) + add-watch *viewing-archive-session :changes $ fn (s prev) (render-app!) + listen-devtools! |k dispatch! + js/window.addEventListener |beforeunload $ fn (event) (persist-storage!) + js/window.addEventListener |visibilitychange $ fn (event) + if (= |hidden js/document.visibilityState) (persist-storage!) + js/window.addEventListener |dblclick $ fn (event) (.!preventDefault event) + js/window.addEventListener |wheel + fn (event) + if (.-ctrlKey event) (.!preventDefault event) + js-object $ :passive false + hydrate-storage-later! + if config/chrome-extension? $ listen-extension! + , sync-gemini-key! + let + t1 $ .!now js/Date + println "|App started at" t1 |cost (- t1 t0) |ms + :examples $ [] + :schema $ :: :fn + {} (:return :dynamic) + :args $ [] + :features $ #{} :js-ffi + |mount-target $ %{} :CodeEntry (:doc |) (:schema :dynamic) + :code $ quote + def mount-target $ js/document.querySelector |.app + :examples $ [] + |persist-storage! $ %{} :CodeEntry (:doc |) + :code $ quote + defn persist-storage! () + println "|Saved at" $ .!toISOString (new js/Date) + js/localStorage.setItem (:storage-key config/site) + format-cirru-edn $ :store @*reel + :examples $ [] + :schema $ :: :fn + {} (:return :dynamic) + :args $ [] + :features $ #{} :js-ffi + |reload! $ %{} :CodeEntry (:doc |) (:schema :dynamic) + :code $ quote + defn reload! () $ if (nil? build-errors) + do (remove-watch *reel :changes) (remove-watch *archived-sessions :changes) (remove-watch *viewing-archive-session :changes) (clear-cache!) + add-watch *reel :changes $ fn (reel prev) (render-app!) + add-watch *archived-sessions :changes $ fn (s prev) (render-app!) + add-watch *viewing-archive-session :changes $ fn (s prev) (render-app!) + reset! *reel $ refresh-reel @*reel schema/store updater + hud! |ok~ |Ok + hud! |error build-errors + :examples $ [] + |render-app! $ %{} :CodeEntry (:doc |) (:schema :dynamic) + :code $ quote + defn render-app! () + let + t_start $ .!now js/Date + println "|Rendering app..." + render! mount-target (comp-container @*reel) dispatch! + println "|Rendered in" + - (.!now js/Date) t_start + , |ms + render! mount-target (comp-container @*reel) dispatch! + :examples $ [] + |sync-gemini-key! $ %{} :CodeEntry (:doc |) + :code $ quote + defn sync-gemini-key! () $ when + and (some? js/window.chrome) (some? js/window.chrome.runtime) + let + gemini-key $ js/localStorage.getItem |gemini-key + when (some? gemini-key) + js/chrome.runtime.sendMessage $ js-object (:action |sync-gemini-key) (:key gemini-key) + js/chrome.storage.local.set $ js-object (:geminiKey gemini-key) + let + deepseek-key $ js/localStorage.getItem |deepseek-key + when (some? deepseek-key) + js/chrome.runtime.sendMessage $ js-object (:action |sync-deepseek-key) (:key deepseek-key) + js/chrome.storage.local.set $ js-object (:deepseekKey deepseek-key) + :examples $ [] + :schema $ :: :fn + {} (:return :dynamic) + :args $ [] + :features $ #{} :js-ffi + :ns $ %{} :NsEntry (:doc |) + :code $ quote + ns app.main $ :require + respo.core :refer $ render! clear-cache! + app.comp.container :refer $ comp-container submit-message! *archived-sessions *viewing-archive-session + app.updater :refer $ updater + app.schema :as schema + reel.util :refer $ listen-devtools! + reel.core :refer $ reel-updater refresh-reel + reel.schema :as reel-schema + app.config :as config + |./calcit.build-errors :default build-errors + |bottom-tip :default hud! + respo.controller.client :refer $ send-to-component! + |app.schema $ %{} :FileEntry + :defs $ {} + |store $ %{} :CodeEntry (:doc |) (:schema :map) + :code $ quote + def store $ {} + :states $ {} + :cursor $ [] + :sessions $ [] + :current-session-id nil + :model nil + :archived-count 0 + :examples $ [] + :ns $ %{} :NsEntry (:doc |) + :code $ quote (ns app.schema) + |app.updater $ %{} :FileEntry + :defs $ {} + |updater $ %{} :CodeEntry (:doc |) + :code $ quote + defn updater (store op op-id op-time) + tag-match op + (:states cursor s) (update-states store cursor s) + (:states-merge cursor s changes) + let + store1 $ update-states-merge store cursor s changes + , store1 + (:hydrate-storage data) data + (:change-model) + if + = (:model store) :anthropic + assoc store :model :gemini + assoc store :model :anthropic + (:save-session state) + let + store1 $ save-current-session store state + assoc store1 :current-session-id nil + (:session session-id id) (assoc store :current-session-id id) + (:load-session cursor state session) + let + store1 $ update-states store cursor + -> state + assoc :messages $ :messages session + assoc :done? true + assoc store1 :current-session-id $ :id session + (:remove-session id) + assoc store :sessions $ filter + or (:sessions store) ([]) + fn (s) + not $ = (:id s) id + (:archive-sessions new-count) + -> store + assoc :sessions $ [] + assoc :archived-count new-count + assoc :current-session-id nil + (:update-archived-count new-count) + -> store $ assoc :archived-count new-count + (:clear-sessions) + -> store + assoc :sessions $ [] + assoc :current-session-id nil + _ $ do (eprintln "|unknown op:" op) store + :examples $ [] + :schema $ :: :fn + {} (:return :map) + :args $ [] :map :list :string :number + :ns $ %{} :NsEntry (:doc |) + :code $ quote + ns app.updater $ :require + respo.cursor :refer $ update-states update-states-merge + app.comp.container :refer $ save-current-session generate-session-id diff --git a/compact.cirru b/compact.cirru deleted file mode 100644 index b5d39a1..0000000 --- a/compact.cirru +++ /dev/null @@ -1,1505 +0,0 @@ - -{} (:about "|file is generated - never edit directly; learn cr edit/tree workflows before changing") (:package |app) - :configs $ {} (:init-fn |app.main/main!) (:reload-fn |app.main/reload!) (:version |0.0.1) - :modules $ [] |respo.calcit/ |memof/ |respo-ui.calcit/ |reel.calcit/ |respo-markdown.calcit/ |alerts.calcit/ |respo-feather.calcit/ |genai.calcit/ - :entries $ {} - :files $ {} - |app.comp.container $ %{} :FileEntry - :defs $ {} - |*abort-control $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote (defatom *abort-control nil) - :examples $ [] - |*gen-ai-new $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote (defatom *gen-ai-new nil) - :examples $ [] - |*image-cache $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote (defatom *image-cache nil) - :examples $ [] - |*openai $ %{} :CodeEntry (:doc "|called openai sdk, but actually for openrouter") (:schema nil) - :code $ quote (defatom *openai nil) - :examples $ [] - |append-user-message $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - defn append-user-message (messages content) - let - messages0 $ if (some? messages) messages ([]) - conj messages0 $ {} (:role :user) (:content content) - :examples $ [] - |call-anthropic-msg! $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - defn call-anthropic-msg! (cursor state prompt-text model thinking? d!) - hint-fn $ {} (:async true) - if-let - abort $ deref *abort-control - do (js/console.warn "\"Aborting prev") (.!abort abort) - d! $ :: :change-model - let - selected $ js-await (get-selected) - content $ .replace prompt-text "\"{{selected}}" (or selected "\"<未找到内容>") - messages0 $ append-user-message (:messages state) content - messages1 $ upsert-assistant-message messages0 "\"" nil - result $ js-await - .!post axios (str "\"https://sa.chenyong.life/v1/messages") - js-object - :model $ get-env "\"claude-model" (or model "\"claude-3-5-sonnet-latest") - :max_tokens 1024 - :stream true - :thinking $ if thinking? - js-object (:type "\"enabled") (:budget_tokens 2000) - , js/undefined - :messages $ messages->anthropic messages0 - js-object - :params $ js-object - :headers $ js-object (; :Accept "\"text/event-stream") (; :Content-Type "\"application/json") - "\"x-api-key" $ get-anthropic-key! - "\"anthropic-version" "\"2023-06-01" - "\"anthropic-dangerous-direct-browser-access" true - :responseType "\"stream" - :adapter "\"fetch" - :signal $ let - abort $ new js/AbortController - reset! *abort-control abort - .-signal abort - stream $ .-data result - reader $ -> - .!pipeThrough stream $ new js/TextDecoderStream - .!getReader - *text $ atom (str "\"Claude AI:" &newline &newline) - js/setTimeout $ fn () - d! $ :: :states-merge cursor state - {} (:answer nil) (:thinking nil) (:loading? true) (:done? false) (:messages messages1) - apply-args () $ fn () - hint-fn $ {} (:async true) - let - info $ js-await (.!read reader) - value $ wo-js-log (.-value info) - done? $ .-done info - if (wo-log done?) (:: :unit) - do - let - events $ -> value .split-lines - filter $ fn (s) (.starts-with? s "\"data: ") - map $ fn (s) - -> (.strip-prefix s "\"data: ") js/JSON.parse to-calcit-data - apply-args (events) - fn (xs) - list-match xs - () $ ;nil println "\"no thing to handle in this Loop" - (x0 xss) - let - stop? $ = (get x0 "\"type") "\"message_stop" - wo-js-log x0 - if stop? - d! $ :: :states-merge cursor state - {} (:answer @*text) (:loading? false) (:done? true) - :messages $ upsert-assistant-message messages1 @*text nil - let - content $ get-in x0 ([] "\"delta" "\"text") - if (nil? content) - do - ;nil d! $ :: :states cursor - -> state - assoc :answer $ str @*text &newline "\"[STOPPED: " (.-finishReason candidate0) "\"]" - assoc :loading? false - assoc :done? true - println "\"content is nil" - recur xss - let () (swap! *text str content) - d! $ :: :states-merge cursor state - {} (:answer @*text) (:loading? false) (:done? false) - :messages $ upsert-assistant-message messages1 @*text nil - recur xss - recur - :examples $ [] - |call-flash-imagen-msg! $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - defn call-flash-imagen-msg! (variant cursor state prompt-text d!) - hint-fn $ {} (:async true) - if (nil? @*gen-ai-new) - let - mod $ js-await (js/import |@google/genai) - GoogleGenAI $ .-GoogleGenAI mod - reset! *gen-ai-new $ new GoogleGenAI - js-object $ :apiKey (get-gemini-key!) - if-let - target $ js/document.querySelector "\".show-image" - .!setAttribute target "\"src" "\"" - if-let - abort $ deref *abort-control - do (js/console.warn "\"Aborting prev") (.!abort abort) - clear-image-cache! - d! $ :: :states cursor - -> state (assoc :answer nil) (assoc :loading? true) - let - selected $ js-await (get-selected) - gen-ai @*gen-ai-new - content $ .!replace prompt-text "\"{{selected}}" (or selected "\"<未找到选中内容>") - abort-signal $ let - abort $ new js/AbortController - reset! *abort-control abort - .-signal abort - sdk-result $ js-await - .!generateContent (.-models gen-ai) - js-object (:model "\"gemini-2.5-flash-image") (:contents content) - :config $ js-object (:abortSignal abort-signal) - :httpOptions $ js-object (:baseUrl |https://ja.chenyong.life) - :responseModalities $ js-array "\"TEXT" "\"IMAGE" - parts $ -> sdk-result .-candidates .-0 .-content .-parts - *text $ atom "\"" - js-await $ .!forEach parts - fn (? chunk _a _b) - if (some? chunk) - if-let - text $ .-text chunk - do (swap! *text str text) - d! $ :: :states cursor - -> state (assoc :answer @*text) (assoc :loading? false) (assoc :done? false) - if-let - image-data $ .-inlineData chunk - let - image-blob $ base64ToBlob (.-data image-data) - url $ js/URL.createObjectURL image-blob - target $ js/document.querySelector "\".show-image" - -> target $ .!setAttribute "\"src" url - reset! *image-cache url - do (swap! *text str "\"(image ready)") - d! $ :: :states cursor - -> state (assoc :answer @*text) (assoc :loading? false) (assoc :done? false) - d! $ :: :states cursor - -> state (assoc :answer @*text) (assoc :loading? false) (assoc :done? false) - d! $ :: :states cursor - -> state (assoc :answer @*text) (assoc :loading? false) (assoc :done? true) - :examples $ [] - |call-genai-msg! $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - defn call-genai-msg! (variant cursor state prompt-text search? think? d! *text *thinking-text) - hint-fn $ {} (:async true) - if (nil? @*gen-ai-new) - let - mod $ js-await (js/import |@google/genai) - GoogleGenAI $ .-GoogleGenAI mod - reset! *gen-ai-new $ new GoogleGenAI - js-object $ :apiKey (get-gemini-key!) - if-let - abort $ deref *abort-control - do (js/console.warn "\"Aborting prev") (.!abort abort) - let - selected $ if (.includes? prompt-text "\"{{selected}}") - js-await $ get-selected - gen-ai @*gen-ai-new - model $ pick-model variant - content $ .!replace prompt-text "\"{{selected}}" (or selected "\"<未找到选中内容>") - json? $ or (.!includes prompt-text "\"{{json}}") (.!includes prompt-text "\"{{JSON}}") - pro? $ .!includes model "\"pro" - has-url? $ or (.!includes prompt-text "\"http://") (.!includes prompt-text "\"https://") - messages0 $ or (:messages state) ([]) - messages1 $ upsert-assistant-message messages0 "\"" nil - abort-signal $ let - abort $ new js/AbortController - reset! *abort-control abort - .-signal abort - tools $ -> - js-array - if search? - js-object $ :googleSearch (js-object) - , js/undefined - if has-url? - js-object $ :urlContext (js-object) - , js/undefined - .!filter $ fn (x _idx _arr) x - sdk-result $ js-await - .!generateContentStream (.-models gen-ai) - js-object (:model model) - :contents $ messages->gemini messages0 - :config $ js-object - :thinkingConfig $ if think? - js-object - :thinkingBudget $ get-env "\"think-budget" (if pro? 3200 800) - :includeThoughts true - js-object (:thinkingBudget 0) (:includeThoughts false) - :tools $ if - > (.-length tools) 0 - , tools js/undefined - :abortSignal abort-signal - :httpOptions $ js-object (:baseUrl |https://ja.chenyong.life) - :responseMimeType $ if json? "\"application/json" js/undefined - do - js/setTimeout $ fn () - d! $ :: :states-merge cursor state - {} (:answer nil) (:thinking nil) (:loading? true) (:done? false) (:messages messages1) - js-await $ js-for-await sdk-result - fn (? chunk) - if (some? chunk) - let - part $ -> chunk .-candidates .-0 .-content .-parts .-0 - is-thinking? $ if (some? part) (.-thought part) false - text $ or - if (some? part) (.-text part) (.-text chunk) - -> chunk .-promptFeedback .-blockReason - if is-thinking? (swap! *thinking-text str text) (swap! *text str text) - d! $ :: :states-merge cursor state - {} (:answer @*text) (:thinking @*thinking-text) (:loading? false) (:done? false) - :messages $ upsert-assistant-message messages1 @*text @*thinking-text - d! $ :: :states-merge cursor state - {} (:answer @*text) (:thinking @*thinking-text) (:loading? false) (:done? false) - :messages $ upsert-assistant-message messages1 @*text @*thinking-text - d! $ :: :states-merge cursor state - {} (:answer @*text) (:thinking @*thinking-text) (:loading? false) (:done? true) - :messages $ upsert-assistant-message messages1 @*text @*thinking-text - :examples $ [] - |call-imagen-4-msg! $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - defn call-imagen-4-msg! (variant cursor state prompt-text d!) - hint-fn $ {} (:async true) - if (nil? @*gen-ai-new) - let - mod $ js-await (js/import |@google/genai) - GoogleGenAI $ .-GoogleGenAI mod - reset! *gen-ai-new $ new GoogleGenAI - js-object $ :apiKey (get-gemini-key!) - if-let - target $ js/document.querySelector "\".show-image" - .!removeAttribute target "\"src" - if-let - abort $ deref *abort-control - do (js/console.warn "\"Aborting prev") (.!abort abort) - clear-image-cache! - d! $ :: :states cursor - -> state (assoc :answer nil) (assoc :loading? true) - let - selected $ js-await (get-selected) - gen-ai @*gen-ai-new - abort-signal $ let - abort $ new js/AbortController - reset! *abort-control abort - .-signal abort - response $ js-await - .!generateImages (.-models gen-ai) - js-object (:model "\"imagen-4.0-generate-001") (:prompt prompt-text) - :config $ js-object (:numberOfImages 1) (:includeRaiReason true) - :httpOptions $ js-object (:baseUrl |https://ja.chenyong.life) - :signal abort-signal - *text $ atom "\"" - if-let - image-data $ -> response .-generatedImages .-0 .-image .-imageBytes - let - image-blob $ base64ToBlob image-data - url $ js/URL.createObjectURL image-blob - target $ js/document.querySelector "\".show-image" - reset! *image-cache url - -> target $ .!setAttribute "\"src" url - do (swap! *text str "\"(image ready)") - d! $ :: :states cursor - -> state (assoc :answer @*text) (assoc :loading? false) (assoc :done? false) - d! $ :: :states cursor - -> state (assoc :answer @*text) (assoc :loading? false) (assoc :done? true) - :examples $ [] - |call-openrouter! $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - defn call-openrouter! (cursor state prompt-text variant thinking? d! *text) - hint-fn $ {} (:async true) - if (nil? @*openai) - let - mod $ js-await (js/import |openai) - OpenAI $ .-default mod - reset! *openai $ new OpenAI - js-object (:baseURL "\"https://openrouter.ai/api/v1") - :apiKey $ get-openrouter-key! - :defaultHeaders $ js-object - :dangerouslyAllowBrowser true - if-let - abort $ deref *abort-control - do (js/console.warn "\"Aborting prev") (.!abort abort) - let - selected $ js-await (get-selected) - openai $ let - ai @*openai - , ai - content $ .!replace prompt-text "\"{{selected}}" (or selected "\"<未找到选中内容>") - json? $ or (.!includes prompt-text "\"{{json}}") (.!includes prompt-text "\"{{JSON}}") - messages0 $ append-user-message (:messages state) content - messages1 $ upsert-assistant-message messages0 "\"" nil - sdk-result $ js-await - -> openai .-chat .-completions $ .!create - js-object (:model variant) - :messages $ messages->openai messages0 - ; :generationConfig $ if json? - js-object $ "\"responseMimeType" "\"application/json" - , js/undefined - :stream true - :headers $ js-object (:HTTP-Referer js/location.host) - js-object $ :signal - let - abort $ new js/AbortController - reset! *abort-control abort - .-signal abort - do - js/setTimeout $ fn () - d! $ :: :states-merge cursor state - {} (:answer nil) (:thinking nil) (:loading? true) (:done? false) (:messages messages1) - js-await $ js-for-await sdk-result - fn (? chunk) - if (some? chunk) - do - swap! *text str $ -> chunk .-choices .-0 .-delta .-content (or "\"") - d! $ :: :states-merge cursor state - {} (:answer @*text) (:loading? false) (:done? false) - :messages $ upsert-assistant-message messages1 @*text nil - d! $ :: :states-merge cursor state - {} (:answer @*text) (:loading? false) (:done? false) - :messages $ upsert-assistant-message messages1 @*text nil - d! $ :: :states-merge cursor state - {} (:answer @*text) (:loading? false) (:done? true) - :messages $ upsert-assistant-message messages1 @*text nil - :examples $ [] - |clear-image-cache! $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - defn clear-image-cache! () $ if-let (url @*image-cache) - do (js/URL.revokeObjectURL url) (reset! *image-cache nil) - :examples $ [] - |comp-abort $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - defn comp-abort (t) - span - {} - :class-name $ str-spaced css/font-fancy css/row-middle style-more - :style $ {} (:cursor :pointer) - :on-click $ fn (e d!) - if-let - abort $ deref *abort-control - do (js/console.warn "\"Aborting prev") (.!abort abort) - <> t - =< 8 nil - <> "\"✕" style-abort-close - :examples $ [] - |comp-container $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - defcomp comp-container (reel) - let - store $ :store reel - sessions $ or (:sessions store) ([]) - current-session-id $ :current-session-id store - states $ :states store - cursor $ or (:cursor states) ([]) - state $ or (:data states) - {} (:answer nil) (:loading? false) (:done? false) - :messages $ [] - done? $ :done? state - messages $ or (:messages state) ([]) - model $ either (:model state) :gemini - is-viewing-history? $ and (some? current-session-id) - let - current-session $ -> sessions - filter $ fn (s) - = (:id s) current-session-id - , first - if (some? current-session) (:is-history? current-session) false - last-assistant $ let - size $ count messages - last-msg $ if (> size 0) (last messages) nil - if - and (some? last-msg) - = :assistant $ :role last-msg - :content last-msg - :answer state - model-plugin $ use-modal-menu (>> states :model) - {} (; :title "|Select model") - :style $ {} (:width 300) - :backdrop-style $ {} - ; :card-class style-card - ; :backdrop-class style-backdrop - ; :confirm-class style-confirm - :items models-menu - :on-result $ fn (result d!) - d! cursor $ assoc state :model (nth result 1) - reply-plugin $ use-prompt (>> states :reply-prompt) - {} (:text |Follow-up) (:placeholder "|Enter your follow-up") (:multiline? true) (:button-text |Send) - :validator $ fn (text) - if (blank? text) "|Please enter text" nil - message-box-state $ either - :data $ >> states :message-box - {} (:search? false) (:think? false) - sessions-plugin $ use-drawer (>> states :sessions-modal) - {} (:title "|History Sessions") - :style $ {} (:min-width "\"|max(320px,30vw)\"") (:max-width |80vw) - :render $ fn (on-close) - comp-sessions-modal sessions - fn (session-id d!) - d! cursor $ -> state - assoc :messages $ :messages - -> sessions - filter $ fn (s) - = (:id s) session-id - , first $ either ({}) - assoc :done? true - d! $ :: :session :session-id session-id - on-close d! - , on-close - div - {} $ :class-name (str-spaced css/preset css/global css/column css/fullscreen css/gap8 style-app-global) - div - {} $ :class-name (str-spaced css/expand style-message-area) - div - {} - :class-name $ str-spaced css/row-parted - :style $ {} (:padding |8px) - div $ {} - div - {} (:class-name css/row-middle) (:title |History) - :style $ {} (:cursor :pointer) - :on-click $ fn (e d!) (.show sessions-plugin d!) - div - {} $ :class-name style-history-button - comp-i |clock - =< 4 nil - if - > (count sessions) 0 - <> - str $ count sessions - str-spaced css/font-fancy style-history-count - div - {} $ :class-name (str-spaced css/column style-message-list) - if - or (= :imagen-4 model) (= :flash-imagen model) - img $ {} - :class-name $ str-spaced style-image "\"show-image" - list-> - {} $ :class-name (str-spaced css/column css/gap8) - -> messages $ map-indexed - fn (idx msg) - [] idx $ let - role $ :role msg - content $ :content msg - thinking $ :thinking msg - div - {} $ :class-name - str-spaced style-message-item $ if (= role :assistant) style-message-assistant style-message-user - div - {} $ :class-name style-message-role - <> $ if (= role :assistant) |Assistant |You - if - not $ blank? thinking - div - {} $ :class-name style-thinking - memof1-call comp-md-block - -> thinking $ either "\"" - {} $ :class-name style-md-content - if (= role :assistant) - if (json-pattern? content) - pre $ {} (:class-name style-code-content) (:inner-text content) - memof1-call comp-md-block - -> content $ either "\"" - {} $ :class-name style-md-content - pre $ {} (:class-name style-message-text) (:inner-text content) - if - and (= role :assistant) - or done? $ not= idx - dec $ count messages - div - {} $ :class-name (str-spaced css/row-middle css/gap8 style-message-actions) - if chrome-extension? - comp-fill $ either content "\"" - , nil - comp-copy $ either content "\"" - , nil - if - and - > (count messages) 0 - :done? state - not is-viewing-history? - div - {} $ :class-name (str-spaced css/row-middle css/gap8 style-reply-actions) - button - {} - :class-name $ str-spaced css/button style-reply-button - :on-click $ fn (e d!) - .show reply-plugin d! $ fn (text) - submit-message! cursor state text (:search? message-box-state) (:think? message-box-state) model d! - <> |Reply - if (:focus-mode? message-box-state) nil $ a - {} (:class-name style-focus-link) (:inner-text |Focus) - :on-click $ fn (e d!) - let - focused $ .-activeElement js/document - do - if (some? focused) (.!blur focused) - d! - :cursor $ >> states :message-box - assoc message-box-state :focus-mode? true - , nil - if (:loading? state) - div ({}) (memof1-call-by :abort-loading comp-abort "\"Loading...") - div - {} $ :class-name css/row-parted - div - {} $ :class-name (str-spaced css/row-middle css/gap8) - if (:done? state) nil $ div - {} $ :style - {} (:display :flex) (:justify-content :center) (:align-items :center) - memof1-call-by :abort-streaming comp-abort "\"Streaming..." - if (:done? state) - div $ {} - :class-name $ str-spaced css/row-middle css/gap8 - =< nil 200 - comp-message-box (>> states :message-box) - a $ {} - :inner-text $ or (turn-str model) "\"-" - :class-name $ str-spaced style-a-toggler - :style $ {} - :opacity $ if (= model :anthropic) 1 0.3 - :on-click $ fn (e d!) - ; d! $ :: :change-model - .show model-plugin d! - fn (text search? think? d!) - do - when - and - > (count messages) 0 - :done? state - nil? current-session-id - d! $ :: :save-session state - d! cursor $ -> state - assoc :messages $ [] - assoc :answer nil - assoc :thinking nil - assoc :done? false - d! $ :: :session :session-id nil - submit-message! cursor - -> state - assoc :messages $ [] - assoc :answer nil - assoc :thinking nil - assoc :done? false - , text search? think? model d! - , model - model-plugin.render - reply-plugin.render - sessions-plugin.render - if dev? $ comp-reel (>> states :reel) reel ({}) - if dev? $ comp-inspect "\"Store" store nil - :examples $ [] - |comp-fill $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - defcomp comp-fill (text) - div - {} (:class-name style-fill) - :on-click $ fn (e d!) - when chrome-extension? $ js/chrome.runtime.sendMessage - js-object (:action |fill-text) (:text text) - comp-i :send 12 :currentColor - :examples $ [] - |comp-message-box $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - defcomp comp-message-box (states picker-el on-submit model) - let - cursor $ :cursor states - state $ either (:data states) - {} (:content "\"") (:search? false) (:think? false) (:focus-mode? false) - [] (effect-focus) (on-fill cursor state on-submit) - div - {} $ :class-name (str-spaced css/center style-message-box-panel) - div - {} $ :class-name (str-spaced css/column style-message-box) - if (:focus-mode? state) - div - {} - :class-name $ str-spaced css/font-code! style-focus-box style-textbox-compact - :on-click $ fn (e d!) - do - d! cursor $ assoc state :focus-mode? false - js/setTimeout - fn () $ -> (js/document.querySelector "\"#message") (.!focus) - , 0 - <> $ if - blank? $ :content state - , "\"Click to expand and type..." (:content state) - textarea $ {} - :value $ :content state - :placeholder "\"Prompt to try LLM..." - :id "\"message" - :class-name $ str-spaced css/textarea css/font-code! style-textbox - :on-input $ fn (e d!) - d! cursor $ assoc state :content (:value e) - :on-keydown $ fn (e d!) - if - and - = 13 $ :keycode e - or (:meta? e) (:ctrl? e) - on-submit (:content state) (:search? state) (:think? state) d! - :on-focus $ fn (e d!) - let - target $ .-target (:event e) - box $ .-parentElement (.-parentElement target) - class-list $ .-classList target - box-class $ .-classList box - if - not $ .!contains class-list "\"focus-within" - .!add class-list "\"focus-within" - if - not $ .!contains box-class "\"focus-within" - .!add box-class "\"focus-within" - :on-blur $ fn (e d!) - let - target $ .-target (:event e) - box $ .-parentElement (.-parentElement target) - class-list $ .-classList target - box-class $ .-classList box - if (.!contains class-list "\"focus-within") (.!remove class-list "\"focus-within") - if (.!contains box-class "\"focus-within") (.!remove box-class "\"focus-within") - if - not $ :focus-mode? state - do (=< nil 4) - div - {} $ :class-name css/row-parted - if - not $ blank? (:content state) - comp-close $ {} (:class-name style-clear) - :on-click $ fn (e d!) - d! cursor $ assoc state :content "\"" - -> (js/document.querySelector "\"#message") (.!focus) - span $ {} (:class-name style-clear) - div - {} $ :class-name (str-spaced css/row style-gap12) - , picker-el - if - contains? (#{} :gemini-flash :gemini-3.1-flash-lite-preview) model - div - {} - :class-name $ str-spaced css/row style-checkbox - :on-click $ fn (e d!) - d! cursor $ assoc state :think? - not $ :think? state - input $ {} - :checked $ :think? state - :type "\"checkbox" - <> "\"Think" css/font-fancy - , nil - div - {} - :class-name $ str-spaced css/row style-checkbox - :on-click $ fn (e d!) - d! cursor $ assoc state :search? - not $ :search? state - input $ {} - :checked $ :search? state - :type "\"checkbox" - <> "\"Search" css/font-fancy - button $ {} - :class-name $ str-spaced css/button style-submit - :inner-text "\"Submit" - :on-click $ fn (e d!) - on-submit (:content state) (:search? state) (:think? state) d! - , nil - :examples $ [] - |comp-sessions-modal $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - defcomp comp-sessions-modal (sessions on-select on-close) - let - history-items $ foldl sessions 0 - fn (acc session) - + acc $ count - or (:messages session) ([]) - div - {} $ :class-name (str-spaced css/column css/gap8 style-sessions-list) - if (empty? sessions) - div - {} $ :style - {} (:padding |12px) - :color $ hsl 0 0 60 - <> "|No history sessions" - list-> - {} $ :class-name css/column - -> sessions (.!reverse) - map $ fn (session) - let - session-id $ :id session - created-at $ :created-at session - preview $ :preview session - date-str $ .!toLocaleString (new js/Date created-at) - [] session-id $ div - {} $ :class-name style-session-item - div - {} - :style $ {} (:flex |1) (:cursor :pointer) (:min-width 0) (:overflow :hidden) - :on-click $ fn (e d!) (on-select session-id d!) (on-close d!) - div - {} $ :style - {} (:font-size |12px) - :color $ hsl 0 0 60 - <> date-str - div - {} $ :style - {} (:margin-top |4px) (:white-space :nowrap) (:overflow :hidden) (:text-overflow :ellipsis) (:max-height |1.2em) (:line-height |1.2) - <> preview - div - {} (:class-name style-delete-button) - :on-click $ fn (e d!) (-> e :event .!stopPropagation) - d! $ :: :remove-session session-id - <> "|✕" - if - > (count sessions) 0 - div - {} - :class-name $ str-spaced css/column css/gap8 - :style $ {} (:padding "|0 12px 12px 12px") - div - {} $ :class-name (str-spaced css/row-parted) - div - {} $ :class-name (str-spaced css/row css/gap8) - a $ {} (:class-name style-clear) (:inner-text |Data) - :on-click $ fn (e d!) (tab-echo! sessions :edn) - a $ {} (:class-name style-clear) (:inner-text |Download) - :on-click $ fn (e d!) (download-sessions! sessions) - if - > (count sessions) 0 - a $ {} (:class-name style-clear) (:inner-text "|Clear all") - :on-click $ fn (e d!) - let - proceed? $ if (> history-items 10) - js/confirm $ str-spaced |Clear history-items "|messages from history?" - , true - when proceed? $ d! (:: :clear-sessions) - span $ {} - div $ {} - :style $ {} (:height 200) - , nil - :examples $ [] - |create-session $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - defn create-session (messages model) - let - id $ generate-session-id - first-msg $ if - > (count messages) 0 - :content $ first messages - , "|New chat" - {} (:id id) - :created-at $ js/Date.now - :messages messages - :model model - :preview $ let - len $ count first-msg - end $ if (< len 100) len 100 - .!slice first-msg 0 end - :is-history? false - :examples $ [] - |download-sessions! $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - defn download-sessions! (sessions) - let - content $ format-cirru-edn sessions - blob $ new js/Blob (js-array content) - js-object $ :type |application/edn;charset=utf-8 - url $ js/URL.createObjectURL blob - link $ js/document.createElement |a - filename $ str |sessions- (js/Date.now) |.cirru - do (.!setAttribute link |href url) (.!setAttribute link |download filename) (.!appendChild js/document.body link) (.!click link) (.!remove link) - js/setTimeout - fn () $ js/URL.revokeObjectURL url - , 0 - :examples $ [] - |effect-focus $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - defeffect effect-focus () (action el at?) - when (= action :mount) - js/setTimeout $ fn () - .!select $ .!querySelector el "\"textarea" - :examples $ [] - |first-line $ %{} :CodeEntry (:doc "|last message from error contains a line starts with \"data: \" and an extra error message. In order that JSON is parsed correctly, only first line is used now.") (:schema nil) - :code $ quote - defn first-line (tt) - let - lines $ -> tt (.!split &newline) - .!filter $ fn (line idx _a) - not $ blank? line - if - > (.-length lines) 1 - js/console.warn "\"Droping some unexpected lines:" $ .!slice lines 1 - .-0 lines - :examples $ [] - |generate-session-id $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - defn generate-session-id () $ str (js/Date.now) - :examples $ [] - |get-anthropic-key! $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - defn get-anthropic-key! () $ let - key $ js/localStorage.getItem "\"claude-key" - if (blank? key) - let - v $ js/prompt "\"Required claude-key in localStorage" - if (blank? v) - raise $ new js/Error "\"key is empty" - js/localStorage.setItem "\"claude-key" v - , v - , key - :examples $ [] - |get-deepinfra-key! $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - defn get-deepinfra-key! () $ let - key $ js/localStorage.getItem "\"deepinfra-key" - if (blank? key) - let - v $ js/prompt "\"Required deepinfra-key in localStorage" - if (blank? v) - raise $ new js/Error "\"key is empty" - js/localStorage.setItem "\"deepinfra-key" v - , v - , key - :examples $ [] - |get-gemini-key! $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - defn get-gemini-key! () $ let - key $ js/localStorage.getItem "\"gemini-key" - if (blank? key) - let - v $ js/prompt "\"Required gemini-key in localStorage" - if (blank? v) - raise $ new js/Error "\"key is empty" - js/localStorage.setItem "\"gemini-key" v - , v - , key - :examples $ [] - |get-openrouter-key! $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - defn get-openrouter-key! () $ let - key $ js/localStorage.getItem "\"openrouter-key" - if (blank? key) - let - v $ js/prompt "\"Required openrouter-key in localStorage" - if (blank? v) - raise $ new js/Error "\"key is empty" - js/localStorage.setItem "\"openrouter-key" v - , v - , key - :examples $ [] - |json-pattern? $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - defn json-pattern? (text) - or (.!startsWith text "\"{") (.!startsWith text "\"[") - :examples $ [] - |messages->anthropic $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - defn messages->anthropic (messages) - to-js-data $ map (or messages []) - fn (m) - {} - :role $ if - = :assistant $ :role m - , |assistant |user - :content $ :content m - :examples $ [] - |messages->gemini $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - defn messages->gemini (messages) - let - messages0 $ if (some? messages) messages ([]) - to-js-data $ map messages0 - fn (m) - {} - :role $ if - = :assistant $ :role m - , |model |user - :parts $ [] - {} $ :text (:content m) - :examples $ [] - |messages->openai $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - defn messages->openai (messages) - let - messages0 $ if (some? messages) messages ([]) - to-js-data $ map messages0 - fn (m) - {} - :role $ if - = :assistant $ :role m - , |assistant |user - :content $ :content m - :examples $ [] - |models-menu $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - def models-menu $ [] (:: :item :gemini-flash "|Gemini Flash 3") (:: :item :gemini-pro "|Gemini Pro 3.1") (:: :item :gemini-3.1-flash-lite-preview "|Gemini Flash Lite 3.1") (:: :item :flash-imagen "\"Flash Imagen") (:: :item :imagen-4 "\"Imagen 4") (:: :item :gemma "|Gemma 3 27b") (:: :item :openrouter/anthropic/claude-sonnet-4.5 "\"Openrouter Claude Sonnet 4.5") (:: :item :openrouter/anthropic/claude-opus-4 "\"Openrouter Claude Opus 4") (:: :item :openrouter/google/gemini-2.5-pro-preview "\"Openrouter Google Gemini 2.5 pro preview") (:: :item :openrouter/google/gemini-2.5-flash-preview-05-20 "\"Openrouter Google Gemini 2.5 flash preview") (:: :item :openrouter/openai/gpt-5 "\"Openrouter GPT 5") (:: :item :openrouter/deepseek/deepseek-chat-v3.1 "\"Openrouter deepseek-chat-v3.1") (; :: :item :claude-4.5 "\"Claude 4.5") - :examples $ [] - |on-fill $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - defn on-fill (cursor state on-submit) - %{} respo.schema/RespoListener (:name :on-fill) - :handler $ fn (event dispatch!) - tag-match event $ - :fill-text info - let - submit? $ either (:submit? info) true - do - dispatch! $ :: :states cursor - assoc state :content $ :text info - if submit? - on-submit (:text info) (:search? state) (:think? state) dispatch! - , nil - :examples $ [] - |pattern-spaced-code $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - def pattern-spaced-code $ noted "\"temp fix of nested code block" (&raw-code "\"/\\n\\s+```/g") - :examples $ [] - |pick-model $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - defn pick-model (variant) - case-default variant "\"gemini-3-flash-preview" (:gemini-3.1-flash-lite-preview "\"gemini-3.1-flash-lite-preview") (:gemini-pro "\"gemini-3.1-pro-preview") (:gemma "\"gemma-3-27b-it") - :examples $ [] - |save-current-session $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - defn save-current-session (store state) - let - messages $ :messages state - model $ either (:model state) :gemini - if - > (count messages) 0 - let - new-session $ create-session messages model - updated-session $ assoc new-session :is-history? true - sessions $ or (:sessions store) ([]) - assoc store :sessions $ append sessions updated-session - , store - :examples $ [] - |style-a-toggler $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - defstyle style-a-toggler $ {} - "\"&" $ {} (:cursor :pointer) (:background-color :white) (:color :black) - "\".focus-within &" $ {} (:color :black) - :examples $ [] - |style-abort-close $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - defstyle style-abort-close $ {} - "\"&" $ {} (:vertical-align :middle) (:font-size 10) - :examples $ [] - |style-app-global $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - defstyle style-app-global $ {} - str "\"& ." style-code-block - {} $ :max-width "\"90vw" - "\"&" $ {} (:color "\"#999") (:transition-duration "\"300ms") - :background-color $ hsl 0 0 98 - :touch-action :none - "\"&:hover" $ {} (:color "\"#777") - :background-color $ hsl 0 0 100 - :examples $ [] - |style-checkbox $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - defstyle style-checkbox $ {} - "\"&" $ {} (:cursor :pointer) (:user-select :none) (:font-size 12) (:line-height "\"28px") (:vertical-align :middle) - :examples $ [] - |style-clear $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - defstyle style-clear $ {} - "\"&" $ {} (:opacity 0.4) (:padding "\"4px 8px") (:display :inline-block) (:height "\"24px") - :examples $ [] - |style-code-content $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - defstyle style-code-content $ {} - "\"&" $ {} (:line-height "\"1.5") (:font-size 13) - :examples $ [] - |style-delete-button $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - defstyle style-delete-button $ {} - |& $ {} (:padding "|4px 8px") (:font-size |18px) (:font-weight |50) - :color $ hsl 0 80 50 - :opacity 0.5 - :cursor :pointer - :transition "|opacity 0.15s, color 0.15s" - :user-select :none - |&:hover $ {} (:opacity 1) - :color $ hsl 0 90 45 - |&:active $ {} (:opacity 1) - :color $ hsl 0 90 40 - :examples $ [] - |style-fill $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - defstyle style-fill $ {} - "\"&" $ {} (:cursor :pointer) (:user-select :none) (:display :inline-flex) (:align-items :center) (:justify-content :center) (:transition-duration "\"200ms") - :color $ hsl 0 0 80 - :margin "\"0 4px 0 8px" - "\"&:hover" $ {} - :color $ hsl 0 0 40 - :transform "\"scale(1.06)" - :examples $ [] - |style-focus-box $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - defstyle style-focus-box $ {} - "\"&" $ {} (:width |100%) (:border-radius 12) (:min-height 40) (:max-height 40) (:padding "\"9px 12px") (:cursor :text) (:overflow :hidden) (:white-space :pre) (:text-overflow :ellipsis) (:background-color :transparent) - :examples $ [] - |style-focus-link $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - defstyle style-focus-link $ {} - "\"&" $ {} (:cursor :pointer) (:font-size 13) - :color $ hsl 200 80 40 - :text-decoration :none - :padding "\"4px 0" - "\"&:hover" $ {} (:text-decoration :underline) - :examples $ [] - |style-gap12 $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - defstyle style-gap12 $ {} - "\"&" $ {} (:gap 12) - :examples $ [] - |style-history-button $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - defstyle style-history-button $ {} - |& $ {} (:font-size |20px) - :color $ hsl 200 80 60 - :height |14px - :line-height |14px - :display :flex - :align-items :center - :justify-content :center - |&:hover $ {} - :color $ hsl 200 80 50 - :examples $ [] - |style-history-count $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - defstyle style-history-count $ {} - |& $ {} - :color $ hsl 200 80 60 - :font-size |12px - :display :inline-block - :examples $ [] - |style-image $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - defstyle style-image $ {} - "\"&" $ {} (:max-width "\"100%") (:align-self :flex-start) (:border-radius "\"6px") - :border $ str "\"1px solid " (hsl 0 0 90) - :examples $ [] - |style-md-content $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - defstyle style-md-content $ {} - "\"& .md-p" $ {} (:margin "\"16px 0") (:line-height "\"1.6") - :examples $ [] - |style-message-actions $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - defstyle style-message-actions $ {} - "\"&" $ {} (:margin-top 6) (:justify-content :flex-end) (:width "\"100%") - :examples $ [] - |style-message-area $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - defstyle style-message-area $ {} - "\"&" $ {} (:flex 2) (:overflow :scroll) - :examples $ [] - |style-message-assistant $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - defstyle style-message-assistant $ {} - "\"&" $ {} (:align-self :flex-start) - :examples $ [] - |style-message-box $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - defstyle style-message-box $ {} - "\"&" $ {} (:width "\"100%") (:max-width 1200) (:right "\"50%") (:padding "\"8px") (:margin :auto) (:transition-duration "\"300ms") (; :transform "\"translate(50%,0)") (:transition-property "\"height") - "\"&:focus-within" $ {} (:opacity 1) (; :transform "\"translate(50%,0)") - :examples $ [] - |style-message-box-panel $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - defstyle style-message-box-panel $ {} - "\"&" $ {} (:position :absolute) (:bottom 0) (:opacity 1) (:width "\"100%") - :background-color $ hsl 0 0 100 0.7 - :border-top $ str "\"1px solid " (hsl 0 0 80 0.6) - "\"&.focus-within" $ {} - :background-color $ hsl 0 0 100 0.9 - :box-shadow $ str "\"0 0px 8px " (hsl 0 0 0 0.3) - :examples $ [] - |style-message-item $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - defstyle style-message-item $ {} - "\"&" $ {} (:line-height "\"1.6") - :examples $ [] - |style-message-list $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - defstyle style-message-list $ {} - "\"&" $ {} (:flex 2) (:padding "\"40px 16px 20vh 16px") (:width "\"100%") (:max-width 1200) (:margin :auto) (:position :relative) - :examples $ [] - |style-message-role $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - defstyle style-message-role $ {} - "\"&" $ {} (:font-size 12) - :color $ hsl 0 0 50 - :margin-bottom 6 - :padding-right "\"16px" - :examples $ [] - |style-message-text $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - defstyle style-message-text $ {} - "\"&" $ {} (:white-space :pre-wrap) (:line-height "\"1.6") (:margin 0) (:padding-right "\"16px") - :examples $ [] - |style-message-user $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - defstyle style-message-user $ {} - "\"&" $ {} (:align-self :flex-end) - :background-color $ hsl 0 0 96 - :padding "\"12px 0 12px 16px" - :border-radius 10 - :max-height "\"240px" - :max-width |100% - :overflow-y :auto - "\"&::-webkit-scrollbar" $ {} (:width "\"4px") - "\"&::-webkit-scrollbar-thumb" $ {} - :background-color $ hsl 0 0 80 - :border-radius "\"2px" - "\"&::-webkit-scrollbar-track" $ {} (:background-color :transparent) - :examples $ [] - |style-more $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - defstyle style-more $ {} - "\"&" $ {} (:text-align :center) (:min-width 80) - :background-color $ hsl 0 0 94 - :border-radius 16 - :padding "\"4px 12px" - :margin "\"8px 0" - :white-space :nowrap - :display :inline-block - "\"&:hover" $ {} - :box-shadow $ str "\"1px 1px 4px " (hsl 0 0 0 0.2) - :examples $ [] - |style-reply-actions $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - defstyle style-reply-actions $ {} - "\"&" $ {} (:margin-top 6) (:justify-content :flex-start) (:width "\"100%") - :examples $ [] - |style-reply-button $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - defstyle style-reply-button $ {} - "\"&" $ {} (:text-align :center) (:min-width 80) - :background-color $ hsl 0 0 100 - :border-radius 16 - :padding "\"4px 12px" - :margin "\"8px 0" - :white-space :nowrap - :display :inline-block - "\"&:hover" $ {} - :box-shadow $ str "\"1px 1px 4px " (hsl 0 0 0 0.2) - :examples $ [] - |style-session-item $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - defstyle style-session-item $ {} - |& $ {} (:padding |12px) - :border-bottom $ str "|1px solid " (hsl 0 0 90) - :display :flex - :flex-direction :row - :align-items :center - :gap |12px - |:hover $ {} - :background-color $ hsl 0 0 96 - :examples $ [] - |style-sessions-list $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - defstyle style-sessions-list $ {} - |& $ {} (:flex |1) (:overflow-y :auto) (:min-width |300px) - :examples $ [] - |style-submit $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - defstyle style-submit $ {} - "\"&" $ {} - :examples $ [] - |style-textbox $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - defstyle style-textbox $ {} - "\"&" $ {} (:border-radius 12) (:height "|max(100px,15vh)") (:width "\"100%") (:transition-duration "\"320ms") (:border :none) (:background-color :transparent) - "\"&.focus-within" $ {} (:height "|max(240px,32vh)") (:border :none) (:box-shadow :none) - :examples $ [] - |style-textbox-compact $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - defstyle style-textbox-compact $ {} - "\"&" $ {} (:height 40) (:min-height 40) (:max-height 40) (:overflow :hidden) - "\"&.focus-within" $ {} (:height "|max(240px,32vh)") (:min-height "\"unset") (:max-height "\"unset") - :examples $ [] - |style-thinking $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - defstyle style-thinking $ {} - "\"&" $ {} (:max-height 200) (:overflow :auto) (:padding "\"12px 16px") - :background-color $ hsl 0 0 96 - :font-size 12 - :line-height "\"1.8" - :color $ hsl 0 0 50 - :border-radius 8 - :margin-bottom 12 - :border $ str "\"1px solid " (hsl 0 0 90) - "\"& .md-p" $ {} (:margin "\"4px 0") - :examples $ [] - |submit-message! $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - defn submit-message! (cursor state prompt-text search? think? model d!) - hint-fn $ {} (:async true) - let - state1 $ assoc state :messages - append-user-message (:messages state) prompt-text - *text $ atom "\"" - *thinking-text $ atom "\"" - model $ :model state - d! cursor state1 - try - do $ case-default model - js-await $ call-genai-msg! model cursor state1 prompt-text search? think? d! *text *thinking-text - :gemini-3.1-flash-lite-preview $ js-await (call-genai-msg! model cursor state1 prompt-text search? think? d! *text *thinking-text) - :gemini-pro $ js-await (call-genai-msg! model cursor state1 prompt-text search? true d! *text *thinking-text) - :flash-imagen $ js-await (call-flash-imagen-msg! model cursor state1 prompt-text d!) - :imagen-4 $ js-await (call-imagen-4-msg! model cursor state1 prompt-text d!) - :gemini-thinking $ js-await (call-genai-msg! model cursor state1 prompt-text search? think? d! *text *thinking-text) - :gemini-flash-thinking $ js-await (call-genai-msg! model cursor state1 prompt-text search? think? d! *text *thinking-text) - :gemini-flash-lite $ js-await (call-genai-msg! model cursor state1 prompt-text search? think? d! *text *thinking-text) - :gemini-flash $ js-await (call-genai-msg! model cursor state1 prompt-text search? think? d! *text *thinking-text) - :gemini-learnlm $ js-await (call-genai-msg! model cursor state1 prompt-text search? think? d! *text *thinking-text) - :claude-3.7 $ js-await (call-anthropic-msg! cursor state1 prompt-text "\"claude-3-7-sonnet-20250219" false d!) - :openrouter/anthropic/claude-sonnet-4 $ js-await (call-openrouter! cursor state1 prompt-text "\"anthropic/claude-sonnet-4" true d! *text) - :openrouter/anthropic/claude-opus-4 $ js-await (call-openrouter! cursor state1 prompt-text "\"anthropic/claude-opus-4" true d! *text) - :openrouter/anthropic/claude-3.7-sonnet:thinking $ js-await (call-openrouter! cursor state1 prompt-text "\"anthropic/claude-3.7-sonnet:thinking" true d! *text) - :openrouter/google/gemini-2.5-pro-preview $ js-await (call-openrouter! cursor state1 prompt-text "\"google/gemini-2.5-pro-preview" true d! *text) - :openrouter/google/gemini-2.5-flash-preview-05-20 $ js-await (call-openrouter! cursor state1 prompt-text "\"google/gemini-2.5-flash-preview-05-20" true d! *text) - :openrouter/openai/gpt-5 $ js-await (call-openrouter! cursor state1 prompt-text "\"openai/gpt-5" true d! *text) - :openrouter/deepseek/deepseek-chat-v3.1 $ js-await (call-openrouter! cursor state1 prompt-text "\"deepseek/deepseek-chat-v3.1" true d! *text) - fn (e) - let - err-text $ str "\"Failed to load: " e - d! cursor $ -> state (assoc :answer err-text) (assoc :loading? false) (assoc :done? true) - assoc :messages $ upsert-assistant-message (:messages state) err-text nil - :examples $ [] - |upsert-assistant-message $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - defn upsert-assistant-message (messages content thinking) - let - messages0 $ if (some? messages) messages ([]) - size $ count messages0 - last-msg $ if (> size 0) (last messages0) nil - if - and (some? last-msg) - = :assistant $ :role last-msg - assoc messages0 (dec size) - -> last-msg (assoc :content content) (assoc :thinking thinking) - conj messages0 $ {} (:role :assistant) (:content content) (:thinking thinking) - :examples $ [] - :ns $ %{} :NsEntry (:doc |) - :code $ quote - ns app.comp.container $ :require (respo-ui.css :as css) - respo.css :refer $ defstyle - respo.util.format :refer $ hsl - respo.core :refer $ defcomp defeffect <> >> list-> div button textarea span input a pre img - respo.comp.space :refer $ =< - respo.comp.inspect :refer $ comp-inspect - reel.comp.reel :refer $ comp-reel - app.config :refer $ dev? chrome-extension? - "\"axios" :default axios - respo-md.comp.md :refer $ comp-md-block style-code-block - respo-ui.comp :refer $ comp-copy comp-close - "\"../extension/get-selected" :refer $ get-selected - memof.once :refer $ memof1-call memof1-call-by - "\"../lib/image" :refer $ base64ToBlob - feather.core :refer $ comp-i - respo-alerts.core :refer $ [] use-modal-menu use-prompt use-drawer - respo-ui.util :refer $ tab-echo! - |app.config $ %{} :FileEntry - :defs $ {} - |chrome-extension? $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - def chrome-extension? $ and (some? js/window.chrome) (some? js/window.chrome.runtime) (some? js/window.chrome.runtime.id) - :examples $ [] - |dev? $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - def dev? $ = "\"dev" (get-env "\"mode" "\"release") - :examples $ [] - |site $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - def site $ {} (:storage-key "\"msg-buffer") - :examples $ [] - :ns $ %{} :NsEntry (:doc |) - :code $ quote (ns app.config) - |app.main $ %{} :FileEntry - :defs $ {} - |*reel $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - defatom *reel $ -> reel-schema/reel (assoc :base schema/store) (assoc :store schema/store) - :examples $ [] - |connect-to-worker! $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - defn connect-to-worker! () $ if - and (some? js/window.chrome) (some? js/window.chrome.runtime) (some? js/window.chrome.runtime.connect) - do (println "|Connecting to worker...") - let - port $ js/chrome.runtime.connect - js-object $ :name |mySidepanel - .!addListener (.-onDisconnect port) - fn (event) - do (println "|Worker disconnected, retrying in 500ms...") (js/setTimeout connect-to-worker! 500) - , nil - :examples $ [] - |dispatch! $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - defn dispatch! (op) - when - and config/dev? $ not= op :states - js/console.log "\"Dispatch:" op - reset! *reel $ reel-updater updater @*reel op - :examples $ [] - |hydrate-storage-later! $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - defn hydrate-storage-later! () $ js/setTimeout - fn () $ let - raw $ js/localStorage.getItem (:storage-key config/site) - when (some? raw) - let - t_start $ .!now js/Date - dispatch! $ :: :hydrate-storage (parse-cirru-edn raw) - println "\"Hydrated in" - - (.!now js/Date) t_start - , "\"ms" - :examples $ [] - |listen-extension! $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - defn listen-extension! () - js/chrome.runtime.onMessage.addListener $ fn (message sender respond!) - when - = "\"menu-summary" $ .-action message - let - content $ str "\"你扮演一个专业的工程师, 对以下内容做一下讲解, 用中文, 注意要简略, 内容注意分块.\n\n" &newline &newline (.-content message) - event-tuple $ :: :fill-text - {} (:text content) (:submit? true) - send-to-component! event-tuple - when - = "\"fill-text" $ .-action message - let - content $ .-text message - submit? $ either (.-submit? message) true - event-tuple $ :: :fill-text - {} (:text content) (:submit? submit?) - send-to-component! event-tuple - when - = "\"menu-translate" $ .-action message - let - content $ str "\"请将以下内容翻译成中文, 保持简洁分段:\n\n" &newline &newline (.-content message) - event-tuple $ :: :fill-text - {} (:text content) (:submit? true) - send-to-component! event-tuple - when - = "\"menu-custom" $ .-action message - let - content $ .-content message - event-tuple $ :: :fill-text - {} (:text content) (:submit? false) - send-to-component! event-tuple - connect-to-worker! - :examples $ [] - |main! $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - defn main! () $ let - t0 $ .!now js/Date - println "\"Starting main! at" t0 - println "\"Running mode:" $ if config/dev? "\"dev" "\"release" - if config/dev? $ load-console-formatter! - render-app! - add-watch *reel :changes $ fn (reel prev) (render-app!) - listen-devtools! |k dispatch! - js/window.addEventListener |beforeunload $ fn (event) (persist-storage!) - js/window.addEventListener |visibilitychange $ fn (event) - if (= "\"hidden" js/document.visibilityState) (persist-storage!) - js/window.addEventListener |dblclick $ fn (event) (.!preventDefault event) - js/window.addEventListener |wheel - fn (event) - if (.-ctrlKey event) (.!preventDefault event) - js-object $ :passive false - hydrate-storage-later! - if config/chrome-extension? $ listen-extension! - let - t1 $ .!now js/Date - println "|App started at" t1 |cost (- t1 t0) "\"ms" - :examples $ [] - |mount-target $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - def mount-target $ js/document.querySelector |.app - :examples $ [] - |persist-storage! $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - defn persist-storage! () - println "\"Saved at" $ .!toISOString (new js/Date) - js/localStorage.setItem (:storage-key config/site) - format-cirru-edn $ :store @*reel - :examples $ [] - |reload! $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - defn reload! () $ if (nil? build-errors) - do (remove-watch *reel :changes) (clear-cache!) - add-watch *reel :changes $ fn (reel prev) (render-app!) - reset! *reel $ refresh-reel @*reel schema/store updater - hud! "\"ok~" "\"Ok" - hud! "\"error" build-errors - :examples $ [] - |render-app! $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - defn render-app! () - let - t_start $ .!now js/Date - println "\"Rendering app..." - render! mount-target (comp-container @*reel) dispatch! - println "\"Rendered in" - - (.!now js/Date) t_start - , "\"ms" - render! mount-target (comp-container @*reel) dispatch! - :examples $ [] - :ns $ %{} :NsEntry (:doc |) - :code $ quote - ns app.main $ :require - respo.core :refer $ render! clear-cache! - app.comp.container :refer $ comp-container submit-message! - app.updater :refer $ updater - app.schema :as schema - reel.util :refer $ listen-devtools! - reel.core :refer $ reel-updater refresh-reel - reel.schema :as reel-schema - app.config :as config - "\"./calcit.build-errors" :default build-errors - "\"bottom-tip" :default hud! - respo.controller.client :refer $ send-to-component! - |app.schema $ %{} :FileEntry - :defs $ {} - |store $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - def store $ {} - :states $ {} - :cursor $ [] - :sessions $ [] - :current-session-id nil - :model nil - :examples $ [] - :ns $ %{} :NsEntry (:doc |) - :code $ quote (ns app.schema) - |app.updater $ %{} :FileEntry - :defs $ {} - |updater $ %{} :CodeEntry (:doc |) (:schema nil) - :code $ quote - defn updater (store op op-id op-time) - tag-match op - :states cursor s - update-states store cursor s - (:states-merge cursor s changes) - let - store1 $ update-states-merge store cursor s changes - , store1 - (:hydrate-storage data) data - (:change-model) - if - = (:model store) :anthropic - assoc store :model :gemini - assoc store :model :anthropic - (:save-session state) - let - store1 $ save-current-session store state - assoc store1 :current-session-id nil - (:session session-id id) (assoc store :current-session-id id) - (:remove-session id) - assoc store :sessions $ filter - or (:sessions store) ([]) - fn (s) - not $ = (:id s) id - (:clear-sessions) - -> store - assoc :sessions $ [] - assoc :current-session-id nil - _ $ do (eprintln "\"unknown op:" op) store - :examples $ [] - :ns $ %{} :NsEntry (:doc |) - :code $ quote - ns app.updater $ :require - respo.cursor :refer $ update-states update-states-merge - app.comp.container :refer $ save-current-session generate-session-id diff --git a/deps.cirru b/deps.cirru index 2d3c7aa..b2fc19a 100644 --- a/deps.cirru +++ b/deps.cirru @@ -1,10 +1,10 @@ -{} (:calcit-version |0.12.35) +{} (:calcit-version |0.12.51) :dependencies $ {} (|Memkits/genai.calcit |0.0.3) - |Respo/alerts.calcit |0.10.10 - |Respo/reel.calcit |main - |Respo/respo-feather.calcit |main - |Respo/respo-markdown.calcit |0.4.13 - |Respo/respo-ui.calcit |0.6.4 - |Respo/respo.calcit |0.16.45 - |calcit-lang/memof |0.0.23 + |Respo/alerts.calcit |0.10.13 + |Respo/reel.calcit |0.6.4 + |Respo/respo-feather.calcit |0.4.1 + |Respo/respo-markdown.calcit |v0.4.15 + |Respo/respo-ui.calcit |0.6.5 + |Respo/respo.calcit |0.16.51 + |calcit-lang/memof |0.0.24 diff --git a/extension/manifest.json b/extension/manifest.json index 4c7a012..609389a 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -29,5 +29,5 @@ } } }, - "permissions": ["sidePanel", "scripting", "activeTab", "tabs", "contextMenus"] + "permissions": ["sidePanel", "scripting", "activeTab", "tabs", "contextMenus", "storage"] } diff --git a/extension/service-worker.js b/extension/service-worker.js index 0c5c887..3f1c2a4 100644 --- a/extension/service-worker.js +++ b/extension/service-worker.js @@ -1,5 +1,6 @@ let sidepanelOpen = false; let pendingMessage = null; +let deepseekApiKey = null; let sendToSidepanel = (message, tabId) => { console.log("[Worker] Sending message to sidepanel:", message.action); @@ -46,10 +47,22 @@ chrome.runtime.onInstalled.addListener(() => { parentId: "msg-gemini-root", }); }); + + // Page-level translation context menu (separate from selection menu) + chrome.contextMenus.create({ + id: "msg-gemini-translate-page", + title: "Translate Page (EN→ZH)", + type: "normal", + contexts: ["page"], + }); }); chrome.contextMenus.onClicked.addListener((item, tab) => { let content = item.selectionText; + if (item.menuItemId === "msg-gemini-translate-page") { + injectTranslateScript(tab); + return; + } if (item.menuItemId === "msg-gemini-translate") { sendToSidepanel({ action: "menu-translate", content }, tab.id); } else if (item.menuItemId === "msg-gemini-custom") { @@ -71,8 +84,81 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { } }); } + + // Receive DeepSeek API key from sidepanel + if (message && message.action === "sync-deepseek-key") { + deepseekApiKey = message.key || null; + if (message.key) { + chrome.storage.local.set({ deepseekKey: message.key }); + } + console.log("[Worker] DeepSeek API key synced:", deepseekApiKey ? "present" : "cleared"); + } }); +// ========================================================================= +// Page Translation +// ========================================================================= + +async function injectTranslateScript(tab) { + // Always read from chrome.storage.local as primary source + try { + const stored = await chrome.storage.local.get("deepseekKey"); + if (stored && stored.deepseekKey) { + deepseekApiKey = stored.deepseekKey; + console.log("[Worker] DeepSeek API key loaded from storage"); + } + } catch (e) { + console.error("[Worker] Failed to read from storage:", e); + } + + // If still no key, prompt once and persist + if (!deepseekApiKey) { + console.log("[Worker] No key found, asking user once..."); + try { + const results = await chrome.scripting.executeScript({ + target: { tabId: tab.id }, + func: () => { + return window.prompt( + "Enter your DeepSeek API key (from platform.deepseek.com):" + ); + }, + }); + if (results && results[0] && results[0].result) { + deepseekApiKey = results[0].result; + await chrome.storage.local.set({ deepseekKey: deepseekApiKey }); + console.log("[Worker] DeepSeek API key saved to storage"); + } + } catch (e) { + console.error("[Worker] Prompt failed:", e); + } + } + + if (!deepseekApiKey) { + console.error("[Worker] No DeepSeek API key, cannot translate"); + return; + } + + try { + // Inject key and translation script into the page + await chrome.scripting.executeScript({ + target: { tabId: tab.id }, + func: (key) => { + window.__TRANSLATE_API_KEY__ = key; + }, + args: [deepseekApiKey], + }); + + await chrome.scripting.executeScript({ + target: { tabId: tab.id }, + files: ["translate-page.js"], + }); + + console.log("[Worker] Translation script injected successfully"); + } catch (err) { + console.error("[Worker] Failed to inject translate script:", err); + } +} + // https://stackoverflow.com/a/77106777/883571 chrome.runtime.onConnect.addListener(function (port) { if (port.name === "mySidepanel") { diff --git a/extension/translate-page.js b/extension/translate-page.js new file mode 100644 index 0000000..b55d083 --- /dev/null +++ b/extension/translate-page.js @@ -0,0 +1,282 @@ +/** + * translate-page.js — Chrome Extension Content Script + * Injected into active tab to translate page content from English to Chinese + * using Gemini Flash 3 API. + * + * Architecture: + * 1. Service worker injects this script with the API key + * 2. Script finds main content area, extracts text segments + * 3. Calls Gemini API to batch-translate + * 4. Replaces text nodes preserving images, links, and DOM structure + */ + +(async function translatePage() { + const apiKey = window.__TRANSLATE_API_KEY__; + if (!apiKey) { + console.error("[TranslatePage] No API key provided"); + return; + } + + const API_URL = "https://api.deepseek.com/v1/chat/completions"; + const MODEL = "deepseek-chat"; + const BATCH_SIZE = 20; + const MAX_CONCURRENT = 2; + const BATCH_DELAY_MS = 300; + const SEGMENT_MIN_LENGTH = 4; + + // Tags whose text content should NOT be translated + const SKIP_TAGS = new Set([ + "SCRIPT", "STYLE", "NOSCRIPT", "IFRAME", "SVG", "CANVAS", + "PRE", "IMG", "VIDEO", "AUDIO", "OBJECT", "EMBED", + ]); + + // ========================================================================= + // 1. Find Main Content Area + // ========================================================================= + + function findMainContent() { + const semanticSelectors = [ + "article", "main", '[role="main"]', + "#content", "#main-content", "#article", "#post-content", + ".content", ".post-content", ".article-content", ".entry-content", + ".markdown-body", ".prose", '[data-testid="content"]', + ]; + for (const sel of semanticSelectors) { + const el = document.querySelector(sel); + if (el && el.textContent.trim().length > 200) return el; + } + + let best = document.body; + let bestScore = 0; + document.querySelectorAll("div, section, main, article").forEach((el) => { + const pCount = el.querySelectorAll("p, h1, h2, h3, h4, h5, h6, li, td, th, blockquote").length; + const textLen = el.textContent.trim().length; + if (pCount >= 3 && textLen > 500 && textLen > bestScore) { + bestScore = textLen; + best = el; + } + }); + + if (best === document.body && best.textContent.trim().length > 5000) return best; + return best; + } + + // ========================================================================= + // 2. Collect Individual Text Nodes + // ========================================================================= + + function collectTextNodes(root) { + const nodes = []; + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { + acceptNode(node) { + let parent = node.parentElement; + if (!parent) return NodeFilter.FILTER_REJECT; + // Walk up to check all ancestors + while (parent && parent !== root) { + if (SKIP_TAGS.has(parent.tagName)) return NodeFilter.FILTER_REJECT; + parent = parent.parentElement; + } + const text = node.textContent.trim(); + if (text.length < SEGMENT_MIN_LENGTH) return NodeFilter.FILTER_REJECT; + // Rough English detection + const asciiCount = (text.match(/[a-zA-Z]/g) || []).length; + if (asciiCount <= text.length * 0.3) return NodeFilter.FILTER_REJECT; + return NodeFilter.FILTER_ACCEPT; + }, + }); + + let node; + while ((node = walker.nextNode())) { + nodes.push({ + text: node.textContent.trim(), + domNode: node, + }); + } + return nodes; + } + + // ========================================================================= + // 3. Call Gemini API with JSON batch + // ========================================================================= + + async function translateBatch(texts) { + const prompt = `Translate each English text segment below into Chinese. +Return ONLY a JSON array of translations in the exact same order. +- Preserve proper nouns, numbers, code snippets, and technical terms as-is +- Keep the original tone and style +- Do NOT add explanations — just the JSON array + +Input: +${JSON.stringify(texts)} + +Output (JSON array):`; + + try { + const response = await fetch(API_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model: MODEL, + messages: [{ role: "user", content: prompt }], + temperature: 0.2, + max_tokens: 16384, + }), + }); + + if (!response.ok) { + throw new Error(`API ${response.status}: ${(await response.text()).slice(0, 200)}`); + } + + const data = await response.json(); + const resultText = data.choices?.[0]?.message?.content || ""; + + // Try to parse JSON array from response + try { + const parsed = JSON.parse(resultText.trim()); + if (Array.isArray(parsed)) return parsed; + } catch (_) { + // Try to extract JSON array from markdown or surrounding text + const match = resultText.match(/\[[\s\S]*\]/); + if (match) { + try { + const parsed = JSON.parse(match[0]); + if (Array.isArray(parsed)) return parsed; + } catch (__) { /* fall through */ } + } + } + + // Final fallback: split by lines (best-effort) + console.warn("[TranslatePage] JSON parse failed, falling back to line split"); + return resultText + .split("\n") + .map((l) => l.replace(/^["\s]*|["\s]*[,]?$/g, "").trim()) + .filter((l) => l.length > 0); + } catch (err) { + console.error("[TranslatePage] API error:", err); + return null; + } + } + + // ========================================================================= + // 4. Apply Translations to Text Nodes + // ========================================================================= + + function applyTranslations(nodes, translations) { + let applied = 0; + for (let i = 0; i < nodes.length && i < translations.length; i++) { + const domNode = nodes[i].domNode; + if (domNode && domNode.textContent) { + domNode.textContent = translations[i]; + applied++; + } + } + return applied; + } + + // ========================================================================= + // Main Execution + // ========================================================================= + + let _notifyEl = null; + let _notifyTimeout = null; + + function showNotification(msg, temporary) { + if (!_notifyEl) { + _notifyEl = document.createElement("div"); + _notifyEl.style.cssText = ` + position:fixed;top:16px;right:16px;z-index:2147483647; + background:#333;color:#fff;padding:8px 16px; + border-radius:8px;font-size:14px;font-family:sans-serif; + box-shadow:0 2px 8px rgba(0,0,0,0.3);pointer-events:none; + animation:tpFadeIn 0.3s ease; + `; + const style = document.createElement("style"); + style.textContent = "@keyframes tpFadeIn{from{opacity:0;transform:translateY(-8px)}to{opacity:1;transform:translateY(0)}}"; + document.head.appendChild(style); + document.body.appendChild(_notifyEl); + } + _notifyEl.textContent = "\u{1F310} " + msg; + clearTimeout(_notifyTimeout); + if (!temporary) { + _notifyTimeout = setTimeout(() => { + _notifyEl.remove(); + _notifyEl = null; + }, 3000); + } + } + + try { + console.log("[TranslatePage] Finding main content..."); + const mainContent = findMainContent(); + console.log("[TranslatePage] Collecting text nodes..."); + const nodes = collectTextNodes(mainContent); + + if (nodes.length === 0) { + console.log("[TranslatePage] No translatable text nodes found"); + return; + } + + console.log(`[TranslatePage] Found ${nodes.length} translatable text nodes`); + + const totalBatches = Math.ceil(nodes.length / BATCH_SIZE); + let appliedTotal = 0; + + // Prepare batches: [{ startIdx, texts, nodes }] + const batches = []; + for (let i = 0; i < nodes.length; i += BATCH_SIZE) { + const batchNodes = nodes.slice(i, i + BATCH_SIZE); + batches.push({ + idx: i, + texts: batchNodes.map((n) => n.text), + nodes: batchNodes, + }); + } + + console.log(`[TranslatePage] ${batches.length} batches, max ${MAX_CONCURRENT} concurrent`); + + // Process with concurrency limit + let running = 0; + let nextIdx = 0; + + async function processBatch(batch, num) { + console.log(`[TranslatePage] Batch ${num}/${batches.length} started...`); + const translations = await translateBatch(batch.texts); + if (translations && translations.length > 0) { + const n = applyTranslations(batch.nodes, translations); + appliedTotal += n; + showNotification(`Translated ${appliedTotal}/${nodes.length}...`, true); + } else { + console.warn(`[TranslatePage] Batch ${num} failed, keeping original`); + } + console.log(`[TranslatePage] Batch ${num}/${batches.length} done`); + } + + await new Promise((resolve) => { + function tryNext() { + while (running < MAX_CONCURRENT && nextIdx < batches.length) { + const batchNum = nextIdx + 1; + const batch = batches[nextIdx]; + nextIdx++; + running++; + processBatch(batch, batchNum).finally(() => { + running--; + tryNext(); + }); + } + if (running === 0 && nextIdx >= batches.length) { + resolve(); + } + } + tryNext(); + }); + + showNotification(`Done: ${appliedTotal}/${nodes.length} translated`); + + } catch (err) { + console.error("[TranslatePage] Error:", err); + showNotification("Translation failed: " + err.message); + } +})(); diff --git a/history/202502121500-schema-improvements.md b/history/202502121500-schema-improvements.md new file mode 100644 index 0000000..733e1da --- /dev/null +++ b/history/202502121500-schema-improvements.md @@ -0,0 +1,29 @@ +# 修改记录: Schema 标注与样式节点 Bug 修复 (2025-02-12) + +## 知识点与变动树 + +1. **Respo `<>` (文本节点) 语法约束**: + - `respo.core/<>` 后面仅支持接受字符串/叶子节点,不能把有子节点的标签(如 `div`、`span`)写在 `<>` 的子级中。 + - 修复了 `app.comp.container/comp-container` else-block 中错误地把 `div` 包在 `<>` 里的问题,改用正常的 `div` 布局包围并进行 flex 调节。 + +2. **Calcit / CodeEntry schema 类型标注**: + - 对以下全局变量、常量、辅助函数补充了正确的 `schema` 类型约束: + - `*archived-sessions` => `:schema :ref` + - `*viewing-archive-session` => `:schema :ref` + - `*abort-control` => `:schema :ref` + - `chrome-extension?` => `:schema :bool` + - `dev?` => `:schema :bool` + - `site` => `:schema :map` + - `first-line` => `:schema $ :: :fn $ {} (:return :string) (:args $ [] :string)` + - `pick-model` => `:schema $ :: :fn $ {} (:return :string) (:args $ [] :dynamic)` + - `save-current-session` => `:schema $ :: :fn $ {} (:return :map) (:args $ [] :map :map)` + - `messages->openai` => `:schema $ :: :fn $ {} (:return :list) (:args $ [] :list)` + - `app.schema/store` => `:schema :map` + - `app.updater/updater` => `:schema $ :: :fn $ {} (:return :map) (:args $ [] :map :list :string :number)` + +3. **Calcit 基础类型标签限定**: + - 在静态类型检测中,Calcit 支持的原生 bool 标签为 `:bool`,使用 `:boolean` 触发 `unknown primitive schema tag` 报错,现已全部修正为 `:bool`。 + +4. **编译与构建验证**: + - 执行 `cr --check-only` 全量通过。 + - 执行 `yarn build` Vite 正确生成 production 混淆打包产物且未引入任何运行时警告项目。 diff --git a/history/20260612-schema-type-improvements.md b/history/20260612-schema-type-improvements.md new file mode 100644 index 0000000..a34462d --- /dev/null +++ b/history/20260612-schema-type-improvements.md @@ -0,0 +1,26 @@ +# 修改记录: Schema 弱类型深度优化与修复 (2026-06-12) + +## 知识点与变动树 + +1. **顶级/常量 Schema 约束**: + - 在 Calcit 编译器的 `:schema` 中,非函数、非宏的 CodeEntry(如 `def`、`defatom` 等常量和引用)不支持复杂的参数化 nested schema(如 `(:: :map ...)` 或 `(:: :list ...)`)。如果使用了复杂 nested schema,会导致类似 `failed to normalize CodeEntry.schema` 的编译错误。 + - 这类 CodeEntry 必须定义为简单/原始的顶级类型标签(如 `:ref`、`:map`、`:list`)。 + +2. **嵌套集合/列表的精确类型声明**: + - 在声明函数签名时,参数和返回值支持完整的 nested parameters。 + - 对于聊天应用中最常出现的 “消息列表” (List of Message Maps),我们可以将其类型从模糊的 `(:: :list :map)` 深度细化为 `(:: :list (:: :map :tag :dynamic))`,其中 map 的 keys 精确定义为关键字 `:tag`(如 `:role`、`:content` 等),从而有效消除了 `cr analyze weak-types` 检测中大量的 `map-key` 弱类型警告。 + - 相应优化的函数包括: + - `append-user-message` + - `create-session` + - `upsert-assistant-message` + - `messages->anthropic` + - `messages->gemini` + - `messages->openai` + +3. **类型纠正与完善**: + - 修正了 `json-pattern?` 的返回类型,将错误的 `:boolean` 统一更正为 Calcit 原生支持的 `:bool` 类型。 + - 精确指定了 `pick-model` 的参数模型 `variant` 类型为 `:tag`。 + +4. **编译与质量保障**: + - 运行 `cr --check-only` 全量完美通过。 + - 运行 `yarn build` Vite production build 编译打包一切正常。 diff --git a/history/202606131230-migrate-archive-to-indexeddb.md b/history/202606131230-migrate-archive-to-indexeddb.md new file mode 100644 index 0000000..9de37d4 --- /dev/null +++ b/history/202606131230-migrate-archive-to-indexeddb.md @@ -0,0 +1,15 @@ +# Migrate Archive Storage from localStorage to IndexedDB + +## Timestamp: 2026-06-13 12:30 + +## Context & Issue: +The application was crashing with `Uncaught QuotaExceededError: Failed to execute 'setItem' on 'Storage'` because the dialogue archive list of `msg-buffer-archive` exceeded the 5MB limit imposed on `localStorage` by browsers, specifically in Chrome side panels/extensions. + +## Solution & Architectural Trade-off: +We migrated the large, ever-growing history archives to **IndexedDB**, which provides hundreds of MBs/GBs of quota storage, while keeping the main active buffer (`msg-buffer`) and settings inside `localStorage` to ensure synchronous persistence mechanics (e.g. `beforeunload`, `visibilitychange`) remain 100% reliable. + +## Changes: +1. Created `lib/db.mjs` containing async helper functions `db_get` and `db_set` using standard raw browser IndexedDB APIs. +2. Imported `db-get` and `db-set` into `app.comp.container`. +3. Updated the 3 major archive CRUD handlers (`on-view-archive`, delete-archived, and `Archive all`) to use `js-await` alongside `db-get`/`db-set`. +4. Successfully verified types, compilation, and integration workflows with no regressions. diff --git a/lib/db.mjs b/lib/db.mjs new file mode 100644 index 0000000..bd92ace --- /dev/null +++ b/lib/db.mjs @@ -0,0 +1,50 @@ +const dbName = 'msg_buffer_db'; +const storeName = 'kv'; + +function getDB() { + return new Promise((resolve, reject) => { + const request = indexedDB.open(dbName, 1); + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result); + request.onupgradeneeded = () => { + request.result.createObjectStore(storeName); + }; + }); +} + +export async function db_get(key) { + const db = await getDB(); + const dbValue = await new Promise((resolve, reject) => { + const transaction = db.transaction(storeName, 'readonly'); + const store = transaction.objectStore(storeName); + const request = store.get(key); + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result); + }); + if (dbValue !== undefined && dbValue !== null) { + return dbValue; + } + try { + const localValue = localStorage.getItem(key); + if (localValue !== null) { + await db_set(key, localValue); + localStorage.removeItem(key); + console.log(`Successfully migrated key "${key}" from localStorage to IndexedDB.`); + return localValue; + } + } catch (e) { + console.error('Migration from localStorage failed:', e); + } + return null; +} + +export async function db_set(key, value) { + const db = await getDB(); + return new Promise((resolve, reject) => { + const transaction = db.transaction(storeName, 'readwrite'); + const store = transaction.objectStore(storeName); + const request = store.put(value, key); + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(); + }); +} diff --git a/package.json b/package.json index 4e55610..d4ff1ab 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "dependencies": { - "@calcit/procs": "^0.12.35", - "@google/genai": "^1.49.0", + "@calcit/procs": "^0.12.51", + "@google/genai": "^2.12.0", "@tiye/main-fonts": "0.0.1", "axios": "^1.15.0", "cirru-color": "^0.2.4", diff --git a/yarn.lock b/yarn.lock index 4ef1620..7ef41bb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5,14 +5,14 @@ __metadata: version: 8 cacheKey: 10c0 -"@calcit/procs@npm:^0.12.35": - version: 0.12.35 - resolution: "@calcit/procs@npm:0.12.35" +"@calcit/procs@npm:^0.12.51": + version: 0.12.51 + resolution: "@calcit/procs@npm:0.12.51" dependencies: "@calcit/ternary-tree": "npm:0.0.26" "@cirru/parser.ts": "npm:^0.0.9" "@cirru/writer.ts": "npm:^0.1.9" - checksum: 10c0/b9d8e2926911e81e56635653c596c2b217fe260ae768a132df49dfd2e7f4ee7707882802e06be0f191a4b5e7df6c415f78abb8d8fb8fb06176611139badc2fde + checksum: 10c0/f99d4e540ae18d08b2ec5aa099351a05ce2e053180e0d7415953f0ee062f746687399b1fce2d2d664fccd35247e1b49e4076e550450178acd4d2c8f8269c67ae languageName: node linkType: hard @@ -65,9 +65,9 @@ __metadata: languageName: node linkType: hard -"@google/genai@npm:^1.49.0": - version: 1.49.0 - resolution: "@google/genai@npm:1.49.0" +"@google/genai@npm:^2.12.0": + version: 2.12.0 + resolution: "@google/genai@npm:2.12.0" dependencies: google-auth-library: "npm:^10.3.0" p-retry: "npm:^4.6.2" @@ -78,7 +78,7 @@ __metadata: peerDependenciesMeta: "@modelcontextprotocol/sdk": optional: true - checksum: 10c0/9500042020bc54a47ba744143cb8af549a8b154ae8b10acae057a70a7c3f867faa3dcb6d34d087da3542198c2a98b0976c3968d14066772eb65de21b2786be77 + checksum: 10c0/c3a2f3f6c8a6f236bf6f09fc6d50a90c616f9e7ea22be0922e39aa44ebda9161ae566789b4cfd2b57599b5079e045b89dd997070899f5504249650f75c8775f9 languageName: node linkType: hard @@ -1573,8 +1573,8 @@ __metadata: version: 0.0.0-use.local resolution: "root-workspace-0b6124@workspace:." dependencies: - "@calcit/procs": "npm:^0.12.35" - "@google/genai": "npm:^1.49.0" + "@calcit/procs": "npm:^0.12.51" + "@google/genai": "npm:^2.12.0" "@tiye/main-fonts": "npm:0.0.1" axios: "npm:^1.15.0" bottom-tip: "npm:^0.1.5"