Skip to content
Open
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
25 changes: 25 additions & 0 deletions apps/presentation/dashboard/src/data/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2377,3 +2377,28 @@ export async function updateGoalOwnership(body: { goal_id: string; mode: Executi
throw error;
}
}


const privateConversationSchema = z.object({
binding_id: z.string(), app_ref: z.string(), context_kind: z.literal("project"),
project_ref: z.string(), project_title: z.string(), context_available: z.boolean(), executor_endpoint_id: z.string(),
grant: z.literal("workspace_read"), listener_status: z.string(),
pending_count: z.number().int(), recovery_count: z.number().int(),
});
const privateConversationsSchema = z.object({ok: z.literal(true), revision: z.number().int(),
connections: z.array(privateConversationSchema)});
export type PrivateConversation = z.infer<typeof privateConversationSchema>;
export async function fetchPrivateConversations() {
return privateConversationsSchema.parse(await requestJson<unknown>("/api/chat/lark/private-conversations"));
}
export async function connectPrivateConversation(appRef: string, projectRef: string, executor: string) {
return privateConversationsSchema.parse(await requestJson<unknown>("/api/chat/lark/private-conversations", {
method: "POST", headers: {"Content-Type": "application/json"},
body: JSON.stringify({app_ref: appRef, project_ref: projectRef, executor_endpoint_id: executor}),
}));
}
export async function disconnectPrivateConversation(bindingId: string, revision: number) {
return privateConversationsSchema.parse(await requestJson<unknown>("/api/chat/lark/private-conversations", {
method: "DELETE", headers: {"Content-Type": "application/json"}, body: JSON.stringify({binding_id: bindingId, revision}),
}));
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {PrivateConversationPanel} from "./private-conversation-panel";
import { useEffect, useMemo, useRef, useState } from "react";
import {
Bot,
Expand Down Expand Up @@ -539,6 +540,7 @@ export function LarkSettingsPage({
</header>
)}

<PrivateConversationPanel />
<nav className="personal-lark-tabs" aria-label={t("lark.management")}>
<button aria-current={tab === "apps" ? "page" : undefined} onClick={() => setTab("apps")} type="button">{t("lark.apps")} <span>{loading ? "…" : apps.length}</span></button>
<button aria-current={tab === "connections" ? "page" : undefined} onClick={() => setTab("connections")} type="button">{t("lark.connections")} <span>{loading ? "…" : connections.length}</span></button>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import {useEffect, useState} from "react";
import {connectPrivateConversation, disconnectPrivateConversation, fetchPrivateConversations,
fetchChatProjects, fetchChatCapabilities, fetchLarkApps, type PrivateConversation,
type ChatProject, type LarkApp} from "../../data/chat";
import {useWorkspaceI18n} from "./i18n";
import "./private-conversation.css";

export function PrivateConversationPanel() {
const {locale} = useWorkspaceI18n();
const zh = locale === "zh-CN";
const [apps, setApps] = useState<LarkApp[]>([]);
const [projects, setProjects] = useState<ChatProject[]>([]);
const [executors, setExecutors] = useState<string[]>([]);
const [rows, setRows] = useState<PrivateConversation[]>([]);
const [revision, setRevision] = useState(0);
const [app, setApp] = useState("");
const [project, setProject] = useState("");
const [executor, setExecutor] = useState("");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);

async function refresh() {
const state = await fetchPrivateConversations();
setRows(state.connections); setRevision(state.revision);
}
useEffect(() => {
let active = true;
Promise.all([fetchLarkApps(), fetchChatProjects(), fetchChatCapabilities(), fetchPrivateConversations()])
.then(([apps, projects, capabilities, state]) => {
if (!active) return;
setApps(apps.filter(app => app.ready && app.app_ref !== "default"));
setProjects(projects.projects);
const choices = (capabilities.adapters ?? []).filter(row => row.available).map(row => row.agent_id);
setExecutors(choices); setExecutor(choices.includes("codex") ? "codex" : choices[0] ?? "");
if (projects.projects.length === 1) setProject(projects.projects[0].project_ref);
setRows(state.connections); setRevision(state.revision);
}).catch(error => {if (active) setError(String(error));});
const timer = setInterval(() => {fetchPrivateConversations().then(state => {
if (active) {setRows(state.connections); setRevision(state.revision);}
}).catch(() => {});}, 5000);
return () => {active = false; clearInterval(timer);};
}, []);

function listenerLabel(state: string) {
const labels: Record<string, [string, string]> = {starting: ["启动中", "Starting"], listening: ["实时连接就绪", "Live connection ready"],
standby: ["等待已有监听服务", "Waiting for the existing listener"], retrying: ["重连中", "Reconnecting"],
stopped: ["已停止", "Stopped"], inactive: ["配置未启用", "Inactive"]};
return labels[state]?.[zh ? 0 : 1] ?? (zh ? "连接尚未确认" : "Connection unconfirmed");
}

async function act(operation: () => Promise<unknown>) {
setBusy(true); setError("");
try {await operation(); await refresh();} catch (error) {setError(String(error));}
finally {setBusy(false);}
}
return <section className="personal-detail-card personal-private-conversation" aria-label={zh ? "本人飞书私聊" : "Owner private Chat"}>
<h3>{zh ? "本人私聊 · 项目对话" : "Owner private Chat · Project conversation"}</h3>
<p>{zh ? "每个 App 单独核验登录本人,只读讨论所选工作区。普通私聊不会创建 Goal。" : "Verify the logged-in owner independently for each App. Discuss the selected workspace with a read grant; ordinary private Chat creates no Goal."}</p>
{rows.map(row => <article key={row.binding_id}>
<strong>{row.app_ref} · {row.context_available ? row.project_title : (zh ? "工作区不可用" : "Workspace unavailable")}</strong>
<p>{row.executor_endpoint_id} · {zh ? "监听状态" : "Listener"}: {listenerLabel(row.listener_status)}</p>
<p>{zh ? `待处理或回复:${row.pending_count}` : `Pending execution or reply: ${row.pending_count}`}</p>
{row.recovery_count > 0 ? <p role="status">{zh ? "存在尚未确认的发送回执。服务会读取原回执恢复;不要重新发送同一任务。检查 App 登录、权限和原会话后刷新状态。" : "A send receipt is unconfirmed. The service reads the original receipt to recover; avoid resending the same task. Check this App login, permissions and original Session, then refresh status."}</p> : null}
{!row.context_available ? <p role="alert">{zh ? "工作区授权已失效;请恢复原工作区或重新选择。旧会话不会移到其它工作区。" : "The workspace grant is unavailable. Restore the original workspace or select a new one; the old Session will not move."}</p> : null}
<button disabled={busy} onClick={() => void act(() => disconnectPrivateConversation(row.binding_id, revision))} type="button">{zh ? "解绑" : "Disconnect"}</button>
</article>)}
{rows.length === 0 ? <p>{zh ? "尚未连接本人私聊。" : "No owner private Chat connected."}</p> : null}
<label>App<select aria-label={zh ? "私聊 App" : "Private Chat App"} value={app} disabled={busy} onChange={event => setApp(event.target.value)}>
<option value="">{zh ? "选择已验证 App" : "Select a verified App"}</option>
{apps.map(app => <option key={app.app_ref} value={app.app_ref}>{app.label} · {app.app_ref}</option>)}
</select></label>
<label>{zh ? "工作区" : "Workspace"}<select aria-label={zh ? "私聊工作区" : "Private Chat workspace"} value={project} disabled={busy} onChange={event => setProject(event.target.value)}>
<option value="">{zh ? "选择授权工作区" : "Select an authorized workspace"}</option>
{projects.map(project => <option key={project.project_ref} value={project.project_ref}>{project.title}</option>)}
</select></label>
<label>{zh ? "执行器" : "Executor"}<select aria-label={zh ? "私聊执行器" : "Private Chat executor"} value={executor} disabled={busy} onChange={event => setExecutor(event.target.value)}>
{executors.map(executor => <option key={executor} value={executor}>{executor}</option>)}
</select></label>
<div className="personal-detail-actions"><button disabled={busy || !app || !project || !executor} onClick={() => void act(() => connectPrivateConversation(app, project, executor))} type="button">
{busy ? (zh ? "正在核验" : "Verifying") : (zh ? "连接本人私聊" : "Connect owner private Chat")}</button>
<button disabled={busy} onClick={() => void act(refresh)} type="button">{zh ? "刷新状态" : "Refresh status"}</button></div>
<p>{zh ? "从手机发送文字开始;后续消息进入原会话队列。/status 查看状态,/stop 停止当前执行,/new 开启新会话。图片、文件会明确提示暂不支持。" : "Send text from your phone to begin; follow-ups queue in the same Session. /status checks state, /stop stops the current Turn, /new starts a new conversation. Images and files receive an explicit unsupported response."}</p>
{error ? <p role="alert">{error}</p> : null}
</section>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
.personal-private-conversation { display: grid; gap: 18px; }
.personal-private-conversation > p { margin: 0; }
.personal-private-conversation label { display: grid; gap: 8px; font-size: 13px; }
.personal-private-conversation select {
width: 100%; min-height: 40px; padding: 10px 12px;
border: 1px solid var(--pw-line-strong); border-radius: 8px;
background: var(--pw-card); color: var(--pw-text); font: inherit;
}
.personal-private-conversation .personal-detail-actions { display: flex; flex-wrap: wrap; gap: 8px; }
.personal-private-conversation button {
justify-self: start; min-height: 36px; padding: 8px 12px;
border: 1px solid var(--pw-line-strong); border-radius: 8px;
background: var(--pw-card); color: var(--pw-text); cursor: pointer; font-size: 13px;
}
.personal-private-conversation button:disabled { color: var(--pw-muted); cursor: default; opacity: .6; }
.personal-private-conversation :is(button, select):focus-visible { outline: 2px solid var(--pw-blue); outline-offset: 2px; }
.personal-private-conversation form { display: grid; gap: 12px; }
.personal-private-conversation form button[type="submit"] { background: var(--pw-text); color: var(--pw-card); }
.personal-private-conversation [role="alert"] { color: var(--pw-red); }
.personal-private-conversation [role="status"]:empty { display: none; }
48 changes: 43 additions & 5 deletions docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,49 @@ the TypeScript owner decides context identity and scope. Native Codex resume ret
the original upstream thread and workspace. HTTP/protocol fixtures qualify that
continuity and denial behavior; they do not establish real model adoption.

The initial grant is workspace reading for the local owner. Lark audience grants,
ordinary private-message selection, durable inbound admission independent of
terminal delivery, and installed/mobile acceptance remain open work in this RFC.
The App scope does not qualify those journeys or authorize edits; a revoked grant
keeps the history readable and blocks new messages until the host grants it again.
The App grant is workspace reading for the local owner. The App scope alone does
not qualify Lark private-message admission, installed or mobile journeys, or
authorize edits. A revoked grant keeps the history readable and blocks new
messages until the host grants it again. The independent Lark checkpoint below
qualifies its source implementation separately.

The source implementation also has an explicit owner-only Lark private-message
binding. Settings → Lark selects an independently verified non-default App,
a current host workspace and executor. Core owns the App-scoped owner/source
identity, Session context and revocation; the provider stores no separate
conversation authority. One machine/App lease covers both profile aliases and
existing group listeners. Replies recheck the original source under that App;
they do not require group-member scopes for private messages.

Text admission persists the canonical Core Turn without waiting for its terminal
answer. The existing bounded Session queue keeps follow-ups in FIFO order; another
App can continue independently. Explicit `/status`, `/stop` and `/new` use the
shared request owner, and a replay retains the original Session/Turn target.
A lost admission correlation cannot move a request to a newer Session. Admission
feedback and final delivery use separate durable provider intents; an ambiguous
write is read back without blind resend. Unsupported media receives an explicit
notice. This grant remains workspace reading, without a Goal, portfolio or peer
execution authority.

The packaged settings journey, source revocation/recovery, duplicate events,
native queue/stop and two-App isolation have synthetic-provider regression and
browser coverage. A native Codex source canary separately qualifies distinct
upstream threads with independently observed real App identities. Neither is a
live Lark/model/mobile result. Installed service qualification, phone journeys,
registered Agent selection, real incremental/media/permission interactions and
a separately granted long-running steward remain open acceptance. Runtime and
permission-boundary changes require maintainer review before promotion.

The private setup UI composes within Settings → Lark; the App workspace scope
remains the sole ordinary local conversation entry. Legacy group profiles without
a stored App identity retain their original profile-hash consumer lease. Requests
without private bindings do not add provider authentication; configured aliases
reuse one request-scoped verified identity observation. Lark HTTP composition
resides in the extension, while the typed binding owner remains provider-neutral.

Synthetic product previews: [desktop](../../assets/personal-workspace/private-project-conversations.png),
[narrow](../../assets/personal-workspace/private-project-conversations-narrow.png),
[revoked workspace](../../assets/personal-workspace/private-project-workspace-revoked.png).

## Decision: make the App the place where work conversations continue

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,33 @@ grant 授权 peer delegation。
store,TypeScript 负责上下文身份及范围。原生 Codex 恢复保留原 upstream thread
和工作区;HTTP/协议 fixture 验证连续性与拒绝行为,不证明真实模型采用了上下文。

首个 grant 仅面向本机 owner 的工作区读取。Lark 受众授权、普通私聊选择、独立于
terminal delivery 的 durable 入站 admission,以及安装/手机验收仍是本 RFC 的未完成项。
App 范围入口不代表这些旅程已通过,也不授权修改文件;grant 撤销后历史仍可读,
新消息在宿主重新授权前被阻止。
App grant 仅面向本机 owner 的工作区读取。App 范围入口本身不证明 Lark 私聊
admission、安装或手机旅程已通过,也不授权修改文件;grant 撤销后历史仍可读,
新消息在宿主重新授权前被阻止。下方独立检查点说明 Lark 的源码实现资格。

本机 owner 的只读工作区入口现已接入独立核验的 Lark App 私聊绑定。既有设置 →
Lark 页面选择一个非默认 App、当前可用工作区和宿主 executor,读回监听状态、待回复
数量及恢复缺口;重新绑定不会把旧 Session 移到其它工作区。

共享 typed Core 核验 App、本人、受众和当前工作区 grant,拥有稳定 Session、原生
Turn FIFO 和确切 stop/new/status 目标。传输层保存独立的受理与最终投递意图,通过
provider 读回确认回复;发生没有 receipt 的不确定写入时不盲目重发。两 App 复用
现有服务及按 App 获取的 listener lease,不创建第二套队列、模型 runner 或隐藏 Goal。

源码 canary 已验证打包页面的桌面/窄屏、撤权拒绝与恢复、慢会话期间另一 App 完成、
后续消息持久排队、exact stop,以及重启后原会话恢复和已确认回复不重复发送。
这些是合成 provider/协议验收;真实原生 Codex 另行验证独立线程与上下文隔离。
真实 Lark 收发、安装候选、手机旅程、注册 Agent 选择、媒体/增量/权限回调,以及
新管家的明确长期委托仍未验收。当前私聊绑定仅支持普通只读项目会话。

私聊配置复用设置 → Lark;App 范围仍是本机普通对话的唯一入口。未存储 App 身份
的旧群聊 profile 保留原 profile-hash 监听锁键。没有私聊绑定时不增加鉴权;有绑定
时,同一请求内的别名检查与连接共用一次已验证身份观测。飞书 HTTP 组合位于
extension,typed binding owner 继续保持 provider-neutral。

![合成私聊工作区设置](../../assets/personal-workspace/private-project-conversations.png)
![窄屏私聊设置](../../assets/personal-workspace/private-project-conversations-narrow.png)
![工作区撤权读回](../../assets/personal-workspace/private-project-workspace-revoked.png)

## 决策:让 App 成为工作会话持续进行的地方

Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading