Переглянути джерело

Add streaming chat, reasoning/thinking support, and file uploads (#293)

* AIChat: collapsible thinking section + media upload from computer

Thinking / reasoning:
- Capture model chain-of-thought in the llm client. openai.go reads
  reasoning_content (DeepSeek) / reasoning (OpenRouter); anthropic.go
  collects "thinking" content blocks (previously dropped). Surfaced via a
  new Choice.Message.ReasoningContent field on the unified response.
- chat.agi forwards it to the frontend as a "reasoning" field.
- The AIChat UI shows reasoning in a disclosure that is collapsed by
  default and only expands when the user presses "Show thinking". Inline
  <think>/<thinking> tags emitted by local models are parsed out of the
  reply too, so the answer bubble stays clean. Prior thinking is not fed
  back to the model on later turns.

Media upload:
- The attach button now opens a small menu: "ArozOS files" (the existing
  file selector) or "Upload from computer". Local picks are uploaded to a
  tmp:/ scratch folder and staged as virtual paths, so they flow through
  the same llm.fileParts path as drive files. Chips show an uploading
  spinner / failed state, and send waits for uploads to finish.
- Added a self-contained toast (the desktop shell's showToast is not
  reachable from the app iframe) and replaced the one stale showToast call.

Also: table-driven tests for reasoning capture (openai/anthropic clients
and chat.agi), updated the llm API docs (README + api.json), and replaced
a pre-existing emoji in the model picker per the icon policy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0136jXkjkwNzRZDrU9pbsHD1

* AIChat: stop the app shell overflowing the viewport

The desktop grid (.app) set height:100dvh but no grid-template-rows, so its
single implicit row was auto-sized to content and grew past the viewport —
the sidebar's chat list and the message area couldn't shrink, producing a
page scrollbar and pushing the composer toward the edge. Pin the row to
minmax(0,1fr) and give the sidebar / chat list min-height:0 so the inner
areas scroll instead of the whole app. Verified overflow is 0 from 600px to
900px tall viewports with the composer fully visible.

Pre-existing layout issue, surfaced while adding the thinking section and
media upload.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0136jXkjkwNzRZDrU9pbsHD1

* AIChat: real-time streaming replies and PDF attachment support

Streaming (token-by-token, including live thinking):
- Add Client.ChatStream to the llm client, with SSE parsing for both wire
  formats: OpenAI (delta.content plus reasoning_content/reasoning, usage via
  stream_options.include_usage) and Anthropic (text_delta / thinking_delta,
  usage split across message_start and message_delta).
- Expose it to AGI scripts as llm.streamRequest(messages, options, onDelta).
  onDelta fires per chunk with {content, reasoning} on the script's own
  goroutine, so a script can relay straight to websocket.send(). Usage and
  quota are recorded exactly as for a blocking call; connection resolution is
  now shared by both paths via llmResolveConnection.
- New backend/chat_stream.agi relays the stream over a WebSocket using the
  frame protocol start/reasoning/delta/done/error.
- The UI now streams: the answer grows token by token with a caret, and the
  thinking section auto-opens while the model reasons ("Thinking…") then
  collapses to "Show thinking" once the answer starts — unless the user
  toggled it themselves. Stop closes the socket and keeps partial output. If
  the socket cannot be opened the app falls back to the one-shot HTTP
  backend, so nothing regresses.

PDF attachments:
- PDFs are rasterised to JPEG pages in the browser with the pdf.js already
  bundled with the PDF Viewer app, then uploaded and attached in the PDF's
  place, fixing "unsupported file type for file-based chat: *.pdf". Works for
  both computer uploads and drive picks (read via /media). Capped at 10 pages
  with a notice; no new dependency and no system tools involved.
- Attachment entries now carry a `paths` array, so one chip can expand to
  several page images; chips show conversion progress and the page count.

Tests: SSE streaming for both formats (deltas, assembly, usage, error
envelopes and error events), the AGI streaming binding, and chat_stream.agi
driven through a stubbed websocket. Docs updated (README + api.json).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0136jXkjkwNzRZDrU9pbsHD1

---------

Co-authored-by: Claude <noreply@anthropic.com>
Alan Yeung 3 тижнів тому
батько
коміт
82b62bc5ea

+ 31 - 0
src/mod/agi/README.md

@@ -1002,6 +1002,37 @@ var resp = llm.request([
 sendResp(resp.choices[0].message.content);
 ```
 
+When the model exposes its chain-of-thought, `choices[0].message.reasoning_content`
+carries that "thinking" text separately from the answer (`content`). It is
+populated from DeepSeek's `reasoning_content`, OpenRouter's `reasoning` or
+Anthropic `thinking` content blocks, and is an empty string for models that do
+not return reasoning.
+
+### `llm.streamRequest(messages, options, onDelta)` → object
+Like `llm.request()` but streams the completion: `onDelta` is called for every
+incremental chunk as the model produces it, and the fully assembled response
+(same shape as `llm.request`, including `usage`) is returned when the stream
+ends. Each delta is `{ content, reasoning }` — `content` is new answer text and
+`reasoning` is new chain-of-thought text; either may be empty for a given chunk.
+
+The callback runs on the script's own goroutine, so it is safe to relay chunks
+straight to a browser over `websocket.send()`:
+
+```javascript
+requirelib("llm");
+requirelib("websocket");
+websocket.upgrade(300);
+
+var resp = llm.streamRequest([{ role: "user", content: "Explain gravity" }], {}, function(d){
+    if (d.reasoning != "") websocket.send(JSON.stringify({ type: "reasoning", content: d.reasoning }));
+    if (d.content   != "") websocket.send(JSON.stringify({ type: "delta",     content: d.content }));
+});
+websocket.send(JSON.stringify({ type: "done", usage: resp.usage }));
+```
+
+Token usage is recorded against the metrics/quota board exactly as for a
+non-streaming call.
+
 ### `llm.usage()` → object
 Returns accumulated token / cost metrics across all models.
 

+ 141 - 0
src/mod/agi/agi.aichat_backend_test.go

@@ -92,6 +92,35 @@ func TestAIChatBackend_Chat(t *testing.T) {
 	}
 }
 
+func TestAIChatBackend_ChatSurfacesReasoning(t *testing.T) {
+	//A reasoning model returns its chain-of-thought in reasoning_content; the
+	//backend must forward it to the frontend as the "reasoning" field so the
+	//UI can show it in a collapsible thinking section.
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Content-Type", "application/json")
+		io.WriteString(w, `{"model":"reasoner",
+			"choices":[{"message":{"role":"assistant","content":"4","reasoning_content":"2 plus 2 is 4."}}],
+			"usage":{"prompt_tokens":3,"completion_tokens":1,"total_tokens":4}}`)
+	}))
+	defer srv.Close()
+
+	g := dbGateway(t)
+	sysdb := g.Option.UserHandler.GetDatabase()
+	sysdb.Write(llmDBTable, "config", LLMConfig{Endpoint: srv.URL, DefaultModel: "reasoner", Currency: "USD"})
+
+	out := runAIChatBackend(t, g, "AIChat/backend/chat.agi", map[string]string{
+		"messages": `[{"role":"user","content":"2+2?"}]`,
+		"options":  `{"model":"reasoner"}`,
+	})
+
+	if !strings.Contains(out, `"ok":true`) {
+		t.Fatalf("expected ok:true, got: %s", out)
+	}
+	if !strings.Contains(out, `"reasoning":"2 plus 2 is 4."`) {
+		t.Errorf("reasoning was not surfaced in the response: %s", out)
+	}
+}
+
 func TestAIChatBackend_ChatNoEndpointReturnsError(t *testing.T) {
 	g := dbGateway(t) //no config written -> endpoint unset
 	out := runAIChatBackend(t, g, "AIChat/backend/chat.agi", map[string]string{
@@ -106,6 +135,118 @@ func TestAIChatBackend_ChatNoEndpointReturnsError(t *testing.T) {
 	}
 }
 
+// runAIChatStreamBackend runs the streaming backend script with the llm lib
+// injected and a stubbed websocket object: the first read() yields reqJSON,
+// send() captures every outgoing frame, and exit()/close() end the script.
+// Returns the ordered list of JSON frames the script sent to the "client".
+func runAIChatStreamBackend(t *testing.T, g *Gateway, reqJSON string) []string {
+	t.Helper()
+	vm := otto.New()
+	g.injectLLMFunctions(&static.AgiLibInjectionPayload{VM: vm, User: &user.User{Username: "tester"}})
+
+	vm.Set("requirelib", func(call otto.FunctionCall) otto.Value {
+		v, _ := vm.ToValue(true)
+		return v
+	})
+	vm.Set("sendResp", func(call otto.FunctionCall) otto.Value { return otto.UndefinedValue() })
+	vm.Set("exit", func(call otto.FunctionCall) otto.Value {
+		panic(vm.MakeCustomError("AGIExit", "exit"))
+	})
+
+	var frames []string
+	readCount := 0
+	vm.Set("_ws_upgrade", func(call otto.FunctionCall) otto.Value { return otto.TrueValue() })
+	vm.Set("_ws_read", func(call otto.FunctionCall) otto.Value {
+		readCount++
+		if readCount == 1 {
+			v, _ := vm.ToValue(reqJSON)
+			return v
+		}
+		return otto.FalseValue()
+	})
+	vm.Set("_ws_send", func(call otto.FunctionCall) otto.Value {
+		s, _ := call.Argument(0).ToString()
+		frames = append(frames, s)
+		return otto.TrueValue()
+	})
+	vm.Set("_ws_close", func(call otto.FunctionCall) otto.Value { return otto.TrueValue() })
+	vm.Run(`var websocket = { upgrade:_ws_upgrade, read:_ws_read, send:_ws_send, close:_ws_close, isClosed:function(){return false;} };`)
+
+	scriptPath := filepath.Join("..", "..", "web", "AIChat/backend/chat_stream.agi")
+	content, err := os.ReadFile(scriptPath)
+	if err != nil {
+		t.Fatalf("cannot read chat_stream.agi: %v", err)
+	}
+	if _, err := vm.Run(string(content)); err != nil && !strings.Contains(err.Error(), "exit") {
+		t.Fatalf("chat_stream.agi errored: %v", err)
+	}
+	return frames
+}
+
+func TestAIChatBackend_Stream(t *testing.T) {
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		body, _ := io.ReadAll(r.Body)
+		if !strings.Contains(string(body), `"stream":true`) {
+			t.Errorf("streaming backend did not request a stream; body=%s", string(body))
+		}
+		w.Header().Set("Content-Type", "text/event-stream")
+		io.WriteString(w, "data: {\"model\":\"m\",\"choices\":[{\"delta\":{\"reasoning_content\":\"pondering\"}}]}\n\n")
+		io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"Hi \"}}]}\n\n")
+		io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"there\"},\"finish_reason\":\"stop\"}]}\n\n")
+		io.WriteString(w, "data: {\"choices\":[],\"usage\":{\"prompt_tokens\":3,\"completion_tokens\":2,\"total_tokens\":5}}\n\n")
+		io.WriteString(w, "data: [DONE]\n\n")
+	}))
+	defer srv.Close()
+
+	g := dbGateway(t)
+	g.Option.UserHandler.GetDatabase().Write(llmDBTable, "config", LLMConfig{Endpoint: srv.URL, DefaultModel: "m", Currency: "USD"})
+
+	frames := runAIChatStreamBackend(t, g, `{"messages":[{"role":"user","content":"hi"}],"options":{"model":"m"}}`)
+
+	joined := strings.Join(frames, "\n")
+	if !strings.Contains(joined, `"type":"start"`) {
+		t.Errorf("missing start frame; frames=%v", frames)
+	}
+	if !strings.Contains(joined, `"type":"reasoning"`) || !strings.Contains(joined, "pondering") {
+		t.Errorf("reasoning was not streamed; frames=%v", frames)
+	}
+	if !strings.Contains(joined, `"type":"delta"`) || !strings.Contains(joined, "there") {
+		t.Errorf("answer deltas were not streamed; frames=%v", frames)
+	}
+	//The terminal "done" frame carries the fully assembled reply + usage.
+	last := frames[len(frames)-1]
+	if !strings.Contains(last, `"type":"done"`) {
+		t.Fatalf("last frame should be done, got: %s", last)
+	}
+	if !strings.Contains(last, `"content":"Hi there"`) {
+		t.Errorf("done frame missing assembled content: %s", last)
+	}
+	if !strings.Contains(last, `"total_tokens":5`) {
+		t.Errorf("done frame missing usage: %s", last)
+	}
+}
+
+func TestAIChatBackend_StreamRecordsUsage(t *testing.T) {
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Content-Type", "text/event-stream")
+		io.WriteString(w, "data: {\"model\":\"m\",\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\n")
+		io.WriteString(w, "data: {\"choices\":[],\"usage\":{\"prompt_tokens\":8,\"completion_tokens\":4,\"total_tokens\":12}}\n\n")
+		io.WriteString(w, "data: [DONE]\n\n")
+	}))
+	defer srv.Close()
+
+	g := dbGateway(t)
+	g.Option.UserHandler.GetDatabase().Write(llmDBTable, "config", LLMConfig{Endpoint: srv.URL, DefaultModel: "m", Currency: "USD"})
+
+	runAIChatStreamBackend(t, g, `{"messages":[{"role":"user","content":"hi"}],"options":{"model":"m"}}`)
+
+	//Streaming must feed the same metrics board as the blocking path.
+	m := g.getLLMMetrics()
+	if m.TotalRequests != 1 || m.TotalTokens != 12 {
+		t.Errorf("streaming usage not recorded in metrics: %+v", m)
+	}
+}
+
 func TestAIChatBackend_Models(t *testing.T) {
 	g := dbGateway(t)
 	sysdb := g.Option.UserHandler.GetDatabase()

+ 89 - 12
src/mod/agi/agi.llm.go

@@ -250,6 +250,42 @@ func (g *Gateway) injectLLMFunctions(payload *static.AgiLibInjectionPayload) {
 		return reply
 	})
 
+	//llm.streamRequest(messages, options, onDelta) => full response object.
+	//Streams the completion, invoking onDelta({content, reasoning}) for each
+	//incremental chunk (so a script can relay tokens to the browser over a
+	//WebSocket in real time), then returns the assembled response with usage.
+	vm.Set("_llm_streamRequest", func(call otto.FunctionCall) otto.Value {
+		messagesJSON := getOttoStringArg(call, 0)
+		opt := parseLLMCallOptions(getOttoStringArg(call, 1))
+		onDelta := call.Argument(2)
+
+		var messages []llm.Message
+		if err := json.Unmarshal([]byte(messagesJSON), &messages); err != nil {
+			panic(vm.MakeCustomError("LLMError", "invalid messages array: "+err.Error()))
+		}
+
+		//The callback runs on this same (script) goroutine inside ChatStream,
+		//so touching the Otto VM from here is safe. Errors thrown by the JS
+		//callback are ignored so one bad frame cannot abort the stream.
+		cb := func(d llm.StreamDelta) {
+			if !onDelta.IsFunction() {
+				return
+			}
+			onDelta.Call(otto.UndefinedValue(), map[string]interface{}{
+				"content":   d.Content,
+				"reasoning": d.Reasoning,
+			})
+		}
+
+		resp, err := g.llmDoStreamRequest(opt.Model, messages, opt, cb)
+		if err != nil {
+			panic(vm.MakeCustomError("LLMError", err.Error()))
+		}
+		out, _ := json.Marshal(resp)
+		reply, _ := vm.ToValue(string(out))
+		return reply
+	})
+
 	//llm.usage() => aggregated metrics object (JSON string)
 	vm.Set("_llm_usage", func(call otto.FunctionCall) otto.Value {
 		out, _ := json.Marshal(g.getLLMMetrics())
@@ -324,6 +360,9 @@ func (g *Gateway) injectLLMFunctions(payload *static.AgiLibInjectionPayload) {
 		llm.request = function(messages, options){
 			return JSON.parse(_llm_request(JSON.stringify(messages || []), JSON.stringify(options || {})));
 		};
+		llm.streamRequest = function(messages, options, onDelta){
+			return JSON.parse(_llm_streamRequest(JSON.stringify(messages || []), JSON.stringify(options || {}), onDelta || null));
+		};
 		llm.usage = function(){
 			return JSON.parse(_llm_usage());
 		};
@@ -342,10 +381,10 @@ func (g *Gateway) injectLLMFunctions(payload *static.AgiLibInjectionPayload) {
 
 // ── Core request logic ───────────────────────────────────────────────────────
 
-// llmDoRequest resolves the connection settings, enforces any usage quota,
-// dispatches the call via mod/aiservers/llm, records the resulting token
-// usage / cost and returns the unified response.
-func (g *Gateway) llmDoRequest(model string, messages []llm.Message, opt llmCallOptions) (*llm.ChatResponse, error) {
+// llmResolveConnection folds the admin config together with any per-call
+// overrides, validates it and returns a ready client plus the resolved model.
+// Shared by the blocking and streaming request paths.
+func (g *Gateway) llmResolveConnection(model string, opt llmCallOptions) (*llm.Client, string, error) {
 	cfg := g.getLLMConfig()
 
 	endpoint := strings.TrimSpace(cfg.Endpoint)
@@ -368,10 +407,22 @@ func (g *Gateway) llmDoRequest(model string, messages []llm.Message, opt llmCall
 	}
 
 	if endpoint == "" {
-		return nil, errors.New("AI model endpoint is not configured (System Settings > AI Integration > AI Model)")
+		return nil, "", errors.New("AI model endpoint is not configured (System Settings > AI Integration > AI Model)")
 	}
 	if strings.TrimSpace(model) == "" {
-		return nil, errors.New("no model specified and no default model configured")
+		return nil, "", errors.New("no model specified and no default model configured")
+	}
+
+	return llm.NewClient(endpoint, apikey, format, llmRequestTimeout), model, nil
+}
+
+// llmDoRequest resolves the connection settings, enforces any usage quota,
+// dispatches the call via mod/aiservers/llm, records the resulting token
+// usage / cost and returns the unified response.
+func (g *Gateway) llmDoRequest(model string, messages []llm.Message, opt llmCallOptions) (*llm.ChatResponse, error) {
+	client, model, err := g.llmResolveConnection(model, opt)
+	if err != nil {
+		return nil, err
 	}
 
 	//Enforce the usage quota before spending any tokens.
@@ -379,22 +430,48 @@ func (g *Gateway) llmDoRequest(model string, messages []llm.Message, opt llmCall
 		return nil, err
 	}
 
-	client := llm.NewClient(endpoint, apikey, format, llmRequestTimeout)
 	resp, err := client.Chat(messages, llm.ChatOptions{Model: model, Temperature: opt.Temperature, MaxTokens: opt.MaxTokens})
 	if err != nil {
 		return nil, err
 	}
 
-	//Record usage. Prefer the model echoed back by the server.
-	usedModel := model
-	if strings.TrimSpace(resp.Model) != "" {
-		usedModel = resp.Model
+	g.recordLLMUsageFromResponse(model, resp)
+	return resp, nil
+}
+
+// llmDoStreamRequest is the streaming counterpart of llmDoRequest: it streams
+// the completion, forwarding each delta to cb, and records usage once done.
+func (g *Gateway) llmDoStreamRequest(model string, messages []llm.Message, opt llmCallOptions, cb llm.StreamCallback) (*llm.ChatResponse, error) {
+	client, model, err := g.llmResolveConnection(model, opt)
+	if err != nil {
+		return nil, err
 	}
-	g.recordLLMUsage(usedModel, resp.Usage.PromptTokens, resp.Usage.CompletionTokens, resp.Usage.GenerationMs)
 
+	if err := g.llmCheckQuota(); err != nil {
+		return nil, err
+	}
+
+	resp, err := client.ChatStream(messages, llm.ChatOptions{Model: model, Temperature: opt.Temperature, MaxTokens: opt.MaxTokens}, cb)
+	if err != nil {
+		return nil, err
+	}
+
+	g.recordLLMUsageFromResponse(model, resp)
 	return resp, nil
 }
 
+// recordLLMUsageFromResponse records the token usage of a completed response,
+// preferring the model name echoed back by the server.
+func (g *Gateway) recordLLMUsageFromResponse(model string, resp *llm.ChatResponse) {
+	usedModel := model
+	if resp != nil && strings.TrimSpace(resp.Model) != "" {
+		usedModel = resp.Model
+	}
+	if resp != nil {
+		g.recordLLMUsage(usedModel, resp.Usage.PromptTokens, resp.Usage.CompletionTokens, resp.Usage.GenerationMs)
+	}
+}
+
 // llmBuildFileParts reads a file from the user's virtual file system and
 // converts it into one or more OpenAI-compatible content parts. Images become
 // base64 data-URI image_url parts (for vision models); textual files are

+ 47 - 1
src/mod/agi/agi.llm_test.go

@@ -197,6 +197,52 @@ func TestLLMDoRequestFlow(t *testing.T) {
 	}
 }
 
+func TestLLMDoStreamRequestFlow(t *testing.T) {
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Content-Type", "text/event-stream")
+		io.WriteString(w, "data: {\"model\":\"m\",\"choices\":[{\"delta\":{\"reasoning_content\":\"hmm\"}}]}\n\n")
+		io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"Hi\"}}]}\n\n")
+		io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"!\"},\"finish_reason\":\"stop\"}]}\n\n")
+		io.WriteString(w, "data: {\"choices\":[],\"usage\":{\"prompt_tokens\":4,\"completion_tokens\":2,\"total_tokens\":6}}\n\n")
+		io.WriteString(w, "data: [DONE]\n\n")
+	}))
+	defer srv.Close()
+
+	g := dbGateway(t)
+	g.Option.UserHandler.GetDatabase().Write(llmDBTable, "config", LLMConfig{Endpoint: srv.URL, DefaultModel: "m", APIFormat: "openai", Currency: "USD"})
+
+	var content, reasoning strings.Builder
+	resp, err := g.llmDoStreamRequest("", []llm.Message{{Role: "user", Content: "hi"}}, llmCallOptions{}, func(d llm.StreamDelta) {
+		content.WriteString(d.Content)
+		reasoning.WriteString(d.Reasoning)
+	})
+	if err != nil {
+		t.Fatalf("llmDoStreamRequest error: %v", err)
+	}
+	if content.String() != "Hi!" {
+		t.Errorf("streamed content = %q, want Hi!", content.String())
+	}
+	if reasoning.String() != "hmm" {
+		t.Errorf("streamed reasoning = %q, want hmm", reasoning.String())
+	}
+	if llmExtractContent(resp) != "Hi!" {
+		t.Errorf("assembled content wrong: %q", llmExtractContent(resp))
+	}
+	//Usage from the streamed final chunk must be recorded like a blocking call.
+	m := g.getLLMMetrics()
+	if m.TotalRequests != 1 || m.TotalTokens != 6 {
+		t.Errorf("metrics not recorded after stream: %+v", m)
+	}
+}
+
+func TestLLMDoStreamRequestNoEndpoint(t *testing.T) {
+	g := dbGateway(t)
+	_, err := g.llmDoStreamRequest("m", []llm.Message{{Role: "user", Content: "hi"}}, llmCallOptions{}, nil)
+	if err == nil {
+		t.Error("expected error when endpoint is not configured")
+	}
+}
+
 func TestLLMDoRequestNoEndpoint(t *testing.T) {
 	g := dbGateway(t)
 	_, err := g.llmDoRequest("m", []llm.Message{{Role: "user", Content: "hi"}}, llmCallOptions{})
@@ -408,7 +454,7 @@ func TestInjectLLMLib_JSObjectExposed(t *testing.T) {
 	payload := &static.AgiLibInjectionPayload{VM: vm, User: &user.User{Username: "alice"}}
 	g.injectLLMFunctions(payload)
 
-	for _, method := range []string{"chat", "chatWithFile", "request", "usage", "models"} {
+	for _, method := range []string{"chat", "chatWithFile", "request", "streamRequest", "usage", "models"} {
 		val, err := vm.Run(`typeof llm.` + method)
 		if err != nil {
 			t.Fatalf("evaluating llm.%s: %v", method, err)

+ 175 - 3
src/mod/aiservers/llm/anthropic.go

@@ -1,6 +1,7 @@
 package llm
 
 import (
+	"bufio"
 	"bytes"
 	"encoding/json"
 	"errors"
@@ -47,6 +48,9 @@ type anthropicResponse struct {
 	Content []struct {
 		Type string `json:"type"`
 		Text string `json:"text"`
+		//Extended-thinking blocks carry their chain-of-thought in a
+		//dedicated "thinking" field rather than "text".
+		Thinking string `json:"thinking"`
 	} `json:"content"`
 	Usage struct {
 		InputTokens  int64 `json:"input_tokens"`
@@ -129,17 +133,23 @@ func (c *Client) chatAnthropic(messages []Message, opt ChatOptions) (*ChatRespon
 		return nil, fmt.Errorf("AI endpoint returned HTTP %d: %s", resp.StatusCode, truncate(string(respBody), 300))
 	}
 
-	//Map the Anthropic response onto the unified ChatResponse.
-	var text strings.Builder
+	//Map the Anthropic response onto the unified ChatResponse. Text blocks
+	//form the answer; thinking blocks are collected separately so callers can
+	//show the model's reasoning in a dedicated (collapsible) section.
+	var text, thinking strings.Builder
 	for _, block := range parsed.Content {
-		if block.Type == "text" {
+		switch block.Type {
+		case "text":
 			text.WriteString(block.Text)
+		case "thinking":
+			thinking.WriteString(block.Thinking)
 		}
 	}
 	unified := &ChatResponse{Model: parsed.Model}
 	choice := Choice{FinishReason: parsed.StopReason}
 	choice.Message.Role = "assistant"
 	choice.Message.Content = text.String()
+	choice.Message.ReasoningContent = thinking.String()
 	unified.Choices = append(unified.Choices, choice)
 	unified.Usage = Usage{
 		PromptTokens:     parsed.Usage.InputTokens,
@@ -149,6 +159,168 @@ func (c *Client) chatAnthropic(messages []Message, opt ChatOptions) (*ChatRespon
 	return unified, nil
 }
 
+// anthropicStreamEvent is one SSE "data:" payload from a streaming Messages
+// call. A single struct covers every event type (message_start,
+// content_block_delta, message_delta, error, ...) since they are
+// distinguished by the Type field and use disjoint sub-objects.
+type anthropicStreamEvent struct {
+	Type    string `json:"type"`
+	Message *struct {
+		Model string `json:"model"`
+		Usage *struct {
+			InputTokens  int64 `json:"input_tokens"`
+			OutputTokens int64 `json:"output_tokens"`
+		} `json:"usage"`
+	} `json:"message"`
+	Delta *struct {
+		Type       string `json:"type"`
+		Text       string `json:"text"`
+		Thinking   string `json:"thinking"`
+		StopReason string `json:"stop_reason"`
+	} `json:"delta"`
+	Usage *struct {
+		OutputTokens int64 `json:"output_tokens"`
+	} `json:"usage"`
+	Error *struct {
+		Type    string `json:"type"`
+		Message string `json:"message"`
+	} `json:"error"`
+}
+
+// chatAnthropicStream performs a streaming Anthropic Messages call, invoking cb
+// for each text/thinking delta and assembling the unified response. Anthropic
+// splits usage across message_start (input tokens) and message_delta (output
+// tokens), so both are accumulated before computing the total.
+func (c *Client) chatAnthropicStream(messages []Message, opt ChatOptions, cb StreamCallback) (*ChatResponse, error) {
+	system := ""
+	amsgs := []anthropicMessage{}
+	for _, m := range messages {
+		if m.Role == "system" {
+			if s, ok := m.Content.(string); ok {
+				if system != "" {
+					system += "\n\n"
+				}
+				system += s
+			}
+			continue
+		}
+		amsgs = append(amsgs, anthropicMessage{Role: m.Role, Content: toAnthropicContent(m.Content)})
+	}
+
+	maxTokens := anthropicDefaultMaxTokens
+	if opt.MaxTokens != nil && *opt.MaxTokens > 0 {
+		maxTokens = *opt.MaxTokens
+	}
+
+	reqStruct := anthropicRequest{
+		Model:       opt.Model,
+		MaxTokens:   maxTokens,
+		System:      system,
+		Messages:    amsgs,
+		Temperature: opt.Temperature,
+		Stream:      true,
+	}
+	body, err := json.Marshal(reqStruct)
+	if err != nil {
+		return nil, err
+	}
+
+	req, err := http.NewRequest("POST", anthropicURL(c.Endpoint), bytes.NewReader(body))
+	if err != nil {
+		return nil, err
+	}
+	req.Header.Set("Content-Type", "application/json")
+	req.Header.Set("Accept", "text/event-stream")
+	req.Header.Set("User-Agent", "arozos-llm-client/1.0")
+	req.Header.Set("anthropic-version", anthropicVersion)
+	if c.APIKey != "" {
+		req.Header.Set("x-api-key", c.APIKey)
+	}
+
+	resp, err := c.httpClient().Do(req)
+	if err != nil {
+		return nil, errors.New("request to AI endpoint failed: " + err.Error())
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+		respBody, _ := io.ReadAll(resp.Body)
+		if msg := openaiErrorMessage(respBody); msg != "" {
+			return nil, errors.New("AI endpoint error: " + msg)
+		}
+		return nil, fmt.Errorf("AI endpoint returned HTTP %d: %s", resp.StatusCode, truncate(string(respBody), 300))
+	}
+
+	var textB, thinkingB strings.Builder
+	var model, stopReason string
+	var inputTokens, outputTokens int64
+
+	reader := bufio.NewReader(resp.Body)
+	for {
+		line, readErr := reader.ReadString('\n')
+		if trimmed := strings.TrimRight(line, "\r\n"); strings.HasPrefix(trimmed, "data:") {
+			data := strings.TrimSpace(trimmed[len("data:"):])
+			if data != "" {
+				var ev anthropicStreamEvent
+				if json.Unmarshal([]byte(data), &ev) == nil {
+					switch ev.Type {
+					case "message_start":
+						if ev.Message != nil {
+							if ev.Message.Model != "" {
+								model = ev.Message.Model
+							}
+							if ev.Message.Usage != nil {
+								inputTokens = ev.Message.Usage.InputTokens
+							}
+						}
+					case "content_block_delta":
+						if ev.Delta != nil {
+							d := StreamDelta{}
+							if ev.Delta.Type == "thinking_delta" && ev.Delta.Thinking != "" {
+								d.Reasoning = ev.Delta.Thinking
+								thinkingB.WriteString(ev.Delta.Thinking)
+							} else if ev.Delta.Text != "" {
+								d.Content = ev.Delta.Text
+								textB.WriteString(ev.Delta.Text)
+							}
+							if cb != nil && (d.Content != "" || d.Reasoning != "") {
+								cb(d)
+							}
+						}
+					case "message_delta":
+						if ev.Delta != nil && ev.Delta.StopReason != "" {
+							stopReason = ev.Delta.StopReason
+						}
+						if ev.Usage != nil {
+							outputTokens = ev.Usage.OutputTokens
+						}
+					case "error":
+						if ev.Error != nil && ev.Error.Message != "" {
+							return nil, errors.New("AI endpoint error: " + ev.Error.Message)
+						}
+					}
+				}
+			}
+		}
+		if readErr != nil {
+			break
+		}
+	}
+
+	unified := &ChatResponse{Model: model}
+	choice := Choice{FinishReason: stopReason}
+	choice.Message.Role = "assistant"
+	choice.Message.Content = textB.String()
+	choice.Message.ReasoningContent = thinkingB.String()
+	unified.Choices = append(unified.Choices, choice)
+	unified.Usage = Usage{
+		PromptTokens:     inputTokens,
+		CompletionTokens: outputTokens,
+		TotalTokens:      inputTokens + outputTokens,
+	}
+	return unified, nil
+}
+
 // listModelsAnthropic lists model IDs from the Anthropic /v1/models endpoint.
 func (c *Client) listModelsAnthropic() ([]string, error) {
 	base := strings.TrimRight(c.Endpoint, "/")

+ 31 - 0
src/mod/aiservers/llm/anthropic_test.go

@@ -66,6 +66,37 @@ func TestClientChatAnthropic(t *testing.T) {
 	}
 }
 
+func TestClientChatAnthropicThinking(t *testing.T) {
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Content-Type", "application/json")
+		//Extended-thinking responses interleave thinking and text blocks.
+		io.WriteString(w, `{"model":"claude-x",
+			"content":[
+				{"type":"thinking","thinking":"The user asked a simple question."},
+				{"type":"text","text":"The answer is 4."}
+			],
+			"usage":{"input_tokens":5,"output_tokens":9},
+			"stop_reason":"end_turn"}`)
+	}))
+	defer srv.Close()
+
+	c := NewClient(srv.URL, "k", "anthropic", 0)
+	resp, err := c.Chat([]Message{{Role: "user", Content: "2+2?"}}, ChatOptions{Model: "claude-x"})
+	if err != nil {
+		t.Fatalf("anthropic request errored: %v", err)
+	}
+	if len(resp.Choices) == 0 {
+		t.Fatalf("no choices returned")
+	}
+	//The thinking block must not leak into the visible answer.
+	if resp.Choices[0].Message.Content != "The answer is 4." {
+		t.Errorf("answer content should exclude thinking, got: %q", resp.Choices[0].Message.Content)
+	}
+	if resp.Choices[0].Message.ReasoningContent != "The user asked a simple question." {
+		t.Errorf("thinking block not captured as reasoning: %q", resp.Choices[0].Message.ReasoningContent)
+	}
+}
+
 func TestClientChatAnthropicErrorEnvelope(t *testing.T) {
 	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		w.WriteHeader(http.StatusBadRequest)

+ 25 - 0
src/mod/aiservers/llm/client.go

@@ -69,6 +69,31 @@ func (c *Client) Chat(messages []Message, opt ChatOptions) (*ChatResponse, error
 	return resp, nil
 }
 
+// ChatStream sends a streaming chat completion request, invoking cb for each
+// incremental delta as it arrives, and returns the fully assembled response
+// (with accumulated content/reasoning and usage) once the stream ends. It
+// dispatches to the configured wire format. GenerationMs/TokensPerSecond are
+// computed from the wall-clock request duration, exactly like Chat.
+func (c *Client) ChatStream(messages []Message, opt ChatOptions, cb StreamCallback) (*ChatResponse, error) {
+	start := time.Now()
+	var resp *ChatResponse
+	var err error
+	if c.APIFormat == "anthropic" {
+		resp, err = c.chatAnthropicStream(messages, opt, cb)
+	} else {
+		resp, err = c.chatOpenAIStream(messages, opt, cb)
+	}
+	if err != nil {
+		return nil, err
+	}
+	elapsed := time.Since(start)
+	resp.Usage.GenerationMs = elapsed.Milliseconds()
+	if resp.Usage.CompletionTokens > 0 && elapsed.Seconds() > 0 {
+		resp.Usage.TokensPerSecond = float64(resp.Usage.CompletionTokens) / elapsed.Seconds()
+	}
+	return resp, nil
+}
+
 // ListModels lists model IDs exposed by the endpoint, dispatching to the
 // configured wire format. Used by connectivity tests and AGI scripts; does
 // not consume any tokens.

+ 40 - 0
src/mod/aiservers/llm/client_test.go

@@ -71,6 +71,46 @@ func TestClientChatOpenAI(t *testing.T) {
 	}
 }
 
+func TestClientChatOpenAIReasoning(t *testing.T) {
+	cases := []struct {
+		name string
+		body string
+	}{
+		{
+			name: "reasoning_content (DeepSeek)",
+			body: `{"model":"r","choices":[{"message":{"role":"assistant","content":"42","reasoning_content":"Let me think step by step."}}]}`,
+		},
+		{
+			name: "reasoning (OpenRouter)",
+			body: `{"model":"r","choices":[{"message":{"role":"assistant","content":"42","reasoning":"Let me think step by step."}}]}`,
+		},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/json")
+				io.WriteString(w, tc.body)
+			}))
+			defer srv.Close()
+
+			c := NewClient(srv.URL, "", "openai", 0)
+			resp, err := c.Chat([]Message{{Role: "user", Content: "hi"}}, ChatOptions{Model: "r"})
+			if err != nil {
+				t.Fatalf("Chat returned error: %v", err)
+			}
+			if len(resp.Choices) == 0 {
+				t.Fatalf("no choices returned")
+			}
+			if resp.Choices[0].Message.Content != "42" {
+				t.Errorf("answer content wrong: %q", resp.Choices[0].Message.Content)
+			}
+			if resp.Choices[0].Message.ReasoningContent != "Let me think step by step." {
+				t.Errorf("reasoning not captured: %q", resp.Choices[0].Message.ReasoningContent)
+			}
+		})
+	}
+}
+
 func TestClientChatOpenAINoAuthHeaderWhenNoKey(t *testing.T) {
 	var gotAuth string
 	called := false

+ 171 - 5
src/mod/aiservers/llm/openai.go

@@ -1,6 +1,7 @@
 package llm
 
 import (
+	"bufio"
 	"bytes"
 	"encoding/json"
 	"errors"
@@ -10,12 +11,41 @@ import (
 	"strings"
 )
 
+type openaiStreamOptions struct {
+	IncludeUsage bool `json:"include_usage"`
+}
+
 type openaiChatRequest struct {
-	Model       string    `json:"model"`
-	Messages    []Message `json:"messages"`
-	Temperature *float64  `json:"temperature,omitempty"`
-	MaxTokens   *int      `json:"max_tokens,omitempty"`
-	Stream      bool      `json:"stream"`
+	Model         string               `json:"model"`
+	Messages      []Message            `json:"messages"`
+	Temperature   *float64             `json:"temperature,omitempty"`
+	MaxTokens     *int                 `json:"max_tokens,omitempty"`
+	Stream        bool                 `json:"stream"`
+	StreamOptions *openaiStreamOptions `json:"stream_options,omitempty"`
+}
+
+// openaiStreamChunk is one SSE "data:" payload from a streaming completion.
+type openaiStreamChunk struct {
+	Model   string `json:"model"`
+	Choices []struct {
+		Index int `json:"index"`
+		Delta struct {
+			Role             string `json:"role"`
+			Content          string `json:"content"`
+			ReasoningContent string `json:"reasoning_content"`
+			Reasoning        string `json:"reasoning"`
+		} `json:"delta"`
+		FinishReason string `json:"finish_reason"`
+	} `json:"choices"`
+	Usage *struct {
+		PromptTokens     int64 `json:"prompt_tokens"`
+		CompletionTokens int64 `json:"completion_tokens"`
+		TotalTokens      int64 `json:"total_tokens"`
+	} `json:"usage"`
+	Error *struct {
+		Message string `json:"message"`
+		Type    string `json:"type"`
+	} `json:"error"`
 }
 
 type openaiChatResponse struct {
@@ -25,6 +55,11 @@ type openaiChatResponse struct {
 		Message struct {
 			Role    string `json:"role"`
 			Content string `json:"content"`
+			//Reasoning / "thinking" text, exposed under different keys by
+			//different OpenAI-compatible providers (reasoning_content by
+			//DeepSeek, reasoning by OpenRouter and some local runtimes).
+			ReasoningContent string `json:"reasoning_content"`
+			Reasoning        string `json:"reasoning"`
 		} `json:"message"`
 		FinishReason string `json:"finish_reason"`
 	} `json:"choices"`
@@ -94,6 +129,12 @@ func (c *Client) chatOpenAI(messages []Message, opt ChatOptions) (*ChatResponse,
 		choice := Choice{Index: ch.Index, FinishReason: ch.FinishReason}
 		choice.Message.Role = ch.Message.Role
 		choice.Message.Content = ch.Message.Content
+		//Prefer reasoning_content (DeepSeek) and fall back to reasoning
+		//(OpenRouter) so either provider's thinking output is surfaced.
+		choice.Message.ReasoningContent = ch.Message.ReasoningContent
+		if choice.Message.ReasoningContent == "" {
+			choice.Message.ReasoningContent = ch.Message.Reasoning
+		}
 		out.Choices = append(out.Choices, choice)
 	}
 	out.Usage = Usage{
@@ -104,6 +145,131 @@ func (c *Client) chatOpenAI(messages []Message, opt ChatOptions) (*ChatResponse,
 	return out, nil
 }
 
+// chatOpenAIStream performs a streaming OpenAI-compatible chat completion,
+// invoking cb for each delta and returning the assembled response. It requests
+// usage via stream_options.include_usage so the final chunk carries token
+// counts (endpoints that ignore the field simply yield zero usage).
+func (c *Client) chatOpenAIStream(messages []Message, opt ChatOptions, cb StreamCallback) (*ChatResponse, error) {
+	reqStruct := openaiChatRequest{
+		Model:         opt.Model,
+		Messages:      messages,
+		Temperature:   opt.Temperature,
+		MaxTokens:     opt.MaxTokens,
+		Stream:        true,
+		StreamOptions: &openaiStreamOptions{IncludeUsage: true},
+	}
+	body, err := json.Marshal(reqStruct)
+	if err != nil {
+		return nil, err
+	}
+
+	requestURL := strings.TrimRight(c.Endpoint, "/")
+	if !strings.HasSuffix(requestURL, "/chat/completions") {
+		requestURL += "/chat/completions"
+	}
+	req, err := http.NewRequest("POST", requestURL, bytes.NewReader(body))
+	if err != nil {
+		return nil, err
+	}
+	req.Header.Set("Content-Type", "application/json")
+	req.Header.Set("Accept", "text/event-stream")
+	req.Header.Set("User-Agent", "arozos-llm-client/1.0")
+	if c.APIKey != "" {
+		req.Header.Set("Authorization", "Bearer "+c.APIKey)
+	}
+
+	resp, err := c.httpClient().Do(req)
+	if err != nil {
+		return nil, errors.New("request to AI endpoint failed: " + err.Error())
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+		respBody, _ := io.ReadAll(resp.Body)
+		if msg := openaiErrorMessage(respBody); msg != "" {
+			return nil, errors.New("AI endpoint error: " + msg)
+		}
+		return nil, fmt.Errorf("AI endpoint returned HTTP %d: %s", resp.StatusCode, truncate(string(respBody), 300))
+	}
+
+	var contentB, reasoningB strings.Builder
+	var model, finishReason string
+	var usage Usage
+
+	reader := bufio.NewReader(resp.Body)
+	for {
+		line, readErr := reader.ReadString('\n')
+		if trimmed := strings.TrimRight(line, "\r\n"); strings.HasPrefix(trimmed, "data:") {
+			data := strings.TrimSpace(trimmed[len("data:"):])
+			if data == "" {
+				// keep reading
+			} else if data == "[DONE]" {
+				break
+			} else {
+				var chunk openaiStreamChunk
+				if json.Unmarshal([]byte(data), &chunk) == nil {
+					if chunk.Error != nil && chunk.Error.Message != "" {
+						return nil, errors.New("AI endpoint error: " + chunk.Error.Message)
+					}
+					if chunk.Model != "" {
+						model = chunk.Model
+					}
+					for _, ch := range chunk.Choices {
+						d := StreamDelta{Content: ch.Delta.Content}
+						d.Reasoning = ch.Delta.ReasoningContent
+						if d.Reasoning == "" {
+							d.Reasoning = ch.Delta.Reasoning
+						}
+						if d.Content != "" {
+							contentB.WriteString(d.Content)
+						}
+						if d.Reasoning != "" {
+							reasoningB.WriteString(d.Reasoning)
+						}
+						if ch.FinishReason != "" {
+							finishReason = ch.FinishReason
+						}
+						if cb != nil && (d.Content != "" || d.Reasoning != "") {
+							cb(d)
+						}
+					}
+					if chunk.Usage != nil {
+						usage.PromptTokens = chunk.Usage.PromptTokens
+						usage.CompletionTokens = chunk.Usage.CompletionTokens
+						usage.TotalTokens = chunk.Usage.TotalTokens
+					}
+				}
+			}
+		}
+		if readErr != nil {
+			break
+		}
+	}
+
+	out := &ChatResponse{Model: model}
+	choice := Choice{FinishReason: finishReason}
+	choice.Message.Role = "assistant"
+	choice.Message.Content = contentB.String()
+	choice.Message.ReasoningContent = reasoningB.String()
+	out.Choices = append(out.Choices, choice)
+	out.Usage = usage
+	return out, nil
+}
+
+// openaiErrorMessage extracts the human-readable message from an OpenAI-style
+// error envelope, or returns "" when the body is not such an envelope.
+func openaiErrorMessage(body []byte) string {
+	var env struct {
+		Error *struct {
+			Message string `json:"message"`
+		} `json:"error"`
+	}
+	if json.Unmarshal(body, &env) == nil && env.Error != nil {
+		return env.Error.Message
+	}
+	return ""
+}
+
 // listModelsOpenAI lists model IDs from an OpenAI-compatible /models endpoint.
 func (c *Client) listModelsOpenAI() ([]string, error) {
 	base := strings.TrimRight(c.Endpoint, "/")

+ 132 - 0
src/mod/aiservers/llm/stream_test.go

@@ -0,0 +1,132 @@
+package llm
+
+import (
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+)
+
+// collectDeltas runs a streaming Chat against srv and returns the assembled
+// response plus the ordered content and reasoning pieces seen by the callback.
+func collectDeltas(t *testing.T, srv *httptest.Server, format string) (*ChatResponse, []string, []string) {
+	t.Helper()
+	var contentSeen, reasoningSeen []string
+	c := NewClient(srv.URL, "k", format, 0)
+	resp, err := c.ChatStream([]Message{{Role: "user", Content: "hi"}}, ChatOptions{Model: "m"}, func(d StreamDelta) {
+		if d.Content != "" {
+			contentSeen = append(contentSeen, d.Content)
+		}
+		if d.Reasoning != "" {
+			reasoningSeen = append(reasoningSeen, d.Reasoning)
+		}
+	})
+	if err != nil {
+		t.Fatalf("ChatStream error: %v", err)
+	}
+	return resp, contentSeen, reasoningSeen
+}
+
+func TestChatStreamOpenAI(t *testing.T) {
+	var gotStream, gotIncludeUsage bool
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		body, _ := io.ReadAll(r.Body)
+		gotStream = strings.Contains(string(body), `"stream":true`)
+		gotIncludeUsage = strings.Contains(string(body), `"include_usage":true`)
+		w.Header().Set("Content-Type", "text/event-stream")
+		io.WriteString(w, "data: {\"model\":\"m\",\"choices\":[{\"delta\":{\"reasoning_content\":\"think \"}}]}\n\n")
+		io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"more\"}}]}\n\n")
+		io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"Hel\"}}]}\n\n")
+		io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"lo\"},\"finish_reason\":\"stop\"}]}\n\n")
+		io.WriteString(w, "data: {\"choices\":[],\"usage\":{\"prompt_tokens\":7,\"completion_tokens\":2,\"total_tokens\":9}}\n\n")
+		io.WriteString(w, "data: [DONE]\n\n")
+	}))
+	defer srv.Close()
+
+	resp, content, reasoning := collectDeltas(t, srv, "openai")
+
+	if !gotStream {
+		t.Error("request did not set stream:true")
+	}
+	if !gotIncludeUsage {
+		t.Error("request did not ask for usage via stream_options.include_usage")
+	}
+	if got := strings.Join(content, ""); got != "Hello" {
+		t.Errorf("assembled content = %q, want Hello", got)
+	}
+	if len(content) != 2 {
+		t.Errorf("expected 2 content deltas, got %d (%v)", len(content), content)
+	}
+	if got := strings.Join(reasoning, ""); got != "think more" {
+		t.Errorf("assembled reasoning = %q, want 'think more'", got)
+	}
+	if resp.Choices[0].Message.Content != "Hello" || resp.Choices[0].Message.ReasoningContent != "think more" {
+		t.Errorf("final message wrong: %+v", resp.Choices[0].Message)
+	}
+	if resp.Usage.TotalTokens != 9 {
+		t.Errorf("usage not captured from final chunk: %+v", resp.Usage)
+	}
+}
+
+func TestChatStreamOpenAIErrorStatus(t *testing.T) {
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusUnauthorized)
+		io.WriteString(w, `{"error":{"message":"bad key","type":"auth"}}`)
+	}))
+	defer srv.Close()
+
+	c := NewClient(srv.URL, "bad", "openai", 0)
+	_, err := c.ChatStream([]Message{{Role: "user", Content: "hi"}}, ChatOptions{Model: "m"}, nil)
+	if err == nil || !strings.Contains(err.Error(), "bad key") {
+		t.Fatalf("expected the endpoint error to surface, got: %v", err)
+	}
+}
+
+func TestChatStreamAnthropic(t *testing.T) {
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		body, _ := io.ReadAll(r.Body)
+		if !strings.Contains(string(body), `"stream":true`) {
+			t.Error("anthropic stream request did not set stream:true")
+		}
+		w.Header().Set("Content-Type", "text/event-stream")
+		io.WriteString(w, "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-x\",\"usage\":{\"input_tokens\":11,\"output_tokens\":0}}}\n\n")
+		io.WriteString(w, "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"reason \"}}\n\n")
+		io.WriteString(w, "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"bit\"}}\n\n")
+		io.WriteString(w, "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"Hi \"}}\n\n")
+		io.WriteString(w, "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"there\"}}\n\n")
+		io.WriteString(w, "event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":4}}\n\n")
+		io.WriteString(w, "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n")
+	}))
+	defer srv.Close()
+
+	resp, content, reasoning := collectDeltas(t, srv, "anthropic")
+
+	if got := strings.Join(content, ""); got != "Hi there" {
+		t.Errorf("assembled content = %q, want 'Hi there'", got)
+	}
+	if got := strings.Join(reasoning, ""); got != "reason bit" {
+		t.Errorf("assembled reasoning = %q, want 'reason bit'", got)
+	}
+	if resp.Choices[0].Message.Content != "Hi there" || resp.Choices[0].Message.ReasoningContent != "reason bit" {
+		t.Errorf("final message wrong: %+v", resp.Choices[0].Message)
+	}
+	//Usage spans message_start (input) and message_delta (output).
+	if resp.Usage.PromptTokens != 11 || resp.Usage.CompletionTokens != 4 || resp.Usage.TotalTokens != 15 {
+		t.Errorf("usage not assembled correctly: %+v", resp.Usage)
+	}
+}
+
+func TestChatStreamAnthropicErrorEvent(t *testing.T) {
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Content-Type", "text/event-stream")
+		io.WriteString(w, "event: error\ndata: {\"type\":\"error\",\"error\":{\"type\":\"overloaded_error\",\"message\":\"overloaded\"}}\n\n")
+	}))
+	defer srv.Close()
+
+	c := NewClient(srv.URL, "k", "anthropic", 0)
+	_, err := c.ChatStream([]Message{{Role: "user", Content: "hi"}}, ChatOptions{Model: "m"}, nil)
+	if err == nil || !strings.Contains(err.Error(), "overloaded") {
+		t.Fatalf("expected the streamed error event to surface, got: %v", err)
+	}
+}

+ 19 - 0
src/mod/aiservers/llm/types.go

@@ -31,6 +31,20 @@ type ChatOptions struct {
 	MaxTokens   *int     //Maximum tokens to generate
 }
 
+// StreamDelta is one incremental chunk emitted during a streaming completion.
+// Content carries newly generated answer text; Reasoning carries newly
+// generated chain-of-thought text. Either (or, rarely, both) may be non-empty
+// for a given delta.
+type StreamDelta struct {
+	Content   string
+	Reasoning string
+}
+
+// StreamCallback receives each StreamDelta as it arrives. It runs on the same
+// goroutine that called Client.ChatStream, so implementations must not block
+// for long and need no locking of their own.
+type StreamCallback func(StreamDelta)
+
 // Usage is the token usage / timing for one completion.
 type Usage struct {
 	PromptTokens     int64   `json:"prompt_tokens"`
@@ -46,6 +60,11 @@ type Choice struct {
 	Message struct {
 		Role    string `json:"role"`
 		Content string `json:"content"`
+		//ReasoningContent holds the model's chain-of-thought / "thinking"
+		//output when the endpoint returns it as a separate field (DeepSeek's
+		//reasoning_content, OpenRouter's reasoning) or as Anthropic thinking
+		//content blocks. Empty when the model does not expose reasoning.
+		ReasoningContent string `json:"reasoning_content,omitempty"`
 	} `json:"message"`
 	FinishReason string `json:"finish_reason"`
 }

+ 8 - 1
src/web/AIChat/backend/chat.agi

@@ -9,8 +9,12 @@
 	               latest user message (images for vision, text documents inlined)
 
 	Response (JSON):
-	    { ok:true,  content:"...", usage:{...}, model:"..." }
+	    { ok:true,  content:"...", reasoning:"...", usage:{...}, model:"..." }
 	    { ok:false, error:"..." }
+
+	"reasoning" carries the model's chain-of-thought / "thinking" output when
+	the endpoint exposes it separately (DeepSeek reasoning_content, OpenRouter
+	reasoning, Anthropic thinking blocks); it is an empty string otherwise.
 */
 
 requirelib("llm");
@@ -71,12 +75,15 @@ if (!handled) {
 		try {
 			var res = llm.request(finalMessages, opts);
 			var replyText = "";
+			var reasoning = "";
 			if (res && res.choices && res.choices.length > 0 && res.choices[0].message) {
 				replyText = res.choices[0].message.content;
+				reasoning = res.choices[0].message.reasoning_content || "";
 			}
 			sendJSONResp(JSON.stringify({
 				ok: true,
 				content: replyText,
+				reasoning: reasoning,
 				usage: (res && res.usage) ? res.usage : null,
 				model: (res && res.model) ? res.model : (opts.model || "")
 			}));

+ 122 - 0
src/web/AIChat/backend/chat_stream.agi

@@ -0,0 +1,122 @@
+/*
+	AI Chat — streaming chat backend (WebSocket)
+	Relays a conversation to the configured AI endpoint via llm.streamRequest()
+	and forwards each token to the browser as it is generated, so both the
+	answer and the model's "thinking" appear in real time.
+
+	Connect a WebSocket to:
+	    /system/ajgi/interface?script=AIChat/backend/chat_stream.agi
+
+	The first frame the client sends is the request (JSON):
+	    { messages:[{role,content}...], options:{model,system,temperature,max_tokens}, files:[vpath...] }
+
+	Frames sent back to the client (JSON, one "type" each):
+	    { type:"start" }                                    generation began
+	    { type:"reasoning", content:"..." }                 incremental thinking
+	    { type:"delta",     content:"..." }                 incremental answer
+	    { type:"done", content, reasoning, usage, model }   final assembled reply
+	    { type:"error", error:"..." }                       failure (terminal)
+
+	Falls back is unnecessary: if the socket cannot be opened the frontend uses
+	the non-streaming backend/chat.agi instead.
+*/
+
+requirelib("llm");
+requirelib("websocket");
+
+if (!websocket.upgrade(1800)) {
+	sendResp("WebSocket upgrade failed");
+	exit();
+}
+
+//The first frame carries the request payload.
+var raw = websocket.read(30000);
+if (raw === false || raw === null) {
+	websocket.close();
+	exit();
+}
+
+var req;
+try {
+	req = JSON.parse(raw);
+} catch (e) {
+	websocket.send(JSON.stringify({ type: "error", error: "invalid request" }));
+	websocket.close();
+	exit();
+}
+
+var msgs = req.messages || [];
+var opts = req.options || {};
+var fileList = req.files || [];
+
+//Build the final message list (system prompt + turns), same shape as chat.agi.
+var finalMessages = [];
+if (opts.system && ("" + opts.system).trim() != "") {
+	finalMessages.push({ role: "system", content: "" + opts.system });
+}
+for (var i = 0; i < msgs.length; i++) {
+	if (msgs[i] && msgs[i].role && typeof msgs[i].content != "undefined") {
+		finalMessages.push({ role: msgs[i].role, content: msgs[i].content });
+	}
+}
+
+//Merge any attached files into the latest user message (multimodal content).
+if (fileList.length > 0 && finalMessages.length > 0) {
+	try {
+		var parts = llm.fileParts(fileList);
+		var lastUserIdx = -1;
+		for (var j = finalMessages.length - 1; j >= 0; j--) {
+			if (finalMessages[j].role == "user") { lastUserIdx = j; break; }
+		}
+		if (lastUserIdx >= 0) {
+			var content = [];
+			var existing = finalMessages[lastUserIdx].content;
+			if (existing && ("" + existing).trim() != "") {
+				content.push({ type: "text", text: "" + existing });
+			}
+			for (var k = 0; k < parts.length; k++) { content.push(parts[k]); }
+			finalMessages[lastUserIdx].content = content;
+		}
+	} catch (e) {
+		websocket.send(JSON.stringify({ type: "error", error: "" + e }));
+		websocket.close();
+		exit();
+	}
+}
+
+if (finalMessages.length == 0) {
+	websocket.send(JSON.stringify({ type: "error", error: "No messages to send" }));
+	websocket.close();
+	exit();
+}
+
+//Stream the completion, relaying each delta to the browser as it arrives.
+websocket.send(JSON.stringify({ type: "start" }));
+try {
+	var res = llm.streamRequest(finalMessages, opts, function (evt) {
+		if (evt.reasoning && evt.reasoning !== "") {
+			websocket.send(JSON.stringify({ type: "reasoning", content: evt.reasoning }));
+		}
+		if (evt.content && evt.content !== "") {
+			websocket.send(JSON.stringify({ type: "delta", content: evt.content }));
+		}
+	});
+
+	var replyText = "";
+	var reasoning = "";
+	if (res && res.choices && res.choices.length > 0 && res.choices[0].message) {
+		replyText = res.choices[0].message.content;
+		reasoning = res.choices[0].message.reasoning_content || "";
+	}
+	websocket.send(JSON.stringify({
+		type: "done",
+		content: replyText,
+		reasoning: reasoning,
+		usage: (res && res.usage) ? res.usage : null,
+		model: (res && res.model) ? res.model : (opts.model || "")
+	}));
+} catch (e) {
+	websocket.send(JSON.stringify({ type: "error", error: "" + e }));
+}
+
+websocket.close();

+ 591 - 29
src/web/AIChat/index.html

@@ -29,12 +29,15 @@
         background:var(--bg); color:var(--text); overflow:hidden;
         -webkit-font-smoothing:antialiased;
     }
-    .app{display:grid; grid-template-columns:268px 1fr; height:100vh; height:100dvh;}
+    /* grid-template-rows: clamp the single row to the viewport (minmax(0,1fr))
+       so the columns' inner scroll areas shrink instead of the whole app
+       growing past 100dvh and showing a page scrollbar. */
+    .app{display:grid; grid-template-columns:268px 1fr; grid-template-rows:minmax(0,1fr); height:100vh; height:100dvh;}
 
     /* ── Sidebar ───────────────────────────── */
     .sidebar{
         background:var(--panel); border-right:1px solid var(--border);
-        display:flex; flex-direction:column; min-width:0;
+        display:flex; flex-direction:column; min-width:0; min-height:0;
     }
     .sb-head{display:flex; align-items:center; gap:10px; padding:16px 16px 12px;}
     .sb-head img{width:30px;height:30px;border-radius:8px;}
@@ -48,7 +51,7 @@
     }
     .new-chat:hover{background:var(--panel-3); border-color:var(--accent);}
     .new-chat svg{width:16px;height:16px;}
-    .chat-list{flex:1; overflow-y:auto; padding:4px 8px 8px;}
+    .chat-list{flex:1; min-height:0; overflow-y:auto; padding:4px 8px 8px;}
     .chat-item{
         position:relative; padding:9px 30px 9px 11px; border-radius:9px; cursor:pointer;
         color:var(--text-dim); font-size:13px; margin-bottom:2px; white-space:nowrap;
@@ -186,6 +189,55 @@
         border-radius:8px;padding:4px 9px;font-size:11.5px;color:var(--text-dim);}
     .msg-files .mf svg{width:13px;height:13px;color:var(--accent);}
 
+    /* Thinking / reasoning disclosure (collapsed by default) */
+    .thinking{margin:0 0 10px;border:1px solid var(--border);border-radius:10px;background:var(--panel-2);overflow:hidden;}
+    .thinking-toggle{display:flex;align-items:center;gap:8px;width:100%;background:transparent;border:none;cursor:pointer;
+        color:var(--text-dim);font-size:12.5px;font-weight:600;font-family:inherit;padding:9px 12px;text-align:left;transition:.12s;}
+    .thinking-toggle:hover{color:var(--text);background:var(--panel-3);}
+    .thinking-toggle .tk-spark{width:15px;height:15px;flex:none;color:var(--accent);}
+    .thinking-toggle .chev{width:14px;height:14px;flex:none;margin-left:auto;transition:transform .18s;}
+    .thinking.open .thinking-toggle .chev{transform:rotate(90deg);}
+    .thinking-body{display:none;padding:4px 13px 12px;font-size:13px;line-height:1.62;color:var(--text-dim);
+        border-top:1px solid var(--border);}
+    .thinking.open .thinking-body{display:block;}
+    .thinking-body p{margin:8px 0;} .thinking-body code.inline{background:var(--panel-3);}
+    .thinking-body pre{white-space:pre-wrap;}
+
+    /* Attach menu — pick from ArozOS files or upload from this computer */
+    .attach-wrap{position:relative;flex:none;display:flex;}
+    .attach-menu{position:absolute;bottom:46px;left:0;z-index:15;min-width:224px;background:var(--panel);
+        border:1px solid var(--border);border-radius:12px;box-shadow:var(--shadow);padding:6px;display:none;}
+    .attach-menu.open{display:block;}
+    .attach-menu button{display:flex;align-items:center;gap:11px;width:100%;background:transparent;border:none;cursor:pointer;
+        color:var(--text);font-size:13px;font-weight:600;font-family:inherit;padding:9px 10px;border-radius:8px;text-align:left;transition:.12s;}
+    .attach-menu button:hover{background:var(--panel-2);}
+    .attach-menu button svg{width:18px;height:18px;flex:none;color:var(--accent);}
+    .attach-menu button small{display:block;color:var(--text-faint);font-size:11px;font-weight:400;margin-top:1px;}
+
+    /* Live streaming affordances */
+    .thinking.live .thinking-toggle .tk-label{color:var(--accent);}
+    .thinking.live .tk-spark{animation:tk-pulse 1.4s ease-in-out infinite;}
+    @keyframes tk-pulse{0%,100%{opacity:1;}50%{opacity:.35;}}
+    .bubble .caret{display:inline-block;width:7px;height:14px;margin-left:2px;background:var(--accent);
+        vertical-align:-2px;animation:caret-blink .9s step-end infinite;}
+    @keyframes caret-blink{0%,100%{opacity:1;}50%{opacity:0;}}
+    .thinking-body.streaming{white-space:pre-wrap;}
+
+    /* Attachment chip upload state */
+    .attach-chip.uploading{opacity:.85;}
+    .attach-chip.failed{border-color:var(--danger);color:var(--danger);}
+    .attach-chip .spin{width:13px;height:13px;flex:none;border:2px solid var(--border);border-top-color:var(--accent);
+        border-radius:50%;animation:aichat-spin .7s linear infinite;}
+    @keyframes aichat-spin{to{transform:rotate(360deg);}}
+
+    /* Self-contained toast (does not depend on the desktop shell) */
+    .toast-wrap{position:absolute;left:50%;bottom:96px;transform:translateX(-50%);z-index:60;display:flex;flex-direction:column;
+        gap:8px;align-items:center;pointer-events:none;}
+    .toast{background:var(--panel-3);color:var(--text);border:1px solid var(--border);border-radius:10px;padding:10px 15px;
+        font-size:13px;box-shadow:var(--shadow);max-width:80vw;opacity:0;transform:translateY(8px);transition:.2s;}
+    .toast.show{opacity:1;transform:translateY(0);}
+    .toast.err{border-color:var(--danger);color:var(--danger);}
+
     /* Settings drawer */
     .scrim{position:absolute;inset:0;background:rgba(0,0,0,.45);opacity:0;pointer-events:none;transition:.2s;z-index:20;}
     .scrim.open{opacity:1;pointer-events:auto;}
@@ -303,9 +355,22 @@
             <div class="attachments" id="attachments" style="display:none;"></div>
             <div class="composer">
                 <div class="composer-box">
-                    <button class="attach-btn" id="attachBtn" onclick="pickFiles()" title="Attach file (image or text document)">
-                        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
-                    </button>
+                    <div class="attach-wrap">
+                        <button class="attach-btn" id="attachBtn" onclick="toggleAttachMenu(event)" title="Attach an image or text document">
+                            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
+                        </button>
+                        <div class="attach-menu" id="attachMenu">
+                            <button type="button" onclick="pickFiles()">
+                                <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 5h5l2 3h7a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2z"/></svg>
+                                <span>ArozOS files<small>Pick from your cloud drive</small></span>
+                            </button>
+                            <button type="button" onclick="pickLocalFiles()">
+                                <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 16V4M7 9l5-5 5 5M4 20h16"/></svg>
+                                <span>Upload from computer<small>Send a local image or document</small></span>
+                            </button>
+                        </div>
+                        <input type="file" id="localFileInput" multiple style="display:none" onchange="onLocalFilesChosen(this)">
+                    </div>
                     <textarea id="composer" rows="1" placeholder="Send a message…"></textarea>
                     <button class="stop-btn" id="stopBtn" onclick="stopGenerating()">
                         <svg viewBox="0 0 24 24" width="13" height="13" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg> Stop
@@ -315,7 +380,7 @@
                     </button>
                 </div>
             </div>
-            <div class="hint">Enter to send · Shift+Enter for a new line · Attach images for vision models or text documents · Configure the endpoint in System Settings → AI Integration → AI Model</div>
+            <div class="hint">Enter to send · Shift+Enter for a new line · Attach images, PDFs or text documents from your ArozOS drive or your computer · Configure the endpoint in System Settings → AI Integration → AI Model</div>
         </div>
 
         <!-- Settings drawer -->
@@ -354,6 +419,9 @@
                 </div>
             </div>
         </div>
+
+        <!-- Transient notifications (self-contained, no desktop dependency) -->
+        <div class="toast-wrap" id="toastWrap"></div>
     </section>
 
     <!-- Backdrop for the mobile off-canvas sidebar -->
@@ -370,7 +438,18 @@ var state = { conversations: [], currentId: null,
 
 var generating = false, revealTimer = null, cancelReveal = false;
 var availableModels = [];          // models known from the backend
-var pendingFiles = [];             // [{filepath, filename}] attached to the next message
+// Files staged for the next message. Each entry is
+// {filename, filepath, paths[], uploading?, failed?, note?} — `paths` holds the
+// virtual path(s) actually sent to the model (a PDF expands to one path per
+// rendered page); uploading/failed flag transient conversion/upload state.
+var pendingFiles = [];
+var LOCAL_UPLOAD_DIR = "tmp:/AIChat/uploads";   // scratch dir for uploads + rendered PDF pages (tmp:/ is auto-cleared)
+var PDF_MAX_PAGES = 10;        // cap pages per PDF — vision tokens add up fast
+var PDF_RENDER_SCALE = 2.0;    // render scale; 2x keeps small print legible
+var PDF_JPEG_QUALITY = 0.85;
+
+var activeSocket = null;       // live streaming WebSocket, if any
+var streamState = null;        // handles for the in-flight streaming reply
 
 function uid(){ return Date.now().toString(36) + Math.random().toString(36).slice(2,7); }
 function persist(){ try{ localStorage.setItem(STORE_KEY, JSON.stringify(state)); }catch(e){} }
@@ -438,7 +517,7 @@ function renderModelOptions(){
     if(list.length === 0){
         html = '<option value="" disabled'+(cur?'':' selected')+'>No models — pick Custom…</option>';
     }
-    html += '<option value="__custom__">✏️ Custom model…</option>';
+    html += '<option value="__custom__">+ Custom model…</option>';
     sel.innerHTML = html;
     sel.value = cur || (list[0] || "__custom__");
 }
@@ -523,13 +602,25 @@ function buildMessage(m, index){
     if(isUser && m.files && m.files.length){
         var fb = $('<div class="msg-files"></div>').appendTo(main);
         m.files.forEach(function(f){
-            $('<span class="mf"></span>').html(fileIconSvg()).append($('<span></span>').text(f.filename)).attr("title", f.filepath).appendTo(fb);
+            // A PDF was sent as several rendered page images — say so.
+            var n = (f.paths && f.paths.length) ? f.paths.length : 1;
+            var label = f.filename + (n > 1 ? (" · " + n + " pages") : "");
+            $('<span class="mf"></span>').html(fileIconSvg()).append($('<span></span>').text(label)).attr("title", f.filepath).appendTo(fb);
         });
     }
+    // For assistant messages, separate the model's "thinking" from the answer.
+    // The thinking goes in a collapsed disclosure above the reply.
+    var answer = isUser ? m.content : "";
+    if(!isUser && !m.error){
+        var parts = computeThinking(m);
+        answer = parts.answer;
+        if(parts.thinkingText.trim() !== ""){ main.append(buildThinking(parts.thinkingText)); }
+    }
+
     var bubble = $('<div class="bubble"></div>').appendTo(main);
     if(m.error){ bubble.html('<div class="err">'+esc(m.content)+'</div>'); }
     else if(isUser){ bubble.text(m.content); }
-    else { bubble.html(renderMarkdown(m.content)); }
+    else { bubble.html(renderMarkdown(answer)); }
 
     if(!isUser && !m.error){
         var foot = $('<div class="msg-footer" style="display:flex;"></div>').appendTo(main);
@@ -539,7 +630,7 @@ function buildMessage(m, index){
             if(m.usage.tokens_per_second > 0){ u += "  · " + m.usage.tokens_per_second.toFixed(1) + " tok/s"; }
             $('<span class="usage"></span>').text(u).appendTo(foot);
         }
-        $('<button class="mini-btn">⧉ Copy</button>').on("click", function(){ copyText(m.content, this); }).appendTo(foot);
+        $('<button class="mini-btn">⧉ Copy</button>').on("click", function(){ copyText(answer, this); }).appendTo(foot);
         $('<button class="mini-btn">↻ Regenerate</button>').on("click", function(){ regenerateFrom(index); }).appendTo(foot);
     }
     row.data("index", index);
@@ -567,14 +658,82 @@ function emptyState(){
 }
 
 /* ─────────────────────────────────────────────────────────────
-   File attachments (via the ArozOS file selector)
+   Thinking / reasoning
+   Reasoning models expose their chain-of-thought either inline as
+   <think>…</think> tags (many local models) or via a separate field the
+   backend forwards as `reasoning` (DeepSeek reasoning_content, OpenRouter
+   reasoning, Anthropic thinking blocks). Both are pulled out of the reply and
+   shown in a disclosure that stays collapsed until the user opens it.
+   ───────────────────────────────────────────────────────────── */
+// splitThinking pulls inline <think>/<thinking> blocks out of the content,
+// returning { thinking, answer }.
+function splitThinking(content){
+    content = String(content == null ? "" : content);
+    var thinking = [];
+    var answer = content.replace(/<(think|thinking)>([\s\S]*?)<\/\1>/gi, function(_, tag, inner){
+        thinking.push(inner.trim());
+        return "";
+    });
+    // A dangling opening tag (reply cut off mid-thought): treat the rest as thinking.
+    var open = answer.match(/<(think|thinking)>([\s\S]*)$/i);
+    if(open){
+        thinking.push(open[2].trim());
+        answer = answer.slice(0, open.index);
+    }
+    return { thinking: thinking.join("\n\n").trim(), answer: answer.trim() };
+}
+
+// computeThinking combines the backend `reasoning` field with any inline
+// think-tag reasoning and returns { thinkingText, answer } for a message.
+function computeThinking(m){
+    var parts = splitThinking(m.content || "");
+    var pieces = [];
+    if(m.reasoning && String(m.reasoning).trim() !== ""){ pieces.push(String(m.reasoning).trim()); }
+    if(parts.thinking !== ""){ pieces.push(parts.thinking); }
+    return { thinkingText: pieces.join("\n\n"), answer: parts.answer };
+}
+
+// buildThinking renders the collapsible "thinking" disclosure (collapsed by
+// default; the header button toggles it open).
+function buildThinking(text){
+    var wrap = $('<div class="thinking"></div>');
+    var head = $('<button class="thinking-toggle" type="button"></button>');
+    head.html(
+        '<svg class="tk-spark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' +
+            '<path d="M9 18h6"/><path d="M10 22h4"/>' +
+            '<path d="M12 2a7 7 0 0 0-4 12.7c.6.5.9 1.1 1 1.8l.1.5h5.8l.1-.5c.1-.7.4-1.3 1-1.8A7 7 0 0 0 12 2z"/>' +
+        '</svg>' +
+        '<span class="tk-label">Show thinking</span>' +
+        '<svg class="chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 6l6 6-6 6"/></svg>'
+    );
+    var body = $('<div class="thinking-body"></div>').html(renderMarkdown(text));
+    head.on("click", function(){
+        var open = wrap.toggleClass("open").hasClass("open");
+        head.find(".tk-label").text(open ? "Hide thinking" : "Show thinking");
+    });
+    wrap.append(head).append(body);
+    return wrap;
+}
+
+/* ─────────────────────────────────────────────────────────────
+   File attachments (via the ArozOS file selector or a local upload)
    ───────────────────────────────────────────────────────────── */
 function fileIconSvg(){
     return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/></svg>';
 }
+// Toggle / close the attach menu (ArozOS files vs. upload from computer).
+function toggleAttachMenu(ev){ if(ev){ ev.stopPropagation(); } $("#attachMenu").toggleClass("open"); }
+function closeAttachMenu(){ $("#attachMenu").removeClass("open"); }
+// Dismiss the menu on any outside click.
+$(document).on("click", function(ev){
+    if($(ev.target).closest(".attach-wrap").length === 0){ closeAttachMenu(); }
+});
+
+// Attach files already living in the user's ArozOS file system.
 function pickFiles(){
+    closeAttachMenu();
     if(typeof ao_module_openFileSelector !== "function"){
-        showToast("File selector is only available inside the ArozOS desktop.", "red");
+        notify('The ArozOS file picker is only available on the ArozOS desktop. Use "Upload from computer" instead.', true);
         return;
     }
     // The callback is referenced by name in virtual-desktop mode, so it must be
@@ -587,12 +746,163 @@ function aichatFileSelected(files){
     files.forEach(function(f){
         var fp = f.filepath || f.path || "";
         var fn = f.filename || (fp ? fp.split("/").pop() : "file");
-        if(fp && !pendingFiles.some(function(p){ return p.filepath === fp; })){
-            pendingFiles.push({ filepath: fp, filename: fn });
+        if(!fp || pendingFiles.some(function(p){ return p.srcPath === fp || p.filepath === fp; })) return;
+        if(isPdfName(fn)){
+            // Rasterise the PDF (read through /media) into attachable JPEG pages.
+            var entry = { filename: fn, filepath: "", srcPath: fp, paths: [], uploading: true, failed: false, note: "reading…" };
+            pendingFiles.push(entry); renderAttachments();
+            stagePdf("/media?file=" + encodeURIComponent(fp), fn, entry);
+        } else {
+            pendingFiles.push({ filepath: fp, filename: fn, srcPath: fp, paths: [fp] });
         }
     });
     renderAttachments();
 }
+
+/* ── PDF support ───────────────────────────────────────────────────────────
+   Vision models take images, not PDFs, so a PDF is rasterised to JPEG pages in
+   the browser with the pdf.js already bundled with the PDF Viewer app, and the
+   pages are uploaded and attached in its place. No server-side or system
+   dependency (no poppler/ghostscript) is involved. */
+var pdfJsReady = false;
+function isPdfName(name){ return /\.pdf$/i.test(name || ""); }
+
+function loadPdfJs(onReady, onError){
+    if(pdfJsReady){ onReady(); return; }
+    if(typeof pdfjsLib !== "undefined"){
+        pdfjsLib.GlobalWorkerOptions.workerSrc = "../PDF Viewer/js/pdf.worker.js";
+        pdfJsReady = true; onReady(); return;
+    }
+    var s = document.createElement("script");
+    s.src = "../PDF Viewer/js/pdf.js";
+    s.onload = function(){
+        pdfjsLib.GlobalWorkerOptions.workerSrc = "../PDF Viewer/js/pdf.worker.js";
+        pdfJsReady = true; onReady();
+    };
+    s.onerror = function(){ onError(new Error("Could not load the PDF library")); };
+    document.head.appendChild(s);
+}
+
+// renderPdfToImages loads a PDF (from an ArrayBuffer or a /media URL), renders
+// up to PDF_MAX_PAGES pages to JPEG, uploads them and reports the vpaths.
+function renderPdfToImages(source, baseName, entry, onDone, onError){
+    loadPdfJs(function(){
+        var task;
+        try { task = pdfjsLib.getDocument(source); }
+        catch(e){ onError(e); return; }
+        task.promise.then(function(pdf){
+            var total = pdf.numPages;
+            var pages = Math.min(total, PDF_MAX_PAGES);
+            var paths = [];
+            var stamp = Date.now().toString(36) + Math.random().toString(36).slice(2,5);
+
+            function next(i){
+                if(i > pages){ onDone(paths, total, pages); return; }
+                entry.note = "page " + i + "/" + pages;
+                renderAttachments();
+                pdf.getPage(i).then(function(page){
+                    var vp = page.getViewport({ scale: PDF_RENDER_SCALE });
+                    var canvas = document.createElement("canvas");
+                    canvas.width = vp.width; canvas.height = vp.height;
+                    var ctx = canvas.getContext("2d");
+                    // JPEG has no alpha — paint white so text stays readable.
+                    ctx.fillStyle = "#FFF"; ctx.fillRect(0, 0, vp.width, vp.height);
+                    page.render({ canvasContext: ctx, viewport: vp }).promise.then(function(){
+                        canvas.toBlob(function(blob){
+                            if(!blob){ onError(new Error("Could not encode page " + i)); return; }
+                            var fname = stamp + "_" + baseName + "_p" + i + ".jpg";
+                            ao_module_uploadFile(new File([blob], fname, { type:"image/jpeg" }), LOCAL_UPLOAD_DIR, function(){
+                                paths.push(LOCAL_UPLOAD_DIR + "/" + fname);
+                                next(i + 1);
+                            }, undefined, function(status){
+                                onError(new Error("Upload failed for page " + i + (status ? " (HTTP " + status + ")" : "")));
+                            });
+                        }, "image/jpeg", PDF_JPEG_QUALITY);
+                    }, onError);
+                }, onError);
+            }
+            next(1);
+        }, onError);
+    }, onError);
+}
+
+// stagePdf converts a PDF into image pages and finishes the pending entry.
+// `source` is an ArrayBuffer (local file) or a /media URL (ArozOS file).
+function stagePdf(source, displayName, entry){
+    var base = displayName.replace(/\.pdf$/i, "").replace(/[^A-Za-z0-9_-]+/g, "_").slice(0, 40) || "document";
+    renderPdfToImages(source, base, entry, function(paths, total, rendered){
+        entry.uploading = false;
+        entry.paths = paths;
+        entry.filepath = paths[0] || "";
+        entry.note = rendered + (rendered === 1 ? " page" : " pages");
+        renderAttachments();
+        if(total > rendered){
+            notify('"' + displayName + '" has ' + total + " pages — only the first " + rendered + " were attached.", false);
+        }
+    }, function(err){
+        entry.uploading = false; entry.failed = true; entry.note = "";
+        renderAttachments();
+        notify('Could not read "' + displayName + '": ' + ((err && err.message) ? err.message : err), true);
+    });
+}
+
+// Attach files from the user's own computer: open the OS file dialog, upload
+// each pick into a scratch folder in the user's ArozOS storage, then stage the
+// resulting virtual path just like an ArozOS-picked file.
+function pickLocalFiles(){
+    closeAttachMenu();
+    var input = document.getElementById("localFileInput");
+    input.value = "";          // allow re-picking the same file
+    input.click();
+}
+function onLocalFilesChosen(input){
+    var files = input.files;
+    if(!files || !files.length) return;
+    for(var i = 0; i < files.length; i++){ uploadLocalFile(files[i]); }
+    input.value = "";
+}
+function uploadLocalFile(file){
+    if(typeof ao_module_uploadFile !== "function"){
+        notify("File upload is not available in this context.", true);
+        return;
+    }
+    // Stage a chip in the uploading state right away for immediate feedback.
+    var entry = { filepath: "", filename: file.name, paths: [], uploading: true, failed: false, note: "" };
+    pendingFiles.push(entry);
+    renderAttachments();
+
+    // A PDF is converted to JPEG pages in the browser instead of being sent
+    // as-is (models only accept images / text documents).
+    if(isPdfName(file.name)){
+        entry.note = "reading…"; renderAttachments();
+        var reader = new FileReader();
+        reader.onload = function(){ stagePdf({ data: new Uint8Array(reader.result) }, file.name, entry); };
+        reader.onerror = function(){
+            entry.uploading = false; entry.failed = true; entry.note = "";
+            renderAttachments();
+            notify('Could not read "' + file.name + '".', true);
+        };
+        reader.readAsArrayBuffer(file);
+        return;
+    }
+
+    // Store under a unique name so same-named uploads don't clobber each other.
+    var safe = (file.name || "file").replace(/[\/\\]/g, "_");
+    var storeName = Date.now().toString(36) + Math.random().toString(36).slice(2,5) + "_" + safe;
+    var toUpload = new File([file], storeName, { type: file.type });
+
+    ao_module_uploadFile(toUpload, LOCAL_UPLOAD_DIR, function(){
+        entry.uploading = false;
+        entry.filepath = LOCAL_UPLOAD_DIR + "/" + storeName;
+        entry.paths = [entry.filepath];
+        renderAttachments();
+    }, undefined, function(status){
+        entry.uploading = false; entry.failed = true;
+        renderAttachments();
+        notify('Upload failed for "' + file.name + '"' + (status ? " (HTTP " + status + ")" : "") + ".", true);
+    });
+}
+
 function removeAttachment(i){ pendingFiles.splice(i,1); renderAttachments(); }
 function renderAttachments(){
     var box = $("#attachments");
@@ -600,13 +910,35 @@ function renderAttachments(){
     box.empty().show();
     pendingFiles.forEach(function(f, i){
         var chip = $('<div class="attach-chip"></div>');
-        chip.append(fileIconSvg());
-        chip.append($('<span class="fn"></span>').text(f.filename).attr("title", f.filepath));
+        if(f.uploading){ chip.addClass("uploading"); }
+        if(f.failed){ chip.addClass("failed"); }
+        chip.append(f.uploading ? '<span class="spin"></span>' : fileIconSvg());
+        var suffix = "";
+        if(f.uploading){ suffix = " · " + (f.note || "uploading…"); }
+        else if(f.failed){ suffix = " · failed"; }
+        else if(f.note){ suffix = " · " + f.note; }
+        chip.append($('<span class="fn"></span>').text(f.filename + suffix).attr("title", f.filepath || f.filename));
         $('<span class="rm">✕</span>').on("click", function(){ removeAttachment(i); }).appendTo(chip);
         box.append(chip);
     });
 }
 
+// Lightweight, self-contained toast (the desktop shell's showToast is not
+// reachable from inside this iframe).
+function notify(msg, isError){
+    var wrap = document.getElementById("toastWrap");
+    if(!wrap){ return; }
+    var el = document.createElement("div");
+    el.className = "toast" + (isError ? " err" : "");
+    el.textContent = msg;
+    wrap.appendChild(el);
+    requestAnimationFrame(function(){ el.classList.add("show"); });
+    setTimeout(function(){
+        el.classList.remove("show");
+        setTimeout(function(){ if(el.parentNode){ el.parentNode.removeChild(el); } }, 250);
+    }, 3400);
+}
+
 /* ─────────────────────────────────────────────────────────────
    Sending & generation
    ───────────────────────────────────────────────────────────── */
@@ -620,11 +952,22 @@ function buildOptions(){
 
 function sendMessage(){
     if(generating) return;
+    closeAttachMenu();
     var conv = currentConv(); if(!conv) return;
+    if(pendingFiles.some(function(f){ return f.uploading; })){
+        notify("Please wait for the file upload to finish.", true);
+        return;
+    }
     var text = $("#composer").val().replace(/\s+$/,"");
-    if(!text.trim() && pendingFiles.length === 0) return;
+    // Only attach files that finished uploading / converting (skip failed ones).
+    var ready = pendingFiles.filter(function(f){ return !f.failed && !f.uploading && (f.paths && f.paths.length); });
+    if(!text.trim() && ready.length === 0) return;
     var msg = { role:"user", content:text };
-    if(pendingFiles.length > 0){ msg.files = pendingFiles.slice(); }
+    if(ready.length > 0){
+        msg.files = ready.map(function(f){
+            return { filepath:f.filepath, filename:f.filename, paths:f.paths.slice() };
+        });
+    }
     conv.messages.push(msg);
     if(!conv.titleSet && text.trim()){ conv.title = text.slice(0,42); conv.titleSet = true; renderSidebar(); }
     pendingFiles = []; renderAttachments();
@@ -636,27 +979,231 @@ function sendMessage(){
 function generate(conv){
     generating = true; updateComposerState();
     var typing = showTyping();
-    var payload = conv.messages.map(function(m){ return { role:m.role, content:m.content }; });
+    // Send only the visible answer for prior assistant turns — don't feed the
+    // model's earlier thinking back to it.
+    var payload = conv.messages.map(function(m){
+        var content = (m.role === "assistant" && !m.error) ? computeThinking(m).answer : m.content;
+        return { role:m.role, content:content };
+    });
     var opts = buildOptions();
-    // Attachments belong to the latest user message (chat.agi merges them in).
+    // Attachments belong to the latest user message. A single attachment may
+    // expand to several paths (one per rendered PDF page).
     var files = [];
     for(var i = conv.messages.length - 1; i >= 0; i--){
-        if(conv.messages[i].role === "user"){ files = (conv.messages[i].files || []).map(function(f){ return f.filepath; }); break; }
+        if(conv.messages[i].role === "user"){ files = flattenFilePaths(conv.messages[i].files); break; }
+    }
+    // Prefer the streaming WebSocket backend; fall back to the one-shot HTTP
+    // backend when the socket cannot be established.
+    if(window.WebSocket){ streamGenerate(conv, payload, opts, files, typing); }
+    else { blockingGenerate(conv, payload, opts, files, typing); }
+}
+
+// flattenFilePaths turns stored attachment entries into the flat vpath list the
+// backend expects (each entry contributes its `paths`, or its single filepath).
+function flattenFilePaths(entries){
+    var out = [];
+    (entries || []).forEach(function(f){
+        if(f.paths && f.paths.length){ f.paths.forEach(function(p){ out.push(p); }); }
+        else if(f.filepath){ out.push(f.filepath); }
+    });
+    return out;
+}
+
+/* ── Streaming generation (WebSocket) ─────────────────────────────────────
+   Connects to backend/chat_stream.agi, which relays llm.streamRequest()
+   deltas frame by frame, so the answer and the model's thinking render as
+   they are produced rather than in one go. */
+function wsEndpoint(){
+    if(typeof ao_module_utils !== "undefined" && ao_module_utils.getWebSocketEndpoint){
+        return ao_module_utils.getWebSocketEndpoint();
+    }
+    var proto = (location.protocol === "https:") ? "wss://" : "ws://";
+    return proto + location.hostname + (location.port ? (":" + location.port) : "");
+}
+
+function streamGenerate(conv, payload, opts, files, typing){
+    var url = wsEndpoint() + "/system/ajgi/interface?script=AIChat/backend/chat_stream.agi";
+    var ws;
+    try { ws = new WebSocket(url); }
+    catch(e){ blockingGenerate(conv, payload, opts, files, typing); return; }
+
+    activeSocket = ws;
+    var st = streamState = {
+        conv: conv, opts: opts, ws: ws, row: null, bubble: null,
+        thinking: null, thinkBody: null, userToggled: false,
+        content: "", reasoning: "", started: false, gotAny: false,
+        finished: false, fellBack: false, typing: typing
+    };
+
+    ws.onopen = function(){
+        ws.send(JSON.stringify({ messages: payload, options: opts, files: files || [] }));
+    };
+
+    ws.onmessage = function(ev){
+        var msg; try { msg = JSON.parse(ev.data); } catch(e){ return; }
+        if(msg.type === "start"){ ensureStreamRow(st); return; }
+        if(msg.type === "reasoning"){ st.gotAny = true; appendReasoning(st, msg.content || ""); return; }
+        if(msg.type === "delta"){ st.gotAny = true; appendDelta(st, msg.content || ""); return; }
+        if(msg.type === "done"){
+            st.finished = true;
+            // Prefer the server's assembled text (authoritative) over our accumulation.
+            finishStream(st, {
+                content: (typeof msg.content === "string" && msg.content !== "") ? msg.content : st.content,
+                reasoning: (typeof msg.reasoning === "string" && msg.reasoning !== "") ? msg.reasoning : st.reasoning,
+                usage: msg.usage || null,
+                model: msg.model || opts.model
+            });
+            try { ws.close(); } catch(e){}
+            return;
+        }
+        if(msg.type === "error"){
+            st.finished = true;
+            clearStreamRow(st);
+            pushAssistant(conv, { role:"assistant", content: msg.error || "Request failed.", error:true });
+            endGenerating();
+            try { ws.close(); } catch(e){}
+        }
+    };
+
+    ws.onerror = function(){
+        // Nothing streamed yet — quietly fall back to the HTTP backend.
+        if(!st.gotAny && !st.finished && !st.fellBack){
+            st.fellBack = true;
+            blockingGenerate(conv, payload, opts, files, typing);
+        }
+    };
+
+    ws.onclose = function(){
+        if(st.finished || st.fellBack) return;
+        if(!st.gotAny){
+            st.fellBack = true;
+            blockingGenerate(conv, payload, opts, files, typing);
+            return;
+        }
+        // Socket dropped mid-reply — keep whatever was received.
+        st.finished = true;
+        finishStream(st, { content: st.content, reasoning: st.reasoning, usage: null, model: opts.model });
+    };
+}
+
+// ensureStreamRow swaps the typing indicator for a live assistant row.
+function ensureStreamRow(st){
+    if(st.row) return;
+    if(st.typing){ st.typing.remove(); st.typing = null; }
+    var wrap = ensureWrap();
+    var row = $('<div class="msg assistant"></div>');
+    $('<div class="avatar"></div>').text("AI").appendTo(row);
+    var main = $('<div class="msg-main"></div>').appendTo(row);
+    $('<div class="msg-role"></div>').text(st.opts.model ? ("Assistant · " + st.opts.model) : "Assistant").appendTo(main);
+    st.bubble = $('<div class="bubble"></div>').appendTo(main);
+    st.bubble.append('<span class="caret"></span>');
+    st.main = main;
+    st.row = row;
+    wrap.append(row);
+    scrollBottom();
+}
+
+// appendReasoning grows the live thinking section. It auto-opens while the
+// model is thinking so the process is visible in real time, then collapses
+// once the answer starts (unless the user toggled it themselves).
+function appendReasoning(st, chunk){
+    if(chunk === "") return;
+    ensureStreamRow(st);
+    st.reasoning += chunk;
+    if(!st.thinking){
+        st.thinking = buildThinking("");
+        st.thinking.addClass("open live");
+        st.thinking.find(".tk-label").text("Thinking…");
+        st.thinkBody = st.thinking.find(".thinking-body").addClass("streaming");
+        // Remember a manual toggle so auto-collapse does not fight the user.
+        st.thinking.find(".thinking-toggle").on("click", function(){ st.userToggled = true; });
+        st.main.prepend(st.thinking);
+        // Keep the role label above the thinking block.
+        st.main.find(".msg-role").prependTo(st.main);
+    }
+    st.thinkBody.text(st.reasoning);
+    maybeScroll();
+}
+
+// appendDelta grows the live answer text.
+function appendDelta(st, chunk){
+    if(chunk === "") return;
+    ensureStreamRow(st);
+    // First answer token: thinking is done — collapse it back to the default.
+    if(st.content === "" && st.thinking){
+        st.thinking.removeClass("live");
+        if(!st.userToggled){
+            st.thinking.removeClass("open");
+            st.thinking.find(".tk-label").text("Show thinking");
+        } else {
+            st.thinking.find(".tk-label").text("Hide thinking");
+        }
     }
+    st.content += chunk;
+    // Plain text while streaming (cheap, no reflow churn); markdown on finish.
+    st.bubble.text(st.content);
+    st.bubble.append('<span class="caret"></span>');
+    maybeScroll();
+}
+
+// finishStream commits the streamed reply to the conversation and re-renders
+// it through the normal path (markdown, footer, collapsed thinking).
+function finishStream(st, result){
+    var conv = st.conv;
+    var text = result.content || "";
+    var reasoning = result.reasoning || "";
+    if(text.trim() === "" && reasoning.trim() === ""){
+        clearStreamRow(st);
+        pushAssistant(conv, { role:"assistant", content:"The model returned an empty reply.", error:true });
+        endGenerating();
+        return;
+    }
+    conv.messages.push({
+        role:"assistant", content:text, reasoning:reasoning,
+        usage:result.usage || null, model:result.model || ""
+    });
+    persist();
+    clearStreamRow(st);
+    renderMessages();
+    endGenerating();
+}
+
+function clearStreamRow(st){
+    if(st.typing){ st.typing.remove(); st.typing = null; }
+    if(st.row){ st.row.remove(); st.row = null; }
+}
+
+function endGenerating(){
+    generating = false;
+    activeSocket = null; streamState = null;
+    $("#stopBtn").hide();
+    updateComposerState(); updateTokenBadge();
+}
+
+// Only auto-scroll when the user is already near the bottom, so scrolling back
+// through a long reply is not yanked away mid-stream.
+function isNearBottom(){
+    var m = document.getElementById("messages");
+    return (m.scrollHeight - m.scrollTop - m.clientHeight) < 120;
+}
+function maybeScroll(){ if(isNearBottom()) scrollBottom(); }
+
+/* ── Non-streaming fallback (one-shot HTTP) ───────────────────────────── */
+function blockingGenerate(conv, payload, opts, files, typing){
     callBackend(payload, opts, files, function(resp){
-        typing.remove();
+        if(typing) typing.remove();
         if(!resp || !resp.ok){
             pushAssistant(conv, { role:"assistant", content:(resp && resp.error) ? resp.error : "Request failed.", error:true });
-            generating = false; updateComposerState(); return;
+            endGenerating(); return;
         }
-        var msg = { role:"assistant", content:resp.content || "", usage:resp.usage || null, model:resp.model || opts.model };
+        var msg = { role:"assistant", content:resp.content || "", reasoning:resp.reasoning || "", usage:resp.usage || null, model:resp.model || opts.model };
         conv.messages.push(msg); persist();
         var row = appendLiveAssistant(msg);
-        typewriter(row, msg.content, function(){ generating = false; updateComposerState(); updateTokenBadge(); });
+        typewriter(row, computeThinking(msg).answer, function(){ endGenerating(); });
     }, function(){
-        typing.remove();
+        if(typing) typing.remove();
         pushAssistant(conv, { role:"assistant", content:"Network error — could not reach the server.", error:true });
-        generating = false; updateComposerState();
+        endGenerating();
     });
 }
 
@@ -718,6 +1265,19 @@ function typewriter(row, fullText, done){
 }
 
 function stopGenerating(){
+    // Streaming: close the socket and keep whatever has arrived so far.
+    if(activeSocket){
+        var st = streamState;
+        try { activeSocket.close(); } catch(e){}
+        activeSocket = null;
+        if(st && !st.finished){
+            st.finished = true;
+            if(st.gotAny){ finishStream(st, { content: st.content, reasoning: st.reasoning, usage: null, model: st.opts.model }); }
+            else { clearStreamRow(st); endGenerating(); }
+        }
+        return;
+    }
+    // Fallback path: stop the typewriter reveal.
     cancelReveal = true;
     if(revealTimer){ clearTimeout(revealTimer); revealTimer = null; }
     $("#stopBtn").hide();
@@ -761,6 +1321,8 @@ function autoGrow(){
 }
 function updateComposerState(){
     $("#sendBtn").prop("disabled", generating);
+    // Stop is offered for the whole generation, streaming included.
+    $("#stopBtn").css("display", generating ? "inline-flex" : "none");
     document.getElementById("composer").disabled = false;
 }
 

+ 8 - 1
src/web/Terminal/docs/api.json

@@ -712,10 +712,17 @@
     {
      "name": "llm.request",
      "sig": "llm.request(messages, options)",
-     "desc": "Low-level call with a full OpenAI-style messages array. Returns the raw response object including choices and usage.",
+     "desc": "Low-level call with a full OpenAI-style messages array. Returns the raw response object including choices and usage. When the model exposes chain-of-thought, choices[0].message.reasoning_content holds its thinking text (DeepSeek reasoning_content, OpenRouter reasoning, or Anthropic thinking blocks).",
      "ret": "object",
      "example": "requirelib(\"llm\");\nvar resp = llm.request([\n    { role: \"system\", content: \"You are helpful.\" },\n    { role: \"user\",   content: \"Hi!\" }\n]);\nsendResp(resp.choices[0].message.content);"
     },
+    {
+     "name": "llm.streamRequest",
+     "sig": "llm.streamRequest(messages, options, onDelta)",
+     "desc": "Streaming version of llm.request(). onDelta({content, reasoning}) fires for each incremental chunk as the model generates it (content = new answer text, reasoning = new thinking text); the assembled response with usage is returned when the stream ends. Safe to relay chunks straight to a browser via websocket.send().",
+     "ret": "object",
+     "example": "requirelib(\"llm\");\nrequirelib(\"websocket\");\nwebsocket.upgrade(300);\nvar resp = llm.streamRequest([{ role: \"user\", content: \"Explain gravity\" }], {}, function(d){\n    if (d.reasoning != \"\") websocket.send(JSON.stringify({ type: \"reasoning\", content: d.reasoning }));\n    if (d.content != \"\") websocket.send(JSON.stringify({ type: \"delta\", content: d.content }));\n});\nwebsocket.send(JSON.stringify({ type: \"done\", usage: resp.usage }));"
+    },
     {
      "name": "llm.usage",
      "sig": "llm.usage()",