Skip to content

AI

Several ways to use the AI system: a stateless completion, a chat with persisted history, an agentic chat with tools and sub-agents, file uploads, tools working on a drive, and a floating chat button. It needs a configured AI provider, e.g. an Anthropic or OpenAI token in the admin center; without one the pages show an error.

agentic.go

package main

import (
	"fmt"
	"math"
	"strings"
	"time"

	"go.wdy.de/nago/application/ai"
	"go.wdy.de/nago/application/ai/completion"
	uicompletion "go.wdy.de/nago/application/ai/completion/ui"
	"go.wdy.de/nago/application/ai/session"
	"go.wdy.de/nago/presentation/core"
	"go.wdy.de/nago/presentation/ui"
	"go.wdy.de/nago/presentation/ui/alert"
)

// agenticTools are the executable Go functions exposed to the model. The schema for the input struct is
// derived automatically by [completion.NewTool] via reflection, so the model knows how to call them.
func agenticTools() []completion.Tool {
	type calcIn struct {
		Op string  `json:"op" desc:"the arithmetic operation, one of: add, sub, mul, div"`
		A  float64 `json:"a" desc:"the left operand"`
		B  float64 `json:"b" desc:"the right operand"`
	}
	type calcOut struct {
		Result float64 `json:"result"`
	}

	calc := completion.NewTool("calculator", "performs a basic arithmetic operation on two numbers",
		func(in calcIn) (calcOut, error) {
			switch in.Op {
			case "add":
				return calcOut{Result: in.A + in.B}, nil
			case "sub":
				return calcOut{Result: in.A - in.B}, nil
			case "mul":
				return calcOut{Result: in.A * in.B}, nil
			case "div":
				if in.B == 0 {
					return calcOut{}, fmt.Errorf("division by zero")
				}
				return calcOut{Result: in.A / in.B}, nil
			default:
				return calcOut{}, fmt.Errorf("unknown operation %q", in.Op)
			}
		})

	type sqrtIn struct {
		X float64 `json:"x" desc:"a non-negative number to take the square root of"`
	}
	type sqrtOut struct {
		Result float64 `json:"result"`
	}

	sqrt := completion.NewTool("sqrt", "computes the square root of a number",
		func(in sqrtIn) (sqrtOut, error) {
			if in.X < 0 {
				return sqrtOut{}, fmt.Errorf("cannot take the square root of a negative number")
			}
			return sqrtOut{Result: math.Sqrt(in.X)}, nil
		})

	type timeIn struct{}
	type timeOut struct {
		Now string `json:"now"`
	}

	now := completion.NewTool("current_time", "returns the current server time in RFC3339 format",
		func(in timeIn) (timeOut, error) {
			return timeOut{Now: time.Now().Format(time.RFC3339)}, nil
		})

	return []completion.Tool{calc, sqrt, now}
}

// agenticChat demonstrates the generic [uicompletion.Chat] component driving a tool-using assistant. It
// configures TWO agents so the component renders an agent picker: a general assistant with the calculator/
// sqrt/time tools and a math specialist that additionally asks the user for confirmation via the built-in
// ask_user tool (enabled through ChatOptions.AskUser). History is off here, so this chat is transient.
//
// ChatOptions.Delegation additionally lets the model hand independent sub-problems to sub-agents working in
// parallel: synchronously via the delegate tool, or in the background via start_tasks/await_tasks while it
// continues itself. Try e.g. "Berechne unabhängig voneinander die Wurzeln von 2, 3, 5 und 7 und addiere sie".
func agenticChat(wnd core.Window, uc ai.UseCases, sessions session.UseCases) core.View {
	prov, comps, err := firstCompletionProvider(wnd.Subject(), uc, false)
	if err != nil {
		return alert.BannerError(err)
	}

	tools := agenticTools()

	chat := uicompletion.Chat(wnd, uicompletion.ChatOptions{
		Sessions:    sessions,
		Completions: comps,
		Provider:    prov,
		Title:       "Agentic Tool-Loop",
		AskUser:     true,
		Delegation: &uicompletion.DelegationOptions{
			BackgroundTasks: true,
			MaxParallel:     4,
		},
		Agents: []uicompletion.Agent{
			{
				ID:   "assistant",
				Name: "Allrounder",
				SystemPrompt: "You are a helpful assistant. Use the provided tools to compute results instead of guessing. " +
					"Delegate independent sub-problems to sub-agents to finish faster.",
				Tools: tools,
			},
			{
				ID:           "math",
				Name:         "Mathe-Spezialist",
				SystemPrompt: "You are a meticulous math specialist. Use the tools for every calculation and use ask_user to confirm ambiguous inputs before computing.",
				Tools:        tools,
			},
		},
	})

	return ui.VStack(
		ui.Text("Agentic Tool-Loop (uicompletion.Chat mit Agent-Auswahl, ask_user und Teilaufgaben)").Font(ui.Title),
		ui.Text("Zwei Agenten stehen zur Auswahl. Beide nutzen dieselben Rechen-Tools; der Mathe-Spezialist fragt bei Bedarf per ask_user nach. "+
			"Unabhängige Teilaufgaben kann die KI an parallel arbeitende Sub-Agenten delegieren."),
		chat,
	).Alignment(ui.Leading).
		Gap(ui.L16).
		FullWidth().
		Padding(ui.Padding{}.All(ui.L16))
}

// renderUsage formats the token accounting of the final turn. The cache fields prove whether Anthropic prompt
// caching kicked in: CacheWriteTokens > 0 means a fresh prefix was stored, CacheReadTokens > 0 means the
// stable prefix (system prompt, tools and earlier conversation turns) was served from cache at ~0.1x cost.
func renderUsage(u completion.Usage) string {
	var sb strings.Builder
	fmt.Fprintf(&sb, "input_tokens:        %d\n", u.InputTokens)
	fmt.Fprintf(&sb, "output_tokens:       %d\n", u.OutputTokens)
	fmt.Fprintf(&sb, "cache_read_tokens:   %d  (aus dem Cache gelesen, ~0.1x Kosten)\n", u.CacheReadTokens)
	fmt.Fprintf(&sb, "cache_write_tokens:  %d  (neu in den Cache geschrieben, ~1.25x Kosten)\n", u.CacheWriteTokens)
	return sb.String()
}

buttonoverlay.go

package main

import (
	"go.wdy.de/nago/application/ai"
	uicompletion "go.wdy.de/nago/application/ai/completion/ui"
	"go.wdy.de/nago/application/ai/session"
	"go.wdy.de/nago/presentation/core"
	"go.wdy.de/nago/presentation/ui"
	"go.wdy.de/nago/presentation/ui/alert"
)

// buttonOverlayPage demonstrates the floating [uicompletion.ChatButton]: a page with arbitrary content that
// gets an assistant button anchored to the bottom-right corner. Clicking it toggles the same chat panel used
// by the embedded variant. The button is configured in the nago value style (Corner/Label), the chat itself
// via ChatOptions.
func buttonOverlayPage(wnd core.Window, uc ai.UseCases, sessions session.UseCases) core.View {
	prov, comps, err := firstCompletionProvider(wnd.Subject(), uc, true)
	if err != nil {
		return alert.BannerError(err)
	}

	button := uicompletion.ChatButton(uicompletion.ChatOptions{
		Sessions:    sessions,
		Completions: comps,
		Provider:    prov,
		Title:       "KI-Assistent",
		History:     true,
		FileUpload:  true,
		Tags:        []string{"tutorial-77:overlay"},
		Agents:      []uicompletion.Agent{{SystemPrompt: "You are a friendly assistant that helps the user navigate this demo application. When the user attaches a file, use its content in your answer."}},
	}).Corner(uicompletion.CornerBottomRight).Label("Assistent")

	return ui.VStack(
		ui.Text("Floating Chat-Button").Font(ui.Title),
		ui.Text("Diese Seite hat beliebigen Inhalt. Unten rechts sitzt der wiederverwendbare uicompletion.ChatButton, der das Chat-Panel ein- und ausklappt."),
		ui.Text("Der Button ist per Position: Fixed an der Bildschirmecke verankert und überlagert die Seite unabhängig vom Scrollzustand."),
		button,
	).Alignment(ui.Leading).
		Gap(ui.L16).
		FullWidth().
		Padding(ui.Padding{}.All(ui.L16))
}

drivetools.go

package main

import (
	"fmt"
	"strings"

	"go.wdy.de/nago/application/ai"
	"go.wdy.de/nago/application/ai/completion"
	uicompletion "go.wdy.de/nago/application/ai/completion/ui"
	"go.wdy.de/nago/application/ai/file"
	"go.wdy.de/nago/application/ai/session"
	"go.wdy.de/nago/application/drive"
	"go.wdy.de/nago/auth"
	"go.wdy.de/nago/presentation/core"
	"go.wdy.de/nago/presentation/ui"
	"go.wdy.de/nago/presentation/ui/alert"
)

// driveToolsChat demonstrates the LLM-driven file access flow via the generic [uicompletion.Chat] component:
// the model itself decides — via tool calls — which file from the nago drive it wants to look at. Two tools
// are offered:
//
//   - list_drive_files: a plain (text-result) tool that walks the user's drives and returns the available
//     files with their ids, names, mime types and sizes, so the model can pick one.
//   - open_drive_file:  a file-providing tool ([completion.NewOpenFileTool]). When the model calls it with a
//     file id, the completion loop reads the file from the drive, uploads it to the provider (wired via
//     ChatOptions.FileUpload) and attaches it to the conversation as a Media block. The model then sees the
//     actual file content (e.g. the pages of a PDF) and can answer questions about it.
//
// All the heavy lifting is handled by the component and the completion loop; the example only supplies the two
// drive-backed tools and turns FileUpload on.
func driveToolsChat(wnd core.Window, uc ai.UseCases, sessions session.UseCases, drives drive.UseCases) core.View {
	prov, comps, err := firstCompletionProvider(wnd.Subject(), uc, true)
	if err != nil {
		return alert.BannerError(err)
	}

	chat := uicompletion.Chat(wnd, uicompletion.ChatOptions{
		Sessions:    sessions,
		Completions: comps,
		Provider:    prov,
		Title:       "Drive-Tools",
		FileUpload:  true,
		Agents: []uicompletion.Agent{{
			SystemPrompt: "You are a helpful assistant with access to a file drive. Use list_drive_files to discover files and open_drive_file to read a specific file before answering. Answer based on the file content.",
			Tools:        driveTools(drives),
		}},
	})

	return ui.VStack(
		ui.Text("LLM liest Drive-Dateien (uicompletion.Chat mit Tools + FileUpload)").Font(ui.Title),
		ui.Text("Das Modell entscheidet selbst per Tool-Call, welche Drive-Datei es öffnet. open_drive_file lädt die Datei hoch und die Loop schleust sie als Media-Block in die Konversation ein."),
		chat,
	).Alignment(ui.Leading).
		Gap(ui.L16).
		FullWidth().
		Padding(ui.Padding{}.All(ui.L16))
}

// driveTools builds the two drive-backed tools offered to the model.
//
// Both receive the acting subject per call rather than closing over it, so the drive's per-file permissions
// are enforced for whoever is actually asking - and the tools can be built once and shared by every window.
func driveTools(drives drive.UseCases) []completion.Tool {
	type listIn struct{}
	type driveFile struct {
		FID  string `json:"fid"`
		Name string `json:"name"`
		Mime string `json:"mime,omitempty"`
		Size int64  `json:"size,omitempty"`
	}
	type listOut struct {
		Files []driveFile `json:"files"`
	}

	list := completion.NewSubjectTool("list_drive_files",
		"lists the files available in the drive with their id, name, mime type and size",
		func(subject auth.Subject, in listIn) (listOut, error) {
			var out listOut
			for d, err := range drives.ReadDrives(subject, subject.ID()) {
				if err != nil {
					return listOut{}, err
				}

				werr := drives.WalkDir(subject, d.Root, func(fid drive.FID, f drive.File, err error) error {
					if err != nil {
						return nil // skip unreadable entries
					}
					if f.IsDir() {
						return nil
					}
					mime := ""
					if f.FileInfo.IsSome() {
						mime = f.FileInfo.Unwrap().MimeType
					}
					out.Files = append(out.Files, driveFile{
						FID:  string(fid),
						Name: f.Name(),
						Mime: mime,
						Size: f.Size(),
					})
					return nil
				})
				if werr != nil {
					return listOut{}, werr
				}
			}
			return out, nil
		})

	type openFileIn struct {
		FID string `json:"fid" desc:"the id of the drive file to open, as returned by list_drive_files"`
	}

	open := completion.NewSubjectOpenFileTool("open_drive_file",
		"opens a drive file by its id and makes its content available to the conversation. Text files (e.g. .md, .txt, .csv, .json, source code) are injected inline as text; images and PDFs are attached as media.",
		func(subject auth.Subject, in openFileIn) (completion.OpenedFile, error) {
			optFile, err := drives.Get(subject, drive.FID(in.FID), "")
			if err != nil {
				return completion.OpenedFile{}, err
			}
			if optFile.IsNone() {
				return completion.OpenedFile{}, fmt.Errorf("no such file: %s", in.FID)
			}

			f := optFile.Unwrap()
			mimeStr, _ := f.MimeType()
			mime := driveMimeToFileType(mimeStr, f.Name())
			if mime == file.Binary {
				return completion.OpenedFile{}, fmt.Errorf("unsupported file type %q for %q; only text files, images and PDFs can be opened", mimeStr, f.Name())
			}

			return completion.OpenedFile{
				Name:     f.Name(),
				MimeType: mime,
				Open:     f.Open,
			}, nil
		})

	return []completion.Tool{list, open}
}

// driveMimeToFileType maps a drive-reported mime string (falling back to the filename extension) to a
// [file.Type] the AI chat can handle: text types are injected inline by the completion loop, while images and
// PDFs are attached as media. Unsupported binary types map to [file.Binary].
func driveMimeToFileType(mime, name string) file.Type {
	// defensively strip any RFC 6838 parameters (e.g. "text/plain; charset=binary") that older drive
	// records may still carry, so the comparisons below see the bare media type.
	if i := strings.IndexByte(mime, ';'); i >= 0 {
		mime = strings.TrimSpace(mime[:i])
	}

	// canonical media types we attach as-is
	switch file.Type(mime) {
	case file.PNG, file.JPEG, file.GIF, file.PDF:
		return file.Type(mime)
	}

	// any text-ish mime (text/*, application/json, ...) is injected inline as text
	if file.IsText(file.Type(mime)) {
		return file.Type(mime)
	}

	lower := strings.ToLower(name)
	switch {
	case strings.HasSuffix(lower, ".png"):
		return file.PNG
	case strings.HasSuffix(lower, ".jpg"), strings.HasSuffix(lower, ".jpeg"):
		return file.JPEG
	case strings.HasSuffix(lower, ".gif"):
		return file.GIF
	case strings.HasSuffix(lower, ".pdf"):
		return file.PDF
	case strings.HasSuffix(lower, ".md"), strings.HasSuffix(lower, ".markdown"):
		return file.Markdown
	case strings.HasSuffix(lower, ".csv"):
		return file.CSV
	case strings.HasSuffix(lower, ".json"):
		return file.JSON
	case strings.HasSuffix(lower, ".xml"):
		return file.XML
	case strings.HasSuffix(lower, ".txt"),
		strings.HasSuffix(lower, ".log"),
		strings.HasSuffix(lower, ".go"),
		strings.HasSuffix(lower, ".ts"),
		strings.HasSuffix(lower, ".js"),
		strings.HasSuffix(lower, ".py"),
		strings.HasSuffix(lower, ".java"),
		strings.HasSuffix(lower, ".rs"),
		strings.HasSuffix(lower, ".c"),
		strings.HasSuffix(lower, ".h"),
		strings.HasSuffix(lower, ".yaml"),
		strings.HasSuffix(lower, ".yml"),
		strings.HasSuffix(lower, ".toml"),
		strings.HasSuffix(lower, ".ini"),
		strings.HasSuffix(lower, ".sh"),
		strings.HasSuffix(lower, ".sql"),
		strings.HasSuffix(lower, ".html"),
		strings.HasSuffix(lower, ".htm"):
		return file.Text
	default:
		return file.Binary
	}
}

fileupload.go

package main

import (
	"bytes"
	"context"
	"fmt"
	"io"
	"strings"
	"unicode/utf8"

	"github.com/worldiety/option"
	"go.wdy.de/nago/application/ai"
	"go.wdy.de/nago/application/ai/completion"
	"go.wdy.de/nago/application/ai/file"
	"go.wdy.de/nago/application/ai/model"
	"go.wdy.de/nago/application/ai/provider"
	"go.wdy.de/nago/auth"
	"go.wdy.de/nago/pkg/xsync"
	"go.wdy.de/nago/presentation/core"
	"go.wdy.de/nago/presentation/ui"
	"go.wdy.de/nago/presentation/ui/alert"
	"go.wdy.de/nago/presentation/ui/dropdown"
	"go.wdy.de/nago/presentation/ui/markdown"
)

// fileUploadChat demonstrates the file *upload* flow: the user picks a file, it is uploaded to the provider via
// its Files capability, and then attached to a user message as a [completion.Media] block referencing the
// uploaded file by its provider-native file id. The model receives the file plus the user's question and
// explains its contents — proving it actually read the file instead of guessing.
//
// This is the natural direction for file handling: bytes go to the provider once (via file id), and the
// reference stays small on every turn. It requires a provider that exposes BOTH stateless completions AND a
// Files capability (e.g. a configured Anthropic secret).
//
// Supported file kinds and how they are sent to Anthropic (see anthropic/conv.go):
//   - images (PNG/JPEG/GIF) -> image block sourced by file id
//   - PDF                   -> document block sourced by file id
//   - other (e.g. CSV/TXT)  -> the raw text is inlined into the prompt (Anthropic's document blocks only
//     accept PDF/plain-text file ids; to keep this example simple and robust we read small text files
//     directly instead of uploading them).
func fileUploadChat(wnd core.Window, uc ai.UseCases) core.View {
	type provEntry struct {
		prov  provider.Provider
		comps completion.Completions
		files provider.Files
	}

	var entries []provEntry
	for p, err := range uc.FindAllProvider(wnd.Subject()) {
		if err != nil {
			return alert.BannerError(err)
		}

		c := p.Completions()
		f := p.Files()
		if c.IsSome() && f.IsSome() {
			entries = append(entries, provEntry{prov: p, comps: c.Unwrap(), files: f.Unwrap()})
		}
	}

	if len(entries) == 0 {
		return alert.BannerError(fmt.Errorf("kein Provider mit Completions UND Files gefunden – bitte ein Anthropic-Secret konfigurieren"))
	}

	selectedProvider := core.AutoState[provider.ID](wnd).Init(func() provider.ID {
		return entries[0].prov.Identity()
	})

	current := entries[0]
	for _, e := range entries {
		if e.prov.Identity() == selectedProvider.Get() {
			current = e
			break
		}
	}
	prov := current.prov
	comps := current.comps
	files := current.files

	prompt := core.AutoState[string](wnd).Init(func() string {
		return "Fasse den Inhalt dieser Datei zusammen und nenne die drei wichtigsten Punkte."
	})
	// The picked file, staged in memory until the request is sent.
	pickedName := core.AutoState[string](wnd)
	pickedMime := core.AutoState[string](wnd)
	pickedBytes := core.AutoState[[]byte](wnd)

	answer := core.AutoState[string](wnd)
	usage := core.AutoState[string](wnd)
	busy := core.AutoState[bool](wnd)
	selectedModel := core.AutoState[model.ID](wnd).Init(func() model.ID {
		for m, err := range comps.Models(wnd.Subject()) {
			if err != nil {
				return ""
			}
			return m.ID
		}
		return ""
	})

	selectedProvider.Observe(func(newValue provider.ID) {
		first := model.ID("")
		for _, e := range entries {
			if e.prov.Identity() == newValue {
				for m, err := range e.comps.Models(wnd.Subject()) {
					if err == nil {
						first = m.ID
					}
					break
				}
				break
			}
		}
		selectedModel.Set(first)
		answer.Set("")
		usage.Set("")
	})

	providerOptions := make([]dropdown.Option[provider.ID], 0, len(entries))
	for _, e := range entries {
		providerOptions = append(providerOptions, dropdown.Option[provider.ID]{
			Value: e.prov.Identity(),
			Label: e.prov.Name(),
		})
	}

	pickFile := func() {
		wnd.ImportFiles(core.ImportFilesOptions{
			Multiple: false,
			MaxBytes: 16 * 1024 * 1024,
			OnCompletion: func(fs []core.File) {
				if len(fs) == 0 {
					return
				}
				f := fs[0]
				r, err := f.Open()
				if err != nil {
					alert.ShowBannerError(wnd, err)
					return
				}
				defer r.Close()

				data, err := io.ReadAll(r)
				if err != nil {
					alert.ShowBannerError(wnd, err)
					return
				}

				mime, _ := f.MimeType()
				wnd.Post(func() {
					pickedName.Set(f.Name())
					pickedMime.Set(mime)
					pickedBytes.Set(data)
					answer.Set("")
					usage.Set("")
				})
			},
		})
	}

	submit := func() {
		question := strings.TrimSpace(prompt.Get())
		if question == "" || busy.Get() {
			return
		}
		if len(pickedBytes.Get()) == 0 {
			alert.ShowBannerError(wnd, fmt.Errorf("bitte zuerst eine Datei auswählen"))
			return
		}

		busy.Set(true)
		answer.Set("")
		usage.Set("")

		name := pickedName.Get()
		mime := detectMime(name, pickedMime.Get())
		data := pickedBytes.Get()

		xsync.Go(func() error {
			userContent, err := buildFileMessage(wnd.Subject(), files, name, mime, data, question)
			if err != nil {
				wnd.Post(func() {
					busy.Set(false)
					alert.ShowBannerError(wnd, err)
				})
				return nil
			}

			res, err := comps.Complete(context.Background(), wnd.Subject(), completion.Options{
				Model:     selectedModel.Get(),
				System:    "You are a helpful assistant. Answer strictly based on the attached file. If the file does not contain the answer, say so.",
				MaxTokens: 1024,
				Messages: []completion.Message{
					{Role: completion.User, Content: userContent},
				},
			})
			if err != nil {
				wnd.Post(func() {
					busy.Set(false)
					alert.ShowBannerError(wnd, err)
				})
				return nil
			}

			var sb strings.Builder
			for _, c := range res.Message.Content {
				if t, ok := c.(completion.Text); ok {
					sb.WriteString(t.Text)
				}
			}

			wnd.Post(func() {
				answer.Set(sb.String())
				usage.Set(renderUsage(res.Usage))
				busy.Set(false)
			})
			return nil
		}, func(err error) {
			if err != nil {
				wnd.Post(func() {
					busy.Set(false)
					alert.ShowBannerError(wnd, err)
				})
			}
		})
	}

	fileLabel := "keine Datei ausgewählt"
	if n := pickedName.Get(); n != "" {
		fileLabel = fmt.Sprintf("%s (%s, %d Bytes)", n, detectMime(n, pickedMime.Get()), len(pickedBytes.Get()))
	}

	return ui.VStack(
		ui.Text(fmt.Sprintf("Datei hochladen und verstehen lassen – %s (%s)", prov.Name(), selectedModel.Get())).Font(ui.Title),
		ui.Text("Wähle eine Datei (Bild, PDF oder Textdatei). Sie wird zum Provider hochgeladen und dem Modell zusammen mit deiner Frage übergeben. Das Modell antwortet ausschließlich anhand des Datei-Inhalts."),

		dropdown.Dropdown("Provider", providerOptions, selectedProvider.Get()).
			InputValue(selectedProvider).
			Disabled(busy.Get()).
			Frame(ui.Frame{}.FullWidth()),

		ui.HStack(
			ui.SecondaryButton(pickFile).Title("Datei auswählen").Enabled(!busy.Get()),
			ui.Text(fileLabel),
		).Gap(ui.L8).Alignment(ui.Leading),

		ui.TextField("Deine Frage zur Datei", prompt.Get()).
			InputValue(prompt).
			Lines(3).
			FullWidth().
			Disabled(busy.Get()),

		ui.PrimaryButton(submit).
			Title("Frage an das Modell senden").
			Enabled(!busy.Get()),

		ui.If(busy.Get(), ui.Text("… Datei wird hochgeladen und analysiert")),

		ui.If(answer.Get() != "", ui.VStack(
			ui.Text("Antwort").Font(ui.SubTitle),
			markdown.RichText(answer.Get()),
		).Alignment(ui.Leading).
			FullWidth().
			BackgroundColor(ui.M2).
			Border(ui.Border{}.Radius(ui.L8)).
			Padding(ui.Padding{}.All(ui.L16))),

		ui.If(usage.Get() != "", ui.VStack(
			ui.Text("Token-Usage").Font(ui.SubTitle),
			ui.CodeEditor(usage.Get()).Language("text").FullWidth(),
		).Alignment(ui.Leading).
			FullWidth().
			BackgroundColor(ui.M2).
			Border(ui.Border{}.Radius(ui.L8)).
			Padding(ui.Padding{}.All(ui.L16))),
	).Alignment(ui.Leading).
		Gap(ui.L16).
		FullWidth().
		Padding(ui.Padding{}.All(ui.L16))
}

// buildFileMessage turns a picked file into the content blocks of a user message. Images and PDFs are uploaded
// to the provider and attached by file id (efficient: the bytes travel once). Small text files are inlined
// into the prompt, because Anthropic's document blocks do not accept arbitrary text file ids.
func buildFileMessage(subject auth.Subject, files provider.Files, name string, mime file.Type, data []byte, question string) ([]completion.Content, error) {
	if isImageMime(mime) || mime == file.PDF {
		f, err := files.Put(subject, file.CreateOptions{
			Name:     name,
			MimeType: mime,
			Purpose:  file.PurposeUserData,
			Open: func() (io.ReadCloser, error) {
				return io.NopCloser(bytes.NewReader(data)), nil
			},
		})
		if err != nil {
			return nil, fmt.Errorf("upload file: %w", err)
		}

		return []completion.Content{
			completion.Media{MimeType: mime, Source: completion.Source{FileID: option.Some(f.ID)}},
			completion.Text{Text: question},
		}, nil
	}

	// Fallback for text-like files: inline the content directly so the model can read it.
	if !isProbablyText(data) {
		return nil, fmt.Errorf("nicht unterstützter Dateityp %q – bitte ein Bild, PDF oder eine Textdatei wählen", mime)
	}

	var sb strings.Builder
	sb.WriteString(question)
	sb.WriteString("\n\n--- Datei: ")
	sb.WriteString(name)
	sb.WriteString(" ---\n")
	sb.Write(data)

	return []completion.Content{completion.Text{Text: sb.String()}}, nil
}

// isImageMime mirrors the provider's image classification for the supported image types.
func isImageMime(t file.Type) bool {
	switch t {
	case file.PNG, file.JPEG, file.GIF:
		return true
	default:
		return false
	}
}

// detectMime maps the browser-reported mime type (or the filename extension as a fallback) to a [file.Type].
func detectMime(name, browserMime string) file.Type {
	switch file.Type(browserMime) {
	case file.PNG, file.JPEG, file.GIF, file.PDF:
		return file.Type(browserMime)
	}

	lower := strings.ToLower(name)
	switch {
	case strings.HasSuffix(lower, ".png"):
		return file.PNG
	case strings.HasSuffix(lower, ".jpg"), strings.HasSuffix(lower, ".jpeg"):
		return file.JPEG
	case strings.HasSuffix(lower, ".gif"):
		return file.GIF
	case strings.HasSuffix(lower, ".pdf"):
		return file.PDF
	default:
		if browserMime != "" {
			return file.Type(browserMime)
		}
		return file.Binary
	}
}

// isProbablyText reports whether data looks like UTF-8 text (no NUL bytes, valid rune sequence) so it can be
// safely inlined into a prompt.
func isProbablyText(data []byte) bool {
	if len(data) == 0 {
		return false
	}
	if bytes.IndexByte(data, 0) >= 0 {
		return false
	}
	return utf8.Valid(data)
}

main.go

package main

import (
	"time"

	"github.com/worldiety/option"
	"go.wdy.de/nago/application"
	cfgai "go.wdy.de/nago/application/ai/cfg"
	_ "go.wdy.de/nago/application/ai/provider/anthropic"
	_ "go.wdy.de/nago/application/ai/provider/gollama"
	_ "go.wdy.de/nago/application/ai/provider/openai"
	"go.wdy.de/nago/application/drive"
	cfgdrive "go.wdy.de/nago/application/drive/cfg"
	uidrive "go.wdy.de/nago/application/drive/ui"
	"go.wdy.de/nago/application/group"
	cfginspector "go.wdy.de/nago/application/inspector/cfg"
	cfglocalization "go.wdy.de/nago/application/localization/cfg"
	"go.wdy.de/nago/application/user"
	"go.wdy.de/nago/presentation/core"
	"go.wdy.de/nago/web/vuejs"
)

func main() {
	application.Configure(func(cfg *application.Configurator) {
		cfg.SetApplicationID("de.worldiety.tutorial_77")
		cfg.Serve(vuejs.Dist())

		option.MustZero(cfg.StandardSystems())
		option.Must(option.Must(cfg.UserManagement()).UseCases.EnableBootstrapAdmin(time.Now().Add(time.Hour), "%6UbRsCuM8N$auy"))
		cfg.SetDecorator(cfg.NewScaffold().
			MenuEntry().Title("home").Forward(".").Private().
			MenuEntry().Title("drive").Forward("drive").Private().
			MenuEntry().Title("stateless").Forward("stateless").Private().
			MenuEntry().Title("agentic").Forward("agentic").Private().
			MenuEntry().Title("fileupload").Forward("fileupload").Private().
			MenuEntry().Title("drivetools").Forward("drivetools").Private().
			MenuEntry().Title("session").Forward("session").Private().
			MenuEntry().Title("button").Forward("button").Private().
			Decorator())
		option.Must(cfginspector.Enable(cfg))
		option.Must(cfglocalization.Enable(cfg))
		drives := option.Must(cfgdrive.Enable(cfg))

		cfg.NoFooter(".")

		modAi := option.Must(cfgai.Enable(cfg))

		groups := option.Must(cfg.GroupManagement())
		gid := option.Must(groups.UseCases.Upsert(user.SU(), group.Group{
			Name: "Meine Gruppe",
		}))

		option.Must(drives.UseCases.OpenDrive(user.SU(), drive.OpenDriveOptions{
			Create:    true,
			Mode:      drive.GroupWrite | drive.GroupRead, //| drive.OtherWrite | drive.OtherRead,
			Group:     gid,
			Namespace: drive.NamespaceGlobal,
		}))

		cfg.RootViewWithDecoration(".", func(wnd core.Window) core.View {
			return sessionChat(wnd, modAi.UseCases, modAi.SessionUseCases)
		})
		cfg.RootViewWithDecoration("drive", func(wnd core.Window) core.View {
			return uidrive.PageDrive(wnd, drives.UseCases)
		})

		cfg.RootViewWithDecoration("stateless", func(wnd core.Window) core.View {
			return statelessChat(wnd, modAi.UseCases)
		})

		cfg.RootViewWithDecoration("agentic", func(wnd core.Window) core.View {
			return agenticChat(wnd, modAi.UseCases, modAi.SessionUseCases)
		})

		cfg.RootViewWithDecoration("fileupload", func(wnd core.Window) core.View {
			return fileUploadChat(wnd, modAi.UseCases)
		})

		cfg.RootViewWithDecoration("drivetools", func(wnd core.Window) core.View {
			return driveToolsChat(wnd, modAi.UseCases, modAi.SessionUseCases, drives.UseCases)
		})

		cfg.RootViewWithDecoration("session", func(wnd core.Window) core.View {
			return sessionChat(wnd, modAi.UseCases, modAi.SessionUseCases)
		})

		cfg.RootViewWithDecoration("button", func(wnd core.Window) core.View {
			return buttonOverlayPage(wnd, modAi.UseCases, modAi.SessionUseCases)
		})

	}).Run()
}

providerpick.go

package main

import (
	"fmt"

	"go.wdy.de/nago/application/ai"
	"go.wdy.de/nago/application/ai/completion"
	"go.wdy.de/nago/application/ai/provider"
	"go.wdy.de/nago/auth"
)

// firstCompletionProvider returns the first configured provider that exposes stateless completions, together
// with its Completions capability. It is a small convenience used by the uicompletion-based demo pages, which
// (unlike the low-level examples) do not offer a provider dropdown but simply take the first usable provider.
// requireFiles additionally demands a Files capability (needed for file upload / drive tools).
func firstCompletionProvider(subject auth.Subject, uc ai.UseCases, requireFiles bool) (provider.Provider, completion.Completions, error) {
	for p, err := range uc.FindAllProvider(subject) {
		if err != nil {
			return nil, nil, err
		}

		c := p.Completions()
		if c.IsNone() {
			continue
		}
		if requireFiles && p.Files().IsNone() {
			continue
		}
		return p, c.Unwrap(), nil
	}

	if requireFiles {
		return nil, nil, fmt.Errorf("kein Provider mit Completions UND Files gefunden – bitte ein Anthropic-Secret konfigurieren")
	}
	return nil, nil, fmt.Errorf("kein Provider mit stateless Completions gefunden – bitte ein Secret konfigurieren")
}

session.go

package main

import (
	"go.wdy.de/nago/application/ai"
	uicompletion "go.wdy.de/nago/application/ai/completion/ui"
	"go.wdy.de/nago/application/ai/session"
	"go.wdy.de/nago/presentation/core"
	"go.wdy.de/nago/presentation/ui"
	"go.wdy.de/nago/presentation/ui/alert"
)

// sessionChat demonstrates the generic [uicompletion.Chat] component with persisted history. The whole chat -
// message list, input, progress, the restore-a-previous-conversation dialog and lazy session creation - is
// provided by the reusable component; the example only wires it to the first usable provider and enables
// History. Compare this with the low-level examples (stateless.go, agentic.go), which drive the completion API
// by hand.
func sessionChat(wnd core.Window, uc ai.UseCases, sessions session.UseCases) core.View {
	prov, comps, err := firstCompletionProvider(wnd.Subject(), uc, false)
	if err != nil {
		return alert.BannerError(err)
	}

	chat := uicompletion.Chat(wnd, uicompletion.ChatOptions{
		Sessions:    sessions,
		Completions: comps,
		Provider:    prov,
		Title:       "Persistente Session",
		History:     true,
		// Tags scope the restore dialog. All sessions of this demo page share one tag, so the history button
		// only lists conversations started here.
		Tags:   []string{"tutorial-77:session"},
		Agents: []uicompletion.Agent{{SystemPrompt: "You are a helpful assistant. Keep answers concise."}},
	})

	return ui.VStack(
		ui.Text("Persistente Session (uicompletion.Chat mit History)").Font(ui.Title),
		ui.Text("Der komplette Chat inkl. Verlauf-Wiederherstellung kommt aus der wiederverwendbaren uicompletion-Komponente."),
		chat,
	).Alignment(ui.Leading).
		Gap(ui.L16).
		FullWidth().
		Padding(ui.Padding{}.All(ui.L16))
}

stateless.go

package main

import (
	"context"
	"fmt"
	"strings"

	"go.wdy.de/nago/application/ai"
	"go.wdy.de/nago/application/ai/completion"
	"go.wdy.de/nago/application/ai/model"
	"go.wdy.de/nago/application/ai/provider"
	"go.wdy.de/nago/pkg/xsync"
	"go.wdy.de/nago/presentation/core"
	"go.wdy.de/nago/presentation/ui"
	"go.wdy.de/nago/presentation/ui/alert"
	"go.wdy.de/nago/presentation/ui/dropdown"
	"go.wdy.de/nago/presentation/ui/markdown"
)

// statelessChat is a minimal example for the stateless completion API: a multiline text field as input and a
// markdown rich text as output. It picks the first configured provider that supports Completions (e.g. an
// Anthropic secret) and its first available model.
func statelessChat(wnd core.Window, uc ai.UseCases) core.View {
	// Collect all providers that expose stateless completions so the user can pick one.
	type provEntry struct {
		prov  provider.Provider
		comps completion.Completions
	}

	var entries []provEntry
	for p, err := range uc.FindAllProvider(wnd.Subject()) {
		if err != nil {
			return alert.BannerError(err)
		}

		if c := p.Completions(); c.IsSome() {
			entries = append(entries, provEntry{prov: p, comps: c.Unwrap()})
		}
	}

	if len(entries) == 0 {
		return alert.BannerError(fmt.Errorf("kein Provider mit stateless Completions gefunden – bitte ein Anthropic-Secret konfigurieren"))
	}

	selectedProvider := core.AutoState[provider.ID](wnd).Init(func() provider.ID {
		return entries[0].prov.Identity()
	})

	// Resolve the currently selected provider (fallback to the first one).
	current := entries[0]
	for _, e := range entries {
		if e.prov.Identity() == selectedProvider.Get() {
			current = e
			break
		}
	}
	prov := current.prov
	comps := current.comps

	prompt := core.AutoState[string](wnd)
	answer := core.AutoState[string](wnd)
	busy := core.AutoState[bool](wnd)
	selectedModel := core.AutoState[model.ID](wnd).Init(func() model.ID {
		for m, err := range comps.Models(wnd.Subject()) {
			if err != nil {
				return ""
			}
			return m.ID
		}
		return ""
	})

	// When the provider changes, reset the model to the first model of the new provider.
	selectedProvider.Observe(func(newValue provider.ID) {
		first := model.ID("")
		for _, e := range entries {
			if e.prov.Identity() == newValue {
				for m, err := range e.comps.Models(wnd.Subject()) {
					if err == nil {
						first = m.ID
					}
					break
				}
				break
			}
		}
		selectedModel.Set(first)
		answer.Set("")
	})

	// Build the dropdown options from the available completion providers.
	providerOptions := make([]dropdown.Option[provider.ID], 0, len(entries))
	for _, e := range entries {
		providerOptions = append(providerOptions, dropdown.Option[provider.ID]{
			Value: e.prov.Identity(),
			Label: e.prov.Name(),
		})
	}

	submit := func() {
		question := strings.TrimSpace(prompt.Get())
		if question == "" || busy.Get() {
			return
		}

		busy.Set(true)
		answer.Set("")

		xsync.Go(func() error {
			res, err := comps.Complete(context.Background(), wnd.Subject(), completion.Options{
				Model: selectedModel.Get(),
				Messages: []completion.Message{
					{
						Role:    completion.User,
						Content: []completion.Content{completion.Text{Text: question}},
					},
				},
			})

			if err != nil {
				wnd.Post(func() {
					busy.Set(false)
					alert.ShowBannerError(wnd, err)
				})
				return nil
			}

			var sb strings.Builder
			for _, c := range res.Message.Content {
				if t, ok := c.(completion.Text); ok {
					sb.WriteString(t.Text)
				}
			}

			wnd.Post(func() {
				answer.Set(sb.String())
				busy.Set(false)
			})

			return nil
		}, func(err error) {
			if err != nil {
				wnd.Post(func() {
					busy.Set(false)
					alert.ShowBannerError(wnd, err)
				})
			}
		})
	}

	return ui.VStack(
		ui.Text(fmt.Sprintf("Stateless Chat – %s (%s)", prov.Name(), selectedModel.Get())).Font(ui.Title),

		dropdown.Dropdown("Provider", providerOptions, selectedProvider.Get()).
			InputValue(selectedProvider).
			Disabled(busy.Get()).
			Frame(ui.Frame{}.FullWidth()),

		ui.TextField("Deine Eingabe", prompt.Get()).
			InputValue(prompt).
			Lines(6).
			FullWidth().
			Disabled(busy.Get()),

		ui.PrimaryButton(submit).
			Title("Senden").
			Enabled(!busy.Get()),

		ui.If(busy.Get(), ui.Text("… die KI denkt nach")),

		ui.If(answer.Get() != "", ui.VStack(
			markdown.RichText(answer.Get()),
		).Alignment(ui.Leading).
			FullWidth().
			BackgroundColor(ui.M2).
			Border(ui.Border{}.Radius(ui.L8)).
			Padding(ui.Padding{}.All(ui.L16))),
	).Alignment(ui.Leading).
		Gap(ui.L16).
		FullWidth().
		Padding(ui.Padding{}.All(ui.L16))
}

View on GitHub ↗ · run it with go run go.wdy.de/nago/example/cmd/tutorial-77-ai@latest