All Downloads are FREE. Search and download functionalities are using the official Maven repository.

tri.util.ui.starship.StarshipView.kt Maven / Gradle / Ivy

The newest version!
/*-
 * #%L
 * tri.promptfx:promptfx
 * %%
 * Copyright (C) 2023 - 2025 Johns Hopkins University Applied Physics Laboratory
 * %%
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 * 
 *      http://www.apache.org/licenses/LICENSE-2.0
 * 
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 * #L%
 */
package tri.util.ui.starship

import de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon
import javafx.animation.Timeline
import javafx.beans.property.SimpleIntegerProperty
import javafx.beans.value.ObservableValue
import javafx.concurrent.Task
import javafx.event.EventHandler
import javafx.geometry.BoundingBox
import javafx.scene.Node
import javafx.scene.input.KeyCode
import javafx.scene.layout.Pane
import javafx.scene.shape.StrokeLineJoin
import javafx.scene.transform.Rotate
import javafx.stage.Screen
import tornadofx.*
import tri.promptfx.PromptFxController
import tri.promptfx.PromptFxDriver.sendInput
import tri.promptfx.PromptFxWorkspace
import tri.promptfx.docs.DocumentQaView
import tri.ai.text.docs.FormattedText
import tri.promptfx.ui.toFxNodes
import tri.util.info
import tri.util.ui.*
import tri.util.ui.AnimatingThumbnailBox
import tri.util.ui.starship.Chromify.chromify
import tri.util.ui.starship.StarshipContentConfig.explain

/** View for a full-screen animated text display. */
class StarshipView : Fragment("Starship") {

    val baseComponentTitle: String? by param()
    val baseComponent: View? by param()

    val controller: PromptFxController by inject()
    val results = StarshipPipelineResults()

    val indicator = BlinkingIndicator(FontAwesomeIcon.ROCKET).apply {
        glyphSize = 60.0
        glyphStyle = "-fx-fill:gray;"
    }
    val input = AnimatingTextFlow()
    val output = AnimatingTextFlow()
    val outputHighlight = AnimatingTextFlow()

    //region BUTTON VARS

    private val summarizeForIndex = SimpleIntegerProperty(0)
    private val summarizeFor = summarizeForIndex.stringBinding { summarizeForOptions[it!!.toInt()] }
    private val summarizeForOptions = StarshipContentConfig.userOptions["text-simplify-audience"]!!["audience"]!!
    private fun nextSummarizeFor() { summarizeForIndex.set((summarizeForIndex.get() + 1) % summarizeForOptions.size) }

    private val targetLanguageIndex = SimpleIntegerProperty(0)
    private val targetLanguage = targetLanguageIndex.stringBinding { targetLanguageOptions[it!!.toInt()] }
    private val targetLanguageOptions = StarshipContentConfig.userOptions["translate-text"]!!["instruct"]!!
    private fun nextTargetLanguage() { targetLanguageIndex.set((targetLanguageIndex.get() + 1) % targetLanguageOptions.size) }

    //endregion

    private lateinit var thumbnails: AnimatingThumbnailBox
    private val DOC_THUMBNAIL_SIZE = 193
    private var isExplainerVisible = false

    private val css1 = ImmersiveChatView::class.java.getResource("resources/chat.css")!!
    private val css2 = StarshipView::class.java.getResource("resources/starship.css")!!

    init {
        if (baseComponent is DocumentQaView) {
            val base = baseComponent as DocumentQaView
            base.snippets.onChange {
                val thumbs = base.snippets.map { it.document.browsable()!! }.toSet()
                    .map { DocumentThumbnail(it, DocumentUtils.documentThumbnail(it, DOC_THUMBNAIL_SIZE, true)) }
                thumbnails.animateThumbs(thumbs.take(6))
            }
        }
    }

    override val root = pane {
        stylesheets.add(css1.toExternalForm())
        stylesheets.add(css2.toExternalForm())

        val curScreen = Screen.getScreensForRectangle(primaryStage.x, primaryStage.y, 1.0, 1.0).firstOrNull()
            ?: Screen.getPrimary()
        val screenWidth = 1920 // curScreen.bounds.width
        val screenHeight = 1080 // curScreen.bounds.height

        // framing for a 3x3 grid
        if (StarshipContentConfig.isShowGrid) {
            (0..2).forEach { x ->
                (0..2).forEach { y ->
                    rectangle(screenWidth * x / 3.0, screenHeight * y / 3.0, screenWidth / 3.0, screenHeight / 3.0) {
                        style = "-fx-stroke:black;-fx-fill:none"
                    }
                }
            }
        }

        val chromePane = pane {
            resizeRelocate(0.0, 0.0, 5.0, 5.0)
        }

        add(input.apply {
            root.isMouseTransparent = true
            root.resizeRelocate(120.0, 60.0, 1080.0, 200.0)
            updatePrefWidth(1080.0)
            updateFontSize(48.0)
            results.input.onChange { animateText(FormattedText(it ?: "").toFxNodes()) }
            chromePane.chromify(root, wid = 36.0, title = "Question", icon = FontAwesomeIcon.QUESTION)
        })
        add(output.apply {
            root.isMouseTransparent = true
            root.resizeRelocate(55.0, 380.0, 525.0, 800.0)
            updatePrefWidth(525.0)
            updateFontSize(20.0)
            results.outputText.onChange { animateText(FormattedText(it ?: "").toFxNodes()) }
            chromePane.chromify(root, wid = 15.0, title = baseComponentTitle ?: "Answer", icon = FontAwesomeIcon.FILE_PDF_ALT)
        })
        add(outputHighlight.apply {
            root.isMouseTransparent = true
            root.visibleWhen(results.outputHighlightText.isNotBlank())
            root.resizeRelocate(700.0, 380.0, 520.0, 800.0)
            updatePrefWidth(520.0)
            updateFontSize(21.0)
            results.outputHighlightText.onChange { animateText(FormattedText(it ?: "").toFxNodes()) }
            chromePane.chromify(root, wid = 17.5, title = "Summarize For", icon = FontAwesomeIcon.COMMENTS,
                buttonText = summarizeFor, buttonAction = ::nextSummarizeFor
            )
        })
        vbox(54.0) {
            resizeRelocate(1340.0, 60.0, 520.0, 400.0)
            bindChildren(results.secondaryOutputs) { output ->
                AnimatingTextFlow().apply {
                    updatePrefWidth(520.0)
                    updateFontSize(15.0)
                    animateText(output.text.toFxNodes())
                }.root.also {
                    isMouseTransparent = true
                    val bt = if (output.label == "Translate") targetLanguage else null
                    val ba = if (output.label == "Translate") ::nextTargetLanguage else null
                    chromePane.chromify(it, wid = 12.0, title = output.label, icon = FontAwesomeIcon.MAGIC, buttonText = bt, buttonAction = ba)
                }
            }
        }

        thumbnails = AnimatingThumbnailBox { }.apply {
            id = "starship-thumbnails"
            isMouseTransparent = true
            resizeRelocate(10.0, screenHeight - 300.0, screenWidth - 80.0, 300.0)
            spacing = 20.0
        }
        results.thumbnails.onChange { thumbnails.animateThumbs(it.list) }
        add(thumbnails)

        results.started.onChange { if (it) indicator.startBlinking() }
        results.completed.onChange { indicator.stopBlinking() }

        add(indicator.apply {
            id = "starship-indicator"
            layoutX = screenWidth - glyphSize.toDouble() - 20.0
            layoutY = screenHeight - 20.0
        })

        // fill background with 100 twinkling stars of various sizes
        pane {
            isMouseTransparent = true
            for (i in 0 until StarshipContentConfig.backgroundIconCount) {
                val star = StarshipContentConfig.backgroundIcon
                val size = (5..20).random()
                add(BlinkingIndicator(star).apply {
                    layoutX = (size..(screenWidth - size)).random().toDouble()
                    layoutY = (size..(screenHeight - size)).random().toDouble() + size
                    opacity = (5..30).random().toDouble() / 100
                    glyphSize = size
                    glyphStyle = "-fx-fill:gray;"
                    initialDelayMillis = (500..1500).random()
                    blinkTimeMillis = 5000
                    opacityRange = (0.5 * opacity)..minOf(1.0, 1.5 * opacity)
                })
            }
        }

        // explainer overlay in orange
        val explainer = pane {
            isMouseTransparent = true
            isVisible = false
            explainerOverlay(0, 0, xn = 2, yn = 1, screenWidth, screenHeight, step = 1, explain = explain[0])
            explainerOverlay(0, 2, xn = 2, yn = 1, screenWidth, screenHeight, step = 2, explain = explain[1])
            explainerOverlay(0, 1, xn = 1, yn = 1, screenWidth, screenHeight, step = 3, explain = explain[2])
            explainerOverlay(1, 1, xn = 1, yn = 1, screenWidth, screenHeight, step = 4, explain = explain[3])
            explainerOverlay(2, 0, xn = 1, yn = 3, screenWidth, screenHeight, step = 5, explain = explain[4])
        }
        onKeyPressed = EventHandler { event ->
            if (event.code == KeyCode.X) {
                explainer.isVisible = !explainer.isVisible
                isExplainerVisible = explainer.isVisible
            }
        }
    }

    private fun Pane.explainerOverlay(x: Int, y: Int, xn: Int, yn: Int, w: Int, h: Int, step: Int, explain: String) {
        // blink all pane content for 2 seconds whenever results activeStep property changes to step
        var blinker: Timeline? = null
        val initialDelayMillis = 0
        val blinkTimeMillis = 1000
        val opacityRange = 0.4..1.0
        val opacityInitial = 0.1

        pane {
            opacity = opacityInitial
            results.activeStep.onChange {
                if (it == 0) {
                    opacity = opacityInitial
                } else if (it == step) {
                    // create timeline to blink opacity of this pane
                    blinker?.stop()
                    blinker = timeline(play = false) {
                        keyframe(0.millis) { keyvalue(opacityProperty(), opacityRange.endInclusive) }
                        keyframe((0.5*blinkTimeMillis).millis) { keyvalue(opacityProperty(), opacityRange.start) }
                        keyframe(blinkTimeMillis.millis) { keyvalue(opacityProperty(), opacityRange.endInclusive) }
                        cycleCount = 5
                        setOnFinished { opacity = opacityRange.endInclusive }
                        playFrom(initialDelayMillis.seconds)
                    }
                }
            }

            val uw = w / 3
            val uh = h / 3
            val bounds = BoundingBox(x * uw.toDouble(), y * uh.toDouble(), xn * uw.toDouble(), yn * uh.toDouble())
            // draw a rectangular box in orange, with corner radius 10 px, inset by 10 pixels
            rectangle(bounds.minX + 10, bounds.minY + 10, bounds.width - 20, bounds.height - 20) {
                style =
                    "-fx-fill:none;-fx-stroke:#EE8F33;-fx-stroke-width:2;-fx-stroke-line-join:round;-fx-stroke-line-cap:round;-fx-stroke-dash-array:5 5;"
                arcWidth = 30.0
                arcHeight = 30.0
            }
            // draw a filled circle in upper left corner of inset rectangle of radius 10 in orange
            circle(bounds.minX + 30, bounds.maxY - 30, 15.0) {
                style = "-fx-fill:#EE8F33;"
            }
            // draw step # over the circle
            text(step.toString()) {
                layoutX = bounds.minX + 22
                layoutY = bounds.maxY - 20
                style = "-fx-fill:black;-fx-font-size:28px;-fx-font-weight:bold;"
            }
            // fill in explanation in textflow box inside the rectangle
            textflow {
                resizeRelocate(bounds.minX + 50, bounds.maxY - 42, bounds.width - 60, bounds.height - 20)
                text(explain) {
                    style = "-fx-fill:#EE8F33;-fx-font-size:17px;"
                }
            }
        }
    }

    private var job: Task? = null
    private var jobCanceled = false

    init {
        runLater(3.seconds) { runPipeline() }
    }

    private fun runPipeline() {
        if (jobCanceled)
            return
        results.clearAll()
        job = runAsync {
            val config = StarshipPipelineConfig(controller.completionEngine.value)
            config.secondaryPrompts[0].params["audience"] = summarizeFor.value
            config.secondaryPrompts[3].params["instruct"] = targetLanguage.value
            config.promptExec = object : AiPromptExecutor {
                override suspend fun exec(prompt: PromptWithParams, input: String): StarshipInterimResult {
                    var text: FormattedText? = null
                    find().sendInput(baseComponentTitle!!, input) { text = it }
                    return StarshipInterimResult(baseComponentTitle!!, text!!, null, emptyList())
                }
            }
            info("Running Starship pipeline with delay=$isExplainerVisible...")
            StarshipPipeline.exec(config, results, if (isExplainerVisible) 3000 else 0)
        }.also {
            it.setOnSucceeded {
                info("Starship pipeline succeeded. Triggering a new run in 20 seconds.")
                runLater(20.seconds) { runPipeline() }
            }
        }
    }

    internal fun cancelPipeline() {
        info("Canceling Starship pipeline...")
        job?.cancel()
        jobCanceled = true
        results.clearAll()
        info("Cancellation succeeded.")
    }

}

/** Utilities for decorating components. */
internal object Chromify {
    val WID2 = 4.0
    val STROKE_STYLE = "-fx-stroke: #333;"
    val CIRC_STYLE = "-fx-stroke: #333; -fx-fill:#777;"

    /** Add decoration to parent panel, for the given node which should be inside that parent pane. */
    internal fun Pane.chromify(
        node: Node, wid: Double, title: String, icon: FontAwesomeIcon? = null,
        buttonText: ObservableValue? = null, buttonAction: (() -> Unit)? = null
    ) {
        val pane = pane {
            isPickOnBounds = false
            val gap = wid * 1.1
            val path = path {
                strokeWidth = wid
                strokeLineJoin = StrokeLineJoin.ROUND
                style = STROKE_STYLE
            }
            val circ = circle {
                strokeWidth = WID2
                style = CIRC_STYLE
                radius = 0.8 * wid
            }
            if (icon != null) {
                val iconView = icon.graphic.apply {
                    glyphSize = 0.9 * wid
                    glyphStyle = "-fx-fill: #333;"
                }
                val fudge = when (icon) {
                    FontAwesomeIcon.COMMENTS -> -2
                    FontAwesomeIcon.FILE_PDF_ALT -> -2
                    FontAwesomeIcon.MAGIC -> -1
                    else -> 0
                }
                iconView.layoutXProperty().bind(circ.centerXProperty() - iconView.layoutBounds.width/2 + fudge)
                iconView.layoutYProperty().bind(circ.centerYProperty() + iconView.layoutBounds.height/3.5)
                children.add(iconView)
            }
            if (buttonText != null) {
                line {
                    startXProperty().bind(circ.centerXProperty() + wid)
                    startYProperty().bind(circ.centerYProperty())
                    endXProperty().bind(circ.centerXProperty() + 2 * wid)
                    endYProperty().bind(circ.centerYProperty())
                    style {
                        stroke = c("#333")
                        strokeWidth = 4.px
                    }
                }
                button(buttonText) {
                    resizeRelocate(0.0, 0.0, 2 * wid, 2 * wid)
                    style {
                        backgroundColor += c("#333")
                        borderColor += box(c("#333"))
                        textFill = c("#777")
                        fontSize = (wid*0.8).px
                        borderRadius += box(10.px)
                        padding = box(0.px, 6.px, 1.px, 6.px)
                    }
                    action { buttonAction?.invoke() }
                    layoutXProperty().bind(circ.centerXProperty() + 2*wid)
                    layoutYProperty().bind(circ.centerYProperty() - 0.7*wid)

                    setOnMouseEntered {
                        style(true) { effect = javafx.scene.effect.DropShadow(10.0, c("#EE8F33")) }
                    }
                    setOnMouseExited {
                        // set to default effect
                        style(true) { effect = javafx.scene.effect.DropShadow(10.0, c("#333")) }
                    }
                }
                node.style += "-fx-padding: ${1.8*wid}px 0 0 0;"
            }
            text(title) {
                transforms.add(Rotate(90.0))
                style = "-fx-fill: gray; -fx-font-size: ${wid}px"
                layoutXProperty().bind(circ.centerXProperty().minus(0.3 * wid))
                layoutYProperty().bind(circ.centerYProperty().plus(wid))
            }

            fun updatePath() {
                // get bounds of node relative to [Pane]
                val boundsInScene = node.localToScene(node.boundsInLocal)
                val boundsInPane = this.sceneToLocal(boundsInScene)
                with(path) {
                    elements.clear()
                    moveTo(boundsInPane.minX - gap - wid / 2, boundsInPane.minY + 1.2 * wid)
                    lineTo(boundsInPane.minX - gap - wid / 2, boundsInPane.maxY)
                    arcTo(
                        gap + wid / 2, gap + wid / 2, 0.0,
                        boundsInPane.minX, boundsInPane.maxY + gap + wid / 2,
                        largeArcFlag = false, sweepFlag = false
                    )
                    lineTo(boundsInPane.maxX, boundsInPane.maxY + gap + wid / 2)
                }
                with(circ) {
                    centerX = boundsInPane.minX - gap - wid / 2
                    centerY = boundsInPane.minY + 0.5 * wid
                }
            }
            updatePath()
            node.boundsInParentProperty().onChange { updatePath() }
        }
        node.parentProperty().onChange { if (it == null) children.remove(pane) }
    }
}





© 2015 - 2025 Weber Informatics LLC | Privacy Policy