# Receita: Chat de IA > Framework web para Go no estilo Next.js: uma pasta em app/ é uma rota, o HTML é escrito em Go e nada fora da biblioteca padrão é importado. > /trilha/pt/llms/recipes/chat-de-ia.txt - [Chat de IA](/trilha/pt/receitas/chat-de-ia): Um chat com modelo dentro do seu app, do zero à tela — onde fica a chave, o que a página conta ao modelo, quem guarda o histórico, o que acontece quando o provedor cai e como o teste roda sem chave. · 5668 tokens est. - [IA e agentes](/trilha/pt/aprender/ia-e-agentes): Chame um modelo, dê ferramentas a um agente, transfira a conversa entre agentes, use e exponha MCP, e transmita a resposta em streaming. · 3397 tokens est. - [Banco de dados](/trilha/pt/receitas/banco-de-dados): Um pool para o processo, consultas que carregam o contexto da requisição, uma transação que desfaz sozinha e migrações aplicadas em ordem. · 2345 tokens est. - [ai](/trilha/pt/referencia/ai): Cliente OpenAI-compatível, ferramentas, agentes, handoffs e composição. · 2394 tokens est. 4 page(s) · 13804 tokens est. in all --- O [Aprender](/trilha/pt/aprender/ia-e-agentes) ensina os pedaços: uma chamada, uma ferramenta, um agente, o `ai.Serve` e o `ui.Chat`. Aqui está tudo montado, na ordem em que um aplicativo precisa, com as decisões que ninguém escreve: onde fica a chave, o que a página conta ao modelo, quem guarda o histórico, o que a pessoa lê quando o provedor está num dia ruim, e como o teste roda sem chave e sem rede. A tela onde isso termina é [a demo](/trilha/pt/demos/ai-chat): um chat de atendimento ao lado do pedido de que ele fala. ## 1. O cliente A chave vem do ambiente, como a URL do banco. Nunca está no código, nunca num commit e nunca num log — o `ai.Client` a mantém fora dos três. ```go // AIChatClient is the provider, built once at startup. The key never appears // in the code and never in a log — it comes from the environment, which is the // same place the database URL comes from. var AIChatClient = newAIChatClient() // newAIChatClient picks the provider from what the environment has. Anthropic // answers the same chat-completions shape at its OpenAI-compatible base URL, // so one Client covers both and the app has no provider branch past this // function. With neither key the client still builds, and only a request // fails: a missing key must not stop the app from starting. func newAIChatClient() *ai.Client { c := ai.NewFromEnv() // OPENAI_API_KEY, OPENAI_BASE_URL, TRILHA_AI_MODEL if key := os.Getenv("ANTHROPIC_API_KEY"); key != "" && os.Getenv("OPENAI_API_KEY") == "" { c.BaseURL, c.APIKey = "https://api.anthropic.com/v1", key } c.HTTPClient = &http.Client{Timeout: AIChatTimeout} return c } ``` O tempo limite é decisão, não padrão: ```go // AIChatTimeout is how long one answer may take. The default client waits two // minutes, which is longer than anybody sits in front of a support chat: a // provider having a bad day should give up before the visitor does. const AIChatTimeout = 60 * time.Second ``` :::note O `ai.Client` fala o formato *chat completions*. OpenAI, Anthropic (na URL compatível com a OpenAI), Groq, OpenRouter, Ollama e vLLM respondem esse formato, então trocar de provedor é uma URL e uma chave, não uma reescrita. ::: O que um administrador muda sem deploy não é a chave — é o modelo, o quanto ele inventa e quem o assistente é. Isso é uma [seção de configuração](/trilha/pt/referencia/app#settings): uma struct, as tags dela e uma tela que o framework monta a partir disso. ```go // AIChatConfig is the part of the assistant an administrator changes without a // deploy: which model answers, how much it invents, and who it is told to be. // The tags do three jobs at once — json stores it, form names the input, and // validate is the rule, the same one on the screen and on anything else that // saves it. type AIChatConfig struct { Model string `json:"model" form:"model" validate:"max=60" label:"Model" help:"the name the provider knows; empty keeps the one the environment chose"` Temperature float64 `json:"temperature" form:"temperature" validate:"min=0,max=2" label:"Temperature" help:"0 answers the same way every time; 2 invents"` Instructions string `json:"instructions" form:"instructions" validate:"required,max=2000" label:"Instructions" help:"who the assistant is, and what it must not answer"` } // AIChatSettings is the section, with the defaults that answer before anybody // has saved anything. It is a package var because configuration is not built // per request — it is read on every one. var AIChatSettings = trilha.NewSettings("ai-chat", AIChatConfig{ Temperature: 0.2, Instructions: "You are the support assistant of an online store. Answer about the " + "order the visitor has open, in the language the question was asked in. " + "When the answer is not in what you were told, say so instead of guessing.", }) ``` O agente é montado por requisição, sobre uma cópia da configuração, para que a mudança na tela de administração valha a partir da próxima mensagem: ```go // AIChatAgent is the agent of one request. It is built per request and from a // copy of the settings, so changing the model on the administration screen // takes effect on the next message — without a restart, and without a request // already running seeing the agent change underneath it. func AIChatAgent() *ai.Agent { cfg := AIChatSettings.Get() return &ai.Agent{ Name: "support", Instructions: cfg.Instructions, Model: cfg.Model, Temperature: &cfg.Temperature, } } ``` :::note Guardar a chave do provedor na própria seção é uma opção de verdade — o `trilha.Secret` a mantém cifrada em repouso e nunca a devolve para a tela. Vale quando alguém além do deploy precisa trocá-la. ::: ## 2. A rota A rota é o `ai.Serve` e, em volta dele, só o que o app sabe: quem está perguntando, com que frequência pode perguntar, e o que significa a página em que a pessoa está. ```go // AIChatPOST is app/api/chat/route.go, the whole route. ai.Serve reads the // message, runs the agent and answers: a stream of events for ui.chat.js, the // finished answer for a plain form submit. What the app adds around it is what // only the app knows — who is asking, how often they may ask, and what the // page they are on means. func AIChatPOST(c *trilha.Ctx) error { who := AIChatWho(c) if ok, after := AIChatLimit.Allow(who); !ok { c.Header("Retry-After", strconv.Itoa(after)) return trilha.Errorf(http.StatusTooManyRequests, "too many questions in a row; try again in %ds", after) } // The question is audited, the answer is not: the trail says who asked // what and when, and the conversation itself stays where it belongs. c.Audit("ai.chat.asked", who) return ai.ServeOpts{ HTML: ui.ChatHTML, Context: AIChatContext, Page: AIChatAnswerPage, }.Serve(c, AIChatClient, AIChatAgent()) } ``` O `ai.Serve` lê a mensagem, roda o agente e responde de dois jeitos: um fluxo de eventos nomeados quando o cliente pediu `text/event-stream`, e a resposta inteira de uma vez quando não pediu. A segunda é a que chega com o JavaScript desligado — mesma rota, mesmo agente, mesma linha de auditoria. ```go // AIChatAnswerPage answers the request that did not ask for a stream — the // plain form submit, which is what arrives when JavaScript is not there. It // runs after the whole answer is ready, so the turn goes into the log and the // visitor is sent back to the page that has it. A redirect and not a render: // reloading must not ask the model a second time. func AIChatAnswerPage(c *trilha.Ctx, res *ai.Result) error { id := c.Form("ctx.order_id") AIChatLog.Append(AIChatWho(c), c.Form("message"), res.Output) if id == "" { return c.Redirect("/") } return c.Redirect("/orders/" + url.PathEscape(id)) } ``` Um redirecionamento e não uma renderização, pelo motivo de sempre: recarregar a página não pode perguntar de novo ao modelo. ## 3. A tela O `ui.Chat` é a conversa, o campo e o botão; o `ui.ChatScript` é o cliente que faz a resposta chegar palavra por palavra. Nenhum dos dois é obrigatório para a página funcionar. ```go // AIChatScreen is the page with the chat in it: the conversation, the field // and the button, plus the script that makes the answer arrive word by word. // Without the script the same form posts to the same route and the page comes // back with the answer in it — nothing on the screen depends on JavaScript. func AIChatScreen(c *trilha.Ctx, o AIChatOrder) h.Node { return h.Div(h.Class("ui-stack"), ui.Chat(c, ui.ChatOpts{ Action: "/api/chat", History: AIChatLog.Read(AIChatWho(c)), Greeting: "Ask anything about this order — where it is, when it arrives, what it cost.", // What the page knows travels with every message, as JSON with // the script and as hidden fields without it. Context: map[string]string{"order_id": o.ID}, }), ui.ChatScript(c), ) } ``` O `Context` é a parte que faz um assistente ser útil em vez de impressionante: o que a página sabe e o modelo não. Cada item vira um campo escondido `ctx.`, enviado junto da mensagem pelo script e pelo formulário puro, e lido do outro lado pelo `ai.ServeOpts.Context`: ```go // AIChatContext turns the ctx.* fields the page sent into the sentence the // model reads before the conversation. It is what the page knows and the model // does not. // // The fields come from the request, so they are what the visitor sent and not // what the server knows: the lookup takes the owner as well as the id, which // makes it the permission check. An id that is not theirs finds nothing, and // the model is told nothing. // // The messages are user turns and not system ones. The history travels through // the browser, so a run drops every system message it finds in it — that is // what stops a page from being talked into new instructions, and it applies // here too. What the app really wants to fix goes in Agent.Instructions, which // no request can touch. func AIChatContext(c *trilha.Ctx, fields map[string]string) []ai.Message { id := fields["order_id"] if id == "" { return nil } o, ok := AIChatFindOrder(AIChatWho(c), id) if !ok { return []ai.Message{ai.User("I have no order open. Answer only general questions about the store.")} } return []ai.Message{ai.User(fmt.Sprintf( "I am looking at order %s, placed on %s by %s. Status: %s. Total: %s. Answer about this order and no other.", o.ID, o.Placed.Format("2006-01-02"), o.Customer, o.Status, o.Total))} } ``` O registro que ele lê é do app, e é a única coisa que o modelo fica sabendo: ```go // AIChatFindOrder is the app's query, with the owner in the WHERE clause. It // is a var because this file has no database; in an application it is the // same function the order page calls. The default finds nothing, which is the // safe way to notice it was never wired. var AIChatFindOrder = func(who, id string) (AIChatOrder, bool) { return AIChatOrder{}, false } ``` :::note Os campos `ctx.*` vêm da requisição: são o que o visitante mandou, não o que o servidor sabe. A consulta recebe o dono junto do id, e é isso que a transforma na conferência de permissão em vez de uma busca. Trate um id daí exatamente como um id na URL. ::: Texto de modelo é Markdown, e o `ui.ChatHTML` é o adaptador que o renderiza do mesmo jeito que o histórico é renderizado: um `