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
3 changes: 2 additions & 1 deletion bin/editor-csp.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ export function secureEditorHtml(html) {
.map((match) => `'sha256-${createHash('sha256').update(match[2]).digest('base64')}'`);
const policy = [
"default-src 'self'",
`script-src 'self' 'wasm-unsafe-eval' ${[...new Set(hashes)].join(' ')}`,
// Cloudflare Pages injects its analytics beacon into this HTML at the edge.
`script-src 'self' 'wasm-unsafe-eval' https://static.cloudflareinsights.com ${[...new Set(hashes)].join(' ')}`,
"script-src-attr 'none'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob:",
Expand Down
93 changes: 93 additions & 0 deletions docs/plans/2026-10-10-assistant-im-redesign.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# AI 助手 IM 整体改版设计与范围锁

状态:用户已确认,已实施;验证边界见第 6 节。

## 1. 目标与已验证依据

目标:一个以自然语言输入为中心、适配 Word / Excel / PPT 的助手侧栏。视觉遵循 ranui,精简重复入口,并把可访问性落实到实际操作。

- 用户要求:整体设计、高级感、删减重复功能、a11y;此前要求取消扩大按钮、减少手动选区检测与任务切换。
- 规范依据:`node_modules/ranui/docs/DESIGN.md` 的语义色、4px 间距体系、字体角色、主题、可见焦点及键盘规范;根目录与 `packages/chat-ui/CLAUDE.md` 的组件和职责边界。
- 代码依据:`panel.ts` 同时负责表单、手动任务选择、上下文引用和请求路由;`styles/assistant.css` 多轮覆盖;共享 `chat-ui/styles.ts` 含固定颜色,且宿主依赖组件内部类名覆盖样式。
- 代码依据:`document-context.ts` 已区分 word/cell/slide,读取范围、工作表或页码;它并不等于读取了所有编辑器的选中内容。
- 代码依据:`panel.ts` 引用按钮依赖通用 GetSelectedText,并在无选区时添加错误消息;提交依赖隐藏任务选择值。聊天 API 服务路径关闭工具,不能假定所有服务已支持编辑操作。
- 代码依据:`panel-resize.ts` 已支持 Pointer Events 和键盘方向键,保留此能力;目前名称未本地化。
- 未验证:完整 PPT 文本替换、多单元格写作、所有远端服务的工具能力。本轮不会以视觉调整宣称已补齐这些能力。

## 2. 最终交互与视觉设计

### 对话主界面

顶部保留“AI 助手”、新对话、历史、设置、关闭。取消扩大按钮。图标统一尺寸、按钮命中区域至少 32px,标题与操作留出明确间距。

空态采用安静的留白与 20px 标题:**“从一个想法开始”**。次级说明根据编辑器变化:Word“提问、润色文字,或调整这份文档”;Excel“分析数据、解释公式,或整理这张表”;PPT“梳理内容、调整表达,或整理演示结构”。最多三个自然语言示例,点击填入可编辑的草稿,不切换工作模式;有消息后消失。设置入口统一放在顶部;仅首次未配置时出现一条“连接模型后即可开始”的行动提示。

输入框成为视觉重心:柔和表面、单层细边框、12px 圆角,克制的焦点环。保留输入、发送/停止;移除引用检测按钮、四项模式菜单和活动任务条。上下文以一条可展开的轻量标签显示,例如“选中文字 · 128 字”“工作表1 · B2:D12”“第3页 · 选中文本”。不选中内容时不报错;实际任务需要选区时,在对话内提出简短补充问题。

自动化有明确边界:发送时重新确认编辑器和目标;明确的改写/总结/翻译请求进入已有对应流程,模糊请求按普通对话处理或询问。普通提问无需选区。不会从一个单元格推断整列,也不会默认上传整个工作簿或整套演示。内容生成和写入保持分离,修改预览继续显示目标、建议结果以及“应用修改 / 放弃修改”。

消息采用助手无气泡、用户浅色气泡;阅读宽度、Markdown、代码、长表格统一处理。工具过程收起为简短状态,失败提供原因与下一步,取消使用中性状态。流式输出不逐 token 打断读屏。

### 模型与服务

根据最新反馈,设置页进一步缩减:标题改为 **“模型”**,删除介绍段落、大图标和大卡片。顶部以 ranui 控件切换 **“本机 / AI 服务”**,只呈现当前路径的字段。默认内容是一行模型选择、资源说明和加载按钮;加载进度替换同一区域。

本机区域提供 **“已下载模型(N)”** 次级入口,展开紧凑列表:模型名、缓存状态、可测得的存储大小、当前使用状态;操作为“使用”“释放内存”“删除下载文件”。缓存可保留多个,本面板同时只运行一个。切换前释放旧实例;释放内存保留下载文件;删除需行内确认,并说明再次使用需要下载。删除当前模型须先停止任务和卸载;卸载失败不能显示删除完成。按模型删除,不清空整个站点缓存,不触碰用户导入的原始文件或外部服务的模型。

缓存占用和预计运行内存分别命名,不把显存预估当成下载大小。共享资源不能随单个模型无差别清理。权限与高级运行配置收进“更多设置”。服务字段按类型出现,有独立标签,密钥不回显到对话。

高级选项折叠为一个区域,保留必要的运行方式、GGUF、自定义模型、生成修改的处理位置;不重复展示每条消息的任务模型选择。修订模式只在支持的 Word 编辑器出现。历史维护与清理放到历史视图,不混入模型表单。保留历史数据与现有保存配置。

### 可访问性与状态

- 侧栏、输入、会话日志、表单字段均有可访问名称;说明和错误与字段关联。
- Tab 顺序跟随视觉布局;菜单 Escape 关闭并恢复触发器焦点;设置返回和面板关闭恢复合理焦点。
- 所有操作都有可见 focus-visible;不以颜色单独表达状态;禁用原因可以读取。
- Enter 发送、Shift+Enter 换行,中文输入法组合期间不提交;运行时停止按钮可操作。
- 消息日志与集中状态播报分工,完成、失败、取消仅在状态变化时播报。
- 适配 320px / 432px / 宽侧栏、深浅主题、长中文与英文、200% 缩放及 reduced-motion。

## 3. 文件级范围锁

允许修改:

- `lib/agent-plugin/ui/panel.ts`:精简入口、装配新界面、统一状态和路由。
- `lib/agent-plugin/ui/composer-context.ts`(新):编辑器感知的上下文读取与展示,显式处理能力缺失。
- `lib/agent-plugin/ui/request-intent.ts`(新):保守的任务识别,复用现有写作和文档操作流程。
- `lib/agent-plugin/ui/action-preview.ts`、`history-controls.ts`、`panel-resize.ts`:预览、历史、焦点、键盘和本地化。
- `styles/assistant.css`:重整为单一结构,删除失效或重复规则,使用 ranui tokens。
- `packages/chat-ui/src/{chat-view,styles,types}.ts`:组件拥有内部布局,提供必要的公开样式和上下文槽契约,完善通用 a11y。
- `packages/shared/src/i18n/types.ts` 与七个 locale 文件:新增状态及表单文案;各语言保持键完整。
- 上述模块对应的 `test/unit` 测试,以及 `docs/plans/2026-10-10-assistant-im-redesign.md`。

不修改:模型默认值、下载及推理引擎、模型验收结论、底层编辑 SDK、工具执行安全边界、存储格式和发布流程。不添加 UI 框架或新的设计系统,不修改 node_modules,不提交设备路径、个人用户名、密钥或截图临时路径。

兼容性红线:保留草稿、历史、停止及过期任务保护;不扩大文档写入范围;现有远端聊天与本机写作权限继续有效。新共享组件契约保持已有调用兼容。遇到编辑器 API 能力与审计不同,先说明再调整范围。

## 4. 实施与验收

新增范围:允许 `packages/agent-core/src/llm/model-cache.ts`(新)、`webllm.ts`、`wllama-cache-storage.ts` 及包导出文件提供缓存查询与定向删除。此范围仅管理缓存,不改变推理引擎或默认模型。WebLLM SDK 已确认导出模型级删除 API;GGUF 的 OPFS / Cache API 缓存归属和大小查询需先验证,再决定可展示的管理操作。

新增验收:多个已缓存模型可识别、切换、分别删除;当前运行模型停止与卸载后才删除;其他模型、会话和站点数据保留;删除失败可重试,不能假报完成;存储大小不可测时不显示估算值。

顺序:共享聊天组件主题与 a11y → 宿主布局和模型表单 → 编辑器上下文与保守任务识别 → 修改预览与历史 → 全面浏览器验收。仅对路由、目标范围、焦点和状态等行为添加有意义的测试。

自动检查:`pnpm run tsc`、`pnpm run lint:ts`,运行 chat-view、styles-contract、agent-panel、agent-panel-loading、agent-action-preview、agent-history-controls、panel-resize、agent-document-context、agent-direct-intent 和新增路由测试。

浏览器验收:实际操作 Word / Excel / PPT 的空态、选区、无选区请求、配置、发送/停止、错误恢复、历史、修改确认;检查深浅色、窄宽布局及仅键盘操作;验证可访问名称、字段关联和焦点归位。对于不能在现有服务环境实测的模型响应或远端能力,明确保留限制。

完成标准:对话主界面无手动模式/选区检测入口,设置无重复入口;所有改变可见状态都已检查;确认写入仍有目标和审阅;检查通过并提供实际界面截图。

## 5. 最简方案复核

只换颜色和圆角无法修复重复模式、表单层级和上下文错误,排除。仅隐藏菜单会使写作流程不可达,排除。无需新增模型驱动分类器;先用保守识别复用现有能力,无法明确判断时询问。UI 使用现有 ranui 和 chat-ui,组件内部样式由组件维护,宿主只装配编辑器相关内容。

## 6. 实施结果与验证边界

已落地自然请求分类、发送时上下文快照、Word / Excel / PPT 适配、修改预览、精简模型设置和按模型缓存管理。自动分类覆盖中文和英文常见指令;模糊请求保持对话。Excel 多单元格写作返回可复制结果,不自动覆盖范围。

13 个测试文件、219 项测试通过,类型检查通过。审查修正了翻译目标语言、相对模型地址保留问题,并标记未完成的分片下载。

浏览器实际验证 Excel B2:C3 求和、PPT 添加幻灯片、中文空态和设置、来源选择键盘操作、320px 侧栏无水平溢出。跨编辑器改写预览和删除前卸载由单元测试覆盖。未下载新模型或删除用户缓存;真实推理质量、全部远端服务、屏幕阅读器、深色主题及 200% 缩放尚未实测。部分宿主样式仍依赖既有聊天组件类名。

最新精简:删除设置与历史中的返回按钮;删除空态写作快捷入口。顶部图标和 Escape 保留视图切换,空态说明随编辑器变化。
17 changes: 17 additions & 0 deletions docs/plans/2026-10-10-assistant-speech-input.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# AI 助手语音输入

用户确认轻量听写设计后实施。输入框麦克风打开紧凑浮层:首次开始前说明音频可能由浏览器在线服务处理,选择识别语言并开始;再次点击麦克风停止。结果写入可编辑草稿,不自动发送或执行文档修改。

采用 SpeechRecognition / webkitSpeechRecognition 能力检测,提供中、英、日、韩、德、西、葡语言标签;默认跟随界面语言。浏览器对语言和离线识别的支持不同,不承诺自动混合语言识别或完全本地处理。

录音期间禁止发送,停止等待最终结果。取消恢复原草稿;手动编辑立即终止识别并保留可见文字和光标。切换会话、界面语言、视图、关闭面板及 pagehide 清理识别,过期回调不可写入新草稿。单次识别 60 秒后清理,避免服务未回调时持续等待。状态、权限失败和不支持提示均本地化;按钮支持键盘和 Escape,状态通过 role=status 播报。

使用现有 ranui 语言选择器和公开 chat-ui actionsEl / setInput 契约,不新增录音上传接口、音频存储、语音播放或推理依赖。权限只在用户点击开始时由浏览器请求。

验证:回归测试覆盖语言映射、临时与最终结果、取消、停止、权限错误、会话结束的迟到回调及手动编辑保护。浏览器验证入口、语言选择、说明、取消与窄侧栏;未启动真实麦克风,识别准确率和浏览器服务可用性未实测。

参考:

- https://help.openai.com/en/articles/12168547-voice-dictation-faq
- https://support.google.com/gemini/answer/14554984
- https://developer.mozilla.org/en-US/docs/Web/API/SpeechRecognition
27 changes: 21 additions & 6 deletions lib/agent-plugin/ui/action-preview.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,30 @@
import { Div, Span, View } from 'ranui/builder';
import { t } from '@ranuts/shared/i18n';
import { displayError } from './presentation';
import { ReviewedAction } from '../reviewed-action';
interface PreviewAction {
target: { label: string; selectedText: string };
plan: { tool: string; input: Readonly<Record<string, unknown>> };
isCurrent(): boolean;
cancel(): void;
apply(signal?: AbortSignal): Promise<'sent' | 'verified'>;
}

/** Transient, text-only preview. Proposed actions are never persisted/replayed. */
export class ActionPreview {
readonly el = Div().class('agent-plan-preview').attr('aria-live', 'polite').build();
readonly el = Div().class('agent-plan-preview').attr('role', 'region').build();
private readonly title = Span().build();
private readonly target = Span().class('agent-plan-target').build();
private readonly beforeLabel = Span().build();
private readonly before = View('pre').class('agent-plan-before').build();
private readonly afterLabel = Span().build();
private readonly content = View('pre').class('agent-plan-content').build();
private readonly status = Span().class('agent-plan-status').build();
private readonly status = Span().class('agent-plan-status').attr('role', 'status').build();
private readonly applyButton = View('r-button').class('agent-plan-apply').build();
private readonly copyButton = View('r-button').class('agent-plan-copy').build();
private readonly cancelButton = View('r-button').class('agent-plan-cancel').build();
private action: ReviewedAction | null = null;
private action: PreviewAction | null = null;
private settled = false;
private returnFocus?: HTMLElement;
private abort?: AbortController;
private onApplied?: (outcome: 'sent' | 'verified') => void;
private timer?: ReturnType<typeof setInterval>;
Expand Down Expand Up @@ -46,6 +53,7 @@ export class ActionPreview {
}
private labels(): void {
this.title.textContent = t('agentPlanTitle');
this.el.setAttribute('aria-label', t('agentPlanTitle'));
this.beforeLabel.textContent = t('agentPlanBefore');
this.afterLabel.textContent = t('agentPlanAfter');
this.applyButton.textContent = t('agentPlanApply');
Expand All @@ -59,17 +67,22 @@ export class ActionPreview {
: t(action.target.selectedText ? 'agentPlanSelection' : 'agentPlanCursor')
}`;
}
show(action: ReviewedAction, onApplied?: (outcome: 'sent' | 'verified') => void): void {
show(action: PreviewAction, onApplied?: (outcome: 'sent' | 'verified') => void): void {
this.hide();
this.action = action;
this.returnFocus = document.activeElement instanceof HTMLElement ? document.activeElement : undefined;
this.abort = new AbortController();
this.onApplied = onApplied;
this.settled = false;
this.el.hidden = false;
this.before.textContent = action.target.selectedText;
this.before.hidden = this.beforeLabel.hidden = !action.target.selectedText;
this.before.parentElement!.hidden = !action.target.selectedText;
this.content.textContent = action.plan.tool === 'set_cell' ? action.plan.input.value : action.plan.input.text;
this.content.textContent = String(
action.plan.tool === 'set_cell'
? action.plan.input.value
: (action.plan.input.text ?? JSON.stringify(action.plan.input)),
);
this.status.textContent = t('agentPlanReady');
this.applyButton.removeAttribute('disabled');
this.labels();
Expand All @@ -88,11 +101,13 @@ export class ActionPreview {
this.status.textContent = t('agentPlanExpired');
}
hide(): void {
const restore = this.el.contains(document.activeElement);
this.abort?.abort();
this.action?.cancel();
this.action = null;
clearInterval(this.timer);
this.el.hidden = true;
if (restore && this.returnFocus?.isConnected) this.returnFocus.focus();
}
private async copy(): Promise<void> {
const action = this.action;
Expand Down
68 changes: 68 additions & 0 deletions lib/agent-plugin/ui/composer-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { captureDocumentContext, type DocumentContext } from '../document-context';
import { getEditorApi } from '../editor-bridge';
import { readSlideTextSelection, type SlideTextShape } from '../slide-text-read';
import { parseOfficeRange } from '../office-tools';
export interface ComposerContext {
context: DocumentContext | null;
text: string;
truncated: boolean;
}
/** Read only the current selection. Bound range reads before visiting SDK cells. */
export function captureComposerContext(): ComposerContext {
const context = captureDocumentContext();
const api = getEditorApi();
let text = '';
let truncated = false;
try {
if (context?.kind === 'word') text = api?.pluginMethod_GetSelectedText({ TabSymbol: '\t', Numbering: false }) ?? '';
else if (context?.kind === 'slide') {
const logic = (
api?.WordControl as
| {
m_oLogicDocument?: { GetCurrentController?(): { getTargetDocContent?: SlideTextShape['getDocContent'] } };
}
| undefined
)?.m_oLogicDocument;
const content = logic?.GetCurrentController?.()?.getTargetDocContent?.();
text = readSlideTextSelection({ getDocContent: () => content })?.selectedText ?? '';
} else if (context?.kind === 'cell' && context.range) {
if (
!context.sheet &&
typeof api?.asc_getWorksheetName === 'function' &&
typeof api?.asc_getActiveWorksheetIndex === 'function'
) {
const name = (api.asc_getWorksheetName as (index: number) => string)(
(api.asc_getActiveWorksheetIndex as () => number)(),
);
if (typeof name === 'string') context.sheet = name.slice(0, 80);
}
const range = parseOfficeRange(context.range);
const count = (range.r2 - range.r1 + 1) * (range.c2 - range.c1 + 1);
if (count > 200) return { context, text: '', truncated: true };
const model = (
api as unknown as {
wb?: {
getWorksheet(): {
model: { getRange3(r1: number, c1: number, r2: number, c2: number): { getValue(): string } };
};
};
}
)?.wb?.getWorksheet().model;
if (model) {
const rows: string[] = [];
for (let row = range.r1; row <= range.r2; row++) {
const values: string[] = [];
for (let col = range.c1; col <= range.c2; col++) values.push(model.getRange3(row, col, row, col).getValue());
rows.push(values.join('\t'));
}
text = rows.join('\n');
}
}
} catch {
if (context?.kind === 'cell') truncated = true;
text = '';
}
text = text.replace(/\r\n/g, '\n');
truncated ||= text.length > 8000;
return { context, text: text.slice(0, 8000), truncated };
}
Loading
Loading