ソースを参照

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

* 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

* AIChat: fix #### headings and --- rules not rendering

Two markdown gaps in the chat renderer:

- Headings were matched one depth at a time with ^###\s+, ^##\s+, ^#\s+.
  "#### Title" matched none of them (the third # is followed by another #,
  not whitespace), so it rendered literally, as did ##### and ######. One
  rule now covers every depth: # -> h2 through ###### -> h6, with the
  closed-ATX trailing #'s stripped.
- Thematic breaks had no rule at all, so --- rendered as literal text. Add
  ---, ***, ___ and their spaced variants as <hr>, matched before the
  emphasis rules so a rule of asterisks is not eaten as bold/italic. Three
  or more markers are required, so list items are unaffected.

Also style h2-h6 and hr explicitly (shared with the thinking panel, which
renders the same markdown) so h5/h6 do not collapse to tiny default text,
collapse <br> runs around block elements, and normalise CRLF so a trailing
\r cannot leak into heading text.

Verified against the real renderer: 24 cases covering both bugs, every
heading depth, the rule variants, and regressions — lists, bold/italic,
inline code, links, escaping, and --- / #### inside fenced code blocks
staying literal.

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

* AIChat: render markdown live while streaming, and support tables

Live markdown during generation:
- The streaming reply and the thinking panel were written as plain text and
  only converted to markdown once the reply finished. Both now render markdown
  as tokens arrive. Paints are coalesced on a 60ms timer so a fast token
  stream cannot thrash layout, and a queued paint is cancelled when the reply
  is finalised.
- Half-written markup is normal mid-stream; the most visible case is an
  unclosed ``` fence, which would flash as raw backticks until the model
  closes it. A copy of the text is closed off for rendering only, leaving the
  stored text untouched, so code blocks appear as code from the first line.

GFM tables:
- Tables were not implemented, so a table rendered as rows of literal pipes.
  Added header/delimiter/body parsing with :--- , ---: and :---: alignment,
  scoped so it cannot swallow prose: a row must start with "|", and the
  delimiter row must be all dashes/colons. Runs after the inline rules, so
  cells keep bold/code/link formatting, and inside a wrapper that scrolls on
  its own so a wide table never widens the chat column.

Verified with 40 renderer cases (16 new for tables, including the reported
table, alignment, ragged rows, inline markup in cells, and the negative cases
- prose containing a pipe, lists, rules, and tables inside code fences), plus
a browser run confirming headings, the table, the rule and the code block are
all rendered elements *during* streaming, with no raw "| :---", "##" or "```"
ever visible.

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 週間 前
コミット
de340b7c44
1 ファイル変更116 行追加54 行削除
  1. 116 54
      src/web/AIChat/index.html

+ 116 - 54
src/web/AIChat/index.html

@@ -118,7 +118,25 @@
     .msg-main{flex:1; min-width:0;}
     .msg-role{font-size:12px;font-weight:700;color:var(--text-dim);margin:3px 0 6px;}
     .bubble{font-size:14.5px; line-height:1.62; color:var(--text); word-wrap:break-word; overflow-wrap:anywhere;}
-    .bubble p{margin:0 0 10px;} .bubble h2,.bubble h3,.bubble h4{margin:14px 0 8px;line-height:1.3;}
+    .bubble p{margin:0 0 10px;}
+    /* Headings and rules are shared with the thinking panel, which renders the
+       same markdown. Explicit sizes keep h5/h6 from collapsing to tiny text. */
+    .bubble h2,.bubble h3,.bubble h4,.bubble h5,.bubble h6,
+    .thinking-body h2,.thinking-body h3,.thinking-body h4,.thinking-body h5,.thinking-body h6{
+        margin:14px 0 8px;line-height:1.3;font-weight:700;}
+    .bubble h2,.thinking-body h2{font-size:1.34em;}
+    .bubble h3,.thinking-body h3{font-size:1.2em;}
+    .bubble h4,.thinking-body h4{font-size:1.08em;}
+    .bubble h5,.thinking-body h5{font-size:1em;}
+    .bubble h6,.thinking-body h6{font-size:.94em;color:var(--text-dim);}
+    .bubble hr,.thinking-body hr{border:none;border-top:1px solid var(--border);margin:16px 0;}
+    /* Tables scroll inside their own wrapper so a wide one never widens the chat. */
+    .bubble .tablewrap,.thinking-body .tablewrap{overflow-x:auto;margin:12px 0;}
+    .bubble table,.thinking-body table{border-collapse:collapse;font-size:.94em;min-width:100%;}
+    .bubble th,.bubble td,.thinking-body th,.thinking-body td{
+        border:1px solid var(--border);padding:7px 11px;text-align:left;vertical-align:top;}
+    .bubble th,.thinking-body th{background:var(--panel-2);font-weight:700;white-space:nowrap;}
+    .bubble tbody tr:nth-child(even) td,.thinking-body tbody tr:nth-child(even) td{background:rgba(127,127,140,.07);}
     .bubble ul{margin:6px 0 10px; padding-left:22px;} .bubble li{margin:3px 0;}
     .bubble a{color:var(--accent);}
     .bubble code.inline{background:var(--panel-3);padding:1.5px 6px;border-radius:5px;font-size:.88em;
@@ -221,7 +239,6 @@
     .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;}
@@ -1086,6 +1103,42 @@ function streamGenerate(conv, payload, opts, files, typing){
     };
 }
 
+/* Live markdown while streaming.
+   Half-written markup is normal mid-stream — most visibly an unclosed ```
+   fence, which would otherwise flash as raw backticks until the model closes
+   it. Close such constructs on a copy of the text just for rendering; the
+   stored text is untouched. */
+function closeOpenMarkdown(src){
+    var fences = src.match(/```/g);
+    if(fences && fences.length % 2 === 1){ src += "\n```"; }   // fence still open
+    return src;
+}
+
+// Re-rendering on every token would thrash layout, so paints are coalesced.
+var STREAM_RENDER_MS = 60;
+function scheduleStreamRender(st){
+    if(st.renderTimer) return;
+    st.renderTimer = setTimeout(function(){
+        st.renderTimer = null;
+        paintStream(st);
+    }, STREAM_RENDER_MS);
+}
+function cancelStreamRender(st){
+    if(st && st.renderTimer){ clearTimeout(st.renderTimer); st.renderTimer = null; }
+}
+function paintStream(st){
+    if(!st.row) return;                       // reply already finalised
+    if(st.thinkBody && st.reasoning !== ""){
+        st.thinkBody.html(renderMarkdown(closeOpenMarkdown(st.reasoning)));
+    }
+    if(st.bubble){
+        st.bubble.html(renderMarkdown(closeOpenMarkdown(st.content)));
+        enhanceCodeBlocks(st.bubble[0]);
+        st.bubble.append('<span class="caret"></span>');
+    }
+    maybeScroll();
+}
+
 // ensureStreamRow swaps the typing indicator for a live assistant row.
 function ensureStreamRow(st){
     if(st.row) return;
@@ -1114,15 +1167,14 @@ function appendReasoning(st, chunk){
         st.thinking = buildThinking("");
         st.thinking.addClass("open live");
         st.thinking.find(".tk-label").text("Thinking…");
-        st.thinkBody = st.thinking.find(".thinking-body").addClass("streaming");
+        st.thinkBody = st.thinking.find(".thinking-body");
         // 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();
+    scheduleStreamRender(st);
 }
 
 // appendDelta grows the live answer text.
@@ -1140,10 +1192,7 @@ function appendDelta(st, chunk){
         }
     }
     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();
+    scheduleStreamRender(st);
 }
 
 // finishStream commits the streamed reply to the conversation and re-renders
@@ -1169,6 +1218,7 @@ function finishStream(st, result){
 }
 
 function clearStreamRow(st){
+    cancelStreamRender(st);                   // no stray paint after finalising
     if(st.typing){ st.typing.remove(); st.typing = null; }
     if(st.row){ st.row.remove(); st.row = null; }
 }
@@ -1370,53 +1420,65 @@ function closeSidebar(){ $("#sidebar,#sidebarScrim").removeClass("open"); }
    ───────────────────────────────────────────────────────────── */
 function esc(s){ return String(s==null?"":s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;"); }
 
-function renderMarkdown(src){
-    src = String(src==null?"":src);
-    var blocks = [];
-    src = src.replace(/```(\w+)?\n?([\s\S]*?)```/g, function(_, lang, code){
-        var idx = blocks.length; blocks.push({ lang:lang||"", code:code.replace(/\n$/,"") });
-        return "CB" + idx + "";
-    });
-    var html = esc(src);
-    html = html.replace(/`([^`\n]+)`/g, function(_, c){ return '<code class="inline">'+c+'</code>'; });
-    html = html.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
-    html = html.replace(/(^|[^*])\*([^*\n]+)\*/g, "$1<em>$2</em>");
-    html = html.replace(/\[([^\]]+)\]\((https?:[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
-    html = html.replace(/^###\s+(.*)$/gm, "<h4>$1</h4>");
-    html = html.replace(/^##\s+(.*)$/gm, "<h3>$1</h3>");
-    html = html.replace(/^#\s+(.*)$/gm, "<h2>$1</h2>");
-    html = html.replace(/(?:^|\n)((?:\s*[-*]\s+.*(?:\n|$))+)/g, function(_, list){
-        var items = list.trim().split(/\n/).map(function(li){ return "<li>"+li.replace(/^\s*[-*]\s+/,"")+"</li>"; }).join("");
-        return "\n<ul>"+items+"</ul>\n";
-    });
-    html = html.replace(/\n/g, "<br>");
-    html = html.replace(/CB(\d+)/g, function(_, i){
-        var b = blocks[+i];
-        var head = b.lang ? '<div class="code-lang">'+esc(b.lang)+'</div>' : "";
-        return '<div class="codeblock">'+head+'<button class="copy-code" title="Copy">⧉</button><pre><code>'+esc(b.code)+'</code></pre></div>';
-    });
-    html = html.replace(/<br>\s*(<ul>|<h[234]>|<div class="codeblock">)/g, "$1");
-    html = html.replace(/(<\/ul>|<\/h[234]>|<\/div>)\s*<br>/g, "$1");
-    return html;
+/* GFM tables.
+   Line-based rather than one big regex: a table is a row, a delimiter row
+   (---, :---, ---:, :---:) directly beneath it, then rows until a line that is
+   no longer one. A leading "|" is required to start a row, which is what
+   models emit and keeps prose containing a stray pipe from being swallowed.
+   Runs after the inline rules, so cells arrive with bold/code/links applied. */
+function mdSplitRow(line){
+    var s = line.trim();
+    if(s.charAt(0) === "|"){ s = s.slice(1); }
+    if(s.charAt(s.length-1) === "|"){ s = s.slice(0, -1); }
+    return s.split("|").map(function(c){ return c.trim(); });
 }
-
-function enhanceCodeBlocks(scope){
-    $(scope).find(".copy-code").off("click").on("click", function(){
-        var code = $(this).siblings("pre").find("code").text();
-        copyText(code, this, "✓");
-    });
+function mdIsTableRow(line){ return /^[ \t]*\|/.test(line); }
+function mdIsTableDelimiter(line){
+    if(!mdIsTableRow(line) || line.indexOf("-") === -1) return false;
+    var cells = mdSplitRow(line);
+    if(cells.length === 0) return false;
+    for(var i = 0; i < cells.length; i++){
+        if(!/^:?-+:?$/.test(cells[i])) return false;
+    }
+    return true;
 }
-function copyText(text, btn, okGlyph){
-    var done = function(){ var $b=$(btn); var old=$b.text(); $b.text(okGlyph||"✓ Copied"); setTimeout(function(){ $b.text(old); }, 1200); };
-    if(navigator.clipboard && navigator.clipboard.writeText){ navigator.clipboard.writeText(text).then(done, function(){ legacyCopy(text); done(); }); }
-    else { legacyCopy(text); done(); }
+function mdAlignAttr(cell){
+    var left = cell.charAt(0) === ":", right = cell.charAt(cell.length-1) === ":";
+    if(left && right) return ' style="text-align:center"';
+    if(right) return ' style="text-align:right"';
+    return "";
 }
-function legacyCopy(text){
-    var ta = document.createElement("textarea"); ta.value = text; ta.style.position="fixed"; ta.style.opacity="0";
-    document.body.appendChild(ta); ta.select(); try{ document.execCommand("copy"); }catch(e){} document.body.removeChild(ta);
+function renderTables(html){
+    if(html.indexOf("|") === -1) return html;      // fast path: no table possible
+    var lines = html.split("\n"), out = [];
+    for(var i = 0; i < lines.length; i++){
+        if(mdIsTableRow(lines[i]) && i + 1 < lines.length && mdIsTableDelimiter(lines[i + 1])){
+            var head = mdSplitRow(lines[i]);
+            var aligns = mdSplitRow(lines[i + 1]).map(mdAlignAttr);
+            var rows = [], j = i + 2;
+            while(j < lines.length && mdIsTableRow(lines[j]) && !mdIsTableDelimiter(lines[j])){
+                rows.push(mdSplitRow(lines[j])); j++;
+            }
+            var th = head.map(function(c, k){ return "<th" + (aligns[k] || "") + ">" + c + "</th>"; }).join("");
+            var tb = rows.map(function(r){
+                var tds = "";
+                for(var k = 0; k < head.length; k++){
+                    tds += "<td" + (aligns[k] || "") + ">" + (r[k] === undefined ? "" : r[k]) + "</td>";
+                }
+                return "<tr>" + tds + "</tr>";
+            }).join("");
+            out.push('<div class="tablewrap"><table><thead><tr>' + th + "</tr></thead><tbody>" + tb + "</tbody></table></div>");
+            i = j - 1;
+        } else {
+            out.push(lines[i]);
+        }
+    }
+    return out.join("\n");
 }
 
-function scrollBottom(){ var m = document.getElementById("messages"); m.scrollTop = m.scrollHeight; }
-</script>
-</body>
-</html>
+function renderMarkdown(src){
+    src = String(src==null?"":src).replace(/\r\n?/g, "\n");   // CRLF would trail into headings
+    var blocks = [];
+    src = src.replace(/```(\w+)?\n?([\s\S]*?)```/g, function(_, lang, code){
+        var idx = blocks.length; blocks.push({ lang:lang||"", code:code.replace(/\n$/,"") });
+        return "