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
33 changes: 25 additions & 8 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,36 @@
## [Unreleased]

### 修复
- 知识库预览旧版 Word(.doc)改为抽取文本;工作区、对话、技能和存储浏览改为下载,不再交给 docx 渲染器(#2155)
- 知识库上传进度只显示在正在接收文件的那个知识库

- `/compact` 压缩对话后上下文环形进度仍停留在压缩前的百分比:改为优先采用较新的服务端快照,并在回合结束时重新拉取

## [1.0.2] - 2026-10-18

### 新增
- 定时任务支持一次性执行:到点跑完(含失败)后自动停用并留在列表中;停机超过 24 小时未跑到则记为错过
- 个性化页的技能、子智能体、工具、插件、MBTI、记忆可以按权限单独开关
- 两台 Octop 可以连起来用:通过云端协同使用对方的专家,能聊天,也能改对方工作区里的文件
- 专家可以组队。主持人负责分派任务和收尾总结,成员的回复会回到聊天,也会同步到即时通讯
- 新连接器:企查查、OpenAlex、语雀、Obsidian、Agent Mail,以及钉钉命令行(设备码登录后可查通讯录、日程和文档)
- 可以用公司的 LDAP 账号登录。Telegram 机器人可以限定哪些用户能跟它说话
- 记忆可以手改「用户档案」和「强记忆」。和已有记忆打架时先请你确认,不会悄悄覆盖;也可以备份和恢复
- 技能和技能包可以导出成一个 ZIP,换一台机器再导入。技能包可以关掉别人的打包下载
- 定时任务可以只跑一次,跑完自动停用,仍留在列表里。聊天进行中可以再补一句,不用等这一轮结束
- 每个专家可以设定默认对话方式:问答、规划或动手。侧栏可以按自己的账号重新排列
- 语音支持腾讯云边说边出字。当前模型不可用时,可以按健康状态自动换一个
- 知识库旁边可以按文件夹浏览文档。新增业务流程、知识工作等专家模板。内置助手可以回答产品和用法问题
- 别的程序可以通过 Agent MCP 网关调用这个 Agent。ONNX 模型可以不连 Hugging Face 直接加载

### 修复
- Edge TTS 语速换算改成四舍五入:1.4 倍速请求 `+40%`(此前写成 `+39%`),0.8 倍速 `-20%`(此前 `-19%`)
- 中文 Windows 上,搜索或执行命令碰到编码问题时,不再把整轮对话判失败,也不再把命令输出弄丢
- 旧版 Word(.doc)在知识库里改为显示抽出的文字,在工作区、对话和技能里改为下载
- 云端团队刷新页面后,正在生成的成员回复还在。对方拒绝登录时,不会把你自己的账号退出
- 删掉会话会一并清掉它留下的数据。远程存储卡住时,不再挡住整个服务启动
- 邮件标题里没有按规范编码的中文,可以正常读出来

### 变更
- 轮转日志文件名改为 `octop.YYYY-MM-DD.log`;启动时把旧的 `octop.log.YYYY-MM-DD` 改成新名字
- 定时任务卡片可以直接禁用或启用;聊天侧栏的历史按钮可以收起,也可以展开
- 技能选择器把用户安装的技能排在内置技能前面
- 聊天侧栏分成专家、团队、云端三块;还没有团队或云端连接时,可以直接去创建
- 个性化里的技能、子智能体、工具、插件、性格和记忆可以分别授权,不必一次全开
- 输入栏不常用的入口收进「+」。没有开启审批时,不再出现批准按钮
- 飞牛提供 ARM 安装包,安装后走建账号向导

## [1.0.2b9] - 2026-10-16

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
<p align="center">
<a href="https://www.python.org/downloads/"><img alt="Python 3.12+" src="https://img.shields.io/badge/python-3.12%2B-blue?logo=python&logoColor=white" /></a>
<a href="https://github.com/TencentCloud/Octop/blob/main/LICENSE"><img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-green" /></a>
<a href="https://github.com/TencentCloud/Octop/releases"><img alt="Version" src="https://img.shields.io/badge/version-1.0.2b9-orange" /></a>
<a href="https://github.com/TencentCloud/Octop/releases"><img alt="Version" src="https://img.shields.io/badge/version-1.0.2-orange" /></a>
<a href="https://pypi.org/project/octop/"><img src="https://img.shields.io/pypi/v/octop" alt="PyPI" /></a>
<a href="https://github.com/astral-sh/ruff"><img alt="Code Style: Ruff" src="https://img.shields.io/badge/code%20style-ruff-000000?logo=ruff&logoColor=white" /></a>
<a href="https://github.com/TencentCloud/Octop"><img alt="GitHub stars" src="https://img.shields.io/github/stars/TencentCloud/Octop?style=social" /></a>
Expand Down
2 changes: 1 addition & 1 deletion README_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
<p align="center">
<a href="https://www.python.org/downloads/"><img alt="Python 3.12+" src="https://img.shields.io/badge/python-3.12%2B-blue?logo=python&logoColor=white" /></a>
<a href="https://github.com/TencentCloud/Octop/blob/main/LICENSE"><img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-green" /></a>
<a href="https://github.com/TencentCloud/Octop/releases"><img alt="Version" src="https://img.shields.io/badge/version-1.0.2b9-orange" /></a>
<a href="https://github.com/TencentCloud/Octop/releases"><img alt="Version" src="https://img.shields.io/badge/version-1.0.2-orange" /></a>
<a href="https://pypi.org/project/octop/"><img src="https://img.shields.io/pypi/v/octop" alt="PyPI" /></a>
<a href="https://github.com/astral-sh/ruff"><img alt="Code Style: Ruff" src="https://img.shields.io/badge/code%20style-ruff-000000?logo=ruff&logoColor=white" /></a>
<a href="https://github.com/TencentCloud/Octop"><img alt="GitHub stars" src="https://img.shields.io/github/stars/TencentCloud/Octop?style=social" /></a>
Expand Down
7 changes: 5 additions & 2 deletions dashboard/src/api/modules/settings.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { request } from "../request";
import { bridgeAgentHeaders, request } from "../request";

export interface OctopTimezoneSettings {
timezone: string;
Expand Down Expand Up @@ -49,7 +49,10 @@ export interface CaptchaSettingsPut {
export const octopSettingsApi = {
timezone: () => request<OctopTimezoneSettings>("/settings/timezone"),
upload: () => request<OctopUploadSettings>("/settings/upload"),
hitl: () => request<OctopHitlSettings>("/settings/hitl"),
hitl: (agentId?: string | null) =>
request<OctopHitlSettings>("/settings/hitl", {
headers: bridgeAgentHeaders(agentId),
}),
capabilities: () =>
request<OctopCapabilitiesSettings>("/settings/capabilities", {
cache: "no-store",
Expand Down
24 changes: 24 additions & 0 deletions dashboard/src/api/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,9 @@ function buildAuthHeaders(path: string): Record<string, string> {

let _redirectingToLogin = false;

/** 401 codes that mean this Octop session is no longer valid. */
const SESSION_UNAUTHORIZED_CODES = new Set(["AUTH_FAILED", "TOKEN_EXPIRED"]);

/**
* Send the user to the login screen exactly once, no matter how many parallel
* requests report an expired session.
Expand Down Expand Up @@ -330,13 +333,34 @@ function handleUnauthorized(): void {
* Handle 401 responses: clear token, redirect to login, and throw.
* Shared by request(), requestBlob(), and requestUpload().
*/
async function errorCodeOf(response: Response): Promise<string | undefined> {
try {
const body = (await response.clone().json()) as {
error?: { code?: string };
};
const code = body?.error?.code;
return typeof code === "string" && code.trim() ? code : undefined;
} catch {
return undefined;
}
}

async function throwIfUnauthorized(
path: string,
response: Response,
): Promise<void> {
if (response.status !== 401 || path.startsWith("/auth/")) {
return;
}
const code = await errorCodeOf(response);
// Business 401s (peer login, etc.) are not a dead local session.
if (
code &&
!SESSION_UNAUTHORIZED_CODES.has(code) &&
!path.startsWith("/setup/")
) {
return;
}
clearAuthToken();
handleUnauthorized();
let message = "Unauthorized";
Expand Down
30 changes: 30 additions & 0 deletions dashboard/src/api/request.unauthorized.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,36 @@ describe("401 handling", () => {
expect(replace).toHaveBeenCalledWith("/login");
});

it("keeps the local session when a 401 is a peer credential error", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() =>
Promise.resolve(
new Response(
JSON.stringify({
error: {
code: "BRIDGE_AUTH_FAILED",
message: "peer rejected credentials",
details: {},
},
}),
{
status: 401,
headers: { "content-type": "application/json" },
},
),
),
),
);

await expect(
mod.request("/bridge/probe", { method: "POST" }),
).rejects.toThrow(/BRIDGE_AUTH_FAILED/);

expect(replace).not.toHaveBeenCalled();
expect(mod.getAuthToken()).toBe("expired-token");
});

it("redirects only once when several requests fail concurrently", async () => {
const listener = vi.fn((event: Event) => event.preventDefault());
window.addEventListener(mod.UNAUTHORIZED_EVENT, listener);
Expand Down
5 changes: 4 additions & 1 deletion dashboard/src/components/CopyableResourceId.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ type CopyableResourceIdProps = {
className?: string;
/** Match surrounding meta text (e.g. creator line). */
inline?: boolean;
/** Inline: show only the value, without the `label:` prefix. */
valueOnly?: boolean;
};

export function CopyableResourceId({
Expand All @@ -22,6 +24,7 @@ export function CopyableResourceId({
copyTitle,
className,
inline = false,
valueOnly = false,
}: CopyableResourceIdProps) {
const { t } = useTranslation();

Expand All @@ -41,7 +44,7 @@ export function CopyableResourceId({
onClick={() => void copy()}
>
<span className={inline ? styles.inlineText : styles.label}>
{inline ? `${label}: ${value}` : label}
{inline ? (valueOnly ? value : `${label}: ${value}`) : label}
</span>
{!inline ? <span className={styles.value}>{value}</span> : null}
<Copy
Expand Down
59 changes: 30 additions & 29 deletions dashboard/src/components/RailEdgeControl.module.less
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,6 @@
pointer-events: auto;

&:hover {
.btn {
opacity: 1;
transform: translateY(-50%) scale(1);
}

.line {
background: var(--fn-color-brand);
opacity: 0.55;
Expand Down Expand Up @@ -53,50 +48,56 @@
.btn {
position: absolute;
top: 50%;
left: 50%;
z-index: 1;
display: flex;
display: inline-flex;
align-items: center;
justify-content: center;
width: 14px;
height: 36px;
border: none;
border-radius: 0;
background: transparent;
color: var(--fn-text-tertiary);
box-shadow: none;
width: 18px;
height: 44px;
padding: 0;
border: 1px solid var(--fn-border-secondary);
border-radius: 8px;
background: var(--fn-bg-elevated, var(--fn-bg-secondary));
color: var(--fn-text-secondary);
box-shadow: 0 1px 4px
color-mix(in srgb, var(--fn-text-primary) 8%, transparent);
cursor: pointer;
transform: translate(-50%, -50%);
opacity: 0;
transform: translateY(-50%) scale(0.96);
pointer-events: none;
transition:
opacity 0.15s ease,
transform 0.15s ease,
color 0.15s ease;
padding: 0;
color 0.15s ease,
border-color 0.15s ease,
background 0.15s ease,
box-shadow 0.15s ease;

&:hover {
border-color: var(--fn-border-primary);
background: var(--fn-bg-secondary);
color: var(--fn-text-primary);
box-shadow: 0 2px 8px
color-mix(in srgb, var(--fn-text-primary) 12%, transparent);
}

&:focus-visible {
outline: 2px solid var(--fn-color-brand);
outline-offset: 1px;
opacity: 1;
}
}

.chevron {
display: block;
flex-shrink: 0;
.edge:hover .btn,
.btn:focus-visible {
opacity: 1;
pointer-events: auto;
}

/* Expanded: collapse button sits to the left of the vertical line */
.edgeExpanded .btn {
right: calc(50% + 1px);
left: auto;
/* Two pills on one shared divider: keep a gap so they do not overlap. */
.btnBefore {
transform: translate(calc(-100% - 3px), -50%);
}

/* Collapsed: expand button sits to the right of the vertical line */
.edgeCollapsed .btn {
left: calc(50% + 1px);
right: auto;
.btnAfter {
transform: translate(3px, -50%);
}
63 changes: 29 additions & 34 deletions dashboard/src/components/RailEdgeControl.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useTranslation } from "react-i18next";
import styles from "./RailEdgeControl.module.less";

Expand All @@ -18,36 +19,16 @@ interface RailEdgeControlProps {
* (used when this edge shares a line with a neighboring rail).
*/
showLine?: boolean;
}

/** Tall, wide-angle chevron (no circular chrome). */
function WideChevron({ dir }: { dir: "left" | "right" }) {
return (
<svg
className={styles.chevron}
viewBox="0 0 10 22"
width="10"
height="22"
aria-hidden
>
<path
d={
dir === "left"
? "M7.5 2.5 L2.5 11 L7.5 19.5"
: "M2.5 2.5 L7.5 11 L2.5 19.5"
}
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
/**
* ``center`` straddles the divider (folder-tree toggle).
* ``before`` / ``after`` sit on either side when two rails share one line.
*/
placement?: "center" | "before" | "after";
}

/**
* Hover-revealed collapse/expand control anchored to a vertical rail line.
* Collapse/expand control anchored to a vertical rail line.
* Matches the knowledge-base folder tree edge button.
*/
export default function RailEdgeControl({
expanded,
Expand All @@ -56,6 +37,7 @@ export default function RailEdgeControl({
label,
className,
showLine = true,
placement = "center",
}: RailEdgeControlProps) {
const { t } = useTranslation();
const ariaLabel =
Expand All @@ -66,21 +48,34 @@ export default function RailEdgeControl({

return (
<div
className={`${styles.edge} ${
side === "start" ? styles.edgeStart : styles.edgeEnd
} ${expanded ? styles.edgeExpanded : styles.edgeCollapsed} ${
className ?? ""
}`}
className={[
styles.edge,
side === "start" ? styles.edgeStart : styles.edgeEnd,
expanded ? null : styles.edgeCollapsed,
className,
]
.filter(Boolean)
.join(" ")}
>
{showLine ? <div className={styles.line} aria-hidden /> : null}
<button
type="button"
className={styles.btn}
className={`${styles.btn} ${
placement === "before"
? styles.btnBefore
: placement === "after"
? styles.btnAfter
: ""
}`}
onClick={onToggle}
aria-label={ariaLabel}
title={ariaLabel}
>
<WideChevron dir={expanded ? "left" : "right"} />
{expanded ? (
<ChevronLeft size={14} strokeWidth={2} aria-hidden />
) : (
<ChevronRight size={14} strokeWidth={2} aria-hidden />
)}
</button>
</div>
);
Expand Down
Loading
Loading