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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,11 @@ liplus-chat は、人間と複数の独立した AI / Li+ セッションが、
npm run tauri dev
```

起動すると部屋ソケットが待ち受けを始めます。タイトルバーでセッションを選び「セッション参加」を押すと、次の 2 つが行われます。
起動すると Tauri の窓が開き、部屋ソケットが待ち受けを始めます。フロントエンドの dev サーバは `vite.config.ts` で 1420 番に固定しています(`src-tauri/tauri.conf.json` の `devUrl` と一致させる必要があるため)。

タイトルバーの「診断」で、部屋ソケットの待受状態、セッションの生死、CLI の出力が確認できます。起動に失敗したときは自動で開きます。

タイトルバーでセッションを選び「セッション参加」を押すと、次の 2 つが行われます。

1. そのタブの作業ディレクトリの `.mcp.json` へ、部屋のサイドカーを `liplus-chat-room` という名前で登録します。**既存の内容はマージして保持します**が、あなたのリポジトリのファイルを書き換える操作です。
2. `--dangerously-load-development-channels server:liplus-chat-room` を付けて CLI を PTY 上の対話セッションとして起動します。
Expand Down
19 changes: 13 additions & 6 deletions docs/0-requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,12 @@ liplus-desktop の `stream_parser.rs` および `spawn_stream_pty` / `spawn_stre
- 会話ログの永続化と観測 UI
- plugin としての allowlist 掲載(配布の第二段階)

### 診断面

会話面とは別枠として、部屋ソケットの待受状態・セッションの生死・CLI の出力を表示する診断面を持つ。既定は折りたたみで、セッションの起動失敗や異常終了を検出したときは自動で開く。

これは「ターミナルエミュレータを置かない」制約と衝突しない。会話面に CLI の生出力を流さないという制約は、何が発言かという axis の話であり、診断面は「部屋の下の機構が生きているか」という別の問いに答える。部屋が沈黙しているとき、その沈黙が正常な待機なのか機構の停止なのかは、会話面だけからは区別できない。

## テストの配置

`.mcp.json` への登録と起動フラグの検査は、`crates/mcp-config/` という tauri 非依存の crate に置く。
Expand All @@ -180,12 +186,13 @@ CI が実行するもの:

上流から順に確認する。各段は下流の前提であるため、順序を飛ばさない。

1. **部屋ソケットが待ち受けているか。** アプリ起動時の標準エラーに `[room] listening on 127.0.0.1:<port>` が出る。出ていなければ以降はすべて不成立。
2. **サイドカーが接続したか。** 参加者表示にセッション名が出る。出ない場合はサイドカー側の標準エラー(CLI のログ)に `room socket: connected as "<name>"` があるかを見る。
3. **サイドカー単体が壊れていないか。** `npm run sidecar:test` を実行する。偽の部屋ソケットを立てて両面を駆動するため、通ればサイドカーではなくアプリ側の問題に絞れる。
4. **`.mcp.json` の登録が効いているか。** セッションの作業ディレクトリの `.mcp.json` に `liplus-chat-room` が入っているかを見る。`--mcp-config` によるファイル渡しでは channel 側が名前を解決できないため、正式登録以外の経路は成立しない。
5. **起動フラグが単独指定になっているか。** `--dangerously-load-development-channels server:liplus-chat-room` 以外に `--channels` が付いていると、同一サーバが二重登録され全体が不通になる。タブ設定に `--channels` / `--print` / `--input-format` / `--output-format` がある場合、セッション起動はエラーで止まる(黙って外すと動いたように見えるため)。
6. **対話セッションになっているか。** `--print` 系では push が届かない。PTY 経由の対話起動であることを確認する。
1. **部屋ソケットが待ち受けているか。** タイトルバーの「診断」を開き、部屋ソケットの行を見る。`127.0.0.1:<port> で待受中` でなければ以降はすべて不成立。
2. **セッションが生きているか。** 同じ診断のセッション行を見る。`終了(終了コード N)` なら CLI が落ちている。直下のセッション出力に理由が残る。
3. **サイドカーが接続したか。** 参加者表示にセッション名が出る。出ない場合はセッション出力に `room socket: connected as "<name>"` があるかを見る。
4. **サイドカー単体が壊れていないか。** `npm run sidecar:test` を実行する。偽の部屋ソケットを立てて両面を駆動するため、通ればサイドカーではなくアプリ側の問題に絞れる。
5. **`.mcp.json` の登録が効いているか。** セッションの作業ディレクトリの `.mcp.json` に `liplus-chat-room` が入っているかを見る。`--mcp-config` によるファイル渡しでは channel 側が名前を解決できないため、正式登録以外の経路は成立しない。
6. **起動フラグが単独指定になっているか。** `--dangerously-load-development-channels server:liplus-chat-room` 以外に `--channels` が付いていると、同一サーバが二重登録され全体が不通になる。タブ設定に `--channels` / `--print` / `--input-format` / `--output-format` がある場合、セッション起動はエラーで止まる(黙って外すと動いたように見えるため)。
7. **対話セッションになっているか。** `--print` 系では push が届かない。PTY 経由の対話起動であることを確認する。

## 配布

Expand Down
14 changes: 14 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,24 @@
</label>
<select id="tab-select" aria-label="起動するセッション"></select>
<button id="start-session" type="button">セッション参加</button>
<button id="toggle-diagnostics" type="button" aria-expanded="false">診断</button>
</header>

<main id="room" aria-live="polite"></main>

<!-- Diagnostics, not the conversation surface. The room shows messages;
this shows whether the machinery under it is alive. Collapsed by
default so it does not compete with the room. -->
<aside id="diagnostics" hidden>
<dl class="facts">
<dt>部屋ソケット</dt>
<dd id="socket-state">確認中</dd>
<dt>セッション</dt>
<dd id="session-state">未起動</dd>
</dl>
<pre id="session-log" aria-label="セッションの出力"></pre>
</aside>

<footer id="composer">
<textarea
id="input"
Expand Down
5 changes: 5 additions & 0 deletions src-tauri/src/room.rs
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,11 @@ pub async fn start(app: AppHandle, room: RoomState) -> Result<u16, String> {
.port();
room.set_port(port);

// The frontend loads before this bind completes, so a poll at load time
// reads "not listening" and reports a failure that is only a race. The
// event is the authority; `room_port` remains for a late reader.
let _ = app.emit("room-ready", port);

tokio::spawn(async move {
loop {
let Ok((stream, _)) = listener.accept().await else {
Expand Down
96 changes: 92 additions & 4 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@
// appended locally on send — it comes back through the same event the agents'
// replies do. See src-tauri/src/room.rs.
//
// No terminal here on purpose: a CLI's raw output is not a message source
// (docs/0-requirements.md).
// No terminal in the room on purpose: a CLI's raw output is not a message
// source (docs/0-requirements.md). The diagnostics pane is a separate surface
// and exists for the opposite reason — when nothing arrives, the machinery
// under the room has to be readable without a console.
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";

Expand Down Expand Up @@ -39,6 +41,8 @@ interface StartedSession {
}

const NAME_KEY = "liplus-chat.display-name";
/** Lines of CLI output kept for triage. Enough to hold a startup failure. */
const LOG_LIMIT = 400;

const roomEl = document.getElementById("room") as HTMLElement;
const rosterEl = document.getElementById("roster") as HTMLElement;
Expand All @@ -48,14 +52,48 @@ const startEl = document.getElementById("start-session") as HTMLButtonElement;
const inputEl = document.getElementById("input") as HTMLTextAreaElement;
const sendEl = document.getElementById("send") as HTMLButtonElement;
const statusEl = document.getElementById("status") as HTMLElement;
const diagnosticsEl = document.getElementById("diagnostics") as HTMLElement;
const toggleEl = document.getElementById("toggle-diagnostics") as HTMLButtonElement;
const socketStateEl = document.getElementById("socket-state") as HTMLElement;
const sessionStateEl = document.getElementById("session-state") as HTMLElement;
const logEl = document.getElementById("session-log") as HTMLPreElement;

let tabs: TabConfig[] = [];
let logLines: string[] = [];

function status(text: string, kind: "info" | "error" = "info"): void {
statusEl.textContent = text;
statusEl.dataset.kind = kind;
}

/** Open the diagnostics pane. Called when something goes wrong on its own. */
function revealDiagnostics(): void {
diagnosticsEl.hidden = false;
toggleEl.setAttribute("aria-expanded", "true");
}

// Terminal control sequences, built from escapes rather than written as
// literal control bytes: a raw 0x1b in the source survives an editor round
// trip only by luck. This pane is read, not driven, so the sequences are
// stripped rather than interpreted.
const CSI = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
const OSC = /\x1b[\]P^_][^\x1b\x07]*(?:\x1b\\|\x07)?/g;
const SHORT_ESCAPE = /\x1b[@-Z\\-_]/g;

function appendLog(chunk: string): void {
const plain = chunk
.replace(CSI, "")
.replace(OSC, "")
.replace(SHORT_ESCAPE, "")
.replace(/\r/g, "");

const atBottom = logEl.scrollHeight - logEl.scrollTop - logEl.clientHeight < 40;
logLines.push(...plain.split("\n"));
if (logLines.length > LOG_LIMIT) logLines = logLines.slice(-LOG_LIMIT);
logEl.textContent = logLines.join("\n");
if (atBottom) logEl.scrollTop = logEl.scrollHeight;
}

function shortTime(iso: string): string {
const at = new Date(iso);
if (Number.isNaN(at.getTime())) return "";
Expand Down Expand Up @@ -122,6 +160,28 @@ async function send(): Promise<void> {
}
}

/**
* Follow a launched session until it dies.
*
* A session that exits on startup is the failure mode with no other witness:
* there is no terminal, and the room simply stays empty. Without this the
* screen is identical whether the CLI is running or was never there.
*/
async function followSession(tab: TabConfig, started: StartedSession): Promise<void> {
sessionStateEl.textContent = `${tab.name} 起動中`;
sessionStateEl.dataset.kind = "ok";

await listen<string>(`pty-data-${started.pty_id}`, (event) => appendLog(event.payload));
await listen<number | null>(`pty-exit-${started.pty_id}`, (event) => {
const code = event.payload;
const detail = code === null ? "終了コード不明" : `終了コード ${code}`;
sessionStateEl.textContent = `${tab.name} 終了(${detail})`;
sessionStateEl.dataset.kind = "error";
status(`${tab.name} が終了しました(${detail})。診断を確認してください。`, "error");
revealDiagnostics();
});
}

async function startSession(): Promise<void> {
const tab = tabs.find((candidate) => candidate.id === tabEl.value);
if (!tab) {
Expand All @@ -138,21 +198,49 @@ async function startSession(): Promise<void> {
rows: 30,
});
status(`${tab.name} を起動しました。${started.mcp_config} に登録済み。`);
await followSession(tab, started);
} catch (err) {
status(`${tab.name} を起動できませんでした: ${err}`, "error");
sessionStateEl.textContent = `${tab.name} 起動失敗`;
sessionStateEl.dataset.kind = "error";
revealDiagnostics();
} finally {
startEl.disabled = false;
}
}

function renderSocket(port: number | null, error?: string): void {
if (error) {
socketStateEl.textContent = error;
socketStateEl.dataset.kind = "error";
return;
}
if (port === null) {
socketStateEl.textContent = "未待受";
socketStateEl.dataset.kind = "error";
return;
}
socketStateEl.textContent = `127.0.0.1:${port} で待受中`;
socketStateEl.dataset.kind = "ok";
}

async function main(): Promise<void> {
nameEl.value = localStorage.getItem(NAME_KEY) ?? "human";
nameEl.addEventListener("change", () => {
localStorage.setItem(NAME_KEY, nameEl.value.trim() || "human");
});

toggleEl.addEventListener("click", () => {
const open = diagnosticsEl.hidden;
diagnosticsEl.hidden = !open;
toggleEl.setAttribute("aria-expanded", String(open));
});

await listen<RoomMessage>("room-message", (event) => appendMessage(event.payload));
await listen<string[]>("room-agents", (event) => renderRoster(event.payload));
// The socket binds after the frontend loads, so the event is the authority
// and the poll below is only for a listener that attached too late.
await listen<number>("room-ready", (event) => renderSocket(event.payload));

sendEl.addEventListener("click", () => void send());
inputEl.addEventListener("keydown", (event) => {
Expand All @@ -179,9 +267,9 @@ async function main(): Promise<void> {
try {
renderRoster(await invoke<string[]>("room_agents"));
const port = await invoke<number | null>("room_port");
if (port === null) status("部屋のソケットがまだ待ち受けていません。", "error");
if (port !== null) renderSocket(port);
} catch (err) {
status(`部屋の状態を取得できませんでした: ${err}`, "error");
renderSocket(null, `取得できませんでした: ${err}`);
}
}

Expand Down
55 changes: 55 additions & 0 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -183,3 +183,58 @@ body {
#status[data-kind="error"] {
color: var(--danger);
}

/* ── diagnostics ─────────────────────────────────────────────────────────── */

/* Not the conversation surface. The room answers "what was said"; this answers
"is anything under the room alive", which is the question a silent room
leaves unanswerable. */
#diagnostics {
border-top: 1px solid var(--line);
background: var(--surface);
padding: 0.6rem 0.9rem;
font-size: 0.78rem;
max-height: 40vh;
display: flex;
flex-direction: column;
gap: 0.5rem;
}

#diagnostics .facts {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.2rem 0.75rem;
margin: 0;
}

#diagnostics dt {
color: var(--muted);
}

#diagnostics dd {
margin: 0;
}

#diagnostics dd[data-kind="ok"] {
color: var(--accent);
}

#diagnostics dd[data-kind="error"] {
color: var(--danger);
}

#session-log {
flex: 1;
min-height: 6rem;
overflow: auto;
margin: 0;
padding: 0.5rem;
background: var(--bg);
border: 1px solid var(--line);
border-radius: 4px;
font-family: ui-monospace, "Cascadia Mono", "Consolas", monospace;
font-size: 0.72rem;
line-height: 1.5;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
23 changes: 23 additions & 0 deletions vite.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { defineConfig } from "vite";

// The dev server port is not a preference: src-tauri/tauri.conf.json waits on
// `build.devUrl` (http://localhost:1420), and Vite's own default is 5173. With
// no config here the two never meet and the app window never opens, which is
// what this file was missing (#18).
export default defineConfig({
// Tauri prints its own progress; clearing the screen eats it.
clearScreen: false,

server: {
port: 1420,
// Fail loudly rather than silently sliding to 1421 when the port is taken.
// A moved port is the same failure this file exists to prevent.
strictPort: true,
watch: {
// Rust rebuilds are driven by cargo, not by the frontend watcher.
ignored: ["**/src-tauri/**", "**/crates/**", "**/sidecar/**"],
},
},

envPrefix: ["VITE_", "TAURI_"],
});
Loading