Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions .github/workflows/chat-with-content.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
name: Chat with Content

on:
workflow_call:

# Setup the environment with the extension name for easy re-use
# Also need the GH_TOKEN for the release-extension action to be able to use gh
env:
EXTENSION_NAME: chat-with-content
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

jobs:
extension:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./extensions/${{ env.EXTENSION_NAME }}

steps:
# Checkout the repository so the rest of the actions can run with no issue
- uses: actions/checkout@v4

# We want to fail quickly if the linting fails, do that first
- uses: ./.github/actions/lint-extension
with:
extension-name: ${{ env.EXTENSION_NAME }}

# Run the Python tests before packaging. pyproject-file only picks the uv
# version, so pin the interpreter separately: without it uv resolves the
# newest Python satisfying requires-python, and the tests would stop matching
# the version Connect deploys on (manifest.json's python.version).
# `uses` steps ignore the job's working-directory, so this can't read the
# extension's .python-version; it preinstalls the same version that file
# pins for `uv run` below. Keep the two in step.
- uses: astral-sh/setup-uv@v9.0.0
with:
pyproject-file: ./extensions/${{ env.EXTENSION_NAME }}/pyproject.toml
python-version: "3.12"

- name: Run tests
run: uv run pytest

# Upload only the files the extension needs to run, leaving out tests,
# pyproject.toml, and other repo-only files.
- name: Upload extension files
uses: actions/upload-artifact@v4
with:
name: ${{ env.EXTENSION_NAME }}
path: |
extensions/${{ env.EXTENSION_NAME }}/app.py
extensions/${{ env.EXTENSION_NAME }}/helpers.py
extensions/${{ env.EXTENSION_NAME }}/requirements.txt
extensions/${{ env.EXTENSION_NAME }}/manifest.json

# Package up the extension into a TAR using the generalized
# package-extension action
- uses: ./.github/actions/package-extension
with:
extension-name: ${{ env.EXTENSION_NAME }}
artifact-name: ${{ env.EXTENSION_NAME }}

connect-integration-tests:
needs: extension
uses: ./.github/workflows/connect-integration-tests.yml
secrets: inherit
with:
extensions: '["chat-with-content"]' # JSON array format to match the workflow input schema

release:
runs-on: ubuntu-latest
needs: [extension, connect-integration-tests]
# Release the extension using the release-extension action
# Will only create a GitHub release if merged to `main` and the semver
# version has been updated
steps:
# Checkout the repository so the rest of the actions can run with no issue
- uses: actions/checkout@v4

- uses: ./.github/actions/release-extension
with:
extension-name: ${{ env.EXTENSION_NAME }}
22 changes: 21 additions & 1 deletion .github/workflows/extensions.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,6 @@ jobs:
stock-report: extensions/stock-report/**
simple-mcp-server: extensions/simple-mcp-server/**
simple-shiny-chat-with-mcp: extensions/simple-shiny-chat-with-mcp/**
chat-with-content: extensions/chat-with-content/**
pqr: extensions/pqr/**

# When infra changed, test all simple extensions; otherwise use the filter output
Expand All @@ -68,6 +67,12 @@ jobs:
echo "Infrastructure changed — testing all simple extensions"
changes=$(find extensions -maxdepth 1 -mindepth 1 -type d -printf '%f\n' \
| while read -r ext; do
# Skip complex extensions: they have their own dedicated
# workflow (which builds them) and would fail the simple
# no-build flow (e.g. a missing frontend dist/).
if [ -f ".github/workflows/$ext.yml" ]; then
continue
fi
if jq -e '.extension.minimumConnectVersion' "extensions/$ext/manifest.json" > /dev/null 2>&1; then
echo "$ext"
fi
Expand Down Expand Up @@ -212,6 +217,7 @@ jobs:
package-vulnerability-scanner: ${{ steps.resolve.outputs.package-vulnerability-scanner }}
runtime-version-scanner: ${{ steps.resolve.outputs.runtime-version-scanner }}
usage-metrics-dashboard: ${{ steps.resolve.outputs.usage-metrics-dashboard }}
chat-with-content: ${{ steps.resolve.outputs.chat-with-content }}

steps:
- uses: actions/checkout@v4
Expand All @@ -233,6 +239,7 @@ jobs:
package-vulnerability-scanner: extensions/package-vulnerability-scanner/**
runtime-version-scanner: extensions/runtime-version-scanner/**
usage-metrics-dashboard: extensions/usage-metrics-dashboard/**
chat-with-content: extensions/chat-with-content/**

# When infra changed, trigger all complex extensions too
- id: resolve
Expand All @@ -243,11 +250,13 @@ jobs:
echo "package-vulnerability-scanner=true" >> $GITHUB_OUTPUT
echo "runtime-version-scanner=true" >> $GITHUB_OUTPUT
echo "usage-metrics-dashboard=true" >> $GITHUB_OUTPUT
echo "chat-with-content=true" >> $GITHUB_OUTPUT
else
echo "publisher-command-center=${{ steps.changes.outputs.publisher-command-center }}" >> $GITHUB_OUTPUT
echo "package-vulnerability-scanner=${{ steps.changes.outputs.package-vulnerability-scanner }}" >> $GITHUB_OUTPUT
echo "runtime-version-scanner=${{ steps.changes.outputs.runtime-version-scanner }}" >> $GITHUB_OUTPUT
echo "usage-metrics-dashboard=${{ steps.changes.outputs.usage-metrics-dashboard }}" >> $GITHUB_OUTPUT
echo "chat-with-content=${{ steps.changes.outputs.chat-with-content }}" >> $GITHUB_OUTPUT
fi

# Creates and releases the Publisher Command Center extension using a custom
Expand Down Expand Up @@ -287,6 +296,15 @@ jobs:
uses: ./.github/workflows/usage-metrics-dashboard.yml
secrets: inherit

# Creates and releases the Chat with Content extension using a custom workflow
chat-with-content:
needs: [complex-extension-changes]
# Only runs if the `complex-extension-changes` job detects changes in the
# chat-with-content extension directory
if: ${{ needs.complex-extension-changes.outputs.chat-with-content == 'true' }}
uses: ./.github/workflows/chat-with-content.yml
secrets: inherit

# All extensions have been linted, packaged, and released, if necessary
# Continuing to update the extension list with the latest release data

Expand All @@ -300,6 +318,7 @@ jobs:
package-vulnerability-scanner,
runtime-version-scanner,
usage-metrics-dashboard,
chat-with-content,
]
permissions:
pull-requests: write
Expand Down Expand Up @@ -383,6 +402,7 @@ jobs:
package-vulnerability-scanner,
runtime-version-scanner,
usage-metrics-dashboard,
chat-with-content,
]
if: ${{ always() }}
outputs:
Expand Down
1 change: 0 additions & 1 deletion extensions/chat-with-content/.gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
rsconnect-python
.venv
.env
.python-version
1 change: 1 addition & 0 deletions extensions/chat-with-content/.python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.12
9 changes: 9 additions & 0 deletions extensions/chat-with-content/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@ All notable changes to the Chat with Content extension will be documented in thi
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.0.8] - 2026-07-17

### Fixed

- Guarded against content with a missing, malformed, or timezone-naive deployment
time, which could previously leave the whole content list empty. (#446)
- Close a code block that truncation cut open, so the model reads the truncation
note as a note rather than as more code. (#446)

## [0.0.7] - 2026-06-15

### Changed
Expand Down
148 changes: 134 additions & 14 deletions extensions/chat-with-content/helpers.py
Original file line number Diff line number Diff line change
@@ -1,31 +1,108 @@
import os
from datetime import datetime, timezone

# Static/rendered content the app can extract text from. Interactive apps (Shiny,
# Streamlit, ...) render in the browser, so their HTML holds no content to chat with.
CHATTABLE_APP_MODES = ("jupyter-static", "quarto-static", "rmd-static", "static")

def time_since_deployment(deployment_time_str):
"""
Calculate time since deployment from ISO format datetime string.
# Upper bound on the markdown handed to the LLM. A large report can produce a DOM
# far bigger than the model's context window; truncating keeps the request within
# bounds instead of erroring, at the cost of dropping the tail of very long pages.
MAX_CONTEXT_CHARS = 100_000


# Both env vars are checked because a missed "on Connect" detection would fall back
# to the deploy client for a viewer (see resolve_visitor_client), so err toward True.
def running_on_connect():
return "CONNECT" in (os.getenv("POSIT_PRODUCT"), os.getenv("RSTUDIO_PRODUCT"))


# What the viewer is told when their session can't be used at all. Adding the
# integration fixes neither case, so these are kept separate from the setup screen.
# Self-contained sentences: the underlying error, if any, goes to the server log
# instead (see resolve_visitor_client), since a viewer can't act on it and it may
# be full of vendor/SDK detail that isn't meant for them.
NO_SESSION_DETAIL = (
"Couldn't read your Connect session, so the app can't list or read content as "
"you. Make sure you're signed in to Connect. If you are, your administrator may "
"need to enable OAuth integrations on this server."
)
EXCHANGE_FAILED_DETAIL = (
"Couldn't read your Connect session, so the app can't list or read content as "
"you. Contact your administrator; the technical detail is in the application logs."
)
# Raised by app.py, not resolve_visitor_client: the exchange itself has no timeout,
# so app.py bounds the call with asyncio.wait_for and reports this on expiry.
SESSION_TIMEOUT_DETAIL = (
"Couldn't read your Connect session, so the app can't list or read content as "
"you. Connect didn't respond in time; try reloading the page."
)

Args:
deployment_time_str (str): Datetime string in format "2025-03-19T23:16:11Z"

Returns:
str: Human-readable time difference like "last deployed 3 hours ago"
"""
# Parse the deployment time
deployment_time = datetime.fromisoformat(deployment_time_str.replace("Z", "+00:00"))
# Returns (client, integration_enabled, session_error), where session_error is the
# detail to show the viewer when their session can't be used, or None otherwise. The
# gate is the point: never fall back to the deploy client for a viewer, because that
# would list the deployer's content as if it were theirs. Off Connect, the deploy
# client is the intended one.
def resolve_visitor_client(client, on_connect, token):
if not on_connect:
return client, True, None
if not token:
# No token means there is no signed-in viewer to act as: content that allows
# anonymous access sends none, and OAuth integrations may be off server-wide.
# Neither is fixed on the Access tab, so say that rather than showing setup.
return client, True, NO_SESSION_DETAIL
try:
return client.with_user_session_token(token), True, None
except Exception as err:
# Compare as a string so a code reported as 212 or "212" both count as the
# missing-integration case (setup screen) rather than a scary error screen.
if str(getattr(err, "error_code", "")) == "212":
return client, False, None
# The raw error may be full of SDK/vendor detail a viewer can't act on, so it
# goes to the log for an administrator rather than onto the screen.
raw = getattr(err, "error_message", None) or str(err)
print(f"chat-with-content: session token exchange failed: {raw}")
return client, True, EXCHANGE_FAILED_DETAIL

# Get current time in UTC

# Whether the app is fully set up and should load and use the viewer's content.
# The content selector runs as a reactive effect independent of the rendered
# screen, so it gates on this itself: a token error leaves the client as the
# unscoped deploy client (must never load with it), and a missing LLM or
# integration means the setup screen is up, so there's nothing to load for yet.
def content_ready(token_error, chat, integration_enabled):
return token_error is None and chat is not None and integration_enabled


def time_since_deployment(deployment_time_str):
# Content that has never been deployed reports no time; skip the label rather
# than crash on a None passed to fromisoformat().
if not deployment_time_str:
return ""

try:
deployment_time = datetime.fromisoformat(
deployment_time_str.replace("Z", "+00:00")
)
except (AttributeError, TypeError, ValueError):
# A malformed timestamp shouldn't crash the whole content list; just omit
# the "last deployed" phrase for this one item. AttributeError covers a
# value that isn't a string at all, which has no .replace().
return ""
# A timestamp with no offset would raise when subtracted from an aware "now";
# treat it as UTC so a naive-but-valid time still renders instead of crashing.
if deployment_time.tzinfo is None:
deployment_time = deployment_time.replace(tzinfo=timezone.utc)
current_time = datetime.now(timezone.utc)

# Calculate the difference
time_diff = current_time - deployment_time
total_seconds = time_diff.total_seconds()

# Handle future dates
# Deployment time slightly in the future (clock skew between servers).
if total_seconds < 0:
return "last deployed in the future"

# Convert to appropriate unit
if total_seconds < 60:
value = int(total_seconds)
unit = "second" if value == 1 else "seconds"
Expand All @@ -49,3 +126,46 @@ def time_since_deployment(deployment_time_str):
unit = "year" if value == 1 else "years"

return f"last deployed {value} {unit} ago"


# Content items are read with .get() throughout: Connect leaves optional fields out
# of the payload, and the SDK raises AttributeError for a field accessed as an
# attribute but absent, which would take out the whole content list over one unusual
# item. Attribute access is also deprecated in the SDK in favour of key access.
def is_chattable_content(item):
return (
item.get("app_mode") in CHATTABLE_APP_MODES
and item.get("app_role") != "none"
and item.get("content_category") != "pin"
)


def content_choice_label(item):
title = item.get("title") or item.get("name") or item.get("guid")
# .get() also sidesteps ContentItem.owner, a property that fetches the owner
# over HTTP (one request per item) when Connect didn't include it.
owner = item.get("owner") or {}
name = f"{owner.get('first_name') or ''} {owner.get('last_name') or ''}".strip()
deployed = time_since_deployment(item.get("last_deployed_time"))

# Join only the parts we actually have so the label never shows " - ".
suffix = " ".join(part for part in (name, deployed) if part)
return f"{title} - {suffix}" if suffix else title


def truncate_for_context(markdown, max_chars=MAX_CONTEXT_CHARS):
if len(markdown) <= max_chars:
return markdown
kept = markdown[:max_chars]
# Cutting mid-page can leave a code fence open, which would make the model read
# the notice below as more code instead of as a note about the content. Only a
# fence starting a line opens or closes a block: page content that merely
# mentions ``` mid-line must not be counted, or a balanced block would be
# "closed" again and the notice pushed inside the new one.
fences = sum(1 for line in kept.splitlines() if line.lstrip().startswith("```"))
if fences % 2:
kept += "\n```"
return (
kept
+ "\n\n[Content truncated because it exceeds the size this app sends to the model.]"
)
22 changes: 10 additions & 12 deletions extensions/chat-with-content/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,29 +24,27 @@
"description": "Provides a way to interact with and query content using an LLM chat interface.",
"homepage": "https://github.com/posit-dev/connect-extensions/tree/main/extensions/chat-with-content",
"category": "extension",
"tags": ["llm", "shiny", "chat", "python"],
"tags": [
"llm",
"shiny",
"chat",
"python"
],
"minimumConnectVersion": "2025.04.0",
"requiredFeatures": ["OAuth Integrations"],
"requiredFeatures": [
"OAuth Integrations"
],
"version": "0.0.7"
},
"files": {
"requirements.txt": {
"checksum": "0c56ba1ce838560d153c50e71dc2b025"
},
".gitignore": {
"checksum": "693ec79eaa892babde62587aaacf0d8b"
},
"README.md": {
"checksum": "7b072eef923c062a0bf1667dde6c2b95"
},
"app.py": {
"checksum": "74b2cad1b559b06c306a1ee44ce00185"
},
"helpers.py": {
"checksum": "b18f4bc0072b6e47864670a3174b0cea"
},
"pyproject.toml": {
"checksum": "604c28539dcb8abf952d7099ce2994bc"
"checksum": "cf9684fe4097503d78c69724e795579b"
}
}
}
Loading
Loading