diff --git a/basis/bin/compute/compute_install.sh b/basis/bin/compute/compute_install.sh index 0c3a6257..9eeb0817 100755 --- a/basis/bin/compute/compute_install.sh +++ b/basis/bin/compute/compute_install.sh @@ -40,6 +40,9 @@ EOT # Resize the boot volume (if >47GB) sudo /usr/libexec/oci-growfs -y + + # Workaround : Force the ol8_oci_included (sometimes it is deactivated) + sudo dnf config-manager --enable ol8_oci_included fi if ! grep -q "# Build Bastion" $HOME/.bashrc; then @@ -69,6 +72,9 @@ if ! grep -q "# Build Bastion" $HOME/.bashrc; then cp $HOME/compute/git/post-receive ~/app.git/hooks chmod +x ~/app.git/hooks/post-receive chmod +x ~/app.git/hooks/post-receive + + # Cline CLI + install_cline_cli fi fi diff --git a/basis/bin/compute/rebuild.sh b/basis/bin/compute/rebuild.sh index eada64bd..cd04aeb9 100755 --- a/basis/bin/compute/rebuild.sh +++ b/basis/bin/compute/rebuild.sh @@ -5,9 +5,11 @@ start_time=$(date +%s) . ./shared_compute.sh title "Rebuild (in alphabetical order)" -TARGET_OKE="$HOME/target/oke" -mkdir -p $TARGET_OKE -export DOCKER_LOGGED=false +if [ "$TF_VAR_build_host" == "bastion" ]; then + TARGET_OKE="$HOME/target/oke" + mkdir -p $TARGET_OKE + export DOCKER_LOGGED=false +fi chmod +x */*.sh diff --git a/basis/bin/compute/shared_compute.sh b/basis/bin/compute/shared_compute.sh index 5eb1b052..74b2f4ec 100755 --- a/basis/bin/compute/shared_compute.sh +++ b/basis/bin/compute/shared_compute.sh @@ -150,11 +150,21 @@ install_java() { if [ "$TF_VAR_build_host" == "bastion" ]; then # sudo dnf install -y maven if [ ! -d $HOME/maven ]; then - MVN_VERSION=3.9.15 - wget https://dlcdn.apache.org/maven/maven-3/$MVN_VERSION/binaries/apache-maven-$MVN_VERSION-bin.tar.gz - tar xfz apache-maven-$MVN_VERSION-bin.tar.gz - mv apache-maven-$MVN_VERSION $HOME/maven - rm apache-maven-$MVN_VERSION-bin.tar.gz + BASE_URL="https://dlcdn.apache.org/maven/maven-3" + LATEST_VERSION=$( + wget -qO- "$BASE_URL/" | + grep -oE 'href="[0-9]+\.[0-9]+\.[0-9]+/' | + sed 's|href="||;s|/||' | + sort -V | + tail -1 + ) + FILE="apache-maven-${LATEST_VERSION}-bin.tar.gz" + URL="${BASE_URL}/${LATEST_VERSION}/binaries/${FILE}" + echo "Downloading Maven ${LATEST_VERSION}..." + wget -nv "$URL" + tar xfz $FILE + mv apache-maven-${LATEST_VERSION} $HOME/maven + rm $FILE export PATH=$HOME/maven/bin:$PATH echo "export PATH=$HOME/maven/bin:$PATH" >> $HOME/.bashrc fi @@ -391,9 +401,9 @@ install_ngnix() { # Default: location /app/ { proxy_pass http://localhost:8080 } if [ -f nginx_app.locations ]; then - cp nginx_app.locations $TARGET_DIR/nginx_app.locations - file_replace_variables $TARGET_DIR/nginx_app.locations - sudo cp $TARGET_DIR/nginx_app.locations /etc/nginx/conf.d/. + cp nginx_app.locations /tmp/nginx_app.locations + file_replace_variables /tmp/nginx_app.locations + sudo cp /tmp/nginx_app.locations /etc/nginx/conf.d/. if grep -q nginx_app /etc/nginx/nginx.conf; then echo "Include nginx_app.locations is already there" else @@ -434,6 +444,31 @@ install_ngnix() { } export -f install_ngnix +# -- install_nodejs ----------------------------------------------------- + +install_nodejs() { + sudo dnf module enable -y nodejs:20 + sudo dnf module install -y nodejs +} +export -f install_nodejs + +# -- install_cline_cli ----------------------------------------------------- +# https://docs.cline.bot/cline-cli/installation + +install_cline_cli() { + install_nodejs + sudo npm install -g cline + cline version + if [ "$TF_VAR_genai_api_key" == "" ] || [ "$TF_VAR_genai_model" == "" ] || [ "$TF_VAR_region" == "" ]; then + echo " SKIP: Missing variables TF_VAR_genai_api_key=$TF_VAR_genai_api_key / TF_VAR_genai_model=$TF_VAR_genai_model / TF_VAR_region=$TF_VAR_region" + else + # cline auth -p openai -k $TF_VAR_genai_api_key -b https://inference.generativeai.${TF_VAR_region}.oci.oraclecloud.com -m $TF_VAR_genai_model + cline auth -p openai -k $TF_VAR_genai_api_key -b https://inference.generativeai.${TF_VAR_region}.oci.oraclecloud.com -m openai.gpt-oss-120b + fi + # xai.grok-4-1-fast-non-reasoning +} +export -f install_cline_cli + # -- Install Docker tools --------------------------------------------------- install_docker_tools() { @@ -636,3 +671,34 @@ build_rsync() { fi } export -f build_rsync + +# -- livelab_oci_config ------------------------------------------------------------ + +# Create a OCI Config for LiveLab (that does not support instance principal) +livelab_oci_config() +{ + if [ "$LIVELABS" != "" ]; then + mkdir -p $HOME/.oci + + # OCI Config file + cat > $HOME/.oci/config << EOF +[DEFAULT] +user=$TF_VAR_current_user_ocid +fingerprint=$FINGERPRINT +tenancy=$TF_VAR_tenancy_ocid +region=$TF_VAR_region +key_file=/home/opc/.oci/oci_api_key.pem +EOF + echo "livelab_oci_config: .oci/config created" + + # oci_api_key.pem + cat > $HOME/.oci/oci_api_key.pem << EOF +$OCI_API_KEY_PEM +OCI_API_KEY + +EOF + chmod 600 $HOME/.oci/config + chmod 600 $HOME/.oci/oci_api_key.pem + fi +} +export -f livelab_oci_config \ No newline at end of file diff --git a/basis/bin/config.sh b/basis/bin/config.sh index 16896ebe..3aeaf783 100755 --- a/basis/bin/config.sh +++ b/basis/bin/config.sh @@ -164,6 +164,45 @@ if declare -p | grep -q "__TO_FILL__"; then fi fi + # PUBLIC_IP_FILTER + if [ "$TF_VAR_public_ip_filter" == "__TO_FILL__" ]; then + title "Config - Public IP Filter" + echo "The setup will include an Internet Gateway that allows HTTP and HTTPS traffic on ports 80 and 443 from the internet." + echo "What is the IP Range of the machines who can access these ports:" + echo "[1] all the machines on the internet -> 0.0.0.0/0" + echo "[2] just my laptop" + echo "[3] other" + read -rp "Choose an option [1-3]: " choice + + case "$choice" in + 1) + export TF_VAR_public_ip_filter="0.0.0.0/0" + ;; + 2) + echo "" + echo "Open a browser and find your public IP address using a site like:" + echo " https://ifconfig.me" + echo " https://whatismyipaddress.com" + echo "" + + read -rp "Enter your public IP address: " USER_IP + export TF_VAR_public_ip_filter="${USER_IP}/32" + ;; + 3) + read -rp "Enter the IP range (example: 192.168.1.0/24): " IP_RANGE + export TF_VAR_public_ip_filter="$IP_RANGE" + ;; + *) + echo "Invalid option." + exit 1 + ;; + esac + + echo "TF_VAR_public_ip_filter=${TF_VAR_public_ip_filter}" + store_terraform_tfvars public_ip_filter $TF_VAR_public_ip_filter + fi + + # LICENSE_MODEL if [ "$TF_VAR_license_model" == "__TO_FILL__" ]; then title "Config - License Model" @@ -269,6 +308,7 @@ if declare -p | grep -q "__TO_FILL__"; then read_ocid TF_VAR_fnapp_ocid "Function Application" ocid1.fnapp read_ocid TF_VAR_log_group_ocid "Log Group" ocid1.loggroup read_ocid TF_VAR_bastion_ocid "Bastion Instance" ocid1.instance + read_ocid TF_VAR_project_ocid "Generative AI Project" ocid1.generativeaiproject # ? # read_ocid TF_VAR_vault_secret_authtoken_ocid "Enter your Private Subnet OCID" ocid1.subnet # -- terraform.tfvars diff --git a/basis/bin/config_oke.sh b/basis/bin/config_oke.sh index 8d1c6923..7095a74d 100755 --- a/basis/bin/config_oke.sh +++ b/basis/bin/config_oke.sh @@ -50,7 +50,7 @@ if [ ! -f $KUBECONFIG ]; then kubectl apply -f src/oke/gateway.yaml # Wait echo "Waiting for Gateway to be ready..." - kubectl wait --for=condition=Programmed gateway/oke-gateway -n default --timeout=120s + kubectl wait --for=condition=Programmed gateway/oke-gateway -n gateway --timeout=120s exit_on_error "Gateway Programmed State" # Get the IP diff --git a/basis/bin/deploy_bastion.j2.sh b/basis/bin/deploy_bastion.j2.sh index a739c82d..33cbeb8e 100755 --- a/basis/bin/deploy_bastion.j2.sh +++ b/basis/bin/deploy_bastion.j2.sh @@ -33,21 +33,44 @@ function setup_bastion_dir() { cp -R src/app/db $BASTION_DIR/app/. fi cp $TARGET_DIR/tf_env.sh $BASTION_DIR/compute/. + + if [ "$TF_VAR_deploy_type" == "public_compute" ]; then + if [ -d src/compute ]; then + cp -R src/compute/* $BASTION_DIR/. + fi + fi } function scp_bastion() { - scp_or_rsync $BASTION_DIR/compute - RESULT=$? + {%- if test_name and deploy_type!="public_compute" %} + # If + # - During TestSuite + # - Public_compute got his own bastion (=compute) and does not need to lock it. + # - Build is done on Bastion + # - This takes as condition that an normal build did happen on the bastion before and has copied the compute/test_bastion_lock.sh before + # Get Lock CleanUp + ssh -o StrictHostKeyChecking=no -i $TF_VAR_ssh_private_path opc@$BASTION_IP "echo" + RESULT=$? if [ $RESULT -eq 0 ]; then - echo "Success - scp $BASTION_DIR/compute" + echo "Success - SSH Bastion" else return 1 - fi - {%- if test_name %} - # Get Lock CleanUp - ssh -o StrictHostKeyChecking=no -i $TF_VAR_ssh_private_path opc@$BASTION_IP "bash compute/test_bastion_lock.sh $TEST_NAME" + fi + ssh -o StrictHostKeyChecking=no -i $TF_VAR_ssh_private_path opc@$BASTION_IP "bash compute/test_bastion_lock.sh $TEST_NAME" + RESULT=$? + if [ $RESULT -eq 0 ]; then + echo "Success - lock $BASTION_DIR" + else + echo "Warning - lock failed $BASTION_DIR" + fi {%- endif %} - scp_or_rsync $BASTION_DIR/app + scp_or_rsync "$BASTION_DIR/*" + RESULT=$? + if [ $RESULT -eq 0 ]; then + echo "Success - scp $BASTION_DIR" + else + return 1 + fi } # Try 5 times to copy the files / wait 5 secs between each try @@ -66,5 +89,5 @@ while [ true ]; do i=$(($i+1)) done -ssh -o StrictHostKeyChecking=no -i $TF_VAR_ssh_private_path opc@$BASTION_IP "bash compute/compute_install.sh 2>&1 | tee compute/compute_install.log" -exit_on_error "Deploy Bastion - ssh" +ssh -o StrictHostKeyChecking=no -i $TF_VAR_ssh_private_path opc@$BASTION_IP "set -o pipefail; bash compute/compute_install.sh 2>&1 | tee compute/compute_install.log" +exit_on_error "Deploy Bastion - ssh" \ No newline at end of file diff --git a/basis/bin/deploy_compute.sh b/basis/bin/deploy_compute.sh index b9dfef9e..94f18839 100755 --- a/basis/bin/deploy_compute.sh +++ b/basis/bin/deploy_compute.sh @@ -11,7 +11,11 @@ echo "COMPUTE_IP=$COMPUTE_IP" # Create the target/compute directory cp $TARGET_DIR/tf_env.sh $TARGET_DIR/compute/compute/. +if -d src/compute; then + cp -R src/compute/* $TARGET_DIR/compute/. +fi + scp_via_bastion "target/compute/*" opc@$COMPUTE_IP:/home/opc/. -ssh -o StrictHostKeyChecking=no -oProxyCommand="$BASTION_PROXY_COMMAND" opc@$COMPUTE_IP "bash compute/compute_install.sh 2>&1 | tee compute/compute_install.log" +ssh -o StrictHostKeyChecking=no -oProxyCommand="$BASTION_PROXY_COMMAND" opc@$COMPUTE_IP "set -o pipefail; bash compute/compute_install.sh 2>&1 | tee compute/compute_install.log" exit_on_error "Deploy Compute - ssh" diff --git a/basis/src/app/rest/k8s-httproute.j2.yaml b/basis/src/app/rest/k8s-httproute.j2.yaml index 92947e96..dfc0f0c4 100644 --- a/basis/src/app/rest/k8s-httproute.j2.yaml +++ b/basis/src/app/rest/k8s-httproute.j2.yaml @@ -88,4 +88,15 @@ spec: tls: mode: SIMPLE sni: ##ORDS_HOST## +--- +apiVersion: networking.istio.io/v1beta1 +kind: DestinationRule +metadata: + name: ##TF_VAR_prefix##-destination-rule2 +spec: + host: ##TF_VAR_prefix##-rest-service + trafficPolicy: + tls: + mode: SIMPLE + sni: ##ORDS_HOST## {%- endif %} \ No newline at end of file diff --git a/basis/src/terraform/build.j21.tf b/basis/src/terraform/build.j21.tf index 351f674d..64dab5a2 100644 --- a/basis/src/terraform/build.j21.tf +++ b/basis/src/terraform/build.j21.tf @@ -73,7 +73,7 @@ resource "null_resource" "build_deploy" { command = <<-EOT cd ${local.project_dir} export CALLED_BY_TERRAFORM="true" - . ./starter.sh env + . ./starter.sh env -silent # pwd # ls -al target # cat target/terraform.tfstate @@ -97,7 +97,7 @@ resource "null_resource" "build_deploy" { fi # Build all apps - if [ "$TF_VAR_build_host" == "terraform" ]; then + if [ "$TF_VAR_build_host" != "bastion" ]; then for APP_NAME in `app_name_list_build`; do src/app/$APP_NAME/build.sh exit_on_error "Build App $APP_NAME" @@ -160,7 +160,7 @@ resource "null_resource" "after_build" { command = <<-EOT cd ${local.project_dir} export CALLED_BY_TERRAFORM="true" - . ./starter.sh env + . ./starter.sh env -silent if [ "$TF_VAR_tls" != "" ]; then title "Certificate - Post Deploy" certificate_post_deploy diff --git a/option/oke/gateway.j2.yaml b/option/oke/gateway.j2.yaml index 99b8b7f8..a5a390dc 100644 --- a/option/oke/gateway.j2.yaml +++ b/option/oke/gateway.j2.yaml @@ -1,8 +1,13 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: gateway +--- apiVersion: gateway.networking.k8s.io/v1 kind: Gateway metadata: name: oke-gateway - namespace: default + namespace: gateway annotations: # OCI specific annotation for Network Load Balancer (Layer 4) oci.oraclecloud.com/load-balancer-type: "nlb" diff --git a/option/src/app/java_helidon4/rest/pom.j2.xml b/option/src/app/java_helidon4/rest/pom.j2.xml index 147e21f2..93bd1e9b 100644 --- a/option/src/app/java_helidon4/rest/pom.j2.xml +++ b/option/src/app/java_helidon4/rest/pom.j2.xml @@ -6,7 +6,7 @@ io.helidon.applications helidon-mp - 4.0.0 + 4.4.1 helidon diff --git a/option/src/app/python_langgraph/rest/agent/agent.py b/option/src/app/python_langgraph/rest/agent/agent.py index 1afe4838..6d57e261 100644 --- a/option/src/app/python_langgraph/rest/agent/agent.py +++ b/option/src/app/python_langgraph/rest/agent/agent.py @@ -10,6 +10,7 @@ import pprint import httpx import oci_openai +from typing import Any COMPARTMENT_OCID = os.getenv("TF_VAR_compartment_ocid") REGION = os.getenv("TF_VAR_region") @@ -35,19 +36,30 @@ # model_id="meta.llama-4-scout-17b-16e-instruct", # model_id="cohere.command-a-03-2025", service_endpoint="https://inference.generativeai."+REGION+".oci.oraclecloud.com", - # model_id="xai.grok-4-fast-reasoning", + # model_id="xai.grok-4.3", # service_endpoint="https://inference.generativeai.us-chicago-1.oci.oraclecloud.com", compartment_id=COMPARTMENT_OCID, - is_stream=True, + is_stream=False, model_kwargs={"temperature": 0} ) +def remove_empty_parameter_names(args: dict[str, Any] | None) -> dict[str, Any]: + """Remove tool arguments whose parameter name is empty or only whitespace.""" + if not args: + return {} + + return { + key: value + for key, value in args.items() + if isinstance(key, str) and key.strip() + } + # See https://docs.langchain.com/oss/python/langchain/mcp#accessing-runtime-context async def inject_user_context( request: MCPToolCallRequest, handler, ): - """Inject user credentials into MCP tool calls.""" + """Inject user credentials into MCP tool calls and keep agent runs alive on tool errors.""" print( "--- request ----" ) pprint.pprint( request ) runtime = request.runtime @@ -57,8 +69,36 @@ async def inject_user_context( print( f" user_id={user_id}", flush=True ) # print( f" auth_header={auth_header}", flush=True ) # modified_request = request.override( headers = { "Authorization": f"User {user_id}" } ) - modified_request = request.override( headers = { "Authorization": auth_header } ) - return await handler(modified_request) + cleaned_args = remove_empty_parameter_names(request.args) + modified_request = request.override( + args=cleaned_args, + headers={ "Authorization": auth_header }, + ) + try: + return await handler(modified_request) + except Exception as first_error: + message = str(first_error) + print(f" tool call failed: {message}", flush=True) + + # Retry once only for likely transient errors. + transient_markers = ["timeout", "temporar", "connection reset", "503", "502", "429"] + if any(marker in message.lower() for marker in transient_markers): + print(" retrying transient tool error once", flush=True) + await asyncio.sleep(0.5) + try: + return await handler(modified_request) + except Exception as second_error: + message = str(second_error) + print(f" retry failed: {message}", flush=True) + + # For validation/format errors, return structured payload instead of raising, + # so the agent can reason on the error and try a corrected tool call. + return { + "status": "tool_error", + "retryable_by_agent": True, + "error": message, + "guidance": "Tool call failed. Adjust parameters based on this error and retry with corrected values.", + } async def init( agent_name, prompt, tools_list, callback_handler=None ) -> StateGraph: @@ -79,6 +119,13 @@ async def init( agent_name, prompt, tools_list, callback_handler=None ) -> State tools = await client.get_tools() print( "-- tools ------------------------------------------------------------") pprint.pprint( tools ) + # Filter tools. + tools_filtered = [] + for tool in tools: + if tools_list==None or tool.name in tools_list: + tools_filtered.append( tool ) + print( "-- tools_filtered ---------------------------------------------------") + pprint.pprint( tools_filtered ) break except Exception as e: print(f"Connection failed {attempt}: {e}") @@ -91,13 +138,11 @@ async def init( agent_name, prompt, tools_list, callback_handler=None ) -> State agent = create_react_agent( model=llm, - tools=tools, + tools=tools_filtered, prompt=prompt, name=agent_name ) - - return agent - + return agent prompt = """You are an agent that use the tools you got access to. INSTRUCTIONS: @@ -108,5 +153,3 @@ async def init( agent_name, prompt, tools_list, callback_handler=None ) -> State """ agent = asyncio.run(init("agent", prompt, None)) - - diff --git a/option/src/app/python_langgraph/rest/start.sh b/option/src/app/python_langgraph/rest/start.sh index c1ba1a95..6959a08e 100755 --- a/option/src/app/python_langgraph/rest/start.sh +++ b/option/src/app/python_langgraph/rest/start.sh @@ -9,5 +9,37 @@ export MCP_SERVER_URL="http://localhost:2025/mcp" # Start LangGraph CompiledStateGraph on port 2024 source myenv/bin/activate cd agent -langgraph dev --port 8080 --host 0.0.0.0 2>&1 | tee ../rest.log +PORT="8080" +HOST="0.0.0.0" +port_owner() { + if command -v lsof >/dev/null 2>&1; then + lsof -nP -iTCP:"$PORT" -sTCP:LISTEN 2>/dev/null || true + elif command -v ss >/dev/null 2>&1; then + ss -ltnp "sport = :$PORT" 2>/dev/null || true + fi +} + +for attempt in {1..10}; do + PORT_OWNER=$(port_owner) + if [ -z "$PORT_OWNER" ]; then + break + fi + + { + echo "Port $PORT is already in use. Waiting 5 seconds before starting LangGraph (attempt $attempt/10)." + echo "$PORT_OWNER" + } | tee -a ../rest.log + + sleep 5 +done + +PORT_OWNER=$(port_owner) +if [ -n "$PORT_OWNER" ]; then + echo "ERROR: Port $PORT is still in use after 10 attempts." | tee -a ../rest.log + echo "$PORT_OWNER" | tee -a ../rest.log + exit 1 +fi + +export LOG_COLOR=false +langgraph dev --no-reload --port "$PORT" --host "$HOST" 2>&1 | tee ../rest.log diff --git a/option/src/app/python_responses/rest/rest.py b/option/src/app/python_responses/rest/rest.py index 4ee475a6..dbfaa9c6 100644 --- a/option/src/app/python_responses/rest/rest.py +++ b/option/src/app/python_responses/rest/rest.py @@ -111,10 +111,7 @@ def runs_stream(thread_id: str, payload: dict[str, Any], request: Request): message_id = int(THREADS[thread_id].get("next_message_id", 1)) if message_id == 1: log(" SYSTEM_PROMPT") - input_payload = [ - {"role": "system", "content": SYSTEM_PROMPT}, - {"role": "user", "content": question}, - ] + input_payload = SYSTEM_PROMPT + '\n' + question else: input_payload = question # just the user message if message_id=1: diff --git a/option/src/ui/langgraph/ui/html/chat.css b/option/src/ui/langgraph/ui/html/chat.css index 61ebac31..f9898834 100644 --- a/option/src/ui/langgraph/ui/html/chat.css +++ b/option/src/ui/langgraph/ui/html/chat.css @@ -1,43 +1,267 @@ +:root { + --ink: #16110d; + --ink-soft: #3c3128; + --paper: #fbf7ef; + --paper-warm: #f1e7d6; + --line: rgba(73, 56, 39, .18); + --brass: #a97c43; + --brass-dark: #76552c; + --green: #183d34; + --green-soft: #e6efe9; + --shadow: 0 28px 70px rgba(23, 16, 10, .24); +} + +* { + box-sizing: border-box; +} + +a { + color: #2A2; +} + +html { + min-height: 100%; + scroll-behavior: smooth; +} + body { - font-family: Arial, sans-serif; + min-height: 100svh; margin: 0; - background: #f6f6f6; + color: var(--ink); + background: var(--ink); + font-family: "Avenir Next", "Segoe UI", Arial, sans-serif; } -#chat-container { - max-width: 1024px; - margin: 40px auto; - background: white; - padding: 16px; - box-shadow: 0 0 8px rgba(0, 0, 0, .06); - border-radius: 8px; +body, +button, +textarea { + font: inherit; } -#form-container { - max-width: 1024px; - margin: 40px auto; - background: white; - padding: 16px; - box-shadow: 0 0 8px rgba(0, 0, 0, .06); +button { + cursor: pointer; +} + +button:focus-visible, +textarea:focus-visible { + outline: 2px solid var(--brass); + outline-offset: 3px; +} + +.experience-shell { + height: 100svh; + min-height: 0; + display: grid; + grid-template-columns: minmax(280px, 30vw) minmax(0, 1fr); + background: + radial-gradient(circle at 80% 15%, rgba(169, 124, 67, .16), transparent 30%), + linear-gradient(135deg, #19110c 0%, #2a2018 45%, #14342d 100%); +} + +.visual-pane { + position: relative; + height: 100svh; + min-height: 0; + overflow: hidden; + background: + linear-gradient(180deg, rgba(15, 10, 7, .08) 0%, rgba(15, 10, 7, .4) 100%), + linear-gradient(90deg, rgba(15, 10, 7, .06) 0%, rgba(15, 10, 7, .22) 100%), + url("images/bg.png") center / cover no-repeat; +} + +.visual-pane::after { + content: ""; + position: absolute; + inset: 0; + background: + linear-gradient(90deg, transparent 72%, rgba(20, 15, 11, .42) 100%), + linear-gradient(0deg, rgba(12, 9, 7, .5) 0%, transparent 46%); +} + +.visual-caption { + position: absolute; + left: clamp(24px, 4vw, 56px); + bottom: clamp(28px, 5vw, 68px); + z-index: 1; + display: grid; + gap: 6px; + color: #fff8ec; + text-transform: uppercase; + letter-spacing: .18em; +} + +.visual-caption span { + font-size: .74rem; +} + +.visual-caption strong { + font-family: Georgia, "Times New Roman", serif; + font-size: clamp(2rem, 4vw, 4.4rem); + font-weight: 400; + letter-spacing: .08em; +} + +.chat-stage { + --stage-padding-x: clamp(30px, 4vw, 64px); + --stage-padding-y: clamp(18px, 3vw, 42px); + position: relative; + height: 100svh; + min-height: 0; + display: flex; + flex-direction: column; + justify-content: flex-start; + gap: clamp(12px, 2vw, 24px); + overflow-x: hidden; + overflow-y: auto; + padding: var(--stage-padding-y) var(--stage-padding-x); + background: + linear-gradient(135deg, rgba(251, 247, 239, .98), rgba(241, 231, 214, .96)), + linear-gradient(45deg, transparent 0 24px, rgba(169, 124, 67, .1) 24px 25px, transparent 25px 50px); + scrollbar-color: var(--brass) transparent; + scrollbar-gutter: stable; +} + +.chat-header { + position: sticky; + top: 0; + z-index: 20; + width: min(100%, 1120px); + min-height: 38px; + margin: 0 auto; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 18px; +} + +.brand-lockup { + display: flex; + align-items: center; + gap: 16px; + min-width: 0; +} + +.header-actions { + position: absolute; + top: 0; + right: 0; + display: flex; + flex: 0 0 auto; + align-items: flex-start; + gap: 12px; +} + +.brand-mark { + width: 204px; + height: 138px; + position: absolute; + top: 0; + right: 0; + display: none; +} + +.eyebrow { + margin: 0 0 8px; + color: var(--brass-dark); + font-size: .72rem; + font-weight: 700; + letter-spacing: .16em; + line-height: 1.3; + text-transform: uppercase; +} + +h1, +h2 { + margin: 0; + font-family: Georgia, "Times New Roman", serif; + font-weight: 400; + color: var(--ink); +} + +h1 { + font-size: clamp(2.2rem, 4vw, 4.7rem); + line-height: .95; +} + +h2 { + max-width: 13em; + font-size: clamp(1.85rem, 4vw, 3.35rem); + line-height: 1.04; +} + +.language-switch { + display: inline-flex; + flex: 0 0 auto; + border: 1px solid var(--line); + background: rgba(255, 252, 245, .76); +} + +.language-switch button { + min-width: 46px; + height: 38px; + border: 0; + border-right: 1px solid var(--line); + color: var(--ink-soft); + background: transparent; + font-size: .78rem; + font-weight: 800; + letter-spacing: .12em; +} + +.language-switch button:last-child { + border-right: 0; +} + +.language-switch button[aria-current="true"] { + color: #fff9ee; + background: var(--ink); +} + +.chat-card { + width: min(100%, 1120px); + min-height: 0; + flex: 0 0 auto; + max-height: none; + margin: 0 auto; + display: flex; + flex-direction: column; + overflow: hidden; + border: 1px solid rgba(73, 56, 39, .24); border-radius: 8px; + background: rgba(255, 252, 245, .9); + box-shadow: var(--shadow); + backdrop-filter: blur(18px); } -#currentDisplay { - font-size: 80%; - color: gray; - position: fixed; - bottom: 10px; - right: 10px; +#chat-container { + min-height: 0; + flex: 0 0 auto; + display: flex; + flex-direction: column; + overflow: visible; + padding: clamp(18px, 3vw, 30px) clamp(22px, 3.5vw, 40px) clamp(8px, 1.4vw, 14px); +} + +.chat-intro { + flex: 0 0 auto; + padding-bottom: clamp(18px, 3vw, 30px); + border-bottom: 1px solid var(--line); + display: flex; +} + +.chat-intro2 { } #messages { - min-height: 20vh; - margin-bottom: 16px; + min-height: 160px; + flex: 0 0 auto; + padding: clamp(16px, 2.4vw, 24px) 4px 0 0; + margin-bottom: 4px; } .message { - margin-bottom: 20px; display: flex; + margin-bottom: 18px; } .human { @@ -50,190 +274,250 @@ body { } .meta { - font-size: .9em; - color: #888; - margin-bottom: 2px; + margin-bottom: 8px; + color: rgba(22, 17, 13, .56); + font-size: .68rem; + font-weight: 800; + letter-spacing: .14em; + text-transform: uppercase; } .bubble { - max-width: 90%; - padding: 14px 20px; - border-radius: 18px; - background: #eee; position: relative; - word-break: break-word; - box-shadow: 0 2px 6px rgba(20, 71, 151, .08); + max-width: min(92%, 920px); + padding: 16px 18px; + border: 1px solid var(--line); + border-radius: 8px; + background: #fffaf0; + color: var(--ink); + line-height: 1.55; + overflow-wrap: anywhere; + box-shadow: 0 10px 30px rgba(23, 16, 10, .07); +} + +.bubble p:first-child { + margin-top: 0; +} + +.bubble p:last-child { + margin-bottom: 0; } .human .bubble { - background: #ddd; - color: black; - border-bottom-right-radius: 5px; - border-bottom-left-radius: 18px; - border-top-right-radius: 18px; - border-top-left-radius: 18px; + border-color: rgba(22, 17, 13, .86); + background: var(--ink); + color: #fff9ee; +} + +.human .meta { + color: rgba(255, 249, 238, .64); } .ai .bubble, .tool .bubble { - background: #ddd; - color: black; - border-bottom-left-radius: 5px; - border-bottom-right-radius: 18px; - border-top-right-radius: 18px; - border-top-left-radius: 18px; - overflow: visible; + max-width: 100%; + background: rgba(255, 250, 240, .96); } -.bubble-content { - overflow: auto; +.human .bubble { + max-width: min(78%, 780px); } +.message.tool-event { + display: inline-flex; + max-width: 100%; + margin: 0 8px 10px 0; + vertical-align: top; +} -form { +.tool-event-line { display: flex; - gap: 8px; + align-items: center; + gap: 6px; + max-width: 100%; + overflow-x: auto; + padding-bottom: 2px; + scrollbar-width: thin; + scrollbar-color: var(--brass) transparent; +} + +.tool-event-button { + min-height: 32px; + max-width: min(260px, 70vw); + padding: 6px 10px; + border: 1px solid rgba(24, 61, 52, .2); + border-radius: 6px; + background: var(--brass); + color: var(--green-soft); + box-shadow: 0 8px 18px rgba(23, 16, 10, .07); + font-size: .72rem; + font-weight: 800; + letter-spacing: .08em; + line-height: 1.2; + text-transform: uppercase; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } -textarea { - flex: 1; - padding: 18px; - border-radius: 18px; - border: 1px lightgray solid; - outline: 0px #eee solid; - font-family: Arial, sans-serif; +.tool-event-button:hover { + border-color: rgba(169, 124, 67, .45); + background: #fffaf0; + color: var(--ink); } -button { - padding: 10px 10px; - border-radius: 18px; - border: 1px lightgray solid; - background-color: lightgray; +.tool-dialog { + width: min(760px, calc(100vw - 32px)); + max-height: min(78vh, 720px); + padding: 0; + border: 0; + background: transparent; + color: var(--ink); } -button img { - height: 25px; +.tool-dialog::backdrop { + background: rgba(22, 17, 13, .48); } -#mic-button { - background-color: #e0f7fa; - border-color: #00acc1; +.tool-dialog-panel { + position: relative; + max-height: inherit; + margin: 0; + padding: 22px; + border: 1px solid var(--line); + border-radius: 8px; + background: #fffaf0; + box-shadow: var(--shadow); + overflow: auto; } -#mic-button.recording { - background-color: #ff4444; - border-color: #cc0000; - animation: micPulse 1s infinite; +.tool-dialog-panel .tool-dialog-close { + position: absolute; + top: 10px; + right: 10px; + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + min-height: 0; + height: 22px; + padding: 0; + border: 1px solid var(--ink); + border-radius: 999px; + background: var(--ink); + color: #fff9ee; + font-size: .95rem; + font-weight: 700; + line-height: 1; + box-shadow: 0 6px 14px rgba(23, 16, 10, .18); + transform: none; } -@keyframes micPulse { - 0% { transform: scale(1); opacity: 1; } - 50% { transform: scale(1.2); opacity: 0.8; } - 100% { transform: scale(1); opacity: 1; } +.tool-dialog-panel .tool-dialog-close:hover { + border-color: var(--brass); + background: var(--ink); + color: #fff9ee; + transform: none; } -.tool-calls { - background: #eef4fc; - padding: 8px; - font-size: 0.95em; - border-radius: 4px; - margin-top: 8px; +.tool-dialog-body { + max-height: calc(78vh - 44px); + padding-right: 32px; + overflow: auto; + line-height: 1.55; } -/* Tools table - hidden by default, shown as popup on hover */ -.bubble .tools-table { - display: none; - position: absolute; - top: 100%; - left: 0; - background: #fff; - border: 1px solid #ddd; - border-radius: 8px; - box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15); - z-index: 100; - min-width: 400px; - max-width: 600px; - margin-top: 8px; - padding: 12px; - font-size: 0.9em; -} - -.bubble:hover .tools-table { - display: block; -} - -.bubble table { - background: #f8f9fa; - padding: 8px; - font-size: 0.95em; - border-radius: 4px; - margin-top: 8px; - min-width: 15em; - max-width: 95%; - width: 95%; - border-collapse: collapse; +.tool-dialog-body p:first-child { + margin-top: 0; } -.bubble td { - padding: 0.4em 0.6em; - max-width: 140px; - background: #fff; - white-space: nowrap; +.tool-dialog-body p:last-child { + margin-bottom: 0; +} + +.tool-dialog-body pre { + margin: 0; + padding: 14px; + border: 1px solid rgba(73, 56, 39, .14); + border-radius: 6px; + background: rgba(241, 231, 214, .62); + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.bubble table, +.tool-dialog-body table { + width: 100%; + max-width: 100%; + margin-top: 10px; + border-collapse: collapse; + table-layout: fixed; + border: 1px solid var(--line); + border-radius: 6px; + background: #fffaf0; + font-size: .92em; overflow: hidden; - text-overflow: ellipsis; - border: 1px solid #eee; } -.bubble th { - padding: 0.5em 0.6em; - background: #f0f0f0; - font-weight: 600; +.bubble th, +.bubble td, +.tool-dialog-body th, +.tool-dialog-body td { + padding: .56em .7em; + border: 1px solid rgba(73, 56, 39, .14); text-align: left; - border: 1px solid #ddd; } -.bubble tr:hover td { +.bubble th, +.tool-dialog-body th { + background: rgba(169, 124, 67, .12); + font-weight: 800; +} + +.bubble td, +.tool-dialog-body td { + overflow-wrap: anywhere; white-space: normal; - overflow: visible; - background: #f0f8ff; +} + +.bubble tr:hover td, +.tool-dialog-body tr:hover td { + background: rgba(230, 239, 233, .72); } .citation { - font-size: 0.9em; - color: #187; - margin-top: 3px; + margin-top: 5px; + color: var(--green); + font-size: .9em; } -/* Spinner styles */ #spinner-container { width: 100%; + min-height: 0; display: flex; justify-content: flex-start; - min-height: 38px; } #spinner { - margin-top: -8px; - margin-bottom: 8px; - width: 35px; - height: 37px; + width: 42px; + height: 30px; display: flex; - align-items: flex-start; + align-items: center; } .pulse-dot { - margin-left: 16px; - width: 18px; - height: 18px; - background: #111; + width: 13px; + height: 13px; + margin-left: 18px; border-radius: 50%; - animation: pulseAnim 1s cubic-bezier(.4, 0, .6, 1) infinite; + background: var(--brass); + animation: pulseAnim 1.1s cubic-bezier(.4, 0, .6, 1) infinite; } @keyframes pulseAnim { 0% { transform: scale(1); - opacity: .8; + opacity: .55; } 50% { @@ -243,104 +527,306 @@ button img { 100% { transform: scale(1); - opacity: .8; + opacity: .55; } } -/* Hamburger Menu */ -.hamburger { - position: fixed; - left: 20px; - top: 20px; - width: 30px; - height: 30px; - padding: 6px; - z-index: 100; - background: - linear-gradient(#111, #111) center 7px / 18px 2px no-repeat, - linear-gradient(#111, #111) center 14px / 18px 2px no-repeat, - linear-gradient(#111, #111) center 21px / 18px 2px no-repeat, - #fff; - border: 1px solid #bbb; - border-radius: 6px; - cursor: pointer; +#form-container { + border-top: 1px solid var(--line); + background: rgba(247, 239, 225, .92); + padding: clamp(14px, 3vw, 22px); } -.hamburger:focus { - outline: 1px solid #000; +form { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + gap: 10px; + align-items: end; } -nav { - position: fixed; - top: 0; - left: -250px; - height: calc(100vh - 70px); - width: 220px; - background: white; - box-shadow: 2px 0 8px rgba(0,0,0,0.1); - transition: left .3s; - border-top: solid 70px black; +textarea { + width: 100%; + max-height: 150px; + min-height: 52px; + overflow-y: hidden; + resize: none; + padding: 15px 16px; + border: 1px solid rgba(73, 56, 39, .24); + border-radius: 6px; + background: #fffaf0; + color: var(--ink); + line-height: 1.45; } -nav.open { - left: 0; - z-index: 2; +textarea::placeholder { + color: rgba(60, 49, 40, .58); } -nav ul { - list-style: none; - padding: 0; - margin: 0; +#mic-button, +form button[type="submit"], +.icon-button { + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid rgba(73, 56, 39, .22); + background: #fffaf0; + transition: transform .18s ease, border-color .18s ease, background .18s ease; } -nav .section-title { - font-weight: bold; - margin: 16px 0 4px 18px; - font-size: 1rem; - color: #005aad; +#mic-button, +form button[type="submit"] { + width: 52px; + height: 52px; + border-radius: 50%; } -nav li { - padding: 12px 20px; - cursor: pointer; +form button[type="submit"] { + border-color: var(--ink); + background: var(--ink); } -nav li:focus, -nav li:hover { - background: #efefef; - outline: none; +#mic-button:hover, +form button[type="submit"]:hover, +.icon-button:hover { + transform: translateY(-1px); + border-color: var(--brass); } -nav .menu-language { - padding: 8px 20px 14px 20px; - display: flex; - flex-direction: column; - gap: 6px; +button img { + width: 24px; + height: 24px; + object-fit: contain; } -nav .menu-language label { - font-size: 0.9rem; - color: #555; +form button[type="submit"] img { + filter: invert(1); } -nav .menu-language select { - border: 1px solid #ccc; - border-radius: 6px; - padding: 6px 8px; - font-size: 0.95rem; - background: #fff; +#mic-button.recording { + border-color: var(--brass); + background: rgba(169, 124, 67, .16); + animation: micPulse 1s infinite; } -nav .menu-language select:focus { - outline: 1px solid #005aad; +@keyframes micPulse { + 0% { + transform: scale(1); + opacity: 1; + } + + 50% { + transform: scale(1.08); + opacity: .82; + } + + 100% { + transform: scale(1); + opacity: 1; + } } #reset { - position: fixed; - top: 10px; - right: 10px; + width: 38px; + height: 38px; + border-radius: 50%; + box-shadow: 0 12px 28px rgba(23, 16, 10, .12); } -#reset:hover { - background: #fff; -} \ No newline at end of file +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +@media (max-width: 980px) { + .experience-shell { + height: auto; + min-height: 100svh; + display: block; + background: var(--paper); + } + + .visual-pane { + height: auto; + min-height: 38svh; + background-position: center 42%; + } + + .visual-pane::after { + background: + linear-gradient(0deg, rgba(17, 12, 8, .72) 0%, transparent 58%), + linear-gradient(90deg, rgba(17, 12, 8, .08), rgba(17, 12, 8, .22)); + } + + .chat-stage { + --stage-padding-y: 0px; + height: auto; + min-height: 62svh; + justify-content: flex-start; + overflow: visible; + margin-top: -42px; + padding-top: 0; + } + + .chat-header { + align-items: center; + } + + .brand-mark { + display: none; + } + + h1 { + font-size: clamp(2.2rem, 9vw, 4rem); + } + + .chat-card { + position: relative; + z-index: 1; + flex: none; + max-height: none; + min-height: 58svh; + } +} + +@media (max-width: 640px) { + html, + body { + height: 100%; + min-height: 0; + overflow: hidden; + } + + .experience-shell { + height: 100dvh; + min-height: 100svh; + display: flex; + flex-direction: column; + overflow: hidden; + } + + .visual-pane { + height: 24svh; + min-height: 160px; + flex: 0 0 24svh; + } + + .visual-caption { + left: 20px; + bottom: 54px; + } + + .visual-caption strong { + font-size: clamp(1.7rem, 8vw, 2.1rem); + } + + .chat-stage { + --stage-padding-x: 16px; + min-height: 0; + flex: 1 1 auto; + gap: 12px; + overflow-x: hidden; + overflow-y: auto; + padding: 0 16px 0; + } + + .chat-header { + flex-direction: column; + align-items: stretch; + gap: 10px; + flex: 0 0 auto; + } + + .brand-lockup { + justify-content: space-between; + } + + .language-switch { + background: rgba(255, 252, 245, .95); + } + + .header-actions { + align-self: flex-start; + right: auto; + left: 0; + } + + .chat-card { + min-height: 0; + flex: 0 0 auto; + border-radius: 8px; + } + + #chat-container { + min-height: 0; + flex: 0 0 auto; + padding: 16px 16px 8px; + } + + h2 { + max-width: none; + font-size: clamp(1.75rem, 9vw, 2.45rem); + } + + #messages { + min-height: 0; + flex: 0 0 auto; + padding-right: 0; + } + + .bubble { + max-width: 94%; + padding: 14px; + } + + #form-container { + flex: 0 0 auto; + padding: 12px; + } + + form { + grid-template-columns: minmax(0, 1fr) 46px 46px; + gap: 8px; + } + + #mic-button, + form button[type="submit"] { + width: 46px; + height: 46px; + } + + textarea { + min-height: 46px; + padding: 12px; + } + + #reset { + width: 38px; + height: 38px; + background: rgba(255, 250, 240, .9); + } +} + +@media (max-width: 420px) { + .visual-caption span { + font-size: .66rem; + } + + .visual-caption strong { + font-size: 1.8rem; + } + + .eyebrow { + font-size: .66rem; + } + + .language-switch button { + min-width: 42px; + } +} diff --git a/option/src/ui/langgraph/ui/html/chat.js b/option/src/ui/langgraph/ui/html/chat.js index e8d0a4db..703afc1f 100644 --- a/option/src/ui/langgraph/ui/html/chat.js +++ b/option/src/ui/langgraph/ui/html/chat.js @@ -17,6 +17,7 @@ const users = ['employee', 'customer']; let thread_id = null; let last_message_id = -1; const messagesEl = document.getElementById('messages'); +const chatStage = document.querySelector('.chat-stage'); const chatForm = document.getElementById('chat-form'); const chatInput = document.getElementById('chat-input'); const spinnerContainer = document.getElementById('spinner-container'); @@ -38,8 +39,10 @@ chatInput.addEventListener('keydown', (e) => { }); function autoGrowTextarea() { if (!chatInput) return; + const maxHeight = Number.parseFloat(getComputedStyle(chatInput).maxHeight); chatInput.style.height = 'auto'; - chatInput.style.height = `${chatInput.scrollHeight - 36}px`; + chatInput.style.height = `${Math.min(chatInput.scrollHeight, maxHeight || chatInput.scrollHeight)}px`; + chatInput.style.overflowY = maxHeight && chatInput.scrollHeight > maxHeight ? 'auto' : 'hidden'; } chatInput.addEventListener('input', autoGrowTextarea); @@ -52,21 +55,121 @@ function safeParse(json) { catch (e) { return {}; } } -async function renderContent(input) { +async function renderContent(input) +{ const MERMAID_FENCE_RE = /```(?:\s*)mermaid\s*\n([\s\S]*?)\n```/i; if (MERMAID_FENCE_RE.test(input)) { const m = input.match(/```mermaid\s*([\s\S]*?)\s*```/i); const m2 = m[1].trim(); - const value = await mermaid.render("diagram", m2); + const value = await mermaid.render("diagram",m2); return value.svg; } else { - return renderMarkdown(input); + return renderMarkdown(input); } } function renderMarkdown(md) { return marked.parse(md || ""); } + +function escapeHtml(value) { + return String(value) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +let toolDialog = null; + +function ensureToolDialog() { + if (toolDialog) return toolDialog; + + toolDialog = document.createElement('dialog'); + toolDialog.className = 'tool-dialog'; + toolDialog.innerHTML = ` +
+ +
+
+ `; + toolDialog.addEventListener('click', (event) => { + if (event.target === toolDialog) { + toolDialog.close(); + } + }); + document.body.appendChild(toolDialog); + return toolDialog; +} + +function openToolDialog(bodyHtml) { + const dialog = ensureToolDialog(); + dialog.querySelector('.tool-dialog-body').innerHTML = bodyHtml; + if (dialog.open) { + dialog.close(); + } + if (typeof dialog.showModal === 'function') { + dialog.showModal(); + } else { + dialog.setAttribute('open', ''); + } +} + +function renderJsonBody(value, emptyText) { + if (value === undefined || value === null || value === '') { + return `${emptyText}`; + } + if (typeof value === 'string') { + return `
${escapeHtml(value)}
`; + } + const json = JSON.stringify(value, null, 2); + return `
${escapeHtml(json ?? String(value))}
`; +} + +function renderToolCallBody(toolCall) { + return renderJsonBody(toolCall.args, '(No arguments)'); +} + +function renderToolResponseBody(msgObj) { + const data = msgObj.artifact?.structured_content ?? {}; + const body = []; + + if (data?.response) { + body.push(renderMarkdown(data.response)); + } + if (data?.result) { + body.push(renderJsTable(data.result)); + } + if (body.length > 0) { + return body.join(''); + } + if (msgObj.content) { + return renderMarkdown(msgObj.content); + } + if (Object.keys(data).length === 0) { + return '(No response body)'; + } + return renderJsonBody(data, '(No response body)'); +} + +function createToolButton(label, bodyHtml) { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'tool-event-button'; + button.textContent = label; + button.addEventListener('click', () => openToolDialog(bodyHtml)); + return button; +} + +function renderToolEventLine(el, buttons) { + el.classList.add('tool-event'); + const line = document.createElement('div'); + line.className = 'tool-event-line'; + buttons.forEach(button => line.appendChild(button)); + el.appendChild(line); +} + // Add or move spinner below last message (show while waiting for SSE) function showSpinner() { spinnerContainer.innerHTML = `
`; @@ -77,14 +180,15 @@ function hideSpinner() { spinnerContainer.innerHTML = ''; } -// Remove spinner (when SSE is done) -function errorSpinner() { - spinnerContainer.innerHTML = 'ERROR'; -} - function scrollToBottom() { - // Scroll so the anchor div is visible - document.getElementById('spinner-container').scrollIntoView({ behavior: "smooth" }); + if (!chatStage) return; + + const scroll = () => { + chatStage.scrollTop = chatStage.scrollHeight; + }; + + scroll(); + requestAnimationFrame(scroll); } function renderJsTable(data) { @@ -112,37 +216,30 @@ function renderJsTable(data) { async function renderMessage(msgObj) { const el = document.createElement('div'); + const msgType = msgObj.type || 'ai'; el.classList.add('message'); - el.classList.add(msgObj.type || 'ai'); + el.classList.add(msgType); let innerHTML = ''; // Human message - if (msgObj.type === 'human') { - innerHTML = `
You
${renderMarkdown(msgObj.content)}
`; - } else if (msgObj.type === 'ai') { + if (msgType === 'human') { + innerHTML = `
You
${renderMarkdown(msgObj.content)}
`; + } else if (msgType === 'ai') { if (msgObj.content) { - innerHTML = `
AI
${await renderContent(msgObj.content)}
`; + innerHTML = `
AI
${await renderContent(msgObj.content)}
`; } else if (msgObj.tool_calls && msgObj.tool_calls.length > 0) { - const toolNames = msgObj.tool_calls.map(t => t.name).join(' - '); - let bubble = `
Tool Calls - ${toolNames}
`; - let tools = msgObj.tool_calls.map(t => - `${t.name}${JSON.stringify(t.args)}` - ).join(''); - bubble += `${tools}
NameArguments
`; - innerHTML = bubble; + const buttons = msgObj.tool_calls.map(toolCall => + createToolButton(`Call: ${toolCall.name || 'tool'}`, renderToolCallBody(toolCall)) + ); + renderToolEventLine(el, buttons); } - } else if (msgObj.type === 'tool') { - let data = msgObj.artifact?.structured_content ?? {}; - let bubble = "
Tool - " + msgObj.name + "
"; - if (data?.response) { - bubble += renderMarkdown(data.response); - } - if (data?.result) { - bubble += renderJsTable(data.result); - } - bubble += "
"; - innerHTML = bubble; + } else if (msgType === 'tool') { + renderToolEventLine(el, [ + createToolButton(`Response: ${msgObj.name || 'tool'}`, renderToolResponseBody(msgObj)) + ]); + } + if (innerHTML) { + el.innerHTML = innerHTML; } - el.innerHTML = innerHTML; messagesEl.appendChild(el); scrollToBottom(); } @@ -154,8 +251,8 @@ function startSSE(reqBody, onMessage, onDone) { // SSE with POST is non-standard. We'll use fetch + stream reader fetch(url, { method: "POST", - headers: { - "Content-Type": "application/json", + headers: { + "Content-Type": "application/json", "Authorization": `User ${currentUser}`, "X-CSRF-TOKEN": csrfToken }, @@ -163,7 +260,7 @@ function startSSE(reqBody, onMessage, onDone) { body: JSON.stringify(reqBody) }).then(async response => { if (!response.ok || !response.body) { - errorSpinner(); + hideSpinner(); onMessage({ type: "ai", content: "Network/server error." }); if (onDone) onDone(); return; @@ -185,6 +282,7 @@ function startSSE(reqBody, onMessage, onDone) { if (match) { let data = match[1]; let json = safeParse(data); + console.log("SSE data:", json); // Debug log if (json?.messages) { for (const id in json.messages) { let nid = Number(id) @@ -193,6 +291,18 @@ function startSSE(reqBody, onMessage, onDone) { last_message_id = nid } } + } else if (json?.error || json?.ToolException) { + // Handle tool errors or LangGraph errors + let errorMsg = json.error || json.ToolException || json.message || "Unknown error occurred"; + if (json.status === "tool_error") { + errorMsg = `Tool Error: ${errorMsg}. Please check the logs for details or try rephrasing your request.`; + } else { + errorMsg = `Error: ${errorMsg}. Please check the logs.`; + } + onMessage({ + type: "ai", + content: `**Error occurred** - ${errorMsg}` + }); } } } @@ -201,7 +311,7 @@ function startSSE(reqBody, onMessage, onDone) { hideSpinner(); if (onDone) onDone(); }).catch(e => { - errorSpinner(); + hideSpinner(); onMessage({ type: "ai", content: "Connection error." }); if (onDone) onDone(); }); @@ -213,21 +323,28 @@ async function getThreadId() { const resp = await fetch(url, { method: "POST", body: "{}", - headers: { + headers: { "Authorization": `User ${currentUser}`, "X-CSRF-TOKEN": csrfToken }, credentials: 'include' }); + if (!resp.ok) { + throw new Error(`Backend responded with ${resp.status}`); + } + const contentType = resp.headers.get('content-type') || ''; + if (!contentType.includes('application/json')) { + throw new Error('Backend did not return JSON'); + } const data = await resp.json(); return data.thread_id; } catch (e) { - alert("Failed to connect to chat server."); + console.warn("Failed to connect to chat server.", e); } } async function addMessage(msgObj) { - renderMessage(msgObj); + await renderMessage(msgObj); } chatForm.addEventListener('submit', async function (e) { @@ -237,6 +354,7 @@ chatForm.addEventListener('submit', async function (e) { addMessage({ type: "human", content: msg }); chatInput.value = ''; + autoGrowTextarea(); const reqBody = { "assistant_id": "agent", @@ -271,23 +389,35 @@ reset.addEventListener('click', () => { window.location.reload(); }); -// -- Hamburger menu logic ------------------------------------------ +// -- Optional settings menu logic ----------------------------------- const hamburger = document.querySelector('.hamburger'); const nav = document.getElementById('agentMenu'); -hamburger.addEventListener('click', () => { - const isOpen = nav.classList.toggle('open'); - hamburger.setAttribute('aria-expanded', isOpen); -}); -document.addEventListener('keydown', function (e) { - if (e.key === "Escape") { + +function closeSettingsPanel() { + if (nav) { nav.classList.remove('open'); + } + if (hamburger) { hamburger.setAttribute('aria-expanded', 'false'); } -}); +} + +if (hamburger && nav) { + hamburger.addEventListener('click', () => { + const isOpen = nav.classList.toggle('open'); + hamburger.setAttribute('aria-expanded', isOpen); + }); + document.addEventListener('keydown', function (e) { + if (e.key === "Escape") { + closeSettingsPanel(); + } + }); +} // Users section function renderBackendList() { const backendList = document.getElementById('backendList'); + if (!backendList) return; backendList.innerHTML = ''; backends.forEach(backend => { const li = document.createElement('li'); @@ -301,8 +431,9 @@ function renderBackendList() { function renderUserList() { const userList = document.getElementById('userList'); + if (!userList) return; userList.innerHTML = ''; - if (csrfToken == "") { + if( csrfToken=="" ) { users.forEach(user => { const li = document.createElement('li'); li.textContent = user; @@ -314,7 +445,7 @@ function renderUserList() { } else { const li = document.createElement('li'); li.textContent = "Logout"; - li.addEventListener('click', () => { + li.addEventListener('click', () => { /* window.location.href = '/openid/logout?postLogoutUrl='+window.location.origin+'/openid/chat.html'; */ window.location.href = '/openid/logout?postLogoutUrl=https://www.oracle.com'; }); @@ -343,6 +474,7 @@ async function fetchAgents() { function renderAgentList(agents) { const agentList = document.getElementById('agentList'); + if (!agentList) return; agentList.innerHTML = ''; agents.forEach(agent => { const li = document.createElement('li'); @@ -356,7 +488,10 @@ function renderAgentList(agents) { // Updating display function updateDisplay() { - document.getElementById('currentDisplay').textContent = `Backend: ${currentBackend} - Agent: ${currentAgent} - User: ${currentUser}`; + const currentDisplay = document.getElementById('currentDisplay'); + if (currentDisplay) { + currentDisplay.textContent = `Backend: ${currentBackend} - Agent: ${currentAgent} - User: ${currentUser}`; + } } async function setCurrentBackend(backendName) { @@ -370,16 +505,18 @@ async function setCurrentBackend(backendName) { thread_id = await getThreadId(); last_message_id = 0; if (!thread_id) { - messagesEl.innerHTML = '
Error: could not get thread_id from backend.
'; + messagesEl.innerHTML = ''; + await addMessage({ type: "ai", content: "The concierge service is currently unavailable." }); chatInput.disabled = true; } else { chatInput.disabled = false; } updateDisplay(); - nav.classList.remove('open'); - hamburger.setAttribute('aria-expanded', 'false'); - fetchAgents().then(renderAgentList); + closeSettingsPanel(); + if (document.getElementById('agentList')) { + fetchAgents().then(renderAgentList); + } renderUserList(); renderBackendList(); } @@ -387,19 +524,21 @@ async function setCurrentBackend(backendName) { function setCurrentAgent(agentName) { currentAgent = agentName; updateDisplay(); - nav.classList.remove('open'); - hamburger.setAttribute('aria-expanded', 'false'); + closeSettingsPanel(); // Re-render to update aria-current - fetchAgents().then(renderAgentList); + if (document.getElementById('agentList')) { + fetchAgents().then(renderAgentList); + } renderUserList(); } function setCurrentUser(user) { currentUser = user; updateDisplay(); - nav.classList.remove('open'); - hamburger.setAttribute('aria-expanded', 'false'); + closeSettingsPanel(); // Re-render to update aria-current - fetchAgents().then(renderAgentList); + if (document.getElementById('agentList')) { + fetchAgents().then(renderAgentList); + } renderUserList(); } @@ -411,7 +550,7 @@ async function fetchUserInfo() { }); if (!response.ok) throw new Error('Failed to fetch UserInfo'); csrfToken = response.headers.get('x-csrf-token'); - console.log(`Found x-csrf-token ${csrfToken}`) + console.log( `Found x-csrf-token ${csrfToken}` ) let data = await response.json(); currentUser = data.sub; updateDisplay(); @@ -421,6 +560,7 @@ let currentLang = 'en'; let recognition = null; function initRecognition() { + if (!micButton) return; if (!('SpeechRecognition' in window) && !('webkitSpeechRecognition' in window)) { micButton.style.display = 'none'; return; @@ -434,7 +574,7 @@ function initRecognition() { recognition.onstart = () => { micButton.classList.add('recording'); - chatInput.placeholder = getPlaceholder(); + chatInput.placeholder = getListeningPlaceholder(); }; recognition.onresult = (event) => { @@ -448,12 +588,12 @@ function initRecognition() { recognition.onerror = (event) => { console.error('Speech recognition error:', event.error); micButton.classList.remove('recording'); - chatInput.placeholder = getPlaceholder(); + chatInput.placeholder = getInputPlaceholder(); }; recognition.onend = () => { micButton.classList.remove('recording'); - chatInput.placeholder = getPlaceholder(); + chatInput.placeholder = getInputPlaceholder(); }; } @@ -461,37 +601,44 @@ function getLangCode(lang) { return lang === 'fr' ? 'fr-FR' : 'en-US'; } -function getPlaceholder() { +function getInputPlaceholder() { + return currentLang === 'fr' ? 'Tapez votre message...' : 'Type your message...'; +} + +function getListeningPlaceholder() { return currentLang === 'fr' ? 'Écoute...' : 'Listening...'; } function updateLanguage(lang) { currentLang = lang; document.documentElement.lang = lang; - document.querySelector('h2').textContent = lang === 'fr' - ? 'Comment puis-je vous aider ?' - : 'How can I help ?'; - chatInput.placeholder = lang === 'fr' ? 'Tapez votre message...' : 'Type your message...'; + document + .querySelector('h2').textContent = lang === 'fr' + ? 'Puis-je vous aider?' + : 'How may I help?'; + chatInput.placeholder = getInputPlaceholder(); if (recognition) { recognition.lang = getLangCode(lang); } - const languageItems = document.querySelectorAll('#languageList li'); + const languageItems = document.querySelectorAll('#languageList [data-lang]'); languageItems.forEach((item) => { item.setAttribute('aria-current', item.dataset.lang === lang ? 'true' : 'false'); }); } -micButton.addEventListener('click', (e) => { - e.preventDefault(); - if (recognition) { - recognition.start(); - } -}); +if (micButton) { + micButton.addEventListener('click', (e) => { + e.preventDefault(); + if (recognition) { + recognition.start(); + } + }); +} -// Language selector list in menu +// Language selector document.addEventListener('DOMContentLoaded', () => { - const languageItems = document.querySelectorAll('#languageList li'); + const languageItems = document.querySelectorAll('#languageList [data-lang]'); languageItems.forEach((item) => { item.addEventListener('click', () => { updateLanguage(item.dataset.lang); @@ -505,21 +652,23 @@ document.addEventListener('DOMContentLoaded', () => { (async function init() { if (window.location.pathname.startsWith('/openid')) { - await fetchUserInfo(); - } - console.log(`before init x-csrf-token ${csrfToken}`); + await fetchUserInfo(); + } + console.log( `before init x-csrf-token ${csrfToken}` ); thread_id = await getThreadId(); last_message_id = 0; if (!thread_id) { - messagesEl.innerHTML = '
Error: could not get thread_id from backend.
'; + messagesEl.innerHTML = ''; + await addMessage({ type: "ai", content: "The service is currently unavailable." }); chatInput.disabled = true; } initRecognition(); renderBackendList(); renderUserList(); - fetchAgents() - .then(renderAgentList) - .catch(error => alert("Could not load agents: " + error)); + if (document.getElementById('agentList')) { + fetchAgents() + .then(renderAgentList) + .catch(error => console.error("Could not load agents:", error)); + } updateDisplay(); })(); - diff --git a/option/src/ui/langgraph/ui/html/images/bg.png b/option/src/ui/langgraph/ui/html/images/bg.png new file mode 100644 index 00000000..f7c89964 Binary files /dev/null and b/option/src/ui/langgraph/ui/html/images/bg.png differ diff --git a/option/src/ui/langgraph/ui/html/images/logo21.png b/option/src/ui/langgraph/ui/html/images/logo21.png new file mode 100644 index 00000000..2507bbaa Binary files /dev/null and b/option/src/ui/langgraph/ui/html/images/logo21.png differ diff --git a/option/src/ui/langgraph/ui/html/index.html b/option/src/ui/langgraph/ui/html/index.html index 6d44b29f..9ed6659f 100644 --- a/option/src/ui/langgraph/ui/html/index.html +++ b/option/src/ui/langgraph/ui/html/index.html @@ -3,43 +3,68 @@ - Chat Page + + OCI Enterprise AI - Demo - -
-

How can I help ?

-
-
+
+
-
-
- - - -
-
-
-
- + + +
+
+
+
+ +
+
+ + +
+ +
+
+ +
+
+
+ +
+

At your service

+

How may I help?

+
+
+
+
+
+ +
+
+ + + +
+
+
+ +
+
+ + - \ No newline at end of file + diff --git a/option/terraform/network.j2.tf b/option/terraform/network.j2.tf index fb52fbff..25cd374b 100644 --- a/option/terraform/network.j2.tf +++ b/option/terraform/network.j2.tf @@ -1,4 +1,8 @@ # --- Network --- +variable "public_ip_filter" { + description = "IP Range that can access the public network" + default = "0.0.0.0/0" +} {%- if vcn_ocid is defined %} # Existing VCN and Subnets variable "vcn_ocid" { @@ -55,10 +59,6 @@ data "oci_core_subnet" "starter_db_subnet" { {%- else %} */ {%- endif %} -variable "public_ip_filter" { - description = "IP Range that can access the public network" - default = "0.0.0.0/0" -} # New VCN and Subnets locals { diff --git a/py_oci_starter.py b/py_oci_starter.py index 67b1b0d7..f0df8984 100755 --- a/py_oci_starter.py +++ b/py_oci_starter.py @@ -206,6 +206,7 @@ def kubernetes_rules(): params['deploy_type'] = longhand('deploy_type', {'oke': 'kubernetes', 'ci': 'container_instance'}) def vcn_rules(): + params['public_ip_filter'] = TO_FILL if 'subnet_ocid' in params: params['web_subnet_ocid'] = params['subnet_ocid'] params['app_subnet_ocid'] = params['subnet_ocid'] @@ -559,7 +560,7 @@ def env_sh_contents(): tfvars.append(f'prefix="{prefix}"') for param in env_params: - if param.endswith("_ocid") or param in ["db_password", "auth_token", "license_model", "certificate_email", "dns_name","dns_zone_name", "tls", "your_public_ssh_key"]: + if param.endswith("_ocid") or param in ["db_password", "auth_token", "license_model", "certificate_email", "dns_name","dns_zone_name", "tls", "public_ip_filter", "your_public_ssh_key"]: to_fill_params.append(param) tfvars.append('') tf_var_comment(tfvars, param) @@ -579,6 +580,7 @@ def env_sh_contents(): table_comments = { 'prefix': ['Prefix to all resources created by terraform'], + 'public_ip_filter': ['IP Range that can access port like 80/443 on the internet. Typically:', '- All internet - 0.0.0.0/0', '- or /32. Get your Laptop IP, by example, using https://whatismyipaddress.com'], 'auth_token': ['See doc: https://docs.oracle.com/en-us/iaas/Content/Registry/Tasks/registrygettingauthtoken.htm'], 'db_password': ['Min length 12 characters, 2 lowercase, 2 uppercase, 2 numbers, 2 special characters. Ex: LiveLab__12345'], 'license_model': ['BRING_YOUR_OWN_LICENSE or LICENSE_INCLUDED'], @@ -588,7 +590,7 @@ def env_sh_contents(): 'dns_name': ['SSL/TLS - Webserver DNS Name used by the installation (ex: www.mydomain.com)'], 'dns_zone_name': ['SSL/TLS - OCI DNS Zone Name (ex:mydomain.com)'], 'tls': ['SSL/TLS - Method to create the certificate (new_http_01 or new_dns_01 or existing_ocid) '], - 'your_public_ssh_key': ['Your ssh public key (associated with your private key stored in your laptop) that will be added in .ssh/authorized host in the bastion. Goal: clone the git repository on your laptop for Vibe Coding'] + 'your_public_ssh_key': ['Your ssh public key (associated with your private key stored in your laptop) that will be added in .ssh/authorized host in the bastion.', 'Goal: clone the git repository on your laptop for Vibe Coding'] } def tf_var_comment(contents, param): diff --git a/test_suite/test_suite_shared.sh b/test_suite/test_suite_shared.sh index 520d4ebd..b76d0d94 100755 --- a/test_suite/test_suite_shared.sh +++ b/test_suite/test_suite_shared.sh @@ -442,7 +442,7 @@ pre_test_suite() { GROUP_NAME="ts${SHAPE_GROUP}" cd $TEST_HOME/oci-starter - ./oci_starter.sh -group_name $GROUP_NAME -group_common atp,mysql,psql,opensearch,nosql,database,fnapp,apigw,oke -compartment_ocid $EX_COMPARTMENT_OCID -db_password $TEST_DB_PASSWORD -shape $SHAPE_GROUP + ./oci_starter.sh -group_name $GROUP_NAME -group_common atp,mysql,psql,opensearch,nosql,database,fnapp,apigw,oke -compartment_ocid $EX_COMPARTMENT_OCID -db_password $TEST_DB_PASSWORD -shape $SHAPE_GROUP -ui_type none -language none exit_on_error "oci_starter.sh" mv output/group_common ../group_common cd $TEST_HOME/group_common